11.9. การทำหน้าที่เป็น peripheral#

รูปแบบ BLE ฝั่งกล้องที่พบบ่อยที่สุดคือการทำหน้าที่เป็น peripheral -- เผยแพร่ฐานข้อมูล GATT ขนาดเล็ก, โฆษณาการมีอยู่ของตัวเอง, รับการเชื่อมต่อจากโทรศัพท์หรืออุปกรณ์คู่หู และส่งข้อมูลไปยังปลายทาง

11.9.1. การสร้างฐานข้อมูล GATT#

สิ่งแรกที่ peripheral ทำเมื่อเริ่มต้น -- แม้กระทั่งก่อนเปิดวิทยุ -- คือสร้างฐานข้อมูลที่วางแผนจะเปิดเผย, สร้างออบเจกต์สำหรับแต่ละ service และ characteristic จากนั้นลงทะเบียนทั้งหมด:

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 ถูกแนบกับ service โดยการสร้างด้วย service เป็นอาร์กิวเมนต์แรก อาร์กิวเมนต์คีย์เวิร์ดแบบบูลีน (read, write, write_no_response, notify, indicate) เลือกว่าการดำเนินการ GATT ใดที่ client จะได้รับอนุญาตให้ทำ การส่ง False (ค่าเริ่มต้น) หมายความว่าไม่ตั้งค่า property bit นั้น

aioble.register_services() ยืนยันโครงสร้างที่ประกอบแล้วไปยัง GATT server ต้องเรียกใช้หนึ่งครั้งก่อนที่ aioble.advertise() ใด ๆ จะเริ่มต้น การเรียกซ้ำจะแทนที่ฐานข้อมูลเดิม

11.9.2. การโฆษณา#

เมื่อฐานข้อมูลพร้อมแล้ว การโฆษณาเป็นการเรียก coroutine เดียวที่รอการเชื่อมต่อ:

async def serve_one():
    connection = await aioble.advertise(
        interval_us=250000,
        name="openmv-env",
        services=[ENV_SERVICE],
        appearance=0x0540,           # Generic Sensor
    )

อาร์กิวเมนต์คีย์เวิร์ดแมปตรงไปยังฟิลด์ payload การโฆษณา name คือฟิลด์ local-name; services คือรายการ UUID ของ service ที่อุปกรณ์โฮสต์ (สแกนเนอร์ฝั่งโทรศัพท์สามารถกรองด้วยค่าเหล่านี้ได้); appearance คือค่าบอกใบ้จากค่า appearance 16 บิตมาตรฐานที่ให้ central แสดงไอคอนที่เหมาะสม ข้อมูลเฉพาะผู้ผลิตส่งผ่าน manufacturer=(company_id, data_bytes)

คีย์เวิร์ดที่ใช้น้อยกว่าหลายตัวครอบคลุมพื้นที่ flag การโฆษณาที่เหลือ:

  • connectable=False -- โหมดส่งสัญญาณเท่านั้น (ไม่รับการเชื่อมต่อ) เหมาะสำหรับ payload แบบ beacon

  • limited_disc=True -- ใช้ flag limited discoverable แทน general discoverable; ระบบปฏิบัติการบางระบบปฏิบัติต่อทั้งสองแตกต่างกันใน UI การจับคู่

  • adv_data / resp_data -- bytes ดิบหากแอปพลิเคชันต้องการควบคุม layout อย่างสมบูรณ์

  • timeout_ms -- หยุดหลังจากเวลาที่กำหนด ค่าเริ่มต้นคือโฆษณาตลอดไป

เมื่อ central เชื่อมต่อ aioble.advertise() จะส่งคืน aioble.DeviceConnection ที่ได้ peripheral จะหยุดโฆษณา ณ จุดนี้

11.9.3. การให้บริการ client หนึ่งราย#

ลูปหลักของ peripheral มักมีลักษณะดังนี้:

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() คือ coroutine ที่หยุดชั่วคราวจนกว่าฝ่ายใดฝ่ายหนึ่งจะสิ้นสุดการเชื่อมต่อ -- วิธีที่สะอาดในการให้ peripheral ให้บริการจนกว่า central จะออกไป จากนั้นวนกลับไปโฆษณารอบถัดไป

11.9.4. การอัปเดต characteristic#

peripheral อัปเดตฐานข้อมูล GATT ในเครื่องด้วย aioble.Characteristic.write()

