蓝牙BLE配网

蓝牙配网方式流程如下:

connect_bluetooth

选用蓝牙配网方式,你的设备需要满足以下要求:

  • 设备具有蓝牙模块,支持BLE 4.0以上,有WiFi模组

  • 设备有物理按键以支持进入蓝牙配对模式。

  • 为了保证用户在关联APP中的配网体验,我们建议你的设备的蓝牙名称拥有统一的前缀。

选择蓝牙配网方式,你需要在 设备接入控制台 中,为你的设备的 设备能力 开启 蓝牙配网,并填入相关的信息,以便用户在为设备配网时有一个良好的体验。跳转到控制台

协议介绍

通过蓝牙配网的流程如下:

  1. 设备进行 BLE Advertising 广播,附带 client id 数据,让小飞在线App可以搜索到设备。

  2. App 向设备读取设备唯一标识。

  3. App 将用户输入的 WiFi、密码、设备码发送到设备。

  4. 设备连接到网络后,通过设备码获取到 token,根据 token 即可连接到 iFLYOS。

广播数据规则

通过在广播中,使用 ManufacturerData 存放 client id 数据。manufacturerId0xAAAAmanufacturerSpecificDataclient id。在 设备接入控制台 中获取到的 client id,都是 UUID 格式的数据,在 manufacturerSpecificData 中直接转为字节数组表示。

那么我们可以得到,广播的源数据(PDU BODY)形如

0x02 0x01 0x02 0x13 0xFF 0xAA 0xAA <client_id> 0x03 0x03 0xF9 0x1F

举个例子,如果 client idf81d4fae-7dec-11d0-a765-00a0c91e6bf1,那么广播源数据为

0x02 0x01 0x02 0x13 0xFF 0xAA 0xAA 0xf8 0x1d 0x4f 0xae 0x7d 0xec 0x11 0xd0 0xa7 0x65 0x00 0xa0 0xc9 0x1e 0x6b 0xf1 0x03 0x03 0xF9 0x1F

App 在搜索到这个广播后,会根据广播中的数据向服务端请求查询是否为可用的设备,在界面中弹出连接框。

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>
    

    其中,idpwdcode 为固定前缀,<ssid><password><code> 代表 App 发送的 WiFi名WiFi密码设备码

  • App 会向设备请求断开连接,而不是主动断开连接,防止连接的意外断开对设备可能造成的问题。App 请求断开连接的数据如下

    disconnect
    

    接收到以上数据后,设备主动断开与 App 的连接。

注意

我们并不能保证收到的网络信息是百分百可用的,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 实现在各平台上有差异,请参考协议介绍自行实现。