10.7. イベントをダッシュボードにプッシュする

ダッシュボードは、次のポーリング時ではなく、動きが発火したその瞬間に通知される必要があります。それを行うのがServer-Sent Eventsです。ブラウザからカメラへの1本のHTTP接続を張り、カメラはイベントが発生するたびにそれを通じてイベントをプッシュします。

10.7.1. 動き検出コルーチン

キャプチャループおよびHTTPサーバーと並行して、3番目のトップレベルタスクが実行されます。これは新しいフレームを待ち、前のフレームとの差分を計算し、変化が設定されたしきい値を超えたときにカウンターを増やしてイベントを通知します。

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)

compute_change の実装はこの章の対象範囲外です。フレーム差分については画像処理のセクションで適切に扱います。今のところは、数値を返すプレースホルダーとして扱ってください。

新しいタスクを main に追加します。

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

10.7.2. /events ルート

microdot.sse.with_sse() は非同期ハンドラをデコレートして、microdotがSSEハンドシェイク(ステータス200、Content-Type: text/event-stream、バッファリングなし)を実行し、ハンドラに SSE オブジェクトを渡すようにします。ハンドラは、ブラウザが接続を開いている限り起き続けます。

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() は1つのイベントを通信に書き込み、イベントループに制御を戻します。event='motion' はイベントタイプに名前を付け、ブラウザ側の EventSource がその名前だけにリスナーを登録できるようにします。event_id=(図示せず)は id: 行を設定し、ブラウザが Last-Event-ID ヘッダー経由で再接続時に既知のオフセットから再開できるようにします。

15秒のタイムアウト + comment=True の送信は、キープアライブのトリックです。コメント行は : で始まり、ブラウザはそれを完全に無視しますが、通信上を流れるバイトが、中間のプロキシやNATボックスがアイドル接続を切断するのを防ぎます。

10.7.3. ダッシュボードがイベントを消費する

これを 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);
});

ブラウザは /events への永続的なHTTP接続を1本開き、切断時には自動的に再度開きます。カメラがプッシュするすべての motion イベントは、リストの先頭に新しい <li> として表示されます。

所有者はこれで、動きイベントが発火した瞬間にそれを見ることができます。