旧实现里 transferProgress 只在 transferFile() 内部才置 0,但 transferMedia 要先走 validateMediaFormat → aniConverter.convert → getRemoteFileSize → prepareTransfer 这条数秒级异步链,期间字段还停 留在上次结束的 100。集成方 UI 一接上观察就看到旧值 100,体感像「上 次进度被带过来」。 修法:transferMedia 在 connectedDevice 守卫通过后立即 transferProgress = 0, delegate 立即收到 didUpdateProgress(0);后续 transferFile() 内部的 = 0 因 didSet 的 oldValue 守卫被自然跳过,不会重复广播。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DuooomiBleSDK (iOS)
纯原生 Swift BLE SDK,零第三方依赖。
⚠️ 请先跑通仓库内 demo 再开始对接 SDK。 demo 已串完整链路(扫描 / 连接 / 绑定 / OTA / 文件传输),是验证设备 + apiKey + brand 是否对的最快途径,避免在自家工程里盲调试。
集成
CocoaPods
pod 'DuooomiBleSDK', :git => 'https://gitea.bowong.cc/comi-logic/duooomi-ios-sdk.git', :branch => 'main'
然后执行 pod install。
Swift Package Manager
// Package.swift
dependencies: [
.package(url: "https://gitea.bowong.cc/comi-logic/duooomi-ios-sdk.git", branch: "main")
]
Demo 初始化(仓库内 demo 目录)
apiKey / brand / scanNamePrefix 不进 git,由本地 demo/Sources/DemoSecrets.swift 提供:
cp demo/Sources/DemoSecrets.template.swift demo/Sources/DemoSecrets.swift
# 然后编辑 DemoSecrets.swift 填真实 apiKey
pod install
open demo.xcworkspace
DemoSecrets.swift 已加入 .gitignore,本地修改不会被 commit。
初始化
let sdk = DuooomiBleSDK(config: .init(
apiKey: "your-api-key", // 必填,Loomart x-api-key
brand: "your-brand", // 必填,设备品牌名(所有 RPC 必带 x-owner header)
scanNamePrefix: "蓝牙广播名前缀" // 必填,大小写不敏感
// apiHost: URL(...)!, // 默认 https://api.duooomi.com
// cdnHost: "https://cdn.bowong.cc/", // 默认
// aniWidth: 360, aniHeight: 360, aniFps: "24", // ANI 转换默认参数
// frameIntervalMs: 35, // 协议帧间隔(ms),合法 [0,500],越界 warn 后 clamp
// logLevel: .off, // 日志等级,默认 .off(完全静默),排查时改 .debug
))
sdk.delegate = self
日志(BleLogLevel)
SDK 内部所有日志统一走 BleLog → print,分级 off / error / warn / info / debug。集成方通过 config.logLevel 控制输出:
// 对外发布:什么都不传,默认完全静默
DuooomiBleSDK(config: .init(apiKey: ..., brand: ..., scanNamePrefix: ...))
// 本地排查:开 debug 看到所有 BLE 帧、RPC opId、传输进度等
DuooomiBleSDK(config: .init(apiKey: ..., brand: ..., scanNamePrefix: ..., logLevel: .debug))
- 等级单调:
.warn会输出 warn + error;.info输出 info + warn + error;以此类推。 - Release 构建因
#if DEBUG条件编译,任何logLevel都不会输出(双保险)。 - 日志按 category 分桶:
SDK / Scan / Connect / BLE / RPC / Command / Transfer / Firmware / Protocol / Decode / Config,Xcode console 可按[DuooomiBleSDK][<category>]过滤。
SDK 能力范围
- 内置:BLE 协议(扫描/连接/绑定/解绑/播放模式/文件传输/OTA 烧录)+ 固件版本检查
- 不内置:UI、retry 策略;SDK 只提供原子方法和状态属性,由集成方组合
绑定与固件升级流程
bind(userId:)— 绑定设备,完成后sn/version/isActivated就绪。fetchLatestFirmware(sn:currentVersion:userId:)— 拉服务端最新固件信息,写入firmwareUpgrade。upgradeFirmware(...)— 烧录固件包,进度通过didUpdateProgress回调。
解绑:unbind(userId:) 一步完成。
API 调用风格
所有公开方法显式传入参,不依赖 SDK 内部状态自动取参。调用方从 SDK 只读属性(sdk.sn / sdk.version / sdk.firmwareUpgrade?.targetFirmware?.* 等)组装参数后传入。
代理(Delegate)
通过 DuooomiBleSDKDelegate 接收状态变化,所有方法在主线程调用,均有默认空实现:
extension ViewController: DuooomiBleSDKDelegate {
func sdk(_ sdk: DuooomiBleSDK, didChangeState state: ConnectionState) { }
func sdk(_ sdk: DuooomiBleSDK, didUpdateDevices devices: [DiscoveredDevice]) { }
func sdk(_ sdk: DuooomiBleSDK, didConnectDevice device: DiscoveredDevice?) { }
func sdk(_ sdk: DuooomiBleSDK, didUpdateDeviceInfo info: DeviceInfo?) { }
func sdk(_ sdk: DuooomiBleSDK, didUpdateVersion version: String) { }
func sdk(_ sdk: DuooomiBleSDK, didChangeActivation isActivated: Bool) { }
func sdk(_ sdk: DuooomiBleSDK, didUpdateProgress progress: Int) { }
func sdk(_ sdk: DuooomiBleSDK, didEncounterError error: String?) { }
}
API
所有异步方法使用 completion 回调,返回 Result<T, Error>。
扫描
| 方法 | 说明 |
|---|---|
scan() |
开始扫描,结果通过 delegate didUpdateDevices 回调 |
stopScan() |
停止扫描 |
连接
sdk.connect(deviceId: deviceId) { result in
switch result {
case .success(let device): print("Connected: \(device.name ?? device.id)")
case .failure(let error): print("Failed: \(error)")
}
}
sdk.disconnect { result in
// ...
}
设备命令
sdk.getDeviceInfo { result in
if case .success(let info) = result {
print("Brand: \(info.brand), Power: \(info.powerlevel)%")
}
}
sdk.getVersion { result in
if case .success(let info) = result {
print("Version: \(info.version)")
}
}
// 绑定设备 + 同步拉版本
sdk.bind(userId: "user-id") { result in
if case .success(let resp) = result {
print("SN: \(resp.sn), Files: \(resp.contents.count)")
// 此时 sdk.sn / sdk.version / sdk.isActivated 已就绪
}
}
// 解绑
sdk.unbind(userId: "user-id") { result in /* ... */ }
sdk.deleteFile(key: "file-key") { result in /* ... */ }
播放模式(Play Mode)
⚠️ 前置条件 — 必须先绑定。
setPlayMode复用BIND_DEVICE(0x0F) 命令通道,固件设计上要求设备先和该userId完成bind(即 SDKisActivated == true)才能切播放模式。SDK 在客户端做了守卫:未绑定时直接 fail,不会发命令。调用方在isActivated == true之前不应调用此方法。
// 0 = 单播(播完一次停)/ 1 = 循环播放
sdk.setPlayMode(.loop, userId: "user-id") { result in
switch result {
case .success:
// 成功后 SDK 自动刷新 deviceInfo,
// 通过 didUpdateDeviceInfo 回调拿到最新 info.loop
break
case .failure(let error):
print("setPlayMode failed: \(error)")
}
}
注意事项:
bind/unbind/setPlayMode共享0x0F/0x12响应通道,不要并发调用- 旧固件可能不支持
loop字段 —— 此时DeviceInfo.loop为nil,调用setPlayMode设备可能回success=0
文件传输
// 一步完成:格式校验 → ANI 转换 → prepare → transfer
sdk.transferMedia(fileUrl: "https://example.com/video.mp4") { result in
switch result {
case .success: print("Transfer complete")
case .failure(let error): print("Transfer failed: \(error)")
}
}
// 进度通过 delegate didUpdateProgress 回调
支持的媒体格式: JPEG、PNG、GIF、MP4、WebM、WebP
格式校验规则:
- 优先从 URL path 提取扩展名(自动忽略查询参数)
- 无扩展名时通过 HEAD 请求检查
Content-Type - 不支持的格式返回
DuooomiBleError.unsupportedFormat
固件升级
完整流程:扫描 → 连接 → bind → fetchLatestFirmware → upgradeFirmware。设备重启重连后再次走一遍即可。
// ─── 1. 扫描 + 连接 ───
sdk.scan()
// 在 didUpdateDevices 拿到列表后选一台 connect
sdk.connect(deviceId: device.id) { result in
guard case .success = result else { return }
// ─── 2. 绑定 ───
sdk.bind(userId: "user-123") { result in
guard case .success(let resp) = result else { return }
// 此时 sdk.sn = resp.sn / sdk.version 已就绪 / sdk.isActivated = true
// ─── 3. 拉最新固件信息 ───
sdk.fetchLatestFirmware(
sn: sdk.sn,
currentVersion: sdk.version,
userId: "user-123"
) { result in
switch result {
case .failure(let err):
print("拉取失败:\(err.localizedDescription)")
return
case .success(let info):
// info 同步写入 sdk.firmwareUpgrade
guard let target = info.targetFirmware,
let fileUrl = target.fileUrl else {
print("无可升级固件")
return
}
// ─── 4. 烧录 ───
sdk.upgradeFirmware(
sn: sdk.sn,
fileUrl: fileUrl,
firmwareId: target.id,
fromVersion: sdk.version,
targetVersion: target.version
) { result in
switch result {
case .success:
// OTA 包发完,设备开始烧录并重启
print("烧录命令完成,等待设备重启")
case .failure(let err):
print("烧录失败:\(err.localizedDescription)")
}
}
}
}
}
}
可读取状态
| 属性 | 类型 | 说明 |
|---|---|---|
btState |
ConnectionState |
idle / scanning / connecting / connected / disconnecting / disconnected |
discoveredDevices |
[DiscoveredDevice] |
扫描到的设备列表 |
connectedDevice |
DiscoveredDevice? |
当前连接设备 |
deviceInfo |
DeviceInfo? |
设备信息 |
version |
String |
固件版本 |
isActivated |
Bool |
是否已绑定 |
transferProgress |
Int |
传输进度 0-100 |
error |
String? |
最近错误 |
汇总:可用方法与属性
一张表速查所有 public API。异步方法的失败分支均为
Result的.failure(Error),Error多为DuooomiBleError,少数透传DecodingError/URLError(详见各方法注释)。
方法
| 方法 | 参数 | 返回 | 说明 |
|---|---|---|---|
scan() |
— | 无(didUpdateDevices 回调) |
开始扫描 |
stopScan() |
— | 无 | 停止扫描 |
connect(deviceId:completion:) |
deviceId: String |
Result<DiscoveredDevice, Error> |
连接设备 |
disconnect(completion:) |
— | Result<Void, Error> |
断开连接 |
getConnectedDevices() |
— | [DiscoveredDevice](同步) |
当前已连接设备列表 |
bind(userId:completion:) |
userId: String |
Result<BindingResponse, Error> |
绑定 + 同步拉版本,成功后 sn/version/isActivated 就绪 |
unbind(userId:completion:) |
userId: String |
Result<UnbindResponse, Error> |
解绑 |
getDeviceInfo(completion:) |
— | Result<DeviceInfo, Error> |
读品牌/电量/存储/loop,写入 deviceInfo |
getVersion(completion:) |
— | Result<VersionInfo, Error> |
读固件版本,写入 version/versionLog |
setPlayMode(_:userId:completion:) |
_ mode: PlayMode, userId: String |
Result<BindingResponse, Error> |
切单播/循环;须先 bind |
deleteFile(key:completion:) |
key: String |
Result<DeleteFileResponse, Error> |
删除设备文件 |
prepareTransfer(key:size:completion:) |
key: String, size: Int |
Result<PrepareTransferResponse, Error> |
传输前预协商(一般无需直调) |
transferMedia(fileUrl:completion:) |
fileUrl: String |
Result<Void, Error> |
一步:校验→ANI 转换→prepare→传输 |
transferFile(fileUri:commandType:completion:) |
fileUri: String, commandType: CommandType = .transferAniVideo |
Result<Void, Error> |
底层文件传输 |
fetchLatestFirmware(sn:currentVersion:userId:completion:) |
sn: String, currentVersion: String, userId: String |
Result<FirmwareUpgradeCheckResult, Error> |
对账上报 + 拉最新固件,写入 firmwareUpgrade |
upgradeFirmware(sn:fileUrl:firmwareId:fromVersion:targetVersion:completion:) |
sn, fileUrl, firmwareId, fromVersion, targetVersion: String |
Result<Void, Error> |
OTA 烧录;下发前校验实时电量,< 40% 返回 .batteryTooLow,不发任何字节 |
tryReportPendingUpgrade(sn:currentVersion:userId:) |
sn: String, currentVersion: String, userId: String |
无(fire-and-forget,结果见 upgradeRecords) |
对账上报本地待报记录(fetchLatestFirmware 已内置调用) |
属性(除 delegate 外均只读)
| 属性 | 类型 | 说明 |
|---|---|---|
delegate |
DuooomiBleSDKDelegate? |
事件回调代理(weak,可读写) |
config |
DuooomiBleConfig |
初始化配置(let) |
btState |
ConnectionState |
idle / scanning / connecting / connected / disconnecting / disconnected |
discoveredDevices |
[DiscoveredDevice] |
扫描到的设备列表 |
connectedDevice |
DiscoveredDevice? |
当前连接设备 |
deviceInfo |
DeviceInfo? |
设备信息(品牌/电量/存储/loop) |
version |
String |
固件版本(bind 后就绪) |
versionLog |
[String: Any]? |
设备版本附带 JSON(透传给 metadata 上报) |
isActivated |
Bool |
是否已绑定 |
sn |
String |
设备序列号(bind 成功后就绪) |
transferProgress |
Int |
传输进度 0–100 |
error |
String? |
最近错误描述 |
firmwareUpgrade |
FirmwareUpgradeCheckResult? |
最近一次 fetchLatestFirmware 结果 |
upgradeRecords |
[UpgradeRecord] |
本地 OTA 对账记录(computed) |
数据类型
// FirmwareUpgradeCheckResult — fetchLatestFirmware 返回,同步写入 sdk.firmwareUpgrade
public struct FirmwareUpgradeCheckResult {
let upgradeAvailable: Bool
let reason: FirmwareUpgradeCheckReason // UPGRADE_AVAILABLE / ALREADY_LATEST / DEVICE_NOT_FOUND ...
let currentSystemVersion: String?
let targetFirmware: TargetFirmware?
}
public struct TargetFirmware {
let id: String // firmwareId(对账时使用)
let version: String // 目标版本
let fileUrl: String? // 固件下载地址
let fileSize: String? // 字节数(字符串)
let fileMd5: String?
}
// PlayMode — 播放模式
public enum PlayMode: Int {
case single = 0 // 单播
case loop = 1 // 循环播放
}
// DeviceInfo — getDeviceInfo 返回
public struct DeviceInfo {
let name: String // 产品名
let brand: String // 品牌
let size: String // 屏幕尺寸
let powerlevel: Int // 电量百分比
let allspace: UInt64 // 总存储(字节)
let freespace: UInt64 // 可用存储(字节)
let loop: PlayMode? // 播放模式(旧固件无此字段时为 nil)
}
// BindingResponse — bind 返回
public struct BindingResponse {
let success: Int // 1=成功
let sn: String // 设备序列号
let contents: [String] // 设备文件 key 列表
}
Info.plist
<key>NSBluetoothAlwaysUsageDescription</key>
<string>This app needs Bluetooth to communicate with your device</string>
技术规格
- iOS 12.2+ / Swift 5.0+ / 零依赖
- Delegate 回调模式,所有状态变化在主线程通知
- BLE 操作专用串行队列