Procházet zdrojové kódy

feat: fall back to the energy-flow endpoint when time-series is empty

Several inverter / WiFi-dongle firmware families never populate the historical
time-series endpoint this integration reads (API_TIME_SERIES), so every realtime
entity stays "unknown" while the portal itself shows live data. Reported for
UWB1, RWB1-0x, JC-62xx, DatouBoss DT-series and EASUN units across issues #3,
#7, #8, #11, #14 and #15 — the single most-reported problem in the tracker.

Issue #7 identified the cause precisely: those devices serve live values from
/apis/deviceState/simple/energy/flow/v1 under data.deviceAttributeState.fields,
using different field names (pv1Power, generationPower, batteryPercentage,
bmsSOC, positiveTerminalBatteryCurrent, load_power, ...).

Add a declarative ENERGY_FLOW_RULES table mapping those names onto the canonical
sensor keys, plus a _get helper mirroring _post's 401-refresh behaviour. The
fallback only runs when the time-series response yields no realtime value, so a
working install makes no extra API calls and its readings are untouched.

Derived values (batteryPower, gridPower, loadPower) now fill gaps instead of
overwriting unconditionally, so a directly-measured batteryPower from the flow
endpoint is kept rather than replaced by the voltage*current estimate.

The per-phase mains-power fields are deliberately left unmapped: every sample
value in the report was zero (captured at night), so the kW-vs-W scale cannot be
pinned down, and a 1000x-wrong value in the Energy dashboard is worse than an
unknown sensor. Unrecognised field names are logged at WARNING with the key
list so new firmware variants can be added from a bug report.

NOT YET VERIFIED against live hardware — no Siseli account or affected device
available here. Logic is covered by tests/test_energy_flow.py (mapping, unit
scaling, value-shape tolerance, hostile input, fallback gating, no-clobber,
error degradation, token propagation) including a regression test pinning the
existing time-series path to its pre-change values. Needs confirmation from an
affected user before release.

Co-Authored-By: Claude <noreply@anthropic.com>
Conexo-Casa před 1 měsícem
rodič
revize
77eccadf93

+ 214 - 19
custom_components/solar_of_things/api.py

