feat: Sdk 封装
This commit is contained in:
@@ -4,14 +4,14 @@ import DuooomiBleSDK
|
||||
// MARK: - CDN Helper
|
||||
|
||||
private enum CDNHelper {
|
||||
static let host = "https://cdn.bowong.cc/"
|
||||
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 host + key
|
||||
return cdnHost + key
|
||||
}
|
||||
|
||||
static func isImage(_ url: String) -> Bool {
|
||||
@@ -21,17 +21,28 @@ private enum CDNHelper {
|
||||
}
|
||||
|
||||
// MARK: - View
|
||||
//
|
||||
// Demo 演示 SDK 全部功能:
|
||||
// 1. Scan & Connect — sdk.scan() / sdk.connect(deviceId:) / sdk.disconnect()
|
||||
// 2. Device — sdk.getDeviceInfo() / sdk.getVersion() / sdk.bind(userId:) / sdk.unbind(userId:)
|
||||
// 3. Transfer — sdk.transferMedia(fileUrl:) 一步传输(内部自动 ANI 转换 + prepare + transfer)
|
||||
// 4. Firmware — sdk.fetchLatestFirmware() 查询 + sdk.transferFile(fileUri:commandType:.otaPackage) OTA
|
||||
// 5. Device Files — sdk.deleteFile(key:) 删除设备文件
|
||||
//
|
||||
// 可观察状态:btState / connectedDevice / deviceInfo / version / transferProgress / error
|
||||
|
||||
struct WrapperTestView: View {
|
||||
@EnvironmentObject var sdk: DuooomiBleSDK
|
||||
|
||||
@State private var userId = "test-user-001"
|
||||
@State private var videoUrl = "https://cdn.bowong.cc/material/569f48a8e29f47859b3a9808be37f94c.mp4"
|
||||
@State private var aniUrlDirect = "https://cdn.bowong.cc/material/c59affcbe0654bf8b69aaadc1900ac3f.ani"
|
||||
// Firmware update
|
||||
@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] = []
|
||||
@@ -39,7 +50,6 @@ struct WrapperTestView: View {
|
||||
|
||||
struct LogLine: Identifiable {
|
||||
let id = UUID()
|
||||
let time = Date()
|
||||
let level: Level
|
||||
let message: String
|
||||
enum Level { case info, success, error }
|
||||
@@ -50,19 +60,17 @@ struct WrapperTestView: View {
|
||||
statusSection
|
||||
scanSection
|
||||
deviceSection
|
||||
fileSection
|
||||
directAniSection
|
||||
transferSection
|
||||
firmwareSection
|
||||
deviceFilesSection
|
||||
logSection
|
||||
}
|
||||
.navigationTitle("Native SDK Test")
|
||||
.navigationTitle("SDK Demo")
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.onTapGesture {
|
||||
UIApplication.shared.sendAction(
|
||||
#selector(UIResponder.resignFirstResponder),
|
||||
to: nil, from: nil, for: nil
|
||||
)
|
||||
.onAppear {
|
||||
CDNHelper.cdnHost = sdk.config.cdnHost
|
||||
firmwareBrand = sdk.config.firmwareIdentifier
|
||||
firmwareStatus = sdk.config.firmwareStatus
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,9 +78,14 @@ struct WrapperTestView: View {
|
||||
|
||||
private var statusSection: some View {
|
||||
Section("Status") {
|
||||
LabeledContent("btState", value: sdk.btState.rawValue)
|
||||
LabeledContent("isActivated", value: sdk.isActivated ? "YES" : "NO")
|
||||
LabeledContent("busy", value: isBusy ? "YES" : "no")
|
||||
LabeledContent("BLE", value: sdk.btState.rawValue)
|
||||
LabeledContent("Device", value: sdk.connectedDevice?.name ?? "—")
|
||||
LabeledContent("Version", value: sdk.version.isEmpty ? "—" : sdk.version)
|
||||
if sdk.transferProgress > 0 {
|
||||
ProgressView(value: Double(sdk.transferProgress), total: 100) {
|
||||
Text("\(sdk.transferProgress)%").font(.caption)
|
||||
}
|
||||
}
|
||||
if let err = sdk.error {
|
||||
Text(err).foregroundStyle(.red).font(.caption)
|
||||
}
|
||||
@@ -80,7 +93,7 @@ struct WrapperTestView: View {
|
||||
}
|
||||
|
||||
private var scanSection: some View {
|
||||
Section("Scan") {
|
||||
Section("Scan & Connect") {
|
||||
HStack {
|
||||
Button("Scan") {
|
||||
sdk.scan()
|
||||
@@ -102,9 +115,7 @@ struct WrapperTestView: View {
|
||||
Text(device.id).font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text("\(device.rssi) dBm")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(device.rssi) dBm").font(.caption).foregroundStyle(.secondary)
|
||||
|
||||
if sdk.connectedDevice?.id == device.id {
|
||||
Button("Disconnect") {
|
||||
@@ -130,19 +141,10 @@ struct WrapperTestView: View {
|
||||
|
||||
private var deviceSection: some View {
|
||||
Section("Device") {
|
||||
LabeledContent("Name", value: sdk.connectedDevice?.name ?? "—")
|
||||
LabeledContent("ID", value: sdk.connectedDevice?.id ?? "—")
|
||||
LabeledContent("Brand", value: sdk.deviceInfo?.brand ?? "—")
|
||||
LabeledContent("Size", value: sdk.deviceInfo?.size ?? "—")
|
||||
LabeledContent(
|
||||
"Power",
|
||||
value: sdk.deviceInfo.map { "\($0.powerlevel)%" } ?? "—"
|
||||
)
|
||||
LabeledContent(
|
||||
"Storage",
|
||||
value: sdk.deviceInfo.map { "\($0.freespace)/\($0.allspace)" } ?? "—"
|
||||
)
|
||||
LabeledContent("Version", value: sdk.version.isEmpty ? "—" : sdk.version)
|
||||
LabeledContent("Power", value: sdk.deviceInfo.map { "\($0.powerlevel)%" } ?? "—")
|
||||
LabeledContent("Storage", value: sdk.deviceInfo.map { "\($0.freespace)/\($0.allspace)" } ?? "—")
|
||||
|
||||
HStack {
|
||||
Button("getDeviceInfo") { Task { await runGetDeviceInfo() } }
|
||||
@@ -161,54 +163,54 @@ struct WrapperTestView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var fileSection: some View {
|
||||
private var transferSection: some View {
|
||||
Section("Transfer") {
|
||||
if sdk.transferProgress > 0 {
|
||||
ProgressView(value: Double(sdk.transferProgress), total: 100) {
|
||||
Text("Transfer: \(sdk.transferProgress)%").font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
TextField("videoUrl (source)", text: $videoUrl)
|
||||
TextField("File URL (mp4/jpg/png/ani)", text: $fileUrl)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
|
||||
Button("Convert → PrepareTransfer → Transfer") {
|
||||
Task { await runConvertAndTransfer() }
|
||||
Button("Transfer") {
|
||||
Task { await runTransfer() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(videoUrl.isEmpty || sdk.connectedDevice == nil)
|
||||
.disabled(fileUrl.isEmpty || sdk.connectedDevice == nil || isBusy)
|
||||
}
|
||||
}
|
||||
|
||||
private var directAniSection: some View {
|
||||
Section("Direct ANI Transfer") {
|
||||
TextField("aniUrl (https://... .ani)", text: $aniUrlDirect)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
|
||||
Button("Prepare → Transfer ANI") {
|
||||
Task { await runTransferAniDirect() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(aniUrlDirect.isEmpty || sdk.connectedDevice == nil || isBusy)
|
||||
}
|
||||
}
|
||||
private static let statusOptions = ["DRAFT", "PUBLISHED"]
|
||||
|
||||
private var firmwareSection: some View {
|
||||
Section("Firmware Update") {
|
||||
LabeledContent("Current Version", value: sdk.version.isEmpty ? "—" : sdk.version)
|
||||
LabeledContent("Brand", value: sdk.deviceInfo?.brand ?? "—")
|
||||
|
||||
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) {
|
||||
Text("Latest: \(info.version)")
|
||||
Text(info.fileUrl).font(.caption2).foregroundStyle(.secondary)
|
||||
if let notes = info.notes, !notes.isEmpty {
|
||||
Text(notes).font(.caption)
|
||||
LabeledContent("Latest", value: info.version)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,18 +218,25 @@ struct WrapperTestView: View {
|
||||
Text(err).font(.caption).foregroundStyle(.red)
|
||||
}
|
||||
|
||||
if firmwareLoading {
|
||||
HStack {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Fetching...").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button(firmwareLoading ? "Fetching..." : "Get Update Info") {
|
||||
Button("Get Update Info") {
|
||||
Task { await runFetchFirmwareInfo() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(firmwareLoading || sdk.connectedDevice == nil || sdk.deviceInfo?.brand == nil)
|
||||
.disabled(firmwareLoading || firmwareBrand.isEmpty)
|
||||
|
||||
Button("Upgrade Firmware") {
|
||||
Button("Upgrade") {
|
||||
Task { await runUpgradeFirmware() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(firmwareInfo?.fileUrl.isEmpty != false || sdk.connectedDevice == nil || isBusy)
|
||||
.disabled(firmwareInfo?.fileUrl.isEmpty != false || sdk.connectedDevice == nil || isBusy || firmwareLoading)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -261,9 +270,9 @@ struct WrapperTestView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text(URL(string: fileUrl)?.lastPathComponent ?? fileUrl)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
Text(rawKey)
|
||||
.font(.caption2)
|
||||
.lineLimit(2)
|
||||
|
||||
Spacer()
|
||||
|
||||
@@ -284,19 +293,11 @@ struct WrapperTestView: View {
|
||||
ForEach(logs.prefix(80)) { line in
|
||||
Text(line.message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(color(for: line.level))
|
||||
.foregroundStyle(line.level == .error ? .red : line.level == .success ? .green : .primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func color(for level: LogLine.Level) -> Color {
|
||||
switch level {
|
||||
case .info: return .primary
|
||||
case .success: return .green
|
||||
case .error: return .red
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Logging
|
||||
|
||||
private func log(_ message: String, level: LogLine.Level = .info) {
|
||||
@@ -304,8 +305,10 @@ struct WrapperTestView: View {
|
||||
if logs.count > 200 { logs.removeLast() }
|
||||
}
|
||||
|
||||
// MARK: - Async runners
|
||||
// MARK: - Actions
|
||||
// 每个 run* 方法对应一个 SDK API 调用,展示输入/输出和错误处理
|
||||
|
||||
/// sdk.connect(deviceId:) → DiscoveredDevice
|
||||
private func runConnect(deviceId: String) async {
|
||||
connectingDeviceId = deviceId
|
||||
defer { connectingDeviceId = nil }
|
||||
@@ -318,6 +321,7 @@ struct WrapperTestView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// sdk.disconnect()
|
||||
private func runDisconnect() async {
|
||||
log("→ disconnect")
|
||||
do {
|
||||
@@ -329,21 +333,20 @@ struct WrapperTestView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// sdk.getDeviceInfo() → DeviceInfo
|
||||
private func runGetDeviceInfo() async {
|
||||
isBusy = true
|
||||
defer { isBusy = false }
|
||||
log("→ getDeviceInfo")
|
||||
do {
|
||||
let info = try await sdk.getDeviceInfo()
|
||||
log(
|
||||
"getDeviceInfo ✓ brand=\(info.brand) size=\(info.size) battery=\(info.powerlevel)",
|
||||
level: .success
|
||||
)
|
||||
log("getDeviceInfo ✓ brand=\(info.brand) power=\(info.powerlevel)%", level: .success)
|
||||
} catch {
|
||||
log("getDeviceInfo ✗ \(error.localizedDescription)", level: .error)
|
||||
}
|
||||
}
|
||||
|
||||
/// sdk.getVersion() → VersionInfo
|
||||
private func runGetVersion() async {
|
||||
isBusy = true
|
||||
defer { isBusy = false }
|
||||
@@ -356,6 +359,7 @@ struct WrapperTestView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// sdk.bind(userId:) → BindingResponse (sn + contents)。绑定后才能查看设备文件
|
||||
private func runBind() async {
|
||||
isBusy = true
|
||||
defer { isBusy = false }
|
||||
@@ -369,6 +373,7 @@ struct WrapperTestView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// sdk.unbind(userId:) → UnbindResponse。解绑后该账号的设备文件将被删除
|
||||
private func runUnbind() async {
|
||||
isBusy = true
|
||||
defer { isBusy = false }
|
||||
@@ -382,90 +387,20 @@ struct WrapperTestView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func runConvertAndTransfer() async {
|
||||
/// sdk.transferMedia(fileUrl:) — 一步完成:自动转换 ANI + prepare + transfer
|
||||
private func runTransfer() async {
|
||||
isBusy = true
|
||||
defer { isBusy = false }
|
||||
log("→ transferMedia(\(fileUrl))")
|
||||
do {
|
||||
// 1. Convert
|
||||
log("→ AniConverter.convert")
|
||||
let aniUrl = try await AniConverter.convert(videoUrl: videoUrl)
|
||||
log("ani url: \(aniUrl)", level: .success)
|
||||
|
||||
// 2. HEAD request to get ani file size
|
||||
log("→ HEAD \(aniUrl)")
|
||||
let aniSize = try await getFileSize(url: aniUrl)
|
||||
log("ani size: \(aniSize) bytes")
|
||||
|
||||
// 3. key = source video full URL
|
||||
let key = videoUrl
|
||||
log("key: \(key)")
|
||||
|
||||
// 4. prepareTransfer
|
||||
log("→ prepareTransfer")
|
||||
let prepareResult = try await sdk.prepareTransfer(key: key, size: aniSize)
|
||||
log("prepareTransfer → status=\(prepareResult.status)", level: .success)
|
||||
|
||||
guard prepareResult.status == "ready" else {
|
||||
log("device not ready: \(prepareResult.status)", level: .error)
|
||||
return
|
||||
}
|
||||
|
||||
// 5. transferFile
|
||||
log("→ transferFile")
|
||||
try await sdk.transferFile(fileUri: aniUrl)
|
||||
log("transferFile ✓", level: .success)
|
||||
try await sdk.transferMedia(fileUrl: fileUrl)
|
||||
log("transfer ✓", level: .success)
|
||||
} catch {
|
||||
log("transfer failed: \(error.localizedDescription)", level: .error)
|
||||
}
|
||||
}
|
||||
|
||||
private func runTransferAniDirect() async {
|
||||
isBusy = true
|
||||
defer { isBusy = false }
|
||||
do {
|
||||
let aniUrl = aniUrlDirect.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !aniUrl.isEmpty else { return }
|
||||
|
||||
// 1) 获取 ani 文件大小(优先 HEAD,失败则直读数据长度)
|
||||
log("→ HEAD \(aniUrl)")
|
||||
var aniSize: Int = 0
|
||||
do {
|
||||
aniSize = try await getFileSize(url: aniUrl)
|
||||
} catch {
|
||||
aniSize = 0
|
||||
}
|
||||
if aniSize <= 0, let url = URL(string: aniUrl) {
|
||||
log("HEAD 无法获取大小,回退为直接下载计算")
|
||||
let (data, response) = try await URLSession.shared.data(from: url)
|
||||
if let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) {
|
||||
aniSize = data.count
|
||||
} else {
|
||||
throw AniConverterError(message: "下载 ani 失败")
|
||||
}
|
||||
}
|
||||
log("ani size: \(aniSize) bytes")
|
||||
|
||||
// 2) 使用 aniUrl 作为 key 进行 prepare
|
||||
let key = aniUrl
|
||||
log("key: \(key)")
|
||||
log("→ prepareTransfer")
|
||||
let prepareResult = try await sdk.prepareTransfer(key: key, size: aniSize)
|
||||
log("prepareTransfer → status=\(prepareResult.status)", level: .success)
|
||||
|
||||
guard prepareResult.status == "ready" else {
|
||||
log("device not ready: \(prepareResult.status)", level: .error)
|
||||
return
|
||||
}
|
||||
|
||||
// 3) 传输 ANI 文件
|
||||
log("→ transferFile (ANI)")
|
||||
try await sdk.transferFile(fileUri: aniUrl, commandType: .transferAniVideo)
|
||||
log("transferFile ✓", level: .success)
|
||||
} catch {
|
||||
log("transfer failed: \(error.localizedDescription)", level: .error)
|
||||
log("transfer ✗ \(error.localizedDescription)", level: .error)
|
||||
}
|
||||
}
|
||||
|
||||
/// sdk.deleteFile(key:) → DeleteFileResponse
|
||||
private func runDeleteFile(_ rawKey: String) async {
|
||||
isBusy = true
|
||||
defer { isBusy = false }
|
||||
@@ -479,48 +414,42 @@ struct WrapperTestView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// sdk.fetchLatestFirmware(identifier:status:) → FirmwareInfo?
|
||||
private func runFetchFirmwareInfo() async {
|
||||
guard let brand = sdk.deviceInfo?.brand, !brand.isEmpty else {
|
||||
firmwareError = "No device brand"
|
||||
let brand = firmwareBrand.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !brand.isEmpty else {
|
||||
firmwareError = "No identifier specified"
|
||||
return
|
||||
}
|
||||
firmwareLoading = true
|
||||
firmwareError = nil
|
||||
defer { firmwareLoading = false }
|
||||
log("→ fetch firmware info for \(brand)")
|
||||
log("→ fetchLatestFirmware(\(brand), \(firmwareStatus))")
|
||||
do {
|
||||
let info = try await FirmwareUpdateService.fetchLatest(brand: brand)
|
||||
let info = try await sdk.fetchLatestFirmware(identifier: brand, status: firmwareStatus)
|
||||
firmwareInfo = info
|
||||
log("firmware latest: \(info.version)", level: .success)
|
||||
if let info {
|
||||
log("firmware latest: \(info.version)", level: .success)
|
||||
} else {
|
||||
log("no firmware available", level: .info)
|
||||
}
|
||||
} catch {
|
||||
firmwareError = error.localizedDescription
|
||||
log("firmware info ✗ \(error.localizedDescription)", level: .error)
|
||||
}
|
||||
}
|
||||
|
||||
/// 固件升级第二步:sdk.upgradeFirmware(fileUrl:)
|
||||
private func runUpgradeFirmware() async {
|
||||
guard let url = firmwareInfo?.fileUrl, !url.isEmpty else { return }
|
||||
guard let info = firmwareInfo, !info.fileUrl.isEmpty else { return }
|
||||
isBusy = true
|
||||
defer { isBusy = false }
|
||||
log("→ OTA transfer")
|
||||
log("→ upgradeFirmware \(info.version)")
|
||||
do {
|
||||
try await sdk.transferFile(fileUri: url, commandType: .otaPackage)
|
||||
try await sdk.upgradeFirmware(fileUrl: info.fileUrl)
|
||||
log("OTA ✓", level: .success)
|
||||
} catch {
|
||||
log("OTA ✗ \(error.localizedDescription)", level: .error)
|
||||
}
|
||||
}
|
||||
|
||||
private func getFileSize(url: String) async throws -> Int {
|
||||
guard let fileUrl = URL(string: url) else {
|
||||
throw AniConverterError(message: "Invalid URL: \(url)")
|
||||
}
|
||||
var request = URLRequest(url: fileUrl)
|
||||
request.httpMethod = "HEAD"
|
||||
let (_, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw AniConverterError(message: "Invalid response")
|
||||
}
|
||||
return Int(http.expectedContentLength)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user