Browse Source

Add remote AMS drying controls (#292)

  Start, monitor, and stop drying sessions for AMS 2 Pro (n3f) and
  AMS-HT (n3s) directly from the Printers page. Flame icon in AMS card
  header opens a popover with filament-based temperature/duration presets
  from BambuStudio. Live countdown status bar shows time remaining.

  Backend: cache module_type from MQTT get_version, expose dry_time and
  module_type in both WebSocket and REST paths, add supports_drying()
  with firmware gating, add server-side guard on start_drying endpoint.

  Frontend: drying button, popover with filament select + temp/duration
  sliders, status bar, start/stop mutations. i18n for all 7 locales.

  Supported: X1/X1C (fw 01.09+), P1P/P1S (fw 01.08+), H2D (fw 01.02.30+),
  H2D Pro, X1E. Not supported: P2S, A1, A1 Mini, H2S, H2C.
maziggy 2 months ago
parent
commit
f21e502b8f

+ 2 - 1
CHANGELOG.md

@@ -2,9 +2,10 @@
 
 
 All notable changes to Bambuddy will be documented in this file.
 All notable changes to Bambuddy will be documented in this file.
 
 
-## [0.2.2b3] - 2026-03-11
+## [0.2.2b3] - 2026-03-12
 
 
 ### New Features
 ### New Features
+- **Remote AMS Drying** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — Start, monitor, and stop drying sessions for AMS 2 Pro and AMS-HT directly from the Printers page. A flame icon appears on supported AMS cards; clicking it opens a popover to select filament type (PLA, PETG, TPU, ABS, ASA, PA, PC, PVA) with official BambuStudio temperature/duration presets, or set temperature manually. When drying is active, a status bar shows the time remaining with a live countdown and stop button. Supported on X1/X1C (fw 01.09+), P1P/P1S (fw 01.08+), H2D (fw 01.02.30+), H2D Pro, and X1E. Not supported on P2S, A1, A1 Mini, H2S, or H2C. Requires `printers:control` permission when authentication is enabled.
 - **Home Assistant Notification Provider** ([#656](https://github.com/maziggy/bambuddy/issues/656)) — Added Home Assistant as a notification provider. When HA is configured in Settings → Network → Home Assistant, selecting "Home Assistant" as a notification provider sends persistent notifications to the HA dashboard — no additional configuration needed. From there, HA automations can forward notifications to mobile apps, WhatsApp, or any other service. Requested by @TravisWilder.
 - **Home Assistant Notification Provider** ([#656](https://github.com/maziggy/bambuddy/issues/656)) — Added Home Assistant as a notification provider. When HA is configured in Settings → Network → Home Assistant, selecting "Home Assistant" as a notification provider sends persistent notifications to the HA dashboard — no additional configuration needed. From there, HA automations can forward notifications to mobile apps, WhatsApp, or any other service. Requested by @TravisWilder.
 - **Virtual Printer Queue Auto-Dispatch Toggle** ([#587](https://github.com/maziggy/bambuddy/issues/587)) — Added an "Auto-dispatch" toggle to virtual printers in Queue mode. When enabled (default), prints sent from the slicer are added to the queue and start automatically on the assigned printer — matching the current behavior. When disabled, prints are added to the queue with `manual_start` set, so they wait for manual dispatch. This allows users who want to review and manually assign prints before they start. Requested by @Percy2Live.
 - **Virtual Printer Queue Auto-Dispatch Toggle** ([#587](https://github.com/maziggy/bambuddy/issues/587)) — Added an "Auto-dispatch" toggle to virtual printers in Queue mode. When enabled (default), prints sent from the slicer are added to the queue and start automatically on the assigned printer — matching the current behavior. When disabled, prints are added to the queue with `manual_start` set, so they wait for manual dispatch. This allows users who want to review and manually assign prints before they start. Requested by @Percy2Live.
 - **Queue All Plates** ([#530](https://github.com/maziggy/bambuddy/issues/530)) — Multi-plate 3MF files can now be queued in one action. When adding a multi-plate file to the queue, a "Queue All N Plates" toggle appears in the plate selector. When activated, every plate is added as a separate queue entry (one per plate × per selected printer), each individually editable from the queue page. The toggle is only available in add-to-queue mode (not reprint or edit). Requested by @Dendrowen.
 - **Queue All Plates** ([#530](https://github.com/maziggy/bambuddy/issues/530)) — Multi-plate 3MF files can now be queued in one action. When adding a multi-plate file to the queue, a "Queue All N Plates" toggle appears in the plate selector. When activated, every plate is added as a separate queue entry (one per plate × per selected printer), each individually editable from the queue page. The toggle is only available in add-to-queue mode (not reprint or edit). Requested by @Dendrowen.

+ 1 - 0
README.md

@@ -91,6 +91,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - AMS slot RFID re-read
 - AMS slot RFID re-read
 - AMS slot configuration (model-filtered presets, K profiles, color picker, pre-population for configured slots)
 - AMS slot configuration (model-filtered presets, K profiles, color picker, pre-population for configured slots)
 - AMS info card (hover for serial number, firmware version) with custom friendly names that persist across printers
 - AMS info card (hover for serial number, firmware version) with custom friendly names that persist across printers
+- **AMS remote drying** — Start, monitor, and stop drying sessions for AMS 2 Pro and AMS-HT directly from the Printers page with filament-based temperature/duration presets
 - Dual external spool support for H2D (Ext-L / Ext-R)
 - Dual external spool support for H2D (Ext-L / Ext-R)
 - HMS error monitoring with history and clear errors
 - HMS error monitoring with history and clear errors
 - Print success rates & trends
 - Print success rates & trends

+ 69 - 1
backend/app/api/routes/printers.py

@@ -35,7 +35,12 @@ from backend.app.services.bambu_ftp import (
     get_storage_info_async,
     get_storage_info_async,
     list_files_async,
     list_files_async,
 )
 )
-from backend.app.services.printer_manager import get_derived_status_name, printer_manager, supports_chamber_temp
+from backend.app.services.printer_manager import (
+    get_derived_status_name,
+    printer_manager,
+    supports_chamber_temp,
+    supports_drying,
+)
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/printers", tags=["printers"])
 router = APIRouter(prefix="/printers", tags=["printers"])
@@ -411,6 +416,8 @@ async def get_printer_status(
                         tray_uuid=tray_uuid,
                         tray_uuid=tray_uuid,
                         nozzle_temp_min=tray_data.get("nozzle_temp_min"),
                         nozzle_temp_min=tray_data.get("nozzle_temp_min"),
                         nozzle_temp_max=tray_data.get("nozzle_temp_max"),
                         nozzle_temp_max=tray_data.get("nozzle_temp_max"),
+                        drying_temp=tray_data.get("drying_temp"),
+                        drying_time=tray_data.get("drying_time"),
                     )
                     )
                 )
                 )
             # Prefer humidity_raw (percentage) over humidity (index 1-5)
             # Prefer humidity_raw (percentage) over humidity (index 1-5)
@@ -443,6 +450,9 @@ async def get_printer_status(
                     serial_number=str(ams_data.get("sn") or ams_data.get("serial_number") or ""),
                     serial_number=str(ams_data.get("sn") or ams_data.get("serial_number") or ""),
                     # Firmware version: populated by _handle_version_info from info.module ams/* entries
                     # Firmware version: populated by _handle_version_info from info.module ams/* entries
                     sw_ver=str(ams_data.get("sw_ver") or ""),
                     sw_ver=str(ams_data.get("sw_ver") or ""),
+                    # Drying: dry_time > 0 means drying is active (minutes remaining)
+                    dry_time=int(ams_data.get("dry_time") or 0),
+                    module_type=str(ams_data.get("module_type") or ""),
                 )
                 )
             )
             )
 
 
@@ -597,6 +607,7 @@ async def get_printer_status(
         firmware_version=state.firmware_version,
         firmware_version=state.firmware_version,
         developer_mode=state.developer_mode if state else None,
         developer_mode=state.developer_mode if state else None,
         plate_cleared=printer_manager.is_plate_cleared(printer_id),
         plate_cleared=printer_manager.is_plate_cleared(printer_id),
+        supports_drying=supports_drying(printer.model, state.firmware_version),
     )
     )
 
 
 
 
@@ -1447,6 +1458,63 @@ async def clear_mqtt_logs(
     return {"status": "cleared"}
     return {"status": "cleared"}
 
 
 
 
+# ============================================
+# AMS Drying Endpoints
+# ============================================
+
+
+@router.post("/{printer_id}/drying/start")
+async def start_drying(
+    printer_id: int,
+    ams_id: int,
+    temp: int = 45,
+    duration: int = 4,
+    filament: str = "",
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Send AMS drying start command. temp=45-85, duration=hours."""
+    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")
+
+    # Server-side guard: reject if this model/firmware doesn't support drying
+    live_state = printer_manager.get_status(printer_id)
+    firmware = live_state.firmware_version if live_state else None
+    if not supports_drying(printer.model, firmware):
+        raise HTTPException(400, "Drying not supported for this printer model or firmware version")
+
+    if temp < 45 or temp > 85:
+        raise HTTPException(400, "Temperature must be 45-85°C")
+    if duration < 1 or duration > 24:
+        raise HTTPException(400, "Duration must be 1-24 hours")
+
+    success = printer_manager.send_drying_command(printer_id, ams_id, temp, duration, mode=1, filament=filament)
+    if not success:
+        raise HTTPException(400, "Printer not connected")
+    return {"status": "drying_started", "ams_id": ams_id, "temp": temp, "duration": duration}
+
+
+@router.post("/{printer_id}/drying/stop")
+async def stop_drying(
+    printer_id: int,
+    ams_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Send AMS drying stop command."""
+    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")
+
+    success = printer_manager.send_drying_command(printer_id, ams_id, temp=0, duration=0, mode=0)
+    if not success:
+        raise HTTPException(400, "Printer not connected")
+    return {"status": "drying_stopped", "ams_id": ams_id}
+
+
 # ============================================
 # ============================================
 # Print Options (AI Detection) Endpoints
 # Print Options (AI Detection) Endpoints
 # ============================================
 # ============================================

+ 4 - 1
backend/app/main.py

@@ -359,12 +359,15 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
 
 
     # Include tray_now and vt_tray hash so external spool changes trigger broadcasts
     # Include tray_now and vt_tray hash so external spool changes trigger broadcasts
     vt_tray_key = hash(str(state.raw_data.get("vt_tray", []))) if state.raw_data else 0
     vt_tray_key = hash(str(state.raw_data.get("vt_tray", []))) if state.raw_data else 0
+    # Include AMS dry_time values so drying status changes trigger broadcasts
+    ams_dry_key = tuple(a.get("dry_time", 0) for a in (state.raw_data.get("ams") or [])) if state.raw_data else ()
     status_key = (
     status_key = (
         f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}:"
         f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}:"
         f"{nozzle_temp}:{bed_temp}:{nozzle_2_temp}:{chamber_temp}:"
         f"{nozzle_temp}:{bed_temp}:{nozzle_2_temp}:{chamber_temp}:"
         f"{state.stg_cur}:{bed_target}:{nozzle_target}:"
         f"{state.stg_cur}:{bed_target}:{nozzle_target}:"
         f"{state.cooling_fan_speed}:{state.big_fan1_speed}:{state.big_fan2_speed}:"
         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"{state.chamber_light}:{state.active_extruder}:{state.tray_now}:{vt_tray_key}:"
+        f"{ams_dry_key}"
     )
     )
 
 
     # MQTT relay - publish status (before dedup check - always publish to MQTT)
     # MQTT relay - publish status (before dedup check - always publish to MQTT)

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

@@ -130,6 +130,8 @@ class AMSTray(BaseModel):
     tray_uuid: str | None = None  # Bambu Lab spool UUID (32-char hex)
     tray_uuid: str | None = None  # Bambu Lab spool UUID (32-char hex)
     nozzle_temp_min: int | None = None  # Min nozzle temperature
     nozzle_temp_min: int | None = None  # Min nozzle temperature
     nozzle_temp_max: int | None = None  # Max nozzle temperature
     nozzle_temp_max: int | None = None  # Max nozzle temperature
+    drying_temp: int | None = None  # RFID-recommended drying temp
+    drying_time: int | None = None  # RFID-recommended drying time (hours)
 
 
 
 
 class AMSUnit(BaseModel):
 class AMSUnit(BaseModel):
@@ -140,6 +142,8 @@ class AMSUnit(BaseModel):
     tray: list[AMSTray] = []
     tray: list[AMSTray] = []
     serial_number: str = ""  # AMS unit serial number (sn from MQTT)
     serial_number: str = ""  # AMS unit serial number (sn from MQTT)
     sw_ver: str = ""  # AMS firmware version (from get_version info.module)
     sw_ver: str = ""  # AMS firmware version (from get_version info.module)
+    dry_time: int = 0  # Minutes remaining (0 = not drying, >0 = drying active)
+    module_type: str = ""  # "ams", "n3f", "n3s"
 
 
 
 
 class NozzleInfoResponse(BaseModel):
 class NozzleInfoResponse(BaseModel):
@@ -257,3 +261,5 @@ class PrinterStatus(BaseModel):
     developer_mode: bool | None = None
     developer_mode: bool | None = None
     # Queue: user has acknowledged plate is cleared for next queued print
     # Queue: user has acknowledged plate is cleared for next queued print
     plate_cleared: bool = False
     plate_cleared: bool = False
+    # AMS drying support
+    supports_drying: bool = False

+ 49 - 2
backend/app/services/bambu_mqtt.py

@@ -745,9 +745,12 @@ class BambuMQTTClient:
             sw_ver = module.get("sw_ver", "")
             sw_ver = module.get("sw_ver", "")
             sn = module.get("sn", "")
             sn = module.get("sn", "")
 
 
+            # Extract module type from prefix (e.g. "ams/0" → "ams", "n3f/0" → "n3f")
+            module_type = name.split("/", 1)[0]
+
             # Always cache so _apply_ams_version_cache can apply it when AMS data arrives
             # Always cache so _apply_ams_version_cache can apply it when AMS data arrives
-            if sw_ver or sn:
-                self._ams_version_cache[ams_id] = {"sw_ver": sw_ver, "sn": sn}
+            if sw_ver or sn or module_type:
+                self._ams_version_cache[ams_id] = {"sw_ver": sw_ver, "sn": sn, "module_type": module_type}
                 state_changed = True
                 state_changed = True
 
 
             # Also directly update any AMS unit already present in raw_data
             # Also directly update any AMS unit already present in raw_data
@@ -766,6 +769,8 @@ class BambuMQTTClient:
                         # Only set sn from version info if not already present in AMS data
                         # Only set sn from version info if not already present in AMS data
                         if sn and not ams_unit.get("sn"):
                         if sn and not ams_unit.get("sn"):
                             ams_unit["sn"] = sn
                             ams_unit["sn"] = sn
+                        if module_type:
+                            ams_unit["module_type"] = module_type
                         break
                         break
 
 
         # Trigger state change callback AFTER both loops so AMS sn/sw_ver are
         # Trigger state change callback AFTER both loops so AMS sn/sw_ver are
@@ -832,6 +837,9 @@ class BambuMQTTClient:
             # Only set sn if not already present in AMS data
             # Only set sn if not already present in AMS data
             if sn and not unit.get("sn") and not unit.get("serial_number"):
             if sn and not unit.get("sn") and not unit.get("serial_number"):
                 unit["sn"] = sn
                 unit["sn"] = sn
+            module_type = cached.get("module_type") or ""
+            if module_type and not unit.get("module_type"):
+                unit["module_type"] = module_type
 
 
     def _parse_xcam_data(self, xcam_data):
     def _parse_xcam_data(self, xcam_data):
         """Parse xcam data for camera settings and AI detection options."""
         """Parse xcam data for camera settings and AI detection options."""
@@ -2898,6 +2906,45 @@ class BambuMQTTClient:
         """Check if logging is enabled."""
         """Check if logging is enabled."""
         return self._logging_enabled
         return self._logging_enabled
 
 
+    def send_drying_command(self, ams_id: int, temp: int, duration: int, mode: int = 1, filament: str = ""):
+        """Send AMS drying start/stop command.
+
+        Args:
+            ams_id: AMS unit ID (0-3 for AMS 2 Pro, 128-135 for AMS-HT)
+            temp: Target drying temperature (45-65 for AMS 2 Pro, 45-85 for AMS-HT)
+            duration: Drying duration in hours
+            mode: 1=start, 0=stop
+            filament: Filament type string (e.g. "PLA", "PETG")
+        """
+        if not self._client:
+            return False
+        self._sequence_id += 1
+        command = {
+            "print": {
+                "sequence_id": str(self._sequence_id),
+                "command": "ams_filament_drying",
+                "ams_id": ams_id,
+                "temp": temp,
+                "cooling_temp": 20 if mode == 1 else 0,
+                "duration": duration,
+                "humidity": 0,
+                "mode": mode,
+                "rotate_tray": False,
+                "filament": filament,
+                "close_power_conflict": False,
+            }
+        }
+        self._client.publish(self.topic_publish, json.dumps(command), qos=1)
+        logger.info(
+            "[%s] Sent drying command: ams_id=%d, temp=%d, duration=%d, mode=%d",
+            self.serial_number,
+            ams_id,
+            temp,
+            duration,
+            mode,
+        )
+        return True
+
     def _handle_kprofile_response(self, data: dict):
     def _handle_kprofile_response(self, data: dict):
         """Handle K-profile response from printer."""
         """Handle K-profile response from printer."""
         response_nozzle = data.get("nozzle_diameter")
         response_nozzle = data.get("nozzle_diameter")

+ 3 - 1
backend/app/services/print_scheduler.py

@@ -451,7 +451,9 @@ class PrintScheduler:
                     return None, f"No matching material/color. Waiting on {', '.join(all_missing)}"
                     return None, f"No matching material/color. Waiting on {', '.join(all_missing)}"
                 # else: fall through — printers_busy will be appended below
                 # else: fall through — printers_busy will be appended below
             else:
             else:
-                names_and_missing = [f"{name} (needs {', '.join(missing)})" for name, missing in printers_missing_filament]
+                names_and_missing = [
+                    f"{name} (needs {', '.join(missing)})" for name, missing in printers_missing_filament
+                ]
                 reasons.append(f"Waiting for filament: {'; '.join(names_and_missing)}")
                 reasons.append(f"Waiting for filament: {'; '.join(names_and_missing)}")
         if printers_busy:
         if printers_busy:
             reasons.append(f"Busy: {', '.join(printers_busy)}")
             reasons.append(f"Busy: {', '.join(printers_busy)}")

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

@@ -82,6 +82,41 @@ def has_stg_cur_idle_bug(model: str | None) -> bool:
     return model_upper in A1_MODELS
     return model_upper in A1_MODELS
 
 
 
 
+# Minimum firmware versions for AMS drying support (confirmed via capture testing)
+# Keys are exact model names (upper-cased). Do NOT use substring matching — it would
+# incorrectly gate X1E (matched by "X1") and H2D Pro (matched by "H2D").
+_DRYING_MIN_FIRMWARE: dict[str, str] = {
+    "H2D": "01.02.30.00",
+    "X1": "01.09.00.00",
+    "X1C": "01.09.00.00",
+    "P1P": "01.08.00.00",
+    "P1S": "01.08.00.00",
+}
+# Models that definitely don't support AMS drying (no AMS 2 Pro / AMS-HT compatibility)
+_DRYING_UNSUPPORTED_MODELS = frozenset(
+    {"P2S", "A1", "A1MINI", "A1-MINI", "A1 MINI", "H2S", "H2C", "N7", "O1C", "O1C2", "O1S", "N1", "N2S"}
+)
+
+
+def supports_drying(model: str | None, firmware: str | None) -> bool:
+    """Check if a printer model supports AMS drying commands.
+
+    Known models with confirmed min firmware get version-gated.
+    Known unsupported models are blocked.
+    All other models (H2D Pro, X1E, future models) are allowed —
+    the command fails gracefully with result: "fail" if unsupported.
+    """
+    if not model:
+        return False
+    model_upper = model.strip().upper()
+    if model_upper in _DRYING_UNSUPPORTED_MODELS:
+        return False
+    if model_upper in _DRYING_MIN_FIRMWARE:
+        return bool(firmware and firmware >= _DRYING_MIN_FIRMWARE[model_upper])
+    # For all other models: allow
+    return True
+
+
 class PrinterInfo:
 class PrinterInfo:
     """Basic printer info for callbacks."""
     """Basic printer info for callbacks."""
 
 
@@ -409,6 +444,20 @@ class PrinterManager:
             return self._clients[printer_id].logging_enabled
             return self._clients[printer_id].logging_enabled
         return False
         return False
 
 
+    def send_drying_command(
+        self,
+        printer_id: int,
+        ams_id: int,
+        temp: int,
+        duration: int,
+        mode: int = 1,
+        filament: str = "",
+    ) -> bool:
+        """Send AMS drying command to printer."""
+        if printer_id not in self._clients:
+            return False
+        return self._clients[printer_id].send_drying_command(ams_id, temp, duration, mode, filament)
+
     def request_status_update(self, printer_id: int) -> bool:
     def request_status_update(self, printer_id: int) -> bool:
         """Request a full status update from the printer.
         """Request a full status update from the printer.
 
 
@@ -560,6 +609,8 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
                         "tray_uuid": tray_uuid,
                         "tray_uuid": tray_uuid,
                         "nozzle_temp_min": tray.get("nozzle_temp_min"),
                         "nozzle_temp_min": tray.get("nozzle_temp_min"),
                         "nozzle_temp_max": tray.get("nozzle_temp_max"),
                         "nozzle_temp_max": tray.get("nozzle_temp_max"),
+                        "drying_temp": tray.get("drying_temp"),
+                        "drying_time": tray.get("drying_time"),
                     }
                     }
                 )
                 )
             # Prefer humidity_raw (actual percentage) over humidity (index 1-5)
             # Prefer humidity_raw (actual percentage) over humidity (index 1-5)
@@ -593,6 +644,10 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
                     "serial_number": str(ams_data.get("sn") or ams_data.get("serial_number") or ""),
                     "serial_number": str(ams_data.get("sn") or ams_data.get("serial_number") or ""),
                     # Firmware version: populated by _handle_version_info from get_version
                     # Firmware version: populated by _handle_version_info from get_version
                     "sw_ver": str(ams_data.get("sw_ver") or ""),
                     "sw_ver": str(ams_data.get("sw_ver") or ""),
+                    # Drying: dry_time > 0 means drying is active (minutes remaining)
+                    "dry_time": int(ams_data.get("dry_time") or 0),
+                    # Module type: "ams", "n3f", "n3s" (from get_version)
+                    "module_type": str(ams_data.get("module_type") or ""),
                 }
                 }
             )
             )
 
 
