Просмотр исходного кода

Stop the AMS temperature alert firing for heat the user asked for (#1802)

    The alert compares against ams_temp_fair, the same threshold that colours
    the printer card, which defaults to 35C. Drying deliberately runs at 45C
    for PLA, 65C for PETG and up to 85C on an AMS-HT, and the alert repeats
    once an hour for as long as the condition holds, so a twelve-hour dry
    sent twelve notifications about a temperature the user chose. It then
    kept sending them while the unit cooled back down, which is the half the
    reporter confirmed on an AMS 2 Pro and an H2C.

    Dispatch now consults the drying state the firmware already reports.
    dry_time alone is not enough: it reads 0 through the cooling phase that
    closes a cycle, so dry_status -- info bits 4-7, already parsed for the
    drying-complete edge -- carries the rest. That constant moves out of
    bambu_mqtt into a leaf util rather than being duplicated; drying_preflight
    would have been the natural home, but it imports printer_manager, which
    imports bambu_mqtt, and bambu_mqtt is one of the callers.

    The cool-down afterwards is held by a latch released as soon as the unit
    reads back at or below the threshold, rather than after a fixed delay, so
    a 65C cycle in a cold basement and a 45C one in a warm room each get the
    time they actually need. A two-hour cap bounds the one case the latch
    cannot resolve on its own -- a unit that never returns below the
    threshold -- and since such a unit would have been alarming with no
    drying involved, releasing there restores the ordinary behaviour instead
    of inventing a new alert.

    Two exclusions are deliberate. Humidity is untouched, because during
    drying that reading falling is the whole point. And dry_status 6,
    HeatOutOfControl, is kept out of the active set: an AMS that has lost
    thermal control is exactly when the alert should still arrive, so it must
    never read as expected heat.

    A cycle plus its cool-down outlasts a restart, so the latch is a settings
    row rather than a dict beside _ams_alarm_cooldown -- the internal
    timestamp-row pattern support.py already uses. It is read once per pass
    and written back only when a unit changed it. Stamps ahead of now are
    clamped on read, since a box whose clock jumps backwards writes them and
    suppression is measured as now minus the stamp; without the clamp the cap
    would measure from a moment that has not happened yet and hold the alert
    quiet for the skew on top of it.

    No new setting. The reporter was offered the opt-out checkbox they asked
    for and said they would not want it if the alert simply never fired
    during drying.
maziggy 2 недель назад
Родитель
Сommit
64a1defa7a
52 измененных файлов с 3938 добавлено и 268 удалено
  1. 0 8
      CHANGELOG.md
  2. 19 43
      backend/app/api/routes/printers.py
  3. 107 0
      backend/app/api/routes/scheduled_dryings.py
  4. 1 0
      backend/app/core/database.py
  5. 114 1
      backend/app/main.py
  6. 2 0
      backend/app/models/__init__.py
  7. 47 0
      backend/app/models/scheduled_drying.py
  8. 43 0
      backend/app/schemas/scheduled_drying.py
  9. 2 7
      backend/app/services/bambu_mqtt.py
  10. 117 0
      backend/app/services/drying_preflight.py
  11. 212 3
      backend/app/services/print_scheduler.py
  12. 88 0
      backend/app/utils/ams_drying.py
  13. 1 0
      backend/tests/conftest.py
  14. 136 0
      backend/tests/integration/test_ams_drying_latch_persistence.py
  15. 24 0
      backend/tests/integration/test_printers_api.py
  16. 191 1
      backend/tests/unit/test_ams_alarm_gating.py
  17. 86 0
      backend/tests/unit/test_drying_preflight.py
  18. 34 0
      backend/tests/unit/test_scheduled_drying_model.py
  19. 199 0
      backend/tests/unit/test_scheduled_drying_routes.py
  20. 45 0
      backend/tests/unit/test_scheduled_drying_schema.py
  21. 12 1
      backend/tests/unit/test_scheduler_clear_plate.py
  22. 619 0
      backend/tests/unit/test_scheduler_scheduled_drying.py
  23. 186 0
      backend/tests/unit/test_scheduler_scheduled_drying_check_queue.py
  24. 158 5
      frontend/src/__tests__/components/BugReportBubble.test.tsx
  25. 21 0
      frontend/src/__tests__/lib/scheduledDrying.test.ts
  26. 25 0
      frontend/src/__tests__/pages/FileManagerPage.test.tsx
  27. 361 0
      frontend/src/__tests__/pages/PrintersPageDryingStartModes.test.tsx
  28. 64 0
      frontend/src/__tests__/utils/popoverPosition.test.ts
  29. 40 0
      frontend/src/api/client.ts
  30. 186 15
      frontend/src/components/BugReportBubble.tsx
  31. 23 4
      frontend/src/components/Layout.tsx
  32. 24 0
      frontend/src/i18n/locales/de.ts
  33. 25 0
      frontend/src/i18n/locales/en.ts
  34. 24 0
      frontend/src/i18n/locales/es.ts
  35. 24 0
      frontend/src/i18n/locales/fr.ts
  36. 24 0
      frontend/src/i18n/locales/it.ts
  37. 24 0
      frontend/src/i18n/locales/ja.ts
  38. 25 1
      frontend/src/i18n/locales/ko.ts
  39. 24 0
      frontend/src/i18n/locales/pt-BR.ts
  40. 24 0
      frontend/src/i18n/locales/ru.ts
  41. 24 0
      frontend/src/i18n/locales/tr.ts
  42. 24 0
      frontend/src/i18n/locales/uk.ts
  43. 24 0
      frontend/src/i18n/locales/zh-CN.ts
  44. 24 0
      frontend/src/i18n/locales/zh-TW.ts
  45. 14 0
      frontend/src/lib/scheduledDrying.ts
  46. 95 116
      frontend/src/pages/FileManagerPage.tsx
  47. 319 52
      frontend/src/pages/PrintersPage.tsx
  48. 30 8
      frontend/src/utils/popoverPosition.ts
  49. 0 1
      static/assets/index-1Ya6fAmN.css
  50. 0 0
      static/assets/index-D5Du0dc3.js
  51. 1 0
      static/assets/index-VSpFxsmE.css
  52. 2 2
      static/index.html

Разница между файлами не показана из-за своего большого размера
+ 0 - 8
CHANGELOG.md


+ 19 - 43
backend/app/api/routes/printers.py

@@ -41,6 +41,7 @@ from backend.app.schemas.printer import (
     PrinterUpdate,
     PrintOptionsResponse,
 )
+from backend.app.services import drying_preflight
 from backend.app.services.bambu_ftp import (
     cache_3mf_download,
     delete_file_async,
@@ -405,6 +406,7 @@ async def delete_printer(
 
     from backend.app.models.archive import PrintArchive
     from backend.app.models.maintenance import MaintenanceHistory, PrinterMaintenance
+    from backend.app.models.scheduled_drying import ScheduledDrying
     from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -426,6 +428,9 @@ async def delete_printer(
     # Delete slot assignments for this printer (SQLite doesn't enforce FK cascades)
     await db.execute(sql_delete(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == printer_id))
 
+    # Delete scheduled drying runs for this printer (SQLite doesn't enforce FK cascades)
+    await db.execute(sql_delete(ScheduledDrying).where(ScheduledDrying.printer_id == printer_id))
+
     # Delete maintenance history and items for this printer
     # (SQLite doesn't enforce FK cascades, so do it explicitly)
     maintenance_ids = (
@@ -1947,7 +1952,7 @@ async def clear_mqtt_logs(
 # The P1 firmware acks `ams_filament_drying` with result: success and then ignores it
 # — Bambu's own P1 manual says drying "may only be controlled from the P1S screen"
 # (#2533). Refuse the command rather than let the caller believe it landed.
-_DRYING_SCREEN_ONLY_DETAIL = "This printer only supports AMS drying from its own screen"
+_DRYING_SCREEN_ONLY_DETAIL = drying_preflight.SCREEN_ONLY_DETAIL
 
 
 @router.post("/{printer_id}/drying/start")
@@ -1970,10 +1975,9 @@ async def start_drying(
     # 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 drying_screen_only(printer.model):
-        raise HTTPException(400, _DRYING_SCREEN_ONLY_DETAIL)
-    if not supports_drying(printer.model, firmware):
-        raise HTTPException(400, "Drying not supported for this printer model or firmware version")
+    unsupported = drying_preflight.check_drying_supported(printer.model, firmware)
+    if unsupported:
+        raise HTTPException(400, unsupported)
 
     if temp < 45 or temp > 85:
         raise HTTPException(400, "Temperature must be 45-85°C")
@@ -1984,44 +1988,16 @@ async def start_drying(
     # firmware silently ignores the command — #971) and backfill an empty
     # filament field from the first loaded tray so the printer doesn't reject
     # the payload.
-    target_ams: dict | None = None
-    for unit in (live_state.raw_data.get("ams") if live_state else None) or []:
-        try:
-            if int(unit.get("id", -1)) == ams_id:
-                target_ams = unit
-                break
-        except (TypeError, ValueError):
-            continue
-
-    if target_ams is not None:
-        reason_messages = {
-            0: "Printer is busy",
-            1: "Insufficient power — too many AMS drying or external PSU required",
-            2: "AMS is busy",
-            3: "Filament is at the AMS outlet — retract it first",
-            4: "AMS is already starting a drying cycle",
-            5: "Not supported in 2D mode",
-            6: "AMS is already drying",
-            7: "AMS firmware is upgrading",
-            8: "Plug in the external AMS power adapter to start drying",
-        }
-        for code in target_ams.get("dry_sf_reason") or []:
-            try:
-                code_int = int(code)
-            except (TypeError, ValueError):
-                continue
-            if code_int in reason_messages:
-                raise HTTPException(409, reason_messages[code_int])
-
-        if not filament:
-            for tray in target_ams.get("tray") or []:
-                tray_type = tray.get("tray_type")
-                if tray_type:
-                    filament = str(tray_type)
-                    break
-
-    if not filament:
-        filament = "PLA"
+    target_ams = drying_preflight.find_ams_unit(live_state, ams_id)
+    blocking = drying_preflight.blocking_reason_codes(target_ams)
+    if blocking:
+        # Same pick the scheduled path makes, so both describe one blocked AMS
+        # the same way rather than differing on which code the firmware listed
+        # first.
+        raise HTTPException(
+            409, drying_preflight.DRY_SF_REASON_MESSAGES[drying_preflight.primary_reason_code(blocking)]
+        )
+    filament = drying_preflight.resolve_filament(target_ams, filament)
 
     success = printer_manager.send_drying_command(
         printer_id, ams_id, temp, duration, mode=1, filament=filament, rotate_tray=rotate_tray

+ 107 - 0
backend/app/api/routes/scheduled_dryings.py

@@ -0,0 +1,107 @@
+"""Scheduled (delayed) manual AMS drying runs (#2638)."""
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.printer import Printer
+from backend.app.models.scheduled_drying import ScheduledDrying
+from backend.app.models.user import User
+from backend.app.schemas.scheduled_drying import ScheduledDryingCreate, ScheduledDryingResponse
+from backend.app.services import drying_preflight
+from backend.app.services.printer_manager import printer_manager
+from backend.app.utils.local_time import utcnow_naive
+
+router = APIRouter(prefix="/scheduled-dryings", tags=["scheduled-dryings"])
+
+ACTIVE_STATUSES = ("pending", "running")
+# Failed rows are listed too. A run can only fail at dispatch (the firmware
+# turns out too old, say), which is exactly the case a schedule-time check on
+# an offline printer cannot catch, so without this the run just disappears and
+# only the backend log knows why. The client dismisses the row to clear it.
+LISTED_STATUSES = (*ACTIVE_STATUSES, "failed")
+
+
+@router.post("", response_model=ScheduledDryingResponse)
+async def create_scheduled_drying(
+    payload: ScheduledDryingCreate,
+    user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    result = await db.execute(select(Printer).where(Printer.id == payload.printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    # Fail fast in the UI rather than hours later with nobody watching. An
+    # offline printer is still schedulable: only its model is judged here.
+    state = printer_manager.get_status(payload.printer_id)
+    unsupported = drying_preflight.check_drying_supported(
+        printer.model,
+        state.firmware_version if state else None,
+        require_firmware=state is not None,
+    )
+    if unsupported:
+        raise HTTPException(400, unsupported)
+
+    if payload.start_after is not None and payload.start_after <= utcnow_naive():
+        raise HTTPException(400, "start_after must be in the future")
+
+    row = ScheduledDrying(
+        printer_id=payload.printer_id,
+        ams_id=payload.ams_id,
+        temp=payload.temp,
+        duration_hours=payload.duration_hours,
+        filament=payload.filament,
+        rotate_tray=payload.rotate_tray,
+        start_after=payload.start_after,
+        created_by_id=user.id if user else None,
+    )
+    db.add(row)
+    await db.commit()
+    await db.refresh(row)
+    return row
+
+
+@router.get("", response_model=list[ScheduledDryingResponse])
+async def list_scheduled_dryings(
+    printer_id: int | None = None,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    query = select(ScheduledDrying).where(ScheduledDrying.status.in_(LISTED_STATUSES))
+    if printer_id is not None:
+        query = query.where(ScheduledDrying.printer_id == printer_id)
+    result = await db.execute(query.order_by(ScheduledDrying.start_after.asc().nullsfirst(), ScheduledDrying.id.asc()))
+    return list(result.scalars().all())
+
+
+@router.delete("/{scheduled_drying_id}")
+async def cancel_scheduled_drying(
+    scheduled_drying_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    result = await db.execute(select(ScheduledDrying).where(ScheduledDrying.id == scheduled_drying_id))
+    row = result.scalar_one_or_none()
+    if not row:
+        raise HTTPException(404, "Scheduled drying not found")
+    if row.status == "failed":
+        # Terminal and now acknowledged; drop it so it stops being listed.
+        await db.delete(row)
+        await db.commit()
+        return {"status": "dismissed", "id": scheduled_drying_id}
+    if row.status not in ACTIVE_STATUSES:
+        raise HTTPException(400, "Only pending, running or failed dryings can be cancelled")
+
+    if row.status == "running":
+        # Best effort; cancellation proceeds even if the printer is offline.
+        printer_manager.send_drying_command(row.printer_id, row.ams_id, 0, 0, mode=0)
+
+    row.status = "cancelled"
+    row.completed_at = utcnow_naive()
+    await db.commit()
+    return {"status": "cancelled", "id": row.id}

+ 1 - 0
backend/app/core/database.py

@@ -285,6 +285,7 @@ async def init_db():
         printer_sensor_history,
         project,
         project_bom,
+        scheduled_drying,
         settings,
         shopping_list,
         slicer_pipeline,

+ 114 - 1
backend/app/main.py

@@ -58,6 +58,7 @@ from backend.app.api.routes import (
     printer_sensor_history,
     printers,
     projects,
+    scheduled_dryings,
     settings as settings_routes,
     slice_jobs,
     slicer_pipelines,
@@ -127,6 +128,7 @@ from backend.app.services.spoolman_tracking import (
     store_print_data as _store_spoolman_print_data,
 )
 from backend.app.services.tasmota import tasmota_service
+from backend.app.utils.ams_drying import is_drying_active, temperature_alarm_suppressed
 
 
 # =============================================================================
@@ -6844,6 +6846,86 @@ _ams_cleanup_counter = 0  # Track recordings to trigger periodic cleanup
 _ams_alarm_cooldown: dict[str, datetime] = {}
 AMS_ALARM_COOLDOWN_MINUTES = 60  # Don't send same alarm more than once per hour
 
+# Per-AMS "drying was live at" latch that suppresses the high-temperature alarm
+# through a cycle and the cool-down after it (#1802). Stored in the settings
+# table rather than alongside _ams_alarm_cooldown above, because a restart
+# partway through a cool-down would otherwise resume alarming about heat the
+# user asked for — the same internal-timestamp-row pattern as
+# support.py's debug_logging_enabled_at.
+AMS_DRYING_LATCH_KEY = "ams_drying_alarm_latch"
+
+# Upper bound on that suppression. The latch normally clears as soon as the unit
+# reads at or below the threshold; see utils.ams_drying for why this cap only
+# matters when it never does.
+AMS_DRYING_GRACE_MINUTES = 120
+
+
+async def _load_ams_drying_latch(db) -> dict[str, datetime]:
+    """Read the persisted per-AMS drying latch, dropping entries out of window.
+
+    Anything older than the grace cap would expire on its next visit anyway, so
+    discarding it here costs nothing and stops rows for deleted printers from
+    accumulating.
+
+    Stamps ahead of now get two defences, because a box whose clock jumps
+    backwards (a Pi with no RTC coming up before NTP) writes them: wildly future
+    ones are discarded outright, and the rest are clamped to now. Without the
+    clamp the cap would measure from a moment that has not happened yet and hold
+    the alarm quiet for the skew on top of the cap. One unnecessary notification
+    after a clock jump is a far better failure than an alarm silently disabled
+    for hours.
+    """
+    from backend.app.models.settings import Settings
+
+    result = await db.execute(select(Settings).where(Settings.key == AMS_DRYING_LATCH_KEY))
+    setting = result.scalar_one_or_none()
+    if not setting or not setting.value:
+        return {}
+    try:
+        raw = json.loads(setting.value)
+    except (ValueError, TypeError):
+        return {}  # Corrupted row → no latch, alarms behave as they did before
+    if not isinstance(raw, dict):
+        return {}
+
+    now = datetime.now(timezone.utc)
+    window = timedelta(minutes=AMS_DRYING_GRACE_MINUTES)
+    latch: dict[str, datetime] = {}
+    for key, value in raw.items():
+        try:
+            stamp = datetime.fromisoformat(str(value))
+        except (ValueError, TypeError):
+            continue
+        if stamp.tzinfo is None:
+            stamp = stamp.replace(tzinfo=timezone.utc)
+        if not (now - window <= stamp <= now + window):
+            continue
+        # Nothing may sit in the future: suppression is measured as now minus
+        # the stamp, so a stamp ahead of now would extend it by the skew on top
+        # of the cap. Clamping the survivors keeps the cap an actual cap.
+        latch[str(key)] = min(stamp, now)
+    return latch
+
+
+async def _save_ams_drying_latch(db, latch: dict[str, datetime]) -> None:
+    """Persist the latch, writing only when it actually changed.
+
+    Adds the session change but does not commit — the caller's own commit
+    carries it, so the latch lands in the same transaction as the sensor rows
+    that produced it.
+    """
+    from backend.app.models.settings import Settings
+
+    payload = json.dumps({key: stamp.isoformat() for key, stamp in sorted(latch.items())})
+    result = await db.execute(select(Settings).where(Settings.key == AMS_DRYING_LATCH_KEY))
+    setting = result.scalar_one_or_none()
+    if setting is None:
+        # Don't create the row on installs that never dry anything.
+        if payload != "{}":
+            db.add(Settings(key=AMS_DRYING_LATCH_KEY, value=payload))
+    elif setting.value != payload:
+        setting.value = payload
+
 
 def _ams_has_filament(ams_data: dict) -> bool:
     """True if this AMS unit has at least one tray slot holding filament.
@@ -6932,6 +7014,11 @@ async def record_ams_history():
                     except (ValueError, TypeError):
                         pass  # Invalid JSON → no overrides, fall through to global threshold
 
+                # Per-AMS drying latch (#1802), loaded once per pass and written
+                # back below only if a unit changed it.
+                drying_latch = await _load_ams_drying_latch(db)
+                drying_latch_before = dict(drying_latch)
+
                 recorded_count = 0
                 for printer in printers:
                     # Get current state from printer manager
@@ -7049,8 +7136,30 @@ async def record_ams_history():
                                 except Exception as e:
                                     logger.warning("Failed to send humidity alarm: %s", e)
 
+                        # A drying cycle heats the unit far past ams_temp_fair on
+                        # purpose — 45 C for PLA, 65 C for PETG, 85 C on an
+                        # AMS-HT, against a 35 C default — so the alarm fired
+                        # once an hour for the whole cycle and kept firing while
+                        # the unit cooled back down (#1802). Latch on the
+                        # firmware's own drying state and hold until the reading
+                        # returns to normal. Humidity is deliberately left alone:
+                        # it falls during drying, which is the whole point.
+                        latch_key = f"{printer.id}:{ams_id}"
+                        suppress_temp_alarm, new_latch = temperature_alarm_suppressed(
+                            drying_active=is_drying_active(ams_data),
+                            temperature=temperature,
+                            threshold=temp_threshold,
+                            latched_at=drying_latch.get(latch_key),
+                            now=datetime.now(timezone.utc),
+                            grace_minutes=AMS_DRYING_GRACE_MINUTES,
+                        )
+                        if new_latch is None:
+                            drying_latch.pop(latch_key, None)
+                        else:
+                            drying_latch[latch_key] = new_latch
+
                         # Check temperature alarm (only if above threshold)
-                        if temperature is not None and temperature > temp_threshold:
+                        if temperature is not None and temperature > temp_threshold and not suppress_temp_alarm:
                             cooldown_key = f"{printer.id}:{ams_id}:temperature"
                             last_alarm = _ams_alarm_cooldown.get(cooldown_key)
                             now = datetime.now(timezone.utc)
@@ -7075,6 +7184,9 @@ async def record_ams_history():
                                 except Exception as e:
                                     logger.warning("Failed to send temperature alarm: %s", e)
 
+                if drying_latch != drying_latch_before:
+                    await _save_ams_drying_latch(db, drying_latch)
+
                 await db.commit()
                 if recorded_count > 0:
                     logger.info("Recorded %s AMS sensor history entries", recorded_count)
@@ -8662,6 +8774,7 @@ app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
 app.include_router(ha_sensors.router, prefix=app_settings.api_prefix)
 app.include_router(print_log.router, prefix=app_settings.api_prefix)
 app.include_router(print_queue.router, prefix=app_settings.api_prefix)
+app.include_router(scheduled_dryings.router, prefix=app_settings.api_prefix)
 app.include_router(kprofiles.router, prefix=app_settings.api_prefix)
 app.include_router(notifications.router, prefix=app_settings.api_prefix)
 app.include_router(notification_templates.router, prefix=app_settings.api_prefix)

+ 2 - 0
backend/app/models/__init__.py

@@ -24,6 +24,7 @@ from backend.app.models.printer import Printer
 from backend.app.models.printer_ha_sensor import PrinterHASensor
 from backend.app.models.printer_sensor_history import PrinterSensorHistory
 from backend.app.models.project import Project
+from backend.app.models.scheduled_drying import ScheduledDrying
 from backend.app.models.settings import Settings
 from backend.app.models.slicer_pipeline import SlicerPipeline
 from backend.app.models.smart_plug import SmartPlug
@@ -58,6 +59,7 @@ __all__ = [
     "AMSSensorHistory",
     "PrinterSensorHistory",
     "PrinterHASensor",
+    "ScheduledDrying",
     "AmsLabel",
     "PendingUpload",
     "PrintBatch",

+ 47 - 0
backend/app/models/scheduled_drying.py

@@ -0,0 +1,47 @@
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class ScheduledDrying(Base):
+    """A manual AMS drying run scheduled to start later (#2638).
+
+    Dispatched by PrintScheduler._check_scheduled_dryings() when start_after
+    has passed and the printer is idle. Parameters mirror the immediate
+    POST /printers/{id}/drying/start endpoint.
+    """
+
+    __tablename__ = "scheduled_dryings"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"))
+    ams_id: Mapped[int] = mapped_column(Integer, default=0)
+
+    temp: Mapped[int] = mapped_column(Integer)
+    duration_hours: Mapped[int] = mapped_column(Integer)
+    filament: Mapped[str] = mapped_column(String(50), default="")
+    rotate_tray: Mapped[bool] = mapped_column(Boolean, default=False)
+
+    # Earliest start instant, naive UTC (same convention as
+    # print_queue.scheduled_time). None = start as soon as the printer is idle.
+    start_after: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+
+    # pending / running / completed / cancelled / failed
+    status: Mapped[str] = mapped_column(String(20), default="pending")
+    waiting_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
+    error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+
+    printer: Mapped["Printer"] = relationship()
+    created_by: Mapped["User | None"] = relationship()
+
+
+from backend.app.models.printer import Printer  # noqa: E402
+from backend.app.models.user import User  # noqa: E402

+ 43 - 0
backend/app/schemas/scheduled_drying.py

@@ -0,0 +1,43 @@
+from datetime import datetime
+from typing import Annotated
+
+from pydantic import AfterValidator, BaseModel, Field
+
+from backend.app.schemas.print_queue import UTCDatetime
+from backend.app.utils.local_time import to_naive_utc
+
+# Coerces any client-sent UTC offset to the naive UTC the DB stores.
+NaiveUTCDatetime = Annotated[datetime | None, AfterValidator(to_naive_utc)]
+
+
+class ScheduledDryingCreate(BaseModel):
+    printer_id: int
+    ams_id: int = 0
+    temp: int = Field(ge=45, le=85)
+    duration_hours: int = Field(ge=1, le=24)
+    # max_length matches the String(50) column: without it PostgreSQL 500s on
+    # an over-long value and SQLite silently accepts it.
+    filament: str = Field("", max_length=50)
+    rotate_tray: bool = False
+    start_after: NaiveUTCDatetime = None
+
+
+class ScheduledDryingResponse(BaseModel):
+    id: int
+    printer_id: int
+    ams_id: int
+    temp: int
+    duration_hours: int
+    filament: str
+    rotate_tray: bool
+    # UTCDatetime, not a bare datetime: every queue route sends the Z suffix and
+    # the frontend parses these the same way.
+    start_after: UTCDatetime
+    status: str
+    waiting_reason: str | None
+    error_message: str | None
+    created_at: UTCDatetime
+    started_at: UTCDatetime
+    completed_at: UTCDatetime
+
+    model_config = {"from_attributes": True}

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

@@ -22,6 +22,7 @@ from datetime import datetime, timezone
 import paho.mqtt.client as mqtt
 
 from backend.app.services.hms_actions import HMSAction, get_actions_for_error_code
+from backend.app.utils.ams_drying import ACTIVE_DRY_STATUSES
 
 logger = logging.getLogger(__name__)
 
@@ -40,12 +41,6 @@ _AMS_MODULE_PREFIXES = ("ams/", "n3f/", "n3s/")
 # printer_manager.ACTIVE_PRINT_STATES and print_scheduler._ACTIVE_PRINT_STATES.
 _ACTIVE_PRINT_STATES = frozenset({"PREPARE", "SLICING", "RUNNING", "PAUSE"})
 
-# AMS dry_status phases (info bits 4-7) in which a drying cycle is still live, so
-# a dry_time of 0 alongside one of them is a transient rather than a completion
-# (#2759). 0=Off, 4=Stopping and 5=Error all mean the cycle is over or ending and
-# are deliberately excluded — those SHOULD end it.
-_ACTIVE_DRY_STATUSES = frozenset({1, 2, 3})  # Checking, Drying, Cooling
-
 # A drying cycle that runs to term ends with its countdown all but exhausted, so
 # the last dry_time we saw before the drop to 0 tells us whether the firmware
 # ended the cycle on schedule or aborted it. More than this many minutes still on
@@ -3240,7 +3235,7 @@ class BambuMQTTClient:
             # schedules smart-plug auto-off. dry_status comes from the same info
             # hex parsed above; when it is absent we let the edge through, so a
             # firmware that never reports one still ends its cycles.
-            if current == 0 and ams_unit.get("dry_status") in _ACTIVE_DRY_STATUSES:
+            if current == 0 and ams_unit.get("dry_status") in ACTIVE_DRY_STATUSES:
                 # Leave the remembered value alone, exactly as the absent-
                 # dry_time skip above does: whichever push ends the cycle for
                 # real must still see a non-zero previous.

+ 117 - 0
backend/app/services/drying_preflight.py

@@ -0,0 +1,117 @@
+"""Shared checks run before an AMS drying command is sent.
+
+Both the immediate POST /printers/{id}/drying/start endpoint and the
+scheduler's delayed dispatch go through here, so a run that the immediate
+path would refuse is never silently published by the scheduled path.
+"""
+
+from backend.app.services.printer_manager import drying_screen_only, supports_drying
+
+SCREEN_ONLY_DETAIL = "This printer only supports AMS drying from its own screen"
+UNSUPPORTED_DETAIL = "Drying not supported for this printer model or firmware version"
+
+# Firmware dry_sf_reason codes, as surfaced by the AMS status payload.
+DRY_SF_REASON_MESSAGES = {
+    0: "Printer is busy",
+    1: "Insufficient power: too many AMS drying or external PSU required",
+    2: "AMS is busy",
+    3: "Filament is at the AMS outlet, retract it first",
+    4: "AMS is already starting a drying cycle",
+    5: "Not supported in 2D mode",
+    6: "AMS is already drying",
+    7: "AMS firmware is upgrading",
+    8: "Plug in the external AMS power adapter to start drying",
+}
+
+# Codes 1 and 8 mean a power-supply problem the user has to fix; the rest
+# clear on their own. The frontend splits blocked states the same way.
+POWER_REASON_CODES = frozenset({1, 8})
+
+# Code 3 also needs the user to act, but the fix is retracting filament rather
+# than anything to do with power, so it gets its own token instead of the
+# generic "cannot dry right now" the transient codes share.
+RETRACT_REASON_CODE = 3
+
+WAITING_REASON_POWER = "ams_power_required"
+WAITING_REASON_RETRACT = "ams_retract_filament"
+WAITING_REASON_BLOCKED = "ams_blocked"
+
+
+def check_drying_supported(model: str | None, firmware: str | None, *, require_firmware: bool = True) -> str | None:
+    """Return a message if this printer cannot dry, else None.
+
+    Pass require_firmware=False when there is no live status to read a version
+    from, which leaves only the model check. Dispatch always has a status and
+    judges both.
+    """
+    if drying_screen_only(model):
+        return SCREEN_ONLY_DETAIL
+    if require_firmware and not supports_drying(model, firmware):
+        return UNSUPPORTED_DETAIL
+    return None
+
+
+def find_ams_unit(state, ams_id: int) -> dict | None:
+    """Locate an AMS unit in a live printer status payload."""
+    for unit in (state.raw_data.get("ams") if state else None) or []:
+        try:
+            if int(unit.get("id", -1)) == ams_id:
+                return unit
+        except (TypeError, ValueError):
+            continue
+    return None
+
+
+def blocking_reason_codes(unit: dict | None) -> list[int]:
+    """Known dry_sf_reason codes on this unit, in reported order."""
+    codes = []
+    for code in (unit or {}).get("dry_sf_reason") or []:
+        try:
+            code_int = int(code)
+        except (TypeError, ValueError):
+            continue
+        if code_int in DRY_SF_REASON_MESSAGES:
+            codes.append(code_int)
+    return codes
+
+
+def primary_reason_code(codes: list[int]) -> int | None:
+    """The one code to report when the AMS sets several at once.
+
+    Power and filament-at-the-outlet need the user to go and do something; the
+    rest clear on their own. Naming an actionable one is more use than whichever
+    the firmware happened to list first, and going through here keeps the
+    immediate endpoint's message and the scheduled row's waiting_reason
+    describing the same blocked AMS the same way.
+    """
+    for code in codes:
+        if code in POWER_REASON_CODES:
+            return code
+    if RETRACT_REASON_CODE in codes:
+        return RETRACT_REASON_CODE
+    return codes[0] if codes else None
+
+
+def waiting_reason_for_codes(codes: list[int]) -> str:
+    """Map blocking codes onto the token the frontend translates."""
+    code = primary_reason_code(codes)
+    if code in POWER_REASON_CODES:
+        return WAITING_REASON_POWER
+    if code == RETRACT_REASON_CODE:
+        return WAITING_REASON_RETRACT
+    return WAITING_REASON_BLOCKED
+
+
+def resolve_filament(unit: dict | None, filament: str) -> str:
+    """Fill an empty filament field from the first loaded tray.
+
+    The printer rejects a drying payload with no filament type, so both
+    callers fall back to the loaded spool and then to PLA.
+    """
+    if filament:
+        return filament
+    for tray in (unit or {}).get("tray") or []:
+        tray_type = tray.get("tray_type")
+        if tray_type:
+            return str(tray_type)
+    return "PLA"

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

@@ -7,7 +7,7 @@ import time
 import uuid
 from collections import deque
 from dataclasses import dataclass
-from datetime import datetime, timezone
+from datetime import datetime, timedelta, timezone
 from pathlib import Path
 
 from fastapi import HTTPException
@@ -23,11 +23,12 @@ from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
 from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
 from backend.app.models.printer import Printer
+from backend.app.models.scheduled_drying import ScheduledDrying
 from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
-from backend.app.services import print_dispatch_context
+from backend.app.services import drying_preflight, print_dispatch_context
 from backend.app.services.bambu_ftp import (
     UploadCancelled,
     cache_3mf_download,
@@ -58,6 +59,7 @@ from backend.app.services.smart_plug_manager import smart_plug_manager
 from backend.app.utils.color_utils import perceptual_color_distance
 from backend.app.utils.filament_types import canonical_filament_type
 from backend.app.utils.filename import derive_remote_filename
+from backend.app.utils.local_time import utcnow_naive
 from backend.app.utils.printer_models import (
     is_gcode_compatible,
     is_nozzle_rack_model,
@@ -186,6 +188,15 @@ class _KeepWarmEntry:
 AUTO_DRY_REARM_COOLDOWN_SECONDS = 30 * 60
 AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES = 2
 
+# How long a finished scheduled drying row is kept before it is pruned.
+SCHEDULED_DRYING_RETENTION_DAYS = 7
+# How often that prune actually runs. The check itself is called on every queue
+# pass — every 3s while dispatching — and issuing the DELETE is what begins a
+# write transaction, which SQLite serialises against every other writer. Rows
+# only become prunable a week after they finish, so anything short of hourly is
+# paying that cost for nothing.
+SCHEDULED_DRYING_PRUNE_INTERVAL_SECONDS = 60 * 60
+
 
 class _UploadProgressBridge:
     """Thread-safe bridge from ``upload_file_async`` to the WS broadcaster.
@@ -603,6 +614,14 @@ class PrintScheduler:
         #                  still above the threshold
         #   suspended    — we have stopped arming this unit and said so
         self._auto_dry_units: dict[tuple[int, int], dict[str, object]] = {}
+        # Printers with a "running" scheduled drying row (#2638). Rebuilt from the
+        # DB on every _check_scheduled_dryings call so route-side cancels show up.
+        # Auto-drying's stop-all branches must not stop or untrack these printers;
+        # both features share _drying_in_progress.
+        self._scheduled_drying_printer_ids: set[int] = set()
+        # Monotonic stamp of the last scheduled-drying prune. None = never, so
+        # the first pass after a restart reaps anything left behind.
+        self._last_scheduled_drying_prune: float | None = None
         # Defensive in-memory dispatch hold (#1157): a printer that just received
         # a project_file command must not get a second dispatch until either it
         # transitions out of pre_state OR the hard timeout expires. The H2D Pro
@@ -908,6 +927,9 @@ class PrintScheduler:
             # to clear it (#1865).
             require_plate_clear = await self._get_bool_setting(db, "require_plate_clear", default=False)
 
+            # Dispatch and track scheduled drying runs (#2638)
+            await self._check_scheduled_dryings(db)
+
             if not items:
                 # No dispatchable pending items — still check auto-drying on idle
                 # printers, but keep any printer with an upload still in flight
@@ -3481,6 +3503,8 @@ class PrintScheduler:
             # Stop active drying on all printers if both features disabled
             if self._drying_in_progress:
                 for pid in list(self._drying_in_progress):
+                    if pid in self._scheduled_drying_printer_ids:
+                        continue
                     logger.info("Auto-drying: printer %d — stopping, auto-drying disabled", pid)
                     await self._stop_drying(pid)
             return
@@ -3502,6 +3526,8 @@ class PrintScheduler:
         # may still be eligible for mid-print drying regardless of queue state).
         if not ambient_drying_enabled and not printers_with_scheduled and not print_drying_enabled:
             for pid in list(self._drying_in_progress):
+                if pid in self._scheduled_drying_printer_ids:
+                    continue
                 logger.info("Auto-drying: printer %d — stopping, no scheduled prints in queue", pid)
                 await self._stop_drying(pid)
             return
@@ -3554,7 +3580,7 @@ class PrintScheduler:
             if not mid_print:
                 # In queue-only mode, only dry printers that have scheduled prints
                 if not ambient_drying_enabled and pid not in printers_with_scheduled:
-                    if self._drying_in_progress.get(pid):
+                    if self._drying_in_progress.get(pid) and pid not in self._scheduled_drying_printer_ids:
                         logger.info("Auto-drying: printer %d — stopping, no scheduled prints for this printer", pid)
                         await self._stop_drying(pid)
                     logger.debug("Auto-drying: printer %d skipped — no scheduled prints", pid)
@@ -3964,6 +3990,189 @@ class PrintScheduler:
                 self.forget_auto_dry_cycle(printer_id, ams_id)
         self._drying_in_progress.pop(printer_id, None)
 
+    # Scheduled manual drying (#2638) -----------------------------------
+
+    SCHEDULED_DRYING_GRACE_SECONDS = 120  # firmware needs time to report dry_time
+    SCHEDULED_DRYING_COMPLETE_FRACTION = 0.9  # dry_time==0 earlier than this = interrupted
+
+    async def _check_scheduled_dryings(self, db: AsyncSession):
+        """Dispatch due scheduled drying runs and track running ones."""
+        now = utcnow_naive()
+
+        # Hourly, not every pass: see SCHEDULED_DRYING_PRUNE_INTERVAL_SECONDS.
+        # Monotonic, so a clock adjustment cannot park the prune for hours.
+        since_prune = time.monotonic()
+        if (
+            self._last_scheduled_drying_prune is None
+            or since_prune - self._last_scheduled_drying_prune >= SCHEDULED_DRYING_PRUNE_INTERVAL_SECONDS
+        ):
+            self._last_scheduled_drying_prune = since_prune
+            await db.execute(
+                delete(ScheduledDrying).where(
+                    ScheduledDrying.status.in_(("completed", "cancelled", "failed")),
+                    ScheduledDrying.completed_at.is_not(None),
+                    ScheduledDrying.completed_at < now - timedelta(days=SCHEDULED_DRYING_RETENTION_DAYS),
+                )
+            )
+
+        # Same order as the list route: with two rows due on one printer the
+        # earliest scheduled wins rather than whatever the DB hands back first.
+        result = await db.execute(
+            select(ScheduledDrying)
+            .where(ScheduledDrying.status.in_(("pending", "running")))
+            .order_by(ScheduledDrying.start_after.asc().nullsfirst(), ScheduledDrying.id.asc())
+        )
+        rows = list(result.scalars().all())
+
+        # Rebuild from the DB every tick so route-side cancels and completions
+        # show up. Auto-drying's stop-all branches check this set before
+        # stopping anything (#2638).
+        # Kept from the previous pass so a run that ended between passes — a
+        # cancel through the route, say — can still be released below.
+        previously_running = self._scheduled_drying_printer_ids
+        self._scheduled_drying_printer_ids = {row.printer_id for row in rows if row.status == "running"}
+        running_printer_ids = set(self._scheduled_drying_printer_ids)
+
+        # Model and firmware come from the printer row, not the live state.
+        printer_ids = {row.printer_id for row in rows}
+        printers_by_id: dict[int, Printer] = {}
+        if printer_ids:
+            printer_rows = await db.execute(select(Printer).where(Printer.id.in_(printer_ids)))
+            printers_by_id = {p.id: p for p in printer_rows.scalars()}
+
+        for row in rows:
+            if row.status == "running":
+                self._update_running_scheduled_drying(row, now)
+                continue
+
+            if row.start_after is not None and row.start_after > now:
+                continue
+
+            state = printer_manager.get_status(row.printer_id)
+            if not state:
+                row.waiting_reason = "printer_offline"
+                continue
+
+            # Same preflight the immediate endpoint runs. Without it the publish
+            # succeeds, the row goes to running, the printer ignores the command
+            # and the run silently cancels itself after the grace window.
+            printer = printers_by_id.get(row.printer_id)
+            unsupported = drying_preflight.check_drying_supported(
+                printer.model if printer else None, state.firmware_version
+            )
+            if unsupported:
+                row.status = "failed"
+                row.error_message = unsupported
+                row.completed_at = now
+                logger.warning("Scheduled drying %d: %s", row.id, unsupported)
+                continue
+
+            if self._drying_in_progress.get(row.printer_id) or row.printer_id in running_printer_ids:
+                row.waiting_reason = "already_drying"
+                continue
+            if not self._is_printer_idle(row.printer_id, require_plate_clear=False):
+                row.waiting_reason = "printer_busy"
+                continue
+
+            target = drying_preflight.find_ams_unit(state, row.ams_id)
+            if target is None:
+                row.waiting_reason = "ams_not_found"
+                continue
+            blocking = drying_preflight.blocking_reason_codes(target)
+            if blocking:
+                # Keep the power case distinct; it needs the user to act, so the
+                # card can say so instead of waiting silently.
+                row.waiting_reason = drying_preflight.waiting_reason_for_codes(blocking)
+                continue
+
+            filament = drying_preflight.resolve_filament(target, row.filament)
+            logger.info(
+                "Scheduled drying %d: starting on printer %d AMS %d at %d°C for %dh",
+                row.id,
+                row.printer_id,
+                row.ams_id,
+                row.temp,
+                row.duration_hours,
+            )
+            success = printer_manager.send_drying_command(
+                row.printer_id,
+                row.ams_id,
+                row.temp,
+                row.duration_hours,
+                mode=1,
+                filament=filament,
+                rotate_tray=row.rotate_tray,
+            )
+            if success:
+                row.status = "running"
+                row.started_at = now
+                row.waiting_reason = None
+                row.filament = filament
+                self._drying_in_progress[row.printer_id] = time.monotonic()
+                self._scheduled_drying_printer_ids.add(row.printer_id)
+                running_printer_ids.add(row.printer_id)
+            else:
+                row.waiting_reason = "printer_offline"
+
+        # Release the printers whose run has ended. `_drying_in_progress` is
+        # shared with auto-drying, which prunes it in `_sync_drying_state()` —
+        # but that call sits behind the auto-drying enabled check, and this
+        # method is the one writer that runs whether auto-drying is on or not.
+        # With it off, nothing would ever drop the entry short of a print being
+        # dispatched to the same printer, so the next scheduled run would wait
+        # on "already_drying" forever and `queue_drying_block` would hold the
+        # printer's prints too. Covers a run that ended during this pass and one
+        # cancelled through the route between passes.
+        self._scheduled_drying_printer_ids = {row.printer_id for row in rows if row.status == "running"}
+        for printer_id in (previously_running | running_printer_ids) - self._scheduled_drying_printer_ids:
+            self._drying_in_progress.pop(printer_id, None)
+
+        await db.commit()
+
+    def _update_running_scheduled_drying(self, row: ScheduledDrying, now: datetime):
+        """Detect completion or interruption of a running scheduled drying.
+
+        The firmware reports remaining minutes in ams.dry_time; 0 means not
+        drying. Within the grace window after start we ignore dry_time==0
+        (the status lags the command). After that, dry_time==0 near the end
+        of the configured duration means completed. Much earlier means the
+        run was stopped: re-queue it if a print preempted the dryer, but a
+        stop while the printer is idle was deliberate, so cancel the row
+        rather than restart drying the user just stopped.
+        """
+        if row.started_at is None:
+            row.started_at = now
+            return
+        elapsed = (now - row.started_at).total_seconds()
+        if elapsed < self.SCHEDULED_DRYING_GRACE_SECONDS:
+            return
+
+        state = printer_manager.get_status(row.printer_id)
+        if not state:
+            return  # offline mid-dry; resolve when it reconnects
+
+        # find_ams_unit, not a local lookup: this runs inside check_queue, so a
+        # throw on a malformed id would cost the whole pass including print
+        # dispatch, every tick.
+        target = drying_preflight.find_ams_unit(state, row.ams_id)
+        try:
+            dry_time = int(target.get("dry_time") or 0) if target else 0
+        except (TypeError, ValueError):
+            dry_time = 0
+        if dry_time > 0:
+            return
+
+        if elapsed >= row.duration_hours * 3600 * self.SCHEDULED_DRYING_COMPLETE_FRACTION:
+            row.status = "completed"
+            row.completed_at = now
+        elif not self._is_printer_idle(row.printer_id, require_plate_clear=False):
+            row.status = "pending"
+            row.started_at = None
+            row.waiting_reason = "interrupted"
+        else:
+            row.status = "cancelled"
+            row.completed_at = now
+
     async def _get_smart_plugs(self, db: AsyncSession, printer_id: int) -> list[SmartPlug]:
         """Get all smart plugs associated with a printer."""
         result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))

+ 88 - 0
backend/app/utils/ams_drying.py

@@ -0,0 +1,88 @@
+"""Shared reading of the firmware's own AMS drying state.
+
+Kept as a leaf module on purpose. ``drying_preflight`` would be the natural
+home, but it imports ``printer_manager``, which imports ``bambu_mqtt`` — and
+``bambu_mqtt`` is one of the callers here, so putting these there would close an
+import cycle. Nothing in this module imports from the app.
+"""
+
+from collections.abc import Mapping
+from datetime import datetime, timedelta
+from typing import Any
+
+# ``dry_status`` is bits 4-7 of the per-AMS ``info`` hex string (BambuStudio
+# DevFilaSystem.cpp): 0=Off, 1=Checking, 2=Drying, 3=Cooling, 4=Stopping,
+# 5=Error, 6=HeatOutOfControl, 7=PrdTesting. Only the first three mean a cycle
+# is still live.
+#
+# 4 (Stopping) and 5 (Error) are excluded because the cycle is over or ending.
+# 6 (HeatOutOfControl) is excluded deliberately and for a different reason: an
+# AMS that has lost thermal control is exactly when a high-temperature alarm
+# should still reach the user, so it must never read as "expected heat".
+ACTIVE_DRY_STATUSES = frozenset({1, 2, 3})  # Checking, Drying, Cooling
+
+
+def is_drying_active(ams_data: Any) -> bool:
+    """True when this AMS unit reports a drying cycle in progress.
+
+    Two independent signals, because neither alone is sufficient. ``dry_time``
+    is minutes remaining and reads 0 through the cooling phase that closes a
+    cycle; ``dry_status`` covers that phase but is only present when the
+    firmware sent a parseable ``info`` field.
+    """
+    if not isinstance(ams_data, Mapping):
+        return False
+    try:
+        if int(ams_data.get("dry_time") or 0) > 0:
+            return True
+    except (TypeError, ValueError):
+        pass  # Unparseable countdown — fall through to the phase field
+    try:
+        return int(ams_data["dry_status"]) in ACTIVE_DRY_STATUSES
+    except (KeyError, TypeError, ValueError):
+        return False
+
+
+def temperature_alarm_suppressed(
+    *,
+    drying_active: bool,
+    temperature: float | None,
+    threshold: float,
+    latched_at: datetime | None,
+    now: datetime,
+    grace_minutes: int,
+) -> tuple[bool, datetime | None]:
+    """Decide whether to hold back the AMS high-temperature alarm (#1802).
+
+    Drying heats an AMS far past the alarm threshold by design — 45 C for PLA,
+    65 C for PETG, up to 85 C on an AMS-HT, against a default threshold of
+    35 C — so without this the alarm fires once an hour for the length of the
+    cycle and keeps going while the unit cools back down.
+
+    Returns ``(suppress, latched_at)``. The second element is the latch to
+    persist: a timestamp while suppression is in force, ``None`` to clear it.
+
+    Suppression is released as soon as the unit reads back at or below the
+    threshold rather than after a fixed delay, so a 65 C cycle in a cold
+    basement and a 45 C one in a warm room each get exactly the cool-down they
+    need. ``grace_minutes`` only bounds the case where the unit never returns
+    below the threshold at all — and a unit that stays that hot would have been
+    alarming with no drying involved, so releasing there restores the ordinary
+    behaviour instead of inventing a new alert.
+    """
+    if drying_active:
+        return True, now
+    if latched_at is None:
+        return False, None
+    # Back at a normal storage temperature: the cool-down is over. Note this is
+    # also the only path that can clear the latch promptly, so it is checked
+    # before the cap.
+    if temperature is not None and temperature <= threshold:
+        return False, None
+    # ``latched_at`` is never in the future: the caller either just stamped it
+    # with this ``now`` or read it back through a loader that clamps. A future
+    # stamp would make this difference negative and hold suppression for the
+    # skew on top of the cap, which is why the clamp lives at the read.
+    if now - latched_at >= timedelta(minutes=grace_minutes):
+        return False, None
+    return True, latched_at

+ 1 - 0
backend/tests/conftest.py

@@ -208,6 +208,7 @@ async def test_engine():
         printer,
         project,
         project_bom,
+        scheduled_drying,
         settings,
         slot_preset,
         smart_plug,

+ 136 - 0
backend/tests/integration/test_ams_drying_latch_persistence.py

@@ -0,0 +1,136 @@
+"""The AMS drying latch has to survive a backend restart (#1802).
+
+Suppression of the high-temperature alarm spans a drying cycle plus the
+cool-down after it, which together can run well over twelve hours. Holding that
+purely in memory — as the sibling ``_ams_alarm_cooldown`` dict does — meant any
+restart partway through resumed alarming about heat the user asked for, so the
+latch is stored in the settings table instead.
+"""
+
+import json
+from datetime import datetime, timedelta, timezone
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.main import (
+    AMS_DRYING_GRACE_MINUTES,
+    AMS_DRYING_LATCH_KEY,
+    _load_ams_drying_latch,
+    _save_ams_drying_latch,
+)
+from backend.app.models.settings import Settings
+
+
+async def _stored_value(db_session) -> str | None:
+    result = await db_session.execute(select(Settings).where(Settings.key == AMS_DRYING_LATCH_KEY))
+    setting = result.scalar_one_or_none()
+    return setting.value if setting else None
+
+
+@pytest.mark.asyncio
+class TestAmsDryingLatchPersistence:
+    async def test_round_trip_survives_a_reload(self, db_session):
+        stamp = datetime.now(timezone.utc) - timedelta(minutes=10)
+        await _save_ams_drying_latch(db_session, {"1:0": stamp})
+        await db_session.commit()
+
+        # A fresh load is what a restarted backend does on its first pass.
+        assert await _load_ams_drying_latch(db_session) == {"1:0": stamp}
+
+    async def test_no_row_created_when_nothing_ever_dries(self, db_session):
+        await _save_ams_drying_latch(db_session, {})
+        await db_session.commit()
+        assert await _stored_value(db_session) is None
+        assert await _load_ams_drying_latch(db_session) == {}
+
+    async def test_existing_row_is_updated_not_duplicated(self, db_session):
+        first = datetime.now(timezone.utc) - timedelta(minutes=30)
+        second = datetime.now(timezone.utc)
+        await _save_ams_drying_latch(db_session, {"1:0": first})
+        await db_session.commit()
+        await _save_ams_drying_latch(db_session, {"1:0": second})
+        await db_session.commit()
+
+        result = await db_session.execute(select(Settings).where(Settings.key == AMS_DRYING_LATCH_KEY))
+        assert len(result.scalars().all()) == 1
+        assert await _load_ams_drying_latch(db_session) == {"1:0": second}
+
+    async def test_clearing_the_latch_empties_the_row(self, db_session):
+        await _save_ams_drying_latch(db_session, {"1:0": datetime.now(timezone.utc)})
+        await db_session.commit()
+        await _save_ams_drying_latch(db_session, {})
+        await db_session.commit()
+
+        assert await _stored_value(db_session) == "{}"
+        assert await _load_ams_drying_latch(db_session) == {}
+
+    async def test_multiple_units_are_tracked_independently(self, db_session):
+        now = datetime.now(timezone.utc)
+        latch = {"1:0": now - timedelta(minutes=5), "1:1": now, "2:128": now - timedelta(minutes=15)}
+        await _save_ams_drying_latch(db_session, latch)
+        await db_session.commit()
+        assert await _load_ams_drying_latch(db_session) == latch
+
+    async def test_entries_past_the_grace_cap_are_dropped_on_load(self, db_session):
+        now = datetime.now(timezone.utc)
+        fresh = now - timedelta(minutes=5)
+        stale = now - timedelta(minutes=AMS_DRYING_GRACE_MINUTES + 30)
+        await _save_ams_drying_latch(db_session, {"1:0": fresh, "9:3": stale})
+        await db_session.commit()
+
+        # The stale one would expire on its next visit anyway; dropping it here
+        # keeps rows for deleted printers from accumulating forever.
+        assert await _load_ams_drying_latch(db_session) == {"1:0": fresh}
+
+    async def test_wildly_future_stamps_are_dropped(self, db_session):
+        # A box whose clock jumps backwards (a Pi coming up before NTP) would
+        # otherwise hold the alarm suppressed until real time caught up.
+        future = datetime.now(timezone.utc) + timedelta(hours=6)
+        await _save_ams_drying_latch(db_session, {"1:0": future})
+        await db_session.commit()
+        assert await _load_ams_drying_latch(db_session) == {}
+
+    async def test_near_future_stamps_are_clamped_to_now(self, db_session):
+        # Small backwards skew survives as a latch, but must not sit ahead of
+        # now: suppression is measured as now minus the stamp, so a future one
+        # would run for the skew on top of the cap instead of the cap alone.
+        before = datetime.now(timezone.utc)
+        await _save_ams_drying_latch(db_session, {"1:0": before + timedelta(minutes=30)})
+        await db_session.commit()
+
+        loaded = await _load_ams_drying_latch(db_session)
+        assert set(loaded) == {"1:0"}
+        assert before <= loaded["1:0"] <= datetime.now(timezone.utc)
+
+    async def test_corrupt_row_reads_as_no_latch(self, db_session):
+        db_session.add(Settings(key=AMS_DRYING_LATCH_KEY, value="{not json"))
+        await db_session.commit()
+        # Degrades to the pre-#1802 behaviour rather than crashing the recorder.
+        assert await _load_ams_drying_latch(db_session) == {}
+
+    async def test_non_object_json_reads_as_no_latch(self, db_session):
+        db_session.add(Settings(key=AMS_DRYING_LATCH_KEY, value="[1, 2, 3]"))
+        await db_session.commit()
+        assert await _load_ams_drying_latch(db_session) == {}
+
+    async def test_unparseable_stamps_are_skipped_individually(self, db_session):
+        good = datetime.now(timezone.utc) - timedelta(minutes=3)
+        db_session.add(
+            Settings(
+                key=AMS_DRYING_LATCH_KEY,
+                value=json.dumps({"1:0": good.isoformat(), "1:1": "yesterday"}),
+            )
+        )
+        await db_session.commit()
+        assert await _load_ams_drying_latch(db_session) == {"1:0": good}
+
+    async def test_naive_stamps_are_read_as_utc(self, db_session):
+        # SQLite hands back naive datetimes elsewhere in the app, so a hand-edited
+        # or migrated value without an offset must not raise on comparison.
+        naive = (datetime.now(timezone.utc) - timedelta(minutes=7)).replace(tzinfo=None)
+        db_session.add(Settings(key=AMS_DRYING_LATCH_KEY, value=json.dumps({"1:0": naive.isoformat()})))
+        await db_session.commit()
+
+        loaded = await _load_ams_drying_latch(db_session)
+        assert loaded == {"1:0": naive.replace(tzinfo=timezone.utc)}

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

@@ -279,6 +279,30 @@ class TestPrintersAPI:
         response = await async_client.get(f"/api/v1/printers/{printer_id}")
         assert response.status_code == 404
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_printer_removes_scheduled_dryings(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """Deleting a printer must also delete its scheduled_dryings rows, since
+        SQLite doesn't enforce FK cascades (#2638).
+        """
+        from backend.app.models.scheduled_drying import ScheduledDrying
+
+        printer = await printer_factory()
+        printer_id = printer.id
+
+        row = ScheduledDrying(printer_id=printer_id, ams_id=0, temp=55, duration_hours=8)
+        db_session.add(row)
+        await db_session.commit()
+        row_id = row.id
+
+        response = await async_client.delete(f"/api/v1/printers/{printer_id}")
+        assert response.status_code == 200
+
+        result = await db_session.execute(select(ScheduledDrying).where(ScheduledDrying.id == row_id))
+        assert result.scalar_one_or_none() is None
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_delete_nonexistent_printer(self, async_client: AsyncClient):

+ 191 - 1
backend/tests/unit/test_ams_alarm_gating.py

@@ -1,4 +1,11 @@
-"""Tests for the empty-AMS alarm gate (#1619).
+"""Tests for the gates that hold back AMS humidity / temperature alarms.
+
+Two independent gates, both sitting in ``record_ams_history``'s dispatch: the
+empty-AMS gate (#1619) documented below, and the drying gate (#1802) that stops
+the temperature alarm firing throughout a drying cycle and the cool-down after
+it.
+
+Empty-AMS alarm gate (#1619).
 
 Empty AMS units still emit humidity/temperature sensor readings, but those
 readings are ambient and not actionable — there's no filament to dry. Without
@@ -8,7 +15,10 @@ array's ``tray_type`` strings) so the alarm dispatch in ``record_ams_history``
 can skip empty units while still alarming on loaded ones in the same printer.
 """
 
+from datetime import datetime, timedelta, timezone
+
 from backend.app.main import _ams_has_filament
+from backend.app.utils.ams_drying import is_drying_active, temperature_alarm_suppressed
 
 
 class TestAmsHasFilament:
@@ -80,3 +90,183 @@ class TestAmsHasFilament:
         assert _ams_has_filament(loaded) is True
         empty_int = {"tray_exist_bits": 0xED}  # no tray array, int ignored
         assert _ams_has_filament(empty_int) is False
+
+
+class TestIsDryingActive:
+    """The two firmware signals that mean "a drying cycle is running" (#1802)."""
+
+    def test_countdown_running_is_active(self):
+        assert is_drying_active({"dry_time": 720}) is True
+        # Strings appear in some payload shapes.
+        assert is_drying_active({"dry_time": "45"}) is True
+
+    def test_idle_unit_is_not_active(self):
+        assert is_drying_active({"dry_time": 0, "dry_status": 0}) is False
+        assert is_drying_active({}) is False
+
+    def test_cooling_phase_counts_as_active(self):
+        # The reason dry_time alone is not enough: the cycle's own cooling phase
+        # runs with the countdown already at 0.
+        assert is_drying_active({"dry_time": 0, "dry_status": 3}) is True
+
+    def test_checking_and_drying_phases_count_as_active(self):
+        assert is_drying_active({"dry_time": 0, "dry_status": 1}) is True
+        assert is_drying_active({"dry_time": 0, "dry_status": 2}) is True
+
+    def test_ending_phases_do_not_count_as_active(self):
+        # 4=Stopping, 5=Error — the cycle is over or aborting.
+        assert is_drying_active({"dry_time": 0, "dry_status": 4}) is False
+        assert is_drying_active({"dry_time": 0, "dry_status": 5}) is False
+
+    def test_heat_out_of_control_is_not_active(self):
+        # 6=HeatOutOfControl is the one phase where a high-temperature alarm is
+        # exactly what the user needs, so it must never read as expected heat.
+        assert is_drying_active({"dry_time": 0, "dry_status": 6}) is False
+
+    def test_missing_dry_status_falls_back_to_countdown(self):
+        # Firmware that never sends a parseable `info` has no dry_status at all.
+        assert is_drying_active({"dry_time": 30}) is True
+        assert is_drying_active({"dry_time": 0}) is False
+
+    def test_unparseable_values_do_not_raise(self):
+        assert is_drying_active({"dry_time": "junk", "dry_status": 2}) is True
+        assert is_drying_active({"dry_time": None, "dry_status": None}) is False
+        assert is_drying_active({"dry_time": "junk", "dry_status": "junk"}) is False
+
+    def test_non_mapping_input_is_not_active(self):
+        assert is_drying_active(None) is False
+        assert is_drying_active("drying") is False
+        assert is_drying_active(42) is False
+
+
+class TestTemperatureAlarmSuppressed:
+    """Latch behaviour for the AMS high-temperature alarm during drying (#1802)."""
+
+    NOW = datetime(2026, 8, 16, 12, 0, tzinfo=timezone.utc)
+    GRACE = 120
+
+    def _call(self, **overrides):
+        kwargs = {
+            "drying_active": False,
+            "temperature": 50.0,
+            "threshold": 35.0,
+            "latched_at": None,
+            "now": self.NOW,
+            "grace_minutes": self.GRACE,
+        }
+        kwargs.update(overrides)
+        return temperature_alarm_suppressed(**kwargs)
+
+    def test_no_drying_no_latch_alarms_normally(self):
+        # The pre-#1802 behaviour has to survive untouched for units that never dry.
+        suppress, latch = self._call(temperature=40.0)
+        assert suppress is False
+        assert latch is None
+
+    def test_drying_suppresses_and_sets_latch(self):
+        suppress, latch = self._call(drying_active=True, temperature=65.0)
+        assert suppress is True
+        assert latch == self.NOW
+
+    def test_drying_latches_even_when_below_threshold(self):
+        # Early in a cycle the unit is still heating up. The latch has to be set
+        # then too, or the cool-down afterwards starts unprotected.
+        suppress, latch = self._call(drying_active=True, temperature=28.0)
+        assert suppress is True
+        assert latch == self.NOW
+
+    def test_still_hot_after_cycle_stays_suppressed(self):
+        # The reported symptom: alarms kept arriving while the unit cooled.
+        suppress, latch = self._call(
+            temperature=52.0,
+            latched_at=self.NOW - timedelta(minutes=20),
+        )
+        assert suppress is True
+        assert latch == self.NOW - timedelta(minutes=20)
+
+    def test_cooled_back_to_normal_clears_latch(self):
+        suppress, latch = self._call(
+            temperature=34.0,
+            latched_at=self.NOW - timedelta(minutes=40),
+        )
+        assert suppress is False
+        assert latch is None
+
+    def test_exactly_at_threshold_counts_as_cooled(self):
+        # The alarm itself fires on `> threshold`, so `== threshold` is not hot.
+        suppress, latch = self._call(
+            temperature=35.0,
+            latched_at=self.NOW - timedelta(minutes=40),
+        )
+        assert suppress is False
+        assert latch is None
+
+    def test_alarms_again_after_the_latch_is_cleared(self):
+        # Having cooled once, a later genuine overheat is not swallowed.
+        _, latch = self._call(temperature=34.0, latched_at=self.NOW - timedelta(minutes=40))
+        suppress, latch = self._call(temperature=48.0, latched_at=latch)
+        assert suppress is False
+        assert latch is None
+
+    def test_grace_cap_releases_a_unit_that_never_cools(self):
+        # A unit stuck above the threshold would have alarmed with no drying
+        # involved, so the cap restores that rather than inventing an alert.
+        suppress, latch = self._call(
+            temperature=45.0,
+            latched_at=self.NOW - timedelta(minutes=self.GRACE + 1),
+        )
+        assert suppress is False
+        assert latch is None
+
+    def test_grace_cap_boundary_releases(self):
+        suppress, _ = self._call(
+            temperature=45.0,
+            latched_at=self.NOW - timedelta(minutes=self.GRACE),
+        )
+        assert suppress is False
+
+    def test_just_inside_the_grace_cap_still_suppresses(self):
+        suppress, _ = self._call(
+            temperature=45.0,
+            latched_at=self.NOW - timedelta(minutes=self.GRACE - 1),
+        )
+        assert suppress is True
+
+    def test_a_new_cycle_refreshes_the_latch(self):
+        # Starting a second dry inside the grace window must restart the clock,
+        # otherwise the cap could expire midway through the new cycle.
+        suppress, latch = self._call(
+            drying_active=True,
+            temperature=60.0,
+            latched_at=self.NOW - timedelta(minutes=self.GRACE - 5),
+        )
+        assert suppress is True
+        assert latch == self.NOW
+
+    def test_unreadable_temperature_holds_the_latch(self):
+        # A dropped reading is not evidence the unit cooled, and there is no
+        # alarm to fire on this pass anyway.
+        suppress, latch = self._call(
+            temperature=None,
+            latched_at=self.NOW - timedelta(minutes=10),
+        )
+        assert suppress is True
+        assert latch == self.NOW - timedelta(minutes=10)
+
+    def test_the_cap_is_measured_from_the_latch(self):
+        # Guards the precondition the loader's clamp exists to maintain: with a
+        # non-future latch, suppression expires exactly one cap after it, so the
+        # cap is a real bound rather than a floor. A future latch would push the
+        # release out by the skew as well, which is why the clamp is at the read
+        # — see _load_ams_drying_latch and its persistence tests.
+        latched = self.NOW - timedelta(minutes=self.GRACE)
+        suppress, latch = temperature_alarm_suppressed(
+            drying_active=False,
+            temperature=45.0,
+            threshold=35.0,
+            latched_at=latched,
+            now=self.NOW,
+            grace_minutes=self.GRACE,
+        )
+        assert suppress is False
+        assert latch is None

+ 86 - 0
backend/tests/unit/test_drying_preflight.py

@@ -0,0 +1,86 @@
+"""The shared drying preflight's blocked-reason rules (#2638).
+
+An AMS can report several ``dry_sf_reason`` codes at once, and the two callers
+render that differently: the immediate endpoint raises the message, the
+scheduler stores a token the frontend translates. Both pick the code through
+``primary_reason_code`` so one blocked AMS is not described two ways.
+"""
+
+import pytest
+
+from backend.app.services import drying_preflight as preflight
+
+pytestmark = pytest.mark.unit
+
+
+class TestPrimaryReasonCode:
+    def test_a_single_code_is_returned_as_is(self):
+        assert preflight.primary_reason_code([2]) == 2
+
+    @pytest.mark.parametrize("power_code", sorted(preflight.POWER_REASON_CODES))
+    def test_power_outranks_a_transient_code(self, power_code):
+        """ "AMS is busy" clears by itself; "plug the PSU in" does not, and the
+        user can only act on one of them."""
+        assert preflight.primary_reason_code([2, power_code]) == power_code
+        assert preflight.primary_reason_code([power_code, 2]) == power_code
+
+    def test_power_outranks_retract(self):
+        assert preflight.primary_reason_code([preflight.RETRACT_REASON_CODE, 1]) == 1
+
+    def test_retract_outranks_a_transient_code(self):
+        assert preflight.primary_reason_code([0, preflight.RETRACT_REASON_CODE]) == preflight.RETRACT_REASON_CODE
+
+    def test_transient_codes_keep_the_reported_order(self):
+        assert preflight.primary_reason_code([6, 2, 4]) == 6
+
+    def test_no_codes_is_no_answer(self):
+        assert preflight.primary_reason_code([]) is None
+
+
+class TestWaitingReason:
+    def test_power(self):
+        assert preflight.waiting_reason_for_codes([2, 8]) == preflight.WAITING_REASON_POWER
+
+    def test_retract(self):
+        assert preflight.waiting_reason_for_codes([2, 3]) == preflight.WAITING_REASON_RETRACT
+
+    def test_transient_falls_through_to_the_generic_token(self):
+        assert preflight.waiting_reason_for_codes([2]) == preflight.WAITING_REASON_BLOCKED
+
+    def test_no_codes_is_not_a_specific_reason(self):
+        """Callers only ask when something is blocking, but answering with a
+        power alert for an empty list would be worse than saying nothing
+        specific."""
+        assert preflight.waiting_reason_for_codes([]) == preflight.WAITING_REASON_BLOCKED
+
+
+class TestBothPathsAgree:
+    """The endpoint quotes a message and the scheduler stores a token. Whatever
+    they pick, it has to be the same code underneath."""
+
+    @pytest.mark.parametrize(
+        "codes,expected_token",
+        [
+            ([2, 1], preflight.WAITING_REASON_POWER),
+            ([2, 8], preflight.WAITING_REASON_POWER),
+            ([0, 3], preflight.WAITING_REASON_RETRACT),
+            ([6, 2], preflight.WAITING_REASON_BLOCKED),
+        ],
+    )
+    def test_the_message_and_the_token_describe_one_code(self, codes, expected_token):
+        code = preflight.primary_reason_code(codes)
+
+        # What the immediate endpoint raises.
+        assert code in preflight.DRY_SF_REASON_MESSAGES
+        # What the scheduled row records, for the same code.
+        assert preflight.waiting_reason_for_codes(codes) == expected_token
+
+
+class TestBlockingReasonCodes:
+    def test_unknown_and_malformed_codes_are_dropped(self):
+        """A firmware that grows a code we have no message for must not block a
+        run with an unexplainable reason."""
+        assert preflight.blocking_reason_codes({"dry_sf_reason": [2, 99, "x", None]}) == [2]
+
+    def test_a_missing_unit_is_not_blocking(self):
+        assert preflight.blocking_reason_codes(None) == []

+ 34 - 0
backend/tests/unit/test_scheduled_drying_model.py

@@ -0,0 +1,34 @@
+"""Tests for the ScheduledDrying model (#2638)."""
+
+import pytest
+
+from backend.app.models.scheduled_drying import ScheduledDrying
+
+
+@pytest.mark.asyncio
+async def test_scheduled_drying_defaults(db_session, printer_factory):
+    printer = await printer_factory()
+    row = ScheduledDrying(printer_id=printer.id, ams_id=0, temp=65, duration_hours=8)
+    db_session.add(row)
+    await db_session.commit()
+    await db_session.refresh(row)
+
+    assert row.id is not None
+    assert row.status == "pending"
+    assert row.start_after is None
+    assert row.rotate_tray is False
+    assert row.filament == ""
+    assert row.created_at is not None
+    assert row.started_at is None
+
+
+def test_printer_fk_declares_cascade_delete():
+    """Verify the model declares CASCADE delete on printer FK (enforced in PostgreSQL)."""
+    fk = next(iter(ScheduledDrying.__table__.c.printer_id.foreign_keys))
+    assert fk.ondelete == "CASCADE"
+
+
+def test_created_by_fk_declares_set_null():
+    """Verify the model declares SET NULL on created_by FK (enforced in PostgreSQL)."""
+    fk = next(iter(ScheduledDrying.__table__.c.created_by_id.foreign_keys))
+    assert fk.ondelete == "SET NULL"

+ 199 - 0
backend/tests/unit/test_scheduled_drying_routes.py

@@ -0,0 +1,199 @@
+"""Route tests for /scheduled-dryings (#2638)."""
+
+from datetime import datetime, timedelta, timezone
+from unittest.mock import patch
+
+import pytest
+
+
+def _future_iso(hours: int = 2) -> str:
+    return (datetime.now(timezone.utc) + timedelta(hours=hours)).isoformat()
+
+
+@pytest.mark.asyncio
+async def test_create_and_list(async_client, printer_factory):
+    printer = await printer_factory()
+    resp = await async_client.post(
+        "/api/v1/scheduled-dryings",
+        json={"printer_id": printer.id, "ams_id": 0, "temp": 65, "duration_hours": 8, "start_after": _future_iso()},
+    )
+    assert resp.status_code == 200, resp.text
+    body = resp.json()
+    assert body["status"] == "pending"
+    assert body["printer_id"] == printer.id
+
+    listed = await async_client.get(f"/api/v1/scheduled-dryings?printer_id={printer.id}")
+    assert listed.status_code == 200
+    assert [row["id"] for row in listed.json()] == [body["id"]]
+
+
+@pytest.mark.asyncio
+async def test_create_rejects_past_start_after(async_client, printer_factory):
+    printer = await printer_factory()
+    past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
+    resp = await async_client.post(
+        "/api/v1/scheduled-dryings",
+        json={"printer_id": printer.id, "temp": 65, "duration_hours": 8, "start_after": past},
+    )
+    assert resp.status_code == 400
+
+
+@pytest.mark.asyncio
+async def test_create_unknown_printer_404(async_client):
+    resp = await async_client.post(
+        "/api/v1/scheduled-dryings", json={"printer_id": 99999, "temp": 65, "duration_hours": 8}
+    )
+    assert resp.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_create_invalid_temp_422(async_client, printer_factory):
+    printer = await printer_factory()
+    resp = await async_client.post(
+        "/api/v1/scheduled-dryings", json={"printer_id": printer.id, "temp": 90, "duration_hours": 8}
+    )
+    assert resp.status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_cancel_pending(async_client, printer_factory):
+    printer = await printer_factory()
+    created = await async_client.post(
+        "/api/v1/scheduled-dryings",
+        json={"printer_id": printer.id, "temp": 65, "duration_hours": 8, "start_after": _future_iso()},
+    )
+    row_id = created.json()["id"]
+
+    resp = await async_client.delete(f"/api/v1/scheduled-dryings/{row_id}")
+    assert resp.status_code == 200
+    assert resp.json()["status"] == "cancelled"
+
+    # Cancelled rows disappear from the active list
+    listed = await async_client.get(f"/api/v1/scheduled-dryings?printer_id={printer.id}")
+    assert listed.json() == []
+
+    # Second cancel is a 400 (not active any more)
+    resp = await async_client.delete(f"/api/v1/scheduled-dryings/{row_id}")
+    assert resp.status_code == 400
+
+
+@pytest.mark.asyncio
+async def test_cancel_running_sends_stop_command(async_client, printer_factory, db_session):
+    from backend.app.models.scheduled_drying import ScheduledDrying
+
+    printer = await printer_factory()
+    row = ScheduledDrying(printer_id=printer.id, ams_id=1, temp=65, duration_hours=8, status="running")
+    db_session.add(row)
+    await db_session.commit()
+    await db_session.refresh(row)
+
+    with patch("backend.app.api.routes.scheduled_dryings.printer_manager") as mock_pm:
+        mock_pm.send_drying_command.return_value = True
+        resp = await async_client.delete(f"/api/v1/scheduled-dryings/{row.id}")
+
+    assert resp.status_code == 200
+    mock_pm.send_drying_command.assert_called_once_with(printer.id, 1, 0, 0, mode=0)
+
+
+@pytest.mark.asyncio
+async def test_screen_only_model_rejected_at_schedule_time(async_client, printer_factory):
+    """A P1S can never run a scheduled dry, so say so in the UI now."""
+    printer = await printer_factory(model="P1S")
+    resp = await async_client.post(
+        "/api/v1/scheduled-dryings",
+        json={"printer_id": printer.id, "temp": 65, "duration_hours": 8, "start_after": _future_iso()},
+    )
+    assert resp.status_code == 400
+    assert "screen" in resp.json()["detail"].lower()
+
+
+@pytest.mark.asyncio
+async def test_offline_printer_is_still_schedulable(async_client, printer_factory):
+    """Firmware is unreadable while offline and may be upgraded before the run."""
+    printer = await printer_factory()
+    resp = await async_client.post(
+        "/api/v1/scheduled-dryings",
+        json={"printer_id": printer.id, "temp": 65, "duration_hours": 8, "start_after": _future_iso()},
+    )
+    assert resp.status_code == 200, resp.text
+
+
+@pytest.mark.asyncio
+async def test_stale_firmware_rejected_when_printer_is_online(async_client, printer_factory):
+    printer = await printer_factory()
+    state = type("S", (), {"firmware_version": "01.05.00.00", "raw_data": {}})()
+    with patch("backend.app.api.routes.scheduled_dryings.printer_manager") as mock_pm:
+        mock_pm.get_status.return_value = state
+        resp = await async_client.post(
+            "/api/v1/scheduled-dryings",
+            json={"printer_id": printer.id, "temp": 65, "duration_hours": 8, "start_after": _future_iso()},
+        )
+    assert resp.status_code == 400
+
+
+@pytest.mark.asyncio
+async def test_timestamps_carry_z_suffix(async_client, printer_factory):
+    """Matches every print_queue route, which the frontend parses the same way."""
+    printer = await printer_factory()
+    resp = await async_client.post(
+        "/api/v1/scheduled-dryings",
+        json={"printer_id": printer.id, "temp": 65, "duration_hours": 8, "start_after": _future_iso()},
+    )
+    body = resp.json()
+    assert body["start_after"].endswith("Z")
+    assert body["created_at"].endswith("Z")
+
+
+@pytest.mark.asyncio
+async def test_failed_rows_are_listed_and_dismissable(async_client, printer_factory, db_session):
+    """A run that only fails at dispatch has to reach the client, not just the log."""
+    from backend.app.models.scheduled_drying import ScheduledDrying
+
+    printer = await printer_factory()
+    row = ScheduledDrying(
+        printer_id=printer.id,
+        temp=65,
+        duration_hours=8,
+        status="failed",
+        error_message="Drying not supported for this printer model or firmware version",
+    )
+    db_session.add(row)
+    await db_session.commit()
+    await db_session.refresh(row)
+
+    listed = await async_client.get(f"/api/v1/scheduled-dryings?printer_id={printer.id}")
+    assert [r["id"] for r in listed.json()] == [row.id]
+    assert listed.json()[0]["error_message"].startswith("Drying not supported")
+
+    resp = await async_client.delete(f"/api/v1/scheduled-dryings/{row.id}")
+    assert resp.status_code == 200
+    assert resp.json()["status"] == "dismissed"
+
+    assert (await async_client.get(f"/api/v1/scheduled-dryings?printer_id={printer.id}")).json() == []
+    # Dismissing twice is a 404, not a second dismiss: the row is gone.
+    assert (await async_client.delete(f"/api/v1/scheduled-dryings/{row.id}")).status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_list_orders_by_start_after_then_id(async_client, printer_factory):
+    printer = await printer_factory()
+    later = await async_client.post(
+        "/api/v1/scheduled-dryings",
+        json={"printer_id": printer.id, "temp": 65, "duration_hours": 8, "start_after": _future_iso(5)},
+    )
+    sooner = await async_client.post(
+        "/api/v1/scheduled-dryings",
+        json={"printer_id": printer.id, "temp": 65, "duration_hours": 8, "start_after": _future_iso(2)},
+    )
+    listed = await async_client.get(f"/api/v1/scheduled-dryings?printer_id={printer.id}")
+    assert [r["id"] for r in listed.json()] == [sooner.json()["id"], later.json()["id"]]
+
+
+@pytest.mark.asyncio
+async def test_over_long_filament_422(async_client, printer_factory):
+    printer = await printer_factory()
+    resp = await async_client.post(
+        "/api/v1/scheduled-dryings",
+        json={"printer_id": printer.id, "temp": 65, "duration_hours": 8, "filament": "X" * 51},
+    )
+    assert resp.status_code == 422

+ 45 - 0
backend/tests/unit/test_scheduled_drying_schema.py

@@ -0,0 +1,45 @@
+"""Tests for ScheduledDrying schemas (#2638)."""
+
+from datetime import datetime, timezone
+
+import pytest
+from pydantic import ValidationError
+
+from backend.app.schemas.scheduled_drying import ScheduledDryingCreate
+
+
+def test_create_normalizes_aware_datetime_to_naive_utc():
+    payload = ScheduledDryingCreate(
+        printer_id=1,
+        temp=65,
+        duration_hours=8,
+        start_after=datetime(2026, 7, 23, 18, 0, tzinfo=timezone.utc),
+    )
+    assert payload.start_after == datetime(2026, 7, 23, 18, 0)
+    assert payload.start_after.tzinfo is None
+
+
+def test_create_accepts_naive_datetime_unchanged():
+    payload = ScheduledDryingCreate(printer_id=1, temp=45, duration_hours=1, start_after=datetime(2026, 7, 23, 18, 0))
+    assert payload.start_after == datetime(2026, 7, 23, 18, 0)
+
+
+def test_temp_out_of_range_rejected():
+    with pytest.raises(ValidationError):
+        ScheduledDryingCreate(printer_id=1, temp=90, duration_hours=8)
+    with pytest.raises(ValidationError):
+        ScheduledDryingCreate(printer_id=1, temp=44, duration_hours=8)
+
+
+def test_duration_out_of_range_rejected():
+    with pytest.raises(ValidationError):
+        ScheduledDryingCreate(printer_id=1, temp=65, duration_hours=0)
+    with pytest.raises(ValidationError):
+        ScheduledDryingCreate(printer_id=1, temp=65, duration_hours=25)
+
+
+def test_over_long_filament_rejected():
+    """String(50) column: unbounded input 500s on PostgreSQL, passes on SQLite."""
+    with pytest.raises(ValidationError):
+        ScheduledDryingCreate(printer_id=1, temp=65, duration_hours=8, filament="X" * 51)
+    assert ScheduledDryingCreate(printer_id=1, temp=65, duration_hours=8, filament="X" * 50).filament == "X" * 50

+ 12 - 1
backend/tests/unit/test_scheduler_clear_plate.py

@@ -458,12 +458,23 @@ class TestSchedulerQueueCheckLogging:
         mock_result = MagicMock()
         mock_result.scalars.return_value.all.return_value = [mock_item]
 
+        empty_result = MagicMock()
+        empty_result.scalars.return_value.all.return_value = []
+
+        async def _execute_side_effect(stmt, *args, **kwargs):
+            # The scheduled-drying dispatch check (#2638) runs its own query
+            # every tick; keep it isolated from the pending-items mock above
+            # so it doesn't misread mock_item as a ScheduledDrying row.
+            if "scheduled_dryings" in str(stmt):
+                return empty_result
+            return mock_result
+
         with (
             patch("backend.app.services.print_scheduler.async_session") as mock_session_ctx,
             caplog.at_level(logging.INFO, logger="backend.app.services.print_scheduler"),
         ):
             mock_db = AsyncMock()
-            mock_db.execute = AsyncMock(return_value=mock_result)
+            mock_db.execute = AsyncMock(side_effect=_execute_side_effect)
             mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
             mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
 

+ 619 - 0
backend/tests/unit/test_scheduler_scheduled_drying.py

@@ -0,0 +1,619 @@
+"""Tests for PrintScheduler scheduled-drying dispatch (#2638)."""
+
+from datetime import datetime, timedelta, timezone
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.models.scheduled_drying import ScheduledDrying
+from backend.app.services.print_scheduler import (
+    SCHEDULED_DRYING_PRUNE_INTERVAL_SECONDS,
+    SCHEDULED_DRYING_RETENTION_DAYS,
+    PrintScheduler,
+)
+
+
+def _utcnow_naive() -> datetime:
+    return datetime.now(timezone.utc).replace(tzinfo=None)
+
+
+# Above the X1C drying minimum, so the shared preflight lets dispatch through.
+DRYING_CAPABLE_FIRMWARE = "01.09.00.00"
+
+
+def _mock_state(ams_id=0, dry_time=0, dry_sf_reason=None, firmware=DRYING_CAPABLE_FIRMWARE):
+    state = MagicMock()
+    state.firmware_version = firmware
+    state.raw_data = {"ams": [{"id": ams_id, "dry_time": dry_time, "dry_sf_reason": dry_sf_reason or []}]}
+    return state
+
+
+async def _make_row(db_session, printer_factory, **kwargs):
+    printer = await printer_factory()
+    defaults = {"printer_id": printer.id, "ams_id": 0, "temp": 65, "duration_hours": 8}
+    defaults.update(kwargs)
+    row = ScheduledDrying(**defaults)
+    db_session.add(row)
+    await db_session.commit()
+    await db_session.refresh(row)
+    return row
+
+
+@pytest.fixture
+def scheduler():
+    return PrintScheduler()
+
+
+@pytest.mark.asyncio
+async def test_future_start_after_not_dispatched(scheduler, db_session, printer_factory):
+    row = await _make_row(db_session, printer_factory, start_after=_utcnow_naive() + timedelta(hours=2))
+    with patch("backend.app.services.print_scheduler.printer_manager") as mock_pm:
+        await scheduler._check_scheduled_dryings(db_session)
+    mock_pm.send_drying_command.assert_not_called()
+    await db_session.refresh(row)
+    assert row.status == "pending"
+
+
+@pytest.mark.asyncio
+async def test_due_row_dispatches_and_goes_running(scheduler, db_session, printer_factory):
+    row = await _make_row(
+        db_session, printer_factory, start_after=_utcnow_naive() - timedelta(minutes=1), filament="PETG"
+    )
+    with (
+        patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
+        patch.object(scheduler, "_is_printer_idle", return_value=True),
+    ):
+        mock_pm.get_status.return_value = _mock_state()
+        mock_pm.send_drying_command.return_value = True
+        await scheduler._check_scheduled_dryings(db_session)
+
+    mock_pm.send_drying_command.assert_called_once_with(
+        row.printer_id, 0, 65, 8, mode=1, filament="PETG", rotate_tray=False
+    )
+    await db_session.refresh(row)
+    assert row.status == "running"
+    assert row.started_at is not None
+    assert scheduler._drying_in_progress.get(row.printer_id)
+
+
+@pytest.mark.asyncio
+async def test_null_start_after_dispatches_immediately(scheduler, db_session, printer_factory):
+    row = await _make_row(db_session, printer_factory, start_after=None)
+    with (
+        patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
+        patch.object(scheduler, "_is_printer_idle", return_value=True),
+    ):
+        mock_pm.get_status.return_value = _mock_state()
+        mock_pm.send_drying_command.return_value = True
+        await scheduler._check_scheduled_dryings(db_session)
+    await db_session.refresh(row)
+    assert row.status == "running"
+
+
+@pytest.mark.asyncio
+async def test_busy_printer_stays_pending_with_reason(scheduler, db_session, printer_factory):
+    row = await _make_row(db_session, printer_factory, start_after=None)
+    with (
+        patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
+        patch.object(scheduler, "_is_printer_idle", return_value=False),
+    ):
+        mock_pm.get_status.return_value = _mock_state()
+        await scheduler._check_scheduled_dryings(db_session)
+    mock_pm.send_drying_command.assert_not_called()
+    await db_session.refresh(row)
+    assert row.status == "pending"
+    assert row.waiting_reason == "printer_busy"
+
+
+@pytest.mark.asyncio
+async def test_offline_printer_stays_pending(scheduler, db_session, printer_factory):
+    row = await _make_row(db_session, printer_factory, start_after=None)
+    with patch("backend.app.services.print_scheduler.printer_manager") as mock_pm:
+        mock_pm.get_status.return_value = None
+        await scheduler._check_scheduled_dryings(db_session)
+    await db_session.refresh(row)
+    assert row.status == "pending"
+    assert row.waiting_reason == "printer_offline"
+
+
+@pytest.mark.asyncio
+async def test_ams_blocked_stays_pending(scheduler, db_session, printer_factory):
+    row = await _make_row(db_session, printer_factory, start_after=None)
+    with (
+        patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
+        patch.object(scheduler, "_is_printer_idle", return_value=True),
+    ):
+        mock_pm.get_status.return_value = _mock_state(dry_sf_reason=[2])
+        await scheduler._check_scheduled_dryings(db_session)
+    mock_pm.send_drying_command.assert_not_called()
+    await db_session.refresh(row)
+    assert row.status == "pending"
+    assert row.waiting_reason == "ams_blocked"
+
+
+@pytest.mark.asyncio
+async def test_retract_block_gets_its_own_waiting_reason(scheduler, db_session, printer_factory):
+    """Code 3 is user-actionable (retract the filament), so it says so rather
+    than bucketing into the generic blocked message.
+    """
+    row = await _make_row(db_session, printer_factory, start_after=None)
+    with (
+        patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
+        patch.object(scheduler, "_is_printer_idle", return_value=True),
+    ):
+        mock_pm.get_status.return_value = _mock_state(dry_sf_reason=[3])
+        await scheduler._check_scheduled_dryings(db_session)
+    mock_pm.send_drying_command.assert_not_called()
+    await db_session.refresh(row)
+    assert row.status == "pending"
+    assert row.waiting_reason == "ams_retract_filament"
+
+
+@pytest.mark.asyncio
+async def test_power_block_outranks_retract(scheduler, db_session, printer_factory):
+    """Both blocking at once: power is the one that has to be fixed first."""
+    row = await _make_row(db_session, printer_factory, start_after=None)
+    with (
+        patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
+        patch.object(scheduler, "_is_printer_idle", return_value=True),
+    ):
+        mock_pm.get_status.return_value = _mock_state(dry_sf_reason=[3, 8])
+        await scheduler._check_scheduled_dryings(db_session)
+    await db_session.refresh(row)
+    assert row.waiting_reason == "ams_power_required"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("code", [1, 8])
+async def test_power_block_gets_its_own_waiting_reason(scheduler, db_session, printer_factory, code):
+    """A run the user has to unblock says so, rather than waiting silently."""
+    row = await _make_row(db_session, printer_factory, start_after=None)
+    with (
+        patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
+        patch.object(scheduler, "_is_printer_idle", return_value=True),
+    ):
+        mock_pm.get_status.return_value = _mock_state(dry_sf_reason=[code])
+        await scheduler._check_scheduled_dryings(db_session)
+    mock_pm.send_drying_command.assert_not_called()
+    await db_session.refresh(row)
+    assert row.status == "pending"
+    assert row.waiting_reason == "ams_power_required"
+
+
+@pytest.mark.asyncio
+async def test_screen_only_model_fails_instead_of_dispatching(scheduler, db_session, printer_factory):
+    """A P1S acks the publish and ignores it; dispatching would silently self-cancel."""
+    printer = await printer_factory(model="P1S")
+    row = ScheduledDrying(printer_id=printer.id, ams_id=0, temp=65, duration_hours=8)
+    db_session.add(row)
+    await db_session.commit()
+
+    with (
+        patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
+        patch.object(scheduler, "_is_printer_idle", return_value=True),
+    ):
+        mock_pm.get_status.return_value = _mock_state()
+        await scheduler._check_scheduled_dryings(db_session)
+
+    mock_pm.send_drying_command.assert_not_called()
+    await db_session.refresh(row)
+    assert row.status == "failed"
+    assert row.error_message
+    assert row.completed_at is not None
+
+
+@pytest.mark.asyncio
+async def test_firmware_below_minimum_fails(scheduler, db_session, printer_factory):
+    row = await _make_row(db_session, printer_factory, start_after=None)
+    with (
+        patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
+        patch.object(scheduler, "_is_printer_idle", return_value=True),
+    ):
+        mock_pm.get_status.return_value = _mock_state(firmware="01.05.00.00")
+        await scheduler._check_scheduled_dryings(db_session)
+
+    mock_pm.send_drying_command.assert_not_called()
+    await db_session.refresh(row)
+    assert row.status == "failed"
+    assert row.error_message
+    assert row.completed_at is not None
+
+
+@pytest.mark.asyncio
+async def test_empty_filament_backfills_from_loaded_tray(scheduler, db_session, printer_factory):
+    """Matches the immediate endpoint, which sends the loaded type rather than PLA."""
+    row = await _make_row(db_session, printer_factory, start_after=None, filament="")
+    state = _mock_state()
+    state.raw_data["ams"][0]["tray"] = [{"tray_type": ""}, {"tray_type": "PETG"}]
+
+    with (
+        patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
+        patch.object(scheduler, "_is_printer_idle", return_value=True),
+    ):
+        mock_pm.get_status.return_value = state
+        mock_pm.send_drying_command.return_value = True
+        await scheduler._check_scheduled_dryings(db_session)
+
+    assert mock_pm.send_drying_command.call_args.kwargs["filament"] == "PETG"
+    await db_session.refresh(row)
+    assert row.filament == "PETG"
+
+
+@pytest.mark.asyncio
+async def test_empty_filament_falls_back_to_pla(scheduler, db_session, printer_factory):
+    await _make_row(db_session, printer_factory, start_after=None, filament="")
+    with (
+        patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
+        patch.object(scheduler, "_is_printer_idle", return_value=True),
+    ):
+        mock_pm.get_status.return_value = _mock_state()
+        mock_pm.send_drying_command.return_value = True
+        await scheduler._check_scheduled_dryings(db_session)
+
+    assert mock_pm.send_drying_command.call_args.kwargs["filament"] == "PLA"
+
+
+@pytest.mark.asyncio
+async def test_finished_rows_pruned_after_retention(scheduler, db_session, printer_factory):
+    printer = await printer_factory()
+    stale = ScheduledDrying(
+        printer_id=printer.id,
+        ams_id=0,
+        temp=65,
+        duration_hours=8,
+        status="completed",
+        completed_at=_utcnow_naive() - timedelta(days=SCHEDULED_DRYING_RETENTION_DAYS + 1),
+    )
+    recent = ScheduledDrying(
+        printer_id=printer.id,
+        ams_id=0,
+        temp=65,
+        duration_hours=8,
+        status="cancelled",
+        completed_at=_utcnow_naive() - timedelta(hours=1),
+    )
+    db_session.add_all([stale, recent])
+    await db_session.commit()
+
+    with patch("backend.app.services.print_scheduler.printer_manager"):
+        await scheduler._check_scheduled_dryings(db_session)
+
+    remaining = (await db_session.execute(select(ScheduledDrying.id))).scalars().all()
+    assert stale.id not in remaining
+    assert recent.id in remaining
+
+
+@pytest.mark.asyncio
+async def test_prune_does_not_run_on_every_pass(scheduler, db_session, printer_factory):
+    """The prune is throttled, because issuing the DELETE is what starts a
+    write transaction and this method runs every 3s while the queue dispatches.
+    Rows only become prunable a week after they finish, so nothing is lost by
+    waiting an hour to reap them."""
+    printer = await printer_factory()
+
+    async def _stale_row() -> ScheduledDrying:
+        row = ScheduledDrying(
+            printer_id=printer.id,
+            ams_id=0,
+            temp=65,
+            duration_hours=8,
+            status="completed",
+            completed_at=_utcnow_naive() - timedelta(days=SCHEDULED_DRYING_RETENTION_DAYS + 1),
+        )
+        db_session.add(row)
+        await db_session.commit()
+        await db_session.refresh(row)
+        return row
+
+    with patch("backend.app.services.print_scheduler.printer_manager"):
+        # First pass after a restart always prunes, so rows left behind by the
+        # process that died are still reaped.
+        first = await _stale_row()
+        await scheduler._check_scheduled_dryings(db_session)
+        assert first.id not in (await db_session.execute(select(ScheduledDrying.id))).scalars().all()
+
+        # A second pass moments later leaves an equally stale row alone.
+        second = await _stale_row()
+        await scheduler._check_scheduled_dryings(db_session)
+        assert second.id in (await db_session.execute(select(ScheduledDrying.id))).scalars().all()
+
+        # ...and reaps it once the interval has elapsed.
+        scheduler._last_scheduled_drying_prune -= SCHEDULED_DRYING_PRUNE_INTERVAL_SECONDS
+        await scheduler._check_scheduled_dryings(db_session)
+        assert second.id not in (await db_session.execute(select(ScheduledDrying.id))).scalars().all()
+
+
+@pytest.mark.asyncio
+async def test_a_finished_run_releases_the_printer_without_auto_drying(scheduler, db_session, printer_factory):
+    """_drying_in_progress must not outlive the run that set it.
+
+    Auto-drying's _sync_drying_state() prunes that map, but it sits behind the
+    enabled check, so on a default install (both auto-drying modes off) it never
+    runs. Nothing else drops the entry unless a print is dispatched to the same
+    printer, and a nightly off-peak dry with no printing in between is exactly
+    the case this feature is for: the second night's run would sit on
+    "already_drying" forever, and with queue_drying_block on the printer would
+    stop taking prints as well.
+    """
+    row = await _make_row(
+        db_session, printer_factory, start_after=_utcnow_naive() - timedelta(minutes=1), duration_hours=1
+    )
+    printer_id = row.printer_id
+
+    with (
+        patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
+        patch.object(scheduler, "_is_printer_idle", return_value=True),
+    ):
+        mock_pm.get_status.return_value = _mock_state()
+        mock_pm.send_drying_command.return_value = True
+        await scheduler._check_scheduled_dryings(db_session)
+    await db_session.refresh(row)
+    assert row.status == "running"
+    assert printer_id in scheduler._drying_in_progress
+
+    # The firmware has stopped reporting a dry_time, well past the duration.
+    row.started_at = _utcnow_naive() - timedelta(hours=2)
+    await db_session.commit()
+    with (
+        patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
+        patch.object(scheduler, "_is_printer_idle", return_value=True),
+    ):
+        mock_pm.get_status.return_value = _mock_state(dry_time=0)
+        await scheduler._check_scheduled_dryings(db_session)
+    await db_session.refresh(row)
+    assert row.status == "completed"
+    assert printer_id not in scheduler._drying_in_progress
+
+    # And the next night's run still dispatches.
+    tomorrow = ScheduledDrying(
+        printer_id=printer_id,
+        ams_id=0,
+        temp=65,
+        duration_hours=8,
+        start_after=_utcnow_naive() - timedelta(minutes=1),
+    )
+    db_session.add(tomorrow)
+    await db_session.commit()
+    with (
+        patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
+        patch.object(scheduler, "_is_printer_idle", return_value=True),
+    ):
+        mock_pm.get_status.return_value = _mock_state()
+        mock_pm.send_drying_command.return_value = True
+        await scheduler._check_scheduled_dryings(db_session)
+    await db_session.refresh(tomorrow)
+    assert tomorrow.status == "running"
+    assert tomorrow.waiting_reason is None
+
+
+@pytest.mark.asyncio
+async def test_a_route_cancel_releases_the_printer(scheduler, db_session, printer_factory):
+    """The DELETE route flips the row to cancelled in the database and sends the
+    stop, but knows nothing about the scheduler's in-memory map. The next pass
+    has to notice the run is gone and release the printer, or it stays marked as
+    drying until a restart."""
+    row = await _make_row(db_session, printer_factory, start_after=_utcnow_naive() - timedelta(minutes=1))
+    printer_id = row.printer_id
+
+    with (
+        patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
+        patch.object(scheduler, "_is_printer_idle", return_value=True),
+    ):
+        mock_pm.get_status.return_value = _mock_state()
+        mock_pm.send_drying_command.return_value = True
+        await scheduler._check_scheduled_dryings(db_session)
+    assert printer_id in scheduler._drying_in_progress
+
+    # What DELETE /scheduled-dryings/{id} leaves behind.
+    row.status = "cancelled"
+    row.completed_at = _utcnow_naive()
+    await db_session.commit()
+
+    with patch("backend.app.services.print_scheduler.printer_manager") as mock_pm:
+        mock_pm.get_status.return_value = _mock_state()
+        await scheduler._check_scheduled_dryings(db_session)
+
+    assert printer_id not in scheduler._drying_in_progress
+    assert printer_id not in scheduler._scheduled_drying_printer_ids
+
+
+@pytest.mark.asyncio
+async def test_running_completes_after_duration(scheduler, db_session, printer_factory):
+    row = await _make_row(
+        db_session,
+        printer_factory,
+        status="running",
+        duration_hours=1,
+        started_at=_utcnow_naive() - timedelta(minutes=58),  # >= 90% of 1h
+    )
+    with patch("backend.app.services.print_scheduler.printer_manager") as mock_pm:
+        mock_pm.get_status.return_value = _mock_state(dry_time=0)
+        await scheduler._check_scheduled_dryings(db_session)
+    await db_session.refresh(row)
+    assert row.status == "completed"
+    assert row.completed_at is not None
+
+
+@pytest.mark.asyncio
+async def test_running_interrupted_by_print_requeues(scheduler, db_session, printer_factory):
+    row = await _make_row(
+        db_session,
+        printer_factory,
+        status="running",
+        duration_hours=8,
+        started_at=_utcnow_naive() - timedelta(minutes=30),  # well past grace, far from done
+    )
+    with (
+        patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
+        patch.object(scheduler, "_is_printer_idle", return_value=False),
+    ):
+        mock_pm.get_status.return_value = _mock_state(dry_time=0)
+        await scheduler._check_scheduled_dryings(db_session)
+    await db_session.refresh(row)
+    assert row.status == "pending"
+    assert row.started_at is None
+    assert row.waiting_reason == "interrupted"
+
+
+@pytest.mark.asyncio
+async def test_running_stopped_while_idle_cancels(scheduler, db_session, printer_factory):
+    """A stop on an idle printer is deliberate; the row must not resurrect."""
+    row = await _make_row(
+        db_session,
+        printer_factory,
+        status="running",
+        duration_hours=8,
+        started_at=_utcnow_naive() - timedelta(minutes=30),
+    )
+    with (
+        patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
+        patch.object(scheduler, "_is_printer_idle", return_value=True),
+    ):
+        mock_pm.get_status.return_value = _mock_state(dry_time=0)
+        await scheduler._check_scheduled_dryings(db_session)
+    await db_session.refresh(row)
+    assert row.status == "cancelled"
+    assert row.completed_at is not None
+
+
+@pytest.mark.asyncio
+async def test_running_within_grace_untouched(scheduler, db_session, printer_factory):
+    row = await _make_row(
+        db_session,
+        printer_factory,
+        status="running",
+        duration_hours=8,
+        started_at=_utcnow_naive() - timedelta(seconds=30),  # inside 120 s grace
+    )
+    with patch("backend.app.services.print_scheduler.printer_manager") as mock_pm:
+        mock_pm.get_status.return_value = _mock_state(dry_time=0)
+        await scheduler._check_scheduled_dryings(db_session)
+    await db_session.refresh(row)
+    assert row.status == "running"
+
+
+@pytest.mark.asyncio
+async def test_scheduled_drying_survives_auto_drying_stop_all(scheduler, db_session, printer_factory):
+    """Regression (#2638): a running scheduled drying must not be stopped or
+    untracked by _check_auto_drying's stop-all branch, even in the default
+    config where both auto-drying toggles are off. Before the fix, the two
+    features co-owned _drying_in_progress and auto-drying would stop/pop any
+    printer it didn't start drying on itself.
+    """
+    row = await _make_row(db_session, printer_factory, start_after=_utcnow_naive() - timedelta(minutes=1))
+    with (
+        patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
+        patch.object(scheduler, "_is_printer_idle", return_value=True),
+    ):
+        mock_pm.get_status.return_value = _mock_state()
+        mock_pm.send_drying_command.return_value = True
+        await scheduler._check_scheduled_dryings(db_session)
+
+        await db_session.refresh(row)
+        assert row.status == "running"
+        assert scheduler._drying_in_progress.get(row.printer_id)
+        assert row.printer_id in scheduler._scheduled_drying_printer_ids
+
+        mock_pm.reset_mock()
+        with patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=False)):
+            # Default config: queue_drying_enabled and ambient_drying_enabled both off.
+            await scheduler._check_auto_drying(db_session, [], set())
+
+    mock_pm.send_drying_command.assert_not_called()
+    await db_session.refresh(row)
+    assert row.status == "running"
+    assert scheduler._drying_in_progress.get(row.printer_id)
+    assert row.printer_id in scheduler._scheduled_drying_printer_ids
+
+
+@pytest.mark.asyncio
+async def test_second_pending_row_for_same_printer_does_not_dispatch(scheduler, db_session, printer_factory):
+    """Regression (#2638): two pending rows for the same printer must not both
+    dispatch in the same tick; the second should see the first's dispatch and
+    stay pending.
+    """
+    printer = await printer_factory()
+    past = _utcnow_naive() - timedelta(minutes=1)
+    row1 = ScheduledDrying(printer_id=printer.id, ams_id=0, temp=65, duration_hours=8, start_after=past)
+    row2 = ScheduledDrying(printer_id=printer.id, ams_id=0, temp=60, duration_hours=6, start_after=past)
+    db_session.add_all([row1, row2])
+    await db_session.commit()
+    await db_session.refresh(row1)
+    await db_session.refresh(row2)
+
+    with (
+        patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
+        patch.object(scheduler, "_is_printer_idle", return_value=True),
+    ):
+        mock_pm.get_status.return_value = _mock_state()
+        mock_pm.send_drying_command.return_value = True
+        await scheduler._check_scheduled_dryings(db_session)
+
+    mock_pm.send_drying_command.assert_called_once()
+    await db_session.refresh(row1)
+    await db_session.refresh(row2)
+    # Ordered dispatch: same start_after, so the row created first wins.
+    assert row1.status == "running"
+    assert row2.status == "pending"
+    assert row2.waiting_reason == "already_drying"
+
+
+@pytest.mark.asyncio
+async def test_earliest_start_after_dispatches_first(scheduler, db_session, printer_factory):
+    """Two rows due on one printer: the earlier schedule starts, not an arbitrary one."""
+    printer = await printer_factory()
+    now = _utcnow_naive()
+    later = ScheduledDrying(
+        printer_id=printer.id, ams_id=0, temp=65, duration_hours=8, start_after=now - timedelta(minutes=1)
+    )
+    sooner = ScheduledDrying(
+        printer_id=printer.id, ams_id=0, temp=60, duration_hours=6, start_after=now - timedelta(hours=3)
+    )
+    # Inserted later-first so row order alone cannot produce the right answer.
+    db_session.add_all([later, sooner])
+    await db_session.commit()
+    await db_session.refresh(later)
+    await db_session.refresh(sooner)
+
+    with (
+        patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
+        patch.object(scheduler, "_is_printer_idle", return_value=True),
+    ):
+        mock_pm.get_status.return_value = _mock_state()
+        mock_pm.send_drying_command.return_value = True
+        await scheduler._check_scheduled_dryings(db_session)
+
+    mock_pm.send_drying_command.assert_called_once_with(printer.id, 0, 60, 6, mode=1, filament="PLA", rotate_tray=False)
+    await db_session.refresh(later)
+    await db_session.refresh(sooner)
+    assert sooner.status == "running"
+    assert later.status == "pending"
+
+
+@pytest.mark.asyncio
+async def test_malformed_ams_id_does_not_throw_while_running(scheduler, db_session, printer_factory):
+    """_update_running_scheduled_drying runs inside check_queue: a throw here
+    would cost the whole pass, print dispatch included, on every tick.
+    """
+    row = await _make_row(
+        db_session,
+        printer_factory,
+        status="running",
+        started_at=_utcnow_naive() - timedelta(minutes=30),
+    )
+    state = MagicMock()
+    state.firmware_version = DRYING_CAPABLE_FIRMWARE
+    state.raw_data = {"ams": [{"id": "not-a-number", "dry_time": 120}]}
+
+    with (
+        patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
+        patch.object(scheduler, "_is_printer_idle", return_value=False),
+    ):
+        mock_pm.get_status.return_value = state
+        await scheduler._check_scheduled_dryings(db_session)
+
+    # No matching unit means no dry_time; the printer is busy, so it re-queues.
+    await db_session.refresh(row)
+    assert row.status == "pending"
+    assert row.waiting_reason == "interrupted"

+ 186 - 0
backend/tests/unit/test_scheduler_scheduled_drying_check_queue.py

@@ -0,0 +1,186 @@
+"""Scheduled drying through the real check_queue (#2638).
+
+The dispatch logic is unit-tested by calling ``_check_scheduled_dryings``
+directly. This drives the whole queue pass instead, because that method is now
+called on every tick of the scheduler's hot path: what matters to an install
+that never schedules a dry is that the pass still completes and still dispatches
+prints, and what matters to one that does is that the two do not interfere.
+"""
+
+from contextlib import ExitStack
+from datetime import datetime, timedelta, timezone
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+import backend.app.models  # noqa: F401 - populate Base.metadata
+from backend.app.core.database import Base
+from backend.app.models.library import LibraryFile
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.models.scheduled_drying import ScheduledDrying
+from backend.app.services.print_scheduler import PrintScheduler
+
+pytestmark = pytest.mark.unit
+
+
+def _utcnow_naive() -> datetime:
+    return datetime.now(timezone.utc).replace(tzinfo=None)
+
+
+def _state(dry_time=0):
+    state = MagicMock()
+    state.firmware_version = "01.09.00.00"
+    state.raw_data = {"ams": [{"id": 0, "dry_time": dry_time, "dry_sf_reason": []}]}
+    return state
+
+
+@pytest.fixture
+async def queue_db():
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    session_maker = async_sessionmaker(engine, expire_on_commit=False)
+
+    async with session_maker() as db:
+        db.add(
+            Printer(
+                id=1,
+                name="P2S-1",
+                serial_number="P2S0001",
+                ip_address="10.0.0.1",
+                access_code="x",
+                model="P2S",
+                is_active=True,
+            )
+        )
+        await db.commit()
+
+    try:
+        yield SimpleNamespace(session_maker=session_maker)
+    finally:
+        await engine.dispose()
+
+
+async def _add_print_item(ctx):
+    async with ctx.session_maker() as db:
+        lib = LibraryFile(
+            filename="job.gcode.3mf",
+            file_path="/library/job.gcode.3mf",
+            file_size=10,
+            file_type="gcode.3mf",
+            file_metadata={"sliced_for_model": "P2S"},
+        )
+        db.add(lib)
+        await db.flush()
+        db.add(PrintQueueItem(status="pending", position=1, printer_id=1, library_file_id=lib.id))
+        await db.commit()
+
+
+async def _add_drying_row(ctx, **kwargs):
+    async with ctx.session_maker() as db:
+        defaults = {
+            "printer_id": 1,
+            "ams_id": 0,
+            "temp": 65,
+            "duration_hours": 8,
+            "start_after": _utcnow_naive() - timedelta(minutes=1),
+        }
+        defaults.update(kwargs)
+        row = ScheduledDrying(**defaults)
+        db.add(row)
+        await db.commit()
+        await db.refresh(row)
+        return row
+
+
+async def _run(ctx, scheduler, *, state, launched=None):
+    """One real check_queue pass with only the print-side collaborators mocked."""
+    patches = [
+        patch("backend.app.services.print_scheduler.async_session", ctx.session_maker),
+        patch("backend.app.core.database.async_session", ctx.session_maker),
+        patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
+        patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=state)),
+        patch(
+            "backend.app.services.print_scheduler.printer_manager.send_drying_command",
+            MagicMock(return_value=True),
+        ),
+        patch(
+            "backend.app.services.print_scheduler.ha_sensor_manager.blocked_printers",
+            AsyncMock(return_value={}),
+        ),
+        patch.object(scheduler, "_is_printer_idle", MagicMock(return_value=True)),
+        patch.object(scheduler, "_ensure_ams_mapping", AsyncMock(return_value=None)),
+        patch.object(scheduler, "_block_on_filament_deficit", AsyncMock(return_value=False)),
+        patch.object(scheduler, "_launch_uploads", launched or MagicMock()),
+    ]
+    with ExitStack() as stack:
+        for p in patches:
+            stack.enter_context(p)
+        return await scheduler.check_queue()
+
+
+@pytest.mark.asyncio
+async def test_a_pass_with_no_scheduled_rows_still_dispatches_prints(queue_db):
+    """The case every existing install is in: the feature is present and unused."""
+    await _add_print_item(queue_db)
+    scheduler = PrintScheduler()
+    launched = MagicMock()
+
+    await _run(queue_db, scheduler, state=_state(), launched=launched)
+
+    launched.assert_called_once()
+    assert launched.call_args[0][0]  # at least one dispatch id
+
+
+@pytest.mark.asyncio
+async def test_a_due_row_dispatches_through_the_real_queue_pass(queue_db):
+    row = await _add_drying_row(queue_db)
+    scheduler = PrintScheduler()
+
+    await _run(queue_db, scheduler, state=_state())
+
+    async with queue_db.session_maker() as db:
+        stored = (await db.execute(select(ScheduledDrying).where(ScheduledDrying.id == row.id))).scalar_one()
+        assert stored.status == "running"
+    assert 1 in scheduler._drying_in_progress
+
+
+@pytest.mark.asyncio
+async def test_a_scheduled_row_does_not_stop_the_queue(queue_db):
+    """A drying row and a print item in the same pass: the print still goes."""
+    await _add_drying_row(queue_db)
+    await _add_print_item(queue_db)
+    scheduler = PrintScheduler()
+    launched = MagicMock()
+
+    await _run(queue_db, scheduler, state=_state(), launched=launched)
+
+    launched.assert_called_once()
+    assert launched.call_args[0][0]
+
+
+@pytest.mark.asyncio
+async def test_a_failed_row_does_not_stop_the_queue(queue_db):
+    """An unsupported model fails the row at dispatch. That is the one path that
+    writes an error mid-pass, so the print behind it must still dispatch."""
+    async with queue_db.session_maker() as db:
+        printer = (await db.execute(select(Printer).where(Printer.id == 1))).scalar_one()
+        printer.model = "P1S"  # drying is screen-only here
+        await db.commit()
+    await _add_drying_row(queue_db)
+    await _add_print_item(queue_db)
+    scheduler = PrintScheduler()
+    launched = MagicMock()
+
+    await _run(queue_db, scheduler, state=_state(), launched=launched)
+
+    async with queue_db.session_maker() as db:
+        stored = (await db.execute(select(ScheduledDrying))).scalars().one()
+        assert stored.status == "failed"
+        assert stored.error_message
+    launched.assert_called_once()
+    assert launched.call_args[0][0]

+ 158 - 5
frontend/src/__tests__/components/BugReportBubble.test.tsx

@@ -2,7 +2,7 @@
  * Tests for the BugReportBubble component.
  */
 
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, afterEach, vi } from 'vitest';
 import { render, screen, waitFor } from '../utils';
 import userEvent from '@testing-library/user-event';
 import { http, HttpResponse } from 'msw';
@@ -135,8 +135,7 @@ describe('BugReportBubble', () => {
 
     // Should show step indicators and elapsed timer
     await waitFor(() => {
-      const reproduceText = screen.queryByText(/reproduce|Reproduce|reproduzieren|reproduire|riproduci|再現|reproduza|重现/i);
-      expect(reproduceText).toBeInTheDocument();
+      expect(screen.queryByTestId('bug-report-step-reproduce')).toBeInTheDocument();
     });
 
     // Should show elapsed timer (00:00 format)
@@ -171,7 +170,7 @@ describe('BugReportBubble', () => {
 
     // Wait for logging state, then click stop
     await waitFor(() => {
-      expect(screen.queryByText(/reproduce|Reproduce|reproduzieren|reproduire|riproduci|再現|reproduza|重现/i)).toBeInTheDocument();
+      expect(screen.queryByTestId('bug-report-step-reproduce')).toBeInTheDocument();
     });
 
     // Find and click the Stop & Submit button
@@ -213,7 +212,7 @@ describe('BugReportBubble', () => {
 
     // Wait for logging state, then click stop
     await waitFor(() => {
-      expect(screen.queryByText(/reproduce|Reproduce|reproduzieren|reproduire|riproduci|再現|reproduza|重现/i)).toBeInTheDocument();
+      expect(screen.queryByTestId('bug-report-step-reproduce')).toBeInTheDocument();
     });
 
     const stopBtn = screen.getAllByRole('button').find(
@@ -314,4 +313,158 @@ describe('BugReportBubble', () => {
     expect(await screen.findByText('Known issues found in your logs')).toBeInTheDocument();
     expect(screen.getByText('Printer rejected the access code')).toBeInTheDocument();
   });
+
+  // Step 2 asks the user to reproduce the problem, and the panel sits over the
+  // part of the app they have to reach to do it. Closing it used to be the only
+  // way through and it threw the run away: the reset-on-open effect put the
+  // panel back on step 1 while the server stayed at DEBUG, with nothing left
+  // that could stop it (#2847).
+  describe('a logging run that outlives the panel (#2847)', () => {
+    afterEach(() => {
+      vi.mocked(localStorage.getItem).mockReset();
+    });
+
+    /** The mock is shared with every other localStorage reader in the tree --
+     *  the theme context among them -- so only answer for our own key. */
+    const storeSession = (session: Record<string, unknown>) => {
+      vi.mocked(localStorage.getItem).mockImplementation((key: string) =>
+        key === 'bambuddy-bug-report-session' ? JSON.stringify(session) : null
+      );
+    };
+
+    /** Types a description and presses Start, landing on step 2. */
+    const startRun = async (user: ReturnType<typeof userEvent.setup>, description: string) => {
+      await user.click(screen.getByRole('button'));
+      await user.type(getDescriptionTextarea(), description);
+      const startBtn = getSubmitButton();
+      if (startBtn) await user.click(startBtn);
+      await waitFor(() => {
+        expect(screen.queryByTestId('bug-report-step-reproduce')).toBeInTheDocument();
+      });
+    };
+
+    const closePanel = async (user: ReturnType<typeof userEvent.setup>) => {
+      const closeButton = screen.getAllByRole('button').find((b) => b.querySelector('.lucide-x'));
+      if (closeButton) await user.click(closeButton);
+      await waitFor(() => {
+        expect(screen.queryByTestId('bug-report-step-reproduce')).not.toBeInTheDocument();
+      });
+    };
+
+    it('survives a close and reopens on the step it left, description intact', async () => {
+      const user = userEvent.setup();
+      let stopCalls = 0;
+      let submitted: { description?: string } | null = null;
+      server.use(
+        http.post('*/bug-report/start-logging', () => HttpResponse.json({ started: true, was_debug: false })),
+        http.post('*/bug-report/stop-logging', () => {
+          stopCalls += 1;
+          return HttpResponse.json({ logs: 'captured' });
+        }),
+        http.post('*/bug-report/submit', async ({ request }) => {
+          submitted = (await request.json()) as { description?: string };
+          return HttpResponse.json({ success: true, message: 'ok', issue_number: 7 });
+        }),
+      );
+
+      render(<BugReportBubble />);
+      await startRun(user, 'Queue page freezes');
+      await closePanel(user);
+
+      // Closing is not cancelling: the log level only comes back down when the
+      // user presses Stop & Submit.
+      expect(stopCalls).toBe(0);
+
+      // The disc carries the run's colour, so a closed panel still says a
+      // recording is live and that clicking gets back to it.
+      const disc = screen.getByRole('button');
+      expect(disc.className).toContain('bg-amber-500');
+
+      await user.click(disc);
+      expect(screen.getByTestId('bug-report-step-reproduce')).toBeInTheDocument();
+
+      const stopBtn = screen.getAllByRole('button').find(
+        (b) => b.className.includes('bg-red-500') && !b.className.includes('rounded-full')
+      );
+      if (stopBtn) await user.click(stopBtn);
+
+      await waitFor(() => expect(submitted).not.toBeNull());
+      expect(submitted!.description).toBe('Queue page freezes');
+      expect(stopCalls).toBe(1);
+    });
+
+    it('picks the run back up after a reload while the server is still logging', async () => {
+      const user = userEvent.setup();
+      const startedAt = Date.now() - 30_000;
+      storeSession({ description: 'Printer card goes blank', email: '', wasDebug: false, startedAt });
+      server.use(
+        http.get('*/support/debug-logging', () =>
+          HttpResponse.json({
+            enabled: true,
+            enabled_at: new Date(startedAt).toISOString(),
+            duration_seconds: 30,
+          })
+        ),
+      );
+
+      render(<BugReportBubble />);
+
+      await waitFor(() => {
+        expect(screen.getByRole('button').className).toContain('bg-amber-500');
+      });
+      await user.click(screen.getByRole('button'));
+      expect(screen.getByTestId('bug-report-step-reproduce')).toBeInTheDocument();
+      // Elapsed comes off the run's start time, so the reload does not reset it.
+      expect(screen.getByText('00:30')).toBeInTheDocument();
+    });
+
+    it('drops a stored run the server already stopped', async () => {
+      storeSession({ description: 'stale', email: '', wasDebug: false, startedAt: Date.now() - 30_000 });
+      let stopCalls = 0;
+      server.use(
+        http.get('*/support/debug-logging', () =>
+          HttpResponse.json({ enabled: false, enabled_at: null, duration_seconds: null })
+        ),
+        http.post('*/bug-report/stop-logging', () => {
+          stopCalls += 1;
+          return HttpResponse.json({ logs: '' });
+        }),
+      );
+
+      render(<BugReportBubble />);
+
+      await waitFor(() => expect(localStorage.removeItem).toHaveBeenCalled());
+      expect(screen.getByRole('button').className).toContain('bg-red-500');
+      // Logging is already off; there is nothing to put back.
+      expect(stopCalls).toBe(0);
+    });
+
+    it('restores the log level for a run that outlived the cap, without filing it', async () => {
+      let stopCalls = 0;
+      let submitCalls = 0;
+      storeSession({ description: 'from an hour ago', email: '', wasDebug: false, startedAt: Date.now() - 3_600_000 });
+      server.use(
+        http.get('*/support/debug-logging', () =>
+          HttpResponse.json({ enabled: true, enabled_at: new Date(Date.now() - 3_600_000).toISOString(), duration_seconds: 3600 })
+        ),
+        http.post('*/bug-report/stop-logging', () => {
+          stopCalls += 1;
+          return HttpResponse.json({ logs: '' });
+        }),
+        http.post('*/bug-report/submit', () => {
+          submitCalls += 1;
+          return HttpResponse.json({ success: true, message: 'ok' });
+        }),
+      );
+
+      render(<BugReportBubble />);
+
+      // The level comes back down, because nothing else was going to do it...
+      await waitFor(() => expect(stopCalls).toBe(1));
+      // ...but an hour-old description is not a report anyone is still waiting
+      // to be filed, and nobody is here to see it happen.
+      expect(submitCalls).toBe(0);
+      expect(screen.getByRole('button').className).toContain('bg-red-500');
+    });
+  });
 });

+ 21 - 0
frontend/src/__tests__/lib/scheduledDrying.test.ts

@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest';
+import { computeStartAfter } from '../../lib/scheduledDrying';
+
+describe('computeStartAfter', () => {
+  const now = new Date('2026-07-23T10:00:00.000Z');
+
+  it('returns null for immediate start', () => {
+    expect(computeStartAfter('now', 120, '', now)).toBeNull();
+  });
+
+  it('adds the delay in minutes', () => {
+    expect(computeStartAfter('delay', 120, '', now)).toBe('2026-07-23T12:00:00.000Z');
+  });
+
+  it('converts a datetime-local value (local tz) to UTC ISO', () => {
+    const result = computeStartAfter('at_time', 0, '2026-07-23T18:30', now);
+    // new Date('YYYY-MM-DDTHH:MM') parses in the local timezone; the ISO
+    // output must be that instant in UTC.
+    expect(result).toBe(new Date('2026-07-23T18:30').toISOString());
+  });
+});

+ 25 - 0
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -1230,6 +1230,31 @@ describe('FileManagerPage', () => {
       expect(openInSlicer).not.toHaveBeenCalled();
     });
 
+    // #2846: the menu used to be an absolutely-positioned child of the card,
+    // and the card clipped its own overflow. A bare STL card is only about
+    // 270px tall -- thumbnail plus name and size -- which is shorter than the
+    // seven-entry menu, so the top entry was cut off. That entry is Slice,
+    // because Print is suppressed for an unsliced file. A 3MF card carries two
+    // more metadata rows and was tall enough, which is why the report said 3MF
+    // worked. Nothing about STL was special; the card was just the shortest.
+    it('keeps the first menu entry out of the card so it cannot be clipped (#2846)', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json({ use_slicer_api: true })),
+      );
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const card = await openMenu(user, 'bracket.stl');
+      const menu = within(card).getByText('Slice').closest('.fixed');
+      // Viewport-positioned, so no ancestor's overflow can cut it down.
+      expect(menu).not.toBeNull();
+      expect(within(menu as HTMLElement).getAllByRole('button')[0]).toHaveTextContent('Slice');
+      // And the card itself no longer clips what its children draw.
+      expect(card.className).not.toContain('overflow-hidden');
+    });
+
     it('hides the slice item for already-sliced files', async () => {
       const user = userEvent.setup();
       render(<FileManagerPage />);

+ 361 - 0
frontend/src/__tests__/pages/PrintersPageDryingStartModes.test.tsx

@@ -0,0 +1,361 @@
+/**
+ * UX for the scheduled-drying start modes.
+ *
+ * "After delay" and "At time" reveal an extra control at the bottom of the
+ * drying popover. These used to render below the fold of a height-capped,
+ * scrollable body with no affordance; the delay options are now inline
+ * chips and an above-placed popover grows upward from its anchored bottom
+ * edge. The click that dismisses the native date picker must not tear down
+ * the popover.
+ */
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, fireEvent, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { PrintersPage } from '../../pages/PrintersPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockPrinter = {
+  id: 1,
+  name: 'X1C',
+  ip_address: '192.168.1.100',
+  serial_number: '01P00A000000001',
+  access_code: '12345678',
+  model: 'X1C',
+  enabled: true,
+  nozzle_diameter: 0.4,
+  nozzle_type: 'stainless_steel',
+  location: 'Workshop',
+  auto_archive: true,
+  created_at: '2024-01-01T00:00:00Z',
+  updated_at: '2024-01-01T00:00:00Z',
+};
+
+const baseTray = {
+  tray_color: 'FF0000FF',
+  tray_type: 'PLA',
+  tray_sub_brands: 'PLA Basic',
+  tray_id_name: 'A00-R0',
+  tray_info_idx: 'GFA00',
+  remain: 80,
+  k: 0.02,
+  cali_idx: null,
+  tag_uid: null,
+  tray_uuid: null,
+  nozzle_temp_min: 190,
+  nozzle_temp_max: 230,
+  drying_temp: null,
+  drying_time: null,
+  state: 3,
+};
+
+/** AMS 2 Pro (n3f) on an idle printer that accepts remote drying commands. */
+const IDLE = {
+  connected: true,
+  state: 'IDLE',
+  progress: 0,
+  layer_num: 0,
+  total_layers: 0,
+  temperatures: { nozzle: 25, bed: 25, chamber: 25 },
+  remaining_time: 0,
+  filename: null,
+  wifi_signal: -29,
+  speed_level: 2,
+  supports_drying: true,
+  drying_screen_only: false,
+  vt_tray: [],
+  ams: [
+    {
+      id: 0,
+      humidity: 30,
+      temp: 33,
+      is_ams_ht: false,
+      serial_number: 'AMS00',
+      sw_ver: '03.00.21.29',
+      dry_sub_status: 0,
+      dry_sf_reason: [],
+      module_type: 'n3f',
+      dry_time: 0,
+      dry_status: 0,
+      tray: [
+        { id: 0, ...baseTray },
+        { id: 1, ...baseTray },
+        { id: 2, ...baseTray },
+        { id: 3, ...baseTray },
+      ],
+    },
+  ],
+};
+
+/** Same AMS mid-cycle: 12h remaining on the dryer. */
+const DRYING = {
+  ...IDLE,
+  ams: [{ ...IDLE.ams[0], dry_time: 720, dry_status: 2 }],
+};
+
+const PENDING_ROW = {
+  id: 1,
+  printer_id: 1,
+  ams_id: 0,
+  temp: 45,
+  duration_hours: 12,
+  filament: 'PLA',
+  rotate_tray: false,
+  start_after: '2026-07-25T23:24:00',
+  status: 'pending',
+  waiting_reason: null,
+  error_message: null,
+  created_at: '2026-07-25T20:00:00',
+  started_at: null,
+  completed_at: null,
+};
+
+async function openDryingPopover(user: ReturnType<typeof userEvent.setup>) {
+  await waitFor(() => {
+    expect(screen.getAllByTitle('Start Drying').length).toBeGreaterThan(0);
+  });
+  await user.click(screen.getAllByTitle('Start Drying')[0]);
+  await screen.findByTestId('drying-start-confirm');
+}
+
+describe('PrintersPage - drying start modes', () => {
+  beforeEach(() => {
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json(IDLE)),
+      http.get('/api/v1/queue/', () => HttpResponse.json([])),
+      http.get('/api/v1/scheduled-dryings', () => HttpResponse.json([])),
+    );
+  });
+
+  it('reveals the delay chips when After delay is selected, with 2h preselected', async () => {
+    const user = userEvent.setup();
+    render(<PrintersPage />);
+    await openDryingPopover(user);
+
+    await user.click(screen.getByRole('button', { name: 'After delay' }));
+
+    for (const label of ['30m', '1h', '2h', '4h', '8h', '12h', '24h']) {
+      expect(screen.getByRole('button', { name: label })).toBeInTheDocument();
+    }
+    expect(screen.getByRole('button', { name: '2h' })).toHaveAttribute('aria-pressed', 'true');
+
+    await user.click(screen.getByRole('button', { name: '4h' }));
+    expect(screen.getByRole('button', { name: '4h' })).toHaveAttribute('aria-pressed', 'true');
+    expect(screen.getByRole('button', { name: '2h' })).toHaveAttribute('aria-pressed', 'false');
+  });
+
+  it('reveals the datetime input for At time and keeps Schedule disabled until a time is set', async () => {
+    const user = userEvent.setup();
+    render(<PrintersPage />);
+    await openDryingPopover(user);
+
+    await user.click(screen.getByRole('button', { name: 'At time' }));
+
+    const input = await screen.findByTestId('drying-start-at');
+    expect(screen.getByTestId('drying-start-confirm')).toBeDisabled();
+
+    fireEvent.change(input, { target: { value: '2099-01-15T18:00' } });
+    expect(screen.getByTestId('drying-start-confirm')).toBeEnabled();
+  });
+
+  it('drops the scheduled banner promptly once a drying cycle starts', async () => {
+    // The banner polls every 30s; without a nudge from the live AMS status
+    // it shows a dispatched schedule as still pending for up to that long.
+    let calls = 0;
+    server.use(
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json(DRYING)),
+      http.get('/api/v1/scheduled-dryings', () => {
+        calls += 1;
+        return HttpResponse.json(calls === 1 ? [PENDING_ROW] : []);
+      }),
+    );
+    render(<PrintersPage />);
+
+    await waitFor(() => expect(calls).toBeGreaterThanOrEqual(2));
+    await waitFor(() => {
+      expect(screen.queryByText(/Drying scheduled for/)).not.toBeInTheDocument();
+    });
+  });
+
+  it('labels a transient cannot-dry reason accurately, not as a power problem', async () => {
+    const blocked = { ...IDLE, ams: [{ ...IDLE.ams[0], dry_sf_reason: [2] }] };
+    server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(blocked)));
+    render(<PrintersPage />);
+    await waitFor(() => {
+      expect(screen.getAllByTitle("AMS can't start drying right now").length).toBeGreaterThan(0);
+    });
+  });
+
+  it('keeps the power tooltip for the power-supply reason codes', async () => {
+    const blocked = { ...IDLE, ams: [{ ...IDLE.ams[0], dry_sf_reason: [8] }] };
+    server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(blocked)));
+    render(<PrintersPage />);
+    await waitFor(() => {
+      expect(screen.getAllByTitle('Connect AMS power adapter to enable drying').length).toBeGreaterThan(0);
+    });
+  });
+
+  it('resets the start mode when the popover is reopened', async () => {
+    // Leaving the mode set carried a stale "At time" timestamp into the next
+    // open, by then in the past, and the POST rejected it.
+    const user = userEvent.setup();
+    render(<PrintersPage />);
+    await openDryingPopover(user);
+
+    await user.click(screen.getByRole('button', { name: 'At time' }));
+    const input = await screen.findByTestId('drying-start-at');
+    fireEvent.change(input, { target: { value: '2026-07-25T23:24' } });
+    expect(screen.getByTestId('drying-start-at')).toHaveValue('2026-07-25T23:24');
+
+    // Close, then reopen on the same flame button.
+    await user.click(screen.getByTestId('drying-popover-backdrop'));
+    await waitFor(() => {
+      expect(screen.queryByTestId('drying-start-confirm')).not.toBeInTheDocument();
+    });
+    await openDryingPopover(user);
+
+    // Back on "Now": neither the delay chips nor the datetime input show.
+    expect(screen.queryByTestId('drying-start-at')).not.toBeInTheDocument();
+    await user.click(screen.getByRole('button', { name: 'At time' }));
+    expect(screen.getByTestId('drying-start-at')).toHaveValue('');
+  });
+
+  it('fetches the scheduled list once for the fleet rather than per printer', async () => {
+    const requests: string[] = [];
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter, { ...mockPrinter, id: 2, name: 'P1S-2' }])),
+      http.get('/api/v1/scheduled-dryings', ({ request }) => {
+        requests.push(new URL(request.url).search);
+        return HttpResponse.json([]);
+      }),
+    );
+    render(<PrintersPage />);
+    await waitFor(() => expect(requests.length).toBeGreaterThan(0));
+    // No printer_id filter: one shared query, filtered client-side.
+    expect(requests.every(search => search === '')).toBe(true);
+  });
+
+  it('says why a due run has not started when the AMS needs the power adapter', async () => {
+    server.use(
+      http.get('/api/v1/scheduled-dryings', () =>
+        HttpResponse.json([{ ...PENDING_ROW, start_after: null, waiting_reason: 'ams_power_required' }])
+      ),
+    );
+    render(<PrintersPage />);
+    expect(await screen.findByText('Connect AMS power adapter to enable drying')).toBeInTheDocument();
+  });
+
+  it('shows a run that failed at dispatch, with a dismiss that clears it', async () => {
+    // Only dispatch can fail (firmware too old on a printer that was offline
+    // at schedule time). Without this the run vanishes and only the backend
+    // log says why.
+    const failedRow = {
+      ...PENDING_ROW,
+      status: 'failed',
+      error_message: 'Drying not supported for this printer model or firmware version',
+      completed_at: '2026-07-25T23:25:00',
+    };
+    let listed = [failedRow];
+    let deleted: number | null = null;
+    server.use(
+      http.get('/api/v1/scheduled-dryings', () => HttpResponse.json(listed)),
+      http.delete('/api/v1/scheduled-dryings/:id', ({ params }) => {
+        deleted = Number(params.id);
+        listed = [];
+        return HttpResponse.json({ status: 'dismissed', id: deleted });
+      }),
+    );
+    const user = userEvent.setup();
+    render(<PrintersPage />);
+
+    expect(
+      await screen.findByText(/Scheduled drying failed: Drying not supported for this printer model/)
+    ).toBeInTheDocument();
+
+    await user.click(screen.getByTitle('Dismiss'));
+    await waitFor(() => expect(deleted).toBe(failedRow.id));
+    await waitFor(() => {
+      expect(screen.queryByText(/Scheduled drying failed/)).not.toBeInTheDocument();
+    });
+  });
+
+  it.each([
+    ['ams_retract_filament', 'Retract the filament at the AMS outlet to start drying'],
+    ['ams_not_found', 'Waiting for the AMS to be detected'],
+    ['printer_offline', 'Waiting for the printer to come online'],
+    ['printer_busy', 'Waiting for the printer to be free'],
+    ['already_drying', 'Waiting for the current drying cycle to finish'],
+    ['interrupted', 'Interrupted, will restart when the printer is free'],
+  ])('renders text for the %s waiting reason', async (reason, text) => {
+    // An unmapped reason left the card showing a bare "Drying scheduled for"
+    // with no hint why it had not started.
+    server.use(
+      http.get('/api/v1/scheduled-dryings', () =>
+        HttpResponse.json([{ ...PENDING_ROW, start_after: null, waiting_reason: reason }])
+      ),
+    );
+    render(<PrintersPage />);
+    expect(await screen.findByText(text)).toBeInTheDocument();
+  });
+
+  it('renders the banner inside the card body, not below it', async () => {
+    // Between </CardContent> and </Card> it went full-bleed and its corners
+    // collided with the card's rounded bottom edge.
+    server.use(
+      http.get('/api/v1/scheduled-dryings', () => HttpResponse.json([PENDING_ROW])),
+    );
+    render(<PrintersPage />);
+    const banner = await screen.findByTestId('scheduled-drying-pending');
+    // Its wrapper's parent is CardContent (which carries the padding), not the
+    // bare Card root it used to hang off.
+    const parent = banner.parentElement?.parentElement;
+    expect(parent?.className).toMatch(/\bp-\d/);
+  });
+
+  it('does not close the popover on the outside click that dismisses the native date picker', async () => {
+    const user = userEvent.setup();
+    render(<PrintersPage />);
+    await openDryingPopover(user);
+
+    await user.click(screen.getByRole('button', { name: 'At time' }));
+    const input = await screen.findByTestId('drying-start-at');
+
+    // With the native picker open the input keeps focus; the click that
+    // dismisses the picker lands on the backdrop.
+    input.focus();
+    await user.click(screen.getByTestId('drying-popover-backdrop'));
+    expect(screen.getByTestId('drying-start-confirm')).toBeInTheDocument();
+
+    // A second outside click (input no longer focused) closes as before.
+    await user.click(screen.getByTestId('drying-popover-backdrop'));
+    expect(screen.queryByTestId('drying-start-confirm')).not.toBeInTheDocument();
+  });
+
+  // The flame button's tooltip is the only place an immediate drying attempt
+  // explains itself, and it has to name the same code the scheduled path would
+  // record — otherwise the same blocked AMS reads two different ways depending
+  // on which button you pressed.
+  it.each([
+    [[1], 'Connect AMS power adapter to enable drying'],
+    [[8], 'Connect AMS power adapter to enable drying'],
+    [[3], 'Retract the filament at the AMS outlet to start drying'],
+    [[2], "AMS can't start drying right now"],
+    // Both set: the power problem outranks the retract, as it does server-side.
+    [[3, 1], 'Connect AMS power adapter to enable drying'],
+    // Transient alongside an actionable one: name the one the user can fix.
+    [[2, 3], 'Retract the filament at the AMS outlet to start drying'],
+  ])('explains dry_sf_reason %j on the drying button', async (reasons, expected) => {
+    server.use(
+      http.get('/api/v1/printers/:id/status', () =>
+        HttpResponse.json({ ...IDLE, ams: [{ ...IDLE.ams[0], dry_sf_reason: reasons }] })
+      ),
+    );
+    render(<PrintersPage />);
+
+    await waitFor(() => {
+      expect(screen.getAllByTitle(expected).length).toBeGreaterThan(0);
+    });
+  });
+});

+ 64 - 0
frontend/src/__tests__/utils/popoverPosition.test.ts

@@ -215,3 +215,67 @@ describe('computePopoverPosition (#1669, iOS Safari visualViewport)', () => {
     expect(pos.top).toBe(324);
   });
 });
+
+/**
+ * The drying popover's start-mode controls appear after the popover is
+ * positioned, so its real height can exceed the open-time estimate. The
+ * helper reports placement and the trigger-facing edge so an 'above'
+ * popover can be anchored by its bottom edge and carry an anchor arrow.
+ */
+describe('computePopoverPosition anchor metadata', () => {
+  const viewport = { viewportWidth: 1024, viewportHeight: 768 };
+
+  it('reports below placement with the anchor on the top edge', () => {
+    const trigger = { top: 300, bottom: 320, left: 400, right: 440 };
+    const pos = computePopoverPosition({
+      triggerRect: trigger,
+      popoverWidth: 240,
+      estimatedHeight: 320,
+      horizontalAlign: 'center',
+      ...viewport,
+    });
+    expect(pos.placement).toBe('below');
+    expect(pos.anchorY).toBe(pos.top); // 324
+  });
+
+  it('reports above placement with the anchor at the trigger-facing bottom edge', () => {
+    const trigger = { top: 680, bottom: 700, left: 400, right: 440 };
+    const pos = computePopoverPosition({
+      triggerRect: trigger,
+      popoverWidth: 240,
+      estimatedHeight: 320,
+      ...viewport,
+    });
+    expect(pos.placement).toBe('above');
+    expect(pos.anchorY).toBe(680 - 4); // trigger.top - gap
+  });
+
+  it('points the arrow at the trigger center', () => {
+    const trigger = { top: 300, bottom: 320, left: 400, right: 440 };
+    const pos = computePopoverPosition({
+      triggerRect: trigger,
+      popoverWidth: 240,
+      estimatedHeight: 320,
+      horizontalAlign: 'center',
+      ...viewport,
+    });
+    // Centered popover: the trigger center (420) sits mid-popover.
+    expect(pos.left + pos.arrowLeft).toBe(420);
+  });
+
+  it('clamps the arrow inside the corners when the popover is edge-clamped', () => {
+    // Trigger hugging the left viewport edge: popover clamps to left=8 while
+    // the trigger center stays at 30 — the arrow must stay clear of the
+    // rounded corner.
+    const trigger = { top: 300, bottom: 320, left: 10, right: 50 };
+    const pos = computePopoverPosition({
+      triggerRect: trigger,
+      popoverWidth: 240,
+      estimatedHeight: 320,
+      horizontalAlign: 'center',
+      ...viewport,
+    });
+    expect(pos.left).toBe(8);
+    expect(pos.arrowLeft).toBe(Math.max(14, 30 - 8));
+  });
+});

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

@@ -435,6 +435,23 @@ export interface AMSUnit {
   module_type: string;    // "ams", "n3f", "n3s"
 }
 
+export interface ScheduledDrying {
+  id: number;
+  printer_id: number;
+  ams_id: number;
+  temp: number;
+  duration_hours: number;
+  filament: string;
+  rotate_tray: boolean;
+  start_after: string | null;  // UTC ISO with Z suffix, like the queue routes
+  status: string;
+  waiting_reason: string | null;
+  error_message: string | null;
+  created_at: string;
+  started_at: string | null;
+  completed_at: string | null;
+}
+
 export interface NozzleInfo {
   nozzle_type: string;  // "stainless_steel" or "hardened_steel"
   nozzle_diameter: string;  // e.g., "0.4"
@@ -4524,6 +4541,29 @@ export const api = {
       { method: 'POST' }
     ),
 
+  // Scheduled (delayed) drying runs (#2638)
+  createScheduledDrying: (data: {
+    printer_id: number;
+    ams_id: number;
+    temp: number;
+    duration_hours: number;
+    filament?: string;
+    rotate_tray?: boolean;
+    start_after: string | null;
+  }) =>
+    request<ScheduledDrying>('/scheduled-dryings', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  // Omit printerId for the whole fleet in one request; the printer cards share
+  // that single query rather than each polling for its own id.
+  listScheduledDryings: (printerId?: number) =>
+    request<ScheduledDrying[]>(
+      printerId === undefined ? '/scheduled-dryings' : `/scheduled-dryings?printer_id=${printerId}`
+    ),
+  cancelScheduledDrying: (id: number) =>
+    request<{ status: string; id: number }>(`/scheduled-dryings/${id}`, { method: 'DELETE' }),
+
   // AMS Filament Backup (auto-switch to a backup spool when one runs out)
   setAmsFilamentBackup: (printerId: number, enabled: boolean) =>
     request<{ success: boolean; ams_filament_backup: boolean }>(

+ 186 - 15
frontend/src/components/BugReportBubble.tsx

@@ -2,7 +2,7 @@ import { useState, useRef, useCallback, useEffect } from 'react';
 import { Bug, X, Loader2, CheckCircle, AlertCircle, AlertTriangle, Trash2, Upload, Circle, CheckCircle2, Stethoscope } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useQuery } from '@tanstack/react-query';
-import { api, bugReportApi, type PrinterDiagnosticResult } from '../api/client';
+import { api, bugReportApi, supportApi, type PrinterDiagnosticResult } from '../api/client';
 import { DiagnosticChecklist } from './ConnectionDiagnostic';
 import { SystemHealthPanel } from './SystemHealthPanel';
 import { Collapsible } from './Collapsible';
@@ -18,6 +18,69 @@ const MAX_DIMENSION = 1920;
 const JPEG_QUALITY = 0.7;
 const MAX_LOG_SECONDS = 300; // 5 minutes
 
+/**
+ * A logging run outlives the panel that started it (#2847).
+ *
+ * Step 2 asks the user to reproduce the problem, and the panel sits over the
+ * part of the app they have to reach to do that. Closing it has to be allowed,
+ * so the run is written down rather than held only in component state: the
+ * panel reopens on the step it left, and a reload lands there too instead of
+ * leaving the server at DEBUG with nothing in the UI still tracking it.
+ *
+ * The screenshot is deliberately not persisted. A 1920px JPEG runs to hundreds
+ * of kilobytes against an origin-wide budget this app shares with everything
+ * else it stores, and it survives a close either way — only a reload loses it,
+ * and it is the one optional field on the form.
+ */
+const SESSION_KEY = 'bambuddy-bug-report-session';
+
+interface LoggingSession {
+  description: string;
+  email: string;
+  /** Debug logging was already on before this run, so stopping must leave it on. */
+  wasDebug: boolean;
+  /** Wall clock. Elapsed is derived from it rather than counted in ticks, which
+   *  a background tab throttles — the 5-minute cap has to mean five minutes. */
+  startedAt: number;
+}
+
+function readSession(): LoggingSession | null {
+  try {
+    const raw = window.localStorage.getItem(SESSION_KEY);
+    if (!raw) return null;
+    const parsed = JSON.parse(raw) as Partial<LoggingSession>;
+    if (typeof parsed?.startedAt !== 'number') return null;
+    return {
+      description: typeof parsed.description === 'string' ? parsed.description : '',
+      email: typeof parsed.email === 'string' ? parsed.email : '',
+      wasDebug: parsed.wasDebug === true,
+      startedAt: parsed.startedAt,
+    };
+  } catch {
+    // Unparseable or unreadable. Treat it as no session rather than trapping
+    // the user in a panel that cannot restore.
+    return null;
+  }
+}
+
+function writeSession(session: LoggingSession): void {
+  try {
+    window.localStorage.setItem(SESSION_KEY, JSON.stringify(session));
+  } catch {
+    // Quota, or storage refused outright in a locked-down browser. The run
+    // still works and still survives a close; it just will not survive a
+    // reload, which is no worse than before it was written down at all.
+  }
+}
+
+function clearSession(): void {
+  try {
+    window.localStorage.removeItem(SESSION_KEY);
+  } catch {
+    // See writeSession.
+  }
+}
+
 function compressImage(file: File): Promise<string> {
   return new Promise((resolve, reject) => {
     const img = new Image();
@@ -63,9 +126,16 @@ interface BugReportBubbleProps {
   /** Controlled open state. Falls back to internal state when omitted. */
   open?: boolean;
   onOpenChange?: (open: boolean) => void;
+  /**
+   * Fired when a logging run starts or ends. The floating disc shows a live run
+   * itself, but the compact layout replaces the disc with a header button and
+   * has no room for a timer, so Layout uses this to mark that button and to
+   * offer a way back into the run from the debug-logging banner (#2847).
+   */
+  onLoggingChange?: (active: boolean) => void;
 }
 
-export function BugReportBubble({ showTrigger = true, open, onOpenChange }: BugReportBubbleProps = {}) {
+export function BugReportBubble({ showTrigger = true, open, onOpenChange, onLoggingChange }: BugReportBubbleProps = {}) {
   const { t } = useTranslation();
   const isMobile = useIsMobile();
   const [internalOpen, setInternalOpen] = useState(false);
@@ -87,10 +157,19 @@ export function BugReportBubble({ showTrigger = true, open, onOpenChange }: BugR
   const [issueNumber, setIssueNumber] = useState<number | null>(null);
   const [errorMessage, setErrorMessage] = useState('');
   const [elapsedSeconds, setElapsedSeconds] = useState(0);
+  const [startedAt, setStartedAt] = useState<number | null>(null);
   const [wasDebug, setWasDebug] = useState(false);
   const modalRef = useRef<HTMLDivElement>(null);
   const fileInputRef = useRef<HTMLInputElement>(null);
   const handleStopLoggingRef = useRef<() => void>(() => {});
+  // Read inside effects that must not re-run when the view changes.
+  const viewStateRef = useRef(viewState);
+  viewStateRef.current = viewState;
+
+  const isLogging = viewState === 'logging';
+  useEffect(() => {
+    onLoggingChange?.(isLogging);
+  }, [isLogging, onLoggingChange]);
 
   // Before the user files a report, diagnose configured printers. Most bug
   // reports are setup issues — surfacing a connection problem inline lets the
@@ -126,23 +205,35 @@ export function BugReportBubble({ showTrigger = true, open, onOpenChange }: BugR
   });
   const logFindings = logHealthScan.data?.findings ?? [];
 
-  // Elapsed timer for logging phase — auto-stop at 5 minutes
+  // Elapsed timer for logging phase — auto-stop at 5 minutes. Measured against
+  // the run's start time rather than counted in ticks: the run continues while
+  // the panel is closed and while the tab is in the background, where timers
+  // are throttled hard enough that a tick count is not a clock.
   useEffect(() => {
-    if (viewState !== 'logging') return;
-    if (elapsedSeconds >= MAX_LOG_SECONDS) {
-      handleStopLoggingRef.current();
-      return;
-    }
-    const timer = setTimeout(() => setElapsedSeconds((s) => s + 1), 1000);
-    return () => clearTimeout(timer);
-  }, [viewState, elapsedSeconds]);
+    if (viewState !== 'logging' || startedAt === null) return;
+    const tick = () => {
+      const elapsed = Math.floor((Date.now() - startedAt) / 1000);
+      setElapsedSeconds(elapsed);
+      if (elapsed >= MAX_LOG_SECONDS) handleStopLoggingRef.current();
+    };
+    tick();
+    const timer = setInterval(tick, 1000);
+    return () => clearInterval(timer);
+  }, [viewState, startedAt]);
 
   // Reset on open rather than in the click handler: the panel now has two
   // possible triggers (the floating disc here, and the compact header's button
   // which only flips the controlled flag), and a stale half-filled form
   // reappearing for one of them would be a nasty little inconsistency.
+  //
+  // A run in progress is the exception (#2847). Step 2 asks the user to
+  // reproduce the problem, which usually means reaching a part of the app the
+  // panel is sitting on top of, so closing it has to be allowed — and the only
+  // thing that stops debug logging is the Stop & Submit button on the step this
+  // reset used to throw away.
   useEffect(() => {
     if (!isOpen) return;
+    if (viewStateRef.current === 'logging' || viewStateRef.current === 'stopping' || viewStateRef.current === 'submitting') return;
     setViewState('form');
     setDescription('');
     setEmail('');
@@ -151,9 +242,63 @@ export function BugReportBubble({ showTrigger = true, open, onOpenChange }: BugR
     setIssueNumber(null);
     setErrorMessage('');
     setElapsedSeconds(0);
+    setStartedAt(null);
     setWasDebug(false);
   }, [isOpen]);
 
+  // Pick a run back up after a reload. The panel's own state is gone by then,
+  // but the server still has the log level raised, so without this the app is
+  // left logging at DEBUG with nothing in the report flow still pointing at it.
+  useEffect(() => {
+    const session = readSession();
+    if (!session) return;
+    let cancelled = false;
+
+    (async () => {
+      let stillLogging: boolean;
+      try {
+        stillLogging = (await supportApi.getDebugLoggingState()).enabled;
+      } catch {
+        // Can't tell. Leave the session written down for the next load rather
+        // than dropping a run that may well still be going.
+        return;
+      }
+      if (cancelled || viewStateRef.current !== 'form') return;
+
+      if (!stillLogging) {
+        // Switched off from the System page, or the run was finished in another
+        // tab. Either way there is nothing left to resume.
+        clearSession();
+        return;
+      }
+
+      const elapsed = Math.floor((Date.now() - session.startedAt) / 1000);
+      if (elapsed >= MAX_LOG_SECONDS) {
+        // Past the cap with nobody watching — the browser was closed, or the
+        // tab sat elsewhere for an hour. Put the log level back, but do not
+        // submit: a description written that long ago is not a report anyone is
+        // still expecting to be filed, and no one is here to see it happen.
+        try {
+          await bugReportApi.stopLogging(session.wasDebug);
+        } catch {
+          // The banner in Layout still shows the raised level, and the System
+          // page can lower it.
+        }
+        clearSession();
+        return;
+      }
+
+      setDescription(session.description);
+      setEmail(session.email);
+      setWasDebug(session.wasDebug);
+      setStartedAt(session.startedAt);
+      setElapsedSeconds(elapsed);
+      setViewState('logging');
+    })();
+
+    return () => { cancelled = true; };
+  }, []);
+
   const handleOpen = () => setIsOpen(true);
 
   const handleClose = () => {
@@ -203,9 +348,17 @@ export function BugReportBubble({ showTrigger = true, open, onOpenChange }: BugR
     if (!description.trim()) return;
     try {
       const result = await bugReportApi.startLogging();
+      const runStartedAt = Date.now();
       setWasDebug(result.was_debug);
+      setStartedAt(runStartedAt);
       setElapsedSeconds(0);
       setViewState('logging');
+      writeSession({
+        description: description.trim(),
+        email: email.trim(),
+        wasDebug: result.was_debug,
+        startedAt: runStartedAt,
+      });
     } catch (err) {
       setErrorMessage(err instanceof Error ? err.message : t('bugReport.unexpectedError'));
       setViewState('error');
@@ -213,6 +366,14 @@ export function BugReportBubble({ showTrigger = true, open, onOpenChange }: BugR
   };
 
   const handleStopLogging = async () => {
+    // The cap can fire while the panel is closed, and stopping submits. Show
+    // the panel so that happens in front of the user instead of behind them.
+    setIsOpen(true);
+    // The run is over from here whichever way it goes, so there is nothing left
+    // to resume — including when stopping fails, where the banner in Layout is
+    // what surfaces a log level that did not come back down.
+    clearSession();
+    setStartedAt(null);
     setViewState('stopping');
     try {
       const stopResult = await bugReportApi.stopLogging(wasDebug);
@@ -255,10 +416,17 @@ export function BugReportBubble({ showTrigger = true, open, onOpenChange }: BugR
       {showTrigger && (
         <button
           onClick={handleOpen}
-          className="fixed bottom-4 right-4 z-40 w-12 h-12 rounded-full bg-red-500 hover:bg-red-600 text-white shadow-lg hover:shadow-xl transition-all duration-200 hover:scale-110 flex items-center justify-center"
-          title={t('bugReport.title')}
+          className={`fixed bottom-4 right-4 z-40 w-12 h-12 rounded-full text-white shadow-lg hover:shadow-xl transition-all duration-200 hover:scale-110 flex items-center justify-center ${
+            // Amber while a run is going, matching the debug-logging banner, so
+            // a closed panel still says the recording is live and clickable.
+            isLogging ? 'bg-amber-500 hover:bg-amber-600' : 'bg-red-500 hover:bg-red-600'
+          }`}
+          title={isLogging ? t('bugReport.resumeRecording', { elapsed: formatElapsed(elapsedSeconds) }) : t('bugReport.title')}
         >
-          <Bug className="w-5 h-5" />
+          {isLogging && (
+            <span className="absolute inset-0 rounded-full bg-amber-400 opacity-75 animate-ping" />
+          )}
+          <Bug className="w-5 h-5 relative" />
         </button>
       )}
 
@@ -515,7 +683,7 @@ export function BugReportBubble({ showTrigger = true, open, onOpenChange }: BugR
                         <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75"></span>
                         <span className="relative inline-flex rounded-full h-3 w-3 bg-blue-500"></span>
                       </span>
-                      <span className="text-sm font-medium text-blue-700 dark:text-blue-300">{t('bugReport.stepReproduce')}</span>
+                      <span data-testid="bug-report-step-reproduce" className="text-sm font-medium text-blue-700 dark:text-blue-300">{t('bugReport.stepReproduce')}</span>
                     </div>
                     {/* Step 3: Upcoming */}
                     <div className="flex items-center gap-3">
@@ -528,6 +696,9 @@ export function BugReportBubble({ showTrigger = true, open, onOpenChange }: BugR
                   <div className="text-center">
                     <p className="text-3xl font-mono text-blue-500">{formatElapsed(elapsedSeconds)}</p>
                     <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{t('bugReport.maxDuration', { minutes: 5 })}</p>
+                    {/* The panel covers whatever has to be clicked to reproduce
+                        the problem, so say plainly that closing it is fine. */}
+                    <p className="text-xs text-gray-500 dark:text-gray-400 mt-2">{t('bugReport.closeKeepsRecording')}</p>
                   </div>
 
                   {/* Stop & Submit button */}

+ 23 - 4
frontend/src/components/Layout.tsx

@@ -86,6 +86,10 @@ export function Layout() {
   // whichever of them happens to be underneath. Moving out of the corner is
   // the only fix that covers in-flow content as well as fixed overlays.
   const [bugReportOpen, setBugReportOpen] = useState(false);
+  // A bug-report logging run survives the panel being closed (#2847). The
+  // floating disc shows that itself; the compact header's button and the
+  // debug-logging banner need telling.
+  const [bugReportLogging, setBugReportLogging] = useState(false);
 
   // Theme toggle: mode → icon and tooltip
   const ThemeIcon = { dark: Sun, light: Monitor, system: Moon }[mode];
@@ -517,11 +521,13 @@ export function Layout() {
           {/* Bug report — the compact-layout home of the floating bubble. */}
           <button
             onClick={() => setBugReportOpen(true)}
-            className="ml-auto p-2 -mr-2 rounded-lg text-red-500 hover:bg-bambu-dark-tertiary transition-colors"
-            title={t('bugReport.title')}
-            aria-label={t('bugReport.title')}
+            className={`ml-auto p-2 -mr-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
+              bugReportLogging ? 'text-amber-500' : 'text-red-500'
+            }`}
+            title={bugReportLogging ? t('bugReport.resumeReport') : t('bugReport.title')}
+            aria-label={bugReportLogging ? t('bugReport.resumeReport') : t('bugReport.title')}
           >
-            <Bug className="w-5 h-5" />
+            <Bug className={`w-5 h-5 ${bugReportLogging ? 'animate-pulse' : ''}`} />
           </button>
         </header>
       )}
@@ -883,6 +889,18 @@ export function Layout() {
                   </span>
                 )}
               </span>
+              {/* A run started from the bug-report panel ends at that panel's
+                  Stop & Submit button, so send the user back there rather than
+                  to the System page's raw toggle, which would drop the logs
+                  and the description they already wrote (#2847). */}
+              {bugReportLogging && (
+                <button
+                  onClick={() => setBugReportOpen(true)}
+                  className="text-amber-700 dark:text-amber-400 hover:text-amber-900 dark:hover:text-amber-300 font-medium underline ml-2"
+                >
+                  {t('bugReport.resumeReport')}
+                </button>
+              )}
               <button
                 onClick={() => navigate('/system')}
                 className="text-amber-700 dark:text-amber-400 hover:text-amber-900 dark:hover:text-amber-300 font-medium underline ml-2"
@@ -1146,6 +1164,7 @@ export function Layout() {
         showTrigger={!isSidebarCompact}
         open={bugReportOpen}
         onOpenChange={setBugReportOpen}
+        onLoggingChange={setBugReportLogging}
       />
     </div>
   );

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

@@ -676,6 +676,7 @@ export default {
       targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: 'Trocknung nicht unterstützt',
       powerRequired: 'AMS-Netzteil anschließen, um Trocknung zu aktivieren',
+      cannotDryNow: 'AMS kann das Trocknen gerade nicht starten',
       startingDrying: 'Trocknung wird gestartet...',
       toastCommandSent: 'Trocknungsbefehl gesendet',
       toastStopped: 'Trocknung gestoppt',
@@ -684,6 +685,24 @@ export default {
       stoppingDrying: 'Trocknung wird gestoppt...',
       rotateTray: 'Spule während der Trocknung drehen',
       rotateUnavailableReason: 'Nicht verfügbar — in diesem AMS ist ein Slot zum Druckkopf hin geladen. Die Spule ist durch den Zuführschlauch blockiert und kann nicht rotieren. Filament zuerst zurückziehen.',
+      startMode: 'Startzeit',
+      modeNow: 'Jetzt',
+      modeDelay: 'Nach Verzögerung',
+      modeAtTime: 'Zu Uhrzeit',
+      schedule: 'Planen',
+      scheduledFor: 'Trocknung geplant für {{time}}',
+      scheduledAsap: 'Trocknung geplant (wartet auf Drucker)',
+      cancelScheduled: 'Geplante Trocknung abbrechen',
+      scheduleFailed: 'Trocknung konnte nicht geplant werden',
+      retractFilament: 'Filament am AMS-Ausgang zurückziehen, um die Trocknung zu starten',
+      waitingAmsNotFound: 'Warten, bis das AMS erkannt wird',
+      waitingOffline: 'Warten, bis der Drucker online ist',
+      waitingPrinterBusy: 'Warten, bis der Drucker frei ist',
+      waitingAlreadyDrying: 'Warten, bis der laufende Trocknungszyklus beendet ist',
+      waitingInterrupted: 'Unterbrochen, startet erneut, sobald der Drucker frei ist',
+      scheduleFailedReason: 'Geplante Trocknung fehlgeschlagen: {{reason}}',
+      scheduleFailedUnknown: 'Unbekannter Fehler',
+      dismissFailed: 'Ausblenden',
     },
     amsBackup: {
       titleOn: 'AMS Filament Backup ist EIN. Zum Deaktivieren klicken.',
@@ -3922,6 +3941,8 @@ export default {
     noPermissionSlice: 'Sie haben keine Berechtigung, Dateien zu slicen',
     noPermissionAddToQueue: 'Sie haben keine Berechtigung, zur Warteschlange hinzuzufügen',
     noPermissionDownload: 'Sie haben keine Berechtigung, Dateien herunterzuladen',
+    noPermissionPreview: 'Sie haben keine Berechtigung, Dateien in der Vorschau anzuzeigen',
+    preview3d: '3D-Vorschau',
     noPermissionRenameFile: 'Sie haben keine Berechtigung, diese Datei umzubenennen',
     noPermissionGenerateThumbnail: 'Sie haben keine Berechtigung, Vorschaubilder zu generieren',
     noPermissionDeleteFile: 'Sie haben keine Berechtigung, diese Datei zu löschen',
@@ -6969,6 +6990,9 @@ export default {
     thankYou: 'Vielen Dank!',
     submitted: 'Ihr Fehlerbericht wurde eingereicht.',
     viewIssue: 'Issue ansehen',
+    closeKeepsRecording: 'Sie können dieses Fenster schließen, während Sie das Problem reproduzieren — die Aufzeichnung läuft weiter, und beim erneuten Öffnen sind Sie wieder hier.',
+    resumeRecording: 'Fehlerbericht zeichnet auf — {{elapsed}}. Zum Abschließen klicken.',
+    resumeReport: 'Bericht fortsetzen',
     unexpectedError: 'Ein unerwarteter Fehler ist aufgetreten',
   },
   failureDetection: {

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

@@ -679,6 +679,7 @@ export default {
       targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: 'Drying not supported',
       powerRequired: 'Connect AMS power adapter to enable drying',
+      cannotDryNow: 'AMS can\'t start drying right now',
       startingDrying: 'Starting drying...',
       toastCommandSent: 'Drying command sent',
       toastStopped: 'Drying stopped',
@@ -687,6 +688,25 @@ export default {
       stoppingDrying: 'Stopping drying...',
       rotateTray: 'Rotate spool during drying',
       rotateUnavailableReason: 'Unavailable — a slot in this AMS is loaded to the toolhead. The spool is locked by the feed tube and cannot rotate. Retract the filament first.',
+      startMode: 'Start time',
+      modeNow: 'Now',
+      modeDelay: 'After delay',
+      modeAtTime: 'At time',
+      // Verb (button label), not the noun.
+      schedule: 'Schedule',
+      scheduledFor: 'Drying scheduled for {{time}}',
+      scheduledAsap: 'Drying scheduled (waiting for printer)',
+      cancelScheduled: 'Cancel scheduled drying',
+      scheduleFailed: 'Failed to schedule drying',
+      retractFilament: 'Retract the filament at the AMS outlet to start drying',
+      waitingAmsNotFound: 'Waiting for the AMS to be detected',
+      waitingOffline: 'Waiting for the printer to come online',
+      waitingPrinterBusy: 'Waiting for the printer to be free',
+      waitingAlreadyDrying: 'Waiting for the current drying cycle to finish',
+      waitingInterrupted: 'Interrupted, will restart when the printer is free',
+      scheduleFailedReason: 'Scheduled drying failed: {{reason}}',
+      scheduleFailedUnknown: 'Unknown error',
+      dismissFailed: 'Dismiss',
     },
     // AMS Filament Backup status badge (printer-wide auto-switch to another spool)
     amsBackup: {
@@ -3951,6 +3971,8 @@ export default {
     noPermissionAddToQueue: 'You do not have permission to add to queue',
     noPermissionSlice: 'You do not have permission to slice files',
     noPermissionDownload: 'You do not have permission to download files',
+    noPermissionPreview: 'You do not have permission to preview files',
+    preview3d: '3D Preview',
     noPermissionRenameFile: 'You do not have permission to rename this file',
     noPermissionGenerateThumbnail: 'You do not have permission to generate thumbnails',
     noPermissionDeleteFile: 'You do not have permission to delete this file',
@@ -7018,6 +7040,9 @@ export default {
     thankYou: 'Thank you!',
     submitted: 'Your bug report has been submitted.',
     viewIssue: 'View Issue',
+    closeKeepsRecording: 'You can close this panel while you reproduce the problem — recording keeps running, and reopening brings you back here.',
+    resumeRecording: 'Bug report recording — {{elapsed}}. Click to finish.',
+    resumeReport: 'Resume report',
     unexpectedError: 'An unexpected error occurred',
   },
   failureDetection: {

+ 24 - 0
frontend/src/i18n/locales/es.ts

@@ -676,6 +676,7 @@ export default {
       targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: 'Secado no compatible',
       powerRequired: 'Conecte el adaptador de corriente del AMS para activar el secado',
+      cannotDryNow: 'El AMS no puede iniciar el secado en este momento',
       startingDrying: 'Iniciando el secado...',
       toastCommandSent: 'Comando de secado enviado',
       toastStopped: 'Secado detenido',
@@ -684,6 +685,24 @@ export default {
       stoppingDrying: 'Deteniendo el secado...',
       rotateTray: 'Girar la bobina durante el secado',
       rotateUnavailableReason: 'No disponible — un slot de este AMS está cargado hacia el cabezal. La bobina está bloqueada por el tubo de alimentación y no puede girar. Retira el filamento primero.',
+      startMode: 'Hora de inicio',
+      modeNow: 'Ahora',
+      modeDelay: 'Tras un retraso',
+      modeAtTime: 'A una hora',
+      schedule: 'Programar',
+      scheduledFor: 'Secado programado para {{time}}',
+      scheduledAsap: 'Secado programado (esperando la impresora)',
+      cancelScheduled: 'Cancelar secado programado',
+      scheduleFailed: 'No se pudo programar el secado',
+      retractFilament: 'Retire el filamento de la salida del AMS para iniciar el secado',
+      waitingAmsNotFound: 'Esperando a que se detecte el AMS',
+      waitingOffline: 'Esperando a que la impresora se conecte',
+      waitingPrinterBusy: 'Esperando a que la impresora esté libre',
+      waitingAlreadyDrying: 'Esperando a que termine el ciclo de secado actual',
+      waitingInterrupted: 'Interrumpido, se reanudará cuando la impresora esté libre',
+      scheduleFailedReason: 'El secado programado falló: {{reason}}',
+      scheduleFailedUnknown: 'Error desconocido',
+      dismissFailed: 'Descartar',
     },
     amsBackup: {
       titleOn: 'AMS Filament Backup está ACTIVADO. Haz clic para desactivar.',
@@ -3924,6 +3943,8 @@ export default {
     noPermissionAddToQueue: 'No tiene permiso para añadir a la cola',
     noPermissionSlice: 'No tiene permiso para laminar archivos',
     noPermissionDownload: 'No tiene permiso para descargar archivos',
+    noPermissionPreview: 'No tienes permiso para previsualizar archivos',
+    preview3d: 'Vista previa 3D',
     noPermissionRenameFile: 'No tiene permiso para renombrar este archivo',
     noPermissionGenerateThumbnail: 'No tiene permiso para generar miniaturas',
     noPermissionDeleteFile: 'No tiene permiso para eliminar este archivo',
@@ -6977,6 +6998,9 @@ export default {
     thankYou: '¡Gracias!',
     submitted: 'Su informe de error se ha enviado.',
     viewIssue: 'Ver incidencia',
+    closeKeepsRecording: 'Puedes cerrar este panel mientras reproduces el problema: la grabación sigue en marcha y al volver a abrirlo regresarás aquí.',
+    resumeRecording: 'Informe de error grabando — {{elapsed}}. Haz clic para finalizar.',
+    resumeReport: 'Reanudar informe',
     unexpectedError: 'Se produjo un error inesperado',
   },
   failureDetection: {

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

@@ -676,6 +676,7 @@ export default {
       targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: 'Séchage non pris en charge',
       powerRequired: 'Brancher l\'adaptateur secteur AMS pour activer le séchage',
+      cannotDryNow: 'L\'AMS ne peut pas démarrer le séchage pour le moment',
       startingDrying: 'Démarrage du séchage...',
       toastCommandSent: 'Commande de séchage envoyée',
       toastStopped: 'Séchage arrêté',
@@ -684,6 +685,24 @@ export default {
       stoppingDrying: 'Arrêt du séchage...',
       rotateTray: 'Tourner la bobine pendant le séchage',
       rotateUnavailableReason: 'Indisponible — un emplacement de cet AMS est chargé vers la tête d\'impression. La bobine est bloquée par le tube d\'alimentation et ne peut pas tourner. Rétractez d\'abord le filament.',
+      startMode: 'Heure de démarrage',
+      modeNow: 'Maintenant',
+      modeDelay: 'Après un délai',
+      modeAtTime: 'À une heure',
+      schedule: 'Planifier',
+      scheduledFor: 'Séchage planifié pour {{time}}',
+      scheduledAsap: 'Séchage planifié (en attente de l\'imprimante)',
+      cancelScheduled: 'Annuler le séchage planifié',
+      scheduleFailed: 'Échec de la planification du séchage',
+      retractFilament: 'Rétractez le filament à la sortie de l\'AMS pour démarrer le séchage',
+      waitingAmsNotFound: 'En attente de la détection de l\'AMS',
+      waitingOffline: 'En attente de la connexion de l\'imprimante',
+      waitingPrinterBusy: 'En attente de la disponibilité de l\'imprimante',
+      waitingAlreadyDrying: 'En attente de la fin du cycle de séchage en cours',
+      waitingInterrupted: 'Interrompu, reprendra lorsque l\'imprimante sera libre',
+      scheduleFailedReason: 'Échec du séchage planifié : {{reason}}',
+      scheduleFailedUnknown: 'Erreur inconnue',
+      dismissFailed: 'Masquer',
     },
     amsBackup: {
       titleOn: "AMS Filament Backup est ACTIVÉ. Cliquez pour désactiver.",
@@ -3911,6 +3930,8 @@ export default {
     noPermissionSlice: 'Vous n\'avez pas la permission de découper des fichiers',
     noPermissionAddToQueue: 'Pas d\'autorisation file',
     noPermissionDownload: 'Pas d\'autorisation téléchargement',
+    noPermissionPreview: 'Vous n\'avez pas la permission de prévisualiser les fichiers',
+    preview3d: 'Aperçu 3D',
     noPermissionRenameFile: 'Pas d\'autorisation renommage fichier',
     noPermissionGenerateThumbnail: 'Pas d\'autorisation vignettes',
     noPermissionDeleteFile: 'Pas d\'autorisation suppression fichier',
@@ -6959,6 +6980,9 @@ export default {
     thankYou: 'Merci !',
     submitted: 'Votre rapport de bug a été soumis.',
     viewIssue: 'Voir l\'issue',
+    closeKeepsRecording: 'Vous pouvez fermer ce panneau pendant que vous reproduisez le problème : l\'enregistrement continue et sa réouverture vous ramènera ici.',
+    resumeRecording: 'Rapport de bogue en cours d\'enregistrement — {{elapsed}}. Cliquez pour terminer.',
+    resumeReport: 'Reprendre le rapport',
     unexpectedError: 'Une erreur inattendue est survenue',
   },
   failureDetection: {

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

@@ -676,6 +676,7 @@ export default {
       targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: 'Essiccazione non supportata',
       powerRequired: 'Collegare l\'alimentatore AMS per abilitare l\'asciugatura',
+      cannotDryNow: 'L\'AMS non può avviare l\'asciugatura al momento',
       startingDrying: 'Avvio essiccazione...',
       toastCommandSent: 'Comando di essiccazione inviato',
       toastStopped: 'Essiccazione interrotta',
@@ -684,6 +685,24 @@ export default {
       stoppingDrying: 'Arresto essiccazione...',
       rotateTray: 'Ruota la bobina durante l\'essiccazione',
       rotateUnavailableReason: 'Non disponibile — uno slot di questo AMS è caricato verso la testa di stampa. La bobina è bloccata dal tubo di alimentazione e non può ruotare. Ritrai prima il filamento.',
+      startMode: 'Ora di avvio',
+      modeNow: 'Ora',
+      modeDelay: 'Dopo un ritardo',
+      modeAtTime: 'A un orario',
+      schedule: 'Pianifica',
+      scheduledFor: 'Asciugatura pianificata per {{time}}',
+      scheduledAsap: 'Asciugatura pianificata (in attesa della stampante)',
+      cancelScheduled: 'Annulla asciugatura pianificata',
+      scheduleFailed: 'Pianificazione dell\'asciugatura non riuscita',
+      retractFilament: 'Ritrarre il filamento dall\'uscita dell\'AMS per avviare l\'asciugatura',
+      waitingAmsNotFound: 'In attesa del rilevamento dell\'AMS',
+      waitingOffline: 'In attesa che la stampante torni online',
+      waitingPrinterBusy: 'In attesa che la stampante sia libera',
+      waitingAlreadyDrying: 'In attesa che termini il ciclo di asciugatura in corso',
+      waitingInterrupted: 'Interrotta, riprenderà quando la stampante sarà libera',
+      scheduleFailedReason: 'Asciugatura pianificata non riuscita: {{reason}}',
+      scheduleFailedUnknown: 'Errore sconosciuto',
+      dismissFailed: 'Ignora',
     },
     amsBackup: {
       titleOn: 'AMS Filament Backup è ATTIVO. Clicca per disabilitare.',
@@ -3910,6 +3929,8 @@ export default {
     noPermissionSlice: 'Non hai il permesso di sezionare i file',
     noPermissionAddToQueue: 'Non hai il permesso di aggiungere alla coda',
     noPermissionDownload: 'Non hai il permesso di scaricare file',
+    noPermissionPreview: 'Non hai il permesso di visualizzare l\'anteprima dei file',
+    preview3d: 'Anteprima 3D',
     noPermissionRenameFile: 'Non hai il permesso di rinominare questo file',
     noPermissionGenerateThumbnail: 'Non hai il permesso di generare miniature',
     noPermissionDeleteFile: 'Non hai il permesso di eliminare questo file',
@@ -6958,6 +6979,9 @@ export default {
     thankYou: 'Grazie!',
     submitted: 'La tua segnalazione bug è stata inviata.',
     viewIssue: 'Vedi issue',
+    closeKeepsRecording: 'Puoi chiudere questo pannello mentre riproduci il problema: la registrazione continua e riaprendolo tornerai qui.',
+    resumeRecording: 'Segnalazione in registrazione — {{elapsed}}. Fai clic per completare.',
+    resumeReport: 'Riprendi segnalazione',
     unexpectedError: 'Si è verificato un errore imprevisto',
   },
   failureDetection: {

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

@@ -675,6 +675,7 @@ export default {
       targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: '乾燥非対応',
       powerRequired: 'AMS電源アダプターを接続して乾燥を有効にしてください',
+      cannotDryNow: 'AMSは現在乾燥を開始できません',
       startingDrying: '乾燥を開始しています...',
       toastCommandSent: '乾燥コマンドを送信しました',
       toastStopped: '乾燥を停止しました',
@@ -683,6 +684,24 @@ export default {
       stoppingDrying: '乾燥を停止しています...',
       rotateTray: '乾燥中にスプールを回転',
       rotateUnavailableReason: '利用不可 — このAMSのスロットがツールヘッドにロードされています。スプールが供給チューブで固定されているため回転できません。先にフィラメントを引き戻してください。',
+      startMode: '開始時刻',
+      modeNow: '今すぐ',
+      modeDelay: '時間経過後',
+      modeAtTime: '時刻指定',
+      schedule: '予約する',
+      scheduledFor: '乾燥予約: {{time}}',
+      scheduledAsap: '乾燥予約済み(プリンター待ち)',
+      cancelScheduled: '乾燥予約をキャンセル',
+      scheduleFailed: '乾燥の予約に失敗しました',
+      retractFilament: 'AMS出口のフィラメントを引き戻すと乾燥を開始できます',
+      waitingAmsNotFound: 'AMSの検出待ち',
+      waitingOffline: 'プリンターのオンライン復帰待ち',
+      waitingPrinterBusy: 'プリンターが空くのを待機中',
+      waitingAlreadyDrying: '実行中の乾燥サイクルの終了待ち',
+      waitingInterrupted: '中断されました。プリンターが空き次第、再開します',
+      scheduleFailedReason: '予約した乾燥に失敗しました: {{reason}}',
+      scheduleFailedUnknown: '不明なエラー',
+      dismissFailed: '閉じる',
     },
     amsBackup: {
       titleOn: 'AMSフィラメントバックアップはONです。クリックして無効化します。',
@@ -3922,6 +3941,8 @@ export default {
     noPermissionSlice: 'ファイルをスライスする権限がありません',
     noPermissionAddToQueue: 'キューに追加する権限がありません',
     noPermissionDownload: 'ファイルをダウンロードする権限がありません',
+    noPermissionPreview: 'ファイルをプレビューする権限がありません',
+    preview3d: '3Dプレビュー',
     noPermissionRenameFile: 'このファイル名を変更する権限がありません',
     noPermissionGenerateThumbnail: 'サムネイルを生成する権限がありません',
     noPermissionDeleteFile: 'このファイルを削除する権限がありません',
@@ -6970,6 +6991,9 @@ export default {
     thankYou: 'ありがとうございます!',
     submitted: 'バグレポートが送信されました。',
     viewIssue: 'Issueを表示',
+    closeKeepsRecording: '問題を再現している間、このパネルを閉じても構いません。記録は続行され、再度開くとここに戻ります。',
+    resumeRecording: 'バグレポートを記録中 — {{elapsed}}。クリックして完了します。',
+    resumeReport: 'レポートを再開',
     unexpectedError: '予期しないエラーが発生しました',
   },
   failureDetection: {

+ 25 - 1
frontend/src/i18n/locales/ko.ts

@@ -638,6 +638,7 @@ export default {
       targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: '건조 지원 안 됨',
       powerRequired: '건조를 활성화하려면 AMS 전원 어댑터를 연결하세요',
+      cannotDryNow: 'AMS가 지금은 건조를 시작할 수 없습니다',
       startingDrying: '건조 시작 중...',
       toastCommandSent: '건조 명령을 전송했습니다',
       toastStopped: '건조를 중지했습니다',
@@ -645,7 +646,25 @@ export default {
       screenOnly: '이 프린터에서는 AMS 건조를 프린터 자체 화면에서만 제어할 수 있습니다 (Bambu 제한 사항)',
       stoppingDrying: '건조 정지 중...',
       rotateTray: '건조 중 스풀 회전',
-      rotateUnavailableReason: '사용할 수 없음 — 이 AMS의 슬롯이 툴헤드로 로드되어 있습니다. 스풀이 공급 튜브에 의해 고정되어 회전할 수 없습니다. 먼저 필라멘트를 뺀 후 다시 시도하십시오.'
+      rotateUnavailableReason: '사용할 수 없음 — 이 AMS의 슬롯이 툴헤드로 로드되어 있습니다. 스풀이 공급 튜브에 의해 고정되어 회전할 수 없습니다. 먼저 필라멘트를 뺀 후 다시 시도하십시오.',
+      startMode: '시작 시간',
+      modeNow: '지금',
+      modeDelay: '지연 후',
+      modeAtTime: '시각 지정',
+      schedule: '예약하기',
+      scheduledFor: '건조 예약: {{time}}',
+      scheduledAsap: '건조 예약됨 (프린터 대기 중)',
+      cancelScheduled: '예약된 건조 취소',
+      scheduleFailed: '건조 예약에 실패했습니다',
+      retractFilament: '건조를 시작하려면 AMS 출구의 필라멘트를 빼내세요',
+      waitingAmsNotFound: 'AMS 감지를 기다리는 중',
+      waitingOffline: '프린터가 온라인 상태가 되기를 기다리는 중',
+      waitingPrinterBusy: '프린터가 사용 가능해지기를 기다리는 중',
+      waitingAlreadyDrying: '현재 건조 주기가 끝나기를 기다리는 중',
+      waitingInterrupted: '중단됨, 프린터가 사용 가능해지면 다시 시작됩니다',
+      scheduleFailedReason: '예약된 건조에 실패했습니다: {{reason}}',
+      scheduleFailedUnknown: '알 수 없는 오류',
+      dismissFailed: '닫기',
     },
     amsBackup: {
       titleOn: 'AMS 필라멘트 백업이 켜져 있습니다. 비활성화하려면 클릭하세요.',
@@ -3732,6 +3751,8 @@ export default {
     noPermissionAddToQueue: '대기열 추가 권한이 없습니다',
     noPermissionSlice: '파일 슬라이싱 권한이 없습니다',
     noPermissionDownload: '파일 다운로드 권한이 없습니다',
+    noPermissionPreview: '파일을 미리 볼 권한이 없습니다',
+    preview3d: '3D 미리보기',
     noPermissionRenameFile: '파일 이름 변경 권한이 없습니다',
     noPermissionGenerateThumbnail: '썸네일 생성 권한이 없습니다',
     noPermissionDeleteFile: '파일 삭제 권한이 없습니다',
@@ -6414,6 +6435,9 @@ export default {
     thankYou: '감사합니다!',
     submitted: '버그 보고서가 제출되었습니다.',
     viewIssue: '이슈 보기',
+    closeKeepsRecording: '문제를 재현하는 동안 이 패널을 닫아도 됩니다. 기록은 계속되며 다시 열면 이 단계로 돌아옵니다.',
+    resumeRecording: '버그 리포트 기록 중 — {{elapsed}}. 클릭하여 완료하세요.',
+    resumeReport: '리포트 계속하기',
     unexpectedError: '예상치 못한 오류가 발생했습니다',
     submittingStepConnection: '프린터 연결 확인 실행 중',
     submittingStepVirtualPrinters: '가상 프린터 설정 확인 실행 중',

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

@@ -676,6 +676,7 @@ export default {
       targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: 'Secagem não suportada',
       powerRequired: 'Conecte o adaptador de energia AMS para ativar a secagem',
+      cannotDryNow: 'O AMS não pode iniciar a secagem no momento',
       startingDrying: 'Iniciando secagem...',
       toastCommandSent: 'Comando de secagem enviado',
       toastStopped: 'Secagem interrompida',
@@ -684,6 +685,24 @@ export default {
       stoppingDrying: 'Parando secagem...',
       rotateTray: 'Girar o carretel durante a secagem',
       rotateUnavailableReason: 'Indisponível — um slot deste AMS está carregado em direção ao cabeçote. O carretel está travado pelo tubo de alimentação e não pode girar. Retraia o filamento primeiro.',
+      startMode: 'Horário de início',
+      modeNow: 'Agora',
+      modeDelay: 'Após um atraso',
+      modeAtTime: 'Em um horário',
+      schedule: 'Agendar',
+      scheduledFor: 'Secagem agendada para {{time}}',
+      scheduledAsap: 'Secagem agendada (aguardando a impressora)',
+      cancelScheduled: 'Cancelar secagem agendada',
+      scheduleFailed: 'Falha ao agendar a secagem',
+      retractFilament: 'Recolha o filamento na saída do AMS para iniciar a secagem',
+      waitingAmsNotFound: 'Aguardando a detecção do AMS',
+      waitingOffline: 'Aguardando a impressora ficar online',
+      waitingPrinterBusy: 'Aguardando a impressora ficar livre',
+      waitingAlreadyDrying: 'Aguardando o ciclo de secagem atual terminar',
+      waitingInterrupted: 'Interrompida, será retomada quando a impressora estiver livre',
+      scheduleFailedReason: 'Falha na secagem agendada: {{reason}}',
+      scheduleFailedUnknown: 'Erro desconhecido',
+      dismissFailed: 'Dispensar',
     },
     amsBackup: {
       titleOn: 'AMS Filament Backup está LIGADO. Clique para desativar.',
@@ -3910,6 +3929,8 @@ export default {
     noPermissionSlice: 'Você não tem permissão para fatiar arquivos',
     noPermissionAddToQueue: 'Você não tem permissão para adicionar à fila',
     noPermissionDownload: 'Você não tem permissão para baixar arquivos',
+    noPermissionPreview: 'Você não tem permissão para pré-visualizar arquivos',
+    preview3d: 'Pré-visualização 3D',
     noPermissionRenameFile: 'Você não tem permissão para renomear este arquivo',
     noPermissionGenerateThumbnail: 'Você não tem permissão para gerar miniaturas',
     noPermissionDeleteFile: 'Você não tem permissão para excluir este arquivo',
@@ -6958,6 +6979,9 @@ export default {
     thankYou: 'Obrigado!',
     submitted: 'Seu relatório de bug foi enviado.',
     viewIssue: 'Ver issue',
+    closeKeepsRecording: 'Você pode fechar este painel enquanto reproduz o problema: a gravação continua e, ao reabrir, você volta para cá.',
+    resumeRecording: 'Relatório de bug gravando — {{elapsed}}. Clique para concluir.',
+    resumeReport: 'Retomar relatório',
     unexpectedError: 'Ocorreu um erro inesperado',
   },
   failureDetection: {

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

@@ -643,6 +643,7 @@ export default {
       targetSummary: "{{filament}} при {{temp}} °C",
       notSupported: "Сушка не поддерживается",
       powerRequired: "Подключите адаптер питания AMS, чтобы включить сушку",
+      cannotDryNow: "AMS сейчас не может начать сушку",
       startingDrying: "Запуск сушки...",
       toastCommandSent: "Команда сушки отправлена",
       toastStopped: "Сушка остановлена",
@@ -651,6 +652,24 @@ export default {
       stoppingDrying: "Остановка сушки...",
       rotateTray: "Вращать катушку во время сушки",
       rotateUnavailableReason: "Недоступно: слот этого AMS сейчас подаёт филамент в печатающую головку. Катушка зафиксирована подающей трубкой и не может вращаться. Сначала выгрузите филамент.",
+      startMode: "Время начала",
+      modeNow: "Сейчас",
+      modeDelay: "После задержки",
+      modeAtTime: "По времени",
+      schedule: "Запланировать",
+      scheduledFor: "Сушка запланирована на {{time}}",
+      scheduledAsap: "Сушка запланирована (ожидание принтера)",
+      cancelScheduled: "Отменить запланированную сушку",
+      scheduleFailed: "Не удалось запланировать сушку",
+      retractFilament: "Извлеките филамент из выхода AMS, чтобы начать сушку",
+      waitingAmsNotFound: "Ожидание обнаружения AMS",
+      waitingOffline: "Ожидание подключения принтера",
+      waitingPrinterBusy: "Ожидание освобождения принтера",
+      waitingAlreadyDrying: "Ожидание завершения текущего цикла сушки",
+      waitingInterrupted: "Прервано, возобновится, когда принтер освободится",
+      scheduleFailedReason: "Не удалось выполнить запланированную сушку: {{reason}}",
+      scheduleFailedUnknown: "Неизвестная ошибка",
+      dismissFailed: "Скрыть",
     },
     amsBackup: {
       titleOn: "Резервный филамент AMS включён. Нажмите, чтобы отключить.",
@@ -3724,6 +3743,8 @@ export default {
     noPermissionAddToQueue: "У вас нет прав на добавление в очередь",
     noPermissionSlice: "У вас нет прав на нарезку файлов",
     noPermissionDownload: "У вас нет прав на скачивание файлов",
+    noPermissionPreview: 'У вас нет прав на предварительный просмотр файлов',
+    preview3d: '3D-просмотр',
     noPermissionRenameFile: "У вас нет прав на переименование этого файла",
     noPermissionGenerateThumbnail: "У вас нет прав на создание миниатюр",
     noPermissionDeleteFile: "У вас нет прав на удаление этого файла",
@@ -6595,6 +6616,9 @@ export default {
     thankYou: "Спасибо!",
     submitted: "Отчёт об ошибке отправлен.",
     viewIssue: "Открыть задачу",
+    closeKeepsRecording: 'Вы можете закрыть эту панель, пока воспроизводите проблему: запись продолжается, и при повторном открытии вы вернётесь сюда.',
+    resumeRecording: 'Идёт запись отчёта об ошибке — {{elapsed}}. Нажмите, чтобы завершить.',
+    resumeReport: 'Продолжить отчёт',
     unexpectedError: "Произошла непредвиденная ошибка",
   },
   failureDetection: {

+ 24 - 0
frontend/src/i18n/locales/tr.ts

@@ -676,6 +676,7 @@ export default {
       targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: 'Kurutma desteklenmiyor',
       powerRequired: 'Kurutmayı etkinleştirmek için AMS güç adaptörünü bağlayın',
+      cannotDryNow: 'AMS şu anda kurutmayı başlatamıyor',
       startingDrying: 'Kurutma başlatılıyor...',
       toastCommandSent: 'Kurutma komutu gönderildi',
       toastStopped: 'Kurutma durduruldu',
@@ -684,6 +685,24 @@ export default {
       stoppingDrying: 'Kurutma durduruluyor...',
       rotateTray: 'Kurutma sırasında makarayı döndür',
       rotateUnavailableReason: 'Kullanılamaz — bu AMS\'nin bir yuvası kafaya doğru yüklenmiş durumda. Makara besleme borusu tarafından kilitlendiği için döndürülemez. Önce filamenti geri çekin.',
+      startMode: 'Başlangıç zamanı',
+      modeNow: 'Şimdi',
+      modeDelay: 'Gecikme sonrası',
+      modeAtTime: 'Belirli saatte',
+      schedule: 'Zamanla',
+      scheduledFor: 'Kurutma {{time}} için zamanlandı',
+      scheduledAsap: 'Kurutma zamanlandı (yazıcı bekleniyor)',
+      cancelScheduled: 'Zamanlanmış kurutmayı iptal et',
+      scheduleFailed: 'Kurutma zamanlanamadı',
+      retractFilament: 'Kurutmayı başlatmak için AMS çıkışındaki filamenti geri çekin',
+      waitingAmsNotFound: 'AMS algılanması bekleniyor',
+      waitingOffline: 'Yazıcının çevrimiçi olması bekleniyor',
+      waitingPrinterBusy: 'Yazıcının boşalması bekleniyor',
+      waitingAlreadyDrying: 'Mevcut kurutma döngüsünün bitmesi bekleniyor',
+      waitingInterrupted: 'Kesildi, yazıcı boşaldığında yeniden başlayacak',
+      scheduleFailedReason: 'Zamanlanmış kurutma başarısız oldu: {{reason}}',
+      scheduleFailedUnknown: 'Bilinmeyen hata',
+      dismissFailed: 'Kapat',
     },
     amsBackup: {
       titleOn: 'AMS Filament Backup AÇIK. Devre dışı bırakmak için tıklayın.',
@@ -3917,6 +3936,8 @@ export default {
     noPermissionAddToQueue: 'Kuyruğa ekleme izniniz yok',
     noPermissionSlice: 'Dosyaları dilimleme izniniz yok',
     noPermissionDownload: 'Dosyaları indirme izniniz yok',
+    noPermissionPreview: 'Dosyaları önizleme izniniz yok',
+    preview3d: '3B Önizleme',
     noPermissionRenameFile: 'Bu dosyayı yeniden adlandırma izniniz yok',
     noPermissionGenerateThumbnail: 'Küçük resim oluşturma izniniz yok',
     noPermissionDeleteFile: 'Bu dosyayı silme izniniz yok',
@@ -6908,6 +6929,9 @@ export default {
     thankYou: 'Teşekkürler!',
     submitted: 'Hata raporunuz gönderildi.',
     viewIssue: 'Sorunu Görüntüle',
+    closeKeepsRecording: 'Sorunu yeniden oluştururken bu paneli kapatabilirsiniz; kayıt devam eder ve yeniden açtığınızda buraya dönersiniz.',
+    resumeRecording: 'Hata raporu kaydediyor — {{elapsed}}. Tamamlamak için tıklayın.',
+    resumeReport: 'Rapora devam et',
     unexpectedError: 'Beklenmedik bir hata oluştu',
   },
   failureDetection: {

+ 24 - 0
frontend/src/i18n/locales/uk.ts

@@ -679,6 +679,7 @@ export default {
       targetSummary: "{{filament}} при {{temp}}°C",
       notSupported: "Сушіння не підтримується",
       powerRequired: "Підключіть адаптер живлення AMS, щоб увімкнути сушіння",
+      cannotDryNow: "AMS зараз не може почати сушіння",
       startingDrying: "Запуск сушіння…",
       toastCommandSent: "Надіслано команду сушіння",
       toastStopped: "Сушіння зупинено",
@@ -687,6 +688,24 @@ export default {
       stoppingDrying: "Припинення сушіння...",
       rotateTray: "Обертати котушку під час сушіння",
       rotateUnavailableReason: "Недоступно: філамент із цього слота AMS завантажено в інструментальну головку. Котушка заблокована подавальною трубкою й не може обертатися. Спочатку вивантажте філамент.",
+      startMode: "Час початку",
+      modeNow: "Зараз",
+      modeDelay: "Після затримки",
+      modeAtTime: "За часом",
+      schedule: "Запланувати",
+      scheduledFor: "Сушіння заплановано на {{time}}",
+      scheduledAsap: "Сушіння заплановано (очікування принтера)",
+      cancelScheduled: "Скасувати заплановане сушіння",
+      scheduleFailed: "Не вдалося запланувати сушіння",
+      retractFilament: "Витягніть філамент із виходу AMS, щоб почати сушіння",
+      waitingAmsNotFound: "Очікування виявлення AMS",
+      waitingOffline: "Очікування підключення принтера",
+      waitingPrinterBusy: "Очікування звільнення принтера",
+      waitingAlreadyDrying: "Очікування завершення поточного циклу сушіння",
+      waitingInterrupted: "Перервано, відновиться, коли принтер звільниться",
+      scheduleFailedReason: "Не вдалося виконати заплановане сушіння: {{reason}}",
+      scheduleFailedUnknown: "Невідома помилка",
+      dismissFailed: "Сховати",
     },
     // AMS Filament Backup status badge (printer-wide auto-switch to another spool)
     amsBackup: {
@@ -3950,6 +3969,8 @@ export default {
     noPermissionAddToQueue: "Ви не маєте дозволу на додавання в чергу",
     noPermissionSlice: "Ви не маєте дозволу нарізати файли",
     noPermissionDownload: "У вас немає дозволу на завантаження файлів",
+    noPermissionPreview: 'У вас немає дозволу на попередній перегляд файлів',
+    preview3d: '3D-перегляд',
     noPermissionRenameFile: "Ви не маєте дозволу на перейменування цього файлу",
     noPermissionGenerateThumbnail: "Ви не маєте дозволу створювати мініатюри",
     noPermissionDeleteFile: "Ви не маєте дозволу на видалення цього файлу",
@@ -7012,6 +7033,9 @@ export default {
     thankYou: "дякую!",
     submitted: "Ваш звіт про помилку надіслано.",
     viewIssue: "Переглянути випуск",
+    closeKeepsRecording: 'Ви можете закрити цю панель, поки відтворюєте проблему: запис триває, і після повторного відкриття ви повернетеся сюди.',
+    resumeRecording: 'Триває запис звіту про помилку — {{elapsed}}. Натисніть, щоб завершити.',
+    resumeReport: 'Продовжити звіт',
     unexpectedError: "Сталася неочікувана помилка",
   },
   failureDetection: {

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

@@ -676,6 +676,7 @@ export default {
       targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: '不支持干燥',
       powerRequired: '连接AMS电源适配器以启用干燥',
+      cannotDryNow: 'AMS 当前无法开始干燥',
       startingDrying: '正在启动干燥...',
       toastCommandSent: '已发送干燥命令',
       toastStopped: '已停止干燥',
@@ -684,6 +685,24 @@ export default {
       stoppingDrying: '正在停止干燥...',
       rotateTray: '干燥时旋转料盘',
       rotateUnavailableReason: '不可用 — 此 AMS 中有插槽已装入打印头。料盘被送料管固定,无法旋转。请先回退耗材。',
+      startMode: '开始时间',
+      modeNow: '立即',
+      modeDelay: '延迟后',
+      modeAtTime: '指定时间',
+      schedule: '预约',
+      scheduledFor: '干燥已预约:{{time}}',
+      scheduledAsap: '干燥已预约(等待打印机)',
+      cancelScheduled: '取消预约干燥',
+      scheduleFailed: '预约干燥失败',
+      retractFilament: '请退回AMS出口处的耗材后再开始干燥',
+      waitingAmsNotFound: '正在等待检测到AMS',
+      waitingOffline: '正在等待打印机上线',
+      waitingPrinterBusy: '正在等待打印机空闲',
+      waitingAlreadyDrying: '正在等待当前干燥周期结束',
+      waitingInterrupted: '已中断,将在打印机空闲后重新开始',
+      scheduleFailedReason: '预约干燥失败:{{reason}}',
+      scheduleFailedUnknown: '未知错误',
+      dismissFailed: '忽略',
     },
     amsBackup: {
       titleOn: 'AMS 备用料盘已开启。点击以禁用。',
@@ -3910,6 +3929,8 @@ export default {
     noPermissionSlice: '您没有切片文件的权限',
     noPermissionAddToQueue: '您没有添加到队列的权限',
     noPermissionDownload: '您没有下载文件的权限',
+    noPermissionPreview: '您没有预览文件的权限',
+    preview3d: '3D 预览',
     noPermissionRenameFile: '您没有重命名此文件的权限',
     noPermissionGenerateThumbnail: '您没有生成缩略图的权限',
     noPermissionDeleteFile: '您没有删除此文件的权限',
@@ -6957,6 +6978,9 @@ export default {
     thankYou: '谢谢!',
     submitted: '您的错误报告已提交。',
     viewIssue: '查看Issue',
+    closeKeepsRecording: '重现问题时可以关闭此面板——记录会继续进行,重新打开后会回到这一步。',
+    resumeRecording: '错误报告记录中 — {{elapsed}}。点击以完成。',
+    resumeReport: '继续报告',
     unexpectedError: '发生了意外错误',
   },
   failureDetection: {

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

@@ -676,6 +676,7 @@ export default {
       targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: '不支援乾燥',
       powerRequired: '連線AMS電源介面卡以啟用乾燥',
+      cannotDryNow: 'AMS 目前無法開始乾燥',
       startingDrying: '正在啟動乾燥...',
       toastCommandSent: '已傳送乾燥命令',
       toastStopped: '已停止乾燥',
@@ -684,6 +685,24 @@ export default {
       stoppingDrying: '正在停止乾燥...',
       rotateTray: '乾燥時旋轉料盤',
       rotateUnavailableReason: '無法使用 — 此 AMS 中有插槽已裝入列印頭。料盤被進料管固定,無法旋轉。請先退回耗材。',
+      startMode: '開始時間',
+      modeNow: '立即',
+      modeDelay: '延遲後',
+      modeAtTime: '指定時間',
+      schedule: '預約',
+      scheduledFor: '乾燥已預約:{{time}}',
+      scheduledAsap: '乾燥已預約(等待印表機)',
+      cancelScheduled: '取消預約乾燥',
+      scheduleFailed: '預約乾燥失敗',
+      retractFilament: '請退回AMS出口處的耗材後再開始乾燥',
+      waitingAmsNotFound: '正在等待偵測到AMS',
+      waitingOffline: '正在等待印表機上線',
+      waitingPrinterBusy: '正在等待印表機空閒',
+      waitingAlreadyDrying: '正在等待目前的乾燥週期結束',
+      waitingInterrupted: '已中斷,將在印表機空閒後重新開始',
+      scheduleFailedReason: '預約乾燥失敗:{{reason}}',
+      scheduleFailedUnknown: '未知錯誤',
+      dismissFailed: '忽略',
     },
     amsBackup: {
       titleOn: 'AMS 備用料盤已開啟。點擊以停用。',
@@ -3910,6 +3929,8 @@ export default {
     noPermissionSlice: '您沒有切片檔案的權限',
     noPermissionAddToQueue: '您沒有新增到佇列的權限',
     noPermissionDownload: '您沒有下載檔案的權限',
+    noPermissionPreview: '您沒有預覽檔案的權限',
+    preview3d: '3D 預覽',
     noPermissionRenameFile: '您沒有重新命名此檔案的權限',
     noPermissionGenerateThumbnail: '您沒有產生縮圖的權限',
     noPermissionDeleteFile: '您沒有刪除此檔案的權限',
@@ -6957,6 +6978,9 @@ export default {
     thankYou: '謝謝!',
     submitted: '您的錯誤報告已提交。',
     viewIssue: '檢視 Issue',
+    closeKeepsRecording: '重現問題時可以關閉此面板——記錄會繼續進行,重新開啟後會回到這一步。',
+    resumeRecording: '錯誤報告記錄中 — {{elapsed}}。點擊以完成。',
+    resumeReport: '繼續報告',
     unexpectedError: '發生了意外錯誤',
   },
   failureDetection: {

+ 14 - 0
frontend/src/lib/scheduledDrying.ts

@@ -0,0 +1,14 @@
+export type DryingStartMode = 'now' | 'delay' | 'at_time';
+
+// Returns the UTC ISO start instant for a drying run, or null for "start now".
+// atTime is the raw value of an <input type="datetime-local"> (local timezone).
+export function computeStartAfter(
+  mode: DryingStartMode,
+  delayMinutes: number,
+  atTime: string,
+  now: Date = new Date(),
+): string | null {
+  if (mode === 'now') return null;
+  if (mode === 'delay') return new Date(now.getTime() + delayMinutes * 60_000).toISOString();
+  return new Date(atTime).toISOString();
+}

+ 95 - 116
frontend/src/pages/FileManagerPage.tsx

@@ -59,6 +59,7 @@ import type {
 } from '../api/client';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
+import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu';
 import { PrintModal } from '../components/PrintModal';
 import { ModelViewerModal } from '../components/ModelViewerModal';
 import { SliceModal } from '../components/SliceModal';
@@ -770,11 +771,94 @@ interface FileCardProps {
 }
 
 function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, canSlice, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
-  const [showActions, setShowActions] = useState(false);
+  // Viewport coordinates rather than a flag, because the menu is rendered by
+  // `ContextMenu` at `position: fixed` and anchored to the button (#2846). The
+  // card it belongs to is only ~270px tall for a bare STL, which is shorter
+  // than the seven-entry menu, so a menu positioned inside the card had its
+  // top entry -- Slice -- cut off. The archive card menu works the same way.
+  const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number } | null>(null);
+
+  const canPreview3d = hasPermission('library:read');
+  const canRename = canModify('library', 'update', file.created_by_id);
+  const canDelete = canModify('library', 'delete', file.created_by_id);
+
+  const menuItems: ContextMenuItem[] = [];
+  if (onPrint && isSlicedFilename(file.filename)) {
+    menuItems.push({
+      label: t('common.print'),
+      // The action stays visually distinct now that the menu component styles
+      // its own labels; only the icon carries the accent.
+      icon: <Printer className="w-4 h-4 text-bambu-green" />,
+      onClick: () => onPrint(file),
+      disabled: !hasPermission('queue:create'),
+      title: !hasPermission('queue:create') ? t('fileManager.noPermissionAddToQueue') : undefined,
+    });
+  }
+  if ((useSlicerApi ? isApiSliceableFilename(file.filename) : isSliceableFilename(file.filename))
+      && (useSlicerApi ? onSlice : onOpenInSlicer)) {
+    menuItems.push({
+      label: t('slice.action'),
+      icon: useSlicerApi ? <Cog className="w-4 h-4" /> : <ExternalLink className="w-4 h-4" />,
+      onClick: () => { if (useSlicerApi) onSlice?.(file); else onOpenInSlicer?.(file); },
+      disabled: !canSlice,
+      title: !canSlice ? (useSlicerApi ? t('fileManager.noPermissionSlice') : t('fileManager.noPermissionDownload')) : undefined,
+    });
+  }
+  if (onRunPipeline && useSlicerApi && isApiSliceableFilename(file.filename)) {
+    menuItems.push({
+      label: t('library.runWithPipeline.actionLabel'),
+      icon: <Play className="w-4 h-4" />,
+      onClick: () => onRunPipeline(file),
+      disabled: !hasPermission('pipelines:run'),
+      title: !hasPermission('pipelines:run') ? t('library.runWithPipeline.noPermission') : undefined,
+    });
+  }
+  if (onPreview3d && (file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'stl' || file.file_type === 'gcode.3mf')) {
+    menuItems.push({
+      label: t('fileManager.preview3d'),
+      icon: <Box className="w-4 h-4" />,
+      onClick: () => onPreview3d(file),
+      disabled: !canPreview3d,
+      title: !canPreview3d ? t('fileManager.noPermissionPreview') : undefined,
+    });
+  }
+  menuItems.push({
+    label: t('common.download'),
+    icon: <Download className="w-4 h-4" />,
+    onClick: () => onDownload(file.id),
+    disabled: !hasPermission('library:read'),
+    title: !hasPermission('library:read') ? t('fileManager.noPermissionDownload') : undefined,
+  });
+  if (onRename) {
+    menuItems.push({
+      label: t('common.rename'),
+      icon: <Pencil className="w-4 h-4" />,
+      onClick: () => onRename(file),
+      disabled: !canRename,
+      title: !canRename ? t('fileManager.noPermissionRenameFile') : undefined,
+    });
+  }
+  if (onGenerateThumbnail && file.file_type === 'stl') {
+    menuItems.push({
+      label: t('fileManager.generateThumbnail'),
+      icon: <Image className="w-4 h-4" />,
+      onClick: () => onGenerateThumbnail(file),
+      disabled: !canRename,
+      title: !canRename ? t('fileManager.noPermissionGenerateThumbnail') : undefined,
+    });
+  }
+  menuItems.push({
+    label: t('common.delete'),
+    icon: <Trash2 className="w-4 h-4" />,
+    onClick: () => onDelete(file.id),
+    danger: true,
+    disabled: !canDelete,
+    title: !canDelete ? t('fileManager.noPermissionDeleteFile') : undefined,
+  });
 
   return (
     <div
-      className={`group relative bg-bambu-dark-secondary rounded-lg border transition-all cursor-pointer overflow-hidden ${
+      className={`group relative bg-bambu-dark-secondary rounded-lg border transition-all cursor-pointer ${
         isSelected
           ? 'border-bambu-green ring-1 ring-bambu-green'
           : 'border-bambu-dark-tertiary hover:border-bambu-green/50'
@@ -782,7 +866,7 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
       onClick={() => onSelect(file.id)}
     >
       {/* Thumbnail */}
-      <div className="aspect-square bg-bambu-dark flex items-center justify-center overflow-hidden">
+      <div className="aspect-square bg-bambu-dark flex items-center justify-center overflow-hidden rounded-t-lg">
         {file.thumbnail_path ? (
           <img
             src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersion ? ((api.getLibraryFileThumbnailUrl(file.id).includes('?') ? '&' : '?') + `v=${thumbnailVersion}`) : ''}`}
@@ -873,123 +957,18 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
       {/* Actions - always visible on mobile, hover on desktop */}
       <div className={`absolute bottom-2 right-2 transition-opacity ${isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`} onClick={(e) => e.stopPropagation()}>
         <button
-          onClick={() => setShowActions(!showActions)}
+          onClick={(e) => {
+            // No open/close toggle: the menu's own outside-mousedown handler
+            // has already dismissed it by the time this click lands.
+            const rect = e.currentTarget.getBoundingClientRect();
+            setMenuAnchor({ x: rect.left, y: rect.bottom + 4 });
+          }}
           className="p-1.5 rounded bg-bambu-dark-secondary/90 hover:bg-bambu-dark-tertiary"
         >
           <MoreVertical className="w-4 h-4 text-bambu-gray" />
         </button>
-        {showActions && (
-          <>
-            <div className="fixed inset-0 z-10" onClick={() => setShowActions(false)} />
-            <div className="absolute right-0 bottom-8 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[140px]">
-              {onPrint && isSlicedFilename(file.filename) && (
-                <button
-                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
-                    hasPermission('queue:create') ? 'text-bambu-green hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
-                  }`}
-                  onClick={() => { if (hasPermission('queue:create')) { onPrint(file); setShowActions(false); } }}
-                  disabled={!hasPermission('queue:create')}
-                  title={!hasPermission('queue:create') ? t('fileManager.noPermissionAddToQueue') : undefined}
-                >
-                  <Printer className="w-3.5 h-3.5" />
-                  {t('common.print')}
-                </button>
-              )}
-              {(useSlicerApi ? isApiSliceableFilename(file.filename) : isSliceableFilename(file.filename)) &&
-                (useSlicerApi ? onSlice : onOpenInSlicer) && (
-                <button
-                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
-                    canSlice ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
-                  }`}
-                  onClick={() => {
-                    if (!canSlice) return;
-                    if (useSlicerApi) onSlice?.(file);
-                    else onOpenInSlicer?.(file);
-                    setShowActions(false);
-                  }}
-                  disabled={!canSlice}
-                  title={!canSlice ? (useSlicerApi ? t('fileManager.noPermissionSlice') : t('fileManager.noPermissionDownload')) : undefined}
-                >
-                  {useSlicerApi ? <Cog className="w-3.5 h-3.5" /> : <ExternalLink className="w-3.5 h-3.5" />}
-                  {t('slice.action')}
-                </button>
-              )}
-              {onRunPipeline && useSlicerApi && isApiSliceableFilename(file.filename) && (
-                <button
-                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
-                    hasPermission('pipelines:run') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
-                  }`}
-                  onClick={() => { if (hasPermission('pipelines:run')) { onRunPipeline(file); setShowActions(false); } }}
-                  disabled={!hasPermission('pipelines:run')}
-                  title={!hasPermission('pipelines:run') ? t('library.runWithPipeline.noPermission') : undefined}
-                >
-                  <Play className="w-3.5 h-3.5" />
-                  {t('library.runWithPipeline.actionLabel')}
-                </button>
-              )}
-              {onPreview3d && (file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'stl' || file.file_type === 'gcode.3mf') && (
-                <button
-                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
-                    hasPermission('library:read') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
-                  }`}
-                  onClick={() => { if (hasPermission('library:read')) { onPreview3d(file); setShowActions(false); } }}
-                  disabled={!hasPermission('library:read')}
-                  title={!hasPermission('library:read') ? 'You do not have permission to preview files' : undefined}
-                >
-                  <Box className="w-3.5 h-3.5" />
-                  3D Preview
-                </button>
-              )}
-              <button
-                className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
-                  hasPermission('library:read') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
-                }`}
-                onClick={() => { if (hasPermission('library:read')) { onDownload(file.id); setShowActions(false); } }}
-                disabled={!hasPermission('library:read')}
-                title={!hasPermission('library:read') ? t('fileManager.noPermissionDownload') : undefined}
-              >
-                <Download className="w-3.5 h-3.5" />
-                {t('common.download')}
-              </button>
-              {onRename && (
-                <button
-                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
-                    canModify('library', 'update', file.created_by_id) ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
-                  }`}
-                  onClick={() => { if (canModify('library', 'update', file.created_by_id)) { onRename(file); setShowActions(false); } }}
-                  disabled={!canModify('library', 'update', file.created_by_id)}
-                  title={!canModify('library', 'update', file.created_by_id) ? t('fileManager.noPermissionRenameFile') : undefined}
-                >
-                  <Pencil className="w-3.5 h-3.5" />
-                  {t('common.rename')}
-                </button>
-              )}
-              {onGenerateThumbnail && file.file_type === 'stl' && (
-                <button
-                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
-                    canModify('library', 'update', file.created_by_id) ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
-                  }`}
-                  onClick={() => { if (canModify('library', 'update', file.created_by_id)) { onGenerateThumbnail(file); setShowActions(false); } }}
-                  disabled={!canModify('library', 'update', file.created_by_id)}
-                  title={!canModify('library', 'update', file.created_by_id) ? t('fileManager.noPermissionGenerateThumbnail') : undefined}
-                >
-                  <Image className="w-3.5 h-3.5" />
-                  {t('fileManager.generateThumbnail')}
-                </button>
-              )}
-              <button
-                className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
-                  canModify('library', 'delete', file.created_by_id) ? 'text-red-700 dark:text-red-400 hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
-                }`}
-                onClick={() => { if (canModify('library', 'delete', file.created_by_id)) { onDelete(file.id); setShowActions(false); } }}
-                disabled={!canModify('library', 'delete', file.created_by_id)}
-                title={!canModify('library', 'delete', file.created_by_id) ? t('fileManager.noPermissionDeleteFile') : undefined}
-              >
-                <Trash2 className="w-3.5 h-3.5" />
-                {t('common.delete')}
-              </button>
-            </div>
-          </>
+        {menuAnchor && (
+          <ContextMenu x={menuAnchor.x} y={menuAnchor.y} items={menuItems} onClose={() => setMenuAnchor(null)} />
         )}
       </div>
 

+ 319 - 52
frontend/src/pages/PrintersPage.tsx

@@ -2,8 +2,9 @@ import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } fr
 import { createPortal } from 'react-dom';
 import { compareFwVersions } from '../utils/firmwareVersion';
 import { formatPrintName } from '../utils/printName';
-import { computePopoverPosition } from '../utils/popoverPosition';
+import { computePopoverPosition, type PopoverPosition } from '../utils/popoverPosition';
 import { resolveDryingPresetKey, type DryingPreset } from '../utils/dryingPresets';
+import { computeStartAfter, type DryingStartMode } from '../lib/scheduledDrying';
 import {
   isExternalSpoolHidden,
   setExternalSpoolHidden as persistExternalSpoolHidden,
@@ -23,7 +24,45 @@ import {
 // earlier); under-estimating leaves the popover clipped off the bottom (the
 // original bug at #1447).
 const DRYING_POPOVER_WIDTH = 240;
-const DRYING_POPOVER_ESTIMATED_HEIGHT = 320;
+// Height in "now" mode plus the tallest start-mode control; a conservative
+// over-estimate just flips the popover above the trigger sooner.
+const DRYING_POPOVER_ESTIMATED_HEIGHT = 440;
+// Delay presets for the "After delay" drying start mode, in minutes.
+const DRYING_DELAY_OPTIONS = [30, 60, 120, 240, 480, 720, 1440];
+
+// Every printer card reads the same fleet-wide scheduled-drying list.
+const SCHEDULED_DRYINGS_KEY = ['scheduled-dryings'] as const;
+
+// Why a due run has not started yet: every waiting_reason the scheduler can
+// set. An unmapped one leaves the card showing a bare "Drying scheduled
+// for ..." with no hint why nothing is happening.
+const WAITING_REASON_KEYS: Record<string, string> = {
+  ams_power_required: 'printers.drying.powerRequired',
+  ams_retract_filament: 'printers.drying.retractFilament',
+  ams_blocked: 'printers.drying.cannotDryNow',
+  ams_not_found: 'printers.drying.waitingAmsNotFound',
+  printer_offline: 'printers.drying.waitingOffline',
+  printer_busy: 'printers.drying.waitingPrinterBusy',
+  already_drying: 'printers.drying.waitingAlreadyDrying',
+  interrupted: 'printers.drying.waitingInterrupted',
+};
+
+function waitingReasonKey(reason: string | null | undefined): string | undefined {
+  return reason ? WAITING_REASON_KEYS[reason] : undefined;
+}
+
+// Which cannot-dry code to name when the firmware reports several at once.
+// Same priority as the backend's drying_preflight.primary_reason_code, so the
+// button's tooltip and a scheduled row's waiting reason describe one blocked
+// AMS the same way: codes 1 and 8 are a power-supply problem, 3 is filament
+// left at the outlet, and both need the user to go and do something. The rest
+// (AMS busy, cooling down) clear by themselves and share the generic wording.
+function dryingBlockedKey(reasons: Array<number | string> | undefined): string {
+  const codes = (reasons ?? []).map(r => Number(r));
+  if (codes.some(c => c === 1 || c === 8)) return 'printers.drying.powerRequired';
+  if (codes.includes(3)) return 'printers.drying.retractFilament';
+  return 'printers.drying.cannotDryNow';
+}
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
@@ -98,7 +137,7 @@ import {
 // Aliased: lucide-react already exports a `Link` icon into this module.
 import { Link as RouterLink, useNavigate } from 'react-router-dom';
 import { api, discoveryApi, firmwareApi, withStreamToken, ApiError } from '../api/client';
-import { formatDateOnly, formatETA, formatDuration, parseUTCDate } from '../utils/date';
+import { formatDateOnly, formatDateTime, formatETA, formatDuration, formatDurationFromHours, parseUTCDate } from '../utils/date';
 import type { Printer, PrinterCreate, PrinterStatus, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo, SpoolAssignment, HMSError, InventorySpool, SmartPlug, PrinterDiagnosticResult } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
@@ -1883,6 +1922,77 @@ function buildCardScaleStyle(cardSize: number): React.CSSProperties {
   } as React.CSSProperties;
 }
 
+function ScheduledDryingBanner({ printerId, dryingActive, timeFormat }: { printerId: number; dryingActive: boolean; timeFormat: 'system' | '12h' | '24h' }) {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  // One fleet-wide query shared by every card, not one per card: the list is
+  // nearly always empty and a 20-printer fleet would otherwise make 20
+  // requests every 30s. React Query dedupes on the key.
+  const { data: scheduled = [] } = useQuery({
+    queryKey: SCHEDULED_DRYINGS_KEY,
+    queryFn: () => api.listScheduledDryings(),
+    refetchInterval: 30_000,
+  });
+  // The live AMS status reports a starting cycle well before the next poll;
+  // refetch so a just-dispatched schedule doesn't linger as pending.
+  useEffect(() => {
+    if (dryingActive) {
+      queryClient.invalidateQueries({ queryKey: SCHEDULED_DRYINGS_KEY });
+    }
+  }, [dryingActive, queryClient]);
+  const cancelMutation = useMutation({
+    mutationFn: (id: number) => api.cancelScheduledDrying(id),
+    onSuccess: () => queryClient.invalidateQueries({ queryKey: SCHEDULED_DRYINGS_KEY }),
+    onError: (error: Error) => showToast(error.message || t('printers.drying.scheduleFailed'), 'error'),
+  });
+  // Failed rows are shown too: dispatch is the only place a run can fail (too
+  // old firmware on a printer that was offline at schedule time), and without
+  // this the run just vanishes with only the backend log saying why.
+  const rows = scheduled.filter(s => s.printer_id === printerId && (s.status === 'pending' || s.status === 'failed'));
+  if (rows.length === 0) return null;
+  return (
+    <div className="mt-2 space-y-1">
+      {rows.map(s => {
+        const failed = s.status === 'failed';
+        const reasonKey = waitingReasonKey(s.waiting_reason);
+        return (
+          <div
+            key={s.id}
+            data-testid={failed ? 'scheduled-drying-failed' : 'scheduled-drying-pending'}
+            className={`flex items-center justify-between px-2 py-1 rounded-lg text-[length:var(--pc-t11,11px)] ${
+              failed ? 'bg-red-500/10 border border-red-500/30' : 'bg-amber-500/10 border border-amber-500/30'
+            }`}
+          >
+            <span className={failed ? 'text-red-700 dark:text-red-400' : 'text-amber-700 dark:text-amber-400'}>
+              {failed ? (
+                t('printers.drying.scheduleFailedReason', {
+                  reason: s.error_message || t('printers.drying.scheduleFailedUnknown'),
+                })
+              ) : (
+                <>
+                  {s.start_after
+                    ? t('printers.drying.scheduledFor', { time: formatDateTime(s.start_after, timeFormat) })
+                    : t('printers.drying.scheduledAsap')}
+                  {reasonKey && <span className="ml-1 opacity-80">{t(reasonKey)}</span>}
+                </>
+              )}
+            </span>
+            <button
+              onClick={() => cancelMutation.mutate(s.id)}
+              disabled={cancelMutation.isPending}
+              title={failed ? t('printers.drying.dismissFailed') : t('printers.drying.cancelScheduled')}
+              className="text-bambu-gray hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+            >
+              <X className="w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)]" />
+            </button>
+          </div>
+        );
+      })}
+    </div>
+  );
+}
+
 function PrinterCard({
   printer,
   hideIfDisconnected,
@@ -2011,7 +2121,57 @@ function PrinterCard({
   const [dryingTemp, setDryingTemp] = useState(50);
   const [dryingDuration, setDryingDuration] = useState(4);
   const [dryingRotateTray, setDryingRotateTray] = useState(false);
-  const [dryingPopoverPos, setDryingPopoverPos] = useState<{ top: number; left: number } | null>(null);
+  // Drying start mode (#2638): 'now' starts immediately; 'delay' and 'at_time'
+  // go through scheduleDryingMutation.
+  const [dryingStartMode, setDryingStartMode] = useState<DryingStartMode>('now');
+  const [dryingDelayMinutes, setDryingDelayMinutes] = useState(120);
+  const [dryingStartAt, setDryingStartAt] = useState('');
+  const [dryingPopoverPos, setDryingPopoverPos] = useState<PopoverPosition | null>(null);
+  const [dryingPopoverAnchor, setDryingPopoverAnchor] = useState<HTMLElement | null>(null);
+  // Reset every field the popover owns, including the start mode. Leaving the
+  // mode alone let an "At time" timestamp from an earlier schedule persist into
+  // the next open, by then in the past, and the POST rejected it.
+  const openDryingPopover = useCallback((ams: AMSUnit, trigger: HTMLElement) => {
+    const firstTray = ams.tray.find(t => t.tray_type);
+    const filType = resolveDryingPresetKey(firstTray?.tray_type, dryingPresets);
+    // Only reachable if a custom preset set dropped PLA itself.
+    const preset = dryingPresets[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);
+    setDryingRotateTray(false);
+    setDryingStartMode('now');
+    setDryingDelayMinutes(120);
+    setDryingStartAt('');
+    setDryingPopoverModuleType(ams.module_type);
+    setDryingPopoverAmsId(ams.id);
+    setDryingPopoverAnchor(trigger);
+  }, [dryingPresets]);
+  // Re-measure on resize/scroll so the popover and its arrow track the
+  // flame button, like IndicatorControlPopover.
+  useLayoutEffect(() => {
+    if (dryingPopoverAmsId === null || !dryingPopoverAnchor) return;
+    const measure = () => {
+      setDryingPopoverPos(computePopoverPosition({
+        triggerRect: dryingPopoverAnchor.getBoundingClientRect(),
+        popoverWidth: DRYING_POPOVER_WIDTH,
+        estimatedHeight: DRYING_POPOVER_ESTIMATED_HEIGHT,
+        horizontalAlign: 'center',
+      }));
+    };
+    measure();
+    window.addEventListener('resize', measure);
+    window.addEventListener('scroll', measure, true);
+    return () => {
+      window.removeEventListener('resize', measure);
+      window.removeEventListener('scroll', measure, true);
+    };
+  }, [dryingPopoverAmsId, dryingPopoverAnchor]);
+  const dryingAtTimeInputRef = useRef<HTMLInputElement | null>(null);
+  // Whether the click hitting the backdrop is the one that dismissed the
+  // native datetime picker (see the backdrop's onMouseDown).
+  const dryingBackdropSkipCloseRef = useRef(false);
   // Which AMS we are waiting on to actually enter a drying cycle (#2533). Held as
   // an object rather than a bare id so restarting drying on the SAME unit produces
   // a new identity and rearms the timeout below.
@@ -2475,6 +2635,25 @@ function PrinterCard({
     onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),
   });
 
+  // Scheduled (delayed / at-time) drying runs (#2638)
+  const scheduleDryingMutation = useMutation({
+    mutationFn: (params: { amsId: number; temp: number; duration: number; filament: string; rotateTray: boolean; startAfter: string }) =>
+      api.createScheduledDrying({
+        printer_id: printer.id,
+        ams_id: params.amsId,
+        temp: params.temp,
+        duration_hours: params.duration,
+        filament: params.filament,
+        rotate_tray: params.rotateTray,
+        start_after: params.startAfter,
+      }),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: SCHEDULED_DRYINGS_KEY });
+      setDryingPopoverAmsId(null);
+    },
+    onError: (error: Error) => showToast(error.message || t('printers.drying.scheduleFailed'), 'error'),
+  });
+
   const stopDryingMutation = useMutation({
     mutationFn: (amsId: number) => api.stopDrying(printer.id, amsId),
     onSuccess: () => {
@@ -4941,19 +5120,7 @@ function PrinterCard({
                                         } else if (dryingPopoverAmsId === ams.id) {
                                           setDryingPopoverAmsId(null);
                                         } else {
-                                          const firstTray = ams.tray.find(t => t.tray_type);
-                                          const filType = resolveDryingPresetKey(firstTray?.tray_type, dryingPresets);
-                                          // Only reachable if a custom preset set dropped PLA itself.
-                                          const preset = dryingPresets[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);
-                                          setDryingRotateTray(false);
-                                          setDryingPopoverModuleType(ams.module_type);
-                                          setDryingPopoverAmsId(ams.id);
-                                          const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
-                                          setDryingPopoverPos(computePopoverPosition({ triggerRect: rect, popoverWidth: DRYING_POPOVER_WIDTH, estimatedHeight: DRYING_POPOVER_ESTIMATED_HEIGHT, horizontalAlign: 'center' }));
+                                          openDryingPopover(ams, e.currentTarget as HTMLElement);
                                         }
                                       }}
                                       className={`ml-1 flex items-center gap-0.5 px-1 py-0.5 rounded text-[length:var(--pc-t9,9px)] transition-colors ${
@@ -4963,7 +5130,7 @@ function PrinterCard({
                                             ? 'bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'
                                             : 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark/80'
                                       }`}
-                                      title={status.drying_screen_only ? t('printers.drying.screenOnly') : ams.dry_time > 0 ? t('printers.drying.stop') : ams.dry_sf_reason?.length ? t('printers.drying.powerRequired') : t('printers.drying.start')}
+                                      title={status.drying_screen_only ? t('printers.drying.screenOnly') : ams.dry_time > 0 ? t('printers.drying.stop') : ams.dry_sf_reason?.length ? t(dryingBlockedKey(ams.dry_sf_reason)) : t('printers.drying.start')}
                                     >
                                       <Flame className="w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)]" />
                                     </button>
@@ -5492,19 +5659,7 @@ function PrinterCard({
                                       } else if (dryingPopoverAmsId === ams.id) {
                                         setDryingPopoverAmsId(null);
                                       } else {
-                                        const firstTray = ams.tray.find(t => t.tray_type);
-                                        const filType = resolveDryingPresetKey(firstTray?.tray_type, dryingPresets);
-                                        // Only reachable if a custom preset set dropped PLA itself.
-                                        const preset = dryingPresets[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);
-                                        setDryingRotateTray(false);
-                                        setDryingPopoverModuleType(ams.module_type);
-                                        setDryingPopoverAmsId(ams.id);
-                                        const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
-                                        setDryingPopoverPos(computePopoverPosition({ triggerRect: rect, popoverWidth: DRYING_POPOVER_WIDTH, estimatedHeight: DRYING_POPOVER_ESTIMATED_HEIGHT, horizontalAlign: 'center' }));
+                                        openDryingPopover(ams, e.currentTarget as HTMLElement);
                                       }
                                     }}
                                     className={`flex items-center gap-0.5 px-1 py-0.5 rounded text-[length:var(--pc-t9,9px)] transition-colors ${
@@ -6157,6 +6312,15 @@ function PrinterCard({
         </div>
           </div>
         )}
+
+        {/* Scheduled Drying Banner -- inside CardContent so it picks up the
+            card's horizontal padding instead of running full-bleed into the
+            rounded bottom edge. */}
+        <ScheduledDryingBanner
+          printerId={printer.id}
+          dryingActive={amsData.some(a => (a.dry_time ?? 0) > 0)}
+          timeFormat={timeFormat}
+        />
       </CardContent>
 
       {/* File Manager Modal */}
@@ -6761,22 +6925,45 @@ function PrinterCard({
         return (
           <>
             {/* Backdrop */}
-            <div className="fixed inset-0 z-[100]" onClick={() => setDryingPopoverAmsId(null)} />
-            {/* Popover */}
+            <div
+              className="fixed inset-0 z-[100]"
+              data-testid="drying-popover-backdrop"
+              onMouseDown={() => {
+                // The click that dismisses the native datetime picker lands
+                // here; it must not close the popover. The input is still
+                // focused at mousedown, so remember that and swallow the
+                // matching click.
+                dryingBackdropSkipCloseRef.current =
+                  dryingAtTimeInputRef.current !== null &&
+                  document.activeElement === dryingAtTimeInputRef.current;
+              }}
+              onClick={() => {
+                if (dryingBackdropSkipCloseRef.current) {
+                  dryingBackdropSkipCloseRef.current = false;
+                  return;
+                }
+                setDryingPopoverAmsId(null);
+              }}
+            />
+            {/* An 'above' popover is anchored by its bottom edge (CSS
+                bottom) so late-appearing content grows it upward, staying on
+                screen and glued to the trigger. maxHeight caps to the space
+                on the anchored side; when the viewport is too short the body
+                scrolls and the footer stays pinned. dvh so iOS Safari's
+                bottom toolbar doesn't clip the footer. */}
             <div
               className="fixed z-[101] flex flex-col w-[240px] bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl overflow-hidden"
               style={{
-                top: dryingPopoverPos.top,
                 left: dryingPopoverPos.left,
-                // Cap to the space between the popover's top and the bottom
-                // viewport margin (8px, matching computePopoverPosition's
-                // margin). When the popover is taller than that space — short
-                // viewport, landscape phone, zoomed-in — the body scrolls and
-                // the footer stays pinned, so the Start button is always
-                // reachable (#1458 / #1447 follow-up). dvh (not vh) so iOS
-                // Safari's bottom toolbar overlay doesn't clip the footer
-                // (#1669, iPhone 17 Safari).
-                maxHeight: `calc(100dvh - ${dryingPopoverPos.top}px - 8px)`,
+                ...(dryingPopoverPos.placement === 'above'
+                  ? {
+                      bottom: `calc(100dvh - ${dryingPopoverPos.anchorY}px)`,
+                      maxHeight: `${dryingPopoverPos.anchorY - 8}px`,
+                    }
+                  : {
+                      top: dryingPopoverPos.top,
+                      maxHeight: `calc(100dvh - ${dryingPopoverPos.top}px - 8px)`,
+                    }),
               }}
               onClick={e => e.stopPropagation()}
             >
@@ -6899,6 +7086,57 @@ function PrinterCard({
                     </button>
                   );
                 })()}
+                {/* Start mode: now / after delay / at time (#2638) */}
+                <div>
+                  <label className="text-[10px] text-white/70 font-medium mb-1 block">{t('printers.drying.startMode')}</label>
+                  <div className="grid grid-cols-3 gap-1">
+                    {(['now', 'delay', 'at_time'] as const).map(mode => (
+                      <button
+                        key={mode}
+                        type="button"
+                        onClick={() => setDryingStartMode(mode)}
+                        className={`py-1 rounded-lg border text-[10px] font-medium transition-colors ${
+                          dryingStartMode === mode
+                            ? 'bg-bambu-green border-bambu-green text-white'
+                            : 'bg-bambu-dark border-bambu-dark-tertiary text-white hover:bg-bambu-dark-tertiary'
+                        }`}
+                      >
+                        {t(mode === 'now' ? 'printers.drying.modeNow' : mode === 'delay' ? 'printers.drying.modeDelay' : 'printers.drying.modeAtTime')}
+                      </button>
+                    ))}
+                  </div>
+                  {dryingStartMode === 'delay' && (
+                    // Inline chips: a dropdown menu would be clipped by
+                    // the popover's scrollable body.
+                    <div className="mt-1.5 grid grid-cols-4 gap-1">
+                      {DRYING_DELAY_OPTIONS.map(min => (
+                        <button
+                          key={min}
+                          type="button"
+                          aria-pressed={dryingDelayMinutes === min}
+                          onClick={() => setDryingDelayMinutes(min)}
+                          className={`py-1 rounded-lg border text-[10px] font-medium transition-colors ${
+                            dryingDelayMinutes === min
+                              ? 'bg-bambu-green border-bambu-green text-white'
+                              : 'bg-bambu-dark border-bambu-dark-tertiary text-white hover:bg-bambu-dark-tertiary'
+                          }`}
+                        >
+                          {formatDurationFromHours(min / 60)}
+                        </button>
+                      ))}
+                    </div>
+                  )}
+                  {dryingStartMode === 'at_time' && (
+                    <input
+                      ref={dryingAtTimeInputRef}
+                      type="datetime-local"
+                      data-testid="drying-start-at"
+                      value={dryingStartAt}
+                      onChange={e => setDryingStartAt(e.target.value)}
+                      className="mt-1.5 w-full px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-[11px] focus:outline-none focus:border-bambu-green [color-scheme:dark]"
+                    />
+                  )}
+                </div>
               </div>
               <div className="shrink-0 h-px bg-bambu-dark-tertiary" />
               {/* Footer */}
@@ -6916,23 +7154,52 @@ function PrinterCard({
                       const trayLoadedInThisAms = (targetAms?.tray ?? []).some(
                         tray => tray.state === 11,
                       );
-                      startDryingMutation.mutate({
-                        amsId: dryingPopoverAmsId,
-                        temp: dryingTemp,
-                        duration: dryingDuration,
-                        filament: dryingFilament,
-                        rotateTray: dryingRotateTray && !trayLoadedInThisAms,
-                      });
+                      const rotate = dryingRotateTray && !trayLoadedInThisAms;
+                      if (dryingStartMode === 'now') {
+                        startDryingMutation.mutate({
+                          amsId: dryingPopoverAmsId,
+                          temp: dryingTemp,
+                          duration: dryingDuration,
+                          filament: dryingFilament,
+                          rotateTray: rotate,
+                        });
+                      } else {
+                        const startAfter = computeStartAfter(dryingStartMode, dryingDelayMinutes, dryingStartAt);
+                        if (startAfter) {
+                          scheduleDryingMutation.mutate({
+                            amsId: dryingPopoverAmsId,
+                            temp: dryingTemp,
+                            duration: dryingDuration,
+                            filament: dryingFilament,
+                            rotateTray: rotate,
+                            startAfter,
+                          });
+                        }
+                      }
                     }
                   }}
-                  disabled={startDryingMutation.isPending}
+                  disabled={startDryingMutation.isPending || scheduleDryingMutation.isPending || (dryingStartMode === 'at_time' && !dryingStartAt)}
                   data-testid="drying-start-confirm"
                   className="w-full py-1.5 bg-bambu-green hover:bg-bambu-green/80 text-white text-xs font-medium rounded-lg transition-colors disabled:opacity-50"
                 >
-                  {startDryingMutation.isPending ? t('printers.drying.startingDrying') : t('printers.drying.start')}
+                  {dryingStartMode === 'now'
+                    ? (startDryingMutation.isPending ? t('printers.drying.startingDrying') : t('printers.drying.start'))
+                    : t('printers.drying.schedule')}
                 </button>
               </div>
             </div>
+            {/* Anchor arrow pointing at the flame button; a fixed sibling
+                since the popover clips its own overflow. */}
+            <div
+              className="fixed z-[102] w-2.5 h-2.5 rotate-45 bg-bambu-dark-secondary border-bambu-dark-tertiary pointer-events-none"
+              style={{
+                left: dryingPopoverPos.left + dryingPopoverPos.arrowLeft - 5,
+                top: dryingPopoverPos.anchorY - 5,
+                ...(dryingPopoverPos.placement === 'above'
+                  ? { borderRightWidth: 1, borderBottomWidth: 1 }
+                  : { borderLeftWidth: 1, borderTopWidth: 1 }),
+              }}
+            />
           </>
         );
       })()}

+ 30 - 8
frontend/src/utils/popoverPosition.ts

@@ -1,6 +1,20 @@
 export interface PopoverPosition {
   top: number;
   left: number;
+  /** Which side of the trigger the popover landed on. */
+  placement: 'below' | 'above';
+  /**
+   * Viewport y of the popover edge facing the trigger: the top edge for
+   * 'below' (equals `top`), the bottom edge for 'above'. Anchoring an
+   * 'above' popover by this edge (CSS `bottom`) lets late-appearing content
+   * grow it upward, keeping it glued to the trigger.
+   */
+  anchorY: number;
+  /**
+   * X-offset within the popover for an anchor arrow pointing at the
+   * trigger's center, clamped inside the popover's rounded corners.
+   */
+  arrowLeft: number;
 }
 
 interface RectLike {
@@ -38,10 +52,10 @@ export interface ComputePopoverPositionOpts {
  * Compute fixed-positioning coordinates for a popover anchored to a trigger.
  *
  * Default placement is BELOW the trigger, right-aligned to the trigger. Flips
- * to ABOVE the trigger when below would overflow the viewport (#1447 — the
- * AMS drying popover on the printer card sits at the bottom of the AMS row
- * and was rendering off the bottom of the viewport with the Start button
- * unreachable on smaller screens).
+ * to ABOVE the trigger when below would overflow the viewport and above fits
+ * (#1447: the AMS drying popover on the printer card sits at the bottom of
+ * the AMS row and was rendering off the bottom of the viewport with the Start
+ * button unreachable on smaller screens).
  *
  * Horizontal axis right-aligns to triggerRect.right and clamps to the
  * viewport with the configured margin so a trigger near the right edge
@@ -72,15 +86,19 @@ export function computePopoverPosition(opts: ComputePopoverPositionOpts): Popove
 
   // Vertical: prefer below, flip to above only when below overflows AND
   // above would actually fit. If neither fits (a popover taller than the
-  // viewport), stay below — at least the top of the popover is visible
-  // and the user can scroll inside it, which is better than flipping to a
-  // top-clipped position where the action buttons might also be unreachable.
+  // viewport), stay below: at least the top of the popover is visible and
+  // the user can scroll to the rest, which beats flipping to a top-clipped
+  // position where the action buttons might also be unreachable (#1447,
+  // #1458, #1669). Callers that cap their own height still rely on this;
+  // IndicatorControlPopover sets no maxHeight at all.
   let top = triggerRect.bottom + gap;
+  let placement: PopoverPosition['placement'] = 'below';
   const wouldOverflowBottom = top + estimatedHeight > viewportHeight - margin;
   if (wouldOverflowBottom) {
     const aboveTop = triggerRect.top - gap - estimatedHeight;
     if (aboveTop >= margin) {
       top = aboveTop;
+      placement = 'above';
     }
   }
 
@@ -95,5 +113,9 @@ export function computePopoverPosition(opts: ComputePopoverPositionOpts): Popove
     left = Math.max(margin, viewportWidth - popoverWidth - margin);
   }
 
-  return { top, left };
+  const anchorY = placement === 'above' ? triggerRect.top - gap : top;
+  // Keep the arrow clear of the popover's rounded corners.
+  const arrowLeft = Math.max(14, Math.min(popoverWidth - 14, triggerCenter - left));
+
+  return { top, left, placement, anchorY, arrowLeft };
 }

Разница между файлами не показана из-за своего большого размера
+ 0 - 1
static/assets/index-1Ya6fAmN.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-D5Du0dc3.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
static/assets/index-VSpFxsmE.css


+ 2 - 2
static/index.html

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

Некоторые файлы не были показаны из-за большого количества измененных файлов