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

@@ -3,9 +3,11 @@ import Foundation
/// JSON fire-and-forget
final class DeviceInfoService {
private let protocolService: BleProtocolService
private let timeProvider: BeijingTimeProvider
init(protocolService: BleProtocolService) {
init(protocolService: BleProtocolService, timeProvider: BeijingTimeProvider) {
self.protocolService = protocolService
self.timeProvider = timeProvider
}
// MARK: - Commands
@@ -24,30 +26,66 @@ final class DeviceInfoService {
)
}
func bindDevice(userId: String) throws {
try protocolService.sendJSON(
type: .bindDevice,
payload: BindPayload(type: CommandType.bindDevice.rawValue, userId: userId, loop: nil)
)
/// RN
func bindDevice(userId: String, completion: @escaping (Result<Void, Error>) -> Void) {
timeProvider.fetch { [weak self] time in
guard let self = self else { return }
do {
try self.protocolService.sendJSON(
type: .bindDevice,
payload: BindPayload(
type: CommandType.bindDevice.rawValue,
userId: userId,
loop: nil,
time: time
)
)
completion(.success(()))
} catch {
completion(.failure(error))
}
}
}
func unbindDevice(userId: String) throws {
try protocolService.sendJSON(
type: .unbindDevice,
payload: BindPayload(type: CommandType.unbindDevice.rawValue, userId: userId, loop: nil)
)
func unbindDevice(userId: String, completion: @escaping (Result<Void, Error>) -> Void) {
timeProvider.fetch { [weak self] time in
guard let self = self else { return }
do {
try self.protocolService.sendJSON(
type: .unbindDevice,
payload: BindPayload(
type: CommandType.unbindDevice.rawValue,
userId: userId,
loop: nil,
time: time
)
)
completion(.success(()))
} catch {
completion(.failure(error))
}
}
}
/// BIND_DEVICE 0x0Fpayload loop
func setPlayMode(userId: String, loop: PlayMode) throws {
try protocolService.sendJSON(
type: .bindDevice,
payload: BindPayload(
type: CommandType.bindDevice.rawValue,
userId: userId,
loop: UInt8(loop.rawValue)
)
)
func setPlayMode(userId: String, loop: PlayMode, completion: @escaping (Result<Void, Error>) -> Void) {
timeProvider.fetch { [weak self] time in
guard let self = self else { return }
do {
try self.protocolService.sendJSON(
type: .bindDevice,
payload: BindPayload(
type: CommandType.bindDevice.rawValue,
userId: userId,
loop: UInt8(loop.rawValue),
time: time
)
)
completion(.success(()))
} catch {
completion(.failure(error))
}
}
}
func deleteFile(key: String) throws {
@@ -77,19 +115,22 @@ private struct CommandPayload: Encodable {
/// / / payload
/// `loop == nil` null Expo zod.optional
/// `time` ISO 8601
struct BindPayload: Encodable {
let type: UInt8
let userId: String
let loop: UInt8?
let time: String
enum CodingKeys: String, CodingKey {
case type, userId, loop
case type, userId, loop, time
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(type, forKey: .type)
try container.encode(userId, forKey: .userId)
try container.encode(time, forKey: .time)
if let loop = loop {
try container.encode(loop, forKey: .loop)
}