Files
frigate-camera-control-bridge/frigate_camera_control_bridge/ui.py
T

384 lines
15 KiB
Python

import html
import urllib.parse
from pathlib import Path
from .i18n import available_languages, normalize_language, text_for
PACKAGE_DIR = Path(__file__).resolve().parent
TEMPLATE_PATH = PACKAGE_DIR / "templates" / "index.html"
STATIC_DIR = PACKAGE_DIR / "static"
def read_static_file(name):
# The HTTP layer serves only explicitly named static files from this package
# directory. This avoids exposing arbitrary local paths.
path = STATIC_DIR / name
if not path.is_file() or path.parent != STATIC_DIR:
return None
return path.read_bytes()
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
# topics or camera command handling.
name_key = action.cfg.get("name_key")
if name_key == "action.siren":
return {
"kind": "siren",
"label": text_for(lang, "visual.siren"),
"svg": (
'<svg viewBox="0 0 64 64" aria-hidden="true">'
'<path d="M18 42V28a14 14 0 0 1 28 0v14Z" fill="currentColor"/>'
'<path d="M14 42h36v8H14Z" fill="currentColor" opacity=".85"/>'
'<path d="M25 20a9 9 0 0 1 14 0" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round" opacity=".9"/>'
'<path d="M32 6v7M12 14l6 6M52 14l-6 6" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>'
"</svg>"
),
}
if name_key == "action.red_blue":
return {
"kind": "red-blue",
"label": text_for(lang, "visual.red_blue"),
"svg": (
'<svg viewBox="0 0 64 64" aria-hidden="true">'
'<path d="M14 26a8 8 0 0 1 8-8h20a8 8 0 0 1 8 8v16H14Z" fill="#fff" opacity=".96"/>'
'<path d="M14 28h18v14H14Z" fill="#ef4444"/>'
'<path d="M32 28h18v14H32Z" fill="#2563eb"/>'
'<path d="M12 42h40v7H12Z" fill="#111827" opacity=".65"/>'
'<path d="M10 16l-5-5M54 16l5-5M32 11V4" fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round"/>'
"</svg>"
),
}
if name_key == "action.manual_white":
return {
"kind": "white-light",
"label": text_for(lang, "visual.white_light"),
"svg": (
'<svg viewBox="0 0 64 64" aria-hidden="true">'
'<path d="M24 39h16v7a8 8 0 0 1-16 0Z" fill="currentColor" opacity=".85"/>'
'<path d="M22 29a10 10 0 1 1 20 0c0 5-4 8-6 11h-8c-2-3-6-6-6-11Z" fill="currentColor"/>'
'<path d="M32 5v8M14 14l6 6M50 14l-6 6M9 32h8M47 32h8" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>'
'<path d="M27 52h10" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>'
"</svg>"
),
}
if action.kind == "all_off":
return {
"kind": "all-off",
"label": text_for(lang, "visual.all_off"),
"svg": (
'<svg viewBox="0 0 64 64" aria-hidden="true">'
'<path d="M32 9v22" fill="none" stroke="currentColor" stroke-width="6" stroke-linecap="round"/>'
'<path d="M21 18a20 20 0 1 0 22 0" fill="none" stroke="currentColor" stroke-width="6" stroke-linecap="round"/>'
"</svg>"
),
}
return {
"kind": "generic",
"label": text_for(lang, "visual.generic"),
"svg": (
'<svg viewBox="0 0 64 64" aria-hidden="true">'
'<path d="M12 24h40v24H12Z" fill="currentColor" opacity=".9"/>'
'<path d="M21 18h14l4 6H21Z" fill="currentColor"/>'
'<circle cx="32" cy="36" r="8" fill="#fff" opacity=".92"/>'
'<circle cx="32" cy="36" r="4" fill="currentColor"/>'
"</svg>"
),
}
def visual_markup(action, lang):
visual = action_visual(action, lang)
return (
f'<div class="visual-badge visual-{html.escape(visual["kind"])}" '
f'role="img" aria-label="{html.escape(visual["label"])}">'
f'{visual["svg"]}</div>'
)
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_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)
)
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)
refresh_label = text_for(lang, "button.refresh_snapshot")
snapshot_markup = (
'<div class="media media-empty">'
f"{icons}"
f'<span>{html.escape(text_for(lang, "no_preview"))}</span>'
"</div>"
)
if action.cfg.get("snapshot"):
snapshot_url = (
f"/snapshot/{urllib.parse.quote(action.id)}.jpg"
f"?lang={urllib.parse.quote(lang)}"
f"&v={int(getattr(action, 'snapshot_version', 0) or 0)}{token_query}"
)
snapshot_markup = (
'<div class="media">'
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>"
)
return snapshot_markup
def camera_stream_url(action):
snapshot = action.cfg.get("snapshot") or {}
base_url = snapshot.get("public_url") or snapshot.get("frigate_url")
camera = snapshot.get("camera")
if not base_url or not camera:
return None
template = snapshot.get("camera_path_template") or "/#{camera_quoted}"
values = {
"camera": camera,
"camera_quoted": urllib.parse.quote(camera, safe=""),
"frigate_url": snapshot.get("frigate_url", "").rstrip("/"),
"frigate_public_url": base_url.rstrip("/"),
}
try:
link = template.format(**values)
except KeyError:
link = template
if urllib.parse.urlsplit(link).scheme:
return link
return f"{base_url.rstrip('/')}/{link.lstrip('/')}"
def camera_stream_link(group, lang):
action = next((item for item in group["actions"] if item.cfg.get("snapshot")), None)
if not action:
return ""
url = camera_stream_url(action)
if not url:
return ""
label = text_for(lang, "link.open_stream")
return (
f'<a class="stream-link" href="{html.escape(url)}" target="_blank" rel="noreferrer" '
f'title="{html.escape(label)}" aria-label="{html.escape(label)}">'
'<svg viewBox="0 0 24 24" aria-hidden="true">'
'<path d="M15 3h6v6"></path>'
'<path d="M10 14 21 3"></path>'
'<path d="M21 13v5a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h5"></path>'
"</svg>"
"</a>"
)
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}")
if state_label == f"state.{state_value}":
state_label = state_value.upper()
platform_label = text_for(lang, f"platform.{action.platform}")
if platform_label == f"platform.{action.platform}":
platform_label = action.platform
timer_markup = ""
if action.auto_off_seconds > 0:
if action.auto_off_at:
timer_markup = (
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">'
f'{html.escape(text_for(lang, "timer.auto_off_configured", seconds=action.auto_off_seconds))}'
"</span>"
)
return (
'<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)}">'
f"{html.escape(state_label)}</span>"
"</div>"
f"{timer_markup}"
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">'
'<div class="card-title-row">'
f"<h2>{html.escape(group['name'])}</h2>"
f"{camera_stream_link(group, lang)}"
"</div>"
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"))}"'
)
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"{disabled_attrs}>'
f"<span>{html.escape(label)}</span>"
"</button></form>"
)
return "".join(buttons)
def render_language_options(lang):
options = []
for code, label in available_languages().items():
selected = " selected" if code == lang else ""
options.append(f'<option value="{html.escape(code)}"{selected}>{html.escape(label)}</option>')
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 ""
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 and not global_markup:
cards_markup = f'<section class="empty-state">{html.escape(text_for(lang, "no_actions"))}</section>'
grid_class = "control-grid grid-single" if len(groups) == 1 else "control-grid"
token_input = (
f'<input type="hidden" name="token" value="{html.escape(token_param)}">'
if token_param
else ""
)
template = TEMPLATE_PATH.read_text(encoding="utf-8")
return template.format(
lang=html.escape(lang),
title=html.escape(text_for(lang, "title")),
action_count=len(groups),
actions_label=html.escape(text_for(lang, "cameras")),
language_label=html.escape(text_for(lang, "language")),
refresh_label=html.escape(text_for(lang, "button.refresh_cameras")),
refresh_working_label=html.escape(text_for(lang, "refresh.working")),
language_options=render_language_options(lang),
token_input=token_input,
token_query=html.escape(token_query),
global_actions=global_markup,
grid_class=html.escape(grid_class),
cards=cards_markup,
)