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 ENV UI_LANGUAGE=en
COPY frigate-camera-control-bridge.py /app/frigate-camera-control-bridge.py 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 locale /app/locale
COPY tools/compile_gettext.py /app/tools/compile_gettext.py 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()" 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 # 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. ## Design
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: - One image works for Podman now and Kubernetes later.
- Ten sam obraz obsluguje Podman dzisiaj i Kubernetes pozniej. - Runtime configuration is environment-based; no Frigate config mount is needed.
- Konfiguracja runtime jest przez zmienne srodowiskowe, bez montowania pliku - The sidecar waits for Frigate startup and retries `/api/config`.
konfiguracyjnego Frigate. - The sidecar periodically refreshes `/api/config`, so added or removed cameras
- Sidecar czeka na Frigate przy starcie i ponawia odczyt `/api/config`. can update MQTT Discovery without a container restart.
- Jesli `ffmpeg.inputs[].path` wskazuje na `rtsp://127.0.0.1:8554/...`, - On startup it runs a safe `all-off` by default, sets switch entities to `OFF`,
sidecar traktuje to jako brame go2rtc i szuka prawdziwego URL-a kamery w 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`. `go2rtc.streams`.
- Aktualny Podman uzywa pliku env per sidecar, np. - If Frigate uses a direct non-loopback stream URL in `ffmpeg.inputs[].path`,
`/etc/frigate-camera-control-bridge/frigate-main.env`. the sidecar uses that direct URL and does not resolve go2rtc in parallel.
- Kubernetes powinien podawac `MQTT_HOST`, `MQTT_USER`, `MQTT_PASSWORD`, - In env mode, missing MQTT or camera credentials are startup errors; the
`DEFAULT_CAMERA_USER`, `DEFAULT_CAMERA_PASSWORD` przez `Secret`/`ConfigMap`. sidecar does not look up credentials in Quadlets or Frigate config.
- W trybie env brak tych wartosci jest bledem startu; sidecar nie szuka - The fallback GUI listens on `5011` by default because `5001` may already be
credentiali w Quadletach ani w konfiguracji Frigate. used by Frigate.
- GUI fallback domyslnie slucha na `5011`, bo `5001` jest juz uzywany przez - GUI languages use standard gettext files:
czesc instancji Frigate. `locale/<lang>/LC_MESSAGES/*.po` and `.mo`.
- Jezyki GUI sa standardowym gettext: `locale/<lang>/LC_MESSAGES/*.po` i `.mo`.
Podstawowe zmienne: ## Environment
| Zmienna | Znaczenie | | Variable | Meaning |
| --- | --- | | --- | --- |
| `FRIGATE_NAME` | Nazwa tej instancji, np. `frigate-main`. | | `FRIGATE_NAME` | Instance name, for example `frigate-main`. |
| `FRIGATE_URL` | URL API Frigate, np. `http://frigate-main.example.test:5000`. | | `FRIGATE_URL` | Frigate API URL, for example `http://frigate-main.example.test:5000`. |
| `MQTT_HOST`, `MQTT_PORT` | Broker MQTT. | | `MQTT_HOST`, `MQTT_PORT` | MQTT broker. |
| `MQTT_USER`, `MQTT_PASSWORD` | Credentiale MQTT. | | `MQTT_USER`, `MQTT_PASSWORD` | MQTT credentials. |
| `DEFAULT_CAMERA_USER`, `DEFAULT_CAMERA_PASSWORD` | Domyslne credentiale kamer odkrytych z Frigate. | | `DEFAULT_CAMERA_USER`, `DEFAULT_CAMERA_PASSWORD` | Default credentials for cameras discovered from Frigate. |
| `BASE_TOPIC` | Bazowy topic MQTT, np. `frigate_camera_control/frigate-main`. | | `BASE_TOPIC` | MQTT base topic, for example `frigate_camera_control/frigate-main`. |
| `DISCOVERY_PREFIX` | Prefix Home Assistant MQTT Discovery, zwykle `homeassistant`. | | `DISCOVERY_PREFIX` | Home Assistant MQTT Discovery prefix, usually `homeassistant`. |
| `UI_LANGUAGE` | Domyslny jezyk GUI i encji HA, domyslnie `en`. | | `UI_LANGUAGE` | Default GUI and HA entity language, defaults to `en`. |
| `HTTP_PORT` | Port GUI fallback, domyslnie `5011`. | | `HTTP_PORT` | Fallback GUI port, defaults to `5011`. |
| `HTTP_TOKEN` | Opcjonalny token dla GUI/API HTTP. | | `HTTP_TOKEN` | Optional HTTP GUI/API token. |
| `SNAPSHOT_RETRY_SECONDS` | Co ile probowac ponownie, gdy migawki brakuja, domyslnie `600`. | | `FRIGATE_REFRESH_SECONDS` | Frigate config refresh interval, defaults to `300`; `0` disables it. |
| `SNAPSHOT_REFRESH_SECONDS` | Co ile odswiezac poprawnie pobrane migawki, domyslnie `3600`. | | `SNAPSHOT_RETRY_SECONDS` | Retry interval while a snapshot is missing, defaults to `600`. |
| `SNAPSHOT_LIVE_ON_GUI` | Czy pobierac swieza migawke z Frigate przy otwieraniu GUI, domyslnie `true`. | | `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`: ## Build
```bash
MQTT_HOST=mqtt.example.test
MQTT_PORT=1883
MQTT_USER=...
MQTT_PASSWORD=...
DEFAULT_CAMERA_USER=admin
DEFAULT_CAMERA_PASSWORD=...
```
Budowanie:
```bash ```bash
docker build -t frigate-camera-control-bridge:1.0 . docker build -t frigate-camera-control-bridge:1.0 .
docker build -f Dockerfile.dev -t frigate-camera-control-bridge:dev . docker build -f Dockerfile.dev -t frigate-camera-control-bridge:dev .
``` ```
Przyklady uruchomienia: ## Run
- Podman Quadlet: `examples/podman-quadlet/`
- Kubernetes sidecar: `examples/kubernetes-sidecar.yaml`
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 ```bash
docker run --rm \ docker run --rm \
@@ -81,100 +95,7 @@ docker run --rm \
frigate-camera-control-bridge:1.0 --check frigate-camera-control-bridge:1.0 --check
``` ```
Jezyki: ## HTTP GUI
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:
```text ```text
http://<frigate-ip>:5011/ http://<frigate-ip>:5011/
@@ -185,10 +106,30 @@ http://<frigate-ip>:5011/healthz
http://<frigate-ip>:5011/readyz 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` - Put the bridge container in the same pod as Frigate, or point `FRIGATE_URL`
at the Frigate service. at the Frigate service.
- Use `readinessProbe` on `/readyz`. - Use `readinessProbe` on `/readyz`.
- Pass secrets through env vars from `Secret`. - Pass secrets through env vars from `Secret`.
- Keep `/var/lib/frigate-camera-control-bridge` on a small persistent volume if - 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_RETRY_SECONDS=600
SNAPSHOT_REFRESH_SECONDS=3600 SNAPSHOT_REFRESH_SECONDS=3600
SNAPSHOT_LIVE_ON_GUI=true 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 value: frigate_camera_control/frigate-main
- name: UI_LANGUAGE - name: UI_LANGUAGE
value: en value: en
- name: FRIGATE_REFRESH_SECONDS
value: "300"
- name: SNAPSHOT_RETRY_SECONDS - name: SNAPSHOT_RETRY_SECONDS
value: "600" value: "600"
- name: SNAPSHOT_REFRESH_SECONDS - name: SNAPSHOT_REFRESH_SECONDS
value: "3600" value: "3600"
- name: SNAPSHOT_LIVE_ON_GUI - name: SNAPSHOT_LIVE_ON_GUI
value: "true" 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 - name: MQTT_USER
valueFrom: valueFrom:
secretKeyRef: 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 # 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 This is a generic example for running `frigate-camera-control-bridge` with
Podman Quadlet. The files model one Frigate instance. For multiple instances, Podman Quadlet. The files model one Frigate instance. For multiple instances,
copy the `.container` and `.env` files and change `FRIGATE_NAME`, copy the `.container` and `.env` files and change `FRIGATE_NAME`,
@@ -15,6 +15,7 @@ UI_LANGUAGE=en
WAIT_FOR_FRIGATE=true WAIT_FOR_FRIGATE=true
FRIGATE_RETRY_SECONDS=5 FRIGATE_RETRY_SECONDS=5
FRIGATE_STARTUP_WAIT_SECONDS=0 FRIGATE_STARTUP_WAIT_SECONDS=0
FRIGATE_REFRESH_SECONDS=300
SNAPSHOT_ENABLED=true SNAPSHOT_ENABLED=true
SNAPSHOT_HEIGHT=180 SNAPSHOT_HEIGHT=180
@@ -22,3 +23,9 @@ SNAPSHOT_CACHE_SECONDS=300
SNAPSHOT_RETRY_SECONDS=600 SNAPSHOT_RETRY_SECONDS=600
SNAPSHOT_REFRESH_SECONDS=3600 SNAPSHOT_REFRESH_SECONDS=3600
SNAPSHOT_LIVE_ON_GUI=true 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" msgid "snapshot"
msgstr "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" msgid "button.on"
msgstr "ON" msgstr "ON"
@@ -29,6 +29,54 @@ msgstr "Akcje"
msgid "snapshot" msgid "snapshot"
msgstr "Migawka" 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" msgid "button.on"
msgstr "WLACZ" msgstr "WLACZ"
+12 -21
View File
@@ -1,20 +1,11 @@
import importlib.util
import unittest import unittest
from pathlib import Path
from frigate_camera_control_bridge.app import CameraControlBridge
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): class DiscoveryMappingTest(unittest.TestCase):
def setUp(self): def setUp(self):
self.bridge = load_bridge_module() self.bridge = CameraControlBridge
def test_loopback_ffmpeg_path_resolves_through_go2rtc(self): def test_loopback_ffmpeg_path_resolves_through_go2rtc(self):
camera_cfg = { 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(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): def test_wrapped_go2rtc_stream_resolves_inner_camera_url(self):
camera_cfg = { 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( self.assertEqual(
urls, urls,
@@ -57,7 +48,7 @@ class DiscoveryMappingTest(unittest.TestCase):
"rtsp://camera-a.example.test:554/Streaming/Channels/101?profile=main#hardware", "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): def test_direct_ffmpeg_path_is_used_without_go2rtc(self):
camera_cfg = { 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(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): def test_wrapped_direct_ffmpeg_path_is_used_without_go2rtc(self):
camera_cfg = { 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( self.assertEqual(
urls, urls,
@@ -90,7 +81,7 @@ class DiscoveryMappingTest(unittest.TestCase):
"rtsp://camera-b.example.test:554/cam/realmonitor?channel=1&subtype=0#video=copy", "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): def test_direct_path_wins_when_loopback_is_also_present(self):
camera_cfg = { 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( self.assertEqual(
urls, urls,
@@ -118,7 +109,7 @@ class DiscoveryMappingTest(unittest.TestCase):
], ],
) )
self.assertEqual( self.assertEqual(
self.bridge.CameraControlBridge.hosts_from_urls(urls), self.bridge.hosts_from_urls(urls),
[ [
"direct-camera.example.test", "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()