10.7. Enviar eventos al panel de control

Hay que avisar al panel de control en el momento en que se dispara el movimiento, no en el siguiente sondeo. Eso es lo que hacen los eventos enviados por el servidor (Server-Sent Events): una conexión HTTP desde el navegador a la cámara, y la cámara envía eventos por ella cada vez que ocurren.

10.7.1. Una corrutina de detección de movimiento

Una tercera tarea de nivel superior se ejecuta junto al bucle de captura y al servidor HTTP. Espera cada nuevo fotograma, ejecuta una diferencia contra el fotograma anterior y – cuando el cambio supera el umbral configurado – incrementa un contador y señala 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)

La implementación de compute_change queda fuera del alcance de este capítulo; la sección de procesamiento de imágenes cubre la diferenciación de fotogramas de forma adecuada. Por ahora, trátala como un marcador de posición que devuelve un número.

Añade la nueva tarea 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 ruta /events

microdot.sse.with_sse() decora un manejador asíncrono para que microdot realice el apretón de manos de SSE (estado 200, Content-Type: text/event-stream, sin almacenamiento en búfer) y entregue al manejador un objeto SSE. El manejador permanece despierto mientras el navegador mantenga la conexión abierta:

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() escribe un evento en la comunicación y cede el control de vuelta al bucle de eventos. event='motion' nombra el tipo de evento para que el EventSource del lado del navegador pueda registrar un escucha solo para ese nombre. event_id= (no mostrado) establece la línea id: para que el navegador pueda reanudar desde un desplazamiento conocido al reconectarse mediante la cabecera Last-Event-ID.

El tiempo de espera de 15 segundos junto con el envío de comment=True es el truco de keep-alive (mantener viva la conexión). Las líneas de comentario empiezan con : y el navegador las ignora por completo, pero los bytes moviéndose por la comunicación impiden que los proxies intermedios y las cajas NAT maten una conexión inactiva.

10.7.3. El panel de control consume eventos

Añade esto a 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);
});

El navegador abre una conexión HTTP persistente a /events y la vuelve a abrir automáticamente ante cualquier desconexión. Cada evento motion que la cámara envía aparece como un nuevo <li> en la parte superior de la lista.

El propietario ve ahora los eventos de movimiento en el instante en que se disparan.