print_queue.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  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. cost_center_id: int | None = None
  100. estimated_cost: float | None = None
  101. # Direct printer-card uploads are temporary library files. The scheduler
  102. # deletes them after creating the durable archive copy.
  103. cleanup_library_after_dispatch: bool = False
  104. # Cross-model alternatives (#671): several sliced files, one job, whichever
  105. # printer frees up first. Mutually exclusive with printer_id (a specific
  106. # printer defeats the purpose) and with archive_id/library_file_id (the
  107. # candidates ARE the files). The scheduler resolves one onto the row at
  108. # dispatch, after which the item is an ordinary single-file job.
  109. variants: list[QueueVariantCreate] | None = None
  110. class PrintQueueItemUpdate(BaseModel):
  111. printer_id: int | None = None
  112. target_model: str | None = None # Target printer model (mutually exclusive with printer_id)
  113. target_location: str | None = None # Target location filter (only used with target_model)
  114. filament_overrides: list[dict] | None = None # Filament overrides for model-based assignment
  115. position: int | None = None
  116. scheduled_time: datetime | None = None
  117. require_previous_success: bool | None = None
  118. auto_off_after: bool | None = None
  119. manual_start: bool | None = None
  120. ams_mapping: list[int] | None = None
  121. plate_id: int | None = None
  122. # Print options
  123. bed_levelling: TriState | None = None
  124. flow_cali: TriState | None = None
  125. vibration_cali: bool | None = None
  126. layer_inspect: bool | None = None
  127. timelapse: bool | None = None
  128. use_ams: bool | None = None
  129. nozzle_offset_cali: TriState | None = None
  130. preheat_override: Literal["inherit", "on", "off"] | None = None
  131. preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
  132. # Auto-print G-code injection
  133. gcode_injection: bool | None = None
  134. cost_center_id: int | None = None
  135. estimated_cost: float | None = None
  136. # H2C dual-nozzle-rack slicer pick (#1780). list[int] per-filament
  137. # physical nozzle position IDs from BambuStudio's project_file MQTT
  138. # body; sent back to the printer verbatim on dispatch.
  139. nozzle_mapping: list[int] | None = None
  140. class QueueVariantSummary(BaseModel):
  141. """One candidate on a cross-model queue item, for display (#671)."""
  142. library_file_id: int
  143. filename: str
  144. target_model: str
  145. position: int
  146. class PrintQueueItemResponse(BaseModel):
  147. id: int
  148. printer_id: int | None # None = unassigned
  149. target_model: str | None = None # Target printer model for model-based assignment
  150. target_location: str | None = None # Target location filter for model-based assignment
  151. required_filament_types: list[str] | None = None # Required filament types for model-based assignment
  152. filament_overrides: list[dict] | None = None # Filament overrides for model-based assignment
  153. waiting_reason: str | None = None # Why a model-based job hasn't started yet
  154. archive_id: int | None # None if library_file_id is set (archive created at print start)
  155. library_file_id: int | None # For queue items from library files
  156. cost_center_id: int | None = None
  157. estimated_cost: float | None = None
  158. position: int
  159. scheduled_time: UTCDatetime
  160. require_previous_success: bool
  161. auto_off_after: bool
  162. manual_start: bool
  163. # True when the dispatch scheduler last evaluated this item and the
  164. # assigned spool could not satisfy at least one slot's required grams
  165. # (#1496). Display-only — the ▶ click recomputes deficit against live
  166. # spool state.
  167. filament_short: bool = False
  168. # User has acknowledged "Print Anyway" — scheduler skips the deficit check
  169. # for this item (#1698-followup).
  170. skip_filament_check: bool = False
  171. ams_mapping: list[int] | None = None
  172. plate_id: int | None = None # Plate ID for multi-plate 3MF files
  173. # Print options
  174. bed_levelling: TriState = "auto"
  175. flow_cali: TriState = "auto"
  176. vibration_cali: bool = True
  177. layer_inspect: bool = False
  178. timelapse: bool = False
  179. use_ams: bool = True
  180. nozzle_offset_cali: TriState = "auto"
  181. preheat_override: Literal["inherit", "on", "off"] = "inherit"
  182. preheat_chamber_target_override: int | None = None
  183. status: Literal["pending", "printing", "completed", "failed", "skipped", "cancelled"]
  184. started_at: UTCDatetime
  185. completed_at: UTCDatetime
  186. error_message: str | None
  187. created_at: UTCDatetime
  188. # Nested info for UI (populated in route)
  189. archive_name: str | None = None
  190. archive_thumbnail: str | None = None
  191. # True when the linked archive has been soft-deleted (its files are gone
  192. # from disk). In that case the *archive_name* / *archive_thumbnail* /
  193. # downstream metadata fields are intentionally left None so the frontend
  194. # doesn't 404-storm the now-missing thumbnail / plates / plate-thumbnail
  195. # endpoints (#1348 follow-up). Frontends can render a "source deleted"
  196. # badge based on this flag.
  197. archive_deleted: bool = False
  198. library_file_name: str | None = None # Name of library file (if library_file_id is set)
  199. library_file_thumbnail: str | None = None # Thumbnail of library file
  200. printer_name: str | None = None
  201. print_time_seconds: int | None = None # Estimated print time from archive or library file
  202. filament_used_grams: float | None = None # Estimated print weight from archive or library file
  203. filament_type: str | None = None # e.g. "PLA", "PETG" (from archive/library file)
  204. filament_color: str | None = None # e.g. "#FFFFFF" (from archive/library file)
  205. layer_height: float | None = None # e.g. 0.2 (from archive/library file)
  206. nozzle_diameter: float | None = None # e.g. 0.4 (from archive/library file)
  207. sliced_for_model: str | None = None # e.g. "P1S" (from archive/library file)
  208. # Build plate type (e.g. "Textured PEI Plate") so the user knows which
  209. # plate to mount on the printer (#1281). Per-plate accurate on multi-plate
  210. # 3MFs: when `plate_id` is set, the value is the matching plate's
  211. # `curr_bed_type` rather than the archive-level first-plate default.
  212. bed_type: str | None = None
  213. # True when the source archive carries the slicer's own live-resolved
  214. # AMS-slot pick (extra_data.slicer_ams_mapping) *and* it was resolved
  215. # against this row's own printer — the only case where dispatch actually
  216. # reuses that exact physical spool instead of the scheduler re-deriving one
  217. # from the file's static type/color.
  218. archive_has_slicer_ams_mapping: bool = False
  219. # User tracking (Issue #206)
  220. created_by_id: int | None = None
  221. created_by_username: str | None = None
  222. # Batch grouping
  223. batch_id: int | None = None
  224. batch_name: str | None = None
  225. # Cross-model alternatives (#671), in priority order. Empty for every
  226. # ordinary item. Present until dispatch resolves one onto the row, after
  227. # which library_file_id / target_model name the candidate that actually ran.
  228. variants: list[QueueVariantSummary] = []
  229. # Shortest-job-first scheduling
  230. been_jumped: bool = False
  231. # Auto-print G-code injection
  232. gcode_injection: bool = False
  233. cleanup_library_after_dispatch: bool = False
  234. # H2C dual-nozzle-rack slicer pick (#1780). Surface for any future
  235. # "edit print → choose nozzle" UI; null on every model except O1C2
  236. # uploads from BambuStudio.
  237. nozzle_mapping: list[int] | None = None
  238. class Config:
  239. from_attributes = True
  240. class PrintQueueReorderItem(BaseModel):
  241. id: int
  242. position: int
  243. class PrintQueueReorder(BaseModel):
  244. items: list[PrintQueueReorderItem]
  245. @model_validator(mode="after")
  246. def _validate_positions_unique(self) -> "PrintQueueReorder":
  247. """Reject reorder requests with duplicate positions in the payload
  248. (#1625-followup).
  249. The /reorder route is the drag-drop renumber path on the queue UI;
  250. a well-behaved client sends a contiguous renumbering of a single
  251. printer's pending queue. A buggy client that sends two items at
  252. the same position would leave the queue in an inconsistent state
  253. (scheduler's ORDER BY (printer_id, position) ties get broken by
  254. physical row order). Fail closed at the schema boundary so the
  255. bug is caught before any DB mutation.
  256. Uniqueness is enforced WITHIN THE PAYLOAD only — cross-printer
  257. reorders that intentionally share positions across different
  258. printer queues are a non-goal of the drag-drop UI, so this is the
  259. right scope.
  260. """
  261. positions = [it.position for it in self.items]
  262. if len(positions) != len(set(positions)):
  263. duplicates = sorted({p for p in positions if positions.count(p) > 1})
  264. raise ValueError(f"Duplicate positions in reorder request: {duplicates}")
  265. return self
  266. class PrintQueueBulkUpdate(BaseModel):
  267. """Bulk update multiple queue items with the same values."""
  268. item_ids: list[int]
  269. # Fields to update (all optional - only set fields are applied)
  270. printer_id: int | None = None
  271. scheduled_time: datetime | None = None
  272. require_previous_success: bool | None = None
  273. auto_off_after: bool | None = None
  274. manual_start: bool | None = None
  275. # Print options
  276. bed_levelling: TriState | None = None
  277. flow_cali: TriState | None = None
  278. vibration_cali: bool | None = None
  279. layer_inspect: bool | None = None
  280. timelapse: bool | None = None
  281. use_ams: bool | None = None
  282. nozzle_offset_cali: TriState | None = None
  283. preheat_override: Literal["inherit", "on", "off"] | None = None
  284. preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
  285. # Auto-print G-code injection
  286. gcode_injection: bool | None = None
  287. cost_center_id: int | None = None
  288. estimated_cost: float | None = None
  289. class PrintQueueBulkUpdateResponse(BaseModel):
  290. """Response for bulk update operation."""
  291. updated_count: int
  292. skipped_count: int # Items that were not pending
  293. message: str
  294. class PrintBatchPlateTarget(BaseModel):
  295. """How many runs of one plate an order wants (#342).
  296. ``plate_id`` is the plate index inside the source 3MF, or null for a
  297. single-plate file — matching ``PrintQueueItem.plate_id``. A target of 0 is
  298. legal and means "this plate is not required (yet)".
  299. """
  300. plate_id: int | None = None
  301. plate_name: str | None = None
  302. quantity_target: int = Field(default=1, ge=0, le=999)
  303. sort_order: int = 0
  304. class PrintBatchCreate(BaseModel):
  305. """Create a batch, either empty (multi-plate pre-batch flow) or by
  306. assigning existing pending queue items into it (manual "Group as batch")."""
  307. name: str
  308. archive_id: int | None = None
  309. library_file_id: int | None = None
  310. # Existing pending queue items to assign to this batch. None / empty for
  311. # the empty-batch flow (client passes the returned id on subsequent
  312. # addToQueue calls).
  313. item_ids: list[int] | None = None
  314. # Per-plate targets. Omitted entirely by the pre-#342 flows, which produce
  315. # a batch that reports progress but owes nothing.
  316. plates: list[PrintBatchPlateTarget] | None = None
  317. # Planning metadata. Projects own the heavier fields (BOM, attachments,
  318. # tags); these two are the ones that are useless without a Project to
  319. # hang them on, so the order carries them directly.
  320. project_id: int | None = None
  321. due_date: datetime | None = None
  322. notes: str | None = None
  323. class PrintBatchUpdate(BaseModel):
  324. """Edit an order's header or its per-plate targets while it runs.
  325. Every field is optional; ``plates`` replaces the full target set when
  326. given, so a plate omitted from the list has its target row removed.
  327. """
  328. name: str | None = None
  329. status: Literal["active", "cancelled"] | None = None
  330. plates: list[PrintBatchPlateTarget] | None = None
  331. project_id: int | None = None
  332. due_date: datetime | None = None
  333. notes: str | None = None
  334. class PrintBatchDispatchRequest(BaseModel):
  335. """Create queue items for the runs an order still owes."""
  336. # Restrict to one plate. Null is a legitimate plate_id (single-plate file),
  337. # so the caller opts in explicitly rather than us inferring from null.
  338. plate_id: int | None = None
  339. only_plate: bool = False
  340. # Cap on how many items to create across all plates. None = everything owed.
  341. limit: int | None = Field(default=None, ge=1, le=999)
  342. class PrintBatchUngroupResponse(BaseModel):
  343. """Response after ungrouping a batch."""
  344. ungrouped_count: int
  345. message: str
  346. class PrintBatchPlateProgress(BaseModel):
  347. """Per-plate progress within a batch."""
  348. plate_id: int | None = None
  349. plate_name: str | None = None
  350. quantity_target: int = 0
  351. dispatched: int = 0
  352. remaining: int = 0
  353. pending_count: int = 0
  354. printing_count: int = 0
  355. completed_count: int = 0
  356. failed_count: int = 0
  357. cancelled_count: int = 0
  358. skipped_count: int = 0
  359. # Measured from finished runs, never estimated from the file. Null until
  360. # at least one run of this plate has produced a cost.
  361. actual_cost: float | None = None
  362. estimated_remaining_cost: float | None = None
  363. filament_used_grams: float | None = None
  364. print_time_seconds: int = 0
  365. class PrintBatchResponse(BaseModel):
  366. """Response for a print batch with progress stats."""
  367. id: int
  368. name: str
  369. archive_id: int | None = None
  370. library_file_id: int | None = None
  371. quantity: int
  372. status: str
  373. created_at: UTCDatetime
  374. completed_at: UTCDatetime | None = None
  375. created_by_id: int | None = None
  376. created_by_username: str | None = None
  377. project_id: int | None = None
  378. due_date: UTCDatetime | None = None
  379. notes: str | None = None
  380. # Derived counts
  381. pending_count: int = 0
  382. printing_count: int = 0
  383. completed_count: int = 0
  384. failed_count: int = 0
  385. cancelled_count: int = 0
  386. skipped_count: int = 0
  387. # Planning roll-up. has_targets is false for batches created before
  388. # per-plate targets existed: they report progress but owe nothing, and the
  389. # dispatch endpoint is a no-op for them.
  390. has_targets: bool = False
  391. target_count: int = 0
  392. remaining_count: int = 0
  393. actual_cost: float | None = None
  394. estimated_remaining_cost: float | None = None
  395. filament_used_grams: float | None = None
  396. print_time_seconds: int = 0
  397. plates: list[PrintBatchPlateProgress] = []
  398. class Config:
  399. from_attributes = True