roadmap Arbeiten

This commit is contained in:
chk
2026-06-04 17:47:58 +02:00
parent 0bcd583b6d
commit f882263972
3 changed files with 398 additions and 0 deletions

View File

@@ -0,0 +1,219 @@
# AppRobotWebcam Hi-Res-Snapshot via Consumer-Umhängen
> Status: **Konzept, phasenweise testbar.** Noch nicht umgesetzt.
> Vorgeschichte & gescheiterte Ansätze: siehe `04_Delay_roadmap.md` (Abschnitt
> „KONSOLIDIERT"). Diese Datei beschreibt den Ansatz, der die dort dokumentierten
> Fehler **strukturell** umgeht.
---
## Grundidee
Das Kernproblem aller bisherigen Versuche: **Eine USB-Kamera lässt sich nur einmal
öffnen**, und der Live-Viewer zwingt go2rtc, das Gerät zu halten (per on-demand-
Reconnect). Jeder Versuch, go2rtc das Gerät zu *entreißen* oder die Live-Quelle zur
Laufzeit *umzuschalten*, ist gescheitert (device-busy, bzw. `PATCH` hängt an → 107 %).
**Neuer Ansatz das Problem umdrehen:** Nicht das Gerät dem Stream entreißen, sondern
**die Zuschauer vom Live-Stream wegziehen.** Hat `cam0` keine Zuschauer mehr, stoppt
go2rtc den Producer von selbst (on-demand) und gibt das Gerät frei. Dann kann ein
separater Hi-Res-Stream es kurz für sich haben.
### Warum das sicher ist (im Gegensatz zu allem vorher)
- **`cam0` wird nie verändert.** Kein `PATCH`/`PUT`/`DELETE` auf den Live-Stream.
Das Append-Problem (107 %) kann nicht auftreten.
- **Zur Laufzeit nur LESENDE go2rtc-Aufrufe**: `GET /api/streams`, `GET /api/frame.jpeg`.
Die einzige „schreibende" Änderung ist das Hinzufügen von `cam0_hires` in der Config
(per Redeploy, nicht zur Laufzeit).
- **Kleiner Schadensradius**: Geht etwas schief, ist die Erholung „Browser wieder auf
`cam0` hängen" → go2rtc startet `cam0` neu. Keine kaputte Stream-Definition, die bis
zum Neustart hängt.
Damit respektiert der Ansatz die eisernen Regeln aus `04_*` (Snapshot-Pfad read-only,
keine Laufzeit-Mutation von cam0/cam1).
---
## Architektur
| Stream | Quelle | on-demand | Zweck |
|--------|--------|-----------|-------|
| `cam0` | Kamera @640 | ja | **unverändert** Live-Stream |
| `cam1` | Kamera @640 | ja | **unverändert** Live-Stream |
| `cam0_hires` | Kamera @1280×960 | ja | **nur** für den Hi-Res-Grab (Phase 2) |
| `cam1_hires` | Kamera @1280×960 | ja | dito für cam1 |
**Platzhalter = rein clientseitig**, kein go2rtc-Stream nötig:
Der Browser friert beim Umhängen den zuletzt gezeigten Live-Frame auf einem `<canvas>`
ein und blendet „HD Image Work" ein (ca. 30 % der Bildgröße, unten rechts). Das ist
das, was während des Grabs zu sehen ist. Vorteil: keine zusätzliche go2rtc-Last, kein
Nachschieben von Bildern in go2rtc.
> Alternative (nur falls *mehrere* Zuschauer gleichzeitig denselben Platzhalter sehen
> sollen): ein echter go2rtc-`standbild0`-Stream aus einer statischen Bilddatei. Mehr
> Aufwand, hier zunächst nicht nötig.
---
## Ziel-Ablauf (vollständig, Phase 1 + 2)
```
1. Browser hängt um: cam0 → Canvas-Standbild ("HD Image Work") [clientseitig]
2. cam0 hat 0 Zuschauer → go2rtc stoppt cam0-Producer → Gerät frei
3. [Pause ~4s] ← Wert ist FREI WÄHLBAR, wird aus Phase-1-Messung gesetzt
4. Node holt 1 Frame von cam0_hires → go2rtc öffnet Gerät @1280 → frame.jpeg
(Breite ≥1000px prüfen, sonst retry; Warmup beachten s.u.)
5. cam0_hires-Consumer endet → Gerät frei [Pause ~4s]
6. Browser hängt zurück: Canvas → cam0 → Live @640 wieder da
```
Blackout auf **cam0** ~810 s (mit 4s-Pausen), cam1 unberührt. Für „alle 30 s,
Button-getriggert, Blackout ok" passt das. Die Pausen sind großzügig gewählt; sobald
Phase 1 die echte Freigabe-Zeit liefert, können sie gekürzt werden.
> **Die 4s sind ein Startwert, keine feste Größe.** Phase 1 misst, wie schnell go2rtc
> das Gerät wirklich freigibt → daraus wird der reale Pausenwert.
---
## Der eine Dreh- und Angelpunkt (= warum Phase 1 zuerst kommt)
Der ganze Ansatz steht und fällt mit **einer** Annahme:
> **Gibt go2rtc das Gerät frei, wenn `cam0` den letzten Zuschauer verliert — und wie
> schnell?**
go2rtc *kann* einen Producer nach dem letzten Consumer „warm" halten statt ihn sofort
zu stoppen. Tut es das, bleibt das Gerät belegt und `cam0_hires` läuft auf „device
busy". **Diese Zahl wird in Phase 1 gemessen, bevor irgendein Hi-Res-Grab gebaut wird.**
---
## PHASE 1 — Freigabe verifizieren (kein Grab, voll reversibel)
**Ziel:** Beweisen, dass Schritt 1 → 2 → 6 funktioniert und das Gerät tatsächlich
(und wie schnell) frei wird. Kein `cam0_hires`, kein 1280-Zugriff. Risiko ~null:
im schlimmsten Fall ist es ein Reconnect von cam0.
### Umzusetzen
**A. Viewer (`public/viewer.js`)** Button „Hi-Res-Test (Phase 1)":
1. Aktuellen `cam0`-Frame auf ein `<canvas>` zeichnen, „HD Image Work" einblenden,
Canvas anstelle des `<video-stream>` zeigen.
2. `<video-stream>` für cam0 **entfernen/stoppen** (das ist das „Umhängen" cam0
verliert seinen Consumer).
3. `GET /api/snapshot/cam0/release-test` aufrufen (neuer Node-Endpunkt, s.u.).
4. Auf Antwort: `<video-stream>` für cam0 wieder einsetzen (Live zurück), Canvas weg.
**B. Node (`src/snapshotService.js`)** neuer read-only Endpunkt
`GET /api/snapshot/:id/release-test`:
1. Startzeit loggen.
2. `GET ${go2rtc}/api/streams` alle 200 ms pollen (max. ~10 s).
3. Loggen:
- Wann erreicht `cam0` **0 Consumer**?
- Wann ist der `cam0`-**Producer gestoppt** (Feld `producers` leer bzw. `state`
`running`) → das ist der Proxy für „Gerät frei".
- Dauer von „0 Consumer" → „Producer gestoppt" in ms.
4. Ergebnis ins Log schreiben **und** als JSON zurückgeben, z. B.:
```json
{ "freed": true, "msUntilFree": 1700, "samples": [...] }
```
5. Kein Schreibzugriff auf go2rtc. Nur Lesen.
### Erfolgskriterium Phase 1
- Log/JSON zeigt `freed: true` und eine **konkrete** `msUntilFree`.
- Nach dem Test (Schritt 6) zeigt cam0 wieder normal Live (~50 % CPU, stabil).
### Was wir daraus lernen
- `msUntilFree` → der reale Pausenwert für Schritt 3/5 (statt der geratenen 4 s).
- Wird der Producer **nicht** gestoppt (`freed: false`): go2rtc hält das Gerät warm →
Ansatz so nicht tragfähig → prüfen, ob ein go2rtc-Setting das Verhalten ändert,
sonst zurück zu Weg A (separate Kamera, siehe `04_*`).
> ⚠ Die genaue JSON-Form von `/api/streams` (Felder `producers`/`consumers`/`state`)
> vor dem Bauen kurz an der echten Instanz ansehen (`curl -s localhost:1984/api/streams`)
> und den Parser danach ausrichten — nicht annehmen.
---
## PHASE 2 — Den Hi-Res-Grab ergänzen (Schritt 4 + 5)
Nur starten, **wenn Phase 1 `freed: true` geliefert hat.**
### Vorbereitung (Config, per Redeploy nicht zur Laufzeit)
`docker-compose.yaml`, go2rtc-`streams` ergänzen:
```yaml
cam0_hires: "ffmpeg:device?video=/dev/video0&input_format=mjpeg&video_size=1280x960&framerate=15#video=mjpeg"
cam1_hires: "ffmpeg:device?video=/dev/video2&input_format=mjpeg&video_size=1280x960&framerate=15#video=mjpeg"
```
- `#video=mjpeg` (Re-Encode) ist ok läuft nur ~12 s pro Grab. (`#video=copy` ist
laut `04_*` auf dieser Kamera tot.)
- **Präzondition prüfen:** Nach Redeploy via `/api/streams` bestätigen, dass
`cam0_hires` **dormant** ist (kein laufender Producer, solange niemand es anfragt).
Sonst würde es beim Start das Gerät greifen und mit `cam0` kollidieren.
### Node-Endpunkt `GET /api/snapshot/:id/hires` (Phase-2-Variante)
Voraussetzung: der **Client hat cam0 bereits losgelassen** (Browser-Dance wie Phase 1).
Ablauf im Endpunkt:
1. Mutex setzen (kein paralleler Grab).
2. (optional) via `/api/streams` verifizieren: `cam0` hat 0 Consumer → sonst abbrechen
(Gerät noch belegt).
3. `sleep(msUntilFree)` Gerät freigeben lassen.
4. **Grab mit Warmup** (robuste Variante):
- Kurz `cam0_hires` als Stream konsumieren (z. B. `GET /api/stream.mjpeg?src=cam0_hires`)
für ~1,5 s, damit die Kamera-Belichtung einschwingt und der Producer warm bleibt.
- Den **letzten** Frame behalten, der `Breite ≥ 1000 px` **und** nicht „zu klein/
schwarz" ist (Warmup-Schutz, vgl. das frühere 1-KB-Schwarzbild).
- Einfachere Alternative: `GET /api/frame.jpeg?src=cam0_hires` mit Retry (mehrfach,
bis Breite ≥1000 px und plausible Größe).
5. Consumer von `cam0_hires` beenden → Gerät frei.
6. Mutex lösen. JPEG (1280×960) zurückgeben.
**Client** nach Antwort: Canvas weg, `<video-stream>` cam0 wieder einsetzen (Schritt 6).
### Robustheit (Pflicht)
- **`finally`/Recovery:** Egal was schiefgeht der Client MUSS am Ende wieder auf `cam0`
hängen. Da `cam0` nie verändert wurde, reicht „wieder anhängen" zur vollen Erholung.
- **Timeout** auf den Grab (z. B. 8 s) → sonst Fehler + Recovery.
- **Mutex**: nie zwei Grabs gleichzeitig (würde zwei 1280-Producer = Gerätekonflikt
provozieren).
---
## Platzhalter-Detail (Canvas „HD Image Work")
- Beim Umhängen den aktuellen Frame des `<video>`-Elements per
`canvasCtx.drawImage(video, …)` einfrieren.
- Text „HD Image Work" unten rechts, ca. 30 % der Bildbreite, mit halbtransparentem
Hintergrund (Lesbarkeit).
- Canvas über/anstelle des gestoppten `<video-stream>` zeigen.
- Nach Schritt 6 Canvas entfernen.
- Rein clientseitig go2rtc sieht davon nichts.
---
## Offene Punkte / Risiken (ehrlich)
| Punkt | Status | Umgang |
|-------|--------|--------|
| **Gibt go2rtc das Gerät frei + wie schnell?** | **ungeklärt Linchpin** | **Phase 1 misst es.** Erst danach Phase 2. |
| Warmup-Schwarzbild bei 1280 | bekannt | kurz konsumieren + Breiten/Größen-Check + Retry |
| Mehrere gleichzeitige Zuschauer | Einschränkung | Gerät wird nur frei, wenn **alle** cam0 loslassen. Für 1 Operator + Button ok; Multi-Client bräuchte ein Broadcast-Signal „alle auf Platzhalter". |
| `cam0_hires` greift Gerät schon beim Start? | zu prüfen | nach Redeploy via `/api/streams` bestätigen, dass es dormant ist |
| Fehler mitten in der Sequenz | beherrschbar | `finally` → Client immer zurück auf cam0; Worst case go2rtc-Restart, cam0-Definition bleibt heil |
| Orchestrierung Client↔Server | Komplexität | klare Reihenfolge: Client löst cam0 → ruft Endpunkt → Endpunkt wartet+grabt → Client hängt zurück |
---
## Käme das so hin? — kurz
**Ja.** Der Ablauf 16 mit anpassbaren Pausen ist tragfähig, **wenn** der Linchpin
(Geräte-Freigabe nach Consumer-Verlust) hält — und genau das beweist Phase 1, ohne
cam0 anzufassen und ohne einen einzigen schreibenden go2rtc-Aufruf. Zwei Vereinfachungen
gegenüber der ersten Skizze: der Platzhalter ist clientseitig (kein eigener Stream),
und zur Laufzeit wird go2rtc nur **gelesen**, nie verändert.
**Reihenfolge:** Phase 1 (messen, ~null Risiko) → Pausen aus der Messung setzen →
Phase 2 (Grab). Fällt Phase 1, bleibt Weg A (separate Kamera) aus `04_*` der sichere
Fallback.

View File

@@ -0,0 +1,179 @@
# AppRobotWebcam Port-Forwarding / HTTPS-Proxy Roadmap
> Ziel: Die Viewer-Webseite über **einen** HTTPS-Reverse-Proxy nach aussen geben,
> ohne go2rtc oder interne Ports ins Internet zu hängen.
> Stand: 2026-06-04 · noch **nicht umgesetzt** (Plan zum Abarbeiten).
---
## TL;DR — welche Ports nach aussen?
| Port | Dienst | Internet? | Begründung |
|------|--------|-----------|------------|
| **443** | HTTPS-Reverse-Proxy | ✅ **ja einziger Internet-Port** | TLS-Terminierung + einziger Einstieg |
| **8444** | Node.js (Webseite, `/api`, Snapshots, Stream-WS) | nur Proxy→Backend (LAN/localhost) | Proxy leitet hierhin weiter; **nicht** ins Internet |
| 1984 | go2rtc API / WebSocket / Debug-UI | ❌ nein | bleibt intern (localhost) |
| 8555/udp | go2rtc WebRTC-Media | ❌ nein | im aktuellen **MJPEG-Modus ungenutzt** (siehe Caveat unten) |
**Merksatz:** Nach Umsetzung dieser Roadmap ist der einzige offene Port `443` am Proxy.
Alles andere läuft über genau eine Origin (`https://<host>`) → Proxy → `8444`.
> `network_mode: host`: Die Container binden direkt an Host-Ports — es gibt **kein**
> Docker-`ports:`-Mapping. „Offen/zu" steuerst du allein über die Host-Firewall.
> Läuft der Proxy auf demselben Host, muss `8444` gar nicht in der Firewall geöffnet
> werden (Proxy erreicht `127.0.0.1:8444`).
---
## Aktueller Stand (was läuft)
- Live-Bild kommt als **MJPEG über WebSocket** (`MODE = 'mjpeg'`, `public/viewer.js:9`).
Die WebRTC-Kommentare im `docker-compose.yaml` beschreiben einen **anderen** Aufbau,
der nicht aktiv ist → UDP 8555 wird derzeit nicht gebraucht.
- Node.js (`8444`) liefert: Webseite, `/config.json`, `/health`, `/api/snapshot/*`
und proxied `/api`, `/video-rtc.js`, `/video-stream.js` per HTTP an go2rtc (`server.js:34`).
- Snapshots und Skripte laufen bereits **relativ/same-origin** über `8444` → proxy-tauglich.
---
## Das Problem (warum es so noch nicht hinter HTTPS läuft)
Der Viewer baut die Stream-Verbindung aktuell **direkt** zu go2rtc auf — `public/viewer.js:36`:
```js
const wsUrl = `ws://${location.hostname}:${GO2RTC_PORT}/api/ws?src=...`; // GO2RTC_PORT = 1984
```
Hinter einem HTTPS-Proxy scheitert das doppelt:
1. **Mixed Content** — eine `https://`-Seite darf kein unverschlüsseltes `ws://` öffnen
→ Browser blockt die Verbindung hart.
2. **Proxy-Umgehung** — die URL zeigt direkt auf Port `1984`, den wir bewusst nicht
exponieren (dort hängt auch die offene go2rtc-Debug-UI).
→ Folge: Seite lädt, aber **kein Live-Bild**.
---
## Lösung — 3 Schritte
Danach geht der Stream über dieselbe Origin wie die Seite (`wss://<host>/api/ws`),
durch den Proxy, auf `8444`, intern weiter zu go2rtc `1984`.
### Schritt 1 — `public/viewer.js`: same-origin & protokoll-bewusste WS-URL
`startStream()`, aktuell `viewer.js:36`:
```js
// ALT:
const wsUrl = `ws://${location.hostname}:${GO2RTC_PORT}/api/ws?src=${encodeURIComponent(cam.id)}`;
// NEU:
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/api/ws?src=${encodeURIComponent(cam.id)}`;
```
- `location.host` enthält ggf. den Port → funktioniert **sowohl** direkt im LAN
(`http://host:8444``ws://host:8444/...`) **als auch** hinter HTTPS
(`https://cam.example.com``wss://cam.example.com/...`).
- `/api/ws` ist im Proxy-`pathFilter` bereits enthalten (`server.js:37`).
- `GO2RTC_PORT` / der `/config.json`-Fetch in `init()` werden für den Stream damit
**nicht mehr gebraucht** (dürfen als harmloser Toter Code bleiben oder raus).
### Schritt 2 — `server.js`: Proxy WebSockets durchreichen lassen
Im `createProxyMiddleware`-Block (`server.js:34`) `ws: true` ergänzen:
```js
const go2rtcProxy = createProxyMiddleware({
target: GO2RTC_URL,
changeOrigin: true,
ws: true, // ← NEU
pathFilter: ['/api', '/video-rtc.js', '/video-stream.js'],
logger: console,
on: { /* … unverändert … */ },
});
```
Und nach dem Erstellen des HTTP-Servers (`server.js:113`) den Upgrade-Handler binden:
```js
const server = http.createServer(app);
server.on('upgrade', go2rtcProxy.upgrade); // ← NEU (WebSocket-Upgrades an go2rtc)
```
> `http-proxy-middleware@^3` (vorhanden, `package.json`): `ws: true` **plus** der
> explizite `server.on('upgrade', …)` ist die dokumentierte, zuverlässige Kombination.
### Schritt 3 — Reverse-Proxy: WebSocket-Upgrade durchreichen
**nginx:**
```nginx
server {
listen 443 ssl;
server_name cam.example.com;
# ssl_certificate … / ssl_certificate_key …;
location / {
proxy_pass http://127.0.0.1:8444;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # ← Pflicht für WS
proxy_set_header Connection "upgrade"; # ← Pflicht für WS
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s; # WS-Stream offen halten
}
}
```
**Synology DSM** (Systemsteuerung → Anmeldeportal → Erweitert → Reverse Proxy):
1. Regel anlegen: Quelle `HTTPS` / `cam.example.com` / `443` → Ziel `HTTP` / `localhost` / `8444`.
2. In der Regel → Reiter **Eigene Kopfzeile (Custom Header)****Erstellen → WebSocket**
(fügt `Upgrade`/`Connection` automatisch hinzu). **Ohne diesen Schritt kommt kein Bild.**
3. Optional Timeout erhöhen, damit der Stream nicht nach kurzer Zeit getrennt wird.
**Caddy** (zur Referenz — WS geht automatisch):
```caddy
cam.example.com {
reverse_proxy 127.0.0.1:8444
}
```
---
## Verifikation (nach Deploy abhaken)
- [ ] `https://cam.example.com/` lädt, Header + Kamera-Kacheln sichtbar.
- [ ] DevTools → Console: `[WebcamViewer][init] … <video-stream> definiert`, **kein** Mixed-Content-Fehler.
- [ ] DevTools → Network → Filter „WS": Eintrag `wss://cam.example.com/api/ws?src=cam0`
mit Status **101 Switching Protocols** (nicht 4xx/blocked).
- [ ] Live-Bild für cam0 **und** cam1 läuft; Status zeigt „MJPEG · live".
- [ ] „⬇ Snapshot alle" lädt JPG(s) herunter (`/api/snapshot/...` über Proxy).
- [ ] Von **aussen**: nur `443` erreichbar. `1984`, `8444`, `8555` aus dem Internet
**nicht** erreichbar (z. B. `curl https://<public>:1984` → Timeout/refused).
---
## Rollback
Kleine, isolierte Änderung in genau zwei Dateien — risikoarm:
1. `public/viewer.js` und `server.js` auf den alten Stand zurück (git).
2. `docker restart AppRobotWebcam` (lädt Code neu).
Der Live-Stream im LAN funktioniert mit der neuen `viewer.js` weiterhin
(`ws://host:8444/...`), d. h. die Änderung ist auch ohne Proxy gefahrlos.
---
## Caveat — falls je wieder WebRTC (MODE mit `webrtc`)
Wird `public/viewer.js` jemals zurück auf `MODE = 'webrtc,mse,mjpeg'` gestellt
(siehe `doc/04_Delay_roadmap.md`), ändert sich die Port-Lage:
- WebRTC-**Signaling** läuft weiter über die WebSocket (`/api/ws`) → durch den Proxy ok.
- WebRTC-**Media** läuft über **UDP 8555** und geht **nicht** durch einen HTTP(S)-Proxy.
Dann bräuchte man entweder UDP 8555 direkt erreichbar **oder** einen TURN-Server.
Für den aktuellen MJPEG-Betrieb ist das **irrelevant** — alles läuft über TCP/WS auf `8444`.