-
Notifications
You must be signed in to change notification settings - Fork 723
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #351 from loopandlearn/bt-heartbeat
Bluetooth Heartbeat
- Loading branch information
Showing
71 changed files
with
2,902 additions
and
809 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -127,4 +127,3 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { | |
updateQuickActions() | ||
} | ||
} | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
// | ||
// BLEDevice.swift | ||
// LoopFollow | ||
// | ||
// Created by Jonas Björkert on 2025-01-02. | ||
// | ||
|
||
import Foundation | ||
|
||
struct BLEDevice: Identifiable, Codable, Equatable { | ||
let id: UUID | ||
|
||
var name: String? | ||
var rssi: Int | ||
var isConnected: Bool | ||
var advertisedServices: [String]? | ||
var lastSeen: Date | ||
var lastConnected: Date? | ||
|
||
init(id: UUID, | ||
name: String? = nil, | ||
rssi: Int, | ||
isConnected: Bool = false, | ||
advertisedServices: [String]? = nil, | ||
lastSeen: Date = Date(), | ||
lastConnected: Date? = nil) { | ||
self.id = id | ||
self.name = name | ||
self.rssi = rssi | ||
self.isConnected = isConnected | ||
self.advertisedServices = advertisedServices | ||
self.lastSeen = lastSeen | ||
self.lastConnected = lastConnected | ||
} | ||
} |
55 changes: 55 additions & 0 deletions
55
LoopFollow/BackgroundRefresh/BT/BLEDeviceSelectionView.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
// | ||
// BLEDeviceSelectionView.swift | ||
// LoopFollow | ||
// | ||
|
||
import SwiftUI | ||
|
||
struct BLEDeviceSelectionView: View { | ||
@ObservedObject var bleManager: BLEManager | ||
var selectedFilter: BackgroundRefreshType | ||
var onSelectDevice: (BLEDevice) -> Void | ||
|
||
var body: some View { | ||
VStack { | ||
List { | ||
if bleManager.devices.filter({ selectedFilter.matches($0) && !isSelected($0) }).isEmpty { | ||
Text("No devices found yet. They'll appear here when discovered.") | ||
.foregroundColor(.secondary) | ||
.multilineTextAlignment(.center) | ||
.padding() | ||
} else { | ||
ForEach(bleManager.devices.filter { selectedFilter.matches($0) && !isSelected($0) }, id: \.id) { device in | ||
HStack { | ||
VStack(alignment: .leading) { | ||
Text(device.name ?? "Unknown") | ||
|
||
Text("RSSI: \(device.rssi) dBm") | ||
.foregroundColor(.secondary) | ||
.font(.footnote) | ||
} | ||
Spacer() | ||
} | ||
.contentShape(Rectangle()) | ||
.onTapGesture { | ||
onSelectDevice(device) | ||
} | ||
} | ||
} | ||
} | ||
} | ||
.onAppear { | ||
bleManager.startScanning() | ||
} | ||
.onDisappear { | ||
bleManager.stopScanning() | ||
} | ||
} | ||
|
||
private func isSelected(_ device: BLEDevice) -> Bool { | ||
guard let selectedDevice = Storage.shared.selectedBLEDevice.value else { | ||
return false | ||
} | ||
return selectedDevice.id == device.id | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,215 @@ | ||
// | ||
// BLEManager.swift | ||
// LoopFollow | ||
// | ||
|
||
import Foundation | ||
import CoreBluetooth | ||
import Combine | ||
|
||
class BLEManager: NSObject, ObservableObject { | ||
static let shared = BLEManager() | ||
|
||
@Published private(set) var devices: [BLEDevice] = [] | ||
|
||
private var centralManager: CBCentralManager! | ||
private var activeDevice: BluetoothDevice? | ||
|
||
private override init() { | ||
super.init() | ||
|
||
centralManager = CBCentralManager( | ||
delegate: self, | ||
queue: .main | ||
) | ||
if let device = Storage.shared.selectedBLEDevice.value { | ||
devices.append(device) | ||
findAndUpdateDevice(with: device.id.uuidString) { device in | ||
device.rssi = 0 | ||
} | ||
connect(device: device) | ||
} | ||
} | ||
|
||
func getSelectedDevice() -> BLEDevice? { | ||
return devices.first { $0.id == Storage.shared.selectedBLEDevice.value?.id } | ||
} | ||
|
||
func startScanning() { | ||
guard centralManager.state == .poweredOn else { | ||
LogManager.shared.log(category: .bluetooth, message: "Not powered on, cannot start scan.") | ||
return | ||
} | ||
centralManager.scanForPeripherals(withServices: nil, options: nil) | ||
|
||
cleanupOldDevices() | ||
} | ||
|
||
func disconnect() { | ||
if let device = activeDevice { | ||
device.disconnect() | ||
activeDevice = nil | ||
device.lastHeartbeatTime = nil | ||
} | ||
Storage.shared.selectedBLEDevice.value = nil | ||
} | ||
|
||
func connect(device: BLEDevice) { | ||
disconnect() | ||
|
||
if let matchedType = BackgroundRefreshType.allCases.first(where: { $0.matches(device) }) { | ||
Storage.shared.backgroundRefreshType.value = matchedType | ||
Storage.shared.selectedBLEDevice.value = device | ||
|
||
findAndUpdateDevice(with: device.id.uuidString) { device in | ||
device.isConnected = false | ||
device.lastConnected = nil | ||
} | ||
|
||
switch matchedType { | ||
case .dexcom: | ||
activeDevice = DexcomHeartbeatBluetoothDevice(address: device.id.uuidString, name: device.name, bluetoothDeviceDelegate: self) | ||
activeDevice?.connect() | ||
case .rileyLink: | ||
activeDevice = RileyLinkHeartbeatBluetoothDevice(address: device.id.uuidString, name: device.name, bluetoothDeviceDelegate: self) | ||
activeDevice?.connect() | ||
case .silentTune, .none: | ||
return | ||
} | ||
} else { | ||
LogManager.shared.log(category: .bluetooth, message: "No matching BackgroundRefreshType found for this device.") | ||
} | ||
} | ||
|
||
func stopScanning() { | ||
centralManager.stopScan() | ||
} | ||
|
||
func expectedHeartbeatInterval() -> TimeInterval? { | ||
guard let device = activeDevice else { | ||
return nil | ||
} | ||
|
||
return device.expectedHeartbeatInterval() | ||
} | ||
|
||
private func addOrUpdateDevice(_ device: BLEDevice) { | ||
if let idx = devices.firstIndex(where: { $0.id == device.id }) { | ||
var updatedDevice = devices[idx] | ||
updatedDevice.rssi = device.rssi | ||
updatedDevice.lastSeen = Date() | ||
devices[idx] = updatedDevice | ||
} else { | ||
var newDevice = device | ||
newDevice.lastSeen = Date() | ||
devices.append(newDevice) | ||
} | ||
|
||
devices = devices | ||
} | ||
|
||
private func cleanupOldDevices() { | ||
let expirationDate = Date().addingTimeInterval(-600) // 10 minutes ago | ||
|
||
// Get the selected device's ID (if any) | ||
let selectedDeviceID = Storage.shared.selectedBLEDevice.value?.id | ||
|
||
// Filter devices, keeping those seen within the last 10 minutes or the selected device | ||
devices = devices.filter { $0.lastSeen > expirationDate || $0.id == selectedDeviceID } | ||
} | ||
} | ||
|
||
// MARK: - CBCentralManagerDelegate | ||
extension BLEManager: CBCentralManagerDelegate { | ||
func centralManagerDidUpdateState(_ central: CBCentralManager) { | ||
switch central.state { | ||
case .poweredOn: | ||
LogManager.shared.log(category: .bluetooth, message: "Central poweredOn", isDebug: true) | ||
default: | ||
LogManager.shared.log(category: .bluetooth, message: "Central state = \(central.state.rawValue), not powered on.", isDebug: true) | ||
} | ||
} | ||
|
||
func centralManager(_ central: CBCentralManager, | ||
didDiscover peripheral: CBPeripheral, | ||
advertisementData: [String: Any], | ||
rssi RSSI: NSNumber) { | ||
let uuid = peripheral.identifier | ||
let services = (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID])? | ||
.map { $0.uuidString } | ||
|
||
let device = BLEDevice( | ||
id: uuid, | ||
name: peripheral.name, | ||
rssi: RSSI.intValue, | ||
advertisedServices: services, | ||
lastSeen: Date() | ||
) | ||
|
||
addOrUpdateDevice(device) | ||
} | ||
|
||
func findAndUpdateDevice(with deviceAddress: String, update: (inout BLEDevice) -> Void) { | ||
if let idx = devices.firstIndex(where: { $0.id.uuidString == deviceAddress }) { | ||
var device = devices[idx] | ||
update(&device) | ||
devices[idx] = device | ||
|
||
devices = devices | ||
} else { | ||
LogManager.shared.log(category: .bluetooth, message: "Device not found in devices array for update") | ||
} | ||
} | ||
} | ||
|
||
extension BLEManager: BluetoothDeviceDelegate { | ||
func didConnectTo(bluetoothDevice: BluetoothDevice) { | ||
LogManager.shared.log(category: .bluetooth, message: "Connected to: \(bluetoothDevice.deviceName ?? "Unknown")", isDebug: true) | ||
|
||
findAndUpdateDevice(with: bluetoothDevice.deviceAddress) { device in | ||
device.isConnected = true | ||
device.lastConnected = Date() | ||
} | ||
} | ||
|
||
func didDisconnectFrom(bluetoothDevice: BluetoothDevice) { | ||
LogManager.shared.log(category: .bluetooth, message: "Disconnect from: \(bluetoothDevice.deviceName ?? "Unknown")", isDebug: true) | ||
|
||
findAndUpdateDevice(with: bluetoothDevice.deviceAddress) { device in | ||
device.isConnected = false | ||
device.lastConnected = Date() | ||
} | ||
} | ||
|
||
func heartBeat() { | ||
guard let device = activeDevice else { | ||
return | ||
} | ||
|
||
let now = Date() | ||
guard let expectedInterval = device.expectedHeartbeatInterval() else { | ||
LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered") | ||
device.lastHeartbeatTime = now | ||
TaskScheduler.shared.checkTasksNow() | ||
return | ||
} | ||
|
||
let marginPercentage: Double = 0.15 // 15% margin | ||
let margin = expectedInterval * marginPercentage | ||
let threshold = expectedInterval + margin | ||
|
||
if let last = device.lastHeartbeatTime { | ||
let elapsedTime = now.timeIntervalSince(last) | ||
if elapsedTime > threshold { | ||
let delay = elapsedTime - expectedInterval | ||
LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered (Delayed by \(String(format: "%.1f", delay)) seconds)") | ||
} | ||
} else { | ||
LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered (First heartbeat)") | ||
} | ||
|
||
device.lastHeartbeatTime = now | ||
|
||
TaskScheduler.shared.checkTasksNow() | ||
} | ||
} |
Oops, something went wrong.