From 0da5377ff276be3ef1d263be2357486a6e6f9da7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Kacprzak?= Date: Mon, 3 Aug 2026 02:04:17 +0200 Subject: [PATCH] Submit GUI commands without page navigation --- README.md | 2 + frigate_camera_control_bridge/app.py | 86 ++++++--- frigate_camera_control_bridge/static/app.css | 30 +++ frigate_camera_control_bridge/static/app.js | 175 +++++++++++++++++- .../templates/index.html | 4 +- frigate_camera_control_bridge/ui.py | 42 ++++- .../frigate_camera_control_bridge.po | 12 ++ .../frigate_camera_control_bridge.po | 12 ++ tests/test_ui_render.py | 20 +- 9 files changed, 346 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 8991376..4b83e0a 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ through Home Assistant MQTT Discovery plus an emergency HTTP fallback GUI. 300s, white light 600s. The GUI timer is rendered from a backend timestamp, so page refreshes do not reset it. - Siren `ON` actions require a browser confirmation in the fallback GUI. +- The fallback GUI sends camera commands with same-origin JavaScript `fetch()` + and keeps plain POST forms as a no-JavaScript fallback. - If `ffmpeg.inputs[].path` points at `rtsp://127.0.0.1:8554/...`, the sidecar treats it as a go2rtc gateway and resolves the real camera URL from `go2rtc.streams`. 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..f750492 100644 --- a/frigate_camera_control_bridge/static/app.css +++ b/frigate_camera_control_bridge/static/app.css @@ -560,6 +560,11 @@ h3 { cursor: not-allowed; } +.cmd.is-working { + cursor: progress; + opacity: 0.78; +} + .cmd-on, .cmd-press { border-color: var(--blue); @@ -596,6 +601,26 @@ h3 { background: #edfdf5; } +.action-status { + display: none; + min-height: 18px; + color: var(--muted); + font-size: 0.82rem; + line-height: 1.25; +} + +.action-status.is-visible { + display: inline-flex; +} + +.action-status.is-success { + color: var(--green-dark); +} + +.action-status.is-error { + color: var(--red); +} + .global-strip { flex: 1; display: flex; @@ -616,6 +641,11 @@ h3 { box-shadow: 0 10px 26px rgba(17, 24, 39, 0.08); } +.global-controls-wrap { + display: grid; + gap: 6px; +} + .global-main { display: grid; grid-template-columns: 44px minmax(0, 1fr); diff --git a/frigate_camera_control_bridge/static/app.js b/frigate_camera_control_bridge/static/app.js index 52f670d..13014d5 100644 --- a/frigate_camera_control_bridge/static/app.js +++ b/frigate_camera_control_bridge/static/app.js @@ -129,6 +129,174 @@ 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 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 || chip.dataset[`label${state.charAt(0).toUpperCase()}${state.slice(1)}`] || action.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 }; + } +} + +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); + if (!response.ok || payload.ok === false) { + throw new Error(payload.message || form.dataset.failedLabel || "Command failed."); + } + applyActionPayload(payload); + showActionMessage(form, payload.message || form.dataset.successLabel || "Command sent.", "success"); + refreshSnapshotForCommand(form); + } catch (error) { + showActionMessage(form, error.message || form.dataset.failedLabel || "Command failed.", "error"); + } finally { + setCommandBusy(button, false); + } +} + document.addEventListener("click", (event) => { const button = event.target.closest(".snapshot-refresh"); if (!(button instanceof HTMLButtonElement)) { @@ -146,13 +314,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..e249281 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 @@ -231,31 +239,39 @@ def render_action_control(action, lang, token_query): if action.auto_off_seconds > 0: if action.auto_off_at: timer_markup = ( - f'' f'{html.escape(text_for(lang, "timer.auto_off"))}' ) else: timer_markup = ( - f'' + f'' f'{html.escape(text_for(lang, "timer.auto_off_configured", seconds=action.auto_off_seconds))}' "" ) 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 +297,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 +305,10 @@ def render_global_action(action, lang, token_query): f'{html.escape(action.id)}' "
" "
" + '
' f'
{render_action_buttons(action, lang, token_query)}
' + '' + "
" "
" ) @@ -308,16 +327,23 @@ 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"))}"' + ) 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 +403,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..af92fd7 100644 --- a/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po +++ b/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po @@ -101,6 +101,18 @@ msgstr "Refresh snapshot" msgid "button.on_blocked_timer" msgstr "Already active until automatic turn-off" +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 "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 ebb109e..2dd59fb 100644 --- a/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po +++ b/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po @@ -101,6 +101,18 @@ msgstr "Odswiez obraz" msgid "button.on_blocked_timer" msgstr "Juz wlaczone do czasu automatycznego wylaczenia" +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 "link.open_stream" msgstr "Otworz stream we Frigate" diff --git a/tests/test_ui_render.py b/tests/test_ui_render.py index 36c0c26..f3171cf 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,23 @@ 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"', html) + self.assertIn('data-working-label="Sending command..."', html) + self.assertIn('data-success-label="Command sent."', html) + self.assertIn('data-action-status', html) + self.assertIn('data-state-chip', 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) + if __name__ == "__main__": unittest.main()