BLEManager.swift 10 KB

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