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

feat(scheduler): preheat & heat-soak before queued prints with per-filament chamber targets + airduct flap control (#1468)

New scheduler stage that heats the bed (and the chamber, on supported
printers) and holds at temperature before each queued print starts —
the heat-soak engineering filaments need for adhesion and warp
control. Bambuddy waits between FTP upload and start_print, so the
soak runs while the printer is otherwise idle. M191 is silently
ignored by Bambu firmware, so doing this at the orchestration layer
is the only place it works.

Resolution order at dispatch:

1. PrintQueueItem.preheat_override ∈ {inherit, on, off}.
   'off' skips entirely; 'inherit' falls back to the global
   preheat_enabled toggle; 'on' forces the stage even when the
   global is off.

2. chamber_target = item.preheat_chamber_target_override
                 ?? max(filament_map[normalize(t.tray_type)] for loaded slots)
                 ?? 0.
   Mixed PA+PLA picks PA's 50 (max-across-slots — PA's chamber
   requirement is binding, PLA doesn't suffer being warm). PLA-only
   derives 0 and skips the chamber phase automatically.

3. Three hardware tiers for chamber heat:
   - Active chamber heater (H2C/H2D/H2D Pro/H2S/X2D/X1E) → M141 +
     chamber-sensor wait
   - Chamber sensor only (X1C/P2S) → no M141, passive bed-radiation
     wait with hard max-wait cap
   - No chamber sensor (P1S/P1P/A1/A1 Mini) → bed + soak timer only

4. Airduct flap (H2C/H2D/H2D Pro/H2S/X2D/P2S) auto-switches to
   match the chamber target — heating mode for engineering
   filaments, cooling mode for PLA. Bambu firmware does NOT
   auto-switch the flap with M141, so without this an ABS print
   on a previously-cooling flap fights the open exhaust, and a
   PLA print on a previously-hot flap recirculates ABS heat.
   Idempotent: only fires set_airduct_mode when current ≠ desired.

Settings → Workflow → Queue & Dispatch → Preheat & Heat Soak card:
master enable toggle (default off — disabled installs see no change),
per-filament chamber-target editor (replaces a single global int that
shipped in the first cut and couldn't serve PA + PLA in the same
config), preheat_max_wait_seconds, preheat_soak_seconds. The Print
Options panel in PrintModal gets a Preheat sub-section with the
tri-state Inherit/On/Off control and an optional chamber-target
override input.

DB migration: PrintQueueItem gains preheat_override VARCHAR(10)
DEFAULT 'inherit' and preheat_chamber_target_override INTEGER NULL.
Idempotent via _safe_execute. Existing rows behave exactly as before
the migration.

Best-effort throughout: printer drops, refused M141 or set_airduct,
missing bed temp, lost MQTT state mid-wait all log and return cleanly.
Normal upload + start path runs after this returns regardless.
maziggy 2 месяцев назад
Родитель
Сommit
61a7f2e4ac
33 измененных файлов с 1600 добавлено и 17 удалено
  1. 1 0
      CHANGELOG.md
  2. 1 0
      README.md
  3. 4 0
      backend/app/api/routes/print_queue.py
  4. 3 0
      backend/app/api/routes/settings.py
  5. 15 0
      backend/app/core/database.py
  6. 9 0
      backend/app/models/print_queue.py
  7. 12 1
      backend/app/schemas/print_queue.py
  8. 41 0
      backend/app/schemas/settings.py
  9. 309 0
      backend/app/services/print_scheduler.py
  10. 45 0
      backend/app/services/printer_manager.py
  11. 568 0
      backend/tests/unit/test_scheduler_preheat.py
  12. 17 0
      frontend/src/api/client.ts
  13. 62 0
      frontend/src/components/PreheatFilamentTargetsEditor.tsx
  14. 75 4
      frontend/src/components/PrintModal/PrintOptions.tsx
  15. 4 0
      frontend/src/components/PrintModal/index.tsx
  16. 10 0
      frontend/src/components/PrintModal/types.ts
  17. 19 0
      frontend/src/i18n/locales/de.ts
  18. 19 0
      frontend/src/i18n/locales/en.ts
  19. 19 0
      frontend/src/i18n/locales/es.ts
  20. 19 0
      frontend/src/i18n/locales/fr.ts
  21. 19 0
      frontend/src/i18n/locales/it.ts
  22. 19 0
      frontend/src/i18n/locales/ja.ts
  23. 19 0
      frontend/src/i18n/locales/ko.ts
  24. 19 0
      frontend/src/i18n/locales/pt-BR.ts
  25. 19 0
      frontend/src/i18n/locales/tr.ts
  26. 19 0
      frontend/src/i18n/locales/zh-CN.ts
  27. 19 0
      frontend/src/i18n/locales/zh-TW.ts
  28. 116 9
      frontend/src/pages/SettingsPage.tsx
  29. 96 0
      frontend/src/utils/preheatFilamentTargets.ts
  30. 0 1
      static/assets/index-BCPW417s.css
  31. 1 0
      static/assets/index-BxVhuRti.css
  32. 0 0
      static/assets/index-bqgSupVY.js
  33. 2 2
      static/index.html

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


+ 1 - 0
README.md

@@ -209,6 +209,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 - Queue Only mode (stage without auto-start)
 - Clear plate confirmation between queued prints (can be disabled in settings for farm workflows)
 - Auto-print G-code injection (per-model start/end snippets for Farmloop, SwapMod, AutoClear, Printflow 3D — toggle per queue item)
+- **Preheat & Heat Soak before queued prints** — Heat the bed (and the chamber, on supported printers) and hold at temperature between FTP upload and print start. Per-print Inherit / On / Off override in the Print Options panel; per-filament chamber-target map under Settings → Workflow so PA wants 50°C, ABS 45°C, PETG-CF 40°C, PLA 0°C (skips chamber phase automatically). Hardware-aware: H-series / X2D / X1E actively heat the chamber via M141; X1C / P2S rely on bed radiation with a chamber-sensor wait; P1S / P1P / A1 family have no chamber sensor so only the soak timer applies. The cooling/heating airduct flap on H-series / X2D / P2S auto-switches to match the resolved chamber target — preheat for ABS opens nothing and recirculates warm air; preheat for PLA opens the exhaust and vents — so engineering filaments actually reach target instead of fighting the open flap, and PLA prints don't inherit a previously-hot recirculation. M191 (wait-for-chamber-temp) isn't honoured by Bambu firmware, so doing this at the orchestration layer is the only place it works
 - Smart plug integration (Tasmota, Home Assistant, MQTT, REST/Webhook)
 - REST smart plugs: Control any device with an HTTP API (openHAB, ioBroker, FHEM, Node-RED) with separate power/energy URLs and unit multipliers
 - MQTT smart plugs: Subscribe to Zigbee2MQTT, Shelly, or any MQTT topic for energy monitoring

+ 4 - 0
backend/app/api/routes/print_queue.py

@@ -187,6 +187,8 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "timelapse": item.timelapse,
         "use_ams": item.use_ams,
         "nozzle_offset_cali": item.nozzle_offset_cali,
+        "preheat_override": item.preheat_override,
+        "preheat_chamber_target_override": item.preheat_chamber_target_override,
         "status": item.status,
         "started_at": item.started_at,
         "completed_at": item.completed_at,
@@ -634,6 +636,8 @@ async def add_to_queue(
             timelapse=data.timelapse,
             use_ams=data.use_ams,
             nozzle_offset_cali=data.nozzle_offset_cali,
+            preheat_override=data.preheat_override,
+            preheat_chamber_target_override=data.preheat_chamber_target_override,
             gcode_injection=data.gcode_injection,
             cleanup_library_after_dispatch=data.cleanup_library_after_dispatch,
             project_id=data.project_id,

+ 3 - 0
backend/app/api/routes/settings.py

@@ -140,6 +140,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "ldap_enabled",
             "ldap_auto_provision",
             "local_login_enabled",
+            "preheat_enabled",
         ]:
             settings_dict[setting.key] = setting.value.lower() == "true"
         elif setting.key in [
@@ -165,6 +166,8 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "forecast_global_lead_time_days",
             "session_max_hours",
             "pipeline_max_copies",
+            "preheat_max_wait_seconds",
+            "preheat_soak_seconds",
         ]:
             settings_dict[setting.key] = int(setting.value)
         elif setting.key == "default_printer_id":

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

@@ -1178,6 +1178,21 @@ async def run_migrations(conn):
     else:
         await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzle_offset_cali BOOLEAN DEFAULT TRUE")
 
+    # Migration: Per-item preheat / heat-soak override (#1468). preheat_override
+    # is one of {inherit, on, off} — 'inherit' falls back to the global
+    # preheat_enabled setting; 'on' / 'off' force the decision. The chamber
+    # target column overrides the filament-map derivation when not null.
+    # Existing rows default to 'inherit' + NULL so behaviour is unchanged for
+    # in-flight queues.
+    await _safe_execute(
+        conn,
+        "ALTER TABLE print_queue ADD COLUMN preheat_override VARCHAR(10) DEFAULT 'inherit'",
+    )
+    await _safe_execute(
+        conn,
+        "ALTER TABLE print_queue ADD COLUMN preheat_chamber_target_override INTEGER",
+    )
+
     # Migration: Add library_file_id column to print_queue and make archive_id nullable
     # This allows queue items to reference library files directly (archive created at print start)
     try:

+ 9 - 0
backend/app/models/print_queue.py

@@ -91,6 +91,15 @@ class PrintQueueItem(Base):
     # Nozzle offset calibration — dual-nozzle printers only, MQTT-gated (#1682)
     nozzle_offset_cali: Mapped[bool] = mapped_column(Boolean, default=True)
 
+    # Preheat / heat-soak override (#1468). 'inherit' uses the global
+    # preheat_enabled setting; 'on' / 'off' force the per-item decision. The
+    # chamber target falls through: per-item override → max(filament-map[loaded
+    # tray type]) → 0 (skips chamber phase). 'inherit' + global off + override
+    # null = no preheat. Default 'inherit' so existing queue items behave
+    # exactly as before the migration.
+    preheat_override: Mapped[str] = mapped_column(String(10), default="inherit")
+    preheat_chamber_target_override: Mapped[int | None] = mapped_column(Integer, nullable=True)
+
     # Status: pending, printing, completed, failed, skipped, cancelled
     status: Mapped[str] = mapped_column(String(20), default="pending")
 

+ 12 - 1
backend/app/schemas/print_queue.py

@@ -1,7 +1,7 @@
 from datetime import datetime
 from typing import Annotated, Literal
 
-from pydantic import BaseModel, PlainSerializer, model_validator
+from pydantic import BaseModel, Field, PlainSerializer, model_validator
 
 
 # Custom serializer to ensure UTC datetimes have Z suffix
@@ -50,6 +50,11 @@ class PrintQueueItemCreate(BaseModel):
     # matches BambuStudio's default; the MQTT layer ignores the flag on
     # single-nozzle printers so the wire value stays "skip" there.
     nozzle_offset_cali: bool = True
+    # Preheat / heat-soak per-item override (#1468). 'inherit' uses the global
+    # preheat_enabled setting; 'on' / 'off' force the decision. The chamber
+    # target falls through: this override → max(filament-map[loaded tray]) → 0.
+    preheat_override: Literal["inherit", "on", "off"] = "inherit"
+    preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=60)
     # Auto-print G-code injection
     gcode_injection: bool = False
     # Batch: create multiple copies (creates a batch if > 1)
@@ -85,6 +90,8 @@ class PrintQueueItemUpdate(BaseModel):
     timelapse: bool | None = None
     use_ams: bool | None = None
     nozzle_offset_cali: bool | None = None
+    preheat_override: Literal["inherit", "on", "off"] | None = None
+    preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=60)
     # Auto-print G-code injection
     gcode_injection: bool | None = None
     # H2C dual-nozzle-rack slicer pick (#1780). list[int] per-filament
@@ -126,6 +133,8 @@ class PrintQueueItemResponse(BaseModel):
     timelapse: bool = False
     use_ams: bool = True
     nozzle_offset_cali: bool = True
+    preheat_override: Literal["inherit", "on", "off"] = "inherit"
+    preheat_chamber_target_override: int | None = None
     status: Literal["pending", "printing", "completed", "failed", "skipped", "cancelled"]
     started_at: UTCDatetime
     completed_at: UTCDatetime
@@ -233,6 +242,8 @@ class PrintQueueBulkUpdate(BaseModel):
     timelapse: bool | None = None
     use_ams: bool | None = None
     nozzle_offset_cali: bool | None = None
+    preheat_override: Literal["inherit", "on", "off"] | None = None
+    preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=60)
     # Auto-print G-code injection
     gcode_injection: bool | None = None
 

+ 41 - 0
backend/app/schemas/settings.py

@@ -327,6 +327,43 @@ class AppSettings(BaseModel):
         description="Shortest Job First — scheduler prioritizes shorter print jobs over longer ones",
     )
 
