test_mqtt_relay_events.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. """MQTT relay events reach the broker.
  2. Five relay methods were written and never called, one of them for a topic the
  3. wiki documents (``smart_plugs/energy``). The first test makes that impossible to
  4. repeat: every public ``on_*`` method of the relay has a caller. The rest cover
  5. the energy publisher; the printer online/offline and filament-low events are
  6. tested where they are produced (test_printer_offline_notification.py,
  7. test_filament_low_producer_2913.py).
  8. """
  9. import ast
  10. import re
  11. from pathlib import Path
  12. from unittest.mock import AsyncMock, patch
  13. import pytest
  14. from backend.app.models.smart_plug import SmartPlug
  15. from backend.app.services.mqtt_relay import MQTTRelayService
  16. from backend.app.services.smart_plug_manager import SmartPlugManager
  17. APP = Path(__file__).resolve().parents[2] / "app"
  18. def test_every_relay_event_has_a_caller():
  19. relay_source = (APP / "services" / "mqtt_relay.py").read_text(encoding="utf-8")
  20. service = next(
  21. node
  22. for node in ast.parse(relay_source).body
  23. if isinstance(node, ast.ClassDef) and node.name == MQTTRelayService.__name__
  24. )
  25. events = [
  26. node.name
  27. for node in service.body
  28. if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith("on_")
  29. ]
  30. callers = "\n".join(path.read_text(encoding="utf-8") for path in APP.rglob("*.py") if path.name != "mqtt_relay.py")
  31. uncalled = [name for name in events if not re.search(rf"\.{name}\(", callers)]
  32. assert events
  33. assert uncalled == []
  34. async def _plug(db_session, **kwargs) -> SmartPlug:
  35. fields = {"name": "Printer plug", "ip_address": "192.168.1.50"}
  36. fields.update(kwargs)
  37. plug = SmartPlug(**fields)
  38. db_session.add(plug)
  39. await db_session.commit()
  40. return plug
  41. @pytest.fixture
  42. def relay():
  43. with patch("backend.app.services.smart_plug_manager.mqtt_relay") as relay:
  44. relay.enabled = True
  45. relay.connected = True
  46. relay.on_smart_plug_energy = AsyncMock()
  47. yield relay
  48. @pytest.mark.asyncio
  49. async def test_each_enabled_plug_publishes_its_energy(db_session, relay):
  50. plug = await _plug(db_session)
  51. await _plug(db_session, name="Off", enabled=False)
  52. read = AsyncMock(return_value={"power": 120.5, "today": 1.25, "total": 310.0})
  53. with patch("backend.app.services.smart_plug_manager.get_plug_energy", read):
  54. await SmartPlugManager()._publish_energy_to_relay(db_session)
  55. relay.on_smart_plug_energy.assert_awaited_once_with(plug.id, "Printer plug", 120.5, 1.25, 310.0)
  56. @pytest.mark.asyncio
  57. async def test_a_plug_that_cannot_be_read_publishes_nothing(db_session, relay):
  58. await _plug(db_session)
  59. with patch("backend.app.services.smart_plug_manager.get_plug_energy", AsyncMock(return_value=None)):
  60. await SmartPlugManager()._publish_energy_to_relay(db_session)
  61. relay.on_smart_plug_energy.assert_not_awaited()
  62. @pytest.mark.asyncio
  63. async def test_one_failing_plug_does_not_stop_the_others(db_session, relay):
  64. await _plug(db_session, name="Broken")
  65. good = await _plug(db_session, name="Good")
  66. async def read(plug, _db):
  67. if plug.name == "Broken":
  68. raise OSError("unreachable")
  69. return {"power": 5.0, "today": 0.1, "total": 2.0}
  70. with patch("backend.app.services.smart_plug_manager.get_plug_energy", side_effect=read):
  71. await SmartPlugManager()._publish_energy_to_relay(db_session)
  72. relay.on_smart_plug_energy.assert_awaited_once_with(good.id, "Good", 5.0, 0.1, 2.0)
  73. @pytest.mark.parametrize(("enabled", "connected"), [(False, False), (True, False)])
  74. @pytest.mark.asyncio
  75. async def test_no_plug_is_read_while_the_relay_is_off(db_session, relay, enabled, connected):
  76. """The loop runs on every install; only relay users pay for the reads."""
  77. await _plug(db_session)
  78. relay.enabled = enabled
  79. relay.connected = connected
  80. read = AsyncMock(return_value={"power": 1.0, "today": 0.0, "total": 0.0})
  81. with patch("backend.app.services.smart_plug_manager.get_plug_energy", read):
  82. await SmartPlugManager()._publish_energy_to_relay(db_session)
  83. read.assert_not_awaited()
  84. relay.on_smart_plug_energy.assert_not_awaited()
  85. @pytest.mark.asyncio
  86. async def test_an_mqtt_plug_that_stopped_reporting_publishes_nothing(db_session, relay):
  87. """An MQTT plug's values are the last message received; once the plug goes
  88. quiet they are stale, and publishing them every minute would report a dead
  89. plug as drawing power."""
  90. await _plug(db_session, name="Quiet", plug_type="mqtt", ip_address=None)
  91. live = await _plug(db_session, name="Live", plug_type="mqtt", ip_address=None)
  92. relay.smart_plug_service.is_reachable.side_effect = lambda plug_id: plug_id == live.id
  93. read = AsyncMock(return_value={"power": 50.0, "today": 0.5, "total": 0.5})
  94. with patch("backend.app.services.smart_plug_manager.get_plug_energy", read):
  95. await SmartPlugManager()._publish_energy_to_relay(db_session)
  96. relay.on_smart_plug_energy.assert_awaited_once_with(live.id, "Live", 50.0, 0.5, 0.5)
  97. # -- smart_plugs/on and /off: every switch, not only the manual one -------------
  98. # The plug backends themselves (a REST toggle is a turn_on or turn_off).
  99. PLUG_BACKENDS = {"tasmota.py", "homeassistant.py", "rest_smart_plug.py", "mqtt_smart_plug.py"}
  100. # Runs a Home Assistant script through its entity's turn_on: no plug changes state.
  101. NOT_A_SWITCH = {("services/smart_plug_manager.py", "run_linked_scripts")}
  102. def _called_names(node: ast.AST) -> set[str]:
  103. names = set()
  104. for call in ast.walk(node):
  105. if isinstance(call, ast.Call):
  106. if isinstance(call.func, ast.Attribute):
  107. names.add(call.func.attr)
  108. elif isinstance(call.func, ast.Name):
  109. names.add(call.func.id)
  110. return names
  111. def test_every_function_that_switches_a_plug_tells_the_relay():
  112. """smart_plugs/on and /off used to come only from the manual switch; auto-on,
  113. auto-off, schedules, the queue and failure detection switched plugs silently."""
  114. switching, missing = [], []
  115. for path in APP.rglob("*.py"):
  116. if path.name in PLUG_BACKENDS:
  117. continue
  118. rel = path.relative_to(APP).as_posix()
  119. for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
  120. if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) or (rel, node.name) in NOT_A_SWITCH:
  121. continue
  122. names = _called_names(node)
  123. if names & {"turn_on", "turn_off", "toggle"}:
  124. switching.append(f"{rel}:{node.name}")
  125. if "publish_plug_state" not in names:
  126. missing.append(f"{rel}:{node.name}")
  127. assert switching
  128. assert missing == []
  129. @pytest.fixture
  130. def session_for(db_session):
  131. """database.async_session() handing out the test session."""
  132. class _Session:
  133. async def __aenter__(self):
  134. return db_session
  135. async def __aexit__(self, *exc):
  136. return False
  137. with patch("backend.app.core.database.async_session", lambda: _Session()):
  138. yield
  139. @pytest.mark.asyncio
  140. async def test_publish_plug_state_names_the_printer_from_the_database(db_session, session_for):
  141. """Not from the connection cache: that keeps a renamed printer's old name
  142. and has none for an inactive printer."""
  143. from backend.app.models.printer import Printer
  144. from backend.app.services.smart_plug_manager import publish_plug_state
  145. printer = Printer(name="Workshop", serial_number="SN1", ip_address="192.0.2.1", access_code="x", is_active=False)
  146. db_session.add(printer)
  147. await db_session.commit()
  148. with patch("backend.app.services.smart_plug_manager.mqtt_relay") as relay:
  149. relay.on_smart_plug_state = AsyncMock()
  150. await publish_plug_state(3, "X1C plug", printer.id, on=False)
  151. await publish_plug_state(4, "Lamp", None, on=True)
  152. assert relay.on_smart_plug_state.await_args_list[0].kwargs == {
  153. "plug_id": 3,
  154. "plug_name": "X1C plug",
  155. "state": "off",
  156. "printer_id": printer.id,
  157. "printer_name": "Workshop",
  158. }
  159. assert relay.on_smart_plug_state.await_args_list[1].kwargs["printer_name"] is None
  160. assert relay.on_smart_plug_state.await_args_list[1].kwargs["state"] == "on"
  161. @pytest.mark.asyncio
  162. async def test_a_home_assistant_script_is_not_a_plug_switching(session_for):
  163. """Running a script through its entity changes no power state."""
  164. from backend.app.services.smart_plug_manager import publish_plug_state
  165. with patch("backend.app.services.smart_plug_manager.mqtt_relay") as relay:
  166. relay.on_smart_plug_state = AsyncMock()
  167. await publish_plug_state(5, "Lights script", None, on=True, ha_entity_id="script.printer_lights")
  168. await publish_plug_state(6, "Switch", None, on=True, ha_entity_id="switch.printer")
  169. relay.on_smart_plug_state.assert_awaited_once()
  170. assert relay.on_smart_plug_state.await_args.kwargs["plug_id"] == 6
  171. @pytest.mark.asyncio
  172. async def test_publish_plug_state_never_raises():
  173. """Switching the plug worked; a broker problem must not turn that into an error."""
  174. from backend.app.services.smart_plug_manager import publish_plug_state
  175. with patch("backend.app.services.smart_plug_manager.mqtt_relay") as relay:
  176. relay.on_smart_plug_state = AsyncMock(side_effect=RuntimeError("broker down"))
  177. await publish_plug_state(3, "X1C plug", None, on=True)
  178. @pytest.mark.asyncio
  179. async def test_the_delayed_auto_off_publishes_with_the_plug_name():
  180. manager = SmartPlugManager()
  181. service = AsyncMock()
  182. service.turn_off = AsyncMock(return_value=True)
  183. with (
  184. patch("backend.app.services.smart_plug_manager.printer_manager") as pm,
  185. patch("backend.app.services.smart_plug_manager.publish_plug_state", new_callable=AsyncMock) as publish,
  186. patch.object(manager, "get_service_for_plug", AsyncMock(return_value=service)),
  187. patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock),
  188. ):
  189. pm.is_print_active.return_value = False
  190. await manager._delayed_off(
  191. 3, "tasmota", "1.2.3.4", None, None, None, printer_id=1, delay_seconds=0, plug_name="X1C plug"
  192. )
  193. publish.assert_awaited_once_with(3, "X1C plug", 1, on=False, ha_entity_id=None)
  194. # -- maintenance/alert carries the real values -----------------------------------
  195. def test_a_print_hours_item_reports_hours_since_and_the_interval():
  196. from types import SimpleNamespace
  197. from backend.app.api.routes.maintenance import maintenance_alert_values
  198. item = SimpleNamespace(
  199. interval_type="hours", interval_hours=200.0, hours_since_maintenance=212.5, days_since_maintenance=None
  200. )
  201. assert maintenance_alert_values(item) == (212.5, 200.0, "hours")
  202. def test_a_time_based_item_reports_days_since_and_the_interval():
  203. from types import SimpleNamespace
  204. from backend.app.api.routes.maintenance import maintenance_alert_values
  205. item = SimpleNamespace(
  206. interval_type="days", interval_hours=30.0, hours_since_maintenance=40.0, days_since_maintenance=31.2
  207. )
  208. assert maintenance_alert_values(item) == (31.2, 30.0, "days")