برمجة المتحكّمات بلغة مايكروبايثون · الفصل 17 — بلوتوث منخفض الطاقة BLEBluetooth Low Energy · תכנות מיקרו-בקרים בשפת MicroPython⌂📚🏠
الفصل 17

بلوتوث منخفض الطاقة BLE

Bluetooth Low Energy
3 ساعات تعليمية

الهاتف في جيب كل طالب، وفيه راديو بلوتوث جاهز. فلماذا نحتاج إلى شبكة وخادم لنقرأ حرارة الغرفة من لوحة تبعد مترين؟ في هذا الفصل نجعل اللوحة تعلن عن نفسها، فيقرؤها الهاتف أو لوحة أخرى مباشرة. المفاهيم هنا جديدة، لكنها قليلة، وحين نفهمها يصبح البرنامج قصيرا.

في هذا الفصل:
  • نقارن بين BLE والبلوتوث الكلاسيكي وWiFi، ونختار المناسب لمشروع.
  • نشرح الجهاز الطرفي والجهاز المركزي والإعلان.
  • نشرح بنية GATT: الخدمة والخاصية وUUID، ونرمّز قيمة في بايتات.
  • نكتب بمكتبة aioble جهازا طرفيا ينشر الحرارة، وجهازا مركزيا يقرؤها.
  • نفحص الجهاز الطرفي من هاتف، بتطبيق عام.
تنبيه: لم يُشغَّل أي برنامج في هذا الفصل في بناء الكتاب. المحاكي لا يمثّل الراديو، فالبرامج تمر بفحص الترجمة فقط، ولا توجد شاشة مُخرَج. كتبناها حسب توثيق المكتبة، ولا نعرض هنا أي نتيجة لم نرها.

17.1 متى نختار BLE؟ – מתי לבחור ב-BLE

البلوتوث منخفض الطاقة (Bluetooth Low Energy)، واختصاره BLE، صُمّم لأجهزة صغيرة ترسل قليلا وتصمت طويلا. وهو ليس نسخة أحدث من البلوتوث الذي نعرفه في السماعات، بل طريقة عمل أخرى. والجدول يقارن بالكلام لا بالأرقام، لأن الأرقام تتعلّق بالشريحة والهوائي والمكان:

BLEبلوتوث كلاسيكيWiFi
استهلاك الطاقةمنخفض، ومع فترات صمتأعلىالأعلى عادة
كمية البياناتقليلة: قراءات وأوامرمتوسطة: صوتكبيرة: صفحات وصور
الوصول إلى الإنترنتليس مباشرةليس مباشرةنعم، عبر الموجّه
يكلّم الهاتف دون موجّهنعمنعميحتاج إلى شبكة
في مايكروبايثونbluetooth وaiobleلا يذكره التوثيقnetwork
استعمال نموذجيحسّاس يقرؤه هاتفسماعةلوحة تكلّم خادما

حسب توثيق وحدة bluetooth، هي تدعم حاليا BLE. ولذلك نعدّ البلوتوث الكلاسيكي خارج ما نستطيع برمجته هنا، حتى إذا كانت الشريحة نفسها قادرة عليه.

سؤال 17.1: حسّاس في مخزن يعمل على بطارية، ويُقرأ مرة في اليوم من هاتف عند المرور بجانبه. أيها أنسب؟ ولو أردنا عرض القراءة من بيت آخر؟

17.2 الجهاز الطرفي والجهاز المركزي – התקן היקפי והתקן מרכזי (Peripheral and Central)

في BLE لا يتساوى الطرفان عند بدء الاتصال. واحد يعلن عن نفسه وينتظر، والآخر يبحث ويختار:

الجهاز الطرفي (התקן היקפי): جهاز يعلن عن وجوده ويقبل الاتصال به. اسمه بالإنجليزية Peripheral. عادة حسّاس صغير.
الجهاز المركزي (התקן מרכזי): جهاز يفحص الإعلانات ويطلب الاتصال. اسمه بالإنجليزية Central. عادة هاتف، وقد يكون لوحة.
الإعلان (פרסום): رسالة قصيرة يبثّها الجهاز الطرفي مرة بعد مرة، دون اتصال بأحد، وفيها اسمه وما يقدّمه. اسمها بالإنجليزية advertising. وهذا غير «النشر» في MQTT: الإعلان ليس فيه وسيط، ويهدف إلى أن يجده أحد.
الجهاز الطرفيPeripheralحسّاس على لوحةالجهاز المركزيCentralهاتف أو لوحة1. إعلان متكرّر، لمن يسمع2. طلب اتصال3. قراءة وإشعاراتخادم على الطرفي، وعميل على المركزي
ثلاث مراحل: الطرفي يعلن، والمركزي يطلب الاتصال، ثم تجري البيانات على الاتصال.

