diff --git a/.gitignore b/.gitignore index 7d7bd2d..9150c2a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ # Environment file with real secrets - NEVER commit this .env +# Virtual environments +.venv/ +venv/ + # Python __pycache__/ *.py[cod] @@ -12,9 +16,17 @@ dist/ build/ *.egg +# Database +*.db +*.db-wal +*.db-shm + # Docker *.log +# Test / cache +.pytest_cache/ + # Editor .vscode/ .idea/ diff --git a/Dockerfile b/Dockerfile index 65ddc4f..9c7d14c 100755 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,8 @@ RUN pip install --no-cache-dir -r requirements.txt COPY app/ . +RUN mkdir -p /data + EXPOSE 5199 CMD ["python", "-u", "main.py"] diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..c704407 --- /dev/null +++ b/app/config.py @@ -0,0 +1,56 @@ +"""Centralized configuration for frigate-notify v2.""" + +import os + + +class Config: + """Application configuration read from environment variables.""" + + # MQTT + MQTT_HOST: str = os.environ.get("MQTT_HOST", "192.168.86.192") + MQTT_PORT: int = int(os.environ.get("MQTT_PORT", "1883")) + + # Frigate + FRIGATE_URL: str = os.environ.get("FRIGATE_URL", "http://192.168.86.192:5000") + + # Matrix + MATRIX_HOMESERVER: str = os.environ.get( + "MATRIX_HOMESERVER", "http://192.168.86.192:8008" + ) + MATRIX_ACCESS_TOKEN: str = os.environ.get("MATRIX_ACCESS_TOKEN", "") + MATRIX_ROOM_ID: str = os.environ.get("MATRIX_ROOM_ID", "") + + # Vision - CLIP service + CLIP_SERVICE_URL: str = os.environ.get( + "CLIP_SERVICE_URL", "http://192.168.86.196:5200" + ) + + # Vision - Ollama + OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://192.168.86.196:11434") + OLLAMA_MODEL: str = os.environ.get("OLLAMA_MODEL", "llava:13b") + + # Recognition thresholds + RECOGNITION_THRESHOLD_HIGH: float = float( + os.environ.get("RECOGNITION_THRESHOLD_HIGH", "0.85") + ) + RECOGNITION_THRESHOLD_LOW: float = float( + os.environ.get("RECOGNITION_THRESHOLD_LOW", "0.60") + ) + + # Filtering + ALLOWED_LABELS: set = set( + os.environ.get("ALLOWED_LABELS", "person,car,dog,cat,bird").split(",") + ) + MIN_SCORE: float = float(os.environ.get("MIN_SCORE", "0.7")) + RATE_LIMIT_SECONDS: int = int(os.environ.get("RATE_LIMIT_SECONDS", "60")) + DEDUP_WINDOW_SECONDS: int = int(os.environ.get("DEDUP_WINDOW_SECONDS", "30")) + + # Server + SERVER_HOST: str = os.environ.get("SERVER_HOST", "0.0.0.0") + SERVER_PORT: int = int(os.environ.get("SERVER_PORT", "5199")) + + # Database + DB_PATH: str = os.environ.get("DB_PATH", "/data/frigate-notify.db") + + +config = Config() diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..d170c37 --- /dev/null +++ b/app/database.py @@ -0,0 +1,108 @@ +"""SQLite database schema and connection management for frigate-notify v2.""" + +import logging +import sqlite3 +import threading + +logger = logging.getLogger(__name__) + +# Thread-local storage for database connections +_local = threading.local() + +_db_path: str = "/data/frigate-notify.db" + + +def set_db_path(path: str) -> None: + """Set the database path. Must be called before any other database operations.""" + global _db_path + _db_path = path + + +def get_connection() -> sqlite3.Connection: + """Get a thread-local database connection with WAL mode and foreign keys enabled.""" + if not hasattr(_local, "connection") or _local.connection is None: + conn = sqlite3.connect(_db_path) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + conn.row_factory = sqlite3.Row + _local.connection = conn + return _local.connection + + +def close_connection() -> None: + """Close the thread-local database connection if one exists.""" + if hasattr(_local, "connection") and _local.connection is not None: + _local.connection.close() + _local.connection = None + + +def init_db() -> None: + """Create all tables and default settings.""" + conn = get_connection() + cursor = conn.cursor() + + cursor.executescript( + """ + CREATE TABLE IF NOT EXISTS known_objects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL CHECK(type IN ('person', 'vehicle', 'pet')), + action TEXT NOT NULL DEFAULT 'quiet' CHECK(action IN ('quiet', 'suppress', 'alert')), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS embeddings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + known_object_id INTEGER NOT NULL, + embedding BLOB NOT NULL, + snapshot_url TEXT, + thumbnail BLOB, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (known_object_id) REFERENCES known_objects(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + frigate_event_id TEXT UNIQUE NOT NULL, + camera TEXT NOT NULL, + label TEXT NOT NULL, + score REAL NOT NULL, + snapshot BLOB, + embedding BLOB, + match_status TEXT NOT NULL DEFAULT 'unreviewed' + CHECK(match_status IN ('known', 'unknown', 'ambiguous', 'unreviewed')), + matched_object_id INTEGER, + match_confidence REAL, + ai_description TEXT, + notified INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (matched_object_id) REFERENCES known_objects(id) ON DELETE SET NULL + ); + + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at); + CREATE INDEX IF NOT EXISTS idx_events_match_status ON events(match_status); + CREATE INDEX IF NOT EXISTS idx_events_camera ON events(camera); + CREATE INDEX IF NOT EXISTS idx_embeddings_known_object_id ON embeddings(known_object_id); + """ + ) + + # Insert default settings if they don't exist + defaults = { + "vision_backend": "haiku", + "recognition_enabled": "false", + "event_retention_days": "90", + } + for key, value in defaults.items(): + cursor.execute( + "INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", + (key, value), + ) + + conn.commit() + logger.info("Database initialized at %s", _db_path) diff --git a/app/main.py b/app/main.py index ba296db..dd765bf 100755 --- a/app/main.py +++ b/app/main.py @@ -1,3 +1,5 @@ +"""frigate-notify v2 — FastAPI application with MQTT listener.""" + import asyncio import json import logging @@ -6,29 +8,22 @@ import signal import sys import time from collections import defaultdict -from http.server import HTTPServer, BaseHTTPRequestHandler +from contextlib import asynccontextmanager +from pathlib import Path from threading import Thread import aiohttp import anthropic import paho.mqtt.client as mqtt +import uvicorn +from fastapi import FastAPI +from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles -from vision import analyze_snapshot +from config import config +from database import close_connection, get_connection, init_db, set_db_path from matrix_client import MatrixClient - -# --- Configuration --- -MQTT_HOST = os.environ.get("MQTT_HOST", "192.168.86.192") -MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883")) -FRIGATE_URL = os.environ.get("FRIGATE_URL", "http://192.168.86.192:5000") -MATRIX_HOMESERVER = os.environ.get("MATRIX_HOMESERVER", "http://192.168.86.192:8008") -MATRIX_ACCESS_TOKEN = os.environ["MATRIX_ACCESS_TOKEN"] -MATRIX_ROOM_ID = os.environ["MATRIX_ROOM_ID"] -HEALTH_PORT = int(os.environ.get("HEALTH_PORT", "5199")) - -ALLOWED_LABELS = {"person", "car", "dog", "cat", "bird"} -MIN_SCORE = 0.7 -RATE_LIMIT_SECONDS = 60 -DEDUP_WINDOW_SECONDS = 30 +from vision import analyze_snapshot # --- Logging --- logging.basicConfig( @@ -44,46 +39,12 @@ last_notify_per_camera: defaultdict[str, float] = defaultdict(float) events_processed = 0 last_event_time: str | None = None mqtt_connected = False - - -# --- Health endpoint --- -class HealthHandler(BaseHTTPRequestHandler): - protocol_version = "HTTP/1.1" - - def do_GET(self): - if self.path == "/health": - body = json.dumps( - { - "status": "ok", - "mqtt_connected": mqtt_connected, - "events_processed": events_processed, - "last_event": last_event_time, - } - ) - encoded = body.encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(encoded))) - self.end_headers() - self.wfile.write(encoded) - else: - self.send_response(404) - self.end_headers() - - def log_message(self, format, *args): - pass # suppress access logs - - -def start_health_server(): - server = HTTPServer(("0.0.0.0", HEALTH_PORT), HealthHandler) - thread = Thread(target=server.serve_forever, daemon=True) - thread.start() - logger.info("Health server started on port %d", HEALTH_PORT) +mqtt_client_instance: mqtt.Client | None = None # --- Snapshot fetching --- async def fetch_snapshot(event_id: str) -> bytes | None: - url = f"{FRIGATE_URL}/api/events/{event_id}/snapshot.jpg" + url = f"{config.FRIGATE_URL}/api/events/{event_id}/snapshot.jpg" try: async with aiohttp.ClientSession() as session: async with session.get(url) as resp: @@ -119,27 +80,30 @@ async def process_event( has_snapshot = after.get("has_snapshot", False) # --- Filtering --- - if label not in ALLOWED_LABELS: + if label not in config.ALLOWED_LABELS: return - if score < MIN_SCORE: + if score < config.MIN_SCORE: return if not has_snapshot: return # Dedup now = time.time() - if event_id in recent_events and now - recent_events[event_id] < DEDUP_WINDOW_SECONDS: + if ( + event_id in recent_events + and now - recent_events[event_id] < config.DEDUP_WINDOW_SECONDS + ): return recent_events[event_id] = now # Rate limit per camera - if now - last_notify_per_camera[camera] < RATE_LIMIT_SECONDS: + if now - last_notify_per_camera[camera] < config.RATE_LIMIT_SECONDS: logger.info("Rate limited: %s on %s", label, camera) return last_notify_per_camera[camera] = now # Cleanup old dedup entries - cutoff = now - DEDUP_WINDOW_SECONDS * 2 + cutoff = now - config.DEDUP_WINDOW_SECONDS * 2 for eid in [k for k, v in recent_events.items() if v < cutoff]: del recent_events[eid] @@ -157,11 +121,24 @@ async def process_event( if not description: description = f"{label.capitalize()} detected on {camera}." + # --- Store event in database --- + try: + conn = get_connection() + conn.execute( + """INSERT OR IGNORE INTO events + (frigate_event_id, camera, label, score, snapshot, ai_description, notified) + VALUES (?, ?, ?, ?, ?, ?, 1)""", + (event_id, camera, label, score, snapshot, description), + ) + conn.commit() + except Exception: + logger.exception("Failed to store event %s in database", event_id) + # --- Format message --- score_pct = int(score * 100) ts = time.strftime("%I:%M:%S %p", time.localtime()) date_str = time.strftime("%b %d, %Y", time.localtime()) - frigate_link = f"{FRIGATE_URL}/review?id={event_id}" + frigate_link = f"{config.FRIGATE_URL}/review?id={event_id}" plain = ( f"{camera} - {label.capitalize()} - {score_pct}%\n" @@ -195,7 +172,7 @@ def create_mqtt_client( loop: asyncio.AbstractEventLoop, claude: anthropic.Anthropic, matrix: MatrixClient, -): +) -> mqtt.Client: global mqtt_connected def on_connect(client, userdata, flags, reason_code, properties): @@ -227,33 +204,90 @@ def create_mqtt_client( return client -# --- Main --- -def main(): - logger.info("Starting frigate-notify") +def start_mqtt(loop: asyncio.AbstractEventLoop) -> None: + """Start MQTT client in a background thread.""" + global mqtt_client_instance claude = anthropic.Anthropic() - matrix = MatrixClient(MATRIX_HOMESERVER, MATRIX_ACCESS_TOKEN, MATRIX_ROOM_ID) + matrix = MatrixClient( + config.MATRIX_HOMESERVER, + config.MATRIX_ACCESS_TOKEN, + config.MATRIX_ROOM_ID, + ) - loop = asyncio.new_event_loop() - loop_thread = Thread(target=loop.run_forever, daemon=True) - loop_thread.start() + mqtt_client_instance = create_mqtt_client(loop, claude, matrix) + mqtt_client_instance.connect(config.MQTT_HOST, config.MQTT_PORT) - start_health_server() - - mqtt_client = create_mqtt_client(loop, claude, matrix) - mqtt_client.connect(MQTT_HOST, MQTT_PORT) - - def shutdown(signum, frame): - logger.info("Shutting down...") - mqtt_client.disconnect() - loop.call_soon_threadsafe(loop.stop) - sys.exit(0) - - signal.signal(signal.SIGTERM, shutdown) - signal.signal(signal.SIGINT, shutdown) - - mqtt_client.loop_forever() + thread = Thread(target=mqtt_client_instance.loop_forever, daemon=True) + thread.start() + logger.info("MQTT client started in background thread") +# --- FastAPI lifespan --- +@asynccontextmanager +async def lifespan(app: FastAPI): + """Startup and shutdown logic for the FastAPI application.""" + logger.info("Starting frigate-notify v2") + + # Initialize database + set_db_path(config.DB_PATH) + init_db() + + # Start MQTT listener in background + loop = asyncio.get_event_loop() + start_mqtt(loop) + + yield + + # Shutdown + logger.info("Shutting down frigate-notify v2") + if mqtt_client_instance: + mqtt_client_instance.disconnect() + close_connection() + + +# --- FastAPI app --- +app = FastAPI( + title="frigate-notify", + version="2.0.0", + lifespan=lifespan, +) + +# Mount static files directory +static_dir = Path(__file__).parent / "static" +static_dir.mkdir(exist_ok=True) +app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") + + +@app.get("/health") +async def health(): + """Health check endpoint.""" + return { + "status": "ok", + "version": "2.0.0", + "mqtt_connected": mqtt_connected, + "events_processed": events_processed, + "last_event": last_event_time, + } + + +@app.get("/") +async def index(): + """Serve the web UI index page.""" + index_path = static_dir / "index.html" + if index_path.exists(): + return FileResponse(str(index_path)) + return JSONResponse( + {"message": "frigate-notify v2", "docs": "/docs"}, + status_code=200, + ) + + +# --- Entrypoint --- if __name__ == "__main__": - main() + uvicorn.run( + "main:app", + host=config.SERVER_HOST, + port=config.SERVER_PORT, + log_level="info", + ) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..450c9a5 --- /dev/null +++ b/app/models.py @@ -0,0 +1,86 @@ +"""Pydantic models for frigate-notify v2 API.""" + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + + +# --- Known Objects --- + + +class KnownObjectCreate(BaseModel): + """Request model for creating a known object.""" + + name: str = Field(..., min_length=1, max_length=255) + type: str = Field(..., pattern="^(person|vehicle|pet)$") + action: str = Field(default="quiet", pattern="^(quiet|suppress|alert)$") + + +class KnownObjectUpdate(BaseModel): + """Request model for updating a known object.""" + + name: Optional[str] = Field(default=None, min_length=1, max_length=255) + type: Optional[str] = Field(default=None, pattern="^(person|vehicle|pet)$") + action: Optional[str] = Field(default=None, pattern="^(quiet|suppress|alert)$") + + +class KnownObjectResponse(BaseModel): + """Response model for a known object.""" + + id: int + name: str + type: str + action: str + created_at: str + updated_at: str + + +# --- Events --- + + +class TagEventRequest(BaseModel): + """Request model for tagging an event with a known object.""" + + known_object_id: int + match_status: str = Field(default="known", pattern="^(known|unknown|ambiguous)$") + + +class EventResponse(BaseModel): + """Response model for an event.""" + + id: int + frigate_event_id: str + camera: str + label: str + score: float + match_status: str + matched_object_id: Optional[int] = None + match_confidence: Optional[float] = None + ai_description: Optional[str] = None + notified: bool + created_at: str + + +# --- Settings --- + + +class SettingsResponse(BaseModel): + """Response model for application settings.""" + + vision_backend: str + recognition_enabled: bool + event_retention_days: int + + +# --- Stats --- + + +class StatsResponse(BaseModel): + """Response model for application statistics.""" + + events_processed: int + mqtt_connected: bool + last_event: Optional[str] = None + total_events_stored: int = 0 + known_objects_count: int = 0 diff --git a/app/requirements.txt b/app/requirements.txt index 8991258..4806b8f 100755 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -1,3 +1,7 @@ paho-mqtt==2.1.0 anthropic>=0.40.0 aiohttp>=3.9.0 +fastapi>=0.115.0 +uvicorn[standard]>=0.34.0 +python-multipart>=0.0.18 +numpy>=1.26.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c0c0bd7 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,16 @@ +"""Shared fixtures for frigate-notify tests.""" + +import os +import sys +import tempfile + +import pytest + +# Add app/ to sys.path so test modules can import application code +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app")) + + +@pytest.fixture +def db_path(tmp_path): + """Provide a temporary database path for testing.""" + return str(tmp_path / "test-frigate-notify.db") diff --git a/tests/test_database.py b/tests/test_database.py new file mode 100644 index 0000000..db9393b --- /dev/null +++ b/tests/test_database.py @@ -0,0 +1,321 @@ +"""Tests for the database module.""" + +import sqlite3 + +import pytest + +from database import close_connection, get_connection, init_db, set_db_path + + +@pytest.fixture(autouse=True) +def setup_db(db_path): + """Initialize a fresh database for each test.""" + set_db_path(db_path) + init_db() + yield + close_connection() + + +class TestTableCreation: + """Tests for database schema initialization.""" + + def test_known_objects_table_exists(self): + conn = get_connection() + cursor = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='known_objects'" + ) + assert cursor.fetchone() is not None + + def test_embeddings_table_exists(self): + conn = get_connection() + cursor = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='embeddings'" + ) + assert cursor.fetchone() is not None + + def test_events_table_exists(self): + conn = get_connection() + cursor = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='events'" + ) + assert cursor.fetchone() is not None + + def test_settings_table_exists(self): + conn = get_connection() + cursor = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='settings'" + ) + assert cursor.fetchone() is not None + + def test_wal_mode_enabled(self): + conn = get_connection() + cursor = conn.execute("PRAGMA journal_mode") + mode = cursor.fetchone()[0] + assert mode == "wal" + + def test_foreign_keys_enabled(self): + conn = get_connection() + cursor = conn.execute("PRAGMA foreign_keys") + assert cursor.fetchone()[0] == 1 + + def test_indexes_created(self): + conn = get_connection() + cursor = conn.execute( + "SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%'" + ) + indexes = {row[0] for row in cursor.fetchall()} + expected = { + "idx_events_created_at", + "idx_events_match_status", + "idx_events_camera", + "idx_embeddings_known_object_id", + } + assert expected.issubset(indexes) + + +class TestDefaultSettings: + """Tests for default settings insertion.""" + + def test_vision_backend_default(self): + conn = get_connection() + cursor = conn.execute( + "SELECT value FROM settings WHERE key = 'vision_backend'" + ) + assert cursor.fetchone()[0] == "haiku" + + def test_recognition_enabled_default(self): + conn = get_connection() + cursor = conn.execute( + "SELECT value FROM settings WHERE key = 'recognition_enabled'" + ) + assert cursor.fetchone()[0] == "false" + + def test_event_retention_days_default(self): + conn = get_connection() + cursor = conn.execute( + "SELECT value FROM settings WHERE key = 'event_retention_days'" + ) + assert cursor.fetchone()[0] == "90" + + def test_reinit_does_not_overwrite_settings(self): + """Calling init_db() again should not reset changed settings.""" + conn = get_connection() + conn.execute( + "UPDATE settings SET value = 'ollama' WHERE key = 'vision_backend'" + ) + conn.commit() + + # Re-init + init_db() + + cursor = conn.execute( + "SELECT value FROM settings WHERE key = 'vision_backend'" + ) + assert cursor.fetchone()[0] == "ollama" + + +class TestKnownObjectsCRUD: + """Tests for known_objects CRUD operations.""" + + def test_insert_known_object(self): + conn = get_connection() + conn.execute( + "INSERT INTO known_objects (name, type, action) VALUES (?, ?, ?)", + ("John", "person", "quiet"), + ) + conn.commit() + + cursor = conn.execute("SELECT * FROM known_objects WHERE name = 'John'") + row = cursor.fetchone() + assert row is not None + assert row["name"] == "John" + assert row["type"] == "person" + assert row["action"] == "quiet" + + def test_update_known_object(self): + conn = get_connection() + conn.execute( + "INSERT INTO known_objects (name, type, action) VALUES (?, ?, ?)", + ("Rover", "pet", "quiet"), + ) + conn.commit() + + conn.execute( + "UPDATE known_objects SET action = 'suppress' WHERE name = 'Rover'" + ) + conn.commit() + + cursor = conn.execute( + "SELECT action FROM known_objects WHERE name = 'Rover'" + ) + assert cursor.fetchone()[0] == "suppress" + + def test_delete_known_object(self): + conn = get_connection() + conn.execute( + "INSERT INTO known_objects (name, type, action) VALUES (?, ?, ?)", + ("Delete Me", "person", "quiet"), + ) + conn.commit() + + conn.execute("DELETE FROM known_objects WHERE name = 'Delete Me'") + conn.commit() + + cursor = conn.execute( + "SELECT * FROM known_objects WHERE name = 'Delete Me'" + ) + assert cursor.fetchone() is None + + def test_type_check_constraint(self): + conn = get_connection() + with pytest.raises(sqlite3.IntegrityError): + conn.execute( + "INSERT INTO known_objects (name, type, action) VALUES (?, ?, ?)", + ("Bad", "alien", "quiet"), + ) + + def test_action_check_constraint(self): + conn = get_connection() + with pytest.raises(sqlite3.IntegrityError): + conn.execute( + "INSERT INTO known_objects (name, type, action) VALUES (?, ?, ?)", + ("Bad", "person", "explode"), + ) + + +class TestEventsCRUD: + """Tests for events CRUD operations.""" + + def test_insert_event(self): + conn = get_connection() + conn.execute( + """INSERT INTO events (frigate_event_id, camera, label, score, ai_description, notified) + VALUES (?, ?, ?, ?, ?, ?)""", + ("evt-001", "front_door", "person", 0.95, "A person at the door.", 1), + ) + conn.commit() + + cursor = conn.execute( + "SELECT * FROM events WHERE frigate_event_id = 'evt-001'" + ) + row = cursor.fetchone() + assert row is not None + assert row["camera"] == "front_door" + assert row["label"] == "person" + assert row["score"] == 0.95 + assert row["match_status"] == "unreviewed" + assert row["notified"] == 1 + + def test_frigate_event_id_unique(self): + conn = get_connection() + conn.execute( + """INSERT INTO events (frigate_event_id, camera, label, score, notified) + VALUES (?, ?, ?, ?, ?)""", + ("evt-dup", "cam1", "car", 0.8, 1), + ) + conn.commit() + + with pytest.raises(sqlite3.IntegrityError): + conn.execute( + """INSERT INTO events (frigate_event_id, camera, label, score, notified) + VALUES (?, ?, ?, ?, ?)""", + ("evt-dup", "cam2", "dog", 0.9, 0), + ) + + def test_match_status_check_constraint(self): + conn = get_connection() + with pytest.raises(sqlite3.IntegrityError): + conn.execute( + """INSERT INTO events (frigate_event_id, camera, label, score, match_status, notified) + VALUES (?, ?, ?, ?, ?, ?)""", + ("evt-bad", "cam1", "person", 0.8, "invalid_status", 1), + ) + + def test_matched_object_id_foreign_key_set_null(self): + """Deleting a known object should set matched_object_id to NULL on events.""" + conn = get_connection() + + # Create known object + conn.execute( + "INSERT INTO known_objects (name, type, action) VALUES (?, ?, ?)", + ("Jane", "person", "quiet"), + ) + conn.commit() + obj_id = conn.execute( + "SELECT id FROM known_objects WHERE name = 'Jane'" + ).fetchone()[0] + + # Create event linked to the known object + conn.execute( + """INSERT INTO events (frigate_event_id, camera, label, score, matched_object_id, match_status, notified) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + ("evt-fk", "cam1", "person", 0.9, obj_id, "known", 1), + ) + conn.commit() + + # Delete the known object + conn.execute("DELETE FROM known_objects WHERE id = ?", (obj_id,)) + conn.commit() + + # Event should still exist with matched_object_id = NULL + cursor = conn.execute( + "SELECT matched_object_id FROM events WHERE frigate_event_id = 'evt-fk'" + ) + row = cursor.fetchone() + assert row is not None + assert row["matched_object_id"] is None + + +class TestEmbeddingsCascade: + """Tests for embeddings cascade delete.""" + + def test_cascade_delete_embeddings(self): + """Deleting a known object should cascade delete its embeddings.""" + conn = get_connection() + + # Create known object + conn.execute( + "INSERT INTO known_objects (name, type, action) VALUES (?, ?, ?)", + ("Bob", "person", "alert"), + ) + conn.commit() + obj_id = conn.execute( + "SELECT id FROM known_objects WHERE name = 'Bob'" + ).fetchone()[0] + + # Create embeddings for that object + fake_embedding = b"\x00\x01\x02\x03" + conn.execute( + "INSERT INTO embeddings (known_object_id, embedding) VALUES (?, ?)", + (obj_id, fake_embedding), + ) + conn.execute( + "INSERT INTO embeddings (known_object_id, embedding) VALUES (?, ?)", + (obj_id, fake_embedding), + ) + conn.commit() + + # Verify embeddings exist + cursor = conn.execute( + "SELECT COUNT(*) FROM embeddings WHERE known_object_id = ?", (obj_id,) + ) + assert cursor.fetchone()[0] == 2 + + # Delete the known object + conn.execute("DELETE FROM known_objects WHERE id = ?", (obj_id,)) + conn.commit() + + # Embeddings should be gone + cursor = conn.execute( + "SELECT COUNT(*) FROM embeddings WHERE known_object_id = ?", (obj_id,) + ) + assert cursor.fetchone()[0] == 0 + + def test_embedding_foreign_key_constraint(self): + """Inserting an embedding with a non-existent known_object_id should fail.""" + conn = get_connection() + with pytest.raises(sqlite3.IntegrityError): + conn.execute( + "INSERT INTO embeddings (known_object_id, embedding) VALUES (?, ?)", + (99999, b"\x00"), + )