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. الإعلان¶
بمجرد وضع قاعدة البيانات، يكون الإعلان استدعاءً واحدًا لإجراء متزامن ينتظر اتصالًا:
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-- يستخدم علم قابل للاكتشاف المحدود بدلًا من قابل للاكتشاف العام؛ تتعامل بعض أنظمة التشغيل مع الاثنين بشكل مختلف في واجهة الاقتران لديها.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) التالية من أي عميل. لكنه بحد ذاته لا يدفع القيمة الجديدة -- لن يرى العميل المشترك أي شيء حتى يستطلع العميل أو ترسل الطرفية إشعارًا صريحًا.
جانب الدفع هو كلمة مفتاحية واحدة في نفس الاستدعاء:
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() اتصال الكاتب فقط؛ وتعيش القيمة الجديدة في المخزن المؤقت الداعم للخاصية ويجلبها التطبيق باستخدام read(). إذا وصلت كتابة ثانية قبل أن يقرأ التطبيق الأولى، فإن القيمة الثانية تكتب فوق الأولى في المخزن المؤقت وتُفقد القيمة الأصلية -- ولا تزال written() توقظ التطبيق، لكن مرة واحدة فقط لكل "يوجد شيء جديد"، وليس مرة واحدة لكل كتابة.
تصحح الكلمة المفتاحية capture=True ذلك. تُلحق كل كتابة واردة بطابور على مستوى الوحدة، وتُرجع written() صفًا (connection, data) لكل كتابة فردية -- فترى حلقة التطبيق كل واحدة منها مرة واحدة بالضبط، بترتيب الوصول. هناك نتيجتان عمليتان:
الطابور محدود الحجم ومشترك عبر كل خاصية مفعّل فيها الالتقاط على الجهاز. تُحتمل دفعات قصيرة من الكتابات المتتالية؛ أما الفيضان المستمر (وصول الكتابات أسرع مما يستنزفه التطبيق) فيُسقط بصمت الإدخالات الأقدم في الطابور، ويمكن لحركة المرور المتقطعة على خاصية واحدة أن تطرد إدخالات معلقة من خاصية أخرى.
اختر
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)
تأخذ دالة رد النداء عينة من المستشعر وتحدّث قيمة الخاصية قبل أن تخدم مكدّس 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 لخدمة البطارية، و 0x181A للاستشعار البيئي، و 0x180D لمعدل ضربات القلب، وهكذا) يعني أن قائمة Bluetooth العامة في الهاتف أو أي تطبيق ماسح تابع لجهة خارجية يمكنه تحديد غرض الجهاز دون أي كود عميل مخصص. كما أن تخطيط البايتات داخل كل خاصية قياسية محدد أيضًا بواسطة المواصفات -- مستوى البطارية (0x2A19) هو بايت واحد من 0 إلى 100؛ ودرجة الحرارة (0x2A6E) هي sint16 بترتيب البايتات الصغير أولًا بوحدات 0.01 درجة مئوية. وبالنسبة للتطبيقات التي لا تتناسب مع خدمة قياسية، أنشئ معرّف UUID بطول 128 بِت مرة واحدة واستخدمه عبر خدمات الجهاز وخصائصه.
الطرفية التي تنشر معرّفات UUID مخصصة فقط لا تزال جيدة -- إنها تحتاج فقط إلى تطبيق عميل مخصص يعرف تلك المعرّفات.
ملاحظة
قيم BLE هي بترتيب البايتات الصغير في كل مكان -- مواصفات GATT، وكل خاصية قياسية، وكل حقل إعلان. تذهب الأعداد الصحيحة متعددة البايتات على السلك بالبايت الأدنى أولًا. البادئة < في سلاسل تنسيق struct هي ما تريده للترميز/فك الترميز ("<h" و "<H" و "<I" ...)؛ واستخدام ترتيب البايتات الأصلي الافتراضي على MCU بترتيب البايتات الصغير يصادف أن يعمل الآن، لكن كتابة < صراحةً هي العادة الآمنة.
11.9.7. الراديو وراء كل ذلك¶
الراديو يعمل في اللحظة التي يلمسه فيها أول إجراء متزامن من aioble. وحتى اتصال جهاز مركزي، تقضي الطرفية وقتها في التبديل بين دفعات إعلان قصيرة والنوم؛ وبعد الاتصال تتبع فترة الاتصال المتفاوض عليها. تدفع الطرفية تكلفة طاقة صغيرة لكل إعلان، لذا فإن اختيار interval_us في aioble.advertise() هو أكثر المفاتيح مباشرةً المتاحة للطرفية للمفاضلة بين زمن استجابة الاكتشاف وعمر البطارية.