10.7. Invio di eventi in push alla dashboard

La dashboard deve essere avvisata nell’istante in cui viene rilevato del movimento, non al polling successivo. È proprio ciò che fanno i Server-Sent Events: una connessione HTTP dal browser alla camera, e la camera vi invia gli eventi in push ogni volta che si verificano.

10.7.1. Una coroutine per il rilevatore di movimento

Un terzo task di primo livello viene eseguito insieme al ciclo di acquisizione e al server HTTP. Attende ogni nuovo frame, calcola la differenza rispetto al frame precedente e – quando la variazione supera la soglia configurata – incrementa un contatore e segnala un evento:

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)

L’implementazione di compute_change esula dall’ambito di questo capitolo: la sezione sull’elaborazione delle immagini tratta in modo approfondito la differenza tra frame. Per ora consideralo un segnaposto che restituisce un numero.

Aggiungi il nuovo task a main:

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

10.7.2. La route /events

microdot.sse.with_sse() decora un handler asincrono in modo che microdot esegua l’handshake SSE (stato 200, Content-Type: text/event-stream, nessun buffering) e passi all’handler un oggetto SSE. L’handler resta attivo finché il browser mantiene aperta la connessione:

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() scrive un evento sul canale e restituisce il controllo all’event loop. event='motion' assegna un nome al tipo di evento, così che l”EventSource lato browser possa registrare un listener solo per quel nome. event_id= (non mostrato) imposta la riga id: in modo che il browser possa riprendere da un offset noto alla riconnessione tramite l’header Last-Event-ID.

Il timeout di 15 secondi più l’invio di comment=True è il trucco del keep-alive. Le righe di commento iniziano con : e il browser le ignora completamente, ma i byte che transitano sul canale impediscono ai proxy intermedi e ai box NAT di interrompere una connessione inattiva.

10.7.3. La dashboard consuma gli eventi

Aggiungi questo ad 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);
});

Il browser apre un’unica connessione HTTP persistente verso /events e la riapre automaticamente a ogni disconnessione. Ogni evento motion che la camera invia in push compare come un nuovo <li> in cima all’elenco.

Il proprietario ora vede gli eventi di movimento nell’istante stesso in cui si verificano.