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> на початку списку.

Власник тепер бачить події руху миттєво, як тільки вони спрацьовують.