test_scheduler_class_target_smart_plug_2786.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424
  1. """Smart-plug power-on for class-targeted queue items (#2786).
  2. Powering a printer on for a queued job has existed since smart plugs did, but
  3. only on the branch that handles an item pinned to one printer. An item queued
  4. as "Any X1C" carries no ``printer_id``, takes the model-based branch, and that
  5. branch's matcher drops an offline printer into a "Offline:" waiting reason
  6. without ever looking at its plugs. With every matching printer switched off the
  7. job sat pending indefinitely.
  8. The reporter's log is the controlled experiment: the same item, same plug, same
  9. Auto On setting, powered a printer on the moment they edited it onto a specific
  10. printer -- and did nothing for the thirteen minutes before that.
  11. """
  12. from contextlib import ExitStack
  13. from datetime import datetime, timedelta, timezone
  14. from types import SimpleNamespace
  15. from unittest.mock import AsyncMock, MagicMock, patch
  16. import pytest
  17. from sqlalchemy import select
  18. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  19. import backend.app.models # noqa: F401 - populate Base.metadata
  20. from backend.app.core.database import Base
  21. from backend.app.models.library import LibraryFile
  22. from backend.app.models.print_queue import PrintQueueItem
  23. from backend.app.models.printer import Printer
  24. from backend.app.models.smart_plug import SmartPlug
  25. from backend.app.services.print_scheduler import PrintScheduler
  26. @pytest.fixture
  27. async def queue_db():
  28. """Two X1Cs, each on its own plug, so "which one" is a real question."""
  29. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  30. async with engine.begin() as conn:
  31. await conn.run_sync(Base.metadata.create_all)
  32. session_maker = async_sessionmaker(engine, expire_on_commit=False)
  33. async with session_maker() as db:
  34. db.add_all(
  35. [
  36. Printer(
  37. id=1,
  38. name="X1C-1",
  39. serial_number="X1C0001",
  40. ip_address="10.0.0.1",
  41. access_code="x",
  42. model="X1C",
  43. is_active=True,
  44. ),
  45. Printer(
  46. id=2,
  47. name="X1C-2",
  48. serial_number="X1C0002",
  49. ip_address="10.0.0.2",
  50. access_code="x",
  51. model="X1C",
  52. is_active=True,
  53. ),
  54. ]
  55. )
  56. await db.commit()
  57. try:
  58. yield SimpleNamespace(session_maker=session_maker)
  59. finally:
  60. await engine.dispose()
  61. async def _add_plug(ctx, printer_id, *, auto_on=True, enabled=True, name=None):
  62. async with ctx.session_maker() as db:
  63. plug = SmartPlug(
  64. name=name or f"Plug {printer_id}",
  65. plug_type="tasmota",
  66. ip_address=f"10.0.1.{printer_id}",
  67. printer_id=printer_id,
  68. enabled=enabled,
  69. auto_on=auto_on,
  70. )
  71. db.add(plug)
  72. await db.commit()
  73. return plug.id
  74. async def _add_item(
  75. ctx, *, printer_id=None, target_model=None, sliced_for="X1C", position=1, scheduled_time=None, manual_start=False
  76. ):
  77. async with ctx.session_maker() as db:
  78. lib = LibraryFile(
  79. filename="job.gcode.3mf",
  80. file_path="/library/job.gcode.3mf",
  81. file_size=10,
  82. file_type="gcode.3mf",
  83. file_metadata={"sliced_for_model": sliced_for},
  84. )
  85. db.add(lib)
  86. await db.flush()
  87. item = PrintQueueItem(
  88. status="pending",
  89. position=position,
  90. printer_id=printer_id,
  91. target_model=target_model,
  92. library_file_id=lib.id,
  93. scheduled_time=scheduled_time,
  94. manual_start=manual_start,
  95. )
  96. db.add(item)
  97. await db.commit()
  98. return item.id
  99. async def _run(
  100. ctx,
  101. scheduler,
  102. *,
  103. power_on=AsyncMock,
  104. connected=False,
  105. awaiting_plate_clear=(),
  106. require_plate_clear=True,
  107. launched=None,
  108. ):
  109. """Run one queue pass with every printer offline unless told otherwise.
  110. ``power_on`` is the patched ``_power_on_and_wait``; the tests assert on the
  111. printer ids it was called with, which is the whole behaviour under test.
  112. """
  113. power_on_mock = power_on() if isinstance(power_on, type) else power_on
  114. with ExitStack() as stack:
  115. for p in [
  116. patch("backend.app.services.print_scheduler.async_session", ctx.session_maker),
  117. patch("backend.app.core.database.async_session", ctx.session_maker),
  118. patch(
  119. "backend.app.services.print_scheduler.printer_manager.is_connected",
  120. MagicMock(side_effect=lambda pid: pid in connected if connected else False),
  121. ),
  122. patch(
  123. "backend.app.services.print_scheduler.printer_manager.is_awaiting_plate_clear",
  124. MagicMock(side_effect=lambda pid: pid in awaiting_plate_clear),
  125. ),
  126. patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
  127. patch(
  128. "backend.app.services.print_scheduler.ha_sensor_manager.blocked_printers",
  129. AsyncMock(return_value={}),
  130. ),
  131. patch(
  132. "backend.app.services.notification_service.notification_service.on_queue_job_waiting",
  133. AsyncMock(),
  134. ),
  135. patch(
  136. "backend.app.services.notification_service.notification_service.on_queue_job_assigned",
  137. AsyncMock(),
  138. ),
  139. patch.object(scheduler, "_check_auto_drying", AsyncMock()),
  140. patch.object(scheduler, "_ensure_ams_mapping", AsyncMock(return_value=None)),
  141. patch.object(scheduler, "_block_on_filament_deficit", AsyncMock(return_value=False)),
  142. patch.object(scheduler, "_launch_uploads", launched or MagicMock()),
  143. patch.object(scheduler, "_power_on_and_wait", power_on_mock),
  144. patch.object(
  145. scheduler,
  146. "_get_bool_setting",
  147. AsyncMock(
  148. side_effect=lambda db, key, default=False: (
  149. require_plate_clear if key == "require_plate_clear" else default
  150. )
  151. ),
  152. ),
  153. ]:
  154. stack.enter_context(p)
  155. await scheduler.check_queue()
  156. return power_on_mock
  157. async def _get_item(ctx, item_id):
  158. async with ctx.session_maker() as db:
  159. return (await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
  160. def _woken_printer_ids(power_on_mock):
  161. """Printer ids ``_power_on_and_wait(plug, printer_id, db)`` was called for."""
  162. return [call.args[1] for call in power_on_mock.await_args_list]
  163. class TestClassTargetWakesAPrinter:
  164. @pytest.mark.asyncio
  165. async def test_offline_printers_are_powered_on_for_an_any_model_job(self, queue_db):
  166. """The bug: this used to do nothing at all."""
  167. await _add_plug(queue_db, 1)
  168. await _add_plug(queue_db, 2)
  169. item_id = await _add_item(queue_db, target_model="X1C")
  170. power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=True))
  171. assert _woken_printer_ids(power_on) == [1]
  172. # Assignment is left to the next pass, once the printer has reported.
  173. item = await _get_item(queue_db, item_id)
  174. assert item.status == "pending"
  175. assert item.printer_id is None
  176. @pytest.mark.asyncio
  177. async def test_a_printer_awaiting_plate_clear_is_passed_over(self, queue_db):
  178. """Waking it buys nothing -- the plate-clear gate would hold it anyway.
  179. This is what the reporter's log shows after a fixed-printer wake: the
  180. printer booted and then reported ``awaiting_plate_clear=True`` every 30
  181. seconds for the next 80 minutes. The flag is Bambuddy-side and
  182. persisted, so it is readable while the printer is still switched off.
  183. """
  184. await _add_plug(queue_db, 1)
  185. await _add_plug(queue_db, 2)
  186. await _add_item(queue_db, target_model="X1C")
  187. power_on = await _run(
  188. queue_db,
  189. PrintScheduler(),
  190. power_on=AsyncMock(return_value=True),
  191. awaiting_plate_clear=(1,),
  192. )
  193. assert _woken_printer_ids(power_on) == [2]
  194. @pytest.mark.asyncio
  195. async def test_nothing_is_woken_when_every_candidate_awaits_plate_clear(self, queue_db):
  196. await _add_plug(queue_db, 1)
  197. await _add_plug(queue_db, 2)
  198. await _add_item(queue_db, target_model="X1C")
  199. power_on = await _run(
  200. queue_db,
  201. PrintScheduler(),
  202. power_on=AsyncMock(return_value=True),
  203. awaiting_plate_clear=(1, 2),
  204. )
  205. power_on.assert_not_awaited()
  206. @pytest.mark.asyncio
  207. async def test_plate_clear_gate_off_wakes_anyway(self, queue_db):
  208. """With the gate disabled the flag is not a reason to skip a printer."""
  209. await _add_plug(queue_db, 1)
  210. await _add_item(queue_db, target_model="X1C")
  211. power_on = await _run(
  212. queue_db,
  213. PrintScheduler(),
  214. power_on=AsyncMock(return_value=True),
  215. awaiting_plate_clear=(1,),
  216. require_plate_clear=False,
  217. )
  218. assert _woken_printer_ids(power_on) == [1]
  219. @pytest.mark.asyncio
  220. async def test_at_most_one_printer_is_woken_per_pass(self, queue_db):
  221. """Each wake blocks the queue loop for the boot wait.
  222. Ten class-targeted jobs must not switch on ten printers inside one
  223. check; the next pass wakes the next one.
  224. """
  225. await _add_plug(queue_db, 1)
  226. await _add_plug(queue_db, 2)
  227. await _add_item(queue_db, target_model="X1C", position=1)
  228. await _add_item(queue_db, target_model="X1C", position=2)
  229. power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=True))
  230. assert _woken_printer_ids(power_on) == [1]
  231. @pytest.mark.asyncio
  232. async def test_a_failed_power_on_is_not_retried_in_the_same_pass(self, queue_db):
  233. await _add_plug(queue_db, 1)
  234. await _add_plug(queue_db, 2)
  235. item_a = await _add_item(queue_db, target_model="X1C", position=1)
  236. item_b = await _add_item(queue_db, target_model="X1C", position=2)
  237. power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=False))
  238. assert _woken_printer_ids(power_on) == [1]
  239. # A printer we failed to switch on is off, not busy. Calling it busy
  240. # would misdescribe it here and, because an all-busy reason is treated
  241. # as needing no user action, silence the notification as well.
  242. for item_id in (item_a, item_b):
  243. reason = (await _get_item(queue_db, item_id)).waiting_reason or ""
  244. assert "Busy" not in reason
  245. assert "Offline" in reason
  246. @pytest.mark.asyncio
  247. async def test_a_dead_plug_does_not_starve_its_siblings(self, queue_db):
  248. """One unreachable plug must not hold every sibling of its model hostage.
  249. Candidates are walked in id order and a pass spends only one power-on
  250. attempt, so without a cool-off the broken printer is picked again on
  251. every pass and the healthy one behind it is never reached. It also
  252. costs a full boot timeout out of each 30s pass, which delays the whole
  253. queue rather than just this job.
  254. """
  255. await _add_plug(queue_db, 1)
  256. await _add_plug(queue_db, 2)
  257. await _add_item(queue_db, target_model="X1C")
  258. scheduler = PrintScheduler()
  259. power_on = AsyncMock(return_value=False)
  260. await _run(queue_db, scheduler, power_on=power_on)
  261. await _run(queue_db, scheduler, power_on=power_on)
  262. assert _woken_printer_ids(power_on) == [1, 2]
  263. @pytest.mark.asyncio
  264. async def test_a_printer_is_tried_again_once_its_cooloff_expires(self, queue_db):
  265. """The skip is a cool-off, not a blacklist — a fixed plug is picked up."""
  266. await _add_plug(queue_db, 1)
  267. await _add_item(queue_db, target_model="X1C")
  268. scheduler = PrintScheduler()
  269. scheduler._wake_failure_cooloff = 0
  270. power_on = AsyncMock(return_value=False)
  271. await _run(queue_db, scheduler, power_on=power_on)
  272. await _run(queue_db, scheduler, power_on=power_on)
  273. assert _woken_printer_ids(power_on) == [1, 1]
  274. @pytest.mark.asyncio
  275. async def test_an_expired_cooloff_is_not_left_behind(self, queue_db):
  276. """The map holds one key per currently-failing printer, not per printer
  277. this process has ever failed to wake."""
  278. await _add_plug(queue_db, 1)
  279. await _add_item(queue_db, target_model="X1C")
  280. scheduler = PrintScheduler()
  281. scheduler._wake_failure_cooloff = 0
  282. await _run(queue_db, scheduler, power_on=AsyncMock(return_value=False))
  283. assert 1 in scheduler._wake_failures
  284. await _run(queue_db, scheduler, power_on=AsyncMock(return_value=True))
  285. assert scheduler._wake_failures == {}
  286. class TestWhatIsNotWokenUp:
  287. @pytest.mark.asyncio
  288. async def test_a_printer_with_no_auto_on_plug_is_left_alone_and_said_so(self, queue_db):
  289. """The first question asked of the reporter was whether Auto On was on.
  290. "Offline" and "offline with no Auto On plug" are different problems and
  291. only the second is one the user has to go and fix, so they must not
  292. share a waiting reason.
  293. """
  294. await _add_plug(queue_db, 1, auto_on=False)
  295. await _add_plug(queue_db, 2, enabled=False)
  296. item_id = await _add_item(queue_db, target_model="X1C")
  297. power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=True))
  298. power_on.assert_not_awaited()
  299. item = await _get_item(queue_db, item_id)
  300. assert item.waiting_reason is not None
  301. assert "no Auto On smart plug" in item.waiting_reason
  302. assert "X1C-1" in item.waiting_reason and "X1C-2" in item.waiting_reason
  303. @pytest.mark.asyncio
  304. async def test_an_incompatible_file_never_wakes_anything(self, queue_db):
  305. """The cross-model gate (#2578) runs before the wake, not after it.
  306. Switching a printer on for a file that can never legally run on it is
  307. worse than leaving it off: the job still cannot start, and now the
  308. printer is drawing power.
  309. """
  310. await _add_plug(queue_db, 1)
  311. await _add_item(queue_db, target_model="X1C", sliced_for="A1")
  312. power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=True))
  313. power_on.assert_not_awaited()
  314. @pytest.mark.asyncio
  315. async def test_a_job_scheduled_for_later_does_not_switch_anything_on_now(self, queue_db):
  316. """Otherwise a print set for 3am powers a printer up the moment it is queued."""
  317. await _add_plug(queue_db, 1)
  318. await _add_item(
  319. queue_db,
  320. target_model="X1C",
  321. scheduled_time=datetime.now(timezone.utc) + timedelta(hours=6),
  322. )
  323. power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=True))
  324. power_on.assert_not_awaited()
  325. @pytest.mark.asyncio
  326. async def test_a_manual_start_job_does_not_switch_anything_on(self, queue_db):
  327. """Manual start means the user presses play; nothing happens until they do."""
  328. await _add_plug(queue_db, 1)
  329. await _add_item(queue_db, target_model="X1C", manual_start=True)
  330. power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=True))
  331. power_on.assert_not_awaited()
  332. @pytest.mark.asyncio
  333. async def test_an_already_connected_printer_is_not_powered_on(self, queue_db):
  334. await _add_plug(queue_db, 1)
  335. await _add_plug(queue_db, 2)
  336. await _add_item(queue_db, target_model="X1C")
  337. power_on = await _run(
  338. queue_db,
  339. PrintScheduler(),
  340. power_on=AsyncMock(return_value=True),
  341. connected=(1, 2),
  342. )
  343. power_on.assert_not_awaited()
  344. class TestFixedPrinterBranchStillWakes:
  345. @pytest.mark.asyncio
  346. async def test_an_item_pinned_to_a_printer_still_powers_it_on(self, queue_db):
  347. """The branch that always worked, pinned so a refactor cannot drop it."""
  348. await _add_plug(queue_db, 2)
  349. await _add_item(queue_db, printer_id=2)
  350. power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=True))
  351. assert _woken_printer_ids(power_on) == [2]