384 lines
15 KiB
Python
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,
|
|
)
|