- 所有公开 API 改为 completion handler (Result<T, Error>) - 状态通知改为 DuooomiBleSDKDelegate 协议 - 移除 Sendable、@MainActor、AsyncStream、CheckedContinuation - 网络请求改为 URLSession.dataTask 回调 - BLE 通信改为 delegate + 闭包模式 - deployment target 降至 iOS 12.2 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
478 lines
17 KiB
Swift
478 lines
17 KiB
Swift
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 = "test-user-001"
|
|
@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
|
|
@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 {
|
|
statusSection
|
|
scanSection
|
|
deviceSection
|
|
transferSection
|
|
firmwareSection
|
|
deviceFilesSection
|
|
logSection
|
|
}
|
|
.navigationTitle("SDK Demo")
|
|
.scrollDismissesKeyboard(.interactively)
|
|
.onAppear {
|
|
CDNHelper.cdnHost = viewModel.sdk.config.cdnHost
|
|
firmwareBrand = viewModel.sdk.config.firmwareIdentifier
|
|
firmwareStatus = viewModel.sdk.config.firmwareStatus
|
|
}
|
|
}
|
|
|
|
// MARK: - Sections
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 static let statusOptions = ["DRAFT", "PUBLISHED"]
|
|
|
|
private var firmwareSection: some View {
|
|
Section("Firmware Update") {
|
|
LabeledContent("Current Version", value: viewModel.version.isEmpty ? "—" : viewModel.version)
|
|
|
|
HStack {
|
|
Text("Identifier")
|
|
TextField("duomi", text: $firmwareBrand)
|
|
.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()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(firmwareLoading || firmwareBrand.isEmpty)
|
|
|
|
Button("Upgrade") {
|
|
runUpgradeFirmware()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(firmwareInfo?.fileUrl.isEmpty != false || viewModel.connectedDevice == nil || isBusy || firmwareLoading)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 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 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
|
|
DispatchQueue.main.async {
|
|
self.firmwareLoading = false
|
|
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 .failure(let error):
|
|
self.firmwareError = error.localizedDescription
|
|
self.log("firmware info ✗ \(error.localizedDescription)", level: .error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func runUpgradeFirmware() {
|
|
guard let info = firmwareInfo, !info.fileUrl.isEmpty else { return }
|
|
isBusy = true
|
|
log("→ upgradeFirmware \(info.version)")
|
|
viewModel.sdk.upgradeFirmware(fileUrl: info.fileUrl) { [self] result in
|
|
DispatchQueue.main.async {
|
|
self.isBusy = false
|
|
switch result {
|
|
case .success:
|
|
self.log("OTA ✓", level: .success)
|
|
case .failure(let error):
|
|
self.log("OTA ✗ \(error.localizedDescription)", level: .error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|