11.9. Hoạt động như một thiết bị ngoại vi

Mẫu BLE phổ biến nhất ở phía camera là hoạt động như một thiết bị ngoại vi -- công bố một cơ sở dữ liệu GATT nhỏ, quảng bá sự tồn tại của nó, chấp nhận kết nối từ điện thoại hoặc thiết bị đồng hành, và truyền phát các giá trị đến đầu kia.

11.9.1. Xây dựng cơ sở dữ liệu GATT

Điều đầu tiên một thiết bị ngoại vi thực hiện khi khởi động -- ngay cả trước khi bật radio -- là xây dựng cơ sở dữ liệu mà nó dự định công bố, tạo các đối tượng cho từng dịch vụ và đặc trưng, sau đó đăng ký tất cả:

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)

Mỗi aioble.Characteristic được gắn vào dịch vụ của nó đơn giản bằng cách khởi tạo nó với dịch vụ là đối số đầu tiên. Các đối số từ khóa boolean (read, write, write_no_response, notify, indicate) chọn các thao tác GATT mà client được phép thực hiện; truyền False (mặc định) có nghĩa là bit thuộc tính không được đặt.

aioble.register_services() đưa cây đã lắp ráp vào máy chủ GATT. Nó phải được gọi một lần, trước khi bất kỳ aioble.advertise() nào bắt đầu; gọi lại sẽ thay thế cơ sở dữ liệu trước đó.

11.9.2. Quảng bá

Sau khi cơ sở dữ liệu được thiết lập, việc quảng bá chỉ là một lời gọi coroutine chờ kết nối:

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

Các đối số từ khóa ánh xạ trực tiếp lên các trường tải trọng quảng bá. name là trường tên cục bộ; services là danh sách UUID dịch vụ mà thiết bị lưu trữ (máy quét phía điện thoại có thể lọc theo các giá trị này); appearance là gợi ý từ các giá trị giao diện 16-bit tiêu chuẩn cho phép thiết bị trung tâm hiển thị biểu tượng phù hợp. Dữ liệu dành riêng cho nhà sản xuất được truyền vào qua manufacturer=(company_id, data_bytes).

Một số từ khóa ít phổ biến hơn bao gồm phần còn lại của không gian cờ quảng bá:

  • connectable=False -- chế độ chỉ phát sóng (không chấp nhận kết nối nào). Lựa chọn đúng cho các tải trọng kiểu beacon.

  • limited_disc=True -- sử dụng cờ có thể phát hiện giới hạn thay vì có thể phát hiện chung; một số hệ điều hành xử lý hai loại này khác nhau trong giao diện người dùng ghép đôi.

  • adv_data / resp_data -- byte thô nếu ứng dụng cần kiểm soát hoàn toàn bố cục.

  • timeout_ms -- từ bỏ sau một khoảng thời gian cố định. Mặc định là quảng bá mãi mãi.

Khi một thiết bị trung tâm kết nối, aioble.advertise() trả về aioble.DeviceConnection kết quả. Thiết bị ngoại vi ngừng quảng bá tại thời điểm này.

11.9.3. Phục vụ một client

Vòng lặp chính của thiết bị ngoại vi thường trông như thế này:

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 làm cho việc dọn dẹp khi ngắt kết nối trở nên tự động. disconnected() là một coroutine tạm dừng cho đến khi một trong hai bên kết thúc kết nối -- một cách gọn gàng để giữ thiết bị ngoại vi phục vụ cho đến khi thiết bị trung tâm rời đi, rồi quay lại quảng bá vòng tiếp theo.

11.9.4. Cập nhật một đặc trưng

Thiết bị ngoại vi cập nhật cơ sở dữ liệu GATT cục bộ bằng aioble.Characteristic.write()

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

Điều đó thay đổi giá trị mà lần read tiếp theo từ bất kỳ client nào sẽ trả về. Bản thân nó không đẩy giá trị mới -- client đã đăng ký sẽ không thấy gì cho đến khi client thăm dò hoặc thiết bị ngoại vi gửi thông báo rõ ràng.

Phía đẩy là một từ khóa duy nhất trên cùng một lời gọi:

temp_char.write(temp_bytes, send_update=True)

send_update=True thông báo (hoặc chỉ ra) cho mọi client đã đăng ký nhận đặc trưng này. Hầu hết mã kiểu cảm biến nằm trong một task theo từng kết nối lặp đọc cảm biến và ghi giá trị với send_update=True mỗi giây hoặc khoảng như vậy:

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()

Nếu bạn muốn hướng một thông báo đến một client cụ thể thay vì toàn bộ tập hợp đã đăng ký (ví dụ một phản hồi riêng theo kết nối cho lệnh của client đó), aioble.Characteristic.notify()indicate() nhận đối số DeviceConnection và một tải trọng tùy chọn.

11.9.5. Nhận các lệnh ghi

Chiều ngược lại -- một client ghi vào đặc trưng -- trở nên khả dụng khi đặc trưng được khởi tạo với write=True hoặc write_no_response=True. Thiết bị ngoại vi chờ lệnh ghi tiếp theo bằng 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)

Nếu không có capture=True, written() chỉ trả về kết nối ghi; giá trị mới nằm trong bộ đệm sao lưu của đặc trưng và ứng dụng lấy nó bằng read(). Nếu một lệnh ghi thứ hai đến trước khi ứng dụng đọc lần đầu, giá trị thứ hai ghi đè giá trị đầu tiên trong bộ đệm và giá trị ban đầu bị mất -- written() vẫn đánh thức ứng dụng, nhưng chỉ một lần cho mỗi "có gì đó mới", không phải một lần cho mỗi lệnh ghi.

