test_scheduler_ha_interlock_1148.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. """The Home Assistant sensor interlock, seen from the scheduler (#1148).
  2. The feature exists because the reporter wanted to know his enclosure was shut
  3. before starting a print remotely. Displaying the door state only helps if he
  4. looks; the interlock is what makes it act on its own.
  5. It is a *hold*, never a failure: the item stays pending and dispatches by
  6. itself once the door closes. And it holds only on a positive, freshly read
  7. finding — a Home Assistant that is unreachable holds nothing, because a queue
  8. that stops whenever an unrelated service goes down is worse than the problem.
  9. """
  10. from contextlib import ExitStack
  11. from types import SimpleNamespace
  12. from unittest.mock import AsyncMock, MagicMock, patch
  13. import pytest
  14. from sqlalchemy import select
  15. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  16. import backend.app.models # noqa: F401 - populate Base.metadata
  17. from backend.app.core.database import Base
  18. from backend.app.models.library import LibraryFile
  19. from backend.app.models.print_queue import PrintQueueItem
  20. from backend.app.models.printer import Printer
  21. from backend.app.services.print_scheduler import PrintScheduler
  22. @pytest.fixture
  23. async def queue_db():
  24. """Two X1Cs, so a model-based job has somewhere else to go."""
  25. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  26. async with engine.begin() as conn:
  27. await conn.run_sync(Base.metadata.create_all)
  28. session_maker = async_sessionmaker(engine, expire_on_commit=False)
  29. async with session_maker() as db:
  30. db.add_all(
  31. [
  32. Printer(
  33. id=1,
  34. name="X1C-1",
  35. serial_number="X1C0001",
  36. ip_address="10.0.0.1",
  37. access_code="x",
  38. model="X1C",
  39. is_active=True,
  40. ),
  41. Printer(
  42. id=2,
  43. name="X1C-2",
  44. serial_number="X1C0002",
  45. ip_address="10.0.0.2",
  46. access_code="x",
  47. model="X1C",
  48. is_active=True,
  49. ),
  50. ]
  51. )
  52. await db.commit()
  53. try:
  54. yield SimpleNamespace(session_maker=session_maker)
  55. finally:
  56. await engine.dispose()
  57. async def _add_item(ctx, *, printer_id=None, target_model=None):
  58. async with ctx.session_maker() as db:
  59. lib = LibraryFile(
  60. filename="job.gcode.3mf",
  61. file_path="/library/job.gcode.3mf",
  62. file_size=10,
  63. file_type="gcode.3mf",
  64. file_metadata={"sliced_for_model": "X1C"},
  65. )
  66. db.add(lib)
  67. await db.flush()
  68. item = PrintQueueItem(
  69. status="pending",
  70. position=1,
  71. printer_id=printer_id,
  72. target_model=target_model,
  73. library_file_id=lib.id,
  74. )
  75. db.add(item)
  76. await db.commit()
  77. return item.id
  78. async def _run(ctx, scheduler, blocked, launched, finder=None, idle=True, drying=None):
  79. patches = [
  80. patch("backend.app.services.print_scheduler.async_session", ctx.session_maker),
  81. patch("backend.app.core.database.async_session", ctx.session_maker),
  82. patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
  83. patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
  84. patch(
  85. "backend.app.services.print_scheduler.ha_sensor_manager.blocked_printers",
  86. AsyncMock(return_value=blocked),
  87. ),
  88. patch(
  89. "backend.app.services.notification_service.notification_service.on_queue_job_waiting",
  90. AsyncMock(),
  91. ),
  92. patch(
  93. "backend.app.services.notification_service.notification_service.on_queue_job_assigned",
  94. AsyncMock(),
  95. ),
  96. patch.object(scheduler, "_is_printer_idle", MagicMock(return_value=idle)),
  97. patch.object(scheduler, "_check_auto_drying", drying or AsyncMock()),
  98. patch.object(scheduler, "_ensure_ams_mapping", AsyncMock(return_value=None)),
  99. patch.object(scheduler, "_block_on_filament_deficit", AsyncMock(return_value=False)),
  100. patch.object(scheduler, "_launch_uploads", launched),
  101. ]
  102. if finder is not None:
  103. patches.append(patch.object(scheduler, "_find_idle_printer_for_model", finder))
  104. with ExitStack() as stack:
  105. for p in patches:
  106. stack.enter_context(p)
  107. return await scheduler.check_queue()
  108. async def _get_item(ctx, item_id):
  109. async with ctx.session_maker() as db:
  110. return (await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
  111. class TestFixedPrinter:
  112. @pytest.mark.asyncio
  113. async def test_holds_the_item_and_says_why(self, queue_db):
  114. item_id = await _add_item(queue_db, printer_id=1)
  115. launched = MagicMock()
  116. await _run(queue_db, PrintScheduler(), {1: "Enclosure Door"}, launched)
  117. launched.assert_not_called()
  118. item = await _get_item(queue_db, item_id)
  119. assert item.status == "pending"
  120. assert item.waiting_reason == "Waiting on Enclosure Door"
  121. @pytest.mark.asyncio
  122. async def test_holding_is_not_failing(self, queue_db):
  123. """The door being open is a thing the user fixes in five seconds. The
  124. job must be there waiting when they do, not failed."""
  125. item_id = await _add_item(queue_db, printer_id=1)
  126. await _run(queue_db, PrintScheduler(), {1: "Enclosure Door"}, MagicMock())
  127. item = await _get_item(queue_db, item_id)
  128. assert item.status == "pending"
  129. assert item.error_message is None
  130. assert item.completed_at is None
  131. @pytest.mark.asyncio
  132. async def test_dispatches_once_the_hold_clears(self, queue_db):
  133. item_id = await _add_item(queue_db, printer_id=1)
  134. scheduler = PrintScheduler()
  135. await _run(queue_db, scheduler, {1: "Enclosure Door"}, MagicMock())
  136. launched = MagicMock()
  137. await _run(queue_db, scheduler, {}, launched)
  138. launched.assert_called_once()
  139. assert launched.call_args[0][0] == [item_id]
  140. @pytest.mark.asyncio
  141. async def test_the_stale_reason_is_cleared_on_dispatch(self, queue_db):
  142. """Otherwise the queue shows "Waiting on Enclosure Door" against a job
  143. that is already printing."""
  144. item_id = await _add_item(queue_db, printer_id=1)
  145. scheduler = PrintScheduler()
  146. await _run(queue_db, scheduler, {1: "Enclosure Door"}, MagicMock())
  147. await _run(queue_db, scheduler, {}, MagicMock())
  148. assert (await _get_item(queue_db, item_id)).waiting_reason is None
  149. @pytest.mark.asyncio
  150. async def test_the_reason_clears_even_when_the_printer_is_still_busy(self, queue_db):
  151. """You shut the door, but the printer is midway through something else.
  152. The hold has lifted and the queue must say so. Leaving the old reason
  153. standing would have a shut door reading "Waiting on Enclosure Door" for
  154. the rest of a ten-hour print.
  155. The reason no longer goes to None here: since #3074 the branch always
  156. says what it is waiting on, and what it is waiting on now is the print.
  157. The invariant under test is the same one — the lifted hold does not
  158. survive the pass that lifted it.
  159. """
  160. item_id = await _add_item(queue_db, printer_id=1)
  161. scheduler = PrintScheduler()
  162. await _run(queue_db, scheduler, {1: "Enclosure Door"}, MagicMock())
  163. launched = MagicMock()
  164. await _run(queue_db, scheduler, {}, launched, idle=False)
  165. launched.assert_not_called()
  166. reason = (await _get_item(queue_db, item_id)).waiting_reason
  167. assert "Enclosure Door" not in (reason or "")
  168. assert reason == "Busy: X1C-1"
  169. @pytest.mark.asyncio
  170. async def test_another_printers_sensor_does_not_hold_this_one(self, queue_db):
  171. item_id = await _add_item(queue_db, printer_id=1)
  172. launched = MagicMock()
  173. await _run(queue_db, PrintScheduler(), {2: "Enclosure Door"}, launched)
  174. launched.assert_called_once()
  175. assert launched.call_args[0][0] == [item_id]
  176. @pytest.mark.asyncio
  177. async def test_nothing_blocked_dispatches_as_before(self, queue_db):
  178. item_id = await _add_item(queue_db, printer_id=1)
  179. launched = MagicMock()
  180. await _run(queue_db, PrintScheduler(), {}, launched)
  181. launched.assert_called_once()
  182. assert launched.call_args[0][0] == [item_id]
  183. @pytest.mark.asyncio
  184. async def test_a_held_printer_is_not_reported_as_busy(self, queue_db):
  185. """A held printer is idle, not printing, and the rest of the scheduler
  186. must keep seeing it that way.
  187. busy_printers looks like the obvious place to put a hold, but
  188. _check_auto_drying reads that set as "is currently printing" and would
  189. route an idle-but-held printer down the mid-print drying path — capped
  190. temperature, and past the queue-only gating. Auto-drying must see this
  191. printer exactly as it did before the interlock existed.
  192. """
  193. await _add_item(queue_db, printer_id=1)
  194. scheduler = PrintScheduler()
  195. drying = AsyncMock()
  196. await _run(queue_db, scheduler, {1: "Enclosure Door"}, MagicMock(), drying=drying)
  197. busy_printers = drying.await_args[0][2]
  198. assert 1 not in busy_printers
  199. @pytest.mark.asyncio
  200. async def test_a_failing_interlock_lookup_never_stops_the_queue(self, queue_db):
  201. """If the check itself breaks, the answer is "no holds", not "no prints"."""
  202. item_id = await _add_item(queue_db, printer_id=1)
  203. launched = MagicMock()
  204. broken = AsyncMock(side_effect=RuntimeError("HA sensor table is on fire"))
  205. with patch(
  206. "backend.app.services.print_scheduler.ha_sensor_manager.blocked_printers",
  207. broken,
  208. ):
  209. await _run(queue_db, PrintScheduler(), {}, launched)
  210. launched.assert_called_once()
  211. assert launched.call_args[0][0] == [item_id]
  212. class TestModelBased:
  213. def _finder(self):
  214. """Stand-in matcher that respects busy_printers, as the real one does.
  215. That is the whole contract under test here: the interlock folds held
  216. printers into busy_printers, so a matcher that honours the set honours
  217. the interlock without knowing it exists.
  218. """
  219. async def finder(db, model, busy, *args, **kwargs):
  220. for printer_id in (1, 2):
  221. if printer_id not in busy:
  222. return printer_id, None
  223. return None, "All printers busy"
  224. return finder
  225. @pytest.mark.asyncio
  226. async def test_an_interlocked_printer_is_passed_over_for_its_sibling(self, queue_db):
  227. """The whole reason the hold is folded into busy_printers: an "Any X1C"
  228. job should run on the printer whose door is shut, not queue behind the
  229. one whose door is open."""
  230. item_id = await _add_item(queue_db, target_model="X1C")
  231. launched = MagicMock()
  232. await _run(
  233. queue_db,
  234. PrintScheduler(),
  235. {1: "Enclosure Door"},
  236. launched,
  237. finder=self._finder(),
  238. )
  239. launched.assert_called_once()
  240. assert launched.call_args[0][0] == [item_id]
  241. assert (await _get_item(queue_db, item_id)).printer_id == 2
  242. @pytest.mark.asyncio
  243. async def test_every_printer_held_leaves_the_job_waiting(self, queue_db):
  244. item_id = await _add_item(queue_db, target_model="X1C")
  245. launched = MagicMock()
  246. await _run(
  247. queue_db,
  248. PrintScheduler(),
  249. {1: "Enclosure Door", 2: "Enclosure Door"},
  250. launched,
  251. finder=self._finder(),
  252. )
  253. launched.assert_not_called()
  254. item = await _get_item(queue_db, item_id)
  255. assert item.status == "pending"
  256. assert item.printer_id is None