print_queue.py 14 KB

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