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": (
'"
),
}
if name_key == "action.red_blue":
return {
"kind": "red-blue",
"label": text_for(lang, "visual.red_blue"),
"svg": (
'"
),
}
if name_key == "action.manual_white":
return {
"kind": "white-light",
"label": text_for(lang, "visual.white_light"),
"svg": (
'"
),
}
if action.kind == "all_off":
return {
"kind": "all-off",
"label": text_for(lang, "visual.all_off"),
"svg": (
'"
),
}
return {
"kind": "generic",
"label": text_for(lang, "visual.generic"),
"svg": (
'"
),
}
def visual_markup(action, lang):
visual = action_visual(action, lang)
return (
f'
'
f'{visual["svg"]}
'
)
def render_card(action, lang, token_query):
display_name = action.display_name(lang)
icon = visual_markup(action, lang)
snapshot_markup = (
''
f"{icon}"
f'{html.escape(text_for(lang, "no_preview"))}'
"
"
)
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 = (
'"
)
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'"
)
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''
f'{html.escape(text_for(lang, "timer.auto_off"))}'
)
else:
timer_markup = (
f''
f'{html.escape(text_for(lang, "timer.auto_off_configured", seconds=action.auto_off_seconds))}'
""
)
return (
''
f"{snapshot_markup}"
''
'
'
f"
{html.escape(display_name)}
"
f'{html.escape(action.id)}'
""
'
'
f'{html.escape(platform_label)}'
f''
f"{html.escape(state_label)}"
"
"
f"{timer_markup}"
f'
{"".join(buttons)}
'
"
"
""
)
def render_language_options(lang):
options = []
for code, label in available_languages().items():
selected = " selected" if code == lang else ""
options.append(f'')
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'{html.escape(text_for(lang, "no_actions"))}'
token_input = (
f''
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,
)