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 Device Pairing Infrastructure and Health Measurements #1

Merged
merged 77 commits into from
Jul 2, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
77 commits
Select commit Hold shift + click to select a range
cb77d17
Basic project setup
Supereg Jun 9, 2024
add69bb
Omron services
Supereg Jun 10, 2024
c54b3f4
Upgrade to SpeziNetworking 2.0
Supereg Jun 13, 2024
a04c44b
Move all infrastructure to SpeziDevices
Supereg Jun 21, 2024
6687758
Make some interface public
Supereg Jun 21, 2024
b9e1856
Add dedicated HealthDevice protocol
Supereg Jun 21, 2024
f914a4a
Ensure UserSlot is publicy accessible
Supereg Jun 21, 2024
ae86e0e
A bunch of documentation and adjustments
Supereg Jun 21, 2024
3c64ead
Resolve some issues
Supereg Jun 21, 2024
7510c5d
Move BluetoothViews to SpeziDevices
Supereg Jun 21, 2024
4aa2d0a
Minor fixes
Supereg Jun 21, 2024
6ca3147
Fix pairing session check
Supereg Jun 22, 2024
4ac6757
Move HealthMeasurements to SpeziDevices
Supereg Jun 22, 2024
deeb620
Make accessible
Supereg Jun 22, 2024
0d633cb
Provide access to state
Supereg Jun 22, 2024
f3212fa
Item bidning fix
Supereg Jun 22, 2024
e451b2d
A bunch of progress, updating to latest SpeziBluetooth prototype
Supereg Jun 24, 2024
df765ba
Rename and provide new configure interface
Supereg Jun 24, 2024
02fd0c9
Provide similar mechnaism for health measurements
Supereg Jun 24, 2024
dbc96bf
Resolve a lot of todos and restructure some parts
Supereg Jun 24, 2024
1c6450f
Final touches on the API design
Supereg Jun 25, 2024
c66bcea
Fix closure
Supereg Jun 25, 2024
45d3498
signalDevicePaired
Supereg Jun 25, 2024
797c72f
Only update last seen if we were previously connected
Supereg Jun 25, 2024
3519a06
Small changes
Supereg Jun 25, 2024
6927155
Debug
Supereg Jun 25, 2024
cc0ac93
Move NavigationLink into dedicated view?
Supereg Jun 25, 2024
55ac1ee
Resolve all todos
Supereg Jun 25, 2024
5011ef2
Some docs structuring and REUSE
Supereg Jun 25, 2024
77d93db
Remove support for macOS, tvOS and watchOS
Supereg Jun 25, 2024
151e164
Fix workflow
Supereg Jun 25, 2024
561eee2
Setup UITest App
Supereg Jun 25, 2024
5835b20
Run with StanfordSpezi workflows
Supereg Jun 25, 2024
8e28290
Project setup
Supereg Jun 25, 2024
52606e1
fix
Supereg Jun 25, 2024
ef11156
Other name?
Supereg Jun 25, 2024
7f7d6e7
Can't run visionOS simulator right now
Supereg Jun 25, 2024
1e3a084
Fix coverage reports
Supereg Jun 25, 2024
3f2a440
Add HealthMeasurements Tests
Supereg Jun 26, 2024
f4f0591
Update SpeziViews
Supereg Jun 26, 2024
e7f1328
Add PairedDevices tests
Supereg Jun 26, 2024
8e3698b
Add SpeziOmron tests and some fixes
Supereg Jun 26, 2024
13218c3
Add UI test app
Supereg Jun 26, 2024
ca16a2f
Add UI tests
Supereg Jun 27, 2024
c2ac2d7
Include other targets in coverage reports as well
Supereg Jun 27, 2024
833e57d
Add UI tests for generic views, fix unit tests
Supereg Jun 27, 2024
0143dd2
Resolve last few todos
Supereg Jun 27, 2024
8e19309
Test pairing hint
Supereg Jun 27, 2024
0d2c3b1
Fix swiftlint
Supereg Jun 27, 2024
c349ac0
Allow to detect if a pairing was successful.
Supereg Jun 27, 2024
f23dd2b
Don't do if debug || test
Supereg Jun 27, 2024
c617acc
Move Omron devices to SpeziDevices
Supereg Jun 27, 2024
947e7c4
Make sure to automatically update stored ImageReference
Supereg Jun 27, 2024
7113fee
Logging
Supereg Jun 27, 2024
59d9265
Fix double storage
Supereg Jun 27, 2024
8cc8ae7
Discaridng?
Supereg Jun 27, 2024
0571017
Minor fixes and new todos
Supereg Jun 27, 2024
d1d1a82
Less todos and always update icon
Supereg Jun 27, 2024
15306ed
Minor tests
Supereg Jun 27, 2024
9b00baa
Release versions
Supereg Jun 27, 2024
7da6d65
Always determine icon at runtime
Supereg Jun 27, 2024
bce9494
Fix HealthMeasurements Storage issue
Supereg Jun 27, 2024
e123dfe
Move to SwiftData for paired devices
Supereg Jun 28, 2024
0340294
Revert some changes for now
Supereg Jun 28, 2024
1fc7fad
Some progress with SwiftData
Supereg Jun 28, 2024
169da42
Seems to consistently NOT crash for now
Supereg Jun 28, 2024
a4046a8
Replace carousel with TabView
Supereg Jul 1, 2024
a7283f9
Remove ACarousel, replacing with TabView
Supereg Jul 1, 2024
2a54884
feedback
Supereg Jul 1, 2024
5daa2c1
Make storage work
Supereg Jul 1, 2024
8af78d0
Documentation progress and resolved some feebdack
Supereg Jul 2, 2024
dc365b1
Dedicated storage location
Supereg Jul 2, 2024
7d27b53
Full workarounds
Supereg Jul 2, 2024
59e35a3
Fix last measurement appearing
Supereg Jul 2, 2024
cee44d8
Docs and README
Supereg Jul 2, 2024
2b500c7
Final touches
Supereg Jul 2, 2024
50a6c0a
Update page indicator tests
Supereg Jul 2, 2024
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
6 changes: 3 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ import PackageDescription


