Files
bambuddy/backend/app/api/routes/printer_control.py
T
2025-12-05 15:20:09 +01:00

771 lines
28 KiB
Python

"""Printer control API endpoints for full printer control."""
import logging
import secrets
import time
from typing import Optional
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from backend.app.core.database import get_db
from backend.app.models.printer import Printer
from backend.app.services.printer_manager import printer_manager
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/printers", tags=["printer-control"])
# Store confirmation tokens with expiry: {token: (printer_id, action, expiry_time)}
_confirmation_tokens: dict[str, tuple[int, str, float]] = {}
CONFIRMATION_TOKEN_EXPIRY = 60 # seconds
def _clean_expired_tokens():
"""Remove expired confirmation tokens."""
now = time.time()
expired = [t for t, (_, _, exp) in _confirmation_tokens.items() if now > exp]
for token in expired:
_confirmation_tokens.pop(token, None)
def _create_confirmation_token(printer_id: int, action: str) -> str:
"""Create a confirmation token for dangerous operations."""
_clean_expired_tokens()
token = secrets.token_urlsafe(16)
_confirmation_tokens[token] = (printer_id, action, time.time() + CONFIRMATION_TOKEN_EXPIRY)
return token
def _validate_confirmation_token(token: str, printer_id: int, action: str) -> bool:
"""Validate and consume a confirmation token."""
_clean_expired_tokens()
if token not in _confirmation_tokens:
return False
stored_printer_id, stored_action, expiry = _confirmation_tokens[token]
if stored_printer_id != printer_id or stored_action != action:
return False
if time.time() > expiry:
_confirmation_tokens.pop(token, None)
return False
# Consume the token
_confirmation_tokens.pop(token, None)
return True
async def get_printer_or_404(printer_id: int, db: AsyncSession) -> Printer:
"""Get printer by ID or raise 404."""
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")
return printer
def get_mqtt_client_or_503(printer_id: int):
"""Get MQTT client for printer or raise 503."""
client = printer_manager.get_client(printer_id)
if not client:
raise HTTPException(status_code=503, detail="Printer not connected")
if not client.state.connected:
raise HTTPException(status_code=503, detail="Printer connection lost")
return client
# =============================================================================
# Request/Response Models
# =============================================================================
class ControlResponse(BaseModel):
success: bool
message: str
class ConfirmationRequired(BaseModel):
requires_confirmation: bool = True
token: str
warning: str
expires_in: int = CONFIRMATION_TOKEN_EXPIRY
class ConfirmableRequest(BaseModel):
confirm_token: Optional[str] = None
class TemperatureRequest(ConfirmableRequest):
target: int = Field(..., ge=0, le=350, description="Target temperature in Celsius")
class NozzleTemperatureRequest(TemperatureRequest):
nozzle: int = Field(default=0, ge=0, le=1, description="Nozzle index (0 or 1 for dual nozzle)")
class SpeedRequest(BaseModel):
mode: int = Field(..., ge=1, le=4, description="Speed mode: 1=silent, 2=standard, 3=sport, 4=ludicrous")
class ExtruderRequest(BaseModel):
extruder: int = Field(..., ge=0, le=1, description="Extruder index (0=right, 1=left for H2D)")
class FanRequest(BaseModel):
speed: int = Field(..., ge=0, le=100, description="Fan speed percentage (0-100)")
class LightRequest(BaseModel):
on: bool = Field(..., description="Light state: true=on, false=off")
class CameraSettingRequest(BaseModel):
enable: bool = Field(..., description="Enable or disable the setting")
class HomeRequest(ConfirmableRequest):
axes: str = Field(default="XYZ", description="Axes to home (e.g., 'XYZ', 'X', 'XY', 'Z')")
class MoveRequest(ConfirmableRequest):
axis: str = Field(..., pattern="^[XYZxyz]$", description="Axis to move: X, Y, or Z")
distance: float = Field(..., ge=-100, le=100, description="Distance in mm (positive or negative)")
speed: int = Field(default=3000, ge=100, le=10000, description="Movement speed in mm/min")
class AMSLoadRequest(BaseModel):
tray_id: int = Field(..., ge=0, le=254, description="Tray ID (0-15 for AMS, 254 for external)")
extruder_id: int | None = Field(default=None, ge=0, le=1, description="Extruder ID for dual-nozzle printers (0=right, 1=left)")
class AMSRefreshTrayRequest(BaseModel):
ams_id: int = Field(..., ge=0, le=128, description="AMS unit ID (0-3, or 128 for H2D external)")
tray_id: int = Field(..., ge=0, le=3, description="Tray ID within the AMS (0-3)")
class AMSFilamentSettingRequest(BaseModel):
ams_id: int = Field(..., ge=0, le=128, description="AMS unit ID (0-3, or 128 for H2D external)")
tray_id: int = Field(..., ge=0, le=3, description="Tray ID within the AMS (0-3)")
tray_info_idx: str = Field(..., description="Filament preset ID (e.g., 'GFA00')")
tray_type: str = Field(..., description="Filament type (e.g., 'PLA', 'PETG')")
tray_sub_brands: str = Field(default="", description="Sub-brand name (e.g., 'PLA Basic')")
tray_color: str = Field(..., description="Color in RRGGBBAA hex format")
nozzle_temp_min: int = Field(..., ge=150, le=350, description="Minimum nozzle temperature")
nozzle_temp_max: int = Field(..., ge=150, le=350, description="Maximum nozzle temperature")
k: float = Field(..., ge=0, le=1, description="Pressure advance (K) value")
class GcodeRequest(ConfirmableRequest):
command: str = Field(..., min_length=1, max_length=500, description="G-code command(s)")
# =============================================================================
# Print Control Endpoints
# =============================================================================
@router.post("/{printer_id}/control/pause", response_model=ControlResponse)
async def pause_print(
printer_id: int,
db: AsyncSession = Depends(get_db),
):
"""Pause the current print job."""
await get_printer_or_404(printer_id, db)
client = get_mqtt_client_or_503(printer_id)
# Check if printer is actually printing
if client.state.state != "RUNNING":
raise HTTPException(status_code=400, detail="Printer is not currently printing")
success = client.pause_print()
return ControlResponse(
success=success,
message="Pause command sent" if success else "Failed to send pause command"
)
@router.post("/{printer_id}/control/resume", response_model=ControlResponse)
async def resume_print(
printer_id: int,
db: AsyncSession = Depends(get_db),
):
"""Resume a paused print job."""
await get_printer_or_404(printer_id, db)
client = get_mqtt_client_or_503(printer_id)
# Check if printer is actually paused
if client.state.state != "PAUSE":
raise HTTPException(status_code=400, detail="Printer is not paused")
success = client.resume_print()
return ControlResponse(
success=success,
message="Resume command sent" if success else "Failed to send resume command"
)
@router.post("/{printer_id}/control/stop")
async def stop_print(
printer_id: int,
request: ConfirmableRequest = None,
db: AsyncSession = Depends(get_db),
):
"""Stop the current print job. Requires confirmation."""
await get_printer_or_404(printer_id, db)
client = get_mqtt_client_or_503(printer_id)
# Check if printer is printing or paused
if client.state.state not in ("RUNNING", "PAUSE"):
raise HTTPException(status_code=400, detail="No active print to stop")
# Require confirmation for stop
if not request or not request.confirm_token:
token = _create_confirmation_token(printer_id, "stop")
return ConfirmationRequired(
token=token,
warning="This will abort the current print. The print cannot be resumed. Are you sure?"
)
if not _validate_confirmation_token(request.confirm_token, printer_id, "stop"):
raise HTTPException(status_code=400, detail="Invalid or expired confirmation token")
success = client.stop_print()
return ControlResponse(
success=success,
message="Stop command sent" if success else "Failed to send stop command"
)
# =============================================================================
# Temperature Control Endpoints
# =============================================================================
@router.post("/{printer_id}/control/temperature/bed", response_model=ControlResponse)
async def set_bed_temperature(
printer_id: int,
request: TemperatureRequest,
db: AsyncSession = Depends(get_db),
):
"""Set the bed target temperature."""
await get_printer_or_404(printer_id, db)
client = get_mqtt_client_or_503(printer_id)
# Warn for high temperatures
if request.target > 100 and not request.confirm_token:
token = _create_confirmation_token(printer_id, "bed_temp")
return ConfirmationRequired(
token=token,
warning=f"Setting bed to {request.target}°C is unusually high. Confirm?"
)
if request.target > 100:
if not _validate_confirmation_token(request.confirm_token, printer_id, "bed_temp"):
raise HTTPException(status_code=400, detail="Invalid or expired confirmation token")
success = client.set_bed_temperature(request.target)
return ControlResponse(
success=success,
message=f"Bed temperature set to {request.target}°C" if success else "Failed to set bed temperature"
)
@router.post("/{printer_id}/control/temperature/nozzle", response_model=ControlResponse)
async def set_nozzle_temperature(
printer_id: int,
request: NozzleTemperatureRequest,
db: AsyncSession = Depends(get_db),
):
"""Set the nozzle target temperature."""
await get_printer_or_404(printer_id, db)
client = get_mqtt_client_or_503(printer_id)
# Warn for high temperatures
if request.target > 280 and not request.confirm_token:
token = _create_confirmation_token(printer_id, "nozzle_temp")
return ConfirmationRequired(
token=token,
warning=f"Setting nozzle to {request.target}°C is very high. Confirm?"
)
if request.target > 280:
if not _validate_confirmation_token(request.confirm_token, printer_id, "nozzle_temp"):
raise HTTPException(status_code=400, detail="Invalid or expired confirmation token")
success = client.set_nozzle_temperature(request.target, request.nozzle)
return ControlResponse(
success=success,
message=f"Nozzle {request.nozzle} temperature set to {request.target}°C" if success else "Failed to set nozzle temperature"
)
@router.post("/{printer_id}/control/temperature/chamber", response_model=ControlResponse)
async def set_chamber_temperature(
printer_id: int,
request: TemperatureRequest,
db: AsyncSession = Depends(get_db),
):
"""Set the chamber target temperature."""
await get_printer_or_404(printer_id, db)
client = get_mqtt_client_or_503(printer_id)
# Warn for high temperatures (chamber typically maxes around 60°C)
if request.target > 60 and not request.confirm_token:
token = _create_confirmation_token(printer_id, "chamber_temp")
return ConfirmationRequired(
token=token,
warning=f"Setting chamber to {request.target}°C is very high. Confirm?"
)
if request.target > 60:
if not _validate_confirmation_token(request.confirm_token, printer_id, "chamber_temp"):
raise HTTPException(status_code=400, detail="Invalid or expired confirmation token")
success = client.set_chamber_temperature(request.target)
return ControlResponse(
success=success,
message=f"Chamber temperature set to {request.target}°C" if success else "Failed to set chamber temperature"
)
# =============================================================================
# Speed Control Endpoint
# =============================================================================
@router.post("/{printer_id}/control/speed", response_model=ControlResponse)
async def set_print_speed(
printer_id: int,
request: SpeedRequest,
db: AsyncSession = Depends(get_db),
):
"""Set the print speed mode."""
await get_printer_or_404(printer_id, db)
client = get_mqtt_client_or_503(printer_id)
speed_names = {1: "Silent", 2: "Standard", 3: "Sport", 4: "Ludicrous"}
success = client.set_print_speed(request.mode)
return ControlResponse(
success=success,
message=f"Speed set to {speed_names[request.mode]}" if success else "Failed to set speed"
)
# =============================================================================
# Extruder Control Endpoint
# =============================================================================
@router.post("/{printer_id}/control/extruder", response_model=ControlResponse)
async def select_extruder(
printer_id: int,
request: ExtruderRequest,
db: AsyncSession = Depends(get_db),
):
"""Select the active extruder for dual-nozzle printers."""
await get_printer_or_404(printer_id, db)
client = get_mqtt_client_or_503(printer_id)
extruder_names = {0: "Right", 1: "Left"}
success = client.select_extruder(request.extruder)
return ControlResponse(
success=success,
message=f"Selected {extruder_names[request.extruder]} extruder" if success else "Failed to select extruder"
)
# =============================================================================
# Fan Control Endpoints
# =============================================================================
@router.post("/{printer_id}/control/fan/part", response_model=ControlResponse)
async def set_part_fan(
printer_id: int,
request: FanRequest,
db: AsyncSession = Depends(get_db),
):
"""Set part cooling fan speed (0-100%)."""
await get_printer_or_404(printer_id, db)
client = get_mqtt_client_or_503(printer_id)
# Convert percentage to 0-255
speed_255 = int(request.speed * 255 / 100)
success = client.set_part_fan(speed_255)
return ControlResponse(
success=success,
message=f"Part fan set to {request.speed}%" if success else "Failed to set part fan"
)
@router.post("/{printer_id}/control/fan/aux", response_model=ControlResponse)
async def set_aux_fan(
printer_id: int,
request: FanRequest,
db: AsyncSession = Depends(get_db),
):
"""Set auxiliary fan speed (0-100%)."""
await get_printer_or_404(printer_id, db)
client = get_mqtt_client_or_503(printer_id)
speed_255 = int(request.speed * 255 / 100)
success = client.set_aux_fan(speed_255)
return ControlResponse(
success=success,
message=f"Aux fan set to {request.speed}%" if success else "Failed to set aux fan"
)
@router.post("/{printer_id}/control/fan/chamber", response_model=ControlResponse)
async def set_chamber_fan(
printer_id: int,
request: FanRequest,
db: AsyncSession = Depends(get_db),
):
"""Set chamber fan speed (0-100%)."""
await get_printer_or_404(printer_id, db)
client = get_mqtt_client_or_503(printer_id)
speed_255 = int(request.speed * 255 / 100)
success = client.set_chamber_fan(speed_255)
return ControlResponse(
success=success,
message=f"Chamber fan set to {request.speed}%" if success else "Failed to set chamber fan"
)
# =============================================================================
# Air Conditioning Control Endpoint
# =============================================================================
class AirductModeRequest(BaseModel):
mode: str # "cooling" or "heating"
@router.post("/{printer_id}/control/airduct", response_model=ControlResponse)
async def set_airduct_mode(
printer_id: int,
request: AirductModeRequest,
db: AsyncSession = Depends(get_db),
):
"""Set air conditioning mode (cooling or heating).
- Cooling: Suitable for PLA/PETG/TPU, filters and cools chamber air
- Heating: Suitable for ABS/ASA/PC/PA, circulates and heats chamber air, closes top exhaust flap
"""
await get_printer_or_404(printer_id, db)
client = get_mqtt_client_or_503(printer_id)
if request.mode not in ("cooling", "heating"):
raise HTTPException(status_code=400, detail="Mode must be 'cooling' or 'heating'")
success = client.set_airduct_mode(request.mode)
return ControlResponse(
success=success,
message=f"Air conditioning set to {request.mode}" if success else "Failed to set air conditioning mode"
)
# =============================================================================
# Light Control Endpoint
# =============================================================================
@router.post("/{printer_id}/control/light", response_model=ControlResponse)
async def set_chamber_light(
printer_id: int,
request: LightRequest,
db: AsyncSession = Depends(get_db),
):
"""Turn chamber light on or off."""
await get_printer_or_404(printer_id, db)
client = get_mqtt_client_or_503(printer_id)
success = client.set_chamber_light(request.on)
return ControlResponse(
success=success,
message=f"Light turned {'on' if request.on else 'off'}" if success else "Failed to control light"
)
# =============================================================================
# Movement Control Endpoints
# =============================================================================
@router.post("/{printer_id}/control/home")
async def home_axes(
printer_id: int,
request: HomeRequest = None,
db: AsyncSession = Depends(get_db),
):
"""Home the specified axes."""
await get_printer_or_404(printer_id, db)
client = get_mqtt_client_or_503(printer_id)
axes = (request.axes if request else "XYZ").upper()
# Warn if homing during print
if client.state.state in ("RUNNING", "PAUSE"):
if not request or not request.confirm_token:
token = _create_confirmation_token(printer_id, "home")
return ConfirmationRequired(
token=token,
warning="Homing during an active print is not recommended. This may damage your print. Continue?"
)
if not _validate_confirmation_token(request.confirm_token, printer_id, "home"):
raise HTTPException(status_code=400, detail="Invalid or expired confirmation token")
success = client.home_axes(axes)
return ControlResponse(
success=success,
message=f"Homing {axes}" if success else "Failed to send home command"
)
@router.post("/{printer_id}/control/move")
async def move_axis(
printer_id: int,
request: MoveRequest,
db: AsyncSession = Depends(get_db),
):
"""Move an axis by a relative distance."""
await get_printer_or_404(printer_id, db)
client = get_mqtt_client_or_503(printer_id)
# Block movement during print unless confirmed
if client.state.state in ("RUNNING", "PAUSE"):
if not request.confirm_token:
token = _create_confirmation_token(printer_id, "move")
return ConfirmationRequired(
token=token,
warning="Manual movement during printing can damage your print. Are you sure?"
)
if not _validate_confirmation_token(request.confirm_token, printer_id, "move"):
raise HTTPException(status_code=400, detail="Invalid or expired confirmation token")
success = client.move_axis(request.axis.upper(), request.distance, request.speed)
direction = "+" if request.distance > 0 else ""
return ControlResponse(
success=success,
message=f"Moving {request.axis.upper()} {direction}{request.distance}mm" if success else "Failed to send move command"
)
@router.post("/{printer_id}/control/motors/disable")
async def disable_motors(
printer_id: int,
request: ConfirmableRequest = None,
db: AsyncSession = Depends(get_db),
):
"""Disable stepper motors. Warning: This will lose position."""
await get_printer_or_404(printer_id, db)
client = get_mqtt_client_or_503(printer_id)
# Always require confirmation
if not request or not request.confirm_token:
token = _create_confirmation_token(printer_id, "disable_motors")
return ConfirmationRequired(
token=token,
warning="Disabling motors will cause the printer to lose its position. You must home before printing. Continue?"
)
if not _validate_confirmation_token(request.confirm_token, printer_id, "disable_motors"):
raise HTTPException(status_code=400, detail="Invalid or expired confirmation token")
success = client.disable_motors()
return ControlResponse(
success=success,
message="Motors disabled" if success else "Failed to disable motors"
)
@router.post("/{printer_id}/control/motors/enable", response_model=ControlResponse)
async def enable_motors(
printer_id: int,
db: AsyncSession = Depends(get_db),
):
"""Enable stepper motors."""
await get_printer_or_404(printer_id, db)
client = get_mqtt_client_or_503(printer_id)
success = client.enable_motors()
return ControlResponse(
success=success,
message="Motors enabled" if success else "Failed to enable motors"
)
# =============================================================================
# AMS Control Endpoints
# =============================================================================
@router.post("/{printer_id}/control/ams/load", response_model=ControlResponse)
async def ams_load_filament(
printer_id: int,
request: AMSLoadRequest,
db: AsyncSession = Depends(get_db),
):
"""Load filament from a specific AMS tray."""
await get_printer_or_404(printer_id, db)
client = get_mqtt_client_or_503(printer_id)
# Don't allow during print
if client.state.state == "RUNNING":
raise HTTPException(status_code=400, detail="Cannot change filament during print")
success = client.ams_load_filament(request.tray_id, request.extruder_id)
extruder_info = f" to extruder {request.extruder_id}" if request.extruder_id is not None else ""
return ControlResponse(
success=success,
message=f"Loading filament from tray {request.tray_id}{extruder_info}" if success else "Failed to load filament"
)
@router.post("/{printer_id}/control/ams/unload", response_model=ControlResponse)
async def ams_unload_filament(
printer_id: int,
db: AsyncSession = Depends(get_db),
):
"""Unload the currently loaded filament."""
await get_printer_or_404(printer_id, db)
client = get_mqtt_client_or_503(printer_id)
# Don't allow during print
if client.state.state == "RUNNING":
raise HTTPException(status_code=400, detail="Cannot unload filament during print")
success = client.ams_unload_filament()
return ControlResponse(
success=success,
message="Unloading filament" if success else "Failed to unload filament"
)
@router.post("/{printer_id}/control/ams/refresh-tray", response_model=ControlResponse)
async def ams_refresh_tray(
printer_id: int,
request: AMSRefreshTrayRequest,
db: AsyncSession = Depends(get_db),
):
"""Trigger RFID re-read for a specific AMS tray."""
await get_printer_or_404(printer_id, db)
client = get_mqtt_client_or_503(printer_id)
success, message = client.ams_refresh_tray(request.ams_id, request.tray_id)
return ControlResponse(success=success, message=message)
@router.post("/{printer_id}/control/ams/filament-setting", response_model=ControlResponse)
async def ams_set_filament_setting(
printer_id: int,
request: AMSFilamentSettingRequest,
db: AsyncSession = Depends(get_db),
):
"""Set filament settings for an AMS tray including K (pressure advance) value."""
await get_printer_or_404(printer_id, db)
client = get_mqtt_client_or_503(printer_id)
success = client.ams_set_filament_setting(
ams_id=request.ams_id,
tray_id=request.tray_id,
tray_info_idx=request.tray_info_idx,
tray_type=request.tray_type,
tray_sub_brands=request.tray_sub_brands,
tray_color=request.tray_color,
nozzle_temp_min=request.nozzle_temp_min,
nozzle_temp_max=request.nozzle_temp_max,
k=request.k,
)
return ControlResponse(
success=success,
message=f"Updated AMS {request.ams_id} tray {request.tray_id} with K={request.k}" if success else "Failed to update filament setting"
)
# =============================================================================
# Advanced: G-code Command
# =============================================================================
@router.post("/{printer_id}/control/gcode")
async def send_gcode(
printer_id: int,
request: GcodeRequest,
db: AsyncSession = Depends(get_db),
):
"""Send raw G-code command(s). Advanced users only."""
await get_printer_or_404(printer_id, db)
client = get_mqtt_client_or_503(printer_id)
# Require confirmation for any G-code
if not request.confirm_token:
token = _create_confirmation_token(printer_id, "gcode")
return ConfirmationRequired(
token=token,
warning="Sending raw G-code can damage your printer if used incorrectly. Are you sure?"
)
if not _validate_confirmation_token(request.confirm_token, printer_id, "gcode"):
raise HTTPException(status_code=400, detail="Invalid or expired confirmation token")
success = client.send_gcode(request.command)
return ControlResponse(
success=success,
message="G-code sent" if success else "Failed to send G-code"
)
# =============================================================================
# Camera Settings Endpoints
# =============================================================================
@router.post("/{printer_id}/control/camera/timelapse", response_model=ControlResponse)
async def set_timelapse(
printer_id: int,
request: CameraSettingRequest,
db: AsyncSession = Depends(get_db),
):
"""Enable or disable timelapse recording."""
await get_printer_or_404(printer_id, db)
client = get_mqtt_client_or_503(printer_id)
success = client.set_timelapse(request.enable)
return ControlResponse(
success=success,
message=f"Timelapse {'enabled' if request.enable else 'disabled'}" if success else "Failed to set timelapse"
)
@router.post("/{printer_id}/control/camera/liveview", response_model=ControlResponse)
async def set_liveview(
printer_id: int,
request: CameraSettingRequest,
db: AsyncSession = Depends(get_db),
):
"""Enable or disable live view / camera streaming."""
await get_printer_or_404(printer_id, db)
client = get_mqtt_client_or_503(printer_id)
success = client.set_liveview(request.enable)
return ControlResponse(
success=success,
message=f"Live view {'enabled' if request.enable else 'disabled'}" if success else "Failed to set live view"
)
# =============================================================================
# Status Refresh Endpoint
# =============================================================================
@router.post("/{printer_id}/control/refresh", response_model=ControlResponse)
async def refresh_status(
printer_id: int,
db: AsyncSession = Depends(get_db),
):
"""Request a full status update from the printer.
This sends a 'pushall' command to get the latest data including nozzle info,
AMS status, and all other printer state.
"""
await get_printer_or_404(printer_id, db)
success = printer_manager.request_status_update(printer_id)
if not success:
raise HTTPException(status_code=503, detail="Printer not connected")
return ControlResponse(
success=success,
message="Status refresh requested"
)