+    # Preheat / heat-soak before queued prints (#1468). The scheduler stage runs
+    # BEFORE FTP upload. Three hardware tiers behave differently:
+    #   - Chamber heater (H2C/H2D/H2DPro/H2S/X2D/X1E): M141 → wait for chamber
+    #     sensor to reach target → soak
+    #   - Chamber sensor only (X1C/P2S): M140 only → wait for radiant chamber
+    #     warm-up to reach target OR max-wait timeout → soak
+    #   - No chamber sensor (P1S/P1P/A1/A1 Mini): M140 only → fixed soak timer
+    #     (no way to verify chamber temp; relies entirely on max_wait + soak)
+    # Chamber target derives per-print from the loaded AMS filament types via
+    # preheat_filament_targets (max across loaded slots). A target of 0 skips
+    # the chamber phase but keeps the bed phase + soak. Per-queue-item
+    # `preheat_chamber_target_override` (nullable) bypasses the derivation.
+    preheat_enabled: bool = Field(
+        default=False,
+        description="Master toggle / default for new queue items. Per-item preheat_override can flip the decision per print.",
+    )
+    preheat_filament_targets: str = Field(
+        default="",
+        description=(
+            "JSON map of normalized filament type → chamber target °C. Empty = bundled defaults "
+            "(PLA/PETG/TPU/PVA: 0, PETG-CF: 40, ABS/ASA: 45, PA/PC/PC-FR: 50, PA-CF: 55, default: 0). "
+            "Scheduler picks max across loaded AMS slots; 0 disables chamber phase for that print."
+        ),
+    )
+    preheat_max_wait_seconds: int = Field(
+        default=900,
+        ge=60,
+        le=3600,
+        description="Maximum time to wait for the chamber to reach the target before falling through to the soak phase (radiant heating on X1C/P2S can take 15-30 min).",
+    )
+    preheat_soak_seconds: int = Field(
+        default=300,
+        ge=0,
+        le=1800,
+        description="Additional hold time at temperature after the chamber reaches the target (or after max_wait_seconds elapses). 0 = no soak.",
+    )
+
     # User-configurable presets for the printer-card temperature / fan-speed
     # popovers. Each is a JSON array of exactly 3 ints (the "Off" button is
     # rendered separately and is not configurable). Empty string = use built-in
@@ -516,6 +553,10 @@ class AppSettingsUpdate(BaseModel):
     stagger_interval_minutes: int | None = Field(default=None, ge=1, le=60)
     require_plate_clear: bool | None = None
     queue_shortest_first: bool | None = None
+    preheat_enabled: bool | None = None
+    preheat_filament_targets: str | None = None
+    preheat_max_wait_seconds: int | None = Field(default=None, ge=60, le=3600)
+    preheat_soak_seconds: int | None = Field(default=None, ge=0, le=1800)
     nozzle_temp_presets: str | None = None
     bed_temp_presets: str | None = None
     chamber_temp_presets: str | None = None

+ 309 - 0
backend/app/services/print_scheduler.py

@@ -34,6 +34,9 @@ from backend.app.services.filament_deficit import compute_deficit_for_queue_item
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import (
     printer_manager,
+    supports_airduct,
+    supports_chamber_heater,
+    supports_chamber_temp,
     supports_drying,
     supports_drying_while_printing,
 )
@@ -1554,6 +1557,17 @@ class PrintScheduler:
             return setting.value.lower() == "true"
         return default
 
+    async def _get_int_setting(self, db: AsyncSession, key: str, default: int) -> int:
+        """Read an int setting; falls back to default on missing/unparseable rows."""
+        result = await db.execute(select(Settings).where(Settings.key == key))
+        setting = result.scalar_one_or_none()
+        if setting and setting.value:
+            try:
+                return int(setting.value)
+            except ValueError:
+                pass
+        return default
+
     async def _get_drying_presets(self, db: AsyncSession) -> dict[str, dict[str, int]]:
         """Get drying presets (user-configured or built-in defaults)."""
         result = await db.execute(select(Settings).where(Settings.key == "drying_presets"))
@@ -1938,6 +1952,294 @@ class PrintScheduler:
         result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
         return list(result.scalars().all())
 
+    # Bundled defaults for preheat_filament_targets (#1468). Values are the
+    # chamber-temperature recommendations BambuStudio ships for the matching
+    # filament profile; users can override via Settings → Workflow → Preheat
+    # card. "default" applies when a loaded tray's normalised type isn't in
+    # the map (rare — Bambu RFID-tagged spools always carry a known type).
+    DEFAULT_PREHEAT_FILAMENT_TARGETS: dict[str, int] = {
+        "PLA": 0,
+        "PETG": 0,
+        "PETG-CF": 40,
+        "ABS": 45,
+        "ASA": 45,
+        "PA": 50,
+        "PA-CF": 55,
+        "PC": 50,
+        "PC-FR": 50,
+        "TPU": 0,
+        "PVA": 0,
+        "default": 0,
+    }
+
+    async def _get_preheat_filament_targets(self, db: AsyncSession) -> dict[str, int]:
+        """Parse the user-configured filament→chamber-target map, falling back
+        to DEFAULT_PREHEAT_FILAMENT_TARGETS on missing / malformed JSON. Keys
+        are uppercased and the 'default' fallback is always present in the
+        returned dict so the resolution loop can index it unconditionally."""
+        raw = await self._get_setting(db, "preheat_filament_targets")
+        if not raw:
+            return dict(self.DEFAULT_PREHEAT_FILAMENT_TARGETS)
+        try:
+            parsed = json.loads(raw)
+            if not isinstance(parsed, dict):
+                raise ValueError("not an object")
+        except (json.JSONDecodeError, ValueError) as exc:
+            logger.warning("preheat_filament_targets unparseable, using defaults: %s", exc)
+            return dict(self.DEFAULT_PREHEAT_FILAMENT_TARGETS)
+        # Coerce values to int; drop unparseable rows so a stray string
+        # doesn't crash the loop.
+        out: dict[str, int] = {}
+        for key, value in parsed.items():
+            try:
+                out[str(key).upper()] = int(value)
+            except (TypeError, ValueError):
+                continue
+        if "DEFAULT" not in out:
+            out["DEFAULT"] = self.DEFAULT_PREHEAT_FILAMENT_TARGETS["default"]
+        return out
+
+    @staticmethod
+    def _normalize_filament_type(tray_type: str) -> str:
+        """Reduce the printer's tray_type to a preset-lookup key. Mirrors the
+        existing drying-preset normalisation (split-at-space, upper-case) so
+        the two maps share vocabulary — "PLA Basic" → "PLA", "PA-CF" stays
+        "PA-CF" (no space to split on)."""
+        return tray_type.split()[0].upper() if tray_type else ""
+
+    def _derive_chamber_target(
+        self,
+        printer: Printer,
+        targets: dict[str, int],
+    ) -> int:
+        """Look up the chamber target for each loaded AMS tray and return the
+        max. Returns 0 when no AMS data is available (e.g. external-spool
+        prints) or when every loaded slot maps to 0 — the chamber phase then
+        short-circuits in the main loop.
+
+        Reads from `printer_manager.get_status(...).raw_data['ams']`, which is
+        the same source the dispatcher uses for AMS slot mapping. Empty / RFID-
+        less slots have empty `tray_type` and contribute nothing."""
+        state = printer_manager.get_status(printer.id)
+        if state is None:
+            return 0
+        ams_list = (state.raw_data or {}).get("ams") if state.raw_data else None
+        # Older Bambu firmware nests AMS as {"ams": {"ams": [...]}} — try both.
+        if isinstance(ams_list, dict):
+            ams_list = ams_list.get("ams") or []
+        if not isinstance(ams_list, list):
+            return 0
+        best = 0
+        for ams in ams_list:
+            for tray in (ams.get("tray") or []) if isinstance(ams, dict) else []:
+                normalised = self._normalize_filament_type(tray.get("tray_type") or "")
+                if not normalised:
+                    continue
+                target = targets.get(normalised, targets.get("DEFAULT", 0))
+                if target > best:
+                    best = target
+        return best
+
+    async def _preheat_and_soak(
+        self,
+        db: AsyncSession,
+        item: PrintQueueItem,
+        printer: Printer,
+        archive: PrintArchive | None,
+    ) -> None:
+        """Run the per-printer preheat + heat-soak stage before FTP upload (#1468).
+
+        Resolution order:
+          1. `item.preheat_override` — 'off' skips entirely; 'inherit' falls back
+             to the global `preheat_enabled` setting; 'on' forces the stage on
+             even if the global is off.
+          2. Chamber target — `item.preheat_chamber_target_override` if non-null;
+             else max of `preheat_filament_targets[normalize(t.tray_type)]`
+             across loaded AMS slots; else 0 (skips chamber phase, keeps bed
+             phase + soak timer).
+          3. Three hardware tiers branch the wait loop:
+             - Chamber heater (H2C/H2D/H2DPro/H2S/X2D/X1E via supports_chamber_heater):
+               send M141 to the resolved target, then wait for the chamber sensor
+               to reach it (or the max-wait timeout to elapse).
+             - Chamber sensor only (X1C/P2S via supports_chamber_temp ∧ ¬supports_chamber_heater):
+               no M141; the bed is the only heat source, so we wait for the chamber
+               sensor to rise via bed radiation OR fall through on timeout.
+             - No chamber sensor (P1S/P1P/A1/A1 Mini): no way to verify chamber
+               temperature; the function just heats the bed and holds for the
+               configured soak duration.
+
+        The bed target comes from the archive's parsed metadata
+        (`bed_temperature`); if missing the preheat stage logs and returns
+        without dispatching anything, rather than guessing at a default that
+        might wreck filament setup.
+
+        Failures are logged but never re-raised — preheat is best-effort. A
+        printer that goes offline mid-soak, a refused gcode command, or a
+        missing temperature reading must not turn into a failed queue item; the
+        normal upload + start path runs immediately after this method returns.
+        """
+        override = (getattr(item, "preheat_override", None) or "inherit").lower()
+        if override == "off":
+            return
+        if override == "inherit":
+            enabled = await self._get_bool_setting(db, "preheat_enabled", default=False)
+            if not enabled:
+                return
+        # override == "on" forces the stage on regardless of the global setting.
+
+        max_wait = await self._get_int_setting(db, "preheat_max_wait_seconds", default=900)
+        soak_seconds = await self._get_int_setting(db, "preheat_soak_seconds", default=300)
+
+        # Chamber target resolution:
+        #   1. Explicit per-item override beats everything (user knows best).
+        #   2. Otherwise derive from loaded AMS filament types via the per-
+        #      filament target map. PLA-only print derives 0 → chamber phase
+        #      auto-skips without the user touching anything.
+        explicit_target = getattr(item, "preheat_chamber_target_override", None)
+        if explicit_target is not None and explicit_target > 0:
+            chamber_target = int(explicit_target)
+            chamber_source = "item-override"
+        elif explicit_target == 0:
+            chamber_target = 0  # explicit 0 means "no chamber, even if filament wants it"
+            chamber_source = "item-override-zero"
+        else:
+            targets = await self._get_preheat_filament_targets(db)
+            chamber_target = self._derive_chamber_target(printer, targets)
+            chamber_source = "filament-map"
+
+        bed_target = int(archive.bed_temperature) if archive and archive.bed_temperature else 0
+        if bed_target <= 0:
+            logger.info(
+                "Queue item %s: preheat skipped — archive has no bed_temperature metadata",
+                item.id,
+            )
+            return
+
+        client = printer_manager.get_client(printer.id)
+        if client is None:
+            logger.warning("Queue item %s: preheat skipped — printer client unavailable", item.id)
+            return
+
+        model = printer.model or ""
+        has_heater = supports_chamber_heater(model)
+        has_sensor = supports_chamber_temp(model)
+        do_chamber = chamber_target > 0 and (has_heater or has_sensor)
+
+        logger.info(
+            "Queue item %s: preheat starting — bed=%d°C chamber_target=%d°C (source=%s override=%s "
+            "model=%s has_heater=%s has_sensor=%s) max_wait=%ds soak=%ds",
+            item.id,
+            bed_target,
+            chamber_target if do_chamber else 0,
+            chamber_source,
+            override,
+            model,
+            has_heater,
+            has_sensor,
+            max_wait,
+            soak_seconds,
+        )
+
+        # Dispatch heaters. set_bed_temperature / set_chamber_temperature already
+        # cache the target locally so the polling reads below see consistent
+        # state (firmware MQTT echoes lag by ~1s).
+        try:
+            client.set_bed_temperature(bed_target)
+        except Exception as exc:
+            logger.warning("Queue item %s: preheat bed M140 failed: %s", item.id, exc)
+            return
+
+        # Airduct mode (#1468 follow-up). Models with the cooling/heating flap
+        # (H2C/H2D/H2D Pro/H2S/X2D/P2S) keep the flap whatever the user last
+        # left it on, regardless of M141. Default cooling actively vents the
+        # chamber, so a `chamber_target > 0` print with the flap stuck in
+        # cooling never converges — the heater fights the open exhaust. We
+        # flip the flap BEFORE M141 to "heating" when the preheat wants
+        # chamber heat, and back to "cooling" when it doesn't (PLA-only print
+        # on an H2D that was previously running ABS would otherwise stay in
+        # heating mode and overheat PLA). The current-state read keeps the
+        # command idempotent — no MQTT chatter when the flap is already where
+        # we want it.
+        if supports_airduct(model):
+            desired_airduct = "heating" if chamber_target > 0 else "cooling"
+            desired_id = 1 if desired_airduct == "heating" else 0
+            current_state = printer_manager.get_status(printer.id)
+            current_airduct = getattr(current_state, "airduct_mode", None) if current_state else None
+            if current_airduct != desired_id:
+                try:
+                    client.set_airduct_mode(desired_airduct)
+                except Exception as exc:
+                    logger.warning(
+                        "Queue item %s: preheat airduct %s mode failed: %s",
+                        item.id,
+                        desired_airduct,
+                        exc,
+                    )
+
+        if do_chamber and has_heater:
+            try:
+                client.set_chamber_temperature(chamber_target)
+            except Exception as exc:
+                logger.warning("Queue item %s: preheat chamber M141 failed: %s", item.id, exc)
+
+        # Wait for convergence. Bed warm-up is fast (~5 min from cold); chamber
+        # via M141 takes a few minutes; chamber via bed radiation can take 20+.
+        # Poll every 3s — frequent enough for responsive logging without
+        # spamming the MQTT state stream. The "converged" predicate is:
+        #   bed reached target (within 2°C tolerance for floating-point + heater hysteresis),
+        #   AND
+        #   chamber phase satisfied (no chamber phase, no sensor, or sensor reached target).
+        BED_TOLERANCE = 2.0
+        CHAMBER_TOLERANCE = 2.0
+        POLL_INTERVAL = 3.0
+        deadline = asyncio.get_event_loop().time() + max_wait
+
+        while True:
+            state = printer_manager.get_status(printer.id)
+            if state is None:
+                logger.warning("Queue item %s: preheat lost state during wait", item.id)
+                break
+
+            temps = state.temperatures or {}
+            bed_now = float(temps.get("bed", 0) or 0)
+            chamber_now = float(temps.get("chamber", 0) or 0)
+            bed_ok = bed_now >= bed_target - BED_TOLERANCE
+
+            if not do_chamber:
+                chamber_ok = True  # phase disabled or model has neither sensor nor heater
+            elif not has_sensor:
+                chamber_ok = True  # P1S etc — can't read, rely on soak timer only
+            else:
+                chamber_ok = chamber_now >= chamber_target - CHAMBER_TOLERANCE
+
+            if bed_ok and chamber_ok:
+                logger.info(
+                    "Queue item %s: preheat target reached (bed=%.1f chamber=%.1f) — entering soak",
+                    item.id,
+                    bed_now,
+                    chamber_now,
+                )
+                break
+
+            if asyncio.get_event_loop().time() >= deadline:
+                logger.info(
+                    "Queue item %s: preheat max_wait reached (bed=%.1f/%d chamber=%.1f/%d) — falling through to soak",
+                    item.id,
+                    bed_now,
+                    bed_target,
+                    chamber_now,
+                    chamber_target if do_chamber else 0,
+                )
+                break
+
+            await asyncio.sleep(POLL_INTERVAL)
+
+        if soak_seconds > 0:
+            logger.info("Queue item %s: preheat soak — holding for %ds", item.id, soak_seconds)
+            await asyncio.sleep(soak_seconds)
+
+        logger.info("Queue item %s: preheat complete — proceeding to upload", item.id)
+
     async def _power_on_and_wait(self, plug: SmartPlug, printer_id: int, db: AsyncSession) -> bool:
         """Turn on smart plug and wait for printer to connect.
 
@@ -2303,6 +2605,13 @@ class PrintScheduler:
             await self._power_off_if_needed(db, item)
             return
 
+        # Preheat / heat-soak (#1468) — fires before upload so the printer's
+        # bed (and chamber, if applicable) is at temperature when the firmware
+        # starts the actual print routine. Best-effort: any failure logs and
+        # falls through to the normal upload+start path rather than turning a
+        # configuration issue into a failed queue item.
+        await self._preheat_and_soak(db, item, printer, archive)
+
         # G-code injection for auto-print systems (#422)
         injected_path = None
         if item.gcode_injection:

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

@@ -122,6 +122,51 @@ def supports_chamber_heater(model: str | None) -> bool:
     return model.strip().upper() in CHAMBER_HEATER_MODELS
 
 
+# Models with a cooling / heating airduct flap. Same set as the frontend
+# PrintersPage airduct-toggle whitelist (P2S, X2D, H2D, H2C, H2S, H2D Pro).
+# X1E has a chamber heater but NO airduct flap — the warm-air recirculation
+# happens via the fixed front-door inlet, so no `set_airduct` command is
+# needed (and the firmware ignores it). P2S has an airduct but no heater —
+# the flap manages chamber airflow even without an active heater. The
+# intersection (chamber heater AND airduct) is what the preheat stage cares
+# about: when M141 fires we also need to assert heating mode, otherwise the
+# default cooling mode actively fights the chamber heater.
+CHAMBER_AIRDUCT_MODELS = frozenset(
+    [
+        # Display names
+        "P2S",
+        "X2D",
+        "H2C",
+        "H2D",
+        "H2DPRO",
+        "H2S",
+        # Internal codes (from MQTT/SSDP)
+        "N7",  # P2S
+        "N6",  # X2D
+        "O1C",  # H2C
+        "O1C2",  # H2C dual-nozzle variant
+        "O1D",  # H2D
+        "O1E",  # H2D Pro
+        "O2D",  # H2D Pro alternate code
+        "O1S",  # H2S
+    ]
+)
+
+
+def supports_airduct(model: str | None) -> bool:
+    """Check if a printer model has a cooling / heating airduct mode toggle.
+
+    Mirrors the frontend PrintersPage `['P2S', 'X2D', 'H2D', 'H2C', 'H2S']`
+    + H2D Pro whitelist. Distinct from `supports_chamber_heater` — P2S has
+    the airduct toggle but no active heater, and X1E has the heater but no
+    airduct. The preheat stage cares about the intersection (heater AND
+    airduct) so it can flip the flap to heating before energising M141.
+    """
+    if not model:
+        return False
+    return model.strip().upper() in CHAMBER_AIRDUCT_MODELS
+
+
 def has_stg_cur_idle_bug(model: str | None) -> bool:
     """Check if a printer model may incorrectly report stg_cur=0 when idle.
 

