Block repeated on while auto-off is active

This commit is contained in:
2026-08-03 01:37:14 +02:00
parent 4ea737024b
commit 6b2bc3853a
8 changed files with 204 additions and 6 deletions
+27 -4
View File
@@ -350,7 +350,7 @@ class Action:
if not key:
return self.name
prefix = self.cfg.get("camera_name") or self.cfg.get("frigate_name") or ""
prefix = self.cfg.get("camera_display_name") or self.cfg.get("camera_name") or self.cfg.get("frigate_name") or ""
return text_for(normalized, key, target=prefix).strip()
def command_list(self):
@@ -807,12 +807,14 @@ class CameraControlBridge:
urls = self.camera_candidate_urls(camera_name, camera_cfg, streams)
endpoints = self.camera_endpoints_from_urls(urls)
kinds = self.kinds_from_urls(urls)
camera_display_name = self.camera_display_name(camera_name, camera_cfg)
for host in endpoints:
inventory.append(
{
"frigate": name,
"frigate_url": base_url,
"camera": camera_name,
"camera_display_name": camera_display_name,
"host": host,
"kinds": kinds,
}
@@ -820,6 +822,15 @@ class CameraControlBridge:
LOG.info("discovered %d camera host mapping(s) from Frigate", len(inventory))
return inventory
@staticmethod
def camera_display_name(camera_name, camera_cfg):
# Frigate camera keys remain the API identifier. friendly_name is only
# used for human-facing labels when present.
friendly_name = camera_cfg.get("friendly_name")
if friendly_name:
return str(friendly_name)
return camera_name
def load_frigate_config_with_retry(self, frigate, base_url):
discovery = self.config.get("discovery", {})
wait = bool(discovery.get("wait_for_frigate", True))
@@ -1081,6 +1092,7 @@ class CameraControlBridge:
"name": text_for(lang, "action.manual_white", target=item["camera"]),
"name_key": "action.manual_white",
"camera_name": item["camera"],
"camera_display_name": item.get("camera_display_name", item["camera"]),
"platform": "switch",
"kind": "hikvision_colorvu",
"host": host,
@@ -1089,7 +1101,7 @@ class CameraControlBridge:
"snapshot": self.snapshot_config(item),
"device": {
"identifiers": [f"camera_{host.replace('.', '_')}"],
"name": item["camera"],
"name": item.get("camera_display_name", item["camera"]),
"manufacturer": "Hikvision",
"model": model,
"configuration_url": item["frigate_url"],
@@ -1120,6 +1132,7 @@ class CameraControlBridge:
"name": text_for(lang, "action.red_blue", target=item["camera"]),
"name_key": "action.red_blue",
"camera_name": item["camera"],
"camera_display_name": item.get("camera_display_name", item["camera"]),
"platform": "switch",
"kind": "dahua_coaxial_io",
"host": host,
@@ -1138,6 +1151,7 @@ class CameraControlBridge:
"name": text_for(lang, "action.siren", target=item["camera"]),
"name_key": "action.siren",
"camera_name": item["camera"],
"camera_display_name": item.get("camera_display_name", item["camera"]),
"platform": "switch",
"kind": "dahua_coaxial_io",
"host": host,
@@ -1182,7 +1196,7 @@ class CameraControlBridge:
def discovered_device(item, manufacturer, model):
return {
"identifiers": [f"camera_{item['host'].replace('.', '_')}"],
"name": item["camera"],
"name": item.get("camera_display_name", item["camera"]),
"manufacturer": manufacturer,
"model": model,
"configuration_url": item["frigate_url"],
@@ -1192,6 +1206,7 @@ class CameraControlBridge:
config = {
"frigate_url": item["frigate_url"],
"camera": item["camera"],
"camera_display_name": item.get("camera_display_name", item["camera"]),
}
defaults = self.config.get("defaults", {})
if defaults.get("frigate_public_url"):
@@ -1413,6 +1428,8 @@ class CameraControlBridge:
action = self.action_by_id(action_id)
if action is None:
raise RuntimeError(f"unknown action {action_id}")
if command == "on" and self.auto_off_running(action):
raise RuntimeError("action is already active until automatic turn-off")
result = action.run(command, bridge=self)
if action.platform == "switch":
if command == "on":
@@ -1428,6 +1445,10 @@ class CameraControlBridge:
LOG.warning("action %s %s worked, but MQTT publish failed: %s", action_id, command, exc)
return result
@staticmethod
def auto_off_running(action):
return bool(getattr(action, "auto_off_at", None))
def schedule_auto_off(self, action):
seconds = int(action.auto_off_seconds or 0)
if seconds <= 0:
@@ -1723,7 +1744,9 @@ class CameraControlBridge:
if parsed.path.startswith("/snapshot/"):
action_id = urllib.parse.unquote(parsed.path.rsplit("/", 1)[-1]).removesuffix(".jpg")
try:
live = bool(bridge.config.get("defaults", {}).get("snapshot_live_on_gui", True))
values = urllib.parse.parse_qs(parsed.query)
refresh = values.get("refresh", ["0"])[0].lower() in ("1", "true", "yes", "on")
live = refresh or bool(bridge.config.get("defaults", {}).get("snapshot_live_on_gui", True))
snapshot = bridge.snapshot_for_action(action_id, live=live)
except Exception as exc:
LOG.info("snapshot request failed for %s: %s", action_id, exc)
@@ -213,6 +213,45 @@ select {
object-fit: cover;
}
.snapshot-refresh {
position: absolute;
top: 12px;
right: 12px;
display: grid;
width: 38px;
height: 38px;
place-items: center;
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: 6px;
background: rgba(15, 23, 42, 0.72);
color: #ffffff;
cursor: pointer;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.24);
}
.snapshot-refresh:hover {
background: rgba(15, 23, 42, 0.88);
}
.snapshot-refresh:focus-visible {
outline: 3px solid rgba(29, 78, 216, 0.35);
outline-offset: 2px;
}
.snapshot-refresh.is-refreshing svg {
animation: refresh-spin 0.9s linear infinite;
}
.snapshot-refresh svg {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2;
}
.media-empty {
display: grid;
place-items: center;
@@ -514,6 +553,13 @@ h3 {
outline-offset: 2px;
}
.cmd:disabled {
border-color: var(--line);
background: #eef2f6;
color: #8290a2;
cursor: not-allowed;
}
.cmd-on,
.cmd-press {
border-color: var(--blue);
@@ -521,12 +567,24 @@ h3 {
color: #ffffff;
}
.cmd-on:disabled {
border-color: var(--line);
background: #eef2f6;
color: #8290a2;
}
.cmd-on:hover,
.cmd-press:hover {
border-color: var(--blue-dark);
background: var(--blue-dark);
}
.cmd-on:disabled:hover {
border-color: var(--line);
background: #eef2f6;
color: #8290a2;
}
.cmd-off {
border-color: #9dd8bd;
background: #ffffff;
@@ -100,6 +100,39 @@ async function handleRefreshSubmit(form, event) {
}
}
function refreshSnapshot(button) {
if (button.classList.contains("is-refreshing")) {
return;
}
const media = button.closest(".media");
const image = media?.querySelector("img.snapshot");
if (!image) {
return;
}
const url = new URL(image.currentSrc || image.src, window.location.href);
url.searchParams.set("refresh", "1");
url.searchParams.set("v", String(Date.now()));
const cleanup = () => {
button.disabled = false;
button.classList.remove("is-refreshing");
button.removeAttribute("aria-busy");
};
button.disabled = true;
button.classList.add("is-refreshing");
button.setAttribute("aria-busy", "true");
image.addEventListener("load", cleanup, { once: true });
image.addEventListener("error", cleanup, { once: true });
image.src = url.toString();
}
document.addEventListener("click", (event) => {
const button = event.target.closest(".snapshot-refresh");
if (!(button instanceof HTMLButtonElement)) {
return;
}
refreshSnapshot(button);
});
document.addEventListener("submit", (event) => {
const form = event.target;
if (!(form instanceof HTMLFormElement)) {
+22 -2
View File
@@ -110,7 +110,9 @@ def camera_group_key(action):
def camera_group_name(action, lang):
return (
action.cfg.get("camera_name")
action.cfg.get("camera_display_name")
or (action.cfg.get("snapshot") or {}).get("camera_display_name")
or action.cfg.get("camera_name")
or (action.cfg.get("snapshot") or {}).get("camera")
or action.cfg.get("device", {}).get("name")
or action.display_name(lang)
@@ -140,6 +142,7 @@ def snapshot_markup_for_group(group, lang, token_query):
actions = group["actions"]
action = next((item for item in actions if item.cfg.get("snapshot")), actions[0])
icons = group_visual_markup(actions, lang)
refresh_label = text_for(lang, "button.refresh_snapshot")
snapshot_markup = (
'<div class="media media-empty">'
f"{icons}"
@@ -157,6 +160,15 @@ def snapshot_markup_for_group(group, lang, token_query):
f'<img class="snapshot" src="{html.escape(snapshot_url)}" '
f'alt="{html.escape(group["name"])}" loading="lazy" '
'onerror="this.parentElement.classList.add(\'media-empty\');this.remove()">'
f'<button class="snapshot-refresh" type="button" title="{html.escape(refresh_label)}" '
f'aria-label="{html.escape(refresh_label)}">'
'<svg viewBox="0 0 24 24" aria-hidden="true">'
'<path d="M20 6v5h-5"></path>'
'<path d="M4 18v-5h5"></path>'
'<path d="M18.6 9A7 7 0 0 0 6.3 6.4L4 8.6"></path>'
'<path d="M5.4 15A7 7 0 0 0 17.7 17.6L20 15.4"></path>'
"</svg>"
"</button>"
f"{icons}"
"</div>"
)
@@ -296,9 +308,17 @@ def render_action_buttons(action, lang, token_query):
' data-requires-ack="true"'
f' data-ack-message="{html.escape(text_for(lang, "ack.siren_on"))}"'
)
disabled_attrs = ""
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}>'
f'<button class="cmd cmd-{html.escape(command)}" type="submit">'
f'<button class="cmd cmd-{html.escape(command)}" type="submit"{disabled_attrs}>'
f"<span>{html.escape(label)}</span>"
"</button></form>"
)
@@ -95,6 +95,12 @@ msgstr "PRESS"
msgid "button.refresh_cameras"
msgstr "Refresh cameras"
msgid "button.refresh_snapshot"
msgstr "Refresh snapshot"
msgid "button.on_blocked_timer"
msgstr "Already active until automatic turn-off"
msgid "link.open_stream"
msgstr "Open Frigate stream"
@@ -95,6 +95,12 @@ msgstr "URUCHOM"
msgid "button.refresh_cameras"
msgstr "Odswiez kamery"
msgid "button.refresh_snapshot"
msgstr "Odswiez obraz"
msgid "button.on_blocked_timer"
msgstr "Juz wlaczone do czasu automatycznego wylaczenia"
msgid "link.open_stream"
msgstr "Otworz stream we Frigate"
+30
View File
@@ -7,6 +7,9 @@ class DummyAction:
def __init__(self, action_id, camera=None, kind="hikvision_colorvu", name_key=None):
self.id = action_id
self.kind = kind
self.platform = "switch"
self.auto_off_at = None
self.run_called = False
self.cfg = {}
if name_key:
self.cfg["name_key"] = name_key
@@ -16,6 +19,10 @@ class DummyAction:
"camera": camera,
}
def run(self, command, bridge=None):
self.run_called = True
return {"ok": True, "command": command}
class RefreshSummaryTest(unittest.TestCase):
def test_camera_keys_ignore_global_actions(self):
@@ -28,6 +35,18 @@ class RefreshSummaryTest(unittest.TestCase):
self.assertEqual(keys, {("http://frigate.example.test:5000", "one")})
def test_camera_display_name_uses_friendly_name(self):
self.assertEqual(
CameraControlBridge.camera_display_name("camera_key", {"friendly_name": "Camera Friendly"}),
"Camera Friendly",
)
def test_camera_display_name_falls_back_to_config_name(self):
self.assertEqual(
CameraControlBridge.camera_display_name("camera_key", {}),
"camera_key",
)
def test_refresh_message_reports_camera_changes_first(self):
message = CameraControlBridge.refresh_message(
"en",
@@ -64,6 +83,17 @@ class RefreshSummaryTest(unittest.TestCase):
self.assertFalse(CameraControlBridge.should_refresh_snapshot_after_command(siren, "on"))
self.assertFalse(CameraControlBridge.should_refresh_snapshot_after_command(white, "press"))
def test_execute_action_rejects_second_on_while_timer_runs(self):
action = DummyAction("white", "one", name_key="action.manual_white")
action.auto_off_at = 1234567890
bridge = object.__new__(CameraControlBridge)
bridge.action_by_id = lambda action_id: action
with self.assertRaisesRegex(RuntimeError, "already active"):
CameraControlBridge.execute_action(bridge, "white", "on", publish=False)
self.assertFalse(action.run_called)
if __name__ == "__main__":
unittest.main()
+22
View File
@@ -37,6 +37,9 @@ class UiRenderTest(unittest.TestCase):
self.assertIn('data-requires-ack="true"', html)
self.assertIn("Example camera", html)
self.assertIn("v=42", html)
self.assertIn('class="snapshot-refresh"', html)
self.assertIn('title="Refresh snapshot"', html)
self.assertIn('disabled title="Already active until automatic turn-off"', html)
def test_controls_from_same_camera_share_one_card(self):
actions = [
@@ -92,6 +95,25 @@ class UiRenderTest(unittest.TestCase):
"https://frigate.example.test/#example",
)
def test_on_button_is_available_when_no_auto_off_timer_runs(self):
action = DummyAction()
action.auto_off_at = None
html = render_page([action], "en")
self.assertIn('class="cmd cmd-on"', html)
self.assertNotIn('Already active until automatic turn-off', html)
def test_camera_card_uses_friendly_display_name(self):
action = DummyAction()
action.cfg["camera_name"] = "camera_key"
action.cfg["camera_display_name"] = "Camera Friendly"
html = render_page([action], "en")
self.assertIn("<h2>Camera Friendly</h2>", html)
self.assertNotIn("<h2>camera_key</h2>", html)
if __name__ == "__main__":
unittest.main()