11.10. العمل كجهاز مركزي

الجانب الآخر من المحادثة هو الجهاز المركزي -- وهو الجهاز الذي يمسح بحثًا عن الطرفيات المُعلِنة، ويختار واحدًا للتحدث إليه، ويفتح اتصالًا، ويتنقل عبر قاعدة بيانات GATT البعيدة، ويقرأ خصائصها أو يشترك فيها. فالكاميرا التي تجمع القراءات من مستشعر يُرتدى، أو تستمع إلى منارة (beacon)، أو تتحدث إلى متحكم دقيق مرافق، هي جهاز مركزي.

يمر نمط الجهاز المركزي في aioble بأربع مراحل: المسح، والاتصال، والاستكشاف، والتشغيل.

11.10.1. المسح

تُرجع aioble.scan() مدير سياق غير متزامن يعمل أيضًا بوصفه مُكرِّرًا غير متزامن على الأجهزة المُكتشفة. والاستخدام النموذجي هو المسح حتى يظهر جهاز مهم، ثم الخروج من التكرار:

import aioble
import asyncio
import bluetooth

HR_SERVICE = bluetooth.UUID(0x180D)

async def find_heart_rate():
    async with aioble.scan(duration_ms=5000, active=True) as scanner:
        async for result in scanner:
            if HR_SERVICE in result.services():
                return result.device
    return None

تحدد duration_ms=5000 المدة التي يستمر فيها المسح؛ بينما تمسح duration_ms=0 إلى الأبد (حتى يخرج مدير السياق). وتطلب active=True استجابات المسح، مما يضاعف حجم الحمولة لكل جهاز مقابل إرسال إضافي صغير من كلا الطرفين. أما الوسيطتان المفتاحيتان المتبقيتان interval_us / window_us فتضبطان دورة عمل راديو الماسح نفسه، ونادرًا ما تُغيَّران عن قيمهما الافتراضية.

تكشف كل aioble.ScanResult عن عنوان الجهاز، وآخر قيمة RSSI، وبايتات الإعلان واستجابة المسح الخام، إلى جانب مساعدات تحلل الحقول القياسية:

  • result.device -- aioble.Device جاهز لاستدعاء connect() عليه.

  • result.rssi -- مؤشر قوة الإشارة المستقبَلة بالديسيبل-ملي واط (dBm)، مفيد لمنطق "اختر الأقرب".

  • result.name() -- سلسلة الاسم المحلي، أو None إذا لم تكن مُعلَنة.

  • result.services() -- مولّد لـ bluetooth.UUID لكل خدمة يعلن عنها الجهاز.

  • result.manufacturer() -- مولّد لصفوف (company_id, data) للحقول الخاصة بالمصنّع.

  • result.connectable -- ما إذا كان أحدث إعلان قابلًا للاتصال.

تُعاد إعادة إنتاج ScanResult نفسها عند ورود بيانات إعلان جديدة للجهاز نفسه، بحيث يمكن لمستمع سلبي لا يريد سوى تتبع الأجهزة إلى أجل غير مسمى أن يشغّل المُكرِّر غير المتزامن إلى الأبد ويوزّع المعالجة عند كل حدث.

11.10.2. الاتصال

بمجرد تحديد جهاز مستهدف، يكون فتح الاتصال بعبارة await واحدة:

async def talk_to(device):
    connection = await device.connect()           # 10 s timeout
    async with connection:
        # ... do GATT work ...
        pass

تأخذ aioble.Device.connect() الوسيطة timeout_ms (مدة الانتظار حتى ينشأ الاتصال؛ والقيمة الافتراضية 10 ثوانٍ)، والوسيطتين min_conn_interval_us / max_conn_interval_us (نطاق فترة الاتصال المطلوب من الاتصالات).

11.10.2.1. إعادة الاتصال بنظير معروف دون مسح

بمجرد وجود ارتباط مع نظير، يكون العنوان معروفًا بالفعل، وتُعد جولة مسح واختيار أخرى وقتًا مهدورًا من الراديو. أنشئ aioble.Device مباشرةً بالعنوان المحفوظ، وانتقل مباشرةً إلى connect()

import aioble

KITCHEN_CAM = aioble.Device(aioble.ADDR_PUBLIC,
                            "aa:bb:cc:dd:ee:ff")

