Modularize bridge and improve control GUI

This commit is contained in:
2026-08-03 00:55:12 +02:00
parent 73b302a965
commit f4ab49e0c8
26 changed files with 3104 additions and 1953 deletions
@@ -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
+50
View File
@@ -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
+81
View File
@@ -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>
+217
View File
@@ -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,
)