Files
bambuddy/backend/app/schemas/print_queue.py
maziggy 48244bcfcf fix(queue): tell a pinned queue item why it is waiting (issue #3074)
A job queued as "Any X1C" explains itself when it cannot start: the
model-based branch builds a reason for every candidate printer and puts it
on the row, so the queue shows "Busy: X1C-01" or "Waiting for filament:
X1C-02 (needs PETG)". The same job pinned to one printer showed nothing.
It sat at Pending with waiting_reason NULL for as long as that printer was
busy, which from the outside is indistinguishable from a queue that has
stopped working -- the reporter watched fourteen minutes of it while his
X1C ran a print he had started from its own screen.

The fixed-printer branch had six ways out and none of them wrote the field.
The sensor interlock (#1148) was its only writer, and it cleared the field
up front on every pass where no sensor was holding the printer, so NULL was
not an oversight on those paths but a guarantee.

Every exit now writes, through one helper. The reasons reuse the
model-based branch's vocabulary so _is_busy_only() keeps deciding what is
worth a notification: a printer that is printing, drying, or working
through the item ahead of this one reads as "Busy: <printer>" and stays
silent, because it resolves itself. A printer that is off with no Auto On
plug, and one whose plug could not switch it on, are worth saying.

A finished plate nobody has acknowledged is split out from plain busy and
named as itself. _is_printer_idle() returns the same plain False for that
and for a running print, but they are not the same thing to the person
looking at the queue: one clears itself and the other needs somebody to
walk over to the printer.

That notification fires on the transition into asking, where a busy-only
reason counts as not asking. Testing whether the item was waiting at all --
which is what the model-based branch does -- would never fire it here:
nobody's queue goes straight from idle to an unconfirmed plate, it waits
behind the print first. The cost is that a printer dropping offline,
returning busy and dropping again asks twice rather than once.

The interlock stays silent. It has never sent this notification, and a
change about what the queue displays is not the place to start.

Clearing the field up front is gone with it. It existed so a shut door
could not leave "Waiting on Enclosure Door" standing while the printer
stayed busy with something else, and the new rule carries that guarantee
instead -- whichever exit runs next overwrites it, and the dispatch path
clears it.

Two paths clear it that the report did not mention. A staged item and a
future-scheduled one skip before this branch and never reach it again, so
anything written on an earlier pass would outlive its condition for the
life of the row. That includes the filament-deficit check, which stages the
item itself.

The notification is wrapped: a queue that cannot say why it is waiting is
the bug being fixed, and a queue that stops dispatching because a provider
timed out would be a worse one.

On the frontend, the queue timeline drops any pending item carrying a
reason, on the grounds that such an item will not auto-dispatch. That held
while only the model-based branch wrote the field; "Busy: <printer>" is
now the commonest reason there is, and it describes the very chain the
timeline forecasts, so the rule would have emptied the view for anyone
whose queue is pinned. It now asks whether the reason needs the user, via
a small shared reader of the same shape the scheduler encodes.

Which job goes out, and when, is unchanged: running the previous scheduler
and this one over the same 768 states dispatches the same items in the same
order with the same statuses, across 1452 rows that now carry a reason.
2026-09-18 19:38:24 +02:00

492 lines
21 KiB
Python

from datetime import datetime
from typing import Annotated, Literal
from pydantic import BaseModel, BeforeValidator, Field, PlainSerializer, model_validator
from backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C
# Custom serializer to ensure UTC datetimes have Z suffix
def serialize_utc_datetime(dt: datetime | None) -> str | None:
if dt is None:
return None
# Add Z suffix to indicate UTC
return dt.isoformat() + "Z"
UTCDatetime = Annotated[datetime | None, PlainSerializer(serialize_utc_datetime)]
def _coerce_tristate(v: object) -> object:
"""Map legacy on/off booleans onto the tri-state calibration options.
bed_levelling / flow_cali / nozzle_offset_cali were plain booleans before we
added BambuStudio's third "auto" state (skip if recently done). Rows and API
payloads created under the old scheme carry bool / 0-1 int / "true"/"false";
coerce them so old clients and un-migrated rows still validate. getValueInt
parity: off=0, on=1, auto=2.
"""
if isinstance(v, bool):
return "on" if v else "off"
if isinstance(v, int):
return {0: "off", 1: "on", 2: "auto"}.get(v, "auto")
if isinstance(v, str):
low = v.strip().lower()
if low in ("true", "1"):
return "on"
if low in ("false", "0"):
return "off"
return v
# Tri-state calibration option: "auto" (printer decides / skip if recent),
# "on" (force every print), "off" (never). Mirrors BambuStudio's ops_auto.
TriState = Annotated[Literal["off", "on", "auto"], BeforeValidator(_coerce_tristate)]
class QueueVariantCreate(BaseModel):
"""One candidate file for a cross-model queue item (#671).
Per-file rather than per-item because the settings genuinely differ between
candidates: an H2C slice is dual-nozzle and will not share slot count, AMS
mapping or nozzle mapping with the H2S slice of the same model.
``target_model`` is normally omitted and read from the file's own
``sliced_for_model``; supply it only for a legacy 3MF that declares none.
"""
library_file_id: int
target_model: str | None = None
plate_id: int | None = None
ams_mapping: list[int] | None = None
nozzle_mapping: list[int] | None = None
# Which rack position each filament group prints from (#1784), as
# {group_id: 1-based position}. The operator's pick, re-checked against the
# live rack at dispatch; null means "assign them for me".
nozzle_rack_choice: dict[int, int] | None = None
filament_overrides: list[dict] | None = None
class PrintQueueItemCreate(BaseModel):
printer_id: int | None = None # None = unassigned, user assigns later
target_model: str | None = None # Target printer model (mutually exclusive with printer_id)
target_location: str | None = None # Target location filter (only used with target_model)
required_filament_types: list[str] | None = None # Required filament types for model-based assignment
filament_overrides: list[dict] | None = None # Filament overrides for model-based assignment
# Either archive_id OR library_file_id must be provided
archive_id: int | None = None
library_file_id: int | None = None
scheduled_time: datetime | None = None # None = ASAP (next when idle)
require_previous_success: bool = False
auto_off_after: bool = False # Power off printer after print completes
manual_start: bool = False # Requires manual trigger to start (staged)
insert_at_top: bool = False # Insert ahead of other pending items in the same queue scope
insert_position: int | None = None # 1-indexed insertion position for priority queueing
# Persistent "Print Anyway" acknowledgement (#1698-followup). When set,
# PrintModal already showed the deficit warning and the user confirmed,
# so the scheduler does not re-flag this item on the next tick.
skip_filament_check: bool = False
# AMS mapping: list 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: list[int] | None = None
# Plate ID for multi-plate 3MF files (1-indexed, None = auto-detect/plate 1)
plate_id: int | None = None
# Print options. bed_levelling / flow_cali / nozzle_offset_cali are tri-state
# (off/on/auto), defaulting to "auto" to match BambuStudio. vibration_cali /
# layer_inspect / timelapse stay on/off (BambuStudio exposes no auto for them).
bed_levelling: TriState = "auto"
flow_cali: TriState = "auto"
vibration_cali: bool = True
layer_inspect: bool = False
timelapse: bool = False
use_ams: bool = True
# Nozzle offset calibration — dual-nozzle printers only (#1682). The MQTT
# layer ignores the value on single-nozzle printers so the wire stays "skip".
nozzle_offset_cali: TriState = "auto"
# 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=MAX_CHAMBER_TEMP_C)
# Auto-print G-code injection
gcode_injection: bool = False
# Batch: create multiple copies (creates a batch if > 1)
quantity: int = 1
# Existing batch to add this item into. When set, the item's batch_id is
# populated on insert so the queue UI groups it with its siblings. Used by
# the multi-plate auto-batch flow and by the "Group as batch" action.
batch_id: int | None = None
# Project to associate the resulting archive with
project_id: int | None = None
cost_center_id: int | None = None
estimated_cost: float | None = None
# Which rack position each filament group prints from (#1784), as
# {group_id: 1-based position}. The operator's pick, re-checked against the
# live rack at dispatch; null means "assign them for me".
nozzle_rack_choice: dict[int, int] | None = None
# Direct printer-card uploads are temporary library files. The scheduler
# deletes them after creating the durable archive copy.
cleanup_library_after_dispatch: bool = False
# Cross-model alternatives (#671): several sliced files, one job, whichever
# printer frees up first. Mutually exclusive with printer_id (a specific
# printer defeats the purpose) and with archive_id/library_file_id (the
# candidates ARE the files). The scheduler resolves one onto the row at
# dispatch, after which the item is an ordinary single-file job.
variants: list[QueueVariantCreate] | None = None
class PrintQueueItemUpdate(BaseModel):
printer_id: int | None = None
target_model: str | None = None # Target printer model (mutually exclusive with printer_id)
target_location: str | None = None # Target location filter (only used with target_model)
filament_overrides: list[dict] | None = None # Filament overrides for model-based assignment
position: int | None = None
scheduled_time: datetime | None = None
require_previous_success: bool | None = None
auto_off_after: bool | None = None
manual_start: bool | None = None
ams_mapping: list[int] | None = None
plate_id: int | None = None
# Print options
bed_levelling: TriState | None = None
flow_cali: TriState | None = None
vibration_cali: bool | None = None
layer_inspect: bool | None = None
timelapse: bool | None = None
use_ams: bool | None = None
nozzle_offset_cali: TriState | None = None
preheat_override: Literal["inherit", "on", "off"] | None = None
preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
# Auto-print G-code injection
gcode_injection: bool | None = None
cost_center_id: int | None = None
estimated_cost: float | None = None
# H2C dual-nozzle-rack slicer pick (#1780). list[int] per-filament
# physical nozzle position IDs from BambuStudio's project_file MQTT
# body; sent back to the printer verbatim on dispatch.
nozzle_mapping: list[int] | None = None
# Which rack position each filament group prints from (#1784), as
# {group_id: 1-based position}. The operator's pick, re-checked against the
# live rack at dispatch; null means "assign them for me".
nozzle_rack_choice: dict[int, int] | None = None
class QueueVariantSummary(BaseModel):
"""One candidate on a cross-model queue item, for display (#671)."""
library_file_id: int
filename: str
target_model: str
position: int
class PrintQueueItemResponse(BaseModel):
id: int
printer_id: int | None # None = unassigned
target_model: str | None = None # Target printer model for model-based assignment
target_location: str | None = None # Target location filter for model-based assignment
required_filament_types: list[str] | None = None # Required filament types for model-based assignment
filament_overrides: list[dict] | None = None # Filament overrides for model-based assignment
waiting_reason: str | None = None # Why this job hasn't started yet (empty once it can)
archive_id: int | None # None if library_file_id is set (archive created at print start)
library_file_id: int | None # For queue items from library files
cost_center_id: int | None = None
estimated_cost: float | None = None
position: int
scheduled_time: UTCDatetime
require_previous_success: bool
auto_off_after: bool
manual_start: bool
# True when the dispatch scheduler last evaluated this item and the
# assigned spool could not satisfy at least one slot's required grams
# (#1496). Display-only — the ▶ click recomputes deficit against live
# spool state.
filament_short: bool = False
# User has acknowledged "Print Anyway" — scheduler skips the deficit check
# for this item (#1698-followup).
skip_filament_check: bool = False
ams_mapping: list[int] | None = None
plate_id: int | None = None # Plate ID for multi-plate 3MF files
# Print options
bed_levelling: TriState = "auto"
flow_cali: TriState = "auto"
vibration_cali: bool = True
layer_inspect: bool = False
timelapse: bool = False
use_ams: bool = True
nozzle_offset_cali: TriState = "auto"
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
error_message: str | None
created_at: UTCDatetime
# Nested info for UI (populated in route)
archive_name: str | None = None
archive_thumbnail: str | None = None
# True when the linked archive has been soft-deleted (its files are gone
# from disk). In that case the *archive_name* / *archive_thumbnail* /
# downstream metadata fields are intentionally left None so the frontend
# doesn't 404-storm the now-missing thumbnail / plates / plate-thumbnail
# endpoints (#1348 follow-up). Frontends can render a "source deleted"
# badge based on this flag.
archive_deleted: bool = False
library_file_name: str | None = None # Name of library file (if library_file_id is set)
library_file_thumbnail: str | None = None # Thumbnail of library file
printer_name: str | None = None
print_time_seconds: int | None = None # Estimated print time from archive or library file
filament_used_grams: float | None = None # Estimated print weight from archive or library file
filament_type: str | None = None # e.g. "PLA", "PETG" (from archive/library file)
filament_color: str | None = None # e.g. "#FFFFFF" (from archive/library file)
layer_height: float | None = None # e.g. 0.2 (from archive/library file)
nozzle_diameter: float | None = None # e.g. 0.4 (from archive/library file)
sliced_for_model: str | None = None # e.g. "P1S" (from archive/library file)
# Build plate type (e.g. "Textured PEI Plate") so the user knows which
# plate to mount on the printer (#1281). Per-plate accurate on multi-plate
# 3MFs: when `plate_id` is set, the value is the matching plate's
# `curr_bed_type` rather than the archive-level first-plate default.
bed_type: str | None = None
# True when the source archive carries the slicer's own live-resolved
# AMS-slot pick (extra_data.slicer_ams_mapping) *and* it was resolved
# against this row's own printer — the only case where dispatch actually
# reuses that exact physical spool instead of the scheduler re-deriving one
# from the file's static type/color.
archive_has_slicer_ams_mapping: bool = False
# User tracking (Issue #206)
created_by_id: int | None = None
created_by_username: str | None = None
# Batch grouping
batch_id: int | None = None
batch_name: str | None = None
# Cross-model alternatives (#671), in priority order. Empty for every
# ordinary item. Present until dispatch resolves one onto the row, after
# which library_file_id / target_model name the candidate that actually ran.
variants: list[QueueVariantSummary] = []
# Shortest-job-first scheduling
been_jumped: bool = False
# Auto-print G-code injection
gcode_injection: bool = False
cleanup_library_after_dispatch: bool = False
# H2C dual-nozzle-rack slicer pick (#1780). Surface for any future
# "edit print → choose nozzle" UI; null on every model except O1C2
# uploads from BambuStudio.
nozzle_mapping: list[int] | None = None
# Which rack position each filament group prints from (#1784), as
# {group_id: 1-based position}. The operator's pick, re-checked against the
# live rack at dispatch; null means "assign them for me".
nozzle_rack_choice: dict[int, int] | None = None
class Config:
from_attributes = True
class PrintQueueReorderItem(BaseModel):
id: int
position: int
class PrintQueueReorder(BaseModel):
items: list[PrintQueueReorderItem]
@model_validator(mode="after")
def _validate_positions_unique(self) -> "PrintQueueReorder":
"""Reject reorder requests with duplicate positions in the payload
(#1625-followup).
The /reorder route is the drag-drop renumber path on the queue UI;
a well-behaved client sends a contiguous renumbering of a single
printer's pending queue. A buggy client that sends two items at
the same position would leave the queue in an inconsistent state
(scheduler's ORDER BY (printer_id, position) ties get broken by
physical row order). Fail closed at the schema boundary so the
bug is caught before any DB mutation.
Uniqueness is enforced WITHIN THE PAYLOAD only — cross-printer
reorders that intentionally share positions across different
printer queues are a non-goal of the drag-drop UI, so this is the
right scope.
"""
positions = [it.position for it in self.items]
if len(positions) != len(set(positions)):
duplicates = sorted({p for p in positions if positions.count(p) > 1})
raise ValueError(f"Duplicate positions in reorder request: {duplicates}")
return self
class PrintQueueBulkUpdate(BaseModel):
"""Bulk update multiple queue items with the same values."""
item_ids: list[int]
# Fields to update (all optional - only set fields are applied)
printer_id: int | None = None
scheduled_time: datetime | None = None
require_previous_success: bool | None = None
auto_off_after: bool | None = None
manual_start: bool | None = None
# Print options
bed_levelling: TriState | None = None
flow_cali: TriState | None = None
vibration_cali: bool | None = None
layer_inspect: bool | None = None
timelapse: bool | None = None
use_ams: bool | None = None
nozzle_offset_cali: TriState | None = None
preheat_override: Literal["inherit", "on", "off"] | None = None
preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
# Auto-print G-code injection
gcode_injection: bool | None = None
cost_center_id: int | None = None
estimated_cost: float | None = None
class PrintQueueBulkUpdateResponse(BaseModel):
"""Response for bulk update operation."""
updated_count: int
skipped_count: int # Items that were not pending
message: str
class PrintBatchPlateTarget(BaseModel):
"""How many runs of one plate an order wants (#342).
``plate_id`` is the plate index inside the source 3MF, or null for a
single-plate file — matching ``PrintQueueItem.plate_id``. A target of 0 is
legal and means "this plate is not required (yet)".
"""
plate_id: int | None = None
plate_name: str | None = None
quantity_target: int = Field(default=1, ge=0, le=999)
sort_order: int = 0
class PrintBatchCreate(BaseModel):
"""Create a batch, either empty (multi-plate pre-batch flow) or by
assigning existing pending queue items into it (manual "Group as batch")."""
name: str
archive_id: int | None = None
library_file_id: int | None = None
# Existing pending queue items to assign to this batch. None / empty for
# the empty-batch flow (client passes the returned id on subsequent
# addToQueue calls).
item_ids: list[int] | None = None
# Per-plate targets. Omitted entirely by the pre-#342 flows, which produce
# a batch that reports progress but owes nothing.
plates: list[PrintBatchPlateTarget] | None = None
# Planning metadata. Projects own the heavier fields (BOM, attachments,
# tags); these two are the ones that are useless without a Project to
# hang them on, so the order carries them directly.
project_id: int | None = None
due_date: datetime | None = None
notes: str | None = None
class PrintBatchUpdate(BaseModel):
"""Edit an order's header or its per-plate targets while it runs.
Every field is optional; ``plates`` replaces the full target set when
given, so a plate omitted from the list has its target row removed.
"""
name: str | None = None
status: Literal["active", "cancelled"] | None = None
plates: list[PrintBatchPlateTarget] | None = None
project_id: int | None = None
due_date: datetime | None = None
notes: str | None = None
class PrintBatchDispatchRequest(BaseModel):
"""Create queue items for the runs an order still owes."""
# Restrict to one plate. Null is a legitimate plate_id (single-plate file),
# so the caller opts in explicitly rather than us inferring from null.
plate_id: int | None = None
only_plate: bool = False
# Cap on how many items to create across all plates. None = everything owed.
limit: int | None = Field(default=None, ge=1, le=999)
class PrintBatchUngroupResponse(BaseModel):
"""Response after ungrouping a batch."""
ungrouped_count: int
message: str
class PrintBatchPlateProgress(BaseModel):
"""Per-plate progress within a batch."""
plate_id: int | None = None
plate_name: str | None = None
quantity_target: int = 0
dispatched: int = 0
remaining: int = 0
pending_count: int = 0
printing_count: int = 0
completed_count: int = 0
failed_count: int = 0
cancelled_count: int = 0
skipped_count: int = 0
# Measured from finished runs, never estimated from the file. Null until
# at least one run of this plate has produced a cost.
actual_cost: float | None = None
estimated_remaining_cost: float | None = None
filament_used_grams: float | None = None
print_time_seconds: int = 0
# False when this plate owes runs but has no queue item left to clone
# their configuration from, so offering to queue it would only fail.
can_dispatch: bool = False
class PrintBatchResponse(BaseModel):
"""Response for a print batch with progress stats."""
id: int
name: str
archive_id: int | None = None
library_file_id: int | None = None
quantity: int
status: str
created_at: UTCDatetime
completed_at: UTCDatetime | None = None
created_by_id: int | None = None
created_by_username: str | None = None
project_id: int | None = None
due_date: UTCDatetime | None = None
notes: str | None = None
# Derived counts
pending_count: int = 0
printing_count: int = 0
completed_count: int = 0
failed_count: int = 0
cancelled_count: int = 0
skipped_count: int = 0
# Planning roll-up. has_targets is false for batches created before
# per-plate targets existed: they report progress but owe nothing, and the
# dispatch endpoint is a no-op for them.
has_targets: bool = False
target_count: int = 0
remaining_count: int = 0
# Of ``remaining_count``, how many runs can actually be queued. Lower when
# a plate's last queue item was deleted (#2960).
dispatchable_count: int = 0
actual_cost: float | None = None
estimated_remaining_cost: float | None = None
filament_used_grams: float | None = None
print_time_seconds: int = 0
plates: list[PrintBatchPlateProgress] = []
class Config:
from_attributes = True