diff --git a/doc/12_cameraConfig_roadmap.md b/doc/12_cameraConfig_roadmap.md index c977f93..40e3a0c 100644 --- a/doc/12_cameraConfig_roadmap.md +++ b/doc/12_cameraConfig_roadmap.md @@ -75,8 +75,12 @@ Separate Admin-Seite (kein Viewer-Umbau nötig). Pro Kamera eine Zeile mit Combo **ComboBox-Optionen pro Kamera** (zentral als Konstante, von Server + Client geteilt): `Aus`, `160×120`, `320×240`, `640×360`, `640×480`, `800×600`, `1280×720` -- `Aus` → `stream: false` (kein Live-Stream; Viewer zeigt Platzhalter nach Reload) -- Auflösung → `stream: true` + `liveSize: "x"` +- `Aus` → `stream: false` = **Snapshot-Modus**: kein Video. Der Viewer zeigt stattdessen + alle 5 s ein Einzelbild (`GET /api/snapshot/`) mit grossem Banner „Single Picture + no Video". Server-seitig öffnet jeder Snapshot das Gerät kurz (one-shot via + `grabSnapshot()`) und schliesst es wieder → Gerät meist geschlossen, minimale Mobil- + Bandbreite (1 JPEG/5 s statt 30/s). +- Auflösung → `stream: true` + `liveSize: "x"` (kontinuierlicher MJPEG-Stream) - Bei cam2 (C920) ein dezenter ⚠-Hinweis-Tooltip: „C920 braucht bei kleinen 4:3-Auflösungen überdurchschnittlich Bandbreite – siehe Doku." @@ -126,11 +130,12 @@ async reconfigure({ liveSize, stream } = {}) { } ``` -> Hinweis: `stream:false`-Handling läuft primär über `camsMeta` (Viewer baut dann keinen -> `` → kein `acquire()` → On-Demand lässt FFmpeg aus). `reconfigure` muss für `Aus` -> daher nichts hart killen; ein bereits laufender Stream stoppt nach Viewer-Reload via -> Idle-Grace. Optional kann bei `stream:false` zusätzlich `_killCurrentAndWait()` gerufen -> werden, falls sofortiges Stoppen gewünscht ist. +> **Umgesetzt:** `CameraSwitch` hat ein `streamEnabled`-Flag, das NUR den kontinuierlichen +> Live-Stream sperrt (`_spawnLive`/`acquire`/`_scheduleRestart` sind dadurch gegated). +> Einzel-Snapshots umgehen das über `grabSnapshot()` (one-shot open/grab/close an +> `liveSize`) – so liefert eine „Aus"-Kamera weiterhin Bilder, ohne dauerhaft zu streamen. +> `reconfigure({stream:false})` killt einen evtl. laufenden Live-Prozess und startet ihn +> nicht neu. **Wichtig – laufende Browser-``-Streams:** Eine reine *Auflösungs*-Änderung ist für den Client nahtlos – der multipart-Stream läuft weiter, nur die Frame-Grösse ändert sich diff --git a/public/index.html b/public/index.html index bd005ca..776aab2 100644 --- a/public/index.html +++ b/public/index.html @@ -79,6 +79,15 @@ color: #444; font-size: 0.82rem; letter-spacing: 0.04em; } + /* Snapshot-Modus: gross+fett über dem Einzelbild */ + .single-pic-banner { + position: absolute; top: 0; left: 0; right: 0; z-index: 3; + text-align: center; padding: 8px 4px; + font-size: 1.15rem; font-weight: bold; letter-spacing: 0.06em; + text-transform: uppercase; + color: #fc6; background: rgba(0,0,0,.72); + } + /* Hi-Res-Test-Button (Phase 1) – links neben dem Ein/Aus-Schalter */ .cam-hdtest { position: absolute; top: 5px; right: 40px; z-index: 2; diff --git a/public/viewer.js b/public/viewer.js index 6efa222..34079e3 100644 --- a/public/viewer.js +++ b/public/viewer.js @@ -38,6 +38,37 @@ function stopStream(cam) { log(cam.id, 'Live aus'); } +// ── Snapshot-Modus (stream:false): alle 5 s ein Einzelbild ────────────────── +// Kein Video – der Server öffnet das Gerät pro Snapshot kurz (one-shot). Fehler +// (z. B. Gerät gerade durch HD-Grab belegt) werden still übersprungen, das letzte +// gute Bild bleibt stehen. +const SNAPSHOT_INTERVAL_MS = 5000; + +async function fetchSnapshot(cam) { + if (!cam.snapshotActive) return; + try { + const r = await fetch(`/api/snapshot/${encodeURIComponent(cam.id)}?t=${Date.now()}`, + { signal: AbortSignal.timeout(8000) }); + if (!r.ok) { setInfo(cam, `Snapshot-Fehler (HTTP ${r.status})`, 'warn'); return; } + const blob = await r.blob(); + const url = URL.createObjectURL(blob); + cam.img.src = url; + if (cam.lastBlobUrl) URL.revokeObjectURL(cam.lastBlobUrl); + cam.lastBlobUrl = url; + setInfo(cam, `Einzelbild · ${new Date().toLocaleTimeString()}`, 'ok'); + } catch (_e) { + setInfo(cam, 'Snapshot-Timeout', 'warn'); + } +} + +function startSnapshotMode(cam) { + cam.snapshotActive = true; + setInfo(cam, 'Einzelbild lädt…', ''); + fetchSnapshot(cam); // sofort das erste Bild + cam.snapTimer = setInterval(() => fetchSnapshot(cam), SNAPSHOT_INTERVAL_MS); + log(cam.id, `Snapshot-Modus (alle ${SNAPSHOT_INTERVAL_MS / 1000} s)`); +} + // ── HD-Snapshot ─────────────────────────────────────────────────────────────── async function runHiresGrab(cam) { if (cam.busy) return; @@ -144,11 +175,21 @@ function buildCamera(camMeta, container) { box.appendChild(toggle); startStream(cam); } else { - const placeholder = document.createElement('div'); - placeholder.className = 'cam-img cam-placeholder'; - placeholder.textContent = 'Kein Live-Stream'; - hd.title = 'Hi-Res-Snapshot (1280×960) – Download'; - box.appendChild(placeholder); + // Snapshot-Modus: grosser Banner + Einzelbild, das alle 5 s aktualisiert wird. + const banner = document.createElement('div'); + banner.className = 'single-pic-banner'; + banner.textContent = 'Single Picture no Video'; + + const img = document.createElement('img'); + img.className = 'cam-img'; + img.alt = labelText; + + cam.img = img; + hd.title = 'Hi-Res-Snapshot – Download'; + + box.appendChild(banner); + box.appendChild(img); + startSnapshotMode(cam); } box.appendChild(label); diff --git a/src/cameraSwitch.js b/src/cameraSwitch.js index 71269a4..37b5323 100644 --- a/src/cameraSwitch.js +++ b/src/cameraSwitch.js @@ -250,7 +250,7 @@ class CameraSwitch extends EventEmitter { async grabHires(opts = {}) { // Shortcut: keine Format-Umschaltung wenn Live- und Hires-Auflösung identisch if (this.liveSize === this.hiresSize) { - return this.getFrame(); + return this.grabSnapshot(); } const hiresW = parseInt(this.hiresSize.split('x')[0], 10); @@ -278,7 +278,7 @@ class CameraSwitch extends EventEmitter { // 2. hires-FFmpeg starten, warmlaufen lassen (settleFrames), besten Frame greifen. // minWidth lehnt etwaige Übergangs-Frames in falscher Auflösung ab. - const jpeg = await this._captureHires({ minSize, minWidth, settleFrames, maxWaitMs }); + const jpeg = await this._captureAt(this.hiresSize, this.hiresFps, this.hiresEncode, { minSize, minWidth, settleFrames, maxWaitMs }); const gotW = readJpegWidth(jpeg) ?? '?'; console.log(`[cam ${this.id}] HD OK – ${jpeg.length} bytes, Breite=${gotW}px (Soll: ${hiresW}px, ${Date.now() - t0}ms)`); return jpeg; @@ -291,6 +291,36 @@ class CameraSwitch extends EventEmitter { } } + // ── Einzelbild-Snapshot (für /api/snapshot, inkl. Snapshot-Modus) ────────── + // Läuft Live → aktuelles Frame (schnell, kein Geräte-Neustart). Sonst: + // • streamEnabled (Live an, aber Frame noch nicht da) → getFrame() (on-demand). + // • stream:false (Snapshot-Modus) → one-shot open/grab/close an liveSize. Das + // Gerät bleibt dazwischen geschlossen → spart CPU/USB; der Viewer pollt das + // alle paar Sekunden. streamEnabled bleibt aus (kein Dauer-Stream). + async grabSnapshot() { + if (this.latest) return this.latest; // Live läuft → sofort + if (this.streamEnabled) return this.getFrame(); // Live an, Frame kommt gleich + + if (this.lock) throw new Error('Gerät belegt (Grab läuft)'); + this.lock = true; + if (this.restartTimer) { clearTimeout(this.restartTimer); this.restartTimer = null; } + this.state = 'grabbing'; + try { + const w = parseInt(this.liveSize.split('x')[0], 10) || 0; + return await this._captureAt(this.liveSize, this.liveFps, this.encode, { + minSize: 1000, + minWidth: w ? Math.floor(w * 0.8) : 0, + settleFrames: 3, + maxWaitMs: 6000, + }); + } finally { + this.state = 'stopped'; + this.lock = false; + // Falls inzwischen wieder Live gewünscht ist (stream:true + Verbraucher): + if (this.streamEnabled && (!this.onDemand || this.subscribers > 0)) this._spawnLive(); + } + } + // Beendet den aktuellen Prozess und resolved erst nach dessen 'close' (FD frei). _killCurrentAndWait(timeoutMs = 4000) { return new Promise((resolve) => { @@ -306,7 +336,9 @@ class CameraSwitch extends EventEmitter { }); } - _captureHires({ minSize, minWidth, settleFrames, maxWaitMs }) { + // Generischer one-shot Capture an beliebiger Auflösung (open → settle → grab → close). + // Genutzt von grabHires (hiresSize) und grabSnapshot (liveSize, Snapshot-Modus). + _captureAt(size, fps, encode, { minSize, minWidth, settleFrames, maxWaitMs }) { return new Promise((resolve, reject) => { const args = [ '-hide_banner', '-loglevel', 'warning', @@ -314,9 +346,9 @@ class CameraSwitch extends EventEmitter { '-probesize', '5000000', '-analyzeduration', '1000000', '-f', 'v4l2', '-input_format', 'mjpeg', - '-video_size', this.hiresSize, '-framerate', String(this.hiresFps), + '-video_size', size, '-framerate', String(fps), '-i', this.device, - ...videoOutArgs(this.hiresEncode), '-f', 'mpjpeg', 'pipe:1', + ...videoOutArgs(encode), '-f', 'mpjpeg', 'pipe:1', ]; let p; try { diff --git a/src/snapshotService.js b/src/snapshotService.js index 43c273d..3c41df4 100644 --- a/src/snapshotService.js +++ b/src/snapshotService.js @@ -54,8 +54,9 @@ function createSnapshotRouter(switches, cameras) { const sw = switches[req.params.id]; if (!sw) return res.status(404).json({ error: `Unbekannte Kamera: ${req.params.id}` }); try { - // getFrame() startet die Kamera bei Bedarf on-demand und wartet auf ein frisches Bild - const frame = await sw.getFrame(); + // grabSnapshot(): liefert das Live-Frame falls vorhanden, sonst (Snapshot-Modus, + // stream:false) ein one-shot Bild – öffnet das Gerät kurz und schliesst es wieder. + const frame = await sw.grabSnapshot(); res.set({ 'Content-Type': 'image/jpeg', 'Content-Length': frame.length, diff --git a/test/grabSnapshot.test.js b/test/grabSnapshot.test.js new file mode 100644 index 0000000..c32b5e2 --- /dev/null +++ b/test/grabSnapshot.test.js @@ -0,0 +1,48 @@ +'use strict'; + +const { CameraSwitch } = require('../src/cameraSwitch'); + +// Testet die Entscheidungslogik von grabSnapshot() ohne Hardware: +// _captureAt (FFmpeg) und _spawnLive sind gemockt. +function makeSwitch(opts = {}) { + const sw = new CameraSwitch({ id: 'camT', device: '/dev/null', liveSize: '640x480', ...opts }); + sw._captureAt = jest.fn(() => Promise.resolve(Buffer.from([0xFF, 0xD8, 9]))); + sw._spawnLive = jest.fn(() => { sw.proc = {}; sw.state = 'live'; }); + return sw; +} + +describe('CameraSwitch.grabSnapshot', () => { + test('Live läuft (latest gesetzt) → liefert latest, kein Capture', async () => { + const sw = makeSwitch({ stream: true }); + sw.latest = Buffer.from([1, 2, 3]); + const f = await sw.grabSnapshot(); + expect(f).toBe(sw.latest); + expect(sw._captureAt).not.toHaveBeenCalled(); + }); + + test('stream:false ohne latest → one-shot _captureAt an liveSize', async () => { + const sw = makeSwitch({ stream: false }); + sw.latest = null; + const f = await sw.grabSnapshot(); + expect(Buffer.isBuffer(f)).toBe(true); + expect(sw._captureAt).toHaveBeenCalledTimes(1); + expect(sw._captureAt.mock.calls[0][0]).toBe('640x480'); // size = liveSize + expect(sw._spawnLive).not.toHaveBeenCalled(); // kein Dauer-Stream + }); + + test('Lock wird nach dem one-shot wieder freigegeben', async () => { + const sw = makeSwitch({ stream: false }); + sw.latest = null; + await sw.grabSnapshot(); + expect(sw.lock).toBe(false); + expect(sw.state).toBe('stopped'); + }); + + test('belegtes Gerät (lock) → Fehler, kein zweiter Capture', async () => { + const sw = makeSwitch({ stream: false }); + sw.latest = null; + sw.lock = true; + await expect(sw.grabSnapshot()).rejects.toThrow(/belegt/i); + expect(sw._captureAt).not.toHaveBeenCalled(); + }); +});