#if swift(<6)
let swiftConcurrency: SwiftSetting = .enableExperimentalFeature("SwiftConcurrency")
let swiftConcurrency: SwiftSetting = .enableExperimentalFeature("StrictConcurrency")
#else
let swiftConcurrency: SwiftSetting = .enableUpcomingFeature("SwiftConcurrency")
let swiftConcurrency: SwiftSetting = .enableUpcomingFeature("StrictConcurrency")
#endif


Expand All @@ -37,7 +37,7 @@ let package = Package(
.package(url: "https://github.com/StanfordSpezi/Spezi.git", from: "1.4.0"),
.package(url: "https://github.com/StanfordSpezi/SpeziViews.git", from: "1.5.0"),
.package(url: "https://github.com/StanfordSpezi/SpeziBluetooth", from: "2.0.0"),
.package(url: "https://github.com/StanfordSpezi/SpeziNetworking", branch: "feature/raw-representable"),
.package(url: "https://github.com/StanfordSpezi/SpeziNetworking", from: "2.1.1"),
.package(url: "https://github.com/StanfordBDHG/XCTestExtensions.git", .upToNextMinor(from: "0.4.11"))
] + swiftLintPackage(),
targets: [
Expand Down
2 changes: 1 addition & 1 deletion Sources/SpeziDevices/Devices/GenericDevice.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import SpeziBluetoothServices
/// A generic Bluetooth device.
///
/// A generic Bluetooth device that provides access to basic device information.
public protocol GenericDevice: BluetoothDevice, GenericBluetoothPeripheral, Identifiable {
public protocol GenericDevice: BluetoothDevice, GenericBluetoothPeripheral, Identifiable, Sendable {
/// An icon that is used to visually present the device to the user.
static var icon: ImageReference? { get }

Expand Down
34 changes: 16 additions & 18 deletions Sources/SpeziDevices/HealthMeasurements.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@
// SPDX-License-Identifier: MIT
//

import OrderedCollections
import HealthKit
@preconcurrency import HealthKit
import OSLog
import Spezi
import SpeziBluetooth
Expand Down Expand Up @@ -84,7 +83,7 @@ import SwiftUI
/// - ``pendingMeasurements``
/// - ``discardMeasurement(_:)``
@Observable
public class HealthMeasurements {
public final class HealthMeasurements: @unchecked Sendable {
private let logger = Logger(subsystem: "ENGAGEHF", category: "HealthMeasurements")

/// Determine if UI components displaying pending measurements should be displayed.
Expand Down Expand Up @@ -185,7 +184,7 @@ public class HealthMeasurements {
}

if let modelContainer {
let storeMeasurement = StoredMeasurement(associatedMeasurement: id, measurement: .init(from: measurement), device: source)
let storeMeasurement = StoredMeasurement(associatedMeasurement: id, measurement: measurement, device: source)
modelContainer.mainContext.insert(storeMeasurement)
} else {
logger.warning("Measurement \(id) could not be persisted on disk due to missing ModelContainer!")
Expand Down Expand Up @@ -273,8 +272,10 @@ extension HealthMeasurements {
return
}

var fetchAll = FetchDescriptor<StoredMeasurement>()
// TODO: fetchAll.includePendingChanges = true
var fetchAll = FetchDescriptor<StoredMeasurement>(
sortBy: [SortDescriptor(\.storageDate)]
)
fetchAll.includePendingChanges = true

let context = modelContainer.mainContext
let storedMeasurements: [StoredMeasurement]
Expand All @@ -285,16 +286,8 @@ extension HealthMeasurements {
return
}

print("hasChanges1: \(context.hasChanges == true)")
try? context.save()

for storedMeasurement in storedMeasurements {
print("Looking at \(storedMeasurement)")
print("checking id \(storedMeasurement.associatedMeasurement)")
print("Otherwise asdf: \(storedMeasurement.device)")
print("Sample: \(storedMeasurement.measurement)")

guard let id = loadMeasurement(storedMeasurement.measurement.measurement, form: storedMeasurement.device) else {
guard let id = loadMeasurement(storedMeasurement.healthMeasurement, form: storedMeasurement.device) else {
context.delete(storedMeasurement)
continue
}
Expand All @@ -303,10 +296,15 @@ extension HealthMeasurements {
// However, when we redo the conversion, the identifier changes.
// Therefore, we need to make sure to update all associated ids after loading.
storedMeasurement.associatedMeasurement = id
print("hasChanges1: \(context.hasChanges == true)")
try? context.save()
}
// TODO: save all?

if context.hasChanges {
do {
try context.save()
} catch {
logger.error("Failed to save updated measurement id associations: \(error)")
}
}
}
}

Expand Down
229 changes: 17 additions & 212 deletions Sources/SpeziDevices/Measurements/BluetoothHealthMeasurement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,191 +17,6 @@ public enum BluetoothHealthMeasurement {
case weight(WeightMeasurement, WeightScaleFeature)
/// A blood pressure measurement and its context.
case bloodPressure(BloodPressureMeasurement, BloodPressureFeature)

private var type: SwiftDataBluetoothHealthMeasurementWorkaroundContainer.MeasurementType {
switch self {
case .weight:
.weight
case .bloodPressure:
.bloodPressure
}
}
}

import SpeziNumerics
import SpeziBluetoothServices
private struct BloodPressureMeasurementCopy: Codable {
/// The systolic value of the blood pressure measurement.
///
/// The unit of this value is defined by the ``unit-swift.property`` property.
public let systolicValue: MedFloat16
/// The diastolic value of the blood pressure measurement.
///
/// The unit of this value is defined by the ``unit-swift.property`` property.
public let diastolicValue: MedFloat16
/// The Mean Arterial Pressure (MAP)
///
/// The unit of this value is defined by the ``unit-swift.property`` property.
public let meanArterialPressure: MedFloat16
/// The unit of the blood pressure measurement values.
///
/// This property defines the unit of the ``systolicValue``, ``diastolicValue`` and ``meanArterialPressure`` properties.
public let unit: String

/// The timestamp of the measurement.
public let timeStamp: DateTime?

/// The pulse rate in beats per minute.
public let pulseRate: MedFloat16?

/// The associated user of the blood pressure measurement.
///
/// This value can be used to differentiate users if the device supports multiple users.
/// - Note: The special value of `0xFF` (`UInt8.max`) is used to represent an unknown user.
///
/// The values are left to the implementation but should be unique per device.
public let userId: UInt8?

/// Additional metadata information of a blood pressure measurement.
public let measurementStatus: UInt16?


var measurement: BloodPressureMeasurement {
.init(
systolic: systolicValue,
diastolic: diastolicValue,
meanArterialPressure: meanArterialPressure,
unit: .init(rawValue: unit) ?? .mmHg,
timeStamp: timeStamp,
pulseRate: pulseRate,
userId: userId,
measurementStatus: measurementStatus.map { .init(rawValue: $0) }
)
}

init(from measurement: BloodPressureMeasurement) {
self.systolicValue = measurement.systolicValue
self.diastolicValue = measurement.diastolicValue
self.meanArterialPressure = measurement.meanArterialPressure
self.unit = measurement.unit.rawValue
self.timeStamp = measurement.timeStamp
self.pulseRate = measurement.pulseRate
self.userId = measurement.userId
self.measurementStatus = measurement.measurementStatus?.rawValue
}

func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.systolicValue, forKey: .systolicValue)
try container.encode(self.diastolicValue, forKey: .diastolicValue)
try container.encode(self.meanArterialPressure, forKey: .meanArterialPressure)
try container.encode(self.unit, forKey: .unit)
try container.encodeIfPresent(self.timeStamp, forKey: .timeStamp)
try container.encodeIfPresent(self.pulseRate, forKey: .pulseRate)
try container.encodeIfPresent(self.userId, forKey: .userId)
try container.encodeIfPresent(self.measurementStatus, forKey: .measurementStatus)
}

enum CodingKeys: CodingKey {
case systolicValue
case diastolicValue
case meanArterialPressure
case unit
case timeStamp
case pulseRate
case userId
case measurementStatus
}

init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.systolicValue = try container.decode(MedFloat16.self, forKey: .systolicValue)
self.diastolicValue = try container.decode(MedFloat16.self, forKey: .diastolicValue)
self.meanArterialPressure = try container.decode(MedFloat16.self, forKey: .meanArterialPressure)
self.unit = try container.decode(String.self, forKey: .unit)
self.timeStamp = try container.decodeIfPresent(DateTime.self, forKey: .timeStamp)
self.pulseRate = try container.decodeIfPresent(MedFloat16.self, forKey: .pulseRate)
self.userId = try container.decodeIfPresent(UInt8.self, forKey: .userId)
self.measurementStatus = try container.decodeIfPresent(UInt16.self, forKey: .measurementStatus)
}
}

struct SwiftDataBluetoothHealthMeasurementWorkaroundContainer: Codable {
enum MeasurementType: String, Codable {
case weight
case bloodPressure
}

private let type: MeasurementType

private var bloodPressureMeasurement: BloodPressureMeasurementCopy?
private var bloodPressureFeatures: BloodPressureFeature.RawValue?

private var weightMeasurement: WeightMeasurement?
private var weightScaleFeatures: WeightScaleFeature.RawValue?

var measurement: BluetoothHealthMeasurement {
switch type {
case .bloodPressure:
guard let bloodPressureMeasurement, let bloodPressureFeatures else {
preconditionFailure("Inconsistent type")
}
return .bloodPressure(bloodPressureMeasurement.measurement, .init(rawValue: bloodPressureFeatures))
case .weight:
guard let weightMeasurement, let weightScaleFeatures else {
preconditionFailure("Inconsistent type")
}
return .weight(weightMeasurement, .init(rawValue: weightScaleFeatures))
}
}

init(from measurement: BluetoothHealthMeasurement) {
switch measurement {
case let .bloodPressure(measurement, feature):
type = .bloodPressure
bloodPressureMeasurement = .init(from: measurement)
bloodPressureFeatures = feature.rawValue
weightMeasurement = nil
weightScaleFeatures = nil
case let .weight(measurement, features):
type = .weight
bloodPressureMeasurement = nil
bloodPressureFeatures = nil
// bloodPressureMeasurement2 = BloodPressureMeasurementCopy(from: .mock())
weightMeasurement = measurement
weightScaleFeatures = features.rawValue
}
}

func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.type, forKey: .type)
try container.encodeIfPresent(self.bloodPressureMeasurement, forKey: .bloodPressureMeasurement)
try container.encodeIfPresent(self.bloodPressureFeatures, forKey: .bloodPressureFeatures)
try container.encodeIfPresent(self.weightMeasurement, forKey: .weightMeasurement)
try container.encodeIfPresent(self.weightScaleFeatures, forKey: .weightScaleFeatures)
}

enum CodingKeys: CodingKey {
case type
case bloodPressureMeasurement
case bloodPressureFeatures
case weightMeasurement
case weightScaleFeatures
}

init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.type = try container.decode(SwiftDataBluetoothHealthMeasurementWorkaroundContainer.MeasurementType.self, forKey: .type)
switch type {
case .bloodPressure:
self.bloodPressureMeasurement = try container.decodeIfPresent(BloodPressureMeasurementCopy.self, forKey: .bloodPressureMeasurement)
self.bloodPressureFeatures = try container.decodeIfPresent(BloodPressureFeature.RawValue.self, forKey: .bloodPressureFeatures)
case .weight:
self.weightMeasurement = try container.decodeIfPresent(WeightMeasurement.self, forKey: .weightMeasurement)
self.weightScaleFeatures = try container.decodeIfPresent(WeightScaleFeature.RawValue.self, forKey: .weightScaleFeatures)
}
}
}


