BLEManager.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  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. /// Arrival times of recent heartbeat dropouts. Main-queue-confined
  13. /// (centralManager delivers on .main) and deliberately not persisted:
  14. /// stale history must not resurrect the banner after a relaunch.
  15. private var heartbeatDropoutTimes: [Date] = []
  16. /// A beat this much later than expected counts as one dropout event.
  17. /// Stricter than the 15% logging margin - normal jitter must not count.
  18. private let dropoutFactor = 1.5
  19. /// Sliding window over which dropout events are counted.
  20. private let dropoutWindow: TimeInterval = 60 * 60
  21. /// Dropout events within the window needed to raise the banner.
  22. private let dropoutTriggerCount = 5
  23. override private init() {
  24. super.init()
  25. centralManager = CBCentralManager(
  26. delegate: self,
  27. queue: .main
  28. )
  29. connectSelectedDeviceIfNeeded()
  30. // After BFU, selectedBLEDevice reads nil until hydration — reconnect when
  31. // storage becomes ready. dropFirst skips the current value (init handled the
  32. // already-ready case), so this fires only on the false→true recovery.
  33. readinessCancellable = StorageReadiness.ready.$value
  34. .dropFirst()
  35. .filter { $0 }
  36. .sink { [weak self] _ in
  37. self?.connectSelectedDeviceIfNeeded()
  38. }
  39. }
  40. private func connectSelectedDeviceIfNeeded() {
  41. guard activeDevice == nil, let device = Storage.shared.selectedBLEDevice.value else { return }
  42. if !devices.contains(where: { $0.id == device.id }) {
  43. devices.append(device)
  44. }
  45. findAndUpdateDevice(with: device.id.uuidString) { device in
  46. device.rssi = 0
  47. }
  48. connect(device: device)
  49. }
  50. func getSelectedDevice() -> BLEDevice? {
  51. return devices.first { $0.id == Storage.shared.selectedBLEDevice.value?.id }
  52. }
  53. func startScanning() {
  54. guard centralManager.state == .poweredOn else {
  55. LogManager.shared.log(category: .bluetooth, message: "Not powered on, cannot start scan.")
  56. return
  57. }
  58. centralManager.scanForPeripherals(withServices: nil, options: nil)
  59. cleanupOldDevices()
  60. }
  61. func disconnect() {
  62. if let device = activeDevice {
  63. device.disconnect()
  64. activeDevice = nil
  65. device.lastHeartbeatTime = nil
  66. }
  67. heartbeatDropoutTimes.removeAll()
  68. BannerManager.shared.clear(.heartbeat)
  69. Storage.shared.selectedBLEDevice.value = nil
  70. }
  71. func connect(device: BLEDevice) {
  72. disconnect()
  73. if let matchedType = BackgroundRefreshType.allCases.first(where: { $0.matches(device) }) {
  74. Storage.shared.backgroundRefreshType.value = matchedType
  75. Storage.shared.selectedBLEDevice.value = device
  76. findAndUpdateDevice(with: device.id.uuidString) { device in
  77. device.isConnected = false
  78. device.lastConnected = nil
  79. }
  80. switch matchedType {
  81. case .dexcom:
  82. activeDevice = DexcomHeartbeatBluetoothDevice(address: device.id.uuidString, name: device.name, bluetoothDeviceDelegate: self)
  83. activeDevice?.connect()
  84. case .rileyLink:
  85. activeDevice = RileyLinkHeartbeatBluetoothDevice(address: device.id.uuidString, name: device.name, bluetoothDeviceDelegate: self)
  86. activeDevice?.connect()
  87. case .omnipodDash:
  88. activeDevice = OmnipodDashHeartbeatBluetoothTransmitter(address: device.id.uuidString, name: device.name, bluetoothDeviceDelegate: self)
  89. activeDevice?.connect()
  90. case .silentTune, .none:
  91. return
  92. }
  93. } else {
  94. LogManager.shared.log(category: .bluetooth, message: "No matching BackgroundRefreshType found for this device.")
  95. }
  96. }
  97. func stopScanning() {
  98. centralManager.stopScan()
  99. }
  100. func expectedHeartbeatInterval() -> TimeInterval? {
  101. guard let device = activeDevice else {
  102. return nil
  103. }
  104. return device.expectedHeartbeatInterval()
  105. }
  106. private func addOrUpdateDevice(_ device: BLEDevice) {
  107. LogManager.shared.log(category: .bluetooth, message: "Adding or updating BLE device: \(device)", isDebug: true)
  108. if let idx = devices.firstIndex(where: { $0.id == device.id }) {
  109. var updatedDevice = devices[idx]
  110. updatedDevice.rssi = device.rssi
  111. updatedDevice.lastSeen = Date()
  112. devices[idx] = updatedDevice
  113. } else {
  114. var newDevice = device
  115. newDevice.lastSeen = Date()
  116. devices.append(newDevice)
  117. }
  118. devices = devices
  119. }
  120. private func cleanupOldDevices() {
  121. let expirationDate = Date().addingTimeInterval(-600) // 10 minutes ago
  122. // Get the selected device's ID (if any)
  123. let selectedDeviceID = Storage.shared.selectedBLEDevice.value?.id
  124. // Filter devices, keeping those seen within the last 10 minutes or the selected device
  125. devices = devices.filter { $0.lastSeen > expirationDate || $0.id == selectedDeviceID }
  126. }
  127. }
  128. // MARK: - CBCentralManagerDelegate
  129. extension BLEManager: CBCentralManagerDelegate {
  130. func centralManagerDidUpdateState(_ central: CBCentralManager) {
  131. switch central.state {
  132. case .poweredOn:
  133. LogManager.shared.log(category: .bluetooth, message: "Central poweredOn", isDebug: true)
  134. default:
  135. LogManager.shared.log(category: .bluetooth, message: "Central state = \(central.state.rawValue), not powered on.", isDebug: true)
  136. }
  137. }
  138. func centralManager(_: CBCentralManager,
  139. didDiscover peripheral: CBPeripheral,
  140. advertisementData: [String: Any],
  141. rssi RSSI: NSNumber)
  142. {
  143. let uuid = peripheral.identifier
  144. let services = (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID])?
  145. .map { $0.uuidString }
  146. let device = BLEDevice(
  147. id: uuid,
  148. name: peripheral.name,
  149. rssi: RSSI.intValue,
  150. advertisedServices: services,
  151. lastSeen: Date()
  152. )
  153. addOrUpdateDevice(device)
  154. }
  155. func findAndUpdateDevice(with deviceAddress: String, update: (inout BLEDevice) -> Void) {
  156. if let idx = devices.firstIndex(where: { $0.id.uuidString == deviceAddress }) {
  157. var device = devices[idx]
  158. update(&device)
  159. devices[idx] = device
  160. devices = devices
  161. } else {
  162. LogManager.shared.log(category: .bluetooth, message: "Device not found in devices array for update")
  163. }
  164. }
  165. }
  166. extension BLEManager: BluetoothDeviceDelegate {
  167. func didConnectTo(bluetoothDevice: BluetoothDevice) {
  168. LogManager.shared.log(category: .bluetooth, message: "Connected to: \(bluetoothDevice.deviceName ?? "Unknown")", isDebug: true)
  169. findAndUpdateDevice(with: bluetoothDevice.deviceAddress) { device in
  170. device.isConnected = true
  171. device.lastConnected = Date()
  172. }
  173. }
  174. func didDisconnectFrom(bluetoothDevice: BluetoothDevice) {
  175. LogManager.shared.log(category: .bluetooth, message: "Disconnect from: \(bluetoothDevice.deviceName ?? "Unknown")", isDebug: true)
  176. findAndUpdateDevice(with: bluetoothDevice.deviceAddress) { device in
  177. device.isConnected = false
  178. device.lastConnected = Date()
  179. }
  180. }
  181. func heartBeat() {
  182. guard let device = activeDevice else {
  183. return
  184. }
  185. let now = Date()
  186. guard let expectedInterval = device.expectedHeartbeatInterval() else {
  187. LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered")
  188. device.lastHeartbeatTime = now
  189. TaskScheduler.shared.checkTasksNow()
  190. return
  191. }
  192. let marginPercentage = 0.15 // 15% margin
  193. let margin = expectedInterval * marginPercentage
  194. let threshold = expectedInterval + margin
  195. if let last = device.lastHeartbeatTime {
  196. let elapsedTime = now.timeIntervalSince(last)
  197. if elapsedTime > threshold {
  198. let delay = elapsedTime - expectedInterval
  199. LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered (Delayed by \(String(format: "%.1f", delay)) seconds)")
  200. }
  201. recordHeartbeatOutcome(elapsed: elapsedTime, expectedInterval: expectedInterval, now: now)
  202. } else {
  203. LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered (First heartbeat)")
  204. }
  205. device.lastHeartbeatTime = now
  206. TaskScheduler.shared.checkTasksNow()
  207. }
  208. /// Counts late heartbeats over a sliding window and raises a banner when
  209. /// dropouts become frequent - the typical symptom of a dying transmitter
  210. /// battery. Detection is arrival-based on purpose: a struggling battery
  211. /// still delivers (late) beats, whereas total silence usually means
  212. /// out-of-range or a dead device and is already surfaced by the
  213. /// connection status in Background Refresh settings and by BG alarms.
  214. private func recordHeartbeatOutcome(elapsed: TimeInterval, expectedInterval: TimeInterval, now: Date) {
  215. heartbeatDropoutTimes.removeAll { now.timeIntervalSince($0) > dropoutWindow }
  216. // A gap this large means out-of-range, Bluetooth off or a suspended
  217. // app - not a struggling battery. Discard the history collected
  218. // before the blind spot instead of counting it.
  219. let resetGap = max(6 * expectedInterval, 30 * 60)
  220. if elapsed > resetGap {
  221. if !heartbeatDropoutTimes.isEmpty {
  222. heartbeatDropoutTimes.removeAll()
  223. LogManager.shared.log(category: .bluetooth, message: "Heartbeat gap of \(Int(elapsed)) seconds, resetting dropout history")
  224. }
  225. } else if elapsed > expectedInterval * dropoutFactor {
  226. heartbeatDropoutTimes.append(now)
  227. LogManager.shared.log(category: .bluetooth, message: "Heartbeat dropout recorded (\(heartbeatDropoutTimes.count) in the last hour)")
  228. }
  229. // Hysteresis: raise at the trigger count, clear only once the window
  230. // is completely clean, so the banner doesn't flap at the boundary.
  231. if heartbeatDropoutTimes.count >= dropoutTriggerCount {
  232. BannerManager.shared.report(source: .heartbeat, severity: .warning, text: heartbeatDropoutBannerText())
  233. } else if heartbeatDropoutTimes.isEmpty {
  234. BannerManager.shared.clear(.heartbeat)
  235. }
  236. }
  237. /// The text must stay stable while the condition persists (no counts or
  238. /// durations) so BannerManager's dedupe keeps the banner from
  239. /// re-animating on every beat and user dismissal keeps working.
  240. private func heartbeatDropoutBannerText() -> String {
  241. let name = activeDevice?.deviceName ?? "heartbeat device"
  242. let cause: String
  243. switch Storage.shared.backgroundRefreshType.value {
  244. case .rileyLink:
  245. cause = "a low RileyLink battery"
  246. case .omnipodDash:
  247. cause = "a low pod battery"
  248. default:
  249. cause = "a low transmitter battery"
  250. }
  251. return "Heartbeat: repeated Bluetooth dropouts from \(name). This can be a sign of \(cause) or a weak Bluetooth connection."
  252. }
  253. }
  254. extension BLEManager {
  255. /// Returns the expected sensor fetch offset as a formatted string ("mm:ss (fetch delay: XX sec)")
  256. /// for Dexcom and RileyLink devices. The expected offset is computed as the sensor's schedule offset plus the polling delay.
  257. /// The device’s lastSeen time is used (mod cycleDuration) to calculate the effective delay between when the sensor value
  258. /// becomes available and when the fetch is actually triggered.
  259. func expectedSensorFetchOffsetString(for device: BLEDevice) -> String? {
  260. guard
  261. let matchedType = BackgroundRefreshType.allCases.first(where: { $0.matches(device) }),
  262. let heartBeatInterval = matchedType.heartBeatInterval,
  263. let sensorOffset = Storage.shared.sensorScheduleOffset.value
  264. else {
  265. return nil
  266. }
  267. let heartbeatLast: Date? = {
  268. if matchedType.estimatedDelayBasedOnHeartbeat {
  269. guard device.isConnected, let lastHeartbeat = activeDevice?.lastHeartbeatTime else {
  270. return nil
  271. }
  272. return lastHeartbeat
  273. } else {
  274. return device.lastSeen
  275. }
  276. }()
  277. guard let heartbeatLast = heartbeatLast else {
  278. return nil
  279. }
  280. let pollingDelay: TimeInterval = Double(Storage.shared.bgUpdateDelay.value)
  281. let expectedOffset = sensorOffset + pollingDelay
  282. // If the heartbeat interval isn't a typical 60 or 300 seconds,
  283. // we simply return a string indicating that the delay is "up to" the heartbeat interval.
  284. if heartBeatInterval != 60, heartBeatInterval != 300 {
  285. return "up to \(Int(heartBeatInterval)) sec"
  286. }
  287. let effectiveDelay = CycleHelper.computeDelay(sensorOffset: expectedOffset, heartbeatLast: heartbeatLast, heartbeatInterval: heartBeatInterval)
  288. return "\(Int(effectiveDelay)) sec"
  289. }
  290. }