test_printer_manager.py 91 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305
  1. """Unit tests for PrinterManager service.
  2. Tests printer connection management, status tracking, and print control.
  3. """
  4. import logging
  5. from unittest.mock import AsyncMock, MagicMock, patch
  6. import pytest
  7. from backend.app.services.printer_manager import (
  8. PrinterManager,
  9. display_temperatures,
  10. drying_screen_only,
  11. get_derived_status_name,
  12. has_stg_cur_idle_bug,
  13. init_printer_connections,
  14. parse_plate_id,
  15. printer_state_to_dict,
  16. supports_chamber_temp,
  17. supports_drying,
  18. supports_drying_while_printing,
  19. )
  20. class TestPrinterManager:
  21. """Tests for PrinterManager class."""
  22. @pytest.fixture
  23. def manager(self):
  24. """Create a fresh PrinterManager instance."""
  25. return PrinterManager()
  26. @pytest.fixture
  27. def mock_printer(self):
  28. """Create a mock Printer object."""
  29. printer = MagicMock()
  30. printer.id = 1
  31. printer.ip_address = "192.168.1.100"
  32. printer.serial_number = "00M09A123456789"
  33. printer.access_code = "12345678"
  34. printer.is_active = True
  35. return printer
  36. @pytest.fixture
  37. def mock_client(self):
  38. """Create a mock BambuMQTTClient."""
  39. client = MagicMock()
  40. client.state = MagicMock()
  41. client.state.connected = True
  42. client.state.state = "IDLE"
  43. client.state.progress = 0
  44. client.state.temperatures = {"nozzle": 25, "bed": 25}
  45. client.state.raw_data = {}
  46. client.logging_enabled = False
  47. # mark_power_off is real logic on BambuMQTTClient (#2629) — mirror it so
  48. # the manager tests still exercise the state transition they assert on.
  49. # The real implementation (and its recovery path) is covered in
  50. # test_bambu_mqtt.py::TestPresumedPowerOffRecovery.
  51. def _mark_power_off():
  52. if not client.state.connected:
  53. return False
  54. client.state.connected = False
  55. client.state.state = "unknown"
  56. return True
  57. client.mark_power_off.side_effect = _mark_power_off
  58. return client
  59. # ========================================================================
  60. # Tests for initialization
  61. # ========================================================================
  62. def test_init_creates_empty_clients_dict(self, manager):
  63. """Verify manager initializes with empty clients dict."""
  64. assert manager._clients == {}
  65. def test_init_callbacks_are_none(self, manager):
  66. """Verify all callbacks are initially None."""
  67. assert manager._on_print_start is None
  68. assert manager._on_print_complete is None
  69. assert manager._on_status_change is None
  70. assert manager._on_ams_change is None
  71. def test_init_loop_is_none(self, manager):
  72. """Verify event loop is initially None."""
  73. assert manager._loop is None
  74. # ========================================================================
  75. # Tests for callback setters
  76. # ========================================================================
  77. def test_set_event_loop(self, manager):
  78. """Verify event loop can be set."""
  79. mock_loop = MagicMock()
  80. manager.set_event_loop(mock_loop)
  81. assert manager._loop == mock_loop
  82. def test_set_print_start_callback(self, manager):
  83. """Verify print start callback can be set."""
  84. callback = MagicMock()
  85. manager.set_print_start_callback(callback)
  86. assert manager._on_print_start == callback
  87. def test_set_print_complete_callback(self, manager):
  88. """Verify print complete callback can be set."""
  89. callback = MagicMock()
  90. manager.set_print_complete_callback(callback)
  91. assert manager._on_print_complete == callback
  92. def test_set_status_change_callback(self, manager):
  93. """Verify status change callback can be set."""
  94. callback = MagicMock()
  95. manager.set_status_change_callback(callback)
  96. assert manager._on_status_change == callback
  97. def test_set_ams_change_callback(self, manager):
  98. """Verify AMS change callback can be set."""
  99. callback = MagicMock()
  100. manager.set_ams_change_callback(callback)
  101. assert manager._on_ams_change == callback
  102. # ========================================================================
  103. # Tests for _schedule_async
  104. # ========================================================================
  105. def test_schedule_async_with_running_loop(self, manager):
  106. """Verify async coroutine is scheduled when loop is running."""
  107. mock_loop = MagicMock()
  108. mock_loop.is_running.return_value = True
  109. manager._loop = mock_loop
  110. async def dummy_coro():
  111. pass
  112. coro = dummy_coro()
  113. manager._schedule_async(coro)
  114. mock_loop.is_running.assert_called_once()
  115. # Clean up the coroutine
  116. coro.close()
  117. def test_schedule_async_without_loop(self, manager):
  118. """Verify nothing happens when no loop is set."""
  119. async def dummy_coro():
  120. pass
  121. coro = dummy_coro()
  122. # Should not raise
  123. manager._schedule_async(coro)
  124. coro.close()
  125. @staticmethod
  126. def _run_with_loop_thread(manager, coro_fn, cancel: bool):
  127. """Schedule on a real loop in a thread, the way the MQTT thread does.
  128. With ``cancel``, cancel the task once it runs, as stopping the loop
  129. does at shutdown. Returns the future ``_schedule_async`` created,
  130. settled, with its done-callback already run.
  131. """
  132. import asyncio
  133. import concurrent.futures
  134. import threading
  135. loop = asyncio.new_event_loop()
  136. thread = threading.Thread(target=loop.run_forever, daemon=True)
  137. thread.start()
  138. futures = []
  139. real = asyncio.run_coroutine_threadsafe
  140. def record(coro, target_loop):
  141. future = real(coro, target_loop)
  142. futures.append(future)
  143. return future
  144. started = threading.Event()
  145. async def wrapped():
  146. started.set()
  147. return await coro_fn()
  148. try:
  149. manager._loop = loop
  150. with patch("asyncio.run_coroutine_threadsafe", side_effect=record):
  151. manager._schedule_async(wrapped())
  152. assert len(futures) == 1
  153. if cancel:
  154. assert started.wait(timeout=5)
  155. loop.call_soon_threadsafe(lambda: [t.cancel() for t in asyncio.all_tasks(loop)])
  156. concurrent.futures.wait(futures, timeout=5)
  157. finally:
  158. # Callbacks run on the loop thread; joining it means they have run.
  159. loop.call_soon_threadsafe(loop.stop)
  160. thread.join(timeout=5)
  161. loop.close()
  162. return futures[0]
  163. def test_schedule_async_cancelled_callback_is_not_an_error(self, manager, caplog):
  164. """#3243: shutdown cancels pending callbacks; that must not log an ERROR."""
  165. import asyncio
  166. import logging
  167. async def slow():
  168. await asyncio.sleep(10)
  169. with caplog.at_level(logging.DEBUG, logger="backend.app.services.printer_manager"):
  170. future = self._run_with_loop_thread(manager, slow, cancel=True)
  171. assert future.cancelled()
  172. assert "Exception in scheduled callback" not in caplog.text
  173. def test_schedule_async_failing_callback_is_still_logged(self, manager, caplog):
  174. import logging
  175. async def boom():
  176. raise RuntimeError("callback failed")
  177. with caplog.at_level(logging.ERROR, logger="backend.app.services.printer_manager"):
  178. future = self._run_with_loop_thread(manager, boom, cancel=False)
  179. assert isinstance(future.exception(), RuntimeError)
  180. assert "Exception in scheduled callback: callback failed" in caplog.text
  181. def test_schedule_async_with_stopped_loop(self, manager):
  182. """Verify nothing happens when loop is not running."""
  183. mock_loop = MagicMock()
  184. mock_loop.is_running.return_value = False
  185. manager._loop = mock_loop
  186. async def dummy_coro():
  187. pass
  188. coro = dummy_coro()
  189. manager._schedule_async(coro)
  190. coro.close()
  191. # ========================================================================
  192. # Tests for connect_printer
  193. # ========================================================================
  194. @pytest.mark.asyncio
  195. async def test_connect_printer_creates_client(self, manager, mock_printer):
  196. """Verify connecting creates an MQTT client."""
  197. with patch("backend.app.services.printer_manager.BambuMQTTClient") as MockClient:
  198. mock_instance = MagicMock()
  199. mock_instance.state = MagicMock()
  200. mock_instance.state.connected = True
  201. MockClient.return_value = mock_instance
  202. result = await manager.connect_printer(mock_printer)
  203. MockClient.assert_called_once()
  204. mock_instance.connect.assert_called_once()
  205. assert mock_printer.id in manager._clients
  206. assert result is True
  207. @pytest.mark.asyncio
  208. async def test_connect_printer_disconnects_existing(self, manager, mock_printer, mock_client):
  209. """Verify connecting disconnects existing client first."""
  210. manager._clients[mock_printer.id] = mock_client
  211. with patch("backend.app.services.printer_manager.BambuMQTTClient") as MockClient:
  212. new_client = MagicMock()
  213. new_client.state = MagicMock()
  214. new_client.state.connected = True
  215. MockClient.return_value = new_client
  216. await manager.connect_printer(mock_printer)
  217. mock_client.disconnect.assert_called_once()
  218. @pytest.mark.asyncio
  219. async def test_connect_printer_returns_false_on_failure(self, manager, mock_printer):
  220. """Verify returns False when connection fails."""
  221. with patch("backend.app.services.printer_manager.BambuMQTTClient") as MockClient:
  222. mock_instance = MagicMock()
  223. mock_instance.state = MagicMock()
  224. mock_instance.state.connected = False
  225. MockClient.return_value = mock_instance
  226. result = await manager.connect_printer(mock_printer)
  227. assert result is False
  228. # ========================================================================
  229. # Tests for disconnect_printer
  230. # ========================================================================
  231. def test_disconnect_printer_removes_client(self, manager, mock_client):
  232. """Verify disconnecting removes and disconnects client."""
  233. manager._clients[1] = mock_client
  234. manager.disconnect_printer(1)
  235. mock_client.disconnect.assert_called_once()
  236. assert 1 not in manager._clients
  237. def test_disconnect_printer_handles_missing(self, manager):
  238. """Verify disconnecting non-existent printer doesn't raise."""
  239. manager.disconnect_printer(999) # Should not raise
  240. # ========================================================================
  241. # Tests for disconnect_all
  242. # ========================================================================
  243. def test_disconnect_all_disconnects_all_clients(self, manager):
  244. """Verify all clients are disconnected."""
  245. client1 = MagicMock()
  246. client2 = MagicMock()
  247. manager._clients[1] = client1
  248. manager._clients[2] = client2
  249. manager.disconnect_all()
  250. client1.disconnect.assert_called_once()
  251. client2.disconnect.assert_called_once()
  252. assert len(manager._clients) == 0
  253. # ========================================================================
  254. # Tests for get_status
  255. # ========================================================================
  256. def test_get_status_returns_state(self, manager, mock_client):
  257. """Verify get_status returns client state."""
  258. manager._clients[1] = mock_client
  259. result = manager.get_status(1)
  260. mock_client.check_staleness.assert_called_once()
  261. assert result == mock_client.state
  262. def test_get_status_returns_none_for_unknown(self, manager):
  263. """Verify get_status returns None for unknown printer."""
  264. result = manager.get_status(999)
  265. assert result is None
  266. # ========================================================================
  267. # Tests for get_all_statuses
  268. # ========================================================================
  269. def test_get_all_statuses_returns_all(self, manager):
  270. """Verify all statuses are returned."""
  271. client1 = MagicMock()
  272. client1.state = MagicMock(connected=True)
  273. client2 = MagicMock()
  274. client2.state = MagicMock(connected=False)
  275. manager._clients[1] = client1
  276. manager._clients[2] = client2
  277. result = manager.get_all_statuses()
  278. assert len(result) == 2
  279. assert 1 in result
  280. assert 2 in result
  281. client1.check_staleness.assert_called_once()
  282. client2.check_staleness.assert_called_once()
  283. # ========================================================================
  284. # Tests for is_connected
  285. # ========================================================================
  286. def test_is_connected_returns_true(self, manager, mock_client):
  287. """Verify is_connected returns True for connected printer."""
  288. mock_client.check_staleness.return_value = True
  289. manager._clients[1] = mock_client
  290. result = manager.is_connected(1)
  291. assert result is True
  292. def test_is_connected_returns_false_for_unknown(self, manager):
  293. """Verify is_connected returns False for unknown printer."""
  294. result = manager.is_connected(999)
  295. assert result is False
  296. # ========================================================================
  297. # Tests for get_client
  298. # ========================================================================
  299. def test_get_client_returns_client(self, manager, mock_client):
  300. """Verify get_client returns the client."""
  301. manager._clients[1] = mock_client
  302. result = manager.get_client(1)
  303. assert result == mock_client
  304. def test_get_client_returns_none_for_unknown(self, manager):
  305. """Verify get_client returns None for unknown printer."""
  306. result = manager.get_client(999)
  307. assert result is None
  308. # ========================================================================
  309. # Tests for mark_printer_offline
  310. # ========================================================================
  311. def test_mark_printer_offline_updates_state(self, manager, mock_client):
  312. """Verify mark_printer_offline updates client state."""
  313. mock_client.state.connected = True
  314. manager._clients[1] = mock_client
  315. manager.mark_printer_offline(1)
  316. assert mock_client.state.connected is False
  317. assert mock_client.state.state == "unknown"
  318. def test_mark_printer_offline_triggers_callback(self, manager, mock_client):
  319. """Verify mark_printer_offline triggers status callback."""
  320. mock_client.state.connected = True
  321. manager._clients[1] = mock_client
  322. # Callback must return a coroutine
  323. async def async_callback(printer_id, state):
  324. pass
  325. manager._on_status_change = async_callback
  326. # Need a running loop for callback
  327. mock_loop = MagicMock()
  328. mock_loop.is_running.return_value = True
  329. manager._loop = mock_loop
  330. manager.mark_printer_offline(1)
  331. # Callback should be scheduled via run_coroutine_threadsafe
  332. mock_loop.is_running.assert_called()
  333. # State should be updated
  334. assert mock_client.state.connected is False
  335. def test_mark_printer_offline_handles_unknown(self, manager):
  336. """Verify mark_printer_offline handles unknown printer."""
  337. manager.mark_printer_offline(999) # Should not raise
  338. def test_mark_printer_offline_skips_already_offline(self, manager, mock_client):
  339. """Verify mark_printer_offline skips already offline printer."""
  340. mock_client.state.connected = False
  341. manager._clients[1] = mock_client
  342. manager.mark_printer_offline(1)
  343. # State should remain unchanged
  344. assert mock_client.state.connected is False
  345. # ========================================================================
  346. # Tests for start_print
  347. # ========================================================================
  348. def test_start_print_calls_client(self, manager, mock_client):
  349. """Verify start_print calls client method."""
  350. mock_client.start_print.return_value = True
  351. manager._clients[1] = mock_client
  352. result = manager.start_print(1, "test.gcode")
  353. mock_client.start_print.assert_called_once_with(
  354. "test.gcode",
  355. 1,
  356. ams_mapping=None,
  357. timelapse=False,
  358. bed_levelling="auto",
  359. flow_cali="auto",
  360. vibration_cali=True,
  361. layer_inspect=False,
  362. use_ams=True,
  363. nozzle_offset_cali="auto",
  364. nozzle_mapping=None,
  365. nozzle_slot_extruders=None,
  366. )
  367. assert result is True
  368. def test_start_print_returns_false_for_unknown(self, manager):
  369. """Verify start_print returns False for unknown printer."""
  370. result = manager.start_print(999, "test.gcode")
  371. assert result is False
  372. def test_start_print_logs_print_command_with_caller(self, manager, mock_client, caplog):
  373. """Verify start_print logs PRINT COMMAND with caller info (#374)."""
  374. mock_client.start_print.return_value = True
  375. manager._clients[1] = mock_client
  376. with caplog.at_level(logging.INFO, logger="backend.app.services.printer_manager"):
  377. manager.start_print(1, "benchy.3mf")
  378. print_cmd_logs = [r for r in caplog.records if "PRINT COMMAND" in r.message]
  379. assert len(print_cmd_logs) == 1
  380. log_msg = print_cmd_logs[0].message
  381. assert "printer=1" in log_msg
  382. assert "file=benchy.3mf" in log_msg
  383. assert "caller=" in log_msg
  384. def test_start_print_logs_even_when_printer_unknown(self, manager, caplog):
  385. """Verify PRINT COMMAND is logged even for unknown printers (#374)."""
  386. with caplog.at_level(logging.INFO, logger="backend.app.services.printer_manager"):
  387. result = manager.start_print(999, "ghost.3mf")
  388. assert result is False
  389. print_cmd_logs = [r for r in caplog.records if "PRINT COMMAND" in r.message]
  390. assert len(print_cmd_logs) == 1
  391. # ========================================================================
  392. # Tests for stop_print
  393. # ========================================================================
  394. def test_stop_print_calls_client(self, manager, mock_client):
  395. """Verify stop_print calls client method."""
  396. mock_client.stop_print.return_value = True
  397. manager._clients[1] = mock_client
  398. result = manager.stop_print(1)
  399. mock_client.stop_print.assert_called_once()
  400. assert result is True
  401. def test_stop_print_returns_false_for_unknown(self, manager):
  402. """Verify stop_print returns False for unknown printer."""
  403. result = manager.stop_print(999)
  404. assert result is False
  405. # ========================================================================
  406. # Tests for wait_for_cooldown
  407. # ========================================================================
  408. @pytest.mark.asyncio
  409. async def test_wait_for_cooldown_returns_true_when_cool(self, manager, mock_client):
  410. """Verify wait_for_cooldown returns True when printer is cool."""
  411. mock_client.state.connected = True
  412. mock_client.state.temperatures = {"nozzle": 40, "bed": 30}
  413. mock_client.check_staleness.return_value = True
  414. manager._clients[1] = mock_client
  415. result = await manager.wait_for_cooldown(1, target_temp=50)
  416. assert result is True
  417. @pytest.mark.asyncio
  418. async def test_wait_for_cooldown_returns_false_on_disconnect(self, manager, mock_client):
  419. """Verify wait_for_cooldown returns False when printer disconnects."""
  420. mock_client.state.connected = False
  421. mock_client.check_staleness.return_value = False
  422. manager._clients[1] = mock_client
  423. result = await manager.wait_for_cooldown(1, target_temp=50, timeout=1)
  424. assert result is False
  425. @pytest.mark.asyncio
  426. async def test_wait_for_cooldown_returns_false_for_unknown(self, manager):
  427. """Verify wait_for_cooldown returns False for unknown printer."""
  428. result = await manager.wait_for_cooldown(999, target_temp=50, timeout=1)
  429. assert result is False
  430. @pytest.mark.asyncio
  431. async def test_wait_for_cooldown_checks_both_nozzles(self, manager, mock_client):
  432. """Verify wait_for_cooldown checks both nozzles for dual extruders."""
  433. mock_client.state.connected = True
  434. mock_client.state.temperatures = {"nozzle": 40, "nozzle_2": 45, "bed": 30}
  435. mock_client.check_staleness.return_value = True
  436. manager._clients[1] = mock_client
  437. result = await manager.wait_for_cooldown(1, target_temp=50)
  438. assert result is True
  439. # ========================================================================
  440. # Tests for is_print_active (#1890)
  441. # ========================================================================
  442. @pytest.mark.parametrize(
  443. "state,expected",
  444. [
  445. ("RUNNING", True),
  446. ("PAUSE", True),
  447. ("PREPARE", True),
  448. ("SLICING", True),
  449. ("FINISH", False),
  450. ("IDLE", False),
  451. ("FAILED", False),
  452. ("unknown", False),
  453. ],
  454. )
  455. def test_is_print_active_state_matrix(self, manager, mock_client, state, expected):
  456. """A job-loaded state is 'active'; idle/terminal states are not."""
  457. mock_client.state.connected = True
  458. mock_client.state.state = state
  459. mock_client.check_staleness.return_value = True
  460. manager._clients[1] = mock_client
  461. assert manager.is_print_active(1) is expected
  462. def test_is_print_active_false_when_disconnected(self, manager, mock_client):
  463. """Even in RUNNING, a disconnected printer is not treated as active —
  464. we fail safe (no active print) only for the 'nothing printing' cases."""
  465. mock_client.state.connected = False
  466. mock_client.state.state = "RUNNING"
  467. mock_client.check_staleness.return_value = False
  468. manager._clients[1] = mock_client
  469. assert manager.is_print_active(1) is False
  470. def test_is_print_active_false_for_unknown_printer(self, manager):
  471. """Unknown printer id → not active (no client)."""
  472. assert manager.is_print_active(999) is False
  473. # ========================================================================
  474. # Tests for logging methods
  475. # ========================================================================
  476. def test_enable_logging_calls_client(self, manager, mock_client):
  477. """Verify enable_logging calls client method."""
  478. manager._clients[1] = mock_client
  479. result = manager.enable_logging(1, True)
  480. mock_client.enable_logging.assert_called_once_with(True)
  481. assert result is True
  482. def test_enable_logging_returns_false_for_unknown(self, manager):
  483. """Verify enable_logging returns False for unknown printer."""
  484. result = manager.enable_logging(999, True)
  485. assert result is False
  486. def test_get_logs_returns_logs(self, manager, mock_client):
  487. """Verify get_logs returns client logs."""
  488. mock_logs = [MagicMock(), MagicMock()]
  489. mock_client.get_logs.return_value = mock_logs
  490. manager._clients[1] = mock_client
  491. result = manager.get_logs(1)
  492. assert result == mock_logs
  493. def test_get_logs_returns_empty_for_unknown(self, manager):
  494. """Verify get_logs returns empty list for unknown printer."""
  495. result = manager.get_logs(999)
  496. assert result == []
  497. def test_clear_logs_calls_client(self, manager, mock_client):
  498. """Verify clear_logs calls client method."""
  499. manager._clients[1] = mock_client
  500. result = manager.clear_logs(1)
  501. mock_client.clear_logs.assert_called_once()
  502. assert result is True
  503. def test_clear_logs_returns_false_for_unknown(self, manager):
  504. """Verify clear_logs returns False for unknown printer."""
  505. result = manager.clear_logs(999)
  506. assert result is False
  507. def test_is_logging_enabled_returns_status(self, manager, mock_client):
  508. """Verify is_logging_enabled returns client status."""
  509. mock_client.logging_enabled = True
  510. manager._clients[1] = mock_client
  511. result = manager.is_logging_enabled(1)
  512. assert result is True
  513. def test_is_logging_enabled_returns_false_for_unknown(self, manager):
  514. """Verify is_logging_enabled returns False for unknown printer."""
  515. result = manager.is_logging_enabled(999)
  516. assert result is False
  517. # ========================================================================
  518. # Tests for request_status_update
  519. # ========================================================================
  520. def test_request_status_update_calls_client(self, manager, mock_client):
  521. """Verify request_status_update calls client method."""
  522. mock_client.request_status_update.return_value = True
  523. manager._clients[1] = mock_client
  524. result = manager.request_status_update(1)
  525. mock_client.request_status_update.assert_called_once()
  526. assert result is True
  527. def test_request_status_update_returns_false_for_unknown(self, manager):
  528. """Verify request_status_update returns False for unknown printer."""
  529. result = manager.request_status_update(999)
  530. assert result is False
  531. # ========================================================================
  532. # Tests for test_connection
  533. # ========================================================================
  534. @pytest.mark.asyncio
  535. async def test_test_connection_success(self, manager):
  536. """Verify test_connection returns success on connection."""
  537. with patch("backend.app.services.printer_manager.BambuMQTTClient") as MockClient:
  538. mock_instance = MagicMock()
  539. mock_instance.state = MagicMock()
  540. mock_instance.state.connected = True
  541. mock_instance.state.state = "IDLE"
  542. mock_instance.state.raw_data = {"device_model": "X1C"}
  543. MockClient.return_value = mock_instance
  544. result = await manager.test_connection("192.168.1.100", "00M09A123456789", "12345678")
  545. assert result["success"] is True
  546. assert result["state"] == "IDLE"
  547. assert result["model"] == "X1C"
  548. mock_instance.disconnect.assert_called_once()
  549. @pytest.mark.asyncio
  550. async def test_test_connection_failure(self, manager):
  551. """Verify test_connection returns failure on connection error."""
  552. with patch("backend.app.services.printer_manager.BambuMQTTClient") as MockClient:
  553. mock_instance = MagicMock()
  554. mock_instance.state = MagicMock()
  555. mock_instance.state.connected = False
  556. MockClient.return_value = mock_instance
  557. # Shorten the probe budget so the test doesn't burn the full
  558. # 8-second production timeout while polling a failing connection.
  559. with (
  560. patch.object(manager, "PROBE_TIMEOUT_SECONDS", 0.4),
  561. patch.object(manager, "PROBE_POLL_INTERVAL_SECONDS", 0.1),
  562. ):
  563. result = await manager.test_connection("192.168.1.100", "00M09A123456789", "12345678")
  564. assert result["success"] is False
  565. assert result["state"] is None
  566. mock_instance.disconnect.assert_called_once()
  567. @pytest.mark.asyncio
  568. async def test_test_connection_polls_and_returns_early_on_connect(self, manager):
  569. """#1445: a slow printer that finishes its handshake mid-probe must
  570. not be reported as a failure. Previously a fixed 2s sleep meant P1S
  571. TLS / CONNACK that took 3-5s got falsely rejected; now we poll and
  572. early-return as soon as connected flips True.
  573. """
  574. import asyncio
  575. import time
  576. with patch("backend.app.services.printer_manager.BambuMQTTClient") as MockClient:
  577. mock_instance = MagicMock()
  578. mock_instance.state = MagicMock()
  579. mock_instance.state.connected = False # not connected at probe start
  580. mock_instance.state.state = "IDLE"
  581. mock_instance.state.raw_data = {"device_model": "P1S"}
  582. MockClient.return_value = mock_instance
  583. async def flip_connected_after(delay: float):
  584. await asyncio.sleep(delay)
  585. mock_instance.state.connected = True
  586. # Simulates the P1S broker finishing its slow handshake ~0.5s in,
  587. # well past the old 2s-or-fail boundary's natural variance.
  588. with (
  589. patch.object(manager, "PROBE_TIMEOUT_SECONDS", 3.0),
  590. patch.object(manager, "PROBE_POLL_INTERVAL_SECONDS", 0.05),
  591. ):
  592. start = time.monotonic()
  593. flip_task = asyncio.create_task(flip_connected_after(0.5))
  594. try:
  595. result = await manager.test_connection("192.168.1.100", "00M09A123456789", "12345678")
  596. finally:
  597. await flip_task
  598. elapsed = time.monotonic() - start
  599. assert result["success"] is True
  600. assert result["state"] == "IDLE"
  601. # Early-return guarantee: must come back well before the configured
  602. # timeout once connected flips. ~0.5s + one poll interval is plenty.
  603. assert elapsed < 1.5, f"probe should have early-returned shortly after 0.5s, took {elapsed:.2f}s"
  604. @pytest.mark.asyncio
  605. async def test_test_connection_disconnect_runs_off_loop(self, manager):
  606. """#1445: the root cause of the "Docker container hangs" symptom was
  607. `client.disconnect()` running on the asyncio thread — paho's
  608. `loop_stop()` does a thread-join that blocks until its network
  609. thread exits, which on a slow P1S TLS handshake could take many
  610. seconds. This test pins the off-loop teardown so a regression that
  611. reintroduces sync disconnect breaks CI immediately.
  612. """
  613. import asyncio
  614. import threading
  615. import time
  616. with patch("backend.app.services.printer_manager.BambuMQTTClient") as MockClient:
  617. asyncio_thread_id = threading.get_ident()
  618. disconnect_thread_ids: list[int] = []
  619. disconnect_blocked_for: list[float] = []
  620. def slow_blocking_disconnect():
  621. # Mirrors paho.Client.loop_stop()'s thread-join semantics —
  622. # if this runs on the asyncio thread the event loop stalls.
  623. disconnect_thread_ids.append(threading.get_ident())
  624. start = time.monotonic()
  625. time.sleep(0.4)
  626. disconnect_blocked_for.append(time.monotonic() - start)
  627. mock_instance = MagicMock()
  628. mock_instance.state = MagicMock()
  629. mock_instance.state.connected = True
  630. mock_instance.state.state = "IDLE"
  631. mock_instance.state.raw_data = {"device_model": "P1S"}
  632. mock_instance.disconnect = slow_blocking_disconnect
  633. MockClient.return_value = mock_instance
  634. # Another coroutine must keep making progress while disconnect()
  635. # runs — proves the event loop was not blocked.
  636. event_loop_alive_ticks = 0
  637. async def heartbeat():
  638. nonlocal event_loop_alive_ticks
  639. while True:
  640. await asyncio.sleep(0.05)
  641. event_loop_alive_ticks += 1
  642. heartbeat_task = asyncio.create_task(heartbeat())
  643. try:
  644. await manager.test_connection("192.168.1.100", "00M09A123456789", "12345678")
  645. finally:
  646. heartbeat_task.cancel()
  647. try:
  648. await heartbeat_task
  649. except asyncio.CancelledError:
  650. pass
  651. # disconnect ran on a different thread than asyncio's
  652. assert disconnect_thread_ids, "disconnect was never called"
  653. assert disconnect_thread_ids[0] != asyncio_thread_id, (
  654. "disconnect ran on the asyncio thread — this blocks the event loop (#1445)"
  655. )
  656. # Heartbeat made progress while the 0.4s disconnect was blocking
  657. # the worker thread (proves the loop wasn't stalled).
  658. assert event_loop_alive_ticks >= 3, (
  659. f"event loop appears to have stalled during disconnect "
  660. f"(only {event_loop_alive_ticks} heartbeats; expected >=3)"
  661. )
  662. # ========================================================================
  663. # Tests for current print user tracking (Issue #206)
  664. # ========================================================================
  665. def test_set_current_print_user(self, manager):
  666. """Verify current print user can be set."""
  667. manager.set_current_print_user(1, 42, "testuser")
  668. assert 1 in manager._current_print_user
  669. assert manager._current_print_user[1]["user_id"] == 42
  670. assert manager._current_print_user[1]["username"] == "testuser"
  671. def test_get_current_print_user_returns_user(self, manager):
  672. """Verify get_current_print_user returns the stored user."""
  673. manager.set_current_print_user(1, 42, "testuser")
  674. result = manager.get_current_print_user(1)
  675. assert result is not None
  676. assert result["user_id"] == 42
  677. assert result["username"] == "testuser"
  678. def test_get_current_print_user_returns_none_for_unknown(self, manager):
  679. """Verify get_current_print_user returns None for unknown printer."""
  680. result = manager.get_current_print_user(999)
  681. assert result is None
  682. def test_clear_current_print_user(self, manager):
  683. """Verify current print user can be cleared."""
  684. manager.set_current_print_user(1, 42, "testuser")
  685. manager.clear_current_print_user(1)
  686. result = manager.get_current_print_user(1)
  687. assert result is None
  688. def test_clear_current_print_user_no_error_for_unknown(self, manager):
  689. """Verify clearing unknown printer doesn't raise error."""
  690. # Should not raise
  691. manager.clear_current_print_user(999)
  692. def test_set_current_print_user_overwrites_existing(self, manager):
  693. """Verify setting user overwrites existing value."""
  694. manager.set_current_print_user(1, 42, "user1")
  695. manager.set_current_print_user(1, 99, "user2")
  696. result = manager.get_current_print_user(1)
  697. assert result["user_id"] == 99
  698. assert result["username"] == "user2"
  699. def test_multiple_printers_have_separate_users(self, manager):
  700. """Verify each printer tracks its own user separately."""
  701. manager.set_current_print_user(1, 42, "user1")
  702. manager.set_current_print_user(2, 99, "user2")
  703. result1 = manager.get_current_print_user(1)
  704. result2 = manager.get_current_print_user(2)
  705. assert result1["username"] == "user1"
  706. assert result2["username"] == "user2"
  707. class TestPrinterStateToDict:
  708. """Tests for printer_state_to_dict helper function."""
  709. @pytest.fixture
  710. def mock_state(self):
  711. """Create a mock PrinterState."""
  712. state = MagicMock()
  713. state.connected = True
  714. state.state = "RUNNING"
  715. state.current_print = "test.3mf"
  716. state.subtask_name = "Test Print"
  717. state.gcode_file = "/sdcard/test.gcode"
  718. state.progress = 50
  719. state.remaining_time = 3600
  720. state.layer_num = 10
  721. state.total_layers = 20
  722. state.temperatures = {"nozzle": 200, "bed": 60}
  723. state.hms_errors = []
  724. state.ams_status_main = 0
  725. state.ams_status_sub = 0
  726. state.tray_now = "1"
  727. state.wifi_signal = -50
  728. state.raw_data = {}
  729. state.stg_cur = -1 # No calibration stage active
  730. state.firmware_version = None
  731. state.extruder_slots = {}
  732. return state
  733. def test_fila_switch_and_inlets_ride_the_websocket(self, mock_state):
  734. """The FTS fields must be in the broadcast dict, not only the REST status.
  735. The frontend shallow-merges each WebSocket push over its cached status,
  736. so a field this dict omits keeps whatever the last full fetch left —
  737. which is why the AMS inlet badges only ever changed on a page reload.
  738. """
  739. from backend.app.services.bambu_mqtt import FilaSwitchState
  740. mock_state.fila_switch = FilaSwitchState(
  741. installed=True, in_slots=[-1, 0x0102], out_extruders=[1, 0], stat=0, info=1
  742. )
  743. mock_state.ams_switch_inlet = {"0": "A", "1": "B"}
  744. result = printer_state_to_dict(mock_state)
  745. assert result["ams_switch_inlet"] == {"0": "A", "1": "B"}
  746. assert result["fila_switch"] == {
  747. "installed": True,
  748. "in_slots": [-1, 0x0102],
  749. "out_extruders": [1, 0],
  750. "stat": 0,
  751. "info": 1,
  752. "ready": True,
  753. }
  754. def test_a_switch_is_not_ready_until_every_ams_has_an_inlet(self, mock_state):
  755. """An AMS with no inlet binding means the switch cannot route a load.
  756. The load dialog blocks on this rather than publishing a command the
  757. firmware will drop, the same way BambuStudio's DevFilaSwitch::IsReady
  758. gates its own dialog.
  759. """
  760. from backend.app.services.bambu_mqtt import FilaSwitchState
  761. mock_state.fila_switch = FilaSwitchState(installed=True)
  762. mock_state.raw_data = {"ams": [{"id": "0", "tray": []}, {"id": "1", "tray": []}]}
  763. mock_state.ams_switch_inlet = {"0": "A"}
  764. assert printer_state_to_dict(mock_state)["fila_switch"]["ready"] is False
  765. mock_state.ams_switch_inlet = {"0": "A", "1": "B"}
  766. assert printer_state_to_dict(mock_state)["fila_switch"]["ready"] is True
  767. def test_extruder_slots_ride_the_websocket(self, mock_state):
  768. """Which hotend holds which slot has to travel with every push.
  769. The AMS slot menu decides from it which hotend the load dialog may
  770. offer, and tray_now cannot stand in: it is one value for the whole
  771. printer, so with both hotends loaded it names only one of them.
  772. """
  773. from backend.app.services.bambu_mqtt import ExtruderSlot
  774. mock_state.extruder_slots = {
  775. 0: ExtruderSlot(ams_id=0, slot_id=2, has_filament=True),
  776. 1: ExtruderSlot(ams_id=None, slot_id=None, has_filament=False),
  777. }
  778. result = printer_state_to_dict(mock_state)
  779. assert result["extruder_slots"] == {
  780. "0": {"ams_id": 0, "slot_id": 2, "has_filament": True},
  781. "1": {"ams_id": None, "slot_id": None, "has_filament": False},
  782. }
  783. def test_extruder_slots_are_empty_when_unreported(self, mock_state):
  784. """Printers outside the H2/X2 series never send the block."""
  785. assert printer_state_to_dict(mock_state)["extruder_slots"] == {}
  786. def test_inlets_are_dropped_without_a_switch(self, mock_state):
  787. """A binding must not outlive the accessory being unplugged."""
  788. from backend.app.services.bambu_mqtt import FilaSwitchState
  789. mock_state.fila_switch = FilaSwitchState(installed=False)
  790. mock_state.ams_switch_inlet = {"0": "A"}
  791. result = printer_state_to_dict(mock_state)
  792. assert result["fila_switch"] is None
  793. assert result["ams_switch_inlet"] == {}
  794. def test_basic_conversion(self, mock_state):
  795. """Verify basic state fields are converted."""
  796. result = printer_state_to_dict(mock_state)
  797. assert result["connected"] is True
  798. assert result["state"] == "RUNNING"
  799. assert result["progress"] == 50
  800. assert result["temperatures"] == {"nozzle": 200, "bed": 60}
  801. def test_ams_data_parsing(self, mock_state):
  802. """Verify AMS data is parsed correctly."""
  803. mock_state.raw_data = {
  804. "ams": [
  805. {
  806. "id": 0,
  807. "humidity_raw": 45,
  808. "temp": 25,
  809. "tray": [
  810. {
  811. "id": 0,
  812. "tray_color": "FF0000",
  813. "tray_type": "PLA",
  814. "tray_sub_brands": "Generic",
  815. "remain": 80,
  816. "k": 0.5,
  817. "tag_uid": "ABC123",
  818. "tray_uuid": "uuid-123",
  819. }
  820. ],
  821. }
  822. ]
  823. }
  824. result = printer_state_to_dict(mock_state)
  825. assert result["ams"] is not None
  826. assert len(result["ams"]) == 1
  827. assert result["ams"][0]["humidity"] == 45
  828. assert len(result["ams"][0]["tray"]) == 1
  829. assert result["ams"][0]["tray"][0]["tray_color"] == "FF0000"
  830. def test_empty_tag_uid_becomes_none(self, mock_state):
  831. """Verify empty tag_uid is converted to None."""
  832. mock_state.raw_data = {
  833. "ams": [
  834. {
  835. "id": 0,
  836. "tray": [
  837. {
  838. "id": 0,
  839. "tag_uid": "",
  840. "tray_uuid": "00000000000000000000000000000000",
  841. }
  842. ],
  843. }
  844. ]
  845. }
  846. result = printer_state_to_dict(mock_state)
  847. assert result["ams"][0]["tray"][0]["tag_uid"] is None
  848. assert result["ams"][0]["tray"][0]["tray_uuid"] is None
  849. def test_bare_tray_emulates_state_9(self, mock_state):
  850. """P1S / A1 Mini physically-empty-slot signal (#1322 follow-up by @RosdasHH):
  851. the firmware sends only `{"id": N}` for a truly empty slot. Treat that as
  852. the firmware's "no spool" state (state=9) so the inventory assign-spool
  853. path can short-circuit the doomed MQTT publish.
  854. """
  855. mock_state.raw_data = {
  856. "ams": [
  857. {
  858. "id": 0,
  859. "tray": [
  860. {"id": 0, "state": 11, "tray_type": "PLA"}, # loaded slot
  861. {"id": 1}, # P1S empty-slot signal — only id
  862. ],
  863. }
  864. ]
  865. }
  866. result = printer_state_to_dict(mock_state)
  867. trays = result["ams"][0]["tray"]
  868. assert trays[0]["state"] == 11, "loaded slot keeps its firmware state"
  869. assert trays[1]["state"] == 9, "bare {id} tray must be promoted to state=9"
  870. def test_populated_payload_with_empty_state_3_is_not_promoted(self, mock_state):
  871. """A1 Mini BMCU / P1S Standard AMS post-Reset-Slot case (#1322 root):
  872. firmware sends state=3 + tray_type="" but with the FULL field set
  873. populated. Must NOT be confused with the bare-tray empty signal —
  874. else inventory.py would short-circuit MQTT and we'd reintroduce the
  875. deadlock the #1322 fix removed.
  876. """
  877. mock_state.raw_data = {
  878. "ams": [
  879. {
  880. "id": 0,
  881. "tray": [
  882. {
  883. "id": 0,
  884. "state": 3,
  885. "tray_type": "", # cleared
  886. "tray_color": "",
  887. "tag_uid": "0000000000000000",
  888. "remain": 0,
  889. }
  890. ],
  891. }
  892. ]
  893. }
  894. result = printer_state_to_dict(mock_state)
  895. # state stays at 3 — the bare-tray promotion requires the dict to have
  896. # ONLY the id key, not just empty/falsy values for the other fields.
  897. assert result["ams"][0]["tray"][0]["state"] == 3
  898. def test_zero_tag_uid_becomes_none(self, mock_state):
  899. """Verify zero tag_uid is converted to None."""
  900. mock_state.raw_data = {
  901. "ams": [
  902. {
  903. "id": 0,
  904. "tray": [
  905. {
  906. "id": 0,
  907. "tag_uid": "0000000000000000",
  908. }
  909. ],
  910. }
  911. ]
  912. }
  913. result = printer_state_to_dict(mock_state)
  914. assert result["ams"][0]["tray"][0]["tag_uid"] is None
  915. def test_exists_bit_is_serialized_for_websocket(self, mock_state):
  916. """#2670: the WS status payload must carry the firmware presence bit
  917. `exists` (set by apply_tray_exist_bits) — the REST serializer already
  918. does. Without it the frontend shallow-merge drops `exists` after the
  919. first WS frame and getEmptySlotKind falls back to the firmware-variant
  920. state 9/10 heuristic, which is wrong for AMS-HT in both directions.
  921. """
  922. mock_state.raw_data = {
  923. "ams": [
  924. {
  925. "id": 128,
  926. "tray": [
  927. # Empty HT: apply_tray_exist_bits cleared it and set exists=False.
  928. {"id": 0, "state": 9, "tray_type": "", "exists": False},
  929. ],
  930. },
  931. {
  932. "id": 0,
  933. "tray": [
  934. # Present non-RFID spool: exists=True, no tray_type ("?").
  935. {"id": 0, "state": 10, "tray_type": "", "exists": True},
  936. ],
  937. },
  938. ]
  939. }
  940. result = printer_state_to_dict(mock_state)
  941. ht_tray = result["ams"][0]["tray"][0]
  942. reg_tray = result["ams"][1]["tray"][0]
  943. assert ht_tray["exists"] is False
  944. assert reg_tray["exists"] is True
  945. def test_dry_countdown_stalled_is_serialized_for_websocket(self, mock_state):
  946. """The WS status payload must carry ``dry_countdown_stalled`` — the REST
  947. serializer already does (routes/printers.py). The frontend merges a WS
  948. frame over the cached status with a top-level shallow spread, so the ~1/s
  949. frame replaces the REST-seeded ``ams`` array wholesale. Omit the flag here
  950. and it reads undefined a second after load, the card falls back to the
  951. amber "Drying - 45m left" badge, and the neutral "Drying not started"
  952. state is unreachable in the live UI. Same failure mode as `exists`/#2670.
  953. """
  954. mock_state.raw_data = {
  955. "ams": [
  956. # Parked command: timer set, countdown never ticked.
  957. {"id": 0, "dry_time": 720, "dry_countdown_stalled": True, "tray": []},
  958. # Genuinely running cycle.
  959. {"id": 1, "dry_time": 45, "dry_countdown_stalled": False, "tray": []},
  960. # Pre-flag raw_data (a unit the MQTT layer has not stamped yet)
  961. # must serialize False, never None/absent — the REST shape is a
  962. # non-optional bool and the two surfaces must not disagree.
  963. {"id": 2, "dry_time": 0, "tray": []},
  964. ]
  965. }
  966. result = printer_state_to_dict(mock_state)
  967. assert result["ams"][0]["dry_countdown_stalled"] is True
  968. assert result["ams"][1]["dry_countdown_stalled"] is False
  969. assert result["ams"][2]["dry_countdown_stalled"] is False
  970. def test_vt_tray_parsing(self, mock_state):
  971. """Verify virtual tray is parsed correctly as a list."""
  972. mock_state.raw_data = {
  973. "vt_tray": [
  974. {
  975. "tray_color": "00FF00",
  976. "tray_type": "PETG",
  977. "tray_sub_brands": "Generic",
  978. "remain": 60,
  979. "tag_uid": "VT123",
  980. }
  981. ]
  982. }
  983. result = printer_state_to_dict(mock_state)
  984. assert isinstance(result["vt_tray"], list)
  985. assert len(result["vt_tray"]) == 1
  986. assert result["vt_tray"][0]["id"] == 254
  987. assert result["vt_tray"][0]["tray_color"] == "00FF00"
  988. assert result["vt_tray"][0]["tray_type"] == "PETG"
  989. def test_vt_tray_dict_normalized_to_list(self, mock_state):
  990. """Verify vt_tray as a raw dict (from MQTT) is normalized to a list."""
  991. mock_state.raw_data = {
  992. "vt_tray": {
  993. "id": "254",
  994. "tray_color": "FF0000",
  995. "tray_type": "PLA",
  996. "tray_sub_brands": "Generic",
  997. "tag_uid": "0000000000000000",
  998. "tray_uuid": "00000000000000000000000000000000",
  999. "remain": 0,
  1000. }
  1001. }
  1002. result = printer_state_to_dict(mock_state)
  1003. assert isinstance(result["vt_tray"], list)
  1004. assert len(result["vt_tray"]) == 1
  1005. assert result["vt_tray"][0]["tray_color"] == "FF0000"
  1006. assert result["vt_tray"][0]["tray_type"] == "PLA"
  1007. assert result["vt_tray"][0]["tag_uid"] is None
  1008. assert result["vt_tray"][0]["tray_uuid"] is None
  1009. def test_vt_tray_non_list_non_dict_ignored(self, mock_state):
  1010. """Verify unexpected vt_tray types (e.g. string) produce empty list."""
  1011. mock_state.raw_data = {"vt_tray": "unexpected_string"}
  1012. result = printer_state_to_dict(mock_state)
  1013. assert result["vt_tray"] == []
  1014. def test_hms_errors_conversion(self, mock_state):
  1015. """Verify HMS errors are converted correctly."""
  1016. error = MagicMock()
  1017. error.code = "0700_0100"
  1018. error.attr = 1
  1019. error.module = "AMS"
  1020. error.severity = 2
  1021. mock_state.hms_errors = [error]
  1022. result = printer_state_to_dict(mock_state)
  1023. assert len(result["hms_errors"]) == 1
  1024. assert result["hms_errors"][0]["code"] == "0700_0100"
  1025. assert result["hms_errors"][0]["module"] == "AMS"
  1026. def test_cover_url_added_for_running_print(self, mock_state):
  1027. """Verify cover_url is added for running prints."""
  1028. result = printer_state_to_dict(mock_state, printer_id=1)
  1029. assert result["cover_url"] == "/api/v1/printers/1/cover"
  1030. def test_current_plate_id_extracted_from_gcode_file(self, mock_state):
  1031. """Verify current_plate_id is parsed from a Bambu plate path (#881)."""
  1032. mock_state.gcode_file = "/Metadata/plate_3.gcode"
  1033. result = printer_state_to_dict(mock_state)
  1034. assert result["current_plate_id"] == 3
  1035. def test_current_plate_id_none_when_no_plate_segment(self, mock_state):
  1036. """Verify current_plate_id stays None when gcode_file has no plate marker."""
  1037. mock_state.gcode_file = "/sdcard/test.gcode"
  1038. result = printer_state_to_dict(mock_state)
  1039. assert result["current_plate_id"] is None
  1040. def test_cover_url_none_when_not_running(self, mock_state):
  1041. """Verify cover_url is None when not printing."""
  1042. mock_state.state = "IDLE"
  1043. result = printer_state_to_dict(mock_state, printer_id=1)
  1044. assert result["cover_url"] is None
  1045. def test_ams_ht_detection(self, mock_state):
  1046. """Verify AMS-HT is detected (1 tray vs 4)."""
  1047. mock_state.raw_data = {
  1048. "ams": [
  1049. {
  1050. "id": 0,
  1051. "tray": [{"id": 0}], # Only 1 tray = AMS-HT
  1052. }
  1053. ]
  1054. }
  1055. result = printer_state_to_dict(mock_state)
  1056. assert result["ams"][0]["is_ams_ht"] is True
  1057. def test_regular_ams_detection(self, mock_state):
  1058. """Verify regular AMS is detected (4 trays)."""
  1059. mock_state.raw_data = {"ams": [{"id": 0, "tray": [{"id": 0}, {"id": 1}, {"id": 2}, {"id": 3}]}]}
  1060. result = printer_state_to_dict(mock_state)
  1061. assert result["ams"][0]["is_ams_ht"] is False
  1062. def test_chamber_temp_filtered_for_p1s(self, mock_state):
  1063. """Verify chamber temperature is filtered out for P1S (no chamber sensor)."""
  1064. mock_state.temperatures = {
  1065. "nozzle": 200,
  1066. "bed": 60,
  1067. "chamber": 5,
  1068. "chamber_target": 0,
  1069. "chamber_heating": False,
  1070. }
  1071. result = printer_state_to_dict(mock_state, model="P1S")
  1072. assert "chamber" not in result["temperatures"]
  1073. assert "chamber_target" not in result["temperatures"]
  1074. assert "chamber_heating" not in result["temperatures"]
  1075. assert result["temperatures"]["nozzle"] == 200
  1076. assert result["temperatures"]["bed"] == 60
  1077. def test_chamber_temp_kept_for_x1c(self, mock_state):
  1078. """Verify chamber temperature is kept for X1C (has chamber sensor)."""
  1079. mock_state.temperatures = {
  1080. "nozzle": 200,
  1081. "bed": 60,
  1082. "chamber": 25,
  1083. "chamber_target": 45,
  1084. "chamber_heating": True,
  1085. }
  1086. result = printer_state_to_dict(mock_state, model="X1C")
  1087. assert result["temperatures"]["chamber"] == 25
  1088. assert result["temperatures"]["chamber_target"] == 45
  1089. assert result["temperatures"]["chamber_heating"] is True
  1090. def test_chamber_temp_filtered_for_a1(self, mock_state):
  1091. """Verify chamber temperature is filtered out for A1 (no chamber sensor)."""
  1092. mock_state.temperatures = {"nozzle": 200, "bed": 60, "chamber": 5}
  1093. result = printer_state_to_dict(mock_state, model="A1")
  1094. assert "chamber" not in result["temperatures"]
  1095. def test_chamber_temp_kept_when_no_model(self, mock_state):
  1096. """Verify chamber temperature is kept when model is not specified (conservative approach)."""
  1097. mock_state.temperatures = {"nozzle": 200, "bed": 60, "chamber": 25}
  1098. result = printer_state_to_dict(mock_state) # No model specified
  1099. # When model is unknown, we can't filter - leave as is
  1100. # Actually supports_chamber_temp returns False for None, so it will filter
  1101. # Let's check the actual behavior
  1102. assert "chamber" not in result["temperatures"]
  1103. def test_ams_drying_fields_included(self, mock_state):
  1104. """Verify AMS drying fields (dry_time, module_type) are included in output."""
  1105. mock_state.raw_data = {
  1106. "ams": [
  1107. {
  1108. "id": 0,
  1109. "dry_time": 42,
  1110. "module_type": "n3f",
  1111. "tray": [
  1112. {
  1113. "id": 0,
  1114. "tray_color": "FF0000",
  1115. "tray_type": "PLA",
  1116. "drying_temp": 55,
  1117. "drying_time": 240,
  1118. }
  1119. ],
  1120. }
  1121. ]
  1122. }
  1123. result = printer_state_to_dict(mock_state)
  1124. ams_unit = result["ams"][0]
  1125. assert ams_unit["dry_time"] == 42
  1126. assert ams_unit["module_type"] == "n3f"
  1127. # Tray-level drying fields
  1128. tray = ams_unit["tray"][0]
  1129. assert tray["drying_temp"] == 55
  1130. assert tray["drying_time"] == 240
  1131. def test_awaiting_plate_clear_defaults_false(self, mock_state):
  1132. """Without a printer_id, awaiting_plate_clear is False (no lookup possible)."""
  1133. result = printer_state_to_dict(mock_state)
  1134. assert result["awaiting_plate_clear"] is False
  1135. def test_awaiting_plate_clear_surfaced_when_set(self, mock_state):
  1136. """With printer_id, awaiting_plate_clear reflects PrinterManager state.
  1137. Regression: PR #939 left this flag off the WebSocket payload, so the
  1138. "Clear Plate" button only appeared after the 30 s REST fallback poll.
  1139. """
  1140. from backend.app.services.printer_manager import printer_manager
  1141. printer_manager.set_awaiting_plate_clear(12345, True)
  1142. try:
  1143. result = printer_state_to_dict(mock_state, printer_id=12345)
  1144. assert result["awaiting_plate_clear"] is True
  1145. finally:
  1146. printer_manager.set_awaiting_plate_clear(12345, False)
  1147. def test_name_and_model_surfaced_when_registered(self, mock_state):
  1148. """Registered PrinterInfo name + model arg should land in the WS payload.
  1149. Regression for #963 follow-up: without this, the gcode viewer's printer
  1150. selector had to wait on a /printers fetch before it could render real
  1151. names, and the initial WS snapshot showed "Printer 1" fallbacks.
  1152. """
  1153. from backend.app.services.printer_manager import PrinterInfo, printer_manager
  1154. # Register a stub PrinterInfo; the real manager writes this on connect.
  1155. printer_manager._printer_info[98765] = PrinterInfo(name="My X1C", serial_number="01S00-0")
  1156. try:
  1157. result = printer_state_to_dict(mock_state, printer_id=98765, model="X1C")
  1158. assert result["name"] == "My X1C"
  1159. assert result["model"] == "X1C"
  1160. finally:
  1161. printer_manager._printer_info.pop(98765, None)
  1162. def test_name_and_model_absent_when_no_printer_id(self, mock_state):
  1163. """Without a printer_id (unusual callsites), name/model keys stay absent.
  1164. The consumers (gcode viewer, frontend card) tolerate missing keys; what
  1165. they can't tolerate is an unrelated printer's name accidentally leaking
  1166. into a status meant for a different one.
  1167. """
  1168. result = printer_state_to_dict(mock_state)
  1169. assert "name" not in result
  1170. assert "model" not in result
  1171. def test_model_absent_when_arg_is_none(self, mock_state):
  1172. """`model` arg=None must not plant a `model` key at all.
  1173. If the arg is None, callers didn't know the model yet; emitting a
  1174. `model: null` field would overwrite a good value cached client-side.
  1175. """
  1176. from backend.app.services.printer_manager import PrinterInfo, printer_manager
  1177. printer_manager._printer_info[55555] = PrinterInfo(name="N", serial_number="S")
  1178. try:
  1179. result = printer_state_to_dict(mock_state, printer_id=55555, model=None)
  1180. assert "model" not in result
  1181. assert result["name"] == "N"
  1182. finally:
  1183. printer_manager._printer_info.pop(55555, None)
  1184. class TestStatusKeyDryingDedup:
  1185. """Regression tests for WebSocket dedup including drying fields.
  1186. The WebSocket broadcast deduplication uses printer_state_to_dict output
  1187. to detect changes. If drying fields (like dry_time) are missing from
  1188. the dict, changes to those fields won't trigger broadcasts.
  1189. """
  1190. def test_dry_time_change_changes_status_key(self):
  1191. """Verify dry_time is present in AMS unit data so dedup can detect changes."""
  1192. state = MagicMock()
  1193. state.connected = True
  1194. state.state = "IDLE"
  1195. state.current_print = None
  1196. state.subtask_name = None
  1197. state.gcode_file = None
  1198. state.progress = 0
  1199. state.remaining_time = 0
  1200. state.layer_num = 0
  1201. state.total_layers = 0
  1202. state.temperatures = {"nozzle": 25, "bed": 25}
  1203. state.hms_errors = []
  1204. state.ams_status_main = 0
  1205. state.ams_status_sub = 0
  1206. state.tray_now = None
  1207. state.wifi_signal = -50
  1208. state.stg_cur = -1
  1209. # First state: drying active with 30 minutes remaining
  1210. state.raw_data = {"ams": [{"id": 0, "dry_time": 30, "module_type": "n3f", "tray": [{"id": 0}]}]}
  1211. result1 = printer_state_to_dict(state)
  1212. # Second state: drying time decreased
  1213. state.raw_data = {"ams": [{"id": 0, "dry_time": 29, "module_type": "n3f", "tray": [{"id": 0}]}]}
  1214. result2 = printer_state_to_dict(state)
  1215. # The dicts should differ — dry_time changed
  1216. assert result1["ams"][0]["dry_time"] == 30
  1217. assert result2["ams"][0]["dry_time"] == 29
  1218. assert result1["ams"] != result2["ams"]
  1219. class TestDryingTargetExposure:
  1220. """Tests for dry_target_temp / dry_filament surfacing on AMS state dict.
  1221. Bambu does not echo the active cycle's chosen filament + target
  1222. temperature on the per-tick AMS push, only the dry_time countdown.
  1223. The badge needs the cached target so it can render "PETG @ 65°C".
  1224. """
  1225. def _state_with_ams(self, ams_data: dict) -> object:
  1226. state = MagicMock()
  1227. state.connected = True
  1228. state.state = "IDLE"
  1229. state.current_print = None
  1230. state.subtask_name = None
  1231. state.gcode_file = None
  1232. state.progress = 0
  1233. state.remaining_time = 0
  1234. state.layer_num = 0
  1235. state.total_layers = 0
  1236. state.temperatures = {"nozzle": 25, "bed": 25}
  1237. state.hms_errors = []
  1238. state.ams_status_main = 0
  1239. state.ams_status_sub = 0
  1240. state.tray_now = None
  1241. state.wifi_signal = -50
  1242. state.stg_cur = -1
  1243. state.raw_data = {"ams": [ams_data]}
  1244. return state
  1245. def test_cached_target_wins_over_tray_fallback(self):
  1246. """When the cache has a target for this AMS, use it verbatim — even
  1247. if the loaded tray's filament/recommended-drying-temp differ."""
  1248. state = self._state_with_ams(
  1249. {
  1250. "id": 0,
  1251. "dry_time": 600,
  1252. "tray": [
  1253. {"id": 0, "tray_type": "PLA", "drying_temp": 50, "state": 11},
  1254. ],
  1255. }
  1256. )
  1257. result = printer_state_to_dict(state, drying_targets={0: {"filament": "PETG", "temp": 65}})
  1258. assert result["ams"][0]["dry_filament"] == "PETG"
  1259. assert result["ams"][0]["dry_target_temp"] == 65
  1260. def test_falls_back_to_loaded_tray_filament_when_no_cache(self):
  1261. """No cached target → name the filament from the loaded trays when they
  1262. agree on a type. The temperature stays unknown: only the cache records
  1263. what we actually sent."""
  1264. state = self._state_with_ams(
  1265. {
  1266. "id": 0,
  1267. "dry_time": 600,
  1268. "tray": [
  1269. {"id": 0, "tray_type": "ABS", "drying_temp": 70, "state": 11},
  1270. ],
  1271. }
  1272. )
  1273. result = printer_state_to_dict(state, drying_targets=None)
  1274. assert result["ams"][0]["dry_filament"] == "ABS"
  1275. assert result["ams"][0]["dry_target_temp"] is None
  1276. def test_returns_none_when_no_cache_and_empty_trays(self):
  1277. """No cache + no loaded tray with tray_type → both fields are None."""
  1278. state = self._state_with_ams(
  1279. {
  1280. "id": 0,
  1281. "dry_time": 600,
  1282. "tray": [{"id": 0}],
  1283. }
  1284. )
  1285. result = printer_state_to_dict(state, drying_targets={})
  1286. assert result["ams"][0]["dry_filament"] is None
  1287. assert result["ams"][0]["dry_target_temp"] is None
  1288. def test_targets_for_other_ams_id_dont_leak(self):
  1289. """A cached target for AMS 1 must not surface on AMS 0."""
  1290. state = self._state_with_ams(
  1291. {
  1292. "id": 0,
  1293. "dry_time": 600,
  1294. "tray": [{"id": 0}],
  1295. }
  1296. )
  1297. result = printer_state_to_dict(state, drying_targets={1: {"filament": "PETG", "temp": 65}})
  1298. assert result["ams"][0]["dry_filament"] is None
  1299. assert result["ams"][0]["dry_target_temp"] is None
  1300. def test_no_fallback_when_loaded_trays_disagree(self):
  1301. """#2759 — the reporter's AMS held 2 PETG and 2 PLA and was drying the
  1302. PLA at 45°C, but the fallback read slot 1 and labelled it "PETG @ 65°C".
  1303. A mixed unit gives no evidence of what the cycle is running, so the
  1304. badge must show the countdown alone rather than a confident wrong
  1305. answer."""
  1306. state = self._state_with_ams(
  1307. {
  1308. "id": 0,
  1309. "dry_time": 719,
  1310. "tray": [
  1311. {"id": 0, "tray_type": "PETG", "drying_temp": 65, "state": 11},
  1312. {"id": 1, "tray_type": "PETG", "drying_temp": 65, "state": 11},
  1313. {"id": 2, "tray_type": "PLA", "drying_temp": 45, "state": 11},
  1314. {"id": 3, "tray_type": "PLA", "drying_temp": 45, "state": 11},
  1315. ],
  1316. }
  1317. )
  1318. result = printer_state_to_dict(state, drying_targets={})
  1319. assert result["ams"][0]["dry_filament"] is None
  1320. assert result["ams"][0]["dry_target_temp"] is None
  1321. def test_fallback_survives_multiple_trays_of_one_type(self):
  1322. """Agreement across slots is still evidence of the filament — a unit
  1323. loaded entirely with PLA keeps the name the mixed case gives up."""
  1324. state = self._state_with_ams(
  1325. {
  1326. "id": 0,
  1327. "dry_time": 719,
  1328. "tray": [
  1329. {"id": 0, "tray_type": "PLA", "drying_temp": 45, "state": 11},
  1330. {"id": 1, "tray_type": "PLA", "drying_temp": 45, "state": 11},
  1331. {"id": 2},
  1332. ],
  1333. }
  1334. )
  1335. result = printer_state_to_dict(state, drying_targets={})
  1336. assert result["ams"][0]["dry_filament"] == "PLA"
  1337. def test_uniform_unit_never_invents_a_temperature(self):
  1338. """#2759 follow-up — the reporter's second AMS held only PLA and was
  1339. drying at the 45°C they picked, but with no cached target the badge
  1340. answered with the RFID recommendation and read "PLA @ 55°C". Every
  1341. spool agreeing tells us the filament; it tells us nothing about a
  1342. temperature the user chose freely in the popover."""
  1343. state = self._state_with_ams(
  1344. {
  1345. "id": 0,
  1346. "dry_time": 719,
  1347. "tray": [
  1348. {"id": 0, "tray_type": "PLA", "drying_temp": 55, "state": 11},
  1349. {"id": 1, "tray_type": "PLA", "drying_temp": 55, "state": 11},
  1350. ],
  1351. }
  1352. )
  1353. result = printer_state_to_dict(state, drying_targets={})
  1354. assert result["ams"][0]["dry_filament"] == "PLA"
  1355. assert result["ams"][0]["dry_target_temp"] is None
  1356. def test_cached_temp_survives_a_unit_whose_trays_disagree(self):
  1357. """The cache is authoritative for both fields. A mixed unit costs us the
  1358. filament fallback but must not touch a target we actually sent."""
  1359. state = self._state_with_ams(
  1360. {
  1361. "id": 0,
  1362. "dry_time": 719,
  1363. "tray": [
  1364. {"id": 0, "tray_type": "PETG", "drying_temp": 65, "state": 11},
  1365. {"id": 1, "tray_type": "PLA", "drying_temp": 55, "state": 11},
  1366. ],
  1367. }
  1368. )
  1369. result = printer_state_to_dict(state, drying_targets={0: {"filament": "PLA", "temp": 45}})
  1370. assert result["ams"][0]["dry_filament"] == "PLA"
  1371. assert result["ams"][0]["dry_target_temp"] == 45
  1372. class TestDisplayTemperatures:
  1373. """#1422 — the readings handed to the streaming overlay.
  1374. `state.temperatures` doubles as the MQTT client's working memory: alongside
  1375. the readings it carries derived heater flags and private timestamps. The
  1376. overlay feed is reached by a token rather than a login, so it gets an
  1377. allow-list rather than the dict.
  1378. """
  1379. def test_keeps_the_readings_the_overlay_draws(self):
  1380. result = display_temperatures({"nozzle": 219.5, "nozzle_target": 220.0, "bed": 60.0, "bed_target": 60.0}, "X1C")
  1381. assert result == {"nozzle": 219.5, "nozzle_target": 220.0, "bed": 60.0, "bed_target": 60.0}
  1382. def test_drops_heater_flags_and_private_bookkeeping(self):
  1383. result = display_temperatures(
  1384. {
  1385. "nozzle": 219.5,
  1386. "nozzle_heating": True,
  1387. "bed_heating": False,
  1388. "_nozzle_target_set_time": 1754300000.0,
  1389. "_chamber_target_set_time": 1754300000.0,
  1390. },
  1391. "X1C",
  1392. )
  1393. assert result == {"nozzle": 219.5}
  1394. def test_chamber_kept_on_models_with_a_real_sensor(self):
  1395. result = display_temperatures({"chamber": 38.0, "chamber_target": 40.0}, "X1C")
  1396. assert result == {"chamber": 38.0, "chamber_target": 40.0}
  1397. def test_chamber_dropped_on_models_without_one(self):
  1398. """P1P, P1S, A1 and A1 mini publish a meaningless chamber_temper. Drawing
  1399. it on a live stream would state a measurement that doesn't exist."""
  1400. for model in ("P1S", "P1P", "A1", "A1MINI"):
  1401. assert display_temperatures({"nozzle": 200.0, "chamber": 38.0}, model) == {"nozzle": 200.0}
  1402. def test_second_nozzle_is_included(self):
  1403. result = display_temperatures({"nozzle": 220.0, "nozzle_2": 240.0, "nozzle_2_target": 250.0}, "H2D")
  1404. assert result == {"nozzle": 220.0, "nozzle_2": 240.0, "nozzle_2_target": 250.0}
  1405. def test_unparseable_and_missing_values_are_skipped(self):
  1406. """A reading that isn't a number is dropped rather than crashing the
  1407. feed or reaching the page as a string."""
  1408. assert display_temperatures({"nozzle": None, "bed": "warm", "chamber": 38.0}, "X1C") == {"chamber": 38.0}
  1409. def test_empty_and_none_are_empty(self):
  1410. assert display_temperatures(None, "X1C") == {}
  1411. assert display_temperatures({}, "X1C") == {}
  1412. class TestSupportsChamberTemp:
  1413. """Tests for supports_chamber_temp helper function."""
  1414. def test_x1_series_supported(self):
  1415. """Verify X1 series printers support chamber temp."""
  1416. assert supports_chamber_temp("X1") is True
  1417. assert supports_chamber_temp("X1C") is True
  1418. assert supports_chamber_temp("X1E") is True
  1419. def test_p2_series_supported(self):
  1420. """Verify P2 series printers support chamber temp."""
  1421. assert supports_chamber_temp("P2S") is True
  1422. def test_h2_series_supported(self):
  1423. """Verify H2 series printers support chamber temp."""
  1424. assert supports_chamber_temp("H2C") is True
  1425. assert supports_chamber_temp("H2D") is True
  1426. assert supports_chamber_temp("H2DPRO") is True
  1427. assert supports_chamber_temp("H2S") is True
  1428. def test_p1_series_not_supported(self):
  1429. """Verify P1 series printers do NOT support chamber temp."""
  1430. assert supports_chamber_temp("P1P") is False
  1431. assert supports_chamber_temp("P1S") is False
  1432. def test_a1_series_not_supported(self):
  1433. """Verify A1 series printers do NOT support chamber temp."""
  1434. assert supports_chamber_temp("A1") is False
  1435. assert supports_chamber_temp("A1MINI") is False
  1436. def test_none_model_not_supported(self):
  1437. """Verify None model returns False."""
  1438. assert supports_chamber_temp(None) is False
  1439. def test_case_insensitive(self):
  1440. """Verify model matching is case-insensitive."""
  1441. assert supports_chamber_temp("x1c") is True
  1442. assert supports_chamber_temp("X1c") is True
  1443. assert supports_chamber_temp("p1s") is False
  1444. def test_internal_model_codes_supported(self):
  1445. """Verify internal model codes from MQTT/SSDP are recognized."""
  1446. # X1/X1C
  1447. assert supports_chamber_temp("BL-P001") is True
  1448. # X1E
  1449. assert supports_chamber_temp("C13") is True
  1450. # H2D
  1451. assert supports_chamber_temp("O1D") is True
  1452. # H2C
  1453. assert supports_chamber_temp("O1C") is True
  1454. # H2S
  1455. assert supports_chamber_temp("O1S") is True
  1456. # H2D Pro
  1457. assert supports_chamber_temp("O1E") is True
  1458. # P2S
  1459. assert supports_chamber_temp("N7") is True
  1460. def test_internal_model_codes_not_supported(self):
  1461. """Verify A1/P1 internal codes are NOT supported."""
  1462. # P1P
  1463. assert supports_chamber_temp("C11") is False
  1464. # P1S
  1465. assert supports_chamber_temp("C12") is False
  1466. # A1
  1467. assert supports_chamber_temp("N2S") is False
  1468. # A1 Mini
  1469. assert supports_chamber_temp("N1") is False
  1470. class TestSupportsDrying:
  1471. """Tests for supports_drying helper function."""
  1472. def test_known_supported_with_firmware(self):
  1473. """Verify known models with sufficient firmware return True."""
  1474. assert supports_drying("X1C", "01.09.00.00") is True
  1475. assert supports_drying("H2D", "01.02.30.00") is True
  1476. assert supports_drying("H2S", "01.02.00.00") is True
  1477. assert supports_drying("H2C", "01.02.00.00") is True
  1478. assert supports_drying("O1C", "01.02.00.00") is True
  1479. assert supports_drying("O1C2", "01.02.00.00") is True
  1480. assert supports_drying("P2S", "01.02.00.00") is True
  1481. assert supports_drying("N7", "01.02.00.00") is True
  1482. def test_known_supported_old_firmware(self):
  1483. """Verify known models with old firmware return False."""
  1484. assert supports_drying("X1C", "01.08.00.00") is False
  1485. assert supports_drying("H2S", "01.01.00.00") is False
  1486. assert supports_drying("H2C", "01.01.99.99") is False
  1487. assert supports_drying("O1C", "01.01.99.99") is False
  1488. assert supports_drying("O1C2", "01.01.99.99") is False
  1489. assert supports_drying("P2S", "01.01.99.99") is False
  1490. assert supports_drying("N7", "01.01.99.99") is False
  1491. def test_known_supported_no_firmware(self):
  1492. """Verify known models with no firmware return False."""
  1493. assert supports_drying("X1C", None) is False
  1494. assert supports_drying("P2S", None) is False
  1495. def test_unsupported_models(self):
  1496. """Verify models without AMS drying support return False regardless of firmware."""
  1497. for model in ["A1", "A1MINI", "A1-MINI", "N1", "N2S"]:
  1498. assert supports_drying(model, "99.99.99.99") is False, f"Expected False for {model}"
  1499. def test_unknown_models_allowed(self):
  1500. """Verify unknown models are allowed (graceful fallback).
  1501. Models not in the unsupported set AND not matching any known firmware-gated
  1502. model substring get the benefit of the doubt and return True.
  1503. "H2D Pro" contains "H2D" so it IS firmware-gated (needs firmware).
  1504. """
  1505. # Truly unknown models: no substring match in _DRYING_MIN_FIRMWARE
  1506. assert supports_drying("FUTURE_MODEL", None) is True
  1507. # X1E contains "X1" substring, so it IS firmware-gated
  1508. assert supports_drying("X1E", "01.09.00.00") is True
  1509. # H2D Pro contains "H2D" substring, so it IS firmware-gated
  1510. assert supports_drying("H2D Pro", "01.02.30.00") is True
  1511. def test_none_model(self):
  1512. """Verify None model returns False."""
  1513. assert supports_drying(None, "01.09.00.00") is False
  1514. def test_case_insensitive(self):
  1515. """Verify model matching is case-insensitive."""
  1516. assert supports_drying("x1c", "01.09.00.00") is True
  1517. assert supports_drying("p2s", "01.02.00.00") is True
  1518. assert supports_drying("a1", "99.99.99.99") is False
  1519. class TestDryingScreenOnly:
  1520. """P1-series AMS drying is screen-only (#2533).
  1521. Bambu's P1 manual: "P1S connected AMS drying functions may only be controlled
  1522. from the P1S screen." The firmware acks `ams_filament_drying` with
  1523. result: success and then does nothing — so no command we send can ever start a
  1524. cycle, whatever the firmware version.
  1525. """
  1526. @pytest.mark.parametrize("model", ["P1S", "P1P", "p1s", " p1p "])
  1527. def test_screen_only_models_reject_remote_drying(self, model):
  1528. assert drying_screen_only(model) is True
  1529. # Not firmware-gated: even the newest firmware won't take the command.
  1530. assert supports_drying(model, "99.99.99.99") is False
  1531. @pytest.mark.parametrize("model", ["X1C", "P2S", "H2D", "A1", None])
  1532. def test_other_models_are_not_screen_only(self, model):
  1533. assert drying_screen_only(model) is False
  1534. def test_screen_only_is_not_the_same_as_unsupported(self):
  1535. # The A1 has no drying-capable AMS at all; the P1S does, it just can't be
  1536. # driven remotely. The UI needs to tell those two apart.
  1537. assert drying_screen_only("A1") is False
  1538. assert supports_drying("A1", "99.99.99.99") is False
  1539. class TestSupportsDryingWhilePrinting:
  1540. """Tests for the supports_drying_while_printing gate (concurrent drying during print).
  1541. Stricter than supports_drying — only models explicitly confirmed by Bambu wiki
  1542. release notes are allowed (verified phrase: "printing while filament is drying"
  1543. / "Print While Drying").
  1544. """
  1545. def test_known_supported_with_firmware(self):
  1546. """Matrix-confirmed models with min firmware return True."""
  1547. assert supports_drying_while_printing("H2D", "01.03.00.00") is True
  1548. assert supports_drying_while_printing("H2D Pro", "01.02.00.00") is True
  1549. assert supports_drying_while_printing("O1E", "01.02.00.00") is True
  1550. assert supports_drying_while_printing("O2D", "01.02.00.00") is True
  1551. assert supports_drying_while_printing("H2C", "01.02.00.00") is True
  1552. assert supports_drying_while_printing("O1C", "01.02.00.00") is True
  1553. assert supports_drying_while_printing("O1C2", "01.02.00.00") is True
  1554. assert supports_drying_while_printing("H2S", "01.02.00.00") is True
  1555. assert supports_drying_while_printing("X2D", "01.01.00.00") is True
  1556. assert supports_drying_while_printing("N6", "01.01.00.00") is True
  1557. assert supports_drying_while_printing("X1C", "01.11.02.00") is True
  1558. assert supports_drying_while_printing("BL-P001", "01.11.02.00") is True
  1559. assert supports_drying_while_printing("P2S", "01.02.00.00") is True
  1560. assert supports_drying_while_printing("N7", "01.02.00.00") is True
  1561. assert supports_drying_while_printing("A2L", "01.01.00.00") is True
  1562. assert supports_drying_while_printing("N9", "01.01.00.00") is True
  1563. def test_known_supported_below_min_firmware(self):
  1564. """Matrix-confirmed models on too-old firmware return False."""
  1565. assert supports_drying_while_printing("H2D", "01.02.30.00") is False
  1566. assert supports_drying_while_printing("X1C", "01.11.01.00") is False
  1567. assert supports_drying_while_printing("P2S", "01.01.99.99") is False
  1568. assert supports_drying_while_printing("H2S", "01.01.99.99") is False
  1569. assert supports_drying_while_printing("A2L", "01.00.99.99") is False
  1570. def test_not_in_matrix_excluded(self):
  1571. """Models absent from the matrix return False regardless of firmware.
  1572. P1*, A1, A1 Mini, X1 (non-C), X1E are intentionally excluded — their wiki
  1573. release notes never mention "Print While Drying" / "printing while filament
  1574. is drying".
  1575. """
  1576. for model in [
  1577. "P1P",
  1578. "P1S",
  1579. "C11",
  1580. "C12",
  1581. "A1",
  1582. "A1 MINI",
  1583. "A1MINI",
  1584. "N1",
  1585. "N2S",
  1586. "X1",
  1587. "X1E",
  1588. "BL-P002",
  1589. "C13",
  1590. ]:
  1591. assert supports_drying_while_printing(model, "99.99.99.99") is False, f"Expected False for {model}"
  1592. def test_no_firmware_returns_false(self):
  1593. """Missing firmware version returns False even for supported models."""
  1594. assert supports_drying_while_printing("H2D", None) is False
  1595. assert supports_drying_while_printing("P2S", None) is False
  1596. def test_none_model_returns_false(self):
  1597. """None model returns False."""
  1598. assert supports_drying_while_printing(None, "01.03.00.00") is False
  1599. def test_case_insensitive(self):
  1600. """Model matching is case-insensitive."""
  1601. assert supports_drying_while_printing("h2d", "01.03.00.00") is True
  1602. assert supports_drying_while_printing("p2s", "01.02.00.00") is True
  1603. assert supports_drying_while_printing("a1", "99.99.99.99") is False
  1604. def test_unknown_model_returns_false(self):
  1605. """Unknown models default to FALSE (strict gate — not the lenient default-allow).
  1606. This contrasts with supports_drying which defaults to True for unknown
  1607. models. For while-printing the cost of being wrong is real (firmware
  1608. rejection mid-print is annoying; melted spool is worse), so we err
  1609. toward conservative.
  1610. """
  1611. assert supports_drying_while_printing("FUTURE_MODEL", "99.99.99.99") is False
  1612. class TestGetDerivedStatusName:
  1613. """Tests for get_derived_status_name function."""
  1614. def test_stg_cur_255_returns_none(self):
  1615. """Verify stg_cur=255 (A1/P1 idle) returns None, not 'Unknown stage (255)'."""
  1616. state = MagicMock()
  1617. state.stg_cur = 255
  1618. state.state = "IDLE"
  1619. result = get_derived_status_name(state)
  1620. assert result is None
  1621. def test_stg_cur_negative_one_returns_none_when_idle(self):
  1622. """Verify stg_cur=-1 (X1 idle) returns None."""
  1623. state = MagicMock()
  1624. state.stg_cur = -1
  1625. state.state = "IDLE"
  1626. result = get_derived_status_name(state)
  1627. assert result is None
  1628. def test_valid_stage_returns_name(self):
  1629. """Verify valid stg_cur values return stage name."""
  1630. state = MagicMock()
  1631. state.stg_cur = 1 # Auto bed leveling
  1632. result = get_derived_status_name(state)
  1633. assert result == "Auto bed leveling"
  1634. def test_stg_cur_zero_returns_printing(self):
  1635. """Verify stg_cur=0 returns 'Printing' when no model specified."""
  1636. state = MagicMock()
  1637. state.stg_cur = 0
  1638. result = get_derived_status_name(state)
  1639. assert result == "Printing"
  1640. def test_a1_idle_with_stg_cur_zero_returns_none(self):
  1641. """Verify A1 with IDLE state and stg_cur=0 returns None (bug workaround)."""
  1642. state = MagicMock()
  1643. state.stg_cur = 0
  1644. state.state = "IDLE"
  1645. # Test various A1 model names
  1646. for model in ["A1", "A1 Mini", "A1-Mini", "A1MINI", "N1", "N2S"]:
  1647. result = get_derived_status_name(state, model)
  1648. assert result is None, f"Expected None for model {model}"
  1649. def test_a1_running_with_stg_cur_zero_returns_printing(self):
  1650. """Verify A1 with RUNNING state and stg_cur=0 still returns 'Printing'."""
  1651. state = MagicMock()
  1652. state.stg_cur = 0
  1653. state.state = "RUNNING"
  1654. result = get_derived_status_name(state, "A1")
  1655. assert result == "Printing"
  1656. def test_non_a1_idle_with_stg_cur_zero_returns_printing(self):
  1657. """Verify non-A1 models with IDLE and stg_cur=0 still return 'Printing'."""
  1658. state = MagicMock()
  1659. state.stg_cur = 0
  1660. state.state = "IDLE"
  1661. # X1C should not get the workaround
  1662. result = get_derived_status_name(state, "X1C")
  1663. assert result == "Printing"
  1664. class TestHasStgCurIdleBug:
  1665. """Tests for has_stg_cur_idle_bug function."""
  1666. def test_a1_models_return_true(self):
  1667. """Verify A1 model variants return True."""
  1668. assert has_stg_cur_idle_bug("A1") is True
  1669. assert has_stg_cur_idle_bug("A1 Mini") is True
  1670. assert has_stg_cur_idle_bug("A1-Mini") is True
  1671. assert has_stg_cur_idle_bug("A1MINI") is True
  1672. assert has_stg_cur_idle_bug("a1") is True # case insensitive
  1673. assert has_stg_cur_idle_bug("a1 mini") is True
  1674. def test_p1_models_return_true(self):
  1675. """Verify P1P/P1S model variants return True."""
  1676. assert has_stg_cur_idle_bug("P1P") is True
  1677. assert has_stg_cur_idle_bug("P1S") is True
  1678. assert has_stg_cur_idle_bug("p1p") is True # case insensitive
  1679. def test_internal_codes_return_true(self):
  1680. """Verify internal model codes return True."""
  1681. assert has_stg_cur_idle_bug("N1") is True # A1 Mini
  1682. assert has_stg_cur_idle_bug("N2S") is True # A1
  1683. assert has_stg_cur_idle_bug("C11") is True # P1P
  1684. assert has_stg_cur_idle_bug("C12") is True # P1S
  1685. def test_non_affected_models_return_false(self):
  1686. """Verify non-affected models return False."""
  1687. assert has_stg_cur_idle_bug("X1C") is False
  1688. assert has_stg_cur_idle_bug("X1") is False
  1689. assert has_stg_cur_idle_bug("H2D") is False
  1690. def test_none_model_returns_false(self):
  1691. """Verify None model returns False."""
  1692. assert has_stg_cur_idle_bug(None) is False
  1693. def test_empty_model_returns_false(self):
  1694. """Verify empty model returns False."""
  1695. assert has_stg_cur_idle_bug("") is False
  1696. class TestInitPrinterConnections:
  1697. """Tests for init_printer_connections function."""
  1698. @pytest.mark.asyncio
  1699. async def test_connects_all_active_printers(self):
  1700. """Verify all active printers are connected."""
  1701. mock_db = AsyncMock()
  1702. mock_printer1 = MagicMock(id=1, is_active=True)
  1703. mock_printer2 = MagicMock(id=2, is_active=True)
  1704. mock_result = MagicMock()
  1705. mock_result.scalars.return_value.all.return_value = [mock_printer1, mock_printer2]
  1706. mock_db.execute.return_value = mock_result
  1707. with patch("backend.app.services.printer_manager.printer_manager") as mock_manager:
  1708. mock_manager.connect_printer = AsyncMock()
  1709. await init_printer_connections(mock_db)
  1710. assert mock_manager.connect_printer.call_count == 2
  1711. @pytest.mark.asyncio
  1712. async def test_handles_empty_printer_list(self):
  1713. """Verify empty printer list is handled."""
  1714. mock_db = AsyncMock()
  1715. mock_result = MagicMock()
  1716. mock_result.scalars.return_value.all.return_value = []
  1717. mock_db.execute.return_value = mock_result
  1718. with patch("backend.app.services.printer_manager.printer_manager") as mock_manager:
  1719. mock_manager.connect_printer = AsyncMock()
  1720. await init_printer_connections(mock_db)
  1721. mock_manager.connect_printer.assert_not_called()
  1722. @pytest.mark.asyncio
  1723. async def test_one_failing_printer_does_not_abort_the_rest(self):
  1724. """A single unreachable printer must not abort startup or the others (#2572).
  1725. The connections are gathered with return_exceptions=True. The old serial
  1726. ``await`` loop had no error handling, so the first printer that raised
  1727. propagated straight out of init_printer_connections and failed the
  1728. FastAPI lifespan — taking the whole app down over one bad row, and
  1729. skipping every printer after it. This asserts the isolation: every
  1730. printer is still attempted and the exception never escapes.
  1731. """
  1732. mock_db = AsyncMock()
  1733. printers = [MagicMock(id=i, name=f"p{i}", is_active=True) for i in range(3)]
  1734. mock_result = MagicMock()
  1735. mock_result.scalars.return_value.all.return_value = printers
  1736. mock_db.execute.return_value = mock_result
  1737. async def connect(printer):
  1738. if printer.id == 1:
  1739. raise ConnectionError("printer 1 is unreachable")
  1740. return True
  1741. with patch("backend.app.services.printer_manager.printer_manager") as mock_manager:
  1742. mock_manager.connect_printer = AsyncMock(side_effect=connect)
  1743. # Must not raise despite printer 1 failing.
  1744. await init_printer_connections(mock_db)
  1745. # All three were still attempted (not aborted at the failing one).
  1746. assert mock_manager.connect_printer.call_count == 3
  1747. class TestAmsChangeCallback:
  1748. """Tests for AMS change callback functionality."""
  1749. @pytest.fixture
  1750. def manager(self):
  1751. """Create a fresh PrinterManager instance."""
  1752. return PrinterManager()
  1753. def test_ams_change_callback_is_triggered(self, manager):
  1754. """Verify AMS change callback is called when AMS data changes."""
  1755. callback = MagicMock()
  1756. manager.set_ams_change_callback(callback)
  1757. # Verify callback was set
  1758. assert manager._on_ams_change == callback
  1759. def test_ams_change_callback_receives_correct_data(self, manager):
  1760. """Verify AMS change callback receives the correct AMS data format."""
  1761. received_data = []
  1762. def capture_callback(printer_id, ams_data):
  1763. received_data.append((printer_id, ams_data))
  1764. manager.set_ams_change_callback(capture_callback)
  1765. # The callback should accept printer_id and ams_data
  1766. # This tests the callback signature
  1767. assert manager._on_ams_change is not None
  1768. assert callable(manager._on_ams_change)
  1769. class TestParsePlateId:
  1770. """Tests for parse_plate_id() — active-print plate extraction from gcode paths.
  1771. Regression coverage for #881 follow-up: the REST /status endpoint and the
  1772. WebSocket push path both use this helper, so they must agree on the plate
  1773. number the frontend sees.
  1774. """
  1775. def test_bambu_metadata_path(self):
  1776. # Canonical path that Bambu Studio / OrcaSlicer stamp into the 3MF.
  1777. assert parse_plate_id("/Metadata/plate_2.gcode") == 2
  1778. def test_plate_one(self):
  1779. assert parse_plate_id("/Metadata/plate_1.gcode") == 1
  1780. def test_double_digit_plate(self):
  1781. assert parse_plate_id("/Metadata/plate_12.gcode") == 12
  1782. def test_none_input(self):
  1783. assert parse_plate_id(None) is None
  1784. def test_empty_string(self):
  1785. assert parse_plate_id("") is None
  1786. def test_path_without_plate_segment(self):
  1787. # Some firmware / slicers report a bare filename without the plate marker.
  1788. assert parse_plate_id("/upload/my-model.gcode") is None
  1789. def test_similar_but_non_matching_names(self):
  1790. # "plate.gcode" (no number) and "nameplate_2.gcode" (substring) must not
  1791. # be mistaken for real plate markers. The regex anchors on `plate_<num>`.
  1792. assert parse_plate_id("/Metadata/plate.gcode") is None
  1793. assert parse_plate_id("/plates/3.gcode") is None
  1794. def test_substring_match_still_extracts(self):
  1795. # The regex isn't anchored to the start of a segment — any occurrence
  1796. # wins. This matches real Bambu paths where the segment is preceded by
  1797. # arbitrary directory noise, and matches the equivalent frontend regex.
  1798. assert parse_plate_id("/uploads/project/plate_5.gcode.md5") == 5
  1799. class TestResolvePlateId:
  1800. """Tests for resolve_plate_id() — plate resolution with dispatch precedence.
  1801. Regression coverage for #1166: P1S firmware 01.10.00.00 only puts the .3mf
  1802. filename in print.gcode_file, so parse_plate_id() returns None and the
  1803. printer card falls back to plate 1. When Bambuddy dispatches the print
  1804. itself we know the right plate; resolve_plate_id() prefers that record over
  1805. the gcode_file regex when subtask_name matches.
  1806. """
  1807. def _make_state(self, **kwargs):
  1808. from backend.app.services.bambu_mqtt import PrinterState
  1809. state = PrinterState()
  1810. for k, v in kwargs.items():
  1811. setattr(state, k, v)
  1812. return state
  1813. def test_dispatched_plate_wins_when_subtask_matches(self):
  1814. # User dispatches plate 4 via Bambuddy. Printer reflects subtask_name
  1815. # but firmware drops the plate path from gcode_file. Without the dispatch
  1816. # record we'd default to plate 1.
  1817. from backend.app.services.printer_manager import resolve_plate_id
  1818. state = self._make_state(
  1819. gcode_file="MyModel.3mf", # No plate path — firmware bug
  1820. subtask_name="MyModel",
  1821. dispatched_plate_id=4,
  1822. dispatched_subtask="MyModel",
  1823. )
  1824. assert resolve_plate_id(state) == 4
  1825. def test_dispatched_ignored_when_subtask_differs(self):
  1826. # Bambuddy's dispatch record is for a previous print; the printer is
  1827. # now running a different subtask (Studio-direct dispatch). The stale
  1828. # record must not be used — fall back to gcode_file regex.
  1829. from backend.app.services.printer_manager import resolve_plate_id
  1830. state = self._make_state(
  1831. gcode_file="/Metadata/plate_2.gcode",
  1832. subtask_name="DifferentPrint",
  1833. dispatched_plate_id=4,
  1834. dispatched_subtask="MyModel",
  1835. )
  1836. assert resolve_plate_id(state) == 2
  1837. def test_falls_back_to_gcode_regex_without_dispatch(self):
  1838. # Studio-direct dispatch — no Bambuddy dispatch record. Existing logic
  1839. # (parse_plate_id on gcode_file) must still work.
  1840. from backend.app.services.printer_manager import resolve_plate_id
  1841. state = self._make_state(
  1842. gcode_file="/Metadata/plate_3.gcode",
  1843. subtask_name="MyModel",
  1844. )
  1845. assert resolve_plate_id(state) == 3
  1846. def test_returns_none_when_nothing_resolvable(self):
  1847. # No dispatch record AND firmware swallowed the plate path. The route
  1848. # uses this signal to invoke the 3MF-scan fallback.
  1849. from backend.app.services.printer_manager import resolve_plate_id
  1850. state = self._make_state(
  1851. gcode_file="MyModel.3mf",
  1852. subtask_name="MyModel",
  1853. )
  1854. assert resolve_plate_id(state) is None
  1855. def test_dispatched_subtask_required_to_avoid_false_match(self):
  1856. # dispatched_plate_id without dispatched_subtask is incomplete — we
  1857. # can't validate it points at the current print, so we ignore it.
  1858. from backend.app.services.printer_manager import resolve_plate_id
  1859. state = self._make_state(
  1860. gcode_file="MyModel.3mf",
  1861. subtask_name="MyModel",
  1862. dispatched_plate_id=4,
  1863. dispatched_subtask=None,
  1864. )
  1865. assert resolve_plate_id(state) is None