printer.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. from datetime import datetime
  2. from pydantic import BaseModel, Field, field_validator
  3. class PrinterBase(BaseModel):
  4. name: str = Field(..., min_length=1, max_length=100)
  5. serial_number: str = Field(..., min_length=1, max_length=50)
  6. @field_validator("serial_number")
  7. @classmethod
  8. def _normalize_serial_number(cls, v: str) -> str:
  9. """Uppercase and trim the serial number.
  10. Bambu serial numbers are uppercase alphanumeric, and the MQTT report
  11. topic ``device/<serial>/report`` is case-sensitive. A serial entered
  12. in the wrong case (or with stray whitespace) connects and subscribes
  13. without error but never receives a message — the printer publishes to
  14. the correctly-cased topic, so every status field stays unknown (#1465).
  15. Normalising on input makes the subscribed topic always match.
  16. """
  17. normalized = v.strip().upper()
  18. if not normalized:
  19. raise ValueError("serial_number must not be blank")
  20. return normalized
  21. ip_address: str = Field(
  22. ...,
  23. max_length=253,
  24. 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])?)*)$",
  25. )
  26. model: str | None = None
  27. location: str | None = None # Group/location name
  28. auto_archive: bool = True
  29. external_camera_url: str | None = None
  30. external_camera_type: str | None = None # "mjpeg", "rtsp", "snapshot", "usb"
  31. external_camera_enabled: bool = False
  32. external_camera_snapshot_url: str | None = None # Optional single-frame override; #1177
  33. camera_rotation: int = 0 # 0, 90, 180, 270 degrees
  34. class PrinterCreate(PrinterBase):
  35. # access_code lives on the input shapes only — never on the default
  36. # PrinterResponse. Direct exposure on PRINTERS_READ would let a Viewer
  37. # connect to the printer's MQTT and bypass Bambuddy's RBAC.
  38. access_code: str = Field(..., min_length=1, max_length=20)
  39. class PlateDetectionROI(BaseModel):
  40. """Region of interest for plate detection (percentages 0.0-1.0)."""
  41. x: float = Field(..., ge=0.0, le=1.0) # X start %
  42. y: float = Field(..., ge=0.0, le=1.0) # Y start %
  43. w: float = Field(..., ge=0.0, le=1.0) # Width %
  44. h: float = Field(..., ge=0.0, le=1.0) # Height %
  45. class PrinterUpdate(BaseModel):
  46. name: str | None = None
  47. ip_address: str | None = Field(
  48. default=None,
  49. max_length=253,
  50. 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])?)*)$",
  51. )
  52. access_code: str | None = None
  53. model: str | None = None
  54. location: str | None = None
  55. is_active: bool | None = None
  56. auto_archive: bool | None = None
  57. print_hours_offset: float | None = None
  58. external_camera_url: str | None = None
  59. external_camera_type: str | None = None
  60. external_camera_enabled: bool | None = None
  61. external_camera_snapshot_url: str | None = None # #1177
  62. camera_rotation: int | None = None # 0, 90, 180, 270 degrees
  63. plate_detection_enabled: bool | None = None
  64. plate_detection_roi: PlateDetectionROI | None = None
  65. class PrinterResponse(PrinterBase):
  66. id: int
  67. is_active: bool
  68. nozzle_count: int = 1 # 1 or 2, auto-detected from MQTT
  69. print_hours_offset: float = 0.0
  70. external_camera_url: str | None = None
  71. external_camera_type: str | None = None
  72. external_camera_enabled: bool = False
  73. external_camera_snapshot_url: str | None = None # #1177
  74. camera_rotation: int = 0 # 0, 90, 180, 270 degrees
  75. plate_detection_enabled: bool = False
  76. plate_detection_roi: PlateDetectionROI | None = None
  77. created_at: datetime
  78. updated_at: datetime
  79. class Config:
  80. from_attributes = True
  81. @classmethod
  82. def from_orm_with_roi(cls, printer) -> "PrinterResponse":
  83. """Create response from ORM model, converting ROI fields to nested object."""
  84. data = {
  85. "id": printer.id,
  86. "name": printer.name,
  87. "serial_number": printer.serial_number,
  88. "ip_address": printer.ip_address,
  89. "model": printer.model,
  90. "location": printer.location,
  91. "auto_archive": printer.auto_archive,
  92. "external_camera_url": printer.external_camera_url,
  93. "external_camera_type": printer.external_camera_type,
  94. "external_camera_enabled": printer.external_camera_enabled,
  95. "external_camera_snapshot_url": printer.external_camera_snapshot_url,
  96. "camera_rotation": printer.camera_rotation,
  97. "is_active": printer.is_active,
  98. "nozzle_count": printer.nozzle_count,
  99. "print_hours_offset": printer.print_hours_offset,
  100. "plate_detection_enabled": printer.plate_detection_enabled,
  101. "created_at": printer.created_at,
  102. "updated_at": printer.updated_at,
  103. }
  104. # Build ROI object if any ROI field is set
  105. if any(
  106. [
  107. printer.plate_detection_roi_x is not None,
  108. printer.plate_detection_roi_y is not None,
  109. printer.plate_detection_roi_w is not None,
  110. printer.plate_detection_roi_h is not None,
  111. ]
  112. ):
  113. data["plate_detection_roi"] = PlateDetectionROI(
  114. x=printer.plate_detection_roi_x or 0.15,
  115. y=printer.plate_detection_roi_y or 0.35,
  116. w=printer.plate_detection_roi_w or 0.70,
  117. h=printer.plate_detection_roi_h or 0.55,
  118. )
  119. return cls(**data)
  120. class PrinterResponseWithSecret(PrinterResponse):
  121. """PrinterResponse + access_code. Returned ONLY to callers with
  122. PRINTERS_UPDATE (Admin / Operator JWTs, or single-trust auth-disabled mode).
  123. Viewers and API keys never receive this shape — they get the bare
  124. PrinterResponse without access_code, since holding the access_code lets
  125. the caller talk to the printer's MQTT directly and bypass Bambuddy's RBAC.
  126. """
  127. access_code: str
  128. class HMSErrorResponse(BaseModel):
  129. code: str
  130. attr: int = 0 # Attribute value for constructing wiki URL
  131. module: int
  132. severity: int # 1=fatal, 2=serious, 3=common, 4=info
  133. actions: list[str] = [] # List of user-facing action keys (e.g. "CHECK_FILAMENT")
  134. job_id: str | None = None # Optional job ID for actions that require it (e.g. "CHECK_ASSISTANT")
  135. class AMSTray(BaseModel):
  136. id: int
  137. tray_color: str | None = None
  138. tray_type: str | None = None
  139. tray_sub_brands: str | None = None # Full name like "PLA Basic", "PETG HF"
  140. tray_id_name: str | None = None # Bambu filament ID like "A00-Y2" (can decode to color)
  141. tray_info_idx: str | None = None # Filament preset ID like "GFA00"
  142. remain: int = 0
  143. k: float | None = None # Pressure advance value (from tray or K-profile lookup)
  144. cali_idx: int | None = None # Calibration index for K-profile lookup
  145. tag_uid: str | None = None # RFID tag UID (any tag)
  146. tray_uuid: str | None = None # Bambu Lab spool UUID (32-char hex)
  147. nozzle_temp_min: int | None = None # Min nozzle temperature
  148. nozzle_temp_max: int | None = None # Max nozzle temperature
  149. drying_temp: int | None = None # RFID-recommended drying temp
  150. drying_time: int | None = None # RFID-recommended drying time (hours)
  151. state: int | None = None # AMS tray state: 9=empty, 10=spool present not loaded, 11=loaded
  152. class AMSUnit(BaseModel):
  153. id: int
  154. humidity: int | None = None
  155. temp: float | None = None
  156. is_ams_ht: bool = False # True for AMS-HT (single spool), False for regular AMS (4 spools)
  157. tray: list[AMSTray] = []
  158. serial_number: str = "" # AMS unit serial number (sn from MQTT)
  159. sw_ver: str = "" # AMS firmware version (from get_version info.module)
  160. dry_time: int = 0 # Minutes remaining (0 = not drying, >0 = drying active)
  161. dry_status: int = 0 # 0=Off, 1=Checking, 2=Drying, 3=Cooling, 4=Stopping, 5=Error
  162. dry_sub_status: int = 0 # 0=Off, 1=Heating, 2=Dehumidify
  163. dry_sf_reason: list[int] = [] # Cannot-dry reasons from firmware (see CannotDryReason)
  164. dry_target_temp: int | None = None # Active-cycle target °C (Bambu doesn't echo this)
  165. dry_filament: str | None = None # Active-cycle filament name we sent
  166. module_type: str = "" # "ams", "n3f", "n3s"
  167. class NozzleInfoResponse(BaseModel):
  168. nozzle_type: str = "" # "stainless_steel" or "hardened_steel"
  169. nozzle_diameter: str = "" # e.g., "0.4"
  170. class NozzleRackSlot(BaseModel):
  171. """H2C nozzle rack slot (6-position tool-changer dock)."""
  172. id: int = 0
  173. nozzle_type: str = ""
  174. nozzle_diameter: str = ""
  175. wear: int | None = None
  176. stat: int | None = None # Nozzle status (e.g. mounted/docked)
  177. max_temp: int = 0 # Max temperature rating °C (0 = not set)
  178. serial_number: str = "" # Nozzle serial number
  179. filament_color: str = "" # RGBA hex ("00000000" = no filament)
  180. filament_id: str = "" # Bambu filament ID
  181. filament_type: str = "" # Material type (e.g. "PLA", "PETG")
  182. class AmsLabelBody(BaseModel):
  183. label: str = Field(..., min_length=1, max_length=100)
  184. ams_serial: str = Field(default="", max_length=50)
  185. class HmsActionBody(BaseModel):
  186. # 8-char hex short code without separator (e.g. "05000070") — frontend strips
  187. # the underscore from the displayed `MMMM_EEEE` before sending.
  188. print_error: str = Field(..., min_length=8, max_length=8, pattern=r"^[0-9A-Fa-f]{8}$")
  189. # One of the HMSAction enum values. Length-capped to keep stray input from
  190. # reaching the dispatcher's `match` statement.
  191. action: str = Field(..., min_length=1, max_length=64)
  192. # The `subtask_id` snapshot from the HMSError that surfaced this dialog.
  193. # Bambu echoes it back in HMS-aware commands. Optional for idle errors.
  194. job_id: str | None = Field(default=None, max_length=64)
  195. class FilaSwitchResponse(BaseModel):
  196. """Filament Track Switch (FTS) state — accessory that mediates AMS-to-extruder routing.
  197. When installed, the AMS info field reports bits 8-11 = 0xE (uninitialized)
  198. because slots are dynamically routed via the FTS rather than tied to a
  199. specific extruder. Frontend uses `installed` to suppress the per-extruder
  200. slot filter in the print modal. See #1162.
  201. """
  202. installed: bool = False
  203. # in[track] = currently loaded slot for that track (-1 = empty)
  204. in_slots: list[int] = []
  205. # out[track] = extruder this track terminates at (0 = right, 1 = left)
  206. out_extruders: list[int] = []
  207. stat: int = 0
  208. info: int = 0
  209. class PrintOptionsResponse(BaseModel):
  210. """AI detection and print options from xcam data."""
  211. # Core AI detectors
  212. spaghetti_detector: bool = False
  213. print_halt: bool = False
  214. halt_print_sensitivity: str = "medium" # Spaghetti sensitivity
  215. first_layer_inspector: bool = False
  216. printing_monitor: bool = False
  217. buildplate_marker_detector: bool = False
  218. allow_skip_parts: bool = False
  219. # Additional AI detectors (decoded from cfg bitmask)
  220. nozzle_clumping_detector: bool = True
  221. nozzle_clumping_sensitivity: str = "medium"
  222. pileup_detector: bool = True
  223. pileup_sensitivity: str = "medium"
  224. airprint_detector: bool = True
  225. airprint_sensitivity: str = "medium"
  226. auto_recovery_step_loss: bool = True
  227. filament_tangle_detect: bool = False
  228. class PrinterStatus(BaseModel):
  229. id: int
  230. name: str
  231. connected: bool
  232. state: str | None = None
  233. current_print: str | None = None
  234. subtask_name: str | None = None
  235. gcode_file: str | None = None
  236. progress: float | None = None
  237. remaining_time: int | None = None
  238. layer_num: int | None = None
  239. total_layers: int | None = None
  240. temperatures: dict | None = None
  241. cover_url: str | None = None
  242. hms_errors: list[HMSErrorResponse] = []
  243. ams: list[AMSUnit] = []
  244. ams_exists: bool = False
  245. vt_tray: list[AMSTray] = [] # Virtual tray / external spool(s)
  246. sdcard: bool = False # SD card inserted
  247. store_to_sdcard: bool = False # Store sent files on SD card
  248. timelapse: bool = False # Timelapse recording active
  249. ipcam: bool = False # Live view enabled
  250. wifi_signal: int | None = None # WiFi signal strength in dBm
  251. wired_network: bool = False # Ethernet connection detected
  252. door_open: bool = False # Enclosure door open (X1/P1S/P2S/H2*)
  253. nozzles: list[NozzleInfoResponse] = [] # Nozzle hardware info (index 0=left/primary, 1=right)
  254. nozzle_rack: list[NozzleRackSlot] = [] # H2C 6-nozzle tool-changer rack
  255. print_options: PrintOptionsResponse | None = None # AI detection and print options
  256. # Calibration stage tracking
  257. stg_cur: int = -1 # Current stage number (-1 = not calibrating)
  258. stg_cur_name: str | None = None # Human-readable current stage name
  259. stg: list[int] = [] # List of stage numbers in calibration sequence
  260. # Air conditioning mode (0=cooling, 1=heating)
  261. airduct_mode: int = 0
  262. # Print speed level (1=silent, 2=standard, 3=sport, 4=ludicrous)
  263. speed_level: int = 2
  264. # Chamber light on/off
  265. chamber_light: bool = False
  266. # Active extruder for dual nozzle (0=right, 1=left)
  267. active_extruder: int = 0
  268. # AMS mapping for dual nozzle: which AMS is connected to which nozzle
  269. ams_mapping: list[int] = []
  270. # Per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left
  271. ams_extruder_map: dict[str, int] = {}
  272. # Filament Track Switch (FTS) accessory — when installed, AMS reports
  273. # bits 8-11 = 0xE (uninitialized) and routing is dynamic via the FTS. See #1162.
  274. fila_switch: FilaSwitchResponse | None = None
  275. # Currently loaded tray (global ID): 254 = external spool, 255 = no filament
  276. tray_now: int = 255
  277. # AMS status for filament change tracking
  278. # Main status: 0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration
  279. ams_status_main: int = 0
  280. # Sub status: specific step within filament change (when main=1)
  281. # Known values: 4=retraction, 6=load verification, 7=purge
  282. ams_status_sub: int = 0
  283. # mc_print_sub_stage - filament change step indicator used by OrcaSlicer/BambuStudio
  284. mc_print_sub_stage: int = 0
  285. # Timestamp of last AMS data update (for RFID refresh detection)
  286. last_ams_update: float = 0.0
  287. # Number of printable objects in current print (for skip objects feature)
  288. printable_objects_count: int = 0
  289. # Fan speeds (0-100 percentage, None if not available for this model)
  290. cooling_fan_speed: int | None = None # Part cooling fan
  291. big_fan1_speed: int | None = None # Auxiliary fan
  292. big_fan2_speed: int | None = None # Chamber/exhaust fan
  293. heatbreak_fan_speed: int | None = None # Hotend heatbreak fan
  294. # Firmware version (from info.module[name="ota"].sw_ver)
  295. firmware_version: str | None = None
  296. # Developer LAN mode: True = enabled, False = disabled (MQTT encryption), None = unknown
  297. developer_mode: bool | None = None
  298. # AMS Filament Backup ("auto-switch" to a second spool when one runs out).
  299. # True = ON, False = OFF, None = unknown / unsupported (A1 family — protocol field
  300. # not yet identified). UI treats None as "status unavailable", not as a hard disable.
  301. ams_filament_backup: bool | None = None
  302. # Queue: printer is awaiting the user to acknowledge the build plate is cleared
  303. # after a finished/failed print. Persisted across restarts (#961).
  304. awaiting_plate_clear: bool = False
  305. # AMS drying support
  306. supports_drying: bool = False
  307. # AMS "Print While Drying" — drying mid-print. Verified per Bambu wiki release notes;
  308. # see _DRY_WHILE_PRINTING_MIN_FIRMWARE in printer_manager.py for the matrix.
  309. supports_drying_while_printing: bool = False
  310. # Active chamber heater (responds to M141). True only for H2C/H2D/H2DPro/H2S/X2D.
  311. supports_chamber_heater: bool = False
  312. # Linked archive for the active print (resolved via subtask_id). Frontend uses
  313. # this to fetch plate metadata and show the plate name when the source 3MF is
  314. # multi-plate (#881 follow-up).
  315. current_archive_id: int | None = None
  316. # 1-indexed plate number parsed from gcode_file (e.g. /Metadata/plate_2.gcode).
  317. # Set for every active print regardless of plate count; the frontend decides
  318. # whether to render it based on current_archive_id's is_multi_plate flag.
  319. current_plate_id: int | None = None
  320. class DiagnosticCheck(BaseModel):
  321. """One connection-diagnostic check result.
  322. ``id`` is a stable key (port_mqtt, port_ftps, port_rtsps, network_mode,
  323. subnet, mqtt_auth, developer_mode); the frontend renders the localized
  324. title and fix text from id + status. ``params`` carries interpolation
  325. values (e.g. network mode, IP addresses) for that text.
  326. """
  327. id: str
  328. status: str # "pass" | "fail" | "warn" | "skip"
  329. params: dict = Field(default_factory=dict)
  330. class PrinterDiagnosticResult(BaseModel):
  331. """Result of a printer connection diagnostic run."""
  332. printer_id: int | None = None
  333. ip_address: str
  334. overall: str # "ok" | "warnings" | "problems"
  335. checks: list[DiagnosticCheck]
  336. class DiagnosticRequest(BaseModel):
  337. """Pre-save (Add Printer) connection diagnostic request.
  338. serial_number + access_code are optional: when both are present the
  339. diagnostic also probes MQTT credentials, otherwise only the
  340. network-level checks run.
  341. """
  342. ip_address: str
  343. serial_number: str | None = None
  344. access_code: str | None = None