mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
Started to implement full printer control
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
"""Camera streaming API endpoints for Bambu Lab printers."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from fastapi.responses import StreamingResponse, Response
|
||||
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.camera import (
|
||||
build_camera_url,
|
||||
capture_camera_frame,
|
||||
test_camera_connection,
|
||||
)
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/printers", tags=["camera"])
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def generate_mjpeg_stream(
|
||||
ip_address: str,
|
||||
access_code: str,
|
||||
model: str | None,
|
||||
fps: int = 10,
|
||||
) -> AsyncGenerator[bytes, None]:
|
||||
"""Generate MJPEG stream from printer camera using ffmpeg.
|
||||
|
||||
This captures frames continuously and yields them in MJPEG format.
|
||||
"""
|
||||
from backend.app.services.camera import get_camera_port
|
||||
|
||||
port = get_camera_port(model)
|
||||
camera_url = f"rtsps://bblp:{access_code}@{ip_address}:{port}/streaming/live/1"
|
||||
|
||||
# ffmpeg command to output MJPEG stream to stdout
|
||||
# -re: Read input at native frame rate
|
||||
# -rtsp_transport tcp: Use TCP for reliability
|
||||
# -f mjpeg: Output as MJPEG
|
||||
# -q:v 5: Quality (lower = better, 2-10 is good range)
|
||||
# -r: Output framerate
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-rtsp_transport", "tcp",
|
||||
"-i", camera_url,
|
||||
"-f", "mjpeg",
|
||||
"-q:v", "5",
|
||||
"-r", str(fps),
|
||||
"-an", # No audio
|
||||
"-" # Output to stdout
|
||||
]
|
||||
|
||||
logger.info(f"Starting camera stream for {ip_address}")
|
||||
|
||||
process = None
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
# Read JPEG frames from ffmpeg output
|
||||
# JPEG images start with 0xFFD8 and end with 0xFFD9
|
||||
buffer = b""
|
||||
jpeg_start = b"\xff\xd8"
|
||||
jpeg_end = b"\xff\xd9"
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Read chunk from ffmpeg
|
||||
chunk = await asyncio.wait_for(
|
||||
process.stdout.read(8192),
|
||||
timeout=10.0
|
||||
)
|
||||
|
||||
if not chunk:
|
||||
logger.warning("Camera stream ended (no more data)")
|
||||
break
|
||||
|
||||
buffer += chunk
|
||||
|
||||
# Find complete JPEG frames in buffer
|
||||
while True:
|
||||
start_idx = buffer.find(jpeg_start)
|
||||
if start_idx == -1:
|
||||
# No start marker, clear buffer up to last 2 bytes
|
||||
buffer = buffer[-2:] if len(buffer) > 2 else buffer
|
||||
break
|
||||
|
||||
# Trim anything before the start marker
|
||||
if start_idx > 0:
|
||||
buffer = buffer[start_idx:]
|
||||
|
||||
end_idx = buffer.find(jpeg_end, 2) # Skip first 2 bytes
|
||||
if end_idx == -1:
|
||||
# No end marker yet, wait for more data
|
||||
break
|
||||
|
||||
# Extract complete frame
|
||||
frame = buffer[:end_idx + 2]
|
||||
buffer = buffer[end_idx + 2:]
|
||||
|
||||
# Yield frame in MJPEG format
|
||||
yield (
|
||||
b"--frame\r\n"
|
||||
b"Content-Type: image/jpeg\r\n"
|
||||
b"Content-Length: " + str(len(frame)).encode() + b"\r\n"
|
||||
b"\r\n" + frame + b"\r\n"
|
||||
)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Camera stream read timeout")
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Camera stream cancelled")
|
||||
break
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.error("ffmpeg not found - camera streaming requires ffmpeg")
|
||||
yield (
|
||||
b"--frame\r\n"
|
||||
b"Content-Type: text/plain\r\n\r\n"
|
||||
b"Error: ffmpeg not installed\r\n"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f"Camera stream error: {e}")
|
||||
finally:
|
||||
if process:
|
||||
try:
|
||||
process.terminate()
|
||||
await asyncio.wait_for(process.wait(), timeout=5.0)
|
||||
except Exception:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
logger.info(f"Camera stream stopped for {ip_address}")
|
||||
|
||||
|
||||
@router.get("/{printer_id}/camera/stream")
|
||||
async def camera_stream(
|
||||
printer_id: int,
|
||||
fps: int = 10,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Stream live video from printer camera as MJPEG.
|
||||
|
||||
This endpoint returns a multipart MJPEG stream that can be used directly
|
||||
in an <img> tag or video player.
|
||||
|
||||
Args:
|
||||
printer_id: Printer ID
|
||||
fps: Target frames per second (default: 10, max: 30)
|
||||
"""
|
||||
printer = await get_printer_or_404(printer_id, db)
|
||||
|
||||
# Validate FPS
|
||||
fps = min(max(fps, 1), 30)
|
||||
|
||||
return StreamingResponse(
|
||||
generate_mjpeg_stream(
|
||||
ip_address=printer.ip_address,
|
||||
access_code=printer.access_code,
|
||||
model=printer.model,
|
||||
fps=fps,
|
||||
),
|
||||
media_type="multipart/x-mixed-replace; boundary=frame",
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{printer_id}/camera/snapshot")
|
||||
async def camera_snapshot(
|
||||
printer_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Capture a single frame from the printer camera.
|
||||
|
||||
Returns a JPEG image.
|
||||
"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
printer = await get_printer_or_404(printer_id, db)
|
||||
|
||||
# Create temporary file for the snapshot
|
||||
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
|
||||
temp_path = Path(f.name)
|
||||
|
||||
try:
|
||||
success = await capture_camera_frame(
|
||||
ip_address=printer.ip_address,
|
||||
access_code=printer.access_code,
|
||||
model=printer.model,
|
||||
output_path=temp_path,
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Failed to capture camera frame. Is the printer powered on?"
|
||||
)
|
||||
|
||||
# Read and return the image
|
||||
with open(temp_path, "rb") as f:
|
||||
image_data = f.read()
|
||||
|
||||
return Response(
|
||||
content=image_data,
|
||||
media_type="image/jpeg",
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Content-Disposition": f'inline; filename="snapshot_{printer_id}.jpg"'
|
||||
}
|
||||
)
|
||||
finally:
|
||||
# Clean up temp file
|
||||
if temp_path.exists():
|
||||
temp_path.unlink()
|
||||
|
||||
|
||||
@router.get("/{printer_id}/camera/test")
|
||||
async def test_camera(
|
||||
printer_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Test camera connection for a printer.
|
||||
|
||||
Returns success status and any error message.
|
||||
"""
|
||||
printer = await get_printer_or_404(printer_id, db)
|
||||
|
||||
result = await test_camera_connection(
|
||||
ip_address=printer.ip_address,
|
||||
access_code=printer.access_code,
|
||||
model=printer.model,
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,555 @@
|
||||
"""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 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 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)")
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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)
|
||||
return ControlResponse(
|
||||
success=success,
|
||||
message=f"Loading filament from tray {request.tray_id}" 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"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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"
|
||||
)
|
||||
Reference in New Issue
Block a user