Skip to content

Latest commit

 

History

History
2613 lines (2140 loc) · 131 KB

DATA_MAPPING.md

File metadata and controls

2613 lines (2140 loc) · 131 KB

EG4 Web Monitor - Data Mapping Reference

Register ground truth lives in the graded ledger, not here

llmwiki/40-hardware/registers.md is 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.yaml and docs/api/README.md (44 endpoints / 55 schemas; includes the firmware update lifecycle and the WAITING status handling from #353). This document remains canonical for the scaling and register-to-sensor mappings the OpenAPI spec does not cover.


Table of Contents

  1. Architecture Overview
  2. Inverter Input Registers
  3. Inverter Holding Registers (Parameters)
  4. GridBOSS Input Registers
  5. GridBOSS Holding Register 20 (Smart Port Status) — includes the smart port option registers
  6. Cloud API Field Mappings
  7. Individual Battery Data
  8. Parallel Group Data
  9. Computed / Derived Sensor Keys
  10. Mode Differences
  11. Smart Port Sensor Filtering
  12. GridBOSS CT Overlay
  13. Entity Counts by Mode
  14. Key Constants Reference
  15. All Calculations Reference
  16. Data Validation

1. Architecture Overview

Data Flow (LOCAL / Modbus)

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

Data Flow (CLOUD / HTTP)

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

Data Flow (HYBRID)

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 Responsibilities

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

2. Inverter Input Registers

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

Runtime Registers (Read every refresh cycle)

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 (Pload in the 6kXP Modbus PDF — valid both grid-tied AND in EPS mode). The cloud zeroes its reg-170 mirror for EG4_OFFGRID, so load_power comes from the LOCAL register only (LOCAL mapping + HYBRID _TRANSPORT_OVERLAY; absent in pure CLOUD). load_power, battery_discharge_power and the backup-output-split sensors below are gated to EG4_OFFGRID via OFFGRID_ONLY_SENSORS in const/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 = cloud smartLoadPower 2999 W + epsLoadPower 365 W. The cloud-only split is exposed as smart_load_power / grid_load_power / eps_load_power sensors (CLOUD/HYBRID supplemental, EG4_OFFGRID-gated; no validated local register — the 18kPV firmware RE names input reg 232 smart_load_power but it has never been observed non-zero and is unvalidated on off-grid hardware, and regs 129/130 are the combined legs, not the epsLoadPower subset). The former eps_load_power_l1/_l2 sensors and the L1+L2 eps_load_power sum (#197) were RETIRED in #335: they aliased the combined regs 129/130 values and so exactly duplicated eps_power_l1/l2 — the #197 "sum ≈ cloud epsLoadPower" validation was a smart-load-idle coincidence. eps_load_power now maps the real cloud epsLoadPower field via the pylxpweb eps_load_power property (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 — gridL1RmsVolt lives only on MidboxData). The coordinator therefore drops per-inverter grid_voltage_l1/l2 when 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

Generator Registers (121-126, 195/196) — family-dependent

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=None on their pylxpweb RegisterDefinition, but the sensors still exist — the coordinator maps them explicitly from the pylxpweb properties (coordinator_mappings.py), so the register's own ha_sensor_key is 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 at 0x0801E9CA returns RAM16[0x2000D70A], an ARM-local 16-bit counter incremented once per second at 0x08018BDA with 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/0x2000DB51 and the two halves of RAM32[0x2000D890]), not accumulators — the "135,494.5 kWh" lifetime figure is the bitfield 0x0014ACC1.

The generator legs themselves are instrumented on off-grid: 121, 122, 195 and 196 all read the DSP receive-frame block (0x2000CE5C base) 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.

Bus Voltage Registers

Reg Canonical Name Scale Unit HA Sensor Key
38 bus_voltage_1 ÷10 V bus1_voltage
39 bus_voltage_2 ÷10 V bus2_voltage

Fault / Warning Code Registers (32-bit pairs)

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_registers merges the BMS codes (regs 99/100, canonical bms_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 cloud getInverterRuntime response has no faultCode/warningCode field (canonical table cloud_api_field=None), so the keys flow through the LOCAL runtime table plus the HYBRID _TRANSPORT_OVERLAY and are correctly absent in pure CLOUD mode. pylxpweb also exposes decoded text via InverterRuntimeData.fault_messages/warning_messages (pylxpweb.constants.fault_codes); the sensors deliberately publish the raw numeric code.

Temperature Registers

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 getInverterRuntime payload relays tinner: 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). tinner is 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 in internal_temperature, battery_temperature or bt_temperature is therefore published as None (HA “unknown”) instead of a wrong constant, via blank_constant_zero_temperatures in coordinator_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/None radiators PROTECT the reading (publish the 0) — there is no evidence it is bogus.

CLOUD-path narrowing vs #490: earlier #490 blanked cloud tinner: 0 unconditionally (including when radiators were absent). The warmth gate now applies on the cloud path too. Known #490/#76 reporter payloads carried live radiators (tradiator1/tradiator2 at 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 Tinner of 31-32 °C alongside radiators at 58-65 °C in EG4's own data table (forum thread). Both are classified EG4_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 > 0 reads unknown. Radiators oscillating across the > 0 freeze point (0↔1) can flap the blanking decision (cosmetic). Bounded, and reversible.

Read-Only Operational Diagnostics

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.

Energy Registers (Daily)

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): yield is PV yield: LOCAL computes it as the epvN_day sum (pv_energy_today), CLOUD reads todayYielding. The cloud's todayYielding is PV yield, not Einv_day — proven by the pvPie permille rates (charge+export+usage sum to 1000 and distribute todayYielding; live 2026-06-10: 53.0 kWh × 0.777 == todayExport 41.2). Reg 31 (Einv_day, inverter OUTPUT energy) therefore feeds the separate inverter_energy sensor and has no cloud mirror.

Energy Registers (Lifetime, 32-bit pairs)

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

Consumption vs Load Energy (two distinct meters)

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

Load Energy (load_energy / load_energy_lifetime) — regs 171/172

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 cloud InverterEnergyData builder also maps todayUsage → 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_usage return the transport register (LOCAL/HYBRID) or the cloud value (CLOUD) uniformly, so the sensor is identical across modes.

Consumption (consumption / consumption_lifetime) — whole-home

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/totalUsage at the parallel-group level (= the cloud "Consumption" card); per-inverter cloud totalUsage for a standalone inverter.

  • LOCAL (transport present): energy balance via coordinator_mappings._energy_balance() —

    consumption = yield + discharge + grid_import − charge − grid_export
    

    with 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-inverter energy_balance lifetime 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-inverter energy_balance.

Why they are separate (validated 2026-06-02: FlexBOSS21 + 18kPV + GridBOSS)

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.

Erec is a third, different register (neither consumption nor load)

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.


3. Inverter Holding Registers (Parameters)

Holding registers use Modbus function code 0x03 (read/write). These map to switch. and number. entities.

Definitions: pylxpweb/registers/inverter_holding.py

HOLD_MODEL (Registers 0-1) — Model Detection

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 += 8

Usage in discovery (_config_flow/discovery.py):

  1. _read_device_info_from_transport() reads device type code from reg 19
  2. For non-GridBOSS devices, reads HOLD_MODEL from transport.read_parameters(0, 2)
  3. InverterModelInfo.from_registers(reg0, reg1) extracts power_rating
  4. get_model_name(device_type_code, power_rating) resolves specific model name
  5. 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.md for full bit layout documentation, validated device table, and example decodings.

Function Enable Bitfield (Register 21)

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)

