# AppRobotWebcam – Multiple Kameras

> Status: **Implementiert** (Phase 1–4A). Phase 4B/C offen.
> Aktueller Stand: 3 Kameras (2× C270, 1× C920), gemischte stream-Konfiguration.

---

## Architektur

```
cameras.json
    │
    └── server.js  →  je Eintrag EINE CameraSwitch (besitzt /dev/videoN, On-Demand)
            │
            ├── stream:true  →  Viewer zeigt Live-Box     →  GET /api/stream/:id
            └── stream:false →  Viewer zeigt Platzhalter  (kein Dauer-<img>)
                                Grab-Pfad identisch: getFrame() / grabHires()

Node.js / Express :8444
    ├── GET /api/cameras              →  Metadaten aller Kameras aus cameras.json
    ├── GET /api/snapshot             →  Liste mit Metadaten (id, name, position, stream …)
    ├── GET /api/snapshot/:id         →  640er JPEG  (on-demand, getFrame)
    ├── GET /api/snapshot/:id/hires   →  hires JPEG  (grabHires – Live kurz pausieren)
    ├── GET /api/stream/:id           →  MJPEG multipart/x-mixed-replace (Live)
    └── GET /health                   →  Status aller Switches
```

**Eine Kamera = eine `CameraSwitch`.** Es gibt keinen separaten Grab-Pfad für
Live- vs. Snapshot-Kameras. Das Feld `stream` entscheidet nur, ob der Viewer
eine Live-Box aufbaut — der Server behandelt alle Kameras gleich.

**CPU-Verbrauch:** On-Demand — 0 % idle, ~35 %/Kamera nur solange jemand schaut
oder ein Grab läuft. Limit ist USB-Bandbreite + Zahl gleichzeitiger Live-Views,
nicht die Gesamtzahl der Kameras.

---

## `cameras.json`

Einzige Konfigurationsquelle für Geräte, Namen und Auflösungen.
Liegt im Projektverzeichnis, wird beim Start geladen und validiert.

```json
{
  "cameras": [
    {
      "id":        "cam0",
      "device":    "/dev/video0",
      "name":      "Vorderseite",
      "position":  "front",
      "stream":    true,
      "hires":     true,
      "note":      "usb-046d_0825_3BB3FE20-video-index0"
    },
    {
      "id":        "cam1",
      "device":    "/dev/video2",
      "name":      "Links",
      "position":  "left",
      "stream":    false,
      "hires":     true,
      "note":      "usb-046d_081b_342D4F40-video-index0"
    },
    {
      "id":        "cam2",
      "device":    "/dev/video4",
      "name":      "Rechts",
      "position":  "right",
      "stream":    true,
      "hires":     true,
      "hiresSize": "1920x1080",
      "note":      "usb-046d_HD_Pro_Webcam_C920_9C5591DF-video-index0"
    }
  ]
}
```

> **cam2 (C920):** Live läuft auf dem globalen Default 640×480 (USB-schonend, flüssig),
> nur der HD-Grab geht auf 1920×1080. `encode`/`hiresEncode` sind **nicht** gesetzt →
> beide nutzen `copybsf` (Kamera-JPEG ohne Re-Encode = beste Standbild-Qualität, s.u.).
> Der Wechsel 640→1920 beim Grab funktioniert direkt; **kein** Warmup/Zwischenformat nötig
> (auf dem Host verifiziert, 2026-06-06). Details: `05_screenShot_roadmap.md`.

### Felder

