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 })} /> )}