test_spoolbuddy_spoolman_cascade_uid_3258.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. """SpoolBuddy scans in Spoolman mode against tag links stored from cascade level 1 (#3258).
  2. SpoolBuddy daemons before #3258 read an NTAG only to cascade level 1 and reported
  3. 04ECE673D32A81 as 8804ECE6 (cascade tag 88 + its first three bytes), so Spoolman
  4. spools linked on the scale carry that form as a native tag or in extra.tag. These
  5. are the Spoolman-mode counterparts of the built-in inventory's tests in
  6. ``test_spool_tag_matcher.py``, with the same rules.
  7. Most tests run the scan route against a mocked SpoolmanClient. The last class runs
  8. a real client against the fake Spoolman of ``backend/tests/_fixtures/spoolman_tags.py``.
  9. """
  10. import json
  11. import logging
  12. from contextlib import contextmanager
  13. from unittest.mock import AsyncMock, create_autospec, patch
  14. import pytest
  15. from httpx import AsyncClient
  16. from sqlalchemy.ext.asyncio import AsyncSession
  17. from backend.app.models.settings import Settings
  18. from backend.app.services.spoolman import SpoolmanClient, SpoolmanClientError, SpoolmanUnavailableError
  19. from backend.tests._fixtures.spoolman_tags import BASE_URL, FakeSpoolman, client_for
  20. SCAN = "/api/v1/spoolbuddy/nfc/tag-scanned"
  21. NTAG_UID = "04ECE673D32A81"
  22. NTAG_LEVEL1 = "8804ECE6"
  23. AMBIGUOUS = "fits the tag links of spools {} at cascade level 1; not guessing between them"
  24. @pytest.fixture
  25. async def spoolman_on(db_session: AsyncSession):
  26. db_session.add(Settings(key="spoolman_enabled", value="true"))
  27. db_session.add(Settings(key="spoolman_url", value=BASE_URL))
  28. await db_session.commit()
  29. def _spool(spool_id: int, *, tags=(), extra_tag: str | None = None, archived: bool = False) -> dict:
  30. """A Spoolman spool as the API answers it; ``tags`` None for a server without native tags."""
  31. spool = {
  32. "id": spool_id,
  33. "filament": {
  34. "material": "PLA",
  35. "name": "PLA Basic",
  36. "color_hex": "FF0000",
  37. "weight": 1000.0,
  38. "spool_weight": 196.0,
  39. "vendor": {"name": "Bambu Lab"},
  40. },
  41. "used_weight": 0.0,
  42. "archived": archived,
  43. "registered": "2026-10-01T00:00:00Z",
  44. "extra": {"tag": json.dumps(extra_tag)} if extra_tag is not None else {},
  45. }
  46. if tags is not None:
  47. spool["tags"] = [{"uid": uid} for uid in tags]
  48. return spool
  49. def _client(spools: list[dict], *, tag_api: bool = True, current: dict | None = None):
  50. """A mocked SpoolmanClient whose exact lookups miss and whose listing is ``spools``.
  51. ``current`` is what get_spool answers after the scan linked its tags.
  52. """
  53. client = create_autospec(SpoolmanClient, instance=True)
  54. client.base_url = BASE_URL
  55. client.has_tag_api.return_value = tag_api
  56. client.find_spool_by_native_tag.return_value = None
  57. client.find_spool_by_tag.return_value = None
  58. client.get_spools.return_value = spools
  59. client.add_native_tags.return_value = 1 if tag_api else 0
  60. client.get_spool.return_value = current
  61. client.merge_spool_extra.return_value = {}
  62. client.unlink_native_tag.return_value = None
  63. return client
  64. @contextmanager
  65. def serving(client):
  66. getter = AsyncMock(return_value=client)
  67. with (
  68. patch("backend.app.services.spoolman.get_spoolman_client", getter),
  69. patch("backend.app.services.spoolman.init_spoolman_client", getter),
  70. patch("backend.app.api.routes.spoolbuddy.ws_manager") as ws,
  71. ):
  72. ws.broadcast = AsyncMock()
  73. yield ws
  74. async def _scan(async_client: AsyncClient, tag_uid: str, sak: int | None = None) -> dict:
  75. body = {"device_id": "sb-1", "tag_uid": tag_uid}
  76. if sak is not None:
  77. body["sak"] = sak
  78. resp = await async_client.post(SCAN, json=body)
  79. assert resp.status_code == 200
  80. return resp.json()
  81. def _broadcast_types(ws) -> list[str]:
  82. return [call.args[0]["type"] for call in ws.broadcast.call_args_list]
  83. def _native_uids_added(client) -> list:
  84. return [uid for call in client.add_native_tags.await_args_list for uid in call.args[1] if uid]
  85. class TestFullUidScan:
  86. """A full 7- or 10-byte read finds the spool linked as 88 + its first three bytes."""
  87. async def test_the_88_native_tag_is_unlinked_once_the_full_uid_is_linked(self, async_client, spoolman_on):
  88. spool = _spool(7, tags=[NTAG_LEVEL1])
  89. client = _client([spool], current=_spool(7, tags=[NTAG_LEVEL1, NTAG_UID]))
  90. with serving(client) as ws:
  91. result = await _scan(async_client, NTAG_UID)
  92. assert result == {"status": "ok", "matched": True, "spool_id": 7}
  93. # The route's own add_native_tags call links the full UID.
  94. assert _native_uids_added(client) == [NTAG_UID]
  95. client.unlink_native_tag.assert_awaited_once_with(7, NTAG_LEVEL1)
  96. client.merge_spool_extra.assert_not_awaited()
  97. assert _broadcast_types(ws) == ["spoolbuddy_tag_matched", "inventory_changed"]
  98. async def test_an_88_extra_tag_is_rewritten_to_the_full_uid(self, async_client, spoolman_on):
  99. """A Spoolman without native tags: extra.tag is the only link."""
  100. client = _client([_spool(7, tags=None, extra_tag=NTAG_LEVEL1)], tag_api=False)
  101. with serving(client):
  102. result = await _scan(async_client, NTAG_UID)
  103. assert result["spool_id"] == 7
  104. client.merge_spool_extra.assert_awaited_once_with(7, {"tag": json.dumps(NTAG_UID)})
  105. client.unlink_native_tag.assert_not_awaited()
  106. async def test_both_88_links_move_to_the_full_uid(self, async_client, spoolman_on):
  107. spool = _spool(7, tags=[NTAG_LEVEL1], extra_tag=NTAG_LEVEL1)
  108. client = _client([spool], current=_spool(7, tags=[NTAG_LEVEL1, NTAG_UID], extra_tag=NTAG_UID))
  109. with serving(client):
  110. result = await _scan(async_client, NTAG_UID)
  111. assert result["spool_id"] == 7
  112. assert _native_uids_added(client) == [NTAG_UID]
  113. client.merge_spool_extra.assert_awaited_once_with(7, {"tag": json.dumps(NTAG_UID)})
  114. client.unlink_native_tag.assert_awaited_once_with(7, NTAG_LEVEL1)
  115. async def test_a_ten_byte_uid_finds_its_88_link(self, async_client, spoolman_on):
  116. full = "04112233445566778899"
  117. client = _client([_spool(7, tags=None, extra_tag="88041122")], tag_api=False)
  118. with serving(client):
  119. result = await _scan(async_client, full)
  120. assert result["spool_id"] == 7
  121. # Spoolman has no length limit, so the whole UID is stored.
  122. client.merge_spool_extra.assert_awaited_once_with(7, {"tag": json.dumps(full)})
  123. async def test_an_archived_spool_neither_matches_nor_makes_it_ambiguous(self, async_client, spoolman_on):
  124. client = _client(
  125. [_spool(1, extra_tag=NTAG_LEVEL1, archived=True), _spool(7, extra_tag=NTAG_LEVEL1)],
  126. current=_spool(7, tags=[NTAG_UID], extra_tag=NTAG_UID),
  127. )
  128. with serving(client):
  129. result = await _scan(async_client, NTAG_UID)
  130. assert result["spool_id"] == 7
  131. client.merge_spool_extra.assert_awaited_once_with(7, {"tag": json.dumps(NTAG_UID)})
  132. async def test_a_spool_matched_exactly_that_still_carries_the_88_form_finishes_the_move(
  133. self, async_client, spoolman_on
  134. ):
  135. """An earlier move stopped half-way: linked by the full UID, the 88 forms still there."""
  136. spool = _spool(7, tags=[NTAG_UID, NTAG_LEVEL1], extra_tag=NTAG_LEVEL1)
  137. client = _client([], current=spool)
  138. client.find_spool_by_native_tag.return_value = spool
  139. with serving(client):
  140. result = await _scan(async_client, NTAG_UID)
  141. assert result["spool_id"] == 7
  142. client.get_spools.assert_not_awaited()
  143. client.merge_spool_extra.assert_awaited_once_with(7, {"tag": json.dumps(NTAG_UID)})
  144. client.unlink_native_tag.assert_awaited_once_with(7, NTAG_LEVEL1)
  145. async def test_a_four_byte_uid_ignores_88_links(self, async_client, spoolman_on):
  146. client = _client([_spool(7, tags=[NTAG_LEVEL1], extra_tag=NTAG_LEVEL1)])
  147. with serving(client):
  148. result = await _scan(async_client, "04ECE673")
  149. assert result["matched"] is False
  150. client.add_native_tags.assert_not_awaited()
  151. class TestLevel1Scan:
  152. """A level-1 read from a daemon not updated yet finds the spool linked by the full UID."""
  153. async def test_a_native_full_uid_is_matched_and_nothing_is_written(self, async_client, spoolman_on):
  154. client = _client([_spool(7, tags=[NTAG_UID])])
  155. with serving(client) as ws:
  156. result = await _scan(async_client, NTAG_LEVEL1)
  157. assert result == {"status": "ok", "matched": True, "spool_id": 7}
  158. # The 88 form is not linked: three bytes can begin more than one tag.
  159. assert _native_uids_added(client) == []
  160. client.merge_spool_extra.assert_not_awaited()
  161. client.unlink_native_tag.assert_not_awaited()
  162. client.get_spool.assert_not_awaited()
  163. assert _broadcast_types(ws) == ["spoolbuddy_tag_matched"]
  164. async def test_a_full_uid_in_extra_tag_is_matched_and_nothing_is_written(self, async_client, spoolman_on):
  165. client = _client([_spool(7, tags=None, extra_tag=NTAG_UID)], tag_api=False)
  166. with serving(client):
  167. result = await _scan(async_client, NTAG_LEVEL1)
  168. assert result["spool_id"] == 7
  169. client.merge_spool_extra.assert_not_awaited()
  170. async def test_an_old_daemon_reports_the_sak_of_level_1(self, async_client, spoolman_on):
  171. client = _client([_spool(7, tags=[NTAG_UID])])
  172. with serving(client):
  173. result = await _scan(async_client, NTAG_LEVEL1, sak=0x04)
  174. assert result["spool_id"] == 7
  175. async def test_a_complete_uid_beginning_with_88_is_no_level1_read(self, async_client, spoolman_on):
  176. """A SAK without bit 0x04 says the UID ended there: a 4-byte clone card, not a level-1 read."""
  177. client = _client([_spool(7, tags=[NTAG_UID])])
  178. with serving(client):
  179. result = await _scan(async_client, NTAG_LEVEL1, sak=0x08)
  180. assert result["matched"] is False
  181. async def test_only_a_seven_byte_uid_can_be_what_it_begins(self, async_client, spoolman_on):
  182. client = _client([_spool(7, tags=["04ECE673D32A8100"], extra_tag="04ECE673")])
  183. with serving(client):
  184. result = await _scan(async_client, NTAG_LEVEL1)
  185. assert result["matched"] is False
  186. class TestTwoCandidates:
  187. """Two fitting spools may hold two different tags: no guess, nothing written."""
  188. async def test_full_uid_scan_with_two_88_links(self, async_client, spoolman_on, caplog):
  189. client = _client([_spool(3, tags=[NTAG_LEVEL1]), _spool(7, extra_tag=NTAG_LEVEL1)])
  190. with serving(client) as ws, caplog.at_level(logging.WARNING):
  191. result = await _scan(async_client, NTAG_UID)
  192. assert result == {"status": "ok", "matched": False, "spool_id": None}
  193. assert _broadcast_types(ws) == ["spoolbuddy_unknown_tag"]
  194. assert f"Tag {NTAG_UID} " + AMBIGUOUS.format([3, 7]) in caplog.text
  195. client.add_native_tags.assert_not_awaited()
  196. client.merge_spool_extra.assert_not_awaited()
  197. client.unlink_native_tag.assert_not_awaited()
  198. async def test_level1_scan_with_two_full_uids_it_begins(self, async_client, spoolman_on, caplog):
  199. client = _client([_spool(3, tags=[NTAG_UID]), _spool(7, extra_tag="04ECE6AABBCCDD")])
  200. with serving(client) as ws, caplog.at_level(logging.WARNING):
  201. result = await _scan(async_client, NTAG_LEVEL1)
  202. assert result["matched"] is False
  203. assert _broadcast_types(ws) == ["spoolbuddy_unknown_tag"]
  204. assert f"Tag {NTAG_LEVEL1} " + AMBIGUOUS.format([3, 7]) in caplog.text
  205. client.add_native_tags.assert_not_awaited()
  206. client.merge_spool_extra.assert_not_awaited()
  207. class TestFailedWrites:
  208. """A refused or failed write keeps the match; the next scan tries the move again."""
  209. async def test_a_refused_extra_tag_rewrite_keeps_the_match_and_is_retried(self, async_client, spoolman_on):
  210. client = _client([_spool(7, tags=None, extra_tag=NTAG_LEVEL1)], tag_api=False)
  211. client.merge_spool_extra.side_effect = SpoolmanClientError("Spoolman rejected update", 400)
  212. with serving(client) as ws:
  213. first = await _scan(async_client, NTAG_UID)
  214. second = await _scan(async_client, NTAG_UID)
  215. assert first["spool_id"] == second["spool_id"] == 7
  216. assert client.merge_spool_extra.await_count == 2
  217. assert _broadcast_types(ws) == ["spoolbuddy_tag_matched", "spoolbuddy_tag_matched"]
  218. async def test_the_88_native_tag_stays_when_the_full_uid_was_not_linked(self, async_client, spoolman_on):
  219. """add_native_tags was refused: unlinking the 88 tag would leave the spool without a link."""
  220. client = _client([_spool(7, tags=[NTAG_LEVEL1])], current=_spool(7, tags=[NTAG_LEVEL1]))
  221. client.add_native_tags.return_value = 0
  222. with serving(client):
  223. result = await _scan(async_client, NTAG_UID)
  224. assert result["spool_id"] == 7
  225. client.unlink_native_tag.assert_not_awaited()
  226. async def test_a_failing_unlink_keeps_the_match(self, async_client, spoolman_on):
  227. client = _client([_spool(7, tags=[NTAG_LEVEL1])], current=_spool(7, tags=[NTAG_LEVEL1, NTAG_UID]))
  228. client.unlink_native_tag.side_effect = SpoolmanUnavailableError("Cannot reach Spoolman")
  229. with serving(client):
  230. result = await _scan(async_client, NTAG_UID)
  231. assert result == {"status": "ok", "matched": True, "spool_id": 7}
  232. @contextmanager
  233. def serving_fake(fake: FakeSpoolman):
  234. with serving(client_for(fake)) as ws:
  235. yield ws
  236. class TestAgainstFakeSpoolman:
  237. """The same move with a real SpoolmanClient, against a server that enforces the tag rules."""
  238. async def test_both_88_links_move_and_the_next_scan_is_an_exact_match(self, async_client, spoolman_on):
  239. fake = FakeSpoolman()
  240. fake.add_spool(7, extra_tag=NTAG_LEVEL1, tags=[NTAG_LEVEL1])
  241. with serving_fake(fake):
  242. first = await _scan(async_client, NTAG_UID)
  243. listings = fake.asked("GET /spool")
  244. second = await _scan(async_client, NTAG_UID)
  245. assert first["spool_id"] == second["spool_id"] == 7
  246. assert fake.native(7) == [NTAG_UID]
  247. assert fake.spools[7]["extra"]["tag"] == json.dumps(NTAG_UID)
  248. assert fake.asked("GET /spool") == listings, "the second scan loaded the inventory again"
  249. async def test_a_failed_extra_tag_write_is_finished_on_the_next_scan(self, async_client, spoolman_on):
  250. fake = FakeSpoolman()
  251. fake.add_spool(7, extra_tag=NTAG_LEVEL1, tags=[NTAG_LEVEL1])
  252. fake.fail_patch.add(7)
  253. with serving_fake(fake):
  254. first = await _scan(async_client, NTAG_UID)
  255. assert fake.spools[7]["extra"]["tag"] == json.dumps(NTAG_LEVEL1)
  256. fake.fail_patch.clear()
  257. second = await _scan(async_client, NTAG_UID)
  258. assert first["spool_id"] == second["spool_id"] == 7
  259. assert fake.native(7) == [NTAG_UID]
  260. assert fake.spools[7]["extra"]["tag"] == json.dumps(NTAG_UID)
  261. async def test_an_older_server_gets_extra_tag_rewritten_and_no_tag_writes(self, async_client, spoolman_on):
  262. fake = FakeSpoolman(tag_api=False)
  263. fake.add_spool(7, extra_tag=NTAG_LEVEL1)
  264. with serving_fake(fake):
  265. result = await _scan(async_client, NTAG_UID)
  266. assert result["spool_id"] == 7
  267. assert fake.spools[7]["extra"]["tag"] == json.dumps(NTAG_UID)
  268. assert fake.tag_writes() == []