Saltar a contenido

Battery driver assessment: Huawei SUN2000 + LUNA2000

Completed against driver-requirements-template.md.

Every value below was read from hardware, not from a datasheet. Where a figure was measured, the measurement is stated. Where something is unknown, it says so.

Assessment outcome

SUITABLE WITH LIMITATIONS. Every blocking requirement is covered. The limitations are not gaps in the data but two architectural mismatches with the existing driver model, both described in §13.

1. Device and documentation evidence

Field Value
Manufacturer Huawei
Commercial model SUN2000-8K-MAP0 with LUNA2000
Device-reported model Storage LUNA2000 (register 47000); inverter SUN2000-8K-MAP0 (register 30000)
Verified firmware range Inverter V200R024C00SPC110; power module and packs V200R025C00SPC103
Region/hardware variant EU, three-phase, EMMA-A02 energy manager, SmartGuard-63A-T0
Rated capacity and power 13.8 kWh in two LUNA2000-7-E1 packs on a LUNA2000-10KW-C1 power module; the battery reports 7000 W charge and discharge; inverter 8000 W rated, 8800 W maximum
Coupling type DC — battery and PV strings share one inverter
Official document Solar Inverter Modbus Interface Definitions v05; SmartHEMS V100R024C00 Modbus Interface Definitions
Interface used Modbus TCP throughout. Control optionally through the huawei_solar integration by @wlcrs, whose register map this work builds on
Hardware used for validation The installation above, one unit
Test date 2026-08-22 / 2026-08-23
  • [x] The interface is published by the manufacturer (PDFs above).
  • [x] Applicable models and firmware are known for the tested unit.
  • [ ] Only one installation tested. Everything below is single-sample.
  • [x] Every field used documents type, unit, scale and sign; each was verified against the same value surfaced by huawei_solar.
  • [x] Read rate and concurrency measured (see transport).
  • [x] Restart and disconnect behaviour known (watchdog, see control).

Firmware compatibility matrix

Model Firmware Transport Read Write Known differences Status
SUN2000-8K-MAP0 V200R024C00SPC110 Modbus TCP via proxy yes via huawei_solar Per-string current reads 0 below ~100 W; registers 37026/37036 answer with a Modbus exception; the pack 1 address run answers with padding because that slot is unpopulated tested
Other SUN2000 String count read from 30071; up to four published untested

Transport and access worksheet

Aspect Value
Scope local
Protocol Modbus TCP, FC03 only
Address The inverter, reached through a modbus-proxy add-on
Unit id 4 = inverter. Also on the bus: 0 = EMMA (SmartHEMS), 2 = SmartGuard, 9 = charger. Not derivable, but discoverable: a scan reads 30000 on nine candidate ids and keeps those answering as an inverter with an SOC
Discovery Address manual, unit id scanned. A cascade yields several inverters, and the user picks
Authentication none
Maximum simultaneous connections The inverter tolerates one client; a Modbus proxy fans it out. Verified: this driver read continuously while huawei_solar held its own connection
Read latency median 3.6 ms, 6 ms at p95 over 800 consecutive reads with nothing else on the bus
Post-connect pause 1500 ms required. The first request after the TCP handshake is dropped without it
Request pacing Single in-flight request; the inverter does not tolerate pipelining. 800 reads each at 10 ms and 0 ms pacing produced zero failures, so the reference library's 50 ms is conservative for Modbus TCP; the driver uses 20 ms
Telemetry freshness Battery and PV values refresh every 2–3 s at the register
Volatile vs persistent Forcible-charge registers are volatile and carry a duration. SOC cutoffs (47081/47082) are persistent
Behaviour without network Forcible commands expire on their own; see the watchdog below

The 30 s figure often quoted for Huawei is the huawei_solar coordinator interval, not the hardware. Reading the registers directly is ~4 ms and the data is 2–3 s old at worst.

2. Admission gate for automatic control

  • [x] Programmable transport with controlled connect, reconnect and close.
  • [x] Real, fresh SOC as a percentage — register 37004, verified against the huawei_solar sensor.
  • [x] Real battery power — register 37001, +charge / −discharge, already the Omnibattery convention.
  • [x] Power-limited charging — huawei_solar.forcible_charge.
  • [x] Power-limited discharging — huawei_solar.forcible_discharge.
  • [x] A safe idle. Not the obvious one — see §13.1.
  • [x] Safe per-unit maxima — registers 37046/37048 report 7000 W each.
  • [x] BMS protections remain active; the inverter enforces its own cutoffs.
  • [x] Write cadence is safe only with a driver-side throttle — see §13.2.
  • [x] Stale communications are detectable; a failed block omits its keys.

