Ver Fonte

release: 2.5.0 — ship only the energy-flow mappings with confirmed units

Narrows the energy-flow fallback to the mappings whose unit is confirmed by a
non-zero observed value, so 2.5.0 can ship to the six issues blocked on it
without risking a wrong reading.

Enabled (all scale 1.0 — published exactly as the portal reports, so no rule in
the table can be 1000x wrong):
  pvInputPower   <- pv1Power..pv4Power, summed
  batteryVoltage <- bmsBatteryVoltage / positiveTerminalBatteryVoltage (26.6 V)
  batterySOC     <- batteryPercentage / bmsSOC (100 %)
  batteryPower   <- batteryPower (9 W)

Demoted to ENERGY_FLOW_UNVERIFIED:
  generationPower, load_power/loadPower — need a kW->W conversion that no
    captured sample can confirm; every value in the #7 payload was zero because
    the reading was taken at night.
  positiveTerminalBatteryCurrent, negativeTerminalBatteryCurrent — magnitude
    checks out (26.6 V x 0.4 A ~= the reported 9 W) but which terminal is charge
    and which is discharge is unverified; a swap would invert the two.

Rationale: a 1000x scaling error feeds the HA Energy dashboard and long-term
statistics, which a later fix cannot un-poison, and an inverted charge/discharge
current is actively misleading. Both are worse than an unknown sensor. Load
Power and Grid Import Power keep their existing derived estimates.

Real-hardware confirmation for the retained mappings came from
@hidemichixt-creator (UWB1-02 / Humsienk 5.12 kWh) in #7, whose sensors went
from all-unavailable to working on this branch.

Tests: 34 passed. test_unverified_fields_are_never_mapped is a new guard that
fails if any ENERGY_FLOW_UNVERIFIED field ever produces a value.
test_working_device_is_unaffected (pre-existing regression guard) still pins the
time-series path to its exact prior outputs with zero flow-endpoint calls.

Also bumps the hardcoded User-Agent, which api.py notes must track manifest.json
on each release.

Co-Authored-By: Claude <noreply@anthropic.com>
Conexo-Casa há 1 mês atrás
pai
commit
7030e03f4f

+ 44 - 0
CHANGELOG.md

@@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ---
 
+## [2.5.0] - 2026-09-11
+
+### Added
+- **Realtime sensors now populate on inverters that never fill the historical
+  time-series endpoint.** Several firmware families (UWB1, RWB1-0x, JC-62xx,
+  DatouBoss DT-series, EASUN) left every realtime entity `unknown` while the
+  portal showed live data — the shared cause behind #3, #7, #8, #11, #14 and
+  #15. Those devices serve live values from
+  `/apis/deviceState/simple/energy/flow/v1` under a different set of field
+  names, and the integration now falls back to that endpoint and maps them.
+
+  Populated by the fallback: **PV Input Power** (per-string `pv1Power`…`pv4Power`,
+  summed), **Battery Voltage**, **Battery State of Charge**, **Battery Power**.
+
+  Thanks to @jazuch for a report that included the endpoint, the data path, the
+  field names and sample values, and to @hidemichixt-creator for confirming the
+  fix against real hardware.
+
+### Changed
+- Derived values (battery / grid / load power) now **fill gaps rather than
+  overwrite**, so a directly measured reading is preferred over the
+  `voltage x current` estimate.
+
+### Notes
+- **The fallback is gated.** It runs only when the time-series endpoint yields
+  no realtime value, so a working installation makes zero extra API calls and
+  its readings are unchanged — pinned by a regression test.
+- **Deliberately not mapped yet.** Publishing a wrong value is worse than an
+  unknown sensor: a 1000x scaling error feeds the Energy dashboard and
+  long-term statistics, which a later fix cannot un-poison.
+  - `generationPower`, `load_power` / `loadPower`, and the per-phase
+    `aPhaseMainsPower` / `bPhaseMainsPower` / `cPhaseMainsPower` — every
+    captured sample was zero (night-time reading), so kW-vs-W is unconfirmed.
+  - `positiveTerminalBatteryCurrent` / `negativeTerminalBatteryCurrent` — the
+    magnitude is correct, but which terminal means charge and which means
+    discharge is unverified, and a swap would invert the two.
+
+  Load Power and Grid Import Power therefore keep their existing derived
+  estimates. See `ENERGY_FLOW_UNVERIFIED` in `const.py`; tracked in #7.
+- If your inverter reports fields this release does not recognise, the log now
+  emits a warning listing the exact key names — please open an issue with them.
+
+---
+
 ## [2.4.3] - 2026-08-31
 
 ### Fixed

