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
276 lines
8.7 KiB
Python
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)
|