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
This commit is contained in:
Claude
2026-04-25 19:10:37 +00:00
parent 8609175410
commit b3940685c4
2 changed files with 54 additions and 14 deletions
+10 -4
View File
@@ -52,13 +52,19 @@ sudo journalctl -u doorbell -f # live logs
Add to home screen (Chrome menu -> Add to home screen) for an app-like Add to home screen (Chrome menu -> Add to home screen) for an app-like
experience. experience.
## Changing the camera ## Choosing the camera
`server.py` near the top: `server.py` near the top has the list of cameras the page offers:
```js ```python
const CAMERA_NAME = "front_door"; CAMERAS = ["front_door"]
``` ```
Each name must match a `go2rtc.streams` entry in
`frigate_config/config.yml` and the camera must be `enabled: true` in
Frigate. With more than one entry the page shows a dropdown above the
talk button; the choice is remembered per-browser in `localStorage`. The
first entry is the default for new visitors.
After editing: After editing:
```bash ```bash
sudo systemctl restart doorbell sudo systemctl restart doorbell
+44 -10
View File
@@ -20,6 +20,11 @@ from flask_sock import Sock
app = Flask(__name__) app = Flask(__name__)
sock = Sock(app) 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> PAGE = """<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -46,6 +51,9 @@ PAGE = """<!doctype html>
.row{display:flex;gap:8px} .row{display:flex;gap:8px}
.row button{flex:1;padding:10px;background:#333;color:#fff;border:none; .row button{flex:1;padding:10px;background:#333;color:#fff;border:none;
border-radius:8px;font-size:13px} 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} #status{font-size:12px;color:#888;text-align:center;min-height:1em}
</style> </style>
</head> </head>
@@ -55,6 +63,9 @@ PAGE = """<!doctype html>
<video id="cam" autoplay playsinline muted></video> <video id="cam" autoplay playsinline muted></video>
</div> </div>
<div id="controls"> <div id="controls">
<div class="row" id="camrow" style="display:none">
<select id="camsel"></select>
</div>
<button id="ptt" disabled>Connecting...</button> <button id="ptt" disabled>Connecting...</button>
<div class="row"> <div class="row">
<button id="unmute">Unmute camera</button> <button id="unmute">Unmute camera</button>
@@ -67,21 +78,43 @@ PAGE = """<!doctype html>
<script> <script>
// ---- CONFIG --------------------------------------------------------- // ---- CONFIG ---------------------------------------------------------
const CAMERA_NAME = "front_door"; const CAMERAS = {{ cameras|tojson }};
const FRIGATE_WEBRTC_URL = "/frigate/api/go2rtc/api/webrtc?src=" + CAMERA_NAME;
// -------------------------------------------------------------------- // --------------------------------------------------------------------
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
const ptt = $('ptt'), status = $('status'), video = $('cam'), const ptt = $('ptt'), status = $('status'), video = $('cam'),
unmute = $('unmute'), reload = $('reload'), wake = $('wake'); unmute = $('unmute'), reload = $('reload'), wake = $('wake'),
camsel = $('camsel'), camrow = $('camrow');
let ws, mediaRecorder, micStream, wakeLock = null; let ws, mediaRecorder, micStream, wakeLock = null, pc = null;
const log = m => { status.textContent = m; console.log('[doorbell]', m); }; const log = m => { status.textContent = m; console.log('[doorbell]', m); };
async function startVideo(){ 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 { try {
const pc = new RTCPeerConnection(); pc = new RTCPeerConnection();
pc.addTransceiver('video', {direction:'recvonly'}); pc.addTransceiver('video', {direction:'recvonly'});
pc.addTransceiver('audio', {direction:'recvonly'}); pc.addTransceiver('audio', {direction:'recvonly'});
pc.ontrack = e => { video.srcObject = e.streams[0]; }; pc.ontrack = e => { video.srcObject = e.streams[0]; };
@@ -90,7 +123,8 @@ async function startVideo(){
const offer = await pc.createOffer(); const offer = await pc.createOffer();
await pc.setLocalDescription(offer); await pc.setLocalDescription(offer);
const resp = await fetch(FRIGATE_WEBRTC_URL, { const url = '/frigate/api/go2rtc/api/webrtc?src=' + encodeURIComponent(camera);
const resp = await fetch(url, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/sdp'}, headers: {'Content-Type': 'application/sdp'},
body: pc.localDescription.sdp, body: pc.localDescription.sdp,
@@ -99,7 +133,7 @@ async function startVideo(){
if(!resp.ok) throw new Error('Frigate returned ' + resp.status); if(!resp.ok) throw new Error('Frigate returned ' + resp.status);
const answer = await resp.text(); const answer = await resp.text();
await pc.setRemoteDescription({type:'answer', sdp: answer}); await pc.setRemoteDescription({type:'answer', sdp: answer});
log('Camera connected'); log('Camera connected: ' + camera);
} catch(e){ log('Video error: ' + e.message); } } catch(e){ log('Video error: ' + e.message); }
} }
@@ -182,7 +216,7 @@ ptt.addEventListener('mousedown', startTalking);
ptt.addEventListener('mouseup', stopTalking); ptt.addEventListener('mouseup', stopTalking);
ptt.addEventListener('mouseleave', stopTalking); ptt.addEventListener('mouseleave', stopTalking);
startVideo(); startVideo(currentCamera);
setupPTT(); setupPTT();
</script> </script>
</body> </body>
@@ -192,7 +226,7 @@ setupPTT();
@app.route('/') @app.route('/')
def index(): def index():
return render_template_string(PAGE) return render_template_string(PAGE, cameras=CAMERAS)
@app.route('/healthz') @app.route('/healthz')