import hashlib 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 static_asset_version(name): payload = read_static_file(name) if payload is None: return "missing" return hashlib.sha256(payload).hexdigest()[:12] 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_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 '
' + "".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) refresh_label = text_for(lang, "button.refresh_snapshot") 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)}" f"&v={int(getattr(action, 'snapshot_version', 0) or 0)}{token_query}" ) snapshot_markup = ( '
' f'' f'" f"{icons}" "
" ) 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'' '" "" ) 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'
' '
' 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"{camera_stream_link(group, lang)}" "
" 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'
' '
' 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"))}"' ) common_attrs = ( f' data-working-label="{html.escape(text_for(lang, "action.working"))}"' f' data-success-label="{html.escape(text_for(lang, "action.success"))}"' f' data-failed-label="{html.escape(text_for(lang, "action.failed_generic"))}"' ) disabled_attrs = "" disabled_label = text_for(lang, "button.on_blocked_timer") if command == "on": common_attrs += f' data-disabled-label="{html.escape(disabled_label)}"' if command == "on" and getattr(action, "auto_off_at", None): disabled_attrs = ( " disabled" f' title="{html.escape(disabled_label)}"' f' aria-label="{html.escape(disabled_label)}"' ) 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")), 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), css_version=html.escape(static_asset_version("app.css")), js_version=html.escape(static_asset_version("app.js")), global_actions=global_markup, grid_class=html.escape(grid_class), cards=cards_markup, )