BLEManager.swift 9.5 KB

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