test_power_alerts.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. """Power alerts: checked in the background, sent once per crossing.
  2. Pre-fix the check called a ``notification_service.send_notification`` that does
  3. not exist, so no power alert was ever sent and the plug status request failed
  4. with 500 once a threshold was crossed. It also only ran from that status
  5. request, i.e. while someone had the printer page open, and it fired every five
  6. minutes for as long as the power stayed outside the range.
  7. """
  8. from datetime import timedelta
  9. from unittest.mock import AsyncMock, patch
  10. import pytest
  11. from sqlalchemy import select
  12. from backend.app.models.smart_plug import SmartPlug
  13. from backend.app.services.power_alerts import PowerAlertMonitor, check_all_plugs
  14. from backend.app.utils.local_time import utcnow_naive
  15. async def _plug(db_session, **kwargs) -> SmartPlug:
  16. fields = {
  17. "name": "Printer plug",
  18. "ip_address": "192.168.1.50",
  19. "power_alert_enabled": True,
  20. "power_alert_high": 200.0,
  21. "power_alert_low": None,
  22. }
  23. fields.update(kwargs)
  24. plug = SmartPlug(**fields)
  25. db_session.add(plug)
  26. await db_session.commit()
  27. return plug
  28. @pytest.fixture
  29. def sent():
  30. with patch(
  31. "backend.app.services.power_alerts.notification_service.on_printer_error", new_callable=AsyncMock
  32. ) as on_printer_error:
  33. yield on_printer_error
  34. class TestCrossings:
  35. @pytest.mark.asyncio
  36. async def test_alerts_once_when_power_rises_above_the_high_value(self, db_session, sent):
  37. plug = await _plug(db_session)
  38. monitor = PowerAlertMonitor()
  39. for watts in (150.0, 250.0, 260.0, 255.0):
  40. await monitor.evaluate(plug, "ON", watts, db_session)
  41. sent.assert_awaited_once()
  42. kwargs = sent.await_args.kwargs
  43. assert kwargs["printer_name"] == "Printer plug"
  44. assert kwargs["error_type"] == "Power High"
  45. assert "250.0W" in kwargs["error_detail"] and "200.0W" in kwargs["error_detail"]
  46. assert plug.power_alert_last_triggered is not None
  47. @pytest.mark.asyncio
  48. async def test_alerts_again_after_the_power_was_back_in_range(self, db_session, sent):
  49. plug = await _plug(db_session)
  50. monitor = PowerAlertMonitor()
  51. await monitor.evaluate(plug, "ON", 250.0, db_session)
  52. await monitor.evaluate(plug, "ON", 150.0, db_session)
  53. plug.power_alert_last_triggered = utcnow_naive() - timedelta(minutes=10)
  54. await monitor.evaluate(plug, "ON", 250.0, db_session)
  55. assert sent.await_count == 2
  56. @pytest.mark.asyncio
  57. async def test_alerts_when_power_falls_below_the_low_value(self, db_session, sent):
  58. plug = await _plug(db_session, power_alert_high=None, power_alert_low=20.0)
  59. monitor = PowerAlertMonitor()
  60. await monitor.evaluate(plug, "ON", 80.0, db_session)
  61. await monitor.evaluate(plug, "ON", 5.0, db_session)
  62. sent.assert_awaited_once()
  63. assert sent.await_args.kwargs["error_type"] == "Power Low"
  64. @pytest.mark.asyncio
  65. async def test_a_switched_off_plug_raises_no_low_alert(self, db_session, sent):
  66. plug = await _plug(db_session, power_alert_high=None, power_alert_low=20.0)
  67. monitor = PowerAlertMonitor()
  68. await monitor.evaluate(plug, "ON", 80.0, db_session)
  69. await monitor.evaluate(plug, "OFF", 0.0, db_session)
  70. sent.assert_not_awaited()
  71. @pytest.mark.asyncio
  72. async def test_a_crossing_inside_the_cooldown_is_sent_once_it_ends(self, db_session, sent):
  73. plug = await _plug(db_session, power_alert_last_triggered=utcnow_naive())
  74. monitor = PowerAlertMonitor()
  75. await monitor.evaluate(plug, "ON", 250.0, db_session)
  76. sent.assert_not_awaited()
  77. plug.power_alert_last_triggered = utcnow_naive() - timedelta(minutes=10)
  78. await monitor.evaluate(plug, "ON", 250.0, db_session)
  79. sent.assert_awaited_once()
  80. @pytest.mark.asyncio
  81. async def test_disabled_alerts_send_nothing(self, db_session, sent):
  82. plug = await _plug(db_session, power_alert_enabled=False)
  83. await PowerAlertMonitor().evaluate(plug, "ON", 999.0, db_session)
  84. sent.assert_not_awaited()
  85. @pytest.mark.asyncio
  86. async def test_the_linked_printer_is_passed_for_the_provider_filter(self, db_session, sent, printer_factory):
  87. printer = await printer_factory()
  88. plug = await _plug(db_session, printer_id=printer.id)
  89. await PowerAlertMonitor().evaluate(plug, "ON", 250.0, db_session)
  90. assert sent.await_args.kwargs["printer_id"] == printer.id
  91. class TestBackgroundPass:
  92. @pytest.mark.asyncio
  93. async def test_reads_only_plugs_with_alerts_on_and_survives_an_unreachable_one(self, db_session, sent):
  94. down = await _plug(db_session, name="Down")
  95. up = await _plug(db_session, name="Up", ip_address="192.168.1.51")
  96. await _plug(db_session, name="Off", ip_address="192.168.1.52", power_alert_enabled=False)
  97. down_id, up_id = down.id, up.id
  98. read_names = []
  99. async def read(plug, db):
  100. read_names.append(plug.name)
  101. if plug.id == down_id:
  102. raise TimeoutError("unreachable")
  103. return "ON", 250.0
  104. with patch("backend.app.services.power_alerts.read_plug", side_effect=read):
  105. await check_all_plugs(db_session, PowerAlertMonitor())
  106. assert sorted(read_names) == ["Down", "Up"]
  107. sent.assert_awaited_once()
  108. assert sent.await_args.kwargs["printer_name"] == "Up"
  109. refreshed = (await db_session.execute(select(SmartPlug).where(SmartPlug.id == up_id))).scalar_one()
  110. assert refreshed.power_alert_last_triggered is not None
  111. class TestStatusRouteNoLongerChecks:
  112. @pytest.mark.asyncio
  113. async def test_status_answers_with_a_plug_above_its_threshold(self, async_client, db_session, sent):
  114. plug = await _plug(db_session)
  115. with (
  116. patch(
  117. "backend.app.api.routes.smart_plugs.tasmota_service.get_status",
  118. AsyncMock(return_value={"state": "ON", "reachable": True, "device_name": None}),
  119. ),
  120. patch(
  121. "backend.app.api.routes.smart_plugs.tasmota_service.get_energy",
  122. AsyncMock(return_value={"power": 250.0, "today": 1.0, "total": 5.0}),
  123. ),
  124. ):
  125. response = await async_client.get(f"/api/v1/smart-plugs/{plug.id}/status")
  126. assert response.status_code == 200, response.text
  127. assert response.json()["energy"]["power"] == 250.0
  128. sent.assert_not_awaited()
  129. class TestReadPlug:
  130. """State and power for every plug type; an unreachable plug reads as nothing."""
  131. @pytest.mark.asyncio
  132. @pytest.mark.parametrize("plug_type", ["tasmota", "rest", "homeassistant"])
  133. async def test_polled_plugs(self, db_session, plug_type):
  134. from backend.app.services import plug_energy
  135. plug = await _plug(db_session, plug_type=plug_type)
  136. service = {
  137. "tasmota": plug_energy.tasmota_service,
  138. "rest": plug_energy.rest_smart_plug_service,
  139. "homeassistant": plug_energy.homeassistant_service,
  140. }[plug_type]
  141. with (
  142. patch.object(service, "get_status", AsyncMock(return_value={"state": "ON", "reachable": True})),
  143. patch.object(service, "get_energy", AsyncMock(return_value={"power": 42.5})),
  144. patch(
  145. "backend.app.api.routes.settings.get_homeassistant_settings",
  146. AsyncMock(return_value={"ha_url": "http://ha", "ha_token": "t"}),
  147. ),
  148. ):
  149. assert await plug_energy.read_plug(plug, db_session) == ("ON", 42.5)
  150. with (
  151. patch.object(service, "get_status", AsyncMock(return_value={"state": None, "reachable": False})),
  152. patch(
  153. "backend.app.api.routes.settings.get_homeassistant_settings",
  154. AsyncMock(return_value={"ha_url": "http://ha", "ha_token": "t"}),
  155. ),
  156. ):
  157. assert await plug_energy.read_plug(plug, db_session) == (None, None)
  158. @pytest.mark.asyncio
  159. async def test_mqtt_plug_answers_from_its_last_message(self, db_session):
  160. from types import SimpleNamespace
  161. from backend.app.services import plug_energy
  162. plug = await _plug(db_session, plug_type="mqtt")
  163. mqtt = plug_energy.mqtt_relay.smart_plug_service
  164. with (
  165. patch.object(mqtt, "get_plug_data", return_value=SimpleNamespace(state="OFF", power=0.0, energy=1.0)),
  166. patch.object(mqtt, "is_reachable", return_value=True),
  167. ):
  168. assert await plug_energy.read_plug(plug, db_session) == ("OFF", 0.0)
  169. with patch.object(mqtt, "get_plug_data", return_value=None):
  170. assert await plug_energy.read_plug(plug, db_session) == (None, None)
  171. class TestReviewRound1:
  172. """Alerts must not be lost on a failed write, one plug's error must not stop
  173. the others, stale state must not swallow a crossing, a plug that can't say
  174. whether it is on raises no low alert, and an unreachable plug is not polled
  175. every minute."""
  176. @pytest.mark.asyncio
  177. async def test_a_failed_write_does_not_lose_the_alert(self, db_session, sent):
  178. from sqlalchemy.exc import OperationalError
  179. plug = await _plug(db_session)
  180. monitor = PowerAlertMonitor()
  181. real_commit = db_session.commit
  182. failing = AsyncMock(side_effect=OperationalError("UPDATE", {}, Exception("database is locked")))
  183. with (
  184. patch("backend.app.services.power_alerts.read_plug", AsyncMock(return_value=("ON", 250.0))),
  185. patch.object(db_session, "commit", failing),
  186. ):
  187. await check_all_plugs(db_session, monitor)
  188. sent.assert_not_awaited()
  189. db_session.commit = real_commit
  190. with patch("backend.app.services.power_alerts.read_plug", AsyncMock(return_value=("ON", 250.0))):
  191. await check_all_plugs(db_session, monitor)
  192. sent.assert_awaited_once()
  193. assert sent.await_args.kwargs["printer_name"] == plug.name
  194. @pytest.mark.asyncio
  195. async def test_a_failed_send_is_retried_after_the_cooldown(self, db_session, sent):
  196. plug = await _plug(db_session)
  197. plug_id = plug.id
  198. monitor = PowerAlertMonitor()
  199. sent.side_effect = [RuntimeError("provider down"), None]
  200. with patch("backend.app.services.power_alerts.read_plug", AsyncMock(return_value=("ON", 250.0))):
  201. await check_all_plugs(db_session, monitor)
  202. row = (await db_session.execute(select(SmartPlug).where(SmartPlug.id == plug_id))).scalar_one()
  203. row.power_alert_last_triggered = utcnow_naive() - timedelta(minutes=10)
  204. await db_session.commit()
  205. await check_all_plugs(db_session, monitor)
  206. assert sent.await_count == 2
  207. @pytest.mark.asyncio
  208. async def test_one_plugs_db_error_does_not_stop_the_others(self, test_engine, db_session, sent):
  209. """A real failed UPDATE leaves the session needing a rollback, as
  210. "database is locked" does on SQLite."""
  211. from sqlalchemy import event
  212. from sqlalchemy.exc import OperationalError
  213. await _plug(db_session, name="First")
  214. await _plug(db_session, name="Second", ip_address="192.168.1.51")
  215. failed = {"done": False}
  216. def fail_first_update(conn, cursor, statement, parameters, context, executemany):
  217. if statement.lstrip().upper().startswith("UPDATE SMART_PLUGS") and not failed["done"]:
  218. failed["done"] = True
  219. raise OperationalError(statement, parameters, Exception("database is locked"))
  220. event.listen(test_engine.sync_engine, "before_cursor_execute", fail_first_update)
  221. try:
  222. with patch("backend.app.services.power_alerts.read_plug", AsyncMock(return_value=("ON", 250.0))):
  223. await check_all_plugs(db_session, PowerAlertMonitor())
  224. finally:
  225. event.remove(test_engine.sync_engine, "before_cursor_execute", fail_first_update)
  226. assert failed["done"]
  227. assert [c.kwargs["printer_name"] for c in sent.await_args_list] == ["Second"]
  228. @pytest.mark.asyncio
  229. async def test_switching_alerts_off_and_on_forgets_the_old_crossing(self, db_session, sent):
  230. plug = await _plug(db_session, power_alert_high=None, power_alert_low=10.0)
  231. plug_id = plug.id
  232. monitor = PowerAlertMonitor()
  233. reader = AsyncMock(return_value=("ON", 3.0))
  234. async def set_enabled(enabled: bool):
  235. row = (await db_session.execute(select(SmartPlug).where(SmartPlug.id == plug_id))).scalar_one()
  236. row.power_alert_enabled = enabled
  237. row.power_alert_last_triggered = utcnow_naive() - timedelta(minutes=10)
  238. await db_session.commit()
  239. with patch("backend.app.services.power_alerts.read_plug", reader):
  240. await check_all_plugs(db_session, monitor)
  241. await set_enabled(False)
  242. await check_all_plugs(db_session, monitor)
  243. await set_enabled(True)
  244. await check_all_plugs(db_session, monitor)
  245. assert sent.await_count == 2
  246. @pytest.mark.asyncio
  247. async def test_unknown_switch_state_raises_no_low_alert_but_still_high(self, db_session, sent):
  248. plug = await _plug(db_session, power_alert_high=200.0, power_alert_low=10.0)
  249. monitor = PowerAlertMonitor()
  250. await monitor.evaluate(plug, None, 0.0, db_session)
  251. sent.assert_not_awaited()
  252. await monitor.evaluate(plug, None, 250.0, db_session)
  253. sent.assert_awaited_once()
  254. assert sent.await_args.kwargs["error_type"] == "Power High"
  255. @pytest.mark.asyncio
  256. async def test_an_unreachable_plug_is_polled_again_only_after_a_pause(self, db_session, sent):
  257. await _plug(db_session)
  258. monitor = PowerAlertMonitor()
  259. reader = AsyncMock(return_value=(None, None))
  260. clock = {"now": 1000.0}
  261. with (
  262. patch("backend.app.services.power_alerts.read_plug", reader),
  263. patch("backend.app.services.power_alerts.time.monotonic", side_effect=lambda: clock["now"]),
  264. ):
  265. await check_all_plugs(db_session, monitor)
  266. clock["now"] += 60
  267. await check_all_plugs(db_session, monitor)
  268. assert reader.await_count == 1
  269. clock["now"] += 600
  270. await check_all_plugs(db_session, monitor)
  271. assert reader.await_count == 2
  272. class TestReviewRound2:
  273. def test_a_fresh_mqtt_subscription_can_be_asked_if_reachable(self):
  274. """Its default last_seen had no time zone, so the check raised TypeError
  275. until the first message arrived."""
  276. from backend.app.services.mqtt_smart_plug import MQTTSmartPlugService, SmartPlugMQTTData
  277. service = MQTTSmartPlugService()
  278. service.plug_data[7] = SmartPlugMQTTData(plug_id=7)
  279. assert service.is_reachable(7) is True
  280. @pytest.mark.asyncio
  281. async def test_an_mqtt_plug_without_data_is_not_paused(self, db_session, sent):
  282. """MQTT is read from memory, so there is no network call to spare; a plug
  283. that hasn't published yet must be checked again on the next pass."""
  284. await _plug(db_session, plug_type="mqtt")
  285. monitor = PowerAlertMonitor()
  286. reader = AsyncMock(side_effect=[(None, None), ("ON", 250.0)])
  287. with patch("backend.app.services.power_alerts.read_plug", reader):
  288. await check_all_plugs(db_session, monitor)
  289. await check_all_plugs(db_session, monitor)
  290. assert reader.await_count == 2
  291. sent.assert_awaited_once()