test_ams_slot_material_2902.py 34 KB

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