+ 568 - 0
backend/tests/unit/test_scheduler_preheat.py

@@ -0,0 +1,568 @@
+"""Tests for the preheat & heat-soak scheduler stage (#1468).
+
+Three layered concerns the stage has to get right:
+
+1. **Override resolution** (per-item beats global beats default).
+2. **Chamber-target derivation** (item-override > filament-map max > 0).
+3. **Hardware-tier branching** (chamber heater vs sensor-only vs no sensor).
+
+The fixtures construct a queue item with `preheat_override` + the override
+target both unset; tests flip those per case. `asyncio.sleep` is patched to
+AsyncMock so the soak phase doesn't actually wait — assertions are on what
+got scheduled, not wall-clock.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.print_scheduler import PrintScheduler
+
+
+@pytest.fixture
+def scheduler():
+    return PrintScheduler()
+
+
+@pytest.fixture
+def item():
+    return SimpleNamespace(
+        id=42,
+        preheat_override="inherit",
+        preheat_chamber_target_override=None,
+    )
+
+
+@pytest.fixture
+def archive():
+    return SimpleNamespace(bed_temperature=60)
+
+
+def _make_printer(model: str, printer_id: int = 7):
+    return SimpleNamespace(id=printer_id, model=model)
+
+
+def _make_client():
+    client = MagicMock()
+    client.set_bed_temperature = MagicMock(return_value=True)
+    client.set_chamber_temperature = MagicMock(return_value=True)
+    client.set_airduct_mode = MagicMock(return_value=True)
+    return client
+
+
+def _make_state(
+    bed_temp: float = 0.0,
+    chamber_temp: float = 0.0,
+    trays: list[str] | None = None,
+    airduct_mode: int = 0,
+):
+    """Build a PrinterState-shaped namespace with optional AMS tray types.
+
+    `trays` is a list of tray_type strings (each becomes one loaded slot in
+    AMS unit 0). Empty / None gives an empty AMS — the derivation falls
+    through to 0. `airduct_mode` is 0 (cooling, default) or 1 (heating);
+    matches the field on PrinterState that the preheat stage reads to
+    decide whether to fire a redundant `set_airduct_mode` call."""
+    raw_data: dict = {}
+    if trays is not None:
+        raw_data["ams"] = [{"tray": [{"tray_type": t} for t in trays]}]
+    return SimpleNamespace(
+        temperatures={"bed": bed_temp, "chamber": chamber_temp},
+        raw_data=raw_data,
+        airduct_mode=airduct_mode,
+    )
+
+
+def _ints(**values):
+    """Mock side_effect for _get_int_setting that returns the kwarg value
+    when the key matches, else the helper's `default` argument."""
+    return AsyncMock(side_effect=lambda _db, key, default: values.get(key, default))
+
+
+# ----------------------------------------------------------------------------
+# Override resolution
+# ----------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_global_disabled_inherit_skips(scheduler, item, archive):
+    """preheat_enabled=False + item.preheat_override='inherit' → no heater dispatch."""
+    db = MagicMock()
+    client = _make_client()
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=False)),
+        patch.object(scheduler, "_get_int_setting", _ints()),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+    ):
+        pm.get_client.return_value = client
+        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
+
+    client.set_bed_temperature.assert_not_called()
+    client.set_chamber_temperature.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_item_override_off_bypasses_global_on(scheduler, item, archive):
+    """preheat_enabled=True + item.preheat_override='off' → preheat suppressed."""
+    db = MagicMock()
+    client = _make_client()
+    item.preheat_override = "off"
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints()),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+    ):
+        pm.get_client.return_value = client
+        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
+
+    client.set_bed_temperature.assert_not_called()
+    client.set_chamber_temperature.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_item_override_on_runs_despite_global_off(scheduler, item, archive):
+    """preheat_enabled=False + item.preheat_override='on' → preheat runs (bed
+    fires, chamber depends on the resolved target)."""
+    db = MagicMock()
+    client = _make_client()
+    item.preheat_override = "on"
+    item.preheat_chamber_target_override = 0  # explicit no-chamber so the assertion is sharp
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=False)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        pm.get_status.return_value = _make_state(60.0, 0.0)
+        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
+
+    client.set_bed_temperature.assert_called_once_with(60)
+    client.set_chamber_temperature.assert_not_called()
+
+
+# ----------------------------------------------------------------------------
+# Chamber-target derivation
+# ----------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_chamber_target_override_beats_filament_map(scheduler, item, archive):
+    """item.preheat_chamber_target_override is the highest-priority source —
+    a PLA-only print with an explicit 50°C override still heats the chamber."""
+    db = MagicMock()
+    client = _make_client()
+    item.preheat_chamber_target_override = 50
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        # Only PLA loaded — filament map would derive 0; override forces 50.
+        pm.get_status.return_value = _make_state(60.0, 52.0, trays=["PLA Basic"])
+        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
+
+    client.set_bed_temperature.assert_called_once_with(60)
+    client.set_chamber_temperature.assert_called_once_with(50)
+
+
+@pytest.mark.asyncio
+async def test_filament_map_picks_max_across_loaded_slots(scheduler, item, archive):
+    """Mixed PA + PLA load: PA=50 + PLA=0 → chamber target 50 (the max).
+    The "lowest common denominator" model is wrong here; PA's requirement
+    is the binding constraint."""
+    db = MagicMock()
+    client = _make_client()
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
+        patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        # `PA Basic` (note the space) normalises to `PA` which the bundled
+        # map keys against; a hyphenated `PA-Generic` would normalise to
+        # `PA-GENERIC` and fall through to `default` (0) — that's a separate
+        # behaviour the user editor handles by adding a custom key.
+        pm.get_status.return_value = _make_state(60.0, 52.0, trays=["PLA Basic", "PA Basic"])
+        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
+
+    client.set_chamber_temperature.assert_called_once_with(50)  # PA's recommendation, not PLA's
+
+
+@pytest.mark.asyncio
+async def test_pla_only_derives_zero_chamber_skips(scheduler, item, archive):
+    """PLA-only print: filament-map lookup returns 0 → chamber phase skips
+    automatically without the user touching anything."""
+    db = MagicMock()
+    client = _make_client()
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
+        patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        pm.get_status.return_value = _make_state(60.0, 0.0, trays=["PLA", "PLA"])
+        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
+
+    client.set_bed_temperature.assert_called_once_with(60)
+    client.set_chamber_temperature.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_unknown_filament_type_falls_to_default(scheduler, item, archive):
+    """A loaded tray with a type not in the map uses the `default` entry —
+    keeps users with custom filament names safe (they get 0 by default,
+    can be tuned via the per-filament editor)."""
+    db = MagicMock()
+    client = _make_client()
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
+        patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        pm.get_status.return_value = _make_state(60.0, 0.0, trays=["MyCustomFilament"])
+        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
+
+    client.set_bed_temperature.assert_called_once_with(60)
+    client.set_chamber_temperature.assert_not_called()  # default = 0
+
+
+@pytest.mark.asyncio
+async def test_custom_filament_targets_json_parses(scheduler, item, archive):
+    """User-customised filament-target JSON overrides the bundled defaults —
+    raising PLA to 30°C makes a PLA-only print actually heat the chamber."""
+    db = MagicMock()
+    client = _make_client()
+    custom_map = '{"PLA": 30, "default": 0}'
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
+        patch.object(scheduler, "_get_setting", AsyncMock(return_value=custom_map)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        pm.get_status.return_value = _make_state(60.0, 31.0, trays=["PLA Basic"])
+        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
+
+    client.set_chamber_temperature.assert_called_once_with(30)
+
+
+@pytest.mark.asyncio
+async def test_malformed_filament_targets_falls_back_to_defaults(scheduler, item, archive):
+    """A corrupted JSON in the setting must not break the scheduler — log
+    and use bundled defaults."""
+    db = MagicMock()
+    client = _make_client()
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
+        patch.object(scheduler, "_get_setting", AsyncMock(return_value="not-json{{{")),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        # Bundled default for ABS = 45 → chamber should fire.
+        pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"])
+        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
+
+    client.set_chamber_temperature.assert_called_once_with(45)
+
+
+# ----------------------------------------------------------------------------
+# Hardware-tier branching (unchanged from the first cut but updated for new
+# fixtures that include AMS data so the derivation lands at a non-zero target).
+# ----------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_no_bed_temperature_in_archive_skips(scheduler, item):
+    """Archive without bed_temperature metadata skips entirely rather than
+    guessing a default that might wreck a non-PLA print."""
+    db = MagicMock()
+    client = _make_client()
+    bare_archive = SimpleNamespace(bed_temperature=None)
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints()),
+        patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+    ):
+        pm.get_client.return_value = client
+        pm.get_status.return_value = _make_state(trays=["ABS"])
+        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), bare_archive)
+
+    client.set_bed_temperature.assert_not_called()
+    client.set_chamber_temperature.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_x1c_skips_m141_but_waits_passively(scheduler, item, archive):
+    """X1C has a chamber sensor but no active heater — M141 must NOT fire even
+    when the filament map derives a non-zero target."""
+    db = MagicMock()
+    client = _make_client()
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
+        patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        # ABS loaded → derived target 45; sensor reads 46 (already there).
+        pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"])
+        await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive)
+
+    client.set_bed_temperature.assert_called_once_with(60)
+    client.set_chamber_temperature.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_p1s_no_chamber_sensor_uses_soak_timer_only(scheduler, item, archive):
+    """P1S has no chamber sensor — derived target is ignored for the wait
+    loop, only the soak timer applies."""
+    db = MagicMock()
+    client = _make_client()
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=600)),
+        patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()) as sleep_mock,
+    ):
+        pm.get_client.return_value = client
+        pm.get_status.return_value = _make_state(60.0, 0.0, trays=["ABS"])
+        await scheduler._preheat_and_soak(db, item, _make_printer("P1S"), archive)
+
+    client.set_bed_temperature.assert_called_once_with(60)
+    client.set_chamber_temperature.assert_not_called()
+    assert 600 in [call.args[0] for call in sleep_mock.call_args_list]
+
+
+@pytest.mark.asyncio
+async def test_lost_client_skips_silently(scheduler, item, archive):
+    """If the MQTT client drops, the helper returns without raising."""
+    db = MagicMock()
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints()),
+        patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+    ):
+        pm.get_client.return_value = None
+        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
+    # No exception escaping — the disable path is silent.
+
+
+@pytest.mark.asyncio
+async def test_h2d_flips_airduct_to_heating_before_m141(scheduler, item, archive):
+    """H-series + X2D have a cooling/heating airduct flap that DEFAULTS to
+    cooling. If we energise M141 without first flipping to heating, the
+    chamber fan actively extracts the heat we're trying to put in and the
+    chamber never converges. Verify airduct=heating fires AND lands before
+    the chamber-target call so the heater starts in the right airflow regime."""
+    db = MagicMock()
+    client = _make_client()
+    call_order = []
+    client.set_airduct_mode.side_effect = lambda mode: call_order.append(("airduct", mode)) or True
+    client.set_chamber_temperature.side_effect = lambda t: call_order.append(("chamber", t)) or True
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
+        patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"])
+        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
+
+    client.set_airduct_mode.assert_called_once_with("heating")
+    client.set_chamber_temperature.assert_called_once_with(45)
+    # Airduct heating must precede M141 — the heater enabling first while the
+    # flap is still in cooling mode wastes minutes of fan-vs-heater tug-of-war.
+    assert call_order == [("airduct", "heating"), ("chamber", 45)]
+
+
+@pytest.mark.asyncio
+async def test_x1c_skips_airduct_no_heater_no_call(scheduler, item, archive):
+    """X1C has neither an active chamber heater nor an airduct flap (the
+    frontend's airduct whitelist is P2S/X2D/H2D/H2C/H2S/H2D Pro — no X1
+    series). The preheat stage's airduct call is gated on supports_airduct
+    AND has_heater, so X1C gets neither call regardless. Important: a
+    spurious set_airduct on X1C wouldn't just be wasted MQTT — there's no
+    flap to set, so the firmware response would be undefined behaviour."""
+    db = MagicMock()
+    client = _make_client()
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
+        patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"])
+        await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive)
+
+    client.set_chamber_temperature.assert_not_called()
+    client.set_airduct_mode.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_normalize_filament_type_strips_at_space():
+    """`PLA Basic` and `ABS Premium` should normalise to `PLA` and `ABS` so
+    they match the map keys. `PA-CF` has no space and stays verbatim."""
+    s = PrintScheduler
+    assert s._normalize_filament_type("PLA Basic") == "PLA"
+    assert s._normalize_filament_type("ABS Premium") == "ABS"
+    assert s._normalize_filament_type("PA-CF") == "PA-CF"
+    assert s._normalize_filament_type("") == ""
+    assert s._normalize_filament_type("petg") == "PETG"  # case-folded
+
+
+@pytest.mark.asyncio
+async def test_get_preheat_filament_targets_defaults_when_missing(scheduler):
+    """Empty / null setting → bundled defaults are used. _get_preheat_filament_targets
+    upper-cases the keys, so the bundled `default` becomes `DEFAULT` on the
+    returned dict — keep both spellings synced."""
+    db = MagicMock()
+    with patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)):
+        targets = await scheduler._get_preheat_filament_targets(db)
+    # The bundled defaults dict is kept as-is on the "no setting" path, so
+    # `default` (lowercase) is what callers see for that fallback.
+    assert targets["PLA"] == 0
+    assert targets["ABS"] == 45
+    assert targets["PA-CF"] == 55
+    # Either casing must resolve to the fallback 0.
+    assert targets.get("default", targets.get("DEFAULT")) == 0
+
+
+# ----------------------------------------------------------------------------
+# Airduct mode switch (#1468 follow-up)
+# ----------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_h2d_chamber_heat_switches_airduct_to_heating(scheduler, item, archive):
+    """H2D in cooling mode (the default; what you get after a PLA print)
+    with chamber_target > 0 must switch the airduct to heating BEFORE the
+    M141 dispatch — otherwise the open exhaust flap actively fights the
+    chamber heater and the chamber never converges."""
+    db = MagicMock()
+    client = _make_client()
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
+        patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        # Currently in cooling (mode 0). ABS loaded → derived target 45.
+        pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"], airduct_mode=0)
+        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
+
+    client.set_airduct_mode.assert_called_once_with("heating")
+    client.set_chamber_temperature.assert_called_once_with(45)
+
+
+@pytest.mark.asyncio
+async def test_h2d_chamber_zero_switches_airduct_to_cooling(scheduler, item, archive):
+    """H2D running a PLA print (chamber_target derives 0) on an airduct
+    previously left in heating mode (from a prior ABS run) must switch
+    back to cooling. Otherwise PLA prints inherit ABS's closed-flap recirc
+    and run hot."""
+    db = MagicMock()
+    client = _make_client()
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
+        patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        # Currently in heating (mode 1). PLA loaded → derived target 0.
+        pm.get_status.return_value = _make_state(60.0, 30.0, trays=["PLA"], airduct_mode=1)
+        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
+
+    client.set_airduct_mode.assert_called_once_with("cooling")
+    client.set_chamber_temperature.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_h2d_airduct_already_correct_idempotent(scheduler, item, archive):
+    """If the airduct is already in the desired mode, don't re-send
+    `set_airduct` — the firmware accepts it but it generates needless MQTT
+    chatter and could thrash the flap motor on rapid repeats."""
+    db = MagicMock()
+    client = _make_client()
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
+        patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        # Already in heating (mode 1) and ABS → derived 45 wants heating.
+        pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"], airduct_mode=1)
+        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
+
+    client.set_airduct_mode.assert_not_called()
+    # But M141 still fires — the airduct decision is independent.
+    client.set_chamber_temperature.assert_called_once_with(45)
+
+
+@pytest.mark.asyncio
+async def test_x1c_no_airduct_flap_never_fires_set_airduct(scheduler, item, archive):
+    """X1C has a chamber sensor but no airduct flap — the firmware ignores
+    `set_airduct`. We gate on `supports_airduct(model)` to avoid sending the
+    no-op. Regression guard: wiring this to `supports_chamber_temp` or
+    `supports_chamber_heater` alone would have leaked the command to
+    X1C/X1E or P2S inappropriately."""
+    db = MagicMock()
+    client = _make_client()
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
+        patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"], airduct_mode=0)
+        await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive)
+
+    client.set_airduct_mode.assert_not_called()

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