temp_char.write(b"\\x9a\\x09")              # 24.58 deg C as sint16, 0.01 units

นั่นเปลี่ยนค่าที่ read ถัดไปจาก client ใด ๆ จะได้รับ แต่ตัวมันเองไม่ได้ ผลักดัน ค่าใหม่ -- client ที่ subscribe จะไม่เห็นอะไรจนกว่า client จะ poll หรือ peripheral ส่ง notification อย่างชัดแจ้ง

ด้านการผลักดันคือคีย์เวิร์ดเดียวในการเรียกเดียวกัน:

temp_char.write(temp_bytes, send_update=True)

send_update=True แจ้งเตือน (หรือบ่งชี้) client ทุกรายที่ subscribe กับ characteristic นี้ โค้ดแบบ sensor ส่วนใหญ่อาศัยอยู่ใน task ต่อการเชื่อมต่อที่วนอ่าน sensor และเขียนค่าด้วย 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()

หากต้องการส่ง notification ไปยัง client เฉพาะรายแทนที่จะเป็นชุดที่ subscribe ทั้งหมด (เช่น การตอบสนองส่วนตัวต่อคำสั่งของ client นั้น) aioble.Characteristic.notify() และ indicate() รับอาร์กิวเมนต์ DeviceConnection และ payload ที่เป็นตัวเลือก

11.9.5. การรับการเขียน#

ทิศทางอื่น -- client เขียน ไปยัง characteristic -- จะพร้อมใช้งานเมื่อสร้าง characteristic ด้วย write=True หรือ write_no_response=True peripheral รอการเขียนถัดไปด้วย 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 สำรองของ characteristic และแอปพลิเคชันดึงด้วย read() หากการเขียนครั้งที่สองมาถึงก่อนที่แอปพลิเคชันจะอ่านครั้งแรก ค่าที่สองจะ เขียนทับ ครั้งแรกใน buffer และค่าเดิมจะหายไป -- written() ยังปลุกแอปพลิเคชัน แต่เพียงครั้งเดียวต่อ "มีบางอย่างใหม่" ไม่ใช่ครั้งต่อการเขียน

คีย์เวิร์ด capture=True แก้ไขปัญหานั้น การเขียนขาเข้าแต่ละครั้งจะถูกต่อท้ายใน queue ระดับโมดูล และ written() ส่งคืน tuple (connection, data) สำหรับการเขียนแต่ละครั้ง -- ลูปแอปพลิเคชันเห็นแต่ละครั้งตรงหนึ่งครั้ง ตามลำดับการมาถึง ผลที่ตามมาจริง ๆ สองประการ:

  • queue มีขนาดจำกัดและ ใช้ร่วมกันทั่วทุก characteristic ที่เปิดใช้งาน capture บนอุปกรณ์ การเขียนต่อเนื่องระยะสั้นเป็นที่ยอมรับได้; การล้นเกินอย่างต่อเนื่อง (การเขียนมาเร็วกว่าที่แอปพลิเคชันดึงออก) จะลบรายการใน queue ที่ เก่าที่สุด อย่างเงียบ ๆ และทราฟฟิกที่ไม่สม่ำเสมอบน characteristic หนึ่งอาจขับรายการที่รอดำเนินการจาก characteristic อื่น

  • เลือก capture=True สำหรับการเขียนแบบคำสั่งที่ทุกค่ามีความสำคัญ ปล่อยให้ปิดสำหรับ characteristic แบบ state ที่ค่าล่าสุดเป็นค่าที่น่าสนใจเพียงค่าเดียว

หากการอ่านจาก client ควรตอบสนองโดยโค้ดที่ทำงานตามต้องการแทนค่าคงที่ ให้ override on_read() เมธอดจะถูกเรียกแบบ synchronous เมื่อมีการอ่านเข้ามา ส่งคืน 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)

คอลแบ็กสุ่มตัวอย่าง sensor และอัปเดตค่าของ characteristic ก่อนที่ GATT stack จะให้บริการการอ่าน ดังนั้น client จะเห็นข้อมูลสดเสมอ ขีดจำกัดอัตราหยุด client จากการโจมตี sensor เร็วกว่าที่จะสุ่มตัวอย่างได้ -- การอ่านใด ๆ ภายใน cooldown หนึ่งวินาทีจะถูกส่งกลับเป็นข้อผิดพลาด ATT Read Not Permitted แทนค่าที่ล้าสมัย

11.9.5.1. บัฟเฟอร์สำรองขนาดใหญ่ -- BufferedCharacteristic#

