Initial commit: vixy-vision distributed sensing system
🦊 Eyes and ears for the fox Components: - server/: Camera server for Raspberry Pi (from camera-server) - mcp/: Vision MCP client for Claude Desktop (from vision-mcp) - analysis/: Placeholder for motion/audio detection - shared/: Common schemas and interfaces Features: - Setup script with systemd service creation - HTTPS + API key authentication - HTTP and RTSP camera support Built under a blanket on Day 45 💕
This commit is contained in:
52
server/.gitignore
vendored
Normal file
52
server/.gitignore
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
# Environment variables (contains API key!)
|
||||
.env
|
||||
|
||||
# SSL certificates
|
||||
ssl/
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Test snapshots
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.png
|
||||
48
server/generate_cert.sh
Normal file
48
server/generate_cert.sh
Normal file
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Generate self-signed SSL certificate for local HTTPS
|
||||
#
|
||||
# This creates a certificate valid for 365 days. While browsers will show
|
||||
# a warning (since it's self-signed), the connection will still be encrypted.
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
CERT_DIR="ssl"
|
||||
CERT_FILE="$CERT_DIR/cert.pem"
|
||||
KEY_FILE="$CERT_DIR/key.pem"
|
||||
|
||||
echo "=== Camera Server SSL Certificate Generator ==="
|
||||
echo
|
||||
|
||||
# Create ssl directory if it doesn't exist
|
||||
mkdir -p "$CERT_DIR"
|
||||
|
||||
# Generate self-signed certificate
|
||||
echo "Generating self-signed certificate..."
|
||||
openssl req -x509 -newkey rsa:4096 \
|
||||
-keyout "$KEY_FILE" \
|
||||
-out "$CERT_FILE" \
|
||||
-days 365 \
|
||||
-nodes \
|
||||
-subj "/C=US/ST=State/L=City/O=CameraServer/CN=camera.local"
|
||||
|
||||
# Set proper permissions
|
||||
chmod 600 "$KEY_FILE"
|
||||
chmod 644 "$CERT_FILE"
|
||||
|
||||
echo
|
||||
echo "✓ Certificate generated successfully!"
|
||||
echo
|
||||
echo "Files created:"
|
||||
echo " - Certificate: $CERT_FILE"
|
||||
echo " - Private key: $KEY_FILE"
|
||||
echo
|
||||
echo "Note: Browsers will show a security warning because this is self-signed."
|
||||
echo "This is normal for local development. The connection is still encrypted."
|
||||
echo
|
||||
echo "To trust this certificate:"
|
||||
echo " - On macOS: Open Keychain Access, import cert.pem, mark as trusted"
|
||||
echo " - On Linux: Copy to /usr/local/share/ca-certificates/ and run update-ca-certificates"
|
||||
echo " - On Windows: Import cert.pem into Trusted Root Certification Authorities"
|
||||
echo
|
||||
220
server/main.py
Normal file
220
server/main.py
Normal file
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Camera Snapshot Server
|
||||
|
||||
Simple FastAPI server that serves snapshots from a USB camera.
|
||||
Features:
|
||||
- API key authentication
|
||||
- HTTPS support
|
||||
- Thread-safe camera access
|
||||
- Auto-reconnect on camera failure
|
||||
"""
|
||||
|
||||
import os
|
||||
import cv2
|
||||
import threading
|
||||
import secrets
|
||||
from typing import Optional
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI, Security, HTTPException, Response
|
||||
from fastapi.security import APIKeyHeader
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Configuration
|
||||
API_KEY = os.getenv("API_KEY")
|
||||
CAMERA_INDEX = int(os.getenv("CAMERA_INDEX", "0"))
|
||||
CAMERA_WIDTH = int(os.getenv("CAMERA_WIDTH", "1920"))
|
||||
CAMERA_HEIGHT = int(os.getenv("CAMERA_HEIGHT", "1080"))
|
||||
JPEG_QUALITY = int(os.getenv("JPEG_QUALITY", "85"))
|
||||
|
||||
if not API_KEY:
|
||||
raise ValueError("API_KEY not set in .env file. Generate one with: python3 -c 'import secrets; print(secrets.token_urlsafe(32))'")
|
||||
|
||||
# FastAPI app
|
||||
app = FastAPI(
|
||||
title="Camera Snapshot Server",
|
||||
description="Serves snapshots from USB camera with API key authentication",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# API Key authentication
|
||||
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
|
||||
|
||||
class CameraManager:
|
||||
"""Thread-safe camera manager with auto-reconnect"""
|
||||
|
||||
def __init__(self, camera_index: int = 0, width: int = 1920, height: int = 1080):
|
||||
self.camera_index = camera_index
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.camera: Optional[cv2.VideoCapture] = None
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def _open_camera(self) -> bool:
|
||||
"""Open camera connection"""
|
||||
try:
|
||||
self.camera = cv2.VideoCapture(self.camera_index)
|
||||
if not self.camera.isOpened():
|
||||
return False
|
||||
|
||||
# Set camera resolution
|
||||
self.camera.set(cv2.CAP_PROP_FRAME_WIDTH, self.width)
|
||||
self.camera.set(cv2.CAP_PROP_FRAME_HEIGHT, self.height)
|
||||
|
||||
# Set camera properties for better performance
|
||||
self.camera.set(cv2.CAP_PROP_BUFFERSIZE, 1) # Reduce buffer to get latest frame
|
||||
|
||||
# Log actual resolution (camera may not support requested resolution)
|
||||
actual_width = int(self.camera.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
actual_height = int(self.camera.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
print(f"Camera resolution: {actual_width}x{actual_height} (requested: {self.width}x{self.height})")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error opening camera: {e}")
|
||||
return False
|
||||
|
||||
def get_snapshot(self) -> Optional[bytes]:
|
||||
"""
|
||||
Capture a snapshot from the camera.
|
||||
|
||||
Returns:
|
||||
JPEG-encoded image bytes, or None if failed
|
||||
"""
|
||||
with self.lock:
|
||||
# Open camera if not initialized or closed
|
||||
if self.camera is None or not self.camera.isOpened():
|
||||
if not self._open_camera():
|
||||
return None
|
||||
|
||||
# Flush buffer to get latest frame
|
||||
# Read and discard several frames to clear old buffered frames
|
||||
for _ in range(5):
|
||||
self.camera.grab()
|
||||
|
||||
# Capture the latest frame
|
||||
ret, frame = self.camera.read()
|
||||
|
||||
# Retry on failure
|
||||
if not ret:
|
||||
print("Failed to capture frame, attempting reconnect...")
|
||||
self.release()
|
||||
if not self._open_camera():
|
||||
return None
|
||||
# Flush buffer again after reconnect
|
||||
for _ in range(5):
|
||||
self.camera.grab()
|
||||
ret, frame = self.camera.read()
|
||||
|
||||
if not ret:
|
||||
return None
|
||||
|
||||
# Encode as JPEG
|
||||
try:
|
||||
ret, buffer = cv2.imencode(
|
||||
'.jpg',
|
||||
frame,
|
||||
[cv2.IMWRITE_JPEG_QUALITY, JPEG_QUALITY]
|
||||
)
|
||||
if not ret:
|
||||
return None
|
||||
|
||||
return buffer.tobytes()
|
||||
except Exception as e:
|
||||
print(f"Error encoding image: {e}")
|
||||
return None
|
||||
|
||||
def release(self):
|
||||
"""Release camera resources"""
|
||||
if self.camera is not None:
|
||||
self.camera.release()
|
||||
self.camera = None
|
||||
|
||||
def __del__(self):
|
||||
"""Cleanup on deletion"""
|
||||
self.release()
|
||||
|
||||
|
||||
# Global camera manager
|
||||
camera_manager = CameraManager(CAMERA_INDEX, CAMERA_WIDTH, CAMERA_HEIGHT)
|
||||
|
||||
|
||||
def verify_api_key(api_key: str = Security(api_key_header)) -> str:
|
||||
"""Verify API key from header"""
|
||||
if api_key is None or api_key != API_KEY:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Invalid or missing API key"
|
||||
)
|
||||
return api_key
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
"""Root endpoint with API info"""
|
||||
return {
|
||||
"service": "Camera Snapshot Server",
|
||||
"version": "1.0.0",
|
||||
"endpoints": {
|
||||
"/snapshot": "GET - Returns JPEG snapshot (requires X-API-Key header)",
|
||||
"/health": "GET - Health check (no auth required)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
"""Health check endpoint"""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/snapshot")
|
||||
def get_snapshot(api_key: str = Security(verify_api_key)):
|
||||
"""
|
||||
Get a snapshot from the USB camera.
|
||||
|
||||
Requires X-API-Key header for authentication.
|
||||
|
||||
Returns:
|
||||
JPEG image
|
||||
"""
|
||||
snapshot = camera_manager.get_snapshot()
|
||||
|
||||
if snapshot is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Failed to capture snapshot. Check camera connection."
|
||||
)
|
||||
|
||||
return Response(
|
||||
content=snapshot,
|
||||
media_type="image/jpeg",
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
def shutdown_event():
|
||||
"""Cleanup on shutdown"""
|
||||
camera_manager.release()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
# For development only - use uvicorn command for production
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host="0.0.0.0",
|
||||
port=8443,
|
||||
ssl_keyfile="ssl/key.pem",
|
||||
ssl_certfile="ssl/cert.pem"
|
||||
)
|
||||
11
server/requirements.txt
Normal file
11
server/requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
# Camera Snapshot Server Dependencies
|
||||
|
||||
# Web framework
|
||||
fastapi>=0.104.0
|
||||
uvicorn[standard]>=0.24.0
|
||||
|
||||
# Camera access
|
||||
opencv-python-headless>=4.8.0
|
||||
|
||||
# Configuration
|
||||
python-dotenv>=1.0.0
|
||||
157
server/setup.sh
Normal file
157
server/setup.sh
Normal file
@@ -0,0 +1,157 @@
|
||||
#!/bin/bash
|
||||
# vixy-vision Server Setup Script
|
||||
# Run this on a Raspberry Pi or similar edge device
|
||||
#
|
||||
# Usage: ./setup.sh [--with-audio]
|
||||
|
||||
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'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
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 "=========================================="
|
||||
echo ""
|
||||
|
||||
# Check if running on Linux
|
||||
if [[ "$(uname)" != "Linux" ]]; then
|
||||
echo_error "This script is designed for Linux (Raspberry Pi)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 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
|
||||
|
||||
# Create install directory
|
||||
echo_info "Creating install directory: ${INSTALL_DIR}"
|
||||
mkdir -p "${INSTALL_DIR}"
|
||||
cp -r "${SCRIPT_DIR}"/* "${INSTALL_DIR}/"
|
||||
|
||||
# Create virtual environment
|
||||
echo_info "Creating Python virtual environment..."
|
||||
cd "${INSTALL_DIR}"
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# Install Python dependencies
|
||||
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 ""
|
||||
else
|
||||
echo_info "Using existing .env file"
|
||||
fi
|
||||
|
||||
# Create systemd service
|
||||
echo_info "Creating systemd service..."
|
||||
sudo tee /etc/systemd/system/${SERVICE_NAME}.service > /dev/null << EOF
|
||||
[Unit]
|
||||
Description=vixy-vision Camera Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${USER}
|
||||
WorkingDirectory=${INSTALL_DIR}
|
||||
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
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# Reload systemd and enable service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable ${SERVICE_NAME}
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " Setup Complete! 🦊"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " Start: sudo systemctl start ${SERVICE_NAME}"
|
||||
echo " Stop: sudo systemctl stop ${SERVICE_NAME}"
|
||||
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 ""
|
||||
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\": \"<your-api-key-from-above>\""
|
||||
echo " }"
|
||||
echo " ]"
|
||||
echo " }"
|
||||
echo ""
|
||||
echo_info "Start the server with: sudo systemctl start ${SERVICE_NAME}"
|
||||
Reference in New Issue
Block a user