async def talk_to_kitchen():
    async with await KITCHEN_CAM.connect() as connection:
        # ... GATT work ...
        pass

الوسيطة الأولى هي إحدى aioble.ADDR_PUBLIC (عنوان المصنع للمتحكم) أو aioble.ADDR_RANDOM (عنوان خاص ثابت أو قابل للحل مُولَّد)؛ والثانية إما قيمة bytes من ستة بايتات أو سلسلة سداسية عشرية مفصولة بنقطتين رأسيتين. ويمكن حفظ السمتين addr_type وaddr لأي Device (مثل واحد حُصِل عليه سابقًا من ScanResult) وإعادتها هنا.

تُعد aioble.DeviceConnection المُرجعة الأساس الذي تتعلق به بقية أعمال الجهاز المركزي. وتضمن async with إغلاق الاتصال عند خروج الكتلة -- عند النجاح، أو عند الإلغاء، أو عند أي استثناء بما في ذلك aioble.DeviceDisconnectedError الناتج عن انصراف النظير.

إذا احتاج الجهاز المركزي إلى قيمة خاصية أكبر مما تسمح به وحدة MTU الافتراضية البالغة 23 بايتًا، فهذا هو المكان المناسب للتفاوض عليها:

await connection.exchange_mtu(512)

(تُرجع exchange_mtu() قيمة MTU المتفاوض عليها فعليًا، وهي الحد الأدنى من القيمة المطلوبة وما يدعمه النظير.)

11.10.3. الاستكشاف

يتنقل الاستكشاف عبر قاعدة بيانات GATT البعيدة للعثور على الخدمات والخصائص حسب معرّفاتها UUID. وهناك نوعان: مُوجَّه (تعرف المعرّف UUID وتريد شيئًا محددًا) وشامل (تريد كل شيء).

المُوجَّه -- الحالة الشائعة:

service = await connection.service(HR_SERVICE)
if service is None:
    return                                        # no such service

char = await service.characteristic(HR_MEASUREMENT)
if char is None:
    return                                        # no such characteristic

تأخذ كل من aioble.DeviceConnection.service() وaioble.ClientService.characteristic() معرّف bluetooth.UUID وتُرجع الكائن المطابق (أو None). ولكليهما وسيطة مفتاحية timeout_ms لكل عملية استكشاف، قيمتها الافتراضية ثانيتان.

الشامل:

async for service in connection.services():
    print("service:", service.uuid)
    async for char in service.characteristics():
        print("  characteristic:", char.uuid, "properties:", hex(char.properties))

هذا ما تفعله تطبيقات استكشاف Bluetooth العامة -- مفيد للتطوير، وأقل فائدة للشيفرة الإنتاجية التي تعرف معرّفات UUID التي تتوقعها.

11.10.3.1. فحص ما تدعمه الخاصية

يُرجع الاستكشاف قناع البتات الخاص بخصائص GATT الذي أعلنه النظير لكل خاصية بوصفه properties. والبتات هي تلك المعرّفة في GATT -- القراءة (0x02)، والكتابة دون استجابة (0x04)، والكتابة (0x08)، والإشعار (0x10)، والإشارة (0x20)، وما إليها. ويتيح فحص قناع البتات قبل إصدار عملية لعميل عام أن يتكيف مع خصائص لا يعرف قدراتها مسبقًا:

_PROP_READ = const(0x02)
_PROP_NOTIFY = const(0x10)

char = await service.characteristic(STATUS_UUID)
if char.properties & _PROP_NOTIFY:
    await char.subscribe(notify=True)
    value = await char.notified()
elif char.properties & _PROP_READ:
    value = await char.read()
else:
    value = None                                  # nothing the client can do

الشيفرة الإنتاجية التي تعرف بالفعل ملف GATT الخاص بالنظير لا تحتاج عادةً إلى هذا -- إذ وُثّقت معرّفات UUID مسبقًا. أما العملاء العامون / الاستكشافيون (صفحة إعدادات تتنقل في جهاز مجهول، أو مضيف ملحقات) فيعتمدون عليه.

11.10.4. التشغيل