وتوثيق الوحدة يذكر دورين آخرين لا يحتاجان إلى اتصال: Broadcaster يبثّ فقط، وObserver يستمع فقط. ويستطيع الجهاز نفسه أن يقوم بأكثر من دور معا.

مثال 17.1
نعلن كل 250000 µs، وهي القيمة التي نستعملها في البرنامج. كم إعلانا في الدقيقة؟
الحل:
T = 250000 µs
T = 0.25 s
n = 60T
n = 600.25
n = 240

كل إعلان يوقظ الراديو. الفترة الأطول توفّر الطاقة، لكن المركزي يحتاج إلى وقت أطول ليجدنا.

n = 240
سؤال 17.2: هاتف يقرأ حرارة من لوحة: من الجهاز الطرفي، ومن الجهاز المركزي؟ ومن يبدأ الاتصال؟

17.3 بنية GATT: الخدمة والخاصية وUUID – מבנה GATT: שירות ומאפיין (Services and Characteristics)

بعد الاتصال، يرى المركزي شجرة مرتّبة من البيانات. والنظام الذي يرتّبها اسمه GATT. الطرف الذي يملك الشجرة هو الخادم، وهو عادة الطرفي، والطرف الذي يقرأ هو العميل:

الخدمة (שירות): مجموعة بيانات مترابطة في الجهاز. اسمها بالإنجليزية service. مثل «قياس البيئة».
الخاصية (מאפיין): قيمة واحدة داخل الخدمة، ومعها ما يُسمح به: قراءة، كتابة، إشعار. اسمها بالإنجليزية characteristic. مثل «الحرارة».
الإشعار (התראה): رسالة يرسلها الخادم حين تتغيّر القيمة، إلى من طلب ذلك، فلا يحتاج العميل إلى أن يسأل كل مرة. اسمه بالإنجليزية notify.
المعرّف UUID (מזהה UUID): عدد يميّز كل خدمة وكل خاصية. وهو من 16 بتّا للأنواع المعرّفة في مواصفة البلوتوث، ومن 128 بتّا لما نخترعه بأنفسنا.
اللوحة: خادم GATTخدمةEnvironmental Sensing — UUID 0x181AخاصيةTemperature — UUID 0x2A6Eread, notifyvalue: 29 0923.45 °Cخدمة أخرى...
الشجرة التي نبنيها في برنامج هذا الفصل. القيمة في الخاصية بايتات لا غير.

نختار 0x181A لخدمة قياس البيئة و0x2A6E للحرارة، لأن هذين المعرّفين معروفان، فيفهمهما التطبيق العام. والمواصفة تطلب أن تكون الحرارة عددا صحيحا من 16 بتّا بإشارة، بوحدة جزء من مئة من الدرجة، والبايت الأقل أهمية أولا. وهذا ما تفعله مكتبة struct بالصيغة "<h".

مثال 17.2
نريد إرسال الحرارة 23.45 °C. ما البايتان اللذان نرسلهما؟
الحل:
N = T · 100
N = 23.45 · 100
N = 2345

بالست عشري: 0x0929. والبايت الأقل أهمية يأتي أولا، فنرسل 29 09.

N = 2345
سؤال 17.3: ما البايتان اللذان نرسلهما للحرارة −5.25 °C؟ استعملوا 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.4: لماذا لا نكتب برنامج البلوتوث على Raspberry Pi 4/5 بالمكتبة نفسها؟

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 ضرورية: بعد انقطاع الاتصال، يعود الجهاز إلى الإعلان. من دونها يصبح الجهاز غير مرئي بعد أول زيارة، والبرنامج ما زال يعمل، فنظن أن العطل في الهاتف.
سؤال 17.5: ما عمل send_update=True؟ وماذا يرى عميل قرأ القيمة مرة، إن حذفناها؟

17.6 الفحص من الهاتف – בדיקה מהטלפון (Testing from a Phone)

قبل أن نكتب الجهاز المركزي نتأكّد أن الطرفي يعمل، بتطبيق عام مثل nRF Connect. الخطوات عامة، وأسماء الأزرار قد تختلف من إصدار إلى آخر:

  1. نشغّل البرنامج على اللوحة، والبلوتوث على الهاتف.
  2. في التطبيق نبدأ الفحص SCAN، ونبحث عن desk1-temp في القائمة.
  3. نضغط CONNECT، فتظهر الخدمات. ويطبع البرنامج على اللوحة سطر connected.
  4. نفتح الخدمة 0x181A، ثم الخاصية 0x2A6E.
  5. نطلب القراءة، فنرى القيمة. وقد تظهر بايتات بالست عشري، أو حرارة مفكوكة، حسب التطبيق.
  6. نطلب الإشعارات، فتتغيّر القيمة وحدها عند كل قياس.
