Files
duooomi-ios-sdk/Sources/DuooomiBleSDK/DuooomiBleSDK.swift
km2023 d5729a1a41 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>
2026-05-08 14:46:41 +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), 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)
}
}
// 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)
}
}