From f4ab49e0c80340f384acb39055734d9e14eaf429 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Kacprzak?= Date: Mon, 3 Aug 2026 00:55:12 +0200 Subject: [PATCH] Modularize bridge and improve control GUI --- Dockerfile | 3 +- README.md | 239 +-- camera-control.env.example | 12 + examples/docker-compose/README.md | 61 + .../docker-compose/camera-control.env.example | 27 + .../docker-compose/compose.sidecar-only.yaml | 27 + examples/docker-compose/compose.yaml | 36 + examples/kubernetes/README.md | 37 + .../same-pod.yaml} | 10 + examples/kubernetes/two-pods.yaml | 130 ++ examples/podman-quadlet/README.md | 31 - .../frigate-camera-control-bridge.env.example | 7 + frigate-camera-control-bridge.py | 1752 +---------------- frigate_camera_control_bridge/__init__.py | 5 + frigate_camera_control_bridge/__main__.py | 4 + frigate_camera_control_bridge/app.py | 1709 ++++++++++++++++ frigate_camera_control_bridge/cli.py | 50 + frigate_camera_control_bridge/i18n.py | 81 + frigate_camera_control_bridge/static/app.css | 383 ++++ frigate_camera_control_bridge/static/app.js | 37 + .../templates/index.html | 33 + frigate_camera_control_bridge/ui.py | 217 ++ .../frigate_camera_control_bridge.po | 48 + .../frigate_camera_control_bridge.po | 48 + tests/test_discovery_mapping.py | 33 +- tests/test_ui_render.py | 37 + 26 files changed, 3104 insertions(+), 1953 deletions(-) create mode 100644 examples/docker-compose/README.md create mode 100644 examples/docker-compose/camera-control.env.example create mode 100644 examples/docker-compose/compose.sidecar-only.yaml create mode 100644 examples/docker-compose/compose.yaml create mode 100644 examples/kubernetes/README.md rename examples/{kubernetes-sidecar.yaml => kubernetes/same-pod.yaml} (88%) create mode 100644 examples/kubernetes/two-pods.yaml create mode 100644 frigate_camera_control_bridge/__init__.py create mode 100644 frigate_camera_control_bridge/__main__.py create mode 100644 frigate_camera_control_bridge/app.py create mode 100644 frigate_camera_control_bridge/cli.py create mode 100644 frigate_camera_control_bridge/i18n.py create mode 100644 frigate_camera_control_bridge/static/app.css create mode 100644 frigate_camera_control_bridge/static/app.js create mode 100644 frigate_camera_control_bridge/templates/index.html create mode 100644 frigate_camera_control_bridge/ui.py create mode 100644 tests/test_ui_render.py diff --git a/Dockerfile b/Dockerfile index 595d488..80b1e90 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,7 @@ ENV HTTP_PORT=5011 ENV UI_LANGUAGE=en COPY frigate-camera-control-bridge.py /app/frigate-camera-control-bridge.py +COPY frigate_camera_control_bridge /app/frigate_camera_control_bridge COPY locale /app/locale COPY tools/compile_gettext.py /app/tools/compile_gettext.py @@ -19,4 +20,4 @@ EXPOSE 5011 HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 CMD python3 -c "import os,urllib.request; urllib.request.urlopen('http://127.0.0.1:%s/readyz' % os.environ.get('HTTP_PORT', '5011'), timeout=3).read()" -ENTRYPOINT ["python3", "/app/frigate-camera-control-bridge.py"] +ENTRYPOINT ["python3", "-m", "frigate_camera_control_bridge"] diff --git a/README.md b/README.md index 13c7498..1afe91b 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,86 @@ # Frigate Camera Control Bridge -## PL +Generic sidecar for a single Frigate instance. The container reads Frigate +configuration from `/api/config`, discovers cameras from `go2rtc` and direct +`ffmpeg.inputs[].path` stream URLs, then exposes light and siren controls +through Home Assistant MQTT Discovery plus an emergency HTTP fallback GUI. -Ten projekt jest uniwersalnym sidecarem dla pojedynczej instancji Frigate. -Kontener pobiera konfiguracje Frigate przez `/api/config`, wykrywa kamery z -`go2rtc` oraz z bezposrednich URL-i `ffmpeg.inputs[].path`, a potem wystawia -sterowanie swiatlem/syrena przez MQTT Discovery dla Home Assistant i awaryjne -GUI HTTP. +## Design -Najwazniejsze zalozenia: -- Ten sam obraz obsluguje Podman dzisiaj i Kubernetes pozniej. -- Konfiguracja runtime jest przez zmienne srodowiskowe, bez montowania pliku - konfiguracyjnego Frigate. -- Sidecar czeka na Frigate przy starcie i ponawia odczyt `/api/config`. -- Jesli `ffmpeg.inputs[].path` wskazuje na `rtsp://127.0.0.1:8554/...`, - sidecar traktuje to jako brame go2rtc i szuka prawdziwego URL-a kamery w +- One image works for Podman now and Kubernetes later. +- Runtime configuration is environment-based; no Frigate config mount is needed. +- The sidecar waits for Frigate startup and retries `/api/config`. +- The sidecar periodically refreshes `/api/config`, so added or removed cameras + can update MQTT Discovery without a container restart. +- On startup it runs a safe `all-off` by default, sets switch entities to `OFF`, + and publishes retained MQTT state. +- Alarms and lights have default auto-off timers: siren 180s, red-blue/strobe + 300s, white light 600s. The GUI timer is rendered from a backend timestamp, + so page refreshes do not reset it. +- Siren `ON` actions require a browser confirmation in the fallback GUI. +- If `ffmpeg.inputs[].path` points at `rtsp://127.0.0.1:8554/...`, the sidecar + treats it as a go2rtc gateway and resolves the real camera URL from `go2rtc.streams`. -- Aktualny Podman uzywa pliku env per sidecar, np. - `/etc/frigate-camera-control-bridge/frigate-main.env`. -- Kubernetes powinien podawac `MQTT_HOST`, `MQTT_USER`, `MQTT_PASSWORD`, - `DEFAULT_CAMERA_USER`, `DEFAULT_CAMERA_PASSWORD` przez `Secret`/`ConfigMap`. -- W trybie env brak tych wartosci jest bledem startu; sidecar nie szuka - credentiali w Quadletach ani w konfiguracji Frigate. -- GUI fallback domyslnie slucha na `5011`, bo `5001` jest juz uzywany przez - czesc instancji Frigate. -- Jezyki GUI sa standardowym gettext: `locale//LC_MESSAGES/*.po` i `.mo`. +- If Frigate uses a direct non-loopback stream URL in `ffmpeg.inputs[].path`, + the sidecar uses that direct URL and does not resolve go2rtc in parallel. +- In env mode, missing MQTT or camera credentials are startup errors; the + sidecar does not look up credentials in Quadlets or Frigate config. +- The fallback GUI listens on `5011` by default because `5001` may already be + used by Frigate. +- GUI languages use standard gettext files: + `locale//LC_MESSAGES/*.po` and `.mo`. -Podstawowe zmienne: +## Environment -| Zmienna | Znaczenie | +| Variable | Meaning | | --- | --- | -| `FRIGATE_NAME` | Nazwa tej instancji, np. `frigate-main`. | -| `FRIGATE_URL` | URL API Frigate, np. `http://frigate-main.example.test:5000`. | -| `MQTT_HOST`, `MQTT_PORT` | Broker MQTT. | -| `MQTT_USER`, `MQTT_PASSWORD` | Credentiale MQTT. | -| `DEFAULT_CAMERA_USER`, `DEFAULT_CAMERA_PASSWORD` | Domyslne credentiale kamer odkrytych z Frigate. | -| `BASE_TOPIC` | Bazowy topic MQTT, np. `frigate_camera_control/frigate-main`. | -| `DISCOVERY_PREFIX` | Prefix Home Assistant MQTT Discovery, zwykle `homeassistant`. | -| `UI_LANGUAGE` | Domyslny jezyk GUI i encji HA, domyslnie `en`. | -| `HTTP_PORT` | Port GUI fallback, domyslnie `5011`. | -| `HTTP_TOKEN` | Opcjonalny token dla GUI/API HTTP. | -| `SNAPSHOT_RETRY_SECONDS` | Co ile probowac ponownie, gdy migawki brakuja, domyslnie `600`. | -| `SNAPSHOT_REFRESH_SECONDS` | Co ile odswiezac poprawnie pobrane migawki, domyslnie `3600`. | -| `SNAPSHOT_LIVE_ON_GUI` | Czy pobierac swieza migawke z Frigate przy otwieraniu GUI, domyslnie `true`. | +| `FRIGATE_NAME` | Instance name, for example `frigate-main`. | +| `FRIGATE_URL` | Frigate API URL, for example `http://frigate-main.example.test:5000`. | +| `MQTT_HOST`, `MQTT_PORT` | MQTT broker. | +| `MQTT_USER`, `MQTT_PASSWORD` | MQTT credentials. | +| `DEFAULT_CAMERA_USER`, `DEFAULT_CAMERA_PASSWORD` | Default credentials for cameras discovered from Frigate. | +| `BASE_TOPIC` | MQTT base topic, for example `frigate_camera_control/frigate-main`. | +| `DISCOVERY_PREFIX` | Home Assistant MQTT Discovery prefix, usually `homeassistant`. | +| `UI_LANGUAGE` | Default GUI and HA entity language, defaults to `en`. | +| `HTTP_PORT` | Fallback GUI port, defaults to `5011`. | +| `HTTP_TOKEN` | Optional HTTP GUI/API token. | +| `FRIGATE_REFRESH_SECONDS` | Frigate config refresh interval, defaults to `300`; `0` disables it. | +| `SNAPSHOT_RETRY_SECONDS` | Retry interval while a snapshot is missing, defaults to `600`. | +| `SNAPSHOT_REFRESH_SECONDS` | Refresh interval after snapshots are available, defaults to `3600`. | +| `SNAPSHOT_LIVE_ON_GUI` | Fetch a fresh Frigate snapshot when the GUI loads, defaults to `true`. | +| `STARTUP_ALL_OFF` | Send safe OFF commands during startup, defaults to `true`. | +| `AUTO_OFF_SIREN_SECONDS` | Siren auto-off, defaults to `180`. | +| `AUTO_OFF_RED_BLUE_SECONDS` | Red-blue/strobe auto-off, defaults to `300`. | +| `AUTO_OFF_WHITE_LIGHT_SECONDS` | White-light auto-off, defaults to `600`. | -Przyklad `/etc/frigate-camera-control-bridge/frigate-main.env`: - -```bash -MQTT_HOST=mqtt.example.test -MQTT_PORT=1883 -MQTT_USER=... -MQTT_PASSWORD=... -DEFAULT_CAMERA_USER=admin -DEFAULT_CAMERA_PASSWORD=... -``` - -Budowanie: +## Build ```bash docker build -t frigate-camera-control-bridge:1.0 . docker build -f Dockerfile.dev -t frigate-camera-control-bridge:dev . ``` -Przyklady uruchomienia: -- Podman Quadlet: `examples/podman-quadlet/` -- Kubernetes sidecar: `examples/kubernetes-sidecar.yaml` +## Run -Lokalny test kontenera: +Podman Quadlet example: + +```text +examples/podman-quadlet/ +``` + +Docker Compose example: + +```text +examples/docker-compose/ +``` + +Kubernetes sidecar example: + +```text +examples/kubernetes/ +``` + +Local check: ```bash docker run --rm \ @@ -81,100 +95,7 @@ docker run --rm \ frigate-camera-control-bridge:1.0 --check ``` -Jezyki: - -1. Dodaj katalog `locale/de/LC_MESSAGES/`. -2. Skopiuj `frigate_camera_control_bridge.po` z `locale/en/LC_MESSAGES/`. -3. Przetlumacz `msgstr`. -4. Uruchom `python3 tools/compile_gettext.py locale`. -5. Po przebudowaniu obrazu GUI samo pokaze nowy jezyk. - -Manualne komendy w kontenerze: - -```bash -/app/frigate-camera-control-bridge.py --list -/app/frigate-camera-control-bridge.py --check -/app/frigate-camera-control-bridge.py --all-off -/app/frigate-camera-control-bridge.py --action frigate_main_camera_a_red_blue off -``` - -HTTP fallback: - -```text -http://:5011/ -http://:5011/?lang=pl -http://:5011/api/actions -http://:5011/snapshot/.jpg -http://:5011/healthz -http://:5011/readyz -``` - -Jesli ustawiono `HTTP_TOKEN`, dodaj `?token=` albo naglowek -`X-Bridge-Token: `. - -## EN - -This project is a generic sidecar for one Frigate instance. The container reads -Frigate configuration from `/api/config`, discovers cameras from `go2rtc` and -direct `ffmpeg.inputs[].path` stream URLs, then exposes light/siren controls -through Home Assistant MQTT Discovery plus an emergency HTTP fallback GUI. - -Main design points: -- One image works for Podman now and Kubernetes later. -- Runtime configuration is environment-based; no Frigate config mount is needed. -- The sidecar waits for Frigate startup and retries `/api/config`. -- If `ffmpeg.inputs[].path` points at `rtsp://127.0.0.1:8554/...`, the sidecar - treats it as a go2rtc gateway and resolves the real camera URL from - `go2rtc.streams`. -- Current Podman deployment uses one env file per sidecar, for example: - `/etc/frigate-camera-control-bridge/frigate-main.env`. -- Kubernetes should pass `MQTT_HOST`, `MQTT_USER`, `MQTT_PASSWORD`, - `DEFAULT_CAMERA_USER`, and `DEFAULT_CAMERA_PASSWORD` from a `Secret`/`ConfigMap`. -- In env mode, missing values are startup errors; the sidecar does not look up - credentials in Quadlets or Frigate config. -- The fallback GUI listens on `5011` by default because `5001` is already used - by some Frigate instances. -- GUI languages use standard gettext files: - `locale//LC_MESSAGES/*.po` and `.mo`. - -Core env vars: - -| Variable | Meaning | -| --- | --- | -| `FRIGATE_NAME` | Instance name, for example `frigate-main`. | -| `FRIGATE_URL` | Frigate API URL, for example `http://frigate-main.example.test:5000`. | -| `MQTT_HOST`, `MQTT_PORT` | MQTT broker. | -| `MQTT_USER`, `MQTT_PASSWORD` | MQTT credentials. | -| `DEFAULT_CAMERA_USER`, `DEFAULT_CAMERA_PASSWORD` | Default credentials for cameras discovered from Frigate. | -| `BASE_TOPIC` | MQTT base topic, for example `frigate_camera_control/frigate-main`. | -| `DISCOVERY_PREFIX` | Home Assistant MQTT Discovery prefix, usually `homeassistant`. | -| `UI_LANGUAGE` | Default GUI and HA entity language, defaults to `en`. | -| `HTTP_PORT` | Fallback GUI port, defaults to `5011`. | -| `HTTP_TOKEN` | Optional HTTP GUI/API token. | -| `SNAPSHOT_RETRY_SECONDS` | Retry interval while a snapshot is missing, defaults to `600`. | -| `SNAPSHOT_REFRESH_SECONDS` | Refresh interval after snapshots are available, defaults to `3600`. | -| `SNAPSHOT_LIVE_ON_GUI` | Fetch a fresh Frigate snapshot when the GUI loads, defaults to `true`. | - -Build: - -```bash -docker build -t frigate-camera-control-bridge:1.0 . -docker build -f Dockerfile.dev -t frigate-camera-control-bridge:dev . -``` - -Run examples: -- Podman Quadlet: `examples/podman-quadlet/` -- Kubernetes sidecar: `examples/kubernetes-sidecar.yaml` - -Development container: - -```bash -docker run --rm -it \ - -v "$PWD":/workspace \ - frigate-camera-control-bridge:dev -``` - -HTTP fallback: +## HTTP GUI ```text http://:5011/ @@ -185,10 +106,30 @@ http://:5011/healthz http://:5011/readyz ``` -Kubernetes sidecar notes: +If `HTTP_TOKEN` is set, pass `?token=` or the `X-Bridge-Token` header. + +## Manual Commands + +```bash +/app/frigate-camera-control-bridge.py --list +/app/frigate-camera-control-bridge.py --check +/app/frigate-camera-control-bridge.py --all-off +/app/frigate-camera-control-bridge.py --action frigate_main_camera_a_red_blue off +``` + +## Languages + +1. Add `locale/de/LC_MESSAGES/`. +2. Copy `frigate_camera_control_bridge.po` from `locale/en/LC_MESSAGES/`. +3. Translate `msgstr`. +4. Run `python3 tools/compile_gettext.py locale`. +5. Rebuild the image. The GUI will discover the new language automatically. + +## Kubernetes Notes + - Put the bridge container in the same pod as Frigate, or point `FRIGATE_URL` at the Frigate service. - Use `readinessProbe` on `/readyz`. - Pass secrets through env vars from `Secret`. - Keep `/var/lib/frigate-camera-control-bridge` on a small persistent volume if - you want stale Home Assistant discovery cleanup to survive pod recreation. + stale Home Assistant discovery cleanup should survive pod recreation. diff --git a/camera-control.env.example b/camera-control.env.example index bd7bb5b..dd4c51a 100644 --- a/camera-control.env.example +++ b/camera-control.env.example @@ -20,3 +20,15 @@ SNAPSHOT_CACHE_SECONDS=300 SNAPSHOT_RETRY_SECONDS=600 SNAPSHOT_REFRESH_SECONDS=3600 SNAPSHOT_LIVE_ON_GUI=true + +# Optional. Re-read Frigate /api/config periodically so camera additions and +# removals update Home Assistant discovery without restarting the sidecar. +FRIGATE_REFRESH_SECONDS=300 + +# Optional. Startup sends safe OFF commands and publishes retained OFF state +# for switch entities. Auto-off values are seconds; 0 disables that class. +STARTUP_ALL_OFF=true +AUTO_OFF_SIREN_SECONDS=180 +AUTO_OFF_RED_BLUE_SECONDS=300 +AUTO_OFF_WHITE_LIGHT_SECONDS=600 +AUTO_OFF_DEFAULT_SECONDS=0 diff --git a/examples/docker-compose/README.md b/examples/docker-compose/README.md new file mode 100644 index 0000000..1f3efb7 --- /dev/null +++ b/examples/docker-compose/README.md @@ -0,0 +1,61 @@ +# Docker Compose Example + +This directory shows two Docker Compose patterns: + +- `compose.yaml`: Frigate and the camera-control bridge run side by side in the + same Compose project. +- `compose.sidecar-only.yaml`: only the bridge is added next to an existing + Frigate container on an existing Docker network. + +In both patterns, the bridge manages exactly one Frigate instance. + +## Full Stack + +1. Build the bridge image from the repository root: + +```bash +docker build -t frigate-camera-control-bridge:1.0 . +``` + +2. Copy the env template: + +```bash +cp examples/docker-compose/camera-control.env.example examples/docker-compose/camera-control.env +``` + +3. Edit `examples/docker-compose/camera-control.env` and set MQTT plus camera + credentials. + +4. Start the stack: + +```bash +docker compose -f examples/docker-compose/compose.yaml up -d +``` + +The fallback GUI will be available on: + +```text +http://localhost:5011/ +``` + +## Sidecar Next To Existing Frigate + +Use `compose.sidecar-only.yaml` when Frigate is already running in Docker and +the bridge only needs to join the same network. + +1. Make sure the external network in the example exists and contains Frigate: + +```bash +docker network ls +``` + +2. Edit `compose.sidecar-only.yaml`: + +- set `networks.frigate-network.external` to the network name used by Frigate, +- set `FRIGATE_URL` to the Frigate container DNS name or another reachable URL. + +3. Start only the bridge: + +```bash +docker compose -f examples/docker-compose/compose.sidecar-only.yaml up -d +``` diff --git a/examples/docker-compose/camera-control.env.example b/examples/docker-compose/camera-control.env.example new file mode 100644 index 0000000..0092b14 --- /dev/null +++ b/examples/docker-compose/camera-control.env.example @@ -0,0 +1,27 @@ +MQTT_HOST=mqtt.example.test +MQTT_PORT=1883 +MQTT_USER=change-me +MQTT_PASSWORD=change-me + +DEFAULT_CAMERA_USER=admin +DEFAULT_CAMERA_PASSWORD=change-me + +HTTP_PORT=5011 +UI_LANGUAGE=en +WAIT_FOR_FRIGATE=true +FRIGATE_RETRY_SECONDS=5 +FRIGATE_STARTUP_WAIT_SECONDS=0 +FRIGATE_REFRESH_SECONDS=300 + +SNAPSHOT_ENABLED=true +SNAPSHOT_HEIGHT=180 +SNAPSHOT_CACHE_SECONDS=300 +SNAPSHOT_RETRY_SECONDS=600 +SNAPSHOT_REFRESH_SECONDS=3600 +SNAPSHOT_LIVE_ON_GUI=true + +STARTUP_ALL_OFF=true +AUTO_OFF_SIREN_SECONDS=180 +AUTO_OFF_RED_BLUE_SECONDS=300 +AUTO_OFF_WHITE_LIGHT_SECONDS=600 +AUTO_OFF_DEFAULT_SECONDS=0 diff --git a/examples/docker-compose/compose.sidecar-only.yaml b/examples/docker-compose/compose.sidecar-only.yaml new file mode 100644 index 0000000..9f8cd46 --- /dev/null +++ b/examples/docker-compose/compose.sidecar-only.yaml @@ -0,0 +1,27 @@ +services: + # Use this variant when Frigate already exists and this bridge should be added + # next to it on the same Docker network. + camera-control: + image: frigate-camera-control-bridge:1.0 + container_name: frigate-camera-control-bridge + restart: unless-stopped + env_file: + - camera-control.env + environment: + FRIGATE_NAME: frigate-main + # Set this to the Frigate container DNS name or another reachable URL. + FRIGATE_URL: http://frigate:5000 + BASE_TOPIC: frigate_camera_control/frigate-main + ports: + - "5011:5011" + volumes: + - camera-control-state:/var/lib/frigate-camera-control-bridge + networks: + - frigate-network + +volumes: + camera-control-state: + +networks: + frigate-network: + external: true diff --git a/examples/docker-compose/compose.yaml b/examples/docker-compose/compose.yaml new file mode 100644 index 0000000..575cb0f --- /dev/null +++ b/examples/docker-compose/compose.yaml @@ -0,0 +1,36 @@ +services: + # Main Frigate container. Replace volumes/devices with the configuration that + # your Frigate installation needs. + frigate: + image: ghcr.io/blakeblackshear/frigate:stable + container_name: frigate + restart: unless-stopped + ports: + - "5000:5000" + volumes: + - ./frigate-config:/config + - ./frigate-media:/media/frigate + + # Sidecar for exactly this Frigate instance. It discovers cameras from the + # Frigate API and exposes MQTT/GUI controls for the detected camera features. + camera-control: + image: frigate-camera-control-bridge:1.0 + container_name: frigate-camera-control-bridge + restart: unless-stopped + depends_on: + - frigate + env_file: + - camera-control.env + environment: + FRIGATE_NAME: frigate-main + # Because both containers share the same Compose network, the sidecar can + # reach Frigate by service name. + FRIGATE_URL: http://frigate:5000 + BASE_TOPIC: frigate_camera_control/frigate-main + ports: + - "5011:5011" + volumes: + - camera-control-state:/var/lib/frigate-camera-control-bridge + +volumes: + camera-control-state: diff --git a/examples/kubernetes/README.md b/examples/kubernetes/README.md new file mode 100644 index 0000000..4081104 --- /dev/null +++ b/examples/kubernetes/README.md @@ -0,0 +1,37 @@ +# Kubernetes Examples + +This directory contains two deployment patterns for one Frigate instance. + +## Same Pod + +`same-pod.yaml` runs Frigate and the bridge as two containers in one Pod: + +- `camera-control` is a real sidecar, +- both containers share the Pod network namespace, +- `FRIGATE_URL=http://127.0.0.1:5000`, +- this is the lowest-latency and simplest option when the bridge lifecycle + should follow the Frigate Pod. + +```bash +kubectl apply -f examples/kubernetes/same-pod.yaml +``` + +For an existing Frigate Deployment, copy only the `camera-control` container +block into the existing Pod template and keep `FRIGATE_URL` pointed at +`http://127.0.0.1:5000`. + +## Two Pods + +`two-pods.yaml` runs Frigate and the bridge as separate Deployments: + +- Frigate is exposed through a Kubernetes Service named `frigate`, +- the bridge uses `FRIGATE_URL=http://frigate:5000`, +- the bridge has its own lifecycle and can be restarted independently, +- this is useful when adding the bridge next to an existing Frigate Deployment. + +```bash +kubectl apply -f examples/kubernetes/two-pods.yaml +``` + +For production, replace image names, storage, device access, resource requests, +and secret names with values from your cluster. diff --git a/examples/kubernetes-sidecar.yaml b/examples/kubernetes/same-pod.yaml similarity index 88% rename from examples/kubernetes-sidecar.yaml rename to examples/kubernetes/same-pod.yaml index f32b290..8f9844a 100644 --- a/examples/kubernetes-sidecar.yaml +++ b/examples/kubernetes/same-pod.yaml @@ -31,12 +31,22 @@ spec: value: frigate_camera_control/frigate-main - name: UI_LANGUAGE value: en + - name: FRIGATE_REFRESH_SECONDS + value: "300" - name: SNAPSHOT_RETRY_SECONDS value: "600" - name: SNAPSHOT_REFRESH_SECONDS value: "3600" - name: SNAPSHOT_LIVE_ON_GUI value: "true" + - name: STARTUP_ALL_OFF + value: "true" + - name: AUTO_OFF_SIREN_SECONDS + value: "180" + - name: AUTO_OFF_RED_BLUE_SECONDS + value: "300" + - name: AUTO_OFF_WHITE_LIGHT_SECONDS + value: "600" - name: MQTT_USER valueFrom: secretKeyRef: diff --git a/examples/kubernetes/two-pods.yaml b/examples/kubernetes/two-pods.yaml new file mode 100644 index 0000000..c70a641 --- /dev/null +++ b/examples/kubernetes/two-pods.yaml @@ -0,0 +1,130 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frigate +spec: + replicas: 1 + selector: + matchLabels: + app: frigate + template: + metadata: + labels: + app: frigate + spec: + containers: + - name: frigate + image: ghcr.io/blakeblackshear/frigate:stable + ports: + - name: http + containerPort: 5000 +--- +apiVersion: v1 +kind: Service +metadata: + name: frigate +spec: + selector: + app: frigate + ports: + - name: http + port: 5000 + targetPort: http +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frigate-camera-control +spec: + replicas: 1 + selector: + matchLabels: + app: frigate-camera-control + template: + metadata: + labels: + app: frigate-camera-control + spec: + containers: + - name: camera-control + image: registry.example.local/frigate-camera-control-bridge:1.0 + env: + - name: FRIGATE_NAME + value: frigate-main + - name: FRIGATE_URL + value: http://frigate:5000 + - name: MQTT_HOST + value: mqtt.default.svc.cluster.local + - name: BASE_TOPIC + value: frigate_camera_control/frigate-main + - name: UI_LANGUAGE + value: en + - name: FRIGATE_REFRESH_SECONDS + value: "300" + - name: STARTUP_ALL_OFF + value: "true" + - name: AUTO_OFF_SIREN_SECONDS + value: "180" + - name: AUTO_OFF_RED_BLUE_SECONDS + value: "300" + - name: AUTO_OFF_WHITE_LIGHT_SECONDS + value: "600" + - name: MQTT_USER + valueFrom: + secretKeyRef: + name: frigate-camera-control + key: mqtt-user + - name: MQTT_PASSWORD + valueFrom: + secretKeyRef: + name: frigate-camera-control + key: mqtt-password + - name: DEFAULT_CAMERA_USER + valueFrom: + secretKeyRef: + name: frigate-camera-control + key: camera-user + - name: DEFAULT_CAMERA_PASSWORD + valueFrom: + secretKeyRef: + name: frigate-camera-control + key: camera-password + - name: HTTP_TOKEN + valueFrom: + secretKeyRef: + name: frigate-camera-control + key: http-token + optional: true + ports: + - name: control-http + containerPort: 5011 + readinessProbe: + httpGet: + path: /readyz + port: control-http + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /healthz + port: control-http + initialDelaySeconds: 20 + periodSeconds: 30 + volumeMounts: + - name: camera-control-state + mountPath: /var/lib/frigate-camera-control-bridge + volumes: + - name: camera-control-state + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: frigate-camera-control +spec: + selector: + app: frigate-camera-control + ports: + - name: control-http + port: 5011 + targetPort: control-http diff --git a/examples/podman-quadlet/README.md b/examples/podman-quadlet/README.md index 21686c9..a93a972 100644 --- a/examples/podman-quadlet/README.md +++ b/examples/podman-quadlet/README.md @@ -1,36 +1,5 @@ # Podman Quadlet Example -## PL - -To jest generyczny przyklad uruchomienia kontenera -`frigate-camera-control-bridge` przez Podman Quadlet. Pliki sa przykladem dla -jednej instancji Frigate. Dla wielu instancji skopiuj `.container` oraz `.env` -i zmien `FRIGATE_NAME`, `FRIGATE_URL`, `BASE_TOPIC`, port publikowany przez -`PublishPort` oraz credentiale MQTT. - -Przykladowa instalacja: - -```bash -install -d /etc/frigate-camera-control-bridge -install -m 0600 frigate-camera-control-bridge.env.example \ - /etc/frigate-camera-control-bridge/frigate-main.env -install -m 0644 frigate-camera-control-bridge.container \ - /etc/containers/systemd/frigate-camera-control-bridge.container -systemctl daemon-reload -systemctl enable --now frigate-camera-control-bridge.service -``` - -W pliku env uzupelnij: -- `MQTT_HOST`, `MQTT_USER`, `MQTT_PASSWORD` -- `DEFAULT_CAMERA_USER`, `DEFAULT_CAMERA_PASSWORD` -- `FRIGATE_NAME`, `FRIGATE_URL`, `BASE_TOPIC` - -Jesli sidecar ma startowac razem z istniejaca usluga Frigate, dopasuj w -`.container` nazwe `frigate.service` w polach `After`, `Requires`, `BindsTo` -i `PartOf`. - -## EN - This is a generic example for running `frigate-camera-control-bridge` with Podman Quadlet. The files model one Frigate instance. For multiple instances, copy the `.container` and `.env` files and change `FRIGATE_NAME`, diff --git a/examples/podman-quadlet/frigate-camera-control-bridge.env.example b/examples/podman-quadlet/frigate-camera-control-bridge.env.example index bbb77aa..fcb3e94 100644 --- a/examples/podman-quadlet/frigate-camera-control-bridge.env.example +++ b/examples/podman-quadlet/frigate-camera-control-bridge.env.example @@ -15,6 +15,7 @@ UI_LANGUAGE=en WAIT_FOR_FRIGATE=true FRIGATE_RETRY_SECONDS=5 FRIGATE_STARTUP_WAIT_SECONDS=0 +FRIGATE_REFRESH_SECONDS=300 SNAPSHOT_ENABLED=true SNAPSHOT_HEIGHT=180 @@ -22,3 +23,9 @@ SNAPSHOT_CACHE_SECONDS=300 SNAPSHOT_RETRY_SECONDS=600 SNAPSHOT_REFRESH_SECONDS=3600 SNAPSHOT_LIVE_ON_GUI=true + +STARTUP_ALL_OFF=true +AUTO_OFF_SIREN_SECONDS=180 +AUTO_OFF_RED_BLUE_SECONDS=300 +AUTO_OFF_WHITE_LIGHT_SECONDS=600 +AUTO_OFF_DEFAULT_SECONDS=0 diff --git a/frigate-camera-control-bridge.py b/frigate-camera-control-bridge.py index bce4f68..fd9b086 100644 --- a/frigate-camera-control-bridge.py +++ b/frigate-camera-control-bridge.py @@ -1,1755 +1,5 @@ #!/usr/bin/env python3 -import argparse -import gettext -import html -import json -import logging -import os -import queue -import re -import signal -import socket -import struct -import sys -import threading -import time -import urllib.error -import urllib.parse -import urllib.request -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path - - -VERSION = "1.0" -DEFAULT_CONFIG = "/etc/frigate-camera-control-bridge.json" -LOG = logging.getLogger("frigate-camera-control-bridge") -STREAM_URL_RE = re.compile(r"(?:rtsp|rtsps|http|https)://[^\s'\"<>]+", re.IGNORECASE) - -# The bridge is intentionally a single-file Python service with no runtime -# dependencies beyond the standard library. That keeps the same container image -# usable as a Podman sidecar today and as a Kubernetes sidecar later. -TRANSLATION_DOMAIN = "frigate_camera_control_bridge" -DEFAULT_LANGUAGE = "en" -LOCALE_DIR = Path( - os.environ.get( - "LOCALE_DIR", - Path(__file__).resolve().parent / "locale", - ) -) - - -_TRANSLATIONS = {} -_AVAILABLE_LANGUAGES = None - - -def parse_po_language_name(path): - # gettext compiled catalogs do not expose a friendly language name, so the - # editable .po file may carry an optional Language-Name header for the GUI. - try: - text = path.read_text(encoding="utf-8") - except OSError: - return None - for line in text.splitlines(): - if line.startswith('"Language-Name:'): - return line.split(":", 1)[1].strip().strip("\\n\"") - return None - - -def available_languages(): - # Discover languages dynamically from locale/*/LC_MESSAGES/*.mo. Adding a - # language should not require a code change; ship a new .po/.mo pair instead. - global _AVAILABLE_LANGUAGES - if _AVAILABLE_LANGUAGES is not None: - return _AVAILABLE_LANGUAGES - - languages = {DEFAULT_LANGUAGE: DEFAULT_LANGUAGE.upper()} - if LOCALE_DIR.exists(): - for item in sorted(LOCALE_DIR.iterdir()): - if not item.is_dir(): - continue - code = item.name - if (item / "LC_MESSAGES" / f"{TRANSLATION_DOMAIN}.mo").exists(): - languages[code] = ( - parse_po_language_name(item / "LC_MESSAGES" / f"{TRANSLATION_DOMAIN}.po") - or code.upper() - ) - _AVAILABLE_LANGUAGES = languages - return languages - - -def normalize_language(value): - if not value: - return DEFAULT_LANGUAGE - base = str(value).lower().replace("_", "-").split("-", 1)[0] - return base if base in available_languages() else DEFAULT_LANGUAGE - - -def translation_for(lang): - # fallback=True makes English msgids work even if a translation file is - # missing, which is useful while developing a new language catalog. - lang = normalize_language(lang) - if lang not in _TRANSLATIONS: - _TRANSLATIONS[lang] = gettext.translation( - TRANSLATION_DOMAIN, - localedir=str(LOCALE_DIR), - languages=[lang], - fallback=True, - ) - return _TRANSLATIONS[lang] - - -def text_for(lang, msgid, **kwargs): - text = translation_for(lang).gettext(msgid) - if kwargs: - try: - return text.format(**kwargs) - except KeyError as exc: - LOG.warning("missing translation placeholder %s for %s", exc, msgid) - return text - - -def mqtt_encode_string(value): - # Minimal MQTT v3.1.1 encoder. Avoiding a client dependency keeps the image - # small and avoids pip installs on offline homelab hosts. - data = value.encode("utf-8") - return struct.pack("!H", len(data)) + data - - -def mqtt_encode_remaining_length(length): - out = bytearray() - while True: - digit = length % 128 - length //= 128 - if length: - digit |= 0x80 - out.append(digit) - if not length: - return bytes(out) - - -def read_exact(sock, size): - buf = bytearray() - while len(buf) < size: - part = sock.recv(size - len(buf)) - if not part: - raise EOFError("socket closed") - buf.extend(part) - return bytes(buf) - - -def read_mqtt_packet(sock): - first = read_exact(sock, 1)[0] - multiplier = 1 - remaining_length = 0 - while True: - digit = read_exact(sock, 1)[0] - remaining_length += (digit & 127) * multiplier - if not digit & 128: - break - multiplier *= 128 - if multiplier > 128 * 128 * 128: - raise ValueError("malformed MQTT remaining length") - return first, read_exact(sock, remaining_length) - - -def send_mqtt_packet(sock, packet_type_with_flags, payload=b""): - sock.sendall( - bytes([packet_type_with_flags]) - + mqtt_encode_remaining_length(len(payload)) - + payload - ) - - -def load_config(path): - with open(path, "r", encoding="utf-8") as handle: - return json.load(handle) - - -def slug(value): - return "".join(ch if ch.isalnum() else "_" for ch in value.lower()).strip("_") - - -def env_bool(name, default=True): - value = os.environ.get(name) - if value is None: - return default - return value.lower() in ("1", "true", "yes", "on") - - -def env_list(name): - value = os.environ.get(name, "") - return [item.strip() for item in value.split(",") if item.strip()] - - -def env_secret(name): - # Support both classic env vars and *_FILE secret mounts. Kubernetes users - # will usually use env from Secret, while Docker/Podman users may prefer a - # mounted secret file. The direct env var wins when both are present. - value = os.environ.get(name) - if value: - return value - path = os.environ.get(f"{name}_FILE") - if not path: - return None - try: - return Path(path).read_text(encoding="utf-8").strip() - except OSError as exc: - raise RuntimeError(f"failed to read {name}_FILE={path}: {exc}") from exc - - -def config_from_env(): - # Env mode is the preferred sidecar mode. It needs only a Frigate API URL, - # MQTT connection parameters, and camera credentials. The old JSON config is - # still accepted for manual runs and migration debugging. - frigate_name = os.environ.get("FRIGATE_NAME", "frigate") - language = normalize_language(os.environ.get("UI_LANGUAGE", DEFAULT_LANGUAGE)) - frigate_url = os.environ.get("FRIGATE_URL") - if not frigate_url: - frigate_host = os.environ.get("FRIGATE_HOST") - if not frigate_host: - raise RuntimeError("FRIGATE_URL or FRIGATE_HOST is required in env mode") - frigate_url = f"http://{frigate_host}:5000" - - mqtt_host = os.environ.get("MQTT_HOST") - if not mqtt_host: - raise RuntimeError("MQTT_HOST is required in env mode") - mqtt_user = env_secret("MQTT_USER") - mqtt_password = env_secret("MQTT_PASSWORD") - if not mqtt_user: - raise RuntimeError("MQTT_USER is required in env mode") - if not mqtt_password: - raise RuntimeError("MQTT_PASSWORD is required in env mode") - camera_user = env_secret("DEFAULT_CAMERA_USER") - camera_password = env_secret("DEFAULT_CAMERA_PASSWORD") - if not camera_user: - raise RuntimeError("DEFAULT_CAMERA_USER is required in env mode") - if not camera_password: - raise RuntimeError("DEFAULT_CAMERA_PASSWORD is required in env mode") - - mqtt = { - "host": mqtt_host, - "port": int(os.environ.get("MQTT_PORT", "1883")), - "base_topic": os.environ.get("BASE_TOPIC", f"frigate_camera_control/{slug(frigate_name)}"), - "discovery_prefix": os.environ.get("DISCOVERY_PREFIX", "homeassistant"), - "client_id": os.environ.get("MQTT_CLIENT_ID", f"frigate-camera-control-{slug(frigate_name)}"), - "username": mqtt_user, - "password": mqtt_password, - "keepalive": int(os.environ.get("MQTT_KEEPALIVE", "60")), - } - - camera_auth = { - "username": camera_user, - "password": camera_password, - } - - return { - "mqtt": mqtt, - "camera_auth": camera_auth, - "discovery": { - "enabled": True, - "state_file": os.environ.get( - "DISCOVERY_STATE_FILE", - "/var/lib/frigate-camera-control-bridge/discovery_state.json", - ), - "include_hikvision_colorvu_white": env_bool("INCLUDE_HIKVISION_COLORVU_WHITE", True), - "include_dahua_coaxial_off_probe": env_bool("INCLUDE_DAHUA_COAXIAL_OFF_PROBE", True), - "wait_for_frigate": env_bool("WAIT_FOR_FRIGATE", True), - "retry_seconds": float(os.environ.get("FRIGATE_RETRY_SECONDS", "5")), - "startup_wait_seconds": float(os.environ.get("FRIGATE_STARTUP_WAIT_SECONDS", "0")), - "dahua_probe_channels": [int(item) for item in env_list("DAHUA_PROBE_CHANNELS") or ["1", "0", "2"]], - "dahua_siren_probe_channels": [ - int(item) for item in env_list("DAHUA_SIREN_PROBE_CHANNELS") or ["2", "1", "0"] - ], - "frigates": [ - { - "name": frigate_name, - "host": os.environ.get("FRIGATE_HOST", urllib.parse.urlsplit(frigate_url).hostname or frigate_name), - "url": frigate_url, - } - ], - }, - "http": { - "enabled": env_bool("HTTP_ENABLED", True), - "bind": os.environ.get("HTTP_BIND", "0.0.0.0"), - "port": int(os.environ.get("HTTP_PORT", "5011")), - "token": env_secret("HTTP_TOKEN"), - }, - "ui": { - "default_language": language, - }, - "defaults": { - "http_timeout": float(os.environ.get("CAMERA_HTTP_TIMEOUT", "6")), - "colorvu_brightness": int(os.environ.get("COLORVU_BRIGHTNESS", "100")), - "snapshot_enabled": env_bool("SNAPSHOT_ENABLED", True), - "snapshot_height": int(os.environ.get("SNAPSHOT_HEIGHT", "180")), - "snapshot_cache_seconds": int(os.environ.get("SNAPSHOT_CACHE_SECONDS", "300")), - "snapshot_retry_seconds": int(os.environ.get("SNAPSHOT_RETRY_SECONDS", "600")), - "snapshot_refresh_seconds": int(os.environ.get("SNAPSHOT_REFRESH_SECONDS", "3600")), - "snapshot_live_on_gui": env_bool("SNAPSHOT_LIVE_ON_GUI", True), - }, - "actions": [ - { - "id": f"{slug(frigate_name)}_all_off", - "name": text_for(language, "action.all_off", target=frigate_name), - "name_key": "action.all_off", - "frigate_name": frigate_name, - "platform": "button", - "kind": "all_off", - "device": { - "identifiers": [f"frigate_camera_control_{slug(frigate_name)}"], - "name": f"Frigate camera control {frigate_name}", - "manufacturer": "Local", - "model": "Sidecar", - }, - } - ], - } - - -def load_config_or_env(path): - if path and Path(path).exists(): - return load_config(path) - return config_from_env() - - -def response_status_ok(body): - text = body.decode("utf-8", errors="replace") - if "Error" in text and "ResponseStatus" not in text: - return False, text.strip()[:180] - if "1" in text or "0" in text: - return True, text.strip()[:180] - if "OK" in text and "Error" not in text: - return True, text.strip()[:180] - if not text.strip(): - return True, "" - return True, text.strip()[:180] - - -class DigestHttpClient: - # Camera APIs in this installation use HTTP Digest. The client also allows - # Basic Auth because some camera firmwares accept either scheme. - def __init__(self, username, password, timeout=6): - self.username = username - self.password = password - self.timeout = timeout - - def _opener(self, base_url): - mgr = urllib.request.HTTPPasswordMgrWithDefaultRealm() - mgr.add_password(None, base_url, self.username, self.password) - return urllib.request.build_opener( - urllib.request.HTTPDigestAuthHandler(mgr), - urllib.request.HTTPBasicAuthHandler(mgr), - ) - - def request(self, url, method="GET", body=None, content_type=None): - headers = {} - data = None - if body is not None: - data = body.encode("utf-8") - headers["Content-Type"] = content_type or "application/xml" - parsed = urllib.parse.urlsplit(url) - base_url = f"{parsed.scheme}://{parsed.netloc}/" - req = urllib.request.Request(url, data=data, headers=headers, method=method) - try: - with self._opener(base_url).open(req, timeout=self.timeout) as response: - payload = response.read() - ok, detail = response_status_ok(payload) - if not ok: - raise RuntimeError(detail) - return response.status, payload.decode("utf-8", errors="replace") - except urllib.error.HTTPError as exc: - payload = exc.read().decode("utf-8", errors="replace") - raise RuntimeError(f"HTTP {exc.code}: {payload[:180]}") from exc - except urllib.error.URLError as exc: - raise RuntimeError(str(exc.reason)) from exc - - -class Action: - # Action is the stable internal contract used by MQTT, HTTP GUI, CLI, and - # Home Assistant discovery. Camera-specific code is kept behind kind-specific - # runners so new vendors can be added without touching the MQTT layer. - def __init__(self, cfg, http_client): - self.cfg = cfg - self.id = cfg["id"] - self.name = cfg.get("name", self.id) - self.platform = cfg.get("platform", "switch") - self.kind = cfg["kind"] - self.http = http_client - self.state = None - - def display_name(self, lang): - names = self.cfg.get("name_i18n") or {} - normalized = normalize_language(lang) - if names: - return names.get(normalized) or names.get(DEFAULT_LANGUAGE) or self.name - - key = self.cfg.get("name_key") - if not key: - return self.name - - prefix = self.cfg.get("camera_name") or self.cfg.get("frigate_name") or "" - return text_for(normalized, key, target=prefix).strip() - - def command_list(self): - if self.kind == "all_off": - return ["press"] - if self.platform == "button": - return ["press"] - return ["on", "off"] - - def safe_off_command(self): - if self.kind == "all_off": - return None - if "safe_off" in self.cfg: - return "safe_off" - if self.platform == "switch" and "off" in self.cfg: - return "off" - return None - - def run(self, command, bridge=None): - command = command.lower() - if self.kind == "all_off": - if bridge is None: - raise RuntimeError("all_off requires bridge context") - return bridge.all_off() - if self.kind == "hikvision_colorvu": - self._run_hikvision(command) - elif self.kind == "dahua_coaxial_io": - self._run_dahua_coax(command) - else: - raise RuntimeError(f"unsupported action kind {self.kind}") - - if self.platform == "switch": - if command == "on": - self.state = "ON" - elif command in ("off", "safe_off"): - self.state = "OFF" - return {"ok": True, "action": self.id, "command": command} - - def _run_hikvision(self, command): - if command == "on": - self._hikvision_put_supplement(colorvu_xml(self.cfg.get("brightness", 100))) - self._hikvision_put_ircut(ircut_xml("night")) - return - if command in ("off", "safe_off"): - restore = self.cfg.get("restore", {}) - self._hikvision_put_ircut(ircut_xml(restore.get("ircutFilterType", "auto"))) - self._hikvision_put_supplement(restore_xml(self.cfg.get("brightness", 100), restore)) - return - raise RuntimeError(f"unsupported command for {self.id}: {command}") - - def _hikvision_put_supplement(self, xml): - host = self.cfg["host"] - self.http.request( - f"http://{host}/ISAPI/Image/channels/1/supplementLight", - method="PUT", - body=xml, - ) - - def _hikvision_put_ircut(self, xml): - host = self.cfg["host"] - self.http.request( - f"http://{host}/ISAPI/Image/channels/1/ircutFilter", - method="PUT", - body=xml, - ) - - def _run_dahua_coax(self, command): - params = self.cfg.get(command) - if params is None and command == "safe_off": - params = self.cfg.get("off") or self.cfg.get("press") - if params is None: - raise RuntimeError(f"unsupported command for {self.id}: {command}") - host = self.cfg["host"] - query = urllib.parse.urlencode( - { - "action": "control", - "channel": int(params["channel"]), - "info[0].Type": int(params["type"]), - "info[0].IO": int(params["io"]), - }, - safe="[]", - ) - self.http.request(f"http://{host}/cgi-bin/coaxialControlIO.cgi?{query}") - - -def colorvu_xml(brightness): - # Hikvision ColorVu white light is controlled through supplementLight plus - # ircutFilter. Turning it off restores the mode captured during discovery. - return f""" - -colorVuWhiteLight -manual -{int(brightness)} -100 - -manual -{int(brightness)} -100 - -""" - - -def restore_xml(brightness, restore): - associated = restore.get("associatedVMDHuman") - associated_xml = "" - if associated is not None: - associated_xml = f"\n{str(bool(associated)).lower()}" - return f""" - -{restore.get("supplementLightMode", "eventIntelligence")} -{restore.get("mixedLightBrightnessRegulatMode", "auto")} -{int(restore.get("whiteLightBrightness", brightness))} -{int(restore.get("irLightBrightness", 100))} - -{restore.get("eventIntelligenceBrightnessRegulatMode", "auto")} -{int(restore.get("whiteLightBrightness", brightness))} -{int(restore.get("irLightBrightness", 100))}{associated_xml} - -""" - - -def ircut_xml(mode): - return f""" - -{mode} -4 -5 -""" - - -class MqttBridgeClient: - # This is a deliberately small MQTT 3.1.1 client. It supports only what the - # bridge needs: connect, subscribe to command topics, publish retained HA - # discovery/state messages, and Last Will availability. - def __init__(self, cfg, messages): - self.cfg = cfg - self.messages = messages - self.sock = None - self.lock = threading.Lock() - self.packet_id = 1 - - @property - def base_topic(self): - return self.cfg.get("base_topic", "frigate_camera_control") - - @property - def availability_topic(self): - return f"{self.base_topic}/status" - - def next_packet_id(self): - self.packet_id += 1 - if self.packet_id > 65535: - self.packet_id = 1 - return self.packet_id - - def connect(self, username=None, password=None): - host = self.cfg["host"] - port = int(self.cfg.get("port", 1883)) - keepalive = int(self.cfg.get("keepalive", 60)) - client_id = self.cfg.get("client_id", "frigate-camera-control-bridge") - - sock = socket.create_connection((host, port), timeout=10) - sock.settimeout(1) - - flags = 0x02 - payload = mqtt_encode_string(client_id) - - flags |= 0x04 | 0x20 - payload += mqtt_encode_string(self.availability_topic) - payload += mqtt_encode_string("offline") - - if username is not None: - flags |= 0x80 - if password is not None: - flags |= 0x40 - - if username is not None: - payload += mqtt_encode_string(username) - if password is not None: - payload += mqtt_encode_string(password) - - variable_header = mqtt_encode_string("MQTT") + bytes([4, flags]) + struct.pack("!H", keepalive) - send_mqtt_packet(sock, 0x10, variable_header + payload) - packet_type, body = read_mqtt_packet(sock) - if packet_type >> 4 != 2 or len(body) != 2 or body[1] != 0: - code = body[1] if len(body) >= 2 else "unknown" - raise RuntimeError(f"MQTT CONNACK failed: {code}") - - discovery_prefix = self.cfg.get("discovery_prefix", "homeassistant") - topics = [f"{self.base_topic}/+/set", f"{discovery_prefix}/status"] - packet_id = self.next_packet_id() - subscribe_payload = struct.pack("!H", packet_id) - for topic in topics: - subscribe_payload += mqtt_encode_string(topic) + b"\x00" - send_mqtt_packet(sock, 0x82, subscribe_payload) - - while True: - packet_type, body = read_mqtt_packet(sock) - if packet_type >> 4 != 9: - continue - if len(body) < 2 or struct.unpack("!H", body[:2])[0] != packet_id: - continue - if any(code == 0x80 for code in body[2:]): - raise RuntimeError(f"MQTT subscribe rejected for {topics}") - break - - with self.lock: - self.sock = sock - return sock - - def publish(self, topic, payload, retain=False): - if not isinstance(payload, bytes): - payload = str(payload).encode("utf-8") - packet = mqtt_encode_string(topic) + payload - with self.lock: - if self.sock is None: - raise RuntimeError("MQTT is not connected") - send_mqtt_packet(self.sock, 0x31 if retain else 0x30, packet) - - def close(self): - with self.lock: - sock = self.sock - self.sock = None - if sock is not None: - try: - sock.close() - except OSError: - pass - - def serve(self, credentials, on_connect, stop_event): - backoff = 1 - credential_index = 0 - keepalive = int(self.cfg.get("keepalive", 60)) - - while not stop_event.is_set(): - credential = credentials[credential_index % len(credentials)] - credential_index += 1 - sock = None - try: - sock = self.connect(credential.get("username"), credential.get("password")) - LOG.info("MQTT connected to %s:%s", self.cfg["host"], self.cfg.get("port", 1883)) - on_connect() - backoff = 1 - last_io = time.monotonic() - - while not stop_event.is_set(): - try: - first, body = read_mqtt_packet(sock) - except socket.timeout: - if time.monotonic() - last_io > max(10, keepalive / 2): - self.publish_ping() - last_io = time.monotonic() - continue - - last_io = time.monotonic() - packet_type = first >> 4 - flags = first & 0x0F - if packet_type == 3: - message = self.parse_publish(flags, body) - if message: - self.messages.put(message) - elif packet_type == 13: - continue - elif packet_type == 14: - raise EOFError("broker disconnected") - except Exception as exc: - if not stop_event.is_set(): - LOG.warning("MQTT failed: %s", exc) - stop_event.wait(backoff) - backoff = min(backoff * 2, 60) - finally: - self.close() - if sock is not None: - try: - sock.close() - except OSError: - pass - - def publish_ping(self): - with self.lock: - if self.sock is not None: - send_mqtt_packet(self.sock, 0xC0) - - def disconnect_cleanly(self): - try: - self.publish(self.availability_topic, "offline", retain=True) - except Exception: - pass - with self.lock: - if self.sock is not None: - try: - send_mqtt_packet(self.sock, 0xE0) - except OSError: - pass - - @staticmethod - def parse_publish(flags, body): - if len(body) < 2: - return None - topic_len = struct.unpack("!H", body[:2])[0] - if len(body) < 2 + topic_len: - return None - topic = body[2 : 2 + topic_len].decode("utf-8", errors="replace") - index = 2 + topic_len - qos = (flags >> 1) & 0x03 - packet_id = None - if qos: - packet_id = struct.unpack("!H", body[index : index + 2])[0] - index += 2 - payload = body[index:].decode("utf-8", errors="replace").strip() - return topic, payload, packet_id - - -class CameraControlBridge: - # CameraControlBridge wires together four boundaries: - # - Frigate API discovery, - # - camera vendor HTTP commands, - # - MQTT/Home Assistant discovery, - # - manual HTTP fallback GUI. - def __init__(self, config): - self.config = config - self.stop_event = threading.Event() - self.messages = queue.Queue() - self.credentials = self.load_mqtt_credentials() - camera_password = self.load_camera_password() - camera_user = config.get("camera_auth", {}).get("username", "admin") - self.default_language = normalize_language( - config.get("ui", {}).get("default_language", DEFAULT_LANGUAGE) - ) - self.snapshot_cache = {} - self.http_client = DigestHttpClient( - camera_user, - camera_password, - timeout=float(config.get("defaults", {}).get("http_timeout", 6)), - ) - self.inventory = [] - self.actions = self.build_actions() - self.refresh_snapshots() - self.mqtt = MqttBridgeClient(config["mqtt"], self.messages) - self.http_server = None - - @property - def base_topic(self): - return self.config["mqtt"].get("base_topic", "frigate_camera_control") - - def load_mqtt_credentials(self): - mqtt_cfg = self.config["mqtt"] - username = mqtt_cfg.get("username") - password = mqtt_cfg.get("password") - if not username: - raise RuntimeError("MQTT username is required") - if not password: - raise RuntimeError("MQTT password is required") - return [{"username": username, "password": password, "source": "environment"}] - - def load_camera_password(self): - auth = self.config.get("camera_auth", {}) - if auth.get("password"): - return auth["password"] - raise RuntimeError("default camera password is required") - - def build_actions(self): - # Keep this bridge self-maintaining: every service start reads Frigate runtime - # configs, derives camera hosts, creates safe HA MQTT entities, and later - # removes stale discovery entities for cameras that disappeared. - self.inventory = self.discover_inventory() - present_hosts = {item["host"] for item in self.inventory if item.get("host")} - action_cfgs = [] - - for action_cfg in self.config.get("actions", []): - required_host = action_cfg.get("required_host") - if required_host and required_host not in present_hosts: - LOG.info("skip static action %s, host %s not in Frigate inventory", action_cfg["id"], required_host) - continue - action_cfgs.append(action_cfg) - - action_cfgs.extend(self.discover_action_configs()) - actions = {} - for action_cfg in action_cfgs: - if action_cfg["id"] in actions: - LOG.warning("duplicate action id skipped: %s", action_cfg["id"]) - continue - actions[action_cfg["id"]] = Action(action_cfg, self.http_client) - return actions - - def discover_inventory(self): - # Frigate may reference cameras either through direct ffmpeg URLs or - # through local go2rtc gateway URLs. The selected source URLs are then - # used to discover the camera API host without hardcoded addresses. - discovery = self.config.get("discovery", {}) - if not discovery.get("enabled", False): - return [] - - inventory = [] - for frigate in discovery.get("frigates", []): - name = frigate.get("name") or frigate["host"] - base_url = frigate.get("url") or f"http://{frigate['host']}:5000" - data = self.load_frigate_config_with_retry(frigate, base_url) - if data is None: - continue - - go2rtc = data.get("go2rtc") or {} - streams = go2rtc.get("streams") or go2rtc - cameras = data.get("cameras") or {} - for camera_name, camera_cfg in cameras.items(): - urls = self.camera_candidate_urls(camera_name, camera_cfg, streams) - endpoints = self.camera_endpoints_from_urls(urls) - kinds = self.kinds_from_urls(urls) - for host in endpoints: - inventory.append( - { - "frigate": name, - "frigate_url": base_url, - "camera": camera_name, - "host": host, - "kinds": kinds, - } - ) - LOG.info("discovered %d camera host mapping(s) from Frigate", len(inventory)) - return inventory - - def load_frigate_config_with_retry(self, frigate, base_url): - discovery = self.config.get("discovery", {}) - wait = bool(discovery.get("wait_for_frigate", True)) - retry_seconds = float(discovery.get("retry_seconds", 5)) - max_wait = float(discovery.get("startup_wait_seconds", 0)) - started = time.monotonic() - source = frigate.get("config_file") or f"{base_url.rstrip('/')}/api/config" - - while True: - try: - if frigate.get("config_file"): - return self.fetch_frigate_config_file(frigate["config_file"]) - return self.fetch_json(f"{base_url.rstrip('/')}/api/config") - except Exception as exc: - if not wait: - LOG.warning("Frigate discovery failed for %s: %s", source, exc) - return None - elapsed = time.monotonic() - started - if max_wait and elapsed >= max_wait: - LOG.warning("Frigate discovery timed out for %s after %.0fs: %s", source, elapsed, exc) - return None - LOG.info("waiting for Frigate API %s: %s", source, exc) - time.sleep(retry_seconds) - - @staticmethod - def fetch_json(url): - with urllib.request.urlopen(url, timeout=8) as response: - return json.loads(response.read().decode("utf-8", errors="replace")) - - @staticmethod - def fetch_frigate_config_file(path): - try: - import yaml - except ImportError as exc: - raise RuntimeError("PyYAML is required for config_file discovery") from exc - with open(path, "r", encoding="utf-8") as handle: - return yaml.safe_load(handle) or {} - - @staticmethod - def as_list(value): - if value is None: - return [] - if isinstance(value, list): - return value - return [value] - - @staticmethod - def camera_candidate_urls(camera_name, camera_cfg, streams): - # Discovery supports two Frigate layouts: - # 1. Direct camera URL in ffmpeg, e.g. - # rtsp://camera-a.example.test/... - # If such URL exists, it is the source of truth. - # 2. go2rtc gateway path in ffmpeg, e.g. rtsp://127.0.0.1:8554/stream-a. - # In this case the loopback URL is only a pointer; the real camera - # endpoint must be resolved from go2rtc.streams["stream-a"]. - urls = CameraControlBridge.camera_direct_urls(camera_cfg) - if urls: - return urls - for stream_name in CameraControlBridge.camera_go2rtc_stream_names(camera_cfg): - resolved = CameraControlBridge.resolve_go2rtc_stream_urls(streams, stream_name) - if not resolved: - LOG.info("go2rtc stream %s/%s not found in Frigate config", camera_name, stream_name) - urls.extend(resolved) - return urls - - @staticmethod - def camera_go2rtc_stream_names(camera_cfg): - # go2rtc-backed Frigate inputs usually look like: - # rtsp://127.0.0.1:8554/. Loopback here is not a camera API - # host; it is the go2rtc gateway. We keep only the stream name and - # resolve it through go2rtc.streams. - names = [] - for item in camera_cfg.get("ffmpeg", {}).get("inputs", []): - path = str(item.get("path", "")) - stream_name = CameraControlBridge.go2rtc_stream_name_from_url(path) - if stream_name: - names.append(stream_name) - return list(dict.fromkeys(names)) - - @staticmethod - def camera_direct_urls(camera_cfg): - # Some installations skip go2rtc and put camera RTSP/HTTP URLs directly - # in ffmpeg inputs. Those URLs are inspected as-is. go2rtc loopback URLs - # are intentionally excluded here because they must be resolved through - # go2rtc.streams first. - urls = [] - for item in camera_cfg.get("ffmpeg", {}).get("inputs", []): - path = str(item.get("path", "")) - if path and not CameraControlBridge.go2rtc_stream_name_from_url(path): - urls.extend(CameraControlBridge.stream_source_urls(path)) - return urls - - @staticmethod - def go2rtc_stream_name_from_url(url): - # go2rtc and Frigate often wrap real stream URLs, for example: - # ffmpeg:rtsp://127.0.0.1:8554/front-door#video=copy. Normalize that - # first so loopback gateway detection works for wrapped and raw values. - for candidate in CameraControlBridge.stream_source_urls(url) or [str(url)]: - try: - parsed = urllib.parse.urlsplit(candidate) - except ValueError: - continue - host = parsed.hostname or "" - if parsed.scheme != "rtsp" or parsed.port != 8554: - continue - if host not in ("127.0.0.1", "localhost", "::1"): - continue - return parsed.path.lstrip("/").split("/", 1)[0] or None - return None - - @staticmethod - def stream_source_urls(value): - # go2rtc supports source wrappers such as "ffmpeg:rtsp://..." and may - # append options after a URL fragment. Extract URL tokens instead of - # treating the wrapper prefix as the network scheme. - text = str(value or "") - urls = STREAM_URL_RE.findall(text) - if urls: - return urls - if "://" in text: - return [text] - return [] - - @staticmethod - def resolve_go2rtc_stream_urls(streams, stream_name): - # go2rtc stream values may be a string, a list of strings, or a small - # mapping depending on how the Frigate config was authored. Flatten only - # the values that can contain source URLs; non-URL helper values are - # harmless because host/vendor extraction is conservative. - value = streams.get(stream_name) if isinstance(streams, dict) else None - return CameraControlBridge.flatten_go2rtc_value(value) - - @staticmethod - def flatten_go2rtc_value(value): - if value is None: - return [] - if isinstance(value, str): - urls = CameraControlBridge.stream_source_urls(value) - return urls or [value] - if isinstance(value, list): - out = [] - for item in value: - out.extend(CameraControlBridge.flatten_go2rtc_value(item)) - return out - if isinstance(value, dict): - out = [] - for key in ("streams", "url", "urls", "source", "sources"): - if key in value: - out.extend(CameraControlBridge.flatten_go2rtc_value(value[key])) - return out - return [str(value)] - - @staticmethod - def hosts_from_urls(urls): - return CameraControlBridge.camera_endpoints_from_urls(urls) - - @staticmethod - def camera_endpoints_from_urls(urls): - # Extract the camera API endpoint from URLs without hardcoding camera IPs. - # IP literals and DNS names are both valid. Loopback addresses should - # already have been resolved through go2rtc.streams, so skip them if a - # config still leaks one through. - endpoints = [] - for url in urls: - try: - parsed = urllib.parse.urlsplit(str(url)) - except ValueError: - continue - host = parsed.hostname - if not host: - continue - normalized = host.strip("[]").lower() - if ( - normalized in ("localhost", "::1", "0.0.0.0", "255.255.255.255") - or normalized.startswith("127.") - ): - continue - endpoints.append(normalized) - return list(dict.fromkeys(endpoints)) - - @staticmethod - def kinds_from_urls(urls): - # Vendor detection is based on URL shape. This decides which safe - # capability probes may be attempted for each discovered camera. - kinds = set() - for url in urls: - if "Streaming/Channels" in url: - kinds.add("hikvision") - elif "cam/realmonitor" in url: - kinds.add("dahua") - elif "/stream1" in url or "/stream2" in url: - kinds.add("tapo") - elif "camera/stream" in url: - kinds.add("tablet") - else: - kinds.add("other") - return sorted(kinds) - - def discover_action_configs(self): - discovery = self.config.get("discovery", {}) - if not discovery.get("enabled", False): - return [] - actions = [] - for item in self.primary_inventory_items(): - host = item["host"] - if discovery.get("include_hikvision_colorvu_white", True): - action = self.discover_hikvision_colorvu_action(item) - if action: - actions.append(action) - if discovery.get("include_dahua_coaxial_off_probe", True): - actions.extend(self.discover_dahua_coaxial_actions(item)) - LOG.info("auto-created %d action(s) from camera capabilities", len(actions)) - return actions - - def primary_inventory_items(self): - grouped = {} - for item in self.inventory: - host = item["host"] - current = grouped.get(host) - if current is None: - grouped[host] = item - continue - if "ptz" in item["camera"].lower() and "ptz" not in current["camera"].lower(): - grouped[host] = item - return list(grouped.values()) - - def discover_hikvision_colorvu_action(self, item): - if "hikvision" not in item.get("kinds", []): - return None - host = item["host"] - try: - _, device_info = self.http_client.request(f"http://{host}/ISAPI/System/deviceInfo") - model = self.xml_text(device_info, "model") or "Hikvision" - _, caps = self.http_client.request(f"http://{host}/ISAPI/Image/channels/1/supplementLight/capabilities") - modes = self.xml_attr(caps, "supplementLightMode", "opt") or "" - if "colorVuWhiteLight" not in modes.split(","): - return None - _, current = self.http_client.request(f"http://{host}/ISAPI/Image/channels/1/supplementLight") - except Exception as exc: - LOG.info("skip Hikvision discovery %s/%s: %s", item["camera"], host, exc) - return None - - restore = { - "supplementLightMode": self.xml_text(current, "supplementLightMode") or "eventIntelligence", - "mixedLightBrightnessRegulatMode": self.xml_text(current, "mixedLightBrightnessRegulatMode") or "auto", - "eventIntelligenceBrightnessRegulatMode": self.xml_text(current, "brightnessRegulatMode") or "auto", - "whiteLightBrightness": int(self.xml_text(current, "whiteLightBrightness") or 100), - "irLightBrightness": int(self.xml_text(current, "irLightBrightness") or 100), - "ircutFilterType": "auto", - } - associated = self.xml_text(current, "associatedVMDHuman") - if associated is not None: - restore["associatedVMDHuman"] = associated.lower() == "true" - - action_id = f"{slug(item['frigate'])}_{slug(item['camera'])}_colorvu_white" - lang = self.default_language - return { - "id": action_id, - "name": text_for(lang, "action.manual_white", target=item["camera"]), - "name_key": "action.manual_white", - "camera_name": item["camera"], - "platform": "switch", - "kind": "hikvision_colorvu", - "host": host, - "brightness": int(self.config.get("defaults", {}).get("colorvu_brightness", 100)), - "restore": restore, - "snapshot": self.snapshot_config(item), - "device": { - "identifiers": [f"camera_{host.replace('.', '_')}"], - "name": item["camera"], - "manufacturer": "Hikvision", - "model": model, - "configuration_url": item["frigate_url"], - }, - } - - def discover_dahua_coaxial_actions(self, item): - # Dahua red-blue lights and sirens are discovered with OFF-only probes. - # A successful OFF endpoint means the command exists without starting - # the alarm/light during bridge startup. - if "dahua" not in item.get("kinds", []): - return [] - host = item["host"] - try: - _, info = self.http_client.request(f"http://{host}/cgi-bin/magicBox.cgi?action=getSystemInfo") - except Exception as exc: - LOG.info("skip Dahua discovery %s/%s: %s", item["camera"], host, exc) - return [] - - model = self.dahua_value(info, "deviceType") or "Dahua" - actions = [] - red_blue_channel = self.dahua_off_probe(host, action_type=1) - lang = self.default_language - if red_blue_channel is not None: - actions.append( - { - "id": f"{slug(item['frigate'])}_{slug(item['camera'])}_red_blue", - "name": text_for(lang, "action.red_blue", target=item["camera"]), - "name_key": "action.red_blue", - "camera_name": item["camera"], - "platform": "switch", - "kind": "dahua_coaxial_io", - "host": host, - "on": {"channel": red_blue_channel, "type": 1, "io": 1}, - "off": {"channel": red_blue_channel, "type": 1, "io": 2}, - "snapshot": self.snapshot_config(item), - "device": self.discovered_device(item, "Dahua", model), - } - ) - - siren_channel = self.dahua_off_probe(host, action_type=2) - if siren_channel is not None: - actions.append( - { - "id": f"{slug(item['frigate'])}_{slug(item['camera'])}_siren", - "name": text_for(lang, "action.siren", target=item["camera"]), - "name_key": "action.siren", - "camera_name": item["camera"], - "platform": "switch", - "kind": "dahua_coaxial_io", - "host": host, - "on": {"channel": siren_channel, "type": 2, "io": 1}, - "off": {"channel": siren_channel, "type": 2, "io": 2}, - "safe_off": {"channel": siren_channel, "type": 2, "io": 2}, - "snapshot": self.snapshot_config(item), - "device": self.discovered_device(item, "Dahua", model), - } - ) - return actions - - def dahua_off_probe(self, host, action_type): - channels = self.config.get("discovery", {}).get("dahua_probe_channels", [1, 2, 0]) - if action_type == 2: - channels = self.config.get("discovery", {}).get("dahua_siren_probe_channels", [2, 1, 0]) - for channel in channels: - query = urllib.parse.urlencode( - { - "action": "control", - "channel": int(channel), - "info[0].Type": int(action_type), - "info[0].IO": 2, - }, - safe="[]", - ) - try: - self.http_client.request(f"http://{host}/cgi-bin/coaxialControlIO.cgi?{query}") - LOG.info("Dahua OFF probe OK host=%s type=%s channel=%s", host, action_type, channel) - return int(channel) - except Exception: - continue - return None - - @staticmethod - def dahua_value(text, key): - match = re.search(rf"^{re.escape(key)}=(.+)$", text, re.MULTILINE) - return match.group(1).strip().strip("\r") if match else None - - @staticmethod - def discovered_device(item, manufacturer, model): - return { - "identifiers": [f"camera_{item['host'].replace('.', '_')}"], - "name": item["camera"], - "manufacturer": manufacturer, - "model": model, - "configuration_url": item["frigate_url"], - } - - @staticmethod - def snapshot_config(item): - return { - "frigate_url": item["frigate_url"], - "camera": item["camera"], - } - - @staticmethod - def xml_text(xml, tag): - match = re.search(rf"<(?:[^:>]+:)?{re.escape(tag)}[^>]*>([^<]+)]+:)?{re.escape(tag)}>", xml) - return match.group(1).strip() if match else None - - @staticmethod - def xml_attr(xml, tag, attr): - match = re.search(rf"<(?:[^:>]+:)?{re.escape(tag)}[^>]*\s{re.escape(attr)}=\"([^\"]*)\"", xml) - return match.group(1).strip() if match else None - - def list_actions(self): - for action in self.actions.values(): - commands = ",".join(action.command_list()) - print(f"{action.id}\t{action.platform}\t{commands}\t{action.display_name(self.default_language)}") - - def check(self): - ok = True - for credential in self.credentials: - try: - test = MqttBridgeClient(self.config["mqtt"], queue.Queue()) - sock = test.connect(credential.get("username"), credential.get("password")) - sock.close() - print(f"MQTT OK: {self.config['mqtt']['host']} as {credential['source']}") - break - except Exception as exc: - ok = False - print(f"MQTT ERROR using {credential['source']}: {exc}") - for action in self.actions.values(): - if action.kind == "hikvision_colorvu": - print(f"CHECK: {action.id} Hikvision host={action.cfg['host']} configured") - elif action.kind == "dahua_coaxial_io": - print(f"CHECK: {action.id} Dahua host={action.cfg['host']} configured") - elif action.kind == "all_off": - print(f"CHECK: {action.id} all-off button configured") - return 0 if ok else 1 - - def refresh_snapshots(self): - # Snapshot refresh is best-effort. It improves the manual GUI but should - # never prevent MQTT controls from starting. - if not self.config.get("defaults", {}).get("snapshot_enabled", True): - return True - seen = set() - ok = True - for action in self.actions.values(): - url = self.snapshot_url(action) - if not url or url in seen: - continue - seen.add(url) - try: - self.fetch_snapshot(url, force=True) - except Exception as exc: - ok = False - LOG.info("snapshot unavailable for %s: %s", action.id, exc) - return ok - - def snapshot_url(self, action): - snapshot = action.cfg.get("snapshot") - if not snapshot: - return None - camera = urllib.parse.quote(snapshot["camera"], safe="") - height = int(self.config.get("defaults", {}).get("snapshot_height", 180)) - return f"{snapshot['frigate_url'].rstrip('/')}/api/{camera}/latest.jpg?h={height}" - - def fetch_snapshot(self, url, force=False): - now = time.monotonic() - max_age = int(self.config.get("defaults", {}).get("snapshot_cache_seconds", 300)) - cached = self.snapshot_cache.get(url) - if cached and not force and now - cached["time"] < max_age: - return cached - with urllib.request.urlopen(url, timeout=8) as response: - data = response.read() - content_type = response.headers.get("Content-Type", "image/jpeg").split(";", 1)[0] - if not data: - raise RuntimeError("empty snapshot") - cached = {"data": data, "content_type": content_type, "time": now} - self.snapshot_cache[url] = cached - return cached - - def snapshot_for_action(self, action_id, live=False): - action = self.actions.get(action_id) - if not action: - return None - url = self.snapshot_url(action) - if not url: - return None - try: - return self.fetch_snapshot(url, force=live) - except Exception: - cached = self.snapshot_cache.get(url) - if cached: - return cached - raise - - def publish_discovery(self): - # Home Assistant MQTT discovery topics are retained. The bridge remembers - # what it published previously and clears stale entities when Frigate no - # longer exposes a camera/action. - discovery_prefix = self.config["mqtt"].get("discovery_prefix", "homeassistant") - availability = f"{self.base_topic}/status" - self.mqtt.publish(availability, "online", retain=True) - previous = self.load_discovery_state() - current = {} - for action in self.actions.values(): - payload = self.discovery_payload(action, availability) - topic = f"{discovery_prefix}/{action.platform}/frigate_camera_control/{action.id}/config" - self.mqtt.publish(topic, json.dumps(payload, separators=(",", ":")), retain=True) - current[action.id] = {"platform": action.platform, "topic": topic} - for action_id, old in previous.items(): - if action_id not in current: - topic = old.get("topic") or f"{discovery_prefix}/{old.get('platform', 'switch')}/frigate_camera_control/{action_id}/config" - self.mqtt.publish(topic, "", retain=True) - LOG.info("removed stale MQTT discovery entity %s", action_id) - self.save_discovery_state(current) - LOG.info("published MQTT discovery for %d actions", len(self.actions)) - - def discovery_state_file(self): - return Path( - self.config.get("discovery", {}).get( - "state_file", - "/var/lib/frigate-camera-control-bridge/discovery_state.json", - ) - ) - - def load_discovery_state(self): - path = self.discovery_state_file() - try: - return json.loads(path.read_text(encoding="utf-8")) - except FileNotFoundError: - return {} - except Exception as exc: - LOG.warning("failed to read discovery state %s: %s", path, exc) - return {} - - def save_discovery_state(self, state): - path = self.discovery_state_file() - try: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(state, indent=2), encoding="utf-8") - except Exception as exc: - LOG.warning("failed to write discovery state %s: %s", path, exc) - - def discovery_payload(self, action, availability_topic): - base = f"{self.base_topic}/{action.id}" - device_cfg = action.cfg.get("device") or {} - device = { - "identifiers": device_cfg.get("identifiers", ["frigate_camera_control"]), - "name": device_cfg.get("name", "Frigate Camera Control"), - } - for key in ("manufacturer", "model", "sw_version", "configuration_url"): - if device_cfg.get(key): - device[key] = device_cfg[key] - payload = { - "name": action.display_name(self.default_language), - "unique_id": f"frigate_camera_control_{action.id}", - "availability_topic": availability_topic, - "payload_available": "online", - "payload_not_available": "offline", - "device": device, - "origin": { - "name": "frigate-camera-control-bridge", - "sw_version": VERSION, - }, - "qos": 0, - } - if action.platform == "switch": - payload.update( - { - "command_topic": f"{base}/set", - "state_topic": f"{base}/state", - "payload_on": "ON", - "payload_off": "OFF", - "state_on": "ON", - "state_off": "OFF", - "optimistic": False, - "retain": False, - } - ) - elif action.platform == "button": - payload.update( - { - "command_topic": f"{base}/set", - "payload_press": "PRESS", - "retain": False, - } - ) - return payload - - def execute_action(self, action_id, command, publish=True): - action = self.actions.get(action_id) - if action is None: - raise RuntimeError(f"unknown action {action_id}") - result = action.run(command, bridge=self) - if publish: - try: - self.publish_result(action, command, result) - except Exception as exc: - LOG.warning("action %s %s worked, but MQTT publish failed: %s", action_id, command, exc) - return result - - def publish_result(self, action, command, result): - base = f"{self.base_topic}/{action.id}" - if action.platform == "switch" and action.state in ("ON", "OFF"): - self.mqtt.publish(f"{base}/state", action.state, retain=True) - self.mqtt.publish( - f"{base}/result", - json.dumps({"ok": True, "command": command, "time": int(time.time())}), - retain=False, - ) - - def publish_error(self, action_id, command, exc): - topic = f"{self.base_topic}/{action_id}/result" - payload = {"ok": False, "command": command, "error": str(exc), "time": int(time.time())} - try: - self.mqtt.publish(topic, json.dumps(payload), retain=False) - except Exception: - pass - - def all_off(self): - results = [] - for action in self.actions.values(): - command = action.safe_off_command() - if not command: - continue - try: - action.run(command, bridge=self) - results.append({"action": action.id, "ok": True}) - if action.platform == "switch": - try: - self.mqtt.publish(f"{self.base_topic}/{action.id}/state", "OFF", retain=True) - except Exception: - pass - except Exception as exc: - results.append({"action": action.id, "ok": False, "error": str(exc)}) - LOG.error("all_off failed for %s: %s", action.id, exc) - return {"ok": all(item["ok"] for item in results), "results": results} - - def handle_mqtt_message(self, topic, payload): - discovery_prefix = self.config["mqtt"].get("discovery_prefix", "homeassistant") - if topic == f"{discovery_prefix}/status" and payload.lower() == "online": - self.publish_discovery() - return - prefix = f"{self.base_topic}/" - if not topic.startswith(prefix) or not topic.endswith("/set"): - return - action_id = topic[len(prefix) : -len("/set")] - action = self.actions.get(action_id) - if not action: - LOG.warning("MQTT command for unknown action %s", action_id) - return - value = payload.upper() - if action.platform == "button": - command = "press" - elif value == "ON": - command = "on" - elif value == "OFF": - command = "off" - else: - LOG.warning("unsupported MQTT payload for %s: %s", action_id, payload) - return - try: - LOG.info("MQTT command %s %s", action_id, command) - self.execute_action(action_id, command, publish=True) - except Exception as exc: - LOG.error("MQTT action failed %s %s: %s", action_id, command, exc) - self.publish_error(action_id, command, exc) - - def serve(self): - signal.signal(signal.SIGTERM, self._handle_signal) - signal.signal(signal.SIGINT, self._handle_signal) - mqtt_thread = threading.Thread( - target=self.mqtt.serve, - args=(self.credentials, self.publish_discovery, self.stop_event), - daemon=True, - ) - mqtt_thread.start() - snapshot_thread = None - if self.config.get("defaults", {}).get("snapshot_enabled", True): - snapshot_thread = threading.Thread(target=self.snapshot_refresh_loop, daemon=True) - snapshot_thread.start() - http_cfg = self.config.get("http", {}) - if http_cfg.get("enabled", True): - self.start_http(http_cfg.get("bind", "0.0.0.0"), int(http_cfg.get("port", 5011))) - LOG.info("serving %d actions", len(self.actions)) - - while not self.stop_event.is_set(): - try: - topic, payload, packet_id = self.messages.get(timeout=1) - self.handle_mqtt_message(topic, payload) - except queue.Empty: - pass - - self.mqtt.disconnect_cleanly() - if self.http_server is not None: - self.http_server.shutdown() - mqtt_thread.join(timeout=2) - if snapshot_thread is not None: - snapshot_thread.join(timeout=2) - - def snapshot_refresh_loop(self): - retry_interval = max(60, int(self.config.get("defaults", {}).get("snapshot_retry_seconds", 600))) - refresh_interval = max(retry_interval, int(self.config.get("defaults", {}).get("snapshot_refresh_seconds", 3600))) - interval = retry_interval if self.missing_snapshots() else refresh_interval - while not self.stop_event.wait(interval): - all_ok = self.refresh_snapshots() - interval = refresh_interval if all_ok and not self.missing_snapshots() else retry_interval - - def missing_snapshots(self): - for action in self.actions.values(): - url = self.snapshot_url(action) - if url and url not in self.snapshot_cache: - return True - return False - - def http_token(self): - return self.config.get("http", {}).get("token") - - def http_authorized(self, headers, query): - token = self.http_token() - if not token: - return True - values = urllib.parse.parse_qs(query) - supplied = values.get("token", [None])[0] or headers.get("X-Bridge-Token") - return supplied == token - - def start_http(self, bind, port): - bridge = self - - class Handler(BaseHTTPRequestHandler): - def do_GET(self): - parsed = urllib.parse.urlsplit(self.path) - lang = bridge.request_language(parsed.query) - if parsed.path in ("/healthz", "/readyz"): - self.send_json({"ok": True, "actions": len(bridge.actions)}) - return - if not bridge.http_authorized(self.headers, parsed.query): - self.send_response(401) - self.end_headers() - return - if parsed.path.startswith("/snapshot/"): - action_id = urllib.parse.unquote(parsed.path.rsplit("/", 1)[-1]).removesuffix(".jpg") - try: - live = bool(bridge.config.get("defaults", {}).get("snapshot_live_on_gui", True)) - snapshot = bridge.snapshot_for_action(action_id, live=live) - except Exception as exc: - LOG.info("snapshot request failed for %s: %s", action_id, exc) - snapshot = None - if not snapshot: - self.send_response(404) - self.end_headers() - return - self.send_bytes(snapshot["data"], snapshot["content_type"]) - return - if parsed.path == "/api/actions": - self.send_json(bridge.api_actions()) - return - token_param = urllib.parse.parse_qs(parsed.query).get("token", [None])[0] - self.send_html(bridge.html_page(lang, token_param)) - - def do_POST(self): - parsed = urllib.parse.urlsplit(self.path) - lang = bridge.request_language(parsed.query) - if not bridge.http_authorized(self.headers, parsed.query): - self.send_response(401) - self.end_headers() - return - parts = parsed.path.strip("/").split("/") - if len(parts) == 3 and parts[0] == "action": - action_id = urllib.parse.unquote(parts[1]) - command = urllib.parse.unquote(parts[2]).lower() - try: - result = bridge.execute_action(action_id, command, publish=True) - token_param = urllib.parse.parse_qs(parsed.query).get("token", [None])[0] - token_query = f"&token={urllib.parse.quote(token_param)}" if token_param else "" - self.send_response(303) - self.send_header("Location", f"/?lang={urllib.parse.quote(lang)}{token_query}") - self.end_headers() - except Exception as exc: - self.send_response(500) - self.end_headers() - self.wfile.write(str(exc).encode("utf-8")) - return - self.send_response(404) - self.end_headers() - - def send_json(self, payload): - data = json.dumps(payload, indent=2).encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) - - def send_html(self, body): - data = body.encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "text/html; charset=utf-8") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) - - def send_bytes(self, data, content_type): - self.send_response(200) - self.send_header("Content-Type", content_type) - self.send_header("Cache-Control", "private, max-age=300") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) - - def log_message(self, fmt, *args): - LOG.info("http %s - %s", self.address_string(), fmt % args) - - self.http_server = ThreadingHTTPServer((bind, port), Handler) - thread = threading.Thread(target=self.http_server.serve_forever, daemon=True) - thread.start() - LOG.info("HTTP fallback GUI listening on %s:%s", bind, port) - - def api_actions(self): - return [ - { - "id": action.id, - "name": action.display_name(self.default_language), - "platform": action.platform, - "commands": action.command_list(), - "state": action.state, - "snapshot": bool(action.cfg.get("snapshot")), - } - for action in self.actions.values() - ] - - def html_page(self, lang=None, token_param=None): - return self.html_page_for_language(lang or self.default_language, token_param) - - def request_language(self, query): - values = urllib.parse.parse_qs(query) - requested = values.get("lang", [self.default_language])[0] - return normalize_language(requested) - - def html_page_for_language(self, lang, token_param=None): - lang = normalize_language(lang) - token_query = f"&token={urllib.parse.quote(token_param)}" if token_param else "" - rows = [] - for action in self.actions.values(): - snapshot_cell = "-" - if action.cfg.get("snapshot"): - snapshot_url = ( - f"/snapshot/{urllib.parse.quote(action.id)}.jpg" - f"?lang={urllib.parse.quote(lang)}{token_query}" - ) - snapshot_cell = ( - f'' - ) - buttons = [] - for command in action.command_list(): - label = text_for(lang, f"button.{command}") - action_url = ( - f"/action/{urllib.parse.quote(action.id)}/{urllib.parse.quote(command)}" - f"?lang={urllib.parse.quote(lang)}{token_query}" - ) - buttons.append( - f'
' - f'
' - ) - state = html.escape(action.state or "-") - rows.append( - "" - f"{snapshot_cell}" - f"{html.escape(action.id)}" - f"{html.escape(action.display_name(lang))}" - f"{html.escape(action.platform)}" - f"{state}" - f"{''.join(buttons)}" - "" - ) - options = [] - for code, label in available_languages().items(): - selected = " selected" if code == lang else "" - options.append(f'') - return f""" - - - - -{html.escape(text_for(lang, "title"))} - - - -
-

