Skip to content

Commit

Permalink
BP7000 and EVOLV Support (#7)
Browse files Browse the repository at this point in the history
# BP7000 and EVOLV Support

## ♻️ Current situation & Problem
This PR adds support for Omron BP7000 and Omron EVOLV blood pressure
cuffs. The implementation of both devices is still achieved using the
`OmronBloodPressureCuff` device. The device icon can now be supplied
dynamically by the device class based on device features. This makes it
possible for a device implementation to visually distinguish between
multiple different models. Currently, we only support to differentiate
between different based on the peripheral name.

## ⚙️ Release Notes 
* Add support for Omron BP7000 and Omron EVOLV blood pressure cuffs.
* Dynamically select the device icon based on device features.
* Provide dark-mode compatible images for all supported devices.


## 📚 Documentation
Documentation was updated to reflect the changes made by this PR.


## ✅ Testing
Testing was added to test new components.


## 📝 Code of Conduct & Contributing Guidelines 

By submitting creating this pull request, you agree to follow our [Code
of
Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md):
- [x] I agree to follow the [Code of
Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md).
  • Loading branch information
Supereg authored Jul 18, 2024
1 parent 641db17 commit ac008a4
Show file tree
Hide file tree
Showing 48 changed files with 456 additions and 81 deletions.
1 change: 1 addition & 0 deletions LICENSES/LicenseRef-Omron-Healthcare.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Refer to https://www.omron-healthcare.com/terms-of-use.
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ let package = Package(
.package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "1.1.1"),
.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/SpeziBluetooth", exact: "3.0.0-beta.1"),
.package(url: "https://github.com/StanfordSpezi/SpeziNetworking", from: "2.1.1"),
.package(url: "https://github.com/StanfordBDHG/XCTestExtensions.git", .upToNextMinor(from: "0.4.12"))
] + swiftLintPackage(),
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,8 @@ device support.

