Files
bambuddy/backend/app/models/print_queue.py
T
maziggy 61a7f2e4ac 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.
2026-06-29 12:35:43 +02:00

156 lines
8.6 KiB
Python

from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base
class PrintQueueItem(Base):
"""Print queue item for scheduled/queued prints."""
__tablename__ = "print_queue"
id: Mapped[int] = mapped_column(primary_key=True)
# Links
printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"), nullable=True)
# Target printer model for model-based assignment (mutually exclusive with printer_id)
# When set, scheduler assigns to any idle printer of matching model
target_model: Mapped[str | None] = mapped_column(String(50), nullable=True)
# Target location filter for model-based assignment (only used with target_model)
# When set, only printers in this location are considered
target_location: Mapped[str | None] = mapped_column(String(100), nullable=True)
# Required filament types for model-based assignment (JSON array, e.g., '["PLA", "PETG"]')
# Used by scheduler to validate printer has compatible filaments loaded
required_filament_types: Mapped[str | None] = mapped_column(Text, nullable=True)
# Waiting reason - explains why a model-based job hasn't started yet
# Set by scheduler when no matching printer is available
waiting_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
# Either archive_id OR library_file_id must be set (archive created at print start from library file)
archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="CASCADE"), nullable=True)
library_file_id: Mapped[int | None] = mapped_column(
ForeignKey("library_files.id", ondelete="CASCADE"), nullable=True
)
project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
batch_id: Mapped[int | None] = mapped_column(ForeignKey("print_batches.id", ondelete="SET NULL"), nullable=True)
# Scheduling
position: Mapped[int] = mapped_column(Integer, default=0) # Queue order
scheduled_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # None = ASAP
manual_start: Mapped[bool] = mapped_column(Boolean, default=False) # Requires manual trigger to start
# Conditions
require_previous_success: Mapped[bool] = mapped_column(Boolean, default=False)
# Power management
auto_off_after: Mapped[bool] = mapped_column(Boolean, default=False) # Power off printer after print
# AMS mapping: JSON array of global tray IDs for each filament slot
# Format: "[5, -1, 2, -1]" where position = slot_id-1, value = global tray ID (-1 = unused)
ams_mapping: Mapped[str | None] = mapped_column(Text, nullable=True)
# Filament overrides for model-based assignment: JSON array of override objects
# Format: '[{"slot_id": 1, "type": "PLA", "color": "#FFFFFF"}]'
# Only slots with overrides are included (sparse). null = use original 3MF values.
filament_overrides: Mapped[str | None] = mapped_column(Text, nullable=True)
# Plate ID for multi-plate 3MF files (1-indexed, None = auto-detect/plate 1)
plate_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Shortest-job-first scheduling
print_time_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True) # Cached from archive/library
been_jumped: Mapped[bool] = mapped_column(Boolean, default=False) # Starvation guard for SJF
# Auto-print G-code injection (#422)
gcode_injection: Mapped[bool] = mapped_column(Boolean, default=False)
# H2C dual-nozzle-rack slicer pick preservation (#1780). BambuStudio's
# project_file MQTT command for rack-swap-capable models (O1C2 today)
# carries per-filament physical nozzle position IDs in `nozzle_mapping`,
# forwarded verbatim through the queue and replayed by the dispatcher so
# the firmware honours the user's pick instead of falling back to
# "last matching nozzle type" auto-pick. Stored as opaque JSON string
# (list[int]); NULL on every other model. `nozzles_info` is a deprecated
# column from the original #1780 attempt — kept nullable so old rows still
# load; never written to or read from.
nozzle_mapping: Mapped[str | None] = mapped_column(Text, nullable=True)
nozzles_info: Mapped[str | None] = mapped_column(Text, nullable=True)
# Printer-card direct uploads create transient library rows. When this is
# true, the scheduler deletes the source row/files after archiving a copy.
cleanup_library_after_dispatch: Mapped[bool] = mapped_column(Boolean, default=False)
# Print options
bed_levelling: Mapped[bool] = mapped_column(Boolean, default=True)
flow_cali: Mapped[bool] = mapped_column(Boolean, default=False)
vibration_cali: Mapped[bool] = mapped_column(Boolean, default=True)
layer_inspect: Mapped[bool] = mapped_column(Boolean, default=False)
timelapse: Mapped[bool] = mapped_column(Boolean, default=False)
use_ams: Mapped[bool] = mapped_column(Boolean, default=True)
# 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")
# Cleared by the per-printer "Resume after failure" action (#1818) so the
# scheduler's `_check_previous_success` lookback skips this row. Without
# this, a single `failed` or `aborted` print poisoned every later
# `require_previous_success` item on the same printer forever — the
# lookback excluded `skipped` but had no way to dismiss the originating
# failure. The flag is per-item, not per-printer, so a fresh failure
# after a resume re-gates downstream items independently.
gate_acknowledged: Mapped[bool] = mapped_column(Boolean, default=False)
# Set by the dispatch scheduler when the assigned spool can't satisfy
# this print's per-slot filament weight (#1496). Display-only flag — the
# actual deficit is recomputed live every time the user clicks ▶, so
# swapping a spool to a fuller one between flag and dispatch clears the
# block automatically.
filament_short: Mapped[bool] = mapped_column(Boolean, default=False)
# User has acknowledged the filament-shortage warning for this item
# ("Print Anyway"). Set by the start route when the user passes
# skip_filament_check=true, or at queue-creation time if PrintModal's
# frontend deficit warning was acknowledged. Survives scheduler ticks so
# the dispatch no longer bounces between "user said anyway" and
# "scheduler re-flagged" (#1698-followup).
skip_filament_check: Mapped[bool] = mapped_column(Boolean, default=False)
# Tracking
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
# Timestamps
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
# User tracking (who added this to the queue)
created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
# Relationships
printer: Mapped["Printer"] = relationship()
archive: Mapped["PrintArchive | None"] = relationship()
library_file: Mapped["LibraryFile | None"] = relationship()
project: Mapped["Project | None"] = relationship(back_populates="queue_items")
batch: Mapped["PrintBatch | None"] = relationship(back_populates="queue_items")
created_by: Mapped["User | None"] = relationship()
from backend.app.models.archive import PrintArchive # noqa: E402
from backend.app.models.library import LibraryFile # noqa: E402
from backend.app.models.print_batch import PrintBatch # noqa: E402
from backend.app.models.printer import Printer # noqa: E402
from backend.app.models.project import Project # noqa: E402
from backend.app.models.user import User # noqa: E402