feat: Sdk 封装
This commit is contained in:
29
Sources/DuooomiBleSDK/DuooomiBleConfig.swift
Normal file
29
Sources/DuooomiBleSDK/DuooomiBleConfig.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
// prepareTransfer(key 用原始文件地址)
|
||||
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(
|
||||
|
||||
78
Sources/DuooomiBleSDK/Services/AniConverter.swift
Normal file
78
Sources/DuooomiBleSDK/Services/AniConverter.swift
Normal 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
|
||||
}
|
||||
}
|
||||
60
Sources/DuooomiBleSDK/Services/FirmwareService.swift
Normal file
60
Sources/DuooomiBleSDK/Services/FirmwareService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user