From 26e042d2d78ae2299ef21376a4a936d15b6be3f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Kacprzak?= Date: Mon, 3 Aug 2026 00:21:18 +0200 Subject: [PATCH] Initial containerized Frigate camera control bridge --- .dockerignore | 11 + .gitignore | 10 + Dockerfile | 22 + Dockerfile.dev | 14 + README.md | 194 ++ camera-control.env.example | 22 + examples/kubernetes-sidecar.yaml | 86 + examples/podman-quadlet/README.md | 57 + .../frigate-camera-control-bridge.container | 25 + .../frigate-camera-control-bridge.env.example | 24 + frigate-camera-control-bridge.py | 1736 +++++++++++++++++ .../frigate_camera_control_bridge.po | 51 + .../frigate_camera_control_bridge.po | 51 + tests/test_discovery_mapping.py | 86 + tools/compile_gettext.py | 77 + 15 files changed, 2466 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Dockerfile.dev create mode 100644 README.md create mode 100644 camera-control.env.example create mode 100644 examples/kubernetes-sidecar.yaml create mode 100644 examples/podman-quadlet/README.md create mode 100644 examples/podman-quadlet/frigate-camera-control-bridge.container create mode 100644 examples/podman-quadlet/frigate-camera-control-bridge.env.example create mode 100644 frigate-camera-control-bridge.py create mode 100644 locale/en/LC_MESSAGES/frigate_camera_control_bridge.po create mode 100644 locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po create mode 100644 tests/test_discovery_mapping.py create mode 100755 tools/compile_gettext.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0ab1739 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +__pycache__/ +*.pyc +*.pyo +.DS_Store +.env +*.env +!*.env.example +camera-control.env +dist/ +build/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9d91ef2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*.mo +.DS_Store +.env +*.env +!.env.example +camera-control.env +dist/ +build/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..595d488 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +ARG BASE_IMAGE=python:3.11-slim +FROM ${BASE_IMAGE} + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV LOCALE_DIR=/app/locale +ENV HTTP_PORT=5011 +ENV UI_LANGUAGE=en + +COPY frigate-camera-control-bridge.py /app/frigate-camera-control-bridge.py +COPY locale /app/locale +COPY tools/compile_gettext.py /app/tools/compile_gettext.py + +RUN python3 /app/tools/compile_gettext.py /app/locale + +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"] diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..bbe6bbc --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,14 @@ +ARG BASE_IMAGE=python:3.11-slim +FROM ${BASE_IMAGE} + +WORKDIR /workspace + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV LOCALE_DIR=/workspace/locale +ENV HTTP_PORT=5011 +ENV UI_LANGUAGE=en + +COPY tools/compile_gettext.py /usr/local/bin/compile_gettext.py + +CMD ["sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..13c7498 --- /dev/null +++ b/README.md @@ -0,0 +1,194 @@ +# Frigate Camera Control Bridge + +## PL + +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. + +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 + `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`. + +Podstawowe zmienne: + +| Zmienna | Znaczenie | +| --- | --- | +| `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`. | + +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: + +```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` + +Lokalny test kontenera: + +```bash +docker run --rm \ + -e FRIGATE_NAME=example \ + -e FRIGATE_URL=http://frigate:5000 \ + -e MQTT_HOST=mqtt.local \ + -e MQTT_USER=user \ + -e MQTT_PASSWORD=password \ + -e DEFAULT_CAMERA_USER=admin \ + -e DEFAULT_CAMERA_PASSWORD=password \ + -p 5011:5011 \ + 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: + +```text +http://:5011/ +http://:5011/?lang=pl +http://:5011/api/actions +http://:5011/snapshot/.jpg +http://:5011/healthz +http://:5011/readyz +``` + +Kubernetes sidecar 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. diff --git a/camera-control.env.example b/camera-control.env.example new file mode 100644 index 0000000..bd7bb5b --- /dev/null +++ b/camera-control.env.example @@ -0,0 +1,22 @@ +# Per-instance runtime env for one Frigate camera-control sidecar. +# Example target on Podman: /etc/frigate-camera-control-bridge/frigate-main.env +# Kubernetes equivalent: env values from Secret/ConfigMap for that Frigate pod. + +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 + +# Optional. If set, HTTP GUI/API require ?token= or X-Bridge-Token. +# HTTP_TOKEN=change-me + +# Optional. Snapshot refresh is intentionally slow; it is only for GUI context. +SNAPSHOT_ENABLED=true +SNAPSHOT_HEIGHT=180 +SNAPSHOT_CACHE_SECONDS=300 +SNAPSHOT_RETRY_SECONDS=600 +SNAPSHOT_REFRESH_SECONDS=3600 +SNAPSHOT_LIVE_ON_GUI=true diff --git a/examples/kubernetes-sidecar.yaml b/examples/kubernetes-sidecar.yaml new file mode 100644 index 0000000..f32b290 --- /dev/null +++ b/examples/kubernetes-sidecar.yaml @@ -0,0 +1,86 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frigate-with-camera-control +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 + - 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://127.0.0.1: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: SNAPSHOT_RETRY_SECONDS + value: "600" + - name: SNAPSHOT_REFRESH_SECONDS + value: "3600" + - name: SNAPSHOT_LIVE_ON_GUI + value: "true" + - 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: {} diff --git a/examples/podman-quadlet/README.md b/examples/podman-quadlet/README.md new file mode 100644 index 0000000..21686c9 --- /dev/null +++ b/examples/podman-quadlet/README.md @@ -0,0 +1,57 @@ +# 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`, +`FRIGATE_URL`, `BASE_TOPIC`, the `PublishPort` binding, and MQTT credentials. + +Example install: + +```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 +``` + +Fill these env values: +- `MQTT_HOST`, `MQTT_USER`, `MQTT_PASSWORD` +- `DEFAULT_CAMERA_USER`, `DEFAULT_CAMERA_PASSWORD` +- `FRIGATE_NAME`, `FRIGATE_URL`, `BASE_TOPIC` + +If the sidecar should follow an existing Frigate service, adjust the +`frigate.service` references in `After`, `Requires`, `BindsTo`, and `PartOf`. diff --git a/examples/podman-quadlet/frigate-camera-control-bridge.container b/examples/podman-quadlet/frigate-camera-control-bridge.container new file mode 100644 index 0000000..bd22e9c --- /dev/null +++ b/examples/podman-quadlet/frigate-camera-control-bridge.container @@ -0,0 +1,25 @@ +[Unit] +Description=Frigate camera control bridge sidecar +Wants=network-online.target +After=network-online.target frigate.service +Requires=frigate.service +BindsTo=frigate.service +PartOf=frigate.service +RequiresMountsFor=/var/lib/frigate-camera-control-bridge + +[Container] +ContainerName=frigate-camera-control-bridge +Image=localhost/frigate-camera-control-bridge:1.0 +PublishPort=0.0.0.0:5011:5011 +Volume=/var/lib/frigate-camera-control-bridge:/var/lib/frigate-camera-control-bridge +Volume=/etc/localtime:/etc/localtime:ro +PodmanArgs=--env-file=/etc/frigate-camera-control-bridge/frigate-main.env + +[Service] +Restart=on-failure +RestartSec=5s +TimeoutStartSec=180 + +[Install] +WantedBy=multi-user.target +WantedBy=frigate.service diff --git a/examples/podman-quadlet/frigate-camera-control-bridge.env.example b/examples/podman-quadlet/frigate-camera-control-bridge.env.example new file mode 100644 index 0000000..bbb77aa --- /dev/null +++ b/examples/podman-quadlet/frigate-camera-control-bridge.env.example @@ -0,0 +1,24 @@ +FRIGATE_NAME=frigate-main +FRIGATE_URL=http://127.0.0.1:5000 +BASE_TOPIC=frigate_camera_control/frigate-main + +MQTT_HOST=mqtt.local +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 + +SNAPSHOT_ENABLED=true +SNAPSHOT_HEIGHT=180 +SNAPSHOT_CACHE_SECONDS=300 +SNAPSHOT_RETRY_SECONDS=600 +SNAPSHOT_REFRESH_SECONDS=3600 +SNAPSHOT_LIVE_ON_GUI=true diff --git a/frigate-camera-control-bridge.py b/frigate-camera-control-bridge.py new file mode 100644 index 0000000..0be67ec --- /dev/null +++ b/frigate-camera-control-bridge.py @@ -0,0 +1,1736 @@ +#!/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") + +# 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 through go2rtc stream names or direct + # ffmpeg URLs. We inspect both sources and extract camera IPs from every + # URL we can find. + 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.append(path) + return urls + + @staticmethod + def go2rtc_stream_name_from_url(url): + try: + parsed = urllib.parse.urlsplit(str(url)) + except ValueError: + return None + host = parsed.hostname or "" + if parsed.scheme != "rtsp" or parsed.port != 8554: + return None + if host not in ("127.0.0.1", "localhost", "::1"): + return None + return parsed.path.lstrip("/").split("/", 1)[0] or None + + @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): + return [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 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po b/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po new file mode 100644 index 0000000..78dbdab --- /dev/null +++ b/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po @@ -0,0 +1,51 @@ +msgid "" +msgstr "" +"Project-Id-Version: frigate-camera-control-bridge 1.0\n" +"Language: en\n" +"Language-Name: English\n" +"Content-Type: text/plain; charset=UTF-8\n" + +msgid "title" +msgstr "Frigate Camera Control" + +msgid "language" +msgstr "Language" + +msgid "id" +msgstr "ID" + +msgid "name" +msgstr "Name" + +msgid "type" +msgstr "Type" + +msgid "state" +msgstr "State" + +msgid "actions" +msgstr "Actions" + +msgid "snapshot" +msgstr "Snapshot" + +msgid "button.on" +msgstr "ON" + +msgid "button.off" +msgstr "OFF" + +msgid "button.press" +msgstr "PRESS" + +msgid "action.all_off" +msgstr "{target} all off" + +msgid "action.manual_white" +msgstr "{target} manual white light" + +msgid "action.red_blue" +msgstr "{target} red-blue light" + +msgid "action.siren" +msgstr "{target} siren" diff --git a/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po b/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po new file mode 100644 index 0000000..1561745 --- /dev/null +++ b/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po @@ -0,0 +1,51 @@ +msgid "" +msgstr "" +"Project-Id-Version: frigate-camera-control-bridge 1.0\n" +"Language: pl\n" +"Language-Name: Polski\n" +"Content-Type: text/plain; charset=UTF-8\n" + +msgid "title" +msgstr "Frigate Camera Control" + +msgid "language" +msgstr "Jezyk" + +msgid "id" +msgstr "ID" + +msgid "name" +msgstr "Nazwa" + +msgid "type" +msgstr "Typ" + +msgid "state" +msgstr "Stan" + +msgid "actions" +msgstr "Akcje" + +msgid "snapshot" +msgstr "Migawka" + +msgid "button.on" +msgstr "WLACZ" + +msgid "button.off" +msgstr "WYLACZ" + +msgid "button.press" +msgstr "URUCHOM" + +msgid "action.all_off" +msgstr "{target} wylacz wszystko" + +msgid "action.manual_white" +msgstr "{target} biale swiatlo reczne" + +msgid "action.red_blue" +msgstr "{target} czerwono-niebieskie" + +msgid "action.siren" +msgstr "{target} syrena" diff --git a/tests/test_discovery_mapping.py b/tests/test_discovery_mapping.py new file mode 100644 index 0000000..2306bbb --- /dev/null +++ b/tests/test_discovery_mapping.py @@ -0,0 +1,86 @@ +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 + + +class DiscoveryMappingTest(unittest.TestCase): + def setUp(self): + self.bridge = load_bridge_module() + + def test_loopback_ffmpeg_path_resolves_through_go2rtc(self): + camera_cfg = { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://127.0.0.1:8554/stream-a?video=copy"}, + ] + } + } + streams = { + "stream-a": [ + "rtsp://camera-a.example.test:554/Streaming/Channels/101", + ] + } + + urls = self.bridge.CameraControlBridge.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"]) + + def test_direct_ffmpeg_path_is_used_without_go2rtc(self): + camera_cfg = { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://camera-b.example.test:554/cam/realmonitor?channel=1&subtype=0"}, + ] + } + } + + urls = self.bridge.CameraControlBridge.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"]) + + def test_direct_path_wins_when_loopback_is_also_present(self): + camera_cfg = { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://127.0.0.1:8554/stream-a"}, + {"path": "rtsp://direct-camera.example.test:554/Streaming/Channels/102"}, + ] + } + } + streams = { + "stream-a": { + "streams": [ + "rtsp://camera-a.example.test:554/Streaming/Channels/101", + ] + } + } + + urls = self.bridge.CameraControlBridge.camera_candidate_urls("camera-a", camera_cfg, streams) + + self.assertEqual( + urls, + [ + "rtsp://direct-camera.example.test:554/Streaming/Channels/102", + ], + ) + self.assertEqual( + self.bridge.CameraControlBridge.hosts_from_urls(urls), + [ + "direct-camera.example.test", + ], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/compile_gettext.py b/tools/compile_gettext.py new file mode 100755 index 0000000..365b333 --- /dev/null +++ b/tools/compile_gettext.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +import ast +import struct +import sys +from pathlib import Path + + +def read_po(path): + messages = {} + current = None + buffers = {"msgid": [], "msgstr": []} + + def flush(): + if buffers["msgid"] or buffers["msgstr"]: + messages["".join(buffers["msgid"])] = "".join(buffers["msgstr"]) + buffers["msgid"] = [] + buffers["msgstr"] = [] + + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("msgid "): + flush() + current = "msgid" + buffers[current].append(ast.literal_eval(line[6:].strip())) + continue + if line.startswith("msgstr "): + current = "msgstr" + buffers[current].append(ast.literal_eval(line[7:].strip())) + continue + if line.startswith('"') and current: + buffers[current].append(ast.literal_eval(line)) + flush() + return messages + + +def write_mo(messages, path): + keys = sorted(messages) + ids = b"\x00".join(key.encode("utf-8") for key in keys) + b"\x00" + strs = b"\x00".join(messages[key].encode("utf-8") for key in keys) + b"\x00" + + keystart = 7 * 4 + len(keys) * 16 + valuestart = keystart + len(ids) + koffsets = [] + voffsets = [] + offset = keystart + for key in keys: + encoded = key.encode("utf-8") + koffsets.append((len(encoded), offset)) + offset += len(encoded) + 1 + offset = valuestart + for key in keys: + encoded = messages[key].encode("utf-8") + voffsets.append((len(encoded), offset)) + offset += len(encoded) + 1 + + output = [ + struct.pack("Iiiiiii", 0x950412DE, 0, len(keys), 7 * 4, 7 * 4 + len(keys) * 8, 0, 0) + ] + output.extend(struct.pack("ii", length, offset) for length, offset in koffsets) + output.extend(struct.pack("ii", length, offset) for length, offset in voffsets) + output.append(ids) + output.append(strs) + path.write_bytes(b"".join(output)) + + +def main(): + root = Path(sys.argv[1] if len(sys.argv) > 1 else "locale") + for po in root.glob("*/LC_MESSAGES/*.po"): + mo = po.with_suffix(".mo") + write_mo(read_po(po), mo) + print(mo) + + +if __name__ == "__main__": + main()