+ 1 - 1
custom_components/solar_of_things/api.py

@@ -369,7 +369,7 @@ class SolarOfThingsAPI:
                 "Referer": "https://solar.siseli.com/",
                 # Keep this version in sync with manifest.json on each release.
                 "User-Agent": (
-                    "HomeAssistant-SolarOfThings/2.4.3 "
+                    "HomeAssistant-SolarOfThings/2.5.0 "
                     "(+https://github.com/Conexo-Casa/solar-of-things-ha)"
                 ),
             }

+ 38 - 26
custom_components/solar_of_things/const.py

@@ -100,17 +100,21 @@ SENSOR_KEYS = [
 # `scale` converts the source value into the unit declared in
 # SENSOR_DEFINITIONS.
 #
-# Units for the per-field values below are taken from the sample payload in
-# issue #7 (bmsBatteryVoltage 26.6 V, batteryPercentage 100 %, batteryPower 9 W,
-# positiveTerminalBatteryCurrent 0.4 A, pv1Power/pv2Power in W).  The kW→W
-# scaling for the aggregate flow values (generationPower, load_power) matches
-# both the reporter's own reading of the portal and the pre-existing kW→W
-# normalisation this integration already applies to acOutputActivePower on the
-# time-series path — i.e. this portal reports aggregate power in kW.
+# ONLY mappings whose unit is confirmed by a NON-ZERO observed value are enabled
+# here.  Every rule below has scale 1.0 — the value is published exactly as the
+# portal reports it — so no rule in this table can be 1000x wrong.  The issue #7
+# payload was captured at night, so it pinned down the units of only these
+# fields:
+#   bmsBatteryVoltage / positiveTerminalBatteryVoltage   26.6 V
+#   batteryPercentage / bmsSOC                           100 %
+#   batteryPower                                         9 W
+#   pv1Power / pv2Power                                  W (labelled; 0 at night)
+# Anything that would need a kW→W conversion, or whose direction/sign is
+# ambiguous, is listed in ENERGY_FLOW_UNVERIFIED below and left unmapped until a
+# daytime, under-load capture confirms it.
 ENERGY_FLOW_RULES: dict[str, list[tuple[str, tuple[str, ...], float]]] = {
     "pvInputPower": [
         ("sum", ("pv1Power", "pv2Power", "pv3Power", "pv4Power"), 1.0),
-        ("first", ("generationPower",), 1000.0),
     ],
     "batteryVoltage": [
         ("first", ("bmsBatteryVoltage", "positiveTerminalBatteryVoltage"), 1.0),
@@ -121,30 +125,38 @@ ENERGY_FLOW_RULES: dict[str, list[tuple[str, tuple[str, ...], float]]] = {
     "batteryPower": [
         ("first", ("batteryPower",), 1.0),
     ],
-    "batteryDischargeCurrent": [
-        ("first", ("positiveTerminalBatteryCurrent",), 1.0),
-    ],
-    "batteryChargingCurrent": [
-        ("first", ("negativeTerminalBatteryCurrent",), 1.0),
-    ],
-    "loadPower": [
-        ("first", ("load_power", "loadPower"), 1000.0),
-    ],
-    "acOutputActivePower": [
-        ("first", ("load_power", "loadPower"), 1000.0),
-    ],
 }
 
-# Fields observed in the issue #7 payload that are deliberately NOT mapped yet.
-# Every sample value the reporter captured was zero (the reading was taken at
-# night), so the kW-vs-W scale cannot be pinned down.  Guessing wrong here would
-# push a value that is 1000x off into the HA Energy dashboard and long-term
-# statistics, which is materially worse than leaving the sensor "unknown".
-# Revisit once a daytime sample with non-zero values is available.
+# Fields observed in the issue #7 payload that are deliberately NOT mapped.
+# Publishing a wrong value is worse than leaving a sensor "unknown": a 1000x
+# scaling error feeds the HA Energy dashboard and long-term statistics, and
+# statistics cannot be un-poisoned by a later fix.  Two distinct reasons:
+#
+# 1. UNIT UNCONFIRMED (kW vs W).  Every captured sample was zero because the
+#    reading was taken at night, so the scale cannot be pinned down.  The
+#    reporter labelled these kW, and this integration already applies kW→W to
+#    acOutputActivePower on the time-series path, so x1000 is *probably* right —
+#    but "probably" is not good enough for a value that lands in statistics.
+# 2. DIRECTION UNCONFIRMED.  The battery terminal currents carry the right
+#    magnitude (26.6 V x 0.4 A ~= 9 W, matching the reported batteryPower), but
+#    which terminal means charge and which means discharge is unverified — the
+#    reporter flagged it, and a swap would invert charge/discharge, which is
+#    actively misleading rather than merely absent.
+#
+# Revisit once two captures from issue #7 are available: one in daylight under
+# load (settles the units), one while the battery is actively charging (settles
+# the direction).
 ENERGY_FLOW_UNVERIFIED: tuple[str, ...] = (
+    # 1. unit unconfirmed (kW vs W)
     "aPhaseMainsPower",   # candidate for gridPower (sum of the three phases)
     "bPhaseMainsPower",
     "cPhaseMainsPower",
+    "generationPower",    # candidate aggregate fallback for pvInputPower
+    "load_power",         # candidate for loadPower / acOutputActivePower
+    "loadPower",
+    # 2. direction unconfirmed (which terminal is charge vs discharge)
+    "positiveTerminalBatteryCurrent",
+    "negativeTerminalBatteryCurrent",
 )
 
 # Canonical keys that indicate the time-series endpoint returned usable realtime

+ 1 - 1
custom_components/solar_of_things/manifest.json

@@ -11,5 +11,5 @@
   "requirements": [
     "pycryptodome>=3.9.0"
   ],
-  "version": "2.4.3"
+  "version": "2.5.0"
 }

+ 48 - 22
tests/test_energy_flow.py

@@ -17,6 +17,7 @@ from custom_components.solar_of_things.api import (
     has_realtime_values,
     map_energy_flow_fields,
 )
+from custom_components.solar_of_things.const import ENERGY_FLOW_UNVERIFIED
 
 # Exactly the fields reported in issue #7 (UWB1 inverter, night-time reading).
 ISSUE_7_NIGHT_PAYLOAD = {
@@ -42,42 +43,64 @@ def test_issue_7_payload_maps_to_canonical_keys() -> None:
     """The reported payload must populate the previously-unknown sensors."""
     mapped = map_energy_flow_fields(ISSUE_7_NIGHT_PAYLOAD)
 
+    # Units confirmed by a non-zero observed value in the report — publishable.
     assert mapped["pvInputPower"] == 0.0
     assert mapped["batteryVoltage"] == 26.6
     assert mapped["batterySOC"] == 100.0
     assert mapped["batteryPower"] == 9.0
-    assert mapped["batteryDischargeCurrent"] == 0.4
-    assert mapped["batteryChargingCurrent"] == 0.0
-    assert mapped["loadPower"] == 0.0
-    assert mapped["acOutputActivePower"] == 0.0
 
+    # Unit or direction unconfirmed — must stay ABSENT rather than publish a
+    # possibly-wrong value. See ENERGY_FLOW_UNVERIFIED in const.py.
+    for unconfirmed in (
+        "loadPower",
+        "acOutputActivePower",
+        "batteryChargingCurrent",
+        "batteryDischargeCurrent",
+    ):
+        assert unconfirmed not in mapped, unconfirmed
 
-def test_mains_power_fields_are_not_mapped() -> None:
-    """Unit for the per-phase mains fields is unconfirmed, so stay out.
 
-    Publishing a possibly-1000x-wrong value into the Energy dashboard is worse
-    than leaving the sensor unknown. See ENERGY_FLOW_UNVERIFIED in const.py.
+def test_unverified_fields_are_never_mapped() -> None:
+    """No field in ENERGY_FLOW_UNVERIFIED may produce a sensor value.
+
+    A 1000x-wrong power value feeds the HA Energy dashboard and long-term
+    statistics, which a later fix cannot un-poison; an inverted charge/discharge
+    current is actively misleading. Both are worse than an unknown sensor.
+
+    This is the guard that stops a well-meaning change from re-enabling one of
+    these before a confirming capture exists.
     """
-    mapped = map_energy_flow_fields(
-        {"aPhaseMainsPower": 500, "bPhaseMainsPower": 0, "cPhaseMainsPower": 0}
-    )
-    assert mapped == {}
+    for field in ENERGY_FLOW_UNVERIFIED:
+        assert map_energy_flow_fields({field: 500}) == {}, field
 
+    # And as a group, mirroring the shape of a real payload.
+    assert map_energy_flow_fields({f: 500 for f in ENERGY_FLOW_UNVERIFIED}) == {}
 
-def test_multi_string_pv_is_summed_and_kw_is_scaled() -> None:
+
+def test_multi_string_pv_is_summed_at_face_value() -> None:
+    """Per-string PV inputs are reported in W and summed without scaling."""
     mapped = map_energy_flow_fields(
         {"pv1Power": 1200, "pv2Power": 800, "load_power": 1.5}
     )
     assert mapped["pvInputPower"] == 2000.0
-    assert mapped["loadPower"] == 1500.0  # kW → W
+    # load_power would need an unconfirmed kW→W conversion, so it stays out.
+    assert "loadPower" not in mapped
+
 
+def test_generation_power_is_not_used_as_a_pv_fallback() -> None:
+    """generationPower is an aggregate in kW and its x1000 is unconfirmed.
 
-def test_generation_power_is_only_a_pv_fallback() -> None:
-    """generationPower (kW) is used only when no per-string field exists."""
-    assert map_energy_flow_fields({"generationPower": 2.4})["pvInputPower"] == 2400.0
-    # A per-string reading always wins over the aggregate.
-    both = {"pv1Power": 500, "generationPower": 2.4}
-    assert map_energy_flow_fields(both)["pvInputPower"] == 500.0
+    Excluding it means a device reporting ONLY generationPower gets an unknown
+    PV sensor rather than one that may be 1000x wrong.
+    """
+    assert map_energy_flow_fields({"generationPower": 2.4}) == {}
+    # A per-string reading is still mapped when one is present.
+    assert (
+        map_energy_flow_fields({"pv1Power": 500, "generationPower": 2.4})[
+            "pvInputPower"
+        ]
+        == 500.0
+    )
 
 
 @pytest.mark.parametrize(
@@ -181,13 +204,16 @@ def test_fallback_populates_sensors_when_time_series_is_empty(api_factory) -> No
     assert calls["energy_flow"] == 1
     assert result["batteryVoltage"] == 26.6
     assert result["batterySOC"] == 100.0
-    assert result["batteryDischargeCurrent"] == 0.4
+    assert result["batteryPower"] == 9.0
 
 
 def test_measured_battery_power_is_not_overwritten_by_the_estimate(api_factory) -> None:
     """The flow endpoint reports batteryPower directly; keep it.
 
-    The derived estimate would give (0.4 - 0) * 26.6 = 10.64 W here.
+    The terminal currents are no longer mapped from flow data (direction
+    unconfirmed), so no voltage x current estimate can be derived from this
+    payload at all — but the no-clobber precedence must still hold for devices
+    that get their currents from the time-series path.
     """
     api, _ = api_factory({}, flow_fields=ISSUE_7_NIGHT_PAYLOAD)
     assert api.fetch_latest_data("device-3")["batteryPower"] == 9.0