test_inventory_assign.py 106 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407
  1. """Integration tests for inventory spool assignment — tray_info_idx resolution.
  2. Tests that the spool's own slicer_filament (including PFUS* cloud-synced
  3. custom presets) takes priority, with slot reuse and generic fallback as
  4. lower-priority fallbacks.
  5. """
  6. from unittest.mock import AsyncMock, MagicMock, patch
  7. import pytest
  8. from httpx import AsyncClient
  9. from sqlalchemy.ext.asyncio import AsyncSession
  10. from backend.app.models.spool import Spool
  11. @pytest.fixture
  12. async def spool_factory(db_session: AsyncSession):
  13. """Factory to create test spools."""
  14. _counter = [0]
  15. async def _create_spool(**kwargs):
  16. _counter[0] += 1
  17. defaults = {
  18. "material": "PLA",
  19. "subtype": "Basic",
  20. "brand": "Devil Design",
  21. "color_name": "Red",
  22. "rgba": "FF0000FF",
  23. "label_weight": 1000,
  24. "weight_used": 0,
  25. "slicer_filament": "PFUS9ac902733670a9",
  26. }
  27. defaults.update(kwargs)
  28. spool = Spool(**defaults)
  29. db_session.add(spool)
  30. await db_session.commit()
  31. await db_session.refresh(spool)
  32. return spool
  33. return _create_spool
  34. def _make_mock_status(ams_data=None, vt_tray=None, nozzles=None, ams_extruder_map=None):
  35. """Build a mock printer status with optional AMS/nozzle data."""
  36. status = MagicMock()
  37. raw = {}
  38. if ams_data is not None:
  39. raw["ams"] = {"ams": ams_data}
  40. if vt_tray is not None:
  41. raw["vt_tray"] = vt_tray
  42. status.raw_data = raw
  43. status.nozzles = nozzles or [MagicMock(nozzle_diameter="0.4")]
  44. status.ams_extruder_map = ams_extruder_map
  45. return status
  46. class TestAssignSpoolTrayInfoIdx:
  47. """Tests for tray_info_idx resolution during spool assignment."""
  48. @pytest.mark.asyncio
  49. @pytest.mark.integration
  50. async def test_pfus_slicer_filament_falls_back_to_generic(
  51. self, async_client: AsyncClient, printer_factory, spool_factory
  52. ):
  53. """PFUS* cloud setting_ids are rejected by the slicer as tray_info_idx, so the
  54. no-kp path falls back to the generic material id (PLA → GFL99). The K-profile
  55. realignment path translates PFUS → P-prefix when a stored kp exists; that's
  56. covered separately."""
  57. printer = await printer_factory(name="H2D")
  58. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  59. mock_client = MagicMock()
  60. mock_client.ams_set_filament_setting.return_value = True
  61. mock_client.extrusion_cali_sel.return_value = True
  62. status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "", "tray_type": "PLA"}]}])
  63. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  64. mock_pm.get_client.return_value = mock_client
  65. mock_pm.get_status.return_value = status
  66. response = await async_client.post(
  67. "/api/v1/inventory/assignments",
  68. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  69. )
  70. assert response.status_code == 200
  71. call_kwargs = mock_client.ams_set_filament_setting.call_args
  72. assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
  73. @pytest.mark.asyncio
  74. @pytest.mark.integration
  75. async def test_pfus_spool_reuses_valid_slot_preset(self, async_client: AsyncClient, printer_factory, spool_factory):
  76. """When the spool's PFUS gets discarded as slicer-invalid, the slot's existing
  77. valid P-prefix preset is reused if it matches the spool's material — preserves
  78. the printer's calibration context rather than resetting to generic."""
  79. printer = await printer_factory(name="H2D")
  80. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  81. mock_client = MagicMock()
  82. mock_client.ams_set_filament_setting.return_value = True
  83. mock_client.extrusion_cali_sel.return_value = True
  84. # Slot already configured by slicer with cloud-synced preset
  85. status = _make_mock_status(
  86. ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "P4d64437", "tray_type": "PLA"}]}]
  87. )
  88. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  89. mock_pm.get_client.return_value = mock_client
  90. mock_pm.get_status.return_value = status
  91. response = await async_client.post(
  92. "/api/v1/inventory/assignments",
  93. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  94. )
  95. assert response.status_code == 200
  96. call_kwargs = mock_client.ams_set_filament_setting.call_args
  97. assert call_kwargs.kwargs["tray_info_idx"] == "P4d64437"
  98. @pytest.mark.asyncio
  99. @pytest.mark.integration
  100. async def test_spool_preset_used_even_if_different_material_on_slot(
  101. self, async_client: AsyncClient, printer_factory, spool_factory
  102. ):
  103. """Spool's material drives the fallback generic id. Slot's existing PLA preset
  104. is overridden because the spool is PETG → GFG99."""
  105. printer = await printer_factory(name="H2D")
  106. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PETG")
  107. mock_client = MagicMock()
  108. mock_client.ams_set_filament_setting.return_value = True
  109. mock_client.extrusion_cali_sel.return_value = True
  110. # Slot currently has PLA but spool is PETG
  111. status = _make_mock_status(
  112. ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "P4d64437", "tray_type": "PLA"}]}]
  113. )
  114. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  115. mock_pm.get_client.return_value = mock_client
  116. mock_pm.get_status.return_value = status
  117. response = await async_client.post(
  118. "/api/v1/inventory/assignments",
  119. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  120. )
  121. assert response.status_code == 200
  122. call_kwargs = mock_client.ams_set_filament_setting.call_args
  123. assert call_kwargs.kwargs["tray_info_idx"] == "GFG99"
  124. @pytest.mark.asyncio
  125. @pytest.mark.integration
  126. async def test_gf_slicer_filament_kept(self, async_client: AsyncClient, printer_factory, spool_factory):
  127. """Standard GF* IDs from spool.slicer_filament are used directly."""
  128. printer = await printer_factory(name="X1C")
  129. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  130. mock_client = MagicMock()
  131. mock_client.ams_set_filament_setting.return_value = True
  132. mock_client.extrusion_cali_sel.return_value = True
  133. status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}]}])
  134. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  135. mock_pm.get_client.return_value = mock_client
  136. mock_pm.get_status.return_value = status
  137. response = await async_client.post(
  138. "/api/v1/inventory/assignments",
  139. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  140. )
  141. assert response.status_code == 200
  142. call_kwargs = mock_client.ams_set_filament_setting.call_args
  143. assert call_kwargs.kwargs["tray_info_idx"] == "GFL05"
  144. @pytest.mark.asyncio
  145. @pytest.mark.integration
  146. async def test_empty_slicer_filament_uses_generic(self, async_client: AsyncClient, printer_factory, spool_factory):
  147. """Spool with no slicer_filament gets a generic ID from material type."""
  148. printer = await printer_factory(name="X1C")
  149. spool = await spool_factory(slicer_filament=None, material="ABS")
  150. mock_client = MagicMock()
  151. mock_client.ams_set_filament_setting.return_value = True
  152. mock_client.extrusion_cali_sel.return_value = True
  153. status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 0, "tray_type": "ABS"}]}])
  154. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  155. mock_pm.get_client.return_value = mock_client
  156. mock_pm.get_status.return_value = status
  157. response = await async_client.post(
  158. "/api/v1/inventory/assignments",
  159. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  160. )
  161. assert response.status_code == 200
  162. call_kwargs = mock_client.ams_set_filament_setting.call_args
  163. assert call_kwargs.kwargs["tray_info_idx"] == "GFB99"
  164. @pytest.mark.asyncio
  165. @pytest.mark.integration
  166. async def test_spool_pfus_falls_back_to_generic_over_slot_pfus(
  167. self, async_client: AsyncClient, printer_factory, spool_factory
  168. ):
  169. """Both spool and slot have PFUS values — both rejected as tray_info_idx —
  170. falls back to generic material id (PLA → GFL99)."""
  171. printer = await printer_factory(name="H2D")
  172. spool = await spool_factory(slicer_filament="PFUS1111111111", material="PLA")
  173. mock_client = MagicMock()
  174. mock_client.ams_set_filament_setting.return_value = True
  175. mock_client.extrusion_cali_sel.return_value = True
  176. # Slot has a PFUS* ID from some previous config
  177. status = _make_mock_status(
  178. ams_data=[{"id": 0, "tray": [{"id": 0, "tray_info_idx": "PFUS2222222222", "tray_type": "PLA"}]}]
  179. )
  180. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  181. mock_pm.get_client.return_value = mock_client
  182. mock_pm.get_status.return_value = status
  183. response = await async_client.post(
  184. "/api/v1/inventory/assignments",
  185. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  186. )
  187. assert response.status_code == 200
  188. call_kwargs = mock_client.ams_set_filament_setting.call_args
  189. assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
  190. @pytest.mark.asyncio
  191. @pytest.mark.integration
  192. async def test_generic_on_slot_falls_back_to_material_generic(
  193. self, async_client: AsyncClient, printer_factory, spool_factory
  194. ):
  195. """When spool's PFUS is discarded and slot only has a generic ID, the result
  196. comes from the spool's material (ABS → GFB99) — not from the slot. Important
  197. because the generic-id check (`not in _generic_id_values`) prevents stale
  198. generic reuse and routes the decision through the material fallback."""
  199. printer = await printer_factory(name="P2S")
  200. spool = await spool_factory(slicer_filament="PFUScda4c46fc9031", material="ABS")
  201. mock_client = MagicMock()
  202. mock_client.ams_set_filament_setting.return_value = True
  203. mock_client.extrusion_cali_sel.return_value = True
  204. # Slot stuck on generic ABS from a previous assignment
  205. status = _make_mock_status(
  206. ams_data=[{"id": 0, "tray": [{"id": 1, "tray_info_idx": "GFB99", "tray_type": "ABS"}]}]
  207. )
  208. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  209. mock_pm.get_client.return_value = mock_client
  210. mock_pm.get_status.return_value = status
  211. response = await async_client.post(
  212. "/api/v1/inventory/assignments",
  213. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
  214. )
  215. assert response.status_code == 200
  216. call_kwargs = mock_client.ams_set_filament_setting.call_args
  217. assert call_kwargs.kwargs["tray_info_idx"] == "GFB99"
  218. @pytest.mark.asyncio
  219. @pytest.mark.integration
  220. async def test_no_preset_with_generic_on_slot_still_uses_generic(
  221. self, async_client: AsyncClient, printer_factory, spool_factory
  222. ):
  223. """Spool without preset + generic on slot → generic fallback (not slot reuse)."""
  224. printer = await printer_factory(name="P2S")
  225. spool = await spool_factory(slicer_filament=None, material="ABS")
  226. mock_client = MagicMock()
  227. mock_client.ams_set_filament_setting.return_value = True
  228. mock_client.extrusion_cali_sel.return_value = True
  229. # Slot has generic ABS
  230. status = _make_mock_status(
  231. ams_data=[{"id": 0, "tray": [{"id": 1, "tray_info_idx": "GFB99", "tray_type": "ABS"}]}]
  232. )
  233. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  234. mock_pm.get_client.return_value = mock_client
  235. mock_pm.get_status.return_value = status
  236. response = await async_client.post(
  237. "/api/v1/inventory/assignments",
  238. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
  239. )
  240. assert response.status_code == 200
  241. call_kwargs = mock_client.ams_set_filament_setting.call_args
  242. # Still gets generic, but via fallback — not via sticky reuse
  243. assert call_kwargs.kwargs["tray_info_idx"] == "GFB99"
  244. @pytest.mark.asyncio
  245. @pytest.mark.integration
  246. async def test_no_preset_reuses_specific_slot_preset(
  247. self, async_client: AsyncClient, printer_factory, spool_factory
  248. ):
  249. """Spool without preset + specific preset on slot → reuse slot's preset."""
  250. printer = await printer_factory(name="X1C")
  251. spool = await spool_factory(slicer_filament=None, material="PLA")
  252. mock_client = MagicMock()
  253. mock_client.ams_set_filament_setting.return_value = True
  254. mock_client.extrusion_cali_sel.return_value = True
  255. # Slot has a specific Bambu PLA preset (not generic)
  256. status = _make_mock_status(
  257. ams_data=[{"id": 0, "tray": [{"id": 0, "tray_info_idx": "GFA05", "tray_type": "PLA"}]}]
  258. )
  259. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  260. mock_pm.get_client.return_value = mock_client
  261. mock_pm.get_status.return_value = status
  262. response = await async_client.post(
  263. "/api/v1/inventory/assignments",
  264. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  265. )
  266. assert response.status_code == 200
  267. call_kwargs = mock_client.ams_set_filament_setting.call_args
  268. # Slot's specific preset is reused when spool has no own preset
  269. assert call_kwargs.kwargs["tray_info_idx"] == "GFA05"
  270. class TestAssignSpoolPresetMapping:
  271. """Tests that assign_spool saves the slot preset mapping for correct UI display."""
  272. @pytest.mark.asyncio
  273. @pytest.mark.integration
  274. async def test_preset_mapping_saved_with_slicer_filament_name(
  275. self, async_client: AsyncClient, printer_factory, spool_factory
  276. ):
  277. """Slot preset mapping uses slicer_filament_name (not material+subtype)."""
  278. printer = await printer_factory(name="X1C")
  279. spool = await spool_factory(
  280. slicer_filament="GFA05",
  281. slicer_filament_name="Bambu PLA Silk",
  282. material="PLA",
  283. subtype="Silk",
  284. brand="Bambu",
  285. )
  286. mock_client = MagicMock()
  287. mock_client.ams_set_filament_setting.return_value = True
  288. mock_client.extrusion_cali_sel.return_value = True
  289. status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 1, "tray_type": "PLA"}]}])
  290. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  291. mock_pm.get_client.return_value = mock_client
  292. mock_pm.get_status.return_value = status
  293. response = await async_client.post(
  294. "/api/v1/inventory/assignments",
  295. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
  296. )
  297. assert response.status_code == 200
  298. # Verify via the slot presets API
  299. presets_resp = await async_client.get(f"/api/v1/printers/{printer.id}/slot-presets")
  300. assert presets_resp.status_code == 200
  301. presets = presets_resp.json()
  302. # Key is str(ams_id * 4 + tray_id) — ams 0, tray 1 → "1"
  303. assert "1" in presets
  304. # Must use slicer_filament_name, NOT "PLA Silk" from material+subtype
  305. assert presets["1"]["preset_name"] == "Bambu PLA Silk"
  306. assert presets["1"]["preset_id"] == "GFSA05"
  307. # The filament id the slot was given goes with it, so the slot card can
  308. # tell a later re-configuration from elsewhere (#3216).
  309. assert presets["1"]["tray_info_idx"] == "GFA05"
  310. @pytest.mark.asyncio
  311. @pytest.mark.integration
  312. async def test_preset_mapping_overwrites_old_mapping(
  313. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  314. ):
  315. """Assigning a new spool overwrites the old slot preset mapping."""
  316. from backend.app.models.slot_preset import SlotPresetMapping
  317. printer = await printer_factory(name="X1C")
  318. # Pre-existing mapping (e.g. from previous manual configuration)
  319. old_mapping = SlotPresetMapping(
  320. printer_id=printer.id,
  321. ams_id=0,
  322. tray_id=2,
  323. preset_id="GFSA01",
  324. preset_name="Bambu PLA Matte",
  325. preset_source="cloud",
  326. tray_info_idx="GFA01",
  327. )
  328. db_session.add(old_mapping)
  329. await db_session.commit()
  330. # Assign a "Generic PLA Silk" spool to same slot
  331. spool = await spool_factory(
  332. slicer_filament="GFL96",
  333. slicer_filament_name="Generic PLA Silk",
  334. material="PLA",
  335. subtype="Silk",
  336. )
  337. mock_client = MagicMock()
  338. mock_client.ams_set_filament_setting.return_value = True
  339. mock_client.extrusion_cali_sel.return_value = True
  340. status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 2, "tray_type": "PLA"}]}])
  341. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  342. mock_pm.get_client.return_value = mock_client
  343. mock_pm.get_status.return_value = status
  344. response = await async_client.post(
  345. "/api/v1/inventory/assignments",
  346. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 2},
  347. )
  348. assert response.status_code == 200
  349. # Verify via the slot presets API to avoid stale session cache
  350. presets_resp = await async_client.get(f"/api/v1/printers/{printer.id}/slot-presets")
  351. assert presets_resp.status_code == 200
  352. presets = presets_resp.json()
  353. # Key is str(ams_id * 4 + tray_id) — ams 0, tray 2 → "2"
  354. assert "2" in presets
  355. # Old "Bambu PLA Matte" must be overwritten
  356. assert presets["2"]["preset_name"] == "Generic PLA Silk"
  357. assert presets["2"]["preset_id"] == "GFSL96"
  358. # ...and so must the filament id recorded with it (#3216).
  359. assert presets["2"]["tray_info_idx"] == "GFL96"
  360. @pytest.mark.asyncio
  361. @pytest.mark.integration
  362. async def test_preset_mapping_fallback_to_tray_sub_brands(
  363. self, async_client: AsyncClient, printer_factory, spool_factory
  364. ):
  365. """When slicer_filament_name is null, falls back to tray_sub_brands."""
  366. from backend.app.models.slot_preset import SlotPresetMapping
  367. printer = await printer_factory(name="A1M")
  368. spool = await spool_factory(
  369. slicer_filament="GFL05",
  370. slicer_filament_name=None,
  371. material="PLA",
  372. subtype="Matte",
  373. brand="Overture",
  374. )
  375. mock_client = MagicMock()
  376. mock_client.ams_set_filament_setting.return_value = True
  377. mock_client.extrusion_cali_sel.return_value = True
  378. status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}]}])
  379. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  380. mock_pm.get_client.return_value = mock_client
  381. mock_pm.get_status.return_value = status
  382. response = await async_client.post(
  383. "/api/v1/inventory/assignments",
  384. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  385. )
  386. assert response.status_code == 200
  387. # Verify via the slot presets API
  388. presets_resp = await async_client.get(f"/api/v1/printers/{printer.id}/slot-presets")
  389. assert presets_resp.status_code == 200
  390. presets = presets_resp.json()
  391. # Key is str(ams_id * 4 + tray_id) — ams 0, tray 0 → "0"
  392. assert "0" in presets
  393. # Falls back to tray_sub_brands ("Overture PLA Matte")
  394. assert presets["0"]["preset_name"] == "Overture PLA Matte"
  395. class TestAssignSpoolLiveCaliIdx:
  396. """assign_spool always resets the slot to Default K when the spool has no stored K-profile."""
  397. @pytest.mark.asyncio
  398. @pytest.mark.integration
  399. async def test_no_kprofile_resets_to_default_k(self, async_client: AsyncClient, printer_factory, spool_factory):
  400. """When no KProfile row exists, slot resets to cali_idx=-1 (Default K) regardless of live value."""
  401. printer = await printer_factory()
  402. spool = await spool_factory()
  403. mock_client = MagicMock()
  404. mock_client.ams_set_filament_setting.return_value = True
  405. mock_client.extrusion_cali_sel.return_value = True
  406. # Live cali_idx=42 belongs to whatever filament was previously calibrated
  407. # in this slot. Applying it to a different spool would use the wrong K
  408. # value, so the assign flow must override it with Default K (-1).
  409. tray_data = {
  410. "id": 1,
  411. "cali_idx": 42,
  412. "tray_color": "FF0000FF",
  413. "tray_type": "PLA",
  414. "tray_sub_brands": "PLA Basic",
  415. "tray_id_name": "GFL99",
  416. }
  417. status = _make_mock_status(ams_data=[{"id": 0, "tray": [tray_data]}])
  418. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  419. mock_pm.get_client.return_value = mock_client
  420. mock_pm.get_status.return_value = status
  421. response = await async_client.post(
  422. "/api/v1/inventory/assignments",
  423. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
  424. )
  425. assert response.status_code == 200
  426. mock_client.extrusion_cali_sel.assert_called_once()
  427. assert mock_client.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
  428. @pytest.mark.asyncio
  429. @pytest.mark.integration
  430. async def test_no_kprofile_no_live_cali_idx_sends_default(
  431. self, async_client: AsyncClient, printer_factory, spool_factory
  432. ):
  433. """When tray has no cali_idx, extrusion_cali_sel is sent with cali_idx=-1 (Default)."""
  434. printer = await printer_factory()
  435. spool = await spool_factory()
  436. mock_client = MagicMock()
  437. mock_client.ams_set_filament_setting.return_value = True
  438. mock_client.extrusion_cali_sel.return_value = True
  439. tray_data = {
  440. "id": 0,
  441. "cali_idx": None,
  442. "tray_color": "FF0000FF",
  443. "tray_type": "PLA",
  444. "tray_sub_brands": "PLA Basic",
  445. "tray_id_name": "GFL99",
  446. }
  447. status = _make_mock_status(ams_data=[{"id": 0, "tray": [tray_data]}])
  448. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  449. mock_pm.get_client.return_value = mock_client
  450. mock_pm.get_status.return_value = status
  451. response = await async_client.post(
  452. "/api/v1/inventory/assignments",
  453. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  454. )
  455. assert response.status_code == 200
  456. mock_client.extrusion_cali_sel.assert_called_once()
  457. assert mock_client.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
  458. @pytest.mark.asyncio
  459. @pytest.mark.integration
  460. async def test_negative_live_cali_idx_sends_default(
  461. self, async_client: AsyncClient, printer_factory, spool_factory
  462. ):
  463. """A negative live cali_idx (-1) falls through and is sent as Default (cali_idx=-1)."""
  464. printer = await printer_factory()
  465. spool = await spool_factory()
  466. mock_client = MagicMock()
  467. mock_client.ams_set_filament_setting.return_value = True
  468. mock_client.extrusion_cali_sel.return_value = True
  469. tray_data = {
  470. "id": 0,
  471. "cali_idx": -1,
  472. "tray_color": "FF0000FF",
  473. "tray_type": "PLA",
  474. "tray_sub_brands": "PLA Basic",
  475. "tray_id_name": "GFL99",
  476. }
  477. status = _make_mock_status(ams_data=[{"id": 0, "tray": [tray_data]}])
  478. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  479. mock_pm.get_client.return_value = mock_client
  480. mock_pm.get_status.return_value = status
  481. response = await async_client.post(
  482. "/api/v1/inventory/assignments",
  483. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  484. )
  485. assert response.status_code == 200
  486. mock_client.extrusion_cali_sel.assert_called_once()
  487. assert mock_client.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
  488. class TestAssignSpoolEmptySlotPreConfig:
  489. """Assign path under ambiguous / explicit-empty AMS state.
  490. Updated for the #1322 follow-up: only the firmware's *explicit* empty
  491. signal (state ∈ {9, 10}) skips MQTT. Anything else — including the
  492. SpoolBuddy weigh-then-assign-before-insert case where state/tray_type
  493. can't tell us whether a spool is loaded — attempts MQTT. The deferred-
  494. config workflow still works because on_ams_change at main.py:1031-1054
  495. re-fires when an AMS push eventually reports the loaded slot.
  496. """
  497. @pytest.mark.asyncio
  498. @pytest.mark.integration
  499. async def test_empty_tray_type_without_state_still_fires_mqtt(
  500. self, async_client: AsyncClient, printer_factory, spool_factory
  501. ):
  502. """tray_type='' with no state field: AMS can't tell us whether a
  503. spool is loaded. Trust the user's Assign click and fire MQTT —
  504. firmware accepts it when a spool is physically there, drops it
  505. silently otherwise (no harm)."""
  506. printer = await printer_factory(name="H2D")
  507. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  508. mock_client = MagicMock()
  509. mock_client.ams_set_filament_setting.return_value = True
  510. mock_client.extrusion_cali_sel.return_value = True
  511. status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_type": ""}]}])
  512. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  513. mock_pm.get_client.return_value = mock_client
  514. mock_pm.get_status.return_value = status
  515. response = await async_client.post(
  516. "/api/v1/inventory/assignments",
  517. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  518. )
  519. assert response.status_code == 200
  520. mock_client.ams_set_filament_setting.assert_called_once()
  521. body = response.json()
  522. assert body["pending_config"] is False
  523. assert body["configured"] is True
  524. @pytest.mark.asyncio
  525. @pytest.mark.integration
  526. async def test_no_ams_data_with_no_client_marks_pending(
  527. self, async_client: AsyncClient, printer_factory, spool_factory
  528. ):
  529. """No AMS data + no MQTT client (printer offline, no telemetry):
  530. publish can't happen, so configured=False and pending_config=True so
  531. on_ams_change replay picks it up when the printer comes online."""
  532. printer = await printer_factory(name="X1C")
  533. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  534. # No AMS data — fingerprint_type stays None.
  535. status = _make_mock_status(ams_data=[])
  536. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  537. mock_pm.get_client.return_value = None # Printer offline, no MQTT client.
  538. mock_pm.get_status.return_value = status
  539. response = await async_client.post(
  540. "/api/v1/inventory/assignments",
  541. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  542. )
  543. assert response.status_code == 200
  544. body = response.json()
  545. assert body["pending_config"] is True
  546. assert body["configured"] is False
  547. @pytest.mark.asyncio
  548. @pytest.mark.integration
  549. async def test_loaded_slot_publishes_mqtt_immediately(
  550. self, async_client: AsyncClient, printer_factory, spool_factory
  551. ):
  552. """Loaded slot (tray_type non-empty) → MQTT fires + pending_config=False."""
  553. printer = await printer_factory(name="X1C")
  554. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  555. mock_client = MagicMock()
  556. mock_client.ams_set_filament_setting.return_value = True
  557. mock_client.extrusion_cali_sel.return_value = True
  558. status = _make_mock_status(
  559. ams_data=[{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_info_idx": "GFL05"}]}]
  560. )
  561. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  562. mock_pm.get_client.return_value = mock_client
  563. mock_pm.get_status.return_value = status
  564. response = await async_client.post(
  565. "/api/v1/inventory/assignments",
  566. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  567. )
  568. assert response.status_code == 200
  569. body = response.json()
  570. assert body["pending_config"] is False
  571. assert body["configured"] is True
  572. mock_client.ams_set_filament_setting.assert_called_once()
  573. @pytest.mark.asyncio
  574. @pytest.mark.integration
  575. async def test_on_ams_change_fires_config_when_pre_assigned_slot_loads(
  576. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  577. ):
  578. """Pre-config replay: SpoolAssignment with empty fingerprint + slot now loaded → MQTT fires."""
  579. from unittest.mock import AsyncMock
  580. from backend.app.main import on_ams_change
  581. from backend.app.models.spool_assignment import SpoolAssignment
  582. printer = await printer_factory(name="H2D")
  583. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  584. # Pre-existing assignment with empty fingerprint (the SpoolBuddy state)
  585. pre_assignment = SpoolAssignment(
  586. spool_id=spool.id,
  587. printer_id=printer.id,
  588. ams_id=2,
  589. tray_id=3,
  590. fingerprint_color=None,
  591. fingerprint_type=None,
  592. )
  593. db_session.add(pre_assignment)
  594. await db_session.commit()
  595. # Filament has now been physically inserted into the slot.
  596. # state=11 ("filament fed to extruder") is the load signal we trigger on.
  597. ams_data = [{"id": 2, "tray": [{"id": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "state": 11}]}]
  598. mock_client = MagicMock()
  599. mock_client.ams_set_filament_setting.return_value = True
  600. mock_client.extrusion_cali_sel.return_value = True
  601. status = _make_mock_status(ams_data=ams_data)
  602. printer_info = MagicMock(name="H2D", serial_number="0948BB540200427")
  603. with (
  604. patch("backend.app.main.printer_manager") as mock_pm_main,
  605. patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
  606. patch("backend.app.main.mqtt_relay") as mock_relay,
  607. patch("backend.app.main.ws_manager") as mock_ws,
  608. ):
  609. mock_pm_main.get_printer.return_value = printer_info
  610. mock_pm_main.get_status.return_value = status
  611. mock_pm_main.get_client.return_value = mock_client
  612. mock_pm_main.get_model.return_value = "H2D"
  613. mock_pm_inv.get_client.return_value = mock_client
  614. mock_pm_inv.get_status.return_value = status
  615. mock_relay.on_ams_change = AsyncMock()
  616. mock_ws.send_printer_status = AsyncMock()
  617. mock_ws.broadcast = AsyncMock()
  618. await on_ams_change(printer.id, ams_data)
  619. # Full filament setting was published when the slot transitioned to loaded
  620. mock_client.ams_set_filament_setting.assert_called_once()
  621. call_kwargs = mock_client.ams_set_filament_setting.call_args.kwargs
  622. assert call_kwargs["ams_id"] == 2
  623. assert call_kwargs["tray_id"] == 3
  624. assert call_kwargs["tray_info_idx"] == "GFL05"
  625. # Fingerprint was updated so the next push doesn't re-fire
  626. await db_session.refresh(pre_assignment)
  627. assert pre_assignment.fingerprint_type == "PLA"
  628. assert pre_assignment.fingerprint_color == "FF0000FF"
  629. @pytest.mark.asyncio
  630. @pytest.mark.integration
  631. async def test_on_ams_change_does_not_refire_for_already_configured_slot(
  632. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  633. ):
  634. """Once fingerprint_type is set, subsequent AMS pushes must not re-fire MQTT."""
  635. from unittest.mock import AsyncMock
  636. from backend.app.main import on_ams_change
  637. from backend.app.models.spool_assignment import SpoolAssignment
  638. printer = await printer_factory(name="X1C")
  639. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  640. # Assignment already configured (fingerprint stamped)
  641. configured_assignment = SpoolAssignment(
  642. spool_id=spool.id,
  643. printer_id=printer.id,
  644. ams_id=0,
  645. tray_id=0,
  646. fingerprint_color="FF0000FF",
  647. fingerprint_type="PLA",
  648. )
  649. db_session.add(configured_assignment)
  650. await db_session.commit()
  651. ams_data = [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF", "state": 11}]}]
  652. mock_client = MagicMock()
  653. mock_client.ams_set_filament_setting.return_value = True
  654. mock_client.extrusion_cali_sel.return_value = True
  655. status = _make_mock_status(ams_data=ams_data)
  656. printer_info = MagicMock(name="X1C", serial_number="00M00A391800004")
  657. with (
  658. patch("backend.app.main.printer_manager") as mock_pm_main,
  659. patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
  660. patch("backend.app.main.mqtt_relay") as mock_relay,
  661. patch("backend.app.main.ws_manager") as mock_ws,
  662. ):
  663. mock_pm_main.get_printer.return_value = printer_info
  664. mock_pm_main.get_status.return_value = status
  665. mock_pm_main.get_client.return_value = mock_client
  666. mock_pm_main.get_model.return_value = "X1C"
  667. mock_pm_inv.get_client.return_value = mock_client
  668. mock_pm_inv.get_status.return_value = status
  669. mock_relay.on_ams_change = AsyncMock()
  670. mock_ws.send_printer_status = AsyncMock()
  671. mock_ws.broadcast = AsyncMock()
  672. await on_ams_change(printer.id, ams_data)
  673. # Fingerprint was already set — re-fire path skipped
  674. mock_client.ams_set_filament_setting.assert_not_called()
  675. @pytest.mark.asyncio
  676. @pytest.mark.integration
  677. async def test_on_ams_change_fires_replay_when_tray_type_appears_without_state_11(
  678. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  679. ):
  680. """A1 Mini / P1S firmware variant of the SpoolBuddy pre-config replay
  681. (#1322). The user pre-assigned via SpoolBuddy (fingerprint empty), then
  682. configured the slot manually in Bambu Studio so tray_type went from ''
  683. to 'PLA' — but state stays at 3 because these firmwares never set it
  684. to 11. With state-only detection the replay never fired."""
  685. from unittest.mock import AsyncMock
  686. from backend.app.main import on_ams_change
  687. from backend.app.models.spool_assignment import SpoolAssignment
  688. printer = await printer_factory(name="A1 mini")
  689. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  690. pre_assignment = SpoolAssignment(
  691. spool_id=spool.id,
  692. printer_id=printer.id,
  693. ams_id=0,
  694. tray_id=3,
  695. fingerprint_color=None,
  696. fingerprint_type=None,
  697. )
  698. db_session.add(pre_assignment)
  699. await db_session.commit()
  700. # state=3 (never goes to 11 on A1 Mini BMCU 01.07.02.00) but tray_type
  701. # is now configured — the replay must fire on this transition too.
  702. ams_data = [
  703. {
  704. "id": 0,
  705. "tray": [{"id": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "state": 3, "tray_info_idx": "GFL05"}],
  706. }
  707. ]
  708. mock_client = MagicMock()
  709. mock_client.ams_set_filament_setting.return_value = True
  710. mock_client.extrusion_cali_sel.return_value = True
  711. status = _make_mock_status(ams_data=ams_data)
  712. printer_info = MagicMock(name="A1 mini", serial_number="0309CA391800999")
  713. with (
  714. patch("backend.app.main.printer_manager") as mock_pm_main,
  715. patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
  716. patch("backend.app.main.mqtt_relay") as mock_relay,
  717. patch("backend.app.main.ws_manager") as mock_ws,
  718. ):
  719. mock_pm_main.get_printer.return_value = printer_info
  720. mock_pm_main.get_status.return_value = status
  721. mock_pm_main.get_client.return_value = mock_client
  722. mock_pm_main.get_model.return_value = "A1 mini"
  723. mock_pm_inv.get_client.return_value = mock_client
  724. mock_pm_inv.get_status.return_value = status
  725. mock_relay.on_ams_change = AsyncMock()
  726. mock_ws.send_printer_status = AsyncMock()
  727. mock_ws.broadcast = AsyncMock()
  728. await on_ams_change(printer.id, ams_data)
  729. # Replay fired despite state never being 11 — the disjunction picked
  730. # up tray_type going non-empty.
  731. mock_client.ams_set_filament_setting.assert_called_once()
  732. await db_session.refresh(pre_assignment)
  733. assert pre_assignment.fingerprint_type == "PLA"
  734. @pytest.mark.asyncio
  735. @pytest.mark.integration
  736. async def test_auto_unlink_broadcasts_assignment_change(
  737. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  738. ):
  739. """#2575 follow-up: when on_ams_change auto-unlinks a stale external-spool
  740. assignment, it must broadcast spool_assignment_changed. Only the manual
  741. REST endpoints did, so open browsers kept rendering the unlinked spool —
  742. the reporter read that as "the fix didn't work" when the DB was correct."""
  743. from unittest.mock import AsyncMock
  744. from sqlalchemy import select
  745. from backend.app.main import on_ams_change
  746. from backend.app.models.spool_assignment import SpoolAssignment
  747. printer = await printer_factory(name="X1C")
  748. spool = await spool_factory(slicer_filament="GFU01", material="TPU")
  749. # TPU inventory spool assigned to the external slot (ams_id=255, tray 0)
  750. assignment = SpoolAssignment(
  751. spool_id=spool.id,
  752. printer_id=printer.id,
  753. ams_id=255,
  754. tray_id=0,
  755. fingerprint_color="000000FF",
  756. fingerprint_type="TPU",
  757. )
  758. db_session.add(assignment)
  759. await db_session.commit()
  760. # The printer's external spool now reports ABS — the assignment is stale.
  761. vt_tray = [
  762. {
  763. "id": "254",
  764. "tray_type": "ABS",
  765. "tray_color": "000000FF",
  766. "tag_uid": "0000000000000000",
  767. "tray_uuid": "00000000000000000000000000000000",
  768. }
  769. ]
  770. status = _make_mock_status(ams_data=[], vt_tray=vt_tray)
  771. printer_info = MagicMock(name="X1C", serial_number="00M00A391800004")
  772. with (
  773. patch("backend.app.main.printer_manager") as mock_pm_main,
  774. patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
  775. patch("backend.app.main.mqtt_relay") as mock_relay,
  776. patch("backend.app.main.ws_manager") as mock_ws,
  777. ):
  778. mock_pm_main.get_printer.return_value = printer_info
  779. mock_pm_main.get_status.return_value = status
  780. mock_pm_main.get_client.return_value = MagicMock()
  781. mock_pm_main.get_model.return_value = "X1C"
  782. mock_pm_inv.get_client.return_value = MagicMock()
  783. mock_pm_inv.get_status.return_value = status
  784. mock_relay.on_ams_change = AsyncMock()
  785. mock_ws.send_printer_status = AsyncMock()
  786. mock_ws.broadcast = AsyncMock()
  787. await on_ams_change(printer.id, [])
  788. # The stale TPU assignment on the now-ABS external slot was unlinked...
  789. gone = await db_session.execute(
  790. select(SpoolAssignment).where(
  791. SpoolAssignment.printer_id == printer.id,
  792. SpoolAssignment.ams_id == 255,
  793. SpoolAssignment.tray_id == 0,
  794. )
  795. )
  796. assert gone.scalar_one_or_none() is None
  797. # ...and the frontend was told about it.
  798. change_events = [
  799. c.args[0]
  800. for c in mock_ws.broadcast.await_args_list
  801. if c.args and isinstance(c.args[0], dict) and c.args[0].get("type") == "spool_assignment_changed"
  802. ]
  803. assert change_events, "auto-unlink must broadcast spool_assignment_changed"
  804. assert change_events[0]["printer_id"] == printer.id
  805. assert change_events[0]["ams_id"] == 255
  806. assert change_events[0]["tray_id"] == 0
  807. class TestAssignSpoolEmptyDetection:
  808. """Bambu firmware reports tray.state — 11=loaded, 9=empty, 10=spool present
  809. but filament not in feeder. The assign route must prefer that signal over
  810. tray_type for the empty-vs-loaded check, because a manual "Reset slot"
  811. clears tray_type to "" while leaving filament physically loaded — the
  812. legacy heuristic would route to the pending-config path and skip MQTT
  813. forever, since on_ams_change replay only fires on an empty→loaded
  814. transition that never comes when the slot is already loaded.
  815. """
  816. @pytest.mark.asyncio
  817. @pytest.mark.integration
  818. async def test_state_loaded_with_empty_tray_type_fires_mqtt(
  819. self, async_client: AsyncClient, printer_factory, spool_factory
  820. ):
  821. """Post-reset case: state=11 (loaded) but tray_type='' — MQTT must fire."""
  822. printer = await printer_factory()
  823. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  824. mock_client = MagicMock()
  825. mock_client.ams_set_filament_setting.return_value = True
  826. mock_client.extrusion_cali_sel.return_value = True
  827. # Simulates the "reset slot" aftermath: filament physically loaded
  828. # (state=11) but tray_type/tray_color/tray_info_idx have been cleared.
  829. tray_data = {"id": 3, "state": 11, "tray_type": "", "tray_color": "", "tray_info_idx": ""}
  830. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  831. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  832. mock_pm.get_client.return_value = mock_client
  833. mock_pm.get_status.return_value = status
  834. response = await async_client.post(
  835. "/api/v1/inventory/assignments",
  836. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  837. )
  838. assert response.status_code == 200
  839. # MQTT must have fired — the bug was that legacy detection saw the
  840. # empty tray_type and skipped this entirely.
  841. mock_client.ams_set_filament_setting.assert_called_once()
  842. # Response must report configured=True, pending_config=False — the
  843. # slot is loaded, just had stale metadata cleared.
  844. body = response.json()
  845. assert body["pending_config"] is False
  846. assert body["configured"] is True
  847. @pytest.mark.asyncio
  848. @pytest.mark.integration
  849. async def test_state_empty_skips_mqtt_and_marks_pending(
  850. self, async_client: AsyncClient, printer_factory, spool_factory
  851. ):
  852. """Genuinely empty slot: state=9 — MQTT skipped, pending_config=True."""
  853. printer = await printer_factory()
  854. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  855. mock_client = MagicMock()
  856. mock_client.ams_set_filament_setting.return_value = True
  857. tray_data = {"id": 3, "state": 9, "tray_type": "", "tray_color": "", "tray_info_idx": ""}
  858. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  859. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  860. mock_pm.get_client.return_value = mock_client
  861. mock_pm.get_status.return_value = status
  862. response = await async_client.post(
  863. "/api/v1/inventory/assignments",
  864. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  865. )
  866. assert response.status_code == 200
  867. # SpoolBuddy weigh-then-assign workflow: firmware drops MQTT for
  868. # unloaded slots, so we don't bother sending it.
  869. mock_client.ams_set_filament_setting.assert_not_called()
  870. body = response.json()
  871. assert body["pending_config"] is True
  872. assert body["configured"] is False
  873. @pytest.mark.asyncio
  874. @pytest.mark.integration
  875. async def test_state_missing_falls_back_to_tray_type_loaded(
  876. self, async_client: AsyncClient, printer_factory, spool_factory
  877. ):
  878. """Older firmware without state field: tray_type='PLA' → treated as loaded."""
  879. printer = await printer_factory()
  880. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  881. mock_client = MagicMock()
  882. mock_client.ams_set_filament_setting.return_value = True
  883. # No 'state' key at all — older firmware behaviour.
  884. tray_data = {"id": 3, "tray_type": "PLA", "tray_color": "FF0000FF"}
  885. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  886. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  887. mock_pm.get_client.return_value = mock_client
  888. mock_pm.get_status.return_value = status
  889. response = await async_client.post(
  890. "/api/v1/inventory/assignments",
  891. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  892. )
  893. assert response.status_code == 200
  894. # Legacy fallback: tray_type non-empty → treated as loaded → MQTT fires.
  895. mock_client.ams_set_filament_setting.assert_called_once()
  896. body = response.json()
  897. assert body["pending_config"] is False
  898. @pytest.mark.asyncio
  899. @pytest.mark.integration
  900. async def test_state_missing_with_empty_tray_type_still_fires_mqtt(
  901. self, async_client: AsyncClient, printer_factory, spool_factory
  902. ):
  903. """Older firmware without state field + empty tray_type still fires MQTT.
  904. The AMS doesn't tell us whether a spool is physically loaded in this
  905. case (no state, no tray_type), so the assign click is the user's
  906. assertion that a spool is there. Firmware silently drops the push on
  907. a truly empty slot — no harm done, and on_ams_change replay handles
  908. the deferred-config case (#1322 follow-up).
  909. """
  910. printer = await printer_factory()
  911. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  912. mock_client = MagicMock()
  913. mock_client.ams_set_filament_setting.return_value = True
  914. mock_client.extrusion_cali_sel.return_value = True
  915. tray_data = {"id": 3, "tray_type": "", "tray_color": ""}
  916. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  917. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  918. mock_pm.get_client.return_value = mock_client
  919. mock_pm.get_status.return_value = status
  920. response = await async_client.post(
  921. "/api/v1/inventory/assignments",
  922. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  923. )
  924. assert response.status_code == 200
  925. mock_client.ams_set_filament_setting.assert_called_once()
  926. body = response.json()
  927. assert body["pending_config"] is False
  928. assert body["configured"] is True
  929. @pytest.mark.asyncio
  930. @pytest.mark.integration
  931. async def test_state_never_eleven_firmware_with_loaded_tray_fires_mqtt(
  932. self, async_client: AsyncClient, printer_factory, spool_factory
  933. ):
  934. """A1 Mini BMCU 01.07.02.00 and P1S Standard AMS 00.00.06.75 always
  935. report tray.state=3, never 11 — even for fully-loaded configured slots.
  936. A state-only check classified those as empty and skipped MQTT (#1322).
  937. With the disjunctive check, tray_type='PLA' alone is enough to fire."""
  938. printer = await printer_factory()
  939. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  940. mock_client = MagicMock()
  941. mock_client.ams_set_filament_setting.return_value = True
  942. mock_client.extrusion_cali_sel.return_value = True
  943. # state=3, tray_type non-empty — A1 Mini / P1S configured slot.
  944. tray_data = {"id": 3, "state": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "tray_info_idx": "GFL99"}
  945. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  946. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  947. mock_pm.get_client.return_value = mock_client
  948. mock_pm.get_status.return_value = status
  949. response = await async_client.post(
  950. "/api/v1/inventory/assignments",
  951. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  952. )
  953. assert response.status_code == 200
  954. mock_client.ams_set_filament_setting.assert_called_once()
  955. body = response.json()
  956. assert body["pending_config"] is False
  957. assert body["configured"] is True
  958. @pytest.mark.asyncio
  959. @pytest.mark.integration
  960. async def test_post_reset_slot_with_state_3_still_fires_mqtt(
  961. self, async_client: AsyncClient, printer_factory, spool_factory
  962. ):
  963. """A1 Mini BMCU / P1S Standard AMS post-"Reset Slot" with spool still
  964. inserted: state=3, tray_type="". The AMS gives us no signal to tell
  965. this apart from a truly-empty slot. We trust the user's Assign click
  966. and fire MQTT — firmware accepts the push because a spool is
  967. physically there (#1322 follow-up by @RosdasHH).
  968. Replaces the previous "marks_pending" assertion which was the bug:
  969. that gate created a deadlock because the AMS would never report a
  970. state change (nothing physically changed), so on_ams_change replay
  971. never re-fired the deferred config either.
  972. """
  973. printer = await printer_factory()
  974. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  975. mock_client = MagicMock()
  976. mock_client.ams_set_filament_setting.return_value = True
  977. mock_client.extrusion_cali_sel.return_value = True
  978. tray_data = {"id": 3, "state": 3, "tray_type": "", "tray_color": "00000000", "tray_info_idx": ""}
  979. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  980. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  981. mock_pm.get_client.return_value = mock_client
  982. mock_pm.get_status.return_value = status
  983. response = await async_client.post(
  984. "/api/v1/inventory/assignments",
  985. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  986. )
  987. assert response.status_code == 200
  988. mock_client.ams_set_filament_setting.assert_called_once()
  989. body = response.json()
  990. assert body["pending_config"] is False
  991. assert body["configured"] is True
  992. @pytest.mark.asyncio
  993. @pytest.mark.integration
  994. async def test_external_slot_state_loaded_with_empty_tray_type_fires_mqtt(
  995. self, async_client: AsyncClient, printer_factory, spool_factory
  996. ):
  997. """External (vt_tray) slot post-reset: same fix applies for ams_id=255."""
  998. printer = await printer_factory(name="X1C")
  999. spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
  1000. mock_client = MagicMock()
  1001. mock_client.ams_set_filament_setting.return_value = True
  1002. # External slot tray_id=0 → vt_tray id=254. state=11 (loaded), tray_type
  1003. # cleared by reset.
  1004. vt_data = [{"id": 254, "state": 11, "tray_type": "", "tray_color": "", "tray_info_idx": ""}]
  1005. status = _make_mock_status(ams_data=[], vt_tray=vt_data)
  1006. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  1007. mock_pm.get_client.return_value = mock_client
  1008. mock_pm.get_status.return_value = status
  1009. response = await async_client.post(
  1010. "/api/v1/inventory/assignments",
  1011. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 255, "tray_id": 0},
  1012. )
  1013. assert response.status_code == 200
  1014. mock_client.ams_set_filament_setting.assert_called_once()
  1015. body = response.json()
  1016. assert body["pending_config"] is False
  1017. assert body["configured"] is True
  1018. class TestAssignSpoolPresenceBit:
  1019. """#3084: the slot the firmware says is full, and the cache says is empty.
  1020. ``apply_tray_exist_bits`` stamps ``state = 9`` on every slot whose
  1021. ``tray_exist_bits`` bit is 0 and annotates ``exists`` on every slot it
  1022. looks at — but when the bit comes back it only refreshes ``exists`` and
  1023. leaves the 9 where it was. Swap a Bambu spool for a non-RFID one and the
  1024. slot sits at ``exists=True, state=9`` until something configures it.
  1025. Reported on an H2D/H2C AMS-HT: remove the Bambu spool (bits ``f``), insert
  1026. a third-party one 9 seconds later (bits ``1000f``), then Assign Spool 28
  1027. seconds after that — and no ``ams_filament_setting`` was published at all.
  1028. Configure worked, because it publishes unconditionally.
  1029. """
  1030. @pytest.mark.asyncio
  1031. @pytest.mark.integration
  1032. async def test_the_bit_overrules_a_stale_empty_state(
  1033. self, async_client: AsyncClient, printer_factory, spool_factory
  1034. ):
  1035. """exists=True with a leftover state=9 — MQTT must fire."""
  1036. printer = await printer_factory(name="H2D")
  1037. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  1038. mock_client = MagicMock()
  1039. mock_client.ams_set_filament_setting.return_value = True
  1040. mock_client.extrusion_cali_sel.return_value = True
  1041. tray_data = {"id": 0, "state": 9, "exists": True, "tray_type": "", "tray_color": "", "tray_info_idx": ""}
  1042. status = _make_mock_status(ams_data=[{"id": 128, "tray": [tray_data]}])
  1043. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  1044. mock_pm.get_client.return_value = mock_client
  1045. mock_pm.get_status.return_value = status
  1046. response = await async_client.post(
  1047. "/api/v1/inventory/assignments",
  1048. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 128, "tray_id": 0},
  1049. )
  1050. assert response.status_code == 200
  1051. mock_client.ams_set_filament_setting.assert_called_once()
  1052. body = response.json()
  1053. assert body["configured"] is True
  1054. assert body["pending_config"] is False
  1055. @pytest.mark.asyncio
  1056. @pytest.mark.integration
  1057. async def test_an_empty_bit_does_not_start_suppressing_pushes(
  1058. self, async_client: AsyncClient, printer_factory, spool_factory
  1059. ):
  1060. """The bit overrules the 9 and nothing else.
  1061. Reading it the other way too would be tidier — skip the push firmware
  1062. is going to drop — but it also means a slot that silently stops
  1063. configuring on whichever AMS variant we compute the bit position
  1064. wrong for. The saving is one MQTT message; the failure is the bug
  1065. this commit is fixing, inverted. So a state that does not say "empty"
  1066. still publishes, exactly as it did before.
  1067. """
  1068. printer = await printer_factory(name="H2D")
  1069. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  1070. mock_client = MagicMock()
  1071. mock_client.ams_set_filament_setting.return_value = True
  1072. mock_client.extrusion_cali_sel.return_value = True
  1073. tray_data = {"id": 3, "state": 11, "exists": False, "tray_type": "", "tray_color": ""}
  1074. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  1075. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  1076. mock_pm.get_client.return_value = mock_client
  1077. mock_pm.get_status.return_value = status
  1078. response = await async_client.post(
  1079. "/api/v1/inventory/assignments",
  1080. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  1081. )
  1082. assert response.status_code == 200
  1083. mock_client.ams_set_filament_setting.assert_called_once()
  1084. assert response.json()["pending_config"] is False
  1085. @pytest.mark.asyncio
  1086. @pytest.mark.integration
  1087. async def test_the_pre_assign_workflow_still_skips_a_genuinely_empty_slot(
  1088. self, async_client: AsyncClient, printer_factory, spool_factory
  1089. ):
  1090. """SpoolBuddy weighs a spool and assigns it before it goes in. Bit
  1091. clear and state 9 agree that the slot is empty, so the push is still
  1092. deferred to the replay — unchanged."""
  1093. printer = await printer_factory(name="H2D")
  1094. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  1095. mock_client = MagicMock()
  1096. mock_client.ams_set_filament_setting.return_value = True
  1097. tray_data = {"id": 3, "state": 9, "exists": False, "tray_type": "", "tray_color": ""}
  1098. status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
  1099. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  1100. mock_pm.get_client.return_value = mock_client
  1101. mock_pm.get_status.return_value = status
  1102. response = await async_client.post(
  1103. "/api/v1/inventory/assignments",
  1104. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  1105. )
  1106. assert response.status_code == 200
  1107. mock_client.ams_set_filament_setting.assert_not_called()
  1108. body = response.json()
  1109. assert body["configured"] is False
  1110. assert body["pending_config"] is True
  1111. @pytest.mark.asyncio
  1112. @pytest.mark.integration
  1113. async def test_an_unannotated_tray_still_reads_the_state(
  1114. self, async_client: AsyncClient, printer_factory, spool_factory
  1115. ):
  1116. """No presence bit in the payload → the 9/10 heuristic still decides.
  1117. The external spool's ``vt_tray`` has no bit in the mask, and neither do
  1118. the hand-built payloads every other test in this file uses.
  1119. """
  1120. printer = await printer_factory(name="H2D")
  1121. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  1122. mock_client = MagicMock()
  1123. mock_client.ams_set_filament_setting.return_value = True
  1124. status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "state": 9, "tray_type": ""}]}])
  1125. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  1126. mock_pm.get_client.return_value = mock_client
  1127. mock_pm.get_status.return_value = status
  1128. response = await async_client.post(
  1129. "/api/v1/inventory/assignments",
  1130. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  1131. )
  1132. assert response.status_code == 200
  1133. mock_client.ams_set_filament_setting.assert_not_called()
  1134. assert response.json()["pending_config"] is True
  1135. @pytest.mark.asyncio
  1136. @pytest.mark.integration
  1137. async def test_deferred_config_fires_for_a_spool_the_ams_cannot_name(
  1138. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  1139. ):
  1140. """The pre-assign workflow's half of the same bug.
  1141. A non-RFID spool inserted into a pre-assigned slot brings no
  1142. ``tray_type`` with it, and the stale 9 kept the replay's "loaded" test
  1143. false, so the deferred configuration never fired for it either. The
  1144. presence bit is the only thing in the payload that changed.
  1145. """
  1146. from unittest.mock import AsyncMock
  1147. from backend.app.main import on_ams_change
  1148. from backend.app.models.spool_assignment import SpoolAssignment
  1149. printer = await printer_factory(name="H2D")
  1150. spool = await spool_factory(slicer_filament="GFL05", material="PLA")
  1151. pre_assignment = SpoolAssignment(
  1152. spool_id=spool.id,
  1153. printer_id=printer.id,
  1154. ams_id=2,
  1155. tray_id=3,
  1156. fingerprint_color=None,
  1157. fingerprint_type=None,
  1158. )
  1159. db_session.add(pre_assignment)
  1160. await db_session.commit()
  1161. ams_data = [{"id": 2, "tray": [{"id": 3, "state": 9, "exists": True, "tray_type": "", "tray_color": ""}]}]
  1162. mock_client = MagicMock()
  1163. mock_client.ams_set_filament_setting.return_value = True
  1164. mock_client.extrusion_cali_sel.return_value = True
  1165. status = _make_mock_status(ams_data=ams_data)
  1166. with (
  1167. patch("backend.app.main.printer_manager") as mock_pm_main,
  1168. patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
  1169. patch("backend.app.main.mqtt_relay") as mock_relay,
  1170. patch("backend.app.main.ws_manager") as mock_ws,
  1171. ):
  1172. mock_pm_main.get_printer.return_value = MagicMock(name="H2D", serial_number="0948BB540200427")
  1173. mock_pm_main.get_status.return_value = status
  1174. mock_pm_main.get_client.return_value = mock_client
  1175. mock_pm_main.get_model.return_value = "H2D"
  1176. mock_pm_inv.get_client.return_value = mock_client
  1177. mock_pm_inv.get_status.return_value = status
  1178. mock_relay.on_ams_change = AsyncMock()
  1179. mock_ws.send_printer_status = AsyncMock()
  1180. mock_ws.broadcast = AsyncMock()
  1181. await on_ams_change(printer.id, ams_data)
  1182. mock_client.ams_set_filament_setting.assert_called_once()
  1183. call_kwargs = mock_client.ams_set_filament_setting.call_args.kwargs
  1184. assert call_kwargs["ams_id"] == 2
  1185. assert call_kwargs["tray_id"] == 3
  1186. assert call_kwargs["tray_info_idx"] == "GFL05"
  1187. # The assignment is still there — the pass that fires the config is the
  1188. # same pass that deletes stale ones (#3100).
  1189. db_session.expunge_all()
  1190. assert await db_session.get(SpoolAssignment, pre_assignment.id) is not None
  1191. class TestAssignSpoolPfcnCloudPreset:
  1192. """Assign path for PFCN-prefix cloud presets (#1648).
  1193. PFCN is a third Bambu cloud preset shape alongside PFUS (cloud user-created)
  1194. and GFS (Bambu official) — used for cloud-shared / partner-uploaded
  1195. presets like Polymaker's "(Custom)" Bambu Lab H2D variants. Before #1648
  1196. the assign path skipped the cloud-detail lookup and left the raw PFCN
  1197. string in tray_info_idx, which the printer's calibration table can't
  1198. resolve. ConfigureAmsSlotModal rescued each assignment by doing the lookup
  1199. itself, making "Configure" feel like a mandatory follow-up step.
  1200. """
  1201. @pytest.mark.asyncio
  1202. @pytest.mark.integration
  1203. async def test_pfcn_falls_back_to_generic_when_cloud_unavailable(
  1204. self, async_client: AsyncClient, printer_factory, spool_factory
  1205. ):
  1206. """When cloud auth isn't available (e.g. user not logged into Bambu Cloud),
  1207. the raw PFCN must be discarded as slicer-invalid and the slot configures
  1208. with the spool's generic material id (PLA → GFL99). Pre-fix behaviour
  1209. was to leak the raw PFCN, which the slicer can't resolve."""
  1210. printer = await printer_factory(name="H2D")
  1211. spool = await spool_factory(slicer_filament="PFCN80e80c1f79db85", material="PLA")
  1212. mock_client = MagicMock()
  1213. mock_client.ams_set_filament_setting.return_value = True
  1214. mock_client.extrusion_cali_sel.return_value = True
  1215. status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "", "tray_type": "PLA"}]}])
  1216. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  1217. mock_pm.get_client.return_value = mock_client
  1218. mock_pm.get_status.return_value = status
  1219. response = await async_client.post(
  1220. "/api/v1/inventory/assignments",
  1221. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  1222. )
  1223. assert response.status_code == 200
  1224. call_kwargs = mock_client.ams_set_filament_setting.call_args
  1225. # PFCN never leaks into tray_info_idx — must resolve to the
  1226. # generic-material fallback when cloud lookup couldn't.
  1227. assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
  1228. assert not call_kwargs.kwargs["tray_info_idx"].startswith("PFCN")
  1229. @pytest.mark.asyncio
  1230. @pytest.mark.integration
  1231. async def test_pfcn_spool_reuses_valid_slot_preset(self, async_client: AsyncClient, printer_factory, spool_factory):
  1232. """Symmetry with the PFUS case: when the spool's PFCN is discarded as
  1233. slicer-invalid, the slot's existing valid P-prefix preset is reused
  1234. if material matches — preserves calibration context instead of
  1235. resetting to generic."""
  1236. printer = await printer_factory(name="H2D")
  1237. spool = await spool_factory(slicer_filament="PFCN80e80c1f79db85", material="PLA")
  1238. mock_client = MagicMock()
  1239. mock_client.ams_set_filament_setting.return_value = True
  1240. mock_client.extrusion_cali_sel.return_value = True
  1241. status = _make_mock_status(
  1242. ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "P4d64437", "tray_type": "PLA"}]}]
  1243. )
  1244. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  1245. mock_pm.get_client.return_value = mock_client
  1246. mock_pm.get_status.return_value = status
  1247. response = await async_client.post(
  1248. "/api/v1/inventory/assignments",
  1249. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  1250. )
  1251. assert response.status_code == 200
  1252. call_kwargs = mock_client.ams_set_filament_setting.call_args
  1253. assert call_kwargs.kwargs["tray_info_idx"] == "P4d64437"
  1254. @pytest.mark.asyncio
  1255. @pytest.mark.integration
  1256. async def test_pfcn_resolves_to_filament_id_via_cloud_lookup(
  1257. self, async_client: AsyncClient, printer_factory, spool_factory
  1258. ):
  1259. """When the user is authenticated against Bambu Cloud, the PFCN setting_id
  1260. triggers the same cloud-detail lookup as PFUS / GFS — extracts the real
  1261. filament_id from `detail["filament_id"]` and ships that as
  1262. tray_info_idx. This is the happy path the Configure modal already had
  1263. but the assign path didn't, #1648."""
  1264. printer = await printer_factory(name="H2D")
  1265. spool = await spool_factory(slicer_filament="PFCN80e80c1f79db85", material="PLA")
  1266. mock_client = MagicMock()
  1267. mock_client.ams_set_filament_setting.return_value = True
  1268. mock_client.extrusion_cali_sel.return_value = True
  1269. status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "", "tray_type": "PLA"}]}])
  1270. # Cloud responds with a real filament_id for the PFCN preset — exactly
  1271. # what the Configure modal already exploits.
  1272. mock_cloud = MagicMock()
  1273. mock_cloud.is_authenticated = True
  1274. async def fake_get_detail(setting_id):
  1275. assert setting_id == "PFCN80e80c1f79db85"
  1276. return {"filament_id": "GFL05", "name": "Polymaker PLA Matte"}
  1277. async def fake_close():
  1278. return None
  1279. mock_cloud.get_setting_detail = fake_get_detail
  1280. mock_cloud.close = fake_close
  1281. async def fake_build_cloud(_db, _user):
  1282. return mock_cloud
  1283. with (
  1284. patch("backend.app.services.printer_manager.printer_manager") as mock_pm,
  1285. patch("backend.app.api.routes.cloud.build_authenticated_cloud", new=fake_build_cloud),
  1286. ):
  1287. mock_pm.get_client.return_value = mock_client
  1288. mock_pm.get_status.return_value = status
  1289. response = await async_client.post(
  1290. "/api/v1/inventory/assignments",
  1291. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
  1292. )
  1293. assert response.status_code == 200
  1294. call_kwargs = mock_client.ams_set_filament_setting.call_args
  1295. # tray_info_idx is the resolved cloud filament_id; setting_id is the
  1296. # original PFCN (which the slicer needs separately).
  1297. assert call_kwargs.kwargs["tray_info_idx"] == "GFL05"
  1298. assert call_kwargs.kwargs["setting_id"] == "PFCN80e80c1f79db85"
  1299. def _make_printing_status(ams_data, state="RUNNING"):
  1300. """Printer status carrying an explicit gcode state for the runout guard."""
  1301. status = _make_mock_status(ams_data=ams_data)
  1302. status.state = state
  1303. return status
  1304. class TestAutoUnlinkDuringRunout:
  1305. """A slot that reports empty mid-print is a filament runout, not a spool
  1306. swap — the spool is still in the AMS, just consumed.
  1307. Unlinking there erased the only record of which spool fed the print, so the
  1308. completion path had nothing to charge the runout segment to. With AMS
  1309. filament backup that is the normal course of events, not an edge case.
  1310. """
  1311. @pytest.mark.asyncio
  1312. @pytest.mark.integration
  1313. async def test_cleared_tray_data_keeps_the_assignment_while_printing(
  1314. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  1315. ):
  1316. from unittest.mock import AsyncMock
  1317. from backend.app.main import on_ams_change
  1318. from backend.app.models.spool_assignment import SpoolAssignment
  1319. printer = await printer_factory(name="H2D")
  1320. spool = await spool_factory(material="ABS", rgba="616777FF")
  1321. assignment = SpoolAssignment(
  1322. spool_id=spool.id,
  1323. printer_id=printer.id,
  1324. ams_id=0,
  1325. tray_id=2,
  1326. fingerprint_color="616777FF",
  1327. fingerprint_type="ABS",
  1328. )
  1329. db_session.add(assignment)
  1330. await db_session.commit()
  1331. # The firmware clears colour and type when it unloads a spool it just
  1332. # emptied (state 26 = "not loaded").
  1333. ams_data = [{"id": 0, "tray": [{"id": 2, "tray_type": "", "tray_color": "", "state": 26}]}]
  1334. with (
  1335. patch("backend.app.main.printer_manager") as mock_pm_main,
  1336. patch("backend.app.main.mqtt_relay") as mock_relay,
  1337. patch("backend.app.main.ws_manager") as mock_ws,
  1338. ):
  1339. mock_pm_main.get_printer.return_value = MagicMock(name="H2D", serial_number="0948BB540200427")
  1340. mock_pm_main.get_status.return_value = _make_printing_status(ams_data)
  1341. mock_pm_main.get_model.return_value = "H2D"
  1342. mock_relay.on_ams_change = AsyncMock()
  1343. mock_ws.send_printer_status = AsyncMock()
  1344. mock_ws.broadcast = AsyncMock()
  1345. await on_ams_change(printer.id, ams_data)
  1346. # on_ams_change committed through its own session — drop this one's
  1347. # identity map so the assertion reads the database, not a cached row.
  1348. db_session.expunge_all()
  1349. remaining = await db_session.get(SpoolAssignment, assignment.id)
  1350. assert remaining is not None, "runout must not unlink the spool that fed the print"
  1351. @pytest.mark.asyncio
  1352. @pytest.mark.integration
  1353. async def test_cleared_tray_data_still_unlinks_when_idle(
  1354. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  1355. ):
  1356. """Off the print, an emptied slot really does mean the spool is gone --
  1357. once it has stayed empty for the grace period (#3186)."""
  1358. from unittest.mock import AsyncMock
  1359. from backend.app.main import on_ams_change
  1360. from backend.app.models.spool_assignment import SpoolAssignment
  1361. from backend.app.services import slot_unlink_grace
  1362. printer = await printer_factory(name="H2D")
  1363. spool = await spool_factory(material="ABS", rgba="616777FF")
  1364. assignment = SpoolAssignment(
  1365. spool_id=spool.id,
  1366. printer_id=printer.id,
  1367. ams_id=0,
  1368. tray_id=2,
  1369. fingerprint_color="616777FF",
  1370. fingerprint_type="ABS",
  1371. )
  1372. db_session.add(assignment)
  1373. await db_session.commit()
  1374. assignment_id = assignment.id
  1375. ams_data = [{"id": 0, "tray": [{"id": 2, "tray_type": "", "tray_color": "", "state": 26}]}]
  1376. with (
  1377. patch("backend.app.main.printer_manager") as mock_pm_main,
  1378. patch("backend.app.main.mqtt_relay") as mock_relay,
  1379. patch("backend.app.main.ws_manager") as mock_ws,
  1380. ):
  1381. mock_pm_main.get_printer.return_value = MagicMock(name="H2D", serial_number="0948BB540200427")
  1382. mock_pm_main.get_status.return_value = _make_printing_status(ams_data, state="IDLE")
  1383. mock_pm_main.get_model.return_value = "H2D"
  1384. mock_relay.on_ams_change = AsyncMock()
  1385. mock_ws.send_printer_status = AsyncMock()
  1386. mock_ws.broadcast = AsyncMock()
  1387. clock = [1000.0]
  1388. with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
  1389. await on_ams_change(printer.id, ams_data)
  1390. db_session.expunge_all()
  1391. assert await db_session.get(SpoolAssignment, assignment_id) is not None
  1392. clock[0] += slot_unlink_grace.GRACE_SECONDS
  1393. await on_ams_change(printer.id, ams_data)
  1394. db_session.expunge_all()
  1395. assert await db_session.get(SpoolAssignment, assignment_id) is None
  1396. @pytest.mark.asyncio
  1397. @pytest.mark.integration
  1398. async def test_a_genuinely_different_filament_still_unlinks_while_printing(
  1399. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  1400. ):
  1401. """The guard is for blank tray data only — a real swap must still
  1402. reconcile, or the wrong spool gets charged."""
  1403. from unittest.mock import AsyncMock
  1404. from backend.app.main import on_ams_change
  1405. from backend.app.models.spool_assignment import SpoolAssignment
  1406. printer = await printer_factory(name="H2D")
  1407. spool = await spool_factory(material="ABS", rgba="616777FF")
  1408. assignment = SpoolAssignment(
  1409. spool_id=spool.id,
  1410. printer_id=printer.id,
  1411. ams_id=0,
  1412. tray_id=2,
  1413. fingerprint_color="616777FF",
  1414. fingerprint_type="ABS",
  1415. )
  1416. db_session.add(assignment)
  1417. await db_session.commit()
  1418. assignment_id = assignment.id
  1419. ams_data = [{"id": 0, "tray": [{"id": 2, "tray_type": "PETG", "tray_color": "6EE53CFF", "state": 11}]}]
  1420. with (
  1421. patch("backend.app.main.printer_manager") as mock_pm_main,
  1422. patch("backend.app.main.mqtt_relay") as mock_relay,
  1423. patch("backend.app.main.ws_manager") as mock_ws,
  1424. ):
  1425. mock_pm_main.get_printer.return_value = MagicMock(name="H2D", serial_number="0948BB540200427")
  1426. mock_pm_main.get_status.return_value = _make_printing_status(ams_data)
  1427. mock_pm_main.get_model.return_value = "H2D"
  1428. mock_relay.on_ams_change = AsyncMock()
  1429. mock_ws.send_printer_status = AsyncMock()
  1430. mock_ws.broadcast = AsyncMock()
  1431. await on_ams_change(printer.id, ams_data)
  1432. db_session.expunge_all()
  1433. assert await db_session.get(SpoolAssignment, assignment_id) is None
  1434. @pytest.mark.asyncio
  1435. @pytest.mark.integration
  1436. async def test_slot_missing_from_ams_data_keeps_the_assignment_while_printing(
  1437. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  1438. ):
  1439. from unittest.mock import AsyncMock
  1440. from backend.app.main import on_ams_change
  1441. from backend.app.models.spool_assignment import SpoolAssignment
  1442. printer = await printer_factory(name="H2D")
  1443. spool = await spool_factory(material="ABS", rgba="616777FF")
  1444. assignment = SpoolAssignment(
  1445. spool_id=spool.id,
  1446. printer_id=printer.id,
  1447. ams_id=0,
  1448. tray_id=2,
  1449. fingerprint_color="616777FF",
  1450. fingerprint_type="ABS",
  1451. )
  1452. db_session.add(assignment)
  1453. await db_session.commit()
  1454. assignment_id = assignment.id
  1455. # Tray 2 dropped out of the payload entirely.
  1456. ams_data = [{"id": 0, "tray": [{"id": 0, "tray_type": "ABS", "tray_color": "FFFFFFFF", "state": 11}]}]
  1457. with (
  1458. patch("backend.app.main.printer_manager") as mock_pm_main,
  1459. patch("backend.app.main.mqtt_relay") as mock_relay,
  1460. patch("backend.app.main.ws_manager") as mock_ws,
  1461. ):
  1462. mock_pm_main.get_printer.return_value = MagicMock(name="H2D", serial_number="0948BB540200427")
  1463. mock_pm_main.get_status.return_value = _make_printing_status(ams_data)
  1464. mock_pm_main.get_model.return_value = "H2D"
  1465. mock_relay.on_ams_change = AsyncMock()
  1466. mock_ws.send_printer_status = AsyncMock()
  1467. mock_ws.broadcast = AsyncMock()
  1468. await on_ams_change(printer.id, ams_data)
  1469. db_session.expunge_all()
  1470. assert await db_session.get(SpoolAssignment, assignment_id) is not None
  1471. class TestAutoUnlinkOccupiedSlot:
  1472. """#3100: six saved assignments deleted across three X1 Carbons.
  1473. Each one had an explicit earlier assignment and then an ``Auto-unlink ...
  1474. fingerprint mismatch``, and the reporter recovered the mappings from logs
  1475. because they were gone from the inventory API, not merely hidden. Two
  1476. shapes, one cause: a slot the presence bit calls occupied while the tray
  1477. reports nothing about what is in it.
  1478. ``cur=/ fp=BCBCBCFF/PLA spool=8A8F92FF/PLA`` — an established assignment,
  1479. a blank idle report, and the row deleted. The spool never went anywhere;
  1480. the AMS simply had nothing to say about a filament it cannot read.
  1481. """
  1482. @staticmethod
  1483. async def _run(printer_id, ams_data, status):
  1484. from unittest.mock import AsyncMock
  1485. from backend.app.main import on_ams_change
  1486. with (
  1487. patch("backend.app.main.printer_manager") as mock_pm_main,
  1488. patch("backend.app.main.mqtt_relay") as mock_relay,
  1489. patch("backend.app.main.ws_manager") as mock_ws,
  1490. ):
  1491. mock_pm_main.get_printer.return_value = MagicMock(name="X1C", serial_number="0948BB540200427")
  1492. mock_pm_main.get_status.return_value = status
  1493. mock_pm_main.get_model.return_value = "X1C"
  1494. mock_pm_main.get_client.return_value = None
  1495. mock_relay.on_ams_change = AsyncMock()
  1496. mock_ws.send_printer_status = AsyncMock()
  1497. mock_ws.broadcast = AsyncMock()
  1498. await on_ams_change(printer_id, ams_data)
  1499. @staticmethod
  1500. async def _assignment(db_session, printer, spool):
  1501. from backend.app.models.spool_assignment import SpoolAssignment
  1502. assignment = SpoolAssignment(
  1503. spool_id=spool.id,
  1504. printer_id=printer.id,
  1505. ams_id=1,
  1506. tray_id=1,
  1507. fingerprint_color="BCBCBCFF",
  1508. fingerprint_type="PLA",
  1509. )
  1510. db_session.add(assignment)
  1511. await db_session.commit()
  1512. return assignment.id
  1513. @pytest.mark.asyncio
  1514. @pytest.mark.integration
  1515. async def test_a_blank_report_from_an_occupied_slot_keeps_the_assignment(
  1516. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  1517. ):
  1518. from backend.app.models.spool_assignment import SpoolAssignment
  1519. printer = await printer_factory(name="X1C")
  1520. spool = await spool_factory(material="PLA", rgba="8A8F92FF")
  1521. assignment_id = await self._assignment(db_session, printer, spool)
  1522. ams_data = [{"id": 1, "tray": [{"id": 1, "exists": True, "tray_type": "", "tray_color": "", "state": 9}]}]
  1523. await self._run(printer.id, ams_data, _make_printing_status(ams_data, state="IDLE"))
  1524. db_session.expunge_all()
  1525. assert await db_session.get(SpoolAssignment, assignment_id) is not None, (
  1526. "a spool the AMS cannot identify is not a spool that was removed"
  1527. )
  1528. @pytest.mark.asyncio
  1529. @pytest.mark.integration
  1530. async def test_a_blank_report_from_an_empty_slot_still_unlinks(
  1531. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  1532. ):
  1533. """The guard reads the bit, not the blankness — take the spool out and
  1534. the assignment still goes, which is what makes the test above a
  1535. distinction rather than a blanket reprieve."""
  1536. from backend.app.models.spool_assignment import SpoolAssignment
  1537. from backend.app.services import slot_unlink_grace
  1538. printer = await printer_factory(name="X1C")
  1539. spool = await spool_factory(material="PLA", rgba="8A8F92FF")
  1540. assignment_id = await self._assignment(db_session, printer, spool)
  1541. ams_data = [{"id": 1, "tray": [{"id": 1, "exists": False, "tray_type": "", "tray_color": "", "state": 9}]}]
  1542. clock = [1000.0]
  1543. with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
  1544. await self._run(printer.id, ams_data, _make_printing_status(ams_data, state="IDLE"))
  1545. clock[0] += slot_unlink_grace.GRACE_SECONDS
  1546. await self._run(printer.id, ams_data, _make_printing_status(ams_data, state="IDLE"))
  1547. db_session.expunge_all()
  1548. assert await db_session.get(SpoolAssignment, assignment_id) is None
  1549. @pytest.mark.asyncio
  1550. @pytest.mark.integration
  1551. async def test_a_genuinely_different_filament_in_an_occupied_slot_still_unlinks(
  1552. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  1553. ):
  1554. """The guard is for a blank report only. A slot that names a filament
  1555. which is not the assigned spool is a swap, bit set or not."""
  1556. from backend.app.models.spool_assignment import SpoolAssignment
  1557. printer = await printer_factory(name="X1C")
  1558. spool = await spool_factory(material="PLA", rgba="8A8F92FF")
  1559. assignment_id = await self._assignment(db_session, printer, spool)
  1560. ams_data = [
  1561. {"id": 1, "tray": [{"id": 1, "exists": True, "tray_type": "PETG", "tray_color": "00FF00FF", "state": 11}]}
  1562. ]
  1563. await self._run(printer.id, ams_data, _make_printing_status(ams_data, state="IDLE"))
  1564. db_session.expunge_all()
  1565. assert await db_session.get(SpoolAssignment, assignment_id) is None
  1566. class TestAutoUnlinkHeldForGrace:
  1567. """#3186: one push is not proof a spool was taken out.
  1568. An idle X1 Carbon on X1Plus sent a push that cleared a whole AMS unit --
  1569. presence bits off, colour and type blank -- and four saved assignments were
  1570. deleted in the same instant (``Auto-unlinked 4 stale spool assignments``).
  1571. The spools never moved. A removal is now held for the grace period and
  1572. unlinked only if the slot is still empty then.
  1573. """
  1574. @staticmethod
  1575. async def _assign_unit(db_session, printer, spool):
  1576. from backend.app.models.spool_assignment import SpoolAssignment
  1577. ids = []
  1578. for tray_id in range(4):
  1579. a = SpoolAssignment(
  1580. spool_id=spool.id,
  1581. printer_id=printer.id,
  1582. ams_id=1,
  1583. tray_id=tray_id,
  1584. fingerprint_color="858585FF",
  1585. fingerprint_type="PLA",
  1586. )
  1587. db_session.add(a)
  1588. await db_session.commit()
  1589. ids.append(a.id)
  1590. return ids
  1591. @staticmethod
  1592. def _unit(blank: bool):
  1593. return [
  1594. {
  1595. "id": 1,
  1596. "tray": [
  1597. {"id": t, "exists": False, "tray_type": "", "tray_color": "", "state": 9}
  1598. if blank
  1599. else {"id": t, "exists": True, "tray_type": "PLA", "tray_color": "858585FF", "state": 11}
  1600. for t in range(4)
  1601. ],
  1602. }
  1603. ]
  1604. @staticmethod
  1605. async def _surviving(db_session, ids):
  1606. from backend.app.models.spool_assignment import SpoolAssignment
  1607. db_session.expunge_all()
  1608. return [i for i in ids if await db_session.get(SpoolAssignment, i) is not None]
  1609. @pytest.mark.asyncio
  1610. @pytest.mark.integration
  1611. async def test_a_whole_unit_blanking_for_one_push_keeps_every_assignment(
  1612. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  1613. ):
  1614. from backend.app.services import slot_unlink_grace
  1615. printer = await printer_factory(name="X1C")
  1616. spool = await spool_factory(material="PLA", rgba="000000FF")
  1617. ids = await self._assign_unit(db_session, printer, spool)
  1618. clock = [1000.0]
  1619. with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
  1620. blank, back = self._unit(blank=True), self._unit(blank=False)
  1621. await TestAutoUnlinkOccupiedSlot._run(printer.id, blank, _make_printing_status(blank, state="IDLE"))
  1622. assert await self._surviving(db_session, ids) == ids
  1623. clock[0] += 30
  1624. await TestAutoUnlinkOccupiedSlot._run(printer.id, back, _make_printing_status(back, state="IDLE"))
  1625. # The recovery dropped the holds, so a blank long after the first
  1626. # one starts a fresh clock instead of confirming the old one.
  1627. clock[0] += slot_unlink_grace.GRACE_SECONDS
  1628. await TestAutoUnlinkOccupiedSlot._run(printer.id, blank, _make_printing_status(blank, state="IDLE"))
  1629. assert await self._surviving(db_session, ids) == ids
  1630. @pytest.mark.asyncio
  1631. @pytest.mark.integration
  1632. async def test_a_unit_missing_from_one_push_keeps_its_assignments(
  1633. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  1634. ):
  1635. printer = await printer_factory(name="X1C")
  1636. spool = await spool_factory(material="PLA", rgba="000000FF")
  1637. ids = await self._assign_unit(db_session, printer, spool)
  1638. # AMS 1 dropped out of the payload entirely.
  1639. other_unit = [{"id": 0, "tray": [{"id": 0, "exists": True, "tray_type": "PLA", "tray_color": "FFFFFFFF"}]}]
  1640. await TestAutoUnlinkOccupiedSlot._run(printer.id, other_unit, _make_printing_status(other_unit, state="IDLE"))
  1641. assert await self._surviving(db_session, ids) == ids
  1642. @pytest.mark.asyncio
  1643. @pytest.mark.integration
  1644. async def test_a_slot_that_stays_empty_is_unlinked_by_the_recheck(
  1645. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  1646. ):
  1647. """No second push has to arrive: a spool taken out and left out never
  1648. changes the AMS hash again, so the held removal re-checks itself."""
  1649. from backend.app.main import _recheck_held_unlinks
  1650. from backend.app.services import slot_unlink_grace
  1651. printer = await printer_factory(name="X1C")
  1652. spool = await spool_factory(material="PLA", rgba="000000FF")
  1653. ids = await self._assign_unit(db_session, printer, spool)
  1654. blank = self._unit(blank=True)
  1655. clock = [1000.0]
  1656. with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
  1657. await TestAutoUnlinkOccupiedSlot._run(printer.id, blank, _make_printing_status(blank, state="IDLE"))
  1658. assert printer.id in slot_unlink_grace._recheck_tasks, "a held removal must schedule its re-check"
  1659. assert await self._surviving(db_session, ids) == ids
  1660. clock[0] += slot_unlink_grace.GRACE_SECONDS
  1661. with (
  1662. patch("backend.app.main.printer_manager") as mock_pm_main,
  1663. patch("backend.app.main.ws_manager") as mock_ws,
  1664. ):
  1665. mock_pm_main.get_status.return_value = _make_printing_status(blank, state="IDLE")
  1666. mock_ws.broadcast = AsyncMock()
  1667. await _recheck_held_unlinks(printer.id)
  1668. assert await self._surviving(db_session, ids) == []
  1669. @pytest.mark.asyncio
  1670. @pytest.mark.integration
  1671. async def test_the_recheck_leaves_a_slot_that_came_back(
  1672. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  1673. ):
  1674. from backend.app.main import _recheck_held_unlinks
  1675. from backend.app.services import slot_unlink_grace
  1676. printer = await printer_factory(name="X1C")
  1677. spool = await spool_factory(material="PLA", rgba="000000FF")
  1678. ids = await self._assign_unit(db_session, printer, spool)
  1679. blank, back = self._unit(blank=True), self._unit(blank=False)
  1680. clock = [1000.0]
  1681. with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
  1682. await TestAutoUnlinkOccupiedSlot._run(printer.id, blank, _make_printing_status(blank, state="IDLE"))
  1683. clock[0] += slot_unlink_grace.GRACE_SECONDS
  1684. with (
  1685. patch("backend.app.main.printer_manager") as mock_pm_main,
  1686. patch("backend.app.main.ws_manager") as mock_ws,
  1687. ):
  1688. mock_pm_main.get_status.return_value = _make_printing_status(back, state="IDLE")
  1689. mock_ws.broadcast = AsyncMock()
  1690. await _recheck_held_unlinks(printer.id)
  1691. assert await self._surviving(db_session, ids) == ids
  1692. class TestAutoUnlinkSwapDuringGrace:
  1693. """A non-RFID spool swapped out and another put in within the grace period.
  1694. The new spool reports occupied-but-blank, which on its own keeps an
  1695. assignment (#3100). After an *empty* report it means the spool changed,
  1696. so the held removal has to keep running rather than be cancelled -- or the
  1697. old spool would stay assigned to the new one indefinitely.
  1698. """
  1699. @pytest.mark.asyncio
  1700. @pytest.mark.integration
  1701. async def test_a_swap_still_unlinks_once_the_grace_runs_out(
  1702. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  1703. ):
  1704. from backend.app.models.spool_assignment import SpoolAssignment
  1705. from backend.app.services import slot_unlink_grace
  1706. printer = await printer_factory(name="X1C")
  1707. spool = await spool_factory(material="PLA", rgba="8A8F92FF")
  1708. assignment_id = await TestAutoUnlinkOccupiedSlot._assignment(db_session, printer, spool)
  1709. taken_out = [{"id": 1, "tray": [{"id": 1, "exists": False, "tray_type": "", "tray_color": "", "state": 9}]}]
  1710. new_spool = [{"id": 1, "tray": [{"id": 1, "exists": True, "tray_type": "", "tray_color": "", "state": 9}]}]
  1711. clock = [1000.0]
  1712. with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
  1713. await TestAutoUnlinkOccupiedSlot._run(printer.id, taken_out, _make_printing_status(taken_out, state="IDLE"))
  1714. clock[0] += 20
  1715. await TestAutoUnlinkOccupiedSlot._run(printer.id, new_spool, _make_printing_status(new_spool, state="IDLE"))
  1716. db_session.expunge_all()
  1717. assert await db_session.get(SpoolAssignment, assignment_id) is not None
  1718. clock[0] += slot_unlink_grace.GRACE_SECONDS
  1719. await TestAutoUnlinkOccupiedSlot._run(printer.id, new_spool, _make_printing_status(new_spool, state="IDLE"))
  1720. db_session.expunge_all()
  1721. assert await db_session.get(SpoolAssignment, assignment_id) is None
  1722. @pytest.mark.asyncio
  1723. @pytest.mark.integration
  1724. async def test_a_new_assignment_to_the_slot_starts_clean(
  1725. self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
  1726. ):
  1727. """Assigning a spool to the slot after the swap -- even the same spool
  1728. again -- makes a new row, and the old clock does not carry over to it."""
  1729. from backend.app.models.spool_assignment import SpoolAssignment
  1730. from backend.app.services import slot_unlink_grace
  1731. printer = await printer_factory(name="X1C")
  1732. spool = await spool_factory(material="PLA", rgba="8A8F92FF")
  1733. old_id = await TestAutoUnlinkOccupiedSlot._assignment(db_session, printer, spool)
  1734. taken_out = [{"id": 1, "tray": [{"id": 1, "exists": False, "tray_type": "", "tray_color": "", "state": 9}]}]
  1735. back_in = [{"id": 1, "tray": [{"id": 1, "exists": True, "tray_type": "", "tray_color": "", "state": 9}]}]
  1736. clock = [1000.0]
  1737. with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
  1738. await TestAutoUnlinkOccupiedSlot._run(printer.id, taken_out, _make_printing_status(taken_out, state="IDLE"))
  1739. # The user puts the spool back and assigns it again through the
  1740. # endpoint, which replaces the row -- on SQLite quite possibly under
  1741. # the same id, so only dropping the hold keeps the old clock off it.
  1742. hold_key = ("inventory", 1, 1, spool.id)
  1743. assert slot_unlink_grace.is_held(printer.id, hold_key)
  1744. with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
  1745. mock_pm.get_client.return_value = None
  1746. mock_pm.get_status.return_value = _make_mock_status(ams_data=back_in)
  1747. response = await async_client.post(
  1748. "/api/v1/inventory/assignments",
  1749. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 1, "tray_id": 1},
  1750. )
  1751. assert response.status_code == 200
  1752. assert not slot_unlink_grace.is_held(printer.id, hold_key), "an assignment must drop the old hold"
  1753. new_id = response.json()["id"]
  1754. assert await db_session.get(SpoolAssignment, old_id) is None or new_id == old_id
  1755. clock[0] += slot_unlink_grace.GRACE_SECONDS
  1756. await TestAutoUnlinkOccupiedSlot._run(printer.id, back_in, _make_printing_status(back_in, state="IDLE"))
  1757. db_session.expunge_all()
  1758. assert await db_session.get(SpoolAssignment, new_id) is not None
  1759. class TestSpoolmanSlotUnlinkHeldForGrace:
  1760. """Spoolman mode's half of #3186: the slot row waits out the same grace."""
  1761. @pytest.mark.asyncio
  1762. @pytest.mark.integration
  1763. async def test_the_recheck_deletes_a_row_only_once_the_slot_stayed_empty(
  1764. self, async_client: AsyncClient, printer_factory, db_session: AsyncSession
  1765. ):
  1766. from backend.app.main import _expire_spoolman_empty_slots
  1767. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  1768. from backend.app.services import slot_unlink_grace
  1769. await TestSpoolmanSlotAssignmentDuringRunout()._enable_spoolman(db_session)
  1770. printer = await printer_factory(name="H2D")
  1771. row = SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=2, spoolman_spool_id=41)
  1772. db_session.add(row)
  1773. await db_session.commit()
  1774. row_id = row.id
  1775. blank = [{"id": 0, "tray": [{"id": 2, "exists": False, "tray_type": "", "tray_color": "", "state": 9}]}]
  1776. clock = [1000.0]
  1777. with (
  1778. patch.object(slot_unlink_grace, "_now", lambda: clock[0]),
  1779. patch("backend.app.main.ws_manager") as mock_ws,
  1780. ):
  1781. mock_ws.broadcast = AsyncMock()
  1782. await _expire_spoolman_empty_slots(printer.id, blank, printing_now=False)
  1783. db_session.expunge_all()
  1784. assert await db_session.get(SpoolmanSlotAssignment, row_id) is not None
  1785. clock[0] += slot_unlink_grace.GRACE_SECONDS
  1786. await _expire_spoolman_empty_slots(printer.id, blank, printing_now=False)
  1787. db_session.expunge_all()
  1788. assert await db_session.get(SpoolmanSlotAssignment, row_id) is None
  1789. mock_ws.broadcast.assert_awaited()
  1790. @pytest.mark.asyncio
  1791. @pytest.mark.integration
  1792. async def test_a_swap_to_an_unreadable_spool_still_expires_the_row(
  1793. self, async_client: AsyncClient, printer_factory, db_session: AsyncSession
  1794. ):
  1795. from backend.app.main import _expire_spoolman_empty_slots
  1796. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  1797. from backend.app.services import slot_unlink_grace
  1798. await TestSpoolmanSlotAssignmentDuringRunout()._enable_spoolman(db_session)
  1799. printer = await printer_factory(name="H2D")
  1800. row = SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=2, spoolman_spool_id=41)
  1801. db_session.add(row)
  1802. await db_session.commit()
  1803. row_id = row.id
  1804. taken_out = [{"id": 0, "tray": [{"id": 2, "exists": False, "tray_type": "", "tray_color": "", "state": 9}]}]
  1805. new_spool = [{"id": 0, "tray": [{"id": 2, "exists": True, "tray_type": "", "tray_color": "", "state": 9}]}]
  1806. clock = [1000.0]
  1807. with (
  1808. patch.object(slot_unlink_grace, "_now", lambda: clock[0]),
  1809. patch("backend.app.main.ws_manager") as mock_ws,
  1810. ):
  1811. mock_ws.broadcast = AsyncMock()
  1812. await _expire_spoolman_empty_slots(printer.id, taken_out, printing_now=False)
  1813. clock[0] += 20
  1814. await _expire_spoolman_empty_slots(printer.id, new_spool, printing_now=False)
  1815. db_session.expunge_all()
  1816. assert await db_session.get(SpoolmanSlotAssignment, row_id) is not None
  1817. clock[0] += slot_unlink_grace.GRACE_SECONDS
  1818. await _expire_spoolman_empty_slots(printer.id, new_spool, printing_now=False)
  1819. db_session.expunge_all()
  1820. assert await db_session.get(SpoolmanSlotAssignment, row_id) is None
  1821. @pytest.mark.asyncio
  1822. @pytest.mark.integration
  1823. async def test_an_occupied_unreadable_slot_with_no_hold_keeps_its_row(
  1824. self, async_client: AsyncClient, printer_factory, db_session: AsyncSession
  1825. ):
  1826. """#3100 still holds for the re-check: never reported empty, never unlinked."""
  1827. from backend.app.main import _expire_spoolman_empty_slots
  1828. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  1829. from backend.app.services import slot_unlink_grace
  1830. await TestSpoolmanSlotAssignmentDuringRunout()._enable_spoolman(db_session)
  1831. printer = await printer_factory(name="H2D")
  1832. row = SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=2, spoolman_spool_id=41)
  1833. db_session.add(row)
  1834. await db_session.commit()
  1835. row_id = row.id
  1836. unreadable = [{"id": 0, "tray": [{"id": 2, "exists": True, "tray_type": "", "tray_color": "", "state": 9}]}]
  1837. clock = [1000.0]
  1838. with (
  1839. patch.object(slot_unlink_grace, "_now", lambda: clock[0]),
  1840. patch("backend.app.main.ws_manager") as mock_ws,
  1841. ):
  1842. mock_ws.broadcast = AsyncMock()
  1843. for _ in range(3):
  1844. await _expire_spoolman_empty_slots(printer.id, unreadable, printing_now=False)
  1845. clock[0] += slot_unlink_grace.GRACE_SECONDS
  1846. db_session.expunge_all()
  1847. assert await db_session.get(SpoolmanSlotAssignment, row_id) is not None
  1848. @pytest.mark.asyncio
  1849. @pytest.mark.integration
  1850. async def test_the_recheck_keeps_a_row_whose_slot_reads_again(
  1851. self, async_client: AsyncClient, printer_factory, db_session: AsyncSession
  1852. ):
  1853. from backend.app.main import _expire_spoolman_empty_slots
  1854. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  1855. from backend.app.services import slot_unlink_grace
  1856. await TestSpoolmanSlotAssignmentDuringRunout()._enable_spoolman(db_session)
  1857. printer = await printer_factory(name="H2D")
  1858. row = SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=2, spoolman_spool_id=41)
  1859. db_session.add(row)
  1860. await db_session.commit()
  1861. row_id = row.id
  1862. blank = [{"id": 0, "tray": [{"id": 2, "exists": False, "tray_type": "", "tray_color": "", "state": 9}]}]
  1863. back = [{"id": 0, "tray": [{"id": 2, "exists": True, "tray_type": "PLA", "tray_color": "858585FF"}]}]
  1864. clock = [1000.0]
  1865. with (
  1866. patch.object(slot_unlink_grace, "_now", lambda: clock[0]),
  1867. patch("backend.app.main.ws_manager") as mock_ws,
  1868. ):
  1869. mock_ws.broadcast = AsyncMock()
  1870. await _expire_spoolman_empty_slots(printer.id, blank, printing_now=False)
  1871. clock[0] += 30
  1872. await _expire_spoolman_empty_slots(printer.id, back, printing_now=False)
  1873. clock[0] += slot_unlink_grace.GRACE_SECONDS
  1874. await _expire_spoolman_empty_slots(printer.id, blank, printing_now=False)
  1875. db_session.expunge_all()
  1876. assert await db_session.get(SpoolmanSlotAssignment, row_id) is not None
  1877. class TestSpoolmanSlotAssignmentDuringRunout:
  1878. """`spoolman_slot_assignments` is how a tag-less spool assigned through the
  1879. Bambuddy UI is resolved at completion (#1459). Deleting the row when a slot
  1880. empties mid-print loses the runout segment's usage — the same failure the
  1881. internal inventory's auto-unlink had, so it needs the same guard."""
  1882. async def _enable_spoolman(self, db_session):
  1883. from backend.app.models.settings import Settings
  1884. for key, value in (
  1885. ("spoolman_enabled", "true"),
  1886. ("spoolman_sync_mode", "auto"),
  1887. ("spoolman_url", "http://spoolman.test"),
  1888. ):
  1889. db_session.add(Settings(key=key, value=value))
  1890. await db_session.commit()
  1891. async def _run(self, printer_id, state, tray=None):
  1892. from unittest.mock import AsyncMock
  1893. from backend.app.main import on_ams_change
  1894. # A tray the firmware has cleared: parse_ams_tray returns None, which
  1895. # is what marks the slot empty for the cleanup pass.
  1896. ams_data = [{"id": 0, "tray": [tray or {"id": 2, "tray_type": "", "tray_color": "", "state": 26}]}]
  1897. spoolman_client = MagicMock()
  1898. spoolman_client.health_check = AsyncMock(return_value=True)
  1899. spoolman_client.get_spools = AsyncMock(return_value=[])
  1900. spoolman_client.sync_ams_tray = AsyncMock(return_value=None)
  1901. # None is what marks the slot empty for the cleanup pass.
  1902. spoolman_client.parse_ams_tray.return_value = None
  1903. with (
  1904. patch("backend.app.main.printer_manager") as mock_pm_main,
  1905. patch("backend.app.main.mqtt_relay") as mock_relay,
  1906. patch("backend.app.main.ws_manager") as mock_ws,
  1907. patch("backend.app.main.get_spoolman_client", new=AsyncMock(return_value=spoolman_client)),
  1908. ):
  1909. mock_pm_main.get_printer.return_value = MagicMock(name="H2D", serial_number="0948BB540200427")
  1910. mock_pm_main.get_status.return_value = state
  1911. mock_pm_main.get_model.return_value = "H2D"
  1912. mock_relay.on_ams_change = AsyncMock()
  1913. mock_ws.send_printer_status = AsyncMock()
  1914. mock_ws.broadcast = AsyncMock()
  1915. await on_ams_change(printer_id, ams_data)
  1916. @pytest.mark.asyncio
  1917. @pytest.mark.integration
  1918. async def test_the_slot_row_survives_a_runout(
  1919. self, async_client: AsyncClient, printer_factory, db_session: AsyncSession
  1920. ):
  1921. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  1922. await self._enable_spoolman(db_session)
  1923. printer = await printer_factory(name="H2D")
  1924. row = SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=2, spoolman_spool_id=41)
  1925. db_session.add(row)
  1926. await db_session.commit()
  1927. row_id = row.id
  1928. await self._run(printer.id, _make_printing_status(None))
  1929. db_session.expunge_all()
  1930. assert await db_session.get(SpoolmanSlotAssignment, row_id) is not None
  1931. @pytest.mark.asyncio
  1932. @pytest.mark.integration
  1933. async def test_an_occupied_slot_the_ams_cannot_read_keeps_its_row(
  1934. self, async_client: AsyncClient, printer_factory, db_session: AsyncSession
  1935. ):
  1936. """Spoolman mode's half of #3100.
  1937. parse_ams_tray calls a tray with no type empty, and a tag-less spool
  1938. has none until something configures it — so the row assigned through
  1939. the UI was deleted by the first idle push after the spool went in.
  1940. Firmware's presence bit is the same answer the built-in inventory
  1941. uses, so the two modes stay in step.
  1942. """
  1943. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  1944. await self._enable_spoolman(db_session)
  1945. printer = await printer_factory(name="H2D")
  1946. row = SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=2, spoolman_spool_id=41)
  1947. db_session.add(row)
  1948. await db_session.commit()
  1949. row_id = row.id
  1950. await self._run(
  1951. printer.id,
  1952. _make_printing_status(None, state="IDLE"),
  1953. tray={"id": 2, "exists": True, "tray_type": "", "tray_color": "", "state": 9},
  1954. )
  1955. db_session.expunge_all()
  1956. assert await db_session.get(SpoolmanSlotAssignment, row_id) is not None
  1957. @pytest.mark.asyncio
  1958. @pytest.mark.integration
  1959. async def test_the_slot_row_is_still_cleaned_up_when_idle(
  1960. self, async_client: AsyncClient, printer_factory, db_session: AsyncSession
  1961. ):
  1962. """Proves the guard is what saved the row above, not an unreachable
  1963. code path -- once the slot has stayed empty for the grace period."""
  1964. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  1965. from backend.app.services import slot_unlink_grace
  1966. await self._enable_spoolman(db_session)
  1967. printer = await printer_factory(name="H2D")
  1968. row = SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=2, spoolman_spool_id=41)
  1969. db_session.add(row)
  1970. await db_session.commit()
  1971. row_id = row.id
  1972. clock = [1000.0]
  1973. with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
  1974. await self._run(printer.id, _make_printing_status(None, state="IDLE"))
  1975. db_session.expunge_all()
  1976. assert await db_session.get(SpoolmanSlotAssignment, row_id) is not None
  1977. clock[0] += slot_unlink_grace.GRACE_SECONDS
  1978. await self._run(printer.id, _make_printing_status(None, state="IDLE"))
  1979. db_session.expunge_all()
  1980. assert await db_session.get(SpoolmanSlotAssignment, row_id) is None