test_spoolman_tray_state_fallback_2953.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  1. """Tray-state slot mapping for printers that can answer no other way (#2953).
  2. #2768 gave the Spoolman writer two ways to recover a print's slot-to-tray
  3. mapping when print start captured none: the printer's ``mapping`` field, and a
  4. colour match of the 3MF's slots against the loaded trays. An A1 can satisfy
  5. neither. It publishes no ``mapping`` field, and it drops the MQTT connection
  6. when Bambuddy subscribes to its request topic, so the slicer's own instruction
  7. never arrives either. That leaves the colour match, which compares hex strings
  8. exactly.
  9. The reporter sliced with a generic black profile (``#000000``) against a tray
  10. they had set to ``#111111``. No match, so every print fell through to the
  11. positional default and charged slot 1 to the first loaded tray -- the grey PLA+
  12. in tray 0 -- while the print was actually fed from tray 3. Their log carries
  13. the printer's own answer, ``Tray change during print: tray=3 at layer=0``,
  14. recorded 90 seconds into the print and already read by ``_print_used_tray_keys``
  15. further down the same completion pass.
  16. Values throughout are the ones from archive 12 of the reporter's support
  17. bundle.
  18. """
  19. from types import SimpleNamespace
  20. from unittest.mock import AsyncMock, MagicMock, patch
  21. import pytest
  22. from backend.app.services.spoolman_tracking import (
  23. _resolve_slot_to_tray_fallback,
  24. _single_slot_tray_from_state,
  25. )
  26. # The reporter's AMS at completion: tray 1 is empty, and no tray is #000000.
  27. REPORTER_AMS = [
  28. {
  29. "id": 0,
  30. "tray": [
  31. {"id": 0, "tray_color": "888888FF", "tray_type": "PLA+"},
  32. {"id": 1, "tray_color": None, "tray_type": None},
  33. {"id": 2, "tray_color": "5F4036FF", "tray_type": "PLA"},
  34. {"id": 3, "tray_color": "111111FF", "tray_type": "PLA+"},
  35. ],
  36. }
  37. ]
  38. REPORTER_USAGE = [{"slot_id": 1, "used_g": 2.17, "type": "PLA+", "color": "#000000"}]
  39. class _AsyncCtx:
  40. def __init__(self, db):
  41. self._db = db
  42. async def __aenter__(self):
  43. return self._db
  44. async def __aexit__(self, *exc):
  45. return False
  46. def _state(tray_change_log=None, tray_now=255, last_loaded_tray=-1, **raw):
  47. """An A1 as it looks at completion: no mapping field, nothing loaded."""
  48. return SimpleNamespace(
  49. raw_data=raw,
  50. layer_num=0,
  51. total_layers=0,
  52. tray_change_log=list(tray_change_log or []),
  53. tray_now=tray_now,
  54. last_loaded_tray=last_loaded_tray,
  55. )
  56. def _patched_pm(state):
  57. pm = MagicMock()
  58. pm.get_status.return_value = state
  59. return pm
  60. class TestSingleSlotTrayFromState:
  61. def test_the_mid_print_tray_change_is_the_answer(self):
  62. """``Tray change during print: tray=3 at layer=0``. The printer
  63. announced the switch while the job was running, so it describes this
  64. print and no other."""
  65. state = _state(tray_change_log=[(3, 0)])
  66. assert _single_slot_tray_from_state(state, REPORTER_USAGE, 255) == (1, 3)
  67. def test_declines_a_multi_slot_print(self):
  68. """Every colour change moves ``tray_now``, so one tray reading can't be
  69. attributed to one slot. Same gate the internal writer uses."""
  70. usage = [
  71. {"slot_id": 1, "used_g": 10.0, "color": "#000000"},
  72. {"slot_id": 2, "used_g": 5.0, "color": "#FFFFFF"},
  73. ]
  74. assert _single_slot_tray_from_state(_state(tray_change_log=[(3, 0)]), usage, None) is None
  75. def test_declines_when_the_print_switched_trays(self):
  76. """Two entries means AMS backup swapped a spool mid-print (#957).
  77. ``report_usage`` splits those per segment; handing it a single-tray
  78. mapping instead would charge the whole print to one of them."""
  79. state = _state(tray_change_log=[(3, 0), (0, 120)])
  80. assert _single_slot_tray_from_state(state, REPORTER_USAGE, None) is None
  81. def test_slots_that_consumed_nothing_do_not_count_as_a_second_slot(self):
  82. """A purge-only slot is in the 3MF with zero grams. It is not a second
  83. filament and must not disqualify the print."""
  84. usage = [
  85. {"slot_id": 1, "used_g": 2.17, "color": "#000000"},
  86. {"slot_id": 2, "used_g": 0.0, "color": "#FFFFFF"},
  87. ]
  88. assert _single_slot_tray_from_state(_state(tray_change_log=[(3, 0)]), usage, None) == (1, 3)
  89. def test_falls_back_to_tray_now_at_start(self):
  90. state = _state(tray_change_log=[])
  91. assert _single_slot_tray_from_state(state, REPORTER_USAGE, 2) == (1, 2)
  92. def test_falls_back_to_current_tray_now(self):
  93. state = _state(tray_change_log=[], tray_now=2)
  94. assert _single_slot_tray_from_state(state, REPORTER_USAGE, 255) == (1, 2)
  95. def test_falls_back_to_last_loaded_tray(self):
  96. """The reporter's A1 parks ``tray_now`` at 255 the moment the print
  97. ends, and their ``tray_now_at_start`` was 255 too because print start
  98. fires before the filament is loaded. ``last_loaded_tray`` only ever
  99. latches real trays, so it is the one still holding the answer."""
  100. state = _state(tray_change_log=[], tray_now=255, last_loaded_tray=3)
  101. assert _single_slot_tray_from_state(state, REPORTER_USAGE, 255) == (1, 3)
  102. def test_255_is_not_a_tray(self):
  103. """255 is ``tray_now`` at rest and what an unparseable reading falls
  104. back to. Reading it as a slot would charge a spool on no evidence."""
  105. state = _state(tray_change_log=[], tray_now=255, last_loaded_tray=255)
  106. assert _single_slot_tray_from_state(state, REPORTER_USAGE, 255) is None
  107. def test_no_state_at_all(self):
  108. assert _single_slot_tray_from_state(None, REPORTER_USAGE, None) is None
  109. class TestResolveSlotToTrayFallbackRung:
  110. def test_the_reporters_print_resolves_to_tray_3(self):
  111. """End of the chain: no mapping field, colours don't match, and the
  112. tray-change log settles it."""
  113. pm = _patched_pm(_state(tray_change_log=[(3, 0)], ams=REPORTER_AMS))
  114. with patch("backend.app.services.printer_manager.printer_manager", pm):
  115. mapping, source = _resolve_slot_to_tray_fallback(1, REPORTER_USAGE, 255)
  116. assert mapping == [3]
  117. assert source == "tray_state"
  118. def test_colour_match_still_wins(self):
  119. """When the slicer colour does equal a tray's, that is a direct
  120. statement about this slot and outranks a tray reading."""
  121. usage = [{"slot_id": 1, "used_g": 2.17, "color": "#5F4036"}]
  122. pm = _patched_pm(_state(tray_change_log=[(3, 0)], ams=REPORTER_AMS))
  123. with patch("backend.app.services.printer_manager.printer_manager", pm):
  124. mapping, source = _resolve_slot_to_tray_fallback(1, usage, 255)
  125. assert mapping == [2]
  126. assert source == "color_match"
  127. def test_mapping_field_still_wins(self):
  128. pm = _patched_pm(_state(tray_change_log=[(3, 0)], mapping=[0], ams=REPORTER_AMS))
  129. with patch("backend.app.services.printer_manager.printer_manager", pm):
  130. mapping, source = _resolve_slot_to_tray_fallback(1, REPORTER_USAGE, 255)
  131. assert mapping == [0]
  132. assert source == "mqtt"
  133. def test_an_empty_status_payload_still_reaches_the_tray_rung(self):
  134. """``tray_change_log`` lives on the state object, not in the status
  135. payload, so an empty payload must not short-circuit past it."""
  136. pm = _patched_pm(_state(tray_change_log=[(3, 0)]))
  137. with patch("backend.app.services.printer_manager.printer_manager", pm):
  138. mapping, source = _resolve_slot_to_tray_fallback(1, REPORTER_USAGE, None)
  139. assert mapping == [3]
  140. assert source == "tray_state"
  141. def test_only_the_used_slot_is_claimed(self):
  142. """A print whose one filament is slot 3 pads the array so the index
  143. lines up. The padding is -1, which no caller resolves: those slots
  144. consumed nothing."""
  145. usage = [
  146. {"slot_id": 1, "used_g": 0.0, "color": "#AAAAAA"},
  147. {"slot_id": 3, "used_g": 2.17, "color": "#000000"},
  148. ]
  149. pm = _patched_pm(_state(tray_change_log=[(3, 0)]))
  150. with patch("backend.app.services.printer_manager.printer_manager", pm):
  151. mapping, source = _resolve_slot_to_tray_fallback(1, usage, None)
  152. assert mapping == [-1, -1, 3]
  153. assert source == "tray_state"
  154. def test_still_says_none_when_the_printer_offers_nothing(self):
  155. pm = _patched_pm(_state(tray_change_log=[], tray_now=255, last_loaded_tray=-1, ams=REPORTER_AMS))
  156. with patch("backend.app.services.printer_manager.printer_manager", pm):
  157. mapping, source = _resolve_slot_to_tray_fallback(1, REPORTER_USAGE, 255)
  158. assert mapping is None
  159. assert source == "none"
  160. class TestReportUsageChargesTheRightSpool:
  161. """The reporter's archive 12, end to end."""
  162. @staticmethod
  163. def _run(tracking, state, spool_by_tag, archive):
  164. rows = iter([tracking])
  165. def _next_row(*_args, **_kwargs):
  166. result = MagicMock()
  167. result.scalar_one_or_none.return_value = next(rows, archive)
  168. return result
  169. db = AsyncMock()
  170. db.execute = AsyncMock(side_effect=_next_row)
  171. db.delete = AsyncMock()
  172. db.commit = AsyncMock()
  173. client = AsyncMock()
  174. client.find_spool_by_tag = AsyncMock(side_effect=lambda tag: spool_by_tag.get(tag))
  175. client.use_spool = AsyncMock()
  176. pm = _patched_pm(state)
  177. async def _go():
  178. from backend.app.services.spoolman_tracking import report_usage
  179. with (
  180. patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)),
  181. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  182. patch(
  183. "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
  184. AsyncMock(return_value=client),
  185. ),
  186. patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="SER")),
  187. patch(
  188. "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
  189. AsyncMock(return_value=None),
  190. ),
  191. patch("backend.app.services.printer_manager.printer_manager", pm),
  192. ):
  193. await report_usage(printer_id=1, archive_id=12)
  194. return _go, client
  195. @staticmethod
  196. def _tracking():
  197. return SimpleNamespace(
  198. filament_usage=list(REPORTER_USAGE),
  199. ams_trays={
  200. "0": {"tray_uuid": "TRAY0", "tag_uid": "", "tray_type": "PLA+"},
  201. "2": {"tray_uuid": "TRAY2", "tag_uid": "", "tray_type": "PLA"},
  202. "3": {"tray_uuid": "TRAY3", "tag_uid": "", "tray_type": "PLA+"},
  203. },
  204. slot_to_tray=None,
  205. tray_remain_start=None,
  206. layer_usage=None,
  207. filament_properties=None,
  208. tray_now_at_start=255,
  209. )
  210. SPOOLS = {
  211. # Spool 41 is the grey PLA+ that was wrongly charged 2.17 g.
  212. "TRAY0": {"id": 41, "filament": {"color_hex": "888888", "material": "PLA+"}},
  213. "TRAY2": {"id": 20, "filament": {"color_hex": "5F4036", "material": "PLA"}},
  214. "TRAY3": {"id": 46, "filament": {"color_hex": "111111", "material": "PLA+"}},
  215. }
  216. @pytest.mark.asyncio
  217. async def test_the_tray_the_printer_named_is_charged(self):
  218. archive = SimpleNamespace(filament_color="#000000", filament_type="PLA+")
  219. state = _state(tray_change_log=[(3, 0)], tray_now=255, last_loaded_tray=3, ams=REPORTER_AMS)
  220. run, client = self._run(self._tracking(), state, self.SPOOLS, archive)
  221. await run()
  222. client.use_spool.assert_awaited_once_with(46, 2.17)
  223. @pytest.mark.asyncio
  224. async def test_the_archive_is_not_restamped_from_a_positional_guess(self):
  225. """Nothing named a tray, so slot 1 is charged by position. The grams
  226. can be put back; overwriting what the slicer recorded cannot, so the
  227. archive keeps the colour and material it was printed with."""
  228. archive = SimpleNamespace(filament_color="#000000", filament_type="PLA+")
  229. state = _state(tray_change_log=[], tray_now=255, last_loaded_tray=-1, ams=REPORTER_AMS)
  230. run, client = self._run(self._tracking(), state, self.SPOOLS, archive)
  231. await run()
  232. client.use_spool.assert_awaited_once_with(41, 2.17)
  233. assert archive.filament_color == "#000000"
  234. assert archive.filament_type == "PLA+"
  235. @pytest.mark.asyncio
  236. async def test_a_resolved_mapping_still_restamps_the_archive(self):
  237. """#1494 and #2563 are unchanged when the mapping is actually known."""
  238. archive = SimpleNamespace(filament_color="#000000", filament_type="PLA+")
  239. state = _state(tray_change_log=[(3, 0)], tray_now=255, last_loaded_tray=3, ams=REPORTER_AMS)
  240. run, _client = self._run(self._tracking(), state, self.SPOOLS, archive)
  241. await run()
  242. assert archive.filament_color == "#111111"
  243. class TestTraySplitIsNotAPositionalGuess:
  244. """A print that switched trays mid-run is attributed per segment from the
  245. tray-change log (#1793). That path never reads ``slot_to_tray``, so the
  246. absence of a mapping says nothing about it -- treating it as a positional
  247. guess would suppress the archive rewrite for the prints whose attribution
  248. is best supported, and log a warning naming a mechanism that did not run.
  249. """
  250. @pytest.mark.asyncio
  251. async def test_a_runout_switch_without_a_mapping_still_restamps_the_archive(self):
  252. from backend.app.services.spoolman_tracking import report_usage
  253. tracking = SimpleNamespace(
  254. filament_usage=[{"slot_id": 1, "used_g": 72.56, "type": "PLA+", "color": "#000000"}],
  255. ams_trays={
  256. "0": {"tray_uuid": "TRAY0", "tag_uid": "", "tray_type": "PLA+"},
  257. "3": {"tray_uuid": "TRAY3", "tag_uid": "", "tray_type": "PLA+"},
  258. },
  259. # The #2768 condition: a Studio print, so print start stored nothing.
  260. slot_to_tray=None,
  261. tray_remain_start=None,
  262. layer_usage={},
  263. filament_properties={},
  264. tray_now_at_start=255,
  265. )
  266. # The #1793 condition: AMS backup switched tray 0 -> tray 3 at layer 50.
  267. state = SimpleNamespace(
  268. raw_data={"ams": REPORTER_AMS},
  269. tray_change_log=[(0, 0), (3, 50)],
  270. total_layers=100,
  271. layer_num=100,
  272. tray_now=255,
  273. last_loaded_tray=3,
  274. )
  275. spools = {
  276. "TRAY0": {"id": 41, "filament": {"color_hex": "888888", "material": "PLA+"}},
  277. "TRAY3": {"id": 46, "filament": {"color_hex": "111111", "material": "PLA+"}},
  278. }
  279. archive = SimpleNamespace(filament_color="#000000", filament_type="PLA+")
  280. rows = iter([tracking])
  281. def _next_row(*_args, **_kwargs):
  282. result = MagicMock()
  283. result.scalar_one_or_none.return_value = next(rows, archive)
  284. return result
  285. db = AsyncMock()
  286. db.execute = AsyncMock(side_effect=_next_row)
  287. db.delete = AsyncMock()
  288. db.commit = AsyncMock()
  289. client = AsyncMock()
  290. client.find_spool_by_tag = AsyncMock(side_effect=lambda tag: spools.get(tag))
  291. client.use_spool = AsyncMock()
  292. with (
  293. patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)),
  294. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  295. patch(
  296. "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
  297. AsyncMock(return_value=client),
  298. ),
  299. patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="SER")),
  300. patch(
  301. "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
  302. AsyncMock(return_value=None),
  303. ),
  304. patch("backend.app.services.printer_manager.printer_manager", _patched_pm(state)),
  305. ):
  306. await report_usage(printer_id=1, archive_id=12)
  307. # Both segments charged, so the split path is what ran.
  308. charged = {c.args[0] for c in client.use_spool.await_args_list}
  309. assert charged == {41, 46}
  310. # And the archive was rewritten from the spools the segments named,
  311. # rather than being left alone as a guess would be.
  312. assert archive.filament_color != "#000000"