test_kprofile_nozzle_buckets_2854.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. """The slot-card K value survives a query for a nozzle the printer lacks.
  2. H2-series AMS trays carry no ``k`` field -- verified against a live H2 wire
  3. capture, where every tray reports ``cali_idx`` and nothing else -- so the value
  4. PR #2854 put on the slot card is resolved from the printer's calibration table
  5. in ``state.kprofiles``.
  6. That table is answered per nozzle diameter, and the printer answers whoever
  7. asks: BambuStudio's queries land on the same report topic Bambuddy subscribes
  8. to. Assigning each response straight to ``state.kprofiles`` let one answer
  9. stand for the whole printer. Measured on the maintainer's H2 on 2026-08-25:
  10. the nightly GitHub backup probes 0.2/0.4/0.6/0.8 in turn, the 0.8 probe found
  11. no profiles on a 0.4+0.6 machine, and every K value on the card went blank
  12. until something refilled the list.
  13. """
  14. from types import SimpleNamespace
  15. from unittest.mock import AsyncMock, MagicMock, patch
  16. import pytest
  17. from backend.app.services.bambu_mqtt import BambuMQTTClient, KProfile, NozzleInfo, PrinterState
  18. from backend.app.utils.kprofile_lookup import build_slot_k_resolver
  19. def _client() -> BambuMQTTClient:
  20. """A client with no transport -- only the response handling is under test."""
  21. return BambuMQTTClient(ip_address="10.0.0.1", serial_number="TESTSERIAL0000", access_code="00000000")
  22. def _response(nozzle: str, *entries: tuple[int, str]) -> dict:
  23. """One ``extrusion_cali_get`` payload, as the printer sends it.
  24. The envelope carries the nozzle diameter; the per-filament entries do not.
  25. """
  26. return {
  27. "command": "extrusion_cali_get",
  28. "nozzle_diameter": nozzle,
  29. "filaments": [
  30. {
  31. "cali_idx": cali_idx,
  32. "extruder_id": 0,
  33. "filament_id": "GFL99",
  34. "k_value": k_value,
  35. "name": f"Profile {cali_idx}",
  36. "setting_id": "GFSL99",
  37. }
  38. for cali_idx, k_value in entries
  39. ],
  40. }
  41. class TestNozzleBuckets:
  42. def test_an_empty_table_clears_only_its_own_nozzle(self):
  43. """The exact backup sequence that emptied the maintainer's card.
  44. 0.2 and 0.8 come back empty on a 0.4+0.6 machine. Neither may take the
  45. other two nozzles' profiles with it.
  46. """
  47. client = _client()
  48. client._handle_kprofile_response(_response("0.2"))
  49. client._handle_kprofile_response(_response("0.4", (3, "0.020000")))
  50. client._handle_kprofile_response(_response("0.6", (3, "0.018000")))
  51. client._handle_kprofile_response(_response("0.8"))
  52. by_nozzle = {kp.nozzle_diameter: kp.k_value for kp in client.state.kprofiles}
  53. assert by_nozzle == {"0.4": "0.020000", "0.6": "0.018000"}
  54. def test_a_fresh_table_replaces_its_own_nozzle_wholesale(self):
  55. """A re-read is authoritative for its nozzle: deletions must stick."""
  56. client = _client()
  57. client._handle_kprofile_response(_response("0.4", (3, "0.020000"), (4, "0.021000")))
  58. client._handle_kprofile_response(_response("0.4", (3, "0.019000")))
  59. assert [(kp.slot_id, kp.k_value) for kp in client.state.kprofiles] == [(3, "0.019000")]
  60. def test_a_response_for_one_nozzle_leaves_the_others_alone(self):
  61. """The BambuStudio case: someone else asks about a nozzle we didn't."""
  62. client = _client()
  63. client._handle_kprofile_response(_response("0.4", (3, "0.020000")))
  64. client._handle_kprofile_response(_response("0.6", (3, "0.018000")))
  65. assert len(client.state.kprofiles) == 2
  66. def test_an_unattributable_answer_is_not_allowed_to_empty_the_table(self):
  67. """No envelope diameter and no entries names no bucket. Keep what we have."""
  68. client = _client()
  69. client._handle_kprofile_response(_response("0.4", (3, "0.020000")))
  70. client._handle_kprofile_response({"command": "extrusion_cali_get", "filaments": []})
  71. assert len(client.state.kprofiles) == 1
  72. def test_entries_name_their_own_bucket_when_the_envelope_does_not(self):
  73. """Firmware that omits the envelope diameter still has to be filed."""
  74. client = _client()
  75. client._handle_kprofile_response(_response("0.4", (3, "0.020000")))
  76. client._handle_kprofile_response(
  77. {
  78. "command": "extrusion_cali_get",
  79. "filaments": [
  80. {"cali_idx": 3, "extruder_id": 0, "k_value": "0.017000", "nozzle_diameter": "0.6"},
  81. ],
  82. }
  83. )
  84. by_nozzle = {kp.nozzle_diameter: kp.k_value for kp in client.state.kprofiles}
  85. assert by_nozzle == {"0.4": "0.020000", "0.6": "0.017000"}
  86. def test_a_pending_request_still_refuses_another_nozzles_answer(self):
  87. """#1748's guard is unchanged: don't wake a waiter with the wrong table."""
  88. client = _client()
  89. client._pending_kprofile_requests["7"] = {"nozzle": "0.4", "event": MagicMock(), "profiles": None}
  90. client._handle_kprofile_response(_response("0.6", (3, "0.018000")))
  91. assert client.state.kprofiles == []
  92. def _state(profiles, *, nozzles=("0.4",), ams_extruder_map=None):
  93. return SimpleNamespace(
  94. kprofiles=list(profiles),
  95. nozzles=[SimpleNamespace(nozzle_diameter=d) for d in nozzles],
  96. ams_extruder_map=ams_extruder_map,
  97. ams_switch_inlet=None,
  98. )
  99. def _profile(cali_idx: int, k_value: str, nozzle: str, extruder: int = 0) -> KProfile:
  100. return KProfile(
  101. slot_id=cali_idx,
  102. extruder_id=extruder,
  103. nozzle_id="",
  104. nozzle_diameter=nozzle,
  105. filament_id="GFL99",
  106. name=f"Profile {cali_idx}",
  107. k_value=k_value,
  108. n_coef="1.000000",
  109. ams_id=0,
  110. tray_id=-1,
  111. )
  112. class TestSlotKResolver:
  113. def test_a_slot_reads_the_profile_on_its_own_extruder(self):
  114. """The H2C case from #2854: one spool, 0.018 left and 0.020 right.
  115. AMS 0 feeds extruder 1, AMS 1 feeds extruder 0, and calibration index 3
  116. exists on both.
  117. """
  118. resolve = build_slot_k_resolver(
  119. _state(
  120. [_profile(3, "0.020000", "0.4", extruder=0), _profile(3, "0.018000", "0.6", extruder=1)],
  121. nozzles=("0.4", "0.6"),
  122. ams_extruder_map={"0": 1, "1": 0},
  123. )
  124. )
  125. assert resolve(3, 0, 0) == pytest.approx(0.018)
  126. assert resolve(3, 1, 0) == pytest.approx(0.020)
  127. def test_a_swapped_out_nozzles_stale_table_loses_to_the_installed_one(self):
  128. """One extruder, two diameters: only one of them is fitted right now."""
  129. resolve = build_slot_k_resolver(
  130. _state([_profile(3, "0.020000", "0.4"), _profile(3, "0.017000", "0.6")], nozzles=("0.6",))
  131. )
  132. assert resolve(3, 0, 0) == pytest.approx(0.017)
  133. def test_an_index_that_two_installed_nozzles_both_claim_reads_as_unknown(self):
  134. """Blank beats confidently printing the other nozzle's number."""
  135. resolve = build_slot_k_resolver(
  136. _state([_profile(3, "0.020000", "0.4"), _profile(3, "0.017000", "0.6")], nozzles=("0.4", "0.6"))
  137. )
  138. assert resolve(3, 0, 0) is None
  139. def test_a_single_nozzle_printer_resolves_without_an_extruder_map(self):
  140. resolve = build_slot_k_resolver(_state([_profile(3, "0.020000", "0.4")]))
  141. assert resolve(3, 0, 2) == pytest.approx(0.020)
  142. def test_an_uncalibrated_slot_has_no_value(self):
  143. resolve = build_slot_k_resolver(_state([_profile(3, "0.020000", "0.4")]))
  144. assert resolve(None, 0, 0) is None
  145. assert resolve(-1, 0, 0) is None
  146. def test_an_unparseable_k_value_is_skipped_rather_than_raising(self):
  147. resolve = build_slot_k_resolver(_state([_profile(3, "not-a-number", "0.4")]))
  148. assert resolve(3, 0, 0) is None
  149. class TestPrimeKProfileTable:
  150. """Nothing used to read the calibration table on connect.
  151. ``state.kprofiles`` was filled only when someone opened the Profiles page
  152. or Configure Slot, when a GitHub backup ran, or when the printer answered
  153. a query BambuStudio made on the report topic Bambuddy shares. On the
  154. printers whose trays carry no ``k``, a Bambuddy nobody had visited showed
  155. an AMS card with no K values at all.
  156. """
  157. def _printer_state(self, *, nozzles, connected=True):
  158. return SimpleNamespace(
  159. connected=connected,
  160. nozzles=[SimpleNamespace(nozzle_diameter=d) for d in nozzles],
  161. )
  162. async def _prime(self, printer_state, client):
  163. from backend.app import main as main_module
  164. with (
  165. patch.object(main_module.printer_manager, "get_client", return_value=client),
  166. patch.object(main_module.printer_manager, "get_status", return_value=printer_state),
  167. ):
  168. return await main_module.prime_kprofile_table(7)
  169. @pytest.mark.asyncio
  170. async def test_it_asks_for_every_fitted_nozzle(self):
  171. """A dual-nozzle H2 needs both tables: cali_idx is numbered per nozzle."""
  172. client = MagicMock()
  173. client.get_kprofiles = AsyncMock(return_value=[])
  174. primed = await self._prime(self._printer_state(nozzles=("0.4", "0.6")), client)
  175. assert primed == 2
  176. assert [call.kwargs["nozzle_diameter"] for call in client.get_kprofiles.await_args_list] == ["0.4", "0.6"]
  177. @pytest.mark.asyncio
  178. async def test_two_identical_nozzles_are_asked_for_once(self):
  179. client = MagicMock()
  180. client.get_kprofiles = AsyncMock(return_value=[])
  181. primed = await self._prime(self._printer_state(nozzles=("0.4", "0.4")), client)
  182. assert primed == 1
  183. @pytest.mark.asyncio
  184. async def test_it_never_probes_sizes_the_printer_does_not_have(self):
  185. """Blind 0.2/0.4/0.6/0.8 probing is what the backup does, and it is
  186. exactly what used to blank the table."""
  187. client = MagicMock()
  188. client.get_kprofiles = AsyncMock(return_value=[])
  189. await self._prime(self._printer_state(nozzles=("0.6",)), client)
  190. assert [call.kwargs["nozzle_diameter"] for call in client.get_kprofiles.await_args_list] == ["0.6"]
  191. @pytest.mark.asyncio
  192. async def test_no_reported_nozzle_yet_asks_nothing(self):
  193. client = MagicMock()
  194. client.get_kprofiles = AsyncMock(return_value=[])
  195. primed = await self._prime(self._printer_state(nozzles=("",)), client)
  196. assert primed == 0
  197. client.get_kprofiles.assert_not_awaited()
  198. @pytest.mark.asyncio
  199. async def test_a_disconnected_printer_is_left_alone(self):
  200. client = MagicMock()
  201. client.get_kprofiles = AsyncMock(return_value=[])
  202. primed = await self._prime(self._printer_state(nozzles=("0.4",), connected=False), client)
  203. assert primed == 0
  204. client.get_kprofiles.assert_not_awaited()
  205. @pytest.mark.asyncio
  206. async def test_one_nozzle_failing_does_not_cost_the_other_its_table(self):
  207. """This runs on the back of a connection; it may not raise into it."""
  208. client = MagicMock()
  209. client.get_kprofiles = AsyncMock(side_effect=[TimeoutError("no answer"), []])
  210. primed = await self._prime(self._printer_state(nozzles=("0.4", "0.6")), client)
  211. assert primed == 1
  212. class TestPrimeOnConnectEdge:
  213. """The connection's one priming attempt must not be spent too early."""
  214. def _state(self, *, connected=True, state="IDLE", nozzles=("0.4",)):
  215. """A real PrinterState: the handler reads far more of it than this
  216. test cares about, and a stub would only pin the fields I remembered."""
  217. printer_state = PrinterState()
  218. printer_state.connected = connected
  219. printer_state.state = state
  220. printer_state.nozzles = [NozzleInfo(nozzle_diameter=d) for d in nozzles]
  221. return printer_state
  222. async def _edge(self, printer_state, main_module):
  223. with (
  224. patch.object(main_module, "spawn_background_task", side_effect=lambda coro, **kw: coro.close()) as spawn,
  225. patch.object(main_module.ws_manager, "send_printer_status", new=AsyncMock()),
  226. patch.object(main_module, "printer_state_to_dict", return_value={}),
  227. patch.object(main_module.printer_manager, "get_model", return_value="H2D"),
  228. patch.object(main_module.printer_manager, "get_drying_targets", return_value={}),
  229. ):
  230. await main_module.on_printer_status_change(31, printer_state)
  231. return [call.kwargs.get("name", "") for call in spawn.call_args_list]
  232. @pytest.fixture(autouse=True)
  233. def _clean_latches(self):
  234. from backend.app import main as main_module
  235. main_module._printer_kprofiles_primed_since_connect.pop(31, None)
  236. main_module._printer_reconciled_since_connect.pop(31, None)
  237. yield
  238. main_module._printer_kprofiles_primed_since_connect.pop(31, None)
  239. main_module._printer_reconciled_since_connect.pop(31, None)
  240. @pytest.mark.asyncio
  241. async def test_a_connected_printer_with_a_known_nozzle_is_primed(self):
  242. from backend.app import main as main_module
  243. names = await self._edge(self._state(), main_module)
  244. assert any(name.startswith("prime-kprofiles") for name in names)
  245. @pytest.mark.asyncio
  246. async def test_it_is_primed_once_per_connection(self):
  247. from backend.app import main as main_module
  248. await self._edge(self._state(), main_module)
  249. names = await self._edge(self._state(), main_module)
  250. assert not any(name.startswith("prime-kprofiles") for name in names)
  251. @pytest.mark.asyncio
  252. async def test_a_state_that_names_no_nozzle_yet_does_not_spend_the_attempt(self):
  253. """The first push_status makes the state known but need not carry the
  254. nozzle fields. Latching there would leave the table unread all session."""
  255. from backend.app import main as main_module
  256. early = await self._edge(self._state(nozzles=("",)), main_module)
  257. assert not any(name.startswith("prime-kprofiles") for name in early)
  258. later = await self._edge(self._state(), main_module)
  259. assert any(name.startswith("prime-kprofiles") for name in later)
  260. @pytest.mark.asyncio
  261. async def test_a_reconnect_re_arms_it(self):
  262. from backend.app import main as main_module
  263. await self._edge(self._state(), main_module)
  264. await self._edge(self._state(connected=False), main_module)
  265. names = await self._edge(self._state(), main_module)
  266. assert any(name.startswith("prime-kprofiles") for name in names)