3. Declared capabilities

DriverCapabilities Value Evidence
hardware_soc_cutoff False 47081/47082 exist and are writable, but accept only 90–100 % and 0–20 % — narrower than the window a user may configure. Claiming hardware enforcement would leave an out-of-range limit unenforced anywhere
has_force_mode True Forcible charge/discharge/stop
push_telemetry False Polled
max_charge_power_w 7000 Register 37046
max_discharge_power_w 7000 Register 37048
has_mppt_pv False Deliberate. See §13.3
has_alarm_registers False 37014 exists but was not validated
has_rs485_control False No external-control gate needed
has_energy_counters True 37066/37068 cumulative, 37015/37017 daily
setpoint_confirm_reliable False The command registers echo instantly while the battery is still ramping
actuator_latency_s 25.0 Measured 19.7 s to 90 % of a +1000 W charge from idle, 11.3 s to reverse to −1000 W, sampled at 1 Hz with exclusive access
readback_latency_s 25.0 Telemetry is milliseconds away; the delay is the physical ramp
engage_grace_s 25.0 Same ramp

4. Telemetry mapping worksheet

All registers FC03 holding, unit id 4, verified against the corresponding huawei_solar sensor on the same installation.

Omnibattery key B/R/O Register Type Scale → unit Cadence Src Tested
battery_soc B 37004 u16 ÷10 → % high N [x]
battery_power B 37001 i32 → W, +charge/−discharge high N [x]
battery_voltage O 37003 u16 ÷10 → V high N [x]
inverter_state O 37000 u16 enum, refined by direction (§13.4) high D [x]
battery_total_energy R 37758 u32 ×0.001 → kWh very_low N [x]
total_charging_energy O 37066 u32 ÷100 → kWh low N [x]
total_discharging_energy O 37068 u32 ÷100 → kWh low N [x]
total_daily_charging_energy O 37015 u32 ÷100 → kWh low N [x]
total_daily_discharging_energy O 37017 u32 ÷100 → kWh low N [x]
internal_temperature O 37022 i16 ÷10 → °C low N [x]
max_charge_power R 37046 u32 → W low N [x]
max_discharge_power R 37048 u32 → W low N [x]
charging_cutoff_capacity O 47081 u16 ÷10 → % low N [x]
discharging_cutoff_capacity O 47082 u16 ÷10 → % low N [x]
user_work_mode O 47086 u16 enum low N [x]
solar_power O 32064 i32 → W (DC total) high N [x]
mppt1..4_power O 32016+2n i16 ×2 V × I → W high D [x]
inverter_ac_power O 32080 i32 → W high N [x]
inverter_max_power R 30075 u32 → W very_low N [x]
grid_power O 31657 i32 → W, +import/−export high N [x]
off_grid_state O 32003 u32 bitfield medium N [x]
ac_offgrid_power O derived (§13.5) medium D [x]
device_name O 30000 str(15) inverter model very_low N [x]
storage_product_model O 47000 u16 enum very_low N
power_module_serial_number O 37052 str(10) very_low N [x]
power_module_firmware_version O 37814 str(15) very_low N [x]
inverter_serial_number O 30015 str(10) very_low N [x]
inverter_software_version O 30050 str(15) very_low N [x]
pack1..3_firmware_version O 38210/38252/38294 str(15) very_low N [x]
pack1..3_serial_number O 38200/38242/38284 str(10) very_low N [x]
max_cell_voltage / min_cell_voltage O X X

Not available. A LUNA2000 reports per pack values, not per cell. There is nothing honest to put in the cell fields, so balance monitoring and the 100 % voltage taper are disabled for this brand.

Registers that did not answer on the tested unit: 37026 (DCDC version) and 37036 (BMS version) return a Modbus exception; the whole pack 1 run (38200) returns padding, because that slot holds no pack — the tested unit has packs 2 and 3 only. All three are optional and omitted rather than shipped as permanently-missing entities.

5. Control mapping worksheet

Set-points take either of two paths, selected per battery.

