Files
duooomi-ios-sdk/Sources/DuooomiBleSDK/Core/BleClient.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

316 lines
13 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
/// BLE
protocol BleClientDelegate: AnyObject {
func bleClient(_ client: BleClient, didDiscoverDevice device: DiscoveredDevice)
func bleClient(_ client: BleClient, didReceiveData data: Data)
}
final class BleClient: NSObject {
weak var delegate: BleClientDelegate?
var onConnectionStateChange: ((ConnectionState) -> Void)?
var onDisconnected: (() -> Void)?
private let queue = DispatchQueue(label: "com.duooomi.ble.client", qos: .userInitiated)
private var centralManager: CBCentralManager!
private var connectedPeripheral: CBPeripheral?
private var writeCharacteristic: CBCharacteristic?
private var readCharacteristic: CBCharacteristic?
// Connect/Disconnect callbacks
private var connectCompletion: ((Result<CBPeripheral, Error>) -> Void)?
private var disconnectCompletion: ((Result<Void, Error>) -> Void)?
private var connectTimeoutWork: DispatchWorkItem?
/// characteristic service service didDiscoverCharacteristicsFor
private var servicesPendingCharDiscover: Int = 0
/// included services service secondary service
private var servicesPendingIncludedDiscover: Int = 0
/// characteristic service uuidString service
private var charDiscoverDispatched: Set<String> = []
var bluetoothState: CBManagerState {
centralManager.state
}
var maximumWriteLength: Int {
connectedPeripheral?.maximumWriteValueLength(for: .withoutResponse) ?? 182
}
override init() {
super.init()
centralManager = CBCentralManager(delegate: self, queue: queue)
}
// MARK: - Scan
func scan(serviceUUIDs: [CBUUID] = [BleUUIDs.service]) {
guard centralManager.state == .poweredOn else {
BleLog.w("Bluetooth not powered on", "BLE")
return
}
centralManager.scanForPeripherals(
withServices: serviceUUIDs,
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
)
}
func stopScan() {
centralManager.stopScan()
}
// MARK: - Connect
func connect(deviceId: String, timeout: TimeInterval = 30, completion: @escaping (Result<CBPeripheral, Error>) -> Void) {
guard centralManager.state == .poweredOn else {
completion(.failure(DuooomiBleError.bluetoothNotPoweredOn("Bluetooth not powered on")))
return
}
let peripherals = centralManager.retrievePeripherals(withIdentifiers: [UUID(uuidString: deviceId)!])
guard let peripheral = peripherals.first else {
completion(.failure(DuooomiBleError.connectionFailed("Device not found: \(deviceId)")))
return
}
self.connectCompletion = completion
self.servicesPendingCharDiscover = 0
self.servicesPendingIncludedDiscover = 0
self.charDiscoverDispatched.removeAll()
peripheral.delegate = self
centralManager.connect(peripheral, options: nil)
// Timeout
let work = DispatchWorkItem { [weak self] in
guard let self = self, self.connectCompletion != nil else { return }
self.centralManager.cancelPeripheralConnection(peripheral)
let cb = self.connectCompletion
self.connectCompletion = nil
cb?(.failure(DuooomiBleError.connectionFailed("Connection timeout")))
}
connectTimeoutWork = work
queue.asyncAfter(deadline: .now() + timeout, execute: work)
}
// MARK: - Disconnect
func disconnect(completion: @escaping (Result<Void, Error>) -> Void) {
guard let peripheral = connectedPeripheral else {
completion(.success(()))
return
}
disconnectCompletion = completion
centralManager.cancelPeripheralConnection(peripheral)
}
// MARK: - Write
func writeWithoutResponse(_ data: Data) throws {
guard let peripheral = connectedPeripheral, let char = writeCharacteristic else {
throw DuooomiBleError.notConnected
}
peripheral.writeValue(data, for: char, type: .withoutResponse)
}
// MARK: - Retrieve Connected
func retrieveConnectedDevices() -> [DiscoveredDevice] {
centralManager.retrieveConnectedPeripherals(withServices: [BleUUIDs.service]).map {
DiscoveredDevice(id: $0.identifier.uuidString, name: $0.name, rssi: 0)
}
}
}
// MARK: - CBCentralManagerDelegate
extension BleClient: CBCentralManagerDelegate {
func centralManagerDidUpdateState(_ central: CBCentralManager) {
BleLog.i("Bluetooth state: \(central.state.rawValue)", "BLE")
}
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any], rssi RSSI: NSNumber) {
let device = DiscoveredDevice(
id: peripheral.identifier.uuidString,
name: peripheral.name,
rssi: RSSI.intValue
)
delegate?.bleClient(self, didDiscoverDevice: device)
}
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
connectTimeoutWork?.cancel()
connectTimeoutWork = nil
connectedPeripheral = peripheral
peripheral.delegate = self
// service expo discoverAllServicesAndCharacteristics
// UUID didDiscoverServices
peripheral.discoverServices(nil)
}
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
connectTimeoutWork?.cancel()
connectTimeoutWork = nil
let cb = connectCompletion
connectCompletion = nil
cb?(.failure(error ?? DuooomiBleError.connectionFailed("Unknown")))
}
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
connectedPeripheral = nil
writeCharacteristic = nil
readCharacteristic = nil
if let cb = disconnectCompletion {
disconnectCompletion = nil
if let error = error {
cb(.failure(error))
} else {
cb(.success(()))
}
}
DispatchQueue.main.async { [weak self] in
self?.onDisconnected?()
}
}
}
// MARK: - CBPeripheralDelegate
extension BleClient: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
if let error = error {
let cb = connectCompletion
connectCompletion = nil
cb?(.failure(error))
return
}
let services = peripheral.services ?? []
BleLog.d("Discovered \(services.count) primary service(s): \(services.map { $0.uuid.uuidString })", "BLE")
// CoreBluetooth `discoverServices(nil)` primary service
// service included servicereact-native-ble-plx
// primary service
// 1. included services
// 2. characteristics
for s in services {
scheduleServiceFullDiscovery(peripheral: peripheral, service: s)
}
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverIncludedServicesFor service: CBService, error: Error?) {
servicesPendingIncludedDiscover = max(0, servicesPendingIncludedDiscover - 1)
if let error = error {
BleLog.w("Included service discover error for \(service.uuid.uuidString): \(error.localizedDescription)", "BLE")
} else {
let included = service.includedServices ?? []
if !included.isEmpty {
BleLog.d(" [Service \(service.uuid.uuidString)] includes \(included.count) sub-service(s): \(included.map { $0.uuid.uuidString })", "BLE")
}
for sub in included {
scheduleServiceFullDiscovery(peripheral: peripheral, service: sub)
}
}
evaluateConnectCompletionIfReady()
}
/// service included services + characteristics
private func scheduleServiceFullDiscovery(peripheral: CBPeripheral, service: CBService) {
let key = service.uuid.uuidString.lowercased()
if charDiscoverDispatched.contains(key) { return }
charDiscoverDispatched.insert(key)
servicesPendingIncludedDiscover += 1
peripheral.discoverIncludedServices(nil, for: service)
servicesPendingCharDiscover += 1
peripheral.discoverCharacteristics(nil, for: service)
}
/// included service characteristic
private func evaluateConnectCompletionIfReady() {
guard servicesPendingIncludedDiscover == 0, servicesPendingCharDiscover == 0 else { return }
guard connectCompletion != nil, let peripheral = connectedPeripheral else { return }
if writeCharacteristic == nil || readCharacteristic == nil {
BleLog.e("Required characteristics not found. SDK service=\(BleUUIDs.service.uuidString), write=\(BleUUIDs.writeCharacteristic.uuidString), read=\(BleUUIDs.readCharacteristic.uuidString). 检查上方 [Service ...] Char 列表,把固件实际使用的 UUID 反馈给 SDK。", "BLE")
let cb = connectCompletion
connectCompletion = nil
cb?(.failure(DuooomiBleError.serviceNotFound))
return
}
let cb = connectCompletion
connectCompletion = nil
cb?(.success(peripheral))
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
servicesPendingCharDiscover = max(0, servicesPendingCharDiscover - 1)
if let error = error {
BleLog.w("Char discover error for service \(service.uuid.uuidString): \(error.localizedDescription)", "BLE")
} else {
// service characteristic write/notify/read
for c in service.characteristics ?? [] {
let props = Self.describeProps(c.properties)
BleLog.d(" [Service \(service.uuid.uuidString)] Char \(c.uuid.uuidString) [\(props)]", "BLE")
}
// service characteristic
if Self.uuidEqual(service.uuid, BleUUIDs.service) {
for char in service.characteristics ?? [] {
if Self.uuidEqual(char.uuid, BleUUIDs.writeCharacteristic) {
writeCharacteristic = char
} else if Self.uuidEqual(char.uuid, BleUUIDs.readCharacteristic) {
readCharacteristic = char
peripheral.setNotifyValue(true, for: char)
}
}
}
}
evaluateConnectCompletionIfReady()
}
private static func describeProps(_ p: CBCharacteristicProperties) -> String {
var bits: [String] = []
if p.contains(.read) { bits.append("Read") }
if p.contains(.write) { bits.append("Write") }
if p.contains(.writeWithoutResponse) { bits.append("WriteNoResp") }
if p.contains(.notify) { bits.append("Notify") }
if p.contains(.indicate) { bits.append("Indicate") }
return bits.joined(separator: ",")
}
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
guard error == nil, let data = characteristic.value else { return }
delegate?.bleClient(self, didReceiveData: data)
}
// MARK: - UUID
/// CoreBluetooth service.uuid base UUID UUID 16-bit
/// `"02C4"` `CBUUID(string: "000002c4-0000-1000-8000-00805f9b34fb")` `==`
/// `data` 128-bit
static func uuidEqual(_ a: CBUUID, _ b: CBUUID) -> Bool {
if a == b { return true }
return Self.canonicalUUIDString(a) == Self.canonicalUUIDString(b)
}
private static func canonicalUUIDString(_ uuid: CBUUID) -> String {
let s = uuid.uuidString.lowercased()
switch s.count {
case 4:
return "0000\(s)-0000-1000-8000-00805f9b34fb"
case 8:
return "\(s)-0000-1000-8000-00805f9b34fb"
default:
return s
}
}
}