test_energy_flow.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  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. # Exactly the fields reported in issue #7 (UWB1 inverter, night-time reading).
  17. ISSUE_7_NIGHT_PAYLOAD = {
  18. "pv1Power": 0,
  19. "pv2Power": 0,
  20. "generationPower": 0,
  21. "batteryPower": 9,
  22. "positiveTerminalBatteryCurrent": 0.4,
  23. "negativeTerminalBatteryCurrent": 0,
  24. "positiveTerminalBatteryVoltage": 26.6,
  25. "bmsBatteryVoltage": 26.6,
  26. "batteryPercentage": 100,
  27. "bmsSOC": 100,
  28. "load_power": 0,
  29. "aPhaseOutputVoltage": 222.8,
  30. "aPhaseOutputFrequency": 50,
  31. }
  32. # ─── Pure mapping ──────────────────────────────────────────────────────────────
  33. def test_issue_7_payload_maps_to_canonical_keys() -> None:
  34. """The reported payload must populate the previously-unknown sensors."""
  35. mapped = map_energy_flow_fields(ISSUE_7_NIGHT_PAYLOAD)
  36. assert mapped["pvInputPower"] == 0.0
  37. assert mapped["batteryVoltage"] == 26.6
  38. assert mapped["batterySOC"] == 100.0
  39. assert mapped["batteryPower"] == 9.0
  40. assert mapped["batteryDischargeCurrent"] == 0.4
  41. assert mapped["batteryChargingCurrent"] == 0.0
  42. assert mapped["loadPower"] == 0.0
  43. assert mapped["acOutputActivePower"] == 0.0
  44. def test_mains_power_fields_are_not_mapped() -> None:
  45. """Unit for the per-phase mains fields is unconfirmed, so stay out.
  46. Publishing a possibly-1000x-wrong value into the Energy dashboard is worse
  47. than leaving the sensor unknown. See ENERGY_FLOW_UNVERIFIED in const.py.
  48. """
  49. mapped = map_energy_flow_fields(
  50. {"aPhaseMainsPower": 500, "bPhaseMainsPower": 0, "cPhaseMainsPower": 0}
  51. )
  52. assert mapped == {}
  53. def test_multi_string_pv_is_summed_and_kw_is_scaled() -> None:
  54. mapped = map_energy_flow_fields(
  55. {"pv1Power": 1200, "pv2Power": 800, "load_power": 1.5}
  56. )
  57. assert mapped["pvInputPower"] == 2000.0
  58. assert mapped["loadPower"] == 1500.0 # kW → W
  59. def test_generation_power_is_only_a_pv_fallback() -> None:
  60. """generationPower (kW) is used only when no per-string field exists."""
  61. assert map_energy_flow_fields({"generationPower": 2.4})["pvInputPower"] == 2400.0
  62. # A per-string reading always wins over the aggregate.
  63. both = {"pv1Power": 500, "generationPower": 2.4}
  64. assert map_energy_flow_fields(both)["pvInputPower"] == 500.0
  65. @pytest.mark.parametrize(
  66. ("raw", "expected"),
  67. [
  68. (77, 77.0),
  69. ("77", 77.0),
  70. ({"value": 63}, 63.0),
  71. ([10, 20, 44], 44.0), # latest-wins, as the time-series endpoint returns
  72. ],
  73. )
  74. def test_accepts_the_value_shapes_this_portal_uses(raw, expected) -> None:
  75. assert map_energy_flow_fields({"bmsSOC": raw})["batterySOC"] == expected
  76. @pytest.mark.parametrize("raw", [None, True, False, "n/a", "", {}, []])
  77. def test_rejects_unusable_values_rather_than_publishing_garbage(raw) -> None:
  78. assert "batterySOC" not in map_energy_flow_fields({"bmsSOC": raw})
  79. @pytest.mark.parametrize("payload", [None, {}, [1, 2], "text", 0])
  80. def test_hostile_input_returns_empty_mapping(payload) -> None:
  81. assert map_energy_flow_fields(payload) == {}
  82. def test_unknown_firmware_fields_are_ignored() -> None:
  83. assert map_energy_flow_fields({"someBrandNewKey": 5}) == {}
  84. # ─── Realtime probe ────────────────────────────────────────────────────────────
  85. def test_probe_treats_zero_as_a_real_reading() -> None:
  86. """A device reporting 0 W at night is working — must not trigger fallback."""
  87. assert has_realtime_values({"pvInputPower": 0}) is True
  88. @pytest.mark.parametrize(
  89. "values", [{}, {"pvInputPower": None}, {"loadPower": 5}, None, "text"]
  90. )
  91. def test_probe_false_when_no_realtime_key_has_a_value(values) -> None:
  92. assert has_realtime_values(values) is False
  93. # ─── fetch_latest_data integration ─────────────────────────────────────────────
  94. @pytest.fixture
  95. def api_factory():
  96. """Build an API instance with stubbed transport, tracking endpoint calls."""
  97. def _factory(history_fields, flow_fields=None, flow_error=None):
  98. api = SolarOfThingsAPI(iot_token="test-token")
  99. api._ensure_token_valid = lambda: None # no network
  100. calls = {"time_series": 0, "energy_flow": 0}
  101. def fake_post(path, payload, **kwargs):
  102. calls["time_series"] += 1
  103. return {"code": 0, "data": {"payload": {"fields": history_fields}}}
  104. def fake_get(path, params, **kwargs):
  105. calls["energy_flow"] += 1
  106. if flow_error is not None:
  107. raise flow_error
  108. return {
  109. "code": 0,
  110. "data": {"deviceAttributeState": {"fields": flow_fields or {}}},
  111. }
  112. api._post = fake_post
  113. api._get = fake_get
  114. return api, calls
  115. return _factory
  116. def test_working_device_is_unaffected(api_factory) -> None:
  117. """Regression guard: values and call pattern must match pre-fallback code."""
  118. history = {
  119. "pvInputPower": [100],
  120. "acOutputActivePower": [1.2],
  121. "batteryVoltage": [52.0],
  122. "batteryDischargeCurrent": [2.0],
  123. "batteryChargingCurrent": [0.0],
  124. "feedInPower": [0],
  125. "batterySOC": [90],
  126. }
  127. api, calls = api_factory(history, flow_fields={"bmsSOC": 1})
  128. result = api.fetch_latest_data("device-1")
  129. assert result["acOutputActivePower"] == 1200.0 # kW → W
  130. assert result["batteryPower"] == 104.0 # (2.0 - 0.0) * 52.0
  131. assert result["gridPower"] == 1204.0 # max(0, 1200-100+104+0)
  132. assert result["loadPower"] == 1200.0
  133. # The fallback must cost a working install nothing.
  134. assert calls["energy_flow"] == 0
  135. def test_fallback_populates_sensors_when_time_series_is_empty(api_factory) -> None:
  136. api, calls = api_factory({}, flow_fields=ISSUE_7_NIGHT_PAYLOAD)
  137. result = api.fetch_latest_data("device-2")
  138. assert calls["energy_flow"] == 1
  139. assert result["batteryVoltage"] == 26.6
  140. assert result["batterySOC"] == 100.0
  141. assert result["batteryDischargeCurrent"] == 0.4
  142. def test_measured_battery_power_is_not_overwritten_by_the_estimate(api_factory) -> None:
  143. """The flow endpoint reports batteryPower directly; keep it.
  144. The derived estimate would give (0.4 - 0) * 26.6 = 10.64 W here.
  145. """
  146. api, _ = api_factory({}, flow_fields=ISSUE_7_NIGHT_PAYLOAD)
  147. assert api.fetch_latest_data("device-3")["batteryPower"] == 9.0
  148. def test_time_series_values_win_over_the_fallback(api_factory) -> None:
  149. api, calls = api_factory({"batterySOC": [42]}, flow_fields={"bmsSOC": 99})
  150. result = api.fetch_latest_data("device-4")
  151. assert result["batterySOC"] == 42
  152. assert calls["energy_flow"] == 0
  153. def test_flow_endpoint_failure_degrades_quietly(api_factory) -> None:
  154. """An unsupported endpoint must not fail the whole coordinator update."""
  155. api, _ = api_factory({}, flow_error=RuntimeError("404 not supported"))
  156. result = api.fetch_latest_data("device-5")
  157. assert isinstance(result, dict)
  158. assert result["loadPower"] == 0.0
  159. def test_token_expiry_propagates_for_reauth(api_factory) -> None:
  160. """TokenExpiredError must reach the coordinator so re-auth can start."""
  161. api, _ = api_factory({}, flow_error=TokenExpiredError("expired"))
  162. with pytest.raises(TokenExpiredError):
  163. api.fetch_latest_data("device-6")
  164. def test_unknown_firmware_logs_field_names_for_reporting(api_factory, caplog) -> None:
  165. api, _ = api_factory({}, flow_fields={"someBrandNewKey": 5, "another": 7})
  166. api.fetch_latest_data("device-7")
  167. assert "another" in caplog.text and "someBrandNewKey" in caplog.text