BLEManager.swift 9.4 KB

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