tray_ids.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. """Bambu Studio's global tray numbering, in one place.
  2. A global tray id names one filament source of a printer: what ``tray_now``
  3. reports, what a print's ``ams_mapping`` holds and what Bambuddy compares slots
  4. by. Studio derives it from the unit's AMS type, bits 0-3 of its hex ``info``
  5. (``DevFilaSystem::GetTrayIndexMap``, ``DevAmsType``)::
  6. AMS / AMS Lite / AMS 2 Pro (1, 2, 3) ams_id * 4 + slot
  7. A2L AMS Lite (5) 24 + slot
  8. AMS 3H (6, ZMULTI_A) 28 + slot
  9. AMS-B (7, ZMULTI_B) 37 + slot
  10. AMS-HT (4, N3S, units 128-135) ams_id
  11. external input its vir_slot id, 254 / 255
  12. On a printer with a direct-connected nozzle rack (the H3C) each hotend has its
  13. own external input, vir_slot 247-255, and input ``v`` feeds hotend ``255 - v``
  14. (``DevAmsTray::get_nozzle_addr_id``); rack AMS slot ``k`` feeds hotend ``k``.
  15. A slot is stored as ``(ams_id, tray_id)``, every external input under ams_id
  16. 255 (tray_id 0 = 254, 1 = 255, 2-8 = 247-253). The A2L's AMS Lite is stored as
  17. unit 6, so ``6 * 4 + slot`` is already Studio's 24-27.
  18. """
  19. from __future__ import annotations
  20. from collections.abc import Iterable, Mapping
  21. from dataclasses import dataclass
  22. from enum import IntEnum
  23. class AmsType(IntEnum):
  24. """Bambu Studio's ``DevAmsType``: bits 0-3 of an AMS unit's ``info``."""
  25. EXT_SPOOL = 0
  26. AMS = 1
  27. AMS_LITE = 2
  28. N3F = 3 # AMS 2 Pro
  29. N3S = 4 # AMS-HT
  30. AMS_LITE_MIXED = 5 # the A2L's AMS Lite
  31. ZMULTI_A = 6 # AMS 3H (H3C)
  32. ZMULTI_B = 7 # AMS-B
  33. RACK_AMS_TYPES = frozenset({AmsType.ZMULTI_A, AmsType.ZMULTI_B})
  34. RACK_TRAY_BASE: Mapping[AmsType, int] = {AmsType.ZMULTI_A: 28, AmsType.ZMULTI_B: 37}
  35. RACK_SLOT_COUNT = 9 # one slot per hotend of the H3C's rack
  36. AMS_LITE_MIXED_TRAY_BASE = 24
  37. # The H3C reports its AMS 3H as unit 32; tells the type when ``info`` is missing.
  38. AMS3H_UNIT_ID = 32
  39. _AMS3H_TRAY_IDS = range(RACK_TRAY_BASE[AmsType.ZMULTI_A], RACK_TRAY_BASE[AmsType.ZMULTI_A] + RACK_SLOT_COUNT)
  40. AMS_HT_IDS = range(128, 136)
  41. _REGULAR_WIRE_AMS_IDS = range(4) # the regular units (ams_id << 8) | slot names
  42. EXTERNAL_AMS_ID = 255
  43. EXTERNAL_MAIN_ID = 255 # VIRTUAL_TRAY_MAIN_ID
  44. EXTERNAL_DEPUTY_ID = 254 # VIRTUAL_TRAY_DEPUTY_ID
  45. RACK_EXTERNAL_IDS = range(247, 256)
  46. NO_TRAY = 255 # tray_now: nothing loaded
  47. WIRE_SLOT_NONE = 0xFFFF
  48. def _as_int(value: object) -> int | None:
  49. """An id field as an int (MQTT sends int or numeric str), else None."""
  50. if isinstance(value, bool):
  51. return None
  52. if isinstance(value, int):
  53. return value
  54. if isinstance(value, str):
  55. try:
  56. return int(value.strip())
  57. except ValueError:
  58. return None
  59. return None
  60. def ams_type_from_info(info: object) -> AmsType | None:
  61. """The AMS type in a unit's ``info`` (hex string, or an already parsed int), else None."""
  62. if isinstance(info, bool):
  63. return None
  64. if isinstance(info, int):
  65. raw = info
  66. elif isinstance(info, str) and info.strip():
  67. try:
  68. raw = int(info.strip(), 16)
  69. except ValueError:
  70. return None
  71. else:
  72. return None
  73. try:
  74. return AmsType(raw & 0xF)
  75. except ValueError:
  76. return None
  77. def is_rack_ams(ams_type: object) -> bool:
  78. """Whether ``ams_type`` is a rack AMS (AMS 3H / AMS-B): one slot per hotend."""
  79. return ams_type in RACK_AMS_TYPES
  80. def is_ams_ht_id(ams_id: object) -> bool:
  81. """Whether ``ams_id`` is an AMS-HT unit (128-135, one tray each)."""
  82. return isinstance(ams_id, int) and not isinstance(ams_id, bool) and ams_id in AMS_HT_IDS
  83. def unit_ams_type(ams_id: int, info: object = None) -> AmsType:
  84. """The AMS type of unit ``ams_id``: from ``info``, else the id (128-135 AMS-HT, 32 AMS 3H, else AMS)."""
  85. if is_ams_ht_id(ams_id):
  86. return AmsType.N3S
  87. from_info = ams_type_from_info(info)
  88. if from_info is not None and from_info != AmsType.EXT_SPOOL:
  89. return from_info
  90. if ams_id >= 128:
  91. return AmsType.N3S
  92. if ams_id == AMS3H_UNIT_ID:
  93. return AmsType.ZMULTI_A
  94. return AmsType.AMS
  95. def global_tray_id(ams_id: int, slot: int, ams_type: AmsType | int | None = None) -> int:
  96. """Bambu Studio's global tray id of an AMS slot (``ams_type`` defaults to ``unit_ams_type``)."""
  97. kind = unit_ams_type(ams_id) if ams_type is None else ams_type
  98. if kind == AmsType.N3S:
  99. return ams_id
  100. if kind == AmsType.AMS_LITE_MIXED:
  101. return AMS_LITE_MIXED_TRAY_BASE + slot
  102. if kind in RACK_TRAY_BASE:
  103. return RACK_TRAY_BASE[AmsType(kind)] + slot
  104. return ams_id * 4 + slot
  105. def legacy_tray_index(ams_id: int, slot: int) -> int:
  106. """The firmware's flat ``ams_id * 4 + slot`` (not a Studio id outside a regular AMS).
  107. What ``M620 R<n>``, ``ams_filament_setting`` / ``extrusion_cali_set``'s
  108. ``tray_id`` on units 0-3 and the A2L AMS Lite's physical tray (64-67) carry.
  109. """
  110. return ams_id * 4 + slot
  111. def unit_label(ams_id: int, ams_type: AmsType | int | None = None) -> str:
  112. """Short name of AMS unit ``ams_id`` for alerts and logs: "AMS-A", "HT-A", "AMS 3H"."""
  113. kind = unit_ams_type(ams_id) if ams_type is None else ams_type
  114. if kind == AmsType.N3S:
  115. return f"HT-{chr(65 + (ams_id - 128))}"
  116. if kind == AmsType.ZMULTI_A:
  117. return "AMS 3H"
  118. if kind == AmsType.ZMULTI_B:
  119. return f"AMS {ams_id}"
  120. return f"AMS-{chr(65 + ams_id)}"
  121. def tray_flag_bit(ams_id: int, slot: int, ams_type: AmsType | int | None = None) -> int | None:
  122. """Bit of a slot in the ``tray_*_bits`` masks (``DevAmsTray::get_tray_flag_bit``).
  123. An AMS-HT at ``16 + (ams_id - 128)``, a rack AMS and the AMS Lite at their
  124. global id, a regular AMS (units 0-15) at ``ams_id * 4 + slot``; None otherwise.
  125. """
  126. kind = unit_ams_type(ams_id) if ams_type is None else ams_type
  127. if kind == AmsType.N3S:
  128. return 16 + (ams_id - 128) if is_ams_ht_id(ams_id) else None
  129. if kind == AmsType.AMS_LITE_MIXED or kind in RACK_TRAY_BASE:
  130. return global_tray_id(ams_id, slot, kind)
  131. if 0 <= ams_id <= 15:
  132. return legacy_tray_index(ams_id, slot)
  133. return None
  134. def is_external_tray(tray_id: object, rack: bool = False) -> bool:
  135. """Whether global tray ``tray_id`` is an external input: 254/255, and 247-255 on a ``rack`` printer."""
  136. if not isinstance(tray_id, int) or isinstance(tray_id, bool):
  137. return False
  138. if tray_id in (EXTERNAL_DEPUTY_ID, EXTERNAL_MAIN_ID):
  139. return True
  140. return rack and tray_id in RACK_EXTERNAL_IDS
  141. def external_hotend(tray_id: int) -> int:
  142. """The hotend a rack printer's external input ``tray_id`` feeds: ``255 - tray_id``."""
  143. return EXTERNAL_MAIN_ID - tray_id
  144. def external_slot_index(tray_id: int) -> int:
  145. """The ``tray_id`` external input ``tray_id`` is stored under: 254 -> 0, 255 -> 1, 247-253 -> 2-8."""
  146. if tray_id in (EXTERNAL_DEPUTY_ID, EXTERNAL_MAIN_ID):
  147. return tray_id - EXTERNAL_DEPUTY_ID
  148. return tray_id - RACK_EXTERNAL_IDS.start + 2
  149. def external_tray_id(slot_index: int) -> int:
  150. """The global tray id of the external input stored as ``(255, slot_index)``."""
  151. if slot_index in (0, 1):
  152. return EXTERNAL_DEPUTY_ID + slot_index
  153. return RACK_EXTERNAL_IDS.start + slot_index - 2
  154. def _unit_id_and_type(unit: object) -> tuple[int, AmsType] | None:
  155. """(id, type) of one unit: a raw MQTT dict, an API dict (``ams_type``) or an object."""
  156. if isinstance(unit, Mapping):
  157. uid = _as_int(unit.get("id"))
  158. explicit = unit.get("ams_type")
  159. info = unit.get("info")
  160. else:
  161. uid = _as_int(getattr(unit, "id", None))
  162. explicit = getattr(unit, "ams_type", None)
  163. info = getattr(unit, "info", None)
  164. if uid is None:
  165. return None
  166. if isinstance(explicit, int) and not isinstance(explicit, bool) and explicit in AmsType._value2member_map_:
  167. kind = AmsType(explicit)
  168. if kind != AmsType.EXT_SPOOL:
  169. return uid, kind
  170. return uid, unit_ams_type(uid, info)
  171. @dataclass(frozen=True)
  172. class TrayLayout:
  173. """How one printer numbers its trays: its AMS units' ``(ams_id, type)`` and whether it is a rack printer.
  174. ``rack``: per-hotend external inputs 247-255 (a rack AMS, or a reported input 247-253).
  175. """
  176. units: tuple[tuple[int, AmsType], ...] = ()
  177. rack: bool = False
  178. @classmethod
  179. def from_units(cls, units: Iterable[object] | None, vt_trays: Iterable[object] | None = None) -> TrayLayout:
  180. """Layout of an AMS unit list (raw or API units) and a vt_tray list; a unit listed twice counts once."""
  181. pairs: list[tuple[int, AmsType]] = []
  182. seen: set[int] = set()
  183. for unit in units or ():
  184. pair = _unit_id_and_type(unit)
  185. if pair is not None and pair[0] not in seen:
  186. seen.add(pair[0])
  187. pairs.append(pair)
  188. rack = any(is_rack_ams(kind) for _, kind in pairs)
  189. if not rack:
  190. for vt in vt_trays or ():
  191. vt_id = _as_int(vt.get("id") if isinstance(vt, Mapping) else getattr(vt, "id", None))
  192. if vt_id is not None and RACK_EXTERNAL_IDS.start <= vt_id < EXTERNAL_DEPUTY_ID:
  193. rack = True
  194. break
  195. return cls(units=tuple(pairs), rack=rack)
  196. @classmethod
  197. def ams3h(cls) -> TrayLayout:
  198. """An H3C's layout (its AMS 3H as unit 32), for when its own AMS data is not at hand."""
  199. return cls(units=((AMS3H_UNIT_ID, AmsType.ZMULTI_A),), rack=True)
  200. @classmethod
  201. def from_state(cls, state: object, ams3h_model: bool = False) -> TrayLayout:
  202. """Layout of a printer's live state; with no AMS data, ``ams3h_model`` (``printer_models.has_ams3h``) gives ``ams3h()``."""
  203. raw = getattr(state, "raw_data", None) if state is not None else None
  204. layout = cls()
  205. if isinstance(raw, Mapping):
  206. units = raw.get("ams")
  207. if isinstance(units, Mapping): # also stored as the push's {"ams": [...]}
  208. units = units.get("ams")
  209. vt_trays = raw.get("vt_tray")
  210. if isinstance(vt_trays, Mapping):
  211. vt_trays = [vt_trays]
  212. layout = cls.from_units(
  213. units if isinstance(units, list) else None, vt_trays if isinstance(vt_trays, list) else None
  214. )
  215. if ams3h_model and not layout.units:
  216. return cls.ams3h()
  217. return layout
  218. def ams_type(self, ams_id: int) -> AmsType:
  219. """Type of unit ``ams_id``; told from the id when the unit isn't listed."""
  220. for uid, kind in self.units:
  221. if uid == ams_id:
  222. return kind
  223. return unit_ams_type(ams_id)
  224. @property
  225. def rack_units(self) -> tuple[tuple[int, AmsType], ...]:
  226. """The rack AMS units as ``(ams_id, type)``."""
  227. return tuple((uid, kind) for uid, kind in self.units if is_rack_ams(kind))
  228. def global_tray_id(self, ams_id: int, slot: int) -> int:
  229. """Global tray id of slot ``slot`` of unit ``ams_id``."""
  230. return global_tray_id(ams_id, slot, self.ams_type(ams_id))
  231. def is_external(self, tray_id: object) -> bool:
  232. """Whether global tray ``tray_id`` is one of this printer's external inputs."""
  233. return is_external_tray(tray_id, self.rack)
  234. def is_loaded_tray(self, tray_id: int) -> bool:
  235. """Whether a ``tray_now`` value names a filament source rather than "nothing loaded"."""
  236. return (
  237. 0 <= tray_id <= 15
  238. or AMS_LITE_MIXED_TRAY_BASE <= tray_id < AMS_LITE_MIXED_TRAY_BASE + 4
  239. or is_ams_ht_id(tray_id)
  240. or tray_id == EXTERNAL_DEPUTY_ID
  241. or self.rack_slot(tray_id) is not None
  242. or (self.is_external(tray_id) and tray_id != NO_TRAY)
  243. )
  244. def rack_slot(self, tray_id: int) -> tuple[int, int] | None:
  245. """``(ams_id, slot)`` of a listed rack AMS's slot, else None."""
  246. for uid, kind in self.rack_units:
  247. base = RACK_TRAY_BASE[kind]
  248. if base <= tray_id < base + RACK_SLOT_COUNT:
  249. return uid, tray_id - base
  250. return None
  251. def slot_key(self, tray_id: int) -> tuple[int, int]:
  252. """The ``(ams_id, tray_id)`` global tray ``tray_id`` is stored under.
  253. 247-255 are only ever external inputs. 28-36 are the AMS 3H's, as in
  254. Studio, unless the printer lists a rack AMS or a regular unit 7, so a
  255. stored AMS 3H slot keeps its key without live data.
  256. """
  257. if tray_id in RACK_EXTERNAL_IDS:
  258. return EXTERNAL_AMS_ID, external_slot_index(tray_id)
  259. if tray_id >= 128:
  260. return tray_id, 0
  261. rack = self.rack_slot(tray_id)
  262. if rack is not None:
  263. return rack
  264. if tray_id in _AMS3H_TRAY_IDS and not self.rack_units and not any(uid == 7 for uid, _ in self.units):
  265. return AMS3H_UNIT_ID, tray_id - _AMS3H_TRAY_IDS.start
  266. return tray_id // 4, tray_id % 4
  267. def tray_from_slot_key(self, ams_id: int, tray_id: int) -> int:
  268. """The global tray id of the stored slot ``(ams_id, tray_id)``."""
  269. if ams_id == EXTERNAL_AMS_ID:
  270. return external_tray_id(tray_id)
  271. if ams_id >= 128:
  272. return ams_id
  273. return self.global_tray_id(ams_id, tray_id)
  274. def raw_slot_to_global(self, raw: int) -> int | None:
  275. """A rack printer's ``tray_now`` / ``tray_tar`` / ``tray_pre`` (the H3C reports the raw slot 0-8).
  276. None unless the printer has exactly one rack AMS and ``raw`` is one of its slots.
  277. """
  278. racks = self.rack_units
  279. if len(racks) != 1 or not 0 <= raw < RACK_SLOT_COUNT:
  280. return None
  281. uid, kind = racks[0]
  282. return global_tray_id(uid, raw, kind)
  283. _NO_LAYOUT = TrayLayout()
  284. def decode_wire_slot(value: object, layout: TrayLayout | None = None) -> int | None:
  285. """Global tray id of a slot the firmware encodes as ``(ams_id << 8) | slot``.
  286. ``print.mapping``, ``ams.csl`` and the extruder / nozzle ``snow`` fields use
  287. it; 0xFFFF is "none". Units 0-3 (slot bits ``& 3``), the AMS-HT, a rack AMS
  288. (the AMS 3H also without a layout), and the external spool: 254/255 give
  289. 254, or 255 for slot 255; a rack printer's inputs 247-255 give their own id.
  290. None when the value names no tray.
  291. """
  292. encoded = _as_int(value)
  293. if encoded is None or not 0 <= encoded < WIRE_SLOT_NONE:
  294. return None
  295. ams_id, slot = encoded >> 8, encoded & 0xFF
  296. layout = layout or _NO_LAYOUT
  297. if layout.rack and ams_id in RACK_EXTERNAL_IDS:
  298. return ams_id
  299. if ams_id in (EXTERNAL_DEPUTY_ID, EXTERNAL_MAIN_ID):
  300. return EXTERNAL_MAIN_ID if slot == 255 else EXTERNAL_DEPUTY_ID
  301. if is_ams_ht_id(ams_id):
  302. return ams_id
  303. if ams_id in _REGULAR_WIRE_AMS_IDS:
  304. return layout.global_tray_id(ams_id, slot & 0x03)
  305. kind = layout.ams_type(ams_id)
  306. if is_rack_ams(kind) and slot < RACK_SLOT_COUNT:
  307. return global_tray_id(ams_id, slot, kind)
  308. return None
  309. def mapped_trays_with_slot(mapping_raw: object, local_slot: int) -> dict[int, int]:
  310. """``{global tray: ams_id}`` for the regular AMS / AMS-HT trays of a ``print.mapping`` whose local slot is ``local_slot``.
  311. Places a bare local ``tray_now`` / ``tray_tar`` (0-3) on a printer with several AMS units.
  312. """
  313. found: dict[int, int] = {}
  314. for value in mapping_raw if isinstance(mapping_raw, list) else []:
  315. tray = decode_wire_slot(value)
  316. if tray is None or is_external_tray(tray):
  317. continue
  318. ams_id, slot = _NO_LAYOUT.slot_key(tray)
  319. if slot == local_slot and (ams_id in _REGULAR_WIRE_AMS_IDS or is_ams_ht_id(ams_id)):
  320. found[tray] = ams_id
  321. return found