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

View File

@@ -0,0 +1,111 @@
import Foundation
/// OTA RN `bleStore.upgradeRecords` 1:1
/// - `(sn, firmwareId)` upsert
/// - 30 TTL `reported=true, outcome=nil`
/// - sn in-flight
final class UpgradeRecordStore {
private static let storageKey = "duooomi.firmware_upgrade_records"
/// 30
static let ttlMillis: Int64 = 30 * 24 * 60 * 60 * 1000
private let defaults: UserDefaults
private let queue = DispatchQueue(label: "duooomi.upgradeRecord", attributes: .concurrent)
private var records: [UpgradeRecord]
private var inFlight: Set<String> = []
/// mutation 线SDK delegate
var onChange: (([UpgradeRecord]) -> Void)?
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
if let data = defaults.data(forKey: Self.storageKey),
let decoded = try? JSONDecoder().decode([UpgradeRecord].self, from: data) {
self.records = decoded
} else {
self.records = []
}
}
// MARK: - Read
var all: [UpgradeRecord] {
queue.sync { records }
}
/// sn triggeredAt
func pending(sn: String) -> [UpgradeRecord] {
queue.sync {
records.filter { $0.sn == sn && !$0.reported }
.sorted { $0.triggeredAt < $1.triggeredAt }
}
}
// MARK: - Mutation
/// `(sn, firmwareId)` reported/outcome
func upsert(_ record: UpgradeRecord) {
guard !record.sn.isEmpty, !record.firmwareId.isEmpty else { return }
queue.async(flags: .barrier) {
if let idx = self.records.firstIndex(where: { $0.sn == record.sn && $0.firmwareId == record.firmwareId }) {
self.records[idx] = record
} else {
self.records.append(record)
}
self.persistLocked()
}
}
/// 线
func markReported(sn: String, firmwareId: String, outcome: UpgradeOutcome?) {
queue.async(flags: .barrier) {
guard let idx = self.records.firstIndex(where: { $0.sn == sn && $0.firmwareId == firmwareId }) else { return }
let original = self.records[idx]
self.records[idx] = UpgradeRecord(
sn: original.sn,
firmwareId: original.firmwareId,
fromVersion: original.fromVersion,
targetVersion: original.targetVersion,
triggeredAt: original.triggeredAt,
reported: true,
reportedAt: Self.nowMillis(),
outcome: outcome
)
self.persistLocked()
}
}
// MARK: - In-flight Mutex (sn )
/// sn false return
func tryAcquire(sn: String) -> Bool {
queue.sync(flags: .barrier) { () -> Bool in
if inFlight.contains(sn) { return false }
inFlight.insert(sn)
return true
}
}
func release(sn: String) {
queue.async(flags: .barrier) {
self.inFlight.remove(sn)
}
}
// MARK: - Helpers
static func nowMillis() -> Int64 {
Int64(Date().timeIntervalSince1970 * 1000)
}
/// + 线 onChange** barrier **
private func persistLocked() {
if let data = try? JSONEncoder().encode(records) {
defaults.set(data, forKey: Self.storageKey)
}
if let onChange = onChange {
let snapshot = records
DispatchQueue.main.async { onChange(snapshot) }
}
}
}