Revert "Submit GUI commands without page navigation"

This reverts commit 0da5377ff2.
This commit is contained in:
2026-08-03 02:18:37 +02:00
parent 0da5377ff2
commit b6030860db
9 changed files with 37 additions and 346 deletions
-2
View File
@@ -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, 300s, white light 600s. The GUI timer is rendered from a backend timestamp,
so page refreshes do not reset it. so page refreshes do not reset it.
- Siren `ON` actions require a browser confirmation in the fallback GUI. - 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 - 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 treats it as a go2rtc gateway and resolves the real camera URL from
`go2rtc.streams`. `go2rtc.streams`.
+22 -64
View File
@@ -1636,22 +1636,6 @@ class CameraControlBridge:
payload["redirect_url"] = redirect_url payload["redirect_url"] = redirect_url
return payload 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): def frigate_refresh_loop(self):
interval = int(self.config.get("defaults", {}).get("frigate_refresh_seconds", 300)) interval = int(self.config.get("defaults", {}).get("frigate_refresh_seconds", 300))
if interval <= 0: if interval <= 0:
@@ -1842,36 +1826,17 @@ class CameraControlBridge:
if len(parts) == 3 and parts[0] == "action": if len(parts) == 3 and parts[0] == "action":
action_id = urllib.parse.unquote(parts[1]) action_id = urllib.parse.unquote(parts[1])
command = urllib.parse.unquote(parts[2]).lower() 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: try:
result = bridge.execute_action(action_id, command, publish=True) result = bridge.execute_action(action_id, command, publish=True)
action = bridge.action_by_id(action_id) token_param = urllib.parse.parse_qs(parsed.query).get("token", [None])[0]
if wants_json: token_query = f"&token={urllib.parse.quote(token_param)}" if token_param else ""
payload = bridge.action_payload(lang, action, command, result, redirect_url) self.send_response(303)
self.send_json(payload, status=200 if payload.get("ok") else 500) self.send_header("Location", f"/?lang={urllib.parse.quote(lang)}{token_query}")
else: self.end_headers()
self.send_response(303)
self.send_header("Location", redirect_url)
self.end_headers()
except Exception as exc: except Exception as exc:
if wants_json: self.send_response(500)
self.send_json( self.end_headers()
{ self.wfile.write(str(exc).encode("utf-8"))
"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 return
self.send_response(404) self.send_response(404)
self.end_headers() self.end_headers()
@@ -1909,27 +1874,20 @@ class CameraControlBridge:
LOG.info("HTTP fallback GUI listening on %s:%s", bind, port) LOG.info("HTTP fallback GUI listening on %s:%s", bind, port)
def api_actions(self): def api_actions(self):
return [self.api_action(action, self.default_language) for action in self.actions_snapshot()] return [
{
def api_action(self, action, lang=None): "id": action.id,
lang = lang or self.default_language "name": action.display_name(self.default_language),
state_value = (action.state or "unknown").lower() "platform": action.platform,
state_label = text_for(lang, f"state.{state_value}") "commands": action.command_list(),
if state_label == f"state.{state_value}": "state": action.state,
state_label = state_value.upper() "snapshot": bool(action.cfg.get("snapshot")),
return { "auto_off_seconds": action.auto_off_seconds,
"id": action.id, "auto_off_at": int(action.auto_off_at) if action.auto_off_at else None,
"name": action.display_name(lang), "requires_ack": bool(action.cfg.get("requires_ack")),
"platform": action.platform, }
"commands": action.command_list(), for action in self.actions_snapshot()
"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): def html_page(self, lang=None, token_param=None):
with self.action_lock: with self.action_lock:
@@ -560,11 +560,6 @@ h3 {
cursor: not-allowed; cursor: not-allowed;
} }
.cmd.is-working {
cursor: progress;
opacity: 0.78;
}
.cmd-on, .cmd-on,
.cmd-press { .cmd-press {
border-color: var(--blue); border-color: var(--blue);
@@ -601,26 +596,6 @@ h3 {
background: #edfdf5; 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 { .global-strip {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -641,11 +616,6 @@ h3 {
box-shadow: 0 10px 26px rgba(17, 24, 39, 0.08); box-shadow: 0 10px 26px rgba(17, 24, 39, 0.08);
} }
.global-controls-wrap {
display: grid;
gap: 6px;
}
.global-main { .global-main {
display: grid; display: grid;
grid-template-columns: 44px minmax(0, 1fr); grid-template-columns: 44px minmax(0, 1fr);
+5 -170
View File
@@ -129,174 +129,6 @@ function refreshSnapshot(button) {
}, SNAPSHOT_REFRESH_DELAY_MS); }, 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) => { document.addEventListener("click", (event) => {
const button = event.target.closest(".snapshot-refresh"); const button = event.target.closest(".snapshot-refresh");
if (!(button instanceof HTMLButtonElement)) { if (!(button instanceof HTMLButtonElement)) {
@@ -314,10 +146,13 @@ document.addEventListener("submit", (event) => {
handleRefreshSubmit(form, event); handleRefreshSubmit(form, event);
return; return;
} }
if (form.classList.contains("command-form")) { if (form.dataset.requiresAck !== "true") {
handleCommandSubmit(form, event);
return; return;
} }
const message = form.dataset.ackMessage || "Confirm this action";
if (!window.confirm(message)) {
event.preventDefault();
}
}); });
updateTimers(); updateTimers();
@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title> <title>{title}</title>
<link rel="stylesheet" href="/static/app.css?v={css_version}"> <link rel="stylesheet" href="/static/app.css">
</head> </head>
<body> <body>
<main class="shell"> <main class="shell">
@@ -40,6 +40,6 @@
{cards} {cards}
</section> </section>
</main> </main>
<script src="/static/app.js?v={js_version}" defer></script> <script src="/static/app.js" defer></script>
</body> </body>
</html> </html>
+7 -35
View File
@@ -1,4 +1,3 @@
import hashlib
import html import html
import urllib.parse import urllib.parse
from pathlib import Path from pathlib import Path
@@ -20,13 +19,6 @@ def read_static_file(name):
return path.read_bytes() 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): def action_visual(action, lang):
# Keep GUI visuals tied to action semantics, not to local camera names. New # 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 # 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_seconds > 0:
if action.auto_off_at: if action.auto_off_at:
timer_markup = ( timer_markup = (
f'<span class="timer" data-timer data-auto-off-at="{int(action.auto_off_at)}" ' f'<span class="timer" data-auto-off-at="{int(action.auto_off_at)}" '
f'data-idle-label="{html.escape(text_for(lang, "timer.auto_off_configured", seconds=action.auto_off_seconds))}" '
f'data-prefix="{html.escape(text_for(lang, "timer.auto_off"))}" ' f'data-prefix="{html.escape(text_for(lang, "timer.auto_off"))}" '
f'data-expired-label="{html.escape(text_for(lang, "timer.expired"))}">' f'data-expired-label="{html.escape(text_for(lang, "timer.expired"))}">'
f'{html.escape(text_for(lang, "timer.auto_off"))}</span>' f'{html.escape(text_for(lang, "timer.auto_off"))}</span>'
) )
else: else:
timer_markup = ( timer_markup = (
f'<span class="timer timer-idle" data-timer ' f'<span class="timer timer-idle">'
f'data-idle-label="{html.escape(text_for(lang, "timer.auto_off_configured", seconds=action.auto_off_seconds))}" '
f'data-prefix="{html.escape(text_for(lang, "timer.auto_off"))}" '
f'data-expired-label="{html.escape(text_for(lang, "timer.expired"))}">'
f'{html.escape(text_for(lang, "timer.auto_off_configured", seconds=action.auto_off_seconds))}' f'{html.escape(text_for(lang, "timer.auto_off_configured", seconds=action.auto_off_seconds))}'
"</span>" "</span>"
) )
return ( return (
f'<section class="action-control" data-action-id="{html.escape(action.id)}">' '<section class="action-control">'
'<div class="control-head">' '<div class="control-head">'
f"{visual_markup(action, lang)}" f"{visual_markup(action, lang)}"
f"<h3>{html.escape(display_name)}</h3>" f"<h3>{html.escape(display_name)}</h3>"
"</div>" "</div>"
'<div class="meta-row">' '<div class="meta-row">'
f'<span class="chip">{html.escape(platform_label)}</span>' f'<span class="chip">{html.escape(platform_label)}</span>'
f'<span class="chip chip-state chip-state-{html.escape(state_value)}" data-state-chip ' f'<span class="chip chip-state chip-state-{html.escape(state_value)}">'
f'data-label-on="{html.escape(text_for(lang, "state.on"))}" '
f'data-label-off="{html.escape(text_for(lang, "state.off"))}" '
f'data-label-unknown="{html.escape(text_for(lang, "state.unknown"))}">'
f"{html.escape(state_label)}</span>" f"{html.escape(state_label)}</span>"
"</div>" "</div>"
f"{timer_markup}" f"{timer_markup}"
f'<div class="command-row">{render_action_buttons(action, lang, token_query)}</div>' f'<div class="command-row">{render_action_buttons(action, lang, token_query)}</div>'
'<span class="action-status" data-action-status role="status" aria-live="polite"></span>'
f'<code class="action-id">{html.escape(action.id)}</code>' f'<code class="action-id">{html.escape(action.id)}</code>'
"</section>" "</section>"
) )
@@ -297,7 +281,7 @@ def render_group_card(group, lang, token_query):
def render_global_action(action, lang, token_query): def render_global_action(action, lang, token_query):
return ( return (
f'<article class="global-action" data-action-id="{html.escape(action.id)}">' '<article class="global-action">'
'<div class="global-main">' '<div class="global-main">'
f"{visual_markup(action, lang)}" f"{visual_markup(action, lang)}"
'<div>' '<div>'
@@ -305,10 +289,7 @@ def render_global_action(action, lang, token_query):
f'<code class="action-id">{html.escape(action.id)}</code>' f'<code class="action-id">{html.escape(action.id)}</code>'
"</div>" "</div>"
"</div>" "</div>"
'<div class="global-controls-wrap">'
f'<div class="global-controls">{render_action_buttons(action, lang, token_query)}</div>' f'<div class="global-controls">{render_action_buttons(action, lang, token_query)}</div>'
'<span class="action-status" data-action-status role="status" aria-live="polite"></span>'
"</div>"
"</article>" "</article>"
) )
@@ -327,23 +308,16 @@ def render_action_buttons(action, lang, token_query):
' data-requires-ack="true"' ' data-requires-ack="true"'
f' data-ack-message="{html.escape(text_for(lang, "ack.siren_on"))}"' 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_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): if command == "on" and getattr(action, "auto_off_at", None):
disabled_label = text_for(lang, "button.on_blocked_timer")
disabled_attrs = ( disabled_attrs = (
" disabled" " disabled"
f' title="{html.escape(disabled_label)}"' f' title="{html.escape(disabled_label)}"'
f' aria-label="{html.escape(disabled_label)}"' f' aria-label="{html.escape(disabled_label)}"'
) )
buttons.append( buttons.append(
f'<form class="command-form" method="post" action="{html.escape(action_url)}"{ack_attrs}{common_attrs}>' f'<form class="command-form" method="post" action="{html.escape(action_url)}"{ack_attrs}>'
f'<button class="cmd cmd-{html.escape(command)}" type="submit"{disabled_attrs}>' f'<button class="cmd cmd-{html.escape(command)}" type="submit"{disabled_attrs}>'
f"<span>{html.escape(label)}</span>" f"<span>{html.escape(label)}</span>"
"</button></form>" "</button></form>"
@@ -403,8 +377,6 @@ def render_page(actions, lang, token_param=None):
language_options=render_language_options(lang), language_options=render_language_options(lang),
token_input=token_input, token_input=token_input,
token_query=html.escape(token_query), 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, global_actions=global_markup,
grid_class=html.escape(grid_class), grid_class=html.escape(grid_class),
cards=cards_markup, cards=cards_markup,
@@ -101,18 +101,6 @@ msgstr "Refresh snapshot"
msgid "button.on_blocked_timer" msgid "button.on_blocked_timer"
msgstr "Already active until automatic turn-off" 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" msgid "link.open_stream"
msgstr "Open Frigate stream" msgstr "Open Frigate stream"
@@ -101,18 +101,6 @@ msgstr "Odswiez obraz"
msgid "button.on_blocked_timer" msgid "button.on_blocked_timer"
msgstr "Juz wlaczone do czasu automatycznego wylaczenia" 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" msgid "link.open_stream"
msgstr "Otworz stream we Frigate" msgstr "Otworz stream we Frigate"
+1 -19
View File
@@ -103,8 +103,7 @@ class UiRenderTest(unittest.TestCase):
html = render_page([action], "en") html = render_page([action], "en")
self.assertIn('class="cmd cmd-on"', html) self.assertIn('class="cmd cmd-on"', html)
self.assertNotIn('class="cmd cmd-on" type="submit" disabled', html) self.assertNotIn('Already active until automatic turn-off', html)
self.assertIn('data-disabled-label="Already active until automatic turn-off"', html)
def test_camera_card_uses_friendly_display_name(self): def test_camera_card_uses_friendly_display_name(self):
action = DummyAction() action = DummyAction()
@@ -122,23 +121,6 @@ class UiRenderTest(unittest.TestCase):
self.assertIn("SNAPSHOT_REFRESH_DELAY_MS = 5000", script) self.assertIn("SNAPSHOT_REFRESH_DELAY_MS = 5000", script)
self.assertIn("window.setTimeout", 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__": if __name__ == "__main__":
unittest.main() unittest.main()