Modularize bridge and improve control GUI

This commit is contained in:
2026-08-03 00:55:12 +02:00
parent 73b302a965
commit f4ab49e0c8
26 changed files with 3104 additions and 1953 deletions
+2 -1
View File
@@ -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"]
+90 -149
View File
@@ -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/<lang>/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/<lang>/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://<ip-frigate>:5011/
http://<ip-frigate>:5011/?lang=pl
http://<ip-frigate>:5011/api/actions
http://<ip-frigate>:5011/snapshot/<action_id>.jpg
http://<ip-frigate>:5011/healthz
http://<ip-frigate>:5011/readyz
```
Jesli ustawiono `HTTP_TOKEN`, dodaj `?token=<token>` albo naglowek
`X-Bridge-Token: <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/<lang>/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://<frigate-ip>:5011/
@@ -185,10 +106,30 @@ http://<frigate-ip>:5011/healthz
http://<frigate-ip>:5011/readyz
```
Kubernetes sidecar notes:
If `HTTP_TOKEN` is set, pass `?token=<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.
+12
View File
@@ -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
+61
View File
@@ -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
```
@@ -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
@@ -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
+36
View File
@@ -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:
+37
View File
@@ -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.
@@ -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:
+130
View File
@@ -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
-31
View File
@@ -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`,
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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"]
@@ -0,0 +1,4 @@
from .cli import main
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
+50
View File
@@ -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
+81
View File
@@ -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
@@ -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%;
}
}
@@ -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);
@@ -0,0 +1,33 @@
<!doctype html>
<html lang="{lang}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<main class="shell">
<div class="topbar">
<div class="title-block">
<h1>{title}</h1>
<div class="summary"><strong>{action_count}</strong><span>{actions_label}</span></div>
</div>
<div class="toolbar">
<form method="get" action="/" class="language-form">
<label for="lang">{language_label}</label>
{token_input}
<select id="lang" name="lang" onchange="this.form.submit()">
{language_options}
</select>
<noscript><button type="submit">OK</button></noscript>
</form>
</div>
</div>
<section class="control-grid">
{cards}
</section>
</main>
<script src="/static/app.js" defer></script>
</body>
</html>
+217
View File
@@ -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": (
'<svg viewBox="0 0 64 64" aria-hidden="true">'
'<path d="M18 42V28a14 14 0 0 1 28 0v14Z" fill="currentColor"/>'
'<path d="M14 42h36v8H14Z" fill="currentColor" opacity=".85"/>'
'<path d="M25 20a9 9 0 0 1 14 0" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round" opacity=".9"/>'
'<path d="M32 6v7M12 14l6 6M52 14l-6 6" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>'
"</svg>"
),
}
if name_key == "action.red_blue":
return {
"kind": "red-blue",
"label": text_for(lang, "visual.red_blue"),
"svg": (
'<svg viewBox="0 0 64 64" aria-hidden="true">'
'<path d="M14 26a8 8 0 0 1 8-8h20a8 8 0 0 1 8 8v16H14Z" fill="#fff" opacity=".96"/>'
'<path d="M14 28h18v14H14Z" fill="#ef4444"/>'
'<path d="M32 28h18v14H32Z" fill="#2563eb"/>'
'<path d="M12 42h40v7H12Z" fill="#111827" opacity=".65"/>'
'<path d="M10 16l-5-5M54 16l5-5M32 11V4" fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round"/>'
"</svg>"
),
}
if name_key == "action.manual_white":
return {
"kind": "white-light",
"label": text_for(lang, "visual.white_light"),
"svg": (
'<svg viewBox="0 0 64 64" aria-hidden="true">'
'<path d="M24 39h16v7a8 8 0 0 1-16 0Z" fill="currentColor" opacity=".85"/>'
'<path d="M22 29a10 10 0 1 1 20 0c0 5-4 8-6 11h-8c-2-3-6-6-6-11Z" fill="currentColor"/>'
'<path d="M32 5v8M14 14l6 6M50 14l-6 6M9 32h8M47 32h8" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>'
'<path d="M27 52h10" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>'
"</svg>"
),
}
if action.kind == "all_off":
return {
"kind": "all-off",
"label": text_for(lang, "visual.all_off"),
"svg": (
'<svg viewBox="0 0 64 64" aria-hidden="true">'
'<path d="M32 9v22" fill="none" stroke="currentColor" stroke-width="6" stroke-linecap="round"/>'
'<path d="M21 18a20 20 0 1 0 22 0" fill="none" stroke="currentColor" stroke-width="6" stroke-linecap="round"/>'
"</svg>"
),
}
return {
"kind": "generic",
"label": text_for(lang, "visual.generic"),
"svg": (
'<svg viewBox="0 0 64 64" aria-hidden="true">'
'<path d="M12 24h40v24H12Z" fill="currentColor" opacity=".9"/>'
'<path d="M21 18h14l4 6H21Z" fill="currentColor"/>'
'<circle cx="32" cy="36" r="8" fill="#fff" opacity=".92"/>'
'<circle cx="32" cy="36" r="4" fill="currentColor"/>'
"</svg>"
),
}
def visual_markup(action, lang):
visual = action_visual(action, lang)
return (
f'<div class="visual-badge visual-{html.escape(visual["kind"])}" '
f'role="img" aria-label="{html.escape(visual["label"])}">'
f'{visual["svg"]}</div>'
)
def render_card(action, lang, token_query):
display_name = action.display_name(lang)
icon = visual_markup(action, lang)
snapshot_markup = (
'<div class="media media-empty">'
f"{icon}"
f'<span>{html.escape(text_for(lang, "no_preview"))}</span>'
"</div>"
)
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 = (
'<div class="media">'
f'<img class="snapshot" src="{html.escape(snapshot_url)}" '
f'alt="{html.escape(display_name)}" loading="lazy" '
'onerror="this.parentElement.classList.add(\'media-empty\');this.remove()">'
f"{icon}"
"</div>"
)
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'<form class="command-form" method="post" action="{html.escape(action_url)}"{ack_attrs}>'
f'<button class="cmd cmd-{html.escape(command)}" type="submit">'
f"<span>{html.escape(label)}</span>"
"</button></form>"
)
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'<span class="timer" data-auto-off-at="{int(action.auto_off_at)}" '
f'data-prefix="{html.escape(text_for(lang, "timer.auto_off"))}" '
f'data-expired-label="{html.escape(text_for(lang, "timer.expired"))}">'
f'{html.escape(text_for(lang, "timer.auto_off"))}</span>'
)
else:
timer_markup = (
f'<span class="timer timer-idle">'
f'{html.escape(text_for(lang, "timer.auto_off_configured", seconds=action.auto_off_seconds))}'
"</span>"
)
return (
'<article class="action-card">'
f"{snapshot_markup}"
'<div class="card-body">'
'<div class="card-main">'
f"<h2>{html.escape(display_name)}</h2>"
f'<code class="action-id">{html.escape(action.id)}</code>'
"</div>"
'<div class="meta-row">'
f'<span class="chip">{html.escape(platform_label)}</span>'
f'<span class="chip chip-state chip-state-{html.escape(state_value)}">'
f"{html.escape(state_label)}</span>"
"</div>"
f"{timer_markup}"
f'<div class="command-row">{"".join(buttons)}</div>'
"</div>"
"</article>"
)
def render_language_options(lang):
options = []
for code, label in available_languages().items():
selected = " selected" if code == lang else ""
options.append(f'<option value="{html.escape(code)}"{selected}>{html.escape(label)}</option>')
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'<section class="empty-state">{html.escape(text_for(lang, "no_actions"))}</section>'
token_input = (
f'<input type="hidden" name="token" value="{html.escape(token_param)}">'
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,
)
@@ -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"
@@ -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"
+12 -21
View File
@@ -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",
],
+37
View File
@@ -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()