Modularize bridge and improve control GUI
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""Frigate camera control bridge package."""
|
||||
|
||||
from .app import CameraControlBridge, VERSION, load_config_or_env
|
||||
|
||||
__all__ = ["CameraControlBridge", "VERSION", "load_config_or_env"]
|
||||
@@ -0,0 +1,4 @@
|
||||
from .cli import main
|
||||
|
||||
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from .app import DEFAULT_CONFIG, LOG, CameraControlBridge, load_config_or_env
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--config", default=DEFAULT_CONFIG)
|
||||
parser.add_argument("--serve", action="store_true")
|
||||
parser.add_argument("--list", action="store_true")
|
||||
parser.add_argument("--check", action="store_true")
|
||||
parser.add_argument("--publish-discovery", action="store_true")
|
||||
parser.add_argument("--action", metavar="ID")
|
||||
parser.add_argument("command", nargs="?")
|
||||
parser.add_argument("--all-off", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
bridge = CameraControlBridge(load_config_or_env(args.config))
|
||||
|
||||
if args.list:
|
||||
bridge.list_actions()
|
||||
return 0
|
||||
if args.check:
|
||||
return bridge.check()
|
||||
if args.all_off:
|
||||
print(json.dumps(bridge.all_off(), indent=2))
|
||||
return 0
|
||||
if args.action:
|
||||
if not args.command:
|
||||
print("missing command", file=sys.stderr)
|
||||
return 2
|
||||
print(json.dumps(bridge.execute_action(args.action, args.command, publish=False), indent=2))
|
||||
return 0
|
||||
if args.publish_discovery:
|
||||
for credential in bridge.credentials:
|
||||
try:
|
||||
bridge.mqtt.connect(credential.get("username"), credential.get("password"))
|
||||
bridge.publish_discovery()
|
||||
bridge.mqtt.disconnect_cleanly()
|
||||
return 0
|
||||
except Exception as exc:
|
||||
LOG.warning("publish discovery failed using %s: %s", credential["source"], exc)
|
||||
return 1
|
||||
|
||||
bridge.serve()
|
||||
return 0
|
||||
@@ -0,0 +1,81 @@
|
||||
import gettext
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
LOG = logging.getLogger("frigate-camera-control-bridge")
|
||||
TRANSLATION_DOMAIN = "frigate_camera_control_bridge"
|
||||
DEFAULT_LANGUAGE = "en"
|
||||
DEFAULT_LOCALE_DIR = Path(__file__).resolve().parents[1] / "locale"
|
||||
LOCALE_DIR = Path(os.environ.get("LOCALE_DIR", DEFAULT_LOCALE_DIR))
|
||||
|
||||
_TRANSLATIONS = {}
|
||||
_AVAILABLE_LANGUAGES = None
|
||||
|
||||
|
||||
def parse_po_language_name(path):
|
||||
# gettext compiled catalogs do not expose a friendly language name, so the
|
||||
# editable .po file may carry an optional Language-Name header for the GUI.
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
for line in text.splitlines():
|
||||
if line.startswith('"Language-Name:'):
|
||||
return line.split(":", 1)[1].strip().strip("\\n\"")
|
||||
return None
|
||||
|
||||
|
||||
def available_languages():
|
||||
# Discover languages dynamically from locale/*/LC_MESSAGES/*.mo. Adding a
|
||||
# language should not require a code change; ship a new .po/.mo pair instead.
|
||||
global _AVAILABLE_LANGUAGES
|
||||
if _AVAILABLE_LANGUAGES is not None:
|
||||
return _AVAILABLE_LANGUAGES
|
||||
|
||||
languages = {DEFAULT_LANGUAGE: DEFAULT_LANGUAGE.upper()}
|
||||
if LOCALE_DIR.exists():
|
||||
for item in sorted(LOCALE_DIR.iterdir()):
|
||||
if not item.is_dir():
|
||||
continue
|
||||
code = item.name
|
||||
catalog = item / "LC_MESSAGES" / f"{TRANSLATION_DOMAIN}.mo"
|
||||
if catalog.exists():
|
||||
languages[code] = (
|
||||
parse_po_language_name(item / "LC_MESSAGES" / f"{TRANSLATION_DOMAIN}.po")
|
||||
or code.upper()
|
||||
)
|
||||
_AVAILABLE_LANGUAGES = languages
|
||||
return languages
|
||||
|
||||
|
||||
def normalize_language(value):
|
||||
if not value:
|
||||
return DEFAULT_LANGUAGE
|
||||
base = str(value).lower().replace("_", "-").split("-", 1)[0]
|
||||
return base if base in available_languages() else DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def translation_for(lang):
|
||||
# fallback=True makes English msgids work even if a translation file is
|
||||
# missing, which is useful while developing a new language catalog.
|
||||
lang = normalize_language(lang)
|
||||
if lang not in _TRANSLATIONS:
|
||||
_TRANSLATIONS[lang] = gettext.translation(
|
||||
TRANSLATION_DOMAIN,
|
||||
localedir=str(LOCALE_DIR),
|
||||
languages=[lang],
|
||||
fallback=True,
|
||||
)
|
||||
return _TRANSLATIONS[lang]
|
||||
|
||||
|
||||
def text_for(lang, msgid, **kwargs):
|
||||
text = translation_for(lang).gettext(msgid)
|
||||
if kwargs:
|
||||
try:
|
||||
return text.format(**kwargs)
|
||||
except KeyError as exc:
|
||||
LOG.warning("missing translation placeholder %s for %s", exc, msgid)
|
||||
return text
|
||||
@@ -0,0 +1,383 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f4f6f8;
|
||||
--panel: #ffffff;
|
||||
--text: #17202a;
|
||||
--muted: #627083;
|
||||
--line: #d8dee7;
|
||||
--line-strong: #b9c3cf;
|
||||
--blue: #1d4ed8;
|
||||
--blue-dark: #1e40af;
|
||||
--green: #047857;
|
||||
--green-dark: #065f46;
|
||||
--red: #b42318;
|
||||
--red-soft: #fff1f0;
|
||||
--amber: #b45309;
|
||||
--shadow: 0 14px 34px rgba(17, 24, 39, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: min(1280px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 28px 0 36px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.title-block {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.35rem, 1.2rem + 0.8vw, 2rem);
|
||||
line-height: 1.1;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 9px;
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.summary strong {
|
||||
color: var(--text);
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.language-form {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
select {
|
||||
min-height: 38px;
|
||||
padding: 6px 34px 6px 10px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.control-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 310px), 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 388px;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.media {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: #dce3eb;
|
||||
}
|
||||
|
||||
.snapshot {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.media-empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: #6b7788;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.media-empty span {
|
||||
margin-top: 78px;
|
||||
}
|
||||
|
||||
.visual-badge {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
display: grid;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 23, 42, 0.74);
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.28);
|
||||
}
|
||||
|
||||
.visual-badge svg {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.media-empty .visual-badge {
|
||||
top: 50%;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
left: 50%;
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
transform: translate(-50%, -58%);
|
||||
border-color: var(--line);
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.visual-siren {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.visual-red-blue {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.visual-white-light {
|
||||
color: #facc15;
|
||||
}
|
||||
|
||||
.visual-all-off {
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.visual-generic {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.card-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.02rem;
|
||||
line-height: 1.25;
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.action-id {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
min-height: 28px;
|
||||
align-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
background: #f8fafc;
|
||||
color: #465568;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chip-state-on {
|
||||
border-color: #9dd8bd;
|
||||
background: #edfdf5;
|
||||
color: var(--green-dark);
|
||||
}
|
||||
|
||||
.chip-state-off {
|
||||
border-color: #f0b8b4;
|
||||
background: var(--red-soft);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.chip-state-unknown {
|
||||
border-color: #e6d2a6;
|
||||
background: #fff8e7;
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.timer {
|
||||
display: inline-flex;
|
||||
min-height: 30px;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 6px;
|
||||
padding: 5px 9px;
|
||||
background: #eff6ff;
|
||||
color: #1e40af;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.timer-idle {
|
||||
border-color: var(--line);
|
||||
background: #f8fafc;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.timer-expired {
|
||||
border-color: #e6d2a6;
|
||||
background: #fff8e7;
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.command-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(112px, 1fr));
|
||||
gap: 9px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.command-form {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cmd {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 6px;
|
||||
padding: 9px 12px;
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 680;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.cmd span {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cmd:hover {
|
||||
border-color: var(--blue);
|
||||
}
|
||||
|
||||
.cmd:focus-visible {
|
||||
outline: 3px solid rgba(29, 78, 216, 0.25);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.cmd-on,
|
||||
.cmd-press {
|
||||
border-color: var(--blue);
|
||||
background: var(--blue);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.cmd-on:hover,
|
||||
.cmd-press:hover {
|
||||
border-color: var(--blue-dark);
|
||||
background: var(--blue-dark);
|
||||
}
|
||||
|
||||
.cmd-off {
|
||||
border-color: #d7a5a1;
|
||||
background: #ffffff;
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.cmd-off:hover {
|
||||
border-color: var(--red);
|
||||
background: var(--red-soft);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
border: 1px dashed var(--line-strong);
|
||||
border-radius: 8px;
|
||||
padding: 28px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.shell {
|
||||
width: min(100% - 20px, 1280px);
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar,
|
||||
.language-form {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
select {
|
||||
max-width: 60%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
function formatRemaining(totalSeconds) {
|
||||
const seconds = Math.max(0, Math.ceil(totalSeconds));
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const rest = seconds % 60;
|
||||
return `${minutes}:${String(rest).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function updateTimers() {
|
||||
const now = Date.now() / 1000;
|
||||
document.querySelectorAll("[data-auto-off-at]").forEach((item) => {
|
||||
const target = Number(item.dataset.autoOffAt || 0);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const remaining = target - now;
|
||||
if (remaining <= 0) {
|
||||
item.textContent = item.dataset.expiredLabel || "Auto-off pending";
|
||||
item.classList.add("timer-expired");
|
||||
return;
|
||||
}
|
||||
item.textContent = `${item.dataset.prefix || "Auto-off"} ${formatRemaining(remaining)}`;
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("submit", (event) => {
|
||||
const form = event.target;
|
||||
if (!(form instanceof HTMLFormElement) || form.dataset.requiresAck !== "true") {
|
||||
return;
|
||||
}
|
||||
const message = form.dataset.ackMessage || "Confirm this action";
|
||||
if (!window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
updateTimers();
|
||||
setInterval(updateTimers, 1000);
|
||||
@@ -0,0 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="{lang}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{title}</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<div class="topbar">
|
||||
<div class="title-block">
|
||||
<h1>{title}</h1>
|
||||
<div class="summary"><strong>{action_count}</strong><span>{actions_label}</span></div>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<form method="get" action="/" class="language-form">
|
||||
<label for="lang">{language_label}</label>
|
||||
{token_input}
|
||||
<select id="lang" name="lang" onchange="this.form.submit()">
|
||||
{language_options}
|
||||
</select>
|
||||
<noscript><button type="submit">OK</button></noscript>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<section class="control-grid">
|
||||
{cards}
|
||||
</section>
|
||||
</main>
|
||||
<script src="/static/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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