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.
This commit is contained in:
Alex Kazaiev
2025-12-30 11:09:40 -06:00
parent 844502b4a1
commit e92b5a560b
6 changed files with 99 additions and 707 deletions

View File

@@ -142,33 +142,37 @@ class CSICameraManager:
return None return None
def _capture_libcamera(self) -> Optional[bytes]: def _capture_libcamera(self) -> Optional[bytes]:
"""Fallback: use libcamera-still command""" """Fallback: use rpicam-still/libcamera-still command"""
try: # Try rpicam-still first (Debian Trixie / newer Pi OS)
cmd = [ for cmd_name in ["rpicam-still", "libcamera-still"]:
"libcamera-still", try:
"-n", # No preview cmd = [
"-o", "-", # Output to stdout cmd_name,
"--width", str(self.width), "-n", # No preview
"--height", str(self.height), "-o", "-", # Output to stdout
"-q", str(JPEG_QUALITY), "--width", str(self.width),
"-t", "1", # 1ms timeout (immediate capture) "--height", str(self.height),
] "-q", str(JPEG_QUALITY),
"-t", "1", # 1ms timeout (immediate capture)
if self.rotation: ]
cmd.extend(["--rotation", str(self.rotation)])
if self.rotation:
result = subprocess.run(cmd, capture_output=True, timeout=10) cmd.extend(["--rotation", str(self.rotation)])
if result.returncode == 0:
return result.stdout result = subprocess.run(cmd, capture_output=True, timeout=10)
else: if result.returncode == 0:
print(f"libcamera-still error: {result.stderr.decode()}") return result.stdout
return None else:
except subprocess.TimeoutExpired: print(f"{cmd_name} error: {result.stderr.decode()}")
print("libcamera-still timed out") continue
return None except subprocess.TimeoutExpired:
except FileNotFoundError: print(f"{cmd_name} timed out")
# Try raspistill (older Pi OS) continue
return self._capture_raspistill() except FileNotFoundError:
continue
# Fall back to raspistill (older Pi OS)
return self._capture_raspistill()
def _capture_raspistill(self) -> Optional[bytes]: def _capture_raspistill(self) -> Optional[bytes]:
"""Legacy fallback: use raspistill command""" """Legacy fallback: use raspistill command"""

View File