System Function Bitfield (Register 110)

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_KEYS in pylxpweb constants/registers.py; unproven slots are FUNC_110_BITn placeholders); 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 bit FunctionEn1.ubFastZeroExport, and both the EG4 (#135 screenshot) and Luxpower (#274 screenshot) web UIs toggle the FUNC_RUN_WITHOUT_GRID cloud param from their Grid Sell tab's "Fast Zero Export" button. pylxpweb's canonical name run_without_grid is 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_EN bit 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 one REGISTER_110_PARAM_KEYS list. This file does not enumerate which reg-110 slots are still placeholders — the canonical per-bit map is llmwiki/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, so EG4OffGridModeSwitch now 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 — see llmwiki/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.

Power Control Registers

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_OFFGRID branch of number.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-checks FUNC_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_DIV10 in 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-checks FUNC_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), all EG4_HYBRID-gated. Evidence for all five is portal-correlated (llmwiki/40-hardware/registers.md).

grid_sell_back_power (reg 103) is the maximum sell-back (feed-in) power cap, cloud key HOLD_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's HOLD_MAX_BACKFLOW_POWER_PERCENT name 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_power targets 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 uses HOLD_PTOUSER_START_DISCHARGE (LOCAL/HYBRID reads + local name-map writes), while the live cloud API uses HOLD_P_TO_USER_START_DISCHG (#272 reporter's browser console + every docs/inverters scanner dump; pylxpweb's guessed api_param_key does 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_parameters falls back to str(addr) for unmapped registers); writes use the raw register address with two's-complement masking (-50 → 65486).