@@ -91,6 +91,9 @@ from .const import (
     API_DEVICE_LIST,
     API_SETTINGS_GET,
     API_SETTINGS_SET,
+    API_ENERGY_FLOW,
+    ENERGY_FLOW_RULES,
+    REALTIME_PROBE_KEYS,
     IOT_APP_ID,
     IOT_APP_SECRET_ENC,
     TOKEN_REFRESH_LEAD_SECONDS,
@@ -208,6 +211,88 @@ def _parse_expiry(value: str | None) -> datetime | None:
         return None
 
 
+# ──────────────────────────────────────────────────────────────────────────────
+# Energy-flow fallback mapping
+# ──────────────────────────────────────────────────────────────────────────────
+
+def _coerce_number(value: Any) -> float | None:
+    """Best-effort conversion of an API field value to a float.
+
+    The energy-flow payload has only been observed second-hand (issue #7), so
+    accept every shape this portal is known to use elsewhere: a bare number, a
+    numeric string, a latest-wins list (as the time-series endpoint returns), or
+    a {"value": ...} wrapper.  Anything unrecognised yields None so the caller
+    leaves the sensor untouched instead of publishing a garbage reading.
+    """
+    if value is None or isinstance(value, bool):
+        return None
+    if isinstance(value, (int, float)):
+        return float(value)
+    if isinstance(value, str):
+        text = value.strip()
+        if not text:
+            return None
+        try:
+            return float(text)
+        except ValueError:
+            return None
+    if isinstance(value, list):
+        for item in reversed(value):
+            number = _coerce_number(item)
+            if number is not None:
+                return number
+        return None
+    if isinstance(value, dict):
+        for key in ("value", "val", "latest"):
+            if key in value:
+                return _coerce_number(value[key])
+    return None
+
+
+def map_energy_flow_fields(fields: Any) -> dict[str, float]:
+    """Translate an energy-flow ``fields`` mapping into canonical sensor keys.
+
+    Pure function: no network access and no instance state, so it can be tested
+    directly.  Applies ENERGY_FLOW_RULES in declaration order and returns only
+    the keys it could actually resolve — the caller merges the result without
+    clobbering better data from the time-series endpoint.
+    """
+    if not isinstance(fields, dict) or not fields:
+        return {}
+
+    mapped: dict[str, float] = {}
+
+    for canonical, rules in ENERGY_FLOW_RULES.items():
+        for mode, sources, scale in rules:
+            if mode == "sum":
+                values = [
+                    number
+                    for source in sources
+                    if (number := _coerce_number(fields.get(source))) is not None
+                ]
+                if values:
+                    mapped[canonical] = sum(values) * scale
+            else:  # "first" — first present source field wins
+                for source in sources:
+                    number = _coerce_number(fields.get(source))
+                    if number is not None:
+                        mapped[canonical] = number * scale
+                        break
+            if canonical in mapped:
+                break
+
+    return mapped
+
+
+def has_realtime_values(values: Any) -> bool:
+    """Return True if any canonical realtime key carries a usable number."""
+    if not isinstance(values, dict):
+        return False
+    return any(
+        _coerce_number(values.get(key)) is not None for key in REALTIME_PROBE_KEYS
+    )
+
+
 # ──────────────────────────────────────────────────────────────────────────────
 # Main API client
 # ──────────────────────────────────────────────────────────────────────────────
@@ -512,6 +597,28 @@ class SolarOfThingsAPI:
         resp.raise_for_status()
         return resp.json()
 
+    def _get(self, path: str, params: dict[str, Any], *, timeout: int = 30) -> dict[str, Any]:
+        """Perform a GET to a data endpoint, refreshing the token once on 401.
+
+        Mirrors _post's retry behaviour: on a second 401 the nested
+        _ensure_token_valid raises TokenExpiredError, which the coordinator
+        turns into a re-auth flow.
+        """
+        self._ensure_token_valid()
+
+        url = f"{API_BASE_URL}{path}"
+        resp = self.session.get(url, params=params, timeout=timeout)
+
+        if resp.status_code == 401:
+            _LOGGER.warning("SolarOfThings: received 401 on GET; forcing token refresh")
+            # Force an immediate refresh even if _token_needs_refresh() is False
+            self._access_expires = None
+            self._ensure_token_valid()
+            resp = self.session.get(url, params=params, timeout=timeout)
+
+        resp.raise_for_status()
+        return resp.json()
+
     # ─── Public properties (for persistence in HA config entry) ───────────────
 
     @property
@@ -591,7 +698,61 @@ class SolarOfThingsAPI:
     # ─── Time-series (per device) ──────────────────────────────────────────────
 
     def fetch_latest_data(self, device_id: str) -> dict[str, Any]:
-        """Fetch the latest readings for a device (last 1 hour)."""
+        """Fetch the latest readings for a device (last 1 hour).
+
+        The historical time-series endpoint is the primary source.  Several
+        inverter / WiFi-dongle firmware families never populate it, which leaves
+        every realtime entity "unknown" even though the portal shows live data
+        (issue #7).  For those devices we fall back to the live energy-flow
+        endpoint and translate its field names onto the canonical sensor keys.
+        """
+        latest_values = self._fetch_time_series_values(device_id)
+
+        if not has_realtime_values(latest_values):
+            _LOGGER.debug(
+                "SolarOfThings device %s: time-series returned no realtime "
+                "values; trying energy-flow fallback",
+                device_id,
+            )
+            try:
+                fields = self.fetch_energy_flow(device_id)
+            except TokenExpiredError:
+                # Must reach the coordinator so it can start the re-auth flow.
+                raise
+            except Exception as err:
+                _LOGGER.debug(
+                    "SolarOfThings device %s: energy-flow fallback unavailable: %s",
+                    device_id,
+                    err,
+                )
+            else:
+                mapped = map_energy_flow_fields(fields)
+                if mapped:
+                    _LOGGER.debug(
+                        "SolarOfThings device %s: energy-flow fallback resolved %s",
+                        device_id,
+                        sorted(mapped),
+                    )
+                    # Only fill gaps — never clobber a time-series reading.
+                    for key, value in mapped.items():
+                        latest_values.setdefault(key, value)
+                elif fields:
+                    # Unknown firmware variant: surface the field names so they
+                    # can be added to ENERGY_FLOW_RULES from a bug report.
+                    _LOGGER.warning(
+                        "SolarOfThings device %s: energy-flow returned %d field(s) "
+                        "but none matched a known mapping. Please open an issue "
+                        "with these key names: %s",
+                        device_id,
+                        len(fields),
+                        sorted(fields),
+                    )
+
+        self._apply_derived_values(latest_values)
+        return latest_values
+
+    def _fetch_time_series_values(self, device_id: str) -> dict[str, Any]:
+        """Return the latest value per key from the historical time-series API."""
         end_time = self._now()
         start_time = end_time - timedelta(hours=1)
 
@@ -634,28 +795,62 @@ class SolarOfThingsAPI:
 
         # Unit normalisation: acOutputActivePower is kW in API → W
         if "acOutputActivePower" in latest_values:
-            try:
-                latest_values["acOutputActivePower"] = (
-                    float(latest_values["acOutputActivePower"]) * 1000.0
-                )
-            except Exception:
-                pass
+            converted = _coerce_number(latest_values["acOutputActivePower"])
+            if converted is not None:
+                latest_values["acOutputActivePower"] = converted * 1000.0
+
+        return latest_values
 
-        # Derived values
-        voltage = float(latest_values.get("batteryVoltage") or 0)
-        discharge = float(latest_values.get("batteryDischargeCurrent") or 0)
-        charge = float(latest_values.get("batteryChargingCurrent") or 0)
-        latest_values["batteryPower"] = (discharge - charge) * voltage
+    def fetch_energy_flow(self, device_id: str) -> dict[str, Any]:
+        """Return the live energy-flow ``fields`` mapping for a device.
 
-        pv_power = float(latest_values.get("pvInputPower") or 0)
-        ac_output = float(latest_values.get("acOutputActivePower") or 0)
-        feed_in = float(latest_values.get("feedInPower") or 0)
-        battery_power = float(latest_values.get("batteryPower") or 0)
+        Fallback source for firmware that never populates the time-series
+        endpoint (issue #7).  Returns an empty dict when the endpoint responds
+        without field data, so "no data" and "unsupported" behave identically.
+        """
+        data = self._get(API_ENERGY_FLOW, {"deviceId": device_id, "dataSource": 1})
 
-        latest_values["gridPower"] = max(0.0, ac_output - pv_power + battery_power + feed_in)
-        latest_values["loadPower"] = ac_output
+        if data.get("code") not in (0, None, "0"):
+            raise RuntimeError(
+                f"Energy-flow error code={data.get('code')} "
+                f"message={data.get('message') or data.get('msg')}"
+            )
 
-        return latest_values
+        payload = data.get("data") or {}
+        state = payload.get("deviceAttributeState") or {}
+        fields = state.get("fields")
+        if not isinstance(fields, dict):
+            # Tolerate the values being nested directly under data.fields.
+            fields = payload.get("fields")
+        return fields if isinstance(fields, dict) else {}
+
+    @staticmethod
+    def _apply_derived_values(latest_values: dict[str, Any]) -> None:
+        """Compute values the API does not report directly.
+
+        Gap-filling only: when a source such as the energy-flow fallback already
+        supplied a real measurement, it is kept rather than overwritten with an
+        estimate.  On the time-series path none of these keys are ever returned
+        by the API, so every one of them is still derived exactly as before.
+        """
+        if latest_values.get("batteryPower") is None:
+            voltage = _coerce_number(latest_values.get("batteryVoltage")) or 0.0
+            discharge = _coerce_number(latest_values.get("batteryDischargeCurrent")) or 0.0
+            charge = _coerce_number(latest_values.get("batteryChargingCurrent")) or 0.0
+            latest_values["batteryPower"] = (discharge - charge) * voltage
+
+        ac_output = _coerce_number(latest_values.get("acOutputActivePower")) or 0.0
+
+        if latest_values.get("loadPower") is None:
+            latest_values["loadPower"] = ac_output
+
+        if latest_values.get("gridPower") is None:
+            pv_power = _coerce_number(latest_values.get("pvInputPower")) or 0.0
+            feed_in = _coerce_number(latest_values.get("feedInPower")) or 0.0
+            battery_power = _coerce_number(latest_values.get("batteryPower")) or 0.0
+            latest_values["gridPower"] = max(
+                0.0, ac_output - pv_power + battery_power + feed_in
+            )
 
     # ─── Monthly summary (station) ─────────────────────────────────────────────
 

+ 79 - 0
custom_components/solar_of_things/const.py

@@ -60,6 +60,10 @@ API_MONTHLY_SUMMARY = "/apis/stationOverView/stateAttributeSummary/category/year
 API_SETTINGS_GET   = "/apis/remote/device/configs/cache/get"  # ?deviceId=<id>
 API_SETTINGS_SET   = "/apis/remote/device/config/write"       # ?deviceId=<id>
 API_DEVICE_LIST    = "/apis/device/list"
+# Live "energy flow" endpoint.  GET with ?deviceId=<id>&dataSource=1; values are
+# returned under data.deviceAttributeState.fields.  Used as a fallback when the
+# historical time-series endpoint yields nothing (see ENERGY_FLOW_RULES below).
+API_ENERGY_FLOW    = "/apis/deviceState/simple/energy/flow/v1"
 
 # ─── Token refresh window ──────────────────────────────────────────────────────
 # Refresh the access token this many seconds *before* its stated expiry.
@@ -80,6 +84,81 @@ SENSOR_KEYS = [
     "loadPower",
 ]
 
+# ─── Energy-flow fallback mapping ──────────────────────────────────────────────
+# Several inverter / WiFi-dongle firmware families never populate the historical
+# time-series endpoint (API_TIME_SERIES) that this integration reads by default,
+# so every realtime sensor stays "unknown" while the portal shows live data.
+# Reported for UWB1, RWB1-0x, JC-62xx, DatouBoss DT-series and EASUN units in
+# https://github.com/Conexo-Casa/solar-of-things-ha/issues/7 (and #3, #8, #11,
+# #14, #15).  Those devices serve live values from API_ENERGY_FLOW instead,
+# under a different set of field names.
+#
+# Each canonical sensor key maps to an ordered list of rules.  The first rule
+# that produces a usable number wins.  A rule is (mode, source_fields, scale):
+#   "first" – use the first source field that is present
+#   "sum"   – add every source field that is present (multi-string PV inputs)
+# `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.
+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),
+    ],
+    "batterySOC": [
+        ("first", ("batteryPercentage", "bmsSOC"), 1.0),
+    ],
+    "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.
+ENERGY_FLOW_UNVERIFIED: tuple[str, ...] = (
+    "aPhaseMainsPower",   # candidate for gridPower (sum of the three phases)
+    "bPhaseMainsPower",
+    "cPhaseMainsPower",
+)
+
+# Canonical keys that indicate the time-series endpoint returned usable realtime
+# data.  If none of these are present the energy-flow fallback is attempted.
+REALTIME_PROBE_KEYS: tuple[str, ...] = (
+    "pvInputPower",
+    "acOutputActivePower",
+    "batteryVoltage",
+    "batterySOC",
+    "batteryChargingCurrent",
+    "batteryDischargeCurrent",
+    "feedInPower",
+)
+
 SENSOR_DEFINITIONS = {
     "pvInputPower": {
         "name": "PV Input Power",

+ 224 - 0
tests/test_energy_flow.py

@@ -0,0 +1,224 @@
+"""Tests for the energy-flow fallback mapping.
+
+Covers the fallback added for inverter / WiFi-dongle firmware families that
+never populate the historical time-series endpoint, leaving every realtime
+entity "unknown" while the portal shows live data (issue #7).
+
+The field names and the sample values in ``ISSUE_7_NIGHT_PAYLOAD`` are taken
+verbatim from that report, which was captured at night — hence the zeros.
+"""
+from __future__ import annotations
+
+import pytest
+
+from custom_components.solar_of_things.api import (
+    SolarOfThingsAPI,
+    TokenExpiredError,
+    has_realtime_values,
+    map_energy_flow_fields,
+)
+
+# Exactly the fields reported in issue #7 (UWB1 inverter, night-time reading).
+ISSUE_7_NIGHT_PAYLOAD = {
+    "pv1Power": 0,
+    "pv2Power": 0,
+    "generationPower": 0,
+    "batteryPower": 9,
+    "positiveTerminalBatteryCurrent": 0.4,
+    "negativeTerminalBatteryCurrent": 0,
+    "positiveTerminalBatteryVoltage": 26.6,
+    "bmsBatteryVoltage": 26.6,
+    "batteryPercentage": 100,
+    "bmsSOC": 100,
+    "load_power": 0,
+    "aPhaseOutputVoltage": 222.8,
+    "aPhaseOutputFrequency": 50,
+}
+
+
+# ─── Pure mapping ──────────────────────────────────────────────────────────────
+
+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)
+
+    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
+
+
+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.
+    """
+    mapped = map_energy_flow_fields(
+        {"aPhaseMainsPower": 500, "bPhaseMainsPower": 0, "cPhaseMainsPower": 0}
+    )
+    assert mapped == {}
+
+
+def test_multi_string_pv_is_summed_and_kw_is_scaled() -> None:
+    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
+
+
+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
+
+
+@pytest.mark.parametrize(
+    ("raw", "expected"),
+    [
+        (77, 77.0),
+        ("77", 77.0),
+        ({"value": 63}, 63.0),
+        ([10, 20, 44], 44.0),  # latest-wins, as the time-series endpoint returns
+    ],
+)
+def test_accepts_the_value_shapes_this_portal_uses(raw, expected) -> None:
+    assert map_energy_flow_fields({"bmsSOC": raw})["batterySOC"] == expected
+
+
+@pytest.mark.parametrize("raw", [None, True, False, "n/a", "", {}, []])
+def test_rejects_unusable_values_rather_than_publishing_garbage(raw) -> None:
+    assert "batterySOC" not in map_energy_flow_fields({"bmsSOC": raw})
+
+
+@pytest.mark.parametrize("payload", [None, {}, [1, 2], "text", 0])
+def test_hostile_input_returns_empty_mapping(payload) -> None:
+    assert map_energy_flow_fields(payload) == {}
+
+
+def test_unknown_firmware_fields_are_ignored() -> None:
+    assert map_energy_flow_fields({"someBrandNewKey": 5}) == {}
+
+
+# ─── Realtime probe ────────────────────────────────────────────────────────────
+
+def test_probe_treats_zero_as_a_real_reading() -> None:
+    """A device reporting 0 W at night is working — must not trigger fallback."""
+    assert has_realtime_values({"pvInputPower": 0}) is True
+
+
+@pytest.mark.parametrize(
+    "values", [{}, {"pvInputPower": None}, {"loadPower": 5}, None, "text"]
+)
+def test_probe_false_when_no_realtime_key_has_a_value(values) -> None:
+    assert has_realtime_values(values) is False
+
+
+# ─── fetch_latest_data integration ─────────────────────────────────────────────
+
+@pytest.fixture
+def api_factory():
+    """Build an API instance with stubbed transport, tracking endpoint calls."""
+
+    def _factory(history_fields, flow_fields=None, flow_error=None):
+        api = SolarOfThingsAPI(iot_token="test-token")
+        api._ensure_token_valid = lambda: None  # no network
+        calls = {"time_series": 0, "energy_flow": 0}
+
+        def fake_post(path, payload, **kwargs):
+            calls["time_series"] += 1
+            return {"code": 0, "data": {"payload": {"fields": history_fields}}}
+
+        def fake_get(path, params, **kwargs):
+            calls["energy_flow"] += 1
+            if flow_error is not None:
+                raise flow_error
+            return {
+                "code": 0,
+                "data": {"deviceAttributeState": {"fields": flow_fields or {}}},
+            }
+
+        api._post = fake_post
+        api._get = fake_get
+        return api, calls
+
+    return _factory
+
+
+def test_working_device_is_unaffected(api_factory) -> None:
+    """Regression guard: values and call pattern must match pre-fallback code."""
+    history = {
+        "pvInputPower": [100],
+        "acOutputActivePower": [1.2],
+        "batteryVoltage": [52.0],
+        "batteryDischargeCurrent": [2.0],
+        "batteryChargingCurrent": [0.0],
+        "feedInPower": [0],
+        "batterySOC": [90],
+    }
+    api, calls = api_factory(history, flow_fields={"bmsSOC": 1})
+    result = api.fetch_latest_data("device-1")
+
+    assert result["acOutputActivePower"] == 1200.0     # kW → W
+    assert result["batteryPower"] == 104.0             # (2.0 - 0.0) * 52.0
+    assert result["gridPower"] == 1204.0               # max(0, 1200-100+104+0)
+    assert result["loadPower"] == 1200.0
+    # The fallback must cost a working install nothing.
+    assert calls["energy_flow"] == 0
+
+
+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")
+
+    assert calls["energy_flow"] == 1
+    assert result["batteryVoltage"] == 26.6
+    assert result["batterySOC"] == 100.0
+    assert result["batteryDischargeCurrent"] == 0.4
+
+
+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.
+    """
+    api, _ = api_factory({}, flow_fields=ISSUE_7_NIGHT_PAYLOAD)
+    assert api.fetch_latest_data("device-3")["batteryPower"] == 9.0
+
+
+def test_time_series_values_win_over_the_fallback(api_factory) -> None:
+    api, calls = api_factory({"batterySOC": [42]}, flow_fields={"bmsSOC": 99})
+    result = api.fetch_latest_data("device-4")
+
+    assert result["batterySOC"] == 42
+    assert calls["energy_flow"] == 0
+
+
+def test_flow_endpoint_failure_degrades_quietly(api_factory) -> None:
+    """An unsupported endpoint must not fail the whole coordinator update."""
+    api, _ = api_factory({}, flow_error=RuntimeError("404 not supported"))
+    result = api.fetch_latest_data("device-5")
+
+    assert isinstance(result, dict)
+    assert result["loadPower"] == 0.0
+
+
+def test_token_expiry_propagates_for_reauth(api_factory) -> None:
+    """TokenExpiredError must reach the coordinator so re-auth can start."""
+    api, _ = api_factory({}, flow_error=TokenExpiredError("expired"))
+    with pytest.raises(TokenExpiredError):
+        api.fetch_latest_data("device-6")
+
+
+def test_unknown_firmware_logs_field_names_for_reporting(api_factory, caplog) -> None:
+    api, _ = api_factory({}, flow_fields={"someBrandNewKey": 5, "another": 7})
+    api.fetch_latest_data("device-7")
+
+    assert "another" in caplog.text and "someBrandNewKey" in caplog.text