@@ -699,6 +754,8 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
             }
             }
             for n in (state.nozzle_rack or [])
             for n in (state.nozzle_rack or [])
         ],
         ],
+        # AMS drying support
+        "supports_drying": supports_drying(model, state.firmware_version),
     }
     }
     # Add cover URL if there's an active print and printer_id is provided
     # Add cover URL if there's an active print and printer_id is provided
     # Include PAUSE state so skip objects modal can show cover
     # Include PAUSE state so skip objects modal can show cover

+ 122 - 0
backend/tests/unit/services/test_printer_manager.py

@@ -15,6 +15,7 @@ from backend.app.services.printer_manager import (
     init_printer_connections,
     init_printer_connections,
     printer_state_to_dict,
     printer_state_to_dict,
     supports_chamber_temp,
     supports_chamber_temp,
+    supports_drying,
 )
 )
 
 
 
 
@@ -673,6 +674,7 @@ class TestPrinterStateToDict:
         state.wifi_signal = -50
         state.wifi_signal = -50
         state.raw_data = {}
         state.raw_data = {}
         state.stg_cur = -1  # No calibration stage active
         state.stg_cur = -1  # No calibration stage active
+        state.firmware_version = None
         return state
         return state
 
 
     def test_basic_conversion(self, mock_state):
     def test_basic_conversion(self, mock_state):
