test_tray_ids.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. """Bambu Studio's global tray numbering (utils.tray_ids), for every AMS kind.
  2. Studio (DevFilaSystem::GetTrayIndexMap, DevAmsTray::get_nozzle_addr_id):
  3. regular AMS ams_id*4+slot, A2L AMS Lite 24+slot, AMS 3H 28+slot, AMS-B
  4. 37+slot, AMS-HT its unit id, external inputs their own vir_slot id -- 254/255,
  5. and 247-255 on a printer with a direct-connected nozzle rack, where input v
  6. feeds hotend 255-v.
  7. """
  8. import pytest
  9. from backend.app.utils.tray_ids import (
  10. AmsType,
  11. TrayLayout,
  12. ams_type_from_info,
  13. decode_wire_slot,
  14. external_hotend,
  15. external_slot_index,
  16. external_tray_id,
  17. global_tray_id,
  18. is_external_tray,
  19. is_rack_ams,
  20. legacy_tray_index,
  21. mapped_trays_with_slot,
  22. tray_flag_bit,
  23. unit_ams_type,
  24. unit_label,
  25. )
  26. # The H3C's AMS 3H as the printer reports it (info captured 2026-11-06).
  27. H3C_UNITS = [{"id": "32", "info": "240842026", "tray": [{"id": str(k)} for k in range(9)]}]
  28. H3C_VT = [{"id": str(v)} for v in range(247, 256)]
  29. H3C = TrayLayout.from_units(H3C_UNITS, H3C_VT)
  30. class TestAmsType:
  31. @pytest.mark.parametrize(
  32. ("info", "kind"),
  33. [
  34. ("10001001", AmsType.AMS),
  35. ("2002", AmsType.AMS_LITE),
  36. ("10003", AmsType.N3F),
  37. ("1104", AmsType.N3S),
  38. ("5", AmsType.AMS_LITE_MIXED),
  39. ("240842026", AmsType.ZMULTI_A),
  40. ("240002006", AmsType.ZMULTI_A),
  41. ("7", AmsType.ZMULTI_B),
  42. ],
  43. )
  44. def test_low_four_bits_of_the_hex_info(self, info, kind):
  45. assert ams_type_from_info(info) == kind
  46. @pytest.mark.parametrize("info", [None, "", "zz", True, 3.5])
  47. def test_unreadable_info_is_none(self, info):
  48. assert ams_type_from_info(info) is None
  49. def test_type_told_from_the_id_without_info(self):
  50. assert unit_ams_type(0) == AmsType.AMS
  51. assert unit_ams_type(3) == AmsType.AMS
  52. assert unit_ams_type(128) == AmsType.N3S
  53. assert unit_ams_type(135) == AmsType.N3S
  54. assert unit_ams_type(32) == AmsType.ZMULTI_A
  55. def test_info_outranks_the_id_except_for_an_ams_ht(self):
  56. assert unit_ams_type(32, "7") == AmsType.ZMULTI_B
  57. assert unit_ams_type(128, "1") == AmsType.N3S
  58. def test_rack_types(self):
  59. assert is_rack_ams(AmsType.ZMULTI_A) and is_rack_ams(AmsType.ZMULTI_B)
  60. assert not any(is_rack_ams(k) for k in (AmsType.AMS, AmsType.N3F, AmsType.N3S, AmsType.AMS_LITE_MIXED))
  61. class TestGlobalTrayId:
  62. @pytest.mark.parametrize(
  63. ("ams_id", "slot", "kind", "expected"),
  64. [
  65. (0, 0, AmsType.AMS, 0),
  66. (1, 2, AmsType.AMS, 6),
  67. (3, 3, AmsType.N3F, 15),
  68. (6, 1, AmsType.AMS_LITE, 25), # the A2L's AMS Lite, stored as unit 6
  69. (16, 1, AmsType.AMS_LITE_MIXED, 25), # ...and Studio's own numbering of its unit 16
  70. (32, 0, AmsType.ZMULTI_A, 28),
  71. (32, 8, AmsType.ZMULTI_A, 36),
  72. (33, 0, AmsType.ZMULTI_B, 37),
  73. (33, 4, AmsType.ZMULTI_B, 41),
  74. (128, 0, AmsType.N3S, 128),
  75. (135, 0, AmsType.N3S, 135),
  76. ],
  77. )
  78. def test_every_ams_kind(self, ams_id, slot, kind, expected):
  79. assert global_tray_id(ams_id, slot, kind) == expected
  80. def test_without_a_type_regular_ht_and_the_ams3h_by_their_ids(self):
  81. assert [global_tray_id(1, s) for s in range(4)] == [4, 5, 6, 7]
  82. assert global_tray_id(129, 0) == 129
  83. assert [global_tray_id(32, s) for s in range(9)] == list(range(28, 37))
  84. def test_the_ams3h_no_longer_lands_in_the_ams_ht_range(self):
  85. ams3h = {global_tray_id(32, s) for s in range(9)}
  86. assert ams3h.isdisjoint(range(128, 136))
  87. assert ams3h.isdisjoint(range(0, 16))
  88. class TestLayout:
  89. def test_h3c_is_a_rack_printer(self):
  90. assert H3C.rack
  91. assert H3C.rack_units == ((32, AmsType.ZMULTI_A),)
  92. assert H3C.ams_type(32) == AmsType.ZMULTI_A
  93. def test_both_directions_on_the_h3c(self):
  94. for slot in range(9):
  95. tray = H3C.global_tray_id(32, slot)
  96. assert tray == 28 + slot
  97. assert H3C.slot_key(tray) == (32, slot)
  98. assert H3C.tray_from_slot_key(32, slot) == tray
  99. def test_both_directions_on_a_regular_printer(self):
  100. layout = TrayLayout.from_units([{"id": "0", "info": "1001"}, {"id": "1", "info": "1001"}, {"id": "128"}])
  101. assert not layout.rack
  102. for ams_id in (0, 1):
  103. for slot in range(4):
  104. tray = layout.global_tray_id(ams_id, slot)
  105. assert tray == ams_id * 4 + slot
  106. assert layout.slot_key(tray) == (ams_id, slot)
  107. assert layout.tray_from_slot_key(ams_id, slot) == tray
  108. assert layout.global_tray_id(128, 0) == 128
  109. assert layout.slot_key(128) == (128, 0)
  110. def test_without_live_data_both_directions_agree(self):
  111. # No AMS data at all: 28-36 are the AMS 3H's (unit 32) and 247-253 the
  112. # per-hotend inputs, the same way round as tray_from_slot_key numbers them.
  113. empty = TrayLayout()
  114. for slot in range(9):
  115. assert empty.tray_from_slot_key(32, slot) == 28 + slot
  116. assert empty.slot_key(28 + slot) == (32, slot)
  117. for index in range(9):
  118. assert empty.slot_key(empty.tray_from_slot_key(255, index)) == (255, index)
  119. def test_a_listed_regular_unit_7_keeps_28_to_31(self):
  120. layout = TrayLayout.from_units([{"id": "7", "info": "1001"}])
  121. assert layout.slot_key(28) == (7, 0)
  122. assert layout.slot_key(31) == (7, 3)
  123. assert layout.tray_from_slot_key(7, 2) == 30
  124. def test_ams_b_by_its_info(self):
  125. layout = TrayLayout.from_units([{"id": "40", "info": "7"}])
  126. assert layout.rack
  127. assert layout.global_tray_id(40, 2) == 39
  128. assert layout.slot_key(39) == (40, 2)
  129. def test_api_units_with_an_explicit_type(self):
  130. layout = TrayLayout.from_units([{"id": 32, "ams_type": 6}])
  131. assert layout.global_tray_id(32, 4) == 32
  132. assert layout.slot_key(32) == (32, 4)
  133. def test_raw_tray_now_of_the_rack(self):
  134. assert [H3C.raw_slot_to_global(k) for k in range(9)] == list(range(28, 37))
  135. assert H3C.raw_slot_to_global(255) is None
  136. assert TrayLayout.from_units([{"id": "0"}]).raw_slot_to_global(2) is None
  137. def test_loaded_trays(self):
  138. regular = TrayLayout.from_units([{"id": "0"}])
  139. for tray in (0, 15, 24, 27, 128, 135, 254):
  140. assert regular.is_loaded_tray(tray)
  141. for tray in (16, 28, 250, 255):
  142. assert not regular.is_loaded_tray(tray)
  143. assert H3C.is_loaded_tray(28) and H3C.is_loaded_tray(36) and H3C.is_loaded_tray(247)
  144. assert not H3C.is_loaded_tray(255)
  145. def test_layout_from_a_state(self):
  146. class _State:
  147. raw_data = {"ams": H3C_UNITS, "vt_tray": H3C_VT}
  148. assert TrayLayout.from_state(_State()) == H3C
  149. assert TrayLayout.from_state(None) == TrayLayout()
  150. def test_an_ams3h_model_without_ams_data_gets_its_ams3h(self):
  151. class _NoAms:
  152. raw_data: dict = {}
  153. assert TrayLayout.from_state(None, ams3h_model=True) == TrayLayout.ams3h()
  154. assert TrayLayout.from_state(_NoAms(), ams3h_model=True).slot_key(250) == (255, 5)
  155. assert TrayLayout.ams3h().is_external(250)
  156. # Live units always win over the model.
  157. class _Regular:
  158. raw_data = {"ams": [{"id": "0", "info": "1001"}]}
  159. assert TrayLayout.from_state(_Regular(), ams3h_model=True).rack_units == ()
  160. class TestWireSlot:
  161. """(ams_id << 8) | slot, as print.mapping and the snow fields carry a slot."""
  162. def test_regular_ams_ht_and_external(self):
  163. assert decode_wire_slot((1 << 8) | 2) == 6
  164. assert decode_wire_slot((0 << 8) | 0x13) == 3 # slot bits & 3
  165. assert decode_wire_slot(129 << 8) == 129
  166. assert decode_wire_slot((254 << 8) | 0) == 254
  167. assert decode_wire_slot((255 << 8) | 0) == 254
  168. assert decode_wire_slot((254 << 8) | 255) == 255
  169. assert decode_wire_slot(0xFFFF) is None
  170. assert decode_wire_slot(-1) is None
  171. assert decode_wire_slot((16 << 8) | 1) is None
  172. assert decode_wire_slot("258") == 6
  173. def test_ams3h_slots_with_and_without_a_layout(self):
  174. assert [decode_wire_slot((32 << 8) | k, H3C) for k in range(9)] == list(range(28, 37))
  175. assert decode_wire_slot((32 << 8) | 6) == 34
  176. assert decode_wire_slot((32 << 8) | 9, H3C) is None
  177. def test_a_rack_printer_keeps_its_external_inputs(self):
  178. # On a rack printer every input 247-255 is its own hotend's.
  179. assert decode_wire_slot((250 << 8) | 0, H3C) == 250
  180. assert decode_wire_slot((255 << 8) | 0, H3C) == 255
  181. assert decode_wire_slot((254 << 8) | 0, H3C) == 254
  182. assert decode_wire_slot((250 << 8) | 0) is None
  183. def test_trays_with_a_local_slot(self):
  184. mapping = [(0 << 8) | 1, (1 << 8) | 1, (1 << 8) | 2, 128 << 8, 254 << 8, 0xFFFF]
  185. assert mapped_trays_with_slot(mapping, 1) == {1: 0, 5: 1}
  186. assert mapped_trays_with_slot(mapping, 0) == {128: 128}
  187. assert mapped_trays_with_slot(None, 0) == {}
  188. def test_legacy_index_is_the_flat_four_per_unit_number(self):
  189. assert legacy_tray_index(1, 2) == 6
  190. assert legacy_tray_index(16, 3) == 67 # the A2L AMS Lite's physical tray
  191. assert legacy_tray_index(255, 1) == 1021
  192. class TestExternals:
  193. def test_254_255_are_external_everywhere(self):
  194. for tray in (254, 255):
  195. assert is_external_tray(tray)
  196. assert is_external_tray(tray, rack=True)
  197. def test_247_to_253_only_on_a_rack_printer(self):
  198. for tray in range(247, 254):
  199. assert not is_external_tray(tray)
  200. assert is_external_tray(tray, rack=True)
  201. assert H3C.is_external(tray)
  202. assert not TrayLayout.from_units([{"id": "0"}], [{"id": "254"}, {"id": "255"}]).is_external(250)
  203. def test_ams_slots_are_not_external(self):
  204. for tray in (0, 15, 28, 36, 128):
  205. assert not is_external_tray(tray, rack=True)
  206. def test_an_input_feeds_hotend_255_minus_its_id(self):
  207. assert [external_hotend(v) for v in range(255, 246, -1)] == list(range(9))
  208. def test_stored_under_ams_255(self):
  209. assert external_slot_index(254) == 0 and external_slot_index(255) == 1
  210. assert [external_slot_index(v) for v in range(247, 254)] == list(range(2, 9))
  211. for v in range(247, 256):
  212. assert external_tray_id(external_slot_index(v)) == v
  213. assert H3C.slot_key(v) == (255, external_slot_index(v))
  214. assert H3C.tray_from_slot_key(255, external_slot_index(v)) == v
  215. def test_a_reported_input_247_to_253_makes_a_rack_printer(self):
  216. assert TrayLayout.from_units([], H3C_VT).rack
  217. assert not TrayLayout.from_units([], [{"id": "254"}, {"id": "255"}]).rack
  218. def test_a_regular_printer_keeps_its_external_slot_keys(self):
  219. layout = TrayLayout.from_units([{"id": "0"}], [{"id": "254"}])
  220. assert layout.slot_key(254) == (255, 0)
  221. assert layout.slot_key(255) == (255, 1)
  222. # 247-253 are only ever external inputs, so they keep their stored key everywhere.
  223. assert layout.slot_key(250) == (255, 5)
  224. assert not layout.is_external(250)
  225. class TestTrayBitsAndLabels:
  226. def test_tray_flag_bits(self):
  227. assert tray_flag_bit(1, 2) == 6
  228. assert tray_flag_bit(128, 0) == 16
  229. assert tray_flag_bit(130, 0) == 18
  230. assert tray_flag_bit(6, 1, AmsType.AMS_LITE_MIXED) == 25
  231. assert [tray_flag_bit(32, k, AmsType.ZMULTI_A) for k in range(9)] == list(range(28, 37))
  232. assert tray_flag_bit(40, 0, AmsType.AMS) is None
  233. def test_unit_labels(self):
  234. assert unit_label(0) == "AMS-A"
  235. assert unit_label(129) == "HT-B"
  236. assert unit_label(32) == "AMS 3H"
  237. def test_a_unit_listed_twice_counts_once():
  238. # A push's units are combined with the ones already known: unit 32 twice is one AMS 3H.
  239. layout = TrayLayout.from_units([{"id": "32", "info": "240002006"}, {"id": "32", "info": "240002006"}])
  240. assert layout.rack_units == ((32, AmsType.ZMULTI_A),)
  241. assert layout.raw_slot_to_global(4) == 32