. Loopback here is not a camera API
# host; it is the go2rtc gateway. We keep only the stream name and
# resolve it through go2rtc.streams.
names = []
for item in camera_cfg.get("ffmpeg", {}).get("inputs", []):
path = str(item.get("path", ""))
stream_name = CameraControlBridge.go2rtc_stream_name_from_url(path)
if stream_name:
names.append(stream_name)
return list(dict.fromkeys(names))
@staticmethod
def camera_direct_urls(camera_cfg):
# Some installations skip go2rtc and put camera RTSP/HTTP URLs directly
# in ffmpeg inputs. Those URLs are inspected as-is. go2rtc loopback URLs
# are intentionally excluded here because they must be resolved through
# go2rtc.streams first.
urls = []
for item in camera_cfg.get("ffmpeg", {}).get("inputs", []):
path = str(item.get("path", ""))
if path and not CameraControlBridge.go2rtc_stream_name_from_url(path):
urls.extend(CameraControlBridge.stream_source_urls(path))
return urls
@staticmethod
def go2rtc_stream_name_from_url(url):
# go2rtc and Frigate often wrap real stream URLs, for example:
# ffmpeg:rtsp://127.0.0.1:8554/front-door#video=copy. Normalize that
# first so loopback gateway detection works for wrapped and raw values.
for candidate in CameraControlBridge.stream_source_urls(url) or [str(url)]:
try:
parsed = urllib.parse.urlsplit(candidate)
except ValueError:
continue
host = parsed.hostname or ""
if parsed.scheme != "rtsp" or parsed.port != 8554:
continue
if host not in ("127.0.0.1", "localhost", "::1"):
continue
return parsed.path.lstrip("/").split("/", 1)[0] or None
return None
@staticmethod
def stream_source_urls(value):
# go2rtc supports source wrappers such as "ffmpeg:rtsp://..." and may
# append options after a URL fragment. Extract URL tokens instead of
# treating the wrapper prefix as the network scheme.
text = str(value or "")
urls = STREAM_URL_RE.findall(text)
if urls:
return urls
if "://" in text:
return [text]
return []
@staticmethod
def resolve_go2rtc_stream_urls(streams, stream_name):
# go2rtc stream values may be a string, a list of strings, or a small
# mapping depending on how the Frigate config was authored. Flatten only
# the values that can contain source URLs; non-URL helper values are
# harmless because host/vendor extraction is conservative.
value = streams.get(stream_name) if isinstance(streams, dict) else None
return CameraControlBridge.flatten_go2rtc_value(value)
@staticmethod
def flatten_go2rtc_value(value):
if value is None:
return []
if isinstance(value, str):
urls = CameraControlBridge.stream_source_urls(value)
return urls or [value]
if isinstance(value, list):
out = []
for item in value:
out.extend(CameraControlBridge.flatten_go2rtc_value(item))
return out
if isinstance(value, dict):
out = []
for key in ("streams", "url", "urls", "source", "sources"):
if key in value:
out.extend(CameraControlBridge.flatten_go2rtc_value(value[key]))
return out
return [str(value)]
@staticmethod
def hosts_from_urls(urls):
return CameraControlBridge.camera_endpoints_from_urls(urls)
@staticmethod
def camera_endpoints_from_urls(urls):
# Extract the camera API endpoint from URLs without hardcoding camera IPs.
# IP literals and DNS names are both valid. Loopback addresses should
# already have been resolved through go2rtc.streams, so skip them if a
# config still leaks one through.
endpoints = []
for url in urls:
try:
parsed = urllib.parse.urlsplit(str(url))
except ValueError:
continue
host = parsed.hostname
if not host:
continue
normalized = host.strip("[]").lower()
if (
normalized in ("localhost", "::1", "0.0.0.0", "255.255.255.255")
or normalized.startswith("127.")
):
continue
endpoints.append(normalized)
return list(dict.fromkeys(endpoints))
@staticmethod
def kinds_from_urls(urls):
# Vendor detection is based on URL shape. This decides which safe
# capability probes may be attempted for each discovered camera.
kinds = set()
for url in urls:
if "Streaming/Channels" in url:
kinds.add("hikvision")
elif "cam/realmonitor" in url:
kinds.add("dahua")
elif "/stream1" in url or "/stream2" in url:
kinds.add("tapo")
elif "camera/stream" in url:
kinds.add("tablet")
else:
kinds.add("other")
return sorted(kinds)
def discover_action_configs(self):
discovery = self.config.get("discovery", {})
if not discovery.get("enabled", False):
return []
actions = []
for item in self.primary_inventory_items():
host = item["host"]
if discovery.get("include_hikvision_colorvu_white", True):
action = self.discover_hikvision_colorvu_action(item)
if action:
actions.append(action)
if discovery.get("include_dahua_coaxial_off_probe", True):
actions.extend(self.discover_dahua_coaxial_actions(item))
LOG.info("auto-created %d action(s) from camera capabilities", len(actions))
return actions
def primary_inventory_items(self):
grouped = {}
for item in self.inventory:
host = item["host"]
current = grouped.get(host)
if current is None:
grouped[host] = item
continue
if "ptz" in item["camera"].lower() and "ptz" not in current["camera"].lower():
grouped[host] = item
return list(grouped.values())
def discover_hikvision_colorvu_action(self, item):
if "hikvision" not in item.get("kinds", []):
return None
host = item["host"]
try:
_, device_info = self.http_client.request(f"http://{host}/ISAPI/System/deviceInfo")
model = self.xml_text(device_info, "model") or "Hikvision"
_, caps = self.http_client.request(f"http://{host}/ISAPI/Image/channels/1/supplementLight/capabilities")
modes = self.xml_attr(caps, "supplementLightMode", "opt") or ""
if "colorVuWhiteLight" not in modes.split(","):
return None
_, current = self.http_client.request(f"http://{host}/ISAPI/Image/channels/1/supplementLight")
except Exception as exc:
LOG.info("skip Hikvision discovery %s/%s: %s", item["camera"], host, exc)
return None
restore = {
"supplementLightMode": self.xml_text(current, "supplementLightMode") or "eventIntelligence",
"mixedLightBrightnessRegulatMode": self.xml_text(current, "mixedLightBrightnessRegulatMode") or "auto",
"eventIntelligenceBrightnessRegulatMode": self.xml_text(current, "brightnessRegulatMode") or "auto",
"whiteLightBrightness": int(self.xml_text(current, "whiteLightBrightness") or 100),
"irLightBrightness": int(self.xml_text(current, "irLightBrightness") or 100),
"ircutFilterType": "auto",
}
associated = self.xml_text(current, "associatedVMDHuman")
if associated is not None:
restore["associatedVMDHuman"] = associated.lower() == "true"
action_id = f"{slug(item['frigate'])}_{slug(item['camera'])}_colorvu_white"
lang = self.default_language
return {
"id": action_id,
"name": text_for(lang, "action.manual_white", target=item["camera"]),
"name_key": "action.manual_white",
"camera_name": item["camera"],
"platform": "switch",
"kind": "hikvision_colorvu",
"host": host,
"brightness": int(self.config.get("defaults", {}).get("colorvu_brightness", 100)),
"restore": restore,
"snapshot": self.snapshot_config(item),
"device": {
"identifiers": [f"camera_{host.replace('.', '_')}"],
"name": item["camera"],
"manufacturer": "Hikvision",
"model": model,
"configuration_url": item["frigate_url"],
},
}
def discover_dahua_coaxial_actions(self, item):
# Dahua red-blue lights and sirens are discovered with OFF-only probes.
# A successful OFF endpoint means the command exists without starting
# the alarm/light during bridge startup.
if "dahua" not in item.get("kinds", []):
return []
host = item["host"]
try:
_, info = self.http_client.request(f"http://{host}/cgi-bin/magicBox.cgi?action=getSystemInfo")
except Exception as exc:
LOG.info("skip Dahua discovery %s/%s: %s", item["camera"], host, exc)
return []
model = self.dahua_value(info, "deviceType") or "Dahua"
actions = []
red_blue_channel = self.dahua_off_probe(host, action_type=1)
lang = self.default_language
if red_blue_channel is not None:
actions.append(
{
"id": f"{slug(item['frigate'])}_{slug(item['camera'])}_red_blue",
"name": text_for(lang, "action.red_blue", target=item["camera"]),
"name_key": "action.red_blue",
"camera_name": item["camera"],
"platform": "switch",
"kind": "dahua_coaxial_io",
"host": host,
"on": {"channel": red_blue_channel, "type": 1, "io": 1},
"off": {"channel": red_blue_channel, "type": 1, "io": 2},
"snapshot": self.snapshot_config(item),
"device": self.discovered_device(item, "Dahua", model),
}
)
siren_channel = self.dahua_off_probe(host, action_type=2)
if siren_channel is not None:
actions.append(
{
"id": f"{slug(item['frigate'])}_{slug(item['camera'])}_siren",
"name": text_for(lang, "action.siren", target=item["camera"]),
"name_key": "action.siren",
"camera_name": item["camera"],
"platform": "switch",
"kind": "dahua_coaxial_io",
"host": host,
"on": {"channel": siren_channel, "type": 2, "io": 1},
"off": {"channel": siren_channel, "type": 2, "io": 2},
"safe_off": {"channel": siren_channel, "type": 2, "io": 2},
"snapshot": self.snapshot_config(item),
"device": self.discovered_device(item, "Dahua", model),
}
)
return actions
def dahua_off_probe(self, host, action_type):
channels = self.config.get("discovery", {}).get("dahua_probe_channels", [1, 2, 0])
if action_type == 2:
channels = self.config.get("discovery", {}).get("dahua_siren_probe_channels", [2, 1, 0])
for channel in channels:
query = urllib.parse.urlencode(
{
"action": "control",
"channel": int(channel),
"info[0].Type": int(action_type),
"info[0].IO": 2,
},
safe="[]",
)
try:
self.http_client.request(f"http://{host}/cgi-bin/coaxialControlIO.cgi?{query}")
LOG.info("Dahua OFF probe OK host=%s type=%s channel=%s", host, action_type, channel)
return int(channel)
except Exception:
continue
return None
@staticmethod
def dahua_value(text, key):
match = re.search(rf"^{re.escape(key)}=(.+)$", text, re.MULTILINE)
return match.group(1).strip().strip("\r") if match else None
@staticmethod
def discovered_device(item, manufacturer, model):
return {
"identifiers": [f"camera_{item['host'].replace('.', '_')}"],
"name": item["camera"],
"manufacturer": manufacturer,
"model": model,
"configuration_url": item["frigate_url"],
}
@staticmethod
def snapshot_config(item):
return {
"frigate_url": item["frigate_url"],
"camera": item["camera"],
}
@staticmethod
def xml_text(xml, tag):
match = re.search(rf"<(?:[^:>]+:)?{re.escape(tag)}[^>]*>([^<]+)(?:[^:>]+:)?{re.escape(tag)}>", xml)
return match.group(1).strip() if match else None
@staticmethod
def xml_attr(xml, tag, attr):
match = re.search(rf"<(?:[^:>]+:)?{re.escape(tag)}[^>]*\s{re.escape(attr)}=\"([^\"]*)\"", xml)
return match.group(1).strip() if match else None
def list_actions(self):
for action in self.actions.values():
commands = ",".join(action.command_list())
print(f"{action.id}\t{action.platform}\t{commands}\t{action.display_name(self.default_language)}")
def check(self):
ok = True
for credential in self.credentials:
try:
test = MqttBridgeClient(self.config["mqtt"], queue.Queue())
sock = test.connect(credential.get("username"), credential.get("password"))
sock.close()
print(f"MQTT OK: {self.config['mqtt']['host']} as {credential['source']}")
break
except Exception as exc:
ok = False
print(f"MQTT ERROR using {credential['source']}: {exc}")
for action in self.actions.values():
if action.kind == "hikvision_colorvu":
print(f"CHECK: {action.id} Hikvision host={action.cfg['host']} configured")
elif action.kind == "dahua_coaxial_io":
print(f"CHECK: {action.id} Dahua host={action.cfg['host']} configured")
elif action.kind == "all_off":
print(f"CHECK: {action.id} all-off button configured")
return 0 if ok else 1
def refresh_snapshots(self):
# Snapshot refresh is best-effort. It improves the manual GUI but should
# never prevent MQTT controls from starting.
if not self.config.get("defaults", {}).get("snapshot_enabled", True):
return True
seen = set()
ok = True
for action in self.actions.values():
url = self.snapshot_url(action)
if not url or url in seen:
continue
seen.add(url)
try:
self.fetch_snapshot(url, force=True)
except Exception as exc:
ok = False
LOG.info("snapshot unavailable for %s: %s", action.id, exc)
return ok
def snapshot_url(self, action):
snapshot = action.cfg.get("snapshot")
if not snapshot:
return None
camera = urllib.parse.quote(snapshot["camera"], safe="")
height = int(self.config.get("defaults", {}).get("snapshot_height", 180))
return f"{snapshot['frigate_url'].rstrip('/')}/api/{camera}/latest.jpg?h={height}"
def fetch_snapshot(self, url, force=False):
now = time.monotonic()
max_age = int(self.config.get("defaults", {}).get("snapshot_cache_seconds", 300))
cached = self.snapshot_cache.get(url)
if cached and not force and now - cached["time"] < max_age:
return cached
with urllib.request.urlopen(url, timeout=8) as response:
data = response.read()
content_type = response.headers.get("Content-Type", "image/jpeg").split(";", 1)[0]
if not data:
raise RuntimeError("empty snapshot")
cached = {"data": data, "content_type": content_type, "time": now}
self.snapshot_cache[url] = cached
return cached
def snapshot_for_action(self, action_id, live=False):
action = self.actions.get(action_id)
if not action:
return None
url = self.snapshot_url(action)
if not url:
return None
try:
return self.fetch_snapshot(url, force=live)
except Exception:
cached = self.snapshot_cache.get(url)
if cached:
return cached
raise
def publish_discovery(self):
# Home Assistant MQTT discovery topics are retained. The bridge remembers
# what it published previously and clears stale entities when Frigate no
# longer exposes a camera/action.
discovery_prefix = self.config["mqtt"].get("discovery_prefix", "homeassistant")
availability = f"{self.base_topic}/status"
self.mqtt.publish(availability, "online", retain=True)
previous = self.load_discovery_state()
current = {}
for action in self.actions.values():
payload = self.discovery_payload(action, availability)
topic = f"{discovery_prefix}/{action.platform}/frigate_camera_control/{action.id}/config"
self.mqtt.publish(topic, json.dumps(payload, separators=(",", ":")), retain=True)
current[action.id] = {"platform": action.platform, "topic": topic}
for action_id, old in previous.items():
if action_id not in current:
topic = old.get("topic") or f"{discovery_prefix}/{old.get('platform', 'switch')}/frigate_camera_control/{action_id}/config"
self.mqtt.publish(topic, "", retain=True)
LOG.info("removed stale MQTT discovery entity %s", action_id)
self.save_discovery_state(current)
LOG.info("published MQTT discovery for %d actions", len(self.actions))
def discovery_state_file(self):
return Path(
self.config.get("discovery", {}).get(
"state_file",
"/var/lib/frigate-camera-control-bridge/discovery_state.json",
)
)
def load_discovery_state(self):
path = self.discovery_state_file()
try:
return json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
return {}
except Exception as exc:
LOG.warning("failed to read discovery state %s: %s", path, exc)
return {}
def save_discovery_state(self, state):
path = self.discovery_state_file()
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(state, indent=2), encoding="utf-8")
except Exception as exc:
LOG.warning("failed to write discovery state %s: %s", path, exc)
def discovery_payload(self, action, availability_topic):
base = f"{self.base_topic}/{action.id}"
device_cfg = action.cfg.get("device") or {}
device = {
"identifiers": device_cfg.get("identifiers", ["frigate_camera_control"]),
"name": device_cfg.get("name", "Frigate Camera Control"),
}
for key in ("manufacturer", "model", "sw_version", "configuration_url"):
if device_cfg.get(key):
device[key] = device_cfg[key]
payload = {
"name": action.display_name(self.default_language),
"unique_id": f"frigate_camera_control_{action.id}",
"availability_topic": availability_topic,
"payload_available": "online",
"payload_not_available": "offline",
"device": device,
"origin": {
"name": "frigate-camera-control-bridge",
"sw_version": VERSION,
},
"qos": 0,
}
if action.platform == "switch":
payload.update(
{
"command_topic": f"{base}/set",
"state_topic": f"{base}/state",
"payload_on": "ON",
"payload_off": "OFF",
"state_on": "ON",
"state_off": "OFF",
"optimistic": False,
"retain": False,
}
)
elif action.platform == "button":
payload.update(
{
"command_topic": f"{base}/set",
"payload_press": "PRESS",
"retain": False,
}
)
return payload
def execute_action(self, action_id, command, publish=True):
action = self.actions.get(action_id)
if action is None:
raise RuntimeError(f"unknown action {action_id}")
result = action.run(command, bridge=self)
if publish:
try:
self.publish_result(action, command, result)
except Exception as exc:
LOG.warning("action %s %s worked, but MQTT publish failed: %s", action_id, command, exc)
return result
def publish_result(self, action, command, result):
base = f"{self.base_topic}/{action.id}"
if action.platform == "switch" and action.state in ("ON", "OFF"):
self.mqtt.publish(f"{base}/state", action.state, retain=True)
self.mqtt.publish(
f"{base}/result",
json.dumps({"ok": True, "command": command, "time": int(time.time())}),
retain=False,
)
def publish_error(self, action_id, command, exc):
topic = f"{self.base_topic}/{action_id}/result"
payload = {"ok": False, "command": command, "error": str(exc), "time": int(time.time())}
try:
self.mqtt.publish(topic, json.dumps(payload), retain=False)
except Exception:
pass
def all_off(self):
results = []
for action in self.actions.values():
command = action.safe_off_command()
if not command:
continue
try:
action.run(command, bridge=self)
results.append({"action": action.id, "ok": True})
if action.platform == "switch":
try:
self.mqtt.publish(f"{self.base_topic}/{action.id}/state", "OFF", retain=True)
except Exception:
pass
except Exception as exc:
results.append({"action": action.id, "ok": False, "error": str(exc)})
LOG.error("all_off failed for %s: %s", action.id, exc)
return {"ok": all(item["ok"] for item in results), "results": results}
def handle_mqtt_message(self, topic, payload):
discovery_prefix = self.config["mqtt"].get("discovery_prefix", "homeassistant")
if topic == f"{discovery_prefix}/status" and payload.lower() == "online":
self.publish_discovery()
return
prefix = f"{self.base_topic}/"
if not topic.startswith(prefix) or not topic.endswith("/set"):
return
action_id = topic[len(prefix) : -len("/set")]
action = self.actions.get(action_id)
if not action:
LOG.warning("MQTT command for unknown action %s", action_id)
return
value = payload.upper()
if action.platform == "button":
command = "press"
elif value == "ON":
command = "on"
elif value == "OFF":
command = "off"
else:
LOG.warning("unsupported MQTT payload for %s: %s", action_id, payload)
return
try:
LOG.info("MQTT command %s %s", action_id, command)
self.execute_action(action_id, command, publish=True)
except Exception as exc:
LOG.error("MQTT action failed %s %s: %s", action_id, command, exc)
self.publish_error(action_id, command, exc)
def serve(self):
signal.signal(signal.SIGTERM, self._handle_signal)
signal.signal(signal.SIGINT, self._handle_signal)
mqtt_thread = threading.Thread(
target=self.mqtt.serve,
args=(self.credentials, self.publish_discovery, self.stop_event),
daemon=True,
)
mqtt_thread.start()
snapshot_thread = None
if self.config.get("defaults", {}).get("snapshot_enabled", True):
snapshot_thread = threading.Thread(target=self.snapshot_refresh_loop, daemon=True)
snapshot_thread.start()
http_cfg = self.config.get("http", {})
if http_cfg.get("enabled", True):
self.start_http(http_cfg.get("bind", "0.0.0.0"), int(http_cfg.get("port", 5011)))
LOG.info("serving %d actions", len(self.actions))
while not self.stop_event.is_set():
try:
topic, payload, packet_id = self.messages.get(timeout=1)
self.handle_mqtt_message(topic, payload)
except queue.Empty:
pass
self.mqtt.disconnect_cleanly()
if self.http_server is not None:
self.http_server.shutdown()
mqtt_thread.join(timeout=2)
if snapshot_thread is not None:
snapshot_thread.join(timeout=2)
def snapshot_refresh_loop(self):
retry_interval = max(60, int(self.config.get("defaults", {}).get("snapshot_retry_seconds", 600)))
refresh_interval = max(retry_interval, int(self.config.get("defaults", {}).get("snapshot_refresh_seconds", 3600)))
interval = retry_interval if self.missing_snapshots() else refresh_interval
while not self.stop_event.wait(interval):
all_ok = self.refresh_snapshots()
interval = refresh_interval if all_ok and not self.missing_snapshots() else retry_interval
def missing_snapshots(self):
for action in self.actions.values():
url = self.snapshot_url(action)
if url and url not in self.snapshot_cache:
return True
return False
def http_token(self):
return self.config.get("http", {}).get("token")
def http_authorized(self, headers, query):
token = self.http_token()
if not token:
return True
values = urllib.parse.parse_qs(query)
supplied = values.get("token", [None])[0] or headers.get("X-Bridge-Token")
return supplied == token
def start_http(self, bind, port):
bridge = self
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
parsed = urllib.parse.urlsplit(self.path)
lang = bridge.request_language(parsed.query)
if parsed.path in ("/healthz", "/readyz"):
self.send_json({"ok": True, "actions": len(bridge.actions)})
return
if not bridge.http_authorized(self.headers, parsed.query):
self.send_response(401)
self.end_headers()
return
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))
snapshot = bridge.snapshot_for_action(action_id, live=live)
except Exception as exc:
LOG.info("snapshot request failed for %s: %s", action_id, exc)
snapshot = None
if not snapshot:
self.send_response(404)
self.end_headers()
return
self.send_bytes(snapshot["data"], snapshot["content_type"])
return
if parsed.path == "/api/actions":
self.send_json(bridge.api_actions())
return
token_param = urllib.parse.parse_qs(parsed.query).get("token", [None])[0]
self.send_html(bridge.html_page(lang, token_param))
def do_POST(self):
parsed = urllib.parse.urlsplit(self.path)
lang = bridge.request_language(parsed.query)
if not bridge.http_authorized(self.headers, parsed.query):
self.send_response(401)
self.end_headers()
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()
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()
except Exception as exc:
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):
data = json.dumps(payload, indent=2).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def send_html(self, body):
data = body.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def send_bytes(self, data, content_type):
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Cache-Control", "private, max-age=300")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, fmt, *args):
LOG.info("http %s - %s", self.address_string(), fmt % args)
self.http_server = ThreadingHTTPServer((bind, port), Handler)
thread = threading.Thread(target=self.http_server.serve_forever, daemon=True)
thread.start()
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")),
}
for action in self.actions.values()
]
def html_page(self, lang=None, token_param=None):
return self.html_page_for_language(lang or self.default_language, token_param)
def request_language(self, query):
values = urllib.parse.parse_qs(query)
requested = values.get("lang", [self.default_language])[0]
return normalize_language(requested)
def html_page_for_language(self, lang, token_param=None):
lang = normalize_language(lang)
token_query = f"&token={urllib.parse.quote(token_param)}" if token_param else ""
rows = []
for action in self.actions.values():
snapshot_cell = "-"
if action.cfg.get("snapshot"):
snapshot_url = (
f"/snapshot/{urllib.parse.quote(action.id)}.jpg"
f"?lang={urllib.parse.quote(lang)}{token_query}"
)
snapshot_cell = (
f'
'
)
buttons = []
for command in action.command_list():
label = text_for(lang, f"button.{command}")
action_url = (
f"/action/{urllib.parse.quote(action.id)}/{urllib.parse.quote(command)}"
f"?lang={urllib.parse.quote(lang)}{token_query}"
)
buttons.append(
f''
)
state = html.escape(action.state or "-")
rows.append(
""
f"| {snapshot_cell} | "
f"{html.escape(action.id)} | "
f"{html.escape(action.display_name(lang))} | "
f"{html.escape(action.platform)} | "
f"{state} | "
f"{''.join(buttons)} | "
"
"
)
options = []
for code, label in available_languages().items():
selected = " selected" if code == lang else ""
options.append(f'')
return f"""
{html.escape(text_for(lang, "title"))}
{html.escape(text_for(lang, "title"))}
| {html.escape(text_for(lang, "snapshot"))} | {html.escape(text_for(lang, "id"))} | {html.escape(text_for(lang, "name"))} | {html.escape(text_for(lang, "type"))} | {html.escape(text_for(lang, "state"))} | {html.escape(text_for(lang, "actions"))} |
{''.join(rows)}
"""
def _handle_signal(self, signum, _frame):
LOG.info("received signal %s, stopping", signum)
self.stop_event.set()
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--config", default=DEFAULT_CONFIG)
parser.add_argument("--serve", action="store_true")
parser.add_argument("--list", action="store_true")
parser.add_argument("--check", action="store_true")
parser.add_argument("--publish-discovery", action="store_true")
parser.add_argument("--action", metavar="ID")
parser.add_argument("command", nargs="?")
parser.add_argument("--all-off", action="store_true")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
bridge = CameraControlBridge(load_config_or_env(args.config))
if args.list:
bridge.list_actions()
return 0
if args.check:
return bridge.check()
if args.all_off:
print(json.dumps(bridge.all_off(), indent=2))
return 0
if args.action:
if not args.command:
print("missing command", file=sys.stderr)
return 2
print(json.dumps(bridge.execute_action(args.action, args.command, publish=False), indent=2))
return 0
if args.publish_discovery:
for credential in bridge.credentials:
try:
bridge.mqtt.connect(credential.get("username"), credential.get("password"))
bridge.publish_discovery()
bridge.mqtt.disconnect_cleanly()
return 0
except Exception as exc:
LOG.warning("publish discovery failed using %s: %s", credential["source"], exc)
return 1
bridge.serve()
return 0
if __name__ == "__main__":
raise SystemExit(main())