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