Files
bambuddy/backend/app/api/routes/webhook.py
T
maziggy 173edd9b7c ● fix(vp-queue): inherit slicer print options instead of always using defaults (#1403)
Reporter sliced in OrcaSlicer with timelapse on, sent the job to a VP
  queue, started from the queue, and got no timelapse video. Their
  dispatch chain itself was correct (queue item -> scheduler -> MQTT
  command honors `timelapse`); the gap was at queue-add time.

  The VP's `_add_to_print_queue` reads `default_timelapse` (and the four
  other print-option settings) from the workflow settings card. That was
  introduced in #1235 to stop column-level defaults from winning. But it
  also discarded the slicer's actual choice carried on the MQTT
  `project_file` command, which all the slicers (Studio / Handy / Orca)
  ship as `timelapse: true|1`. Result: a user with the new-install value
  `default_timelapse=false` had to either flip the global setting or
  edit every queue item by hand, even though their slicer's "Print
  options" UI clearly said "record timelapse".

  Investigation went wider than #1403 because Martin's hypothesis was
  "the print options modal isn't respected either." Cross-checking
  86 captured P1S `project_file` commands across the support packages
  shows 46 from the queue scheduler and 33 from background_dispatch
  emitting `"timelapse": true` correctly to real printers - the modal +
  re-print path is intact end-to-end. The slicer-side gap was the only
  real bug. Two unrelated dead-code issues turned up in the same dig and
  are folded in below.

  Fix (VP queue inheritance)

  - `on_print_command` in the VP manager now stashes the slicer's
    project_file dict keyed by filename, then signals an asyncio.Event.
  - `_add_to_print_queue` checks the dict first; if empty, creates the
    event and waits up to 2 s for it before reading the settings
    fallback. Each option flows through per-field - slicer value wins
    if present, else the existing settings default (so users who
    explicitly set `default_timelapse=true` in their VP workflow card
    still get that on slicers that don't send a print command).
  - MQTT field naming preserved exactly: `bed_leveling` (single L) on
    the wire stays mapped to `bed_levelling` (double L) on the Bambuddy
    column. Integer 0/1 from H-family slicers and bool true/false from
    P1/X1 slicers both coerce via `bool()`.
  - Capture is gated on `mode == "print_queue"` so immediate / review /
    proxy modes keep their pre-fix no-op `on_print_command` and don't
    accumulate stashed entries over the VP's uptime.
  - Wait is also skipped when there's no MQTT server attached
    (`self._mqtt is None`), so unit tests that invoke
    `_add_to_print_queue` directly don't pay the 2 s tax.
  - Capture is consumed on use so the dict stays bounded.
  - `printer_manager.get_status(...).get(...)` against a `PrinterState`
    dataclass that has no `.get()` method.
  - Every print option discarded (timelapse, bed_levelling, AMS mapping).

  The route 500'd before ever reaching the printer. Rewritten to mirror
  `POST /print-queue/{item_id}/start`: clear `manual_start=False` on the
  next pending queue item and let the scheduler dispatch with the
  queue's stored options intact. Response shape preserved.

  Side-bug b: vibration_cali default drift in background_dispatch

  - `ReprintRequest.vibration_cali` and `FilePrintRequest.vibration_cali`
    both default to `True` (matches Bambu Studio behavior for X1/P1).
  - Both `_process_job` call sites read
    `job.options.get("vibration_cali", False)`.

  Cosmetic today because the frontend always sends the field, but a
  latent landmine for any future caller that bypasses the schema. Both
  sites flipped to `True`.
2026-05-18 09:38:53 +02:00

333 lines
10 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_permission, check_printer_access, 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.services.printer_manager import printer_manager
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
connected: bool
state: str | None
current_print: str | None
progress: float | None
remaining_time: int | None
class QueueStatusResponse(BaseModel):
printer_id: int
printer_name: str
pending: int
printing: int
items: list[dict]
# 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.
"""
check_permission(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")
# Get next position
result = await db.execute(
select(PrintQueueItem.position)
.where(
PrintQueueItem.printer_id == data.printer_id,
PrintQueueItem.status == "pending",
)
.order_by(PrintQueueItem.position.desc())
.limit(1)
)
max_position = result.scalar()
next_position = (max_position or 0) + 1
# 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,
)
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.
"""
check_permission(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),
):
"""Stop the current print on a printer.
Requires 'can_control_printer' permission.
"""
check_permission(api_key, "control_printer")
check_printer_access(api_key, printer_id)
status = printer_manager.get_status(printer_id)
if not status or not status.get("connected"):
raise HTTPException(status_code=503, detail="Printer not connected")
if status.get("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),
):
"""Cancel the current print on a printer.
Requires 'can_control_printer' permission.
"""
check_permission(api_key, "control_printer")
check_printer_access(api_key, printer_id)
status = printer_manager.get_status(printer_id)
if not status or not status.get("connected"):
raise HTTPException(status_code=503, detail="Printer not connected")
if status.get("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.
"""
check_permission(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)
return PrinterStatusResponse(
id=printer.id,
name=printer.name,
connected=status.get("connected", False) if status else False,
state=status.get("state") if status else None,
current_print=status.get("current_print") if status else None,
progress=status.get("progress") if status else None,
remaining_time=status.get("remaining_time") if status else None,
)
@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.
"""
check_permission(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