Modularize bridge and improve control GUI
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
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,
|
||||
)
|
||||
Reference in New Issue
Block a user