print_queue.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  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 PrintQueueItemResponse(BaseModel):
  137. id: int
  138. printer_id: int | None # None = unassigned
  139. target_model: str | None = None # Target printer model for model-based assignment
  140. target_location: str | None = None # Target location filter for model-based assignment
  141. required_filament_types: list[str] | None = None # Required filament types for model-based assignment
  142. filament_overrides: list[dict] | None = None # Filament overrides for model-based assignment
  143. waiting_reason: str | None = None # Why a model-based job hasn't started yet
  144. archive_id: int | None # None if library_file_id is set (archive created at print start)
  145. library_file_id: int | None # For queue items from library files
  146. position: int
  147. scheduled_time: UTCDatetime
  148. require_previous_success: bool
  149. auto_off_after: bool
  150. manual_start: bool
  151. # True when the dispatch scheduler last evaluated this item and the
  152. # assigned spool could not satisfy at least one slot's required grams
  153. # (#1496). Display-only — the ▶ click recomputes deficit against live
  154. # spool state.
  155. filament_short: bool = False
  156. # User has acknowledged "Print Anyway" — scheduler skips the deficit check
  157. # for this item (#1698-followup).
  158. skip_filament_check: bool = False
  159. ams_mapping: list[int] | None = None
  160. plate_id: int | None = None # Plate ID for multi-plate 3MF files
  161. # Print options
  162. bed_levelling: TriState = "auto"
  163. flow_cali: TriState = "auto"
  164. vibration_cali: bool = True
  165. layer_inspect: bool = False
  166. timelapse: bool = False
  167. use_ams: bool = True
  168. nozzle_offset_cali: TriState = "auto"
  169. preheat_override: Literal["inherit", "on", "off"] = "inherit"
  170. preheat_chamber_target_override: int | None = None
  171. status: Literal["pending", "printing", "completed", "failed", "skipped", "cancelled"]
  172. started_at: UTCDatetime
  173. completed_at: UTCDatetime
  174. error_message: str | None
  175. created_at: UTCDatetime
  176. # Nested info for UI (populated in route)
  177. archive_name: str | None = None
  178. archive_thumbnail: str | None = None
  179. # True when the linked archive has been soft-deleted (its files are gone
  180. # from disk). In that case the *archive_name* / *archive_thumbnail* /
  181. # downstream metadata fields are intentionally left None so the frontend
  182. # doesn't 404-storm the now-missing thumbnail / plates / plate-thumbnail
  183. # endpoints (#1348 follow-up). Frontends can render a "source deleted"
  184. # badge based on this flag.
  185. archive_deleted: bool = False
  186. library_file_name: str | None = None # Name of library file (if library_file_id is set)
  187. library_file_thumbnail: str | None = None # Thumbnail of library file
  188. printer_name: str | None = None
  189. print_time_seconds: int | None = None # Estimated print time from archive or library file
  190. filament_used_grams: float | None = None # Estimated print weight from archive or library file
  191. filament_type: str | None = None # e.g. "PLA", "PETG" (from archive/library file)
  192. filament_color: str | None = None # e.g. "#FFFFFF" (from archive/library file)
  193. layer_height: float | None = None # e.g. 0.2 (from archive/library file)
  194. nozzle_diameter: float | None = None # e.g. 0.4 (from archive/library file)
  195. sliced_for_model: str | None = None # e.g. "P1S" (from archive/library file)
  196. # Build plate type (e.g. "Textured PEI Plate") so the user knows which
  197. # plate to mount on the printer (#1281). Per-plate accurate on multi-plate
  198. # 3MFs: when `plate_id` is set, the value is the matching plate's
  199. # `curr_bed_type` rather than the archive-level first-plate default.
  200. bed_type: str | None = None
  201. # True when the source archive carries the slicer's own live-resolved
  202. # AMS-slot pick (extra_data.slicer_ams_mapping) *and* it was resolved
  203. # against this row's own printer — the only case where dispatch actually
  204. # reuses that exact physical spool instead of the scheduler re-deriving one
  205. # from the file's static type/color.
  206. archive_has_slicer_ams_mapping: bool = False
  207. # User tracking (Issue #206)
  208. created_by_id: int | None = None
  209. created_by_username: str | None = None
  210. # Batch grouping
  211. batch_id: int | None = None
  212. batch_name: str | None = None
  213. # Shortest-job-first scheduling
  214. been_jumped: bool = False
  215. # Auto-print G-code injection
  216. gcode_injection: bool = False
  217. cleanup_library_after_dispatch: bool = False
  218. # H2C dual-nozzle-rack slicer pick (#1780). Surface for any future
  219. # "edit print → choose nozzle" UI; null on every model except O1C2
  220. # uploads from BambuStudio.
  221. nozzle_mapping: list[int] | None = None
  222. class Config:
  223. from_attributes = True
  224. class PrintQueueReorderItem(BaseModel):
  225. id: int
  226. position: int
  227. class PrintQueueReorder(BaseModel):
  228. items: list[PrintQueueReorderItem]
  229. @model_validator(mode="after")
  230. def _validate_positions_unique(self) -> "PrintQueueReorder":
  231. """Reject reorder requests with duplicate positions in the payload
  232. (#1625-followup).
  233. The /reorder route is the drag-drop renumber path on the queue UI;
  234. a well-behaved client sends a contiguous renumbering of a single
  235. printer's pending queue. A buggy client that sends two items at
  236. the same position would leave the queue in an inconsistent state
  237. (scheduler's ORDER BY (printer_id, position) ties get broken by
  238. physical row order). Fail closed at the schema boundary so the
  239. bug is caught before any DB mutation.
  240. Uniqueness is enforced WITHIN THE PAYLOAD only — cross-printer
  241. reorders that intentionally share positions across different
  242. printer queues are a non-goal of the drag-drop UI, so this is the
  243. right scope.
  244. """
  245. positions = [it.position for it in self.items]
  246. if len(positions) != len(set(positions)):
  247. duplicates = sorted({p for p in positions if positions.count(p) > 1})
  248. raise ValueError(f"Duplicate positions in reorder request: {duplicates}")
  249. return self
  250. class PrintQueueBulkUpdate(BaseModel):
  251. """Bulk update multiple queue items with the same values."""
  252. item_ids: list[int]
  253. # Fields to update (all optional - only set fields are applied)
  254. printer_id: int | None = None
  255. scheduled_time: datetime | None = None
  256. require_previous_success: bool | None = None
  257. auto_off_after: bool | None = None
  258. manual_start: bool | None = None
  259. # Print options
  260. bed_levelling: TriState | None = None
  261. flow_cali: TriState | None = None
  262. vibration_cali: bool | None = None
  263. layer_inspect: bool | None = None
  264. timelapse: bool | None = None
  265. use_ams: bool | None = None
  266. nozzle_offset_cali: TriState | None = None
  267. preheat_override: Literal["inherit", "on", "off"] | None = None
  268. preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
  269. # Auto-print G-code injection
  270. gcode_injection: bool | None = None
  271. class PrintQueueBulkUpdateResponse(BaseModel):
  272. """Response for bulk update operation."""
  273. updated_count: int
  274. skipped_count: int # Items that were not pending
  275. message: str
  276. class PrintBatchCreate(BaseModel):
  277. """Create a batch, either empty (multi-plate pre-batch flow) or by
  278. assigning existing pending queue items into it (manual "Group as batch")."""
  279. name: str
  280. archive_id: int | None = None
  281. library_file_id: int | None = None
  282. # Existing pending queue items to assign to this batch. None / empty for
  283. # the empty-batch flow (client passes the returned id on subsequent
  284. # addToQueue calls).
  285. item_ids: list[int] | None = None
  286. class PrintBatchUngroupResponse(BaseModel):
  287. """Response after ungrouping a batch."""
  288. ungrouped_count: int
  289. message: str
  290. class PrintBatchResponse(BaseModel):
  291. """Response for a print batch with progress stats."""
  292. id: int
  293. name: str
  294. archive_id: int | None = None
  295. library_file_id: int | None = None
  296. quantity: int
  297. status: str
  298. created_at: UTCDatetime
  299. created_by_id: int | None = None
  300. created_by_username: str | None = None
  301. # Derived counts
  302. pending_count: int = 0
  303. printing_count: int = 0
  304. completed_count: int = 0
  305. failed_count: int = 0
  306. cancelled_count: int = 0
  307. class Config:
  308. from_attributes = True