@@ -885,6 +887,79 @@ class TestPrinterStateToDict:
         # Let's check the actual behavior
         # Let's check the actual behavior
         assert "chamber" not in result["temperatures"]
         assert "chamber" not in result["temperatures"]
 
 
+    def test_ams_drying_fields_included(self, mock_state):
+        """Verify AMS drying fields (dry_time, module_type) are included in output."""
+        mock_state.raw_data = {
+            "ams": [
+                {
+                    "id": 0,
+                    "dry_time": 42,
+                    "module_type": "n3f",
+                    "tray": [
+                        {
+                            "id": 0,
+                            "tray_color": "FF0000",
+                            "tray_type": "PLA",
+                            "drying_temp": 55,
+                            "drying_time": 240,
+                        }
+                    ],
+                }
+            ]
+        }
+
+        result = printer_state_to_dict(mock_state)
+
+        ams_unit = result["ams"][0]
+        assert ams_unit["dry_time"] == 42
+        assert ams_unit["module_type"] == "n3f"
+        # Tray-level drying fields
+        tray = ams_unit["tray"][0]
+        assert tray["drying_temp"] == 55
+        assert tray["drying_time"] == 240
+
+
+class TestStatusKeyDryingDedup:
+    """Regression tests for WebSocket dedup including drying fields.
+
+    The WebSocket broadcast deduplication uses printer_state_to_dict output
+    to detect changes. If drying fields (like dry_time) are missing from
+    the dict, changes to those fields won't trigger broadcasts.
+    """
+
+    def test_dry_time_change_changes_status_key(self):
+        """Verify dry_time is present in AMS unit data so dedup can detect changes."""
+        state = MagicMock()
+        state.connected = True
+        state.state = "IDLE"
+        state.current_print = None
+        state.subtask_name = None
+        state.gcode_file = None
+        state.progress = 0
+        state.remaining_time = 0
+        state.layer_num = 0
+        state.total_layers = 0
+        state.temperatures = {"nozzle": 25, "bed": 25}
+        state.hms_errors = []
+        state.ams_status_main = 0
+        state.ams_status_sub = 0
+        state.tray_now = None
+        state.wifi_signal = -50
+        state.stg_cur = -1
+
+        # First state: drying active with 30 minutes remaining
+        state.raw_data = {"ams": [{"id": 0, "dry_time": 30, "module_type": "n3f", "tray": [{"id": 0}]}]}
+        result1 = printer_state_to_dict(state)
+
+        # Second state: drying time decreased
+        state.raw_data = {"ams": [{"id": 0, "dry_time": 29, "module_type": "n3f", "tray": [{"id": 0}]}]}
+        result2 = printer_state_to_dict(state)
+
+        # The dicts should differ — dry_time changed
+        assert result1["ams"][0]["dry_time"] == 30
+        assert result2["ams"][0]["dry_time"] == 29
+        assert result1["ams"] != result2["ams"]
+
 
 
 class TestSupportsChamberTemp:
 class TestSupportsChamberTemp:
     """Tests for supports_chamber_temp helper function."""
     """Tests for supports_chamber_temp helper function."""
