test_h3c_ams3h.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782
  1. """H3C "AMS 3H" (model N10): one nine-slot rack AMS, numbered as Bambu Studio does.
  2. The H3C reports its AMS 3H as ONE unit (id 32, AMS type 6 = Studio's ZMULTI_A)
  3. with trays 0-8, tray_now as the raw slot, slot k of the tray bit masks at bit
  4. 28+k, and (32 << 8) | k in `mapping` / extruder `snow`. Bambuddy keeps the
  5. unit as it is and gives its slots Bambu Studio's global tray ids 28-36
  6. (utils.tray_ids): tray_now 6 is global 34, a print maps slot k as 28+k with
  7. ams_mapping2 {ams_id: 32, slot_id: k}, and the slot rows are (32, k).
  8. Commands keep the firmware's own {ams_id: 32, slot_id: k}.
  9. ``FRAME`` is trimmed from a live H3C capture (firmware 01.01.03.00, AMS 3H
  10. 02.00.32.82) taken mid-print on 2026-11-06: tray_now "6", slot 7 empty, and
  11. the hotend bound to slot 6 mounted on the toolhead (nozzle id 0, addr_id 6).
  12. """
  13. import asyncio
  14. import copy
  15. import json
  16. from unittest.mock import MagicMock
  17. from backend.app.services.bambu_mqtt import (
  18. BambuMQTTClient,
  19. ams3h_drying_cooling_temp,
  20. resolve_external_spools_in_mapping,
  21. )
  22. from backend.app.services.printer_manager import ams_units_to_dicts, resolve_expected_tray
  23. from backend.app.utils.tray_ids import AmsType, TrayLayout
  24. FRAME = {
  25. "command": "push_status",
  26. "ams": {
  27. "tray_now": "6",
  28. "tray_pre": "6",
  29. "tray_tar": "6",
  30. "tray_exist_bits": "17f0000000",
  31. "tray_is_bbl_bits": "17f0000000",
  32. "tray_read_done_bits": "17f0000000",
  33. "tray_reading_bits": "0",
  34. "ams_exist_bits": "2000",
  35. "csl": [8193],
  36. "insert_flag": True,
  37. "power_on_flag": False,
  38. "version": 6369,
  39. "ams": [
  40. {
  41. "id": "32",
  42. "info": "240842026",
  43. "tray": [
  44. {
  45. "id": "0",
  46. "state": 27,
  47. "tray_type": "PLA",
  48. "tray_color": "DE4343FF",
  49. "remain": -1,
  50. "tray_info_idx": "GFL99",
  51. "tag_uid": "0000000000000000",
  52. },
  53. {
  54. "id": "1",
  55. "state": 27,
  56. "tray_type": "PLA",
  57. "tray_color": "FFFFFFFF",
  58. "remain": -1,
  59. "tray_info_idx": "GFL99",
  60. "tag_uid": "0000000000000000",
  61. },
  62. {
  63. "id": "2",
  64. "state": 27,
  65. "tray_type": "PLA",
  66. "tray_color": "757575FF",
  67. "remain": -1,
  68. "tray_info_idx": "GFL99",
  69. "tag_uid": "0000000000000000",
  70. },
  71. {
  72. "id": "3",
  73. "state": 27,
  74. "tray_type": "PLA",
  75. "tray_color": "000000FF",
  76. "remain": -1,
  77. "tray_info_idx": "GFL99",
  78. "tag_uid": "0000000000000000",
  79. },
  80. {
  81. "id": "4",
  82. "state": 27,
  83. "tray_type": "PLA",
  84. "tray_color": "0178BFFF",
  85. "remain": -1,
  86. "tray_info_idx": "GFL99",
  87. "tag_uid": "0000000000000000",
  88. },
  89. {
  90. "id": "5",
  91. "state": 27,
  92. "tray_type": "PLA",
  93. "tray_color": "AE96D4FF",
  94. "remain": -1,
  95. "tray_info_idx": "GFL99",
  96. "tag_uid": "0000000000000000",
  97. },
  98. {
  99. "id": "6",
  100. "state": 27,
  101. "tray_type": "PLA",
  102. "tray_color": "F7D959FF",
  103. "remain": -1,
  104. "tray_info_idx": "GFL99",
  105. "tag_uid": "0000000000000000",
  106. },
  107. {"id": "7", "state": 160},
  108. {
  109. "id": "8",
  110. "state": 27,
  111. "tray_type": "PLA",
  112. "tray_color": "61C680FF",
  113. "remain": -1,
  114. "tray_info_idx": "GFL99",
  115. "tag_uid": "0000000000000000",
  116. },
  117. ],
  118. }
  119. ],
  120. },
  121. "mapping": [8193, 8195, 8198, 8192, 8200, 8196, 8192, 8194, 8197],
  122. "device": {
  123. "extruder": {
  124. "state": 1,
  125. "info": [{"id": 0, "snow": 8198, "spre": 8198, "star": 8198, "hnow": 0, "hpre": 0, "htar": 0}],
  126. },
  127. "nozzle": {
  128. "hc": 511,
  129. "info": [
  130. {
  131. "id": 16,
  132. "addr_id": 0,
  133. "snow": 8192,
  134. "stat": 168,
  135. "type": "HS01",
  136. "diameter": 0.4,
  137. "color_m": "DE4343FF",
  138. "fila_id": "GFL99",
  139. "tm": 350,
  140. "wear": 0.0,
  141. },
  142. {
  143. "id": 17,
  144. "addr_id": 1,
  145. "snow": 8193,
  146. "stat": 176,
  147. "type": "HS01",
  148. "diameter": 0.4,
  149. "color_m": "FFFFFFFF",
  150. "fila_id": "GFL99",
  151. "tm": 350,
  152. "wear": 0.0,
  153. },
  154. {
  155. "id": 18,
  156. "addr_id": 2,
  157. "snow": 8194,
  158. "stat": 184,
  159. "type": "HS01",
  160. "diameter": 0.4,
  161. "color_m": "757575FF",
  162. "fila_id": "GFL99",
  163. "tm": 350,
  164. "wear": 0.0,
  165. },
  166. {
  167. "id": 19,
  168. "addr_id": 3,
  169. "snow": 8195,
  170. "stat": 184,
  171. "type": "HS01",
  172. "diameter": 0.4,
  173. "color_m": "000000FF",
  174. "fila_id": "GFL99",
  175. "tm": 350,
  176. "wear": 0.0,
  177. },
  178. {
  179. "id": 20,
  180. "addr_id": 4,
  181. "snow": 8196,
  182. "stat": 184,
  183. "type": "HS01",
  184. "diameter": 0.4,
  185. "color_m": "0178BFFF",
  186. "fila_id": "GFL99",
  187. "tm": 350,
  188. "wear": 0.0,
  189. },
  190. {
  191. "id": 21,
  192. "addr_id": 5,
  193. "snow": 8197,
  194. "stat": 160,
  195. "type": "HS01",
  196. "diameter": 0.4,
  197. "color_m": "AE96D4FF",
  198. "fila_id": "GFL99",
  199. "tm": 350,
  200. "wear": 0.0,
  201. },
  202. {
  203. "id": 0,
  204. "addr_id": 6,
  205. "snow": 8198,
  206. "stat": 168,
  207. "type": "HS01",
  208. "diameter": 0.4,
  209. "color_m": "F7D959FF",
  210. "fila_id": "GFL99",
  211. "tm": 350,
  212. "wear": 0.0,
  213. },
  214. {
  215. "id": 23,
  216. "addr_id": 7,
  217. "snow": 63488,
  218. "stat": 1,
  219. "type": "HT01",
  220. "diameter": 0.4,
  221. "color_m": "00000000",
  222. "fila_id": "",
  223. "tm": 350,
  224. "wear": 0.0,
  225. },
  226. {
  227. "id": 24,
  228. "addr_id": 8,
  229. "snow": 8200,
  230. "stat": 160,
  231. "type": "HS01",
  232. "diameter": 0.4,
  233. "color_m": "61C680FF",
  234. "fila_id": "GFL99",
  235. "tm": 350,
  236. "wear": 0.0,
  237. },
  238. ],
  239. },
  240. },
  241. }
  242. # Slot colours of FRAME, physical slot 0-8 (7 is empty).
  243. COLOURS = ["DE4343FF", "FFFFFFFF", "757575FF", "000000FF", "0178BFFF", "AE96D4FF", "F7D959FF", None, "61C680FF"]
  244. def _frame() -> dict:
  245. return copy.deepcopy(FRAME)
  246. def _client(model: str = "H3C") -> BambuMQTTClient:
  247. return BambuMQTTClient(ip_address="10.0.0.1", serial_number="H3C", access_code="c", model=model)
  248. def _wired(client: BambuMQTTClient) -> BambuMQTTClient:
  249. client._client = MagicMock()
  250. client.state.connected = True
  251. return client
  252. def _ingested(model: str = "H3C", frame: dict | None = None) -> BambuMQTTClient:
  253. client = _client(model)
  254. client._process_message({"print": frame if frame is not None else _frame()})
  255. return client
  256. def _last_payload(client: BambuMQTTClient) -> dict:
  257. return json.loads(client._client.publish.call_args[0][1])["print"]
  258. def _unit(client: BambuMQTTClient) -> dict:
  259. (unit,) = client.state.raw_data["ams"]
  260. return unit
  261. def _trays_by_id(unit: dict) -> dict[int, dict]:
  262. return {int(t["id"]): t for t in unit.get("tray", [])}
  263. class TestIngest:
  264. def test_unit_32_stays_one_unit_with_its_nine_trays(self):
  265. client = _ingested()
  266. unit = _unit(client)
  267. assert unit["id"] == "32"
  268. trays = _trays_by_id(unit)
  269. assert sorted(trays) == list(range(9))
  270. for slot, colour in enumerate(COLOURS):
  271. assert trays[slot].get("tray_color") == colour
  272. def test_the_layout_names_the_rack_ams(self):
  273. layout = _ingested().tray_layout
  274. assert layout.rack_units == ((32, AmsType.ZMULTI_A),)
  275. assert [layout.global_tray_id(32, k) for k in range(9)] == list(range(28, 37))
  276. def test_tray_now_is_the_global_tray_28_plus_slot(self):
  277. assert _ingested().state.tray_now == 34 # FRAME: raw slot 6
  278. for slot in ("0", "2", "3", "8"):
  279. frame = _frame()
  280. frame["ams"]["tray_now"] = slot
  281. assert _ingested(frame=frame).state.tray_now == 28 + int(slot)
  282. def test_nothing_on_the_toolhead_stays_255(self):
  283. frame = _frame()
  284. frame["ams"]["tray_now"] = "255"
  285. assert _ingested(frame=frame).state.tray_now == 255
  286. def test_a_partial_push_is_globalised_too(self):
  287. client = _ingested()
  288. client._process_message({"print": {"ams": {"tray_now": "2"}}})
  289. assert client.state.tray_now == 30
  290. def test_a_partial_state_27_push_keeps_the_slots_spool(self):
  291. # The AMS 3H reports a loaded slot as state 27. The "state != 11 is not
  292. # loaded" rule for partial {id, state} pushes (#784) must not empty it,
  293. # as it must not for an AMS-HT (#2594).
  294. client = _wired(_ingested())
  295. client._process_message(
  296. {"print": {"command": "push_status", "ams": {"ams": [{"id": "32", "tray": [{"id": "3", "state": 27}]}]}}}
  297. )
  298. assert _trays_by_id(_unit(client))[3]["tray_type"] == "PLA"
  299. assert client.ams_load_filament(31)
  300. def test_every_full_push_is_globalised_not_just_the_first(self):
  301. # The H3C sends the unit list with every push. The push's units plus the
  302. # ones already known used to list unit 32 twice, which read as two rack
  303. # AMSes, so from the second push on tray_now stayed the raw slot (seen live).
  304. client = _ingested()
  305. for slot in ("4", "0", "8"):
  306. frame = _frame()
  307. frame["ams"]["tray_now"] = slot
  308. client._process_message({"print": frame})
  309. assert client.state.tray_now == 28 + int(slot)
  310. def test_loaded_trays_are_the_rack_slots_and_inputs(self):
  311. layout = _ingested().tray_layout
  312. assert layout.is_loaded_tray(28) and layout.is_loaded_tray(36)
  313. assert layout.is_loaded_tray(250) # a per-hotend external input
  314. assert not layout.is_loaded_tray(255) and not layout.is_loaded_tray(37)
  315. regular = _client(model="X1C").tray_layout
  316. assert not regular.is_loaded_tray(30) and not regular.is_loaded_tray(250)
  317. def test_tray_exist_bits_mark_only_slot_7_empty(self):
  318. # "17f0000000": slots 0-6 and 8 at bit 28+k, Studio's get_tray_flag_bit
  319. trays = _trays_by_id(_unit(_ingested()))
  320. for slot in range(9):
  321. assert trays[slot].get("exists") is (slot != 7), slot
  322. def test_one_extruder_no_ams_extruder_map(self):
  323. client = _ingested()
  324. assert client.state.ams_extruder_map == {}
  325. assert client._is_dual_nozzle is False
  326. def test_extruder_slot_points_at_unit_32(self):
  327. slots = _ingested().state.extruder_slots
  328. loaded = next(iter(slots.values()))
  329. assert (loaded.ams_id, loaded.slot_id) == (32, 6)
  330. def test_status_unit_carries_its_ams_type(self):
  331. (unit,) = ams_units_to_dicts(_ingested().state.raw_data["ams"], lambda *a: None)
  332. assert (unit["id"], unit["ams_type"], len(unit["tray"])) == (32, 6, 9)
  333. assert unit["is_ams_ht"] is False
  334. def test_nozzle_rack_keeps_home_position(self):
  335. rack = _ingested().state.nozzle_rack
  336. assert len(rack) == 9
  337. mounted = [n for n in rack if n["id"] == 0]
  338. assert len(mounted) == 1 and mounted[0]["addr_id"] == 6
  339. assert {n["addr_id"] for n in rack} == set(range(9))
  340. def test_version_module_n10a_reaches_the_unit(self):
  341. client = _ingested()
  342. client._process_message(
  343. {"info": {"command": "get_version", "module": [{"name": "n10a", "sw_ver": "02.00.32.82", "sn": "SN3H"}]}}
  344. )
  345. unit = _unit(client)
  346. assert (unit["sw_ver"], unit["module_type"], unit["sn"]) == ("02.00.32.82", "n10a", "SN3H")
  347. def test_runout_slots_are_global_tray_ids(self):
  348. units = ams_units_to_dicts(_ingested().state.raw_data["ams"], lambda *a: None)
  349. layout = TrayLayout.from_units(units)
  350. assert resolve_expected_tray(6, [(32, False)], None, tray_layout=layout) == 34
  351. assert resolve_expected_tray(255, [(32, False)], None, tray_layout=layout) is None
  352. def test_tray_layout_is_rebuilt_only_when_the_units_change(self):
  353. client = _ingested()
  354. first = client.tray_layout
  355. client._process_message({"print": _frame()}) # same units again
  356. assert client.tray_layout is first
  357. client.state.raw_data["ams"] = [{"id": "0", "info": "1001"}]
  358. assert client.tray_layout is not first
  359. assert client.tray_layout.rack_units == ()
  360. def test_an_offline_h3c_keeps_its_numbering(self):
  361. from backend.app.services.printer_manager import printer_tray_layout
  362. class _Manager:
  363. def __init__(self, model):
  364. self.model = model
  365. def get_status(self, printer_id):
  366. return None
  367. def get_model(self, printer_id):
  368. return self.model
  369. h3c = printer_tray_layout(2, _Manager("N10"))
  370. assert h3c.slot_key(30) == (32, 2) and h3c.tray_from_slot_key(32, 2) == 30
  371. assert h3c.is_external(250) and h3c.slot_key(250) == (255, 5)
  372. assert printer_tray_layout(1, _Manager("X1C")) == TrayLayout()
  373. def test_runout_keeps_an_ams_ht_target_next_to_the_ams3h(self):
  374. layout = TrayLayout.from_units([{"id": 32, "ams_type": 6}, {"id": 128, "ams_type": 4}])
  375. assert resolve_expected_tray(128, [(32, False), (128, True)], None, tray_layout=layout) == 128
  376. assert resolve_expected_tray(3, [(32, False), (128, True)], None, tray_layout=layout) == 31
  377. class TestWire:
  378. def _setting(self, client: BambuMQTTClient, ams_id: int, tray_id: int) -> bool:
  379. return client.ams_set_filament_setting(
  380. ams_id=ams_id,
  381. tray_id=tray_id,
  382. tray_info_idx="GFL99",
  383. tray_type="PLA",
  384. tray_sub_brands="",
  385. tray_color="F7D959FF",
  386. nozzle_temp_min=190,
  387. nozzle_temp_max=230,
  388. )
  389. def test_filament_setting_targets_unit_32(self):
  390. # Verified on the printer: {ams_id 32, tray_id 8, slot_id 8} changed only slot 9.
  391. client = _wired(_ingested())
  392. assert self._setting(client, 32, 6)
  393. sent = _last_payload(client)
  394. assert (sent["ams_id"], sent["tray_id"], sent["slot_id"]) == (32, 6, 6)
  395. def test_reset_slot_targets_unit_32(self):
  396. client = _wired(_ingested())
  397. assert client.reset_ams_slot(32, 8)
  398. sent = _last_payload(client)
  399. assert (sent["ams_id"], sent["tray_id"], sent["slot_id"]) == (32, 8, 8)
  400. def test_k_profile_selection_targets_unit_32(self):
  401. client = _wired(_ingested())
  402. assert client.extrusion_cali_sel(ams_id=32, tray_id=8, cali_idx=-1, filament_id="GFL99")
  403. sent = _last_payload(client)
  404. assert (sent["ams_id"], sent["tray_id"], sent["slot_id"]) == (32, 8, 8)
  405. def test_rfid_refresh_names_the_loaded_ams3h_slot(self):
  406. client = _wired(_ingested()) # tray_now 6: global 34
  407. ok, message = asyncio.run(client.ams_refresh_tray(32, 4))
  408. assert not ok
  409. assert message.endswith("Currently loaded: AMS 3H slot 7")
  410. def test_rfid_reread_targets_unit_32(self):
  411. frame = _frame()
  412. frame["ams"]["tray_now"] = "255"
  413. client = _wired(_ingested(frame=frame))
  414. client._rfid_ack_timeout = 0.01
  415. ok, _ = asyncio.run(client.ams_refresh_tray(32, 4))
  416. assert ok
  417. sent = _last_payload(client)
  418. assert (sent["command"], sent["ams_id"], sent["slot_id"]) == ("ams_get_rfid", 32, 4)
  419. def test_regular_ams_unchanged(self):
  420. client = _wired(_client(model="X1C"))
  421. assert self._setting(client, 0, 2)
  422. sent = _last_payload(client)
  423. assert (sent["ams_id"], sent["tray_id"], sent["slot_id"]) == (0, 2, 2)
  424. # Load / unload as Bambu Studio 02.09.01.70 sends them for a direct-connected
  425. # hotend rack (PageFilament.cpp): unit 32, extruder 0; a load targets the
  426. # unit at slot k, an unload targets 255 at slot 255-k. Verified on the printer.
  427. def test_load_feeds_the_slot_into_its_own_hotend(self):
  428. client = _wired(_ingested())
  429. assert client.ams_load_filament(30) # AMS 3H slot 3
  430. sent = _last_payload(client)
  431. assert sent["command"] == "ams_change_filament"
  432. assert (sent["ams_id"], sent["target"], sent["slot_id"], sent["extruder_id"]) == (32, 32, 2, 0)
  433. assert client.state.pending_tray_target == 30
  434. def test_unload_names_the_feeder_as_255_minus_slot(self):
  435. client = _wired(_ingested())
  436. assert client.ams_unload_filament(36)
  437. sent = _last_payload(client)
  438. assert (sent["ams_id"], sent["target"], sent["slot_id"], sent["extruder_id"]) == (32, 255, 247, 0)
  439. def test_unload_without_a_slot_takes_the_hotend_on_the_toolhead(self):
  440. client = _wired(_ingested()) # FRAME: tray_now "6" -> 34
  441. assert client.ams_unload_filament()
  442. assert _last_payload(client)["slot_id"] == 255 - 6
  443. def test_temps_are_the_slot_filament_midpoints(self):
  444. frame = _frame()
  445. trays = {t["id"]: t for t in frame["ams"]["ams"][0]["tray"]}
  446. trays["2"].update(nozzle_temp_min="190", nozzle_temp_max="230")
  447. trays["6"].update(nozzle_temp_min="220", nozzle_temp_max="240")
  448. client = _wired(_ingested(frame=frame))
  449. assert client.ams_load_filament(30)
  450. sent = _last_payload(client)
  451. assert (sent["tar_temp"], sent["curr_temp"]) == (210, 230) # slot 2's range; slot 6 is on the toolhead
  452. def test_temps_unknown_are_minus_one(self):
  453. client = _wired(_ingested())
  454. assert client.ams_load_filament(28)
  455. sent = _last_payload(client)
  456. assert (sent["tar_temp"], sent["curr_temp"]) == (-1, -1)
  457. def test_load_refuses_a_slot_with_no_filament(self):
  458. # Studio offers Load only once the AMS has pulled a spool in; FRAME slot 7 is empty.
  459. client = _wired(_ingested())
  460. assert client.rack_tray_has_filament(28) and not client.rack_tray_has_filament(35)
  461. assert client.ams_load_filament(35) is False
  462. client._client.publish.assert_not_called()
  463. def test_only_an_ams3h_slot_loads(self):
  464. client = _wired(_ingested())
  465. for tray in (254, 250, 2, 128):
  466. assert client.ams_load_filament(tray) is False
  467. client._client.publish.assert_not_called()
  468. def test_a_regular_printer_has_no_rack(self):
  469. assert _client(model="X1C").tray_layout.rack_units == ()
  470. def test_captured_mapping2_slot_becomes_the_global_tray(self):
  471. detail = [{"ams_id": 32, "slot_id": 6}, {"ams_id": 32, "slot_id": 2}]
  472. # Studio's flat value is already 28 + k; Bambuddy's own pre-renumbering
  473. # dispatches wrote the raw slot. ams_mapping2 decides either way.
  474. assert resolve_external_spools_in_mapping([34, 2], detail, False, _ingested().tray_layout) == [34, 30]
  475. assert resolve_external_spools_in_mapping([6, 2], detail, False) == [34, 30]
  476. def test_dispatch_maps_slots_as_bambu_studio_does(self):
  477. client = _wired(_ingested())
  478. client.start_print("plate_2.3mf", ams_mapping=[28, 30, 36], use_ams=True)
  479. sent = _last_payload(client)
  480. assert sent["ams_mapping"] == [28, 30, 36]
  481. assert sent["ams_mapping2"] == [
  482. {"ams_id": 32, "slot_id": 0},
  483. {"ams_id": 32, "slot_id": 2},
  484. {"ams_id": 32, "slot_id": 8},
  485. ]
  486. assert sent["use_ams"] is True
  487. class TestUsageAttribution:
  488. """Usage and Spoolman tracking see the AMS 3H through its global ids 28-36, its rows as (32, k)."""
  489. def test_printer_mapping_decodes_to_global_trays(self):
  490. from backend.app.services.usage_tracker import _decode_mqtt_mapping
  491. client = _ingested()
  492. # FRAME's mapping: slicer filament i -> (32 << 8) | slot
  493. expected = [28 + (v & 0xFF) for v in FRAME["mapping"]]
  494. assert _decode_mqtt_mapping(client.state.raw_data["mapping"], client.tray_layout) == expected
  495. def test_spoolman_tray_lookup_is_keyed_by_global_tray(self):
  496. from backend.app.services.spoolman_tracking import _global_tray_id_to_ams_slot, build_ams_tray_lookup
  497. client = _ingested()
  498. lookup = build_ams_tray_lookup(client.state.raw_data)
  499. assert sorted(k for k in lookup if k < 128) == list(range(28, 37))
  500. assert lookup[36]["tray_type"] == "PLA" and not lookup[35]["tray_type"] # slot 9 loaded, slot 8 empty
  501. # ...and back to the (unit, slot) pair the slot-assignment rows are stored under
  502. assert _global_tray_id_to_ams_slot(34, client.tray_layout) == (32, 6)
  503. assert _global_tray_id_to_ams_slot(36, client.tray_layout) == (32, 8)
  504. def test_nothing_lands_in_the_ams_ht_range(self):
  505. from backend.app.services.spoolman_tracking import build_ams_tray_lookup
  506. lookup = build_ams_tray_lookup(_ingested().state.raw_data)
  507. assert not any(128 <= k <= 135 for k in lookup)
  508. class TestExternalInputs:
  509. """The H3C's nine vir_slot entries (247-255) are one external input per hotend."""
  510. VIR_SLOT = [{"id": str(i), "state": 1, "tray_type": "", "tray_color": "00000000"} for i in range(247, 256)]
  511. def _with_inputs(self) -> BambuMQTTClient:
  512. frame = _frame()
  513. frame["vir_slot"] = copy.deepcopy(self.VIR_SLOT)
  514. frame["vir_slot"][3].update(tray_type="PETG", tray_color="FF0000FF") # id 250 -> hotend 5
  515. return _ingested(frame=frame)
  516. def test_all_nine_are_kept_with_their_ids(self):
  517. vt = self._with_inputs().state.raw_data["vt_tray"]
  518. assert [v["id"] for v in vt] == [str(i) for i in range(247, 256)]
  519. def test_the_layout_counts_them_as_externals(self):
  520. layout = self._with_inputs().tray_layout
  521. assert layout.rack
  522. assert all(layout.is_external(v) for v in range(247, 256))
  523. assert layout.slot_key(250) == (255, 5)
  524. def test_dual_nozzle_external_slots_untouched(self):
  525. client = _client(model="H2D")
  526. client._process_message({"print": {"vir_slot": [{"id": "254"}, {"id": "255"}]}})
  527. assert [v["id"] for v in client.state.raw_data["vt_tray"]] == ["254", "255"]
  528. assert not client.tray_layout.rack
  529. def test_filament_setting_names_the_input(self):
  530. client = _wired(self._with_inputs())
  531. assert TestWire()._setting(client, 255, 5) # stored slot 5 = input 250
  532. sent = _last_payload(client)
  533. assert (sent["ams_id"], sent["tray_id"], sent["slot_id"]) == (250, 0, 0)
  534. def test_dispatch_sends_an_input_as_studio_does(self):
  535. client = _wired(self._with_inputs())
  536. client.start_print("plate_2.3mf", ams_mapping=[28, 250], use_ams=True)
  537. sent = _last_payload(client)
  538. assert sent["ams_mapping"] == [28, -1]
  539. assert sent["ams_mapping2"][1] == {"ams_id": 250, "slot_id": 0}
  540. assert sent["use_ams"] is True
  541. def test_an_input_only_print_turns_the_ams_off(self):
  542. client = _wired(self._with_inputs())
  543. client.start_print("plate_2.3mf", ams_mapping=[250], use_ams=True)
  544. assert _last_payload(client)["use_ams"] is False
  545. def test_a_captured_input_keeps_its_id(self):
  546. layout = self._with_inputs().tray_layout
  547. resolved = resolve_external_spools_in_mapping(
  548. [-1, -1], [{"ams_id": 250, "slot_id": 0}, {"ams_id": 255, "slot_id": 255}], False, layout
  549. )
  550. assert resolved == [250, -1]
  551. def test_printer_mapping_keeps_its_external_inputs(self):
  552. # (250 << 8) | 0 is input 250 and (255 << 8) | 0 input 255 (hotend 0), not 254.
  553. from backend.app.services.spool_assignment_notifications import _decode_mqtt_mapping_to_global_trays
  554. from backend.app.services.usage_tracker import _decode_mqtt_mapping
  555. layout = self._with_inputs().tray_layout
  556. mapping = [(32 << 8) | 1, (250 << 8) | 0, (255 << 8) | 0, 0xFFFF]
  557. assert _decode_mqtt_mapping(mapping, layout) == [29, 250, 255, -1]
  558. assert _decode_mqtt_mapping_to_global_trays(mapping, layout) == [29, 250, 255]
  559. def test_tray_now_255_is_not_a_second_external_holder(self):
  560. from backend.app.services.usage_tracker import has_second_external_holder
  561. assert has_second_external_holder(self._with_inputs().state.raw_data) is False
  562. assert has_second_external_holder({"vt_tray": [{"id": "254"}, {"id": "255"}]}) is True
  563. def test_not_a_dual_nozzle_printer(self):
  564. from backend.app.services.slot_nozzle import reports_two_nozzles
  565. assert reports_two_nozzles(self._with_inputs().state) is False
  566. class TestChamberHeater:
  567. """H3C: M141 only heats with the airduct in heating mode (captured 2026-11-06)."""
  568. def _sent(self, client: BambuMQTTClient) -> list[dict]:
  569. return [json.loads(c[0][1])["print"] for c in client._client.publish.call_args_list]
  570. def test_heating_mode_is_set_before_m141(self):
  571. client = _wired(_ingested())
  572. assert client.state.airduct_mode == 0
  573. assert client.set_chamber_temperature(45)
  574. sent = self._sent(client)
  575. assert [s["command"] for s in sent] == ["set_airduct", "gcode_line"]
  576. assert sent[0]["modeId"] == 1 and "M141 S45" in sent[1]["param"]
  577. def test_no_switch_when_already_heating_or_turning_off(self):
  578. client = _wired(_ingested())
  579. client.state.airduct_mode = 1
  580. client.set_chamber_temperature(45)
  581. client.state.airduct_mode = 0
  582. client.set_chamber_temperature(0)
  583. assert [s["command"] for s in self._sent(client)] == ["gcode_line", "gcode_line"]
  584. def test_h2_series_unchanged(self):
  585. client = _wired(_client(model="H2D"))
  586. client.set_chamber_temperature(45)
  587. assert [s["command"] for s in self._sent(client)] == ["gcode_line"]
  588. class TestDrying:
  589. """AMS 3H drying is per zone: only a zone holding exactly two slots dries.
  590. Zones as captured on 2026-11-06 while drying PLA: zone 0 = slots [5, 7]
  591. (drying, 675 min left), zone 1 = the other seven (ambient).
  592. """
  593. ZONES = [
  594. {
  595. "zone_id": 0,
  596. "zone_slots": [5, 7],
  597. "dry_time": 675,
  598. "dry_setting": {"dry_duration": 12, "dry_filament": "PLA", "dry_temperature": 55},
  599. "dry_sf_reason": [6],
  600. "info": "240842026",
  601. "temp": "55.5",
  602. "humidity_raw": "25",
  603. },
  604. {
  605. "zone_id": 1,
  606. "zone_slots": [0, 1, 2, 3, 4, 6, 8],
  607. "dry_time": 675,
  608. "dry_setting": {"dry_duration": -1, "dry_filament": "", "dry_temperature": -1},
  609. "dry_sf_reason": [6],
  610. "info": "240042006",
  611. "temp": "27.6",
  612. "humidity_raw": "33",
  613. },
  614. ]
  615. def _drying(self, zones: list | None = None) -> BambuMQTTClient:
  616. frame = _frame()
  617. unit = frame["ams"]["ams"][0]
  618. unit["zone_info"] = copy.deepcopy(zones if zones is not None else self.ZONES)
  619. unit["dry_info"] = "1"
  620. return _ingested(frame=frame)
  621. def test_the_drying_zone_goes_on_the_unit(self):
  622. unit = _unit(self._drying())
  623. assert unit["dry_time"] == 675
  624. assert unit["dry_zone_id"] == 0 and unit["dry_zone_slots"] == [5, 7]
  625. assert unit["dry_setting"]["dry_filament"] == "PLA"
  626. assert unit["dry_status"] == 2 # Drying, from the unit's info bits 4-7
  627. def test_start_names_the_zone_and_the_cooling_temp(self):
  628. client = _wired(self._drying())
  629. client.send_drying_command(32, temp=55, duration=12, mode=1, filament="PLA", rotate_tray=True)
  630. sent = _last_payload(client)
  631. assert sent["command"] == "ams_filament_drying"
  632. assert (sent["ams_id"], sent["zone_id"], sent["mode"]) == (32, 0, 1)
  633. assert (sent["temp"], sent["duration"], sent["cooling_temp"]) == (55, 12, 65)
  634. assert sent["rotate_tray"] is False # the AMS 3H can't turn spools
  635. def test_zone_id_follows_whichever_zone_holds_two_slots(self):
  636. zones = copy.deepcopy(self.ZONES)
  637. zones[0]["zone_slots"], zones[1]["zone_slots"] = [0, 1, 2, 3, 4, 6, 8], [5, 7]
  638. client = _wired(self._drying(zones))
  639. client.send_drying_command(32, temp=55, duration=4, mode=1, filament="PLA")
  640. assert _last_payload(client)["zone_id"] == 1
  641. def test_abs_cools_at_its_softening_temperature(self):
  642. client = _wired(self._drying())
  643. client.send_drying_command(32, temp=80, duration=8, mode=1, filament="ABS")
  644. sent = _last_payload(client)
  645. assert (sent["ams_id"], sent["cooling_temp"]) == (32, 95)
  646. def test_stop_names_no_zone(self):
  647. client = _wired(self._drying())
  648. client.send_drying_command(32, temp=0, duration=0, mode=0)
  649. sent = _last_payload(client)
  650. assert (sent["ams_id"], sent["mode"], sent["cooling_temp"]) == (32, 0, 0)
  651. assert "zone_id" not in sent
  652. def test_cooling_temps(self):
  653. assert ams3h_drying_cooling_temp("PLA") == 65
  654. assert ams3h_drying_cooling_temp("PETG-CF") == 75
  655. assert ams3h_drying_cooling_temp("TPU") == 70
  656. assert ams3h_drying_cooling_temp("WOOD") == 50
  657. def test_other_models_unchanged(self):
  658. client = _wired(_client(model="H2D"))
  659. client.send_drying_command(0, temp=55, duration=4, mode=1, filament="PLA")
  660. sent = _last_payload(client)
  661. assert "zone_id" not in sent and sent["cooling_temp"] == 20 and sent["ams_id"] == 0
  662. def test_the_header_reads_the_ambient_zone_and_keeps_the_dryers_own_readings(self):
  663. # Bambu Studio shows the zone that can't dry as the AMS's humidity and the
  664. # drying zone's on the dryer; the unit itself reports neither.
  665. unit = _unit(self._drying())
  666. assert (unit["humidity_raw"], unit["temp"]) == ("33", "27.6")
  667. assert (unit["dry_zone_humidity_raw"], unit["dry_zone_temp"]) == ("25", "55.5")
  668. def test_the_card_gets_the_dryer_slots_and_readings(self):
  669. (card,) = ams_units_to_dicts(self._drying().state.raw_data["ams"], lambda *a: None)
  670. assert (card["humidity"], card["temp"]) == (33, 27.6)
  671. assert (card["dryer_slots"], card["dryer_humidity"], card["dryer_temp"]) == ([5, 7], 25, 55.5)
  672. def test_no_zone_info_means_no_dryer_on_the_card(self):
  673. (card,) = ams_units_to_dicts(_ingested().state.raw_data["ams"], lambda *a: None)
  674. assert (card["dryer_slots"], card["dryer_humidity"], card["dryer_temp"]) == ([], None, None)
  675. assert card["humidity"] is None