Initial home camera stack
This commit is contained in:
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
##############################################################################
|
||||
@@ -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
|
||||
+101
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
Reference in New Issue
Block a user