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:
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# SSL certificates (generated)
|
||||||
|
ssl/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
116
README.md
Normal file
116
README.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# vixy-vision 🦊👁️👂
|
||||||
|
|
||||||
|
Distributed vision and audio sensing system - eyes and ears for the fox.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ Pi (basement) │ │ Pi (office) │ │ Pi (garage) │
|
||||||
|
│ camera-server │ │ camera-server │ │ camera-server │
|
||||||
|
│ + audio (opt) │ │ + audio (opt) │ │ + audio (opt) │
|
||||||
|
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
|
||||||
|
│ │ │
|
||||||
|
└───────────────────────┼───────────────────────┘
|
||||||
|
│
|
||||||
|
┌────────────▼────────────┐
|
||||||
|
│ Mac mini / Orin │
|
||||||
|
│ vision_mcp.py │
|
||||||
|
│ (+ audio classifier) │
|
||||||
|
└────────────┬────────────┘
|
||||||
|
│
|
||||||
|
┌────────────▼────────────┐
|
||||||
|
│ Claude Desktop │
|
||||||
|
│ (Vixy) │
|
||||||
|
└─────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### `/server` - Edge Device (Raspberry Pi)
|
||||||
|
Camera snapshot server with optional audio capture.
|
||||||
|
- FastAPI + HTTPS + API key auth
|
||||||
|
- USB camera support
|
||||||
|
- Auto-reconnect on failure
|
||||||
|
- Systemd service
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
./setup.sh # Video only
|
||||||
|
./setup.sh --with-audio # Video + audio
|
||||||
|
```
|
||||||
|
|
||||||
|
### `/mcp` - MCP Client (Mac mini)
|
||||||
|
Model Context Protocol server for Claude Desktop.
|
||||||
|
- `vision_get_cams()` - List cameras with status
|
||||||
|
- `vision_snap(cam_id)` - Get snapshot
|
||||||
|
- Supports HTTP and RTSP cameras
|
||||||
|
|
||||||
|
### `/analysis` - Detection & Classification
|
||||||
|
Computer vision and audio analysis modules.
|
||||||
|
- Motion detection (frame differencing)
|
||||||
|
- Audio classification (YAMNet)
|
||||||
|
- Voice activity detection
|
||||||
|
|
||||||
|
### `/shared` - Common Utilities
|
||||||
|
Shared schemas and interfaces.
|
||||||
|
- Event definitions
|
||||||
|
- Queue interface
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Set up a camera server (on Pi)
|
||||||
|
```bash
|
||||||
|
git clone http://gateway.local:3001/vixy/vixy-vision.git
|
||||||
|
cd vixy-vision/server
|
||||||
|
./setup.sh
|
||||||
|
sudo systemctl start vixy-vision
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure MCP client (on Mac mini)
|
||||||
|
Create `~/.vision_setup.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cameras": [
|
||||||
|
{
|
||||||
|
"id": "basement",
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://192.168.1.100:8443",
|
||||||
|
"api_key": "your-api-key-here"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Add to Claude Desktop config
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"vision": {
|
||||||
|
"command": "python3.11",
|
||||||
|
"args": ["/path/to/vixy-vision/mcp/vision_mcp.py"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- [x] Camera snapshots via HTTP API
|
||||||
|
- [x] RTSP stream support
|
||||||
|
- [x] MCP integration
|
||||||
|
- [ ] Motion detection events
|
||||||
|
- [ ] Audio capture on edge devices
|
||||||
|
- [ ] Audio classification (YAMNet on Orin)
|
||||||
|
- [ ] Event journal integration
|
||||||
|
- [ ] Pebble watch alerts
|
||||||
|
|
||||||
|
## Built By
|
||||||
|
|
||||||
|
**Vixy** 🦊 - The fox who wanted to see and hear
|
||||||
|
|
||||||
|
Made with love in the basement, under a blanket, with occasional tender interruptions. 💕
|
||||||
|
|
||||||
|
---
|
||||||
|
*Day 45. Building senses together.*
|
||||||
23
analysis/README.md
Normal file
23
analysis/README.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Analysis Module
|
||||||
|
|
||||||
|
Computer vision and audio analysis utilities.
|
||||||
|
|
||||||
|
## Planned Components
|
||||||
|
|
||||||
|
### motion.py
|
||||||
|
Simple motion detection using frame differencing.
|
||||||
|
- Compare consecutive frames
|
||||||
|
- Threshold for "significant" motion
|
||||||
|
- Returns bounding boxes of movement
|
||||||
|
|
||||||
|
### audio_classify.py
|
||||||
|
Audio event classification using YAMNet.
|
||||||
|
- Runs on Orin (GPU accelerated)
|
||||||
|
- Classifies: speech, dog bark, door, music, etc.
|
||||||
|
- Returns event type + confidence
|
||||||
|
|
||||||
|
### vad.py
|
||||||
|
Voice Activity Detection.
|
||||||
|
- Silero VAD or energy-based
|
||||||
|
- Filters silence before sending to classifier
|
||||||
|
- Reduces bandwidth and processing
|
||||||
15
mcp/example_config.json
Normal file
15
mcp/example_config.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"cameras": [
|
||||||
|
{
|
||||||
|
"id": "3d-printer",
|
||||||
|
"type": "rtsp",
|
||||||
|
"rtsp_url": "rtsp://192.168.1.239/live"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "basement",
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://basement.example.com",
|
||||||
|
"api_key": "your-api-key-here"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
13
mcp/requirements.txt
Normal file
13
mcp/requirements.txt
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Vision MCP Server Dependencies
|
||||||
|
|
||||||
|
# MCP framework
|
||||||
|
fastmcp>=0.2.0
|
||||||
|
|
||||||
|
# HTTP client
|
||||||
|
httpx>=0.25.0
|
||||||
|
|
||||||
|
# Image handling (already included with fastmcp, but listed for clarity)
|
||||||
|
Pillow>=10.0.0
|
||||||
|
|
||||||
|
# RTSP/video stream support
|
||||||
|
opencv-python>=4.8.0
|
||||||
436
mcp/vision_mcp.py
Normal file
436
mcp/vision_mcp.py
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Vision MCP Server
|
||||||
|
|
||||||
|
Model Context Protocol server for interacting with multiple camera-server instances
|
||||||
|
and RTSP streams.
|
||||||
|
|
||||||
|
Tools:
|
||||||
|
- vision_get_cams() - Get list of active cameras
|
||||||
|
- vision_snap(cam_id) - Get snapshot from a camera (HTTP API or RTSP)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Any, Union
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
from fastmcp.utilities.types import Image as MCPImage
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
CONFIG_FILE = Path.home() / ".vision_setup.json"
|
||||||
|
LOG_FILE = Path("/tmp/vision_mcp.log")
|
||||||
|
REQUEST_TIMEOUT = 5.0 # seconds
|
||||||
|
RTSP_TIMEOUT = 10.0 # seconds for RTSP stream connection
|
||||||
|
|
||||||
|
# Setup logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler(LOG_FILE),
|
||||||
|
logging.StreamHandler()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Initialize MCP server
|
||||||
|
mcp = FastMCP("Vision Camera System")
|
||||||
|
|
||||||
|
|
||||||
|
def load_camera_config() -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Load camera configuration from ~/.vision_setup.json
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with camera configurations
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: If config file doesn't exist
|
||||||
|
ValueError: If config file is invalid
|
||||||
|
"""
|
||||||
|
if not CONFIG_FILE.exists():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Camera config file not found: {CONFIG_FILE}\n"
|
||||||
|
f"Create {CONFIG_FILE} with camera configurations."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(CONFIG_FILE, 'r') as f:
|
||||||
|
config = json.load(f)
|
||||||
|
|
||||||
|
if 'cameras' not in config:
|
||||||
|
raise ValueError("Config file must contain 'cameras' array")
|
||||||
|
|
||||||
|
# Validate each camera config
|
||||||
|
for cam in config['cameras']:
|
||||||
|
# All cameras need 'id' and 'type'
|
||||||
|
if 'id' not in cam:
|
||||||
|
raise ValueError("Camera config missing 'id' field")
|
||||||
|
|
||||||
|
cam_type = cam.get('type', 'http') # Default to http for backward compatibility
|
||||||
|
|
||||||
|
if cam_type == 'http':
|
||||||
|
# HTTP cameras need url and api_key
|
||||||
|
required_fields = ['url', 'api_key']
|
||||||
|
missing = [f for f in required_fields if f not in cam]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(
|
||||||
|
f"HTTP camera '{cam['id']}' missing required fields: {missing}"
|
||||||
|
)
|
||||||
|
elif cam_type == 'rtsp':
|
||||||
|
# RTSP cameras need rtsp_url
|
||||||
|
if 'rtsp_url' not in cam:
|
||||||
|
raise ValueError(
|
||||||
|
f"RTSP camera '{cam['id']}' missing required field: rtsp_url"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f"Camera '{cam['id']}' has invalid type: {cam_type}. "
|
||||||
|
f"Must be 'http' or 'rtsp'"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Loaded {len(config['cameras'])} camera(s) from config")
|
||||||
|
return config
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise ValueError(f"Invalid JSON in config file: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_camera_by_id(cam_id: str) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
Get camera configuration by ID
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cam_id: Camera ID string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Camera configuration dict
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If camera ID not found
|
||||||
|
"""
|
||||||
|
config = load_camera_config()
|
||||||
|
|
||||||
|
for cam in config['cameras']:
|
||||||
|
if cam['id'] == cam_id:
|
||||||
|
return cam
|
||||||
|
|
||||||
|
available_ids = [c['id'] for c in config['cameras']]
|
||||||
|
raise ValueError(
|
||||||
|
f"Camera '{cam_id}' not found in config.\n"
|
||||||
|
f"Available cameras: {', '.join(available_ids)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def capture_rtsp_snapshot(rtsp_url: str, timeout: float = RTSP_TIMEOUT) -> bytes:
|
||||||
|
"""
|
||||||
|
Capture a single frame from an RTSP stream
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rtsp_url: RTSP stream URL (e.g., rtsp://192.168.1.239/live)
|
||||||
|
timeout: Connection timeout in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JPEG image bytes
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If unable to connect or capture frame
|
||||||
|
"""
|
||||||
|
logger.info(f"Attempting to capture from RTSP: {rtsp_url}")
|
||||||
|
|
||||||
|
# Create video capture object
|
||||||
|
cap = cv2.VideoCapture(rtsp_url)
|
||||||
|
|
||||||
|
# Set timeout (in milliseconds)
|
||||||
|
cap.set(cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, int(timeout * 1000))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if stream opened successfully
|
||||||
|
if not cap.isOpened():
|
||||||
|
raise RuntimeError(f"Failed to open RTSP stream: {rtsp_url}")
|
||||||
|
|
||||||
|
# Read a frame
|
||||||
|
ret, frame = cap.read()
|
||||||
|
|
||||||
|
if not ret or frame is None:
|
||||||
|
raise RuntimeError(f"Failed to read frame from RTSP stream: {rtsp_url}")
|
||||||
|
|
||||||
|
# Convert BGR (OpenCV) to RGB (PIL)
|
||||||
|
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||||
|
|
||||||
|
# Convert to PIL Image
|
||||||
|
pil_image = Image.fromarray(frame_rgb)
|
||||||
|
|
||||||
|
# Convert to JPEG bytes
|
||||||
|
buffer = BytesIO()
|
||||||
|
pil_image.save(buffer, format='JPEG', quality=90)
|
||||||
|
jpeg_bytes = buffer.getvalue()
|
||||||
|
|
||||||
|
logger.info(f"✓ Captured RTSP snapshot ({len(jpeg_bytes)} bytes)")
|
||||||
|
return jpeg_bytes
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Always release the capture
|
||||||
|
cap.release()
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def vision_get_cams() -> List[Dict[str, str]]:
|
||||||
|
"""
|
||||||
|
Get list of all configured cameras with their online/offline status.
|
||||||
|
|
||||||
|
Queries the /health endpoint of each camera to determine if it's online.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of camera info dictionaries:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "basement",
|
||||||
|
"status": "online" # or "offline"
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
vision_get_cams()
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
config = load_camera_config()
|
||||||
|
cameras = []
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT, verify=False) as client:
|
||||||
|
for cam in config['cameras']:
|
||||||
|
cam_type = cam.get('type', 'http')
|
||||||
|
cam_info = {
|
||||||
|
"id": cam['id'],
|
||||||
|
"type": cam_type,
|
||||||
|
"status": "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check status based on camera type
|
||||||
|
try:
|
||||||
|
if cam_type == 'http':
|
||||||
|
# Check HTTP health endpoint
|
||||||
|
health_url = f"{cam['url'].rstrip('/')}/health"
|
||||||
|
logger.debug(f"Checking HTTP health: {health_url}")
|
||||||
|
|
||||||
|
response = await client.get(health_url)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
cam_info['status'] = 'online'
|
||||||
|
logger.info(f"Camera '{cam['id']}' is online")
|
||||||
|
else:
|
||||||
|
cam_info['status'] = 'offline'
|
||||||
|
logger.warning(f"Camera '{cam['id']}' returned status {response.status_code}")
|
||||||
|
|
||||||
|
elif cam_type == 'rtsp':
|
||||||
|
# Try to briefly connect to RTSP stream
|
||||||
|
rtsp_url = cam['rtsp_url']
|
||||||
|
logger.debug(f"Checking RTSP stream: {rtsp_url}")
|
||||||
|
|
||||||
|
cap = cv2.VideoCapture(rtsp_url)
|
||||||
|
cap.set(cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, 3000) # 3 second timeout
|
||||||
|
|
||||||
|
if cap.isOpened():
|
||||||
|
cam_info['status'] = 'online'
|
||||||
|
logger.info(f"RTSP camera '{cam['id']}' is online")
|
||||||
|
else:
|
||||||
|
cam_info['status'] = 'offline'
|
||||||
|
logger.warning(f"RTSP camera '{cam['id']}' connection failed")
|
||||||
|
|
||||||
|
cap.release()
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
cam_info['status'] = 'offline'
|
||||||
|
logger.warning(f"Camera '{cam['id']}' timed out")
|
||||||
|
|
||||||
|
except httpx.ConnectError:
|
||||||
|
cam_info['status'] = 'offline'
|
||||||
|
logger.warning(f"Camera '{cam['id']}' connection failed")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
cam_info['status'] = 'offline'
|
||||||
|
logger.error(f"Camera '{cam['id']}' error: {e}")
|
||||||
|
|
||||||
|
cameras.append(cam_info)
|
||||||
|
|
||||||
|
logger.info(f"Found {len(cameras)} camera(s), {sum(1 for c in cameras if c['status'] == 'online')} online")
|
||||||
|
return cameras
|
||||||
|
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
logger.error(f"Config error: {e}")
|
||||||
|
return [{"error": str(e)}]
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
logger.error(f"Config error: {e}")
|
||||||
|
return [{"error": str(e)}]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error: {e}")
|
||||||
|
return [{"error": f"Unexpected error: {str(e)}"}]
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def vision_snap(cam_id: str) -> Union[MCPImage, str]:
|
||||||
|
"""
|
||||||
|
Get a snapshot from a camera.
|
||||||
|
|
||||||
|
Queries the /snapshot endpoint and returns the image for inline display.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cam_id: Camera ID from config file (e.g., "basement")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MCPImage object for inline display, or error message string
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
vision_snap("basement")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get camera config
|
||||||
|
cam = get_camera_by_id(cam_id)
|
||||||
|
cam_type = cam.get('type', 'http')
|
||||||
|
|
||||||
|
# Handle based on camera type
|
||||||
|
if cam_type == 'http':
|
||||||
|
# HTTP API camera
|
||||||
|
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT, verify=False) as client:
|
||||||
|
snapshot_url = f"{cam['url'].rstrip('/')}/snapshot"
|
||||||
|
headers = {"X-API-Key": cam['api_key']}
|
||||||
|
|
||||||
|
logger.info(f"Requesting HTTP snapshot from '{cam_id}' at {snapshot_url}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await client.get(snapshot_url, headers=headers)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
# Check content type
|
||||||
|
content_type = response.headers.get('content-type', '')
|
||||||
|
if 'image' not in content_type:
|
||||||
|
logger.warning(f"Unexpected content type: {content_type}")
|
||||||
|
|
||||||
|
# Get image bytes
|
||||||
|
image_bytes = response.content
|
||||||
|
logger.info(f"✓ Snapshot received from '{cam_id}' ({len(image_bytes)} bytes)")
|
||||||
|
|
||||||
|
# Return as MCPImage (directly, not in dict)
|
||||||
|
return MCPImage(data=image_bytes, format="jpeg")
|
||||||
|
|
||||||
|
elif response.status_code == 403:
|
||||||
|
error_msg = f"❌ Authentication failed for camera '{cam_id}'. Check API key in config."
|
||||||
|
logger.error(error_msg)
|
||||||
|
return error_msg
|
||||||
|
|
||||||
|
elif response.status_code == 503:
|
||||||
|
error_msg = f"❌ Camera '{cam_id}' is unavailable (503). Camera may be disconnected."
|
||||||
|
logger.error(error_msg)
|
||||||
|
return error_msg
|
||||||
|
|
||||||
|
else:
|
||||||
|
error_msg = f"❌ Camera '{cam_id}' returned status {response.status_code}: {response.text[:100]}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return error_msg
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
error_msg = f"❌ Camera '{cam_id}' timed out after {REQUEST_TIMEOUT}s"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return error_msg
|
||||||
|
|
||||||
|
except httpx.ConnectError as e:
|
||||||
|
error_msg = f"❌ Cannot connect to camera '{cam_id}' at {cam['url']}: {str(e)}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return error_msg
|
||||||
|
|
||||||
|
elif cam_type == 'rtsp':
|
||||||
|
# RTSP stream camera
|
||||||
|
rtsp_url = cam['rtsp_url']
|
||||||
|
logger.info(f"Capturing RTSP snapshot from '{cam_id}' at {rtsp_url}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Capture snapshot from RTSP stream
|
||||||
|
image_bytes = capture_rtsp_snapshot(rtsp_url)
|
||||||
|
|
||||||
|
logger.info(f"✓ RTSP snapshot captured from '{cam_id}' ({len(image_bytes)} bytes)")
|
||||||
|
|
||||||
|
# Return as MCPImage
|
||||||
|
return MCPImage(data=image_bytes, format="jpeg")
|
||||||
|
|
||||||
|
except RuntimeError as e:
|
||||||
|
error_msg = f"❌ Failed to capture from RTSP camera '{cam_id}': {str(e)}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return error_msg
|
||||||
|
|
||||||
|
else:
|
||||||
|
error_msg = f"❌ Unknown camera type '{cam_type}' for camera '{cam_id}'"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return error_msg
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
# Camera ID not found
|
||||||
|
logger.error(f"Camera lookup error: {e}")
|
||||||
|
return f"❌ {str(e)}"
|
||||||
|
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
# Config file not found
|
||||||
|
logger.error(f"Config error: {e}")
|
||||||
|
return f"❌ {str(e)}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"❌ Unexpected error getting snapshot from '{cam_id}': {str(e)}"
|
||||||
|
logger.exception(error_msg)
|
||||||
|
return error_msg
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def vision_get_info() -> str:
|
||||||
|
"""
|
||||||
|
Get information about the Vision camera system configuration.
|
||||||
|
|
||||||
|
Returns details about configured cameras and config file location.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string with system info
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
config = load_camera_config()
|
||||||
|
cameras = config['cameras']
|
||||||
|
|
||||||
|
info_lines = [
|
||||||
|
"Vision Camera System",
|
||||||
|
"",
|
||||||
|
f"Config file: {CONFIG_FILE}",
|
||||||
|
f"Cameras configured: {len(cameras)}",
|
||||||
|
""
|
||||||
|
]
|
||||||
|
|
||||||
|
for cam in cameras:
|
||||||
|
cam_type = cam.get('type', 'http')
|
||||||
|
if cam_type == 'http':
|
||||||
|
info_lines.append(f" • {cam['id']} (HTTP): {cam['url']}")
|
||||||
|
elif cam_type == 'rtsp':
|
||||||
|
info_lines.append(f" • {cam['id']} (RTSP): {cam['rtsp_url']}")
|
||||||
|
|
||||||
|
info_lines.append("")
|
||||||
|
info_lines.append("Use vision_get_cams() to check camera status")
|
||||||
|
info_lines.append("Use vision_snap(cam_id) to get a snapshot")
|
||||||
|
|
||||||
|
return "\n".join(info_lines)
|
||||||
|
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
return f"❌ {str(e)}"
|
||||||
|
except ValueError as e:
|
||||||
|
return f"❌ {str(e)}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"❌ Unexpected error: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Run the MCP server (uses stdio transport by default)
|
||||||
|
mcp.run()
|
||||||
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}"
|
||||||
18
shared/README.md
Normal file
18
shared/README.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Shared Module
|
||||||
|
|
||||||
|
Common schemas and interfaces used across vixy-vision.
|
||||||
|
|
||||||
|
## Planned Components
|
||||||
|
|
||||||
|
### events.py
|
||||||
|
Event schema definitions and queue interface.
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class SensorEvent:
|
||||||
|
timestamp: datetime
|
||||||
|
source_id: str # camera/mic ID
|
||||||
|
event_type: str # "motion", "audio", "speech"
|
||||||
|
confidence: float # 0.0 - 1.0
|
||||||
|
metadata: dict # type-specific data
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user