Browse Source

Started to implement full printer control

Martin Ziegler 5 months ago
parent
commit
37caef239f

+ 522 - 0
PLAN_PRINTER_CONTROL.md

@@ -0,0 +1,522 @@
+# Full Printer Control - Implementation Plan
+
+## Overview
+
+Add a dedicated **Control Page** (`/control`) with full printer control capabilities, including:
+- Live camera feed
+- Print control (pause/resume/stop)
+- Temperature control (bed, nozzle, chamber)
+- Speed adjustment
+- Fan control
+- Light control
+- Axis movement
+- AMS visualization and operations
+
+---
+
+## Phase 1: Backend - MQTT Control Commands
+
+### 1.1 Add Control Methods to `bambu_mqtt.py`
+
+```python
+# Print Control
+async def pause_print(self) -> bool
+async def resume_print(self) -> bool
+# stop_print() already exists
+
+# Temperature Control
+async def set_bed_temperature(self, target: int) -> bool
+async def set_nozzle_temperature(self, target: int, nozzle: int = 0) -> bool
+
+# Speed Control
+async def set_print_speed(self, mode: int) -> bool  # 1=silent, 2=standard, 3=sport, 4=ludicrous
+
+# Fan Control
+async def set_part_fan(self, speed: int) -> bool  # 0-255
+async def set_aux_fan(self, speed: int) -> bool   # 0-255
+async def set_chamber_fan(self, speed: int) -> bool  # 0-255
+
+# Light Control
+async def set_chamber_light(self, on: bool) -> bool
+
+# Movement Control
+async def home_axes(self, axes: str = "XYZ") -> bool
+async def move_axis(self, axis: str, distance: float, speed: int = 3000) -> bool
+async def disable_motors(self) -> bool
+
+# AMS Control
+async def ams_load_filament(self, tray_id: int) -> bool
+async def ams_unload_filament(self) -> bool
+
+# G-code
+async def send_gcode(self, gcode: str) -> bool
+```
+
+### 1.2 MQTT Command Formats
+
+| Command | JSON Payload |
+|---------|-------------|
+| Pause | `{"print": {"sequence_id": "0", "command": "pause"}}` |
+| Resume | `{"print": {"sequence_id": "0", "command": "resume"}}` |
+| Bed Temp | `{"print": {"sequence_id": "0", "command": "gcode_line", "param": "M140 S{temp}"}}` |
+| Nozzle Temp | `{"print": {"sequence_id": "0", "command": "gcode_line", "param": "M104 S{temp}"}}` |
+| Print Speed | `{"print": {"sequence_id": "0", "command": "print_speed", "param": "{1-4}"}}` |
+| Fan (P1=part, P2=aux, P3=chamber) | `{"print": {"sequence_id": "0", "command": "gcode_line", "param": "M106 P{n} S{0-255}"}}` |
+| Light On | `{"system": {"sequence_id": "0", "command": "ledctrl", "led_node": "chamber_light", "led_mode": "on", ...}}` |
+| Home | `{"print": {"sequence_id": "0", "command": "gcode_line", "param": "G28 {axes}"}}` |
+| Move | `{"print": {"sequence_id": "0", "command": "gcode_line", "param": "G91\nG0 {axis}{dist} F{speed}\nG90"}}` |
+| AMS Load | `{"print": {"sequence_id": "0", "command": "ams_change_filament", "target": {tray_id}}}` |
+| AMS Unload | `{"print": {"sequence_id": "0", "command": "ams_change_filament", "target": 255}}` |
+
+### 1.3 Model-Specific Handling
+
+- **P1/A1 series**: Use blocking temp commands (M109/M190) instead of M104/M140
+- **H2D**: Handle dual nozzle targeting
+- Store printer model in status for frontend to adapt UI
+
+---
+
+## Phase 2: Backend - Control API Endpoints
+
+### 2.1 New Routes in `backend/app/api/routes/printer_control.py`
+
+```python
+# Print Control
+POST /api/v1/printers/{id}/control/pause
+POST /api/v1/printers/{id}/control/resume
+POST /api/v1/printers/{id}/control/stop
+
+# Temperature
+POST /api/v1/printers/{id}/control/temperature/bed
+  Body: {"target": 60}
+POST /api/v1/printers/{id}/control/temperature/nozzle
+  Body: {"target": 200, "nozzle": 0}
+
+# Speed
+POST /api/v1/printers/{id}/control/speed
+  Body: {"mode": 2}  # 1-4
+
+# Fans
+POST /api/v1/printers/{id}/control/fan/part
+  Body: {"speed": 255}  # 0-255
+POST /api/v1/printers/{id}/control/fan/aux
+POST /api/v1/printers/{id}/control/fan/chamber
+
+# Light
+POST /api/v1/printers/{id}/control/light
+  Body: {"on": true}
+
+# Movement
+POST /api/v1/printers/{id}/control/home
+  Body: {"axes": "XYZ"}  # optional, default all
+POST /api/v1/printers/{id}/control/move
+  Body: {"axis": "Z", "distance": 10, "speed": 600}
+POST /api/v1/printers/{id}/control/motors/disable
+
+# AMS
+POST /api/v1/printers/{id}/control/ams/load
+  Body: {"tray_id": 0}
+POST /api/v1/printers/{id}/control/ams/unload
+
+# G-code (advanced)
+POST /api/v1/printers/{id}/control/gcode
+  Body: {"command": "G28"}
+```
+
+### 2.2 Safety Confirmations
+
+Commands that need confirmation token (generated and validated server-side):
+- `stop` - Aborts print
+- `home` while printing - Could cause issues
+- `move` while printing - Dangerous
+- `motors/disable` - Causes position loss
+
+Flow:
+1. Frontend calls endpoint without token
+2. Backend returns `{"requires_confirmation": true, "token": "abc123", "warning": "This will abort..."}`
+3. Frontend shows confirmation dialog
+4. Frontend calls again with `{"confirm_token": "abc123"}`
+5. Backend validates token and executes
+
+---
+
+## Phase 3: Backend - Camera Streaming
+
+### 3.1 Streaming Approach
+
+Option A: **MJPEG Stream** (simpler)
+- Backend captures RTSP frames via ffmpeg
+- Serves as MJPEG stream at `/api/v1/printers/{id}/camera/stream`
+- Frontend uses `<img src="...">` with streaming
+
+Option B: **WebSocket Frames** (more control)
+- Backend sends JPEG frames via WebSocket
+- Frontend renders on canvas
+- Allows frame rate control, pause/resume
+
+**Recommended: Option A (MJPEG)** - Simpler, works in all browsers
+
+### 3.2 Implementation
+
+```python
+# backend/app/api/routes/camera.py
+
+@router.get("/printers/{printer_id}/camera/stream")
+async def camera_stream(printer_id: int):
+    """Stream camera as MJPEG"""
+    printer = get_printer(printer_id)
+
+    async def generate():
+        process = await asyncio.create_subprocess_exec(
+            'ffmpeg',
+            '-rtsp_transport', 'tcp',
+            '-i', f'rtsps://bblp:{printer.access_code}@{printer.ip_address}:{port}/streaming/live/1',
+            '-f', 'mjpeg',
+            '-q:v', '5',
+            '-r', '15',  # 15 fps
+            '-',
+            stdout=asyncio.subprocess.PIPE
+        )
+
+        while True:
+            frame = await read_jpeg_frame(process.stdout)
+            if not frame:
+                break
+            yield (
+                b'--frame\r\n'
+                b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n'
+            )
+
+    return StreamingResponse(
+        generate(),
+        media_type='multipart/x-mixed-replace; boundary=frame'
+    )
+
+@router.get("/printers/{printer_id}/camera/snapshot")
+async def camera_snapshot(printer_id: int):
+    """Get single camera frame"""
+    # Use existing camera.py capture_frame logic
+```
+
+### 3.3 Camera Ports by Model
+
+| Model | Port | Protocol |
+|-------|------|----------|
+| X1/X1C/H2D | 322 | RTSPS |
+| P1/P1S/P1P | 6000 | RTSPS |
+| A1/A1 Mini | 6000 | RTSPS |
+
+---
+
+## Phase 4: Frontend - Control Page
+
+### 4.1 Page Structure
+
+```
+/control
+├── ControlPage.tsx           # Main page with printer tabs
+├── components/
+│   ├── CameraFeed.tsx        # Live video stream
+│   ├── PrintControls.tsx     # Pause/Resume/Stop + progress
+│   ├── TemperaturePanel.tsx  # Bed/Nozzle/Chamber controls
+│   ├── SpeedControl.tsx      # Speed mode selector
+│   ├── FanControls.tsx       # Part/Aux/Chamber fan sliders
+│   ├── LightToggle.tsx       # Chamber light on/off
+│   ├── MovementControls.tsx  # Home + XYZ jog buttons
+│   ├── AMSPanel.tsx          # AMS visualization + load/unload
+│   └── ConfirmDialog.tsx     # Safety confirmation modal
+```
+
+### 4.2 Layout (Desktop)
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│  [Printer 1] [Printer 2] [Printer 3]                    tabs     │
+├──────────────────────────────────────────────────────────────────┤
+│                                                                  │
+│  ┌─────────────────────────┐  ┌────────────────────────────────┐│
+│  │                         │  │  Print Status                  ││
+│  │     Camera Feed         │  │  ┌────────────────────────┐    ││
+│  │     (16:9 aspect)       │  │  │ State: RUNNING         │    ││
+│  │                         │  │  │ File: benchy.3mf       │    ││
+│  │                         │  │  │ Progress: ████████░░ 78%│   ││
+│  │                         │  │  │ Layer: 156/200         │    ││
+│  │                         │  │  │ Time: 45min remaining  │    ││
+│  │                         │  │  └────────────────────────┘    ││
+│  │                         │  │                                ││
+│  │  [⏸ Pause] [■ Stop]     │  │  [⏸ Pause] [▶ Resume] [■ Stop]││
+│  └─────────────────────────┘  └────────────────────────────────┘│
+│                                                                  │
+│  ┌─────────────────────────┐  ┌────────────────────────────────┐│
+│  │  Temperatures           │  │  Speed & Fans                  ││
+│  │  ┌───────────────────┐  │  │  Speed: [Silent][Std][Sport][!]││
+│  │  │ 🛏️ Bed             │  │  │                                ││
+│  │  │ 60°C → 60°C       │  │  │  Part Fan:    ████████░░ 80%  ││
+│  │  │ [-] [target] [+]  │  │  │  Aux Fan:     ░░░░░░░░░░  0%  ││
+│  │  ├───────────────────┤  │  │  Chamber Fan: ████░░░░░░ 40%  ││
+│  │  │ 🔥 Nozzle          │  │  └────────────────────────────────┘│
+│  │  │ 205°C → 210°C     │  │                                    │
+│  │  │ [-] [target] [+]  │  │  ┌────────────────────────────────┐│
+│  │  ├───────────────────┤  │  │  Movement                      ││
+│  │  │ 📦 Chamber: 35°C   │  │  │        [Y+]                   ││
+│  │  └───────────────────┘  │  │  [X-]  [Home]  [X+]            ││
+│  └─────────────────────────┘  │        [Y-]       [Z+][Z-]     ││
+│                               │  [Disable Motors]              ││
+│  ┌─────────────────────────┐  └────────────────────────────────┘│
+│  │  💡 Light  [ON] / [OFF] │                                    │
+│  └─────────────────────────┘                                    │
+│                                                                  │
+│  ┌──────────────────────────────────────────────────────────────┐│
+│  │  AMS                                                         ││
+│  │  ┌────┐ ┌────┐ ┌────┐ ┌────┐    [Load] [Unload]             ││
+│  │  │ 1  │ │ 2  │ │ 3  │ │ 4  │                                ││
+│  │  │ 🔴 │ │ 🔵 │ │ ⚪ │ │ ⬛ │    Selected: Slot 1 (PLA Red)  ││
+│  │  │80% │ │45% │ │100%│ │ -- │                                ││
+│  │  └────┘ └────┘ └────┘ └────┘                                ││
+│  └──────────────────────────────────────────────────────────────┘│
+└──────────────────────────────────────────────────────────────────┘
+```
+
+### 4.3 Mobile Layout
+
+Stacked vertically:
+1. Camera (full width)
+2. Print controls
+3. Temperatures (collapsible)
+4. Speed/Fans (collapsible)
+5. Movement (collapsible)
+6. AMS (collapsible)
+
+### 4.4 State Management
+
+Use React Query for:
+- Printer status (already exists, real-time via WebSocket)
+- Control mutations with optimistic updates
+
+```typescript
+// Example mutation
+const pausePrint = useMutation({
+  mutationFn: (printerId: number) =>
+    api.post(`/printers/${printerId}/control/pause`),
+  onSuccess: () => {
+    // Optimistic: printer status will update via WebSocket
+  }
+});
+```
+
+---
+
+## Phase 5: Component Details
+
+### 5.1 CameraFeed Component
+
+```typescript
+interface CameraFeedProps {
+  printerId: number;
+  enabled: boolean;
+}
+
+// Features:
+// - MJPEG stream from /api/v1/printers/{id}/camera/stream
+// - Fallback to static thumbnail if stream fails
+// - Loading state with skeleton
+// - Click to fullscreen
+// - Optional: snapshot button
+```
+
+### 5.2 TemperaturePanel Component
+
+```typescript
+interface TemperaturePanelProps {
+  printerId: number;
+  bed: { current: number; target: number };
+  nozzle: { current: number; target: number };
+  nozzle2?: { current: number; target: number }; // H2D
+  chamber?: number;
+}
+
+// Features:
+// - Visual temperature bars (current vs target)
+// - Input field or +/- buttons for target
+// - Presets: Off (0), PLA (60/200), PETG (70/230), ABS (90/250)
+// - Debounced API calls (don't spam on rapid clicks)
+// - Disable controls during print (optional setting)
+```
+
+### 5.3 SpeedControl Component
+
+```typescript
+// Speed modes as toggle buttons:
+// [Silent] [Standard] [Sport] [Ludicrous]
+// Visual feedback for current mode
+// Warning tooltip for Ludicrous mode
+```
+
+### 5.4 FanControls Component
+
+```typescript
+// Sliders for each fan (0-100%)
+// Convert to 0-255 for API
+// Real-time value display
+// Disable chamber fan if not available (check model)
+```
+
+### 5.5 MovementControls Component
+
+```typescript
+// Grid layout:
+//        [Y+10] [Y+1]
+// [X-10] [X-1] [Home] [X+1] [X+10]
+//        [Y-1] [Y-10]
+//                    [Z+10] [Z+1] [Z-1] [Z-10]
+//
+// [Disable Motors] button with confirmation
+// Warning: "Movement controls disabled during print" overlay
+```
+
+### 5.6 AMSPanel Component
+
+```typescript
+// Visual representation matching Bambu style:
+// - 4 slots per AMS unit
+// - Color-coded by filament
+// - Percentage remaining
+// - Active slot indicator (animated)
+// - Click to select slot
+// - [Load Selected] [Unload] buttons
+// - Support for external spool indicator
+```
+
+---
+
+## Phase 6: Safety Features
+
+### 6.1 Confirmation Dialogs
+
+Required for:
+- **Stop Print**: "This will abort the current print. Are you sure?"
+- **Home During Print**: "Homing during a print is not recommended. Continue?"
+- **Move During Print**: "Manual movement during printing can damage your print. Continue?"
+- **Disable Motors**: "This will disable motors and lose position. Home before next print."
+- **High Temperatures**: Warning for temps > 260°C nozzle or > 100°C bed
+
+### 6.2 State-Based Disabling
+
+| Control | IDLE | RUNNING | PAUSE | FINISH |
+|---------|------|---------|-------|--------|
+| Pause | ❌ | ✅ | ❌ | ❌ |
+| Resume | ❌ | ❌ | ✅ | ❌ |
+| Stop | ❌ | ✅ | ✅ | ❌ |
+| Temp Control | ✅ | ⚠️ | ✅ | ✅ |
+| Speed | ❌ | ✅ | ❌ | ❌ |
+| Fans | ✅ | ⚠️ | ✅ | ✅ |
+| Movement | ✅ | ❌ | ⚠️ | ✅ |
+| AMS Load | ✅ | ❌ | ❌ | ✅ |
+
+⚠️ = Allowed with warning
+
+---
+
+## Phase 7: WebSocket Updates
+
+### 7.1 Extended Status Data
+
+Ensure these fields are included in printer status broadcasts:
+
+```typescript
+interface PrinterStatus {
+  // Existing
+  state: string;
+  progress: number;
+  remaining_time: number;
+  temperatures: {...};
+
+  // Add for control page
+  print_speed_mode: number;      // 1-4
+  fan_speeds: {
+    part: number;      // 0-255
+    aux: number;
+    chamber: number;
+  };
+  light_state: boolean;
+  ams_status: {
+    units: [{
+      id: number;
+      trays: [{
+        id: number;
+        color: string;      // hex
+        type: string;       // PLA, PETG, etc
+        remaining: number;  // percentage
+        active: boolean;
+      }];
+    }];
+    current_tray: number;
+  };
+  position?: {
+    x: number;
+    y: number;
+    z: number;
+  };
+}
+```
+
+---
+
+## Implementation Order
+
+1. **Backend MQTT commands** - Add all control methods
+2. **Backend API endpoints** - Create control routes with safety
+3. **Backend camera streaming** - MJPEG endpoint
+4. **Frontend ControlPage** - Basic structure with tabs
+5. **Frontend CameraFeed** - Live stream component
+6. **Frontend PrintControls** - Pause/Resume/Stop
+7. **Frontend TemperaturePanel** - Temp controls
+8. **Frontend SpeedControl** - Speed mode
+9. **Frontend FanControls** - Fan sliders
+10. **Frontend LightToggle** - Light switch
+11. **Frontend MovementControls** - Jog buttons
+12. **Frontend AMSPanel** - AMS visualization
+13. **Navigation integration** - Add to sidebar
+14. **Testing & refinement** - All printer models
+
+---
+
+## Files to Create/Modify
+
+### New Files
+```
+backend/app/api/routes/printer_control.py
+backend/app/api/routes/camera.py
+backend/app/schemas/control.py
+frontend/src/pages/ControlPage.tsx
+frontend/src/components/control/CameraFeed.tsx
+frontend/src/components/control/PrintControls.tsx
+frontend/src/components/control/TemperaturePanel.tsx
+frontend/src/components/control/SpeedControl.tsx
+frontend/src/components/control/FanControls.tsx
+frontend/src/components/control/LightToggle.tsx
+frontend/src/components/control/MovementControls.tsx
+frontend/src/components/control/AMSPanel.tsx
+frontend/src/components/control/ConfirmDialog.tsx
+```
+
+### Modified Files
+```
+backend/app/services/bambu_mqtt.py     # Add control methods
+backend/app/api/routes/__init__.py     # Register new routes
+backend/app/main.py                    # Include new router
+backend/app/schemas/printer.py         # Extend status schema
+frontend/src/App.tsx                   # Add route
+frontend/src/components/Sidebar.tsx    # Add nav item
+frontend/src/api/client.ts             # Add control API calls
+```
+
+---
+
+## Estimated Scope
+
+- Backend: ~500 lines new code
+- Frontend: ~1500 lines new code
+- Total: ~2000 lines
+
+Ready to begin implementation?

+ 256 - 0
backend/app/api/routes/camera.py

@@ -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

+ 555 - 0
backend/app/api/routes/printer_control.py

@@ -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"
+    )

+ 3 - 1
backend/app/main.py

@@ -54,7 +54,7 @@ from fastapi.responses import FileResponse
 from backend.app.core.database import init_db, async_session
 from sqlalchemy import select, or_
 from backend.app.core.websocket import ws_manager
-from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, spoolman, updates, maintenance
+from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, spoolman, updates, maintenance, printer_control, camera
 from backend.app.api.routes import settings as settings_routes
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import (
@@ -1021,6 +1021,8 @@ app.include_router(notifications.router, prefix=app_settings.api_prefix)
 app.include_router(spoolman.router, prefix=app_settings.api_prefix)
 app.include_router(updates.router, prefix=app_settings.api_prefix)
 app.include_router(maintenance.router, prefix=app_settings.api_prefix)
+app.include_router(printer_control.router, prefix=app_settings.api_prefix)
+app.include_router(camera.router, prefix=app_settings.api_prefix)
 app.include_router(websocket.router, prefix=app_settings.api_prefix)
 
 

+ 304 - 0
backend/app/services/bambu_mqtt.py

@@ -840,3 +840,307 @@ class BambuMQTTClient:
         # Use QoS 1 for reliable delivery (at least once)
         self._client.publish(self.topic_publish, command_json, qos=1)
         return True
+
+    # =========================================================================
+    # Printer Control Commands
+    # =========================================================================
+
+    def pause_print(self) -> bool:
+        """Pause the current print job."""
+        if not self._client or not self.state.connected:
+            logger.warning(f"[{self.serial_number}] Cannot pause print: not connected")
+            return False
+
+        command = {
+            "print": {
+                "command": "pause",
+                "sequence_id": "0"
+            }
+        }
+        self._client.publish(self.topic_publish, json.dumps(command), qos=1)
+        logger.info(f"[{self.serial_number}] Sent pause print command")
+        return True
+
+    def resume_print(self) -> bool:
+        """Resume a paused print job."""
+        if not self._client or not self.state.connected:
+            logger.warning(f"[{self.serial_number}] Cannot resume print: not connected")
+            return False
+
+        command = {
+            "print": {
+                "command": "resume",
+                "sequence_id": "0"
+            }
+        }
+        self._client.publish(self.topic_publish, json.dumps(command), qos=1)
+        logger.info(f"[{self.serial_number}] Sent resume print command")
+        return True
+
+    def send_gcode(self, gcode: str) -> bool:
+        """Send G-code command(s) to the printer.
+
+        Multiple commands can be separated by newlines.
+
+        Args:
+            gcode: G-code command(s) to send
+
+        Returns:
+            True if command was sent, False otherwise
+        """
+        if not self._client or not self.state.connected:
+            logger.warning(f"[{self.serial_number}] Cannot send G-code: not connected")
+            return False
+
+        self._sequence_id += 1
+        command = {
+            "print": {
+                "command": "gcode_line",
+                "param": gcode,
+                "sequence_id": str(self._sequence_id)
+            }
+        }
+        self._client.publish(self.topic_publish, json.dumps(command))
+        logger.debug(f"[{self.serial_number}] Sent G-code: {gcode[:50]}...")
+        return True
+
+    def set_bed_temperature(self, target: int) -> bool:
+        """Set the bed target temperature.
+
+        Args:
+            target: Target temperature in Celsius (0 to turn off)
+
+        Returns:
+            True if command was sent, False otherwise
+        """
+        # Use M140 for non-blocking (preferred when not waiting)
+        # Note: P1/A1 series with newer firmware may need M190 (blocking)
+        return self.send_gcode(f"M140 S{target}")
+
+    def set_nozzle_temperature(self, target: int, nozzle: int = 0) -> bool:
+        """Set the nozzle target temperature.
+
+        Args:
+            target: Target temperature in Celsius (0 to turn off)
+            nozzle: Nozzle index (0 for primary, 1 for secondary on H2D)
+
+        Returns:
+            True if command was sent, False otherwise
+        """
+        # Use M104 for non-blocking
+        # For dual nozzle (H2D), T parameter selects the tool
+        if nozzle == 0:
+            return self.send_gcode(f"M104 S{target}")
+        else:
+            return self.send_gcode(f"M104 T{nozzle} S{target}")
+
+    def set_print_speed(self, mode: int) -> bool:
+        """Set the print speed mode.
+
+        Args:
+            mode: Speed mode (1=silent, 2=standard, 3=sport, 4=ludicrous)
+
+        Returns:
+            True if command was sent, False otherwise
+        """
+        if not self._client or not self.state.connected:
+            logger.warning(f"[{self.serial_number}] Cannot set print speed: not connected")
+            return False
+
+        if mode not in (1, 2, 3, 4):
+            logger.warning(f"[{self.serial_number}] Invalid speed mode: {mode}")
+            return False
+
+        command = {
+            "print": {
+                "command": "print_speed",
+                "param": str(mode),
+                "sequence_id": "0"
+            }
+        }
+        self._client.publish(self.topic_publish, json.dumps(command))
+        logger.info(f"[{self.serial_number}] Set print speed mode to {mode}")
+        return True
+
+    def set_fan_speed(self, fan: int, speed: int) -> bool:
+        """Set fan speed.
+
+        Args:
+            fan: Fan index (1=part cooling, 2=auxiliary, 3=chamber)
+            speed: Speed 0-255 (0=off, 255=full)
+
+        Returns:
+            True if command was sent, False otherwise
+        """
+        if fan not in (1, 2, 3):
+            logger.warning(f"[{self.serial_number}] Invalid fan index: {fan}")
+            return False
+
+        speed = max(0, min(255, speed))  # Clamp to 0-255
+        return self.send_gcode(f"M106 P{fan} S{speed}")
+
+    def set_part_fan(self, speed: int) -> bool:
+        """Set part cooling fan speed (0-255)."""
+        return self.set_fan_speed(1, speed)
+
+    def set_aux_fan(self, speed: int) -> bool:
+        """Set auxiliary fan speed (0-255)."""
+        return self.set_fan_speed(2, speed)
+
+    def set_chamber_fan(self, speed: int) -> bool:
+        """Set chamber fan speed (0-255)."""
+        return self.set_fan_speed(3, speed)
+
+    def set_chamber_light(self, on: bool) -> bool:
+        """Turn chamber light on or off.
+
+        Args:
+            on: True to turn on, False to turn off
+
+        Returns:
+            True if command was sent, False otherwise
+        """
+        if not self._client or not self.state.connected:
+            logger.warning(f"[{self.serial_number}] Cannot set chamber light: not connected")
+            return False
+
+        command = {
+            "system": {
+                "command": "ledctrl",
+                "led_node": "chamber_light",
+                "led_mode": "on" if on else "off",
+                "led_on_time": 500,
+                "led_off_time": 500,
+                "loop_times": 0,
+                "interval_time": 0,
+                "sequence_id": "0"
+            }
+        }
+        self._client.publish(self.topic_publish, json.dumps(command))
+        logger.info(f"[{self.serial_number}] Set chamber light {'on' if on else 'off'}")
+        return True
+
+    def home_axes(self, axes: str = "XYZ") -> bool:
+        """Home the specified axes.
+
+        Args:
+            axes: Axes to home (e.g., "XYZ", "X", "XY", "Z")
+
+        Returns:
+            True if command was sent, False otherwise
+        """
+        # G28 homes all axes, G28 X Y Z homes specific axes
+        axes_param = " ".join(axes.upper())
+        return self.send_gcode(f"G28 {axes_param}")
+
+    def move_axis(self, axis: str, distance: float, speed: int = 3000) -> bool:
+        """Move an axis by a relative distance.
+
+        Args:
+            axis: Axis to move ("X", "Y", or "Z")
+            distance: Distance to move in mm (positive or negative)
+            speed: Movement speed in mm/min
+
+        Returns:
+            True if command was sent, False otherwise
+        """
+        axis = axis.upper()
+        if axis not in ("X", "Y", "Z"):
+            logger.warning(f"[{self.serial_number}] Invalid axis: {axis}")
+            return False
+
+        # G91 = relative mode, G0 = rapid move, G90 = back to absolute
+        gcode = f"G91\nG0 {axis}{distance:.2f} F{speed}\nG90"
+        return self.send_gcode(gcode)
+
+    def disable_motors(self) -> bool:
+        """Disable all stepper motors.
+
+        Warning: This will cause the printer to lose its position.
+        A homing operation will be required before printing.
+
+        Returns:
+            True if command was sent, False otherwise
+        """
+        return self.send_gcode("M18")
+
+    def enable_motors(self) -> bool:
+        """Enable all stepper motors.
+
+        Returns:
+            True if command was sent, False otherwise
+        """
+        return self.send_gcode("M17")
+
+    def ams_load_filament(self, tray_id: int) -> bool:
+        """Load filament from a specific AMS tray.
+
+        Args:
+            tray_id: Tray ID (0-15 for AMS slots, or 254 for external spool)
+
+        Returns:
+            True if command was sent, False otherwise
+        """
+        if not self._client or not self.state.connected:
+            logger.warning(f"[{self.serial_number}] Cannot load filament: not connected")
+            return False
+
+        command = {
+            "print": {
+                "command": "ams_change_filament",
+                "target": tray_id,
+                "sequence_id": "0"
+            }
+        }
+        self._client.publish(self.topic_publish, json.dumps(command))
+        logger.info(f"[{self.serial_number}] Loading filament from tray {tray_id}")
+        return True
+
+    def ams_unload_filament(self) -> bool:
+        """Unload the currently loaded filament.
+
+        Returns:
+            True if command was sent, False otherwise
+        """
+        if not self._client or not self.state.connected:
+            logger.warning(f"[{self.serial_number}] Cannot unload filament: not connected")
+            return False
+
+        command = {
+            "print": {
+                "command": "ams_change_filament",
+                "target": 255,  # 255 = unload
+                "sequence_id": "0"
+            }
+        }
+        self._client.publish(self.topic_publish, json.dumps(command))
+        logger.info(f"[{self.serial_number}] Unloading filament")
+        return True
+
+    def ams_control(self, action: str) -> bool:
+        """Control AMS operations.
+
+        Args:
+            action: "resume", "reset", or "pause"
+
+        Returns:
+            True if command was sent, False otherwise
+        """
+        if not self._client or not self.state.connected:
+            logger.warning(f"[{self.serial_number}] Cannot control AMS: not connected")
+            return False
+
+        if action not in ("resume", "reset", "pause"):
+            logger.warning(f"[{self.serial_number}] Invalid AMS action: {action}")
+            return False
+
+        command = {
+            "print": {
+                "command": "ams_control",
+                "param": action,
+                "sequence_id": "0"
+            }
+        }
+        self._client.publish(self.topic_publish, json.dumps(command))
+        logger.info(f"[{self.serial_number}] AMS control: {action}")
+        return True

+ 2 - 0
frontend/src/App.tsx

@@ -8,6 +8,7 @@ import { StatsPage } from './pages/StatsPage';
 import { SettingsPage } from './pages/SettingsPage';
 import { ProfilesPage } from './pages/ProfilesPage';
 import { MaintenancePage } from './pages/MaintenancePage';
+import { ControlPage } from './pages/ControlPage';
 import { useWebSocket } from './hooks/useWebSocket';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ToastProvider } from './contexts/ToastContext';
@@ -41,6 +42,7 @@ function App() {
                   <Route path="stats" element={<StatsPage />} />
                   <Route path="profiles" element={<ProfilesPage />} />
                   <Route path="maintenance" element={<MaintenancePage />} />
+                  <Route path="control" element={<ControlPage />} />
                   <Route path="settings" element={<SettingsPage />} />
                 </Route>
               </Routes>

+ 100 - 0
frontend/src/api/client.ts

@@ -523,6 +523,25 @@ export interface UpdateStatus {
   error: string | null;
 }
 
+// Printer Control types
+export interface ControlResponse {
+  success: boolean;
+  message: string;
+}
+
+export interface ConfirmationRequired {
+  requires_confirmation: boolean;
+  token: string;
+  warning: string;
+  expires_in: number;
+}
+
+export type ControlResult = ControlResponse | ConfirmationRequired;
+
+export function isConfirmationRequired(result: ControlResult): result is ConfirmationRequired {
+  return 'requires_confirmation' in result && result.requires_confirmation === true;
+}
+
 // Maintenance types
 export interface MaintenanceType {
   id: number;
@@ -1045,4 +1064,85 @@ export const api = {
       `/maintenance/printers/${printerId}/hours?total_hours=${totalHours}`,
       { method: 'PATCH' }
     ),
+
+  // Printer Control
+  pausePrint: (printerId: number) =>
+    request<ControlResponse>(`/printers/${printerId}/control/pause`, { method: 'POST' }),
+  resumePrint: (printerId: number) =>
+    request<ControlResponse>(`/printers/${printerId}/control/resume`, { method: 'POST' }),
+  stopPrint: (printerId: number, confirmToken?: string) =>
+    request<ControlResult>(`/printers/${printerId}/control/stop`, {
+      method: 'POST',
+      body: JSON.stringify({ confirm_token: confirmToken }),
+    }),
+  setBedTemperature: (printerId: number, target: number, confirmToken?: string) =>
+    request<ControlResult>(`/printers/${printerId}/control/temperature/bed`, {
+      method: 'POST',
+      body: JSON.stringify({ target, confirm_token: confirmToken }),
+    }),
+  setNozzleTemperature: (printerId: number, target: number, nozzle = 0, confirmToken?: string) =>
+    request<ControlResult>(`/printers/${printerId}/control/temperature/nozzle`, {
+      method: 'POST',
+      body: JSON.stringify({ target, nozzle, confirm_token: confirmToken }),
+    }),
+  setPrintSpeed: (printerId: number, mode: number) =>
+    request<ControlResponse>(`/printers/${printerId}/control/speed`, {
+      method: 'POST',
+      body: JSON.stringify({ mode }),
+    }),
+  setPartFan: (printerId: number, speed: number) =>
+    request<ControlResponse>(`/printers/${printerId}/control/fan/part`, {
+      method: 'POST',
+      body: JSON.stringify({ speed }),
+    }),
+  setAuxFan: (printerId: number, speed: number) =>
+    request<ControlResponse>(`/printers/${printerId}/control/fan/aux`, {
+      method: 'POST',
+      body: JSON.stringify({ speed }),
+    }),
+  setChamberFan: (printerId: number, speed: number) =>
+    request<ControlResponse>(`/printers/${printerId}/control/fan/chamber`, {
+      method: 'POST',
+      body: JSON.stringify({ speed }),
+    }),
+  setChamberLight: (printerId: number, on: boolean) =>
+    request<ControlResponse>(`/printers/${printerId}/control/light`, {
+      method: 'POST',
+      body: JSON.stringify({ on }),
+    }),
+  homeAxes: (printerId: number, axes = 'XYZ', confirmToken?: string) =>
+    request<ControlResult>(`/printers/${printerId}/control/home`, {
+      method: 'POST',
+      body: JSON.stringify({ axes, confirm_token: confirmToken }),
+    }),
+  moveAxis: (printerId: number, axis: string, distance: number, speed = 3000, confirmToken?: string) =>
+    request<ControlResult>(`/printers/${printerId}/control/move`, {
+      method: 'POST',
+      body: JSON.stringify({ axis, distance, speed, confirm_token: confirmToken }),
+    }),
+  disableMotors: (printerId: number, confirmToken?: string) =>
+    request<ControlResult>(`/printers/${printerId}/control/motors/disable`, {
+      method: 'POST',
+      body: JSON.stringify({ confirm_token: confirmToken }),
+    }),
+  enableMotors: (printerId: number) =>
+    request<ControlResponse>(`/printers/${printerId}/control/motors/enable`, { method: 'POST' }),
+  amsLoadFilament: (printerId: number, trayId: number) =>
+    request<ControlResponse>(`/printers/${printerId}/control/ams/load`, {
+      method: 'POST',
+      body: JSON.stringify({ tray_id: trayId }),
+    }),
+  amsUnloadFilament: (printerId: number) =>
+    request<ControlResponse>(`/printers/${printerId}/control/ams/unload`, { method: 'POST' }),
+  sendGcode: (printerId: number, command: string, confirmToken?: string) =>
+    request<ControlResult>(`/printers/${printerId}/control/gcode`, {
+      method: 'POST',
+      body: JSON.stringify({ command, confirm_token: confirmToken }),
+    }),
+  getCameraStreamUrl: (printerId: number, fps = 10) =>
+    `${API_BASE}/printers/${printerId}/camera/stream?fps=${fps}`,
+  getCameraSnapshotUrl: (printerId: number) =>
+    `${API_BASE}/printers/${printerId}/camera/snapshot`,
+  testCameraConnection: (printerId: number) =>
+    request<{ success: boolean; message?: string; error?: string }>(`/printers/${printerId}/camera/test`),
 };

+ 2 - 1
frontend/src/components/Layout.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useCallback, useRef } from 'react';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
-import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, type LucideIcon } from 'lucide-react';
+import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, Gamepad2, type LucideIcon } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -16,6 +16,7 @@ interface NavItem {
 
 export const defaultNavItems: NavItem[] = [
   { id: 'printers', to: '/', icon: Printer, labelKey: 'nav.printers' },
+  { id: 'control', to: '/control', icon: Gamepad2, labelKey: 'nav.control' },
   { id: 'archives', to: '/archives', icon: Archive, labelKey: 'nav.archives' },
   { id: 'queue', to: '/queue', icon: Calendar, labelKey: 'nav.queue' },
   { id: 'stats', to: '/stats', icon: BarChart3, labelKey: 'nav.stats' },

+ 223 - 0
frontend/src/components/control/AMSPanel.tsx

@@ -0,0 +1,223 @@
+import { useState } from 'react';
+import { useMutation } from '@tanstack/react-query';
+import { api } from '../../api/client';
+import type { PrinterStatus } from '../../api/client';
+import { Package, Loader2, ArrowDown, ArrowUp } from 'lucide-react';
+
+interface AMSPanelProps {
+  printerId: number;
+  status: PrinterStatus | null | undefined;
+}
+
+interface AMSTray {
+  id: number;
+  color: string;
+  type: string;
+  remain: number;
+  active: boolean;
+}
+
+interface AMSUnit {
+  id: number;
+  trays: AMSTray[];
+}
+
+// Parse AMS data from raw_data in status
+function parseAMSData(status: PrinterStatus | null | undefined): AMSUnit[] {
+  // AMS data comes from raw MQTT data
+  // This is a simplified parser - actual data structure may vary
+  const rawData = (status as { raw_data?: { ams?: unknown[] } })?.raw_data;
+  if (!rawData?.ams) return [];
+
+  try {
+    return (rawData.ams as Array<{
+      id?: number;
+      tray?: Array<{
+        id?: number;
+        tray_color?: string;
+        tray_type?: string;
+        remain?: number;
+      }>;
+    }>).map((unit) => ({
+      id: unit.id ?? 0,
+      trays: (unit.tray ?? []).map((tray) => ({
+        id: tray.id ?? 0,
+        color: tray.tray_color ?? 'FFFFFF',
+        type: tray.tray_type ?? 'Unknown',
+        remain: tray.remain ?? 0,
+        active: false, // Would need additional MQTT data to determine
+      })),
+    }));
+  } catch {
+    return [];
+  }
+}
+
+function hexToRgb(hex: string): string {
+  // Handle RRGGBBAA format
+  const cleanHex = hex.replace('#', '').substring(0, 6);
+  const r = parseInt(cleanHex.substring(0, 2), 16) || 128;
+  const g = parseInt(cleanHex.substring(2, 4), 16) || 128;
+  const b = parseInt(cleanHex.substring(4, 6), 16) || 128;
+  return `rgb(${r}, ${g}, ${b})`;
+}
+
+export function AMSPanel({ printerId, status }: AMSPanelProps) {
+  const isConnected = status?.connected ?? false;
+  const isPrinting = status?.state === 'RUNNING';
+  const amsUnits = parseAMSData(status);
+
+  const [selectedTray, setSelectedTray] = useState<number | null>(null);
+
+  const loadMutation = useMutation({
+    mutationFn: (trayId: number) => api.amsLoadFilament(printerId, trayId),
+  });
+
+  const unloadMutation = useMutation({
+    mutationFn: () => api.amsUnloadFilament(printerId),
+  });
+
+  const handleLoad = () => {
+    if (selectedTray !== null) {
+      loadMutation.mutate(selectedTray);
+    }
+  };
+
+  const handleUnload = () => {
+    unloadMutation.mutate();
+  };
+
+  const isLoading = loadMutation.isPending || unloadMutation.isPending;
+
+  if (amsUnits.length === 0) {
+    return (
+      <div className="bg-bambu-dark-secondary rounded-lg p-4">
+        <div className="flex items-center gap-2 mb-4">
+          <Package className="w-4 h-4 text-bambu-gray" />
+          <h3 className="text-sm font-medium">AMS</h3>
+        </div>
+        <p className="text-sm text-bambu-gray text-center py-4">
+          No AMS detected or using external spool
+        </p>
+      </div>
+    );
+  }
+
+  return (
+    <div className="bg-bambu-dark-secondary rounded-lg p-4">
+      <div className="flex items-center justify-between mb-4">
+        <div className="flex items-center gap-2">
+          <Package className="w-4 h-4 text-bambu-gray" />
+          <h3 className="text-sm font-medium">AMS</h3>
+        </div>
+        {isLoading && <Loader2 className="w-4 h-4 animate-spin text-bambu-green" />}
+      </div>
+
+      {/* AMS Units */}
+      {amsUnits.map((unit) => (
+        <div key={unit.id} className="mb-4">
+          {amsUnits.length > 1 && (
+            <div className="text-xs text-bambu-gray mb-2">AMS {unit.id + 1}</div>
+          )}
+          <div className="grid grid-cols-4 gap-2">
+            {unit.trays.map((tray) => {
+              const globalTrayId = unit.id * 4 + tray.id;
+              const isSelected = selectedTray === globalTrayId;
+              const isEmpty = tray.type === 'NONE' || !tray.type;
+
+              return (
+                <button
+                  key={tray.id}
+                  onClick={() => !isEmpty && setSelectedTray(isSelected ? null : globalTrayId)}
+                  disabled={isEmpty || isPrinting}
+                  className={`relative p-2 rounded-lg transition-all ${
+                    isSelected
+                      ? 'ring-2 ring-bambu-green bg-bambu-dark'
+                      : 'bg-bambu-dark hover:bg-bambu-dark-tertiary'
+                  } ${isEmpty ? 'opacity-50' : ''} disabled:cursor-not-allowed`}
+                >
+                  {/* Color Indicator */}
+                  <div
+                    className="w-8 h-8 mx-auto rounded-full mb-1 border-2 border-bambu-dark-tertiary"
+                    style={{
+                      backgroundColor: isEmpty ? '#333' : hexToRgb(tray.color),
+                    }}
+                  />
+
+                  {/* Tray Number */}
+                  <div className="text-xs text-center text-bambu-gray">
+                    {tray.id + 1}
+                  </div>
+
+                  {/* Type */}
+                  <div className="text-xs text-center truncate" title={tray.type}>
+                    {isEmpty ? '--' : tray.type}
+                  </div>
+
+                  {/* Remaining */}
+                  {!isEmpty && (
+                    <div className="mt-1">
+                      <div className="h-1 bg-bambu-dark-tertiary rounded-full overflow-hidden">
+                        <div
+                          className="h-full bg-bambu-green"
+                          style={{ width: `${Math.min(100, tray.remain)}%` }}
+                        />
+                      </div>
+                      <div className="text-[10px] text-center text-bambu-gray mt-0.5">
+                        {tray.remain}%
+                      </div>
+                    </div>
+                  )}
+
+                  {/* Active Indicator */}
+                  {tray.active && (
+                    <div className="absolute top-1 right-1 w-2 h-2 rounded-full bg-bambu-green animate-pulse" />
+                  )}
+                </button>
+              );
+            })}
+          </div>
+        </div>
+      ))}
+
+      {/* Load/Unload Controls */}
+      <div className="flex gap-2 mt-4">
+        <button
+          onClick={handleLoad}
+          disabled={!isConnected || isPrinting || selectedTray === null || isLoading}
+          className="flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+        >
+          <ArrowDown className="w-4 h-4" />
+          <span className="text-sm">Load</span>
+        </button>
+        <button
+          onClick={handleUnload}
+          disabled={!isConnected || isPrinting || isLoading}
+          className="flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded bg-bambu-dark hover:bg-bambu-dark-tertiary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+        >
+          <ArrowUp className="w-4 h-4" />
+          <span className="text-sm">Unload</span>
+        </button>
+      </div>
+
+      {selectedTray !== null && (
+        <p className="mt-2 text-xs text-bambu-gray text-center">
+          Selected: Slot {(selectedTray % 4) + 1}
+          {amsUnits.length > 1 && ` (AMS ${Math.floor(selectedTray / 4) + 1})`}
+        </p>
+      )}
+
+      {isPrinting && (
+        <p className="mt-2 text-xs text-yellow-500 text-center">
+          Filament change disabled during print
+        </p>
+      )}
+
+      {(loadMutation.error || unloadMutation.error) && (
+        <p className="mt-2 text-sm text-red-400">
+          {(loadMutation.error || unloadMutation.error)?.message}
+        </p>
+      )}
+    </div>
+  );
+}

+ 138 - 0
frontend/src/components/control/CameraFeed.tsx

@@ -0,0 +1,138 @@
+import { useState, useRef } from 'react';
+import { api } from '../../api/client';
+import { Camera, CameraOff, Maximize2, RefreshCw, Loader2 } from 'lucide-react';
+
+interface CameraFeedProps {
+  printerId: number;
+  isConnected: boolean;
+}
+
+export function CameraFeed({ printerId, isConnected }: CameraFeedProps) {
+  const [streamEnabled, setStreamEnabled] = useState(false);
+  const [isLoading, setIsLoading] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+  const imgRef = useRef<HTMLImageElement>(null);
+
+  const streamUrl = api.getCameraStreamUrl(printerId, 10);
+
+  const handleToggleStream = () => {
+    if (streamEnabled) {
+      setStreamEnabled(false);
+      setError(null);
+    } else {
+      setIsLoading(true);
+      setError(null);
+      setStreamEnabled(true);
+    }
+  };
+
+  const handleImageLoad = () => {
+    setIsLoading(false);
+    setError(null);
+  };
+
+  const handleImageError = () => {
+    setIsLoading(false);
+    setError('Failed to load camera stream');
+  };
+
+  const handleFullscreen = () => {
+    if (imgRef.current) {
+      if (document.fullscreenElement) {
+        document.exitFullscreen();
+      } else {
+        imgRef.current.requestFullscreen();
+      }
+    }
+  };
+
+  const handleRefresh = () => {
+    setStreamEnabled(false);
+    setTimeout(() => {
+      setIsLoading(true);
+      setStreamEnabled(true);
+    }, 100);
+  };
+
+  return (
+    <div className="bg-bambu-dark-secondary rounded-lg overflow-hidden">
+      <div className="flex items-center justify-between px-4 py-2 border-b border-bambu-dark-tertiary">
+        <div className="flex items-center gap-2">
+          <Camera className="w-4 h-4 text-bambu-gray" />
+          <span className="text-sm font-medium">Camera</span>
+        </div>
+        <div className="flex items-center gap-2">
+          {streamEnabled && (
+            <>
+              <button
+                onClick={handleRefresh}
+                className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
+                title="Refresh stream"
+              >
+                <RefreshCw className="w-4 h-4" />
+              </button>
+              <button
+                onClick={handleFullscreen}
+                className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
+                title="Fullscreen"
+              >
+                <Maximize2 className="w-4 h-4" />
+              </button>
+            </>
+          )}
+          <button
+            onClick={handleToggleStream}
+            disabled={!isConnected}
+            className={`px-3 py-1 rounded text-sm transition-colors ${
+              streamEnabled
+                ? 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
+                : 'bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30'
+            } disabled:opacity-50 disabled:cursor-not-allowed`}
+          >
+            {streamEnabled ? 'Stop' : 'Start'}
+          </button>
+        </div>
+      </div>
+
+      <div className="relative aspect-video bg-bambu-dark">
+        {!streamEnabled ? (
+          <div className="absolute inset-0 flex flex-col items-center justify-center text-bambu-gray">
+            <CameraOff className="w-12 h-12 mb-2" />
+            <span className="text-sm">
+              {isConnected ? 'Click Start to view camera' : 'Printer not connected'}
+            </span>
+          </div>
+        ) : (
+          <>
+            {isLoading && (
+              <div className="absolute inset-0 flex items-center justify-center bg-bambu-dark">
+                <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
+              </div>
+            )}
+            {error ? (
+              <div className="absolute inset-0 flex flex-col items-center justify-center text-red-400">
+                <CameraOff className="w-12 h-12 mb-2" />
+                <span className="text-sm">{error}</span>
+                <button
+                  onClick={handleRefresh}
+                  className="mt-2 text-xs text-bambu-green hover:underline"
+                >
+                  Retry
+                </button>
+              </div>
+            ) : (
+              <img
+                ref={imgRef}
+                src={streamUrl}
+                alt="Camera stream"
+                className="w-full h-full object-contain"
+                onLoad={handleImageLoad}
+                onError={handleImageError}
+              />
+            )}
+          </>
+        )}
+      </div>
+    </div>
+  );
+}

+ 135 - 0
frontend/src/components/control/FanControls.tsx

@@ -0,0 +1,135 @@
+import { useState } from 'react';
+import { useMutation } from '@tanstack/react-query';
+import { api } from '../../api/client';
+import type { PrinterStatus } from '../../api/client';
+import { Fan, Loader2 } from 'lucide-react';
+
+interface FanControlsProps {
+  printerId: number;
+  status: PrinterStatus | null | undefined;
+}
+
+interface FanSliderProps {
+  label: string;
+  value: number;
+  onChange: (value: number) => void;
+  disabled: boolean;
+  isLoading: boolean;
+}
+
+function FanSlider({ label, value, onChange, disabled, isLoading }: FanSliderProps) {
+  const [localValue, setLocalValue] = useState(value);
+  const [isDragging, setIsDragging] = useState(false);
+
+  const displayValue = isDragging ? localValue : value;
+
+  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const newValue = parseInt(e.target.value, 10);
+    setLocalValue(newValue);
+  };
+
+  const handleMouseUp = () => {
+    setIsDragging(false);
+    if (localValue !== value) {
+      onChange(localValue);
+    }
+  };
+
+  return (
+    <div className="p-3 rounded bg-bambu-dark">
+      <div className="flex items-center justify-between mb-2">
+        <span className="text-sm text-bambu-gray">{label}</span>
+        <div className="flex items-center gap-2">
+          <span className="text-sm font-mono text-white">{displayValue}%</span>
+          {isLoading && <Loader2 className="w-3 h-3 animate-spin" />}
+        </div>
+      </div>
+      <input
+        type="range"
+        min="0"
+        max="100"
+        step="5"
+        value={displayValue}
+        onChange={handleChange}
+        onMouseDown={() => setIsDragging(true)}
+        onMouseUp={handleMouseUp}
+        onTouchStart={() => setIsDragging(true)}
+        onTouchEnd={handleMouseUp}
+        disabled={disabled}
+        className="w-full h-2 bg-bambu-dark-tertiary rounded-lg appearance-none cursor-pointer disabled:cursor-not-allowed disabled:opacity-50
+          [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-bambu-green [&::-webkit-slider-thumb]:cursor-pointer
+          [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-bambu-green [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:cursor-pointer"
+      />
+      <div className="flex justify-between text-xs text-bambu-gray mt-1">
+        <span>Off</span>
+        <span>Max</span>
+      </div>
+    </div>
+  );
+}
+
+export function FanControls({ printerId, status }: FanControlsProps) {
+  const isConnected = status?.connected ?? false;
+
+  // Note: Bambu printers don't report fan speeds via MQTT
+  // So we track locally and can't show actual current values
+  const [fanValues, setFanValues] = useState({
+    part: 100,
+    aux: 0,
+    chamber: 0,
+  });
+
+  const partFanMutation = useMutation({
+    mutationFn: (speed: number) => api.setPartFan(printerId, speed),
+    onSuccess: (_, speed) => setFanValues((prev) => ({ ...prev, part: speed })),
+  });
+
+  const auxFanMutation = useMutation({
+    mutationFn: (speed: number) => api.setAuxFan(printerId, speed),
+    onSuccess: (_, speed) => setFanValues((prev) => ({ ...prev, aux: speed })),
+  });
+
+  const chamberFanMutation = useMutation({
+    mutationFn: (speed: number) => api.setChamberFan(printerId, speed),
+    onSuccess: (_, speed) => setFanValues((prev) => ({ ...prev, chamber: speed })),
+  });
+
+  return (
+    <div className="bg-bambu-dark-secondary rounded-lg p-4">
+      <div className="flex items-center gap-2 mb-4">
+        <Fan className="w-4 h-4 text-bambu-gray" />
+        <h3 className="text-sm font-medium">Fans</h3>
+      </div>
+
+      <div className="space-y-3">
+        <FanSlider
+          label="Part Cooling"
+          value={fanValues.part}
+          onChange={(v) => partFanMutation.mutate(v)}
+          disabled={!isConnected}
+          isLoading={partFanMutation.isPending}
+        />
+        <FanSlider
+          label="Auxiliary"
+          value={fanValues.aux}
+          onChange={(v) => auxFanMutation.mutate(v)}
+          disabled={!isConnected}
+          isLoading={auxFanMutation.isPending}
+        />
+        <FanSlider
+          label="Chamber"
+          value={fanValues.chamber}
+          onChange={(v) => chamberFanMutation.mutate(v)}
+          disabled={!isConnected}
+          isLoading={chamberFanMutation.isPending}
+        />
+      </div>
+
+      {(partFanMutation.error || auxFanMutation.error || chamberFanMutation.error) && (
+        <p className="mt-2 text-sm text-red-400">
+          {(partFanMutation.error || auxFanMutation.error || chamberFanMutation.error)?.message}
+        </p>
+      )}
+    </div>
+  );
+}

+ 68 - 0
frontend/src/components/control/LightToggle.tsx

@@ -0,0 +1,68 @@
+import { useState } from 'react';
+import { useMutation } from '@tanstack/react-query';
+import { api } from '../../api/client';
+import type { PrinterStatus } from '../../api/client';
+import { Lightbulb, LightbulbOff, Loader2 } from 'lucide-react';
+
+interface LightToggleProps {
+  printerId: number;
+  status: PrinterStatus | null | undefined;
+}
+
+export function LightToggle({ printerId, status }: LightToggleProps) {
+  const isConnected = status?.connected ?? false;
+
+  // Note: Bambu printers don't report light state via standard MQTT
+  // Track locally
+  const [isOn, setIsOn] = useState(true);
+
+  const lightMutation = useMutation({
+    mutationFn: (on: boolean) => api.setChamberLight(printerId, on),
+    onSuccess: (_, on) => setIsOn(on),
+  });
+
+  const handleToggle = () => {
+    lightMutation.mutate(!isOn);
+  };
+
+  return (
+    <div className="bg-bambu-dark-secondary rounded-lg p-4">
+      <div className="flex items-center justify-between">
+        <div className="flex items-center gap-2">
+          {isOn ? (
+            <Lightbulb className="w-4 h-4 text-yellow-400" />
+          ) : (
+            <LightbulbOff className="w-4 h-4 text-bambu-gray" />
+          )}
+          <h3 className="text-sm font-medium">Chamber Light</h3>
+        </div>
+
+        <button
+          onClick={handleToggle}
+          disabled={!isConnected || lightMutation.isPending}
+          className={`relative w-12 h-6 rounded-full transition-colors ${
+            isOn ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
+          } disabled:opacity-50 disabled:cursor-not-allowed`}
+        >
+          {lightMutation.isPending ? (
+            <div className="absolute inset-0 flex items-center justify-center">
+              <Loader2 className="w-4 h-4 animate-spin" />
+            </div>
+          ) : (
+            <div
+              className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${
+                isOn ? 'translate-x-7' : 'translate-x-1'
+              }`}
+            />
+          )}
+        </button>
+      </div>
+
+      {lightMutation.error && (
+        <p className="mt-2 text-sm text-red-400">
+          {lightMutation.error.message}
+        </p>
+      )}
+    </div>
+  );
+}

