diff --git a/Package.swift b/Package.swift index f92d649..decde89 100644 --- a/Package.swift +++ b/Package.swift @@ -44,7 +44,8 @@ let package = Package( .product(name: "OrderedCollections", package: "swift-collections"), .product(name: "SpeziFoundation", package: "SpeziFoundation"), .product(name: "SpeziBluetooth", package: "SpeziBluetooth"), - .product(name: "SpeziBluetoothServices", package: "SpeziBluetooth") + .product(name: "SpeziBluetoothServices", package: "SpeziBluetooth"), + .product(name: "SpeziViews", package: "SpeziViews") ], plugins: [.swiftLintPlugin] ), @@ -71,6 +72,13 @@ let package = Package( ], plugins: [.swiftLintPlugin] ), + .testTarget( + name: "SpeziDevicesTests", + dependencies: [ + .target(name: "SpeziDevices") + ], + plugins: [.swiftLintPlugin] + ), .testTarget( name: "SpeziOmronTests", dependencies: [ diff --git a/Sources/SpeziDevices/Devices/PairableDevice.swift b/Sources/SpeziDevices/Devices/PairableDevice.swift index 6613347..fa4fff1 100644 --- a/Sources/SpeziDevices/Devices/PairableDevice.swift +++ b/Sources/SpeziDevices/Devices/PairableDevice.swift @@ -7,7 +7,6 @@ // import SpeziBluetooth -import SpeziFoundation /// A Bluetooth device that is pairable. @@ -26,10 +25,6 @@ public protocol PairableDevice: GenericDevice { /// ``` var nearby: Bool { get } - /// Storage for pairing continuation. - var pairing: PairingContinuation { get } - // TODO: use SPI for access? - /// Connect action. /// /// Use the [`DeviceAction`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/deviceaction) property wrapper to @@ -51,18 +46,6 @@ public protocol PairableDevice: GenericDevice { /// /// This might be a value that is reported by the device for example through the manufacturer data in the Bluetooth advertisement. var isInPairingMode: Bool { get } - - /// Start pairing procedure with the device. - /// - /// This method pairs with a currently advertising Bluetooth device. - /// - Note: The ``isInPairingMode`` property determines if the device is currently pairable. - /// - /// This method is implemented by default. - /// - Important: In order to support the default implementation, you **must** interact with the ``PairingContinuation`` accordingly. - /// Particularly, you must call the ``PairingContinuation/signalPaired()`` and ``PairingContinuation/signalDisconnect()`` - /// methods when appropriate. - /// - Throws: Throws a ``DevicePairingError`` if not successful. - func pair() async throws // TODO: make a pair(with:) (passing the DevicePairings?) so the PairedDevicesx module manages the continuations? } @@ -74,46 +57,3 @@ extension PairableDevice { "\(Self.self)" } } - - -extension PairableDevice { - /// Default pairing implementation. - /// - /// The default implementation verifies that the device ``isInPairingMode``, is currently disconnected and ``nearby``. - /// It automatically connects to the device to start pairing. Pairing has a 15 second timeout by default. Pairing is considered successful once - /// ``PairingContinuation/signalPaired()`` gets called. It is considered unsuccessful once ``PairingContinuation/signalDisconnect`` is called. - /// - Throws: Throws a ``DevicePairingError`` if not successful. - public func pair() async throws { // TODO: just move the whole method to the PairedDevices thing! - guard isInPairingMode else { - throw DevicePairingError.notInPairingMode - } - - guard case .disconnected = state else { - throw DevicePairingError.invalidState - } - - guard nearby else { - throw DevicePairingError.invalidState - } - - - try await pairing.withPairingSession { - await connect() - - async let _ = withTimeout(of: .seconds(15)) { - pairing.signalTimeout() - } - - try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - pairing.assign(continuation: continuation) - } - } onCancel: { - Task { @MainActor in - pairing.signalCancellation() - await disconnect() - } - } - } - } -} diff --git a/Sources/SpeziDevices/HealthMeasurements.swift b/Sources/SpeziDevices/HealthMeasurements.swift index 82e0a80..f43c290 100644 --- a/Sources/SpeziDevices/HealthMeasurements.swift +++ b/Sources/SpeziDevices/HealthMeasurements.swift @@ -6,35 +6,123 @@ // SPDX-License-Identifier: MIT // -import Foundation import HealthKit import OSLog import Spezi import SpeziBluetooth import SpeziBluetoothServices +import SwiftUI -/// Manage and process incoming health measurements. +/// Manage and process health measurements from nearby Bluetooth Peripherals. +/// +/// Use the `HealthMeasurements` module to collect health measurements from nearby Bluetooth Peripherals like connected weight scales or +/// blood pressure cuffs. +/// - Note: Implement your device as a [`BluetoothDevice`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetoothdevice) +/// using [SpeziBluetooth](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth). +/// +/// To support `HealthMeasurements`, you need to adopt the ``HealthDevice`` protocol for your device. +/// One your device is loaded, register its measurement service with the `HealthMeasurements` module +/// by calling a suitable variant of `configureReceivingMeasurements(for:on:)`. +/// +/// ```swift +/// import SpeziDevices +/// +/// class MyDevice: HealthDevice { +/// @Service var deviceInformation = DeviceInformationService() +/// @Service var weightScale = WeightScaleService() +/// +/// @Dependency private var measurements: HealthMeasurements? +/// +/// required init() {} +/// +/// func configure() { +/// measurements?.configureReceivingMeasurements(for: self, on: weightScale) +/// } +/// } +/// ``` +/// +/// To display new measurements to the user and save them to your external data store, you can use ``MeasurementRecordedSheet``. +/// Below is a short code example. +/// +/// ```swift +/// import SpeziDevices +/// import SpeziDevicesUI +/// +/// struct MyHomeView: View { +/// @Environment(HealthMeasurements.self) private var measurements +/// +/// var body: some View { +/// ContentView() +/// .sheet(isPresented: $measurements.shouldPresentMeasurements) { +/// MeasurementRecordedSheet { measurement in +/// // handle saving the measurement +/// } +/// } +/// } +/// } +/// ``` +/// +/// - Important: Don't forget to configure the `HealthMeasurements` module in +/// your [`SpeziAppDelegate`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/speziappdelegate) /// /// ## Topics /// +/// ### Configuring Health Measurements +/// - ``init()`` +/// - ``init(_:)`` +/// /// ### Register Devices /// - ``configureReceivingMeasurements(for:on:)-8cbd0`` /// - ``configureReceivingMeasurements(for:on:)-87sgc`` +/// +/// ### Processing Measurements +/// - ``shouldPresentMeasurements`` +/// - ``pendingMeasurements`` +/// - ``discardMeasurement(_:)`` @Observable -public class HealthMeasurements { // TODO: code example? +public class HealthMeasurements { private let logger = Logger(subsystem: "ENGAGEHF", category: "HealthMeasurements") - // TODO: measurement is just discarded if the sheet closes? - // TODO: support array of new measurements? (item binding needs write access :/) => carousel? - // TODO: support long term storage - public var newMeasurement: HealthKitMeasurement? + /// Determine if UI components displaying pending measurements should be displayed. + @MainActor public var shouldPresentMeasurements = false + /// The current queue of pending measurements. + /// + /// To clear pending measurements call ``discardMeasurement(_:)``. + @MainActor public private(set) var pendingMeasurements: [HealthKitMeasurement] = [] + @MainActor @AppStorage @ObservationIgnored private var storedMeasurements: SavableDictionary - @StandardActor @ObservationIgnored private var standard: any HealthMeasurementsConstraint @Dependency @ObservationIgnored private var bluetooth: Bluetooth? /// Initialize the Health Measurements Module. - public required init() {} + public required convenience init() { + self.init("edu.stanford.spezi.SpeziDevices.HealthMeasurements.measurements-default") + } + + /// Initialize the Health Measurements Module with custom storage key. + /// - Parameter storageKey: The storage key for pending measurements. + public init(_ storageKey: String) { + self._storedMeasurements = AppStorage(wrappedValue: [:], storageKey) + } + + /// Initialize the Health Measurements Module with mock measurements. + /// - Parameter measurements: The list of measurements to inject. + @_spi(TestingSupport) + @MainActor + public convenience init(mock measurements: [HealthKitMeasurement]) { + self.init() + self.pendingMeasurements = measurements + } + + /// Configure the Module. + @_documentation(visibility: internal) + public func configure() { + Task.detached { @MainActor in + for measurement in self.storedMeasurements.values { + self.loadMeasurement(measurement.measurement, form: measurement.device) + } + } + } /// Configure receiving and processing weight measurements from the provided service. /// @@ -52,7 +140,7 @@ public class HealthMeasurements { // TODO: code example? return } logger.debug("Received new weight measurement: \(String(describing: measurement))") - handleNewMeasurement(.weight(measurement, service.features ?? []), from: hkDevice) + await handleNewMeasurement(.weight(measurement, service.features ?? []), from: hkDevice) } } @@ -72,11 +160,20 @@ public class HealthMeasurements { // TODO: code example? return } logger.debug("Received new blood pressure measurement: \(String(describing: measurement))") - handleNewMeasurement(.bloodPressure(measurement, service.features ?? []), from: hkDevice) + await handleNewMeasurement(.bloodPressure(measurement, service.features ?? []), from: hkDevice) } } + @MainActor private func handleNewMeasurement(_ measurement: BluetoothHealthMeasurement, from source: HKDevice) { + loadMeasurement(measurement, form: source) + + shouldPresentMeasurements = true + } + + @MainActor + private func loadMeasurement(_ measurement: BluetoothHealthMeasurement, form source: HKDevice) { + let healthKitMeasurement: HealthKitMeasurement switch measurement { case let .weight(measurement, feature): let sample = measurement.weightSample(source: source, resolution: feature.weightResolution) @@ -84,7 +181,7 @@ public class HealthMeasurements { // TODO: code example? let heightSample = measurement.heightSample(source: source, resolution: feature.heightResolution) logger.debug("Measurement loaded: \(String(describing: measurement))") - newMeasurement = .weight(sample, bmi: bmiSample, height: heightSample) + healthKitMeasurement = .weight(sample, bmi: bmiSample, height: heightSample) case let .bloodPressure(measurement, _): let bloodPressureSample = measurement.bloodPressureSample(source: source) let heartRateSample = measurement.heartRateSample(source: source) @@ -96,33 +193,30 @@ public class HealthMeasurements { // TODO: code example? logger.debug("Measurement loaded: \(String(describing: measurement))") - newMeasurement = .bloodPressure(bloodPressureSample, heartRate: heartRateSample) + healthKitMeasurement = .bloodPressure(bloodPressureSample, heartRate: heartRateSample) } - } - // TODO: make it closure based???? way better! - public func saveMeasurement() async throws { // TODO: rename? - if ProcessInfo.processInfo.isPreviewSimulator { - try await Task.sleep(for: .seconds(5)) - return - } - - guard let measurement = self.newMeasurement else { - logger.error("Attempting to save a nil measurement.") - return - } + // prepend to pending measurements + pendingMeasurements.insert(healthKitMeasurement, at: 0) + storedMeasurements[healthKitMeasurement.id] = StoredMeasurement(measurement: measurement, device: source) + } - logger.info("Saving the following measurement: \(String(describing: measurement))") + /// Discard a pending measurement. + /// + /// Measurements are discarded if they are no longer of interest. Either because the users discarded the measurements contents or + /// if the measurement was processed otherwise (e.g., saved to an external data store). - do { - try await standard.addMeasurement(samples: measurement.samples) - } catch { - logger.error("Failed to save measurement samples: \(error)") - throw error + /// - Parameter measurement: The pending measurement to discard. + /// - Returns: Returns `true` if the measurement was in the array of pending measurement, `false` if nothing was discarded. + @MainActor + @discardableResult + public func discardMeasurement(_ measurement: HealthKitMeasurement) -> Bool { + guard let index = self.pendingMeasurements.firstIndex(where: { $0.id == measurement.id }) else { + return false } - - logger.info("Save successful!") - newMeasurement = nil + let element = self.pendingMeasurements.remove(at: index) + storedMeasurements[element.id] = nil + return true } } @@ -136,6 +230,7 @@ extension HealthMeasurements { /// /// Loads a mock measurement to display in preview. @_spi(TestingSupport) + @MainActor public func loadMockWeightMeasurement() { let device = MockDevice.createMockDevice() @@ -150,6 +245,7 @@ extension HealthMeasurements { /// /// Loads a mock measurement to display in preview. @_spi(TestingSupport) + @MainActor public func loadMockBloodPressureMeasurement() { let device = MockDevice.createMockDevice() diff --git a/Sources/SpeziDevices/Measurements/BluetoothHealthMeasurement.swift b/Sources/SpeziDevices/Measurements/BluetoothHealthMeasurement.swift index f62d275..8fa6eb2 100644 --- a/Sources/SpeziDevices/Measurements/BluetoothHealthMeasurement.swift +++ b/Sources/SpeziDevices/Measurements/BluetoothHealthMeasurement.swift @@ -18,3 +18,51 @@ public enum BluetoothHealthMeasurement { /// A blood pressure measurement and its context. case bloodPressure(BloodPressureMeasurement, BloodPressureFeature) } + + +extension BluetoothHealthMeasurement: Hashable, Sendable {} + + +extension BluetoothHealthMeasurement: Codable { + private enum CodingKeys: String, CodingKey { + case type + case measurement + case features + } + + private enum MeasurementType: String, Codable { + case weight + case bloodPressure + } + + public init(from decoder: any Decoder) throws { + 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: .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 { + 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: .measurement) + try container.encode(feature, forKey: .features) + case let .bloodPressure(measurement, feature): + try container.encode(MeasurementType.bloodPressure, forKey: .type) + try container.encode(measurement, forKey: .measurement) + try container.encode(feature, forKey: .features) + } + } +} diff --git a/Sources/SpeziDevices/Measurements/HealthKitMeasurement.swift b/Sources/SpeziDevices/Measurements/HealthKitMeasurement.swift index f299427..d3cdf96 100644 --- a/Sources/SpeziDevices/Measurements/HealthKitMeasurement.swift +++ b/Sources/SpeziDevices/Measurements/HealthKitMeasurement.swift @@ -18,6 +18,9 @@ public enum HealthKitMeasurement { } +extension HealthKitMeasurement: Hashable {} + + extension HealthKitMeasurement { /// The collection of HealthKit samples contained in the measurement. public var samples: [HKSample] { diff --git a/Sources/SpeziDevices/Measurements/HealthMeasurementsConstraint.swift b/Sources/SpeziDevices/Measurements/HealthMeasurementsConstraint.swift deleted file mode 100644 index b3e15d3..0000000 --- a/Sources/SpeziDevices/Measurements/HealthMeasurementsConstraint.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import HealthKit -import Spezi - - -/// A Standard constraint when using the `HealthMeasurements` Module. -/// -/// A Standard must adopt this constraint when the ``HealthMeasurements`` module is loaded. -/// -/// ```swift -/// actor ExampleStandard: Standard, HealthMeasurementsConstraint { -/// func addMeasurement(samples: [HKSample]) async throws { -/// // ... be notified when new measurements arrive -/// } -/// } -/// ``` -public protocol HealthMeasurementsConstraint: Standard { - func addMeasurement(samples: [HKSample]) async throws - // TODO: document that it might throw errors, but only for visualization purposes in the UI -} diff --git a/Sources/SpeziDevices/Measurements/StoredMeasurement.swift b/Sources/SpeziDevices/Measurements/StoredMeasurement.swift new file mode 100644 index 0000000..e5b5e93 --- /dev/null +++ b/Sources/SpeziDevices/Measurements/StoredMeasurement.swift @@ -0,0 +1,74 @@ +// +// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + + +import HealthKit + + +private struct CodableHKDevice { + let name: String? + let manufacturer: String? + let model: String? + let hardwareVersion: String? + let firmwareVersion: String? + let softwareVersion: String? + let localIdentifier: String? + let udiDeviceIdentifier: String? +} + + +struct StoredMeasurement { + let measurement: BluetoothHealthMeasurement + fileprivate let codableDevice: CodableHKDevice + + var device: HKDevice { + codableDevice.hkDevice + } + + init(measurement: BluetoothHealthMeasurement, device: HKDevice) { + self.measurement = measurement + self.codableDevice = CodableHKDevice(from: device) + } +} + + +extension CodableHKDevice: Codable {} + +extension StoredMeasurement: Codable { + private enum CodingKeys: String, CodingKey { + case measurement + case codableDevice = "device" + } +} + + +extension CodableHKDevice { + var hkDevice: HKDevice { + HKDevice( + name: name, + manufacturer: manufacturer, + model: model, + hardwareVersion: hardwareVersion, + firmwareVersion: firmwareVersion, + softwareVersion: softwareVersion, + localIdentifier: localIdentifier, + udiDeviceIdentifier: udiDeviceIdentifier + ) + } + + init(from hkDevice: HKDevice) { + self.name = hkDevice.name + self.manufacturer = hkDevice.manufacturer + self.model = hkDevice.model + self.hardwareVersion = hkDevice.hardwareVersion + self.firmwareVersion = hkDevice.firmwareVersion + self.softwareVersion = hkDevice.softwareVersion + self.localIdentifier = hkDevice.localIdentifier + self.udiDeviceIdentifier = hkDevice.udiDeviceIdentifier + } +} diff --git a/Sources/SpeziDevices/Model/PairedDeviceInfo.swift b/Sources/SpeziDevices/Model/PairedDeviceInfo.swift index 9fc0304..8e3b028 100644 --- a/Sources/SpeziDevices/Model/PairedDeviceInfo.swift +++ b/Sources/SpeziDevices/Model/PairedDeviceInfo.swift @@ -30,9 +30,6 @@ public class PairedDeviceInfo { /// The last reported battery percentage of the device. public internal(set) var lastBatteryPercentage: UInt8? - // TODO: how with codability? public var additionalData: [String: Any] - // TODO: additionalData: lastSequenceNumber: UInt16?, userDatabaseNumber: UInt32?, consentCode: UIntX - /// Create new paired device information. /// - Parameters: /// - id: The CoreBluetooth device identifier @@ -60,6 +57,7 @@ public class PairedDeviceInfo { self.lastBatteryPercentage = batteryPercentage } + /// Initialize from decoder. public required convenience init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) diff --git a/Sources/SpeziDevices/Model/PairingContinuation.swift b/Sources/SpeziDevices/Model/PairingContinuation.swift index cd105b3..242b952 100644 --- a/Sources/SpeziDevices/Model/PairingContinuation.swift +++ b/Sources/SpeziDevices/Model/PairingContinuation.swift @@ -11,72 +11,34 @@ import SpeziFoundation /// Stores pairing state information. -public final class PairingContinuation { - private let lock = NSLock() - - private var isInSession = false - private var pairingContinuation: CheckedContinuation? +final class PairingContinuation { + private var pairingContinuation: CheckedContinuation /// Create a new pairing continuation management object. - public init() {} - - func withPairingSession(_ action: () async throws -> T) async throws -> T { - try lock.withLock { - guard !isInSession else { - throw DevicePairingError.busy - } - - assert(pairingContinuation == nil, "Started pairing session, but continuation was not nil.") - isInSession = true - } - - defer { - lock.withLock { - isInSession = false - } - } - - return try await action() - } - - func assign(continuation: CheckedContinuation) { - lock.withLock { - guard isInSession else { - preconditionFailure("Tried to assign continuation outside of calling withPairingSession(_:)") - } - self.pairingContinuation = continuation - } - } - - private func resumePairingContinuation(with result: Result) { - lock.withLock { - if let pairingContinuation { - pairingContinuation.resume(with: result) - self.pairingContinuation = nil - } - } + init(_ continuation: CheckedContinuation) { + self.pairingContinuation = continuation } func signalTimeout() { - resumePairingContinuation(with: .failure(TimeoutError())) + pairingContinuation.resume(with: .failure(TimeoutError())) } func signalCancellation() { - resumePairingContinuation(with: .failure(CancellationError())) + pairingContinuation.resume(with: .failure(CancellationError())) } /// Signal that the device was successfully paired. /// /// This method should always be called if the condition for a successful pairing happened. It may be called even if there isn't currently a ongoing pairing. - public func signalPaired() { - resumePairingContinuation(with: .success(())) + func signalPaired() { + pairingContinuation.resume(with: .success(())) } /// Signal that the device disconnected. /// /// This method should always be called if the condition for a successful pairing happened. It may be called even if there isn't currently a ongoing pairing. - public func signalDisconnect() { - resumePairingContinuation(with: .failure(DevicePairingError.deviceDisconnected)) + func signalDisconnect() { + pairingContinuation.resume(with: .failure(DevicePairingError.deviceDisconnected)) } } diff --git a/Sources/SpeziDevices/Model/SavableCollection.swift b/Sources/SpeziDevices/Model/SavableCollection.swift index a192bb2..49aa3ca 100644 --- a/Sources/SpeziDevices/Model/SavableCollection.swift +++ b/Sources/SpeziDevices/Model/SavableCollection.swift @@ -10,7 +10,7 @@ import OSLog struct SavableCollection { - private let storage: [Element] + private var storage: [Element] var values: [Element] { storage @@ -48,6 +48,21 @@ extension SavableCollection: ExpressibleByArrayLiteral { } +extension SavableCollection: RangeReplaceableCollection { + public init() { + self.init([]) + } + + public mutating func replaceSubrange>(_ subrange: Range, with newElements: C) { + storage.replaceSubrange(subrange, with: newElements) + } + + public mutating func removeAll(where shouldBeRemoved: (Element) throws -> Bool) rethrows { + try storage.removeAll(where: shouldBeRemoved) + } +} + + extension SavableCollection: RawRepresentable { private static var logger: Logger { Logger(subsystem: "edu.stanford.spezi.SpeziDevices", category: "\(Self.self)") diff --git a/Sources/SpeziDevices/Model/SavableDictionary.swift b/Sources/SpeziDevices/Model/SavableDictionary.swift new file mode 100644 index 0000000..d0248af --- /dev/null +++ b/Sources/SpeziDevices/Model/SavableDictionary.swift @@ -0,0 +1,105 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import OSLog + + +struct SavableDictionary { + private var storage: [Key: Value] + + var keys: Dictionary.Keys { + storage.keys + } + + var values: Dictionary.Values { + storage.values + } + + init() { + self.storage = [:] + } + + subscript(key: Key) -> Value? { + get { + storage[key] + } + _modify { + yield &storage[key] + } + set { + storage[key] = newValue + } + } +} + + +extension SavableDictionary: ExpressibleByDictionaryLiteral { + init(dictionaryLiteral elements: (Key, Value)...) { + self.storage = .init(elements) { _, rhs in + rhs + } + } +} + + +extension SavableDictionary: Collection { + public typealias Index = Dictionary.Index + public typealias Element = Dictionary.Iterator.Element + + public var startIndex: Index { + storage.startIndex + } + public var endIndex: Index { + storage.endIndex + } + + public func index(after index: Index) -> Index { + storage.index(after: index) + } + + public subscript(position: Index) -> Element { + storage[position] + } +} + + +extension SavableDictionary: RawRepresentable { + private static var logger: Logger { + Logger(subsystem: "edu.stanford.spezi.SpeziDevices", category: "\(Self.self)") + } + + var rawValue: String { + let data: Data + do { + data = try JSONEncoder().encode(storage) + } catch { + Self.logger.error("Failed to encode \(Self.self): \(error)") + return "{}" + } + guard let rawValue = String(data: data, encoding: .utf8) else { + Self.logger.error("Failed to convert data of \(Self.self) to string: \(data)") + return "{}" + } + + return rawValue + } + + init?(rawValue: String) { + guard let data = rawValue.data(using: .utf8) else { + Self.logger.error("Failed to convert string of \(Self.self) to data: \(rawValue)") + return nil + } + + do { + self.storage = try JSONDecoder().decode([Key: Value].self, from: data) + } catch { + Self.logger.error("Failed to decode \(Self.self): \(error)") + return nil + } + } +} diff --git a/Sources/SpeziDevices/PairedDevices.swift b/Sources/SpeziDevices/PairedDevices.swift index 8d6cb0f..644c6c0 100644 --- a/Sources/SpeziDevices/PairedDevices.swift +++ b/Sources/SpeziDevices/PairedDevices.swift @@ -10,19 +10,88 @@ import OrderedCollections import Spezi import SpeziBluetooth import SpeziBluetoothServices +import SpeziFoundation import SpeziViews import SwiftUI +/// Persistently pair with Bluetooth devices and automatically manage connections. +/// +/// Use the `PairedDevices` module to discover and pair ``PairedDevices`` and automatically manage connection establishment +/// of connected devices. +/// - Note: Implement your device as a [`BluetoothDevice`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetoothdevice) +/// using [SpeziBluetooth](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth). +/// +/// To support `PairedDevices`, you need to adopt the ``PairedDevices`` protocol for your device. +/// Optionally you can adopt ``BatteryPoweredDevice`` if your device supports the `BatteryService`. +/// Once your device is loaded, register it with the `PairedDevices` module by calling the ``configure(device:accessing:_:_:)`` method. +/// +/// ```swift +/// import SpeziDevices +/// +/// class MyDevice: PairableDevice { +/// @DeviceState(\.id) var id +/// @DeviceState(\.name) var name +/// @DeviceState(\.state) var state +/// @DeviceState(\.advertisementData) var advertisementData +/// @DeviceState(\.nearby) var nearby +/// +/// @Service var deviceInformation = DeviceInformationService() +/// +/// @DeviceAction(\.connect) var connect +/// @DeviceAction(\.disconnect) var disconnect +/// +/// var isInPairingMode: Bool { +/// // determine if a nearby device is in pairing mode +/// } +/// +/// @Dependency private var pairedDevices: PairedDevices? +/// +/// required init() {} +/// +/// func configure() { +/// pairedDevices?.configure(device: self, accessing: $state, $advertisementData, $nearby) +/// } +/// } +/// ``` +/// +/// To display and manage paired devices and support adding new paired devices, you can use the full-featured ``DevicesTab`` view. +/// +/// ## Topics +/// +/// ### Configuring Paired Devices +/// - ``init()`` +/// - ``init(_:)`` +/// +/// ### Register Devices +/// - ``configure(device:accessing:_:_:)`` +/// +/// ### Pairing Nearby Devices +/// - ``shouldPresentDevicePairing`` +/// - ``discoveredDevices`` +/// - ``isScanningForNearbyDevices`` +/// - ``pairedDevices`` +/// +/// ### Add Paired Device +/// - ``registerPairedDevice(_:)`` +/// +/// ### Forget Paired Device +/// - ``forgetDevice(id:)`` +/// +/// ### Manage Paired Devices +/// - ``isPaired(_:)`` +/// - ``isConnected(device:)`` +/// - ``updateName(for:name:)`` @Observable -public final class PairedDevices: Module, EnvironmentAccessible, DefaultInitializable { // TODO: Docs all interfaces +public final class PairedDevices { /// Determines if the device discovery sheet should be presented. @MainActor public var shouldPresentDevicePairing = false + /// Collection of discovered devices indexed by their Bluetooth identifier. @MainActor public private(set) var discoveredDevices: OrderedDictionary = [:] @MainActor private(set) var peripherals: [UUID: any PairableDevice] = [:] - @AppStorage @MainActor @ObservationIgnored private var _pairedDevices: SavableCollection + /// Device Information of paired devices. @MainActor public var pairedDevices: [PairedDeviceInfo] { get { access(keyPath: \.pairedDevices) @@ -34,8 +103,10 @@ public final class PairedDevices: Module, EnvironmentAccessible, DefaultInitiali } } } + @AppStorage @MainActor @ObservationIgnored private var _pairedDevices: SavableCollection @MainActor @ObservationIgnored private var pendingConnectionAttempts: [UUID: Task] = [:] + @MainActor @ObservationIgnored private var ongoingPairings: [UUID: PairingContinuation] = [:] @AppStorage("edu.stanford.spezi.SpeziDevices.ever-paired-once") @MainActor @ObservationIgnored private var everPairedDevice = false @@ -45,6 +116,9 @@ public final class PairedDevices: Module, EnvironmentAccessible, DefaultInitiali @Dependency @ObservationIgnored private var bluetooth: Bluetooth? @Dependency @ObservationIgnored private var tipKit: ConfigureTipKit + /// Determine if Bluetooth is scanning to discovery nearby devices. + /// + /// Scanning is automatically started if there hasn't been a paired device or if the discovery sheet is presented. @MainActor public var isScanningForNearbyDevices: Bool { (pairedDevices.isEmpty && !everPairedDevice) || shouldPresentDevicePairing } @@ -56,15 +130,20 @@ public final class PairedDevices: Module, EnvironmentAccessible, DefaultInitiali } - // TODO: configure automatic search without devices paired! + /// Initialize the Paired Devices Module. public required convenience init() { self.init("edu.stanford.spezi.SpeziDevices.PairedDevices.devices-default") } + /// Initialize the Paired Devices Module with custom storage key. + /// - Parameter storageKey: The storage key for storing paired device information. public init(_ storageKey: String) { self.__pairedDevices = AppStorage(wrappedValue: [], storageKey) } + + /// Configures the Module. + @_documentation(visibility: internal) public func configure() { guard bluetooth != nil else { self.logger.warning("PairedDevices Module initialized without Bluetooth dependency!") @@ -81,24 +160,39 @@ public final class PairedDevices: Module, EnvironmentAccessible, DefaultInitiali } } + /// Determine if a device is currently connected. + /// - Parameter device: The Bluetooth device identifier. + /// - Returns: Returns `true` if the device for the given identifier is currently connected. @MainActor public func isConnected(device: UUID) -> Bool { peripherals[device]?.state == .connected } + /// Determine if a device is paired. + /// - Parameter device: The device instance. + /// - Returns: Returns `true` if the given device is paired. @MainActor public func isPaired(_ device: Device) -> Bool { pairedDevices.contains { $0.id == device.id } } + /// Update the user-chosen name of a paired device. + /// - Parameters: + /// - deviceInfo: The paired device information for which to update the name. + /// - name: The new name. @MainActor public func updateName(for deviceInfo: PairedDeviceInfo, name: String) { deviceInfo.name = name - _pairedDevices = _pairedDevices // update app storage + flush() } /// Configure a device to be managed by this PairedDevices instance. - public func configure( // TODO: docs code example, docs parameters + /// - Parameters: + /// - device: The device instance to configure. + /// - state: The `@DeviceState` accessor for the `PeripheralState`. + /// - advertisements: The `@DeviceState` accessor for the current `AdvertisementData`. + /// - nearby: The `@DeviceState` accessor for the `nearby` flag. + public func configure( device: Device, accessing state: DeviceStateAccessor, _ advertisements: DeviceStateAccessor, @@ -133,6 +227,27 @@ public final class PairedDevices: Module, EnvironmentAccessible, DefaultInitiali } } + @MainActor + private func handleDeviceStateUpdated(_ device: Device, _ state: PeripheralState) { + switch state { + case .connected: + cancelConnectionAttempt(for: device) // just clear the entry + updateLastSeen(for: device) + case .disconnecting: + updateLastSeen(for: device) + case .disconnected: + ongoingPairings.removeValue(forKey: device.id)?.signalDisconnect() + + // TODO: only update if previous state was connected (might have been just connecting!) + updateLastSeen(for: device) + if isPaired(device) { + connectionAttempt(for: device) + } + default: + break + } + } + @MainActor private func discoveredPairableDevice(_ device: Device) { guard discoveredDevices[device.id] == nil else { @@ -151,9 +266,132 @@ public final class PairedDevices: Module, EnvironmentAccessible, DefaultInitiali shouldPresentDevicePairing = true } + @MainActor + private func updateBattery(for device: Device, percentage: UInt8) { + guard let index = pairedDevices.firstIndex(where: { $0.id == device.id }) else { + return + } + logger.debug("Updated battery level for \(device.label): \(percentage) %") + pairedDevices[index].lastBatteryPercentage = percentage + flush() + } + + @MainActor + private func updateLastSeen(for device: Device, lastSeen: Date = .now) { + guard let index = pairedDevices.firstIndex(where: { $0.id == device.id }) else { + return // not paired + } + logger.debug("Updated lastSeen for \(device.label): \(lastSeen) %") + pairedDevices[index].lastSeen = lastSeen + flush() + } + + @MainActor + private func handleDiscardedDevice(_ device: Device) { + // device discovery was cleared by SpeziBluetooth + self.logger.debug("\(Device.self) \(device.label) was discarded from discovered devices.") + discoveredDevices[device.id] = nil + } + + @MainActor + private func connectionAttempt(for device: some PairableDevice) { + guard isPaired(device) else { + return + } + + let previousTask = cancelConnectionAttempt(for: device) + + pendingConnectionAttempts[device.id] = Task { + await previousTask?.value // make sure its ordered + await device.connect() + } + } @MainActor - public func registerPairedDevice(_ device: Device) async { + @discardableResult + private func cancelConnectionAttempt(for device: some PairableDevice) -> Task? { + let task = pendingConnectionAttempts.removeValue(forKey: device.id) + task?.cancel() + return task + } + + @MainActor + private func flush() { + _pairedDevices = _pairedDevices // update app storage + } + + deinit { + _peripherals.removeAll() + stateSubscriptionTask = nil + } +} + + +extension PairedDevices: Module, EnvironmentAccessible, DefaultInitializable {} + +// MARK: - Device Pairing + +extension PairedDevices { + /// Start pairing procedure with the device. + /// + /// This method pairs with a currently advertising Bluetooth device. + /// - Note: The ``isInPairingMode`` property determines if the device is currently pairable. + /// + /// This method is implemented by default. + /// - Important: In order to support the default implementation, you **must** interact with the ``PairingContinuation`` accordingly. + /// Particularly, you must call the ``PairingContinuation/signalPaired()`` and ``PairingContinuation/signalDisconnect()`` + /// methods when appropriate. + /// - Throws: Throws a ``DevicePairingError`` if not successful. + @MainActor + public func pair(with device: some PairableDevice) async throws { + // TODO: update docs + + /// Default pairing implementation. + /// + /// The default implementation verifies that the device ``isInPairingMode``, is currently disconnected and ``nearby``. + /// It automatically connects to the device to start pairing. Pairing has a 15 second timeout by default. Pairing is considered successful once + /// ``PairingContinuation/signalPaired()`` gets called. It is considered unsuccessful once ``PairingContinuation/signalDisconnect`` is called. + /// - Throws: Throws a ``DevicePairingError`` if not successful. + guard ongoingPairings[device.id] == nil else { + throw DevicePairingError.busy + } + + guard device.isInPairingMode else { + throw DevicePairingError.notInPairingMode + } + + guard case .disconnected = device.state else { + throw DevicePairingError.invalidState + } + + guard device.nearby else { + throw DevicePairingError.invalidState + } + + await device.connect() + + let id = device.id + async let _ = withTimeout(of: .seconds(15)) { @MainActor in + ongoingPairings.removeValue(forKey: id)?.signalTimeout() + } + + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + ongoingPairings[id] = PairingContinuation(continuation) + } + } onCancel: { + Task { @MainActor [weak device] in + ongoingPairings.removeValue(forKey: id)?.signalCancellation() + await device?.disconnect() + } + } + + // if cancelled the continuation throws an CancellationError + await registerPairedDevice(device) + } + + @MainActor + private func registerPairedDevice(_ device: Device) async { everPairedDevice = true var batteryLevel: UInt8? @@ -194,12 +432,15 @@ public final class PairedDevices: Module, EnvironmentAccessible, DefaultInitiali } } + /// Forget a paired device. + /// - Parameter id: The Bluetooth peripheral identifier of a paired device. @MainActor public func forgetDevice(id: UUID) { pairedDevices.removeAll { info in info.id == id } + discoveredDevices.removeValue(forKey: id) let device = peripherals.removeValue(forKey: id) if let device { Task { @@ -212,65 +453,6 @@ public final class PairedDevices: Module, EnvironmentAccessible, DefaultInitiali await cancelSubscription() } } - // TODO: make sure to remove them from discoveredDevices? => should happen automatically? - } - - @MainActor - private func handleDeviceStateUpdated(_ device: Device, _ state: PeripheralState) { - switch state { - case .connected: - cancelConnectionAttempt(for: device) // just clear the entry - case .disconnected: - guard let deviceInfoIndex = pairedDevices.firstIndex(where: { $0.id == device.id }) else { - return // not paired - } - - // TODO: only update if previous state was connected (might have been just connecting!) - pairedDevices[deviceInfoIndex].lastSeen = .now - - connectionAttempt(for: device) - default: - break - } - } - - @MainActor - public func updateBattery(for device: Device, percentage: UInt8) { - guard let index = pairedDevices.firstIndex(where: { $0.id == device.id }) else { - return - } - logger.debug("Updated battery level for \(device.label): \(percentage) %") - pairedDevices[index].lastBatteryPercentage = percentage - } - - @MainActor - private func handleDiscardedDevice(_ device: Device) { // TODO: naming? - // device discovery was cleared by SpeziBluetooth - self.logger.debug("\(Device.self) \(device.label) was discarded from discovered devices.") // TODO: devices do not disappear currently??? - discoveredDevices[device.id] = nil - } - - @MainActor - private func connectionAttempt(for device: some PairableDevice) { - let previousTask = cancelConnectionAttempt(for: device) - - pendingConnectionAttempts[device.id] = Task { - await previousTask?.value // make sure its ordered - await device.connect() - } - } - - @MainActor - @discardableResult - private func cancelConnectionAttempt(for device: some PairableDevice) -> Task? { - let task = pendingConnectionAttempts.removeValue(forKey: device.id) - task?.cancel() - return task - } - - deinit { - _peripherals.removeAll() - stateSubscriptionTask = nil } } @@ -398,3 +580,5 @@ extension PairableDevice { await bluetooth.retrieveDevice(for: id) } } + +// swiftlint:disable:this file_length diff --git a/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md b/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md index cef0823..c06deaf 100644 --- a/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md +++ b/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md @@ -18,26 +18,25 @@ SPDX-License-Identifier: MIT ## Topics -### Devices - -- ``GenericBluetoothPeripheral`` -- ``GenericDevice`` -- ``BatteryPoweredDevice`` -- ``PairableDevice`` -- ``HealthDevice`` - ### Device Pairing -- ``PairableDevices`` +- ``PairedDevices`` - ``PairedDeviceInfo`` - ``DevicePairingError`` - ``PairingContinuation`` - ``ImageReference`` +### Devices + +- ``GenericBluetoothPeripheral`` +- ``GenericDevice`` +- ``BatteryPoweredDevice`` +- ``PairableDevice`` + ### Processing Measurements - ``HealthMeasurements`` -- ``HealthMeasurementsConstraint`` -- ``HealthMeasurement`` -- ``ProcessedHealthMeasurement`` +- ``HealthDevice`` +- ``BluetoothHealthMeasurement`` - +- ``HealthKitMeasurement`` diff --git a/Sources/SpeziDevices/Testing/MockDevice.swift b/Sources/SpeziDevices/Testing/MockDevice.swift index 2cbffb1..58b1aab 100644 --- a/Sources/SpeziDevices/Testing/MockDevice.swift +++ b/Sources/SpeziDevices/Testing/MockDevice.swift @@ -31,8 +31,7 @@ public final class MockDevice: PairableDevice, HealthDevice { @Service public var bloodPressure = BloodPressureService() @Service public var weightScale = WeightScaleService() - public let pairing = PairingContinuation() - public var isInPairingMode = false + public var isInPairingMode = true public init() {} } diff --git a/Sources/SpeziDevicesUI/Measurements/ConfirmMeasurementButton.swift b/Sources/SpeziDevicesUI/Measurements/ConfirmMeasurementButton.swift index d829c60..1982f2e 100644 --- a/Sources/SpeziDevicesUI/Measurements/ConfirmMeasurementButton.swift +++ b/Sources/SpeziDevicesUI/Measurements/ConfirmMeasurementButton.swift @@ -12,24 +12,29 @@ import SwiftUI struct DiscardButton: View { - @Environment(\.dismiss) var dismiss + private let discard: () -> Void + @Binding var viewState: ViewState var body: some View { - Button { - dismiss() - } label: { + Button(action: discard) { Text("Discard") .foregroundStyle(viewState == .idle ? Color.red : Color.gray) } .disabled(viewState != .idle) } + + init(viewState: Binding, discard: @escaping () -> Void) { + self._viewState = viewState + self.discard = discard + } } struct ConfirmMeasurementButton: View { private let confirm: () async throws -> Void + private let discard: () -> Void @ScaledMetric private var buttonHeight: CGFloat = 38 @Binding var viewState: ViewState @@ -45,15 +50,16 @@ struct ConfirmMeasurementButton: View { .buttonStyle(.borderedProminent) .padding([.leading, .trailing], 36) - DiscardButton(viewState: $viewState) - .padding(.top, 10) + DiscardButton(viewState: $viewState, discard: discard) + .padding(.top, 8) } .padding() } - init(viewState: Binding, confirm: @escaping () async throws -> Void) { + init(viewState: Binding, confirm: @escaping () async throws -> Void, discard: @escaping () -> Void) { self._viewState = viewState self.confirm = confirm + self.discard = discard } } @@ -62,6 +68,8 @@ struct ConfirmMeasurementButton: View { #Preview { ConfirmMeasurementButton(viewState: .constant(.idle)) { print("Save") + } discard: { + print("Discarded") } } #endif diff --git a/Sources/SpeziDevicesUI/Measurements/MeasurementLayer.swift b/Sources/SpeziDevicesUI/Measurements/MeasurementLayer.swift index 7def121..ac33985 100644 --- a/Sources/SpeziDevicesUI/Measurements/MeasurementLayer.swift +++ b/Sources/SpeziDevicesUI/Measurements/MeasurementLayer.swift @@ -14,8 +14,6 @@ import SwiftUI struct MeasurementLayer: View { private let measurement: HealthKitMeasurement - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - var body: some View { VStack(spacing: 15) { switch measurement { @@ -24,13 +22,8 @@ struct MeasurementLayer: View { case let .bloodPressure(bloodPressure, heartRate): BloodPressureMeasurementLabel(bloodPressure, heartRate: heartRate) } - /* - if dynamicTypeSize < .accessibility4 { - Text("Measurement Recorded") - .font(.title3) - .foregroundStyle(.secondary) - }*/ } + .accessibilityElement(children: .combine) .multilineTextAlignment(.center) } diff --git a/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift b/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift index 0d8ee0f..53cc05e 100644 --- a/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift +++ b/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift @@ -6,6 +6,9 @@ // SPDX-License-Identifier: MIT // +import ACarousel +import HealthKit +import OSLog @_spi(TestingSupport) import SpeziDevices import SpeziViews import SwiftUI @@ -13,38 +16,63 @@ import SwiftUI /// A sheet view displaying a newly recorded measurement. /// -/// Make sure to pass the ``ProcessedHealthMeasurement`` from the ``HealthMeasurements/newMeasurement``. +/// This view retrieves the pending measurements from the ``HealthMeasurements`` Module that is present in the SwiftUI environment. public struct MeasurementRecordedSheet: View { - private let measurement: HealthKitMeasurement + private let logger = Logger(subsystem: "edu.stanford.spezi.SpeziDevices", category: "MeasurementRecordedSheet") + private let saveSamples: ([HKSample]) async throws -> Void @Environment(HealthMeasurements.self) private var measurements + @Environment(\.dismiss) private var dismiss @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @State private var viewState = ViewState.idle + @State private var viewState = ViewState.idle + @State private var selectedMeasurementIndex: Int = 0 @State private var dynamicDetent: PresentationDetent = .medium - private var supportedTypeSize: ClosedRange { - switch measurement { + @MainActor private var selectedMeasurement: HealthKitMeasurement? { + guard selectedMeasurementIndex < measurements.pendingMeasurements.count else { + return nil + } + return measurements.pendingMeasurements[selectedMeasurementIndex] + } + + @MainActor private var supportedTypeSize: ClosedRange { + let upperBound: DynamicTypeSize = switch selectedMeasurement { case .weight: - DynamicTypeSize.xSmall...DynamicTypeSize.accessibility4 + .accessibility4 case .bloodPressure: - DynamicTypeSize.xSmall...DynamicTypeSize.accessibility3 + .accessibility3 + case nil: + .accessibility5 } + + return DynamicTypeSize.xSmall...upperBound } public var body: some View { NavigationStack { - PaneContent { - Text("Measurement Recorded") - .font(.title) - .fixedSize(horizontal: false, vertical: true) - // TODO: subtitle with the date of the measurement? - } content: { - // TODO: caoursel! - MeasurementLayer(measurement: measurement) - } action: { - ConfirmMeasurementButton(viewState: $viewState) { - try await measurements.saveMeasurement() + Group { + if measurements.pendingMeasurements.isEmpty { + ContentUnavailableView( + "No Pending Measurements", + systemImage: "heart.text.square", + description: Text("There are currently no pending measurements. Conduct a measurement with a paired device while nearby.") + ) + } else { + PaneContent { + Text("Measurement Recorded") + .font(.title) + .fixedSize(horizontal: false, vertical: true) + } subtitle: { + EmptyView() // TODO: do we have date information? + } content: { + content + } action: { + action + } + .viewStateAlert(state: $viewState) + .interactiveDismissDisabled(viewState != .idle) + .dynamicTypeSize(supportedTypeSize) } } .background { @@ -55,25 +83,59 @@ public struct MeasurementRecordedSheet: View { } } } - .viewStateAlert(state: $viewState) - .interactiveDismissDisabled(viewState != .idle) .toolbar { DismissButton() - /*ToolbarItem(placement: .cancellationAction) { - CloseButtonLayer(viewState: $viewState) - .disabled(viewState != .idle) - }*/ } - .dynamicTypeSize(supportedTypeSize) } - .presentationDetents([dynamicDetent]) + .presentationDetents([dynamicDetent]) + } + + + @ViewBuilder @MainActor private var content: some View { + if measurements.pendingMeasurements.count > 1 { + HStack { + ACarousel(measurements.pendingMeasurements, index: $selectedMeasurementIndex, spacing: 0, headspace: 0) { measurement in + MeasurementLayer(measurement: measurement) + } + } + CarouselDots(count: measurements.pendingMeasurements.count, selectedIndex: $selectedMeasurementIndex) + } else if let measurement = measurements.pendingMeasurements.first { + MeasurementLayer(measurement: measurement) + } + } + + @ViewBuilder @MainActor private var action: some View { + ConfirmMeasurementButton(viewState: $viewState) { + guard let selectedMeasurement else { + return + } + + do { + try await saveSamples(selectedMeasurement.samples) + } catch { + logger.error("Failed to save measurement samples: \(error)") + throw error + } + + measurements.discardMeasurement(selectedMeasurement) + + logger.info("Saved measurement: \(String(describing: selectedMeasurement))") + dismiss() + } discard: { + guard let selectedMeasurement else { + return + } + measurements.discardMeasurement(selectedMeasurement) + if measurements.pendingMeasurements.isEmpty { + dismiss() + } + } } /// Create a new measurement sheet. - /// - Parameter measurement: The processed measurement to display. - public init(measurement: HealthKitMeasurement) { - self.measurement = measurement + public init(save saveSamples: @escaping ([HKSample]) -> Void) { + self.saveSamples = saveSamples } } @@ -82,29 +144,63 @@ public struct MeasurementRecordedSheet: View { #Preview { Text(verbatim: "") .sheet(isPresented: .constant(true)) { - MeasurementRecordedSheet(measurement: .weight(.mockWeighSample)) + MeasurementRecordedSheet { samples in + print("Saving samples \(samples)") + } } - .previewWith(standard: TestMeasurementStandard()) { - HealthMeasurements() + .previewWith { + HealthMeasurements(mock: [.weight(.mockWeighSample)]) } } #Preview { Text(verbatim: "") .sheet(isPresented: .constant(true)) { - MeasurementRecordedSheet(measurement: .weight(.mockWeighSample, bmi: .mockBmiSample, height: .mockHeightSample)) + MeasurementRecordedSheet { samples in + print("Saving samples \(samples)") + } } - .previewWith(standard: TestMeasurementStandard()) { - HealthMeasurements() + .previewWith { + HealthMeasurements(mock: [.weight(.mockWeighSample, bmi: .mockBmiSample, height: .mockHeightSample)]) + } +} + +#Preview { + Text(verbatim: "") + .sheet(isPresented: .constant(true)) { + MeasurementRecordedSheet { samples in + print("Saving samples \(samples)") + } + } + .previewWith { + HealthMeasurements(mock: [.bloodPressure(.mockBloodPressureSample, heartRate: .mockHeartRateSample)]) } } #Preview { Text(verbatim: "") .sheet(isPresented: .constant(true)) { - MeasurementRecordedSheet(measurement: .bloodPressure(.mockBloodPressureSample, heartRate: .mockHeartRateSample)) + MeasurementRecordedSheet { samples in + print("Saving samples \(samples)") + } + } + .previewWith { + HealthMeasurements(mock: [ + .weight(.mockWeighSample, bmi: .mockBmiSample, height: .mockHeightSample), + .bloodPressure(.mockBloodPressureSample, heartRate: .mockHeartRateSample), + .weight(.mockWeighSample) + ]) + } +} + +#Preview { + Text(verbatim: "") + .sheet(isPresented: .constant(true)) { + MeasurementRecordedSheet { samples in + print("Saving samples \(samples)") + } } - .previewWith(standard: TestMeasurementStandard()) { + .previewWith { HealthMeasurements() } } diff --git a/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift b/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift index cef802e..7460e86 100644 --- a/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift +++ b/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift @@ -39,12 +39,11 @@ public struct AccessorySetupSheet: View wher } else if !devices.isEmpty { PairDeviceView(devices: devices, appName: appName, state: $pairingState) { device in do { - try await device.pair() + try await pairedDevices.pair(with: device) } catch { Self.logger.error("Failed to pair device \(device.id), \(device.name ?? "unnamed"): \(error)") throw error } - await pairedDevices.registerPairedDevice(device) } } else { DiscoveryView() diff --git a/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift b/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift index ea89f99..deb72e9 100644 --- a/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift +++ b/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift @@ -42,7 +42,7 @@ struct PairDeviceView: View where Collection ACarousel(devices, id: \.id, index: $selectedDeviceIndex, spacing: 0, headspace: 0) { device in AccessoryImageView(device) } - .frame(maxHeight: 150) + .frame(maxHeight: 150) CarouselDots(count: devices.count, selectedIndex: $selectedDeviceIndex) } else if let device = devices.first { AccessoryImageView(device) diff --git a/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings b/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings index 3af7142..1d790d5 100644 --- a/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings +++ b/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings @@ -358,6 +358,9 @@ } } } + }, + "No Pending Measurements" : { + }, "OK" : { "localizations" : { @@ -481,6 +484,9 @@ } } } + }, + "There are currently no pending measurements. Conduct a measurement with a paired device while nearby." : { + }, "This device was last seen at %@" : { "localizations" : { diff --git a/Sources/SpeziDevicesUI/Testing/TestMeasurementStandard.swift b/Sources/SpeziDevicesUI/Testing/TestMeasurementStandard.swift deleted file mode 100644 index c306f01..0000000 --- a/Sources/SpeziDevicesUI/Testing/TestMeasurementStandard.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project -// -// SPDX-FileCopyrightText: 2024 Stanford University -// -// SPDX-License-Identifier: MIT -// - -import HealthKit -import Spezi -import SpeziDevices - - -#if DEBUG || TEST -actor TestMeasurementStandard: Standard, HealthMeasurementsConstraint { - func addMeasurement(samples: [HKSample]) async throws { - print("Adding sample \(samples)") - } -} -#endif diff --git a/Sources/SpeziOmron/OmronOptionService.swift b/Sources/SpeziOmron/OmronOptionService.swift index d4d45e0..7f7b2cb 100644 --- a/Sources/SpeziOmron/OmronOptionService.swift +++ b/Sources/SpeziOmron/OmronOptionService.swift @@ -20,10 +20,6 @@ public final class OmronOptionService: BluetoothService, @unchecked Sendable { @Characteristic(id: "2A52", notify: true) private var recordAccessControlPoint: RecordAccessControlPoint? - // TODO: OMRON Measurement (BLM): C195DA8A-0E23-4582-ACD8-D446C77C45DE - // - Getting extended blood pressure measurement index by OMRON. - // TODO: Body Composition: 8FF2DDFB-4A52-4CE5-85A4-D2F97917792A - public init() {} diff --git a/Tests/SpeziDevicesTests/SpeziDevicesTests.swift b/Tests/SpeziDevicesTests/SpeziDevicesTests.swift new file mode 100644 index 0000000..1c18f88 --- /dev/null +++ b/Tests/SpeziDevicesTests/SpeziDevicesTests.swift @@ -0,0 +1,75 @@ +// +// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SpeziBluetoothServices +@_spi(TestingSupport) import SpeziDevices +import XCTest + + +final class SpeziDevicesTests: XCTestCase { + func testBluetoothMeasurementCodable() throws { // swiftlint:disable:this function_body_length + let weightMeasurement = + """ + { + "type": "weight", + "measurement": {"weight":8400, "unit":"si", "timeStamp":{"minutes":33,"day":5,"year":2024,"hours":12,"seconds":11,"month":6}}, + "features": 6 + } + + """ + let bloodPressureMeasurement = + """ + { + "type":"bloodPressure", + "measurement":{ + "unit":"mmHg", + "systolicValue":62470, + "diastolicValue":62080, + "timeStamp":{"seconds":11,"day":5,"hours":12,"year":2024,"month":6,"minutes":33}, + "meanArterialPressure":62210, + "pulseRate":62060, + "measurementStatus":0, + "userId":1 + }, + "features":257 + } + + """ + + let decoder = JSONDecoder() + + let weightData = try XCTUnwrap(weightMeasurement.data(using: .utf8)) + let pressureData = try XCTUnwrap(bloodPressureMeasurement.data(using: .utf8)) + + let decodedWeight = try decoder.decode(BluetoothHealthMeasurement.self, from: weightData) + let decodedPressure = try decoder.decode(BluetoothHealthMeasurement.self, from: pressureData) + + let dateTime = DateTime(year: 2024, month: .june, day: 5, hours: 12, minutes: 33, seconds: 11) + XCTAssertEqual( + decodedWeight, + .weight(.init(weight: 8400, unit: .si, timeStamp: dateTime), [.bmiSupported, .multipleUsersSupported]) + ) + + XCTAssertEqual( + decodedPressure, + .bloodPressure( + .init( + systolic: 103, + diastolic: 64, + meanArterialPressure: 77, + unit: .mmHg, + timeStamp: dateTime, + pulseRate: 62, + userId: 1, + measurementStatus: [] + ), + [.bodyMovementDetectionSupported, .userFacingTimeSupported] + ) + ) + } +}