Files
duooomi-ios-sdk/Sources/DuooomiBleSDK/DuooomiBleSDK.swift
km2023 6ed3c555df feat(sdk): 日志等级 logLevel 配置化(默认 .off,对外发布完全静默)
BleLog 之前所有调用都无条件 print(Debug 包),集成方没法关。新增
public enum BleLogLevel(off/error/warn/info/debug),通过
DuooomiBleConfig.logLevel 传入,DuooomiBleSDK.init 第一行写入
BleLog.level。默认 .off 保证对外集成默认静默,排查时显式开 .debug。
保留 #if DEBUG 兜底,Release 任何 level 都不输出。

frameIntervalMs 越界 warn 从 DuooomiBleConfig.init 挪到
DuooomiBleSDK.init 内、level 设置之后,避免在 level 写入前丢日志。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:02:28 +08:00

429 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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` 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 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) {
BleLog.level = config.logLevel
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), logLevel=\(config.logLevel))", "SDK")
if config.frameIntervalMs < 0 || config.frameIntervalMs > 500 {
BleLog.w("frameIntervalMs=\(config.frameIntervalMs) out of [0,500], will clamp at use site", "Config")
}
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)
}
}