Dabbsson DBS2000L / DBS2300 — full Tuya-based local + cloud integration findings

Dabbsson DBS2000L and DBS2300 — full local + cloud control findings (Tuya-based)

This is a writeup of what I've learned while building a local dashboard for two Dabbsson power stations (DBS2000L and DBS2300). Both are Tuya-based devices under the hood, but they behave very differently when you try to control them outside the Dabbsson app. Hopefully saves someone else a few hours.

TL;DR

Unit Local API Cloud API Recommended path
DBS2000L (newer) :white_check_mark: v3.5 protocol, full read+write works Pure local with cloud fallback for one rotating DP
DBS2300 (older) :cross_mark: rejects auth on every version works Cloud-only via Tuya's v2.0 properties endpoint

The big "aha" for me: Tuya's standard v1.0 getfunctions() API lies about which DPs are writable on the DBS2300. It reports only beep as writable. But using the v2.0 shadow/properties/issue endpoint, you can actually control AC output, USB, DC, charge target, LED, etc. — all the things the Tuya app uses.

Setup (high level)

  1. Factory reset the unit
  2. Pair to the Tuya Smart app (not Dabbsson app)
  3. Create a Tuya IoT Platform project, link your Tuya Smart account
  4. Use tinytuya wizard to extract device ID + local key
  5. For DBS2000L: communicate via local LAN at protocol v3.5
  6. For DBS2300: communicate via Tuya cloud REST API

Reverting to Dabbsson app requires another factory reset and re-pair.

DBS2000L (local control)

  • Local protocol: v3.5
  • Direct LAN, no internet needed (mostly — see DP 156 caveat below)
  • All read/write operations work

Readable DPs

