BLEManager.swift 14 KB

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