From df3fb3466a0add5cd6812478a325082e73318dc8 Mon Sep 17 00:00:00 2001 From: noise Date: Sat, 9 May 2026 11:34:26 -0400 Subject: [PATCH] updates to evse --- LVX6048/homeassistant/README.md | 5 ++ LVX6048/homeassistant/template_sensors.yaml | 54 +++++++++++++++++++++ eg4battery/bin/eg4-battery | 42 ++++++---------- 3 files changed, 75 insertions(+), 26 deletions(-) create mode 100644 LVX6048/homeassistant/template_sensors.yaml diff --git a/LVX6048/homeassistant/README.md b/LVX6048/homeassistant/README.md index 3a8a9aa..675e0c2 100644 --- a/LVX6048/homeassistant/README.md +++ b/LVX6048/homeassistant/README.md @@ -11,6 +11,7 @@ Mirrors the `eg4battery/homeassistant/` pattern. | File | Where it goes in HA | |----------------------------|--------------------------------------------------------------| | `mqtt_controls.yaml` | `configuration.yaml` → `mqtt: !include lvx6048/mqtt_controls.yaml` (or merge by hand) | +| `template_sensors.yaml` | `configuration.yaml` → `template: !include lvx6048/template_sensors.yaml` | | `lovelace_controls.yaml` | Raw Lovelace card config — paste into a new dashboard view | The auto-discovery sensors (battery V, fault code, mode, MPPT power, …) @@ -20,6 +21,10 @@ This folder only adds the pieces HA can't infer: - **Control entities** — selects + numbers that publish to `solar/control/lvx6048/` so users can change settings from a dashboard without touching the LCD. +- **Stack-total derived sensors** — the PI18 GS command only exposes per-unit + numbers, so a 240 V load split across two LVX6048s shows as ~half on each. + `template_sensors.yaml` synthesizes household-level totals (apparent_power, + active_power, mppt1_input_power). - **A dashboard view** that wraps those controls with the existing telemetry into one screen. diff --git a/LVX6048/homeassistant/template_sensors.yaml b/LVX6048/homeassistant/template_sensors.yaml new file mode 100644 index 0000000..edd242d --- /dev/null +++ b/LVX6048/homeassistant/template_sensors.yaml @@ -0,0 +1,54 @@ +# Derived template sensors for the 2× LVX6048 parallel stack. +# Each LVX6048 reports its own AC output and MPPT contribution; PI18 GS has +# no system-total field (PGS does, but powermon doesn't poll it). These +# rollups synthesize the household-level numbers from the per-unit data. +# +# Include into configuration.yaml as: +# template: !include lvx6048/template_sensors.yaml +# +# Stack rollups created: +# sensor.lvx6048_stack_ac_output_apparent_power VA (unit_1 + unit_2) +# sensor.lvx6048_stack_ac_output_active_power W (unit_1 + unit_2) +# sensor.lvx6048_stack_mppt1_input_power W (solar in, both units) +# +# Background: with the inverters in 120/240 split-phase wiring, an EV +# pulling 24 A at 240 V drives 24 A through each leg, so each LVX shows +# only its 120 V × 24 A ≈ 2880 VA contribution. The stack rollup is the +# real household-level number. + +- sensor: + - name: "lvx6048_stack ac_output_apparent_power" + unique_id: lvx6048_stack_ac_output_apparent_power + unit_of_measurement: "VA" + device_class: apparent_power + state_class: measurement + state: > + {% set p = [ + states('sensor.lvx6048_1_ac_output_apparent_power') | float(0), + states('sensor.lvx6048_2_ac_output_apparent_power') | float(0), + ] %} + {{ p | sum | round(0) }} + + - name: "lvx6048_stack ac_output_active_power" + unique_id: lvx6048_stack_ac_output_active_power + unit_of_measurement: "W" + device_class: power + state_class: measurement + state: > + {% set p = [ + states('sensor.lvx6048_1_ac_output_active_power') | float(0), + states('sensor.lvx6048_2_ac_output_active_power') | float(0), + ] %} + {{ p | sum | round(0) }} + + - name: "lvx6048_stack mppt1_input_power" + unique_id: lvx6048_stack_mppt1_input_power + unit_of_measurement: "W" + device_class: power + state_class: measurement + state: > + {% set p = [ + states('sensor.lvx6048_1_mppt1_input_power') | float(0), + states('sensor.lvx6048_2_mppt1_input_power') | float(0), + ] %} + {{ p | sum | round(0) }} diff --git a/eg4battery/bin/eg4-battery b/eg4battery/bin/eg4-battery index 8cc2eec..292b631 100755 --- a/eg4battery/bin/eg4-battery +++ b/eg4battery/bin/eg4-battery @@ -292,19 +292,18 @@ def decode_modbus_response(frame: ModbusFrame) -> dict[str, Any]: # live probing of a single pack. See ../NOTES.md "Register map" section. # High-confidence fields promoted to named entities; unknowns (reg 32, 35, # 38-40, 43-45) still emitted as register_NN for correlation. - -_WARNING_BITS = [ - "pack_ov", "cell_ov", "pack_uv", "cell_uv", - "charge_oc", "discharge_oc", "temp_anomaly", "mos_ot", - "charge_ot", "discharge_ot", "charge_ut", "discharge_ut", - "low_capacity", "other_error", -] -_PROTECTION_BITS = [ - "pack_ov", "cell_ov", "pack_uv", "cell_uv", - "charge_oc", "discharge_oc", "temp_anomaly", "mos_ot", - "charge_ot", "discharge_ot", "charge_ut", "discharge_ut", - "float_stopped", "discharge_sc", -] +# +# regs 33 and 34 USED to decode as warning_* / protection_* bitfields here, +# per the lv_host.app UI display order. That mapping was wrong: live probe +# (2026-05-09, all 3 packs) showed reg33 == reg34 always, with both halves +# of each register equal to the byte value of reg20 (temperature_03): +# pack 1/2 temp_03 = 19 (0x13) → reg33 = reg34 = 0x1313 +# pack 3 temp_03 = 18 (0x12) → reg33 = reg34 = 0x1212 +# i.e. the "alarms that flipped between packs" were just a 1 °C ambient +# difference, decoded as if the temperature byte were a 14-bit alarm mask. +# The real alarm word is reg35 (error_code), already exposed and = 0 on +# healthy packs. Don't re-add reg33/34 as alarms without first confirming +# they break out of the temp-mirror pattern under a real fault condition. def _signed16(v: int) -> int: @@ -357,13 +356,10 @@ def decode_eg4_modbus_regs(regs: list[int], expose_raw: bool = False) -> dict[st # --- max charge/discharge current limit (reg 31), A --- out["max_current_limit"] = round(regs[31] / 100.0, 2) - # --- bitfields: warnings (reg 33), protections (reg 34), error code (reg 35) --- - warn = regs[33] - for i, name in enumerate(_WARNING_BITS): - out[f"warning_{name}"] = "on" if (warn >> i) & 1 else "off" - prot = regs[34] - for i, name in enumerate(_PROTECTION_BITS): - out[f"protection_{name}"] = "on" if (prot >> i) & 1 else "off" + # --- error / alarm word (reg 35) --- + # reg 33/34 used to decode as warning_/protection_ bitfields but were + # actually mirroring a temperature byte — see the file-header comment. + # reg 35 is the canonical alarm word; 0 on healthy packs. out["error_code"] = regs[35] # --- static-ish (regs 36, 37) --- @@ -706,12 +702,6 @@ _FIELD_META.update({ "firmware_version": (None, None, None, "mdi:chip"), "firmware_date": (None, None, None, "mdi:calendar"), }) -for _name in _WARNING_BITS: - _FIELD_META[f"warning_{_name}"] = (None, None, None, "mdi:alert") -for _name in _PROTECTION_BITS: - _FIELD_META[f"protection_{_name}"] = (None, None, None, "mdi:shield-alert") - - def field_meta(key: str) -> tuple[str | None, str | None, str | None, str | None]: if key.startswith("register_"): return (None, None, "measurement", "mdi:numeric")