+ 235 - 0
frontend/src/components/control/MovementControls.tsx

@@ -0,0 +1,235 @@
+import { useState } from 'react';
+import { useMutation } from '@tanstack/react-query';
+import { api, isConfirmationRequired } from '../../api/client';
+import type { PrinterStatus } from '../../api/client';
+import { Home, Move, Loader2, ChevronUp, ChevronDown, ChevronLeft, ChevronRight, Power } from 'lucide-react';
+import { ConfirmModal } from '../ConfirmModal';
+
+interface MovementControlsProps {
+  printerId: number;
+  status: PrinterStatus | null | undefined;
+}
+
+export function MovementControls({ printerId, status }: MovementControlsProps) {
+  const isConnected = status?.connected ?? false;
+  const isPrinting = status?.state === 'RUNNING' || status?.state === 'PAUSE';
+
+  const [confirmModal, setConfirmModal] = useState<{
+    action: string;
+    token: string;
+    warning: string;
+    onConfirm: () => void;
+  } | null>(null);
+
+  const [moveDistance, setMoveDistance] = useState(10);
+
+  const homeMutation = useMutation({
+    mutationFn: ({ axes, token }: { axes: string; token?: string }) =>
+      api.homeAxes(printerId, axes, token),
+    onSuccess: (result) => {
+      if (isConfirmationRequired(result)) {
+        setConfirmModal({
+          action: 'home',
+          token: result.token,
+          warning: result.warning,
+          onConfirm: () => homeMutation.mutate({ axes: 'XYZ', token: result.token }),
+        });
+      }
+    },
+  });
+
+  const moveMutation = useMutation({
+    mutationFn: ({ axis, distance, token }: { axis: string; distance: number; token?: string }) =>
+      api.moveAxis(printerId, axis, distance, 3000, token),
+    onSuccess: (result, variables) => {
+      if (isConfirmationRequired(result)) {
+        setConfirmModal({
+          action: 'move',
+          token: result.token,
+          warning: result.warning,
+          onConfirm: () =>
+            moveMutation.mutate({
+              axis: variables.axis,
+              distance: variables.distance,
+              token: result.token,
+            }),
+        });
+      }
+    },
+  });
+
+  const disableMotorsMutation = useMutation({
+    mutationFn: (token?: string) => api.disableMotors(printerId, token),
+    onSuccess: (result) => {
+      if (isConfirmationRequired(result)) {
+        setConfirmModal({
+          action: 'disable',
+          token: result.token,
+          warning: result.warning,
+          onConfirm: () => disableMotorsMutation.mutate(result.token),
+        });
+      }
+    },
+  });
+
+  const handleHome = () => {
+    homeMutation.mutate({ axes: 'XYZ' });
+  };
+
+  const handleMove = (axis: string, distance: number) => {
+    moveMutation.mutate({ axis, distance });
+  };
+
+  const handleDisableMotors = () => {
+    disableMotorsMutation.mutate(undefined);
+  };
+
+  const handleConfirm = () => {
+    if (confirmModal) {
+      confirmModal.onConfirm();
+      setConfirmModal(null);
+    }
+  };
+
+  const isLoading =
+    homeMutation.isPending || moveMutation.isPending || disableMotorsMutation.isPending;
+
+  return (
+    <>
+      <div className="bg-bambu-dark-secondary rounded-lg p-4">
+        <div className="flex items-center justify-between mb-4">
+          <div className="flex items-center gap-2">
+            <Move className="w-4 h-4 text-bambu-gray" />
+            <h3 className="text-sm font-medium">Movement</h3>
+          </div>
+          {isLoading && <Loader2 className="w-4 h-4 animate-spin text-bambu-green" />}
+        </div>
+
+        {isPrinting && (
+          <div className="mb-4 p-2 rounded bg-yellow-500/20 text-yellow-500 text-xs text-center">
+            Movement is restricted during printing
+          </div>
+        )}
+
+        {/* Distance Selector */}
+        <div className="flex items-center justify-center gap-2 mb-4">
+          <span className="text-xs text-bambu-gray">Distance:</span>
+          {[1, 10, 50].map((d) => (
+            <button
+              key={d}
+              onClick={() => setMoveDistance(d)}
+              className={`px-3 py-1 rounded text-xs transition-colors ${
+                moveDistance === d
+                  ? 'bg-bambu-green text-white'
+                  : 'bg-bambu-dark-tertiary text-bambu-gray hover:bg-bambu-dark hover:text-white'
+              }`}
+            >
+              {d}mm
+            </button>
+          ))}
+        </div>
+
+        {/* Movement Grid */}
+        <div className="grid grid-cols-5 gap-2 mb-4">
+          {/* Row 1: Y+ */}
+          <div />
+          <div />
+          <button
+            onClick={() => handleMove('Y', moveDistance)}
+            disabled={!isConnected || isLoading}
+            className="p-3 rounded bg-bambu-dark hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
+            title={`Y+${moveDistance}`}
+          >
+            <ChevronUp className="w-5 h-5 mx-auto" />
+          </button>
+          <div />
+          <button
+            onClick={() => handleMove('Z', moveDistance)}
+            disabled={!isConnected || isLoading}
+            className="p-3 rounded bg-bambu-dark hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
+            title={`Z+${moveDistance}`}
+          >
+            <ChevronUp className="w-5 h-5 mx-auto text-blue-400" />
+          </button>
+
+          {/* Row 2: X-, Home, X+ */}
+          <div />
+          <button
+            onClick={() => handleMove('X', -moveDistance)}
+            disabled={!isConnected || isLoading}
+            className="p-3 rounded bg-bambu-dark hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
+            title={`X-${moveDistance}`}
+          >
+            <ChevronLeft className="w-5 h-5 mx-auto" />
+          </button>
+          <button
+            onClick={handleHome}
+            disabled={!isConnected || isLoading}
+            className="p-3 rounded bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 disabled:opacity-50 disabled:cursor-not-allowed"
+            title="Home all axes"
+          >
+            <Home className="w-5 h-5 mx-auto" />
+          </button>
+          <button
+            onClick={() => handleMove('X', moveDistance)}
+            disabled={!isConnected || isLoading}
+            className="p-3 rounded bg-bambu-dark hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
+            title={`X+${moveDistance}`}
+          >
+            <ChevronRight className="w-5 h-5 mx-auto" />
+          </button>
+          <div className="text-center text-xs text-bambu-gray self-center">Z</div>
+
+          {/* Row 3: Y- */}
+          <div />
+          <div />
+          <button
+            onClick={() => handleMove('Y', -moveDistance)}
+            disabled={!isConnected || isLoading}
+            className="p-3 rounded bg-bambu-dark hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
+            title={`Y-${moveDistance}`}
+          >
+            <ChevronDown className="w-5 h-5 mx-auto" />
+          </button>
+          <div />
+          <button
+            onClick={() => handleMove('Z', -moveDistance)}
+            disabled={!isConnected || isLoading}
+            className="p-3 rounded bg-bambu-dark hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
+            title={`Z-${moveDistance}`}
+          >
+            <ChevronDown className="w-5 h-5 mx-auto text-blue-400" />
+          </button>
+        </div>
+
+        {/* Disable Motors */}
+        <button
+          onClick={handleDisableMotors}
+          disabled={!isConnected || isLoading}
+          className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded bg-bambu-dark hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+        >
+          <Power className="w-4 h-4" />
+          <span className="text-sm">Disable Motors</span>
+        </button>
+
+        {(homeMutation.error || moveMutation.error || disableMotorsMutation.error) && (
+          <p className="mt-2 text-sm text-red-400">
+            {(homeMutation.error || moveMutation.error || disableMotorsMutation.error)?.message}
+          </p>
+        )}
+      </div>
+
+      {/* Confirmation Modal */}
+      {confirmModal && (
+        <ConfirmModal
+          title="Confirm Action"
+          message={confirmModal.warning}
+          confirmText="Continue"
+          variant="warning"
+          onConfirm={handleConfirm}
+          onCancel={() => setConfirmModal(null)}
+        />
+      )}
+    </>
+  );
+}

