主要改动: - 新增 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>
316 lines
13 KiB
Swift
316 lines
13 KiB
Swift
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 service(react-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
|
||
}
|
||
}
|
||
}
|