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
|
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"]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
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:
|
||||||
@@ -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
|
# 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
@@ -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"
|
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"
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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