DuooomiBleConfig 新增 frameIntervalMs,BleProtocolService 构造期注入。 顺手清死代码 FrameConstants.frameIntervalNanos、修 sendFrames 0.035 字面量 (统一走 config)。demo 加可调输入框 + UserDefaults 持久化。 越界 warn 不抛错,使用点 min/max 兜底。 设计稿:docs/superpowers/specs/2026-05-20-frame-interval-config-design.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
425 lines
16 KiB
Swift
425 lines
16 KiB
Swift
import CoreBluetooth
|
||
import Foundation
|
||
|
||
/// Duooomi BLE SDK - 纯原生 iOS 蓝牙 SDK
|
||
///
|
||
/// 提供设备扫描、连接、命令交互、文件传输等原子蓝牙操作。
|
||
/// 业务编排(双层绑定 / OTA 检查 / 升级对账上报)由 SDK 主类内部完成,集成方仅与公开 API 交互。
|
||
///
|
||
/// 文件分层(按职责拆分;类即门面,对外 API 形态不变):
|
||
/// - `DuooomiBleSDK.swift` 状态属性 / init / 扫描 / RPC helpers / delegate 实现
|
||
/// - `DuooomiBleSDK+Connection.swift` 连接 / 断开
|
||
/// - `DuooomiBleSDK+Commands.swift` 纯协议层 RPC(getDeviceInfo / getVersion / setPlayMode / deleteFile / prepareTransfer)
|
||
/// - `DuooomiBleSDK+Binding.swift` 双层绑定编排(硬绑 → getVersion → OTA 检查/对账 → 软绑,失败回滚)
|
||
/// - `DuooomiBleSDK+Firmware.swift` OTA 检查 / 安装 / 升级对账上报
|
||
/// - `DuooomiBleSDK+Transfer.swift` 文件传输(mime 校验 / ANI 转换 / 远程 size 探测)
|
||
///
|
||
/// 注:`scan()` / `stopScan()` 留在主类不放 extension:name 与 `Combine.Publisher.scan(_:_:)` 冲突,
|
||
/// 当 SDK 实例被 `@Published` 包装时 extension 上的 `scan` 会被 Publisher.scan 抢占名字解析。
|
||
public final class DuooomiBleSDK: NSObject {
|
||
|
||
// MARK: - Observable State
|
||
|
||
public weak var delegate: DuooomiBleSDKDelegate?
|
||
|
||
public internal(set) var btState: ConnectionState = .idle {
|
||
didSet {
|
||
guard btState != oldValue else { return }
|
||
notifyMain { self.delegate?.sdk(self, didChangeState: self.btState) }
|
||
}
|
||
}
|
||
|
||
public internal(set) var connectedDevice: DiscoveredDevice? = nil {
|
||
didSet { notifyMain { self.delegate?.sdk(self, didConnectDevice: self.connectedDevice) } }
|
||
}
|
||
|
||
public internal(set) var deviceInfo: DeviceInfo? = nil {
|
||
didSet { notifyMain { self.delegate?.sdk(self, didUpdateDeviceInfo: self.deviceInfo) } }
|
||
}
|
||
|
||
public internal(set) var version: String = "" {
|
||
didSet { notifyMain { self.delegate?.sdk(self, didUpdateVersion: self.version) } }
|
||
}
|
||
|
||
/// 设备返回的 `log` 字段(任意 JSON 对象)。`getVersion` 成功时写入,断连/解绑时清空。
|
||
/// `fetchLatestFirmware` 软绑后透传给 shipment-sn `update-metadata`,集成方一般无需直接读。
|
||
public internal(set) var versionLog: [String: Any]?
|
||
|
||
public internal(set) var isActivated: Bool = false {
|
||
didSet {
|
||
guard isActivated != oldValue else { return }
|
||
notifyMain { self.delegate?.sdk(self, didChangeActivation: self.isActivated) }
|
||
}
|
||
}
|
||
|
||
/// 当前已绑定设备的 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 internal(set) var error: String? = nil {
|
||
didSet { notifyMain { self.delegate?.sdk(self, didEncounterError: self.error) } }
|
||
}
|
||
|
||
public internal(set) var discoveredDevices: [DiscoveredDevice] = [] {
|
||
didSet { notifyMain { self.delegate?.sdk(self, didUpdateDevices: self.discoveredDevices) } }
|
||
}
|
||
|
||
// MARK: - Configuration
|
||
|
||
public let config: DuooomiBleConfig
|
||
|
||
// MARK: - Internal Services (extension 文件按职责调用)
|
||
|
||
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
|
||
|
||
/// 服务端最新一次 `fetchLatestFirmware` 结果
|
||
public internal(set) var firmwareUpgrade: FirmwareUpgradeCheckResult? = nil
|
||
|
||
/// 当前 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
|
||
|
||
var scanDelegate: ScanBleClientDelegate?
|
||
var allDiscoveredDevices: [DiscoveredDevice] = []
|
||
var pendingDevices: [DiscoveredDevice] = []
|
||
var flushWorkItem: DispatchWorkItem?
|
||
|
||
// MARK: - Init
|
||
|
||
public init(config: DuooomiBleConfig) {
|
||
self.config = config
|
||
bleClient = BleClient()
|
||
protocolService = BleProtocolService(client: bleClient, frameIntervalMs: config.frameIntervalMs)
|
||
timeProvider = BeijingTimeProvider()
|
||
deviceInfoService = DeviceInfoService(protocolService: protocolService, timeProvider: timeProvider)
|
||
fileTransferService = FileTransferService(protocolService: protocolService)
|
||
aniConverter = AniConverter(config: config)
|
||
shipmentService = ShipmentSnDeviceService(config: config)
|
||
firmwareUpgradeService = FirmwareUpgradeService(config: config)
|
||
upgradeRecordStore = UpgradeRecordStore()
|
||
|
||
super.init()
|
||
|
||
protocolService.delegate = self
|
||
BleLog.i("SDK initialized (apiHost=\(config.apiHost), brand=\(config.brand))", "SDK")
|
||
setupDisconnectHandler()
|
||
|
||
// UpgradeRecord 变化(含对账上报结果)→ delegate 通知
|
||
upgradeRecordStore.onChange = { [weak self] records in
|
||
guard let self = self else { return }
|
||
self.delegate?.sdk(self, didUpdateUpgradeRecords: records)
|
||
}
|
||
}
|
||
|
||
// 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")
|
||
self.connectedDevice = nil
|
||
self.deviceInfo = nil
|
||
self.version = ""
|
||
self.versionLog = nil
|
||
self.isActivated = false
|
||
self.btState = .disconnected
|
||
self.protocolService.stopListening()
|
||
self.cancelAllPending(error: DuooomiBleError.notConnected)
|
||
}
|
||
}
|
||
|
||
// MARK: - Request-Response Pattern
|
||
|
||
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)
|
||
}
|
||
|
||
/// 异步 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,
|
||
completion: @escaping (Result<Data, Error>) -> Void,
|
||
send: @escaping () throws -> Void
|
||
) {
|
||
BleLog.d("Register opId=\(opId), cmd=\(commandType)", "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)
|
||
|
||
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
|
||
|
||
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
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
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))
|
||
}
|
||
}
|
||
}
|
||
|
||
func notifyMain(_ block: @escaping () -> Void) {
|
||
if Thread.isMainThread {
|
||
block()
|
||
} else {
|
||
DispatchQueue.main.async(execute: block)
|
||
}
|
||
}
|
||
|
||
// MARK: - Scan Batching (500ms throttle)
|
||
|
||
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)
|
||
|
||
flushWorkItem?.cancel()
|
||
let workItem = DispatchWorkItem { [weak self] in
|
||
self?.flushDevices()
|
||
}
|
||
flushWorkItem = workItem
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: workItem)
|
||
}
|
||
|
||
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)"
|
||
|
||
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.
|
||
final 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)
|
||
}
|
||
}
|