+ 137 - 0
frontend/src/components/control/PrintControls.tsx

@@ -0,0 +1,137 @@
+import { useState } from 'react';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { api, isConfirmationRequired } from '../../api/client';
+import type { PrinterStatus } from '../../api/client';
+import { Pause, Play, Square, Loader2 } from 'lucide-react';
+import { ConfirmModal } from '../ConfirmModal';
+
+interface PrintControlsProps {
+  printerId: number;
+  status: PrinterStatus | null | undefined;
+}
+
+export function PrintControls({ printerId, status }: PrintControlsProps) {
+  const queryClient = useQueryClient();
+  const [confirmModal, setConfirmModal] = useState<{
+    action: string;
+    token: string;
+    warning: string;
+  } | null>(null);
+
+  const isConnected = status?.connected ?? false;
+  const isPrinting = status?.state === 'RUNNING';
+  const isPaused = status?.state === 'PAUSE';
+
+  const pauseMutation = useMutation({
+    mutationFn: () => api.pausePrint(printerId),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['printerStatuses'] });
+    },
+  });
+
+  const resumeMutation = useMutation({
+    mutationFn: () => api.resumePrint(printerId),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['printerStatuses'] });
+    },
+  });
+
+  const stopMutation = useMutation({
+    mutationFn: (token?: string) => api.stopPrint(printerId, token),
+    onSuccess: (result) => {
+      if (isConfirmationRequired(result)) {
+        setConfirmModal({
+          action: 'stop',
+          token: result.token,
+          warning: result.warning,
+        });
+      } else {
+        queryClient.invalidateQueries({ queryKey: ['printerStatuses'] });
+      }
+    },
+  });
+
+  const handleStop = () => {
+    stopMutation.mutate(undefined);
+  };
+
+  const handleConfirmStop = () => {
+    if (confirmModal) {
+      stopMutation.mutate(confirmModal.token);
+      setConfirmModal(null);
+    }
+  };
+
+  const isLoading = pauseMutation.isPending || resumeMutation.isPending || stopMutation.isPending;
+
+  return (
+    <>
+      <div className="bg-bambu-dark-secondary rounded-lg p-4">
+        <h3 className="text-sm font-medium text-bambu-gray mb-3">Print Controls</h3>
+
+        <div className="flex gap-2">
+          {/* Pause Button */}
+          <button
+            onClick={() => pauseMutation.mutate()}
+            disabled={!isConnected || !isPrinting || isLoading}
+            className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg bg-yellow-500/20 text-yellow-500 hover:bg-yellow-500/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+          >
+            {pauseMutation.isPending ? (
+              <Loader2 className="w-5 h-5 animate-spin" />
+            ) : (
+              <Pause className="w-5 h-5" />
+            )}
+            <span className="font-medium">Pause</span>
+          </button>
+
+          {/* Resume Button */}
+          <button
+            onClick={() => resumeMutation.mutate()}
+            disabled={!isConnected || !isPaused || isLoading}
+            className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+          >
+            {resumeMutation.isPending ? (
+              <Loader2 className="w-5 h-5 animate-spin" />
+            ) : (
+              <Play className="w-5 h-5" />
+            )}
+            <span className="font-medium">Resume</span>
+          </button>
+
+          {/* Stop Button */}
+          <button
+            onClick={handleStop}
+            disabled={!isConnected || (!isPrinting && !isPaused) || isLoading}
+            className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg bg-red-500/20 text-red-500 hover:bg-red-500/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+          >
+            {stopMutation.isPending ? (
+              <Loader2 className="w-5 h-5 animate-spin" />
+            ) : (
+              <Square className="w-5 h-5" />
+            )}
+            <span className="font-medium">Stop</span>
+          </button>
+        </div>
+
+        {/* Error Message */}
+        {(pauseMutation.error || resumeMutation.error || stopMutation.error) && (
+          <p className="mt-2 text-sm text-red-400">
+            {(pauseMutation.error || resumeMutation.error || stopMutation.error)?.message}
+          </p>
+        )}
+      </div>
+
+      {/* Confirmation Modal */}
+      {confirmModal && (
+        <ConfirmModal
+          title="Confirm Stop"
+          message={confirmModal.warning}
+          confirmText="Stop Print"
+          variant="danger"
+          onConfirm={handleConfirmStop}
+          onCancel={() => setConfirmModal(null)}
+        />
+      )}
+    </>
+  );
+}

