From 4ea737024b7937222fe103fd72b959e965474020 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Kacprzak?= Date: Mon, 3 Aug 2026 01:28:56 +0200 Subject: [PATCH] Refresh snapshots after light actions --- README.md | 2 + .../docker-compose/camera-control.env.example | 2 + .../docker-compose/compose.sidecar-only.yaml | 2 + examples/docker-compose/compose.yaml | 2 + examples/kubernetes/same-pod.yaml | 4 ++ examples/kubernetes/two-pods.yaml | 4 ++ .../frigate-camera-control-bridge.env.example | 2 + frigate_camera_control_bridge/app.py | 37 ++++++++++++-- frigate_camera_control_bridge/static/app.css | 39 +++++++++++++++ frigate_camera_control_bridge/ui.py | 48 ++++++++++++++++++- .../frigate_camera_control_bridge.po | 3 ++ .../frigate_camera_control_bridge.po | 3 ++ tests/test_refresh_summary.py | 14 +++++- tests/test_ui_render.py | 21 +++++++- 14 files changed, 177 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1afe91b..5f37e96 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ through Home Assistant MQTT Discovery plus an emergency HTTP fallback GUI. | --- | --- | | `FRIGATE_NAME` | Instance name, for example `frigate-main`. | | `FRIGATE_URL` | Frigate API URL, for example `http://frigate-main.example.test:5000`. | +| `FRIGATE_PUBLIC_URL` | Optional public GUI URL used by camera stream links, for example a reverse proxy URL. If omitted, stream links use `FRIGATE_URL`. | +| `FRIGATE_CAMERA_PATH_TEMPLATE` | Optional camera page path or full URL template, defaults to `/#{camera_quoted}`. Available placeholders: `{camera}`, `{camera_quoted}`, `{frigate_url}`, `{frigate_public_url}`. | | `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. | diff --git a/examples/docker-compose/camera-control.env.example b/examples/docker-compose/camera-control.env.example index 0092b14..6ef1171 100644 --- a/examples/docker-compose/camera-control.env.example +++ b/examples/docker-compose/camera-control.env.example @@ -8,6 +8,8 @@ DEFAULT_CAMERA_PASSWORD=change-me HTTP_PORT=5011 UI_LANGUAGE=en +FRIGATE_PUBLIC_URL= +FRIGATE_CAMERA_PATH_TEMPLATE=/#{camera_quoted} WAIT_FOR_FRIGATE=true FRIGATE_RETRY_SECONDS=5 FRIGATE_STARTUP_WAIT_SECONDS=0 diff --git a/examples/docker-compose/compose.sidecar-only.yaml b/examples/docker-compose/compose.sidecar-only.yaml index 9f8cd46..3b96da0 100644 --- a/examples/docker-compose/compose.sidecar-only.yaml +++ b/examples/docker-compose/compose.sidecar-only.yaml @@ -11,6 +11,8 @@ services: FRIGATE_NAME: frigate-main # Set this to the Frigate container DNS name or another reachable URL. FRIGATE_URL: http://frigate:5000 + # Optional public Frigate GUI URL for stream links shown in the bridge GUI. + FRIGATE_PUBLIC_URL: "" BASE_TOPIC: frigate_camera_control/frigate-main ports: - "5011:5011" diff --git a/examples/docker-compose/compose.yaml b/examples/docker-compose/compose.yaml index 575cb0f..b0277e8 100644 --- a/examples/docker-compose/compose.yaml +++ b/examples/docker-compose/compose.yaml @@ -26,6 +26,8 @@ services: # Because both containers share the same Compose network, the sidecar can # reach Frigate by service name. FRIGATE_URL: http://frigate:5000 + # Optional public Frigate GUI URL for stream links shown in the bridge GUI. + FRIGATE_PUBLIC_URL: "" BASE_TOPIC: frigate_camera_control/frigate-main ports: - "5011:5011" diff --git a/examples/kubernetes/same-pod.yaml b/examples/kubernetes/same-pod.yaml index 8f9844a..7c6a74c 100644 --- a/examples/kubernetes/same-pod.yaml +++ b/examples/kubernetes/same-pod.yaml @@ -25,6 +25,10 @@ spec: value: frigate-main - name: FRIGATE_URL value: http://127.0.0.1:5000 + - name: FRIGATE_PUBLIC_URL + value: "" + - name: FRIGATE_CAMERA_PATH_TEMPLATE + value: /#{camera_quoted} - name: MQTT_HOST value: mqtt.default.svc.cluster.local - name: BASE_TOPIC diff --git a/examples/kubernetes/two-pods.yaml b/examples/kubernetes/two-pods.yaml index c70a641..289c6d0 100644 --- a/examples/kubernetes/two-pods.yaml +++ b/examples/kubernetes/two-pods.yaml @@ -53,6 +53,10 @@ spec: value: frigate-main - name: FRIGATE_URL value: http://frigate:5000 + - name: FRIGATE_PUBLIC_URL + value: "" + - name: FRIGATE_CAMERA_PATH_TEMPLATE + value: /#{camera_quoted} - name: MQTT_HOST value: mqtt.default.svc.cluster.local - name: BASE_TOPIC diff --git a/examples/podman-quadlet/frigate-camera-control-bridge.env.example b/examples/podman-quadlet/frigate-camera-control-bridge.env.example index fcb3e94..1cdcbea 100644 --- a/examples/podman-quadlet/frigate-camera-control-bridge.env.example +++ b/examples/podman-quadlet/frigate-camera-control-bridge.env.example @@ -12,6 +12,8 @@ DEFAULT_CAMERA_PASSWORD=change-me HTTP_PORT=5011 UI_LANGUAGE=en +FRIGATE_PUBLIC_URL= +FRIGATE_CAMERA_PATH_TEMPLATE=/#{camera_quoted} WAIT_FOR_FRIGATE=true FRIGATE_RETRY_SECONDS=5 FRIGATE_STARTUP_WAIT_SECONDS=0 diff --git a/frigate_camera_control_bridge/app.py b/frigate_camera_control_bridge/app.py index c5b749e..8bc4088 100644 --- a/frigate_camera_control_bridge/app.py +++ b/frigate_camera_control_bridge/app.py @@ -235,6 +235,11 @@ def config_from_env(): "snapshot_retry_seconds": int(os.environ.get("SNAPSHOT_RETRY_SECONDS", "600")), "snapshot_refresh_seconds": int(os.environ.get("SNAPSHOT_REFRESH_SECONDS", "3600")), "snapshot_live_on_gui": env_bool("SNAPSHOT_LIVE_ON_GUI", True), + "frigate_public_url": os.environ.get("FRIGATE_PUBLIC_URL"), + "frigate_camera_path_template": os.environ.get( + "FRIGATE_CAMERA_PATH_TEMPLATE", + "/#{camera_quoted}", + ), "frigate_refresh_seconds": int(os.environ.get("FRIGATE_REFRESH_SECONDS", "300")), "auto_off_siren_seconds": int(os.environ.get("AUTO_OFF_SIREN_SECONDS", "180")), "auto_off_red_blue_seconds": int(os.environ.get("AUTO_OFF_RED_BLUE_SECONDS", "300")), @@ -333,6 +338,7 @@ class Action: self.state = None self.auto_off_seconds = int(cfg.get("auto_off_seconds") or 0) self.auto_off_at = None + self.snapshot_version = 0 def display_name(self, lang): names = self.cfg.get("name_i18n") or {} @@ -714,6 +720,7 @@ class CameraControlBridge: continue action.state = old.state action.auto_off_at = old.auto_off_at + action.snapshot_version = old.snapshot_version def initialize_action_states(self): for action in self.actions.values(): @@ -1181,12 +1188,17 @@ class CameraControlBridge: "configuration_url": item["frigate_url"], } - @staticmethod - def snapshot_config(item): - return { + def snapshot_config(self, item): + config = { "frigate_url": item["frigate_url"], "camera": item["camera"], } + defaults = self.config.get("defaults", {}) + if defaults.get("frigate_public_url"): + config["public_url"] = defaults["frigate_public_url"] + if defaults.get("frigate_camera_path_template"): + config["camera_path_template"] = defaults["frigate_camera_path_template"] + return config @staticmethod def xml_text(xml, tag): @@ -1243,6 +1255,23 @@ class CameraControlBridge: LOG.info("snapshot unavailable for %s: %s", action.id, exc) return ok + @staticmethod + def should_refresh_snapshot_after_command(action, command): + if command.lower() not in ("on", "off", "safe_off"): + return False + return action.cfg.get("name_key") in ("action.manual_white", "action.red_blue") + + def invalidate_action_snapshot(self, action): + url = self.snapshot_url(action) + if not url: + return + with self.action_lock: + self.snapshot_cache.pop(url, None) + version = int(time.time() * 1000) + for item in self.actions.values(): + if self.snapshot_url(item) == url: + item.snapshot_version = version + def snapshot_url(self, action): snapshot = action.cfg.get("snapshot") if not snapshot: @@ -1390,6 +1419,8 @@ class CameraControlBridge: self.schedule_auto_off(action) elif command in ("off", "safe_off"): self.cancel_auto_off(action.id) + if self.should_refresh_snapshot_after_command(action, command): + self.invalidate_action_snapshot(action) if publish: try: self.publish_result(action, command, result) diff --git a/frigate_camera_control_bridge/static/app.css b/frigate_camera_control_bridge/static/app.css index 8d6574e..5729797 100644 --- a/frigate_camera_control_bridge/static/app.css +++ b/frigate_camera_control_bridge/static/app.css @@ -302,6 +302,13 @@ select { min-width: 0; } +.card-title-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 34px; + gap: 8px; + align-items: start; +} + h2 { margin: 0 0 8px; font-size: 1.02rem; @@ -310,6 +317,38 @@ h2 { overflow-wrap: anywhere; } +.stream-link { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border: 1px solid var(--line); + border-radius: 6px; + background: #ffffff; + color: #1f3a5f; + text-decoration: none; +} + +.stream-link:hover { + border-color: var(--blue); + color: var(--blue-dark); +} + +.stream-link:focus-visible { + outline: 3px solid rgba(29, 78, 216, 0.25); + outline-offset: 2px; +} + +.stream-link svg { + width: 18px; + height: 18px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2; +} + .camera-count { display: inline-flex; color: var(--muted); diff --git a/frigate_camera_control_bridge/ui.py b/frigate_camera_control_bridge/ui.py index 148dafb..3b77038 100644 --- a/frigate_camera_control_bridge/ui.py +++ b/frigate_camera_control_bridge/ui.py @@ -149,7 +149,8 @@ def snapshot_markup_for_group(group, lang, token_query): if action.cfg.get("snapshot"): snapshot_url = ( f"/snapshot/{urllib.parse.quote(action.id)}.jpg" - f"?lang={urllib.parse.quote(lang)}{token_query}" + f"?lang={urllib.parse.quote(lang)}" + f"&v={int(getattr(action, 'snapshot_version', 0) or 0)}{token_query}" ) snapshot_markup = ( '
' @@ -162,6 +163,48 @@ def snapshot_markup_for_group(group, lang, token_query): return snapshot_markup +def camera_stream_url(action): + snapshot = action.cfg.get("snapshot") or {} + base_url = snapshot.get("public_url") or snapshot.get("frigate_url") + camera = snapshot.get("camera") + if not base_url or not camera: + return None + template = snapshot.get("camera_path_template") or "/#{camera_quoted}" + values = { + "camera": camera, + "camera_quoted": urllib.parse.quote(camera, safe=""), + "frigate_url": snapshot.get("frigate_url", "").rstrip("/"), + "frigate_public_url": base_url.rstrip("/"), + } + try: + link = template.format(**values) + except KeyError: + link = template + if urllib.parse.urlsplit(link).scheme: + return link + return f"{base_url.rstrip('/')}/{link.lstrip('/')}" + + +def camera_stream_link(group, lang): + action = next((item for item in group["actions"] if item.cfg.get("snapshot")), None) + if not action: + return "" + url = camera_stream_url(action) + if not url: + return "" + label = text_for(lang, "link.open_stream") + return ( + f'' + '" + "" + ) + + def render_action_control(action, lang, token_query): display_name = action_label(action, lang) @@ -213,7 +256,10 @@ def render_group_card(group, lang, token_query): f"{snapshot_markup_for_group(group, lang, token_query)}" '
' '
' + '
' f"

{html.escape(group['name'])}

" + f"{camera_stream_link(group, lang)}" + "
" f'{html.escape(text_for(lang, "camera.controls_count", count=len(group["actions"])))}' "
" f'
{controls}
' diff --git a/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po b/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po index df691ef..79e33f4 100644 --- a/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po +++ b/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po @@ -95,6 +95,9 @@ msgstr "PRESS" msgid "button.refresh_cameras" msgstr "Refresh cameras" +msgid "link.open_stream" +msgstr "Open Frigate stream" + msgid "refresh.working" msgstr "Refreshing cameras..." diff --git a/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po b/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po index 1c3bdaa..6bc3ba6 100644 --- a/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po +++ b/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po @@ -95,6 +95,9 @@ msgstr "URUCHOM" msgid "button.refresh_cameras" msgstr "Odswiez kamery" +msgid "link.open_stream" +msgstr "Otworz stream we Frigate" + msgid "refresh.working" msgstr "Odswiezanie kamer..." diff --git a/tests/test_refresh_summary.py b/tests/test_refresh_summary.py index b308cd7..f4638d0 100644 --- a/tests/test_refresh_summary.py +++ b/tests/test_refresh_summary.py @@ -4,10 +4,12 @@ from frigate_camera_control_bridge.app import CameraControlBridge class DummyAction: - def __init__(self, action_id, camera=None, kind="hikvision_colorvu"): + def __init__(self, action_id, camera=None, kind="hikvision_colorvu", name_key=None): self.id = action_id self.kind = kind self.cfg = {} + if name_key: + self.cfg["name_key"] = name_key if camera: self.cfg["snapshot"] = { "frigate_url": "http://frigate.example.test:5000", @@ -52,6 +54,16 @@ class RefreshSummaryTest(unittest.TestCase): self.assertEqual(message, "No camera changes found.") + def test_light_actions_refresh_snapshot_after_on_off(self): + white = DummyAction("white", "one", name_key="action.manual_white") + red_blue = DummyAction("red_blue", "one", name_key="action.red_blue") + siren = DummyAction("siren", "one", name_key="action.siren") + + self.assertTrue(CameraControlBridge.should_refresh_snapshot_after_command(white, "on")) + self.assertTrue(CameraControlBridge.should_refresh_snapshot_after_command(red_blue, "off")) + self.assertFalse(CameraControlBridge.should_refresh_snapshot_after_command(siren, "on")) + self.assertFalse(CameraControlBridge.should_refresh_snapshot_after_command(white, "press")) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_ui_render.py b/tests/test_ui_render.py index 72b6613..2079f94 100644 --- a/tests/test_ui_render.py +++ b/tests/test_ui_render.py @@ -1,6 +1,6 @@ import unittest -from frigate_camera_control_bridge.ui import render_page +from frigate_camera_control_bridge.ui import camera_stream_url, render_page class DummyAction: @@ -11,6 +11,7 @@ class DummyAction: self.state = state self.auto_off_seconds = 180 self.auto_off_at = 1234567890 + self.snapshot_version = 42 self.cfg = { "name_key": name_key, "camera_name": "Example camera", @@ -35,6 +36,7 @@ class UiRenderTest(unittest.TestCase): self.assertIn('data-auto-off-at="1234567890"', html) self.assertIn('data-requires-ack="true"', html) self.assertIn("Example camera", html) + self.assertIn("v=42", html) def test_controls_from_same_camera_share_one_card(self): actions = [ @@ -73,6 +75,23 @@ class UiRenderTest(unittest.TestCase): self.assertIn('data-working-label="Refreshing cameras..."', html) self.assertIn("