@@ -955,6 +1030,53 @@ class TestSupportsChamberTemp:
         assert supports_chamber_temp("N1") is False
         assert supports_chamber_temp("N1") is False
 
 
 
 
+class TestSupportsDrying:
+    """Tests for supports_drying helper function."""
+
+    def test_known_supported_with_firmware(self):
+        """Verify known models with sufficient firmware return True."""
+        assert supports_drying("X1C", "01.09.00.00") is True
+        assert supports_drying("P1S", "01.08.00.00") is True
+        assert supports_drying("H2D", "01.02.30.00") is True
+
+    def test_known_supported_old_firmware(self):
+        """Verify known models with old firmware return False."""
+        assert supports_drying("X1C", "01.08.00.00") is False
+        assert supports_drying("P1S", "01.07.00.00") is False
+
+    def test_known_supported_no_firmware(self):
+        """Verify known models with no firmware return False."""
+        assert supports_drying("X1C", None) is False
+
+    def test_unsupported_models(self):
+        """Verify models without AMS drying support return False regardless of firmware."""
+        for model in ["P2S", "A1", "A1MINI", "A1-MINI", "H2S", "H2C", "N7", "N1", "N2S"]:
+            assert supports_drying(model, "99.99.99.99") is False, f"Expected False for {model}"
+
+    def test_unknown_models_allowed(self):
+        """Verify unknown models are allowed (graceful fallback).
+
+        Models not in the unsupported set AND not matching any known firmware-gated
+        model substring get the benefit of the doubt and return True.
+        "H2D Pro" contains "H2D" so it IS firmware-gated (needs firmware).
+        """
+        # Truly unknown models: no substring match in _DRYING_MIN_FIRMWARE
+        assert supports_drying("FUTURE_MODEL", None) is True
+        # X1E contains "X1" substring, so it IS firmware-gated
+        assert supports_drying("X1E", "01.09.00.00") is True
+        # H2D Pro contains "H2D" substring, so it IS firmware-gated
+        assert supports_drying("H2D Pro", "01.02.30.00") is True
+
+    def test_none_model(self):
+        """Verify None model returns False."""
+        assert supports_drying(None, "01.09.00.00") is False
+
+    def test_case_insensitive(self):
+        """Verify model matching is case-insensitive."""
+        assert supports_drying("x1c", "01.09.00.00") is True
+        assert supports_drying("p2s", "99.99.99.99") is False
+
+
 class TestGetDerivedStatusName:
 class TestGetDerivedStatusName:
     """Tests for get_derived_status_name function."""
     """Tests for get_derived_status_name function."""
 
 

+ 196 - 0
frontend/src/__tests__/pages/PrintersPageDrying.test.ts

