Initial home camera stack

This commit is contained in:
Outis
2026-04-25 11:06:10 -04:00
commit 5d7df0c0c8
15 changed files with 1411 additions and 0 deletions
+36
View File
@@ -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
+33
View File
@@ -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
+273
View File
@@ -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/<n>/`. 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 <camera_ip>
```
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
+48
View File
@@ -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
}
}
+71
View File
@@ -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
+153
View File
@@ -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:
@@ -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
+202
View File
@@ -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)
##############################################################################
+17
View File
@@ -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
View File
View File
+101
View File
@@ -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.
+19
View File
@@ -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
+53
View File
@@ -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 "=========================================="
+241
View File
@@ -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 = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no,viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<title>Doorbell</title>
<style>
*{box-sizing:border-box}
html,body{margin:0;padding:0;height:100%;background:#000;color:#fff;
font-family:system-ui,-apple-system,sans-serif;overflow:hidden;
touch-action:none;-webkit-user-select:none;user-select:none}
#wrap{display:flex;flex-direction:column;height:100vh;height:100dvh}
#video{flex:1;min-height:0;background:#000;position:relative}
video{width:100%;height:100%;object-fit:contain;background:#000}
#controls{padding:16px;display:flex;flex-direction:column;gap:10px;
background:#111;padding-bottom:max(16px,env(safe-area-inset-bottom))}
#ptt{font-size:24px;padding:28px;border:none;border-radius:14px;
background:#2d6a2d;color:#fff;font-weight:700;touch-action:none;
transition:background .05s,transform .05s}
#ptt.active{background:#d33;transform:scale(.98)}
#ptt:disabled{background:#333;color:#666}
.row{display:flex;gap:8px}
.row button{flex:1;padding:10px;background:#333;color:#fff;border:none;
border-radius:8px;font-size:13px}
#status{font-size:12px;color:#888;text-align:center;min-height:1em}
</style>
</head>
<body>
<div id="wrap">
<div id="video">
<video id="cam" autoplay playsinline muted></video>
</div>
<div id="controls">
<button id="ptt" disabled>Connecting...</button>
<div class="row">
<button id="unmute">Unmute camera</button>
<button id="wake">Keep screen on</button>
<button id="reload">Reconnect</button>
</div>
<div id="status"></div>
</div>
</div>
<script>
// ---- CONFIG ---------------------------------------------------------
const CAMERA_NAME = "front_door";
const FRIGATE_WEBRTC_URL = "/frigate/api/go2rtc/api/webrtc?src=" + CAMERA_NAME;
// --------------------------------------------------------------------
const $ = id => document.getElementById(id);
const ptt = $('ptt'), status = $('status'), video = $('cam'),
unmute = $('unmute'), reload = $('reload'), wake = $('wake');
let ws, mediaRecorder, micStream, wakeLock = null;
const log = m => { status.textContent = m; console.log('[doorbell]', m); };
async function startVideo(){
try {
const pc = new RTCPeerConnection();
pc.addTransceiver('video', {direction:'recvonly'});
pc.addTransceiver('audio', {direction:'recvonly'});
pc.ontrack = e => { video.srcObject = e.streams[0]; };
pc.oniceconnectionstatechange = () => log('ICE: ' + pc.iceConnectionState);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const resp = await fetch(FRIGATE_WEBRTC_URL, {
method: 'POST',
headers: {'Content-Type': 'application/sdp'},
body: pc.localDescription.sdp,
credentials: 'include'
});
if(!resp.ok) throw new Error('Frigate returned ' + resp.status);
const answer = await resp.text();
await pc.setRemoteDescription({type:'answer', sdp: answer});
log('Camera connected');
} catch(e){ log('Video error: ' + e.message); }
}
unmute.onclick = () => {
video.muted = !video.muted;
unmute.textContent = video.muted ? 'Unmute camera' : 'Mute camera';
if(!video.muted) video.play().catch(()=>{});
};
reload.onclick = () => location.reload();
wake.onclick = async () => {
if(!('wakeLock' in navigator)){ log('Wake lock not supported'); return; }
if(wakeLock){
wakeLock.release(); wakeLock = null;
wake.textContent = 'Keep screen on';
} else {
try {
wakeLock = await navigator.wakeLock.request('screen');
wake.textContent = 'Screen locked on';
wakeLock.addEventListener('release', () => {
wake.textContent = 'Keep screen on'; wakeLock = null;
});
} catch(e){ log('Wake lock failed: ' + e.message); }
}
};
async function setupPTT(){
try {
micStream = await navigator.mediaDevices.getUserMedia({
audio: {echoCancellation: true, noiseSuppression: true, autoGainControl: true}
});
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(proto + '//' + location.host + '/audio');
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
ptt.disabled = false;
ptt.textContent = 'Hold to talk';
log('Ready');
};
ws.onclose = () => {
ptt.disabled = true;
ptt.textContent = 'Disconnected';
log('WebSocket closed -- tap Reconnect');
};
ws.onerror = () => log('WebSocket error');
} catch(e){ log('Mic permission error: ' + e.message); }
}
function startTalking(e){
if(!ws || ws.readyState !== WebSocket.OPEN) return;
e.preventDefault();
ptt.classList.add('active');
ptt.textContent = 'TALKING';
video.muted = true; // prevent feedback loop
mediaRecorder = new MediaRecorder(micStream, {mimeType:'audio/webm;codecs=opus'});
mediaRecorder.ondataavailable = ev => {
if(ev.data.size > 0 && ws.readyState === WebSocket.OPEN){
ev.data.arrayBuffer().then(buf => ws.send(buf));
}
};
mediaRecorder.start(100);
}
function stopTalking(e){
e && e.preventDefault();
if(mediaRecorder && mediaRecorder.state === 'recording'){
mediaRecorder.stop();
}
ptt.classList.remove('active');
ptt.textContent = 'Hold to talk';
video.muted = false;
video.play().catch(()=>{});
}
ptt.addEventListener('touchstart', startTalking, {passive:false});
ptt.addEventListener('touchend', stopTalking, {passive:false});
ptt.addEventListener('touchcancel', stopTalking, {passive:false});
ptt.addEventListener('mousedown', startTalking);
ptt.addEventListener('mouseup', stopTalking);
ptt.addEventListener('mouseleave', stopTalking);
startVideo();
setupPTT();
</script>
</body>
</html>
"""
@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)