前情提要

最近在做一個 BLE(低功耗藍牙) 的 APP 案,從開發工具選擇、研究到實作中間花了一些時間,也踩了許多對 BLE 知識不夠所產生的雷。在開發第一版時,對裝置的連線總是特別慢,而且找不出什麼原因,上了好多討論版發問但得到的回答甚少,我甚至拋棄 React native 跑去怒學 Kotlin 實作一個 demo,但也沒解決問題,後來怎麼解決呢?換了一個藍牙晶片後走路跟飛的一樣,這件事也大大加深了我對「硬體好可怕」的既定印象 😭。

以下針對 Android 版本,若 iOS 還踩一堆雷就可以寫第二篇(?)。


使用套件:React-native-ble-plx

文件

這家公司同時也是 RxAndroidBle 的開發者,相較於其他套件,Ble-plx 的文件寫得清楚許多,討論度也高,不過有些 BLE 的基礎知識應該是假設開發者都懂了(像是寫入資料的 Package 大小限制),所以對 BLE 一無所知的我才踩到不少雷 QQ。

安裝照著官方沒問題指南

Permissions

BLE 需要在 AndroidManifest.xml 開啟 / 要求下列權限:

1
2
3
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>

如果你的 APP 只想開放給有 BLE 功能的裝置來安裝,要加上下面這行:

1
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>

連線流程

這次開發的專案中,我使用的流程簡化後有下面幾個步驟:

  1. 確認藍牙狀態
  2. 確認權限是否開啟
  3. 搜尋裝置
  4. 與裝置連線
  5. Discover services and characteristics
  6. 讀寫操作

Step 1 — 確認藍牙狀態

一開始我們先實例化 BleManager

1
const bleManager = new BleManager()

在建立連線之前,我們需要確保使用者已經將藍牙開啟,我們可以透過 state() 來獲取目前的藍牙狀態:

1
const state = await bleManager.state()

或是用 subscribe 的方式(官網上的例子):

1
2
3
4
5
6
const subscription = this.bleManager.onStateChange((state) => {
if (state === State.PowerOn) {
// 已確認藍牙正常開啟
subscription.remove()
}
}, true)

只有 PowerOn 才是藍牙開啟且可使用的狀態,若使用者的藍牙尚未就緒,你也可以幫他打開(Android 限定):

1
await bleManager.enable()

Step2 — 確認權限是否開啟

接下來要確定 ACCESS_COARSE_LOCATION 這個權限是否已經被授權,這個有點離題就不在這裡詳述了,只是沒有打開這個權限會無法進行 Scan。(踩到的第一個雷)

Step3 — 搜尋裝置

我們通常會透過 裝置的名稱 ,或是 UUID 來找到我們要連線的裝置:

1
bleManager.startDeviceScan(UUIDs, ScanOptions, Listener)
  • UUIDs (Array of UUID)
    這是在搜尋時會使用的 filter ,如果傳入 null ,則每發現一個任何裝置,他就會 call Listener 一次,若你已經知道你裝置的 UUID ,可以直接在這裡過濾,就不用在 Listener 多做一層檢查。
  • ScanOptions (Object)
    這裡有一個重要的參數叫作 ScanMode,他控制了搜尋裝置的 duty cycle:
    • LowPower:最節能也最慢(預設)
    • Balanced:耗能與速度介於兩者之間
    • LowLatency:最耗能也最快

還有比較特別的模式: Opportunistic,投機仔,使用的話這個 APP 本身不會進行搜尋,而是會使用其他 APP 的掃描結果,像是我開著我的 APP ,然後另外打開 Ble Tool 來進行搜尋,Ble Tool 找到裝置後我的 APP 就可以把結果撿去用。

  • Listener (Function)
    你的 callback function,若你使用名字來搜尋裝置的話,就像下面的程式:
1
2
3
4
5
6
7
8
9
10
11
bleManager.startDeviceScan(null, { scanMode: ScanMode.LowLatency }, (error, device) => {
if (error) {
console.log(error)
return
}

if (device.name !== "DeviceName") return

bleManager.stopDeviceScan()
// 在這裡對 device 進行操作...
})

Step 4— 與裝置連線

當你搜尋到裝置,就可以馬上與裝置進行連線:

1
2
3
4
5
try {
await device.connect()
} catch (error) {
console.log(error.reason)
}

那我可不可以跳過搜尋裝置這個步驟,直接進行連線呢?可以哇,如果你知道裝置的 UUID,你是可以直接進行連線的:

1
2
3
4
5
try {
await bleManager.connectToDevice(UUID, { timeout: 2000 })
} catch (error) {
console.log(error.reason)
}

connect() 和 connectToDevice() 都會接收一個 ConnectionOptions 的參數,用來設定連線:

  • refreshGatt (Boolean)
    設定 true 的話,連線時會清除 service cache ,如果你的韌體有更新,導致 services / characteristics 經過修改的話,它可以防止你使用到舊版的 cache。
  • timeout (Number)
    單位是 ms,如果你是直接連線不經過搜尋的話,這個特別好用,舉例來說,你可以先進行直接連線, timeout 了再開始搜尋裝置。
  • requestMTU (Number)
    MTU 是 Maximum Transmission Unit,最大傳輸單元,代表你一次 Write 一包的 package 可以有幾個 bytes,但這要看藍牙裝置是否可以調整 MTU。
  • autoConnect (Boolean)
    若設為 false(預設),則會直接進行連線,設為 true 會等裝置 ready 才進行連線。

