Browse Source

Fix telemetry showing unknown for PV/AC/SOC on Siseli HPVINV02 devices (#13)

* Fix telemetry showing unknown for PV/AC/SOC on Siseli HPVINV02 devices

Some inverter models (e.g. HPVINV02, "Inverter Top One" gather protocol)
report PV input power, AC output power, and battery SOC under different
timeseries key names than the vendor's documented API:

  pvInputPower       -> pvPower
  acOutputActivePower -> outputActivePower
  batterySOC         -> batteryCapacity

The documented keys return null for every sample on these devices, so the
corresponding sensors stayed stuck at "unknown" even though the device was
online and the account/auth were healthy.

Requests now include both the documented key and the known alternate for
each metric, and prefer whichever the device actually populates - this
should not change behavior for devices that already worked with the
documented names.

Also applies the existing kW->W unit normalisation to pvInputPower, which
was previously only applied to acOutputActivePower - needed once PV data
started flowing since the API reports it in kW.

* fixup: scope pvInputPower kW->W conversion to the aliased path only

Rebasing PR #13 onto main (which added _apply_derived_values as the
single source of truth for batteryPower/gridPower/loadPower) surfaced a
real regression: converting pvInputPower unconditionally broke
test_working_device_is_unaffected, because pvInputPower under the
documented key has always been W for every device this integration
supported before HPVINV02 - only the pvPower alias reports kW.

Track which canonical keys were populated via the alias and gate the
pvInputPower conversion on that, matching the pinned regression test.
Also drop the duplicate inline derived-value block, now redundant with
_apply_derived_values (which already gap-fills without clobbering a
measured value, per the 2.5.0 changelog), and add two tests: the
HPVINV02 alias path, and confirmation that a device already using the
documented keys is unaffected.

---------

Co-authored-by: Chandan Malhotra <UnSpritz@users.noreply.github.com>
Co-authored-by: Conexo-Casa <bob@conexo.casa>
Chandan Malhotra 1 month ago
parent
commit
e461d7b400
2 changed files with 99 additions and 13 deletions
  1. 47 13
      custom_components/solar_of_things/api.py
  2. 52 0
      tests/test_energy_flow.py

+ 47 - 13
custom_components/solar_of_things/api.py

@@ -756,28 +756,41 @@ class SolarOfThingsAPI:
         end_time = self._now()
         start_time = end_time - timedelta(hours=1)
 
+        # Some inverter models (e.g. Siseli HPVINV02 / "Inverter Top One"
+        # gather protocol) report these three metrics under different key
+        # names than the vendor's documented API. Request both the
+        # documented key and the known alternate, and prefer whichever the
+        # device actually populates — keeps this working for devices that
+        # use either naming instead of hardcoding one over the other.
+        ALIAS_GROUPS = [
+            ("pvInputPower", "pvPower"),
+            ("acOutputActivePower", "outputActivePower"),
+            ("batterySOC", "batteryCapacity"),
+        ]
+
         keys = [
             "pvInputPower",
+            "pvPower",
             "acOutputActivePower",
+            "outputActivePower",
             "batteryDischargeCurrent",
             "batteryChargingCurrent",
             "batteryVoltage",
             "feedInPower",
             "batterySOC",
+            "batteryCapacity",
         ]
 
