Group GUI controls by camera
This commit is contained in:
@@ -98,12 +98,51 @@ def visual_markup(action, lang):
|
||||
)
|
||||
|
||||
|
||||
def render_card(action, lang, token_query):
|
||||
display_name = action.display_name(lang)
|
||||
icon = visual_markup(action, lang)
|
||||
def camera_group_key(action):
|
||||
# Prefer the Frigate camera identifier because all actions discovered from
|
||||
# the same camera share it. Actions without a snapshot remain grouped by id.
|
||||
snapshot = action.cfg.get("snapshot") or {}
|
||||
return (
|
||||
snapshot.get("frigate_url"),
|
||||
snapshot.get("camera") or action.cfg.get("camera_name") or action.id,
|
||||
)
|
||||
|
||||
|
||||
def camera_group_name(action, lang):
|
||||
return (
|
||||
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)
|
||||
)
|
||||
|
||||
|
||||
def action_label(action, lang):
|
||||
name_key = action.cfg.get("name_key")
|
||||
short_labels = {
|
||||
"action.siren": "visual.siren",
|
||||
"action.red_blue": "visual.red_blue",
|
||||
"action.manual_white": "visual.white_light",
|
||||
"action.all_off": "visual.all_off",
|
||||
}
|
||||
if name_key in short_labels:
|
||||
return text_for(lang, short_labels[name_key])
|
||||
return action.display_name(lang)
|
||||
|
||||
|
||||
def group_visual_markup(actions, lang):
|
||||
return '<div class="visual-stack">' + "".join(
|
||||
visual_markup(action, lang) for action in actions[:3]
|
||||
) + "</div>"
|
||||
|
||||
|
||||
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)
|
||||
snapshot_markup = (
|
||||
'<div class="media media-empty">'
|
||||
f"{icon}"
|
||||
f"{icons}"
|
||||
f'<span>{html.escape(text_for(lang, "no_preview"))}</span>'
|
||||
"</div>"
|
||||
)
|
||||
@@ -115,31 +154,16 @@ def render_card(action, lang, token_query):
|
||||
snapshot_markup = (
|
||||
'<div class="media">'
|
||||
f'<img class="snapshot" src="{html.escape(snapshot_url)}" '
|
||||
f'alt="{html.escape(display_name)}" loading="lazy" '
|
||||
f'alt="{html.escape(group["name"])}" loading="lazy" '
|
||||
'onerror="this.parentElement.classList.add(\'media-empty\');this.remove()">'
|
||||
f"{icon}"
|
||||
f"{icons}"
|
||||
"</div>"
|
||||
)
|
||||
return snapshot_markup
|
||||
|
||||
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'<form class="command-form" method="post" action="{html.escape(action_url)}"{ack_attrs}>'
|
||||
f'<button class="cmd cmd-{html.escape(command)}" type="submit">'
|
||||
f"<span>{html.escape(label)}</span>"
|
||||
"</button></form>"
|
||||
)
|
||||
|
||||
def render_action_control(action, lang, token_query):
|
||||
display_name = action_label(action, lang)
|
||||
|
||||
state_value = (action.state or "unknown").lower()
|
||||
state_label = text_for(lang, f"state.{state_value}")
|
||||
@@ -165,12 +189,10 @@ def render_card(action, lang, token_query):
|
||||
)
|
||||
|
||||
return (
|
||||
'<article class="action-card">'
|
||||
f"{snapshot_markup}"
|
||||
'<div class="card-body">'
|
||||
'<div class="card-main">'
|
||||
f"<h2>{html.escape(display_name)}</h2>"
|
||||
f'<code class="action-id">{html.escape(action.id)}</code>'
|
||||
'<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>'
|
||||
@@ -178,12 +200,65 @@ def render_card(action, lang, token_query):
|
||||
f"{html.escape(state_label)}</span>"
|
||||
"</div>"
|
||||
f"{timer_markup}"
|
||||
f'<div class="command-row">{"".join(buttons)}</div>'
|
||||
f'<div class="command-row">{render_action_buttons(action, lang, token_query)}</div>'
|
||||
f'<code class="action-id">{html.escape(action.id)}</code>'
|
||||
"</section>"
|
||||
)
|
||||
|
||||
|
||||
def render_group_card(group, lang, token_query):
|
||||
controls = "".join(render_action_control(action, lang, token_query) for action in group["actions"])
|
||||
return (
|
||||
'<article class="camera-card">'
|
||||
f"{snapshot_markup_for_group(group, lang, token_query)}"
|
||||
'<div class="card-body">'
|
||||
'<div class="card-main">'
|
||||
f"<h2>{html.escape(group['name'])}</h2>"
|
||||
f'<span class="camera-count">{html.escape(text_for(lang, "camera.controls_count", count=len(group["actions"])))}</span>'
|
||||
"</div>"
|
||||
f'<div class="control-stack">{controls}</div>'
|
||||
"</article>"
|
||||
)
|
||||
|
||||
|
||||
def render_global_action(action, lang, token_query):
|
||||
return (
|
||||
'<article class="global-action">'
|
||||
'<div class="global-main">'
|
||||
f"{visual_markup(action, lang)}"
|
||||
'<div>'
|
||||
f"<h2>{html.escape(action.display_name(lang))}</h2>"
|
||||
f'<code class="action-id">{html.escape(action.id)}</code>'
|
||||
"</div>"
|
||||
"</div>"
|
||||
f'<div class="global-controls">{render_action_buttons(action, lang, token_query)}</div>'
|
||||
"</article>"
|
||||
)
|
||||
|
||||
|
||||
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'<form class="command-form" method="post" action="{html.escape(action_url)}"{ack_attrs}>'
|
||||
f'<button class="cmd cmd-{html.escape(command)}" type="submit">'
|
||||
f"<span>{html.escape(label)}</span>"
|
||||
"</button></form>"
|
||||
)
|
||||
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'<section class="global-strip">{global_markup}</section>'
|
||||
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'<section class="empty-state">{html.escape(text_for(lang, "no_actions"))}</section>'
|
||||
token_input = (
|
||||
f'<input type="hidden" name="token" value="{html.escape(token_param)}">'
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user