feat: Sdk 封装

This commit is contained in:
km2023
2026-04-10 16:15:53 +08:00
parent 7d9ff3081d
commit ba0a80e921
10 changed files with 431 additions and 486 deletions

View File

@@ -0,0 +1,29 @@
import Foundation
/// SDK
public struct DuooomiBleConfig: Sendable {
/// API
public let apiHost: URL
/// API key
public let apiKey: String
/// CDN `/`
public let cdnHost: String
/// "duomi"
public let firmwareIdentifier: String
/// "DRAFT" / "PUBLISHED"
public let firmwareStatus: String
public init(
apiHost: URL = URL(string: "https://api.mixvideo.bowong.cc")!,
apiKey: String,
cdnHost: String = "https://cdn.bowong.cc/",
firmwareIdentifier: String = "duomi",
firmwareStatus: String = "DRAFT"
) {
self.apiHost = apiHost
self.apiKey = apiKey
self.cdnHost = cdnHost.hasSuffix("/") ? cdnHost : cdnHost + "/"
self.firmwareIdentifier = firmwareIdentifier
self.firmwareStatus = firmwareStatus
}
}

View File

@@ -20,12 +20,18 @@ public final class DuooomiBleSDK: ObservableObject {
@Published public private(set) var error: String? = nil
@Published public private(set) var discoveredDevices: [DiscoveredDevice] = []
// MARK: - Configuration
public let config: DuooomiBleConfig
// MARK: - Internal Services
private let bleClient: BleClient
private let protocolService: BleProtocolService
private let deviceInfoService: DeviceInfoService
private let fileTransferService: FileTransferService
private let aniConverter: AniConverter
private let firmwareService: FirmwareService
// MARK: - Request-Response
@@ -41,13 +47,16 @@ public final class DuooomiBleSDK: ObservableObject {
// MARK: - Init
public init() {
public init(config: DuooomiBleConfig) {
self.config = config
bleClient = BleClient()
protocolService = BleProtocolService(client: bleClient)
deviceInfoService = DeviceInfoService(protocolService: protocolService)
fileTransferService = FileTransferService(protocolService: protocolService)
aniConverter = AniConverter(config: config)
firmwareService = FirmwareService(config: config)
BleLog.i("SDK initialized", "SDK")
BleLog.i("SDK initialized (apiHost=\(config.apiHost), firmware=\(config.firmwareIdentifier)/\(config.firmwareStatus))", "SDK")
setupDisconnectHandler()
}
@@ -327,6 +336,97 @@ public final class DuooomiBleSDK: ObservableObject {
BleLog.i("Transfer completed", "Transfer")
}
// MARK: - High-Level APIs
///
///
/// / ANI prepareTransfer transferFile
/// - Parameter fileUrl: URL mp4/jpg/png ANI.ani
/// - Throws:
public func transferMedia(fileUrl: String) async throws {
try ensureConnected()
let url = fileUrl.trimmingCharacters(in: .whitespacesAndNewlines)
guard !url.isEmpty else { throw DuooomiBleError.transferFailed("Empty file URL") }
let isAni = url.lowercased().hasSuffix(".ani")
let aniUrl: String
if isAni {
aniUrl = url
BleLog.i("Direct ANI transfer: \(url)", "Transfer")
} else {
BleLog.i("Converting to ANI: \(url)", "Transfer")
do {
aniUrl = try await aniConverter.convert(fileUrl: url)
} catch {
throw DuooomiBleError.transferFailed("ANI conversion failed: \(error.localizedDescription)")
}
BleLog.i("ANI ready: \(aniUrl)", "Transfer")
}
//
let aniSize = try await getRemoteFileSize(url: aniUrl)
BleLog.d("ANI size: \(aniSize) bytes", "Transfer")
// prepareTransferkey
let key = url
_ = try await prepareTransfer(key: key, size: aniSize)
//
try await transferFile(fileUri: aniUrl, commandType: .transferAniVideo)
}
///
/// - Parameters:
/// - identifier: 使 config firmwareIdentifier
/// - status: 使 config firmwareStatus
/// - Returns: nil
public func fetchLatestFirmware(identifier: String? = nil, status: String? = nil) async throws -> FirmwareInfo? {
return try await firmwareService.fetchLatest(identifier: identifier, status: status)
}
/// OTA
/// - Parameter fileUrl: `fetchLatestFirmware()`
/// - Throws:
public func upgradeFirmware(fileUrl: String) async throws {
try ensureConnected()
BleLog.i("OTA upgrade start: \(fileUrl)", "Firmware")
try await transferFile(fileUri: fileUrl, commandType: .otaPackage)
BleLog.i("OTA upgrade completed", "Firmware")
}
// MARK: - Internal Helpers
private func getRemoteFileSize(url: String) async throws -> Int {
guard let fileUrl = URL(string: url) else {
throw DuooomiBleError.transferFailed("Invalid URL: \(url)")
}
var request = URLRequest(url: fileUrl)
request.httpMethod = "HEAD"
let (_, response) = try await URLSession.shared.data(for: request)
if let http = response as? HTTPURLResponse, http.expectedContentLength > 0 {
return Int(http.expectedContentLength)
}
// HEAD Content-Length
if let http = response as? HTTPURLResponse,
let lengthStr = http.value(forHTTPHeaderField: "Content-Length"),
let length = Int(lengthStr), length > 0 {
return length
}
// 退Range
var rangeReq = URLRequest(url: fileUrl)
rangeReq.httpMethod = "GET"
rangeReq.setValue("bytes=0-0", forHTTPHeaderField: "Range")
let (_, rangeResp) = try await URLSession.shared.data(for: rangeReq)
if let http = rangeResp as? HTTPURLResponse,
let rangeHeader = http.value(forHTTPHeaderField: "Content-Range"),
let totalStr = rangeHeader.split(separator: "/").last,
let total = Int(totalStr), total > 0 {
return total
}
throw DuooomiBleError.transferFailed("Cannot determine file size: \(url)")
}
// MARK: - Request-Response Pattern
private func sendAndWait(

View File

@@ -0,0 +1,78 @@
import Foundation
struct AniConverterError: LocalizedError {
let message: String
var errorDescription: String? { message }
}
/// / ANI
final class AniConverter {
private let config: DuooomiBleConfig
private var endpoint: URL {
config.apiHost.appendingPathComponent("api/auth/loomart/file/convert-to-ani")
}
private let session: URLSession = {
let cfg = URLSessionConfiguration.default
cfg.timeoutIntervalForRequest = 120
cfg.timeoutIntervalForResource = 180
cfg.waitsForConnectivity = true
return URLSession(configuration: cfg)
}()
init(config: DuooomiBleConfig) {
self.config = config
}
/// ANI ani CDN URL
func convert(fileUrl: String) async throws -> String {
do {
return try await performConvert(fileUrl: fileUrl)
} catch let error as NSError where error.code == NSURLErrorNetworkConnectionLost {
return try await performConvert(fileUrl: fileUrl)
}
}
private func performConvert(fileUrl: String) async throws -> String {
var request = URLRequest(url: endpoint)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "content-type")
request.setValue(config.apiKey, forHTTPHeaderField: "x-api-key")
request.timeoutInterval = 120
let body: [String: Any] = [
"videoUrl": fileUrl,
"width": 360,
"height": 360,
"fps": "24",
]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: request)
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
throw AniConverterError(message: "HTTP \(http.statusCode)")
}
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw AniConverterError(message: "Invalid JSON response")
}
guard (json["success"] as? Bool) == true,
let outer = json["data"] as? [String: Any] else {
throw AniConverterError(message: "ANI API reported failure")
}
if (outer["status"] as? Bool) != true {
let msg = (outer["msg"] as? String) ?? "ANI conversion failed"
throw AniConverterError(message: msg)
}
guard let aniUrl = outer["data"] as? String, !aniUrl.isEmpty else {
throw AniConverterError(message: "Missing ani url in response")
}
return aniUrl
}
}

