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
This commit is contained in:
+27
-8
@@ -54,22 +54,41 @@ experience.
|
|||||||
|
|
||||||
## Choosing the camera
|
## Choosing the camera
|
||||||
|
|
||||||
`server.py` near the top has the list of cameras the page offers:
|
Each Pi is hardcoded to one camera -- the one whose mic and speaker are
|
||||||
|
physically co-located with this Pi. The PTT button on this page only
|
||||||
|
talks to *this* Pi's speaker, so mixing cameras here would let a misclick
|
||||||
|
talk into the wrong room.
|
||||||
|
|
||||||
|
`server.py` near the top:
|
||||||
```python
|
```python
|
||||||
CAMERAS = ["front_door"]
|
CAMERA_NAME = "front_door"
|
||||||
```
|
```
|
||||||
|
|
||||||
Each name must match a `go2rtc.streams` entry in
|
The name must match a `go2rtc.streams` entry in
|
||||||
`frigate_config/config.yml` and the camera must be `enabled: true` 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
|
Frigate. After editing:
|
||||||
talk button; the choice is remembered per-browser in `localStorage`. The
|
|
||||||
first entry is the default for new visitors.
|
|
||||||
|
|
||||||
After editing:
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl restart doorbell
|
sudo systemctl restart doorbell
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Multiple doorbell Pis
|
||||||
|
|
||||||
|
Run one copy of this app per Pi, each on its own subdomain (e.g.
|
||||||
|
`frontdoor.yourdomain.com`, `backdoor.yourdomain.com`). Add a Caddy
|
||||||
|
block per subdomain pointing at that Pi's LAN IP -- same shape as the
|
||||||
|
existing `doorbell.yourdomain.com` block in `../caddy/Caddyfile`.
|
||||||
|
|
||||||
|
To render quick-jump buttons to the other Pis at the top of the page,
|
||||||
|
fill in `PEER_LINKS` near the top of `server.py`:
|
||||||
|
```python
|
||||||
|
PEER_LINKS = [
|
||||||
|
{"label": "Back door", "url": "https://backdoor.yourdomain.com"},
|
||||||
|
{"label": "Squirrel", "url": "https://squirrel.yourdomain.com"},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
Leave it as `[]` (the default) and the row is hidden. Restart with
|
||||||
|
`sudo systemctl restart doorbell` after editing.
|
||||||
|
|
||||||
## Audio stack
|
## Audio stack
|
||||||
|
|
||||||
ALSA-only -- no PipeWire/PulseAudio. Lighter on the Pi Zero. If you ever
|
ALSA-only -- no PipeWire/PulseAudio. Lighter on the Pi Zero. If you ever
|
||||||
|
|||||||
+34
-40
@@ -20,10 +20,17 @@ 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.
|
# Camera this Pi corresponds to. Must match a go2rtc stream name in
|
||||||
# Names must match go2rtc stream names in frigate_config/config.yml and
|
# frigate_config/config.yml. The PTT button talks to the speaker physically
|
||||||
# the corresponding camera must be enabled in Frigate.
|
# attached to this Pi, so this should be the camera at the same location.
|
||||||
CAMERAS = ["front_door"]
|
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>
|
PAGE = """<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -51,9 +58,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;
|
.row a{flex:1;padding:10px;background:#333;color:#fff;border-radius:8px;
|
||||||
border-radius:8px;font-size:14px;appearance:none;-webkit-appearance:none;
|
font-size:13px;text-decoration:none;text-align:center;
|
||||||
text-align:center;text-align-last:center}
|
display:flex;align-items:center;justify-content: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>
|
||||||
@@ -63,9 +70,7 @@ 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">
|
<div class="row" id="peers" style="display:none"></div>
|
||||||
<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>
|
||||||
@@ -78,43 +83,33 @@ PAGE = """<!doctype html>
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
// ---- CONFIG ---------------------------------------------------------
|
// ---- CONFIG ---------------------------------------------------------
|
||||||
const CAMERAS = {{ cameras|tojson }};
|
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 $ = 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');
|
peersRow = $('peers');
|
||||||
|
|
||||||
let ws, mediaRecorder, micStream, wakeLock = null, pc = null;
|
let ws, mediaRecorder, micStream, wakeLock = null;
|
||||||
|
|
||||||
const log = m => { status.textContent = m; console.log('[doorbell]', m); };
|
const log = m => { status.textContent = m; console.log('[doorbell]', m); };
|
||||||
|
|
||||||
let currentCamera = localStorage.getItem('camera');
|
if (PEERS.length) {
|
||||||
if (!CAMERAS.includes(currentCamera)) currentCamera = CAMERAS[0];
|
for (const p of PEERS) {
|
||||||
|
const a = document.createElement('a');
|
||||||
for (const c of CAMERAS) {
|
a.href = p.url;
|
||||||
const opt = document.createElement('option');
|
a.textContent = p.label;
|
||||||
opt.value = c; opt.textContent = c;
|
peersRow.appendChild(a);
|
||||||
if (c === currentCamera) opt.selected = true;
|
|
||||||
camsel.appendChild(opt);
|
|
||||||
}
|
}
|
||||||
if (CAMERAS.length > 1) camrow.style.display = 'flex';
|
peersRow.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;
|
|
||||||
|
async function startVideo(){
|
||||||
try {
|
try {
|
||||||
pc = new RTCPeerConnection();
|
const 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]; };
|
||||||
@@ -123,8 +118,7 @@ async function startVideo(camera){
|
|||||||
const offer = await pc.createOffer();
|
const offer = await pc.createOffer();
|
||||||
await pc.setLocalDescription(offer);
|
await pc.setLocalDescription(offer);
|
||||||
|
|
||||||
const url = '/frigate/api/go2rtc/api/webrtc?src=' + encodeURIComponent(camera);
|
const resp = await fetch(FRIGATE_WEBRTC_URL, {
|
||||||
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,
|
||||||
@@ -133,7 +127,7 @@ async function startVideo(camera){
|
|||||||
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: ' + camera);
|
log('Camera connected');
|
||||||
} catch(e){ log('Video error: ' + e.message); }
|
} catch(e){ log('Video error: ' + e.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +210,7 @@ ptt.addEventListener('mousedown', startTalking);
|
|||||||
ptt.addEventListener('mouseup', stopTalking);
|
ptt.addEventListener('mouseup', stopTalking);
|
||||||
ptt.addEventListener('mouseleave', stopTalking);
|
ptt.addEventListener('mouseleave', stopTalking);
|
||||||
|
|
||||||
startVideo(currentCamera);
|
startVideo();
|
||||||
setupPTT();
|
setupPTT();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
@@ -226,7 +220,7 @@ setupPTT();
|
|||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
return render_template_string(PAGE, cameras=CAMERAS)
|
return render_template_string(PAGE, camera_name=CAMERA_NAME, peers=PEER_LINKS)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/healthz')
|
@app.route('/healthz')
|
||||||
|
|||||||
Reference in New Issue
Block a user