Files
TrackingDemo/NearbyDemo/Managers/BLEManager.swift

189 lines
7.8 KiB
Swift

import CoreBluetooth
import Combine
import Foundation
private extension Data {
var hexString: String { map { String(format: "%02x", $0) }.joined(separator: " ") }
}
enum ConnectionState {
case disconnected, scanning, connecting, connected
}
class BLEManager: NSObject, ObservableObject {
@Published var connectionState: ConnectionState = .disconnected
/// Called with raw data when the TX characteristic notifies.
var onAccessoryData: ((Data) -> Void)?
/// Called when a peripheral successfully connects, passing its UUID.
var onConnected: ((UUID) -> Void)?
/// The identifier of the currently connected peripheral, if any.
private(set) var connectedPeripheralIdentifier: UUID?
private var centralManager: CBCentralManager!
private var peripheral: CBPeripheral?
private var rxCharacteristic: CBCharacteristic?
private var txCharacteristic: CBCharacteristic?
// Whether to scan all peripherals (fallback) or filter by service UUID.
private var broadScan = false
override init() {
super.init()
centralManager = CBCentralManager(delegate: self, queue: nil)
}
func startScanning() {
guard centralManager.state == .poweredOn else {
// Will be triggered again in centralManagerDidUpdateState
return
}
connectionState = .scanning
// Try targeted scan first
broadScan = false
centralManager.scanForPeripherals(withServices: [QorvoBLEUUIDs.niService], options: nil)
print("[BLE] Scanning for NI service peripherals…")
// Fall back to broad scan after 5 seconds if nothing found
DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak self] in
guard let self, self.connectionState == .scanning else { return }
print("[BLE] No service-filtered result. Falling back to broad scan.")
self.broadScan = true
self.centralManager.stopScan()
self.centralManager.scanForPeripherals(withServices: nil, options: nil)
}
}
func stopScanning() {
centralManager.stopScan()
}
func sendToAccessory(_ data: Data) {
guard let peripheral, let rx = rxCharacteristic else {
print("[BLE] sendToAccessory: no peripheral/rx, dropping \(data.hexString)")
return
}
// Use withoutResponse for small messages only the iOS BLE stack silently
// drops withoutResponse writes that exceed maximumWriteValueLength (often 20
// bytes before MTU negotiation). For larger payloads (e.g. 0x0B + config)
// use withResponse, which the nRF52 SoftDevice will accept up to 247 bytes.
let maxNoRsp = peripheral.maximumWriteValueLength(for: .withoutResponse)
let canUseNoRsp = rx.properties.contains(.writeWithoutResponse) && data.count <= maxNoRsp
let writeType: CBCharacteristicWriteType = canUseNoRsp ? .withoutResponse : .withResponse
print("[BLE] → TX \(data.count) bytes (MTU max=\(maxNoRsp), type=\(writeType == .withResponse ? "rsp" : "no-rsp")): \(data.hexString)")
peripheral.writeValue(data, for: rx, type: writeType)
}
func disconnect() {
guard let peripheral else { return }
centralManager.cancelPeripheralConnection(peripheral)
}
}
// MARK: - CBCentralManagerDelegate
extension BLEManager: CBCentralManagerDelegate {
func centralManagerDidUpdateState(_ central: CBCentralManager) {
if central.state == .poweredOn {
startScanning()
} else {
connectionState = .disconnected
print("[BLE] Central state: \(central.state.rawValue)")
}
}
func centralManager(_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi RSSI: NSNumber) {
// When doing a broad scan, match by name prefix
if broadScan {
let name = peripheral.name ?? ""
guard name.hasPrefix("Qorvo") || name.hasPrefix("DWM") || name.contains("UWB") else { return }
}
print("[BLE] Discovered: \(peripheral.name ?? "unknown") \(peripheral.identifier)")
centralManager.stopScan()
self.peripheral = peripheral
connectionState = .connecting
centralManager.connect(peripheral, options: nil)
}
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
print("[BLE] Connected to \(peripheral.name ?? peripheral.identifier.uuidString)")
connectionState = .connected
connectedPeripheralIdentifier = peripheral.identifier
peripheral.delegate = self
peripheral.discoverServices([QorvoBLEUUIDs.niService])
// onConnected is deferred until TX notifications are confirmed enabled
}
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
print("[BLE] Failed to connect: \(error?.localizedDescription ?? "unknown")")
connectionState = .disconnected
self.peripheral = nil
}
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
print("[BLE] Disconnected: \(error?.localizedDescription ?? "clean")")
connectionState = .disconnected
connectedPeripheralIdentifier = nil
self.peripheral = nil
rxCharacteristic = nil
txCharacteristic = nil
}
}
// MARK: - CBPeripheralDelegate
extension BLEManager: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
guard error == nil else {
print("[BLE] Service discovery error: \(error!)")
return
}
for service in peripheral.services ?? [] {
print("[BLE] Discovered service: \(service.uuid)")
peripheral.discoverCharacteristics([QorvoBLEUUIDs.rxCharacteristic, QorvoBLEUUIDs.txCharacteristic], for: service)
}
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
guard error == nil else {
print("[BLE] Characteristic discovery error: \(error!)")
return
}
for characteristic in service.characteristics ?? [] {
print("[BLE] Discovered characteristic: \(characteristic.uuid) props: \(characteristic.properties.rawValue)")
if characteristic.uuid == QorvoBLEUUIDs.rxCharacteristic {
rxCharacteristic = characteristic
} else if characteristic.uuid == QorvoBLEUUIDs.txCharacteristic {
txCharacteristic = characteristic
peripheral.setNotifyValue(true, for: characteristic)
}
}
}
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
if let error {
print("[BLE] Notify enable error on \(characteristic.uuid): \(error)")
return
}
guard characteristic.uuid == QorvoBLEUUIDs.txCharacteristic, characteristic.isNotifying else { return }
print("[BLE] TX notifications enabled — ready for NI handshake")
// Both characteristics discovered and notifications live: signal ready
onConnected?(peripheral.identifier)
}
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
guard error == nil, let data = characteristic.value else { return }
print("[BLE] ← RX \(data.count) bytes: \(data.hexString)")
onAccessoryData?(data)
}
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
if let error {
print("[BLE] Write error on \(characteristic.uuid): \(error)")
}
}
}