test_energy_flow.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  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. """
  8. from __future__ import annotations
  9. import pytest
  10. from custom_components.solar_of_things.api import (
  11. SolarOfThingsAPI,
  12. TokenExpiredError,
  13. has_realtime_values,
  14. map_energy_flow_fields,
  15. )
  16. from custom_components.solar_of_things.const import ENERGY_FLOW_UNVERIFIED
  17. # Exactly the fields reported in issue #7 (UWB1 inverter, night-time reading).
  18. ISSUE_7_NIGHT_PAYLOAD = {
  19. "pv1Power": 0,
  20. "pv2Power": 0,
  21. "generationPower": 0,
  22. "batteryPower": 9,
  23. "positiveTerminalBatteryCurrent": 0.4,
  24. "negativeTerminalBatteryCurrent": 0,
  25. "positiveTerminalBatteryVoltage": 26.6,
  26. "bmsBatteryVoltage": 26.6,
  27. "batteryPercentage": 100,
  28. "bmsSOC": 100,
  29. "load_power": 0,
  30. "aPhaseOutputVoltage": 222.8,
  31. "aPhaseOutputFrequency": 50,
  32. }
  33. # ─── Pure mapping ──────────────────────────────────────────────────────────────
  34. def test_issue_7_payload_maps_to_canonical_keys() -> None:
  35. """The reported payload must populate the previously-unknown sensors."""
  36. mapped = map_energy_flow_fields(ISSUE_7_NIGHT_PAYLOAD)
  37. # Units confirmed by a non-zero observed value in the report — publishable.
  38. assert mapped["pvInputPower"] == 0.0
  39. assert mapped["batteryVoltage"] == 26.6
  40. assert mapped["batterySOC"] == 100.0
  41. assert mapped["batteryPower"] == 9.0
  42. # Unit or direction unconfirmed — must stay ABSENT rather than publish a
  43. # possibly-wrong value. See ENERGY_FLOW_UNVERIFIED in const.py.
  44. for unconfirmed in (
  45. "loadPower",
  46. "acOutputActivePower",
  47. "batteryChargingCurrent",
  48. "batteryDischargeCurrent",
  49. ):
  50. assert unconfirmed not in mapped, unconfirmed
  51. def test_unverified_fields_are_never_mapped() -> None:
  52. """No field in ENERGY_FLOW_UNVERIFIED may produce a sensor value.
  53. A 1000x-wrong power value feeds the HA Energy dashboard and long-term
  54. statistics, which a later fix cannot un-poison; an inverted charge/discharge
  55. current is actively misleading. Both are worse than an unknown sensor.
  56. This is the guard that stops a well-meaning change from re-enabling one of
  57. these before a confirming capture exists.
  58. """
  59. for field in ENERGY_FLOW_UNVERIFIED:
  60. assert map_energy_flow_fields({field: 500}) == {}, field
  61. # And as a group, mirroring the shape of a real payload.
  62. assert map_energy_flow_fields({f: 500 for f in ENERGY_FLOW_UNVERIFIED}) == {}
  63. def test_multi_string_pv_is_summed_at_face_value() -> None:
  64. """Per-string PV inputs are reported in W and summed without scaling."""
  65. mapped = map_energy_flow_fields(
  66. {"pv1Power": 1200, "pv2Power": 800, "load_power": 1.5}
  67. )
  68. assert mapped["pvInputPower"] == 2000.0
  69. # load_power would need an unconfirmed kW→W conversion, so it stays out.
  70. assert "loadPower" not in mapped
  71. def test_generation_power_is_not_used_as_a_pv_fallback() -> None:
  72. """generationPower is an aggregate in kW and its x1000 is unconfirmed.
  73. Excluding it means a device reporting ONLY generationPower gets an unknown
  74. PV sensor rather than one that may be 1000x wrong.
  75. """
  76. assert map_energy_flow_fields({"generationPower": 2.4}) == {}
  77. # A per-string reading is still mapped when one is present.
  78. assert (
  79. map_energy_flow_fields({"pv1Power": 500, "generationPower": 2.4})[
  80. "pvInputPower"
  81. ]
  82. == 500.0
  83. )
  84. @pytest.mark.parametrize(
  85. ("raw", "expected"),
  86. [
  87. (77, 77.0),
  88. ("77", 77.0),
  89. ({"value": 63}, 63.0),
  90. ([10, 20, 44], 44.0), # latest-wins, as the time-series endpoint returns
  91. ],
  92. )
  93. def test_accepts_the_value_shapes_this_portal_uses(raw, expected) -> None:
  94. assert map_energy_flow_fields({"bmsSOC": raw})["batterySOC"] == expected
  95. @pytest.mark.parametrize("raw", [None, True, False, "n/a", "", {}, []])
  96. def test_rejects_unusable_values_rather_than_publishing_garbage(raw) -> None:
  97. assert "batterySOC" not in map_energy_flow_fields({"bmsSOC": raw})
  98. @pytest.mark.parametrize("payload", [None, {}, [1, 2], "text", 0])
  99. def test_hostile_input_returns_empty_mapping(payload) -> None:
  100. assert map_energy_flow_fields(payload) == {}
  101. def test_unknown_firmware_fields_are_ignored() -> None:
  102. assert map_energy_flow_fields({"someBrandNewKey": 5}) == {}
  103. # ─── Realtime probe ────────────────────────────────────────────────────────────
  104. def test_probe_treats_zero_as_a_real_reading() -> None:
  105. """A device reporting 0 W at night is working — must not trigger fallback."""
  106. assert has_realtime_values({"pvInputPower": 0}) is True
  107. @pytest.mark.parametrize(
  108. "values", [{}, {"pvInputPower": None}, {"loadPower": 5}, None, "text"]
  109. )
  110. def test_probe_false_when_no_realtime_key_has_a_value(values) -> None:
  111. assert has_realtime_values(values) is False
  112. # ─── fetch_latest_data integration ─────────────────────────────────────────────
  113. @pytest.fixture
  114. def api_factory():
  115. """Build an API instance with stubbed transport, tracking endpoint calls."""
  116. def _factory(history_fields, flow_fields=None, flow_error=None):
  117. api = SolarOfThingsAPI(iot_token="test-token")
  118. api._ensure_token_valid = lambda: None # no network
  119. calls = {"time_series": 0, "energy_flow": 0}
  120. def fake_post(path, payload, **kwargs):
  121. calls["time_series"] += 1
  122. return {"code": 0, "data": {"payload": {"fields": history_fields}}}
  123. def fake_get(path, params, **kwargs):
  124. calls["energy_flow"] += 1
  125. if flow_error is not None:
  126. raise flow_error
  127. return {
  128. "code": 0,
  129. "data": {"deviceAttributeState": {"fields": flow_fields or {}}},
  130. }
  131. api._post = fake_post
  132. api._get = fake_get
  133. return api, calls
  134. return _factory
  135. def test_working_device_is_unaffected(api_factory) -> None:
  136. """Regression guard: values and call pattern must match pre-fallback code."""
  137. history = {
  138. "pvInputPower": [100],
  139. "acOutputActivePower": [1.2],
  140. "batteryVoltage": [52.0],
  141. "batteryDischargeCurrent": [2.0],
  142. "batteryChargingCurrent": [0.0],
  143. "feedInPower": [0],
  144. "batterySOC": [90],
  145. }
  146. api, calls = api_factory(history, flow_fields={"bmsSOC": 1})
  147. result = api.fetch_latest_data("device-1")
  148. assert result["acOutputActivePower"] == 1200.0 # kW → W
  149. assert result["batteryPower"] == 104.0 # (2.0 - 0.0) * 52.0
  150. assert result["gridPower"] == 1204.0 # max(0, 1200-100+104+0)
  151. assert result["loadPower"] == 1200.0
  152. # The fallback must cost a working install nothing.
  153. assert calls["energy_flow"] == 0
  154. def test_hpvinv02_alternate_key_names_are_mapped_to_canonical(api_factory) -> None:
  155. """Siseli HPVINV02 ("Inverter Top One" gather protocol) reports PV/AC/SOC
  156. under pvPower / outputActivePower / batteryCapacity instead of the
  157. documented pvInputPower / acOutputActivePower / batterySOC — these stayed
  158. "unknown" forever until the alias mapping was added.
  159. """
  160. history = {
  161. "pvPower": [1.5],
  162. "outputActivePower": [1.1],
  163. "batteryCapacity": [77],
  164. "batteryVoltage": [52.0],
  165. "batteryDischargeCurrent": [2.0],
  166. "batteryChargingCurrent": [0.0],
  167. "feedInPower": [0],
  168. }
  169. api, calls = api_factory(history, flow_fields={"bmsSOC": 1})
  170. result = api.fetch_latest_data("device-hpvinv02")
  171. assert result["pvInputPower"] == 1500.0 # kW → W, via the alias
  172. assert result["acOutputActivePower"] == 1100.0 # kW → W, via the alias
  173. assert result["batterySOC"] == 77
  174. # The alternate keys must not leak into the published values.
  175. assert "pvPower" not in result
  176. assert "outputActivePower" not in result
  177. assert "batteryCapacity" not in result
  178. # Derived values still come from the shared _apply_derived_values path.
  179. assert result["batteryPower"] == 104.0 # (2.0 - 0.0) * 52.0
  180. assert result["loadPower"] == 1100.0
  181. assert calls["energy_flow"] == 0
  182. def test_documented_keys_win_when_a_device_reports_both(api_factory) -> None:
  183. """A device that already uses the documented key names must be unaffected
  184. by the alias — the canonical value always wins, the alias is discarded,
  185. and (critically) pvInputPower is NOT kW->W converted on this path: it's
  186. already W for every device this integration supported before HPVINV02,
  187. and converting it unconditionally regressed test_working_device_is_unaffected.
  188. """
  189. history = {
  190. "pvInputPower": [2.0],
  191. "pvPower": [999],
  192. "acOutputActivePower": [1.0],
  193. "batterySOC": [55],
  194. "batteryCapacity": [10],
  195. }
  196. api, _ = api_factory(history, flow_fields={})
  197. result = api.fetch_latest_data("device-both-keys")
  198. assert result["pvInputPower"] == 2.0
  199. assert result["batterySOC"] == 55
  200. def test_fallback_populates_sensors_when_time_series_is_empty(api_factory) -> None:
  201. api, calls = api_factory({}, flow_fields=ISSUE_7_NIGHT_PAYLOAD)
  202. result = api.fetch_latest_data("device-2")
  203. assert calls["energy_flow"] == 1
  204. assert result["batteryVoltage"] == 26.6
  205. assert result["batterySOC"] == 100.0
  206. assert result["batteryPower"] == 9.0
  207. def test_measured_battery_power_is_not_overwritten_by_the_estimate(api_factory) -> None:
  208. """The flow endpoint reports batteryPower directly; keep it.
  209. The terminal currents are no longer mapped from flow data (direction
  210. unconfirmed), so no voltage x current estimate can be derived from this
  211. payload at all — but the no-clobber precedence must still hold for devices
  212. that get their currents from the time-series path.
  213. """
  214. api, _ = api_factory({}, flow_fields=ISSUE_7_NIGHT_PAYLOAD)
  215. assert api.fetch_latest_data("device-3")["batteryPower"] == 9.0
  216. def test_time_series_values_win_over_the_fallback(api_factory) -> None:
  217. api, calls = api_factory({"batterySOC": [42]}, flow_fields={"bmsSOC": 99})
  218. result = api.fetch_latest_data("device-4")
  219. assert result["batterySOC"] == 42
  220. assert calls["energy_flow"] == 0
  221. def test_flow_endpoint_failure_degrades_quietly(api_factory) -> None:
  222. """An unsupported endpoint must not fail the whole coordinator update."""
  223. api, _ = api_factory({}, flow_error=RuntimeError("404 not supported"))
  224. result = api.fetch_latest_data("device-5")
  225. assert isinstance(result, dict)
  226. assert result["loadPower"] == 0.0
  227. def test_token_expiry_propagates_for_reauth(api_factory) -> None:
  228. """TokenExpiredError must reach the coordinator so re-auth can start."""
  229. api, _ = api_factory({}, flow_error=TokenExpiredError("expired"))
  230. with pytest.raises(TokenExpiredError):
  231. api.fetch_latest_data("device-6")
  232. def test_unknown_firmware_logs_field_names_for_reporting(api_factory, caplog) -> None:
  233. api, _ = api_factory({}, flow_fields={"someBrandNewKey": 5, "another": 7})
  234. api.fetch_latest_data("device-7")
  235. assert "another" in caplog.text and "someBrandNewKey" in caplog.text