mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-01 03:31:25 +02:00
390 lines
14 KiB
Python
390 lines
14 KiB
Python
import logging
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from backend.app.core.auth import check_printer_access, check_webhook_permission, get_api_key
|
|
from backend.app.core.database import get_db
|
|
from backend.app.models.api_key import APIKey
|
|
from backend.app.models.archive import PrintArchive
|
|
from backend.app.models.print_queue import PrintQueueItem
|
|
from backend.app.models.printer import Printer
|
|
from backend.app.schemas.printer import HMSErrorResponse, hms_error_responses
|
|
from backend.app.services.print_confirmation import confirm_outcome_for_new_queue_item
|
|
from backend.app.services.printer_manager import printer_manager
|
|
from backend.app.services.queue_position import next_queue_position
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/webhook", tags=["webhook"])
|
|
|
|
|
|
# Request schemas
|
|
class QueueAddRequest(BaseModel):
|
|
archive_id: int
|
|
printer_id: int
|
|
project_id: int | None = None
|
|
scheduled_time: str | None = None # ISO format datetime
|
|
require_previous_success: bool = False
|
|
auto_off_after: bool = False
|
|
|
|
|
|
class QueueAddResponse(BaseModel):
|
|
id: int
|
|
archive_id: int
|
|
printer_id: int
|
|
position: int
|
|
status: str
|
|
message: str
|
|
|
|
|
|
class PrinterStatusResponse(BaseModel):
|
|
id: int
|
|
name: str
|
|
# The printer's own serial, so a client can tell printers apart by what
|
|
# they report rather than by Bambuddy's row id (#2919).
|
|
serial_number: str
|
|
connected: bool
|
|
state: str | None
|
|
current_print: str | None
|
|
progress: float | None
|
|
# Minutes, as the printer reports it. Kept for existing clients;
|
|
# remaining_seconds is the same estimate in seconds, the unit the
|
|
# notification pipeline uses (#2919).
|
|
remaining_time: int | None
|
|
remaining_seconds: int | None = None
|
|
layer_num: int | None = None
|
|
total_layers: int | None = None
|
|
# Bambu's id for the running job. A new value marks a new print, even when
|
|
# two prints of the same file run back to back between two polls. None
|
|
# when the job has no id: Bambu reports "0" or "" for local prints (for
|
|
# example one started on the printer), the same reading main.py uses.
|
|
subtask_id: str | None = None
|
|
# Live HMS faults, in the same shape as GET /printers/{id}/status. They
|
|
# tell a filament runout apart from someone pressing pause.
|
|
hms_errors: list[HMSErrorResponse] = []
|
|
|
|
|
|
class QueueStatusResponse(BaseModel):
|
|
printer_id: int
|
|
printer_name: str
|
|
pending: int
|
|
printing: int
|
|
items: list[dict]
|
|
|
|
|
|
def _job_id(subtask_id) -> str | None:
|
|
"""The printer's job id as text, or None when the job has none.
|
|
|
|
Stored as the printer sent it, so coerce: a numeric id would fail
|
|
validation and turn a status poll into a 500.
|
|
"""
|
|
if subtask_id is None:
|
|
return None
|
|
value = str(subtask_id).strip()
|
|
return None if value in ("", "0") else value
|
|
|
|
|
|
# Webhook endpoints
|
|
|
|
|
|
@router.post("/queue/add", response_model=QueueAddResponse)
|
|
async def webhook_add_to_queue(
|
|
data: QueueAddRequest,
|
|
api_key: APIKey = Depends(get_api_key),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Add a print to the queue via webhook.
|
|
|
|
Requires 'can_queue' permission.
|
|
"""
|
|
await check_webhook_permission(db, api_key, "queue")
|
|
check_printer_access(api_key, data.printer_id)
|
|
|
|
# Verify archive exists
|
|
result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
|
|
archive = result.scalar_one_or_none()
|
|
if not archive:
|
|
raise HTTPException(status_code=404, detail="Archive not found")
|
|
|
|
# Verify printer exists
|
|
result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
|
|
printer = result.scalar_one_or_none()
|
|
if not printer:
|
|
raise HTTPException(status_code=404, detail="Printer not found")
|
|
|
|
# Append to the end of the queue: positions are one sequence across all
|
|
# pending items, not one per printer (#3200).
|
|
next_position = await next_queue_position(db)
|
|
|
|
# Parse scheduled time if provided
|
|
scheduled_time = None
|
|
if data.scheduled_time:
|
|
from datetime import datetime
|
|
|
|
try:
|
|
scheduled_time = datetime.fromisoformat(data.scheduled_time.replace("Z", "+00:00"))
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="Invalid scheduled_time format")
|
|
|
|
# Create queue item
|
|
queue_item = PrintQueueItem(
|
|
printer_id=data.printer_id,
|
|
archive_id=data.archive_id,
|
|
project_id=data.project_id,
|
|
position=next_position,
|
|
scheduled_time=scheduled_time,
|
|
require_previous_success=data.require_previous_success,
|
|
auto_off_after=data.auto_off_after,
|
|
# No dialog to pick this per job, so the install-wide default decides
|
|
# whether the finished print asks for a verdict (#1898).
|
|
confirm_outcome=await confirm_outcome_for_new_queue_item(db),
|
|
# Attribute to the key's owner so the item shows up under `queue:read_own`
|
|
# for the person whose key it is. Legacy keys predating per-user ownership
|
|
# have no `user_id`, and those rows stay ownerless.
|
|
created_by_id=api_key.user_id,
|
|
)
|
|
db.add(queue_item)
|
|
await db.flush()
|
|
await db.refresh(queue_item)
|
|
|
|
return QueueAddResponse(
|
|
id=queue_item.id,
|
|
archive_id=queue_item.archive_id,
|
|
printer_id=queue_item.printer_id,
|
|
position=queue_item.position,
|
|
status=queue_item.status,
|
|
message=f"Added to queue at position {queue_item.position}",
|
|
)
|
|
|
|
|
|
@router.post("/printer/{printer_id}/start")
|
|
async def webhook_start_print(
|
|
printer_id: int,
|
|
api_key: APIKey = Depends(get_api_key),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Trigger the next manual-start queue item on a printer.
|
|
|
|
Mirrors `POST /print-queue/{item_id}/start`: clears `manual_start` on
|
|
the next pending item so the scheduler picks it up — which handles
|
|
FTP upload, AMS mapping, and all print options (timelapse,
|
|
bed_levelling, etc.) correctly via the queue's stored fields. The
|
|
previous implementation called `printer_manager.start_print()`
|
|
directly with `archive_id` as the filename arg and no print options,
|
|
bypassing the upload step entirely and discarding the user's
|
|
workflow choices — it 500'd before ever reaching the printer.
|
|
|
|
Requires 'can_control_printer' permission.
|
|
"""
|
|
await check_webhook_permission(db, api_key, "control_printer")
|
|
check_printer_access(api_key, printer_id)
|
|
|
|
# Get printer
|
|
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
printer = result.scalar_one_or_none()
|
|
if not printer:
|
|
raise HTTPException(status_code=404, detail="Printer not found")
|
|
|
|
# Get next pending queue item
|
|
result = await db.execute(
|
|
select(PrintQueueItem)
|
|
.where(
|
|
PrintQueueItem.printer_id == printer_id,
|
|
PrintQueueItem.status == "pending",
|
|
)
|
|
.order_by(PrintQueueItem.position)
|
|
.limit(1)
|
|
)
|
|
queue_item = result.scalar_one_or_none()
|
|
if not queue_item:
|
|
raise HTTPException(status_code=404, detail="No pending prints in queue")
|
|
|
|
# Clear manual_start so the scheduler will dispatch. If the item was
|
|
# already auto-dispatchable this is a no-op; the scheduler will still
|
|
# pick it up on its next tick.
|
|
queue_item.manual_start = False
|
|
await db.commit()
|
|
await db.refresh(queue_item)
|
|
|
|
logger.info("Webhook started queue item %s on printer %s", queue_item.id, printer_id)
|
|
return {"message": "Print started", "queue_item_id": queue_item.id}
|
|
|
|
|
|
@router.post("/printer/{printer_id}/stop")
|
|
async def webhook_stop_print(
|
|
printer_id: int,
|
|
api_key: APIKey = Depends(get_api_key),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Stop the current print on a printer.
|
|
|
|
Requires 'can_control_printer' permission.
|
|
"""
|
|
await check_webhook_permission(db, api_key, "control_printer")
|
|
check_printer_access(api_key, printer_id)
|
|
|
|
status = printer_manager.get_status(printer_id)
|
|
# `printer_manager.get_status(...)` returns a ``PrinterState`` dataclass
|
|
# (see backend/app/services/bambu_mqtt.py), not a dict — `.get(...)` on it
|
|
# raises AttributeError and surfaces as a generic 500 (#1584).
|
|
if not status or not status.connected:
|
|
raise HTTPException(status_code=503, detail="Printer not connected")
|
|
|
|
if status.state != "RUNNING":
|
|
raise HTTPException(status_code=409, detail="No print in progress")
|
|
|
|
try:
|
|
await printer_manager.stop_print(printer_id)
|
|
except Exception as e:
|
|
logger.error("Failed to stop print: %s", e)
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
return {"message": "Print stopped"}
|
|
|
|
|
|
@router.post("/printer/{printer_id}/cancel")
|
|
async def webhook_cancel_print(
|
|
printer_id: int,
|
|
api_key: APIKey = Depends(get_api_key),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Cancel the current print on a printer.
|
|
|
|
Requires 'can_control_printer' permission.
|
|
"""
|
|
await check_webhook_permission(db, api_key, "control_printer")
|
|
check_printer_access(api_key, printer_id)
|
|
|
|
status = printer_manager.get_status(printer_id)
|
|
# Same dataclass-not-dict shape as stop_print above (#1584).
|
|
if not status or not status.connected:
|
|
raise HTTPException(status_code=503, detail="Printer not connected")
|
|
|
|
if status.state not in ["RUNNING", "PAUSE"]:
|
|
raise HTTPException(status_code=409, detail="No print to cancel")
|
|
|
|
try:
|
|
await printer_manager.cancel_print(printer_id)
|
|
except Exception as e:
|
|
logger.error("Failed to cancel print: %s", e)
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
return {"message": "Print cancelled"}
|
|
|
|
|
|
@router.get("/printer/{printer_id}/status", response_model=PrinterStatusResponse)
|
|
async def webhook_get_printer_status(
|
|
printer_id: int,
|
|
api_key: APIKey = Depends(get_api_key),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Get status of a printer.
|
|
|
|
Requires 'can_read_status' permission.
|
|
"""
|
|
await check_webhook_permission(db, api_key, "read_status")
|
|
check_printer_access(api_key, printer_id)
|
|
|
|
# Get printer
|
|
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
printer = result.scalar_one_or_none()
|
|
if not printer:
|
|
raise HTTPException(status_code=404, detail="Printer not found")
|
|
|
|
status = printer_manager.get_status(printer_id)
|
|
|
|
# `printer_manager.get_status(...)` returns a ``PrinterState`` dataclass —
|
|
# attribute access, not dict lookup. The previous `.get(...)` calls raised
|
|
# AttributeError and surfaced as a generic 500 for any printer that
|
|
# actually had a status row (#1584).
|
|
if status is None:
|
|
return PrinterStatusResponse(
|
|
id=printer.id,
|
|
name=printer.name,
|
|
serial_number=printer.serial_number,
|
|
connected=False,
|
|
state=None,
|
|
current_print=None,
|
|
progress=None,
|
|
remaining_time=None,
|
|
)
|
|
return PrinterStatusResponse(
|
|
id=printer.id,
|
|
name=printer.name,
|
|
serial_number=printer.serial_number,
|
|
connected=status.connected,
|
|
state=status.state,
|
|
current_print=status.current_print,
|
|
progress=status.progress,
|
|
remaining_time=status.remaining_time,
|
|
remaining_seconds=status.remaining_time * 60 if status.remaining_time is not None else None,
|
|
layer_num=status.layer_num,
|
|
total_layers=status.total_layers,
|
|
subtask_id=_job_id(status.subtask_id),
|
|
hms_errors=hms_error_responses(status.hms_errors),
|
|
)
|
|
|
|
|
|
@router.get("/queue", response_model=list[QueueStatusResponse])
|
|
async def webhook_get_queue_status(
|
|
printer_id: int | None = None,
|
|
api_key: APIKey = Depends(get_api_key),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Get queue status for all printers or a specific printer.
|
|
|
|
Requires 'can_read_status' permission.
|
|
"""
|
|
await check_webhook_permission(db, api_key, "read_status")
|
|
|
|
# Get printers
|
|
if printer_id:
|
|
check_printer_access(api_key, printer_id)
|
|
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
printers = result.scalars().all()
|
|
else:
|
|
result = await db.execute(select(Printer))
|
|
printers = result.scalars().all()
|
|
# Filter by allowed printers if limited
|
|
if api_key.printer_ids is not None:
|
|
printers = [p for p in printers if p.id in api_key.printer_ids]
|
|
|
|
response = []
|
|
for printer in printers:
|
|
# Get queue items
|
|
result = await db.execute(
|
|
select(PrintQueueItem)
|
|
.where(
|
|
PrintQueueItem.printer_id == printer.id,
|
|
PrintQueueItem.status.in_(["pending", "printing"]),
|
|
)
|
|
.order_by(PrintQueueItem.position)
|
|
)
|
|
items = result.scalars().all()
|
|
|
|
pending_count = sum(1 for i in items if i.status == "pending")
|
|
printing_count = sum(1 for i in items if i.status == "printing")
|
|
|
|
response.append(
|
|
QueueStatusResponse(
|
|
printer_id=printer.id,
|
|
printer_name=printer.name,
|
|
pending=pending_count,
|
|
printing=printing_count,
|
|
items=[
|
|
{
|
|
"id": item.id,
|
|
"archive_id": item.archive_id,
|
|
"position": item.position,
|
|
"status": item.status,
|
|
}
|
|
for item in items
|
|
],
|
|
)
|
|
)
|
|
|
|
return response
|