test_wled.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860
  1. import asyncio
  2. import logging
  3. import httpx
  4. import pytest
  5. from backend.app.services.bambu_mqtt import HMSError, PrinterState
  6. from backend.app.services.wled import WLEDManager, WLEDResponseError, effective_wled_status
  7. def _state(state: str, **values) -> PrinterState:
  8. return PrinterState(connected=values.pop("connected", True), state=state, **values)
  9. def _manager(handler) -> tuple[WLEDManager, httpx.AsyncClient]:
  10. client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
  11. return WLEDManager(client), client
  12. def test_effective_status_uses_existing_bambuddy_states_and_priorities():
  13. assert effective_wled_status(_state("IDLE")) == "idle"
  14. assert effective_wled_status(_state("RUNNING")) == "printing"
  15. assert effective_wled_status(_state("PRINTING")) == "printing"
  16. assert effective_wled_status(_state("PREPARE")) == "prepare"
  17. assert effective_wled_status(_state("SLICING")) == "prepare"
  18. assert effective_wled_status(_state("PAUSE")) == "paused"
  19. assert effective_wled_status(_state("FINISH")) == "finished"
  20. assert effective_wled_status(_state("FAILED")) == "error"
  21. assert effective_wled_status(_state("IDLE"), awaiting_plate_clear=True, require_plate_clear=True) == "queue_waiting"
  22. assert effective_wled_status(_state("PAUSE", stg_cur=6)) == "filament_problem"
  23. assert effective_wled_status(_state("PAUSE", stg_cur=16)) == "paused"
  24. assert effective_wled_status(_state("PAUSE", stg_cur=16, ams_status_main=1, mc_print_sub_stage=3)) == "paused"
  25. assert (
  26. effective_wled_status(_state("PAUSE", stg_cur=6, ams_status_main=0, mc_print_sub_stage=0)) == "filament_problem"
  27. )
  28. fault = HMSError(code="0300_0008", attr=0x03000200, module=3, severity=1, description="Nozzle temperature abnormal")
  29. assert effective_wled_status(_state("IDLE", hms_errors=[fault])) == "hms_error"
  30. assert effective_wled_status(_state("RUNNING", connected=False)) == "offline"
  31. assert effective_wled_status(_state("UNKNOWN")) is None
  32. @pytest.mark.parametrize(
  33. ("state", "awaiting_plate_clear", "require_plate_clear", "expected"),
  34. [
  35. ("FINISH", True, False, "finished"),
  36. ("FAILED", True, False, "error"),
  37. ("IDLE", True, False, "idle"),
  38. ("FINISH", True, True, "queue_waiting"),
  39. ("FAILED", True, True, "queue_waiting"),
  40. ("IDLE", True, True, "queue_waiting"),
  41. ("FINISH", False, True, "finished"),
  42. ("FAILED", False, True, "error"),
  43. ("RUNNING", True, True, "printing"),
  44. ("PRINTING", True, True, "printing"),
  45. ("PREPARE", True, True, "prepare"),
  46. ("SLICING", True, True, "prepare"),
  47. ("PAUSE", True, True, "paused"),
  48. ("UNKNOWN", True, True, None),
  49. ],
  50. )
  51. def test_plate_gate_precedes_only_idle_finished_and_error(state, awaiting_plate_clear, require_plate_clear, expected):
  52. assert (
  53. effective_wled_status(
  54. _state(state), awaiting_plate_clear=awaiting_plate_clear, require_plate_clear=require_plate_clear
  55. )
  56. == expected
  57. )
  58. @pytest.mark.parametrize(
  59. ("severity", "description", "actions", "full_code", "expected"),
  60. [
  61. (0, "Invalid level", ["OK_BUTTON"], "0500000000004038", "printing"),
  62. (2, None, [], "0C0001000002001B", "printing"),
  63. (3, "The top cover is open", [], "0300970000030001", "printing"),
  64. (1, "Nozzle temperature abnormal", [], "0300020000010008", "hms_error"),
  65. (2, "Filament fault", [], "0500060000020005", "hms_error"),
  66. (3, None, ["OK_BUTTON"], "0300970000030001", "hms_error"),
  67. (3, "Unable to start drying", [], "1880C003", "hms_error"),
  68. ],
  69. )
  70. def test_hms_filter_matches_notifications(severity, description, actions, full_code, expected):
  71. fault = HMSError(
  72. code="fault", attr=0, module=0, severity=severity, description=description, actions=actions, full_code=full_code
  73. )
  74. assert effective_wled_status(_state("RUNNING", hms_errors=[fault])) == expected
  75. def test_specific_fault_priorities_are_preserved_with_plate_gate():
  76. ignored = HMSError(code="ignored", attr=0, module=0, severity=0)
  77. fault = HMSError(code="fault", attr=0, module=0, severity=2, description="Filament fault")
  78. for state in ("RUNNING", "FINISH", "FAILED", "IDLE", "PAUSE"):
  79. assert (
  80. effective_wled_status(
  81. _state(state, hms_errors=[ignored, fault]), awaiting_plate_clear=True, require_plate_clear=True
  82. )
  83. == "hms_error"
  84. )
  85. assert (
  86. effective_wled_status(
  87. _state("PAUSE", stg_cur=6, hms_errors=[fault]), awaiting_plate_clear=True, require_plate_clear=True
  88. )
  89. == "filament_problem"
  90. )
  91. assert effective_wled_status(_state("PAUSE", stg_cur=16, hms_errors=[fault])) == "hms_error"
  92. assert (
  93. effective_wled_status(
  94. _state("FINISH", connected=False, hms_errors=[fault]), awaiting_plate_clear=True, require_plate_clear=True
  95. )
  96. == "offline"
  97. )
  98. @pytest.mark.asyncio
  99. async def test_list_presets_returns_only_valid_named_slots():
  100. manager, client = _manager(
  101. lambda request: httpx.Response(
  102. 200,
  103. json={"0": {}, "3": {"n": "Printing Blue"}, "7": {"n": ""}, "251": {"n": "No"}},
  104. )
  105. )
  106. assert await manager.list_presets("http://wled.local") == [
  107. {"id": 3, "name": "Printing Blue"},
  108. {"id": 7, "name": "7"},
  109. ]
  110. await client.aclose()
  111. @pytest.mark.asyncio
  112. @pytest.mark.parametrize(
  113. "response",
  114. [httpx.Response(200, text="not-json"), httpx.Response(200, json=[])],
  115. )
  116. async def test_list_presets_rejects_invalid_responses(response):
  117. manager, client = _manager(lambda request: response)
  118. with pytest.raises(WLEDResponseError):
  119. await manager.list_presets("http://wled.local")
  120. await client.aclose()
  121. @pytest.mark.asyncio
  122. async def test_get_info_returns_optional_wled_identity_and_rejects_invalid_response():
  123. manager, client = _manager(lambda request: httpx.Response(200, json={"name": " Kitchen LEDs ", "ver": "0.15.0"}))
  124. assert await manager.get_info("http://wled.local") == {"name": "Kitchen LEDs", "version": "0.15.0"}
  125. await client.aclose()
  126. invalid, invalid_client = _manager(lambda request: httpx.Response(200, json=[]))
  127. with pytest.raises(WLEDResponseError):
  128. await invalid.get_info("http://wled.local")
  129. await invalid_client.aclose()
  130. @pytest.mark.asyncio
  131. async def test_send_preset_posts_expected_payload_and_handles_failures(caplog):
  132. caplog.set_level(logging.INFO, logger="backend.app.services.wled")
  133. requests = []
  134. def handler(request: httpx.Request):
  135. requests.append(request)
  136. return httpx.Response(200, json={"success": True})
  137. manager, client = _manager(handler)
  138. assert await manager.send_preset(1, "http://wled.local", 3) is True
  139. assert requests[0].url == httpx.URL("http://wled.local/json/state")
  140. assert requests[0].content == b'{"ps":3}'
  141. assert (logging.INFO, "[WLED] Printer 1 state=manual -> preset=3") in [
  142. (record.levelno, record.message) for record in caplog.records
  143. ]
  144. caplog.clear()
  145. for response in (httpx.Response(500), httpx.Response(200, text="bad")):
  146. failing, failing_client = _manager(lambda request, result=response: result)
  147. assert await failing.send_preset(1, "http://wled.local", 3) is False
  148. await failing_client.aclose()
  149. offline, offline_client = _manager(lambda request: (_ for _ in ()).throw(httpx.ConnectError("offline")))
  150. assert await offline.send_preset(1, "http://wled.local", 3) is False
  151. records = [record for record in caplog.records if record.name == "backend.app.services.wled"]
  152. assert len(records) == 3
  153. assert all(record.levelno == logging.WARNING and "preset=3 failed" in record.message for record in records)
  154. assert all("wled.local" not in record.message for record in records)
  155. await offline_client.aclose()
  156. await client.aclose()
  157. @pytest.mark.asyncio
  158. async def test_status_logging_follows_successful_sends_without_poll_duplicates(caplog):
  159. caplog.set_level(logging.INFO, logger="backend.app.services.wled")
  160. manager, client = _manager(lambda request: httpx.Response(200, json={"success": True}))
  161. manager.configure_printer(
  162. 1,
  163. {
  164. "enabled": True,
  165. "base_url": "http://wled.local",
  166. "presets": {"printing": 32, "finished": 34, "queue_waiting": 36},
  167. },
  168. )
  169. manager.require_plate_clear = True
  170. for state, awaiting in (("RUNNING", False), ("FINISH", False), ("FINISH", True)):
  171. manager.handle_status(1, _state(state), awaiting_plate_clear=awaiting)
  172. await asyncio.sleep(0)
  173. manager.handle_status(1, _state(state), awaiting_plate_clear=awaiting)
  174. await asyncio.sleep(0)
  175. assert [
  176. (record.levelno, record.message) for record in caplog.records if record.name == "backend.app.services.wled"
  177. ] == [
  178. (logging.INFO, "[WLED] Printer 1 state=printing -> preset=32"),
  179. (logging.INFO, "[WLED] Printer 1 state=finished -> preset=34"),
  180. (logging.INFO, "[WLED] Printer 1 state=queue_waiting -> preset=36"),
  181. ]
  182. await manager.shutdown()
  183. await client.aclose()
  184. @pytest.mark.asyncio
  185. async def test_cancelled_preset_send_does_not_log_a_switch_or_failure(caplog):
  186. caplog.set_level(logging.INFO, logger="backend.app.services.wled")
  187. async def handler(request):
  188. raise asyncio.CancelledError
  189. manager, client = _manager(handler)
  190. with pytest.raises(asyncio.CancelledError):
  191. await manager.send_preset(1, "http://wled.local", 32, status="printing")
  192. assert not [record for record in caplog.records if record.name == "backend.app.services.wled"]
  193. await client.aclose()
  194. @pytest.mark.asyncio
  195. async def test_status_changes_are_deduplicated_and_missing_or_disabled_mappings_do_nothing():
  196. sent = []
  197. def handler(request: httpx.Request):
  198. sent.append(request)
  199. return httpx.Response(200, json={"success": True})
  200. manager, client = _manager(handler)
  201. manager.configure_printer(
  202. 1,
  203. {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3, "paused": 7}},
  204. )
  205. manager.handle_status(1, _state("RUNNING"))
  206. manager.handle_status(1, _state("RUNNING", progress=50))
  207. await asyncio.sleep(0)
  208. assert len(sent) == 1
  209. manager.handle_status(1, _state("PAUSE"))
  210. await asyncio.sleep(0)
  211. assert len(sent) == 2
  212. manager.handle_status(1, _state("IDLE"))
  213. await asyncio.sleep(0)
  214. assert len(sent) == 2
  215. manager.configure_printer(2, {"enabled": False, "base_url": "http://wled.local", "presets": {"idle": 1}})
  216. manager.handle_status(2, _state("IDLE"))
  217. await asyncio.sleep(0)
  218. assert len(sent) == 2
  219. await manager.shutdown()
  220. await client.aclose()
  221. @pytest.mark.asyncio
  222. async def test_failed_status_send_retries_after_the_interval_then_deduplicates_success(caplog, monkeypatch):
  223. caplog.set_level(logging.INFO, logger="backend.app.services.wled")
  224. now = [1000.0]
  225. monkeypatch.setattr("backend.app.services.wled.time.monotonic", lambda: now[0])
  226. sent = []
  227. def handler(request: httpx.Request):
  228. sent.append(request)
  229. return httpx.Response(503 if len(sent) <= 2 else 200, json={"success": True})
  230. manager, client = _manager(handler)
  231. manager.configure_printer(1, {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3}})
  232. manager.handle_status(1, _state("RUNNING"))
  233. await manager._runtime[1].send_task
  234. assert len(sent) == 1
  235. assert manager._runtime[1].status is None
  236. assert manager._runtime[1].preset_id is None
  237. # Status updates arrive every second or so: none of them retries before
  238. # the interval is over.
  239. for _ in range(5):
  240. now[0] += 1
  241. manager.handle_status(1, _state("RUNNING"))
  242. await asyncio.sleep(0)
  243. assert len(sent) == 1
  244. now[0] += 30
  245. manager.handle_status(1, _state("RUNNING"))
  246. await manager._runtime[1].send_task
  247. assert len(sent) == 2
  248. now[0] += 30
  249. manager.handle_status(1, _state("RUNNING"))
  250. await manager._runtime[1].send_task
  251. assert len(sent) == 3
  252. assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("printing", 3)
  253. manager.handle_status(1, _state("RUNNING"))
  254. await asyncio.sleep(0)
  255. assert len(sent) == 3
  256. messages = [
  257. (record.levelno, record.message) for record in caplog.records if record.name == "backend.app.services.wled"
  258. ]
  259. # One warning for the whole outage, not one per retry.
  260. assert messages == [
  261. (logging.WARNING, "[WLED] Printer 1 state=printing -> preset=3 failed (HTTP 503)"),
  262. (logging.INFO, "[WLED] Printer 1: retrying every 30s until WLED answers"),
  263. (logging.INFO, "[WLED] Printer 1 state=printing -> preset=3"),
  264. (logging.INFO, "[WLED] Printer 1: WLED answers again"),
  265. ]
  266. assert all("wled.local" not in message for _, message in messages)
  267. await manager.shutdown()
  268. await client.aclose()
  269. @pytest.mark.asyncio
  270. async def test_a_new_state_is_sent_at_once_during_an_outage_without_a_new_warning(caplog, monkeypatch):
  271. caplog.set_level(logging.INFO, logger="backend.app.services.wled")
  272. monkeypatch.setattr("backend.app.services.wled.time.monotonic", lambda: 1000.0)
  273. sent = []
  274. def handler(request: httpx.Request):
  275. sent.append(request.content)
  276. return httpx.Response(503)
  277. manager, client = _manager(handler)
  278. manager.configure_printer(
  279. 1, {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3, "paused": 7}}
  280. )
  281. manager.handle_status(1, _state("RUNNING"))
  282. await manager._runtime[1].send_task
  283. manager.handle_status(1, _state("PAUSE"))
  284. await manager._runtime[1].send_task
  285. assert sent == [b'{"ps":3}', b'{"ps":7}']
  286. warnings = [record for record in caplog.records if record.levelno == logging.WARNING]
  287. assert len(warnings) == 1
  288. await manager.shutdown()
  289. await client.aclose()
  290. @pytest.mark.asyncio
  291. async def test_identical_status_does_not_start_parallel_sends():
  292. started = asyncio.Event()
  293. release = asyncio.Event()
  294. sent = []
  295. async def handler(request: httpx.Request):
  296. sent.append(request)
  297. started.set()
  298. await release.wait()
  299. return httpx.Response(200, json={"success": True})
  300. manager, client = _manager(handler)
  301. manager.configure_printer(1, {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3}})
  302. manager.handle_status(1, _state("RUNNING"))
  303. task = manager._runtime[1].send_task
  304. await asyncio.wait_for(started.wait(), 1)
  305. manager.handle_status(1, _state("RUNNING", progress=20))
  306. manager.handle_status(1, _state("RUNNING", progress=30))
  307. assert manager._runtime[1].send_task is task
  308. assert manager._runtime[1].status is None
  309. assert len(sent) == 1
  310. release.set()
  311. await task
  312. assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("printing", 3)
  313. await manager.shutdown()
  314. await client.aclose()
  315. @pytest.mark.asyncio
  316. async def test_failed_return_to_previously_confirmed_state_still_retries():
  317. sent = []
  318. def handler(request: httpx.Request):
  319. sent.append(request)
  320. return httpx.Response(503 if len(sent) == 2 else 200, json={"success": True})
  321. manager, client = _manager(handler)
  322. manager.retry_seconds = 0
  323. manager.configure_printer(
  324. 1,
  325. {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3, "paused": 7}},
  326. )
  327. manager.handle_status(1, _state("RUNNING"))
  328. await manager._runtime[1].send_task
  329. assert manager._runtime[1].status == "printing"
  330. manager.handle_status(1, _state("PAUSE"))
  331. manager.handle_status(1, _state("RUNNING"))
  332. await manager._runtime[1].send_task
  333. assert len(sent) == 2
  334. assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("printing", 3)
  335. assert manager._runtime[1].confirmed_generation != manager._runtime[1].generation
  336. manager.handle_status(1, _state("RUNNING"))
  337. await manager._runtime[1].send_task
  338. assert len(sent) == 3
  339. assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("printing", 3)
  340. await manager.shutdown()
  341. await client.aclose()
  342. @pytest.mark.asyncio
  343. async def test_manual_preset_restores_automatic_mapping_once_on_next_status():
  344. sent = []
  345. def handler(request: httpx.Request):
  346. sent.append(request.content)
  347. return httpx.Response(200, json={"success": True})
  348. manager, client = _manager(handler)
  349. manager.configure_printer(1, {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3}})
  350. manager.handle_status(1, _state("RUNNING"))
  351. await manager._runtime[1].send_task
  352. assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("printing", 3)
  353. assert await manager.test_preset(1, "http://wled.local", 11) is True
  354. manager.handle_status(1, _state("RUNNING"))
  355. await manager._runtime[1].send_task
  356. manager.handle_status(1, _state("RUNNING"))
  357. await asyncio.sleep(0)
  358. assert sent == [b'{"ps":3}', b'{"ps":11}', b'{"ps":3}']
  359. assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("printing", 3)
  360. await manager.shutdown()
  361. await client.aclose()
  362. @pytest.mark.asyncio
  363. async def test_failed_manual_preset_keeps_automatic_mapping_retryable():
  364. sent = []
  365. def handler(request: httpx.Request):
  366. sent.append(request.content)
  367. return httpx.Response(503 if request.content == b'{"ps":11}' else 200, json={"success": True})
  368. manager, client = _manager(handler)
  369. manager.configure_printer(1, {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3}})
  370. manager.handle_status(1, _state("RUNNING"))
  371. await manager._runtime[1].send_task
  372. assert await manager.test_preset(1, "http://wled.local", 11) is False
  373. manager.handle_status(1, _state("RUNNING"))
  374. await manager._runtime[1].send_task
  375. assert sent == [b'{"ps":3}', b'{"ps":11}', b'{"ps":3}']
  376. assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("printing", 3)
  377. await manager.shutdown()
  378. await client.aclose()
  379. @pytest.mark.asyncio
  380. async def test_manual_preset_invalidates_slow_automatic_send(monkeypatch):
  381. started = asyncio.Event()
  382. release = asyncio.Event()
  383. sent = []
  384. async def delayed_send(printer_id, base_url, preset_id, *, status=None, quiet=False):
  385. sent.append(preset_id)
  386. if preset_id == 3 and sent.count(3) == 1:
  387. started.set()
  388. try:
  389. await release.wait()
  390. except asyncio.CancelledError:
  391. await release.wait()
  392. return True
  393. manager = WLEDManager()
  394. monkeypatch.setattr(manager, "send_preset", delayed_send)
  395. manager.configure_printer(1, {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3}})
  396. manager.handle_status(1, _state("RUNNING"))
  397. old_task = manager._runtime[1].send_task
  398. await asyncio.wait_for(started.wait(), 1)
  399. assert await manager.test_preset(1, "http://wled.local", 11) is True
  400. release.set()
  401. await old_task
  402. assert manager._runtime[1].status is None
  403. manager.handle_status(1, _state("RUNNING"))
  404. await manager._runtime[1].send_task
  405. assert sent == [3, 11, 3]
  406. assert manager._runtime[1].status == "printing"
  407. await manager.shutdown()
  408. @pytest.mark.asyncio
  409. async def test_status_change_during_manual_send_restores_new_mapping(monkeypatch):
  410. started = asyncio.Event()
  411. release = asyncio.Event()
  412. sent = []
  413. async def delayed_send(printer_id, base_url, preset_id, *, status=None, quiet=False):
  414. sent.append(preset_id)
  415. if preset_id == 11:
  416. started.set()
  417. await release.wait()
  418. return True
  419. manager = WLEDManager()
  420. monkeypatch.setattr(manager, "send_preset", delayed_send)
  421. manager.configure_printer(
  422. 1,
  423. {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3, "paused": 7}},
  424. )
  425. manager.handle_status(1, _state("RUNNING"))
  426. await manager._runtime[1].send_task
  427. manual_task = asyncio.create_task(manager.test_preset(1, "http://wled.local", 11))
  428. await asyncio.wait_for(started.wait(), 1)
  429. manager.handle_status(1, _state("PAUSE"))
  430. await manager._runtime[1].send_task
  431. assert manager._runtime[1].status == "paused"
  432. release.set()
  433. assert await manual_task is True
  434. manager.handle_status(1, _state("PAUSE"))
  435. await manager._runtime[1].send_task
  436. assert sent == [3, 11, 7, 7]
  437. assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("paused", 7)
  438. await manager.shutdown()
  439. @pytest.mark.asyncio
  440. async def test_offline_lifecycle_send_and_reconnect_restore_mapping():
  441. sent = []
  442. def handler(request: httpx.Request):
  443. sent.append(request.content)
  444. return httpx.Response(200, json={"success": True})
  445. manager, client = _manager(handler)
  446. manager.configure_printer(
  447. 1, {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3, "offline": 9}}
  448. )
  449. manager.handle_status(1, _state("RUNNING"))
  450. await manager._runtime[1].send_task
  451. manager.handle_offline(1)
  452. await manager._runtime[1].send_task
  453. assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("offline", 9)
  454. manager.handle_status(1, _state("RUNNING"))
  455. await manager._runtime[1].send_task
  456. manager.handle_status(1, _state("RUNNING"))
  457. await asyncio.sleep(0)
  458. assert sent == [b'{"ps":3}', b'{"ps":9}', b'{"ps":3}']
  459. await manager.shutdown()
  460. await client.aclose()
  461. @pytest.mark.asyncio
  462. async def test_offline_lifecycle_skips_disabled_or_missing_preset():
  463. sent = []
  464. def handler(request: httpx.Request):
  465. sent.append(request.content)
  466. return httpx.Response(200, json={"success": True})
  467. manager, client = _manager(handler)
  468. manager.handle_offline(1)
  469. manager.configure_printer(1, {"enabled": False, "base_url": "http://wled.local", "presets": {"offline": 9}})
  470. manager.handle_offline(1)
  471. manager.configure_printer(1, {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3}})
  472. manager.handle_offline(1)
  473. await asyncio.sleep(0)
  474. assert sent == []
  475. await manager.shutdown()
  476. await client.aclose()
  477. @pytest.mark.asyncio
  478. async def test_offline_lifecycle_http_failure_does_not_block_and_is_retryable(caplog):
  479. manager, client = _manager(lambda request: httpx.Response(503, json={"error": "offline"}))
  480. manager.retry_seconds = 0
  481. manager.configure_printer(1, {"enabled": True, "base_url": "http://wled.local", "presets": {"offline": 9}})
  482. manager.handle_offline(1)
  483. await manager._runtime[1].send_task
  484. assert manager._runtime[1].confirmed_generation is None
  485. assert "state=offline -> preset=9 failed (HTTP 503)" in caplog.text
  486. manager.handle_offline(1)
  487. await manager._runtime[1].send_task
  488. await manager.shutdown()
  489. await client.aclose()
  490. @pytest.mark.asyncio
  491. async def test_late_automatic_send_cannot_confirm_over_offline(monkeypatch):
  492. started = asyncio.Event()
  493. release = asyncio.Event()
  494. sent = []
  495. async def delayed_send(printer_id, base_url, preset_id, *, status=None, quiet=False):
  496. sent.append(preset_id)
  497. if preset_id == 3:
  498. started.set()
  499. try:
  500. await release.wait()
  501. except asyncio.CancelledError:
  502. await release.wait()
  503. return True
  504. manager = WLEDManager()
  505. monkeypatch.setattr(manager, "send_preset", delayed_send)
  506. manager.configure_printer(
  507. 1, {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3, "offline": 9}}
  508. )
  509. manager.handle_status(1, _state("RUNNING"))
  510. old_task = manager._runtime[1].send_task
  511. await asyncio.wait_for(started.wait(), 1)
  512. manager.handle_offline(1)
  513. await manager._runtime[1].send_task
  514. release.set()
  515. await old_task
  516. assert sent == [3, 9]
  517. assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("offline", 9)
  518. await manager.shutdown()
  519. @pytest.mark.asyncio
  520. async def test_late_old_send_cannot_confirm_over_new_state(monkeypatch):
  521. started = asyncio.Event()
  522. release = asyncio.Event()
  523. async def delayed_send(printer_id, base_url, preset_id, *, status=None, quiet=False):
  524. if preset_id == 3:
  525. started.set()
  526. try:
  527. await release.wait()
  528. except asyncio.CancelledError:
  529. await release.wait()
  530. return True
  531. manager = WLEDManager()
  532. monkeypatch.setattr(manager, "send_preset", delayed_send)
  533. manager.configure_printer(
  534. 1,
  535. {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3, "paused": 7}},
  536. )
  537. manager.handle_status(1, _state("RUNNING"))
  538. old_task = manager._runtime[1].send_task
  539. await asyncio.wait_for(started.wait(), 1)
  540. manager.handle_status(1, _state("PAUSE"))
  541. assert old_task.cancelled() or old_task.cancelling()
  542. await manager._runtime[1].send_task
  543. assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("paused", 7)
  544. release.set()
  545. await old_task
  546. assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("paused", 7)
  547. await manager.shutdown()
  548. @pytest.mark.asyncio
  549. @pytest.mark.parametrize(
  550. ("raw_state", "stage", "hms", "gate", "required", "presets", "expected_state", "expected_preset"),
  551. [
  552. ("PAUSE", 6, False, False, True, {"paused": 7}, "paused", 7),
  553. ("PAUSE", 6, True, False, True, {"hms_error": 8, "paused": 7}, "hms_error", 8),
  554. ("PAUSE", 16, False, False, True, {"filament_problem": 6, "paused": 7}, "paused", 7),
  555. ("RUNNING", -1, True, False, True, {"printing": 3}, "printing", 3),
  556. ("FINISH", -1, False, True, False, {"queue_waiting": 5, "finished": 9}, "finished", 9),
  557. ("FINISH", -1, False, True, True, {"queue_waiting": 5, "finished": 9}, "queue_waiting", 5),
  558. ("FINISH", -1, False, True, True, {"finished": 9}, "finished", 9),
  559. ("FAILED", -1, False, True, True, {"error": 10}, "error", 10),
  560. ("IDLE", -1, False, True, True, {"idle": 1}, "idle", 1),
  561. ("FINISH", -1, False, True, True, {"idle": 1}, None, None),
  562. ("RUNNING", -1, False, False, True, {"idle": 1}, None, None),
  563. ],
  564. )
  565. async def test_missing_preset_falls_back_only_to_applicable_states(
  566. raw_state, stage, hms, gate, required, presets, expected_state, expected_preset
  567. ):
  568. sent = []
  569. def handler(request: httpx.Request):
  570. sent.append(request)
  571. return httpx.Response(200, json={"success": True})
  572. manager, client = _manager(handler)
  573. manager.require_plate_clear = required
  574. manager.configure_printer(1, {"enabled": True, "base_url": "http://wled.local", "presets": presets})
  575. fault = HMSError(code="fault", attr=0, module=0, severity=1, description="Nozzle fault")
  576. manager.handle_status(
  577. 1,
  578. _state(raw_state, stg_cur=stage, hms_errors=[fault] if hms else []),
  579. awaiting_plate_clear=gate,
  580. )
  581. await asyncio.sleep(0)
  582. assert [request.content for request in sent] == (
  583. [f'{{"ps":{expected_preset}}}'.encode()] if expected_preset else []
  584. )
  585. assert manager._runtime[1].status == expected_state
  586. await manager.shutdown()
  587. await client.aclose()
  588. def test_invalid_persisted_config_is_treated_as_disabled():
  589. manager = WLEDManager()
  590. manager.configure_printer(1, {"enabled": True, "base_url": None})
  591. assert manager._configs[1].enabled is False
  592. manager.configure_printer(
  593. 1,
  594. {"enabled": True, "base_url": "http://169.254.169.254/latest/meta-data", "presets": {"printing": 3}},
  595. )
  596. manager.handle_status(1, _state("RUNNING"))
  597. assert manager._configs[1].enabled is False
  598. assert manager._runtime == {}
  599. assert manager._client is None
  600. @pytest.mark.asyncio
  601. async def test_finished_timeout_confirms_idle_until_new_print_cycle(caplog):
  602. caplog.set_level(logging.INFO, logger="backend.app.services.wled")
  603. sent = []
  604. def handler(request: httpx.Request):
  605. sent.append(request)
  606. return httpx.Response(200, json={"success": True})
  607. manager, client = _manager(handler)
  608. manager.configure_printer(
  609. 1,
  610. {
  611. "enabled": True,
  612. "base_url": "http://wled.local",
  613. "presets": {"finished": 9, "idle": 1, "printing": 3},
  614. "finished_timeout_seconds": 10,
  615. },
  616. )
  617. manager.handle_status(1, _state("FINISH"))
  618. await manager._runtime[1].send_task
  619. runtime = manager._runtime[1]
  620. await manager._finish_timeout(1, runtime.generation, "http://wled.local", 1, 0)
  621. assert [request.content for request in sent] == [b'{"ps":9}', b'{"ps":1}']
  622. assert "[WLED] Printer 1 state=idle -> preset=1" in caplog.messages
  623. assert (runtime.status, runtime.preset_id) == ("idle", 1)
  624. manager.handle_status(1, _state("FINISH", progress=100))
  625. await asyncio.sleep(0)
  626. assert len(sent) == 2
  627. manager.handle_status(1, _state("IDLE"))
  628. await manager._runtime[1].send_task
  629. manager.handle_status(1, _state("FINISH"))
  630. await asyncio.sleep(0)
  631. assert [request.content for request in sent].count(b'{"ps":9}') == 1
  632. manager.handle_status(1, _state("RUNNING"))
  633. await manager._runtime[1].send_task
  634. assert sent[-1].content == b'{"ps":3}'
  635. manager.handle_status(1, _state("FINISH"))
  636. await manager._runtime[1].send_task
  637. assert [request.content for request in sent].count(b'{"ps":9}') == 2
  638. await manager.shutdown()
  639. await client.aclose()
  640. @pytest.mark.asyncio
  641. async def test_finished_timeout_cancels_when_printing_resumes():
  642. manager, client = _manager(lambda request: httpx.Response(200, json={"success": True}))
  643. manager.configure_printer(
  644. 1,
  645. {
  646. "enabled": True,
  647. "base_url": "http://wled.local",
  648. "presets": {"finished": 9, "idle": 1, "printing": 3},
  649. "finished_timeout_seconds": 10,
  650. },
  651. )
  652. manager.handle_status(1, _state("FINISH"))
  653. timer = manager._runtime[1].finished_task
  654. manager.handle_status(1, _state("RUNNING"))
  655. assert timer is not None and timer.cancelling()
  656. await manager._runtime[1].send_task
  657. await manager.shutdown()
  658. await client.aclose()
  659. @pytest.mark.asyncio
  660. async def test_failed_finished_timeout_idle_send_is_retryable():
  661. sent = []
  662. def handler(request: httpx.Request):
  663. sent.append(request)
  664. return httpx.Response(503 if request.content == b'{"ps":1}' and len(sent) == 2 else 200, json={"success": True})
  665. manager, client = _manager(handler)
  666. manager.retry_seconds = 0
  667. manager.configure_printer(
  668. 1,
  669. {
  670. "enabled": True,
  671. "base_url": "http://wled.local",
  672. "presets": {"finished": 9, "idle": 1},
  673. "finished_timeout_seconds": 10,
  674. },
  675. )
  676. manager.handle_status(1, _state("FINISH"))
  677. runtime = manager._runtime[1]
  678. await runtime.send_task
  679. runtime.finished_task.cancel()
  680. runtime.finished_task = asyncio.create_task(
  681. manager._finish_timeout(1, runtime.generation, "http://wled.local", 1, 0)
  682. )
  683. await runtime.finished_task
  684. assert (runtime.status, runtime.preset_id) == ("finished", 9)
  685. manager.handle_status(1, _state("FINISH"))
  686. await runtime.finished_task
  687. assert [request.content for request in sent] == [b'{"ps":9}', b'{"ps":1}', b'{"ps":1}']
  688. assert (runtime.status, runtime.preset_id) == ("idle", 1)
  689. await manager.shutdown()
  690. await client.aclose()
  691. @pytest.mark.asyncio
  692. async def test_late_finished_timeout_idle_cannot_confirm_over_new_printing(monkeypatch):
  693. started = asyncio.Event()
  694. release = asyncio.Event()
  695. async def delayed_send(printer_id, base_url, preset_id, *, status=None, quiet=False):
  696. if preset_id == 1:
  697. started.set()
  698. try:
  699. await release.wait()
  700. except asyncio.CancelledError:
  701. await release.wait()
  702. return True
  703. manager = WLEDManager()
  704. monkeypatch.setattr(manager, "send_preset", delayed_send)
  705. manager.configure_printer(
  706. 1,
  707. {
  708. "enabled": True,
  709. "base_url": "http://wled.local",
  710. "presets": {"finished": 9, "idle": 1, "printing": 3},
  711. "finished_timeout_seconds": 10,
  712. },
  713. )
  714. manager.handle_status(1, _state("FINISH"))
  715. runtime = manager._runtime[1]
  716. await runtime.send_task
  717. runtime.finished_task.cancel()
  718. old_timeout = asyncio.create_task(manager._finish_timeout(1, runtime.generation, "http://wled.local", 1, 0))
  719. runtime.finished_task = old_timeout
  720. await asyncio.wait_for(started.wait(), 1)
  721. manager.handle_status(1, _state("RUNNING"))
  722. await runtime.send_task
  723. assert (runtime.status, runtime.preset_id) == ("printing", 3)
  724. release.set()
  725. await old_timeout
  726. assert (runtime.status, runtime.preset_id) == ("printing", 3)
  727. await manager.shutdown()