#!/usr/bin/env python3 """ Auvi Live Stream — reference ingest client (Python 3.8+, standard library only). AUVI_ENDPOINT=https://your-site.lovable.app/api/public/ingest \ AUVI_KEY=auvi_xxx \ python3 auvi_ingest.py ./frame.jpg Environment: AUVI_ENDPOINT ingest URL for your site (required) AUVI_KEY device key for the camera (required) AUVI_AUTH header | bearer | basic | query (default header) AUVI_FORMAT raw | multipart | json (default raw) AUVI_INTERVAL seconds between uploads; 0 = post once (default 0) AUVI_CAPTURE shell command that writes the frame file (optional) AUVI_RETRIES attempts per frame (default 3) """ import base64 import json import mimetypes import os import subprocess import sys import time import urllib.error import urllib.parse import urllib.request import uuid from datetime import datetime, timezone def required(name: str) -> str: value = os.environ.get(name) if not value: sys.exit(f"Missing {name}") return value ENDPOINT = required("AUVI_ENDPOINT") KEY = required("AUVI_KEY") AUTH = os.environ.get("AUVI_AUTH", "header").lower() FORMAT = os.environ.get("AUVI_FORMAT", "raw").lower() INTERVAL = float(os.environ.get("AUVI_INTERVAL", "0")) CAPTURE = os.environ.get("AUVI_CAPTURE", "") RETRIES = int(os.environ.get("AUVI_RETRIES", "3")) FILE = sys.argv[1] if len(sys.argv) > 1 else "./frame.jpg" ALLOWED = {"image/jpeg", "image/png", "image/webp"} def content_type_for(path: str) -> str: guessed, _ = mimetypes.guess_type(path) return guessed if guessed in ALLOWED else "image/jpeg" def apply_auth(url: str, headers: dict) -> str: """All four auth methods are accepted by the API — pick whichever your device supports.""" if AUTH == "bearer": headers["authorization"] = f"Bearer {KEY}" elif AUTH == "basic": token = base64.b64encode(f"camera:{KEY}".encode()).decode() headers["authorization"] = f"Basic {token}" elif AUTH == "query": joiner = "&" if "?" in url else "?" url = f"{url}{joiner}{urllib.parse.urlencode({'key': KEY})}" else: headers["x-device-key"] = KEY return url def build_body(path: str, captured_at: str, headers: dict) -> bytes: with open(path, "rb") as handle: raw = handle.read() content_type = content_type_for(path) if FORMAT == "multipart": boundary = uuid.uuid4().hex filename = os.path.basename(path) parts = [ f"--{boundary}\r\n".encode() + f'Content-Disposition: form-data; name="image"; filename="{filename}"\r\n'.encode() + f"Content-Type: {content_type}\r\n\r\n".encode() + raw + b"\r\n", f"--{boundary}\r\n".encode() + b'Content-Disposition: form-data; name="captured_at"\r\n\r\n' + captured_at.encode() + b"\r\n", f"--{boundary}--\r\n".encode(), ] headers["content-type"] = f"multipart/form-data; boundary={boundary}" return b"".join(parts) if FORMAT == "json": headers["content-type"] = "application/json" payload = { "image": f"data:{content_type};base64,{base64.b64encode(raw).decode()}", "captured_at": captured_at, } return json.dumps(payload).encode() headers["content-type"] = content_type return raw def send(method: str, body=None) -> tuple: headers: dict = {} url = apply_auth(ENDPOINT, headers) data = build_body(FILE, iso_now(), headers) if body == "frame" else None request = urllib.request.Request(url, data=data, headers=headers, method=method) try: with urllib.request.urlopen(request, timeout=20) as response: return response.status, json.loads(response.read().decode() or "{}") except urllib.error.HTTPError as error: detail = error.read().decode() try: return error.code, json.loads(detail or "{}") except json.JSONDecodeError: return error.code, {"error": detail} def iso_now() -> str: return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") def check_key() -> None: status, payload = send("GET") if status != 200: sys.exit(f"Key check failed ({status}): {payload}") camera = payload.get("camera", {}) print(f"Authenticated as {camera.get('name')} ({camera.get('slug')}) — {payload.get('frames')} frames stored") def post_frame() -> None: if CAPTURE: subprocess.run(CAPTURE, shell=True, check=False) for attempt in range(1, RETRIES + 1): try: status, payload = send("POST", body="frame") if status == 200: print(f"{iso_now()} → stored {payload.get('bytes')} bytes at {payload.get('path')}") return print(f"attempt {attempt}/{RETRIES} rejected: {status} {payload.get('error')}") if status < 500: # bad key, bad format, too large — retrying will not help return except Exception as error: # network/timeout print(f"attempt {attempt}/{RETRIES} failed: {error}") if attempt < RETRIES: time.sleep(min(2 ** attempt, 15)) if __name__ == "__main__": check_key() post_frame() if INTERVAL > 0: print(f"Posting every {INTERVAL:g}s — Ctrl+C to stop.") while True: time.sleep(INTERVAL) post_frame()