10.7. 將事件推播至儀表板

儀表板需要在動作觸發的那一刻就被告知——而非在下一次輪詢時。這正是 Server-Sent Events 的作用:一個從瀏覽器到相機的 HTTP 連線,相機便在事件發生時隨即將事件往下推送。

10.7.1. 動作偵測器協程

第三個頂層任務會與擷取迴圈以及 HTTP 伺服器並行執行。它會等待每個新影格、對前一影格執行差異運算,並——當變化超過所設定的閾值時——遞增一個計數器並發出一個事件訊號:

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() 會把一個事件寫到傳輸線路上,並讓出控制權回到事件迴圈。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 連線,並在任何斷線時自動重新開啟它。相機推播的每一個 motion 事件都會作為一個新的 <li> 出現在清單的最上方。

擁有者現在會在動作觸發的瞬間就看到動作事件。