test_ams_slot_material_2902.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894
  1. """What reaches an AMS slot when a spool's material is a product line (#2902).
  2. The reporter assigned an eSUN PLA+ spool and the slot came out unusable: any
  3. plate sliced with a PLA profile refused it. Four routes configure a slot and
  4. all four wrote the spool's material straight into ``tray_type``, where "PLA+"
  5. matches nothing -- not the slicer, and not Bambuddy's own dispatch matcher,
  6. which compares the printer's reported ``tray_type`` to the 3MF's declared type
  7. as plain equality.
  8. Each test below asserts the whole slot, not just the type: an unrecognised
  9. material also missed the generic-filament-id lookup, so the slot went out with
  10. an empty ``tray_info_idx`` -- the half-configured state #2604 documents the
  11. printer as reverting from -- and took the 200/240 catch-all temperatures
  12. instead of PLA's.
  13. """
  14. import json
  15. from unittest.mock import AsyncMock, MagicMock, patch
  16. import pytest
  17. from httpx import AsyncClient
  18. from sqlalchemy.ext.asyncio import AsyncSession
  19. from backend.app.models.spool import Spool
  20. def _mqtt_mock():
  21. client = MagicMock()
  22. client.ams_set_filament_setting.return_value = True
  23. client.extrusion_cali_sel.return_value = True
  24. return client
  25. def _status(ams_data=None):
  26. status = MagicMock()
  27. status.raw_data = {"ams": {"ams": ams_data if ams_data is not None else []}}
  28. status.nozzles = [MagicMock(nozzle_diameter="0.4")]
  29. status.ams_extruder_map = None
  30. status.kprofiles = []
  31. return status
  32. def _spoolman_spool(material, spool_id=11, slicer_filament=None):
  33. extra = {}
  34. if slicer_filament is not None:
  35. extra["bambu_slicer_filament"] = json.dumps(str(slicer_filament))
  36. return {
  37. "id": spool_id,
  38. "filament": {
  39. "id": 1,
  40. "name": "Cool White",
  41. "material": material,
  42. "color_hex": "E1E9E9",
  43. "weight": 1000,
  44. "vendor": {"id": 1, "name": "eSUN"},
  45. },
  46. "remaining_weight": 800.0,
  47. "used_weight": 200.0,
  48. "archived": False,
  49. "extra": extra,
  50. }
  51. def _spoolman_client(spool):
  52. client = MagicMock()
  53. client.has_tag_api = AsyncMock(return_value=False)
  54. client.add_native_tags = AsyncMock(return_value=0)
  55. client.unlink_all_native_tags = AsyncMock()
  56. client.base_url = "http://localhost:7912"
  57. client.health_check = AsyncMock(return_value=True)
  58. client.get_spool = AsyncMock(return_value=spool)
  59. client.get_spools = AsyncMock(return_value=[spool])
  60. client.merge_spool_extra = AsyncMock(return_value=spool)
  61. return client
  62. class TestInternalInventoryAssign:
  63. async def _assign(self, async_client, db_session, material, **spool_kwargs):
  64. from backend.app.models.printer import Printer
  65. printer = Printer(
  66. name="P1S",
  67. serial_number=f"MAT2902{material[:4]}",
  68. ip_address="192.168.1.77",
  69. access_code="12345678",
  70. )
  71. db_session.add(printer)
  72. spool = Spool(
  73. material=material,
  74. brand="eSUN",
  75. color_name="Cool White",
  76. rgba="E1E9E9FF",
  77. label_weight=1000,
  78. weight_used=0,
  79. **spool_kwargs,
  80. )
  81. db_session.add(spool)
  82. await db_session.commit()
  83. await db_session.refresh(printer)
  84. await db_session.refresh(spool)
  85. client = _mqtt_mock()
  86. with patch("backend.app.services.printer_manager.printer_manager") as pm:
  87. pm.get_client.return_value = client
  88. pm.get_status.return_value = _status()
  89. response = await async_client.post(
  90. "/api/v1/inventory/assignments",
  91. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
  92. )
  93. assert response.status_code == 200
  94. client.ams_set_filament_setting.assert_called_once()
  95. return client.ams_set_filament_setting.call_args.kwargs
  96. @pytest.mark.asyncio
  97. @pytest.mark.integration
  98. async def test_a_pla_plus_spool_configures_the_slot_as_pla(
  99. self, async_client: AsyncClient, db_session: AsyncSession
  100. ):
  101. sent = await self._assign(async_client, db_session, "PLA+")
  102. assert sent["tray_type"] == "PLA"
  103. # Not just the label: the id and its setting_id are what stop the
  104. # printer treating the slot as half configured, and the temperatures
  105. # are PLA's rather than the catch-all.
  106. assert sent["tray_info_idx"] == "GFL99"
  107. assert sent["setting_id"] == "GFSL99"
  108. assert (sent["nozzle_temp_min"], sent["nozzle_temp_max"]) == (190, 230)
  109. @pytest.mark.asyncio
  110. @pytest.mark.integration
  111. async def test_the_product_name_is_not_lost_it_moves_to_the_sub_brand(
  112. self, async_client: AsyncClient, db_session: AsyncSession
  113. ):
  114. """Which is where Bambu itself puts it -- their own catalogue has a
  115. preset named "eSUN PLA+" (GFL03) whose type is PLA."""
  116. sent = await self._assign(async_client, db_session, "PLA+")
  117. assert "PLA+" in sent["tray_sub_brands"]
  118. @pytest.mark.asyncio
  119. @pytest.mark.integration
  120. async def test_a_material_that_already_resolved_keeps_its_own_preset(
  121. self, async_client: AsyncClient, db_session: AsyncSession
  122. ):
  123. """ "PETG HF" has a generic preset of its own (GFG96, "Generic PETG HF").
  124. Reducing the material before the id lookup rather than after it would
  125. trade that away for plain PETG's GFG99 -- a quiet downgrade of slots
  126. that work today."""
  127. sent = await self._assign(async_client, db_session, "PETG HF")
  128. assert sent["tray_info_idx"] == "GFG96"
  129. assert sent["tray_type"] == "PETG"
  130. @pytest.mark.asyncio
  131. @pytest.mark.integration
  132. async def test_it_can_now_reuse_the_calibrated_preset_already_in_the_slot(
  133. self, async_client: AsyncClient, db_session: AsyncSession
  134. ):
  135. """A slot already holding a specific preset keeps it when the incoming
  136. spool is the same material -- that is how a printer's calibration
  137. context survives an assignment. The comparison is against the slot's
  138. reported type, so a PLA+ spool could never match a PLA slot and the
  139. reuse branch was dead for every spool this issue is about."""
  140. from backend.app.models.printer import Printer
  141. printer = Printer(
  142. name="Reuse P1S",
  143. serial_number="MAT2902RU",
  144. ip_address="192.168.1.81",
  145. access_code="12345678",
  146. )
  147. db_session.add(printer)
  148. spool = Spool(material="PLA+", brand="eSUN", rgba="E1E9E9FF", label_weight=1000, weight_used=0)
  149. db_session.add(spool)
  150. await db_session.commit()
  151. await db_session.refresh(printer)
  152. await db_session.refresh(spool)
  153. client = _mqtt_mock()
  154. live_slot = [{"id": 0, "tray": [{"id": 1, "tray_info_idx": "P4d64437", "tray_type": "PLA"}]}]
  155. with patch("backend.app.services.printer_manager.printer_manager") as pm:
  156. pm.get_client.return_value = client
  157. pm.get_status.return_value = _status(live_slot)
  158. response = await async_client.post(
  159. "/api/v1/inventory/assignments",
  160. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
  161. )
  162. assert response.status_code == 200
  163. sent = client.ams_set_filament_setting.call_args.kwargs
  164. assert sent["tray_info_idx"] == "P4d64437"
  165. @pytest.mark.asyncio
  166. @pytest.mark.integration
  167. async def test_but_it_does_not_reuse_a_product_name_a_previous_version_left_there(
  168. self, async_client: AsyncClient, db_session: AsyncSession
  169. ):
  170. """A spool whose slicer_filament was free text could send that text to
  171. the printer as the slot's filament id, and the printer reports it
  172. straight back -- so an upgraded install can be looking at a slot that
  173. says type PLA, id "PLA+". Reuse has always refused a bare material name
  174. in that field; refusing a product line too is what stops the bad id
  175. being carried forward on every assignment instead of replaced."""
  176. from backend.app.models.printer import Printer
  177. printer = Printer(
  178. name="Stale P1S",
  179. serial_number="MAT2902ST",
  180. ip_address="192.168.1.82",
  181. access_code="12345678",
  182. )
  183. db_session.add(printer)
  184. spool = Spool(material="PLA", brand="eSUN", rgba="E1E9E9FF", label_weight=1000, weight_used=0)
  185. db_session.add(spool)
  186. await db_session.commit()
  187. await db_session.refresh(printer)
  188. await db_session.refresh(spool)
  189. client = _mqtt_mock()
  190. stale_slot = [{"id": 0, "tray": [{"id": 1, "tray_info_idx": "PLA+", "tray_type": "PLA"}]}]
  191. with patch("backend.app.services.printer_manager.printer_manager") as pm:
  192. pm.get_client.return_value = client
  193. pm.get_status.return_value = _status(stale_slot)
  194. response = await async_client.post(
  195. "/api/v1/inventory/assignments",
  196. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
  197. )
  198. assert response.status_code == 200
  199. sent = client.ams_set_filament_setting.call_args.kwargs
  200. assert sent["tray_info_idx"] == "GFL99"
  201. assert sent["tray_type"] == "PLA"
  202. @pytest.mark.asyncio
  203. @pytest.mark.integration
  204. async def test_a_material_nothing_can_be_made_of_is_sent_unchanged(
  205. self, async_client: AsyncClient, db_session: AsyncSession
  206. ):
  207. """The catalogue ships a few names with no filament type in them at all.
  208. Guessing at those would be worse than leaving them: this route behaved
  209. exactly this way before #2902, and still does."""
  210. sent = await self._assign(async_client, db_session, "CPE HG100")
  211. assert sent["tray_type"] == "CPE HG100"
  212. assert sent["tray_info_idx"] == ""
  213. @pytest.mark.asyncio
  214. @pytest.mark.integration
  215. async def test_a_free_text_slicer_filament_naming_a_product_is_not_a_filament_id(
  216. self, async_client: AsyncClient, db_session: AsyncSession
  217. ):
  218. """slicer_filament is free text on older spools, so "PLA+" can be sitting
  219. in it. It is as unusable a tray_info_idx as the bare "PLA" the resolver
  220. already discarded, and letting it through would put a product name in
  221. the field the printer keys its calibration table by."""
  222. sent = await self._assign(async_client, db_session, "PLA+", slicer_filament="PLA+")
  223. assert sent["tray_info_idx"] == "GFL99"
  224. class TestSpoolmanInventoryAssign:
  225. @pytest.fixture
  226. async def settings(self, db_session):
  227. from backend.app.models.settings import Settings
  228. db_session.add(Settings(key="spoolman_enabled", value="true"))
  229. db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
  230. await db_session.commit()
  231. @pytest.fixture
  232. async def printer(self, db_session):
  233. from backend.app.models.printer import Printer
  234. p = Printer(
  235. name="Spoolman P1S",
  236. serial_number="MAT2902SM",
  237. ip_address="192.168.1.78",
  238. access_code="12345678",
  239. )
  240. db_session.add(p)
  241. await db_session.commit()
  242. await db_session.refresh(p)
  243. return p
  244. async def _assign(self, async_client, printer, material, slicer_filament=None):
  245. mqtt = _mqtt_mock()
  246. spool = _spoolman_spool(material, slicer_filament=slicer_filament)
  247. with (
  248. patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm,
  249. patch(
  250. "backend.app.api.routes.spoolman_inventory.get_spoolman_client",
  251. AsyncMock(return_value=_spoolman_client(spool)),
  252. ),
  253. ):
  254. pm.get_client.return_value = mqtt
  255. pm.get_status.return_value = _status()
  256. response = await async_client.post(
  257. "/api/v1/spoolman/inventory/slot-assignments",
  258. json={"spoolman_spool_id": 11, "printer_id": printer.id, "ams_id": 0, "tray_id": 2},
  259. )
  260. assert response.status_code == 200
  261. return mqtt.ams_set_filament_setting.call_args.kwargs
  262. @pytest.mark.asyncio
  263. @pytest.mark.integration
  264. async def test_spoolmans_free_text_material_is_reduced_the_same_way(
  265. self, async_client: AsyncClient, settings, printer
  266. ):
  267. """Spoolman's material field is free text too, so the same product names
  268. arrive by this route -- and it is the route the reporter used."""
  269. sent = await self._assign(async_client, printer, "PLA+")
  270. assert sent["tray_type"] == "PLA"
  271. assert sent["tray_info_idx"] == "GFL99"
  272. # Exactly, not just "contains PLA+": the filament's own name is in this
  273. # string too, so a substring check would pass even if the material had
  274. # been reduced before it was built.
  275. assert sent["tray_sub_brands"] == "eSUN PLA+ Cool White"
  276. @pytest.mark.asyncio
  277. @pytest.mark.integration
  278. async def test_it_keeps_this_routes_own_preset_for_a_material_that_had_one(
  279. self, async_client: AsyncClient, settings, printer
  280. ):
  281. sent = await self._assign(async_client, printer, "PETG HF")
  282. assert sent["tray_info_idx"] == "GFG96"
  283. assert sent["tray_type"] == "PETG"
  284. @pytest.mark.asyncio
  285. @pytest.mark.integration
  286. async def test_the_resolver_is_handed_the_spools_own_wording_not_the_type(
  287. self, async_client: AsyncClient, settings, printer, db_session
  288. ):
  289. """This route also passes the material down to the slicer-filament
  290. resolver. Handing that the reduced type instead would look harmless and
  291. quietly downgrade GFG96 to GFG99 whenever the spool points at a local
  292. preset with no filament_id of its own."""
  293. from backend.app.models.local_preset import LocalPreset
  294. lp = LocalPreset(name="Generic PETG HF", preset_type="filament", source="orcaslicer", setting="{}")
  295. db_session.add(lp)
  296. await db_session.commit()
  297. await db_session.refresh(lp)
  298. sent = await self._assign(async_client, printer, "PETG HF", slicer_filament=lp.id)
  299. assert sent["tray_info_idx"] == "GFG96"
  300. class TestConfigureSlotModal:
  301. @pytest.mark.asyncio
  302. @pytest.mark.integration
  303. async def test_a_product_line_typed_into_the_modal_is_reduced_too(self, async_client: AsyncClient, printer_factory):
  304. """The Configure Slot modal derives tray_type from a preset name or the
  305. spool's material, so it can hand the backend a product line as readily
  306. as the assignment routes can."""
  307. printer = await printer_factory(model="P1S")
  308. client = _mqtt_mock()
  309. with patch("backend.app.api.routes.printers.printer_manager") as pm:
  310. pm.get_client.return_value = client
  311. pm.get_status.return_value = _status()
  312. response = await async_client.post(
  313. f"/api/v1/printers/{printer.id}/slots/0/1/configure",
  314. params={
  315. "tray_info_idx": "",
  316. "tray_type": "PLA+",
  317. "tray_sub_brands": "eSUN PLA+",
  318. "tray_color": "E1E9E9FF",
  319. "nozzle_temp_min": 190,
  320. "nozzle_temp_max": 230,
  321. },
  322. )
  323. assert response.status_code == 200
  324. sent = client.ams_set_filament_setting.call_args.kwargs
  325. assert sent["tray_type"] == "PLA"
  326. # The empty tray_info_idx the modal sent for a generic material is what
  327. # the reduced type now rescues.
  328. assert sent["tray_info_idx"] == "GFL99"
  329. assert sent["tray_sub_brands"] == "eSUN PLA+"
  330. class TestSpoolmanLink:
  331. """The fourth route that configures a slot: linking a Spoolman spool to a
  332. slot's tag auto-configures it too, from the same free-text material."""
  333. @pytest.fixture
  334. async def settings(self, db_session):
  335. from backend.app.models.settings import Settings
  336. db_session.add(Settings(key="spoolman_enabled", value="true"))
  337. db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
  338. await db_session.commit()
  339. @pytest.fixture
  340. async def printer(self, db_session):
  341. from backend.app.models.printer import Printer
  342. p = Printer(
  343. name="Link P1S",
  344. serial_number="MAT2902LK",
  345. ip_address="192.168.1.79",
  346. access_code="12345678",
  347. )
  348. db_session.add(p)
  349. await db_session.commit()
  350. await db_session.refresh(p)
  351. return p
  352. async def _link(self, async_client, printer, material):
  353. client = _spoolman_client(_spoolman_spool(material, spool_id=12))
  354. mqtt = _mqtt_mock()
  355. with (
  356. patch("backend.app.api.routes.spoolman.get_spoolman_client", AsyncMock(return_value=client)),
  357. patch("backend.app.api.routes.spoolman.init_spoolman_client", AsyncMock(return_value=client)),
  358. patch("backend.app.api.routes.spoolman.printer_manager") as pm,
  359. ):
  360. pm.get_client.return_value = mqtt
  361. pm.get_status.return_value = _status()
  362. response = await async_client.post(
  363. "/api/v1/spoolman/spools/12/link",
  364. json={
  365. "tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
  366. "printer_id": printer.id,
  367. "ams_id": 0,
  368. "tray_id": 3,
  369. },
  370. )
  371. assert response.status_code == 200
  372. return mqtt.ams_set_filament_setting.call_args.kwargs
  373. @pytest.mark.asyncio
  374. @pytest.mark.integration
  375. async def test_linking_a_pla_plus_spool_configures_the_slot_as_pla(
  376. self, async_client: AsyncClient, settings, printer
  377. ):
  378. sent = await self._link(async_client, printer, "PLA+")
  379. assert sent["tray_type"] == "PLA"
  380. assert sent["tray_info_idx"] == "GFL99"
  381. assert sent["tray_sub_brands"] == "eSUN PLA+ Cool White"
  382. assert (sent["nozzle_temp_min"], sent["nozzle_temp_max"]) == (190, 230)
  383. @pytest.mark.asyncio
  384. @pytest.mark.integration
  385. async def test_it_keeps_this_routes_own_preset_for_a_material_that_had_one(
  386. self, async_client: AsyncClient, settings, printer
  387. ):
  388. sent = await self._link(async_client, printer, "PETG HF")
  389. assert sent["tray_info_idx"] == "GFG96"
  390. assert sent["tray_type"] == "PETG"
  391. class TestALocalPresetThatNamesNoFilamentId:
  392. """The one path where the material reaches the slicer-filament resolver
  393. rather than the route's own fallback: a spool pointing at an imported local
  394. preset whose setting JSON carries no filament_id. The resolver then has only
  395. the material to go on, so it has to read it the same way -- and be handed
  396. the spool's own wording, not the reduced type."""
  397. @pytest.fixture
  398. async def preset(self, db_session):
  399. from backend.app.models.local_preset import LocalPreset
  400. lp = LocalPreset(
  401. name="eSUN PLA+ @BBL P1S",
  402. preset_type="filament",
  403. source="orcaslicer",
  404. filament_type=None,
  405. setting="{}",
  406. )
  407. db_session.add(lp)
  408. await db_session.commit()
  409. await db_session.refresh(lp)
  410. return lp
  411. @pytest.fixture
  412. async def printer(self, db_session):
  413. from backend.app.models.printer import Printer
  414. p = Printer(
  415. name="LP P1S",
  416. serial_number="MAT2902LP",
  417. ip_address="192.168.1.80",
  418. access_code="12345678",
  419. )
  420. db_session.add(p)
  421. await db_session.commit()
  422. await db_session.refresh(p)
  423. return p
  424. async def _assign(self, async_client, db_session, printer, preset, material):
  425. spool = Spool(
  426. material=material,
  427. brand="eSUN",
  428. rgba="E1E9E9FF",
  429. label_weight=1000,
  430. weight_used=0,
  431. slicer_filament=str(preset.id),
  432. )
  433. db_session.add(spool)
  434. await db_session.commit()
  435. await db_session.refresh(spool)
  436. client = _mqtt_mock()
  437. with patch("backend.app.services.printer_manager.printer_manager") as pm:
  438. pm.get_client.return_value = client
  439. pm.get_status.return_value = _status()
  440. response = await async_client.post(
  441. "/api/v1/inventory/assignments",
  442. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  443. )
  444. assert response.status_code == 200
  445. return client.ams_set_filament_setting.call_args.kwargs
  446. @pytest.mark.asyncio
  447. @pytest.mark.integration
  448. async def test_the_resolver_places_a_product_line_too(
  449. self, async_client: AsyncClient, db_session: AsyncSession, printer, preset
  450. ):
  451. sent = await self._assign(async_client, db_session, printer, preset, "PLA+")
  452. assert sent["tray_info_idx"] == "GFL99"
  453. @pytest.mark.asyncio
  454. @pytest.mark.integration
  455. async def test_and_still_prefers_a_material_that_has_its_own_preset(
  456. self, async_client: AsyncClient, db_session: AsyncSession, printer, preset
  457. ):
  458. sent = await self._assign(async_client, db_session, printer, preset, "PETG HF")
  459. assert sent["tray_info_idx"] == "GFG96"
  460. class TestTheAssignmentSurvivesTheSlotItJustConfigured:
  461. """The other side of the same coin, and the one that bites hardest.
  462. on_ams_change auto-unlinks an assignment whose slot no longer looks like it
  463. did when the spool was assigned. The fingerprint is snapshotted *before* the
  464. MQTT config goes out, so the very next AMS push after an assignment is a
  465. mismatch by construction -- and what saves the assignment is a second check:
  466. does the tray match the assigned spool now? That check read the spool's raw
  467. material, which the slot no longer carries, so every spool this issue is
  468. about would have been silently unlinked from the slot it had just been
  469. assigned to. Correct in isolation, ruinous together.
  470. """
  471. async def _push(
  472. self,
  473. db_session,
  474. printer_factory,
  475. spool_material,
  476. reported_type,
  477. fingerprint_type="PETG",
  478. **spool_kwargs,
  479. ):
  480. from unittest.mock import AsyncMock
  481. from backend.app.main import on_ams_change
  482. from backend.app.models.spool_assignment import SpoolAssignment
  483. printer = await printer_factory(name="H2D")
  484. spool = Spool(
  485. material=spool_material,
  486. brand="eSUN",
  487. rgba="E1E9E9FF",
  488. label_weight=1000,
  489. weight_used=0,
  490. **spool_kwargs,
  491. )
  492. db_session.add(spool)
  493. await db_session.commit()
  494. await db_session.refresh(spool)
  495. assignment = SpoolAssignment(
  496. spool_id=spool.id,
  497. printer_id=printer.id,
  498. ams_id=0,
  499. tray_id=2,
  500. fingerprint_color="E1E9E9FF",
  501. fingerprint_type=fingerprint_type,
  502. )
  503. db_session.add(assignment)
  504. await db_session.commit()
  505. assignment_id = assignment.id
  506. ams_data = [{"id": 0, "tray": [{"id": 2, "tray_type": reported_type, "tray_color": "E1E9E9FF", "state": 11}]}]
  507. status = _status(ams_data)
  508. status.state = "IDLE"
  509. with (
  510. patch("backend.app.main.printer_manager") as pm,
  511. patch("backend.app.main.mqtt_relay") as relay,
  512. patch("backend.app.main.ws_manager") as ws,
  513. ):
  514. pm.get_printer.return_value = MagicMock(name="H2D", serial_number="0948BB540200427")
  515. pm.get_status.return_value = status
  516. pm.get_model.return_value = "H2D"
  517. relay.on_ams_change = AsyncMock()
  518. ws.send_printer_status = AsyncMock()
  519. ws.broadcast = AsyncMock()
  520. await on_ams_change(printer.id, ams_data)
  521. # on_ams_change commits through its own session.
  522. db_session.expunge_all()
  523. return await db_session.get(SpoolAssignment, assignment_id)
  524. @pytest.mark.asyncio
  525. @pytest.mark.integration
  526. async def test_a_pla_plus_spool_is_not_unlinked_from_the_slot_now_reporting_pla(
  527. self, async_client: AsyncClient, db_session: AsyncSession, printer_factory
  528. ):
  529. surviving = await self._push(db_session, printer_factory, "PLA+", reported_type="PLA")
  530. assert surviving is not None, "the slot reports what we wrote to it -- that is a match, not a swap"
  531. @pytest.mark.asyncio
  532. @pytest.mark.integration
  533. async def test_nor_is_one_in_a_slot_an_older_version_configured(
  534. self, async_client: AsyncClient, db_session: AsyncSession, printer_factory
  535. ):
  536. """An install upgrading into this fix has slots still reporting "PLA+"
  537. until something reconfigures them. Reducing only the spool's side would
  538. break those the moment they were left alone."""
  539. surviving = await self._push(db_session, printer_factory, "PLA+", reported_type="PLA+")
  540. assert surviving is not None
  541. @pytest.mark.asyncio
  542. @pytest.mark.integration
  543. async def test_nor_is_one_whose_slot_took_its_presets_type_rather_than_its_material(
  544. self, async_client: AsyncClient, db_session: AsyncSession, printer_factory
  545. ):
  546. """The preset outranks the material column when the spool has one, so
  547. the slot can legitimately carry a type the material never named. The
  548. check has to recognise that as its own handiwork or it unlinks the
  549. assignment on the very next AMS push."""
  550. surviving = await self._push(
  551. db_session,
  552. printer_factory,
  553. "PLA",
  554. reported_type="PLA-AERO",
  555. slicer_filament_name="Bambu PLA Aero @BBL H2D",
  556. )
  557. assert surviving is not None
  558. @pytest.mark.asyncio
  559. @pytest.mark.integration
  560. async def test_even_when_the_preset_name_was_never_stored(
  561. self, async_client: AsyncClient, db_session: AsyncSession, printer_factory
  562. ):
  563. """slicer_filament_name is optional. An imported local preset carries
  564. its type outright, which is the value the assign path actually used."""
  565. from backend.app.models.local_preset import LocalPreset
  566. lp = LocalPreset(
  567. name="Bambu PLA Aero @BBL H2D",
  568. preset_type="filament",
  569. source="orcaslicer",
  570. filament_type="PLA-AERO",
  571. setting="{}",
  572. )
  573. db_session.add(lp)
  574. await db_session.commit()
  575. await db_session.refresh(lp)
  576. surviving = await self._push(
  577. db_session,
  578. printer_factory,
  579. "PLA",
  580. reported_type="PLA-AERO",
  581. slicer_filament=str(lp.id),
  582. )
  583. assert surviving is not None
  584. @pytest.mark.asyncio
  585. @pytest.mark.integration
  586. async def test_a_genuinely_different_filament_still_unlinks(
  587. self, async_client: AsyncClient, db_session: AsyncSession, printer_factory
  588. ):
  589. """The check still has to do its job: someone swapping PLA for ABS in
  590. the slot must lose the assignment, or usage gets charged to the wrong
  591. spool."""
  592. surviving = await self._push(db_session, printer_factory, "PLA+", reported_type="ABS")
  593. assert surviving is None
  594. @pytest.mark.asyncio
  595. @pytest.mark.integration
  596. async def test_and_a_preset_name_does_not_excuse_an_unrelated_slot(
  597. self, async_client: AsyncClient, db_session: AsyncSession, printer_factory
  598. ):
  599. """Widening the check to the preset only accepts the types the assign
  600. path could actually have written. Anything else is still a swap."""
  601. surviving = await self._push(
  602. db_session,
  603. printer_factory,
  604. "PLA",
  605. reported_type="ABS",
  606. slicer_filament_name="Bambu PLA Aero @BBL H2D",
  607. )
  608. assert surviving is None
  609. class TestAFilledOrFoamedVariantIsATypeOfItsOwn:
  610. """The first cut of this fix reduced PLA-AERO, PLA-GF, ASA-GF and PPS-GF
  611. onto their base material, because the reduction table was assembled from
  612. the cloud filament names and the frontend preset parser and never checked
  613. against ``filament_fields.json`` -- the list Bambuddy itself offers when a
  614. preset is created. @doncaruana caught PLA Aero on the issue.
  615. That is worse than the bug it replaced. "PLA-AERO" matched nothing before,
  616. which was useless but honest; "PLA" matches every plain PLA plate in the
  617. queue, so the dispatcher would have sent one to foaming filament.
  618. """
  619. @pytest.fixture
  620. async def printer(self, db_session):
  621. from backend.app.models.printer import Printer
  622. p = Printer(
  623. name="Aero P1S",
  624. serial_number="MAT2902AERO",
  625. ip_address="192.168.1.81",
  626. access_code="12345678",
  627. )
  628. db_session.add(p)
  629. await db_session.commit()
  630. await db_session.refresh(p)
  631. return p
  632. async def _assign(self, async_client, db_session, printer, material, tray_id, **spool_kwargs):
  633. spool = Spool(
  634. material=material,
  635. brand="Bambu Lab",
  636. rgba="E1E9E9FF",
  637. label_weight=1000,
  638. weight_used=0,
  639. **spool_kwargs,
  640. )
  641. db_session.add(spool)
  642. await db_session.commit()
  643. await db_session.refresh(spool)
  644. client = _mqtt_mock()
  645. with patch("backend.app.services.printer_manager.printer_manager") as pm:
  646. pm.get_client.return_value = client
  647. pm.get_status.return_value = _status()
  648. response = await async_client.post(
  649. "/api/v1/inventory/assignments",
  650. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": tray_id},
  651. )
  652. assert response.status_code == 200
  653. return client.ams_set_filament_setting.call_args.kwargs
  654. @pytest.mark.asyncio
  655. @pytest.mark.integration
  656. @pytest.mark.parametrize(
  657. ("material", "tray_id"),
  658. [("PLA-AERO", 0), ("PLA-GF", 1), ("ASA-GF", 2), ("PPS-GF", 3)],
  659. )
  660. async def test_it_reaches_the_slot_intact(
  661. self, async_client: AsyncClient, db_session: AsyncSession, printer, material, tray_id
  662. ):
  663. sent = await self._assign(async_client, db_session, printer, material, tray_id)
  664. assert sent["tray_type"] == material
  665. @pytest.mark.asyncio
  666. @pytest.mark.integration
  667. async def test_written_with_a_space_it_still_reaches_the_slot_intact(
  668. self, async_client: AsyncClient, db_session: AsyncSession, printer
  669. ):
  670. """The table hyphenates because the slicers do; a spool says "PLA Aero"
  671. and so does every Bambu preset name."""
  672. sent = await self._assign(async_client, db_session, printer, "PLA Aero", 0)
  673. assert sent["tray_type"] == "PLA-AERO"
  674. class TestThePresetOutranksTheMaterialColumn:
  675. """#2902 again, from @doncaruana: a preset has to be picked from a list the
  676. slicer defines, so it already knows its own type and nothing has to be read
  677. out of a product name. When a spool points at one, that answer wins.
  678. It cannot be the only answer. ``material`` is required on a spool and
  679. ``slicer_filament`` is not -- the spool this issue was reported for had no
  680. preset at all -- so the reduction stays as the fallback.
  681. """
  682. @pytest.fixture
  683. async def printer(self, db_session):
  684. from backend.app.models.printer import Printer
  685. p = Printer(
  686. name="Preset P1S",
  687. serial_number="MAT2902PRE",
  688. ip_address="192.168.1.82",
  689. access_code="12345678",
  690. )
  691. db_session.add(p)
  692. await db_session.commit()
  693. await db_session.refresh(p)
  694. return p
  695. async def _preset(self, db_session, name, filament_type):
  696. from backend.app.models.local_preset import LocalPreset
  697. lp = LocalPreset(
  698. name=name,
  699. preset_type="filament",
  700. source="orcaslicer",
  701. filament_type=filament_type,
  702. setting="{}",
  703. )
  704. db_session.add(lp)
  705. await db_session.commit()
  706. await db_session.refresh(lp)
  707. return lp
  708. async def _assign(self, async_client, db_session, printer, material, preset, tray_id):
  709. spool = Spool(
  710. material=material,
  711. brand="Bambu Lab",
  712. rgba="E1E9E9FF",
  713. label_weight=1000,
  714. weight_used=0,
  715. slicer_filament=str(preset.id) if preset else None,
  716. )
  717. db_session.add(spool)
  718. await db_session.commit()
  719. await db_session.refresh(spool)
  720. client = _mqtt_mock()
  721. with patch("backend.app.services.printer_manager.printer_manager") as pm:
  722. pm.get_client.return_value = client
  723. pm.get_status.return_value = _status()
  724. response = await async_client.post(
  725. "/api/v1/inventory/assignments",
  726. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": tray_id},
  727. )
  728. assert response.status_code == 200
  729. return client.ams_set_filament_setting.call_args.kwargs
  730. @pytest.mark.asyncio
  731. @pytest.mark.integration
  732. async def test_the_slot_gets_the_presets_type_not_one_read_from_the_material(
  733. self, async_client: AsyncClient, db_session: AsyncSession, printer
  734. ):
  735. """The material column says "PLA", which the reduction would happily
  736. accept. The preset says the spool is foaming PLA, and it is right."""
  737. preset = await self._preset(db_session, "Bambu PLA Aero @BBL P1S", "PLA-AERO")
  738. sent = await self._assign(async_client, db_session, printer, "PLA", preset, 0)
  739. assert sent["tray_type"] == "PLA-AERO"
  740. @pytest.mark.asyncio
  741. @pytest.mark.integration
  742. async def test_a_preset_that_names_no_type_leaves_the_reduction_in_charge(
  743. self, async_client: AsyncClient, db_session: AsyncSession, printer
  744. ):
  745. preset = await self._preset(db_session, "eSUN PLA+ @BBL P1S", None)
  746. sent = await self._assign(async_client, db_session, printer, "PLA+", preset, 1)
  747. assert sent["tray_type"] == "PLA"
  748. @pytest.mark.asyncio
  749. @pytest.mark.integration
  750. async def test_and_a_spool_with_no_preset_at_all_still_gets_one(
  751. self, async_client: AsyncClient, db_session: AsyncSession, printer
  752. ):
  753. sent = await self._assign(async_client, db_session, printer, "PLA+", None, 2)
  754. assert sent["tray_type"] == "PLA"
  755. @pytest.mark.asyncio
  756. @pytest.mark.integration
  757. async def test_a_hand_edited_preset_naming_a_product_line_is_still_reduced(
  758. self, async_client: AsyncClient, db_session: AsyncSession, printer
  759. ):
  760. """Preferring the preset does not mean trusting it blindly. A profile
  761. whose filament_type is a product line puts that product line in the
  762. slot, which is the exact failure this issue is about."""
  763. preset = await self._preset(db_session, "My PLA+ @BBL P1S", "PLA+")
  764. sent = await self._assign(async_client, db_session, printer, "PLA", preset, 3)
  765. assert sent["tray_type"] == "PLA"