demo app + SDK updates:\n- Add direct ANI transfer (prepare -> transfer)\n- Add firmware update flow (fetch latest w/ x-api-key, OTA transfer)\n- Extract x-api-key to NetworkConstants\n- Rename example app to demo; move folder and entry to DemoApp\n- Switch from SPM package dependency to local framework target\n- Enable automatic Info.plist generation for framework target\n- Add placeholder test to satisfy SwiftPM test target\n- Regenerate XcodeGen project (demo.xcodeproj)

This commit is contained in:
km2023
2026-04-09 18:41:56 +08:00
parent c33e7d3fa1
commit 7d9ff3081d
30 changed files with 2594 additions and 516 deletions

View File

@@ -0,0 +1,341 @@
import CoreBluetooth
import Foundation
/// CoreBluetooth async API
final class BleClient: NSObject, @unchecked Sendable {
private var centralManager: CBCentralManager!
private let queue = DispatchQueue(label: "com.duooomi.ble.client")
private(set) var connectedPeripheral: CBPeripheral?
private(set) var writeCharacteristic: CBCharacteristic?
private(set) var readCharacteristic: CBCharacteristic?
/// (scan )
private var discoveredPeripherals: [UUID: CBPeripheral] = [:]
// MARK: - Continuations
private var connectContinuation: CheckedContinuation<CBPeripheral, Error>?
private var disconnectContinuation: CheckedContinuation<Void, Error>?
// MARK: - Streams
private var scanContinuation: AsyncStream<DiscoveredDevice>.Continuation?
private var notificationContinuation: AsyncStream<Data>.Continuation?
// MARK: - Callbacks
var onConnectionStateChange: ((ConnectionState) -> Void)?
var onDisconnected: (() -> Void)?
// MARK: - Init
override init() {
super.init()
centralManager = CBCentralManager(delegate: self, queue: queue)
BleLog.i("BleClient initialized", "BLE")
}
// MARK: - Public API
///
var bluetoothState: CBManagerState {
centralManager.state
}
/// `.withoutResponse`
var maximumWriteLength: Int {
connectedPeripheral?.maximumWriteValueLength(for: .withoutResponse) ?? 20
}
/// Service
/// - Parameter serviceUUIDs: Service UUID 使 SDK
/// - Returns:
func scan(serviceUUIDs: [CBUUID] = [BleUUIDs.service]) -> AsyncStream<DiscoveredDevice> {
stopScan()
discoveredPeripherals.removeAll()
return AsyncStream { continuation in
self.scanContinuation = continuation
continuation.onTermination = { [weak self] _ in
self?.queue.async {
self?.centralManager.stopScan()
self?.scanContinuation = nil
}
}
self.queue.async {
guard self.centralManager.state == .poweredOn else { return }
BleLog.i("CBCentral startScan", "BLE")
self.centralManager.scanForPeripherals(
withServices: serviceUUIDs,
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
)
}
}
}
///
func stopScan() {
queue.async { [weak self] in
self?.centralManager.stopScan()
BleLog.i("CBCentral stopScan", "BLE")
}
scanContinuation?.finish()
scanContinuation = nil
}
///
/// - Parameters:
/// - deviceId: UUID
/// - timeout: 30
/// - Returns: `CBPeripheral`
/// - Throws:
func connect(deviceId: String, timeout: TimeInterval = 30) async throws -> CBPeripheral {
guard centralManager.state == .poweredOn else {
throw DuooomiBleError.bluetoothNotPoweredOn(String(describing: centralManager.state))
}
guard let uuid = UUID(uuidString: deviceId),
let peripheral = discoveredPeripherals[uuid] else {
throw DuooomiBleError.connectionFailed("Device not found: \(deviceId)")
}
return try await withCheckedThrowingContinuation { continuation in
self.connectContinuation = continuation
self.queue.async {
BleLog.i("Connecting to peripheral: \(peripheral.identifier.uuidString)", "BLE")
self.centralManager.connect(peripheral, options: nil)
}
// Timeout
self.queue.asyncAfter(deadline: .now() + timeout) { [weak self] in
if let cont = self?.connectContinuation {
self?.connectContinuation = nil
self?.centralManager.cancelPeripheralConnection(peripheral)
BleLog.w("Connection timeout: \(peripheral.identifier.uuidString)", "BLE")
cont.resume(throwing: DuooomiBleError.connectionFailed("Connection timeout after \(Int(timeout))s"))
}
}
}
}
///
/// - Throws:
func disconnect() async throws {
guard let peripheral = connectedPeripheral else { return }
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
self.disconnectContinuation = continuation
self.queue.async {
BleLog.i("Cancel connection: \(peripheral.identifier.uuidString)", "BLE")
self.centralManager.cancelPeripheralConnection(peripheral)
}
// Timeout for disconnect
self.queue.asyncAfter(deadline: .now() + 5) { [weak self] in
if let cont = self?.disconnectContinuation {
self?.disconnectContinuation = nil
BleLog.w("Force-finish disconnect after timeout", "BLE")
self?.cleanup()
cont.resume()
}
}
}
}
/// GATT Write Without Response
/// - Parameter data:
/// - Throws:
func writeWithoutResponse(_ data: Data) throws {
guard let peripheral = connectedPeripheral,
let characteristic = writeCharacteristic else {
throw DuooomiBleError.notConnected
}
BleLog.d("Write w/o response, len=\(data.count)", "BLE")
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
}
/// Service
/// - Returns:
func retrieveConnectedDevices() -> [DiscoveredDevice] {
let peripherals = centralManager.retrieveConnectedPeripherals(withServices: [BleUUIDs.service])
return peripherals.map { peripheral in
DiscoveredDevice(
id: peripheral.identifier.uuidString,
name: peripheral.name,
rssi: 0
)
}
}
///
/// - Returns:
func notifications() -> AsyncStream<Data> {
AsyncStream { continuation in
self.notificationContinuation = continuation
continuation.onTermination = { [weak self] _ in
self?.notificationContinuation = nil
}
BleLog.i("Notifications stream attached", "BLE")
}
}
// MARK: - Private
private func cleanup() {
if let readChar = readCharacteristic, let peripheral = connectedPeripheral {
peripheral.setNotifyValue(false, for: readChar)
}
connectedPeripheral = nil
writeCharacteristic = nil
readCharacteristic = nil
notificationContinuation?.finish()
notificationContinuation = nil
BleLog.i("Client state cleaned up", "BLE")
}
}
// MARK: - CBCentralManagerDelegate
extension BleClient: CBCentralManagerDelegate {
func centralManagerDidUpdateState(_ central: CBCentralManager) {
// If scanning was requested before poweredOn, start now
if central.state == .poweredOn, scanContinuation != nil {
BleLog.d("State poweredOn; resume scan", "BLE")
central.scanForPeripherals(
withServices: [BleUUIDs.service],
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
)
}
BleLog.d("Central state=\(central.state.rawValue)", "BLE")
}
func centralManager(
_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi RSSI: NSNumber
) {
discoveredPeripherals[peripheral.identifier] = peripheral
let device = DiscoveredDevice(
id: peripheral.identifier.uuidString,
name: peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String,
rssi: RSSI.intValue
)
BleLog.d("Did discover: id=\(device.id), rssi=\(device.rssi)", "BLE")
scanContinuation?.yield(device)
}
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
connectedPeripheral = peripheral
peripheral.delegate = self
BleLog.i("Did connect: \(peripheral.identifier.uuidString)", "BLE")
peripheral.discoverServices([BleUUIDs.service])
}
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
let cont = connectContinuation
connectContinuation = nil
BleLog.e("Fail to connect: \(peripheral.identifier.uuidString), error=\(error?.localizedDescription ?? "-")", "BLE")
cont?.resume(throwing: DuooomiBleError.connectionFailed(error?.localizedDescription ?? "Unknown error"))
}
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
// If disconnect happens mid-setup (after didConnect but before discoverCharacteristics
// completes), the connect continuation must be resolved or the caller hangs forever.
if let connectCont = connectContinuation {
connectContinuation = nil
connectCont.resume(throwing: DuooomiBleError.connectionFailed(
error?.localizedDescription ?? "Disconnected during setup"
))
}
cleanup()
if let cont = disconnectContinuation {
disconnectContinuation = nil
cont.resume()
}
BleLog.w("Did disconnect: \(peripheral.identifier.uuidString), error=\(error?.localizedDescription ?? "-")", "BLE")
onDisconnected?()
}
}
// MARK: - CBPeripheralDelegate
extension BleClient: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
if let error = error {
let cont = connectContinuation
connectContinuation = nil
BleLog.e("Discover services error: \(error.localizedDescription)", "BLE")
cont?.resume(throwing: DuooomiBleError.connectionFailed(error.localizedDescription))
return
}
guard let service = peripheral.services?.first(where: { $0.uuid == BleUUIDs.service }) else {
let cont = connectContinuation
connectContinuation = nil
BleLog.e("Service not found", "BLE")
cont?.resume(throwing: DuooomiBleError.serviceNotFound)
return
}
BleLog.d("Service found: \(service.uuid.uuidString)", "BLE")
peripheral.discoverCharacteristics(
[BleUUIDs.writeCharacteristic, BleUUIDs.readCharacteristic],
for: service
)
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
if let error = error {
let cont = connectContinuation
connectContinuation = nil
BleLog.e("Discover characteristics error: \(error.localizedDescription)", "BLE")
cont?.resume(throwing: DuooomiBleError.connectionFailed(error.localizedDescription))
return
}
guard let characteristics = service.characteristics else {
let cont = connectContinuation
connectContinuation = nil
cont?.resume(throwing: DuooomiBleError.characteristicNotFound)
return
}
for char in characteristics {
if char.uuid == BleUUIDs.writeCharacteristic {
writeCharacteristic = char
} else if char.uuid == BleUUIDs.readCharacteristic {
readCharacteristic = char
peripheral.setNotifyValue(true, for: char)
}
}
guard writeCharacteristic != nil, readCharacteristic != nil else {
let cont = connectContinuation
connectContinuation = nil
BleLog.e("Required characteristics missing", "BLE")
cont?.resume(throwing: DuooomiBleError.characteristicNotFound)
return
}
// Connection fully ready
let cont = connectContinuation
connectContinuation = nil
BleLog.i("Characteristics ready; connection established", "BLE")
cont?.resume(returning: peripheral)
}
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
guard error == nil, characteristic.uuid == BleUUIDs.readCharacteristic,
let value = characteristic.value else { return }
BleLog.d("Notification received, len=\(value.count)", "BLE")
notificationContinuation?.yield(value)
}
}

