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 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_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 '
' + "".join( visual_markup(action, lang) for action in actions[:3] ) + "
" 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) snapshot_markup = ( '
' f"{icons}" 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 = ( '
' f'' f"{icons}" "
" ) return snapshot_markup 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'' 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"{visual_markup(action, lang)}" f"

{html.escape(display_name)}

" "
" '
' f'{html.escape(platform_label)}' f'' f"{html.escape(state_label)}" "
" f"{timer_markup}" f'
{render_action_buttons(action, lang, token_query)}
' f'{html.escape(action.id)}' "
" ) def render_group_card(group, lang, token_query): controls = "".join(render_action_control(action, lang, token_query) for action in group["actions"]) return ( '
' f"{snapshot_markup_for_group(group, lang, token_query)}" '
' '
' f"

{html.escape(group['name'])}

" f'{html.escape(text_for(lang, "camera.controls_count", count=len(group["actions"])))}' "
" f'
{controls}
' "
" ) def render_global_action(action, lang, token_query): return ( '
' '
' f"{visual_markup(action, lang)}" '
' f"

{html.escape(action.display_name(lang))}

" f'{html.escape(action.id)}' "
" "
" f'
{render_action_buttons(action, lang, token_query)}
' "
" ) 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"))}"' ) buttons.append( f'
' f'
" ) 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'') 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'
{global_markup}
' 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'
{html.escape(text_for(lang, "no_actions"))}
' grid_class = "control-grid grid-single" if len(groups) == 1 else "control-grid" 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(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")), 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, )