'
+ '
'
+ f"
{html.escape(group['name'])}
"
+ f'{html.escape(text_for(lang, "camera.controls_count", count=len(group["actions"])))}'
""
+ f'
{controls}
'
""
)
+def render_global_action(action, lang, token_query):
+ return (
+ '
'
+ ''
+ f"{visual_markup(action, lang)}"
+ '
'
+ f"
{html.escape(action.display_name(lang))}
"
+ f'{html.escape(action.id)}'
+ ""
+ "
"
+ f'{render_action_buttons(action, lang, token_query)}
'
+ ""
+ )
+
+
+def render_action_buttons(action, lang, token_query):
+ buttons = []
+ for command in action.command_list():
+ label = text_for(lang, f"button.{command}")
+ action_url = (
+ f"/action/{urllib.parse.quote(action.id)}/{urllib.parse.quote(command)}"
+ f"?lang={urllib.parse.quote(lang)}{token_query}"
+ )
+ ack_attrs = ""
+ if command == "on" and action.cfg.get("requires_ack"):
+ ack_attrs = (
+ ' data-requires-ack="true"'
+ f' data-ack-message="{html.escape(text_for(lang, "ack.siren_on"))}"'
+ )
+ buttons.append(
+ f'
"
+ )
+ return "".join(buttons)
+
+
def render_language_options(lang):
options = []
for code, label in available_languages().items():
@@ -192,12 +267,31 @@ def render_language_options(lang):
return "".join(options)
+def group_actions(actions, lang):
+ grouped = {}
+ order = []
+ for action in actions:
+ if action.kind == "all_off":
+ continue
+ key = camera_group_key(action)
+ if key not in grouped:
+ grouped[key] = {"name": camera_group_name(action, lang), "actions": []}
+ order.append(key)
+ grouped[key]["actions"].append(action)
+ return [grouped[key] for key in order]
+
+
def render_page(actions, lang, token_param=None):
lang = normalize_language(lang)
token_query = f"&token={urllib.parse.quote(token_param)}" if token_param else ""
- cards = [render_card(action, lang, token_query) for action in actions]
+ global_actions = [action for action in actions if action.kind == "all_off"]
+ global_markup = "".join(render_global_action(action, lang, token_query) for action in global_actions)
+ if global_markup:
+ global_markup = f'
'
+ groups = group_actions(actions, lang)
+ cards = [render_group_card(group, lang, token_query) for group in groups]
cards_markup = "".join(cards)
- if not cards_markup:
+ if not cards_markup and not global_markup:
cards_markup = f'
{html.escape(text_for(lang, "no_actions"))}'
token_input = (
f'
'
@@ -208,10 +302,11 @@ def render_page(actions, lang, token_param=None):
return template.format(
lang=html.escape(lang),
title=html.escape(text_for(lang, "title")),
- action_count=len(cards),
- actions_label=html.escape(text_for(lang, "actions")),
+ action_count=len(groups),
+ actions_label=html.escape(text_for(lang, "cameras")),
language_label=html.escape(text_for(lang, "language")),
language_options=render_language_options(lang),
token_input=token_input,
+ global_actions=global_markup,
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 a88353e..ff13931 100644
--- a/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po
+++ b/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po
@@ -26,6 +26,12 @@ msgstr "State"
msgid "actions"
msgstr "Actions"
+msgid "cameras"
+msgstr "Cameras"
+
+msgid "camera.controls_count"
+msgstr "{count} controls"
+
msgid "snapshot"
msgstr "Snapshot"
@@ -66,13 +72,13 @@ msgid "visual.generic"
msgstr "Camera control"
msgid "timer.auto_off"
-msgstr "Auto-off in"
+msgstr "Automatic turn-off in"
msgid "timer.expired"
-msgstr "Auto-off pending"
+msgstr "Automatic turn-off pending"
msgid "timer.auto_off_configured"
-msgstr "Auto-off after {seconds}s"
+msgstr "Automatic turn-off after {seconds}s"
msgid "ack.siren_on"
msgstr "Confirm siren activation."
diff --git a/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po b/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po
index 9a2218b..f703175 100644
--- a/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po
+++ b/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po
@@ -26,6 +26,12 @@ msgstr "Stan"
msgid "actions"
msgstr "Akcje"
+msgid "cameras"
+msgstr "Kamery"
+
+msgid "camera.controls_count"
+msgstr "Kontrolki: {count}"
+
msgid "snapshot"
msgstr "Migawka"
@@ -66,13 +72,13 @@ msgid "visual.generic"
msgstr "Sterowanie kamera"
msgid "timer.auto_off"
-msgstr "Auto-off za"
+msgstr "Automatyczne wylaczenie za"
msgid "timer.expired"
-msgstr "Auto-off w toku"
+msgstr "Automatyczne wylaczenie w toku"
msgid "timer.auto_off_configured"
-msgstr "Auto-off po {seconds}s"
+msgstr "Automatyczne wylaczanie po {seconds}s"
msgid "ack.siren_on"
msgstr "Potwierdz wlaczenie syreny."
diff --git a/tests/test_ui_render.py b/tests/test_ui_render.py
index 6c9e78a..527240e 100644
--- a/tests/test_ui_render.py
+++ b/tests/test_ui_render.py
@@ -4,21 +4,25 @@ from frigate_camera_control_bridge.ui import render_page
class DummyAction:
- def __init__(self):
- self.id = "example_camera_siren"
+ def __init__(self, suffix="siren", name_key="action.siren", state="ON", kind="dahua_coaxial_io"):
+ self.id = f"example_camera_{suffix}"
self.platform = "switch"
- self.kind = "dahua_coaxial_io"
- self.state = "ON"
+ self.kind = kind
+ self.state = state
self.auto_off_seconds = 180
self.auto_off_at = 1234567890
self.cfg = {
- "name_key": "action.siren",
- "requires_ack": True,
- "snapshot": {"camera": "example", "frigate_url": "http://frigate.example.test:5000"},
+ "name_key": name_key,
+ "camera_name": "Example camera",
+ "requires_ack": name_key == "action.siren",
+ "snapshot": {
+ "camera": "example",
+ "frigate_url": "http://frigate.example.test:5000",
+ },
}
def display_name(self, lang):
- return "Example siren"
+ return f"Example {self.id}"
def command_list(self):
return ["on", "off"]
@@ -30,7 +34,32 @@ class UiRenderTest(unittest.TestCase):
self.assertIn('data-auto-off-at="1234567890"', html)
self.assertIn('data-requires-ack="true"', html)
- self.assertIn("Example siren", html)
+ self.assertIn("Example camera", html)
+
+ def test_controls_from_same_camera_share_one_card(self):
+ actions = [
+ DummyAction("siren", "action.siren"),
+ DummyAction("white", "action.manual_white", state="OFF"),
+ DummyAction("red_blue", "action.red_blue", state="OFF"),
+ ]
+
+ html = render_page(actions, "en")
+
+ self.assertEqual(html.count('class="camera-card"'), 1)
+ self.assertEqual(html.count('class="action-control"'), 3)
+ self.assertIn("3 controls", html)
+ self.assertIn("White light", html)
+ self.assertIn("Red-blue light", html)
+ self.assertIn("Siren", html)
+
+ def test_all_off_renders_in_topbar_not_camera_grid(self):
+ action = DummyAction("all_off", "action.all_off", state=None, kind="all_off")
+
+ html = render_page([action], "en")
+
+ self.assertIn('class="global-strip"', html)
+ self.assertIn('class="global-action"', html)
+ self.assertNotIn('class="camera-card"', html)
if __name__ == "__main__":