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,
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`.
+22 -64
View File
@@ -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:
@@ -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);
+5 -170
View File
@@ -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();
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<link rel="stylesheet" href="/static/app.css?v={css_version}">
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<main class="shell">
@@ -40,6 +40,6 @@
{cards}
</section>
</main>
<script src="/static/app.js?v={js_version}" defer></script>
<script src="/static/app.js" defer></script>
</body>
</html>
+7 -35
View File
@@ -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'<span class="timer" data-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'<span class="timer" data-auto-off-at="{int(action.auto_off_at)}" '
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"))}</span>'
)
else:
timer_markup = (
f'<span class="timer timer-idle" data-timer '
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'<span class="timer timer-idle">'
f'{html.escape(text_for(lang, "timer.auto_off_configured", seconds=action.auto_off_seconds))}'
"</span>"
)
return (
f'<section class="action-control" data-action-id="{html.escape(action.id)}">'
'<section class="action-control">'
'<div class="control-head">'
f"{visual_markup(action, lang)}"
f"<h3>{html.escape(display_name)}</h3>"
"</div>"
'<div class="meta-row">'
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'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'<span class="chip chip-state chip-state-{html.escape(state_value)}">'
f"{html.escape(state_label)}</span>"
"</div>"
f"{timer_markup}"
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>'
"</section>"
)
@@ -297,7 +281,7 @@ def render_group_card(group, lang, token_query):
def render_global_action(action, lang, token_query):
return (
f'<article class="global-action" data-action-id="{html.escape(action.id)}">'
'<article class="global-action">'
'<div class="global-main">'
f"{visual_markup(action, lang)}"
'<div>'
@@ -305,10 +289,7 @@ def render_global_action(action, lang, token_query):
f'<code class="action-id">{html.escape(action.id)}</code>'
"</div>"
"</div>"
'<div class="global-controls-wrap">'
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>"
)
@@ -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'<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"<span>{html.escape(label)}</span>"
"</button></form>"
@@ -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,
@@ -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"
@@ -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"
+1 -19
View File
@@ -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()