BLEManager.swift 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. // LoopFollow
  2. // BLEManager.swift
  3. // Created by Jonas Björkert on 2025-01-13.
  4. import Combine
  5. import CoreBluetooth
  6. import Foundation
  7. class BLEManager: NSObject, ObservableObject {
  8. static let shared = BLEManager()
  9. @Published private(set) var devices: [BLEDevice] = []
  10. private var centralManager: CBCentralManager!
  11. private var activeDevice: BluetoothDevice?
  12. override private init() {
  13. super.init()
  14. centralManager = CBCentralManager(
  15. delegate: self,
  16. queue: .main
  17. )
  18. if let device = Storage.shared.selectedBLEDevice.value {
  19. devices.append(device)
  20. findAndUpdateDevice(with: device.id.uuidString) { device in
  21. device.rssi = 0
  22. }
  23. connect(device: device)
  24. }
  25. }
  26. func getSelectedDevice() -> BLEDevice? {
  27. return devices.first { $0.id == Storage.shared.selectedBLEDevice.value?.id }
  28. }
  29. func startScanning() {
  30. guard centralManager.state == .poweredOn else {
  31. LogManager.shared.log(category: .bluetooth, message: "Not powered on, cannot start scan.")
  32. return
  33. }
  34. centralManager.scanForPeripherals(withServices: nil, options: nil)
  35. cleanupOldDevices()
  36. }
  37. func disconnect() {
  38. if let device = activeDevice {
  39. device.disconnect()
  40. activeDevice = nil
  41. device.lastHeartbeatTime = nil
  42. }
  43. Storage.shared.selectedBLEDevice.value = nil
  44. }
  45. func connect(device: BLEDevice) {
  46. disconnect()
  47. if let matchedType = BackgroundRefreshType.allCases.first(where: { $0.matches(device) }) {
  48. Storage.shared.backgroundRefreshType.value = matchedType
  49. Storage.shared.selectedBLEDevice.value = device
  50. findAndUpdateDevice(with: device.id.uuidString) { device in
  51. device.isConnected = false
  52. device.lastConnected = nil
  53. }
  54. switch matchedType {
  55. case .dexcom:
  56. activeDevice = DexcomHeartbeatBluetoothDevice(address: device.id.uuidString, name: device.name, bluetoothDeviceDelegate: self)
  57. activeDevice?.connect()
  58. case .rileyLink:
  59. activeDevice = RileyLinkHeartbeatBluetoothDevice(address: device.id.uuidString, name: device.name, bluetoothDeviceDelegate: self)
  60. activeDevice?.connect()
  61. case .omnipodDash:
  62. activeDevice = OmnipodDashHeartbeatBluetoothTransmitter(address: device.id.uuidString, name: device.name, bluetoothDeviceDelegate: self)
  63. activeDevice?.connect()
  64. case .silentTune, .none:
  65. return
  66. }
  67. } else {
  68. LogManager.shared.log(category: .bluetooth, message: "No matching BackgroundRefreshType found for this device.")
  69. }
  70. }
  71. func stopScanning() {
  72. centralManager.stopScan()
  73. }
  74. func expectedHeartbeatInterval() -> TimeInterval? {
  75. guard let device = activeDevice else {
  76. return nil
  77. }
  78. return device.expectedHeartbeatInterval()
  79. }
  80. private func addOrUpdateDevice(_ device: BLEDevice) {
  81. LogManager.shared.log(category: .bluetooth, message: "Adding or updating BLE device: \(device)", isDebug: true)
  82. if let idx = devices.firstIndex(where: { $0.id == device.id }) {
  83. var updatedDevice = devices[idx]
  84. updatedDevice.rssi = device.rssi
  85. updatedDevice.lastSeen = Date()
  86. devices[idx] = updatedDevice
  87. } else {
  88. var newDevice = device
  89. newDevice.lastSeen = Date()
  90. devices.append(newDevice)
  91. }
  92. devices = devices
  93. }
  94. private func cleanupOldDevices() {
  95. let expirationDate = Date().addingTimeInterval(-600) // 10 minutes ago
  96. // Get the selected device's ID (if any)
  97. let selectedDeviceID = Storage.shared.selectedBLEDevice.value?.id
  98. // Filter devices, keeping those seen within the last 10 minutes or the selected device
  99. devices = devices.filter { $0.lastSeen > expirationDate || $0.id == selectedDeviceID }
  100. }
  101. }
  102. // MARK: - CBCentralManagerDelegate
  103. extension BLEManager: CBCentralManagerDelegate {
  104. func centralManagerDidUpdateState(_ central: CBCentralManager) {
  105. switch central.state {
  106. case .poweredOn:
  107. LogManager.shared.log(category: .bluetooth, message: "Central poweredOn", isDebug: true)
  108. default:
  109. LogManager.shared.log(category: .bluetooth, message: "Central state = \(central.state.rawValue), not powered on.", isDebug: true)
  110. }
  111. }
  112. func centralManager(_: CBCentralManager,
  113. didDiscover peripheral: CBPeripheral,
  114. advertisementData: [String: Any],
  115. rssi RSSI: NSNumber)
  116. {
  117. let uuid = peripheral.identifier
  118. let services = (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID])?
  119. .map { $0.uuidString }
  120. let device = BLEDevice(
  121. id: uuid,
  122. name: peripheral.name,
  123. rssi: RSSI.intValue,
  124. advertisedServices: services,
  125. lastSeen: Date()
  126. )
  127. addOrUpdateDevice(device)
  128. }
  129. func findAndUpdateDevice(with deviceAddress: String, update: (inout BLEDevice) -> Void) {
  130. if let idx = devices.firstIndex(where: { $0.id.uuidString == deviceAddress }) {
  131. var device = devices[idx]
  132. update(&device)
  133. devices[idx] = device
  134. devices = devices
  135. } else {
  136. LogManager.shared.log(category: .bluetooth, message: "Device not found in devices array for update")
  137. }
  138. }
  139. }
  140. extension BLEManager: BluetoothDeviceDelegate {
  141. func didConnectTo(bluetoothDevice: BluetoothDevice) {
  142. LogManager.shared.log(category: .bluetooth, message: "Connected to: \(bluetoothDevice.deviceName ?? "Unknown")", isDebug: true)
  143. findAndUpdateDevice(with: bluetoothDevice.deviceAddress) { device in
  144. device.isConnected = true
  145. device.lastConnected = Date()
  146. }
  147. }
  148. func didDisconnectFrom(bluetoothDevice: BluetoothDevice) {
  149. LogManager.shared.log(category: .bluetooth, message: "Disconnect from: \(bluetoothDevice.deviceName ?? "Unknown")", isDebug: true)
  150. findAndUpdateDevice(with: bluetoothDevice.deviceAddress) { device in
  151. device.isConnected = false
  152. device.lastConnected = Date()
  153. }
  154. }
  155. func heartBeat() {
  156. guard let device = activeDevice else {
  157. return
  158. }
  159. let now = Date()
  160. guard let expectedInterval = device.expectedHeartbeatInterval() else {
  161. LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered")
  162. device.lastHeartbeatTime = now
  163. TaskScheduler.shared.checkTasksNow()
  164. return
  165. }
  166. let marginPercentage = 0.15 // 15% margin
  167. let margin = expectedInterval * marginPercentage
  168. let threshold = expectedInterval + margin
  169. if let last = device.lastHeartbeatTime {
  170. let elapsedTime = now.timeIntervalSince(last)
  171. if elapsedTime > threshold {
  172. let delay = elapsedTime - expectedInterval
  173. LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered (Delayed by \(String(format: "%.1f", delay)) seconds)")
  174. }
  175. } else {
  176. LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered (First heartbeat)")
  177. }
  178. device.lastHeartbeatTime = now
  179. TaskScheduler.shared.checkTasksNow()
  180. }
  181. }
  182. extension BLEManager {
  183. /// Returns the expected sensor fetch offset as a formatted string ("mm:ss (fetch delay: XX sec)")
  184. /// for Dexcom and RileyLink devices. The expected offset is computed as the sensor's schedule offset plus the polling delay.
  185. /// The device’s lastSeen time is used (mod cycleDuration) to calculate the effective delay between when the sensor value
  186. /// becomes available and when the fetch is actually triggered.
  187. func expectedSensorFetchOffsetString(for device: BLEDevice) -> String? {
  188. guard
  189. let matchedType = BackgroundRefreshType.allCases.first(where: { $0.matches(device) }),
  190. let heartBeatInterval = matchedType.heartBeatInterval,
  191. let sensorOffset = Storage.shared.sensorScheduleOffset.value
  192. else {
  193. return nil
  194. }
  195. let heartbeatLast: Date? = {
  196. if matchedType.estimatedDelayBasedOnHeartbeat {
  197. guard device.isConnected, let lastHeartbeat = activeDevice?.lastHeartbeatTime else {
  198. return nil
  199. }
  200. return lastHeartbeat
  201. } else {
  202. return device.lastSeen
  203. }
  204. }()
  205. guard let heartbeatLast = heartbeatLast else {
  206. return nil
  207. }
  208. let pollingDelay: TimeInterval = Double(UserDefaultsRepository.bgUpdateDelay.value)
  209. let expectedOffset = sensorOffset + pollingDelay
  210. // If the heartbeat interval isn't a typical 60 or 300 seconds,
  211. // we simply return a string indicating that the delay is "up to" the heartbeat interval.
  212. if heartBeatInterval != 60, heartBeatInterval != 300 {
  213. return "up to \(Int(heartBeatInterval)) sec"
  214. }
  215. let effectiveDelay = CycleHelper.computeDelay(sensorOffset: expectedOffset, heartbeatLast: heartbeatLast, heartbeatInterval: heartBeatInterval)
  216. return "\(Int(effectiveDelay)) sec"
  217. }
  218. }