llmwiki/40-hardware/registers.mdis the current ground truth for what a register means. Every claim there carries an explicit evidence grade (firmware-proven,hardware-toggle-proven,portal-correlated,lineage-inferred, …). Most register-semantic claims there are correlations or inferences rather than proof — and a correlation looks identical to proof once it is restated without its grade. The ledger publishes the exact proven-of-total count and the arithmetic behind it; read it there rather than anywhere else, including here.This document's register sections are subordinate to that ledger. They describe how the integration currently decodes and drives each register — implementation reality, useful for changing code. They are not evidence of hardware semantics, and they are ungraded. Where the two disagree, the ledger wins; where this file states a meaning the ledger has not proven, treat it as unproven regardless of how confident the prose sounds. Never promote a claim from here into a safety or write decision without checking its grade there.
The non-register content here — Cloud API field mappings (§6), computed sensor keys (§9), mode differences (§10), smart-port filtering (§11), the GridBOSS CT overlay (§12), entity counts (§13), constants (§14) and calculations (§15) — has no counterpart in the ledger and remains canonical in this file.
Consult this document whenever working with register-to-sensor or API-to-sensor mappings.
Cloud API surface: for the full endpoint/request/response contract of the EG4 monitor cloud API, see the reverse-engineered OpenAPI 3.1 spec and reference at
docs/api/openapi.yamlanddocs/api/README.md(44 endpoints / 55 schemas; includes the firmware update lifecycle and theWAITINGstatus handling from #353). This document remains canonical for the scaling and register-to-sensor mappings the OpenAPI spec does not cover.
- Architecture Overview
- Inverter Input Registers
- Inverter Holding Registers (Parameters)
- GridBOSS Input Registers
- GridBOSS Holding Register 20 (Smart Port Status) — includes the smart port option registers
- Cloud API Field Mappings
- Individual Battery Data
- Parallel Group Data
- Computed / Derived Sensor Keys
- Mode Differences
- Smart Port Sensor Filtering
- GridBOSS CT Overlay
- Entity Counts by Mode
- Key Constants Reference
- All Calculations Reference
- Data Validation
Modbus Register Read (function codes 0x03/0x04)
|
v
pylxpweb: _canonical_reader.py → read_raw() / read_scaled()
|
v
pylxpweb: data.py → InverterRuntimeData.from_modbus_registers()
→ InverterEnergyData.from_modbus_registers()
→ BatteryBankData / BatteryData
→ MidboxRuntimeData.from_modbus_registers()
|
v
eg4_web_monitor: coordinator_mappings.py
→ _build_runtime_sensor_mapping(runtime_data)
→ _build_energy_sensor_mapping(energy_data)
→ _build_battery_bank_sensor_mapping(battery_data)
→ _build_individual_battery_mapping(battery)
→ _build_gridboss_sensor_mapping(mid_device)
|
v
Sensor Dict {sensor_key: value} → Home Assistant Entity
Cloud API HTTP POST → JSON Response
|
v
pylxpweb: LuxpowerClient → JSON dict
|
v
eg4_web_monitor: coordinator_http.py
→ _map_device_properties(json_dict, FIELD_MAPPING)
→ const/sensors/mappings.py → INVERTER_RUNTIME_FIELD_MAPPING
→ GRIDBOSS_FIELD_MAPPING
→ Parallel groups: coordinator_mixins._get_parallel_group_property_map()
→ Applies scaling (÷10, ÷100) per DIVIDE_BY_10_SENSORS etc.
|
v
Sensor Dict {sensor_key: value} → Home Assistant Entity
LOCAL (Modbus) data ─────────────┐
├──→ Merged Sensor Dict
CLOUD (HTTP) data ───────────────┘
|
v
apply_gridboss_overlay() merges GridBOSS CT data onto parallel group
|
v
Home Assistant Entity
| Layer | Component | Responsibility |
|---|---|---|
| Transport | pylxpweb _register_data.py |
Read raw Modbus registers, group reads |
| Canonical | pylxpweb _canonical_reader.py |
Apply scale factors, handle 32-bit pairs |
| Data Model | pylxpweb data.py |
Transport-agnostic dataclasses (InverterRuntimeData, etc.) |
| Field Maps | pylxpweb _field_mappings.py |
Canonical register name → data model field name |
| HTTP Maps | eg4_web_monitor const/sensors/mappings.py |
Cloud API JSON field → HA sensor key |
| Coordinator Maps | eg4_web_monitor coordinator_mappings.py |
Data model property → HA sensor key |
| Sensor Platform | eg4_web_monitor sensor.py |
Sensor key → HA entity with metadata |
All input registers use Modbus function code 0x04 (read-only).
Definitions: pylxpweb/registers/inverter_input.py (canonical source of truth)
Mapping chain: Register → _canonical_reader.read_scaled() → InverterRuntimeData field
→ _build_runtime_sensor_mapping() → HA sensor key
| Reg | Canonical Name | Scale | Unit | pylxpweb Field | HA Sensor Key |
|---|---|---|---|---|---|
| 0 | device_status |
1 | - | device_status |
status_code |
| 1 | pv1_voltage |
÷10 | V | pv1_voltage |
pv1_voltage |
| 2 | pv2_voltage |
÷10 | V | pv2_voltage |
pv2_voltage |
| 3 | pv3_voltage |
÷10 | V | pv3_voltage |
pv3_voltage |
| 4 | battery_voltage |
÷10 | V | battery_voltage |
battery_voltage |
| 5 | soc_soh_packed |
1 | % | battery_soc / battery_soh |
state_of_charge |
| 7 | pv1_power |
1 | W | pv1_power |
pv1_power |
| 8 | pv2_power |
1 | W | pv2_power |
pv2_power |
| 9 | pv3_power |
1 | W | pv3_power |
pv3_power |
| 10 | charge_power |
1 | W | battery_charge_power |
battery_charge_power |
| 11 | discharge_power |
1 | W | battery_discharge_power |
battery_discharge_power (entity EG4_OFFGRID-only, #197) |
| 12 | grid_voltage_r |
÷10 | V | grid_voltage_r |
grid_voltage_r |
| 13 | grid_voltage_s |
÷10 | V | grid_voltage_s |
grid_voltage_s |
| 14 | grid_voltage_t |
÷10 | V | grid_voltage_t |
grid_voltage_t |
| 15 | grid_frequency |
÷100 | Hz | grid_frequency |
grid_frequency |
| 16 | inverter_power |
1 | W | inverter_power |
ac_power |
| 17 | rectifier_power |
1 | W | rectifier_power |
rectifier_power (Prec; NOT grid power — eg4-9wf) |
| 18 | inverter_rms_current_r |
÷100 | A | inverter_rms_current_r |
grid_current_l1 |
| 20 | eps_voltage_r |
÷10 | V | eps_voltage_r |
eps_voltage_r |
| 21 | eps_voltage_s |
÷10 | V | eps_voltage_s |
eps_voltage_s |
| 22 | eps_voltage_t |
÷10 | V | eps_voltage_t |
eps_voltage_t |
| 23 | eps_frequency |
÷100 | Hz | eps_frequency |
eps_frequency |
| 24 | eps_power |
1 | W | eps_power |
eps_power |
| 26 | power_to_grid |
1 | W | power_to_grid |
grid_export_power |
| 27 | power_to_user |
1 | W | power_from_grid |
grid_import_power |
Split-Phase Registers (EG4_HYBRID / EG4_OFFGRID only):
| Reg | Canonical Name | Scale | Unit | pylxpweb Field | HA Sensor Key |
|---|---|---|---|---|---|
| 127 | eps_l1_voltage |
÷10 | V | eps_l1_voltage |
eps_voltage_l1 |
| 128 | eps_l2_voltage |
÷10 | V | eps_l2_voltage |
eps_voltage_l2 |
| 129 | eps_l1_power |
1 | W | eps_l1_power |
eps_power_l1 (COMBINED backup-path leg — see #335 note) |
| 130 | eps_l2_power |
1 | W | eps_l2_power |
eps_power_l2 (COMBINED backup-path leg — see #335 note) |
| 170 | output_power |
1 | W | output_power |
output_power; also load_power (EG4_OFFGRID-only, #197) |
| 193 | grid_l1_voltage |
÷10 | V | grid_l1_voltage |
grid_voltage_l1 (suppressed when 0) |
| 194 | grid_l2_voltage |
÷10 | V | grid_l2_voltage |
grid_voltage_l2 (suppressed when 0) |
EG4_OFFGRID confirmed registers (issue #197): live Modbus sweep + cloud cross-reference on a 12000XP (device type 54) validated reg 170 as load power (
Ploadin the 6kXP Modbus PDF — valid both grid-tied AND in EPS mode). The cloud zeroes its reg-170 mirror for EG4_OFFGRID, soload_powercomes from the LOCAL register only (LOCAL mapping + HYBRID_TRANSPORT_OVERLAY; absent in pure CLOUD).load_power,battery_discharge_powerand the backup-output-split sensors below are gated to EG4_OFFGRID viaOFFGRID_ONLY_SENSORSinconst/device_types.py.Backup-output split (issues #222/#335): on the 6000XP/12000XP the GEN terminal can be a smart-load output, and
peps/pEpsL1N/pEpsL2N(regs 129/130 locally) carry the COMBINED backup-path output (smart load + EPS loads) — live evidence: L1+L2 = 3371 W = cloudsmartLoadPower2999 W +epsLoadPower365 W. The cloud-only split is exposed assmart_load_power/grid_load_power/eps_load_powersensors (CLOUD/HYBRID supplemental, EG4_OFFGRID-gated; no validated local register — the 18kPV firmware RE names input reg 232smart_load_powerbut it has never been observed non-zero and is unvalidated on off-grid hardware, and regs 129/130 are the combined legs, not theepsLoadPowersubset). The formereps_load_power_l1/_l2sensors and the L1+L2eps_load_powersum (#197) were RETIRED in #335: they aliased the combined regs 129/130 values and so exactly duplicatedeps_power_l1/l2— the #197 "sum ≈ cloud epsLoadPower" validation was a smart-load-idle coincidence.eps_load_powernow maps the real cloudepsLoadPowerfield via the pylxpwebeps_load_powerproperty (pylxpweb ≥0.9.36).
Note: Regs 193-204 (grid/generator per-leg voltage + per-leg power) are firmware-zero on EG4 US split-phase inverters — confirmed live across the full block on both 18kPV (dtc 2092) and FlexBOSS21 (dtc 10284) while producing. The inverter measures only aggregate grid voltage (reg 12 ≈ 240 V) and its own per-leg EPS output (regs 127/128); per-leg grid voltage is a GridBOSS CT measurement (GridBOSS regs 4/5) and is not plumbed to the inverter at all (EG4's cloud API has no inverter grid-per-leg field —
gridL1RmsVoltlives only onMidboxData). The coordinator therefore drops per-invertergrid_voltage_l1/l2when the register reads 0/None (drop_dead_inverter_grid_legs) so the entity stays unavailable instead of publishing a misleading 0; a genuine non-zero reading still flows through, so no claim is made about other topologies. Real per-leg grid voltage is published on the GridBOSS and parallel-group entities (issue #243 follow-up).
Three-Phase Registers (LXP only):
| Reg | Canonical Name | Scale | Unit | pylxpweb Field | HA Sensor Key |
|---|---|---|---|---|---|
| 190 | inverter_rms_current_s |
÷100 | A | inverter_rms_current_s |
grid_current_l2 |
| 191 | inverter_rms_current_t |
÷100 | A | inverter_rms_current_t |
grid_current_l3 |
| Reg | Canonical Name | Scale | Unit | pylxpweb Field | HA Sensor Key |
|---|---|---|---|---|---|
| 121 | generator_voltage |
÷10 | V | generator_voltage |
generator_voltage |
| 122 | generator_frequency |
÷100 | Hz | generator_frequency |
generator_frequency |
| 123 | generator_power |
1 | W | generator_power |
generator_power (EG4_HYBRID only — see below) |
| 124 | generator_energy_today |
÷10 | kWh | generator_energy_today |
generator_energy (not energy on EG4_OFFGRID) |
| 125/126 | generator_energy_total |
÷10 | kWh | generator_energy_total |
generator_energy_lifetime (not energy on EG4_OFFGRID) |
| 195 | generator_l1_voltage |
÷10 | V | generator_l1_voltage |
generator_voltage_l1 (cloud genVoltL1) |
| 196 | generator_l2_voltage |
÷10 | V | generator_l2_voltage |
generator_voltage_l2 (cloud genVoltL2) |
Note: regs 195/196 carry
ha_sensor_key=Noneon their pylxpwebRegisterDefinition, but the sensors still exist — the coordinator maps them explicitly from the pylxpweb properties (coordinator_mappings.py), so the register's ownha_sensor_keyis not the whole story for this pair.
⚠️ EG4_OFFGRID: register 123 is NOT generator power. Proven from the 12000XP firmware (ceaa-0709, issue #544): the FC04 handler at0x0801E9CAreturnsRAM16[0x2000D70A], an ARM-local 16-bit counter incremented once per second at0x08018BDAwith no bound check, so it wraps at 65536. It is never written from DSP data. Registers 124/125/126 are likewise ARM-local status words (0x2000DB49/0x2000DB51and the two halves ofRAM32[0x2000D890]), not accumulators — the "135,494.5 kWh" lifetime figure is the bitfield0x0014ACC1.The generator legs themselves are instrumented on off-grid: 121, 122, 195 and 196 all read the DSP receive-frame block (
0x2000CE5Cbase) and correctly report 0 with no generator attached. There is no dedicated generator-power register in the off-grid dispatcher (188/189 are unimplemented); input 17 + input 27 are the untested candidates for a real substitute.On EG4_HYBRID register 123 is genuine —
low16(int16[0x2000EAE4] − int16[0x2000EAE6]), both DSP-fed — and on a GridBOSS parallel system the two inverters' values sum to the GridBOSS AC-Couple-1 total within 0.13%. Any suppression must be family-gated.Full derivation with firmware addresses:
reference/firmware/OFFGRID_GENERATOR_REGISTERS.md.
| Reg | Canonical Name | Scale | Unit | HA Sensor Key |
|---|---|---|---|---|
| 38 | bus_voltage_1 |
÷10 | V | bus1_voltage |
| 39 | bus_voltage_2 |
÷10 | V | bus2_voltage |
| Reg | Canonical Name | Scale | Unit | pylxpweb Field | HA Sensor Key |
|---|---|---|---|---|---|
| 60-61 | fault_code |
1 | - | fault_code |
fault_code |
| 62-63 | warning_code |
1 | - | warning_code |
warning_code |
Note (eg4-23a6): Raw 32-bit bitfields surfaced as diagnostic sensors (state
0= no fault/warning).from_modbus_registersmerges the BMS codes (regs 99/100, canonicalbms_fault_code/bms_warning_code) into the inverter-level fields as a fallback when the inverter code reads 0, so the dataclass fields — and therefore the sensors — carry the combined value. LOCAL/HYBRID only: the cloudgetInverterRuntimeresponse has nofaultCode/warningCodefield (canonical tablecloud_api_field=None), so the keys flow through the LOCAL runtime table plus the HYBRID_TRANSPORT_OVERLAYand are correctly absent in pure CLOUD mode. pylxpweb also exposes decoded text viaInverterRuntimeData.fault_messages/warning_messages(pylxpweb.constants.fault_codes); the sensors deliberately publish the raw numeric code.
| Reg | Canonical Name | Scale | Unit | HA Sensor Key |
|---|---|---|---|---|
| 64 | internal_temperature |
1 | C | internal_temperature (constant-0 on some hardware; see the caveat below) |
| 65 | radiator_temperature_1 |
1 | C | radiator1_temperature |
| 66 | radiator_temperature_2 |
1 | C | radiator2_temperature |
| 67 | battery_temperature |
1 | C | battery_temperature (same caveat) |
| 108 | temperature_t1 |
÷10 | C | bt_temperature (same caveat) |
Note:
bt_temperature(reg 108) is Modbus-only (not available via Cloud API). Available in LOCAL and HYBRID modes (overlaid via_TRANSPORT_OVERLAY).
Constant-zero temperatures on some hardware (#490, generalized for #560). Some units serve a permanent 0 in a temperature channel while the radiator temperatures read live: the cloud
getInverterRuntimepayload relaystinner: 0(the #490 reporter's 12000XP and the #76 raw payload from a second 12000XP), and a HYBRID-mode 12000XP serves the same constant 0 in LOCAL input regs 64 (internal), 67 (battery) and 108 (BT) while radiator1/2 read 58/61 °C and the BMS cell temps read a healthy 30/32 °C (#560 — reporter diagnostics confirm those live radiators).tinneris a REQUIRED pydantic field in pylxpweb and the register 0 is genuinely what the DSP serves — there is no sentinel to translate on either path. An exact 0 ininternal_temperature,battery_temperatureorbt_temperatureis therefore published asNone(HA “unknown”) instead of a wrong constant, viablank_constant_zero_temperaturesincoordinator_mappings.py— on every path (CLOUD, HYBRID, LOCAL), gated only by the value plus positive warmth radiator corroboration: blank only when at least one radiator reading is STRICTLY> 0°C. Radiators<= 0(cold-consistent, including negatives) and absent/Noneradiators PROTECT the reading (publish the 0) — there is no evidence it is bogus.CLOUD-path narrowing vs #490: earlier #490 blanked cloud
tinner: 0unconditionally (including when radiators were absent). The warmth gate now applies on the cloud path too. Known #490/#76 reporter payloads carried live radiators (tradiator1/tradiator2at 46/54 and similar), so those cases remain fixed.This is NOT a family difference — do not turn it into one. The defect splits within deviceTypeCode 54: a 12000XP reports the constant 0, while a 6000XP reports live
Tinnerof 31-32 °C alongside radiators at 58-65 °C in EG4's own data table (forum thread). Both are classifiedEG4_OFFGRID, and nothing distinguishes them (#259/#307), so a family gate would suppress a sensor that demonstrably works. Only the observed VALUE is treated.Accepted residuals: an all-zero boot/placeholder frame (targets 0, radiators 0) is physically indistinguishable from a genuinely cold unit and publishes the zeros until radiators warm — no family/freshness heuristic. A unit genuinely at 0 °C whose radiators nonetheless read
> 0reads unknown. Radiators oscillating across the> 0freeze point (0↔1) can flap the blanking decision (cosmetic). Bounded, and reversible.
The following canonical input fields are exposed as disabled-by-default diagnostics in LOCAL and HYBRID modes. They add no writable register surface.
| Reg | Canonical field | Decode / unit | HA sensor key |
|---|---|---|---|
| 25 | eps_apparent_power |
Phase-neutral VA only in positively known non-three-phase context | eps_apparent_power |
| 25 | eps_apparent_power |
R-phase VA in positively known three-phase context | eps_apparent_power_r |
| 69-70 | running_time |
Unsigned 32-bit seconds; diagnostic measurement, not a statistics total | inverter_running_time |
| 77 bit 0 | ac_input_type |
0 = grid, 1 = generator (localized enum) |
ac_input_type |
| 113 bits 0-1 | parallel_config role |
Standalone / Master / Slave / Three-Phase Master (localized enum) | parallel_role |
| 113 bits 2-3 | parallel_config phase |
0 = R, 1 = S, 2 = T; localized enum, unknown when standalone |
parallel_phase |
| 113 bits 8-15 | parallel_config unit |
Unit ID; unknown when standalone | parallel_unit_number |
I25 is deliberately phase-contextual. The pinned ant0nkr comparison
(d3d1014, I_SEPS) explicitly qualifies the register as R-phase on
three-phase systems. pylxpweb ≥ 0.9.39b6 gives the field aggregate
semantics on split-phase systems, falling back to I131 + I132 when the legacy
combined value is zero. The integration therefore publishes a phase-neutral
entity only for known non-three-phase devices, an explicitly R-phase entity for
known three-phase devices, and neither when feature detection cannot resolve
the topology. It never labels an unresolved or three-phase I25 value as an
aggregate.
The I69-70 unit is independently corroborated by the same pinned ant0nkr
comparison (I_RUNNING_TIME_L/H, seconds). An older InverterRuntimeData
field comment says hours; the parser and both canonical register tables carry
the raw seconds count, so the integration does not rescale it. Neither source,
however, supplies a hardware capture across reboot/replacement or enough
continuity evidence to establish Home Assistant's total_increasing reset
semantics. The opt-in duration therefore uses measurement; promoting it to a
statistics total remains deferred until that evidence exists.
For I77, only the well-defined AC-source bit is exposed. Bits 1-2 (AC-couple flow/enable in the comparison implementation) remain unexposed until their EG4 family behavior is captured. For I113, the integration follows pylxpweb's hardware-tested zero-based phase decode; the ant0nkr entity table labels those same bit values one-based. Exposing already-decoded values avoids importing that conflict or publishing the opaque packed word.
| Reg | Canonical Name | Scale | Unit | HA Sensor Key |
|---|---|---|---|---|
| 31 | inverter_energy_today |
÷10 | kWh | inverter_energy (NOT yield — see note) |
| 33 | charge_energy_today |
÷10 | kWh | charging |
| 34 | discharge_energy_today |
÷10 | kWh | discharging |
| 36 | grid_export_energy_today |
÷10 | kWh | grid_export |
| 37 | grid_import_energy_today |
÷10 | kWh | grid_import |
Note (eg4-bc0):
yieldis PV yield: LOCAL computes it as the epvN_day sum (pv_energy_today), CLOUD readstodayYielding. The cloud'stodayYieldingis PV yield, not Einv_day — proven by the pvPie permille rates (charge+export+usage sum to 1000 and distributetodayYielding; live 2026-06-10: 53.0 kWh × 0.777 == todayExport 41.2). Reg 31 (Einv_day, inverter OUTPUT energy) therefore feeds the separateinverter_energysensor and has no cloud mirror.
32-bit values use (high_word << 16) | low_word with little-endian register ordering.
| Reg Pair | Canonical Name | Scale | Unit | HA Sensor Key |
|---|---|---|---|---|
| 46-47 | inverter_energy_total |
÷10 | kWh | inverter_energy_lifetime (NOT yield_lifetime — see daily note) |
| 50-51 | charge_energy_total |
÷10 | kWh | charging_lifetime |
| 52-53 | discharge_energy_total |
÷10 | kWh | discharging_lifetime |
| 56-57 | grid_export_energy_total |
÷10 | kWh | grid_export_lifetime |
| 58-59 | grid_import_energy_total |
÷10 | kWh | grid_import_lifetime |
The integration exposes two different energy meters that are easy to confuse. They measure different things, do not sum to each other, and the EG4 cloud itself reports them as two separate numbers on two different screens (per-inverter usage vs. the station "Consumption" card).
| Meter | HA sensor keys | Device level | Scope | Source |
|---|---|---|---|---|
| Load Energy | load_energy, load_energy_lifetime |
per-inverter | Inverter-served load (Eload) | regs 171/172, raw |
| Consumption | consumption, consumption_lifetime |
inverter (standalone) + parallel-group | Whole-home consumption | energy balance / GridBOSS CT / cloud group |
Energy the inverter delivered to its own load output, read verbatim from the
Eload registers (scale ÷10):
| Reg(s) | pylxpweb field | Canonical | HA Sensor Key |
|---|---|---|---|
| 171 | load_energy_today |
Eload_day |
load_energy |
| 172-173 | load_energy_total |
Eload_all (32-bit) |
load_energy_lifetime |
- Reliable and exact. Validated 2026-06-02 against the cloud's per-inverter
todayUsage/totalUsage— matches to the decimal in all modes (the cloudInverterEnergyDatabuilder also mapstodayUsage→load_energy_today). - In a parallel group a master can read 0 while importing tens of kWh — it passes grid power through the parallel bus and serves no backup load directly. This is correct, not a bug.
- pylxpweb's
energy_today_usage/energy_lifetime_usagereturn the transport register (LOCAL/HYBRID) or the cloud value (CLOUD) uniformly, so the sensor is identical across modes.
Whole-home consumption, including grid-direct loads that never flow through an inverter's Eload. There is no single register for it; it is derived per mode:
-
CLOUD / HYBRID: cloud group
todayUsage/totalUsageat the parallel-group level (= the cloud "Consumption" card); per-inverter cloudtotalUsagefor a standalone inverter. -
LOCAL (transport present): energy balance via
coordinator_mappings._energy_balance()—consumption = yield + discharge + grid_import − charge − grid_exportwith the GridBOSS CT overlay (
ups + load,apply_gridboss_overlay) providing the authoritative whole-home figure at the parallel-group level.
⚠️ Energy balance is reliable for today but NOT for lifetime. Validated 2026-06-02: per-inverterenergy_balancelifetime summed to ~10.6 MWh vs. the true 34.71 MWh (cloud group) — the lifetime component registers wrap near 6553.5 kWh (16-bit ÷10). Whole-home lifetime must come from the cloud group (CLOUD/HYBRID) or GridBOSS UPS+Load CT totals (LOCAL) — never per-inverterenergy_balance.
| Quantity | today | lifetime |
|---|---|---|
| reg 171/172 Eload — 18kPV (slave) | 20.9 | 22609.7 |
| reg 171/172 Eload — FlexBOSS21 (master) | 0.0 | 142.9 |
| per-inverter Eload SUM | 20.9 | 22.75 MWh |
cloud GROUP todayUsage/totalUsage (= Consumption) |
41.8 | 34.71 MWh |
The ~12 MWh lifetime gap between the per-inverter Eload sum and the whole-home figure
is grid-direct / GridBOSS-Load-port load. Per-inverter Eload and whole-home
Consumption are different scopes — surfacing them under one consumption sensor
was the root of long-standing confusion (eg4-d49). Local Eload (regs 171/172) is
exact; the earlier "Eload unreliable" finding (eg4-05k) was really "Eload ≠
whole-home", which the two-meter split now resolves. The non-breaking change adds
load_energy; existing consumption entities are unchanged.
| Reg(s) | pylxpweb field | Meaning | HA Sensor Key |
|---|---|---|---|
| 32 | ac_charge_energy_today |
Erec_day — AC charge from grid |
ac_charge_energy |
| 48-49 | ac_charge_energy_total |
Erec_all — lifetime AC charge from grid |
ac_charge_energy_lifetime |
Before eg4-8oq, Erec (regs 32/48-49) was mis-aliased to load_energy_*. That alias
is removed: load_energy_* holds the real Eload registers (surfaced as Load Energy),
and Erec has its own ac_charge_energy_* fields.
Holding registers use Modbus function code 0x03 (read/write). These map to
switch. and number. entities.
Definitions: pylxpweb/registers/inverter_holding.py
Registers 0-1 contain a 32-bit bitfield (HOLD_MODEL) with hardware
configuration. Used during discovery to refine the model name beyond what
register 19 (device type code) provides.
Extraction formula (InverterModelInfo.from_registers()):
# Base rating: bits 5-7 of the low byte of reg0
power_rating = ((reg0 & 0xFF) >> 5) & 0x7
# FlexBOSS family offset: bit 8 of reg1 adds 8
if reg1 & 0x100:
power_rating += 8Usage in discovery (_config_flow/discovery.py):
_read_device_info_from_transport()reads device type code from reg 19- For non-GridBOSS devices, reads HOLD_MODEL from
transport.read_parameters(0, 2) InverterModelInfo.from_registers(reg0, reg1)extractspower_ratingget_model_name(device_type_code, power_rating)resolves specific model name- Guard: if result contains "Unknown", falls back to family default name
Power rating → model mapping:
| Device Type | powerRating | Model |
|---|---|---|
| 2092 | 2 | 12KPV |
| 2092 | 6 | 18KPV |
| 10284 | 8 | FlexBOSS21 |
| 10284 | 9 | FlexBOSS18 |
| 54 | 6 | 12000XP |
| 54 | 8 | 18000XP |
Note: See
pylxpweb/docs/DEVICE_TYPES.mdfor full bit layout documentation, validated device table, and example decodings.
| Bit | HA Entity Key | Entity Type | Purpose |
|---|---|---|---|
| 0 | battery_backup |
switch | EPS/Battery Backup mode |
| 7 | ac_charge |
switch | AC (Grid) Charging enable |
| 10 | forced_discharge |
switch | Forced Battery Discharge |
| 11 | pv_charge_priority |
switch | Forced PV Charge Priority |
| 15 | feed_in_grid_en |
switch | Grid Sell Back (feed-in/export enable, #135; grid-tied families only) |
| Bit | Parameter Key | HA Entity Key | Entity Type | Purpose |
|---|---|---|---|---|
| 1 | FUNC_RUN_WITHOUT_GRID |
fast_zero_export |
switch | Fast Zero Export — speeds up the zero-export control loop; select as the opposite of Grid Sell Back (#274; grid-tied families only) |
| 3 | FUNC_BAT_SHARED |
share_battery |
switch | Share Battery — one battery bank across paralleled inverters (#288; disabled by default) |
| 4 | FUNC_CHARGE_LAST |
charge_last |
switch | Charge Last — PV serves loads/export first, charges battery last (#177) |
| 14 | FUNC_GREEN_EN |
off_grid_mode |
switch | Green/Off-Grid Mode (bit 14 hardware-verified 2026-07-21, #476; was wrongly mapped at bit 8) |
Register 110 holds 16 bit fields in one lineage-wide layout (
REGISTER_110_PARAM_KEYSin pylxpwebconstants/registers.py; unproven slots areFUNC_110_BITnplaceholders); only bits 1, 3, 4 and 14 are mapped to entities. Local writes read-modify-write the register via the named-parameter map; cloud writes use the function-control API (control_function), which applies the bit server-side.
FUNC_RUN_WITHOUT_GRID(bit 1) is "Fast Zero Export" — the LXP protocol PDF names the bitFunctionEn1.ubFastZeroExport, and both the EG4 (#135 screenshot) and Luxpower (#274 screenshot) web UIs toggle theFUNC_RUN_WITHOUT_GRIDcloud param from their Grid Sell tab's "Fast Zero Export" button. pylxpweb's canonical namerun_without_gridis the vendor param dictionary's literal wording, not the function: the bit does not make the inverter run without grid. Same bit position in the base (18kPV) and SNA register-110 tables.The register-110 layout is unified lineage-wide (#476, 2026-07-21): a live 18kPV toggle test pinned green mode at bit 14 (raw
1056 ↔ 17440, single-bit delta, EG4 cloud decode in lockstep), matching both the 12000XP hardware evidence from PR #220 (buzzer bit 7,FUNC_BATTERY_ECO_ENbit 15) and the ant0nkr lxp_modbus reference — every hardware-tested position agrees with the lxp_modbus layout, and the historic 18kPV-specific upper-bit table (green at 8, ECO at 9, buzzer at 6) matched none of them. pylxpweb's base and EG4_OFFGRID tables now share oneREGISTER_110_PARAM_KEYSlist. This file does not enumerate which reg-110 slots are still placeholders — the canonical per-bit map isllmwiki/40-hardware/registers.md; read it there. The enumeration this sentence used to carry was wrong in both directions: it omitted one placeholder and swept in a bit that had since been proven and named, so the list described a proven writable bit as a guarded unknown. It went stale because a promotion recorded in the keeper falsified a duplicated list one file away — changing a bit's grade is never a local edit, and that is the general hazard here, not a one-off. The former "cloud-only on EG4_OFFGRID" restriction for the Off Grid Mode switch is lifted, soEG4OffGridModeSwitchnow writes local-first with cloud fallback wherever it is created (switch.py:1196-1215). That is a statement about the shipped write route, not evidence that the bit-14 mapping holds on each family: the toggle proof is from one tested unit, and whether it extends to any other family is the keeper's to state — seellmwiki/40-hardware/registers.md. A wrong bit would be firmware-ACKed here exactly as in #476, which is the bug this remapping came from. The resulting risk — a shipped entity writing this bit local-first on families where the mapping is unresolved — is tracked in #558; the full list of write paths in that position is enumerated in the wiki, not here. No ECO entity exists in the integration; that relocation only corrects the library mapping.
| Reg | HA Entity Key | Entity Type | Unit | Range |
|---|---|---|---|---|
| 64 | charge_power_percent |
(unmapped) | % | 0-100 |
| 65 | discharge_power_percent |
number | % | 0-100 |
| 66 | ac_charge_power |
number | W | 0-15000 |
| 67 | ac_charge_soc_limit |
number | % | 0-100 |
| 74 | pv_charge_power |
number | kW | 0-15 |
| 103 | grid_sell_back_power |
number | kW | 0-25.5 |
| 82 | forced_discharge_power |
number | kW | 0-25.5 |
| 83 | forced_discharge_soc_limit |
number | % | 0-100 |
| 116 | start_discharge_power_threshold |
number | W | 50-10000; LXP 0-10000 (#628 portal evidence) |
| 117 | start_charge_power_threshold |
number | W (signed) | -10000-10000 |
| 206 | grid_peak_shaving_power |
number | kW | 0-25.5 |
| 207 | grid_peak_shaving_soc |
number | % | 0-100 |
| 208 | grid_peak_shaving_volt |
number | V | 40.0-64.0 write, 20-70 read |
| 218 | grid_peak_shaving_soc_2 |
number | % | 0-100 |
| 219 | grid_peak_shaving_volt_2 |
number | V | 40.0-64.0 write, 20-70 read |
| 232 | grid_peak_shaving_power_2 |
number | kW | 0-25.5 |
forced_discharge_power(reg 82) /forced_discharge_soc_limit(reg 83) (#207, PR #249). Reg 82 uses the 100 W encoding of regs 66/74/103 (panel 2.5 kW reads raw 25); reg 83 is a plain percent. Grid-tied families only — both entities are created in the non-EG4_OFFGRIDbranch ofnumber.py.
grid_peak_shaving_power(reg 206) (#328) is peak shaving period 1 (PS1), stored in 0.1 kW units. It lives at reg 206, not reg 231 — the old pylxpweb 231 mapping was wrong and(231,1)names nothing (const/modbus.py:161-163). The entity pre-checksFUNC_GRID_PEAK_SHAVING(reg 179 bit 7) with verify-then-block, because the firmware NAKs writes and zeroes the setpoint while the mode is off. Grid-tied families only.
Daily peak-shaving set — regs 207/208/218/219/232 (#592): Grid Peak Shaving SOC 1/2 (whole percent, raw 1:1), Voltage 1/2 (decivolts) and Power 2 (deci-kW). Created in the same grid-tied branch as PS1 and spec-driven (
PeakShavingNumber). Unlike PS1 they write through the shared router: a local named write on a positively resolved non-off-grid family (the transport scales kW / V to deci-units itself —LOCAL_PARAM_SCALE_DIV10in pylxpweb — so the entity never pre-scales), the cloud holdParam with an equality-checked readback otherwise; off-grid / unresolved families are cloud-only (#558). Only Power 2 pre-checksFUNC_GRID_PEAK_SHAVING; whether the firmware NAKs the SOC / voltage rows with the mode off is unverified. The voltage write bounds 40.0–64.0 V are shared with the pinned pylxpweb 0.10.0b9 rows (pylxpweb PR #327); both are a maintainer-chosen bracket with no firmware/portal bound captured. The read window is 20-70 V, so any plausible portal-stored value (within that window) is preserved rather than blanked — a value outside it still renders unknown. LOCAL reads: one(206, 7)frame (PS1 + period-1 floors + both schedule windows),(218, 2)and(232, 1), allEG4_HYBRID-gated. Evidence for all five isportal-correlated(llmwiki/40-hardware/registers.md).
grid_sell_back_power(reg 103) is the maximum sell-back (feed-in) power cap, cloud keyHOLD_FEED_IN_GRID_POWER_PERCENT— register pinned via single-register named cloud reads on 18kPV + FlexBOSS21 (2026-06-12, #135). The cloud never uses the protocol spec'sHOLD_MAX_BACKFLOW_POWER_PERCENTname for this register. kW with 100 W raw units (the reg-66/74/82 encoding), NOT the percent in the spec or the param name (#274): the 2026-04-13 live local probe read raw 160 on the 18kPV whose cloud named read returned "16", both web UIs label the field "Grid Sell Back Power(kW)" (#135 + #274 screenshots), and the #274 LXP-LB shows 12.1 kW (raw 121) — impossible as a 0-100 percent. Cloud reads/writes are kW floats (server scales); the local/Modbus path scales kW×10 on write and ÷10 on read. Grid-tied families only.
pv_charge_powertargets register 74 (HOLD_FORCED_CHG_POWER_CMD, the forced/PV-charge-priority power command), stored in 100W units (0-150 = 0-15 kW) — same encoding as AC charge power (reg 66). The cloud path uses kW directly; the local/Modbus path scales kW×10 on write and ÷10 on read. Register 64 (charge_power_percent, a 0-100% limit) is not the PV charge power control and is currently unmapped to any entity. The local path previously mis-targeted reg 64 with a lossy kW↔% conversion (the "set 1 → reads 0" bounce); see issue history.
start_discharge_power_threshold(reg 116,PtoUserStartdischg) starts battery discharge once grid import (P_to_user) exceeds the value — the Luxpower web UI's "Start Discharge P_import(W)". The original[50, ]hint (#272) was not a minimum: the LXP portal accepts zero (#628). Raw register IS whole watts (protocol register table scale "1W", default 50 W) — NOT the 100 W encoding of regs 66/74/82/103: fleet scanner reads show raw 100 == cloud "100" == 100 W. TWO parameter keys for the one register: pylxpweb's local name map usesHOLD_PTOUSER_START_DISCHARGE(LOCAL/HYBRID reads + local name-map writes), while the live cloud API usesHOLD_P_TO_USER_START_DISCHG(#272 reporter's browser console + everydocs/invertersscanner dump; pylxpweb's guessedapi_param_keydoes not exist on the server, so its cloud read leg returns 0). Cloud writes go through the generic named-parameter API with the live key. Grid-tied families only (EG4_HYBRID, LXP); CT required for the feature to act.
start_charge_power_threshold(reg 117,PtoUserStartchg) starts charging once P_to_user drops below the value — signed watts, protocol default -50 W (= start charging once exporting more than 50 W). The register is absent from the Luxpower web UI and has no cloud parameter name (remoteRead names reg 117<EMPTY>on every scanned model, incl. LXP-EU), so the entity is LOCAL/HYBRID-only and disabled by default (untested register, added for #272 field testing). Local reads surface it under the raw"117"key (read_named_parametersfalls back tostr(addr)for unmapped registers); writes use the raw register address with two's-complement masking (-50 → 65486).
Seven schedule types share one packed-time layout, declared once in the
SCHEDULE_TIME_TYPES table (const/modbus.py, mirroring pylxpweb's
SCHEDULE_CONFIGS) and consumed by a single time entity class
(EG4ScheduleTimeEntity). Per type: up to three daily windows × (start, end) =
two consecutive holding registers per window from the base (AC Charge, AC First,
Forced Charge, Forced Discharge, Off-Grid expose 3 windows; Peak Shaving and
Generator Charge expose 2). Each 16-bit register packs both time fields:
hour in the low byte, minute in the high byte (value = hour | (minute << 8), pylxpweb pack_time()/unpack_time(); e.g. 23:30 → 7703).
| Schedule | Regs | Windows | Cloud param prefix | Entities (gating) |
|---|---|---|---|---|
| AC Charge | 68-73 | 3 | HOLD_AC_CHARGE |
all control-capable families (#277) |
| AC First | 152-157 | 3 | HOLD_AC_FIRST |
EG4_OFFGRID (SNA) only — the portal shows the AC First section only on the SNA working-mode page (/WManage/web/maintain/workingMode/sna) |
| Forced Charge | 76-81 | 3 | HOLD_FORCED_CHARGE |
control-capable grid-tied families — suppressed on EG4_OFFGRID (cloud rejects HOLD_FORCED_CHARGE_*, no portal params on the SNA page; #316) |
| Forced Discharge | 84-89 | 3 | HOLD_FORCED_DISCHARGE |
control-capable grid-tied families — suppressed on EG4_OFFGRID like the forced discharge power/SOC numbers (PR #220 / #197) |
| Peak Shaving | 209-212 | 2 | HOLD_PEAK_SHAVING (cloud reads via interleaved LSP_HOLD_DIS_CHG_POWER_TIME_37..44) |
EG4_HYBRID only (absent on the SNA probe); #312 |
| Generator Charge | 256-259 | 2 | HOLD_GEN |
EG4_HYBRID and EG4_OFFGRID (SNA probe carries the same names); #312 |
| Off-Grid | 269-274 | 3 | HOLD_OFF_GRID |
EG4_HYBRID only; #312 |
Within each block: reg base+0/1 = window 1 start/end, +2/3 = window 2,
+4/5 = window 3. HA entity keys are {schedule}_{start|end}_time_{1|2|3}
(e.g. ac_first_start_time_1); all schedule time windows are created
disabled by default (changed in #312 / beta.22 — beta.18–.20 created window 1
enabled; already-enabled entities keep their state, the default only affects new
registrations). Peak Shaving, Generator Charge and Off-Grid write their windows
through the portal's atomic writeTime endpoint (write_via_time_api; one call
per boundary). Cloud param names take the window suffix
""/_1/_2: e.g. reg 72 (AC charge window 3 start) ↔
HOLD_AC_CHARGE_START_HOUR_2 + HOLD_AC_CHARGE_START_MINUTE_2.
Evidence for the packed layout and cloud naming: the live cloud register probes (pylxpweb
docs/inverters/FlexBOSS21_52XXXXXX78.jsonfor 68-73,SNA12KUS_52XXXXXX68.jsonblocks 106-111 for 152-157) read each register individually and get two named params back per register — the hour and the minute — with window 1 unsuffixed and windows 2/3 suffixed_1/_2. This matches pylxpweb'sSCHEDULE_CONFIGS(bases 68/76/84/152) and its Modbus schedule helpers, which writepack_time(hour, minute)per register. All probed families (12kPV, FlexBOSS18/21, LXP-US, SNA12K-US) report the named cloud params for the AC Charge, AC First and Forced Charge/Discharge schedules; the Peak Shaving, Generator Charge and Off-Grid families (bases 209/256/269) were pinned by the #312 live cloud-write ↔ local-register correlation on a FlexBOSS21. The family gates above reflect the portal UI (which page shows the section) and the #197 off-grid adjudication, not param absence.LOCAL parameter-cache naming caveat (AC charge only): pylxpweb's
REGISTER_TO_PARAM_KEYSstill carries a stale pre-probe interpretation of 68-73 (one field per register:HOLD_AC_CHARGE_START_HOUR_1@ 68, …,HOLD_AC_CHARGE_ENABLE_1/_2@ 72/73). Localread_named_parameters()therefore surfaces the raw packed values under those misleading names; each spec'slocal_param_keysalias chain (const/modbus.py) maps the register to its cache keys and the time entities unpack the value themselves. Do not treat those cache keys as separated hour/minute values on the local path. The other schedules use pylxpweb's canonical packed names (HOLD_FORCED_CHARGE_TIME_0_START, …; 84-89 and 152-157 named in pylxpweb > 0.9.36b21) with the raw"84"/"152"-style address keys as fallback on older releases.Write paths: LOCAL/HYBRID-with-transport writes the single packed register (FC06 — the firmware rejects FC16 multi-writes on schedule registers) via
coordinator.write_register(); CLOUD writes the portal's named hour + minute params viacontrol.write_parameter(). Overnight windows (end < start, e.g. 20:00 → 08:00) are firmware-legal and are not cross-validated. Whether a window is active is governed by the separate enable bits (e.g. AC Charge reg 21 bit 7, Forced Charge/Discharge reg 21 bits 11/10) and the AC charge type (reg 120 bits 1-3) controls — the time entities manage only the schedule.
| Reg | HA Entity Key | Entity Type | Unit | Range |
|---|---|---|---|---|
| 101 | charge_current |
number | A | 0-140 |
| 102 | discharge_current |
number | A | 0-140 |
| 105 | ongrid_discharge_soc |
number | % | 10-100 |
| 125 | offgrid_discharge_soc |
number | % | 0-100 |
| 160 | ac_charge_start_battery_soc |
number | % | 1-90 off-grid/unresolved; 0-90 positively resolved non-off-grid |
| 161 | ac_charge_end_battery_soc |
number | % | 20-100 off-grid/unresolved; 0-100 positively resolved non-off-grid |
AC-charge SOC window (regs 160/161) (#570, PR #600). Bounds and routes are family-scoped in
number.py:ACChargeStartBatterySOCNumber,ACChargeEndBatterySOCNumber, andEG4BaseNumberEntity._offgrid_cloud_only_reason.
- H160 (Start) is created for
EG4_OFFGRIDandEG4_HYBRID. Off-grid or unresolved families use a 1% floor; positively resolved non-off-grid families retain 0%. The write ceiling is 90%; reads tolerate values through 100%. The hybrid creation gate fails closed for unknown families. On hybrids the AC-charge stop SOC is H67, not H161.- H161 (End) is created for
EG4_OFFGRID. The entity uses a 20% floor for off-grid or unresolved families and retains 0% for positively resolved non-off-grid families; the ceiling is 100%. Creation eligibility and the defensive write/read bounds are separate checks.- Routing for both registers: off-grid, missing and UNKNOWN families take the cloud-only named-parameter route. A pure-LOCAL entry in those families receives an error instead of attempting a local write. Only a positively resolved non-off-grid family may use the local-first route. Local reads remain enabled.
Evidence is scoped to the claim and family. The register keeper owns the grades and primary artifacts. H160's raw 1-90 bounds and H161's raw 20-100 bounds, H161 >= H160 check, and charge-state-machine use are
firmware-provenon the decoded CEAA/CCAA off-grid images. This does not prove a live off-grid local write. H160's named cloud write/storage/restoration ishardware-toggle-provenonly on the tested FlexBOSS21 and 18kPV units (component firmware unrecorded); other-family mapping remainsportal-correlated. H161's broader mapping isportal-correlated, with inert write behavior on the tested grid-tied hybrids and live off-grid local writability unresolved.The routing owner documents the gate shipped by PR #600. Readback proves storage and transport, not semantics; it cannot detect a wrong-but-writable target. #558 and the keeper's C6/C7 links retain the unresolved hardware scope. These documentation corrections neither promote evidence grades nor authorize a local off-grid write.
16-bit bit field for extended functions. Added in pylxpweb 0.9.5.
| Bit | Parameter Key | HA Entity Key | Purpose |
|---|---|---|---|
| 3 | FUNC_PV_SELL_TO_GRID_EN |
pv_sell_to_grid_en (switch "Export PV Only") |
Only export PV surplus, never battery (#135; pinned 2026-06-12) |
| 7 | FUNC_GRID_PEAK_SHAVING |
grid_peak_shaving |
Grid peak shaving mode |
| 9 | FUNC_BAT_CHARGE_CONTROL |
battery_charge_control (select) |
Battery charge regulation: 0=SOC, 1=Voltage |
| 10 | FUNC_BAT_DISCHARGE_CONTROL |
battery_discharge_control (select) |
Battery discharge regulation: 0=SOC, 1=Voltage |
| 11 | FUNC_AC_COUPLING_FUNCTION |
ac_couple (switch "AC Couple") |
Inverter-level AC-coupled source enable (#471/#472); see the note below and the keeper's per-bit map |
Note: Register 179 contains 16 API-mapped parameters (
FUNC_ACTIVE_POWER_LIMIT_MODE,FUNC_AC_COUPLING_FUNCTION, etc.); bits without an established name carry placeholders (FUNC_179_BIT0etc.).This file does not grade reg-179 bits. The per-bit evidence grades live in the H179 safe bit map in
llmwiki/40-hardware/registers.md, which is the sole grading authority; consult it there rather than any restatement here. The table above describes what each bit drives in this integration — that is implementation reality, not evidence of hardware semantics, and the two must not be conflated.
FUNC_AC_COUPLING_FUNCTION(theAC Coupleswitch, #472) is mapped to bit 11 WITHOUT a raw↔named lockstep toggle. The evidence is: the Luxpower Modbus doc; theant0nkr/luxpower-ha-integrationregister map (H_FUNCTION_ENABLE_4 = 179, "Bit 11: uFunctionEn2.ubACcoupling"), whose full 16-bit reg-179 layout matches this project's canonical table bit-for-bit and several of whose other bits carry independent corroboration on EG4 hardware (which bits, and at what grade, is the keeper's to state — see the H179 safe bit map inllmwiki/40-hardware/registers.md; do not infer bit 11 from a neighbour's standing); and #471's reporter having driven the control through that mapping on his LXP, with his live named reads agreeing (AC-couple-enabled LXPUS810K reads the cloud paramTrue, a disabled SNA12K-US probe readsFalse). None of that is a controlled toggle: it is lineage plus correlation. This is the same standing the #476 off-grid green-mode bit shipped on — and #476 was wrong.
⚠️ A readback delta proves storage and transport ONLY, never semantic. A wrong-but-writable bit is firmware-ACKed: no exception, no fallback, no log above DEBUG, and it reads back exactly the value written. Reading reg 179 before and after a write therefore cannot tell "the AC-couple function was toggled" from "some other function was silently changed". That is precisely how reg 110 bit 8 shipped wrong. Do not treat any readback — including the entity's own_verify_local_writere-read — as evidence that the mapping is correct.Discharging this requires the complete tuple, not any part of it: a family-specific named action (portal control or documented vendor function), the device family it was performed on, the raw integer register word before, the raw integer register word after (differing by exactly
0x0800), an independent physical observation that the AC-coupled input actually changed behavior, and restoration of the original value. Scaled or engineering-unit values are not raw captures, and a reconstructed integer is not a capture. #472 tracks exactly this capture; until it lands, bit 11 stayslineage-inferredinllmwiki/40-hardware/registers.md, which is the grading authority.Current state — the local write path is LIVE IN PRODUCTION, not gated.
EG4ACCoupleSwitchwrites bit 11 local-first with cloud fallback whenever the installed pylxpweb decodes the name from a register (switch.py:965-985, gate at:248; the routing is stated in the class docstring at:795), and the bit-11 mapping ships from pylxpweb 0.9.39b6 — at or below the currentmanifest.jsonfloor, so on a conforming install the local write is the default route in LOCAL and HYBRID, not a fallback.This is an un-discharged risk that ships today. It is not gated, it has not been reviewed and approved, and it is not safe. Nothing here recommends or blesses the local write; this note records what the code does so the risk is visible rather than implied, and changing the write routing is a code decision deliberately not made in a documentation change. Tracked as #558.
Note the deliberate contrast in the same file:
EG4SmartLoadSwitchis kept cloud-only precisely because its bit is unpinned (switch.py:252-263), so two unpinned reg-179 bits are currently routed differently. The register contract harness pins the name to (179, 11) on every inverter family — that is a consistency check on our own table, not hardware evidence.
FUNC_PV_SELL_TO_GRID_EN(theExport PV Onlyswitch, #135) was pinned to bit 3 on 2026-06-12 (~16:05–16:07 PT) via authorized live cloud toggles raw-verified on BOTH 12K-hybrid models: FlexBOSS21 SYNTH00003 and 18kPV SYNTH00004 each toggled the reg-179 raw frame0x104c↔0x1044(XOR0x0008= single bit 3) in lockstep with the named parameter, restores verified by re-read (remoteReadvalueFrame, base64 LE uint16). With pylxpweb ≥ 0.9.36b6 the switch works in ALL modes: local-raw parameter caches decode the bit by name and local writes RMW it; against older pylxpweb the setup probe (switch._local_params_can_carry) keeps it cloud-parameter-mode only. The register contract harness pins the name to (179, 3).
Related: PS1 grid_peak_shaving_power is holding register 206 (deci-kW),
not 231 — H231 is an unknown field (single-register cloud reads name nothing
there; eg4-gfu5). The rest of the daily peak-shaving set is H207/H208 (period-1
SOC / voltage), H218/H219 (period-2 SOC / voltage) and H232 (PS2 power); see the
number table above (#592).
Family availability: the Grid Peak Shaving switch/power number and the Forced Discharge switch/power/SOC-limit numbers are not created for EG4_OFFGRID (12000XP/6000XP). These functions act on grid-parallel export/import blending; the SNA platform has no sellback or grid-parallel operation (bypass-or-invert), uses
FUNC_GEN_PEAK_SHAVINGfor its generator-overload variant, and manages battery-vs-grid priority through its ownLSP_*/discharge-control parameters. Field data: stock SNA12K-US cloud dump and the #222 6000XP capture both readFUNC_GRID_PEAK_SHAVING=False/FUNC_FORCED_DISCHG_EN=False, and the SNA cloud parameter set does not even expose_12K_HOLD_GRID_PEAK_SHAVING_POWER. Existing users see a Repairs issue (offgrid_grid_controls_removed) explaining the removal (#219 precedent).
16-bit bit field for additional functions. Added in pylxpweb 0.9.5.
| Bit | Parameter Key | HA Entity Key | Purpose |
|---|---|---|---|
| 1 | FUNC_BATTERY_BACKUP_CTRL |
battery_backup_mode |
Battery backup control. Not created on EG4_OFFGRID — cloud write rejected on a 12000XP v2, and local reg-233 access is reported to return ILLEGAL DATA ADDRESS on the off-grid units tested (#289/#296). No raw request/exception-response capture was preserved, so broader family applicability is unresolved — see H233 off-grid access boundary |
Note: Register 233 contains 9 API-mapped parameters (
BIT_DRY_CONTRACTOR_MULTIPLEX,BIT_LCD_TYPE,FUNC_BATTERY_CALIBRATION_EN,FUNC_SPORADIC_CHARGE, etc.). As with reg 179, this file does not grade reg-233 bits — see the H233 safe bit map inllmwiki/40-hardware/registers.md. An earlier revision of this note guessed that bit 12 was "possiblyFUNC_QUICK_CHARGE_CTRL"; the keeper records b12 as sporadic charge and states explicitly that it is not Quick Charge, so that guess is removed rather than carried forward. Reg 233 was also the root cause of issue #153 — beta.31 shipped withpylxpweb>=0.9.4in the manifest, but these register mappings only exist from 0.9.5.
The charge/discharge regulation regime (register 179 bits 9/10 above) selects which battery-limit registers are active. The integration exposes both the SOC and Voltage limits as Number entities, enabling only the active mode's limits by default. Added in integration 3.4.0 / pylxpweb 0.9.36b1.
| Reg | EG4 Param Key | HA Entity Key | Side / Mode | Unit | Range |
|---|---|---|---|---|---|
| 67 | HOLD_AC_CHARGE_SOC_LIMIT |
ac_charge_soc_limit |
charge / SOC | % | 0-100 |
| 227 | HOLD_SYSTEM_CHARGE_SOC_LIMIT |
system_charge_soc_limit |
charge / SOC | % | 0-101 |
| 228 | HOLD_SYSTEM_CHARGE_VOLT_LIMIT |
system_charge_volt_limit |
charge / Voltage | V | 40-64 |
| 158 | HOLD_AC_CHARGE_START_BATTERY_VOLTAGE |
ac_charge_start_voltage |
charge / Voltage | V | 38-60 |
| 159 | HOLD_AC_CHARGE_END_BATTERY_VOLTAGE |
ac_charge_end_voltage |
charge / Voltage | V | 38-60 |
| 105 | HOLD_DISCHG_CUT_OFF_SOC_EOD |
on_grid_soc_cutoff |
discharge / SOC | % | 10-100 |
| 125 | HOLD_SOC_LOW_LIMIT_EPS_DISCHG |
off_grid_soc_cutoff |
discharge / SOC | % | 0-100 |
| 169 | HOLD_ON_GRID_EOD_VOLTAGE |
on_grid_cutoff_voltage |
discharge / Voltage | V | 40-58 |
| 100 | HOLD_LEAD_ACID_DISCHARGE_CUT_OFF_VOLT |
off_grid_cutoff_voltage |
discharge / Voltage | V | 40-58 |
| 202 | _12K_HOLD_STOP_DISCHG_VOLT |
stop_discharge_voltage |
discharge / Voltage | V | 40-56 |
Voltage scaling: local Modbus returns raw decivolts (e.g.
595) while the cloud API returns already-scaled volts (e.g.59.5); the Number entities normalize by magnitude (a value ≥ 100 is decivolts and is divided by 10). Reg 159's cloud param key isHOLD_AC_CHARGE_END_BATTERY_VOLTAGE(the holding-registerapi_param_keyHOLD_AC_CHARGE_END_VOLTAGEis aliased to it).Stop Discharge Voltage (reg 202): the cloud maintain page's "Stop Discharge Volt 1(V)", gated by
disChgVoltEnable— the forced-discharge stop limit for the Voltage regime (the voltage twin offorced_discharge_soc_limit, reg 83). Located by single-register cloud window bisection and raw-verified DECIVOLTS 2026-06-11 (raw 400 ↔ cloud 40 V); cloud accepts fractional volts (live round-trip 40 → 41.5 → 40 V). Added in bead eg4-aa3t (pylxpweb > 0.9.36b5).EG4 web UI labels: some of these registers appear under different names on the EG4 monitor — e.g. "Back Up Volt(V)" is reg 159 (
ac_charge_end_voltage, the voltage twin of the AC-charge SOC limit, active in battery-backup/voltage mode) and "System Charge Volt Limit(V)" is reg 228. The full label cross-reference lives in CONFIGURATION.md.
All GridBOSS registers are INPUT registers (function code 0x04). Device type code: 50.
Definitions: pylxpweb/registers/gridboss.py
Mapping chain: Register → read_scaled() → MidboxRuntimeData field
→ _build_gridboss_sensor_mapping() → HA sensor key
| Reg | Canonical Name | pylxpweb Field | HA Sensor Key |
|---|---|---|---|
| 1 | grid_voltage |
grid_voltage |
grid_voltage |
| 2 | ups_voltage |
ups_voltage |
ups_voltage |
| 3 | gen_voltage |
gen_voltage |
generator_voltage |
| 4 | grid_l1_voltage |
grid_l1_voltage |
grid_voltage_l1 |
| 5 | grid_l2_voltage |
grid_l2_voltage |
grid_voltage_l2 |
| 6 | ups_l1_voltage |
ups_l1_voltage |
load_voltage_l1 |
| 7 | ups_l2_voltage |
ups_l2_voltage |
load_voltage_l2 |
| 8 | gen_l1_voltage |
gen_l1_voltage |
generator_voltage_l1 |
| 9 | gen_l2_voltage |
gen_l2_voltage |
generator_voltage_l2 |
| Reg | Canonical Name | pylxpweb Field | HA Sensor Key |
|---|---|---|---|
| 10 | grid_l1_current |
grid_l1_current |
grid_current_l1 |
| 11 | grid_l2_current |
grid_l2_current |
grid_current_l2 |
| 12 | load_l1_current |
load_l1_current |
load_current_l1 |
| 13 | load_l2_current |
load_l2_current |
load_current_l2 |
| 14 | gen_l1_current |
gen_l1_current |
generator_current_l1 |
| 15 | gen_l2_current |
gen_l2_current |
generator_current_l2 |
| 16 | ups_l1_current |
ups_l1_current |
ups_current_l1 |
| 17 | ups_l2_current |
ups_l2_current |
ups_current_l2 |
| 18 | smart_port1_l1_current |
smart_port_1_l1_current |
smart_load1_current_l1 |
| 19 | smart_port1_l2_current |
smart_port_1_l2_current |
smart_load1_current_l2 |
| 20 | smart_port2_l1_current |
smart_port_2_l1_current |
smart_load2_current_l1 |
| 21 | smart_port2_l2_current |
smart_port_2_l2_current |
smart_load2_current_l2 |
| 22 | smart_port3_l1_current |
smart_port_3_l1_current |
smart_load3_current_l1 |
| 23 | smart_port3_l2_current |
smart_port_3_l2_current |
smart_load3_current_l2 |
| 24 | smart_port4_l1_current |
smart_port_4_l1_current |
smart_load4_current_l1 |
| 25 | smart_port4_l2_current |
smart_port_4_l2_current |
smart_load4_current_l2 |
Smart port current: Like power registers, current registers always contain actual measurements regardless of port mode. When a port is in AC Couple mode (status=2), the coordinator remaps
smart_load{N}_current_l{1,2}toac_couple{N}_current_l{1,2}. The cloud carries the same values assmartLoad{N}L{1,2}RmsCurr(÷10, pylxpweb 0.9.34+, #243), so Cloud-only installs get the per-port current sensors too.
| Reg | Canonical Name | pylxpweb Field | HA Sensor Key |
|---|---|---|---|
| 26 | grid_l1_power |
grid_l1_power |
grid_power_l1 |
| 27 | grid_l2_power |
grid_l2_power |
grid_power_l2 |
| 28 | load_l1_power |
load_l1_power |
load_power_l1 |
| 29 | load_l2_power |
load_l2_power |
load_power_l2 |
| 30 | gen_l1_power |
gen_l1_power |
generator_power_l1 |
| 31 | gen_l2_power |
gen_l2_power |
generator_power_l2 |
| 32 | ups_l1_power |
ups_l1_power |
ups_power_l1 |
| 33 | ups_l2_power |
ups_l2_power |
ups_power_l2 |
Smart ports can be configured as Smart Load (status=1) or AC Couple (status=2). The power registers report actual measurement regardless of port mode — the same physical registers contain the power reading; HA sensor keys are aliased based on port status.
| Reg | Canonical Name | pylxpweb Field | HA Sensor Key |
|---|---|---|---|
| 34 | smart_load1_l1_power |
smart_load_1_l1_power |
smart_load1_power_l1 |
| 35 | smart_load1_l2_power |
smart_load_1_l2_power |
smart_load1_power_l2 |
| 36 | smart_load2_l1_power |
smart_load_2_l1_power |
smart_load2_power_l1 |
| 37 | smart_load2_l2_power |
smart_load_2_l2_power |
smart_load2_power_l2 |
| 38 | smart_load3_l1_power |
smart_load_3_l1_power |
smart_load3_power_l1 |
| 39 | smart_load3_l2_power |
smart_load_3_l2_power |
smart_load3_power_l2 |
| 40 | smart_load4_l1_power |
smart_load_4_l1_power |
smart_load4_power_l1 |
| 41 | smart_load4_l2_power |
smart_load_4_l2_power |
smart_load4_power_l2 |
AC Couple power: When a port is in AC Couple mode (status=2), the
smart_load{N}registers contain the AC couple power. The coordinator createsac_couple{N}_power_l1/l2sensor keys aliased from the same register values. See Section 11.
| Reg | Canonical Name | pylxpweb Field | HA Sensor Key |
|---|---|---|---|
| 128 | phase_lock_frequency |
phase_lock_freq |
phase_lock_frequency |
| 129 | grid_frequency |
grid_frequency |
frequency |
| 130 | gen_frequency |
gen_frequency |
generator_frequency |
| Reg | Canonical Name | HA Sensor Key |
|---|---|---|
| 42 | load_energy_today_l1 |
load_l1 |
| 43 | load_energy_today_l2 |
load_l2 |
| 44 | ups_energy_today_l1 |
ups_l1 |
| 45 | ups_energy_today_l2 |
ups_l2 |
| 46 | grid_export_today_l1 |
grid_export_l1 |
| 47 | grid_export_today_l2 |
grid_export_l2 |
| 48 | grid_import_today_l1 |
grid_import_l1 |
| 49 | grid_import_today_l2 |
grid_import_l2 |
| 52-59 | smart_load{1-4}_energy_today_l{1-2} |
smart_load{N}_l{P} |
| 60-67 | ac_couple{1-4}_energy_today_l{1-2} |
ac_couple{N}_l{P} |
Note: L2 energy registers always read 0 in practice. Aggregate energy sensors (e.g.,
ups_today,load_today) are computed by summing L1+L2 in pylxpweb.Note: Regs 50-51 are likely generator energy today (L1/L2, ÷10 → kWh), based on the EG4 cloud CSV export which places an
eGenDaycolumn betweeneToUserDayandeSmartLoad1Day. On systems without a generator these registers are always 0, so cloud API ↔ Modbus comparison cannot confirm. Smart load daily block confirmed to start at reg 52 (issue #146). Community verification needed: if you have a GridBOSS with an active generator, check whether regs 50-51 accumulate generator kWh.
| Reg Pair | Canonical Name | HA Sensor Key |
|---|---|---|
| 68-69 | load_energy_total_l1 |
load_lifetime_l1 |
| 70-71 | load_energy_total_l2 |
load_lifetime_l2 |
| 72-73 | ups_energy_total_l1 |
ups_lifetime_l1 |
| 74-75 | ups_energy_total_l2 |
ups_lifetime_l2 |
| 76-77 | grid_export_total_l1 |
grid_export_lifetime_l1 |
| 78-79 | grid_export_total_l2 |
grid_export_lifetime_l2 |
| 80-81 | grid_import_total_l1 |
grid_import_lifetime_l1 |
| 82-83 | grid_import_total_l2 |
grid_import_lifetime_l2 |
| 88-103 | smart_load{1-4}_energy_total_l{1-2} |
smart_load{N}_lifetime_l{P} |
| 104-118 | ac_couple{1-4}_energy_total_l{1-2} |
ac_couple{N}_lifetime_l{P} |
Note: Regs 84-87 are likely generator lifetime energy (two 32-bit pairs, L1/L2, ÷10 → kWh), based on the EG4 cloud CSV export
eGenAllcolumn. On systems without a generator these registers are always 0. Smart load lifetime confirmed to start at reg 88 (issue #146). Community verification needed: check regs 84-85 (32-bit low+high) on a system with accumulated generator kWh.Warning: Input registers 105-108 are the HIGH words of 32-bit AC couple lifetime energy, NOT smart port status. See Section 5.
Input registers 134-253 are an exact mirror of holding registers 134-253. This is a firmware quirk, NOT new data. Do not add register definitions in this range.
Smart port status is stored as a bit-packed value in HOLDING register 20, not in input registers.
2 bits per port, LSB-first:
Bits 0-1: Port 1
Bits 2-3: Port 2
Bits 4-5: Port 3
Bits 6-7: Port 4
| Value | Meaning | HA Display |
|---|---|---|
| 0 | Unused/Off | Entity not created |
| 1 | Smart Load | smart_port{N}_status = 1 |
| 2 | AC Couple | smart_port{N}_status = 2 |
Register 20 = 18 (0b00010010):
- Port 1: bits 0-1 =
10= 2 (AC Couple) - Port 2: bits 2-3 =
00= 0 (Unused) - Port 3: bits 4-5 =
01= 1 (Smart Load) - Port 4: bits 6-7 =
00= 0 (Unused)
pylxpweb (_register_data.py): read_midbox_runtime() reads holding register 20
and passes it to MidboxRuntimeData.from_modbus_registers(smart_port_mode_reg=value).
The from_modbus_registers() method decodes the bit-packed value:
for port in range(1, 5):
mode = (smart_port_mode_reg >> ((port - 1) * 2)) & 0x03
kwargs[f"smart_port_{port}_status"] = modeCloud API: Uses bitParamControl with BIT_MIDBOX_SP_MODE_N to read/write
this register. The getMidboxRuntime endpoint returns smartPort{N}Status fields.
The per-port settings behind the smart port option entities
(CONFIGURATION.md). pylxpweb's
MIDBOX name map doesn't carry them, so the integration reads them raw over
the local transport and decodes them in const/midbox.py, which holds the
per-field evidence. Parameter keys are the cloud's own names
(MIDBOX_HOLD_SL_START_SOC_1, …); n = port 1-4.
| Register | Field | Encoding |
|---|---|---|
| 229 | FUNC_SMART_LOAD_EN_n bit n-1, FUNC_SMART_LOAD_GRID_ON_n bit n+3, FUNC_AC_COUPLE_EN_n bit n+7, FUNC_SHEDDING_MODE_EN_n bit n+11 |
flags |
| 229+n | Smart Load start / end SOC | low byte start, high byte end (%) |
| 232+2n, 233+2n | Smart Load start / end voltage | ÷10 V |
| 241+n | AC Couple start / end SOC | low / high byte (%) |
| 244+2n, 245+2n | AC Couple start / end voltage | ÷10 V |
| 253+n | Shedding start PV power | ÷10 kW |
| 257+n | Shedding start / end SOC | low / high byte (%) |
| 260+2n, 261+2n | Shedding start / end voltage | ÷10 V |
| 270+6(n-1) … +5 | Smart Load windows: start 1, end 1, start 2, end 2, start 3, end 3 | low byte hour, high byte minute |
| 294+6(n-1) … +5 | AC Couple windows, same layout | low byte hour, high byte minute |
| 2101 | BIT_SMART_LOAD_BASE_ON_n bit n |
1 = SOC/Volt, 0 = Time |
- The cloud also names
BIT_MID_INSTALL_POSITIONandBIT_SMART_LOAD_BASE_ON_TIME_SOC_VOLT_n(the mobile app's "Time+SOC/Volt" option) in 2101; their bit positions are not pinned. Writes change only the one based-on bit and preserve the rest. - Register 229 is read on every GridBOSS refresh (HYBRID: every cycle that refreshes the MID over the dongle), and the full set (blocks 229+40, 269+40, 309+9, 2101+1) on the parameter refresh interval. Each block gets one retry, and a failed block carries its previous values forward; a full read that lost a block is retried after 2, 4, 8 … minutes, capped at the refresh interval.
- Writes are a locked read-modify-write of one register against a fresh read, then a verify read 1.5 s later (the firmware reverts a rejected function bit within a second). The GridBOSS's transport is resolved from the LOCAL MID cache or, in HYBRID, from the station's MID device.
- The voltage scale (÷10) follows the cloud's values but was not
change-tested. Representative Smart Load settings were written from Home
Assistant on port 4 and read back over the dongle (2026-09-30); the exact
tested fields are recorded in
const/midbox.py. The shedding bit (229 bit 15) was also confirmed in the portal. This does not establish a write test for every field or the firmware's behavior at each threshold.
Cloud API responses are mapped to HA sensor keys via dictionaries in
const/sensors/mappings.py. Scaling is applied by _map_device_properties()
in coordinator_mixins.py.
Mapping dict: INVERTER_RUNTIME_FIELD_MAPPING
| API Field | HA Sensor Key | Scale |
|---|---|---|
status |
status_code |
1 |
pinv |
ac_power |
1 |
pLoad170 |
output_power |
1 |
ppv |
pv_total_power |
1 |
ppv1 |
pv1_power |
1 |
ppv2 |
pv2_power |
1 |
ppv3 |
pv3_power |
1 |
pCharge |
battery_charge_power |
1 |
pDisCharge |
battery_discharge_power |
1 |
consumptionPower |
consumption_power |
1 |
vBat |
battery_voltage |
1 |
vpv1 |
pv1_voltage |
1 |
vpv2 |
pv2_voltage |
1 |
vpv3 |
pv3_voltage |
1 |
soc |
state_of_charge |
1 |
frequency |
frequency |
1 |
tinner |
internal_temperature (a constant 0 on some units is published as unknown, #490) |
1 |
tradiator1 |
radiator1_temperature |
1 |
tradiator2 |
radiator2_temperature |
1 |
todayYielding |
yield |
÷10 |
todayCharging |
charging |
÷10 |
todayDischarging |
discharging |
÷10 |
todayLoad |
consumption |
÷10 |
todayGridFeed |
grid_export |
÷10 |
todayGridConsumption |
grid_import |
÷10 |
totalYielding |
yield_lifetime |
÷10 |
totalCharging |
charging_lifetime |
÷10 |
totalDischarging |
discharging_lifetime |
÷10 |
totalLoad |
consumption_lifetime |
÷10 |
totalGridFeed |
grid_export_lifetime |
÷10 |
totalGridConsumption |
grid_import_lifetime |
÷10 |
No
faultCode/warningCodefields (eg4-23a6): thegetInverterRuntimeresponse does not carry the inverter fault/warning codes (pylxpweb'sInverterRuntimemodel has no such fields and the canonical table pairs regs 60-63 withcloud_api_field=None). Thefault_code/warning_codesensors are therefore LOCAL/HYBRID-only — see "Fault / Warning Code Registers" in section 2.
PV1-3 energy does not come from the inverter property map above. The coordinator fetches it separately from the chart analytics endpoints, with independent daily and lifetime throttles:
| API Endpoint | Request / Response Field | HA Sensor Key | Scale |
|---|---|---|---|
/WManage/api/inverterChart/monthColumn |
Current month's today row, ePv1Day..ePv3Day |
pv1_yield..pv3_yield |
Raw 0.1 kWh, ÷10 |
/WManage/api/analyze/energy/totalColumn |
One request per string with energyType=ePvNDay; sum every year row's energy |
pvN_yield_lifetime (N=1..3) |
Raw 0.1 kWh, ÷10 after summing |
The cloud exposes strings 1-3 only; there is no ePv4Day. PV4-6 energy is
therefore LOCAL/HYBRID transport-only. In HYBRID, a local PV1 daily or lifetime
value suppresses the matching cloud tier for that cycle; local transport is
authoritative about which strings exist.
Live validation against plant 1234567890 on 2026-08-01 confirms the fields and scale:
- 18kPV
SYNTH00004: lifetime strings1471.8 + 527.5 + 98.4 = 2097.7kWh, exactly matchinggetInverterEnergyInfo.totalYieldingText = "2097.7". - FlexBOSS21
SYNTH00003: lifetime strings2731.5 + 4481.4 + 4.8 = 7217.7kWh versus aggregate rawtotalYielding = 72216(7221.6kWh), a 0.054% difference (approximately 0.05%). - FlexBOSS21 daily mid-morning: today's raw values
ePv1Day=3,ePv2Day=7,ePv3Day=0produce1.0kWh versustodayYieldingText = "1.1".
Mapping dict: GRIDBOSS_FIELD_MAPPING
| API Field | HA Sensor Key | Scale |
|---|---|---|
gridFreq |
frequency |
÷100 |
genFreq |
generator_frequency |
÷100 |
phaseLockFreq |
phase_lock_frequency |
÷100 |
gridL1RmsVolt |
grid_voltage_l1 |
÷10 |
gridL2RmsVolt |
grid_voltage_l2 |
÷10 |
upsL1RmsVolt |
load_voltage_l1 |
÷10 |
upsL2RmsVolt |
load_voltage_l2 |
÷10 |
upsRmsVolt |
ups_voltage |
÷10 |
gridRmsVolt |
grid_voltage |
÷10 |
genRmsVolt |
generator_voltage |
÷10 |
gridL1RmsCurr |
grid_current_l1 |
÷10 |
gridL2RmsCurr |
grid_current_l2 |
÷10 |
loadL1RmsCurr |
load_current_l1 |
÷10 |
loadL2RmsCurr |
load_current_l2 |
÷10 |
upsL1RmsCurr |
ups_current_l1 |
÷10 |
upsL2RmsCurr |
ups_current_l2 |
÷10 |
genL1RmsCurr |
generator_current_l1 |
÷10 |
genL2RmsCurr |
generator_current_l2 |
÷10 |
gridL1ActivePower |
grid_power_l1 |
1 |
gridL2ActivePower |
grid_power_l2 |
1 |
loadL1ActivePower |
load_power_l1 |
1 |
loadL2ActivePower |
load_power_l2 |
1 |
upsL1ActivePower |
ups_power_l1 |
1 |
upsL2ActivePower |
ups_power_l2 |
1 |
genL1ActivePower |
generator_power_l1 |
1 |
genL2ActivePower |
generator_power_l2 |
1 |
smartLoad{N}L{P}ActivePower |
smart_load{N}_power_l{P} |
1 |
smartPort{N}Status |
smart_port{N}_status |
1 |
| Energy fields | See DIVIDE_BY_10_SENSORS |
÷10 |
Mapped via _get_parallel_group_property_map() (coordinator_mixins.py) from the
parsed pylxpweb parallel-group object. The cloud API fields are:
| API Field | HA Sensor Key | Scale |
|---|---|---|
todayYielding |
yield |
÷10 |
todayCharging |
charging |
÷10 |
todayDischarging |
discharging |
÷10 |
todayExport |
grid_export |
÷10 |
todayImport |
grid_import |
÷10 |
todayUsage |
consumption |
÷10 |
totalYielding |
yield_lifetime |
÷10 |
totalCharging |
charging_lifetime |
÷10 |
totalDischarging |
discharging_lifetime |
÷10 |
totalExport |
grid_export_lifetime |
÷10 |
totalImport |
grid_import_lifetime |
÷10 |
totalUsage |
consumption_lifetime |
÷10 |
Battery data comes from two distinct register ranges:
-
Regular registers (0-255): Aggregate battery bank data (SOC, voltage, current, charge/discharge power, temperature, capacity). Available on ALL inverters with batteries connected. Mapped via
BatteryBankData. -
Extended registers (5002+): Individual battery CAN bus data (per-battery cell voltages, temperatures, cycle counts, SOH). Only available when batteries actively communicate via CAN bus to the inverter.
Important: Some batteries do not communicate on the 5002+ range. This is NOT specific to any inverter family — it can occur with any inverter/battery combination. When 5002+ data is unavailable:
- Battery bank aggregate entities ARE created (from regular registers)
- Individual battery entities are NOT created (no per-battery data)
- Cloud API returns
batteryArray=[],totalNumber=0 - Cross-battery diagnostic sensors (
battery_bank_soc_delta, etc.) return None
Individual Battery Filtering (beta.28+): Even when 5002+ registers are
read, individual batteries with ALL CAN bus data as None (voltage=None,
soc=None) are skipped. This prevents creating "Unknown" entities when CAN
communication is not established. The guard checks batt.voltage is None and batt.soc is None in both coordinator_local.py and coordinator_http.py.
The battery key is the battery device identifier ((DOMAIN, battery_key))
and is embedded in every battery entity unique_id
({inverterSn}_{batteryKey}_{suffix}). All three modes derive ONE canonical,
serial-first key (helpers in utils.py):
| Mode | Derivation | Result |
|---|---|---|
| CLOUD | cloud_battery_key() — cleans the cloud batteryKey ({inverterSn}_{batterySn}) |
{inv}-{batterySn} |
| HYBRID | cloud_battery_key() on the same cloud battery objects (baseline + transport overlay) |
identical to CLOUD |
| LOCAL | local_battery_key() — synthesized from the CAN-reported serial |
identical to CLOUD (cloud batterySn == CAN serial) |
| No serial | positional fallback | {inv}-{slot+1:02d} |
Placeholder serials (Battery_ID_NN, packs whose BMS reports no serial)
collapse to {inv}-NN in every path via clean_battery_display_name(). A
cloud battery whose batteryKey deviates from {inverterSn}_{batterySn}
(which would split LOCAL vs CLOUD identity) logs a one-shot WARNING.
No-serial debounce (LOCAL): a serial-less slot is held for
_NO_SERIAL_EXPOSE_POLLS (3) data-bearing polls before its positional
fallback entity is exposed, so a late-arriving CAN serial claims the identity
first. When a serial later claims a slot, the slot's positional fallback cache
entry is retired (same physical battery — the rr-cache never evicts on its
own).
Migration: pre-#252 HYBRID/LOCAL installs used positional keys
({inv}-01..NN). Once serials are known, battery_migration.py:
- re-identifies the positional device in place when no canonical device exists (device UUID, area, user name and labels survive; device automations and dashboard cards keep working) and renames entity unique_ids in place (entity_id and recorder history preserved);
- when the canonical entity/device already exists (cloud→hybrid installs that ran both identities), the positional duplicates are removed — their own accumulated history and long-term statistics are deleted, not merged; area/name/label customizations on the positional device are backfilled onto the canonical device where the canonical's value is unset;
- skips migration entirely (positional registry rows stay as removable orphans, logged once) when the legacy positional history cannot be attributed safely: rotating packs (more distinct serials than the 4 register slots, or reg 96 > 4) and payloads with duplicate battery serials;
- never renames or removes a row whose entity object is currently live — deferred to the next restart/reload (migration runs before entities instantiate);
- marks a key migrated only after its registry ops succeed (in-memory, per-session guard; a restart re-run is a proven no-op).
Driven by _register_battery_key_migrations() from all three battery
processing paths. Downgrade note: rolling back to a pre-#252 beta
re-creates the positional duplicates.
Base address: 5002, 30 registers per battery, max 5 batteries per inverter.
Address formula: 5002 + (battery_index * 30) + offset
Definitions: pylxpweb/registers/battery.py
| Offset | Canonical Name | Scale | Unit | HA Sensor Key |
|---|---|---|---|---|
| 1 | battery_full_capacity |
1 | Ah | battery_full_capacity |
| 2 | battery_charge_voltage_ref |
÷10 | V | battery_charge_voltage_ref |
| 3 | battery_charge_current_limit |
÷10 | A | battery_max_charge_current |
| 6 | battery_voltage |
÷100 | V | battery_real_voltage |
| 7 | battery_current |
÷10 | A | battery_real_current |
| 8 (low) | battery_soc |
1 | % | battery_rsoc |
| 8 (high) | battery_soh |
1 | % | state_of_health |
| 9 | battery_cycle_count |
1 | - | cycle_count |
| 12 | battery_max_cell_voltage |
÷1000 | V | battery_max_cell_voltage |
| 13 | battery_min_cell_voltage |
÷1000 | V | battery_min_cell_voltage |
| 14 (low) | battery_max_cell_num_temp |
1 | - | battery_max_cell_temp_num |
| 14 (high) | battery_min_cell_num_temp |
1 | - | battery_min_cell_temp_num |
| 15 (low) | battery_max_cell_num_voltage |
1 | - | battery_max_cell_voltage_num |
| 15 (high) | battery_min_cell_num_voltage |
1 | - | battery_min_cell_voltage_num |
Cell-number registers (eg4-4yg): offset 14 holds the TEMPERATURE cell
numbers and offset 15 holds the VOLTAGE cell numbers (low byte = max,
high byte = min). Earlier pylxpweb register maps had these two registers
crossed, so LOCAL/HYBRID modes showed voltage cell numbers in the temp
sensors and vice versa; CLOUD mode was always correct. Verified by live
cross-mode capture (2026-02-26) against
batMaxCellNumTemp/batMaxCellNumVolt.
| API Field | HA Sensor Key |
|---|---|
totalVoltage |
battery_real_voltage |
current |
battery_real_current |
soc |
battery_rsoc |
soh |
state_of_health |
cycleCnt |
cycle_count |
batMaxCellVoltage |
battery_max_cell_voltage |
batMinCellVoltage |
battery_min_cell_voltage |
batMaxCellNumTemp |
battery_max_cell_temp_num |
batMinCellNumTemp |
battery_min_cell_temp_num |
batMaxCellNumVolt |
battery_max_cell_voltage_num |
batMinCellNumVolt |
battery_min_cell_voltage_num |
currentFullCapacity |
battery_full_capacity |
batBmsModelText |
battery_model |
These are derived from register/API data in coordinator_mappings.py:
| HA Sensor Key | Computation |
|---|---|
battery_real_power |
voltage * current |
battery_cell_voltage_delta |
max_cell_voltage - min_cell_voltage |
battery_remaining_capacity |
full_capacity * soc / 100 |
From _build_battery_bank_sensor_mapping(), sourced from regular registers:
| HA Sensor Key | Source |
|---|---|
battery_bank_soc |
BatteryBankData.soc (register 5 low byte) |
battery_bank_voltage |
BatteryBankData.voltage (register 4, ÷10) |
battery_bank_current |
BatteryBankData.current (register 98, ÷10, signed) |
battery_bank_charge_power |
BatteryBankData.charge_power (register 10) |
battery_bank_discharge_power |
BatteryBankData.discharge_power (register 11) |
battery_bank_power |
charge_power - discharge_power |
battery_bank_count |
BatteryBankData.battery_count (register 96) |
battery_bank_min_soh |
Min SOH across individual batteries (5002+ only) |
battery_bank_max_cell_temp |
Max cell temp across individual batteries (5002+ only) |
battery_bank_soc_delta |
Max SOC - Min SOC across individual batteries (5002+ only) |
battery_bank_cell_voltage_delta_max |
Max cell voltage delta across individual batteries (5002+ only) |
Note: Cross-battery diagnostic sensors (min_soh, max_cell_temp, soc_delta, etc.) require individual battery data from the 5002+ register range. When batteries don't communicate on 5002+, these sensors return None.
Input register 95 is a BMS permission/request bitmap (not the legacy
idle/standby/active enum). pylxpweb decode_bms_permissions() splits it into
three flags, validated against the cloud API booleans. The integration encodes
each as an enum sensor on the battery-bank device (both modes via
build_battery_bank_sensors).
| HA Sensor Key | Reg 95 bit | Cloud field | States |
|---|---|---|---|
battery_bank_charge_allowed |
0x01 |
bmsCharge |
Allowed / Blocked |
battery_bank_discharge_allowed |
0x02 |
bmsDischarge |
Allowed / Blocked |
battery_bank_force_charge |
0x20 |
bmsForceCharge |
Requested / Idle |
- LOCAL/HYBRID: decoded from reg 95 onto
BatteryBankData(andInverterRuntimeData/BaseInverter.bms_allow_chargeetc.). - CLOUD: the
BatteryBankdevice delegates to its parent inverter'sbmsCharge/bmsDischarge/bmsForceChargeruntime booleans. battery_bank_force_charge(BMS calibration request) is read-only and distinct from the writable "Forced Charge" control (holding reg 21 bit 11).
Parallel groups aggregate data from multiple inverters in the same group.
| HA Sensor Key | Computation |
|---|---|
pv_total_power |
Sum of all inverter pv_total_power |
grid_power |
Sum of all inverter grid_power |
grid_import_power |
Sum of all inverter grid_import_power |
grid_export_power |
Sum of all inverter grid_export_power |
consumption_power |
Sum of all inverter consumption_power |
eps_power |
Sum of all inverter eps_power |
ac_power |
Sum of all inverter ac_power |
output_power |
Sum of all inverter output_power |
See Section 6 - Parallel Group Energy.
| HA Sensor Key | Source |
|---|---|
parallel_battery_charge_power |
Sum of inverter charge powers |
parallel_battery_discharge_power |
Sum of inverter discharge powers |
parallel_battery_power |
charge - discharge |
parallel_battery_current |
Sum of inverter battery_bank_current |
parallel_battery_soc |
Average SOC |
parallel_battery_voltage |
Average voltage |
parallel_battery_count |
Sum of battery counts |
These keys are NOT directly read from registers or API. They are computed in the coordinator layer.
From INVERTER_COMPUTED_KEYS frozenset in coordinator_mappings.py:
| HA Sensor Key | Computation | Where |
|---|---|---|
consumption_power |
pylxpweb inverter.consumption_power (energy balance: PV + discharge + grid_import - charge - grid_export) |
coordinator_local.py |
total_load_power |
Aliased from consumption_power | coordinator_local.py |
battery_power |
charge_power - discharge_power |
coordinator_local.py |
rectifier_power |
From register 17 (grid_power) — renamed for clarity |
coordinator_local.py |
grid_import_power |
From register 27 (power_to_user) |
coordinator_local.py |
eps_power_l1 |
Direct reg 129 (pylxpweb ≥0.9.36b1); voltage-ratio split eps_power * (V_l1 / (V_l1 + V_l2)) as fallback |
pylxpweb eps_power_l1 |
eps_power_l2 |
Direct reg 130 (pylxpweb ≥0.9.36b1); voltage-ratio split as fallback | pylxpweb eps_power_l2 |
operating_state |
Friendly decode of status_code (see below) |
operating_state_slug() in const/operating_state.py; injected in both paths |
eps_load_poweris no longer computed: the former L1+L2 sum (#197) was the COMBINED backup output and duplicatedeps_power(#335). It now maps the cloudepsLoadPowerfield via the HTTP property map (cloud-only).
status_code (INPUT reg 0 device_status / cloud status) is a bit-packed
operating-mode code. The shared helpers in const/operating_state.py
(operating_state_slug() / is_off_grid()) decode it into two friendly
entities available in all modes (LOCAL/CLOUD/HYBRID), since both paths
produce the identical status_code:
operating_state— a primary enum sensor. The stored state is a stable slug; the localized label lives instrings.json/translations(entity.sensor.operating_state.state.*).off_grid— a binary sensor (binary_sensor.py), ON whenstatus_codeis one of the off-grid codes (OFF_GRID_STATUS_CODES).
| Code | Slug (operating_state) |
Label (en) | Off-grid |
|---|---|---|---|
| 0x00 | standby |
Standby | |
| 0x01 | fault |
Fault | |
| 0x02 | programming |
Programming | |
| 0x04 | pv_to_grid |
PV → Grid | |
| 0x08 | pv_charging |
PV → Battery | |
| 0x0C | pv_charging_to_grid |
PV → Battery + Grid | |
| 0x10 | battery_to_grid |
Battery → Grid | |
| 0x11 | standby |
Standby | |
| 0x14 | pv_battery_to_grid |
PV + Battery → Grid | |
| 0x20 | ac_charging |
AC → Battery | |
| 0x28 | pv_ac_charging |
PV + AC → Battery | |
| 0x40 | off_grid_battery |
Off-Grid (Battery) | ✅ |
| 0x60 | ac_coupled_charging |
Off-Grid (AC-Coupled Charging) | ✅ |
| 0x80 | pv_off_grid |
Off-Grid (PV, unstable) | ✅ |
| 0x88 | pv_charging_off_grid |
Off-Grid (PV + Charging) | ✅ |
| 0xC0 | pv_battery_off_grid |
Off-Grid (PV + Battery) | ✅ |
Off-grid is determined by the threshold rule code >= 0x40 (bit 6/7),
so even an undocumented ≥0x40 combination still trips the Off-Grid binary
sensor. This was corrected against real LXP-LB hardware and the lxp_modbus
integration (#262, @ivanfmartinez):
0x60— Table 9 names it "Off-grid + battery charging" but describes it as "On-grid … AC Coupled" (contradictory). Hardware (main breaker off, AC-couple charging the battery) andlxp_modbusboth confirm it is off-grid.0x20— Table 9's description "Grid charges the battery" is misleading; on real hardware this also occurs charging from AC-coupled PV with no grid, so the label is "AC → Battery", not "Grid → Battery".0x11— an observed Standby alias (not in Table 9) thatlxp_modbusalso maps.
An unmapped code → operating_state is unknown (a debug log records the raw
value; the status_code sensor retains it for diagnosis).
Distinct from the Operating Mode select (holding reg 21 Normal/Standby power control) and the Cloud Status sensor (
status_text— cloud health string, CLOUD/HYBRID only).
| HA Sensor Key | Computation |
|---|---|
grid_power |
grid_l1_power + grid_l2_power |
ups_power |
ups_l1_power + ups_l2_power |
load_power |
load_l1_power + load_l2_power |
generator_power |
gen_l1_power + gen_l2_power |
consumption_power |
load_power (CT measurement = actual consumption) |
hybrid_power |
ups_power - grid_power |
| HA Sensor Key | Source |
|---|---|
firmware_version |
From holding register read or API |
connection_transport |
"Cloud", "Modbus", "Dongle" (from config) |
transport_host |
IP/hostname of transport |
last_polled |
dt_util.utcnow() at refresh time |
midbox_last_polled |
dt_util.utcnow() for GridBOSS refresh |
- Data source: Modbus TCP or WiFi Dongle (direct register reads)
- Static entity creation: First refresh creates all entities with
Nonevalues (zero Modbus reads). Second refresh populates real data. - Consumption: Computed via
_energy_balance()from register values - bt_temperature: Available (register 108) - Modbus-only, also in HYBRID via transport overlay
- Battery data: Read from extended register range (5000+)
- Smart port status: Read from holding register 20 (bit-packed)
- GridBOSS energy: Read from input registers 42-118 (smart load daily at 52-59, lifetime at 88-103)
- Data source: Cloud API HTTP endpoints
- PV1-3 energy: Chart analytics side-fetch (daily: one request per 5 minutes; lifetime: three requests per hour; maximum 15 requests/hour/inverter)
- Consumption:
todayLoad/totalLoadfrom API (server-computed) - bt_temperature: NOT available (no API field)
- Battery data: From
getBatteryInfoAPI endpoint - Smart port status: From
smartPort{N}StatusAPI fields - GridBOSS energy: From
getMidboxRuntimeAPI fields - Scaling: Applied by
_map_device_properties()viaDIVIDE_BY_10_SENSORS
- Data source: Both LOCAL (Modbus for runtime) and CLOUD (API for supplemental)
- Priority: LOCAL data preferred when available; CLOUD fills gaps
- PV1-3 energy: 0 analytics requests/hour when local supplies every string in both tiers. Otherwise the shared daily request runs when any daily string is missing, and lifetime requests run only for the missing lifetime strings.
- Transport-exclusive overlay: When local transport is attached, Modbus-only sensors are overlaid onto cloud data via
_TRANSPORT_OVERLAYincoordinator_mixins.py:bt_temperature,grid_current_l1/l2/l3,battery_current,total_load_power,grid_voltage_l1/l2,eps_voltage_l1/l2,load_power(reg 170, #197),fault_code/warning_code(regs 60-63, eg4-23a6) - GridBOSS overlay:
apply_gridboss_overlay()merges CT data onto parallel group - Consumption: Uses GridBOSS CT
load_powerwhen GridBOSS present
- Same as LOCAL but without direct Modbus connection to GridBOSS
- GridBOSS data comes from cloud API if hybrid, or is absent
- Fewer GridBOSS entities than full LOCAL mode
| Sensor Key | LOCAL | CLOUD | HYBRID | Notes |
|---|---|---|---|---|
bt_temperature |
Yes | No | Yes (overlay) | Modbus reg 108 only |
grid_current_l1/l2/l3 |
Yes | No | Yes (overlay) | Modbus regs 18, 190, 191 |
battery_current (inverter) |
Yes | No | Yes (overlay) | Modbus reg 4 (via _transport_runtime) |
total_load_power |
Yes | API | Yes (overlay) | Aliased from consumption_power |
consumption_power (inverter) |
Computed | API | API or computed | Energy balance vs API |
consumption (energy) |
Computed | API (÷10) | API (÷10) | _energy_balance() vs todayLoad |
| Smart port power | Modbus regs 34-41 | API fields | Both | Filtered by port status |
load_power (inverter) |
Yes | No | Yes (overlay) | Reg 170; EG4_OFFGRID-only — cloud zeroes its mirror field (#197) |
battery_discharge_power |
Yes | API (pDisCharge) | Yes (transport) | Reg 11; EG4_OFFGRID-only entities (#197) |
smart_load_power / grid_load_power / eps_load_power |
No | API (smartLoadPower/gridLoadPower/epsLoadPower) | API (cloud supplemental) | Cloud-only backup-output split; smart load also supported on 18kPV (#625), other split entities EG4_OFFGRID-only (#222/#335); pylxpweb ≥0.9.36 properties. The former eps_load_power_l1/_l2 (#197) were retired duplicates of eps_power_l1/l2 (#335) |
fault_code / warning_code |
Yes | No | Yes (overlay) | Regs 60-63 (32-bit, BMS fallback merge); no cloud field (eg4-23a6) |
pv1_yield..pv3_yield / lifetime |
Yes | Yes (chart side-fetch) | Yes (local preferred) | Cloud exposes ePv1Day..ePv3Day; not in the inverter property map |
pv4_yield..pv6_yield / lifetime |
Yes | No | Yes (overlay) | Local registers only; cloud has no ePv4Day |
_filter_unused_smart_port_sensors() in coordinator_mixins.py filters smart
port entities based on port status from holding register 20 (LOCAL) or API (CLOUD).
For each port (1-4), based on smart_port{N}_status:
| Status | Smart Load Keys | AC Couple Keys | Current Remap |
|---|---|---|---|
| 0 (Unused) | All removed | All removed | N/A |
| 1 (Smart Load) | Power: setdefault(0.0) |
All removed | No remap |
| 2 (AC Couple) | All removed | Power: setdefault(0.0) |
smart_load{N}_current_l{1,2} → ac_couple{N}_current_l{1,2} |
- Correct-type power sensors: Ensures key exists with real value or 0.0
- Unused ports: All keys removed; the port device's sensors read nothing and are disabled (see Entities below)
- AC Couple current remap: Current register values are always in
smart_load{N}keys from the mapping function; for AC couple ports, the filter pops the smart_load current value and inserts it under the ac_couple current key
The 58 keys in GRIDBOSS_SMART_PORT_DYNAMIC_KEYS:
smart_load{1-4}_power_l{1-2} (L1/L2 per-port power)
ac_couple{1-4}_power_l{1-2} (L1/L2 per-port power)
smart_load{1-4}_power (per-port aggregate, computed by _calculate_gridboss_aggregates)
ac_couple{1-4}_power (per-port aggregate, computed by _calculate_gridboss_aggregates)
smart_load_power (total across all smart load ports)
ac_couple_power (total across all AC couple ports)
smart_load{1-4}_current_l{1-2} (L1/L2 per-port RMS current, Modbus regs 18-25 or cloud smartLoad{N}L{1,2}RmsCurr)
ac_couple{1-4}_current_l{1-2} (L1/L2 per-port RMS current, remapped from smart_load)
smart_load{1-4}_today (per-port energy today)
smart_load{1-4}_total (per-port energy lifetime)
ac_couple{1-4}_today (per-port energy today)
ac_couple{1-4}_total (per-port energy lifetime)
Aggregation behavior: _calculate_gridboss_aggregates() runs AFTER the filter.
When both L1 and L2 are None (wrong-type port), the per-port aggregate is also
set to None instead of computing 0.0. Total aggregates only sum correct-type ports.
Per-port keys are not GridBOSS entities. Each port is its own device
(smart_port_devices.py) with a fixed sensor set created at setup: mode-neutral
power/current sensors that read {mode}{N}_{suffix} for the port's current mode
(smart_port{N}_status is the key prefix), and one energy pair per mode
(smart_load{N}_today/total, ac_couple{N}_today/total). Sensors that don't
serve the port's mode are disabled in the entity registry.
Only the cross-port totals (smart_load_power, ac_couple_power) are still
registered dynamically, via a coordinator listener in sensor.py, when a port
first reports that mode.
When a GridBOSS device is present, its CT (Current Transformer) measurements provide accurate grid and load power readings for the parallel group. These override the less accurate inverter-summed values.
apply_gridboss_overlay() in coordinator_mixins.py (module-level function).
Called from BOTH:
coordinator_http.py(HYBRID mode)coordinator_local.py(LOCAL mode)
_GRIDBOSS_PG_OVERLAY dict maps GridBOSS sensor keys to parallel group keys:
| GridBOSS Key | Parallel Group Key | Category |
|---|---|---|
grid_power |
grid_power (PG) |
Power |
grid_power_l1 |
grid_power_l1 (PG) |
Power |
grid_power_l2 |
grid_power_l2 (PG) |
Power |
load_power |
load_power (PG) |
Power |
load_power_l1 |
load_power_l1 (PG) |
Power |
load_power_l2 |
load_power_l2 (PG) |
Power |
grid_voltage_l1 |
grid_voltage_l1 (PG) |
Voltage |
grid_voltage_l2 |
grid_voltage_l2 (PG) |
Voltage |
grid_export_today |
grid_export (PG) |
Energy (daily) |
grid_export_total |
grid_export_lifetime (PG) |
Energy (lifetime) |
grid_import_today |
grid_import (PG) |
Energy (daily) |
grid_import_total |
grid_import_lifetime (PG) |
Energy (lifetime) |
Consumption energy (UPS + Load): Computed separately after the overlay loop because it requires summing two MID sources (not a simple key→key mapping):
consumption = ups_today + load_today
consumption_lifetime = ups_total + load_total
UPS CTs measure inverter output (backup loads); Load CTs measure direct-from-grid loads that bypass the inverter. Both contribute to total consumption.
After the shared apply_gridboss_overlay(), coordinator_local.py applies:
Consumption power (energy balance with MID grid_power):
consumption_power = pv_total_power + battery_net + grid_power
where battery_net = parallel_battery_discharge_power - parallel_battery_charge_power
and grid_power is from MID overlay (positive = importing)
clamped to >= 0
Grid voltage conditional: Grid voltage is copied from the master inverter ONLY when no MID device is present. When a MID device exists, the overlay provides authoritative grid voltage (inverter regs 193-194 return 0 on 18kPV/FlexBOSS firmware).
AC couple PV inclusion (optional, controlled by CONF_INCLUDE_AC_COUPLE_PV):
pv_total_power += Σ ac_couple_port_l1 + ac_couple_port_l2
(for all ports with status == 2)
Captured 2026-02-13 from Docker test environment (v3.2.0-beta.32).
Test configuration: 2 inverters (FlexBOSS21 + 18kPV), 1 GridBOSS, batteries. Smart ports: Port 1 = AC Couple, Port 2 = Unused, Port 3 = Smart Load, Port 4 = Unused.
| Mode | Total | Sensors | Switches | Updates | Numbers | Buttons | Selects | Unavail | Unknown |
|---|---|---|---|---|---|---|---|---|---|
| CLOUD | 475 | 424 | 17 | 3 | 18 | 11 | 2 | 10 | 8 |
| HYBRID | 485 | 434 | 17 | 3 | 18 | 11 | 2 | 17 | 8 |
| LOCAL | 454 | 413 | 14 | 3 | 18 | 4 | 2 | 2 | 2 |
| LOCAL-NOMIDBOX | 391 | 352 | 14 | 2 | 18 | 3 | 2 | 1 | 1 |
Entity growth since v3.2.0-beta.25: Entity counts increased from ~365-373 to ~391-485 due to addition of button entities (cloud-only commands), select entities (working mode), additional switch entities (cloud-only controls), and transport-exclusive sensors.
- HYBRID has the most entities (485): combines LOCAL sensors + CLOUD-only buttons/switches
- CLOUD (475): includes cloud-only command buttons (11) and cloud-only switches (3 extra vs LOCAL)
- LOCAL (454): includes transport-exclusive sensors (bt_temperature, grid_current, etc.)
- LOCAL-NOMIDBOX (391): no GridBOSS sensors, fewer buttons/updates
HYBRID has 10 more entities than CLOUD due to transport-exclusive sensors
(bt_temperature, battery_current, total_load_power, grid_current_l1/l2/l3,
transport_ip_address per inverter). CLOUD has cloud-only command buttons not
available in LOCAL mode.
| Constant | Count | Description |
|---|---|---|
INVERTER_RUNTIME_KEYS |
43 | Voltage, current, power, temperature, status, grid_current_l1/l2/l3 |
INVERTER_ENERGY_KEYS |
12 | Daily + lifetime energy (6 each) |
BATTERY_BANK_KEYS |
23 | Battery aggregate sensors (incl. battery_bank_current) |
INVERTER_COMPUTED_KEYS |
7 | Derived sensors (consumption, battery, EPS split) |
INVERTER_METADATA_KEYS |
4 | Firmware, transport, host, last_polled |
ALL_INVERTER_SENSOR_KEYS |
89 | Union of all above |
GRIDBOSS_SENSOR_KEYS |
83 | All GridBOSS sensor keys (incl. smart port, energy, metadata) |
GRIDBOSS_SMART_PORT_DYNAMIC_KEYS |
58 | Smart load + AC couple power, current, and energy (L1/L2 power, L1/L2 current, per-port aggregates, per-port energy today/total, total aggregates) |
PARALLEL_GROUP_SENSOR_KEYS |
32 | PG power, energy, battery aggregates (incl. grid import/export, battery current) |
PARALLEL_GROUP_GRIDBOSS_KEYS |
5 | Additional keys from CT overlay |
| Constant | Description |
|---|---|
DIVIDE_BY_10_SENSORS |
All energy sensors requiring ÷10 from Cloud API |
DIVIDE_BY_100_SENSORS |
Frequency sensors requiring ÷100 from Cloud API |
VOLTAGE_SENSORS |
GridBOSS voltage sensors requiring ÷10 from Cloud API |
CURRENT_SENSORS |
GridBOSS current sensors requiring ÷10 from Cloud API |
| Family | split_phase |
three_phase |
Models |
|---|---|---|---|
EG4_OFFGRID |
True | False | 12000XP, 6000XP |
EG4_HYBRID |
True | False | FlexBOSS, 18kPV, 12kPV |
LXP (code 12) |
False | True | LXP-EU (3-phase) |
LXP (code 44) |
True | False | LXP-LB (BR/US) |
This section documents every calculation, derivation, scaling, and transformation in the data pipeline. Organized by pipeline stage.
The canonical reader applies scale factors from RegisterDefinition.scale:
ScaleFactor.DIVIDE_10 → raw_value / 10.0 (voltages, energy kWh)
ScaleFactor.DIVIDE_100 → raw_value / 100.0 (frequencies, currents)
ScaleFactor.DIVIDE_1000 → raw_value / 1000.0 (cell voltages)
ScaleFactor.NONE → raw_value (power W, status codes)
read_scaled(registers, field) returns the final float.
read_raw(registers, field) returns the unscaled int.
For lifetime energy counters spanning two 16-bit registers:
value = (high_word << 16) | low_word
RegisterDefinition with size=2 auto-sets little_endian=True via
__post_init__. The "low" register has the lower address, "high" the next.
Final value is then scaled (typically ÷10 for kWh).
Example: Regs 46-47 → (reg47 << 16) | reg46 → ÷10 → yield_lifetime kWh.
Register 5 (soc_soh_packed) encodes two values in a single 16-bit register:
soc = raw_value & 0xFF # Low byte: State of Charge (0-100%)
soh = (raw_value >> 8) & 0xFF # High byte: State of Health (0-100%)Handled by InverterRuntimeData.from_modbus_registers() in pylxpweb.
Function: coordinator_mixins._map_device_properties(device, property_map)
Behavior:
- Iterates
property_map(API field → sensor key) - Calls
getattr(device, property_name, None)— catches TypeError/ValueError from property getters that callfloat(None)on unpopulated data - Skips
Nonevalues and empty strings — keys not added to sensors dict - Non-None values passed through as-is
Post-mapping scaling in coordinator_http.py and coordinator_mixins.py:
After _map_device_properties() produces the sensor dict, scaling is applied
based on membership in these sets (defined in const/sensors/mappings.py):
| Scaling Set | Factor | Applied To |
|---|---|---|
DIVIDE_BY_10_SENSORS |
value / 10 |
All energy sensors (kWh) |
DIVIDE_BY_100_SENSORS |
value / 100 |
Frequency sensors (Hz) |
VOLTAGE_SENSORS |
value / 10 |
GridBOSS voltage sensors (V) |
CURRENT_SENSORS |
value / 10 |
GridBOSS current sensors (A) |
Note: Inverter voltage/power sensors arrive pre-scaled from pylxpweb properties in CLOUD mode. Only GridBOSS and energy sensors need post-mapping scaling since they come from raw JSON fields.
File: coordinator_mixins.py:260
def _safe_numeric(value: Any) -> float:
if value is None:
return 0.0
try:
return float(value)
except (ValueError, TypeError):
return 0.0Used throughout aggregation code. Critical behavior: None → 0.0.
This means summing a mix of real values and None treats missing data as zero.
The smart port aggregation explicitly checks for None before calling this
to avoid creating misleading 0.0 aggregates for wrong-type ports.
These sensors are NOT read from a single register or API field. They are computed in the coordinator layer.
Source: pylxpweb inverter.consumption_power property
consumption_power = pv_total_power + grid_import - grid_export
(clamped to >= 0)
Where: coordinator_local.py — set via inverter.consumption_power
property accessor.
Function: coordinator_mappings._energy_balance()
def _energy_balance(pv, discharge, grid_import, charge, grid_export):
if all(v is None for v in (pv, discharge, grid_import, charge, grid_export)):
return None
result = (float(pv or 0)
+ float(discharge or 0)
+ float(grid_import or 0)
- float(charge or 0)
- float(grid_export or 0))
return max(0.0, result)Formula:
consumption = yield + discharging + grid_import - charging - grid_export
(clamped >= 0, returns None if all inputs None)
When applied:
_build_energy_sensor_mapping()always uses energy balance for LOCAL sensors_process_inverter_object()recalculates when_transportis attached (overrides pylxpweb'senergy_today_usagewhich reads the wrong register)
CLOUD mode: Uses todayLoad / totalLoad from API (server-computed).
battery_power = charge_power - discharge_power
Positive = net charging, negative = net discharging.
Source: inverter.battery_power property in pylxpweb (LOCAL),
or batPower API field (CLOUD).
grid_power = power_to_user - power_to_grid
Positive = importing from grid, negative = exporting to grid.
Both paths use this NET-flow formula (eg4-9wf): CLOUD/HYBRID computes it in
coordinator_mixins.py _process_inverter_object(); LOCAL computes it in
_build_runtime_sensor_mapping() from regs 27/26 (power_from_grid −
power_to_grid). Register 17 (Prec) is RECTIFIER power and feeds the
separate rectifier_power sensor — it must never be published as
grid_power (the historical LOCAL mapping did exactly that).
Split-phase EPS power per leg. Since pylxpweb 0.9.36b1 these prefer the direct register reads (input regs 129/130) and only fall back to the voltage-ratio split for older firmware / missing registers:
eps_power_l1 = eps_power * (eps_voltage_l1 / (eps_voltage_l1 + eps_voltage_l2))
eps_power_l2 = eps_power * (eps_voltage_l2 / (eps_voltage_l1 + eps_voltage_l2))CLOUD mode reads the pEpsL1N / pEpsL2N API fields.
Source: inverter.eps_power_l1 / inverter.eps_power_l2 properties in
pylxpweb. Set in coordinator_local.py _build_local_device_data().
The #197 sensors aliased eps_power_l1/eps_power_l2 (regs 129/130 / cloud
pEpsL1N/pEpsL2N) onto eps_load_power_l1/_l2 and published the L1+L2 sum
as eps_load_power. Those source values are the COMBINED backup-path output
(smart load + EPS loads), so the sensors were exact duplicates of
eps_power_l1/l2 — the #197 "sum 1327 ≈ cloud epsLoadPower 1338"
validation was a smart-load-idle coincidence (#222 evidence: with the GEN
port active, L1+L2 = 3371 W = smartLoadPower 2999 + epsLoadPower 365).
No per-leg fields for the EPS-loads subset exist on any path. The keys were
removed from SENSOR_TYPES/key sets and orphaned registry entries are purged
at setup (_DEPRECATED_DUPLICATE_SENSOR_SUFFIXES in __init__.py).
Cloud-only backup-output split, mapped from the pylxpweb smart_load_power /
grid_load_power / eps_load_power properties (cloud smartLoadPower /
gridLoadPower / epsLoadPower runtime fields, raw W; eps_load_power
shipped in pylxpweb 0.9.36, pylxpweb #219 — the property returns None when no
cloud runtime is attached, so the key stays absent in pure-LOCAL operation):
| Mode | Source |
|---|---|
| CLOUD | getInverterRuntime fields via the HTTP property map |
| HYBRID | Same cloud fields — the pylxpweb properties intentionally read the HTTP runtime even with a transport attached, and BaseInverter.refresh() schedules a supplemental _fetch_runtime_http() for EG4_OFFGRID devices with a healthy transport so the values track the cloud on the runtime TTL instead of freezing at the setup-time snapshot |
| LOCAL | Absent — no validated register on the off-grid family; keys are deliberately NOT in ALL_INVERTER_SENSOR_KEYS |
The smart_load_power entity also exposes the 18kPV cloud GEN-port value
(reporter-confirmed in #625). Only grid_load_power and eps_load_power
retain the off-grid family gate.
Live evidence (6000XP, EV charging on the GEN port): smartLoadPower
2999 W + epsLoadPower 365 W ≈ peps 3371 W combined backup output.
Candidate local register for future validation: input reg 232
(smart_load_power in the 18kPV firmware RE) — never observed non-zero,
different firmware codebase, DO NOT wire without off-grid hardware proof.
Alias for consumption_power. Same value, different sensor key for
backward compatibility.
Direct alias for register 17 (rectifier_power in pylxpweb; the dataclass
field was renamed from the misleading grid_power in eg4-9wf). AC-charging
rectifier power (grid-to-battery), distinct from the computed net
grid_power sensor.
Unified LOAD-OUTPUT semantics on every path (eg4-9e4): LOCAL/HYBRID read
input register 170 (Pload), pure CLOUD reads its mirror pLoad170 via the
pylxpweb power_output property. Historically the cloud path published
pinv here, making the sensor an exact duplicate of ac_power
(live-confirmed on prod FlexBOSS21: both 2309 W). consumptionPower114 is
NOT used as the mirror — it reads 0 on FlexBOSS21 while pLoad170 is
populated on every model. Entity creation is no longer split-phase-gated
(reg 170 exists on all families).
EG4_OFFGRID cloud-zero gate: the cloud zeroes its reg-170 mirror for
EG4_OFFGRID models (#197), so drop_offgrid_cloud_output_power() in
coordinator_mappings.py removes the cloud-mapped key unless transport
runtime backs it or the family is in _CLOUD_PLOAD170_TRUSTED_FAMILIES
(EG4_HYBRID live-verified, LXP canonically paired — UNKNOWN and any
unrecognized family drop, since the pylxpweb enum emits the truthy string
"UNKNOWN" on failed detection). Pure-CLOUD 12000XP/6000XP therefore has no
output_power entity (same policy as load_power); LOCAL and HYBRID get
the genuine register value.
Direct alias for register 27 (power_to_user in pylxpweb).
File: coordinator_mixins.py:1306
Computes total power from individual L1/L2 values using sum_l1_l2():
def sum_l1_l2(l1_key, l2_key):
if l1_key in sensors and l2_key in sensors:
l1_val, l2_val = sensors[l1_key], sensors[l2_key]
if l1_val is None and l2_val is None:
return None # Wrong-type port marker
return _safe_numeric(l1_val) + _safe_numeric(l2_val)
return None # Keys don't existSimple L1+L2 pairs:
| Output Key | Formula |
|---|---|
grid_power |
grid_power_l1 + grid_power_l2 |
ups_power |
ups_power_l1 + ups_power_l2 |
load_power |
load_power_l1 + load_power_l2 |
generator_power |
generator_power_l1 + generator_power_l2 |
Smart port per-port aggregates:
| Output Key | Formula |
|---|---|
smart_load{N}_power |
smart_load{N}_power_l1 + smart_load{N}_power_l2 |
ac_couple{N}_power |
ac_couple{N}_power_l1 + ac_couple{N}_power_l2 |
For wrong-type ports (both L1 and L2 are None), the per-port aggregate
is also set to None (not 0.0).
Smart port total aggregates:
| Output Key | Formula |
|---|---|
smart_load_power |
Sum of all smart_load{N}_power where value is not None |
ac_couple_power |
Sum of all ac_couple{N}_power where value is not None |
Total keys are only created when at least one port of that type has a real value. If no ports are active, the total key is not added to the sensors dict.
consumption_power = load_power (direct CT measurement, NOT energy balance)
The GridBOSS load CT is the authoritative consumption measurement. Set in
_build_gridboss_sensor_mapping() (LOCAL) and
_get_mid_device_property_aliases() (CLOUD/HYBRID, eg4-7uz). The cloud
alias table exists because _get_mid_device_property_map() is keyed by
property name, so load_power cannot feed a second sensor key there.
hybrid_power = ups_power - grid_power
Computed by MIDRuntimePropertiesMixin.hybrid_power in pylxpweb.
battery_real_power = voltage * current
Computed by pylxpweb Battery.power property. Also available from
BatteryData.power in LOCAL mode.
battery_cell_voltage_delta = max_cell_voltage - min_cell_voltage
Computed by pylxpweb Battery.cell_voltage_delta / BatteryData.cell_voltage_delta.
battery_remaining_capacity = max_capacity * soc / 100
Computed by pylxpweb BatteryData.remaining_capacity.
battery_capacity_percentage = remaining_capacity / full_capacity * 100
Only computed when not already provided by the library. Calculated in
_calculate_battery_derived_sensors().
battery_cell_voltage_diff = max_cell_voltage - min_cell_voltage
Rounded to 3 decimal places. Only computed when battery_cell_voltage_diff
is not already in the sensors dict. Calculated in
_calculate_battery_derived_sensors().
File: coordinator_mappings._build_battery_bank_sensor_mapping()
Primary formula:
battery_bank_power = charge_power - discharge_power
Positive = net charging, negative = net discharging.
Fallback (when charge/discharge unavailable):
battery_bank_power = battery_data.battery_power (V * I computation)
Note: In HTTP mode (_extract_battery_bank_from_object), the same
charge−discharge formula is used as fallback when batPower API field
is not present.
These are computed by BatteryBankData properties in pylxpweb, comparing
values across all batteries in the bank:
| Sensor Key | Computation |
|---|---|
battery_bank_soc_delta |
max(soc) - min(soc) across all batteries |
battery_bank_min_soh |
min(soh) across all batteries |
battery_bank_soh_delta |
max(soh) - min(soh) across all batteries |
battery_bank_voltage_delta |
max(voltage) - min(voltage) across all batteries |
battery_bank_cell_voltage_delta_max |
max(cell_voltage_delta) across all batteries |
battery_bank_cycle_count_delta |
max(cycle_count) - min(cycle_count) across all batteries |
battery_bank_max_cell_temp |
max(max_cell_temp) across all batteries |
battery_bank_temp_delta |
max(max_cell_temp) - min(min_cell_temp) across all batteries |
All return None when insufficient data (fewer than 1 battery with the field).
File: coordinator_local.py:_process_local_parallel_groups()
These sensors are summed across all member inverters:
pv_total_power = Σ inverter.pv_total_power
grid_power = Σ inverter.grid_power
grid_import_power = Σ inverter.grid_import_power
grid_export_power = Σ inverter.grid_export_power
consumption_power = Σ inverter.consumption_power
eps_power = Σ inverter.eps_power
ac_power = Σ inverter.ac_power
output_power = Σ inverter.output_power
Only non-None values are included in each sum.
yield = Σ inverter.yield
charging = Σ inverter.charging
discharging = Σ inverter.discharging
grid_import = Σ inverter.grid_import
grid_export = Σ inverter.grid_export
consumption = Σ inverter.consumption
(same for _lifetime variants)
parallel_battery_charge_power = Σ inverter.battery_charge_power
parallel_battery_discharge_power = Σ inverter.battery_discharge_power
parallel_battery_power = discharge_sum - charge_sum
(positive = discharging)
parallel_battery_soc = average(inverter.state_of_charge)
parallel_battery_voltage = average(inverter.battery_voltage)
parallel_battery_current = Σ inverter.battery_bank_current
parallel_battery_count = Σ inverter.battery_bank_count
parallel_battery_max_capacity = Σ inverter.battery_bank_max_capacity
parallel_battery_current_capacity = Σ inverter.battery_bank_current_capacity
Sign convention note: parallel_battery_power uses discharge - charge
(positive = discharging), which is the opposite sign convention from
battery_bank_power (which uses charge - discharge, positive = charging).
Grid voltage L1/L2 comes from the MID device (GridBOSS) when present (via
apply_gridboss_overlay()). When no MID device exists, it falls back to the
master inverter:
grid_voltage_l1 = gridboss.grid_voltage_l1 (if MID present)
grid_voltage_l2 = gridboss.grid_voltage_l2 (if MID present)
# OR
grid_voltage_l1 = master_inverter.grid_voltage_l1 (fallback, no MID)
grid_voltage_l2 = master_inverter.grid_voltage_l2 (fallback, no MID)
Rationale: Inverter regs 193-194 return 0 on 18kPV/FlexBOSS firmware. The MID device has authoritative grid voltage from its own sensors.
Function: apply_gridboss_overlay() in coordinator_mixins.py
The _GRIDBOSS_PG_OVERLAY dict maps GridBOSS sensor keys to parallel group
sensor keys. Only non-None GridBOSS values are applied (cast to float).
| GridBOSS Key | → Parallel Group Key | Category |
|---|---|---|
grid_power |
grid_power |
Power |
grid_power_l1 |
grid_power_l1 |
Power |
grid_power_l2 |
grid_power_l2 |
Power |
load_power |
load_power |
Power |
load_power_l1 |
load_power_l1 |
Power |
load_power_l2 |
load_power_l2 |
Power |
grid_voltage_l1 |
grid_voltage_l1 |
Voltage |
grid_voltage_l2 |
grid_voltage_l2 |
Voltage |
grid_export_today |
grid_export |
Energy (daily) |
grid_export_total |
grid_export_lifetime |
Energy (lifetime) |
grid_import_today |
grid_import |
Energy (daily) |
grid_import_total |
grid_import_lifetime |
Energy (lifetime) |
Consumption energy (computed after overlay loop):
consumption = ups_today + load_today
consumption_lifetime = ups_total + load_total
UPS CTs measure backup loads (inverter output); Load CTs measure non-backup loads (direct from grid). Both contribute to total consumption.
After the shared overlay, _process_local_parallel_groups() applies:
Consumption power (energy balance with MID grid_power):
consumption_power = pv_total_power + battery_net + grid_power
where battery_net = parallel_battery_discharge_power - parallel_battery_charge_power
and grid_power comes from MID overlay (positive = importing)
clamped to >= 0
Rationale: Inverters lack grid CTs in MID systems — their grid register
values are unreliable. The MID overlay provides authoritative grid_power,
which combined with known PV and battery flow yields accurate consumption.
AC couple PV inclusion (optional, controlled by CONF_INCLUDE_AC_COUPLE_PV):
pv_total_power += Σ ac_couple_port_l1 + ac_couple_port_l2
(for all ports with status == 2)
When enabled, AC-coupled solar inverters on smart ports are included in the total PV power. Default: disabled.
for port in range(1, 5):
status = (register_20_value >> ((port - 1) * 2)) & 0x03See Section 5 for encoding details. The decoded status drives all smart port sensor filtering in Section 11.
| Sensor Key | CLOUD Source | LOCAL Computation |
|---|---|---|
consumption |
todayLoad ÷ 10 (server-computed) |
_energy_balance(yield, discharge, grid_import, charge, grid_export) |
consumption_lifetime |
totalLoad ÷ 10 (server-computed) |
_energy_balance(...) on lifetime values |
consumption_power |
consumptionPower API field |
inverter.consumption_power property (energy balance on instantaneous power) |
grid_power (inverter) |
Computed: pToUser - pToGrid |
Computed: power_to_user - power_to_grid (same formula, different source) |
battery_power (inverter) |
batPower API field |
inverter.battery_power property (charge - discharge) |
battery_bank_power |
batPower API or charge - discharge |
charge_power - discharge_power (with V*I fallback) |
| GridBOSS L1+L2 aggregates | Computed by _calculate_gridboss_aggregates() |
Same function, same computation |
| Smart port status | smartPort{N}Status API fields |
Holding register 20 bit-decode |
| GridBOSS energy | getMidboxRuntime API (÷10 scaling) |
Input registers 42-67 (daily), 68-103 (lifetime), 104-118 (AC couple) (÷10 by canonical reader) |
Data validation operates at two independent layers:
Controlled by inverter.validate_data property (set from CONF_DATA_VALIDATION
option in the Options flow). When enabled, is_corrupt() canary checks run
after each Modbus read. If corrupt, the read is rejected and the previous
cached value is preserved.
Canary fields by data class:
| Data Class | Check | Threshold | Rationale |
|---|---|---|---|
InverterRuntimeData |
_raw_soc > 100 |
SoC physically 0-100% | Register desync |
InverterRuntimeData |
_raw_soh > 100 |
SoH physically 0-100% | Register desync |
InverterRuntimeData |
frequency < 30 or > 90 |
World grids 50/60 Hz | Extreme corruption only |
InverterRuntimeData |
frequency == 0 |
Allowed (off-grid/EPS) | Not corruption |
BatteryData |
_raw_soc > 100 |
SoC 0-100% | CAN bus error |
BatteryData |
_raw_soh > 100 |
SoH 0-100% | CAN bus error |
BatteryData |
voltage > 100V |
No LFP exceeds 60V | Register desync |
BatteryBankData |
Cascade to batteries | Skips ghost batteries (V=0, SoC=0) | No CAN data |
MidboxRuntimeData |
frequency < 30 or > 90 |
Same as inverter | Extreme corruption |
MidboxRuntimeData |
smart_port_status > 2 |
Valid: 0, 1, 2 | Bit decode error |
Implementation: coordinator_local.py sets inverter.validate_data = self._data_validation_enabled
before each inverter.refresh() call. The _data_validation_enabled property
reads from CONF_DATA_VALIDATION in the config entry options (default: True).
Energy monotonicity: validate_energy_monotonicity() in pylxpweb/validation.py
checks that lifetime energy counters never decrease (and don't spike upward
by more than 72 kWh — the max hourly throughput at 300A/240V) between
refresh cycles. Runs inside device refresh()
methods (BaseInverter._fetch_energy(), _fetch_combined_input_data(),
MIDDevice.refresh()), protecting all pylxpweb consumers automatically.
Validated fields are returned by lifetime_energy_values() on each data class:
InverterEnergyData: 8 fields (pv_energy_total, charge_energy_total, etc.)MidboxRuntimeData: 24 fields (all*energy_total*fields)
When corruption is detected, the new value is rejected and the previous cached value is preserved. After 3 consecutive rejections of the same direction, the new value is accepted as a baseline (self-healing), provided it exceeds 1000 kWh minimum.
The data validation toggle appears in Options flow (_config_flow/options.py)
only when local transports are configured (Modbus TCP, WiFi dongle, serial).
Cloud-only mode does not show this option since the cloud API has its own
server-side validation.