test_fts_kprofile_routing.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. """K-profiles follow an AMS when it moves between Filament Track Switch inlets.
  2. Measured on the maintainer's H2C, 2026-08-16. Spool 75, "HF Bambu PLA Matte
  3. Black", is calibrated on both hotends and Bambuddy stores both:
  4. extruder 1 (left) K 0.018 cali_idx 16
  5. extruder 0 (right) K 0.020 cali_idx 15
  6. A tray holds exactly one ``cali_idx``, and the printer's calibration table is
  7. numbered per nozzle — so index 16 exists on both hotends and means a different
  8. profile on each. Moving that AMS from In-A to In-B therefore leaves the slot
  9. bound to the *left* profile while it now feeds the right nozzle. The printer
  10. does not re-resolve it, and an RFID re-read only re-asserts the same wrong
  11. index. That is the state the live printer was in when this was written:
  12. AMS 1 info=10002E03 inlet=IN-B tray 0: PLA 000000 cali_idx=16
  13. """
  14. from unittest.mock import AsyncMock, MagicMock, patch
  15. import pytest
  16. from backend.app.utils.fts_routing import FTS_INLET_EXTRUDER, extruder_for_inlet, slot_extruder
  17. class TestInletToNozzle:
  18. def test_in_a_is_the_left_hotend(self):
  19. assert extruder_for_inlet("A") == 1
  20. def test_in_b_is_the_right_hotend(self):
  21. assert extruder_for_inlet("B") == 0
  22. @pytest.mark.parametrize("value", [None, "", "C", "left"])
  23. def test_anything_else_is_unknown(self, value):
  24. assert extruder_for_inlet(value) is None
  25. def test_the_two_inlets_do_not_share_a_nozzle(self):
  26. """A table that mapped both inlets to one hotend would silently bind
  27. every slot to the same K-profile."""
  28. assert sorted(FTS_INLET_EXTRUDER.values()) == [0, 1]
  29. class TestSlotExtruder:
  30. def test_a_real_extruder_id_wins(self):
  31. """A non-0xE binding is authoritative even on a machine with a switch,
  32. which is how BambuStudio treats it too."""
  33. assert slot_extruder(2, 0, {"2": 1}, {"2": "B"}) == 1
  34. def test_falls_back_to_the_switch_inlet(self):
  35. """The FTS case: every AMS reports 0xE, so the map is empty."""
  36. assert slot_extruder(1, 0, {}, {"1": "B"}) == 0
  37. assert slot_extruder(2, 3, {}, {"2": "A"}) == 1
  38. def test_unknown_is_none_not_zero(self):
  39. """The bug this replaced: three call sites ended in `else 0`, which on a
  40. switch machine filed every profile under the right-hand nozzle."""
  41. assert slot_extruder(1, 0, {}, {}) is None
  42. assert slot_extruder(1, 0, None, None) is None
  43. def test_external_slots_name_their_own_side(self):
  44. assert slot_extruder(255, 0, {}, {}) == 1 # Ext-L
  45. assert slot_extruder(255, 1, {}, {}) == 0 # Ext-R
  46. def test_an_unmapped_ams_does_not_borrow_another_units_inlet(self):
  47. assert slot_extruder(3, 0, {}, {"1": "A", "2": "B"}) is None
  48. def test_the_maintainers_h2c(self):
  49. """The live layout: AMS 0/1/128 on In-B, AMS 2 on In-A, empty map."""
  50. inlets = {"0": "B", "1": "B", "2": "A", "128": "B"}
  51. assert slot_extruder(0, 0, {}, inlets) == 0
  52. assert slot_extruder(1, 0, {}, inlets) == 0
  53. assert slot_extruder(2, 1, {}, inlets) == 1
  54. assert slot_extruder(128, 0, {}, inlets) == 0
  55. def _profile(cali_idx: int, extruder: int, k: float):
  56. return MagicMock(
  57. cali_idx=cali_idx, extruder=extruder, k_value=k, name="HF Bambu PLA Matte Black", filament_id="GFA01"
  58. )
  59. class TestReSelectOnInletMove:
  60. """The callback that re-points a moved AMS's slots."""
  61. @staticmethod
  62. def _run(inlet, tray, profile, *, connected=True):
  63. """Drive on_fts_inlet_change for one AMS holding one tray."""
  64. from backend.app import main as main_module
  65. client = MagicMock()
  66. client.extrusion_cali_sel = MagicMock(return_value=True)
  67. state = MagicMock()
  68. state.raw_data = {"ams": [{"id": "1", "tray": [tray]}]}
  69. state.nozzles = [MagicMock(nozzle_diameter="0.4")]
  70. pm = MagicMock()
  71. pm.get_client.return_value = client if connected else None
  72. pm.get_status.return_value = state
  73. session = AsyncMock()
  74. session.__aenter__ = AsyncMock(return_value=session)
  75. session.__aexit__ = AsyncMock()
  76. with (
  77. patch("backend.app.main.printer_manager", pm),
  78. patch("backend.app.main.async_session", return_value=session),
  79. patch(
  80. "backend.app.main.find_slot_kprofile_for_extruder",
  81. new=AsyncMock(return_value=profile),
  82. ) as lookup,
  83. ):
  84. import asyncio
  85. asyncio.run(main_module.on_fts_inlet_change(7, 1, inlet))
  86. return client, lookup
  87. def test_moving_to_in_b_selects_the_right_hotends_profile(self):
  88. """The reported case, with the maintainer's real numbers."""
  89. tray = {"id": "0", "tray_type": "PLA", "cali_idx": 16, "tray_info_idx": "GFA01"}
  90. client, lookup = self._run("B", tray, _profile(15, 0, 0.020))
  91. assert lookup.await_args.args[4] == 0, "must look up the RIGHT hotend's profile"
  92. client.extrusion_cali_sel.assert_called_once()
  93. assert client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 15
  94. def test_moving_to_in_a_selects_the_left_hotends_profile(self):
  95. tray = {"id": "0", "tray_type": "PLA", "cali_idx": 15, "tray_info_idx": "GFA01"}
  96. client, lookup = self._run("A", tray, _profile(16, 1, 0.018))
  97. assert lookup.await_args.args[4] == 1
  98. assert client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 16
  99. def test_an_already_correct_slot_is_left_alone(self):
  100. """No point re-sending a binding the printer already holds, and every
  101. avoided write is one less chance of the firmware mislinking it."""
  102. tray = {"id": "0", "tray_type": "PLA", "cali_idx": 15, "tray_info_idx": "GFA01"}
  103. client, _ = self._run("B", tray, _profile(15, 0, 0.020))
  104. client.extrusion_cali_sel.assert_not_called()
  105. def test_a_spool_with_no_profile_for_that_nozzle_is_untouched(self):
  106. """Calibrated on one hotend only: keep whatever the operator set by hand
  107. rather than swapping it for a guess."""
  108. tray = {"id": "0", "tray_type": "PLA", "cali_idx": 16, "tray_info_idx": "GFA01"}
  109. client, _ = self._run("B", tray, None)
  110. client.extrusion_cali_sel.assert_not_called()
  111. def test_an_empty_slot_is_skipped(self):
  112. tray = {"id": "0", "tray_type": "", "cali_idx": -1}
  113. client, lookup = self._run("B", tray, _profile(15, 0, 0.020))
  114. lookup.assert_not_awaited()
  115. client.extrusion_cali_sel.assert_not_called()
  116. def test_a_disconnected_printer_is_a_no_op(self):
  117. tray = {"id": "0", "tray_type": "PLA", "cali_idx": 16, "tray_info_idx": "GFA01"}
  118. client, _ = self._run("B", tray, _profile(15, 0, 0.020), connected=False)
  119. client.extrusion_cali_sel.assert_not_called()
  120. def test_an_unknown_inlet_is_a_no_op(self):
  121. tray = {"id": "0", "tray_type": "PLA", "cali_idx": 16, "tray_info_idx": "GFA01"}
  122. client, lookup = self._run("C", tray, _profile(15, 0, 0.020))
  123. lookup.assert_not_awaited()
  124. client.extrusion_cali_sel.assert_not_called()
  125. class TestTheMoveIsDetected:
  126. """The MQTT side: only a genuine move fires the callback."""
  127. @pytest.fixture
  128. def mqtt_client(self):
  129. from backend.app.services.bambu_mqtt import BambuMQTTClient
  130. return BambuMQTTClient(ip_address="192.168.1.100", serial_number="TEST123", access_code="12345678")
  131. @staticmethod
  132. def _info(inlet_bits: int) -> str:
  133. return f"{(inlet_bits << 24) | (0xE << 8) | 1:08X}"
  134. def _push(self, client, inlet_bits):
  135. client._process_message(
  136. {
  137. "print": {
  138. "gcode_state": "IDLE",
  139. "device": {"fila_switch": {"in": [-1, -1], "out": [1, 1], "stat": 1, "info": 0}},
  140. "ams": {"ams": [{"id": "1", "info": self._info(inlet_bits), "tray": []}]},
  141. }
  142. }
  143. )
  144. def test_a_move_fires_once(self, mqtt_client):
  145. seen = []
  146. mqtt_client.on_fts_inlet_change = lambda ams_id, inlet: seen.append((ams_id, inlet))
  147. self._push(mqtt_client, 1) # In-A
  148. self._push(mqtt_client, 0) # In-B
  149. assert seen == [(1, "B")]
  150. def test_the_first_sighting_does_not_fire(self, mqtt_client):
  151. """Every reconnect learns the bindings afresh. Re-applying K-profiles
  152. there would fight a binding the operator set deliberately."""
  153. seen = []
  154. mqtt_client.on_fts_inlet_change = lambda ams_id, inlet: seen.append((ams_id, inlet))
  155. self._push(mqtt_client, 1)
  156. assert seen == []
  157. def test_repeated_frames_do_not_fire(self, mqtt_client):
  158. seen = []
  159. mqtt_client.on_fts_inlet_change = lambda ams_id, inlet: seen.append((ams_id, inlet))
  160. for _ in range(4):
  161. self._push(mqtt_client, 1)
  162. assert seen == []
  163. def test_moving_back_fires_again(self, mqtt_client):
  164. seen = []
  165. mqtt_client.on_fts_inlet_change = lambda ams_id, inlet: seen.append((ams_id, inlet))
  166. self._push(mqtt_client, 1)
  167. self._push(mqtt_client, 0)
  168. self._push(mqtt_client, 1)
  169. assert seen == [(1, "B"), (1, "A")]