Step5 — Discover services

連線完之後,你必須要 discover 裝置上所有的 services 和 characteristics 才可以進行讀寫,藍牙裝置裡會有許多的 services ,像是系統電量 Service,或是系統資訊 Service,而每一個 Service 底下會有許多 characteristics (特徵),這才是我們要讀寫資料的目標。

每一個 Service 和 Characteristic 都有自己的 UUID,在你讀寫前你要知道你該對 哪一個 Service 裡面的 哪一個 Characteristic 進行操作。

1
await device.discoverAllServicesAndCharacteristics()

Step6 — 讀寫操作

寫進去

讀寫的操作目標是 Characteristic (特徵),在 Ble-plx 中,一個 characteristic 物件長得像下面這樣:

在 BLE 傳輸時,會有 MTU (最大傳輸單元)的限制,他代表你一次只能讀寫多少 bytes 的資料,預設值是 23 bytes (但扣掉 3 bytes 的 header,你一次只能傳 20 bytes!),這時候有兩個方法可以解決:

  • requestMTU
    跟裝置協調你們之間溝通的 MTU,但這得看該裝置是否有提供這個功能。
  • 傳一次不夠,你有傳很多次嗎?
    例如說我一次要寫 25 bytes 的資料,那我就分成 20 跟 5 bytes 來傳,我使用的是這個方式,因為⋯⋯我的裝置不給我改 MTU ╰(〒皿〒)╯。
1
2
3
4
5
await device.writeCharacteristicWithResponse(
Service_UUID,
Characteristic_UUID,
Data
);

要注意的是,你傳的 data 必須是經過 base64 encode 之後的字串,在這裡我推薦使用 JavaScript 的 Buffer 來進行處理,假設我要傳「PASS」四個字元,可以這樣處理:

1
2
3
4
5
6
7
const byteArray = ("PASS").split("").map(char => char.charCodeAt(0))

await device.writeCharacteristicWithResponse(
Service_UUID,
Characteristic_UUID,
Buffer.from(byteArray).toString("base64")
);

會選用 Buffer ,除了它 encode base64 很方便之外,要進行切割也可以直接用內建的 slice 來進行,相當適合這個情境!

讀出來

相較於寫入,讀資料就相對單純:

1
2
3
4
5
6
7
const { value } = await bleManager.readCharacteristicForDevice(
Device_UUID,
Service_UUID,
Characteristic_UUID
);

console.log(atob(value))

若你要追蹤一個 Characteristic 的數值,想要知道他什麼時候改變的話,也有對應的 function 可以用:

1
2
3
4
5
6
7
8
9
10
11
const subscription = device.monitorCharacteristicForService(
Service_UUID,
Characteristic_UUID,
(error, characteristic) => {
if (error) {
console.log(error)
return;
}
console.log(characteristic.value)
}
);

測試工具

推薦使用 Android 上的 BLE Tool,可以進行讀寫,介面相對地好用。


疑難雜症系列

  • 狀態太多難以管理
    上述的連線流程是基本的資料讀寫,在一般的案例中必定有額外的流程,如從 AsyncStorage 撈資料啦、再次確認連線狀態等等⋯⋯
    而且這些都是非同步操作欸,讓人很頭痛,一不小心又進入 await 地獄,我第二版改用 redux-saga 來改寫連線的流程,Code 變得乾淨許多又很爽,寫測試也變得輕輕鬆鬆!(如果你要寫測試的話)。
    尤其是 take / put 式的寫法寫起來跟同步 Code 一樣直覺,可以參考這份文件。
  • 搜尋裝置很慢 / 搜尋不到裝置
    通常我會先用 BLE Tool 檢查,若 BLE Tool 正常,可以檢查你的 APP 是否有正確授權 ACCESS_COARSE_LOCATION,BLE Tool 也找不到的話⋯⋯可以換顆裝置看看。
    搜尋很慢的話,將 ScanMode 改成 LowLatency 速度會大幅提升。
  • 搜尋裝置時快時慢
    一般的 BLE Peripheral 同時只能與一個 Application 連線,如果它正在與其他裝置處於連接的狀態,則大家都會搜尋不到它,你可以確認一下你的 APP 是否在操作結束後有好好的跟他斷線,如果還是時好時壞,那⋯⋯可以換顆裝置看看。
  • 在連線前,請記得要先 stopDeviceScan,在 Android 系統中有可能出 Bug
    以上是與 BLE 裝置連線操作的一些小心得,若有任何錯誤或疑惑的部分,歡迎留言討論。

喜歡這篇文章嗎?

歡迎點擊按鈕分享到 Facebook 上唷!

Weightless Theme
Rocking Basscss