Files
bambuddy/backend/app/schemas/printer.py
T
maziggy 1ccaf74dd5 fix(install): sign the Python that macOS grants local network access to (issue #3114)
macOS attributes Local Network permission to a code signature and judges a
launchd-spawned process on its own, rather than letting it inherit the grant
of the Terminal that started it. Homebrew ships Python unsigned on Intel, so
there is no identity for the grant to attach to: every connection to a LAN
address is dropped with no error the application can log and no permission
prompt. The printer reads as unreachable and nothing says why, and the entry
in Privacy & Security cannot be made to work because it refers to an identity
that no longer resolves.

install.sh signs during a macOS install; update_macos.sh re-checks on every
update, because `brew upgrade python` installs a fresh unsigned binary under
a new versioned path.

Both sign only what is currently unsigned. That gate is load-bearing: on
arm64 the linker ad-hoc signs every binary and the identity is a hash of the
file, so re-signing would rotate it and revoke a working grant on each update.
A python.org build carries a real Developer ID and must not be downgraded for
the same reason.

The interpreter and the framework's Python.app are both signed. The first is
what sys._base_executable resolves to and what the reporter's TCC log names;
the second is what his fix actually targeted. Which one macOS attributes
could not be established from either, and signing both costs nothing.

-----

fix(diagnostics): name the macOS permission that silently blocks the printer (issue #3114)

The port checks reported all three ports unreachable while the subnet check
passed, and port_mqtt's fix text sent the reporter after firewalls and IP
addresses. On a macOS native install that pattern has a cause neither of
those covers: no Local Network grant, denied with no error and no prompt.

A new macos_local_network check, appended on macOS only so no permanently
dimmed row appears for anyone else. It passes when the control port answered,
which is proof the permission is in place and means the signature probe never
runs on a healthy diagnostic. Otherwise it probes the interpreter: an
unsigned one gets the repair that fixes it, a signed one gets System Settings
— the arm64 case, where the identity is a hash of the binary, so a Python
upgrade presents macOS with a new application and strands the old grant.

Always warn, never fail, and only once port_mqtt has already failed, so this
can never be why a green diagnostic turns red. A printer that is simply
switched off produces the same all-ports-dead pattern, which is why the
signature, not the pattern, is what earns the specific advice. An
undeterminable signature is reported as the generic case rather than as
unsigned: that advice rewrites a file in the user's Python installation and
must not be offered on a guess.
2026-09-19 16:03:52 +02:00

508 lines
23 KiB
Python

from datetime import datetime
from pydantic import BaseModel, Field, field_validator, model_validator
from backend.app.utils.printer_models import supports_nozzle_flow_type
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
# Whether the model is sold with both Standard and High Flow nozzles, so a
# K-profile's flow type is a real choice rather than a meaningless field.
# Derived from the model, not from nozzle_count — see
# printer_models.supports_nozzle_flow_type.
supports_nozzle_flow_type: bool = True
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,
"supports_nozzle_flow_type": supports_nozzle_flow_type(printer.model),
"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
actions: list[str] = [] # List of user-facing action keys (e.g. "CHECK_FILAMENT")
job_id: str | None = None # Optional job ID for actions that require it (e.g. "CHECK_ASSISTANT")
# Canonical hex identifier the firmware uses to match HMS-related commands.
# 16 chars for `hms[]`-array faults (full 64-bit attr+code), 8 chars for
# `print_error` faults. The frontend echoes this back as
# HmsActionBody.print_error so we send the firmware-recognised key, not the
# truncated short_code that historically caused silent command rejection
# (#1830, H2D wrong-plate verification).
full_code: str = ""
# The bundled catalogue's sentence for this fault, so a client does not have
# to carry its own copy of the same table to tell a user why a print halted
# (#2926). English only and not localized — the catalogue ships one language.
# None when the catalogue does not cover the code, which is common for
# `hms[]`-array faults: those resolve through a lossy collapse of their
# 16-char identifier and many land on no key at all (#2728). A client should
# treat null as "no text available", never as "no fault" — `full_code` is
# what identifies the fault, and it is always present.
description: str | None = None
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
# Firmware's authoritative "spool physically present" bit (from tray_exist_bits).
# True for a non-RFID spool the firmware can't identify — the UI shows "?" rather
# than "Empty" (#2527). None when the bitmask was unavailable (→ state-based fallback).
exists: bool | None = None
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)
dry_target_temp: int | None = None # Active-cycle target °C (Bambu doesn't echo this)
dry_filament: str | None = None # Active-cycle filament name we sent
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 HmsActionBody(BaseModel):
# Canonical hex identifier (HMSErrorResponse.full_code): 8 chars for
# `print_error`-sourced faults, 16 chars for `hms[]`-array faults whose
# full 64-bit code is the firmware's matching key. Length-bounded to
# those two valid shapes to keep stray input from reaching the dispatcher.
print_error: str = Field(..., min_length=8, max_length=16, pattern=r"^[0-9A-Fa-f]{8}([0-9A-Fa-f]{8})?$")
# One of the HMSAction enum values. Length-capped to keep stray input from
# reaching the dispatcher's `match` statement.
action: str = Field(..., min_length=1, max_length=64)
# The `subtask_id` snapshot from the HMSError that surfaced this dialog.
# Bambu echoes it back in HMS-aware commands. Optional for idle errors.
job_id: str | None = Field(default=None, max_length=64)
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
# Whether the switch is set up: every AMS bound to one of its two inlets.
# A load cannot be routed until it is, so the UI blocks on this rather than
# sending a command the firmware will drop.
ready: bool = False
class ExtruderSlotResponse(BaseModel):
"""Which AMS slot one hotend is currently fed from.
From ``device.extruder.info[i].snow``. Needed because ``tray_now`` is a
single printer-wide value: on a dual-nozzle machine with both hotends
loaded it names only one of them, so it cannot say which hotend holds a
given slot.
"""
# None when the hotend is not fed from any slot.
ams_id: int | None = None
slot_id: int | None = None
has_filament: bool = False
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
# Per-AMS FTS inlet binding: {ams_id: "A" | "B"}, from AMS info bits 24-27.
# Which of the switch's two inlets each AMS is plumbed into, as set on the
# printer's "Manual AMS Setup" screen. Empty unless an FTS is installed —
# an FTS-bound AMS reaches BOTH nozzles, so it has no entry in
# ams_extruder_map and must not be labelled left or right.
ams_switch_inlet: dict[str, str] = {}
# Which AMS slot each hotend is fed from, keyed by extruder id as a string
# ("0" = right/main, "1" = left/deputy). Empty on printers that do not
# report ``device.extruder.info``.
extruder_slots: dict[str, ExtruderSlotResponse] = {}
# Currently loaded tray (global ID): 254 = external spool, 255 = no filament
tray_now: int = 255
# Runout / filament-replacement guidance (#2587). Populated only while the
# print is PAUSED. Both are globalised tray IDs (ams_id*4+slot, or 128-135 for
# AMS-HT, or 254 for external) so the frontend can highlight them with the same
# logic it uses for tray_now:
# expected_tray = the slot the firmware now expects filament in (from tray_tar).
# None when idle, not paused, or the slot can't be resolved
# (multi-AMS ambiguity) — the UI then says "check the printer".
# previous_tray = the slot loaded before the pause, i.e. the one that ran out
# (from tray_pre). None when unknown.
expected_tray: int | None = None
previous_tray: int | None = None
# 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
# Left auxiliary part cooling fan (optional P2S/X2D accessory, airduct part id 10).
# None = not installed / not reported by this model.
left_aux_fan_speed: int | None = None
# Chamber exhaust fan present (P2S/X2D External Exhaust Fan kit; airduct part id 3).
exhaust_fan_present: bool = False
# 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
# AMS "Print While Drying" — drying mid-print. Verified per Bambu wiki release notes;
# see _DRY_WHILE_PRINTING_MIN_FIRMWARE in printer_manager.py for the matrix.
supports_drying_while_printing: bool = False
# The AMS can dry, but only from the printer's own screen (P1 series, #2533).
# supports_drying is False on these; the UI keeps the control visible but disabled
# and says why, rather than dropping it without explanation.
drying_screen_only: 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,
macos_local_network, network_mode, subnet, external_storage, mqtt_auth,
developer_mode, printer_publishing); 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.
Not every check is emitted on every run: ``macos_local_network`` appears
only on macOS, where it is the only platform it can say anything about.
"""
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
class PrinterFilesDownloadRequest(BaseModel):
"""Printer paths selected for a bulk download."""
paths: list[str] = Field(..., max_length=1000)
sizes: dict[str, int] = Field(default_factory=dict, max_length=1000)
@model_validator(mode="after")
def _validate_sizes(self):
"""Validate optional FTP-reported sizes used for early rejection."""
if self.sizes and set(self.sizes) != set(self.paths):
raise ValueError("A size is required for every selected printer path")
if any(size < 0 for size in self.sizes.values()):
raise ValueError("Printer file sizes must not be negative")
return self
class PrinterFilesJobRequest(PrinterFilesDownloadRequest):
"""Browser preparation request, including native download presentation."""
filename: str = Field(default="printer-files.zip", min_length=1, max_length=255)
as_zip: bool = True