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