ソースを参照

Feature: Scheduled drying (#2703)

* feat: add ScheduledDrying model for delayed drying runs (#2638)

* Release the printer when a scheduled dry ends (#2638)

_check_scheduled_dryings marks a printer as drying in _drying_in_progress,
which is shared with auto-drying. Auto-drying prunes that map in
_sync_drying_state(), but that call sits behind its enabled check, and this is
the first writer that runs whether auto-drying is on or not. With it off --
the default -- nothing dropped the entry short of a print being dispatched to
the same printer, so the next scheduled run parked on "already_drying"
forever and queue_drying_block held that printer's prints too. A nightly
off-peak dry with no printing in between is exactly the workflow this feature
is for: night one worked, every night after it silently did not. The check now
releases what it acquired, covering both a run that ends mid-pass and one
cancelled through the route between passes.

The retention prune ran on every pass. Issuing the DELETE is what opens a
write transaction, this method is called every 3s while the queue dispatches,
and rows only become prunable a week after they finish, so it is now gated to
hourly on a monotonic stamp -- with the first pass after a restart still
reaping whatever the dead process left behind.

Both drying paths now pick the blocking dry_sf_reason through one rule.
The immediate endpoint quoted whichever code the firmware listed first while
the scheduler prioritised power over retract, so one blocked AMS read two ways
depending on which button you pressed. drying_preflight.primary_reason_code
holds the order and both call it, including the flame button's tooltip, which
had no wording for filament at the outlet at all and sent those users to the
generic "can't start drying right now".

scheduled_drying joins the model list in init_db. The table was already
created -- importing the package registers it -- but it was the only model
relying on that indirection.

Tests: the release (completion and route-cancel), the prune throttle, the
shared reason rule, the tooltip priority, and four driving the real check_queue,
which nothing covered before -- a pass with no rows still dispatching prints, a
due row dispatching, and a failed row not stalling the queue behind it. Each
one fails against the code it guards.

---------

Co-authored-by: MartinNYHC <martin@bambuddy.cool>
Co-authored-by: maziggy <mz@v8w.de>
Ben Hamilton (Ben Gertzfield) 3 週間 前
コミット
d37ce94f81
42 ファイル変更2857 行追加111 行削除
  1. 19 43
      backend/app/api/routes/printers.py
  2. 107 0
      backend/app/api/routes/scheduled_dryings.py
  3. 1 0
      backend/app/core/database.py
  4. 2 0
      backend/app/main.py
  5. 2 0
      backend/app/models/__init__.py
  6. 47 0
      backend/app/models/scheduled_drying.py
  7. 43 0
      backend/app/schemas/scheduled_drying.py
  8. 117 0
      backend/app/services/drying_preflight.py
  9. 212 3
      backend/app/services/print_scheduler.py
  10. 1 0
      backend/tests/conftest.py
  11. 24 0
      backend/tests/integration/test_printers_api.py
  12. 86 0
      backend/tests/unit/test_drying_preflight.py
  13. 34 0
      backend/tests/unit/test_scheduled_drying_model.py
  14. 199 0
      backend/tests/unit/test_scheduled_drying_routes.py
  15. 45 0
      backend/tests/unit/test_scheduled_drying_schema.py
  16. 12 1
      backend/tests/unit/test_scheduler_clear_plate.py
  17. 619 0
      backend/tests/unit/test_scheduler_scheduled_drying.py
  18. 186 0
      backend/tests/unit/test_scheduler_scheduled_drying_check_queue.py
  19. 21 0
      frontend/src/__tests__/lib/scheduledDrying.test.ts
  20. 361 0
      frontend/src/__tests__/pages/PrintersPageDryingStartModes.test.tsx
  21. 64 0
      frontend/src/__tests__/utils/popoverPosition.test.ts
  22. 40 0
      frontend/src/api/client.ts
  23. 19 0
      frontend/src/i18n/locales/de.ts
  24. 20 0
      frontend/src/i18n/locales/en.ts
  25. 19 0
      frontend/src/i18n/locales/es.ts
  26. 19 0
      frontend/src/i18n/locales/fr.ts
  27. 19 0
      frontend/src/i18n/locales/it.ts
  28. 19 0
      frontend/src/i18n/locales/ja.ts
  29. 20 1
      frontend/src/i18n/locales/ko.ts
  30. 19 0
      frontend/src/i18n/locales/pt-BR.ts
  31. 19 0
      frontend/src/i18n/locales/ru.ts
  32. 19 0
      frontend/src/i18n/locales/tr.ts
  33. 19 0
      frontend/src/i18n/locales/uk.ts
  34. 19 0
      frontend/src/i18n/locales/zh-CN.ts
  35. 19 0
      frontend/src/i18n/locales/zh-TW.ts
  36. 14 0
      frontend/src/lib/scheduledDrying.ts
  37. 319 52
      frontend/src/pages/PrintersPage.tsx
  38. 30 8
      frontend/src/utils/popoverPosition.ts
  39. 0 1
      static/assets/index-1Ya6fAmN.css
  40. 0 0
      static/assets/index-DS0_D3go.js
  41. 1 0
      static/assets/index-IhvHtjfG.css
  42. 2 2
      static/index.html

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

+ 2 - 0
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,
@@ -8662,6 +8663,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}

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

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

+ 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):

+ 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]

+ 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());
+  });
+});

+ 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 }>(

+ 19 - 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.',

+ 20 - 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: {

+ 19 - 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.',

+ 19 - 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.",

+ 19 - 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.',

+ 19 - 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です。クリックして無効化します。',

+ 20 - 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 필라멘트 백업이 켜져 있습니다. 비활성화하려면 클릭하세요.',

+ 19 - 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.',

+ 19 - 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 включён. Нажмите, чтобы отключить.",

+ 19 - 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.',

+ 19 - 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: {

+ 19 - 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 备用料盘已开启。点击以禁用。',

+ 19 - 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 備用料盤已開啟。點擊以停用。',

+ 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();
+}

+ 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-DS0_D3go.js


ファイルの差分が大きいため隠しています
+ 1 - 0
static/assets/index-IhvHtjfG.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-DS0_D3go.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-IhvHtjfG.css">
   </head>
   <body>
     <div id="root"></div>

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません