Files
duooomi-ios-sdk/README.md
km2023 d5729a1a41 feat(sdk): OTA 升级流程对齐 expo + 拆 extension 文件分层 + 秘钥本地化
主要改动:
- 新增 fetchLatestFirmware/upgradeFirmware/tryReportPendingUpgrade
  三段 OTA 接口,对齐 expo bindDeviceWithOrchestration 行为
- bind 仅做硬绑+getVersion,软绑/对账上报/拉最新固件搬到
  fetchLatestFirmware 内部串;软绑失败自动硬解回滚
- UpgradeRecord 复合主键 (sn, firmwareId) 持久化 + sn 级 in-flight 互斥
- DuooomiBleSDKDelegate 新增 didUpdateUpgradeRecords,集成方
  可观察对账上报结果(reported/outcome 状态)
- DuooomiBleSDK 主类按职责拆 5 个 extension 文件,公开 API 不变
- TargetFirmware 简化为只取必要字段 + 新增 forceUpgrade
- scanNamePrefix 改为必填 String
- demo 秘钥移至本地 DemoSecrets.swift (gitignored)
- README 重写:完整 OTA 流程示例 + 上报状态判定表

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:46:41 +08:00

14 KiB
Raw Blame History

DuooomiBleSDK (iOS)

纯原生 Swift BLE SDK零第三方依赖。

集成

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 / owner / 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
    owner: "your-owner",                                 // 必填,所有 RPC 必带 x-owner header
    scanNamePrefix: "蓝牙广播名前缀"                       // 必填,大小写不敏感
    // apiHost: URL(...)!,                               // 默认 https://api.duooomi.com
    // cdnHost: "https://cdn.bowong.cc/",                // 默认
    // aniWidth: 360, aniHeight: 360, aniFps: "24",      // ANI 转换默认参数
))

sdk.delegate = self

SDK 能力范围

  • 内置BLE 协议(扫描/连接/硬绑/硬解/播放模式/文件传输/OTA 烧录)+ 软绑POST /api/auth/loomart/shipment-sn/device/bind+ 软解绑 + 升级对账上报 + 服务端拉最新固件 + 北京时间获取
  • 不内置UI、retry 策略SDK 只提供原子方法和状态属性,由集成方组合

绑定与固件升级流程

绑定按 BLE 链路 / 云端登记拆两步:

  1. bind(userId:) — 硬绑BLE 0x0F+ 同步 getDeviceVersion。完成后 sn / version / isActivated 就绪。
  2. fetchLatestFirmware(sn:currentVersion:userId:) — 内部串:
    1. 对账上报本地 pending UpgradeRecord(容错失败)
    2. 软绑 shipment-sn(失败 → 自动硬解绑回滚 + 重置 isActivated/sn,整个 fetch 失败)
    3. 拉服务端最新固件 → 写入 firmwareUpgrade

升级烧录走 upgradeFirmware(sn:fileUrl:firmwareId:fromVersion:targetVersion:),烧录完自动落 UpgradeRecord

解绑只一步:unbind(userId:) 内部硬解 + 软解(软解失败仅 warn 不阻断)。

API 调用风格

所有公开方法显式传入参,不依赖 SDK 内部状态自动取参。调用方从 SDK 只读属性(sdk.sn / sdk.version / sdk.firmwareUpgrade?.targetFirmware?.* 等)组装参数后传入。

解绑只一步:unbind(userId:) 内部硬解 + 软解(软解失败仅 warn 不阻断)。

代理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)")
    }
}