By default they go through the huawei_solar services. Optionally the driver writes the same four-register sequence itself via FC16 — same registers, same order — which removes the dependency for control. Verified on hardware: a no-op FC16 write was acknowledged through the Modbus proxy while another client held its own connection, and a full charge/reverse/release cycle drove the battery as commanded.

Writing directly means reimplementing two things the services provided: the power is clamped to the register maximum (huawei_solar refuses an over-range value outright, aborting the control cycle), and the mode register is written last so a sequence failing earlier leaves the inverter untouched.

Operation Call Range Persistence Latency Safe failure Tested
Charge at W forcible_charge, or FC16 47247/47083/47246/47100 0…37046, duration 1–1440 min volatile 19.7 s expires [x]
Discharge at W forcible_discharge, or FC16 47249/47083/47246/47100 0…37048 volatile 11.3 s from charge expires [x]
Idle huawei_solar.stop_forcible_charge volatile already released [x]
Shutdown same as idle, from standby() [x]
Max/min SOC number.set_value on the cutoff entities 90–100 % / 0–20 % persistent skipped when out of range [x]

The service validates power against the register maximum and raises ValueError: Power cannot be more than 7000W — it refuses an over-range command rather than trimming it, and the control cycle dies with it. Since the configured limit may legitimately sit above the present reading (§12), the driver clamps every set-point to 37046/37048 as read, on both control paths. The configured figure still binds whenever it is the lower of the two.

The cutoff entities live on two different devices. huawei_solar puts the charge cutoff on the inverter and the discharge cutoff on the battery, so resolving against the configured battery device alone finds one and misses the other. The driver searches the whole config entry.

Watchdog. Every command carries a duration (10 minutes as issued). If Home Assistant dies without unloading, the inverter drops the command by itself and returns to its own regulation. This is why a duration is sent rather than an open-ended command.

6. Feature degradation matrix

Feature Status
PD charge/discharge Supported, with the throttle of §13.2
Multi-battery Supported; capacity is native
Min/max SOC Software-enforced; registers written as a backstop when representable
Predictive / pricing charge Supported
Energy, cycles, efficiency Native counters
100 % taper, balance monitoring Disabled — no cell voltages
Thermal limit Supported (37022)
Backup exclusion Derived, see §13.5
MPPT / DC production Reported, with the caveat of §13.6
Alarms Omitted — 37014 not validated

7. Minimum acceptance tests

Covered by tests/test_huawei_driver.py (152 tests). Beyond the template's list, these encode failures found on hardware — each was a live installation misbehaving first and a test second:

  • Capacity is published in kWh, not the register's Wh.
  • A throttled set-point reports the standing command, not the request.
  • A reversal skips the deadband but not the ramp.
  • The dynamic discharge limit ignores the battery's own contribution.
  • No read group may hold a single key.
  • The inverter's AC total is not published as the battery's AC port.
  • Every form schema survives the serialisation the frontend needs (§13.8).
  • An unpopulated pack slot produces no entities at all.
  • A battery device belonging to a different inverter is refused.
  • The limits form allows more than the battery reports today.
  • A set-point above the battery's present reading is trimmed, never sent.

12. Setup and identity

What the user is asked for. An address and a port; everything else the setup can find out for itself. The slave id is optional, and left empty it triggers the scan — which is the recommended path, because a wrong id reads an energy manager or a charger rather than failing outright. One inverter with a battery is taken straight away; several mean a cascade and the user picks from a list naming each model and id. A single checkbox chooses the control path, and the battery-device field is only needed on the service side.

A Huawei inverter accepts one Modbus connection. Anything else already talking to it — huawei_solar, evcc, another system — takes that one. The setup says so and points at a Modbus proxy, and the reference installation runs one: this driver read continuously while huawei_solar held its own connection.

One "battery" is three kinds of hardware. A LUNA2000 installation is an inverter, a power module, and one to three battery packs, and each carries its own serial and firmware in its own registers — 30015/30050 for the inverter, 37052/37814 for the power module, 38200+ per pack. Publishing any one of them as the serial mislabels the other two, so the driver names each part. The device registry entry stands for the storage, so it takes the power module's serial — and its model from 47000 (2 = LUNA2000) rather than from 30000, which is the inverter's. Calling the device a SUN2000 would read as though the packs belonged to the inverter. That enum is telemetry-only: it resolves to a label and is dropped, so no entity carries a bare 2.

