diff --git a/README.md b/README.md index 3b4818b..ab6022d 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Duooomi 蓝牙 BLE SDK 的 Android 版本(与 iOS / expo 版业务行为 1:1 对齐)。 +> ⚠️ **请先跑通仓库内 demo 再开始对接 SDK。** demo 已串完整链路(扫描 / 连接 / 绑定 / OTA / 文件传输),是验证设备 + apiKey + brand 是否对的最快途径,避免在自家工程里盲调试。 + ## 环境要求 **SDK 集成要求(消费方最低版本):** @@ -34,7 +36,7 @@ duooomi-android-sdk/ │ ├── DuooomiBleConfig.kt # SDK 配置 │ ├── core/ # BLE 核心层 (BleClient, 类型定义, 错误类型) │ ├── protocol/ # BLE 协议层 (帧格式, UUID, 协议管理) -│ ├── services/ # 服务层 (设备命令 / OTA / 软绑 / 升级记录持久化) +│ ├── services/ # 服务层 (设备命令 / OTA / HTTP) │ ├── models/ # 数据模型 │ └── utils/ # 北京时间 / 日志 ├── demo/ # 演示 App (Jetpack Compose) @@ -81,7 +83,7 @@ dependencies { - + @@ -96,7 +98,7 @@ val sdk = DuooomiBleSDK( context = this, config = DuooomiBleConfig( apiKey = "YOUR_API_KEY", - owner = "YOUR_OWNER", // 多租户标识,所有 RPC 必带 + brand = "YOUR_BRAND", // 设备品牌名,所有 RPC 必带 scanNamePrefix = "Duooomi-" // 扫描时按广播名前缀过滤 ) ) @@ -107,7 +109,7 @@ val sdk = DuooomiBleSDK( | 参数 | 必填 | 说明 | |------|:----:|------| | `apiKey` | ✅ | Loomart x-api-key 鉴权 | -| `owner` | ✅ | 多租户 owner,所有 RPC 带 `x-owner` header | +| `brand` | ✅ | 设备品牌名(所有 RPC 带 `x-owner` header) | | `scanNamePrefix` | ✅ | 扫描时按广播名前缀过滤(大小写不敏感) | | `apiHost` | | 默认 `https://api.duooomi.com` | | `cdnHost` | | 默认 `https://cdn.bowong.cc/` | @@ -126,7 +128,6 @@ sdk.version // String (设备固件版本) sdk.isActivated // Boolean (是否已 bind) sdk.sn // String (绑定后写入设备序列号) sdk.firmwareUpgrade // FirmwareUpgradeCheckResult? (最新一次拉取结果) -sdk.upgradeRecords // List (持久化的 OTA 历史) sdk.transferProgress // Int (0-100) sdk.error // String? ``` @@ -150,13 +151,11 @@ val info = sdk.getDeviceInfo() val versionInfo = sdk.getVersion() // versionInfo.version / versionInfo.type -// 绑定设备 —— 只做 BLE 硬绑 + getVersion() -// 完成后写入 sdk.sn / sdk.version / sdk.isActivated -// 软绑 / 拉最新固件 / 对账上报 —— 全部走 fetchLatestFirmware(...) +// 绑定设备 —— 完成后写入 sdk.sn / sdk.version / sdk.isActivated val bindResult = sdk.bind(userId) // bindResult.sn / bindResult.contents (设备文件列表) -// 解绑 —— 硬解 + 软解(软解失败仅 warn 不抛) +// 解绑 sdk.unbind(userId) // 删除设备文件 @@ -190,28 +189,27 @@ sdk.transferProgress.collect { percent -> /* 0-100 */ } ### OTA 升级流程 -完整三段式 OTA(与 expo `bindDeviceWithOrchestration` 对齐): +完整流程:**扫描 → 连接 → bind → fetchLatestFirmware → upgradeFirmware**。设备重启重连后再次走一遍即可。 ```kotlin -// Step 1: 硬绑(前置:已 connect) +// Step 1: 绑定(前置:已 connect) val bindResult = sdk.bind(userId) val sn = sdk.sn.value // 设备序列号 val cur = sdk.version.value // 当前固件版本 -// Step 2: 拉最新固件 —— 内部串「对账上报 → 软绑 → checkUpgrade」 +// Step 2: 拉最新固件信息 val result = sdk.fetchLatestFirmware(sn = sn, currentVersion = cur, userId = userId) -// 软绑失败 → 自动硬解回滚 + 重置 isActivated/sn,整个 fetch 抛错 // 成功 → 写入 sdk.firmwareUpgrade val target = result.targetFirmware ?: return // null 表示已是最新 -target.id // 固件 ID(对账上报必带) +target.id // 固件 ID target.version // 目标版本字符串 target.fileUrl // 固件包下载 URL target.fileSize // 字节数(字符串编码) target.fileMd5 // MD5 target.forceUpgrade // 强制升级标记(true 时建议弹不可关闭弹窗) -// Step 3: 烧录 —— 完成后自动落 UpgradeRecord 到本地 +// Step 3: 烧录 sdk.upgradeFirmware( sn = sn, fileUrl = target.fileUrl!!, @@ -219,24 +217,9 @@ sdk.upgradeFirmware( fromVersion = cur, targetVersion = target.version ) - -// Step 4 (下一次启动):重新连接 → bind → 再调 fetchLatestFirmware -// 会自动触发 tryReportPendingUpgrade,把上次 OTA 的对账结果上报服务端 +// OTA 包发完,设备开始烧录并重启 ``` -### 升级对账上报状态判定表 - -`fetchLatestFirmware` 内部第一步会调 `tryReportPendingUpgrade`,把本地 `pending` 的 `UpgradeRecord` 逐条报给服务端。结果通过 `sdk.upgradeRecords` 的 `reported` / `outcome` 字段反映: - -| `reported` | `outcome` | 含义 | 触发条件 | -|---|---|---|---| -| `false` | `null` | 待上报 / HTTP 失败重试中 | 网络异常 / 服务端 5xx | -| `true` | `SUCCESS` | 升级成功 | 设备实际版本 == 目标版本,`report-success` 已成功 | -| `true` | `VERSION_MISMATCH` | 升级失败 | 设备实际版本 ≠ 目标版本,`report-failure(VERSION_MISMATCH …)` 已成功 | -| `true` | `null` | 30 天 TTL 过期 | 触发 OTA 后 30 天内仍未匹配上 / 未上报,标记为审计轨迹保留 | - -集成方可观察 `sdk.upgradeRecords` StateFlow,在 UI 上展示对账历史。 - ### 错误处理 ```kotlin @@ -250,13 +233,13 @@ try { catch (e: DuooomiBleError.Timeout) { /* 超时 */ } catch (e: DuooomiBleError.PermissionDenied) { /* 权限不足 */ } catch (e: DuooomiBleError.AlreadyBoundByOtherUser) { /* 设备已绑给他人 */ } - catch (e: DuooomiBleError.SoftBindFailed) { /* 软绑失败(已自动硬解回滚) */ } + catch (e: DuooomiBleError.BrandMismatch) { /* 不支持该品牌设备(已自动断连) */ } catch (e: DuooomiBleError.FirmwareUpgradeUnavailable) { /* sn 为空等前置不满足 */ } ``` ## 运行 Demo -`demo/src/main/kotlin/com/duooomi/ble/demo/DemoSecrets.kt` 是本地秘钥文件(已 gitignore),从 `docs/DemoSecrets.template.kt` 拷贝并填入真实 `apiKey` / `owner`。 +`demo/src/main/kotlin/com/duooomi/ble/demo/DemoSecrets.kt` 是本地秘钥文件(已 gitignore),从 `docs/DemoSecrets.template.kt` 拷贝并填入真实 `apiKey` / `brand`。 ```bash ./gradlew :demo:assembleDebug @@ -273,18 +256,6 @@ adb install demo/build/outputs/apk/debug/demo-debug.apk - **大数据分片:** 自动拆分,帧间延迟 35ms - **Service 发现:** 不传 UUID 全发现并过滤(兼容部分固件 scoped discovery 返回空的 bug) -## 服务端依赖 - -| Endpoint | 触发时机 | -|---|---| -| `POST /api/auth/loomart/shipment-sn/device/bind` | `fetchLatestFirmware` 内部软绑 | -| `POST /api/auth/loomart/shipment-sn/device/unbind` | `unbind` 软解 | -| `POST /api/auth/loomart/firmware/upgrade/check` | `fetchLatestFirmware` checkUpgrade | -| `POST /api/auth/loomart/firmware/upgrade/report-success` | `tryReportPendingUpgrade`(版本命中) | -| `POST /api/auth/loomart/firmware/upgrade/report-failure` | `tryReportPendingUpgrade`(VERSION_MISMATCH) | - -所有请求带 `x-api-key` + `x-owner` header。bind / unbind / setPlayMode BLE payload 还会携带 `time` 字段(北京时间 ISO 8601),来源走 `BeijingTimeProvider` 三级 fallback:苏宁公开 API → response Date header → 本地 + 8h。 - ## License Proprietary - Duooomi diff --git a/demo/src/main/kotlin/com/duooomi/ble/demo/DemoScreen.kt b/demo/src/main/kotlin/com/duooomi/ble/demo/DemoScreen.kt index e425716..eb9710b 100644 --- a/demo/src/main/kotlin/com/duooomi/ble/demo/DemoScreen.kt +++ b/demo/src/main/kotlin/com/duooomi/ble/demo/DemoScreen.kt @@ -65,7 +65,7 @@ fun DemoScreen( val upgradeRecords by sdk.upgradeRecords.collectAsState() // Editable config (rekey on sdk change so new SDK 不会带着旧编辑残留) - var configOwner by remember(sdk) { mutableStateOf(config.owner) } + var configBrand by remember(sdk) { mutableStateOf(config.brand) } var configApiKey by remember(sdk) { mutableStateOf(config.apiKey) } var configScanPrefix by remember(sdk) { mutableStateOf(config.scanPrefix) } @@ -126,14 +126,14 @@ fun DemoScreen( // === Config Section === item { SectionHeader("SDK Config(修改后点 Reload 生效)") - ConfigField("x-owner", configOwner) { configOwner = it } + ConfigField("brand", configBrand) { configBrand = it } ConfigField("apiKey", configApiKey) { configApiKey = it } ConfigField("scanPrefix", configScanPrefix) { configScanPrefix = it } Button( onClick = { - onReload(DemoConfig(configOwner, configApiKey, configScanPrefix)) + onReload(DemoConfig(configBrand, configApiKey, configScanPrefix)) log( - "SDK reloaded with owner=$configOwner, prefix=$configScanPrefix", + "SDK reloaded with brand=$configBrand, prefix=$configScanPrefix", LogLevel.SUCCESS ) }, diff --git a/demo/src/main/kotlin/com/duooomi/ble/demo/MainActivity.kt b/demo/src/main/kotlin/com/duooomi/ble/demo/MainActivity.kt index ac04856..8f72742 100644 --- a/demo/src/main/kotlin/com/duooomi/ble/demo/MainActivity.kt +++ b/demo/src/main/kotlin/com/duooomi/ble/demo/MainActivity.kt @@ -35,7 +35,7 @@ class MainActivity : ComponentActivity() { val prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE) val initialConfig = DemoConfig( - owner = prefs.getString(KEY_OWNER, DemoSecrets.OWNER) ?: DemoSecrets.OWNER, + brand = prefs.getString(KEY_BRAND, DemoSecrets.BRAND) ?: DemoSecrets.BRAND, apiKey = prefs.getString(KEY_API_KEY, DemoSecrets.API_KEY) ?: DemoSecrets.API_KEY, scanPrefix = prefs.getString(KEY_SCAN_PREFIX, DemoSecrets.SCAN_NAME_PREFIX) ?: DemoSecrets.SCAN_NAME_PREFIX @@ -69,14 +69,14 @@ class MainActivity : ComponentActivity() { context = this, config = DuooomiBleConfig( apiKey = c.apiKey, - owner = c.owner, + brand = c.brand, scanNamePrefix = c.scanPrefix ) ) private fun persist(prefs: SharedPreferences, c: DemoConfig) { prefs.edit() - .putString(KEY_OWNER, c.owner) + .putString(KEY_BRAND, c.brand) .putString(KEY_API_KEY, c.apiKey) .putString(KEY_SCAN_PREFIX, c.scanPrefix) .apply() @@ -101,14 +101,14 @@ class MainActivity : ComponentActivity() { private companion object { const val PREFS = "demo_config" - const val KEY_OWNER = "demo.config.owner" + const val KEY_BRAND = "demo.config.brand" const val KEY_API_KEY = "demo.config.apiKey" const val KEY_SCAN_PREFIX = "demo.config.scanPrefix" } } data class DemoConfig( - val owner: String, + val brand: String, val apiKey: String, val scanPrefix: String ) diff --git a/docs/DemoSecrets.template.kt b/docs/DemoSecrets.template.kt index 8bfd08a..91fc20e 100644 --- a/docs/DemoSecrets.template.kt +++ b/docs/DemoSecrets.template.kt @@ -6,6 +6,6 @@ package com.duooomi.ble.demo */ object DemoSecrets { const val API_KEY: String = "" - const val OWNER: String = "xmly" + const val BRAND: String = "xmly" const val SCAN_NAME_PREFIX: String = "Duooomi-" } diff --git a/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleConfig.kt b/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleConfig.kt index f92848b..f8285b1 100644 --- a/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleConfig.kt +++ b/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleConfig.kt @@ -8,8 +8,8 @@ data class DuooomiBleConfig( val apiHost: String = "https://api.duooomi.com", /** API 鉴权 key(Loomart x-api-key) */ val apiKey: String, - /** 多租户 owner 标识,所有 RPC 必带 `x-owner` header */ - val owner: String, + /** 设备品牌名(所有 RPC 必带 `x-owner` header) */ + val brand: String, /** 扫描时按设备蓝牙广播名前缀过滤(大小写不敏感)。**必填**,由集成方约定固件广播名前缀。 */ val scanNamePrefix: String, /** CDN 资源地址(末尾自动补 `/`) */ diff --git a/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleSDK.kt b/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleSDK.kt index 20be589..c1199ae 100644 --- a/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleSDK.kt +++ b/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleSDK.kt @@ -83,7 +83,7 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) { private var flushJob: Job? = null init { - BleLog.i("SDK initialized (apiHost=${config.apiHost}, owner=${config.owner})", "SDK") + BleLog.i("SDK initialized (apiHost=${config.apiHost}, brand=${config.brand})", "SDK") setupDisconnectHandler() // 启动时把持久化记录回放到 StateFlow _upgradeRecords.value = upgradeRecordStore.all @@ -253,6 +253,7 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) { */ suspend fun bind(userId: String): BindingResponse { ensureConnected() + verifyBrand() BleLog.d("Sending bind (userId=$userId)", "Command") val data = sendAndWait(CommandType.BIND_DEVICE) { deviceInfoService.bindDevice(userId) @@ -732,6 +733,19 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) { } } + /** + * 校验设备 brand 与 SDK 配置 brand 是否一致;缺失则先拉一次 deviceInfo。 + * 不一致时主动断连并抛 [DuooomiBleError.BrandMismatch]。 + */ + private suspend fun verifyBrand() { + val actual = _deviceInfo.value?.brand ?: getDeviceInfo().brand + val expected = config.brand + if (actual == expected) return + BleLog.w("Brand mismatch: expected=$expected, actual=$actual; disconnecting", "Command") + runCatching { disconnect() } + throw DuooomiBleError.BrandMismatch(expected = expected, actual = actual) + } + private fun parseJsonResponse(data: ByteArray): JSONObject { // Device responses may contain null bytes val cleaned = data.filter { it != 0.toByte() }.toByteArray() diff --git a/sdk/src/main/kotlin/com/duooomi/ble/core/BleTypes.kt b/sdk/src/main/kotlin/com/duooomi/ble/core/BleTypes.kt index 8220709..236f72b 100644 --- a/sdk/src/main/kotlin/com/duooomi/ble/core/BleTypes.kt +++ b/sdk/src/main/kotlin/com/duooomi/ble/core/BleTypes.kt @@ -31,4 +31,5 @@ sealed class DuooomiBleError(message: String) : Exception(message) { class UnsupportedFormat(val format: String) : DuooomiBleError("Unsupported file format: $format. Supported: jpeg, png, gif, mp4, webm, webp") object InvalidFrame : DuooomiBleError("Invalid protocol frame") object PermissionDenied : DuooomiBleError("Bluetooth permission denied") + class BrandMismatch(val expected: String, val actual: String) : DuooomiBleError("不支持该品牌设备 (expected=$expected, actual=$actual)") } diff --git a/sdk/src/main/kotlin/com/duooomi/ble/services/HttpHelper.kt b/sdk/src/main/kotlin/com/duooomi/ble/services/HttpHelper.kt index 56452ac..d84a8cb 100644 --- a/sdk/src/main/kotlin/com/duooomi/ble/services/HttpHelper.kt +++ b/sdk/src/main/kotlin/com/duooomi/ble/services/HttpHelper.kt @@ -32,7 +32,7 @@ internal object HttpHelper { conn.setRequestProperty("Content-Type", "application/json") conn.setRequestProperty("Accept", "application/json") conn.setRequestProperty("x-api-key", config.apiKey) - conn.setRequestProperty("x-owner", config.owner) + conn.setRequestProperty("x-owner", config.brand) val payload = JSONObject().apply { for ((k, v) in body) {