Files
duooomi-ios-sdk/Sources/DuooomiBleSDK/DuooomiBleSDK.swift
km2023 97e5b8359a feat(sdk): 重命名 owner→brand 并在硬绑前校验设备品牌
- DuooomiBleConfig.owner → brand(语义:设备品牌名),HTTP header x-owner 不变
- bind() 前置 verifyBrand:缺 deviceInfo 先 getDeviceInfo,与 config.brand 严格匹配,不一致主动断连并抛 .brandMismatch
- README 抹除软绑/对账/UpgradeRecord 等内部实现细节,仅保留厂商面向的公开 API(功能代码完全保留)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:51:17 +08:00

420 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) } }
}
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)
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.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)
}
}