10.7. Émettre des événements vers le tableau de bord

Le tableau de bord doit être averti à l’instant même où un mouvement se déclenche – pas au prochain sondage. C’est ce que font les Server-Sent Events : une connexion HTTP unique du navigateur vers la caméra, et la caméra y émet des événements dès qu’ils surviennent.

10.7.1. Une coroutine de détection de mouvement

Une troisième tâche de premier niveau s’exécute en parallèle de la boucle de capture et du serveur HTTP. Elle attend chaque nouvelle trame, effectue une différence avec la trame précédente, et – lorsque le changement dépasse le seuil configuré – incrémente un compteur et signale un événement :

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’implémentation de compute_change sort du cadre de ce chapitre – la section sur le traitement d’image couvre correctement la différence entre trames. Pour l’instant, considérez-la comme un espace réservé qui renvoie un nombre.

Ajoutez la nouvelle tâche à 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() décore un gestionnaire asynchrone afin que microdot effectue la poignée de main SSE (statut 200, Content-Type: text/event-stream, sans mise en tampon) et transmette au gestionnaire un objet SSE. Le gestionnaire reste actif aussi longtemps que le navigateur maintient la connexion ouverte :

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() écrit un événement sur le canal et rend la main à la boucle d’événements. event='motion' nomme le type d’événement afin que l”EventSource côté navigateur puisse enregistrer un écouteur pour ce seul nom. event_id= (non montré) définit la ligne id: pour que le navigateur puisse reprendre depuis un décalage connu lors d’une reconnexion via l’en-tête Last-Event-ID.

L’envoi avec un délai d’expiration de 15 secondes + comment=True est l’astuce du maintien en vie (keep-alive). Les lignes de commentaire commencent par : et le navigateur les ignore entièrement, mais les octets qui circulent sur le canal empêchent les proxys intermédiaires et les boîtiers NAT de fermer une connexion inactive.

10.7.3. Le tableau de bord consomme les événements

Ajoutez ceci à 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);
});

Le navigateur ouvre une connexion HTTP persistante unique vers /events et la rouvre automatiquement à chaque déconnexion. Chaque événement motion émis par la caméra apparaît sous forme d’un nouveau <li> en haut de la liste.

Le propriétaire voit désormais les événements de mouvement à l’instant même où ils se déclenchent.