View File

@@ -0,0 +1,60 @@
import Foundation
///
public struct FirmwareInfo: Codable, Equatable, Sendable {
public let version: String
public let fileUrl: String
public let description: String?
public let fileSize: String?
public let fileMd5: String?
public let identifier: String?
public let status: String?
}
struct FirmwareResponse: Codable {
let success: Bool
let data: FirmwareInfo?
}
///
final class FirmwareService {
private let config: DuooomiBleConfig
private let latestPath = "api/auth/loomart/firmware/latest-published"
init(config: DuooomiBleConfig) {
self.config = config
}
func fetchLatest(identifier: String? = nil, status: String? = nil) async throws -> FirmwareInfo? {
let id = (identifier ?? config.firmwareIdentifier).trimmingCharacters(in: .whitespacesAndNewlines)
let st = status ?? config.firmwareStatus
guard !id.isEmpty else {
throw DuooomiBleError.transferFailed("Invalid firmware identifier")
}
var comps = URLComponents(url: config.apiHost.appendingPathComponent(latestPath), resolvingAgainstBaseURL: false)!
comps.queryItems = [
URLQueryItem(name: "identifier", value: id),
URLQueryItem(name: "status", value: st)
]
guard let url = comps.url else {
throw DuooomiBleError.transferFailed("Invalid firmware URL")
}
var req = URLRequest(url: url)
req.httpMethod = "GET"
req.setValue(config.apiKey, forHTTPHeaderField: "x-api-key")
req.setValue("application/json", forHTTPHeaderField: "accept")
let (data, resp) = try await URLSession.shared.data(for: req)
guard let http = resp as? HTTPURLResponse else {
throw DuooomiBleError.transferFailed("Invalid response")
}
guard (200...299).contains(http.statusCode) else {
throw DuooomiBleError.transferFailed("HTTP \(http.statusCode)")
}
let decoded = try JSONDecoder().decode(FirmwareResponse.self, from: data)
return decoded.data
}
}