import CoreBluetooth import Foundation /// Duooomi BLE SDK - 纯原生 iOS 蓝牙 SDK /// /// 提供设备扫描、连接、命令交互、文件传输等原子蓝牙操作。 /// 不做业务编排,调用方自行串联多步流程。 public final class DuooomiBleSDK: NSObject { // MARK: - Observable State public weak var delegate: DuooomiBleSDKDelegate? public private(set) var btState: ConnectionState = .idle { didSet { guard btState != oldValue else { return } notifyMain { self.delegate?.sdk(self, didChangeState: self.btState) } } } public private(set) var connectedDevice: DiscoveredDevice? = nil { didSet { notifyMain { self.delegate?.sdk(self, didConnectDevice: self.connectedDevice) } } } public private(set) var deviceInfo: DeviceInfo? = nil { didSet { notifyMain { self.delegate?.sdk(self, didUpdateDeviceInfo: self.deviceInfo) } } } public private(set) var version: String = "" { didSet { notifyMain { self.delegate?.sdk(self, didUpdateVersion: self.version) } } } public private(set) var isActivated: Bool = false { didSet { guard isActivated != oldValue else { return } notifyMain { self.delegate?.sdk(self, didChangeActivation: self.isActivated) } } } public private(set) var transferProgress: Int = 0 { didSet { guard transferProgress != oldValue else { return } notifyMain { self.delegate?.sdk(self, didUpdateProgress: self.transferProgress) } } } public private(set) var error: String? = nil { didSet { notifyMain { self.delegate?.sdk(self, didEncounterError: self.error) } } } public private(set) var discoveredDevices: [DiscoveredDevice] = [] { didSet { notifyMain { self.delegate?.sdk(self, didUpdateDevices: self.discoveredDevices) } } } // 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 pendingCallbacks: [String: (Result) -> Void] = [:] private var pendingTimeouts: [String: DispatchWorkItem] = [:] private let callbackQueue = DispatchQueue(label: "com.duooomi.ble.callback") // MARK: - Scan Batching private var scanDelegate: ScanBleClientDelegate? private var allDiscoveredDevices: [DiscoveredDevice] = [] private var pendingDevices: [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) super.init() protocolService.delegate = self BleLog.i("SDK initialized (apiHost=\(config.apiHost), firmware=\(config.firmwareIdentifier)/\(config.firmwareStatus))", "SDK") setupDisconnectHandler() } private func setupDisconnectHandler() { bleClient.onDisconnected = { [weak self] in guard let self = 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 /// 开始扫描设备。 public func scan() { stopScan() btState = .scanning discoveredDevices = [] allDiscoveredDevices = [] pendingDevices = [] BleLog.i("Start scanning...", "Scan") // Create scan proxy to forward discovered devices to SDK while // keeping protocol service as the data receiver let proxy = ScanBleClientDelegate(sdk: self, original: protocolService) scanDelegate = proxy bleClient.delegate = proxy bleClient.scan() } /// 停止扫描设备。 public func stopScan() { bleClient.stopScan() flushDevices() // Restore protocol service as delegate scanDelegate = nil bleClient.delegate = protocolService if connectedDevice != nil { btState = .connected } else if btState == .scanning { btState = .idle } BleLog.i("Stop scanning (state=\(btState.rawValue))", "Scan") } // MARK: - Connection /// 连接到指定设备。 public func connect(deviceId: String, completion: @escaping (Result) -> Void) { stopScan() btState = .connecting BleLog.i("Connecting to \(deviceId)...", "Connect") bleClient.connect(deviceId: deviceId) { [weak self] result in guard let self = self else { return } switch result { case .success(let peripheral): self.protocolService.startListening() let device = DiscoveredDevice( id: peripheral.identifier.uuidString, name: peripheral.name, rssi: 0 ) self.connectedDevice = device self.btState = .connected BleLog.i("Connected: \(device.id)", "Connect") completion(.success(device)) case .failure(let error): self.btState = .idle self.error = error.localizedDescription BleLog.e("Connect failed: \(error.localizedDescription)", "Connect") completion(.failure(error)) } } } /// 断开当前连接。 public func disconnect(completion: @escaping (Result) -> Void) { btState = .disconnecting BleLog.i("Disconnect requested", "Connect") protocolService.stopListening() cancelAllPending(error: DuooomiBleError.notConnected) bleClient.disconnect { [weak self] result in guard let self = self else { return } if case .failure(let error) = result { self.error = error.localizedDescription BleLog.e("Disconnect error: \(error.localizedDescription)", "Connect") } self.connectedDevice = nil self.deviceInfo = nil self.version = "" self.isActivated = false self.btState = .disconnected BleLog.i("Disconnected", "Connect") completion(result) } } /// 返回系统已连接的设备 public func getConnectedDevices() -> [DiscoveredDevice] { let list = bleClient.retrieveConnectedDevices() BleLog.d("System-connected devices: \(list.count)", "Connect") return list } // MARK: - Device Commands /// 读取设备信息 public func getDeviceInfo(completion: @escaping (Result) -> Void) { guard ensureConnected(completion: completion) else { return } BleLog.d("Sending getDeviceInfo", "Command") sendAndWait(commandType: .getDeviceInfo, completion: { [weak self] result in guard let self = self else { return } switch result { case .failure(let error): completion(.failure(error)) case .success(let data): do { let info = try self.decodeResponse(DeviceInfo.self, from: data) BleLog.i("DeviceInfo received: name=\(info.name), brand=\(info.brand)", "Command") self.deviceInfo = info completion(.success(info)) } catch { completion(.failure(error)) } } }, send: { try self.deviceInfoService.getDeviceInfo() }) } /// 读取设备固件版本 public func getVersion(completion: @escaping (Result) -> Void) { guard ensureConnected(completion: completion) else { return } BleLog.d("Sending getDeviceVersion", "Command") sendAndWait(commandType: .getDeviceVersion, completion: { [weak self] result in guard let self = self else { return } switch result { case .failure(let error): completion(.failure(error)) case .success(let data): do { let info = try self.decodeResponse(VersionInfo.self, from: data) self.version = info.version BleLog.i("Version received: \(info.version) (type=\(info.type))", "Command") completion(.success(info)) } catch { completion(.failure(error)) } } }, send: { try self.deviceInfoService.getDeviceVersion() }) } /// 绑定设备到用户 public func bind(userId: String, completion: @escaping (Result) -> Void) { guard ensureConnected(completion: completion) else { return } BleLog.d("Sending bind (userId=\(userId))", "Command") sendAndWait(commandType: .bindDevice, completion: { [weak self] result in guard let self = self else { return } switch result { case .failure(let error): completion(.failure(error)) case .success(let data): do { let resp = try self.decodeResponse(BindingResponse.self, from: data) self.isActivated = resp.success == 1 if resp.success != 1 { BleLog.w("Bind failed: device already bound", "Command") completion(.failure(DuooomiBleError.bindingFailed("Device already bound to another user"))) } else { BleLog.i("Bind success: sn=\(resp.sn)", "Command") completion(.success(resp)) } } catch { completion(.failure(error)) } } }, send: { try self.deviceInfoService.bindDevice(userId: userId) }) } /// 切换播放模式(0=单播 / 1=循环播放)。 /// /// 复用 `BIND_DEVICE` (0x0F) 命令通道;响应类型为 `BindingResponse`。 /// 成功后自动刷新一次 `deviceInfo`(含最新 `loop` 字段),与 expo 端行为对齐。 /// /// - Important: 此方法要求 **设备已和该 `userId` 完成 `bind`**(即 `isActivated == true`)。 /// 未绑定状态下设备会返回 `success=0`;SDK 在客户端先做守卫,直接返回失败而不发命令。 /// - Note: `bind` / `unbind` / `setPlayMode` 共享 `0x0F` / `0x12` opId 通道,**不能并发**。 public func setPlayMode( _ mode: PlayMode, userId: String, completion: @escaping (Result) -> Void ) { guard ensureConnected(completion: completion) else { return } guard isActivated else { BleLog.w("setPlayMode called before bind (isActivated=false)", "Command") DispatchQueue.main.async { completion(.failure(DuooomiBleError.bindingFailed("Device not bound; call bind(userId:) first"))) } return } BleLog.d("Sending setPlayMode (mode=\(mode), userId=\(userId))", "Command") sendAndWait(commandType: .bindDevice, completion: { [weak self] result in guard let self = self else { return } switch result { case .failure(let error): completion(.failure(error)) case .success(let data): do { let resp = try self.decodeResponse(BindingResponse.self, from: data) if resp.success != 1 { BleLog.w("setPlayMode failed: device rejected", "Command") completion(.failure(DuooomiBleError.bindingFailed("Set play mode failed"))) return } BleLog.i("setPlayMode success: mode=\(mode)", "Command") // 自动刷新 deviceInfo(含最新 loop 字段) self.getDeviceInfo { _ in completion(.success(resp)) } } catch { completion(.failure(error)) } } }, send: { try self.deviceInfoService.setPlayMode(userId: userId, loop: mode) }) } /// 解除设备绑定 public func unbind(userId: String, completion: @escaping (Result) -> Void) { guard ensureConnected(completion: completion) else { return } BleLog.d("Sending unbind (userId=\(userId))", "Command") sendAndWait(commandType: .unbindDevice, completion: { [weak self] result in guard let self = self else { return } switch result { case .failure(let error): completion(.failure(error)) case .success(let data): do { let resp = try self.decodeResponse(UnbindResponse.self, from: data) if resp.success == 1 { self.isActivated = false BleLog.i("Unbind success", "Command") completion(.success(resp)) } else { BleLog.w("Unbind failed", "Command") completion(.failure(DuooomiBleError.unbindFailed)) } } catch { completion(.failure(error)) } } }, send: { try self.deviceInfoService.unbindDevice(userId: userId) }) } /// 删除设备文件 public func deleteFile(key: String, completion: @escaping (Result) -> Void) { guard ensureConnected(completion: completion) else { return } BleLog.d("Sending deleteFile (key=\(key))", "Command") sendAndWait(commandType: .deleteFile, completion: { [weak self] result in guard let self = self else { return } switch result { case .failure(let error): completion(.failure(error)) case .success(let data): do { let resp = try self.decodeResponse(DeleteFileResponse.self, from: data) if resp.success != 0 { BleLog.w("Delete failed with status=\(resp.success)", "Command") completion(.failure(DuooomiBleError.deleteFileFailed(status: resp.success))) } else { BleLog.i("Delete success (key=\(key))", "Command") completion(.success(resp)) } } catch { completion(.failure(error)) } } }, send: { try self.deviceInfoService.deleteFile(key: key) }) } /// 传输前准备校验 public func prepareTransfer(key: String, size: Int, completion: @escaping (Result) -> Void) { guard ensureConnected(completion: completion) else { return } let opId = "\(CommandType.prepareTransfer.rawValue)_\(key)" BleLog.d("Sending prepareTransfer (key=\(key), size=\(size))", "Transfer") sendAndWait(opId: opId, commandType: .prepareTransfer, completion: { [weak self] result in guard let self = self else { return } switch result { case .failure(let error): completion(.failure(error)) case .success(let data): do { let resp = try self.decodeResponse(PrepareTransferResponse.self, from: data) if resp.status != "ready" { BleLog.w("PrepareTransfer not ready: status=\(resp.status)", "Transfer") completion(.failure(DuooomiBleError.prepareTransferFailed(status: resp.status))) } else { BleLog.i("PrepareTransfer ready (key=\(key))", "Transfer") completion(.success(resp)) } } catch { completion(.failure(error)) } } }, send: { try self.deviceInfoService.prepareTransfer(key: key, size: size) }) } // MARK: - File Transfer /// 传输文件到设备 public func transferFile( fileUri: String, commandType: CommandType = .transferAniVideo, completion: @escaping (Result) -> Void ) { guard connectedDevice != nil else { completion(.failure(DuooomiBleError.notConnected)) return } transferProgress = 0 BleLog.i("Transfer start: uri=\(fileUri), cmd=\(commandType)", "Transfer") fileTransferService.transferFile( fileUri: fileUri, commandType: commandType, onProgress: { [weak self] progress in DispatchQueue.main.async { let p = Int(progress * 100) self?.transferProgress = p if p % 10 == 0 { BleLog.d("Progress: \(p)%", "Transfer") } } }, completion: { [weak self] result in if case .success = result { self?.transferProgress = 100 BleLog.i("Transfer completed", "Transfer") } completion(result) } ) } // MARK: - High-Level APIs private static let supportedMediaExtensions: Set = [ "jpeg", "jpg", "png", "gif", "mp4", "webm", "webp" ] private static let supportedMimeTypes: Set = [ "image/jpeg", "image/png", "image/gif", "image/webp", "video/mp4", "video/webm" ] private func extractExtension(from urlString: String) -> String? { guard let comps = URLComponents(string: urlString) else { return nil } let path = comps.path let ext = (path.lowercased() as NSString).pathExtension guard !ext.isEmpty else { return nil } return ext } private func validateMediaFormat(url: String, completion: @escaping (Result) -> Void) { if let ext = extractExtension(from: url) { if DuooomiBleSDK.supportedMediaExtensions.contains(ext) { completion(.success(())) } else { completion(.failure(DuooomiBleError.unsupportedFormat(ext))) } return } guard let requestUrl = URL(string: url) else { completion(.failure(DuooomiBleError.transferFailed("Invalid URL: \(url)"))) return } var request = URLRequest(url: requestUrl) request.httpMethod = "HEAD" URLSession.shared.dataTask(with: request) { _, response, error in if let error = error { completion(.failure(DuooomiBleError.transferFailed("Format check failed: \(error.localizedDescription)"))) return } guard let mime = (response as? HTTPURLResponse)?.allHeaderFields["Content-Type"] as? String else { completion(.failure(DuooomiBleError.unsupportedFormat("unknown"))) return } let baseMime = mime.components(separatedBy: ";").first?.trimmingCharacters(in: .whitespaces).lowercased() ?? "" if DuooomiBleSDK.supportedMimeTypes.contains(baseMime) { completion(.success(())) } else { completion(.failure(DuooomiBleError.unsupportedFormat(baseMime))) } }.resume() } /// 传输媒体文件到设备(一步完成) public func transferMedia(fileUrl: String, completion: @escaping (Result) -> Void) { guard connectedDevice != nil else { completion(.failure(DuooomiBleError.notConnected)) return } let url = fileUrl.trimmingCharacters(in: .whitespacesAndNewlines) guard !url.isEmpty else { completion(.failure(DuooomiBleError.transferFailed("Empty file URL"))) return } let isAni = url.lowercased().hasSuffix(".ani") if isAni { BleLog.i("Direct ANI transfer: \(url)", "Transfer") transferMediaStep2(aniUrl: url, originalUrl: url, completion: completion) } else { validateMediaFormat(url: url) { [weak self] result in switch result { case .failure(let error): completion(.failure(error)) case .success: BleLog.i("Converting to ANI: \(url)", "Transfer") self?.aniConverter.convert(fileUrl: url) { [weak self] result in switch result { case .failure(let error): completion(.failure(DuooomiBleError.transferFailed("ANI conversion failed: \(error.localizedDescription)"))) case .success(let aniUrl): BleLog.i("ANI ready: \(aniUrl)", "Transfer") self?.transferMediaStep2(aniUrl: aniUrl, originalUrl: url, completion: completion) } } } } } } private func transferMediaStep2(aniUrl: String, originalUrl: String, completion: @escaping (Result) -> Void) { getRemoteFileSize(url: aniUrl) { [weak self] result in switch result { case .failure(let error): completion(.failure(error)) case .success(let aniSize): BleLog.d("ANI size: \(aniSize) bytes", "Transfer") self?.prepareTransfer(key: originalUrl, size: aniSize) { [weak self] result in switch result { case .failure(let error): completion(.failure(error)) case .success: self?.transferFile(fileUri: aniUrl, commandType: .transferAniVideo, completion: completion) } } } } } /// 获取最新固件信息 public func fetchLatestFirmware( identifier: String? = nil, status: String? = nil, completion: @escaping (Result) -> Void ) { firmwareService.fetchLatest(identifier: identifier, status: status, completion: completion) } /// 比较设备版本和服务端版本 public func hasNewerFirmware(deviceVersion: String?, serverVersion: String?) -> Bool { guard let device = deviceVersion, let server = serverVersion, device.count >= 10, server.count >= 10 else { return false } let deviceTs = device.suffix(10) let serverTs = server.suffix(10) guard let d = Int(deviceTs), let s = Int(serverTs) else { return false } return s > d } /// OTA 固件升级 public func upgradeFirmware(fileUrl: String, completion: @escaping (Result) -> Void) { guard connectedDevice != nil else { completion(.failure(DuooomiBleError.notConnected)) return } BleLog.i("OTA upgrade start: \(fileUrl)", "Firmware") transferFile(fileUri: fileUrl, commandType: .otaPackage) { result in if case .success = result { BleLog.i("OTA upgrade completed", "Firmware") } completion(result) } } // MARK: - Internal Helpers private func getRemoteFileSize(url: String, completion: @escaping (Result) -> Void) { guard let fileUrl = URL(string: url) else { completion(.failure(DuooomiBleError.transferFailed("Invalid URL: \(url)"))) return } var request = URLRequest(url: fileUrl) request.httpMethod = "HEAD" URLSession.shared.dataTask(with: request) { [weak self] _, response, error in if let error = error { completion(.failure(error)) return } if let http = response as? HTTPURLResponse { if let lengthStr = http.allHeaderFields["Content-Length"] as? String, let length = Int(lengthStr), length > 0 { completion(.success(length)) return } if http.expectedContentLength > 0 { completion(.success(Int(http.expectedContentLength))) return } } // Fallback: Range request self?.getFileSizeViaRange(fileUrl: fileUrl, completion: completion) }.resume() } private func getFileSizeViaRange(fileUrl: URL, completion: @escaping (Result) -> Void) { var rangeReq = URLRequest(url: fileUrl) rangeReq.httpMethod = "GET" rangeReq.setValue("bytes=0-0", forHTTPHeaderField: "Range") URLSession.shared.dataTask(with: rangeReq) { _, response, error in if let error = error { completion(.failure(error)) return } if let http = response as? HTTPURLResponse, let rangeHeader = http.allHeaderFields["Content-Range"] as? String, let totalStr = rangeHeader.split(separator: "/").last, let total = Int(totalStr), total > 0 { completion(.success(total)) return } completion(.failure(DuooomiBleError.transferFailed("Cannot determine file size: \(fileUrl.absoluteString)"))) }.resume() } // MARK: - Request-Response Pattern private func sendAndWait( commandType: CommandType, timeout: TimeInterval = 10, completion: @escaping (Result) -> Void, send: @escaping () throws -> Void ) { let opId = "\(commandType.rawValue)" sendAndWait(opId: opId, commandType: commandType, timeout: timeout, completion: completion, send: send) } private func sendAndWait( opId: String, commandType: CommandType, timeout: TimeInterval = 10, completion: @escaping (Result) -> Void, send: @escaping () throws -> Void ) { BleLog.d("Register opId=\(opId), cmd=\(commandType)", "RPC") // Register callback BEFORE sending callbackQueue.sync { self.pendingCallbacks[opId] = { result in DispatchQueue.main.async { completion(result) } } } // Setup timeout let timeoutWork = DispatchWorkItem { [weak self] in guard let self = self else { return } self.callbackQueue.sync { if let cb = self.pendingCallbacks.removeValue(forKey: opId) { self.pendingTimeouts.removeValue(forKey: opId) cb(.failure(DuooomiBleError.timeout(command: commandType))) } } } callbackQueue.sync { pendingTimeouts[opId] = timeoutWork } DispatchQueue.global().asyncAfter(deadline: .now() + timeout, execute: timeoutWork) // Send command do { try send() BleLog.d("Command sent for opId=\(opId)", "RPC") } catch { callbackQueue.sync { self.pendingCallbacks.removeValue(forKey: opId) self.pendingTimeouts.removeValue(forKey: opId)?.cancel() } BleLog.e("Send failed for opId=\(opId): \(error.localizedDescription)", "RPC") DispatchQueue.main.async { completion(.failure(error)) } } } // MARK: - Helpers private func ensureConnected(completion: @escaping (Result) -> Void) -> Bool { guard connectedDevice != nil else { BleLog.w("Operation requires connection", "SDK") completion(.failure(DuooomiBleError.notConnected)) return false } return true } private func decodeResponse(_ type: T.Type, from data: Data) throws -> T { 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) { callbackQueue.sync { let callbacks = self.pendingCallbacks self.pendingCallbacks.removeAll() for (_, timeout) in self.pendingTimeouts { timeout.cancel() } self.pendingTimeouts.removeAll() for (_, cb) in callbacks { cb(.failure(error)) } } } private func notifyMain(_ block: @escaping () -> Void) { if Thread.isMainThread { block() } else { DispatchQueue.main.async(execute: block) } } // MARK: - Scan Batching (500ms throttle) fileprivate 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 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") } } // MARK: - BleProtocolServiceDelegate (incoming messages) extension DuooomiBleSDK: BleProtocolServiceDelegate { func protocolService(_ service: BleProtocolService, didReceiveMessage commandType: UInt8, data: Data) { BleLog.d("Incoming message: type=\(commandType), size=\(data.count)", "RPC") 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)" var cb: ((Result) -> Void)? callbackQueue.sync { cb = self.pendingCallbacks.removeValue(forKey: keyedOpId) self.pendingTimeouts.removeValue(forKey: keyedOpId)?.cancel() } if let cb = cb { BleLog.d("Resuming keyed opId=\(keyedOpId)", "RPC") cb(.success(data)) return } } } var cb: ((Result) -> Void)? callbackQueue.sync { cb = self.pendingCallbacks.removeValue(forKey: opId) self.pendingTimeouts.removeValue(forKey: opId)?.cancel() } if let cb = cb { BleLog.d("Resuming opId=\(opId)", "RPC") cb(.success(data)) } else { BleLog.w("No pending callback for opId=\(opId)", "RPC") } } } // MARK: - Scan Proxy /// Forwards scan results to SDK while keeping protocol service as data receiver. private class ScanBleClientDelegate: BleClientDelegate { private weak var sdk: DuooomiBleSDK? private let original: BleClientDelegate? init(sdk: DuooomiBleSDK, original: BleClientDelegate?) { self.sdk = sdk self.original = original } func bleClient(_ client: BleClient, didDiscoverDevice device: DiscoveredDevice) { BleLog.d("Discovered: id=\(device.id), name=\(device.name ?? "-"), rssi=\(device.rssi)", "Scan") sdk?.queueDevice(device) } func bleClient(_ client: BleClient, didReceiveData data: Data) { original?.bleClient(client, didReceiveData: data) } }