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 خارج نطاق هذا الفصل -- إذ يغطي قسم معالجة الصور المقارنة بين الإطارات (frame-differencing) كما ينبغي. أما الآن فاعتبره عنصرًا نائبًا يُعيد عددًا.

أضف المهمة الجديدة إلى 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 هي حيلة إبقاء الاتصال حيًّا. فأسطر التعليقات تبدأ بـ : ويتجاهلها المتصفح تمامًا، لكن البايتات المتحركة عبر السلك تمنع الوسطاء (proxies) وصناديق 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> جديدًا في أعلى القائمة.

يرى المالك الآن أحداث الحركة لحظة وقوعها.