print_queue.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  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 PrintQueueItemCreate(BaseModel):
  35. printer_id: int | None = None # None = unassigned, user assigns later
  36. target_model: str | None = None # Target printer model (mutually exclusive with printer_id)
  37. target_location: str | None = None # Target location filter (only used with target_model)
  38. required_filament_types: list[str] | None = None # Required filament types for model-based assignment
  39. filament_overrides: list[dict] | None = None # Filament overrides for model-based assignment
  40. # Either archive_id OR library_file_id must be provided
  41. archive_id: int | None = None
  42. library_file_id: int | None = None
  43. scheduled_time: datetime | None = None # None = ASAP (next when idle)
  44. require_previous_success: bool = False
  45. auto_off_after: bool = False # Power off printer after print completes
  46. manual_start: bool = False # Requires manual trigger to start (staged)
  47. insert_at_top: bool = False # Insert ahead of other pending items in the same queue scope
  48. insert_position: int | None = None # 1-indexed insertion position for priority queueing
  49. # Persistent "Print Anyway" acknowledgement (#1698-followup). When set,
  50. # PrintModal already showed the deficit warning and the user confirmed,
  51. # so the scheduler does not re-flag this item on the next tick.
  52. skip_filament_check: bool = False
  53. # AMS mapping: list of global tray IDs for each filament slot
  54. # Format: [5, -1, 2, -1] where position = slot_id-1, value = global tray ID (-1 = unused)
  55. ams_mapping: list[int] | None = None
  56. # Plate ID for multi-plate 3MF files (1-indexed, None = auto-detect/plate 1)
  57. plate_id: int | None = None
  58. # Print options. bed_levelling / flow_cali / nozzle_offset_cali are tri-state
  59. # (off/on/auto), defaulting to "auto" to match BambuStudio. vibration_cali /
  60. # layer_inspect / timelapse stay on/off (BambuStudio exposes no auto for them).
  61. bed_levelling: TriState = "auto"
  62. flow_cali: TriState = "auto"
  63. vibration_cali: bool = True
  64. layer_inspect: bool = False
  65. timelapse: bool = False
  66. use_ams: bool = True
  67. # Nozzle offset calibration — dual-nozzle printers only (#1682). The MQTT
  68. # layer ignores the value on single-nozzle printers so the wire stays "skip".
  69. nozzle_offset_cali: TriState = "auto"
  70. # Preheat / heat-soak per-item override (#1468). 'inherit' uses the global
  71. # preheat_enabled setting; 'on' / 'off' force the decision. The chamber
  72. # target falls through: this override → max(filament-map[loaded tray]) → 0.
  73. preheat_override: Literal["inherit", "on", "off"] = "inherit"
  74. preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
  75. # Auto-print G-code injection
  76. gcode_injection: bool = False
  77. # Batch: create multiple copies (creates a batch if > 1)
  78. quantity: int = 1
  79. # Existing batch to add this item into. When set, the item's batch_id is
  80. # populated on insert so the queue UI groups it with its siblings. Used by
  81. # the multi-plate auto-batch flow and by the "Group as batch" action.
  82. batch_id: int | None = None
  83. # Project to associate the resulting archive with
  84. project_id: int | None = None
  85. # Direct printer-card uploads are temporary library files. The scheduler
  86. # deletes them after creating the durable archive copy.
  87. cleanup_library_after_dispatch: bool = False
  88. class PrintQueueItemUpdate(BaseModel):
  89. printer_id: int | None = None
  90. target_model: str | None = None # Target printer model (mutually exclusive with printer_id)
  91. target_location: str | None = None # Target location filter (only used with target_model)
  92. filament_overrides: list[dict] | None = None # Filament overrides for model-based assignment
  93. position: int | None = None
  94. scheduled_time: datetime | None = None
  95. require_previous_success: bool | None = None
  96. auto_off_after: bool | None = None
  97. manual_start: bool | None = None
  98. ams_mapping: list[int] | None = None
  99. plate_id: int | None = None
  100. # Print options
  101. bed_levelling: TriState | None = None
  102. flow_cali: TriState | None = None
  103. vibration_cali: bool | None = None
  104. layer_inspect: bool | None = None
  105. timelapse: bool | None = None
  106. use_ams: bool | None = None
  107. nozzle_offset_cali: TriState | None = None
  108. preheat_override: Literal["inherit", "on", "off"] | None = None
  109. preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
  110. # Auto-print G-code injection
  111. gcode_injection: bool | None = None
  112. # H2C dual-nozzle-rack slicer pick (#1780). list[int] per-filament
  113. # physical nozzle position IDs from BambuStudio's project_file MQTT
  114. # body; sent back to the printer verbatim on dispatch.
  115. nozzle_mapping: list[int] | None = None
  116. class PrintQueueItemResponse(BaseModel):
  117. id: int
  118. printer_id: int | None # None = unassigned
  119. target_model: str | None = None # Target printer model for model-based assignment
  120. target_location: str | None = None # Target location filter for model-based assignment
  121. required_filament_types: list[str] | None = None # Required filament types for model-based assignment
  122. filament_overrides: list[dict] | None = None # Filament overrides for model-based assignment
  123. waiting_reason: str | None = None # Why a model-based job hasn't started yet
  124. archive_id: int | None # None if library_file_id is set (archive created at print start)
  125. library_file_id: int | None # For queue items from library files
  126. position: int
  127. scheduled_time: UTCDatetime
  128. require_previous_success: bool
  129. auto_off_after: bool
  130. manual_start: bool
  131. # True when the dispatch scheduler last evaluated this item and the
  132. # assigned spool could not satisfy at least one slot's required grams
  133. # (#1496). Display-only — the ▶ click recomputes deficit against live
  134. # spool state.
  135. filament_short: bool = False
  136. # User has acknowledged "Print Anyway" — scheduler skips the deficit check
  137. # for this item (#1698-followup).
  138. skip_filament_check: bool = False
  139. ams_mapping: list[int] | None = None
  140. plate_id: int | None = None # Plate ID for multi-plate 3MF files
  141. # Print options
  142. bed_levelling: TriState = "auto"
  143. flow_cali: TriState = "auto"
  144. vibration_cali: bool = True
  145. layer_inspect: bool = False
  146. timelapse: bool = False
  147. use_ams: bool = True
  148. nozzle_offset_cali: TriState = "auto"
  149. preheat_override: Literal["inherit", "on", "off"] = "inherit"
  150. preheat_chamber_target_override: int | None = None
  151. status: Literal["pending", "printing", "completed", "failed", "skipped", "cancelled"]
  152. started_at: UTCDatetime
  153. completed_at: UTCDatetime
  154. error_message: str | None
  155. created_at: UTCDatetime
  156. # Nested info for UI (populated in route)
  157. archive_name: str | None = None
  158. archive_thumbnail: str | None = None
  159. # True when the linked archive has been soft-deleted (its files are gone
  160. # from disk). In that case the *archive_name* / *archive_thumbnail* /
  161. # downstream metadata fields are intentionally left None so the frontend
  162. # doesn't 404-storm the now-missing thumbnail / plates / plate-thumbnail
  163. # endpoints (#1348 follow-up). Frontends can render a "source deleted"
  164. # badge based on this flag.
  165. archive_deleted: bool = False
  166. library_file_name: str | None = None # Name of library file (if library_file_id is set)
  167. library_file_thumbnail: str | None = None # Thumbnail of library file
  168. printer_name: str | None = None
  169. print_time_seconds: int | None = None # Estimated print time from archive or library file
  170. filament_used_grams: float | None = None # Estimated print weight from archive or library file
  171. filament_type: str | None = None # e.g. "PLA", "PETG" (from archive/library file)
  172. filament_color: str | None = None # e.g. "#FFFFFF" (from archive/library file)
  173. layer_height: float | None = None # e.g. 0.2 (from archive/library file)
  174. nozzle_diameter: float | None = None # e.g. 0.4 (from archive/library file)
  175. sliced_for_model: str | None = None # e.g. "P1S" (from archive/library file)
  176. # Build plate type (e.g. "Textured PEI Plate") so the user knows which
  177. # plate to mount on the printer (#1281). Per-plate accurate on multi-plate
  178. # 3MFs: when `plate_id` is set, the value is the matching plate's
  179. # `curr_bed_type` rather than the archive-level first-plate default.
  180. bed_type: str | None = None
  181. # True when the source archive carries the slicer's own live-resolved
  182. # AMS-slot pick (extra_data.slicer_ams_mapping) *and* it was resolved
  183. # against this row's own printer — the only case where dispatch actually
  184. # reuses that exact physical spool instead of the scheduler re-deriving one
  185. # from the file's static type/color.
  186. archive_has_slicer_ams_mapping: bool = False
  187. # User tracking (Issue #206)
  188. created_by_id: int | None = None
  189. created_by_username: str | None = None
  190. # Batch grouping
  191. batch_id: int | None = None
  192. batch_name: str | None = None
  193. # Shortest-job-first scheduling
  194. been_jumped: bool = False
  195. # Auto-print G-code injection
  196. gcode_injection: bool = False
  197. cleanup_library_after_dispatch: bool = False
  198. # H2C dual-nozzle-rack slicer pick (#1780). Surface for any future
  199. # "edit print → choose nozzle" UI; null on every model except O1C2
  200. # uploads from BambuStudio.
  201. nozzle_mapping: list[int] | None = None
  202. class Config:
  203. from_attributes = True
  204. class PrintQueueReorderItem(BaseModel):
  205. id: int
  206. position: int
  207. class PrintQueueReorder(BaseModel):
  208. items: list[PrintQueueReorderItem]
  209. @model_validator(mode="after")
  210. def _validate_positions_unique(self) -> "PrintQueueReorder":
  211. """Reject reorder requests with duplicate positions in the payload
  212. (#1625-followup).
  213. The /reorder route is the drag-drop renumber path on the queue UI;
  214. a well-behaved client sends a contiguous renumbering of a single
  215. printer's pending queue. A buggy client that sends two items at
  216. the same position would leave the queue in an inconsistent state
  217. (scheduler's ORDER BY (printer_id, position) ties get broken by
  218. physical row order). Fail closed at the schema boundary so the
  219. bug is caught before any DB mutation.
  220. Uniqueness is enforced WITHIN THE PAYLOAD only — cross-printer
  221. reorders that intentionally share positions across different
  222. printer queues are a non-goal of the drag-drop UI, so this is the
  223. right scope.
  224. """
  225. positions = [it.position for it in self.items]
  226. if len(positions) != len(set(positions)):
  227. duplicates = sorted({p for p in positions if positions.count(p) > 1})
  228. raise ValueError(f"Duplicate positions in reorder request: {duplicates}")
  229. return self
  230. class PrintQueueBulkUpdate(BaseModel):
  231. """Bulk update multiple queue items with the same values."""
  232. item_ids: list[int]
  233. # Fields to update (all optional - only set fields are applied)
  234. printer_id: int | None = None
  235. scheduled_time: datetime | None = None
  236. require_previous_success: bool | None = None
  237. auto_off_after: bool | None = None
  238. manual_start: bool | None = None
  239. # Print options
  240. bed_levelling: TriState | None = None
  241. flow_cali: TriState | None = None
  242. vibration_cali: bool | None = None
  243. layer_inspect: bool | None = None
  244. timelapse: bool | None = None
  245. use_ams: bool | None = None
  246. nozzle_offset_cali: TriState | None = None
  247. preheat_override: Literal["inherit", "on", "off"] | None = None
  248. preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
  249. # Auto-print G-code injection
  250. gcode_injection: bool | None = None
  251. class PrintQueueBulkUpdateResponse(BaseModel):
  252. """Response for bulk update operation."""
  253. updated_count: int
  254. skipped_count: int # Items that were not pending
  255. message: str
  256. class PrintBatchCreate(BaseModel):
  257. """Create a batch, either empty (multi-plate pre-batch flow) or by
  258. assigning existing pending queue items into it (manual "Group as batch")."""
  259. name: str
  260. archive_id: int | None = None
  261. library_file_id: int | None = None
  262. # Existing pending queue items to assign to this batch. None / empty for
  263. # the empty-batch flow (client passes the returned id on subsequent
  264. # addToQueue calls).
  265. item_ids: list[int] | None = None
  266. class PrintBatchUngroupResponse(BaseModel):
  267. """Response after ungrouping a batch."""
  268. ungrouped_count: int
  269. message: str
  270. class PrintBatchResponse(BaseModel):
  271. """Response for a print batch with progress stats."""
  272. id: int
  273. name: str
  274. archive_id: int | None = None
  275. library_file_id: int | None = None
  276. quantity: int
  277. status: str
  278. created_at: UTCDatetime
  279. created_by_id: int | None = None
  280. created_by_username: str | None = None
  281. # Derived counts
  282. pending_count: int = 0
  283. printing_count: int = 0
  284. completed_count: int = 0
  285. failed_count: int = 0
  286. cancelled_count: int = 0
  287. class Config:
  288. from_attributes = True