Files
duooomi-ios-sdk/Sources/DuooomiBleSDK/DuooomiBleSDK.swift
2026-04-10 16:15:53 +08:00

585 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 Combine
import CoreBluetooth
import Foundation
/// Duooomi BLE SDK - iOS SDK
///
///
///
@MainActor
public final class DuooomiBleSDK: ObservableObject {
// MARK: - Observable State
@Published public private(set) var btState: ConnectionState = .idle
@Published public private(set) var connectedDevice: DiscoveredDevice? = nil
@Published public private(set) var deviceInfo: DeviceInfo? = nil
@Published public private(set) var version: String = ""
@Published public private(set) var isActivated: Bool = false
@Published public private(set) var transferProgress: Int = 0
@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
private var pendingContinuations: [String: CheckedContinuation<Data, Error>] = [:]
private var messageListenerTask: Task<Void, Never>?
// MARK: - Scan Batching
private var scanTask: Task<Void, Never>?
private var pendingDevices: [DiscoveredDevice] = []
private var allDiscoveredDevices: [DiscoveredDevice] = []
private var flushWorkItem: DispatchWorkItem?
// MARK: - 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 (apiHost=\(config.apiHost), firmware=\(config.firmwareIdentifier)/\(config.firmwareStatus))", "SDK")
setupDisconnectHandler()
}
private func setupDisconnectHandler() {
bleClient.onDisconnected = { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
BleLog.w("Peripheral disconnected; resetting state", "SDK")
self.connectedDevice = nil
self.deviceInfo = nil
self.version = ""
self.isActivated = false
self.btState = .disconnected
self.protocolService.stopListening()
self.cancelAllPending(error: DuooomiBleError.notConnected)
}
}
}
// MARK: - Scanning
///
/// - Note: 使 500ms UI `stopScan()`
public func scan() {
stopScan()
btState = .scanning
discoveredDevices = []
allDiscoveredDevices = []
pendingDevices = []
BleLog.i("Start scanning...", "Scan")
let stream = bleClient.scan()
scanTask = Task { [weak self] in
for await device in stream {
guard let self, !Task.isCancelled else { break }
BleLog.d("Discovered: id=\(device.id), name=\(device.name ?? "-"), rssi=\(device.rssi)", "Scan")
await self.queueDevice(device)
}
}
}
///
/// - Note: `btState` `.connected`
public func stopScan() {
scanTask?.cancel()
scanTask = nil
bleClient.stopScan()
flushDevices()
if connectedDevice != nil {
btState = .connected
} else if btState == .scanning {
btState = .idle
}
BleLog.i("Stop scanning (state=\(btState.rawValue))", "Scan")
}
// MARK: - Connection
///
/// - Parameter deviceId: `DiscoveredDevice.id`CBPeripheral UUID
/// - Returns:
/// - Throws: `DuooomiBleError.connectionFailed`
public func connect(deviceId: String) async throws -> DiscoveredDevice {
stopScan()
btState = .connecting
BleLog.i("Connecting to \(deviceId)...", "Connect")
do {
let peripheral = try await bleClient.connect(deviceId: deviceId)
// Start protocol listener
protocolService.startListening()
startMessageListener()
let device = DiscoveredDevice(
id: peripheral.identifier.uuidString,
name: peripheral.name,
rssi: 0
)
connectedDevice = device
btState = .connected
BleLog.i("Connected: \(device.id)", "Connect")
return device
} catch {
btState = .idle
self.error = error.localizedDescription
BleLog.e("Connect failed: \(error.localizedDescription)", "Connect")
throw error
}
}
///
/// - Throws:
public func disconnect() async throws {
btState = .disconnecting
BleLog.i("Disconnect requested", "Connect")
protocolService.stopListening()
messageListenerTask?.cancel()
messageListenerTask = nil
cancelAllPending(error: DuooomiBleError.notConnected)
// Capture the underlying disconnect error so we can clean up state regardless,
// but still surface it to the caller. UI consumers may also see it via `error`.
var disconnectError: Error?
do {
try await bleClient.disconnect()
} catch {
disconnectError = error
self.error = error.localizedDescription
BleLog.e("Disconnect error: \(error.localizedDescription)", "Connect")
}
connectedDevice = nil
deviceInfo = nil
version = ""
isActivated = false
btState = .disconnected
BleLog.i("Disconnected", "Connect")
if let disconnectError {
throw disconnectError
}
}
/// SDK service SDK
/// SDK Service
/// - Returns:
public func getConnectedDevices() -> [DiscoveredDevice] {
let list = bleClient.retrieveConnectedDevices()
BleLog.d("System-connected devices: \(list.count)", "Connect")
return list
}
// MARK: - Device Commands
///
/// - Returns:
/// - Throws:
public func getDeviceInfo() async throws -> DeviceInfo {
try ensureConnected()
BleLog.d("Sending getDeviceInfo", "Command")
let data = try await sendAndWait(commandType: .getDeviceInfo) {
try await self.deviceInfoService.getDeviceInfo()
}
let info = try decodeResponse(DeviceInfo.self, from: data)
BleLog.i("DeviceInfo received: name=\(info.name), brand=\(info.brand)", "Command")
self.deviceInfo = info
return info
}
///
/// - Returns:
/// - Throws:
public func getVersion() async throws -> VersionInfo {
try ensureConnected()
BleLog.d("Sending getDeviceVersion", "Command")
let data = try await sendAndWait(commandType: .getDeviceVersion) {
try await self.deviceInfoService.getDeviceVersion()
}
let info = try decodeResponse(VersionInfo.self, from: data)
self.version = info.version
BleLog.i("Version received: \(info.version) (type=\(info.type))", "Command")
return info
}
///
/// - Parameter userId:
/// - Returns: SN
/// - Throws:
public func bind(userId: String) async throws -> BindingResponse {
try ensureConnected()
BleLog.d("Sending bind (userId=\(userId))", "Command")
let data = try await sendAndWait(commandType: .bindDevice) {
try await self.deviceInfoService.bindDevice(userId: userId)
}
let resp = try decodeResponse(BindingResponse.self, from: data)
isActivated = resp.success == 1
if resp.success != 1 {
BleLog.w("Bind failed: device already bound", "Command")
throw DuooomiBleError.bindingFailed("Device already bound to another user")
}
BleLog.i("Bind success: sn=\(resp.sn)", "Command")
return resp
}
///
/// - Parameter userId:
/// - Returns:
/// - Throws:
public func unbind(userId: String) async throws -> UnbindResponse {
try ensureConnected()
BleLog.d("Sending unbind (userId=\(userId))", "Command")
let data = try await sendAndWait(commandType: .unbindDevice) {
try await self.deviceInfoService.unbindDevice(userId: userId)
}
let resp = try decodeResponse(UnbindResponse.self, from: data)
if resp.success == 1 {
isActivated = false
BleLog.i("Unbind success", "Command")
} else {
BleLog.w("Unbind failed", "Command")
throw DuooomiBleError.unbindFailed
}
return resp
}
/// key
/// - Parameter key: key URL
/// - Returns:
/// - Throws:
public func deleteFile(key: String) async throws -> DeleteFileResponse {
try ensureConnected()
BleLog.d("Sending deleteFile (key=\(key))", "Command")
let data = try await sendAndWait(commandType: .deleteFile) {
try await self.deviceInfoService.deleteFile(key: key)
}
let resp = try decodeResponse(DeleteFileResponse.self, from: data)
if resp.success != 0 {
BleLog.w("Delete failed with status=\(resp.success)", "Command")
throw DuooomiBleError.deleteFileFailed(status: resp.success)
}
BleLog.i("Delete success (key=\(key))", "Command")
return resp
}
///
/// - Parameters:
/// - key: key使 URL
/// - size:
/// - Returns: `status == "ready"`
/// - Throws:
public func prepareTransfer(key: String, size: Int) async throws -> PrepareTransferResponse {
try ensureConnected()
let opId = "\(CommandType.prepareTransfer.rawValue)_\(key)"
BleLog.d("Sending prepareTransfer (key=\(key), size=\(size))", "Transfer")
let data = try await sendAndWait(opId: opId, command: .prepareTransfer) {
try await self.deviceInfoService.prepareTransfer(key: key, size: size)
}
let resp = try decodeResponse(PrepareTransferResponse.self, from: data)
if resp.status != "ready" {
BleLog.w("PrepareTransfer not ready: status=\(resp.status)", "Transfer")
throw DuooomiBleError.prepareTransferFailed(status: resp.status)
}
BleLog.i("PrepareTransfer ready (key=\(key))", "Transfer")
return resp
}
// MARK: - File Transfer
///
/// - Parameters:
/// - fileUri: `file://` `https://` URL
/// - commandType: `.transferAniVideo``.otaPackage`
/// - Throws:
public func transferFile(
fileUri: String,
commandType: CommandType = .transferAniVideo
) async throws {
try ensureConnected()
transferProgress = 0
BleLog.i("Transfer start: uri=\(fileUri), cmd=\(commandType)", "Transfer")
try await fileTransferService.transferFile(
fileUri: fileUri,
commandType: commandType
) { [weak self] progress in
Task { @MainActor [weak self] in
self?.transferProgress = Int(progress * 100)
if let p = self?.transferProgress, p % 10 == 0 {
BleLog.d("Progress: \(p)%", "Transfer")
}
}
}
transferProgress = 100
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(
commandType: CommandType,
timeout: TimeInterval = 10,
send: @escaping () async throws -> Void
) async throws -> Data {
try await sendAndWait(
opId: "\(commandType.rawValue)",
command: commandType,
timeout: timeout,
send: send
)
}
private func sendAndWait(
opId: String,
command: CommandType,
timeout: TimeInterval = 10,
send: @escaping () async throws -> Void
) async throws -> Data {
BleLog.d("Register opId=\(opId), cmd=\(command)", "RPC")
// Register continuation BEFORE sending so a fast device response can't race past us.
// Use a defer block to guarantee opId removal regardless of timeout/send/cancel outcome.
do {
return try await withThrowingTaskGroup(of: Data.self) { group in
group.addTask { @MainActor [self] in
try await withCheckedThrowingContinuation { continuation in
self.pendingContinuations[opId] = continuation
BleLog.d("Continuation stored for opId=\(opId)", "RPC")
}
}
group.addTask {
// Send first; if send fails, propagate immediately instead of waiting timeout.
try await send()
BleLog.d("Command sent for opId=\(opId)", "RPC")
try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
throw DuooomiBleError.timeout(command: command)
}
defer { group.cancelAll() }
guard let result = try await group.next() else {
throw DuooomiBleError.timeout(command: command)
}
BleLog.d("Result received for opId=\(opId)", "RPC")
return result
}
} catch {
// Whatever the failure (timeout, cancellation, send error), make sure no stale
// continuation is left in the dictionary so a late device response cannot resume
// a continuation that has already been released.
await MainActor.run {
if let stale = self.pendingContinuations.removeValue(forKey: opId) {
stale.resume(throwing: error)
}
}
BleLog.e("RPC failed for opId=\(opId): \(error.localizedDescription)", "RPC")
throw error
}
}
// MARK: - Message Listener
private func startMessageListener() {
messageListenerTask?.cancel()
messageListenerTask = Task { [weak self] in
guard let self else { return }
BleLog.i("Message listener started", "RPC")
for await (commandType, data) in self.protocolService.incomingMessages {
guard !Task.isCancelled else { break }
BleLog.d("Incoming message: type=\(commandType), size=\(data.count)", "RPC")
await self.handleIncomingMessage(commandType: commandType, data: data)
}
}
}
@MainActor
private func handleIncomingMessage(commandType: UInt8, data: Data) {
let opId = "\(commandType)"
// Check for prepareTransfer with key-based opId
if commandType == CommandType.prepareTransfer.rawValue {
if let resp = try? decodeResponse(PrepareTransferResponse.self, from: data) {
let keyedOpId = "\(commandType)_\(resp.key)"
if let continuation = pendingContinuations.removeValue(forKey: keyedOpId) {
BleLog.d("Resuming keyed opId=\(keyedOpId)", "RPC")
continuation.resume(returning: data)
return
}
}
}
if let continuation = pendingContinuations.removeValue(forKey: opId) {
BleLog.d("Resuming opId=\(opId)", "RPC")
continuation.resume(returning: data)
} else {
BleLog.w("No pending continuation for opId=\(opId)", "RPC")
}
}
// MARK: - Helpers
private func ensureConnected() throws {
guard connectedDevice != nil else {
BleLog.w("Operation requires connection", "SDK")
throw DuooomiBleError.notConnected
}
}
private func decodeResponse<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
// Device responses may contain null bytes
let cleaned = data.filter { $0 != 0 }
do {
return try JSONDecoder().decode(type, from: cleaned)
} catch {
let raw = String(data: cleaned, encoding: .utf8) ?? "<non-utf8, \(cleaned.count) bytes>"
BleLog.e("JSON decode \(T.self) failed: \(error.localizedDescription)\nRaw: \(raw)", "Decode")
throw error
}
}
private func cancelAllPending(error: Error) {
let continuations = pendingContinuations
pendingContinuations.removeAll()
for (_, continuation) in continuations {
continuation.resume(throwing: error)
}
}
// MARK: - Scan Batching (500ms throttle)
private func queueDevice(_ device: DiscoveredDevice) {
guard !allDiscoveredDevices.contains(where: { $0.id == device.id }) else { return }
allDiscoveredDevices.append(device)
pendingDevices.append(device)
flushWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
Task { @MainActor [weak self] in
self?.flushDevices()
}
}
flushWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: workItem)
}
private func flushDevices() {
guard !pendingDevices.isEmpty else { return }
pendingDevices = []
discoveredDevices = allDiscoveredDevices
BleLog.d("Discovered devices flushed: total=\(discoveredDevices.count)", "Scan")
}
}