test_kprofile_nozzle_buckets_2854.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  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 TestASlotWhoseExtruderClaimsNoProfile:
  150. """#3044: an X2D showed K on its first AMS and nothing on its second.
  151. The printer does not always file a profile per hotend. In the reporter's
  152. capture the second AMS's slots pointed at the same table entries as the
  153. first -- B1 read the K of A4, B3 the K of A1 -- and those entries carry one
  154. extruder. Requiring the slot's own extruder to match therefore found
  155. nothing for every slot on the right-hand AMS.
  156. BambuStudio, filling the same card, does not scope by extruder at all:
  157. ``AMSItem.cpp`` resolves through ``get_pa_k_n_value_by_cali_idx``, which
  158. takes the first entry with a matching ``cali_idx``.
  159. """
  160. def test_a_shared_profile_resolves_on_the_other_extruder(self):
  161. resolve = build_slot_k_resolver(
  162. _state(
  163. [_profile(3, "0.021000", "0.4", extruder=0)],
  164. ams_extruder_map={"0": 0, "1": 1},
  165. )
  166. )
  167. assert resolve(3, 0, 0) == pytest.approx(0.021)
  168. assert resolve(3, 1, 0) == pytest.approx(0.021)
  169. def test_the_slots_own_extruder_still_wins_over_the_fallback(self):
  170. """The H2C case has to keep winning: the fallback is a last resort, not
  171. a replacement. Index 3 exists on both hotends with different K."""
  172. resolve = build_slot_k_resolver(
  173. _state(
  174. [_profile(3, "0.020000", "0.4", extruder=0), _profile(3, "0.018000", "0.4", extruder=1)],
  175. ams_extruder_map={"0": 1, "1": 0},
  176. )
  177. )
  178. assert resolve(3, 0, 0) == pytest.approx(0.018)
  179. assert resolve(3, 1, 0) == pytest.approx(0.020)
  180. def test_two_entries_agreeing_on_one_k_is_not_an_ambiguity(self):
  181. """Both hotends calibrated to the same number says nothing is in doubt."""
  182. resolve = build_slot_k_resolver(
  183. _state(
  184. [_profile(3, "0.021000", "0.4", extruder=0), _profile(3, "0.021000", "0.6", extruder=0)],
  185. nozzles=("0.4", "0.6"),
  186. ams_extruder_map={"0": 1},
  187. )
  188. )
  189. assert resolve(3, 0, 0) == pytest.approx(0.021)
  190. def test_the_fallback_still_prefers_the_nozzle_that_is_fitted(self):
  191. """A table left behind by a swapped-out nozzle loses to the live one."""
  192. resolve = build_slot_k_resolver(
  193. _state(
  194. [_profile(3, "0.020000", "0.4", extruder=0), _profile(3, "0.017000", "0.6", extruder=0)],
  195. nozzles=("0.6",),
  196. ams_extruder_map={"0": 1},
  197. )
  198. )
  199. assert resolve(3, 0, 0) == pytest.approx(0.017)
  200. def test_the_fallback_refuses_when_the_candidates_disagree(self):
  201. """Blank still beats confidently printing one of two different numbers."""
  202. resolve = build_slot_k_resolver(
  203. _state(
  204. [_profile(3, "0.020000", "0.4", extruder=0), _profile(3, "0.017000", "0.6", extruder=0)],
  205. nozzles=("0.4", "0.6"),
  206. ams_extruder_map={"0": 1},
  207. )
  208. )
  209. assert resolve(3, 0, 0) is None
  210. def test_a_hotend_with_its_own_profiles_does_not_borrow_the_others(self):
  211. """The H2C guard, which the fallback must not reopen.
  212. Index 16 is the left hotend's entry and index 15 the right's. A
  213. right-hand slot bound to 16 means "entry 16 of the right nozzle's
  214. table", which this printer does not have -- and the left's entry 16 is
  215. a different profile, not a stand-in. Blank is the honest answer.
  216. """
  217. resolve = build_slot_k_resolver(
  218. _state(
  219. [_profile(16, "0.018000", "0.4", extruder=1), _profile(15, "0.020000", "0.4", extruder=0)],
  220. ams_extruder_map={"0": 0, "1": 1},
  221. )
  222. )
  223. assert resolve(16, 0, 0) is None
  224. assert resolve(15, 0, 0) == pytest.approx(0.020)
  225. assert resolve(16, 1, 0) == pytest.approx(0.018)
  226. def test_an_index_no_profile_holds_is_still_nothing(self):
  227. resolve = build_slot_k_resolver(_state([_profile(3, "0.020000", "0.4", extruder=0)], ams_extruder_map={"0": 1}))
  228. assert resolve(9, 0, 0) is None
  229. class TestPrimeKProfileTable:
  230. """Nothing used to read the calibration table on connect.
  231. ``state.kprofiles`` was filled only when someone opened the Profiles page
  232. or Configure Slot, when a GitHub backup ran, or when the printer answered
  233. a query BambuStudio made on the report topic Bambuddy shares. On the
  234. printers whose trays carry no ``k``, a Bambuddy nobody had visited showed
  235. an AMS card with no K values at all.
  236. """
  237. def _printer_state(self, *, nozzles, connected=True):
  238. return SimpleNamespace(
  239. connected=connected,
  240. nozzles=[SimpleNamespace(nozzle_diameter=d) for d in nozzles],
  241. )
  242. async def _prime(self, printer_state, client):
  243. from backend.app import main as main_module
  244. with (
  245. patch.object(main_module.printer_manager, "get_client", return_value=client),
  246. patch.object(main_module.printer_manager, "get_status", return_value=printer_state),
  247. ):
  248. return await main_module.prime_kprofile_table(7)
  249. @pytest.mark.asyncio
  250. async def test_it_asks_for_every_fitted_nozzle(self):
  251. """A dual-nozzle H2 needs both tables: cali_idx is numbered per nozzle."""
  252. client = MagicMock()
  253. client.get_kprofiles = AsyncMock(return_value=[])
  254. primed = await self._prime(self._printer_state(nozzles=("0.4", "0.6")), client)
  255. assert primed == 2
  256. assert [call.kwargs["nozzle_diameter"] for call in client.get_kprofiles.await_args_list] == ["0.4", "0.6"]
  257. @pytest.mark.asyncio
  258. async def test_two_identical_nozzles_are_asked_for_once(self):
  259. client = MagicMock()
  260. client.get_kprofiles = AsyncMock(return_value=[])
  261. primed = await self._prime(self._printer_state(nozzles=("0.4", "0.4")), client)
  262. assert primed == 1
  263. @pytest.mark.asyncio
  264. async def test_it_never_probes_sizes_the_printer_does_not_have(self):
  265. """Blind 0.2/0.4/0.6/0.8 probing is what the backup does, and it is
  266. exactly what used to blank the table."""
  267. client = MagicMock()
  268. client.get_kprofiles = AsyncMock(return_value=[])
  269. await self._prime(self._printer_state(nozzles=("0.6",)), client)
  270. assert [call.kwargs["nozzle_diameter"] for call in client.get_kprofiles.await_args_list] == ["0.6"]
  271. @pytest.mark.asyncio
  272. async def test_no_reported_nozzle_yet_asks_nothing(self):
  273. client = MagicMock()
  274. client.get_kprofiles = AsyncMock(return_value=[])
  275. primed = await self._prime(self._printer_state(nozzles=("",)), client)
  276. assert primed == 0
  277. client.get_kprofiles.assert_not_awaited()
  278. @pytest.mark.asyncio
  279. async def test_a_disconnected_printer_is_left_alone(self):
  280. client = MagicMock()
  281. client.get_kprofiles = AsyncMock(return_value=[])
  282. primed = await self._prime(self._printer_state(nozzles=("0.4",), connected=False), client)
  283. assert primed == 0
  284. client.get_kprofiles.assert_not_awaited()
  285. @pytest.mark.asyncio
  286. async def test_one_nozzle_failing_does_not_cost_the_other_its_table(self):
  287. """This runs on the back of a connection; it may not raise into it."""
  288. client = MagicMock()
  289. client.get_kprofiles = AsyncMock(side_effect=[TimeoutError("no answer"), []])
  290. primed = await self._prime(self._printer_state(nozzles=("0.4", "0.6")), client)
  291. assert primed == 1
  292. class TestPrimeOnConnectEdge:
  293. """The connection's one priming attempt must not be spent too early."""
  294. def _state(self, *, connected=True, state="IDLE", nozzles=("0.4",)):
  295. """A real PrinterState: the handler reads far more of it than this
  296. test cares about, and a stub would only pin the fields I remembered."""
  297. printer_state = PrinterState()
  298. printer_state.connected = connected
  299. printer_state.state = state
  300. printer_state.nozzles = [NozzleInfo(nozzle_diameter=d) for d in nozzles]
  301. return printer_state
  302. async def _edge(self, printer_state, main_module):
  303. with (
  304. patch.object(main_module, "spawn_background_task", side_effect=lambda coro, **kw: coro.close()) as spawn,
  305. patch.object(main_module.ws_manager, "send_printer_status", new=AsyncMock()),
  306. patch.object(main_module, "printer_state_to_dict", return_value={}),
  307. patch.object(main_module.printer_manager, "get_model", return_value="H2D"),
  308. patch.object(main_module.printer_manager, "get_drying_targets", return_value={}),
  309. ):
  310. await main_module.on_printer_status_change(31, printer_state)
  311. return [call.kwargs.get("name", "") for call in spawn.call_args_list]
  312. @pytest.fixture(autouse=True)
  313. def _clean_latches(self):
  314. from backend.app import main as main_module
  315. main_module._printer_kprofiles_primed_since_connect.pop(31, None)
  316. main_module._printer_reconciled_since_connect.pop(31, None)
  317. yield
  318. main_module._printer_kprofiles_primed_since_connect.pop(31, None)
  319. main_module._printer_reconciled_since_connect.pop(31, None)
  320. @pytest.mark.asyncio
  321. async def test_a_connected_printer_with_a_known_nozzle_is_primed(self):
  322. from backend.app import main as main_module
  323. names = await self._edge(self._state(), main_module)
  324. assert any(name.startswith("prime-kprofiles") for name in names)
  325. @pytest.mark.asyncio
  326. async def test_it_is_primed_once_per_connection(self):
  327. from backend.app import main as main_module
  328. await self._edge(self._state(), main_module)
  329. names = await self._edge(self._state(), main_module)
  330. assert not any(name.startswith("prime-kprofiles") for name in names)
  331. @pytest.mark.asyncio
  332. async def test_a_state_that_names_no_nozzle_yet_does_not_spend_the_attempt(self):
  333. """The first push_status makes the state known but need not carry the
  334. nozzle fields. Latching there would leave the table unread all session."""
  335. from backend.app import main as main_module
  336. early = await self._edge(self._state(nozzles=("",)), main_module)
  337. assert not any(name.startswith("prime-kprofiles") for name in early)
  338. later = await self._edge(self._state(), main_module)
  339. assert any(name.startswith("prime-kprofiles") for name in later)
  340. @pytest.mark.asyncio
  341. async def test_a_reconnect_re_arms_it(self):
  342. from backend.app import main as main_module
  343. await self._edge(self._state(), main_module)
  344. await self._edge(self._state(connected=False), main_module)
  345. names = await self._edge(self._state(), main_module)
  346. assert any(name.startswith("prime-kprofiles") for name in names)