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

281 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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`
- `Aus``stream: 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:
```js
'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:
```js
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:
```json
{
"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).
### 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:
```html
<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; `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`):
```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):
```bash
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:
```json
{ "id": "cam0", "...": "...", "liveSize": "320x240" }
```
Fehlt `liveSize`, gilt der globale `LIVE_SIZE`-Default (640×480, aus `server.js`/Env).