Learning Objectives
- 說明 BLE 適合的用途,以及它和 Wi-Fi 的差別
- 分辨中央與周邊、GATT 伺服器與用戶端,說明廣播與連線的過程
- 看懂服務、特徵值、UUID 與讀取、寫入、通知的差別
- 把感測值編碼成位元組(例如溫度的 sint16 小端序),並讀懂 MicroPython 的 BLE 周邊程式
另一種無線:藍牙低功耗
第 12~14 課用 Wi-Fi 把 ESP32 接上網路。Wi-Fi 很方便,但要有無線網路分享器,而且耗電(第 15 課:發射時約 200 mA 等級)。 有些情況其實只需要:手機靠近時,直接和裝置交換一點點資料。例如心率帶把心跳傳給手機、手機設定一顆智慧燈泡、體重計把數字送到 App。
這就是藍牙低功耗(Bluetooth Low Energy,BLE)的用途。BLE 在 2010 年的藍牙核心規格 4.0 加入, 和傳統藍牙(耳機、喇叭用的那一種)名字相同,但是不同的通訊方式。ESP32 的資料手冊寫明它支援藍牙 4.2 的傳統藍牙與 BLE。
| Wi-Fi+MQTT | BLE | |
|---|---|---|
| 需要分享器、網際網路 | 要 | 不用,手機和裝置直接連 |
| 距離 | 整棟建築(透過網路可以到全世界) | 通常是同一個房間 |
| 一次的資料量 | 大 | 小(MicroPython 的特徵值預設最多 20 位元組) |
| 耗電 | 高 | 低,適合電池或鈕扣電池 |
| 常見用途 | 感測資料上雲端、遠端控制 | 穿戴裝置、設定新裝置、靠近時互動 |
這一課的觀念用下面的模擬器操作(模擬器沒有模擬 MicroPython 的 bluetooth 模組),實機程式放在課文後半。
廣播、連線與四種角色
先廣播,再連線
一個 BLE 裝置要被找到,先要廣播(advertising):每隔一段時間送出一個小封包,內容是「我是誰、我有什麼服務」。
- 廣播使用三個專門的頻道,編號 37、38、39;每次廣播在三個頻道各送一份,提高被收到的機會。
- 廣播間隔可以從 20 毫秒到 10.24 秒:越短越快被找到,也越耗電。
- 傳統的廣播封包,資料部分最多 31 位元組;放不下的名稱或資訊要縮短,或放進「掃描回應」。
手機掃描時收聽這三個頻道,列出附近的裝置;選了一個按下連線,兩邊就建立連線,之後在其他頻道上交換資料。
四種角色(GAP)
| 角色 | 做什麼 | 例子 |
|---|---|---|
| 廣播者(broadcaster) | 只廣播,不接受連線 | 博物館展品旁的信標 |
| 觀察者(observer) | 只收聽廣播,不連線 | 收集信標訊號的閘道器 |
| 周邊(peripheral) | 廣播,等別人來連線 | ESP32 溫度計、心率帶 |
| 中央(central) | 掃描並發起連線 | 手機、電腦 |
連線後:GATT 伺服器與用戶端
連線後交換資料的規則叫 GATT(Generic Attribute Profile)。保存資料的一方是 GATT 伺服器,來讀寫的一方是 GATT 用戶端。 常見的組合是「周邊=伺服器、中央=用戶端」:ESP32 有溫度,手機來讀。
伺服器的資料整理成一張屬性表,分成兩層:
服務(service)0x181A 環境感測
├─ 特徵值(characteristic)0x2A6E 溫度 性質:讀取、通知
│ └─ 描述元(descriptor)0x2902 CCCD 用戶端在這裡開關通知
服務 0x180F 電池
└─ 特徵值 0x2A19 電量 性質:讀取
- UUID:每個服務與特徵值都有一個編號。藍牙技術聯盟(Bluetooth SIG)制定的標準項目用 16 位元的短編號(例如 0x181A),
自己發明的服務用 128 位元的長編號,例如
9f3e0001-5b1c-4c8a-9e8b-2a6d7c0e1f00。用標準編號,任何認得它的 App 都知道 0x2A6E 是溫度。 - 讀取(read):用戶端問,伺服器答。
- 寫入(write):用戶端送一個值過去,例如開燈。
- 通知(notify):用戶端先在 CCCD 寫入
01 00訂閱,之後伺服器的值一改變就主動送過來,用戶端不必一直問; 指示(indicate)和通知類似,但用戶端要回覆確認。
這和第 14 課的 MQTT 很像:訂閱之後由對方主動送來,不用一直詢問。
特徵值裡裝的是位元組
MQTT 的訊息常用 JSON 文字;BLE 的特徵值很小(MicroPython 預設 20 位元組),通常直接放二進位的數字。 標準的溫度特徵值(0x2A6E)規定:有號 16 位元整數(sint16)、單位 0.01 °C、小端序(低位元組在前)。
25.84 °C → × 100 → 2584 → 十六進位 0x0A18 → 小端序送出:18 0A
收到 18 0A → 組回 0x0A18 → 2584 → × 0.01 → 25.84 °C
Python 用 struct 模組做這件事(MicroPython 也有):
import struct
data = struct.pack("<h", 2584) # "<" 小端序,"h" 有號 16 位元
print(data.hex()) # 180a
value = struct.unpack("<h", data)[0] # 2584
print(value / 100) # 25.84
用整數乘上 100,而不是傳浮點數,資料只要 2 個位元組,接收端也不會遇到浮點數格式不同的問題。
小測驗:角色與 GATT
心率帶廣播自己的名稱,手機掃描後連上它,持續收到心跳數字。這裡手機是?
手機想在 ESP32 溫度一變化就收到新值,又不想每秒讀一次,該怎麼做?
自己設計一個「門鎖」服務,App 要能寫入開鎖指令。這個服務的 UUID 應該怎麼選?
ESP32 上的 BLE 周邊程式
MicroPython 的 bluetooth 模組是低階介面:事件(有人連線、離線、寫入)透過一個你登記的函式通知程式。
下面的程式改寫自 MicroPython 官方範例 examples/bluetooth/ble_temperature.py,它需要同一個資料夾的 ble_advertising.py
(負責組出廣播封包,就是模擬器裡那串位元組)。
import bluetooth
import struct
import time
from micropython import const
from ble_advertising import advertising_payload
_IRQ_CENTRAL_CONNECT = const(1)
_IRQ_CENTRAL_DISCONNECT = const(2)
_FLAG_READ = const(0x0002)
_FLAG_NOTIFY = const(0x0010)
ENV_UUID = bluetooth.UUID(0x181A) # 環境感測服務
TEMP_CHAR = (bluetooth.UUID(0x2A6E), _FLAG_READ | _FLAG_NOTIFY) # 溫度:可讀取、可通知
ble = bluetooth.BLE()
ble.active(True)
((temp_handle,),) = ble.gatts_register_services(((ENV_UUID, (TEMP_CHAR,)),))
connections = set()
payload = advertising_payload(name="Uedu-Temp", services=[ENV_UUID])
def on_ble(event, data):
if event == _IRQ_CENTRAL_CONNECT:
conn, _, _ = data
connections.add(conn)
print("手機連上了")
elif event == _IRQ_CENTRAL_DISCONNECT:
conn, _, _ = data
connections.discard(conn)
print("手機離開了,重新廣播")
ble.gap_advertise(500000, adv_data=payload)
ble.irq(on_ble)
ble.gap_advertise(500000, adv_data=payload) # 每 0.5 秒(500000 微秒)廣播一次
print("廣播中:Uedu-Temp")
temp = 25.0
while True:
data = struct.pack("<h", int(temp * 100)) # sint16、小端序、單位 0.01 °C
ble.gatts_write(temp_handle, data) # 更新屬性表裡的值:手機讀取時拿到它
for conn in connections:
ble.gatts_notify(conn, temp_handle) # 主動送給已連線的手機
temp += 0.1
time.sleep(1)
幾個要注意的地方:
gatts_register_services依你給的服務定義建立屬性表,回傳每個特徵值的 handle(屬性表裡的編號),之後讀寫都用它。- 旗標常數(
_FLAG_READ等)沒有內建在模組裡,要像上面一樣自己定義。 gatts_notify不管手機有沒有訂閱都會送出;正式的程式應該記錄手機寫入 CCCD 的值,有訂閱才送。- MicroPython 文件建議一般應用改用較高階的 aioble 函式庫,它幫你處理事件與連線管理,但需要
asyncio。 - ESP32 的 Wi-Fi 與藍牙共用同一組 2.4 GHz 無線電,靠分時輪流使用;可以同時開著,但同時大量傳輸時兩邊都會變慢。
實機操作:讓手機找到你的 ESP32
這部分要用真的 ESP32 和手機操作(模擬器不能執行 bluetooth 模組)。檔案用第 16 課的 mpremote 上傳;輸出是範例。
-
1
上傳廣播輔助模組
從 MicroPython 原始碼的
examples/bluetooth/資料夾下載ble_advertising.py,複製到板子上。 -
2
執行周邊程式
把上面的程式存成
ble_temp.py,用run直接執行(不會存進板子)。程式是無窮迴圈,要停止時按 Ctrl+C。 -
3
用手機掃描與連線
手機安裝一個通用的 BLE 除錯 App(例如 nRF Connect 或 LightBlue),開啟藍牙後掃描: 清單裡應該看得到 Uedu-Temp。按下連線,App 會列出 Environmental Sensing(0x181A)服務與 Temperature(0x2A6E)特徵值。 這時電腦上的終端機會印出「手機連上了」。
-
4
讀取與訂閱
按特徵值旁的「讀取」,App 顯示的原始值像
0A 0A,有些 App 會直接換算成 25.70 °C。 按下訂閱(通知)圖示,之後每秒都會收到新的溫度。斷開連線時終端機印出「手機離開了,重新廣播」,手機又能再掃描到它。
小測驗:位元組與廣播封包
下面的程式會印出什麼?
import struct
print(struct.pack("<h", 2584).hex())
手機讀到溫度特徵值的兩個位元組是 F6 FF。下面的程式會印出什麼?
import struct
raw = struct.unpack("<h", bytes([0xF6, 0xFF]))[0]
print(raw / 100)
廣播封包由 Flags(3 位元組)、名稱(2 + 名稱的位元組數)、服務 UUID 清單(4 位元組)組成。下面的程式算出名稱「教室溫度計」的封包長度,會印出什麼?
name = "教室溫度計"
total = 3 + (2 + len(name.encode("utf-8"))) + 4
print(total, total <= 31)
重點整理
- BLE 讓手機和裝置不經過網路、直接交換少量資料,耗電低、距離短;ESP32 同時支援 Wi-Fi 與 BLE。
- 周邊在 37、38、39 頻道廣播(資料最多 31 位元組),中央掃描後發起連線;只廣播不連線的是廣播者。
- 連線後,保存資料的 GATT 伺服器提供屬性表:服務底下有特徵值,每個都有 UUID(標準 16 位元、自訂 128 位元)。
- 用戶端可以讀取、寫入,或在 CCCD 寫入
01 00訂閱通知,之後伺服器主動送新值。 - 特徵值放位元組:標準溫度是 sint16、小端序、0.01 °C,用
struct.pack("<h", ...)編碼。 - MicroPython 的
bluetooth模組是低階、事件驅動的介面;一般應用可以改用 aioble。
下一課是 ESP32 軌的專題:把 Wi-Fi、感測、MQTT 整合成一個完整的感測節點。