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

@@ -16,8 +16,21 @@ struct DemoApp: App {
}
/// ViewModel SDK callback API DuooomiBleSDKDelegate UI
class DemoViewModel: NSObject, ObservableObject, DuooomiBleSDKDelegate {
let sdk: DuooomiBleSDK
final class DemoViewModel: NSObject, ObservableObject, DuooomiBleSDKDelegate {
// MARK: - SDK Configdemo UserDefaults
@Published var configOwner: String {
didSet { defaults.set(configOwner, forKey: Self.keyOwner) }
}
@Published var configApiKey: String {
didSet { defaults.set(configApiKey, forKey: Self.keyApiKey) }
}
@Published var configScanPrefix: String {
didSet { defaults.set(configScanPrefix, forKey: Self.keyScanPrefix) }
}
// MARK: - SDK State (driven by delegate)
@Published var btState: ConnectionState = .idle
@Published var connectedDevice: DiscoveredDevice? = nil
@@ -27,11 +40,63 @@ class DemoViewModel: NSObject, ObservableObject, DuooomiBleSDKDelegate {
@Published var isActivated: Bool = false
@Published var transferProgress: Int = 0
@Published var error: String? = nil
@Published var upgradeRecords: [UpgradeRecord] = []
@Published private(set) var sdk: DuooomiBleSDK
// MARK: - Internals
private let defaults = UserDefaults.standard
private static let keyOwner = "demo.config.owner"
private static let keyApiKey = "demo.config.apiKey"
private static let keyScanPrefix = "demo.config.scanPrefix"
/// demo UserDefaults 使 DemoSecrets
private static let defaultOwner = DemoSecrets.owner
private static let defaultApiKey = DemoSecrets.apiKey
private static let defaultScanPrefix = DemoSecrets.scanNamePrefix
override init() {
sdk = DuooomiBleSDK(config: .init(apiKey: ""))
let d = UserDefaults.standard
let owner = d.string(forKey: Self.keyOwner) ?? Self.defaultOwner
let apiKey = d.string(forKey: Self.keyApiKey) ?? Self.defaultApiKey
let scanPrefix = d.string(forKey: Self.keyScanPrefix) ?? Self.defaultScanPrefix
self.configOwner = owner
self.configApiKey = apiKey
self.configScanPrefix = scanPrefix
self.sdk = DuooomiBleSDK(config: .init(
apiKey: apiKey,
owner: owner,
scanNamePrefix: scanPrefix
))
super.init()
sdk.delegate = self
// SDK records /
upgradeRecords = sdk.upgradeRecords
}
/// demo SDK delegate
func reloadSDK() {
let new = DuooomiBleSDK(config: .init(
apiKey: configApiKey,
owner: configOwner,
scanNamePrefix: configScanPrefix
))
new.delegate = self
self.sdk = new
//
btState = .idle
connectedDevice = nil
discoveredDevices = []
deviceInfo = nil
version = ""
isActivated = false
transferProgress = 0
error = nil
upgradeRecords = new.upgradeRecords
}
// MARK: - DuooomiBleSDKDelegate
@@ -64,6 +129,10 @@ class DemoViewModel: NSObject, ObservableObject, DuooomiBleSDKDelegate {
transferProgress = progress
}
func sdk(_ sdk: DuooomiBleSDK, didUpdateUpgradeRecords records: [UpgradeRecord]) {
upgradeRecords = records
}
func sdk(_ sdk: DuooomiBleSDK, didEncounterError error: String?) {
self.error = error
}

View File

@@ -0,0 +1,14 @@
// Demo
//
// clone
// cp demo/Sources/DemoSecrets.template.swift demo/Sources/DemoSecrets.swift
// DemoSecrets.swift apiKey / owner
//
// `demo/Sources/DemoSecrets.swift` .gitignore commit
import Foundation
enum DemoSecrets {
static let apiKey: String = ""
static let owner: String = "xmly"
static let scanNamePrefix: String = "Duooomi-"
}

View File

@@ -25,14 +25,13 @@ private enum CDNHelper {
struct WrapperTestView: View {
@ObservedObject var viewModel: DemoViewModel
@State private var userId = "test-user-001"
@State private var userId = "Cjgex9kTTTJq9u9RMOnc5hiIZTvrOOq3"
@State private var fileUrl = "https://cdn.bowong.cc/material/569f48a8e29f47859b3a9808be37f94c.mp4"
// Firmware
@State private var firmwareBrand = "duomi"
@State private var firmwareStatus = "DRAFT"
@State private var firmwareInfo: FirmwareInfo? = nil
@State private var firmwareLoading = false
@State private var firmwareError: String? = nil
@State private var otaRecordsTick = 0
// fetchLatestFirmware bind / version
@State private var otaFetchSn = ""
@State private var otaFetchVersion = ""
@State private var otaFetchUserId = ""
// State
@State private var isBusy = false
@State private var connectingDeviceId: String?
@@ -48,6 +47,7 @@ struct WrapperTestView: View {
var body: some View {
List {
configSection
statusSection
scanSection
deviceSection
@@ -60,13 +60,42 @@ struct WrapperTestView: View {
.scrollDismissesKeyboard(.interactively)
.onAppear {
CDNHelper.cdnHost = viewModel.sdk.config.cdnHost
firmwareBrand = viewModel.sdk.config.firmwareIdentifier
firmwareStatus = viewModel.sdk.config.firmwareStatus
}
}
// MARK: - Sections
private var configSection: some View {
Section("SDK Config修改后点 Reload 生效)") {
HStack {
Text("x-owner").frame(width: 90, alignment: .leading)
TextField("owner", text: $viewModel.configOwner)
.textFieldStyle(.roundedBorder)
.autocapitalization(.none)
.disableAutocorrection(true)
}
HStack {
Text("apiKey").frame(width: 90, alignment: .leading)
TextField("x-api-key", text: $viewModel.configApiKey)
.textFieldStyle(.roundedBorder)
.autocapitalization(.none)
.disableAutocorrection(true)
}
HStack {
Text("scanPrefix").frame(width: 90, alignment: .leading)
TextField("Duooomi-", text: $viewModel.configScanPrefix)
.textFieldStyle(.roundedBorder)
.autocapitalization(.none)
.disableAutocorrection(true)
}
Button("Reload SDK") {
viewModel.reloadSDK()
log("SDK reloaded with owner=\(viewModel.configOwner), prefix=\(viewModel.configScanPrefix)", level: .success)
}
.buttonStyle(.borderedProminent)
}
}
private var statusSection: some View {
Section("Status") {
LabeledContent("BLE", value: viewModel.btState.rawValue)
@@ -185,67 +214,120 @@ struct WrapperTestView: View {
}
}
private static let statusOptions = ["DRAFT", "PUBLISHED"]
private var firmwareSection: some View {
Section("Firmware Update") {
Section("Firmware OTA") {
LabeledContent("Current Version", value: viewModel.version.isEmpty ? "" : viewModel.version)
// fetchLatestFirmware bind/getVersion
HStack {
Text("Identifier")
TextField("duomi", text: $firmwareBrand)
Text("sn").frame(width: 110, alignment: .leading).font(.caption)
TextField("绑定后自动填", text: $otaFetchSn)
.textFieldStyle(.roundedBorder)
.autocapitalization(.none)
.disableAutocorrection(true)
}
HStack {
Text("currentVersion").frame(width: 110, alignment: .leading).font(.caption)
TextField("getVersion 后自动填", text: $otaFetchVersion)
.textFieldStyle(.roundedBorder)
.autocapitalization(.none)
.disableAutocorrection(true)
}
HStack {
Text("userId").frame(width: 110, alignment: .leading).font(.caption)
TextField("用户 ID", text: $otaFetchUserId)
.textFieldStyle(.roundedBorder)
.autocapitalization(.none)
.disableAutocorrection(true)
}
Picker("Status", selection: $firmwareStatus) {
ForEach(Self.statusOptions, id: \.self) { Text($0) }
}
.pickerStyle(.segmented)
if let info = firmwareInfo {
VStack(alignment: .leading, spacing: 6) {
LabeledContent("Latest", value: info.version)
LabeledContent("Has Update", value: viewModel.sdk.hasNewerFirmware(deviceVersion: viewModel.version, serverVersion: info.version) ? "Yes" : "No")
if let size = info.fileSize {
let bytes = Int(size) ?? 0
LabeledContent("Size", value: ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file))
}
if let md5 = info.fileMd5, !md5.isEmpty {
LabeledContent("MD5", value: md5).font(.caption2)
}
if let desc = info.description, !desc.isEmpty {
Text(desc).font(.caption).foregroundStyle(.secondary)
}
Text(info.fileUrl).font(.caption2).foregroundStyle(.secondary).lineLimit(2)
}
}
if let err = firmwareError {
Text(err).font(.caption).foregroundStyle(.red)
}
if firmwareLoading {
HStack {
ProgressView().controlSize(.small)
Text("Fetching...").font(.caption).foregroundStyle(.secondary)
}
}
HStack {
Button("Get Update Info") {
runFetchFirmwareInfo()
Button("回填当前状态") {
otaFetchSn = viewModel.sdk.sn
otaFetchVersion = viewModel.version
otaFetchUserId = userId
}
.buttonStyle(.bordered)
.disabled(firmwareLoading || firmwareBrand.isEmpty)
.controlSize(.small)
Button("Upgrade") {
runUpgradeFirmware()
}
.buttonStyle(.borderedProminent)
.disabled(firmwareInfo?.fileUrl.isEmpty != false || viewModel.connectedDevice == nil || isBusy || firmwareLoading)
Spacer()
Button("拉取安装包") { runCheckFirmwareUpgrade() }
.buttonStyle(.bordered)
.disabled(otaFetchSn.isEmpty || otaFetchUserId.isEmpty || isBusy)
Button("安装") { runInstallFirmwareUpgrade() }
.buttonStyle(.borderedProminent)
.disabled(viewModel.sdk.firmwareUpgrade?.targetFirmware?.fileUrl == nil || isBusy)
}
if let upgrade = viewModel.sdk.firmwareUpgrade {
if let target = upgrade.targetFirmware {
LabeledContent("target version", value: target.version).font(.caption)
LabeledContent("firmwareId", value: target.id).font(.caption2).foregroundStyle(.secondary)
if let force = target.forceUpgrade {
LabeledContent("forceUpgrade", value: force ? "" : "")
.font(.caption2)
.foregroundStyle(force ? Color.red : Color.secondary)
}
if let size = target.fileSize {
LabeledContent("fileSize", value: size).font(.caption2).foregroundStyle(.secondary)
}
if let md5 = target.fileMd5 {
LabeledContent("md5", value: md5).font(.caption2).foregroundStyle(.secondary)
}
if let url = target.fileUrl {
Text(url).font(.caption2).foregroundStyle(.secondary).lineLimit(2)
}
} else {
Text("无可用固件targetFirmware 为空)")
.font(.caption).foregroundStyle(.secondary)
}
}
// UpgradeRecords ViewModel @Published
let records = viewModel.upgradeRecords
if !records.isEmpty {
Text("Upgrade Records (\(records.count))").font(.caption).foregroundStyle(.secondary)
ForEach(records, id: \.firmwareId) { r in
VStack(alignment: .leading, spacing: 2) {
Text("\(r.sn) · \(r.firmwareId)").font(.caption)
Text("\(r.fromVersion)\(r.targetVersion)").font(.caption2).foregroundStyle(.secondary)
HStack {
// (reported, outcome) 4
switch (r.reported, r.outcome) {
case (false, _):
Text("待上报").font(.caption2).foregroundStyle(Color.orange)
case (true, .success):
Text("✅ 正确上报").font(.caption2).foregroundStyle(Color.green)
case (true, .versionMismatch):
Text("❌ 错误上报(版本不匹配)").font(.caption2).foregroundStyle(Color.red)
case (true, nil):
Text("过期放弃").font(.caption2).foregroundStyle(Color.gray)
}
}
}
.padding(.vertical, 2)
}
}
}
.id(otaRecordsTick)
.onAppear {
//
if otaFetchSn.isEmpty { otaFetchSn = viewModel.sdk.sn }
if otaFetchVersion.isEmpty { otaFetchVersion = viewModel.version }
if otaFetchUserId.isEmpty { otaFetchUserId = userId }
}
.onChange(of: viewModel.isActivated) { activated in
// bind sn / currentVersion bind
if activated {
otaFetchSn = viewModel.sdk.sn
otaFetchVersion = viewModel.version
}
}
.onChange(of: viewModel.version) { v in
// getVersion
if !v.isEmpty { otaFetchVersion = v }
}
}
@@ -461,47 +543,54 @@ struct WrapperTestView: View {
}
}
private func runFetchFirmwareInfo() {
let brand = firmwareBrand.trimmingCharacters(in: .whitespacesAndNewlines)
guard !brand.isEmpty else {
firmwareError = "No identifier specified"
return
}
firmwareLoading = true
firmwareError = nil
log("→ fetchLatestFirmware(\(brand), \(firmwareStatus))")
viewModel.sdk.fetchLatestFirmware(identifier: brand, status: firmwareStatus) { [self] result in
private func runCheckFirmwareUpgrade() {
isBusy = true
log("→ fetchLatestFirmware(sn=\(otaFetchSn), version=\(otaFetchVersion), userId=\(otaFetchUserId)) [report→softBind→check]")
viewModel.sdk.fetchLatestFirmware(
sn: otaFetchSn,
currentVersion: otaFetchVersion,
userId: otaFetchUserId
) { [self] result in
DispatchQueue.main.async {
self.firmwareLoading = false
self.isBusy = false
self.otaRecordsTick &+= 1
switch result {
case .success(let info):
self.firmwareInfo = info
if let info = info {
let hasUpdate = self.viewModel.sdk.hasNewerFirmware(deviceVersion: self.viewModel.version, serverVersion: info.version)
self.log("firmware latest: \(info.version), hasUpdate=\(hasUpdate)", level: .success)
} else {
self.log("no firmware available", level: .info)
}
case .success(let r):
let target = r.targetFirmware?.version ?? ""
self.log("fetchLatestFirmware ✓ target=\(target)", level: .success)
case .failure(let error):
self.firmwareError = error.localizedDescription
self.log("firmware info ✗ \(error.localizedDescription)", level: .error)
self.log("fetchLatestFirmware ✗ \(error.localizedDescription)", level: .error)
}
}
}
}
private func runUpgradeFirmware() {
guard let info = firmwareInfo, !info.fileUrl.isEmpty else { return }
private func runInstallFirmwareUpgrade() {
guard let upgrade = viewModel.sdk.firmwareUpgrade,
let target = upgrade.targetFirmware,
let fileUrl = target.fileUrl, !fileUrl.isEmpty else {
log("install ✗ 无可用固件包(请先点拉取安装包)", level: .error)
return
}
let sn = viewModel.sdk.sn
let fromVersion = viewModel.version
isBusy = true
log("→ upgradeFirmware \(info.version)")
viewModel.sdk.upgradeFirmware(fileUrl: info.fileUrl) { [self] result in
log("→ upgradeFirmware sn=\(sn) \(fromVersion)\(target.version) (fw=\(target.id))")
viewModel.sdk.upgradeFirmware(
sn: sn,
fileUrl: fileUrl,
firmwareId: target.id,
fromVersion: fromVersion,
targetVersion: target.version
) { [self] result in
DispatchQueue.main.async {
self.isBusy = false
self.otaRecordsTick &+= 1
switch result {
case .success:
self.log("OTA ✓", level: .success)
self.log("install ✓(已落 UpgradeRecord下次 fetchLatestFirmware 自动对账上报)", level: .success)
case .failure(let error):
self.log("OTA\(error.localizedDescription)", level: .error)
self.log("install\(error.localizedDescription)", level: .error)
}
}
}