Config Page
This commit is contained in:
@@ -1,108 +1,264 @@
|
||||
## Roadmap – Dynamische Kamera-Konfiguration (config.html)
|
||||
|
||||
**Ziel:** Live-Auflösung pro Kamera zur Laufzeit umschalten, ohne Datei-Editor oder Container-Restart.
|
||||
**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:
|
||||
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 |
|
||||
|-----------|-------|----------------------|
|
||||
| 160×120 | 19'200 | −96% |
|
||||
| 320×240 | 76'800 | −83% |
|
||||
| 640×360 | 230'400 | −53% (16:9) |
|
||||
| 640×480 | 307'200 | Referenz (aktuell) |
|
||||
| 800×600 | 480'000 | +56% |
|
||||
| 1280×720 | 921'600 | +200% |
|
||||
| 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).
|
||||
und FFmpeg muss software-encoden (~50% CPU pro Kamera). Siehe `09_Bug_reports.md`.
|
||||
|
||||
**Priorität:** Latenz > CPU > Bildqualität. Bandbreite ist der Engpass beim Internet-Zugriff.
|
||||
---
|
||||
|
||||
## ℹ️ 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:
|
||||
Separate Admin-Seite (kein Viewer-Umbau nötig). Pro Kamera eine Zeile mit ComboBox:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Kamera-Konfiguration │
|
||||
├──────────┬────────────┬──────────┬──────────────────┤
|
||||
│ cam0 │ Kamera 0 │ [320×240 ▼] │ Live: 320×240│
|
||||
│ cam1 │ Kamera 1 │ [640×360 ▼] │ Live: 640×360│
|
||||
│ cam2 │ Kamera 2 │ [Aus ▼] │ Live: aus │
|
||||
├──────────┴────────────┴──────────┴──────────────────┤
|
||||
│ [Speichern & Neu starten] │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 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:**
|
||||
- `Aus` → Stream deaktivieren (`stream: false`)
|
||||
- `160×120`
|
||||
- `320×240`
|
||||
- `640×360`
|
||||
- `640×480` (aktuell Default)
|
||||
- `800×600`
|
||||
- `1280×720`
|
||||
**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
|
||||
|
||||
### Phase 1 – API-Endpunkt (server.js / neues Modul)
|
||||
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).
|
||||
|
||||
**GET `/api/config`** → liefert aktuelle Konfiguration aller Kameras:
|
||||
```json
|
||||
{
|
||||
"cameras": [
|
||||
{ "id": "cam0", "liveSize": "320x240", "stream": true },
|
||||
...
|
||||
]
|
||||
### 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();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**POST `/api/config`** → nimmt neue Konfiguration entgegen:
|
||||
> Hinweis: `stream:false`-Handling läuft primär über `camsMeta` (Viewer baut dann keinen
|
||||
> `<img>` → 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.
|
||||
|
||||
**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", "liveSize": "640x360", "stream": true },
|
||||
{ "id": "cam1", "liveSize": "320x240", "stream": true },
|
||||
{ "id": "cam2", "stream": false }
|
||||
{ "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`.
|
||||
|
||||
Ablauf im Handler:
|
||||
1. Validierung (nur erlaubte Auflösungen akzeptieren)
|
||||
2. `cameras.json` aktualisieren
|
||||
3. Pro geänderter Kamera: Live-FFmpeg stoppen (`_killCurrentAndWait`) + mit neuen Params neu starten (`_spawnLive`) – kein Container-Restart nötig
|
||||
**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.
|
||||
|
||||
### Phase 2 – `config.html` (statische Seite in `/public`)
|
||||
`express.json()`-Middleware in `server.js` ergänzen (aktuell nicht aktiv). Router unter
|
||||
`app.use('/api/config', createConfigRouter(switches, camsMeta, () => camerasJson, persistFn))`.
|
||||
|
||||
- Liest beim Laden `GET /api/config`
|
||||
- Zeigt pro Kamera ein `<select>` mit den erlaubten Auflösungen
|
||||
- `[Speichern]` → `POST /api/config` → Erfolgsmeldung
|
||||
- Keine Authentifizierung (ist im internen Netz, wie der Viewer)
|
||||
### Phase 2 – `public/config.html` + `public/config.js`
|
||||
|
||||
### Phase 3 – Hot-Reload in CameraSwitch
|
||||
- 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).
|
||||
|
||||
Neue Methode `reconfigure({ liveSize, stream })` in `CameraSwitch`:
|
||||
- Wenn `stream: false` → `_killCurrentAndWait()`, kein Neustart
|
||||
- Wenn `liveSize` geändert → `_killCurrentAndWait()` + `this.liveSize = newSize` + `_spawnLive()`
|
||||
- Wenn nichts geändert → no-op
|
||||
### 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).
|
||||
|
||||
---
|
||||
|
||||
## Abgrenzung (nicht im Scope dieser Roadmap)
|
||||
## Test-Strategie
|
||||
|
||||
- MJPEG Re-Encode-Qualität (`-q:v`) → separates Thema, eigene Env-Variable
|
||||
- `liveFps` per Kamera → triviale Erweiterung, analog zu `liveSize`
|
||||
- Authentifizierung auf `/config.html` → wenn Internet-Zugang geplant
|
||||
- `hiresSize` per Kamera (HD-Grab) → bereits in cameras.json, kein UI nötig
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -111,7 +267,7 @@ Neue Methode `reconfigure({ liveSize, stream })` in `CameraSwitch`:
|
||||
`cameras.json` direkt bearbeiten und Container neu starten:
|
||||
|
||||
```json
|
||||
{ "id": "cam0", ..., "liveSize": "320x240" }
|
||||
{ "id": "cam0", "...": "...", "liveSize": "320x240" }
|
||||
```
|
||||
|
||||
Details: siehe unten im Gesprächsprotokoll / `09_Bug_reports.md`.
|
||||
Fehlt `liveSize`, gilt der globale `LIVE_SIZE`-Default (640×480, aus `server.js`/Env).
|
||||
|
||||
Reference in New Issue
Block a user