Bluetooth Low Energy
בכיס של כל תלמיד יש טלפון, ובו משדר Bluetooth מוכן. אז למה צריך רשת ושרת כדי לקרוא את הטמפרטורה בחדר מלוח שנמצא במרחק שני מטרים? בפרק הזה נגרום ללוח להכריז על עצמו, והטלפון או לוח אחר יקראו אותו ישירות. המושגים כאן חדשים, אבל מעטים, ואחרי שמבינים אותם התוכנית קצרה.
- נשווה בין BLE ל-Bluetooth הקלאסי ול-WiFi, ונבחר את המתאים לפרויקט.
- נסביר מהם התקן היקפי, התקן מרכזי ופרסום.
- נסביר את המבנה של GATT: שירות, מאפיין ו-UUID, ונקודד ערך לבייטים.
- נכתוב בספרייה
aiobleהתקן היקפי שמפרסם את הטמפרטורה, והתקן מרכזי שקורא אותה. - נבדוק את ההתקן ההיקפי מטלפון, באפליקציה כללית.
17.1 מתי לבחור ב-BLE
Bluetooth בצריכה נמוכה (Bluetooth Low Energy), וקיצורו BLE, תוכנן להתקנים קטנים ששולחים מעט ושותקים הרבה. הוא אינו גרסה חדשה יותר של ה-Bluetooth שאנחנו מכירים באוזניות, אלא דרך עבודה אחרת. הטבלה משווה במילים ולא במספרים, כי המספרים תלויים בשבב, באנטנה ובמקום:
| BLE | Bluetooth קלאסי | WiFi | |
|---|---|---|---|
| צריכת אנרגיה | נמוכה, עם תקופות שתיקה | גבוהה יותר | בדרך כלל הגבוהה ביותר |
| כמות הנתונים | קטנה: קריאות ופקודות | בינונית: שמע | גדולה: דפים ותמונות |
| גישה לאינטרנט | לא ישירה | לא ישירה | כן, דרך הנתב |
| מדבר עם טלפון בלי נתב | כן | כן | צריך רשת |
| במיקרו-פייתון | bluetooth ו-aioble | התיעוד אינו מזכיר אותו | network |
| שימוש אופייני | חיישן שטלפון קורא | אוזנייה | לוח שמדבר עם שרת |
לפי תיעוד המודול bluetooth, הוא תומך כיום ב-BLE. לכן Bluetooth קלאסי נמצא מחוץ למה שאפשר לתכנת כאן, גם אם השבב עצמו מסוגל לו.
17.2 התקן היקפי והתקן מרכזי (Peripheral and Central)
ב-BLE שני הצדדים אינם שווים בתחילת החיבור. אחד מכריז על עצמו וממתין, והשני מחפש ובוחר:
תיעוד המודול מזכיר עוד שני תפקידים שאינם צריכים חיבור: Broadcaster רק משדר, ו-Observer רק מאזין. אותו התקן יכול למלא יותר מתפקיד אחד בו בזמן.
כל פרסום מעיר את הרדיו. פרק זמן ארוך יותר חוסך אנרגיה, אבל ההתקן המרכזי צריך יותר זמן כדי למצוא אותנו.
17.3 מבנה GATT: שירות, מאפיין ו-UUID (Services and Characteristics)
אחרי החיבור ההתקן המרכזי רואה עץ מסודר של נתונים. המערכת שמסדרת אותו נקראת GATT. הצד שמחזיק בעץ הוא השרת, בדרך כלל ההיקפי, והצד שקורא הוא הלקוח:
בוחרים ב-0x181A לשירות מדידת הסביבה וב-0x2A6E לטמפרטורה, כי שני המזהים האלה מוכרים, ולכן האפליקציה הכללית מבינה אותם. המפרט דורש שהטמפרטורה תהיה מספר שלם בן 16 סיביות עם סימן, ביחידות של מאית המעלה, כשהבייט הפחות משמעותי בא ראשון. את זה עושה הספרייה struct בפורמט "<h".
בבסיס הקסדצימלי: 0x0929. הבייט הפחות משמעותי בא ראשון, ולכן שולחים 29 09.
struct.pack("<h", n) במיקרו-פייתון.17.4 הלוחות והספרייה (Boards and Library)
| הלוח | BLE במיקרו-פייתון | מקור המידע |
|---|---|---|
| ESP32 | קיים | דף ההורדה של הקושחה ESP32_GENERIC |
| Pico W | קיים | דף ההורדה RPI_PICO_W |
| Pico 2 W | קיים | דף ההורדה RPI_PICO2_W |
| Raspberry Pi 4/5 | לא: רצה עליו לינוקס, לא מיקרו-פייתון | פרק 22 |
המודול bluetooth שבקושחה הוא ברמה נמוכה, והתיעוד שלו עצמו ממליץ על הספרייה aioble לרוב היישומים. היא חלק מ-micropython-lib, ולפי התיעוד שלה היא דורשת מיקרו-פייתון 1.17 ומעלה. מתקינים אותה פעם אחת מהמחשב, או מהלוח אחרי חיבור לרשת, בפונקציה mip.install('aioble'):
mpremote mip install aioble
הספרייה מחולקת לחלקים, ולכן אפשר להתקין רק את החלקים הדרושים, כמו aioble-peripheral ו-aioble-server, אם הזיכרון צר.
aioble נשענת על asyncio, ונסביר אותה בפרק 19. כאן מספיק לדעת: async def מגדירה משימה, await פירושה "המתן, ותן לאחרים לעבוד", ו-asyncio.gather מריצה שתי משימות יחד. כך התוכנית מודדת טמפרטורה בזמן שהיא ממתינה לחיבור.17.5 התקן היקפי ששולח טמפרטורה (A Temperature Peripheral)
התוכנית בונה את העץ, ואז מפעילה שתי משימות: משימה שמודדת וכותבת את הערך, ומשימה שמפרסמת וממתינה לחיבור. היא משתמשת בחיישן DHT22 מפרק 13.
import asyncio
import struct
import aioble
import bluetooth
import dht
from machine import Pin
import board
ENV_SERVICE = bluetooth.UUID(0x181A)
TEMP_CHAR = bluetooth.UUID(0x2A6E)
ADV_INTERVAL_US = 250_000
service = aioble.Service(ENV_SERVICE)
temp = aioble.Characteristic(service, TEMP_CHAR, read=True, notify=True)
aioble.register_services(service)
sensor = dht.DHT22(Pin(board.DHT))
async def measure_task():
while True:
sensor.measure()
data = struct.pack("<h", round(sensor.temperature() * 100))
temp.write(data, send_update=True)
await asyncio.sleep(5)
async def advertise_task():
while True:
async with await aioble.advertise(
ADV_INTERVAL_US, name="desk1-temp", services=[ENV_SERVICE]
) as conn:
print("connected:", conn.device)
await conn.disconnected(timeout_ms=None)
async def main():
await asyncio.gather(measure_task(), advertise_task())
asyncio.run(main())| פקודה | מה היא עושה |
|---|---|
| aioble.Service(uuid) | יוצרים שירות. |
| aioble.Characteristic(s, uuid, read=True, notify=True) | מאפיין בתוך השירות, ולצדו מה שמותר. |
| aioble.register_services(s) | רושמים את העץ ברדיו. |
| temp.write(data, send_update=True) | כותבים את הערך ושולחים התראה למי שביקש. |
| aioble.advertise(us, name=, services=) | מפרסמים וממתינים עד שמישהו מתחבר, ואז מקבלים את החיבור. |
| conn.disconnected(timeout_ms=None) | ממתינים לסיום החיבור. |
while True סביב advertise הכרחית: אחרי ניתוק החיבור ההתקן חוזר לפרסם. בלעדיה ההתקן נעשה בלתי נראה אחרי הביקור הראשון, והתוכנית עדיין רצה, ולכן אנחנו חושבים שהתקלה בטלפון.send_update=True? ומה רואה לקוח שקרא את הערך פעם אחת, אם נמחק אותו?17.6 בדיקה מהטלפון (Testing from a Phone)
לפני שכותבים את ההתקן המרכזי מוודאים שההיקפי עובד, באפליקציה כללית כמו nRF Connect. השלבים כלליים, ושמות הלחצנים עשויים להשתנות בין גרסאות:
- מפעילים את התוכנית בלוח ואת ה-Bluetooth בטלפון.
- באפליקציה מתחילים סריקה SCAN ומחפשים את desk1-temp ברשימה.
- לוחצים CONNECT והשירותים מופיעים. התוכנית מדפיסה בלוח את השורה
connected. - פותחים את השירות 0x181A, ואחר כך את המאפיין 0x2A6E.
- מבקשים קריאה ורואים את הערך. ייתכן שיופיעו בייטים בבסיס הקסדצימלי, או טמפרטורה מפוענחת, לפי האפליקציה.
- מבקשים התראות, והערך משתנה מעצמו בכל מדידה.
17.7 התקן מרכזי שקורא (A Central That Reads)
לוח שני מחפש את ההתקן לפי שמו, מתחבר אליו, ואז יורד בעץ: שירות, מאפיין, קריאה. זה מה שעשינו ידנית בטלפון.
import asyncio
import struct
import aioble
import bluetooth
ENV_SERVICE = bluetooth.UUID(0x181A)
TEMP_CHAR = bluetooth.UUID(0x2A6E)
TARGET = "desk1-temp"
async def find_sensor():
async with aioble.scan(5000, active=True) as scanner:
async for result in scanner:
if result.name() == TARGET:
return result.device
return None
async def read_loop(device):
conn = await device.connect()
async with conn:
service = await conn.service(ENV_SERVICE)
char = await service.characteristic(TEMP_CHAR)
while conn.is_connected():
data = await char.read()
celsius = struct.unpack("<h", data)[0] / 100
print("temp: %.2f C" % celsius)
await asyncio.sleep(5)
async def main():
device = await find_sensor()
if device is None:
print("sensor not found")
return
try:
await read_loop(device)
except asyncio.TimeoutError:
print("timeout")
asyncio.run(main())| פקודה | מה היא עושה |
|---|---|
| aioble.scan(ms, active=True) | סורקים פרסומים למשך זמן מוגדר, בסריקה פעילה כמו בדוגמת הספרייה. |
| result.name() | שם ההתקן, אם הגענו אליו. |
| device.connect() | מבקשים חיבור. כישלון מופיע כ-asyncio.TimeoutError. |
| conn.service(uuid) | מחפשים שירות בהתקן הרחוק. |
| service.characteristic(uuid) | מחפשים בתוכו מאפיין. |
| char.read() | קוראים את הערך כבייטים. |
| conn.is_connected() | האם אנחנו עדיין מחוברים? |
data ישירות, היינו רואים משהו כמו b")\t" במקום הטמפרטורה. שני הצדדים צריכים להסכים על הפורמט "<h" ועל הקנה מידה 100: הפרוטוקול אינו בודק זאת, ולכן כל הבדל נותן מספר שגוי בלי שגיאה.17.8 מגבלות והבדלים מתועדים (Documented Limits)
- תיעוד המודול
bluetoothאומר שהוא עדיין בפיתוח, וייתכן שהממשק שלו ישתנה. לכן קוראים את התיעוד של הגרסה שבה משתמשים. - ערוצי L2CAP אינם נתמכים ב-ESP32, לפי התיעוד. אנחנו לא צריכים אותם כאן.
- צימוד נתמך רק בחלק מהיציאות. לכן התוכניות שלנו כאן חשופות: כל טלפון קרוב יכול להתחבר ולקרוא, ולכן לא שמים בהן סוד ולא פותחים בהן דלת.
- מטען הפרסום קטן, ולכן בוחרים שם קצר.
- בספר הזה לא בדקנו צריכת אנרגיה, טווח, או עבודה בו בזמן עם WiFi בלוח אמיתי. לכן איננו נותנים עליהם מספרים, ומודדים אותם בפרויקט שלנו.
ble_sensor.py אינה מתאימה כמות שהיא לפתיחת מנעול דלת?עיקרי הפרק:
- BLE מתאים לכמות קטנה של נתונים ולאנרגיה נמוכה, ו-WiFi לרשת ולאינטרנט.
- ההיקפי מפרסם וממתין, והמרכזי סורק ומבקש להתחבר.
- אחרי החיבור קוראים עץ: התקן, אחר כך שירות, אחר כך מאפיין, ולכל אחד מהם יש UUID.
- הערך הוא בייטים, והפורמט הוא הסכם בין שני הצדדים.
aiobleנשענת עלasyncio, וההתקן ההיקפי חוזר לפרסם אחרי כל ניתוק.- התוכניות בפרק הזה לא הורצו בבניית הספר, ולכן קודם בודקים אותן בלוח אמיתי מהטלפון.
פקודות הפרק:
| פקודה | מה היא עושה |
|---|---|
| bluetooth.UUID(0x181A) | מזהה בן 16 סיביות. |
| aioble.Service(uuid) | שירות. |
| aioble.Characteristic(s, uuid, ...) | מאפיין. |
| aioble.register_services(s) | רישום העץ. |
| aioble.advertise(us, name=, services=) | פרסום והמתנה לחיבור. |
| aioble.scan(ms, ...) | סריקת פרסומים. |
| device.connect() | בקשת חיבור. |
| conn.service(u), s.characteristic(u) | חיפוש בעץ. |
| char.read() | קריאה. |
| temp.write(d, send_update=True) | כתיבה והתראה. |
מילון מונחים:
| עברית | English | ערבית |
|---|---|---|
| Bluetooth Low Energy | Bluetooth Low Energy | البلوتوث منخفض الطاقة |
| התקן היקפי | Peripheral | الجهاز الطرفي |
| התקן מרכזי | Central | الجهاز المركزي |
| פרסום | Advertising | الإعلان |
| שירות | Service | الخدمة |
| מאפיין | Characteristic | الخاصية |
| התראה | Notification | الإشعار |
| צימוד | Pairing | الاقتران |
notify. לא בדקנו כאן את פקודות ההרשמה בצד הלקוח, ולכן פונים לתיעוד של aioble.שאלות לתרגול:
TARGET במרכזי. מה המרכזי מדפיס?sensor not found.bluetooth.UUID('7b1e0d3a-4c52-4f6e-9a10-2f6d8c31a5e7'), כלומר מחרוזת בת 128 סיביות שמפיקים בכלי. האפליקציה תציג את הבייטים שלו, ולא תדע מה פירושם.