@@ -0,0 +1,196 @@
+/**
+ * Tests for AMS drying feature logic.
+ *
+ * The drying presets, time formatting, module type gating, and temperature
+ * clamping are all defined inline in PrintersPage.tsx. These tests validate
+ * the logic directly by mirroring the relevant constants and functions.
+ */
+import { describe, it, expect } from 'vitest';
+
+/**
+ * Mirrors the DRYING_PRESETS constant from PrintersPage.tsx.
+ * Format: { n3f temp, n3s temp, n3f hours, n3s hours }
+ */
+const DRYING_PRESETS: Record<string, { n3f: number; n3s: number; n3f_hours: number; n3s_hours: number }> = {
+  'PLA':   { n3f: 45, n3s: 45, n3f_hours: 12, n3s_hours: 12 },
+  'PETG':  { n3f: 65, n3s: 65, n3f_hours: 12, n3s_hours: 12 },
+  'TPU':   { n3f: 65, n3s: 75, n3f_hours: 12, n3s_hours: 18 },
+  'ABS':   { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
+  'ASA':   { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
+  'PA':    { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 12 },
+  'PC':    { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
+  'PVA':   { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 18 },
+};
+
+/**
+ * Mirrors the inline dry_time formatting from PrintersPage.tsx:
+ *   dry_time >= 60 ? `${Math.floor(dry_time / 60)}h ${dry_time % 60}m` : `${dry_time}m`
+ */
+function formatDryTime(dry_time: number): string {
+  if (dry_time >= 60) {
+    return `${Math.floor(dry_time / 60)}h ${dry_time % 60}m`;
+  }
+  return `${dry_time}m`;
+}
+
+/**
+ * Mirrors the temperature clamping from PrintersPage.tsx:
+ *   Math.min(maxTemp, Math.max(45, value))
+ * where maxTemp is 65 for n3f and 85 for n3s.
+ */
+function clampTemp(value: number, moduleType: 'n3f' | 'n3s'): number {
+  const maxTemp = moduleType === 'n3s' ? 85 : 65;
+  return Math.min(maxTemp, Math.max(45, value));
+}
+
+describe('DRYING_PRESETS structure', () => {
+  const expectedFilaments = ['PLA', 'PETG', 'TPU', 'ABS', 'ASA', 'PA', 'PC', 'PVA'];
+
+  it('contains all expected filament types', () => {
+    for (const fil of expectedFilaments) {
+      expect(DRYING_PRESETS).toHaveProperty(fil);
+    }
+  });
+
+  it('has no unexpected filament types', () => {
+    expect(Object.keys(DRYING_PRESETS).sort()).toEqual(expectedFilaments.sort());
+  });
+
+  it('n3f temps are all within 45-65 range', () => {
+    for (const [fil, preset] of Object.entries(DRYING_PRESETS)) {
+      expect(preset.n3f, `${fil} n3f temp`).toBeGreaterThanOrEqual(45);
+      expect(preset.n3f, `${fil} n3f temp`).toBeLessThanOrEqual(65);
+    }
+  });
+
+  it('n3s temps are all within 45-85 range', () => {
+    for (const [fil, preset] of Object.entries(DRYING_PRESETS)) {
+      expect(preset.n3s, `${fil} n3s temp`).toBeGreaterThanOrEqual(45);
+      expect(preset.n3s, `${fil} n3s temp`).toBeLessThanOrEqual(85);
+    }
+  });
+
+  it('all hours are between 1-24', () => {
+    for (const [fil, preset] of Object.entries(DRYING_PRESETS)) {
+      expect(preset.n3f_hours, `${fil} n3f_hours`).toBeGreaterThanOrEqual(1);
+      expect(preset.n3f_hours, `${fil} n3f_hours`).toBeLessThanOrEqual(24);
+      expect(preset.n3s_hours, `${fil} n3s_hours`).toBeGreaterThanOrEqual(1);
+      expect(preset.n3s_hours, `${fil} n3s_hours`).toBeLessThanOrEqual(24);
+    }
+  });
+
+  it('n3s temp is always >= n3f temp for same filament', () => {
+    for (const [fil, preset] of Object.entries(DRYING_PRESETS)) {
+      expect(preset.n3s, `${fil}: n3s should be >= n3f`).toBeGreaterThanOrEqual(preset.n3f);
+    }
+  });
+});
+
+describe('dry_time formatting', () => {
+  it('formats >= 60 minutes as hours and minutes', () => {
+    expect(formatDryTime(119)).toBe('1h 59m');
+  });
+
+  it('formats exactly 60 minutes as 1h 0m', () => {
+    expect(formatDryTime(60)).toBe('1h 0m');
+  });
+
+  it('formats large values correctly', () => {
+    expect(formatDryTime(750)).toBe('12h 30m');
+  });
+
+  it('formats < 60 minutes as minutes only', () => {
+    expect(formatDryTime(42)).toBe('42m');
+  });
+
+  it('formats 1 minute', () => {
+    expect(formatDryTime(1)).toBe('1m');
+  });
+
+  it('dry_time = 0 means not drying (shows 0m)', () => {
+    // In the UI, dry_time > 0 gates whether the drying bar is shown at all,
+    // so formatDryTime(0) would not be called. But the value itself means "not drying".
+    expect(formatDryTime(0)).toBe('0m');
+  });
+});
+
+describe('module type detection — drying button visibility', () => {
+  /**
+   * Mirrors the condition from PrintersPage.tsx:
+   *   ams.module_type === 'n3f' || ams.module_type === 'n3s'
+   * The drying button only shows for AMS 2 Pro (n3f) and AMS-HT (n3s).
+   */
+  function shouldShowDryingButton(moduleType: string): boolean {
+    return moduleType === 'n3f' || moduleType === 'n3s';
+  }
+
+  it('shows for n3f (AMS 2 Pro)', () => {
+    expect(shouldShowDryingButton('n3f')).toBe(true);
+  });
+
+  it('shows for n3s (AMS-HT)', () => {
+    expect(shouldShowDryingButton('n3s')).toBe(true);
+  });
+
+  it('does not show for ams (original AMS)', () => {
+    expect(shouldShowDryingButton('ams')).toBe(false);
+  });
+
+  it('does not show for empty string', () => {
+    expect(shouldShowDryingButton('')).toBe(false);
+  });
+
+  it('does not show for unknown types', () => {
+    expect(shouldShowDryingButton('unknown')).toBe(false);
+  });
+});
+
+describe('temperature clamping', () => {
+  describe('n3f (max 65)', () => {
+    it('clamps value below minimum to 45', () => {
+      expect(clampTemp(30, 'n3f')).toBe(45);
+    });
+
+    it('clamps value above maximum to 65', () => {
+      expect(clampTemp(80, 'n3f')).toBe(65);
+    });
+
+    it('keeps value within range unchanged', () => {
+      expect(clampTemp(55, 'n3f')).toBe(55);
+    });
+
+    it('keeps minimum boundary value', () => {
+      expect(clampTemp(45, 'n3f')).toBe(45);
+    });
+
+    it('keeps maximum boundary value', () => {
+      expect(clampTemp(65, 'n3f')).toBe(65);
+    });
+  });
+
+  describe('n3s (max 85)', () => {
+    it('clamps value below minimum to 45', () => {
+      expect(clampTemp(10, 'n3s')).toBe(45);
+    });
+
+    it('clamps value above maximum to 85', () => {
+      expect(clampTemp(100, 'n3s')).toBe(85);
+    });
+
+    it('keeps value within range unchanged', () => {
+      expect(clampTemp(70, 'n3s')).toBe(70);
+    });
+
+    it('keeps minimum boundary value', () => {
+      expect(clampTemp(45, 'n3s')).toBe(45);
+    });
+
+    it('keeps maximum boundary value', () => {
+      expect(clampTemp(85, 'n3s')).toBe(85);
+    });
+
+    it('allows values above n3f max (e.g. 75)', () => {
+      expect(clampTemp(75, 'n3s')).toBe(75);
+    });
+  });
+});

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

@@ -126,6 +126,8 @@ export interface AMSTray {
   tray_uuid: string | null;  // Bambu Lab spool UUID (32-char hex, only valid for Bambu Lab spools)
   tray_uuid: string | null;  // Bambu Lab spool UUID (32-char hex, only valid for Bambu Lab spools)
   nozzle_temp_min: number | null;  // Min nozzle temperature
   nozzle_temp_min: number | null;  // Min nozzle temperature
   nozzle_temp_max: number | null;  // Max nozzle temperature
   nozzle_temp_max: number | null;  // Max nozzle temperature
+  drying_temp: number | null;      // RFID-recommended drying temp
+  drying_time: number | null;      // RFID-recommended drying time (hours)
 }
 }
 
 
 export interface AMSUnit {
 export interface AMSUnit {
@@ -136,6 +138,8 @@ export interface AMSUnit {
   tray: AMSTray[];
   tray: AMSTray[];
   serial_number: string;  // AMS unit serial number (from MQTT sn field)
   serial_number: string;  // AMS unit serial number (from MQTT sn field)
   sw_ver: string;         // AMS firmware version (from get_version info.module ams/* entry)
   sw_ver: string;         // AMS firmware version (from get_version info.module ams/* entry)
+  dry_time: number;       // Minutes remaining (0 = not drying, >0 = drying active)
+  module_type: string;    // "ams", "n3f", "n3s"
 }
 }
 
 
 export interface NozzleInfo {
 export interface NozzleInfo {
@@ -257,6 +261,8 @@ export interface PrinterStatus {
   developer_mode: boolean | null;
   developer_mode: boolean | null;
   // Queue: user has acknowledged plate is cleared for next queued print
   // Queue: user has acknowledged plate is cleared for next queued print
   plate_cleared: boolean;
   plate_cleared: boolean;
+  // AMS drying support
+  supports_drying: boolean;
 }
 }
 
 
 export interface PrinterCreate {
 export interface PrinterCreate {
@@ -2377,6 +2383,18 @@ export const api = {
       method: 'POST',
       method: 'POST',
     }),
     }),
 
 
+  // AMS Drying Control
+  startDrying: (printerId: number, amsId: number, temp: number, duration: number, filament: string = '') =>
+    request<{ status: string; ams_id: number; temp: number; duration: number }>(
+      `/printers/${printerId}/drying/start?ams_id=${amsId}&temp=${temp}&duration=${duration}&filament=${encodeURIComponent(filament)}`,
+      { method: 'POST' }
+    ),
+  stopDrying: (printerId: number, amsId: number) =>
+    request<{ status: string; ams_id: number }>(
+      `/printers/${printerId}/drying/stop?ams_id=${amsId}`,
+      { method: 'POST' }
+    ),
+
   // Skip Objects
   // Skip Objects
   getPrintableObjects: (printerId: number) =>
   getPrintableObjects: (printerId: number) =>
     request<{
     request<{

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

@@ -404,6 +404,19 @@ export default {
       unknown: 'Unbekannt',
       unknown: 'Unbekannt',
       failedToStart: 'Erkennung konnte nicht gestartet werden',
       failedToStart: 'Erkennung konnte nicht gestartet werden',
     },
     },
+    // AMS Drying
+    drying: {
+      start: 'Trocknung starten',
+      stop: 'Trocknung stoppen',
+      temperature: 'Temperatur',
+      duration: 'Dauer',
+      hours: 'Stunden',
+      timeRemaining: '{{time}} verbleibend',
+      active: 'Trocknung',
+      notSupported: 'Trocknung nicht unterstützt',
+      startingDrying: 'Trocknung wird gestartet...',
+      stoppingDrying: 'Trocknung wird gestoppt...',
+    },
     // Filaments section
     // Filaments section
     filaments: 'Filamente',
     filaments: 'Filamente',
     // Camera
     // Camera

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

@@ -404,6 +404,19 @@ export default {
       unknown: 'Unknown',
       unknown: 'Unknown',
       failedToStart: 'Failed to start discovery',
       failedToStart: 'Failed to start discovery',
     },
     },
+    // AMS Drying
+    drying: {
+      start: 'Start Drying',
+      stop: 'Stop Drying',
+      temperature: 'Temperature',
+      duration: 'Duration',
+      hours: 'hours',
+      timeRemaining: '{{time}} left',
+      active: 'Drying',
+      notSupported: 'Drying not supported',
+      startingDrying: 'Starting drying...',
+      stoppingDrying: 'Stopping drying...',
+    },
     // Filaments section
     // Filaments section
     filaments: 'Filaments',
     filaments: 'Filaments',
     // Camera
     // Camera

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

@@ -404,6 +404,19 @@ export default {
       unknown: 'Inconnu',
       unknown: 'Inconnu',
       failedToStart: 'Échec du démarrage de la découverte',
       failedToStart: 'Échec du démarrage de la découverte',
     },
     },
+    // AMS Drying
+    drying: {
+      start: 'Démarrer le séchage',
+      stop: 'Arrêter le séchage',
+      temperature: 'Température',
+      duration: 'Durée',
+      hours: 'heures',
+      timeRemaining: '{{time}} restant',
+      active: 'Séchage',
+      notSupported: 'Séchage non pris en charge',
+      startingDrying: 'Démarrage du séchage...',
+      stoppingDrying: 'Arrêt du séchage...',
+    },
     // Filaments section
     // Filaments section
     filaments: 'Filaments',
     filaments: 'Filaments',
     // Camera
     // Camera

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

@@ -404,6 +404,19 @@ export default {
       unknown: 'Sconosciuto',
       unknown: 'Sconosciuto',
       failedToStart: 'Avvio ricerca non riuscito',
       failedToStart: 'Avvio ricerca non riuscito',
     },
     },
+    // AMS Drying
+    drying: {
+      start: 'Avvia essiccazione',
+      stop: 'Ferma essiccazione',
+      temperature: 'Temperatura',
+      duration: 'Durata',
+      hours: 'ore',
+      timeRemaining: '{{time}} rimanente',
+      active: 'Essiccazione',
+      notSupported: 'Essiccazione non supportata',
+      startingDrying: 'Avvio essiccazione...',
+      stoppingDrying: 'Arresto essiccazione...',
+    },
     // Filaments section
     // Filaments section
     filaments: 'Filamenti',
     filaments: 'Filamenti',
     // Camera
     // Camera

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

@@ -404,6 +404,19 @@ export default {
       unknown: '不明',
       unknown: '不明',
       failedToStart: '印刷の開始に失敗しました',
       failedToStart: '印刷の開始に失敗しました',
     },
     },
