From 9ee162d51a6348bb172237ac84b806065a75a5c4 Mon Sep 17 00:00:00 2001 From: Gabe Date: Fri, 24 Jul 2026 17:42:58 -0500 Subject: [PATCH] feat(printers): expose P2S/X2D accessory fans (left aux + exhaust) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The P2S/X2D have two fans bambuddy didn't fully handle. On the P2S both are add-on kits; on the X2D they ship from the factory. 1. Left auxiliary part cooling fan — not shown or controllable. It is reported ONLY as device.airduct part id 10 (raw id 160 >> 4; FAN_REMOTE_COOLING_1 in Bambu Studio's DevFan::ParseV3_0) and is never mirrored into a flat big_fanX_speed field, which is why it was invisible. This is the gap identified in #2576, where the single 'Auxiliary' fan (big_fan1 / M106 P2) only reaches the right-hand aux fan. 2. Chamber exhaust fan — shown on every P2S labelled 'Chamber Fan'. On P2S/X2D Bambu's firmware/UI (and Bambu Studio's FAN_CHAMBER_0_IDX) call it 'Exhaust', and it is a kit on the P2S rather than built in. Both are now detected from device.airduct.parts, which lists only the fans that physically exist, so each tile appears only when the hardware is present. - bambu_mqtt: parse airduct part 10 -> left_aux_fan_speed (None when absent) and part 3 presence -> exhaust_fan_present; set_fan_speed() accepts index 10 plus a set_left_aux_fan() helper - schema / status route / printer_manager broadcast / mqtt_relay expose both fields - POST /printers/{id}/fan-speed accepts fan=aux2 -> M106 P10, the command Bambu's official P2S machine profiles use - frontend: 'Left Auxiliary Fan' tile shown when reported; big_fan2 tile labelled 'Exhaust' and presence-gated on P2S/X2D, unchanged 'Chamber Fan' elsewhere - i18n: leftAuxiliary + exhaust for all 12 locales Verified fan -> field map on a live P2S (fw 01.02.00.00), stable across cooling and heating airduct modes: Part cooling -> cooling_fan_speed / airduct id 1 (built in) Aux -> big_fan1_speed / airduct id 2 (built in) Exhaust -> big_fan2_speed / airduct id 3 (kit) Left aux -> airduct id 10 only (kit; forced off in heating by mode config) Tests: airduct id-10 parsing (raw 160 -> id 10, not literal 160), id-3 presence, base-P2S absence, diff-push survival, clamping, malformed entries, M106 P10 emission, invalid-index rejection; fan-speed API aux2->10 mapping; frontend tile presence and labelling per model/kit. --- backend/app/api/routes/printers.py | 23 +- backend/app/schemas/printer.py | 5 + backend/app/services/bambu_mqtt.py | 58 ++++- backend/app/services/mqtt_relay.py | 2 + backend/app/services/printer_manager.py | 2 + .../tests/integration/test_printers_api.py | 8 +- .../unit/services/test_p2s_accessory_fans.py | 207 ++++++++++++++++++ .../test_printer_manager_status_broadcast.py | 2 + .../src/__tests__/pages/PrintersPage.test.tsx | 85 +++++++ frontend/src/api/client.ts | 7 +- frontend/src/i18n/locales/de.ts | 2 + frontend/src/i18n/locales/en.ts | 2 + frontend/src/i18n/locales/es.ts | 2 + frontend/src/i18n/locales/fr.ts | 2 + frontend/src/i18n/locales/it.ts | 2 + frontend/src/i18n/locales/ja.ts | 2 + frontend/src/i18n/locales/ko.ts | 2 + frontend/src/i18n/locales/pt-BR.ts | 2 + frontend/src/i18n/locales/ru.ts | 2 + frontend/src/i18n/locales/tr.ts | 2 + frontend/src/i18n/locales/zh-CN.ts | 2 + frontend/src/i18n/locales/zh-TW.ts | 2 + frontend/src/pages/PrintersPage.tsx | 53 ++++- 23 files changed, 460 insertions(+), 16 deletions(-) create mode 100644 backend/tests/unit/services/test_p2s_accessory_fans.py diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index 63e70d246..b64adccac 100644 --- a/backend/app/api/routes/printers.py +++ b/backend/app/api/routes/printers.py @@ -798,6 +798,8 @@ async def get_printer_status( big_fan1_speed=state.big_fan1_speed, big_fan2_speed=state.big_fan2_speed, heatbreak_fan_speed=state.heatbreak_fan_speed, + left_aux_fan_speed=state.left_aux_fan_speed, + exhaust_fan_present=state.exhaust_fan_present, firmware_version=state.firmware_version, developer_mode=state.developer_mode if state else None, ams_filament_backup=state.ams_filament_backup if state else None, @@ -3193,16 +3195,22 @@ async def set_chamber_temperature( @router.post("/{printer_id}/fan-speed") async def set_fan_speed( printer_id: int, - fan: str = Query(..., description="Fan to control: part, aux, or chamber"), + fan: str = Query(..., description="Fan to control: part, aux, aux2 (left aux), or chamber"), speed: int = Query(..., ge=0, le=100, description="Fan speed percentage"), _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): - """Set a fan speed by percentage.""" - fan_ids = {"part": 1, "aux": 2, "chamber": 3} + """Set a fan speed by percentage. + + Fan index 10 ("aux2") is the optional left auxiliary part cooling fan on + P2S/X2D — driven with "M106 P10" exactly like Bambu's official machine + profile gcode does. It only exists when the accessory is installed; the + firmware silently ignores the command otherwise. + """ + fan_ids = {"part": 1, "aux": 2, "chamber": 3, "aux2": 10} fan_id = fan_ids.get(fan) if fan_id is None: - raise HTTPException(400, "fan must be 'part', 'aux', or 'chamber'") + raise HTTPException(400, "fan must be 'part', 'aux', 'aux2', or 'chamber'") result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() @@ -3218,7 +3226,12 @@ async def set_fan_speed( if not success: raise HTTPException(500, "Failed to set fan speed") - fan_names = {"part": "Part cooling fan", "aux": "Auxiliary fan", "chamber": "Chamber fan"} + fan_names = { + "part": "Part cooling fan", + "aux": "Auxiliary fan", + "aux2": "Left auxiliary fan", + "chamber": "Chamber fan", + } return {"success": True, "message": f"{fan_names[fan]} set to {speed}%"} diff --git a/backend/app/schemas/printer.py b/backend/app/schemas/printer.py index e55486e62..ae9dad664 100644 --- a/backend/app/schemas/printer.py +++ b/backend/app/schemas/printer.py @@ -360,6 +360,11 @@ class PrinterStatus(BaseModel): big_fan1_speed: int | None = None # Auxiliary fan big_fan2_speed: int | None = None # Chamber/exhaust fan heatbreak_fan_speed: int | None = None # Hotend heatbreak fan + # Left auxiliary part cooling fan (optional P2S/X2D accessory, airduct part id 10). + # None = not installed / not reported by this model. + left_aux_fan_speed: int | None = None + # Chamber exhaust fan present (P2S/X2D External Exhaust Fan kit; airduct part id 3). + exhaust_fan_present: bool = False # Firmware version (from info.module[name="ota"].sw_ver) firmware_version: str | None = None # Developer LAN mode: True = enabled, False = disabled (MQTT encryption), None = unknown diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index 6b9862a47..fa45be216 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -449,6 +449,14 @@ class PrinterState: big_fan1_speed: int | None = None # Auxiliary fan big_fan2_speed: int | None = None # Chamber/exhaust fan heatbreak_fan_speed: int | None = None # Hotend heatbreak fan + # Left auxiliary part cooling fan (optional accessory on P2S/X2D). Reported ONLY + # via device.airduct.parts (decoded part id 10 = FAN_REMOTE_COOLING_1 in Bambu + # Studio's AIR_FUN enum) — the firmware does NOT mirror it into any flat + # big_fanX_speed field, which is why it was previously dropped. 0-100 percent. + left_aux_fan_speed: int | None = None + # Chamber exhaust fan (P2S/X2D External Exhaust Fan kit). Presence is derived + # from the airduct parts list containing decoded id 3; a base P2S omits it. + exhaust_fan_present: bool = False # Tray change history during current print: [(global_tray_id, layer_num), ...] # Used by usage tracker to split filament weight on mid-print tray switch tray_change_log: list = field(default_factory=list) @@ -3215,6 +3223,45 @@ class BambuMQTTClient: f"[{self.serial_number}] airduct_mode changed: {self.state.airduct_mode} -> {new_mode}" ) self.state.airduct_mode = new_mode + # Parse individual airduct fan parts (new-protocol models: P2S/X2D/H2*). + # Raw part ids are bit-packed — decoded id = raw_id >> 4 (bits 4-11), + # mirroring Bambu Studio DevFan::ParseV3_0. Decoded ids follow the + # AIR_FUN enum: 1=part cooling, 2=right aux, 3=chamber/exhaust, + # 10=left aux (FAN_REMOTE_COOLING_1). The airduct `parts` list only + # contains the fans that physically exist, so it doubles as a + # presence signal for the two P2S/X2D add-on kits: + # - id 10 (left auxiliary part cooling fan) — reported ONLY here, + # never mirrored into a flat big_fanX_speed field. + # - id 3 (chamber exhaust fan) — its speed is mirrored into + # big_fan2_speed, but the part is only listed when the External + # Exhaust Fan kit (get_version module "eef") is installed. + # `state` is already a 0-100 percentage. + parts = airduct_data.get("parts") + if isinstance(parts, list): + left_aux_speed = None + exhaust_present = False + for part in parts: + if not isinstance(part, dict): + continue + try: + part_id = int(part["id"]) >> 4 + part_state = int(part["state"]) + except (KeyError, ValueError, TypeError): + continue + if part_id == 10: + left_aux_speed = max(0, min(100, part_state)) + elif part_id == 3: + exhaust_present = True + if left_aux_speed != self.state.left_aux_fan_speed: + logger.debug( + f"[{self.serial_number}] left_aux_fan_speed changed: " + f"{self.state.left_aux_fan_speed} -> {left_aux_speed}" + ) + # A full parts list without id 10 means the left aux fan is not + # installed — report None so the UI can hide the widget. + self.state.left_aux_fan_speed = left_aux_speed + # id 3 present == chamber exhaust fan installed (base P2S omits it). + self.state.exhaust_fan_present = exhaust_present # Parse chamber temp - may be encoded as (target*65536+current) when > 500 # Check if we recently set the target locally (within 5 seconds) local_set_time = self.state.temperatures.get("_chamber_target_set_time", 0) @@ -5463,13 +5510,16 @@ class BambuMQTTClient: """Set fan speed. Args: - fan: Fan index (1=part cooling, 2=auxiliary, 3=chamber) + fan: Fan index (1=part cooling, 2=auxiliary, 3=chamber, 10=left auxiliary). + Index 10 is the optional left auxiliary part cooling fan on P2S/X2D + (airduct part id 10); Bambu's official machine profiles drive it with + "M106 P10" in start/layer-change gcode. speed: Speed 0-255 (0=off, 255=full) Returns: True if command was sent, False otherwise """ - if fan not in (1, 2, 3): + if fan not in (1, 2, 3, 10): logger.warning("[%s] Invalid fan index: %s", self.serial_number, fan) return False @@ -5488,6 +5538,10 @@ class BambuMQTTClient: """Set chamber fan speed (0-255).""" return self.set_fan_speed(3, speed) + def set_left_aux_fan(self, speed: int) -> bool: + """Set left auxiliary part cooling fan speed (0-255). P2S/X2D accessory.""" + return self.set_fan_speed(10, speed) + def set_airduct_mode(self, mode: str) -> bool: """Set air conditioning mode (cooling or heating). diff --git a/backend/app/services/mqtt_relay.py b/backend/app/services/mqtt_relay.py index 0150dc652..8f04d48cb 100644 --- a/backend/app/services/mqtt_relay.py +++ b/backend/app/services/mqtt_relay.py @@ -282,6 +282,8 @@ class MQTTRelayService: "big_fan1_speed": state.big_fan1_speed, "big_fan2_speed": state.big_fan2_speed, "heatbreak_fan_speed": state.heatbreak_fan_speed, + "left_aux_fan_speed": state.left_aux_fan_speed, + "exhaust_fan_present": state.exhaust_fan_present, # Bambuddy-side gate, not printer telemetry (#2525). Mirrors what the # Web UI already receives via printer_state_to_dict, so an external # automation can tell "finished" from "finished and still waiting for diff --git a/backend/app/services/printer_manager.py b/backend/app/services/printer_manager.py index 9729569ce..903d26984 100644 --- a/backend/app/services/printer_manager.py +++ b/backend/app/services/printer_manager.py @@ -1413,6 +1413,8 @@ def printer_state_to_dict( "big_fan1_speed": state.big_fan1_speed, "big_fan2_speed": state.big_fan2_speed, "heatbreak_fan_speed": state.heatbreak_fan_speed, + "left_aux_fan_speed": state.left_aux_fan_speed, + "exhaust_fan_present": state.exhaust_fan_present, # Chamber light state "chamber_light": state.chamber_light, # Active extruder for dual-nozzle printers (0=right, 1=left) diff --git a/backend/tests/integration/test_printers_api.py b/backend/tests/integration/test_printers_api.py index 6cd4abef8..e75cc2e9d 100644 --- a/backend/tests/integration/test_printers_api.py +++ b/backend/tests/integration/test_printers_api.py @@ -3855,8 +3855,10 @@ class TestSetChamberTemperatureAPI: class TestSetFanSpeedAPI: """Integration tests for POST /printers/{id}/fan-speed (#1661). - The fan-id mapping (part->1, aux->2, chamber->3) is the critical - correctness gate — wrong mapping would target the wrong physical fan. + The fan-id mapping (part->1, aux->2, chamber->3, aux2->10) is the + critical correctness gate — wrong mapping would target the wrong + physical fan. "aux2" (M106 P10) is the optional left auxiliary part + cooling fan on P2S/X2D. """ @pytest.mark.asyncio @@ -3879,7 +3881,7 @@ class TestSetFanSpeedAPI: @pytest.mark.integration @pytest.mark.parametrize( "fan_name,expected_fan_id", - [("part", 1), ("aux", 2), ("chamber", 3)], + [("part", 1), ("aux", 2), ("chamber", 3), ("aux2", 10)], ) async def test_fan_id_mapping(self, async_client: AsyncClient, printer_factory, fan_name, expected_fan_id): """Verify each fan name maps to the correct hardware fan-id.""" diff --git a/backend/tests/unit/services/test_p2s_accessory_fans.py b/backend/tests/unit/services/test_p2s_accessory_fans.py new file mode 100644 index 000000000..1ff40d23d --- /dev/null +++ b/backend/tests/unit/services/test_p2s_accessory_fans.py @@ -0,0 +1,207 @@ +"""Tests for the P2S/X2D left auxiliary part cooling fan (#2576). + +The "Auxiliary Part Cooling Fan - Left" (also fits X2D) is reported ONLY as +device.airduct part with raw id 160 (decoded id = 160 >> 4 = 10, +AIR_FUN.FAN_REMOTE_COOLING_1 in Bambu Studio) — the firmware does NOT mirror +it into any flat big_fanX_speed field, which is why it was previously dropped. +It is controlled with "M106 P10", exactly like Bambu's official P2S machine- +profile gcode does. + +The airduct payloads below are verbatim captures from a live P2S +(fw 01.02.00.00) with the accessory installed. +""" + +import pytest + + +@pytest.fixture +def mqtt_client(): + from backend.app.services.bambu_mqtt import BambuMQTTClient + + return BambuMQTTClient( + ip_address="192.168.1.100", + serial_number="TESTP2S", + access_code="12345678", + ) + + +def _airduct_device(parts): + """Wrap airduct parts in the device envelope as pushed by a P2S.""" + return { + "device": { + "airduct": { + "modeCur": 0, + "modeFunc": 0, + "modeList": [ + {"ctrl": [16, 32, 160, 48], "modeId": 0, "off": []}, + {"ctrl": [16, 32, 48], "modeId": 1, "off": [160]}, + ], + "modeVisable": 7, + "parts": parts, + "subFunc": 0, + "subMode": 0, + "subVisable": 7, + "version": 1, + }, + "type": 1, + } + } + + +# Verbatim parts list from a live P2S: part cooling ramping (state 30, +# target 90), right aux at 40%, left aux OFF, chamber at 70%. +P2S_PARTS_LEFT_AUX_OFF = [ + {"func": 0, "id": 16, "range": 6553600, "state": 30, "tar_state": 90}, + {"func": 6, "id": 32, "range": 6553600, "state": 40, "tar_state": 40}, + {"func": 5, "id": 160, "range": 6553600, "state": 0, "tar_state": 0}, + {"func": 2, "id": 48, "range": 6553600, "state": 70, "tar_state": 70}, +] + +# Same printer later in the print: left aux running at 80%. +P2S_PARTS_LEFT_AUX_80 = [ + {"func": 0, "id": 16, "range": 6553600, "state": 60, "tar_state": 60}, + {"func": 6, "id": 32, "range": 6553600, "state": 100, "tar_state": 100}, + {"func": 5, "id": 160, "range": 6553600, "state": 80, "tar_state": 80}, + {"func": 2, "id": 48, "range": 6553600, "state": 80, "tar_state": 80}, +] + + +class TestLeftAuxFanParsing: + """device.airduct part id 10 (raw 160) -> state.left_aux_fan_speed.""" + + def test_defaults_to_none(self, mqtt_client): + assert mqtt_client.state.left_aux_fan_speed is None + + def test_parses_left_aux_running(self, mqtt_client): + mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80)) + assert mqtt_client.state.left_aux_fan_speed == 80 + + def test_parses_left_aux_off(self, mqtt_client): + mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_OFF)) + assert mqtt_client.state.left_aux_fan_speed == 0 + + def test_raw_id_is_bit_unpacked(self, mqtt_client): + """Raw id 160 must decode to part id 10 (id >> 4), NOT match on 160.""" + # A hypothetical raw id of 10 would decode to part id 0 — must not match. + parts = [{"func": 5, "id": 10, "range": 6553600, "state": 50, "tar_state": 50}] + mqtt_client._update_state(_airduct_device(parts)) + assert mqtt_client.state.left_aux_fan_speed is None + + def test_parts_without_left_aux_reports_none(self, mqtt_client): + """A full parts list without id 10 means the fan is not installed.""" + mqtt_client.state.left_aux_fan_speed = 80 # previously seen + parts = [p for p in P2S_PARTS_LEFT_AUX_80 if p["id"] != 160] + mqtt_client._update_state(_airduct_device(parts)) + assert mqtt_client.state.left_aux_fan_speed is None + + def test_diff_push_without_device_preserves_value(self, mqtt_client): + """P-series diff pushes omit device.airduct — value must survive.""" + mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80)) + mqtt_client._update_state({"nozzle_temper": 250.0}) + assert mqtt_client.state.left_aux_fan_speed == 80 + + def test_state_clamped_to_0_100(self, mqtt_client): + parts = [{"func": 5, "id": 160, "range": 6553600, "state": 250, "tar_state": 0}] + mqtt_client._update_state(_airduct_device(parts)) + assert mqtt_client.state.left_aux_fan_speed == 100 + + def test_malformed_part_entries_ignored(self, mqtt_client): + parts = [ + "not-a-dict", + {"func": 5}, # no id/state + {"id": "garbage", "state": 10}, + {"func": 5, "id": 160, "range": 6553600, "state": 30, "tar_state": 30}, + ] + mqtt_client._update_state(_airduct_device(parts)) + assert mqtt_client.state.left_aux_fan_speed == 30 + + def test_flat_fan_fields_unaffected(self, mqtt_client): + """Regression: flat fields keep coming from the flat MQTT keys.""" + payload = { + "cooling_fan_speed": "4", + "big_fan1_speed": "6", + "big_fan2_speed": "10", + "heatbreak_fan_speed": "14", + **_airduct_device(P2S_PARTS_LEFT_AUX_OFF), + } + mqtt_client._update_state(payload) + assert mqtt_client.state.cooling_fan_speed == 27 # 4/15 + assert mqtt_client.state.big_fan1_speed == 40 # 6/15 + assert mqtt_client.state.big_fan2_speed == 67 # 10/15 + assert mqtt_client.state.heatbreak_fan_speed == 93 # 14/15 + assert mqtt_client.state.left_aux_fan_speed == 0 + + +class TestExhaustFanPresence: + """device.airduct part id 3 (raw 48) presence -> state.exhaust_fan_present. + + The chamber exhaust fan is a P2S/X2D add-on kit (get_version module "eef"). + Its speed rides on the flat big_fan2_speed field, but the airduct only lists + part id 3 when the kit is physically installed — so part-3 presence is the + signal the UI uses to show/hide the Exhaust tile. + """ + + def test_defaults_to_false(self, mqtt_client): + assert mqtt_client.state.exhaust_fan_present is False + + def test_present_when_part_3_reported(self, mqtt_client): + # Full P2S parts list includes id 48 (>>4 = 3). + mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80)) + assert mqtt_client.state.exhaust_fan_present is True + + def test_absent_when_part_3_missing(self, mqtt_client): + mqtt_client.state.exhaust_fan_present = True # previously seen + parts = [p for p in P2S_PARTS_LEFT_AUX_80 if p["id"] != 48] + mqtt_client._update_state(_airduct_device(parts)) + assert mqtt_client.state.exhaust_fan_present is False + + def test_base_p2s_only_part_cooling_and_aux(self, mqtt_client): + # A base P2S (no exhaust kit, no left aux kit) lists only ids 1 and 2. + parts = [ + {"func": 0, "id": 16, "range": 6553600, "state": 0, "tar_state": 0}, + {"func": 6, "id": 32, "range": 6553600, "state": 0, "tar_state": 0}, + ] + mqtt_client._update_state(_airduct_device(parts)) + assert mqtt_client.state.exhaust_fan_present is False + assert mqtt_client.state.left_aux_fan_speed is None + + def test_diff_push_without_device_preserves_value(self, mqtt_client): + mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80)) + mqtt_client._update_state({"nozzle_temper": 250.0}) + assert mqtt_client.state.exhaust_fan_present is True + + +class TestLeftAuxFanCommand: + """set_fan_speed must accept index 10 and emit M106 P10.""" + + def test_set_fan_speed_10_sends_m106_p10(self, mqtt_client, monkeypatch): + sent = [] + monkeypatch.setattr(mqtt_client, "send_gcode", lambda g: sent.append(g) or True) + assert mqtt_client.set_fan_speed(10, 204) is True + assert sent == ["M106 P10 S204"] + + def test_set_left_aux_fan_helper(self, mqtt_client, monkeypatch): + sent = [] + monkeypatch.setattr(mqtt_client, "send_gcode", lambda g: sent.append(g) or True) + assert mqtt_client.set_left_aux_fan(255) is True + assert sent == ["M106 P10 S255"] + + def test_speed_clamped_to_255(self, mqtt_client, monkeypatch): + sent = [] + monkeypatch.setattr(mqtt_client, "send_gcode", lambda g: sent.append(g) or True) + mqtt_client.set_left_aux_fan(999) + assert sent == ["M106 P10 S255"] + + def test_invalid_fan_index_rejected(self, mqtt_client, monkeypatch): + sent = [] + monkeypatch.setattr(mqtt_client, "send_gcode", lambda g: sent.append(g) or True) + assert mqtt_client.set_fan_speed(4, 100) is False + assert mqtt_client.set_fan_speed(11, 100) is False + assert sent == [] + + def test_existing_fan_indexes_still_accepted(self, mqtt_client, monkeypatch): + sent = [] + monkeypatch.setattr(mqtt_client, "send_gcode", lambda g: sent.append(g) or True) + for idx in (1, 2, 3): + assert mqtt_client.set_fan_speed(idx, 128) is True + assert sent == ["M106 P1 S128", "M106 P2 S128", "M106 P3 S128"] diff --git a/backend/tests/unit/test_printer_manager_status_broadcast.py b/backend/tests/unit/test_printer_manager_status_broadcast.py index 229328c21..3ddc2dcda 100644 --- a/backend/tests/unit/test_printer_manager_status_broadcast.py +++ b/backend/tests/unit/test_printer_manager_status_broadcast.py @@ -90,6 +90,8 @@ def _fake_state(**overrides): "firmware_version": None, "gcode_file": None, "heatbreak_fan_speed": None, + "left_aux_fan_speed": None, + "exhaust_fan_present": False, "layer_num": None, "remaining_time": None, "speed_level": None, diff --git a/frontend/src/__tests__/pages/PrintersPage.test.tsx b/frontend/src/__tests__/pages/PrintersPage.test.tsx index c8b0f8cb1..216de553e 100644 --- a/frontend/src/__tests__/pages/PrintersPage.test.tsx +++ b/frontend/src/__tests__/pages/PrintersPage.test.tsx @@ -310,6 +310,91 @@ describe('PrintersPage', () => { expect(screen.getByTitle('Chamber Fan')).toBeInTheDocument(); }); }); + + // P2S/X2D left auxiliary part cooling fan (airduct part id 10) — optional + // hardware, so the badge must only appear when the firmware reports it. + const renderWithStatus = ( + printer: typeof mockPrinters[number], + status: Record, + ) => { + server.use( + http.get('/api/v1/printers/', () => HttpResponse.json([printer])), + http.get('/api/v1/printers/:id/status', () => HttpResponse.json(status)), + ); + render(); + }; + + it('shows the exhaust tile labeled "Exhaust" on P2S when the kit is present', async () => { + // Exhaust fan is an add-on kit; the tile appears only when the printer + // reports it (airduct part id 3 -> exhaust_fan_present). + renderWithStatus( + { ...mockPrinters[0], model: 'P2S' }, + { ...statusWithFans, exhaust_fan_present: true }, + ); + + await waitFor(() => { + expect(screen.getByTitle('Part Cooling Fan')).toBeInTheDocument(); + }); + expect(screen.getByTitle('Exhaust')).toBeInTheDocument(); + expect(screen.queryByTitle('Chamber Fan')).not.toBeInTheDocument(); + }); + + it('hides the exhaust tile on a base P2S without the kit', async () => { + renderWithStatus( + { ...mockPrinters[0], model: 'P2S' }, + { ...statusWithFans, exhaust_fan_present: false }, + ); + + await waitFor(() => { + expect(screen.getByTitle('Part Cooling Fan')).toBeInTheDocument(); + }); + expect(screen.queryByTitle('Exhaust')).not.toBeInTheDocument(); + expect(screen.queryByTitle('Chamber Fan')).not.toBeInTheDocument(); + }); + + it('keeps the always-on "Chamber Fan" tile on X1C regardless of exhaust_fan_present', async () => { + renderWithStatus( + { ...mockPrinters[0], model: 'X1C' }, + { ...statusWithFans, exhaust_fan_present: false }, + ); + + await waitFor(() => { + expect(screen.getByTitle('Chamber Fan')).toBeInTheDocument(); + }); + expect(screen.queryByTitle('Exhaust')).not.toBeInTheDocument(); + }); + + it('hides the left aux badge when the accessory is not reported', async () => { + renderWithStatus({ ...mockPrinters[0], model: 'P2S' }, statusWithFans); + + await waitFor(() => { + expect(screen.getByTitle('Part Cooling Fan')).toBeInTheDocument(); + }); + expect(screen.queryByTitle('Left Auxiliary Fan')).not.toBeInTheDocument(); + }); + + it('shows left aux fan badge when the accessory is installed (P2S)', async () => { + renderWithStatus( + { ...mockPrinters[0], model: 'P2S' }, + { ...statusWithFans, left_aux_fan_speed: 80 }, + ); + + await waitFor(() => { + expect(screen.getByTitle('Left Auxiliary Fan')).toBeInTheDocument(); + }); + }); + + it('shows left aux fan badge even at 0% while installed', async () => { + renderWithStatus( + { ...mockPrinters[0], model: 'P2S' }, + { ...statusWithFans, left_aux_fan_speed: 0 }, + ); + + await waitFor(() => { + expect(screen.getByTitle('Left Auxiliary Fan')).toBeInTheDocument(); + }); + }); + }); describe('empty state', () => { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f9cbbb212..f08d987d9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -565,6 +565,11 @@ export interface PrinterStatus { big_fan1_speed: number | null; // Auxiliary fan big_fan2_speed: number | null; // Chamber/exhaust fan heatbreak_fan_speed: number | null; // Hotend heatbreak fan + // Left auxiliary part cooling fan (optional P2S/X2D accessory, M106 P10). + // null = not installed / not reported by this model. + left_aux_fan_speed: number | null; + // Chamber exhaust fan present (P2S/X2D External Exhaust Fan kit, airduct part 3). + exhaust_fan_present: boolean; firmware_version: string | null; // Firmware version from MQTT // Developer LAN mode: true = enabled, false = disabled, null = unknown developer_mode: boolean | null; @@ -3934,7 +3939,7 @@ export const api = { method: 'POST', }), - setFanSpeed: (printerId: number, fan: 'part' | 'aux' | 'chamber', speed: number) => + setFanSpeed: (printerId: number, fan: 'part' | 'aux' | 'aux2' | 'chamber', speed: number) => request<{ success: boolean; message: string }>(`/printers/${printerId}/fan-speed?fan=${fan}&speed=${speed}`, { method: 'POST', }), diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 753047ab8..76b98d260 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -668,6 +668,8 @@ export default { fans: { partCooling: 'Bauteilkühlung', auxiliary: 'Hilfsventilator', + leftAuxiliary: 'Linker Hilfsventilator', + exhaust: 'Abluft', chamber: 'Kammerventilator', }, // HMS errors diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 701c02806..165f31bb3 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -672,6 +672,8 @@ export default { fans: { partCooling: 'Part Cooling Fan', auxiliary: 'Auxiliary Fan', + leftAuxiliary: 'Left Auxiliary Fan', + exhaust: 'Exhaust', chamber: 'Chamber Fan', }, // HMS errors diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index a9b9de4c6..10676cbda 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -668,6 +668,8 @@ export default { fans: { partCooling: 'Ventilador de refrigeración de piezas', auxiliary: 'Ventilador auxiliar', + leftAuxiliary: 'Ventilador auxiliar izquierdo', + exhaust: 'Extracción', chamber: 'Ventilador de la cámara', }, // HMS errors diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 6b1d4c9f4..18277b8e5 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -668,6 +668,8 @@ export default { fans: { partCooling: 'Ventilateur pièce', auxiliary: 'Ventilateur auxiliaire', + leftAuxiliary: 'Ventilateur auxiliaire gauche', + exhaust: 'Extraction', chamber: 'Ventilateur chambre', }, // HMS errors diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 92fb9a9ab..7449d91fd 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -668,6 +668,8 @@ export default { fans: { partCooling: 'Ventola raffreddamento parte', auxiliary: 'Ventola ausiliaria', + leftAuxiliary: 'Ventola ausiliaria sinistra', + exhaust: 'Estrazione', chamber: 'Ventola camera', }, // HMS errors diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 9c47c08c6..74e49567f 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -667,6 +667,8 @@ export default { fans: { partCooling: 'パーツ冷却ファン', auxiliary: '補助ファン', + leftAuxiliary: '左補助ファン', + exhaust: '排気', chamber: 'チャンバーファン', }, // HMS errors diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 29d728726..154e7839a 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -625,6 +625,8 @@ export default { fans: { partCooling: '파트 냉각 팬', auxiliary: '보조 팬', + leftAuxiliary: '왼쪽 보조 팬', + exhaust: '배기', chamber: '챔버 팬' }, clickToViewHmsErrors: 'HMS 오류 보기 클릭', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 81f62a625..a91466923 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -668,6 +668,8 @@ export default { fans: { partCooling: 'Ventilador de resfriamento da peça', auxiliary: 'Ventilador auxiliar', + leftAuxiliary: 'Ventilador auxiliar esquerdo', + exhaust: 'Exaustão', chamber: 'Ventilador da câmara', }, // HMS errors diff --git a/frontend/src/i18n/locales/ru.ts b/frontend/src/i18n/locales/ru.ts index 149df02e4..88b771d3a 100644 --- a/frontend/src/i18n/locales/ru.ts +++ b/frontend/src/i18n/locales/ru.ts @@ -630,6 +630,8 @@ export default { fans: { partCooling: "Вентилятор обдува модели", auxiliary: "Дополнительный вентилятор", + leftAuxiliary: "Левый дополнительный вентилятор", + exhaust: "Вытяжка", chamber: "Вентилятор камеры", }, clickToViewHmsErrors: "Нажмите, чтобы посмотреть ошибки HMS", diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index f264a339c..333c50768 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/frontend/src/i18n/locales/tr.ts @@ -668,6 +668,8 @@ export default { fans: { partCooling: 'Parça Soğutma Fanı', auxiliary: 'Yardımcı Fan', + leftAuxiliary: 'Sol Yardımcı Fan', + exhaust: 'Egzoz', chamber: 'Hazne Fanı', }, // HMS hataları diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 6a3e21e53..b35851780 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -668,6 +668,8 @@ export default { fans: { partCooling: '零件冷却风扇', auxiliary: '辅助风扇', + leftAuxiliary: '左辅助风扇', + exhaust: '排气', chamber: '腔室风扇', }, // HMS errors diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index 219c4e3f4..5d911f00a 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -668,6 +668,8 @@ export default { fans: { partCooling: '零件冷卻風扇', auxiliary: '輔助風扇', + leftAuxiliary: '左輔助風扇', + exhaust: '排氣', chamber: '腔室風扇', }, // HMS errors diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index 193af952f..229e52b06 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -1490,6 +1490,17 @@ const MODELS_WITH_CHAMBER_FAN: ReadonlySet = new Set([ 'H2S', ]); +// On the P2S/X2D, the enclosure fan (big_fan2 / airduct part id 3) is a +// dedicated chamber EXHAUST fan: it's its own control and stays the same +// regardless of cooling/heating mode (unlike the aux fan, id 2, which a flap +// re-tasks between part-cooling and chamber-filter recirculation). Bambu's own +// firmware/UI and Bambu Studio (FAN_CHAMBER_0_IDX -> "Exhaust") label it +// "Exhaust" on these models. Other enclosed models (X1/P1S/H2*) keep "Chamber". +const MODELS_WITH_EXHAUST_LABEL: ReadonlySet = new Set([ + 'P2S', + 'X2D', +]); + // Map SSDP model codes to display names function mapModelCode(ssdpModel: string | null): string { if (!ssdpModel) return ''; @@ -2449,7 +2460,7 @@ function PrinterCard({ }); const fanSpeedMutation = useMutation({ - mutationFn: ({ fan, speed }: { fan: 'part' | 'aux' | 'chamber'; speed: number }) => + mutationFn: ({ fan, speed }: { fan: 'part' | 'aux' | 'aux2' | 'chamber'; speed: number }) => api.setFanSpeed(printer.id, fan, speed), onMutate: async ({ fan, speed }) => { await queryClient.cancelQueries({ queryKey: ['printerStatus', printer.id] }); @@ -2457,6 +2468,7 @@ function PrinterCard({ const fanField = { part: 'cooling_fan_speed', aux: 'big_fan1_speed', + aux2: 'left_aux_fan_speed', chamber: 'big_fan2_speed', }[fan]; queryClient.setQueryData(['printerStatus', printer.id], (old: PrinterStatus | undefined) => @@ -3886,7 +3898,24 @@ function PrinterCard({ // control that does nothing. Mirrors the enclosure-door badge // gate above. const hasChamberFan = MODELS_WITH_CHAMBER_FAN.has(printer.model ?? ''); - const fanItems = [ + // On P2S/X2D the big_fan2 fan is the dedicated chamber EXHAUST fan + // ("Exhaust" in Bambu's naming) and is an add-on kit, not preinstalled: + // show it only when the printer actually reports it (airduct part id 3, + // surfaced as exhaust_fan_present). Other enclosed models (X1/P1S/H2*) + // have a built-in chamber fan that's always present, so they keep the + // existing model-list gate and the "Chamber Fan" label. + const isExhaustModel = MODELS_WITH_EXHAUST_LABEL.has(printer.model ?? ''); + const chamberFanLabel = isExhaustModel + ? t('printers.fans.exhaust') + : t('printers.fans.chamber'); + const showChamberFan = isExhaustModel ? status.exhaust_fan_present : hasChamberFan; + const fanItems: { + key: string; + label: string; + value: number; + Icon: typeof Fan; + activeClass: string; + }[] = [ { key: 'part', label: t('printers.fans.partCooling'), @@ -3901,11 +3930,25 @@ function PrinterCard({ Icon: Wind, activeClass: 'text-blue-600 dark:text-blue-400', }, - ...(hasChamberFan + // Left auxiliary part cooling fan (optional P2S/X2D accessory). + // Only reported (non-null) when the firmware lists airduct part + // id 10, i.e. when the fan is physically installed. + ...(status.left_aux_fan_speed != null + ? [ + { + key: 'aux2', + label: t('printers.fans.leftAuxiliary'), + value: status.left_aux_fan_speed, + Icon: Wind, + activeClass: 'text-indigo-600 dark:text-indigo-400', + }, + ] + : []), + ...(showChamberFan ? [ { key: 'chamber', - label: t('printers.fans.chamber'), + label: chamberFanLabel, value: status.big_fan2_speed ?? 0, Icon: AirVent, activeClass: 'text-green-600 dark:text-green-400', @@ -4157,7 +4200,7 @@ function PrinterCard({ isPending={fanSpeedMutation.isPending} options={buildPresetOptions(fanSpeedPresets, '%')} onClose={() => setStatusControlMenu(null)} - onSubmit={(speed) => fanSpeedMutation.mutate({ fan: key as 'part' | 'aux' | 'chamber', speed })} + onSubmit={(speed) => fanSpeedMutation.mutate({ fan: key as 'part' | 'aux' | 'aux2' | 'chamber', speed })} /> )}