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 @@