| Feld | Typ | Pflicht | Bedeutung |
|---|---|---|---|
| `id` | string | ✓ | Stabiler Bezeichner; wird in Dateinamen und API-Pfaden verwendet |
| `device` | string | ✓ | `/dev/videoN` — der Pfad **im Container** (siehe by-id-Mapping unten) |
| `name` | string | | Anzeigename im Viewer (Fallback: `id`) |
| `position` | string | | Frei; wird im Viewer-Label angezeigt (`name · position`) |
| `stream` | bool | | `true` → Live-Box im Viewer; `false` → Platzhalter (Default: `true`) |
| `hires` | bool | | `false` → kein hires-Grab verfügbar, z.B. bei schwacher Kamera (Default: `true`) |
| `liveSize` | string | | z.B. `"1280x720"` — überschreibt globales `LIVE_SIZE`-Env |
| `liveFps` | int | | Überschreibt globales `LIVE_FPS`-Env |
| `hiresSize` | string | | Überschreibt globales `HIRES_SIZE`-Env |
| `hiresFps` | int | | Überschreibt globales `HIRES_FPS`-Env |
| `encode` | string | | `"copybsf"` oder `"mjpeg"` — überschreibt `ENCODE_MODE`-Env (gilt für Live; und für Grab, falls `hiresEncode` fehlt) |
| `hiresEncode` | string | | Encode **nur** für den HD-Grab; überschreibt `encode`. Default = `encode`. Für Standbilder `copybsf` bevorzugen (s.u.) |
| `note` | string | | Freitext; empfohlen: by-id-Name des Geräts (Dokumentation) |

**Globale Env-Defaults** (gelten wenn das Feld in cameras.json fehlt):
`LIVE_SIZE=640x480`, `LIVE_FPS=30`, `HIRES_SIZE=1280x960`, `HIRES_FPS=15`,
`ENCODE_MODE=copybsf`.

---

## Stabile Gerätepfade (`docker-compose.yaml`)

`/dev/videoN` kann nach einem Reboot eine andere Kamera bezeichnen.
Lösung: by-id auf dem **Host** → festes `/dev/videoN` im **Container**.

```bash
ls -la /dev/v4l/by-id/    # zeigt die stabilen Namen
```

In `docker-compose.yaml`:

```yaml
devices:
  - /dev/v4l/by-id/usb-046d_0825_3BB3FE20-video-index0:/dev/video0   # cam0
  - /dev/v4l/by-id/usb-046d_081b_342D4F40-video-index0:/dev/video2   # cam1
  - /dev/v4l/by-id/usb-046d_HD_Pro_Webcam_C920_9C5591DF-video-index0:/dev/video4  # cam2
```

Der by-id-Name gehört zusätzlich ins `note`-Feld von cameras.json (Dokumentation).
`cameras.json` selbst verwendet `/dev/videoN` — den Pfad den der Container sieht.

---

## MJPEG-Auflösung prüfen

Nicht jede Auflösung ist bei jeder Kamera nativ MJPEG. Nicht-native Auflösungen
erzwingen Software-Re-Encode (~50 % CPU extra).

```bash
v4l2-ctl --list-formats-ext -d /dev/video4 | grep -A 20 MJPG
```

Nur Auflösungen unter `'MJPG'` in `liveSize`/`hiresSize` eintragen.
Falls eine Auflösung nur unter `'YUYV'` erscheint → andere Auflösung wählen.

> **Lehrgeld (2026-06-06):** Beim Einbau der C920 schien 1920×1080 „nur 720 zu liefern".
> Ursache war **nicht** die Kamera (1920×1080 MJPEG ist nativ, auf dem Host verifiziert),
> sondern (a) veralteter Code im Container und (b) ein nachträglich eingebauter
> „Warmup-Zwischenformat"-Schritt, der einen zweiten FFmpeg auf dem Gerät startete
> (`Device or resource busy`). Beides entfernt. Lehre: erst auf dem **Host** mit
> `ffmpeg`/`ffprobe` verifizieren, was die Kamera real liefert, bevor man Code-Theorien baut.

---

## Standbild-Qualität: Encode-Wahl

Eine USB-Kamera liefert das Bild bei `input_format mjpeg` bereits **JPEG-komprimiert**
(Hardware, unvermeidbar). Wie der HD-Grab das ausgibt, entscheidet `hiresEncode`/`encode`:

| Modus | Was passiert | Qualität / Größe | Wann |
|---|---|---|---|
| `copybsf` (Default) | Kamera-JPEG **durchreichen** (`-c:v copy -bsf:v mjpeg2jpeg`), keine zweite Kompression | beste JPEG-Qualität, klein, niedrigste CPU | **Standardwahl, auch für Standbilder** |
| `mjpeg` | Re-Encode (`-c:v mjpeg -q:v 5`) — **zweite** Kompression | sichtbare Artefakte bei Standbildern, ~50 % CPU | nur als Fallback, wenn copybsf bei einer Kamera zickt |

