10.7. Ereignisse an das Dashboard senden

Dem Dashboard muss in dem Moment mitgeteilt werden, in dem Bewegung ausgelöst wird – nicht beim nächsten Polling. Genau das tun Server-Sent Events: eine HTTP-Verbindung vom Browser zur Kamera, und die Kamera schiebt Ereignisse über diese hinunter, wann immer sie auftreten.

10.7.1. Eine Bewegungsmelder-Koroutine

Eine dritte Top-Level-Task läuft neben der Aufnahmeschleife und dem HTTP-Server. Sie wartet auf jedes neue Einzelbild, führt einen Vergleich mit dem vorherigen Einzelbild durch und – wenn die Änderung über dem konfigurierten Schwellenwert liegt – erhöht einen Zähler und signalisiert ein Ereignis:

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)

Die Implementierung von compute_change liegt außerhalb des Geltungsbereichs dieses Kapitels – der Abschnitt zur Bildverarbeitung behandelt die Einzelbild-Differenzbildung ordnungsgemäß. Behandeln Sie es vorerst als Platzhalter, der eine Zahl zurückgibt.

Fügen Sie die neue Task zu main hinzu:

async def main():
    await asyncio.gather(
        capture_loop(),
        motion_detector(),
        app.start_server(host='0.0.0.0', port=80),
    )

10.7.2. Die /events-Route

microdot.sse.with_sse() dekoriert einen asynchronen Handler, sodass microdot den SSE-Handshake durchführt (Status 200, Content-Type: text/event-stream, keine Pufferung) und dem Handler ein SSE-Objekt übergibt. Der Handler bleibt so lange wach, wie der Browser die Verbindung offen hält:

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() schreibt ein Ereignis auf die Leitung und gibt die Kontrolle an die Ereignisschleife zurück. event='motion' benennt den Ereignistyp, sodass das browserseitige EventSource einen Listener für genau diesen Namen registrieren kann. event_id= (nicht gezeigt) setzt die id:-Zeile, sodass der Browser bei einer Wiederverbindung über den Last-Event-ID-Header von einem bekannten Offset aus fortsetzen kann.

Die 15-Sekunden-Zeitüberschreitung in Kombination mit dem comment=True-Senden ist der Keep-Alive-Trick. Kommentarzeilen beginnen mit :, und der Browser ignoriert sie vollständig, aber die Bytes, die über die Leitung wandern, hindern zwischengeschaltete Proxys und NAT-Boxen daran, eine inaktive Verbindung zu beenden.

10.7.3. Das Dashboard verarbeitet Ereignisse

Fügen Sie dies zu app.js hinzu:

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);
});

Der Browser öffnet eine dauerhafte HTTP-Verbindung zu /events und öffnet sie bei jeder Trennung automatisch erneut. Jedes motion-Ereignis, das die Kamera sendet, erscheint als neues <li> oben in der Liste.

Der Besitzer sieht nun Bewegungsereignisse in dem Moment, in dem sie ausgelöst werden.