The Modbus endpoint can be a fourth device again: on the reference installation the address answers as SmartHEMS (an EMMA-A02, serial NS24A1211290) on slave 0, with the inverter behind it on slave 4. That is the gateway, not the battery, and it appears nowhere in the telemetry.

An EMMA carries the grid meter, and it is worth reading here. Register 31657 on the EMMA's own unit id is its built-in meter's active power, already in the sign convention Omnibattery uses. It answers live on every request — 25 ms per read, 20 of 20 on the reference installation — where huawei_solar publishes the same figure on a 30 s coordinator, far too slow to control against. An installation with an EMMA is metered by it and may well have no other meter, so the setup looks for one by model name (SmartHEMS) and wires it up without asking. No EMMA means no entity, rather than one that reads unknown.

Its neighbour load_power (30356) is not usable as a house-load figure, despite the name. The EMMA derives it from its own PV, battery and meter, so storage it does not control is invisible to it: while a second battery covered the whole house, 30356 read 0 W against a real 660 W. Feeding that back into a controller would oscillate — the value collapses precisely when the controller acts on it.

The battery's reported power caps are a starting value, not a ceiling. 37046/37048 say what the battery permits right now, and that moves with the pack count — a third pack raises it. The limits form therefore opens on that figure but allows up to the inverter's maximum active power (30075), which is what the installation genuinely cannot exceed: charge and discharge both pass through it. On the tested unit that is 7000 W reported against an 8800 W inverter.

The driver then has to hold the other end of that bargain: a command above what the battery permits right now is refused outright, so every set-point is clamped to the live reading before it is sent (§5).

The setup names the battery twice. On the service path a Modbus address identifies the inverter and a registry device identifies the battery, and nothing forces those to be the same unit — Huawei inverters cascade, so a two-inverter bus can readily have telemetry coming from one while the commands land on the other. Register 30015 carries the inverter serial, which is also what huawei_solar builds its device identifiers from, so the config flow compares the two and refuses a pairing that contradicts itself. A serial that is simply absent — older huawei_solar releases leave serial_number unset — never blocks the setup; only a contradiction does. Where huawei_solar is not installed at all, the setup says that rather than asking for a device that cannot exist.

13. What a DC-coupled hybrid breaks

This is the part worth reading. Every previous brand is an AC battery with its own inverter, and several of the driver model's assumptions quietly depend on that. Each item below was found by breaking a live installation.

13.1 Idle cannot mean "hold at zero"

stop_forcible_charge does not idle the battery — it returns control to the inverter's working mode, which resumes self-consumption. The obvious alternative, a forcible charge at 0 W, does hold it, and was the driver's first implementation.

It was wrong twice over. A pinned battery cannot absorb its own PV, so the inverter derates the strings instead — observed dropping from 4757 W to 55 W within one 30 s sample and staying there for over half an hour of daylight. And the control layer means something else by idle: manual mode idles once on turn-on and then deliberately leaves the device alone, so switching a battery to manual left 13.8 kWh frozen with nothing to clear the command.

A zero set-point must release this battery. That does hand it back to a second regulator on the same meter, which is what the pin was meant to avoid. Measured against a derated array and a frozen battery, it is the cheaper side-effect.

13.2 The write path needs its own throttle

A set-point costs four serialised Modbus writes inside huawei_solar, and the battery needs ~15 s to reach any target. A 2 s control loop that sees no response yet keeps revising its request.

Without a throttle, the battery received a new forced command every 10–20 s swinging between 0 and 4190 W, and the inverter answered by derating PV. A throttle that lets direction changes bypass it is no throttle at all: the loop flip-flops between a held zero and a discharge, so nearly every cycle counts as a reversal. The rule that works is a reversal skips the deadband but not the ramp.

The throttle must also report the standing command when it suppresses a write. Reporting the request tells the control layer the battery was commanded to a value it never received; it then measures the older power and flags a battery that accepts commands without delivering.

13.3 The static power envelope is not reachable

Battery and PV share one inverter, so the discharge power actually available is whatever the AC rating has left after PV. At 7 kW of PV on an 8.8 kW inverter the battery can contribute 1.8 kW whatever its 7 kW BMS allows — and load sharing, allocating proportionally to nameplate limits, hands it 74 % of the deficit and starves the batteries that could have delivered.

