860 lines
33 KiB
Swift
860 lines
33 KiB
Swift
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<Data, Error>) -> 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<DiscoveredDevice, Error>) -> 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, Error>) -> 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<DeviceInfo, Error>) -> 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<VersionInfo, Error>) -> 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<BindingResponse, Error>) -> 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<BindingResponse, Error>) -> 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<UnbindResponse, Error>) -> 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<DeleteFileResponse, Error>) -> 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<PrepareTransferResponse, Error>) -> 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, Error>) -> 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<String> = [
|
||
"jpeg", "jpg", "png", "gif", "mp4", "webm", "webp"
|
||
]
|
||
|
||
private static let supportedMimeTypes: Set<String> = [
|
||
"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, Error>) -> 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, Error>) -> 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, Error>) -> 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<FirmwareInfo?, Error>) -> 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, Error>) -> 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<Int, Error>) -> 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<Int, Error>) -> 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<Data, Error>) -> 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<Data, Error>) -> 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<T>(completion: @escaping (Result<T, Error>) -> Void) -> Bool {
|
||
guard connectedDevice != nil else {
|
||
BleLog.w("Operation requires connection", "SDK")
|
||
completion(.failure(DuooomiBleError.notConnected))
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
private func decodeResponse<T: Decodable>(_ 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) ?? "<non-utf8, \(cleaned.count) bytes>"
|
||
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<Data, Error>) -> 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<Data, Error>) -> 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)
|
||
}
|
||
}
|