test_scheduler_class_target_smart_plug_2786.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745
  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. import json
  13. from contextlib import ExitStack
  14. from datetime import datetime, timedelta, timezone
  15. from types import SimpleNamespace
  16. from unittest.mock import AsyncMock, MagicMock, patch
  17. import pytest
  18. from sqlalchemy import select
  19. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  20. import backend.app.models # noqa: F401 - populate Base.metadata
  21. from backend.app.core.database import Base
  22. from backend.app.models.library import LibraryFile
  23. from backend.app.models.print_queue import PrintQueueItem
  24. from backend.app.models.printer import Printer
  25. from backend.app.models.smart_plug import SmartPlug
  26. from backend.app.services.print_scheduler import PrintScheduler
  27. @pytest.fixture
  28. async def queue_db():
  29. """Two X1Cs, each on its own plug, so "which one" is a real question."""
  30. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  31. async with engine.begin() as conn:
  32. await conn.run_sync(Base.metadata.create_all)
  33. session_maker = async_sessionmaker(engine, expire_on_commit=False)
  34. async with session_maker() as db:
  35. db.add_all(
  36. [
  37. Printer(
  38. id=1,
  39. name="X1C-1",
  40. serial_number="X1C0001",
  41. ip_address="10.0.0.1",
  42. access_code="x",
  43. model="X1C",
  44. is_active=True,
  45. ),
  46. Printer(
  47. id=2,
  48. name="X1C-2",
  49. serial_number="X1C0002",
  50. ip_address="10.0.0.2",
  51. access_code="x",
  52. model="X1C",
  53. is_active=True,
  54. ),
  55. ]
  56. )
  57. await db.commit()
  58. try:
  59. yield SimpleNamespace(session_maker=session_maker)
  60. finally:
  61. await engine.dispose()
  62. async def _add_plug(ctx, printer_id, *, auto_on=True, enabled=True, name=None):
  63. async with ctx.session_maker() as db:
  64. plug = SmartPlug(
  65. name=name or f"Plug {printer_id}",
  66. plug_type="tasmota",
  67. ip_address=f"10.0.1.{printer_id}",
  68. printer_id=printer_id,
  69. enabled=enabled,
  70. auto_on=auto_on,
  71. )
  72. db.add(plug)
  73. await db.commit()
  74. return plug.id
  75. async def _add_printer(ctx, printer_id, *, model="X1C"):
  76. """One more machine, for the cases that need a farm rather than a pair."""
  77. async with ctx.session_maker() as db:
  78. db.add(
  79. Printer(
  80. id=printer_id,
  81. name=f"{model}-{printer_id}",
  82. serial_number=f"{model}{printer_id:04d}",
  83. ip_address=f"10.0.0.{printer_id}",
  84. access_code="x",
  85. model=model,
  86. is_active=True,
  87. )
  88. )
  89. await db.commit()
  90. def _tray(tray_type, color, *, idx=""):
  91. return {"tray_type": tray_type, "tray_color": color, "tray_info_idx": idx}
  92. def _external(tray_type, color, *, idx=""):
  93. """A printer whose filament sits on the external spool holder."""
  94. return {"vt_tray": [dict(_tray(tray_type, color, idx=idx), id=254)]}
  95. def _ams(*trays):
  96. return {"ams": [{"id": 0, "tray": list(trays)}]}
  97. async def _add_item(
  98. ctx,
  99. *,
  100. printer_id=None,
  101. target_model=None,
  102. sliced_for="X1C",
  103. position=1,
  104. scheduled_time=None,
  105. manual_start=False,
  106. required_filament_types=None,
  107. filament_overrides=None,
  108. ):
  109. async with ctx.session_maker() as db:
  110. lib = LibraryFile(
  111. filename="job.gcode.3mf",
  112. file_path="/library/job.gcode.3mf",
  113. file_size=10,
  114. file_type="gcode.3mf",
  115. file_metadata={"sliced_for_model": sliced_for},
  116. )
  117. db.add(lib)
  118. await db.flush()
  119. item = PrintQueueItem(
  120. status="pending",
  121. position=position,
  122. printer_id=printer_id,
  123. target_model=target_model,
  124. library_file_id=lib.id,
  125. scheduled_time=scheduled_time,
  126. manual_start=manual_start,
  127. required_filament_types=(
  128. json.dumps(required_filament_types) if required_filament_types is not None else None
  129. ),
  130. filament_overrides=json.dumps(filament_overrides) if filament_overrides is not None else None,
  131. )
  132. db.add(item)
  133. await db.commit()
  134. return item.id
  135. async def _run(
  136. ctx,
  137. scheduler,
  138. *,
  139. power_on=AsyncMock,
  140. connected=False,
  141. awaiting_plate_clear=(),
  142. require_plate_clear=True,
  143. launched=None,
  144. statuses=None,
  145. remembered=None,
  146. ):
  147. """Run one queue pass with every printer offline unless told otherwise.
  148. ``power_on`` is the patched ``_power_on_and_wait``; the tests assert on the
  149. printer ids it was called with, which is the whole behaviour under test.
  150. """
  151. power_on_mock = power_on() if isinstance(power_on, type) else power_on
  152. with ExitStack() as stack:
  153. for p in [
  154. patch("backend.app.services.print_scheduler.async_session", ctx.session_maker),
  155. patch("backend.app.core.database.async_session", ctx.session_maker),
  156. patch(
  157. "backend.app.services.print_scheduler.printer_manager.is_connected",
  158. MagicMock(side_effect=lambda pid: pid in connected if connected else False),
  159. ),
  160. patch(
  161. "backend.app.services.print_scheduler.printer_manager.is_awaiting_plate_clear",
  162. MagicMock(side_effect=lambda pid: pid in awaiting_plate_clear),
  163. ),
  164. patch(
  165. "backend.app.services.print_scheduler.printer_manager.get_status",
  166. MagicMock(
  167. side_effect=lambda pid: (
  168. SimpleNamespace(raw_data=statuses[pid]) if statuses and pid in statuses else None
  169. )
  170. ),
  171. ),
  172. patch(
  173. "backend.app.services.print_scheduler.printer_manager.last_known_trays",
  174. MagicMock(side_effect=lambda pid: (remembered or {}).get(pid, {})),
  175. ),
  176. patch(
  177. "backend.app.services.print_scheduler.ha_sensor_manager.blocked_printers",
  178. AsyncMock(return_value={}),
  179. ),
  180. patch(
  181. "backend.app.services.notification_service.notification_service.on_queue_job_waiting",
  182. AsyncMock(),
  183. ),
  184. patch(
  185. "backend.app.services.notification_service.notification_service.on_queue_job_assigned",
  186. AsyncMock(),
  187. ),
  188. patch.object(scheduler, "_check_auto_drying", AsyncMock()),
  189. patch.object(scheduler, "_ensure_ams_mapping", AsyncMock(return_value=None)),
  190. patch.object(scheduler, "_block_on_filament_deficit", AsyncMock(return_value=False)),
  191. patch.object(scheduler, "_launch_uploads", launched or MagicMock()),
  192. patch.object(scheduler, "_power_on_and_wait", power_on_mock),
  193. patch.object(
  194. scheduler,
  195. "_get_bool_setting",
  196. AsyncMock(
  197. side_effect=lambda db, key, default=False: (
  198. require_plate_clear if key == "require_plate_clear" else default
  199. )
  200. ),
  201. ),
  202. ]:
  203. stack.enter_context(p)
  204. await scheduler.check_queue()
  205. return power_on_mock
  206. async def _get_item(ctx, item_id):
  207. async with ctx.session_maker() as db:
  208. return (await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
  209. def _woken_printer_ids(power_on_mock):
  210. """Printer ids ``_power_on_and_wait(plug, printer_id, db)`` was called for."""
  211. return [call.args[1] for call in power_on_mock.await_args_list]
  212. class TestClassTargetWakesAPrinter:
  213. @pytest.mark.asyncio
  214. async def test_offline_printers_are_powered_on_for_an_any_model_job(self, queue_db):
  215. """The bug: this used to do nothing at all."""
  216. await _add_plug(queue_db, 1)
  217. await _add_plug(queue_db, 2)
  218. item_id = await _add_item(queue_db, target_model="X1C")
  219. power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=True))
  220. assert _woken_printer_ids(power_on) == [1]
  221. # Assignment is left to the next pass, once the printer has reported.
  222. item = await _get_item(queue_db, item_id)
  223. assert item.status == "pending"
  224. assert item.printer_id is None
  225. @pytest.mark.asyncio
  226. async def test_a_printer_awaiting_plate_clear_is_passed_over(self, queue_db):
  227. """Waking it buys nothing -- the plate-clear gate would hold it anyway.
  228. This is what the reporter's log shows after a fixed-printer wake: the
  229. printer booted and then reported ``awaiting_plate_clear=True`` every 30
  230. seconds for the next 80 minutes. The flag is Bambuddy-side and
  231. persisted, so it is readable while the printer is still switched off.
  232. """
  233. await _add_plug(queue_db, 1)
  234. await _add_plug(queue_db, 2)
  235. await _add_item(queue_db, target_model="X1C")
  236. power_on = await _run(
  237. queue_db,
  238. PrintScheduler(),
  239. power_on=AsyncMock(return_value=True),
  240. awaiting_plate_clear=(1,),
  241. )
  242. assert _woken_printer_ids(power_on) == [2]
  243. @pytest.mark.asyncio
  244. async def test_nothing_is_woken_when_every_candidate_awaits_plate_clear(self, queue_db):
  245. await _add_plug(queue_db, 1)
  246. await _add_plug(queue_db, 2)
  247. await _add_item(queue_db, target_model="X1C")
  248. power_on = await _run(
  249. queue_db,
  250. PrintScheduler(),
  251. power_on=AsyncMock(return_value=True),
  252. awaiting_plate_clear=(1, 2),
  253. )
  254. power_on.assert_not_awaited()
  255. @pytest.mark.asyncio
  256. async def test_plate_clear_gate_off_wakes_anyway(self, queue_db):
  257. """With the gate disabled the flag is not a reason to skip a printer."""
  258. await _add_plug(queue_db, 1)
  259. await _add_item(queue_db, target_model="X1C")
  260. power_on = await _run(
  261. queue_db,
  262. PrintScheduler(),
  263. power_on=AsyncMock(return_value=True),
  264. awaiting_plate_clear=(1,),
  265. require_plate_clear=False,
  266. )
  267. assert _woken_printer_ids(power_on) == [1]
  268. @pytest.mark.asyncio
  269. async def test_at_most_one_printer_is_woken_per_pass(self, queue_db):
  270. """Each wake blocks the queue loop for the boot wait.
  271. Ten class-targeted jobs must not switch on ten printers inside one
  272. check; the next pass wakes the next one.
  273. """
  274. await _add_plug(queue_db, 1)
  275. await _add_plug(queue_db, 2)
  276. await _add_item(queue_db, target_model="X1C", position=1)
  277. await _add_item(queue_db, target_model="X1C", position=2)
  278. power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=True))
  279. assert _woken_printer_ids(power_on) == [1]
  280. @pytest.mark.asyncio
  281. async def test_a_failed_power_on_is_not_retried_in_the_same_pass(self, queue_db):
  282. await _add_plug(queue_db, 1)
  283. await _add_plug(queue_db, 2)
  284. item_a = await _add_item(queue_db, target_model="X1C", position=1)
  285. item_b = await _add_item(queue_db, target_model="X1C", position=2)
  286. power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=False))
  287. assert _woken_printer_ids(power_on) == [1]
  288. # A printer we failed to switch on is off, not busy. Calling it busy
  289. # would misdescribe it here and, because an all-busy reason is treated
  290. # as needing no user action, silence the notification as well.
  291. for item_id in (item_a, item_b):
  292. reason = (await _get_item(queue_db, item_id)).waiting_reason or ""
  293. assert "Busy" not in reason
  294. assert "Offline" in reason
  295. @pytest.mark.asyncio
  296. async def test_a_dead_plug_does_not_starve_its_siblings(self, queue_db):
  297. """One unreachable plug must not hold every sibling of its model hostage.
  298. Candidates are walked in id order and a pass spends only one power-on
  299. attempt, so without a cool-off the broken printer is picked again on
  300. every pass and the healthy one behind it is never reached. It also
  301. costs a full boot timeout out of each 30s pass, which delays the whole
  302. queue rather than just this job.
  303. """
  304. await _add_plug(queue_db, 1)
  305. await _add_plug(queue_db, 2)
  306. await _add_item(queue_db, target_model="X1C")
  307. scheduler = PrintScheduler()
  308. power_on = AsyncMock(return_value=False)
  309. await _run(queue_db, scheduler, power_on=power_on)
  310. await _run(queue_db, scheduler, power_on=power_on)
  311. assert _woken_printer_ids(power_on) == [1, 2]
  312. @pytest.mark.asyncio
  313. async def test_a_printer_is_tried_again_once_its_cooloff_expires(self, queue_db):
  314. """The skip is a cool-off, not a blacklist — a fixed plug is picked up."""
  315. await _add_plug(queue_db, 1)
  316. await _add_item(queue_db, target_model="X1C")
  317. scheduler = PrintScheduler()
  318. scheduler._wake_failure_cooloff = 0
  319. power_on = AsyncMock(return_value=False)
  320. await _run(queue_db, scheduler, power_on=power_on)
  321. await _run(queue_db, scheduler, power_on=power_on)
  322. assert _woken_printer_ids(power_on) == [1, 1]
  323. @pytest.mark.asyncio
  324. async def test_an_expired_cooloff_is_not_left_behind(self, queue_db):
  325. """The map holds one key per currently-failing printer, not per printer
  326. this process has ever failed to wake."""
  327. await _add_plug(queue_db, 1)
  328. await _add_item(queue_db, target_model="X1C")
  329. scheduler = PrintScheduler()
  330. scheduler._wake_failure_cooloff = 0
  331. await _run(queue_db, scheduler, power_on=AsyncMock(return_value=False))
  332. assert 1 in scheduler._wake_failures
  333. await _run(queue_db, scheduler, power_on=AsyncMock(return_value=True))
  334. assert scheduler._wake_failures == {}
  335. class TestWhatIsNotWokenUp:
  336. @pytest.mark.asyncio
  337. async def test_a_printer_with_no_auto_on_plug_is_left_alone_and_said_so(self, queue_db):
  338. """The first question asked of the reporter was whether Auto On was on.
  339. "Offline" and "offline with no Auto On plug" are different problems and
  340. only the second is one the user has to go and fix, so they must not
  341. share a waiting reason.
  342. """
  343. await _add_plug(queue_db, 1, auto_on=False)
  344. await _add_plug(queue_db, 2, enabled=False)
  345. item_id = await _add_item(queue_db, target_model="X1C")
  346. power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=True))
  347. power_on.assert_not_awaited()
  348. item = await _get_item(queue_db, item_id)
  349. assert item.waiting_reason is not None
  350. assert "no Auto On smart plug" in item.waiting_reason
  351. assert "X1C-1" in item.waiting_reason and "X1C-2" in item.waiting_reason
  352. @pytest.mark.asyncio
  353. async def test_an_incompatible_file_never_wakes_anything(self, queue_db):
  354. """The cross-model gate (#2578) runs before the wake, not after it.
  355. Switching a printer on for a file that can never legally run on it is
  356. worse than leaving it off: the job still cannot start, and now the
  357. printer is drawing power.
  358. """
  359. await _add_plug(queue_db, 1)
  360. await _add_item(queue_db, target_model="X1C", sliced_for="A1")
  361. power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=True))
  362. power_on.assert_not_awaited()
  363. @pytest.mark.asyncio
  364. async def test_a_job_scheduled_for_later_does_not_switch_anything_on_now(self, queue_db):
  365. """Otherwise a print set for 3am powers a printer up the moment it is queued."""
  366. await _add_plug(queue_db, 1)
  367. await _add_item(
  368. queue_db,
  369. target_model="X1C",
  370. scheduled_time=datetime.now(timezone.utc) + timedelta(hours=6),
  371. )
  372. power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=True))
  373. power_on.assert_not_awaited()
  374. @pytest.mark.asyncio
  375. async def test_a_manual_start_job_does_not_switch_anything_on(self, queue_db):
  376. """Manual start means the user presses play; nothing happens until they do."""
  377. await _add_plug(queue_db, 1)
  378. await _add_item(queue_db, target_model="X1C", manual_start=True)
  379. power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=True))
  380. power_on.assert_not_awaited()
  381. @pytest.mark.asyncio
  382. async def test_an_already_connected_printer_is_not_powered_on(self, queue_db):
  383. await _add_plug(queue_db, 1)
  384. await _add_plug(queue_db, 2)
  385. await _add_item(queue_db, target_model="X1C")
  386. power_on = await _run(
  387. queue_db,
  388. PrintScheduler(),
  389. power_on=AsyncMock(return_value=True),
  390. connected=(1, 2),
  391. )
  392. power_on.assert_not_awaited()
  393. class TestFixedPrinterBranchStillWakes:
  394. @pytest.mark.asyncio
  395. async def test_an_item_pinned_to_a_printer_still_powers_it_on(self, queue_db):
  396. """The branch that always worked, pinned so a refactor cannot drop it."""
  397. await _add_plug(queue_db, 2)
  398. await _add_item(queue_db, printer_id=2)
  399. power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=True))
  400. assert _woken_printer_ids(power_on) == [2]
  401. class TestFilamentIsCheckedBeforeSwitchingOn:
  402. """#2876: the colours are readable while the printers are off.
  403. The wake step used to know only a printer's model, so a job for a colour
  404. loaded on the far end of the farm switched machines on in ID order,
  405. evaluated each once it booted, rejected it on colour and left it running.
  406. Bambuddy held those colours the whole time -- a printer keeps its last
  407. reported trays after the power goes.
  408. """
  409. @pytest.mark.asyncio
  410. async def test_only_the_printer_that_can_take_the_job_is_woken(self, queue_db):
  411. """The reporter's farm, minus the machines that were busy anyway."""
  412. for pid in (3, 4):
  413. await _add_printer(queue_db, pid)
  414. for pid in (1, 2, 3, 4):
  415. await _add_plug(queue_db, pid)
  416. await _add_item(
  417. queue_db,
  418. target_model="X1C",
  419. filament_overrides=[{"type": "ASA", "color": "161616FF", "force_color_match": True}],
  420. )
  421. power_on = await _run(
  422. queue_db,
  423. PrintScheduler(),
  424. power_on=AsyncMock(return_value=True),
  425. statuses={
  426. 1: _external("ASA", "4B5320FF"), # olive
  427. 2: _external("ASA", "898989FF"), # grey
  428. 3: _external("ASA", "FFFFFFFF"), # white
  429. 4: _external("ASA", "161616FF"), # black -- the only one that can print it
  430. },
  431. )
  432. assert _woken_printer_ids(power_on) == [4]
  433. @pytest.mark.asyncio
  434. async def test_a_printer_we_have_never_heard_from_is_still_woken(self, queue_db):
  435. """No reading is not the same as no filament, and must not exclude.
  436. Bambuddy holds the trays in memory only. A restart while the farm was
  437. switched off leaves it knowing nothing, and concluding from that that
  438. no printer can take the job would strand every queue on the planet.
  439. """
  440. await _add_plug(queue_db, 1)
  441. await _add_plug(queue_db, 2)
  442. await _add_item(
  443. queue_db,
  444. target_model="X1C",
  445. filament_overrides=[{"type": "PLA", "color": "161616FF", "force_color_match": True}],
  446. )
  447. power_on = await _run(
  448. queue_db,
  449. PrintScheduler(),
  450. power_on=AsyncMock(return_value=True),
  451. statuses={1: _external("PLA", "FFFFFFFF")}, # printer 2: nothing known
  452. )
  453. assert _woken_printer_ids(power_on) == [2]
  454. @pytest.mark.asyncio
  455. async def test_an_empty_reading_counts_as_unknown(self, queue_db):
  456. """A powered-down AMS printer reports empty, which tells us nothing."""
  457. await _add_plug(queue_db, 1)
  458. await _add_plug(queue_db, 2)
  459. await _add_item(
  460. queue_db,
  461. target_model="X1C",
  462. filament_overrides=[{"type": "PLA", "color": "161616FF", "force_color_match": True}],
  463. )
  464. power_on = await _run(
  465. queue_db,
  466. PrintScheduler(),
  467. power_on=AsyncMock(return_value=True),
  468. statuses={1: {"ams": [], "vt_tray": []}, 2: _external("PLA", "FFFFFFFF")},
  469. )
  470. assert _woken_printer_ids(power_on) == [1]
  471. @pytest.mark.asyncio
  472. async def test_ams_trays_are_read_the_same_as_the_external_spool(self, queue_db):
  473. await _add_plug(queue_db, 1)
  474. await _add_plug(queue_db, 2)
  475. await _add_item(
  476. queue_db,
  477. target_model="X1C",
  478. filament_overrides=[{"type": "PLA", "color": "161616FF", "force_color_match": True}],
  479. )
  480. power_on = await _run(
  481. queue_db,
  482. PrintScheduler(),
  483. power_on=AsyncMock(return_value=True),
  484. statuses={
  485. 1: _ams(_tray("PLA", "FFFFFFFF"), _tray("PLA", "F98C36FF")),
  486. 2: _ams(_tray("PETG", "00FF00FF"), _tray("PLA", "161616FF")),
  487. },
  488. )
  489. assert _woken_printer_ids(power_on) == [2]
  490. @pytest.mark.asyncio
  491. async def test_a_missing_filament_type_rules_a_printer_out(self, queue_db):
  492. await _add_plug(queue_db, 1)
  493. await _add_plug(queue_db, 2)
  494. await _add_item(queue_db, target_model="X1C", required_filament_types=["PETG"])
  495. power_on = await _run(
  496. queue_db,
  497. PrintScheduler(),
  498. power_on=AsyncMock(return_value=True),
  499. statuses={1: _external("PLA", "FFFFFFFF"), 2: _external("PETG", "FFFFFFFF")},
  500. )
  501. assert _woken_printer_ids(power_on) == [2]
  502. @pytest.mark.asyncio
  503. async def test_a_preferred_colour_nobody_has_still_wakes_the_first_printer(self, queue_db):
  504. """Preference overrides only order the field -- unless nothing matches.
  505. The matcher skips a printer that has none of the preferred colours, so
  506. the wake step passes over one too. With no printer holding any of them
  507. the item is simply waiting for a filament change, and switching the
  508. farm on will not produce one.
  509. """
  510. await _add_plug(queue_db, 1)
  511. await _add_plug(queue_db, 2)
  512. await _add_item(
  513. queue_db,
  514. target_model="X1C",
  515. filament_overrides=[{"type": "PLA", "color": "161616FF"}],
  516. )
  517. power_on = await _run(
  518. queue_db,
  519. PrintScheduler(),
  520. power_on=AsyncMock(return_value=True),
  521. statuses={1: _external("PLA", "FFFFFFFF"), 2: _external("PLA", "F98C36FF")},
  522. )
  523. power_on.assert_not_awaited()
  524. @pytest.mark.asyncio
  525. async def test_a_job_with_no_filament_requirement_wakes_as_before(self, queue_db):
  526. await _add_plug(queue_db, 1)
  527. await _add_plug(queue_db, 2)
  528. await _add_item(queue_db, target_model="X1C")
  529. power_on = await _run(
  530. queue_db,
  531. PrintScheduler(),
  532. power_on=AsyncMock(return_value=True),
  533. statuses={1: _external("PLA", "FFFFFFFF"), 2: _external("PLA", "161616FF")},
  534. )
  535. assert _woken_printer_ids(power_on) == [1]
  536. @pytest.mark.asyncio
  537. async def test_the_filament_variant_is_honoured(self, queue_db):
  538. """Bambu reports every PLA variant as PLA; only tray_info_idx separates them (#2650)."""
  539. await _add_plug(queue_db, 1)
  540. await _add_plug(queue_db, 2)
  541. await _add_item(
  542. queue_db,
  543. target_model="X1C",
  544. filament_overrides=[
  545. {"type": "PLA", "color": "FFFFFFFF", "tray_info_idx": "GFA01", "force_color_match": True}
  546. ],
  547. )
  548. power_on = await _run(
  549. queue_db,
  550. PrintScheduler(),
  551. power_on=AsyncMock(return_value=True),
  552. statuses={
  553. 1: _external("PLA", "FFFFFFFF", idx="GFA00"), # Basic, not Matte
  554. 2: _external("PLA", "FFFFFFFF", idx="GFA01"),
  555. },
  556. )
  557. assert _woken_printer_ids(power_on) == [2]
  558. @pytest.mark.asyncio
  559. async def test_the_waiting_reason_says_filament_not_offline(self, queue_db):
  560. """Not switching a printer on must not look like nothing happening.
  561. Before, a wrong-colour printer was woken and then reported as needing
  562. filament. Passing it over instead has to say the same thing, or the
  563. job sits on "Offline:" while Bambuddy silently declines to act on it.
  564. """
  565. await _add_plug(queue_db, 1)
  566. await _add_plug(queue_db, 2)
  567. item_id = await _add_item(
  568. queue_db,
  569. target_model="X1C",
  570. filament_overrides=[{"type": "PLA", "color": "161616FF", "color_name": "Black", "force_color_match": True}],
  571. )
  572. power_on = await _run(
  573. queue_db,
  574. PrintScheduler(),
  575. power_on=AsyncMock(return_value=True),
  576. statuses={1: _external("PLA", "FFFFFFFF"), 2: _external("PLA", "F98C36FF")},
  577. )
  578. power_on.assert_not_awaited()
  579. item = await _get_item(queue_db, item_id)
  580. assert item.waiting_reason == "No matching material/color. Waiting on PLA (Black)"
  581. @pytest.mark.asyncio
  582. async def test_a_reading_kept_after_the_client_was_dropped_still_counts(self, queue_db):
  583. """Waking a printer replaces its client, which drops its status.
  584. The manager keeps the trays separately for exactly this reason -- a
  585. power-on that times out must not leave the next queue check knowing
  586. less than this one did.
  587. """
  588. await _add_plug(queue_db, 1)
  589. await _add_plug(queue_db, 2)
  590. await _add_item(
  591. queue_db,
  592. target_model="X1C",
  593. filament_overrides=[{"type": "PLA", "color": "161616FF", "force_color_match": True}],
  594. )
  595. power_on = await _run(
  596. queue_db,
  597. PrintScheduler(),
  598. power_on=AsyncMock(return_value=True),
  599. remembered={1: _external("PLA", "FFFFFFFF"), 2: _external("PLA", "161616FF")},
  600. )
  601. assert _woken_printer_ids(power_on) == [2]
  602. @pytest.mark.asyncio
  603. async def test_what_the_printer_reported_beats_what_was_remembered(self, queue_db):
  604. """The kept reading is a fallback, never an override.
  605. Printer 1 is back and reporting black; the record from before it was
  606. power-cycled says white. Acting on the record would pass over the one
  607. printer that can take the job.
  608. """
  609. await _add_plug(queue_db, 1)
  610. await _add_plug(queue_db, 2)
  611. await _add_item(
  612. queue_db,
  613. target_model="X1C",
  614. filament_overrides=[{"type": "PLA", "color": "161616FF", "force_color_match": True}],
  615. )
  616. power_on = await _run(
  617. queue_db,
  618. PrintScheduler(),
  619. power_on=AsyncMock(return_value=True),
  620. statuses={1: _external("PLA", "161616FF"), 2: _external("PLA", "FFFFFFFF")},
  621. remembered={1: _external("PLA", "FFFFFFFF"), 2: _external("PLA", "161616FF")},
  622. )
  623. assert _woken_printer_ids(power_on) == [1]