@@ -6,12 +6,20 @@
# API Key for authentication (generate with: python3 -c 'import secrets; print(secrets.token_urlsafe(32))') # API Key for authentication (generate with: python3 -c 'import secrets; print(secrets.token_urlsafe(32))')
API_KEY=your-secret-key-here API_KEY=your-secret-key-here
# Camera identifier (used in events) # Camera identifier (used in events and API responses)
CAMERA_ID=basement 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 Settings ============
# Camera device index (0 = first USB camera) # Camera device index (0 = /dev/video0, 2 = /dev/video2, etc.)
CAMERA_INDEX=0 CAMERA_INDEX=0
# Resolution (camera will use closest supported) # Resolution (camera will use closest supported)
@@ -40,8 +48,8 @@ MOTION_INTERVAL=0.5
# ============ Event Collector ============ # ============ Event Collector ============
# URL to POST motion events to (on Mac mini) # URL to POST motion events to (collector on Mac mini)
COLLECTOR_URL=http://192.168.1.50:8780/events COLLECTOR_URL=http://macmini.local:8780/events
# API key for collector (optional) # API key for collector (optional)
COLLECTOR_API_KEY= COLLECTOR_API_KEY=

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -1,14 +1,14 @@
#!/bin/bash #!/bin/bash
# vixy-vision Server Setup Script # vixy-vision Server Setup Script
# Run this on a Raspberry Pi or similar edge device # 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 set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INSTALL_DIR="${HOME}/vixy-vision"
SERVICE_NAME="vixy-vision"
# Colors for output # Colors for output
RED='\033[0;31m' 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_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
echo_error() { echo -e "${RED}[ERROR]${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 "=========================================="
echo " vixy-vision Server Setup" echo " vixy-vision Server Setup"
echo " Eyes and ears for the fox 🦊" echo " Eyes for the fox 🦊"
echo "==========================================" echo "=========================================="
echo "" echo ""
@@ -42,21 +31,48 @@ if [[ "$(uname)" != "Linux" ]]; then
echo_error "This script is designed for Linux (Raspberry Pi)" echo_error "This script is designed for Linux (Raspberry Pi)"
exit 1 exit 1
fi 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 # Install system dependencies
echo_info "Installing system dependencies..." echo_info "Installing system dependencies..."
sudo apt-get update sudo apt-get update
sudo apt-get install -y python3-pip python3-venv libopencv-dev sudo apt-get install -y python3-pip python3-venv libopencv-dev python3-opencv
if [ "$WITH_AUDIO" = true ]; then
echo_info "Installing audio dependencies..."
sudo apt-get install -y portaudio19-dev python3-pyaudio alsa-utils
fi
# Create install directory # Create install directory
echo_info "Creating install directory: ${INSTALL_DIR}" echo_info "Creating install directory: ${INSTALL_DIR}"
mkdir -p "${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 # Create virtual environment
echo_info "Creating Python virtual environment..." echo_info "Creating Python virtual environment..."
@@ -69,54 +85,29 @@ echo_info "Installing Python dependencies..."
pip install --upgrade pip pip install --upgrade pip
pip install -r requirements.txt pip install -r requirements.txt
if [ "$WITH_AUDIO" = true ]; then # Generate SSL certificates if not present
pip install pyaudio webrtcvad numpy if [ ! -f ssl/cert.pem ]; then
fi echo_info "Generating SSL certificates..."
chmod +x generate_cert.sh
# Generate SSL certificates ./generate_cert.sh
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 ""
else else
echo_info "Using existing .env file" echo_info "SSL certificates already exist"
fi fi
# Create systemd service # 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 sudo tee /etc/systemd/system/${SERVICE_NAME}.service > /dev/null << EOF
[Unit] [Unit]
Description=vixy-vision Camera Server Description=vixy-vision Camera Server (${CAMERA_ID})
After=network.target After=network.target
[Service] [Service]
Type=simple Type=simple
User=${USER} User=${USER}
WorkingDirectory=${INSTALL_DIR} WorkingDirectory=${INSTALL_DIR}
EnvironmentFile=${INSTALL_DIR}/.env
Environment="PATH=${INSTALL_DIR}/venv/bin" 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 Restart=always
RestartSec=10 RestartSec=10
@@ -133,6 +124,10 @@ echo "=========================================="
echo " Setup Complete! 🦊" echo " Setup Complete! 🦊"
echo "==========================================" echo "=========================================="
echo "" echo ""
echo "Service: ${SERVICE_NAME}"
echo "Port: ${PORT}"
echo "Camera ID: ${CAMERA_ID}"
echo ""
echo "Commands:" echo "Commands:"
echo " Start: sudo systemctl start ${SERVICE_NAME}" echo " Start: sudo systemctl start ${SERVICE_NAME}"
echo " Stop: sudo systemctl stop ${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 " Logs: sudo journalctl -u ${SERVICE_NAME} -f"
echo "" echo ""
echo "Server will be available at:" echo "Server will be available at:"
echo " https://$(hostname -I | awk '{print $1}'):8443/" echo " https://$(hostname).local:${PORT}/"
echo "" echo ""
echo "Add to Vixy's vision config (~/.vision_setup.json):" echo "API Key from .env:"
echo " {" grep "^API_KEY=" "${INSTALL_DIR}/.env" | cut -d'=' -f2
echo " \"cameras\": ["
echo " {"
echo " \"id\": \"$(hostname)\","
echo " \"type\": \"http\","
echo " \"url\": \"https://$(hostname -I | awk '{print $1}'):8443\","
echo " \"api_key\": \"<your-api-key-from-above>\""
echo " }"
echo " ]"
echo " }"
echo "" echo ""
echo_info "Start the server with: sudo systemctl start ${SERVICE_NAME}" echo_info "Start the server with: sudo systemctl start ${SERVICE_NAME}"