蓝牙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 0xAB 0xAA <client_id> 0x03 0x03 0xF9 0x1F

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

0xf8 0x1d 0x4f 0xae 0x7d 0xec 0x11 0xd0 0xa7 0x65 0x00 0xa0 0xc9 0x1e 0x6b 0xf6

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 实现在各平台上有差异,请参考协议介绍自行实现。