printer.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643
  1. from datetime import datetime
  2. from urllib.parse import urlparse
  3. from pydantic import BaseModel, Field, field_serializer, field_validator, model_validator
  4. from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
  5. from backend.app.schemas.printer_location import normalize_location_name
  6. def _mask_url_login(url: str | None) -> str | None:
  7. """A stored camera URL with its login secret masked.
  8. One URL, not a log line: the login runs to the last ``@`` before the
  9. first ``/`` after the scheme, whitespace, ``?`` and ``#`` included (when
  10. in doubt it is masked). The user part up to the first ``:`` survives;
  11. without a ``:`` the login may be a token and goes whole.
  12. """
  13. if not url or "://" not in url:
  14. return url
  15. start = url.index("://") + 3
  16. slash = url.find("/", start)
  17. end = len(url) if slash == -1 else slash
  18. if "@" not in url[start:end]:
  19. return url
  20. login, host = url[start:end].rsplit("@", 1)
  21. shown = f"{login.split(':', 1)[0]}:[REDACTED]" if ":" in login else "[REDACTED]"
  22. return f"{url[:start]}{shown}@{host}{url[end:]}"
  23. class WLEDPresets(BaseModel):
  24. idle: int | None = Field(default=None, ge=1, le=250)
  25. prepare: int | None = Field(default=None, ge=1, le=250)
  26. printing: int | None = Field(default=None, ge=1, le=250)
  27. paused: int | None = Field(default=None, ge=1, le=250)
  28. finished: int | None = Field(default=None, ge=1, le=250)
  29. error: int | None = Field(default=None, ge=1, le=250)
  30. queue_waiting: int | None = Field(default=None, ge=1, le=250)
  31. filament_problem: int | None = Field(default=None, ge=1, le=250)
  32. hms_error: int | None = Field(default=None, ge=1, le=250)
  33. offline: int | None = Field(default=None, ge=1, le=250)
  34. class WLEDStoredConfig(BaseModel):
  35. enabled: bool = False
  36. base_url: str | None = None
  37. presets: WLEDPresets = Field(default_factory=WLEDPresets)
  38. finished_timeout_seconds: int | None = Field(default=None, ge=0, le=86400)
  39. @field_serializer("base_url")
  40. def _mask_stored_url_login(self, value: str | None) -> str | None:
  41. return _mask_url_login(value)
  42. class WLEDConfig(WLEDStoredConfig):
  43. base_url: str | None = Field(default=None, max_length=500)
  44. @field_validator("base_url")
  45. @classmethod
  46. def _validate_base_url(cls, value: str | None) -> str | None:
  47. if value is None:
  48. return None
  49. normalized = value.strip().rstrip("/")
  50. if not normalized:
  51. return None
  52. from backend.app.api.routes._url_safety import assert_safe_lan_service_url
  53. assert_safe_lan_service_url(normalized, label="WLED URL")
  54. parsed = urlparse(normalized)
  55. if parsed.username is not None or parsed.password is not None:
  56. raise ValueError("WLED URL must not include credentials")
  57. return normalized
  58. @model_validator(mode="after")
  59. def _require_base_url_when_enabled(self):
  60. if self.enabled and not self.base_url:
  61. raise ValueError("base_url is required when WLED is enabled")
  62. return self
  63. class WLEDPreset(BaseModel):
  64. id: int
  65. name: str
  66. class WLEDPresetListRequest(BaseModel):
  67. base_url: str = Field(max_length=500)
  68. @field_validator("base_url")
  69. @classmethod
  70. def _validate_base_url(cls, value: str) -> str:
  71. normalized = WLEDConfig._validate_base_url(value)
  72. if not normalized:
  73. raise ValueError("base_url is required")
  74. return normalized
  75. class WLEDPresetTestRequest(WLEDPresetListRequest):
  76. preset_id: int = Field(ge=1, le=250)
  77. class WLEDConnectionInfo(BaseModel):
  78. name: str | None = None
  79. version: str | None = None
  80. class PrinterBase(BaseModel):
  81. name: str = Field(..., min_length=1, max_length=100)
  82. serial_number: str = Field(..., min_length=1, max_length=50)
  83. @field_validator("serial_number")
  84. @classmethod
  85. def _normalize_serial_number(cls, v: str) -> str:
  86. """Uppercase and trim the serial number.
  87. Bambu serial numbers are uppercase alphanumeric, and the MQTT report
  88. topic ``device/<serial>/report`` is case-sensitive. A serial entered
  89. in the wrong case (or with stray whitespace) connects and subscribes
  90. without error but never receives a message — the printer publishes to
  91. the correctly-cased topic, so every status field stays unknown (#1465).
  92. Normalising on input makes the subscribed topic always match.
  93. """
  94. normalized = v.strip().upper()
  95. if not normalized:
  96. raise ValueError("serial_number must not be blank")
  97. return normalized
  98. ip_address: str = Field(
  99. ...,
  100. max_length=253,
  101. 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])?)*)$",
  102. )
  103. model: str | None = None
  104. location: str | None = None # Group/location name
  105. auto_archive: bool = True
  106. external_camera_url: str | None = None
  107. external_camera_type: str | None = None # "mjpeg", "rtsp", "snapshot", "usb"
  108. external_camera_enabled: bool = False
  109. external_camera_snapshot_url: str | None = None # Optional single-frame override; #1177
  110. camera_rotation: int = 0 # 0, 90, 180, 270 degrees
  111. camera_light_auto: bool = False # #1655, used when camera_light_mode is "selected"
  112. class PrinterCreate(PrinterBase):
  113. # access_code lives on the input shapes only — never on the default
  114. # PrinterResponse. Direct exposure on PRINTERS_READ would let a Viewer
  115. # connect to the printer's MQTT and bypass Bambuddy's RBAC.
  116. access_code: str = Field(..., min_length=1, max_length=20)
  117. # Input shape validates the URL; PrinterResponse serialises stored rows
  118. # with WLEDStoredConfig so one bad stored URL cannot break GET /printers.
  119. wled_config: WLEDConfig | None = None
  120. # Input only, not on PrinterBase: SQLite never enforced the column width,
  121. # so a stored location may be longer, and the response must still read it.
  122. _location = field_validator("location")(normalize_location_name)
  123. class PlateDetectionROI(BaseModel):
  124. """Region of interest for plate detection (percentages 0.0-1.0)."""
  125. x: float = Field(..., ge=0.0, le=1.0) # X start %
  126. y: float = Field(..., ge=0.0, le=1.0) # Y start %
  127. w: float = Field(..., ge=0.0, le=1.0) # Width %
  128. h: float = Field(..., ge=0.0, le=1.0) # Height %
  129. class PrinterUpdate(IgnoresNullForRequiredColumns):
  130. __orm_table__ = "printers"
  131. name: str | None = None
  132. ip_address: str | None = Field(
  133. default=None,
  134. max_length=253,
  135. 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])?)*)$",
  136. )
  137. access_code: str | None = None
  138. model: str | None = None
  139. location: str | None = None
  140. _location = field_validator("location")(normalize_location_name)
  141. is_active: bool | None = None
  142. auto_archive: bool | None = None
  143. print_hours_offset: float | None = None
  144. # Wear cost per printing hour (#694); null or 0 turns it off.
  145. wear_cost_per_hour: float | None = Field(default=None, ge=0, le=100000)
  146. external_camera_url: str | None = None
  147. external_camera_type: str | None = None
  148. external_camera_enabled: bool | None = None
  149. external_camera_snapshot_url: str | None = None # #1177
  150. camera_rotation: int | None = None # 0, 90, 180, 270 degrees
  151. camera_light_auto: bool | None = None # #1655
  152. plate_detection_enabled: bool | None = None
  153. plate_detection_roi: PlateDetectionROI | None = None
  154. wled_config: WLEDConfig | None = None
  155. class PrinterResponse(PrinterBase):
  156. wled_config: WLEDStoredConfig | None = None
  157. id: int
  158. is_active: bool
  159. nozzle_count: int = 1 # 1 or 2, auto-detected from MQTT
  160. # Whether the model is sold with both Standard and High Flow nozzles, so a
  161. # K-profile's flow type is a real choice rather than a meaningless field.
  162. # Derived from the model, not from nozzle_count — see
  163. # printer_models.supports_nozzle_flow_type.
  164. supports_nozzle_flow_type: bool = True
  165. print_hours_offset: float = 0.0
  166. wear_cost_per_hour: float | None = None # #694
  167. external_camera_url: str | None = None
  168. external_camera_type: str | None = None
  169. external_camera_enabled: bool = False
  170. external_camera_snapshot_url: str | None = None # #1177
  171. camera_rotation: int = 0 # 0, 90, 180, 270 degrees
  172. camera_light_auto: bool = False # #1655
  173. plate_detection_enabled: bool = False
  174. plate_detection_roi: PlateDetectionROI | None = None
  175. created_at: datetime
  176. updated_at: datetime
  177. class Config:
  178. from_attributes = True
  179. @field_validator("external_camera_url", "external_camera_snapshot_url", mode="after")
  180. @classmethod
  181. def _camera_credentials(cls, value: str | None) -> str | None:
  182. """Readers see the camera URLs without the password in them."""
  183. return _mask_url_login(value)
  184. class PrinterResponseWithSecret(PrinterResponse):
  185. """PrinterResponse + access_code and the full camera URLs. Returned ONLY to callers with
  186. PRINTERS_UPDATE (Admin / Operator JWTs, or single-trust auth-disabled mode).
  187. Viewers and API keys never receive this shape — they get the bare
  188. PrinterResponse without access_code, since holding the access_code lets
  189. the caller talk to the printer's MQTT directly and bypass Bambuddy's RBAC.
  190. """
  191. access_code: str
  192. @field_validator("external_camera_url", "external_camera_snapshot_url", mode="after")
  193. @classmethod
  194. def _camera_credentials(cls, value: str | None) -> str | None:
  195. """Editors keep the full camera URLs, to edit them."""
  196. return value
  197. class HMSErrorResponse(BaseModel):
  198. code: str
  199. attr: int = 0 # Attribute value for constructing wiki URL
  200. module: int
  201. severity: int # Bambu alert level: 1 error (stopped), 2 warning (paused), 3 notification, 0 invalid
  202. actions: list[str] = [] # List of user-facing action keys (e.g. "CHECK_FILAMENT")
  203. job_id: str | None = None # Optional job ID for actions that require it (e.g. "CHECK_ASSISTANT")
  204. # Canonical hex identifier the firmware uses to match HMS-related commands.
  205. # 16 chars for `hms[]`-array faults (full 64-bit attr+code), 8 chars for
  206. # `print_error` faults. The frontend echoes this back as
  207. # HmsActionBody.print_error so we send the firmware-recognised key, not the
  208. # truncated short_code that historically caused silent command rejection
  209. # (#1830, H2D wrong-plate verification).
  210. full_code: str = ""
  211. # The bundled catalogue's sentence for this fault, so a client does not have
  212. # to carry its own copy of the same table to tell a user why a print halted
  213. # (#2926). English only and not localized — the catalogue ships one language.
  214. # Generated from Bambu Studio's HMS files, keyed by `full_code` and the
  215. # printer model (#2728). None when Bambu publishes no text for the code,
  216. # which it does for some codes it lists. A client should treat null as "no
  217. # text available", never as "no fault" — `full_code` is what identifies the
  218. # fault, and it is always present.
  219. description: str | None = None
  220. def hms_error_responses(errors) -> list[HMSErrorResponse]:
  221. """A printer's live HMS faults (``PrinterState.hms_errors``) as API rows.
  222. Shared by the printer status route and the webhook status route, so a
  223. fault reads the same to the UI and to an API-key client (#2919).
  224. """
  225. return [
  226. HMSErrorResponse(
  227. code=e.code,
  228. attr=e.attr,
  229. module=e.module,
  230. severity=e.severity,
  231. actions=e.actions,
  232. job_id=e.job_id,
  233. full_code=e.full_code,
  234. description=e.description,
  235. )
  236. for e in (errors or [])
  237. ]
  238. class AMSTray(BaseModel):
  239. id: int
  240. tray_color: str | None = None
  241. tray_type: str | None = None
  242. tray_sub_brands: str | None = None # Full name like "PLA Basic", "PETG HF"
  243. tray_id_name: str | None = None # Bambu filament ID like "A00-Y2" (can decode to color)
  244. tray_info_idx: str | None = None # Filament preset ID like "GFA00"
  245. remain: int = 0
  246. k: float | None = None # Pressure advance value (from tray or K-profile lookup)
  247. cali_idx: int | None = None # Calibration index for K-profile lookup
  248. tag_uid: str | None = None # RFID tag UID (any tag)
  249. tray_uuid: str | None = None # Bambu Lab spool UUID (32-char hex)
  250. nozzle_temp_min: int | None = None # Min nozzle temperature
  251. nozzle_temp_max: int | None = None # Max nozzle temperature
  252. drying_temp: int | None = None # RFID-recommended drying temp
  253. drying_time: int | None = None # RFID-recommended drying time (hours)
  254. state: int | None = None # AMS tray state: 9=empty, 10=spool present not loaded, 11=loaded
  255. # Firmware's authoritative "spool physically present" bit (from tray_exist_bits).
  256. # True for a non-RFID spool the firmware can't identify — the UI shows "?" rather
  257. # than "Empty" (#2527). None when the bitmask was unavailable (→ state-based fallback).
  258. exists: bool | None = None
  259. class AMSUnit(BaseModel):
  260. id: int
  261. humidity: int | None = None
  262. temp: float | None = None
  263. is_ams_ht: bool = False # True for AMS-HT (single spool), False for regular AMS (4 spools)
  264. tray: list[AMSTray] = []
  265. serial_number: str = "" # AMS unit serial number (sn from MQTT)
  266. sw_ver: str = "" # AMS firmware version (from get_version info.module)
  267. dry_time: int = 0 # Minutes remaining (0 = not drying, >0 = drying active)
  268. # True when dry_time > 0 but the countdown has not ticked for over
  269. # DRY_COUNTDOWN_STALL_SECONDS with no active dry_status phase: the timer is
  270. # set but no cycle is running (never started, or paused partway).
  271. dry_countdown_stalled: bool = False
  272. dry_status: int = 0 # 0=Off, 1=Checking, 2=Drying, 3=Cooling, 4=Stopping, 5=Error
  273. dry_sub_status: int = 0 # 0=Off, 1=Heating, 2=Dehumidify
  274. dry_sf_reason: list[int] = [] # Cannot-dry reasons from firmware (see CannotDryReason)
  275. dry_target_temp: int | None = None # Active-cycle target °C (Bambu doesn't echo this)
  276. dry_filament: str | None = None # Active-cycle filament name we sent
  277. module_type: str = "" # "ams", "ams_f1", "n3f", "n3s", "n10a" (the H3C's AMS 3H)
  278. ams_type: int | None = None # Studio's AMS type (utils.tray_ids.AmsType); decides the global tray ids
  279. # The AMS 3H's dryer: its drying zone's two slots and readings. Empty elsewhere.
  280. dryer_slots: list[int] = []
  281. dryer_humidity: int | None = None
  282. dryer_temp: float | None = None
  283. class NozzleInfoResponse(BaseModel):
  284. nozzle_type: str = "" # "stainless_steel" or "hardened_steel"
  285. nozzle_diameter: str = "" # e.g., "0.4"
  286. class NozzleRackSlot(BaseModel):
  287. """H2C nozzle rack slot (6-position tool-changer dock)."""
  288. id: int = 0
  289. nozzle_type: str = ""
  290. nozzle_diameter: str = ""
  291. wear: int | None = None
  292. print_time_s: int | None = None # H2C: seconds this nozzle has printed, from "p_t" (#1908)
  293. stat: int | None = None # Nozzle status (e.g. mounted/docked)
  294. max_temp: int = 0 # Max temperature rating °C (0 = not set)
  295. serial_number: str = "" # Nozzle serial number
  296. filament_color: str = "" # RGBA hex ("00000000" = no filament)
  297. filament_id: str = "" # Bambu filament ID
  298. filament_type: str = "" # Material type (e.g. "PLA", "PETG")
  299. addr_id: int | None = None # H3C: home rack position 0-8 (the mounted hotend reports id 0)
  300. class AmsLabelBody(BaseModel):
  301. label: str = Field(..., min_length=1, max_length=100)
  302. ams_serial: str = Field(default="", max_length=50)
  303. class HmsActionBody(BaseModel):
  304. # Canonical hex identifier (HMSErrorResponse.full_code): 8 chars for
  305. # `print_error`-sourced faults, 16 chars for `hms[]`-array faults whose
  306. # full 64-bit code is the firmware's matching key. Length-bounded to
  307. # those two valid shapes to keep stray input from reaching the dispatcher.
  308. print_error: str = Field(..., min_length=8, max_length=16, pattern=r"^[0-9A-Fa-f]{8}([0-9A-Fa-f]{8})?$")
  309. # One of the HMSAction enum values. Length-capped to keep stray input from
  310. # reaching the dispatcher's `match` statement.
  311. action: str = Field(..., min_length=1, max_length=64)
  312. # The `subtask_id` snapshot from the HMSError that surfaced this dialog.
  313. # Bambu echoes it back in HMS-aware commands. Optional for idle errors.
  314. job_id: str | None = Field(default=None, max_length=64)
  315. class FilaSwitchResponse(BaseModel):
  316. """Filament Track Switch (FTS) state — accessory that mediates AMS-to-extruder routing.
  317. When installed, the AMS info field reports bits 8-11 = 0xE (uninitialized)
  318. because slots are dynamically routed via the FTS rather than tied to a
  319. specific extruder. Frontend uses `installed` to suppress the per-extruder
  320. slot filter in the print modal. See #1162.
  321. """
  322. installed: bool = False
  323. # in[track] = currently loaded slot for that track (-1 = empty)
  324. in_slots: list[int] = []
  325. # out[track] = extruder this track terminates at (0 = right, 1 = left)
  326. out_extruders: list[int] = []
  327. stat: int = 0
  328. info: int = 0
  329. # Whether the switch is set up: every AMS bound to one of its two inlets.
  330. # A load cannot be routed until it is, so the UI blocks on this rather than
  331. # sending a command the firmware will drop.
  332. ready: bool = False
  333. class ExtruderSlotResponse(BaseModel):
  334. """Which AMS slot one hotend is currently fed from.
  335. From ``device.extruder.info[i].snow``. Needed because ``tray_now`` is a
  336. single printer-wide value: on a dual-nozzle machine with both hotends
  337. loaded it names only one of them, so it cannot say which hotend holds a
  338. given slot.
  339. """
  340. # None when the hotend is not fed from any slot.
  341. ams_id: int | None = None
  342. slot_id: int | None = None
  343. has_filament: bool = False
  344. class PrintOptionsResponse(BaseModel):
  345. """AI detection and print options from xcam data."""
  346. # Core AI detectors
  347. spaghetti_detector: bool = False
  348. print_halt: bool = False
  349. halt_print_sensitivity: str = "medium" # Spaghetti sensitivity
  350. first_layer_inspector: bool = False
  351. printing_monitor: bool = False
  352. buildplate_marker_detector: bool = False
  353. allow_skip_parts: bool = False
  354. # Additional AI detectors (decoded from cfg bitmask)
  355. nozzle_clumping_detector: bool = True
  356. nozzle_clumping_sensitivity: str = "medium"
  357. pileup_detector: bool = True
  358. pileup_sensitivity: str = "medium"
  359. airprint_detector: bool = True
  360. airprint_sensitivity: str = "medium"
  361. auto_recovery_step_loss: bool = True
  362. filament_tangle_detect: bool = False
  363. class NextPause(BaseModel):
  364. """The next pause the slicer put into the running print (#2984)."""
  365. layer: int
  366. # Minutes until the printer gets there; None while it reports no time left.
  367. minutes: int | None = None
  368. class PrinterStatus(BaseModel):
  369. id: int
  370. name: str
  371. connected: bool
  372. state: str | None = None
  373. current_print: str | None = None
  374. subtask_name: str | None = None
  375. gcode_file: str | None = None
  376. progress: float | None = None
  377. remaining_time: int | None = None
  378. layer_num: int | None = None
  379. total_layers: int | None = None
  380. temperatures: dict | None = None
  381. cover_url: str | None = None
  382. hms_errors: list[HMSErrorResponse] = []
  383. ams: list[AMSUnit] = []
  384. ams_exists: bool = False
  385. vt_tray: list[AMSTray] = [] # Virtual tray / external spool(s)
  386. sdcard: bool = False # SD card inserted
  387. store_to_sdcard: bool = False # Store sent files on SD card
  388. timelapse: bool = False # Timelapse recording active
  389. ipcam: bool = False # Live view enabled
  390. wifi_signal: int | None = None # WiFi signal strength in dBm
  391. wired_network: bool = False # Ethernet connection detected
  392. door_open: bool = False # Enclosure door open (X1/P1S/P2S/H2*)
  393. nozzles: list[NozzleInfoResponse] = [] # Nozzle hardware info (index 0=left/primary, 1=right)
  394. nozzle_rack: list[NozzleRackSlot] = [] # H2C 6-nozzle tool-changer rack
  395. print_options: PrintOptionsResponse | None = None # AI detection and print options
  396. # Calibration stage tracking
  397. stg_cur: int = -1 # Current stage number (-1 = not calibrating)
  398. stg_cur_name: str | None = None # Human-readable current stage name
  399. stg: list[int] = [] # List of stage numbers in calibration sequence
  400. # Air conditioning mode (0=cooling, 1=heating)
  401. airduct_mode: int = 0
  402. # Print speed level (1=silent, 2=standard, 3=sport, 4=ludicrous)
  403. speed_level: int = 2
  404. # Chamber light on/off
  405. chamber_light: bool = False
  406. # Active extruder for dual nozzle (0=right, 1=left)
  407. active_extruder: int = 0
  408. # AMS mapping for dual nozzle: which AMS is connected to which nozzle
  409. ams_mapping: list[int] = []
  410. # Per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left
  411. ams_extruder_map: dict[str, int] = {}
  412. # Filament Track Switch (FTS) accessory — when installed, AMS reports
  413. # bits 8-11 = 0xE (uninitialized) and routing is dynamic via the FTS. See #1162.
  414. fila_switch: FilaSwitchResponse | None = None
  415. # Per-AMS FTS inlet binding: {ams_id: "A" | "B"}, from AMS info bits 24-27.
  416. # Which of the switch's two inlets each AMS is plumbed into, as set on the
  417. # printer's "Manual AMS Setup" screen. Empty unless an FTS is installed —
  418. # an FTS-bound AMS reaches BOTH nozzles, so it has no entry in
  419. # ams_extruder_map and must not be labelled left or right.
  420. ams_switch_inlet: dict[str, str] = {}
  421. # Which AMS slot each hotend is fed from, keyed by extruder id as a string
  422. # ("0" = right/main, "1" = left/deputy). Empty on printers that do not
  423. # report ``device.extruder.info``.
  424. extruder_slots: dict[str, ExtruderSlotResponse] = {}
  425. # Currently loaded tray (global ID): 254 = external spool, 255 = no filament
  426. tray_now: int = 255
  427. # The trays this print has drawn from, in order: [[global tray ID, layer the
  428. # switch happened at], ...]. A tray outside the job's AMS mapping is a backup
  429. # spool the printer switched to (AMS Filament Backup); the queue card shows it
  430. # in place of the slot that ran out. Reset at the start of each print.
  431. tray_change_log: list[list[int]] = []
  432. # Runout / filament-replacement guidance (#2587). Populated only while the
  433. # print is PAUSED. Both are globalised tray IDs (ams_id*4+slot, or 128-135 for
  434. # AMS-HT, or 254 for external) so the frontend can highlight them with the same
  435. # logic it uses for tray_now:
  436. # expected_tray = the slot the firmware now expects filament in (from tray_tar).
  437. # None when idle, not paused, or the slot can't be resolved
  438. # (multi-AMS ambiguity) — the UI then says "check the printer".
  439. # previous_tray = the slot loaded before the pause, i.e. the one that ran out
  440. # (from tray_pre). None when unknown.
  441. expected_tray: int | None = None
  442. previous_tray: int | None = None
  443. # AMS status for filament change tracking
  444. # Main status: 0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration
  445. ams_status_main: int = 0
  446. # Sub status: specific step within filament change (when main=1)
  447. # Known values: 4=retraction, 6=load verification, 7=purge
  448. ams_status_sub: int = 0
  449. # mc_print_sub_stage - filament change step indicator used by OrcaSlicer/BambuStudio
  450. mc_print_sub_stage: int = 0
  451. # Timestamp of last AMS data update (for RFID refresh detection)
  452. last_ams_update: float = 0.0
  453. # Number of printable objects in current print (for skip objects feature)
  454. printable_objects_count: int = 0
  455. # The next pause the slicer put into the print, while it is running (#2984)
  456. next_pause: NextPause | None = None
  457. # Fan speeds (0-100 percentage, None if not available for this model)
  458. cooling_fan_speed: int | None = None # Part cooling fan
  459. big_fan1_speed: int | None = None # Auxiliary fan
  460. big_fan2_speed: int | None = None # Chamber/exhaust fan
  461. heatbreak_fan_speed: int | None = None # Hotend heatbreak fan
  462. # Second auxiliary part cooling fan (airduct part id 10). Named for the P2S,
  463. # where it is the left fan (an accessory kit); on the X2D it is the RIGHT one
  464. # and big_fan1_speed (part 2) the left (#3373). The name stays for API clients.
  465. # None = not installed / not reported by this model.
  466. left_aux_fan_speed: int | None = None
  467. # Chamber exhaust fan present (P2S/X2D External Exhaust Fan kit; airduct part id 3).
  468. exhaust_fan_present: bool = False
  469. # Firmware version (from info.module[name="ota"].sw_ver)
  470. firmware_version: str | None = None
  471. # Developer LAN mode: True = enabled, False = disabled (MQTT encryption), None = unknown
  472. developer_mode: bool | None = None
  473. # AMS Filament Backup ("auto-switch" to a second spool when one runs out).
  474. # True = ON, False = OFF, None = unknown / unsupported (A1 family — protocol field
  475. # not yet identified). UI treats None as "status unavailable", not as a hard disable.
  476. ams_filament_backup: bool | None = None
  477. # Queue: printer is awaiting the user to acknowledge the build plate is cleared
  478. # after a finished/failed print. Persisted across restarts (#961).
  479. awaiting_plate_clear: bool = False
  480. # AMS drying support
  481. supports_drying: bool = False
  482. # AMS "Print While Drying" — drying mid-print. Verified per Bambu wiki release notes;
  483. # see _DRY_WHILE_PRINTING_MIN_FIRMWARE in printer_manager.py for the matrix.
  484. supports_drying_while_printing: bool = False
  485. # The AMS can dry, but only from the printer's own screen (P1 series, #2533).
  486. # supports_drying is False on these; the UI keeps the control visible but disabled
  487. # and says why, rather than dropping it without explanation.
  488. drying_screen_only: bool = False
  489. # Active chamber heater (responds to M141). True only for H2C/H2D/H2DPro/H2S/X2D.
  490. supports_chamber_heater: bool = False
  491. # Linked archive for the active print (resolved via subtask_id). Frontend uses
  492. # this to fetch plate metadata and show the plate name when the source 3MF is
  493. # multi-plate (#881 follow-up).
  494. current_archive_id: int | None = None
  495. # 1-indexed plate number parsed from gcode_file (e.g. /Metadata/plate_2.gcode).
  496. # Set for every active print regardless of plate count; the frontend decides
  497. # whether to render it based on current_archive_id's is_multi_plate flag.
  498. current_plate_id: int | None = None
  499. class DiagnosticCheck(BaseModel):
  500. """One connection-diagnostic check result.
  501. ``id`` is a stable key (port_mqtt, port_ftps, port_rtsps,
  502. macos_local_network, network_mode, subnet, external_storage, mqtt_auth,
  503. developer_mode, printer_publishing); the frontend renders the localized
  504. title and fix text from id + status. ``params`` carries interpolation
  505. values (e.g. network mode, IP addresses) for that text.
  506. Not every check is emitted on every run: ``macos_local_network`` appears
  507. only on macOS, where it is the only platform it can say anything about.
  508. """
  509. id: str
  510. status: str # "pass" | "fail" | "warn" | "skip"
  511. params: dict = Field(default_factory=dict)
  512. class PrinterDiagnosticResult(BaseModel):
  513. """Result of a printer connection diagnostic run."""
  514. printer_id: int | None = None
  515. ip_address: str
  516. overall: str # "ok" | "warnings" | "problems"
  517. checks: list[DiagnosticCheck]
  518. class DiagnosticRequest(BaseModel):
  519. """Pre-save (Add Printer) connection diagnostic request.
  520. serial_number + access_code are optional: when both are present the
  521. diagnostic also probes MQTT credentials, otherwise only the
  522. network-level checks run.
  523. """
  524. ip_address: str
  525. serial_number: str | None = None
  526. access_code: str | None = None
  527. class PrinterFilesDownloadRequest(BaseModel):
  528. """Printer paths selected for a bulk download."""
  529. paths: list[str] = Field(..., max_length=1000)
  530. sizes: dict[str, int] = Field(default_factory=dict, max_length=1000)
  531. @model_validator(mode="after")
  532. def _validate_sizes(self):
  533. """Validate optional FTP-reported sizes used for early rejection."""
  534. if self.sizes and set(self.sizes) != set(self.paths):
  535. raise ValueError("A size is required for every selected printer path")
  536. if any(size < 0 for size in self.sizes.values()):
  537. raise ValueError("Printer file sizes must not be negative")
  538. return self
  539. class PrinterFilesJobRequest(PrinterFilesDownloadRequest):
  540. """Browser preparation request, including native download presentation."""
  541. filename: str = Field(default="printer-files.zip", min_length=1, max_length=255)
  542. as_zip: bool = True