mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
Printing a multi-plate file in different quantities per plate meant queueing each plate separately and tracking the counts by hand: one shared Quantity field cannot say "plate 1 once, plate 2 twice, plate 3 three times". Each selected plate now carries its own quantity, and the submission becomes an order on a new Batches tab. The point is the distinction the old flat batch could not express. print_batch_plates stores how many runs of each plate were wanted, separately from what was queued, so a run that fails, is cancelled or is skipped does not satisfy a target -- the order goes on saying it owes a print instead of quietly under-delivering. Queue remaining re-queues exactly what is missing, for the whole order or one plate, by cloning the most recent item for that plate: that inherits the printer or model target, AMS mapping, filament overrides and print options along with the validation they already passed, rather than re-serialising twenty fields through a template that would drift from the model the first time someone adds a column. Clones append to the end of the relevant printer's queue and take the same advisory lock the add-to-queue route does; positions are per-printer sequences, not global. Cost is measured, not estimated. print_log_entries gains queue_item_id, set where the queue item is already in scope, so each run's material and energy are attributed through the item that produced them -- an unrelated reprint of the same archive never lands in an order's total, and a multi-plate order gets each plate's own cost rather than the whole file's via the plate-scoped estimate from #2614. Before any run has completed there is no honest figure, so cost reads as unknown instead of a fabricated 0.00. The Batches tab wires up GET /queue/batches, which has been unreferenced since the batch MVP shipped, along with six locale keys that were translated and never used. It is a separate tab because an order outlives the queue that produced it: once its runs finish they leave the active queue, so Queue and History each hold half the picture. completed was not a reachable status before now, so every batch created since April is still marked active however long ago its last print finished -- 73 of them on the development install. A startup pass closes out the finished ones: those whose runs all completed become completed, and groupings whose items were all cancelled become cancelled, which is what they are. Not applied to orders, which state their intent independently of their runs and still owe the work. Only batches with nothing queued or printing are considered, and repeating the pass also catches an order whose last run landed while the process was down. Batches with neither items nor targets are no longer listed at all -- empty shells left when a grouping's items went with their source archive. Dispatch applies the same source-file gates as POST /queue/. It creates queue items, so without them it would be a weaker door to the same outcome; the archive and library-file checks move into shared helpers so a third route cannot drift from them.
462 lines
19 KiB
Python
462 lines
19 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
|
|
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
|
|
# 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
|
|
# 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
|
|
|
|
|
|
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 a model-based job hasn't started yet
|
|
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
|
|
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
|
|
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
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
|
|
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
|