#!/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[^>]*>(.*?)", detail, re.S)
die(f"ONVIF call failed ({exc.code}): {reason.group(1) if reason else detail[:200]}")
except URLError as exc:
die(f"cannot reach ONVIF service at {service_url}: {exc.reason}")
return "" # unreachable
def _tags(xml: str, local_name: str) -> List[str]:
return re.findall(rf"<(?:\w+:)?{local_name}[^>]*>(.*?)(?:\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()