webhook.py 14 KB

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