print_queue.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  1. from datetime import datetime
  2. from typing import Annotated, Literal
  3. from pydantic import BaseModel, BeforeValidator, Field, PlainSerializer, model_validator
  4. from backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C
  5. # Custom serializer to ensure UTC datetimes have Z suffix
  6. def serialize_utc_datetime(dt: datetime | None) -> str | None:
  7. if dt is None:
  8. return None
  9. # Add Z suffix to indicate UTC
  10. return dt.isoformat() + "Z"
  11. UTCDatetime = Annotated[datetime | None, PlainSerializer(serialize_utc_datetime)]
  12. def _coerce_tristate(v: object) -> object:
  13. """Map legacy on/off booleans onto the tri-state calibration options.
  14. bed_levelling / flow_cali / nozzle_offset_cali were plain booleans before we
  15. added BambuStudio's third "auto" state (skip if recently done). Rows and API
  16. payloads created under the old scheme carry bool / 0-1 int / "true"/"false";
  17. coerce them so old clients and un-migrated rows still validate. getValueInt
  18. parity: off=0, on=1, auto=2.
  19. """
  20. if isinstance(v, bool):
  21. return "on" if v else "off"
  22. if isinstance(v, int):
  23. return {0: "off", 1: "on", 2: "auto"}.get(v, "auto")
  24. if isinstance(v, str):
  25. low = v.strip().lower()
  26. if low in ("true", "1"):
  27. return "on"
  28. if low in ("false", "0"):
  29. return "off"
  30. return v
  31. # Tri-state calibration option: "auto" (printer decides / skip if recent),
  32. # "on" (force every print), "off" (never). Mirrors BambuStudio's ops_auto.
  33. TriState = Annotated[Literal["off", "on", "auto"], BeforeValidator(_coerce_tristate)]
  34. class QueueVariantCreate(BaseModel):
  35. """One candidate file for a cross-model queue item (#671).
  36. Per-file rather than per-item because the settings genuinely differ between
  37. candidates: an H2C slice is dual-nozzle and will not share slot count, AMS
  38. mapping or nozzle mapping with the H2S slice of the same model.
  39. ``target_model`` is normally omitted and read from the file's own
  40. ``sliced_for_model``; supply it only for a legacy 3MF that declares none.
  41. """
  42. library_file_id: int
  43. target_model: str | None = None
  44. plate_id: int | None = None
  45. ams_mapping: list[int] | None = None
  46. nozzle_mapping: list[int] | None = None
  47. filament_overrides: list[dict] | None = None
  48. class PrintQueueItemCreate(BaseModel):
  49. printer_id: int | None = None # None = unassigned, user assigns later
  50. target_model: str | None = None # Target printer model (mutually exclusive with printer_id)
  51. target_location: str | None = None # Target location filter (only used with target_model)
  52. required_filament_types: list[str] | None = None # Required filament types for model-based assignment
  53. filament_overrides: list[dict] | None = None # Filament overrides for model-based assignment
  54. # Either archive_id OR library_file_id must be provided
  55. archive_id: int | None = None
  56. library_file_id: int | None = None
  57. scheduled_time: datetime | None = None # None = ASAP (next when idle)
  58. require_previous_success: bool = False
  59. auto_off_after: bool = False # Power off printer after print completes
  60. manual_start: bool = False # Requires manual trigger to start (staged)
  61. insert_at_top: bool = False # Insert ahead of other pending items in the same queue scope
  62. insert_position: int | None = None # 1-indexed insertion position for priority queueing
  63. # Persistent "Print Anyway" acknowledgement (#1698-followup). When set,
  64. # PrintModal already showed the deficit warning and the user confirmed,
  65. # so the scheduler does not re-flag this item on the next tick.
  66. skip_filament_check: bool = False
  67. # AMS mapping: list of global tray IDs for each filament slot
  68. # Format: [5, -1, 2, -1] where position = slot_id-1, value = global tray ID (-1 = unused)
  69. ams_mapping: list[int] | None = None
  70. # Plate ID for multi-plate 3MF files (1-indexed, None = auto-detect/plate 1)
  71. plate_id: int | None = None
  72. # Print options. bed_levelling / flow_cali / nozzle_offset_cali are tri-state
  73. # (off/on/auto), defaulting to "auto" to match BambuStudio. vibration_cali /
  74. # layer_inspect / timelapse stay on/off (BambuStudio exposes no auto for them).
  75. bed_levelling: TriState = "auto"
  76. flow_cali: TriState = "auto"
  77. vibration_cali: bool = True
  78. layer_inspect: bool = False
  79. timelapse: bool = False
  80. use_ams: bool = True
  81. # Nozzle offset calibration — dual-nozzle printers only (#1682). The MQTT
  82. # layer ignores the value on single-nozzle printers so the wire stays "skip".
  83. nozzle_offset_cali: TriState = "auto"
  84. # Preheat / heat-soak per-item override (#1468). 'inherit' uses the global
  85. # preheat_enabled setting; 'on' / 'off' force the decision. The chamber
  86. # target falls through: this override → max(filament-map[loaded tray]) → 0.
  87. preheat_override: Literal["inherit", "on", "off"] = "inherit"
  88. preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
  89. # Auto-print G-code injection
  90. gcode_injection: bool = False
  91. # Batch: create multiple copies (creates a batch if > 1)
  92. quantity: int = 1
  93. # Existing batch to add this item into. When set, the item's batch_id is
  94. # populated on insert so the queue UI groups it with its siblings. Used by
  95. # the multi-plate auto-batch flow and by the "Group as batch" action.
  96. batch_id: int | None = None
  97. # Project to associate the resulting archive with
  98. project_id: int | None = None
  99. # Direct printer-card uploads are temporary library files. The scheduler
  100. # deletes them after creating the durable archive copy.
  101. cleanup_library_after_dispatch: bool = False
  102. # Cross-model alternatives (#671): several sliced files, one job, whichever
  103. # printer frees up first. Mutually exclusive with printer_id (a specific
  104. # printer defeats the purpose) and with archive_id/library_file_id (the
  105. # candidates ARE the files). The scheduler resolves one onto the row at
  106. # dispatch, after which the item is an ordinary single-file job.
  107. variants: list[QueueVariantCreate] | None = None
  108. class PrintQueueItemUpdate(BaseModel):
  109. printer_id: int | None = None
  110. target_model: str | None = None # Target printer model (mutually exclusive with printer_id)
  111. target_location: str | None = None # Target location filter (only used with target_model)
  112. filament_overrides: list[dict] | None = None # Filament overrides for model-based assignment
  113. position: int | None = None
  114. scheduled_time: datetime | None = None
  115. require_previous_success: bool | None = None
  116. auto_off_after: bool | None = None
  117. manual_start: bool | None = None
  118. ams_mapping: list[int] | None = None
  119. plate_id: int | None = None
  120. # Print options
  121. bed_levelling: TriState | None = None
  122. flow_cali: TriState | None = None
  123. vibration_cali: bool | None = None
  124. layer_inspect: bool | None = None
  125. timelapse: bool | None = None
  126. use_ams: bool | None = None
  127. nozzle_offset_cali: TriState | None = None
  128. preheat_override: Literal["inherit", "on", "off"] | None = None
  129. preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
  130. # Auto-print G-code injection
  131. gcode_injection: bool | None = None
  132. # H2C dual-nozzle-rack slicer pick (#1780). list[int] per-filament
  133. # physical nozzle position IDs from BambuStudio's project_file MQTT
  134. # body; sent back to the printer verbatim on dispatch.
  135. nozzle_mapping: list[int] | None = None
  136. class QueueVariantSummary(BaseModel):
  137. """One candidate on a cross-model queue item, for display (#671)."""
  138. library_file_id: int
  139. filename: str
  140. target_model: str
  141. position: int
  142. class PrintQueueItemResponse(BaseModel):
  143. id: int
  144. printer_id: int | None # None = unassigned
  145. target_model: str | None = None # Target printer model for model-based assignment
  146. target_location: str | None = None # Target location filter for model-based assignment
  147. required_filament_types: list[str] | None = None # Required filament types for model-based assignment
  148. filament_overrides: list[dict] | None = None # Filament overrides for model-based assignment
  149. waiting_reason: str | None = None # Why a model-based job hasn't started yet
  150. archive_id: int | None # None if library_file_id is set (archive created at print start)
  151. library_file_id: int | None # For queue items from library files
  152. position: int
  153. scheduled_time: UTCDatetime
  154. require_previous_success: bool
  155. auto_off_after: bool
  156. manual_start: bool
  157. # True when the dispatch scheduler last evaluated this item and the
  158. # assigned spool could not satisfy at least one slot's required grams
  159. # (#1496). Display-only — the ▶ click recomputes deficit against live
  160. # spool state.
  161. filament_short: bool = False
  162. # User has acknowledged "Print Anyway" — scheduler skips the deficit check
  163. # for this item (#1698-followup).
  164. skip_filament_check: bool = False
  165. ams_mapping: list[int] | None = None
  166. plate_id: int | None = None # Plate ID for multi-plate 3MF files
  167. # Print options
  168. bed_levelling: TriState = "auto"
  169. flow_cali: TriState = "auto"
  170. vibration_cali: bool = True
  171. layer_inspect: bool = False
  172. timelapse: bool = False
  173. use_ams: bool = True
  174. nozzle_offset_cali: TriState = "auto"
  175. preheat_override: Literal["inherit", "on", "off"] = "inherit"
  176. preheat_chamber_target_override: int | None = None
  177. status: Literal["pending", "printing", "completed", "failed", "skipped", "cancelled"]
  178. started_at: UTCDatetime
  179. completed_at: UTCDatetime
  180. error_message: str | None
  181. created_at: UTCDatetime
  182. # Nested info for UI (populated in route)
  183. archive_name: str | None = None
  184. archive_thumbnail: str | None = None
  185. # True when the linked archive has been soft-deleted (its files are gone
  186. # from disk). In that case the *archive_name* / *archive_thumbnail* /
  187. # downstream metadata fields are intentionally left None so the frontend
  188. # doesn't 404-storm the now-missing thumbnail / plates / plate-thumbnail
  189. # endpoints (#1348 follow-up). Frontends can render a "source deleted"
  190. # badge based on this flag.
  191. archive_deleted: bool = False
  192. library_file_name: str | None = None # Name of library file (if library_file_id is set)
  193. library_file_thumbnail: str | None = None # Thumbnail of library file
  194. printer_name: str | None = None
  195. print_time_seconds: int | None = None # Estimated print time from archive or library file
  196. filament_used_grams: float | None = None # Estimated print weight from archive or library file
  197. filament_type: str | None = None # e.g. "PLA", "PETG" (from archive/library file)
  198. filament_color: str | None = None # e.g. "#FFFFFF" (from archive/library file)
  199. layer_height: float | None = None # e.g. 0.2 (from archive/library file)
  200. nozzle_diameter: float | None = None # e.g. 0.4 (from archive/library file)
  201. sliced_for_model: str | None = None # e.g. "P1S" (from archive/library file)
  202. # Build plate type (e.g. "Textured PEI Plate") so the user knows which
  203. # plate to mount on the printer (#1281). Per-plate accurate on multi-plate
  204. # 3MFs: when `plate_id` is set, the value is the matching plate's
  205. # `curr_bed_type` rather than the archive-level first-plate default.
  206. bed_type: str | None = None
  207. # True when the source archive carries the slicer's own live-resolved
  208. # AMS-slot pick (extra_data.slicer_ams_mapping) *and* it was resolved
  209. # against this row's own printer — the only case where dispatch actually
  210. # reuses that exact physical spool instead of the scheduler re-deriving one
  211. # from the file's static type/color.
  212. archive_has_slicer_ams_mapping: bool = False
  213. # User tracking (Issue #206)
  214. created_by_id: int | None = None
  215. created_by_username: str | None = None
  216. # Batch grouping
  217. batch_id: int | None = None
  218. batch_name: str | None = None
  219. # Cross-model alternatives (#671), in priority order. Empty for every
  220. # ordinary item. Present until dispatch resolves one onto the row, after
  221. # which library_file_id / target_model name the candidate that actually ran.
  222. variants: list[QueueVariantSummary] = []
  223. # Shortest-job-first scheduling
  224. been_jumped: bool = False
  225. # Auto-print G-code injection
  226. gcode_injection: bool = False
  227. cleanup_library_after_dispatch: bool = False
  228. # H2C dual-nozzle-rack slicer pick (#1780). Surface for any future
  229. # "edit print → choose nozzle" UI; null on every model except O1C2
  230. # uploads from BambuStudio.
  231. nozzle_mapping: list[int] | None = None
  232. class Config:
  233. from_attributes = True
  234. class PrintQueueReorderItem(BaseModel):
  235. id: int
  236. position: int
  237. class PrintQueueReorder(BaseModel):
  238. items: list[PrintQueueReorderItem]
  239. @model_validator(mode="after")
  240. def _validate_positions_unique(self) -> "PrintQueueReorder":
  241. """Reject reorder requests with duplicate positions in the payload
  242. (#1625-followup).
  243. The /reorder route is the drag-drop renumber path on the queue UI;
  244. a well-behaved client sends a contiguous renumbering of a single
  245. printer's pending queue. A buggy client that sends two items at
  246. the same position would leave the queue in an inconsistent state
  247. (scheduler's ORDER BY (printer_id, position) ties get broken by
  248. physical row order). Fail closed at the schema boundary so the
  249. bug is caught before any DB mutation.
  250. Uniqueness is enforced WITHIN THE PAYLOAD only — cross-printer
  251. reorders that intentionally share positions across different
  252. printer queues are a non-goal of the drag-drop UI, so this is the
  253. right scope.
  254. """
  255. positions = [it.position for it in self.items]
  256. if len(positions) != len(set(positions)):
  257. duplicates = sorted({p for p in positions if positions.count(p) > 1})
  258. raise ValueError(f"Duplicate positions in reorder request: {duplicates}")
  259. return self
  260. class PrintQueueBulkUpdate(BaseModel):
  261. """Bulk update multiple queue items with the same values."""
  262. item_ids: list[int]
  263. # Fields to update (all optional - only set fields are applied)
  264. printer_id: int | None = None
  265. scheduled_time: datetime | None = None
  266. require_previous_success: bool | None = None
  267. auto_off_after: bool | None = None
  268. manual_start: bool | None = None
  269. # Print options
  270. bed_levelling: TriState | None = None
  271. flow_cali: TriState | None = None
  272. vibration_cali: bool | None = None
  273. layer_inspect: bool | None = None
  274. timelapse: bool | None = None
  275. use_ams: bool | None = None
  276. nozzle_offset_cali: TriState | None = None
  277. preheat_override: Literal["inherit", "on", "off"] | None = None
  278. preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
  279. # Auto-print G-code injection
  280. gcode_injection: bool | None = None
  281. class PrintQueueBulkUpdateResponse(BaseModel):
  282. """Response for bulk update operation."""
  283. updated_count: int
  284. skipped_count: int # Items that were not pending
  285. message: str
  286. class PrintBatchPlateTarget(BaseModel):
  287. """How many runs of one plate an order wants (#342).
  288. ``plate_id`` is the plate index inside the source 3MF, or null for a
  289. single-plate file — matching ``PrintQueueItem.plate_id``. A target of 0 is
  290. legal and means "this plate is not required (yet)".
  291. """
  292. plate_id: int | None = None
  293. plate_name: str | None = None
  294. quantity_target: int = Field(default=1, ge=0, le=999)
  295. sort_order: int = 0
  296. class PrintBatchCreate(BaseModel):
  297. """Create a batch, either empty (multi-plate pre-batch flow) or by
  298. assigning existing pending queue items into it (manual "Group as batch")."""
  299. name: str
  300. archive_id: int | None = None
  301. library_file_id: int | None = None
  302. # Existing pending queue items to assign to this batch. None / empty for
  303. # the empty-batch flow (client passes the returned id on subsequent
  304. # addToQueue calls).
  305. item_ids: list[int] | None = None
  306. # Per-plate targets. Omitted entirely by the pre-#342 flows, which produce
  307. # a batch that reports progress but owes nothing.
  308. plates: list[PrintBatchPlateTarget] | None = None
  309. # Planning metadata. Projects own the heavier fields (BOM, attachments,
  310. # tags); these two are the ones that are useless without a Project to
  311. # hang them on, so the order carries them directly.
  312. project_id: int | None = None
  313. due_date: datetime | None = None
  314. notes: str | None = None
  315. class PrintBatchUpdate(BaseModel):
  316. """Edit an order's header or its per-plate targets while it runs.
  317. Every field is optional; ``plates`` replaces the full target set when
  318. given, so a plate omitted from the list has its target row removed.
  319. """
  320. name: str | None = None
  321. status: Literal["active", "cancelled"] | None = None
  322. plates: list[PrintBatchPlateTarget] | None = None
  323. project_id: int | None = None
  324. due_date: datetime | None = None
  325. notes: str | None = None
  326. class PrintBatchDispatchRequest(BaseModel):
  327. """Create queue items for the runs an order still owes."""
  328. # Restrict to one plate. Null is a legitimate plate_id (single-plate file),
  329. # so the caller opts in explicitly rather than us inferring from null.
  330. plate_id: int | None = None
  331. only_plate: bool = False
  332. # Cap on how many items to create across all plates. None = everything owed.
  333. limit: int | None = Field(default=None, ge=1, le=999)
  334. class PrintBatchUngroupResponse(BaseModel):
  335. """Response after ungrouping a batch."""
  336. ungrouped_count: int
  337. message: str
  338. class PrintBatchPlateProgress(BaseModel):
  339. """Per-plate progress within a batch."""
  340. plate_id: int | None = None
  341. plate_name: str | None = None
  342. quantity_target: int = 0
  343. dispatched: int = 0
  344. remaining: int = 0
  345. pending_count: int = 0
  346. printing_count: int = 0
  347. completed_count: int = 0
  348. failed_count: int = 0
  349. cancelled_count: int = 0
  350. skipped_count: int = 0
  351. # Measured from finished runs, never estimated from the file. Null until
  352. # at least one run of this plate has produced a cost.
  353. actual_cost: float | None = None
  354. estimated_remaining_cost: float | None = None
  355. filament_used_grams: float | None = None
  356. print_time_seconds: int = 0
  357. class PrintBatchResponse(BaseModel):
  358. """Response for a print batch with progress stats."""
  359. id: int
  360. name: str
  361. archive_id: int | None = None
  362. library_file_id: int | None = None
  363. quantity: int
  364. status: str
  365. created_at: UTCDatetime
  366. completed_at: UTCDatetime | None = None
  367. created_by_id: int | None = None
  368. created_by_username: str | None = None
  369. project_id: int | None = None
  370. due_date: UTCDatetime | None = None
  371. notes: str | None = None
  372. # Derived counts
  373. pending_count: int = 0
  374. printing_count: int = 0
  375. completed_count: int = 0
  376. failed_count: int = 0
  377. cancelled_count: int = 0
  378. skipped_count: int = 0
  379. # Planning roll-up. has_targets is false for batches created before
  380. # per-plate targets existed: they report progress but owe nothing, and the
  381. # dispatch endpoint is a no-op for them.
  382. has_targets: bool = False
  383. target_count: int = 0
  384. remaining_count: int = 0
  385. actual_cost: float | None = None
  386. estimated_remaining_cost: float | None = None
  387. filament_used_grams: float | None = None
  388. print_time_seconds: int = 0
  389. plates: list[PrintBatchPlateProgress] = []
  390. class Config:
  391. from_attributes = True