test_rfid_releases_other_slots_3427.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. """A tag read in a slot takes the spool out of its other slots (#3427).
  2. A spool assigned by hand to printer A's empty slot, then loaded into printer B,
  3. stayed assigned to both once B read its tag: A's slot was configured from, and
  4. charged to, a spool sitting in B. A manual move already took a spool out of its
  5. other slots (#1574); the RFID auto-assign and the Spoolman AMS sync did not.
  6. """
  7. from types import SimpleNamespace
  8. from unittest.mock import AsyncMock, MagicMock, patch
  9. import pytest
  10. from sqlalchemy import select
  11. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  12. from backend.app.models.spool import Spool
  13. from backend.app.models.spool_assignment import SpoolAssignment
  14. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  15. from backend.app.services import spool_relocation
  16. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  17. UUID = "2E5476BB23AA431296203D76D312DA6F"
  18. TAG = "0D19333900000100"
  19. def _tray(tray_id: int, *, tagged: bool = True) -> dict:
  20. return {
  21. "id": tray_id,
  22. "tray_type": "PLA",
  23. "tray_color": "AFB1AEFF",
  24. "tray_info_idx": "GFA10",
  25. "tray_uuid": UUID if tagged else "0" * 32,
  26. "tag_uid": TAG if tagged else "0" * 16,
  27. "remain": 100,
  28. }
  29. def _status(by_printer: dict[int, list], *, wrapped: bool = False):
  30. """``get_status`` answering each printer with its own AMS report: the list
  31. the MQTT client stores, or with ``wrapped`` the push's ``{"ams": [...]}``."""
  32. return lambda pid: SimpleNamespace(
  33. raw_data={"ams": {"ams": by_printer.get(pid, [])} if wrapped else by_printer.get(pid, [])},
  34. state="IDLE",
  35. connected=True,
  36. )
  37. async def _rows(db_session, model=SpoolAssignment) -> set[tuple[int, int, int]]:
  38. db_session.expire_all()
  39. rows = (await db_session.execute(select(model))).scalars().all()
  40. return {(r.printer_id, r.ams_id, r.tray_id) for r in rows}
  41. async def test_a_tag_read_on_printer_b_releases_printer_as_slot(test_engine, db_session, printer_factory):
  42. """The reporter's case, through the real AMS-update handler."""
  43. from backend.app.main import on_ams_change
  44. a_id = (await printer_factory()).id
  45. b_id = (await printer_factory()).id
  46. spool = Spool(material="PLA", label_weight=1000, weight_used=0, rgba="AFB1AEFF", tray_uuid=UUID, tag_uid=TAG)
  47. db_session.add(spool)
  48. await db_session.flush()
  49. spool_id = spool.id
  50. # Assigned by hand to A's empty slot, waiting for a spool.
  51. db_session.add(SpoolAssignment(spool_id=spool_id, printer_id=a_id, ams_id=0, tray_id=1))
  52. await db_session.commit()
  53. b_ams = [{"id": 0, "tray": [_tray(0)]}]
  54. real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  55. status = _status({b_id: b_ams}, wrapped=True)
  56. with (
  57. patch("backend.app.main.async_session", real_db),
  58. patch("backend.app.core.database.async_session", real_db),
  59. patch("backend.app.main.printer_manager") as pm,
  60. patch("backend.app.services.printer_manager.printer_manager.get_status", status),
  61. patch("backend.app.main.mqtt_relay") as relay,
  62. patch("backend.app.main.ws_manager") as ws,
  63. patch("backend.app.services.spool_relocation.ws_manager") as relocation_ws,
  64. ):
  65. pm.get_printer.return_value = MagicMock(name="X1C", serial_number="B")
  66. pm.get_status.side_effect = status
  67. pm.get_client.return_value = MagicMock()
  68. pm.get_model.return_value = "X1C"
  69. relay.on_ams_change = AsyncMock()
  70. ws.send_printer_status = AsyncMock()
  71. ws.broadcast = AsyncMock()
  72. relocation_ws.broadcast = AsyncMock()
  73. await on_ams_change(b_id, b_ams)
  74. assert await _rows(db_session) == {(b_id, 0, 0)}
  75. relocation_ws.broadcast.assert_awaited_once_with(
  76. {"type": "spool_assignment_changed", "printer_id": a_id, "ams_id": 0, "tray_id": 1}
  77. )
  78. async def test_a_spool_matched_by_material_is_not_taken_from_its_slot(test_engine, db_session, printer_factory):
  79. """An unknown tag linked to an untagged spool of the same filament proves
  80. nothing about where that spool is: two identical rolls, one in each printer."""
  81. from backend.app.main import on_ams_change
  82. a_id = (await printer_factory()).id
  83. b_id = (await printer_factory()).id
  84. spool = Spool(material="PLA", brand="Bambu Lab", label_weight=1000, weight_used=0, rgba="AFB1AEFF")
  85. db_session.add(spool)
  86. await db_session.flush()
  87. spool_id = spool.id
  88. db_session.add(SpoolAssignment(spool_id=spool_id, printer_id=a_id, ams_id=0, tray_id=1))
  89. await db_session.commit()
  90. b_ams = [{"id": 0, "tray": [_tray(0)]}]
  91. real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  92. status = _status({b_id: b_ams})
  93. with (
  94. patch("backend.app.main.async_session", real_db),
  95. patch("backend.app.core.database.async_session", real_db),
  96. patch("backend.app.main.printer_manager") as pm,
  97. patch("backend.app.services.printer_manager.printer_manager.get_status", status),
  98. patch("backend.app.main.mqtt_relay") as relay,
  99. patch("backend.app.main.ws_manager") as ws,
  100. patch("backend.app.services.spool_relocation.ws_manager") as relocation_ws,
  101. ):
  102. pm.get_printer.return_value = MagicMock(name="X1C", serial_number="B")
  103. pm.get_status.side_effect = status
  104. pm.get_client.return_value = MagicMock()
  105. pm.get_model.return_value = "X1C"
  106. relay.on_ams_change = AsyncMock()
  107. ws.send_printer_status = AsyncMock()
  108. ws.broadcast = AsyncMock()
  109. relocation_ws.broadcast = AsyncMock()
  110. await on_ams_change(b_id, b_ams)
  111. db_session.expire_all()
  112. linked = (await db_session.execute(select(Spool).where(Spool.id == spool_id))).scalar_one()
  113. assert linked.tray_uuid == UUID # the match did happen...
  114. assert (a_id, 0, 1) in await _rows(db_session) # ...and A keeps its spool
  115. class TestReleaseOtherSlots:
  116. async def _setup(self, db_session, printer_factory, slots):
  117. printers = [(await printer_factory()).id for _ in range(2)]
  118. spool = Spool(material="PLA", label_weight=1000, weight_used=0)
  119. db_session.add(spool)
  120. await db_session.flush()
  121. spool_id = spool.id
  122. for printer_idx, ams_id, tray_id in slots:
  123. db_session.add(
  124. SpoolAssignment(spool_id=spool_id, printer_id=printers[printer_idx], ams_id=ams_id, tray_id=tray_id)
  125. )
  126. await db_session.commit()
  127. return printers, spool_id
  128. async def _release(self, test_engine, reports, spool_id, keep, inventory=spool_relocation.INTERNAL):
  129. real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  130. with (
  131. patch("backend.app.core.database.async_session", real_db),
  132. patch("backend.app.services.printer_manager.printer_manager.get_status", _status(reports)),
  133. patch("backend.app.services.spool_relocation.ws_manager") as ws,
  134. ):
  135. ws.broadcast = AsyncMock()
  136. return await spool_relocation.release_other_slots(inventory, spool_id, keep)
  137. async def test_releases_every_other_slot(self, test_engine, db_session, printer_factory):
  138. (a, b), spool_id = await self._setup(db_session, printer_factory, [(0, 0, 1), (0, 1, 2), (1, 0, 0)])
  139. vacated = await self._release(test_engine, {b: [{"id": 0, "tray": [_tray(0)]}]}, spool_id, (b, 0, 0))
  140. assert sorted(vacated) == [(a, 0, 1), (a, 1, 2)]
  141. assert await _rows(db_session) == {(b, 0, 0)}
  142. async def test_a_slot_reporting_the_same_tag_keeps_its_assignment(self, test_engine, db_session, printer_factory):
  143. """One tag in two slots is a misread: neither takes the other's row."""
  144. (a, b), spool_id = await self._setup(db_session, printer_factory, [(0, 0, 1), (1, 0, 0)])
  145. reports = {a: [{"id": 0, "tray": [_tray(0), _tray(1)]}], b: [{"id": 0, "tray": [_tray(0)]}]}
  146. assert await self._release(test_engine, reports, spool_id, (b, 0, 0)) == []
  147. assert await _rows(db_session) == {(a, 0, 1), (b, 0, 0)}
  148. async def test_nothing_without_a_tag_in_the_kept_slot(self, test_engine, db_session, printer_factory):
  149. (a, b), spool_id = await self._setup(db_session, printer_factory, [(0, 0, 1), (1, 0, 0)])
  150. reports = {b: [{"id": 0, "tray": [_tray(0, tagged=False)]}]}
  151. assert await self._release(test_engine, reports, spool_id, (b, 0, 0)) == []
  152. assert await _rows(db_session) == {(a, 0, 1), (b, 0, 0)}
  153. async def test_nothing_once_the_kept_slot_holds_another_spool(self, test_engine, db_session, printer_factory):
  154. """Reassigned since the read: the old read decides nothing."""
  155. (a, b), spool_id = await self._setup(db_session, printer_factory, [(0, 0, 1)])
  156. other = Spool(material="PLA", label_weight=1000, weight_used=0)
  157. db_session.add(other)
  158. await db_session.flush()
  159. db_session.add(SpoolAssignment(spool_id=other.id, printer_id=b, ams_id=0, tray_id=0))
  160. await db_session.commit()
  161. reports = {b: [{"id": 0, "tray": [_tray(0)]}]}
  162. assert await self._release(test_engine, reports, spool_id, (b, 0, 0)) == []
  163. assert await _rows(db_session) == {(a, 0, 1), (b, 0, 0)}
  164. async def test_external_slot_is_read_from_vt_tray(self, test_engine, db_session, printer_factory):
  165. (a, b), spool_id = await self._setup(db_session, printer_factory, [(0, 0, 1), (1, 255, 0)])
  166. real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  167. status = lambda pid: SimpleNamespace(raw_data={"vt_tray": [{**_tray(0), "id": "254"}]} if pid == b else {}) # noqa: E731
  168. with (
  169. patch("backend.app.core.database.async_session", real_db),
  170. patch("backend.app.services.printer_manager.printer_manager.get_status", status),
  171. patch("backend.app.services.spool_relocation.ws_manager") as ws,
  172. ):
  173. ws.broadcast = AsyncMock()
  174. vacated = await spool_relocation.release_other_slots(spool_relocation.INTERNAL, spool_id, (b, 255, 0))
  175. assert vacated == [(a, 0, 1)]
  176. async def test_takes_the_move_lock_then_printer_locks_in_id_order(self, test_engine, db_session, printer_factory):
  177. """A move's order (#1574): any other order can deadlock against one."""
  178. (a, b), spool_id = await self._setup(db_session, printer_factory, [(0, 0, 1), (1, 0, 0)])
  179. taken = []
  180. real_move, real_printer = spool_relocation.get_spool_move_lock, spool_relocation.get_ams_assignment_lock
  181. def move_lock(inventory, spool_id):
  182. taken.append(("spool", spool_id))
  183. return real_move(inventory, spool_id)
  184. def printer_lock(pid):
  185. taken.append(("printer", pid))
  186. return real_printer(pid)
  187. with (
  188. patch("backend.app.services.spool_relocation.get_spool_move_lock", move_lock),
  189. patch("backend.app.services.spool_relocation.get_ams_assignment_lock", printer_lock),
  190. ):
  191. await self._release(test_engine, {b: [{"id": 0, "tray": [_tray(0)]}]}, spool_id, (b, 0, 0))
  192. assert taken == [("spool", spool_id), ("printer", min(a, b)), ("printer", max(a, b))]
  193. async def test_spoolman_sync_releases_the_spools_other_slots(test_engine, db_session, printer_factory):
  194. """Parity: the Spoolman AMS sync writes the slot it read the tag in and
  195. takes the spool out of its other slots."""
  196. from backend.app.services.spoolman_slot_sync import write_slot_results
  197. a = (await printer_factory()).id
  198. b = (await printer_factory()).id
  199. db_session.add(SpoolmanSlotAssignment(printer_id=a, ams_id=0, tray_id=1, spoolman_spool_id=42))
  200. await db_session.commit()
  201. real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  202. reports = {b: [{"id": 0, "tray": [_tray(0)]}]}
  203. with (
  204. patch("backend.app.core.database.async_session", real_db),
  205. patch("backend.app.services.printer_manager.printer_manager.get_status", _status(reports)),
  206. patch("backend.app.services.spool_relocation.ws_manager") as ws,
  207. patch("backend.app.services.spoolman_slot_sync.ws_manager") as sync_ws,
  208. ):
  209. ws.broadcast = AsyncMock()
  210. sync_ws.broadcast = AsyncMock()
  211. async with real_db() as db:
  212. await write_slot_results(db, {(a, 0, 1): 42}, [(b, 0, 0, 42)], [])
  213. assert await _rows(db_session, SpoolmanSlotAssignment) == {(b, 0, 0)}
  214. async def test_spoolman_sync_without_a_slot_snapshot_releases_too(test_engine, db_session, printer_factory):
  215. """The write that runs when the slot table could not be read first."""
  216. from backend.app.services.spoolman_slot_sync import write_slot_results
  217. a = (await printer_factory()).id
  218. b = (await printer_factory()).id
  219. db_session.add(SpoolmanSlotAssignment(printer_id=a, ams_id=0, tray_id=1, spoolman_spool_id=42))
  220. await db_session.commit()
  221. real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  222. reports = {b: [{"id": 0, "tray": [_tray(0)]}]}
  223. with (
  224. patch("backend.app.core.database.async_session", real_db),
  225. patch("backend.app.services.printer_manager.printer_manager.get_status", _status(reports)),
  226. patch("backend.app.services.spool_relocation.ws_manager") as ws,
  227. patch("backend.app.services.spoolman_slot_sync.ws_manager") as sync_ws,
  228. ):
  229. ws.broadcast = AsyncMock()
  230. sync_ws.broadcast = AsyncMock()
  231. async with real_db() as db:
  232. await write_slot_results(db, None, [(b, 0, 0, 42)], [])
  233. assert await _rows(db_session, SpoolmanSlotAssignment) == {(b, 0, 0)}
  234. async def test_spoolman_sync_of_an_untagged_slot_releases_nothing(test_engine, db_session, printer_factory):
  235. from backend.app.services.spoolman_slot_sync import write_slot_results
  236. a = (await printer_factory()).id
  237. b = (await printer_factory()).id
  238. db_session.add(SpoolmanSlotAssignment(printer_id=a, ams_id=0, tray_id=1, spoolman_spool_id=42))
  239. await db_session.commit()
  240. real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  241. reports = {b: [{"id": 0, "tray": [_tray(0, tagged=False)]}]}
  242. with (
  243. patch("backend.app.core.database.async_session", real_db),
  244. patch("backend.app.services.printer_manager.printer_manager.get_status", _status(reports)),
  245. patch("backend.app.services.spoolman_slot_sync.ws_manager") as sync_ws,
  246. ):
  247. sync_ws.broadcast = AsyncMock()
  248. async with real_db() as db:
  249. await write_slot_results(db, None, [(b, 0, 0, 42)], [])
  250. assert await _rows(db_session, SpoolmanSlotAssignment) == {(a, 0, 1), (b, 0, 0)}
  251. async def test_spoolman_release_clears_the_old_slots_fallback_tag(test_engine, db_session, printer_factory):
  252. """As a Spoolman move does: a spool linked to an untagged slot carries that
  253. slot's fallback tag, and usage tracking would keep finding it there."""
  254. from backend.app.services.spoolman_tracking import get_fallback_spool_tag_for_slot
  255. printer_a = await printer_factory()
  256. a, serial_a = printer_a.id, printer_a.serial_number
  257. b = (await printer_factory()).id
  258. db_session.add(SpoolmanSlotAssignment(printer_id=a, ams_id=0, tray_id=1, spoolman_spool_id=42))
  259. db_session.add(SpoolmanSlotAssignment(printer_id=b, ams_id=0, tray_id=0, spoolman_spool_id=42))
  260. await db_session.commit()
  261. fallback = get_fallback_spool_tag_for_slot(serial_a, 0, 1)
  262. client = MagicMock()
  263. client.get_spool = AsyncMock(return_value={"id": 42, "extra": {"tag": f'"{fallback}"'}})
  264. client.merge_spool_extra = AsyncMock()
  265. real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  266. with (
  267. patch("backend.app.core.database.async_session", real_db),
  268. patch(
  269. "backend.app.services.printer_manager.printer_manager.get_status",
  270. _status({b: [{"id": 0, "tray": [_tray(0)]}]}),
  271. ),
  272. patch("backend.app.services.spool_relocation.ws_manager") as ws,
  273. patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)),
  274. ):
  275. ws.broadcast = AsyncMock()
  276. vacated = await spool_relocation.release_other_slots(spool_relocation.SPOOLMAN, 42, (b, 0, 0))
  277. assert vacated == [(a, 0, 1)]
  278. client.merge_spool_extra.assert_awaited_once_with(42, {"tag": '""'})