#!/usr/bin/env python3 import argparse import json import re import threading import time from http import HTTPStatus from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from zeroconf import ServiceBrowser, ServiceInfo, ServiceListener, Zeroconf ROOT = Path(__file__).resolve().parent SERVICE_TYPE = "_vaioncam._tcp.local." KEY_PATTERN = re.compile(r"^[A-Z0-9]{4}(?:-[A-Z0-9]{4}){2}$") def camera_from_service(name: str, hostname: str, addresses: list[str], port: int): suffix = "." + SERVICE_TYPE if not name.lower().endswith(suffix.lower()): return None alta_key = name[: -len(suffix)].upper() if not KEY_PATTERN.fullmatch(alta_key): return None ipv4 = next((address for address in addresses if ":" not in address), None) hostname = (hostname or "").rstrip(".") return { "alta_key": alta_key, "hostname": hostname, "web_ui_url": f"https://{hostname}" if hostname else "", "ip_address": ipv4 or (addresses[0] if addresses else ""), "port": port, "service": SERVICE_TYPE.rstrip("."), } def normalize_results(rows): unique = {} for row in rows: unique[(row["alta_key"], row.get("ip_address", ""))] = row return sorted(unique.values(), key=lambda row: (row["alta_key"], row.get("ip_address", ""))) class CameraListener(ServiceListener): def __init__(self): self.rows = [] self.lock = threading.Lock() def _capture(self, zeroconf: Zeroconf, service_type: str, name: str): info: ServiceInfo | None = zeroconf.get_service_info(service_type, name, timeout=2500) if not info: return row = camera_from_service(name, info.server or "", info.parsed_addresses(), info.port) if row: with self.lock: self.rows.append(row) def add_service(self, zeroconf, service_type, name): self._capture(zeroconf, service_type, name) def update_service(self, zeroconf, service_type, name): self._capture(zeroconf, service_type, name) def remove_service(self, zeroconf, service_type, name): pass def scan_cameras(timeout: float = 5.0): listener = CameraListener() zeroconf = Zeroconf() browser = ServiceBrowser(zeroconf, SERVICE_TYPE, listener) try: time.sleep(timeout) with listener.lock: cameras = normalize_results(listener.rows) finally: browser.cancel() zeroconf.close() return cameras class Handler(SimpleHTTPRequestHandler): def __init__(self, *args, **kwargs): super().__init__(*args, directory=str(ROOT / "static"), **kwargs) def log_message(self, fmt, *args): print(f"{self.client_address[0]} - {fmt % args}") def _json(self, payload, status=HTTPStatus.OK): body = json.dumps(payload).encode() self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") self.send_header("X-Content-Type-Options", "nosniff") self.send_header("X-Frame-Options", "DENY") self.end_headers() self.wfile.write(body) def do_GET(self): if self.path == "/api/health": return self._json({"ok": True, "service": "Alta Camera Finder"}) if self.path == "/api/scan": started = time.monotonic() try: cameras = scan_cameras() return self._json({ "ok": True, "count": len(cameras), "duration_seconds": round(time.monotonic() - started, 1), "cameras": cameras, }) except Exception as exc: return self._json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR) if self.path == "/": self.path = "/index.html" return super().do_GET() def main(): parser = argparse.ArgumentParser(description="Discover Alta cloud-native cameras using mDNS") parser.add_argument("--host", default="0.0.0.0") parser.add_argument("--port", type=int, default=8787) parser.add_argument("--scan", action="store_true", help="scan once and print JSON") args = parser.parse_args() if args.scan: print(json.dumps(scan_cameras(), indent=2)) return server = ThreadingHTTPServer((args.host, args.port), Handler) print(f"Alta Camera Finder listening on http://{args.host}:{args.port}", flush=True) server.serve_forever() if __name__ == "__main__": main()