mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
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.
156 lines
8.6 KiB
Python
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
|