feat(sdk): OTA 升级流程对齐 expo + 拆 extension 文件分层 + 秘钥本地化

主要改动:
- 新增 fetchLatestFirmware/upgradeFirmware/tryReportPendingUpgrade
  三段 OTA 接口,对齐 expo bindDeviceWithOrchestration 行为
- bind 仅做硬绑+getVersion,软绑/对账上报/拉最新固件搬到
  fetchLatestFirmware 内部串;软绑失败自动硬解回滚
- UpgradeRecord 复合主键 (sn, firmwareId) 持久化 + sn 级 in-flight 互斥
- DuooomiBleSDKDelegate 新增 didUpdateUpgradeRecords,集成方
  可观察对账上报结果(reported/outcome 状态)
- DuooomiBleSDK 主类按职责拆 5 个 extension 文件,公开 API 不变
- TargetFirmware 简化为只取必要字段 + 新增 forceUpgrade
- scanNamePrefix 改为必填 String
- demo 秘钥移至本地 DemoSecrets.swift (gitignored)
- README 重写:完整 OTA 流程示例 + 上报状态判定表

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
km2023
2026-05-08 14:46:41 +08:00
parent f4a32bb7e8
commit d5729a1a41
24 changed files with 2349 additions and 880 deletions

View File

@@ -4,51 +4,65 @@ import Foundation
/// Duooomi BLE SDK - iOS SDK
///
///
///
/// / OTA / SDK API
///
/// API
/// - `DuooomiBleSDK.swift` / init / / RPC helpers / delegate
/// - `DuooomiBleSDK+Connection.swift` /
/// - `DuooomiBleSDK+Commands.swift` RPCgetDeviceInfo / getVersion / setPlayMode / deleteFile / prepareTransfer
/// - `DuooomiBleSDK+Binding.swift` getVersion OTA /
/// - `DuooomiBleSDK+Firmware.swift` OTA / /
/// - `DuooomiBleSDK+Transfer.swift` mime / ANI / size
///
/// `scan()` / `stopScan()` extensionname `Combine.Publisher.scan(_:_:)`
/// SDK `@Published` extension `scan` Publisher.scan
public final class DuooomiBleSDK: NSObject {
// MARK: - Observable State
public weak var delegate: DuooomiBleSDKDelegate?
public private(set) var btState: ConnectionState = .idle {
public internal(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 {
public internal(set) var connectedDevice: DiscoveredDevice? = nil {
didSet { notifyMain { self.delegate?.sdk(self, didConnectDevice: self.connectedDevice) } }
}
public private(set) var deviceInfo: DeviceInfo? = nil {
public internal(set) var deviceInfo: DeviceInfo? = nil {
didSet { notifyMain { self.delegate?.sdk(self, didUpdateDeviceInfo: self.deviceInfo) } }
}
public private(set) var version: String = "" {
public internal(set) var version: String = "" {
didSet { notifyMain { self.delegate?.sdk(self, didUpdateVersion: self.version) } }
}
public private(set) var isActivated: Bool = false {
public internal(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 {
/// SN
public internal(set) var sn: String = ""
public internal(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 {
public internal(set) var error: String? = nil {
didSet { notifyMain { self.delegate?.sdk(self, didEncounterError: self.error) } }
}
public private(set) var discoveredDevices: [DiscoveredDevice] = [] {
public internal(set) var discoveredDevices: [DiscoveredDevice] = [] {
didSet { notifyMain { self.delegate?.sdk(self, didUpdateDevices: self.discoveredDevices) } }
}
@@ -56,27 +70,36 @@ public final class DuooomiBleSDK: NSObject {
public let config: DuooomiBleConfig
// MARK: - Internal Services
// MARK: - Internal Services (extension )
private let bleClient: BleClient
private let protocolService: BleProtocolService
private let deviceInfoService: DeviceInfoService
private let fileTransferService: FileTransferService
private let aniConverter: AniConverter
private let firmwareService: FirmwareService
let bleClient: BleClient
let protocolService: BleProtocolService
let deviceInfoService: DeviceInfoService
let fileTransferService: FileTransferService
let aniConverter: AniConverter
let timeProvider: BeijingTimeProvider
let shipmentService: ShipmentSnDeviceService
let firmwareUpgradeService: FirmwareUpgradeService
let upgradeRecordStore: UpgradeRecordStore
// MARK: - Request-Response
/// `fetchLatestFirmware`
public internal(set) var firmwareUpgrade: FirmwareUpgradeCheckResult? = nil
private var pendingCallbacks: [String: (Result<Data, Error>) -> Void] = [:]
private var pendingTimeouts: [String: DispatchWorkItem] = [:]
private let callbackQueue = DispatchQueue(label: "com.duooomi.ble.callback")
/// OTA triggeredAt UserDefaults
public var upgradeRecords: [UpgradeRecord] { upgradeRecordStore.all }
// MARK: - Request-Response (extension sendAndWait / sendAsyncAndWait )
var pendingCallbacks: [String: (Result<Data, Error>) -> Void] = [:]
var pendingTimeouts: [String: DispatchWorkItem] = [:]
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?
var scanDelegate: ScanBleClientDelegate?
var allDiscoveredDevices: [DiscoveredDevice] = []
var pendingDevices: [DiscoveredDevice] = []
var flushWorkItem: DispatchWorkItem?
// MARK: - Init
@@ -84,19 +107,67 @@ public final class DuooomiBleSDK: NSObject {
self.config = config
bleClient = BleClient()
protocolService = BleProtocolService(client: bleClient)
deviceInfoService = DeviceInfoService(protocolService: protocolService)
timeProvider = BeijingTimeProvider()
deviceInfoService = DeviceInfoService(protocolService: protocolService, timeProvider: timeProvider)
fileTransferService = FileTransferService(protocolService: protocolService)
aniConverter = AniConverter(config: config)
firmwareService = FirmwareService(config: config)
shipmentService = ShipmentSnDeviceService(config: config)
firmwareUpgradeService = FirmwareUpgradeService(config: config)
upgradeRecordStore = UpgradeRecordStore()
super.init()
protocolService.delegate = self
BleLog.i("SDK initialized (apiHost=\(config.apiHost), firmware=\(config.firmwareIdentifier)/\(config.firmwareStatus))", "SDK")
BleLog.i("SDK initialized (apiHost=\(config.apiHost), owner=\(config.owner))", "SDK")
setupDisconnectHandler()
// UpgradeRecord delegate
upgradeRecordStore.onChange = { [weak self] records in
guard let self = self else { return }
self.delegate?.sdk(self, didUpdateUpgradeRecords: records)
}
}
private func setupDisconnectHandler() {
// MARK: - Scanning
/// BLE throttle 500ms delegate `didUpdateDevices`
/// `sdk.discoveredDevices``btState` `.scanning`
///
/// stopScan stopScan btState `.connected` `.idle`
/// `config.scanNamePrefix` "Duooomi-"
public func scan() {
stopScan()
btState = .scanning
discoveredDevices = []
allDiscoveredDevices = []
pendingDevices = []
BleLog.i("Start scanning...", "Scan")
let proxy = ScanBleClientDelegate(sdk: self, original: protocolService)
scanDelegate = proxy
bleClient.delegate = proxy
bleClient.scan()
}
/// BLE flush `bleClient.delegate`
/// `btState` `.connected` `.idle`
public func stopScan() {
bleClient.stopScan()
flushDevices()
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: - Disconnect
func setupDisconnectHandler() {
bleClient.onDisconnected = { [weak self] in
guard let self = self else { return }
BleLog.w("Peripheral disconnected; resetting state", "SDK")
@@ -110,565 +181,9 @@ public final class DuooomiBleSDK: NSObject {
}
}
// 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(
func sendAndWait(
commandType: CommandType,
timeout: TimeInterval = 10,
completion: @escaping (Result<Data, Error>) -> Void,
@@ -678,7 +193,51 @@ public final class DuooomiBleSDK: NSObject {
sendAndWait(opId: opId, commandType: commandType, timeout: timeout, completion: completion, send: send)
}
private func sendAndWait(
/// send BeijingTime bind/unbind/setPlayMode
/// timeout 10s + BLE +
func sendAsyncAndWait(
commandType: CommandType,
timeout: TimeInterval = 10,
completion: @escaping (Result<Data, Error>) -> Void,
send: @escaping (@escaping (Result<Void, Error>) -> Void) -> Void
) {
let opId = "\(commandType.rawValue)"
BleLog.d("Register opId=\(opId), cmd=\(commandType) (async send)", "RPC")
callbackQueue.sync {
self.pendingCallbacks[opId] = { result in
DispatchQueue.main.async { completion(result) }
}
}
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 { [weak self] result in
guard let self = self else { return }
if case .failure(let error) = result {
self.callbackQueue.sync {
self.pendingCallbacks.removeValue(forKey: opId)
self.pendingTimeouts.removeValue(forKey: opId)?.cancel()
}
BleLog.e("Async send failed for opId=\(opId): \(error.localizedDescription)", "RPC")
DispatchQueue.main.async { completion(.failure(error)) }
} else {
BleLog.d("Async command sent for opId=\(opId)", "RPC")
}
}
}
func sendAndWait(
opId: String,
commandType: CommandType,
timeout: TimeInterval = 10,
@@ -687,7 +246,6 @@ public final class DuooomiBleSDK: NSObject {
) {
BleLog.d("Register opId=\(opId), cmd=\(commandType)", "RPC")
// Register callback BEFORE sending
callbackQueue.sync {
self.pendingCallbacks[opId] = { result in
DispatchQueue.main.async {
@@ -696,7 +254,6 @@ public final class DuooomiBleSDK: NSObject {
}
}
// Setup timeout
let timeoutWork = DispatchWorkItem { [weak self] in
guard let self = self else { return }
self.callbackQueue.sync {
@@ -711,7 +268,6 @@ public final class DuooomiBleSDK: NSObject {
}
DispatchQueue.global().asyncAfter(deadline: .now() + timeout, execute: timeoutWork)
// Send command
do {
try send()
BleLog.d("Command sent for opId=\(opId)", "RPC")
@@ -729,7 +285,7 @@ public final class DuooomiBleSDK: NSObject {
// MARK: - Helpers
private func ensureConnected<T>(completion: @escaping (Result<T, Error>) -> Void) -> Bool {
func ensureConnected<T>(completion: @escaping (Result<T, Error>) -> Void) -> Bool {
guard connectedDevice != nil else {
BleLog.w("Operation requires connection", "SDK")
completion(.failure(DuooomiBleError.notConnected))
@@ -738,7 +294,7 @@ public final class DuooomiBleSDK: NSObject {
return true
}
private func decodeResponse<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
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)
@@ -749,7 +305,7 @@ public final class DuooomiBleSDK: NSObject {
}
}
private func cancelAllPending(error: Error) {
func cancelAllPending(error: Error) {
callbackQueue.sync {
let callbacks = self.pendingCallbacks
self.pendingCallbacks.removeAll()
@@ -763,7 +319,7 @@ public final class DuooomiBleSDK: NSObject {
}
}
private func notifyMain(_ block: @escaping () -> Void) {
func notifyMain(_ block: @escaping () -> Void) {
if Thread.isMainThread {
block()
} else {
@@ -773,9 +329,14 @@ public final class DuooomiBleSDK: NSObject {
// MARK: - Scan Batching (500ms throttle)
fileprivate func queueDevice(_ device: DiscoveredDevice) {
func queueDevice(_ device: DiscoveredDevice) {
guard !allDiscoveredDevices.contains(where: { $0.id == device.id }) else { return }
let name = device.name?.lowercased() ?? ""
if !name.hasPrefix(config.scanNamePrefix.lowercased()) {
return
}
allDiscoveredDevices.append(device)
pendingDevices.append(device)
@@ -787,7 +348,7 @@ public final class DuooomiBleSDK: NSObject {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: workItem)
}
private func flushDevices() {
func flushDevices() {
guard !pendingDevices.isEmpty else { return }
pendingDevices = []
discoveredDevices = allDiscoveredDevices
@@ -804,7 +365,6 @@ extension DuooomiBleSDK: BleProtocolServiceDelegate {
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)"
@@ -839,7 +399,7 @@ extension DuooomiBleSDK: BleProtocolServiceDelegate {
// MARK: - Scan Proxy
/// Forwards scan results to SDK while keeping protocol service as data receiver.
private class ScanBleClientDelegate: BleClientDelegate {
final class ScanBleClientDelegate: BleClientDelegate {
private weak var sdk: DuooomiBleSDK?
private let original: BleClientDelegate?