Compare commits
10
Commits
1290122e57
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
789a9b349c | ||
|
|
b6030860db | ||
|
|
0da5377ff2 | ||
|
|
d7d472d9e1 | ||
|
|
91b0fedbe8 | ||
|
|
15ec93f82e | ||
|
|
6b2bc3853a | ||
|
|
4ea737024b | ||
|
|
127d07dc6b | ||
|
|
e403f4e6b4 |
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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&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()
|
||||
|
||||
Reference in New Issue
Block a user