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()는 async 핸들러를 데코레이트하여 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 전송은 킵얼라이브(keep-alive) 트릭입니다. 주석 줄은 :으로 시작하며 브라우저는 그것을 완전히 무시하지만, 전송 경로를 오가는 바이트가 중간의 프록시와 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>로 나타납니다.

이제 소유자는 모션 이벤트가 발생하는 즉시 그것을 봅니다.