Files
frigate_w_audio/pi/server.py
T
Claude 09d5b28e3d Revert camera dropdown; add PEER_LINKS for sibling Pis
The dropdown allowed switching the video feed but PTT always pointed at
this Pi's local speaker, so picking a non-co-located camera could have
let a user talk into the wrong room. Each Pi is now hardcoded to the
camera at its own location via CAMERA_NAME, with optional PEER_LINKS to
render quick-jump buttons to sibling doorbell Pis at other URLs.

https://claude.ai/code/session_013XZ1vmgk78k2PEQ5DmJhF3
2026-04-25 21:23:17 +00:00

270 lines
8.7 KiB
Python

#!/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)
# Camera this Pi corresponds to. Must match a go2rtc stream name in
# frigate_config/config.yml. The PTT button talks to the speaker physically
# attached to this Pi, so this should be the camera at the same location.
CAMERA_NAME = "front_door"
# Optional jump-links to sibling doorbell Pis (each running its own copy of
# this app, hardcoded to its own camera). Rendered as a row of buttons above
# the PTT button when non-empty. Leave empty if there are no other Pis.
PEER_LINKS = [
# {"label": "Back door", "url": "https://backdoor.yourdomain.com"},
]
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}
.row a{flex:1;padding:10px;background:#333;color:#fff;border-radius:8px;
font-size:13px;text-decoration:none;text-align:center;
display:flex;align-items:center;justify-content:center}
#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">
<div class="row" id="peers" style="display:none"></div>
<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 = {{ camera_name|tojson }};
const PEERS = {{ peers|tojson }};
const FRIGATE_WEBRTC_URL = "/frigate/api/go2rtc/api/webrtc?src=" + encodeURIComponent(CAMERA_NAME);
// --------------------------------------------------------------------
const $ = id => document.getElementById(id);
const ptt = $('ptt'), status = $('status'), video = $('cam'),
unmute = $('unmute'), reload = $('reload'), wake = $('wake'),
peersRow = $('peers');
let ws, mediaRecorder, micStream, wakeLock = null;
const log = m => { status.textContent = m; console.log('[doorbell]', m); };
if (PEERS.length) {
for (const p of PEERS) {
const a = document.createElement('a');
a.href = p.url;
a.textContent = p.label;
peersRow.appendChild(a);
}
peersRow.style.display = 'flex';
}
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, camera_name=CAMERA_NAME, peers=PEER_LINKS)
@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)