My garden CCTV system started with a simple loop: Frigate detects movement, a Python script sends a Telegram notification, and I can look at a snapshot or short clip from my phone.

That was good enough for real-time alerts. It was not good enough for history.

Telegram is a terrible archive browser. It is excellent at interrupting me when something happens, but bad at answering questions like:

  • What happened in the last two hours?
  • Did I miss a person detection yesterday?
  • Which clips exist for this camera?
  • Can I open the full event without digging through chat history?

So the next small project was obvious: keep Telegram as the notification surface, but build a proper CCTV history portal on the Raspberry Pi itself.

This post documents the first working version.

Why Host It on the Pi?

The CCTV stack already runs on a Raspberry Pi 5:

  • camera feed
  • Frigate
  • MQTT
  • Telegram notification script
  • video storage

Putting the portal somewhere else would create work for no real benefit. A cloud-hosted portal would need video uploads, sync logic, authentication, and another failure mode. The clips already live on the Pi. Frigate already has an API. The browser only needs a thin layer on top.

The MVP architecture is deliberately boring:

Browser / phone
    ↓
CCTV history portal on raspi5 :8097
    ↓
Frigate API on 127.0.0.1:5000
    ↓
Frigate event clips and snapshots

Telegram stays in the loop, but only as a launcher:

Frigate MQTT event
    ↓
frigate-notify.py
    ↓
Telegram alert with buttons
    ↓
Open event / Clip
    ↓
CCTV portal on the Pi

That split is important. Telegram should tell me something happened. The web portal should help me browse, filter, and play back history.

Finding the Existing Data Source

The useful thing about Frigate is that it already knows about events. There was no need to create a new database for the first version.

On the Pi, Frigate exposes events through its local API:

curl -fsS "http://127.0.0.1:5000/api/events?limit=3"

That response includes the pieces the portal needs:

  • event id
  • camera name
  • label
  • start and end timestamps
  • whether a snapshot exists
  • whether a clip exists
  • zones and score metadata

Clips are also available directly:

curl -I "http://127.0.0.1:5000/api/events/<event-id>/clip.mp4"

So the portal does not own the archive. Frigate remains the source of truth.

Building the Smallest Useful Portal

I first checked what Python dependencies were already available on the Pi. jinja2 was installed, but fastapi, uvicorn, and httpx were not.

For an MVP, adding a framework felt unnecessary. The portal only needed:

  • one timeline page
  • one event detail page
  • a snapshot proxy
  • a clip proxy
  • a health check endpoint

So I built it with Python’s standard library: http.server, urllib, and a small amount of HTML rendering.

The first version exposes:

GET /                              timeline
GET /event/<id>                    event detail page
GET /media/<id>/snapshot.jpg       Frigate snapshot proxy
GET /media/<id>/clip.mp4           Frigate clip proxy
GET /healthz                       health check

The timeline has simple filters:

  • last 2 hours
  • last 6 hours
  • today
  • yesterday
  • all
  • camera
  • label
  • search text

That is enough for the job I actually need: open a browser on the home network and quickly answer “what happened recently?”

Installing It as a Service

The portal lives on the Pi at:

/home/timur/cctv-history-portal/portal.py

I installed it with a systemd unit:

sudo install -d -o timur -g timur /home/timur/cctv-history-portal
sudo install -m 0755 -o timur -g timur /tmp/portal.py /home/timur/cctv-history-portal/portal.py
sudo install -m 0644 -o root -g root /tmp/cctv-history-portal.service /etc/systemd/system/cctv-history-portal.service
sudo systemctl daemon-reload
sudo systemctl enable --now cctv-history-portal.service

The service listens on port 8097:

http://192.168.0.23:8097/

For now this is LAN-only. I deliberately did not punch a public hole into the Pi. The right next step is to put it behind an OpenClaw-authenticated route, not expose a home CCTV archive directly to the internet.

Telegram as the Remote Control

The existing notification bot already posted Frigate alerts into my Telegram group. I changed it so new notifications include an inline keyboard:

def event_keyboard(event_id):
    if not event_id:
        return None
    return {
        "inline_keyboard": [[
            {"text": "Open event", "url": f"{PORTAL_URL}/event/{event_id}"},
            {"text": "Clip", "url": f"{PORTAL_URL}/media/{event_id}/clip.mp4"},
        ]]
    }

The result is simple:

  • Open event opens the event page in the portal
  • Clip opens the MP4 directly

This is much better than trying to make Telegram itself into the archive UI. Telegram does what it is good at: timely notifications. The browser does what it is good at: navigation and playback.

One caveat: old alerts do not magically get buttons. Only new notifications sent after the bot update include the portal links.

The iPhone Playback Bug

The first version looked fine on desktop and still failed on my phone.

The portal rendered the event page. The video element was there. The MP4 endpoint returned data. But mobile Safari would not play it.

The bug was in the clip proxy.

The first implementation streamed the MP4 from Frigate to the browser, but it did not behave like a real static MP4 endpoint. Safari expects byte-range support for video playback. If the browser asks for part of a file, the server needs to answer with 206 Partial Content, not just dump the whole file as a stream.

The fix was to make the portal’s clip route support:

  • HTTP/1.1
  • Accept-Ranges: bytes
  • Range requests from the browser
  • 206 Partial Content
  • correct Content-Range
  • correct Content-Length
  • HEAD requests for media probes

The verification command looked like this:

curl --http1.1 -D - -o /tmp/range-body \
  -H "Range: bytes=0-1023" \
  "http://127.0.0.1:8097/media/<event-id>/clip.mp4"

And the important response was:

HTTP/1.1 206 Partial Content
Content-Range: bytes 0-1023/1079008
Content-Length: 1024
Accept-Ranges: bytes

After that, the clip endpoint behaved like something a mobile browser could trust.

This is the kind of bug that makes “just proxy the file” slightly less true than it sounds.

Verification

After deployment I checked:

systemctl is-active cctv-history-portal.service
curl -fsS http://127.0.0.1:8097/healthz
curl -fsS http://127.0.0.1:8097/
curl -fsS http://127.0.0.1:8097/event/<event-id>
curl -fsS -o /tmp/portal-snapshot.jpg http://127.0.0.1:8097/media/<event-id>/snapshot.jpg
curl --http1.1 -r 0-1023 -o /tmp/portal-clip.part http://127.0.0.1:8097/media/<event-id>/clip.mp4

The service was active, the health check returned ok, the timeline rendered, the event page rendered, snapshots came back as real JPEGs, and clips supported byte-range playback.

The Telegram notification bot was also restarted and reconnected to MQTT after the inline-button change.

What I Did Not Build Yet

There is no separate database in this version.

That is intentional. A database becomes useful when the portal needs state that Frigate does not have:

  • starred clips
  • hidden clips
  • notes
  • richer search
  • joins back to Telegram message ids
  • manual classifications

Until then, Frigate’s API is enough.

There is also no remote access yet. This is a local home-network tool. Remote access needs authentication first.

The Useful Pattern

The pattern here is broader than CCTV:

  1. Let the existing system remain the source of truth.
  2. Add a very thin browsing layer close to the data.
  3. Keep chat as the notification and command surface.
  4. Move real inspection into a web UI.

That is a good shape for personal automation.

Chat is brilliant for interruption. It is mediocre for browsing. A tiny local web app can fill that gap without turning the whole project into a platform.

This CCTV portal is not finished, but it is already useful: Frigate keeps recording, Telegram keeps alerting, and now there is a proper place to browse the history.