{html.escape(text_for(lang, "title"))}

-
- -{f'' if token_param else ''} - - -
-
- - - -{''.join(rows)} - -
{html.escape(text_for(lang, "snapshot"))}{html.escape(text_for(lang, "id"))}{html.escape(text_for(lang, "name"))}{html.escape(text_for(lang, "type"))}{html.escape(text_for(lang, "state"))}{html.escape(text_for(lang, "actions"))}
- -""" - - def _handle_signal(self, signum, _frame): - LOG.info("received signal %s, stopping", signum) - self.stop_event.set() - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--config", default=DEFAULT_CONFIG) - parser.add_argument("--serve", action="store_true") - parser.add_argument("--list", action="store_true") - parser.add_argument("--check", action="store_true") - parser.add_argument("--publish-discovery", action="store_true") - parser.add_argument("--action", metavar="ID") - parser.add_argument("command", nargs="?") - parser.add_argument("--all-off", action="store_true") - args = parser.parse_args() - - logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") - bridge = CameraControlBridge(load_config_or_env(args.config)) - - if args.list: - bridge.list_actions() - return 0 - if args.check: - return bridge.check() - if args.all_off: - print(json.dumps(bridge.all_off(), indent=2)) - return 0 - if args.action: - if not args.command: - print("missing command", file=sys.stderr) - return 2 - print(json.dumps(bridge.execute_action(args.action, args.command, publish=False), indent=2)) - return 0 - if args.publish_discovery: - for credential in bridge.credentials: - try: - bridge.mqtt.connect(credential.get("username"), credential.get("password")) - bridge.publish_discovery() - bridge.mqtt.disconnect_cleanly() - return 0 - except Exception as exc: - LOG.warning("publish discovery failed using %s: %s", credential["source"], exc) - return 1 - - bridge.serve() - return 0 +from frigate_camera_control_bridge.cli import main if __name__ == "__main__": diff --git a/frigate_camera_control_bridge/__init__.py b/frigate_camera_control_bridge/__init__.py new file mode 100644 index 0000000..901c17c --- /dev/null +++ b/frigate_camera_control_bridge/__init__.py @@ -0,0 +1,5 @@ +"""Frigate camera control bridge package.""" + +from .app import CameraControlBridge, VERSION, load_config_or_env + +__all__ = ["CameraControlBridge", "VERSION", "load_config_or_env"] diff --git a/frigate_camera_control_bridge/__main__.py b/frigate_camera_control_bridge/__main__.py new file mode 100644 index 0000000..faaa63b --- /dev/null +++ b/frigate_camera_control_bridge/__main__.py @@ -0,0 +1,4 @@ +from .cli import main + + +raise SystemExit(main()) diff --git a/frigate_camera_control_bridge/app.py b/frigate_camera_control_bridge/app.py new file mode 100644 index 0000000..1215bc3 --- /dev/null +++ b/frigate_camera_control_bridge/app.py @@ -0,0 +1,1709 @@ +import json +import logging +import os +import queue +import re +import signal +import socket +import struct +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + + +VERSION = "1.0" +DEFAULT_CONFIG = "/etc/frigate-camera-control-bridge.json" +LOG = logging.getLogger("frigate-camera-control-bridge") +STREAM_URL_RE = re.compile(r"(?:rtsp|rtsps|http|https)://[^\s'\"<>]+", re.IGNORECASE) + +from .i18n import DEFAULT_LANGUAGE, normalize_language, text_for +from .ui import read_static_file, render_page + + +def mqtt_encode_string(value): + # Minimal MQTT v3.1.1 encoder. Avoiding a client dependency keeps the image + # small and avoids pip installs on offline homelab hosts. + data = value.encode("utf-8") + return struct.pack("!H", len(data)) + data + + +def mqtt_encode_remaining_length(length): + out = bytearray() + while True: + digit = length % 128 + length //= 128 + if length: + digit |= 0x80 + out.append(digit) + if not length: + return bytes(out) + + +def read_exact(sock, size): + buf = bytearray() + while len(buf) < size: + part = sock.recv(size - len(buf)) + if not part: + raise EOFError("socket closed") + buf.extend(part) + return bytes(buf) + + +def read_mqtt_packet(sock): + first = read_exact(sock, 1)[0] + multiplier = 1 + remaining_length = 0 + while True: + digit = read_exact(sock, 1)[0] + remaining_length += (digit & 127) * multiplier + if not digit & 128: + break + multiplier *= 128 + if multiplier > 128 * 128 * 128: + raise ValueError("malformed MQTT remaining length") + return first, read_exact(sock, remaining_length) + + +def send_mqtt_packet(sock, packet_type_with_flags, payload=b""): + sock.sendall( + bytes([packet_type_with_flags]) + + mqtt_encode_remaining_length(len(payload)) + + payload + ) + + +def load_config(path): + with open(path, "r", encoding="utf-8") as handle: + return json.load(handle) + + +def slug(value): + return "".join(ch if ch.isalnum() else "_" for ch in value.lower()).strip("_") + + +def env_bool(name, default=True): + value = os.environ.get(name) + if value is None: + return default + return value.lower() in ("1", "true", "yes", "on") + + +def env_list(name): + value = os.environ.get(name, "") + return [item.strip() for item in value.split(",") if item.strip()] + + +def env_secret(name): + # Support both classic env vars and *_FILE secret mounts. Kubernetes users + # will usually use env from Secret, while Docker/Podman users may prefer a + # mounted secret file. The direct env var wins when both are present. + value = os.environ.get(name) + if value: + return value + path = os.environ.get(f"{name}_FILE") + if not path: + return None + try: + return Path(path).read_text(encoding="utf-8").strip() + except OSError as exc: + raise RuntimeError(f"failed to read {name}_FILE={path}: {exc}") from exc + + +def config_from_env(): + # Env mode is the preferred sidecar mode. It needs only a Frigate API URL, + # MQTT connection parameters, and camera credentials. The old JSON config is + # still accepted for manual runs and migration debugging. + frigate_name = os.environ.get("FRIGATE_NAME", "frigate") + language = normalize_language(os.environ.get("UI_LANGUAGE", DEFAULT_LANGUAGE)) + frigate_url = os.environ.get("FRIGATE_URL") + if not frigate_url: + frigate_host = os.environ.get("FRIGATE_HOST") + if not frigate_host: + raise RuntimeError("FRIGATE_URL or FRIGATE_HOST is required in env mode") + frigate_url = f"http://{frigate_host}:5000" + + mqtt_host = os.environ.get("MQTT_HOST") + if not mqtt_host: + raise RuntimeError("MQTT_HOST is required in env mode") + mqtt_user = env_secret("MQTT_USER") + mqtt_password = env_secret("MQTT_PASSWORD") + if not mqtt_user: + raise RuntimeError("MQTT_USER is required in env mode") + if not mqtt_password: + raise RuntimeError("MQTT_PASSWORD is required in env mode") + camera_user = env_secret("DEFAULT_CAMERA_USER") + camera_password = env_secret("DEFAULT_CAMERA_PASSWORD") + if not camera_user: + raise RuntimeError("DEFAULT_CAMERA_USER is required in env mode") + if not camera_password: + raise RuntimeError("DEFAULT_CAMERA_PASSWORD is required in env mode") + + mqtt = { + "host": mqtt_host, + "port": int(os.environ.get("MQTT_PORT", "1883")), + "base_topic": os.environ.get("BASE_TOPIC", f"frigate_camera_control/{slug(frigate_name)}"), + "discovery_prefix": os.environ.get("DISCOVERY_PREFIX", "homeassistant"), + "client_id": os.environ.get("MQTT_CLIENT_ID", f"frigate-camera-control-{slug(frigate_name)}"), + "username": mqtt_user, + "password": mqtt_password, + "keepalive": int(os.environ.get("MQTT_KEEPALIVE", "60")), + } + + camera_auth = { + "username": camera_user, + "password": camera_password, + } + + return { + "mqtt": mqtt, + "camera_auth": camera_auth, + "discovery": { + "enabled": True, + "state_file": os.environ.get( + "DISCOVERY_STATE_FILE", + "/var/lib/frigate-camera-control-bridge/discovery_state.json", + ), + "include_hikvision_colorvu_white": env_bool("INCLUDE_HIKVISION_COLORVU_WHITE", True), + "include_dahua_coaxial_off_probe": env_bool("INCLUDE_DAHUA_COAXIAL_OFF_PROBE", True), + "wait_for_frigate": env_bool("WAIT_FOR_FRIGATE", True), + "retry_seconds": float(os.environ.get("FRIGATE_RETRY_SECONDS", "5")), + "startup_wait_seconds": float(os.environ.get("FRIGATE_STARTUP_WAIT_SECONDS", "0")), + "dahua_probe_channels": [int(item) for item in env_list("DAHUA_PROBE_CHANNELS") or ["1", "0", "2"]], + "dahua_siren_probe_channels": [ + int(item) for item in env_list("DAHUA_SIREN_PROBE_CHANNELS") or ["2", "1", "0"] + ], + "frigates": [ + { + "name": frigate_name, + "host": os.environ.get("FRIGATE_HOST", urllib.parse.urlsplit(frigate_url).hostname or frigate_name), + "url": frigate_url, + } + ], + }, + "http": { + "enabled": env_bool("HTTP_ENABLED", True), + "bind": os.environ.get("HTTP_BIND", "0.0.0.0"), + "port": int(os.environ.get("HTTP_PORT", "5011")), + "token": env_secret("HTTP_TOKEN"), + }, + "ui": { + "default_language": language, + }, + "defaults": { + "http_timeout": float(os.environ.get("CAMERA_HTTP_TIMEOUT", "6")), + "colorvu_brightness": int(os.environ.get("COLORVU_BRIGHTNESS", "100")), + "snapshot_enabled": env_bool("SNAPSHOT_ENABLED", True), + "snapshot_height": int(os.environ.get("SNAPSHOT_HEIGHT", "180")), + "snapshot_cache_seconds": int(os.environ.get("SNAPSHOT_CACHE_SECONDS", "300")), + "snapshot_retry_seconds": int(os.environ.get("SNAPSHOT_RETRY_SECONDS", "600")), + "snapshot_refresh_seconds": int(os.environ.get("SNAPSHOT_REFRESH_SECONDS", "3600")), + "snapshot_live_on_gui": env_bool("SNAPSHOT_LIVE_ON_GUI", True), + "frigate_refresh_seconds": int(os.environ.get("FRIGATE_REFRESH_SECONDS", "300")), + "auto_off_siren_seconds": int(os.environ.get("AUTO_OFF_SIREN_SECONDS", "180")), + "auto_off_red_blue_seconds": int(os.environ.get("AUTO_OFF_RED_BLUE_SECONDS", "300")), + "auto_off_white_light_seconds": int(os.environ.get("AUTO_OFF_WHITE_LIGHT_SECONDS", "600")), + "auto_off_default_seconds": int(os.environ.get("AUTO_OFF_DEFAULT_SECONDS", "0")), + "startup_all_off": env_bool("STARTUP_ALL_OFF", True), + }, + "actions": [ + { + "id": f"{slug(frigate_name)}_all_off", + "name": text_for(language, "action.all_off", target=frigate_name), + "name_key": "action.all_off", + "frigate_name": frigate_name, + "platform": "button", + "kind": "all_off", + "device": { + "identifiers": [f"frigate_camera_control_{slug(frigate_name)}"], + "name": f"Frigate camera control {frigate_name}", + "manufacturer": "Local", + "model": "Sidecar", + }, + } + ], + } + + +def load_config_or_env(path): + if path and Path(path).exists(): + return load_config(path) + return config_from_env() + + +def response_status_ok(body): + text = body.decode("utf-8", errors="replace") + if "Error" in text and "ResponseStatus" not in text: + return False, text.strip()[:180] + if "1" in text or "0" in text: + return True, text.strip()[:180] + if "OK" in text and "Error" not in text: + return True, text.strip()[:180] + if not text.strip(): + return True, "" + return True, text.strip()[:180] + + +class DigestHttpClient: + # Camera APIs in this installation use HTTP Digest. The client also allows + # Basic Auth because some camera firmwares accept either scheme. + def __init__(self, username, password, timeout=6): + self.username = username + self.password = password + self.timeout = timeout + + def _opener(self, base_url): + mgr = urllib.request.HTTPPasswordMgrWithDefaultRealm() + mgr.add_password(None, base_url, self.username, self.password) + return urllib.request.build_opener( + urllib.request.HTTPDigestAuthHandler(mgr), + urllib.request.HTTPBasicAuthHandler(mgr), + ) + + def request(self, url, method="GET", body=None, content_type=None): + headers = {} + data = None + if body is not None: + data = body.encode("utf-8") + headers["Content-Type"] = content_type or "application/xml" + parsed = urllib.parse.urlsplit(url) + base_url = f"{parsed.scheme}://{parsed.netloc}/" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with self._opener(base_url).open(req, timeout=self.timeout) as response: + payload = response.read() + ok, detail = response_status_ok(payload) + if not ok: + raise RuntimeError(detail) + return response.status, payload.decode("utf-8", errors="replace") + except urllib.error.HTTPError as exc: + payload = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"HTTP {exc.code}: {payload[:180]}") from exc + except urllib.error.URLError as exc: + raise RuntimeError(str(exc.reason)) from exc + + +class Action: + # Action is the stable internal contract used by MQTT, HTTP GUI, CLI, and + # Home Assistant discovery. Camera-specific code is kept behind kind-specific + # runners so new vendors can be added without touching the MQTT layer. + def __init__(self, cfg, http_client): + self.cfg = cfg + self.id = cfg["id"] + self.name = cfg.get("name", self.id) + self.platform = cfg.get("platform", "switch") + self.kind = cfg["kind"] + self.http = http_client + self.state = None + self.auto_off_seconds = int(cfg.get("auto_off_seconds") or 0) + self.auto_off_at = None + + def display_name(self, lang): + names = self.cfg.get("name_i18n") or {} + normalized = normalize_language(lang) + if names: + return names.get(normalized) or names.get(DEFAULT_LANGUAGE) or self.name + + key = self.cfg.get("name_key") + if not key: + return self.name + + prefix = self.cfg.get("camera_name") or self.cfg.get("frigate_name") or "" + return text_for(normalized, key, target=prefix).strip() + + def command_list(self): + if self.kind == "all_off": + return ["press"] + if self.platform == "button": + return ["press"] + return ["on", "off"] + + def safe_off_command(self): + if self.kind == "all_off": + return None + if "safe_off" in self.cfg: + return "safe_off" + if self.platform == "switch" and "off" in self.cfg: + return "off" + return None + + def run(self, command, bridge=None): + command = command.lower() + if self.kind == "all_off": + if bridge is None: + raise RuntimeError("all_off requires bridge context") + return bridge.all_off() + if self.kind == "hikvision_colorvu": + self._run_hikvision(command) + elif self.kind == "dahua_coaxial_io": + self._run_dahua_coax(command) + else: + raise RuntimeError(f"unsupported action kind {self.kind}") + + if self.platform == "switch": + if command == "on": + self.state = "ON" + elif command in ("off", "safe_off"): + self.state = "OFF" + return {"ok": True, "action": self.id, "command": command} + + def _run_hikvision(self, command): + if command == "on": + self._hikvision_put_supplement(colorvu_xml(self.cfg.get("brightness", 100))) + self._hikvision_put_ircut(ircut_xml("night")) + return + if command in ("off", "safe_off"): + restore = self.cfg.get("restore", {}) + self._hikvision_put_ircut(ircut_xml(restore.get("ircutFilterType", "auto"))) + self._hikvision_put_supplement(restore_xml(self.cfg.get("brightness", 100), restore)) + return + raise RuntimeError(f"unsupported command for {self.id}: {command}") + + def _hikvision_put_supplement(self, xml): + host = self.cfg["host"] + self.http.request( + f"http://{host}/ISAPI/Image/channels/1/supplementLight", + method="PUT", + body=xml, + ) + + def _hikvision_put_ircut(self, xml): + host = self.cfg["host"] + self.http.request( + f"http://{host}/ISAPI/Image/channels/1/ircutFilter", + method="PUT", + body=xml, + ) + + def _run_dahua_coax(self, command): + params = self.cfg.get(command) + if params is None and command == "safe_off": + params = self.cfg.get("off") or self.cfg.get("press") + if params is None: + raise RuntimeError(f"unsupported command for {self.id}: {command}") + host = self.cfg["host"] + query = urllib.parse.urlencode( + { + "action": "control", + "channel": int(params["channel"]), + "info[0].Type": int(params["type"]), + "info[0].IO": int(params["io"]), + }, + safe="[]", + ) + self.http.request(f"http://{host}/cgi-bin/coaxialControlIO.cgi?{query}") + + +def colorvu_xml(brightness): + # Hikvision ColorVu white light is controlled through supplementLight plus + # ircutFilter. Turning it off restores the mode captured during discovery. + return f""" + +colorVuWhiteLight +manual +{int(brightness)} +100 + +manual +{int(brightness)} +100 + +""" + + +def restore_xml(brightness, restore): + associated = restore.get("associatedVMDHuman") + associated_xml = "" + if associated is not None: + associated_xml = f"\n{str(bool(associated)).lower()}" + return f""" + +{restore.get("supplementLightMode", "eventIntelligence")} +{restore.get("mixedLightBrightnessRegulatMode", "auto")} +{int(restore.get("whiteLightBrightness", brightness))} +{int(restore.get("irLightBrightness", 100))} + +{restore.get("eventIntelligenceBrightnessRegulatMode", "auto")} +{int(restore.get("whiteLightBrightness", brightness))} +{int(restore.get("irLightBrightness", 100))}{associated_xml} + +""" + + +def ircut_xml(mode): + return f""" + +{mode} +4 +5 +""" + + +class MqttBridgeClient: + # This is a deliberately small MQTT 3.1.1 client. It supports only what the + # bridge needs: connect, subscribe to command topics, publish retained HA + # discovery/state messages, and Last Will availability. + def __init__(self, cfg, messages): + self.cfg = cfg + self.messages = messages + self.sock = None + self.lock = threading.Lock() + self.packet_id = 1 + + @property + def base_topic(self): + return self.cfg.get("base_topic", "frigate_camera_control") + + @property + def availability_topic(self): + return f"{self.base_topic}/status" + + def next_packet_id(self): + self.packet_id += 1 + if self.packet_id > 65535: + self.packet_id = 1 + return self.packet_id + + def connect(self, username=None, password=None): + host = self.cfg["host"] + port = int(self.cfg.get("port", 1883)) + keepalive = int(self.cfg.get("keepalive", 60)) + client_id = self.cfg.get("client_id", "frigate-camera-control-bridge") + + sock = socket.create_connection((host, port), timeout=10) + sock.settimeout(1) + + flags = 0x02 + payload = mqtt_encode_string(client_id) + + flags |= 0x04 | 0x20 + payload += mqtt_encode_string(self.availability_topic) + payload += mqtt_encode_string("offline") + + if username is not None: + flags |= 0x80 + if password is not None: + flags |= 0x40 + + if username is not None: + payload += mqtt_encode_string(username) + if password is not None: + payload += mqtt_encode_string(password) + + variable_header = mqtt_encode_string("MQTT") + bytes([4, flags]) + struct.pack("!H", keepalive) + send_mqtt_packet(sock, 0x10, variable_header + payload) + packet_type, body = read_mqtt_packet(sock) + if packet_type >> 4 != 2 or len(body) != 2 or body[1] != 0: + code = body[1] if len(body) >= 2 else "unknown" + raise RuntimeError(f"MQTT CONNACK failed: {code}") + + discovery_prefix = self.cfg.get("discovery_prefix", "homeassistant") + topics = [f"{self.base_topic}/+/set", f"{discovery_prefix}/status"] + packet_id = self.next_packet_id() + subscribe_payload = struct.pack("!H", packet_id) + for topic in topics: + subscribe_payload += mqtt_encode_string(topic) + b"\x00" + send_mqtt_packet(sock, 0x82, subscribe_payload) + + while True: + packet_type, body = read_mqtt_packet(sock) + if packet_type >> 4 != 9: + continue + if len(body) < 2 or struct.unpack("!H", body[:2])[0] != packet_id: + continue + if any(code == 0x80 for code in body[2:]): + raise RuntimeError(f"MQTT subscribe rejected for {topics}") + break + + with self.lock: + self.sock = sock + return sock + + def publish(self, topic, payload, retain=False): + if not isinstance(payload, bytes): + payload = str(payload).encode("utf-8") + packet = mqtt_encode_string(topic) + payload + with self.lock: + if self.sock is None: + raise RuntimeError("MQTT is not connected") + send_mqtt_packet(self.sock, 0x31 if retain else 0x30, packet) + + def close(self): + with self.lock: + sock = self.sock + self.sock = None + if sock is not None: + try: + sock.close() + except OSError: + pass + + def serve(self, credentials, on_connect, stop_event): + backoff = 1 + credential_index = 0 + keepalive = int(self.cfg.get("keepalive", 60)) + + while not stop_event.is_set(): + credential = credentials[credential_index % len(credentials)] + credential_index += 1 + sock = None + try: + sock = self.connect(credential.get("username"), credential.get("password")) + LOG.info("MQTT connected to %s:%s", self.cfg["host"], self.cfg.get("port", 1883)) + on_connect() + backoff = 1 + last_io = time.monotonic() + + while not stop_event.is_set(): + try: + first, body = read_mqtt_packet(sock) + except socket.timeout: + if time.monotonic() - last_io > max(10, keepalive / 2): + self.publish_ping() + last_io = time.monotonic() + continue + + last_io = time.monotonic() + packet_type = first >> 4 + flags = first & 0x0F + if packet_type == 3: + message = self.parse_publish(flags, body) + if message: + self.messages.put(message) + elif packet_type == 13: + continue + elif packet_type == 14: + raise EOFError("broker disconnected") + except Exception as exc: + if not stop_event.is_set(): + LOG.warning("MQTT failed: %s", exc) + stop_event.wait(backoff) + backoff = min(backoff * 2, 60) + finally: + self.close() + if sock is not None: + try: + sock.close() + except OSError: + pass + + def publish_ping(self): + with self.lock: + if self.sock is not None: + send_mqtt_packet(self.sock, 0xC0) + + def disconnect_cleanly(self): + try: + self.publish(self.availability_topic, "offline", retain=True) + except Exception: + pass + with self.lock: + if self.sock is not None: + try: + send_mqtt_packet(self.sock, 0xE0) + except OSError: + pass + + @staticmethod + def parse_publish(flags, body): + if len(body) < 2: + return None + topic_len = struct.unpack("!H", body[:2])[0] + if len(body) < 2 + topic_len: + return None + topic = body[2 : 2 + topic_len].decode("utf-8", errors="replace") + index = 2 + topic_len + qos = (flags >> 1) & 0x03 + packet_id = None + if qos: + packet_id = struct.unpack("!H", body[index : index + 2])[0] + index += 2 + payload = body[index:].decode("utf-8", errors="replace").strip() + return topic, payload, packet_id + + +class CameraControlBridge: + # CameraControlBridge wires together four boundaries: + # - Frigate API discovery, + # - camera vendor HTTP commands, + # - MQTT/Home Assistant discovery, + # - manual HTTP fallback GUI. + def __init__(self, config): + self.config = config + self.stop_event = threading.Event() + self.messages = queue.Queue() + self.action_lock = threading.RLock() + self.auto_off_timers = {} + self.credentials = self.load_mqtt_credentials() + camera_password = self.load_camera_password() + camera_user = config.get("camera_auth", {}).get("username", "admin") + self.default_language = normalize_language( + config.get("ui", {}).get("default_language", DEFAULT_LANGUAGE) + ) + self.snapshot_cache = {} + self.http_client = DigestHttpClient( + camera_user, + camera_password, + timeout=float(config.get("defaults", {}).get("http_timeout", 6)), + ) + self.inventory = [] + self.actions = self.build_actions() + self.initialize_action_states() + self.refresh_snapshots() + self.mqtt = MqttBridgeClient(config["mqtt"], self.messages) + self.http_server = None + + @property + def base_topic(self): + return self.config["mqtt"].get("base_topic", "frigate_camera_control") + + def actions_snapshot(self): + with self.action_lock: + return list(self.actions.values()) + + def action_count(self): + with self.action_lock: + return len(self.actions) + + def action_by_id(self, action_id): + with self.action_lock: + return self.actions.get(action_id) + + def inherit_action_runtime_state(self, old_actions, new_actions): + for action_id, action in new_actions.items(): + old = old_actions.get(action_id) + if old is None: + if action.platform == "switch": + action.state = "OFF" + continue + action.state = old.state + action.auto_off_at = old.auto_off_at + + def initialize_action_states(self): + for action in self.actions.values(): + if action.platform == "switch" and action.state is None: + action.state = "OFF" + + def load_mqtt_credentials(self): + mqtt_cfg = self.config["mqtt"] + username = mqtt_cfg.get("username") + password = mqtt_cfg.get("password") + if not username: + raise RuntimeError("MQTT username is required") + if not password: + raise RuntimeError("MQTT password is required") + return [{"username": username, "password": password, "source": "environment"}] + + def load_camera_password(self): + auth = self.config.get("camera_auth", {}) + if auth.get("password"): + return auth["password"] + raise RuntimeError("default camera password is required") + + def build_actions(self): + # Keep this bridge self-maintaining: every service start reads Frigate runtime + # configs, derives camera hosts, creates safe HA MQTT entities, and later + # removes stale discovery entities for cameras that disappeared. + self.inventory = self.discover_inventory() + present_hosts = {item["host"] for item in self.inventory if item.get("host")} + action_cfgs = [] + + for action_cfg in self.config.get("actions", []): + required_host = action_cfg.get("required_host") + if required_host and required_host not in present_hosts: + LOG.info("skip static action %s, host %s not in Frigate inventory", action_cfg["id"], required_host) + continue + action_cfgs.append(action_cfg) + + action_cfgs.extend(self.discover_action_configs()) + actions = {} + for action_cfg in action_cfgs: + cfg = dict(action_cfg) + auto_off_seconds = self.auto_off_seconds_for_config(cfg) + if auto_off_seconds > 0 and cfg.get("platform") == "switch": + cfg["auto_off_seconds"] = auto_off_seconds + if cfg["id"] in actions: + LOG.warning("duplicate action id skipped: %s", cfg["id"]) + continue + actions[cfg["id"]] = Action(cfg, self.http_client) + return actions + + def auto_off_seconds_for_config(self, cfg): + if "auto_off_seconds" in cfg: + return int(cfg.get("auto_off_seconds") or 0) + defaults = self.config.get("defaults", {}) + name_key = cfg.get("name_key") + if name_key == "action.siren": + return int(defaults.get("auto_off_siren_seconds", 180)) + if name_key == "action.red_blue": + return int(defaults.get("auto_off_red_blue_seconds", 300)) + if name_key == "action.manual_white": + return int(defaults.get("auto_off_white_light_seconds", 600)) + return int(defaults.get("auto_off_default_seconds", 0)) + + def discover_inventory(self): + # Frigate may reference cameras either through direct ffmpeg URLs or + # through local go2rtc gateway URLs. The selected source URLs are then + # used to discover the camera API host without hardcoded addresses. + discovery = self.config.get("discovery", {}) + if not discovery.get("enabled", False): + return [] + + inventory = [] + for frigate in discovery.get("frigates", []): + name = frigate.get("name") or frigate["host"] + base_url = frigate.get("url") or f"http://{frigate['host']}:5000" + data = self.load_frigate_config_with_retry(frigate, base_url) + if data is None: + continue + + go2rtc = data.get("go2rtc") or {} + streams = go2rtc.get("streams") or go2rtc + cameras = data.get("cameras") or {} + for camera_name, camera_cfg in cameras.items(): + urls = self.camera_candidate_urls(camera_name, camera_cfg, streams) + endpoints = self.camera_endpoints_from_urls(urls) + kinds = self.kinds_from_urls(urls) + for host in endpoints: + inventory.append( + { + "frigate": name, + "frigate_url": base_url, + "camera": camera_name, + "host": host, + "kinds": kinds, + } + ) + LOG.info("discovered %d camera host mapping(s) from Frigate", len(inventory)) + return inventory + + def load_frigate_config_with_retry(self, frigate, base_url): + discovery = self.config.get("discovery", {}) + wait = bool(discovery.get("wait_for_frigate", True)) + retry_seconds = float(discovery.get("retry_seconds", 5)) + max_wait = float(discovery.get("startup_wait_seconds", 0)) + started = time.monotonic() + source = frigate.get("config_file") or f"{base_url.rstrip('/')}/api/config" + + while True: + try: + if frigate.get("config_file"): + return self.fetch_frigate_config_file(frigate["config_file"]) + return self.fetch_json(f"{base_url.rstrip('/')}/api/config") + except Exception as exc: + if not wait: + LOG.warning("Frigate discovery failed for %s: %s", source, exc) + return None + elapsed = time.monotonic() - started + if max_wait and elapsed >= max_wait: + LOG.warning("Frigate discovery timed out for %s after %.0fs: %s", source, elapsed, exc) + return None + LOG.info("waiting for Frigate API %s: %s", source, exc) + time.sleep(retry_seconds) + + @staticmethod + def fetch_json(url): + with urllib.request.urlopen(url, timeout=8) as response: + return json.loads(response.read().decode("utf-8", errors="replace")) + + @staticmethod + def fetch_frigate_config_file(path): + try: + import yaml + except ImportError as exc: + raise RuntimeError("PyYAML is required for config_file discovery") from exc + with open(path, "r", encoding="utf-8") as handle: + return yaml.safe_load(handle) or {} + + @staticmethod + def as_list(value): + if value is None: + return [] + if isinstance(value, list): + return value + return [value] + + @staticmethod + def camera_candidate_urls(camera_name, camera_cfg, streams): + # Discovery supports two Frigate layouts: + # 1. Direct camera URL in ffmpeg, e.g. + # rtsp://camera-a.example.test/... + # If such URL exists, it is the source of truth. + # 2. go2rtc gateway path in ffmpeg, e.g. rtsp://127.0.0.1:8554/stream-a. + # In this case the loopback URL is only a pointer; the real camera + # endpoint must be resolved from go2rtc.streams["stream-a"]. + urls = CameraControlBridge.camera_direct_urls(camera_cfg) + if urls: + return urls + for stream_name in CameraControlBridge.camera_go2rtc_stream_names(camera_cfg): + resolved = CameraControlBridge.resolve_go2rtc_stream_urls(streams, stream_name) + if not resolved: + LOG.info("go2rtc stream %s/%s not found in Frigate config", camera_name, stream_name) + urls.extend(resolved) + return urls + + @staticmethod + def camera_go2rtc_stream_names(camera_cfg): + # go2rtc-backed Frigate inputs usually look like: + # rtsp://127.0.0.1:8554/. Loopback here is not a camera API + # host; it is the go2rtc gateway. We keep only the stream name and + # resolve it through go2rtc.streams. + names = [] + for item in camera_cfg.get("ffmpeg", {}).get("inputs", []): + path = str(item.get("path", "")) + stream_name = CameraControlBridge.go2rtc_stream_name_from_url(path) + if stream_name: + names.append(stream_name) + return list(dict.fromkeys(names)) + + @staticmethod + def camera_direct_urls(camera_cfg): + # Some installations skip go2rtc and put camera RTSP/HTTP URLs directly + # in ffmpeg inputs. Those URLs are inspected as-is. go2rtc loopback URLs + # are intentionally excluded here because they must be resolved through + # go2rtc.streams first. + urls = [] + for item in camera_cfg.get("ffmpeg", {}).get("inputs", []): + path = str(item.get("path", "")) + if path and not CameraControlBridge.go2rtc_stream_name_from_url(path): + urls.extend(CameraControlBridge.stream_source_urls(path)) + return urls + + @staticmethod + def go2rtc_stream_name_from_url(url): + # go2rtc and Frigate often wrap real stream URLs, for example: + # ffmpeg:rtsp://127.0.0.1:8554/front-door#video=copy. Normalize that + # first so loopback gateway detection works for wrapped and raw values. + for candidate in CameraControlBridge.stream_source_urls(url) or [str(url)]: + try: + parsed = urllib.parse.urlsplit(candidate) + except ValueError: + continue + host = parsed.hostname or "" + if parsed.scheme != "rtsp" or parsed.port != 8554: + continue + if host not in ("127.0.0.1", "localhost", "::1"): + continue + return parsed.path.lstrip("/").split("/", 1)[0] or None + return None + + @staticmethod + def stream_source_urls(value): + # go2rtc supports source wrappers such as "ffmpeg:rtsp://..." and may + # append options after a URL fragment. Extract URL tokens instead of + # treating the wrapper prefix as the network scheme. + text = str(value or "") + urls = STREAM_URL_RE.findall(text) + if urls: + return urls + if "://" in text: + return [text] + return [] + + @staticmethod + def resolve_go2rtc_stream_urls(streams, stream_name): + # go2rtc stream values may be a string, a list of strings, or a small + # mapping depending on how the Frigate config was authored. Flatten only + # the values that can contain source URLs; non-URL helper values are + # harmless because host/vendor extraction is conservative. + value = streams.get(stream_name) if isinstance(streams, dict) else None + return CameraControlBridge.flatten_go2rtc_value(value) + + @staticmethod + def flatten_go2rtc_value(value): + if value is None: + return [] + if isinstance(value, str): + urls = CameraControlBridge.stream_source_urls(value) + return urls or [value] + if isinstance(value, list): + out = [] + for item in value: + out.extend(CameraControlBridge.flatten_go2rtc_value(item)) + return out + if isinstance(value, dict): + out = [] + for key in ("streams", "url", "urls", "source", "sources"): + if key in value: + out.extend(CameraControlBridge.flatten_go2rtc_value(value[key])) + return out + return [str(value)] + + @staticmethod + def hosts_from_urls(urls): + return CameraControlBridge.camera_endpoints_from_urls(urls) + + @staticmethod + def camera_endpoints_from_urls(urls): + # Extract the camera API endpoint from URLs without hardcoding camera IPs. + # IP literals and DNS names are both valid. Loopback addresses should + # already have been resolved through go2rtc.streams, so skip them if a + # config still leaks one through. + endpoints = [] + for url in urls: + try: + parsed = urllib.parse.urlsplit(str(url)) + except ValueError: + continue + host = parsed.hostname + if not host: + continue + normalized = host.strip("[]").lower() + if ( + normalized in ("localhost", "::1", "0.0.0.0", "255.255.255.255") + or normalized.startswith("127.") + ): + continue + endpoints.append(normalized) + return list(dict.fromkeys(endpoints)) + + @staticmethod + def kinds_from_urls(urls): + # Vendor detection is based on URL shape. This decides which safe + # capability probes may be attempted for each discovered camera. + kinds = set() + for url in urls: + if "Streaming/Channels" in url: + kinds.add("hikvision") + elif "cam/realmonitor" in url: + kinds.add("dahua") + elif "/stream1" in url or "/stream2" in url: + kinds.add("tapo") + elif "camera/stream" in url: + kinds.add("tablet") + else: + kinds.add("other") + return sorted(kinds) + + def discover_action_configs(self): + discovery = self.config.get("discovery", {}) + if not discovery.get("enabled", False): + return [] + actions = [] + for item in self.primary_inventory_items(): + host = item["host"] + if discovery.get("include_hikvision_colorvu_white", True): + action = self.discover_hikvision_colorvu_action(item) + if action: + actions.append(action) + if discovery.get("include_dahua_coaxial_off_probe", True): + actions.extend(self.discover_dahua_coaxial_actions(item)) + LOG.info("auto-created %d action(s) from camera capabilities", len(actions)) + return actions + + def primary_inventory_items(self): + grouped = {} + for item in self.inventory: + host = item["host"] + current = grouped.get(host) + if current is None: + grouped[host] = item + continue + if "ptz" in item["camera"].lower() and "ptz" not in current["camera"].lower(): + grouped[host] = item + return list(grouped.values()) + + def discover_hikvision_colorvu_action(self, item): + if "hikvision" not in item.get("kinds", []): + return None + host = item["host"] + try: + _, device_info = self.http_client.request(f"http://{host}/ISAPI/System/deviceInfo") + model = self.xml_text(device_info, "model") or "Hikvision" + _, caps = self.http_client.request(f"http://{host}/ISAPI/Image/channels/1/supplementLight/capabilities") + modes = self.xml_attr(caps, "supplementLightMode", "opt") or "" + if "colorVuWhiteLight" not in modes.split(","): + return None + _, current = self.http_client.request(f"http://{host}/ISAPI/Image/channels/1/supplementLight") + except Exception as exc: + LOG.info("skip Hikvision discovery %s/%s: %s", item["camera"], host, exc) + return None + + restore = { + "supplementLightMode": self.xml_text(current, "supplementLightMode") or "eventIntelligence", + "mixedLightBrightnessRegulatMode": self.xml_text(current, "mixedLightBrightnessRegulatMode") or "auto", + "eventIntelligenceBrightnessRegulatMode": self.xml_text(current, "brightnessRegulatMode") or "auto", + "whiteLightBrightness": int(self.xml_text(current, "whiteLightBrightness") or 100), + "irLightBrightness": int(self.xml_text(current, "irLightBrightness") or 100), + "ircutFilterType": "auto", + } + associated = self.xml_text(current, "associatedVMDHuman") + if associated is not None: + restore["associatedVMDHuman"] = associated.lower() == "true" + + action_id = f"{slug(item['frigate'])}_{slug(item['camera'])}_colorvu_white" + lang = self.default_language + return { + "id": action_id, + "name": text_for(lang, "action.manual_white", target=item["camera"]), + "name_key": "action.manual_white", + "camera_name": item["camera"], + "platform": "switch", + "kind": "hikvision_colorvu", + "host": host, + "brightness": int(self.config.get("defaults", {}).get("colorvu_brightness", 100)), + "restore": restore, + "snapshot": self.snapshot_config(item), + "device": { + "identifiers": [f"camera_{host.replace('.', '_')}"], + "name": item["camera"], + "manufacturer": "Hikvision", + "model": model, + "configuration_url": item["frigate_url"], + }, + } + + def discover_dahua_coaxial_actions(self, item): + # Dahua red-blue lights and sirens are discovered with OFF-only probes. + # A successful OFF endpoint means the command exists without starting + # the alarm/light during bridge startup. + if "dahua" not in item.get("kinds", []): + return [] + host = item["host"] + try: + _, info = self.http_client.request(f"http://{host}/cgi-bin/magicBox.cgi?action=getSystemInfo") + except Exception as exc: + LOG.info("skip Dahua discovery %s/%s: %s", item["camera"], host, exc) + return [] + + model = self.dahua_value(info, "deviceType") or "Dahua" + actions = [] + red_blue_channel = self.dahua_off_probe(host, action_type=1) + lang = self.default_language + if red_blue_channel is not None: + actions.append( + { + "id": f"{slug(item['frigate'])}_{slug(item['camera'])}_red_blue", + "name": text_for(lang, "action.red_blue", target=item["camera"]), + "name_key": "action.red_blue", + "camera_name": item["camera"], + "platform": "switch", + "kind": "dahua_coaxial_io", + "host": host, + "on": {"channel": red_blue_channel, "type": 1, "io": 1}, + "off": {"channel": red_blue_channel, "type": 1, "io": 2}, + "snapshot": self.snapshot_config(item), + "device": self.discovered_device(item, "Dahua", model), + } + ) + + siren_channel = self.dahua_off_probe(host, action_type=2) + if siren_channel is not None: + actions.append( + { + "id": f"{slug(item['frigate'])}_{slug(item['camera'])}_siren", + "name": text_for(lang, "action.siren", target=item["camera"]), + "name_key": "action.siren", + "camera_name": item["camera"], + "platform": "switch", + "kind": "dahua_coaxial_io", + "host": host, + "on": {"channel": siren_channel, "type": 2, "io": 1}, + "off": {"channel": siren_channel, "type": 2, "io": 2}, + "safe_off": {"channel": siren_channel, "type": 2, "io": 2}, + "requires_ack": True, + "snapshot": self.snapshot_config(item), + "device": self.discovered_device(item, "Dahua", model), + } + ) + return actions + + def dahua_off_probe(self, host, action_type): + channels = self.config.get("discovery", {}).get("dahua_probe_channels", [1, 2, 0]) + if action_type == 2: + channels = self.config.get("discovery", {}).get("dahua_siren_probe_channels", [2, 1, 0]) + for channel in channels: + query = urllib.parse.urlencode( + { + "action": "control", + "channel": int(channel), + "info[0].Type": int(action_type), + "info[0].IO": 2, + }, + safe="[]", + ) + try: + self.http_client.request(f"http://{host}/cgi-bin/coaxialControlIO.cgi?{query}") + LOG.info("Dahua OFF probe OK host=%s type=%s channel=%s", host, action_type, channel) + return int(channel) + except Exception: + continue + return None + + @staticmethod + def dahua_value(text, key): + match = re.search(rf"^{re.escape(key)}=(.+)$", text, re.MULTILINE) + return match.group(1).strip().strip("\r") if match else None + + @staticmethod + def discovered_device(item, manufacturer, model): + return { + "identifiers": [f"camera_{item['host'].replace('.', '_')}"], + "name": item["camera"], + "manufacturer": manufacturer, + "model": model, + "configuration_url": item["frigate_url"], + } + + @staticmethod + def snapshot_config(item): + return { + "frigate_url": item["frigate_url"], + "camera": item["camera"], + } + + @staticmethod + def xml_text(xml, tag): + match = re.search(rf"<(?:[^:>]+:)?{re.escape(tag)}[^>]*>([^<]+)]+:)?{re.escape(tag)}>", xml) + return match.group(1).strip() if match else None + + @staticmethod + def xml_attr(xml, tag, attr): + match = re.search(rf"<(?:[^:>]+:)?{re.escape(tag)}[^>]*\s{re.escape(attr)}=\"([^\"]*)\"", xml) + return match.group(1).strip() if match else None + + def list_actions(self): + for action in self.actions_snapshot(): + commands = ",".join(action.command_list()) + print(f"{action.id}\t{action.platform}\t{commands}\t{action.display_name(self.default_language)}") + + def check(self): + ok = True + for credential in self.credentials: + try: + test = MqttBridgeClient(self.config["mqtt"], queue.Queue()) + sock = test.connect(credential.get("username"), credential.get("password")) + sock.close() + print(f"MQTT OK: {self.config['mqtt']['host']} as {credential['source']}") + break + except Exception as exc: + ok = False + print(f"MQTT ERROR using {credential['source']}: {exc}") + for action in self.actions_snapshot(): + if action.kind == "hikvision_colorvu": + print(f"CHECK: {action.id} Hikvision host={action.cfg['host']} configured") + elif action.kind == "dahua_coaxial_io": + print(f"CHECK: {action.id} Dahua host={action.cfg['host']} configured") + elif action.kind == "all_off": + print(f"CHECK: {action.id} all-off button configured") + return 0 if ok else 1 + + def refresh_snapshots(self): + # Snapshot refresh is best-effort. It improves the manual GUI but should + # never prevent MQTT controls from starting. + if not self.config.get("defaults", {}).get("snapshot_enabled", True): + return True + seen = set() + ok = True + for action in self.actions_snapshot(): + url = self.snapshot_url(action) + if not url or url in seen: + continue + seen.add(url) + try: + self.fetch_snapshot(url, force=True) + except Exception as exc: + ok = False + LOG.info("snapshot unavailable for %s: %s", action.id, exc) + return ok + + def snapshot_url(self, action): + snapshot = action.cfg.get("snapshot") + if not snapshot: + return None + camera = urllib.parse.quote(snapshot["camera"], safe="") + height = int(self.config.get("defaults", {}).get("snapshot_height", 180)) + return f"{snapshot['frigate_url'].rstrip('/')}/api/{camera}/latest.jpg?h={height}" + + def fetch_snapshot(self, url, force=False): + now = time.monotonic() + max_age = int(self.config.get("defaults", {}).get("snapshot_cache_seconds", 300)) + cached = self.snapshot_cache.get(url) + if cached and not force and now - cached["time"] < max_age: + return cached + with urllib.request.urlopen(url, timeout=8) as response: + data = response.read() + content_type = response.headers.get("Content-Type", "image/jpeg").split(";", 1)[0] + if not data: + raise RuntimeError("empty snapshot") + cached = {"data": data, "content_type": content_type, "time": now} + self.snapshot_cache[url] = cached + return cached + + def snapshot_for_action(self, action_id, live=False): + action = self.action_by_id(action_id) + if not action: + return None + url = self.snapshot_url(action) + if not url: + return None + try: + return self.fetch_snapshot(url, force=live) + except Exception: + cached = self.snapshot_cache.get(url) + if cached: + return cached + raise + + def publish_discovery(self): + # Home Assistant MQTT discovery topics are retained. The bridge remembers + # what it published previously and clears stale entities when Frigate no + # longer exposes a camera/action. + discovery_prefix = self.config["mqtt"].get("discovery_prefix", "homeassistant") + availability = f"{self.base_topic}/status" + self.mqtt.publish(availability, "online", retain=True) + previous = self.load_discovery_state() + current = {} + for action in self.actions_snapshot(): + payload = self.discovery_payload(action, availability) + topic = f"{discovery_prefix}/{action.platform}/frigate_camera_control/{action.id}/config" + self.mqtt.publish(topic, json.dumps(payload, separators=(",", ":")), retain=True) + self.publish_state(action, retain=True) + current[action.id] = {"platform": action.platform, "topic": topic} + for action_id, old in previous.items(): + if action_id not in current: + topic = old.get("topic") or f"{discovery_prefix}/{old.get('platform', 'switch')}/frigate_camera_control/{action_id}/config" + self.mqtt.publish(topic, "", retain=True) + LOG.info("removed stale MQTT discovery entity %s", action_id) + self.save_discovery_state(current) + LOG.info("published MQTT discovery for %d actions", len(current)) + + def publish_state(self, action, retain=True): + if action.platform == "switch" and action.state in ("ON", "OFF"): + self.mqtt.publish(f"{self.base_topic}/{action.id}/state", action.state, retain=retain) + + def discovery_state_file(self): + return Path( + self.config.get("discovery", {}).get( + "state_file", + "/var/lib/frigate-camera-control-bridge/discovery_state.json", + ) + ) + + def load_discovery_state(self): + path = self.discovery_state_file() + try: + return json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return {} + except Exception as exc: + LOG.warning("failed to read discovery state %s: %s", path, exc) + return {} + + def save_discovery_state(self, state): + path = self.discovery_state_file() + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(state, indent=2), encoding="utf-8") + except Exception as exc: + LOG.warning("failed to write discovery state %s: %s", path, exc) + + def discovery_payload(self, action, availability_topic): + base = f"{self.base_topic}/{action.id}" + device_cfg = action.cfg.get("device") or {} + device = { + "identifiers": device_cfg.get("identifiers", ["frigate_camera_control"]), + "name": device_cfg.get("name", "Frigate Camera Control"), + } + for key in ("manufacturer", "model", "sw_version", "configuration_url"): + if device_cfg.get(key): + device[key] = device_cfg[key] + payload = { + "name": action.display_name(self.default_language), + "unique_id": f"frigate_camera_control_{action.id}", + "availability_topic": availability_topic, + "payload_available": "online", + "payload_not_available": "offline", + "device": device, + "origin": { + "name": "frigate-camera-control-bridge", + "sw_version": VERSION, + }, + "qos": 0, + } + if action.platform == "switch": + payload.update( + { + "command_topic": f"{base}/set", + "state_topic": f"{base}/state", + "payload_on": "ON", + "payload_off": "OFF", + "state_on": "ON", + "state_off": "OFF", + "optimistic": False, + "retain": False, + } + ) + elif action.platform == "button": + payload.update( + { + "command_topic": f"{base}/set", + "payload_press": "PRESS", + "retain": False, + } + ) + return payload + + def execute_action(self, action_id, command, publish=True): + action = self.action_by_id(action_id) + if action is None: + raise RuntimeError(f"unknown action {action_id}") + result = action.run(command, bridge=self) + if action.platform == "switch": + if command == "on": + self.schedule_auto_off(action) + elif command in ("off", "safe_off"): + self.cancel_auto_off(action.id) + if publish: + try: + self.publish_result(action, command, result) + except Exception as exc: + LOG.warning("action %s %s worked, but MQTT publish failed: %s", action_id, command, exc) + return result + + def schedule_auto_off(self, action): + seconds = int(action.auto_off_seconds or 0) + if seconds <= 0: + self.cancel_auto_off(action.id) + return + self.cancel_auto_off(action.id) + with self.action_lock: + action.auto_off_at = time.time() + seconds + auto_off_at = action.auto_off_at + timer = threading.Timer(seconds, self.auto_off_action, args=(action.id,)) + timer.daemon = True + with self.action_lock: + self.auto_off_timers[action.id] = timer + timer.start() + LOG.info("scheduled auto-off for %s in %ss at %s", action.id, seconds, int(auto_off_at)) + + def cancel_auto_off(self, action_id): + with self.action_lock: + timer = self.auto_off_timers.pop(action_id, None) + if timer is not None: + timer.cancel() + action = self.action_by_id(action_id) + if action is not None: + with self.action_lock: + action.auto_off_at = None + + def auto_off_action(self, action_id): + with self.action_lock: + self.auto_off_timers.pop(action_id, None) + action = self.actions.get(action_id) + if action is None: + return + command = action.safe_off_command() + if not command: + return + try: + LOG.info("auto-off executing %s %s", action_id, command) + action.run(command, bridge=self) + action.auto_off_at = None + self.publish_result(action, command, {"ok": True, "auto_off": True}) + except Exception as exc: + LOG.error("auto-off failed for %s: %s", action_id, exc) + self.publish_error(action_id, command, exc) + + def publish_result(self, action, command, result): + base = f"{self.base_topic}/{action.id}" + self.publish_state(action, retain=True) + self.mqtt.publish( + f"{base}/result", + json.dumps({"ok": True, "command": command, "time": int(time.time())}), + retain=False, + ) + + def publish_error(self, action_id, command, exc): + topic = f"{self.base_topic}/{action_id}/result" + payload = {"ok": False, "command": command, "error": str(exc), "time": int(time.time())} + try: + self.mqtt.publish(topic, json.dumps(payload), retain=False) + except Exception: + pass + + def all_off(self): + results = [] + for action in self.actions_snapshot(): + command = action.safe_off_command() + if not command: + continue + try: + action.run(command, bridge=self) + self.cancel_auto_off(action.id) + results.append({"action": action.id, "ok": True}) + if action.platform == "switch": + try: + self.publish_state(action, retain=True) + except Exception: + pass + except Exception as exc: + results.append({"action": action.id, "ok": False, "error": str(exc)}) + LOG.error("all_off failed for %s: %s", action.id, exc) + return {"ok": all(item["ok"] for item in results), "results": results} + + def startup_all_off(self): + if not self.config.get("defaults", {}).get("startup_all_off", True): + return + LOG.info("running startup all-off") + result = self.all_off() + if not result["ok"]: + LOG.warning("startup all-off completed with errors") + + def refresh_frigate_inventory(self): + LOG.info("refreshing Frigate camera inventory") + with self.action_lock: + old_actions = dict(self.actions) + new_actions = self.build_actions() + with self.action_lock: + removed_ids = set(old_actions) - set(new_actions) + for action_id in removed_ids: + timer = self.auto_off_timers.pop(action_id, None) + if timer is not None: + timer.cancel() + self.inherit_action_runtime_state(old_actions, new_actions) + self.actions = new_actions + self.refresh_snapshots() + try: + self.publish_discovery() + except Exception as exc: + LOG.warning("Frigate refresh could not publish MQTT discovery: %s", exc) + + def frigate_refresh_loop(self): + interval = int(self.config.get("defaults", {}).get("frigate_refresh_seconds", 300)) + if interval <= 0: + LOG.info("Frigate periodic refresh disabled") + return + interval = max(60, interval) + while not self.stop_event.wait(interval): + try: + self.refresh_frigate_inventory() + except Exception as exc: + LOG.warning("Frigate periodic refresh failed: %s", exc) + + def handle_mqtt_message(self, topic, payload): + discovery_prefix = self.config["mqtt"].get("discovery_prefix", "homeassistant") + if topic == f"{discovery_prefix}/status" and payload.lower() == "online": + self.publish_discovery() + return + prefix = f"{self.base_topic}/" + if not topic.startswith(prefix) or not topic.endswith("/set"): + return + action_id = topic[len(prefix) : -len("/set")] + action = self.action_by_id(action_id) + if not action: + LOG.warning("MQTT command for unknown action %s", action_id) + return + value = payload.upper() + if action.platform == "button": + command = "press" + elif value == "ON": + command = "on" + elif value == "OFF": + command = "off" + else: + LOG.warning("unsupported MQTT payload for %s: %s", action_id, payload) + return + try: + LOG.info("MQTT command %s %s", action_id, command) + self.execute_action(action_id, command, publish=True) + except Exception as exc: + LOG.error("MQTT action failed %s %s: %s", action_id, command, exc) + self.publish_error(action_id, command, exc) + + def serve(self): + signal.signal(signal.SIGTERM, self._handle_signal) + signal.signal(signal.SIGINT, self._handle_signal) + self.startup_all_off() + mqtt_thread = threading.Thread( + target=self.mqtt.serve, + args=(self.credentials, self.publish_discovery, self.stop_event), + daemon=True, + ) + mqtt_thread.start() + snapshot_thread = None + if self.config.get("defaults", {}).get("snapshot_enabled", True): + snapshot_thread = threading.Thread(target=self.snapshot_refresh_loop, daemon=True) + snapshot_thread.start() + refresh_thread = threading.Thread(target=self.frigate_refresh_loop, daemon=True) + refresh_thread.start() + http_cfg = self.config.get("http", {}) + if http_cfg.get("enabled", True): + self.start_http(http_cfg.get("bind", "0.0.0.0"), int(http_cfg.get("port", 5011))) + LOG.info("serving %d actions", self.action_count()) + + while not self.stop_event.is_set(): + try: + topic, payload, packet_id = self.messages.get(timeout=1) + self.handle_mqtt_message(topic, payload) + except queue.Empty: + pass + + self.mqtt.disconnect_cleanly() + if self.http_server is not None: + self.http_server.shutdown() + mqtt_thread.join(timeout=2) + if snapshot_thread is not None: + snapshot_thread.join(timeout=2) + refresh_thread.join(timeout=2) + + def snapshot_refresh_loop(self): + retry_interval = max(60, int(self.config.get("defaults", {}).get("snapshot_retry_seconds", 600))) + refresh_interval = max(retry_interval, int(self.config.get("defaults", {}).get("snapshot_refresh_seconds", 3600))) + interval = retry_interval if self.missing_snapshots() else refresh_interval + while not self.stop_event.wait(interval): + all_ok = self.refresh_snapshots() + interval = refresh_interval if all_ok and not self.missing_snapshots() else retry_interval + + def missing_snapshots(self): + for action in self.actions_snapshot(): + url = self.snapshot_url(action) + if url and url not in self.snapshot_cache: + return True + return False + + def http_token(self): + return self.config.get("http", {}).get("token") + + def http_authorized(self, headers, query): + token = self.http_token() + if not token: + return True + values = urllib.parse.parse_qs(query) + supplied = values.get("token", [None])[0] or headers.get("X-Bridge-Token") + return supplied == token + + def start_http(self, bind, port): + bridge = self + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + parsed = urllib.parse.urlsplit(self.path) + lang = bridge.request_language(parsed.query) + if parsed.path in ("/healthz", "/readyz"): + self.send_json({"ok": True, "actions": bridge.action_count()}) + return + if parsed.path.startswith("/static/"): + name = parsed.path.rsplit("/", 1)[-1] + payload = read_static_file(name) + if payload is None: + self.send_response(404) + self.end_headers() + return + content_type = "text/css" if name.endswith(".css") else "application/javascript" + self.send_bytes(payload, content_type, cache_seconds=3600) + return + if not bridge.http_authorized(self.headers, parsed.query): + self.send_response(401) + self.end_headers() + return + if parsed.path.startswith("/snapshot/"): + action_id = urllib.parse.unquote(parsed.path.rsplit("/", 1)[-1]).removesuffix(".jpg") + try: + live = bool(bridge.config.get("defaults", {}).get("snapshot_live_on_gui", True)) + snapshot = bridge.snapshot_for_action(action_id, live=live) + except Exception as exc: + LOG.info("snapshot request failed for %s: %s", action_id, exc) + snapshot = None + if not snapshot: + self.send_response(404) + self.end_headers() + return + self.send_bytes(snapshot["data"], snapshot["content_type"]) + return + if parsed.path == "/api/actions": + self.send_json(bridge.api_actions()) + return + token_param = urllib.parse.parse_qs(parsed.query).get("token", [None])[0] + self.send_html(bridge.html_page(lang, token_param)) + + def do_POST(self): + parsed = urllib.parse.urlsplit(self.path) + lang = bridge.request_language(parsed.query) + if not bridge.http_authorized(self.headers, parsed.query): + self.send_response(401) + self.end_headers() + return + parts = parsed.path.strip("/").split("/") + if len(parts) == 3 and parts[0] == "action": + action_id = urllib.parse.unquote(parts[1]) + command = urllib.parse.unquote(parts[2]).lower() + try: + result = bridge.execute_action(action_id, command, publish=True) + token_param = urllib.parse.parse_qs(parsed.query).get("token", [None])[0] + token_query = f"&token={urllib.parse.quote(token_param)}" if token_param else "" + self.send_response(303) + self.send_header("Location", f"/?lang={urllib.parse.quote(lang)}{token_query}") + self.end_headers() + except Exception as exc: + self.send_response(500) + self.end_headers() + self.wfile.write(str(exc).encode("utf-8")) + return + self.send_response(404) + self.end_headers() + + def send_json(self, payload): + data = json.dumps(payload, indent=2).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def send_html(self, body): + data = body.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def send_bytes(self, data, content_type, cache_seconds=300): + self.send_response(200) + self.send_header("Content-Type", content_type) + self.send_header("Cache-Control", f"private, max-age={cache_seconds}") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def log_message(self, fmt, *args): + LOG.info("http %s - %s", self.address_string(), fmt % args) + + self.http_server = ThreadingHTTPServer((bind, port), Handler) + thread = threading.Thread(target=self.http_server.serve_forever, daemon=True) + thread.start() + LOG.info("HTTP fallback GUI listening on %s:%s", bind, port) + + def api_actions(self): + return [ + { + "id": action.id, + "name": action.display_name(self.default_language), + "platform": action.platform, + "commands": action.command_list(), + "state": action.state, + "snapshot": bool(action.cfg.get("snapshot")), + "auto_off_seconds": action.auto_off_seconds, + "auto_off_at": int(action.auto_off_at) if action.auto_off_at else None, + "requires_ack": bool(action.cfg.get("requires_ack")), + } + for action in self.actions_snapshot() + ] + + def html_page(self, lang=None, token_param=None): + with self.action_lock: + actions = list(self.actions.values()) + return render_page(actions, lang or self.default_language, token_param) + + def request_language(self, query): + values = urllib.parse.parse_qs(query) + requested = values.get("lang", [self.default_language])[0] + return normalize_language(requested) + + def _handle_signal(self, signum, _frame): + LOG.info("received signal %s, stopping", signum) + self.stop_event.set() diff --git a/frigate_camera_control_bridge/cli.py b/frigate_camera_control_bridge/cli.py new file mode 100644 index 0000000..9192593 --- /dev/null +++ b/frigate_camera_control_bridge/cli.py @@ -0,0 +1,50 @@ +import argparse +import json +import logging +import sys + +from .app import DEFAULT_CONFIG, LOG, CameraControlBridge, load_config_or_env + + +def main(argv=None): + parser = argparse.ArgumentParser() + parser.add_argument("--config", default=DEFAULT_CONFIG) + parser.add_argument("--serve", action="store_true") + parser.add_argument("--list", action="store_true") + parser.add_argument("--check", action="store_true") + parser.add_argument("--publish-discovery", action="store_true") + parser.add_argument("--action", metavar="ID") + parser.add_argument("command", nargs="?") + parser.add_argument("--all-off", action="store_true") + args = parser.parse_args(argv) + + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + bridge = CameraControlBridge(load_config_or_env(args.config)) + + if args.list: + bridge.list_actions() + return 0 + if args.check: + return bridge.check() + if args.all_off: + print(json.dumps(bridge.all_off(), indent=2)) + return 0 + if args.action: + if not args.command: + print("missing command", file=sys.stderr) + return 2 + print(json.dumps(bridge.execute_action(args.action, args.command, publish=False), indent=2)) + return 0 + if args.publish_discovery: + for credential in bridge.credentials: + try: + bridge.mqtt.connect(credential.get("username"), credential.get("password")) + bridge.publish_discovery() + bridge.mqtt.disconnect_cleanly() + return 0 + except Exception as exc: + LOG.warning("publish discovery failed using %s: %s", credential["source"], exc) + return 1 + + bridge.serve() + return 0 diff --git a/frigate_camera_control_bridge/i18n.py b/frigate_camera_control_bridge/i18n.py new file mode 100644 index 0000000..d332947 --- /dev/null +++ b/frigate_camera_control_bridge/i18n.py @@ -0,0 +1,81 @@ +import gettext +import logging +import os +from pathlib import Path + + +LOG = logging.getLogger("frigate-camera-control-bridge") +TRANSLATION_DOMAIN = "frigate_camera_control_bridge" +DEFAULT_LANGUAGE = "en" +DEFAULT_LOCALE_DIR = Path(__file__).resolve().parents[1] / "locale" +LOCALE_DIR = Path(os.environ.get("LOCALE_DIR", DEFAULT_LOCALE_DIR)) + +_TRANSLATIONS = {} +_AVAILABLE_LANGUAGES = None + + +def parse_po_language_name(path): + # gettext compiled catalogs do not expose a friendly language name, so the + # editable .po file may carry an optional Language-Name header for the GUI. + try: + text = path.read_text(encoding="utf-8") + except OSError: + return None + for line in text.splitlines(): + if line.startswith('"Language-Name:'): + return line.split(":", 1)[1].strip().strip("\\n\"") + return None + + +def available_languages(): + # Discover languages dynamically from locale/*/LC_MESSAGES/*.mo. Adding a + # language should not require a code change; ship a new .po/.mo pair instead. + global _AVAILABLE_LANGUAGES + if _AVAILABLE_LANGUAGES is not None: + return _AVAILABLE_LANGUAGES + + languages = {DEFAULT_LANGUAGE: DEFAULT_LANGUAGE.upper()} + if LOCALE_DIR.exists(): + for item in sorted(LOCALE_DIR.iterdir()): + if not item.is_dir(): + continue + code = item.name + catalog = item / "LC_MESSAGES" / f"{TRANSLATION_DOMAIN}.mo" + if catalog.exists(): + languages[code] = ( + parse_po_language_name(item / "LC_MESSAGES" / f"{TRANSLATION_DOMAIN}.po") + or code.upper() + ) + _AVAILABLE_LANGUAGES = languages + return languages + + +def normalize_language(value): + if not value: + return DEFAULT_LANGUAGE + base = str(value).lower().replace("_", "-").split("-", 1)[0] + return base if base in available_languages() else DEFAULT_LANGUAGE + + +def translation_for(lang): + # fallback=True makes English msgids work even if a translation file is + # missing, which is useful while developing a new language catalog. + lang = normalize_language(lang) + if lang not in _TRANSLATIONS: + _TRANSLATIONS[lang] = gettext.translation( + TRANSLATION_DOMAIN, + localedir=str(LOCALE_DIR), + languages=[lang], + fallback=True, + ) + return _TRANSLATIONS[lang] + + +def text_for(lang, msgid, **kwargs): + text = translation_for(lang).gettext(msgid) + if kwargs: + try: + return text.format(**kwargs) + except KeyError as exc: + LOG.warning("missing translation placeholder %s for %s", exc, msgid) + return text diff --git a/frigate_camera_control_bridge/static/app.css b/frigate_camera_control_bridge/static/app.css new file mode 100644 index 0000000..3711be6 --- /dev/null +++ b/frigate_camera_control_bridge/static/app.css @@ -0,0 +1,383 @@ +:root { + color-scheme: light; + --bg: #f4f6f8; + --panel: #ffffff; + --text: #17202a; + --muted: #627083; + --line: #d8dee7; + --line-strong: #b9c3cf; + --blue: #1d4ed8; + --blue-dark: #1e40af; + --green: #047857; + --green-dark: #065f46; + --red: #b42318; + --red-soft: #fff1f0; + --amber: #b45309; + --shadow: 0 14px 34px rgba(17, 24, 39, 0.08); +} + +* { + box-sizing: border-box; +} + +body { + min-height: 100vh; + margin: 0; + background: var(--bg); + color: var(--text); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + letter-spacing: 0; +} + +.shell { + width: min(1280px, calc(100% - 32px)); + margin: 0 auto; + padding: 28px 0 36px; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 22px; +} + +.title-block { + min-width: 0; +} + +h1 { + margin: 0; + font-size: clamp(1.35rem, 1.2rem + 0.8vw, 2rem); + line-height: 1.1; + font-weight: 720; +} + +.summary { + display: inline-flex; + align-items: center; + gap: 8px; + margin-top: 9px; + color: var(--muted); + font-size: 0.92rem; +} + +.summary strong { + color: var(--text); + font-weight: 720; +} + +.toolbar { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.language-form { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 0; +} + +label { + color: var(--muted); + font-size: 0.9rem; +} + +select { + min-height: 38px; + padding: 6px 34px 6px 10px; + border: 1px solid var(--line-strong); + border-radius: 6px; + background: var(--panel); + color: var(--text); +} + +.control-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 310px), 1fr)); + gap: 16px; +} + +.action-card { + display: flex; + min-width: 0; + min-height: 388px; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + box-shadow: var(--shadow); +} + +.media { + position: relative; + aspect-ratio: 16 / 9; + width: 100%; + overflow: hidden; + background: #dce3eb; +} + +.snapshot { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.media-empty { + display: grid; + place-items: center; + border-bottom: 1px solid var(--line); + color: #6b7788; + font-size: 0.9rem; +} + +.media-empty span { + margin-top: 78px; +} + +.visual-badge { + position: absolute; + right: 12px; + bottom: 12px; + display: grid; + width: 58px; + height: 58px; + place-items: center; + border: 1px solid rgba(255, 255, 255, 0.6); + border-radius: 8px; + background: rgba(15, 23, 42, 0.74); + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.28); +} + +.visual-badge svg { + width: 38px; + height: 38px; +} + +.media-empty .visual-badge { + top: 50%; + right: auto; + bottom: auto; + left: 50%; + width: 76px; + height: 76px; + transform: translate(-50%, -58%); + border-color: var(--line); + background: rgba(255, 255, 255, 0.82); +} + +.visual-siren { + color: #dc2626; +} + +.visual-red-blue { + color: #1d4ed8; +} + +.visual-white-light { + color: #facc15; +} + +.visual-all-off { + color: #334155; +} + +.visual-generic { + color: #2563eb; +} + +.card-body { + display: flex; + flex: 1; + flex-direction: column; + gap: 14px; + padding: 15px; +} + +.card-main { + min-width: 0; +} + +h2 { + margin: 0 0 8px; + font-size: 1.02rem; + line-height: 1.25; + font-weight: 700; + overflow-wrap: anywhere; +} + +.action-id { + display: block; + max-width: 100%; + color: var(--muted); + font-size: 0.78rem; + line-height: 1.45; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.meta-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.chip { + display: inline-flex; + min-height: 28px; + align-items: center; + border: 1px solid var(--line); + border-radius: 999px; + padding: 4px 10px; + background: #f8fafc; + color: #465568; + font-size: 0.82rem; + line-height: 1; +} + +.chip-state-on { + border-color: #9dd8bd; + background: #edfdf5; + color: var(--green-dark); +} + +.chip-state-off { + border-color: #f0b8b4; + background: var(--red-soft); + color: var(--red); +} + +.chip-state-unknown { + border-color: #e6d2a6; + background: #fff8e7; + color: var(--amber); +} + +.timer { + display: inline-flex; + min-height: 30px; + align-items: center; + width: fit-content; + border: 1px solid #bfdbfe; + border-radius: 6px; + padding: 5px 9px; + background: #eff6ff; + color: #1e40af; + font-size: 0.84rem; + font-weight: 650; +} + +.timer-idle { + border-color: var(--line); + background: #f8fafc; + color: var(--muted); +} + +.timer-expired { + border-color: #e6d2a6; + background: #fff8e7; + color: var(--amber); +} + +.command-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)); + gap: 9px; + margin-top: auto; +} + +.command-form { + min-width: 0; + margin: 0; +} + +.cmd { + width: 100%; + min-height: 42px; + border: 1px solid var(--line-strong); + border-radius: 6px; + padding: 9px 12px; + background: #ffffff; + color: var(--text); + cursor: pointer; + font: inherit; + font-weight: 680; + line-height: 1.1; +} + +.cmd span { + display: block; + overflow-wrap: anywhere; +} + +.cmd:hover { + border-color: var(--blue); +} + +.cmd:focus-visible { + outline: 3px solid rgba(29, 78, 216, 0.25); + outline-offset: 2px; +} + +.cmd-on, +.cmd-press { + border-color: var(--blue); + background: var(--blue); + color: #ffffff; +} + +.cmd-on:hover, +.cmd-press:hover { + border-color: var(--blue-dark); + background: var(--blue-dark); +} + +.cmd-off { + border-color: #d7a5a1; + background: #ffffff; + color: var(--red); +} + +.cmd-off:hover { + border-color: var(--red); + background: var(--red-soft); +} + +.empty-state { + border: 1px dashed var(--line-strong); + border-radius: 8px; + padding: 28px; + background: rgba(255, 255, 255, 0.7); + color: var(--muted); + text-align: center; +} + +@media (max-width: 720px) { + .shell { + width: min(100% - 20px, 1280px); + padding-top: 18px; + } + + .topbar { + align-items: flex-start; + flex-direction: column; + } + + .toolbar, + .language-form { + width: 100%; + justify-content: space-between; + } + + select { + max-width: 60%; + } +} diff --git a/frigate_camera_control_bridge/static/app.js b/frigate_camera_control_bridge/static/app.js new file mode 100644 index 0000000..f7a6c37 --- /dev/null +++ b/frigate_camera_control_bridge/static/app.js @@ -0,0 +1,37 @@ +function formatRemaining(totalSeconds) { + const seconds = Math.max(0, Math.ceil(totalSeconds)); + const minutes = Math.floor(seconds / 60); + const rest = seconds % 60; + return `${minutes}:${String(rest).padStart(2, "0")}`; +} + +function updateTimers() { + const now = Date.now() / 1000; + document.querySelectorAll("[data-auto-off-at]").forEach((item) => { + const target = Number(item.dataset.autoOffAt || 0); + if (!target) { + return; + } + const remaining = target - now; + if (remaining <= 0) { + item.textContent = item.dataset.expiredLabel || "Auto-off pending"; + item.classList.add("timer-expired"); + return; + } + item.textContent = `${item.dataset.prefix || "Auto-off"} ${formatRemaining(remaining)}`; + }); +} + +document.addEventListener("submit", (event) => { + const form = event.target; + if (!(form instanceof HTMLFormElement) || form.dataset.requiresAck !== "true") { + return; + } + const message = form.dataset.ackMessage || "Confirm this action"; + if (!window.confirm(message)) { + event.preventDefault(); + } +}); + +updateTimers(); +setInterval(updateTimers, 1000); diff --git a/frigate_camera_control_bridge/templates/index.html b/frigate_camera_control_bridge/templates/index.html new file mode 100644 index 0000000..869a73f --- /dev/null +++ b/frigate_camera_control_bridge/templates/index.html @@ -0,0 +1,33 @@ + + + + + +{title} + + + +
+
+
+

{title}

+
{action_count}{actions_label}
+
+
+
+ +{token_input} + + +
+
+
+
+{cards} +
+
+ + + diff --git a/frigate_camera_control_bridge/ui.py b/frigate_camera_control_bridge/ui.py new file mode 100644 index 0000000..7b115a6 --- /dev/null +++ b/frigate_camera_control_bridge/ui.py @@ -0,0 +1,217 @@ +import html +import urllib.parse +from pathlib import Path + +from .i18n import available_languages, normalize_language, text_for + + +PACKAGE_DIR = Path(__file__).resolve().parent +TEMPLATE_PATH = PACKAGE_DIR / "templates" / "index.html" +STATIC_DIR = PACKAGE_DIR / "static" + + +def read_static_file(name): + # The HTTP layer serves only explicitly named static files from this package + # directory. This avoids exposing arbitrary local paths. + path = STATIC_DIR / name + if not path.is_file() or path.parent != STATIC_DIR: + return None + return path.read_bytes() + + +def action_visual(action, lang): + # Keep GUI visuals tied to action semantics, not to local camera names. New + # action families can add another name_key/kind branch without changing MQTT + # topics or camera command handling. + name_key = action.cfg.get("name_key") + if name_key == "action.siren": + return { + "kind": "siren", + "label": text_for(lang, "visual.siren"), + "svg": ( + '" + ), + } + if name_key == "action.red_blue": + return { + "kind": "red-blue", + "label": text_for(lang, "visual.red_blue"), + "svg": ( + '" + ), + } + if name_key == "action.manual_white": + return { + "kind": "white-light", + "label": text_for(lang, "visual.white_light"), + "svg": ( + '" + ), + } + if action.kind == "all_off": + return { + "kind": "all-off", + "label": text_for(lang, "visual.all_off"), + "svg": ( + '" + ), + } + return { + "kind": "generic", + "label": text_for(lang, "visual.generic"), + "svg": ( + '" + ), + } + + +def visual_markup(action, lang): + visual = action_visual(action, lang) + return ( + f'
' + f'{visual["svg"]}
' + ) + + +def render_card(action, lang, token_query): + display_name = action.display_name(lang) + icon = visual_markup(action, lang) + snapshot_markup = ( + '
' + f"{icon}" + f'{html.escape(text_for(lang, "no_preview"))}' + "
" + ) + if action.cfg.get("snapshot"): + snapshot_url = ( + f"/snapshot/{urllib.parse.quote(action.id)}.jpg" + f"?lang={urllib.parse.quote(lang)}{token_query}" + ) + snapshot_markup = ( + '
' + f'' + f"{icon}" + "
" + ) + + buttons = [] + for command in action.command_list(): + label = text_for(lang, f"button.{command}") + action_url = ( + f"/action/{urllib.parse.quote(action.id)}/{urllib.parse.quote(command)}" + f"?lang={urllib.parse.quote(lang)}{token_query}" + ) + ack_attrs = "" + if command == "on" and action.cfg.get("requires_ack"): + ack_attrs = ( + ' data-requires-ack="true"' + f' data-ack-message="{html.escape(text_for(lang, "ack.siren_on"))}"' + ) + buttons.append( + f'
' + f'
" + ) + + state_value = (action.state or "unknown").lower() + state_label = text_for(lang, f"state.{state_value}") + if state_label == f"state.{state_value}": + state_label = state_value.upper() + platform_label = text_for(lang, f"platform.{action.platform}") + if platform_label == f"platform.{action.platform}": + platform_label = action.platform + timer_markup = "" + if action.auto_off_seconds > 0: + if action.auto_off_at: + timer_markup = ( + f'' + f'{html.escape(text_for(lang, "timer.auto_off"))}' + ) + else: + timer_markup = ( + f'' + f'{html.escape(text_for(lang, "timer.auto_off_configured", seconds=action.auto_off_seconds))}' + "" + ) + + return ( + '
' + f"{snapshot_markup}" + '
' + '
' + f"

{html.escape(display_name)}

" + f'{html.escape(action.id)}' + "
" + '
' + f'{html.escape(platform_label)}' + f'' + f"{html.escape(state_label)}" + "
" + f"{timer_markup}" + f'
{"".join(buttons)}
' + "
" + "
" + ) + + +def render_language_options(lang): + options = [] + for code, label in available_languages().items(): + selected = " selected" if code == lang else "" + options.append(f'') + return "".join(options) + + +def render_page(actions, lang, token_param=None): + lang = normalize_language(lang) + token_query = f"&token={urllib.parse.quote(token_param)}" if token_param else "" + cards = [render_card(action, lang, token_query) for action in actions] + cards_markup = "".join(cards) + if not cards_markup: + cards_markup = f'
{html.escape(text_for(lang, "no_actions"))}
' + token_input = ( + f'' + if token_param + else "" + ) + template = TEMPLATE_PATH.read_text(encoding="utf-8") + return template.format( + lang=html.escape(lang), + title=html.escape(text_for(lang, "title")), + action_count=len(cards), + actions_label=html.escape(text_for(lang, "actions")), + language_label=html.escape(text_for(lang, "language")), + language_options=render_language_options(lang), + token_input=token_input, + cards=cards_markup, + ) diff --git a/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po b/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po index 78dbdab..a88353e 100644 --- a/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po +++ b/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po @@ -29,6 +29,54 @@ msgstr "Actions" msgid "snapshot" msgstr "Snapshot" +msgid "no_preview" +msgstr "No preview" + +msgid "no_actions" +msgstr "No actions discovered" + +msgid "state.on" +msgstr "ON" + +msgid "state.off" +msgstr "OFF" + +msgid "state.unknown" +msgstr "UNKNOWN" + +msgid "platform.switch" +msgstr "Switch" + +msgid "platform.button" +msgstr "Button" + +msgid "visual.siren" +msgstr "Siren" + +msgid "visual.red_blue" +msgstr "Red-blue light" + +msgid "visual.white_light" +msgstr "White light" + +msgid "visual.all_off" +msgstr "All off" + +msgid "visual.generic" +msgstr "Camera control" + +msgid "timer.auto_off" +msgstr "Auto-off in" + +msgid "timer.expired" +msgstr "Auto-off pending" + +msgid "timer.auto_off_configured" +msgstr "Auto-off after {seconds}s" + +msgid "ack.siren_on" +msgstr "Confirm siren activation." + msgid "button.on" msgstr "ON" diff --git a/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po b/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po index 1561745..9a2218b 100644 --- a/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po +++ b/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po @@ -29,6 +29,54 @@ msgstr "Akcje" msgid "snapshot" msgstr "Migawka" +msgid "no_preview" +msgstr "Brak podgladu" + +msgid "no_actions" +msgstr "Nie wykryto akcji" + +msgid "state.on" +msgstr "WLACZONE" + +msgid "state.off" +msgstr "WYLACZONE" + +msgid "state.unknown" +msgstr "NIEZNANY" + +msgid "platform.switch" +msgstr "Przelacznik" + +msgid "platform.button" +msgstr "Przycisk" + +msgid "visual.siren" +msgstr "Syrena" + +msgid "visual.red_blue" +msgstr "Czerwono-niebieskie swiatlo" + +msgid "visual.white_light" +msgstr "Biale swiatlo" + +msgid "visual.all_off" +msgstr "Wylacz wszystko" + +msgid "visual.generic" +msgstr "Sterowanie kamera" + +msgid "timer.auto_off" +msgstr "Auto-off za" + +msgid "timer.expired" +msgstr "Auto-off w toku" + +msgid "timer.auto_off_configured" +msgstr "Auto-off po {seconds}s" + +msgid "ack.siren_on" +msgstr "Potwierdz wlaczenie syreny." + msgid "button.on" msgstr "WLACZ" diff --git a/tests/test_discovery_mapping.py b/tests/test_discovery_mapping.py index 1f1916d..9fe1705 100644 --- a/tests/test_discovery_mapping.py +++ b/tests/test_discovery_mapping.py @@ -1,20 +1,11 @@ -import importlib.util import unittest -from pathlib import Path - -def load_bridge_module(): - root = Path(__file__).resolve().parents[1] - path = root / "frigate-camera-control-bridge.py" - spec = importlib.util.spec_from_file_location("bridge", path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module +from frigate_camera_control_bridge.app import CameraControlBridge class DiscoveryMappingTest(unittest.TestCase): def setUp(self): - self.bridge = load_bridge_module() + self.bridge = CameraControlBridge def test_loopback_ffmpeg_path_resolves_through_go2rtc(self): camera_cfg = { @@ -30,10 +21,10 @@ class DiscoveryMappingTest(unittest.TestCase): ] } - urls = self.bridge.CameraControlBridge.camera_candidate_urls("camera-a", camera_cfg, streams) + urls = self.bridge.camera_candidate_urls("camera-a", camera_cfg, streams) self.assertEqual(urls, ["rtsp://camera-a.example.test:554/Streaming/Channels/101"]) - self.assertEqual(self.bridge.CameraControlBridge.hosts_from_urls(urls), ["camera-a.example.test"]) + self.assertEqual(self.bridge.hosts_from_urls(urls), ["camera-a.example.test"]) def test_wrapped_go2rtc_stream_resolves_inner_camera_url(self): camera_cfg = { @@ -49,7 +40,7 @@ class DiscoveryMappingTest(unittest.TestCase): ] } - urls = self.bridge.CameraControlBridge.camera_candidate_urls("camera-a", camera_cfg, streams) + urls = self.bridge.camera_candidate_urls("camera-a", camera_cfg, streams) self.assertEqual( urls, @@ -57,7 +48,7 @@ class DiscoveryMappingTest(unittest.TestCase): "rtsp://camera-a.example.test:554/Streaming/Channels/101?profile=main#hardware", ], ) - self.assertEqual(self.bridge.CameraControlBridge.hosts_from_urls(urls), ["camera-a.example.test"]) + self.assertEqual(self.bridge.hosts_from_urls(urls), ["camera-a.example.test"]) def test_direct_ffmpeg_path_is_used_without_go2rtc(self): camera_cfg = { @@ -68,10 +59,10 @@ class DiscoveryMappingTest(unittest.TestCase): } } - urls = self.bridge.CameraControlBridge.camera_candidate_urls("camera-b", camera_cfg, {}) + urls = self.bridge.camera_candidate_urls("camera-b", camera_cfg, {}) self.assertEqual(urls, ["rtsp://camera-b.example.test:554/cam/realmonitor?channel=1&subtype=0"]) - self.assertEqual(self.bridge.CameraControlBridge.hosts_from_urls(urls), ["camera-b.example.test"]) + self.assertEqual(self.bridge.hosts_from_urls(urls), ["camera-b.example.test"]) def test_wrapped_direct_ffmpeg_path_is_used_without_go2rtc(self): camera_cfg = { @@ -82,7 +73,7 @@ class DiscoveryMappingTest(unittest.TestCase): } } - urls = self.bridge.CameraControlBridge.camera_candidate_urls("camera-b", camera_cfg, {}) + urls = self.bridge.camera_candidate_urls("camera-b", camera_cfg, {}) self.assertEqual( urls, @@ -90,7 +81,7 @@ class DiscoveryMappingTest(unittest.TestCase): "rtsp://camera-b.example.test:554/cam/realmonitor?channel=1&subtype=0#video=copy", ], ) - self.assertEqual(self.bridge.CameraControlBridge.hosts_from_urls(urls), ["camera-b.example.test"]) + self.assertEqual(self.bridge.hosts_from_urls(urls), ["camera-b.example.test"]) def test_direct_path_wins_when_loopback_is_also_present(self): camera_cfg = { @@ -109,7 +100,7 @@ class DiscoveryMappingTest(unittest.TestCase): } } - urls = self.bridge.CameraControlBridge.camera_candidate_urls("camera-a", camera_cfg, streams) + urls = self.bridge.camera_candidate_urls("camera-a", camera_cfg, streams) self.assertEqual( urls, @@ -118,7 +109,7 @@ class DiscoveryMappingTest(unittest.TestCase): ], ) self.assertEqual( - self.bridge.CameraControlBridge.hosts_from_urls(urls), + self.bridge.hosts_from_urls(urls), [ "direct-camera.example.test", ], diff --git a/tests/test_ui_render.py b/tests/test_ui_render.py new file mode 100644 index 0000000..6c9e78a --- /dev/null +++ b/tests/test_ui_render.py @@ -0,0 +1,37 @@ +import unittest + +from frigate_camera_control_bridge.ui import render_page + + +class DummyAction: + def __init__(self): + self.id = "example_camera_siren" + self.platform = "switch" + self.kind = "dahua_coaxial_io" + self.state = "ON" + self.auto_off_seconds = 180 + self.auto_off_at = 1234567890 + self.cfg = { + "name_key": "action.siren", + "requires_ack": True, + "snapshot": {"camera": "example", "frigate_url": "http://frigate.example.test:5000"}, + } + + def display_name(self, lang): + return "Example siren" + + def command_list(self): + return ["on", "off"] + + +class UiRenderTest(unittest.TestCase): + def test_timer_timestamp_and_ack_survive_server_render(self): + html = render_page([DummyAction()], "en") + + self.assertIn('data-auto-off-at="1234567890"', html) + self.assertIn('data-requires-ack="true"', html) + self.assertIn("Example siren", html) + + +if __name__ == "__main__": + unittest.main()