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

@@ -52,31 +52,35 @@ final class DeviceInfoLoopTests: XCTestCase {
}
final class BindPayloadEncodingTests: XCTestCase {
private static let fixedTime = "2026-05-07T16:58:00+08:00"
private func encode(_ payload: BindPayload) throws -> [String: Any] {
let data = try JSONEncoder().encode(payload)
return try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
}
func testBindPayloadWithoutLoopOmitsKey() throws {
let payload = BindPayload(type: 0x0F, userId: "u1", loop: nil)
let payload = BindPayload(type: 0x0F, userId: "u1", loop: nil, time: Self.fixedTime)
let dict = try encode(payload)
XCTAssertEqual(dict["type"] as? Int, 0x0F)
XCTAssertEqual(dict["userId"] as? String, "u1")
XCTAssertEqual(dict["time"] as? String, Self.fixedTime)
XCTAssertNil(dict["loop"], "loop key MUST be omitted when nil (not encoded as null)")
XCTAssertEqual(dict.count, 2)
XCTAssertEqual(dict.count, 3)
}
func testBindPayloadWithLoopIncludesValue() throws {
let payload = BindPayload(type: 0x0F, userId: "u1", loop: 1)
let payload = BindPayload(type: 0x0F, userId: "u1", loop: 1, time: Self.fixedTime)
let dict = try encode(payload)
XCTAssertEqual(dict["type"] as? Int, 0x0F)
XCTAssertEqual(dict["userId"] as? String, "u1")
XCTAssertEqual(dict["loop"] as? Int, 1)
XCTAssertEqual(dict.count, 3)
XCTAssertEqual(dict["time"] as? String, Self.fixedTime)
XCTAssertEqual(dict.count, 4)
}
func testBindPayloadWithLoopZeroIncludesValue() throws {
let payload = BindPayload(type: 0x0F, userId: "u1", loop: 0)
let payload = BindPayload(type: 0x0F, userId: "u1", loop: 0, time: Self.fixedTime)
let dict = try encode(payload)
XCTAssertEqual(dict["loop"] as? Int, 0)
}
@@ -84,7 +88,7 @@ final class BindPayloadEncodingTests: XCTestCase {
final class SetPlayModeGuardTests: XCTestCase {
private func makeSDK() -> DuooomiBleSDK {
DuooomiBleSDK(config: DuooomiBleConfig(apiKey: "test"))
DuooomiBleSDK(config: DuooomiBleConfig(apiKey: "test", owner: "test-owner", scanNamePrefix: "Duooomi-"))
}
func testSetPlayModeFailsWhenNotConnected() {
@@ -105,45 +109,50 @@ final class SetPlayModeGuardTests: XCTestCase {
}
final class BindPayloadByteRegressionTests: XCTestCase {
private static let fixedTime = "2026-05-07T16:58:00+08:00"
/// bind / unbind loop=nil "loop" key "loop":null
/// JSONEncoder key Swift substring + dict
func testBindPayloadNilLoopOmitsKeyAndNull() throws {
let payload = BindPayload(type: 0x0F, userId: "abc", loop: nil)
let payload = BindPayload(type: 0x0F, userId: "abc", loop: nil, time: Self.fixedTime)
let data = try JSONEncoder().encode(payload)
let json = String(data: data, encoding: .utf8) ?? ""
// JSON "loop" "null"
XCTAssertFalse(json.contains("loop"), "loop key MUST be absent; got \(json)")
XCTAssertFalse(json.contains("null"), "null literal MUST be absent; got \(json)")
// type / userId key
// type / userId / time key
let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
XCTAssertEqual(dict.count, 2)
XCTAssertEqual(dict.count, 3)
XCTAssertEqual(dict["type"] as? Int, 15)
XCTAssertEqual(dict["userId"] as? String, "abc")
XCTAssertEqual(dict["time"] as? String, Self.fixedTime)
XCTAssertNil(dict["loop"])
}
/// setPlayMode loop=1 "1"
func testSetPlayModePayloadIncludesLoopOne() throws {
let payload = BindPayload(type: 0x0F, userId: "abc", loop: 1)
let payload = BindPayload(type: 0x0F, userId: "abc", loop: 1, time: Self.fixedTime)
let data = try JSONEncoder().encode(payload)
let json = String(data: data, encoding: .utf8) ?? ""
// "loop":1
XCTAssertTrue(json.contains(#""loop":1"#), "expected literal substring \"loop\":1 in \(json)")
//
let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
XCTAssertEqual(dict.count, 3)
XCTAssertEqual(dict.count, 4)
XCTAssertEqual(dict["type"] as? Int, 15)
XCTAssertEqual(dict["userId"] as? String, "abc")
XCTAssertEqual(dict["loop"] as? Int, 1)
XCTAssertEqual(dict["time"] as? String, Self.fixedTime)
}
/// setPlayMode loop=0 0 "default"
func testSetPlayModePayloadIncludesLoopZero() throws {
let payload = BindPayload(type: 0x0F, userId: "abc", loop: 0)
let payload = BindPayload(type: 0x0F, userId: "abc", loop: 0, time: Self.fixedTime)
let data = try JSONEncoder().encode(payload)
let json = String(data: data, encoding: .utf8) ?? ""
XCTAssertTrue(json.contains(#""loop":0"#), "expected literal substring \"loop\":0 in \(json)")
let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
XCTAssertEqual(dict["loop"] as? Int, 0)
XCTAssertEqual(dict["time"] as? String, Self.fixedTime)
}
}

View File

@@ -0,0 +1,168 @@
import XCTest
@testable import DuooomiBleSDK
/// RN `bleStore.upgradeRecords`
final class UpgradeRecordStoreTests: XCTestCase {
private var defaults: UserDefaults!
private let suiteName = "duooomi.test.upgradeRecord"
override func setUp() {
super.setUp()
defaults = UserDefaults(suiteName: suiteName)
defaults.removePersistentDomain(forName: suiteName)
}
override func tearDown() {
defaults.removePersistentDomain(forName: suiteName)
defaults = nil
super.tearDown()
}
private func makeStore() -> UpgradeRecordStore {
UpgradeRecordStore(defaults: defaults)
}
private func sample(sn: String = "A123456", firmwareId: String = "fw-1", triggeredAt: Int64? = nil) -> UpgradeRecord {
UpgradeRecord(
sn: sn,
firmwareId: firmwareId,
fromVersion: "1.0.0",
targetVersion: "1.1.0",
triggeredAt: triggeredAt ?? UpgradeRecordStore.nowMillis()
)
}
private func wait(_ s: TimeInterval = 0.1) {
let exp = expectation(description: "delay")
DispatchQueue.main.asyncAfter(deadline: .now() + s) { exp.fulfill() }
wait(for: [exp], timeout: s + 1)
}
// MARK: - upsert
func testUpsertAddsNewRecord() {
let store = makeStore()
store.upsert(sample())
wait()
XCTAssertEqual(store.all.count, 1)
XCTAssertEqual(store.all.first?.firmwareId, "fw-1")
}
func testUpsertSameKeyReplacesAndResetsReportedFlag() {
let store = makeStore()
let r1 = UpgradeRecord(
sn: "A1", firmwareId: "fw-1",
fromVersion: "1.0.0", targetVersion: "1.1.0",
triggeredAt: 1000, reported: true, reportedAt: 2000, outcome: .success
)
store.upsert(r1)
wait()
// (sn, firmwareId) record reported=false
let r2 = UpgradeRecord(
sn: "A1", firmwareId: "fw-1",
fromVersion: "1.0.0", targetVersion: "1.2.0",
triggeredAt: 5000
)
store.upsert(r2)
wait()
XCTAssertEqual(store.all.count, 1)
let only = store.all[0]
XCTAssertEqual(only.targetVersion, "1.2.0")
XCTAssertFalse(only.reported)
XCTAssertNil(only.reportedAt)
XCTAssertNil(only.outcome)
}
func testUpsertDifferentFirmwareIdAppends() {
let store = makeStore()
store.upsert(sample(firmwareId: "fw-1"))
store.upsert(sample(firmwareId: "fw-2"))
wait()
XCTAssertEqual(store.all.count, 2)
}
func testUpsertEmptySnIgnored() {
let store = makeStore()
store.upsert(sample(sn: ""))
wait()
XCTAssertEqual(store.all.count, 0)
}
// MARK: - pending
func testPendingExcludesReportedAndSortsAsc() {
let store = makeStore()
store.upsert(UpgradeRecord(sn: "A", firmwareId: "fw-old", fromVersion: "1.0", targetVersion: "1.1", triggeredAt: 100))
store.upsert(UpgradeRecord(sn: "A", firmwareId: "fw-new", fromVersion: "1.0", targetVersion: "1.2", triggeredAt: 300))
store.upsert(UpgradeRecord(sn: "A", firmwareId: "fw-mid", fromVersion: "1.0", targetVersion: "1.15", triggeredAt: 200))
store.upsert(UpgradeRecord(sn: "B", firmwareId: "fw-other", fromVersion: "1.0", targetVersion: "1.1", triggeredAt: 50))
wait()
let pending = store.pending(sn: "A")
XCTAssertEqual(pending.map { $0.firmwareId }, ["fw-old", "fw-mid", "fw-new"])
store.markReported(sn: "A", firmwareId: "fw-old", outcome: .success)
wait()
XCTAssertEqual(store.pending(sn: "A").map { $0.firmwareId }, ["fw-mid", "fw-new"])
}
// MARK: - markReported
func testMarkReportedSetsFlagAndOutcome() {
let store = makeStore()
store.upsert(sample())
wait()
store.markReported(sn: "A123456", firmwareId: "fw-1", outcome: .versionMismatch)
wait()
let r = store.all[0]
XCTAssertTrue(r.reported)
XCTAssertNotNil(r.reportedAt)
XCTAssertEqual(r.outcome, .versionMismatch)
}
func testMarkReportedTtlExpiredOutcomeNil() {
// RN TTL markReported(null)
let store = makeStore()
store.upsert(sample())
wait()
store.markReported(sn: "A123456", firmwareId: "fw-1", outcome: nil)
wait()
let r = store.all[0]
XCTAssertTrue(r.reported)
XCTAssertNil(r.outcome)
}
// MARK: - sn
func testTryAcquireReturnsFalseWhileHeld() {
let store = makeStore()
XCTAssertTrue(store.tryAcquire(sn: "A1"))
XCTAssertFalse(store.tryAcquire(sn: "A1"))
// sn
XCTAssertTrue(store.tryAcquire(sn: "B1"))
}
func testReleaseAllowsReacquire() {
let store = makeStore()
XCTAssertTrue(store.tryAcquire(sn: "A1"))
store.release(sn: "A1")
wait()
XCTAssertTrue(store.tryAcquire(sn: "A1"))
}
// MARK: - round-trip
func testRecordsPersistAcrossInstances() {
let store1 = makeStore()
store1.upsert(sample())
wait()
let store2 = UpgradeRecordStore(defaults: defaults)
XCTAssertEqual(store2.all.count, 1)
XCTAssertEqual(store2.all[0].firmwareId, "fw-1")
}
}