mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
Merge pull request #188 from maziggy/feature/164
Streaming overlay page for OBS integration (#164)
This commit is contained in:
@@ -5,6 +5,14 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
## [0.1.6-final] - Not released
|
||||
|
||||
### New Features
|
||||
- **Streaming Overlay for OBS** - Embeddable overlay page for live streaming with camera and print status (Issue #164):
|
||||
- All-in-one page at `/overlay/:printerId` combining camera feed with status overlay
|
||||
- Real-time print progress, ETA, layer count, and filename display
|
||||
- Bambuddy logo branding (links to GitHub)
|
||||
- Customizable via query parameters: `?size=small|medium|large` and `?show=progress,layers,eta,filename,status,printer`
|
||||
- No authentication required - designed for OBS browser source embedding
|
||||
- Gradient overlay at bottom for readable text over camera feed
|
||||
- Auto-reconnect on camera stream errors
|
||||
- **MQTT Smart Plug Support** - Add smart plugs that subscribe to MQTT topics for energy monitoring (Issue #173):
|
||||
- New "MQTT" plug type alongside Tasmota and Home Assistant
|
||||
- Subscribe to any MQTT topic (Zigbee2MQTT, Shelly, Tasmota discovery, etc.)
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
### 📊 Monitoring & Control
|
||||
- Real-time printer status via WebSocket
|
||||
- Live camera streaming (MJPEG) & snapshots with multi-viewer support
|
||||
- **Streaming overlay for OBS** - Embeddable page with camera + status for live streaming (`/overlay/:printerId`)
|
||||
- External camera support (MJPEG, RTSP, HTTP snapshot, USB/V4L2) with layer-based timelapse
|
||||
- **Build plate empty detection** - Auto-pause print if objects detected on plate (multi-reference calibration, ROI adjustment)
|
||||
- Fan status monitoring (part cooling, auxiliary, chamber)
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ProjectsPage } from './pages/ProjectsPage';
|
||||
import { ProjectDetailPage } from './pages/ProjectDetailPage';
|
||||
import { FileManagerPage } from './pages/FileManagerPage';
|
||||
import { CameraPage } from './pages/CameraPage';
|
||||
import { StreamOverlayPage } from './pages/StreamOverlayPage';
|
||||
import { ExternalLinkPage } from './pages/ExternalLinkPage';
|
||||
import { SystemInfoPage } from './pages/SystemInfoPage';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
@@ -109,6 +110,9 @@ function App() {
|
||||
{/* Camera page - standalone, no layout, no WebSocket (doesn't need real-time updates) */}
|
||||
<Route path="/camera/:printerId" element={<CameraPage />} />
|
||||
|
||||
{/* Stream overlay page - standalone for OBS/streaming embeds, no auth required */}
|
||||
<Route path="/overlay/:printerId" element={<StreamOverlayPage />} />
|
||||
|
||||
{/* Main app with WebSocket for real-time updates */}
|
||||
<Route element={<ProtectedRoute><WebSocketProvider><Layout /></WebSocketProvider></ProtectedRoute>}>
|
||||
<Route index element={<PrintersPage />} />
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Tests for the StreamOverlayPage component.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { screen, waitFor, render as rtlRender } from '@testing-library/react';
|
||||
import { StreamOverlayPage } from '../../pages/StreamOverlayPage';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../mocks/server';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ThemeProvider } from '../../contexts/ThemeContext';
|
||||
import { ToastProvider } from '../../contexts/ToastContext';
|
||||
|
||||
const mockPrinter = {
|
||||
id: 1,
|
||||
name: 'X1 Carbon',
|
||||
ip_address: '192.168.1.100',
|
||||
serial_number: '00M09A350100001',
|
||||
access_code: '12345678',
|
||||
model: 'X1C',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const mockStatusIdle = {
|
||||
id: 1,
|
||||
name: 'X1 Carbon',
|
||||
connected: true,
|
||||
state: 'IDLE',
|
||||
progress: 0,
|
||||
current_print: null,
|
||||
remaining_time: null,
|
||||
layer_num: null,
|
||||
total_layers: null,
|
||||
stg_cur_name: null,
|
||||
};
|
||||
|
||||
const mockStatusPrinting = {
|
||||
id: 1,
|
||||
name: 'X1 Carbon',
|
||||
connected: true,
|
||||
state: 'RUNNING',
|
||||
progress: 45,
|
||||
current_print: 'Benchy.gcode.3mf',
|
||||
remaining_time: 82,
|
||||
layer_num: 150,
|
||||
total_layers: 300,
|
||||
stg_cur_name: null,
|
||||
};
|
||||
|
||||
// Custom render for StreamOverlayPage
|
||||
function renderOverlayPage(printerId: number, queryParams = '') {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0 },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return rtlRender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[`/overlay/${printerId}${queryParams}`]}>
|
||||
<ThemeProvider>
|
||||
<ToastProvider>
|
||||
<Routes>
|
||||
<Route path="/overlay/:printerId" element={<StreamOverlayPage />} />
|
||||
</Routes>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('StreamOverlayPage', () => {
|
||||
const originalTitle = document.title;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock WebSocket
|
||||
vi.stubGlobal('WebSocket', vi.fn().mockImplementation(() => ({
|
||||
close: vi.fn(),
|
||||
onmessage: null,
|
||||
onerror: null,
|
||||
})));
|
||||
|
||||
server.use(
|
||||
http.get('/api/v1/printers/:id', () => {
|
||||
return HttpResponse.json(mockPrinter);
|
||||
}),
|
||||
http.get('/api/v1/printers/:id/status', () => {
|
||||
return HttpResponse.json(mockStatusIdle);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.title = originalTitle;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders overlay page for printer', async () => {
|
||||
renderOverlayPage(1);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Printer is idle')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Bambuddy logo', async () => {
|
||||
renderOverlayPage(1);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByAltText('Bambuddy')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('logo links to GitHub', async () => {
|
||||
renderOverlayPage(1);
|
||||
|
||||
await waitFor(() => {
|
||||
const logo = screen.getByAltText('Bambuddy');
|
||||
const link = logo.closest('a');
|
||||
expect(link).toHaveAttribute('href', 'https://github.com/maziggy/bambuddy');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('printing state', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
http.get('/api/v1/printers/:id/status', () => {
|
||||
return HttpResponse.json(mockStatusPrinting);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('shows filename when printing', async () => {
|
||||
renderOverlayPage(1);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Benchy')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows progress percentage', async () => {
|
||||
renderOverlayPage(1);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('45%')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows layer count', async () => {
|
||||
renderOverlayPage(1);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('150')).toBeInTheDocument();
|
||||
expect(screen.getByText('300')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows status text', async () => {
|
||||
renderOverlayPage(1);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Printing')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid printer', () => {
|
||||
it('shows invalid printer message for ID 0', async () => {
|
||||
renderOverlayPage(0);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invalid printer ID')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('query parameters', () => {
|
||||
it('respects size parameter', async () => {
|
||||
renderOverlayPage(1, '?size=large');
|
||||
|
||||
await waitFor(() => {
|
||||
// Just verify it renders without error
|
||||
expect(screen.getByAltText('Bambuddy')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('respects show parameter to hide elements', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/printers/:id/status', () => {
|
||||
return HttpResponse.json(mockStatusPrinting);
|
||||
})
|
||||
);
|
||||
|
||||
renderOverlayPage(1, '?show=progress');
|
||||
|
||||
await waitFor(() => {
|
||||
// Progress should be visible
|
||||
expect(screen.getByText('45%')).toBeInTheDocument();
|
||||
// Status text should be hidden when not in show list
|
||||
expect(screen.queryByText('Printing')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('offline state', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
http.get('/api/v1/printers/:id/status', () => {
|
||||
return HttpResponse.json({
|
||||
...mockStatusIdle,
|
||||
connected: false,
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('shows offline message when printer disconnected', async () => {
|
||||
renderOverlayPage(1);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Printer offline')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Layers, Clock, Timer, Printer } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { PrinterStatus } from '../api/client';
|
||||
|
||||
type OverlaySize = 'small' | 'medium' | 'large';
|
||||
|
||||
interface OverlayConfig {
|
||||
size: OverlaySize;
|
||||
showProgress: boolean;
|
||||
showLayers: boolean;
|
||||
showEta: boolean;
|
||||
showFilename: boolean;
|
||||
showStatus: boolean;
|
||||
showPrinter: boolean;
|
||||
}
|
||||
|
||||
function parseConfig(params: URLSearchParams): OverlayConfig {
|
||||
const show = params.get('show')?.split(',') || ['progress', 'layers', 'eta', 'filename', 'status'];
|
||||
|
||||
return {
|
||||
size: (params.get('size') as OverlaySize) || 'medium',
|
||||
showProgress: show.includes('progress'),
|
||||
showLayers: show.includes('layers'),
|
||||
showEta: show.includes('eta'),
|
||||
showFilename: show.includes('filename'),
|
||||
showStatus: show.includes('status'),
|
||||
showPrinter: show.includes('printer'),
|
||||
};
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
||||
}
|
||||
|
||||
function formatETA(remainingMinutes: number): string {
|
||||
const now = new Date();
|
||||
const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const etaDay = new Date(eta);
|
||||
etaDay.setHours(0, 0, 0, 0);
|
||||
|
||||
const timeStr = eta.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
if (etaDay.getTime() === today.getTime()) {
|
||||
return timeStr;
|
||||
} else if (etaDay.getTime() === today.getTime() + 86400000) {
|
||||
return `Tomorrow ${timeStr}`;
|
||||
} else {
|
||||
return eta.toLocaleDateString([], { weekday: 'short' }) + ' ' + timeStr;
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusText(status: PrinterStatus): string {
|
||||
if (status.stg_cur_name) return status.stg_cur_name;
|
||||
|
||||
switch (status.state) {
|
||||
case 'RUNNING': return 'Printing';
|
||||
case 'PAUSE': return 'Paused';
|
||||
case 'FINISH': return 'Finished';
|
||||
case 'FAILED': return 'Failed';
|
||||
case 'IDLE': return 'Idle';
|
||||
default: return status.state || 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function getSizeClasses(size: OverlaySize) {
|
||||
switch (size) {
|
||||
case 'small':
|
||||
return {
|
||||
container: 'p-3',
|
||||
text: 'text-sm',
|
||||
textLarge: 'text-lg',
|
||||
progressHeight: 'h-2',
|
||||
icon: 'w-3 h-3',
|
||||
gap: 'gap-2',
|
||||
logoHeight: 'h-12',
|
||||
};
|
||||
case 'large':
|
||||
return {
|
||||
container: 'p-6',
|
||||
text: 'text-xl',
|
||||
textLarge: 'text-3xl',
|
||||
progressHeight: 'h-4',
|
||||
icon: 'w-6 h-6',
|
||||
gap: 'gap-4',
|
||||
logoHeight: 'h-24',
|
||||
};
|
||||
case 'medium':
|
||||
default:
|
||||
return {
|
||||
container: 'p-4',
|
||||
text: 'text-base',
|
||||
textLarge: 'text-xl',
|
||||
progressHeight: 'h-3',
|
||||
icon: 'w-4 h-4',
|
||||
gap: 'gap-3',
|
||||
logoHeight: 'h-16',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function StreamOverlayPage() {
|
||||
const { printerId } = useParams<{ printerId: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const id = parseInt(printerId || '0', 10);
|
||||
const [imageKey, setImageKey] = useState(Date.now());
|
||||
|
||||
const config = useMemo(() => parseConfig(searchParams), [searchParams]);
|
||||
const sizes = getSizeClasses(config.size);
|
||||
|
||||
// Fetch printer info
|
||||
const { data: printer } = useQuery({
|
||||
queryKey: ['printer', id],
|
||||
queryFn: () => api.getPrinter(id),
|
||||
enabled: id > 0,
|
||||
});
|
||||
|
||||
// Fetch printer status with polling
|
||||
const { data: status } = useQuery({
|
||||
queryKey: ['printerStatus', id],
|
||||
queryFn: () => api.getPrinterStatus(id),
|
||||
enabled: id > 0,
|
||||
refetchInterval: 2000,
|
||||
});
|
||||
|
||||
// WebSocket for real-time updates
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/v1/ws`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'printer_status' && data.printer_id === id) {
|
||||
queryClient.setQueryData(['printerStatus', id], data.status);
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
// WebSocket error - polling will continue as fallback
|
||||
};
|
||||
|
||||
return () => {
|
||||
ws.close();
|
||||
};
|
||||
}, [id, queryClient]);
|
||||
|
||||
// Update document title
|
||||
useEffect(() => {
|
||||
document.title = printer ? `${printer.name} - Stream Overlay` : 'Stream Overlay';
|
||||
return () => {
|
||||
document.title = 'Bambuddy';
|
||||
};
|
||||
}, [printer]);
|
||||
|
||||
// Refresh stream on error
|
||||
const handleStreamError = () => {
|
||||
setTimeout(() => {
|
||||
setImageKey(Date.now());
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
if (!id) {
|
||||
return (
|
||||
<div className="min-h-screen bg-black flex items-center justify-center">
|
||||
<p className="text-white">Invalid printer ID</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<div className="min-h-screen bg-black flex items-center justify-center">
|
||||
<p className="text-gray-400">Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isPrinting = status.state === 'RUNNING' || status.state === 'PAUSE';
|
||||
const progress = status.progress || 0;
|
||||
const streamUrl = `/api/v1/printers/${id}/camera/stream?fps=10&t=${imageKey}`;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black relative overflow-hidden">
|
||||
{/* Camera feed - fullscreen background */}
|
||||
<img
|
||||
key={imageKey}
|
||||
src={streamUrl}
|
||||
alt="Camera stream"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
onError={handleStreamError}
|
||||
/>
|
||||
|
||||
{/* Bambuddy logo - top right */}
|
||||
<a
|
||||
href="https://github.com/maziggy/bambuddy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="absolute top-4 right-4 z-10"
|
||||
>
|
||||
<img
|
||||
src="/img/bambuddy_logo_dark_transparent.png"
|
||||
alt="Bambuddy"
|
||||
className={`${sizes.logoHeight} object-contain drop-shadow-lg hover:scale-105 transition-transform`}
|
||||
/>
|
||||
</a>
|
||||
|
||||
{/* Status overlay - bottom */}
|
||||
<div className="absolute bottom-0 left-0 right-0 z-10 bg-gradient-to-t from-black/80 via-black/60 to-transparent">
|
||||
<div className={`${sizes.container}`}>
|
||||
{/* Printer name */}
|
||||
{config.showPrinter && printer && (
|
||||
<div className={`flex items-center ${sizes.gap} mb-2`}>
|
||||
<Printer className={`${sizes.icon} text-white/70`} />
|
||||
<span className={`${sizes.text} text-white font-medium`}>{printer.name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filename */}
|
||||
{config.showFilename && status.current_print && (
|
||||
<div className={`${sizes.textLarge} text-white font-semibold mb-2 truncate drop-shadow-md`}>
|
||||
{status.current_print.replace(/\.gcode\.3mf$|\.3mf$|\.gcode$/i, '')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status text */}
|
||||
{config.showStatus && (
|
||||
<div className={`${sizes.text} text-white/70 mb-2`}>
|
||||
{getStatusText(status)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
{config.showProgress && isPrinting && (
|
||||
<div className="mb-3">
|
||||
<div className={`flex items-center justify-between mb-1 ${sizes.text}`}>
|
||||
<span className="text-white/70">Progress</span>
|
||||
<span className="text-white font-bold">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<div className={`w-full bg-white/20 rounded-full ${sizes.progressHeight}`}>
|
||||
<div
|
||||
className={`bg-bambu-green ${sizes.progressHeight} rounded-full transition-all duration-500`}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats row */}
|
||||
{isPrinting && (config.showLayers || config.showEta) && (
|
||||
<div className={`flex items-center ${sizes.gap} flex-wrap`}>
|
||||
{/* Layers */}
|
||||
{config.showLayers && status.layer_num != null && status.total_layers != null && status.total_layers > 0 && (
|
||||
<div className={`flex items-center ${sizes.gap} text-white/70`}>
|
||||
<Layers className={sizes.icon} />
|
||||
<span className={sizes.text}>
|
||||
<span className="text-white">{status.layer_num}</span>
|
||||
<span className="mx-1">/</span>
|
||||
<span>{status.total_layers}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remaining time */}
|
||||
{config.showEta && status.remaining_time != null && status.remaining_time > 0 && (
|
||||
<>
|
||||
<div className={`flex items-center ${sizes.gap} text-white/70`}>
|
||||
<Timer className={sizes.icon} />
|
||||
<span className={`${sizes.text} text-white`}>
|
||||
{formatTime(status.remaining_time * 60)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center ${sizes.gap} text-white/70`}>
|
||||
<Clock className={sizes.icon} />
|
||||
<span className={`${sizes.text} text-white`}>
|
||||
ETA {formatETA(status.remaining_time)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Idle state */}
|
||||
{!isPrinting && (
|
||||
<div className={`${sizes.text} text-white/70 py-2`}>
|
||||
{status.connected ? 'Printer is idle' : 'Printer offline'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user