Files
frigate_w_audio/pi/server.py
T
Claude b3940685c4 Add camera selector dropdown to doorbell page
Replaces the hardcoded CAMERA_NAME with a CAMERAS list. The page renders
a dropdown when more than one camera is configured and remembers the
choice in localStorage; switching tears down and reopens the WebRTC
connection.

https://claude.ai/code/session_013XZ1vmgk78k2PEQ5DmJhF3
2026-04-25 19:10:37 +00:00

276 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)
# Cameras to offer in the page's dropdown. First entry is the default.
# Names must match go2rtc stream names in frigate_config/config.yml and
# the corresponding camera must be enabled in Frigate.
CAMERAS = ["front_door"]
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 select{flex:1;padding:10px;background:#333;color:#fff;border:none;
border-radius:8px;font-size:14px;appearance:none;-webkit-appearance:none;
text-align:center;text-align-last: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="camrow" style="display:none">
<select id="camsel"></select>
</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 CAMERAS = {{ cameras|tojson }};
// --------------------------------------------------------------------
const $ = id => document.getElementById(id);
const ptt = $('ptt'), status = $('status'), video = $('cam'),
unmute = $('unmute'), reload = $('reload'), wake = $('wake'),
camsel = $('camsel'), camrow = $('camrow');
let ws, mediaRecorder, micStream, wakeLock = null, pc = null;
const log = m => { status.textContent = m; console.log('[doorbell]', m); };
let currentCamera = localStorage.getItem('camera');
if (!CAMERAS.includes(currentCamera)) currentCamera = CAMERAS[0];
for (const c of CAMERAS) {
const opt = document.createElement('option');
opt.value = c; opt.textContent = c;
if (c === currentCamera) opt.selected = true;
camsel.appendChild(opt);
}
if (CAMERAS.length > 1) camrow.style.display = 'flex';
camsel.onchange = () => {
currentCamera = camsel.value;
localStorage.setItem('camera', currentCamera);
startVideo(currentCamera);
};
async function startVideo(camera){
if (pc) {
try { pc.close(); } catch(e){}
pc = null;
}
video.srcObject = null;
try {
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 url = '/frigate/api/go2rtc/api/webrtc?src=' + encodeURIComponent(camera);
const resp = await fetch(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: ' + camera);
} 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(currentCamera);
setupPTT();
</script>
</body>
</html>
"""
@app.route('/')
def index():
return render_template_string(PAGE, cameras=CAMERAS)
@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)