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:
2026-03-07 17:02:17 -06:00
parent 6086ed3cc0
commit 031b9eaaec
10 changed files with 721 additions and 82 deletions
+56
View File
@@ -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
View File
@@ -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
View File
@@ -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",
)
+86
View File
@@ -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
+4
View File
@@ -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