#!/usr/bin/env python3 """ Auvi Live Stream - ONVIF snapshot ingest client (Python 3.8+, standard library only). Talks ONVIF Media service directly over SOAP (no onvif-zeep dependency): 1. GetProfiles -> discovers the camera's media profiles 2. GetSnapshotUri -> resolves the JPEG snapshot URL for a profile 3. fetch snapshot -> HTTP GET with Basic or Digest auth 4. POST to Auvi -> raw / multipart / json-base64, header|bearer|basic|query auth Usage ----- AUVI_ENDPOINT="https://your-app.lovable.app/api/public/ingest/riverside-north" \ AUVI_KEY="auvi_xxx" \ ONVIF_HOST="10.0.0.42" ONVIF_USER="admin" ONVIF_PASS="secret" \ python3 auvi_onvif_ingest.py Environment ----------- AUVI_ENDPOINT camera ingest URL (required) AUVI_KEY device key (required) AUVI_AUTH header | bearer | basic | query (default header) AUVI_FORMAT raw | multipart | json (default raw) AUVI_INTERVAL seconds between frames, 0 = post once (default 10) ONVIF_HOST camera IP or host[:port] (required) ONVIF_USER ONVIF username (required) ONVIF_PASS ONVIF password (required) ONVIF_PORT ONVIF service port (default 80) ONVIF_PROFILE profile token; default is the first profile ONVIF_SNAPSHOT skip discovery and use this snapshot URL directly Pass --list to print the discovered profiles and snapshot URLs, then exit. """ from __future__ import annotations import base64 import hashlib import json as jsonlib import os import re import ssl import sys import time import uuid from datetime import datetime, timezone from typing import Dict, List, Optional, Tuple from urllib import request as urlrequest from urllib.error import HTTPError, URLError # --------------------------------------------------------------------------- # # Config # --------------------------------------------------------------------------- # ENDPOINT = os.environ.get("AUVI_ENDPOINT", "").strip() KEY = os.environ.get("AUVI_KEY", "").strip() AUTH = os.environ.get("AUVI_AUTH", "header").strip().lower() FORMAT = os.environ.get("AUVI_FORMAT", "raw").strip().lower() INTERVAL = float(os.environ.get("AUVI_INTERVAL", "10")) ONVIF_HOST = os.environ.get("ONVIF_HOST", "").strip() ONVIF_PORT = os.environ.get("ONVIF_PORT", "80").strip() ONVIF_USER = os.environ.get("ONVIF_USER", "").strip() ONVIF_PASS = os.environ.get("ONVIF_PASS", "") ONVIF_PROFILE = os.environ.get("ONVIF_PROFILE", "").strip() ONVIF_SNAPSHOT = os.environ.get("ONVIF_SNAPSHOT", "").strip() TIMEOUT = 20 MAX_BYTES = 20 * 1024 * 1024 SSL_CTX = ssl.create_default_context() SSL_CTX.check_hostname = False SSL_CTX.verify_mode = ssl.CERT_NONE # cameras usually ship self-signed certs def die(message: str) -> "None": print(f"error: {message}", file=sys.stderr) raise SystemExit(1) def now_iso() -> str: return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") def log(*parts: object) -> None: print(datetime.now(timezone.utc).strftime("%H:%M:%S"), *parts, flush=True) # --------------------------------------------------------------------------- # # ONVIF: WS-Security UsernameToken (PasswordDigest) + SOAP calls # --------------------------------------------------------------------------- # def ws_security_header(user: str, password: str) -> str: """WS-UsernameToken with PasswordDigest — what ONVIF devices expect.""" nonce = uuid.uuid4().bytes created = now_iso() digest = hashlib.sha1(nonce + created.encode() + password.encode()).digest() return ( '' f"{user}" '' f"{base64.b64encode(digest).decode()}" '' f"{base64.b64encode(nonce).decode()}" '' f"{created}" ) def soap_call(service_url: str, body: str) -> str: envelope = ( '' '' f"{ws_security_header(ONVIF_USER, ONVIF_PASS)}" f"{body}" ) req = urlrequest.Request( service_url, data=envelope.encode(), headers={"content-type": "application/soap+xml; charset=utf-8"}, method="POST", ) try: with urlrequest.urlopen(req, timeout=TIMEOUT, context=SSL_CTX) as res: return res.read().decode("utf-8", "replace") except HTTPError as exc: # SOAP faults arrive as 4xx/5xx with a useful body detail = exc.read().decode("utf-8", "replace") reason = re.search(r"<[^>]*Text[^>]*>(.*?) List[str]: return re.findall(rf"<(?:\w+:)?{local_name}[^>]*>(.*?)", xml, re.S) def media_service_url() -> str: host = ONVIF_HOST if ":" in ONVIF_HOST else f"{ONVIF_HOST}:{ONVIF_PORT}" scheme = "https" if host.endswith(":443") else "http" return f"{scheme}://{host}/onvif/media_service" def get_profiles() -> List[Tuple[str, str]]: """Returns [(token, name), ...] for every media profile on the camera.""" xml = soap_call(media_service_url(), "") tokens = re.findall(r"<(?:\w+:)?Profiles[^>]*token=\"([^\"]+)\"", xml) names = _tags(xml, "Name") return [(t, names[i] if i < len(names) else t) for i, t in enumerate(tokens)] def get_snapshot_uri(token: str) -> str: xml = soap_call( media_service_url(), f"{token}", ) uris = _tags(xml, "Uri") if not uris: die(f"camera returned no snapshot URI for profile '{token}'") return uris[0].strip() def resolve_snapshot_url() -> str: if ONVIF_SNAPSHOT: return ONVIF_SNAPSHOT profiles = get_profiles() if not profiles: die("camera reported no media profiles") token = ONVIF_PROFILE or profiles[0][0] if ONVIF_PROFILE and ONVIF_PROFILE not in [p[0] for p in profiles]: die(f"profile '{ONVIF_PROFILE}' not found; available: {', '.join(p[0] for p in profiles)}") return get_snapshot_uri(token) # --------------------------------------------------------------------------- # # Snapshot fetch (Basic, falling back to Digest) # --------------------------------------------------------------------------- # def _digest_header(challenge: str, method: str, uri_path: str) -> str: fields = dict(re.findall(r'(\w+)="([^"]*)"', challenge)) realm, nonce = fields.get("realm", ""), fields.get("nonce", "") qop, opaque = fields.get("qop"), fields.get("opaque") ha1 = hashlib.md5(f"{ONVIF_USER}:{realm}:{ONVIF_PASS}".encode()).hexdigest() ha2 = hashlib.md5(f"{method}:{uri_path}".encode()).hexdigest() parts = [ f'username="{ONVIF_USER}"', f'realm="{realm}"', f'nonce="{nonce}"', f'uri="{uri_path}"', ] if qop: cnonce, nc = uuid.uuid4().hex[:16], "00000001" response = hashlib.md5(f"{ha1}:{nonce}:{nc}:{cnonce}:auth:{ha2}".encode()).hexdigest() parts += [f'response="{response}"', "qop=auth", f"nc={nc}", f'cnonce="{cnonce}"'] else: parts.append(f'response="{hashlib.md5(f"{ha1}:{nonce}:{ha2}".encode()).hexdigest()}"') if opaque: parts.append(f'opaque="{opaque}"') return "Digest " + ", ".join(parts) def fetch_snapshot(url: str) -> bytes: basic = base64.b64encode(f"{ONVIF_USER}:{ONVIF_PASS}".encode()).decode() req = urlrequest.Request(url, headers={"authorization": f"Basic {basic}"}) try: with urlrequest.urlopen(req, timeout=TIMEOUT, context=SSL_CTX) as res: return res.read() except HTTPError as exc: if exc.code != 401: raise challenge = exc.headers.get("www-authenticate", "") if "digest" not in challenge.lower(): raise path = "/" + url.split("/", 3)[3] if url.count("/") > 2 else "/" retry = urlrequest.Request(url, headers={"authorization": _digest_header(challenge, "GET", path)}) with urlrequest.urlopen(retry, timeout=TIMEOUT, context=SSL_CTX) as res: return res.read() # --------------------------------------------------------------------------- # # Auvi upload - raw / multipart / json, with every auth method # --------------------------------------------------------------------------- # def apply_auth(url: str, headers: Dict[str, str]) -> str: if AUTH == "header": headers["x-device-key"] = KEY elif AUTH == "bearer": headers["authorization"] = f"Bearer {KEY}" elif AUTH == "basic": headers["authorization"] = "Basic " + base64.b64encode(f"auvi:{KEY}".encode()).decode() elif AUTH == "query": return url + ("&" if "?" in url else "?") + f"key={KEY}" else: die(f"unknown AUVI_AUTH '{AUTH}' (header|bearer|basic|query)") return url def build_body(image: bytes, captured_at: str) -> Tuple[bytes, str]: """Returns (body, content-type) for the configured upload format.""" if FORMAT == "raw": return image, "image/jpeg" if FORMAT == "multipart": boundary = "----auvi" + uuid.uuid4().hex crlf = b"\r\n" parts = [ b"--" + boundary.encode(), crlf, b'Content-Disposition: form-data; name="captured_at"', crlf, crlf, captured_at.encode(), crlf, b"--" + boundary.encode(), crlf, b'Content-Disposition: form-data; name="image"; filename="frame.jpg"', crlf, b"Content-Type: image/jpeg", crlf, crlf, image, crlf, b"--" + boundary.encode() + b"--", crlf, ] return b"".join(parts), f"multipart/form-data; boundary={boundary}" if FORMAT == "json": payload = { "image": "data:image/jpeg;base64," + base64.b64encode(image).decode(), "captured_at": captured_at, } return jsonlib.dumps(payload).encode(), "application/json" die(f"unknown AUVI_FORMAT '{FORMAT}' (raw|multipart|json)") return b"", "" def upload(image: bytes, captured_at: str) -> Tuple[int, str]: body, content_type = build_body(image, captured_at) headers = {"content-type": content_type} url = apply_auth(ENDPOINT, headers) if FORMAT == "raw": headers["x-captured-at"] = captured_at # raw bodies carry no fields req = urlrequest.Request(url, data=body, headers=headers, method="POST") try: with urlrequest.urlopen(req, timeout=TIMEOUT, context=SSL_CTX) as res: return res.status, res.read().decode("utf-8", "replace") except HTTPError as exc: return exc.code, exc.read().decode("utf-8", "replace") def check_key() -> None: headers: Dict[str, str] = {} url = apply_auth(ENDPOINT, headers) req = urlrequest.Request(url, headers=headers, method="GET") try: with urlrequest.urlopen(req, timeout=TIMEOUT, context=SSL_CTX) as res: log("key ok ->", res.read().decode("utf-8", "replace")) except HTTPError as exc: die(f"key check failed ({exc.code}): {exc.read().decode('utf-8', 'replace')}") except URLError as exc: die(f"cannot reach {ENDPOINT}: {exc.reason}") # --------------------------------------------------------------------------- # # Main # --------------------------------------------------------------------------- # def main() -> None: for name, value in (("ONVIF_HOST", ONVIF_HOST), ("ONVIF_USER", ONVIF_USER)): if not value and not ONVIF_SNAPSHOT: die(f"{name} is required (or set ONVIF_SNAPSHOT to skip discovery)") if "--list" in sys.argv: for token, name in get_profiles(): print(f"{token}\t{name}\t{get_snapshot_uri(token)}") return if not ENDPOINT or not KEY: die("AUVI_ENDPOINT and AUVI_KEY are required") check_key() snapshot_url = resolve_snapshot_url() log(f"snapshot source: {re.sub(r'//[^@/]+@', '//***@', snapshot_url)}") log(f"posting as auth={AUTH} format={FORMAT} interval={INTERVAL}s") backoff = 1.0 while True: started = time.time() captured_at = now_iso() try: image = fetch_snapshot(snapshot_url) if not image: raise RuntimeError("camera returned an empty snapshot") if len(image) > MAX_BYTES: raise RuntimeError(f"snapshot is {len(image)} bytes, over the 20MB limit") status, text = upload(image, captured_at) if status in (200, 201): backoff = 1.0 log(f"ok {len(image)} bytes via {FORMAT}") elif status == 429: wait = backoff log(f"rate limited, waiting {wait:.0f}s") time.sleep(wait) backoff = min(backoff * 2, 60) else: log(f"upload failed ({status}): {text[:200]}") except Exception as exc: # keep the loop alive across camera hiccups log(f"capture/upload error: {exc}") time.sleep(min(backoff, 30)) backoff = min(backoff * 2, 60) if INTERVAL <= 0: return remaining = INTERVAL - (time.time() - started) if remaining > 0: time.sleep(remaining) if __name__ == "__main__": try: main() except KeyboardInterrupt: print()