BLEManager.swift 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. //
  2. // BLEManager.swift
  3. // LoopFollow
  4. //
  5. import Foundation
  6. import CoreBluetooth
  7. import Combine
  8. class BLEManager: NSObject, ObservableObject {
  9. static let shared = BLEManager()
  10. @Published private(set) var devices: [BLEDevice] = []
  11. private var centralManager: CBCentralManager!
  12. private var activeDevice: BluetoothDevice?
  13. private override init() {
  14. super.init()
  15. centralManager = CBCentralManager(
  16. delegate: self,
  17. queue: .main
  18. )
  19. if let device = Storage.shared.selectedBLEDevice.value {
  20. connect(device: device)
  21. }
  22. }
  23. func startScanning() {
  24. guard centralManager.state == .poweredOn else {
  25. LogManager.shared.log(category: .bluetooth, message: "Not powered on, cannot start scan.")
  26. return
  27. }
  28. centralManager.scanForPeripherals(withServices: nil, options: nil)
  29. cleanupOldDevices()
  30. }
  31. func disconnect() {
  32. if let device = activeDevice {
  33. device.disconnect()
  34. activeDevice = nil
  35. device.lastHeartbeatTime = nil
  36. }
  37. Storage.shared.selectedBLEDevice.value = nil
  38. }
  39. func connect(device: BLEDevice) {
  40. disconnect()
  41. if let matchedType = BackgroundRefreshType.allCases.first(where: { $0.matches(device) }) {
  42. Storage.shared.backgroundRefreshType.value = matchedType
  43. Storage.shared.selectedBLEDevice.value = device
  44. switch matchedType {
  45. case .dexcom:
  46. activeDevice = DexcomHeartbeatBluetoothDevice(bluetoothDeviceDelegate: self)
  47. activeDevice?.connect()
  48. case .rileyLink:
  49. activeDevice = RileyLinkHeartbeatBluetoothDevice(bluetoothDeviceDelegate: self)
  50. activeDevice?.connect()
  51. case .silentTune, .none:
  52. return
  53. }
  54. } else {
  55. LogManager.shared.log(category: .bluetooth, message: "No matching BackgroundRefreshType found for this device.")
  56. }
  57. }
  58. func stopScanning() {
  59. centralManager.stopScan()
  60. }
  61. func expectedHeartbeatInterval() -> TimeInterval? {
  62. guard let device = activeDevice else {
  63. return nil
  64. }
  65. return device.expectedHeartbeatInterval()
  66. }
  67. private func addOrUpdateDevice(_ device: BLEDevice) {
  68. if let idx = devices.firstIndex(where: { $0.id == device.id }) {
  69. devices[idx] = device.updateLastSeen()
  70. } else {
  71. var newDevice = device
  72. newDevice.lastSeen = Date()
  73. devices.append(newDevice)
  74. }
  75. devices = devices
  76. }
  77. private func cleanupOldDevices() {
  78. let expirationDate = Date().addingTimeInterval(-600) // 10 minutes ago
  79. // Get the selected device's ID (if any)
  80. let selectedDeviceID = Storage.shared.selectedBLEDevice.value?.id
  81. // Filter devices, keeping those seen within the last 10 minutes or the selected device
  82. devices = devices.filter { $0.lastSeen > expirationDate || $0.id == selectedDeviceID }
  83. }
  84. }
  85. // MARK: - CBCentralManagerDelegate
  86. extension BLEManager: CBCentralManagerDelegate {
  87. func centralManagerDidUpdateState(_ central: CBCentralManager) {
  88. switch central.state {
  89. case .poweredOn:
  90. print("[BLE] Central poweredOn.")
  91. default:
  92. print("[BLE] Central state = \(central.state.rawValue), not powered on.")
  93. }
  94. }
  95. func centralManager(_ central: CBCentralManager,
  96. didDiscover peripheral: CBPeripheral,
  97. advertisementData: [String: Any],
  98. rssi RSSI: NSNumber) {
  99. let uuid = peripheral.identifier
  100. let services = (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID])?
  101. .map { $0.uuidString }
  102. let device = BLEDevice(
  103. id: uuid,
  104. name: peripheral.name,
  105. rssi: RSSI.intValue,
  106. advertisedServices: services,
  107. lastSeen: Date()
  108. )
  109. addOrUpdateDevice(device)
  110. }
  111. }
  112. extension BLEManager: BluetoothDeviceDelegate {
  113. func didConnectTo(bluetoothDevice: BluetoothDevice) {
  114. LogManager.shared.log(category: .bluetooth, message: "Connected to: \(bluetoothDevice.deviceName ?? "Unknown")")
  115. if var device = Storage.shared.selectedBLEDevice.value {
  116. device.isConnected = true
  117. Storage.shared.selectedBLEDevice.value = device.updateLastConnected()
  118. }
  119. }
  120. func didDisconnectFrom(bluetoothDevice: BluetoothDevice) {
  121. LogManager.shared.log(category: .bluetooth, message: "Disconnect from: \(bluetoothDevice.deviceName ?? "Unknown")")
  122. if var device = Storage.shared.selectedBLEDevice.value {
  123. device.isConnected = false
  124. Storage.shared.selectedBLEDevice.value = device
  125. }
  126. }
  127. func heartBeat() {
  128. guard let device = activeDevice else {
  129. return
  130. }
  131. let now = Date()
  132. guard let expectedInterval = device.expectedHeartbeatInterval() else {
  133. LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered")
  134. device.lastHeartbeatTime = now
  135. TaskScheduler.shared.checkTasksNow()
  136. return
  137. }
  138. let marginPercentage: Double = 0.15 // 15% margin
  139. let margin = expectedInterval * marginPercentage
  140. let threshold = expectedInterval + margin
  141. if let last = device.lastHeartbeatTime {
  142. let elapsedTime = now.timeIntervalSince(last)
  143. if elapsedTime > threshold {
  144. let delay = elapsedTime - expectedInterval
  145. LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered (Delayed by \(String(format: "%.1f", delay)) seconds)")
  146. }
  147. } else {
  148. LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered (First heartbeat)")
  149. }
  150. device.lastHeartbeatTime = now
  151. TaskScheduler.shared.checkTasksNow()
  152. }
  153. }