Files
duooomi-ios-sdk/demo/Sources/WrapperTestView.swift
km2023 d5729a1a41 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>
2026-05-08 14:46:41 +08:00

599 lines
23 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import DuooomiBleSDK
// MARK: - CDN Helper
private enum CDNHelper {
static var cdnHost = "https://cdn.bowong.cc/"
static func ensureFullUrl(_ keyOrUrl: String) -> String {
if keyOrUrl.hasPrefix("http://") || keyOrUrl.hasPrefix("https://") {
return keyOrUrl
}
let key = keyOrUrl.hasPrefix("/") ? String(keyOrUrl.dropFirst()) : keyOrUrl
return cdnHost + key
}
static func isImage(_ url: String) -> Bool {
let ext = URL(string: url)?.pathExtension.lowercased() ?? ""
return ["jpg", "jpeg", "png", "webp", "gif"].contains(ext)
}
}
// MARK: - View
struct WrapperTestView: View {
@ObservedObject var viewModel: DemoViewModel
@State private var userId = "Cjgex9kTTTJq9u9RMOnc5hiIZTvrOOq3"
@State private var fileUrl = "https://cdn.bowong.cc/material/569f48a8e29f47859b3a9808be37f94c.mp4"
@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?
@State private var deviceFiles: [String] = []
@State private var logs: [LogLine] = []
struct LogLine: Identifiable {
let id = UUID()
let level: Level
let message: String
enum Level { case info, success, error }
}
var body: some View {
List {
configSection
statusSection
scanSection
deviceSection
transferSection
firmwareSection
deviceFilesSection
logSection
}
.navigationTitle("SDK Demo")
.scrollDismissesKeyboard(.interactively)
.onAppear {
CDNHelper.cdnHost = viewModel.sdk.config.cdnHost
}
}
// 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)
LabeledContent("Device", value: viewModel.connectedDevice?.name ?? "")
LabeledContent("Version", value: viewModel.version.isEmpty ? "" : viewModel.version)
if viewModel.transferProgress > 0 {
ProgressView(value: Double(viewModel.transferProgress), total: 100) {
Text("\(viewModel.transferProgress)%").font(.caption)
}
}
if let err = viewModel.error {
Text(err).foregroundStyle(.red).font(.caption)
}
}
}
private var scanSection: some View {
Section("Scan & Connect") {
HStack {
Button("Scan") {
viewModel.sdk.scan()
log("→ scan()")
}
.buttonStyle(.borderedProminent)
Button("Stop") {
viewModel.sdk.stopScan()
log("→ stopScan()")
}
.buttonStyle(.bordered)
}
ForEach(viewModel.discoveredDevices) { device in
HStack {
VStack(alignment: .leading) {
Text(device.name ?? "Unknown")
Text(device.id).font(.caption2).foregroundStyle(.secondary)
}
Spacer()
Text("\(device.rssi) dBm").font(.caption).foregroundStyle(.secondary)
if viewModel.connectedDevice?.id == device.id {
Button("Disconnect") {
runDisconnect()
}
.buttonStyle(.bordered)
.controlSize(.small)
.tint(.red)
} else if connectingDeviceId == device.id {
ProgressView().controlSize(.small)
} else {
Button("Connect") {
runConnect(deviceId: device.id)
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(connectingDeviceId != nil)
}
}
}
}
}
private var deviceSection: some View {
Section("Device") {
LabeledContent("Brand", value: viewModel.deviceInfo?.brand ?? "")
LabeledContent("Size", value: viewModel.deviceInfo?.size ?? "")
LabeledContent("Power", value: viewModel.deviceInfo.map { "\($0.powerlevel)%" } ?? "")
LabeledContent("Storage", value: viewModel.deviceInfo.map { "\($0.freespace)/\($0.allspace)" } ?? "")
HStack {
Button("getDeviceInfo") { runGetDeviceInfo() }
Button("getVersion") { runGetVersion() }
}
.buttonStyle(.bordered)
HStack {
TextField("userId", text: $userId)
.textFieldStyle(.roundedBorder)
Button("bind") { runBind() }
.buttonStyle(.bordered)
Button("unbind") { runUnbind() }
.buttonStyle(.bordered)
}
HStack {
Text("PlayMode")
.font(.caption)
Spacer()
if let mode = viewModel.deviceInfo?.loop {
Text(mode == .loop ? "循环" : "单播")
.font(.caption)
.foregroundColor(.secondary)
}
Button("Single") { runSetPlayMode(.single) }
.buttonStyle(.bordered)
Button("Loop") { runSetPlayMode(.loop) }
.buttonStyle(.bordered)
}
.disabled(!viewModel.isActivated || isBusy)
}
}
private var transferSection: some View {
Section("Transfer") {
TextField("File URL (mp4/jpg/png/ani)", text: $fileUrl)
.textFieldStyle(.roundedBorder)
.autocapitalization(.none)
.disableAutocorrection(true)
Button("Transfer") {
runTransfer()
}
.buttonStyle(.borderedProminent)
.disabled(fileUrl.isEmpty || viewModel.connectedDevice == nil || isBusy)
}
}
private var firmwareSection: some View {
Section("Firmware OTA") {
LabeledContent("Current Version", value: viewModel.version.isEmpty ? "" : viewModel.version)
// fetchLatestFirmware bind/getVersion
HStack {
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)
}
HStack {
Button("回填当前状态") {
otaFetchSn = viewModel.sdk.sn
otaFetchVersion = viewModel.version
otaFetchUserId = userId
}
.buttonStyle(.bordered)
.controlSize(.small)
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 }
}
}
private var deviceFilesSection: some View {
Section("Device Files (\(deviceFiles.count))") {
if deviceFiles.isEmpty {
Text("Bind 后显示设备文件")
.font(.caption)
.foregroundStyle(.secondary)
}
ForEach(deviceFiles, id: \.self) { rawKey in
let fileUrl = CDNHelper.ensureFullUrl(rawKey)
HStack {
if CDNHelper.isImage(fileUrl), let url = URL(string: fileUrl) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image.resizable().aspectRatio(contentMode: .fill)
case .failure:
Image(systemName: "photo").foregroundStyle(.secondary)
default:
ProgressView()
}
}
.frame(width: 50, height: 50)
.clipShape(RoundedRectangle(cornerRadius: 6))
} else {
Image(systemName: "film")
.frame(width: 50, height: 50)
.foregroundStyle(.secondary)
}
Text(rawKey)
.font(.caption2)
.lineLimit(2)
Spacer()
Button(role: .destructive) {
runDeleteFile(rawKey)
} label: {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
.disabled(isBusy)
}
}
}
}
private var logSection: some View {
Section("Log (\(logs.count))") {
ForEach(logs.prefix(80)) { line in
Text(line.message)
.font(.caption)
.foregroundStyle(line.level == .error ? .red : line.level == .success ? .green : .primary)
}
}
}
// MARK: - Logging
private func log(_ message: String, level: LogLine.Level = .info) {
logs.insert(LogLine(level: level, message: message), at: 0)
if logs.count > 200 { logs.removeLast() }
}
// MARK: - Actions (callback-based)
private func runConnect(deviceId: String) {
connectingDeviceId = deviceId
log("→ connect(\(deviceId))")
viewModel.sdk.connect(deviceId: deviceId) { [self] result in
DispatchQueue.main.async {
self.connectingDeviceId = nil
switch result {
case .success(let device):
self.log("connect ✓ \(device.name ?? device.id)", level: .success)
case .failure(let error):
self.log("connect ✗ \(error.localizedDescription)", level: .error)
}
}
}
}
private func runDisconnect() {
log("→ disconnect")
viewModel.sdk.disconnect { [self] result in
DispatchQueue.main.async {
switch result {
case .success:
self.deviceFiles = []
self.log("disconnect ✓", level: .success)
case .failure(let error):
self.log("disconnect ✗ \(error.localizedDescription)", level: .error)
}
}
}
}
private func runGetDeviceInfo() {
isBusy = true
log("→ getDeviceInfo")
viewModel.sdk.getDeviceInfo { [self] result in
DispatchQueue.main.async {
self.isBusy = false
switch result {
case .success(let info):
self.log("getDeviceInfo ✓ brand=\(info.brand) power=\(info.powerlevel)%", level: .success)
case .failure(let error):
self.log("getDeviceInfo ✗ \(error.localizedDescription)", level: .error)
}
}
}
}
private func runGetVersion() {
isBusy = true
log("→ getVersion")
viewModel.sdk.getVersion { [self] result in
DispatchQueue.main.async {
self.isBusy = false
switch result {
case .success(let info):
self.log("getVersion ✓ \(info.version)", level: .success)
case .failure(let error):
self.log("getVersion ✗ \(error.localizedDescription)", level: .error)
}
}
}
}
private func runBind() {
isBusy = true
log("→ bind(\(userId))")
viewModel.sdk.bind(userId: userId) { [self] result in
DispatchQueue.main.async {
self.isBusy = false
switch result {
case .success(let resp):
self.log("bind ✓ sn=\(resp.sn) contents=\(resp.contents.count)", level: .success)
self.deviceFiles = resp.contents
case .failure(let error):
self.log("bind ✗ \(error.localizedDescription)", level: .error)
}
}
}
}
private func runUnbind() {
isBusy = true
log("→ unbind(\(userId))")
viewModel.sdk.unbind(userId: userId) { [self] result in
DispatchQueue.main.async {
self.isBusy = false
switch result {
case .success:
self.deviceFiles = []
self.log("unbind ✓", level: .success)
case .failure(let error):
self.log("unbind ✗ \(error.localizedDescription)", level: .error)
}
}
}
}
private func runSetPlayMode(_ mode: PlayMode) {
isBusy = true
log("→ setPlayMode(\(mode == .loop ? "loop" : "single"), userId=\(userId))")
viewModel.sdk.setPlayMode(mode, userId: userId) { [self] result in
DispatchQueue.main.async {
self.isBusy = false
switch result {
case .success(let resp):
self.log("setPlayMode ✓ sn=\(resp.sn)", level: .success)
case .failure(let error):
self.log("setPlayMode ✗ \(error.localizedDescription)", level: .error)
}
}
}
}
private func runTransfer() {
isBusy = true
log("→ transferMedia(\(fileUrl))")
viewModel.sdk.transferMedia(fileUrl: fileUrl) { [self] result in
DispatchQueue.main.async {
self.isBusy = false
switch result {
case .success:
self.log("transfer ✓", level: .success)
case .failure(let error):
self.log("transfer ✗ \(error.localizedDescription)", level: .error)
}
}
}
}
private func runDeleteFile(_ rawKey: String) {
isBusy = true
log("→ deleteFile(\(rawKey))")
viewModel.sdk.deleteFile(key: rawKey) { [self] result in
DispatchQueue.main.async {
self.isBusy = false
switch result {
case .success:
self.log("deleteFile ✓", level: .success)
self.deviceFiles.removeAll { $0 == rawKey }
case .failure(let error):
self.log("deleteFile ✗ \(error.localizedDescription)", level: .error)
}
}
}
}
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.isBusy = false
self.otaRecordsTick &+= 1
switch result {
case .success(let r):
let target = r.targetFirmware?.version ?? ""
self.log("fetchLatestFirmware ✓ target=\(target)", level: .success)
case .failure(let error):
self.log("fetchLatestFirmware ✗ \(error.localizedDescription)", level: .error)
}
}
}
}
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 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("install ✓(已落 UpgradeRecord下次 fetchLatestFirmware 自动对账上报)", level: .success)
case .failure(let error):
self.log("install ✗ \(error.localizedDescription)", level: .error)
}
}
}
}
}