#!/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)