+    // AMS Drying
+    drying: {
+      start: '乾燥開始',
+      stop: '乾燥停止',
+      temperature: '温度',
+      duration: '時間',
+      hours: '時間',
+      timeRemaining: '残り {{time}}',
+      active: '乾燥中',
+      notSupported: '乾燥非対応',
+      startingDrying: '乾燥を開始しています...',
+      stoppingDrying: '乾燥を停止しています...',
+    },
     // Filaments section
     // Filaments section
     filaments: 'フィラメント',
     filaments: 'フィラメント',
     // Camera
     // Camera

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

@@ -404,6 +404,19 @@ export default {
       unknown: 'Desconhecido',
       unknown: 'Desconhecido',
       failedToStart: 'Falha ao iniciar a descoberta',
       failedToStart: 'Falha ao iniciar a descoberta',
     },
     },
+    // AMS Drying
+    drying: {
+      start: 'Iniciar secagem',
+      stop: 'Parar secagem',
+      temperature: 'Temperatura',
+      duration: 'Duração',
+      hours: 'horas',
+      timeRemaining: '{{time}} restante',
+      active: 'Secagem',
+      notSupported: 'Secagem não suportada',
+      startingDrying: 'Iniciando secagem...',
+      stoppingDrying: 'Parando secagem...',
+    },
     // Filaments section
     // Filaments section
     filaments: 'Filamentos',
     filaments: 'Filamentos',
     // Camera
     // Camera

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

@@ -404,6 +404,19 @@ export default {
       unknown: '未知',
       unknown: '未知',
       failedToStart: '启动发现失败',
       failedToStart: '启动发现失败',
     },
     },
+    // AMS Drying
+    drying: {
+      start: '开始干燥',
+      stop: '停止干燥',
+      temperature: '温度',
+      duration: '时长',
+      hours: '小时',
+      timeRemaining: '剩余 {{time}}',
+      active: '干燥中',
+      notSupported: '不支持干燥',
+      startingDrying: '正在启动干燥...',
+      stoppingDrying: '正在停止干燥...',
+    },
     // Filaments section
     // Filaments section
     filaments: '耗材',
     filaments: '耗材',
     // Camera
     // Camera

+ 270 - 1
frontend/src/pages/PrintersPage.tsx

@@ -45,6 +45,7 @@ import {
   Printer as PrinterIcon,
   Printer as PrinterIcon,
   Info,
   Info,
   Cable,
   Cable,
+  Flame,
 } from 'lucide-react';
 } from 'lucide-react';
 
 
 import { useNavigate } from 'react-router-dom';
 import { useNavigate } from 'react-router-dom';
@@ -1506,6 +1507,19 @@ export function AmsNameHoverCard({
 }
 }
 
 
 
 