@@ -1210,6 +1210,15 @@ export interface AppSettings {
   require_plate_clear: boolean;
   // Shortest job first scheduling
   queue_shortest_first: boolean;
+  // Preheat / heat-soak before queued prints (#1468). Master toggle is the
+  // default for new queue items; per-item PrintQueueItem.preheat_override can
+  // flip the decision per print. Chamber target derives from the loaded AMS
+  // filament types via preheat_filament_targets (JSON map of type → °C, max
+  // across loaded slots); the per-item override field bypasses derivation.
+  preheat_enabled: boolean;
+  preheat_filament_targets: string;
+  preheat_max_wait_seconds: number;
+  preheat_soak_seconds: number;
   // User-configurable presets for the printer-card popovers (JSON arrays of 3 ints).
   // Empty string = use built-in defaults.
   nozzle_temp_presets: string;
@@ -2076,6 +2085,8 @@ export interface PrintQueueItem {
   timelapse: boolean;
   use_ams: boolean;
   nozzle_offset_cali: boolean;
+  preheat_override: 'inherit' | 'on' | 'off';
+  preheat_chamber_target_override: number | null;
   status: 'pending' | 'printing' | 'completed' | 'failed' | 'skipped' | 'cancelled';
   started_at: string | null;
   completed_at: string | null;
@@ -2151,6 +2162,8 @@ export interface PrintQueueItemCreate {
   timelapse?: boolean;
   use_ams?: boolean;
   nozzle_offset_cali?: boolean;
+  preheat_override?: 'inherit' | 'on' | 'off';
+  preheat_chamber_target_override?: number | null;
   // Auto-print G-code injection
   gcode_injection?: boolean;
   // Batch: create multiple copies (creates a batch if > 1)
@@ -2193,6 +2206,8 @@ export interface PrintQueueItemUpdate {
   timelapse?: boolean;
   use_ams?: boolean;
   nozzle_offset_cali?: boolean;
+  preheat_override?: 'inherit' | 'on' | 'off';
+  preheat_chamber_target_override?: number | null;
   // Auto-print G-code injection
   gcode_injection?: boolean;
 }
@@ -2212,6 +2227,8 @@ export interface PrintQueueBulkUpdate {
   timelapse?: boolean;
   use_ams?: boolean;
   nozzle_offset_cali?: boolean;
+  preheat_override?: 'inherit' | 'on' | 'off';
+  preheat_chamber_target_override?: number | null;
   // Auto-print G-code injection
   gcode_injection?: boolean;
 }

+ 62 - 0
frontend/src/components/PreheatFilamentTargetsEditor.tsx

@@ -0,0 +1,62 @@
+import { useTranslation } from 'react-i18next';
+import {
+  DEFAULT_PREHEAT_FILAMENT_TARGETS,
+  PREHEAT_FILAMENT_ORDER,
+  parsePreheatFilamentTargets,
+  serializePreheatFilamentTargets,
+} from '../utils/preheatFilamentTargets';
+
+interface Props {
+  // JSON-encoded map; empty string means "use bundled defaults".
+  value: string;
+  onChange: (next: string) => void;
+  disabled?: boolean;
+}
+
+// Per-filament chamber target editor for Settings → Workflow → Preheat card
+// (#1468). Renders one row per filament type with a numeric input clamped to
+// 0-60 °C. Stripping back to the bundled defaults is handled by the parent
+// (Reset button next to the section title) — passing an empty string upward
+// is the canonical "use defaults" signal, which keeps the editor stateless
+// across resets.
+export function PreheatFilamentTargetsEditor({ value, onChange, disabled = false }: Props) {
+  const { t } = useTranslation();
+  const map = parsePreheatFilamentTargets(value);
+
+  const updateOne = (key: string, next: number) => {
+    const clamped = Math.max(0, Math.min(60, Math.round(next)));
+    const updated = { ...map, [key]: clamped };
+    onChange(serializePreheatFilamentTargets(updated));
+  };
+
+  return (
+    <div className="grid grid-cols-1 sm:grid-cols-2 gap-x-3 gap-y-1.5">
+      {PREHEAT_FILAMENT_ORDER.map((key) => {
+        const current = map[key] ?? DEFAULT_PREHEAT_FILAMENT_TARGETS[key] ?? 0;
+        const label = key === 'default'
+          ? t('settings.preheatFilamentTargetsDefaultRow', 'Other / unmapped')
+          : key;
+        return (
+          <div key={key} className="flex items-center justify-between gap-2">
+            <span className={`text-xs ${key === 'default' ? 'text-bambu-gray italic' : 'text-bambu-gray'}`}>
+              {label}
+            </span>
+            <div className="flex items-center gap-1">
+              <input
+                type="number"
+                min={0}
+                max={60}
+                step={1}
+                value={current}
+                onChange={(e) => updateOne(key, parseInt(e.target.value, 10) || 0)}
+                disabled={disabled}
+                className="w-16 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-xs text-right focus:outline-none focus:border-bambu-green disabled:opacity-50"
+              />
+              <span className="text-xs text-bambu-gray">°C</span>
+            </div>
+          </div>
+        );
+      })}
+    </div>
+  );
+}

+ 75 - 4
frontend/src/components/PrintModal/PrintOptions.tsx

@@ -1,7 +1,7 @@
 import { useState } from 'react';
-import { Settings, ChevronDown, ChevronUp } from 'lucide-react';
+import { Settings, ChevronDown, ChevronUp, Flame } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
-import type { PrintOptionsProps, PrintOptions as PrintOptionsType } from './types';
+import type { PrintOptionsProps, PrintOptions as PrintOptionsType, PreheatOverride } from './types';
 
 type OptionConfig = {
   key: keyof PrintOptionsType;
@@ -41,6 +41,29 @@ export function PrintOptionsPanel({
     onChange({ ...options, [key]: !options[key] });
   };
 
+  const handlePreheatOverride = (next: PreheatOverride) => {
+    onChange({
+      ...options,
+      preheat_override: next,
+      // Clearing override→off also clears the chamber-target override so the
+      // backend doesn't carry a stale value if the user re-enables later.
+      ...(next === 'off' ? { preheat_chamber_target_override: null } : {}),
+    });
+  };
+
+  const handlePreheatTarget = (raw: string) => {
+    if (raw === '') {
+      onChange({ ...options, preheat_chamber_target_override: null });
+      return;
+    }
+    const parsed = parseInt(raw, 10);
+    if (Number.isNaN(parsed)) return;
+    onChange({
+      ...options,
+      preheat_chamber_target_override: Math.max(0, Math.min(60, parsed)),
+    });
+  };
+
   return (
     <div className="mb-4">
       <button
@@ -66,18 +89,66 @@ export function PrintOptionsPanel({
               </div>
               <div
                 className={`relative w-10 h-5 rounded-full transition-colors ${
-                  options[key] ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
+                  options[key as 'bed_levelling'] ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
                 }`}
                 onClick={() => handleToggle(key)}
               >
                 <div
                   className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
-                    options[key] ? 'translate-x-5' : 'translate-x-0.5'
+                    options[key as 'bed_levelling'] ? 'translate-x-5' : 'translate-x-0.5'
                   }`}
                 />
               </div>
             </label>
           ))}
+
+          {/* Preheat / heat-soak per-item override (#1468). Defaults to
+              'inherit' which means the global Settings → Workflow toggle
+              decides. Forcing 'on' or 'off' overrides per-print; the chamber
+              target override (optional °C input, visible when not 'off')
+              bypasses the per-filament-type derivation. */}
+          <div className="pt-2 mt-1 border-t border-bambu-dark-tertiary/60">
+            <div className="flex items-center gap-2 mb-1.5">
+              <Flame className="w-3.5 h-3.5 text-amber-400" />
+              <span className="text-sm text-white">{t('settings.preheatTitle', 'Preheat & Heat Soak')}</span>
+            </div>
+            <p className="text-xs text-bambu-gray mb-2">
+              {t('settings.preheatPerItemDesc', 'Heat the bed and chamber before this print starts. Defaults to the global Settings → Workflow toggle.')}
+            </p>
+            <div className="flex gap-1.5 mb-2">
+              {(['inherit', 'on', 'off'] as PreheatOverride[]).map((opt) => (
+                <button
+                  key={opt}
+                  type="button"
+                  onClick={() => handlePreheatOverride(opt)}
+                  className={`flex-1 px-2 py-1.5 text-xs rounded transition-colors ${
+                    options.preheat_override === opt
+                      ? 'bg-bambu-green text-white'
+                      : 'bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
+                  }`}
+                >
+                  {t(`settings.preheatOverride_${opt}`, opt === 'inherit' ? 'Inherit' : opt === 'on' ? 'On' : 'Off')}
+                </button>
+              ))}
+            </div>
+            {options.preheat_override !== 'off' && (
+              <div className="flex items-center gap-2">
+                <label className="text-xs text-bambu-gray flex-1">
+                  {t('settings.preheatTargetOverride', 'Chamber target override (°C, blank = filament default)')}
+                </label>
+                <input
+                  type="number"
+                  min={0}
+                  max={60}
+                  step={1}
+                  value={options.preheat_chamber_target_override ?? ''}
+                  onChange={(e) => handlePreheatTarget(e.target.value)}
+                  placeholder="—"
+                  className="w-16 px-2 py-1 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-white text-xs text-right focus:outline-none focus:border-bambu-green"
+                />
+              </div>
+            )}
+          </div>
         </div>
       )}
     </div>

+ 4 - 0
frontend/src/components/PrintModal/index.tsx

@@ -102,6 +102,8 @@ export function PrintModal({
         layer_inspect: queueItem.layer_inspect ?? DEFAULT_PRINT_OPTIONS.layer_inspect,
         timelapse: queueItem.timelapse ?? DEFAULT_PRINT_OPTIONS.timelapse,
         nozzle_offset_cali: queueItem.nozzle_offset_cali ?? DEFAULT_PRINT_OPTIONS.nozzle_offset_cali,
+        preheat_override: queueItem.preheat_override ?? DEFAULT_PRINT_OPTIONS.preheat_override,
+        preheat_chamber_target_override: queueItem.preheat_chamber_target_override ?? DEFAULT_PRINT_OPTIONS.preheat_chamber_target_override,
       };
     }
     return DEFAULT_PRINT_OPTIONS;
@@ -239,6 +241,8 @@ export function PrintModal({
       layer_inspect: settings.default_layer_inspect ?? DEFAULT_PRINT_OPTIONS.layer_inspect,
       timelapse: settings.default_timelapse ?? DEFAULT_PRINT_OPTIONS.timelapse,
       nozzle_offset_cali: settings.default_nozzle_offset_cali ?? DEFAULT_PRINT_OPTIONS.nozzle_offset_cali,
+      preheat_override: DEFAULT_PRINT_OPTIONS.preheat_override,
+      preheat_chamber_target_override: DEFAULT_PRINT_OPTIONS.preheat_chamber_target_override,
     });
   }, [settings, mode]);
 

+ 10 - 0
frontend/src/components/PrintModal/types.ts

@@ -41,6 +41,8 @@ export interface PrintModalProps {
 /**
  * Print options that can be configured for a print job.
  */
+export type PreheatOverride = 'inherit' | 'on' | 'off';
+
 export interface PrintOptions {
   bed_levelling: boolean;
   flow_cali: boolean;
@@ -48,6 +50,12 @@ export interface PrintOptions {
   layer_inspect: boolean;
   timelapse: boolean;
   nozzle_offset_cali: boolean;
+  // Per-item preheat / heat-soak override (#1468). 'inherit' uses the global
+  // Settings → Workflow toggle; 'on' / 'off' force the per-print decision.
+  // chamber_target_override is non-null to bypass the per-filament-type
+  // derivation with an explicit °C target.
+  preheat_override: PreheatOverride;
+  preheat_chamber_target_override: number | null;
 }
 
 /**
@@ -60,6 +68,8 @@ export const DEFAULT_PRINT_OPTIONS: PrintOptions = {
   layer_inspect: false,
   timelapse: false,
   nozzle_offset_cali: true,
+  preheat_override: 'inherit',
+  preheat_chamber_target_override: null,
 };
 
 /**

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

@@ -2058,6 +2058,25 @@ export default {
     tempFanPresetsReset: 'Auf Standardwerte zurücksetzen',
     staggeredStart: 'Versetzter Start',
     staggeredStartDescription: 'Standard-Gruppengröße und -Intervall beim Staffeln von Mehrdrucker-Batchstarts. Pro Batch im Druck-Dialog überschreibbar.',
+    preheatTitle: 'Vorheizen & Heat Soak',
+    preheatDescription: 'Heizt das Bett (und die Kammer, sofern unterstützt) auf und hält die Temperatur, bevor jeder Druck aus der Warteschlange startet. Hilfreich für technische Filamente (PA, ABS) auf Druckern ohne aktive Kammerheizung — das Bett wärmt die Kammer per Abstrahlung, während der Soak-Timer läuft. Die Bett-Zieltemperatur wird aus der Druckdatei gelesen; das Kammerverhalten hängt vom Druckermodell ab.',
+    preheatEnabled: 'Vorheizen & Soak aktivieren',
+    preheatEnabledDesc: 'Wenn aus, starten Drucke aus der Warteschlange sofort. Jeder Warteschlangeneintrag kann das pro Druck überschreiben.',
+    preheatFilamentTargetsLabel: 'Kammer-Ziel je Filament (°C)',
+    preheatFilamentTargetsHint: 'Bambuddy wählt das höchste Ziel über die geladenen AMS-Slots; reine PLA-Drucke ergeben 0 und überspringen die Kammerphase automatisch.',
+    preheatFilamentTargetsReset: 'Auf Standardwerte zurücksetzen',
+    preheatFilamentTargetsDefaultRow: 'Sonstige / nicht zugeordnet',
+    preheatMaxWait: 'Max. Wartezeit (Sekunden)',
+    preheatMaxWaitHelp: 'Obergrenze für die Aufwärmphase, bevor in den Soak gewechselt wird.',
+    preheatSoak: 'Soak (Sekunden)',
+    preheatSoakHelp: 'Haltezeit nach Erreichen des Ziels oder nach Ablauf der Max-Wartezeit.',
+    preheatHardwareTitle: 'Verhalten je Drucker:',
+    preheatHardwareDetail: 'H2C/H2D/H2D Pro/H2S/X2D/X1E heizen die Kammer aktiv per M141. X1C/P2S lesen die Kammertemperatur, heizen sie aber nur per Bett-Abstrahlung. P1S/P1P/A1/A1 Mini haben keinen Kammersensor — nur der Soak-Timer greift.',
+    preheatPerItemDesc: 'Bett und Kammer vor diesem Druck aufheizen. Standardmäßig wird der globale Umschalter aus Einstellungen → Workflow verwendet.',
+    preheatOverride_inherit: 'Übernehmen',
+    preheatOverride_on: 'An',
+    preheatOverride_off: 'Aus',
+    preheatTargetOverride: 'Kammer-Ziel überschreiben (°C, leer = Filament-Standard)',
     plateClear: 'Druckplatte-Bestätigung',
     requirePlateClear: 'Druckplatte-Bestätigung erforderlich',
     requirePlateClearDescription: 'Wenn aktiviert, wartet der Scheduler auf eine Druckplatten-Bestätigung pro Drucker, bevor geplante Drucke auf Druckern mit abgeschlossenen Aufträgen gestartet werden. Wenn dies deaktiviert ist, werden auch das Druckplatten-Status-Badge und die Schaltfläche "Druckplatte als freigegeben markieren" auf den Druckerkarten ausgeblendet.',

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

@@ -2077,6 +2077,25 @@ export default {
     tempFanPresetsReset: 'Reset to defaults',
     staggeredStart: 'Staggered Start',
     staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
+    preheatTitle: 'Preheat & Heat Soak',
+    preheatDescription: 'Heat the bed (and chamber, if supported) and hold at temperature before each queued print starts. Helpful for engineering filaments (PA, ABS) on printers without an active chamber heater — the bed warms the chamber by radiation while the soak timer runs. The bed target is read from the print file; chamber behaviour depends on printer model.',
+    preheatEnabled: 'Enable preheat & soak',
+    preheatEnabledDesc: 'When off, queued prints dispatch immediately. Each queue item can override per print.',
+    preheatFilamentTargetsLabel: 'Per-filament chamber target (°C)',
+    preheatFilamentTargetsHint: 'Bambuddy picks the highest target across the loaded AMS slots; PLA-only prints derive 0 and skip the chamber phase automatically.',
+    preheatFilamentTargetsReset: 'Reset to defaults',
+    preheatFilamentTargetsDefaultRow: 'Other / unmapped',
+    preheatMaxWait: 'Max wait (seconds)',
+    preheatMaxWaitHelp: 'Cap on the chamber warm-up phase before falling through.',
+    preheatSoak: 'Soak (seconds)',
+    preheatSoakHelp: 'Hold time after target reached or max-wait elapsed.',
+    preheatHardwareTitle: 'Per-printer behaviour:',
+    preheatHardwareDetail: 'H2C/H2D/H2D Pro/H2S/X2D/X1E actively heat the chamber via M141. X1C/P2S read chamber temp but rely on bed-radiation heating. P1S/P1P/A1/A1 Mini have no chamber sensor — only the soak timer applies.',
+    preheatPerItemDesc: 'Heat the bed and chamber before this print starts. Defaults to the global Settings → Workflow toggle.',
+    preheatOverride_inherit: 'Inherit',
+    preheatOverride_on: 'On',
+    preheatOverride_off: 'Off',
+    preheatTargetOverride: 'Chamber target override (°C, blank = filament default)',
     plateClear: 'Plate-Clear Confirmation',
     requirePlateClear: 'Require plate-clear confirmation',
     requirePlateClearDescription: 'When enabled, the scheduler waits for per-printer plate-clear confirmation before starting queued prints on printers with finished jobs. Disabling this also hides the plate status badge and the "Mark plate as cleared" button on printer cards.',

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

@@ -2061,6 +2061,25 @@ export default {
     tempFanPresetsReset: 'Restablecer valores predeterminados',
     staggeredStart: 'Inicio escalonado',
     staggeredStartDescription: 'Tamaño de grupo e intervalo predeterminados al escalonar los inicios de lotes en varias impresoras. Se pueden anular por lote en la ventana de impresión.',
+    preheatTitle: 'Precalentamiento y Heat Soak',
+    preheatDescription: 'Calienta la cama (y la cámara, si es compatible) y mantiene la temperatura antes de iniciar cada impresión en cola. Útil para filamentos técnicos (PA, ABS) en impresoras sin calentador activo de cámara — la cama calienta la cámara por radiación mientras corre el temporizador de soak. La temperatura objetivo de la cama se lee del archivo de impresión; el comportamiento de la cámara depende del modelo de impresora.',
+    preheatEnabled: 'Activar precalentamiento y soak',
+    preheatEnabledDesc: 'Si está desactivado, las impresiones en cola se inician de inmediato. Cada elemento puede anular por impresión.',
+    preheatFilamentTargetsLabel: 'Objetivo de cámara por filamento (°C)',
+    preheatFilamentTargetsHint: 'Bambuddy elige el objetivo más alto entre los slots AMS cargados; las impresiones solo PLA derivan 0 y omiten la fase de cámara automáticamente.',
+    preheatFilamentTargetsReset: 'Restablecer valores predeterminados',
+    preheatFilamentTargetsDefaultRow: 'Otros / sin asignar',
+    preheatMaxWait: 'Espera máx. (segundos)',
+    preheatMaxWaitHelp: 'Tope de la fase de calentamiento de cámara antes de pasar al soak.',
+    preheatSoak: 'Soak (segundos)',
+    preheatSoakHelp: 'Tiempo de retención tras alcanzar el objetivo o caducar la espera máx.',
+    preheatHardwareTitle: 'Comportamiento por impresora:',
+    preheatHardwareDetail: 'H2C/H2D/H2D Pro/H2S/X2D/X1E calientan activamente la cámara vía M141. X1C/P2S leen la temperatura pero solo calientan la cámara por radiación de la cama. P1S/P1P/A1/A1 Mini no tienen sensor de cámara — solo aplica el temporizador de soak.',
+    preheatPerItemDesc: 'Calienta la cama y la cámara antes de iniciar esta impresión. Por defecto usa el interruptor global de Ajustes → Workflow.',
+    preheatOverride_inherit: 'Heredar',
+    preheatOverride_on: 'Activado',
+    preheatOverride_off: 'Desactivado',
+    preheatTargetOverride: 'Sobrescribir objetivo de cámara (°C, vacío = por filamento)',
     plateClear: 'Confirmación de cama despejada',
     requirePlateClear: 'Requerir confirmación de cama despejada',
     requirePlateClearDescription: 'Cuando está activado, el planificador espera la confirmación de cama despejada por impresora antes de iniciar impresiones en cola en impresoras con trabajos finalizados. Desactivar esto también oculta la insignia de estado de la cama y el botón "Marcar cama como despejada" en las tarjetas de impresora.',

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

@@ -2014,6 +2014,25 @@ export default {
     tempFanPresetsReset: 'Rétablir les valeurs par défaut',
     staggeredStart: 'Démarrage échelonné',
     staggeredStartDescription: 'Taille de groupe et intervalle par défaut lors de l\'échelonnement des démarrages de lots multi-imprimantes. Modifiable par lot dans la fenêtre d\'impression.',
+    preheatTitle: 'Préchauffage & Heat Soak',
+    preheatDescription: 'Chauffe le plateau (et la chambre, si pris en charge) et maintient la température avant le démarrage de chaque impression de la file. Utile pour les filaments techniques (PA, ABS) sur les imprimantes sans chauffage actif de la chambre — le plateau chauffe la chambre par rayonnement pendant que le minuteur de soak s\'écoule. La température cible du plateau provient du fichier d\'impression ; le comportement de la chambre dépend du modèle d\'imprimante.',
+    preheatEnabled: 'Activer préchauffage & soak',
+    preheatEnabledDesc: 'Si désactivé, les impressions en file démarrent immédiatement. Chaque élément peut surcharger par impression.',
+    preheatFilamentTargetsLabel: 'Cible chambre par filament (°C)',
+    preheatFilamentTargetsHint: 'Bambuddy choisit la cible la plus haute parmi les slots AMS chargés ; les impressions PLA uniquement dérivent 0 et sautent la phase chambre automatiquement.',
+    preheatFilamentTargetsReset: 'Restaurer les valeurs par défaut',
+    preheatFilamentTargetsDefaultRow: 'Autre / non mappé',
+    preheatMaxWait: 'Attente max (secondes)',
+    preheatMaxWaitHelp: 'Plafond de la phase de chauffe de la chambre avant passage au soak.',
+    preheatSoak: 'Soak (secondes)',
+    preheatSoakHelp: 'Maintien après l\'atteinte de la cible ou l\'expiration de l\'attente max.',
+    preheatHardwareTitle: 'Comportement par imprimante :',
+    preheatHardwareDetail: 'H2C/H2D/H2D Pro/H2S/X2D/X1E chauffent activement la chambre via M141. X1C/P2S lisent la température mais ne chauffent la chambre que par rayonnement du plateau. P1S/P1P/A1/A1 Mini n\'ont pas de capteur de chambre — seul le minuteur de soak s\'applique.',
+    preheatPerItemDesc: 'Chauffe le plateau et la chambre avant le démarrage de cette impression. Utilise par défaut le commutateur global de Paramètres → Workflow.',
+    preheatOverride_inherit: 'Hériter',
+    preheatOverride_on: 'Activé',
+    preheatOverride_off: 'Désactivé',
+    preheatTargetOverride: 'Surcharger la cible chambre (°C, vide = par filament)',
     plateClear: 'Confirmation de plateau libre',
     requirePlateClear: 'Exiger la confirmation de plateau libre',
     requirePlateClearDescription: 'Lorsque cette option est activée, le planificateur attend une confirmation de plateau libre par imprimante avant de lancer les impressions en file d\'attente sur les imprimantes ayant terminé. La désactiver masque également le badge d\'état du plateau et le bouton « Marquer le plateau comme dégagé » sur les cartes d\'imprimante.',

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

@@ -2014,6 +2014,25 @@ export default {
     tempFanPresetsReset: 'Ripristina valori predefiniti',
     staggeredStart: 'Avvio scaglionato',
     staggeredStartDescription: 'Dimensione gruppo e intervallo predefiniti per scaglionare avvii di batch multi-stampante. Sovrascrivibili per batch nella finestra di stampa.',
+    preheatTitle: 'Preriscaldo & Heat Soak',
+    preheatDescription: 'Riscalda il piano (e la camera, se supportata) e mantiene la temperatura prima dell\'avvio di ogni stampa in coda. Utile per filamenti tecnici (PA, ABS) su stampanti senza riscaldatore attivo della camera — il piano scalda la camera per irraggiamento mentre il timer di soak scorre. La temperatura target del piano viene letta dal file di stampa; il comportamento della camera dipende dal modello di stampante.',
+    preheatEnabled: 'Abilita preriscaldo & soak',
+    preheatEnabledDesc: 'Se disattivato, le stampe in coda partono subito. Ogni elemento può sovrascrivere per singola stampa.',
+    preheatFilamentTargetsLabel: 'Target camera per filamento (°C)',
+    preheatFilamentTargetsHint: 'Bambuddy sceglie il target più alto tra gli slot AMS caricati; le stampe solo PLA derivano 0 e saltano la fase camera automaticamente.',
+    preheatFilamentTargetsReset: 'Ripristina valori predefiniti',
+    preheatFilamentTargetsDefaultRow: 'Altro / non mappato',
+    preheatMaxWait: 'Attesa max (secondi)',
+    preheatMaxWaitHelp: 'Limite della fase di riscaldamento camera prima del passaggio al soak.',
+    preheatSoak: 'Soak (secondi)',
+    preheatSoakHelp: 'Tenuta dopo il raggiungimento del target o lo scadere dell\'attesa max.',
+    preheatHardwareTitle: 'Comportamento per stampante:',
+    preheatHardwareDetail: 'H2C/H2D/H2D Pro/H2S/X2D/X1E riscaldano attivamente la camera con M141. X1C/P2S leggono la temperatura ma scaldano la camera solo per irraggiamento dal piano. P1S/P1P/A1/A1 Mini non hanno sensore di camera — vale solo il timer di soak.',
+    preheatPerItemDesc: 'Riscalda il piano e la camera prima dell\'avvio di questa stampa. Per impostazione predefinita usa l\'interruttore globale in Impostazioni → Workflow.',
+    preheatOverride_inherit: 'Eredita',
+    preheatOverride_on: 'Attivo',
+    preheatOverride_off: 'Spento',
+    preheatTargetOverride: 'Sovrascrivi target camera (°C, vuoto = per filamento)',
     plateClear: 'Conferma piatto libero',
     requirePlateClear: 'Richiedi conferma piatto libero',
     requirePlateClearDescription: 'Quando questa opzione è abilitata, lo scheduler attende una conferma per stampante che il piatto sia libero prima di avviare le stampe in coda su stampanti con lavori completati. Disabilitandola vengono nascosti anche il badge di stato del piatto e il pulsante "Segna il piatto come liberato" sulle schede stampante.',

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

@@ -2057,6 +2057,25 @@ export default {
     tempFanPresetsReset: 'デフォルトにリセット',
     staggeredStart: '段階的開始',
     staggeredStartDescription: '複数プリンターのバッチ開始を段階的に行う際のデフォルトのグループサイズと間隔。プリントモーダルでバッチごとに上書き可能。',
+    preheatTitle: 'プレヒート & ヒートソーク',
+    preheatDescription: 'キュー内の各印刷を開始する前に、ベッド(および対応している場合はチャンバー)を加熱し、温度を保持します。エンジニアリング素材(PA、ABS)をチャンバー能動加熱のないプリンターで使う際に有効です。ソーク中はベッドの放射熱でチャンバーが温まります。ベッド目標温度は印刷ファイルから読み取られ、チャンバーの挙動はプリンター機種によって異なります。',
+    preheatEnabled: 'プレヒート & ソークを有効化',
+    preheatEnabledDesc: 'オフにすると、キュー内の印刷は即時開始されます。各キュー項目で印刷ごとに上書き可能です。',
+    preheatFilamentTargetsLabel: 'フィラメント別チャンバー目標 (°C)',
+    preheatFilamentTargetsHint: 'Bambuddyはロード済みのAMSスロット中で最も高い目標値を選びます。PLAのみの印刷は0となり、チャンバー段階は自動でスキップされます。',
+    preheatFilamentTargetsReset: 'デフォルトにリセット',
+    preheatFilamentTargetsDefaultRow: 'その他 / 未登録',
+    preheatMaxWait: '最大待機時間(秒)',
+    preheatMaxWaitHelp: 'ソークへ移行するまでのチャンバー加熱フェーズの上限。',
+    preheatSoak: 'ソーク(秒)',
+    preheatSoakHelp: '目標到達後または最大待機経過後の保持時間。',
+    preheatHardwareTitle: 'プリンター別の挙動:',
+    preheatHardwareDetail: 'H2C/H2D/H2D Pro/H2S/X2D/X1EはM141でチャンバーを能動加熱します。X1C/P2Sはチャンバー温度を取得しますが、加熱はベッド放射のみです。P1S/P1P/A1/A1 Miniにはチャンバーセンサーがなく、ソークタイマーのみが適用されます。',
+    preheatPerItemDesc: 'この印刷を開始する前にベッドとチャンバーを加熱します。既定では設定 → ワークフローのグローバルトグルを使用します。',
+    preheatOverride_inherit: '継承',
+    preheatOverride_on: 'オン',
+    preheatOverride_off: 'オフ',
+    preheatTargetOverride: 'チャンバー目標を上書き (°C、空欄でフィラメント既定値)',
     plateClear: 'プレートクリア確認',
     requirePlateClear: 'プレートクリア確認を必須にする',
     requirePlateClearDescription: '有効にすると、スケジューラーは完了したプリンターでキューの印刷を開始する前に、プリンターごとのプレートクリア確認を待ちます。無効にすると、プリンターカード上のプレート状態バッジと「プレートをクリア済みにする」ボタンも非表示になります。',

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

@@ -1939,6 +1939,25 @@ export default {
     tempFanPresetsReset: '기본값으로 재설정',
     staggeredStart: '엇갈린 시작',
     staggeredStartDescription: '다중 프린터 일괄 시작 시 기본 그룹 크기 및 간격. 인쇄 모달에서 배치별로 재정의할 수 있습니다.',
+    preheatTitle: '예열 & 히트 소크',
+    preheatDescription: '대기열의 각 인쇄가 시작되기 전에 베드(및 지원되는 경우 챔버)를 가열하고 온도를 유지합니다. 챔버 능동 가열이 없는 프린터에서 엔지니어링 필라멘트(PA, ABS) 인쇄에 유용합니다. 소크 타이머가 진행되는 동안 베드 복사열로 챔버가 데워집니다. 베드 목표 온도는 인쇄 파일에서 읽어오며 챔버 동작은 프린터 모델에 따라 다릅니다.',
+    preheatEnabled: '예열 & 소크 활성화',
+    preheatEnabledDesc: '꺼짐일 경우 대기열 인쇄가 즉시 시작됩니다. 각 항목은 인쇄별로 재정의할 수 있습니다.',
+    preheatFilamentTargetsLabel: '필라멘트별 챔버 목표 (°C)',
+    preheatFilamentTargetsHint: 'Bambuddy는 로드된 AMS 슬롯 중 가장 높은 목표를 선택합니다. PLA만 사용하는 인쇄는 0으로 도출되어 챔버 단계를 자동으로 건너뜁니다.',
+    preheatFilamentTargetsReset: '기본값으로 재설정',
+    preheatFilamentTargetsDefaultRow: '기타 / 미매핑',
+    preheatMaxWait: '최대 대기 (초)',
+    preheatMaxWaitHelp: '소크로 전환되기 전 챔버 가열 단계의 상한.',
+    preheatSoak: '소크 (초)',
+    preheatSoakHelp: '목표 도달 또는 최대 대기 경과 후 유지 시간.',
+    preheatHardwareTitle: '프린터별 동작:',
+    preheatHardwareDetail: 'H2C/H2D/H2D Pro/H2S/X2D/X1E는 M141으로 챔버를 능동적으로 가열합니다. X1C/P2S는 챔버 온도를 읽지만 베드 복사열로만 가열됩니다. P1S/P1P/A1/A1 Mini는 챔버 센서가 없어 소크 타이머만 적용됩니다.',
+    preheatPerItemDesc: '이 인쇄가 시작되기 전에 베드와 챔버를 가열합니다. 기본적으로 설정 → 워크플로의 전역 토글을 사용합니다.',
+    preheatOverride_inherit: '상속',
+    preheatOverride_on: '켜기',
+    preheatOverride_off: '끄기',
+    preheatTargetOverride: '챔버 목표 재정의 (°C, 비우면 필라멘트 기본값)',
     plateClear: '플레이트 비움 확인',
     requirePlateClear: '플레이트 비움 확인 필요',
     requirePlateClearDescription: '활성화하면 스케줄러가 완료된 작업이 있는 프린터에서 대기 중인 인쇄를 시작하기 전에 프린터별 플레이트 비움 확인을 기다립니다.',

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

@@ -2014,6 +2014,25 @@ export default {
     tempFanPresetsReset: 'Restaurar padrões',
     staggeredStart: 'Início escalonado',
     staggeredStartDescription: 'Tamanho de grupo e intervalo padrão ao escalonar inícios de lotes multi-impressora. Pode ser sobrescrito por lote no modal de impressão.',
+    preheatTitle: 'Pré-aquecimento & Heat Soak',
+    preheatDescription: 'Aquece a mesa (e a câmara, se suportada) e mantém a temperatura antes de iniciar cada impressão da fila. Útil para filamentos técnicos (PA, ABS) em impressoras sem aquecedor ativo de câmara — a mesa aquece a câmara por radiação enquanto o temporizador de soak roda. A temperatura alvo da mesa vem do arquivo de impressão; o comportamento da câmara depende do modelo da impressora.',
+    preheatEnabled: 'Ativar pré-aquecimento & soak',
+    preheatEnabledDesc: 'Quando desligado, as impressões na fila iniciam imediatamente. Cada item pode sobrescrever por impressão.',
+    preheatFilamentTargetsLabel: 'Alvo da câmara por filamento (°C)',
+    preheatFilamentTargetsHint: 'Bambuddy escolhe o alvo mais alto entre os slots AMS carregados; impressões apenas com PLA derivam 0 e pulam a fase da câmara automaticamente.',
+    preheatFilamentTargetsReset: 'Redefinir para padrões',
+    preheatFilamentTargetsDefaultRow: 'Outro / sem mapeamento',
+    preheatMaxWait: 'Espera máx. (segundos)',
+    preheatMaxWaitHelp: 'Teto da fase de aquecimento da câmara antes de passar ao soak.',
+    preheatSoak: 'Soak (segundos)',
+    preheatSoakHelp: 'Tempo de retenção após atingir o alvo ou expirar a espera máx.',
+    preheatHardwareTitle: 'Comportamento por impressora:',
+    preheatHardwareDetail: 'H2C/H2D/H2D Pro/H2S/X2D/X1E aquecem ativamente a câmara via M141. X1C/P2S leem a temperatura mas só aquecem a câmara por radiação da mesa. P1S/P1P/A1/A1 Mini não têm sensor de câmara — apenas o temporizador de soak se aplica.',
+    preheatPerItemDesc: 'Aquece a mesa e a câmara antes desta impressão começar. Por padrão usa o interruptor global em Configurações → Workflow.',
+    preheatOverride_inherit: 'Herdar',
+    preheatOverride_on: 'Ligado',
+    preheatOverride_off: 'Desligado',
+    preheatTargetOverride: 'Sobrescrever alvo da câmara (°C, vazio = por filamento)',
     plateClear: 'Confirmação de placa livre',
     requirePlateClear: 'Exigir confirmação de placa livre',
     requirePlateClearDescription: 'Quando ativado, o agendador aguarda uma confirmação de placa livre por impressora antes de iniciar impressões na fila em impressoras com trabalhos concluídos. Desativar isso também oculta o indicador de status da placa e o botão "Marcar placa como liberada" nos cartões das impressoras.',

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

@@ -2062,6 +2062,25 @@ export default {
     tempFanPresetsReset: 'Varsayılanlara sıfırla',
     staggeredStart: 'Kademeli Başlatma',
     staggeredStartDescription: 'Çoklu yazıcı toplu başlatmaları kademelendirilirken varsayılan grup boyutu ve aralığı. Baskı modalinde yığın başına geçersiz kılınabilir.',
+    preheatTitle: 'Ön Isıtma & Heat Soak',
+    preheatDescription: 'Kuyruktaki her baskı başlamadan önce tablayı (ve destekleniyorsa odayı) ısıtır ve sıcaklığı korur. Aktif oda ısıtıcısı olmayan yazıcılarda mühendislik filamentleri (PA, ABS) için yararlıdır — soak zamanlayıcısı çalışırken tabla, odayı radyasyonla ısıtır. Tabla hedef sıcaklığı baskı dosyasından okunur; oda davranışı yazıcı modeline bağlıdır.',
+    preheatEnabled: 'Ön ısıtma & soak\'u etkinleştir',
+    preheatEnabledDesc: 'Kapalıyken kuyruktaki baskılar hemen başlar. Her kuyruk öğesi baskı bazında geçersiz kılabilir.',
+    preheatFilamentTargetsLabel: 'Filament başına oda hedefi (°C)',
+    preheatFilamentTargetsHint: 'Bambuddy yüklü AMS yuvaları arasındaki en yüksek hedefi seçer; yalnız PLA baskıları 0 türeterek oda aşamasını otomatik atlar.',
+    preheatFilamentTargetsReset: 'Varsayılanlara sıfırla',
+    preheatFilamentTargetsDefaultRow: 'Diğer / eşlenmemiş',
+    preheatMaxWait: 'Maks. bekleme (saniye)',
+    preheatMaxWaitHelp: 'Soak\'a geçilmeden önce oda ısıtma aşaması için üst sınır.',
+    preheatSoak: 'Soak (saniye)',
+    preheatSoakHelp: 'Hedefe ulaşıldıktan veya maks. bekleme dolduktan sonra tutma süresi.',
+    preheatHardwareTitle: 'Yazıcıya göre davranış:',
+    preheatHardwareDetail: 'H2C/H2D/H2D Pro/H2S/X2D/X1E odayı M141 ile aktif ısıtır. X1C/P2S oda sıcaklığını okur ancak yalnızca tabla radyasyonu ile ısınır. P1S/P1P/A1/A1 Mini\'de oda sensörü yoktur — yalnızca soak zamanlayıcısı geçerlidir.',
+    preheatPerItemDesc: 'Bu baskı başlamadan önce tablayı ve odayı ısıtın. Varsayılan olarak Ayarlar → İş Akışı genel anahtarını kullanır.',
+    preheatOverride_inherit: 'Devral',
+    preheatOverride_on: 'Açık',
+    preheatOverride_off: 'Kapalı',
+    preheatTargetOverride: 'Oda hedefini geçersiz kıl (°C, boş = filament varsayılanı)',
     plateClear: 'Plaka Temizleme Onayı',
     requirePlateClear: 'Plaka temizleme onayı gerektir',
     requirePlateClearDescription: 'Etkinleştirildiğinde, planlayıcı bitmiş işleri olan yazıcılarda kuyruktaki baskıları başlatmadan önce yazıcı başına plaka temizleme onayını bekler. Bunu devre dışı bırakmak ayrıca plaka durum rozetini ve yazıcı kartlarındaki "Plakayı temizlendi olarak işaretle" düğmesini gizler.',

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

@@ -2059,6 +2059,25 @@ export default {
     tempFanPresetsReset: '恢复默认值',
     staggeredStart: '错峰启动',
     staggeredStartDescription: '错峰启动多台打印机批次时的默认组大小和间隔。可在打印对话框中按批次覆盖。',
+    preheatTitle: '预热与热保温',
+    preheatDescription: '在每个排队打印开始前预热热床(以及支持的话腔体)并保持温度。对于没有腔体主动加热的打印机使用工程材料(PA、ABS)很有帮助 — 保温计时进行时,热床通过辐射温暖腔体。热床目标温度从打印文件读取;腔体行为取决于打印机型号。',
+    preheatEnabled: '启用预热与保温',
+    preheatEnabledDesc: '关闭时,排队打印立即开始。每个队列项可按单次打印覆盖。',
+    preheatFilamentTargetsLabel: '按耗材的腔体目标 (°C)',
+    preheatFilamentTargetsHint: 'Bambuddy 会在已装入的 AMS 槽位中选择最高目标;仅 PLA 的打印推导为 0 并自动跳过腔体阶段。',
+    preheatFilamentTargetsReset: '重置为默认值',
+    preheatFilamentTargetsDefaultRow: '其他 / 未映射',
+    preheatMaxWait: '最长等待(秒)',
+    preheatMaxWaitHelp: '进入保温阶段前的腔体预热阶段上限。',
+    preheatSoak: '保温(秒)',
+    preheatSoakHelp: '达到目标或最长等待结束后的保持时间。',
+    preheatHardwareTitle: '按打印机的行为:',
+    preheatHardwareDetail: 'H2C/H2D/H2D Pro/H2S/X2D/X1E 通过 M141 主动加热腔体。X1C/P2S 读取腔体温度但仅靠热床辐射加热。P1S/P1P/A1/A1 Mini 无腔体传感器 — 仅保温计时器生效。',
+    preheatPerItemDesc: '在本次打印开始前预热热床和腔体。默认使用设置 → 工作流的全局开关。',
+    preheatOverride_inherit: '继承',
+    preheatOverride_on: '开启',
+    preheatOverride_off: '关闭',
+    preheatTargetOverride: '覆盖腔体目标 (°C,留空使用耗材默认)',
     plateClear: '热床清空确认',
     requirePlateClear: '需要热床清空确认',
     requirePlateClearDescription: '启用后,调度器会在已完成打印的打印机上启动排队打印之前,等待每台打印机的热床清空确认。禁用后,也会隐藏打印机卡片上的打印板状态标记和“将打印板标记为已清理”按钮。',

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

@@ -2059,6 +2059,25 @@ export default {
     tempFanPresetsReset: '還原預設值',
     staggeredStart: '錯開啟動',
     staggeredStartDescription: '多台印表機批次啟動時的預設群組大小與間隔。可在列印對話框中逐批覆寫。',
+    preheatTitle: '預熱與熱保溫',
+    preheatDescription: '在每個佇列列印開始前預熱熱床(如支援則包含腔體)並維持溫度。對於沒有腔體主動加熱的印表機使用工程材料(PA、ABS)相當有用 — 保溫計時進行時,熱床透過輻射溫暖腔體。熱床目標溫度從列印檔案讀取;腔體行為取決於印表機型號。',
+    preheatEnabled: '啟用預熱與保溫',
+    preheatEnabledDesc: '關閉時,佇列列印立即開始。每個佇列項目可依單次列印覆寫。',
+    preheatFilamentTargetsLabel: '依耗材的腔體目標 (°C)',
+    preheatFilamentTargetsHint: 'Bambuddy 在已裝入的 AMS 槽位中挑選最高目標;只有 PLA 的列印推導為 0,並自動跳過腔體階段。',
+    preheatFilamentTargetsReset: '重設為預設值',
+    preheatFilamentTargetsDefaultRow: '其他 / 未對應',
+    preheatMaxWait: '最長等待(秒)',
+    preheatMaxWaitHelp: '進入保溫階段前的腔體預熱階段上限。',
+    preheatSoak: '保溫(秒)',
+    preheatSoakHelp: '達到目標或最長等待結束後的維持時間。',
+    preheatHardwareTitle: '依印表機的行為:',
+    preheatHardwareDetail: 'H2C/H2D/H2D Pro/H2S/X2D/X1E 透過 M141 主動加熱腔體。X1C/P2S 讀取腔體溫度但僅靠熱床輻射加熱。P1S/P1P/A1/A1 Mini 無腔體感測器 — 僅保溫計時器生效。',
+    preheatPerItemDesc: '在本次列印開始前預熱熱床與腔體。預設使用設定 → 工作流程的全域開關。',
+    preheatOverride_inherit: '繼承',
+    preheatOverride_on: '開啟',
+    preheatOverride_off: '關閉',
+    preheatTargetOverride: '覆寫腔體目標 (°C,留空使用耗材預設)',
     plateClear: '熱床清空確認',
     requirePlateClear: '需要熱床清空確認',
     requirePlateClearDescription: '啟用後,排程器會在已完成列印的印表機上啟動佇列列印之前,等待每臺印表機的熱床清空確認。停用後,也會隱藏印表機卡片上的列印板狀態標記和「將列印板標記為已清理」按鈕。',

+ 116 - 9
frontend/src/pages/SettingsPage.tsx

@@ -8,6 +8,7 @@ import { formatDateOnly } from '../utils/date';
 import { getCurrencySymbol, SUPPORTED_CURRENCIES } from '../utils/currency';
 import { checkPasswordComplexity } from '../utils/password';
 import { PRESET_CATEGORIES, parsePresetTriple } from '../utils/temperatureFanPresets';
+import { PreheatFilamentTargetsEditor } from '../components/PreheatFilamentTargetsEditor';
 import type { APIKey, AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse } from '../api/client';
 import { Card, CardContent, CardDensityProvider, CardHeader } from '../components/Card';
 import { SlicerBundlesPanel } from '../components/SlicerBundlesPanel';
@@ -1010,6 +1011,10 @@ export function SettingsPage() {
       (settings.stagger_group_size ?? 2) !== (localSettings.stagger_group_size ?? 2) ||
       (settings.stagger_interval_minutes ?? 5) !== (localSettings.stagger_interval_minutes ?? 5) ||
       (settings.require_plate_clear ?? false) !== (localSettings.require_plate_clear ?? false) ||
+      (settings.preheat_enabled ?? false) !== (localSettings.preheat_enabled ?? false) ||
+      (settings.preheat_filament_targets ?? '') !== (localSettings.preheat_filament_targets ?? '') ||
+      (settings.preheat_max_wait_seconds ?? 900) !== (localSettings.preheat_max_wait_seconds ?? 900) ||
+      (settings.preheat_soak_seconds ?? 300) !== (localSettings.preheat_soak_seconds ?? 300) ||
       (settings.nozzle_temp_presets ?? '') !== (localSettings.nozzle_temp_presets ?? '') ||
       (settings.bed_temp_presets ?? '') !== (localSettings.bed_temp_presets ?? '') ||
       (settings.chamber_temp_presets ?? '') !== (localSettings.chamber_temp_presets ?? '') ||
@@ -1104,6 +1109,10 @@ export function SettingsPage() {
         stagger_group_size: localSettings.stagger_group_size,
         stagger_interval_minutes: localSettings.stagger_interval_minutes,
         require_plate_clear: localSettings.require_plate_clear,
+        preheat_enabled: localSettings.preheat_enabled,
+        preheat_filament_targets: localSettings.preheat_filament_targets,
+        preheat_max_wait_seconds: localSettings.preheat_max_wait_seconds,
+        preheat_soak_seconds: localSettings.preheat_soak_seconds,
         nozzle_temp_presets: localSettings.nozzle_temp_presets,
         bed_temp_presets: localSettings.bed_temp_presets,
         chamber_temp_presets: localSettings.chamber_temp_presets,
@@ -4166,6 +4175,37 @@ export function SettingsPage() {
             </CardContent>
           </Card>
 
+          {/* Plate-Clear Confirmation */}
+          <Card id="card-plate">
+            <CardHeader>
+              <h3 className="text-base font-semibold text-white flex items-center gap-2">
+                <Shield className="w-4 h-4 text-bambu-green" />
+                {t('settings.plateClear', 'Plate-Clear Confirmation')}
+              </h3>
+            </CardHeader>
+            <CardContent className="space-y-3">
+              <div className="flex items-center justify-between">
+                <div className="flex-1 mr-4">
+                  <p className="text-sm text-white">
+                    {t('settings.requirePlateClear', 'Require plate-clear confirmation')}
+                  </p>
+                  <p className="text-xs text-bambu-gray mt-1">
+                    {t('settings.requirePlateClearDescription', 'When enabled, the scheduler waits for per-printer plate-clear confirmation before starting queued prints on printers with finished jobs. Disabling this also hides the plate status badge and the "Mark plate as cleared" button on printer cards.')}
+                  </p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={localSettings.require_plate_clear ?? false}
+                    onChange={(e) => updateSetting('require_plate_clear', e.target.checked)}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
+            </CardContent>
+          </Card>
+
           {/* Temperature & Fan Presets */}
           <Card id="card-temp-fan-presets">
             <CardHeader>
@@ -4284,34 +4324,101 @@ export function SettingsPage() {
             </CardContent>
           </Card>
 
-          {/* Plate-Clear Confirmation */}
-          <Card id="card-plate">
+          {/* Preheat & Heat Soak (#1468) */}
+          <Card id="card-preheat">
             <CardHeader>
               <h3 className="text-base font-semibold text-white flex items-center gap-2">
-                <Shield className="w-4 h-4 text-bambu-green" />
-                {t('settings.plateClear', 'Plate-Clear Confirmation')}
+                <Flame className="w-4 h-4 text-amber-400" />
+                {t('settings.preheatTitle', 'Preheat & Heat Soak')}
               </h3>
             </CardHeader>
             <CardContent className="space-y-3">
+              <p className="text-xs text-bambu-gray">
+                {t('settings.preheatDescription', 'Heat the bed (and chamber, if supported) and hold at temperature before each queued print starts. Helpful for engineering filaments (PA, ABS) on printers without an active chamber heater — the bed warms the chamber by radiation while the soak timer runs. The bed target is read from the print file; chamber behaviour depends on printer model.')}
+              </p>
               <div className="flex items-center justify-between">
                 <div className="flex-1 mr-4">
                   <p className="text-sm text-white">
-                    {t('settings.requirePlateClear', 'Require plate-clear confirmation')}
+                    {t('settings.preheatEnabled', 'Enable preheat & soak')}
                   </p>
-                  <p className="text-xs text-bambu-gray mt-1">
-                    {t('settings.requirePlateClearDescription', 'When enabled, the scheduler waits for per-printer plate-clear confirmation before starting queued prints on printers with finished jobs. Disabling this also hides the plate status badge and the "Mark plate as cleared" button on printer cards.')}
+                  <p className="text-xs text-bambu-gray mt-0.5">
+                    {t('settings.preheatEnabledDesc', 'When off, queued prints dispatch immediately.')}
                   </p>
                 </div>
                 <label className="relative inline-flex items-center cursor-pointer">
                   <input
                     type="checkbox"
-                    checked={localSettings.require_plate_clear ?? false}
-                    onChange={(e) => updateSetting('require_plate_clear', e.target.checked)}
+                    checked={localSettings.preheat_enabled ?? false}
+                    onChange={(e) => updateSetting('preheat_enabled', e.target.checked)}
                     className="sr-only peer"
                   />
                   <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
                 </label>
               </div>
+              <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1">
+                <div>
+                  <label className="block text-xs text-bambu-gray mb-1">
+                    {t('settings.preheatMaxWait', 'Max wait (seconds)')}
+                  </label>
+                  <input
+                    type="number"
+                    min={60}
+                    max={3600}
+                    value={localSettings.preheat_max_wait_seconds ?? 900}
+                    onChange={(e) => updateSetting('preheat_max_wait_seconds', Math.max(60, Math.min(3600, parseInt(e.target.value) || 900)))}
+                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green disabled:opacity-50"
+                    disabled={!(localSettings.preheat_enabled ?? false)}
+                  />
+                  <p className="text-xs text-bambu-gray mt-1">
+                    {t('settings.preheatMaxWaitHelp', 'Cap on the chamber warm-up phase before falling through.')}
+                  </p>
+                </div>
+                <div>
+                  <label className="block text-xs text-bambu-gray mb-1">
+                    {t('settings.preheatSoak', 'Soak (seconds)')}
+                  </label>
+                  <input
+                    type="number"
+                    min={0}
+                    max={1800}
+                    value={localSettings.preheat_soak_seconds ?? 300}
+                    onChange={(e) => updateSetting('preheat_soak_seconds', Math.max(0, Math.min(1800, parseInt(e.target.value) || 0)))}
+                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green disabled:opacity-50"
+                    disabled={!(localSettings.preheat_enabled ?? false)}
+                  />
+                  <p className="text-xs text-bambu-gray mt-1">
+                    {t('settings.preheatSoakHelp', 'Hold time after target reached or max-wait elapsed.')}
+                  </p>
+                </div>
+              </div>
+              {/* Per-filament chamber target editor (#1468) */}
+              <div className="pt-2 border-t border-bambu-dark-tertiary/50">
+                <div className="flex items-center justify-between mb-1">
+                  <label className="text-sm text-white">
+                    {t('settings.preheatFilamentTargetsLabel', 'Per-filament chamber target (°C)')}
+                  </label>
+                  <button
+                    type="button"
+                    onClick={() => updateSetting('preheat_filament_targets', '')}
+                    title={t('settings.preheatFilamentTargetsReset', 'Reset to defaults')}
+                    className="text-bambu-gray hover:text-white transition-colors p-1 rounded hover:bg-bambu-dark-tertiary"
+                  >
+                    <RotateCcw className="w-3.5 h-3.5" />
+                  </button>
+                </div>
+                <p className="text-xs text-bambu-gray mb-2">
+                  {t('settings.preheatFilamentTargetsHint', 'Bambuddy picks the highest target across the loaded AMS slots; PLA-only prints derive 0 and skip the chamber phase automatically.')}
+                </p>
+                <PreheatFilamentTargetsEditor
+                  value={localSettings.preheat_filament_targets ?? ''}
+                  onChange={(v) => updateSetting('preheat_filament_targets', v)}
+                  disabled={!(localSettings.preheat_enabled ?? false)}
+                />
+              </div>
+              <p className="text-xs text-bambu-gray pt-1 border-t border-bambu-dark-tertiary/50">
+                <span className="font-medium text-bambu-gray/90">{t('settings.preheatHardwareTitle', 'Per-printer behaviour:')}</span>{' '}
+                {t('settings.preheatHardwareDetail', 'H2C/H2D/H2D Pro/H2S/X2D/X1E actively heat the chamber via M141. X1C/P2S read chamber temp but rely on bed-radiation heating. P1S/P1P/A1/A1 Mini have no chamber sensor — only the soak timer applies.')}
+              </p>
             </CardContent>
           </Card>
 

+ 96 - 0
frontend/src/utils/preheatFilamentTargets.ts

@@ -0,0 +1,96 @@
+// Shared frontend helpers for the per-filament chamber-target map (#1468).
+// Mirrors backend `PrintScheduler.DEFAULT_PREHEAT_FILAMENT_TARGETS`. Values
+// are chamber-temperature recommendations from BambuStudio's bundled filament
+// profiles; users can override the whole map via the Settings → Workflow card.
+
+export const DEFAULT_PREHEAT_FILAMENT_TARGETS: Record<string, number> = {
+  PLA: 0,
+  PETG: 0,
+  'PETG-CF': 40,
+  ABS: 45,
+  ASA: 45,
+  PA: 50,
+  'PA-CF': 55,
+  PC: 50,
+  'PC-FR': 50,
+  TPU: 0,
+  PVA: 0,
+  default: 0,
+};
+
+// Stable display order for the editor and any preview UI. Matches the order
+// users will most likely tune (engineering filaments first, then commodity).
+export const PREHEAT_FILAMENT_ORDER: readonly string[] = [
+  'PA-CF',
+  'PA',
+  'PC',
+  'PC-FR',
+  'ABS',
+  'ASA',
+  'PETG-CF',
+  'PETG',
+  'PLA',
+  'TPU',
+  'PVA',
+  'default',
+];
+
+export function parsePreheatFilamentTargets(raw: string): Record<string, number> {
+  if (!raw) return { ...DEFAULT_PREHEAT_FILAMENT_TARGETS };
+  try {
+    const parsed = JSON.parse(raw);
+    if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+      const out: Record<string, number> = {};
+      for (const [key, value] of Object.entries(parsed)) {
+        const num = typeof value === 'number' ? value : Number(value);
+        if (Number.isFinite(num)) {
+          out[key] = Math.max(0, Math.min(60, Math.round(num)));
+        }
+      }
+      if (out.default === undefined) out.default = DEFAULT_PREHEAT_FILAMENT_TARGETS.default;
+      return out;
+    }
+  } catch {
+    // fall through to defaults
+  }
+  return { ...DEFAULT_PREHEAT_FILAMENT_TARGETS };
+}
+
+export function serializePreheatFilamentTargets(map: Record<string, number>): string {
+  // Re-encode in the canonical display order so the on-disk JSON stays
+  // diff-stable. Strip 0 entries that match the default to keep the payload
+  // small — except `default` itself, which we always keep for predictability.
+  const out: Record<string, number> = {};
+  for (const key of PREHEAT_FILAMENT_ORDER) {
+    if (map[key] !== undefined) out[key] = map[key];
+  }
+  // Preserve any custom keys the user added that aren't in our canonical order.
+  for (const [key, value] of Object.entries(map)) {
+    if (!(key in out)) out[key] = value;
+  }
+  return JSON.stringify(out);
+}
+
+// Normalize a printer-reported tray_type to a map key. Mirrors the backend
+// `_normalize_filament_type` — split on space, uppercase.
+export function normalizePreheatFilamentType(trayType: string): string {
+  if (!trayType) return '';
+  return trayType.split(/\s+/)[0].toUpperCase();
+}
+
+// Pick the max chamber target across a list of loaded tray types, falling
+// back to `default` when a type isn't in the map. Returns 0 when nothing
+// is loaded, which short-circuits the chamber phase at dispatch.
+export function deriveChamberTargetForTrays(
+  trayTypes: readonly string[],
+  map: Record<string, number>,
+): number {
+  let best = 0;
+  for (const raw of trayTypes) {
+    const normalized = normalizePreheatFilamentType(raw);
+    if (!normalized) continue;
+    const target = map[normalized] ?? map.default ?? 0;
+    if (target > best) best = target;
+  }
+  return best;
+}

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


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


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


+ 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-CFA6-beJ.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BCPW417s.css">
+    <script type="module" crossorigin src="/assets/index-bqgSupVY.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BxVhuRti.css">
   </head>
   <body>
     <div id="root"></div>

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