10.7. Enviar eventos para o painel de controlo

O painel de controlo precisa de ser informado no momento em que é detetado movimento – não na próxima consulta. É para isso que servem os Server-Sent Events: uma ligação HTTP do browser para a câmara, e a câmara envia eventos por essa ligação sempre que ocorrem.

10.7.1. Uma corrotina de deteção de movimento

Uma terceira tarefa de nível superior é executada a par do ciclo de captura e do servidor HTTP. Aguarda cada novo fotograma, executa uma diferença em relação ao fotograma anterior e – quando a alteração está acima do limiar configurado – incrementa um contador e sinaliza um 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)

A implementação de compute_change está fora do âmbito deste capítulo – a secção de processamento de imagem aborda a diferenciação de fotogramas adequadamente. Por agora, trate-a como um espaço reservado que devolve um número.

Adicione a nova tarefa ao main:

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

10.7.2. A rota /events

microdot.sse.with_sse() decora um handler assíncrono para que o microdot realize o handshake SSE (estado 200, Content-Type: text/event-stream, sem buffering) e entregue ao handler um objeto SSE. O handler permanece ativo enquanto o browser mantiver a ligação aberta:

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() escreve um evento no canal e cede de volta ao ciclo de eventos. event='motion' nomeia o tipo de evento para que o EventSource do lado do browser possa registar um ouvinte apenas para esse nome. event_id= (não mostrado) define a linha id: para que o browser possa retomar a partir de um offset conhecido na reconexão, através do cabeçalho Last-Event-ID.

O timeout de 15 segundos com envio de comment=True é o truque de keep-alive. As linhas de comentário começam com : e o browser ignora-as completamente, mas os bytes a circular no canal impedem que proxies intermediários e caixas NAT encerrem uma ligação inativa.

10.7.3. O painel de controlo consome eventos

Adicione isto ao 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);
});

O browser abre uma ligação HTTP persistente para /events e reabre-a automaticamente em qualquer desconexão. Cada evento motion que a câmara envia aparece como um novo <li> no topo da lista.

O proprietário vê agora os eventos de movimento no instante em que ocorrem.