Files
appRobotWebcam/doc/12_cameraConfig_roadmap.md
2026-06-07 10:53:27 +02:00

14 KiB
Raw Permalink Blame History

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):

  1. Native 16:9-Auflösung erzwingen 640x360 statt 320x240. Mehr Pixel, aber der Encoder arbeitet im nativen Seitenverhältnis evtl. effizienter. Muss gemessen werden.
  2. FPS senken liveFps: 10 für cam2. Linear weniger Bandbreite, keine Latenz-Kosten.
  3. Server-Re-Encode "encode": "mjpeg" + steuerbares -q:v nur 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

  • Ausstream: false = Snapshot-Modus: kein Video. Der Viewer zeigt stattdessen alle 15 s ein HD-Einzelbild (GET /api/snapshot/<id>/hires, HD-Auflösung pro Kamera aus cameras.json) mit grossem Banner „Single Picture no Video". Server-seitig öffnet jeder Grab das Gerät kurz und schliesst es wieder → Gerät meist geschlossen, minimale Mobil- Bandbreite (1 HD-JPEG / 15 s statt 30 Frames/s). Der generische Endpunkt GET /api/snapshot/<id> (z. B. fürs Homing) bleibt unverändert (Live-Frame bzw. one-shot via grabSnapshot() an liveSize).
  • Auflösung → stream: true + liveSize: "<W>x<H>" (kontinuierlicher MJPEG-Stream)
  • 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();
  }
}

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-<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 }):

  1. Validieren: jede id muss existieren; liveSize (falls gesetzt) muss in LIVE_SIZES sein; sonst 400 mit Fehlertext, keine Teiländerung.
  2. In-Memory patchen: camsMeta[i].stream setzen; in camerasJson.cameras[i] liveSize/stream setzen (übrige Felder bleiben).
  3. Persistieren: cameras.json atomar schreiben → in cameras.json.tmp schreiben, dann fs.renameSync über das Original (kein halb-geschriebenes File bei Crash).
  4. Anwenden: für jede geänderte Kamera await switches[id].reconfigure({...}).
  5. Antwort 200 mit 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> aus liveSizes + Aus, aktuellen Wert vorselektieren.
  • [Speichern & Anwenden]POST /api/config mit 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).

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: jest als devDependency + Script "test": "jest".
  • Wichtig: docker-compose.yaml installiert mit npm 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.py bleibt unverändert das ist der manuelle Integrationstest gegen den laufenden Server, keine Jest-Datei (testMatch greift nur *.test.js, .py wird ignoriert).

Test-Umfang nur testbare reine Logik / Entscheidungslogik:

Datei Was getestet wird
test/configValidate.test.js POST-Validierung: unbekannte id → Fehler; liveSizeLIVE_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)

  1. Phase 1 per curl (vor der UI):
    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}]}'
    
    Danach cameras.json prüfen (übrige Felder erhalten?) und im Viewer das Live-Bild auf neue Auflösung kontrollieren.
  2. Auf dem Host messen, nicht vorhersagen (Bandbreite vor/nach pro Kamera), gemäss 04_Delay_roadmap.md / Memory.
  3. test/grabSnapShot.py unverä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.
  • liveFps per Kamera im UI → triviale spätere Erweiterung, exakt analog zu liveSize.
  • Authentifizierung auf /config.html → erst bei geplantem Internet-Zugang.
  • hiresSize per Kamera (HD-Grab) → bereits in cameras.json, kein UI nötig.
  • Persistenz-Konflikt: Wenn cameras.json per Volume gemountet ist (siehe docker-compose.yaml), landet die Änderung dauerhaft auf dem Host gewünscht. Bei read-only-Mount würde Schritt 3 fehlschlagen → dann sauber 500 melden.

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).