-        data = self._post(
-            API_TIME_SERIES,
-            {
-                "deviceId": device_id,
-                "count": 2000,
-                "page": 1,
-                "fromTime": self._format_time(start_time),
-                "toTime": self._format_time(end_time),
-                "orderByTimeAsc": True,
-                "keys": keys,
-            },
-        )
+        request_body = {
+            "deviceId": device_id,
+            "count": 2000,
+            "page": 1,
+            "fromTime": self._format_time(start_time),
+            "toTime": self._format_time(end_time),
+            "orderByTimeAsc": True,
+            "keys": keys,
+        }
+        data = self._post(API_TIME_SERIES, request_body)
 
         if data.get("code") not in (0, None):
             raise RuntimeError(
@@ -793,12 +806,33 @@ class SolarOfThingsAPI:
             if isinstance(arr, list) and arr:
                 latest_values[key] = arr[-1]
 
-        # Unit normalisation: acOutputActivePower is kW in API → W
+        # Some inverter models (e.g. Siseli HPVINV02) report pvInputPower,
+        # acOutputActivePower and batterySOC under an alternate key name
+        # instead of the documented one. Prefer the canonical key when the
+        # device populates it; fall back to the alternate otherwise.
+        aliased_keys: set[str] = set()
+        for canonical, alternate in ALIAS_GROUPS:
+            if latest_values.get(canonical) is None and latest_values.get(alternate) is not None:
+                latest_values[canonical] = latest_values[alternate]
+                aliased_keys.add(canonical)
+            latest_values.pop(alternate, None)
+
+        # Unit normalisation: acOutputActivePower is always kW in API → W.
+        # pvInputPower is W under the documented key on every device this
+        # integration already supports (pinned by
+        # test_working_device_is_unaffected) but kW when it arrived via the
+        # pvPower alias (confirmed on HPVINV02) — convert only in that case,
+        # so devices that were already working are unaffected.
         if "acOutputActivePower" in latest_values:
             converted = _coerce_number(latest_values["acOutputActivePower"])
             if converted is not None:
                 latest_values["acOutputActivePower"] = converted * 1000.0
 
+        if "pvInputPower" in aliased_keys:
+            converted = _coerce_number(latest_values["pvInputPower"])
+            if converted is not None:
+                latest_values["pvInputPower"] = converted * 1000.0
+
         return latest_values
 
     def fetch_energy_flow(self, device_id: str) -> dict[str, Any]:

+ 52 - 0
tests/test_energy_flow.py

@@ -197,6 +197,58 @@ def test_working_device_is_unaffected(api_factory) -> None:
     assert calls["energy_flow"] == 0
 
 
+def test_hpvinv02_alternate_key_names_are_mapped_to_canonical(api_factory) -> None:
+    """Siseli HPVINV02 ("Inverter Top One" gather protocol) reports PV/AC/SOC
+    under pvPower / outputActivePower / batteryCapacity instead of the
+    documented pvInputPower / acOutputActivePower / batterySOC — these stayed
+    "unknown" forever until the alias mapping was added.
+    """
+    history = {
+        "pvPower": [1.5],
+        "outputActivePower": [1.1],
+        "batteryCapacity": [77],
+        "batteryVoltage": [52.0],
+        "batteryDischargeCurrent": [2.0],
+        "batteryChargingCurrent": [0.0],
+        "feedInPower": [0],
+    }
+    api, calls = api_factory(history, flow_fields={"bmsSOC": 1})
+    result = api.fetch_latest_data("device-hpvinv02")
+
+    assert result["pvInputPower"] == 1500.0        # kW → W, via the alias
+    assert result["acOutputActivePower"] == 1100.0  # kW → W, via the alias
+    assert result["batterySOC"] == 77
+    # The alternate keys must not leak into the published values.
+    assert "pvPower" not in result
+    assert "outputActivePower" not in result
+    assert "batteryCapacity" not in result
+    # Derived values still come from the shared _apply_derived_values path.
+    assert result["batteryPower"] == 104.0          # (2.0 - 0.0) * 52.0
+    assert result["loadPower"] == 1100.0
+    assert calls["energy_flow"] == 0
+
+
+def test_documented_keys_win_when_a_device_reports_both(api_factory) -> None:
+    """A device that already uses the documented key names must be unaffected
+    by the alias — the canonical value always wins, the alias is discarded,
+    and (critically) pvInputPower is NOT kW->W converted on this path: it's
+    already W for every device this integration supported before HPVINV02,
+    and converting it unconditionally regressed test_working_device_is_unaffected.
+    """
+    history = {
+        "pvInputPower": [2.0],
+        "pvPower": [999],
+        "acOutputActivePower": [1.0],
+        "batterySOC": [55],
+        "batteryCapacity": [10],
+    }
+    api, _ = api_factory(history, flow_fields={})
+    result = api.fetch_latest_data("device-both-keys")
+
+    assert result["pvInputPower"] == 2.0
+    assert result["batterySOC"] == 55
+
+
 def test_fallback_populates_sensors_when_time_series_is_empty(api_factory) -> None:
     api, calls = api_factory({}, flow_fields=ISSUE_7_NIGHT_PAYLOAD)
     result = api.fetch_latest_data("device-2")