feat: scaffold v2 with FastAPI, SQLite database, and project structure
Migrate from raw HTTPServer to FastAPI, add SQLite database with schema for known objects, embeddings, events, and settings. Add centralized config, Pydantic models, and 22 database integration tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+12
@@ -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/
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
+108
@@ -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)
|
||||
+116
-82
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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"),
|
||||
)
|
||||
Reference in New Issue
Block a user