BatteryDriver.dynamic_discharge_limit_w(data) was added for this, defaulting to None so every AC brand keeps its static envelope. The subtraction must exclude the battery's own contribution, or the limit chases its own output.

13.4 The status register knows no direction

Register 37000 reports Offline / Standby / Running / Fault / Sleep. The panel prints that verbatim, so this brand sat on the word "Running" all day. Running is refined by measured battery power into Charge / Discharge / Standby, using the Marstek register map's wording so the header reads the same across brands.

The direction deadband is not cosmetic: this inverter idles around +50 W, so comparing against zero labels a standing battery "Charge".

13.5 Backup output is not metered

There is no register for off-grid power. Register 32003 bit 0 says on-grid or off-grid, and while the grid is disconnected the inverter feeds nothing but the backup circuit — so its AC power is the backup output. On-grid the value is zero, deliberately: printing the house supply in that tile would be wrong.

13.6 ac_power means something else here

The system aggregates derive household load as home = grid + sum(ac_power) + external_solar, reading ac_power as the battery's own AC port with DC-coupled PV already netted into it.

Register 32080 is the whole inverter's AC output, PV included. Publishing it under that key counted the roof array twice — once inside ac_power, once in the external solar sensor — and showed 8.42 kW of house load against 8.87 kW of PV when the real figure was near 0.7 kW.

The inverter total is published as inverter_ac_power instead. With no ac_power key the aggregates take their documented fallback, -battery_power, which is what a battery with no AC port of its own actually contributes.

13.7 A read group must never hold one optional key

Not brand-specific, but it bites here. The coordinator counts a group that returns nothing as a failed read, and a cycle in which every attempted group fails marks the whole battery unavailable and stops the control loop writing to it.

A block holding a single optional value therefore takes the battery offline on every poll of that block. The tested inverter answers register 38210 with padding, so the pack 1 firmware string decodes to nothing, and the battery flapped in and out of the pool every three seconds all day. Groups are now one per cadence rather than one per block.

13.8 A form schema must survive serialisation

Home Assistant hands the frontend a serialised copy of every form schema, and not every voluptuous construct has a serialised form. A vol.Any in the slave-id field — added so an empty value could mean "search" — made the whole step fail with "Unknown error occurred" before the form was ever drawn. Setup and reconfiguration alike were unreachable for that brand.

Nothing caught it, because the tests inspected schema markers and never converted them. Any driver contributing a config-flow step should convert its schemas in test the way Home Assistant converts them, including the output the step actually returns rather than only the helper it calls.

11. Decision report

Manufacturer/model: Huawei SUN2000-8K-MAP0 + LUNA2000 13.8 kWh
Firmware tested:    inverter V200R024C00SPC110, storage V200R025C00SPC103
Documentation:      Solar Inverter Modbus Interface Definitions v05

Verdict: SUITABLE WITH LIMITATIONS

Blocking items:
- Real SOC:        N — register 37004, verified
- Real power:      N — register 37001, sign already matches
- Adjustable charge/discharge: yes — 0..7000 W via huawei_solar services
- Safe idle:       yes, as a release rather than a hold (§13.1)
- Safe limits:     registers 37046/37048
- Freshness:       2-3 s at the register; failed blocks omit their keys

Omnibattery adaptations:
- Split transport: native Modbus for telemetry, services or direct registers for control
- Dynamic discharge limit from the inverter's AC headroom
- Per-string power derived from separate voltage and current registers
- Inverter status refined by measured direction
- Unit id discovered by scan; a cascade is resolved by the user
- Inverter, power module and each populated pack named separately
- Limits form bounded by the inverter rating, not by the battery's reading

Disabled features:
- Cell voltages, balance monitoring, 100% voltage taper (per-pack data only)
- Alarms (37014 not validated)

Open risks:
- One installation, one firmware pair
- Direct Modbus writes verified on one installation only; off by default
- Two regulators share the meter whenever the battery is released
- Cascaded inverters reasoned about and guarded against, but never tested:
  the reference bus holds exactly one

Pending hardware tests:
- A second installation, ideally with more than two strings
- A genuine cascade, and a three-pack storage
- Off-grid behaviour (the tested unit has its off-grid switch disabled)
- Whether per-string current is unpopulated below ~100 W on other models