diff --git a/README.md b/README.md index 4b83e0a..8991376 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,6 @@ 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 d290090..3b2accf 100644 --- a/frigate_camera_control_bridge/app.py +++ b/frigate_camera_control_bridge/app.py @@ -1636,22 +1636,6 @@ 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: @@ -1842,36 +1826,17 @@ 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) - 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() + 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() except Exception as exc: - 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")) + self.send_response(500) + self.end_headers() + self.wfile.write(str(exc).encode("utf-8")) return self.send_response(404) self.end_headers() @@ -1909,27 +1874,20 @@ class CameraControlBridge: LOG.info("HTTP fallback GUI listening on %s:%s", bind, port) def api_actions(self): - 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")), - } + 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() + ] 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 f750492..1d285ee 100644 --- a/frigate_camera_control_bridge/static/app.css +++ b/frigate_camera_control_bridge/static/app.css @@ -560,11 +560,6 @@ h3 { cursor: not-allowed; } -.cmd.is-working { - cursor: progress; - opacity: 0.78; -} - .cmd-on, .cmd-press { border-color: var(--blue); @@ -601,26 +596,6 @@ 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; @@ -641,11 +616,6 @@ 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 13014d5..52f670d 100644 --- a/frigate_camera_control_bridge/static/app.js +++ b/frigate_camera_control_bridge/static/app.js @@ -129,174 +129,6 @@ 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)) { @@ -314,10 +146,13 @@ document.addEventListener("submit", (event) => { handleRefreshSubmit(form, event); return; } - if (form.classList.contains("command-form")) { - handleCommandSubmit(form, event); + if (form.dataset.requiresAck !== "true") { 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 72f9099..24dd099 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 e249281..4b6b527 100644 --- a/frigate_camera_control_bridge/ui.py +++ b/frigate_camera_control_bridge/ui.py @@ -1,4 +1,3 @@ -import hashlib import html import urllib.parse from pathlib import Path @@ -20,13 +19,6 @@ 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 @@ -239,39 +231,31 @@ 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)}' "
" ) @@ -297,7 +281,7 @@ def render_group_card(group, lang, token_query): def render_global_action(action, lang, token_query): return ( - f'
' + '
' '
' f"{visual_markup(action, lang)}" '
' @@ -305,10 +289,7 @@ def render_global_action(action, lang, token_query): f'{html.escape(action.id)}' "
" "
" - '
' f'
{render_action_buttons(action, lang, token_query)}
' - '' - "
" "
" ) @@ -327,23 +308,16 @@ 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'
" @@ -403,8 +377,6 @@ 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 af92fd7..820c7c5 100644 --- a/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po +++ b/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po @@ -101,18 +101,6 @@ 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 2dd59fb..ebb109e 100644 --- a/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po +++ b/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po @@ -101,18 +101,6 @@ 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 f3171cf..36c0c26 100644 --- a/tests/test_ui_render.py +++ b/tests/test_ui_render.py @@ -103,8 +103,7 @@ class UiRenderTest(unittest.TestCase): html = render_page([action], "en") self.assertIn('class="cmd cmd-on"', html) - self.assertNotIn('class="cmd cmd-on" type="submit" disabled', html) - self.assertIn('data-disabled-label="Already active until automatic turn-off"', html) + self.assertNotIn('Already active until automatic turn-off', html) def test_camera_card_uses_friendly_display_name(self): action = DummyAction() @@ -122,23 +121,6 @@ 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()