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>
This commit is contained in:
km2023
2026-05-08 14:46:41 +08:00
parent f4a32bb7e8
commit d5729a1a41
24 changed files with 2349 additions and 880 deletions

208
README.md
View File

@@ -21,24 +21,59 @@ dependencies: [
]
```
## Demo 初始化(仓库内 demo 目录)
`apiKey` / `owner` / `scanNamePrefix` 不进 git由本地 `demo/Sources/DemoSecrets.swift` 提供:
```bash
cp demo/Sources/DemoSecrets.template.swift demo/Sources/DemoSecrets.swift
# 然后编辑 DemoSecrets.swift 填真实 apiKey
pod install
open demo.xcworkspace
```
`DemoSecrets.swift` 已加入 `.gitignore`,本地修改不会被 commit。
## 初始化
```swift
let sdk = DuooomiBleSDK(config: .init(
apiKey: "your-api-key" //
// apiHost: URL(...)!, // https://api.duooomi.com
// cdnHost: "https://cdn.bowong.cc/", //
// firmwareIdentifier: "duomi", //
// firmwareStatus: "DRAFT" //
// aniWidth: 360, // ANI 360
// aniHeight: 360, // ANI 360
// aniFps: "24", // ANI 24
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` 接收状态变化,所有方法在主线程调用,均有默认空实现:
@@ -97,19 +132,22 @@ sdk.getVersion { result in
}
}
// + 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不会发命令。请在 UI 上禁用切换按钮,直到 `didChangeActivation(true)` 回调到达
> ⚠️ **前置条件 — 必须先绑定。** `setPlayMode` 复用 `BIND_DEVICE` (0x0F) 命令通道,固件设计上要求设备先和该 `userId` 完成 `bind`(即 SDK `isActivated == true`才能切播放模式。SDK 在客户端做了守卫:未绑定时直接 fail不会发命令。调用方在 `isActivated == true` 之前不应调用此方法
```swift
// 0 = / 1 =
@@ -151,24 +189,113 @@ sdk.transferMedia(fileUrl: "https://example.com/video.mp4") { result in
### 固件升级
完整流程:**扫描 → 连接 → bind → fetchLatestFirmware含对账上报→ upgradeFirmware落 record→ 重连后再次 fetchLatestFirmware 触发对账**。
```swift
// 1.
sdk.fetchLatestFirmware { result in
guard case .success(let info?) = result else { return }
// 2.
let needUpdate = sdk.hasNewerFirmware(
deviceVersion: sdk.version,
serverVersion: info.version
)
// 3. OTA
if needUpdate {
sdk.upgradeFirmware(fileUrl: info.fileUrl) { result in /* ... */ }
// 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 一条 `UpgradeRecord``UserDefaults`(复合主键 `(sn, firmwareId)`),初始 `reported=false`
- **何时触发对账**:每次 `fetchLatestFirmware(...)` 进入时,第一步就跑 `tryReportPendingUpgrade`
- **判定规则**
- `currentVersion == record.targetVersion``POST report-success``outcome=.success`
- `currentVersion != record.targetVersion``POST report-failure(VERSION_MISMATCH expected=... actual=...)``outcome=.versionMismatch`
- `now - triggeredAt > 30 天` → 标记 `outcome=nil` 不再尝试,保留为审计轨迹
- HTTP 失败 → 保持 `reported=false`,下次 fetchLatestFirmware 再试
- **响应回调**`DuooomiBleSDKDelegate.didUpdateUpgradeRecords` 在每次 record 状态变化时主线程触发,完整 records 数组。
- **手动触发**(仅调试用):
```swift
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 触发,保留作审计 |
### 可读取状态
| 属性 | 类型 | 说明 |
@@ -185,15 +312,32 @@ sdk.fetchLatestFirmware { result in
### 数据类型
```swift
// FirmwareInfo fetchLatestFirmware
public struct FirmwareInfo {
let version: String //
let fileUrl: String //
let description: String? //
let fileSize: String? //
let fileMd5: String? // MD5
let identifier: String? //
let status: String? // DRAFT / PUBLISHED
// 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 — 播放模式