11.10. פעולה כצומת מרכזי

הצד השני של השיחה הוא המרכזי (central) – ההתקן שסורק אחר התקנים היקפיים משדרי פרסום, בוחר אחד לדבר איתו, פותח חיבור, עובר על מסד הנתונים המרוחק של GATT, וקורא או נרשם למאפיינים שעליו. מצלמה שאוספת קריאות מחיישן לביש, מאזינה למשואה, או מדברת עם בקר מיקרו נלווה היא מרכזי.

דפוס המרכזי ב-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.deviceaioble.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 כדי למצוא את השירותים והמאפיינים לפי ה-UUIDs שלהם. יש שני סוגים: ממוקד (אתם יודעים את ה-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 לכל גילוי שברירת המחדל שלו היא 2 שניות.

ממצה:

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 כלליות עושות – שימושי לפיתוח, פחות מכך לקוד ייצור שיודע אילו UUIDs הוא מצפה להם.

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 של העמית בדרך כלל אינו זקוק לכך – ה-UUIDs תועדו מראש. לקוחות כלליים / חקרניים (עמוד הגדרות שעובר על התקן לא מוכר, מארח תוספים) נשענים על כך.

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() משביתה את המועד האחרון ומשאירה פעיל רק את שומר הניתוק.