Compare commits

...
10 Commits
18 changed files with 1290 additions and 50 deletions
+4
View File
@@ -14,6 +14,8 @@ through Home Assistant MQTT Discovery plus an emergency HTTP fallback GUI.
can update MQTT Discovery without a container restart.
- On startup it runs a safe `all-off` by default, sets switch entities to `OFF`,
and publishes retained MQTT state.
- Home Assistant MQTT Discovery payloads include semantic MDI icons for all-off,
siren, red-blue/strobe, and white-light entities.
- Alarms and lights have default auto-off timers: siren 180s, red-blue/strobe
300s, white light 600s. The GUI timer is rendered from a backend timestamp,
so page refreshes do not reset it.
@@ -36,6 +38,8 @@ through Home Assistant MQTT Discovery plus an emergency HTTP fallback GUI.
| --- | --- |
| `FRIGATE_NAME` | Instance name, for example `frigate-main`. |
| `FRIGATE_URL` | Frigate API URL, for example `http://frigate-main.example.test:5000`. |
| `FRIGATE_PUBLIC_URL` | Optional public GUI URL used by camera stream links, for example a reverse proxy URL. If omitted, stream links use `FRIGATE_URL`. |
| `FRIGATE_CAMERA_PATH_TEMPLATE` | Optional camera page path or full URL template, defaults to `/#{camera_quoted}`. Available placeholders: `{camera}`, `{camera_quoted}`, `{frigate_url}`, `{frigate_public_url}`. |
| `MQTT_HOST`, `MQTT_PORT` | MQTT broker. |
| `MQTT_USER`, `MQTT_PASSWORD` | MQTT credentials. |
| `DEFAULT_CAMERA_USER`, `DEFAULT_CAMERA_PASSWORD` | Default credentials for cameras discovered from Frigate. |
@@ -8,6 +8,8 @@ DEFAULT_CAMERA_PASSWORD=change-me
HTTP_PORT=5011
UI_LANGUAGE=en
FRIGATE_PUBLIC_URL=
FRIGATE_CAMERA_PATH_TEMPLATE=/#{camera_quoted}
WAIT_FOR_FRIGATE=true
FRIGATE_RETRY_SECONDS=5
FRIGATE_STARTUP_WAIT_SECONDS=0
@@ -11,6 +11,8 @@ services:
FRIGATE_NAME: frigate-main
# Set this to the Frigate container DNS name or another reachable URL.
FRIGATE_URL: http://frigate:5000
# Optional public Frigate GUI URL for stream links shown in the bridge GUI.
FRIGATE_PUBLIC_URL: ""
BASE_TOPIC: frigate_camera_control/frigate-main
ports:
- "5011:5011"
+2
View File
@@ -26,6 +26,8 @@ services:
# Because both containers share the same Compose network, the sidecar can
# reach Frigate by service name.
FRIGATE_URL: http://frigate:5000
# Optional public Frigate GUI URL for stream links shown in the bridge GUI.
FRIGATE_PUBLIC_URL: ""
BASE_TOPIC: frigate_camera_control/frigate-main
ports:
- "5011:5011"
+4
View File
@@ -25,6 +25,10 @@ spec:
value: frigate-main
- name: FRIGATE_URL
value: http://127.0.0.1:5000
- name: FRIGATE_PUBLIC_URL
value: ""
- name: FRIGATE_CAMERA_PATH_TEMPLATE
value: /#{camera_quoted}
- name: MQTT_HOST
value: mqtt.default.svc.cluster.local
- name: BASE_TOPIC
+4
View File
@@ -53,6 +53,10 @@ spec:
value: frigate-main
- name: FRIGATE_URL
value: http://frigate:5000
- name: FRIGATE_PUBLIC_URL
value: ""
- name: FRIGATE_CAMERA_PATH_TEMPLATE
value: /#{camera_quoted}
- name: MQTT_HOST
value: mqtt.default.svc.cluster.local
- name: BASE_TOPIC
@@ -12,6 +12,8 @@ DEFAULT_CAMERA_PASSWORD=change-me
HTTP_PORT=5011
UI_LANGUAGE=en
FRIGATE_PUBLIC_URL=
FRIGATE_CAMERA_PATH_TEMPLATE=/#{camera_quoted}
WAIT_FOR_FRIGATE=true
FRIGATE_RETRY_SECONDS=5
FRIGATE_STARTUP_WAIT_SECONDS=0
+235 -31
View File
@@ -19,6 +19,12 @@ VERSION = "1.0"
DEFAULT_CONFIG = "/etc/frigate-camera-control-bridge.json"
LOG = logging.getLogger("frigate-camera-control-bridge")
STREAM_URL_RE = re.compile(r"(?:rtsp|rtsps|http|https)://[^\s'\"<>]+", re.IGNORECASE)
HOME_ASSISTANT_ACTION_ICONS = {
"action.siren": "mdi:bullhorn",
"action.red_blue": "mdi:alarm-light",
"action.manual_white": "mdi:lightbulb-on",
"action.all_off": "mdi:power",
}
from .i18n import DEFAULT_LANGUAGE, available_languages, normalize_language, text_for
from .ui import read_static_file, render_page
@@ -235,6 +241,11 @@ def config_from_env():
"snapshot_retry_seconds": int(os.environ.get("SNAPSHOT_RETRY_SECONDS", "600")),
"snapshot_refresh_seconds": int(os.environ.get("SNAPSHOT_REFRESH_SECONDS", "3600")),
"snapshot_live_on_gui": env_bool("SNAPSHOT_LIVE_ON_GUI", True),
"frigate_public_url": os.environ.get("FRIGATE_PUBLIC_URL"),
"frigate_camera_path_template": os.environ.get(
"FRIGATE_CAMERA_PATH_TEMPLATE",
"/#{camera_quoted}",
),
"frigate_refresh_seconds": int(os.environ.get("FRIGATE_REFRESH_SECONDS", "300")),
"auto_off_siren_seconds": int(os.environ.get("AUTO_OFF_SIREN_SECONDS", "180")),
"auto_off_red_blue_seconds": int(os.environ.get("AUTO_OFF_RED_BLUE_SECONDS", "300")),
@@ -333,6 +344,7 @@ class Action:
self.state = None
self.auto_off_seconds = int(cfg.get("auto_off_seconds") or 0)
self.auto_off_at = None
self.snapshot_version = 0
def display_name(self, lang):
names = self.cfg.get("name_i18n") or {}
@@ -344,7 +356,7 @@ class Action:
if not key:
return self.name
prefix = self.cfg.get("camera_name") or self.cfg.get("frigate_name") or ""
prefix = self.cfg.get("camera_display_name") or self.cfg.get("camera_name") or self.cfg.get("frigate_name") or ""
return text_for(normalized, key, target=prefix).strip()
def command_list(self):
@@ -714,6 +726,7 @@ class CameraControlBridge:
continue
action.state = old.state
action.auto_off_at = old.auto_off_at
action.snapshot_version = old.snapshot_version
def initialize_action_states(self):
for action in self.actions.values():
@@ -800,12 +813,14 @@ class CameraControlBridge:
urls = self.camera_candidate_urls(camera_name, camera_cfg, streams)
endpoints = self.camera_endpoints_from_urls(urls)
kinds = self.kinds_from_urls(urls)
camera_display_name = self.camera_display_name(camera_name, camera_cfg)
for host in endpoints:
inventory.append(
{
"frigate": name,
"frigate_url": base_url,
"camera": camera_name,
"camera_display_name": camera_display_name,
"host": host,
"kinds": kinds,
}
@@ -813,6 +828,15 @@ class CameraControlBridge:
LOG.info("discovered %d camera host mapping(s) from Frigate", len(inventory))
return inventory
@staticmethod
def camera_display_name(camera_name, camera_cfg):
# Frigate camera keys remain the API identifier. friendly_name is only
# used for human-facing labels when present.
friendly_name = camera_cfg.get("friendly_name")
if friendly_name:
return str(friendly_name)
return camera_name
def load_frigate_config_with_retry(self, frigate, base_url):
discovery = self.config.get("discovery", {})
wait = bool(discovery.get("wait_for_frigate", True))
@@ -1074,6 +1098,7 @@ class CameraControlBridge:
"name": text_for(lang, "action.manual_white", target=item["camera"]),
"name_key": "action.manual_white",
"camera_name": item["camera"],
"camera_display_name": item.get("camera_display_name", item["camera"]),
"platform": "switch",
"kind": "hikvision_colorvu",
"host": host,
@@ -1082,7 +1107,7 @@ class CameraControlBridge:
"snapshot": self.snapshot_config(item),
"device": {
"identifiers": [f"camera_{host.replace('.', '_')}"],
"name": item["camera"],
"name": item.get("camera_display_name", item["camera"]),
"manufacturer": "Hikvision",
"model": model,
"configuration_url": item["frigate_url"],
@@ -1113,6 +1138,7 @@ class CameraControlBridge:
"name": text_for(lang, "action.red_blue", target=item["camera"]),
"name_key": "action.red_blue",
"camera_name": item["camera"],
"camera_display_name": item.get("camera_display_name", item["camera"]),
"platform": "switch",
"kind": "dahua_coaxial_io",
"host": host,
@@ -1131,6 +1157,7 @@ class CameraControlBridge:
"name": text_for(lang, "action.siren", target=item["camera"]),
"name_key": "action.siren",
"camera_name": item["camera"],
"camera_display_name": item.get("camera_display_name", item["camera"]),
"platform": "switch",
"kind": "dahua_coaxial_io",
"host": host,
@@ -1175,18 +1202,24 @@ class CameraControlBridge:
def discovered_device(item, manufacturer, model):
return {
"identifiers": [f"camera_{item['host'].replace('.', '_')}"],
"name": item["camera"],
"name": item.get("camera_display_name", item["camera"]),
"manufacturer": manufacturer,
"model": model,
"configuration_url": item["frigate_url"],
}
@staticmethod
def snapshot_config(item):
return {
def snapshot_config(self, item):
config = {
"frigate_url": item["frigate_url"],
"camera": item["camera"],
"camera_display_name": item.get("camera_display_name", item["camera"]),
}
defaults = self.config.get("defaults", {})
if defaults.get("frigate_public_url"):
config["public_url"] = defaults["frigate_public_url"]
if defaults.get("frigate_camera_path_template"):
config["camera_path_template"] = defaults["frigate_camera_path_template"]
return config
@staticmethod
def xml_text(xml, tag):
@@ -1243,6 +1276,23 @@ class CameraControlBridge:
LOG.info("snapshot unavailable for %s: %s", action.id, exc)
return ok
@staticmethod
def should_refresh_snapshot_after_command(action, command):
if command.lower() not in ("on", "off", "safe_off"):
return False
return action.cfg.get("name_key") in ("action.manual_white", "action.red_blue")
def invalidate_action_snapshot(self, action):
url = self.snapshot_url(action)
if not url:
return
with self.action_lock:
self.snapshot_cache.pop(url, None)
version = int(time.time() * 1000)
for item in self.actions.values():
if self.snapshot_url(item) == url:
item.snapshot_version = version
def snapshot_url(self, action):
snapshot = action.cfg.get("snapshot")
if not snapshot:
@@ -1357,6 +1407,9 @@ class CameraControlBridge:
},
"qos": 0,
}
icon = self.home_assistant_icon(action)
if icon:
payload["icon"] = icon
if action.platform == "switch":
payload.update(
{
@@ -1380,16 +1433,34 @@ class CameraControlBridge:
)
return payload
@staticmethod
def home_assistant_icon(action):
# Home Assistant MQTT discovery accepts MDI icons on switches and
# buttons. Keep icons semantic and overrideable by static action config.
configured = action.cfg.get("icon")
if configured:
return configured
name_key = action.cfg.get("name_key")
if name_key in HOME_ASSISTANT_ACTION_ICONS:
return HOME_ASSISTANT_ACTION_ICONS[name_key]
if action.kind == "all_off":
return HOME_ASSISTANT_ACTION_ICONS["action.all_off"]
return None
def execute_action(self, action_id, command, publish=True):
action = self.action_by_id(action_id)
if action is None:
raise RuntimeError(f"unknown action {action_id}")
if command == "on" and self.auto_off_running(action):
raise RuntimeError("action is already active until automatic turn-off")
result = action.run(command, bridge=self)
if action.platform == "switch":
if command == "on":
self.schedule_auto_off(action)
elif command in ("off", "safe_off"):
self.cancel_auto_off(action.id)
if self.should_refresh_snapshot_after_command(action, command):
self.invalidate_action_snapshot(action)
if publish:
try:
self.publish_result(action, command, result)
@@ -1397,6 +1468,10 @@ class CameraControlBridge:
LOG.warning("action %s %s worked, but MQTT publish failed: %s", action_id, command, exc)
return result
@staticmethod
def auto_off_running(action):
return bool(getattr(action, "auto_off_at", None))
def schedule_auto_off(self, action):
seconds = int(action.auto_off_seconds or 0)
if seconds <= 0:
@@ -1500,10 +1575,82 @@ class CameraControlBridge:
self.inherit_action_runtime_state(old_actions, new_actions)
self.actions = new_actions
self.refresh_snapshots()
old_ids = set(old_actions)
new_ids = set(new_actions)
old_cameras = self.camera_keys_from_actions(old_actions.values())
new_cameras = self.camera_keys_from_actions(new_actions.values())
result = {
"ok": True,
"added_actions": len(new_ids - old_ids),
"removed_actions": len(old_ids - new_ids),
"added_cameras": len(new_cameras - old_cameras),
"removed_cameras": len(old_cameras - new_cameras),
"total_actions": len(new_ids),
"total_cameras": len(new_cameras),
"mqtt_published": True,
}
result["changed"] = any(
result[key] > 0
for key in ("added_actions", "removed_actions", "added_cameras", "removed_cameras")
)
try:
self.publish_discovery()
except Exception as exc:
result["mqtt_published"] = False
LOG.warning("Frigate refresh could not publish MQTT discovery: %s", exc)
return result
@staticmethod
def camera_keys_from_actions(actions):
keys = set()
for action in actions:
if action.kind == "all_off":
continue
snapshot = action.cfg.get("snapshot") or {}
camera = snapshot.get("camera") or action.cfg.get("camera_name")
if camera:
keys.add((snapshot.get("frigate_url"), camera))
return keys
@staticmethod
def refresh_message(lang, result):
if result.get("added_cameras") or result.get("removed_cameras"):
return text_for(
lang,
"refresh.cameras_changed",
added=result.get("added_cameras", 0),
removed=result.get("removed_cameras", 0),
)
if result.get("added_actions") or result.get("removed_actions"):
return text_for(
lang,
"refresh.controls_changed",
added=result.get("added_actions", 0),
removed=result.get("removed_actions", 0),
)
return text_for(lang, "refresh.no_changes")
def refresh_payload(self, lang, result, redirect_url):
payload = dict(result)
payload["message"] = self.refresh_message(lang, result)
payload["redirect_url"] = redirect_url
return payload
def action_payload(self, lang, action, command, result, redirect_url):
ok = bool(result.get("ok", True))
payload = {
"ok": ok,
"command": command,
"message": text_for(lang, "action.success" if ok else "action.failed_generic"),
"redirect_url": redirect_url,
"result": result,
}
if action is not None:
payload["action_id"] = action.id
payload["action"] = self.api_action(action, lang)
if action.kind == "all_off":
payload["actions"] = [self.api_action(item, lang) for item in self.actions_snapshot()]
return payload
def frigate_refresh_loop(self):
interval = int(self.config.get("defaults", {}).get("frigate_refresh_seconds", 300))
@@ -1636,7 +1783,9 @@ class CameraControlBridge:
if parsed.path.startswith("/snapshot/"):
action_id = urllib.parse.unquote(parsed.path.rsplit("/", 1)[-1]).removesuffix(".jpg")
try:
live = bool(bridge.config.get("defaults", {}).get("snapshot_live_on_gui", True))
values = urllib.parse.parse_qs(parsed.query)
refresh = values.get("refresh", ["0"])[0].lower() in ("1", "true", "yes", "on")
live = refresh or bool(bridge.config.get("defaults", {}).get("snapshot_live_on_gui", True))
snapshot = bridge.snapshot_for_action(action_id, live=live)
except Exception as exc:
LOG.info("snapshot request failed for %s: %s", action_id, exc)
@@ -1660,28 +1809,76 @@ class CameraControlBridge:
self.send_response(401)
self.end_headers()
return
if parsed.path == "/refresh":
token_param = urllib.parse.parse_qs(parsed.query).get("token", [None])[0]
token_query = f"&token={urllib.parse.quote(token_param)}" if token_param else ""
redirect_url = f"/?lang={urllib.parse.quote(lang)}{token_query}"
wants_json = "application/json" in self.headers.get("Accept", "").lower()
try:
result = bridge.refresh_frigate_inventory()
if wants_json:
self.send_json(bridge.refresh_payload(lang, result, redirect_url))
else:
self.send_response(303)
self.send_header("Location", redirect_url)
self.end_headers()
except Exception as exc:
if wants_json:
self.send_json(
{
"ok": False,
"changed": False,
"message": text_for(lang, "refresh.failed", error=str(exc)),
"redirect_url": redirect_url,
},
status=500,
)
else:
self.send_response(500)
self.end_headers()
self.wfile.write(str(exc).encode("utf-8"))
return
parts = parsed.path.strip("/").split("/")
if len(parts) == 3 and parts[0] == "action":
action_id = urllib.parse.unquote(parts[1])
command = urllib.parse.unquote(parts[2]).lower()
token_param = urllib.parse.parse_qs(parsed.query).get("token", [None])[0]
token_query = f"&token={urllib.parse.quote(token_param)}" if token_param else ""
redirect_url = f"/?lang={urllib.parse.quote(lang)}{token_query}"
wants_json = "application/json" in self.headers.get("Accept", "").lower()
try:
result = bridge.execute_action(action_id, command, publish=True)
token_param = urllib.parse.parse_qs(parsed.query).get("token", [None])[0]
token_query = f"&token={urllib.parse.quote(token_param)}" if token_param else ""
self.send_response(303)
self.send_header("Location", f"/?lang={urllib.parse.quote(lang)}{token_query}")
self.end_headers()
action = bridge.action_by_id(action_id)
if wants_json:
payload = bridge.action_payload(lang, action, command, result, redirect_url)
self.send_json(payload, status=200 if payload.get("ok") else 500)
else:
self.send_response(303)
self.send_header("Location", redirect_url)
self.end_headers()
except Exception as exc:
self.send_response(500)
self.end_headers()
self.wfile.write(str(exc).encode("utf-8"))
if wants_json:
self.send_json(
{
"ok": False,
"action_id": action_id,
"command": command,
"message": text_for(lang, "action.failed", error=str(exc)),
"redirect_url": redirect_url,
},
status=500,
)
else:
self.send_response(500)
self.end_headers()
self.wfile.write(str(exc).encode("utf-8"))
return
self.send_response(404)
self.end_headers()
def send_json(self, payload):
def send_json(self, payload, status=200):
data = json.dumps(payload, indent=2).encode("utf-8")
self.send_response(200)
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
@@ -1712,20 +1909,27 @@ class CameraControlBridge:
LOG.info("HTTP fallback GUI listening on %s:%s", bind, port)
def api_actions(self):
return [
{
"id": action.id,
"name": action.display_name(self.default_language),
"platform": action.platform,
"commands": action.command_list(),
"state": action.state,
"snapshot": bool(action.cfg.get("snapshot")),
"auto_off_seconds": action.auto_off_seconds,
"auto_off_at": int(action.auto_off_at) if action.auto_off_at else None,
"requires_ack": bool(action.cfg.get("requires_ack")),
}
for action in self.actions_snapshot()
]
return [self.api_action(action, self.default_language) for action in self.actions_snapshot()]
def api_action(self, action, lang=None):
lang = lang or self.default_language
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()
return {
"id": action.id,
"name": action.display_name(lang),
"platform": action.platform,
"commands": action.command_list(),
"state": action.state,
"state_label": state_label,
"snapshot": bool(action.cfg.get("snapshot")),
"snapshot_version": int(action.snapshot_version or 0),
"auto_off_seconds": action.auto_off_seconds,
"auto_off_at": int(action.auto_off_at) if action.auto_off_at else None,
"requires_ack": bool(action.cfg.get("requires_ack")),
}
def html_page(self, lang=None, token_param=None):
with self.action_lock:
@@ -76,6 +76,73 @@ h1 {
justify-content: flex-end;
}
.refresh-form {
margin: 0;
}
.refresh-button {
display: grid;
width: 38px;
height: 38px;
place-items: center;
border: 1px solid var(--line-strong);
border-radius: 6px;
padding: 0;
background: #ffffff;
color: #1f3a5f;
cursor: pointer;
}
.refresh-button:hover {
border-color: var(--blue);
color: var(--blue-dark);
}
.refresh-button:focus-visible {
outline: 3px solid rgba(29, 78, 216, 0.25);
outline-offset: 2px;
}
.refresh-button svg {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2;
}
.refresh-button.is-refreshing svg {
animation: refresh-spin 0.9s linear infinite;
}
.refresh-status {
display: none;
max-width: 260px;
color: var(--muted);
font-size: 0.86rem;
line-height: 1.25;
}
.refresh-status.is-visible {
display: inline-flex;
}
.refresh-status.is-success {
color: var(--green-dark);
}
.refresh-status.is-error {
color: var(--red);
}
@keyframes refresh-spin {
to {
transform: rotate(360deg);
}
}
.language-form {
display: inline-flex;
align-items: center;
@@ -146,6 +213,45 @@ select {
object-fit: cover;
}
.snapshot-refresh {
position: absolute;
top: 12px;
right: 12px;
display: grid;
width: 38px;
height: 38px;
place-items: center;
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: 6px;
background: rgba(15, 23, 42, 0.72);
color: #ffffff;
cursor: pointer;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.24);
}
.snapshot-refresh:hover {
background: rgba(15, 23, 42, 0.88);
}
.snapshot-refresh:focus-visible {
outline: 3px solid rgba(29, 78, 216, 0.35);
outline-offset: 2px;
}
.snapshot-refresh.is-refreshing svg {
animation: refresh-spin 0.9s linear infinite;
}
.snapshot-refresh svg {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2;
}
.media-empty {
display: grid;
place-items: center;
@@ -235,6 +341,13 @@ select {
min-width: 0;
}
.card-title-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 34px;
gap: 8px;
align-items: start;
}
h2 {
margin: 0 0 8px;
font-size: 1.02rem;
@@ -243,6 +356,38 @@ h2 {
overflow-wrap: anywhere;
}
.stream-link {
display: grid;
width: 34px;
height: 34px;
place-items: center;
border: 1px solid var(--line);
border-radius: 6px;
background: #ffffff;
color: #1f3a5f;
text-decoration: none;
}
.stream-link:hover {
border-color: var(--blue);
color: var(--blue-dark);
}
.stream-link:focus-visible {
outline: 3px solid rgba(29, 78, 216, 0.25);
outline-offset: 2px;
}
.stream-link svg {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2;
}
.camera-count {
display: inline-flex;
color: var(--muted);
@@ -408,6 +553,17 @@ h3 {
outline-offset: 2px;
}
.cmd:disabled {
border-color: var(--line);
background: #eef2f6;
color: #8290a2;
cursor: not-allowed;
}
.cmd.is-working {
opacity: 0.82;
}
.cmd-on,
.cmd-press {
border-color: var(--blue);
@@ -415,12 +571,24 @@ h3 {
color: #ffffff;
}
.cmd-on:disabled {
border-color: var(--line);
background: #eef2f6;
color: #8290a2;
}
.cmd-on:hover,
.cmd-press:hover {
border-color: var(--blue-dark);
background: var(--blue-dark);
}
.cmd-on:disabled:hover {
border-color: var(--line);
background: #eef2f6;
color: #8290a2;
}
.cmd-off {
border-color: #9dd8bd;
background: #ffffff;
@@ -432,6 +600,26 @@ h3 {
background: #edfdf5;
}
.action-status {
display: none;
color: var(--muted);
font-size: 0.84rem;
line-height: 1.35;
overflow-wrap: anywhere;
}
.action-status.is-visible {
display: block;
}
.action-status.is-success {
color: var(--green-dark);
}
.action-status.is-error {
color: var(--red);
}
.global-strip {
flex: 1;
display: flex;
@@ -482,6 +670,12 @@ h3 {
min-width: 120px;
}
.global-controls-wrap {
display: grid;
min-width: 120px;
gap: 8px;
}
.empty-state {
border: 1px dashed var(--line-strong);
border-radius: 8px;
@@ -517,6 +711,14 @@ h3 {
width: 100%;
}
.refresh-form {
margin-left: auto;
}
.refresh-status {
max-width: 100%;
}
.grid-single,
.control-grid {
grid-template-columns: 1fr;
+310 -6
View File
@@ -1,3 +1,5 @@
const SNAPSHOT_REFRESH_DELAY_MS = 5000;
function formatRemaining(totalSeconds) {
const seconds = Math.max(0, Math.ceil(totalSeconds));
const minutes = Math.floor(seconds / 60);
@@ -22,16 +24,318 @@ function updateTimers() {
});
}
document.addEventListener("submit", (event) => {
const form = event.target;
if (!(form instanceof HTMLFormElement) || form.dataset.requiresAck !== "true") {
function refreshStatusElement() {
return document.querySelector("[data-refresh-status]");
}
function showRefreshMessage(message, type = "success") {
const status = refreshStatusElement();
if (!status) {
return;
}
const message = form.dataset.ackMessage || "Confirm this action";
if (!window.confirm(message)) {
event.preventDefault();
status.textContent = message || "";
status.classList.toggle("is-visible", Boolean(message));
status.classList.toggle("is-success", type === "success");
status.classList.toggle("is-error", type === "error");
}
function rememberRefreshMessage(message) {
try {
window.sessionStorage.setItem("frigateRefreshMessage", message);
} catch (_error) {
// Some browsers may block sessionStorage; the refresh still succeeds.
}
}
function restoreRefreshMessage() {
try {
const message = window.sessionStorage.getItem("frigateRefreshMessage");
if (!message) {
return;
}
window.sessionStorage.removeItem("frigateRefreshMessage");
showRefreshMessage(message, "success");
} catch (_error) {
// Ignore storage errors and keep the GUI usable.
}
}
async function handleRefreshSubmit(form, event) {
event.preventDefault();
const button = form.querySelector(".refresh-button");
if (button && button.classList.contains("is-refreshing")) {
return;
}
const status = refreshStatusElement();
const workingLabel = status?.dataset.workingLabel || "Refreshing cameras...";
showRefreshMessage(workingLabel, "success");
if (button) {
button.disabled = true;
button.classList.add("is-refreshing");
button.setAttribute("aria-busy", "true");
}
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
});
const payload = await response.json();
if (!response.ok || payload.ok === false) {
throw new Error(payload.message || "Refresh failed");
}
showRefreshMessage(payload.message, "success");
if (payload.changed && payload.redirect_url) {
rememberRefreshMessage(payload.message);
window.setTimeout(() => {
window.location.assign(payload.redirect_url);
}, 900);
return;
}
} catch (error) {
showRefreshMessage(error.message || "Refresh failed", "error");
} finally {
if (button) {
button.disabled = false;
button.classList.remove("is-refreshing");
button.removeAttribute("aria-busy");
}
}
}
function refreshSnapshot(button) {
if (button.classList.contains("is-refreshing")) {
return;
}
const media = button.closest(".media");
const image = media?.querySelector("img.snapshot");
if (!image) {
return;
}
const url = new URL(image.currentSrc || image.src, window.location.href);
url.searchParams.set("refresh", "1");
url.searchParams.set("v", String(Date.now()));
const cleanup = () => {
button.disabled = false;
button.classList.remove("is-refreshing");
button.removeAttribute("aria-busy");
};
button.disabled = true;
button.classList.add("is-refreshing");
button.setAttribute("aria-busy", "true");
image.addEventListener("load", cleanup, { once: true });
image.addEventListener("error", cleanup, { once: true });
window.setTimeout(() => {
image.src = url.toString();
}, SNAPSHOT_REFRESH_DELAY_MS);
}
function actionContainerById(actionId) {
for (const container of document.querySelectorAll("[data-action-id]")) {
if (container.dataset.actionId === actionId) {
return container;
}
}
return null;
}
function actionStatusForForm(form) {
return form.closest(".action-control, .global-action")?.querySelector("[data-action-status]");
}
function showActionMessage(form, message, type = "success") {
const status = actionStatusForForm(form);
if (!status) {
return;
}
status.textContent = message || "";
status.classList.toggle("is-visible", Boolean(message));
status.classList.toggle("is-success", type === "success");
status.classList.toggle("is-error", type === "error");
}
function setCommandBusy(button, busy) {
if (!button) {
return;
}
if (busy) {
button.disabled = true;
button.classList.add("is-working");
button.setAttribute("aria-busy", "true");
return;
}
if (button.dataset.timerLocked !== "true") {
button.disabled = false;
}
button.classList.remove("is-working");
button.removeAttribute("aria-busy");
}
function labelFromStateChip(chip, state) {
const key = `label${state.charAt(0).toUpperCase()}${state.slice(1)}`;
return chip.dataset[key] || state.toUpperCase();
}
function updateStateChip(container, action) {
const chip = container.querySelector("[data-state-chip]");
if (!chip || !action.state) {
return;
}
const state = String(action.state).toLowerCase();
for (const className of Array.from(chip.classList)) {
if (className.startsWith("chip-state-")) {
chip.classList.remove(className);
}
}
chip.classList.add(`chip-state-${state}`);
chip.textContent = action.state_label || labelFromStateChip(chip, state);
}
function updateTimer(container, action) {
const timer = container.querySelector("[data-timer]");
if (!timer) {
return;
}
if (action.auto_off_at) {
timer.dataset.autoOffAt = String(action.auto_off_at);
timer.classList.remove("timer-idle", "timer-expired");
updateTimers();
return;
}
delete timer.dataset.autoOffAt;
timer.classList.add("timer-idle");
timer.classList.remove("timer-expired");
timer.textContent = timer.dataset.idleLabel || "";
}
function updateOnButtonLock(container, action) {
const button = container.querySelector(".cmd-on");
if (!button) {
return;
}
if (action.auto_off_at) {
const label = button.dataset.disabledLabel || "Already active until automatic turn-off";
button.dataset.timerLocked = "true";
button.disabled = true;
button.title = label;
button.setAttribute("aria-label", label);
return;
}
delete button.dataset.timerLocked;
if (!button.classList.contains("is-working")) {
button.disabled = false;
}
button.removeAttribute("title");
button.removeAttribute("aria-label");
}
function applyActionState(action) {
if (!action?.id) {
return;
}
const container = actionContainerById(action.id);
if (!container) {
return;
}
updateStateChip(container, action);
updateTimer(container, action);
updateOnButtonLock(container, action);
}
function applyActionPayload(payload) {
if (Array.isArray(payload.actions)) {
payload.actions.forEach(applyActionState);
return;
}
applyActionState(payload.action);
}
function refreshSnapshotForCommand(form) {
const button = form.closest(".camera-card")?.querySelector(".snapshot-refresh");
if (!button || button.classList.contains("is-refreshing")) {
return;
}
refreshSnapshot(button);
}
async function responseJson(response) {
const text = await response.text();
if (!text) {
return {};
}
try {
return JSON.parse(text);
} catch (_error) {
return { ok: false, message: text };
}
}
function commandErrorMessage(form, error) {
const fallback = form.dataset.failedLabel || "Command failed.";
if (!error) {
return fallback;
}
if (error.name === "TypeError") {
return form.dataset.networkFailedLabel || fallback;
}
return error.message || fallback;
}
async function handleCommandSubmit(form, event) {
event.preventDefault();
if (form.dataset.requiresAck === "true") {
const message = form.dataset.ackMessage || "Confirm this action";
if (!window.confirm(message)) {
return;
}
}
const button = form.querySelector(".cmd");
if (button?.classList.contains("is-working")) {
return;
}
showActionMessage(form, form.dataset.workingLabel || "Sending command...", "success");
setCommandBusy(button, true);
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
});
const payload = await responseJson(response);
applyActionPayload(payload);
if (!response.ok || payload.ok === false) {
throw new Error(payload.message || form.dataset.failedLabel || "Command failed.");
}
showActionMessage(form, payload.message || form.dataset.successLabel || "Command sent.", "success");
refreshSnapshotForCommand(form);
} catch (error) {
showActionMessage(form, commandErrorMessage(form, error), "error");
} finally {
setCommandBusy(button, false);
}
}
document.addEventListener("click", (event) => {
const button = event.target.closest(".snapshot-refresh");
if (!(button instanceof HTMLButtonElement)) {
return;
}
refreshSnapshot(button);
});
document.addEventListener("submit", (event) => {
const form = event.target;
if (!(form instanceof HTMLFormElement)) {
return;
}
if (form.classList.contains("refresh-form")) {
handleRefreshSubmit(form, event);
return;
}
if (form.classList.contains("command-form")) {
handleCommandSubmit(form, event);
return;
}
});
updateTimers();
restoreRefreshMessage();
setInterval(updateTimers, 1000);
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="stylesheet" href="/static/app.css?v={css_version}">
</head>
<body>
<main class="shell">
@@ -15,6 +15,17 @@
</div>
{global_actions}
<div class="toolbar">
<form method="post" action="/refresh?lang={lang}{token_query}" class="refresh-form">
<button class="refresh-button" type="submit" title="{refresh_label}" aria-label="{refresh_label}">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M20 6v5h-5"></path>
<path d="M4 18v-5h5"></path>
<path d="M18.6 9A7 7 0 0 0 6.3 6.4L4 8.6"></path>
<path d="M5.4 15A7 7 0 0 0 17.7 17.6L20 15.4"></path>
</svg>
</button>
</form>
<span class="refresh-status" data-refresh-status data-working-label="{refresh_working_label}" role="status" aria-live="polite"></span>
<form method="get" action="/" class="language-form">
<label for="lang">{language_label}</label>
{token_input}
@@ -29,6 +40,6 @@
{cards}
</section>
</main>
<script src="/static/app.js" defer></script>
<script src="/static/app.js?v={js_version}" defer></script>
</body>
</html>
+109 -10
View File
@@ -1,3 +1,4 @@
import hashlib
import html
import urllib.parse
from pathlib import Path
@@ -19,6 +20,13 @@ def read_static_file(name):
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
@@ -110,7 +118,9 @@ def camera_group_key(action):
def camera_group_name(action, lang):
return (
action.cfg.get("camera_name")
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)
@@ -140,6 +150,7 @@ 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 = (
'<div class="media media-empty">'
f"{icons}"
@@ -149,19 +160,71 @@ def snapshot_markup_for_group(group, lang, token_query):
if action.cfg.get("snapshot"):
snapshot_url = (
f"/snapshot/{urllib.parse.quote(action.id)}.jpg"
f"?lang={urllib.parse.quote(lang)}{token_query}"
f"?lang={urllib.parse.quote(lang)}"
f"&v={int(getattr(action, 'snapshot_version', 0) or 0)}{token_query}"
)
snapshot_markup = (
'<div class="media">'
f'<img class="snapshot" src="{html.escape(snapshot_url)}" '
f'alt="{html.escape(group["name"])}" loading="lazy" '
'onerror="this.parentElement.classList.add(\'media-empty\');this.remove()">'
f'<button class="snapshot-refresh" type="button" title="{html.escape(refresh_label)}" '
f'aria-label="{html.escape(refresh_label)}">'
'<svg viewBox="0 0 24 24" aria-hidden="true">'
'<path d="M20 6v5h-5"></path>'
'<path d="M4 18v-5h5"></path>'
'<path d="M18.6 9A7 7 0 0 0 6.3 6.4L4 8.6"></path>'
'<path d="M5.4 15A7 7 0 0 0 17.7 17.6L20 15.4"></path>'
"</svg>"
"</button>"
f"{icons}"
"</div>"
)
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'<a class="stream-link" href="{html.escape(url)}" target="_blank" rel="noreferrer" '
f'title="{html.escape(label)}" aria-label="{html.escape(label)}">'
'<svg viewBox="0 0 24 24" aria-hidden="true">'
'<path d="M15 3h6v6"></path>'
'<path d="M10 14 21 3"></path>'
'<path d="M21 13v5a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h5"></path>'
"</svg>"
"</a>"
)
def render_action_control(action, lang, token_query):
display_name = action_label(action, lang)
@@ -174,33 +237,42 @@ def render_action_control(action, lang, token_query):
platform_label = action.platform
timer_markup = ""
if action.auto_off_seconds > 0:
idle_label = text_for(lang, "timer.auto_off_configured", seconds=action.auto_off_seconds)
if action.auto_off_at:
timer_markup = (
f'<span class="timer" data-auto-off-at="{int(action.auto_off_at)}" '
f'<span class="timer" data-timer data-auto-off-at="{int(action.auto_off_at)}" '
f'data-idle-label="{html.escape(idle_label)}" '
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))}'
f'<span class="timer timer-idle" data-timer '
f'data-idle-label="{html.escape(idle_label)}" '
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(idle_label)}'
"</span>"
)
return (
'<section class="action-control">'
f'<section class="action-control" data-action-id="{html.escape(action.id)}">'
'<div class="control-head">'
f"{visual_markup(action, lang)}"
f"<h3>{html.escape(display_name)}</h3>"
"</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'<span class="chip chip-state chip-state-{html.escape(state_value)}" data-state-chip '
f'data-label-on="{html.escape(text_for(lang, "state.on"))}" '
f'data-label-off="{html.escape(text_for(lang, "state.off"))}" '
f'data-label-unknown="{html.escape(text_for(lang, "state.unknown"))}">'
f"{html.escape(state_label)}</span>"
"</div>"
f"{timer_markup}"
f'<div class="command-row">{render_action_buttons(action, lang, token_query)}</div>'
'<span class="action-status" data-action-status role="status" aria-live="polite"></span>'
f'<code class="action-id">{html.escape(action.id)}</code>'
"</section>"
)
@@ -213,7 +285,10 @@ def render_group_card(group, lang, token_query):
f"{snapshot_markup_for_group(group, lang, token_query)}"
'<div class="card-body">'
'<div class="card-main">'
'<div class="card-title-row">'
f"<h2>{html.escape(group['name'])}</h2>"
f"{camera_stream_link(group, lang)}"
"</div>"
f'<span class="camera-count">{html.escape(text_for(lang, "camera.controls_count", count=len(group["actions"])))}</span>'
"</div>"
f'<div class="control-stack">{controls}</div>'
@@ -223,7 +298,7 @@ def render_group_card(group, lang, token_query):
def render_global_action(action, lang, token_query):
return (
'<article class="global-action">'
f'<article class="global-action" data-action-id="{html.escape(action.id)}">'
'<div class="global-main">'
f"{visual_markup(action, lang)}"
'<div>'
@@ -231,7 +306,10 @@ def render_global_action(action, lang, token_query):
f'<code class="action-id">{html.escape(action.id)}</code>'
"</div>"
"</div>"
'<div class="global-controls-wrap">'
f'<div class="global-controls">{render_action_buttons(action, lang, token_query)}</div>'
'<span class="action-status" data-action-status role="status" aria-live="polite"></span>'
"</div>"
"</article>"
)
@@ -250,9 +328,25 @@ def render_action_buttons(action, lang, token_query):
' 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"))}"'
f' data-network-failed-label="{html.escape(text_for(lang, "action.network_failed"))}"'
)
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'<form class="command-form" method="post" action="{html.escape(action_url)}"{ack_attrs}>'
f'<button class="cmd cmd-{html.escape(command)}" type="submit">'
f'<form class="command-form" method="post" action="{html.escape(action_url)}"{ack_attrs}{common_attrs}>'
f'<button class="cmd cmd-{html.escape(command)}" type="submit"{disabled_attrs}>'
f"<span>{html.escape(label)}</span>"
"</button></form>"
)
@@ -306,8 +400,13 @@ def render_page(actions, lang, token_param=None):
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,
@@ -92,6 +92,48 @@ msgstr "OFF"
msgid "button.press"
msgstr "PRESS"
msgid "button.refresh_cameras"
msgstr "Refresh cameras"
msgid "button.refresh_snapshot"
msgstr "Refresh snapshot"
msgid "button.on_blocked_timer"
msgstr "Already active until automatic turn-off"
msgid "link.open_stream"
msgstr "Open Frigate stream"
msgid "refresh.working"
msgstr "Refreshing cameras..."
msgid "refresh.no_changes"
msgstr "No camera changes found."
msgid "refresh.cameras_changed"
msgstr "Cameras updated: +{added}, -{removed}."
msgid "refresh.controls_changed"
msgstr "Camera controls updated: +{added}, -{removed}."
msgid "refresh.failed"
msgstr "Camera refresh failed: {error}"
msgid "action.working"
msgstr "Sending command..."
msgid "action.success"
msgstr "Command sent."
msgid "action.failed_generic"
msgstr "Command failed."
msgid "action.failed"
msgstr "Command failed: {error}"
msgid "action.network_failed"
msgstr "Could not reach the bridge. Check network access from this device."
msgid "action.all_off"
msgstr "{target} all off"
@@ -92,6 +92,48 @@ msgstr "WYLACZ"
msgid "button.press"
msgstr "URUCHOM"
msgid "button.refresh_cameras"
msgstr "Odswiez kamery"
msgid "button.refresh_snapshot"
msgstr "Odswiez obraz"
msgid "button.on_blocked_timer"
msgstr "Juz wlaczone do czasu automatycznego wylaczenia"
msgid "link.open_stream"
msgstr "Otworz stream we Frigate"
msgid "refresh.working"
msgstr "Odswiezanie kamer..."
msgid "refresh.no_changes"
msgstr "Nie wykryto zmian w kamerach."
msgid "refresh.cameras_changed"
msgstr "Kamery zaktualizowane: +{added}, -{removed}."
msgid "refresh.controls_changed"
msgstr "Kontrolki kamer zaktualizowane: +{added}, -{removed}."
msgid "refresh.failed"
msgstr "Odswiezanie kamer nie powiodlo sie: {error}"
msgid "action.working"
msgstr "Wysylanie komendy..."
msgid "action.success"
msgstr "Komenda wyslana."
msgid "action.failed_generic"
msgstr "Komenda nie powiodla sie."
msgid "action.failed"
msgstr "Komenda nie powiodla sie: {error}"
msgid "action.network_failed"
msgstr "Nie mozna polaczyc sie z bridge. Sprawdz dostep sieciowy z tego urzadzenia."
msgid "action.all_off"
msgstr "{target} wylacz wszystko"
+81
View File
@@ -0,0 +1,81 @@
import unittest
from frigate_camera_control_bridge.app import CameraControlBridge
class DummyAction:
def __init__(self, action_id="example_camera_white", kind="hikvision_colorvu"):
self.id = action_id
self.platform = "switch"
self.kind = kind
self.state = "ON"
self.auto_off_seconds = 600
self.auto_off_at = 1234567890
self.snapshot_version = 7
self.cfg = {
"id": self.id,
"name": "Example white light",
"name_key": "action.manual_white",
"requires_ack": False,
"snapshot": {
"camera": "example",
"frigate_url": "http://frigate.example.test:5000",
},
}
def command_list(self):
return ["on", "off"]
def display_name(self, _lang):
return "Example white light"
class ActionPayloadTest(unittest.TestCase):
def setUp(self):
self.bridge = object.__new__(CameraControlBridge)
self.bridge.default_language = "en"
self.action = DummyAction()
self.bridge.actions_snapshot = lambda: [self.action]
def test_api_action_includes_ui_state_fields(self):
payload = CameraControlBridge.api_action(self.bridge, self.action, "en")
self.assertEqual(payload["id"], "example_camera_white")
self.assertEqual(payload["state"], "ON")
self.assertEqual(payload["state_label"], "ON")
self.assertEqual(payload["snapshot_version"], 7)
self.assertEqual(payload["auto_off_at"], 1234567890)
def test_action_payload_returns_updated_action_for_json_gui(self):
payload = CameraControlBridge.action_payload(
self.bridge,
"en",
self.action,
"on",
{"ok": True, "command": "on"},
"/?lang=en",
)
self.assertTrue(payload["ok"])
self.assertEqual(payload["message"], "Command sent.")
self.assertEqual(payload["redirect_url"], "/?lang=en")
self.assertEqual(payload["action"]["id"], "example_camera_white")
def test_all_off_payload_includes_all_actions_for_gui_refresh(self):
action = DummyAction("example_all_off", kind="all_off")
payload = CameraControlBridge.action_payload(
self.bridge,
"en",
action,
"press",
{"ok": True, "results": []},
"/?lang=en",
)
self.assertEqual(payload["action_id"], "example_all_off")
self.assertEqual(len(payload["actions"]), 1)
self.assertEqual(payload["actions"][0]["id"], "example_camera_white")
if __name__ == "__main__":
unittest.main()
+55
View File
@@ -0,0 +1,55 @@
import unittest
from frigate_camera_control_bridge.app import Action, CameraControlBridge
class HomeAssistantDiscoveryTest(unittest.TestCase):
def setUp(self):
self.bridge = object.__new__(CameraControlBridge)
self.bridge.config = {"mqtt": {"base_topic": "test/camera_control"}}
self.bridge.default_language = "en"
def payload_for(self, cfg):
base_cfg = {
"id": "example_action",
"name": "Example action",
"platform": "switch",
"kind": "dahua_coaxial_io",
"device": {"identifiers": ["camera_example"], "name": "Example camera"},
}
base_cfg.update(cfg)
action = Action(base_cfg, http_client=None)
return self.bridge.discovery_payload(action, "test/camera_control/status")
def test_switch_discovery_includes_semantic_icons(self):
cases = {
"action.siren": "mdi:bullhorn",
"action.red_blue": "mdi:alarm-light",
"action.manual_white": "mdi:lightbulb-on",
}
for name_key, icon in cases.items():
with self.subTest(name_key=name_key):
payload = self.payload_for({"name_key": name_key})
self.assertEqual(payload["icon"], icon)
def test_all_off_button_discovery_includes_power_icon(self):
payload = self.payload_for(
{
"platform": "button",
"kind": "all_off",
"name_key": "action.all_off",
}
)
self.assertEqual(payload["icon"], "mdi:power")
def test_configured_icon_overrides_default_icon(self):
payload = self.payload_for({"name_key": "action.siren", "icon": "mdi:test-tube"})
self.assertEqual(payload["icon"], "mdi:test-tube")
if __name__ == "__main__":
unittest.main()
+99
View File
@@ -0,0 +1,99 @@
import unittest
from frigate_camera_control_bridge.app import CameraControlBridge
class DummyAction:
def __init__(self, action_id, camera=None, kind="hikvision_colorvu", name_key=None):
self.id = action_id
self.kind = kind
self.platform = "switch"
self.auto_off_at = None
self.run_called = False
self.cfg = {}
if name_key:
self.cfg["name_key"] = name_key
if camera:
self.cfg["snapshot"] = {
"frigate_url": "http://frigate.example.test:5000",
"camera": camera,
}
def run(self, command, bridge=None):
self.run_called = True
return {"ok": True, "command": command}
class RefreshSummaryTest(unittest.TestCase):
def test_camera_keys_ignore_global_actions(self):
keys = CameraControlBridge.camera_keys_from_actions(
[
DummyAction("one_light", "one"),
DummyAction("all_off", kind="all_off"),
]
)
self.assertEqual(keys, {("http://frigate.example.test:5000", "one")})
def test_camera_display_name_uses_friendly_name(self):
self.assertEqual(
CameraControlBridge.camera_display_name("camera_key", {"friendly_name": "Camera Friendly"}),
"Camera Friendly",
)
def test_camera_display_name_falls_back_to_config_name(self):
self.assertEqual(
CameraControlBridge.camera_display_name("camera_key", {}),
"camera_key",
)
def test_refresh_message_reports_camera_changes_first(self):
message = CameraControlBridge.refresh_message(
"en",
{
"added_cameras": 1,
"removed_cameras": 0,
"added_actions": 2,
"removed_actions": 0,
},
)
self.assertEqual(message, "Cameras updated: +1, -0.")
def test_refresh_message_reports_no_changes(self):
message = CameraControlBridge.refresh_message(
"en",
{
"added_cameras": 0,
"removed_cameras": 0,
"added_actions": 0,
"removed_actions": 0,
},
)
self.assertEqual(message, "No camera changes found.")
def test_light_actions_refresh_snapshot_after_on_off(self):
white = DummyAction("white", "one", name_key="action.manual_white")
red_blue = DummyAction("red_blue", "one", name_key="action.red_blue")
siren = DummyAction("siren", "one", name_key="action.siren")
self.assertTrue(CameraControlBridge.should_refresh_snapshot_after_command(white, "on"))
self.assertTrue(CameraControlBridge.should_refresh_snapshot_after_command(red_blue, "off"))
self.assertFalse(CameraControlBridge.should_refresh_snapshot_after_command(siren, "on"))
self.assertFalse(CameraControlBridge.should_refresh_snapshot_after_command(white, "press"))
def test_execute_action_rejects_second_on_while_timer_runs(self):
action = DummyAction("white", "one", name_key="action.manual_white")
action.auto_off_at = 1234567890
bridge = object.__new__(CameraControlBridge)
bridge.action_by_id = lambda action_id: action
with self.assertRaisesRegex(RuntimeError, "already active"):
CameraControlBridge.execute_action(bridge, "white", "on", publish=False)
self.assertFalse(action.run_called)
if __name__ == "__main__":
unittest.main()
+82 -1
View File
@@ -1,6 +1,7 @@
import unittest
from pathlib import Path
from frigate_camera_control_bridge.ui import render_page
from frigate_camera_control_bridge.ui import camera_stream_url, render_page
class DummyAction:
@@ -11,6 +12,7 @@ class DummyAction:
self.state = state
self.auto_off_seconds = 180
self.auto_off_at = 1234567890
self.snapshot_version = 42
self.cfg = {
"name_key": name_key,
"camera_name": "Example camera",
@@ -35,6 +37,10 @@ class UiRenderTest(unittest.TestCase):
self.assertIn('data-auto-off-at="1234567890"', html)
self.assertIn('data-requires-ack="true"', html)
self.assertIn("Example camera", html)
self.assertIn("v=42", html)
self.assertIn('class="snapshot-refresh"', html)
self.assertIn('title="Refresh snapshot"', html)
self.assertIn('disabled title="Already active until automatic turn-off"', html)
def test_controls_from_same_camera_share_one_card(self):
actions = [
@@ -62,6 +68,81 @@ class UiRenderTest(unittest.TestCase):
self.assertIn('class="global-action"', html)
self.assertNotIn('class="camera-card"', html)
def test_refresh_button_preserves_language_and_token(self):
html = render_page([DummyAction()], "en", token_param="secret")
self.assertIn('class="refresh-form"', html)
self.assertIn('action="/refresh?lang=en&amp;token=secret"', html)
self.assertIn('title="Refresh cameras"', html)
self.assertIn('aria-label="Refresh cameras"', html)
self.assertIn('data-refresh-status', html)
self.assertIn('data-working-label="Refreshing cameras..."', html)
self.assertIn("<svg", html)
def test_camera_stream_link_uses_default_frigate_url(self):
action = DummyAction()
self.assertEqual(
camera_stream_url(action),
"http://frigate.example.test:5000/#example",
)
def test_camera_stream_link_uses_public_proxy_url_from_env_config(self):
action = DummyAction()
action.cfg["snapshot"]["public_url"] = "https://frigate.example.test"
self.assertEqual(
camera_stream_url(action),
"https://frigate.example.test/#example",
)
def test_on_button_is_available_when_no_auto_off_timer_runs(self):
action = DummyAction()
action.auto_off_at = None
html = render_page([action], "en")
self.assertIn('class="cmd cmd-on"', html)
self.assertNotIn('class="cmd cmd-on" type="submit" disabled', html)
self.assertIn('data-disabled-label="Already active until automatic turn-off"', html)
def test_camera_card_uses_friendly_display_name(self):
action = DummyAction()
action.cfg["camera_name"] = "camera_key"
action.cfg["camera_display_name"] = "Camera Friendly"
html = render_page([action], "en")
self.assertIn("<h2>Camera Friendly</h2>", html)
self.assertNotIn("<h2>camera_key</h2>", html)
def test_manual_snapshot_refresh_waits_before_fetch(self):
script = Path("frigate_camera_control_bridge/static/app.js").read_text(encoding="utf-8")
self.assertIn("SNAPSHOT_REFRESH_DELAY_MS = 5000", script)
self.assertIn("window.setTimeout", script)
def test_command_forms_expose_ajax_status_metadata(self):
html = render_page([DummyAction()], "en")
self.assertIn('class="command-form" method="post"', html)
self.assertIn('type="submit"', html)
self.assertIn('data-working-label="Sending command..."', html)
self.assertIn('data-success-label="Command sent."', html)
self.assertIn('data-network-failed-label="Could not reach the bridge.', html)
self.assertIn('data-action-status', html)
self.assertIn('data-state-chip', html)
self.assertIn('data-timer', html)
def test_command_submit_uses_fetch_without_page_navigation(self):
script = Path("frigate_camera_control_bridge/static/app.js").read_text(encoding="utf-8")
self.assertIn("handleCommandSubmit(form, event)", script)
self.assertIn('form.classList.contains("command-form")', script)
self.assertIn('headers: { Accept: "application/json" }', script)
self.assertIn("event.preventDefault()", script)
self.assertIn("commandErrorMessage", script)
if __name__ == "__main__":
unittest.main()