diff --git a/frigate_camera_control_bridge/app.py b/frigate_camera_control_bridge/app.py index 8bc4088..30659b0 100644 --- a/frigate_camera_control_bridge/app.py +++ b/frigate_camera_control_bridge/app.py @@ -350,7 +350,7 @@ class Action: if not key: return self.name - prefix = self.cfg.get("camera_name") or self.cfg.get("frigate_name") or "" + prefix = self.cfg.get("camera_display_name") or self.cfg.get("camera_name") or self.cfg.get("frigate_name") or "" return text_for(normalized, key, target=prefix).strip() def command_list(self): @@ -807,12 +807,14 @@ class CameraControlBridge: urls = self.camera_candidate_urls(camera_name, camera_cfg, streams) endpoints = self.camera_endpoints_from_urls(urls) kinds = self.kinds_from_urls(urls) + camera_display_name = self.camera_display_name(camera_name, camera_cfg) for host in endpoints: inventory.append( { "frigate": name, "frigate_url": base_url, "camera": camera_name, + "camera_display_name": camera_display_name, "host": host, "kinds": kinds, } @@ -820,6 +822,15 @@ class CameraControlBridge: LOG.info("discovered %d camera host mapping(s) from Frigate", len(inventory)) return inventory + @staticmethod + def camera_display_name(camera_name, camera_cfg): + # Frigate camera keys remain the API identifier. friendly_name is only + # used for human-facing labels when present. + friendly_name = camera_cfg.get("friendly_name") + if friendly_name: + return str(friendly_name) + return camera_name + def load_frigate_config_with_retry(self, frigate, base_url): discovery = self.config.get("discovery", {}) wait = bool(discovery.get("wait_for_frigate", True)) @@ -1081,6 +1092,7 @@ class CameraControlBridge: "name": text_for(lang, "action.manual_white", target=item["camera"]), "name_key": "action.manual_white", "camera_name": item["camera"], + "camera_display_name": item.get("camera_display_name", item["camera"]), "platform": "switch", "kind": "hikvision_colorvu", "host": host, @@ -1089,7 +1101,7 @@ class CameraControlBridge: "snapshot": self.snapshot_config(item), "device": { "identifiers": [f"camera_{host.replace('.', '_')}"], - "name": item["camera"], + "name": item.get("camera_display_name", item["camera"]), "manufacturer": "Hikvision", "model": model, "configuration_url": item["frigate_url"], @@ -1120,6 +1132,7 @@ class CameraControlBridge: "name": text_for(lang, "action.red_blue", target=item["camera"]), "name_key": "action.red_blue", "camera_name": item["camera"], + "camera_display_name": item.get("camera_display_name", item["camera"]), "platform": "switch", "kind": "dahua_coaxial_io", "host": host, @@ -1138,6 +1151,7 @@ class CameraControlBridge: "name": text_for(lang, "action.siren", target=item["camera"]), "name_key": "action.siren", "camera_name": item["camera"], + "camera_display_name": item.get("camera_display_name", item["camera"]), "platform": "switch", "kind": "dahua_coaxial_io", "host": host, @@ -1182,7 +1196,7 @@ class CameraControlBridge: def discovered_device(item, manufacturer, model): return { "identifiers": [f"camera_{item['host'].replace('.', '_')}"], - "name": item["camera"], + "name": item.get("camera_display_name", item["camera"]), "manufacturer": manufacturer, "model": model, "configuration_url": item["frigate_url"], @@ -1192,6 +1206,7 @@ class CameraControlBridge: config = { "frigate_url": item["frigate_url"], "camera": item["camera"], + "camera_display_name": item.get("camera_display_name", item["camera"]), } defaults = self.config.get("defaults", {}) if defaults.get("frigate_public_url"): @@ -1413,6 +1428,8 @@ class CameraControlBridge: action = self.action_by_id(action_id) if action is None: raise RuntimeError(f"unknown action {action_id}") + if command == "on" and self.auto_off_running(action): + raise RuntimeError("action is already active until automatic turn-off") result = action.run(command, bridge=self) if action.platform == "switch": if command == "on": @@ -1428,6 +1445,10 @@ class CameraControlBridge: LOG.warning("action %s %s worked, but MQTT publish failed: %s", action_id, command, exc) return result + @staticmethod + def auto_off_running(action): + return bool(getattr(action, "auto_off_at", None)) + def schedule_auto_off(self, action): seconds = int(action.auto_off_seconds or 0) if seconds <= 0: @@ -1723,7 +1744,9 @@ class CameraControlBridge: if parsed.path.startswith("/snapshot/"): action_id = urllib.parse.unquote(parsed.path.rsplit("/", 1)[-1]).removesuffix(".jpg") try: - live = bool(bridge.config.get("defaults", {}).get("snapshot_live_on_gui", True)) + values = urllib.parse.parse_qs(parsed.query) + refresh = values.get("refresh", ["0"])[0].lower() in ("1", "true", "yes", "on") + live = refresh or bool(bridge.config.get("defaults", {}).get("snapshot_live_on_gui", True)) snapshot = bridge.snapshot_for_action(action_id, live=live) except Exception as exc: LOG.info("snapshot request failed for %s: %s", action_id, exc) diff --git a/frigate_camera_control_bridge/static/app.css b/frigate_camera_control_bridge/static/app.css index 5729797..1d285ee 100644 --- a/frigate_camera_control_bridge/static/app.css +++ b/frigate_camera_control_bridge/static/app.css @@ -213,6 +213,45 @@ select { object-fit: cover; } +.snapshot-refresh { + position: absolute; + top: 12px; + right: 12px; + display: grid; + width: 38px; + height: 38px; + place-items: center; + border: 1px solid rgba(255, 255, 255, 0.7); + border-radius: 6px; + background: rgba(15, 23, 42, 0.72); + color: #ffffff; + cursor: pointer; + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.24); +} + +.snapshot-refresh:hover { + background: rgba(15, 23, 42, 0.88); +} + +.snapshot-refresh:focus-visible { + outline: 3px solid rgba(29, 78, 216, 0.35); + outline-offset: 2px; +} + +.snapshot-refresh.is-refreshing svg { + animation: refresh-spin 0.9s linear infinite; +} + +.snapshot-refresh svg { + width: 20px; + height: 20px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2; +} + .media-empty { display: grid; place-items: center; @@ -514,6 +553,13 @@ h3 { outline-offset: 2px; } +.cmd:disabled { + border-color: var(--line); + background: #eef2f6; + color: #8290a2; + cursor: not-allowed; +} + .cmd-on, .cmd-press { border-color: var(--blue); @@ -521,12 +567,24 @@ h3 { color: #ffffff; } +.cmd-on:disabled { + border-color: var(--line); + background: #eef2f6; + color: #8290a2; +} + .cmd-on:hover, .cmd-press:hover { border-color: var(--blue-dark); background: var(--blue-dark); } +.cmd-on:disabled:hover { + border-color: var(--line); + background: #eef2f6; + color: #8290a2; +} + .cmd-off { border-color: #9dd8bd; background: #ffffff; diff --git a/frigate_camera_control_bridge/static/app.js b/frigate_camera_control_bridge/static/app.js index 74f129b..2aeaf1e 100644 --- a/frigate_camera_control_bridge/static/app.js +++ b/frigate_camera_control_bridge/static/app.js @@ -100,6 +100,39 @@ async function handleRefreshSubmit(form, event) { } } +function refreshSnapshot(button) { + if (button.classList.contains("is-refreshing")) { + return; + } + const media = button.closest(".media"); + const image = media?.querySelector("img.snapshot"); + if (!image) { + return; + } + const url = new URL(image.currentSrc || image.src, window.location.href); + url.searchParams.set("refresh", "1"); + url.searchParams.set("v", String(Date.now())); + const cleanup = () => { + button.disabled = false; + button.classList.remove("is-refreshing"); + button.removeAttribute("aria-busy"); + }; + button.disabled = true; + button.classList.add("is-refreshing"); + button.setAttribute("aria-busy", "true"); + image.addEventListener("load", cleanup, { once: true }); + image.addEventListener("error", cleanup, { once: true }); + image.src = url.toString(); +} + +document.addEventListener("click", (event) => { + const button = event.target.closest(".snapshot-refresh"); + if (!(button instanceof HTMLButtonElement)) { + return; + } + refreshSnapshot(button); +}); + document.addEventListener("submit", (event) => { const form = event.target; if (!(form instanceof HTMLFormElement)) { diff --git a/frigate_camera_control_bridge/ui.py b/frigate_camera_control_bridge/ui.py index 3b77038..4b6b527 100644 --- a/frigate_camera_control_bridge/ui.py +++ b/frigate_camera_control_bridge/ui.py @@ -110,7 +110,9 @@ def camera_group_key(action): def camera_group_name(action, lang): return ( - action.cfg.get("camera_name") + action.cfg.get("camera_display_name") + or (action.cfg.get("snapshot") or {}).get("camera_display_name") + or action.cfg.get("camera_name") or (action.cfg.get("snapshot") or {}).get("camera") or action.cfg.get("device", {}).get("name") or action.display_name(lang) @@ -140,6 +142,7 @@ def snapshot_markup_for_group(group, lang, token_query): actions = group["actions"] action = next((item for item in actions if item.cfg.get("snapshot")), actions[0]) icons = group_visual_markup(actions, lang) + refresh_label = text_for(lang, "button.refresh_snapshot") snapshot_markup = ( '
' f"{icons}" @@ -157,6 +160,15 @@ def snapshot_markup_for_group(group, lang, token_query): f'' + f'" f"{icons}" "
" ) @@ -296,9 +308,17 @@ def render_action_buttons(action, lang, token_query): ' data-requires-ack="true"' f' data-ack-message="{html.escape(text_for(lang, "ack.siren_on"))}"' ) + disabled_attrs = "" + if command == "on" and getattr(action, "auto_off_at", None): + disabled_label = text_for(lang, "button.on_blocked_timer") + disabled_attrs = ( + " disabled" + f' title="{html.escape(disabled_label)}"' + f' aria-label="{html.escape(disabled_label)}"' + ) buttons.append( f'
' - f'
" ) diff --git a/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po b/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po index 79e33f4..820c7c5 100644 --- a/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po +++ b/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po @@ -95,6 +95,12 @@ msgstr "PRESS" msgid "button.refresh_cameras" msgstr "Refresh cameras" +msgid "button.refresh_snapshot" +msgstr "Refresh snapshot" + +msgid "button.on_blocked_timer" +msgstr "Already active until automatic turn-off" + msgid "link.open_stream" msgstr "Open Frigate stream" diff --git a/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po b/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po index 6bc3ba6..ebb109e 100644 --- a/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po +++ b/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po @@ -95,6 +95,12 @@ msgstr "URUCHOM" msgid "button.refresh_cameras" msgstr "Odswiez kamery" +msgid "button.refresh_snapshot" +msgstr "Odswiez obraz" + +msgid "button.on_blocked_timer" +msgstr "Juz wlaczone do czasu automatycznego wylaczenia" + msgid "link.open_stream" msgstr "Otworz stream we Frigate" diff --git a/tests/test_refresh_summary.py b/tests/test_refresh_summary.py index f4638d0..41c42cc 100644 --- a/tests/test_refresh_summary.py +++ b/tests/test_refresh_summary.py @@ -7,6 +7,9 @@ class DummyAction: def __init__(self, action_id, camera=None, kind="hikvision_colorvu", name_key=None): self.id = action_id self.kind = kind + self.platform = "switch" + self.auto_off_at = None + self.run_called = False self.cfg = {} if name_key: self.cfg["name_key"] = name_key @@ -16,6 +19,10 @@ class DummyAction: "camera": camera, } + def run(self, command, bridge=None): + self.run_called = True + return {"ok": True, "command": command} + class RefreshSummaryTest(unittest.TestCase): def test_camera_keys_ignore_global_actions(self): @@ -28,6 +35,18 @@ class RefreshSummaryTest(unittest.TestCase): self.assertEqual(keys, {("http://frigate.example.test:5000", "one")}) + def test_camera_display_name_uses_friendly_name(self): + self.assertEqual( + CameraControlBridge.camera_display_name("camera_key", {"friendly_name": "Camera Friendly"}), + "Camera Friendly", + ) + + def test_camera_display_name_falls_back_to_config_name(self): + self.assertEqual( + CameraControlBridge.camera_display_name("camera_key", {}), + "camera_key", + ) + def test_refresh_message_reports_camera_changes_first(self): message = CameraControlBridge.refresh_message( "en", @@ -64,6 +83,17 @@ class RefreshSummaryTest(unittest.TestCase): self.assertFalse(CameraControlBridge.should_refresh_snapshot_after_command(siren, "on")) self.assertFalse(CameraControlBridge.should_refresh_snapshot_after_command(white, "press")) + def test_execute_action_rejects_second_on_while_timer_runs(self): + action = DummyAction("white", "one", name_key="action.manual_white") + action.auto_off_at = 1234567890 + bridge = object.__new__(CameraControlBridge) + bridge.action_by_id = lambda action_id: action + + with self.assertRaisesRegex(RuntimeError, "already active"): + CameraControlBridge.execute_action(bridge, "white", "on", publish=False) + + self.assertFalse(action.run_called) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_ui_render.py b/tests/test_ui_render.py index 2079f94..355a4f9 100644 --- a/tests/test_ui_render.py +++ b/tests/test_ui_render.py @@ -37,6 +37,9 @@ class UiRenderTest(unittest.TestCase): self.assertIn('data-requires-ack="true"', html) self.assertIn("Example camera", html) self.assertIn("v=42", html) + self.assertIn('class="snapshot-refresh"', html) + self.assertIn('title="Refresh snapshot"', html) + self.assertIn('disabled title="Already active until automatic turn-off"', html) def test_controls_from_same_camera_share_one_card(self): actions = [ @@ -92,6 +95,25 @@ class UiRenderTest(unittest.TestCase): "https://frigate.example.test/#example", ) + def test_on_button_is_available_when_no_auto_off_timer_runs(self): + action = DummyAction() + action.auto_off_at = None + + html = render_page([action], "en") + + self.assertIn('class="cmd cmd-on"', html) + self.assertNotIn('Already active until automatic turn-off', html) + + def test_camera_card_uses_friendly_display_name(self): + action = DummyAction() + action.cfg["camera_name"] = "camera_key" + action.cfg["camera_display_name"] = "Camera Friendly" + + html = render_page([action], "en") + + self.assertIn("

Camera Friendly

", html) + self.assertNotIn("

camera_key

", html) + if __name__ == "__main__": unittest.main()