diff --git a/frigate_camera_control_bridge/app.py b/frigate_camera_control_bridge/app.py index 1deddf4..c5b749e 100644 --- a/frigate_camera_control_bridge/app.py +++ b/frigate_camera_control_bridge/app.py @@ -1500,10 +1500,66 @@ class CameraControlBridge: self.inherit_action_runtime_state(old_actions, new_actions) self.actions = new_actions self.refresh_snapshots() + old_ids = set(old_actions) + new_ids = set(new_actions) + old_cameras = self.camera_keys_from_actions(old_actions.values()) + new_cameras = self.camera_keys_from_actions(new_actions.values()) + result = { + "ok": True, + "added_actions": len(new_ids - old_ids), + "removed_actions": len(old_ids - new_ids), + "added_cameras": len(new_cameras - old_cameras), + "removed_cameras": len(old_cameras - new_cameras), + "total_actions": len(new_ids), + "total_cameras": len(new_cameras), + "mqtt_published": True, + } + result["changed"] = any( + result[key] > 0 + for key in ("added_actions", "removed_actions", "added_cameras", "removed_cameras") + ) try: self.publish_discovery() except Exception as exc: + result["mqtt_published"] = False LOG.warning("Frigate refresh could not publish MQTT discovery: %s", exc) + return result + + @staticmethod + def camera_keys_from_actions(actions): + keys = set() + for action in actions: + if action.kind == "all_off": + continue + snapshot = action.cfg.get("snapshot") or {} + camera = snapshot.get("camera") or action.cfg.get("camera_name") + if camera: + keys.add((snapshot.get("frigate_url"), camera)) + return keys + + @staticmethod + def refresh_message(lang, result): + if result.get("added_cameras") or result.get("removed_cameras"): + return text_for( + lang, + "refresh.cameras_changed", + added=result.get("added_cameras", 0), + removed=result.get("removed_cameras", 0), + ) + if result.get("added_actions") or result.get("removed_actions"): + return text_for( + lang, + "refresh.controls_changed", + added=result.get("added_actions", 0), + removed=result.get("removed_actions", 0), + ) + return text_for(lang, "refresh.no_changes") + + def refresh_payload(self, lang, result, redirect_url): + payload = dict(result) + payload["message"] = self.refresh_message(lang, result) + payload["redirect_url"] = redirect_url + return payload def frigate_refresh_loop(self): interval = int(self.config.get("defaults", {}).get("frigate_refresh_seconds", 300)) @@ -1661,17 +1717,33 @@ class CameraControlBridge: self.end_headers() return if parsed.path == "/refresh": + token_param = urllib.parse.parse_qs(parsed.query).get("token", [None])[0] + token_query = f"&token={urllib.parse.quote(token_param)}" if token_param else "" + redirect_url = f"/?lang={urllib.parse.quote(lang)}{token_query}" + wants_json = "application/json" in self.headers.get("Accept", "").lower() try: - bridge.refresh_frigate_inventory() - token_param = urllib.parse.parse_qs(parsed.query).get("token", [None])[0] - token_query = f"&token={urllib.parse.quote(token_param)}" if token_param else "" - self.send_response(303) - self.send_header("Location", f"/?lang={urllib.parse.quote(lang)}{token_query}") - self.end_headers() + result = bridge.refresh_frigate_inventory() + if wants_json: + self.send_json(bridge.refresh_payload(lang, result, redirect_url)) + else: + self.send_response(303) + self.send_header("Location", redirect_url) + self.end_headers() except Exception as exc: - self.send_response(500) - self.end_headers() - self.wfile.write(str(exc).encode("utf-8")) + if wants_json: + self.send_json( + { + "ok": False, + "changed": False, + "message": text_for(lang, "refresh.failed", error=str(exc)), + "redirect_url": redirect_url, + }, + status=500, + ) + else: + self.send_response(500) + self.end_headers() + self.wfile.write(str(exc).encode("utf-8")) return parts = parsed.path.strip("/").split("/") if len(parts) == 3 and parts[0] == "action": @@ -1692,9 +1764,9 @@ class CameraControlBridge: self.send_response(404) self.end_headers() - def send_json(self, payload): + def send_json(self, payload, status=200): data = json.dumps(payload, indent=2).encode("utf-8") - self.send_response(200) + self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(data))) self.end_headers() diff --git a/frigate_camera_control_bridge/static/app.css b/frigate_camera_control_bridge/static/app.css index 815753d..8d6574e 100644 --- a/frigate_camera_control_bridge/static/app.css +++ b/frigate_camera_control_bridge/static/app.css @@ -113,6 +113,36 @@ h1 { stroke-width: 2; } +.refresh-button.is-refreshing svg { + animation: refresh-spin 0.9s linear infinite; +} + +.refresh-status { + display: none; + max-width: 260px; + color: var(--muted); + font-size: 0.86rem; + line-height: 1.25; +} + +.refresh-status.is-visible { + display: inline-flex; +} + +.refresh-status.is-success { + color: var(--green-dark); +} + +.refresh-status.is-error { + color: var(--red); +} + +@keyframes refresh-spin { + to { + transform: rotate(360deg); + } +} + .language-form { display: inline-flex; align-items: center; @@ -558,6 +588,10 @@ h3 { margin-left: auto; } + .refresh-status { + max-width: 100%; + } + .grid-single, .control-grid { grid-template-columns: 1fr; diff --git a/frigate_camera_control_bridge/static/app.js b/frigate_camera_control_bridge/static/app.js index 2fd2061..74f129b 100644 --- a/frigate_camera_control_bridge/static/app.js +++ b/frigate_camera_control_bridge/static/app.js @@ -22,9 +22,94 @@ function updateTimers() { }); } +function refreshStatusElement() { + return document.querySelector("[data-refresh-status]"); +} + +function showRefreshMessage(message, type = "success") { + const status = refreshStatusElement(); + if (!status) { + return; + } + status.textContent = message || ""; + status.classList.toggle("is-visible", Boolean(message)); + status.classList.toggle("is-success", type === "success"); + status.classList.toggle("is-error", type === "error"); +} + +function rememberRefreshMessage(message) { + try { + window.sessionStorage.setItem("frigateRefreshMessage", message); + } catch (_error) { + // Some browsers may block sessionStorage; the refresh still succeeds. + } +} + +function restoreRefreshMessage() { + try { + const message = window.sessionStorage.getItem("frigateRefreshMessage"); + if (!message) { + return; + } + window.sessionStorage.removeItem("frigateRefreshMessage"); + showRefreshMessage(message, "success"); + } catch (_error) { + // Ignore storage errors and keep the GUI usable. + } +} + +async function handleRefreshSubmit(form, event) { + event.preventDefault(); + const button = form.querySelector(".refresh-button"); + if (button && button.classList.contains("is-refreshing")) { + return; + } + const status = refreshStatusElement(); + const workingLabel = status?.dataset.workingLabel || "Refreshing cameras..."; + showRefreshMessage(workingLabel, "success"); + if (button) { + button.disabled = true; + button.classList.add("is-refreshing"); + button.setAttribute("aria-busy", "true"); + } + try { + const response = await fetch(form.action, { + method: "POST", + headers: { Accept: "application/json" }, + }); + const payload = await response.json(); + if (!response.ok || payload.ok === false) { + throw new Error(payload.message || "Refresh failed"); + } + showRefreshMessage(payload.message, "success"); + if (payload.changed && payload.redirect_url) { + rememberRefreshMessage(payload.message); + window.setTimeout(() => { + window.location.assign(payload.redirect_url); + }, 900); + return; + } + } catch (error) { + showRefreshMessage(error.message || "Refresh failed", "error"); + } finally { + if (button) { + button.disabled = false; + button.classList.remove("is-refreshing"); + button.removeAttribute("aria-busy"); + } + } +} + document.addEventListener("submit", (event) => { const form = event.target; - if (!(form instanceof HTMLFormElement) || form.dataset.requiresAck !== "true") { + if (!(form instanceof HTMLFormElement)) { + return; + } + if (form.classList.contains("refresh-form")) { + handleRefreshSubmit(form, event); + return; + } + if (form.dataset.requiresAck !== "true") { return; } const message = form.dataset.ackMessage || "Confirm this action"; @@ -34,4 +119,5 @@ document.addEventListener("submit", (event) => { }); updateTimers(); +restoreRefreshMessage(); setInterval(updateTimers, 1000); diff --git a/frigate_camera_control_bridge/templates/index.html b/frigate_camera_control_bridge/templates/index.html index 2be9233..24dd099 100644 --- a/frigate_camera_control_bridge/templates/index.html +++ b/frigate_camera_control_bridge/templates/index.html @@ -25,6 +25,7 @@ +
{token_input} diff --git a/frigate_camera_control_bridge/ui.py b/frigate_camera_control_bridge/ui.py index 849b112..148dafb 100644 --- a/frigate_camera_control_bridge/ui.py +++ b/frigate_camera_control_bridge/ui.py @@ -307,6 +307,7 @@ def render_page(actions, lang, token_param=None): actions_label=html.escape(text_for(lang, "cameras")), language_label=html.escape(text_for(lang, "language")), refresh_label=html.escape(text_for(lang, "button.refresh_cameras")), + refresh_working_label=html.escape(text_for(lang, "refresh.working")), language_options=render_language_options(lang), token_input=token_input, token_query=html.escape(token_query), diff --git a/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po b/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po index 0f20297..df691ef 100644 --- a/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po +++ b/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po @@ -95,6 +95,21 @@ msgstr "PRESS" msgid "button.refresh_cameras" msgstr "Refresh cameras" +msgid "refresh.working" +msgstr "Refreshing cameras..." + +msgid "refresh.no_changes" +msgstr "No camera changes found." + +msgid "refresh.cameras_changed" +msgstr "Cameras updated: +{added}, -{removed}." + +msgid "refresh.controls_changed" +msgstr "Camera controls updated: +{added}, -{removed}." + +msgid "refresh.failed" +msgstr "Camera refresh failed: {error}" + msgid "action.all_off" msgstr "{target} all off" diff --git a/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po b/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po index 1215f42..1c3bdaa 100644 --- a/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po +++ b/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po @@ -95,6 +95,21 @@ msgstr "URUCHOM" msgid "button.refresh_cameras" msgstr "Odswiez kamery" +msgid "refresh.working" +msgstr "Odswiezanie kamer..." + +msgid "refresh.no_changes" +msgstr "Nie wykryto zmian w kamerach." + +msgid "refresh.cameras_changed" +msgstr "Kamery zaktualizowane: +{added}, -{removed}." + +msgid "refresh.controls_changed" +msgstr "Kontrolki kamer zaktualizowane: +{added}, -{removed}." + +msgid "refresh.failed" +msgstr "Odswiezanie kamer nie powiodlo sie: {error}" + msgid "action.all_off" msgstr "{target} wylacz wszystko" diff --git a/tests/test_refresh_summary.py b/tests/test_refresh_summary.py new file mode 100644 index 0000000..b308cd7 --- /dev/null +++ b/tests/test_refresh_summary.py @@ -0,0 +1,57 @@ +import unittest + +from frigate_camera_control_bridge.app import CameraControlBridge + + +class DummyAction: + def __init__(self, action_id, camera=None, kind="hikvision_colorvu"): + self.id = action_id + self.kind = kind + self.cfg = {} + if camera: + self.cfg["snapshot"] = { + "frigate_url": "http://frigate.example.test:5000", + "camera": camera, + } + + +class RefreshSummaryTest(unittest.TestCase): + def test_camera_keys_ignore_global_actions(self): + keys = CameraControlBridge.camera_keys_from_actions( + [ + DummyAction("one_light", "one"), + DummyAction("all_off", kind="all_off"), + ] + ) + + self.assertEqual(keys, {("http://frigate.example.test:5000", "one")}) + + def test_refresh_message_reports_camera_changes_first(self): + message = CameraControlBridge.refresh_message( + "en", + { + "added_cameras": 1, + "removed_cameras": 0, + "added_actions": 2, + "removed_actions": 0, + }, + ) + + self.assertEqual(message, "Cameras updated: +1, -0.") + + def test_refresh_message_reports_no_changes(self): + message = CameraControlBridge.refresh_message( + "en", + { + "added_cameras": 0, + "removed_cameras": 0, + "added_actions": 0, + "removed_actions": 0, + }, + ) + + self.assertEqual(message, "No camera changes found.") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ui_render.py b/tests/test_ui_render.py index 71da650..72b6613 100644 --- a/tests/test_ui_render.py +++ b/tests/test_ui_render.py @@ -69,6 +69,8 @@ class UiRenderTest(unittest.TestCase): self.assertIn('action="/refresh?lang=en&token=secret"', html) self.assertIn('title="Refresh cameras"', html) self.assertIn('aria-label="Refresh cameras"', html) + self.assertIn('data-refresh-status', html) + self.assertIn('data-working-label="Refreshing cameras..."', html) self.assertIn("