Config Page

This commit is contained in:
chk
2026-06-07 10:03:34 +02:00
parent f205418640
commit faccbf55ce
16 changed files with 5375 additions and 69 deletions

View File

@@ -77,13 +77,14 @@ class MpjpegParser {
//
// Events: 'frame' (Buffer) je ein Live-JPEG
class CameraSwitch extends EventEmitter {
constructor({ id, device, liveSize = '640x480', liveFps = 30, hiresSize = '1280x960', hiresFps = 15, encode = 'copybsf', hiresEncode, onDemand = true, idleGraceMs = 15000 }) {
constructor({ id, device, liveSize = '640x480', liveFps = 30, hiresSize = '1280x960', hiresFps = 15, encode = 'copybsf', hiresEncode, onDemand = true, idleGraceMs = 15000, stream = true }) {
super();
this.setMaxListeners(0); // beliebig viele Stream-Clients
this.id = id;
this.device = device;
this.liveSize = liveSize;
this.liveFps = liveFps;
this.streamEnabled = stream; // UI "Aus": Kamera darf NICHT live gehen (gated _spawnLive)
this.hiresSize = hiresSize;
this.hiresFps = hiresFps;
this.encode = encode; // für Live: 'copybsf' (Default) | 'mjpeg' (Re-Encode)
@@ -104,14 +105,14 @@ class CameraSwitch extends EventEmitter {
start() {
// On-Demand: lazy Live startet erst beim ersten Verbraucher (acquire()).
if (this.onDemand) return;
if (this.state === 'stopped' && !this.proc) this._spawnLive();
if (this.streamEnabled && this.state === 'stopped' && !this.proc) this._spawnLive();
}
// ── Verbraucher-Zählung / On-Demand ────────────────────────────────────────
acquire() {
this.subscribers++;
if (this.idleTimer) { clearTimeout(this.idleTimer); this.idleTimer = null; }
if (this.onDemand && this.state === 'stopped' && !this.lock && !this.proc) this._spawnLive();
if (this.onDemand && this.streamEnabled && this.state === 'stopped' && !this.lock && !this.proc) this._spawnLive();
}
release() {
@@ -149,6 +150,7 @@ class CameraSwitch extends EventEmitter {
// ── Live-Producer (Dauerbetrieb, Auto-Restart bei Crash) ───────────────────
_spawnLive() {
if (!this.streamEnabled) return; // UI "Aus" → Kamera bleibt dunkel
this.stopping = false;
const args = [
'-hide_banner', '-loglevel', 'warning',
@@ -193,14 +195,49 @@ class CameraSwitch extends EventEmitter {
}
_scheduleRestart() {
if (!this.streamEnabled) return; // UI "Aus" → kein Auto-Restart
if (this.onDemand && this.subscribers === 0) return; // niemand schaut → nicht neu starten
if (this.restartTimer) return;
this.restartTimer = setTimeout(() => {
this.restartTimer = null;
if (this.state === 'stopped' && !this.lock && (!this.onDemand || this.subscribers > 0)) this._spawnLive();
if (this.streamEnabled && this.state === 'stopped' && !this.lock && (!this.onDemand || this.subscribers > 0)) this._spawnLive();
}, 1500);
}
// ── Hot-Reload (config.html / POST /api/config) ────────────────────────────
// Wendet eine neue Live-Auflösung bzw. Stream-An/Aus zur Laufzeit an, OHNE
// Container-Restart. Nutzt die vorhandenen Bausteine (_killCurrentAndWait /
// _spawnLive) und respektiert Lock (HD-Grab) sowie On-Demand.
async reconfigure({ liveSize, stream } = {}) {
if (typeof stream === 'boolean') this.streamEnabled = stream;
// Während eines HD-Grabs nicht eingreifen die neue liveSize gilt nach dem
// Grab (grabHires startet Live über _spawnLive neu, das this.liveSize liest).
if (this.lock) { if (liveSize) this.liveSize = liveSize; return; }
const sizeChanged = !!liveSize && liveSize !== this.liveSize;
if (liveSize) this.liveSize = liveSize;
// Stream deaktiviert → laufenden Live-Prozess stoppen, NICHT neu starten.
if (!this.streamEnabled) {
if (this.proc && this.state === 'live') await this._killCurrentAndWait();
this.state = 'stopped';
return;
}
// Auflösung geändert → laufenden Prozess beenden (FD frei via close-Event).
if (sizeChanged && this.proc && this.state === 'live') {
await this._killCurrentAndWait();
this.state = 'stopped';
}
// (Neu) starten, wenn nichts läuft und Verbraucher da sind (On-Demand) bzw.
// Dauerbetrieb. _spawnLive ist zusätzlich durch streamEnabled gegated.
if (this.state === 'stopped' && !this.proc && (!this.onDemand || this.subscribers > 0)) {
this._spawnLive();
}
}
// ── HD-Grab ────────────────────────────────────────────────────────────────
// Wenn liveSize == hiresSize: kein Format-Wechsel nötig. Live-Frame direkt
// zurückgeben (on-demand startet den Stream bei Bedarf). Schnell, kein Gerät-

112
src/configService.js Normal file
View File

@@ -0,0 +1,112 @@
'use strict';
const express = require('express');
const { LIVE_SIZES } = require('./liveSizes');
// ── Reine Logik (kein Express, kein fs) Jest-testbar ───────────────────────
// Validiert den POST-Body. reqCameras: [{ id, liveSize?, stream? }].
// Liefert { ok, errors[] }. Ein einziger Fehler kippt das Ergebnis → kein
// Teil-Apply (der Aufrufer wendet nur bei ok:true etwas an).
function validateConfig(reqCameras, knownIds, liveSizes = LIVE_SIZES) {
if (!Array.isArray(reqCameras)) {
return { ok: false, errors: ['"cameras" muss ein Array sein'] };
}
const known = new Set(knownIds);
const errors = [];
for (const c of reqCameras) {
if (!c || typeof c.id !== 'string') {
errors.push(`Eintrag ohne gültige id: ${JSON.stringify(c)}`);
continue;
}
if (!known.has(c.id)) {
errors.push(`Unbekannte Kamera: ${c.id}`);
continue;
}
if ('stream' in c && typeof c.stream !== 'boolean') {
errors.push(`${c.id}: "stream" muss boolean sein`);
}
// liveSize darf bei "Aus" fehlen; wenn gesetzt, muss sie erlaubt sein.
if (c.liveSize != null && !liveSizes.includes(c.liveSize)) {
errors.push(`${c.id}: ungültige Auflösung "${c.liveSize}" (erlaubt: ${liveSizes.join(', ')})`);
}
}
return { ok: errors.length === 0, errors };
}
// Liefert ein NEUES camerasJson-Objekt: patcht nur liveSize/stream der genannten
// Kameras, lässt alle übrigen Felder (device, name, hiresSize, note …) und nicht
// genannte Kameras unangetastet. Mutiert das Original nicht.
function mergeConfig(camerasJson, reqCameras) {
const patchById = new Map(reqCameras.map((c) => [c.id, c]));
return {
...camerasJson,
cameras: camerasJson.cameras.map((cam) => {
const p = patchById.get(cam.id);
if (!p) return cam;
const next = { ...cam };
if (p.liveSize != null) next.liveSize = p.liveSize;
if ('stream' in p) next.stream = p.stream;
return next;
}),
};
}
// Aktueller Ist-Zustand für GET /api/config und die POST-Antwort.
// liveSize aus dem Switch (Laufzeit-Wahrheit), name/stream aus camsMeta.
function currentConfig(switches, camsMeta) {
return {
liveSizes: LIVE_SIZES,
cameras: camsMeta.map((m) => ({
id: m.id,
name: m.name,
liveSize: switches[m.id]?.liveSize ?? null,
stream: m.stream,
})),
};
}
// ── Express-Router ───────────────────────────────────────────────────────────
// GET /api/config → { liveSizes, cameras:[{id,name,liveSize,stream}] }
// POST /api/config → Body { cameras:[{id,liveSize?,stream}] }
// validiert → cameras.json atomar schreiben → Hot-Reload
function createConfigRouter({ switches, camsMeta, getCamerasJson, setCamerasJson, persist }) {
const router = express.Router();
router.get('/', (_req, res) => {
res.json(currentConfig(switches, camsMeta));
});
router.post('/', async (req, res) => {
const reqCameras = req.body && req.body.cameras;
const v = validateConfig(reqCameras, camsMeta.map((c) => c.id));
if (!v.ok) return res.status(400).json({ error: v.errors.join('; ') });
// 1. cameras.json in-memory patchen + atomar persistieren (übrige Felder bleiben).
const merged = mergeConfig(getCamerasJson(), reqCameras);
try {
persist(merged);
} catch (e) {
return res.status(500).json({ error: `Persistieren fehlgeschlagen: ${e.message}` });
}
setCamerasJson(merged);
// 2. camsMeta.stream nachziehen (Viewer respektiert das beim nächsten Laden).
for (const c of reqCameras) {
const meta = camsMeta.find((m) => m.id === c.id);
if (meta && 'stream' in c) meta.stream = c.stream;
}
// 3. Hot-Reload pro genannter Kamera (Auflösung sofort aktiv, Aus/An n. Reload).
await Promise.allSettled(reqCameras.map((c) => {
const sw = switches[c.id];
return sw ? sw.reconfigure({ liveSize: c.liveSize, stream: c.stream }) : Promise.resolve();
}));
res.json(currentConfig(switches, camsMeta));
});
return router;
}
module.exports = { validateConfig, mergeConfig, currentConfig, createConfigRouter };

10
src/liveSizes.js Normal file
View File

@@ -0,0 +1,10 @@
'use strict';
// MJPEG-native Live-Auflösungen ALLER eingesetzten Kameras (C270 / C920 / C922).
// Auf dem Host mit `v4l2-ctl --list-formats-ext` verifiziert (2026-06-07).
// Single Source of Truth: server.js (Validierung) + config.html (über /api/config).
// NUR diese Auflösungen verwenden sonst fällt V4L2 auf YUYV (unkomprimiert) zurück
// und FFmpeg muss software-encoden (~50% CPU pro Kamera). Siehe doc/12 + doc/09.
const LIVE_SIZES = ['160x120', '320x240', '640x360', '640x480', '800x600', '1280x720'];
module.exports = { LIVE_SIZES };