+// AMS drying presets from BambuStudio filament profiles (idle mode temps)
+// Format: { n3f temp, n3s temp, n3f hours, n3s hours }
+const DRYING_PRESETS: Record<string, { n3f: number; n3s: number; n3f_hours: number; n3s_hours: number }> = {
+  'PLA':   { n3f: 45, n3s: 45, n3f_hours: 12, n3s_hours: 12 },
+  'PETG':  { n3f: 65, n3s: 65, n3f_hours: 12, n3s_hours: 12 },
+  'TPU':   { n3f: 65, n3s: 75, n3f_hours: 12, n3s_hours: 18 },
+  'ABS':   { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
+  'ASA':   { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
+  'PA':    { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 12 },
+  'PC':    { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
+  'PVA':   { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 18 },
+};
+
 function PrinterCard({
 function PrinterCard({
   printer,
   printer,
   hideIfDisconnected,
   hideIfDisconnected,
@@ -1568,6 +1582,13 @@ function PrinterCard({
   const [showPrinterInfo, setShowPrinterInfo] = useState(false);
   const [showPrinterInfo, setShowPrinterInfo] = useState(false);
   const closePrinterInfo = useCallback(() => setShowPrinterInfo(false), []);
   const closePrinterInfo = useCallback(() => setShowPrinterInfo(false), []);
   const [printAfterUpload, setPrintAfterUpload] = useState<{ id: number; filename: string } | null>(null);
   const [printAfterUpload, setPrintAfterUpload] = useState<{ id: number; filename: string } | null>(null);
+  // AMS drying popover state: which AMS unit has the popover open
+  const [dryingPopoverAmsId, setDryingPopoverAmsId] = useState<number | null>(null);
+  const [dryingPopoverModuleType, setDryingPopoverModuleType] = useState<string>('n3f');
+  const [dryingFilament, setDryingFilament] = useState('PLA');
+  const [dryingTemp, setDryingTemp] = useState(50);
+  const [dryingDuration, setDryingDuration] = useState(4);
+  const [dryingPopoverPos, setDryingPopoverPos] = useState<{ top: number; left: number } | null>(null);
   const [isDraggingFile, setIsDraggingFile] = useState(false);
   const [isDraggingFile, setIsDraggingFile] = useState(false);
   const [isDropUploading, setIsDropUploading] = useState(false);
   const [isDropUploading, setIsDropUploading] = useState(false);
   const dragCounterRef = useRef(0);
   const dragCounterRef = useRef(0);
@@ -1865,6 +1886,25 @@ function PrinterCard({
     },
     },
   });
   });
 
 
+  // AMS drying mutations
+  const startDryingMutation = useMutation({
+    mutationFn: ({ amsId, temp, duration, filament }: { amsId: number; temp: number; duration: number; filament: string }) =>
+      api.startDrying(printer.id, amsId, temp, duration, filament),
+    onSuccess: () => {
+      setDryingPopoverAmsId(null);
+      queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
+    },
+    onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),
+  });
+
+  const stopDryingMutation = useMutation({
+    mutationFn: (amsId: number) => api.stopDrying(printer.id, amsId),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
+    },
+    onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),
+  });
+
   // Smart plug control mutations
   // Smart plug control mutations
   const powerControlMutation = useMutation({
   const powerControlMutation = useMutation({
     mutationFn: (action: 'on' | 'off') =>
     mutationFn: (action: 'on' | 'off') =>
@@ -3056,9 +3096,63 @@ function PrinterCard({
                                       compact
                                       compact
                                     />
                                     />
                                   )}
                                   )}
+                                  {/* Drying button — only for AMS 2 Pro (n3f) and AMS-HT (n3s) */}
+                                  {status.supports_drying && (ams.module_type === 'n3f' || ams.module_type === 'n3s') && hasPermission('printers:control') && (
+                                    <button
+                                      onClick={(e) => {
+                                        if (ams.dry_time > 0) {
+                                          stopDryingMutation.mutate(ams.id);
+                                        } else if (dryingPopoverAmsId === ams.id) {
+                                          setDryingPopoverAmsId(null);
+                                        } else {
+                                          const firstTray = ams.tray.find(t => t.tray_type);
+                                          const filType = firstTray?.tray_type || 'PLA';
+                                          const preset = DRYING_PRESETS[filType] || DRYING_PRESETS['PLA'];
+                                          const moduleType = ams.module_type as 'n3f' | 'n3s';
+                                          setDryingFilament(filType);
+                                          setDryingTemp(preset[moduleType] || preset.n3f);
+                                          setDryingDuration(moduleType === 'n3s' ? preset.n3s_hours : preset.n3f_hours);
+                                          setDryingPopoverModuleType(ams.module_type);
+                                          setDryingPopoverAmsId(ams.id);
+                                          const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
+                                          setDryingPopoverPos({ top: rect.bottom + 4, left: Math.max(8, rect.right - 240) });
+                                        }
+                                      }}
+                                      className={`flex items-center gap-0.5 px-1 py-0.5 rounded text-[9px] transition-colors ${
+                                        ams.dry_time > 0
+                                          ? 'bg-amber-500/20 text-amber-400'
+                                          : 'bg-bambu-dark-tertiary/50 text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
+                                      }`}
+                                      title={ams.dry_time > 0 ? t('printers.drying.stop') : t('printers.drying.start')}
+                                    >
+                                      <Flame className="w-3 h-3" />
+                                    </button>
+                                  )}
                                 </div>
                                 </div>
                               )}
                               )}
                             </div>
                             </div>
