BLEManager.swift 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  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. connect(device: device)
  21. }
  22. }
  23. func startScanning() {
  24. guard centralManager.state == .poweredOn else {
  25. LogManager.shared.log(category: .bluetooth, message: "Not powered on, cannot start scan.")
  26. return
  27. }
  28. centralManager.scanForPeripherals(withServices: nil, options: nil)
  29. cleanupOldDevices()
  30. }
  31. func disconnect() {
  32. if let device = activeDevice {
  33. device.disconnect()
  34. activeDevice = nil
  35. device.lastHeartbeatTime = nil
  36. }
  37. Storage.shared.selectedBLEDevice.value = nil
  38. }
  39. func connect(device: BLEDevice) {
  40. disconnect()
  41. if let matchedType = BackgroundRefreshType.allCases.first(where: { $0.matches(device) }) {
  42. Storage.shared.backgroundRefreshType.value = matchedType
  43. Storage.shared.selectedBLEDevice.value = device
  44. switch matchedType {
  45. case .dexcomG7:
  46. activeDevice = DexcomG7HeartbeatBluetoothDevice(bluetoothDeviceDelegate: self)
  47. activeDevice?.connect()
  48. case .rileyLink:
  49. activeDevice = RileyLinkHeartbeatBluetoothDevice(bluetoothDeviceDelegate: self)
  50. activeDevice?.connect()
  51. case .silentTune, .none:
  52. return
  53. }
  54. } else {
  55. LogManager.shared.log(category: .bluetooth, message: "No matching BackgroundRefreshType found for this device.")
  56. }
  57. }
  58. func stopScanning() {
  59. centralManager.stopScan()
  60. }
  61. private func addOrUpdateDevice(_ device: BLEDevice) {
  62. if let idx = devices.firstIndex(where: { $0.id == device.id }) {
  63. devices[idx] = device.updateLastSeen()
  64. } else {
  65. var newDevice = device
  66. newDevice.lastSeen = Date()
  67. devices.append(newDevice)
  68. }
  69. devices = devices
  70. }
  71. private func cleanupOldDevices() {
  72. let expirationDate = Date().addingTimeInterval(-600) // 10 minutes ago
  73. // Get the selected device's ID (if any)
  74. let selectedDeviceID = Storage.shared.selectedBLEDevice.value?.id
  75. // Filter devices, keeping those seen within the last 10 minutes or the selected device
  76. devices = devices.filter { $0.lastSeen > expirationDate || $0.id == selectedDeviceID }
  77. }
  78. }
  79. // MARK: - CBCentralManagerDelegate
  80. extension BLEManager: CBCentralManagerDelegate {
  81. func centralManagerDidUpdateState(_ central: CBCentralManager) {
  82. switch central.state {
  83. case .poweredOn:
  84. print("[BLE] Central poweredOn.")
  85. default:
  86. print("[BLE] Central state = \(central.state.rawValue), not powered on.")
  87. }
  88. }
  89. func centralManager(_ central: CBCentralManager,
  90. didDiscover peripheral: CBPeripheral,
  91. advertisementData: [String: Any],
  92. rssi RSSI: NSNumber) {
  93. let uuid = peripheral.identifier
  94. let services = (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID])?
  95. .map { $0.uuidString }
  96. let device = BLEDevice(
  97. id: uuid,
  98. name: peripheral.name,
  99. rssi: RSSI.intValue,
  100. advertisedServices: services,
  101. lastSeen: Date()
  102. )
  103. addOrUpdateDevice(device)
  104. }
  105. }
  106. extension BLEManager: BluetoothDeviceDelegate {
  107. func didConnectTo(bluetoothDevice: BluetoothDevice) {
  108. LogManager.shared.log(category: .bluetooth, message: "Connected to: \(String(describing: bluetoothDevice.deviceName))")
  109. if var device = Storage.shared.selectedBLEDevice.value {
  110. device.isConnected = true
  111. Storage.shared.selectedBLEDevice.value = device.updateLastConnected()
  112. }
  113. }
  114. func didDisconnectFrom(bluetoothDevice: BluetoothDevice) {
  115. LogManager.shared.log(category: .bluetooth, message: "Disconnect from: \(String(describing: bluetoothDevice.deviceName))")
  116. if var device = Storage.shared.selectedBLEDevice.value {
  117. device.isConnected = false
  118. Storage.shared.selectedBLEDevice.value = device
  119. }
  120. }
  121. func heartBeat() {
  122. guard let device = activeDevice else {
  123. return
  124. }
  125. let now = Date()
  126. guard let expectedInterval = device.expectedHeartbeatInterval() else {
  127. LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered")
  128. device.lastHeartbeatTime = now
  129. TaskScheduler.shared.checkTasksNow()
  130. return
  131. }
  132. let marginPercentage: Double = 0.15 // 15% margin
  133. let margin = expectedInterval * marginPercentage
  134. let threshold = expectedInterval + margin
  135. if let last = device.lastHeartbeatTime {
  136. let elapsedTime = now.timeIntervalSince(last)
  137. if elapsedTime > threshold {
  138. let delay = elapsedTime - expectedInterval
  139. LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered (Delayed by \(String(format: "%.1f", delay)) seconds)")
  140. }
  141. } else {
  142. LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered (First heartbeat)")
  143. }
  144. device.lastHeartbeatTime = now
  145. TaskScheduler.shared.checkTasksNow()
  146. }
  147. }