+ 90 - 0
frontend/src/components/control/PrintStatus.tsx

@@ -0,0 +1,90 @@
+import type { PrinterStatus } from '../../api/client';
+import { Clock, Layers, FileText } from 'lucide-react';
+
+interface PrintStatusProps {
+  printerId: number;
+  status: PrinterStatus | null | undefined;
+}
+
+function formatTime(seconds: number | null | undefined): string {
+  if (!seconds) return '--:--';
+  const hours = Math.floor(seconds / 3600);
+  const mins = Math.floor((seconds % 3600) / 60);
+  if (hours > 0) {
+    return `${hours}h ${mins}m`;
+  }
+  return `${mins}m`;
+}
+
+export function PrintStatus({ status }: PrintStatusProps) {
+  const isPrinting = status?.state === 'RUNNING' || status?.state === 'PAUSE';
+  const progress = status?.progress ?? 0;
+
+  return (
+    <div className="bg-bambu-dark-secondary rounded-lg p-4">
+      <div className="flex items-center justify-between mb-3">
+        <h3 className="text-sm font-medium text-bambu-gray">Print Status</h3>
+        <span
+          className={`px-2 py-0.5 rounded text-xs font-medium ${
+            status?.state === 'RUNNING'
+              ? 'bg-bambu-green/20 text-bambu-green'
+              : status?.state === 'PAUSE'
+              ? 'bg-yellow-500/20 text-yellow-500'
+              : status?.state === 'FINISH'
+              ? 'bg-blue-500/20 text-blue-500'
+              : status?.state === 'FAILED'
+              ? 'bg-red-500/20 text-red-500'
+              : 'bg-bambu-dark-tertiary text-bambu-gray'
+          }`}
+        >
+          {status?.state || 'IDLE'}
+        </span>
+      </div>
+
+      {isPrinting && status?.subtask_name && (
+        <div className="flex items-center gap-2 mb-3 text-sm">
+          <FileText className="w-4 h-4 text-bambu-gray" />
+          <span className="truncate" title={status.subtask_name}>
+            {status.subtask_name}
+          </span>
+        </div>
+      )}
+
+      {/* Progress Bar */}
+      <div className="mb-3">
+        <div className="flex justify-between text-xs text-bambu-gray mb-1">
+          <span>Progress</span>
+          <span>{Math.round(progress)}%</span>
+        </div>
+        <div className="h-2 bg-bambu-dark-tertiary rounded-full overflow-hidden">
+          <div
+            className={`h-full transition-all duration-300 ${
+              status?.state === 'PAUSE' ? 'bg-yellow-500' : 'bg-bambu-green'
+            }`}
+            style={{ width: `${progress}%` }}
+          />
+        </div>
+      </div>
+
+      {/* Stats */}
+      <div className="grid grid-cols-2 gap-3 text-sm">
+        <div className="flex items-center gap-2">
+          <Clock className="w-4 h-4 text-bambu-gray" />
+          <div>
+            <div className="text-xs text-bambu-gray">Remaining</div>
+            <div className="font-medium">{formatTime(status?.remaining_time)}</div>
+          </div>
+        </div>
+        <div className="flex items-center gap-2">
+          <Layers className="w-4 h-4 text-bambu-gray" />
+          <div>
+            <div className="text-xs text-bambu-gray">Layer</div>
+            <div className="font-medium">
+              {status?.layer_num ?? 0} / {status?.total_layers ?? 0}
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 71 - 0
frontend/src/components/control/SpeedControl.tsx

@@ -0,0 +1,71 @@
+import { useMutation } from '@tanstack/react-query';
+import { api } from '../../api/client';
+import type { PrinterStatus } from '../../api/client';
+import { Gauge, Loader2 } from 'lucide-react';
+
+interface SpeedControlProps {
+  printerId: number;
+  status: PrinterStatus | null | undefined;
+}
+
+const SPEED_MODES = [
+  { mode: 1, name: 'Silent', icon: '🔇' },
+  { mode: 2, name: 'Standard', icon: '⚡' },
+  { mode: 3, name: 'Sport', icon: '🚀' },
+  { mode: 4, name: 'Ludicrous', icon: '💨' },
+];
+
+export function SpeedControl({ printerId, status }: SpeedControlProps) {
+  const isConnected = status?.connected ?? false;
+  const isPrinting = status?.state === 'RUNNING';
+
+  // Note: Bambu printers don't report current speed mode via MQTT
+  // So we can't show which mode is currently active
+
+  const speedMutation = useMutation({
+    mutationFn: (mode: number) => api.setPrintSpeed(printerId, mode),
+  });
+
+  return (
+    <div className="bg-bambu-dark-secondary rounded-lg p-4">
+      <div className="flex items-center gap-2 mb-4">
+        <Gauge className="w-4 h-4 text-bambu-gray" />
+        <h3 className="text-sm font-medium">Print Speed</h3>
+        {speedMutation.isPending && (
+          <Loader2 className="w-4 h-4 animate-spin text-bambu-green" />
+        )}
+      </div>
+
+      <div className="grid grid-cols-4 gap-2">
+        {SPEED_MODES.map(({ mode, name, icon }) => (
+          <button
+            key={mode}
+            onClick={() => speedMutation.mutate(mode)}
+            disabled={!isConnected || !isPrinting || speedMutation.isPending}
+            className={`flex flex-col items-center gap-1 px-3 py-3 rounded-lg transition-colors ${
+              mode === 4
+                ? 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
+                : 'bg-bambu-dark hover:bg-bambu-dark-tertiary'
+            } disabled:opacity-50 disabled:cursor-not-allowed`}
+            title={name}
+          >
+            <span className="text-lg">{icon}</span>
+            <span className="text-xs">{name}</span>
+          </button>
+        ))}
+      </div>
+
+      {!isPrinting && (
+        <p className="mt-3 text-xs text-bambu-gray text-center">
+          Speed control only available during print
+        </p>
+      )}
+
+      {speedMutation.error && (
+        <p className="mt-2 text-sm text-red-400">
+          {speedMutation.error.message}
+        </p>
+      )}
+    </div>
+  );
+}

+ 246 - 0
frontend/src/components/control/TemperaturePanel.tsx

@@ -0,0 +1,246 @@
+import { useState } from 'react';
+import { useMutation } from '@tanstack/react-query';
+import { api, isConfirmationRequired } from '../../api/client';
+import type { PrinterStatus } from '../../api/client';
+import { Thermometer, Loader2, Plus, Minus } from 'lucide-react';
+import { ConfirmModal } from '../ConfirmModal';
+
+// Extended temperature interface for dual nozzle support
+interface Temperatures {
+  bed?: number;
+  bed_target?: number;
+  nozzle?: number;
+  nozzle_target?: number;
+  nozzle_2?: number;
+  nozzle_2_target?: number;
+  chamber?: number;
+}
+
+interface TemperaturePanelProps {
+  printerId: number;
+  status: PrinterStatus | null | undefined;
+  nozzleCount: number;
+}
+
+interface TempPreset {
+  name: string;
+  bed: number;
+  nozzle: number;
+}
+
+const PRESETS: TempPreset[] = [
+  { name: 'Off', bed: 0, nozzle: 0 },
+  { name: 'PLA', bed: 55, nozzle: 220 },
+  { name: 'PETG', bed: 70, nozzle: 250 },
+  { name: 'ABS', bed: 90, nozzle: 260 },
+  { name: 'TPU', bed: 50, nozzle: 230 },
+];
+
+export function TemperaturePanel({ printerId, status, nozzleCount }: TemperaturePanelProps) {
+  const [confirmModal, setConfirmModal] = useState<{
+    type: 'bed' | 'nozzle';
+    target: number;
+    nozzle?: number;
+    token: string;
+    warning: string;
+  } | null>(null);
+
+  const isConnected = status?.connected ?? false;
+  const temps = (status?.temperatures ?? {}) as Temperatures;
+
+  const bedMutation = useMutation({
+    mutationFn: ({ target, token }: { target: number; token?: string }) =>
+      api.setBedTemperature(printerId, target, token),
+    onSuccess: (result, variables) => {
+      if (isConfirmationRequired(result)) {
+        setConfirmModal({
+          type: 'bed',
+          target: variables.target,
+          token: result.token,
+          warning: result.warning,
+        });
+      }
+    },
+  });
+
+  const nozzleMutation = useMutation({
+    mutationFn: ({ target, nozzle, token }: { target: number; nozzle: number; token?: string }) =>
+      api.setNozzleTemperature(printerId, target, nozzle, token),
+    onSuccess: (result, variables) => {
+      if (isConfirmationRequired(result)) {
+        setConfirmModal({
+          type: 'nozzle',
+          target: variables.target,
+          nozzle: variables.nozzle,
+          token: result.token,
+          warning: result.warning,
+        });
+      }
+    },
+  });
+
+  const handleConfirm = () => {
+    if (confirmModal) {
+      if (confirmModal.type === 'bed') {
+        bedMutation.mutate({ target: confirmModal.target, token: confirmModal.token });
+      } else {
+        nozzleMutation.mutate({
+          target: confirmModal.target,
+          nozzle: confirmModal.nozzle ?? 0,
+          token: confirmModal.token,
+        });
+      }
+      setConfirmModal(null);
+    }
+  };
+
+  const handlePreset = (preset: TempPreset) => {
+    bedMutation.mutate({ target: preset.bed });
+    nozzleMutation.mutate({ target: preset.nozzle, nozzle: 0 });
+    if (nozzleCount > 1) {
+      nozzleMutation.mutate({ target: preset.nozzle, nozzle: 1 });
+    }
+  };
+
+  const adjustTemp = (type: 'bed' | 'nozzle', delta: number, nozzle = 0) => {
+    const currentTarget =
+      type === 'bed'
+        ? (temps.bed_target ?? 0)
+        : nozzle === 0
+        ? (temps.nozzle_target ?? 0)
+        : (temps.nozzle_2_target ?? 0);
+    const newTarget = Math.max(0, Math.min(type === 'bed' ? 120 : 300, currentTarget + delta));
+
+    if (type === 'bed') {
+      bedMutation.mutate({ target: newTarget });
+    } else {
+      nozzleMutation.mutate({ target: newTarget, nozzle });
+    }
+  };
+
+  return (
+    <>
+      <div className="bg-bambu-dark-secondary rounded-lg p-4">
+        <div className="flex items-center gap-2 mb-4">
+          <Thermometer className="w-4 h-4 text-bambu-gray" />
+          <h3 className="text-sm font-medium">Temperatures</h3>
+        </div>
+
+        {/* Presets */}
+        <div className="flex gap-2 mb-4">
+          {PRESETS.map((preset) => (
+            <button
+              key={preset.name}
+              onClick={() => handlePreset(preset)}
+              disabled={!isConnected}
+              className="flex-1 px-2 py-1.5 text-xs rounded bg-bambu-dark-tertiary text-bambu-gray hover:bg-bambu-dark hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+            >
+              {preset.name}
+            </button>
+          ))}
+        </div>
+
+        {/* Bed Temperature */}
+        <div className="mb-4 p-3 rounded bg-bambu-dark">
+          <div className="flex items-center justify-between mb-2">
+            <span className="text-sm text-bambu-gray">Bed</span>
+            <span className="text-sm font-mono">
+              <span className="text-orange-400">{Math.round(temps.bed ?? 0)}°C</span>
+              <span className="text-bambu-gray mx-1">/</span>
+              <span className="text-white">{Math.round(temps.bed_target ?? 0)}°C</span>
+            </span>
+          </div>
+          <div className="flex items-center gap-2">
+            <button
+              onClick={() => adjustTemp('bed', -5)}
+              disabled={!isConnected || bedMutation.isPending}
+              className="p-2 rounded bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
+            >
+              <Minus className="w-4 h-4" />
+            </button>
+            <div className="flex-1 h-2 bg-bambu-dark-tertiary rounded-full overflow-hidden">
+              <div
+                className="h-full bg-orange-500 transition-all"
+                style={{ width: `${Math.min(100, ((temps.bed ?? 0) / 120) * 100)}%` }}
+              />
+            </div>
+            <button
+              onClick={() => adjustTemp('bed', 5)}
+              disabled={!isConnected || bedMutation.isPending}
+              className="p-2 rounded bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
+            >
+              <Plus className="w-4 h-4" />
+            </button>
+            {bedMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
+          </div>
+        </div>
+
+        {/* Nozzle Temperature(s) */}
+        {[0, ...(nozzleCount > 1 ? [1] : [])].map((nozzle) => {
+          const current = nozzle === 0 ? temps.nozzle : temps.nozzle_2;
+          const target = nozzle === 0 ? temps.nozzle_target : temps.nozzle_2_target;
+
+          return (
+            <div key={nozzle} className="mb-4 p-3 rounded bg-bambu-dark">
+              <div className="flex items-center justify-between mb-2">
+                <span className="text-sm text-bambu-gray">
+                  Nozzle {nozzleCount > 1 ? nozzle + 1 : ''}
+                </span>
+                <span className="text-sm font-mono">
+                  <span className="text-red-400">{Math.round(current ?? 0)}°C</span>
+                  <span className="text-bambu-gray mx-1">/</span>
+                  <span className="text-white">{Math.round(target ?? 0)}°C</span>
+                </span>
+              </div>
+              <div className="flex items-center gap-2">
+                <button
+                  onClick={() => adjustTemp('nozzle', -5, nozzle)}
+                  disabled={!isConnected || nozzleMutation.isPending}
+                  className="p-2 rounded bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
+                >
+                  <Minus className="w-4 h-4" />
+                </button>
+                <div className="flex-1 h-2 bg-bambu-dark-tertiary rounded-full overflow-hidden">
+                  <div
+                    className="h-full bg-red-500 transition-all"
+                    style={{ width: `${Math.min(100, ((current ?? 0) / 300) * 100)}%` }}
+                  />
+                </div>
+                <button
+                  onClick={() => adjustTemp('nozzle', 5, nozzle)}
+                  disabled={!isConnected || nozzleMutation.isPending}
+                  className="p-2 rounded bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
+                >
+                  <Plus className="w-4 h-4" />
+                </button>
+                {nozzleMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
+              </div>
+            </div>
+          );
+        })}
+
+        {/* Chamber Temperature (read-only) */}
+        {temps.chamber !== undefined && (
+          <div className="p-3 rounded bg-bambu-dark">
+            <div className="flex items-center justify-between">
+              <span className="text-sm text-bambu-gray">Chamber</span>
+              <span className="text-sm font-mono text-blue-400">{Math.round(temps.chamber)}°C</span>
+            </div>
+          </div>
+        )}
+      </div>
+
+      {/* Confirmation Modal */}
+      {confirmModal && (
+        <ConfirmModal
+          title="Confirm Temperature"
+          message={confirmModal.warning}
+          confirmText="Set Temperature"
+          variant="warning"
+          onConfirm={handleConfirm}
+          onCancel={() => setConfirmModal(null)}
+        />
+      )}
+    </>
+  );
+}

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -2,6 +2,7 @@ export default {
   // Navigation
   nav: {
     printers: 'Drucker',
+    control: 'Steuerung',
     archives: 'Archiv',
     queue: 'Warteschlange',
     stats: 'Statistiken',

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -2,6 +2,7 @@ export default {
   // Navigation
   nav: {
     printers: 'Printers',
+    control: 'Control',
     archives: 'Archives',
     queue: 'Queue',
     stats: 'Statistics',

+ 207 - 0
frontend/src/pages/ControlPage.tsx

@@ -0,0 +1,207 @@
+import { useState, useEffect } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useSearchParams } from 'react-router-dom';
+import { api } from '../api/client';
+import type { PrinterStatus } from '../api/client';
+import { CameraFeed } from '../components/control/CameraFeed';
+import { PrintStatus } from '../components/control/PrintStatus';
+import { PrintControls } from '../components/control/PrintControls';
+import { TemperaturePanel } from '../components/control/TemperaturePanel';
+import { SpeedControl } from '../components/control/SpeedControl';
+import { FanControls } from '../components/control/FanControls';
+import { LightToggle } from '../components/control/LightToggle';
+import { MovementControls } from '../components/control/MovementControls';
+import { AMSPanel } from '../components/control/AMSPanel';
+import { Loader2, WifiOff } from 'lucide-react';
+
+export function ControlPage() {
+  const [searchParams, setSearchParams] = useSearchParams();
+  const [selectedPrinterId, setSelectedPrinterId] = useState<number | null>(null);
+
+  // Fetch all printers
+  const { data: printers, isLoading: loadingPrinters } = useQuery({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+  });
+
+  // Get statuses for all printers
+  const { data: statuses } = useQuery({
+    queryKey: ['printerStatuses'],
+    queryFn: async () => {
+      if (!printers) return {};
+      const statusMap: Record<number, PrinterStatus> = {};
+      await Promise.all(
+        printers.map(async (p) => {
+          try {
+            statusMap[p.id] = await api.getPrinterStatus(p.id);
+          } catch {
+            // Printer offline
+          }
+        })
+      );
+      return statusMap;
+    },
+    enabled: !!printers && printers.length > 0,
+    refetchInterval: 2000,
+  });
+
+  // Initialize selected printer from URL or first printer
+  useEffect(() => {
+    const printerParam = searchParams.get('printer');
+    if (printerParam) {
+      const id = parseInt(printerParam, 10);
+      if (!isNaN(id)) {
+        setSelectedPrinterId(id);
+        return;
+      }
+    }
+    // Default to first printer
+    if (printers && printers.length > 0 && !selectedPrinterId) {
+      setSelectedPrinterId(printers[0].id);
+    }
+  }, [printers, searchParams, selectedPrinterId]);
+
+  // Update URL when printer changes
+  const handlePrinterSelect = (printerId: number) => {
+    setSelectedPrinterId(printerId);
+    setSearchParams({ printer: String(printerId) });
+  };
+
+  const selectedPrinter = printers?.find((p) => p.id === selectedPrinterId);
+  const selectedStatus = selectedPrinterId ? statuses?.[selectedPrinterId] : null;
+
+  if (loadingPrinters) {
+    return (
+      <div className="flex items-center justify-center h-screen">
+        <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
+      </div>
+    );
+  }
+
+  if (!printers || printers.length === 0) {
+    return (
+      <div className="flex flex-col items-center justify-center h-screen text-bambu-gray">
+        <WifiOff className="w-16 h-16 mb-4" />
+        <p className="text-xl">No printers configured</p>
+        <p className="text-sm mt-2">Add a printer in the Printers page first</p>
+      </div>
+    );
+  }
+
+  return (
+    <div className="h-screen flex flex-col">
+      {/* Printer Tabs */}
+      <div className="bg-bambu-dark-secondary border-b border-bambu-dark-tertiary">
+        <div className="flex overflow-x-auto">
+          {printers.map((printer) => {
+            const status = statuses?.[printer.id];
+            const isConnected = status?.connected ?? false;
+            const isSelected = printer.id === selectedPrinterId;
+
+            return (
+              <button
+                key={printer.id}
+                onClick={() => handlePrinterSelect(printer.id)}
+                className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors whitespace-nowrap border-b-2 ${
+                  isSelected
+                    ? 'border-bambu-green text-bambu-green bg-bambu-dark'
+                    : 'border-transparent text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
+                }`}
+              >
+                <span
+                  className={`w-2 h-2 rounded-full ${
+                    isConnected ? 'bg-bambu-green' : 'bg-red-500'
+                  }`}
+                />
+                {printer.name}
+                {status?.state && status.state !== 'IDLE' && (
+                  <span className="text-xs px-2 py-0.5 rounded bg-bambu-dark-tertiary">
+                    {status.state}
+                  </span>
+                )}
+              </button>
+            );
+          })}
+        </div>
+      </div>
+
+      {/* Main Content */}
+      {selectedPrinter && (
+        <div className="flex-1 overflow-auto p-4">
+          <div className="max-w-7xl mx-auto">
+            {/* Connection Warning */}
+            {!selectedStatus?.connected && (
+              <div className="mb-4 p-4 bg-red-500/20 border border-red-500/50 rounded-lg flex items-center gap-3">
+                <WifiOff className="w-5 h-5 text-red-500" />
+                <span className="text-red-400">
+                  Printer is not connected. Controls are disabled.
+                </span>
+              </div>
+            )}
+
+            <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
+              {/* Left Column: Camera + Print Status */}
+              <div className="space-y-4">
+                {/* Camera Feed */}
+                <CameraFeed
+                  printerId={selectedPrinter.id}
+                  isConnected={selectedStatus?.connected ?? false}
+                />
+
+                {/* Print Status & Controls */}
+                <PrintStatus
+                  printerId={selectedPrinter.id}
+                  status={selectedStatus}
+                />
+                <PrintControls
+                  printerId={selectedPrinter.id}
+                  status={selectedStatus}
+                />
+
+                {/* AMS Panel */}
+                <AMSPanel
+                  printerId={selectedPrinter.id}
+                  status={selectedStatus}
+                />
+              </div>
+
+              {/* Right Column: Controls */}
+              <div className="space-y-4">
+                {/* Temperature Panel */}
+                <TemperaturePanel
+                  printerId={selectedPrinter.id}
+                  status={selectedStatus}
+                  nozzleCount={selectedPrinter.nozzle_count}
+                />
+
+                {/* Speed Control */}
+                <SpeedControl
+                  printerId={selectedPrinter.id}
+                  status={selectedStatus}
+                />
+
+                {/* Fan Controls */}
+                <FanControls
+                  printerId={selectedPrinter.id}
+                  status={selectedStatus}
+                />
+
+                {/* Light Toggle */}
+                <LightToggle
+                  printerId={selectedPrinter.id}
+                  status={selectedStatus}
+                />
+
+                {/* Movement Controls */}
+                <MovementControls
+                  printerId={selectedPrinter.id}
+                  status={selectedStatus}
+                />
+              </div>
+            </div>
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-4olH2ask.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CLsUm3wk.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-Dqi_M3E3.css


+ 2 - 2
static/index.html

@@ -7,8 +7,8 @@
     <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
-    <script type="module" crossorigin src="/assets/index-soeWpmRO.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-4olH2ask.css">
+    <script type="module" crossorigin src="/assets/index-CLsUm3wk.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Dqi_M3E3.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff