Umbau mit cameraSwitch

This commit is contained in:
chk
2026-06-05 06:36:48 +02:00
parent 0ea475d6b6
commit 8c8c769e22
10 changed files with 641 additions and 839 deletions

151
server.js
View File

@@ -1,140 +1,71 @@
'use strict';
const express = require('express');
const http = require('http');
const path = require('path');
const { createProxyMiddleware } = require('http-proxy-middleware');
const { createSnapshotRouter } = require('./src/snapshotService');
const http = require('http');
const path = require('path');
const { CameraSwitch } = require('./src/cameraSwitch');
const { detectDevices } = require('./src/deviceDetect');
const { createSnapshotRouter, createStreamRouter } = require('./src/snapshotService');
const PORT = parseInt(process.env.PORT ?? '8444', 10);
const GO2RTC_URL = process.env.GO2RTC_URL ?? 'http://localhost:1984';
const GO2RTC_PORT = parseInt(process.env.GO2RTC_PORT ?? '1984', 10);
const PORT = parseInt(process.env.PORT ?? '8444', 10);
const LIVE_SIZE = process.env.LIVE_SIZE ?? '640x480';
const LIVE_FPS = parseInt(process.env.LIVE_FPS ?? '30', 10);
const HIRES_SIZE = process.env.HIRES_SIZE ?? '1280x960';
const HIRES_FPS = parseInt(process.env.HIRES_FPS ?? '15', 10);
// ── Kameras: cam0 = erstes Gerät, cam1 = zweites … (DEV0/DEV1-Env überschreibt) ─
const devices = detectDevices();
const switches = {};
devices.forEach((device, i) => {
const id = `cam${i}`;
switches[id] = new CameraSwitch({ id, device, liveSize: LIVE_SIZE, liveFps: LIVE_FPS, hiresSize: HIRES_SIZE, hiresFps: HIRES_FPS });
});
const app = express();
// ── 1. Eigene Endpunkte (vor dem Proxy registrieren) ─────────────────────────
// ── 1. Eigene Endpunkte ───────────────────────────────────────────────────────
app.use('/api/snapshot', createSnapshotRouter(switches));
app.use('/api/stream', createStreamRouter(switches));
app.use('/api/snapshot', createSnapshotRouter(GO2RTC_URL));
app.get('/health', async (_req, res) => {
try {
const r = await fetch(`${GO2RTC_URL}/api/streams`);
const streams = r.ok ? await r.json() : {};
res.json({ status: r.ok ? 'ok' : 'degraded', cameras: Object.keys(streams) });
} catch (err) {
res.status(503).json({ status: 'down', error: err.message });
}
app.get('/health', (_req, res) => {
res.json({
status: 'ok',
cameras: Object.values(switches).map((sw) => ({
id: sw.id, device: sw.device, state: sw.state, hasFrame: !!sw.latest,
})),
});
});
app.get('/config.json', (_req, res) => {
res.json({ go2rtcPort: GO2RTC_PORT });
res.json({ cameras: Object.keys(switches) });
});
// ── 2. HTTP-Proxy zu go2rtc ───────────────────────────────────────────────────
const go2rtcProxy = createProxyMiddleware({
target: GO2RTC_URL,
changeOrigin: true,
pathFilter: ['/api', '/video-rtc.js', '/video-stream.js'],
logger: console,
on: {
error: (err, _req, res) => {
console.error('[HPM] proxy error:', err.message);
if (!res.headersSent) res.status(502).json({ error: 'go2rtc nicht erreichbar' });
},
},
});
app.use(go2rtcProxy);
// ── 3. Statische Dateien ──────────────────────────────────────────────────────
// no-cache: Browser MUSS viewer.js/index.html vor Nutzung revalidieren. Verhindert,
// dass eine alte gecachte viewer.js (z.B. mit WebRTC-Modus) weiterläuft → sonst
// transcodiert go2rtc nach H.264 = ~108% CPU statt ~50% (MJPEG).
// ── 2. Statische Dateien ──────────────────────────────────────────────────────
// no-cache: Browser MUSS index.html/viewer.js vor Nutzung revalidieren.
app.use(express.static(path.join(__dirname, 'public'), {
setHeaders: (res) => res.setHeader('Cache-Control', 'no-cache'),
}));
// ── 4. go2rtc Stream-Monitor (server-seitiges Logging) ───────────────────────
// Pollt alle 5 s go2rtc /api/streams und loggt Änderungen.
// Sichtbar im Portainer-Log von AppRobotWebcam.
// Logt: Producer-Starts/-Stops, Consumer-Anzahl, Timeouts/Restarts.
//
// go2rtc /api/streams liefert z.B.:
// { "cam0": { "producers": [{"url":"...","state":"running"}], "consumers": [...] } }
//
const STREAM_POLL_MS = 5000;
let prevStreamState = {};
async function pollGo2rtcStreams() {
try {
const r = await fetch(`${GO2RTC_URL}/api/streams`);
if (!r.ok) { console.warn(`[monitor] /api/streams → HTTP ${r.status}`); return; }
const streams = await r.json();
for (const [name, data] of Object.entries(streams)) {
const producers = data.producers ?? [];
const consumers = data.consumers ?? [];
const nConsumers = consumers.length;
const prev = prevStreamState[name] ?? {};
// Producer-Status
for (let i = 0; i < producers.length; i++) {
const p = producers[i];
const state = p.state ?? 'unknown';
const key = `${name}.p${i}`;
const pPrev = prevStreamState[key];
if (pPrev !== state) {
if (state === 'running') console.log(`[monitor][${name}] producer #${i} LÄUFT (${p.url ?? ''})`);
if (state === 'error') console.error(`[monitor][${name}] producer #${i} FEHLER (${p.url ?? ''})`);
if (state === 'stop') console.warn(`[monitor][${name}] producer #${i} GESTOPPT`);
if (!['running','error','stop'].includes(state))
console.log(`[monitor][${name}] producer #${i} state="${state}"`);
prevStreamState[key] = state;
}
}
// Consumer-Anzahl — nur loggen wenn sie sich ändert
if (prev.nConsumers !== nConsumers) {
console.log(`[monitor][${name}] consumers: ${prev.nConsumers ?? '?'}${nConsumers}`);
prevStreamState[name] = { ...prev, nConsumers };
}
}
// Streams die verschwunden sind (Timeout/Restart)
for (const name of Object.keys(prevStreamState)) {
if (name.includes('.')) continue; // skip producer-state keys
if (!streams[name]) {
console.warn(`[monitor][${name}] Stream verschwunden aus go2rtc`);
delete prevStreamState[name];
}
}
} catch (err) {
console.error('[monitor] go2rtc nicht erreichbar:', err.message);
}
}
// ── Start ─────────────────────────────────────────────────────────────────────
// ── 3. Start ──────────────────────────────────────────────────────────────────
const server = http.createServer(app);
server.listen(PORT, '0.0.0.0', () => {
console.log(`AppRobotWebcam http://0.0.0.0:${PORT}`);
console.log(` go2rtc HTTP: ${GO2RTC_URL}`);
console.log(` go2rtc WS: ws://[host]:${GO2RTC_PORT} (Browser verbindet direkt)`);
console.log(` Kameras: ${Object.entries(switches).map(([id, sw]) => `${id}=${sw.device}`).join(', ')}`);
console.log(` Live: ${LIVE_SIZE}@${LIVE_FPS} · HD-Grab: ${HIRES_SIZE}@${HIRES_FPS}`);
console.log(` Viewer: http://0.0.0.0:${PORT}/`);
console.log(` Snapshot API: http://0.0.0.0:${PORT}/api/snapshot/cam0`);
console.log(` Stream-Monitor: alle ${STREAM_POLL_MS / 1000}s → Portainer-Log`);
console.log(` Live-Stream: http://0.0.0.0:${PORT}/api/stream/cam0`);
console.log(` Snapshot API: http://0.0.0.0:${PORT}/api/snapshot/cam0 (+ /hires)`);
// Ersten Poll nach 3 s (go2rtc braucht einen Moment zum Starten)
setTimeout(() => {
pollGo2rtcStreams();
setInterval(pollGo2rtcStreams, STREAM_POLL_MS);
}, 3000);
// Live-Producer starten (Dauerbetrieb)
Object.values(switches).forEach((sw) => sw.start());
});
const shutdown = (sig) => {
console.log(`\n${sig} shutting down`);
Object.values(switches).forEach((sw) => { sw.stopping = true; if (sw.proc) { try { sw.proc.kill('SIGKILL'); } catch (_e) {} } });
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 3000); // Sicherheitsnetz
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));