webhook.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  1. import logging
  2. from fastapi import APIRouter, Depends, HTTPException
  3. from pydantic import BaseModel
  4. from sqlalchemy import select
  5. from sqlalchemy.ext.asyncio import AsyncSession
  6. from backend.app.core.auth import check_printer_access, check_webhook_permission, get_api_key
  7. from backend.app.core.database import get_db
  8. from backend.app.models.api_key import APIKey
  9. from backend.app.models.archive import PrintArchive
  10. from backend.app.models.print_queue import PrintQueueItem
  11. from backend.app.models.printer import Printer
  12. from backend.app.schemas.printer import HMSErrorResponse, hms_error_responses
  13. from backend.app.services.print_confirmation import confirm_outcome_for_new_queue_item
  14. from backend.app.services.printer_manager import printer_manager
  15. from backend.app.services.queue_position import next_queue_position
  16. logger = logging.getLogger(__name__)
  17. router = APIRouter(prefix="/webhook", tags=["webhook"])
  18. # Request schemas
  19. class QueueAddRequest(BaseModel):
  20. archive_id: int
  21. printer_id: int
  22. project_id: int | None = None
  23. scheduled_time: str | None = None # ISO format datetime
  24. require_previous_success: bool = False
  25. auto_off_after: bool = False
  26. class QueueAddResponse(BaseModel):
  27. id: int
  28. archive_id: int
  29. printer_id: int
  30. position: int
  31. status: str
  32. message: str
  33. class PrinterStatusResponse(BaseModel):
  34. id: int
  35. name: str
  36. # The printer's own serial, so a client can tell printers apart by what
  37. # they report rather than by Bambuddy's row id (#2919).
  38. serial_number: str
  39. connected: bool
  40. state: str | None
  41. current_print: str | None
  42. progress: float | None
  43. # Minutes, as the printer reports it. Kept for existing clients;
  44. # remaining_seconds is the same estimate in seconds, the unit the
  45. # notification pipeline uses (#2919).
  46. remaining_time: int | None
  47. remaining_seconds: int | None = None
  48. layer_num: int | None = None
  49. total_layers: int | None = None
  50. # Bambu's id for the running job. A new value marks a new print, even when
  51. # two prints of the same file run back to back between two polls. None
  52. # when the job has no id: Bambu reports "0" or "" for local prints (for
  53. # example one started on the printer), the same reading main.py uses.
  54. subtask_id: str | None = None
  55. # Live HMS faults, in the same shape as GET /printers/{id}/status. They
  56. # tell a filament runout apart from someone pressing pause.
  57. hms_errors: list[HMSErrorResponse] = []
  58. class QueueStatusResponse(BaseModel):
  59. printer_id: int
  60. printer_name: str
  61. pending: int
  62. printing: int
  63. items: list[dict]
  64. def _job_id(subtask_id) -> str | None:
  65. """The printer's job id as text, or None when the job has none.
  66. Stored as the printer sent it, so coerce: a numeric id would fail
  67. validation and turn a status poll into a 500.
  68. """
  69. if subtask_id is None:
  70. return None
  71. value = str(subtask_id).strip()
  72. return None if value in ("", "0") else value
  73. # Webhook endpoints
  74. @router.post("/queue/add", response_model=QueueAddResponse)
  75. async def webhook_add_to_queue(
  76. data: QueueAddRequest,
  77. api_key: APIKey = Depends(get_api_key),
  78. db: AsyncSession = Depends(get_db),
  79. ):
  80. """Add a print to the queue via webhook.
  81. Requires 'can_queue' permission.
  82. """
  83. await check_webhook_permission(db, api_key, "queue")
  84. check_printer_access(api_key, data.printer_id)
  85. # Verify archive exists
  86. result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
  87. archive = result.scalar_one_or_none()
  88. if not archive:
  89. raise HTTPException(status_code=404, detail="Archive not found")
  90. # Verify printer exists
  91. result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
  92. printer = result.scalar_one_or_none()
  93. if not printer:
  94. raise HTTPException(status_code=404, detail="Printer not found")
  95. # Append to the end of the queue: positions are one sequence across all
  96. # pending items, not one per printer (#3200).
  97. next_position = await next_queue_position(db)
  98. # Parse scheduled time if provided
  99. scheduled_time = None
  100. if data.scheduled_time:
  101. from datetime import datetime
  102. try:
  103. scheduled_time = datetime.fromisoformat(data.scheduled_time.replace("Z", "+00:00"))
  104. except ValueError:
  105. raise HTTPException(status_code=400, detail="Invalid scheduled_time format")
  106. # Create queue item
  107. queue_item = PrintQueueItem(
  108. printer_id=data.printer_id,
  109. archive_id=data.archive_id,
  110. project_id=data.project_id,
  111. position=next_position,
  112. scheduled_time=scheduled_time,
  113. require_previous_success=data.require_previous_success,
  114. auto_off_after=data.auto_off_after,
  115. # No dialog to pick this per job, so the install-wide default decides
  116. # whether the finished print asks for a verdict (#1898).
  117. confirm_outcome=await confirm_outcome_for_new_queue_item(db),
  118. # Attribute to the key's owner so the item shows up under `queue:read_own`
  119. # for the person whose key it is. Legacy keys predating per-user ownership
  120. # have no `user_id`, and those rows stay ownerless.
  121. created_by_id=api_key.user_id,
  122. )
  123. db.add(queue_item)
  124. await db.flush()
  125. await db.refresh(queue_item)
  126. return QueueAddResponse(
  127. id=queue_item.id,
  128. archive_id=queue_item.archive_id,
  129. printer_id=queue_item.printer_id,
  130. position=queue_item.position,
  131. status=queue_item.status,
  132. message=f"Added to queue at position {queue_item.position}",
  133. )
  134. @router.post("/printer/{printer_id}/start")
  135. async def webhook_start_print(
  136. printer_id: int,
  137. api_key: APIKey = Depends(get_api_key),
  138. db: AsyncSession = Depends(get_db),
  139. ):
  140. """Trigger the next manual-start queue item on a printer.
  141. Mirrors `POST /print-queue/{item_id}/start`: clears `manual_start` on
  142. the next pending item so the scheduler picks it up — which handles
  143. FTP upload, AMS mapping, and all print options (timelapse,
  144. bed_levelling, etc.) correctly via the queue's stored fields. The
  145. previous implementation called `printer_manager.start_print()`
  146. directly with `archive_id` as the filename arg and no print options,
  147. bypassing the upload step entirely and discarding the user's
  148. workflow choices — it 500'd before ever reaching the printer.
  149. Requires 'can_control_printer' permission.
  150. """
  151. await check_webhook_permission(db, api_key, "control_printer")
  152. check_printer_access(api_key, printer_id)
  153. # Get printer
  154. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  155. printer = result.scalar_one_or_none()
  156. if not printer:
  157. raise HTTPException(status_code=404, detail="Printer not found")
  158. # Get next pending queue item
  159. result = await db.execute(
  160. select(PrintQueueItem)
  161. .where(
  162. PrintQueueItem.printer_id == printer_id,
  163. PrintQueueItem.status == "pending",
  164. )
  165. .order_by(PrintQueueItem.position)
  166. .limit(1)
  167. )
  168. queue_item = result.scalar_one_or_none()
  169. if not queue_item:
  170. raise HTTPException(status_code=404, detail="No pending prints in queue")
  171. # Clear manual_start so the scheduler will dispatch. If the item was
  172. # already auto-dispatchable this is a no-op; the scheduler will still
  173. # pick it up on its next tick.
  174. queue_item.manual_start = False
  175. await db.commit()
  176. await db.refresh(queue_item)
  177. logger.info("Webhook started queue item %s on printer %s", queue_item.id, printer_id)
  178. return {"message": "Print started", "queue_item_id": queue_item.id}
  179. @router.post("/printer/{printer_id}/stop")
  180. async def webhook_stop_print(
  181. printer_id: int,
  182. api_key: APIKey = Depends(get_api_key),
  183. db: AsyncSession = Depends(get_db),
  184. ):
  185. """Stop the current print on a printer.
  186. Requires 'can_control_printer' permission.
  187. """
  188. await check_webhook_permission(db, api_key, "control_printer")
  189. check_printer_access(api_key, printer_id)
  190. status = printer_manager.get_status(printer_id)
  191. # `printer_manager.get_status(...)` returns a ``PrinterState`` dataclass
  192. # (see backend/app/services/bambu_mqtt.py), not a dict — `.get(...)` on it
  193. # raises AttributeError and surfaces as a generic 500 (#1584).
  194. if not status or not status.connected:
  195. raise HTTPException(status_code=503, detail="Printer not connected")
  196. if status.state != "RUNNING":
  197. raise HTTPException(status_code=409, detail="No print in progress")
  198. try:
  199. await printer_manager.stop_print(printer_id)
  200. except Exception as e:
  201. logger.error("Failed to stop print: %s", e)
  202. raise HTTPException(status_code=500, detail=str(e))
  203. return {"message": "Print stopped"}
  204. @router.post("/printer/{printer_id}/cancel")
  205. async def webhook_cancel_print(
  206. printer_id: int,
  207. api_key: APIKey = Depends(get_api_key),
  208. db: AsyncSession = Depends(get_db),
  209. ):
  210. """Cancel the current print on a printer.
  211. Requires 'can_control_printer' permission.
  212. """
  213. await check_webhook_permission(db, api_key, "control_printer")
  214. check_printer_access(api_key, printer_id)
  215. status = printer_manager.get_status(printer_id)
  216. # Same dataclass-not-dict shape as stop_print above (#1584).
  217. if not status or not status.connected:
  218. raise HTTPException(status_code=503, detail="Printer not connected")
  219. if status.state not in ["RUNNING", "PAUSE"]:
  220. raise HTTPException(status_code=409, detail="No print to cancel")
  221. try:
  222. await printer_manager.cancel_print(printer_id)
  223. except Exception as e:
  224. logger.error("Failed to cancel print: %s", e)
  225. raise HTTPException(status_code=500, detail=str(e))
  226. return {"message": "Print cancelled"}
  227. @router.get("/printer/{printer_id}/status", response_model=PrinterStatusResponse)
  228. async def webhook_get_printer_status(
  229. printer_id: int,
  230. api_key: APIKey = Depends(get_api_key),
  231. db: AsyncSession = Depends(get_db),
  232. ):
  233. """Get status of a printer.
  234. Requires 'can_read_status' permission.
  235. """
  236. await check_webhook_permission(db, api_key, "read_status")
  237. check_printer_access(api_key, printer_id)
  238. # Get printer
  239. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  240. printer = result.scalar_one_or_none()
  241. if not printer:
  242. raise HTTPException(status_code=404, detail="Printer not found")
  243. status = printer_manager.get_status(printer_id)
  244. # `printer_manager.get_status(...)` returns a ``PrinterState`` dataclass —
  245. # attribute access, not dict lookup. The previous `.get(...)` calls raised
  246. # AttributeError and surfaced as a generic 500 for any printer that
  247. # actually had a status row (#1584).
  248. if status is None:
  249. return PrinterStatusResponse(
  250. id=printer.id,
  251. name=printer.name,
  252. serial_number=printer.serial_number,
  253. connected=False,
  254. state=None,
  255. current_print=None,
  256. progress=None,
  257. remaining_time=None,
  258. )
  259. return PrinterStatusResponse(
  260. id=printer.id,
  261. name=printer.name,
  262. serial_number=printer.serial_number,
  263. connected=status.connected,
  264. state=status.state,
  265. current_print=status.current_print,
  266. progress=status.progress,
  267. remaining_time=status.remaining_time,
  268. remaining_seconds=status.remaining_time * 60 if status.remaining_time is not None else None,
  269. layer_num=status.layer_num,
  270. total_layers=status.total_layers,
  271. subtask_id=_job_id(status.subtask_id),
  272. hms_errors=hms_error_responses(status.hms_errors),
  273. )
  274. @router.get("/queue", response_model=list[QueueStatusResponse])
  275. async def webhook_get_queue_status(
  276. printer_id: int | None = None,
  277. api_key: APIKey = Depends(get_api_key),
  278. db: AsyncSession = Depends(get_db),
  279. ):
  280. """Get queue status for all printers or a specific printer.
  281. Requires 'can_read_status' permission.
  282. """
  283. await check_webhook_permission(db, api_key, "read_status")
  284. # Get printers
  285. if printer_id:
  286. check_printer_access(api_key, printer_id)
  287. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  288. printers = result.scalars().all()
  289. else:
  290. result = await db.execute(select(Printer))
  291. printers = result.scalars().all()
  292. # Filter by allowed printers if limited
  293. if api_key.printer_ids is not None:
  294. printers = [p for p in printers if p.id in api_key.printer_ids]
  295. response = []
  296. for printer in printers:
  297. # Get queue items
  298. result = await db.execute(
  299. select(PrintQueueItem)
  300. .where(
  301. PrintQueueItem.printer_id == printer.id,
  302. PrintQueueItem.status.in_(["pending", "printing"]),
  303. )
  304. .order_by(PrintQueueItem.position)
  305. )
  306. items = result.scalars().all()
  307. pending_count = sum(1 for i in items if i.status == "pending")
  308. printing_count = sum(1 for i in items if i.status == "printing")
  309. response.append(
  310. QueueStatusResponse(
  311. printer_id=printer.id,
  312. printer_name=printer.name,
  313. pending=pending_count,
  314. printing=printing_count,
  315. items=[
  316. {
  317. "id": item.id,
  318. "archive_id": item.archive_id,
  319. "position": item.position,
  320. "status": item.status,
  321. }
  322. for item in items
  323. ],
  324. )
  325. )
  326. return response