Files
bambuddy/backend/app/schemas/printer.py
T
maziggy fa1c46d9a5 feat(printers): show plate name on card for multi-plate active prints (#881)
When two printers were running different plates of the same multi-plate
  3MF, the Printers page cards displayed the same file name on both and
  there was no way to tell them apart. The Queue view already had this
  information by cross-referencing the archive's plate list; the card
  didn't have the linkage.

  Expose `current_archive_id` (resolved by matching the MQTT `subtask_id`
  against `PrintArchive.subtask_id` — the bridge introduced in #972 for
  restart-resume) and `current_plate_id` (parsed from `gcode_file` by a
  new shared `parse_plate_id` helper) on the status endpoint. The helper
  is also called from the WebSocket push path so plate transitions
  reflect within 100 ms instead of waiting 30 s for the next REST poll;
  the archive id itself stays REST-only since it's stable for the life
  of a print and shouldn't make the push path touch the DB.

  The card fetches plate metadata via the same `api.getArchivePlates()`
  call QueuePage uses — shared React Query cache keeps it cheap across
  polls — and renders the actual plate name (or a "Plate N" fallback)
  only when `is_multi_plate` is true. Single-plate prints stay clean.
  Falls back to the previous `plate_N.gcode` regex path when there's no
  archive linkage (e.g. prints started directly from the printer LCD).

  Tests cover the plate-id extraction across Bambu Studio path shapes
  (backend parse_plate_id, printer_state_to_dict wiring) and the label
  override precedence in formatPrintName (frontend).
2026-04-21 09:46:42 +02:00

284 lines
12 KiB
Python

from datetime import datetime
from pydantic import BaseModel, Field
class PrinterBase(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
serial_number: str = Field(..., min_length=1, max_length=50)
ip_address: str = Field(
...,
max_length=253,
pattern=r"^(\d{1,3}(\.\d{1,3}){3}|[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*)$",
)
access_code: str = Field(..., min_length=1, max_length=20)
model: str | None = None
location: str | None = None # Group/location name
auto_archive: bool = True
external_camera_url: str | None = None
external_camera_type: str | None = None # "mjpeg", "rtsp", "snapshot", "usb"
external_camera_enabled: bool = False
camera_rotation: int = 0 # 0, 90, 180, 270 degrees
class PrinterCreate(PrinterBase):
pass
class PlateDetectionROI(BaseModel):
"""Region of interest for plate detection (percentages 0.0-1.0)."""
x: float = Field(..., ge=0.0, le=1.0) # X start %
y: float = Field(..., ge=0.0, le=1.0) # Y start %
w: float = Field(..., ge=0.0, le=1.0) # Width %
h: float = Field(..., ge=0.0, le=1.0) # Height %
class PrinterUpdate(BaseModel):
name: str | None = None
ip_address: str | None = Field(
default=None,
max_length=253,
pattern=r"^(\d{1,3}(\.\d{1,3}){3}|[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*)$",
)
access_code: str | None = None
model: str | None = None
location: str | None = None
is_active: bool | None = None
auto_archive: bool | None = None
print_hours_offset: float | None = None
external_camera_url: str | None = None
external_camera_type: str | None = None
external_camera_enabled: bool | None = None
camera_rotation: int | None = None # 0, 90, 180, 270 degrees
plate_detection_enabled: bool | None = None
plate_detection_roi: PlateDetectionROI | None = None
class PrinterResponse(PrinterBase):
id: int
is_active: bool
nozzle_count: int = 1 # 1 or 2, auto-detected from MQTT
print_hours_offset: float = 0.0
external_camera_url: str | None = None
external_camera_type: str | None = None
external_camera_enabled: bool = False
camera_rotation: int = 0 # 0, 90, 180, 270 degrees
plate_detection_enabled: bool = False
plate_detection_roi: PlateDetectionROI | None = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
@classmethod
def from_orm_with_roi(cls, printer) -> "PrinterResponse":
"""Create response from ORM model, converting ROI fields to nested object."""
data = {
"id": printer.id,
"name": printer.name,
"serial_number": printer.serial_number,
"ip_address": printer.ip_address,
"access_code": printer.access_code,
"model": printer.model,
"location": printer.location,
"auto_archive": printer.auto_archive,
"external_camera_url": printer.external_camera_url,
"external_camera_type": printer.external_camera_type,
"external_camera_enabled": printer.external_camera_enabled,
"camera_rotation": printer.camera_rotation,
"is_active": printer.is_active,
"nozzle_count": printer.nozzle_count,
"print_hours_offset": printer.print_hours_offset,
"plate_detection_enabled": printer.plate_detection_enabled,
"created_at": printer.created_at,
"updated_at": printer.updated_at,
}
# Build ROI object if any ROI field is set
if any(
[
printer.plate_detection_roi_x is not None,
printer.plate_detection_roi_y is not None,
printer.plate_detection_roi_w is not None,
printer.plate_detection_roi_h is not None,
]
):
data["plate_detection_roi"] = PlateDetectionROI(
x=printer.plate_detection_roi_x or 0.15,
y=printer.plate_detection_roi_y or 0.35,
w=printer.plate_detection_roi_w or 0.70,
h=printer.plate_detection_roi_h or 0.55,
)
return cls(**data)
class HMSErrorResponse(BaseModel):
code: str
attr: int = 0 # Attribute value for constructing wiki URL
module: int
severity: int # 1=fatal, 2=serious, 3=common, 4=info
class AMSTray(BaseModel):
id: int
tray_color: str | None = None
tray_type: str | None = None
tray_sub_brands: str | None = None # Full name like "PLA Basic", "PETG HF"
tray_id_name: str | None = None # Bambu filament ID like "A00-Y2" (can decode to color)
tray_info_idx: str | None = None # Filament preset ID like "GFA00"
remain: int = 0
k: float | None = None # Pressure advance value (from tray or K-profile lookup)
cali_idx: int | None = None # Calibration index for K-profile lookup
tag_uid: str | None = None # RFID tag UID (any tag)
tray_uuid: str | None = None # Bambu Lab spool UUID (32-char hex)
nozzle_temp_min: int | None = None # Min nozzle temperature
nozzle_temp_max: int | None = None # Max nozzle temperature
drying_temp: int | None = None # RFID-recommended drying temp
drying_time: int | None = None # RFID-recommended drying time (hours)
state: int | None = None # AMS tray state: 9=empty, 10=spool present not loaded, 11=loaded
class AMSUnit(BaseModel):
id: int
humidity: int | None = None
temp: float | None = None
is_ams_ht: bool = False # True for AMS-HT (single spool), False for regular AMS (4 spools)
tray: list[AMSTray] = []
serial_number: str = "" # AMS unit serial number (sn from MQTT)
sw_ver: str = "" # AMS firmware version (from get_version info.module)
dry_time: int = 0 # Minutes remaining (0 = not drying, >0 = drying active)
dry_status: int = 0 # 0=Off, 1=Checking, 2=Drying, 3=Cooling, 4=Stopping, 5=Error
dry_sub_status: int = 0 # 0=Off, 1=Heating, 2=Dehumidify
dry_sf_reason: list[int] = [] # Cannot-dry reasons from firmware (see CannotDryReason)
module_type: str = "" # "ams", "n3f", "n3s"
class NozzleInfoResponse(BaseModel):
nozzle_type: str = "" # "stainless_steel" or "hardened_steel"
nozzle_diameter: str = "" # e.g., "0.4"
class NozzleRackSlot(BaseModel):
"""H2C nozzle rack slot (6-position tool-changer dock)."""
id: int = 0
nozzle_type: str = ""
nozzle_diameter: str = ""
wear: int | None = None
stat: int | None = None # Nozzle status (e.g. mounted/docked)
max_temp: int = 0 # Max temperature rating °C (0 = not set)
serial_number: str = "" # Nozzle serial number
filament_color: str = "" # RGBA hex ("00000000" = no filament)
filament_id: str = "" # Bambu filament ID
filament_type: str = "" # Material type (e.g. "PLA", "PETG")
class AmsLabelBody(BaseModel):
label: str = Field(..., min_length=1, max_length=100)
ams_serial: str = Field(default="", max_length=50)
class PrintOptionsResponse(BaseModel):
"""AI detection and print options from xcam data."""
# Core AI detectors
spaghetti_detector: bool = False
print_halt: bool = False
halt_print_sensitivity: str = "medium" # Spaghetti sensitivity
first_layer_inspector: bool = False
printing_monitor: bool = False
buildplate_marker_detector: bool = False
allow_skip_parts: bool = False
# Additional AI detectors (decoded from cfg bitmask)
nozzle_clumping_detector: bool = True
nozzle_clumping_sensitivity: str = "medium"
pileup_detector: bool = True
pileup_sensitivity: str = "medium"
airprint_detector: bool = True
airprint_sensitivity: str = "medium"
auto_recovery_step_loss: bool = True
filament_tangle_detect: bool = False
class PrinterStatus(BaseModel):
id: int
name: str
connected: bool
state: str | None = None
current_print: str | None = None
subtask_name: str | None = None
gcode_file: str | None = None
progress: float | None = None
remaining_time: int | None = None
layer_num: int | None = None
total_layers: int | None = None
temperatures: dict | None = None
cover_url: str | None = None
hms_errors: list[HMSErrorResponse] = []
ams: list[AMSUnit] = []
ams_exists: bool = False
vt_tray: list[AMSTray] = [] # Virtual tray / external spool(s)
sdcard: bool = False # SD card inserted
store_to_sdcard: bool = False # Store sent files on SD card
timelapse: bool = False # Timelapse recording active
ipcam: bool = False # Live view enabled
wifi_signal: int | None = None # WiFi signal strength in dBm
wired_network: bool = False # Ethernet connection detected
door_open: bool = False # Enclosure door open (X1/P1S/P2S/H2*)
nozzles: list[NozzleInfoResponse] = [] # Nozzle hardware info (index 0=left/primary, 1=right)
nozzle_rack: list[NozzleRackSlot] = [] # H2C 6-nozzle tool-changer rack
print_options: PrintOptionsResponse | None = None # AI detection and print options
# Calibration stage tracking
stg_cur: int = -1 # Current stage number (-1 = not calibrating)
stg_cur_name: str | None = None # Human-readable current stage name
stg: list[int] = [] # List of stage numbers in calibration sequence
# Air conditioning mode (0=cooling, 1=heating)
airduct_mode: int = 0
# Print speed level (1=silent, 2=standard, 3=sport, 4=ludicrous)
speed_level: int = 2
# Chamber light on/off
chamber_light: bool = False
# Active extruder for dual nozzle (0=right, 1=left)
active_extruder: int = 0
# AMS mapping for dual nozzle: which AMS is connected to which nozzle
ams_mapping: list[int] = []
# Per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left
ams_extruder_map: dict[str, int] = {}
# Currently loaded tray (global ID): 254 = external spool, 255 = no filament
tray_now: int = 255
# AMS status for filament change tracking
# Main status: 0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration
ams_status_main: int = 0
# Sub status: specific step within filament change (when main=1)
# Known values: 4=retraction, 6=load verification, 7=purge
ams_status_sub: int = 0
# mc_print_sub_stage - filament change step indicator used by OrcaSlicer/BambuStudio
mc_print_sub_stage: int = 0
# Timestamp of last AMS data update (for RFID refresh detection)
last_ams_update: float = 0.0
# Number of printable objects in current print (for skip objects feature)
printable_objects_count: int = 0
# Fan speeds (0-100 percentage, None if not available for this model)
cooling_fan_speed: int | None = None # Part cooling fan
big_fan1_speed: int | None = None # Auxiliary fan
big_fan2_speed: int | None = None # Chamber/exhaust fan
heatbreak_fan_speed: int | None = None # Hotend heatbreak fan
# Firmware version (from info.module[name="ota"].sw_ver)
firmware_version: str | None = None
# Developer LAN mode: True = enabled, False = disabled (MQTT encryption), None = unknown
developer_mode: bool | None = None
# Queue: printer is awaiting the user to acknowledge the build plate is cleared
# after a finished/failed print. Persisted across restarts (#961).
awaiting_plate_clear: bool = False
# AMS drying support
supports_drying: bool = False
# Linked archive for the active print (resolved via subtask_id). Frontend uses
# this to fetch plate metadata and show the plate name when the source 3MF is
# multi-plate (#881 follow-up).
current_archive_id: int | None = None
# 1-indexed plate number parsed from gcode_file (e.g. /Metadata/plate_2.gcode).
# Set for every active print regardless of plate count; the frontend decides
# whether to render it based on current_archive_id's is_multi_plate flag.
current_plate_id: int | None = None