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 แบบ beaconlimited_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=Falsebuffer จะทำงานเหมือน 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 มีสำหรับการแลกเปลี่ยนเวลาแฝงในการค้นหากับอายุแบตเตอรี่