585 lines
23 KiB
Swift
585 lines
23 KiB
Swift
import Combine
|
||
import CoreBluetooth
|
||
import Foundation
|
||
|
||
/// Duooomi BLE SDK - 纯原生 iOS 蓝牙 SDK
|
||
///
|
||
/// 提供设备扫描、连接、命令交互、文件传输等原子蓝牙操作。
|
||
/// 不做业务编排,调用方自行串联多步流程。
|
||
@MainActor
|
||
public final class DuooomiBleSDK: ObservableObject {
|
||
|
||
// MARK: - Observable State
|
||
|
||
@Published public private(set) var btState: ConnectionState = .idle
|
||
@Published public private(set) var connectedDevice: DiscoveredDevice? = nil
|
||
@Published public private(set) var deviceInfo: DeviceInfo? = nil
|
||
@Published public private(set) var version: String = ""
|
||
@Published public private(set) var isActivated: Bool = false
|
||
@Published public private(set) var transferProgress: Int = 0
|
||
@Published public private(set) var error: String? = nil
|
||
@Published public private(set) var discoveredDevices: [DiscoveredDevice] = []
|
||
|
||
// MARK: - Configuration
|
||
|
||
public let config: DuooomiBleConfig
|
||
|
||
// MARK: - Internal Services
|
||
|
||
private let bleClient: BleClient
|
||
private let protocolService: BleProtocolService
|
||
private let deviceInfoService: DeviceInfoService
|
||
private let fileTransferService: FileTransferService
|
||
private let aniConverter: AniConverter
|
||
private let firmwareService: FirmwareService
|
||
|
||
// MARK: - Request-Response
|
||
|
||
private var pendingContinuations: [String: CheckedContinuation<Data, Error>] = [:]
|
||
private var messageListenerTask: Task<Void, Never>?
|
||
|
||
// MARK: - Scan Batching
|
||
|
||
private var scanTask: Task<Void, Never>?
|
||
private var pendingDevices: [DiscoveredDevice] = []
|
||
private var allDiscoveredDevices: [DiscoveredDevice] = []
|
||
private var flushWorkItem: DispatchWorkItem?
|
||
|
||
// MARK: - Init
|
||
|
||
public init(config: DuooomiBleConfig) {
|
||
self.config = config
|
||
bleClient = BleClient()
|
||
protocolService = BleProtocolService(client: bleClient)
|
||
deviceInfoService = DeviceInfoService(protocolService: protocolService)
|
||
fileTransferService = FileTransferService(protocolService: protocolService)
|
||
aniConverter = AniConverter(config: config)
|
||
firmwareService = FirmwareService(config: config)
|
||
|
||
BleLog.i("SDK initialized (apiHost=\(config.apiHost), firmware=\(config.firmwareIdentifier)/\(config.firmwareStatus))", "SDK")
|
||
setupDisconnectHandler()
|
||
}
|
||
|
||
private func setupDisconnectHandler() {
|
||
bleClient.onDisconnected = { [weak self] in
|
||
Task { @MainActor [weak self] in
|
||
guard let self else { return }
|
||
BleLog.w("Peripheral disconnected; resetting state", "SDK")
|
||
self.connectedDevice = nil
|
||
self.deviceInfo = nil
|
||
self.version = ""
|
||
self.isActivated = false
|
||
self.btState = .disconnected
|
||
self.protocolService.stopListening()
|
||
self.cancelAllPending(error: DuooomiBleError.notConnected)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Scanning
|
||
|
||
/// 开始扫描设备。
|
||
/// - Note: 扫描使用 500ms 批量刷新以减少 UI 抖动,调用 `stopScan()` 可停止。
|
||
public func scan() {
|
||
stopScan()
|
||
btState = .scanning
|
||
discoveredDevices = []
|
||
allDiscoveredDevices = []
|
||
pendingDevices = []
|
||
|
||
BleLog.i("Start scanning...", "Scan")
|
||
let stream = bleClient.scan()
|
||
scanTask = Task { [weak self] in
|
||
for await device in stream {
|
||
guard let self, !Task.isCancelled else { break }
|
||
BleLog.d("Discovered: id=\(device.id), name=\(device.name ?? "-"), rssi=\(device.rssi)", "Scan")
|
||
await self.queueDevice(device)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 停止扫描设备。
|
||
/// - Note: 会立即刷新已缓存的发现列表;若保持已连接,`btState` 回到 `.connected`。
|
||
public func stopScan() {
|
||
scanTask?.cancel()
|
||
scanTask = nil
|
||
bleClient.stopScan()
|
||
flushDevices()
|
||
|
||
if connectedDevice != nil {
|
||
btState = .connected
|
||
} else if btState == .scanning {
|
||
btState = .idle
|
||
}
|
||
BleLog.i("Stop scanning (state=\(btState.rawValue))", "Scan")
|
||
}
|
||
|
||
// MARK: - Connection
|
||
|
||
/// 连接到指定设备。
|
||
/// - Parameter deviceId: 通过扫描得到的 `DiscoveredDevice.id`(CBPeripheral UUID 字符串)。
|
||
/// - Returns: 成功连接的设备信息。
|
||
/// - Throws: `DuooomiBleError.connectionFailed` 等连接相关错误。
|
||
public func connect(deviceId: String) async throws -> DiscoveredDevice {
|
||
stopScan()
|
||
btState = .connecting
|
||
BleLog.i("Connecting to \(deviceId)...", "Connect")
|
||
|
||
do {
|
||
let peripheral = try await bleClient.connect(deviceId: deviceId)
|
||
|
||
// Start protocol listener
|
||
protocolService.startListening()
|
||
startMessageListener()
|
||
|
||
let device = DiscoveredDevice(
|
||
id: peripheral.identifier.uuidString,
|
||
name: peripheral.name,
|
||
rssi: 0
|
||
)
|
||
connectedDevice = device
|
||
btState = .connected
|
||
BleLog.i("Connected: \(device.id)", "Connect")
|
||
return device
|
||
} catch {
|
||
btState = .idle
|
||
self.error = error.localizedDescription
|
||
BleLog.e("Connect failed: \(error.localizedDescription)", "Connect")
|
||
throw error
|
||
}
|
||
}
|
||
|
||
/// 断开当前连接。
|
||
/// - Throws: 断开过程中发生的底层错误(若有)。
|
||
public func disconnect() async throws {
|
||
btState = .disconnecting
|
||
BleLog.i("Disconnect requested", "Connect")
|
||
protocolService.stopListening()
|
||
messageListenerTask?.cancel()
|
||
messageListenerTask = nil
|
||
cancelAllPending(error: DuooomiBleError.notConnected)
|
||
|
||
// Capture the underlying disconnect error so we can clean up state regardless,
|
||
// but still surface it to the caller. UI consumers may also see it via `error`.
|
||
var disconnectError: Error?
|
||
do {
|
||
try await bleClient.disconnect()
|
||
} catch {
|
||
disconnectError = error
|
||
self.error = error.localizedDescription
|
||
BleLog.e("Disconnect error: \(error.localizedDescription)", "Connect")
|
||
}
|
||
|
||
connectedDevice = nil
|
||
deviceInfo = nil
|
||
version = ""
|
||
isActivated = false
|
||
btState = .disconnected
|
||
BleLog.i("Disconnected", "Connect")
|
||
|
||
if let disconnectError {
|
||
throw disconnectError
|
||
}
|
||
}
|
||
|
||
/// 返回系统已连接的、属于本 SDK service 的外设(不一定是被本 SDK 主动连接的)
|
||
/// 返回系统层面已连接、且包含本 SDK 所需 Service 的外设。
|
||
/// - Returns: 符合条件的已连接外设列表。
|
||
public func getConnectedDevices() -> [DiscoveredDevice] {
|
||
let list = bleClient.retrieveConnectedDevices()
|
||
BleLog.d("System-connected devices: \(list.count)", "Connect")
|
||
return list
|
||
}
|
||
|
||
// MARK: - Device Commands
|
||
|
||
/// 读取设备信息(容量、电量、品牌、型号等)。
|
||
/// - Returns: 设备信息结构体。
|
||
/// - Throws: 未连接或超时、解析失败等错误。
|
||
public func getDeviceInfo() async throws -> DeviceInfo {
|
||
try ensureConnected()
|
||
BleLog.d("Sending getDeviceInfo", "Command")
|
||
let data = try await sendAndWait(commandType: .getDeviceInfo) {
|
||
try await self.deviceInfoService.getDeviceInfo()
|
||
}
|
||
let info = try decodeResponse(DeviceInfo.self, from: data)
|
||
BleLog.i("DeviceInfo received: name=\(info.name), brand=\(info.brand)", "Command")
|
||
self.deviceInfo = info
|
||
return info
|
||
}
|
||
|
||
/// 读取设备固件版本。
|
||
/// - Returns: 版本信息。
|
||
/// - Throws: 未连接或超时、解析失败等错误。
|
||
public func getVersion() async throws -> VersionInfo {
|
||
try ensureConnected()
|
||
BleLog.d("Sending getDeviceVersion", "Command")
|
||
let data = try await sendAndWait(commandType: .getDeviceVersion) {
|
||
try await self.deviceInfoService.getDeviceVersion()
|
||
}
|
||
let info = try decodeResponse(VersionInfo.self, from: data)
|
||
self.version = info.version
|
||
BleLog.i("Version received: \(info.version) (type=\(info.type))", "Command")
|
||
return info
|
||
}
|
||
|
||
/// 绑定设备到用户。
|
||
/// - Parameter userId: 业务侧用户唯一标识。
|
||
/// - Returns: 绑定结果,包含 SN 与设备文件清单。
|
||
/// - Throws: 已被他人绑定或其他错误时抛出。
|
||
public func bind(userId: String) async throws -> BindingResponse {
|
||
try ensureConnected()
|
||
BleLog.d("Sending bind (userId=\(userId))", "Command")
|
||
let data = try await sendAndWait(commandType: .bindDevice) {
|
||
try await self.deviceInfoService.bindDevice(userId: userId)
|
||
}
|
||
let resp = try decodeResponse(BindingResponse.self, from: data)
|
||
isActivated = resp.success == 1
|
||
if resp.success != 1 {
|
||
BleLog.w("Bind failed: device already bound", "Command")
|
||
throw DuooomiBleError.bindingFailed("Device already bound to another user")
|
||
}
|
||
BleLog.i("Bind success: sn=\(resp.sn)", "Command")
|
||
return resp
|
||
}
|
||
|
||
/// 解除设备与用户的绑定关系。
|
||
/// - Parameter userId: 业务侧用户唯一标识。
|
||
/// - Returns: 解绑结果。
|
||
/// - Throws: 未连接或设备拒绝等错误。
|
||
public func unbind(userId: String) async throws -> UnbindResponse {
|
||
try ensureConnected()
|
||
BleLog.d("Sending unbind (userId=\(userId))", "Command")
|
||
let data = try await sendAndWait(commandType: .unbindDevice) {
|
||
try await self.deviceInfoService.unbindDevice(userId: userId)
|
||
}
|
||
let resp = try decodeResponse(UnbindResponse.self, from: data)
|
||
if resp.success == 1 {
|
||
isActivated = false
|
||
BleLog.i("Unbind success", "Command")
|
||
} else {
|
||
BleLog.w("Unbind failed", "Command")
|
||
throw DuooomiBleError.unbindFailed
|
||
}
|
||
return resp
|
||
}
|
||
|
||
/// 从设备中删除指定 key 的文件。
|
||
/// - Parameter key: 设备中文件对应的 key 或完整 URL。
|
||
/// - Returns: 删除结果。
|
||
/// - Throws: 文件不存在或删除失败时抛出。
|
||
public func deleteFile(key: String) async throws -> DeleteFileResponse {
|
||
try ensureConnected()
|
||
BleLog.d("Sending deleteFile (key=\(key))", "Command")
|
||
let data = try await sendAndWait(commandType: .deleteFile) {
|
||
try await self.deviceInfoService.deleteFile(key: key)
|
||
}
|
||
let resp = try decodeResponse(DeleteFileResponse.self, from: data)
|
||
if resp.success != 0 {
|
||
BleLog.w("Delete failed with status=\(resp.success)", "Command")
|
||
throw DuooomiBleError.deleteFileFailed(status: resp.success)
|
||
}
|
||
BleLog.i("Delete success (key=\(key))", "Command")
|
||
return resp
|
||
}
|
||
|
||
/// 进行传输前的准备校验(空间、重复等)。
|
||
/// - Parameters:
|
||
/// - key: 用于标识该文件的唯一 key(可直接使用源文件 URL)。
|
||
/// - size: 即将传输的字节大小。
|
||
/// - Returns: 设备返回的准备状态,`status == "ready"` 表示可传。
|
||
/// - Throws: 未连接或设备拒绝等错误。
|
||
public func prepareTransfer(key: String, size: Int) async throws -> PrepareTransferResponse {
|
||
try ensureConnected()
|
||
let opId = "\(CommandType.prepareTransfer.rawValue)_\(key)"
|
||
BleLog.d("Sending prepareTransfer (key=\(key), size=\(size))", "Transfer")
|
||
let data = try await sendAndWait(opId: opId, command: .prepareTransfer) {
|
||
try await self.deviceInfoService.prepareTransfer(key: key, size: size)
|
||
}
|
||
let resp = try decodeResponse(PrepareTransferResponse.self, from: data)
|
||
if resp.status != "ready" {
|
||
BleLog.w("PrepareTransfer not ready: status=\(resp.status)", "Transfer")
|
||
throw DuooomiBleError.prepareTransferFailed(status: resp.status)
|
||
}
|
||
BleLog.i("PrepareTransfer ready (key=\(key))", "Transfer")
|
||
return resp
|
||
}
|
||
|
||
// MARK: - File Transfer
|
||
|
||
/// 传输文件到设备。
|
||
/// - Parameters:
|
||
/// - fileUri: `file://` 本地路径或 `https://` 远程 URL。
|
||
/// - commandType: 传输命令类型(如 `.transferAniVideo`、`.otaPackage`)。
|
||
/// - Throws: 下载失败、蓝牙发送失败等错误。
|
||
public func transferFile(
|
||
fileUri: String,
|
||
commandType: CommandType = .transferAniVideo
|
||
) async throws {
|
||
try ensureConnected()
|
||
transferProgress = 0
|
||
BleLog.i("Transfer start: uri=\(fileUri), cmd=\(commandType)", "Transfer")
|
||
|
||
try await fileTransferService.transferFile(
|
||
fileUri: fileUri,
|
||
commandType: commandType
|
||
) { [weak self] progress in
|
||
Task { @MainActor [weak self] in
|
||
self?.transferProgress = Int(progress * 100)
|
||
if let p = self?.transferProgress, p % 10 == 0 {
|
||
BleLog.d("Progress: \(p)%", "Transfer")
|
||
}
|
||
}
|
||
}
|
||
|
||
transferProgress = 100
|
||
BleLog.i("Transfer completed", "Transfer")
|
||
}
|
||
|
||
// MARK: - High-Level APIs
|
||
|
||
/// 传输媒体文件到设备(一步完成)。
|
||
///
|
||
/// 内部自动处理:视频/图片 → ANI 转换 → 获取大小 → prepareTransfer → transferFile。
|
||
/// - Parameter fileUrl: 文件的公网 URL(支持 mp4/jpg/png 等,会自动转 ANI;.ani 文件直接传输)。
|
||
/// - Throws: 转换失败、设备空间不足、传输失败等。
|
||
public func transferMedia(fileUrl: String) async throws {
|
||
try ensureConnected()
|
||
let url = fileUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !url.isEmpty else { throw DuooomiBleError.transferFailed("Empty file URL") }
|
||
|
||
let isAni = url.lowercased().hasSuffix(".ani")
|
||
let aniUrl: String
|
||
|
||
if isAni {
|
||
aniUrl = url
|
||
BleLog.i("Direct ANI transfer: \(url)", "Transfer")
|
||
} else {
|
||
BleLog.i("Converting to ANI: \(url)", "Transfer")
|
||
do {
|
||
aniUrl = try await aniConverter.convert(fileUrl: url)
|
||
} catch {
|
||
throw DuooomiBleError.transferFailed("ANI conversion failed: \(error.localizedDescription)")
|
||
}
|
||
BleLog.i("ANI ready: \(aniUrl)", "Transfer")
|
||
}
|
||
|
||
// 获取文件大小
|
||
let aniSize = try await getRemoteFileSize(url: aniUrl)
|
||
BleLog.d("ANI size: \(aniSize) bytes", "Transfer")
|
||
|
||
// 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<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
|
||
// Device responses may contain null bytes
|
||
let cleaned = data.filter { $0 != 0 }
|
||
do {
|
||
return try JSONDecoder().decode(type, from: cleaned)
|
||
} catch {
|
||
let raw = String(data: cleaned, encoding: .utf8) ?? "<non-utf8, \(cleaned.count) bytes>"
|
||
BleLog.e("JSON decode \(T.self) failed: \(error.localizedDescription)\nRaw: \(raw)", "Decode")
|
||
throw error
|
||
}
|
||
}
|
||
|
||
private func cancelAllPending(error: Error) {
|
||
let continuations = pendingContinuations
|
||
pendingContinuations.removeAll()
|
||
for (_, continuation) in continuations {
|
||
continuation.resume(throwing: error)
|
||
}
|
||
}
|
||
|
||
// MARK: - Scan Batching (500ms throttle)
|
||
|
||
private func queueDevice(_ device: DiscoveredDevice) {
|
||
guard !allDiscoveredDevices.contains(where: { $0.id == device.id }) else { return }
|
||
|
||
allDiscoveredDevices.append(device)
|
||
pendingDevices.append(device)
|
||
|
||
flushWorkItem?.cancel()
|
||
let workItem = DispatchWorkItem { [weak self] in
|
||
Task { @MainActor [weak self] in
|
||
self?.flushDevices()
|
||
}
|
||
}
|
||
flushWorkItem = workItem
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: workItem)
|
||
}
|
||
|
||
private func flushDevices() {
|
||
guard !pendingDevices.isEmpty else { return }
|
||
pendingDevices = []
|
||
discoveredDevices = allDiscoveredDevices
|
||
BleLog.d("Discovered devices flushed: total=\(discoveredDevices.count)", "Scan")
|
||
}
|
||
}
|