Skip to content

Commit

Permalink
Merge pull request #351 from loopandlearn/bt-heartbeat
Browse files Browse the repository at this point in the history
Bluetooth Heartbeat
  • Loading branch information
marionbarker authored Jan 27, 2025
2 parents d676af5 + a819fe1 commit ae5f5c0
Show file tree
Hide file tree
Showing 71 changed files with 2,902 additions and 809 deletions.
188 changes: 184 additions & 4 deletions LoopFollow.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

29 changes: 13 additions & 16 deletions LoopFollow/Application/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,24 +18,25 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
let notificationCenter = UNUserNotificationCenter.current()

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.

LogManager.shared.log(category: .general, message: "App started")
LogManager.shared.cleanupOldLogs()

let options: UNAuthorizationOptions = [.alert, .sound, .badge]
notificationCenter.requestAuthorization(options: options) {
(didAllow, error) in
if !didAllow {
print("User has declined notifications")
LogManager.shared.log(category: .general, message: "User has declined notifications")
}
}

let store = EKEventStore()
store.requestCalendarAccess { (granted, error) in
if !granted {
print("Failed to get calendar access: \(String(describing: error))")
LogManager.shared.log(category: .calendar, message: "Failed to get calendar access: \(String(describing: error))")
return
}
}

let action = UNNotificationAction(identifier: "OPEN_APP_ACTION", title: "Open App", options: .foreground)
let category = UNNotificationCategory(identifier: "loopfollow.background.alert", actions: [action], intentIdentifiers: [], options: [])
UNUserNotificationCenter.current().setNotificationCategories([category])
Expand All @@ -44,7 +45,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate {

// Ensure ViewControllerManager is initialized
_ = ViewControllerManager.shared


_ = BLEManager.shared

return true
}

Expand All @@ -56,23 +59,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
}

// MARK: UISceneSession Lifecycle

func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

// This application should be called in background every X Minutes
UIApplication.shared.setMinimumBackgroundFetchInterval(
TimeInterval(UserDefaultsRepository.backgroundRefreshFrequency.value * 60)
)

// set "prevent screen lock" to ON when the app is started for the first time
if !UserDefaultsRepository.screenlockSwitchState.exists {
UserDefaultsRepository.screenlockSwitchState.value = true
}

// set the "prevent screen lock" option when the app is started
// This method doesn't seem to be working anymore. Added to view controllers as solution offered on SO
UIApplication.shared.isIdleTimerDisabled = UserDefaultsRepository.screenlockSwitchState.value

return true
}

Expand Down
1 change: 0 additions & 1 deletion LoopFollow/Application/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,4 +127,3 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
updateQuickActions()
}
}

35 changes: 35 additions & 0 deletions LoopFollow/BackgroundRefresh/BT/BLEDevice.swift
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 LoopFollow/BackgroundRefresh/BT/BLEDeviceSelectionView.swift
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
}
}
215 changes: 215 additions & 0 deletions LoopFollow/BackgroundRefresh/BT/BLEManager.swift
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()
}
}
Loading

0 comments on commit ae5f5c0

Please sign in to comment.