سؤال 17.6: عرض التطبيق القيمة 53 07. ما الحرارة؟
نشاط: على لوحة حقيقية: قرّبوا الهاتف وأبعدوه، وراقبوا قيمة RSSI في قائمة الفحص. سجّلوها في جدول، واكتبوا ما يدل عليه هذا العدد.

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.7: البرنامج يقرأ كل 5 ثوان. كيف نجعله يتلقّى القيمة حين تتغيّر، دون أن يسأل؟

17.8 حدود وفروق موثّقة – מגבלות והבדלים מתועדים (Documented Limits)

سؤال 17.8: لماذا لا يصلح برنامج ble_sensor.py كما هو لفتح قفل باب؟

أهمّ أفكار الفصل:

أوامر الفصل:

الأمرعمله
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 EnergyBluetooth Low Energy
الجهاز الطرفيהתקן היקפיPeripheral
الجهاز المركزيהתקן מרכזיCentral
الإعلانפרסוםAdvertising
الخدمةשירותService
الخاصيةמאפייןCharacteristic
الإشعارהתראהNotification
الاقترانצימודPairing
الإجابات النهائية لأسئلة الفصل
17.1 BLE للحالة الأولى. أما من بيت آخر فلا بد من الإنترنت، أي WiFi، أو جهاز وسيط يتصل بالاثنين.
17.2 اللوحة طرفية، والهاتف مركزي. والمركزي هو الذي يبدأ الاتصال.
17.3 f3 fd، لأن N = −525 ويُكتب بطريقة المكمّل لاثنين.
17.4 لأنها مكتبة مايكروبايثون، وRaspberry Pi 4/5 تشغّل بايثون العادي على لينكس.
17.5 ترسل إشعارا إلى المشتركين. من دونها تتغيّر القيمة في الخادم، لكن العميل لا يعرف إلا إذا قرأها من جديد.
17.6 نعكس البايتين: 0x0753 أي 1875، فالحرارة 18.75 °C.
17.7 بالإشعارات: الخاصية تدعم notify. ولم نتحقق هنا من أوامر الاشتراك في جهة العميل، فنرجع إلى توثيق aioble.
17.8 لأنه دون اقتران ولا تحقق، فأي جهاز قريب يستطيع الاتصال. والقفل يحتاج إلى حماية لم نتعلمها هنا.

أسئلة للتمرين:

المستوى الأول — أساسي
1.
ما الفرق بين الجهاز الطرفي والجهاز المركزي؟
الإجابات: الطرفي يعلن وينتظر، والمركزي يفحص ويطلب الاتصال.
2.
رتّبوا من الأكبر إلى الأصغر: خاصية، جهاز، خدمة.
الإجابات: جهاز، خدمة، خاصية.
3.
متى نستعمل UUID من 128 بتّا؟
الإجابات: لخدمة أو خاصية نخترعها، ليس لها معرّف من 16 بتّا في المواصفة.
المستوى الثاني — تطبيق
4.
ما الحرارة التي يعطيها البايتان 53 07؟ وما البايتان للحرارة −5.25 °C؟
الإجابات: 18.75 °C، والبايتان f3 fd.
5.
لدينا إعلان كل 100 ms. كم إعلانا في الدقيقة؟ وما أثر ذلك مقارنة ببرنامج الفصل؟
الإجابات: 600 إعلانا في الدقيقة، أكثر من 240. نجد الجهاز أسرع، بطاقة أكبر.
6.
غيّرنا اسم الجهاز في الجهاز الطرفي، ولم نغيّر TARGET في الجهاز المركزي. ماذا يطبع المركزي؟
الإجابات: بعد 5 ثوان من الفحص يطبع sensor not found.
المستوى الثالث — تحدٍّ
7.
أضيفوا إلى الخدمة خاصية ثانية نخترعها، لنسبة قراءة مقياس الجهد. كيف نكتب المعرّف؟ وهل يفهمها التطبيق العام؟
الإجابات: bluetooth.UUID('7b1e0d3a-4c52-4f6e-9a10-2f6d8c31a5e7')، أي نص من 128 بتّا نولّده بأداة. يظهر التطبيق بايتاتها، ولا يعرف معناها.
8.
مشروع: لوحة في صف، ومعلم يريد الحرارة على هاتفه في الصف، وعلى موقع من البيت. صمّموا الحل بجمل.
الإجابات: في الصف BLE يكفي. وللموقع نحتاج إلى WiFi وMQTT كما في الفصل 16. واللوحة تستطيع نظريا الاثنين، لكن علينا أن نختبر العمل المتزامن.