🗄️ New collector/ component: - collector.py: FastAPI service receiving events from cameras - SQLite database for event storage - Snapshot images saved to disk by date - launchd setup script for macOS 🔍 New MCP tools in vision_mcp.py: - vision_get_events(): Query events with filters - vision_get_event_snapshot(): View event image inline - vision_annotate_event(): Add meaning + tags to events - vision_event_stats(): Database statistics 📡 Complete flow: Pi detects motion → POST to collector → stored in DB Vixy queries events → views snapshots → annotates Ready to deploy! 🦊
266 lines
7.2 KiB
Python
266 lines
7.2 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
vixy-vision Event Collector
|
|
|
|
Receives motion events from camera servers and stores them
|
|
in SQLite database with snapshots saved to disk.
|
|
|
|
Runs as a service on Mac mini, listens for POSTs from Pis.
|
|
"""
|
|
|
|
import os
|
|
import sqlite3
|
|
import base64
|
|
import logging
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
from contextlib import contextmanager
|
|
|
|
from fastapi import FastAPI, HTTPException, Header
|
|
from pydantic import BaseModel
|
|
|
|
# Configuration
|
|
DATA_DIR = Path(os.getenv("VIXY_DATA_DIR", Path.home() / "Documents" / "Vixy" / "events"))
|
|
DB_PATH = DATA_DIR / "events.db"
|
|
SNAPSHOTS_DIR = DATA_DIR / "snapshots"
|
|
API_KEY = os.getenv("COLLECTOR_API_KEY", "") # Optional auth
|
|
PORT = int(os.getenv("COLLECTOR_PORT", "8780"))
|
|
|
|
# Ensure directories exist
|
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
SNAPSHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Logging
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s - %(levelname)s - %(message)s'
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# FastAPI app
|
|
app = FastAPI(
|
|
title="vixy-vision Event Collector",
|
|
description="Collects motion events for the fox 🦊",
|
|
version="1.0.0"
|
|
)
|
|
|
|
|
|
# === Database ===
|
|
|
|
def init_db():
|
|
"""Initialize SQLite database"""
|
|
with get_db() as conn:
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
event_id TEXT UNIQUE NOT NULL,
|
|
timestamp TEXT NOT NULL,
|
|
camera_id TEXT NOT NULL,
|
|
event_type TEXT NOT NULL,
|
|
confidence REAL,
|
|
area_percent REAL,
|
|
snapshot_path TEXT,
|
|
annotation TEXT,
|
|
tags TEXT,
|
|
created_at TEXT NOT NULL
|
|
)
|
|
""")
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_timestamp ON events(timestamp)")
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_camera ON events(camera_id)")
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_type ON events(event_type)")
|
|
conn.commit()
|
|
logger.info(f"Database initialized: {DB_PATH}")
|
|
|
|
|
|
@contextmanager
|
|
def get_db():
|
|
"""Database connection context manager"""
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.row_factory = sqlite3.Row
|
|
try:
|
|
yield conn
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# === Models ===
|
|
|
|
class EventData(BaseModel):
|
|
timestamp: str
|
|
camera_id: str
|
|
event_type: str = "motion"
|
|
confidence: float = 0.0
|
|
region: str = "full"
|
|
area_percent: float = 0.0
|
|
|
|
|
|
class IncomingEvent(BaseModel):
|
|
event: EventData
|
|
snapshot: Optional[str] = None # Base64 encoded JPEG
|
|
|
|
|
|
# === Endpoints ===
|
|
|
|
@app.on_event("startup")
|
|
def startup():
|
|
init_db()
|
|
logger.info(f"🦊 Event collector started on port {PORT}")
|
|
logger.info(f" Data directory: {DATA_DIR}")
|
|
|
|
|
|
@app.get("/")
|
|
def root():
|
|
return {
|
|
"service": "vixy-vision Event Collector",
|
|
"version": "1.0.0",
|
|
"data_dir": str(DATA_DIR),
|
|
}
|
|
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.post("/events")
|
|
def receive_event(
|
|
incoming: IncomingEvent,
|
|
x_api_key: Optional[str] = Header(None)
|
|
):
|
|
"""Receive motion event from camera server"""
|
|
|
|
# Check API key if configured
|
|
if API_KEY and x_api_key != API_KEY:
|
|
raise HTTPException(status_code=403, detail="Invalid API key")
|
|
|
|
event = incoming.event
|
|
now = datetime.utcnow()
|
|
|
|
# Generate unique event ID
|
|
event_id = f"{event.camera_id}-{now.strftime('%Y%m%d%H%M%S%f')}"
|
|
|
|
# Save snapshot if provided
|
|
snapshot_path = None
|
|
if incoming.snapshot:
|
|
try:
|
|
# Decode base64
|
|
image_data = base64.b64decode(incoming.snapshot)
|
|
|
|
# Create date-based subdirectory
|
|
date_dir = SNAPSHOTS_DIR / now.strftime("%Y-%m-%d")
|
|
date_dir.mkdir(exist_ok=True)
|
|
|
|
# Save image
|
|
filename = f"{event_id}.jpg"
|
|
snapshot_path = date_dir / filename
|
|
snapshot_path.write_bytes(image_data)
|
|
|
|
# Store relative path
|
|
snapshot_path = str(snapshot_path.relative_to(DATA_DIR))
|
|
|
|
logger.info(f"Saved snapshot: {snapshot_path}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to save snapshot: {e}")
|
|
|
|
# Store in database
|
|
try:
|
|
with get_db() as conn:
|
|
conn.execute("""
|
|
INSERT INTO events
|
|
(event_id, timestamp, camera_id, event_type, confidence,
|
|
area_percent, snapshot_path, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
""", (
|
|
event_id,
|
|
event.timestamp,
|
|
event.camera_id,
|
|
event.event_type,
|
|
event.confidence,
|
|
event.area_percent,
|
|
snapshot_path,
|
|
now.isoformat() + "Z"
|
|
))
|
|
conn.commit()
|
|
|
|
logger.info(f"✓ Event stored: {event_id} ({event.camera_id}, {event.event_type})")
|
|
|
|
return {
|
|
"status": "ok",
|
|
"event_id": event_id,
|
|
"snapshot_saved": snapshot_path is not None
|
|
}
|
|
|
|
except sqlite3.IntegrityError:
|
|
raise HTTPException(status_code=409, detail="Duplicate event")
|
|
except Exception as e:
|
|
logger.error(f"Database error: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/events")
|
|
def list_events(
|
|
since: Optional[str] = None,
|
|
camera_id: Optional[str] = None,
|
|
event_type: Optional[str] = None,
|
|
limit: int = 50,
|
|
x_api_key: Optional[str] = Header(None)
|
|
):
|
|
"""List recent events (for debugging, MCP uses direct DB access)"""
|
|
|
|
if API_KEY and x_api_key != API_KEY:
|
|
raise HTTPException(status_code=403, detail="Invalid API key")
|
|
|
|
query = "SELECT * FROM events WHERE 1=1"
|
|
params = []
|
|
|
|
if since:
|
|
query += " AND timestamp >= ?"
|
|
params.append(since)
|
|
|
|
if camera_id:
|
|
query += " AND camera_id = ?"
|
|
params.append(camera_id)
|
|
|
|
if event_type:
|
|
query += " AND event_type = ?"
|
|
params.append(event_type)
|
|
|
|
query += " ORDER BY timestamp DESC LIMIT ?"
|
|
params.append(limit)
|
|
|
|
with get_db() as conn:
|
|
rows = conn.execute(query, params).fetchall()
|
|
return {
|
|
"count": len(rows),
|
|
"events": [dict(row) for row in rows]
|
|
}
|
|
|
|
|
|
@app.get("/stats")
|
|
def get_stats():
|
|
"""Get collector statistics"""
|
|
with get_db() as conn:
|
|
total = conn.execute("SELECT COUNT(*) FROM events").fetchone()[0]
|
|
by_camera = conn.execute("""
|
|
SELECT camera_id, COUNT(*) as count
|
|
FROM events GROUP BY camera_id
|
|
""").fetchall()
|
|
by_type = conn.execute("""
|
|
SELECT event_type, COUNT(*) as count
|
|
FROM events GROUP BY event_type
|
|
""").fetchall()
|
|
|
|
return {
|
|
"total_events": total,
|
|
"by_camera": {row[0]: row[1] for row in by_camera},
|
|
"by_type": {row[0]: row[1] for row in by_type},
|
|
"data_dir": str(DATA_DIR),
|
|
"db_path": str(DB_PATH),
|
|
}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
uvicorn.run(app, host="0.0.0.0", port=PORT)
|