Expand All @@ -216,48 +31,38 @@ extension BluetoothHealthMeasurement: Codable {

private enum CodingKeys: String, CodingKey {
case type
case bloodPressure
case bloodPressureFeatures

case weight
case weightScaleFeatures
case measurement
case features
}

public init(from decoder: any Decoder) throws {
do {
print("Decoding \(Self.self)")
let container = try decoder.container(keyedBy: CodingKeys.self)
let container = try decoder.container(keyedBy: CodingKeys.self)

let type = try container.decode(MeasurementType.self, forKey: .type)
switch type {
case .weight:
let measurement = try container.decode(WeightMeasurement.self, forKey: .bloodPressure)
let features = try container.decode(WeightScaleFeature.self, forKey: .bloodPressureFeatures)
self = .weight(measurement, features)
case .bloodPressure:
let measurement = try container.decode(BloodPressureMeasurement.self, forKey: .weight)
let features = try container.decode(BloodPressureFeature.self, forKey: .weightScaleFeatures)
self = .bloodPressure(measurement, features)
}
} catch {
print("FAILED TO DECODE: \(error)")
throw error
let type = try container.decode(MeasurementType.self, forKey: .type)
switch type {
case .weight:
let measurement = try container.decode(WeightMeasurement.self, forKey: .measurement)
let features = try container.decode(WeightScaleFeature.self, forKey: .features)
self = .weight(measurement, features)
case .bloodPressure:
let measurement = try container.decode(BloodPressureMeasurement.self, forKey: .measurement)
let features = try container.decode(BloodPressureFeature.self, forKey: .features)
self = .bloodPressure(measurement, features)
}
}

public func encode(to encoder: any Encoder) throws {
print("encoding \(Self.self)")
var container = encoder.container(keyedBy: CodingKeys.self)

switch self {
case let .weight(measurement, feature):
try container.encode(MeasurementType.weight, forKey: .type)
try container.encode(measurement, forKey: .bloodPressure)
try container.encode(feature, forKey: .bloodPressureFeatures)
try container.encode(measurement, forKey: .measurement)
try container.encode(feature, forKey: .features)
case let .bloodPressure(measurement, feature):
try container.encode(MeasurementType.bloodPressure, forKey: .type)
try container.encode(measurement, forKey: .weight)
try container.encode(feature, forKey: .weightScaleFeatures)
try container.encode(measurement, forKey: .measurement)
try container.encode(feature, forKey: .features)
}
}
}
Loading
Loading