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);
});

Браузер открывает одно постоянное HTTP-соединение к /events и автоматически переоткрывает его при любом разрыве. Каждое событие motion, которое отправляет камера, появляется как новый <li> в начале списка.

Теперь владелец видит события движения в тот же миг, когда они срабатывают.