Từ khóa capture=True khắc phục điều đó. Mỗi lệnh ghi đến được thêm vào hàng đợi toàn cục của module, và written() trả về một tuple (connection, data) cho mỗi lệnh ghi riêng lẻ -- vòng lặp ứng dụng thấy từng lệnh đúng một lần, theo thứ tự đến. Hai hệ quả thực tế:

  • Hàng đợi bị giới hạn và được chia sẻ trên mọi đặc trưng có bật capture trên thiết bị. Các đợt ghi liên tiếp ngắn được chấp nhận; quá tải liên tục (các lệnh ghi đến nhanh hơn ứng dụng xử lý) âm thầm bỏ các mục cũ nhất trong hàng đợi, và lưu lượng bùng phát trên một đặc trưng có thể đẩy các mục đang chờ xử lý của đặc trưng khác ra ngoài.

  • Chọn capture=True cho các lệnh ghi kiểu lệnh nơi mỗi giá trị đều quan trọng. Không cần bật nếu là các đặc trưng kiểu trạng thái chỉ cần giá trị mới nhất.

Nếu một lần đọc từ client nên được trả lời bằng mã chạy theo yêu cầu thay vì một giá trị tĩnh, hãy ghi đè on_read(). Phương thức được gọi đồng bộ khi có yêu cầu đọc; trả về 0 để cho phép đọc (giá trị hiện tại từ write() sẽ được gửi), hoặc mã lỗi ATT khác không để từ chối:

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)

Hàm gọi lại lấy mẫu cảm biến và cập nhật giá trị của đặc trưng ngay trước khi ngăn xếp GATT phục vụ yêu cầu đọc, để client luôn thấy dữ liệu mới. Giới hạn tốc độ ngăn client tấn công cảm biến nhanh hơn mức có thể lấy mẫu -- bất kỳ lần đọc nào trong khoảng cooldown một giây đều bị trả về dưới dạng lỗi ATT Read Not Permitted thay vì giá trị cũ.

11.9.5.1. Bộ đệm sao lưu lớn hơn -- BufferedCharacteristic

Bộ đệm sao lưu cho Characteristic thông thường rộng 20 byte -- giới hạn thực tế ở MTU mặc định 23 byte. Một client ghi nhiều hơn giá trị đó vào đặc trưng thông thường sẽ bị cắt bớt giá trị. Đối với các giá trị đến lớn hơn hoặc để xếp hàng các lệnh ghi liên tiếp mà vòng lặp ứng dụng sẽ bắt kịp sau, hãy khai báo đặc trưng như BufferedCharacteristic và chọn kích thước bộ đệm trước:

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)

Hai điều khác biệt nó với một Characteristic thông thường:

  • max_len là kích thước của bộ đệm sao lưu tính bằng byte. Chọn để khớp với lệnh ghi đơn lẻ lớn nhất mà client dự kiến thực hiện (sau khi đàm phán MTU).

  • append=True làm cho các lệnh ghi tuần tự thêm vào bộ đệm thay vì ghi đè -- hữu ích để nhận một giá trị đến qua nhiều lệnh ghi (các khối cập nhật firmware, dòng nhật ký). Với append=False bộ đệm hoạt động như một đặc trưng bình thường, chỉ là rộng hơn.

Tất cả các cờ constructor khác (read, write, notify, indicate, capture, initial) được chuyển tiếp không thay đổi đến đặc trưng cơ bản.

11.9.6. Các dịch vụ tiêu chuẩn và UUID được SIG gán

Việc tuân thủ các UUID số được gán (0x180F cho Battery Service, 0x181A cho Environmental Sensing, 0x180D cho Heart Rate, v.v.) có nghĩa là menu Bluetooth chung của điện thoại hoặc bất kỳ ứng dụng máy quét bên thứ ba nào đều có thể xác định mục đích của thiết bị mà không cần mã client tùy chỉnh. Bố cục byte bên trong mỗi đặc trưng tiêu chuẩn cũng được cố định theo thông số kỹ thuật -- Battery Level (0x2A19) là một byte 0..100; Temperature (0x2A6E) là số nguyên sint16 little-endian theo đơn vị 0.01 độ C. Đối với các ứng dụng không phù hợp với dịch vụ tiêu chuẩn, hãy tạo UUID 128-bit một lần và sử dụng nó cho các dịch vụ và đặc trưng của thiết bị.

Một thiết bị ngoại vi chỉ công bố UUID tùy chỉnh vẫn ổn -- nó chỉ cần một ứng dụng client tùy chỉnh biết về các UUID đó.

Ghi chú

Các giá trị BLE đều little-endian -- thông số kỹ thuật GATT, mọi đặc trưng tiêu chuẩn, mọi trường quảng bá. Các số nguyên nhiều byte được truyền trên đường dây theo byte thấp trước. Tiền tố < trong chuỗi định dạng struct là thứ bạn cần để mã hóa/giải mã ("<h", "<H", "<I", ...); sử dụng thứ tự byte gốc mặc định trên MCU little-endian hoạt động đúng hiện tại, nhưng viết rõ < là thói quen an toàn.

11.9.7. Radio đằng sau tất cả

Radio hoạt động ngay khi coroutine aioble đầu tiên chạm vào nó. Cho đến khi thiết bị trung tâm kết nối, thiết bị ngoại vi dành thời gian chuyển đổi giữa các đợt quảng bá ngắn và chế độ ngủ; sau kết nối nó tuân theo khoảng thời gian kết nối đã đàm phán. Thiết bị ngoại vi trả một chi phí năng lượng nhỏ cho mỗi lần quảng bá, vì vậy việc chọn interval_us trên aioble.advertise() là điều chỉnh trực tiếp nhất mà thiết bị ngoại vi có để đánh đổi giữa độ trễ phát hiện và tuổi thọ pin.