# 蓝牙BLE配网
蓝牙配网方式流程如下:
选用蓝牙配网方式,你的设备需要满足以下要求:
设备具有蓝牙模块,支持BLE 4.0以上,有WiFi模组
设备有物理按键以支持进入蓝牙配对模式。
为了保证用户在关联APP中的配网体验,我们建议你的设备的蓝牙名称拥有统一的前缀。
选择蓝牙配网方式,你需要在 设备接入控制台 中,为你的设备的 设备能力 开启 蓝牙配网,并填入相关的信息,以便用户在为设备配网时有一个良好的体验。跳转到控制台 (opens new window)
# 协议介绍
通过蓝牙配网的流程如下:
设备进行 BLE Advertising 广播,附带
client id
数据,让小飞在线App (opens new window)可以搜索到设备。App 向设备读取设备唯一标识。
App 将用户输入的 WiFi、密码、设备码发送到设备。
设备连接到网络后,通过设备码获取到 token,根据 token 即可连接到 iFLYOS。
# 广播数据规则
通过在广播中,使用 ManufacturerData
存放 client id
数据。manufacturerId
为 0xAAAA
,manufacturerSpecificData
为 client id
。在 设备接入控制台 中获取到的 client id
,都是 UUID 格式的数据,在 manufacturerSpecificData
中直接转为字节数组表示。
广播的源数据(PDU BODY)部分长度27字节
BODY部分
0x02 0x01 0x02 0x13 0xFF 0xAA 0xAA <client_id> 0x03 0x03 0xF9 0x1F
举个例子,如果 client id
为 f81d4fae-7dec-11d0-a765-00a0c91e6bf6
,那么广播源数据为
0xf8 0x1d 0x4f 0xae 0x7d 0xec 0x11 0xd0 0xa7 0x65 0x00 0xa0 0xc9 0x1e 0x6b 0xf6
App 在搜索到这个广播后,会根据广播中的数据向服务端请求查询是否为可用的设备,在界面中弹出连接框。
# 设备BLE蓝牙名称格式
IFLYOS-<model>-<id>
model是设备型号,id是该设备特有id,用于多设备区分,由厂商自己定义,utf8编码。
# BLE 通讯
BLE 服务的 UUID 需要声明为 00001ff9-0000-1000-8000-00805f9b34fb
。
读写 Characteristic 的 UUID 声明为 00001ffa-0000-1000-8000-00805f9b34fb
,且声明为 WITHOUT RESPONSE。
连接成功后,你需要遵循以下几项协议:
App 会向设备读取唯一标识,用于作为 iFLYOS 的
deviceSerialNumber
请求授权。设备仅需作为 ASCII 字符串发送到 App 端即可。App 会向设备持续发送心跳包保持连接(每隔五秒发送一次以下数据),否则在部分手机或设备上可能导致 BLE 服务为了节能断开连接。设备上直接忽略。
keep-alive
App 会在用户填写完网络信息,点击确认后,向设备发送形如以下形式的数据
id <ssid> pwd <password> code <code>
其中,
id
、pwd
和code
为固定前缀,<ssid>
、<password>
和<code>
代表 App 发送的 WiFi名、WiFi密码 和 设备码。App 会向设备请求断开连接,而不是主动断开连接,防止连接的意外断开对设备可能造成的问题。App 请求断开连接的数据如下
disconnect
接收到以上数据后,设备主动断开与 App 的连接。
# 添加设备
# 1 app扫描并展示配网模式下的设备
根据 client ID 请求设备需要展示给用户的信息,比如图片等
GET http://host/xxxx?client_id_key=01020304
{
"picture": "http://xxxx",
... //其他信息,如有
}
# 2 通过BLE GATT和设备通信
# 2.1 app连接BLE设备
GATT service UUID: 00001ff9-0000-1000-8000-00805f9b34fb
连接建立以后设备关闭iBeacon广播
# 2.2 读写Characteristic
GATT Characteristic UUID:00001ffa-0000-1000-8000-00805f9b34fb WITHOUT RESPONSE写模式
- 连接成功以后,app提示用户输入wifi配置,同时从设备读取device id,以ASCII字符串编码
- 读取成功device id以后,每5秒发送一次连接保持
keep-alive
- 读取device id以后,同时向服务器请求获取授权code
GET http://host/xxxx?device_id=20190308111023&client_id=000dfff9-0120-1af0-8fa0-00805f9b34fb
{
"code": "998312"
}
- 用户填好wifi配置以后,发送配网请求。换行符是
\n
。 如果code尚未返回,则等待至返回以后发送配网请求
id <ssid>
pwd <pwd>
code <code>
后期又增加了一种协议,可以在应用层随意分包,最后发送end表示结束即可。
ver 2
id <ssid>
pwd <pwd>
code <code>
end
- 设备收到配网请求以后断开BLE链接
- 如app获取获取code失败主动断开BLE链接
# 3 设备配置wifi网络
如果wifi配置失败,重新进入配网模式
如果wifi配置成功,向服务器请求token
GET http://host/xxxx?device_id=20190308111023&client_id=000dfff9-0120-1af0-8fa0-00805f9b34fb&code=998312
{
"token_type": "bearer",
"refresh_token": "4_vujpFOfu0G5yf4**************DwX5S80s74CY7",
"expires_in": 86400000,
"created_at": 1526485197,
"access_token": "bd6XMEqzIokI6mnMM**************iKAdYNa9T-1WXY"
}
# 4 app检测设备授权结果
app发送完配网请求后,向服务器查询设备是否正常连接并授权成功
GET http://host/xxxx?code=998312
{
"error": 0
}
{
"error": 01234
"message": "xxxxx"
}
注意
我们并不能保证收到的网络信息是百分百可用的,App 端的误操作可能会导致设备收到错误的 WiFi名称 或 WiFi密码。设备如果无法正确连接到网络,则应当通过灯光、提示语或其他方式向用户做出友善的提示,并通过某些方式告知用户重新为设备配置网络的方式。
# 认证授权
在 App 向设备发送的网络信息中包含了设备码字段,设备上向 iFLYOS 请求 token,并用于后续连接到 iFLYOS。
获取到设备码后,请求 token API 参考此处接口说明。
# Android 实现
这里提供一个蓝牙配网的 Android 设备实现,其中省略了打开蓝牙等初始化逻辑代码。
# 启动 BLE 服务
val IFLYOS_SETUP_SERVICE = UUID.fromString("00001ff9-0000-1000-8000-00805f9b34fb")
val IFLYOS_SETUP_REQUEST = UUID.fromString("00001ffa-0000-1000-8000-00805f9b34fb")
private fun startServer() {
val service = BluetoothGattService(
IFLYOS_SETUP_SERVICE, SERVICE_TYPE_PRIMARY)
service.addCharacteristic(BluetoothGattCharacteristic(IFLYOS_SETUP_REQUEST,
PROPERTY_READ or PROPERTY_WRITE or PROPERTY_WRITE_NO_RESPONSE,
PERMISSION_READ or PERMISSION_WRITE))
val callback = object : BluetoothGattServerCallback() {
override fun onConnectionStateChange(
device: BluetoothDevice, status: Int, newState: Int) {
// handleConnection
}
override fun onCharacteristicReadRequest(
device: BluetoothDevice, requestId: Int, offset: Int,
characteristic: BluetoothGattCharacteristic) {
// handleReadRequest
}
override fun onCharacteristicWriteRequest(
device: BluetoothDevice, requestId: Int,
characteristic: BluetoothGattCharacteristic,
preparedWrite: Boolean, responseNeeded: Boolean,
offset: Int, value: ByteArray) {
// handleWriteRequest
}
}
val server = bluetoothManager.openGattServer(context, callback)
server.addService(service)
}
建立服务后,主要从 onCharacteristicWriteRequest
回调中获取 App 对设备的请求。
if (IFLYOS_SETUP_REQUEST == characteristic.uuid) {
val requestString = String(value)
when {
KEEP_ALIVE == requestString -> {
// keep-alive ignore
}
DISCONNECT == requestString -> {
// 断开与 App 的连接
server.cancelConnection(device)
}
Regex("id [\\s\\S]*\\npwd [\\s\\S]*\\ncode [\\s\\S]*\$").matches(requestString) -> {
// 匹配以下格式
// id <ssid>
// pwd <password>
// code <device_code>
val dataArray = requestString.split("\n".toRegex()).dropLastWhile {
it.isEmpty()
}.toTypedArray()
val ssid = dataArray[0].substring("id ".length)
val password = dataArray[1].substring("pwd ".length)
val deviceCode = dataArray[2].substring("code ".length)
// 根据 ssid 和 password 连接到 WiFi,连接成功后通过 deviceCode 获取 Token
}
}
}
# 开启蓝牙广播
请注意,由于广播数据中存放数据较多,会导致无法设置过长的 BLE 名称。建议不设置 BLE 名称,这并不影响 App 搜索设备。
val IFLYOS_SETUP_SERVICE = UUID.fromString("00001ff9-0000-1000-8000-00805f9b34fb")
private fun startAdvertising() {
val adapter = BluetoothAdapter.getDefaultAdapter()
val advertiser = adapter.bluetoothLeAdvertiser
val settings = createAdvertiseSettings(0)
val uuid = clientId.replace("-", "") // 从设备接入控制台复制下来的 client id,替换掉「-」字符
val data = createAdvertiseData(uuid)
advertiser.startAdvertising(settings, data, advertiseCallback)
}
private fun createAdvertiseSettings(timeoutMillis: Int): AdvertiseSettings {
val builder = AdvertiseSettings.Builder()
builder.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
builder.setConnectable(true)
builder.setTimeout(timeoutMillis)
builder.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
return builder.build()
}
private fun createAdvertiseData(uuid: String): AdvertiseData {
val mDataBuilder = AdvertiseData.Builder()
val data = Conversion.hexStringToBytes(uuid) // 将16进制字符串转为字节,例如 "01" 转为 0x01,"a0" 转为 0xa0
mDataBuilder.addManufacturerData(0xAAAA, data)
mDataBuilder.addServiceUuid(ParcelUuid(IFLYOS_SETUP_SERVICE))
return mDataBuilder.build()
}
# 根据 deviceCode 获取 Token
Android SDK 中提供一个基础的 HttpURLConnection
实现,如果你使用 Volley
或者 Retrofit
等网络请求库,请参考相关文档构建请求。
val sTokenRequestUrl = "https://auth.iflyos.cn/oauth/ivs/token"
var con: HttpURLConnection? = null
var os: DataOutputStream? = null
var input: BufferedReader? = null
var errorInput: BufferedReader? = null
val urlParameters = ("grant_type=urn:ietf:params:oauth:grant-type:device_code"
+ "&device_code=" + deviceCode
+ "&client_id=" + clientId)
try {
val url = URL(sTokenRequestUrl)
con = url.openConnection() as HttpURLConnection
con.requestMethod = "POST"
con.setRequestProperty("Host", sAuthHost)
con.setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
con.doOutput = true
os = DataOutputStream(con.outputStream)
os.writeBytes(urlParameters)
val responseCode = con.responseCode
if (responseCode == 200) {
input = BufferedReader(
InputStreamReader(con.inputStream))
val responseSb = StringBuilder()
var inputLine = input.readLine()
while (inputLine != null) {
responseSb.append(inputLine)
inputLine = input.readLine()
}
val responseJSON = JSONObject(responseSb.toString())
val accessToken = responseJSON.getString("access_token")
val refreshToken = responseJSON.getString("refresh_token")
val expiresInSeconds = responseJSON.getString("expires_in")
// ... 其他处理
} else {
// 请求失败,从 con.errorStream 中获取错误信息
}
} catch (e: Exception) {
e.printStackTrace()
} finally {
con?.disconnect()
if (os != null) {
try {
os.flush()
os.close()
} catch (e: IOException) {
mLogger.postWarn(sTag, "Cannot close resource. Error: " + e.message)
}
}
if (input != null) {
try {
input.close()
} catch (e: IOException) {
mLogger.postWarn(sTag, "Cannot close resource. Error: " + e.message)
}
}
}
若请求成功,使用该 token 连接到 iFLYOS;请求失败则应检查网络是否可用、参数是否正确,并做响应提示。
# Linux实现
Linux 实现在各平台上有差异,请参考协议介绍自行实现。