Schedule Time Window Registers (#277 + #295 + #312)

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.json for 68-73, SNA12KUS_52XXXXXX68.json blocks 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's SCHEDULE_CONFIGS (bases 68/76/84/152) and its Modbus schedule helpers, which write pack_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_KEYS still 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). Local read_named_parameters() therefore surfaces the raw packed values under those misleading names; each spec's local_param_keys alias 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 via control.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.

Battery Control Registers

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, and EG4BaseNumberEntity._offgrid_cloud_only_reason.

  • H160 (Start) is created for EG4_OFFGRID and EG4_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-proven on the decoded CEAA/CCAA off-grid images. This does not prove a live off-grid local write. H160's named cloud write/storage/restoration is hardware-toggle-proven only on the tested FlexBOSS21 and 18kPV units (component firmware unrecorded); other-family mapping remains portal-correlated. H161's broader mapping is portal-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.

Extended Function Enable (Register 179)

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_BIT0 etc.).

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 (the AC Couple switch, #472) is mapped to bit 11 WITHOUT a raw↔named lockstep toggle. The evidence is: the Luxpower Modbus doc; the ant0nkr/luxpower-ha-integration register 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 in llmwiki/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 param True, a disabled SNA12K-US probe reads False). 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_write re-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 stays lineage-inferred in llmwiki/40-hardware/registers.md, which is the grading authority.

Current state — the local write path is LIVE IN PRODUCTION, not gated. EG4ACCoupleSwitch writes 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 current manifest.json floor, 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: EG4SmartLoadSwitch is 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 (the Export PV Only switch, #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 frame 0x104c ↔ 0x1044 (XOR 0x0008 = single bit 3) in lockstep with the named parameter, restores verified by re-read (remoteRead valueFrame, 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_SHAVING for its generator-overload variant, and manages battery-vs-grid priority through its own LSP_*/discharge-control parameters. Field data: stock SNA12K-US cloud dump and the #222 6000XP capture both read FUNC_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).

Extended Function Enable 2 (Register 233)

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 in llmwiki/40-hardware/registers.md. An earlier revision of this note guessed that bit 12 was "possibly FUNC_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 with pylxpweb>=0.9.4 in the manifest, but these register mappings only exist from 0.9.5.

Battery Charge/Discharge Control Mode (SOC vs Voltage)

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 is HOLD_AC_CHARGE_END_BATTERY_VOLTAGE (the holding-register api_param_key HOLD_AC_CHARGE_END_VOLTAGE is 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 of forced_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.


4. GridBOSS Input Registers

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

Voltage Registers (÷10 → V)

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

Current Registers (÷10 → A)

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} to ac_couple{N}_current_l{1,2}. The cloud carries the same values as smartLoad{N}L{1,2}RmsCurr (÷10, pylxpweb 0.9.34+, #243), so Cloud-only installs get the per-port current sensors too.

Power Registers (signed, W, no scaling)

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 Load/AC Couple Power (signed, W, no scaling)

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 creates ac_couple{N}_power_l1/l2 sensor keys aliased from the same register values. See Section 11.

Frequency Registers (÷100 → Hz)

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

Daily Energy Registers (÷10 → kWh)

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 eGenDay column between eToUserDay and eSmartLoad1Day. 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.

Lifetime Energy Registers (32-bit pairs, ÷10 → 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 eGenAll column. 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.

Register 134-253 Mirror

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.


5. GridBOSS Holding Register 20 (Smart Port Status)

Smart port status is stored as a bit-packed value in HOLDING register 20, not in input registers.

Encoding

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

Values

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

Example

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)

Implementation

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"] = mode

Cloud API: Uses bitParamControl with BIT_MIDBOX_SP_MODE_N to read/write this register. The getMidboxRuntime endpoint returns smartPort{N}Status fields.

Smart Port Option Registers (229-317, 2101)

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_POSITION and BIT_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.

6. Cloud API Field Mappings

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.

Inverter Runtime (getInverterRuntime)

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/warningCode fields (eg4-23a6): the getInverterRuntime response does not carry the inverter fault/warning codes (pylxpweb's InverterRuntime model has no such fields and the canonical table pairs regs 60-63 with cloud_api_field=None). The fault_code/warning_code sensors are therefore LOCAL/HYBRID-only — see "Fault / Warning Code Registers" in section 2.

Per-String PV Energy (chart analytics side-fetch)

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 strings 1471.8 + 527.5 + 98.4 = 2097.7 kWh, exactly matching getInverterEnergyInfo.totalYieldingText = "2097.7".
  • FlexBOSS21 SYNTH00003: lifetime strings 2731.5 + 4481.4 + 4.8 = 7217.7 kWh versus aggregate raw totalYielding = 72216 (7221.6 kWh), a 0.054% difference (approximately 0.05%).
  • FlexBOSS21 daily mid-morning: today's raw values ePv1Day=3, ePv2Day=7, ePv3Day=0 produce 1.0 kWh versus todayYieldingText = "1.1".

GridBOSS Runtime (getMidboxRuntime)

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

Parallel Group Energy (getInverterEnergyInfoParallel)

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

7. Individual Battery Data

Battery Data Sources

Battery data comes from two distinct register ranges:

  1. 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.

  2. 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.

Battery Device Identity (#252)

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.

Battery Register Space (Modbus)

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.

Cloud API (getBatteryInfo)

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

Computed Battery Keys

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

Battery Bank Aggregate Keys

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.

BMS Permission/Request Flags (register 95 bitmap, issue #232)

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 (and InverterRuntimeData / BaseInverter.bms_allow_charge etc.).
  • CLOUD: the BatteryBank device delegates to its parent inverter's bmsCharge / bmsDischarge / bmsForceCharge runtime booleans.
  • battery_bank_force_charge (BMS calibration request) is read-only and distinct from the writable "Forced Charge" control (holding reg 21 bit 11).

8. Parallel Group Data

Parallel groups aggregate data from multiple inverters in the same group.

Power Sensors (from inverter summing in LOCAL mode)

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

Energy Sensors (from parallel group API in CLOUD mode)

See Section 6 - Parallel Group Energy.

Battery Aggregate Sensors

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

9. Computed / Derived Sensor Keys

These keys are NOT directly read from registers or API. They are computed in the coordinator layer.

Inverter Computed Keys

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_power is no longer computed: the former L1+L2 sum (#197) was the COMBINED backup output and duplicated eps_power (#335). It now maps the cloud epsLoadPower field via the HTTP property map (cloud-only).

Operating State Decode (Table 9, issue #262)

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 in strings.json/translations (entity.sensor.operating_state.state.*).
  • off_grid — a binary sensor (binary_sensor.py), ON when status_code is 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) and lxp_modbus both 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) that lxp_modbus also 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).

GridBOSS Computed Keys

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

Metadata Keys

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

10. Mode Differences

LOCAL Mode

  • Data source: Modbus TCP or WiFi Dongle (direct register reads)
  • Static entity creation: First refresh creates all entities with None values (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)

CLOUD Mode

  • 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 / totalLoad from API (server-computed)
  • bt_temperature: NOT available (no API field)
  • Battery data: From getBatteryInfo API endpoint
  • Smart port status: From smartPort{N}Status API fields
  • GridBOSS energy: From getMidboxRuntime API fields
  • Scaling: Applied by _map_device_properties() via DIVIDE_BY_10_SENSORS

HYBRID Mode

  • 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_OVERLAY in coordinator_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_power when GridBOSS present

LOCAL-NOMIDBOX Mode

  • 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 Availability by 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

11. Smart Port Sensor Filtering

How It Works

_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).

Filtering Rules

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

Dynamic Keys Affected

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.

Entities

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.


12. GridBOSS CT Overlay

Purpose

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.

Function

apply_gridboss_overlay() in coordinator_mixins.py (module-level function).

Called from BOTH:

  • coordinator_http.py (HYBRID mode)
  • coordinator_local.py (LOCAL mode)

Overlay Mapping

_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.

LOCAL-Specific Overlays

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)

13. Entity Counts by Mode

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.

Mode Differences

  • 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

Known Discrepancies

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.


14. Key Constants Reference

Frozensets (coordinator_mappings.py)

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

Scaling Sets (const/sensors/mappings.py)

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

Feature Flags (from _features_from_family)

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)

15. All Calculations Reference

This section documents every calculation, derivation, scaling, and transformation in the data pipeline. Organized by pipeline stage.


15.1 Register Scaling (pylxpweb _canonical_reader.py)

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.

15.2 32-bit Register Pairs

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.

15.3 Battery SoC/SoH Byte Unpacking (Register 5)

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.

15.4 Cloud API Scaling (_map_device_properties)

Function: coordinator_mixins._map_device_properties(device, property_map)

Behavior:

  1. Iterates property_map (API field → sensor key)
  2. Calls getattr(device, property_name, None) — catches TypeError/ValueError from property getters that call float(None) on unpopulated data
  3. Skips None values and empty strings — keys not added to sensors dict
  4. 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.

15.5 _safe_numeric() Helper

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.0

Used 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.


15.6 Inverter Computed Sensors

These sensors are NOT read from a single register or API field. They are computed in the coordinator layer.

consumption_power (Inverter, LOCAL)

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.

consumption / consumption_lifetime (Inverter Energy, LOCAL)

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 _transport is attached (overrides pylxpweb's energy_today_usage which reads the wrong register)

CLOUD mode: Uses todayLoad / totalLoad from API (server-computed).

battery_power (Inverter)

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 (Inverter)

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).

eps_power_l1 / eps_power_l2 (Inverter)

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().

eps_load_power_l1 / eps_load_power_l2 — RETIRED (#335)

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).

smart_load_power / grid_load_power / eps_load_power (Inverter, #222/#335/#625)

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.

total_load_power (Inverter)

Alias for consumption_power. Same value, different sensor key for backward compatibility.

rectifier_power (Inverter)

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.

output_power (Inverter)

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.

grid_import_power (Inverter)

Direct alias for register 27 (power_to_user in pylxpweb).


15.7 GridBOSS Computed Sensors

L1+L2 Aggregate Power (_calculate_gridboss_aggregates)

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 exist

Simple 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 (GridBOSS)

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 (GridBOSS)

hybrid_power = ups_power - grid_power

Computed by MIDRuntimePropertiesMixin.hybrid_power in pylxpweb.


15.8 Individual Battery Computed Sensors

battery_real_power

battery_real_power = voltage * current

Computed by pylxpweb Battery.power property. Also available from BatteryData.power in LOCAL mode.

battery_cell_voltage_delta

battery_cell_voltage_delta = max_cell_voltage - min_cell_voltage

Computed by pylxpweb Battery.cell_voltage_delta / BatteryData.cell_voltage_delta.

battery_remaining_capacity

battery_remaining_capacity = max_capacity * soc / 100

Computed by pylxpweb BatteryData.remaining_capacity.

battery_capacity_percentage (fallback)

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 (fallback)

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().


15.9 Battery Bank Computed Sensors

File: coordinator_mappings._build_battery_bank_sensor_mapping()

battery_bank_power

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.

Cross-Battery Diagnostic Sensors

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).


15.10 Parallel Group Computations (LOCAL mode)

File: coordinator_local.py:_process_local_parallel_groups()

Power Sensor Summing

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.

Energy Sensor Summing

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)

Battery Aggregates at Parallel Group Level

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 Copy

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.


15.11 GridBOSS CT Overlay (Full Mapping)

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.

LOCAL-Only Additional Overlays

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.


15.12 Smart Port Status Decode (Holding Register 20)

for port in range(1, 5):
    status = (register_20_value >> ((port - 1) * 2)) & 0x03

See Section 5 for encoding details. The decoded status drives all smart port sensor filtering in Section 11.


15.13 Cloud API vs LOCAL Calculation Differences

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)

15.14 Data Validation (Two-Layer Architecture)

Data validation operates at two independent layers:

Layer 1: Transport-Level (pylxpweb)

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).

Layer 2: Device-Level Energy Monotonicity (pylxpweb)

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.

Options Flow

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.