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.