test_a2l_ams_lite_2619.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. """A2L "AMS Lite" unit-id normalisation (memory a2l-am-unit-16).
  2. The A2L reports its 4-slot AMS Lite as physical unit id 16, but its tray
  3. bitmasks sit at bit base 24 (= id 6) and it reports tray_now as a local 0-3
  4. slot. We normalise 16 -> 6 at the MQTT ingest boundary so global tray ids land
  5. at 24-27 and every ams_id*4+slot consumer works unchanged, and translate back to
  6. the physical id 16 only on the outbound wire.
  7. Field values here mirror the confirmed capture (2026-07-20): physical slots 1
  8. empty, 2 & 3 loaded, 4 empty; tray_exist_bits "6000000"; tray_now "2" while
  9. printing physical slot 3.
  10. """
  11. import json
  12. from unittest.mock import MagicMock
  13. from backend.app.services.bambu_mqtt import (
  14. A2L_LITE_GLOBAL_BASE,
  15. A2L_LITE_NORMALIZED_AMS_ID,
  16. A2L_LITE_PHYSICAL_AMS_ID,
  17. BambuMQTTClient,
  18. a2l_lite_wire_ids,
  19. normalize_am_unit_id,
  20. )
  21. def _client(model: str = "A2L") -> BambuMQTTClient:
  22. return BambuMQTTClient(ip_address="10.0.0.1", serial_number="A2L", access_code="c", model=model)
  23. def _wired(client: BambuMQTTClient) -> BambuMQTTClient:
  24. client._client = MagicMock()
  25. client.state.connected = True
  26. return client
  27. def _capture_frame() -> dict:
  28. """One push_status frame matching Mike's 2026-07-20 capture."""
  29. return {
  30. "ams": [
  31. {
  32. "id": 16,
  33. "tray": [
  34. {"id": 0},
  35. {
  36. "id": 1,
  37. "state": 3,
  38. "remain": 100,
  39. "tray_type": "",
  40. "tray_info_idx": "",
  41. "tray_color": "FFFFFF00",
  42. },
  43. {
  44. "id": 2,
  45. "state": 3,
  46. "remain": 100,
  47. "tray_type": "",
  48. "tray_info_idx": "",
  49. "tray_color": "FFFFFF00",
  50. },
  51. {"id": 3},
  52. ],
  53. }
  54. ],
  55. "ams_exist_bits": "1000",
  56. "tray_exist_bits": "6000000",
  57. "tray_now": "2",
  58. "tray_pre": "2",
  59. "tray_tar": "2",
  60. }
  61. def _last_payload(client: BambuMQTTClient) -> dict:
  62. return json.loads(client._client.publish.call_args[0][1])["print"]
  63. class TestHelpers:
  64. def test_normalize_touches_only_16(self):
  65. assert normalize_am_unit_id(A2L_LITE_PHYSICAL_AMS_ID) == A2L_LITE_NORMALIZED_AMS_ID
  66. for other in (0, 1, 2, 3, 6, 15, 128, 135, 254, 255):
  67. assert normalize_am_unit_id(other) == other
  68. def test_wire_ids_only_for_normalised_6(self):
  69. # (physical ams id, local slot, physical global tray)
  70. assert a2l_lite_wire_ids(6, 2) == (16, 2, 66)
  71. assert a2l_lite_wire_ids(6, 0) == (16, 0, 64)
  72. # tray_id is taken modulo 4, so a global tray works too.
  73. assert a2l_lite_wire_ids(6, 26) == (16, 2, 66)
  74. # Any other unit id is left alone (returns None).
  75. for ams in (0, 3, 16, 128, 255):
  76. assert a2l_lite_wire_ids(ams, 2) is None
  77. class TestIngestNormalisation:
  78. def test_unit_id_16_normalised_to_6(self):
  79. client = _client()
  80. client._handle_ams_data(_capture_frame())
  81. assert client.state.raw_data["ams"][0]["id"] == A2L_LITE_NORMALIZED_AMS_ID
  82. assert client._has_a2l_am_unit is True
  83. def test_exists_annotation_uses_bit_base_24(self):
  84. # tray_exist_bits "6000000" = bits 25,26 -> global_bit 24+slot -> slots 1,2.
  85. client = _client()
  86. client._handle_ams_data(_capture_frame())
  87. trays = {t["id"]: t for t in client.state.raw_data["ams"][0]["tray"]}
  88. assert trays[1]["exists"] is True
  89. assert trays[2]["exists"] is True
  90. assert trays[0]["exists"] is False
  91. assert trays[3]["exists"] is False
  92. def test_regular_ams_untouched(self):
  93. client = _client(model="X1C")
  94. frame = {
  95. "ams": [{"id": 0, "tray": [{"id": 0}, {"id": 1}, {"id": 2}, {"id": 3}]}],
  96. "tray_exist_bits": "3",
  97. "tray_now": "1",
  98. }
  99. client._handle_ams_data(frame)
  100. assert client.state.raw_data["ams"][0]["id"] == 0
  101. assert client._has_a2l_am_unit is False
  102. assert client.state.tray_now == 1 # regular AMS 0 slot 1 == global 1
  103. def test_bare_list_ams_shape_is_also_normalised(self):
  104. # Some firmware/shapes deliver the unit list directly (no dict wrapper).
  105. client = _client()
  106. client._handle_ams_data([{"id": 16, "tray": [{"id": 0}, {"id": 1}, {"id": 2}, {"id": 3}]}])
  107. assert client.state.raw_data["ams"][0]["id"] == A2L_LITE_NORMALIZED_AMS_ID
  108. assert client._has_a2l_am_unit is True
  109. class TestTrayNowGlobalisation:
  110. def test_local_tray_now_globalised_to_24_plus_slot(self):
  111. client = _client()
  112. client._handle_ams_data(_capture_frame())
  113. # local slot 2 -> global 26 (24 + 2)
  114. assert client.state.tray_now == A2L_LITE_GLOBAL_BASE + 2 == 26
  115. def test_globalised_tray_passes_last_valid_guard(self):
  116. # last_loaded_tray is only written when the valid-tray guard accepts tn.
  117. client = _client()
  118. client._handle_ams_data(_capture_frame())
  119. assert client.state.last_loaded_tray == 26
  120. class TestOutboundTranslation:
  121. def test_set_filament_setting_uses_physical_16_local_slot(self):
  122. client = _wired(_client())
  123. assert client.ams_set_filament_setting(
  124. ams_id=6,
  125. tray_id=2,
  126. tray_info_idx="GFL05",
  127. tray_type="PLA",
  128. tray_sub_brands="PLA Basic",
  129. tray_color="FF0000FF",
  130. nozzle_temp_min=190,
  131. nozzle_temp_max=230,
  132. )
  133. p = _last_payload(client)
  134. assert p["ams_id"] == A2L_LITE_PHYSICAL_AMS_ID
  135. assert p["tray_id"] == 2
  136. assert p["slot_id"] == 2
  137. def test_reset_slot_uses_physical_16_local_slot(self):
  138. client = _wired(_client())
  139. assert client.reset_ams_slot(ams_id=6, tray_id=3)
  140. p = _last_payload(client)
  141. assert p["ams_id"] == A2L_LITE_PHYSICAL_AMS_ID
  142. assert p["tray_id"] == 3
  143. assert p["slot_id"] == 3
  144. def test_cali_sel_uses_physical_global_tray(self):
  145. client = _wired(_client())
  146. assert client.extrusion_cali_sel(ams_id=6, tray_id=2, cali_idx=1, filament_id="GFL05")
  147. p = _last_payload(client)
  148. assert p["ams_id"] == A2L_LITE_PHYSICAL_AMS_ID
  149. assert p["tray_id"] == 66 # 16*4 + 2 (extrapolated physical global)
  150. assert p["slot_id"] == 2
  151. def test_cali_set_remaps_global_tray(self):
  152. client = _wired(_client())
  153. assert client.extrusion_cali_set(tray_id=26, k_value=0.02, filament_id="GFL05")
  154. p = _last_payload(client)
  155. assert p["filaments"][0]["tray_id"] == 66 # 26 (normalised) -> 66 (physical)
  156. def test_load_filament_target_and_ams(self):
  157. client = _wired(_client())
  158. assert client.ams_load_filament(tray_id=26)
  159. p = _last_payload(client)
  160. assert p["ams_id"] == A2L_LITE_PHYSICAL_AMS_ID
  161. assert p["slot_id"] == 2
  162. assert p["target"] == 66
  163. def test_unload_uses_physical_ams(self):
  164. client = _wired(_client())
  165. client.state.tray_now = 26
  166. assert client.ams_unload_filament()
  167. assert _last_payload(client)["ams_id"] == A2L_LITE_PHYSICAL_AMS_ID
  168. def test_refresh_tray_uses_physical_16(self):
  169. client = _wired(_client())
  170. client.state.tray_now = 255 # nothing loaded, so refresh is allowed
  171. ok, _ = client.ams_refresh_tray(ams_id=6, tray_id=2)
  172. assert ok
  173. p = _last_payload(client)
  174. assert p["ams_id"] == A2L_LITE_PHYSICAL_AMS_ID
  175. assert p["slot_id"] == 2
  176. def test_drying_uses_physical_16(self):
  177. client = _wired(_client())
  178. assert client.send_drying_command(ams_id=6, temp=55, duration=4, mode=1, filament="PLA")
  179. assert _last_payload(client)["ams_id"] == A2L_LITE_PHYSICAL_AMS_ID
  180. def test_regular_ams_command_unchanged(self):
  181. client = _wired(_client(model="X1C"))
  182. assert client.ams_set_filament_setting(
  183. ams_id=0,
  184. tray_id=2,
  185. tray_info_idx="GFL05",
  186. tray_type="PLA",
  187. tray_sub_brands="PLA Basic",
  188. tray_color="FF0000FF",
  189. nozzle_temp_min=190,
  190. nozzle_temp_max=230,
  191. )
  192. p = _last_payload(client)
  193. assert p["ams_id"] == 0
  194. assert p["tray_id"] == 2