Config Page
This commit is contained in:
@@ -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
112
src/configService.js
Normal 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
10
src/liveSizes.js
Normal 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 };
|
||||
Reference in New Issue
Block a user