DP Code Notes
1 battery_percentage always present
10 temp_current °C
25 beep bool
102 led_status enum: off, dim, bright, sos
109 ac_switch (AC output state) bool
111 dc12v_output_state bool — note: physically this controls BOTH DC and USB on the 2000L
112 usb_output_state PHANTOM DP — always reports a value but doesn't actually control anything on the 2000L. The 2000L only has one DC+USB combined hardware switch.
120 device_standby_time_set enum
121 lcd_off_time_set enum
122 ac_standby_time_set
123 custom_charge_power int (W) — settable
124 fast_or_slow_charge bool
127 device_mode enum
130 pd_verinfo firmware version string (PD subsystem)
132 bms_verinfo firmware version string (BMS)
133 inv_verinfo firmware version string (inverter)
145 output_vol_level 110 / 120
156 marge_value_dpid see DP 156 caveat
158 sale_log multi-line string with all power flow data — see DP 158 format
159 energy_in always 0 in my testing (firmware doesn't populate)
160 energy_out always 0 in my testing

DP 158 format (the only place real-time power data lives on the DBS2000L)

AC输入{V,A,W,Hz,enabled}
AC输出{V,A,W,parallel}
PV{V1,V2,A,W}        ← two voltage readings (PV1 and PV2)
INV电池端{V,A,W,setting}

Power flow you can derive from this:

  • AC input charging
  • AC output (the actual outlet draw — but NOT DC out, which the 2000L doesn't track separately)
  • Solar input
  • Battery (compute true W as |V × A|; the W field in this section is unreliable / often duplicates PV W)

DP 156 caveat — rotating buffer

DP 156 (marge_value_dpid) is a base64-encoded binary blob containing a rotating subset of other DPs. Format:

repeating: 1 byte DP id + 4 byte int32 big-endian

Different reads contain different DPs. The full set includes:

  • Per-cell battery voltages (DPs 77–~92, value in mV, LiFePO4 range 2500-3700)
  • DP 2 (remain_time in minutes — yes, the 2000L only exposes remain time inside this rotating buffer!)
  • Several other readings

Critical: the local protocol doesn't include DP 156 in routine status deltas — only the first full snapshot, and rarely after that. To get cell data and remain_time reliably, you need to either:

  • Poll the cloud /v2.0/cloud/thing/{id}/shadow/properties endpoint periodically just for this DP, or
  • Cache aggressively (10+ min TTL) and accept gaps

I went with the second approach + cloud fallback when local lacks the DP. Cells should be merged in cache (not replaced) so they accumulate to a full 16-cell readout over time.

What's NOT exposed on the DBS2000L

  • DC output wattage (DP 110 exists but is always 0)
  • USB per-port wattage
  • Energy in/out totals (DPs 159, 160 exist but always 0)
  • Detailed power breakdown per outlet

DBS2300 (cloud-only)

The DBS2300 actively rejects all Tuya local protocol versions (v3.1 through v3.5). Confirmed both reads and writes fail with auth errors. All control must go via cloud.

Why the v1.0 API is misleading

Calling cloud.getfunctions(device_id) returns ONLY beep as writable. But all of these actually work via cloud:

DP Code Type
25 beep writable
102 led_status enum: off, dim, bright, sos — writable
109 ac_output_state bool — writable
111 dc12v_output_state bool — writable
112 usb_output_state bool — writable
123 custom_charge_power int W — writable
124 fast_or_slow_charge bool — writable
144 app_heat bool — writable (cold weather battery preheat)
145 output_vol_level 110/120 — writable

The trick — use v2.0 endpoints

For reads:

GET /v2.0/cloud/thing/{device_id}/shadow/properties

Returns ALL DPs the device knows about, not the filtered schema list.

For writes:

POST /v2.0/cloud/thing/{device_id}/shadow/properties/issue
body: {"properties": "{\"code_name\": value}"}

This bypasses the v1.0 schema check and just sends the DP. Works for any DP the device firmware actually accepts.

DBS2300 readable DPs (selection)

DP Code Notes
1 battery_percentage
2 remain_time minutes
10 temp_current
103 display_mppt_input_power solar W
104 dispaly_acpower_input AC charging W
108 dispaly_ac_outpower AC outlet draw W
110 display_12v_outpower DC out W — works on the 2300, unlike the 2000L
113-118 display_usba1/2/3_outpower, display_usbc1/2/3_outpower per-port USB W (rounds to nearest W, so loads under ~1W show as 0)
127 device_mode
134 fault_type
136 bms_errorcode
137 bms_errorcode1 misnamed — actually a charge-state bitmap. See section below.
130/132/133 firmware version strings
140 serial_number

DP 156 (marge_value_dpid) on the 2300 contains a different rotating subset than the 2000L (more live power data, less cell info) — cells didn't show up in any of my samples on this unit.

Useful side discoveries

  • Per-cell battery voltages (DBS2000L only): a healthy 16S LiFePO4 pack should show cells within ~10-20 mV of each other. Worth monitoring for drift.
  • TLS encrypted cloud traffic: can't sniff and parse the DBS2300's cloud communication without firmware extraction. The device talks to mq.tuyaus.com over MQTT/TLS.
  • Tuya's free tier IoT Core: enough headroom for a polling dashboard (60 polls/min ≈ 86k/day, well under the daily quota).
  • OTA updates: Tuya Smart's "update available" prompt for the DBS2300 looped forever for me — firmware versions never actually changed across multiple "successful" update cycles. Dabbsson's issue, nothing fixable from the integrator side.

Caveats

  • Pairing with Tuya Smart breaks compatibility with the Dabbsson app until you factory reset again.
  • Local key changes every time you re-pair, so save it after each pairing.
  • DP IDs and meanings can vary between firmware revisions — verify on your specific unit.
  • See the dedicated bms_errorcode1 section below — it's not an error code at all, despite the name.

bms_errorcode1 (DP 137) is a charge-state machine, not an error register

This was the biggest surprise. The DP is named bms_errorcode1 so any naive integration treats it as an error code and fires alerts whenever it's non-zero. In practice it's a status bitmap tracking the BMS's charge-cycle state machine. I logged 6+ hours of state transitions while scripted-cycling AC/USB outputs and the pattern was very consistent.

Decoded bit meanings:

Bit Hex Meaning
64 0x40 Standby / ready (set during idle steady state)
128 0x80 Actively charging (any source — AC or PV)
256 0x100 Transition complete / cycle settled

Low bits (1, 2, 4, 8, 16, 32) were never set in any sample during normal operation. Those are presumably where real faults would show up.

Typical charge cycle sequence:

320 (64+256)      ← idle, ready, settled (default steady state)
  ↓ charge begins
64 only           ← "preparing to charge" transition
  ↓ ~6-10s
128 only          ← actively charging (charge_state DP 101 also flips True ~5s later)
  ↓ charge ends
320 (64+256)      ← back to idle/settled

Observations:

  • AC/USB output toggle events don't directly drive bms_errorcode1 changes — they cause charge cycles to start/stop, which then triggers the bit changes.
  • bms_errorcode1 transitions reliably lead charge_state (DP 101) by ~5 seconds. The status register reports the BMS's intent; charge_state reports the result.
  • Brief excursions to value 0 happen during fast transitions (all status flags momentarily off). Harmless.

Practical recommendation for integrations:

  • Treat values 64, 128, 192, 256, 320, 384, 448 as benign status — these are combinations of bits 6, 7, 8 that represent normal operating states.
  • Only alert if a low bit (1-32) sets. That's where real faults like cell over-voltage, BMS communication errors, temperature trips would likely live.
  • Don't waste user time with notifications when the BMS just cycles through normal states.

While I appreciated this writeup I found there to be quite a few gaps that I would like to fill in. So I will be expanding on the DBS2000L portion as that is the unit I have.

Setup

  1. Factory reset the unit. I could not find any language on how to do this in the manual or elsewhere. I factory reset settings from inside the Dabbsson app and then pressed “unlink” this was sufficient.
  2. Pair to the Tuya Smart App instead of Dabbsson
  3. Create an account on the Tuya Developer Platform. This appears to have been wildly remade since the original writeup. Go to project management, create a cloud project, within that project go to devices, in devices is where you link your Tuya account.
  4. If you are a windows user grab Python.
  5. Once Python is installed, open it up and pull down a copy of TinyTuya
  6. Use the tinytuya wizard to get device ID + local key. Do not bother looking anywhere else for the local key it cannot be obtained anywhere on their website.
  7. Grab this fork of the localtuya integration. It supports the 3.5 tuya LAN protocol.
  8. Ensure the power station has a static IP address for easiest config. I still had to manually add it to LocalTuya and it was not automatically discovered.

Template Sensors

While several sensors and switches are provided many have to be derived from DP 158. OP did not include these but I’m a nice guy. I will summarize all of them here for ease of use but if you’re curious where they came from I will explain further down.

:red_exclamation_mark::red_exclamation_mark: You will first need to set up a sensor in LocalTuya to pull DP 158, I called mine sensor.2000l_power_string.

After that you can create template sensor helpers for each of these.

AC Volts In

{% set ac_data = states('sensor.2000l_power_string') | regex_findall('AC输入\{([-\d.]+)V') %} {{ ac_data[0] if ac_data else none }}

AC Power In

{% set ac_data = states('sensor.2000l_power_string') | regex_findall('AC输入\{[^}]*,\s*([\d.]+)W') %} {{ ac_data[0] if ac_data else none }}

AC Power Out

{% set ac_out = states('sensor.2000l_power_string') | regex_findall('AC输出\{[^}]*,\s*([\d.]+)W') %} {{ ac_out[0] if ac_out else none }}

Solar Amps In

{% set pv_data = states('sensor.2000l_power_string') | regex_findall('PV\{[^}]*,\s*([\d.]+)A') %} {{ pv_data[0] if pv_data else none }}

Solar Volts In

{% set pv_data = states('sensor.2000l_power_string') | regex_findall('PV\{[^,]+,\s*([\d.]+)V') %}
{{ pv_data[0] if pv_data else none }}

Solar Watts In

{% set pv_data = (states('sensor.2000l_power_string') | regex_findall('PV\{[^}]*,\s*([\d.]+)W\}')) %}
{{ pv_data[0] if pv_data else none }}

BMS String
Necessary for derivation of battery sensors (see below).

{% set BMS = states('sensor.2000l_power_string') %}
{% if BMS is search("BMS主机") %}
  {{ states('sensor.2000l_power_string') }}
{% else %}
  {{ this.state }}
{% endif %}

Battery Status
charging, discharging, standby

{% set bat_power_string = states('sensor.2000l_bms_string') | regex_findall('BMS主机\{\d+,([-\d.]+)V,\s*([-\d.]+)A') %}
{% if bat_power_string %}  
  {% set bat_volts = bat_power_string[0][0] | float(0) %} 
  {% set bat_amps = bat_power_string[0][1] | float(0) %} 
  {% set bat_watts = bat_volts * bat_amps %} 
  {% if bat_watts < 0 %}
   discharging
   {% elif bat_watts == 0 %}
   standby
  {% else %}
   charging
  {% endif %}
{% endif %}

Battery Watts In
Useful alongside an integral sensor (Left Riemann Sum) to track energy in Home Assistant.

{% set bat_power_string = states('sensor.2000l_bms_string') | regex_findall('BMS主机\{\d+,([-\d.]+)V,\s*([-\d.]+)A') %}
{% if bat_power_string %}  
  {% set bat_volts = bat_power_string[0][0] | float(0) %} 
  {% set bat_amps = bat_power_string[0][1] | float(0) %} 
  {% set bat_watts = bat_volts * bat_amps %} 
  {% if bat_watts >= 0 %}
   {{ bat_watts | round(2) }}
   {% else %}
   0
    {% endif %}
{% endif %}

Battery Watts Out
Useful alongside an integral sensor (Left Riemann Sum) to track energy in Home Assistant.

{% set bat_power_string = states('sensor.2000l_bms_string') | regex_findall('BMS主机\{\d+,([-\d.]+)V,\s*([-\d.]+)A') %}
{% if bat_power_string %}  
  {% set bat_volts = bat_power_string[0][0] | float(0) %} 
  {% set bat_amps = bat_power_string[0][1] | float(0) %} 
  {% set bat_watts = bat_volts * bat_amps %} 
  {% if bat_watts <= 0 %}
   {{ bat_watts | abs | round(2) }}
   {% else %}
   0
    {% endif %}
{% endif %}

Battery Current

{% set bat_power_string = states('sensor.2000l_bms_string') | regex_findall('BMS主机\{\d+,([-\d.]+)V,\s*([-\d.]+)A') %}
{% if bat_power_string %}  
  {% set bat_amps = bat_power_string[0][1] | float(0) %} 
  {% if bat_amps %}
   {{ bat_amps | round(2) }}
  {% else %}
    none
  {% endif %}
{% endif %}

Battery Volts
It may be more accurate to pull from the “sample voltage” in the BMS string but the difference is always like 0.12V so I did not bother.

{% set bat_power_string = states('sensor.2000l_bms_string') | regex_findall('BMS主机\{\d+,([-\d.]+)V,\s*([-\d.]+)A') %}
{% if bat_power_string %}  
  {% set bat_volts = bat_power_string[0][0] | float(0) %} 
  {% if bat_volts %}
   {{ bat_volts| round(2) }}
  {% else %}
   none
  {% endif %}
{% endif %}

INV Calculated Power :red_question_mark::red_question_mark:
I’ll be so for real. I really don’t know what the INV{} line is reflecting. The watts field never makes any sense and the VA derivation often has higher wattage than is actually available. That is to say, if you take AC in + PV in + Battery Discharge you may get an INV value that is 50W or even 100W higher than all the available power. Which is of course impossible. I’ll leave this template here in case you’d also like to track INV stats. You can tweak it to your liking but by default it derives W from VA.

{% set bat_power_string = states('sensor.2000l_power_string') | regex_findall('INV电池端\{([-\d.]+)V,\s*([-\d.]+)A,\s*([-\d.]+)W') %} 
{% if bat_power_string %}  
  {% set bat_volts = bat_power_string[0][0] | float(0) %} 
  {% set bat_amps = bat_power_string[0][1] | float(0) %} 
  {% if bat_volts and bat_amps %}
    {{ bat_volts * bat_amps }}
  {% else %}
    0
  {%endif%}
{%else%}
 none
{% endif %}

Battery Sensors - the first problem child

In the previous post the advice is to simply ignore the Watts portion of the INV{} line and use V*A but I can assure you that is equally inaccurate. I am still researching exactly what the INV{} portion is even reflecting as it frequently does not make sense. What does work is a thing I found:

Hidden Data

It turns out that within DP 158 is not just multiple line it’s multiple packet. If one creates a sensor in localtuya for this it becomes clear as day. This is very easily missed if you are only interacting with the API as the overwrite is instant.


Both strings are pushed back to back and “BMS” data always gets overridden. To grab this we create another template sensor with the following template. this.state is critical to dodging the overwrite.

{% set BMS = states('sensor.2000l_power_string') %}
{% if BMS is search("BMS主机") %}
  {{ states('sensor.2000l_power_string') }}
{% else %}
  {{ this.state }}
{% endif %}

I named mine sensor.2000l_bms_string but do what you want. I’m not your dad. Before you know it we have a hot steamy BMS string:

BMS主机{20,53.05V,0.39A,采样:52.97V,放电:120.00A,充电:20.00A,容量:{8.13,40.99},电芯{3.317,3.315},soh:100} 点烟器{12.5V,0.0A,0W} 温度{转速:1,INV:30℃,BMS:28℃,MPPT:0℃,PD:38℃}

Inside we have:

  • BMS { Battery % (20), Voltage at BMS (53.05), Battery Current (0.39), “Sample Voltage at Battery” (52.97), “Current Limit Out” (120), “Current Limit In” (20), “Capacity”: { Current (8.13 Ah), Total (40.99 Ah)}, “Battery Cells” {Voltage 1?, Voltage 2?}, “State of Health” (100%)
  • “Cigarette lighter socket / port” { Volts, Amps, Watts } Sorry this is NOT DC out.
  • “Temperature / System Status” { “Fan Speed” (1), the rest is self explanatory }

From this string we can derive a ton of useful information and I will be attempting to gleam DC out from all of the data available but I’m still researching. I will include templates to use for the battery sensors above.

DC Output - the second problem child

For whatever reason the 2000L only reports the status of the car port but not the rest of the DC out. To me it looks to be buried in the data somewhere and I haven’t quite nailed it down yet. I believe it can be back-calculated out now that we know almost everything but the power balance equation has two unknowns still:

[Power Available] = [Power Consumed]

Paci + Ppv + Pbo = Pdco + Ploss + Paco + Pbi

Now, we do have fan speed, and we do have an inverter value that stays pretty constant but ocassionally goes up. So just as an example I’ll rip some data from my unit right now:

Power
AC输入{0.00V,0.00A,0W,35HZ,使能:1}
AC输出{119.90V,0.46A,15W,并机:0}
PV{0.00V,33.30V,1.30A,45W}
INV电池端{53.2V,-1.4A,45W,设置:700W}
BMS
BMS主机{57,53.15V,-0.67A,采样:53.03V,放电:120.00A,充电:20.00A,容量:{23.67,40.99},电芯{3.325,3.320},soh:100}
点烟器{12.5V,0.0A,0W}
温度{转速:0,INV:41℃,BMS:26℃,MPPT:0℃,PD:43℃}

So:

0 + 45W + 35.61 = Pdco + Ploss + 10.0W

Pdco + Ploss = 70.61
Note that this is very close to the INV VxA = -74.48

In fact these numbers stay quite closely paired over time but I am still researching. Actual DC output in this example is around 12 watts.

Cool stuff thank you - do you have any updates or changes to this? I tried copying and setting mine up and dashboard. some things work, I feel like most things don’t. I used Claude to help me set it all up and write up a guide for it. Was a total pain to build my full table of DP_IDs with what they do going through the debugging in tuya cloud panel. had to string it together from a few different pages.

Don’t know if my automations work at all, total energy in/out doesnt work. buttons don’t really work and/or scared to try them all. But I do like seeing in/out in general and I want to get a solar panel to match my average usage.

https://jackcreager.com/battery.html

Sorry - are you lifting whole chunks of my writeup and putting them on your AI for-proft webpage with “© 2026 Jack Creager LLC. All rights reserved.” at the bottom? Very obviously you do not have copyright nor “all rights” over what I made.

Please don’t do that or at the very least link the source. This was a lot of work.

I have made some more progress but frankly now I don’t want to post it.