test_ha_scripts_on_automatic_switch.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. """HA "Run when printer turns on/off" scripts follow automatic switching too.
  2. They ran only when someone switched the printer's plug by hand. Auto-off
  3. (delayed, by temperature, resumed after a restart), the plug schedule and AI
  4. failure detection switched the printer without them. Print start already runs
  5. the "on" scripts itself, as linked plugs with auto-on, so it is not routed
  6. through here; nor is the queue's power-on, which print start follows.
  7. """
  8. from types import SimpleNamespace
  9. from unittest.mock import AsyncMock, MagicMock, patch
  10. import pytest
  11. from backend.app.services import smart_plug_manager as spm
  12. from backend.app.services.smart_plug_manager import SmartPlugManager
  13. @pytest.fixture(autouse=True)
  14. def _fresh_run_memory():
  15. spm._recent_automatic_script_runs.clear()
  16. yield
  17. spm._recent_automatic_script_runs.clear()
  18. def _plug(**fields):
  19. base = {
  20. "id": 1,
  21. "name": "Plug",
  22. "plug_type": "tasmota",
  23. "ha_entity_id": None,
  24. "printer_id": 7,
  25. "controls_printer_power": True,
  26. "auto_on": False,
  27. "auto_off": False,
  28. "enabled": True,
  29. }
  30. base.update(fields)
  31. return SimpleNamespace(**base)
  32. def _script(name, **fields):
  33. return _plug(name=name, plug_type="homeassistant", ha_entity_id=f"script.{name}", **fields)
  34. class TestWhichScriptsRun:
  35. @pytest.mark.asyncio
  36. @pytest.mark.parametrize(("state", "ran"), [("ON", ["lights_on"]), ("OFF", ["fan_off"])])
  37. async def test_by_the_direction_of_the_switch(self, state, ran):
  38. plugs = [_plug(), _script("lights_on", auto_on=True), _script("fan_off", auto_off=True)]
  39. db = MagicMock()
  40. db.execute = AsyncMock(return_value=MagicMock(scalars=lambda: MagicMock(all=lambda: plugs)))
  41. service = MagicMock(turn_on=AsyncMock(return_value=True))
  42. with patch.object(spm.smart_plug_manager, "get_service_for_plug", AsyncMock(return_value=service)):
  43. await spm.run_linked_scripts(7, state, db)
  44. assert [call.args[0].name for call in service.turn_on.await_args_list] == ran
  45. class TestOnlyThePrintersPowerPlug:
  46. @pytest.mark.asyncio
  47. @pytest.mark.parametrize(
  48. ("plug", "printer_id", "runs"),
  49. [
  50. (_plug(), 7, True),
  51. (_plug(controls_printer_power=False), 7, False), # an accessory, e.g. a lamp
  52. (_script("x"), 7, False),
  53. (_plug(), None, False),
  54. ],
  55. )
  56. async def test_after_an_automatic_switch(self, plug, printer_id, runs):
  57. session = MagicMock()
  58. session.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
  59. session.return_value.__aexit__ = AsyncMock(return_value=False)
  60. run = AsyncMock()
  61. with (
  62. patch("backend.app.core.database.async_session", session),
  63. patch.object(spm, "run_linked_scripts", run),
  64. ):
  65. await spm.run_linked_scripts_after_automatic_switch(printer_id, plug, "OFF")
  66. assert run.await_count == (1 if runs else 0)
  67. @pytest.fixture
  68. def scripts():
  69. run = AsyncMock()
  70. with patch.object(spm, "run_linked_scripts_after_automatic_switch", run):
  71. yield run
  72. def _service():
  73. return MagicMock(turn_on=AsyncMock(return_value=True), turn_off=AsyncMock(return_value=True))
  74. class TestEveryAutomaticOff:
  75. @pytest.mark.asyncio
  76. @pytest.mark.parametrize("feeds_printer", [True, False])
  77. async def test_the_delayed_auto_off(self, scripts, feeds_printer):
  78. manager = SmartPlugManager()
  79. with (
  80. patch.object(spm, "printer_manager") as pm,
  81. patch.object(spm, "publish_plug_state", AsyncMock()),
  82. patch.object(manager, "get_service_for_plug", AsyncMock(return_value=_service())),
  83. patch.object(manager, "_mark_auto_off_executed", AsyncMock()),
  84. ):
  85. pm.is_print_active.return_value = False
  86. await manager._delayed_off(
  87. 3, "tasmota", "1.2.3.4", None, None, None, 7, 0, controls_printer_power=feeds_printer
  88. )
  89. printer_id, plug, state = scripts.await_args.args
  90. assert (printer_id, state, plug.controls_printer_power) == (7, "OFF", feeds_printer)
  91. @pytest.mark.asyncio
  92. async def test_the_temperature_auto_off(self, scripts):
  93. manager = SmartPlugManager()
  94. with (
  95. patch.object(spm, "printer_manager") as pm,
  96. patch.object(spm, "publish_plug_state", AsyncMock()),
  97. patch.object(spm.asyncio, "sleep", AsyncMock()),
  98. patch.object(manager, "get_service_for_plug", AsyncMock(return_value=_service())),
  99. patch.object(manager, "_mark_auto_off_executed", AsyncMock()),
  100. ):
  101. pm.get_status.return_value = MagicMock(state="FINISH", temperatures={"nozzle": 30})
  102. pm.is_print_active.return_value = False
  103. await manager._temp_based_off(3, "tasmota", "1.2.3.4", None, None, None, printer_id=7, temp_threshold=55)
  104. assert scripts.await_args.args[0] == 7 and scripts.await_args.args[2] == "OFF"
  105. @pytest.mark.asyncio
  106. async def test_a_resumed_auto_off(self, scripts):
  107. manager = SmartPlugManager()
  108. plug = MagicMock(
  109. id=1, printer_id=7, off_delay_mode="time", controls_printer_power=True, auto_off_pending_since=None
  110. )
  111. db = AsyncMock()
  112. db.execute = AsyncMock(return_value=MagicMock(scalars=lambda: MagicMock(all=lambda: [plug])))
  113. session = MagicMock()
  114. session.return_value.__aenter__ = AsyncMock(return_value=db)
  115. session.return_value.__aexit__ = AsyncMock(return_value=False)
  116. with (
  117. patch("backend.app.core.database.async_session", session),
  118. patch.object(spm, "printer_manager") as pm,
  119. patch.object(spm, "publish_plug_state", AsyncMock()),
  120. patch.object(manager, "get_service_for_plug", AsyncMock(return_value=_service())),
  121. patch.object(manager, "_mark_auto_off_executed", AsyncMock()),
  122. ):
  123. pm.is_print_active.return_value = False
  124. await manager.resume_pending_auto_offs()
  125. scripts.assert_awaited_once_with(7, plug, "OFF")
  126. class TestTheSchedule:
  127. @pytest.mark.asyncio
  128. @pytest.mark.parametrize(("now", "state"), [("08:00", "ON"), ("22:00", "OFF")])
  129. async def test_a_scheduled_switch(self, scripts, now, state):
  130. manager = SmartPlugManager()
  131. plug = MagicMock(
  132. id=1, printer_id=7, schedule_on_time="08:00", schedule_off_time="22:00", controls_printer_power=True
  133. )
  134. db = AsyncMock()
  135. db.execute = AsyncMock(return_value=MagicMock(scalars=lambda: MagicMock(all=lambda: [plug])))
  136. session = MagicMock()
  137. session.return_value.__aenter__ = AsyncMock(return_value=db)
  138. session.return_value.__aexit__ = AsyncMock(return_value=False)
  139. with (
  140. patch("backend.app.core.database.async_session", session),
  141. patch.object(spm, "datetime") as clock,
  142. patch.object(spm, "printer_manager"),
  143. patch.object(spm, "publish_plug_state", AsyncMock()),
  144. patch.object(manager, "get_service_for_plug", AsyncMock(return_value=_service())),
  145. ):
  146. clock.now.return_value = MagicMock(strftime=MagicMock(return_value=now))
  147. await manager._check_schedules()
  148. scripts.assert_awaited_once_with(7, plug, state)
  149. class TestFailureDetection:
  150. @pytest.mark.asyncio
  151. async def test_scripts_run_once_and_are_not_turned_off(self, scripts):
  152. """Two plugs feed the printer; the linked script is run, not switched."""
  153. from backend.app.services import obico_actions
  154. mains = [_plug(id=1), _plug(id=2)]
  155. script = _script("fan_off", auto_off=True, id=3)
  156. service = _service()
  157. session = MagicMock()
  158. session.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
  159. session.return_value.__aexit__ = AsyncMock(return_value=False)
  160. with (
  161. patch.object(obico_actions, "async_session", session),
  162. patch.object(spm.smart_plug_manager, "_get_plugs_for_printer", AsyncMock(return_value=[*mains, script])),
  163. patch.object(spm.smart_plug_manager, "get_service_for_plug", AsyncMock(return_value=service)),
  164. patch.object(spm, "publish_plug_state", AsyncMock()),
  165. ):
  166. await obico_actions._turn_off_linked_plugs(7)
  167. assert [call.args[0].id for call in service.turn_off.await_args_list] == [1, 2]
  168. assert scripts.await_count == 1
  169. assert scripts.await_args.args[0] == 7 and scripts.await_args.args[2] == "OFF"
  170. class TestReviewCases:
  171. @pytest.mark.asyncio
  172. async def test_a_script_whose_automation_is_off_does_not_run(self):
  173. plugs = [_script("paused", auto_off=True, enabled=False), _script("live", auto_off=True)]
  174. db = MagicMock()
  175. db.execute = AsyncMock(return_value=MagicMock(scalars=lambda: MagicMock(all=lambda: plugs)))
  176. service = MagicMock(turn_on=AsyncMock(return_value=True))
  177. with patch.object(spm.smart_plug_manager, "get_service_for_plug", AsyncMock(return_value=service)):
  178. await spm.run_linked_scripts(7, "OFF", db)
  179. assert [call.args[0].name for call in service.turn_on.await_args_list] == ["live"]
  180. @pytest.mark.asyncio
  181. async def test_two_plugs_switching_the_printer_run_the_scripts_once(self):
  182. """Two plugs feed one printer and both auto-off after a print."""
  183. session = MagicMock()
  184. session.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
  185. session.return_value.__aexit__ = AsyncMock(return_value=False)
  186. run = AsyncMock()
  187. with (
  188. patch("backend.app.core.database.async_session", session),
  189. patch.object(spm, "run_linked_scripts", run),
  190. ):
  191. await spm.run_linked_scripts_after_automatic_switch(7, _plug(id=1), "OFF")
  192. await spm.run_linked_scripts_after_automatic_switch(7, _plug(id=2), "OFF")
  193. await spm.run_linked_scripts_after_automatic_switch(7, _plug(id=1), "ON")
  194. assert [call.args[1] for call in run.await_args_list] == ["OFF", "ON"]
  195. @pytest.mark.asyncio
  196. async def test_failure_detection_that_switched_no_power_plug_runs_nothing(self, scripts):
  197. from backend.app.services import obico_actions
  198. lamp = _plug(id=4, controls_printer_power=False)
  199. session = MagicMock()
  200. session.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
  201. session.return_value.__aexit__ = AsyncMock(return_value=False)
  202. with (
  203. patch.object(obico_actions, "async_session", session),
  204. patch.object(spm.smart_plug_manager, "_get_plugs_for_printer", AsyncMock(return_value=[lamp])),
  205. patch.object(spm.smart_plug_manager, "get_service_for_plug", AsyncMock(return_value=_service())),
  206. patch.object(spm, "publish_plug_state", AsyncMock()),
  207. ):
  208. await obico_actions._turn_off_linked_plugs(7)
  209. scripts.assert_not_awaited()