test_p2s_accessory_fans.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. """Tests for the P2S/X2D left auxiliary part cooling fan (#2576).
  2. The "Auxiliary Part Cooling Fan - Left" (also fits X2D) is reported ONLY as
  3. device.airduct part with raw id 160 (decoded id = 160 >> 4 = 10,
  4. AIR_FUN.FAN_REMOTE_COOLING_1 in Bambu Studio) — the firmware does NOT mirror
  5. it into any flat big_fanX_speed field, which is why it was previously dropped.
  6. It is controlled with "M106 P10", exactly like Bambu's official P2S machine-
  7. profile gcode does.
  8. The airduct payloads below are verbatim captures from a live P2S
  9. (fw 01.02.00.00) with the accessory installed.
  10. """
  11. import pytest
  12. @pytest.fixture
  13. def mqtt_client():
  14. from backend.app.services.bambu_mqtt import BambuMQTTClient
  15. return BambuMQTTClient(
  16. ip_address="192.168.1.100",
  17. serial_number="TESTP2S",
  18. access_code="12345678",
  19. )
  20. def _airduct_device(parts):
  21. """Wrap airduct parts in the device envelope as pushed by a P2S."""
  22. return {
  23. "device": {
  24. "airduct": {
  25. "modeCur": 0,
  26. "modeFunc": 0,
  27. "modeList": [
  28. {"ctrl": [16, 32, 160, 48], "modeId": 0, "off": []},
  29. {"ctrl": [16, 32, 48], "modeId": 1, "off": [160]},
  30. ],
  31. "modeVisable": 7,
  32. "parts": parts,
  33. "subFunc": 0,
  34. "subMode": 0,
  35. "subVisable": 7,
  36. "version": 1,
  37. },
  38. "type": 1,
  39. }
  40. }
  41. # Verbatim parts list from a live P2S: part cooling ramping (state 30,
  42. # target 90), right aux at 40%, left aux OFF, chamber at 70%.
  43. P2S_PARTS_LEFT_AUX_OFF = [
  44. {"func": 0, "id": 16, "range": 6553600, "state": 30, "tar_state": 90},
  45. {"func": 6, "id": 32, "range": 6553600, "state": 40, "tar_state": 40},
  46. {"func": 5, "id": 160, "range": 6553600, "state": 0, "tar_state": 0},
  47. {"func": 2, "id": 48, "range": 6553600, "state": 70, "tar_state": 70},
  48. ]
  49. # Same printer later in the print: left aux running at 80%.
  50. P2S_PARTS_LEFT_AUX_80 = [
  51. {"func": 0, "id": 16, "range": 6553600, "state": 60, "tar_state": 60},
  52. {"func": 6, "id": 32, "range": 6553600, "state": 100, "tar_state": 100},
  53. {"func": 5, "id": 160, "range": 6553600, "state": 80, "tar_state": 80},
  54. {"func": 2, "id": 48, "range": 6553600, "state": 80, "tar_state": 80},
  55. ]
  56. class TestLeftAuxFanParsing:
  57. """device.airduct part id 10 (raw 160) -> state.left_aux_fan_speed."""
  58. def test_defaults_to_none(self, mqtt_client):
  59. assert mqtt_client.state.left_aux_fan_speed is None
  60. def test_parses_left_aux_running(self, mqtt_client):
  61. mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
  62. assert mqtt_client.state.left_aux_fan_speed == 80
  63. def test_parses_left_aux_off(self, mqtt_client):
  64. mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_OFF))
  65. assert mqtt_client.state.left_aux_fan_speed == 0
  66. def test_raw_id_is_bit_unpacked(self, mqtt_client):
  67. """Raw id 160 must decode to part id 10 (id >> 4), NOT match on 160."""
  68. # A hypothetical raw id of 10 would decode to part id 0 — must not match.
  69. parts = [{"func": 5, "id": 10, "range": 6553600, "state": 50, "tar_state": 50}]
  70. mqtt_client._update_state(_airduct_device(parts))
  71. assert mqtt_client.state.left_aux_fan_speed is None
  72. def test_parts_without_left_aux_reports_none(self, mqtt_client):
  73. """A full parts list without id 10 means the fan is not installed."""
  74. mqtt_client.state.left_aux_fan_speed = 80 # previously seen
  75. parts = [p for p in P2S_PARTS_LEFT_AUX_80 if p["id"] != 160]
  76. mqtt_client._update_state(_airduct_device(parts))
  77. assert mqtt_client.state.left_aux_fan_speed is None
  78. def test_diff_push_without_device_preserves_value(self, mqtt_client):
  79. """P-series diff pushes omit device.airduct — value must survive."""
  80. mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
  81. mqtt_client._update_state({"nozzle_temper": 250.0})
  82. assert mqtt_client.state.left_aux_fan_speed == 80
  83. def test_state_clamped_to_0_100(self, mqtt_client):
  84. parts = [{"func": 5, "id": 160, "range": 6553600, "state": 250, "tar_state": 0}]
  85. mqtt_client._update_state(_airduct_device(parts))
  86. assert mqtt_client.state.left_aux_fan_speed == 100
  87. def test_packed_state_decodes_from_low_8_bits(self, mqtt_client):
  88. """`state` is bit-packed like its sibling `range` (end << 16 | start).
  89. Bambu Studio decodes it with get_flag_bits(state, 0, 8), so only the low
  90. byte carries the percentage. Without the mask a packed value would clamp
  91. to 100 instead of decoding to the real speed.
  92. """
  93. packed = (60 << 16) | 45 # sibling field in the high bits, 45% in the low byte
  94. parts = [{"func": 5, "id": 160, "range": 6553600, "state": packed, "tar_state": 0}]
  95. mqtt_client._update_state(_airduct_device(parts))
  96. assert mqtt_client.state.left_aux_fan_speed == 45
  97. def test_unpacked_state_is_unaffected_by_the_mask(self, mqtt_client):
  98. # Plain 0-100 values (what a P2S actually sends) must round-trip exactly.
  99. for speed in (0, 30, 80, 100):
  100. parts = [{"func": 5, "id": 160, "range": 6553600, "state": speed, "tar_state": speed}]
  101. mqtt_client._update_state(_airduct_device(parts))
  102. assert mqtt_client.state.left_aux_fan_speed == speed
  103. def test_malformed_part_entries_ignored(self, mqtt_client):
  104. parts = [
  105. "not-a-dict",
  106. {"func": 5}, # no id/state
  107. {"id": "garbage", "state": 10},
  108. {"func": 5, "id": 160, "range": 6553600, "state": 30, "tar_state": 30},
  109. ]
  110. mqtt_client._update_state(_airduct_device(parts))
  111. assert mqtt_client.state.left_aux_fan_speed == 30
  112. def test_flat_fan_fields_unaffected(self, mqtt_client):
  113. """Regression: flat fields keep coming from the flat MQTT keys."""
  114. payload = {
  115. "cooling_fan_speed": "4",
  116. "big_fan1_speed": "6",
  117. "big_fan2_speed": "10",
  118. "heatbreak_fan_speed": "14",
  119. **_airduct_device(P2S_PARTS_LEFT_AUX_OFF),
  120. }
  121. mqtt_client._update_state(payload)
  122. assert mqtt_client.state.cooling_fan_speed == 27 # 4/15
  123. assert mqtt_client.state.big_fan1_speed == 40 # 6/15
  124. assert mqtt_client.state.big_fan2_speed == 67 # 10/15
  125. assert mqtt_client.state.heatbreak_fan_speed == 93 # 14/15
  126. assert mqtt_client.state.left_aux_fan_speed == 0
  127. class TestExhaustFanPresence:
  128. """device.airduct part id 3 (raw 48) presence -> state.exhaust_fan_present.
  129. The chamber exhaust fan is a P2S/X2D add-on kit (get_version module "eef").
  130. Its speed rides on the flat big_fan2_speed field, but the airduct only lists
  131. part id 3 when the kit is physically installed — so part-3 presence is the
  132. signal the UI uses to show/hide the Exhaust tile.
  133. """
  134. def test_defaults_to_false(self, mqtt_client):
  135. assert mqtt_client.state.exhaust_fan_present is False
  136. def test_present_when_part_3_reported(self, mqtt_client):
  137. # Full P2S parts list includes id 48 (>>4 = 3).
  138. mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
  139. assert mqtt_client.state.exhaust_fan_present is True
  140. def test_absent_when_part_3_missing(self, mqtt_client):
  141. mqtt_client.state.exhaust_fan_present = True # previously seen
  142. parts = [p for p in P2S_PARTS_LEFT_AUX_80 if p["id"] != 48]
  143. mqtt_client._update_state(_airduct_device(parts))
  144. assert mqtt_client.state.exhaust_fan_present is False
  145. def test_base_p2s_only_part_cooling_and_aux(self, mqtt_client):
  146. # A base P2S (no exhaust kit, no left aux kit) lists only ids 1 and 2.
  147. parts = [
  148. {"func": 0, "id": 16, "range": 6553600, "state": 0, "tar_state": 0},
  149. {"func": 6, "id": 32, "range": 6553600, "state": 0, "tar_state": 0},
  150. ]
  151. mqtt_client._update_state(_airduct_device(parts))
  152. assert mqtt_client.state.exhaust_fan_present is False
  153. assert mqtt_client.state.left_aux_fan_speed is None
  154. def test_diff_push_without_device_preserves_value(self, mqtt_client):
  155. mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
  156. mqtt_client._update_state({"nozzle_temper": 250.0})
  157. assert mqtt_client.state.exhaust_fan_present is True
  158. class TestPartialPartsFrames:
  159. """A `parts` list that is not a full inventory must not retract presence.
  160. `device.airduct` is pushed field by field — the `modeCur` handler reads it
  161. with an `in` check for exactly that reason — so a frame can carry `parts`
  162. without carrying every fan. Absence is what tells us a kit is not fitted, so
  163. it is only trustworthy on a complete list. Read as gospel, a truncated frame
  164. would make both accessory badges vanish mid-print and start rejecting
  165. ``fan=aux2`` on a printer that does have the fan.
  166. Completeness is judged on ids 1 (part cooling) and 2 (aux) being present:
  167. neither is optional on any machine that reports an airduct at all, and both
  168. appear in every layout in the support-package archive (P2S base 1,2 /
  169. P2S+kit 1,2,3 / X2D 1,2,3,10 / H2C,H2D,H2S 1,2,3,6).
  170. """
  171. def test_partial_frame_does_not_retract_the_left_aux_fan(self, mqtt_client):
  172. mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
  173. assert mqtt_client.state.left_aux_fan_speed == 80
  174. # Only the part cooling fan changed — the frame says nothing about the
  175. # left aux fan, which is not the same as saying it is gone.
  176. mqtt_client._update_state(
  177. _airduct_device([{"func": 0, "id": 16, "range": 6553600, "state": 70, "tar_state": 70}])
  178. )
  179. assert mqtt_client.state.left_aux_fan_speed == 80
  180. def test_partial_frame_does_not_retract_the_exhaust_fan(self, mqtt_client):
  181. mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
  182. assert mqtt_client.state.exhaust_fan_present is True
  183. mqtt_client._update_state(
  184. _airduct_device([{"func": 0, "id": 16, "range": 6553600, "state": 70, "tar_state": 70}])
  185. )
  186. assert mqtt_client.state.exhaust_fan_present is True
  187. def test_a_partial_frame_still_applies_the_speed_it_carries(self, mqtt_client):
  188. """Not-authoritative-for-absence is not the same as ignored."""
  189. mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
  190. assert mqtt_client.state.left_aux_fan_speed == 80
  191. mqtt_client._update_state(
  192. _airduct_device([{"func": 5, "id": 160, "range": 6553600, "state": 25, "tar_state": 25}])
  193. )
  194. assert mqtt_client.state.left_aux_fan_speed == 25
  195. def test_a_partial_frame_can_still_reveal_a_fan(self, mqtt_client):
  196. """Presence may always be added — only retraction needs a full list."""
  197. mqtt_client._update_state(
  198. _airduct_device([{"func": 2, "id": 48, "range": 6553600, "state": 70, "tar_state": 70}])
  199. )
  200. assert mqtt_client.state.exhaust_fan_present is True
  201. def test_a_full_frame_still_retracts_both(self, mqtt_client):
  202. """The kits really can be removed, and a complete list must say so —
  203. this is the behaviour the presence gate exists for."""
  204. mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
  205. assert mqtt_client.state.left_aux_fan_speed == 80
  206. assert mqtt_client.state.exhaust_fan_present is True
  207. # Base P2S layout: part cooling + aux only.
  208. mqtt_client._update_state(
  209. _airduct_device(
  210. [
  211. {"func": 0, "id": 16, "range": 6553600, "state": 0, "tar_state": 0},
  212. {"func": 6, "id": 32, "range": 6553600, "state": 0, "tar_state": 0},
  213. ]
  214. )
  215. )
  216. assert mqtt_client.state.left_aux_fan_speed is None
  217. assert mqtt_client.state.exhaust_fan_present is False
  218. def test_an_empty_parts_list_changes_nothing(self, mqtt_client):
  219. mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
  220. mqtt_client._update_state(_airduct_device([]))
  221. assert mqtt_client.state.left_aux_fan_speed == 80
  222. assert mqtt_client.state.exhaust_fan_present is True
  223. class TestLeftAuxFanCommand:
  224. """set_fan_speed must accept index 10 and emit M106 P10."""
  225. def test_set_fan_speed_10_sends_m106_p10(self, mqtt_client, monkeypatch):
  226. sent = []
  227. monkeypatch.setattr(mqtt_client, "send_gcode", lambda g: sent.append(g) or True)
  228. assert mqtt_client.set_fan_speed(10, 204) is True
  229. assert sent == ["M106 P10 S204"]
  230. def test_set_left_aux_fan_helper(self, mqtt_client, monkeypatch):
  231. sent = []
  232. monkeypatch.setattr(mqtt_client, "send_gcode", lambda g: sent.append(g) or True)
  233. assert mqtt_client.set_left_aux_fan(255) is True
  234. assert sent == ["M106 P10 S255"]
  235. def test_speed_clamped_to_255(self, mqtt_client, monkeypatch):
  236. sent = []
  237. monkeypatch.setattr(mqtt_client, "send_gcode", lambda g: sent.append(g) or True)
  238. mqtt_client.set_left_aux_fan(999)
  239. assert sent == ["M106 P10 S255"]
  240. def test_invalid_fan_index_rejected(self, mqtt_client, monkeypatch):
  241. sent = []
  242. monkeypatch.setattr(mqtt_client, "send_gcode", lambda g: sent.append(g) or True)
  243. assert mqtt_client.set_fan_speed(4, 100) is False
  244. assert mqtt_client.set_fan_speed(11, 100) is False
  245. assert sent == []
  246. def test_existing_fan_indexes_still_accepted(self, mqtt_client, monkeypatch):
  247. sent = []
  248. monkeypatch.setattr(mqtt_client, "send_gcode", lambda g: sent.append(g) or True)
  249. for idx in (1, 2, 3):
  250. assert mqtt_client.set_fan_speed(idx, 128) is True
  251. assert sent == ["M106 P1 S128", "M106 P2 S128", "M106 P3 S128"]
  252. class TestExhaustFanLabelModels:
  253. """P2S/X2D call the big_fan2 enclosure fan "Exhaust"; others say "Chamber"."""
  254. def test_p2s_and_x2d_use_exhaust_label(self):
  255. from backend.app.utils.printer_models import uses_exhaust_fan_label
  256. for model in ("P2S", "X2D", "p2s", " P2S ", "N7", "N6"):
  257. assert uses_exhaust_fan_label(model) is True, model
  258. def test_other_enclosed_models_keep_chamber_label(self):
  259. from backend.app.utils.printer_models import uses_exhaust_fan_label
  260. for model in ("X1C", "X1", "X1E", "P1S", "H2D", "H2C", "H2S", "A1"):
  261. assert uses_exhaust_fan_label(model) is False, model
  262. def test_unknown_or_missing_model_defaults_to_chamber(self):
  263. from backend.app.utils.printer_models import uses_exhaust_fan_label
  264. assert uses_exhaust_fan_label(None) is False
  265. assert uses_exhaust_fan_label("") is False
  266. assert uses_exhaust_fan_label("SomeFutureModel") is False
  267. class TestExhaustLabelModelListsAgree:
  268. """The exhaust-label model list is duplicated across the stack.
  269. The backend keeps ``EXHAUST_FAN_LABEL_MODELS`` (display names plus the N7/N6
  270. internal codes, since the API can be handed either) and the frontend keeps
  271. ``MODELS_WITH_EXHAUST_LABEL`` in PrintersPage.tsx (display names only —
  272. ``printer.model`` is always a display name by the time it reaches the card).
  273. Both are correct as written, but nothing stopped them drifting apart: adding
  274. a model to one and forgetting the other silently produces a card labelled
  275. "Exhaust" whose control toast says "Chamber fan", or vice versa.
  276. """
  277. def _frontend_models(self) -> set[str]:
  278. import re
  279. from pathlib import Path
  280. import pytest
  281. # Walk up rather than hard-coding a parent depth, so the test survives
  282. # the file being moved and works whatever directory pytest runs from.
  283. relative = Path("frontend") / "src" / "pages" / "PrintersPage.tsx"
  284. source = next(
  285. (candidate for parent in Path(__file__).resolve().parents if (candidate := parent / relative).is_file()),
  286. None,
  287. )
  288. if source is None:
  289. pytest.skip("frontend sources not present in this checkout")
  290. text = source.read_text(encoding="utf-8")
  291. match = re.search(
  292. r"const MODELS_WITH_EXHAUST_LABEL:[^=]*=\s*new Set\(\[(.*?)\]\)",
  293. text,
  294. re.DOTALL,
  295. )
  296. assert match, "MODELS_WITH_EXHAUST_LABEL not found in PrintersPage.tsx"
  297. return set(re.findall(r"['\"]([^'\"]+)['\"]", match.group(1)))
  298. def test_frontend_list_is_the_display_name_subset_of_the_backend_list(self):
  299. from backend.app.utils.printer_models import EXHAUST_FAN_LABEL_MODELS
  300. frontend = self._frontend_models()
  301. assert frontend, "frontend list parsed as empty"
  302. missing = frontend - set(EXHAUST_FAN_LABEL_MODELS)
  303. assert not missing, (
  304. f"models {sorted(missing)} label the fan 'Exhaust' in the UI but the backend "
  305. f"would report 'Chamber fan' — add them to EXHAUST_FAN_LABEL_MODELS"
  306. )
  307. def test_every_backend_display_name_is_handled_by_the_frontend(self):
  308. from backend.app.utils.printer_models import EXHAUST_FAN_LABEL_MODELS
  309. # N7/N6 are internal codes that never reach the card, so exclude them.
  310. internal_codes = {"N7", "N6"}
  311. backend_display = set(EXHAUST_FAN_LABEL_MODELS) - internal_codes
  312. missing = backend_display - self._frontend_models()
  313. assert not missing, (
  314. f"models {sorted(missing)} say 'Exhaust fan' in the API response but the card "
  315. f"would still show 'Chamber Fan' — add them to MODELS_WITH_EXHAUST_LABEL"
  316. )