Browse Source

feat(printers): airduct mode + status badges + force refresh on printer card

  Surface four Home Assistant-style controls on the Printers page card:

  - SD Card badge in the top status row (green / red, icon-only).
  - Enclosure Door badge in the top status row (green / yellow, icon-only).
    Detection per printer family — X1/X1C/X1E read home_flag bit 23, all
    others read top-level `stat` (hex string) bit 23 — so X1 firmware that
    does not flip stat bit 23 stops false-triggering "open". WebSocket
    status-change dedup key now includes door_open so toggling the door
    alone publishes a push, no 30s REST-poll wait.
  - Airduct Mode badge beside the speed control (cooling / heating)
    for P2S/H2D/H2C/H2S; one-click dropdown calls the existing
    set_airduct MQTT command via a new POST /printers/{id}/airduct-mode
    route.
  - Force Refresh entry in the kebab menu — calls the existing
    /printers/{id}/refresh-status endpoint to request a pushall snapshot
    without forcing a reconnect.

  Tests: door-open parsing (X1 home_flag, non-X1 stat, ignore mismatched
  source, invalid hex) and airduct route (validation, not-connected,
  success, failure).
maziggy 1 month ago
parent
commit
8af0966e68

+ 5 - 0
CHANGELOG.md

@@ -5,6 +5,11 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.2.3b4] - Unreleased
 
 ### New Features
