BESS Manager supports four inverter platform configurations. Each combines a specific inverter hardware family with a Home Assistant integration for communication.
| Platform | Inverter | HA Integration | Connection | Control Method | solax_modbus Gen |
|---|---|---|---|---|---|
| Growatt MIN (Cloud) | Growatt MIC/MIN/MOD/MID | Growatt Server | Cloud API | TOU service calls | — |
| Growatt MIN (Local) | Growatt MIC/MIN/MOD/MID | solax_modbus Growatt plugin | Local Modbus | TOU entity writes | GEN4 |
| Growatt SPH (Cloud) | Growatt SPH | Growatt Server | Cloud API | AC charge/discharge periods | — |
| Growatt MIX/SPH (Local) | Growatt MIX/SPA/SPH | solax_modbus Growatt plugin | Local Modbus | Mode-specific time slots | GEN3 |
| SolaX | SolaX hybrid | solax_modbus | Local Modbus | VPP active-power commands | — |
| Solis | Solis hybrid | solis_modbus | Local Modbus | Grid Time of Use v2 (6 charge + 6 discharge periods) | — |
| Huawei LUNA2000 (Local) | Huawei LUNA2000 | huawei_solar | Local Modbus | TOU period-list writes | — |
solax_modbus generation mapping: The
wills106/homeassistant-solax-modbusGrowatt plugin classifies inverters by generation. GEN4 = MIN/MOD/MID/TL-X (AC-coupled, numbered TOU slots). GEN3 = MIX/SPA/SPH (DC-coupled, mode-specific time slots). BESS detects the generation automatically from entity markers.
Why
solis_modbusand notsolax_modbusfor Solis:solax_modbus(thewills106project) advertises multi-brand support but does not support Solis inverters in practice, soPho3niX90/solis_modbus— a separate, dedicated integration domain — is the only viable local-Modbus option for Solis.
Inverter control is not a single flat list of patterns — it is two orthogonal axes plus a shared vocabulary of control primitives. Adding a new inverter means placing it on both axes and listing which primitives it supports — that determines which existing controller to model on and how much is new.
| Transport | HA integration(s) | Mechanism | Implemented today | Model controller(s) |
|---|---|---|---|---|
| TX-Cloud | growatt_server |
Vendor cloud API via HA service calls | ✅ | GrowattMinController, GrowattSphController |
| TX-Modbus | solax_modbus (multi-brand: SolaX, Solis, Growatt, Sofar, AlphaESS, …) |
Local Modbus entity writes (select/number/button) | ✅ | SolaxModbusGrowattController, SolaxController |
| TX-Vendor-service | huawei_solar (and similar) |
Local vendor integration: entity writes to persistent TOU period lists gated by working-mode select | ✅ | HuaweiController |
| TX-REST / TX-MQTT | GivTCP, Solar Assistant, Sofar2mqtt | REST API / MQTT | ❌ not planned | — |
solax_modbus is a generic transport, not a Growatt thing — the same channel
serves SolaX, Solis, Growatt, Sofar, etc. via per-brand register/entity names.
| Scheduling model | Description | Implemented example |
|---|---|---|
| SM-TOU-numbered | Persistent numbered TOU slots (start/end/mode) | Growatt MIN (cloud & GEN4 single-segment) |
| SM-Period-lists | Persistent charge/discharge period lists (≤N each), power/SOC in the write | Growatt SPH (cloud), Huawei LUNA2000 (local) |
| SM-Mode-slots | Persistent mode-specific time slots | Growatt MIX/SPH GEN3 (monitoring-only today) |
| SM-Ephemeral | No persistent schedule — push a duration-bounded command that auto-expires | SolaX VPP, Growatt VPP (GEN3+GEN4, experimental) |
Regardless of transport/model, a controller works in these terms (each platform declares which it supports, mapped to BESS sensor keys): charge window (start/end) · discharge window · target / charge-stop SOC · reserve / discharge-stop SOC · charge rate · discharge rate · grid-charge enable.
| Platform | Transport | Scheduling model | Controller | Detection marker / service | Suffix map |
|---|---|---|---|---|---|
growatt_server_min |
TX-Cloud | SM-TOU-numbered | GrowattMinController |
growatt_server.update_time_segment |
GROWATT_MIN_SUFFIX_MAP |
growatt_server_sph |
TX-Cloud | SM-Period-lists | GrowattSphController |
growatt_server.write_ac_charge_times |
GROWATT_SPH_SUFFIX_MAP |
solax_modbus_growatt_min |
TX-Modbus | SM-TOU-numbered (single-segment) | SolaxModbusGrowattController |
_GROWATT_TOU_MARKER_SUFFIX (time_1_enabled) |
SOLAX_GROWATT_MIN_SUFFIX_MAP |
solax_modbus_growatt_sph |
TX-Modbus | SM-Mode-slots (GEN3, monitoring-only) | SolaxModbusGrowattController |
_GROWATT_GEN3_MARKER_SUFFIX |
SOLAX_GROWATT_SPH_SUFFIX_MAP |
solax_modbus_native |
TX-Modbus | SM-Ephemeral (VPP) | SolaxController |
_SOLAX_NATIVE_MARKER_SUFFIX (remotecontrol_power_control) |
SOLAX_NATIVE_SUFFIX_MAP |
solis_modbus |
TX-Modbus | SM-Period-lists (6 charge + 6 discharge) | SolisModbusController |
_SOLIS_TOU_MARKER_SUFFIX (time_entity_43711) |
SOLIS_SUFFIX_MAP + SOLIS_DICT_EMBEDDED_SUFFIX_MAP |
huawei_solar_luna2000 |
TX-Vendor-service | SM-Period-lists | HuaweiController |
_HUAWEI_BATTERY_MARKER_SUFFIX (storage_working_mode_settings) |
HUAWEI_SUFFIX_MAP |
BESS reaches your inverter through whatever Home Assistant integration you have installed. It is not bound to the specific integration named in the table above — that column records what the platform was built against, not a requirement. If the usual integration can't reach your hardware, or you run a different one that talks to it, BESS can be pointed at that instead.
The case this exists for — Huawei behind an EMMA energy manager. Where
a third party owns the Modbus TCP socket, wlcrs/huawei_solar
cannot connect to the inverter at all.
valexi7/Huawei-Modbus-TLS-Server
gets there through EMMA and exposes the same set_tou_periods service
under its own domain, so BESS drives such an install as an ordinary
huawei_solar_luna2000 platform with one setting changed.
What has to line up:
- The vendor service call. Growatt Cloud and Huawei are the only
platforms with one (see Axis 1). Your integration must expose that same
service, with the same signature, under its own domain — set
inverter.service_domainto that domain. The exact signatures are in the Growatt Cloud and Huawei LUNA2000 sections below. TX-Modbus platforms (Growatt Local, SolaX, Solis) have no vendor service at all: control there is plainnumber/select/switchentity writes, so only the entities below matter. - The entities. Auto-discovery only recognizes the integration domains in the table above, so map the sensors — and, for Huawei, the battery Device ID — by hand under Settings → Integrations & Sensors.
- Optional entities may legitimately be missing. Huawei's working-mode select is the example: EMMA owns the mode, so nothing maps it. BESS then skips both the mode write and the LUNA2000-vs-LG-RESU battery check, logs that it did, and the health check reports WARNING rather than OK.
BESS cannot test against an integration it doesn't ship support for, so any such setup is experimental by definition. If your inverter has no matching platform at all — a scheduling model BESS doesn't implement — configuration can't bridge that; open an issue describing what it needs.
BESS writes a 24-hour TOU (Time of Use) schedule to the inverter using up to 9 time slots. Each slot specifies a time range and battery mode (battery_first or grid_first). Periods not covered by a slot default to load_first.
Schedule writes: Single HA service call per slot:
growatt_server.update_time_segment(segment_id, start_time, end_time, mode, enabled)
Per-period control: Generic HA entity service calls:
- Grid charge enable/disable:
switch.turn_on/switch.turn_off - Charge/discharge rate:
number.set_value
Uses a single TOU segment (slot 1) with a full-day time window
(00:00-23:59). The battery mode is updated per-period via apply_period()
— only when the mode actually changes — instead of pre-programming up to 9
slots. This reduces the required entity count from 45 (9 slots x 5 entities)
to just 5 entities (slot 1 only). Uses GEN4 entities from the
solax_modbus Growatt plugin (MIN/MOD/MID/TL-X models).
Schedule writes: 5 HA service calls when mode changes:
select.select_option(entity: time_1_enabled, option: "Enabled"/"Disabled")
select.select_option(entity: time_1_begin, option: "00:00")
select.select_option(entity: time_1_end, option: "23:59")
select.select_option(entity: time_1_mode, option: "Battery First"/"Load First"/"Grid First")
button.press(entity: time_1_update)
When the mode is load_first (inverter default), segment 1 is disabled.
When the mode is battery_first or grid_first, segment 1 is enabled with
that mode. Writes only occur on mode transitions, not every period.
Entity ID vs unique_id naming: The solax_modbus Growatt plugin uses
key="time_N_enabled"internally butname="Time N Active"for display. HA generates theentity_idfrom the name (e.g.select.growatt_inverter_time_1_active), while theunique_iduses the key (e.g.growatt_inverter_time_1_enabled). BESS auto-detection matches onunique_id, which is immutable.
Migration from 9-slot mode: On startup, BESS reads all available TOU slots (1-9) and automatically disables any enabled slots 2-9. Users who previously had slots 2-9 enabled do not need to take manual action.
Per-period control: Same generic calls as cloud variant:
- Grid charge:
switch.turn_on/switch.turn_offon charger_switch entity - Charge/discharge rate:
number.set_valueon EMS rate entities
Lifetime energy notes (GEN4): GEN4 does have a native load consumption
register — total_yield (register 3077, "Total Load Energy") maps to
lifetime_load_consumption, and total_power_generation (register 3051) maps
to lifetime_system_production. GEN4 therefore never takes the derived-load
path. (total_load is GEN3, home_consumption_energy is SPF.)
Registers 122/123 via the solax_modbus Growatt plugin — verified against
plugin_growatt.py SELECT_TYPES/NUMBER_TYPES (allowedtypes=GEN2|GEN3|GEN4),
so this is available on both GEN3 and GEN4 hardware, not GEN4-only. Requires
a grid CT/smart meter; disabled (BatterySettings.export_curtailment_enabled = False) by default.
When enabled and a period is exporting solar surplus at a sell price below
export_curtailment_price_floor, BESS writes:
select.select_option(entity: limit_grid_export, option: "Meter 1")
number.set_value(entity: grid_export_limit, value: 0)
This throttles PV/MPPT production at the panel via the CT meter's real-time
export reading — genuine supply-side curtailment, not just a downstream cap
that gets bypassed once the battery is full. Confirmed by a real user's live
test (Meter 1 + 0% dropped measured export to 0W within seconds). Once the
period's sell price is no longer below the floor, BESS releases the limit
with select.select_option(entity: limit_grid_export, option: "Disabled")
only — the percentage register is left untouched on release (its negative
range means "allow this much import," never written by BESS).
The decision is platform-agnostic (grid_exported > 0 AND sell_price < floor, independent of strategic intent) but the actuation itself is
gated by the supports_export_limit_control capability flag — currently
only SolaxModbusGrowattController implements it. growatt_server (cloud)
has no equivalent HA service to hook into and stays a safe no-op.
This same condition is also computed at planning time (mirrored, not
re-derived at dispatch) and exposed as PeriodData.decision.curtailed
(#501), so the UI can show a period the plan expects to curtail as
distinct from a genuinely profitable export — see
core/bess/dp_battery_algorithm.py's _build_period_data.
GEN3 models (MIX/SPA/SPH) connected via the solax_modbus Growatt plugin.
These use mode-specific time slots rather than numbered TOU slots:
battery_first_time_N, grid_first_time_N, load_first_time_N.
Status: Monitoring and dashboards are fully supported. Schedule control requires a dedicated controller (not yet implemented — the GEN3 time slot architecture differs from GEN4).
EMS entities (GEN3-specific):
| Entity Key | BESS Sensor Key | Purpose |
|---|---|---|
battery_first_charge_rate |
battery_charging_power_rate |
Charge rate in battery-first mode |
grid_first_discharge_rate |
battery_discharging_power_rate |
Discharge rate in grid-first mode |
battery_first_maximum_soc |
battery_charge_stop_soc |
Max SOC target |
load_first_battery_minimum_soc |
battery_discharge_stop_soc |
Min SOC target |
Lifetime energy notes (GEN3): GEN3 has total_load (register 1062) for
load consumption but no total_yield. BESS derives
lifetime_system_production from lifetime_solar_energy.
Not yet real-world validated — see
docs/agents/memory/project_platform_maturity.md.
solax_modbus_growatt_min (GEN4) and solax_modbus_growatt_sph (GEN3) both
support a second control strategy, control_mode="vpp", selectable via the
inverter.control_mode setting ("tou" or "vpp"; GEN4 default remains
"tou" — GEN3 always runs "vpp" since it has no working TOU path). VPP
uses Growatt's remote power control registers instead of a persistent TOU
schedule — the same SM-Ephemeral model the SolaX platform below already
uses. See issue #118.
Verified against wills106/homeassistant-solax-modbus's
custom_components/solax_modbus/plugin_growatt.py (NUMBER_TYPES/
SELECT_TYPES, allowedtypes=GEN3 | GEN4 — present on both generations):
| BESS Sensor Key | Entity Type | Register | Purpose |
|---|---|---|---|
growatt_vpp_status |
select | 30100 | Master VPP enable (written once at startup) |
growatt_vpp_remote_control |
select | 30407 | Per-period VPP active/inactive |
growatt_vpp_allow_ac_charging |
select | 30410 | Allow charging from grid via VPP (written once) |
growatt_vpp_time |
number | 30408 | Fallback timer, minutes — reset every active period; reverts inverter to load_first on its own if BESS stops writing |
growatt_vpp_power |
number | 30409 | Power target, -100..100% (negative=discharge/export, positive=charge) |
Intent → VPP mapping (originally mirrored SolaxController; LOAD_SUPPORT
has since diverged — see "LOAD_SUPPORT semantics" below — plus a
block_passive_charging distinction at rate=0 — see "SOLAR_EXPORT
semantics" below):
GRID_CHARGING→vpp_power=+100%, remote control enabledBATTERY_EXPORT(rate>0) →vpp_power=-rate%, remote control enabledLOAD_SUPPORT(any rate) →vpp_power=0, remote control disabled, regardless ofdischarge_rate(releases toload_firstself-use — see "LOAD_SUPPORT semantics" below)SOLAR_STORAGE(rate=0,block_passive_charging=False) → remote control disabled (load_first/self-use — battery may absorb solar surplus)IDLE(rate=0) →vpp_power=+1%, remote control enabled (battery_firsthold — see "IDLE semantics" below)SOLAR_EXPORT(rate=0,block_passive_charging=True) →vpp_power=0, remote control enabled (grid_firsthold)
Register write ordering (issue #593):
Growatt VPP has no separate trigger entity — writing vpp_remote_control
(30407) is the commit, so the inverter immediately executes whatever
30409/30408 already hold. The write order is therefore load-bearing, not
stylistic:
- Activating a period:
vpp_power(30409) →vpp_time(30408) →vpp_remote_control=Enabled(30407) last. Arming first would execute the previous active period's power value until the new one lands — up to ±100%, i.e. a full-rated charge or export spike on every mode switch. - Releasing to
load_first:vpp_remote_control=Disabledfirst, thenvpp_power=0. The zero clears the latch so the next activation can't inherit a stale value; it must come after the disable, because a 0 written while remote control is still enabled selects thegrid_firsthold (see "SOLAR_EXPORT semantics" below) rather than being inert.
A consequence worth knowing when reading logs: a failed power or timer write
now leaves remote control untouched, so the period degrades to load_first
self-use instead of executing a stale command.
LOAD_SUPPORT semantics (fixed — issue #413):
Unlike TOU mode (where LOAD_SUPPORT maps to load_first, letting the
inverter's own control loop follow actual house load), VPP mode previously
forced LOAD_SUPPORT into the same branch as BATTERY_EXPORT — a fixed
grid_first discharge percentage, immune to real load. Since grid_first's
power value is an immediate forced command rather than a load-following
ceiling, this caused unnecessary grid imports/exports whenever the DP's
average-power discharge rate for the period didn't match the real,
fluctuating house load. LOAD_SUPPORT now disables vpp_remote_control
outright, falling back to the inverter's native load_first self-consumption
— the VPP-mode equivalent of TOU's load_first mapping. Reported
independently by two real-hardware testers (Growatt MIN, control_mode=vpp)
on issue #118.
SOLAR_EXPORT semantics (fixed — issue #355):
The Growatt VPP protocol
(GROWATT VPP COMMUNICATION PROTOCOL OF INVERTER V2.01,
2024-9-20, linked from issue #118 — the authoritative vendor register
reference for all Growatt VPP work; check here first before assuming any
Growatt VPP register behavior), §3.5 "Remote power control schematic
diagram", p.32) documents that with
vpp_remote_control (30407) enabled, the sign of vpp_power (30409)
selects the firmware priority mode: > 0 → battery first (charge); ≤ 0
→ grid first. That is, vpp_power=0 while remote control stays enabled
is a distinct, documented state — grid first, the same solar-goes-to-load-
then-grid priority TOU mode uses for BATTERY_EXPORT — not the same thing as
disabling remote control, which instead falls through to plain load first self-use (battery-first for any solar surplus).
The controller previously conflated these two zero-power states: it always
disabled remote control at rate=0, landing in self-use load_first
instead of the documented grid_first hold, which let solar surplus
recharge the battery during SOLAR_EXPORT periods instead of holding it out
and exporting. The DP's SOLAR_EXPORT-below-max candidate (issue #313)
assumes charging can be blocked, an assumption that only holds for
TOU-style hardware rate control unless VPP mode is given an equivalent
signal — see
docs/superpowers/specs/2026-07-20-vpp-passive-charge-block-design.md
for the full design (a block_passive_charging flag threaded through
InverterController.apply_period, computed once from intent, acted on only
by forced-power/VPP-style controllers).
SOLAR_EXPORT now keeps vpp_remote_control enabled and writes
vpp_power=0 instead of disabling remote control, selecting the documented
grid first state instead of self-use load_first. Not yet
real-hardware-validated: whether grid first reached via a forced
vpp_power=0 command holds the battery exactly like grid_first under TOU
is a firmware behavior claim the register table documents the mode
selection logic for, not the runtime power-flow guarantee — ships as
experimental pending confirmation from a real debug export (no existing TOU
code path exercises grid_first with a zero target either, so there's no
already-proven precedent to lean on).
SolaxController (real SolaX hardware) has the same underlying
architectural gap but is not fixed here — no SolaX vendor protocol has
been verified the way the Growatt spec was, so extending this fix there
would be speculation, not a verified command. Tracked as a follow-up.
IDLE semantics (fixed — issue #466):
IDLE previously mapped to the same remote_control=Disabled → native
load_first self-use as SOLAR_STORAGE. That is wrong for IDLE
specifically: load_first self-use discharges the battery to cover house
load before drawing from grid, but the DP's own cost model for IDLE periods
(_idle_battery_flows in dp_battery_algorithm.py) never credits the
battery discharging — it only ever models passive solar absorption
(charging). A real overnight IDLE period therefore drained the battery for
house load in a way the optimizer's schedule never priced for. Confirmed by
a real-hardware report (Growatt MIN, control_mode=vpp) on issue #466.
The grid_first hold used for SOLAR_EXPORT above is not a fix for
this: per issue #118's real-hardware testing, grid_first
(vpp_power<=0, remote control enabled) still draws self-consumption from
the battery — it only stops the battery absorbing solar, not discharging
for load. Only battery_first (vpp_power>0) releases self-consumption to
grid/solar. IDLE now maps to remote_control=Enabled, vpp_power=+1
(the minimal battery_first magnitude) instead. Not yet
real-hardware-validated: ships experimental pending confirmation that this
doesn't cause any grid-charge creep overnight (vpp_power>0 is the same
mechanism GRID_CHARGING uses at +100% to force-charge from grid, just at
a 1% target).
IDLE at the reserve floor (issue #592):
The battery_first hold above exists to protect stored energy from
self-consumption. At the configured minimum SoC there is none left to
protect, so the hold buys nothing — and because it keeps remote control
enabled, _apply_period_vpp rewrites the command every period to refresh
the fallback timer, so the inverter is never handed back and its BMS never
idles down. Reported on real hardware as a long overnight idle where the
battery and BMS stayed awake.
IDLE with the battery at (or below) min_soc therefore maps to
vpp_power=0, remote control disabled — released to the inverter's own
load_first self-use. Above the floor the battery_first hold is
unchanged, so #466 is preserved.
Releasing is chosen over the alternative of writing vpp_power=0 with
remote control still enabled (grid_first) because only the released form
is flow-neutral: load_first still absorbs passive solar surplus exactly as
the battery_first hold does, whereas grid_first holds against charging
and would bypass that surplus to the grid — a real change, since IDLE's DP
cost model does credit passive absorption. Verified across the fixture
corpus: the v10.0.2 VPP regression baseline's commands move at every
IDLE-at-floor period — 499 periods across 50 entries, [1, true] →
[0, false] — while realized cost and the SoE trajectory are bit-identical
(0.000000000000 on both halves of the pin). Commands changing with no energy
moving is precisely what flow-neutral means here; an unchanged baseline would
have meant the branch was never exercised, which is what an earlier revision
of this fix wrongly reported.
Caveat — the floor that actually binds is the inverter's own. How far
the battery can fall under released self-use is governed by the inverter's
discharge_stop_soc register, not by BESS's min_soc, and in VPP mode
BESS never writes that register (initialize_hardware returns before
sync_soc_limits — see issue #309). If the inverter's own floor sits below
the configured min_soc, released self-use can draw the gap between them.
This is not new to #592: LOAD_SUPPORT (#413) and SOLAR_STORAGE already
release control the same way at any SoC. vpp_simulator models the release
as a hold at min_soe_kwh, i.e. it assumes the two floors agree. Not yet
real-hardware-validated; ships experimental pending confirmation that the
BMS does sleep and that the battery does not discharge below the configured
minimum.
Enable sequence (real-hardware-tested, see issue #118 comments): write
vpp_status=Enabled + vpp_allow_ac_charging=Enabled, wait ~1s, then write
vpp_remote_control — VPP Remote Control has no effect while VPP Status is
disabled. State survives controller re-instantiation (BESS recreates the
controller each optimization cycle) by reading the VPP registers back from
hardware in read_and_initialize_from_hardware, the same pattern TOU mode
already uses — not class-level statics.
Out of scope: sub-period reactive power correction against a live P1/smart
meter reading (demonstrated in community forks of this feature) is not built
into BESS — BESS stays on its 15-minute period model. Users wanting tighter
self-consumption can add their own HA automation nudging growatt_vpp_power
between BESS's writes, using the sensor key above as the target entity.
Why VPP over TOU long-term: VPP's per-period writes
(growatt_vpp_power/growatt_vpp_time) target RAM-backed registers, safe to
rewrite every period. TOU mode's per-period rate control instead writes
ems_charging_rate/ems_discharging_rate, which are flash-backed — fine at
TOU's lower write frequency, but not something VPP mode should ever fall back
to, since it writes far more often. This is the reasoning behind the
"Path to deprecating TOU" plan above, not yet a recommendation: GEN4 default
stays "tou" until VPP is validated on real hardware (see the platform
maturity note at the top of this section).
Path to deprecating TOU: once GEN4 VPP is validated on real hardware, the
GEN4 default flips to "vpp", then the "tou" code path and setting are
removed entirely in a later release — no user migration needed, since this is
a setting inside the existing platform IDs, not a new platform ID.
SPH inverters use separate charge and discharge period lists (max 3 each) rather than TOU slots. Each write sets all periods at once with global power and SOC targets.
Schedule writes: HA service calls:
growatt_server.write_ac_charge_times(periods, power, stop_soc, mains_enabled)
growatt_server.write_ac_discharge_times(periods, power, stop_soc)
Per-period control: None — the growatt_server integration exposes no
number or switch entities for SPH models. All control (power rates, SOC
limits, grid charge) is embedded in the service call parameters.
SolaX inverters have no persistent TOU schedule. BESS issues VPP (Virtual Power Plant) commands at each 15-minute period boundary. Commands auto-expire after 1200 seconds, providing a safe fallback to self-use mode.
Per-period control (VPP):
select.select_option(power_control_mode: "Enabled Battery Control")
number.set_value(active_power: <watts>) # positive=charge, negative=discharge
number.set_value(autorepeat_duration: 1200)
button.press(trigger)
Idle/solar mode: Disables VPP, inverter reverts to self-use.
Solis hybrids, connected via the community
Pho3niX90/solis_modbus
integration (verified against release v4.1.6), share Growatt SPH's
SM-Period-lists scheduling model — separate charge and discharge period
lists — but Solis's "Grid Time of Use v2" schedule supports 6 charge
periods and 6 discharge periods (not SPH's 3+3), and each period is written
directly to HA time/switch entities rather than via a cloud service call.
Credit: based on SA7BNT's research and initial implementation in
bess-manager-beta PR #51.
Schedule writes: one time.set_value call for each slot's start and end,
plus one switch.turn_on/turn_off for its enable bit — for all 6 charge
slots and all 6 discharge slots, every time (full rewrite, unused slots get
00:00-00:00 + disabled):
time.set_value(entity: solis_charge_start_N, time: "HH:MM:00")
time.set_value(entity: solis_charge_end_N, time: "HH:MM:00")
switch.turn_on/turn_off(entity: solis_charge_enable_N)
# ...and the same for solis_discharge_{start,end,enable}_N, N = 1..6
Per-period control: none — Solis has no per-period charge/discharge rate
register exposed by solis_modbus (supports_charge_rate_control = False,
same limitation as SPH).
Verified integration bug (source-cited): SolisSensorGroup.__init__
(sensors/solis_base_sensor.py:254) calls
unique_id_generator(controller, entity) — passing the entire entity
definition dict instead of entity["unique"]. This means most read-only
sensors and all "editable" number entities (per-slot TOU current/cutoff-SOC,
global charge/discharge stop SOC) get a unique_id containing the Python
repr() of their whole definition dict, e.g. solis_modbus_SN123_{'name': 'Battery SOC', ..., 'unique': 'solis_modbus_inverter_battery_soc', ...} —
not a clean, endswith()-matchable suffix. Present in v4.1.6 (stable) and
unchanged on the integration's HEAD as of 2026-07-05. BESS works around this
with a Solis-only substring matcher
(_match_solis_dict_embedded_entities in ha_api_controller.py) that
checks for the verified 'unique': '<key>' fragment — this never touches
the shared _map_registry_entities suffix matching every other platform
uses. TOU period times (time.py) and per-slot enable switches
(solis_binary_sensor.py) go through the integration's correct
unique_id_generator call and are matched normally.
Known gaps in this first pass:
- Global charge/discharge stop SOC ("Max Charge SOC" / "Overdischarge SOC")
are affected by the same dict-embedded-unique_id bug and have no verified
write path yet —
sync_soc_limits()is an explicit no-op, not a silent fallback. pv_powermaps to PV string 1 only (dc_power_1); Solis hybrids with multiple MPPT strings will under-report total PV power until a summed sensor is added.import_powerandexport_powerboth resolve to the single signed "Grid Power Net" sensor (Solis exposes no separate import/export power entities).HomeAssistantAPIController.grid_power_polarity("import_positive"forsolis_modbus, set viaSettingsStore.get_grid_power_polarity()) splits the one raw reading by sign at read time: positive →import_power, negative →export_power. Not user-configurable — it's a fixed property of the platform, not install-specific.
Huawei LUNA2000 batteries use a persistent charge/discharge period list (max 14 periods) gated behind the working-mode select entity. BESS writes a combined list with separate charge and discharge periods, each specifying a time range and the number of periods effective.
Schedule writes: Single HA service call with atomically-deployed charge/discharge lists:
huawei_solar.set_tou_periods(device_id, charge_periods, discharge_periods, working_mode_settings="time_of_use_luna2000")
The service call is gated by a preflight check verifying the battery model via
get_huawei_working_mode_options() — LUNA2000 only; LG RESU batteries are
explicitly not supported (they use a price-bidding TOU format incompatible with
BESS's optimization model).
When no working-mode entity is mapped, that whole gate is skipped: BESS neither sets the working mode nor verifies the battery family, and logs both. This is the expected shape for an install behind an energy manager (EMMA), where the manager owns the mode. The health check reports WARNING rather than OK for such an install, since BESS is then trusting the operator's platform choice instead of checking it.
Compatible integrations under another domain. This set_tou_periods call
targets whichever domain inverter.service_domain resolves to (default
huawei_solar) — see "Bring-your-own integration" above for when and how to
change it.
Schedule readback (optional, #431). huawei_solar has no
read_tou_periods service, but its TOU period sensor publishes the
programmed periods as Period N extra state attributes, in the very text
format set_tou_periods accepts (HuaweiSolarTOUSensorEntity, sensor.py).
When the huawei_tou_periods sensor is mapped, BESS reads those in two
places, the same read-compare-write shape Growatt MIN adopted in #551/#552:
- at startup, to initialise the period list from what the battery actually
holds, so a restart doesn't rewrite a schedule already running. The periods
carry no strategic intent (the battery reports only charge/discharge
flags), so they display as
existing_scheduleuntil the first optimization. - before every write, comparing the plan against a fresh read rather than
against BESS's own model of the battery — a model can only ever claim what
BESS meant to write.
set_tou_periodsrewrites the whole list atomically and cannot update just what moved, so every skipped write is a flash-wear event spared. Paths that write without consultingevaluate_intents(a retried write, the corruption flag, the 23:55 next-day preparation) go through this check too.
An empty plan still writes: the empty string is how BESS clears periods the battery is otherwise left running.
Readback is enabled by the sensor being mapped, never by attempting the
read and catching failure. Installs whose integration exposes no such entity
keep the original behaviour: start with an empty period list, converge on the
first cycle. If the entity is mapped but unreadable, that raises rather than
reading as "no periods programmed" — the two must not look alike, or BESS
would skip a write it genuinely needs. That read is the first thing
sync_to_hardware does, ahead of the working-mode and grid-charge writes, so
a failed read aborts the cycle with the battery untouched instead of arming
grid charging against the period list the old plan left behind; BSM's
_hardware_write_pending then retries the whole sync next cycle.
days is part of a period's identity in that comparison. BESS always writes
all-days (1234567) periods, so a period programmed elsewhere for a subset
of weekdays is not a match for BESS's own period even at identical times.
EMMA's equivalent register (emma_tou_periods) uses the same period format
but its entity is disabled by default upstream, so it is deliberately left
out of HUAWEI_SUFFIX_MAP — auto-discovery would otherwise map a disabled,
stateless entity. EMMA users who enable it can map it by hand in Settings.
Scheduling model: Charge periods are flagged GRID_CHARGING intents;
discharge periods are flagged LOAD_SUPPORT or BATTERY_EXPORT intents.
Periods without an explicit flag (SOLAR_STORAGE, SOLAR_EXPORT, IDLE) use the
inverter's default self-consumption mode.
Per-period control: None — all control (power, SOC limits, working mode) is embedded in the service call parameters or requires manual inverter configuration.
Note on open items: The LUNA2000's days_effective digit convention
(mapping "1234567" to day-of-week slots) and out-of-period battery behavior
remain unverified on real hardware — currently specified per the huawei_solar
integration's source code (services.py). See
docs/superpowers/specs/2026-07-22-issue-120-huawei-inverter-platform-design.md
for design rationale and open items.
SolaX native, Solis and Huawei LUNA2000 expose no lifetime load-consumption register. For those three, BESS derives it from the energy balance:
load = solar + grid_import + battery_discharged − battery_charged − grid_export
All three map both battery counters, so the derivation is always computable
where it is used. If any of the five inputs is unmapped, BESS returns no
value rather than a partial one — dropping the battery terms would report
load plus net battery charge, a kWh-scale error on every period the battery is
active (issue #528). The identity lives in one place,
core/bess/energy_balance.py, and is shared by the lifetime-sensor path
(ha_api_controller) and the per-period flow path (energy_flow_calculator).
Every Growatt variant maps a native load register and never takes this path.
| BESS Sensor Key | Entity Type | Growatt Server Suffix | Purpose |
|---|---|---|---|
battery_soc |
sensor | state_of_charge_soc |
Current battery level |
battery_charge_power |
sensor | battery_1_charging_w |
Charge power (W) |
battery_discharge_power |
sensor | battery_1_discharging_w |
Discharge power (W) |
import_power |
sensor | import_power |
Grid import (W) |
export_power |
sensor | export_power |
Grid export (W) |
pv_power |
sensor | internal_wattage |
Solar production (W) |
local_load_power |
sensor | local_load_power |
Home consumption (W) |
grid_charge |
switch | charge_from_grid |
Grid charge enable |
battery_charging_power_rate |
number | battery_charge_power_limit |
Charge rate (%) |
battery_discharging_power_rate |
number | battery_discharge_power_limit |
Discharge rate (%) |
battery_charge_stop_soc |
number | battery_charge_soc_limit |
Max SOC target |
battery_discharge_stop_soc |
number | battery_discharge_soc_limit |
Min SOC target |
Lifetime energy (optional but recommended):
| BESS Sensor Key | Growatt Server Suffix |
|---|---|
lifetime_battery_charged |
lifetime_total_all_batteries_charged |
lifetime_battery_discharged |
lifetime_total_all_batteries_discharged |
lifetime_solar_energy |
lifetime_total_solar_energy |
lifetime_export_to_grid |
lifetime_total_export_to_grid |
lifetime_import_from_grid |
lifetime_import_from_grid |
lifetime_load_consumption |
lifetime_total_load_consumption |
The growatt_server integration exposes no number or switch entities for
SPH models. All control (power, SOC, grid charge, time periods) is via
write_ac_charge_times and write_ac_discharge_times service calls.
Monitoring sensors (required):
| BESS Sensor Key | Entity Type | Growatt Server Suffix | Purpose |
|---|---|---|---|
battery_soc |
sensor | state_of_charge_soc |
Current battery level |
battery_charge_power |
sensor | battery_1_charging_w |
Charge power (W) |
battery_discharge_power |
sensor | battery_1_discharging_w |
Discharge power (W) |
import_power |
sensor | import_power |
Grid import (W) |
export_power |
sensor | export_power |
Grid export (W) |
pv_power |
sensor | internal_wattage |
Solar production (W) |
local_load_power |
sensor | local_load_power |
Home consumption (W) |
Lifetime energy (optional but recommended):
| BESS Sensor Key | Growatt Server Suffix |
|---|---|
lifetime_battery_charged |
lifetime_total_all_batteries_charged |
lifetime_battery_discharged |
lifetime_total_all_batteries_discharged |
lifetime_solar_energy |
lifetime_total_solar_energy |
lifetime_export_to_grid |
lifetime_total_export_to_grid |
lifetime_import_from_grid |
lifetime_import_from_grid |
lifetime_load_consumption |
lifetime_total_load_consumption |
Monitoring and EMS control (GEN4):
| BESS Sensor Key | Entity Type | solax_modbus Suffix | Purpose |
|---|---|---|---|
battery_soc |
sensor | battery_soc |
Current battery level |
battery_charge_power |
sensor | battery_charge_power |
Charge power (W) |
battery_discharge_power |
sensor | battery_discharge_power |
Discharge power (W) |
import_power |
sensor | total_forward_power |
Grid import (W) |
export_power |
sensor | total_reverse_power |
Grid export (W) |
pv_power |
sensor | pv_power_1 |
Solar production (W) |
local_load_power |
sensor | total_load_power |
Home consumption (W) |
grid_charge |
select | charger_switch |
Grid charge enable (Enabled/Disabled) |
battery_charging_power_rate |
number | ems_charging_rate |
Charge rate (%) |
battery_discharging_power_rate |
number | ems_discharging_rate |
Discharge rate (%) |
battery_charge_stop_soc |
number | ems_charging_stop_soc |
Max SOC target |
battery_discharge_stop_soc |
number | ems_discharging_stop_soc |
Min SOC target |
TOU time slot control (slot 1 only, 5 entities):
| BESS Sensor Key | Entity Type | solax_modbus Key (unique_id) | HA Entity ID Contains | Purpose |
|---|---|---|---|---|
tou_time_1_enabled |
select | time_1_enabled |
time_1_active |
Slot active (Enabled/Disabled) |
tou_time_1_begin |
select | time_1_begin |
time_1_begin |
Start time (HH:MM) |
tou_time_1_end |
select | time_1_end |
time_1_end |
End time (HH:MM) |
tou_time_1_mode |
select | time_1_mode |
time_1_mode |
Battery First/Load First/Grid First |
tou_time_1_update |
button | time_1_update |
time_1_update |
Commit slot changes |
Only slot 1 is required. Slots 2-9 entities still exist in the suffix map for
backward compatibility (discovery will pick them up if enabled), but BESS only
actively uses slot 1. A time_N_clear button also exists in the plugin
(zeros out the slot) but is not used by BESS.
Note: The
entity_idfor the enabled/disabled entity containsactive(from the plugin's display name "Time N Active") while theunique_idcontainsenabled(from the plugin's internal key). BESS matches onunique_id, so the suffix map usestime_N_enabled.Slot availability: Slots 1-3 are enabled by default in HA. Slots 4-9 are disabled by default in the entity registry and must be manually enabled in HA before BESS can discover or use them.
Lifetime energy (GEN4, optional):
| BESS Sensor Key | solax_modbus Suffix | Notes |
|---|---|---|
lifetime_battery_charged |
total_battery_input_energy |
|
lifetime_battery_discharged |
total_battery_output_energy |
|
lifetime_solar_energy |
total_solar_energy |
|
lifetime_import_from_grid |
total_grid_import |
|
lifetime_export_to_grid |
total_grid_export |
|
lifetime_system_production |
total_power_generation |
GEN4 register 3051 |
lifetime_load_consumption |
total_yield |
GEN4 register 3077, "Total Load Energy" |
Export-limit curtailment (GEN4, optional):
| BESS Sensor Key | Entity Type | solax_modbus Suffix | Purpose |
|---|---|---|---|
growatt_export_limit_mode |
select | limit_grid_export |
Meter/CT selection (Disabled/Meter 1/Meter 2/CT Clamp), register 122 |
growatt_export_limit_value |
number | grid_export_limit |
Export limit percentage, register 123 |
Monitoring and EMS control (GEN3):
| BESS Sensor Key | Entity Type | solax_modbus Suffix | Purpose |
|---|---|---|---|
battery_soc |
sensor | battery_soc |
Current battery level |
battery_charge_power |
sensor | battery_charge_power |
Charge power (W) |
battery_discharge_power |
sensor | battery_discharge_power |
Discharge power (W) |
import_power |
sensor | ac_power_to_user |
Grid import (W) |
export_power |
sensor | ac_power_to_grid |
Grid export (W) |
pv_power |
sensor | pv_power_total |
Solar production (W) |
local_load_power |
sensor | total_load_power |
Home consumption (W) |
grid_charge |
select | battery_first_charge_from_grid |
Grid charge enable |
battery_charging_power_rate |
number | battery_first_charge_rate |
Charge rate (battery-first mode) |
battery_discharging_power_rate |
number | grid_first_discharge_rate |
Discharge rate (grid-first mode) |
battery_charge_stop_soc |
number | battery_first_maximum_soc |
Max SOC target |
battery_discharge_stop_soc |
number | load_first_battery_minimum_soc |
Min SOC target |
Lifetime energy (GEN3, optional):
| BESS Sensor Key | solax_modbus Suffix | Notes |
|---|---|---|
lifetime_battery_charged |
total_battery_input_energy |
Register 1058 |
lifetime_battery_discharged |
total_battery_output_energy |
Register 1054 |
lifetime_solar_energy |
total_solar_energy |
|
lifetime_import_from_grid |
total_grid_import |
Register 1046 |
lifetime_export_to_grid |
total_grid_export |
Register 1050 |
lifetime_load_consumption |
total_load |
Register 1062 |
lifetime_system_production |
— | No native register. BESS derives from lifetime_solar_energy |
Export-limit curtailment (GEN3, optional): same registers/entities as
GEN4 above (limit_grid_export / grid_export_limit, registers 122/123) —
plugin_growatt.py marks these allowedtypes=GEN2|GEN3|GEN4, not GEN4-only.
Monitoring:
| BESS Sensor Key | Entity Type | solax_modbus Suffix | Purpose |
|---|---|---|---|
battery_soc |
sensor | battery_capacity |
Current battery level |
battery_charge_power / battery_discharge_power |
sensor | battery_power_charge (signed) |
Battery power (W); both keys map to this entity, split by sign at read time (battery_power_polarity, "charge_positive" — issue #542) |
import_power |
sensor | measured_power |
Grid import (W) |
export_power |
sensor | grid_export |
Grid export (W) |
pv_power |
sensor | pv_power_1 |
Solar production (W) |
local_load_power |
sensor | house_load |
Home consumption (W) |
Lifetime energy (optional):
| BESS Sensor Key | solax_modbus Suffix | Notes |
|---|---|---|
lifetime_battery_charged |
battery_input_energy_total |
|
lifetime_battery_discharged |
battery_output_energy_total |
|
lifetime_solar_energy |
total_solar_energy |
|
lifetime_import_from_grid |
grid_import_total |
|
lifetime_export_to_grid |
grid_export_total |
|
lifetime_system_production |
total_yield |
Register 0x52, "Total Yield" (production) |
lifetime_load_consumption |
— | No native register. BESS derives it — see Derived load consumption |
VPP control (required for SolaX):
| BESS Sensor Key | Entity Type | solax_modbus Suffix | Purpose |
|---|---|---|---|
solax_power_control_mode |
select | remotecontrol_power_control |
Enable/disable VPP |
solax_active_power |
number | remotecontrol_active_power |
Power target (W) |
solax_autorepeat_duration |
number | remotecontrol_autorepeat_duration |
Command timeout (s) |
solax_power_control_trigger |
button | remotecontrol_trigger |
Execute command |
solax_battery_min_soc |
number | battery_minimum_capacity |
Min battery SOC (%) |
solax_charger_use_mode |
select | charger_use_mode |
Charger use mode (optional) |
Monitoring:
| BESS Sensor Key | Entity Type | solis_modbus unique_id (verified) | Purpose |
|---|---|---|---|
battery_soc |
sensor | dict-embedded: 'unique': 'solis_modbus_inverter_battery_soc' |
Current battery level |
battery_charge_power |
sensor | solis_modbus_inverter_battery_charge_power (derived, clean) |
Charge power (W) |
battery_discharge_power |
sensor | solis_modbus_inverter_battery_discharge_power (derived, clean) |
Discharge power (W) |
import_power / export_power |
sensor | solis_modbus_inverter_grid_power_net (derived, clean, signed) |
Net grid power (W); both keys map to this entity, split by sign at read time (grid_power_polarity) |
pv_power |
sensor | solis_modbus_inverter_dc_power_1 (derived, clean) |
PV string 1 power (W) — see known gaps above |
local_load_power |
sensor | dict-embedded: 'unique': 'solis_modbus_inverter_household_load_power' |
Home consumption (W) |
Lifetime energy (optional, all dict-embedded — see "Verified integration bug" above):
| BESS Sensor Key | solis_modbus unique key |
Notes |
|---|---|---|
lifetime_battery_charged |
solis_modbus_inverter_total_battery_charge_energy |
|
lifetime_battery_discharged |
solis_modbus_inverter_total_battery_discharge_energy |
|
lifetime_solar_energy |
solis_modbus_inverter_pv_total_generation |
|
lifetime_import_from_grid |
solis_modbus_inverter_total_energy_imported_from_grid |
|
lifetime_export_to_grid |
solis_modbus_inverter_total_energy_fed_into_grid |
Grid Time of Use v2 schedule (required, 6 charge + 6 discharge slots):
| BESS Sensor Key | Entity Type | solis_modbus unique_id | Purpose |
|---|---|---|---|
solis_charge_start_N (N=1-6) |
time | time_entity_{register} (registers 43711/43718/43725/43732/43739/43746) |
Charge slot N start |
solis_charge_end_N |
time | time_entity_{register} (43713/43720/43727/43734/43741/43748) |
Charge slot N end |
solis_charge_enable_N |
switch | {register}_{bit} = 43707_0..43707_5 |
Charge slot N enable |
solis_discharge_start_N |
time | time_entity_{register} (43753/43760/43767/43774/43781/43788) |
Discharge slot N start |
solis_discharge_end_N |
time | time_entity_{register} (43755/43762/43769/43776/43783/43790) |
Discharge slot N end |
solis_discharge_enable_N |
switch | {register}_{bit} = 43707_6..43707_11 |
Discharge slot N enable |
Only slot 1 of each direction is strictly required; slots 2-6 are optional (unused slots are simply left disabled by BESS).
Monitoring and schedule control:
| BESS Sensor Key | Entity Type | huawei_solar Suffix | Purpose |
|---|---|---|---|
battery_soc |
sensor | storage_state_of_capacity |
Current battery level (%) |
battery_charge_power / battery_discharge_power |
sensor | storage_charge_discharge_power (signed, reg 37765) |
Battery power (W); both keys map to this entity, split by sign at read time (battery_power_polarity, "charge_positive" — issue #542) |
battery_charging_power_rate |
number | storage_maximum_charging_power |
Max charge power (W) |
battery_discharging_power_rate |
number | storage_maximum_discharging_power |
Max discharge power (W) |
battery_charge_stop_soc |
number | storage_charging_cutoff_capacity |
Charge stop SOC (%) |
battery_discharge_stop_soc |
number | storage_grid_charge_cutoff_state_of_charge |
Discharge stop SOC (%) |
grid_charge |
switch | storage_charge_from_grid_function |
Grid charge enable |
huawei_working_mode |
select | storage_working_mode_settings |
Battery working mode (gating TOU writes) |
huawei_tou_periods |
sensor | storage_huawei_luna2000_time_of_use_charging_and_discharging_periods |
Optional: programmed TOU periods, read back at startup (#431) |
local_load_power |
sensor | active_power |
Home consumption (W) |
pv_power |
sensor | input_power |
Real-time solar PV power (W) |
import_power / export_power |
sensor | power_meter_active_power (separate power-meter device, signed) |
Net grid power (W); both keys map to this entity, split by sign at read time (grid_power_polarity, "export_positive" — issue #438) |
Lifetime energy: see HUAWEI_SUFFIX_MAP in ha_api_controller.py for the
five lifetime-energy suffixes added in #471/#473 (not reproduced here to
avoid duplicating a list that will drift — the suffix map is the source of
truth). These map to the same five core energy sensors EnergyFlowCalculator
requires on every platform (energy_flow_calculator.py), so they're required
for BESS to compute energy flows on Huawei too, same as elsewhere — not
optional. grid_exported_energy/grid_accumulated_energy specifically come
from the separate power-meter device (see the real-time grid-power row
above); an install with no power meter genuinely cannot report them, and the
health check correctly reports that as an error rather than silently
reporting OK.
lifetime_solar_energy is total_dc_input_power, not accumulated_yield_energy
(#569) — do not "correct" it back. The obvious-looking candidate,
accumulated_yield_energy (reg 32106, entity "Total yield"), is the inverter's
accumulated AC output: on a LUNA2000 hybrid it rises while the battery
discharges and misses everything used to charge it, which upstream states
outright in its README FAQ. Feeding that to derive_load_consumption's
five-term balance inflates home consumption — and therefore grid_only_cost
and reported savings — by battery_discharged - solar_to_battery, silently,
because the lifetime total stays positive and the health check still passes.
total_dc_input_power (reg 32108, entity "Total DC input energy") is the
lifetime integral of reg 32064, already mapped to pv_power above. It is
DC-side, so it excludes inverter conversion losses (a systematic ~2-3% bias on
the solar term); Huawei exposes no AC-side PV total at all, so that residual is
irreducible. FusionSolar's own reconstruction
(yield - discharge + charge) is not a valid alternative here: it assumes
all battery charge came from PV, and BESS grid-charges for arbitrage, so it
would report grid-charged energy as solar production.
Note the suffix collision this creates: ..._total_dc_input_power also ends in
_input_power. The two stay apart only because _map_registry_entities sorts
suffixes longest-first before breaking on the first match.
Auto-detection: HUAWEI_SUFFIX_MAP is wired into discover_sensors_from_registry
(the same production entity-registry scan every other platform uses — fixed
in #438; previously this map had no caller there and every Huawei sensor
required manual entity entry). Presence of huawei_solar integration entities
with the storage_working_mode_settings unique_id suffix triggers Huawei
platform detection; the setup wizard confirms the battery model is LUNA2000
via get_huawei_working_mode_options() before proceeding.
BESS auto-detects the inverter platform during setup by scanning the HA entity registry:
-
Growatt Server detected (
platform: growatt_server):- If
growatt_server.update_time_segmentservice exists → Growatt MIN (Cloud) - If
growatt_server.write_ac_charge_timesservice exists → Growatt SPH (Cloud)
- If
-
solax_modbus detected (
platform: solax_modbus):- If
time_1_enabledunique_id suffix found → Growatt MIN (Local) — GEN4 - Else if
load_first_battery_minimum_socunique_id suffix found → Growatt MIX/SPH (Local) — GEN3 - Else if VPP entities present (
remotecontrol_power_control) → SolaX
Detection uses
unique_id(built from the plugin's internalkeyfield), notentity_id(built from displayname). For Growatt TOU entities the unique_id ends withtime_1_enabledeven though the entity_id containstime_1_active. - If
-
solis_modbus detected (
platform: solis_modbus, its own dedicated integration domain — no sub-variant disambiguation needed): confirmed further by checking for thetime_entity_43711unique_id suffix (Grid Time of Use v2 Charge Start, Slot 1) — if absent, the installed inverter/firmware lacks the v2 schedule and schedule control is unavailable (monitoring sensors are still mapped). -
huawei_solar detected (
platform: huawei_solar):- If
storage_working_mode_settingsunique_id suffix found → Probeget_huawei_working_mode_options(device_id)to verify LUNA2000 model - If confirmed LUNA2000 → Huawei LUNA2000 (Local)
- Else (LG RESU or other unsupported model) → Not supported
- If
If multiple platforms are detected (e.g. both Growatt and SolaX entities exist), the Settings page under Integrations & Sensors → Inverter Platform allows selecting between the detected options. Only platforms with matching entities in the HA registry are available for selection.
| Growatt Server (Cloud) | solax_modbus (Local) | |
|---|---|---|
| Connection | Internet → Growatt cloud → inverter | LAN → Modbus TCP/RTU → inverter |
| Latency | 5-30 seconds | < 1 second |
| Reliability | Depends on Growatt cloud availability | Independent of internet |
| Setup | Built-in HA integration, token auth | HACS integration, Modbus config |
Both options provide identical BESS functionality (9-slot TOU scheduling, per-period grid charge control, SOC limits).