Files
bambuddy/backend/app/schemas/printer.py
T
maziggy b1cb26f6ee feat(ams-backup): add status badge + toggle, fix prefer-lowest (#1766)
Two tightly-coupled deliverables in one drop -- a new AMS Filament Backup
  status/control surface, and the #1766 fix that depends on it.

  Added -- AMS Filament Backup status + control
  - Parse bit 18 of top-level print.cfg into PrinterState.ams_filament_backup
    on every push_status. Verified against OrcaSlicer source
    (DeviceManager.cpp:4961) and a live H2D ON/OFF capture. Tri-state
    (None = A1 family / pre-cfg push) preserves today's behaviour.
  - Hold-timer guard (3 s) prevents stale frames from flickering the badge
    back to the printer's old cfg after a user-initiated toggle.
  - POST /printers/{id}/ams-backup toggle, set_ams_filament_backup() client
    method calling _set_print_option("auto_switch_filament", enabled).
  - GET /printers/{id}/inventory-remain endpoint exposes the same map the
    dispatcher uses (internal and Spoolman modes both work uniformly).
  - Small icon badge in the printer card's "Filaments" section header
    (placement reads as printer-wide because the cfg bit is printer-wide,
    not per-AMS). Click to toggle, success toast.
  - 5 i18n keys x 11 locales for the badge UI.

  Fixed -- #1766: prefer_lowest didn't pick lowest, ignored backup state
  - Backend gate in _compute_ams_mapping_for_printer: coerce prefer_lowest
    to False when status.ams_filament_backup is False; log the skip.
  - New effectivePreferLowest(setting, backup) helper applied at every
    frontend sort entry point: single-printer PrintModal, multi-printer
    hook per-printer, PrinterSelector InlineMappingEditor, FilamentMapping
    standalone editor (the last had NO preferLowest awareness at all
    before this change).
  - New preferLowestSortKey(f, inventoryByTrayId) mirrors backend's two-tier
    key exactly, including the banding tie-break (regular AMS < AMS-HT <
    external) so the client-side pre-compute matches the dispatch-time pick.
    An earlier draft used a flat `amsId * 4 + trayId` priority which gave
    external slots (ams_id = -1) a NEGATIVE priority -- caught in code
    review before commit.
  - Settings -> Filament -> "Prefer lowest remaining filament" gets an
    explanatory note about the printer-side AMS Backup dependency, with
    i18n key in all 11 locales.
2026-06-20 12:07:20 +02:00

382 lines
16 KiB
Python

from datetime import datetime
from pydantic import BaseModel, Field, field_validator
class PrinterBase(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
serial_number: str = Field(..., min_length=1, max_length=50)
@field_validator("serial_number")
@classmethod
def _normalize_serial_number(cls, v: str) -> str:
"""Uppercase and trim the serial number.
Bambu serial numbers are uppercase alphanumeric, and the MQTT report
topic ``device/<serial>/report`` is case-sensitive. A serial entered
in the wrong case (or with stray whitespace) connects and subscribes
without error but never receives a message — the printer publishes to
the correctly-cased topic, so every status field stays unknown (#1465).
Normalising on input makes the subscribed topic always match.
"""
normalized = v.strip().upper()
if not normalized:
raise ValueError("serial_number must not be blank")
return normalized
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])?)*)$",
)
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
external_camera_snapshot_url: str | None = None # Optional single-frame override; #1177
camera_rotation: int = 0 # 0, 90, 180, 270 degrees
class PrinterCreate(PrinterBase):
# access_code lives on the input shapes only — never on the default
# PrinterResponse. Direct exposure on PRINTERS_READ would let a Viewer
# connect to the printer's MQTT and bypass Bambuddy's RBAC.
access_code: str = Field(..., min_length=1, max_length=20)
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
external_camera_snapshot_url: str | None = None # #1177
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
external_camera_snapshot_url: str | None = None # #1177
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,
"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,
"external_camera_snapshot_url": printer.external_camera_snapshot_url,
"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 PrinterResponseWithSecret(PrinterResponse):
"""PrinterResponse + access_code. Returned ONLY to callers with
PRINTERS_UPDATE (Admin / Operator JWTs, or single-trust auth-disabled mode).
Viewers and API keys never receive this shape — they get the bare
PrinterResponse without access_code, since holding the access_code lets
the caller talk to the printer's MQTT directly and bypass Bambuddy's RBAC.
"""
access_code: str
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 FilaSwitchResponse(BaseModel):
"""Filament Track Switch (FTS) state — accessory that mediates AMS-to-extruder routing.
When installed, the AMS info field reports bits 8-11 = 0xE (uninitialized)
because slots are dynamically routed via the FTS rather than tied to a
specific extruder. Frontend uses `installed` to suppress the per-extruder
slot filter in the print modal. See #1162.
"""
installed: bool = False
# in[track] = currently loaded slot for that track (-1 = empty)
in_slots: list[int] = []
# out[track] = extruder this track terminates at (0 = right, 1 = left)
out_extruders: list[int] = []
stat: int = 0
info: int = 0
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] = {}
# Filament Track Switch (FTS) accessory — when installed, AMS reports
# bits 8-11 = 0xE (uninitialized) and routing is dynamic via the FTS. See #1162.
fila_switch: FilaSwitchResponse | None = None
# 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
# AMS Filament Backup ("auto-switch" to a second spool when one runs out).
# True = ON, False = OFF, None = unknown / unsupported (A1 family — protocol field
# not yet identified). UI treats None as "status unavailable", not as a hard disable.
ams_filament_backup: 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
# Active chamber heater (responds to M141). True only for H2C/H2D/H2DPro/H2S/X2D.
supports_chamber_heater: 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
class DiagnosticCheck(BaseModel):
"""One connection-diagnostic check result.
``id`` is a stable key (port_mqtt, port_ftps, port_rtsps, network_mode,
subnet, mqtt_auth, developer_mode); the frontend renders the localized
title and fix text from id + status. ``params`` carries interpolation
values (e.g. network mode, IP addresses) for that text.
"""
id: str
status: str # "pass" | "fail" | "warn" | "skip"
params: dict = Field(default_factory=dict)
class PrinterDiagnosticResult(BaseModel):
"""Result of a printer connection diagnostic run."""
printer_id: int | None = None
ip_address: str
overall: str # "ok" | "warnings" | "problems"
checks: list[DiagnosticCheck]
class DiagnosticRequest(BaseModel):
"""Pre-save (Add Printer) connection diagnostic request.
serial_number + access_code are optional: when both are present the
diagnostic also probes MQTT credentials, otherwise only the
network-level checks run.
"""
ip_address: str
serial_number: str | None = None
access_code: str | None = None