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.