Merge pull request #188 from maziggy/feature/164

Streaming overlay page for OBS integration (#164)
This commit is contained in:
MartinNYHC
2026-01-31 15:10:39 +01:00
committed by GitHub
7 changed files with 9217 additions and 0 deletions
+8
View File
@@ -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.)
+1
View File
@@ -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)
+4
View File
@@ -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();
});
});
});
});
+308
View File
@@ -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