10.7. Wypychanie zdarzeń do panelu

Panel musi zostać poinformowany w chwili, gdy wyzwoli się ruch – nie przy kolejnym odpytaniu. Do tego służą Server-Sent Events: jedno połączenie HTTP od przeglądarki do kamery, a kamera wypycha nim zdarzenia, kiedy tylko się pojawią.

10.7.1. Korutyna detektora ruchu

Trzecie zadanie najwyższego poziomu działa obok pętli przechwytywania i serwera HTTP. Czeka na każdą nową ramkę, wykonuje różnicowanie względem poprzedniej ramki i – gdy zmiana przekroczy skonfigurowany próg – zwiększa licznik i sygnalizuje zdarzenie:

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)

Implementacja compute_change jest poza zakresem tego rozdziału – sekcja o przetwarzaniu obrazu omawia różnicowanie ramek jak należy. Na razie traktuj ją jako symbol zastępczy zwracający liczbę.

Dodaj nowe zadanie do main:

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

10.7.2. Trasa /events

microdot.sse.with_sse() dekoruje asynchroniczny handler, tak aby microdot wykonał uściskanie dłoni SSE (status 200, Content-Type: text/event-stream, brak buforowania) i przekazał handlerowi obiekt SSE. Handler pozostaje aktywny tak długo, jak przeglądarka utrzymuje otwarte połączenie:

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() zapisuje jedno zdarzenie na łącze i oddaje sterowanie z powrotem do pętli zdarzeń. event='motion' nadaje nazwę typowi zdarzenia, aby EventSource po stronie przeglądarki mógł zarejestrować nasłuch tylko dla tej nazwy. event_id= (nie pokazane) ustawia linię id:, dzięki czemu przeglądarka może wznowić od znanego przesunięcia przy ponownym połączeniu za pomocą nagłówka Last-Event-ID.

Wysłanie z 15-sekundowym limitem czasu + comment=True to sztuczka podtrzymująca połączenie. Linie komentarza zaczynają się od : i przeglądarka całkowicie je ignoruje, ale bajty przesyłane łączem powstrzymują pośrednie serwery proxy i urządzenia NAT przed zerwaniem bezczynnego połączenia.

10.7.3. Panel konsumuje zdarzenia

Dodaj to do 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);
});

Przeglądarka otwiera jedno trwałe połączenie HTTP do /events i automatycznie otwiera je ponownie przy każdym rozłączeniu. Każde zdarzenie motion wypchnięte przez kamerę pojawia się jako nowe <li> na górze listy.

Właściciel widzi teraz zdarzenia ruchu w chwili ich wyzwolenia.