主要改动: - DemoSecrets 默认值对齐 iOS:owner=xmly, scanNamePrefix=Duooomi- - userId 默认值对齐:Cjgex9kTTTJq9u9RMOnc5hiIZTvrOOq3 - Config 区可在运行时编辑 owner/apiKey/scanPrefix,SharedPreferences 持久化;Reload SDK 按钮重建实例(旧 SDK 自动 disconnect) - PlayMode 行:显示当前模式标签(循环 / 单播)+ Single/Loop 按钮 - Firmware OTA:3 个独立输入(sn/currentVersion/userId) +「回填当前 状态」+「拉取安装包」+「安装」三按钮,对齐 iOS WrapperTestView - bind 完成 / version 更新自动回填 OTA 入参 - UpgradeRecords 4 状态展示:待上报 / ✅ 正确上报 / ❌ 错误上报 (版本不匹配) / 过期放弃 - 保留先前加的 Info+Ver 并发按钮(async 同时发 getDeviceInfo + getVersion,验证不同 commandType 通道并发不冲突) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Duooomi Android BLE SDK
Duooomi 蓝牙 BLE SDK 的 Android 版本(与 iOS / expo 版业务行为 1:1 对齐)。
环境要求
SDK 集成要求(消费方最低版本):
| 项目 | 最低要求 |
|---|---|
| Kotlin | 1.9.0+ |
| AGP | 7.4.2+ |
| minSdk | 23 (Android 6.0) |
| JDK | 11+ |
SDK 开发构建环境:
| 项目 | 要求 |
|---|---|
| Android Studio | Hedgehog (2023.1.1) 或更高 |
| Kotlin | 1.9.22 |
| AGP | 8.2.2 |
| Gradle | 8.x |
| JDK | 17 |
| compileSdk / targetSdk | 34 |
项目结构
duooomi-android-sdk/
├── sdk/ # SDK 库模块 (com.android.library)
│ └── src/main/kotlin/com/duooomi/ble/
│ ├── DuooomiBleSDK.kt # 公开 API 入口
│ ├── DuooomiBleConfig.kt # SDK 配置
│ ├── core/ # BLE 核心层 (BleClient, 类型定义, 错误类型)
│ ├── protocol/ # BLE 协议层 (帧格式, UUID, 协议管理)
│ ├── services/ # 服务层 (设备命令 / OTA / 软绑 / 升级记录持久化)
│ ├── models/ # 数据模型
│ └── utils/ # 北京时间 / 日志
├── demo/ # 演示 App (Jetpack Compose)
└── build.gradle.kts
依赖
SDK 仅依赖一个外部库:
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3
其余全部使用 Android 内置 API(BLE、JSON、HttpURLConnection、SharedPreferences)。
快速开始
1. 引入 SDK
settings.gradle.kts:
include(":sdk")
app 模块的 build.gradle.kts:
dependencies {
implementation(project(":sdk"))
}
2. 添加权限
AndroidManifest.xml:
<!-- Android 11 及以下 -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<!-- Android 12+ -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- 网络 (ANI 转换 / OTA / 软绑) -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
运行时仍需动态申请
BLUETOOTH_SCAN、BLUETOOTH_CONNECT(Android 12+)和ACCESS_FINE_LOCATION。
3. 初始化 SDK
val sdk = DuooomiBleSDK(
context = this,
config = DuooomiBleConfig(
apiKey = "YOUR_API_KEY",
owner = "YOUR_OWNER", // 多租户标识,所有 RPC 必带
scanNamePrefix = "Duooomi-" // 扫描时按广播名前缀过滤
)
)
4. Config 字段说明
| 参数 | 必填 | 说明 |
|---|---|---|
apiKey |
✅ | Loomart x-api-key 鉴权 |
owner |
✅ | 多租户 owner,所有 RPC 带 x-owner header |
scanNamePrefix |
✅ | 扫描时按广播名前缀过滤(大小写不敏感) |
apiHost |
默认 https://api.duooomi.com |
|
cdnHost |
默认 https://cdn.bowong.cc/ |
|
aniWidth / aniHeight / aniFps |
ANI 转换参数,默认 360 / 360 / "24" |
API 参考
可观察状态(StateFlow)
sdk.btState // ConnectionState: IDLE, SCANNING, CONNECTING, CONNECTED, ...
sdk.discoveredDevices // List<DiscoveredDevice>
sdk.connectedDevice // DiscoveredDevice?
sdk.deviceInfo // DeviceInfo? (含可选 loop: PlayMode?)
sdk.version // String (设备固件版本)
sdk.isActivated // Boolean (是否已 bind)
sdk.sn // String (绑定后写入设备序列号)
sdk.firmwareUpgrade // FirmwareUpgradeCheckResult? (最新一次拉取结果)
sdk.upgradeRecords // List<UpgradeRecord> (持久化的 OTA 历史)
sdk.transferProgress // Int (0-100)
sdk.error // String?
扫描与连接
sdk.scan() // 开始扫描(按 scanNamePrefix 过滤)
sdk.stopScan()
val device = sdk.connect(deviceId) // suspend
sdk.disconnect() // suspend
设备操作(均需先连接)
val info = sdk.getDeviceInfo()
// info.brand / info.name / info.size / info.powerlevel
// info.freespace / info.allspace / info.loop (PlayMode?)
val versionInfo = sdk.getVersion()
// versionInfo.version / versionInfo.type
// 绑定设备 —— 只做 BLE 硬绑 + getVersion()
// 完成后写入 sdk.sn / sdk.version / sdk.isActivated
// 软绑 / 拉最新固件 / 对账上报 —— 全部走 fetchLatestFirmware(...)
val bindResult = sdk.bind(userId)
// bindResult.sn / bindResult.contents (设备文件列表)
// 解绑 —— 硬解 + 软解(软解失败仅 warn 不抛)
sdk.unbind(userId)
// 删除设备文件
sdk.deleteFile(fileKey)
播放模式切换
import com.duooomi.ble.models.PlayMode
// 单播(播完一次停)
sdk.setPlayMode(PlayMode.SINGLE, userId)
// 循环播放
sdk.setPlayMode(PlayMode.LOOP, userId)
复用
BIND_DEVICE(0x0F) 命令通道;前置必须已bind()完成(isActivated == true), 否则直接抛BindingFailed("Device not bound; call bind(userId) first"),不发送命令。bind/unbind/setPlayMode共享0x0F / 0x12opId 通道,不能并发。
文件传输
sdk.transferMedia(fileUrl)
sdk.transferProgress.collect { percent -> /* 0-100 */ }
支持格式: JPEG / PNG / GIF / MP4 / WebM / WebP。先按 URL 扩展名校验,无扩展名时 HEAD 请求查 Content-Type,不支持的格式抛 DuooomiBleError.UnsupportedFormat。
OTA 升级流程
完整三段式 OTA(与 expo bindDeviceWithOrchestration 对齐):
// Step 1: 硬绑(前置:已 connect)
val bindResult = sdk.bind(userId)
val sn = sdk.sn.value // 设备序列号
val cur = sdk.version.value // 当前固件版本
// Step 2: 拉最新固件 —— 内部串「对账上报 → 软绑 → checkUpgrade」
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.version // 目标版本字符串
target.fileUrl // 固件包下载 URL
target.fileSize // 字节数(字符串编码)
target.fileMd5 // MD5
target.forceUpgrade // 强制升级标记(true 时建议弹不可关闭弹窗)
// Step 3: 烧录 —— 完成后自动落 UpgradeRecord 到本地
sdk.upgradeFirmware(
sn = sn,
fileUrl = target.fileUrl!!,
firmwareId = target.id,
fromVersion = cur,
targetVersion = target.version
)
// Step 4 (下一次启动):重新连接 → bind → 再调 fetchLatestFirmware
// 会自动触发 tryReportPendingUpgrade,把上次 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 上展示对账历史。
错误处理
try {
sdk.connect(deviceId)
sdk.bind(userId)
sdk.fetchLatestFirmware(sn = sdk.sn.value, currentVersion = sdk.version.value, userId = userId)
} catch (e: DuooomiBleError.BluetoothNotPoweredOn) { /* 蓝牙未开启 */ }
catch (e: DuooomiBleError.ConnectionFailed) { /* 连接失败 */ }
catch (e: DuooomiBleError.NotConnected) { /* 未连接 */ }
catch (e: DuooomiBleError.Timeout) { /* 超时 */ }
catch (e: DuooomiBleError.PermissionDenied) { /* 权限不足 */ }
catch (e: DuooomiBleError.AlreadyBoundByOtherUser) { /* 设备已绑给他人 */ }
catch (e: DuooomiBleError.SoftBindFailed) { /* 软绑失败(已自动硬解回滚) */ }
catch (e: DuooomiBleError.FirmwareUpgradeUnavailable) { /* sn 为空等前置不满足 */ }
运行 Demo
demo/src/main/kotlin/com/duooomi/ble/demo/DemoSecrets.kt 是本地秘钥文件(已 gitignore),从 docs/DemoSecrets.template.kt 拷贝并填入真实 apiKey / owner。
./gradlew :demo:assembleDebug
adb install demo/build/outputs/apk/debug/demo-debug.apk
BLE 协议简述
- Service UUID:
000002c4-0000-1000-8000-00805f9b34fb - Write Characteristic:
000002c5-... - Read Characteristic (Notify):
000002c6-... - 帧格式:
[head:1][type:1][subpageTotal:2][curPage:2][dataLen:2][data:N][checksum:1] - 校验和: 二进制补码
(~sum + 1) & 0xFF - 大数据分片: 自动拆分,帧间延迟 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