Config Page
This commit is contained in:
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# ── Node ──────────────────────────────────────────────────────────────────────
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# ── Tests / Coverage ──────────────────────────────────────────────────────────
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# ── Laufzeit / Atomares Schreiben ─────────────────────────────────────────────
|
||||||
|
# cameras.json wird per tmp+rename geschrieben; ein verwaister .tmp soll nicht rein.
|
||||||
|
*.tmp
|
||||||
|
cameras.json.tmp
|
||||||
|
|
||||||
|
# ── Logs ──────────────────────────────────────────────────────────────────────
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# ── Umgebung ──────────────────────────────────────────────────────────────────
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# ── Editor / OS ───────────────────────────────────────────────────────────────
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
@@ -1,108 +1,264 @@
|
|||||||
## Roadmap – Dynamische Kamera-Konfiguration (config.html)
|
## 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
|
## 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 |
|
| Auflösung | Pixel | Faktor ggü. 640×480 | Seitenverhältnis |
|
||||||
|-----------|-------|----------------------|
|
|-----------|-------|----------------------|------------------|
|
||||||
| 160×120 | 19'200 | −96% |
|
| 160×120 | 19'200 | −94% | 4:3 |
|
||||||
| 320×240 | 76'800 | −83% |
|
| 320×240 | 76'800 | −75% | 4:3 |
|
||||||
| 640×360 | 230'400 | −53% (16:9) |
|
| 640×360 | 230'400 | −25% | 16:9 |
|
||||||
| 640×480 | 307'200 | Referenz (aktuell) |
|
| 640×480 | 307'200 | Referenz (aktuell) | 4:3 |
|
||||||
| 800×600 | 480'000 | +56% |
|
| 800×600 | 480'000 | +56% | 4:3 |
|
||||||
| 1280×720 | 921'600 | +200% |
|
| 1280×720 | 921'600 | +200% | 16:9 |
|
||||||
|
|
||||||
Nur MJPEG-native Auflösungen verwenden – sonst fällt V4L2 auf YUYV (unkomprimiert) zurück
|
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`
|
## 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 │
|
│ AppRobotWebcam · Konfiguration [← zum Viewer] │
|
||||||
├──────────┬────────────┬──────────┬──────────────────┤
|
├──────────┬──────────────┬────────────────┬──────────────┤
|
||||||
│ cam0 │ Kamera 0 │ [320×240 ▼] │ Live: 320×240│
|
│ cam0 │ Kamera 0 │ [320×240 ▼] │ aktuell: 320×240 │
|
||||||
│ cam1 │ Kamera 1 │ [640×360 ▼] │ Live: 640×360│
|
│ cam1 │ Kamera 1 │ [640×360 ▼] │ aktuell: 640×360 │
|
||||||
│ cam2 │ Kamera 2 │ [Aus ▼] │ Live: aus │
|
│ cam2 │ Kamera 2 ⚠ │ [Aus ▼] │ aktuell: aus │
|
||||||
├──────────┴────────────┴──────────┴──────────────────┤
|
├──────────┴──────────────┴────────────────┴──────────────┤
|
||||||
│ [Speichern & Neu starten] │
|
│ Status: bereit [Speichern & Anwenden] │
|
||||||
└─────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**ComboBox-Optionen pro Kamera:**
|
**ComboBox-Optionen pro Kamera** (zentral als Konstante, von Server + Client geteilt):
|
||||||
- `Aus` → Stream deaktivieren (`stream: false`)
|
`Aus`, `160×120`, `320×240`, `640×360`, `640×480`, `800×600`, `1280×720`
|
||||||
- `160×120`
|
|
||||||
- `320×240`
|
- `Aus` → `stream: false` (kein Live-Stream; Viewer zeigt Platzhalter nach Reload)
|
||||||
- `640×360`
|
- Auflösung → `stream: true` + `liveSize: "<W>x<H>"`
|
||||||
- `640×480` (aktuell Default)
|
- Bei cam2 (C920) ein dezenter ⚠-Hinweis-Tooltip: „C920 braucht bei kleinen 4:3-Auflösungen
|
||||||
- `800×600`
|
überdurchschnittlich Bandbreite – siehe Doku."
|
||||||
- `1280×720`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Implementierung
|
## 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:
|
### Erlaubte Auflösungen – Single Source of Truth
|
||||||
```json
|
|
||||||
{
|
Neue Datei `src/liveSizes.js`, von `server.js` und `config.html` (über `/api/config`) genutzt:
|
||||||
"cameras": [
|
|
||||||
{ "id": "cam0", "liveSize": "320x240", "stream": true },
|
```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
|
```json
|
||||||
{
|
{
|
||||||
|
"liveSizes": ["160x120","320x240","640x360","640x480","800x600","1280x720"],
|
||||||
"cameras": [
|
"cameras": [
|
||||||
{ "id": "cam0", "liveSize": "640x360", "stream": true },
|
{ "id": "cam0", "name": "Kamera 0", "liveSize": "320x240", "stream": true },
|
||||||
{ "id": "cam1", "liveSize": "320x240", "stream": true },
|
{ "id": "cam1", "name": "Kamera 1", "liveSize": "640x360", "stream": true },
|
||||||
{ "id": "cam2", "stream": false }
|
{ "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:
|
**POST `/api/config`** (Body = Array von `{ id, liveSize?, stream }`):
|
||||||
1. Validierung (nur erlaubte Auflösungen akzeptieren)
|
1. **Validieren:** jede `id` muss existieren; `liveSize` (falls gesetzt) muss in
|
||||||
2. `cameras.json` aktualisieren
|
`LIVE_SIZES` sein; sonst `400` mit Fehlertext, **keine** Teiländerung.
|
||||||
3. Pro geänderter Kamera: Live-FFmpeg stoppen (`_killCurrentAndWait`) + mit neuen Params neu starten (`_spawnLive`) – kein Container-Restart nötig
|
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`
|
### Phase 2 – `public/config.html` + `public/config.js`
|
||||||
- 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 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`:
|
### Phase 4 – Link aus `index.html`
|
||||||
- Wenn `stream: false` → `_killCurrentAndWait()`, kein Neustart
|
|
||||||
- Wenn `liveSize` geändert → `_killCurrentAndWait()` + `this.liveSize = newSize` + `_spawnLive()`
|
In die `<header>`-Leiste von `public/index.html` einen Link neben `#snapAllBtn` setzen,
|
||||||
- Wenn nichts geändert → no-op
|
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
|
Zwei Ebenen: **Jest-Unit-Tests** für reine Logik (auf der Dev-Maschine, ohne Hardware) und
|
||||||
- `liveFps` per Kamera → triviale Erweiterung, analog zu `liveSize`
|
**Host-Tests** für alles, was echte Kameras/FFmpeg/Bandbreite betrifft.
|
||||||
- Authentifizierung auf `/config.html` → wenn Internet-Zugang geplant
|
|
||||||
- `hiresSize` per Kamera (HD-Grab) → bereits in cameras.json, kein UI nötig
|
### 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:
|
`cameras.json` direkt bearbeiten und Container neu starten:
|
||||||
|
|
||||||
```json
|
```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).
|
||||||
|
|||||||
4493
package-lock.json
generated
Normal file
4493
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,11 +5,15 @@
|
|||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"dev": "node server.js"
|
"dev": "node server.js",
|
||||||
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.21.1"
|
"express": "^4.21.1"
|
||||||
},
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jest": "^29.7.0"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
|
|||||||
80
public/config.html
Normal file
80
public/config.html
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>AppRobotWebcam · Konfiguration</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { background: #0f0f0f; color: #e0e0e0; font-family: monospace; }
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 10px 16px; background: #1a1a1a; border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1rem; font-weight: normal; letter-spacing: 0.05em; }
|
||||||
|
|
||||||
|
a.back {
|
||||||
|
margin-left: auto; color: #8cf; text-decoration: none;
|
||||||
|
border: 1px solid #468; padding: 5px 12px; border-radius: 3px; font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
a.back:hover { background: #1d2d3d; }
|
||||||
|
|
||||||
|
main { padding: 16px; max-width: 760px; }
|
||||||
|
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||||
|
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid #2a2a2a; }
|
||||||
|
th { color: #888; font-weight: normal; }
|
||||||
|
|
||||||
|
select {
|
||||||
|
background: #1a1a1a; color: #e0e0e0; border: 1px solid #444;
|
||||||
|
padding: 4px 8px; font-family: monospace; font-size: 0.85rem; border-radius: 3px;
|
||||||
|
}
|
||||||
|
.warn-badge { color: #fb4; cursor: help; }
|
||||||
|
.cur { color: #777; }
|
||||||
|
|
||||||
|
.actions { margin-top: 16px; display: flex; align-items: center; gap: 14px; }
|
||||||
|
#saveBtn {
|
||||||
|
background: #2a4a2a; color: #8f8; border: 1px solid #4a8;
|
||||||
|
padding: 7px 16px; font-family: monospace; font-size: 0.85rem;
|
||||||
|
cursor: pointer; border-radius: 3px;
|
||||||
|
}
|
||||||
|
#saveBtn:hover:not(:disabled) { background: #3a6a3a; }
|
||||||
|
#saveBtn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
|
#status { font-size: 0.8rem; color: #888; }
|
||||||
|
#status.ok { color: #6d6; }
|
||||||
|
#status.err { color: #f66; }
|
||||||
|
|
||||||
|
.hint { margin-top: 18px; font-size: 0.75rem; color: #666; line-height: 1.6; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>AppRobotWebcam · Konfiguration</h1>
|
||||||
|
<a class="back" href="/">← zum Viewer</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>ID</th><th>Name</th><th>Live-Auflösung</th><th>aktuell</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="rows"><tr><td colspan="4">lädt…</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button id="saveBtn" disabled>Speichern & Anwenden</button>
|
||||||
|
<span id="status">lädt…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="hint">
|
||||||
|
Auflösungs-Änderung ist sofort aktiv (laufende Streams frieren kurz ein).<br>
|
||||||
|
„Aus" bzw. Wiedereinschalten wirkt erst nach Neuladen des Viewers.<br>
|
||||||
|
⚠ C920 (cam2) braucht bei kleinen 4:3-Auflösungen überdurchschnittlich Bandbreite (siehe doc/12).
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="config.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
94
public/config.js
Normal file
94
public/config.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Kamera-Konfiguration: liest GET /api/config, baut pro Kamera eine ComboBox
|
||||||
|
// (Aus + erlaubte Auflösungen), POSTet die Auswahl zurück. Server wendet die
|
||||||
|
// Auflösung sofort an (Hot-Reload) und persistiert in cameras.json.
|
||||||
|
|
||||||
|
const OFF = '__off__';
|
||||||
|
let liveSizes = [];
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const r = await fetch('/api/config');
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
|
const cfg = await r.json();
|
||||||
|
liveSizes = cfg.liveSizes || [];
|
||||||
|
render(cfg.cameras || []);
|
||||||
|
setStatus('bereit', '');
|
||||||
|
document.getElementById('saveBtn').disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(cameras) {
|
||||||
|
const tbody = document.getElementById('rows');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
for (const cam of cameras) {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
|
||||||
|
const tdId = document.createElement('td');
|
||||||
|
tdId.textContent = cam.id;
|
||||||
|
|
||||||
|
const tdName = document.createElement('td');
|
||||||
|
tdName.textContent = cam.name || cam.id;
|
||||||
|
if (cam.id === 'cam2') {
|
||||||
|
const w = document.createElement('span');
|
||||||
|
w.className = 'warn-badge';
|
||||||
|
w.textContent = ' ⚠';
|
||||||
|
w.title = 'C920: bei kleinen 4:3-Auflösungen überdurchschnittlich Bandbreite';
|
||||||
|
tdName.appendChild(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tdSel = document.createElement('td');
|
||||||
|
const sel = document.createElement('select');
|
||||||
|
sel.dataset.id = cam.id;
|
||||||
|
const isOff = cam.stream === false;
|
||||||
|
sel.add(new Option('Aus', OFF, false, isOff));
|
||||||
|
for (const s of liveSizes) {
|
||||||
|
sel.add(new Option(s.replace('x', '×'), s, false, !isOff && cam.liveSize === s));
|
||||||
|
}
|
||||||
|
tdSel.appendChild(sel);
|
||||||
|
|
||||||
|
const tdCur = document.createElement('td');
|
||||||
|
tdCur.className = 'cur';
|
||||||
|
tdCur.textContent = isOff ? 'aus' : (cam.liveSize || '?');
|
||||||
|
|
||||||
|
tr.append(tdId, tdName, tdSel, tdCur);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collect() {
|
||||||
|
return Array.from(document.querySelectorAll('select[data-id]')).map((sel) => {
|
||||||
|
const id = sel.dataset.id;
|
||||||
|
if (sel.value === OFF) return { id, stream: false };
|
||||||
|
return { id, stream: true, liveSize: sel.value };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const btn = document.getElementById('saveBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
setStatus('speichert…', '');
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ cameras: collect() }),
|
||||||
|
});
|
||||||
|
const body = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok) throw new Error(body.error || `HTTP ${r.status}`);
|
||||||
|
render(body.cameras || []);
|
||||||
|
setStatus('gespeichert & angewendet', 'ok');
|
||||||
|
} catch (e) {
|
||||||
|
setStatus('Fehler: ' + e.message, 'err');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(text, cls) {
|
||||||
|
const el = document.getElementById('status');
|
||||||
|
el.textContent = text;
|
||||||
|
el.className = cls || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('saveBtn').addEventListener('click', save);
|
||||||
|
load().catch((e) => setStatus('Laden fehlgeschlagen: ' + e.message, 'err'));
|
||||||
@@ -24,6 +24,13 @@
|
|||||||
#snapAllBtn:hover:not(:disabled) { background: #3a6a3a; }
|
#snapAllBtn:hover:not(:disabled) { background: #3a6a3a; }
|
||||||
#snapAllBtn:disabled { opacity: 0.4; cursor: default; }
|
#snapAllBtn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
|
/* Config-Link – rechts neben dem Snapshot-Button */
|
||||||
|
#configLink {
|
||||||
|
color: #8cf; text-decoration: none; border: 1px solid #468;
|
||||||
|
padding: 5px 12px; border-radius: 3px; font-size: 0.82rem; letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
#configLink:hover { background: #1d2d3d; }
|
||||||
|
|
||||||
/* Überlast-Banner */
|
/* Überlast-Banner */
|
||||||
#notice {
|
#notice {
|
||||||
display: none; align-items: center; gap: 10px;
|
display: none; align-items: center; gap: 10px;
|
||||||
@@ -88,6 +95,7 @@
|
|||||||
<h1>AppRobotWebcam</h1>
|
<h1>AppRobotWebcam</h1>
|
||||||
<span id="statusText">Verbinde...</span>
|
<span id="statusText">Verbinde...</span>
|
||||||
<button id="snapAllBtn" disabled>⬇ Snapshot alle</button>
|
<button id="snapAllBtn" disabled>⬇ Snapshot alle</button>
|
||||||
|
<a id="configLink" href="config.html" title="Kamera-Konfiguration">⚙ Config</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div id="notice"></div>
|
<div id="notice"></div>
|
||||||
|
|||||||
19
server.js
19
server.js
@@ -6,6 +6,7 @@ const http = require('http');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { CameraSwitch } = require('./src/cameraSwitch');
|
const { CameraSwitch } = require('./src/cameraSwitch');
|
||||||
const { createSnapshotRouter, createStreamRouter, createCamerasRouter } = require('./src/snapshotService');
|
const { createSnapshotRouter, createStreamRouter, createCamerasRouter } = require('./src/snapshotService');
|
||||||
|
const { createConfigRouter } = require('./src/configService');
|
||||||
|
|
||||||
const PORT = parseInt(process.env.PORT ?? '8444', 10);
|
const PORT = parseInt(process.env.PORT ?? '8444', 10);
|
||||||
const LIVE_SIZE = process.env.LIVE_SIZE ?? '640x480';
|
const LIVE_SIZE = process.env.LIVE_SIZE ?? '640x480';
|
||||||
@@ -17,12 +18,20 @@ const ON_DEMAND = (process.env.ON_DEMAND ?? 'true') !== 'false'; // Live nur bei
|
|||||||
const IDLE_GRACE_MS = parseInt(process.env.IDLE_GRACE_MS ?? '15000', 10);
|
const IDLE_GRACE_MS = parseInt(process.env.IDLE_GRACE_MS ?? '15000', 10);
|
||||||
|
|
||||||
// ── cameras.json → CameraSwitch-Instanzen ─────────────────────────────────────
|
// ── cameras.json → CameraSwitch-Instanzen ─────────────────────────────────────
|
||||||
|
const CAMERAS_PATH = path.join(__dirname, 'cameras.json');
|
||||||
let camerasJson;
|
let camerasJson;
|
||||||
try {
|
try {
|
||||||
camerasJson = JSON.parse(fs.readFileSync(path.join(__dirname, 'cameras.json'), 'utf8'));
|
camerasJson = JSON.parse(fs.readFileSync(CAMERAS_PATH, 'utf8'));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('cameras.json fehlt oder ungültig:', e.message); process.exit(1);
|
console.error('cameras.json fehlt oder ungültig:', e.message); process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Atomar schreiben: tmp + rename → nie ein halb-geschriebenes cameras.json.
|
||||||
|
function persistCameras(obj) {
|
||||||
|
const tmp = CAMERAS_PATH + '.tmp';
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', 'utf8');
|
||||||
|
fs.renameSync(tmp, CAMERAS_PATH);
|
||||||
|
}
|
||||||
const camsConfig = camerasJson.cameras;
|
const camsConfig = camerasJson.cameras;
|
||||||
if (!Array.isArray(camsConfig) || camsConfig.length === 0) {
|
if (!Array.isArray(camsConfig) || camsConfig.length === 0) {
|
||||||
console.error('cameras.json: "cameras" muss ein nicht-leeres Array sein'); process.exit(1);
|
console.error('cameras.json: "cameras" muss ein nicht-leeres Array sein'); process.exit(1);
|
||||||
@@ -43,6 +52,7 @@ for (const cam of camsConfig) {
|
|||||||
hiresFps: cam.hiresFps ?? HIRES_FPS,
|
hiresFps: cam.hiresFps ?? HIRES_FPS,
|
||||||
encode: cam.encode ?? ENCODE_MODE,
|
encode: cam.encode ?? ENCODE_MODE,
|
||||||
hiresEncode: cam.hiresEncode, // undefined → fällt im Konstruktor auf encode zurück
|
hiresEncode: cam.hiresEncode, // undefined → fällt im Konstruktor auf encode zurück
|
||||||
|
stream: cam.stream !== false, // UI "Aus" → Kamera startet nicht live
|
||||||
onDemand: ON_DEMAND, idleGraceMs: IDLE_GRACE_MS,
|
onDemand: ON_DEMAND, idleGraceMs: IDLE_GRACE_MS,
|
||||||
});
|
});
|
||||||
camsMeta.push({
|
camsMeta.push({
|
||||||
@@ -57,11 +67,18 @@ for (const cam of camsConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.use(express.json()); // POST /api/config liest JSON-Body
|
||||||
|
|
||||||
// ── 1. Eigene Endpunkte ───────────────────────────────────────────────────────
|
// ── 1. Eigene Endpunkte ───────────────────────────────────────────────────────
|
||||||
app.use('/api/snapshot', createSnapshotRouter(switches, camsMeta));
|
app.use('/api/snapshot', createSnapshotRouter(switches, camsMeta));
|
||||||
app.use('/api/stream', createStreamRouter(switches));
|
app.use('/api/stream', createStreamRouter(switches));
|
||||||
app.use('/api/cameras', createCamerasRouter(camsMeta));
|
app.use('/api/cameras', createCamerasRouter(camsMeta));
|
||||||
|
app.use('/api/config', createConfigRouter({
|
||||||
|
switches, camsMeta,
|
||||||
|
getCamerasJson: () => camerasJson,
|
||||||
|
setCamerasJson: (v) => { camerasJson = v; },
|
||||||
|
persist: persistCameras,
|
||||||
|
}));
|
||||||
|
|
||||||
app.get('/health', (_req, res) => {
|
app.get('/health', (_req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -77,13 +77,14 @@ class MpjpegParser {
|
|||||||
//
|
//
|
||||||
// Events: 'frame' (Buffer) – je ein Live-JPEG
|
// Events: 'frame' (Buffer) – je ein Live-JPEG
|
||||||
class CameraSwitch extends EventEmitter {
|
class CameraSwitch extends EventEmitter {
|
||||||
constructor({ id, device, liveSize = '640x480', liveFps = 30, hiresSize = '1280x960', hiresFps = 15, encode = 'copybsf', hiresEncode, onDemand = true, idleGraceMs = 15000 }) {
|
constructor({ id, device, liveSize = '640x480', liveFps = 30, hiresSize = '1280x960', hiresFps = 15, encode = 'copybsf', hiresEncode, onDemand = true, idleGraceMs = 15000, stream = true }) {
|
||||||
super();
|
super();
|
||||||
this.setMaxListeners(0); // beliebig viele Stream-Clients
|
this.setMaxListeners(0); // beliebig viele Stream-Clients
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.device = device;
|
this.device = device;
|
||||||
this.liveSize = liveSize;
|
this.liveSize = liveSize;
|
||||||
this.liveFps = liveFps;
|
this.liveFps = liveFps;
|
||||||
|
this.streamEnabled = stream; // UI "Aus": Kamera darf NICHT live gehen (gated _spawnLive)
|
||||||
this.hiresSize = hiresSize;
|
this.hiresSize = hiresSize;
|
||||||
this.hiresFps = hiresFps;
|
this.hiresFps = hiresFps;
|
||||||
this.encode = encode; // für Live: 'copybsf' (Default) | 'mjpeg' (Re-Encode)
|
this.encode = encode; // für Live: 'copybsf' (Default) | 'mjpeg' (Re-Encode)
|
||||||
@@ -104,14 +105,14 @@ class CameraSwitch extends EventEmitter {
|
|||||||
start() {
|
start() {
|
||||||
// On-Demand: lazy – Live startet erst beim ersten Verbraucher (acquire()).
|
// On-Demand: lazy – Live startet erst beim ersten Verbraucher (acquire()).
|
||||||
if (this.onDemand) return;
|
if (this.onDemand) return;
|
||||||
if (this.state === 'stopped' && !this.proc) this._spawnLive();
|
if (this.streamEnabled && this.state === 'stopped' && !this.proc) this._spawnLive();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Verbraucher-Zählung / On-Demand ────────────────────────────────────────
|
// ── Verbraucher-Zählung / On-Demand ────────────────────────────────────────
|
||||||
acquire() {
|
acquire() {
|
||||||
this.subscribers++;
|
this.subscribers++;
|
||||||
if (this.idleTimer) { clearTimeout(this.idleTimer); this.idleTimer = null; }
|
if (this.idleTimer) { clearTimeout(this.idleTimer); this.idleTimer = null; }
|
||||||
if (this.onDemand && this.state === 'stopped' && !this.lock && !this.proc) this._spawnLive();
|
if (this.onDemand && this.streamEnabled && this.state === 'stopped' && !this.lock && !this.proc) this._spawnLive();
|
||||||
}
|
}
|
||||||
|
|
||||||
release() {
|
release() {
|
||||||
@@ -149,6 +150,7 @@ class CameraSwitch extends EventEmitter {
|
|||||||
|
|
||||||
// ── Live-Producer (Dauerbetrieb, Auto-Restart bei Crash) ───────────────────
|
// ── Live-Producer (Dauerbetrieb, Auto-Restart bei Crash) ───────────────────
|
||||||
_spawnLive() {
|
_spawnLive() {
|
||||||
|
if (!this.streamEnabled) return; // UI "Aus" → Kamera bleibt dunkel
|
||||||
this.stopping = false;
|
this.stopping = false;
|
||||||
const args = [
|
const args = [
|
||||||
'-hide_banner', '-loglevel', 'warning',
|
'-hide_banner', '-loglevel', 'warning',
|
||||||
@@ -193,14 +195,49 @@ class CameraSwitch extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_scheduleRestart() {
|
_scheduleRestart() {
|
||||||
|
if (!this.streamEnabled) return; // UI "Aus" → kein Auto-Restart
|
||||||
if (this.onDemand && this.subscribers === 0) return; // niemand schaut → nicht neu starten
|
if (this.onDemand && this.subscribers === 0) return; // niemand schaut → nicht neu starten
|
||||||
if (this.restartTimer) return;
|
if (this.restartTimer) return;
|
||||||
this.restartTimer = setTimeout(() => {
|
this.restartTimer = setTimeout(() => {
|
||||||
this.restartTimer = null;
|
this.restartTimer = null;
|
||||||
if (this.state === 'stopped' && !this.lock && (!this.onDemand || this.subscribers > 0)) this._spawnLive();
|
if (this.streamEnabled && this.state === 'stopped' && !this.lock && (!this.onDemand || this.subscribers > 0)) this._spawnLive();
|
||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Hot-Reload (config.html / POST /api/config) ────────────────────────────
|
||||||
|
// Wendet eine neue Live-Auflösung bzw. Stream-An/Aus zur Laufzeit an, OHNE
|
||||||
|
// Container-Restart. Nutzt die vorhandenen Bausteine (_killCurrentAndWait /
|
||||||
|
// _spawnLive) und respektiert Lock (HD-Grab) sowie On-Demand.
|
||||||
|
async reconfigure({ liveSize, stream } = {}) {
|
||||||
|
if (typeof stream === 'boolean') this.streamEnabled = stream;
|
||||||
|
|
||||||
|
// Während eines HD-Grabs nicht eingreifen – die neue liveSize gilt nach dem
|
||||||
|
// Grab (grabHires startet Live über _spawnLive neu, das this.liveSize liest).
|
||||||
|
if (this.lock) { if (liveSize) this.liveSize = liveSize; return; }
|
||||||
|
|
||||||
|
const sizeChanged = !!liveSize && liveSize !== this.liveSize;
|
||||||
|
if (liveSize) this.liveSize = liveSize;
|
||||||
|
|
||||||
|
// Stream deaktiviert → laufenden Live-Prozess stoppen, NICHT neu starten.
|
||||||
|
if (!this.streamEnabled) {
|
||||||
|
if (this.proc && this.state === 'live') await this._killCurrentAndWait();
|
||||||
|
this.state = 'stopped';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auflösung geändert → laufenden Prozess beenden (FD frei via close-Event).
|
||||||
|
if (sizeChanged && this.proc && this.state === 'live') {
|
||||||
|
await this._killCurrentAndWait();
|
||||||
|
this.state = 'stopped';
|
||||||
|
}
|
||||||
|
|
||||||
|
// (Neu) starten, wenn nichts läuft und Verbraucher da sind (On-Demand) bzw.
|
||||||
|
// Dauerbetrieb. _spawnLive ist zusätzlich durch streamEnabled gegated.
|
||||||
|
if (this.state === 'stopped' && !this.proc && (!this.onDemand || this.subscribers > 0)) {
|
||||||
|
this._spawnLive();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── HD-Grab ────────────────────────────────────────────────────────────────
|
// ── HD-Grab ────────────────────────────────────────────────────────────────
|
||||||
// Wenn liveSize == hiresSize: kein Format-Wechsel nötig. Live-Frame direkt
|
// Wenn liveSize == hiresSize: kein Format-Wechsel nötig. Live-Frame direkt
|
||||||
// zurückgeben (on-demand startet den Stream bei Bedarf). Schnell, kein Gerät-
|
// zurückgeben (on-demand startet den Stream bei Bedarf). Schnell, kein Gerät-
|
||||||
|
|||||||
112
src/configService.js
Normal file
112
src/configService.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { LIVE_SIZES } = require('./liveSizes');
|
||||||
|
|
||||||
|
// ── Reine Logik (kein Express, kein fs) – Jest-testbar ───────────────────────
|
||||||
|
|
||||||
|
// Validiert den POST-Body. reqCameras: [{ id, liveSize?, stream? }].
|
||||||
|
// Liefert { ok, errors[] }. Ein einziger Fehler kippt das Ergebnis → kein
|
||||||
|
// Teil-Apply (der Aufrufer wendet nur bei ok:true etwas an).
|
||||||
|
function validateConfig(reqCameras, knownIds, liveSizes = LIVE_SIZES) {
|
||||||
|
if (!Array.isArray(reqCameras)) {
|
||||||
|
return { ok: false, errors: ['"cameras" muss ein Array sein'] };
|
||||||
|
}
|
||||||
|
const known = new Set(knownIds);
|
||||||
|
const errors = [];
|
||||||
|
for (const c of reqCameras) {
|
||||||
|
if (!c || typeof c.id !== 'string') {
|
||||||
|
errors.push(`Eintrag ohne gültige id: ${JSON.stringify(c)}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!known.has(c.id)) {
|
||||||
|
errors.push(`Unbekannte Kamera: ${c.id}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ('stream' in c && typeof c.stream !== 'boolean') {
|
||||||
|
errors.push(`${c.id}: "stream" muss boolean sein`);
|
||||||
|
}
|
||||||
|
// liveSize darf bei "Aus" fehlen; wenn gesetzt, muss sie erlaubt sein.
|
||||||
|
if (c.liveSize != null && !liveSizes.includes(c.liveSize)) {
|
||||||
|
errors.push(`${c.id}: ungültige Auflösung "${c.liveSize}" (erlaubt: ${liveSizes.join(', ')})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: errors.length === 0, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liefert ein NEUES camerasJson-Objekt: patcht nur liveSize/stream der genannten
|
||||||
|
// Kameras, lässt alle übrigen Felder (device, name, hiresSize, note …) und nicht
|
||||||
|
// genannte Kameras unangetastet. Mutiert das Original nicht.
|
||||||
|
function mergeConfig(camerasJson, reqCameras) {
|
||||||
|
const patchById = new Map(reqCameras.map((c) => [c.id, c]));
|
||||||
|
return {
|
||||||
|
...camerasJson,
|
||||||
|
cameras: camerasJson.cameras.map((cam) => {
|
||||||
|
const p = patchById.get(cam.id);
|
||||||
|
if (!p) return cam;
|
||||||
|
const next = { ...cam };
|
||||||
|
if (p.liveSize != null) next.liveSize = p.liveSize;
|
||||||
|
if ('stream' in p) next.stream = p.stream;
|
||||||
|
return next;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aktueller Ist-Zustand für GET /api/config und die POST-Antwort.
|
||||||
|
// liveSize aus dem Switch (Laufzeit-Wahrheit), name/stream aus camsMeta.
|
||||||
|
function currentConfig(switches, camsMeta) {
|
||||||
|
return {
|
||||||
|
liveSizes: LIVE_SIZES,
|
||||||
|
cameras: camsMeta.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
name: m.name,
|
||||||
|
liveSize: switches[m.id]?.liveSize ?? null,
|
||||||
|
stream: m.stream,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Express-Router ───────────────────────────────────────────────────────────
|
||||||
|
// GET /api/config → { liveSizes, cameras:[{id,name,liveSize,stream}] }
|
||||||
|
// POST /api/config → Body { cameras:[{id,liveSize?,stream}] }
|
||||||
|
// validiert → cameras.json atomar schreiben → Hot-Reload
|
||||||
|
function createConfigRouter({ switches, camsMeta, getCamerasJson, setCamerasJson, persist }) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/', (_req, res) => {
|
||||||
|
res.json(currentConfig(switches, camsMeta));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
const reqCameras = req.body && req.body.cameras;
|
||||||
|
const v = validateConfig(reqCameras, camsMeta.map((c) => c.id));
|
||||||
|
if (!v.ok) return res.status(400).json({ error: v.errors.join('; ') });
|
||||||
|
|
||||||
|
// 1. cameras.json in-memory patchen + atomar persistieren (übrige Felder bleiben).
|
||||||
|
const merged = mergeConfig(getCamerasJson(), reqCameras);
|
||||||
|
try {
|
||||||
|
persist(merged);
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(500).json({ error: `Persistieren fehlgeschlagen: ${e.message}` });
|
||||||
|
}
|
||||||
|
setCamerasJson(merged);
|
||||||
|
|
||||||
|
// 2. camsMeta.stream nachziehen (Viewer respektiert das beim nächsten Laden).
|
||||||
|
for (const c of reqCameras) {
|
||||||
|
const meta = camsMeta.find((m) => m.id === c.id);
|
||||||
|
if (meta && 'stream' in c) meta.stream = c.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Hot-Reload pro genannter Kamera (Auflösung sofort aktiv, Aus/An n. Reload).
|
||||||
|
await Promise.allSettled(reqCameras.map((c) => {
|
||||||
|
const sw = switches[c.id];
|
||||||
|
return sw ? sw.reconfigure({ liveSize: c.liveSize, stream: c.stream }) : Promise.resolve();
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(currentConfig(switches, camsMeta));
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { validateConfig, mergeConfig, currentConfig, createConfigRouter };
|
||||||
10
src/liveSizes.js
Normal file
10
src/liveSizes.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// MJPEG-native Live-Auflösungen ALLER eingesetzten Kameras (C270 / C920 / C922).
|
||||||
|
// Auf dem Host mit `v4l2-ctl --list-formats-ext` verifiziert (2026-06-07).
|
||||||
|
// Single Source of Truth: server.js (Validierung) + config.html (über /api/config).
|
||||||
|
// NUR diese Auflösungen verwenden – sonst fällt V4L2 auf YUYV (unkomprimiert) zurück
|
||||||
|
// und FFmpeg muss software-encoden (~50% CPU pro Kamera). Siehe doc/12 + doc/09.
|
||||||
|
const LIVE_SIZES = ['160x120', '320x240', '640x360', '640x480', '800x600', '1280x720'];
|
||||||
|
|
||||||
|
module.exports = { LIVE_SIZES };
|
||||||
49
test/configMerge.test.js
Normal file
49
test/configMerge.test.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { mergeConfig } = require('../src/configService');
|
||||||
|
|
||||||
|
function base() {
|
||||||
|
return {
|
||||||
|
cameras: [
|
||||||
|
{ id: 'cam0', device: '/dev/video0', name: 'Kamera 0', position: 'front', stream: true, hires: true, note: 'x', liveSize: '640x480', hiresSize: '1280x960' },
|
||||||
|
{ id: 'cam1', device: '/dev/video2', name: 'Kamera 1', position: 'left', stream: true, hires: true, note: 'y', hiresSize: '1280x960' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mergeConfig', () => {
|
||||||
|
test('patcht liveSize, erhält ALLE übrigen Felder', () => {
|
||||||
|
const out = mergeConfig(base(), [{ id: 'cam0', liveSize: '320x240', stream: true }]);
|
||||||
|
const c0 = out.cameras.find((c) => c.id === 'cam0');
|
||||||
|
expect(c0.liveSize).toBe('320x240');
|
||||||
|
expect(c0.device).toBe('/dev/video0');
|
||||||
|
expect(c0.name).toBe('Kamera 0');
|
||||||
|
expect(c0.position).toBe('front');
|
||||||
|
expect(c0.hiresSize).toBe('1280x960');
|
||||||
|
expect(c0.note).toBe('x');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lässt nicht genannte Kameras unverändert', () => {
|
||||||
|
const b = base();
|
||||||
|
const out = mergeConfig(b, [{ id: 'cam0', liveSize: '320x240', stream: true }]);
|
||||||
|
expect(out.cameras.find((c) => c.id === 'cam1')).toEqual(b.cameras[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setzt stream:false', () => {
|
||||||
|
const out = mergeConfig(base(), [{ id: 'cam1', stream: false }]);
|
||||||
|
expect(out.cameras.find((c) => c.id === 'cam1').stream).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mutiert das Original nicht', () => {
|
||||||
|
const b = base();
|
||||||
|
const snapshot = JSON.stringify(b);
|
||||||
|
mergeConfig(b, [{ id: 'cam0', liveSize: '160x120', stream: true }]);
|
||||||
|
expect(JSON.stringify(b)).toBe(snapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('erhält Top-Level-Felder neben "cameras"', () => {
|
||||||
|
const b = { version: 2, cameras: base().cameras };
|
||||||
|
const out = mergeConfig(b, [{ id: 'cam0', liveSize: '800x600', stream: true }]);
|
||||||
|
expect(out.version).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
54
test/configValidate.test.js
Normal file
54
test/configValidate.test.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { validateConfig } = require('../src/configService');
|
||||||
|
const { LIVE_SIZES } = require('../src/liveSizes');
|
||||||
|
|
||||||
|
const IDS = ['cam0', 'cam1', 'cam2'];
|
||||||
|
|
||||||
|
describe('validateConfig', () => {
|
||||||
|
test('akzeptiert gültige Auflösung', () => {
|
||||||
|
const r = validateConfig([{ id: 'cam0', liveSize: '320x240', stream: true }], IDS);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(r.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('alle erlaubten Auflösungen sind gültig', () => {
|
||||||
|
for (const s of LIVE_SIZES) {
|
||||||
|
expect(validateConfig([{ id: 'cam0', liveSize: s, stream: true }], IDS).ok).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lehnt unbekannte id ab', () => {
|
||||||
|
expect(validateConfig([{ id: 'camX', liveSize: '320x240', stream: true }], IDS).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lehnt ungültige Auflösung ab', () => {
|
||||||
|
expect(validateConfig([{ id: 'cam0', liveSize: '999x999', stream: true }], IDS).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"Aus" (stream:false ohne liveSize) ist gültig', () => {
|
||||||
|
expect(validateConfig([{ id: 'cam2', stream: false }], IDS).ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stream muss boolean sein', () => {
|
||||||
|
expect(validateConfig([{ id: 'cam0', liveSize: '320x240', stream: 'ja' }], IDS).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ein Fehler kippt das ganze Ergebnis (kein Teil-Apply)', () => {
|
||||||
|
const r = validateConfig([
|
||||||
|
{ id: 'cam0', liveSize: '320x240', stream: true },
|
||||||
|
{ id: 'cam1', liveSize: 'bad', stream: true },
|
||||||
|
], IDS);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.errors.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Nicht-Array → Fehler', () => {
|
||||||
|
expect(validateConfig(undefined, IDS).ok).toBe(false);
|
||||||
|
expect(validateConfig(null, IDS).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Eintrag ohne id → Fehler', () => {
|
||||||
|
expect(validateConfig([{ liveSize: '320x240', stream: true }], IDS).ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
59
test/mpjpegParser.test.js
Normal file
59
test/mpjpegParser.test.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { MpjpegParser } = require('../src/cameraSwitch');
|
||||||
|
|
||||||
|
// Baut ein FFmpeg-`-f mpjpeg`-Paket: Boundary + Header (Content-Length) + Body + CRLF.
|
||||||
|
function packet(jpeg) {
|
||||||
|
return Buffer.concat([
|
||||||
|
Buffer.from(`--frame\r\nContent-Type: image/jpeg\r\nContent-Length: ${jpeg.length}\r\n\r\n`, 'latin1'),
|
||||||
|
jpeg,
|
||||||
|
Buffer.from('\r\n', 'latin1'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MpjpegParser', () => {
|
||||||
|
test('ein Frame', () => {
|
||||||
|
const frames = [];
|
||||||
|
const p = new MpjpegParser((f) => frames.push(f));
|
||||||
|
const jpeg = Buffer.from([0xFF, 0xD8, 1, 2, 3, 0xFF, 0xD9]);
|
||||||
|
p.push(packet(jpeg));
|
||||||
|
expect(frames).toHaveLength(1);
|
||||||
|
expect(frames[0].equals(jpeg)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mehrere Frames in einem Chunk', () => {
|
||||||
|
const frames = [];
|
||||||
|
const p = new MpjpegParser((f) => frames.push(f));
|
||||||
|
const a = Buffer.from([0xFF, 0xD8, 1]);
|
||||||
|
const b = Buffer.from([0xFF, 0xD8, 2, 3, 4]);
|
||||||
|
p.push(Buffer.concat([packet(a), packet(b)]));
|
||||||
|
expect(frames).toHaveLength(2);
|
||||||
|
expect(frames[0].equals(a)).toBe(true);
|
||||||
|
expect(frames[1].equals(b)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('über Chunk-Grenze gesplittetes Frame', () => {
|
||||||
|
const frames = [];
|
||||||
|
const p = new MpjpegParser((f) => frames.push(f));
|
||||||
|
const jpeg = Buffer.from([10, 20, 30, 40, 50]);
|
||||||
|
const pkt = packet(jpeg);
|
||||||
|
p.push(pkt.subarray(0, 6)); // mitten im Header
|
||||||
|
expect(frames).toHaveLength(0);
|
||||||
|
p.push(pkt.subarray(6)); // Rest
|
||||||
|
expect(frames).toHaveLength(1);
|
||||||
|
expect(frames[0].equals(jpeg)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Body über mehrere Chunks', () => {
|
||||||
|
const frames = [];
|
||||||
|
const p = new MpjpegParser((f) => frames.push(f));
|
||||||
|
const jpeg = Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||||
|
const pkt = packet(jpeg);
|
||||||
|
const cut = pkt.length - 5; // mitten im Body trennen
|
||||||
|
p.push(pkt.subarray(0, cut));
|
||||||
|
expect(frames).toHaveLength(0);
|
||||||
|
p.push(pkt.subarray(cut));
|
||||||
|
expect(frames).toHaveLength(1);
|
||||||
|
expect(frames[0].equals(jpeg)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
32
test/readJpegWidth.test.js
Normal file
32
test/readJpegWidth.test.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { readJpegWidth } = require('../src/cameraSwitch');
|
||||||
|
|
||||||
|
// Minimaler JPEG-Kopf: SOI (FFD8) + SOF-Marker mit Höhe/Breite.
|
||||||
|
// SOF-Layout: FF Cx | len(2) | precision(1) | height(2) | width(2)
|
||||||
|
function jpegWithWidth(width, height = 480, marker = 0xC0) {
|
||||||
|
const b = Buffer.alloc(20, 0);
|
||||||
|
b[0] = 0xFF; b[1] = 0xD8; // SOI
|
||||||
|
b[2] = 0xFF; b[3] = marker; // SOF0 / SOF2
|
||||||
|
b.writeUInt16BE(17, 4); // Segment-Länge
|
||||||
|
b[6] = 8; // precision
|
||||||
|
b.writeUInt16BE(height, 7); // Höhe
|
||||||
|
b.writeUInt16BE(width, 9); // Breite
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('readJpegWidth', () => {
|
||||||
|
test('liest Breite aus SOF0 (baseline)', () => {
|
||||||
|
expect(readJpegWidth(jpegWithWidth(320))).toBe(320);
|
||||||
|
expect(readJpegWidth(jpegWithWidth(1920))).toBe(1920);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('liest Breite aus SOF2 (progressive)', () => {
|
||||||
|
expect(readJpegWidth(jpegWithWidth(1280, 720, 0xC2))).toBe(1280);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kein SOF-Marker → null', () => {
|
||||||
|
const b = Buffer.from([0xFF, 0xD8, 0xFF, 0xD9, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||||
|
expect(readJpegWidth(b)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
75
test/reconfigure.test.js
Normal file
75
test/reconfigure.test.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { CameraSwitch } = require('../src/cameraSwitch');
|
||||||
|
|
||||||
|
// Mockt die FFmpeg-berührenden Methoden – getestet wird NUR die
|
||||||
|
// Entscheidungslogik von reconfigure() (kill/spawn/no-op), keine Hardware.
|
||||||
|
function makeSwitch(opts = {}) {
|
||||||
|
const sw = new CameraSwitch({ id: 'camT', device: '/dev/null', liveSize: '640x480', ...opts });
|
||||||
|
sw._killCurrentAndWait = jest.fn(() => { sw.proc = null; sw.state = 'stopped'; return Promise.resolve(); });
|
||||||
|
sw._spawnLive = jest.fn(() => { sw.proc = {}; sw.state = 'live'; });
|
||||||
|
return sw;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CameraSwitch.reconfigure', () => {
|
||||||
|
test('geänderte liveSize → genau 1× kill + 1× spawn', async () => {
|
||||||
|
const sw = makeSwitch({ onDemand: false });
|
||||||
|
sw.proc = {}; sw.state = 'live';
|
||||||
|
await sw.reconfigure({ liveSize: '320x240', stream: true });
|
||||||
|
expect(sw.liveSize).toBe('320x240');
|
||||||
|
expect(sw._killCurrentAndWait).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sw._spawnLive).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gleiche liveSize → no-op (kein kill, kein spawn)', async () => {
|
||||||
|
const sw = makeSwitch({ onDemand: false });
|
||||||
|
sw.proc = {}; sw.state = 'live';
|
||||||
|
await sw.reconfigure({ liveSize: '640x480', stream: true });
|
||||||
|
expect(sw._killCurrentAndWait).not.toHaveBeenCalled();
|
||||||
|
expect(sw._spawnLive).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lock aktiv (HD-Grab) → nur Feld setzen, kein kill/spawn', async () => {
|
||||||
|
const sw = makeSwitch({ onDemand: false });
|
||||||
|
sw.lock = true; sw.proc = {}; sw.state = 'grabbing';
|
||||||
|
await sw.reconfigure({ liveSize: '320x240', stream: true });
|
||||||
|
expect(sw.liveSize).toBe('320x240');
|
||||||
|
expect(sw._killCurrentAndWait).not.toHaveBeenCalled();
|
||||||
|
expect(sw._spawnLive).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stream:false → kill, KEIN respawn, streamEnabled=false', async () => {
|
||||||
|
const sw = makeSwitch({ onDemand: false });
|
||||||
|
sw.proc = {}; sw.state = 'live';
|
||||||
|
await sw.reconfigure({ stream: false });
|
||||||
|
expect(sw._killCurrentAndWait).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sw._spawnLive).not.toHaveBeenCalled();
|
||||||
|
expect(sw.streamEnabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('On-Demand ohne Verbraucher → kill bei Resize, aber kein spawn', async () => {
|
||||||
|
const sw = makeSwitch({ onDemand: true });
|
||||||
|
sw.subscribers = 0;
|
||||||
|
sw.proc = {}; sw.state = 'live';
|
||||||
|
await sw.reconfigure({ liveSize: '320x240', stream: true });
|
||||||
|
expect(sw._killCurrentAndWait).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sw._spawnLive).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('On-Demand mit Verbraucher → kill + spawn (nahtloser Resize)', async () => {
|
||||||
|
const sw = makeSwitch({ onDemand: true });
|
||||||
|
sw.subscribers = 1;
|
||||||
|
sw.proc = {}; sw.state = 'live';
|
||||||
|
await sw.reconfigure({ liveSize: '320x240', stream: true });
|
||||||
|
expect(sw._killCurrentAndWait).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sw._spawnLive).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Wiedereinschalten (stream:true) startet bei vorhandenem Verbraucher', async () => {
|
||||||
|
const sw = makeSwitch({ onDemand: true, stream: false });
|
||||||
|
sw.subscribers = 1; sw.state = 'stopped'; sw.proc = null;
|
||||||
|
await sw.reconfigure({ liveSize: '320x240', stream: true });
|
||||||
|
expect(sw.streamEnabled).toBe(true);
|
||||||
|
expect(sw._spawnLive).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user