diff --git a/compose/hf-pdocker-01/clip-service/docker-compose.yml b/compose/hf-pdocker-01/clip-service/docker-compose.yml new file mode 100644 index 0000000..3aff0d4 --- /dev/null +++ b/compose/hf-pdocker-01/clip-service/docker-compose.yml @@ -0,0 +1,19 @@ +services: + clip-service: + image: clip-service:latest + container_name: clip-service + restart: unless-stopped + network_mode: host + mem_limit: 3G + memswap_limit: 3G + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:5200/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 180s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" diff --git a/compose/hf-pdocker-01/frigate-notify/Dockerfile b/compose/hf-pdocker-01/frigate-notify/Dockerfile index 65ddc4f..9c7d14c 100644 --- a/compose/hf-pdocker-01/frigate-notify/Dockerfile +++ b/compose/hf-pdocker-01/frigate-notify/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/compose/hf-pdocker-01/frigate-notify/app/cleanup.py b/compose/hf-pdocker-01/frigate-notify/app/cleanup.py new file mode 100644 index 0000000..f8e03d3 --- /dev/null +++ b/compose/hf-pdocker-01/frigate-notify/app/cleanup.py @@ -0,0 +1,35 @@ +"""Automatic event cleanup based on retention settings.""" + +import asyncio +import logging + +from database import get_connection + +logger = logging.getLogger(__name__) + + +async def cleanup_old_events(): + """Delete events older than the configured retention period. Runs hourly.""" + while True: + try: + conn = get_connection() + row = conn.execute( + "SELECT value FROM settings WHERE key='event_retention_days'" + ).fetchone() + days = int(row["value"]) if row else 90 + + result = conn.execute( + "DELETE FROM events WHERE created_at < datetime('now', ? || ' days')", + (f"-{days}",), + ) + conn.commit() + if result.rowcount > 0: + logger.info( + "Cleaned up %d events older than %d days", + result.rowcount, + days, + ) + except Exception: + logger.exception("Cleanup failed") + + await asyncio.sleep(3600) diff --git a/compose/hf-pdocker-01/frigate-notify/app/config.py b/compose/hf-pdocker-01/frigate-notify/app/config.py new file mode 100644 index 0000000..569cdcc --- /dev/null +++ b/compose/hf-pdocker-01/frigate-notify/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.150:5200" + ) + + # Vision - Ollama + OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://192.168.86.150: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/compose/hf-pdocker-01/frigate-notify/app/database.py b/compose/hf-pdocker-01/frigate-notify/app/database.py new file mode 100644 index 0000000..a768746 --- /dev/null +++ b/compose/hf-pdocker-01/frigate-notify/app/database.py @@ -0,0 +1,109 @@ +"""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. Closes any existing connection first.""" + global _db_path + close_connection() + _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/compose/hf-pdocker-01/frigate-notify/app/main.py b/compose/hf-pdocker-01/frigate-notify/app/main.py index ba296db..34e6172 100644 --- a/compose/hf-pdocker-01/frigate-notify/app/main.py +++ b/compose/hf-pdocker-01/frigate-notify/app/main.py @@ -1,3 +1,5 @@ +"""frigate-notify v2 — FastAPI application with MQTT listener.""" + import asyncio import json import logging @@ -6,29 +8,28 @@ 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 routes.events import router as events_router +from routes.known_objects import router as known_objects_router +from routes.settings import router as settings_router +from ollama_client import OllamaClient +from recognition import recognize_event, get_clip_embedding, embedding_to_bytes +from vision import get_description +from cleanup import cleanup_old_events # --- Logging --- logging.basicConfig( @@ -44,46 +45,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: @@ -103,7 +70,8 @@ async def fetch_snapshot(event_id: str) -> bytes | None: # --- Event processing --- async def process_event( - event_data: dict, claude: anthropic.Anthropic, matrix: MatrixClient + event_data: dict, claude: anthropic.Anthropic, matrix: MatrixClient, + ollama_client: OllamaClient = None, ): global events_processed, last_event_time @@ -119,27 +87,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] @@ -150,18 +121,84 @@ async def process_event( # --- Fetch snapshot --- snapshot = await fetch_snapshot(event_id) - # --- AI analysis --- + # --- Recognition --- + match_status = "unreviewed" + matched_id = None + match_conf = 0.0 + matched_name = None + + # Check if recognition is enabled + recognition_enabled = False + try: + conn = get_connection() + row = conn.execute("SELECT value FROM settings WHERE key='recognition_enabled'").fetchone() + if row: + recognition_enabled = row["value"].lower() == "true" + except Exception: + pass + + if recognition_enabled and snapshot: + match_status, matched_id, match_conf, matched_name = await recognize_event( + snapshot, ollama_client + ) + # Store embedding with event + embedding = await get_clip_embedding(snapshot) + if embedding: + embedding_blob = embedding_to_bytes(embedding) + try: + conn = get_connection() + conn.execute( + "UPDATE events SET embedding=?, match_status=?, matched_object_id=?, match_confidence=? WHERE frigate_event_id=?", + (embedding_blob, match_status, matched_id, match_conf, event_id), + ) + conn.commit() + except Exception: + logger.exception("Failed to update event recognition") + + # If known, check action - skip notification for quiet/suppress + if match_status == "known" and matched_id: + conn = get_connection() + obj = conn.execute("SELECT action FROM known_objects WHERE id=?", (matched_id,)).fetchone() + if obj and obj["action"] in ("quiet", "suppress"): + logger.info("Known object '%s' (%s), skipping notification", matched_name, obj["action"]) + events_processed += 1 + last_event_time = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + return + + # --- AI description --- + vision_backend = "haiku" + try: + conn = get_connection() + row = conn.execute("SELECT value FROM settings WHERE key='vision_backend'").fetchone() + if row: + vision_backend = row["value"] + except Exception: + pass + description = None if snapshot: - description = analyze_snapshot(claude, snapshot) + description = await get_description(snapshot, vision_backend, claude, ollama_client) 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, match_status, ai_description, notified) + VALUES (?, ?, ?, ?, ?, ?, ?, 1)""", + (event_id, camera, label, score, snapshot, match_status, 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 +232,8 @@ def create_mqtt_client( loop: asyncio.AbstractEventLoop, claude: anthropic.Anthropic, matrix: MatrixClient, -): + ollama_client: OllamaClient = None, +) -> mqtt.Client: global mqtt_connected def on_connect(client, userdata, flags, reason_code, properties): @@ -213,7 +251,7 @@ def create_mqtt_client( try: payload = json.loads(msg.payload) asyncio.run_coroutine_threadsafe( - process_event(payload, claude, matrix), loop + process_event(payload, claude, matrix, ollama_client), loop ) except json.JSONDecodeError: logger.warning("Invalid JSON on %s", msg.topic) @@ -227,33 +265,99 @@ 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, + ) + ollama_client = OllamaClient(config.OLLAMA_URL, config.OLLAMA_MODEL) - 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, ollama_client) + 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) + + # Start periodic event cleanup + asyncio.create_task(cleanup_old_events()) + + 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, +) + +# Include API routers +app.include_router(events_router) +app.include_router(known_objects_router) +app.include_router(settings_router) + +# 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/compose/hf-pdocker-01/frigate-notify/app/models.py b/compose/hf-pdocker-01/frigate-notify/app/models.py new file mode 100644 index 0000000..5233db1 --- /dev/null +++ b/compose/hf-pdocker-01/frigate-notify/app/models.py @@ -0,0 +1,95 @@ +"""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. + + Either provide known_object_id to link to an existing object, + or provide name + type to create a new known object. + """ + + known_object_id: Optional[int] = None + name: Optional[str] = Field(default=None, min_length=1, max_length=255) + type: Optional[str] = Field(default=None, pattern="^(person|vehicle|pet)$") + + +class EventResponse(BaseModel): + """Response model for an event.""" + + id: int + frigate_event_id: str + camera: str + label: str + score: float + has_snapshot: bool = False + snapshot_base64: Optional[str] = None + match_status: str + matched_object_id: Optional[int] = None + matched_object_name: Optional[str] = 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.""" + + total_events: int = 0 + known_matches: int = 0 + unknown_events: int = 0 + unreviewed: int = 0 + known_objects_count: int = 0 + events_today: int = 0 diff --git a/compose/hf-pdocker-01/frigate-notify/app/ollama_client.py b/compose/hf-pdocker-01/frigate-notify/app/ollama_client.py new file mode 100644 index 0000000..4d68c68 --- /dev/null +++ b/compose/hf-pdocker-01/frigate-notify/app/ollama_client.py @@ -0,0 +1,132 @@ +"""Ollama vision LLM client for frigate-notify v2.""" + +import base64 +import logging + +import aiohttp + +logger = logging.getLogger(__name__) + + +class OllamaClient: + """Client for Ollama vision models (e.g., llava).""" + + def __init__(self, base_url: str, model: str): + self.base_url = base_url.rstrip("/") + self.model = model + + async def analyze_image(self, image_bytes: bytes, prompt: str) -> str | None: + """Send an image to Ollama for analysis. + + Args: + image_bytes: Raw image bytes (JPEG). + prompt: Text prompt for the vision model. + + Returns: + Response text or None on failure. + """ + b64 = base64.b64encode(image_bytes).decode("utf-8") + payload = { + "model": self.model, + "prompt": prompt, + "images": [b64], + "stream": False, + "options": {"num_predict": 200}, + } + try: + async with aiohttp.ClientSession() as session: + async with session.post( + f"{self.base_url}/api/generate", + json=payload, + timeout=aiohttp.ClientTimeout(total=30), + ) as resp: + if resp.status == 200: + data = await resp.json() + text = data.get("response", "").strip() + logger.info( + "Ollama analysis complete: %d chars", len(text) + ) + return text if text else None + body = await resp.text() + logger.error( + "Ollama analyze_image failed %s: %s", resp.status, body + ) + except Exception: + logger.exception("Ollama analyze_image request failed") + return None + + async def compare_images( + self, + image_bytes: bytes, + reference_bytes: bytes, + object_name: str, + ) -> bool | None: + """Compare two images to determine if they show the same object/person. + + Args: + image_bytes: The new event image bytes. + reference_bytes: The reference (known) image bytes. + object_name: Name of the known object for context. + + Returns: + True if same, False if different, None on failure/inconclusive. + """ + b64_new = base64.b64encode(image_bytes).decode("utf-8") + b64_ref = base64.b64encode(reference_bytes).decode("utf-8") + prompt = ( + f"Look at these two images. The first image is from a security camera. " + f"The second image is a reference photo of '{object_name}'. " + f"Are these the same person/vehicle/animal? " + f"Answer with ONLY 'yes' or 'no'." + ) + payload = { + "model": self.model, + "prompt": prompt, + "images": [b64_new, b64_ref], + "stream": False, + "options": {"num_predict": 200}, + } + try: + async with aiohttp.ClientSession() as session: + async with session.post( + f"{self.base_url}/api/generate", + json=payload, + timeout=aiohttp.ClientTimeout(total=30), + ) as resp: + if resp.status == 200: + data = await resp.json() + text = data.get("response", "").strip().lower() + logger.info("Ollama compare result: %s", text) + if "yes" in text.split()[:3]: + return True + if "no" in text.split()[:3]: + return False + logger.warning( + "Ollama compare inconclusive: %s", text + ) + return None + body = await resp.text() + logger.error( + "Ollama compare_images failed %s: %s", + resp.status, + body, + ) + except Exception: + logger.exception("Ollama compare_images request failed") + return None + + async def is_available(self) -> bool: + """Check if Ollama is reachable and responding. + + Returns: + True if Ollama responds with HTTP 200, False otherwise. + """ + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"{self.base_url}/api/tags", + timeout=aiohttp.ClientTimeout(total=3), + ) as resp: + return resp.status == 200 + except Exception: + return False diff --git a/compose/hf-pdocker-01/frigate-notify/app/recognition.py b/compose/hf-pdocker-01/frigate-notify/app/recognition.py new file mode 100644 index 0000000..0099c26 --- /dev/null +++ b/compose/hf-pdocker-01/frigate-notify/app/recognition.py @@ -0,0 +1,232 @@ +"""Recognition pipeline using CLIP embeddings with Ollama vision fallback.""" + +import base64 +import logging +import struct +from typing import Optional + +import aiohttp +import numpy as np + +from config import config +from database import get_connection +from ollama_client import OllamaClient + +logger = logging.getLogger(__name__) + + +def embedding_to_bytes(embedding: list[float]) -> bytes: + """Pack a float32 embedding list into bytes for SQLite storage. + + Args: + embedding: List of float values (typically 768-dimensional). + + Returns: + Packed bytes (len(embedding) * 4 bytes). + """ + return struct.pack(f"{len(embedding)}f", *embedding) + + +def bytes_to_embedding(data: bytes) -> np.ndarray: + """Unpack bytes back into a float32 numpy array. + + Args: + data: Packed bytes from embedding_to_bytes(). + + Returns: + Numpy array of float32 values. + """ + count = len(data) // 4 + values = struct.unpack(f"{count}f", data) + return np.array(values, dtype=np.float32) + + +async def get_clip_embedding(image_bytes: bytes) -> list[float] | None: + """Get CLIP embedding for an image from the clip-service sidecar. + + Args: + image_bytes: Raw image bytes (JPEG). + + Returns: + List of float embedding values, or None on failure. + """ + b64 = base64.b64encode(image_bytes).decode("utf-8") + try: + async with aiohttp.ClientSession() as session: + async with session.post( + f"{config.CLIP_SERVICE_URL}/embed", + json={"image": b64}, + timeout=aiohttp.ClientTimeout(total=10), + ) as resp: + if resp.status == 200: + data = await resp.json() + embedding = data.get("embedding") + if embedding: + logger.info( + "CLIP embedding received: %d dimensions", + len(embedding), + ) + return embedding + logger.warning("CLIP response missing embedding field") + else: + body = await resp.text() + logger.error( + "CLIP embed failed %s: %s", resp.status, body + ) + except Exception: + logger.exception("CLIP embedding request failed") + return None + + +async def find_best_match( + query_embedding: list[float], +) -> tuple[int | None, float, str | None]: + """Find the best matching known object for a query embedding. + + Loads all embeddings from the database, computes cosine similarity, + groups by known_object_id and takes the max score per object. + + Args: + query_embedding: The query embedding to match against. + + Returns: + Tuple of (best_object_id, best_score, best_name). + Returns (None, 0.0, None) if no embeddings exist. + """ + conn = get_connection() + cursor = conn.execute( + """ + SELECT e.id, e.known_object_id, e.embedding, ko.name + FROM embeddings e + JOIN known_objects ko ON e.known_object_id = ko.id + """ + ) + rows = cursor.fetchall() + + if not rows: + return (None, 0.0, None) + + query_vec = np.array(query_embedding, dtype=np.float32) + query_norm = np.linalg.norm(query_vec) + if query_norm == 0: + return (None, 0.0, None) + query_vec = query_vec / query_norm + + # Group scores by known_object_id, keeping max per object + best_per_object: dict[int, tuple[float, str]] = {} + + for row in rows: + stored_vec = bytes_to_embedding(row["embedding"]) + stored_norm = np.linalg.norm(stored_vec) + if stored_norm == 0: + continue + stored_vec = stored_vec / stored_norm + + similarity = float(np.dot(query_vec, stored_vec)) + obj_id = row["known_object_id"] + obj_name = row["name"] + + if obj_id not in best_per_object or similarity > best_per_object[obj_id][0]: + best_per_object[obj_id] = (similarity, obj_name) + + if not best_per_object: + return (None, 0.0, None) + + best_id = max(best_per_object, key=lambda k: best_per_object[k][0]) + best_score, best_name = best_per_object[best_id] + + return (best_id, best_score, best_name) + + +async def recognize_event( + image_bytes: bytes, + ollama_client: Optional[OllamaClient] = None, +) -> tuple[str, int | None, float, str | None]: + """Full recognition pipeline for a Frigate event image. + + Steps: + 1. Get CLIP embedding for the image. + 2. Find the best matching known object. + 3. High confidence (>= threshold_high): return "known". + 4. Low confidence (< threshold_low): return "unknown". + 5. Ambiguous range: use Ollama vision LLM to confirm/deny. + 6. If LLM unavailable or inconclusive: return "ambiguous". + 7. If no embedding could be obtained: return "unreviewed". + + Args: + image_bytes: Raw image bytes (JPEG). + ollama_client: Optional OllamaClient for ambiguous case fallback. + + Returns: + Tuple of (match_status, object_id, score, object_name). + match_status is one of: "known", "unknown", "ambiguous", "unreviewed". + """ + # Step 1: Get CLIP embedding + embedding = await get_clip_embedding(image_bytes) + if embedding is None: + logger.warning("Could not get CLIP embedding, returning unreviewed") + return ("unreviewed", None, 0.0, None) + + # Step 2: Find best match + best_id, best_score, best_name = await find_best_match(embedding) + + # No embeddings in DB at all + if best_id is None: + return ("unknown", None, 0.0, None) + + high_threshold = config.RECOGNITION_THRESHOLD_HIGH + low_threshold = config.RECOGNITION_THRESHOLD_LOW + + # Step 3: High confidence match + if best_score >= high_threshold: + logger.info( + "High confidence match: %s (%.3f)", best_name, best_score + ) + return ("known", best_id, best_score, best_name) + + # Step 4: Low confidence - clearly not a match + if best_score < low_threshold: + logger.info("Low confidence, no match (%.3f)", best_score) + return ("unknown", None, best_score, None) + + # Step 5: Ambiguous range - try Ollama vision LLM + if ollama_client is not None: + logger.info( + "Ambiguous score %.3f for %s, trying Ollama comparison", + best_score, + best_name, + ) + # Get a reference thumbnail from the embeddings table + conn = get_connection() + cursor = conn.execute( + """ + SELECT thumbnail FROM embeddings + WHERE known_object_id = ? AND thumbnail IS NOT NULL + LIMIT 1 + """, + (best_id,), + ) + ref_row = cursor.fetchone() + if ref_row and ref_row["thumbnail"] is not None: + result = await ollama_client.compare_images( + image_bytes, ref_row["thumbnail"], best_name + ) + if result is True: + logger.info( + "Ollama confirmed match: %s (%.3f)", + best_name, + best_score, + ) + return ("known", best_id, best_score, best_name) + elif result is False: + logger.info( + "Ollama denied match: %s (%.3f)", best_name, best_score + ) + return ("unknown", None, best_score, None) + # result is None: inconclusive, fall through to ambiguous + + # Step 6: LLM unavailable or inconclusive + logger.info( + "Ambiguous match: %s (%.3f)", best_name, best_score + ) + return ("ambiguous", best_id, best_score, best_name) diff --git a/compose/hf-pdocker-01/frigate-notify/app/requirements.txt b/compose/hf-pdocker-01/frigate-notify/app/requirements.txt index 8991258..4806b8f 100644 --- a/compose/hf-pdocker-01/frigate-notify/app/requirements.txt +++ b/compose/hf-pdocker-01/frigate-notify/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/compose/hf-pdocker-01/frigate-notify/app/routes/__init__.py b/compose/hf-pdocker-01/frigate-notify/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/compose/hf-pdocker-01/frigate-notify/app/routes/events.py b/compose/hf-pdocker-01/frigate-notify/app/routes/events.py new file mode 100644 index 0000000..25dad77 --- /dev/null +++ b/compose/hf-pdocker-01/frigate-notify/app/routes/events.py @@ -0,0 +1,226 @@ +"""Event API endpoints for frigate-notify v2.""" + +import base64 +import logging +from typing import Optional + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import Response + +from database import get_connection +from models import EventResponse, TagEventRequest + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/events", tags=["events"]) + + +@router.get("", response_model=list[EventResponse]) +async def list_events( + camera: Optional[str] = Query(default=None), + label: Optional[str] = Query(default=None), + status: Optional[str] = Query(default=None), + limit: int = Query(default=50, ge=1, le=500), + offset: int = Query(default=0, ge=0), +): + """List events with optional filters and pagination. + + Returns events without snapshot/embedding BLOBs but with has_snapshot bool. + Joins to known_objects for matched_object_name. + """ + conn = get_connection() + + query = """ + SELECT + e.id, e.frigate_event_id, e.camera, e.label, e.score, + CASE WHEN e.snapshot IS NOT NULL THEN 1 ELSE 0 END AS has_snapshot, + e.match_status, e.matched_object_id, + ko.name AS matched_object_name, + e.match_confidence, e.ai_description, e.notified, e.created_at + FROM events e + LEFT JOIN known_objects ko ON e.matched_object_id = ko.id + """ + conditions = [] + params: list = [] + + if camera: + conditions.append("e.camera = ?") + params.append(camera) + if label: + conditions.append("e.label = ?") + params.append(label) + if status: + conditions.append("e.match_status = ?") + params.append(status) + + if conditions: + query += " WHERE " + " AND ".join(conditions) + + query += " ORDER BY e.created_at DESC LIMIT ? OFFSET ?" + params.extend([limit, offset]) + + cursor = conn.execute(query, params) + rows = cursor.fetchall() + + return [ + EventResponse( + id=row["id"], + frigate_event_id=row["frigate_event_id"], + camera=row["camera"], + label=row["label"], + score=row["score"], + has_snapshot=bool(row["has_snapshot"]), + match_status=row["match_status"], + matched_object_id=row["matched_object_id"], + matched_object_name=row["matched_object_name"], + match_confidence=row["match_confidence"], + ai_description=row["ai_description"], + notified=bool(row["notified"]), + created_at=row["created_at"], + ) + for row in rows + ] + + +@router.get("/{event_id}", response_model=EventResponse) +async def get_event(event_id: int): + """Get a single event detail. Includes base64-encoded snapshot if exists.""" + conn = get_connection() + cursor = conn.execute( + """ + SELECT + e.id, e.frigate_event_id, e.camera, e.label, e.score, + e.snapshot, + CASE WHEN e.snapshot IS NOT NULL THEN 1 ELSE 0 END AS has_snapshot, + e.match_status, e.matched_object_id, + ko.name AS matched_object_name, + e.match_confidence, e.ai_description, e.notified, e.created_at + FROM events e + LEFT JOIN known_objects ko ON e.matched_object_id = ko.id + WHERE e.id = ? + """, + (event_id,), + ) + row = cursor.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Event not found") + + snapshot_b64 = None + if row["snapshot"] is not None: + snapshot_b64 = base64.b64encode(row["snapshot"]).decode("ascii") + + return EventResponse( + id=row["id"], + frigate_event_id=row["frigate_event_id"], + camera=row["camera"], + label=row["label"], + score=row["score"], + has_snapshot=bool(row["has_snapshot"]), + snapshot_base64=snapshot_b64, + match_status=row["match_status"], + matched_object_id=row["matched_object_id"], + matched_object_name=row["matched_object_name"], + match_confidence=row["match_confidence"], + ai_description=row["ai_description"], + notified=bool(row["notified"]), + created_at=row["created_at"], + ) + + +@router.get("/{event_id}/snapshot") +async def get_event_snapshot(event_id: int): + """Return raw JPEG snapshot for an event. 404 if no snapshot.""" + conn = get_connection() + cursor = conn.execute( + "SELECT snapshot FROM events WHERE id = ?", + (event_id,), + ) + row = cursor.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Event not found") + if row["snapshot"] is None: + raise HTTPException(status_code=404, detail="No snapshot available") + + return Response(content=row["snapshot"], media_type="image/jpeg") + + +@router.post("/{event_id}/tag") +async def tag_event(event_id: int, body: TagEventRequest): + """Tag an event as a known object. + + Either provide known_object_id to link to an existing object, + or provide name + type to create a new known object. + If the event has an embedding, store it in the embeddings table. + """ + conn = get_connection() + + # Verify event exists + cursor = conn.execute( + "SELECT id, embedding FROM events WHERE id = ?", + (event_id,), + ) + event_row = cursor.fetchone() + if not event_row: + raise HTTPException(status_code=404, detail="Event not found") + + known_object_id = body.known_object_id + + if known_object_id is not None: + # Verify the known object exists + cursor = conn.execute( + "SELECT id FROM known_objects WHERE id = ?", + (known_object_id,), + ) + if not cursor.fetchone(): + raise HTTPException(status_code=404, detail="Known object not found") + elif body.name and body.type: + # Create a new known object + cursor = conn.execute( + "INSERT INTO known_objects (name, type) VALUES (?, ?)", + (body.name, body.type), + ) + known_object_id = cursor.lastrowid + conn.commit() + else: + raise HTTPException( + status_code=400, + detail="Must provide either known_object_id or both name and type", + ) + + # If event has an embedding, store it in the embeddings table + if event_row["embedding"] is not None: + conn.execute( + "INSERT INTO embeddings (known_object_id, embedding) VALUES (?, ?)", + (known_object_id, event_row["embedding"]), + ) + + # Update event + conn.execute( + """UPDATE events + SET match_status = 'known', + matched_object_id = ?, + match_confidence = 1.0 + WHERE id = ?""", + (known_object_id, event_id), + ) + conn.commit() + + return {"status": "ok", "matched_object_id": known_object_id} + + +@router.post("/{event_id}/dismiss") +async def dismiss_event(event_id: int): + """Set event match_status to 'unknown'.""" + conn = get_connection() + + cursor = conn.execute("SELECT id FROM events WHERE id = ?", (event_id,)) + if not cursor.fetchone(): + raise HTTPException(status_code=404, detail="Event not found") + + conn.execute( + "UPDATE events SET match_status = 'unknown' WHERE id = ?", + (event_id,), + ) + conn.commit() + + return {"status": "ok"} diff --git a/compose/hf-pdocker-01/frigate-notify/app/routes/known_objects.py b/compose/hf-pdocker-01/frigate-notify/app/routes/known_objects.py new file mode 100644 index 0000000..c6fced4 --- /dev/null +++ b/compose/hf-pdocker-01/frigate-notify/app/routes/known_objects.py @@ -0,0 +1,177 @@ +"""Known objects API endpoints for frigate-notify v2.""" + +import logging +from typing import Optional + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import Response + +from database import get_connection +from models import KnownObjectResponse, KnownObjectUpdate + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/known-objects", tags=["known-objects"]) + + +@router.get("") +async def list_known_objects( + type: Optional[str] = Query(default=None, pattern="^(person|vehicle|pet)$"), +): + """List all known objects with embedding_count. Optional type filter.""" + conn = get_connection() + + query = """ + SELECT + ko.id, ko.name, ko.type, ko.action, + ko.created_at, ko.updated_at, + COUNT(e.id) AS embedding_count + FROM known_objects ko + LEFT JOIN embeddings e ON ko.id = e.known_object_id + """ + params: list = [] + + if type: + query += " WHERE ko.type = ?" + params.append(type) + + query += " GROUP BY ko.id ORDER BY ko.name" + + cursor = conn.execute(query, params) + rows = cursor.fetchall() + + return [ + { + "id": row["id"], + "name": row["name"], + "type": row["type"], + "action": row["action"], + "created_at": row["created_at"], + "updated_at": row["updated_at"], + "embedding_count": row["embedding_count"], + } + for row in rows + ] + + +@router.get("/{object_id}") +async def get_known_object(object_id: int): + """Get a single known object with list of its embeddings (id, created_at only).""" + conn = get_connection() + + cursor = conn.execute( + "SELECT id, name, type, action, created_at, updated_at FROM known_objects WHERE id = ?", + (object_id,), + ) + row = cursor.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Known object not found") + + # Get embeddings list + emb_cursor = conn.execute( + "SELECT id, created_at FROM embeddings WHERE known_object_id = ? ORDER BY created_at", + (object_id,), + ) + embeddings = [ + {"id": e["id"], "created_at": e["created_at"]} for e in emb_cursor.fetchall() + ] + + return { + "id": row["id"], + "name": row["name"], + "type": row["type"], + "action": row["action"], + "created_at": row["created_at"], + "updated_at": row["updated_at"], + "embeddings": embeddings, + } + + +@router.get("/{object_id}/thumbnails/{embedding_id}") +async def get_embedding_thumbnail(object_id: int, embedding_id: int): + """Return raw JPEG thumbnail from embeddings table.""" + conn = get_connection() + + cursor = conn.execute( + "SELECT thumbnail FROM embeddings WHERE id = ? AND known_object_id = ?", + (embedding_id, object_id), + ) + row = cursor.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Embedding not found") + if row["thumbnail"] is None: + raise HTTPException(status_code=404, detail="No thumbnail available") + + return Response(content=row["thumbnail"], media_type="image/jpeg") + + +@router.put("/{object_id}") +async def update_known_object(object_id: int, body: KnownObjectUpdate): + """Update name/type/action for a known object. Sets updated_at.""" + conn = get_connection() + + cursor = conn.execute( + "SELECT id FROM known_objects WHERE id = ?", + (object_id,), + ) + if not cursor.fetchone(): + raise HTTPException(status_code=404, detail="Known object not found") + + updates = [] + params: list = [] + + if body.name is not None: + updates.append("name = ?") + params.append(body.name) + if body.type is not None: + updates.append("type = ?") + params.append(body.type) + if body.action is not None: + updates.append("action = ?") + params.append(body.action) + + if not updates: + raise HTTPException(status_code=400, detail="No fields to update") + + updates.append("updated_at = CURRENT_TIMESTAMP") + params.append(object_id) + + conn.execute( + f"UPDATE known_objects SET {', '.join(updates)} WHERE id = ?", + params, + ) + conn.commit() + + # Return the updated object + cursor = conn.execute( + "SELECT id, name, type, action, created_at, updated_at FROM known_objects WHERE id = ?", + (object_id,), + ) + row = cursor.fetchone() + + return KnownObjectResponse( + id=row["id"], + name=row["name"], + type=row["type"], + action=row["action"], + created_at=row["created_at"], + updated_at=row["updated_at"], + ) + + +@router.delete("/{object_id}") +async def delete_known_object(object_id: int): + """Delete a known object. Embeddings cascade delete automatically.""" + conn = get_connection() + + cursor = conn.execute( + "SELECT id FROM known_objects WHERE id = ?", + (object_id,), + ) + if not cursor.fetchone(): + raise HTTPException(status_code=404, detail="Known object not found") + + conn.execute("DELETE FROM known_objects WHERE id = ?", (object_id,)) + conn.commit() + + return {"status": "ok"} diff --git a/compose/hf-pdocker-01/frigate-notify/app/routes/settings.py b/compose/hf-pdocker-01/frigate-notify/app/routes/settings.py new file mode 100644 index 0000000..7a0841c --- /dev/null +++ b/compose/hf-pdocker-01/frigate-notify/app/routes/settings.py @@ -0,0 +1,78 @@ +"""Settings and stats API endpoints for frigate-notify v2.""" + +import logging +from datetime import date + +from fastapi import APIRouter + +from config import config +from database import get_connection +from models import StatsResponse + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api", tags=["settings"]) + + +@router.get("/settings") +async def get_settings(): + """Return all settings from DB plus runtime config values.""" + conn = get_connection() + cursor = conn.execute("SELECT key, value FROM settings") + db_settings = {row["key"]: row["value"] for row in cursor.fetchall()} + + # Merge with runtime config values + return { + **db_settings, + "recognition_threshold_high": config.RECOGNITION_THRESHOLD_HIGH, + "recognition_threshold_low": config.RECOGNITION_THRESHOLD_LOW, + "rate_limit_seconds": config.RATE_LIMIT_SECONDS, + "allowed_labels": list(config.ALLOWED_LABELS), + "min_score": config.MIN_SCORE, + } + + +@router.put("/settings") +async def update_settings(body: dict): + """Accept dict body, upsert each key/value into settings table.""" + conn = get_connection() + for key, value in body.items(): + conn.execute( + "INSERT INTO settings (key, value) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value", + (key, str(value)), + ) + conn.commit() + return {"status": "ok"} + + +@router.get("/stats", response_model=StatsResponse) +async def get_stats(): + """Return event and object counts.""" + conn = get_connection() + + total = conn.execute("SELECT COUNT(*) FROM events").fetchone()[0] + known = conn.execute( + "SELECT COUNT(*) FROM events WHERE match_status = 'known'" + ).fetchone()[0] + unknown = conn.execute( + "SELECT COUNT(*) FROM events WHERE match_status = 'unknown'" + ).fetchone()[0] + unreviewed = conn.execute( + "SELECT COUNT(*) FROM events WHERE match_status = 'unreviewed'" + ).fetchone()[0] + objects_count = conn.execute("SELECT COUNT(*) FROM known_objects").fetchone()[0] + today = date.today().isoformat() + events_today = conn.execute( + "SELECT COUNT(*) FROM events WHERE DATE(created_at) = ?", + (today,), + ).fetchone()[0] + + return StatsResponse( + total_events=total, + known_matches=known, + unknown_events=unknown, + unreviewed=unreviewed, + known_objects_count=objects_count, + events_today=events_today, + ) diff --git a/compose/hf-pdocker-01/frigate-notify/app/static/app.js b/compose/hf-pdocker-01/frigate-notify/app/static/app.js new file mode 100644 index 0000000..5805e79 --- /dev/null +++ b/compose/hf-pdocker-01/frigate-notify/app/static/app.js @@ -0,0 +1,578 @@ +// Frigate Alerts - Web UI +(function () { + 'use strict'; + + let currentView = 'dashboard'; + let currentTagEventId = null; + let refreshTimer = null; + let knownObjectsCache = []; + + // --- Initialization --- + + document.addEventListener('DOMContentLoaded', () => { + setupNavigation(); + setupTagModal(); + loadView('dashboard'); + loadStats(); + }); + + // --- Navigation --- + + function setupNavigation() { + document.querySelectorAll('.sidebar-item').forEach(item => { + item.addEventListener('click', (e) => { + e.preventDefault(); + const view = item.dataset.view; + loadView(view); + }); + }); + } + + function setActiveNav(view) { + document.querySelectorAll('.sidebar-item').forEach(item => { + item.classList.toggle('active', item.dataset.view === view); + }); + } + + // --- View Router --- + + function loadView(view) { + currentView = view; + setActiveNav(view); + clearAutoRefresh(); + + switch (view) { + case 'dashboard': + loadDashboard(); + startAutoRefresh(); + break; + case 'review': + loadReview(); + startAutoRefresh(); + break; + case 'known': + loadKnown(); + break; + case 'settings': + loadSettings(); + break; + } + } + + // --- Auto Refresh --- + + function startAutoRefresh() { + refreshTimer = setInterval(() => { + if (currentView === 'dashboard') { + loadDashboard(); + } else if (currentView === 'review') { + loadReview(); + } + loadStats(); + }, 30000); + } + + function clearAutoRefresh() { + if (refreshTimer) { + clearInterval(refreshTimer); + refreshTimer = null; + } + } + + // --- Dashboard View --- + + function loadDashboard() { + const content = document.getElementById('main-content'); + content.innerHTML = '
No events recorded yet.
+Events will appear here when Frigate detects objects.
+Error loading events: ${err.message}
No events match the current filters.
No events to review.
+All events have been reviewed or dismissed.
+Error loading events: ${err.message}
No known objects registered.
+Tag events from the Dashboard to add known objects.
+Error loading objects: ${err.message}
Error loading settings: ${err.message}