The [`OmronBloodPressureCuff`](https://swiftpackageindex.com/stanfordspezi/spezidevices/documentation/speziomron/omronbloodpressurecuff)
and [`OmronWeightScale`](https://swiftpackageindex.com/stanfordspezi/spezidevices/documentation/speziomron/omronweightscale)
devices provide reusable device implementations for the Omron `BP5250` blood pressure cuff
and the Omron `SC-150` weight scale.
devices provide reusable device implementations for Omron blood pressure cuffs
and the Omron weight scales respectively.
Both devices automatically integrate with the [`HealthMeasurements`](https://swiftpackageindex.com/stanfordspezi/spezidevices/documentation/spezidevices/healthmeasurements)
and [`PairedDevices`](https://swiftpackageindex.com/stanfordspezi/spezidevices/documentation/spezidevices/paireddevices) modules of SpeziDevices.
You just need to configure them for use with the [`Bluetooth`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetooth#Configure-the-Bluetooth-Module)
Expand All @@ -234,8 +234,8 @@ class ExampleAppDelegate: SpeziAppDelegate {
override var configuration: Configuration {
Configuration {
Bluetooth {
Discover(OmronBloodPressureCuff.self, by: .accessory(manufacturer: .omronHealthcareCoLtd, advertising: BloodPressureService.self))
Discover(OmronWeightScale.self, by: .accessory(manufacturer: .omronHealthcareCoLtd, advertising: WeightScaleService.self))
Discover(OmronBloodPressureCuff.self, by: .advertisedService(BloodPressureService.self))
Discover(OmronWeightScale.self, by: .advertisedService(WeightScaleService.self))
}

// If required, configure the PairedDevices and HealthMeasurements modules
Expand Down
16 changes: 10 additions & 6 deletions Sources/SpeziDevices/Devices/GenericDevice.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@ import SpeziBluetoothServices
///
/// A generic Bluetooth device that provides access to basic device information.
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 }
/// A catalog of assets that is used to visually present the device to the user.
///
/// You can provide an array of ``DeviceAsset``s to visually represent the device.
/// Providing multiple assets can be used to support multiple different models of a device kind with a single implementation.
/// The first matching `DeviceAsset` will be used.
static var assets: [DeviceAsset] { get }

/// The device identifier.
///
Expand Down Expand Up @@ -57,11 +61,11 @@ public protocol GenericDevice: BluetoothDevice, GenericBluetoothPeripheral, Iden


extension GenericDevice {
/// Default icon implementation.
/// Default `assets` implementation.
///
/// Returns `nil` by default. Results in a generic icon to be presented.
public static var icon: ImageReference? {
nil
/// Returns an empty asset catalog by default. Results in a generic icon to be presented.
public static var assets: [DeviceAsset] {
[]
}

/// Default label implementation.
Expand Down
45 changes: 28 additions & 17 deletions Sources/SpeziDevices/HealthMeasurements.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,23 @@ import SwiftUI
/// - ``init()``
///
/// ### Register Devices
/// - ``configureReceivingMeasurements(for:on:)-8cbd0``
/// - ``configureReceivingMeasurements(for:on:)-87sgc``
/// - ``configureReceivingMeasurements(for:on:)-5e7b7``
/// - ``configureReceivingMeasurements(for:on:)-2iu4v``
///
/// ### Processing Measurements
/// - ``shouldPresentMeasurements``
/// - ``pendingMeasurements``
/// - ``discardMeasurement(_:)``
@Observable
public final class HealthMeasurements: @unchecked Sendable {
#if compiler(<6)
public typealias WeightScaleKeyPath<Device> = KeyPath<Device, WeightScaleService>
public typealias BloodPressureKeyPath<Device> = KeyPath<Device, BloodPressureService>
#else
public typealias WeightScaleKeyPath<Device> = KeyPath<Device, WeightScaleService> & Sendable
public typealias BloodPressureKeyPath<Device> = KeyPath<Device, BloodPressureService> & Sendable
#endif

private let logger = Logger(subsystem: "ENGAGEHF", category: "HealthMeasurements")

/// Determine if UI components displaying pending measurements should be displayed.
Expand Down Expand Up @@ -141,17 +149,18 @@ public final class HealthMeasurements: @unchecked Sendable {
///
/// - Parameters:
/// - device: The device on which the service is present.
/// - service: The Weight Scale service to register.
public func configureReceivingMeasurements<Device: HealthDevice>(for device: Device, on service: WeightScaleService) {
let hkDevice = device.hkDevice

// make sure to not capture the device
service.$weightMeasurement.onChange { @MainActor [weak self, weak service] measurement in
guard let self, let service else {
/// - keyPath: A KeyPath to the Weight Scale service to register.
public func configureReceivingMeasurements<Device: HealthDevice>(
for device: Device,
on keyPath: WeightScaleKeyPath<Device>
) {
device[keyPath: keyPath].$weightMeasurement.onChange { @MainActor [weak self, weak device] measurement in
guard let self, let device else {
return
}
let service = device[keyPath: keyPath]

Check warning on line 161 in Sources/SpeziDevices/HealthMeasurements.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package iOS / Test using xcodebuild or run fastlane

capture of 'keyPath' with non-sendable type 'HealthMeasurements.WeightScaleKeyPath<Device>' (aka 'KeyPath<Device, WeightScaleService>') in a `@Sendable` closure

Check warning on line 161 in Sources/SpeziDevices/HealthMeasurements.swift

View workflow job for this annotation

GitHub Actions / Build and Test iOS / Test using xcodebuild or run fastlane

capture of 'keyPath' with non-sendable type 'HealthMeasurements.WeightScaleKeyPath<Device>' (aka 'KeyPath<Device, WeightScaleService>') in a `@Sendable` closure

Check warning on line 161 in Sources/SpeziDevices/HealthMeasurements.swift

View workflow job for this annotation

GitHub Actions / CodeQL / Test using xcodebuild or run fastlane

capture of 'keyPath' with non-sendable type 'HealthMeasurements.WeightScaleKeyPath<Device>' (aka 'KeyPath<Device, WeightScaleService>') in a `@Sendable` closure
logger.debug("Received new weight measurement: \(String(describing: measurement))")
handleNewMeasurement(.weight(measurement, service.features ?? []), from: hkDevice)
handleNewMeasurement(.weight(measurement, service.features ?? []), from: device.hkDevice)
}
}

Expand All @@ -161,17 +170,19 @@ public final class HealthMeasurements: @unchecked Sendable {
///
/// - Parameters:
/// - device: The device on which the service is present.
/// - service: The Blood Pressure service to register.
public func configureReceivingMeasurements<Device: HealthDevice>(for device: Device, on service: BloodPressureService) {
let hkDevice = device.hkDevice

/// - keyPath: A KeyPath to the Blood Pressure service to register.
public func configureReceivingMeasurements<Device: HealthDevice>(
for device: Device,
on keyPath: BloodPressureKeyPath<Device>
) {
// make sure to not capture the device
service.$bloodPressureMeasurement.onChange { @MainActor [weak self, weak service] measurement in
guard let self, let service else {
device[keyPath: keyPath].$bloodPressureMeasurement.onChange { @MainActor [weak self, weak device] measurement in
guard let self, let device else {
return
}
let service = device[keyPath: keyPath]

Check warning on line 183 in Sources/SpeziDevices/HealthMeasurements.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package iOS / Test using xcodebuild or run fastlane

capture of 'keyPath' with non-sendable type 'HealthMeasurements.BloodPressureKeyPath<Device>' (aka 'KeyPath<Device, BloodPressureService>') in a `@Sendable` closure

Check warning on line 183 in Sources/SpeziDevices/HealthMeasurements.swift

View workflow job for this annotation

GitHub Actions / Build and Test iOS / Test using xcodebuild or run fastlane

capture of 'keyPath' with non-sendable type 'HealthMeasurements.BloodPressureKeyPath<Device>' (aka 'KeyPath<Device, BloodPressureService>') in a `@Sendable` closure

Check warning on line 183 in Sources/SpeziDevices/HealthMeasurements.swift

View workflow job for this annotation

GitHub Actions / CodeQL / Test using xcodebuild or run fastlane

capture of 'keyPath' with non-sendable type 'HealthMeasurements.BloodPressureKeyPath<Device>' (aka 'KeyPath<Device, BloodPressureService>') in a `@Sendable` closure
logger.debug("Received new blood pressure measurement: \(String(describing: measurement))")
handleNewMeasurement(.bloodPressure(measurement, service.features ?? []), from: hkDevice)
handleNewMeasurement(.bloodPressure(measurement, service.features ?? []), from: device.hkDevice)
}

logger.debug("Registered device \(device.label), \(device.id) with HealthMeasurements")
Expand Down
71 changes: 71 additions & 0 deletions Sources/SpeziDevices/Model/DeviceAsset.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//
// This source file is part of the Stanford Spezi open-project
//
// SPDX-FileCopyrightText: 2024 Stanford University
//
// SPDX-License-Identifier: MIT
//


/// Description of a asset for a device.
public struct DeviceAsset {
enum DeviceDescriptor {
case name(_ substring: String, isSubstring: Bool)
}

private let descriptor: DeviceDescriptor
fileprivate let asset: ImageReference


func matches(for pairedDevice: PairedDeviceInfo) -> Bool {
switch descriptor {
case let .name(substring, isSubstring):
return isSubstring
? pairedDevice.peripheralName?.hasPrefix(substring) == true
: pairedDevice.peripheralName == substring
}
}

func matches(for device: some GenericDevice) -> Bool {
switch descriptor {
case let .name(substring, isSubstring):
return isSubstring
? device.name?.hasPrefix(substring) == true
: device.name == substring
}
}
}


extension DeviceAsset {
/// Define an asset for devices with a given name.
///
/// - Parameters:
/// - name: The name of the peripheral. The provided `asset` will be used if the name matches the peripherals name.
/// - asset: The image to use.
public static func name(_ name: String, _ asset: ImageReference) -> DeviceAsset {
DeviceAsset(descriptor: .name(name, isSubstring: false), asset: asset)
}
}


extension Array where Element == DeviceAsset {
/// Retrieve the first matching asset for the given paired device info.
/// - Parameter pairedDevice: The paired device info.
/// - Returns: The first matching asset or `nil` if none were found.
public func firstAsset(for pairedDevice: PairedDeviceInfo) -> ImageReference? {
first { asset in
asset.matches(for: pairedDevice)
}?.asset
}


/// Retrieve the first matching asset for the given device.
/// - Parameter device: The device.
/// - Returns: The first matching asset or `nil` if none were found.
public func firstAsset(for device: some GenericDevice) -> ImageReference? {
first { asset in
asset.matches(for: device)
}?.asset
}
}
19 changes: 17 additions & 2 deletions Sources/SpeziDevices/Model/PairedDeviceInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ public final class PairedDeviceInfo {
///
/// Stores the associated ``PairableDevice/deviceTypeIdentifier-9wsed`` device type used to locate the device implementation.
public var deviceType: String
/// The last known peripheral name.
public var peripheralName: String?
/// A model string of the device.
public var model: String?

Expand All @@ -34,8 +36,20 @@ public final class PairedDeviceInfo {

/// Could not retrieve the device from the Bluetooth central.
@Transient public internal(set) var notLocatable: Bool = false
@Transient private var _icon: ImageReference?

/// Visual representation of the device.
@Transient public var icon: ImageReference?
public var icon: ImageReference? {
get {
_$observationRegistrar.access(self, keyPath: \.icon)
return _icon
}
set {
_$observationRegistrar.withMutation(of: self, keyPath: \.icon) {
_icon = newValue
}
}
}

/// Create new paired device information.
/// - Parameters:
Expand All @@ -58,8 +72,9 @@ public final class PairedDeviceInfo {
self.id = id
self.deviceType = deviceType
self.name = name
self.peripheralName = name
self.model = model
self.icon = icon
self._icon = icon
self.lastSeen = lastSeen
self.lastBatteryPercentage = batteryPercentage

Expand Down
11 changes: 9 additions & 2 deletions Sources/SpeziDevices/PairedDevices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ public final class PairedDevices: @unchecked Sendable {
/// - state: The `@DeviceState` accessor for the `PeripheralState`.
/// - advertisements: The `@DeviceState` accessor for the current `AdvertisementData`.
/// - nearby: The `@DeviceState` accessor for the `nearby` flag.
@MainActor
public func configure<Device: PairableDevice>(
device: Device,
accessing state: DeviceStateAccessor<PeripheralState>,
Expand All @@ -221,6 +222,12 @@ public final class PairedDevices: @unchecked Sendable {
""")
}

// update name to the latest value
if let info = _pairedDevices[device.id] {
info.peripheralName = device.name
info.icon = Device.assets.firstAsset(for: info) // the asset might have changed
}

state.onChange { [weak self, weak device] oldValue, newValue in
if let device {
await self?.handleDeviceStateUpdated(device, old: oldValue, new: newValue)
Expand Down Expand Up @@ -452,7 +459,7 @@ extension PairedDevices {
deviceType: Device.deviceTypeIdentifier,
name: device.label,
model: device.deviceInformation.modelNumber,
icon: Device.icon,
icon: Device.assets.firstAsset(for: device),
batteryPercentage: batteryLevel
)

Expand Down Expand Up @@ -559,7 +566,7 @@ extension PairedDevices {
continue
}

deviceInfo.icon = deviceType.icon
deviceInfo.icon = deviceType.assets.firstAsset(for: deviceInfo)
}
}

Expand Down
4 changes: 2 additions & 2 deletions Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ public struct DeviceDetailsView: View {
image
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundStyle(Color.accentColor) // set accent color if one uses sf symbols
.symbolRenderingMode(.hierarchical) // set symbol rendering mode if one uses sf symbols
.frame(maxWidth: 180, maxHeight: 120)
.accessibilityHidden(true)
}
Expand All @@ -106,7 +108,6 @@ public struct DeviceDetailsView: View {
deviceType: MockDevice.deviceTypeIdentifier,
name: "Blood Pressure Monitor",
model: "BP5250",
icon: .asset("Omron-BP5250"),
batteryPercentage: 100
))
}
Expand All @@ -122,7 +123,6 @@ public struct DeviceDetailsView: View {
deviceType: MockDevice.deviceTypeIdentifier,
name: "Weight Scale",
model: "SC-150",
icon: .asset("Omron-SC-150"),
lastSeen: .now.addingTimeInterval(-60 * 60 * 24),
batteryPercentage: 85
))
Expand Down
1 change: 0 additions & 1 deletion Sources/SpeziDevicesUI/Devices/DeviceInfoSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ struct DeviceInfoSection: View {
deviceType: "MockDevice",
name: "Blood Pressure Monitor",
model: "BP5250",
icon: .asset("Omron-BP5250"),
batteryPercentage: 100
))
}
Expand Down
1 change: 0 additions & 1 deletion Sources/SpeziDevicesUI/Devices/NameEditView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ extension ValidationRule {
deviceType: MockDevice.deviceTypeIdentifier,
name: "Blood Pressure Monitor",
model: "BP5250",
icon: .asset("Omron-BP5250"),
batteryPercentage: 100
)) { name in
print("New Name is \(name)")
Expand Down
2 changes: 1 addition & 1 deletion Sources/SpeziDevicesUI/Pairing/AccessoryImageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ struct AccessoryImageView: View {

extension GenericDevice {
fileprivate var anyIcon: ImageReference? {
Self.icon
Self.assets.firstAsset(for: self)
}
}

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 17 additions & 7 deletions Sources/SpeziOmron/Devices/OmronBloodPressureCuff.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,22 @@ import SpeziDevices
import SpeziNumerics


/// Implementation of Omron BP5250 Blood Pressure Cuff.
/// Implementation of a Omron Blood Pressure Cuff.
///
/// This device class currently supports the following models:
/// * `BP5250`
/// * `BP7000`
/// * `EVOLV`
///
/// - Note: It is likely that other Omron Blood Pressure Cuffs are also supported with this implementation. However, they will be displayed with a generic device icon
/// in `SpeziDevicesUI` related components.
public final class OmronBloodPressureCuff: BluetoothDevice, Identifiable, OmronHealthDevice, BatteryPoweredDevice, @unchecked Sendable {
public static var icon: ImageReference? {
.asset("Omron-BP5250", bundle: .module)
public static var assets: [DeviceAsset] {
[
.name("BP5250", .asset("Omron-BP5250", bundle: .module)),
.name("EVOLV", .asset("Omron-EVOLV", bundle: .module)),
.name("BP7000", .asset("Omron-BP7000", bundle: .module))
]
}

private let logger = Logger(subsystem: "ENGAGEHF", category: "BloodPressureCuffDevice")
Expand Down Expand Up @@ -60,7 +72,7 @@ public final class OmronBloodPressureCuff: BluetoothDevice, Identifiable, OmronH
pairedDevices.configure(device: self, accessing: $state, $advertisementData, $nearby)
}
if let measurements {
measurements.configureReceivingMeasurements(for: self, on: bloodPressure)
measurements.configureReceivingMeasurements(for: self, on: \.bloodPressure)
}
}

Expand Down Expand Up @@ -142,9 +154,7 @@ extension OmronBloodPressureCuff {
device.bloodPressure.$features.inject(features)
device.bloodPressure.$bloodPressureMeasurement.inject(measurement)

let advertisementData = AdvertisementData([
CBAdvertisementDataManufacturerDataKey: manufacturerData.encode()
])
let advertisementData = AdvertisementData(manufacturerData: manufacturerData.encode())
device.$advertisementData.inject(advertisementData)

device.$connect.inject { @MainActor [weak device] in
Expand Down
Loading

0 comments on commit ac008a4

Please sign in to comment.