diff --git a/frigate_camera_control_bridge/app.py b/frigate_camera_control_bridge/app.py index 3b2accf..d290090 100644 --- a/frigate_camera_control_bridge/app.py +++ b/frigate_camera_control_bridge/app.py @@ -1636,6 +1636,22 @@ class CameraControlBridge: payload["redirect_url"] = redirect_url return payload + def action_payload(self, lang, action, command, result, redirect_url): + ok = bool(result.get("ok", True)) + payload = { + "ok": ok, + "command": command, + "message": text_for(lang, "action.success" if ok else "action.failed_generic"), + "redirect_url": redirect_url, + "result": result, + } + if action is not None: + payload["action_id"] = action.id + payload["action"] = self.api_action(action, lang) + if action.kind == "all_off": + payload["actions"] = [self.api_action(item, lang) for item in self.actions_snapshot()] + return payload + def frigate_refresh_loop(self): interval = int(self.config.get("defaults", {}).get("frigate_refresh_seconds", 300)) if interval <= 0: @@ -1826,17 +1842,36 @@ class CameraControlBridge: if len(parts) == 3 and parts[0] == "action": action_id = urllib.parse.unquote(parts[1]) command = urllib.parse.unquote(parts[2]).lower() + 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: result = bridge.execute_action(action_id, command, publish=True) - 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() + action = bridge.action_by_id(action_id) + if wants_json: + payload = bridge.action_payload(lang, action, command, result, redirect_url) + self.send_json(payload, status=200 if payload.get("ok") else 500) + 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, + "action_id": action_id, + "command": command, + "message": text_for(lang, "action.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 self.send_response(404) self.end_headers() @@ -1874,20 +1909,27 @@ class CameraControlBridge: LOG.info("HTTP fallback GUI listening on %s:%s", bind, port) def api_actions(self): - return [ - { - "id": action.id, - "name": action.display_name(self.default_language), - "platform": action.platform, - "commands": action.command_list(), - "state": action.state, - "snapshot": bool(action.cfg.get("snapshot")), - "auto_off_seconds": action.auto_off_seconds, - "auto_off_at": int(action.auto_off_at) if action.auto_off_at else None, - "requires_ack": bool(action.cfg.get("requires_ack")), - } - for action in self.actions_snapshot() - ] + return [self.api_action(action, self.default_language) for action in self.actions_snapshot()] + + def api_action(self, action, lang=None): + lang = lang or self.default_language + 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() + return { + "id": action.id, + "name": action.display_name(lang), + "platform": action.platform, + "commands": action.command_list(), + "state": action.state, + "state_label": state_label, + "snapshot": bool(action.cfg.get("snapshot")), + "snapshot_version": int(action.snapshot_version or 0), + "auto_off_seconds": action.auto_off_seconds, + "auto_off_at": int(action.auto_off_at) if action.auto_off_at else None, + "requires_ack": bool(action.cfg.get("requires_ack")), + } def html_page(self, lang=None, token_param=None): with self.action_lock: diff --git a/frigate_camera_control_bridge/static/app.css b/frigate_camera_control_bridge/static/app.css index 1d285ee..873b070 100644 --- a/frigate_camera_control_bridge/static/app.css +++ b/frigate_camera_control_bridge/static/app.css @@ -560,6 +560,10 @@ h3 { cursor: not-allowed; } +.cmd.is-working { + opacity: 0.82; +} + .cmd-on, .cmd-press { border-color: var(--blue); @@ -596,6 +600,26 @@ h3 { background: #edfdf5; } +.action-status { + display: none; + color: var(--muted); + font-size: 0.84rem; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.action-status.is-visible { + display: block; +} + +.action-status.is-success { + color: var(--green-dark); +} + +.action-status.is-error { + color: var(--red); +} + .global-strip { flex: 1; display: flex; @@ -646,6 +670,12 @@ h3 { min-width: 120px; } +.global-controls-wrap { + display: grid; + min-width: 120px; + gap: 8px; +} + .empty-state { border: 1px dashed var(--line-strong); border-radius: 8px; diff --git a/frigate_camera_control_bridge/static/app.js b/frigate_camera_control_bridge/static/app.js index 52f670d..68f93cb 100644 --- a/frigate_camera_control_bridge/static/app.js +++ b/frigate_camera_control_bridge/static/app.js @@ -129,6 +129,190 @@ function refreshSnapshot(button) { }, SNAPSHOT_REFRESH_DELAY_MS); } +function actionContainerById(actionId) { + for (const container of document.querySelectorAll("[data-action-id]")) { + if (container.dataset.actionId === actionId) { + return container; + } + } + return null; +} + +function actionStatusForForm(form) { + return form.closest(".action-control, .global-action")?.querySelector("[data-action-status]"); +} + +function showActionMessage(form, message, type = "success") { + const status = actionStatusForForm(form); + 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 setCommandBusy(button, busy) { + if (!button) { + return; + } + if (busy) { + button.disabled = true; + button.classList.add("is-working"); + button.setAttribute("aria-busy", "true"); + return; + } + if (button.dataset.timerLocked !== "true") { + button.disabled = false; + } + button.classList.remove("is-working"); + button.removeAttribute("aria-busy"); +} + +function labelFromStateChip(chip, state) { + const key = `label${state.charAt(0).toUpperCase()}${state.slice(1)}`; + return chip.dataset[key] || state.toUpperCase(); +} + +function updateStateChip(container, action) { + const chip = container.querySelector("[data-state-chip]"); + if (!chip || !action.state) { + return; + } + const state = String(action.state).toLowerCase(); + for (const className of Array.from(chip.classList)) { + if (className.startsWith("chip-state-")) { + chip.classList.remove(className); + } + } + chip.classList.add(`chip-state-${state}`); + chip.textContent = action.state_label || labelFromStateChip(chip, state); +} + +function updateTimer(container, action) { + const timer = container.querySelector("[data-timer]"); + if (!timer) { + return; + } + if (action.auto_off_at) { + timer.dataset.autoOffAt = String(action.auto_off_at); + timer.classList.remove("timer-idle", "timer-expired"); + updateTimers(); + return; + } + delete timer.dataset.autoOffAt; + timer.classList.add("timer-idle"); + timer.classList.remove("timer-expired"); + timer.textContent = timer.dataset.idleLabel || ""; +} + +function updateOnButtonLock(container, action) { + const button = container.querySelector(".cmd-on"); + if (!button) { + return; + } + if (action.auto_off_at) { + const label = button.dataset.disabledLabel || "Already active until automatic turn-off"; + button.dataset.timerLocked = "true"; + button.disabled = true; + button.title = label; + button.setAttribute("aria-label", label); + return; + } + delete button.dataset.timerLocked; + if (!button.classList.contains("is-working")) { + button.disabled = false; + } + button.removeAttribute("title"); + button.removeAttribute("aria-label"); +} + +function applyActionState(action) { + if (!action?.id) { + return; + } + const container = actionContainerById(action.id); + if (!container) { + return; + } + updateStateChip(container, action); + updateTimer(container, action); + updateOnButtonLock(container, action); +} + +function applyActionPayload(payload) { + if (Array.isArray(payload.actions)) { + payload.actions.forEach(applyActionState); + return; + } + applyActionState(payload.action); +} + +function refreshSnapshotForCommand(form) { + const button = form.closest(".camera-card")?.querySelector(".snapshot-refresh"); + if (!button || button.classList.contains("is-refreshing")) { + return; + } + refreshSnapshot(button); +} + +async function responseJson(response) { + const text = await response.text(); + if (!text) { + return {}; + } + try { + return JSON.parse(text); + } catch (_error) { + return { ok: false, message: text }; + } +} + +function commandErrorMessage(form, error) { + const fallback = form.dataset.failedLabel || "Command failed."; + if (!error) { + return fallback; + } + if (error.name === "TypeError") { + return form.dataset.networkFailedLabel || fallback; + } + return error.message || fallback; +} + +async function handleCommandSubmit(form, event) { + event.preventDefault(); + if (form.dataset.requiresAck === "true") { + const message = form.dataset.ackMessage || "Confirm this action"; + if (!window.confirm(message)) { + return; + } + } + const button = form.querySelector(".cmd"); + if (button?.classList.contains("is-working")) { + return; + } + showActionMessage(form, form.dataset.workingLabel || "Sending command...", "success"); + setCommandBusy(button, true); + try { + const response = await fetch(form.action, { + method: "POST", + headers: { Accept: "application/json" }, + }); + const payload = await responseJson(response); + applyActionPayload(payload); + if (!response.ok || payload.ok === false) { + throw new Error(payload.message || form.dataset.failedLabel || "Command failed."); + } + showActionMessage(form, payload.message || form.dataset.successLabel || "Command sent.", "success"); + refreshSnapshotForCommand(form); + } catch (error) { + showActionMessage(form, commandErrorMessage(form, error), "error"); + } finally { + setCommandBusy(button, false); + } +} + document.addEventListener("click", (event) => { const button = event.target.closest(".snapshot-refresh"); if (!(button instanceof HTMLButtonElement)) { @@ -146,13 +330,10 @@ document.addEventListener("submit", (event) => { handleRefreshSubmit(form, event); return; } - if (form.dataset.requiresAck !== "true") { + if (form.classList.contains("command-form")) { + handleCommandSubmit(form, event); return; } - const message = form.dataset.ackMessage || "Confirm this action"; - if (!window.confirm(message)) { - event.preventDefault(); - } }); updateTimers(); diff --git a/frigate_camera_control_bridge/templates/index.html b/frigate_camera_control_bridge/templates/index.html index 24dd099..72f9099 100644 --- a/frigate_camera_control_bridge/templates/index.html +++ b/frigate_camera_control_bridge/templates/index.html @@ -4,7 +4,7 @@ {title} - +
@@ -40,6 +40,6 @@ {cards}
- + diff --git a/frigate_camera_control_bridge/ui.py b/frigate_camera_control_bridge/ui.py index 4b6b527..21550a5 100644 --- a/frigate_camera_control_bridge/ui.py +++ b/frigate_camera_control_bridge/ui.py @@ -1,3 +1,4 @@ +import hashlib import html import urllib.parse from pathlib import Path @@ -19,6 +20,13 @@ def read_static_file(name): return path.read_bytes() +def static_asset_version(name): + payload = read_static_file(name) + if payload is None: + return "missing" + return hashlib.sha256(payload).hexdigest()[:12] + + 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 @@ -229,33 +237,42 @@ def render_action_control(action, lang, token_query): platform_label = action.platform timer_markup = "" if action.auto_off_seconds > 0: + idle_label = text_for(lang, "timer.auto_off_configured", seconds=action.auto_off_seconds) if action.auto_off_at: timer_markup = ( - f'' f'{html.escape(text_for(lang, "timer.auto_off"))}' ) else: timer_markup = ( - f'' - f'{html.escape(text_for(lang, "timer.auto_off_configured", seconds=action.auto_off_seconds))}' + f'' + f'{html.escape(idle_label)}' "" ) return ( - '
' + f'
' '
' f"{visual_markup(action, lang)}" f"

{html.escape(display_name)}

" "
" '
' f'{html.escape(platform_label)}' - f'' + f'' f"{html.escape(state_label)}" "
" f"{timer_markup}" f'
{render_action_buttons(action, lang, token_query)}
' + '' f'{html.escape(action.id)}' "
" ) @@ -281,7 +298,7 @@ def render_group_card(group, lang, token_query): def render_global_action(action, lang, token_query): return ( - '
' + f'
' '
' f"{visual_markup(action, lang)}" '
' @@ -289,7 +306,10 @@ def render_global_action(action, lang, token_query): f'{html.escape(action.id)}' "
" "
" + '
' f'
{render_action_buttons(action, lang, token_query)}
' + '' + "
" "
" ) @@ -308,16 +328,24 @@ def render_action_buttons(action, lang, token_query): ' data-requires-ack="true"' f' data-ack-message="{html.escape(text_for(lang, "ack.siren_on"))}"' ) + common_attrs = ( + f' data-working-label="{html.escape(text_for(lang, "action.working"))}"' + f' data-success-label="{html.escape(text_for(lang, "action.success"))}"' + f' data-failed-label="{html.escape(text_for(lang, "action.failed_generic"))}"' + f' data-network-failed-label="{html.escape(text_for(lang, "action.network_failed"))}"' + ) disabled_attrs = "" + disabled_label = text_for(lang, "button.on_blocked_timer") + if command == "on": + common_attrs += f' data-disabled-label="{html.escape(disabled_label)}"' 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'' f'
" @@ -377,6 +405,8 @@ def render_page(actions, lang, token_param=None): language_options=render_language_options(lang), token_input=token_input, token_query=html.escape(token_query), + css_version=html.escape(static_asset_version("app.css")), + js_version=html.escape(static_asset_version("app.js")), global_actions=global_markup, grid_class=html.escape(grid_class), cards=cards_markup, diff --git a/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po b/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po index 820c7c5..46cbdfa 100644 --- a/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po +++ b/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po @@ -119,6 +119,21 @@ msgstr "Camera controls updated: +{added}, -{removed}." msgid "refresh.failed" msgstr "Camera refresh failed: {error}" +msgid "action.working" +msgstr "Sending command..." + +msgid "action.success" +msgstr "Command sent." + +msgid "action.failed_generic" +msgstr "Command failed." + +msgid "action.failed" +msgstr "Command failed: {error}" + +msgid "action.network_failed" +msgstr "Could not reach the bridge. Check network access from this device." + 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 ebb109e..eab91c0 100644 --- a/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po +++ b/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po @@ -119,6 +119,21 @@ msgstr "Kontrolki kamer zaktualizowane: +{added}, -{removed}." msgid "refresh.failed" msgstr "Odswiezanie kamer nie powiodlo sie: {error}" +msgid "action.working" +msgstr "Wysylanie komendy..." + +msgid "action.success" +msgstr "Komenda wyslana." + +msgid "action.failed_generic" +msgstr "Komenda nie powiodla sie." + +msgid "action.failed" +msgstr "Komenda nie powiodla sie: {error}" + +msgid "action.network_failed" +msgstr "Nie mozna polaczyc sie z bridge. Sprawdz dostep sieciowy z tego urzadzenia." + msgid "action.all_off" msgstr "{target} wylacz wszystko" diff --git a/tests/test_action_payload.py b/tests/test_action_payload.py new file mode 100644 index 0000000..d131177 --- /dev/null +++ b/tests/test_action_payload.py @@ -0,0 +1,81 @@ +import unittest + +from frigate_camera_control_bridge.app import CameraControlBridge + + +class DummyAction: + def __init__(self, action_id="example_camera_white", kind="hikvision_colorvu"): + self.id = action_id + self.platform = "switch" + self.kind = kind + self.state = "ON" + self.auto_off_seconds = 600 + self.auto_off_at = 1234567890 + self.snapshot_version = 7 + self.cfg = { + "id": self.id, + "name": "Example white light", + "name_key": "action.manual_white", + "requires_ack": False, + "snapshot": { + "camera": "example", + "frigate_url": "http://frigate.example.test:5000", + }, + } + + def command_list(self): + return ["on", "off"] + + def display_name(self, _lang): + return "Example white light" + + +class ActionPayloadTest(unittest.TestCase): + def setUp(self): + self.bridge = object.__new__(CameraControlBridge) + self.bridge.default_language = "en" + self.action = DummyAction() + self.bridge.actions_snapshot = lambda: [self.action] + + def test_api_action_includes_ui_state_fields(self): + payload = CameraControlBridge.api_action(self.bridge, self.action, "en") + + self.assertEqual(payload["id"], "example_camera_white") + self.assertEqual(payload["state"], "ON") + self.assertEqual(payload["state_label"], "ON") + self.assertEqual(payload["snapshot_version"], 7) + self.assertEqual(payload["auto_off_at"], 1234567890) + + def test_action_payload_returns_updated_action_for_json_gui(self): + payload = CameraControlBridge.action_payload( + self.bridge, + "en", + self.action, + "on", + {"ok": True, "command": "on"}, + "/?lang=en", + ) + + self.assertTrue(payload["ok"]) + self.assertEqual(payload["message"], "Command sent.") + self.assertEqual(payload["redirect_url"], "/?lang=en") + self.assertEqual(payload["action"]["id"], "example_camera_white") + + def test_all_off_payload_includes_all_actions_for_gui_refresh(self): + action = DummyAction("example_all_off", kind="all_off") + payload = CameraControlBridge.action_payload( + self.bridge, + "en", + action, + "press", + {"ok": True, "results": []}, + "/?lang=en", + ) + + self.assertEqual(payload["action_id"], "example_all_off") + self.assertEqual(len(payload["actions"]), 1) + self.assertEqual(payload["actions"][0]["id"], "example_camera_white") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ui_render.py b/tests/test_ui_render.py index 36c0c26..fe4f4b4 100644 --- a/tests/test_ui_render.py +++ b/tests/test_ui_render.py @@ -103,7 +103,8 @@ class UiRenderTest(unittest.TestCase): html = render_page([action], "en") self.assertIn('class="cmd cmd-on"', html) - self.assertNotIn('Already active until automatic turn-off', html) + self.assertNotIn('class="cmd cmd-on" type="submit" disabled', html) + self.assertIn('data-disabled-label="Already active until automatic turn-off"', html) def test_camera_card_uses_friendly_display_name(self): action = DummyAction() @@ -121,6 +122,27 @@ class UiRenderTest(unittest.TestCase): self.assertIn("SNAPSHOT_REFRESH_DELAY_MS = 5000", script) self.assertIn("window.setTimeout", script) + def test_command_forms_expose_ajax_status_metadata(self): + html = render_page([DummyAction()], "en") + + self.assertIn('class="command-form" method="post"', html) + self.assertIn('type="submit"', html) + self.assertIn('data-working-label="Sending command..."', html) + self.assertIn('data-success-label="Command sent."', html) + self.assertIn('data-network-failed-label="Could not reach the bridge.', html) + self.assertIn('data-action-status', html) + self.assertIn('data-state-chip', html) + self.assertIn('data-timer', html) + + def test_command_submit_uses_fetch_without_page_navigation(self): + script = Path("frigate_camera_control_bridge/static/app.js").read_text(encoding="utf-8") + + self.assertIn("handleCommandSubmit(form, event)", script) + self.assertIn('form.classList.contains("command-form")', script) + self.assertIn('headers: { Accept: "application/json" }', script) + self.assertIn("event.preventDefault()", script) + self.assertIn("commandErrorMessage", script) + if __name__ == "__main__": unittest.main()