Procházet zdrojové kódy

feat(printers): expose P2S/X2D accessory fans (left aux + exhaust)

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.
Gabe před 1 měsícem
rodič
revize
9ee162d51a

+ 18 - 5
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}%"}
 
 

+ 5 - 0
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

+ 56 - 2
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).
 

+ 2 - 0
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

+ 2 - 0
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)

+ 5 - 3
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."""

+ 207 - 0
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"]

+ 2 - 0
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,

+ 85 - 0
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<string, unknown>,
+    ) => {
+      server.use(
+        http.get('/api/v1/printers/', () => HttpResponse.json([printer])),
+        http.get('/api/v1/printers/:id/status', () => HttpResponse.json(status)),
+      );
+      render(<PrintersPage />);
+    };
+
+    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', () => {

+ 6 - 1
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',
     }),

+ 2 - 0
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

+ 2 - 0
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

+ 2 - 0
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

+ 2 - 0
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

+ 2 - 0
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

+ 2 - 0
frontend/src/i18n/locales/ja.ts

@@ -667,6 +667,8 @@ export default {
     fans: {
       partCooling: 'パーツ冷却ファン',
       auxiliary: '補助ファン',
+      leftAuxiliary: '左補助ファン',
+      exhaust: '排気',
       chamber: 'チャンバーファン',
     },
     // HMS errors

+ 2 - 0
frontend/src/i18n/locales/ko.ts

@@ -625,6 +625,8 @@ export default {
     fans: {
       partCooling: '파트 냉각 팬',
       auxiliary: '보조 팬',
+      leftAuxiliary: '왼쪽 보조 팬',
+      exhaust: '배기',
       chamber: '챔버 팬'
     },
     clickToViewHmsErrors: 'HMS 오류 보기 클릭',

+ 2 - 0
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

+ 2 - 0
frontend/src/i18n/locales/ru.ts

@@ -630,6 +630,8 @@ export default {
     fans: {
       partCooling: "Вентилятор обдува модели",
       auxiliary: "Дополнительный вентилятор",
+      leftAuxiliary: "Левый дополнительный вентилятор",
+      exhaust: "Вытяжка",
       chamber: "Вентилятор камеры",
     },
     clickToViewHmsErrors: "Нажмите, чтобы посмотреть ошибки HMS",

+ 2 - 0
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ı

+ 2 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -668,6 +668,8 @@ export default {
     fans: {
       partCooling: '零件冷却风扇',
       auxiliary: '辅助风扇',
+      leftAuxiliary: '左辅助风扇',
+      exhaust: '排气',
       chamber: '腔室风扇',
     },
     // HMS errors

+ 2 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -668,6 +668,8 @@ export default {
     fans: {
       partCooling: '零件冷卻風扇',
       auxiliary: '輔助風扇',
+      leftAuxiliary: '左輔助風扇',
+      exhaust: '排氣',
       chamber: '腔室風扇',
     },
     // HMS errors

+ 48 - 5
frontend/src/pages/PrintersPage.tsx

@@ -1490,6 +1490,17 @@ const MODELS_WITH_CHAMBER_FAN: ReadonlySet<string> = 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<string> = 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 })}
                             />
                           )}
                         </div>