Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bluetooth Heartbeat #351

Merged
merged 29 commits into from
Jan 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
5803f48
BT heartbeat
bjorkert Jan 13, 2025
3cce5b7
Refreshing buggfix
bjorkert Jan 14, 2025
11dc1c3
Reduced heartbeat logging
bjorkert Jan 14, 2025
90bc583
Reduced schedule logging
bjorkert Jan 14, 2025
53676f8
Remove notification when application resumes
bjorkert Jan 15, 2025
6c0fd1b
Change log file name to include LoopFollow
bjorkert Jan 15, 2025
b5064b8
Buggfix minAgo scheduling
bjorkert Jan 16, 2025
be4f091
Enable audio for alarms alarm even if we not are using silent tune
bjorkert Jan 16, 2025
f512a2c
Restore speak bg functionality
bjorkert Jan 16, 2025
ec7b408
BLE GUI improvement
bjorkert Jan 16, 2025
e992052
Improved logging and logviewer
bjorkert Jan 17, 2025
802842a
Dexcom
bjorkert Jan 17, 2025
71c5891
Revert of unintentional commit of 'PING'
bjorkert Jan 17, 2025
676f3fc
Background alert adjustment
bjorkert Jan 17, 2025
a67f09a
Log device name without 'optional'
bjorkert Jan 17, 2025
31a18aa
Skip 6 minute alert for dexcom
bjorkert Jan 17, 2025
65b40bd
Removal of redundant log row
bjorkert Jan 17, 2025
20baafe
Buggfix for 'Skip 6 minute alert for dexcom'
bjorkert Jan 18, 2025
027f893
Refactoring of nighgtscout and dexcom credential gui
bjorkert Jan 18, 2025
909beab
Improved ble device selection view
bjorkert Jan 20, 2025
718d223
Fix for false Not Looping alarm using bt-heartbeat
bjorkert Jan 23, 2025
5b670f7
Introducing debug logging, implemented advanced settings as SwiftUI
bjorkert Jan 24, 2025
0dc2919
Switching from print/NSLog to log
bjorkert Jan 24, 2025
2bb8bb7
Remove old logging
bjorkert Jan 24, 2025
6ca342d
Ensure alertNotLooping has a minimum value of 15
bjorkert Jan 24, 2025
1974f5c
Remove unused logging
bjorkert Jan 25, 2025
321e9e3
Share todays and yesterdays log file.
bjorkert Jan 25, 2025
4aa0029
Download 5 devicestatus in order for Trio to have better chance of ge…
bjorkert Jan 25, 2025
a819fe1
Only fetch 5 devicestatus if last attempt resulted in suggested
bjorkert Jan 26, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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