11.9. פעולה כהתקן היקפי¶
דפוס ה-BLE הנפוץ ביותר בצד המצלמה הוא לפעול כהתקן היקפי – לפרסם בסיס נתונים GATT קטן, להכריז על קיומו, לקבל חיבור מטלפון או מהתקן נלווה, ולשדר ערכים למי שנמצא בקצה השני.
11.9.1. בניית בסיס הנתונים GATT¶
הדבר הראשון שהתקן היקפי עושה באתחול – עוד לפני הפעלת הרדיו – הוא בניית בסיס הנתונים שבכוונתו לחשוף, יצירת אובייקטים עבור כל שירות ומאפיין, ולאחר מכן רישום כולם:
import aioble
import bluetooth
ENV_SERVICE = bluetooth.UUID(0x181A) # Environmental Sensing
TEMP_UUID = bluetooth.UUID(0x2A6E) # Temperature
HUMID_UUID = bluetooth.UUID(0x2A6F) # Humidity
env = aioble.Service(ENV_SERVICE)
temp_char = aioble.Characteristic(
env, TEMP_UUID,
read=True, notify=True, initial=b"\\x00\\x00",
)
humid_char = aioble.Characteristic(
env, HUMID_UUID,
read=True, notify=True, initial=b"\\x00\\x00",
)
aioble.register_services(env)
כל aioble.Characteristic מקושר לשירות שלו פשוט על ידי יצירתו כשהשירות הוא הארגומנט הראשון. ארגומנטי מילות המפתח הבוליאניים (read, write, write_no_response, notify, indicate) בוחרים אילו פעולות GATT יורשה הלקוח לבצע; העברת False (ברירת המחדל) משמעה שביט המאפיין אינו מוגדר.
aioble.register_services() מאשרת את העץ המורכב לשרת ה-GATT. עליה להיקרא פעם אחת, לפני שמתחילה כל aioble.advertise(); קריאה נוספת לה מחליפה את בסיס הנתונים הקודם.
11.9.2. הכרזה (Advertising)¶
לאחר שבסיס הנתונים במקומו, ההכרזה היא קריאת קורוטינה אחת הממתינה לחיבור:
async def serve_one():
connection = await aioble.advertise(
interval_us=250000,
name="openmv-env",
services=[ENV_SERVICE],
appearance=0x0540, # Generic Sensor
)
ארגומנטי מילות המפתח ממופים ישירות לשדות מטען ההכרזה. name הוא שדה השם המקומי; services הוא רשימת ה-UUID של השירותים שההתקן מארח (סורק בצד הטלפון יכול לסנן לפיהם); appearance הוא רמז מתוך ערכי המראה התקניים בני 16 הביט המאפשר להתקן המרכזי להציג סמל הגיוני. נתונים ספציפיים ליצרן מועברים דרך manufacturer=(company_id, data_bytes).
מספר מצומצם של מילות מפתח פחות נפוצות מכסה את שאר מרחב דגלי ההכרזה:
connectable=False– מצב שידור בלבד (לעולם לא מתקבל חיבור). הבחירה הנכונה למטענים בסגנון beacon.limited_disc=True– שימוש בדגל limited discoverable במקום general discoverable; מערכות הפעלה מסוימות מתייחסות לשניים באופן שונה בממשק ההתאמה (pairing) שלהן.adv_data/resp_data– בתים גולמיים אם היישום זקוק לשליטה מלאה על הפריסה.timeout_ms– ויתור לאחר זמן קבוע. ברירת המחדל היא להכריז ללא הגבלת זמן.
כאשר התקן מרכזי מתחבר, aioble.advertise() מחזירה את ה-aioble.DeviceConnection שנוצר. ההתקן ההיקפי מפסיק להכריז בנקודה זו.
11.9.3. שירות ללקוח אחד¶
הלולאה הראשית של התקן היקפי נראית בדרך כלל כך:
async def serve():
while True:
connection = await aioble.advertise(
interval_us=250000,
name="openmv-env",
services=[ENV_SERVICE],
)
print("connected:", connection.device.addr_hex())
async with connection:
await connection.disconnected()
print("disconnected; advertising again")
asyncio.run(serve())
async with connection הופך את ניקוי הניתוק לאוטומטי. disconnected() היא קורוטינה המושהית עד שאחד הצדדים מסיים את החיבור – דרך נקייה להמשיך לשרת בהתקן ההיקפי עד שההתקן המרכזי עוזב, ואז לחזור בלולאה להכרזה של הסבב הבא.
11.9.4. עדכון מאפיין¶
ההתקן ההיקפי מעדכן את בסיס הנתונים המקומי GATT באמצעות aioble.Characteristic.write()
temp_char.write(b"\\x9a\\x09") # 24.58 deg C as sint16, 0.01 units
פעולה זו משנה את הערך שתחזיר ה-read הבאה מכל לקוח. כשלעצמה, היא אינה דוחפת את הערך החדש – לקוח מנוי לא יראה דבר עד שהלקוח יבצע סקירה (poll) או עד שההתקן ההיקפי ישלח התראה מפורשת.
צד הדחיפה הוא מילת מפתח יחידה באותה קריאה:
temp_char.write(temp_bytes, send_update=True)
send_update=True מתריע (או מציין) לכל לקוח שנרשם למאפיין זה. רוב הקוד בסגנון חיישן שוכן במשימה ייעודית לכל חיבור, הקוראת את החיישן בלולאה וכותבת את הערך עם send_update=True כל שנייה בערך:
async def stream_temperature(connection):
while connection.is_connected():
temp_char.write(encode_temperature(read_sensor()), send_update=True)
await asyncio.sleep(1)
async def serve():
while True:
connection = await aioble.advertise(
interval_us=250000,
name="openmv-env",
services=[ENV_SERVICE],
)
async with connection:
asyncio.create_task(stream_temperature(connection))
await connection.disconnected()
אם תעדיף לכוון התראה ללקוח ספציפי אחד במקום לכל קבוצת המנויים (נניח תגובה פרטית לחיבור לפקודה של אותו לקוח), aioble.Characteristic.notify() ו-indicate() מקבלות ארגומנט DeviceConnection ומטען אופציונלי.
11.9.5. קבלת כתיבות¶
הכיוון ההפוך – לקוח הכותב למאפיין – הופך זמין כאשר המאפיין נוצר עם write=True או write_no_response=True. ההתקן ההיקפי ממתין לכתיבה הבאה באמצעות aioble.Characteristic.written()
cmd_char = aioble.Characteristic(env, CMD_UUID, write=True, capture=True)
async def handle_commands():
while True:
connection, data = await cmd_char.written()
print("command from", connection.device.addr_hex(), "=", data)
ללא capture=True, written() מחזירה רק את חיבור הכתיבה; הערך החדש שוכן בחוצץ הגיבוי (buffer) של המאפיין והיישום מאחזר אותו באמצעות read(). אם כתיבה שנייה מגיעה לפני שהיישום קרא את הראשונה, הערך השני דורס את הראשון בחוצץ והערך המקורי אובד – written() עדיין מעירה את היישום, אך רק פעם אחת לכל ”יש משהו חדש“, ולא פעם אחת לכל כתיבה.
מילת המפתח capture=True מתקנת זאת. כל כתיבה נכנסת מתווספת לתור ברמת המודול, ו-written() מחזירה צמד (connection, data) עבור כל כתיבה בודדת – לולאת היישום רואה כל אחת מהן בדיוק פעם אחת, בסדר ההגעה. שתי השלכות מעשיות:
התור מוגבל בגודלו ומשותף בין כל המאפיינים המאופשרים ל-capture בהתקן. התפרצויות קצרות של כתיבות רצופות נסבלות; הצפה מתמשכת (כתיבות המגיעות מהר יותר מכפי שהיישום מרוקן אותן) משמיטה בשקט את הערכים הישנים ביותר בתור, ותעבורה מתפרצת על מאפיין אחד עלולה לפנות ערכים ממתינים ממאפיין אחר.
בחר
capture=Trueעבור כתיבות בסגנון פקודה שבהן כל ערך חשוב. השאר אותה כבויה עבור מאפיינים בסגנון מצב שבהם רק הערך האחרון מעניין.
אם על קריאה מהלקוח להיענות על ידי קוד הרץ לפי דרישה במקום ערך סטטי, דרוס את on_read(). השיטה נקראת באופן סינכרוני כאשר מגיעה קריאה; החזר 0 כדי להתיר את הקריאה (הערך הנוכחי מ-write() יישלח), או קוד שגיאה ATT שונה מאפס כדי לדחותה:
import time
_ATT_ERR_READ_NOT_PERMITTED = const(0x02)
_MIN_READ_INTERVAL_MS = const(1000) # at most once per second
class TempChar(aioble.Characteristic):
_last_read_ms = 0
def on_read(self, connection):
now = time.ticks_ms()
if time.ticks_diff(now, self._last_read_ms) < _MIN_READ_INTERVAL_MS:
return _ATT_ERR_READ_NOT_PERMITTED
self._last_read_ms = now
self.write(encode_temperature(read_sensor()))
return 0
temp_char = TempChar(env, TEMP_UUID, read=True)
פונקציית ה-callback דוגמת את החיישן ומעדכנת את ערך המאפיין ממש לפני שמחסנית ה-GATT משרתת את הקריאה, כך שהלקוח תמיד רואה נתונים טריים. הגבלת הקצב מונעת מלקוח להציק לחיישן מהר יותר מכפי שניתן לדגום אותו – כל קריאה בתוך זמן הצינון של שנייה מוחזרת כשגיאת ATT מסוג Read Not Permitted במקום ערך מיושן.
11.9.5.1. חוצצי גיבוי גדולים יותר – BufferedCharacteristic¶
חוצץ הגיבוי עבור Characteristic רגיל הוא ברוחב 20 בתים – הגבול המעשי ב-MTU ברירת המחדל בן 23 הבתים. לקוח הכותב יותר מכך למאפיין רגיל מקבל את ערכו קטוע. עבור ערכים נכנסים גדולים יותר או עבור תור של כתיבות רצופות שלולאת היישום תדביק מאוחר יותר, הצהר על המאפיין כ-BufferedCharacteristic ובחר את גודל החוצץ מראש:
blob = aioble.BufferedCharacteristic(
service, BLOB_UUID,
max_len=512, append=True,
write=True, capture=True,
)
async def receive_blob():
while True:
connection, chunk = await blob.written()
handle_chunk(connection, chunk)
שני כפתורים מבדילים אותו מ-Characteristic רגיל:
max_lenהוא גודל חוצץ הגיבוי בבתים. בחר אותו כך שיתאים לכתיבה הבודדת הגדולה ביותר שהלקוח צפוי לבצע (לאחר משא ומתן על MTU).append=Trueגורם לכתיבות עוקבות להתווסף לחוצץ במקום לדרוס – שימושי לקבלת ערך המגיע לאורך מספר כתיבות (מקטעי עדכון קושחה, שורות יומן). עםappend=Falseהחוצץ מתנהג כמו מאפיין רגיל, רק רחב יותר.
כל שאר דגלי הבנאי (read, write, notify, indicate, capture, initial) מועברים ללא שינוי למאפיין הבסיסי.
11.9.6. שירותים תקניים וה-UUID המוקצים על ידי SIG¶
היצמדות ל-UUID מספרי המוקצים (0x180F עבור Battery Service, 0x181A עבור Environmental Sensing, 0x180D עבור Heart Rate, וכן הלאה) משמעה שתפריט ה-Bluetooth הכללי של טלפון או כל אפליקציית סורק של צד שלישי יכולים לזהות את מטרת ההתקן ללא קוד לקוח מותאם אישית כלשהו. גם פריסת הבתים בתוך כל מאפיין תקני קבועה על ידי המפרט – Battery Level (0x2A19) הוא בית בודד 0..100; Temperature (0x2A6E) הוא sint16 בסדר little-endian ביחידות של 0.01 מעלות צלזיוס. עבור יישומים שאינם תואמים לשירות תקני, צור UUID בן 128 ביט פעם אחת והשתמש בו לרוחב שירותי ומאפייני ההתקן.
התקן היקפי המפרסם רק UUID מותאמים אישית עדיין תקין – הוא פשוט זקוק לאפליקציית לקוח מותאמת אישית המכירה את אותם UUID.
הערה
ערכי BLE הם little-endian בכל מקום – מפרט ה-GATT, כל מאפיין תקני, כל שדה הכרזה. מספרים שלמים מרובי-בתים נשלחים על החוט כשהבית הנמוך ראשון. הקידומת < במחרוזות פורמט של struct היא מה שאתה רוצה לקידוד/פענוח ("<h", "<H", "<I", …); שימוש בסדר הבתים המקומי ברירת המחדל ב-MCU מסוג little-endian במקרה עובד לעת עתה, אך איות מפורש של < הוא ההרגל הבטוח.
11.9.7. הרדיו שמאחורי הכול¶
הרדיו פעיל ברגע שהקורוטינה הראשונה של aioble נוגעת בו. עד שמתחבר התקן מרכזי, ההתקן ההיקפי מבלה את זמנו במעבר בין התפרצויות הכרזה קצרות לשינה; לאחר חיבור הוא עוקב אחר מרווח החיבור שסוכם. ההתקן ההיקפי משלם עלות הספק קטנה לכל הכרזה, ולכן הבחירה של interval_us ב-aioble.advertise() היא הכפתור הישיר ביותר שיש להתקן היקפי לאיזון בין השהיית גילוי לבין חיי הסוללה.