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] = [:] private var messageListenerTask: Task? // MARK: - Scan Batching private var scanTask: Task? 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") // 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( 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(_ 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) ?? "" 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") } }