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> 出现在列表顶部。

现在拥有者会在运动触发的瞬间就看到运动事件。