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 = ( '