10.7. Pushing events to the dashboard¶
The dashboard needs to be told the moment motion fires – not on the next poll. That’s what Server-Sent Events do: one HTTP connection from the browser to the cam, and the cam pushes events down it whenever they happen.
10.7.1. A motion-detector coroutine¶
A third top-level task runs alongside the capture loop and the HTTP server. It waits for each new frame, runs a difference against the previous frame, and – when the change is above the configured threshold – bumps a counter and signals an event:
import time
motion_event = asyncio.Event()
last_motion = None
async def motion_detector():
global last_motion
prev = None
while True:
await new_frame.wait()
change = compute_change(prev, latest_jpeg)
if change > state['threshold']:
state['trigger_count'] += 1
last_motion = {
'ts': time.time(),
'count': state['trigger_count'],
'change': change,
}
motion_event.set()
prev = latest_jpeg
await asyncio.sleep_ms(50)
The compute_change implementation is out of scope for this
chapter – the image-processing section covers frame-differencing
properly. For now treat it as a placeholder that returns a number.
Add the new task to main:
async def main():
await asyncio.gather(
capture_loop(),
motion_detector(),
app.start_server(host='0.0.0.0', port=80),
)
10.7.2. The /events route¶
microdot.sse.with_sse() decorates an async handler so microdot
performs the SSE handshake (status 200, Content-Type:
text/event-stream, no buffering) and hands the handler an
SSE object. The handler stays awake for as long
as the browser keeps the connection open:
from microdot.sse import with_sse
@app.get('/events')
@with_sse
async def events(request, sse):
while True:
try:
await asyncio.wait_for(motion_event.wait(), timeout=15)
motion_event.clear()
if last_motion:
await sse.send(last_motion, event='motion')
except asyncio.TimeoutError:
await sse.send('keepalive', comment=True)
send() writes one event to the wire and
yields back to the event loop. event='motion' names the event
type so the browser-side EventSource can register a listener for
just that name. event_id= (not shown) sets the
id: line so the browser can resume from a known offset on
reconnect via the Last-Event-ID header.
The 15-second timeout + comment=True send is the keep-alive
trick. Comment lines start with : and the browser ignores them
entirely, but the bytes moving over the wire stop intermediate
proxies and NAT boxes from killing an idle connection.
10.7.3. The dashboard consumes events¶
Add this to app.js:
const events = document.getElementById('events');
const source = new EventSource('/events');
source.addEventListener('motion', (e) => {
const data = JSON.parse(e.data);
const li = document.createElement('li');
const t = new Date(data.ts * 1000).toLocaleTimeString();
li.textContent = t + ' -- change ' + data.change;
events.prepend(li);
});
The browser opens one persistent HTTP connection to /events and
re-opens it automatically on any disconnect. Every motion event
the cam pushes appears as a new <li> at the top of the list.
The owner now sees motion events the instant they fire.