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

218 lines
8.8 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 render_card(action, lang, token_query):
display_name = action.display_name(lang)
icon = visual_markup(action, lang)
snapshot_markup = (
'<div class="media media-empty">'
f"{icon}"
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)}{token_query}"
)
snapshot_markup = (
'<div class="media">'
f'<img class="snapshot" src="{html.escape(snapshot_url)}" '
f'alt="{html.escape(display_name)}" loading="lazy" '
'onerror="this.parentElement.classList.add(\'media-empty\');this.remove()">'
f"{icon}"
"</div>"
)
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>"
)
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 (
'<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>'
"</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">{"".join(buttons)}</div>'
"</div>"
"</article>"
)
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 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]
cards_markup = "".join(cards)
if not cards_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)}">'
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(cards),
actions_label=html.escape(text_for(lang, "actions")),
language_label=html.escape(text_for(lang, "language")),
language_options=render_language_options(lang),
token_input=token_input,
cards=cards_markup,
)