031b9eaaec
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>
294 lines
8.5 KiB
Python
Executable File
294 lines
8.5 KiB
Python
Executable File
"""frigate-notify v2 — FastAPI application with MQTT listener."""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import signal
|
|
import sys
|
|
import time
|
|
from collections import defaultdict
|
|
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 config import config
|
|
from database import close_connection, get_connection, init_db, set_db_path
|
|
from matrix_client import MatrixClient
|
|
from vision import analyze_snapshot
|
|
|
|
# --- Logging ---
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='{"time":"%(asctime)s","level":"%(levelname)s","module":"%(module)s","message":"%(message)s"}',
|
|
stream=sys.stdout,
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# --- State ---
|
|
recent_events: dict[str, float] = {}
|
|
last_notify_per_camera: defaultdict[str, float] = defaultdict(float)
|
|
events_processed = 0
|
|
last_event_time: str | None = None
|
|
mqtt_connected = False
|
|
mqtt_client_instance: mqtt.Client | None = None
|
|
|
|
|
|
# --- Snapshot fetching ---
|
|
async def fetch_snapshot(event_id: str) -> bytes | None:
|
|
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:
|
|
if resp.status == 200:
|
|
data = await resp.read()
|
|
logger.info(
|
|
"Fetched snapshot for %s: %d bytes", event_id, len(data)
|
|
)
|
|
return data
|
|
logger.warning(
|
|
"Snapshot fetch failed for %s: HTTP %d", event_id, resp.status
|
|
)
|
|
except Exception:
|
|
logger.exception("Snapshot fetch error for %s", event_id)
|
|
return None
|
|
|
|
|
|
# --- Event processing ---
|
|
async def process_event(
|
|
event_data: dict, claude: anthropic.Anthropic, matrix: MatrixClient
|
|
):
|
|
global events_processed, last_event_time
|
|
|
|
event_type = event_data.get("type")
|
|
if event_type != "end":
|
|
return
|
|
|
|
after = event_data.get("after", {})
|
|
event_id = after.get("id", "")
|
|
camera = after.get("camera", "unknown")
|
|
label = after.get("label", "")
|
|
score = after.get("top_score", after.get("score", 0))
|
|
has_snapshot = after.get("has_snapshot", False)
|
|
|
|
# --- Filtering ---
|
|
if label not in config.ALLOWED_LABELS:
|
|
return
|
|
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] < config.DEDUP_WINDOW_SECONDS
|
|
):
|
|
return
|
|
recent_events[event_id] = now
|
|
|
|
# Rate limit per camera
|
|
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 - config.DEDUP_WINDOW_SECONDS * 2
|
|
for eid in [k for k, v in recent_events.items() if v < cutoff]:
|
|
del recent_events[eid]
|
|
|
|
logger.info(
|
|
"Processing event: %s on %s (%s, score=%.2f)", event_id, camera, label, score
|
|
)
|
|
|
|
# --- Fetch snapshot ---
|
|
snapshot = await fetch_snapshot(event_id)
|
|
|
|
# --- AI analysis ---
|
|
description = None
|
|
if snapshot:
|
|
description = analyze_snapshot(claude, snapshot)
|
|
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"{config.FRIGATE_URL}/review?id={event_id}"
|
|
|
|
plain = (
|
|
f"{camera} - {label.capitalize()} - {score_pct}%\n"
|
|
f"{description}\n\n"
|
|
f"View in Frigate: {frigate_link}\n"
|
|
f"{ts} - {date_str}"
|
|
)
|
|
html = (
|
|
f"<b>{camera}</b> · {label.capitalize()} · {score_pct}%<br/>"
|
|
f"{description}<br/><br/>"
|
|
f'<a href="{frigate_link}">View in Frigate</a><br/>'
|
|
f"<small>{ts} · {date_str}</small>"
|
|
)
|
|
|
|
# --- Send to Matrix ---
|
|
if snapshot:
|
|
filename = f"{camera}_{event_id}.jpg"
|
|
mxc_uri = await matrix.upload_image(snapshot, filename)
|
|
if mxc_uri:
|
|
await matrix.send_image(mxc_uri, filename, len(snapshot))
|
|
|
|
await matrix.send_text(plain, html)
|
|
|
|
events_processed += 1
|
|
last_event_time = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
logger.info("Notification sent for %s on %s", label, camera)
|
|
|
|
|
|
# --- MQTT ---
|
|
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):
|
|
global mqtt_connected
|
|
mqtt_connected = True
|
|
client.subscribe("frigate/events")
|
|
logger.info("MQTT connected, subscribed to frigate/events")
|
|
|
|
def on_disconnect(client, userdata, flags, reason_code, properties):
|
|
global mqtt_connected
|
|
mqtt_connected = False
|
|
logger.warning("MQTT disconnected: %s", reason_code)
|
|
|
|
def on_message(client, userdata, msg):
|
|
try:
|
|
payload = json.loads(msg.payload)
|
|
asyncio.run_coroutine_threadsafe(
|
|
process_event(payload, claude, matrix), loop
|
|
)
|
|
except json.JSONDecodeError:
|
|
logger.warning("Invalid JSON on %s", msg.topic)
|
|
except Exception:
|
|
logger.exception("Error processing MQTT message")
|
|
|
|
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
|
|
client.on_connect = on_connect
|
|
client.on_disconnect = on_disconnect
|
|
client.on_message = on_message
|
|
return client
|
|
|
|
|
|
def start_mqtt(loop: asyncio.AbstractEventLoop) -> None:
|
|
"""Start MQTT client in a background thread."""
|
|
global mqtt_client_instance
|
|
|
|
claude = anthropic.Anthropic()
|
|
matrix = MatrixClient(
|
|
config.MATRIX_HOMESERVER,
|
|
config.MATRIX_ACCESS_TOKEN,
|
|
config.MATRIX_ROOM_ID,
|
|
)
|
|
|
|
mqtt_client_instance = create_mqtt_client(loop, claude, matrix)
|
|
mqtt_client_instance.connect(config.MQTT_HOST, config.MQTT_PORT)
|
|
|
|
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__":
|
|
uvicorn.run(
|
|
"main:app",
|
|
host=config.SERVER_HOST,
|
|
port=config.SERVER_PORT,
|
|
log_level="info",
|
|
)
|