بمجرد أن يحمل الجهاز المركزي ClientCharacteristic، تكون كل عملية GATT استدعاء إجراء متزامن واحد:

  • القراءة. أصدر قراءة GATT واحصل على القيمة:

    value = await char.read()
    print("value:", value)
    

    تُعالَج القراءات الطويلة (القيم الأكبر من وحدة MTU) بشفافية.

  • الكتابة. أرسل قيمة جديدة إلى الخادم:

    await char.write(b"\\x01")
    

    تنتظر response=True استجابة كتابة وتطلق aioble.GattError إذا رفض الخادم الكتابة. أما response=False فهي كتابة دون استجابة: أطلِق وانسَ. وتختار response=None (القيمة الافتراضية) تلقائيًا بناءً على ما أعلنه النظير.

  • الاشتراك. فعّل الإشعارات أو الإشارات بالكتابة إلى وصف CCCD الخاص بالخاصية:

    await char.subscribe(notify=True)
    

    بعد عودة هذا، يستطيع الجهاز المركزي انتظار الدفعات الواردة.

  • الإشعار / الإشارة. انتظر الدفعة التالية من الخادم:

    while True:
        data = await char.notified()
        print("push:", data)
    

    تنتظر timeout_ms=None (القيمة الافتراضية) إلى الأبد؛ مرّر عددًا صحيحًا بالميلي ثانية للاستسلام بعد فترة.

وضع المراحل الأربع معًا يعطي البرنامج المركزي القياسي "اتصل، اشترك، تدفّق":

async def stream_heart_rate():
    async with aioble.scan(duration_ms=5000, active=True) as scanner:
        async for result in scanner:
            if HR_SERVICE in result.services():
                device = result.device
                break
        else:
            return

    async with await device.connect() as connection:
        service = await connection.service(HR_SERVICE)
        char = await service.characteristic(HR_MEASUREMENT)
        await char.subscribe(notify=True)
        while connection.is_connected():
            data = await char.notified()
            print("hr push:", data)

asyncio.run(stream_heart_rate())

الأمر برمته نحو اثني عشر سطرًا ويغطي التدفق من "لا يوجد Bluetooth قيد التشغيل" إلى "تدفق البيانات المباشر". يطابق مُكرِّر المسح نمط البث/المراقبة، ويفتح connect اتصال GAP، ويتنقل service / characteristic في شجرة GATT، وتكتب subscribe وصف CCCD، وتنتظر notified الدفعات.

11.10.5. حالات قطع الاتصال وإعادة الاتصال

أي شيء يحدث لوصلة الراديو يظهر في الإجراء المتزامن الذي كان ينتظر عليها. ويُعد aioble.DeviceDisconnectedError الإشارة إلى أن النظير قد انصرف أو أن مهلة الإشراف قد انتهت؛ ويُنهي الاستثناء أيّ استدعاء read() أو write() أو notified() كان قيد التنفيذ، وتخرج أي كتلة async with connection بنظافة.

الجهاز المركزي الذي يُفترض أن يعيد الاتصال عند فقدانه يغلّف العمل في حلقته الخارجية الخاصة:

async def keep_streaming():
    while True:
        try:
            await stream_heart_rate()
        except aioble.DeviceDisconnectedError:
            print("disconnected, retrying...")
            await asyncio.sleep(2)

11.10.5.1. تأطير سلسلة عمليات بـ timeout()

عندما يُفترض أن تكتمل عدة عمليات GATT متتالية ضمن ميزانية واحدة -- لا كل واحدة على حدة بمهلتها timeout_ms الخاصة -- استخدم aioble.DeviceConnection.timeout() لتغليفها. ويلغي مدير السياق المُرجَع جسمه إذا انقضت الميزانية (مطلِقًا asyncio.TimeoutError) أو إذا انقطع اتصال النظير (مطلِقًا aioble.DeviceDisconnectedError):

async with await device.connect() as connection:
    try:
        with connection.timeout(2000):                    # 2 s for the whole block
            service = await connection.service(HR_SERVICE)
            char = await service.characteristic(HR_MEASUREMENT)
            await char.subscribe(notify=True)
    except asyncio.TimeoutError:
        print("discovery + subscribe took too long")

هذا هو البديل الأنظف عن تغليف كل استدعاء على حدة بـ asyncio.wait_for()، ويتجنب حالات النجاح الزائف حيث يفي كل استدعاء بموعده النهائي الخاص بينما تتجاوز السلسلة ككل المدة المحددة. وتمرير timeout_ms=None إلى timeout() يعطّل الموعد النهائي ويترك حارس قطع الاتصال نشطًا فقط.