This commit is contained in:
maziggy
2025-12-07 14:29:54 +00:00
parent 53e196c38f
commit e688c35719
18 changed files with 1630 additions and 631 deletions
+28 -4
View File
@@ -15,6 +15,8 @@ from backend.app.services.camera import (
build_camera_url,
capture_camera_frame,
test_camera_connection,
get_ffmpeg_path,
get_camera_port,
)
from backend.app.services.printer_manager import printer_manager
@@ -41,20 +43,29 @@ async def generate_mjpeg_stream(
This captures frames continuously and yields them in MJPEG format.
"""
from backend.app.services.camera import get_camera_port
ffmpeg = get_ffmpeg_path()
if not ffmpeg:
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"
)
return
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
# -rtsp_flags prefer_tcp: Prefer TCP for RTSP
# -f mjpeg: Output as MJPEG
# -q:v 5: Quality (lower = better, 2-10 is good range)
# -r: Output framerate
cmd = [
"ffmpeg",
ffmpeg,
"-rtsp_transport", "tcp",
"-rtsp_flags", "prefer_tcp",
"-i", camera_url,
"-f", "mjpeg",
"-q:v", "5",
@@ -63,7 +74,8 @@ async def generate_mjpeg_stream(
"-" # Output to stdout
]
logger.info(f"Starting camera stream for {ip_address}")
logger.info(f"Starting camera stream for {ip_address} using URL: rtsps://bblp:***@{ip_address}:{port}/streaming/live/1")
logger.debug(f"ffmpeg command: {ffmpeg} ... (url hidden)")
process = None
try:
@@ -73,6 +85,18 @@ async def generate_mjpeg_stream(
stderr=asyncio.subprocess.PIPE,
)
# Give ffmpeg a moment to start and check for immediate failures
await asyncio.sleep(0.5)
if process.returncode is not None:
stderr = await process.stderr.read()
logger.error(f"ffmpeg failed immediately: {stderr.decode()}")
yield (
b"--frame\r\n"
b"Content-Type: text/plain\r\n\r\n"
b"Error: Camera connection failed. Check printer is on and camera is enabled.\r\n"
)
return
# Read JPEG frames from ffmpeg output
# JPEG images start with 0xFFD8 and end with 0xFFD9
buffer = b""
+5 -1
View File
@@ -190,8 +190,8 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
# humidity_raw is the actual percentage value from the sensor
humidity_raw = ams_data.get("humidity_raw")
humidity_idx = ams_data.get("humidity")
# Use humidity_raw if available, otherwise fall back to humidity index
humidity_value = None
if humidity_raw is not None:
try:
humidity_value = int(humidity_raw)
@@ -202,10 +202,14 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
humidity_value = int(humidity_idx)
except (ValueError, TypeError):
pass
# AMS-HT has 1 tray, regular AMS has 4 trays
is_ams_ht = len(trays) == 1
ams_units.append(AMSUnit(
id=ams_data.get("id", 0),
humidity=humidity_value,
temp=ams_data.get("temp"),
is_ams_ht=is_ams_ht,
tray=trays,
))
+2 -17
View File
@@ -1,6 +1,3 @@
import shutil
from pathlib import Path
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@@ -95,21 +92,9 @@ async def reset_settings(db: AsyncSession = Depends(get_db)):
@router.get("/check-ffmpeg")
async def check_ffmpeg():
"""Check if ffmpeg is installed and available."""
ffmpeg_path = shutil.which("ffmpeg")
from backend.app.services.camera import get_ffmpeg_path
# If not found via PATH, check common installation locations
# (systemd services often have limited PATH)
if ffmpeg_path is None:
common_paths = [
"/usr/bin/ffmpeg",
"/usr/local/bin/ffmpeg",
"/opt/homebrew/bin/ffmpeg",
"/snap/bin/ffmpeg",
]
for path in common_paths:
if Path(path).exists():
ffmpeg_path = path
break
ffmpeg_path = get_ffmpeg_path()
return {
"installed": ffmpeg_path is not None,
+9
View File
@@ -100,3 +100,12 @@ async def run_migrations(conn):
except Exception:
# Column already exists
pass
# Migration: Add location column to printers for grouping
try:
await conn.execute(text(
"ALTER TABLE printers ADD COLUMN location VARCHAR(100)"
))
except Exception:
# Column already exists
pass
+1
View File
@@ -14,6 +14,7 @@ class Printer(Base):
ip_address: Mapped[str] = mapped_column(String(45))
access_code: Mapped[str] = mapped_column(String(20))
model: Mapped[str | None] = mapped_column(String(50))
location: Mapped[str | None] = mapped_column(String(100)) # Group/location name
nozzle_count: Mapped[int] = mapped_column(default=1) # 1 or 2, auto-detected from MQTT
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
auto_archive: Mapped[bool] = mapped_column(Boolean, default=True)
+3
View File
@@ -8,6 +8,7 @@ class PrinterBase(BaseModel):
ip_address: str = Field(..., pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
access_code: str = Field(..., min_length=1, max_length=20)
model: str | None = None
location: str | None = None # Group/location name
auto_archive: bool = True
@@ -20,6 +21,7 @@ class PrinterUpdate(BaseModel):
ip_address: str | None = None
access_code: str | None = None
model: str | None = None
location: str | None = None
is_active: bool | None = None
auto_archive: bool | None = None
print_hours_offset: float | None = None
@@ -63,6 +65,7 @@ class AMSUnit(BaseModel):
id: int
humidity: int | None = None
temp: float | None = None
is_ams_ht: bool = False # True for AMS-HT (single spool), False for regular AMS (4 spools)
tray: list[AMSTray] = []
+51 -2
View File
@@ -5,7 +5,7 @@ Captures images from the printer's RTSPS camera stream using ffmpeg.
import asyncio
import logging
import subprocess
import shutil
from pathlib import Path
from datetime import datetime
import uuid
@@ -14,6 +14,46 @@ from backend.app.core.config import settings
logger = logging.getLogger(__name__)
# Cache the ffmpeg path after first lookup
_ffmpeg_path: str | None = None
def get_ffmpeg_path() -> str | None:
"""Find the ffmpeg executable path.
Uses shutil.which first, then checks common installation locations
for systems where PATH may be limited (e.g., systemd services).
"""
global _ffmpeg_path
if _ffmpeg_path is not None:
return _ffmpeg_path
# Try PATH first
ffmpeg_path = shutil.which("ffmpeg")
# If not found via PATH, check common installation locations
if ffmpeg_path is None:
common_paths = [
"/usr/bin/ffmpeg",
"/usr/local/bin/ffmpeg",
"/opt/homebrew/bin/ffmpeg", # macOS Homebrew
"/snap/bin/ffmpeg", # Ubuntu Snap
"C:\\ffmpeg\\bin\\ffmpeg.exe", # Windows common
]
for path in common_paths:
if Path(path).exists():
ffmpeg_path = path
break
_ffmpeg_path = ffmpeg_path
if ffmpeg_path:
logger.info(f"Found ffmpeg at: {ffmpeg_path}")
else:
logger.warning("ffmpeg not found in PATH or common locations")
return ffmpeg_path
def get_camera_port(model: str | None) -> int:
"""Get the RTSPS port based on printer model.
@@ -59,17 +99,26 @@ async def capture_camera_frame(
# Ensure output directory exists
output_path.parent.mkdir(parents=True, exist_ok=True)
ffmpeg = get_ffmpeg_path()
if not ffmpeg:
logger.error("ffmpeg not found. Please install ffmpeg to enable camera capture.")
return False
# ffmpeg command to capture a single frame from RTSPS stream
# -rtsp_transport tcp: Use TCP for RTSP (more reliable)
# -rtsp_flags prefer_tcp: Prefer TCP for RTSP
# -y: Overwrite output file
# -frames:v 1: Capture only 1 frame
# -update 1: Allow writing single image without sequence pattern
# -q:v 2: High quality JPEG (1-31, lower is better)
cmd = [
"ffmpeg",
ffmpeg,
"-y", # Overwrite output
"-rtsp_transport", "tcp",
"-rtsp_flags", "prefer_tcp",
"-i", camera_url,
"-frames:v", "1",
"-update", "1",
"-q:v", "2",
str(output_path),
]
+71
View File
@@ -268,6 +268,74 @@ class PrinterManager:
def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) -> dict:
"""Convert PrinterState to a JSON-serializable dict."""
# Parse AMS data from raw_data
ams_units = []
vt_tray = None
raw_data = state.raw_data or {}
if "ams" in raw_data and isinstance(raw_data["ams"], list):
for ams_data in raw_data["ams"]:
trays = []
for tray in ams_data.get("tray", []):
tag_uid = tray.get("tag_uid")
if tag_uid in ("", "0000000000000000"):
tag_uid = None
tray_uuid = tray.get("tray_uuid")
if tray_uuid in ("", "00000000000000000000000000000000"):
tray_uuid = None
trays.append({
"id": tray.get("id", 0),
"tray_color": tray.get("tray_color"),
"tray_type": tray.get("tray_type"),
"tray_sub_brands": tray.get("tray_sub_brands"),
"remain": tray.get("remain", 0),
"k": tray.get("k"),
"tag_uid": tag_uid,
"tray_uuid": tray_uuid,
})
# Prefer humidity_raw (actual percentage) over humidity (index 1-5)
humidity_raw = ams_data.get("humidity_raw")
humidity_idx = ams_data.get("humidity")
humidity_value = None
if humidity_raw is not None:
try:
humidity_value = int(humidity_raw)
except (ValueError, TypeError):
pass
# Fall back to index if no raw value (index is 1-5, not percentage)
if humidity_value is None and humidity_idx is not None:
try:
humidity_value = int(humidity_idx)
except (ValueError, TypeError):
pass
# AMS-HT has 1 tray, regular AMS has 4 trays
is_ams_ht = len(trays) == 1
ams_units.append({
"id": ams_data.get("id", 0),
"humidity": humidity_value,
"temp": ams_data.get("temp"),
"is_ams_ht": is_ams_ht,
"tray": trays,
})
# Parse virtual tray (external spool)
if "vt_tray" in raw_data:
vt_data = raw_data["vt_tray"]
vt_tag_uid = vt_data.get("tag_uid")
if vt_tag_uid in ("", "0000000000000000"):
vt_tag_uid = None
vt_tray = {
"id": 254,
"tray_color": vt_data.get("tray_color"),
"tray_type": vt_data.get("tray_type"),
"tray_sub_brands": vt_data.get("tray_sub_brands"),
"remain": vt_data.get("remain", 0),
"tag_uid": vt_tag_uid,
}
result = {
"connected": state.connected,
"state": state.state,
@@ -283,6 +351,9 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) ->
{"code": e.code, "attr": e.attr, "module": e.module, "severity": e.severity}
for e in (state.hms_errors or [])
],
# AMS data for filament colors
"ams": ams_units if ams_units else None,
"vt_tray": vt_tray,
# AMS status for filament change tracking
"ams_status_main": state.ams_status_main,
"ams_status_sub": state.ams_status_sub,
+4
View File
@@ -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 { CameraPage } from './pages/CameraPage';
import { useWebSocket } from './hooks/useWebSocket';
import { ThemeProvider } from './contexts/ThemeContext';
import { ToastProvider } from './contexts/ToastContext';
@@ -34,6 +35,9 @@ function App() {
<WebSocketProvider>
<BrowserRouter>
<Routes>
{/* Camera page - standalone, no layout */}
<Route path="/camera/:printerId" element={<CameraPage />} />
<Route path="/" element={<Layout />}>
<Route index element={<PrintersPage />} />
<Route path="archives" element={<ArchivesPage />} />
+5
View File
@@ -28,6 +28,7 @@ export interface Printer {
ip_address: string;
access_code: string;
model: string | null;
location: string | null; // Group/location name
nozzle_count: number; // 1 or 2, auto-detected from MQTT
is_active: boolean;
auto_archive: boolean;
@@ -61,6 +62,7 @@ export interface AMSUnit {
id: number;
humidity: number | null;
temp: number | null;
is_ams_ht: boolean; // True for AMS-HT (single spool), False for regular AMS (4 spools)
tray: AMSTray[];
}
@@ -106,6 +108,8 @@ export interface PrinterStatus {
bed_target?: number;
nozzle?: number;
nozzle_target?: number;
nozzle_2?: number; // Second nozzle for H2 series (dual nozzle)
nozzle_2_target?: number;
chamber?: number;
} | null;
cover_url: string | null;
@@ -157,6 +161,7 @@ export interface PrinterCreate {
ip_address: string;
access_code: string;
model?: string;
location?: string;
auto_archive?: boolean;
}
+10 -13
View File
@@ -35,11 +35,14 @@ export function PrinterQueueWidget({ printerId }: PrinterQueueWidgetProps) {
}
return (
<div className="mt-3 p-2 bg-bambu-dark rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<Calendar className="w-4 h-4 text-yellow-400 flex-shrink-0" />
<div className="min-w-0">
<Link
to="/queue"
className="block mb-3 p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0 flex-1">
<Calendar className="w-5 h-5 text-yellow-400 flex-shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-xs text-bambu-gray">Next in queue</p>
<p className="text-sm text-white truncate">
{nextItem?.archive_name || `Archive #${nextItem?.archive_id}`}
@@ -56,15 +59,9 @@ export function PrinterQueueWidget({ printerId }: PrinterQueueWidgetProps) {
+{totalPending - 1}
</span>
)}
<Link
to="/queue"
className="p-1 hover:bg-bambu-dark-tertiary rounded transition-colors text-bambu-gray hover:text-white"
title="View queue"
>
<ChevronRight className="w-4 h-4" />
</Link>
<ChevronRight className="w-4 h-4 text-bambu-gray" />
</div>
</div>
</div>
</Link>
);
}
+21
View File
@@ -122,3 +122,24 @@ body {
.dark .jogpad-theme {
filter: brightness(0.35) contrast(1.2);
}
/* Empty AMS slot with diagonal stripes */
.ams-empty-slot {
background: repeating-linear-gradient(
45deg,
#444,
#444 2px,
#222 2px,
#222 4px
);
}
.dark .ams-empty-slot {
background: repeating-linear-gradient(
45deg,
#555,
#555 2px,
#333 2px,
#333 4px
);
}
+233
View File
@@ -0,0 +1,233 @@
import { useState, useEffect, useRef } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize } from 'lucide-react';
import { api } from '../api/client';
export function CameraPage() {
const { printerId } = useParams<{ printerId: string }>();
const id = parseInt(printerId || '0', 10);
const [streamMode, setStreamMode] = useState<'stream' | 'snapshot'>('stream');
const [streamError, setStreamError] = useState(false);
const [streamLoading, setStreamLoading] = useState(true);
const [imageKey, setImageKey] = useState(Date.now());
const [transitioning, setTransitioning] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const imgRef = useRef<HTMLImageElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Fetch printer info for the title
const { data: printer } = useQuery({
queryKey: ['printer', id],
queryFn: () => api.getPrinter(id),
enabled: id > 0,
});
// Update document title
useEffect(() => {
if (printer) {
document.title = `${printer.name} - Camera`;
}
return () => {
document.title = 'Bambusy';
};
}, [printer]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (imgRef.current) {
imgRef.current.src = '';
}
};
}, []);
// Auto-hide loading after timeout
useEffect(() => {
if (streamLoading && !transitioning) {
const timeout = streamMode === 'stream' ? 3000 : 20000;
const timer = setTimeout(() => {
setStreamLoading(false);
}, timeout);
return () => clearTimeout(timer);
}
}, [streamMode, streamLoading, imageKey, transitioning]);
// Fullscreen change listener
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, []);
const handleStreamError = () => {
setStreamError(true);
setStreamLoading(false);
};
const handleStreamLoad = () => {
setStreamLoading(false);
setStreamError(false);
};
const switchToMode = (newMode: 'stream' | 'snapshot') => {
if (streamMode === newMode || transitioning) return;
setTransitioning(true);
setStreamLoading(true);
setStreamError(false);
if (imgRef.current) {
imgRef.current.src = '';
}
setTimeout(() => {
setStreamMode(newMode);
setImageKey(Date.now());
setTransitioning(false);
}, 100);
};
const refresh = () => {
if (transitioning) return;
setTransitioning(true);
setStreamLoading(true);
setStreamError(false);
if (imgRef.current) {
imgRef.current.src = '';
}
setTimeout(() => {
setImageKey(Date.now());
setTransitioning(false);
}, 100);
};
const toggleFullscreen = () => {
if (!containerRef.current) return;
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
containerRef.current.requestFullscreen();
}
};
const currentUrl = transitioning
? ''
: streamMode === 'stream'
? `/api/v1/printers/${id}/camera/stream?fps=10&t=${imageKey}`
: `/api/v1/printers/${id}/camera/snapshot?t=${imageKey}`;
const isDisabled = streamLoading || transitioning;
if (!id) {
return (
<div className="min-h-screen bg-black flex items-center justify-center">
<p className="text-white">Invalid printer ID</p>
</div>
);
}
return (
<div ref={containerRef} className="min-h-screen bg-black flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary">
<h1 className="text-sm font-medium text-white flex items-center gap-2">
<Camera className="w-4 h-4" />
{printer?.name || `Printer ${id}`}
</h1>
<div className="flex items-center gap-2">
{/* Mode toggle */}
<div className="flex bg-bambu-dark rounded p-0.5">
<button
onClick={() => switchToMode('stream')}
disabled={isDisabled}
className={`px-3 py-1 text-xs rounded transition-colors ${
streamMode === 'stream'
? 'bg-bambu-green text-white'
: 'text-bambu-gray hover:text-white disabled:opacity-50'
}`}
>
Live
</button>
<button
onClick={() => switchToMode('snapshot')}
disabled={isDisabled}
className={`px-3 py-1 text-xs rounded transition-colors ${
streamMode === 'snapshot'
? 'bg-bambu-green text-white'
: 'text-bambu-gray hover:text-white disabled:opacity-50'
}`}
>
Snapshot
</button>
</div>
<button
onClick={refresh}
disabled={isDisabled}
className="p-1.5 hover:bg-bambu-dark-tertiary rounded disabled:opacity-50"
title={streamMode === 'stream' ? 'Restart stream' : 'Refresh snapshot'}
>
<RefreshCw className={`w-4 h-4 text-bambu-gray ${isDisabled ? 'animate-spin' : ''}`} />
</button>
<button
onClick={toggleFullscreen}
className="p-1.5 hover:bg-bambu-dark-tertiary rounded"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
>
{isFullscreen ? (
<Minimize className="w-4 h-4 text-bambu-gray" />
) : (
<Maximize className="w-4 h-4 text-bambu-gray" />
)}
</button>
</div>
</div>
{/* Video area */}
<div className="flex-1 flex items-center justify-center p-2">
<div className="relative w-full h-full flex items-center justify-center">
{(streamLoading || transitioning) && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50 z-10">
<div className="text-center">
<RefreshCw className="w-8 h-8 text-bambu-gray animate-spin mx-auto mb-2" />
<p className="text-sm text-bambu-gray">
{streamMode === 'stream' ? 'Connecting to camera...' : 'Capturing snapshot...'}
</p>
</div>
</div>
)}
{streamError && (
<div className="absolute inset-0 flex items-center justify-center bg-black z-10">
<div className="text-center p-4">
<AlertTriangle className="w-12 h-12 text-orange-400 mx-auto mb-3" />
<p className="text-white mb-2">Camera unavailable</p>
<p className="text-xs text-bambu-gray mb-4 max-w-md">
Make sure the printer is powered on and connected.
</p>
<button
onClick={refresh}
className="px-4 py-2 bg-bambu-green text-white rounded hover:bg-bambu-green/80 transition-colors"
>
Retry
</button>
</div>
</div>
)}
<img
ref={imgRef}
key={imageKey}
src={currentUrl}
alt="Camera stream"
className="max-w-full max-h-full object-contain"
onError={currentUrl ? handleStreamError : undefined}
onLoad={currentUrl ? handleStreamLoad : undefined}
/>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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-B5zVKOmX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BkIG8nsB.css">
<script type="module" crossorigin src="/assets/index-DzQp7Kty.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-SGO6jLr2.css">
</head>
<body>
<div id="root"></div>