Modularize bridge and improve control GUI
This commit is contained in:
+2
-1
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user