14 KiB
Roadmap – Dynamische Kamera-Konfiguration (config.html)
Ziel: Live-Auflösung pro Kamera zur Laufzeit umschalten, ohne Datei-Editor oder
Container-Restart. Erreichbar über einen Link aus index.html.
Priorität (unverändert): Latenz > CPU > Bildqualität. Bandbreite ist der Engpass beim Internet-Zugriff.
Kontext
Alle drei Kameras (C270, C920, C922) unterstützen dieselben MJPEG-nativen Auflösungen.
Auf dem Host bestätigt (v4l2-ctl --list-formats-ext):
| Auflösung | Pixel | Faktor ggü. 640×480 | Seitenverhältnis |
|---|---|---|---|
| 160×120 | 19'200 | −94% | 4:3 |
| 320×240 | 76'800 | −75% | 4:3 |
| 640×360 | 230'400 | −25% | 16:9 |
| 640×480 | 307'200 | Referenz (aktuell) | 4:3 |
| 800×600 | 480'000 | +56% | 4:3 |
| 1280×720 | 921'600 | +200% | 16:9 |
Nur MJPEG-native Auflösungen verwenden – sonst fällt V4L2 auf YUYV (unkomprimiert) zurück
und FFmpeg muss software-encoden (~50% CPU pro Kamera). Siehe 09_Bug_reports.md.
ℹ️ Info: C920 Bandbreiten-Anomalie (nur Doku, NICHT umsetzen)
Messung auf dem Host (2026-06-07), alle Kameras auf liveSize: 320x240:
| Setup | Gesamt-Bandbreite |
|---|---|
| nur cam0 (C270) bzw. cam1 (C270) | ~4 Mbps pro Kamera |
| cam2 (C920) allein | ~13 Mbps |
Befund: Die C920 produziert bei kleinen 4:3-Auflösungen (320×240) unverhältnismässig
grosse MJPEG-Frames. Ihr Hardware-Encoder ist für den nativen 1920×1080-Sensor (16:9)
kalibriert und komprimiert bei heruntergerechneten 4:3-Formaten kaum – ~3× mehr Bandbreite
als die C270 bei identischer Auflösung. Die compression_quality lässt sich per V4L2 nicht
steuern (auf dem Host geprüft: Control existiert nicht), und es gibt kein Logitech-Tool dafür.
Mögliche spätere Gegenmassnahmen für cam2 (bewusst NICHT Teil dieser Roadmap):
- Native 16:9-Auflösung erzwingen –
640x360statt320x240. Mehr Pixel, aber der Encoder arbeitet im nativen Seitenverhältnis evtl. effizienter. Muss gemessen werden. - FPS senken –
liveFps: 10für cam2. Linear weniger Bandbreite, keine Latenz-Kosten. - Server-Re-Encode –
"encode": "mjpeg"+ steuerbares-q:vnur für cam2. Volle Kontrolle über die Frame-Grösse, kostet aber ~20% CPU für diese eine Kamera.
→ Diese drei Punkte sind ein separates Thema (MJPEG-Qualität / FPS). Diese Roadmap deckt nur die Auflösungs-Umschaltung ab. Die Erkenntnis ist hier nur dokumentiert, damit bei der UI-Auswahl klar ist, warum 320×240 für die C920 nicht automatisch „sparsam" bedeutet.
Geplante UI: /config.html
Separate Admin-Seite (kein Viewer-Umbau nötig). Pro Kamera eine Zeile mit ComboBox:
┌─────────────────────────────────────────────────────────┐
│ AppRobotWebcam · Konfiguration [← zum Viewer] │
├──────────┬──────────────┬────────────────┬──────────────┤
│ cam0 │ Kamera 0 │ [320×240 ▼] │ aktuell: 320×240 │
│ cam1 │ Kamera 1 │ [640×360 ▼] │ aktuell: 640×360 │
│ cam2 │ Kamera 2 ⚠ │ [Aus ▼] │ aktuell: aus │
├──────────┴──────────────┴────────────────┴──────────────┤
│ Status: bereit [Speichern & Anwenden] │
└─────────────────────────────────────────────────────────┘
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: "<W>x<H>" - Bei cam2 (C920) ein dezenter ⚠-Hinweis-Tooltip: „C920 braucht bei kleinen 4:3-Auflösungen überdurchschnittlich Bandbreite – siehe Doku."
Implementierung
Reihenfolge: Phase 3 (Hot-Reload) → Phase 1 (API) → Phase 2 (UI) → Phase 4 (Link).
So ist jede Phase einzeln testbar (zuerst per curl, dann erst die UI).
Erlaubte Auflösungen – Single Source of Truth
Neue Datei src/liveSizes.js, von server.js und config.html (über /api/config) genutzt:
'use strict';
// MJPEG-native Auflösungen ALLER eingesetzten Kameras (C270/C920/C922).
// Auf dem Host mit `v4l2-ctl --list-formats-ext` verifiziert (2026-06-07).
const LIVE_SIZES = ['160x120', '320x240', '640x360', '640x480', '800x600', '1280x720'];
module.exports = { LIVE_SIZES };
videoOutArgs/Re-Encode bleibt unberührt. Validierung im POST-Handler nutzt diese Liste.
Phase 3 – Hot-Reload in CameraSwitch (src/cameraSwitch.js)
Neue Methode reconfigure({ liveSize, stream }). Nutzt die vorhandenen Bausteine
(_killCurrentAndWait, _spawnLive), respektiert Lock und On-Demand:
async reconfigure({ liveSize, stream } = {}) {
// Während eines HD-Grabs nicht eingreifen – nach dem Grab gilt eh die neue liveSize.
if (this.lock) { if (liveSize) this.liveSize = liveSize; this.streamEnabled = stream; return; }
const sizeChanged = liveSize && liveSize !== this.liveSize;
if (liveSize) this.liveSize = liveSize;
// Laufenden Live-Prozess stoppen, damit er mit neuer Auflösung neu startet.
if (sizeChanged && this.proc && this.state === 'live') {
await this._killCurrentAndWait(); // FD frei (close-Event)
}
// Neu starten nur wenn Stream erwünscht UND (On-Demand: Verbraucher vorhanden).
if (this.state === 'stopped' && !this.proc &&
(!this.onDemand || this.subscribers > 0)) {
this._spawnLive();
}
}
Hinweis:
stream:false-Handling läuft primär übercamsMeta(Viewer baut dann keinen<img>→ keinacquire()→ On-Demand lässt FFmpeg aus).reconfiguremuss fürAusdaher nichts hart killen; ein bereits laufender Stream stoppt nach Viewer-Reload via Idle-Grace. Optional kann beistream:falsezusätzlich_killCurrentAndWait()gerufen werden, falls sofortiges Stoppen gewünscht ist.
Wichtig – laufende Browser-<img>-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
(kurzes Einfrieren während des FFmpeg-Neustarts, wie beim HD-Grab). Ein Aus-Schalten wirkt
erst nach Viewer-Reload. Das in der UI-Statuszeile vermerken.
Phase 1 – API (server.js, neuer Router src/configService.js)
server.js hält bereits camerasJson (vollständig geparst) und switches/camsMeta.
Den geparsten camerasJson als let im Modulscope behalten, damit der POST-Handler beim
Schreiben alle übrigen Felder erhält (device, name, hiresSize, note …) und nur
liveSize/stream patcht.
GET /api/config → aktuelle Konfiguration + erlaubte Optionen:
{
"liveSizes": ["160x120","320x240","640x360","640x480","800x600","1280x720"],
"cameras": [
{ "id": "cam0", "name": "Kamera 0", "liveSize": "320x240", "stream": true },
{ "id": "cam1", "name": "Kamera 1", "liveSize": "640x360", "stream": true },
{ "id": "cam2", "name": "Kamera 2", "liveSize": "320x240", "stream": false }
]
}
liveSize pro Kamera aus switches[id].liveSize (Laufzeit-Wahrheit), stream/name aus
camsMeta.
POST /api/config (Body = Array von { id, liveSize?, stream }):
- Validieren: jede
idmuss existieren;liveSize(falls gesetzt) muss inLIVE_SIZESsein; sonst400mit Fehlertext, keine Teiländerung. - In-Memory patchen:
camsMeta[i].streamsetzen; incamerasJson.cameras[i]liveSize/streamsetzen (übrige Felder bleiben). - Persistieren:
cameras.jsonatomar schreiben → incameras.json.tmpschreiben, dannfs.renameSyncüber das Original (kein halb-geschriebenes File bei Crash). - Anwenden: für jede geänderte Kamera
await switches[id].reconfigure({...}). - Antwort
200mit der frischen Config (wie GET), damit die UI sofort den Ist-Stand zeigt.
express.json()-Middleware in server.js ergänzen (aktuell nicht aktiv). Router unter
app.use('/api/config', createConfigRouter(switches, camsMeta, () => camerasJson, persistFn)).
Phase 2 – public/config.html + public/config.js
- Stil aus
index.htmlübernehmen (dunkles Monospace-Theme, gleiche Header-Leiste). - Beim Laden
GET /api/config→ Tabelle rendern,<select>ausliveSizes+Aus, aktuellen Wert vorselektieren. [Speichern & Anwenden]→POST /api/configmit dem Array; Statuszeile zeigt Erfolg/Fehler. Bei Erfolg Hinweis: „Auflösungs-Änderung sofort aktiv; Ein/Aus erst nach Viewer-Reload."- Link
[← zum Viewer]zurück auf/. - Keine Authentifizierung (internes Netz, wie der Viewer). Bei späterem Internet-Zugang nachrüsten (siehe Abgrenzung).
Phase 4 – Link aus index.html
In die <header>-Leiste von public/index.html einen Link neben #snapAllBtn setzen,
z. B. vor oder nach dem Snapshot-Button:
<a id="configLink" href="config.html" title="Kamera-Konfiguration">⚙ Config</a>
Styling analog #snapAllBtn (kleiner Button-Look, margin-left/Reihenfolge beachten,
damit snapAllBtn mit margin-left:auto weiterhin rechts bündig bleibt – Link davor
einfügen oder eine kleine Flex-Gruppe rechts bilden).
Test-Strategie
Zwei Ebenen: Jest-Unit-Tests für reine Logik (auf der Dev-Maschine, ohne Hardware) und Host-Tests für alles, was echte Kameras/FFmpeg/Bandbreite betrifft.
Phase 5 – Jest-Unit-Tests (neu)
Setup (Jest wird in anderen Projekten bereits genutzt):
package.json:jestals devDependency + Script"test": "jest".- Wichtig:
docker-compose.yamlinstalliert mitnpm install --omit=dev→ Jest landet nicht im Container, bläht das Image nicht auf. Tests laufen nur lokal/CI. - Konvention: Tests unter
test/*.test.js(CommonJS, wie der Rest des Projekts). - Der bestehende
test/grabSnapShot.pybleibt unverändert – das ist der manuelle Integrationstest gegen den laufenden Server, keine Jest-Datei (testMatchgreift nur*.test.js,.pywird ignoriert).
Test-Umfang – nur testbare reine Logik / Entscheidungslogik:
| Datei | Was getestet wird |
|---|---|
test/configValidate.test.js |
POST-Validierung: unbekannte id → Fehler; liveSize ∉ LIVE_SIZES → Fehler; Aus/stream:false ok; gültige Auflösung ok. Kein Teil-Apply bei einem Fehler. |
test/configMerge.test.js |
Kernrisiko: Patch von liveSize/stream in einem cameras.json-Objekt lässt device, name, hiresSize, note etc. unangetastet. Reine Objekt-Transformation (kein fs). |
test/reconfigure.test.js |
CameraSwitch.reconfigure() mit gemockten _killCurrentAndWait/_spawnLive (und gemocktem spawn): geänderte liveSize → genau 1× kill + 1× spawn; keine Änderung → no-op; lock===true → nur Feld setzen, kein kill/spawn. |
test/mpjpegParser.test.js |
Bonus, schon jetzt sinnvoll: MpjpegParser (bereits exportiert) – ein Frame, mehrere Frames in einem Chunk, über Chunk-Grenzen gesplittetes Frame, Müll vor Header. |
test/readJpegWidth.test.js |
Bonus: readJpegWidth mit echten kleinen JPEG-Headern (SOF0/SOF2) + Fehlerfall (kein Marker → null). |
Damit gut testbar wird, Validierung und Merge als reine, exportierte Funktionen
faktorieren (z. B. in src/configService.js):
function validateConfig(reqCameras, knownIds, liveSizes) { /* → {ok, errors} */ }
function mergeConfig(camerasJson, patch) { /* → neues camerasJson, übrige Felder erhalten */ }
So braucht der Test weder Express noch fs noch FFmpeg.
Nicht Jest-getestet (bewusst, gehört auf den Host): echtes /dev/videoN-Öffnen,
FFmpeg-Spawn, atomares fs.rename gegen ein reales Volume, Bandbreite/Latenz.
Host- / Integrationstests (wie gehabt)
- Phase 1 per curl (vor der UI):
Danach
curl http://<host>:8444/api/config curl -X POST http://<host>:8444/api/config -H 'Content-Type: application/json' \ -d '{"cameras":[{"id":"cam0","liveSize":"640x360","stream":true}]}'cameras.jsonprüfen (übrige Felder erhalten?) und im Viewer das Live-Bild auf neue Auflösung kontrollieren. - Auf dem Host messen, nicht vorhersagen (Bandbreite vor/nach pro Kamera), gemäss
04_Delay_roadmap.md/ Memory. test/grabSnapShot.pyunverändert als Smoke-Test der HD-Grab-Kette weiterverwenden.
Abgrenzung (NICHT im Scope dieser Roadmap)
- MJPEG Re-Encode-Qualität (
-q:v) und C920-Gegenmassnahmen → siehe Info-Block oben, separates Thema. liveFpsper Kamera im UI → triviale spätere Erweiterung, exakt analog zuliveSize.- Authentifizierung auf
/config.html→ erst bei geplantem Internet-Zugang. hiresSizeper Kamera (HD-Grab) → bereits incameras.json, kein UI nötig.- Persistenz-Konflikt: Wenn
cameras.jsonper Volume gemountet ist (siehedocker-compose.yaml), landet die Änderung dauerhaft auf dem Host – gewünscht. Bei read-only-Mount würde Schritt 3 fehlschlagen → dann sauber500melden.
Manueller Test (sofort möglich, ohne diese Roadmap)
cameras.json direkt bearbeiten und Container neu starten:
{ "id": "cam0", "...": "...", "liveSize": "320x240" }
Fehlt liveSize, gilt der globale LIVE_SIZE-Default (640×480, aus server.js/Env).