Initial home camera stack
This commit is contained in:
+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