mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
Adds /library/variant-groups for declaring that several sliced files are the same job for different printers, and a variants payload on queue creation that turns such a set into one queue item with a candidate per file. The candidate set is validated as a set: one file per printer model, each file sliced for the model it is offered as, and at least one model with an active printer. A cross-model item deliberately holds no file of its own, because print_queue.library_file_id is ON DELETE CASCADE and would destroy the whole job when a single alternative is deleted. Fixes internal printer-model codes never being resolved on queue create and update: normalize_printer_model returns unknown input unchanged, so the or-chain never reached the code map and a "C13" target matched no printer and waited forever. Skips candidates whose file is trashed or missing. Library deletes are soft, and SQLite runs with PRAGMA foreign_keys off, so neither case is covered by the schema; the hard-delete paths now also drop the rows. Adds library_files.variant_target_model so a user can say which printer a file without slicer metadata is for, kept out of file_metadata so the assertion is never mistaken for parsed data.
361 lines
16 KiB
Python
361 lines
16 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 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
|
|
|
|
# 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 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
|
|
|
|
|
|
class PrintBatchUngroupResponse(BaseModel):
|
|
"""Response after ungrouping a batch."""
|
|
|
|
ungrouped_count: int
|
|
message: str
|
|
|
|
|
|
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
|
|
created_by_id: int | None = None
|
|
created_by_username: 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
|
|
|
|
class Config:
|
|
from_attributes = True
|