+                            {/* Drying status bar */}
+                            {ams.dry_time > 0 && (
+                              <div className="flex items-center gap-2 px-2 py-1 mb-1 bg-amber-500/10 border border-amber-500/20 rounded text-[9px]">
+                                <Flame className="w-3 h-3 text-amber-400 shrink-0" />
+                                <span className="text-amber-400 font-medium">{t('printers.drying.active')}</span>
+                                <span className="text-amber-300/70">
+                                  {t('printers.drying.timeRemaining', {
+                                    time: ams.dry_time >= 60
+                                      ? `${Math.floor(ams.dry_time / 60)}h ${ams.dry_time % 60}m`
+                                      : `${ams.dry_time}m`
+                                  })}
+                                </span>
+                                <button
+                                  onClick={() => stopDryingMutation.mutate(ams.id)}
+                                  disabled={stopDryingMutation.isPending}
+                                  className="ml-auto text-amber-400 hover:text-amber-300 transition-colors disabled:opacity-50"
+                                  title={t('printers.drying.stop')}
+                                >
+                                  <X className="w-3 h-3" />
+                                </button>
+                              </div>
+                            )}
                             {/* Slots grid: 4 columns - always render 4 slots */}
                             {/* Slots grid: 4 columns - always render 4 slots */}
                             <div className="grid grid-cols-4 gap-1.5">
                             <div className="grid grid-cols-4 gap-1.5">
                               {[0, 1, 2, 3].map((slotIdx) => {
                               {[0, 1, 2, 3].map((slotIdx) => {
@@ -3367,7 +3461,7 @@ function PrinterCard({
 
 
                         return (
                         return (
                           <div key={ams.id} className="p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary/30">
                           <div key={ams.id} className="p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary/30">
-                            {/* Row 1: Label + Nozzle */}
+                            {/* Row 1: Label + Nozzle + Drying */}
                             <div className="flex items-center gap-1 mb-2">
                             <div className="flex items-center gap-1 mb-2">
                               {/* AMS name — hover to see serial, firmware, and edit friendly name */}
                               {/* AMS name — hover to see serial, firmware, and edit friendly name */}
                               <AmsNameHoverCard
                               <AmsNameHoverCard
@@ -3385,7 +3479,60 @@ function PrinterCard({
                               {isDualNozzle && (isLeftNozzle || isRightNozzle) && (
                               {isDualNozzle && (isLeftNozzle || isRightNozzle) && (
                                 <NozzleBadge side={isLeftNozzle ? 'L' : 'R'} />
                                 <NozzleBadge side={isLeftNozzle ? 'L' : 'R'} />
                               )}
                               )}
+                              {/* Drying button for HT AMS */}
+                              {status.supports_drying && (ams.module_type === 'n3f' || ams.module_type === 'n3s') && hasPermission('printers:control') && (
+                                <div className="relative ml-auto">
+                                  <button
+                                    onClick={(e) => {
+                                      if (ams.dry_time > 0) {
+                                        stopDryingMutation.mutate(ams.id);
+                                      } else if (dryingPopoverAmsId === ams.id) {
+                                        setDryingPopoverAmsId(null);
+                                      } else {
+                                        const firstTray = ams.tray.find(t => t.tray_type);
+                                        const filType = firstTray?.tray_type || 'PLA';
+                                        const preset = DRYING_PRESETS[filType] || DRYING_PRESETS['PLA'];
+                                        const moduleType = ams.module_type as 'n3f' | 'n3s';
+                                        setDryingFilament(filType);
+                                        setDryingTemp(firstTray?.drying_temp || preset[moduleType] || preset.n3f);
+                                        setDryingDuration(firstTray?.drying_time || (moduleType === 'n3s' ? preset.n3s_hours : preset.n3f_hours));
+                                        setDryingPopoverModuleType(ams.module_type);
+                                        setDryingPopoverAmsId(ams.id);
+                                        const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
+                                        setDryingPopoverPos({ top: rect.bottom + 4, left: Math.max(8, rect.right - 240) });
+                                      }
+                                    }}
+                                    className={`flex items-center gap-0.5 px-1 py-0.5 rounded text-[9px] transition-colors ${
+                                      ams.dry_time > 0
+                                        ? 'bg-amber-500/20 text-amber-400'
+                                        : 'bg-bambu-dark-tertiary/50 text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
+                                    }`}
+                                    title={ams.dry_time > 0 ? t('printers.drying.stop') : t('printers.drying.start')}
+                                  >
+                                    <Flame className="w-3 h-3" />
+                                  </button>
+                                </div>
+                              )}
                             </div>
                             </div>
+                            {/* HT AMS drying status bar */}
+                            {ams.dry_time > 0 && (
+                              <div className="flex items-center gap-1.5 px-2 py-1 mb-1 bg-amber-500/10 border border-amber-500/20 rounded text-[9px] whitespace-nowrap overflow-hidden">
+                                <Flame className="w-3 h-3 text-amber-400 shrink-0" />
+                                <span className="text-amber-300/70 text-[8px] truncate">
+                                  {ams.dry_time >= 60
+                                    ? `${Math.floor(ams.dry_time / 60)}h ${ams.dry_time % 60}m`
+                                    : `${ams.dry_time}m`}
+                                </span>
+                                <button
+                                  onClick={() => stopDryingMutation.mutate(ams.id)}
+                                  disabled={stopDryingMutation.isPending}
+                                  className="ml-auto text-amber-400 hover:text-amber-300 transition-colors disabled:opacity-50 shrink-0"
+                                  title={t('printers.drying.stop')}
+                                >
+                                  <X className="w-3 h-3" />
+                                </button>
+                              </div>
+                            )}
                             {/* Row 2: Slot (left) + Stats (right stacked) */}
                             {/* Row 2: Slot (left) + Stats (right stacked) */}
                             <div className="flex gap-1.5 max-[550px]:flex-col max-[550px]:items-start">
                             <div className="flex gap-1.5 max-[550px]:flex-col max-[550px]:items-start">
                               {/* Slot wrapper with menu button, dropdown, and loading overlay */}
                               {/* Slot wrapper with menu button, dropdown, and loading overlay */}
@@ -4444,6 +4591,128 @@ function PrinterCard({
           onClick={() => setAmsSlotMenu(null)}
           onClick={() => setAmsSlotMenu(null)}
         />
         />
       )}
       )}
+
+      {/* AMS Drying Popover — fixed position to avoid overflow/z-index issues */}
+      {dryingPopoverAmsId !== null && dryingPopoverPos && (() => {
+        const maxTemp = dryingPopoverModuleType === 'n3s' ? 85 : 65;
+        const sliderMin = 35;
+        const sliderMax = maxTemp + 10;
+        return (
+          <>
+            {/* Backdrop */}
+            <div className="fixed inset-0 z-[100]" onClick={() => setDryingPopoverAmsId(null)} />
+            {/* Popover */}
+            <div
+              className="fixed z-[101] w-[240px] bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl overflow-hidden"
+              style={{ top: dryingPopoverPos.top, left: dryingPopoverPos.left }}
+              onClick={e => e.stopPropagation()}
+            >
+              {/* Header */}
+              <div className="flex items-center gap-2 px-3 py-2.5 border-b border-bambu-dark-tertiary">
+                <Flame className="w-3.5 h-3.5 text-amber-400" />
+                <span className="text-xs text-white font-medium">{t('printers.drying.start')}</span>
+              </div>
+              {/* Body */}
+              <div className="px-3 py-2.5 space-y-2.5">
+                {/* Filament type select */}
+                <div>
+                  <label className="text-[10px] text-bambu-gray mb-1 block">{t('printers.filaments')}</label>
+                  <select
+                    value={dryingFilament}
+                    onChange={e => {
+                      const fil = e.target.value;
+                      setDryingFilament(fil);
+                      const preset = DRYING_PRESETS[fil];
+                      if (preset) {
+                        const key = dryingPopoverModuleType === 'n3s' ? 'n3s' : 'n3f';
+                        setDryingTemp(preset[key]);
+                        setDryingDuration(dryingPopoverModuleType === 'n3s' ? preset.n3s_hours : preset.n3f_hours);
+                      }
+                    }}
+                    className="w-full px-2 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-xs focus:outline-none focus:border-amber-500/50"
+                  >
+                    {Object.keys(DRYING_PRESETS).map(fil => (
+                      <option key={fil} value={fil}>{fil}</option>
+                    ))}
+                  </select>
+                </div>
+                {/* Temperature */}
+                <div>
+                  <div className="flex items-center justify-between mb-1">
+                    <label className="text-[10px] text-bambu-gray">{t('printers.drying.temperature')}</label>
+                    <div className="flex items-center gap-1">
+                      <input
+                        type="number"
+                        min={45}
+                        max={maxTemp}
+                        value={dryingTemp}
+                        onChange={e => setDryingTemp(Math.min(maxTemp, Math.max(45, Number(e.target.value) || 45)))}
+                        className="w-12 px-1 py-0.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-[11px] text-center focus:outline-none focus:border-amber-500/50 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
+                      />
+                      <span className="text-[10px] text-bambu-gray">°C</span>
+                    </div>
+                  </div>
+                  <input
+                    type="range"
+                    min={sliderMin}
+                    max={sliderMax}
+                    value={dryingTemp}
+                    onChange={e => setDryingTemp(Math.min(maxTemp, Math.max(45, Number(e.target.value))))}
+                    className="w-full h-1 accent-amber-500 cursor-pointer"
+                  />
+                  <div className="flex justify-between text-[9px] text-bambu-gray/50 mt-0.5">
+                    <span>45°C</span>
+                    <span>{maxTemp}°C</span>
+                  </div>
+                </div>
+                {/* Duration */}
+                <div>
+                  <div className="flex items-center justify-between mb-1">
+                    <label className="text-[10px] text-bambu-gray">{t('printers.drying.duration')}</label>
+                    <div className="flex items-center gap-1">
+                      <input
+                        type="number"
+                        min={1}
+                        max={24}
+                        value={dryingDuration}
+                        onChange={e => setDryingDuration(Math.min(24, Math.max(1, Number(e.target.value) || 1)))}
+                        className="w-10 px-1 py-0.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-[11px] text-center focus:outline-none focus:border-amber-500/50 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
+                      />
+                      <span className="text-[10px] text-bambu-gray">{t('printers.drying.hours')}</span>
+                    </div>
+                  </div>
+                  <input
+                    type="range"
+                    min={1}
+                    max={24}
+                    value={dryingDuration}
+                    onChange={e => setDryingDuration(Number(e.target.value))}
+                    className="w-full h-1 accent-amber-500 cursor-pointer"
+                  />
+                  <div className="flex justify-between text-[9px] text-bambu-gray/50 mt-0.5">
+                    <span>1h</span>
+                    <span>24h</span>
+                  </div>
+                </div>
+              </div>
+              {/* Footer */}
+              <div className="px-3 pb-3">
+                <button
+                  onClick={() => {
+                    if (dryingPopoverAmsId !== null) {
+                      startDryingMutation.mutate({ amsId: dryingPopoverAmsId, temp: dryingTemp, duration: dryingDuration, filament: dryingFilament });
+                    }
+                  }}
+                  disabled={startDryingMutation.isPending}
+                  className="w-full py-1.5 bg-amber-500 hover:bg-amber-400 text-white text-xs font-medium rounded-lg transition-colors disabled:opacity-50"
+                >
+                  {startDryingMutation.isPending ? t('printers.drying.startingDrying') : t('printers.drying.start')}
+                </button>
+              </div>
+            </div>
+          </>
+        );
+      })()}
     </Card>
     </Card>
   );
   );
 }
 }

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


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


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


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-tM2oTG9k.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BoLtXYT2.css">
+    <script type="module" crossorigin src="/assets/index-_97yluWW.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-_3X9byk-.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

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