10.7. Skicka händelser till instrumentpanelen

Instrumentpanelen behöver få veta i det ögonblick rörelse utlöser – inte vid nästa pollning. Det är vad Server-Sent Events gör: en HTTP-anslutning från webbläsaren till kameran, och kameran skickar ned händelser genom den när de inträffar.

10.7.1. En rörelsedetektor-koroutin

En tredje toppnivåuppgift körs vid sidan av infångningsslingan och HTTP-servern. Den väntar på varje ny bildruta, kör en jämförelse mot föregående bildruta och – när ändringen är över det konfigurerade tröskelvärdet – ökar en räknare och signalerar en händelse:

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)

Implementeringen av compute_change ligger utanför detta kapitels omfattning – avsnittet om bildbehandling behandlar bildrutejämförelse ordentligt. För tillfället, behandla det som en platshållare som returnerar ett tal.

Lägg till den nya uppgiften i main:

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

10.7.2. Rutten /events

microdot.sse.with_sse() dekorerar en async-hanterare så att microdot utför SSE-handskakningen (status 200, Content-Type: text/event-stream, ingen buffring) och överlämnar ett SSE-objekt till hanteraren. Hanteraren förblir vaken så länge som webbläsaren håller anslutningen öppen:

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() skriver en händelse till tråden och lämnar tillbaka kontrollen till händelseslingan. event='motion' namnger händelsetypen så att webbläsarsidans EventSource kan registrera en lyssnare för just det namnet. event_id= (visas inte) sätter id:-raden så att webbläsaren kan återuppta från en känd offset vid återanslutning via huvudet Last-Event-ID.

Sändningen med 15 sekunders timeout + comment=True är keep-alive-tricket. Kommentarsrader börjar med : och webbläsaren ignorerar dem helt, men byten som rör sig över tråden hindrar mellanliggande proxyservrar och NAT-boxar från att döda en inaktiv anslutning.

10.7.3. Instrumentpanelen konsumerar händelser

Lägg till detta i 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);
});

Webbläsaren öppnar en beständig HTTP-anslutning till /events och öppnar den automatiskt på nytt vid varje frånkoppling. Varje motion-händelse som kameran skickar visas som en ny <li> högst upp i listan.

Ägaren ser nu rörelsehändelser i det ögonblick de utlöser.