+- **Printer Card Status Badges & Quick Controls** — The Printers page printer card now exposes four new at-a-glance controls inspired by the Home Assistant Bambu Lab integration:
+  - **SD Card badge** in the top status row (HardDrive icon, green when card present, red when missing).
+  - **Enclosure Door badge** in the top status row (DoorOpen/DoorClosed icons, green when closed, yellow when open). Detection uses the right MQTT field per printer family — `home_flag` bit 23 on X1/X1C/X1E and the top-level `stat` hex string bit 23 on P1/P2/H2 — and falls through the existing WebSocket push (status-change dedup key now includes door state, so toggling the door alone triggers a live badge update without waiting for the 30 s REST poll).
+  - **Airduct Mode badge** beside the print speed control (Snowflake/Flame icons, sky for Cooling and orange for Heating). One-click dropdown switches the printer between cooling and heating via the existing `set_airduct` MQTT command. Gated to P2S/H2D/H2C/H2S.
+  - **Force Refresh** menu entry in the printer card kebab menu (RotateCw icon) that re-requests a full `pushall` MQTT status report from the printer without forcing a reconnect.
 - **AI Print-Failure Detection via self-hosted Obico ML API** ([#172](https://github.com/maziggy/bambuddy/issues/172)) — New Settings → Failure Detection tab wires Bambuddy to a self-hosted [Obico](https://github.com/TheSpaghettiDetective/obico-server) `ml_api` container (no Obico account, no cloud, no WebSocket). While a print is running, the detection service periodically hands the printer's camera snapshot URL to the ML API, which returns YOLO failure-detection scores. Scores are smoothed over time using Obico's own EWM + short/long rolling-mean math (30-frame warmup, alpha = 2/13, short window ≈ 5 min at 10s/frame, long window ≈ 20 h) so a single noisy frame cannot trigger an action. Sensitivity (Low / Medium / High) scales the LOW/HIGH thresholds; when the smoothed score crosses HIGH, the configured action runs exactly once per print: *Notify only*, *Pause print* (MQTT pause command), or *Pause and cut power* (pause + turn off any smart plug linked to that printer). A per-printer toggle lets you monitor all connected printers or just a subset. The Status card shows whether the service is running, the active thresholds, each monitored print's current verdict (safe / warning / failure), and a live rolling detection history. Requires that the External URL setting (General tab) points to a hostname/IP reachable from the ML API container, since the ML API fetches snapshots by URL.
 
 ### Fixed

+ 3 - 1
README.md

@@ -102,7 +102,9 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - External camera support (MJPEG, RTSP, HTTP snapshot, USB/V4L2) with layer-based timelapse
 - **Build plate empty detection** - Auto-pause print if objects detected on plate (multi-reference calibration, ROI adjustment)
 - Fan status monitoring (part cooling, auxiliary, chamber)
-- Printer control (stop, pause, resume, chamber light, print speed)
+- Printer control (stop, pause, resume, chamber light, print speed, **airduct mode** for P2S/H2*)
+- **Status badges on printer card**: SD Card (green / red), Enclosure Door (green / yellow — X1/P1S/P2S/H2*), Airduct Mode (cooling / heating)
+- **Force Refresh** menu item — request a full status push from the printer without reconnecting
 - Bulk printer actions (multi-select cards, then stop/pause/resume/clear all — select by state or location)
 - Printer search and filters — live search by name/model/location/serial plus status and location dropdown filters (WebSocket-reactive, mobile-friendly)
 - Resizable printer cards (S/M/L/XL)

+ 28 - 0
backend/app/api/routes/printers.py

@@ -583,6 +583,7 @@ async def get_printer_status(
         ipcam=state.ipcam,
         wifi_signal=state.wifi_signal,
         wired_network=state.wired_network,
+        door_open=state.door_open,
         nozzles=nozzles,
         nozzle_rack=nozzle_rack,
         print_options=print_options,
@@ -2333,6 +2334,33 @@ async def set_print_speed(
     return {"success": True, "message": f"Print speed set to {speed_names.get(mode, 'Unknown')}"}
 
 
+@router.post("/{printer_id}/airduct-mode")
+async def set_airduct_mode(
+    printer_id: int,
+    mode: str = Query(..., description="Airduct mode: 'cooling' or 'heating'"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Set the airduct mode (cooling/heating) on supported printers (P2S/H2*)."""
+    if mode not in ("cooling", "heating"):
+        raise HTTPException(400, "Mode must be 'cooling' or 'heating'")
+
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    success = client.set_airduct_mode(mode)
+    if not success:
+        raise HTTPException(500, "Failed to set airduct mode")
+
+    return {"success": True, "message": f"Airduct mode set to {mode}"}
+
+
 @router.post("/{printer_id}/chamber-light")
 async def set_chamber_light(
     printer_id: int,

+ 1 - 1
backend/app/main.py

@@ -487,7 +487,7 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
         f"{state.stg_cur}:{bed_target}:{nozzle_target}:"
         f"{state.cooling_fan_speed}:{state.big_fan1_speed}:{state.big_fan2_speed}:"
         f"{state.chamber_light}:{state.active_extruder}:{state.tray_now}:{vt_tray_key}:"
-        f"{ams_dry_key}:{ams_tray_key}"
+        f"{ams_dry_key}:{ams_tray_key}:{state.door_open}"
     )
 
     # MQTT relay - publish status (before dedup check - always publish to MQTT)

+ 1 - 0
backend/app/schemas/printer.py

@@ -225,6 +225,7 @@ class PrinterStatus(BaseModel):
     ipcam: bool = False  # Live view enabled
     wifi_signal: int | None = None  # WiFi signal strength in dBm
     wired_network: bool = False  # Ethernet connection detected
+    door_open: bool = False  # Enclosure door open (X1/P1S/P2S/H2*)
     nozzles: list[NozzleInfoResponse] = []  # Nozzle hardware info (index 0=left/primary, 1=right)
     nozzle_rack: list[NozzleRackSlot] = []  # H2C 6-nozzle tool-changer rack
     print_options: PrintOptionsResponse | None = None  # AI detection and print options

+ 34 - 0
backend/app/services/bambu_mqtt.py

@@ -122,6 +122,7 @@ class PrinterState:
     ipcam: bool = False  # Live view / camera streaming enabled
     wifi_signal: int | None = None  # WiFi signal strength in dBm
     wired_network: bool = False  # Ethernet connection detected (home_flag bit 18)
+    door_open: bool = False  # Enclosure door open (home_flag bit 23, X1/P1S/P2S/H2*)
     # Nozzle hardware info (for dual nozzle printers, index 0 = left, 1 = right)
     nozzles: list = field(default_factory=lambda: [NozzleInfo(), NozzleInfo()])
     # AI detection and print options
@@ -2254,6 +2255,39 @@ class BambuMQTTClient:
                 )
             self.state.store_to_sdcard = store_to_sdcard
 
+        # Door open detection — source depends on printer family:
+        #   X1 series (X1, X1C, X1E): home_flag bit 23
+        #   All others (P1/P2/H2/A1/N-series): top-level `stat` field (hex string), bit 23
+        # Both share the same bitmask (0x00800000) but live in different fields.
+        model_upper = (self.model or "").upper().strip()
+        is_x1_family = model_upper in ("X1", "X1C", "X1E")
+        if is_x1_family and "home_flag" in data:
+            door_open = (home_flag & 0x00800000) != 0
+            if door_open != self.state.door_open:
+                logger.debug(
+                    "[%s] door_open changed: %s -> %s (home_flag=0x%08X)",
+                    self.serial_number,
+                    self.state.door_open,
+                    door_open,
+                    home_flag,
+                )
+            self.state.door_open = door_open
+        elif not is_x1_family and "stat" in data:
+            try:
+                stat_value = int(data["stat"], 16) if isinstance(data["stat"], str) else int(data["stat"])
+                door_open = (stat_value & 0x00800000) != 0
+                if door_open != self.state.door_open:
+                    logger.debug(
+                        "[%s] door_open changed: %s -> %s (stat=0x%08X)",
+                        self.serial_number,
+                        self.state.door_open,
+                        door_open,
+                        stat_value,
+                    )
+                self.state.door_open = door_open
+            except (ValueError, TypeError):
+                logger.debug("[%s] could not parse stat field: %r", self.serial_number, data["stat"])
+
         # Parse timelapse status (recording active during print)
         if "timelapse" in data:
             logger.debug("[%s] timelapse field: %s", self.serial_number, data["timelapse"])

+ 1 - 0
backend/app/services/printer_manager.py

@@ -801,6 +801,7 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
         # WiFi signal strength
         "wifi_signal": state.wifi_signal,
         "wired_network": state.wired_network,
+        "door_open": state.door_open,
         # Calibration stage tracking
         "stg_cur": state.stg_cur,
         "stg_cur_name": get_derived_status_name(state, model),

+ 44 - 0
backend/tests/integration/test_printers_api.py

@@ -1113,6 +1113,50 @@ class TestChamberLightAPI:
             assert "failed" in response.json()["detail"].lower()
 
 
+class TestAirductModeAPI:
+    """Integration tests for the airduct mode endpoint (P2S/H2*)."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_invalid_mode_rejected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="P2S")
+        response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=foo")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_not_connected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="P2S")
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = None
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=cooling")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cooling_success(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="P2S")
+        mock_client = MagicMock()
+        mock_client.set_airduct_mode.return_value = True
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=cooling")
+        assert response.status_code == 200
+        assert response.json()["success"] is True
+        mock_client.set_airduct_mode.assert_called_once_with("cooling")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_heating_failure_returns_500(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="P2S")
+        mock_client = MagicMock()
+        mock_client.set_airduct_mode.return_value = False
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=heating")
+        assert response.status_code == 500
+
+
 class TestClearHMSErrorsAPI:
     """Integration tests for clear HMS errors endpoint."""
 

+ 54 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -3520,3 +3520,57 @@ class TestStaleReconnect:
 
         assert state_changes == [False]
         assert mqtt_client.state.connected is False
+
+
+class TestDoorOpenParsing:
+    """Tests for enclosure door state parsing (X1 home_flag bit 23 vs others stat bit 23)."""
+
+    def _make_client(self, model: str):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST",
+            access_code="12345678",
+            model=model,
+        )
+
+    def test_x1c_door_open_from_home_flag(self):
+        client = self._make_client("X1C")
+        # bit 23 set
+        client._update_state({"home_flag": 0xC0E5CD98})
+        assert client.state.door_open is True
+
+    def test_x1c_door_closed_from_home_flag(self):
+        client = self._make_client("X1C")
+        client.state.door_open = True  # start "open"
+        client._update_state({"home_flag": 0xC065CD98})
+        assert client.state.door_open is False
+
+    def test_x1c_ignores_stat_field(self):
+        # X1C must NOT use stat (bit 23 in stat is unrelated for X1)
+        client = self._make_client("X1C")
+        client._update_state({"home_flag": 0xC065CD98, "stat": "47A58000"})
+        assert client.state.door_open is False  # home_flag wins
+
+    def test_h2d_door_open_from_stat(self):
+        client = self._make_client("H2D")
+        client._update_state({"stat": "640A58000"})  # bit 23 set
+        assert client.state.door_open is True
+
+    def test_h2d_door_closed_from_stat(self):
+        client = self._make_client("H2D")
+        client.state.door_open = True
+        client._update_state({"stat": "640258000"})  # bit 23 cleared
+        assert client.state.door_open is False
+
+    def test_h2d_ignores_home_flag(self):
+        # Non-X1 must NOT consume home_flag for door state
+        client = self._make_client("H2D")
+        client._update_state({"home_flag": 0xC0E5CD98, "stat": "640258000"})
+        assert client.state.door_open is False  # stat wins
+
+    def test_invalid_stat_does_not_raise(self):
+        client = self._make_client("H2D")
+        client._update_state({"stat": "not-hex"})
+        assert client.state.door_open is False

+ 35 - 0
frontend/src/__tests__/api/client.test.ts

@@ -206,3 +206,38 @@ describe('FormData requests include auth header', () => {
     expect(capturedHeaders!.get('Authorization')).toBe('Bearer test-token');
   });
 });
+
+describe('Printer control endpoints', () => {
+  it('refreshPrinterStatus POSTs to /printers/:id/refresh-status', async () => {
+    let calledUrl: string | null = null;
+    let calledMethod: string | null = null;
+    server.use(
+      http.post('/api/v1/printers/:id/refresh-status', ({ request, params }) => {
+        calledUrl = `/printers/${params.id}/refresh-status`;
+        calledMethod = request.method;
+        return HttpResponse.json({ status: 'ok' });
+      }),
+    );
+
+    const result = await api.refreshPrinterStatus(7);
+    expect(calledMethod).toBe('POST');
+    expect(calledUrl).toBe('/printers/7/refresh-status');
+    expect(result).toEqual({ status: 'ok' });
+  });
+
+  it('setAirductMode passes mode in query string', async () => {
+    let capturedUrl = '';
+    server.use(
+      http.post('/api/v1/printers/:id/airduct-mode', ({ request }) => {
+        capturedUrl = request.url;
+        return HttpResponse.json({ success: true, message: 'ok' });
+      }),
+    );
+
+    await api.setAirductMode(3, 'cooling');
+    expect(capturedUrl).toContain('mode=cooling');
+
+    await api.setAirductMode(3, 'heating');
+    expect(capturedUrl).toContain('mode=heating');
+  });
+});

+ 6 - 0
frontend/src/api/client.ts

@@ -241,6 +241,7 @@ export interface PrinterStatus {
   ipcam: boolean;  // Live view enabled
   wifi_signal: number | null;  // WiFi signal strength in dBm
   wired_network: boolean;  // Ethernet connection detected
+  door_open: boolean;  // Enclosure door open (X1/P1S/P2S/H2*)
   nozzles: NozzleInfo[];  // Nozzle hardware info (index 0=left/primary, 1=right)
   nozzle_rack: NozzleRackSlot[];  // H2C 6-nozzle tool-changer rack
   print_options: PrintOptions | null;  // AI detection and print options
@@ -2643,6 +2644,11 @@ export const api = {
       method: 'POST',
     }),
 
+  setAirductMode: (printerId: number, mode: 'cooling' | 'heating') =>
+    request<{ success: boolean; message: string }>(`/printers/${printerId}/airduct-mode?mode=${mode}`, {
+      method: 'POST',
+    }),
+
   // Chamber Light Control
   setChamberLight: (printerId: number, on: boolean) =>
     request<{ success: boolean; message: string }>(`/printers/${printerId}/chamber-light?on=${on}`, {

+ 12 - 0
frontend/src/i18n/locales/de.ts

@@ -224,6 +224,8 @@ export default {
     camera: 'Kamera',
     skipObject: 'Objekt überspringen',
     reconnect: 'Neu verbinden',
+    forceRefresh: 'Aktualisierung erzwingen',
+    forceRefreshSuccess: 'Aktualisierung angefordert',
     mqttDebug: 'MQTT-Debug',
     printerInformation: 'Druckerinformationen',
     copyToClipboard: 'Kopieren',
@@ -497,6 +499,16 @@ export default {
       sport: 'Sport (124%)',
       ludicrous: 'Ludicrous (166%)',
     },
+    airduct: {
+      title: 'Luftkanal-Modus',
+      cooling: 'Kühlen',
+      heating: 'Heizen',
+    },
+    noSdCard: 'Keine SD',
+    door: {
+      open: 'Offen',
+      closed: 'Zu',
+    },
     // Fans
     fans: {
       partCooling: 'Bauteilkühlung',

+ 12 - 0
frontend/src/i18n/locales/en.ts

@@ -224,6 +224,8 @@ export default {
     camera: 'Camera',
     skipObject: 'Skip Object',
     reconnect: 'Reconnect',
+    forceRefresh: 'Force Refresh',
+    forceRefreshSuccess: 'Refresh requested',
     mqttDebug: 'MQTT Debug',
     printerInformation: 'Printer Information',
     copyToClipboard: 'Copy',
@@ -497,6 +499,16 @@ export default {
       sport: 'Sport (124%)',
       ludicrous: 'Ludicrous (166%)',
     },
+    airduct: {
+      title: 'Airduct Mode',
+      cooling: 'Cooling',
+      heating: 'Heating',
+    },
+    noSdCard: 'No SD',
+    door: {
+      open: 'Open',
+      closed: 'Closed',
+    },
     // Fans
     fans: {
       partCooling: 'Part Cooling Fan',

+ 12 - 0
frontend/src/i18n/locales/fr.ts

@@ -224,6 +224,8 @@ export default {
     camera: 'Caméra',
     skipObject: 'Sauter l\'objet',
     reconnect: 'Reconnecter',
+    forceRefresh: 'Forcer l\'actualisation',
+    forceRefreshSuccess: 'Actualisation demandée',
     mqttDebug: 'Débogage MQTT',
     printerInformation: 'Informations imprimante',
     copyToClipboard: 'Copier',
@@ -497,6 +499,16 @@ export default {
       sport: 'Sport (124%)',
       ludicrous: 'Ludicrous (166%)',
     },
+    airduct: {
+      title: 'Mode conduit d\'air',
+      cooling: 'Refroidissement',
+      heating: 'Chauffage',
+    },
+    noSdCard: 'Pas de SD',
+    door: {
+      open: 'Ouverte',
+      closed: 'Fermée',
+    },
     // Fans
     fans: {
       partCooling: 'Ventilateur pièce',

+ 12 - 0
frontend/src/i18n/locales/it.ts

@@ -224,6 +224,8 @@ export default {
     camera: 'Camera',
     skipObject: 'Salta Oggetto',
     reconnect: 'Riconnetti',
+    forceRefresh: 'Forza aggiornamento',
+    forceRefreshSuccess: 'Aggiornamento richiesto',
     mqttDebug: 'Debug MQTT',
     printerInformation: 'Informazioni stampante',
     copyToClipboard: 'Copia',
@@ -497,6 +499,16 @@ export default {
       sport: 'Sport (124%)',
       ludicrous: 'Ludicrous (166%)',
     },
+    airduct: {
+      title: 'Modalità condotto d\'aria',
+      cooling: 'Raffreddamento',
+      heating: 'Riscaldamento',
+    },
+    noSdCard: 'Nessuna SD',
+    door: {
+      open: 'Aperta',
+      closed: 'Chiusa',
+    },
     // Fans
     fans: {
       partCooling: 'Ventola raffreddamento parte',

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

@@ -223,6 +223,8 @@ export default {
     camera: 'カメラ',
     skipObject: 'オブジェクトスキップ',
     reconnect: '再接続',
+    forceRefresh: '強制更新',
+    forceRefreshSuccess: '更新をリクエストしました',
     mqttDebug: 'MQTTデバッグ',
     printerInformation: 'プリンター情報',
     copyToClipboard: 'コピー',
@@ -496,6 +498,16 @@ export default {
       sport: 'スポーツ (124%)',
       ludicrous: 'ルディクラス (166%)',
     },
+    airduct: {
+      title: 'エアダクトモード',
+      cooling: '冷却',
+      heating: '加熱',
+    },
+    noSdCard: 'SDなし',
+    door: {
+      open: '開',
+      closed: '閉',
+    },
     // Fans
     fans: {
       partCooling: 'パーツ冷却ファン',

+ 12 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -224,6 +224,8 @@ export default {
     camera: 'âmera',
     skipObject: 'Ignorar objeto',
     reconnect: 'Reconectar',
+    forceRefresh: 'Forçar atualização',
+    forceRefreshSuccess: 'Atualização solicitada',
     mqttDebug: 'Depuração MQTT',
     printerInformation: 'Informações da impressora',
     copyToClipboard: 'Copiar',
@@ -497,6 +499,16 @@ export default {
       sport: 'Sport (124%)',
       ludicrous: 'Ludicrous (166%)',
     },
+    airduct: {
+      title: 'Modo do duto de ar',
+      cooling: 'Resfriamento',
+      heating: 'Aquecimento',
+    },
+    noSdCard: 'Sem SD',
+    door: {
+      open: 'Aberta',
+      closed: 'Fechada',
+    },
     // Fans
     fans: {
       partCooling: 'Ventilador de resfriamento da peça',

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

@@ -224,6 +224,8 @@ export default {
     camera: '摄像头',
     skipObject: '跳过对象',
     reconnect: '重新连接',
+    forceRefresh: '强制刷新',
+    forceRefreshSuccess: '已请求刷新',
     mqttDebug: 'MQTT 调试',
     printerInformation: '打印机信息',
     copyToClipboard: '复制',
@@ -497,6 +499,16 @@ export default {
       sport: '运动 (124%)',
       ludicrous: '疯狂 (166%)',
     },
+    airduct: {
+      title: '风道模式',
+      cooling: '制冷',
+      heating: '加热',
+    },
+    noSdCard: '无SD',
+    door: {
+      open: '开',
+      closed: '关',
+    },
     // Fans
     fans: {
       partCooling: '零件冷却风扇',

+ 122 - 0
frontend/src/pages/PrintersPage.tsx

@@ -12,6 +12,7 @@ import {
   MoreVertical,
   Trash2,
   RefreshCw,
+  RotateCw,
   Box,
   HardDrive,
   AlertTriangle,
@@ -47,7 +48,10 @@ import {
   Info,
   Cable,
   Flame,
+  Snowflake,
   Gauge,
+  DoorOpen,
+  DoorClosed,
 } from 'lucide-react';
 
 import { useNavigate } from 'react-router-dom';
@@ -1326,6 +1330,7 @@ function PrinterCard({
   const [showStopConfirm, setShowStopConfirm] = useState(false);
   const [showPauseConfirm, setShowPauseConfirm] = useState(false);
   const [showSpeedMenu, setShowSpeedMenu] = useState<number | null>(null);
+  const [showAirductMenu, setShowAirductMenu] = useState<number | null>(null);
   const [showResumeConfirm, setShowResumeConfirm] = useState(false);
   const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false);
   const [showUploadForPrint, setShowUploadForPrint] = useState(false);
@@ -1625,6 +1630,15 @@ function PrinterCard({
     },
   });
 
+  const forceRefreshMutation = useMutation({
+    mutationFn: () => api.refreshPrinterStatus(printer.id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
+      showToast(t('printers.forceRefreshSuccess'), 'success');
+    },
+    onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),
+  });
+
   const unlinkSpoolMutation = useMutation({
     mutationFn: (spoolId: number) => api.unlinkSpool(spoolId),
     onSuccess: (result) => {
@@ -1759,6 +1773,25 @@ function PrinterCard({
     },
   });
 
+  const airductMutation = useMutation({
+    mutationFn: (mode: 'cooling' | 'heating') => api.setAirductMode(printer.id, mode),
+    onMutate: async (mode) => {
+      await queryClient.cancelQueries({ queryKey: ['printerStatus', printer.id] });
+      const previousStatus = queryClient.getQueryData(['printerStatus', printer.id]);
+      queryClient.setQueryData(['printerStatus', printer.id], (old: typeof status) => ({
+        ...old,
+        airduct_mode: mode === 'cooling' ? 0 : 1,
+      }));
+      return { previousStatus };
+    },
+    onError: (error: Error, _, context) => {
+      if (context?.previousStatus) {
+        queryClient.setQueryData(['printerStatus', printer.id], context.previousStatus);
+      }
+      showToast(error.message || t('printers.toast.failedToSendCommand'), 'error');
+    },
+  });
+
   // Plate detection setting mutation
   const plateDetectionMutation = useMutation({
     mutationFn: (enabled: boolean) => api.updatePrinter(printer.id, { plate_detection_enabled: enabled }),
@@ -2260,6 +2293,17 @@ function PrinterCard({
                     <RefreshCw className="w-4 h-4" />
                     {t('printers.reconnect')}
                   </button>
+                  <button
+                    className="w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2 disabled:opacity-50"
+                    disabled={forceRefreshMutation.isPending}
+                    onClick={() => {
+                      forceRefreshMutation.mutate();
+                      setShowMenu(false);
+                    }}
+                  >
+                    <RotateCw className={`w-4 h-4 ${forceRefreshMutation.isPending ? 'animate-spin' : ''}`} />
+                    {t('printers.forceRefresh')}
+                  </button>
                   <button
                     className="w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2"
                     onClick={() => {
@@ -2416,6 +2460,34 @@ function PrinterCard({
                   {status.firmware_version}
                 </span>
               ) : null}
+
+              {/* SD Card Badge */}
+              {status && (
+                <span
+                  className={`flex items-center px-2 py-1 rounded-full text-xs ${
+                    status.sdcard
+                      ? 'bg-status-ok/20 text-status-ok'
+                      : 'bg-red-500/20 text-red-400'
+                  }`}
+                  title={`${t('printers.sdCard')}: ${status.sdcard ? t('printers.inserted') : t('printers.notInserted')}`}
+                >
+                  <HardDrive className="w-3 h-3" />
+                </span>
+              )}
+
+              {/* Enclosure Door Badge (X1/P1S/P2S/H2*) */}
+              {status && ['X1C', 'X1', 'X1E', 'P1S', 'P1P', 'P2S', 'H2D', 'H2D Pro', 'H2C', 'H2S'].includes(printer.model ?? '') && (
+                <span
+                  className={`flex items-center px-2 py-1 rounded-full text-xs ${
+                    status.door_open
+                      ? 'bg-yellow-500/20 text-yellow-400'
+                      : 'bg-status-ok/20 text-status-ok'
+                  }`}
+                  title={status.door_open ? t('printers.door.open') : t('printers.door.closed')}
+                >
+                  {status.door_open ? <DoorOpen className="w-3 h-3" /> : <DoorClosed className="w-3 h-3" />}
+                </span>
+              )}
             </div>
           )}
         </div>
@@ -2772,6 +2844,56 @@ function PrinterCard({
                       {/* Separator */}
                       <div className="w-px h-5 bg-bambu-gray/30" />
 
+                      {/* Airduct Mode (P2S / H2*) */}
+                      {(['P2S', 'H2D', 'H2C', 'H2S'].includes(printer.model ?? '')) && (() => {
+                        const isHeating = status.airduct_mode === 1;
+                        const Icon = isHeating ? Flame : Snowflake;
+                        const color = isHeating ? 'text-orange-400' : 'text-sky-400';
+                        const bg = isHeating ? 'bg-orange-500/10 hover:bg-orange-500/20' : 'bg-sky-500/10 hover:bg-sky-500/20';
+                        return (
+                          <div className="relative">
+                            <button
+                              onClick={() => setShowAirductMenu(showAirductMenu === printer.id ? null : printer.id)}
+                              disabled={!hasPermission('printers:control')}
+                              className={`flex items-center gap-1 px-1.5 py-1 rounded transition-colors ${bg} disabled:opacity-50 disabled:cursor-not-allowed`}
+                              title={t('printers.airduct.title')}
+                            >
+                              <Icon className={`w-3.5 h-3.5 ${color}`} />
+                              <span className={`text-[10px] ${color}`}>
+                                {isHeating ? t('printers.airduct.heating') : t('printers.airduct.cooling')}
+                              </span>
+                            </button>
+                            {showAirductMenu === printer.id && (
+                              <>
+                                <div className="fixed inset-0 z-40" onClick={() => setShowAirductMenu(null)} />
+                                <div className="absolute bottom-full left-0 mb-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg py-1 min-w-[130px]">
+                                  {([
+                                    { mode: 'cooling', label: t('printers.airduct.cooling'), modeId: 0 },
+                                    { mode: 'heating', label: t('printers.airduct.heating'), modeId: 1 },
+                                  ] as const).map(({ mode, label, modeId }) => (
+                                    <button
+                                      key={mode}
+                                      onClick={() => {
+                                        airductMutation.mutate(mode);
+                                        setShowAirductMenu(null);
+                                      }}
+                                      className={`w-full text-left px-3 py-1.5 text-xs transition-colors flex items-center gap-2 ${
+                                        status.airduct_mode === modeId
+                                          ? 'text-bambu-green bg-bambu-green/10'
+                                          : 'text-white hover:bg-bambu-dark-tertiary'
+                                      }`}
+                                    >
+                                      {mode === 'heating' ? <Flame className="w-3 h-3" /> : <Snowflake className="w-3 h-3" />}
+                                      {label}
+                                    </button>
+                                  ))}
+                                </div>
+                              </>
+                            )}
+                          </div>
+                        );
+                      })()}
+
                       {/* Print Speed */}
                       {(() => {
                         const speedLabels: Record<number, string> = { 1: '50%', 2: '100%', 3: '124%', 4: '166%' };

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-B1-InwHl.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CP-2HMi5.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-D7ghDKX2.js


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Dc-TAKpR.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-CP-2HMi5.css">
+    <script type="module" crossorigin src="/assets/index-D7ghDKX2.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-B1-InwHl.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff