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:
@@ -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 Config(demo 内可改,持久化到 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
|
||||
}
|
||||
|
||||
14
demo/Sources/DemoSecrets.template.swift
Normal file
14
demo/Sources/DemoSecrets.template.swift
Normal 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-"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user