// 硬绑 + getVersion不含软绑/OTA 检查
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(即 SDK isActivated == 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.loopnil,调用 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落 record→ 重连后再次 fetchLatestFirmware 触发对账

// ─── 0. 接收升级记录变化(用来观察对账上报结果) ───
extension MyController: DuooomiBleSDKDelegate {
    func sdk(_ sdk: DuooomiBleSDK, didUpdateUpgradeRecords records: [UpgradeRecord]) {
        for r in records {
            switch (r.reported, r.outcome) {
            case (false, _):                  print("待上报 sn=\(r.sn) fw=\(r.firmwareId)")
            case (true, .success):            print("✅ 正确上报 \(r.fromVersion)\(r.targetVersion)")
            case (true, .versionMismatch):    print("❌ 错误上报 versionMismatch")
            case (true, nil):                 print("过期放弃 (>30 天)")
            }
        }
    }
}
sdk.delegate = self

// ─── 1. 扫描 + 连接 ───
sdk.scan()
// 在 didUpdateDevices 拿到列表后选一台 connect
sdk.connect(deviceId: device.id) { result in
    guard case .success = result else { return }

    // ─── 2. 硬绑BLE 0x0F + 同步 getVersion───
    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. 拉最新固件(内部串:对账上报 → 软绑 → checkUpgrade───
        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. 强制升级判定(可选)───
                if target.forceUpgrade == true {
                    // 弹不可关闭弹窗
                }

                // ─── 5. 烧录(完成后自动落 UpgradeRecord ───
                sdk.upgradeFirmware(
                    sn: sdk.sn,
                    fileUrl: fileUrl,
                    firmwareId: target.id,
                    fromVersion: sdk.version,
                    targetVersion: target.version
                ) { result in
                    switch result {
                    case .success:
                        // OTA 包发完,设备开始烧录并重启。
                        // UpgradeRecord 已落本地reported=false等设备重启 + 重连。
                        print("烧录命令完成,等待设备重启")
                    case .failure(let err):
                        print("烧录失败:\(err.localizedDescription)")
                    }
                }
            }
        }
    }
}

// ─── 6. 设备重启 + 重连后,再次走完整流程 ───
//    重新 scan/connect/bind 完成后,调一次 fetchLatestFirmware
//    内部 tryReportPendingUpgrade 会拿当前 version 和上次 record.targetVersion 对账,
//    命中 → report-success不命中 → report-failure(VERSION_MISMATCH ...)。
//    结果通过 didUpdateUpgradeRecords 主动通知(参见步骤 0

升级对账上报机制

  • 何时落记录upgradeFirmware(...) 烧录命令发完后SDK 自动 upsert 一条 UpgradeRecordUserDefaults(复合主键 (sn, firmwareId)),初始 reported=false

  • 何时触发对账:每次 fetchLatestFirmware(...) 进入时,第一步就跑 tryReportPendingUpgrade

  • 判定规则

    • currentVersion == record.targetVersionPOST report-successoutcome=.success
    • currentVersion != record.targetVersionPOST report-failure(VERSION_MISMATCH expected=... actual=...)outcome=.versionMismatch
    • now - triggeredAt > 30 天 → 标记 outcome=nil 不再尝试,保留为审计轨迹
    • HTTP 失败 → 保持 reported=false,下次 fetchLatestFirmware 再试
  • 响应回调DuooomiBleSDKDelegate.didUpdateUpgradeRecords 在每次 record 状态变化时主线程触发,完整 records 数组。

  • 手动触发(仅调试用):

    sdk.tryReportPendingUpgrade(sn: sdk.sn, currentVersion: sdk.version, userId: "user-123")
    

UpgradeRecord 状态判定

reported outcome 含义
false nil 待上报HTTP 失败/未跑),下次 fetchLatestFirmware 重试
true .success 正确上报:版本命中,业务对账成功
true .versionMismatch 错误上报:设备版本与目标不符,烧录可能失败
true nil 过期放弃30 天 TTL 触发,保留作审计

可读取状态

属性 类型 说明
btState ConnectionState idle / scanning / connecting / connected / disconnecting / disconnected
discoveredDevices [DiscoveredDevice] 扫描到的设备列表
connectedDevice DiscoveredDevice? 当前连接设备
deviceInfo DeviceInfo? 设备信息
version String 固件版本
isActivated Bool 是否已绑定
transferProgress Int 传输进度 0-100
error String? 最近错误

数据类型

// 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?
}

// UpgradeRecord — 本地持久化的 OTA 烧录记录(用 UserDefaults集成方一般不用直接读
public struct UpgradeRecord {
    let sn: String
    let firmwareId: String
    let fromVersion: String
    let targetVersion: String
    let triggeredAt: Int64                       // 毫秒时间戳
    let reported: Bool
    let reportedAt: Int64?
    let outcome: UpgradeOutcome?                 // .success / .versionMismatch / nilTTL 过期)
}

// 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 操作专用串行队列