test_scheduler_auto_drying.py 108 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497
  1. """Tests for the auto-drying feature in the print scheduler.
  2. Covers:
  3. - Conservative drying parameter selection (mixed filaments)
  4. - Drying preset loading (user-configured vs defaults)
  5. - Auto-drying lifecycle: start, humidity stop, minimum drying time
  6. - Auto-drying stop conditions: feature disabled, no scheduled items, per-printer
  7. - Sync drying state after restart
  8. """
  9. import re
  10. import time
  11. from datetime import datetime, timedelta, timezone
  12. from unittest.mock import AsyncMock, MagicMock, patch
  13. import pytest
  14. from backend.app.services.print_scheduler import (
  15. AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES,
  16. AUTO_DRY_REARM_COOLDOWN_SECONDS,
  17. AUTO_DRY_SUSTAINED_GAP_FLOOR_SECONDS,
  18. PrintScheduler,
  19. )
  20. class TestConservativeDryingParams:
  21. """Test _get_conservative_drying_params — picks safest temp/duration for mixed filaments."""
  22. @pytest.fixture
  23. def scheduler(self):
  24. return PrintScheduler()
  25. def test_single_filament_pla(self, scheduler):
  26. """Single PLA tray uses PLA preset."""
  27. trays = [{"tray_type": "PLA"}]
  28. presets = PrintScheduler.DEFAULT_DRYING_PRESETS
  29. result = scheduler._get_conservative_drying_params(trays, "n3f", presets)
  30. assert result == (45, 12, "PLA")
  31. def test_mixed_filaments_lowest_temp(self, scheduler):
  32. """Mixed PLA + ABS: should use PLA's 45°C (lowest), ABS's 12h (longest for n3f)."""
  33. trays = [{"tray_type": "PLA"}, {"tray_type": "ABS"}]
  34. presets = PrintScheduler.DEFAULT_DRYING_PRESETS
  35. result = scheduler._get_conservative_drying_params(trays, "n3f", presets)
  36. temp, hours, _ = result
  37. assert temp == 45 # PLA is lowest
  38. assert hours == 12
  39. def test_mixed_filaments_longest_duration(self, scheduler):
  40. """Mixed ABS (8h) + PVA (18h) on n3s: should use longest duration."""
  41. trays = [{"tray_type": "ABS"}, {"tray_type": "PVA"}]
  42. presets = PrintScheduler.DEFAULT_DRYING_PRESETS
  43. result = scheduler._get_conservative_drying_params(trays, "n3s", presets)
  44. temp, hours, _ = result
  45. assert temp == 80 # ABS n3s=80, PVA n3s=85 → lowest=80
  46. assert hours == 18 # ABS n3s_hours=8, PVA n3s_hours=18 → longest=18
  47. def test_empty_trays_returns_none(self, scheduler):
  48. """No loaded trays returns None."""
  49. result = scheduler._get_conservative_drying_params([], "n3f", PrintScheduler.DEFAULT_DRYING_PRESETS)
  50. assert result is None
  51. def test_unknown_filament_skipped(self, scheduler):
  52. """Unknown filament types are ignored."""
  53. trays = [{"tray_type": "EXOTIC_WOOD"}]
  54. result = scheduler._get_conservative_drying_params(trays, "n3f", PrintScheduler.DEFAULT_DRYING_PRESETS)
  55. assert result is None
  56. def test_filament_type_normalization(self, scheduler):
  57. """'PLA Basic' should normalize to 'PLA'."""
  58. trays = [{"tray_type": "PLA Basic"}]
  59. presets = PrintScheduler.DEFAULT_DRYING_PRESETS
  60. result = scheduler._get_conservative_drying_params(trays, "n3f", presets)
  61. assert result is not None
  62. assert result[0] == 45 # PLA temp
  63. def test_empty_tray_type_skipped(self, scheduler):
  64. """Trays with empty tray_type are skipped."""
  65. trays = [{"tray_type": ""}, {"tray_type": "PETG"}]
  66. presets = PrintScheduler.DEFAULT_DRYING_PRESETS
  67. result = scheduler._get_conservative_drying_params(trays, "n3f", presets)
  68. assert result is not None
  69. assert result[2] == "PETG"
  70. def test_n3s_uses_n3s_keys(self, scheduler):
  71. """AMS-HT (n3s) should use n3s temp and n3s_hours."""
  72. trays = [{"tray_type": "TPU"}]
  73. presets = PrintScheduler.DEFAULT_DRYING_PRESETS
  74. result = scheduler._get_conservative_drying_params(trays, "n3s", presets)
  75. assert result == (75, 18, "TPU") # n3s=75, n3s_hours=18
  76. def test_n3f_uses_n3f_keys(self, scheduler):
  77. """AMS 2 Pro (n3f) should use n3f temp and n3f_hours."""
  78. trays = [{"tray_type": "TPU"}]
  79. presets = PrintScheduler.DEFAULT_DRYING_PRESETS
  80. result = scheduler._get_conservative_drying_params(trays, "n3f", presets)
  81. assert result == (65, 12, "TPU") # n3f=65, n3f_hours=12
  82. def test_custom_presets(self, scheduler):
  83. """Custom presets override defaults."""
  84. trays = [{"tray_type": "PLA"}]
  85. custom = {"PLA": {"n3f": 50, "n3s": 50, "n3f_hours": 6, "n3s_hours": 6}}
  86. result = scheduler._get_conservative_drying_params(trays, "n3f", custom)
  87. assert result == (50, 6, "PLA")
  88. class TestCompositesResolveToTheirBaseMaterial:
  89. """#3067: a composite spool was skipped by auto-drying entirely.
  90. The preset key came from ``tray_type.split()[0].upper()``, which splits on
  91. spaces only -- so "PA6-CF" stayed "PA6-CF", found no row in an 8-key table,
  92. and the tray contributed nothing. Every caller reads "no row" as "nothing to
  93. dry here", so the AMS was passed over on every scheduler pass, silently.
  94. It was never only PA. Of the 41 types a printer can report, 33 had no row
  95. under that rule and 20 of them have a base material sitting right there:
  96. every -CF, -GF and -AERO variant of PLA, PETG, ABS, ASA, PC and PA.
  97. The reporter could still dry the same spool by hand, because the drying
  98. popover has resolved composites since #2774 -- these tests pin the two ends
  99. to the same answer.
  100. """
  101. @pytest.fixture
  102. def scheduler(self):
  103. return PrintScheduler()
  104. @pytest.mark.parametrize(
  105. ("tray_type", "expected_key"),
  106. [
  107. # The reported spool, and the rest of the polyamide spellings. Bambu
  108. # labels nylon "PA" and spells its own composites out, so none of
  109. # these match a PA row without the alias map.
  110. ("PA6-CF", "PA"),
  111. ("PA6-GF", "PA"),
  112. ("PA12-CF", "PA"),
  113. ("PAHT-CF", "PA"),
  114. ("PA-CF", "PA"),
  115. ("Nylon", "PA"),
  116. # Polyphthalamide is a distinct polymer, not a nylon grade, so this
  117. # one is a judgement: an aromatic polyamide that takes up moisture
  118. # the same way, dried on the hottest row the table has.
  119. ("PPA-CF", "PA"),
  120. ("PPA-GF", "PA"),
  121. # ...and the variants of everything else, which were equally skipped.
  122. ("PLA-CF", "PLA"),
  123. ("PLA-GF", "PLA"),
  124. ("PLA-AERO", "PLA"),
  125. ("PLA-S", "PLA"),
  126. ("PETG-CF", "PETG"),
  127. ("ABS-GF", "ABS"),
  128. ("ASA-CF", "ASA"),
  129. ("ASA-AERO", "ASA"),
  130. ("PC-CF", "PC"),
  131. # Already worked, and must keep working.
  132. ("PLA", "PLA"),
  133. ("PLA Basic", "PLA"),
  134. ("TPU for AMS", "TPU"),
  135. ],
  136. )
  137. def test_the_tray_reaches_its_base_materials_preset(self, scheduler, tray_type, expected_key):
  138. result = scheduler._get_conservative_drying_params(
  139. [{"tray_type": tray_type}], "n3s", PrintScheduler.DEFAULT_DRYING_PRESETS
  140. )
  141. assert result is not None, f"{tray_type} is still skipped by auto-drying"
  142. assert result[2] == expected_key
  143. assert result[0] == PrintScheduler.DEFAULT_DRYING_PRESETS[expected_key]["n3s"]
  144. def test_the_reported_spool_gets_nylons_temperature(self, scheduler):
  145. """The whole point of resolving it rather than defaulting: PA6-CF wants
  146. PA's 85C on an AMS-HT. Landing on PLA's 45 would run a cycle that dries
  147. nothing, which is worse than the skip it replaces -- it looks like it
  148. worked."""
  149. result = scheduler._get_conservative_drying_params(
  150. [{"tray_type": "PA6-CF"}], "n3s", PrintScheduler.DEFAULT_DRYING_PRESETS
  151. )
  152. assert result == (85, 12, "PA")
  153. @pytest.mark.parametrize("tray_type", ["PPS-CF", "PET-CF", "PEEK", "PP", "PE", "wildly unknown"])
  154. def test_a_material_with_no_base_row_is_still_skipped(self, scheduler, tray_type):
  155. """Nothing here invents a drying profile. A material with no row and no
  156. alias keeps the behaviour it has today rather than being dried at a
  157. number nobody chose.
  158. This is deliberately where the backend parts company with the drying
  159. popover, which falls back to PLA because a dropdown has to show
  160. something. A scheduler does not.
  161. """
  162. result = scheduler._get_conservative_drying_params(
  163. [{"tray_type": tray_type}], "n3s", PrintScheduler.DEFAULT_DRYING_PRESETS
  164. )
  165. assert result is None
  166. def test_a_user_row_for_the_exact_type_wins_over_the_base(self, scheduler):
  167. """Someone who has added PA6-CF to their own table meant it."""
  168. custom = {
  169. **PrintScheduler.DEFAULT_DRYING_PRESETS,
  170. "PA6-CF": {"n3f": 70, "n3s": 90, "n3f_hours": 10, "n3s_hours": 10},
  171. }
  172. result = scheduler._get_conservative_drying_params([{"tray_type": "PA6-CF"}], "n3s", custom)
  173. assert result == (90, 10, "PA6-CF")
  174. def test_a_mixed_load_still_takes_the_coolest_row(self, scheduler):
  175. """Resolving more types must not disturb the conservative choice: a
  176. PA6-CF spool sharing the unit with PLA still gets PLA's 45C, because
  177. 85 would deform the PLA."""
  178. result = scheduler._get_conservative_drying_params(
  179. [{"tray_type": "PA6-CF"}, {"tray_type": "PLA"}], "n3s", PrintScheduler.DEFAULT_DRYING_PRESETS
  180. )
  181. assert result[0] == 45
  182. def test_an_empty_preset_row_still_means_skip(self, scheduler):
  183. """The table is user-editable JSON and nothing validates a row, so one
  184. can be present and empty. Resolving the key is not the same as having a
  185. preset: the temp/hours reads each fall back to 55C/12h, which would dry
  186. a PLA spool at 55 degrees because somebody left a row blank."""
  187. custom = {**PrintScheduler.DEFAULT_DRYING_PRESETS, "PLA": {}}
  188. assert scheduler._get_conservative_drying_params([{"tray_type": "PLA"}], "n3s", custom) is None
  189. # And it does not quietly fall through to some other row either.
  190. assert scheduler._get_conservative_drying_params([{"tray_type": "PLA-CF"}], "n3s", custom) is None
  191. def test_a_zero_valued_row_is_a_row(self, scheduler):
  192. """The resolver tests key presence, not truthiness. It is shared with the
  193. chamber-preheat map, where 0 is the correct target for PLA, PETG, TPU and
  194. PVA -- reading those as "no row" would send every one of them to the
  195. catch-all."""
  196. targets = PrintScheduler._bundled_preheat_targets()
  197. assert targets["PLA"] == 0
  198. assert PrintScheduler._resolve_filament_key("PLA", targets) == "PLA"
  199. assert scheduler._target_for_tray_type("PLA", targets) == 0
  200. assert scheduler._target_for_tray_type("PLA-CF", targets) == 0
  201. def test_a_whitespace_only_tray_type_is_not_a_material(self, scheduler):
  202. """Truthy, and splits to nothing. The old normaliser indexed the split
  203. after testing the string, so this raised IndexError rather than reading
  204. as an empty tray."""
  205. assert PrintScheduler._normalize_filament_type(" ") == ""
  206. result = scheduler._get_conservative_drying_params(
  207. [{"tray_type": " "}], "n3s", PrintScheduler.DEFAULT_DRYING_PRESETS
  208. )
  209. assert result is None
  210. class TestDryingPresets:
  211. """Test _get_drying_presets — loads user presets from DB or falls back to defaults."""
  212. @pytest.fixture
  213. def scheduler(self):
  214. return PrintScheduler()
  215. @pytest.mark.asyncio
  216. async def test_default_presets_when_no_setting(self, scheduler):
  217. """Returns built-in defaults when no DB setting exists."""
  218. db = AsyncMock()
  219. result_mock = MagicMock()
  220. result_mock.scalar_one_or_none.return_value = None
  221. db.execute = AsyncMock(return_value=result_mock)
  222. presets = await scheduler._get_drying_presets(db)
  223. assert presets == PrintScheduler.DEFAULT_DRYING_PRESETS
  224. @pytest.mark.asyncio
  225. async def test_user_presets_from_db(self, scheduler):
  226. """Returns user-configured presets when saved in DB."""
  227. db = AsyncMock()
  228. setting = MagicMock()
  229. setting.value = '{"PLA": {"n3f": 50, "n3s": 50, "n3f_hours": 6, "n3s_hours": 6}}'
  230. result_mock = MagicMock()
  231. result_mock.scalar_one_or_none.return_value = setting
  232. db.execute = AsyncMock(return_value=result_mock)
  233. presets = await scheduler._get_drying_presets(db)
  234. assert presets["PLA"]["n3f"] == 50
  235. @pytest.mark.asyncio
  236. async def test_invalid_json_falls_back(self, scheduler):
  237. """Invalid JSON in DB falls back to defaults."""
  238. db = AsyncMock()
  239. setting = MagicMock()
  240. setting.value = "not valid json{{"
  241. result_mock = MagicMock()
  242. result_mock.scalar_one_or_none.return_value = setting
  243. db.execute = AsyncMock(return_value=result_mock)
  244. presets = await scheduler._get_drying_presets(db)
  245. assert presets == PrintScheduler.DEFAULT_DRYING_PRESETS
  246. @pytest.mark.asyncio
  247. async def test_empty_string_falls_back(self, scheduler):
  248. """Empty string in DB falls back to defaults."""
  249. db = AsyncMock()
  250. setting = MagicMock()
  251. setting.value = ""
  252. result_mock = MagicMock()
  253. result_mock.scalar_one_or_none.return_value = setting
  254. db.execute = AsyncMock(return_value=result_mock)
  255. presets = await scheduler._get_drying_presets(db)
  256. assert presets == PrintScheduler.DEFAULT_DRYING_PRESETS
  257. class TestSyncDryingState:
  258. """Test _sync_drying_state — syncs in-memory state with actual printer status."""
  259. @pytest.fixture
  260. def scheduler(self):
  261. return PrintScheduler()
  262. @patch("backend.app.services.print_scheduler.printer_manager")
  263. def test_removes_stopped_printers(self, mock_pm, scheduler):
  264. """Printers that stopped drying are removed from tracking."""
  265. scheduler._drying_in_progress = {1: time.monotonic()}
  266. state = MagicMock()
  267. state.raw_data = {"ams": [{"dry_time": 0}]}
  268. mock_pm.get_status.return_value = state
  269. scheduler._sync_drying_state()
  270. assert 1 not in scheduler._drying_in_progress
  271. @patch("backend.app.services.print_scheduler.printer_manager")
  272. def test_keeps_active_printers(self, mock_pm, scheduler):
  273. """Printers still drying remain in tracking."""
  274. ts = time.monotonic()
  275. scheduler._drying_in_progress = {1: ts}
  276. state = MagicMock()
  277. state.raw_data = {"ams": [{"dry_time": 120}]}
  278. mock_pm.get_status.return_value = state
  279. scheduler._sync_drying_state()
  280. assert scheduler._drying_in_progress[1] == ts
  281. @patch("backend.app.services.print_scheduler.printer_manager")
  282. def test_removes_disconnected_printers(self, mock_pm, scheduler):
  283. """Disconnected printers are removed from tracking."""
  284. scheduler._drying_in_progress = {1: time.monotonic()}
  285. mock_pm.get_status.return_value = None
  286. scheduler._sync_drying_state()
  287. assert 1 not in scheduler._drying_in_progress
  288. class TestPlateHoldDoesNotGateDrying:
  289. """#2801 — an unacknowledged plate must not stop the AMS heating.
  290. Plate-clear answers "is the bed ready for the next job". It says nothing
  291. about whether filament may be dried, and the gap between a finished print
  292. and the acknowledgment is exactly when drying is most useful: the printer
  293. is free and nobody is waiting on it. Leaving the plate unacknowledged is
  294. also how people hold the queue by hand.
  295. Before this, such a printer landed in the dispatch set, was read as
  296. "currently printing", took the mid-print path -- capped temperature,
  297. (mid-print) in the log -- and bypassed the very gate that was meant to
  298. hold it, while the queue loop tore the cycle down once a tick.
  299. """
  300. @pytest.fixture
  301. def scheduler(self):
  302. return PrintScheduler()
  303. @staticmethod
  304. def _finished_printer_state():
  305. state = MagicMock()
  306. state.state = "FINISH"
  307. state.firmware_version = "01.03.00.00"
  308. state.raw_data = {
  309. "ams": [
  310. {
  311. "id": 0,
  312. "module_type": "n3f",
  313. "dry_time": 0,
  314. "humidity_raw": "75",
  315. "dry_sf_reason": [],
  316. "tray": [{"tray_type": "PLA"}],
  317. }
  318. ]
  319. }
  320. return state
  321. def _db(self):
  322. db = AsyncMock()
  323. db.execute = AsyncMock(
  324. side_effect=TestAmbientDrying._make_db_side_effect(
  325. {
  326. "queue_drying_enabled": TestAmbientDrying._make_setting("false"),
  327. "ambient_drying_enabled": TestAmbientDrying._make_setting("true"),
  328. "print_drying_enabled": TestAmbientDrying._make_setting("true"),
  329. "ams_humidity_fair": TestAmbientDrying._make_setting("60"),
  330. "queue_drying_block": TestAmbientDrying._make_setting("false"),
  331. "drying_presets": None,
  332. }
  333. )
  334. )
  335. return db
  336. @pytest.mark.asyncio
  337. @patch("backend.app.services.print_scheduler.printer_manager")
  338. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  339. async def test_finished_printer_with_dirty_plate_dries_at_full_temperature(self, mock_sd, mock_pm, scheduler):
  340. mock_pm.get_status.return_value = self._finished_printer_state()
  341. mock_pm.is_connected.return_value = True
  342. mock_pm.get_model.return_value = "P2S"
  343. mock_pm.send_drying_command.return_value = True
  344. scheduler._is_printer_idle = MagicMock(return_value=True)
  345. await scheduler._check_auto_drying(self._db(), [], set())
  346. # 45 degC is the uncapped PLA preset: mid-print would have sent 40.
  347. mock_pm.send_drying_command.assert_called_once_with(1, 0, 45, 12, mode=1, filament="PLA")
  348. @pytest.mark.asyncio
  349. @patch("backend.app.services.print_scheduler.printer_manager")
  350. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  351. async def test_idleness_is_judged_without_the_plate_gate(self, mock_sd, mock_pm, scheduler):
  352. mock_pm.get_status.return_value = self._finished_printer_state()
  353. mock_pm.is_connected.return_value = True
  354. mock_pm.get_model.return_value = "P2S"
  355. mock_pm.send_drying_command.return_value = True
  356. scheduler._is_printer_idle = MagicMock(return_value=True)
  357. await scheduler._check_auto_drying(self._db(), [], set())
  358. scheduler._is_printer_idle.assert_called_with(1, require_plate_clear=False)
  359. @pytest.mark.asyncio
  360. @patch("backend.app.services.print_scheduler.printer_manager")
  361. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  362. async def test_a_printer_about_to_print_is_still_left_alone(self, mock_sd, mock_pm, scheduler):
  363. """The narrow set keeps its job: an imminent print must not be dried into."""
  364. mock_pm.get_status.return_value = self._finished_printer_state()
  365. mock_pm.is_connected.return_value = True
  366. mock_pm.get_model.return_value = "P2S"
  367. mock_pm.send_drying_command.return_value = True
  368. scheduler._is_printer_idle = MagicMock(return_value=True)
  369. await scheduler._check_auto_drying(self._db(), [], {1})
  370. assert not mock_pm.send_drying_command.called
  371. class TestStopDrying:
  372. """Test _stop_drying — sends stop commands and clears tracking."""
  373. @pytest.fixture
  374. def scheduler(self):
  375. return PrintScheduler()
  376. @pytest.mark.asyncio
  377. @patch("backend.app.services.print_scheduler.printer_manager")
  378. async def test_stops_all_ams_units(self, mock_pm, scheduler):
  379. """Sends stop command to each auto-armed AMS unit that is drying."""
  380. scheduler._drying_in_progress = {1: time.monotonic()}
  381. scheduler._auto_dry_units[(1, 0)] = {"ended_at": None}
  382. scheduler._auto_dry_units[(1, 128)] = {"ended_at": None}
  383. state = MagicMock()
  384. state.raw_data = {
  385. "ams": [
  386. {"id": 0, "dry_time": 120},
  387. {"id": 1, "dry_time": 0},
  388. {"id": 128, "dry_time": 60},
  389. ]
  390. }
  391. mock_pm.get_status.return_value = state
  392. await scheduler._stop_drying(1)
  393. # Should send stop to AMS 0 and 128, not AMS 1
  394. calls = mock_pm.send_drying_command.call_args_list
  395. assert len(calls) == 2
  396. assert calls[0].args == (1, 0, 0, 0)
  397. assert calls[1].args == (1, 128, 0, 0)
  398. assert 1 not in scheduler._drying_in_progress
  399. @pytest.mark.asyncio
  400. @patch("backend.app.services.print_scheduler.printer_manager")
  401. async def test_leaves_cycles_bambuddy_did_not_start(self, mock_pm, scheduler):
  402. """A hand-started dry on another unit survives (#2801).
  403. One auto-dried unit used to be enough to stop every AMS on the
  404. printer reporting dry_time > 0, which took the user's own cycle with
  405. it. The entry gate only ever knew about cycles Bambuddy began; the
  406. action now matches.
  407. """
  408. scheduler._drying_in_progress = {1: time.monotonic()}
  409. scheduler._auto_dry_units[(1, 0)] = {"ended_at": None}
  410. state = MagicMock()
  411. state.raw_data = {"ams": [{"id": 0, "dry_time": 120}, {"id": 1, "dry_time": 600}]}
  412. mock_pm.get_status.return_value = state
  413. await scheduler._stop_drying(1)
  414. calls = mock_pm.send_drying_command.call_args_list
  415. assert [c.args[1] for c in calls] == [0]
  416. @pytest.mark.asyncio
  417. @patch("backend.app.services.print_scheduler.printer_manager")
  418. async def test_stops_nothing_it_cannot_prove_it_started(self, mock_pm, scheduler):
  419. """After a restart Bambuddy cannot tell its own cycle from a manual one.
  420. _sync_drying_state prunes but never adopts, for exactly this reason, so
  421. a cycle armed before the restart is left running rather than risking a
  422. stop on somebody's manual dry. Tracking is still cleared.
  423. """
  424. scheduler._drying_in_progress = {1: time.monotonic()}
  425. state = MagicMock()
  426. state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
  427. mock_pm.get_status.return_value = state
  428. await scheduler._stop_drying(1)
  429. assert not mock_pm.send_drying_command.called
  430. assert 1 not in scheduler._drying_in_progress
  431. @pytest.mark.asyncio
  432. @patch("backend.app.services.print_scheduler.printer_manager")
  433. async def test_clears_tracking_when_no_state(self, mock_pm, scheduler):
  434. """Clears tracking when printer has no state (disconnected)."""
  435. scheduler._drying_in_progress = {1: time.monotonic()}
  436. mock_pm.get_status.return_value = None
  437. await scheduler._stop_drying(1)
  438. assert 1 not in scheduler._drying_in_progress
  439. class TestMinimumDryingTime:
  440. """Regression #1892: a running drying cycle must never be stopped by a humidity re-check.
  441. Relative humidity reads low in heated air (the AMS sensor sees ~15-20% within
  442. minutes of the dryer starting even while the filament is still saturated), so a
  443. humidity-based auto-stop would truncate every cycle — manual or Bambuddy-started —
  444. to the old minimum-time floor. Drying is now left to run to its configured
  445. duration; the firmware stops it when the duration elapses.
  446. """
  447. @pytest.fixture
  448. def scheduler(self):
  449. return PrintScheduler()
  450. @pytest.mark.asyncio
  451. @patch("backend.app.services.print_scheduler.printer_manager")
  452. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  453. async def test_no_stop_before_minimum_time(self, mock_sd, mock_pm, scheduler):
  454. """Drying should NOT stop when humidity drops below threshold shortly after start."""
  455. # Simulate: drying started 5 minutes ago
  456. scheduler._drying_in_progress = {1: time.monotonic() - 300}
  457. state = MagicMock()
  458. state.raw_data = {
  459. "ams": [
  460. {
  461. "id": 0,
  462. "module_type": "n3f",
  463. "dry_time": 600,
  464. "humidity_raw": "18",
  465. "dry_sf_reason": [],
  466. "tray": [{"tray_type": "PLA"}],
  467. }
  468. ]
  469. }
  470. state.firmware_version = "01.09.00.00"
  471. mock_pm.get_status.return_value = state
  472. mock_pm.is_connected.return_value = True
  473. mock_pm.get_model.return_value = "X1C"
  474. # Mock _is_printer_idle and DB
  475. scheduler._is_printer_idle = MagicMock(return_value=True)
  476. db = AsyncMock()
  477. # Mock settings: enabled, threshold=21
  478. settings_returns = {
  479. "queue_drying_enabled": self._make_setting("true"),
  480. "ams_humidity_fair": self._make_setting("21"),
  481. "queue_drying_block": self._make_setting("false"),
  482. "drying_presets": None,
  483. }
  484. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns, printer_id=1))
  485. # Queue item with schedule
  486. item = MagicMock()
  487. item.printer_id = 1
  488. item.scheduled_time = datetime.now(timezone.utc) + timedelta(hours=2) # Has a schedule
  489. item.manual_start = False
  490. await scheduler._check_auto_drying(db, [item], set())
  491. # Should NOT have sent stop command via humidity check — minimum time not elapsed
  492. # The only calls should NOT include the humidity-based stop
  493. for call in mock_pm.send_drying_command.call_args_list:
  494. # If any stop was called, it should NOT be from the humidity path
  495. # (humidity path uses keyword args: temp=0, duration=0, mode=0)
  496. assert call != ((1, 0), {"temp": 0, "duration": 0, "mode": 0}), (
  497. "Humidity-based stop should not fire before minimum drying time"
  498. )
  499. @pytest.mark.asyncio
  500. @patch("backend.app.services.print_scheduler.printer_manager")
  501. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  502. async def test_no_stop_after_long_elapsed_time(self, mock_sd, mock_pm, scheduler):
  503. """#1892: drying must NOT stop even long after start with low humidity — let it run."""
  504. # Simulate: drying started 35 minutes ago, humidity reads low (heated air)
  505. scheduler._drying_in_progress = {1: time.monotonic() - 2100}
  506. state = MagicMock()
  507. state.raw_data = {
  508. "ams": [
  509. {
  510. "id": 0,
  511. "module_type": "n3f",
  512. "dry_time": 600,
  513. "humidity_raw": "18",
  514. "dry_sf_reason": [],
  515. "tray": [{"tray_type": "PLA"}],
  516. }
  517. ]
  518. }
  519. state.firmware_version = "01.09.00.00"
  520. mock_pm.get_status.return_value = state
  521. mock_pm.is_connected.return_value = True
  522. mock_pm.get_model.return_value = "X1C"
  523. scheduler._is_printer_idle = MagicMock(return_value=True)
  524. db = AsyncMock()
  525. settings_returns = {
  526. "queue_drying_enabled": self._make_setting("true"),
  527. "ams_humidity_fair": self._make_setting("21"),
  528. "queue_drying_block": self._make_setting("false"),
  529. "drying_presets": None,
  530. }
  531. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns, printer_id=1))
  532. item = MagicMock()
  533. item.printer_id = 1
  534. item.scheduled_time = datetime.now(timezone.utc) + timedelta(hours=2)
  535. item.manual_start = False
  536. await scheduler._check_auto_drying(db, [item], set())
  537. # Must NOT send a humidity-based stop — drying is left to run to its duration
  538. for call in mock_pm.send_drying_command.call_args_list:
  539. assert call != ((1, 0), {"temp": 0, "duration": 0, "mode": 0}), (
  540. "Humidity re-check must never stop a running drying cycle (#1892)"
  541. )
  542. @staticmethod
  543. def _make_setting(value):
  544. s = MagicMock()
  545. s.value = value
  546. return s
  547. @staticmethod
  548. def _make_db_side_effect(settings_map, printer_id=1):
  549. """Create a side_effect for db.execute that returns settings and printers."""
  550. async def side_effect(stmt):
  551. result = MagicMock()
  552. stmt_str = str(stmt)
  553. # Extract bind parameter values (SQLAlchemy uses :key_1 placeholders)
  554. try:
  555. compiled = stmt.compile(compile_kwargs={"literal_binds": False})
  556. param_values = list(compiled.params.values())
  557. except Exception:
  558. param_values = []
  559. # Match settings queries by checking bind parameter values
  560. matched = False
  561. for key, val in settings_map.items():
  562. if key in param_values:
  563. result.scalar_one_or_none.return_value = val
  564. matched = True
  565. break
  566. if not matched:
  567. if "printer" in stmt_str.lower() or "is_active" in stmt_str:
  568. printer = MagicMock()
  569. printer.id = printer_id
  570. printer.is_active = True
  571. scalars_mock = MagicMock()
  572. scalars_mock.__iter__ = MagicMock(return_value=iter([printer]))
  573. result.scalars.return_value = scalars_mock
  574. else:
  575. result.scalar_one_or_none.return_value = None
  576. return result
  577. return side_effect
  578. class TestAutoStopOnFeatureDisabled:
  579. """Regression: disabling auto-drying in settings should stop active drying sessions."""
  580. @pytest.fixture
  581. def scheduler(self):
  582. return PrintScheduler()
  583. @pytest.mark.asyncio
  584. @patch("backend.app.services.print_scheduler.printer_manager")
  585. async def test_stops_drying_when_disabled(self, mock_pm, scheduler):
  586. """Disabling auto-drying should send stop commands to all drying printers."""
  587. scheduler._drying_in_progress = {1: time.monotonic(), 2: time.monotonic()}
  588. scheduler._auto_dry_units[(1, 0)] = {"ended_at": None}
  589. scheduler._auto_dry_units[(2, 0)] = {"ended_at": None}
  590. # Printer 1: drying, Printer 2: drying
  591. def get_status(pid):
  592. state = MagicMock()
  593. state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
  594. return state
  595. mock_pm.get_status.side_effect = get_status
  596. db = AsyncMock()
  597. # queue_drying_enabled = false
  598. setting = MagicMock()
  599. setting.value = "false"
  600. result_mock = MagicMock()
  601. result_mock.scalar_one_or_none.return_value = setting
  602. db.execute = AsyncMock(return_value=result_mock)
  603. await scheduler._check_auto_drying(db, [], set())
  604. # Should have sent stop commands
  605. assert mock_pm.send_drying_command.call_count == 2
  606. assert not scheduler._drying_in_progress
  607. class TestAutoStopOnNoScheduledItems:
  608. """Regression: removing scheduled items should stop auto-drying."""
  609. @pytest.fixture
  610. def scheduler(self):
  611. return PrintScheduler()
  612. @staticmethod
  613. def _make_setting(value):
  614. s = MagicMock()
  615. s.value = value
  616. return s
  617. @staticmethod
  618. def _make_db_side_effect(settings_map):
  619. """Create a side_effect for db.execute that returns settings by key."""
  620. async def side_effect(stmt):
  621. result = MagicMock()
  622. try:
  623. compiled = stmt.compile(compile_kwargs={"literal_binds": False})
  624. param_values = list(compiled.params.values())
  625. except Exception:
  626. param_values = []
  627. for key, val in settings_map.items():
  628. if key in param_values:
  629. result.scalar_one_or_none.return_value = val
  630. return result
  631. result.scalar_one_or_none.return_value = None
  632. return result
  633. return side_effect
  634. @pytest.mark.asyncio
  635. @patch("backend.app.services.print_scheduler.printer_manager")
  636. async def test_stops_when_no_scheduled_items(self, mock_pm, scheduler):
  637. """Auto-drying stops when queue has no scheduled items (queue mode only)."""
  638. scheduler._drying_in_progress = {1: time.monotonic()}
  639. scheduler._auto_dry_units[(1, 0)] = {"ended_at": None}
  640. state = MagicMock()
  641. state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
  642. mock_pm.get_status.return_value = state
  643. db = AsyncMock()
  644. settings_returns = {
  645. "queue_drying_enabled": self._make_setting("true"),
  646. "ambient_drying_enabled": self._make_setting("false"),
  647. }
  648. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  649. # Manual-start items only (no scheduled_time)
  650. item = MagicMock()
  651. item.printer_id = 1
  652. item.scheduled_time = None
  653. item.manual_start = True
  654. await scheduler._check_auto_drying(db, [item], set())
  655. # Should have stopped drying
  656. assert mock_pm.send_drying_command.called
  657. assert not scheduler._drying_in_progress
  658. @pytest.mark.asyncio
  659. @patch("backend.app.services.print_scheduler.printer_manager")
  660. async def test_stops_when_empty_queue(self, mock_pm, scheduler):
  661. """Auto-drying stops when queue is completely empty (queue mode only)."""
  662. scheduler._drying_in_progress = {1: time.monotonic()}
  663. scheduler._auto_dry_units[(1, 0)] = {"ended_at": None}
  664. state = MagicMock()
  665. state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
  666. mock_pm.get_status.return_value = state
  667. db = AsyncMock()
  668. settings_returns = {
  669. "queue_drying_enabled": self._make_setting("true"),
  670. "ambient_drying_enabled": self._make_setting("false"),
  671. }
  672. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  673. await scheduler._check_auto_drying(db, [], set())
  674. assert mock_pm.send_drying_command.called
  675. assert not scheduler._drying_in_progress
  676. class TestDryingTrackingTimestamps:
  677. """Test that _drying_in_progress uses timestamps, not booleans."""
  678. def test_initial_state_empty(self):
  679. """Fresh scheduler has no drying tracked."""
  680. scheduler = PrintScheduler()
  681. assert scheduler._drying_in_progress == {}
  682. def test_timestamp_is_monotonic(self):
  683. """Tracked values should be monotonic timestamps."""
  684. scheduler = PrintScheduler()
  685. before = time.monotonic()
  686. scheduler._drying_in_progress[1] = time.monotonic()
  687. after = time.monotonic()
  688. assert before <= scheduler._drying_in_progress[1] <= after
  689. def test_timestamp_is_truthy(self):
  690. """Timestamps are truthy for .get() checks (backward compat with bool pattern)."""
  691. scheduler = PrintScheduler()
  692. scheduler._drying_in_progress[1] = time.monotonic()
  693. assert scheduler._drying_in_progress.get(1)
  694. assert not scheduler._drying_in_progress.get(999)
  695. class _DryingTestBase:
  696. """Shared helpers for auto-drying integration tests."""
  697. @staticmethod
  698. def _make_setting(value):
  699. s = MagicMock()
  700. s.value = value
  701. return s
  702. @staticmethod
  703. def _make_db_side_effect(settings_map, printer_ids=None):
  704. """Create a side_effect for db.execute that returns settings by key and printers."""
  705. if printer_ids is None:
  706. printer_ids = [1]
  707. async def side_effect(stmt):
  708. result = MagicMock()
  709. stmt_str = str(stmt)
  710. try:
  711. compiled = stmt.compile(compile_kwargs={"literal_binds": False})
  712. param_values = list(compiled.params.values())
  713. except Exception:
  714. param_values = []
  715. for key, val in settings_map.items():
  716. if key in param_values:
  717. result.scalar_one_or_none.return_value = val
  718. return result
  719. if "printer" in stmt_str.lower() or "is_active" in stmt_str:
  720. printers = []
  721. for pid in printer_ids:
  722. p = MagicMock()
  723. p.id = pid
  724. p.is_active = True
  725. printers.append(p)
  726. scalars_mock = MagicMock()
  727. scalars_mock.__iter__ = MagicMock(return_value=iter(printers))
  728. result.scalars.return_value = scalars_mock
  729. else:
  730. result.scalar_one_or_none.return_value = None
  731. return result
  732. return side_effect
  733. class TestAmbientDrying(_DryingTestBase):
  734. """Tests for ambient drying mode — drying based on humidity regardless of queue state."""
  735. @pytest.fixture
  736. def scheduler(self):
  737. return PrintScheduler()
  738. @pytest.mark.asyncio
  739. @patch("backend.app.services.print_scheduler.printer_manager")
  740. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  741. async def test_ambient_dries_idle_printer_without_queue(self, mock_sd, mock_pm, scheduler):
  742. """Ambient mode starts drying on idle printers even with no queue items."""
  743. state = MagicMock()
  744. state.raw_data = {
  745. "ams": [
  746. {
  747. "id": 0,
  748. "module_type": "n3f",
  749. "dry_time": 0,
  750. "humidity_raw": "75",
  751. "dry_sf_reason": [],
  752. "tray": [{"tray_type": "PLA"}],
  753. }
  754. ]
  755. }
  756. state.firmware_version = "01.09.00.00"
  757. mock_pm.get_status.return_value = state
  758. mock_pm.is_connected.return_value = True
  759. mock_pm.get_model.return_value = "X1C"
  760. mock_pm.send_drying_command.return_value = True
  761. scheduler._is_printer_idle = MagicMock(return_value=True)
  762. db = AsyncMock()
  763. settings_returns = {
  764. "queue_drying_enabled": self._make_setting("false"),
  765. "ambient_drying_enabled": self._make_setting("true"),
  766. "ams_humidity_fair": self._make_setting("60"),
  767. "queue_drying_block": self._make_setting("false"),
  768. "drying_presets": None,
  769. }
  770. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  771. # Empty queue — ambient mode should still dry
  772. await scheduler._check_auto_drying(db, [], set())
  773. mock_pm.send_drying_command.assert_called_once_with(1, 0, 45, 12, mode=1, filament="PLA")
  774. assert 1 in scheduler._drying_in_progress
  775. @pytest.mark.asyncio
  776. @patch("backend.app.services.print_scheduler.printer_manager")
  777. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  778. async def test_ambient_does_not_dry_below_threshold(self, mock_sd, mock_pm, scheduler):
  779. """Ambient mode does NOT dry when humidity is below threshold."""
  780. state = MagicMock()
  781. state.raw_data = {
  782. "ams": [
  783. {
  784. "id": 0,
  785. "module_type": "n3f",
  786. "dry_time": 0,
  787. "humidity_raw": "40",
  788. "dry_sf_reason": [],
  789. "tray": [{"tray_type": "PLA"}],
  790. }
  791. ]
  792. }
  793. state.firmware_version = "01.09.00.00"
  794. mock_pm.get_status.return_value = state
  795. mock_pm.is_connected.return_value = True
  796. mock_pm.get_model.return_value = "X1C"
  797. scheduler._is_printer_idle = MagicMock(return_value=True)
  798. db = AsyncMock()
  799. settings_returns = {
  800. "queue_drying_enabled": self._make_setting("false"),
  801. "ambient_drying_enabled": self._make_setting("true"),
  802. "ams_humidity_fair": self._make_setting("60"),
  803. "queue_drying_block": self._make_setting("false"),
  804. "drying_presets": None,
  805. }
  806. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  807. await scheduler._check_auto_drying(db, [], set())
  808. mock_pm.send_drying_command.assert_not_called()
  809. @pytest.mark.asyncio
  810. @patch("backend.app.services.print_scheduler.printer_manager")
  811. async def test_ambient_off_stops_drying_without_queue(self, mock_pm, scheduler):
  812. """Disabling ambient drying stops drying on printers without queue items."""
  813. scheduler._drying_in_progress = {1: time.monotonic()}
  814. scheduler._auto_dry_units[(1, 0)] = {"ended_at": None}
  815. state = MagicMock()
  816. state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
  817. mock_pm.get_status.return_value = state
  818. db = AsyncMock()
  819. settings_returns = {
  820. "queue_drying_enabled": self._make_setting("false"),
  821. "ambient_drying_enabled": self._make_setting("false"),
  822. }
  823. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  824. await scheduler._check_auto_drying(db, [], set())
  825. assert mock_pm.send_drying_command.called
  826. assert not scheduler._drying_in_progress
  827. @pytest.mark.asyncio
  828. @patch("backend.app.services.print_scheduler.printer_manager")
  829. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  830. async def test_ambient_continues_when_queue_empty(self, mock_sd, mock_pm, scheduler):
  831. """Ambient drying continues even when queue has no scheduled items (unlike queue mode)."""
  832. scheduler._drying_in_progress = {1: time.monotonic() - 100}
  833. state = MagicMock()
  834. state.raw_data = {
  835. "ams": [
  836. {
  837. "id": 0,
  838. "module_type": "n3f",
  839. "dry_time": 600,
  840. "humidity_raw": "75",
  841. "dry_sf_reason": [],
  842. "tray": [{"tray_type": "PLA"}],
  843. }
  844. ]
  845. }
  846. state.firmware_version = "01.09.00.00"
  847. mock_pm.get_status.return_value = state
  848. mock_pm.is_connected.return_value = True
  849. mock_pm.get_model.return_value = "X1C"
  850. scheduler._is_printer_idle = MagicMock(return_value=True)
  851. db = AsyncMock()
  852. settings_returns = {
  853. "queue_drying_enabled": self._make_setting("false"),
  854. "ambient_drying_enabled": self._make_setting("true"),
  855. "ams_humidity_fair": self._make_setting("60"),
  856. "queue_drying_block": self._make_setting("false"),
  857. "drying_presets": None,
  858. }
  859. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  860. await scheduler._check_auto_drying(db, [], set())
  861. # Should NOT have sent stop — humidity still high, drying continues
  862. for call in mock_pm.send_drying_command.call_args_list:
  863. assert call.kwargs.get("mode") != 0, "Should not stop drying in ambient mode with high humidity"
  864. assert 1 in scheduler._drying_in_progress
  865. @pytest.mark.asyncio
  866. @patch("backend.app.services.print_scheduler.printer_manager")
  867. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  868. async def test_queue_only_does_not_dry_without_scheduled_items(self, mock_sd, mock_pm, scheduler):
  869. """Queue mode alone does NOT dry printers that have no scheduled queue items."""
  870. state = MagicMock()
  871. state.raw_data = {
  872. "ams": [
  873. {
  874. "id": 0,
  875. "module_type": "n3f",
  876. "dry_time": 0,
  877. "humidity_raw": "75",
  878. "dry_sf_reason": [],
  879. "tray": [{"tray_type": "PLA"}],
  880. }
  881. ]
  882. }
  883. state.firmware_version = "01.09.00.00"
  884. mock_pm.get_status.return_value = state
  885. mock_pm.is_connected.return_value = True
  886. mock_pm.get_model.return_value = "X1C"
  887. scheduler._is_printer_idle = MagicMock(return_value=True)
  888. db = AsyncMock()
  889. settings_returns = {
  890. "queue_drying_enabled": self._make_setting("true"),
  891. "ambient_drying_enabled": self._make_setting("false"),
  892. "ams_humidity_fair": self._make_setting("60"),
  893. "queue_drying_block": self._make_setting("false"),
  894. "drying_presets": None,
  895. }
  896. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  897. # No queue items at all
  898. await scheduler._check_auto_drying(db, [], set())
  899. mock_pm.send_drying_command.assert_not_called()
  900. class TestBlockForDryingBugFix(_DryingTestBase):
  901. """Regression: block mode gates NEW drying starts but must leave running dries alone (#1892)."""
  902. @pytest.fixture
  903. def scheduler(self):
  904. return PrintScheduler()
  905. @pytest.mark.asyncio
  906. @patch("backend.app.services.print_scheduler.printer_manager")
  907. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  908. async def test_block_mode_leaves_active_drying_running(self, mock_sd, mock_pm, scheduler):
  909. """#1892: a printer already drying in block mode must not be stopped by a humidity re-check."""
  910. # Drying started 35 minutes ago
  911. scheduler._drying_in_progress = {1: time.monotonic() - 2100}
  912. state = MagicMock()
  913. state.raw_data = {
  914. "ams": [
  915. {
  916. "id": 0,
  917. "module_type": "n3f",
  918. "dry_time": 600,
  919. "humidity_raw": "30", # Below threshold
  920. "dry_sf_reason": [],
  921. "tray": [{"tray_type": "PLA"}],
  922. }
  923. ]
  924. }
  925. state.firmware_version = "01.09.00.00"
  926. mock_pm.get_status.return_value = state
  927. mock_pm.is_connected.return_value = True
  928. mock_pm.get_model.return_value = "X1C"
  929. scheduler._is_printer_idle = MagicMock(return_value=True)
  930. db = AsyncMock()
  931. settings_returns = {
  932. "queue_drying_enabled": self._make_setting("true"),
  933. "ambient_drying_enabled": self._make_setting("false"),
  934. "ams_humidity_fair": self._make_setting("60"),
  935. "queue_drying_block": self._make_setting("true"),
  936. "drying_presets": None,
  937. }
  938. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  939. # Queue item due on this printer (triggers block mode gate)
  940. item = MagicMock()
  941. item.printer_id = 1
  942. item.scheduled_time = datetime.now(timezone.utc) - timedelta(minutes=1)
  943. item.manual_start = False
  944. await scheduler._check_auto_drying(db, [item], set())
  945. # Must NOT stop the running dry — block mode gates new starts, not active cycles
  946. for call in mock_pm.send_drying_command.call_args_list:
  947. assert call != ((1, 0), {"temp": 0, "duration": 0, "mode": 0}), (
  948. "Block mode must not stop an already-running drying cycle (#1892)"
  949. )
  950. @pytest.mark.asyncio
  951. @patch("backend.app.services.print_scheduler.printer_manager")
  952. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  953. async def test_block_mode_prevents_new_drying_start(self, mock_sd, mock_pm, scheduler):
  954. """Block mode should still prevent starting NEW drying on printers with pending items."""
  955. state = MagicMock()
  956. state.raw_data = {
  957. "ams": [
  958. {
  959. "id": 0,
  960. "module_type": "n3f",
  961. "dry_time": 0,
  962. "humidity_raw": "75",
  963. "dry_sf_reason": [],
  964. "tray": [{"tray_type": "PLA"}],
  965. }
  966. ]
  967. }
  968. state.firmware_version = "01.09.00.00"
  969. mock_pm.get_status.return_value = state
  970. mock_pm.is_connected.return_value = True
  971. mock_pm.get_model.return_value = "X1C"
  972. scheduler._is_printer_idle = MagicMock(return_value=True)
  973. db = AsyncMock()
  974. settings_returns = {
  975. "queue_drying_enabled": self._make_setting("true"),
  976. "ambient_drying_enabled": self._make_setting("false"),
  977. "ams_humidity_fair": self._make_setting("60"),
  978. "queue_drying_block": self._make_setting("true"),
  979. "drying_presets": None,
  980. }
  981. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  982. item = MagicMock()
  983. item.printer_id = 1
  984. # Due now: a job that could start keeps a new cycle off in block mode.
  985. item.scheduled_time = datetime.now(timezone.utc) - timedelta(minutes=1)
  986. item.manual_start = False
  987. await scheduler._check_auto_drying(db, [item], set())
  988. # Should NOT start drying — block mode with pending items
  989. mock_pm.send_drying_command.assert_not_called()
  990. class TestResolveHumidityThreshold:
  991. """Per-filament humidity threshold resolver (#1605).
  992. Resolves the trigger threshold for an AMS unit from the loaded tray types.
  993. Mixed loads use the lowest (most restrictive) value. Empty / unloaded trays
  994. contribute no constraint; falls back to the global ``ams_humidity_fair``
  995. when no per-type overrides are configured.
  996. """
  997. def test_no_overrides_falls_back_to_global(self):
  998. """Empty overrides map → caller's global fallback is used verbatim."""
  999. result = PrintScheduler.resolve_humidity_threshold([{"tray_type": "PLA"}], {}, 60)
  1000. assert result == 60
  1001. def test_single_known_type_uses_override(self):
  1002. """Single PLA tray with override = 50 returns 50."""
  1003. result = PrintScheduler.resolve_humidity_threshold(
  1004. [{"tray_type": "PLA Basic"}],
  1005. {"default": 60, "PLA": 50},
  1006. 60,
  1007. )
  1008. assert result == 50
  1009. def test_a_composite_takes_its_base_materials_threshold(self):
  1010. """Same lookup, same gap (#3067): a PA6-CF spool read as an unknown type
  1011. and took the default, so the override the user set for nylon -- the
  1012. material most worth a low threshold -- never applied to the spool they
  1013. set it for."""
  1014. result = PrintScheduler.resolve_humidity_threshold(
  1015. [{"tray_type": "PA6-CF"}],
  1016. {"default": 60, "PA": 20},
  1017. 60,
  1018. )
  1019. assert result == 20
  1020. def test_a_composite_with_its_own_threshold_row_keeps_it(self):
  1021. result = PrintScheduler.resolve_humidity_threshold(
  1022. [{"tray_type": "PETG-CF"}],
  1023. {"default": 60, "PETG": 55, "PETG-CF": 40},
  1024. 60,
  1025. )
  1026. assert result == 40
  1027. def test_mixed_load_picks_lowest(self):
  1028. """Mixed PLA (60) + Nylon (20) → most restrictive = 20."""
  1029. result = PrintScheduler.resolve_humidity_threshold(
  1030. [{"tray_type": "PLA Basic"}, {"tray_type": "PA Glass"}],
  1031. {"default": 60, "PLA": 60, "PA": 20},
  1032. 60,
  1033. )
  1034. assert result == 20
  1035. def test_unknown_type_uses_default_key(self):
  1036. """Tray type not in the map falls back to the 'default' key, not the
  1037. caller fallback. Lets the user tune unknown-filament behavior."""
  1038. result = PrintScheduler.resolve_humidity_threshold(
  1039. [{"tray_type": "EXOTIC_WOOD"}],
  1040. {"default": 40, "PLA": 60},
  1041. 999,
  1042. )
  1043. assert result == 40
  1044. def test_empty_tray_slots_skipped(self):
  1045. """Empty tray_type strings (unloaded slots) contribute no constraint."""
  1046. result = PrintScheduler.resolve_humidity_threshold(
  1047. [{"tray_type": ""}, {"tray_type": "PLA"}],
  1048. {"default": 30, "PLA": 50},
  1049. 60,
  1050. )
  1051. assert result == 50
  1052. def test_all_empty_trays_uses_default_key(self):
  1053. """No loaded trays at all → falls back to default key (or fallback if
  1054. no overrides). Matches the empty-AMS behavior of the existing alarm
  1055. site so an empty AMS still alarms at the user's default rate."""
  1056. result = PrintScheduler.resolve_humidity_threshold(
  1057. [{"tray_type": ""}, {}],
  1058. {"default": 30, "PLA": 50},
  1059. 60,
  1060. )
  1061. assert result == 30
  1062. def test_filament_name_normalized(self):
  1063. """Tray types like 'PLA Basic', 'pla basic' all normalize to 'PLA'."""
  1064. result = PrintScheduler.resolve_humidity_threshold(
  1065. [{"tray_type": "pla basic"}],
  1066. {"default": 60, "PLA": 25},
  1067. 60,
  1068. )
  1069. assert result == 25
  1070. def test_no_tray_type_field_skipped(self):
  1071. """Missing tray_type field is treated as empty (unloaded)."""
  1072. result = PrintScheduler.resolve_humidity_threshold(
  1073. [{}, {"tray_type": "ASA"}],
  1074. {"default": 60, "ASA": 30},
  1075. 60,
  1076. )
  1077. assert result == 30
  1078. class TestGetHumidityThresholds:
  1079. """The DB-loading helper for ``ams_humidity_thresholds`` (#1605)."""
  1080. @pytest.fixture
  1081. def scheduler(self):
  1082. return PrintScheduler()
  1083. @pytest.mark.asyncio
  1084. async def test_missing_setting_returns_empty(self, scheduler):
  1085. db = AsyncMock()
  1086. db.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=None)))
  1087. result = await scheduler._get_humidity_thresholds(db)
  1088. assert result == {}
  1089. @pytest.mark.asyncio
  1090. async def test_empty_value_returns_empty(self, scheduler):
  1091. db = AsyncMock()
  1092. setting = MagicMock(value="")
  1093. db.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=setting)))
  1094. result = await scheduler._get_humidity_thresholds(db)
  1095. assert result == {}
  1096. @pytest.mark.asyncio
  1097. async def test_invalid_json_returns_empty(self, scheduler):
  1098. db = AsyncMock()
  1099. setting = MagicMock(value="not json{")
  1100. db.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=setting)))
  1101. result = await scheduler._get_humidity_thresholds(db)
  1102. assert result == {}
  1103. @pytest.mark.asyncio
  1104. async def test_valid_json_normalizes_keys(self, scheduler):
  1105. """Filament-type keys uppercase; 'default' preserved."""
  1106. db = AsyncMock()
  1107. setting = MagicMock(value='{"default": 60, "pla": 50, "ASA": 30, "garbage": "x"}')
  1108. db.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=setting)))
  1109. result = await scheduler._get_humidity_thresholds(db)
  1110. assert result == {"default": 60, "PLA": 50, "ASA": 30}
  1111. class TestMidPrintDrying(_DryingTestBase):
  1112. """Tests for the print_drying_enabled path — drying that runs CONCURRENTLY
  1113. with an active print on capable hardware (H2D / H2C / H2S / P2S / X2D / X1C /
  1114. A2L / H2D Pro on recent firmware). Distinct from idle drying.
  1115. Verifies:
  1116. - With the toggle ON and capable hardware, a printer in the busy set is
  1117. still evaluated and drying fires at the capped temperature.
  1118. - The temperature cap is max(40, preset_temp - 5) — protects spools.
  1119. - With the toggle OFF, the existing busy-printer skip still applies.
  1120. - With the toggle ON but unsupported firmware, the busy-printer skip
  1121. still applies (gated by supports_drying_while_printing).
  1122. """
  1123. @pytest.fixture
  1124. def scheduler(self):
  1125. return PrintScheduler()
  1126. @staticmethod
  1127. def _ams_unit(humidity: str = "75"):
  1128. return {
  1129. "id": 0,
  1130. "module_type": "n3f",
  1131. "dry_time": 0,
  1132. "humidity_raw": humidity,
  1133. "dry_sf_reason": [],
  1134. "tray": [{"tray_type": "PLA"}],
  1135. }
  1136. def _state(self, firmware: str):
  1137. state = MagicMock()
  1138. state.raw_data = {"ams": [self._ams_unit()]}
  1139. state.firmware_version = firmware
  1140. return state
  1141. @pytest.mark.asyncio
  1142. @patch("backend.app.services.print_scheduler.printer_manager")
  1143. async def test_running_printer_dries_when_enabled_and_capable(self, mock_pm, scheduler):
  1144. """Toggle ON + capable hardware: running printer dries at capped temp."""
  1145. state = self._state("01.03.00.00")
  1146. state.state = "RUNNING"
  1147. mock_pm.get_status.return_value = state
  1148. mock_pm.is_connected.return_value = True
  1149. mock_pm.get_model.return_value = "H2D"
  1150. mock_pm.send_drying_command.return_value = True
  1151. scheduler._is_printer_idle = MagicMock(return_value=False)
  1152. db = AsyncMock()
  1153. settings_returns = {
  1154. "queue_drying_enabled": self._make_setting("true"),
  1155. "ambient_drying_enabled": self._make_setting("false"),
  1156. "print_drying_enabled": self._make_setting("true"),
  1157. "ams_humidity_fair": self._make_setting("60"),
  1158. "queue_drying_block": self._make_setting("false"),
  1159. "drying_presets": None,
  1160. }
  1161. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  1162. # Actually printing (RUNNING), so the mid-print path applies
  1163. await scheduler._check_auto_drying(db, [], {1})
  1164. # PLA preset is 45 degC for n3f; mid-print cap is max(40, 45-5) = 40
  1165. mock_pm.send_drying_command.assert_called_once_with(1, 0, 40, 12, mode=1, filament="PLA")
  1166. assert 1 in scheduler._drying_in_progress
  1167. @pytest.mark.asyncio
  1168. @patch("backend.app.services.print_scheduler.printer_manager")
  1169. async def test_temp_cap_applied_above_floor(self, mock_pm, scheduler):
  1170. """Higher-temp filament (PETG n3f=65) caps to 60, not floor."""
  1171. state = MagicMock()
  1172. state.raw_data = {
  1173. "ams": [
  1174. {
  1175. "id": 0,
  1176. "module_type": "n3f",
  1177. "dry_time": 0,
  1178. "humidity_raw": "75",
  1179. "dry_sf_reason": [],
  1180. "tray": [{"tray_type": "PETG"}],
  1181. }
  1182. ]
  1183. }
  1184. state.firmware_version = "01.03.00.00"
  1185. state.state = "RUNNING"
  1186. mock_pm.get_status.return_value = state
  1187. mock_pm.is_connected.return_value = True
  1188. mock_pm.get_model.return_value = "H2D"
  1189. mock_pm.send_drying_command.return_value = True
  1190. scheduler._is_printer_idle = MagicMock(return_value=False)
  1191. db = AsyncMock()
  1192. settings_returns = {
  1193. "queue_drying_enabled": self._make_setting("true"),
  1194. "ambient_drying_enabled": self._make_setting("false"),
  1195. "print_drying_enabled": self._make_setting("true"),
  1196. "ams_humidity_fair": self._make_setting("60"),
  1197. "queue_drying_block": self._make_setting("false"),
  1198. "drying_presets": None,
  1199. }
  1200. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  1201. await scheduler._check_auto_drying(db, [], {1})
  1202. # PETG preset 65 -> max(40, 65-5) = 60
  1203. mock_pm.send_drying_command.assert_called_once_with(1, 0, 60, 12, mode=1, filament="PETG")
  1204. @pytest.mark.asyncio
  1205. @patch("backend.app.services.print_scheduler.printer_manager")
  1206. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1207. async def test_running_printer_skipped_when_toggle_off(self, mock_sd, mock_pm, scheduler):
  1208. """Toggle OFF: running printer is skipped even on capable hardware."""
  1209. mock_pm.get_status.return_value = self._state("01.03.00.00")
  1210. mock_pm.is_connected.return_value = True
  1211. mock_pm.get_model.return_value = "H2D"
  1212. scheduler._is_printer_idle = MagicMock(return_value=False)
  1213. db = AsyncMock()
  1214. settings_returns = {
  1215. "queue_drying_enabled": self._make_setting("true"),
  1216. "ambient_drying_enabled": self._make_setting("false"),
  1217. "print_drying_enabled": self._make_setting("false"),
  1218. "ams_humidity_fair": self._make_setting("60"),
  1219. "queue_drying_block": self._make_setting("false"),
  1220. "drying_presets": None,
  1221. }
  1222. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  1223. await scheduler._check_auto_drying(db, [], {1})
  1224. mock_pm.send_drying_command.assert_not_called()
  1225. @pytest.mark.asyncio
  1226. @patch("backend.app.services.print_scheduler.printer_manager")
  1227. async def test_running_printer_skipped_when_firmware_too_old(self, mock_pm, scheduler):
  1228. """Toggle ON but firmware below matrix threshold: skip."""
  1229. # H2D matrix minimum is 01.03.00.00; this is below
  1230. mock_pm.get_status.return_value = self._state("01.02.30.00")
  1231. mock_pm.is_connected.return_value = True
  1232. mock_pm.get_model.return_value = "H2D"
  1233. scheduler._is_printer_idle = MagicMock(return_value=False)
  1234. db = AsyncMock()
  1235. settings_returns = {
  1236. "queue_drying_enabled": self._make_setting("true"),
  1237. "ambient_drying_enabled": self._make_setting("false"),
  1238. "print_drying_enabled": self._make_setting("true"),
  1239. "ams_humidity_fair": self._make_setting("60"),
  1240. "queue_drying_block": self._make_setting("false"),
  1241. "drying_presets": None,
  1242. }
  1243. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  1244. await scheduler._check_auto_drying(db, [], {1})
  1245. mock_pm.send_drying_command.assert_not_called()
  1246. @pytest.mark.asyncio
  1247. @patch("backend.app.services.print_scheduler.printer_manager")
  1248. async def test_running_printer_skipped_when_model_excluded(self, mock_pm, scheduler):
  1249. """Toggle ON, recent firmware, but excluded model (A1): skip."""
  1250. mock_pm.get_status.return_value = self._state("99.99.99.99")
  1251. mock_pm.is_connected.return_value = True
  1252. mock_pm.get_model.return_value = "A1"
  1253. scheduler._is_printer_idle = MagicMock(return_value=False)
  1254. db = AsyncMock()
  1255. settings_returns = {
  1256. "queue_drying_enabled": self._make_setting("true"),
  1257. "ambient_drying_enabled": self._make_setting("false"),
  1258. "print_drying_enabled": self._make_setting("true"),
  1259. "ams_humidity_fair": self._make_setting("60"),
  1260. "queue_drying_block": self._make_setting("false"),
  1261. "drying_presets": None,
  1262. }
  1263. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  1264. await scheduler._check_auto_drying(db, [], {1})
  1265. mock_pm.send_drying_command.assert_not_called()
  1266. class TestAutoDryRearmGuards(_DryingTestBase):
  1267. """The re-arm loop from #2770.
  1268. An AMS reads higher humidity while it is warm than once it has cooled, so a
  1269. threshold set inside that band is never satisfied at the moment a cycle
  1270. ends. The firmware is separately free to end a cycle early when it decides
  1271. the filament is dry. Together those produced five 12-hour cycles armed in
  1272. four hours on the reporter's H2D, one of them six seconds after the previous
  1273. ended. These tests pin the two guards that break the loop and, just as
  1274. importantly, that neither guard ever stops a cycle that is running.
  1275. """
  1276. THRESHOLD = "14"
  1277. ABOVE = 16
  1278. BELOW = 10
  1279. @pytest.fixture
  1280. def scheduler(self):
  1281. return PrintScheduler()
  1282. @staticmethod
  1283. def _ams_state(dry_time, humidity):
  1284. state = MagicMock()
  1285. state.raw_data = {
  1286. "ams": [
  1287. {
  1288. "id": 0,
  1289. "module_type": "n3f",
  1290. "dry_time": dry_time,
  1291. "humidity_raw": str(humidity),
  1292. "dry_sf_reason": [],
  1293. "tray": [{"tray_type": "PLA"}],
  1294. }
  1295. ]
  1296. }
  1297. state.firmware_version = "01.09.00.00"
  1298. return state
  1299. def _db(self):
  1300. db = AsyncMock()
  1301. db.execute = AsyncMock(
  1302. side_effect=self._make_db_side_effect(
  1303. {
  1304. "queue_drying_enabled": self._make_setting("false"),
  1305. "ambient_drying_enabled": self._make_setting("true"),
  1306. "ams_humidity_fair": self._make_setting(self.THRESHOLD),
  1307. "queue_drying_block": self._make_setting("false"),
  1308. "drying_presets": None,
  1309. }
  1310. )
  1311. )
  1312. return db
  1313. async def _pass(self, scheduler, mock_pm, db, dry_time, humidity):
  1314. """One 30-second scheduler pass with the AMS in the given state."""
  1315. mock_pm.get_status.return_value = self._ams_state(dry_time, humidity)
  1316. await scheduler._check_auto_drying(db, [], set())
  1317. async def _unproductive_cycle(self, scheduler, mock_pm, db):
  1318. """Arm a cycle, watch it run, then see it end with humidity still high."""
  1319. await self._pass(scheduler, mock_pm, db, 0, self.ABOVE)
  1320. await self._pass(scheduler, mock_pm, db, 720, self.ABOVE)
  1321. await self._pass(scheduler, mock_pm, db, 0, self.ABOVE)
  1322. @pytest.mark.asyncio
  1323. @patch("backend.app.services.print_scheduler.printer_manager")
  1324. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1325. async def test_does_not_rearm_immediately_after_a_cycle_ends(self, mock_sd, mock_pm, scheduler):
  1326. """The six-second re-arm: one cycle ends, the next pass must not start another."""
  1327. mock_pm.is_connected.return_value = True
  1328. mock_pm.get_model.return_value = "H2D"
  1329. mock_pm.send_drying_command.return_value = True
  1330. scheduler._is_printer_idle = MagicMock(return_value=True)
  1331. db = self._db()
  1332. await self._unproductive_cycle(scheduler, mock_pm, db)
  1333. await self._pass(scheduler, mock_pm, db, 0, self.ABOVE)
  1334. assert mock_pm.send_drying_command.call_count == 1
  1335. @pytest.mark.asyncio
  1336. @patch("backend.app.services.print_scheduler.notification_service")
  1337. @patch("backend.app.services.print_scheduler.printer_manager")
  1338. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1339. async def test_suspends_after_repeated_unproductive_cycles(self, mock_sd, mock_pm, mock_notify, scheduler):
  1340. """Past the cooldown the loop would resume, so the counter has to stop it."""
  1341. mock_pm.is_connected.return_value = True
  1342. mock_pm.get_model.return_value = "H2D"
  1343. mock_pm.send_drying_command.return_value = True
  1344. mock_notify.on_ams_drying_suspended = AsyncMock()
  1345. scheduler._is_printer_idle = MagicMock(return_value=True)
  1346. db = self._db()
  1347. for _ in range(AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES):
  1348. await self._unproductive_cycle(scheduler, mock_pm, db)
  1349. # Age the last cycle out of its cooldown so the next arm is allowed.
  1350. scheduler._auto_dry_units[(1, 0)]["ended_at"] -= AUTO_DRY_REARM_COOLDOWN_SECONDS + 1
  1351. assert mock_pm.send_drying_command.call_count == AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES
  1352. await self._pass(scheduler, mock_pm, db, 0, self.ABOVE)
  1353. assert mock_pm.send_drying_command.call_count == AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES
  1354. assert scheduler._auto_dry_units[(1, 0)]["suspended"] is True
  1355. mock_notify.on_ams_drying_suspended.assert_awaited_once()
  1356. @pytest.mark.asyncio
  1357. @patch("backend.app.services.print_scheduler.notification_service")
  1358. @patch("backend.app.services.print_scheduler.printer_manager")
  1359. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1360. async def test_suspension_notifies_once_and_stays_put(self, mock_sd, mock_pm, mock_notify, scheduler):
  1361. """A suspension is a state, not a repeating alarm."""
  1362. mock_pm.is_connected.return_value = True
  1363. mock_pm.get_model.return_value = "H2D"
  1364. mock_pm.send_drying_command.return_value = True
  1365. mock_notify.on_ams_drying_suspended = AsyncMock()
  1366. scheduler._is_printer_idle = MagicMock(return_value=True)
  1367. db = self._db()
  1368. scheduler._auto_dry_units[(1, 0)] = {
  1369. "unproductive": AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES,
  1370. "suspended": False,
  1371. "ended_at": None,
  1372. }
  1373. for _ in range(4):
  1374. await self._pass(scheduler, mock_pm, db, 0, self.ABOVE)
  1375. mock_pm.send_drying_command.assert_not_called()
  1376. assert mock_notify.on_ams_drying_suspended.await_count == 1
  1377. @pytest.mark.asyncio
  1378. @patch("backend.app.services.print_scheduler.notification_service")
  1379. @patch("backend.app.services.print_scheduler.printer_manager")
  1380. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1381. async def test_humidity_dropping_lifts_the_suspension(self, mock_sd, mock_pm, mock_notify, scheduler):
  1382. """The reading coming down is the evidence that drying works after all."""
  1383. mock_pm.is_connected.return_value = True
  1384. mock_pm.get_model.return_value = "H2D"
  1385. mock_pm.send_drying_command.return_value = True
  1386. mock_notify.on_ams_drying_suspended = AsyncMock()
  1387. scheduler._is_printer_idle = MagicMock(return_value=True)
  1388. db = self._db()
  1389. scheduler._auto_dry_units[(1, 0)] = {
  1390. "unproductive": AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES,
  1391. "suspended": True,
  1392. "ended_at": None,
  1393. }
  1394. await self._pass(scheduler, mock_pm, db, 0, self.BELOW)
  1395. # The judgement is cleared, but the re-arm clock is kept (#2801).
  1396. assert not scheduler._auto_dry_units[(1, 0)].get("suspended")
  1397. assert not scheduler._auto_dry_units[(1, 0)].get("unproductive")
  1398. await self._pass(scheduler, mock_pm, db, 0, self.ABOVE)
  1399. mock_pm.send_drying_command.assert_called_once_with(1, 0, 45, 12, mode=1, filament="PLA")
  1400. @pytest.mark.asyncio
  1401. @patch("backend.app.services.print_scheduler.printer_manager")
  1402. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1403. async def test_manual_cycle_is_neither_counted_nor_interrupted(self, mock_sd, mock_pm, scheduler):
  1404. """A dry the user started by hand carries no history and is never stopped."""
  1405. mock_pm.is_connected.return_value = True
  1406. mock_pm.get_model.return_value = "H2D"
  1407. mock_pm.send_drying_command.return_value = True
  1408. scheduler._is_printer_idle = MagicMock(return_value=True)
  1409. db = self._db()
  1410. await self._pass(scheduler, mock_pm, db, 720, self.ABOVE)
  1411. await self._pass(scheduler, mock_pm, db, 600, self.ABOVE)
  1412. mock_pm.send_drying_command.assert_not_called()
  1413. assert (1, 0) not in scheduler._auto_dry_units
  1414. # It ends; Bambuddy is free to arm its own cycle, with a clean slate.
  1415. await self._pass(scheduler, mock_pm, db, 0, self.ABOVE)
  1416. mock_pm.send_drying_command.assert_called_once_with(1, 0, 45, 12, mode=1, filament="PLA")
  1417. assert scheduler._auto_dry_units[(1, 0)]["unproductive"] == 0
  1418. @pytest.mark.asyncio
  1419. @patch("backend.app.services.print_scheduler.printer_manager")
  1420. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1421. async def test_history_is_dropped_when_the_printer_goes_away(self, mock_sd, mock_pm, scheduler):
  1422. """A printer deleted and re-added must not inherit a suspension."""
  1423. mock_pm.get_status.return_value = None
  1424. scheduler._auto_dry_units[(1, 0)] = {"unproductive": 5, "suspended": True, "ended_at": None}
  1425. scheduler._sync_drying_state()
  1426. assert scheduler._auto_dry_units == {}
  1427. class TestAutoDryStoppedByBambuddy(_DryingTestBase):
  1428. """A cycle Bambuddy itself cut short must not count against the unit."""
  1429. THRESHOLD = "14"
  1430. ABOVE = 16
  1431. @pytest.fixture
  1432. def scheduler(self):
  1433. return PrintScheduler()
  1434. @staticmethod
  1435. def _ams_state(dry_time, humidity):
  1436. state = MagicMock()
  1437. state.raw_data = {
  1438. "ams": [
  1439. {
  1440. "id": 0,
  1441. "module_type": "n3f",
  1442. "dry_time": dry_time,
  1443. "humidity_raw": str(humidity),
  1444. "dry_sf_reason": [],
  1445. "tray": [{"tray_type": "PLA"}],
  1446. }
  1447. ]
  1448. }
  1449. state.firmware_version = "01.09.00.00"
  1450. return state
  1451. def _db(self):
  1452. db = AsyncMock()
  1453. db.execute = AsyncMock(
  1454. side_effect=self._make_db_side_effect(
  1455. {
  1456. "queue_drying_enabled": self._make_setting("false"),
  1457. "ambient_drying_enabled": self._make_setting("true"),
  1458. "ams_humidity_fair": self._make_setting(self.THRESHOLD),
  1459. "queue_drying_block": self._make_setting("false"),
  1460. "drying_presets": None,
  1461. }
  1462. )
  1463. )
  1464. return db
  1465. @pytest.mark.asyncio
  1466. @patch("backend.app.services.print_scheduler.printer_manager")
  1467. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1468. async def test_print_takes_priority_stop_is_not_an_unproductive_cycle(self, mock_sd, mock_pm, scheduler):
  1469. """The queue stopping a dry so a print can start is Bambuddy's own doing.
  1470. Counting it would suspend auto-drying on any printer that dries between
  1471. jobs often enough -- exactly the install queue-drying exists for.
  1472. """
  1473. mock_pm.is_connected.return_value = True
  1474. mock_pm.get_model.return_value = "H2D"
  1475. mock_pm.send_drying_command.return_value = True
  1476. scheduler._is_printer_idle = MagicMock(return_value=True)
  1477. db = self._db()
  1478. mock_pm.get_status.return_value = self._ams_state(0, self.ABOVE)
  1479. await scheduler._check_auto_drying(db, [], set())
  1480. mock_pm.get_status.return_value = self._ams_state(720, self.ABOVE)
  1481. await scheduler._check_auto_drying(db, [], set())
  1482. # A print is ready: check_queue stops drying on this printer.
  1483. await scheduler._stop_drying(1)
  1484. mock_pm.get_status.return_value = self._ams_state(0, self.ABOVE)
  1485. await scheduler._check_auto_drying(db, [], set())
  1486. assert scheduler._auto_dry_units[(1, 0)]["unproductive"] == 0
  1487. @pytest.mark.asyncio
  1488. @patch("backend.app.services.print_scheduler.printer_manager")
  1489. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1490. async def test_manual_stop_button_is_not_an_unproductive_cycle(self, mock_sd, mock_pm, scheduler):
  1491. """The Stop button goes straight to printer_manager, bypassing the
  1492. scheduler, so the route has to tell the scheduler to forget the cycle."""
  1493. mock_pm.is_connected.return_value = True
  1494. mock_pm.get_model.return_value = "H2D"
  1495. mock_pm.send_drying_command.return_value = True
  1496. scheduler._is_printer_idle = MagicMock(return_value=True)
  1497. db = self._db()
  1498. mock_pm.get_status.return_value = self._ams_state(0, self.ABOVE)
  1499. await scheduler._check_auto_drying(db, [], set())
  1500. mock_pm.get_status.return_value = self._ams_state(720, self.ABOVE)
  1501. await scheduler._check_auto_drying(db, [], set())
  1502. scheduler.forget_auto_dry_cycle(1, 0)
  1503. mock_pm.get_status.return_value = self._ams_state(0, self.ABOVE)
  1504. await scheduler._check_auto_drying(db, [], set())
  1505. assert scheduler._auto_dry_units[(1, 0)]["unproductive"] == 0
  1506. class TestAutoDryProgressKeepsItGoing(_DryingTestBase):
  1507. """Suspension must not punish a spool that is genuinely drying, just slowly."""
  1508. THRESHOLD = "25"
  1509. @pytest.fixture
  1510. def scheduler(self):
  1511. return PrintScheduler()
  1512. @staticmethod
  1513. def _ams_state(dry_time, humidity):
  1514. state = MagicMock()
  1515. state.raw_data = {
  1516. "ams": [
  1517. {
  1518. "id": 0,
  1519. "module_type": "n3f",
  1520. "dry_time": dry_time,
  1521. "humidity_raw": str(humidity),
  1522. "dry_sf_reason": [],
  1523. "tray": [{"tray_type": "PLA"}],
  1524. }
  1525. ]
  1526. }
  1527. state.firmware_version = "01.09.00.00"
  1528. return state
  1529. def _db(self):
  1530. db = AsyncMock()
  1531. db.execute = AsyncMock(
  1532. side_effect=self._make_db_side_effect(
  1533. {
  1534. "queue_drying_enabled": self._make_setting("false"),
  1535. "ambient_drying_enabled": self._make_setting("true"),
  1536. "ams_humidity_fair": self._make_setting(self.THRESHOLD),
  1537. "queue_drying_block": self._make_setting("false"),
  1538. "drying_presets": None,
  1539. }
  1540. )
  1541. )
  1542. return db
  1543. async def _cycle(self, scheduler, mock_pm, db, end_humidity):
  1544. """Arm a cycle, run it, and end it at the given reading."""
  1545. mock_pm.get_status.return_value = self._ams_state(0, end_humidity)
  1546. await scheduler._check_auto_drying(db, [], set())
  1547. mock_pm.get_status.return_value = self._ams_state(720, end_humidity)
  1548. await scheduler._check_auto_drying(db, [], set())
  1549. mock_pm.get_status.return_value = self._ams_state(0, end_humidity)
  1550. await scheduler._check_auto_drying(db, [], set())
  1551. entry = scheduler._auto_dry_units.get((1, 0))
  1552. if entry is not None and isinstance(entry.get("ended_at"), float):
  1553. entry["ended_at"] -= AUTO_DRY_REARM_COOLDOWN_SECONDS + 1
  1554. @pytest.mark.asyncio
  1555. @patch("backend.app.services.print_scheduler.notification_service")
  1556. @patch("backend.app.services.print_scheduler.printer_manager")
  1557. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1558. async def test_a_reading_that_keeps_falling_is_never_suspended(self, mock_sd, mock_pm, mock_notify, scheduler):
  1559. """40 -> 37 -> 35 -> 33 with a 25% threshold: nowhere near it yet, but
  1560. every cycle is working. A humid workshop must not lose auto-drying."""
  1561. mock_pm.is_connected.return_value = True
  1562. mock_pm.get_model.return_value = "H2D"
  1563. mock_pm.send_drying_command.return_value = True
  1564. mock_notify.on_ams_drying_suspended = AsyncMock()
  1565. scheduler._is_printer_idle = MagicMock(return_value=True)
  1566. db = self._db()
  1567. for reading in (40, 37, 35, 33, 31, 29):
  1568. await self._cycle(scheduler, mock_pm, db, reading)
  1569. assert scheduler._auto_dry_units[(1, 0)]["suspended"] is False
  1570. mock_notify.on_ams_drying_suspended.assert_not_awaited()
  1571. assert mock_pm.send_drying_command.call_count == 6
  1572. @pytest.mark.asyncio
  1573. @patch("backend.app.services.print_scheduler.notification_service")
  1574. @patch("backend.app.services.print_scheduler.printer_manager")
  1575. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1576. async def test_a_reading_oscillating_by_one_point_still_suspends(self, mock_sd, mock_pm, mock_notify, scheduler):
  1577. """A plateau with sensor noise — 31, 30, 31, 30 — is not progress.
  1578. Comparing against the previous cycle rather than the best-so-far would
  1579. read every other cycle as an improvement and loop indefinitely."""
  1580. mock_pm.is_connected.return_value = True
  1581. mock_pm.get_model.return_value = "H2D"
  1582. mock_pm.send_drying_command.return_value = True
  1583. mock_notify.on_ams_drying_suspended = AsyncMock()
  1584. scheduler._is_printer_idle = MagicMock(return_value=True)
  1585. db = self._db()
  1586. for reading in (31, 30, 31, 30, 31, 30):
  1587. await self._cycle(scheduler, mock_pm, db, reading)
  1588. assert scheduler._auto_dry_units[(1, 0)]["suspended"] is True
  1589. mock_notify.on_ams_drying_suspended.assert_awaited_once()
  1590. class TestAmbientDryingSustainedDelay(_DryingTestBase):
  1591. """#2518 — ambient auto-drying waits for CONTINUOUSLY-above-threshold humidity.
  1592. ``ambient_drying_sustained_minutes`` (default 0 = instant, matching the
  1593. pre-#2518 behavior) makes a pure-ambient start wait for the reading to sit
  1594. above the threshold for that many minutes straight before drying begins.
  1595. The streak lives in ``scheduler._auto_dry_above``, keyed like
  1596. ``_auto_dry_units`` by (printer_id, ams_id), but deliberately a separate
  1597. dict: arming a streak must never look like a Bambuddy-started cycle to
  1598. ``_auto_dry_units``, which is what ``_stop_drying`` and the manual-cycle
  1599. immunity in #2801 key off of.
  1600. """
  1601. UNIT_KEY = (1, 0)
  1602. @pytest.fixture
  1603. def scheduler(self):
  1604. return PrintScheduler()
  1605. @staticmethod
  1606. def _ams_unit(dry_time=0, humidity="75", include_humidity=True):
  1607. ams = {
  1608. "id": 0,
  1609. "module_type": "n3f",
  1610. "dry_time": dry_time,
  1611. "dry_sf_reason": [],
  1612. "tray": [{"tray_type": "PLA"}],
  1613. }
  1614. if include_humidity:
  1615. ams["humidity_raw"] = humidity
  1616. return ams
  1617. @classmethod
  1618. def _state(cls, dry_time=0, humidity="75", include_humidity=True):
  1619. state = MagicMock()
  1620. state.raw_data = {"ams": [cls._ams_unit(dry_time, humidity, include_humidity)]}
  1621. state.firmware_version = "01.09.00.00"
  1622. return state
  1623. def _db(self, sustained_minutes=None, queue_enabled="false", ambient_enabled="true"):
  1624. settings = {
  1625. "queue_drying_enabled": self._make_setting(queue_enabled),
  1626. "ambient_drying_enabled": self._make_setting(ambient_enabled),
  1627. "ams_humidity_fair": self._make_setting("60"),
  1628. "queue_drying_block": self._make_setting("false"),
  1629. "drying_presets": None,
  1630. }
  1631. if sustained_minutes is not None:
  1632. settings["ambient_drying_sustained_minutes"] = self._make_setting(str(sustained_minutes))
  1633. db = AsyncMock()
  1634. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings))
  1635. return db
  1636. @pytest.mark.asyncio
  1637. @patch("backend.app.services.print_scheduler.printer_manager")
  1638. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1639. async def test_setting_absent_dries_immediately(self, mock_sd, mock_pm, scheduler):
  1640. """No ``ambient_drying_sustained_minutes`` row → default 0 → instant dry,
  1641. same as ambient mode before #2518."""
  1642. mock_pm.get_status.return_value = self._state()
  1643. mock_pm.is_connected.return_value = True
  1644. mock_pm.get_model.return_value = "X1C"
  1645. mock_pm.send_drying_command.return_value = True
  1646. scheduler._is_printer_idle = MagicMock(return_value=True)
  1647. await scheduler._check_auto_drying(self._db(sustained_minutes=None), [], set())
  1648. mock_pm.send_drying_command.assert_called_once_with(1, 0, 45, 12, mode=1, filament="PLA")
  1649. @pytest.mark.asyncio
  1650. @patch("backend.app.services.print_scheduler.printer_manager")
  1651. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1652. async def test_first_above_threshold_pass_arms_but_does_not_dry(self, mock_sd, mock_pm, scheduler):
  1653. """First observation above threshold arms the streak instead of drying."""
  1654. mock_pm.get_status.return_value = self._state()
  1655. mock_pm.is_connected.return_value = True
  1656. mock_pm.get_model.return_value = "X1C"
  1657. scheduler._is_printer_idle = MagicMock(return_value=True)
  1658. await scheduler._check_auto_drying(self._db(sustained_minutes=5), [], set())
  1659. mock_pm.send_drying_command.assert_not_called()
  1660. assert self.UNIT_KEY in scheduler._auto_dry_above
  1661. @pytest.mark.asyncio
  1662. @patch("backend.app.services.print_scheduler.printer_manager")
  1663. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1664. async def test_matured_streak_dries_on_next_pass(self, mock_sd, mock_pm, scheduler):
  1665. """Once the streak's age clears the configured minutes, the next pass dries."""
  1666. mock_pm.get_status.return_value = self._state()
  1667. mock_pm.is_connected.return_value = True
  1668. mock_pm.get_model.return_value = "X1C"
  1669. mock_pm.send_drying_command.return_value = True
  1670. scheduler._is_printer_idle = MagicMock(return_value=True)
  1671. db = self._db(sustained_minutes=5)
  1672. await scheduler._check_auto_drying(db, [], set())
  1673. mock_pm.send_drying_command.assert_not_called()
  1674. # Age the streak past the 5-minute requirement, keeping "last" recent
  1675. # so the observation-gap guard doesn't treat it as a fresh streak.
  1676. scheduler._auto_dry_above[self.UNIT_KEY]["since"] = time.monotonic() - (5 * 60 + 5)
  1677. scheduler._auto_dry_above[self.UNIT_KEY]["last"] = time.monotonic() - 5
  1678. await scheduler._check_auto_drying(db, [], set())
  1679. mock_pm.send_drying_command.assert_called_once_with(1, 0, 45, 12, mode=1, filament="PLA")
  1680. @pytest.mark.asyncio
  1681. @patch("backend.app.services.print_scheduler.printer_manager")
  1682. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1683. async def test_dip_below_threshold_clears_streak(self, mock_sd, mock_pm, scheduler):
  1684. """A below-threshold reading between passes clears the streak — the wait restarts."""
  1685. mock_pm.is_connected.return_value = True
  1686. mock_pm.get_model.return_value = "X1C"
  1687. scheduler._is_printer_idle = MagicMock(return_value=True)
  1688. db = self._db(sustained_minutes=5)
  1689. mock_pm.get_status.return_value = self._state(humidity="75")
  1690. await scheduler._check_auto_drying(db, [], set())
  1691. assert self.UNIT_KEY in scheduler._auto_dry_above
  1692. mock_pm.get_status.return_value = self._state(humidity="40") # below the 60% threshold
  1693. await scheduler._check_auto_drying(db, [], set())
  1694. assert self.UNIT_KEY not in scheduler._auto_dry_above
  1695. @pytest.mark.asyncio
  1696. @patch("backend.app.services.print_scheduler.printer_manager")
  1697. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1698. async def test_missing_humidity_does_not_clear_armed_streak(self, mock_sd, mock_pm, scheduler):
  1699. """No humidity_raw/humidity in the AMS payload is no-information, not a dip below threshold."""
  1700. mock_pm.is_connected.return_value = True
  1701. mock_pm.get_model.return_value = "X1C"
  1702. scheduler._is_printer_idle = MagicMock(return_value=True)
  1703. db = self._db(sustained_minutes=5)
  1704. mock_pm.get_status.return_value = self._state(humidity="75")
  1705. await scheduler._check_auto_drying(db, [], set())
  1706. assert self.UNIT_KEY in scheduler._auto_dry_above
  1707. mock_pm.get_status.return_value = self._state(include_humidity=False)
  1708. await scheduler._check_auto_drying(db, [], set())
  1709. assert self.UNIT_KEY in scheduler._auto_dry_above
  1710. mock_pm.send_drying_command.assert_not_called()
  1711. @pytest.mark.asyncio
  1712. @patch("backend.app.services.print_scheduler.printer_manager")
  1713. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1714. async def test_manual_cycle_immunity(self, mock_sd, mock_pm, scheduler):
  1715. """Arming a streak must never create a ``_auto_dry_units`` entry — that dict is
  1716. reserved for cycles Bambuddy itself started (#2801's manual-cycle immunity). A
  1717. unit already drying by the user's own hand (dry_time > 0) with a streak still
  1718. present is left alone: no command sent, no entry created, streak untouched.
  1719. """
  1720. mock_pm.is_connected.return_value = True
  1721. mock_pm.get_model.return_value = "X1C"
  1722. scheduler._is_printer_idle = MagicMock(return_value=True)
  1723. db = self._db(sustained_minutes=5)
  1724. # First pass: nothing drying yet, arms the streak.
  1725. mock_pm.get_status.return_value = self._state(dry_time=0)
  1726. await scheduler._check_auto_drying(db, [], set())
  1727. assert self.UNIT_KEY not in scheduler._auto_dry_units
  1728. assert self.UNIT_KEY in scheduler._auto_dry_above
  1729. # A manual cycle is now running on the same unit — dry_time > 0.
  1730. mock_pm.get_status.return_value = self._state(dry_time=120)
  1731. await scheduler._check_auto_drying(db, [], set())
  1732. mock_pm.send_drying_command.assert_not_called()
  1733. assert self.UNIT_KEY not in scheduler._auto_dry_units
  1734. assert self.UNIT_KEY in scheduler._auto_dry_above
  1735. @pytest.mark.asyncio
  1736. @patch("backend.app.services.print_scheduler.printer_manager")
  1737. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1738. async def test_scheduled_queue_item_dries_immediately(self, mock_sd, mock_pm, scheduler):
  1739. """A printer with a scheduled (non-manual) queue item dries instantly —
  1740. the sustained wait applies only to pure-ambient starts."""
  1741. mock_pm.get_status.return_value = self._state()
  1742. mock_pm.is_connected.return_value = True
  1743. mock_pm.get_model.return_value = "X1C"
  1744. mock_pm.send_drying_command.return_value = True
  1745. scheduler._is_printer_idle = MagicMock(return_value=True)
  1746. db = self._db(sustained_minutes=5, queue_enabled="true", ambient_enabled="false")
  1747. item = MagicMock()
  1748. item.printer_id = 1
  1749. item.scheduled_time = datetime.now(timezone.utc) + timedelta(hours=2)
  1750. item.manual_start = False
  1751. await scheduler._check_auto_drying(db, [item], set())
  1752. mock_pm.send_drying_command.assert_called_once_with(1, 0, 45, 12, mode=1, filament="PLA")
  1753. @pytest.mark.asyncio
  1754. @patch("backend.app.services.print_scheduler.printer_manager")
  1755. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1756. async def test_observation_gap_restarts_the_streak(self, mock_sd, mock_pm, scheduler):
  1757. """A streak whose last observation is older than the gap ceiling
  1758. can no longer claim "continuously above" — it is treated as fresh and the
  1759. pass must not dry, even though 'since' alone clears the required minutes."""
  1760. mock_pm.get_status.return_value = self._state()
  1761. mock_pm.is_connected.return_value = True
  1762. mock_pm.get_model.return_value = "X1C"
  1763. scheduler._is_printer_idle = MagicMock(return_value=True)
  1764. old_since = time.monotonic() - (5 * 60 + 100)
  1765. scheduler._auto_dry_above[self.UNIT_KEY] = {
  1766. "since": old_since,
  1767. "last": time.monotonic() - (AUTO_DRY_SUSTAINED_GAP_FLOOR_SECONDS + 10),
  1768. }
  1769. await scheduler._check_auto_drying(self._db(sustained_minutes=5), [], set())
  1770. mock_pm.send_drying_command.assert_not_called()
  1771. # The streak was restarted, not left at its stale (matured) age.
  1772. assert scheduler._auto_dry_above[self.UNIT_KEY]["since"] > old_since + 90
  1773. @pytest.mark.asyncio
  1774. @patch("backend.app.services.print_scheduler.printer_manager")
  1775. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1776. async def test_suspension_wins_over_a_matured_streak(self, mock_sd, mock_pm, scheduler):
  1777. """A suspended unit (#2770) stays suspended even once the sustained wait matures."""
  1778. mock_pm.get_status.return_value = self._state()
  1779. mock_pm.is_connected.return_value = True
  1780. mock_pm.get_model.return_value = "X1C"
  1781. scheduler._is_printer_idle = MagicMock(return_value=True)
  1782. scheduler._auto_dry_units[self.UNIT_KEY] = {
  1783. "unproductive": AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES,
  1784. "suspended": True,
  1785. "ended_at": None,
  1786. }
  1787. scheduler._auto_dry_above[self.UNIT_KEY] = {
  1788. "since": time.monotonic() - (5 * 60 + 5),
  1789. "last": time.monotonic() - 5,
  1790. }
  1791. await scheduler._check_auto_drying(self._db(sustained_minutes=5), [], set())
  1792. mock_pm.send_drying_command.assert_not_called()
  1793. assert scheduler._auto_dry_units[self.UNIT_KEY]["suspended"] is True
  1794. def test_forget_auto_dry_cycle_pops_the_streak(self, scheduler):
  1795. """``forget_auto_dry_cycle`` (the route-side stop path) also spends the
  1796. streak that armed the cycle (#2518). The other cycle-end path — the
  1797. in-loop branch inside ``_check_auto_drying`` that pops ``running`` when
  1798. firmware reports ``dry_time == 0`` — is covered separately by
  1799. ``test_in_loop_cycle_end_restarts_the_streak``."""
  1800. scheduler._auto_dry_units[self.UNIT_KEY] = {
  1801. "running": True,
  1802. "unproductive": 0,
  1803. "suspended": False,
  1804. "ended_at": None,
  1805. }
  1806. scheduler._auto_dry_above[self.UNIT_KEY] = {"since": time.monotonic(), "last": time.monotonic()}
  1807. scheduler.forget_auto_dry_cycle(*self.UNIT_KEY)
  1808. assert self.UNIT_KEY not in scheduler._auto_dry_above
  1809. assert "running" not in scheduler._auto_dry_units[self.UNIT_KEY]
  1810. @pytest.mark.asyncio
  1811. @patch("backend.app.services.print_scheduler.printer_manager")
  1812. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1813. async def test_in_loop_cycle_end_restarts_the_streak(self, mock_sd, mock_pm, scheduler):
  1814. """When the in-loop cycle-end branch fires (a Bambuddy-armed cycle whose
  1815. ``dry_time`` has gone to 0), the matured streak that armed it is spent —
  1816. the pass re-arms a FRESH streak rather than keeping the old ``since``.
  1817. Without the pop, a matured pre-cycle streak would survive the cycle and
  1818. reduce the sustained wait to the re-arm cooldown on every re-arm."""
  1819. mock_pm.get_status.return_value = self._state()
  1820. mock_pm.is_connected.return_value = True
  1821. mock_pm.get_model.return_value = "X1C"
  1822. scheduler._is_printer_idle = MagicMock(return_value=True)
  1823. scheduler._auto_dry_units[self.UNIT_KEY] = {"running": True}
  1824. old_since = time.monotonic() - (60 * 60)
  1825. scheduler._auto_dry_above[self.UNIT_KEY] = {"since": old_since, "last": time.monotonic() - 5}
  1826. before = time.monotonic()
  1827. await scheduler._check_auto_drying(self._db(sustained_minutes=5), [], set())
  1828. # The cycle-end pass never dries (its own ended_at starts the cooldown)...
  1829. mock_pm.send_drying_command.assert_not_called()
  1830. # ...and the streak now on file must be fresh, not the pre-cycle one.
  1831. assert scheduler._auto_dry_above[self.UNIT_KEY]["since"] >= before
  1832. @pytest.mark.asyncio
  1833. @patch("backend.app.services.print_scheduler.printer_manager")
  1834. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1835. async def test_streak_arms_during_the_rearm_cooldown(self, mock_sd, mock_pm, scheduler):
  1836. """The wait OVERLAPS the 30-minute re-arm cooldown instead of stacking
  1837. after it: a pass blocked by the cooldown still arms/advances the streak,
  1838. so a streak matured inside the cooldown dries at cooldown expiry."""
  1839. mock_pm.get_status.return_value = self._state()
  1840. mock_pm.is_connected.return_value = True
  1841. mock_pm.get_model.return_value = "X1C"
  1842. mock_pm.send_drying_command.return_value = True
  1843. scheduler._is_printer_idle = MagicMock(return_value=True)
  1844. db = self._db(sustained_minutes=5)
  1845. scheduler._auto_dry_units[self.UNIT_KEY] = {"ended_at": time.monotonic() - 60}
  1846. await scheduler._check_auto_drying(db, [], set())
  1847. mock_pm.send_drying_command.assert_not_called()
  1848. # The cooldown blocked the start, but the streak armed anyway.
  1849. assert self.UNIT_KEY in scheduler._auto_dry_above
  1850. # Cooldown over; the streak that matured inside it satisfies the wait.
  1851. scheduler._auto_dry_units[self.UNIT_KEY]["ended_at"] = time.monotonic() - (AUTO_DRY_REARM_COOLDOWN_SECONDS + 5)
  1852. scheduler._auto_dry_above[self.UNIT_KEY]["since"] = time.monotonic() - (5 * 60 + 5)
  1853. scheduler._auto_dry_above[self.UNIT_KEY]["last"] = time.monotonic() - 5
  1854. await scheduler._check_auto_drying(db, [], set())
  1855. mock_pm.send_drying_command.assert_called_once_with(1, 0, 45, 12, mode=1, filament="PLA")
  1856. @pytest.mark.asyncio
  1857. @patch("backend.app.services.print_scheduler.printer_manager")
  1858. async def test_mid_print_ambient_start_waits_like_any_ambient_start(self, mock_pm, scheduler):
  1859. """With ambient drying on, a humidity start on a printer that happens to
  1860. be printing must serve the sustained wait like one on an idle printer.
  1861. (The original exemption here was disproven live — a 2-point threshold
  1862. crossing mid-print bought a parked 12h command instantly.)"""
  1863. state = self._state()
  1864. state.state = "RUNNING"
  1865. mock_pm.get_status.return_value = state
  1866. mock_pm.is_connected.return_value = True
  1867. mock_pm.get_model.return_value = "H2D"
  1868. mock_pm.send_drying_command.return_value = True
  1869. scheduler._is_printer_idle = MagicMock(return_value=False)
  1870. state.firmware_version = "01.03.00.00"
  1871. settings_returns = {
  1872. "queue_drying_enabled": self._make_setting("false"),
  1873. "ambient_drying_enabled": self._make_setting("true"),
  1874. "print_drying_enabled": self._make_setting("true"),
  1875. "ambient_drying_sustained_minutes": self._make_setting("5"),
  1876. "ams_humidity_fair": self._make_setting("60"),
  1877. "queue_drying_block": self._make_setting("false"),
  1878. "drying_presets": None,
  1879. }
  1880. db = AsyncMock()
  1881. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  1882. # First pass: the streak arms; no instant start.
  1883. await scheduler._check_auto_drying(db, [], {1})
  1884. mock_pm.send_drying_command.assert_not_called()
  1885. assert self.UNIT_KEY in scheduler._auto_dry_above
  1886. # Streak matured: the start fires, at the mid-print capped
  1887. # temperature max(40, 45 - 5) = 40.
  1888. scheduler._auto_dry_above[self.UNIT_KEY]["since"] = time.monotonic() - (5 * 60 + 5)
  1889. scheduler._auto_dry_above[self.UNIT_KEY]["last"] = time.monotonic() - 5
  1890. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  1891. await scheduler._check_auto_drying(db, [], {1})
  1892. mock_pm.send_drying_command.assert_called_once_with(1, 0, 40, 12, mode=1, filament="PLA")
  1893. @pytest.mark.asyncio
  1894. @patch("backend.app.services.print_scheduler.printer_manager")
  1895. async def test_mid_print_with_scheduled_item_stays_instant(self, mock_pm, scheduler):
  1896. """The instant exemption keys on the pending schedule — a printer
  1897. drying ahead of a scheduled job keeps it whether idle or printing."""
  1898. state = self._state()
  1899. state.state = "RUNNING"
  1900. mock_pm.get_status.return_value = state
  1901. mock_pm.is_connected.return_value = True
  1902. mock_pm.get_model.return_value = "H2D"
  1903. mock_pm.send_drying_command.return_value = True
  1904. scheduler._is_printer_idle = MagicMock(return_value=False)
  1905. state.firmware_version = "01.03.00.00"
  1906. settings_returns = {
  1907. "queue_drying_enabled": self._make_setting("false"),
  1908. "ambient_drying_enabled": self._make_setting("true"),
  1909. "print_drying_enabled": self._make_setting("true"),
  1910. "ambient_drying_sustained_minutes": self._make_setting("5"),
  1911. "ams_humidity_fair": self._make_setting("60"),
  1912. "queue_drying_block": self._make_setting("false"),
  1913. "drying_presets": None,
  1914. }
  1915. db = AsyncMock()
  1916. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  1917. item = MagicMock()
  1918. item.printer_id = 1
  1919. item.scheduled_time = datetime.now(timezone.utc) + timedelta(hours=2)
  1920. item.manual_start = False
  1921. await scheduler._check_auto_drying(db, [item], {1})
  1922. mock_pm.send_drying_command.assert_called_once_with(1, 0, 40, 12, mode=1, filament="PLA")
  1923. @pytest.mark.asyncio
  1924. @patch("backend.app.services.print_scheduler.printer_manager")
  1925. async def test_stored_wait_is_inert_while_ambient_drying_is_off(self, mock_pm, scheduler):
  1926. """The settings UI hides the wait while ambient drying is off, so a value
  1927. left behind must not keep delaying a mid-print start under print_drying
  1928. (queue mode on, no scheduled item): that start stays instant, and no
  1929. streak entry is written for it."""
  1930. state = self._state()
  1931. state.state = "RUNNING"
  1932. mock_pm.get_status.return_value = state
  1933. mock_pm.is_connected.return_value = True
  1934. mock_pm.get_model.return_value = "H2D"
  1935. mock_pm.send_drying_command.return_value = True
  1936. scheduler._is_printer_idle = MagicMock(return_value=False)
  1937. state.firmware_version = "01.03.00.00"
  1938. settings_returns = {
  1939. "queue_drying_enabled": self._make_setting("true"),
  1940. "ambient_drying_enabled": self._make_setting("false"),
  1941. "print_drying_enabled": self._make_setting("true"),
  1942. "ambient_drying_sustained_minutes": self._make_setting("15"),
  1943. "ams_humidity_fair": self._make_setting("60"),
  1944. "queue_drying_block": self._make_setting("false"),
  1945. "drying_presets": None,
  1946. }
  1947. db = AsyncMock()
  1948. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  1949. # A streak left over from when ambient drying was on is dropped too.
  1950. scheduler._auto_dry_above[self.UNIT_KEY] = {"since": time.monotonic(), "last": time.monotonic()}
  1951. await scheduler._check_auto_drying(db, [], {1})
  1952. mock_pm.send_drying_command.assert_called_once_with(1, 0, 40, 12, mode=1, filament="PLA")
  1953. assert scheduler._auto_dry_above == {}
  1954. @patch("backend.app.services.print_scheduler.printer_manager")
  1955. def test_sync_drying_state_prunes_streaks_of_vanished_printers(self, mock_pm, scheduler):
  1956. """A printer that has gone away takes its streak with it — a deleted and
  1957. re-added printer starts a fresh wait, and entries do not leak."""
  1958. mock_pm.get_status.return_value = None
  1959. scheduler._auto_dry_above[(99, 0)] = {"since": time.monotonic(), "last": time.monotonic()}
  1960. scheduler._auto_dry_units[(99, 0)] = {"unproductive": 1}
  1961. scheduler._sync_drying_state()
  1962. assert (99, 0) not in scheduler._auto_dry_above
  1963. assert (99, 0) not in scheduler._auto_dry_units
  1964. @pytest.mark.asyncio
  1965. @patch("backend.app.services.print_scheduler.printer_manager")
  1966. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1967. async def test_dip_below_threshold_logs_the_discard_at_info(self, mock_sd, mock_pm, scheduler, caplog):
  1968. """Discarding an armed streak on a real below-threshold reading is logged
  1969. at INFO — it is the only way an operator can tell why an ambient dry
  1970. never started."""
  1971. import logging
  1972. mock_pm.get_status.return_value = self._state()
  1973. mock_pm.is_connected.return_value = True
  1974. mock_pm.get_model.return_value = "X1C"
  1975. scheduler._is_printer_idle = MagicMock(return_value=True)
  1976. db = self._db(sustained_minutes=5)
  1977. await scheduler._check_auto_drying(db, [], set())
  1978. assert self.UNIT_KEY in scheduler._auto_dry_above
  1979. mock_pm.get_status.return_value = self._state(humidity="50")
  1980. with caplog.at_level(logging.INFO, logger="backend.app.services.print_scheduler"):
  1981. await scheduler._check_auto_drying(db, [], set())
  1982. assert self.UNIT_KEY not in scheduler._auto_dry_above
  1983. assert any("fell back" in rec.getMessage() for rec in caplog.records if rec.levelno == logging.INFO)
  1984. @pytest.mark.asyncio
  1985. @patch("backend.app.services.print_scheduler.printer_manager")
  1986. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1987. async def test_observation_gap_restart_logs_at_info(self, mock_sd, mock_pm, scheduler, caplog):
  1988. """Restarting a streak on an observation gap is logged at INFO with the
  1989. measured gap — the silent twin of the dip reset, and the only way a
  1990. user who set a long wait can see why it never matures."""
  1991. import logging
  1992. mock_pm.get_status.return_value = self._state()
  1993. mock_pm.is_connected.return_value = True
  1994. mock_pm.get_model.return_value = "X1C"
  1995. scheduler._is_printer_idle = MagicMock(return_value=True)
  1996. scheduler._auto_dry_above[self.UNIT_KEY] = {
  1997. "since": time.monotonic() - (5 * 60 + 100),
  1998. "last": time.monotonic() - (AUTO_DRY_SUSTAINED_GAP_FLOOR_SECONDS + 10),
  1999. }
  2000. with caplog.at_level(logging.INFO, logger="backend.app.services.print_scheduler"):
  2001. await scheduler._check_auto_drying(self._db(sustained_minutes=5), [], set())
  2002. restarts = [
  2003. rec for rec in caplog.records if rec.levelno == logging.INFO and "streak restarted" in rec.getMessage()
  2004. ]
  2005. assert len(restarts) == 1
  2006. # The measured gap is in the message, not just the fact of a restart.
  2007. assert re.search(r"\d+s observation gap", restarts[0].getMessage())
  2008. @pytest.mark.asyncio
  2009. @patch("backend.app.services.print_scheduler.printer_manager")
  2010. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  2011. async def test_gap_ceiling_scales_with_the_check_interval(self, mock_sd, mock_pm, scheduler):
  2012. """The ceiling is max(4 * check_interval, floor): a scheduler polling
  2013. slower than the default must not void streaks it structurally cannot
  2014. observe often enough (review on #2895)."""
  2015. mock_pm.get_status.return_value = self._state()
  2016. mock_pm.is_connected.return_value = True
  2017. mock_pm.get_model.return_value = "X1C"
  2018. mock_pm.send_drying_command.return_value = True
  2019. scheduler._is_printer_idle = MagicMock(return_value=True)
  2020. scheduler._check_interval = 100 # ceiling 400s, well above the 120s floor
  2021. scheduler._auto_dry_above[self.UNIT_KEY] = {
  2022. "since": time.monotonic() - (5 * 60 + 100),
  2023. # Over the floor, under 4 * check_interval: must NOT restart.
  2024. "last": time.monotonic() - (AUTO_DRY_SUSTAINED_GAP_FLOOR_SECONDS + 50),
  2025. }
  2026. await scheduler._check_auto_drying(self._db(sustained_minutes=5), [], set())
  2027. # The streak survived the slow pass and had already matured.
  2028. mock_pm.send_drying_command.assert_called_once()
  2029. @pytest.mark.asyncio
  2030. @patch("backend.app.services.print_scheduler.printer_manager")
  2031. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  2032. async def test_streak_tracking_is_inert_when_the_feature_is_off(self, mock_sd, mock_pm, scheduler):
  2033. """sustained_minutes 0 must not write streak entries — every install
  2034. would otherwise pay the bookkeeping for a feature nobody enabled
  2035. (review on #2895)."""
  2036. mock_pm.get_status.return_value = self._state()
  2037. mock_pm.is_connected.return_value = True
  2038. mock_pm.get_model.return_value = "X1C"
  2039. mock_pm.send_drying_command.return_value = True
  2040. scheduler._is_printer_idle = MagicMock(return_value=True)
  2041. await scheduler._check_auto_drying(self._db(sustained_minutes=0), [], set())
  2042. mock_pm.send_drying_command.assert_called_once()
  2043. assert scheduler._auto_dry_above == {}
  2044. @pytest.mark.asyncio
  2045. @patch("backend.app.services.print_scheduler.printer_manager")
  2046. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  2047. async def test_toggle_off_drops_a_leftover_streak_entry(self, mock_sd, mock_pm, scheduler):
  2048. """An entry armed while the feature was on is dropped once it is off,
  2049. so toggling back on later cannot inherit a stale streak."""
  2050. mock_pm.get_status.return_value = self._state()
  2051. mock_pm.is_connected.return_value = True
  2052. mock_pm.get_model.return_value = "X1C"
  2053. mock_pm.send_drying_command.return_value = True
  2054. scheduler._is_printer_idle = MagicMock(return_value=True)
  2055. scheduler._auto_dry_above[self.UNIT_KEY] = {
  2056. "since": time.monotonic() - 30,
  2057. "last": time.monotonic() - 1,
  2058. }
  2059. await scheduler._check_auto_drying(self._db(sustained_minutes=0), [], set())
  2060. assert self.UNIT_KEY not in scheduler._auto_dry_above
  2061. @pytest.mark.asyncio
  2062. @patch("backend.app.services.print_scheduler.printer_manager")
  2063. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  2064. async def test_disabling_all_auto_drying_drops_streak_before_early_return(self, mock_sd, mock_pm, scheduler):
  2065. """A quick disable/re-enable must start a fresh wait, even though the
  2066. disabled pass returns before visiting individual AMS units."""
  2067. mock_pm.get_status.return_value = self._state()
  2068. mock_pm.is_connected.return_value = True
  2069. mock_pm.get_model.return_value = "X1C"
  2070. mock_pm.send_drying_command.return_value = True
  2071. scheduler._is_printer_idle = MagicMock(return_value=True)
  2072. scheduler._auto_dry_above[self.UNIT_KEY] = {
  2073. "since": time.monotonic() - 360,
  2074. "last": time.monotonic() - 1,
  2075. }
  2076. await scheduler._check_auto_drying(self._db(sustained_minutes=5, ambient_enabled="false"), [], set())
  2077. assert self.UNIT_KEY not in scheduler._auto_dry_above
  2078. await scheduler._check_auto_drying(self._db(sustained_minutes=5), [], set())
  2079. mock_pm.send_drying_command.assert_not_called()
  2080. assert self.UNIT_KEY in scheduler._auto_dry_above
  2081. @pytest.mark.asyncio
  2082. @patch("backend.app.services.print_scheduler.printer_manager")
  2083. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  2084. async def test_firmware_refusal_skips_before_the_wait_logs(self, mock_sd, mock_pm, scheduler, caplog):
  2085. """A unit the firmware refuses to dry (dry_sf_reason set) hits its skip
  2086. before the sustained wait, so it never logs "waiting" for a dry it was
  2087. never going to get (review nit on #2895)."""
  2088. import logging
  2089. state = self._state()
  2090. state.raw_data["ams"][0]["dry_sf_reason"] = ["8"]
  2091. mock_pm.get_status.return_value = state
  2092. mock_pm.is_connected.return_value = True
  2093. mock_pm.get_model.return_value = "X1C"
  2094. scheduler._is_printer_idle = MagicMock(return_value=True)
  2095. with caplog.at_level(logging.DEBUG, logger="backend.app.services.print_scheduler"):
  2096. await scheduler._check_auto_drying(self._db(sustained_minutes=5), [], set())
  2097. messages = [rec.getMessage() for rec in caplog.records]
  2098. assert any("cannot dry reasons" in m for m in messages)
  2099. assert not any("waiting" in m for m in messages)