บัฟเฟอร์สำรองสำหรับ Characteristic ปกติกว้าง 20 bytes -- ขีดจำกัดจริงที่ MTU เริ่มต้น 23 bytes client ที่เขียนมากกว่านั้นใน characteristic ปกติจะได้ค่าที่ถูกตัดทอน สำหรับค่าขาเข้าที่ใหญ่กว่าหรือสำหรับการจัดคิวการเขียนต่อเนื่องที่ลูปแอปพลิเคชันจะตามทันภายหลัง ให้ประกาศ characteristic เป็น BufferedCharacteristic และเลือกขนาด buffer ล่วงหน้า:

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 คือขนาดของ buffer สำรองเป็น bytes เลือกให้ตรงกับการเขียนครั้งเดียวที่ใหญ่ที่สุดที่ client คาดว่าจะทำ (หลังจากการเจรจา MTU)

  • append=True ทำให้การเขียนตามลำดับ ต่อท้าย เข้าใน buffer แทนการเขียนทับ -- มีประโยชน์สำหรับการรับค่าที่มาถึงผ่านการเขียนหลายครั้ง (ชิ้นส่วนอัปเดต เฟิร์มแวร์, บรรทัด log) เมื่อ append=False buffer จะทำงานเหมือน characteristic ปกติ แค่กว้างกว่า

flags constructor อื่น ๆ ทั้งหมด (read, write, notify, indicate, capture, initial) ส่งต่อไปยัง characteristic พื้นฐานโดยไม่มีการเปลี่ยนแปลง

11.9.6. Service มาตรฐานและ UUID ที่ SIG กำหนด#

การยึดติดกับ UUID ที่กำหนดหมายเลข (0x180F สำหรับ Battery Service, 0x181A สำหรับ Environmental Sensing, 0x180D สำหรับ Heart Rate และอื่น ๆ) หมายความว่าเมนู Bluetooth ทั่วไปของโทรศัพท์หรือแอปสแกนเนอร์ของบุคคลที่สามสามารถระบุวัตถุประสงค์ของอุปกรณ์ได้โดยไม่ต้องใช้โค้ด client แบบกำหนดเอง รูปแบบ byte ภายใน characteristic มาตรฐานแต่ละอันยังถูกกำหนดโดยสเปค -- Battery Level (0x2A19) คือ byte เดียว 0..100; Temperature (0x2A6E) คือ sint16 แบบ little-endian ในหน่วย 0.01 องศาเซลเซียส สำหรับแอปพลิเคชันที่ ไม่ เหมาะกับ service มาตรฐาน ให้สร้าง UUID 128 บิตหนึ่งครั้งและใช้กับ service และ characteristic ของอุปกรณ์

peripheral ที่เผยแพร่เฉพาะ UUID แบบกำหนดเองก็ยังใช้งานได้ -- แค่ต้องการแอป client แบบกำหนดเองที่รู้จัก UUID เหล่านั้น

Note

ค่า BLE เป็น little-endian ทุกที่ -- สเปค GATT, ทุก characteristic มาตรฐาน, ทุกฟิลด์การโฆษณา จำนวนเต็ม multi-byte ส่งผ่านสายโดย byte ต่ำก่อน prefix < ใน format string ของ struct คือสิ่งที่ต้องการสำหรับการเข้ารหัส/ถอดรหัส ("<h", "<H", "<I", ...); การใช้ byte order native เริ่มต้นบน MCU แบบ little-endian ใช้งานได้สำหรับตอนนี้ แต่การระบุ < อย่างชัดเจนเป็นนิสัยที่ปลอดภัย

11.9.7. วิทยุที่อยู่เบื้องหลังทั้งหมด#

วิทยุเปิดทันทีที่ coroutine ของ aioble แรกสัมผัส จนกว่า central จะเชื่อมต่อ peripheral ใช้เวลาสลับระหว่างชุดโฆษณาสั้น ๆ และการนอนหลับ หลังจากเชื่อมต่อแล้วจะตาม connection interval ที่เจรจาได้ peripheral จ่ายค่าพลังงานเล็กน้อยต่อการโฆษณาแต่ละครั้ง ดังนั้นการเลือก interval_us บน aioble.advertise() คือปุ่มโดยตรงที่สุดที่ peripheral มีสำหรับการแลกเปลี่ยนเวลาแฝงในการค้นหากับอายุแบตเตอรี่