test_scheduler_keep_warm.py 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899
  1. """Tests for the keep-bed-warm loop that fires between queued prints.
  2. `_apply_keep_warm()` is the per-tick helper that holds the bed hot on a
  3. printer sitting in FINISH awaiting a plate-clear — so the chamber does not
  4. cool down between back-to-back chamber-heated prints.
  5. The hold temperature is `queue_keep_warm_bed_temp` (default 90 °C), raised to
  6. the next item's own parsed bed_temperature when that is higher. The bed is
  7. the chamber's heat source here, not a print surface, so an item with no
  8. bed_temperature metadata still gets a hold — what gates the feature is
  9. whether the next print needs chamber heat.
  10. Gates the whole block on three settings AND-ed together
  11. (`queue_keep_bed_warm`, `require_plate_clear`, `preheat_enabled`) so a user
  12. who turns off the plate-clear or preheat gate stops holding heat without
  13. having to also toggle keep-warm. Bounded by `queue_keep_warm_max_minutes`, and
  14. skips the MQTT publish when the firmware already has the target.
  15. """
  16. from types import SimpleNamespace
  17. from unittest.mock import AsyncMock, MagicMock, patch
  18. import pytest
  19. from backend.app.services.print_scheduler import (
  20. PrintScheduler,
  21. _KeepWarmEntry,
  22. )
  23. PRINTER_ID = 7
  24. # Archive bed temperature of the next queued item. Deliberately ABOVE HOLD_TEMP
  25. # so the default fixtures exercise the "item's own bed temp wins" branch.
  26. BED_TARGET = 100
  27. # The configured `queue_keep_warm_bed_temp` floor used by `_run`.
  28. HOLD_TEMP = 90
  29. # The configured `queue_keep_warm_max_minutes` used by `_run`, in minutes and
  30. # the seconds the scheduler derives from it.
  31. MAX_HOLD_MINUTES = 120
  32. MAX_HOLD_SECONDS = MAX_HOLD_MINUTES * 60
  33. NOW = 10_000.0
  34. @pytest.fixture
  35. def scheduler():
  36. return PrintScheduler()
  37. def _make_item(
  38. item_id: int = 1,
  39. printer_id: int = PRINTER_ID,
  40. bed_temperature: int | None = BED_TARGET,
  41. preheat_chamber_target_override: int | None = 60,
  42. ):
  43. """Build a queue-item-shaped namespace with an archive.
  44. ``preheat_chamber_target_override`` at a non-zero int makes the chamber-
  45. needed check pass without any AMS-derivation mocking. Set to ``None`` in
  46. tests that specifically want to exercise the derivation branch.
  47. """
  48. archive = SimpleNamespace(bed_temperature=bed_temperature)
  49. return SimpleNamespace(
  50. id=item_id,
  51. printer_id=printer_id,
  52. archive=archive,
  53. preheat_chamber_target_override=preheat_chamber_target_override,
  54. )
  55. def _make_state(*, state="FINISH", bed_target=0.0, chamber=55.0):
  56. """PrinterState-shaped namespace with just what the keep-warm loop reads."""
  57. return SimpleNamespace(
  58. state=state,
  59. temperatures={"bed_target": bed_target, "chamber": chamber},
  60. raw_data={},
  61. )
  62. def _make_client():
  63. client = MagicMock()
  64. client.set_bed_temperature = MagicMock(return_value=True)
  65. return client
  66. def _bool_settings(**overrides):
  67. """AsyncMock side_effect returning per-key bool values.
  68. Defaults enable the full stack; pass ``queue_keep_bed_warm=False`` etc
  69. to switch individual gates off.
  70. """
  71. defaults = {
  72. "queue_keep_bed_warm": True,
  73. "preheat_enabled": True,
  74. }
  75. defaults.update(overrides)
  76. return AsyncMock(side_effect=lambda _db, key, default: defaults.get(key, default))
  77. def _int_settings(hold_temp, max_hold_minutes):
  78. return {
  79. "queue_keep_warm_bed_temp": hold_temp,
  80. "queue_keep_warm_max_minutes": max_hold_minutes,
  81. }
  82. async def _run(
  83. scheduler,
  84. *,
  85. items=None,
  86. dispatch_ids=None,
  87. busy_printers=None,
  88. require_plate_clear=True,
  89. bool_settings=None,
  90. hold_temp=HOLD_TEMP,
  91. max_hold_minutes=MAX_HOLD_MINUTES,
  92. ):
  93. """Invoke `_apply_keep_warm` with sensible defaults and standard patches."""
  94. if items is None:
  95. items = [_make_item()]
  96. if dispatch_ids is None:
  97. dispatch_ids = []
  98. if busy_printers is None:
  99. busy_printers = {PRINTER_ID}
  100. if bool_settings is None:
  101. bool_settings = _bool_settings()
  102. db = AsyncMock()
  103. with (
  104. patch.object(scheduler, "_get_bool_setting", bool_settings),
  105. patch.object(
  106. scheduler,
  107. "_get_int_setting",
  108. AsyncMock(
  109. side_effect=lambda _db, key, default=0: _int_settings(hold_temp, max_hold_minutes).get(key, default)
  110. ),
  111. ),
  112. patch("backend.app.services.print_scheduler.time") as t,
  113. ):
  114. t.monotonic.return_value = NOW
  115. await scheduler._apply_keep_warm(db, items, dispatch_ids, busy_printers, require_plate_clear)
  116. # ---------------------------------------------------------------------------
  117. # Gating: all three settings AND-ed together
  118. # ---------------------------------------------------------------------------
  119. @pytest.mark.asyncio
  120. async def test_keep_warm_skips_when_feature_disabled(scheduler):
  121. """queue_keep_bed_warm=False → no MQTT publish, no state change."""
  122. client = _make_client()
  123. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  124. pm.get_status.return_value = _make_state()
  125. pm.get_client.return_value = client
  126. await _run(scheduler, bool_settings=_bool_settings(queue_keep_bed_warm=False))
  127. client.set_bed_temperature.assert_not_called()
  128. assert PRINTER_ID not in scheduler._keep_warm
  129. @pytest.mark.asyncio
  130. async def test_keep_warm_skips_when_require_plate_clear_off(scheduler):
  131. """require_plate_clear=False → skip even if keep-warm and preheat are on."""
  132. client = _make_client()
  133. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  134. pm.get_status.return_value = _make_state()
  135. pm.get_client.return_value = client
  136. await _run(scheduler, require_plate_clear=False)
  137. client.set_bed_temperature.assert_not_called()
  138. @pytest.mark.asyncio
  139. async def test_keep_warm_skips_when_preheat_disabled(scheduler):
  140. """preheat_enabled=False → skip regardless of the toggle."""
  141. client = _make_client()
  142. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  143. pm.get_status.return_value = _make_state()
  144. pm.get_client.return_value = client
  145. await _run(scheduler, bool_settings=_bool_settings(preheat_enabled=False))
  146. client.set_bed_temperature.assert_not_called()
  147. # ---------------------------------------------------------------------------
  148. # Per-printer skip conditions
  149. # ---------------------------------------------------------------------------
  150. @pytest.mark.asyncio
  151. async def test_keep_warm_skips_when_printer_not_in_finish(scheduler):
  152. """Only FINISH printers keep warm — a printer that's still printing is not held."""
  153. client = _make_client()
  154. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  155. pm.get_status.return_value = _make_state(state="RUNNING")
  156. pm.get_client.return_value = client
  157. await _run(scheduler)
  158. client.set_bed_temperature.assert_not_called()
  159. @pytest.mark.asyncio
  160. async def test_keep_warm_holds_configured_temp_when_archive_has_no_bed_temp(scheduler):
  161. """No parsed bed_temperature → still hold, at the configured keep-warm temp.
  162. The bed is the chamber's heat source during the hold, not a print surface,
  163. so missing slicer metadata must not disable the feature. OrcaSlicer
  164. `.gcode.3mf` exports parse without a bed temperature and would otherwise
  165. never keep warm even though their filament requires chamber heat.
  166. """
  167. client = _make_client()
  168. item = _make_item(bed_temperature=None)
  169. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  170. pm.get_status.return_value = _make_state()
  171. pm.get_client.return_value = client
  172. await _run(scheduler, items=[item])
  173. client.set_bed_temperature.assert_called_once_with(HOLD_TEMP)
  174. assert scheduler._keep_warm[PRINTER_ID].held_target == HOLD_TEMP
  175. @pytest.mark.asyncio
  176. async def test_keep_warm_uses_item_bed_temp_when_higher_than_configured(scheduler):
  177. """Item's own bed temp (100) > configured hold (90) → hold at 100.
  178. The hold must never run cooler than the print itself will, or the chamber
  179. would dip right before dispatch.
  180. """
  181. client = _make_client()
  182. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  183. pm.get_status.return_value = _make_state()
  184. pm.get_client.return_value = client
  185. await _run(scheduler, items=[_make_item(bed_temperature=100)])
  186. client.set_bed_temperature.assert_called_once_with(100)
  187. @pytest.mark.asyncio
  188. async def test_keep_warm_uses_configured_temp_when_item_bed_temp_lower(scheduler):
  189. """Item's bed temp (60) < configured hold (90) → hold at 90.
  190. A cool-plate ASA profile still needs the chamber hot; the floor wins.
  191. """
  192. client = _make_client()
  193. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  194. pm.get_status.return_value = _make_state()
  195. pm.get_client.return_value = client
  196. await _run(scheduler, items=[_make_item(bed_temperature=60)])
  197. client.set_bed_temperature.assert_called_once_with(HOLD_TEMP)
  198. @pytest.mark.asyncio
  199. async def test_keep_warm_honours_custom_configured_hold_temp(scheduler):
  200. """`queue_keep_warm_bed_temp` is read from settings, not hard-coded."""
  201. client = _make_client()
  202. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  203. pm.get_status.return_value = _make_state()
  204. pm.get_client.return_value = client
  205. await _run(scheduler, items=[_make_item(bed_temperature=None)], hold_temp=105)
  206. client.set_bed_temperature.assert_called_once_with(105)
  207. @pytest.mark.asyncio
  208. async def test_keep_warm_skips_when_no_client(scheduler):
  209. """No live client (e.g. printer just deregistered) → skip silently."""
  210. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  211. pm.get_status.return_value = _make_state()
  212. pm.get_client.return_value = None
  213. await _run(scheduler)
  214. assert PRINTER_ID not in scheduler._keep_warm
  215. @pytest.mark.asyncio
  216. async def test_keep_warm_skips_when_chamber_override_zero(scheduler):
  217. """Per-item override of 0 → 'no chamber even if filament wants it' → skip."""
  218. client = _make_client()
  219. item = _make_item(preheat_chamber_target_override=0)
  220. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  221. pm.get_status.return_value = _make_state()
  222. pm.get_client.return_value = client
  223. await _run(scheduler, items=[item])
  224. client.set_bed_temperature.assert_not_called()
  225. @pytest.mark.asyncio
  226. async def test_keep_warm_skips_when_dispatched_this_cycle(scheduler):
  227. """Printers being dispatched this tick are excluded — _preheat_and_soak owns their bed."""
  228. client = _make_client()
  229. item = _make_item(item_id=42)
  230. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  231. pm.get_status.return_value = _make_state()
  232. pm.get_client.return_value = client
  233. await _run(scheduler, items=[item], dispatch_ids=[42])
  234. client.set_bed_temperature.assert_not_called()
  235. @pytest.mark.asyncio
  236. async def test_keep_warm_skips_when_chamber_derivation_yields_zero(scheduler):
  237. """No per-item override + _derive_chamber_target returns 0 → skip."""
  238. client = _make_client()
  239. item = _make_item(preheat_chamber_target_override=None)
  240. with (
  241. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  242. patch.object(scheduler, "_get_preheat_filament_targets", AsyncMock(return_value={})),
  243. patch.object(scheduler, "_get_printer", AsyncMock(return_value=SimpleNamespace(id=PRINTER_ID, model="H2D"))),
  244. patch.object(scheduler, "_derive_chamber_target", return_value=0),
  245. ):
  246. pm.get_status.return_value = _make_state()
  247. pm.get_client.return_value = client
  248. await _run(scheduler, items=[item])
  249. client.set_bed_temperature.assert_not_called()
  250. # ---------------------------------------------------------------------------
  251. # Happy path
  252. # ---------------------------------------------------------------------------
  253. @pytest.mark.asyncio
  254. async def test_keep_warm_publishes_bed_target(scheduler):
  255. """Full-stack happy path: gates on, printer in FINISH, chamber needed → M140 sent."""
  256. client = _make_client()
  257. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  258. pm.get_status.return_value = _make_state() # bed_target=0 → publish fires
  259. pm.get_client.return_value = client
  260. await _run(scheduler)
  261. client.set_bed_temperature.assert_called_once_with(BED_TARGET)
  262. assert PRINTER_ID in scheduler._keep_warm
  263. entry = scheduler._keep_warm[PRINTER_ID]
  264. assert entry.held_target == BED_TARGET
  265. assert entry.expired is False
  266. # ---------------------------------------------------------------------------
  267. # Idempotence guard
  268. # ---------------------------------------------------------------------------
  269. @pytest.mark.asyncio
  270. async def test_keep_warm_skips_when_firmware_already_at_target(scheduler):
  271. """state.temperatures['bed_target'] already equals the desired target → no publish."""
  272. client = _make_client()
  273. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  274. pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
  275. pm.get_client.return_value = client
  276. await _run(scheduler)
  277. client.set_bed_temperature.assert_not_called()
  278. # ---------------------------------------------------------------------------
  279. # Max-duration timeout — publish bed → 0 once, latch expired, do NOT re-arm
  280. # ---------------------------------------------------------------------------
  281. @pytest.mark.asyncio
  282. async def test_keep_warm_publishes_bed_off_and_latches_on_timeout(scheduler):
  283. """After MAX_HOLD_SECONDS: publish bed → 0, latch expired, keep the entry.
  284. The old behaviour popped the entry — but that meant the next tick's
  285. ``setdefault`` re-seeded ``started`` and the 2 h window restarted forever.
  286. The entry must stay so subsequent ticks skip re-engagement.
  287. """
  288. scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
  289. started=NOW - (MAX_HOLD_SECONDS + 1),
  290. held_target=BED_TARGET,
  291. )
  292. client = _make_client()
  293. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  294. # Firmware still holds our target → bed-off publish fires.
  295. pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
  296. pm.get_client.return_value = client
  297. await _run(scheduler)
  298. client.set_bed_temperature.assert_called_once_with(0)
  299. assert PRINTER_ID in scheduler._keep_warm
  300. assert scheduler._keep_warm[PRINTER_ID].expired is True
  301. @pytest.mark.asyncio
  302. async def test_keep_warm_timeout_does_not_rearm_on_next_tick(scheduler):
  303. """Multi-tick regression guard: the tick AFTER a timeout must NOT re-engage.
  304. This is the bug the review flagged: popping on timeout let the next
  305. tick's ``setdefault(pid, now_mono)`` re-seed the clock, restarting the
  306. 2 h window. Latching ``expired=True`` on the entry (kept in place)
  307. prevents that.
  308. """
  309. original_started = NOW - (MAX_HOLD_SECONDS + 1)
  310. scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
  311. started=original_started,
  312. held_target=BED_TARGET,
  313. expired=True, # already latched by previous tick's timeout
  314. )
  315. client = _make_client()
  316. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  317. pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
  318. pm.get_client.return_value = client
  319. await _run(scheduler)
  320. # No re-engagement, no bed-off (already sent in the prior tick), and the
  321. # entry keeps its ORIGINAL started timestamp — no clock re-seed.
  322. client.set_bed_temperature.assert_not_called()
  323. assert scheduler._keep_warm[PRINTER_ID].started == original_started
  324. assert scheduler._keep_warm[PRINTER_ID].expired is True
  325. @pytest.mark.asyncio
  326. async def test_keep_warm_timeout_skips_bed_off_when_firmware_target_changed(scheduler):
  327. """Firmware bed_target != held_target on timeout → don't clobber user's change."""
  328. scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
  329. started=NOW - (MAX_HOLD_SECONDS + 1),
  330. held_target=BED_TARGET,
  331. )
  332. client = _make_client()
  333. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  334. # Firmware target no longer matches held_target.
  335. pm.get_status.return_value = _make_state(bed_target=42.0)
  336. pm.get_client.return_value = client
  337. await _run(scheduler)
  338. client.set_bed_temperature.assert_not_called()
  339. # Latch still fires so we don't re-engage next tick.
  340. assert scheduler._keep_warm[PRINTER_ID].expired is True
  341. @pytest.mark.asyncio
  342. async def test_keep_warm_starts_timer_on_first_tick(scheduler):
  343. """First tick for a printer creates a _KeepWarmEntry with started=NOW."""
  344. client = _make_client()
  345. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  346. pm.get_status.return_value = _make_state()
  347. pm.get_client.return_value = client
  348. await _run(scheduler)
  349. assert PRINTER_ID in scheduler._keep_warm
  350. entry = scheduler._keep_warm[PRINTER_ID]
  351. assert entry.started == NOW
  352. assert entry.held_target == BED_TARGET
  353. assert entry.expired is False
  354. @pytest.mark.asyncio
  355. async def test_keep_warm_preserves_existing_timer(scheduler):
  356. """Subsequent ticks must NOT reset started — otherwise timeout never fires."""
  357. started_earlier = NOW - 3600
  358. scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
  359. started=started_earlier,
  360. held_target=BED_TARGET,
  361. )
  362. client = _make_client()
  363. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  364. # Firmware already at our target → idempotence skips the publish;
  365. # the entry is preserved as-is.
  366. pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
  367. pm.get_client.return_value = client
  368. await _run(scheduler)
  369. assert scheduler._keep_warm[PRINTER_ID].started == started_earlier
  370. # ---------------------------------------------------------------------------
  371. # Release sweep — bed → 0 when printer leaves the candidate set / gate off
  372. # ---------------------------------------------------------------------------
  373. @pytest.mark.asyncio
  374. async def test_keep_warm_releases_bed_when_printer_leaves_candidate_set(scheduler):
  375. """Owned printer no longer in candidates → publish bed → 0, drop entry."""
  376. scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
  377. started=NOW - 300,
  378. held_target=BED_TARGET,
  379. )
  380. client = _make_client()
  381. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  382. pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
  383. pm.get_client.return_value = client
  384. await _run(scheduler, items=[], busy_printers=set())
  385. client.set_bed_temperature.assert_called_once_with(0)
  386. assert PRINTER_ID not in scheduler._keep_warm
  387. @pytest.mark.asyncio
  388. async def test_keep_warm_release_skipped_when_printer_was_dispatched(scheduler):
  389. """Dispatched printers exit candidates but _preheat_and_soak owns the bed — no bed-off."""
  390. scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
  391. started=NOW - 300,
  392. held_target=BED_TARGET,
  393. )
  394. item = _make_item(item_id=42)
  395. client = _make_client()
  396. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  397. pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
  398. pm.get_client.return_value = client
  399. # Item 42 is being dispatched this tick — release must NOT publish.
  400. await _run(scheduler, items=[item], dispatch_ids=[42])
  401. client.set_bed_temperature.assert_not_called()
  402. assert PRINTER_ID not in scheduler._keep_warm # tracking dropped either way
  403. @pytest.mark.asyncio
  404. async def test_keep_warm_hands_bed_ownership_to_preheat_pin_on_dispatch(scheduler):
  405. """Handing a hot bed to dispatch must register it for preheat rollback.
  406. Keep-warm stops tracking the printer the moment it is dispatched, and
  407. `_preheat_and_soak` may never claim the bed itself (it returns early when
  408. the item has no bed_temperature metadata). Without this transfer, an
  409. aborted dispatch — failed upload, cancelled item — would leave the bed hot
  410. with no owner and nothing to turn it off.
  411. """
  412. scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(started=NOW - 300, held_target=BED_TARGET)
  413. client = _make_client()
  414. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  415. pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
  416. pm.get_client.return_value = client
  417. await _run(scheduler, items=[_make_item(item_id=42)], dispatch_ids=[42])
  418. assert "bed" in scheduler._preheat_pin.get(PRINTER_ID, set())
  419. @pytest.mark.asyncio
  420. async def test_keep_warm_release_does_not_touch_preheat_pin(scheduler):
  421. """A genuine release (not a dispatch) turns the bed off — no pin entry needed."""
  422. scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(started=NOW - 300, held_target=BED_TARGET)
  423. client = _make_client()
  424. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  425. pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
  426. pm.get_client.return_value = client
  427. await _run(scheduler, items=[], busy_printers=set())
  428. client.set_bed_temperature.assert_called_once_with(0)
  429. assert PRINTER_ID not in scheduler._preheat_pin
  430. @pytest.mark.asyncio
  431. async def test_keep_warm_release_skipped_when_firmware_target_changed(scheduler):
  432. """Firmware bed_target != held_target on release → don't clobber user's change."""
  433. scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
  434. started=NOW - 300,
  435. held_target=BED_TARGET,
  436. )
  437. client = _make_client()
  438. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  439. pm.get_status.return_value = _make_state(bed_target=42.0)
  440. pm.get_client.return_value = client
  441. await _run(scheduler, items=[], busy_printers=set())
  442. client.set_bed_temperature.assert_not_called()
  443. assert PRINTER_ID not in scheduler._keep_warm # tracking still dropped
  444. @pytest.mark.asyncio
  445. async def test_keep_warm_release_fires_when_feature_toggled_off_mid_hold(scheduler):
  446. """queue_keep_bed_warm turned off while a printer is owned → release still fires."""
  447. scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
  448. started=NOW - 300,
  449. held_target=BED_TARGET,
  450. )
  451. client = _make_client()
  452. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  453. pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
  454. pm.get_client.return_value = client
  455. await _run(scheduler, bool_settings=_bool_settings(queue_keep_bed_warm=False))
  456. client.set_bed_temperature.assert_called_once_with(0)
  457. assert PRINTER_ID not in scheduler._keep_warm
  458. @pytest.mark.asyncio
  459. async def test_keep_warm_release_fires_when_plate_clear_toggled_off_mid_hold(scheduler):
  460. """require_plate_clear=False mid-hold → release still fires."""
  461. scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
  462. started=NOW - 300,
  463. held_target=BED_TARGET,
  464. )
  465. client = _make_client()
  466. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  467. pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
  468. pm.get_client.return_value = client
  469. await _run(scheduler, require_plate_clear=False)
  470. client.set_bed_temperature.assert_called_once_with(0)
  471. assert PRINTER_ID not in scheduler._keep_warm
  472. # ---------------------------------------------------------------------------
  473. # Candidate-set eviction of stale state
  474. # ---------------------------------------------------------------------------
  475. @pytest.mark.asyncio
  476. async def test_keep_warm_keeps_entry_when_printer_is_unreachable(scheduler):
  477. """An unreachable printer keeps its entry so a later tick can still release it.
  478. Printer 99 left the candidate set, but `get_status` returns None — it is
  479. briefly offline, not gone. Its bed may still be hot, so dropping the entry
  480. here would stop the max-duration timeout applying and leave nothing
  481. tracking it. The entry is kept and the release retried later;
  482. `_sample_chamber_temps` is the only place that gives up, once the printer
  483. has left the manager entirely.
  484. """
  485. scheduler._keep_warm[99] = _KeepWarmEntry(started=NOW - 60, held_target=BED_TARGET)
  486. scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(started=NOW - 60, held_target=BED_TARGET)
  487. client = _make_client()
  488. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  489. pm.get_status.side_effect = lambda pid: _make_state(bed_target=float(BED_TARGET)) if pid == PRINTER_ID else None
  490. pm.get_client.side_effect = lambda pid: client if pid == PRINTER_ID else None
  491. await _run(scheduler, busy_printers={PRINTER_ID})
  492. assert 99 in scheduler._keep_warm, "unreachable printer must stay tracked"
  493. assert PRINTER_ID in scheduler._keep_warm
  494. @pytest.mark.asyncio
  495. async def test_keep_warm_release_retries_after_a_failed_publish(scheduler):
  496. """A failed bed-off keeps the entry so the next tick tries again."""
  497. scheduler._keep_warm[99] = _KeepWarmEntry(started=NOW - 60, held_target=BED_TARGET)
  498. client = _make_client()
  499. client.set_bed_temperature.side_effect = RuntimeError("mqtt down")
  500. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  501. pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
  502. pm.get_client.return_value = client
  503. await _run(scheduler, items=[], busy_printers=set())
  504. client.set_bed_temperature.assert_called_once_with(0)
  505. assert 99 in scheduler._keep_warm, "a failed release must not silently drop the entry"
  506. def test_sample_chamber_temps_evicts_preheat_pin_for_removed_printer(scheduler):
  507. """Per-printer preheat state is evicted with the rest when a printer disappears."""
  508. scheduler._preheat_pin[99] = {"bed"}
  509. scheduler._preheat_pin[PRINTER_ID] = {"bed"}
  510. with (
  511. patch("backend.app.services.print_scheduler.time") as t,
  512. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  513. ):
  514. t.monotonic.return_value = NOW
  515. pm.get_all_statuses.return_value = {PRINTER_ID: SimpleNamespace(connected=True, temperatures={"chamber": 40.0})}
  516. scheduler._sample_chamber_temps()
  517. assert 99 not in scheduler._preheat_pin
  518. assert PRINTER_ID in scheduler._preheat_pin
  519. # ---------------------------------------------------------------------------
  520. # Lazy filament target fetch
  521. # ---------------------------------------------------------------------------
  522. @pytest.mark.asyncio
  523. async def test_keep_warm_does_not_fetch_filament_targets_when_all_overrides(scheduler):
  524. """Per-item overrides supply chamber_needed → skip the DB round-trip."""
  525. client = _make_client()
  526. fetch_targets = AsyncMock(return_value={})
  527. with (
  528. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  529. patch.object(scheduler, "_get_preheat_filament_targets", fetch_targets),
  530. ):
  531. pm.get_status.return_value = _make_state()
  532. pm.get_client.return_value = client
  533. # Item has an explicit chamber override, so derivation is not needed.
  534. await _run(scheduler)
  535. fetch_targets.assert_not_called()
  536. @pytest.mark.asyncio
  537. async def test_keep_warm_fetches_filament_targets_once_per_tick(scheduler):
  538. """When derivation is needed for multiple printers, only fetch targets once."""
  539. items = [
  540. _make_item(item_id=1, printer_id=1, preheat_chamber_target_override=None),
  541. _make_item(item_id=2, printer_id=2, preheat_chamber_target_override=None),
  542. ]
  543. client1 = _make_client()
  544. client2 = _make_client()
  545. fetch_targets = AsyncMock(return_value={"ASA": 60})
  546. with (
  547. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  548. patch.object(scheduler, "_get_preheat_filament_targets", fetch_targets),
  549. patch.object(scheduler, "_get_printer", AsyncMock(return_value=SimpleNamespace(id=1, model="H2D"))),
  550. patch.object(scheduler, "_derive_chamber_target", return_value=60),
  551. ):
  552. pm.get_status.return_value = _make_state()
  553. pm.get_client.side_effect = lambda pid: {1: client1, 2: client2}[pid]
  554. await _run(scheduler, items=items, busy_printers={1, 2})
  555. assert fetch_targets.call_count == 1
  556. @pytest.mark.asyncio
  557. async def test_keep_warm_timeout_honours_configured_minutes(scheduler):
  558. """A 15-minute limit stops the hold at 15 minutes, not at the default.
  559. The whole point of `queue_keep_warm_max_minutes`: a user who does not want
  560. a bed sitting hot while they are away sets a short window, and the heaters
  561. go off when it elapses.
  562. """
  563. scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
  564. started=NOW - (15 * 60 + 1),
  565. held_target=BED_TARGET,
  566. )
  567. client = _make_client()
  568. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  569. pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
  570. pm.get_client.return_value = client
  571. await _run(scheduler, max_hold_minutes=15)
  572. client.set_bed_temperature.assert_called_once_with(0)
  573. assert scheduler._keep_warm[PRINTER_ID].expired is True
  574. @pytest.mark.asyncio
  575. async def test_keep_warm_holds_within_configured_window(scheduler):
  576. """Just inside the configured window the hold continues untouched."""
  577. scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
  578. started=NOW - (15 * 60 - 60),
  579. held_target=BED_TARGET,
  580. )
  581. client = _make_client()
  582. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  583. # Firmware already holds the target, so an untouched hold means no publish.
  584. pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
  585. pm.get_client.return_value = client
  586. await _run(scheduler, max_hold_minutes=15)
  587. client.set_bed_temperature.assert_not_called()
  588. assert scheduler._keep_warm[PRINTER_ID].expired is False
  589. # ---------------------------------------------------------------------------
  590. # Handing the hold to the preheat pin, and getting it back when dispatch bails
  591. # ---------------------------------------------------------------------------
  592. #
  593. # `_sweep_keep_warm` gives up the keep-warm entry for a printer being dispatched
  594. # this tick and pins "bed" instead, on the promise that `_dispatch_one` unwinds
  595. # it on any non-success exit. Two of `_dispatch_one`'s exits used to break that
  596. # promise by returning before the `finally` could run, which left the bed hot
  597. # with the entry already gone -- so neither the max-duration cap nor
  598. # `_release_keep_warm` applied, and on the printer's last pending item nothing
  599. # would ever switch it off.
  600. def test_dispatch_handover_records_the_held_target(scheduler):
  601. """The pin remembers what keep-warm was holding, not just that it held."""
  602. scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(started=NOW, held_target=HOLD_TEMP)
  603. scheduler._sweep_keep_warm(active_candidates=set(), dispatched={PRINTER_ID})
  604. assert PRINTER_ID not in scheduler._keep_warm
  605. assert scheduler._preheat_pin[PRINTER_ID] == {"bed"}
  606. assert scheduler._preheat_pin_bed[PRINTER_ID] == HOLD_TEMP
  607. @pytest.mark.asyncio
  608. async def test_unclaimable_item_releases_the_handed_over_bed(scheduler):
  609. """A cancel landing between selection and the claim must not strand the bed.
  610. `_claim_for_dispatch` returning False exits before the try/finally, so the
  611. rollback has to fire on that path explicitly.
  612. """
  613. scheduler._preheat_pin[PRINTER_ID] = {"bed"}
  614. scheduler._preheat_pin_bed[PRINTER_ID] = HOLD_TEMP
  615. client = MagicMock()
  616. with (
  617. patch("backend.app.services.print_scheduler.async_session") as session_factory,
  618. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  619. patch.object(scheduler, "_claim_for_dispatch", AsyncMock(return_value=False)),
  620. ):
  621. session_factory.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
  622. session_factory.return_value.__aexit__ = AsyncMock(return_value=False)
  623. pm.get_client.return_value = client
  624. pm.get_status.return_value = SimpleNamespace(temperatures={"bed_target": HOLD_TEMP})
  625. await scheduler._dispatch_one(42, selected_printer_id=PRINTER_ID)
  626. client.set_bed_temperature.assert_called_once_with(0)
  627. assert PRINTER_ID not in scheduler._preheat_pin
  628. assert PRINTER_ID not in scheduler._preheat_pin_bed
  629. @pytest.mark.asyncio
  630. async def test_unclaimable_item_without_a_known_printer_is_a_noop(scheduler):
  631. """Direct callers that pass no printer keep the old behaviour."""
  632. scheduler._preheat_pin[PRINTER_ID] = {"bed"}
  633. client = MagicMock()
  634. with (
  635. patch("backend.app.services.print_scheduler.async_session") as session_factory,
  636. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  637. patch.object(scheduler, "_claim_for_dispatch", AsyncMock(return_value=False)),
  638. ):
  639. session_factory.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
  640. session_factory.return_value.__aexit__ = AsyncMock(return_value=False)
  641. pm.get_client.return_value = client
  642. await scheduler._dispatch_one(42)
  643. client.set_bed_temperature.assert_not_called()
  644. assert scheduler._preheat_pin[PRINTER_ID] == {"bed"}
  645. @pytest.mark.asyncio
  646. async def test_vanished_item_releases_the_handed_over_bed(scheduler):
  647. """The row disappearing after a successful claim takes the same exit.
  648. That return is inside the try, but `item_printer_id` used to still be None
  649. there, so the rollback was skipped by its own guard.
  650. """
  651. scheduler._preheat_pin[PRINTER_ID] = {"bed"}
  652. scheduler._preheat_pin_bed[PRINTER_ID] = HOLD_TEMP
  653. client = MagicMock()
  654. item_db = MagicMock()
  655. item_db.get = AsyncMock(return_value=None)
  656. with (
  657. patch("backend.app.services.print_scheduler.async_session") as session_factory,
  658. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  659. patch.object(scheduler, "_claim_for_dispatch", AsyncMock(return_value=True)),
  660. patch.object(scheduler, "_clear_dispatch_claim", AsyncMock()),
  661. patch.object(scheduler, "_release_unconfirmed_budget_reservation", AsyncMock()),
  662. ):
  663. session_factory.return_value.__aenter__ = AsyncMock(return_value=item_db)
  664. session_factory.return_value.__aexit__ = AsyncMock(return_value=False)
  665. pm.get_client.return_value = client
  666. pm.get_status.return_value = SimpleNamespace(temperatures={"bed_target": HOLD_TEMP})
  667. await scheduler._dispatch_one(42, selected_printer_id=PRINTER_ID)
  668. client.set_bed_temperature.assert_called_once_with(0)
  669. assert PRINTER_ID not in scheduler._preheat_pin
  670. # ---------------------------------------------------------------------------
  671. # Rollback leaves a bed somebody else now owns alone
  672. # ---------------------------------------------------------------------------
  673. def test_rollback_leaves_a_reassigned_bed_alone(scheduler):
  674. """Firmware reports a target we did not set → the bed belongs to someone else."""
  675. scheduler._preheat_pin[PRINTER_ID] = {"bed"}
  676. scheduler._preheat_pin_bed[PRINTER_ID] = HOLD_TEMP
  677. client = MagicMock()
  678. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  679. pm.get_client.return_value = client
  680. pm.get_status.return_value = SimpleNamespace(temperatures={"bed_target": 45})
  681. scheduler._rollback_preheat_pin(item_id=42, printer_id=PRINTER_ID)
  682. client.set_bed_temperature.assert_not_called()
  683. assert PRINTER_ID not in scheduler._preheat_pin
  684. assert PRINTER_ID not in scheduler._preheat_pin_bed
  685. def test_rollback_switches_off_when_the_target_still_matches(scheduler):
  686. scheduler._preheat_pin[PRINTER_ID] = {"bed"}
  687. scheduler._preheat_pin_bed[PRINTER_ID] = HOLD_TEMP
  688. client = MagicMock()
  689. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  690. pm.get_client.return_value = client
  691. pm.get_status.return_value = SimpleNamespace(temperatures={"bed_target": HOLD_TEMP})
  692. scheduler._rollback_preheat_pin(item_id=42, printer_id=PRINTER_ID)
  693. client.set_bed_temperature.assert_called_once_with(0)
  694. def test_rollback_switches_off_when_the_target_cannot_be_read(scheduler):
  695. """No evidence is not evidence of reassignment -- err towards a cold bed."""
  696. scheduler._preheat_pin[PRINTER_ID] = {"bed"}
  697. scheduler._preheat_pin_bed[PRINTER_ID] = HOLD_TEMP
  698. client = MagicMock()
  699. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  700. pm.get_client.return_value = client
  701. pm.get_status.return_value = None
  702. scheduler._rollback_preheat_pin(item_id=42, printer_id=PRINTER_ID)
  703. client.set_bed_temperature.assert_called_once_with(0)
  704. def test_unregistered_printer_evicts_the_recorded_bed_target(scheduler):
  705. scheduler._preheat_pin[PRINTER_ID] = {"bed"}
  706. scheduler._preheat_pin_bed[PRINTER_ID] = HOLD_TEMP
  707. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  708. pm.get_all_statuses.return_value = {}
  709. scheduler._sample_chamber_temps()
  710. assert PRINTER_ID not in scheduler._preheat_pin
  711. assert PRINTER_ID not in scheduler._preheat_pin_bed