View File

@@ -0,0 +1,69 @@
import Foundation
public enum ConnectionState: String, Sendable {
case idle
case scanning
case connecting
case connected
case disconnecting
case disconnected
}
public struct DiscoveredDevice: Identifiable, Sendable, Equatable {
public let id: String
public let name: String?
public let rssi: Int
public init(id: String, name: String?, rssi: Int) {
self.id = id
self.name = name
self.rssi = rssi
}
}
public enum DuooomiBleError: LocalizedError, Sendable {
case bluetoothNotPoweredOn(String)
case notConnected
case timeout(command: CommandType)
case connectionFailed(String)
case serviceNotFound
case characteristicNotFound
case bindingFailed(String)
case unbindFailed
case deleteFileFailed(status: Int)
case prepareTransferFailed(status: String)
case transferFailed(String)
case invalidFrame
case permissionDenied
public var errorDescription: String? {
switch self {
case .bluetoothNotPoweredOn(let state):
return "Bluetooth is not powered on. State: \(state)"
case .notConnected:
return "No device connected"
case .timeout(let command):
return "Operation timeout: \(command)"
case .connectionFailed(let reason):
return "Connection failed: \(reason)"
case .serviceNotFound:
return "BLE service not found"
case .characteristicNotFound:
return "BLE characteristic not found"
case .bindingFailed(let reason):
return "Binding failed: \(reason)"
case .unbindFailed:
return "Unbind failed"
case .deleteFileFailed(let status):
return "Delete file failed with status: \(status)"
case .prepareTransferFailed(let status):
return "Prepare transfer failed: \(status)"
case .transferFailed(let reason):
return "Transfer failed: \(reason)"
case .invalidFrame:
return "Invalid protocol frame"
case .permissionDenied:
return "Bluetooth permission denied"
}
}
}