Files
Jeremy Hobbs 6086ed3cc0 Initial commit: frigate-notify application
AI-powered video alert service that bridges Frigate NVR events to Matrix.
Listens to MQTT, uses Claude Haiku vision to analyze snapshots, and posts
annotated alerts to a Matrix room.

- app/main.py: MQTT subscriber and event orchestration loop
- app/vision.py: Claude Haiku vision analysis wrapper
- app/matrix_client.py: Matrix HTTP client for sending messages/images
- app/requirements.txt: Python dependencies
- Dockerfile: container image build definition
- docker-compose.yml: deployment configuration (host network, NAS mounts)
- .env.example: required environment variable template (copy to .env)
2026-03-07 14:30:57 -06:00

85 lines
3.2 KiB
Python
Executable File

import asyncio
import logging
import time
import aiohttp
logger = logging.getLogger(__name__)
RETRY_DELAYS = [1, 2, 4]
class MatrixClient:
def __init__(self, homeserver: str, token: str, room_id: str):
self.homeserver = homeserver.rstrip("/")
self.token = token
self.room_id = room_id
self.headers = {"Authorization": f"Bearer {token}"}
async def _request(self, method: str, url: str, **kwargs) -> dict | None:
for attempt, delay in enumerate(RETRY_DELAYS):
try:
async with aiohttp.ClientSession() as session:
async with session.request(
method, url, headers=self.headers, **kwargs
) as resp:
if resp.status < 300:
return await resp.json()
body = await resp.text()
logger.warning(
"Matrix API %s (attempt %d): %s",
resp.status,
attempt + 1,
body,
)
except Exception:
logger.exception("Matrix request failed (attempt %d)", attempt + 1)
if attempt < len(RETRY_DELAYS) - 1:
await asyncio.sleep(delay)
logger.error("All Matrix retries exhausted for %s", url)
return None
async def upload_image(self, image_bytes: bytes, filename: str) -> str | None:
url = f"{self.homeserver}/_matrix/media/v3/upload?filename={filename}"
headers = {**self.headers, "Content-Type": "image/jpeg"}
try:
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, data=image_bytes) as resp:
if resp.status < 300:
data = await resp.json()
mxc_uri = data.get("content_uri")
logger.info("Uploaded image: %s", mxc_uri)
return mxc_uri
body = await resp.text()
logger.error("Image upload failed %s: %s", resp.status, body)
except Exception:
logger.exception("Image upload failed")
return None
async def send_image(self, mxc_uri: str, filename: str, size: int) -> dict | None:
txn_id = str(int(time.time() * 1000))
url = (
f"{self.homeserver}/_matrix/client/r0/rooms/"
f"{self.room_id}/send/m.room.message/{txn_id}"
)
body = {
"msgtype": "m.image",
"body": filename,
"url": mxc_uri,
"info": {"mimetype": "image/jpeg", "size": size},
}
return await self._request("PUT", url, json=body)
async def send_text(self, plain: str, html: str) -> dict | None:
txn_id = str(int(time.time() * 1000) + 1)
url = (
f"{self.homeserver}/_matrix/client/r0/rooms/"
f"{self.room_id}/send/m.room.message/{txn_id}"
)
body = {
"msgtype": "m.text",
"body": plain,
"format": "org.matrix.custom.html",
"formatted_body": html,
}
return await self._request("PUT", url, json=body)