// LoopFollow // BluetoothDevice.swift import CoreBluetooth import Foundation import os class BluetoothDevice: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate { weak var bluetoothDeviceDelegate: BluetoothDeviceDelegate? private(set) var deviceAddress: String private(set) var deviceName: String? private let CBUUID_Advertisement: String? private let servicesCBUUIDs: [CBUUID]? private let CBUUID_ReceiveCharacteristic: String private var centralManager: CBCentralManager? private var peripheral: CBPeripheral? private var timeStampLastStatusUpdate: Date private var receiveCharacteristic: CBCharacteristic? private let maxTimeToWaitForPeripheralResponse = 5.0 private var connectTimeOutTimer: Timer? var lastHeartbeatTime: Date? init(address: String, name: String?, CBUUID_Advertisement: String?, servicesCBUUIDs: [CBUUID]?, CBUUID_ReceiveCharacteristic: String, bluetoothDeviceDelegate: BluetoothDeviceDelegate) { lastHeartbeatTime = nil deviceAddress = address deviceName = name self.servicesCBUUIDs = servicesCBUUIDs self.CBUUID_Advertisement = CBUUID_Advertisement self.CBUUID_ReceiveCharacteristic = CBUUID_ReceiveCharacteristic timeStampLastStatusUpdate = Date() self.bluetoothDeviceDelegate = bluetoothDeviceDelegate super.init() initialize() } deinit { disconnect() } func connect() { if let centralManager = centralManager, !retrievePeripherals(centralManager) { _ = startScanning() } } func disconnect() { if let peripheral = peripheral { if let centralManager = centralManager { centralManager.cancelPeripheralConnection(peripheral) } } } func disconnectAndForget() { disconnect() peripheral = nil deviceName = nil // deviceAddress = nil } func stopScanning() { centralManager?.stopScan() } func isScanning() -> Bool { if let centralManager = centralManager { return centralManager.isScanning } return false } func startScanning() -> BluetoothDevice.startScanningResult { LogManager.shared.log(category: .bluetooth, message: "Start Scanning", isDebug: true) var returnValue = BluetoothDevice.startScanningResult.unknown if let peripheral = peripheral { switch peripheral.state { case .connected: return .alreadyConnected case .connecting: if Date() > Date(timeInterval: maxTimeToWaitForPeripheralResponse, since: timeStampLastStatusUpdate) { disconnect() } return .connecting default: () } } var services: [CBUUID]? if let CBUUID_Advertisement = CBUUID_Advertisement { services = [CBUUID(string: CBUUID_Advertisement)] } if let centralManager = centralManager { if centralManager.isScanning { return .alreadyScanning } switch centralManager.state { case .poweredOn: centralManager.scanForPeripherals(withServices: services, options: nil) returnValue = .success case .poweredOff: return .poweredOff case .unknown: return .unknown case .unauthorized: return .unauthorized default: return returnValue } } else { returnValue = .other(reason: "centralManager is nil, can not start scanning") } return returnValue } func readValueForCharacteristic(for characteristic: CBCharacteristic) { peripheral?.readValue(for: characteristic) } func setNotifyValue(_ enabled: Bool, for characteristic: CBCharacteristic) { if let peripheral = peripheral { peripheral.setNotifyValue(enabled, for: characteristic) } } fileprivate func stopScanAndconnect(to peripheral: CBPeripheral) { LogManager.shared.log(category: .bluetooth, message: "Stop Scan And Connect", isDebug: true) centralManager?.stopScan() deviceAddress = peripheral.identifier.uuidString deviceName = peripheral.name peripheral.delegate = self self.peripheral = peripheral if peripheral.state == .disconnected { connectTimeOutTimer = Timer.scheduledTimer(timeInterval: 5.0, target: self, selector: #selector(stopConnectAndRestartScanning), userInfo: nil, repeats: false) centralManager?.connect(peripheral, options: nil) } else { if let newCentralManager = centralManager { centralManager(newCentralManager, didConnect: peripheral) } } } @objc fileprivate func stopConnectAndRestartScanning() { disconnectAndForget() _ = startScanning() } func cancelConnectionTimer() { if let connectTimeOutTimer = connectTimeOutTimer { connectTimeOutTimer.invalidate() self.connectTimeOutTimer = nil } } fileprivate func retrievePeripherals(_ central: CBCentralManager) -> Bool { if let uuid = UUID(uuidString: deviceAddress) { // trace(" uuid is not nil", log: log, category: ConstantsLog.categoryBlueToothTransmitter, type: .info) let peripheralArr = central.retrievePeripherals(withIdentifiers: [uuid]) if peripheralArr.count > 0 { peripheral = peripheralArr[0] if let peripheral = peripheral { peripheral.delegate = self central.connect(peripheral, options: nil) return true } } } return false } func centralManager(_: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData _: [String: Any], rssi _: NSNumber) { print("[BLE] didDiscover") timeStampLastStatusUpdate = Date() if peripheral.identifier.uuidString == deviceAddress { stopScanAndconnect(to: peripheral) } } func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) { cancelConnectionTimer() timeStampLastStatusUpdate = Date() bluetoothDeviceDelegate?.didConnectTo(bluetoothDevice: self) peripheral.discoverServices(servicesCBUUIDs) } func centralManager(_: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { timeStampLastStatusUpdate = Date() let peripheralName = peripheral.name ?? "Unknown" let errorMessage = error?.localizedDescription ?? "No error details provided" LogManager.shared.log(category: .bluetooth, message: "Failed to connect to peripheral '\(peripheralName)' (UUID: \(peripheral.identifier.uuidString)). Error: \(errorMessage). Retrying...") centralManager?.connect(peripheral, options: nil) } func centralManagerDidUpdateState(_ central: CBCentralManager) { LogManager.shared.log(category: .bluetooth, message: "Central Manager Did Update State", isDebug: true) timeStampLastStatusUpdate = Date() if central.state == .poweredOn { _ = retrievePeripherals(central) } } func centralManager(_: CBCentralManager, didDisconnectPeripheral _: CBPeripheral, error _: Error?) { timeStampLastStatusUpdate = Date() bluetoothDeviceDelegate?.didDisconnectFrom(bluetoothDevice: self) cancelConnectionTimer() guard let ownPeripheral = peripheral else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in guard let self = self, let manager = self.centralManager, manager.state == .poweredOn else { return } switch ownPeripheral.state { case .connected, .connecting: // Already (re)connecting; do nothing break default: manager.connect(ownPeripheral, options: nil) } } } func peripheral(_ peripheral: CBPeripheral, didDiscoverServices _: Error?) { timeStampLastStatusUpdate = Date() if let services = peripheral.services { for service in services { peripheral.discoverCharacteristics(nil, for: service) } } else { disconnect() } } func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error _: Error?) { timeStampLastStatusUpdate = Date() if let characteristics = service.characteristics { for characteristic in characteristics { if characteristic.uuid == CBUUID(string: CBUUID_ReceiveCharacteristic) { receiveCharacteristic = characteristic peripheral.setNotifyValue(true, for: characteristic) } } } } func peripheral(_: CBPeripheral, didWriteValueFor _: CBCharacteristic, error _: Error?) { timeStampLastStatusUpdate = Date() } func peripheral(_: CBPeripheral, didUpdateNotificationStateFor _: CBCharacteristic, error _: Error?) { timeStampLastStatusUpdate = Date() } func peripheral(_: CBPeripheral, didUpdateValueFor _: CBCharacteristic, error _: Error?) { timeStampLastStatusUpdate = Date() } func centralManager(_: CBCentralManager, willRestoreState _: [String: Any]) { LogManager.shared.log(category: .bluetooth, message: "Restoring BLE after crash/kill") } private func initialize() { var cBCentralManagerOptionRestoreIdentifierKeyToUse: String? cBCentralManagerOptionRestoreIdentifierKeyToUse = "LoopFollow-" + deviceAddress centralManager = CBCentralManager(delegate: self, queue: nil, options: [CBCentralManagerOptionShowPowerAlertKey: true, CBCentralManagerOptionRestoreIdentifierKey: cBCentralManagerOptionRestoreIdentifierKeyToUse!]) } enum startScanningResult: Equatable { case success case alreadyScanning case poweredOff case alreadyConnected case connecting case unknown case unauthorized case nfcScanNeeded case other(reason: String) func description() -> String { switch self { case .success: return "success" case .alreadyScanning: return "alreadyScanning" case .poweredOff: return "poweredOff" case .alreadyConnected: return "alreadyConnected" case .connecting: return "connecting" case let .other(reason): return "other reason : " + reason case .unknown: return "unknown" case .unauthorized: return "unauthorized" case .nfcScanNeeded: return "nfcScanNeeded" } } } func expectedHeartbeatInterval() -> TimeInterval? { return nil } }