#!/usr/bin/env python3 """ Vision Engine — Camera Relay ============================== Bridges the gap when Vision Engine runs on a remote VPS but your camera (Android IP Webcam) is only reachable on the local Wi-Fi network. Run this script on your laptop — it fetches frames from the phone and pushes them to the VPS over HTTPS. No firewall changes, no port-forwarding, no tunnels. Zero pip dependencies (Python stdlib only). Usage ----- python relay.py python relay.py --server https://lab.decimla.com python relay.py --camera http://10.102.196.114:8080/photo.jpg python relay.py --server https://lab.decimla.com --api-key YOUR_KEY --fps 10 If --camera is omitted, the camera URL is fetched from the VPS config and updated automatically every 30 seconds — so changing the URL in the dashboard takes effect here without restarting this script. Environment variables (alternative to args): VISION_SERVER_URL — VPS base URL (default: https://lab.decimla.com) VISION_CAM_URL — camera URL override VISION_API_KEY — API key (if VISION_API_KEY is set on the VPS) VISION_RELAY_FPS — target frame rate (default: 10) """ import argparse import json import os import sys import time import urllib.error import urllib.request from typing import Optional # ── Defaults from environment ───────────────────────────────────────────────── _DEFAULT_SERVER = os.environ.get("VISION_SERVER_URL", "https://lab.decimla.com") _DEFAULT_CAM_URL = os.environ.get("VISION_CAM_URL", "") _DEFAULT_API_KEY = os.environ.get("VISION_API_KEY", "") _DEFAULT_FPS = float(os.environ.get("VISION_RELAY_FPS", "10")) # ── Helpers ─────────────────────────────────────────────────────────────────── def _headers(api_key: str) -> dict: h = {} if api_key: h["X-API-Key"] = api_key return h def fetch_camera_url_from_vps(server: str, api_key: str) -> Optional[str]: """Ask the VPS for the currently configured camera URL.""" try: req = urllib.request.Request( server + "/api/v1/debug/camera/config", headers=_headers(api_key), ) with urllib.request.urlopen(req, timeout=5) as resp: data = json.loads(resp.read()) url = (data.get("data") or {}).get("cam_url", "").strip() return url if url else None except Exception as exc: print(f"[relay] Warning: could not fetch camera URL from VPS — {exc}") return None def fetch_jpeg(cam_url: str) -> Optional[bytes]: """Pull one JPEG frame from the local camera.""" try: with urllib.request.urlopen(cam_url, timeout=3) as resp: data = resp.read() return data if data else None except Exception: return None def push_jpeg(push_url: str, jpeg: bytes, api_key: str) -> bool: """POST a JPEG to the VPS relay endpoint.""" try: hdrs = {"Content-Type": "image/jpeg", "X-Relay-Source": "laptop", **_headers(api_key)} req = urllib.request.Request(push_url, data=jpeg, headers=hdrs, method="POST") with urllib.request.urlopen(req, timeout=5) as resp: return 200 <= resp.status < 300 except urllib.error.HTTPError as exc: if exc.code == 401: print("[relay] ERROR: 401 Unauthorized — check --api-key matches VISION_API_KEY on VPS") return False except Exception: return False # ── Main loop ───────────────────────────────────────────────────────────────── def main() -> None: parser = argparse.ArgumentParser( description="Vision Engine Camera Relay — push local camera frames to the VPS", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # Auto-detect camera URL from VPS (recommended): python relay.py --server https://lab.decimla.com # Explicit camera URL: python relay.py --server https://lab.decimla.com \\ --camera http://10.102.196.114:8080/photo.jpg # With API key: python relay.py --server https://lab.decimla.com --api-key abc123 IP Webcam (Android) photo URL format: http://:8080/photo.jpg <- single JPEG, works best http://:8080/video <- MJPEG stream (also supported) """, ) parser.add_argument("--server", default=_DEFAULT_SERVER, help=f"VPS base URL (default: {_DEFAULT_SERVER})") parser.add_argument("--camera", default=_DEFAULT_CAM_URL, help="Camera URL override (default: read from VPS config)") parser.add_argument("--api-key", default=_DEFAULT_API_KEY, help="API key matching VISION_API_KEY on VPS (blank = no auth)") parser.add_argument("--fps", type=float, default=_DEFAULT_FPS, help=f"Target push rate in frames/sec (default: {_DEFAULT_FPS})") args = parser.parse_args() server = args.server.rstrip("/") api_key = args.api_key interval = 1.0 / max(0.5, args.fps) push_url = server + "/api/v1/debug/camera/relay/frame" print("=" * 60) print(" Vision Engine Camera Relay") print("=" * 60) print(f" Server : {server}") print(f" Push URL: {push_url}") print(f" FPS : {args.fps:.1f}") print(f" API key : {'(set)' if api_key else '(none — auth disabled)'}") print() # ── Resolve initial camera URL ──────────────────────────────────────────── cam_url = args.camera.strip() cam_url_override = bool(cam_url) # user explicitly passed --camera if not cam_url: print(" Fetching camera URL from VPS config…") cam_url = fetch_camera_url_from_vps(server, api_key) or "" if not cam_url: print() print("ERROR: No camera URL found.") print(" Set it in the Vision Engine dashboard (Camera Settings → Change URL),") print(" or pass --camera http://10.x.x.x:8080/photo.jpg") sys.exit(1) print(f" Camera : {cam_url}") print() print(" Press Ctrl+C to stop.") print() # ── Tracking ────────────────────────────────────────────────────────────── push_count = 0 cam_fail_count = 0 push_fail_count = 0 cam_backoff = 1.0 push_backoff = 1.0 last_config_check = time.monotonic() last_status_print = time.monotonic() CONFIG_POLL_S = 30.0 # re-read camera URL from VPS (handles IP changes) STATUS_PRINT_S = 10.0 # print stats every N seconds while True: loop_start = time.monotonic() # ── Periodically refresh camera URL from VPS ────────────────────────── if not cam_url_override and (loop_start - last_config_check) >= CONFIG_POLL_S: new_url = fetch_camera_url_from_vps(server, api_key) if new_url and new_url != cam_url: print(f"[relay] Camera URL changed: {cam_url}") print(f"[relay] → {new_url}") cam_url = new_url cam_fail_count = 0 cam_backoff = 1.0 last_config_check = loop_start # ── Fetch frame from local camera ───────────────────────────────────── jpeg = fetch_jpeg(cam_url) if jpeg is None: cam_fail_count += 1 wait = min(cam_backoff, 30.0) print(f"[relay] Camera unreachable ({cam_url}) — retry in {wait:.1f}s " f"(attempt {cam_fail_count})") time.sleep(wait) cam_backoff = min(cam_backoff * 2.0, 30.0) continue if cam_backoff > 1.0: print(f"[relay] Camera reconnected: {cam_url}") cam_backoff = 1.0 cam_fail_count = 0 # ── Push frame to VPS ───────────────────────────────────────────────── ok = push_jpeg(push_url, jpeg, api_key) if ok: push_count += 1 push_fail_count = 0 push_backoff = 1.0 else: push_fail_count += 1 wait = min(push_backoff, 15.0) print(f"[relay] Push to VPS failed — retry in {wait:.1f}s " f"(attempt {push_fail_count}; is {server} reachable?)") time.sleep(wait) push_backoff = min(push_backoff * 2.0, 15.0) continue # ── Periodic status line ────────────────────────────────────────────── now = time.monotonic() if now - last_status_print >= STATUS_PRINT_S: elapsed = now - last_status_print fps_actual = push_count / elapsed if elapsed > 0 else 0 print(f"[relay] {push_count:,} frames pushed " f"fps≈{fps_actual:.1f} camera={cam_url}") push_count = 0 last_status_print = now # ── Pace to target FPS ──────────────────────────────────────────────── elapsed = time.monotonic() - loop_start sleep_for = max(0.0, interval - elapsed) if sleep_for: time.sleep(sleep_for) if __name__ == "__main__": try: main() except KeyboardInterrupt: print("\n[relay] Stopped.")