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:
@@ -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"""
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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")
|
|
||||||
@@ -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")
|
|
||||||
@@ -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")
|
|
||||||
126
server/setup.sh
126
server/setup.sh
@@ -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}"
|
||||||
|
|||||||
Reference in New Issue
Block a user