test_energy_flow.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. """Tests for the energy-flow fallback mapping.
  2. Covers the fallback added for inverter / WiFi-dongle firmware families that
  3. never populate the historical time-series endpoint, leaving every realtime
  4. entity "unknown" while the portal shows live data (issue #7).
  5. The field names and the sample values in ``ISSUE_7_NIGHT_PAYLOAD`` are taken
  6. verbatim from that report, which was captured at night — hence the zeros.
  7. The daylight payloads (``ISSUE_7_AC_PV_CHARGING`` / ``ISSUE_7_NOAC_PV_DISCHARGING``)
  8. are trimmed from hidemichixt-creator's four-state capture set posted
  9. 2026-09-15, which settled the unit and sign questions the night capture
  10. could not.
  11. """
  12. from __future__ import annotations
  13. import pytest
  14. from custom_components.solar_of_things.api import (
  15. EnergyFlowRuleNotConfiguredError,
  16. SolarOfThingsAPI,
  17. TokenExpiredError,
  18. has_realtime_values,
  19. map_energy_flow_fields,
  20. )
  21. from custom_components.solar_of_things.const import ENERGY_FLOW_UNVERIFIED
  22. # Exactly the fields reported in issue #7 (UWB1 inverter, night-time reading).
  23. ISSUE_7_NIGHT_PAYLOAD = {
  24. "pv1Power": 0,
  25. "pv2Power": 0,
  26. "generationPower": 0,
  27. "batteryPower": 9,
  28. "positiveTerminalBatteryCurrent": 0.4,
  29. "negativeTerminalBatteryCurrent": 0,
  30. "positiveTerminalBatteryVoltage": 26.6,
  31. "bmsBatteryVoltage": 26.6,
  32. "batteryPercentage": 100,
  33. "bmsSOC": 100,
  34. "load_power": 0,
  35. "aPhaseOutputVoltage": 222.8,
  36. "aPhaseOutputFrequency": 50,
  37. }
  38. # Trimmed from UPS_AC_PV_BatteryCharging.txt (issue #7, 2026-09-15): AC
  39. # connected, PV producing, battery charging.
  40. ISSUE_7_AC_PV_CHARGING = {
  41. "pv1Power": 234,
  42. "pv2Power": 0,
  43. "load_power": 0.614, # kW, per the payload's own "unit" field
  44. "aPhaseMainsPower": -1100, # W, per the payload's own "unit" field
  45. "bPhaseMainsPower": 0,
  46. "cPhaseMainsPower": 0,
  47. "positiveTerminalBatteryCurrent": -27,
  48. "negativeTerminalBatteryCurrent": 0,
  49. "batteryPower": -720,
  50. "bmsSOC": 54,
  51. }
  52. # Trimmed from UPS_NoAC_PV_BatteryDischarging.txt (issue #7, 2026-09-15): AC
  53. # disconnected, PV producing, battery discharging.
  54. ISSUE_7_NOAC_PV_DISCHARGING = {
  55. "pv1Power": 262,
  56. "pv2Power": 0,
  57. "load_power": 0.635,
  58. "aPhaseMainsPower": 0,
  59. "bPhaseMainsPower": 0,
  60. "cPhaseMainsPower": 0,
  61. "positiveTerminalBatteryCurrent": 17.6,
  62. "negativeTerminalBatteryCurrent": 0,
  63. "batteryPower": 462,
  64. "bmsSOC": 54,
  65. }
  66. # Trimmed from UPS_AC_PV_GridFeeding.txt (issue #7, 2026-09-16): the capture
  67. # hidemichixt-creator took specifically to settle the positive/exporting case
  68. # left unconfirmed in v2.6.1 — PV surplus after a full battery is being fed
  69. # back to the grid.
  70. ISSUE_7_GRID_FEEDING = {
  71. "pv1Power": 897,
  72. "pv2Power": 0,
  73. "load_power": 0.351,
  74. "aPhaseMainsPower": 495,
  75. "bPhaseMainsPower": 0,
  76. "cPhaseMainsPower": 0,
  77. "positiveTerminalBatteryCurrent": 0,
  78. "negativeTerminalBatteryCurrent": 0,
  79. "batteryPower": 0, # battery full (96% SOC), neither charging nor discharging
  80. "bmsSOC": 96,
  81. }
  82. # ─── Pure mapping ──────────────────────────────────────────────────────────────
  83. def test_issue_7_payload_maps_to_canonical_keys() -> None:
  84. """The reported payload must populate the previously-unknown sensors."""
  85. mapped = map_energy_flow_fields(ISSUE_7_NIGHT_PAYLOAD)
  86. # Units confirmed by a non-zero observed value in the report — publishable.
  87. assert mapped["pvInputPower"] == 0.0
  88. assert mapped["batteryVoltage"] == 26.6
  89. assert mapped["batterySOC"] == 100.0
  90. assert mapped["batteryPower"] == 9.0
  91. # Confirmed by the 2026-09-15 daylight captures (see below) — 0 at night
  92. # is a real reading now, not an unconfirmed guess.
  93. assert mapped["acOutputActivePower"] == 0.0
  94. assert mapped["batteryChargingCurrent"] == 0.0
  95. assert mapped["batteryDischargeCurrent"] == 0.4 # idle trickle, per the report
  96. # loadPower (camelCase) has no confirmed source — must stay ABSENT rather
  97. # than publish a possibly-wrong value. See ENERGY_FLOW_UNVERIFIED in const.py.
  98. assert "loadPower" not in mapped
  99. def test_ac_output_power_mapped_from_load_power_kw() -> None:
  100. """load_power is confirmed kW by the payload's own "unit" tag → x1000."""
  101. mapped = map_energy_flow_fields(ISSUE_7_AC_PV_CHARGING)
  102. assert mapped["acOutputActivePower"] == 614.0
  103. def test_grid_import_and_feed_in_split_from_signed_mains_power() -> None:
  104. """aPhaseMainsPower is confirmed W; negative means importing from mains.
  105. Sign confirmed by conservation of power: load (614 W) + battery charging
  106. (720 W) - PV (234 W) = 1100 W, matching |aPhaseMainsPower| exactly.
  107. """
  108. mapped = map_energy_flow_fields(ISSUE_7_AC_PV_CHARGING)
  109. assert mapped["gridPower"] == 1100.0
  110. assert mapped["feedInPower"] == 0.0 # not exporting while importing
  111. # No AC connection at all -> the field reads 0, which is a real
  112. # "no grid" measurement, not a missing one.
  113. mapped_no_ac = map_energy_flow_fields(ISSUE_7_NOAC_PV_DISCHARGING)
  114. assert mapped_no_ac["gridPower"] == 0.0
  115. assert mapped_no_ac["feedInPower"] == 0.0
  116. def test_feed_in_power_positive_case_confirmed_by_direct_export_capture() -> None:
  117. """v2.6.1 shipped Grid Feed-in Power's positive/exporting case inferred
  118. only by symmetry — no capture had shown a device actually exporting.
  119. hidemichixt-creator's follow-up capture (issue #7, 2026-09-16) settles it:
  120. PV surplus (897 W) after a full battery (0 W charge/discharge) with a
  121. 351 W load leaves ~546 W to go somewhere, and aPhaseMainsPower reads
  122. +495 W (the ~50 W gap is ordinary inverter conversion loss) — positive
  123. really does mean exporting, matching the prediction exactly.
  124. """
  125. mapped = map_energy_flow_fields(ISSUE_7_GRID_FEEDING)
  126. assert mapped["feedInPower"] == 495.0
  127. assert mapped["gridPower"] == 0.0 # not importing while exporting
  128. def test_battery_charging_and_discharge_current_split_by_sign() -> None:
  129. """positiveTerminalBatteryCurrent flips sign with charge direction.
  130. Confirmed across all four states captured for issue #7: negative while
  131. charging, positive while discharging.
  132. """
  133. charging = map_energy_flow_fields(ISSUE_7_AC_PV_CHARGING)
  134. assert charging["batteryChargingCurrent"] == 27.0
  135. assert charging["batteryDischargeCurrent"] == 0.0
  136. discharging = map_energy_flow_fields(ISSUE_7_NOAC_PV_DISCHARGING)
  137. assert discharging["batteryChargingCurrent"] == 0.0
  138. assert discharging["batteryDischargeCurrent"] == 17.6
  139. def test_unverified_fields_are_never_mapped() -> None:
  140. """No field in ENERGY_FLOW_UNVERIFIED may produce a sensor value.
  141. A 1000x-wrong power value feeds the HA Energy dashboard and long-term
  142. statistics, which a later fix cannot un-poison; an inverted charge/discharge
  143. current is actively misleading. Both are worse than an unknown sensor.
  144. This is the guard that stops a well-meaning change from re-enabling one of
  145. these before a confirming capture exists.
  146. """
  147. for field in ENERGY_FLOW_UNVERIFIED:
  148. assert map_energy_flow_fields({field: 500}) == {}, field
  149. # And as a group, mirroring the shape of a real payload.
  150. assert map_energy_flow_fields({f: 500 for f in ENERGY_FLOW_UNVERIFIED}) == {}
  151. def test_multi_string_pv_is_summed_at_face_value() -> None:
  152. """Per-string PV inputs are reported in W and summed without scaling."""
  153. mapped = map_energy_flow_fields(
  154. {"pv1Power": 1200, "pv2Power": 800, "load_power": 1.5}
  155. )
  156. assert mapped["pvInputPower"] == 2000.0
  157. # load_power would need an unconfirmed kW→W conversion, so it stays out.
  158. assert "loadPower" not in mapped
  159. def test_generation_power_is_not_used_as_a_pv_fallback() -> None:
  160. """generationPower is an aggregate in kW and its x1000 is unconfirmed.
  161. Excluding it means a device reporting ONLY generationPower gets an unknown
  162. PV sensor rather than one that may be 1000x wrong.
  163. """
  164. assert map_energy_flow_fields({"generationPower": 2.4}) == {}
  165. # A per-string reading is still mapped when one is present.
  166. assert (
  167. map_energy_flow_fields({"pv1Power": 500, "generationPower": 2.4})[
  168. "pvInputPower"
  169. ]
  170. == 500.0
  171. )
  172. @pytest.mark.parametrize(
  173. ("raw", "expected"),
  174. [
  175. (77, 77.0),
  176. ("77", 77.0),
  177. ({"value": 63}, 63.0),
  178. ([10, 20, 44], 44.0), # latest-wins, as the time-series endpoint returns
  179. ],
  180. )
  181. def test_accepts_the_value_shapes_this_portal_uses(raw, expected) -> None:
  182. assert map_energy_flow_fields({"bmsSOC": raw})["batterySOC"] == expected
  183. @pytest.mark.parametrize("raw", [None, True, False, "n/a", "", {}, []])
  184. def test_rejects_unusable_values_rather_than_publishing_garbage(raw) -> None:
  185. assert "batterySOC" not in map_energy_flow_fields({"bmsSOC": raw})
  186. @pytest.mark.parametrize("payload", [None, {}, [1, 2], "text", 0])
  187. def test_hostile_input_returns_empty_mapping(payload) -> None:
  188. assert map_energy_flow_fields(payload) == {}
  189. def test_unknown_firmware_fields_are_ignored() -> None:
  190. assert map_energy_flow_fields({"someBrandNewKey": 5}) == {}
  191. # ─── Realtime probe ────────────────────────────────────────────────────────────
  192. def test_probe_treats_zero_as_a_real_reading() -> None:
  193. """A device reporting 0 W at night is working — must not trigger fallback."""
  194. assert has_realtime_values({"pvInputPower": 0}) is True
  195. @pytest.mark.parametrize(
  196. "values", [{}, {"pvInputPower": None}, {"loadPower": 5}, None, "text"]
  197. )
  198. def test_probe_false_when_no_realtime_key_has_a_value(values) -> None:
  199. assert has_realtime_values(values) is False
  200. # ─── fetch_latest_data integration ─────────────────────────────────────────────
  201. @pytest.fixture
  202. def api_factory():
  203. """Build an API instance with stubbed transport, tracking endpoint calls."""
  204. def _factory(history_fields, flow_fields=None, flow_error=None):
  205. api = SolarOfThingsAPI(iot_token="test-token")
  206. api._ensure_token_valid = lambda: None # no network
  207. calls = {"time_series": 0, "energy_flow": 0}
  208. def fake_post(path, payload, **kwargs):
  209. calls["time_series"] += 1
  210. return {"code": 0, "data": {"payload": {"fields": history_fields}}}
  211. def fake_get(path, params, **kwargs):
  212. calls["energy_flow"] += 1
  213. if flow_error is not None:
  214. raise flow_error
  215. return {
  216. "code": 0,
  217. "data": {"deviceAttributeState": {"fields": flow_fields or {}}},
  218. }
  219. api._post = fake_post
  220. api._get = fake_get
  221. return api, calls
  222. return _factory
  223. def test_working_device_is_unaffected(api_factory) -> None:
  224. """Regression guard: values and call pattern must match pre-fallback code."""
  225. history = {
  226. "pvInputPower": [100],
  227. "acOutputActivePower": [1.2],
  228. "batteryVoltage": [52.0],
  229. "batteryDischargeCurrent": [2.0],
  230. "batteryChargingCurrent": [0.0],
  231. "feedInPower": [0],
  232. "batterySOC": [90],
  233. }
  234. api, calls = api_factory(history, flow_fields={"bmsSOC": 1})
  235. result = api.fetch_latest_data("device-1")
  236. assert result["acOutputActivePower"] == 1200.0 # kW → W
  237. assert result["batteryPower"] == 104.0 # (2.0 - 0.0) * 52.0
  238. assert result["gridPower"] == 1204.0 # max(0, 1200-100+104+0)
  239. assert result["loadPower"] == 1200.0
  240. # The fallback must cost a working install nothing.
  241. assert calls["energy_flow"] == 0
  242. def test_hpvinv02_alternate_key_names_are_mapped_to_canonical(api_factory) -> None:
  243. """Siseli HPVINV02 ("Inverter Top One" gather protocol) reports PV/AC/SOC
  244. under pvPower / outputActivePower / batteryCapacity instead of the
  245. documented pvInputPower / acOutputActivePower / batterySOC — these stayed
  246. "unknown" forever until the alias mapping was added.
  247. """
  248. history = {
  249. "pvPower": [1.5],
  250. "outputActivePower": [1.1],
  251. "batteryCapacity": [77],
  252. "batteryVoltage": [52.0],
  253. "batteryDischargeCurrent": [2.0],
  254. "batteryChargingCurrent": [0.0],
  255. "feedInPower": [0],
  256. }
  257. api, calls = api_factory(history, flow_fields={"bmsSOC": 1})
  258. result = api.fetch_latest_data("device-hpvinv02")
  259. assert result["pvInputPower"] == 1500.0 # kW → W, via the alias
  260. assert result["acOutputActivePower"] == 1100.0 # kW → W, via the alias
  261. assert result["batterySOC"] == 77
  262. # The alternate keys must not leak into the published values.
  263. assert "pvPower" not in result
  264. assert "outputActivePower" not in result
  265. assert "batteryCapacity" not in result
  266. # Derived values still come from the shared _apply_derived_values path.
  267. assert result["batteryPower"] == 104.0 # (2.0 - 0.0) * 52.0
  268. assert result["loadPower"] == 1100.0
  269. assert calls["energy_flow"] == 0
  270. def test_documented_keys_win_when_a_device_reports_both(api_factory) -> None:
  271. """A device that already uses the documented key names must be unaffected
  272. by the alias — the canonical value always wins, the alias is discarded,
  273. and (critically) pvInputPower is NOT kW->W converted on this path: it's
  274. already W for every device this integration supported before HPVINV02,
  275. and converting it unconditionally regressed test_working_device_is_unaffected.
  276. """
  277. history = {
  278. "pvInputPower": [2.0],
  279. "pvPower": [999],
  280. "acOutputActivePower": [1.0],
  281. "batterySOC": [55],
  282. "batteryCapacity": [10],
  283. }
  284. api, _ = api_factory(history, flow_fields={})
  285. result = api.fetch_latest_data("device-both-keys")
  286. assert result["pvInputPower"] == 2.0
  287. assert result["batterySOC"] == 55
  288. def test_fallback_populates_sensors_when_time_series_is_empty(api_factory) -> None:
  289. api, calls = api_factory({}, flow_fields=ISSUE_7_NIGHT_PAYLOAD)
  290. result = api.fetch_latest_data("device-2")
  291. assert calls["energy_flow"] == 1
  292. assert result["batteryVoltage"] == 26.6
  293. assert result["batterySOC"] == 100.0
  294. assert result["batteryPower"] == 9.0
  295. def test_measured_battery_power_is_not_overwritten_by_the_estimate(api_factory) -> None:
  296. """The flow endpoint reports batteryPower directly; keep it.
  297. The terminal currents are no longer mapped from flow data (direction
  298. unconfirmed), so no voltage x current estimate can be derived from this
  299. payload at all — but the no-clobber precedence must still hold for devices
  300. that get their currents from the time-series path.
  301. """
  302. api, _ = api_factory({}, flow_fields=ISSUE_7_NIGHT_PAYLOAD)
  303. assert api.fetch_latest_data("device-3")["batteryPower"] == 9.0
  304. def test_time_series_values_win_over_the_fallback(api_factory) -> None:
  305. api, calls = api_factory({"batterySOC": [42]}, flow_fields={"bmsSOC": 99})
  306. result = api.fetch_latest_data("device-4")
  307. assert result["batterySOC"] == 42
  308. assert calls["energy_flow"] == 0
  309. def test_flow_endpoint_failure_degrades_quietly(api_factory) -> None:
  310. """An unsupported endpoint must not fail the whole coordinator update."""
  311. api, _ = api_factory({}, flow_error=RuntimeError("404 not supported"))
  312. result = api.fetch_latest_data("device-5")
  313. assert isinstance(result, dict)
  314. assert result["loadPower"] == 0.0
  315. def test_token_expiry_propagates_for_reauth(api_factory) -> None:
  316. """TokenExpiredError must reach the coordinator so re-auth can start."""
  317. api, _ = api_factory({}, flow_error=TokenExpiredError("expired"))
  318. with pytest.raises(TokenExpiredError):
  319. api.fetch_latest_data("device-6")
  320. def test_energy_flow_rule_not_configured_logs_a_warning(api_factory, caplog) -> None:
  321. """Issue #21: this failure mode looked identical to "no data" — no
  322. sensors AND no warning — because it fell into the same silent DEBUG-level
  323. except clause as an unsupported/404 endpoint. It must be loud by default,
  324. since the fix (configuring an energy-flow rule on the portal) is on the
  325. user/installer's side, not something a future field mapping can resolve.
  326. """
  327. api, _ = api_factory(
  328. {}, flow_error=EnergyFlowRuleNotConfiguredError("code=70132 message=Energy flow rule not exists")
  329. )
  330. with caplog.at_level("WARNING"):
  331. result = api.fetch_latest_data("device-8")
  332. assert isinstance(result, dict) # still degrades quietly, not a raised error
  333. assert any(record.levelname == "WARNING" for record in caplog.records)
  334. assert "energy-flow rule" in caplog.text.lower()
  335. def test_unknown_firmware_logs_field_names_for_reporting(api_factory, caplog) -> None:
  336. api, _ = api_factory({}, flow_fields={"someBrandNewKey": 5, "another": 7})
  337. api.fetch_latest_data("device-7")
  338. assert "another" in caplog.text and "someBrandNewKey" in caplog.text