commit 5d7df0c0c85da6e273e97eb6544d5631a676f274 Author: Outis Date: Sat Apr 25 11:06:10 2026 -0400 Initial home camera stack diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c604b50 --- /dev/null +++ b/.env.example @@ -0,0 +1,36 @@ +# Copy to .env and fill in real values. DO NOT commit .env to git. +# +# Env var prefixes: +# FRIGATE_ - referenced via {FRIGATE_*} substitution in frigate_config/config.yml +# FN_ - read directly by frigate-notify (Viper). Note the DOUBLE underscore +# between YAML hierarchy levels: frigate.mqtt.password -> FN_FRIGATE__MQTT__PASSWORD + +# ---- Camera credentials ---- +FRIGATE_RTSP_USER=admin +FRIGATE_RTSP_PASSWORD=pick_a_url_safe_password +FRIGATE_FRONT_DOOR_IP=192.168.1.100 + +FRIGATE_RTSP_USER1=admin +FRIGATE_RTSP_PASSWORD1=changeme +FRIGATE_BACK_DOOR_IP=192.168.1.101 + +FRIGATE_RTSP_USER2=admin +FRIGATE_RTSP_PASSWORD2=changeme +FRIGATE_SQUIRREL_IP=192.168.1.102 + +# Future Anpviz camera with mic +# FRIGATE_RTSP_USER3=admin +# FRIGATE_RTSP_PASSWORD3=changeme +# FRIGATE_ANPVIZ_IP=192.168.1.103 + +# ---- MQTT broker ---- +# Used by Frigate via {FRIGATE_MQTT_*} substitution +FRIGATE_MQTT_USER=frigate +FRIGATE_MQTT_PASSWORD=pick_a_strong_mqtt_password + +# ---- frigate-notify ---- +# Same MQTT password as above. Note double underscores between YAML levels. +FN_FRIGATE__MQTT__PASSWORD=same_value_as_FRIGATE_MQTT_PASSWORD +FN_FRIGATE__SERVER=http://frigate:5000 +FN_FRIGATE__PUBLIC_URL=https://cam.yourdomain.com +FN_ALERTS__NTFY__SERVER=https://ntfy.yourdomain.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..73a1631 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Secrets & credentials +.env + +# Mosquitto runtime state +mosquitto/config/passwd +mosquitto/data/* +mosquitto/log/* +!mosquitto/data/.gitkeep +!mosquitto/log/.gitkeep + +# Frigate runtime state +frigate_config/frigate.db* +frigate_config/model_cache/ +frigate_config/backup.db +frigate_config/backup_config.yaml +frigate_config/*.bak +frigate_config/config.yaml-b4-* +frigate_config/*.16 +frigate_config/*.old + +# Frigate media (way too big for git; lives on a separate volume) +media/ +frigate_media/ + +# Editor / OS junk +*.swp +*~ +.DS_Store +.vscode/ +.idea/ + +# Archives +*.tar.gz diff --git a/README.md b/README.md new file mode 100644 index 0000000..ad85440 --- /dev/null +++ b/README.md @@ -0,0 +1,273 @@ +# Home camera stack + +Frigate NVR + Mosquitto MQTT + frigate-notify -> ntfy push notifications, +with face recognition and license plate recognition on the Frigate side. +Includes a planned Pi Zero W "doorbell speaker" stack (push-to-talk web +page fronted by Caddy) that's wired but not yet deployed. + +## Architecture + +``` + Cameras (RTSP) Phone / browser + | | + v v + +------------+ MQTT events +----------------+ +-------------+ + | Frigate | <-------------> | Mosquitto | | Pi | + | (NVR) | | broker | | (planned) | + +-----+------+ +-------+--------+ | speaker | + | WebRTC / MSE | +------+------+ + v v ^ + +------------+ +------------------+ | + | Caddy |<--HTTPS--------| frigate-notify | | + | proxy | | (event consumer) | | + +------------+ +--------+---------+ | + | | | + | cam.yourdomain.com ntfy push | + | doorbell.yourdomain.com | + +-------HTTPS---------------> PTT button ------------+ +``` + +## What's deployed vs planned + +| Component | Status | +|---|---| +| Frigate 0.17 | Deployed | +| Mosquitto MQTT broker | Deployed | +| frigate-notify -> ntfy | Deployed | +| Face recognition | Configured, needs training | +| License plate recognition | Configured | +| Caddy reverse proxy | Whatever your existing Caddy does | +| Pi Zero W doorbell speaker | Planned, not deployed yet | + +## Repo layout + +``` +home-cameras/ +|-- docker-compose.yml # frigate + mosquitto + frigate-notify +|-- .env.example # template -- copy to .env, fill in +|-- .gitignore +|-- README.md # this file +| +|-- frigate_config/ +| |-- config.yml # production: main stream for detect +| `-- alternatives/ +| `-- config-simple.yml # lower-CPU fallback: sub-stream detect +| +|-- frigate-notify/ +| `-- config.yml # MQTT in, ntfy out, face-aware templates +| +|-- mosquitto/ +| |-- config/ +| | `-- mosquitto.conf # broker config (allow_anonymous false) +| |-- data/.gitkeep +| `-- log/.gitkeep +| +|-- caddy/ +| `-- Caddyfile # reverse proxy for both subdomains +| +`-- pi/ # runs on the Pi, NOT on the Frigate host + |-- README.md # Pi-specific setup + |-- server.py # Flask + WebSocket PTT receiver + |-- doorbell.service # systemd unit + `-- install.sh # one-shot installer +``` + +## First-run on the Frigate host + +Prerequisites: +- Docker + docker compose v2 +- DNS records for any subdomains you intend to use, pointing at your Caddy + host +- An existing Caddy instance (separate from this stack) handling TLS at the + edge, OR adapt for whatever reverse proxy you use +- Coral USB stick plugged in (or adjust `detectors:` for a different accel) +- A media disk mounted on the host; update the `/media/frigate` path in + `docker-compose.yml` + +Steps: + +```bash +git clone https://github.com/YOU/home-cameras.git +cd home-cameras + +# 1) Configure secrets +cp .env.example .env +$EDITOR .env + +# 2) Make mosquitto dirs writable by the container's mosquitto user (UID 1883) +sudo chown -R 1883:1883 mosquitto/ + +# 3) Bootstrap mosquitto BEFORE applying the production config. +# The committed mosquitto.conf has allow_anonymous false + password_file, +# which means we need to create the password file first OR temporarily +# flip to allow_anonymous true to start. +# +# Easiest: temporarily edit mosquitto/config/mosquitto.conf: +# allow_anonymous false -> allow_anonymous true +# comment out: password_file /mosquitto/config/passwd +# Then start: +docker compose up -d mosquitto +docker compose logs mosquitto --tail 10 # expect "running" + +# 4) Create the MQTT user (use the password from your .env) +docker compose exec mosquitto mosquitto_passwd -c -b \ + /mosquitto/config/passwd frigate \ + "$(grep ^FRIGATE_MQTT_PASSWORD .env | cut -d= -f2)" + +sudo chown 1883:1883 mosquitto/config/passwd +sudo chmod 0640 mosquitto/config/passwd + +# 5) Restore mosquitto.conf to its committed state: +# allow_anonymous true -> allow_anonymous false +# uncomment: password_file /mosquitto/config/passwd +git checkout mosquitto/config/mosquitto.conf +docker compose restart mosquitto + +# 6) Verify auth works +sudo apt install -y mosquitto-clients +mosquitto_sub -h 127.0.0.1 -u frigate \ + -P "$(grep ^FRIGATE_MQTT_PASSWORD .env | cut -d= -f2)" \ + -t 'test/#' -v & +mosquitto_pub -h 127.0.0.1 -u frigate \ + -P "$(grep ^FRIGATE_MQTT_PASSWORD .env | cut -d= -f2)" \ + -t 'test/hello' -m 'ok' +# expect: test/hello ok +kill %1 + +# 7) Bring up the rest +docker compose up -d +docker compose logs -f +``` + +Healthy startup looks like: +- Frigate: `frigate.comms.mqtt INFO : MQTT connected` +- frigate-notify: `Successfully connected to http://frigate:5000` then + `Connected to MQTT.` then `Subscribed to MQTT topic: frigate/events` +- mosquitto: incoming client connections from both + +## Caddy + +On whichever host runs Caddy, copy `caddy/Caddyfile` (or merge the relevant +site blocks into your existing one), edit IPs and domains, then: + +```bash +sudo caddy validate --config /etc/caddy/Caddyfile +sudo systemctl reload caddy +``` + +Until the Pi is deployed, comment out the `doorbell.yourdomain.com` block. + +## Train face recognition + +1. Let Frigate run with normal foot traffic for a day. Face crops are + captured automatically and appear under **Face Library -> Train** in + the Frigate UI. +2. For each person, label 5-10 **diverse** clear crops -- different angles, + lighting, times of day. Diversity beats quantity; 30 near-identical + frames from one event hurts recognition. +3. Walk past the camera again. Events should now show the person's name as + the sub-label, and frigate-notify's ntfy push will say their name + instead of "person". + +Training images live on the Frigate host at +`/media/frigate/clips/faces//`. Not version-controlled (privacy). + +## Switching config profiles + +This repo ships with two Frigate configs: + +- `frigate_config/config.yml` -- active. Main 2688x1520 stream for both + detect and record. Better face recognition at distance, higher CPU. +- `frigate_config/alternatives/config-simple.yml` -- lower-CPU fallback. + Sub-stream 640x480 for detect, main for record only. + +To switch: + +```bash +cp frigate_config/config.yml frigate_config/config.yml.bak +cp frigate_config/alternatives/config-simple.yml frigate_config/config.yml +docker compose restart frigate +``` + +## Adding a camera with a mic + +When adding a camera with a built-in microphone (e.g. an Anpviz with mic), +see the comment block at the bottom of `frigate_config/config.yml` for the +exact edits needed to enable live audio in the Frigate UI. + +## Pi doorbell speaker (when you're ready) + +See `pi/README.md`. Summary: +1. Flash Pi OS Lite, scp `pi/` to the Pi, run `install.sh`. +2. Add the `doorbell.yourdomain.com` site block in Caddy. +3. Open the URL on your phone. + +## Security notes + +- `.env` has RTSP credentials, MQTT password, ntfy URL. Never commit it. + This repo's `.gitignore` blocks it; review `git status` before committing. +- The Pi's Flask server binds only to `127.0.0.1`. Caddy is what exposes it. + Do NOT bind `server.py` to `0.0.0.0` -- it has no auth of its own. +- Mosquitto's port 1883 is LAN-only. Use a VPN for any remote MQTT clients. +- For the doorbell page, optionally add basic auth in Caddy (`caddy + hash-password`). + +## Troubleshooting + +### Frigate can't reach cameras + +```bash +docker compose exec frigate ping -c 2 +``` + +RTSP path varies by camera vendor: +- Dahua / Amcrest: `/cam/realmonitor?channel=1&subtype=0` +- Hikvision / Anpviz H-series: `/Streaming/Channels/101` + +If your password contains `%`, `@`, `/`, `?`, `#`, `&`, or `+`, either +URL-encode it or change the password to avoid those characters. + +### Mosquitto restarts in a loop + +Almost always permission on `mosquitto/config/passwd`: +```bash +sudo chown -R 1883:1883 mosquitto/ +sudo chmod 0640 mosquitto/config/passwd +docker compose restart mosquitto +``` + +Or the config file or password file simply doesn't exist yet -- see the +"First-run on the Frigate host" section above for the bootstrap flow. + +### frigate-notify connects to MQTT but doesn't send pings + +```bash +docker compose logs frigate-notify --tail 30 +``` + +- "webapi" in logs but expecting MQTT? Check `webapi.enabled: false` and + `mqtt.enabled: true` in `frigate-notify/config.yml`. +- MQTT auth fails? Verify `FN_FRIGATE__MQTT__PASSWORD` (note DOUBLE + underscores) matches what you set with `mosquitto_passwd`. +- Confirm what reached the container: + `docker inspect frigate-notify --format '{{range .Config.Env}}{{println .}}{{end}}' | grep FN_` + +### Always see "person" instead of trained name + +- Increase `alerts.general.recheck_delay` in `frigate-notify/config.yml` + from 10 to 15 or 20 seconds. +- Check the Frigate UI event timeline -- if the event itself doesn't + show a sub_label, the face crop was too small / too blurry / too + obscured for recognition. + +## Hardware reference + +Current: +- NVR host: x86_64 + Docker, USB Coral +- Cameras: Amcrest (Dahua RTSP) +- Notifier: self-hosted ntfy + +Planned: +- Anpviz 4K camera with built-in mic (front door audio) +- Back door + squirrel feeder cameras +- Pi Zero W + USB speaker at the door diff --git a/caddy/Caddyfile b/caddy/Caddyfile new file mode 100644 index 0000000..f65594d --- /dev/null +++ b/caddy/Caddyfile @@ -0,0 +1,48 @@ +# --------------------------------------------------------------------------- +# Caddyfile +# +# Install at /etc/caddy/Caddyfile (or merge with your existing one), edit the +# placeholders below, then: sudo systemctl reload caddy +# +# Caddy auto-provisions Let's Encrypt certs for every site block. DNS for +# both subdomains must point at this Caddy host's public IP first. +# +# Placeholders to replace: +# yourdomain.com -> your real domain +# 192.168.1.50 -> Frigate host LAN IP +# 192.168.1.60 -> Pi Zero W LAN IP (when you set up the doorbell) +# --------------------------------------------------------------------------- + +# ---------- Frigate authenticated UI ---------- +cam.yourdomain.com { + encode zstd gzip + + reverse_proxy 192.168.1.50:8971 { + transport http { + read_timeout 60s + write_timeout 60s + } + } +} + +# ---------- Doorbell PTT page (Pi Zero W) ---------- +# Comment out this whole block until the Pi is deployed. +doorbell.yourdomain.com { + encode zstd gzip + + # Same-origin proxy to Frigate so the browser's WebRTC fetch works + # without CORS issues. /frigate/* is stripped before forwarding. + handle_path /frigate/* { + reverse_proxy 192.168.1.50:8971 { + transport http { + read_timeout 60s + write_timeout 60s + } + } + } + + # Everything else (HTML page + /audio WebSocket) goes to the Pi. + handle { + reverse_proxy 192.168.1.60:5555 + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c6cb761 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,71 @@ +# --------------------------------------------------------------------------- +# Home camera stack +# - Frigate 0.17 (NVR + face recognition + LPR + audio detection) +# - Mosquitto (MQTT broker) +# - frigate-notify (event consumer -> ntfy push notifications) +# +# First-run setup: see README.md. +# --------------------------------------------------------------------------- + +services: + + frigate: + container_name: frigate + image: ghcr.io/blakeblackshear/frigate:0.17.1 + restart: unless-stopped + stop_grace_period: 30s + privileged: true # needed for USB Coral + shm_size: "512mb" + env_file: .env + depends_on: + - mosquitto + devices: + - /dev/bus/usb:/dev/bus/usb # USB Coral + # - /dev/apex_0:/dev/apex_0 # PCIe Coral + # - /dev/dri/renderD128 # Intel/AMD hwaccel + volumes: + - /etc/localtime:/etc/localtime:ro + - ./frigate_config:/config + - /home/user/drives/sc-games/frigate:/media/frigate + - type: tmpfs + target: /tmp/cache + tmpfs: + size: 1000000000 + ports: + - "8971:8971" # authenticated UI (proxied by Caddy) + - "5001:5000" # unauthenticated UI (LAN debug only) + - "8554:8554" # RTSP restream + - "8555:8555/tcp" # WebRTC TCP + - "8555:8555/udp" # WebRTC UDP + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:5000/api/version"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 60s + + mosquitto: + container_name: mosquitto + hostname: mosquitto + image: eclipse-mosquitto:2 + restart: unless-stopped + ports: + - "1883:1883" # MQTT (LAN ONLY -- never expose to internet) + volumes: + - ./mosquitto/config:/mosquitto/config + - ./mosquitto/data:/mosquitto/data + - ./mosquitto/log:/mosquitto/log + + frigate-notify: + container_name: frigate-notify + hostname: frigate-notify + image: ghcr.io/0x2142/frigate-notify:latest + restart: unless-stopped + env_file: .env + depends_on: + mosquitto: + condition: service_started + frigate: + condition: service_healthy + volumes: + - ./frigate-notify/config.yml:/app/config.yml:ro diff --git a/frigate-notify/config.yml b/frigate-notify/config.yml new file mode 100644 index 0000000..5dc5e72 --- /dev/null +++ b/frigate-notify/config.yml @@ -0,0 +1,153 @@ +## frigate-notify config +## Docs: https://frigate-notify.0x2142.com +## +## Secrets and per-deployment values come from .env via the FN_* env vars. +## frigate-notify uses Viper-style env var lookup with DOUBLE underscores +## between YAML levels: +## frigate.server -> FN_FRIGATE__SERVER +## frigate.public_url -> FN_FRIGATE__PUBLIC_URL +## frigate.mqtt.password -> FN_FRIGATE__MQTT__PASSWORD +## alerts.ntfy.server -> FN_ALERTS__NTFY__SERVER + +frigate: + server: # FN_FRIGATE__SERVER + ignoressl: true + public_url: # FN_FRIGATE__PUBLIC_URL + headers: + + startup_check: + attempts: 5 + interval: 30 + + webapi: + enabled: false # MQTT below is the primary event source + interval: 5 + + mqtt: + enabled: true + server: mosquitto # docker DNS name; constant for this stack + port: 1883 + clientid: frigate-notify + username: frigate + password: # FN_FRIGATE__MQTT__PASSWORD + topic_prefix: frigate + + cameras: + exclude: + + +alerts: + general: + # Title shows the recognized name (face recognition or LPR) when present, + # otherwise the generic label. Camera names are auto-titlecased by + # frigate-notify. + title: 'Frigate - {{ if .SubLabel }}{{ .SubLabel }}{{ else }}{{ .Label }}{{ end }} at {{ .Camera }}' + timeformat: + nosnap: allow + snap_bbox: + snap_timestamp: + snap_crop: + + # Face recognition runs slightly AFTER the initial event. Without this + # delay, .SubLabel is empty and notifications always say "person". + recheck_delay: 10 + + quiet: + start: + end: + + zones: + unzoned: allow + allow: + block: + + labels: + min_score: + allow: + block: + + sublabels: + # Once face recognition reliably IDs household members, list their names + # here to silence pings on them: + # block: + # - alex + # - bob + allow: + block: + + discord: + enabled: false + webhook: + template: + + gotify: + enabled: false + server: + token: + ignoressl: + template: + + smtp: + enabled: false + server: + port: + tls: + user: + password: + recipient: + template: + + telegram: + enabled: false + chatid: + token: + template: + + pushover: + enabled: false + token: + userkey: + devices: + priority: + retry: + expire: + ttl: + template: + + ntfy: + enabled: true + server: # FN_ALERTS__NTFY__SERVER + topic: "frigate" + ignoressl: false + + # Priority + emoji change based on whether the person is recognized. + # Recognized -> priority 3 (normal) + wave; unknown -> priority 4 + alarm. + headers: + - X-Priority: '{{ if .SubLabel }}3{{ else }}4{{ end }}' + - X-Tags: '{{ if .SubLabel }}wave{{ else }}rotating_light{{ end }}' + + template: | + {{ if .SubLabel -}} + {{ .SubLabel }} at {{ .Camera }} + {{- else -}} + {{ .Label }} at {{ .Camera }} + {{- end }} + {{ if gt (len .CurrentZones) 0 }} + Zone: {{ range $i, $z := .CurrentZones }}{{ if $i }}, {{ end }}{{ $z }}{{ end }} + {{- end }} + Score: {{ printf "%.0f" (mul .TopScore 100) }}% + Time: {{ .StartTime.Format "Mon 3:04 PM" }} + + webhook: + enabled: false + server: + ignoressl: + headers: + template: + + +monitor: + enabled: false + url: + interval: + ignoressl: diff --git a/frigate_config/alternatives/config-simple.yml b/frigate_config/alternatives/config-simple.yml new file mode 100644 index 0000000..fe5b054 --- /dev/null +++ b/frigate_config/alternatives/config-simple.yml @@ -0,0 +1,164 @@ +############################################################################## +# Frigate 0.17 - SIMPLE (lower-CPU) variant +# +# Use if main config.yml uses too much CPU on your hardware. +# +# Differences vs main config.yml: +# - Sub-stream (640x480) used for detect; main stream for record only +# - Lower CPU: only the small sub-stream is decoded for detection +# - Face recognition still works for close-up faces; struggles at distance +# - LPR will rarely succeed (plate area too small in 640x480) +# - face_recognition.min_area lowered to 300 to catch smaller faces +# +# To activate: +# cp frigate_config/config.yml frigate_config/config.yml.bak +# cp frigate_config/alternatives/config-simple.yml frigate_config/config.yml +# docker compose restart frigate +############################################################################## + +version: 0.17-0 + +mqtt: + enabled: true + host: mosquitto + port: 1883 + user: "{FRIGATE_MQTT_USER}" + password: "{FRIGATE_MQTT_PASSWORD}" + topic_prefix: frigate + client_id: frigate + stats_interval: 60 + +tls: + enabled: false + +audio: + enabled: false + +detectors: + coral: + type: edgetpu + device: usb + +birdseye: + mode: continuous + +semantic_search: + enabled: false + model_size: small + +face_recognition: + enabled: true + model_size: small + min_area: 300 # smaller default since faces will be small on sub-stream + +lpr: + enabled: true + model_size: small + +classification: + bird: + enabled: false + +objects: + track: + - person + +record: + enabled: true + continuous: + days: 0 + motion: + days: 10 + alerts: + retain: + days: 360 + mode: motion + detections: + retain: + days: 360 + mode: motion + +snapshots: + enabled: true + bounding_box: true + crop: true + retain: + default: 360 + +go2rtc: + streams: + front_door: + - rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@{FRIGATE_FRONT_DOOR_IP}:554/cam/realmonitor?channel=1&subtype=0#backchannel=0 + front_door_sub: + - rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@{FRIGATE_FRONT_DOOR_IP}:554/cam/realmonitor?channel=1&subtype=1#backchannel=0 + + back_door: + - rtsp://{FRIGATE_RTSP_USER1}:{FRIGATE_RTSP_PASSWORD1}@{FRIGATE_BACK_DOOR_IP}:554/cam/realmonitor?channel=1&subtype=0#backchannel=0 + back_door_sub: + - rtsp://{FRIGATE_RTSP_USER1}:{FRIGATE_RTSP_PASSWORD1}@{FRIGATE_BACK_DOOR_IP}:554/cam/realmonitor?channel=1&subtype=1#backchannel=0 + + squirrel: + - rtsp://{FRIGATE_RTSP_USER2}:{FRIGATE_RTSP_PASSWORD2}@{FRIGATE_SQUIRREL_IP}:554/cam/realmonitor?channel=1&subtype=0#backchannel=0 + squirrel_sub: + - rtsp://{FRIGATE_RTSP_USER2}:{FRIGATE_RTSP_PASSWORD2}@{FRIGATE_SQUIRREL_IP}:554/cam/realmonitor?channel=1&subtype=1#backchannel=0 + +cameras: + + front_door: + enabled: true + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/front_door + input_args: preset-rtsp-restream + roles: + - record + - path: rtsp://127.0.0.1:8554/front_door_sub + input_args: preset-rtsp-restream + roles: + - detect + detect: + enabled: true + width: 640 + height: 480 + fps: 5 + motion: + mask: + - 0.582,0.426,0.582,0.476,0.989,0.534,0.994,0.467 + - 0.984,0.614,0.513,0.99,0.991,0.996 + - 0.001,0.163,0.085,0.165,0.095,0.255,0.003,0.263 + + back_door: + enabled: false + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/back_door + input_args: preset-rtsp-restream + roles: + - record + - path: rtsp://127.0.0.1:8554/back_door_sub + input_args: preset-rtsp-restream + roles: + - detect + detect: + enabled: true + width: 640 + height: 480 + fps: 5 + + squirrel: + enabled: false + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/squirrel + input_args: preset-rtsp-restream + roles: + - record + - path: rtsp://127.0.0.1:8554/squirrel_sub + input_args: preset-rtsp-restream + roles: + - detect + detect: + enabled: true + width: 640 + height: 480 + fps: 5 diff --git a/frigate_config/config.yml b/frigate_config/config.yml new file mode 100644 index 0000000..f2cf7a9 --- /dev/null +++ b/frigate_config/config.yml @@ -0,0 +1,202 @@ +############################################################################## +# Frigate 0.17 production config +# +# - Main stream (2688x1520) used for detect + record on each camera +# (better face crops at distance, higher CPU) +# - Face recognition + LPR enabled (small models, CPU-friendly) +# - MQTT enabled for frigate-notify push events +# +# An alternate lower-CPU config that uses the sub-stream for detect lives at +# frigate_config/alternatives/config-simple.yml. To swap: +# cp frigate_config/config.yml frigate_config/config.yml.bak +# cp frigate_config/alternatives/config-simple.yml frigate_config/config.yml +# docker compose restart frigate +# +# Validate before restart: +# docker run --rm \ +# -v $(pwd)/frigate_config/config.yml:/config/config.yml \ +# --entrypoint python3 \ +# ghcr.io/blakeblackshear/frigate:0.17.1 \ +# -u -m frigate --validate-config +############################################################################## + +version: 0.17-0 + +mqtt: + enabled: true + host: mosquitto + port: 1883 + user: "{FRIGATE_MQTT_USER}" + password: "{FRIGATE_MQTT_PASSWORD}" + topic_prefix: frigate + client_id: frigate + stats_interval: 60 + +tls: + enabled: false + +audio: + enabled: false # flip on when a mic-equipped camera arrives + +detectors: + coral: + type: edgetpu + device: usb + +birdseye: + mode: continuous + +semantic_search: + enabled: false + model_size: small + +face_recognition: + enabled: true + model_size: small + +lpr: + enabled: true + model_size: small + # known_plates: + # owner: + # - "ABC-1234" + +classification: + bird: + enabled: false + +objects: + track: + - person + +# ---------- global record defaults (0.17 schema) ---------- + +record: + enabled: true + continuous: + days: 0 + motion: + days: 10 + alerts: + retain: + days: 360 + mode: motion + detections: + retain: + days: 360 + mode: motion + +snapshots: + enabled: true + bounding_box: true + crop: true + retain: + default: 360 + +# ---------- go2rtc: restream from cameras ---------- + +go2rtc: + streams: + front_door: + - rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@{FRIGATE_FRONT_DOOR_IP}:554/cam/realmonitor?channel=1&subtype=0#backchannel=0 + front_door_sub: + - rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@{FRIGATE_FRONT_DOOR_IP}:554/cam/realmonitor?channel=1&subtype=1#backchannel=0 + + back_door: + - rtsp://{FRIGATE_RTSP_USER1}:{FRIGATE_RTSP_PASSWORD1}@{FRIGATE_BACK_DOOR_IP}:554/cam/realmonitor?channel=1&subtype=0#backchannel=0 + back_door_sub: + - rtsp://{FRIGATE_RTSP_USER1}:{FRIGATE_RTSP_PASSWORD1}@{FRIGATE_BACK_DOOR_IP}:554/cam/realmonitor?channel=1&subtype=1#backchannel=0 + + squirrel: + - rtsp://{FRIGATE_RTSP_USER2}:{FRIGATE_RTSP_PASSWORD2}@{FRIGATE_SQUIRREL_IP}:554/cam/realmonitor?channel=1&subtype=0#backchannel=0 + squirrel_sub: + - rtsp://{FRIGATE_RTSP_USER2}:{FRIGATE_RTSP_PASSWORD2}@{FRIGATE_SQUIRREL_IP}:554/cam/realmonitor?channel=1&subtype=1#backchannel=0 + +# ---------- cameras ---------- + +cameras: + + front_door: + enabled: true + ffmpeg: + inputs: + # Main stream used for both detect and record. Single connection, + # higher resolution -> better face recognition crops. + - path: rtsp://127.0.0.1:8554/front_door + input_args: preset-rtsp-restream + roles: + - detect + - record + detect: + enabled: true + width: 2688 # adjust if your main stream is different + height: 1520 + fps: 5 # detection doesn't need high fps; saves CPU + motion: + mask: + - 0.582,0.426,0.582,0.476,0.989,0.534,0.994,0.467 + - 0.984,0.614,0.513,0.99,0.991,0.996 + - 0.001,0.163,0.085,0.165,0.095,0.255,0.003,0.263 + + back_door: + enabled: false + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/back_door + input_args: preset-rtsp-restream + roles: + - detect + - record + detect: + enabled: true + width: 2688 + height: 1520 + fps: 5 + + squirrel: + enabled: false + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/squirrel + input_args: preset-rtsp-restream + roles: + - detect + - record + detect: + enabled: true + width: 2688 + height: 1520 + fps: 5 + +############################################################################## +# HOW TO ADD A CAMERA WITH A MIC (e.g., future Anpviz) +# +# 1. Set audio.enabled: true at the top of this file. +# +# 2. In go2rtc.streams, add the ffmpeg audio transcode line so live view +# has both AAC (for MSE) and opus (for WebRTC): +# anpviz: +# - rtsp://{FRIGATE_RTSP_USER3}:{FRIGATE_RTSP_PASSWORD3}@{FRIGATE_ANPVIZ_IP}:554/...#backchannel=0 +# - "ffmpeg:anpviz#audio=aac#audio=opus" +# +# 3. In cameras, add the 'audio' role and set the audio-aware record preset: +# anpviz: +# enabled: true +# ffmpeg: +# output_args: +# record: preset-record-generic-audio-aac +# inputs: +# - path: rtsp://127.0.0.1:8554/anpviz +# input_args: preset-rtsp-restream +# roles: +# - detect +# - record +# - audio +# +# 4. Add FRIGATE_RTSP_USER3 / _PASSWORD3 / FRIGATE_ANPVIZ_IP to .env. +# +# 5. RTSP paths vary by vendor: +# Anpviz H-series (Hikvision OEM): /Streaming/Channels/101 (main), /102 (sub) +# Anpviz U-series (Dahua OEM): /cam/realmonitor?channel=1&subtype=0 (main) +# /cam/realmonitor?channel=1&subtype=1 (sub) +############################################################################## diff --git a/mosquitto/config/mosquitto.conf b/mosquitto/config/mosquitto.conf new file mode 100644 index 0000000..d6197bd --- /dev/null +++ b/mosquitto/config/mosquitto.conf @@ -0,0 +1,17 @@ +# Mosquitto MQTT broker +# +# This config assumes the password file exists at /mosquitto/config/passwd. +# On first deployment, see README "First-run on the Frigate host" for how +# to bootstrap that file. + +listener 1883 0.0.0.0 +protocol mqtt + +persistence true +persistence_location /mosquitto/data/ + +log_dest stdout +log_dest file /mosquitto/log/mosquitto.log + +allow_anonymous false +password_file /mosquitto/config/passwd diff --git a/mosquitto/data/.gitkeep b/mosquitto/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/mosquitto/log/.gitkeep b/mosquitto/log/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pi/README.md b/pi/README.md new file mode 100644 index 0000000..4e959cd --- /dev/null +++ b/pi/README.md @@ -0,0 +1,101 @@ +# Pi doorbell PTT + +Turns a Raspberry Pi into a network speaker so a phone hitting +`https://doorbell.yourdomain.com` can see/hear the front-door Frigate feed +and hold a button to talk through a speaker mounted at the door. + +## Hardware + +- Any Raspberry Pi (Zero W 1st gen is enough; Zero 2 W is better for live + two-way; Pi 3A+ has a 3.5mm jack onboard and skips the OTG adapter) +- Audio output, one of: + - USB speaker + micro-USB-to-USB-A OTG adapter (simplest) + - 3.5mm powered speaker (Pi 3A+ has the jack; Zero W does not) + - I2S DAC HAT (best quality, requires GPIO header) +- microSD card, power supply, WiFi or USB ethernet + +## Install on the Pi + +```bash +# From your laptop/desktop: +scp -r pi/ pi@PI_LAN_IP:~/doorbell-src + +# SSH to the Pi: +ssh pi@PI_LAN_IP +cd ~/doorbell-src +chmod +x install.sh +./install.sh +``` + +The installer apt-installs ffmpeg + alsa-utils + Python deps, creates a +virtualenv, drops `server.py` into `~/doorbell/`, installs and enables the +systemd service, runs `speaker-test` to confirm ALSA output works, and +starts the service. + +## Verify + +```bash +curl http://127.0.0.1:5555/healthz # -> ok +sudo journalctl -u doorbell -f # live logs +``` + +## Wire it up + +1. On the Caddy host, add the `doorbell.yourdomain.com` block from + `../caddy/Caddyfile` and reload Caddy. +2. DNS: point `doorbell.yourdomain.com` at the Caddy host's public IP. +3. Open `https://doorbell.yourdomain.com` on an Android phone. +4. Grant the one-time microphone permission. +5. Tap **Unmute camera** if browser autoplay swallowed the audio. +6. Hold the big green button to talk. + +Add to home screen (Chrome menu -> Add to home screen) for an app-like +experience. + +## Changing the camera + +`server.py` near the top: +```js +const CAMERA_NAME = "front_door"; +``` + +After editing: +```bash +sudo systemctl restart doorbell +``` + +## Audio stack + +ALSA-only -- no PipeWire/PulseAudio. Lighter on the Pi Zero. If you ever +need PipeWire (e.g., to share the speaker with another app), change +`'-f', 'alsa'` to `'-f', 'pulse'` in `server.py` and install the +PipeWire/Pulse compatibility shim. + +## Troubleshooting + +### speaker-test fails + +USB/3.5mm output isn't the default ALSA card. Check: +```bash +aplay -l +``` +If your speaker isn't card 0, create `/etc/asound.conf`: +``` +defaults.pcm.card 1 +defaults.ctl.card 1 +``` +(Replace `1` with whatever card your speaker is.) + +### Video plays but talk button stuck on "Disconnected" + +The WebSocket isn't reaching the Pi. Most common: Caddy not proxying +`doorbell.yourdomain.com` -> Pi correctly. From the Caddy host: +```bash +curl -i http://PI_LAN_IP:5555/healthz # should return 200 ok +``` + +### Feedback loop when talking + +The page auto-mutes the camera while the PTT button is held, so this +should not happen. If it does, increase distance between Pi speaker and +camera mic, or turn the speaker volume down. diff --git a/pi/doorbell.service b/pi/doorbell.service new file mode 100644 index 0000000..ea355c2 --- /dev/null +++ b/pi/doorbell.service @@ -0,0 +1,19 @@ +[Unit] +Description=Doorbell PTT server +After=network-online.target sound.target +Wants=network-online.target + +[Service] +Type=simple +User=pi +Group=audio +WorkingDirectory=/home/pi/doorbell +ExecStart=/home/pi/doorbell-venv/bin/python /home/pi/doorbell/server.py +Restart=always +RestartSec=3 + +NoNewPrivileges=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/pi/install.sh b/pi/install.sh new file mode 100644 index 0000000..6441b6c --- /dev/null +++ b/pi/install.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Pi Zero W setup for the doorbell PTT server. +# Run as the 'pi' user after flashing Raspberry Pi OS Lite (Bookworm). +# +# Usage: +# chmod +x install.sh +# ./install.sh + +set -euo pipefail + +echo ">>> Installing OS packages..." +sudo apt update +sudo apt install -y ffmpeg alsa-utils python3-venv python3-pip + +echo ">>> Creating project dirs..." +mkdir -p "$HOME/doorbell" + +echo ">>> Creating Python virtualenv..." +python3 -m venv "$HOME/doorbell-venv" +# shellcheck disable=SC1091 +source "$HOME/doorbell-venv/bin/activate" +pip install --upgrade pip +pip install flask flask-sock + +echo ">>> Copying server.py..." +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cp "$SCRIPT_DIR/server.py" "$HOME/doorbell/server.py" + +echo ">>> Installing systemd service..." +sudo cp "$SCRIPT_DIR/doorbell.service" /etc/systemd/system/doorbell.service +sudo systemctl daemon-reload +sudo systemctl enable doorbell + +echo ">>> Testing audio output..." +echo "You should hear 'front left' in a moment. Ctrl-C if nothing plays." +speaker-test -D default -c 2 -t wav -l 1 || { + echo "!! speaker-test failed. Fix ALSA output before starting the service." + echo " Try: sudo raspi-config -> System Options -> Audio" + echo " Or: aplay -l and edit /etc/asound.conf" + exit 1 +} + +echo ">>> Starting doorbell service..." +sudo systemctl restart doorbell +sleep 2 +sudo systemctl status doorbell --no-pager + +echo +echo "==========================================" +echo "Done. Quick checks:" +echo " curl http://127.0.0.1:5555/healthz" +echo " sudo journalctl -u doorbell -f" +echo "==========================================" diff --git a/pi/server.py b/pi/server.py new file mode 100644 index 0000000..5fd342b --- /dev/null +++ b/pi/server.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +""" +Doorbell PTT server for Pi Zero W. + +Serves a single-page web app that: + * shows the Frigate WebRTC live feed (video + camera mic if present) + * provides a push-to-talk button that streams phone mic audio over a + WebSocket; this script decodes and plays it out ALSA. + +Deployment: + * listens on 127.0.0.1:5555; expose publicly via Caddy reverse proxy + * runs under systemd as the 'pi' user + * requires: python3-flask, flask-sock, ffmpeg, alsa-utils +""" + +import subprocess +from flask import Flask, render_template_string +from flask_sock import Sock + +app = Flask(__name__) +sock = Sock(app) + +PAGE = """ + + + + + + + Doorbell + + + +
+
+ +
+
+ +
+ + + +
+
+
+
+ + + + +""" + + +@app.route('/') +def index(): + return render_template_string(PAGE) + + +@app.route('/healthz') +def healthz(): + return 'ok' + + +@sock.route('/audio') +def audio(ws): + ff = subprocess.Popen( + [ + 'ffmpeg', + '-loglevel', 'error', + '-f', 'webm', '-i', 'pipe:0', + '-f', 'alsa', 'default', + ], + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + while True: + data = ws.receive() + if data is None: + break + if isinstance(data, (bytes, bytearray)): + try: + ff.stdin.write(data) + ff.stdin.flush() + except BrokenPipeError: + break + finally: + try: + ff.stdin.close() + except Exception: + pass + try: + ff.terminate() + ff.wait(timeout=2) + except Exception: + ff.kill() + + +if __name__ == '__main__': + # 127.0.0.1 only -- Caddy reverse-proxies from the public domain + app.run(host='127.0.0.1', port=5555, threaded=True)