test_inventory_assign.py 108 KB

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