10.7. Mendorong acara ke dasbor

Dasbor perlu diberi tahu pada saat gerakan terdeteksi -- tidak pada polling berikutnya. Itulah yang dilakukan Server-Sent Events: satu koneksi HTTP dari browser ke kamera, dan kamera mendorong acara ke bawahnya kapan pun terjadi.

10.7.1. Coroutine detektor gerakan

Tugas tingkat atas ketiga berjalan bersama loop pengambilan dan server HTTP. Ia menunggu setiap bingkai baru, menjalankan perbedaan terhadap bingkai sebelumnya, dan -- ketika perubahan berada di atas ambang batas yang dikonfigurasi -- menaikkan penghitung dan memberi sinyal suatu acara:

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)

Implementasi compute_change berada di luar cakupan bab ini -- bagian pemrosesan citra mencakup perbedaan bingkai dengan benar. Untuk sekarang anggap saja sebagai placeholder yang mengembalikan sebuah angka.

Tambahkan tugas baru ke main:

async def main():
    await asyncio.gather(
        capture_loop(),
        motion_detector(),
        app.start_server(host='0.0.0.0', port=80),
    )

10.7.2. Rute /events

microdot.sse.with_sse() mendekorasi handler async sehingga microdot melakukan handshake SSE (status 200, Content-Type: text/event-stream, tanpa buffering) dan memberikan handler sebuah objek SSE. Handler tetap aktif selama browser menjaga koneksi tetap terbuka:

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() menulis satu acara ke kabel dan menghasilkan kembali ke event loop. event='motion' memberi nama tipe acara sehingga EventSource sisi browser dapat mendaftarkan listener hanya untuk nama itu. event_id= (tidak ditampilkan) mengatur baris id: sehingga browser dapat melanjutkan dari offset yang diketahui saat koneksi ulang melalui header Last-Event-ID.

Timeout 15 detik + pengiriman comment=True adalah trik keep-alive. Baris komentar dimulai dengan : dan browser mengabaikannya sepenuhnya, tetapi byte yang bergerak melalui kabel mencegah proxy perantara dan kotak NAT dari memutus koneksi yang diam.

10.7.3. Dasbor mengonsumsi acara

Tambahkan ini ke 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);
});

Browser membuka satu koneksi HTTP persisten ke /events dan membukanya kembali secara otomatis pada setiap pemutusan sambungan. Setiap acara motion yang didorong kamera muncul sebagai <li> baru di bagian atas daftar.

Pemilik sekarang melihat acara gerakan pada saat mereka terjadi.