→ Für scharfe Standbilder **`copybsf`** verwenden (kein `hiresEncode` setzen). `mjpeg`
war ein Workaround und erzeugte bei cam2 sichtbare Artefakte (131 kB vs. ~350 kB copybsf).

**Wirklich verlustfrei** ginge nur über das rohe **YUYV**-Format → PNG (kein JPEG-Verlust
überhaupt). Das ist ein eigener Grab-Pfad (große Dateien ~3–6 MB, langsamer) und noch
**nicht implementiert** — bei Bedarf siehe „Offene Punkte".

---

## USB-Hardware: Bandbreite

Mehrere aktive Live-Streams brauchen genug USB-Kapazität.

| Auflösung | fps | MJPEG-Bitrate | Streams pro USB-2.0-Controller |
|---|---|---|---|
| 640×480 | 30 | ~5 MB/s | 8 (theoretisch), 3–4 (praktisch) |
| 1280×720 | 30 | ~12 MB/s | 2–3 |
| 1280×960 | 15 | ~15 MB/s | 2–3 |
| 1920×1080 | 30 | ~25 MB/s | 1–2 |

`lsusb -t` zeigt welche Kameras am selben Controller hängen.
Streaming-Kameras (`stream:true`) möglichst auf **getrennten Controllern**.
Snapshot-only-Kameras (`stream:false`) teilen einen Controller problemlos.

---

## Neue Kamera hinzufügen

1. `ls -la /dev/v4l/by-id/` → by-id-Namen identifizieren
2. `v4l2-ctl --list-formats-ext -d /dev/videoN | grep -A 20 MJPG` → Auflösung wählen
3. Eintrag in `cameras.json` ergänzen (id, device, name, position, stream, liveSize …)
4. `docker-compose.yaml` → neues Device (`by-id → /dev/videoN`) eintragen
5. Stack in Portainer redeployen

---

## Phase 4B/C — WebService-Push (offen)

**Erst umsetzen wenn ein konkreter aufrufender Container existiert.**

### Option B — Push-Trigger mit Job-ID

Ein fremder Container löst einen Grab aus; Bilder landen auf einem gemeinsamen Volume.

```
POST /api/snapshot/trigger
    Body (optional): { "cameras": ["cam0", "cam2"] }   ← leer = alle
    Response: { "job": "abc123", "status": "grabbing" }

GET /api/snapshot/job/abc123
    Response: { "status": "done", "files": [
      { "id": "cam0", "path": "/snapshots/cam0_hires_1234.jpg" },
      { "id": "cam2", "path": "/snapshots/cam2_hires_1234.jpg" }
    ]}
```

`docker-compose.yaml` — gemeinsames Volume:
```yaml
volumes:
  snapshots:
webcam:
  volumes:
    - snapshots:/snapshots
homing-service:
  volumes:
    - snapshots:/snapshots:ro
```

`src/snapshotService.js`: Bilder nach `/snapshots/` schreiben, In-Memory Job-Queue.

**Aufwand:** ~3–4 h.

### Option C — Synchroner Einzel-Endpunkt

```
GET /api/snapshot/all/hires
    Response: JSON { "cam0": "<base64>", "cam2": "<base64>" }
```

Blockiert ~8–10 s. Nur sinnvoll wenn der Aufrufer synchron warten kann.
**Aufwand:** ~2 h.

---

## Offene Punkte

| Punkt | Priorität | Massnahme |
|---|---|---|
| Phase 4B/C WebService-Push | niedrig | erst wenn aufrufender Container konkret |
| Verlustfreie Standbilder (YUYV→PNG) | niedrig | eigener Grab-Pfad: rohes YUYV lesen → PNG; nur wenn JPEG-Qualität nicht reicht |
| USB-Bandbreite bei >4 aktiven Streams | mittel | `lsusb -t` prüfen, Kameras auf Controller verteilen |
| Stream-Freeze (selten) | niedrig | bekannt; noch kein reproduzierbarer Fall |
