From e92b5a560b91b7abcc11164fdd0006a45da577a9 Mon Sep 17 00:00:00 2001 From: Alex Kazaiev Date: Tue, 30 Dec 2025 11:09:40 -0600 Subject: [PATCH] Clean up server architecture for multi-instance deployment - Remove main_cycling.py, main_multi.py, main_release.py (single main.py is canonical) - Update setup.sh to read SERVICE_NAME and PORT from .env - Update env.example with SERVICE_NAME and PORT for multi-instance support - Fix server-csi to try rpicam-still before libcamera-still (Debian Trixie) Deploy pattern: clone repo twice, configure each .env, run setup.sh Each instance gets its own systemd service and install directory. --- server-csi/main.py | 58 ++++----- server/env.example | 16 ++- server/main_cycling.py | 259 ----------------------------------------- server/main_multi.py | 208 --------------------------------- server/main_release.py | 139 ---------------------- server/setup.sh | 126 +++++++++----------- 6 files changed, 99 insertions(+), 707 deletions(-) delete mode 100644 server/main_cycling.py delete mode 100644 server/main_multi.py delete mode 100644 server/main_release.py diff --git a/server-csi/main.py b/server-csi/main.py index a3d8d71..7571b6a 100644 --- a/server-csi/main.py +++ b/server-csi/main.py @@ -142,33 +142,37 @@ class CSICameraManager: return None def _capture_libcamera(self) -> Optional[bytes]: - """Fallback: use libcamera-still command""" - try: - cmd = [ - "libcamera-still", - "-n", # No preview - "-o", "-", # Output to stdout - "--width", str(self.width), - "--height", str(self.height), - "-q", str(JPEG_QUALITY), - "-t", "1", # 1ms timeout (immediate capture) - ] - - if self.rotation: - cmd.extend(["--rotation", str(self.rotation)]) - - result = subprocess.run(cmd, capture_output=True, timeout=10) - if result.returncode == 0: - return result.stdout - else: - print(f"libcamera-still error: {result.stderr.decode()}") - return None - except subprocess.TimeoutExpired: - print("libcamera-still timed out") - return None - except FileNotFoundError: - # Try raspistill (older Pi OS) - return self._capture_raspistill() + """Fallback: use rpicam-still/libcamera-still command""" + # Try rpicam-still first (Debian Trixie / newer Pi OS) + for cmd_name in ["rpicam-still", "libcamera-still"]: + try: + cmd = [ + cmd_name, + "-n", # No preview + "-o", "-", # Output to stdout + "--width", str(self.width), + "--height", str(self.height), + "-q", str(JPEG_QUALITY), + "-t", "1", # 1ms timeout (immediate capture) + ] + + if self.rotation: + cmd.extend(["--rotation", str(self.rotation)]) + + result = subprocess.run(cmd, capture_output=True, timeout=10) + if result.returncode == 0: + return result.stdout + else: + print(f"{cmd_name} error: {result.stderr.decode()}") + continue + except subprocess.TimeoutExpired: + print(f"{cmd_name} timed out") + continue + except FileNotFoundError: + continue + + # Fall back to raspistill (older Pi OS) + return self._capture_raspistill() def _capture_raspistill(self) -> Optional[bytes]: """Legacy fallback: use raspistill command""" diff --git a/server/env.example b/server/env.example index 695dafd..57d7437 100644 --- a/server/env.example +++ b/server/env.example @@ -6,12 +6,20 @@ # API Key for authentication (generate with: python3 -c 'import secrets; print(secrets.token_urlsafe(32))') API_KEY=your-secret-key-here -# Camera identifier (used in events) +# Camera identifier (used in events and API responses) CAMERA_ID=basement +# ============ Service Settings ============ + +# Service name for systemd (allows multiple instances) +SERVICE_NAME=vixy-vision-basement + +# Port to run on (each instance needs unique port) +PORT=8443 + # ============ Camera Settings ============ -# Camera device index (0 = first USB camera) +# Camera device index (0 = /dev/video0, 2 = /dev/video2, etc.) CAMERA_INDEX=0 # Resolution (camera will use closest supported) @@ -40,8 +48,8 @@ MOTION_INTERVAL=0.5 # ============ Event Collector ============ -# URL to POST motion events to (on Mac mini) -COLLECTOR_URL=http://192.168.1.50:8780/events +# URL to POST motion events to (collector on Mac mini) +COLLECTOR_URL=http://macmini.local:8780/events # API key for collector (optional) COLLECTOR_API_KEY= diff --git a/server/main_cycling.py b/server/main_cycling.py deleted file mode 100644 index 3ed7f63..0000000 --- a/server/main_cycling.py +++ /dev/null @@ -1,259 +0,0 @@ -#!/usr/bin/env python3 -""" -vixy-vision Camera Server v3.2 - Multi-Camera with Cycling Motion Detection - -For Pi with V4L2 limitations: -- Releases camera after each snapshot -- Single motion detection thread cycles between cameras -""" - -import os -import cv2 -import json -import time -import threading -import requests -from datetime import datetime -from typing import Optional, Dict -from dotenv import load_dotenv -from fastapi import FastAPI, Security, HTTPException, Response -from fastapi.security import APIKeyHeader - -load_dotenv() - -# Configuration -API_KEY = os.getenv("API_KEY") -JPEG_QUALITY = int(os.getenv("JPEG_QUALITY", "85")) -CAMERAS_CONFIG = os.getenv("CAMERAS", '{"camera": 0}') -DEFAULT_WIDTH = int(os.getenv("CAMERA_WIDTH", "1920")) -DEFAULT_HEIGHT = int(os.getenv("CAMERA_HEIGHT", "1080")) -MOTION_ENABLED = os.getenv("MOTION_ENABLED", "false").lower() == "true" -MOTION_THRESHOLD = int(os.getenv("MOTION_THRESHOLD", "25")) -MOTION_MIN_AREA = float(os.getenv("MOTION_MIN_AREA", "0.5")) -MOTION_COOLDOWN = float(os.getenv("MOTION_COOLDOWN", "60.0")) -COLLECTOR_URL = os.getenv("COLLECTOR_URL", "") -CYCLE_INTERVAL = float(os.getenv("CYCLE_INTERVAL", "1.0")) # seconds between camera switches - -if not API_KEY: - raise ValueError("API_KEY not set") - -CAMERAS: Dict[str, int] = json.loads(CAMERAS_CONFIG) - -app = FastAPI( - title="vixy-vision Camera Server", - description="Multi-camera with cycling motion detection 🦊", - version="3.2.0" -) - -api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) -camera_lock = threading.Lock() - -# Motion detection state per camera -motion_state: Dict[str, dict] = {} -for cam_id in CAMERAS: - motion_state[cam_id] = { - "prev_frame": None, - "last_event": 0, - } - - -def capture_frame(device_index: int, width: int, height: int) -> Optional[any]: - """Capture a single frame, release camera immediately.""" - cap = None - try: - cap = cv2.VideoCapture(device_index) - if not cap.isOpened(): - return None - cap.set(cv2.CAP_PROP_FRAME_WIDTH, width) - cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height) - cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) - for _ in range(3): - cap.grab() - ret, frame = cap.read() - return frame if ret else None - except Exception as e: - print(f"Error capturing from /dev/video{device_index}: {e}") - return None - finally: - if cap is not None: - cap.release() - - -def capture_snapshot(device_index: int, width: int, height: int) -> Optional[bytes]: - """Capture snapshot as JPEG bytes.""" - with camera_lock: - frame = capture_frame(device_index, width, height) - if frame is None: - return None - ret, buffer = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, JPEG_QUALITY]) - return buffer.tobytes() if ret else None - - -def detect_motion(cam_id: str, frame) -> Optional[dict]: - """Check for motion against previous frame.""" - state = motion_state[cam_id] - - # Convert to grayscale and blur - gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) - gray = cv2.GaussianBlur(gray, (21, 21), 0) - - if state["prev_frame"] is None: - state["prev_frame"] = gray - return None - - # Calculate difference - delta = cv2.absdiff(state["prev_frame"], gray) - state["prev_frame"] = gray - - thresh = cv2.threshold(delta, MOTION_THRESHOLD, 255, cv2.THRESH_BINARY)[1] - thresh = cv2.dilate(thresh, None, iterations=2) - - contours, _ = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) - - if not contours: - return None - - # Find largest contour - largest = max(contours, key=cv2.contourArea) - area = cv2.contourArea(largest) - frame_area = frame.shape[0] * frame.shape[1] - area_percent = (area / frame_area) * 100 - - if area_percent < MOTION_MIN_AREA: - return None - - # Check cooldown - now = time.time() - if now - state["last_event"] < MOTION_COOLDOWN: - return None - - state["last_event"] = now - - return { - "camera_id": cam_id, - "area_percent": round(area_percent, 2), - "timestamp": datetime.now().isoformat(), - } - - -def send_event(event: dict, snapshot: bytes): - """Send motion event to collector.""" - if not COLLECTOR_URL: - print(f"🔔 Motion on {event['camera_id']}: {event['area_percent']}% (no collector)") - return - - try: - files = {"snapshot": ("snapshot.jpg", snapshot, "image/jpeg")} - data = { - "camera_id": event["camera_id"], - "event_type": "motion", - "confidence": min(event["area_percent"] * 10, 100), - "area_percent": event["area_percent"], - } - response = requests.post(COLLECTOR_URL, data=data, files=files, timeout=5) - if response.ok: - print(f"🔔 Motion event sent: {event['camera_id']}") - else: - print(f"⚠️ Collector error: {response.status_code}") - except Exception as e: - print(f"⚠️ Failed to send event: {e}") - - -def motion_detection_loop(): - """Single thread that cycles through all cameras for motion detection.""" - print(f"🎯 Motion detection started (cycling {len(CAMERAS)} cameras, {CYCLE_INTERVAL}s interval)") - - camera_list = list(CAMERAS.items()) - - while True: - for cam_id, device_index in camera_list: - try: - with camera_lock: - frame = capture_frame(device_index, DEFAULT_WIDTH, DEFAULT_HEIGHT) - - if frame is None: - continue - - event = detect_motion(cam_id, frame) - - if event: - # Capture fresh snapshot for the event - with camera_lock: - snap_frame = capture_frame(device_index, DEFAULT_WIDTH, DEFAULT_HEIGHT) - if snap_frame is not None: - ret, buffer = cv2.imencode('.jpg', snap_frame, [cv2.IMWRITE_JPEG_QUALITY, JPEG_QUALITY]) - if ret: - send_event(event, buffer.tobytes()) - - except Exception as e: - print(f"Motion error on {cam_id}: {e}") - - # Wait before checking next camera - time.sleep(CYCLE_INTERVAL) - - -def verify_api_key(api_key: str = Security(api_key_header)) -> str: - if api_key != API_KEY: - raise HTTPException(status_code=403, detail="Invalid API key") - return api_key - - -# Startup -for cam_id, dev_idx in CAMERAS.items(): - print(f"🦊 Registered: {cam_id} -> /dev/video{dev_idx}") - -if MOTION_ENABLED: - motion_thread = threading.Thread(target=motion_detection_loop, daemon=True) - motion_thread.start() -else: - print("📷 Motion detection disabled") - -DEFAULT_CAM = list(CAMERAS.keys())[0] if CAMERAS else None - - -@app.get("/") -def root(): - return { - "service": "vixy-vision Camera Server", - "version": "3.2.0", - "mode": "cycling-motion", - "cameras": list(CAMERAS.keys()), - "motion_enabled": MOTION_ENABLED, - "cycle_interval": CYCLE_INTERVAL, - } - - -@app.get("/health") -def health(): - return {"status": "ok", "cameras": list(CAMERAS.keys()), "motion": MOTION_ENABLED} - - -@app.get("/snapshot") -def snapshot_default(api_key: str = Security(verify_api_key)): - if not DEFAULT_CAM: - raise HTTPException(status_code=503, detail="No cameras configured") - return snapshot_by_id(DEFAULT_CAM, api_key) - - -@app.get("/snapshot/{cam_id}") -def snapshot_by_id(cam_id: str, api_key: str = Security(verify_api_key)): - if cam_id not in CAMERAS: - raise HTTPException(status_code=404, detail=f"Camera '{cam_id}' not found") - - device_index = CAMERAS[cam_id] - snapshot = capture_snapshot(device_index, DEFAULT_WIDTH, DEFAULT_HEIGHT) - - if snapshot is None: - raise HTTPException(status_code=503, detail=f"Camera '{cam_id}' unavailable") - - return Response( - content=snapshot, - media_type="image/jpeg", - headers={"Cache-Control": "no-cache", "X-Camera-ID": cam_id} - ) - - -if __name__ == "__main__": - import uvicorn - uvicorn.run("main:app", host="0.0.0.0", port=8443, - ssl_keyfile="ssl/key.pem", ssl_certfile="ssl/cert.pem") diff --git a/server/main_multi.py b/server/main_multi.py deleted file mode 100644 index 01e90dc..0000000 --- a/server/main_multi.py +++ /dev/null @@ -1,208 +0,0 @@ -#!/usr/bin/env python3 -""" -vixy-vision Camera Server v3 - Multi-Camera Support - -FastAPI server with multiple USB cameras, identified by name. -""" - -import os -import cv2 -import json -import threading -from typing import Optional, Dict -from dotenv import load_dotenv -from fastapi import FastAPI, Security, HTTPException, Response, Path -from fastapi.security import APIKeyHeader - -from motion import MotionDetector - -load_dotenv() - -# Configuration -API_KEY = os.getenv("API_KEY") -JPEG_QUALITY = int(os.getenv("JPEG_QUALITY", "85")) - -# Camera config: {"camera_id": device_index, ...} -# Example: CAMERAS='{"basement": 0, "basement2": 1}' -CAMERAS_CONFIG = os.getenv("CAMERAS", '{"camera": 0}') -DEFAULT_WIDTH = int(os.getenv("CAMERA_WIDTH", "1920")) -DEFAULT_HEIGHT = int(os.getenv("CAMERA_HEIGHT", "1080")) - -# Motion detection config -MOTION_ENABLED = os.getenv("MOTION_ENABLED", "false").lower() == "true" -MOTION_THRESHOLD = int(os.getenv("MOTION_THRESHOLD", "25")) -MOTION_MIN_AREA = float(os.getenv("MOTION_MIN_AREA", "0.5")) -MOTION_COOLDOWN = float(os.getenv("MOTION_COOLDOWN", "5.0")) -MOTION_INTERVAL = float(os.getenv("MOTION_INTERVAL", "0.5")) -COLLECTOR_URL = os.getenv("COLLECTOR_URL", "") -COLLECTOR_API_KEY = os.getenv("COLLECTOR_API_KEY", "") - -if not API_KEY: - raise ValueError("API_KEY not set in .env file") - -# Parse camera config -CAMERAS: Dict[str, int] = json.loads(CAMERAS_CONFIG) - -app = FastAPI( - title="vixy-vision Camera Server", - description="Multi-camera snapshots for the fox 🦊", - version="3.0.0" -) - -api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) - - -class CameraManager: - """Thread-safe camera manager""" - - def __init__(self, camera_id: str, device_index: int, width: int, height: int): - self.camera_id = camera_id - self.device_index = device_index - self.width = width - self.height = height - self.camera: Optional[cv2.VideoCapture] = None - self.lock = threading.Lock() - - def _open_camera(self) -> bool: - try: - self.camera = cv2.VideoCapture(self.device_index) - if not self.camera.isOpened(): - return False - self.camera.set(cv2.CAP_PROP_FRAME_WIDTH, self.width) - self.camera.set(cv2.CAP_PROP_FRAME_HEIGHT, self.height) - self.camera.set(cv2.CAP_PROP_BUFFERSIZE, 1) - actual_w = int(self.camera.get(cv2.CAP_PROP_FRAME_WIDTH)) - actual_h = int(self.camera.get(cv2.CAP_PROP_FRAME_HEIGHT)) - print(f"📷 {self.camera_id} (dev {self.device_index}): {actual_w}x{actual_h}") - return True - except Exception as e: - print(f"Error opening {self.camera_id}: {e}") - return False - - def get_raw_frame(self): - with self.lock: - if self.camera is None or not self.camera.isOpened(): - if not self._open_camera(): - return None - for _ in range(3): - self.camera.grab() - ret, frame = self.camera.read() - return frame if ret else None - - def get_snapshot(self) -> Optional[bytes]: - frame = self.get_raw_frame() - if frame is None: - return None - try: - ret, buffer = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, JPEG_QUALITY]) - return buffer.tobytes() if ret else None - except Exception as e: - print(f"Error encoding: {e}") - return None - - def release(self): - if self.camera: - self.camera.release() - self.camera = None - - -# Initialize cameras by ID -cameras: Dict[str, CameraManager] = {} -for cam_id, dev_idx in CAMERAS.items(): - cameras[cam_id] = CameraManager(cam_id, dev_idx, DEFAULT_WIDTH, DEFAULT_HEIGHT) - print(f"🦊 Registered: {cam_id} -> /dev/video{dev_idx}") - -# First camera is default -DEFAULT_CAM = list(cameras.keys())[0] if cameras else None - -# Motion detectors -motion_detectors: Dict[str, MotionDetector] = {} -if MOTION_ENABLED: - for cam_id, cam_mgr in cameras.items(): - motion_detectors[cam_id] = MotionDetector( - camera_id=cam_id, - collector_url=COLLECTOR_URL or None, - collector_api_key=COLLECTOR_API_KEY or None, - threshold=MOTION_THRESHOLD, - min_area_percent=MOTION_MIN_AREA, - cooldown_seconds=MOTION_COOLDOWN, - check_interval=MOTION_INTERVAL, - ) - - -def verify_api_key(api_key: str = Security(api_key_header)) -> str: - if api_key != API_KEY: - raise HTTPException(status_code=403, detail="Invalid API key") - return api_key - - -@app.on_event("startup") -def startup(): - if MOTION_ENABLED: - for cam_id, detector in motion_detectors.items(): - detector.start(cameras[cam_id].get_raw_frame) - print(f"🦊 Motion enabled: {cam_id}") - - -@app.on_event("shutdown") -def shutdown(): - for d in motion_detectors.values(): - d.stop() - for c in cameras.values(): - c.release() - - -@app.get("/") -def root(): - return { - "service": "vixy-vision Camera Server", - "version": "3.0.0", - "cameras": list(cameras.keys()), - "motion_enabled": MOTION_ENABLED, - } - - -@app.get("/health") -def health(): - return {"status": "ok", "cameras": list(cameras.keys())} - - -@app.get("/snapshot") -def snapshot_default(api_key: str = Security(verify_api_key)): - """Snapshot from default camera""" - if not DEFAULT_CAM: - raise HTTPException(status_code=503, detail="No cameras configured") - return snapshot_by_id(DEFAULT_CAM, api_key) - - -@app.get("/snapshot/{cam_id}") -def snapshot_by_id(cam_id: str, api_key: str = Security(verify_api_key)): - """Snapshot from specific camera""" - if cam_id not in cameras: - raise HTTPException(status_code=404, detail=f"Camera '{cam_id}' not found") - - snapshot = cameras[cam_id].get_snapshot() - if snapshot is None: - raise HTTPException(status_code=503, detail=f"Camera '{cam_id}' unavailable") - - return Response( - content=snapshot, - media_type="image/jpeg", - headers={"Cache-Control": "no-cache", "X-Camera-ID": cam_id} - ) - - -@app.get("/motion/stats") -def motion_stats(api_key: str = Security(verify_api_key)): - if not motion_detectors: - return {"enabled": False} - return { - "enabled": True, - "cameras": {cam_id: d.get_stats() for cam_id, d in motion_detectors.items()} - } - - -if __name__ == "__main__": - import uvicorn - uvicorn.run("main_multi:app", host="0.0.0.0", port=8443, - ssl_keyfile="ssl/key.pem", ssl_certfile="ssl/cert.pem") diff --git a/server/main_release.py b/server/main_release.py deleted file mode 100644 index 89d1a1a..0000000 --- a/server/main_release.py +++ /dev/null @@ -1,139 +0,0 @@ -#!/usr/bin/env python3 -""" -vixy-vision Camera Server v3.1 - Multi-Camera with Release-After-Use - -For Pi with V4L2 limitations - releases camera after each snapshot -to allow multiple cameras to work. -""" - -import os -import cv2 -import json -import threading -from typing import Optional, Dict -from dotenv import load_dotenv -from fastapi import FastAPI, Security, HTTPException, Response, Path -from fastapi.security import APIKeyHeader - -load_dotenv() - -# Configuration -API_KEY = os.getenv("API_KEY") -JPEG_QUALITY = int(os.getenv("JPEG_QUALITY", "85")) -CAMERAS_CONFIG = os.getenv("CAMERAS", '{"camera": 0}') -DEFAULT_WIDTH = int(os.getenv("CAMERA_WIDTH", "1920")) -DEFAULT_HEIGHT = int(os.getenv("CAMERA_HEIGHT", "1080")) - -if not API_KEY: - raise ValueError("API_KEY not set in .env file") - -CAMERAS: Dict[str, int] = json.loads(CAMERAS_CONFIG) - -app = FastAPI( - title="vixy-vision Camera Server", - description="Multi-camera snapshots for the fox 🦊 (release-after-use mode)", - version="3.1.0" -) - -api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) - -# Global lock to prevent simultaneous camera access on Pi -camera_lock = threading.Lock() - - -def capture_snapshot(device_index: int, width: int, height: int) -> Optional[bytes]: - """ - Capture a single snapshot, then release the camera. - Uses global lock to prevent V4L2 conflicts. - """ - with camera_lock: - cap = None - try: - cap = cv2.VideoCapture(device_index) - if not cap.isOpened(): - print(f"Failed to open /dev/video{device_index}") - return None - - cap.set(cv2.CAP_PROP_FRAME_WIDTH, width) - cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height) - cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) - - # Grab a few frames to get fresh image - for _ in range(3): - cap.grab() - - ret, frame = cap.read() - if not ret or frame is None: - print(f"Failed to read from /dev/video{device_index}") - return None - - ret, buffer = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, JPEG_QUALITY]) - return buffer.tobytes() if ret else None - - except Exception as e: - print(f"Error capturing from /dev/video{device_index}: {e}") - return None - finally: - if cap is not None: - cap.release() - - -def verify_api_key(api_key: str = Security(api_key_header)) -> str: - if api_key != API_KEY: - raise HTTPException(status_code=403, detail="Invalid API key") - return api_key - - -# Print registered cameras on startup -for cam_id, dev_idx in CAMERAS.items(): - print(f"🦊 Registered: {cam_id} -> /dev/video{dev_idx}") - -DEFAULT_CAM = list(CAMERAS.keys())[0] if CAMERAS else None - - -@app.get("/") -def root(): - return { - "service": "vixy-vision Camera Server", - "version": "3.1.0", - "mode": "release-after-use", - "cameras": list(CAMERAS.keys()), - } - - -@app.get("/health") -def health(): - return {"status": "ok", "cameras": list(CAMERAS.keys())} - - -@app.get("/snapshot") -def snapshot_default(api_key: str = Security(verify_api_key)): - """Snapshot from default camera""" - if not DEFAULT_CAM: - raise HTTPException(status_code=503, detail="No cameras configured") - return snapshot_by_id(DEFAULT_CAM, api_key) - - -@app.get("/snapshot/{cam_id}") -def snapshot_by_id(cam_id: str, api_key: str = Security(verify_api_key)): - """Snapshot from specific camera""" - if cam_id not in CAMERAS: - raise HTTPException(status_code=404, detail=f"Camera '{cam_id}' not found") - - device_index = CAMERAS[cam_id] - snapshot = capture_snapshot(device_index, DEFAULT_WIDTH, DEFAULT_HEIGHT) - - if snapshot is None: - raise HTTPException(status_code=503, detail=f"Camera '{cam_id}' unavailable") - - return Response( - content=snapshot, - media_type="image/jpeg", - headers={"Cache-Control": "no-cache", "X-Camera-ID": cam_id} - ) - - -if __name__ == "__main__": - import uvicorn - uvicorn.run("main_multi:app", host="0.0.0.0", port=8443, - ssl_keyfile="ssl/key.pem", ssl_certfile="ssl/cert.pem") diff --git a/server/setup.sh b/server/setup.sh index 83a0ef1..d1f5efb 100644 --- a/server/setup.sh +++ b/server/setup.sh @@ -1,14 +1,14 @@ #!/bin/bash # vixy-vision Server Setup Script # Run this on a Raspberry Pi or similar edge device -# -# Usage: ./setup.sh [--with-audio] +# +# Usage: ./setup.sh +# +# Reads SERVICE_NAME and PORT from .env file for multi-instance support set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -INSTALL_DIR="${HOME}/vixy-vision" -SERVICE_NAME="vixy-vision" # Colors for output RED='\033[0;31m' @@ -20,20 +20,9 @@ echo_info() { echo -e "${GREEN}[INFO]${NC} $1"; } echo_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } echo_error() { echo -e "${RED}[ERROR]${NC} $1"; } -# Parse arguments -WITH_AUDIO=false -for arg in "$@"; do - case $arg in - --with-audio) - WITH_AUDIO=true - shift - ;; - esac -done - echo "==========================================" echo " vixy-vision Server Setup" -echo " Eyes and ears for the fox 🦊" +echo " Eyes for the fox 🦊" echo "==========================================" echo "" @@ -42,21 +31,48 @@ if [[ "$(uname)" != "Linux" ]]; then echo_error "This script is designed for Linux (Raspberry Pi)" exit 1 fi +# Check for .env file +if [ ! -f "${SCRIPT_DIR}/.env" ]; then + echo_error "No .env file found!" + echo_info "Copy env.example to .env and configure it first:" + echo " cp env.example .env" + echo " nano .env" + exit 1 +fi + +# Load configuration from .env +source "${SCRIPT_DIR}/.env" + +# Set defaults if not in .env +SERVICE_NAME="${SERVICE_NAME:-vixy-vision}" +PORT="${PORT:-8443}" +CAMERA_ID="${CAMERA_ID:-camera}" + +echo_info "Configuration:" +echo " SERVICE_NAME: ${SERVICE_NAME}" +echo " PORT: ${PORT}" +echo " CAMERA_ID: ${CAMERA_ID}" +echo "" + +# Install directory based on service name +INSTALL_DIR="${HOME}/${SERVICE_NAME}" # Install system dependencies echo_info "Installing system dependencies..." sudo apt-get update -sudo apt-get install -y python3-pip python3-venv libopencv-dev - -if [ "$WITH_AUDIO" = true ]; then - echo_info "Installing audio dependencies..." - sudo apt-get install -y portaudio19-dev python3-pyaudio alsa-utils -fi +sudo apt-get install -y python3-pip python3-venv libopencv-dev python3-opencv # Create install directory echo_info "Creating install directory: ${INSTALL_DIR}" mkdir -p "${INSTALL_DIR}" -cp -r "${SCRIPT_DIR}"/* "${INSTALL_DIR}/" +mkdir -p "${INSTALL_DIR}/ssl" + +# Copy files +cp "${SCRIPT_DIR}/main.py" "${INSTALL_DIR}/" +cp "${SCRIPT_DIR}/motion.py" "${INSTALL_DIR}/" +cp "${SCRIPT_DIR}/requirements.txt" "${INSTALL_DIR}/" +cp "${SCRIPT_DIR}/generate_cert.sh" "${INSTALL_DIR}/" +cp "${SCRIPT_DIR}/.env" "${INSTALL_DIR}/" # Create virtual environment echo_info "Creating Python virtual environment..." @@ -69,54 +85,29 @@ echo_info "Installing Python dependencies..." pip install --upgrade pip pip install -r requirements.txt -if [ "$WITH_AUDIO" = true ]; then - pip install pyaudio webrtcvad numpy -fi - -# Generate SSL certificates -echo_info "Generating SSL certificates..." -chmod +x generate_cert.sh -./generate_cert.sh - -# Generate API key if .env doesn't exist -if [ ! -f .env ]; then - echo_info "Generating API key..." - API_KEY=$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))') - cat > .env << EOF -# vixy-vision Server Configuration -# Generated by setup.sh on $(date) - -# API Key for authentication (keep secret!) -API_KEY=${API_KEY} - -# Camera settings -CAMERA_INDEX=0 -CAMERA_WIDTH=1920 -CAMERA_HEIGHT=1080 -JPEG_QUALITY=85 -EOF - echo_info "API key generated and saved to .env" - echo "" - echo_warn "IMPORTANT: Save this API key for your MCP config:" - echo -e " ${GREEN}${API_KEY}${NC}" - echo "" +# Generate SSL certificates if not present +if [ ! -f ssl/cert.pem ]; then + echo_info "Generating SSL certificates..." + chmod +x generate_cert.sh + ./generate_cert.sh else - echo_info "Using existing .env file" + echo_info "SSL certificates already exist" fi # Create systemd service -echo_info "Creating systemd service..." +echo_info "Creating systemd service: ${SERVICE_NAME}" sudo tee /etc/systemd/system/${SERVICE_NAME}.service > /dev/null << EOF [Unit] -Description=vixy-vision Camera Server +Description=vixy-vision Camera Server (${CAMERA_ID}) After=network.target [Service] Type=simple User=${USER} WorkingDirectory=${INSTALL_DIR} +EnvironmentFile=${INSTALL_DIR}/.env Environment="PATH=${INSTALL_DIR}/venv/bin" -ExecStart=${INSTALL_DIR}/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8443 --ssl-keyfile ssl/key.pem --ssl-certfile ssl/cert.pem +ExecStart=${INSTALL_DIR}/venv/bin/uvicorn main:app --host 0.0.0.0 --port ${PORT} --ssl-keyfile ssl/key.pem --ssl-certfile ssl/cert.pem Restart=always RestartSec=10 @@ -133,6 +124,10 @@ echo "==========================================" echo " Setup Complete! 🦊" echo "==========================================" echo "" +echo "Service: ${SERVICE_NAME}" +echo "Port: ${PORT}" +echo "Camera ID: ${CAMERA_ID}" +echo "" echo "Commands:" echo " Start: sudo systemctl start ${SERVICE_NAME}" echo " Stop: sudo systemctl stop ${SERVICE_NAME}" @@ -140,18 +135,9 @@ echo " Status: sudo systemctl status ${SERVICE_NAME}" echo " Logs: sudo journalctl -u ${SERVICE_NAME} -f" echo "" echo "Server will be available at:" -echo " https://$(hostname -I | awk '{print $1}'):8443/" +echo " https://$(hostname).local:${PORT}/" echo "" -echo "Add to Vixy's vision config (~/.vision_setup.json):" -echo " {" -echo " \"cameras\": [" -echo " {" -echo " \"id\": \"$(hostname)\"," -echo " \"type\": \"http\"," -echo " \"url\": \"https://$(hostname -I | awk '{print $1}'):8443\"," -echo " \"api_key\": \"\"" -echo " }" -echo " ]" -echo " }" +echo "API Key from .env:" +grep "^API_KEY=" "${INSTALL_DIR}/.env" | cut -d'=' -f2 echo "" echo_info "Start the server with: sudo systemctl start ${SERVICE_NAME}"