From cb77d17d3949ccfc9e14954dcc196babc62674c5 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Sun, 9 Jun 2024 19:31:27 +0200 Subject: [PATCH 01/77] Basic project setup --- .github/workflows/build-and-test.yml | 36 +++++++++---------- .github/workflows/pull_request.yml | 2 +- .gitignore | 2 +- .spi.yml | 4 +-- .swiftlint.yml | 2 +- CITATION.cff | 12 +++---- CONTRIBUTORS.md | 4 +-- Package.swift | 13 +++---- README.md | 11 +++--- .../TemplatePackage.docc/TemplatePackage.md | 2 +- Sources/TemplatePackage/TemplatePackage.swift | 19 ---------- .../TemplatePackageTests.swift | 18 ---------- 12 files changed, 44 insertions(+), 81 deletions(-) rename Sources/{TemplatePackage => SpeziOmron}/TemplatePackage.docc/TemplatePackage.md (84%) delete mode 100644 Sources/TemplatePackage/TemplatePackage.swift delete mode 100644 Tests/TemplatePackageTests/TemplatePackageTests.swift diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index a62aa6d..6ed53d0 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -1,5 +1,5 @@ # -# This source file is part of the TemplatePackage open source project +# This source file is part of the Stanford SpeziDevices open source project # # SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) # @@ -20,41 +20,41 @@ jobs: name: Build and Test Swift Package iOS uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: - scheme: TemplatePackage - resultBundle: TemplatePackage-iOS.xcresult - artifactname: TemplatePackage-iOS.xcresult + scheme: SpeziDevices + resultBundle: SpeziDevices-iOS.xcresult + artifactname: SpeziDevices-iOS.xcresult packagewatchos: name: Build and Test Swift Package watchOS uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: - scheme: TemplatePackage + scheme: SpeziDevices destination: 'platform=watchOS Simulator,name=Apple Watch Series 9 (45mm)' - resultBundle: TemplatePackage-watchOS.xcresult - artifactname: TemplatePackage-watchOS.xcresult + resultBundle: SpeziDevices-watchOS.xcresult + artifactname: SpeziDevices-watchOS.xcresult packagevisionos: name: Build and Test Swift Package visionOS uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: - scheme: TemplatePackage + scheme: SpeziDevices destination: 'platform=visionOS Simulator,name=Apple Vision Pro' - resultBundle: TemplatePackage-visionOS.xcresult - artifactname: TemplatePackage-visionOS.xcresult + resultBundle: SpeziDevices-visionOS.xcresult + artifactname: SpeziDevices-visionOS.xcresult packagetvos: name: Build and Test Swift Package tvOS uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: - scheme: TemplatePackage - resultBundle: TemplatePackage-tvOS.xcresult + scheme: SpeziDevices + resultBundle: SpeziDevices-tvOS.xcresult destination: 'platform=tvOS Simulator,name=Apple TV 4K (3rd generation)' - artifactname: TemplatePackage-tvOS.xcresult + artifactname: SpeziDevices-tvOS.xcresult packagemacos: name: Build and Test Swift Package macOS uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: - scheme: TemplatePackage - resultBundle: TemplatePackage-macOS.xcresult + scheme: SpeziDevices + resultBundle: SpeziDevices-macOS.xcresult destination: 'platform=macOS,arch=arm64' - artifactname: TemplatePackage-macOS.xcresult + artifactname: SpeziDevices-macOS.xcresult ios: name: Build and Test iOS uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 @@ -110,7 +110,7 @@ jobs: with: codeql: true test: false - scheme: TemplatePackage + scheme: SpeziDevices permissions: security-events: write actions: read @@ -119,6 +119,6 @@ jobs: needs: [packageios, packagewatchos, packagevisionos, packagetvos, packagemacos, ios, ipados, watchos, visionos, tvos] uses: StanfordBDHG/.github/.github/workflows/create-and-upload-coverage-report.yml@v2 with: - coveragereports: TemplatePackage-iOS.xcresult TemplatePackage-watchOS.xcresult TemplatePackage-visionOS.xcresult TemplatePackage-tvOS.xcresult TemplatePackage-macOS.xcresult TestApp-iOS.xcresult TestApp-iPadOS.xcresult TestApp-watchOS.xcresult TestApp-visionOS.xcresult TestApp-tvOS.xcresult + coveragereports: SpeziDevices-iOS.xcresult SpeziDevices-watchOS.xcresult SpeziDevices-visionOS.xcresult SpeziDevices-tvOS.xcresult SpeziDevices-macOS.xcresult TestApp-iOS.xcresult TestApp-iPadOS.xcresult TestApp-watchOS.xcresult TestApp-visionOS.xcresult TestApp-tvOS.xcresult secrets: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 45ed630..4d46666 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -1,5 +1,5 @@ # -# This source file is part of the TemplatePackage open source project +# This source file is part of the Stanford SpeziDevices open source project # # SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) # diff --git a/.gitignore b/.gitignore index f9a765f..929b1be 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # -# This source file is part of the TemplatePackage open source project +# This source file is part of the Stanford SpeziDevices open source project # # SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) # diff --git a/.spi.yml b/.spi.yml index 504bb5a..c7bba65 100644 --- a/.spi.yml +++ b/.spi.yml @@ -1,5 +1,5 @@ # -# This source file is part of the TemplatePackage open source project +# This source file is part of the Stanford SpeziDevices open source project # # SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) # @@ -11,4 +11,4 @@ builder: configs: - platform: ios documentation_targets: - - TemplatePackage + - SpeziOmron diff --git a/.swiftlint.yml b/.swiftlint.yml index 3c7bab5..96eebc3 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,5 +1,5 @@ # -# This source file is part of the TemplatePackage open source project +# This source file is part of the Stanford SpeziDevices open source project # # SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) # diff --git a/CITATION.cff b/CITATION.cff index ee53639..8e4bc03 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,5 +1,5 @@ # -# This source file is part of the TemplatePackage open source project +# This source file is part of the Stanford SpeziDevices open source project # # SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) # @@ -12,9 +12,9 @@ authors: - family-names: "Schmiedmayer" given-names: "Paul" orcid: "https://orcid.org/0000-0002-8607-9148" -- family-names: "Ravi" - given-names: "Vishnu" - orcid: "https://orcid.org/0000-0003-0359-1275" -title: "TemplatePackage" +- family-names: "Bauer" + given-names: "Andreas" + orcid: "https://orcid.org/0000-0002-1680-237X" +title: "SpeziDevices" doi: 10.5281/zenodo.7538165 -url: "https://github.com/StanfordBDHG/SwiftPackageTemplate" +url: "https://github.com/StanfordSpezi/SpeziDevices" diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 7574c84..5e26835 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -1,7 +1,7 @@ + ## How To Use This Template diff --git a/Sources/TemplatePackage/TemplatePackage.docc/TemplatePackage.md b/Sources/SpeziOmron/TemplatePackage.docc/TemplatePackage.md similarity index 84% rename from Sources/TemplatePackage/TemplatePackage.docc/TemplatePackage.md rename to Sources/SpeziOmron/TemplatePackage.docc/TemplatePackage.md index 2417f3d..27f0c89 100644 --- a/Sources/TemplatePackage/TemplatePackage.docc/TemplatePackage.md +++ b/Sources/SpeziOmron/TemplatePackage.docc/TemplatePackage.md @@ -2,7 +2,7 @@ + +## Overview + +Text + +## Topics + +### Group + +- ``Symbol`` diff --git a/Sources/SpeziOmron/TemplatePackage.docc/TemplatePackage.md b/Sources/SpeziOmron/TemplatePackage.docc/TemplatePackage.md deleted file mode 100644 index 27f0c89..0000000 --- a/Sources/SpeziOmron/TemplatePackage.docc/TemplatePackage.md +++ /dev/null @@ -1,23 +0,0 @@ -# ``TemplatePackage`` - - - -The template repository contains a template Swift Package, including a continuous integration setup. - -## Overview - -Please follow the steps in the README.md file to customize the code to your needs. - -## Types - -### Template Package - -- ``TemplatePackage`` From c54b3f4a506425a17549a4c12d0201c49adcb1e2 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 13 Jun 2024 20:52:25 +0200 Subject: [PATCH 03/77] Upgrade to SpeziNetworking 2.0 --- Package.swift | 4 ++-- .../OmronRecordAccessOperand.swift | 21 +++++++++---------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/Package.swift b/Package.swift index 6dceda9..0af8924 100644 --- a/Package.swift +++ b/Package.swift @@ -25,8 +25,8 @@ let package = Package( .library(name: "SpeziOmron", targets: ["SpeziOmron"]) ], dependencies: [ - .package(url: "https://github.com/StanfordSpezi/SpeziBluetooth", branch: "main"), - .package(url: "https://github.com/StanfordSpezi/SpeziNetworking", from: "1.0.0") + .package(url: "https://github.com/StanfordSpezi/SpeziBluetooth", branch: "feature/accessory-discovery"), + .package(url: "https://github.com/StanfordSpezi/SpeziNetworking", from: "2.0.0") ], targets: [ .target( diff --git a/Sources/SpeziOmron/Characteristic/OmronRecordAccessOperand.swift b/Sources/SpeziOmron/Characteristic/OmronRecordAccessOperand.swift index b9db20f..2336125 100644 --- a/Sources/SpeziOmron/Characteristic/OmronRecordAccessOperand.swift +++ b/Sources/SpeziOmron/Characteristic/OmronRecordAccessOperand.swift @@ -44,25 +44,24 @@ extension OmronRecordAccessOperand: RecordAccessOperand { public init?( // swiftlint:disable:this cyclomatic_complexity from byteBuffer: inout ByteBuffer, - preferredEndianness endianness: Endianness, opCode: RecordAccessOpCode, operator: RecordAccessOperator ) { switch opCode { case .responseCode: - guard let response = RecordAccessGeneralResponse(from: &byteBuffer, preferredEndianness: endianness) else { + guard let response = RecordAccessGeneralResponse(from: &byteBuffer) else { return nil } self = .generalResponse(response) case .reportStoredRecords, .deleteStoredRecords, .reportNumberOfStoredRecords: switch `operator` { case .lessThanOrEqualTo, .greaterThanOrEqual: - guard let filterCriteria = RecordAccessFilterCriteria(from: &byteBuffer, preferredEndianness: endianness) else { + guard let filterCriteria = RecordAccessFilterCriteria(from: &byteBuffer) else { return nil } self = .filterCriteria(filterCriteria) case .withinInclusiveRangeOf: - guard let filterCriteria = RecordAccessRangeFilterCriteria(from: &byteBuffer, preferredEndianness: endianness) else { + guard let filterCriteria = RecordAccessRangeFilterCriteria(from: &byteBuffer) else { return nil } self = .rangeFilterCriteria(filterCriteria) @@ -70,12 +69,12 @@ extension OmronRecordAccessOperand: RecordAccessOperand { return nil } case .numberOfStoredRecordsResponse: - guard let count = UInt16(from: &byteBuffer, preferredEndianness: endianness) else { + guard let count = UInt16(from: &byteBuffer) else { return nil } self = .numberOfRecords(count) case .omronSequenceNumberOfLatestRecordsResponse: - guard let sequenceNumber = UInt16(from: &byteBuffer, preferredEndianness: endianness) else { + guard let sequenceNumber = UInt16(from: &byteBuffer) else { return nil } self = .sequenceNumber(sequenceNumber) @@ -84,16 +83,16 @@ extension OmronRecordAccessOperand: RecordAccessOperand { } } - public func encode(to byteBuffer: inout ByteBuffer, preferredEndianness endianness: Endianness) { + public func encode(to byteBuffer: inout ByteBuffer) { switch self { case let .generalResponse(response): - response.encode(to: &byteBuffer, preferredEndianness: endianness) + response.encode(to: &byteBuffer) case let .filterCriteria(criteria): - criteria.encode(to: &byteBuffer, preferredEndianness: endianness) + criteria.encode(to: &byteBuffer) case let .rangeFilterCriteria(criteria): - criteria.encode(to: &byteBuffer, preferredEndianness: endianness) + criteria.encode(to: &byteBuffer) case let .numberOfRecords(value), let .sequenceNumber(value): - value.encode(to: &byteBuffer, preferredEndianness: endianness) + value.encode(to: &byteBuffer) } } } From a04c44b568651b05ddaee88e6d50a5ebbc77e509 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 21 Jun 2024 18:29:35 +0200 Subject: [PATCH 04/77] Move all infrastructure to SpeziDevices --- .github/workflows/build-and-test.yml | 12 +- Package.swift | 42 +++- .../SpeziDevices/BatteryPoweredDevice.swift | 15 ++ Sources/SpeziDevices/DeviceManager.swift | 222 ++++++++++++++++++ Sources/SpeziDevices/DevicePairingError.swift | 47 ++++ Sources/SpeziDevices/GenericDevice.swift | 35 +++ Sources/SpeziDevices/ImageReference.swift | 80 +++++++ Sources/SpeziDevices/PairableDevice.swift | 106 +++++++++ Sources/SpeziDevices/PairedDeviceInfo.swift | 134 +++++++++++ .../SpeziDevicesUI/AccessoryImageView.swift | 42 ++++ .../SpeziDevicesUI/AccessorySetupSheet.swift | 89 +++++++ Sources/SpeziDevicesUI/BatteryIcon.swift | 96 ++++++++ Sources/SpeziDevicesUI/CarouselDots.swift | 100 ++++++++ .../SpeziDevicesUI/DeviceDetailsView.swift | 152 ++++++++++++ Sources/SpeziDevicesUI/DeviceTile.swift | 99 ++++++++ Sources/SpeziDevicesUI/DevicesGrid.swift | 127 ++++++++++ .../DevicesUnavailableView.swift | 39 +++ Sources/SpeziDevicesUI/DiscoveryView.swift | 32 +++ Sources/SpeziDevicesUI/NameEditView.swift | 72 ++++++ Sources/SpeziDevicesUI/PairDeviceView.swift | 105 +++++++++ Sources/SpeziDevicesUI/PairedDeviceView.swift | 46 ++++ .../SpeziDevicesUI/PairingFailureView.swift | 58 +++++ Sources/SpeziDevicesUI/PairingSheet.swift | 50 ++++ Sources/SpeziDevicesUI/PairingState.swift | 19 ++ Sources/SpeziDevicesUI/PaneContent.swift | 114 +++++++++ .../Resources/Localizable.xcstrings | 131 +++++++++++ .../SpeziDevicesUI/Testing/MockDevice.swift | 76 ++++++ .../SpeziDevicesUI/Tips/ForgetDeviceTip.swift | 47 ++++ .../ManufacturerIdentifier+Omron.swift | 17 ++ Sources/SpeziOmron/OmronHealthDevice.swift | 37 +++ .../SpeziOmron/OmronManufacturerData.swift | 164 +++++++++++++ Sources/SpeziOmron/OmronModel.swift | 41 ++++ Sources/SpeziOmron/OmronOptionService.swift | 4 + Tests/SpeziOmronTests/Empty.swift | 9 + 34 files changed, 2450 insertions(+), 9 deletions(-) create mode 100644 Sources/SpeziDevices/BatteryPoweredDevice.swift create mode 100644 Sources/SpeziDevices/DeviceManager.swift create mode 100644 Sources/SpeziDevices/DevicePairingError.swift create mode 100644 Sources/SpeziDevices/GenericDevice.swift create mode 100644 Sources/SpeziDevices/ImageReference.swift create mode 100644 Sources/SpeziDevices/PairableDevice.swift create mode 100644 Sources/SpeziDevices/PairedDeviceInfo.swift create mode 100644 Sources/SpeziDevicesUI/AccessoryImageView.swift create mode 100644 Sources/SpeziDevicesUI/AccessorySetupSheet.swift create mode 100644 Sources/SpeziDevicesUI/BatteryIcon.swift create mode 100644 Sources/SpeziDevicesUI/CarouselDots.swift create mode 100644 Sources/SpeziDevicesUI/DeviceDetailsView.swift create mode 100644 Sources/SpeziDevicesUI/DeviceTile.swift create mode 100644 Sources/SpeziDevicesUI/DevicesGrid.swift create mode 100644 Sources/SpeziDevicesUI/DevicesUnavailableView.swift create mode 100644 Sources/SpeziDevicesUI/DiscoveryView.swift create mode 100644 Sources/SpeziDevicesUI/NameEditView.swift create mode 100644 Sources/SpeziDevicesUI/PairDeviceView.swift create mode 100644 Sources/SpeziDevicesUI/PairedDeviceView.swift create mode 100644 Sources/SpeziDevicesUI/PairingFailureView.swift create mode 100644 Sources/SpeziDevicesUI/PairingSheet.swift create mode 100644 Sources/SpeziDevicesUI/PairingState.swift create mode 100644 Sources/SpeziDevicesUI/PaneContent.swift create mode 100644 Sources/SpeziDevicesUI/Resources/Localizable.xcstrings create mode 100644 Sources/SpeziDevicesUI/Testing/MockDevice.swift create mode 100644 Sources/SpeziDevicesUI/Tips/ForgetDeviceTip.swift create mode 100644 Sources/SpeziOmron/ManufacturerIdentifier+Omron.swift create mode 100644 Sources/SpeziOmron/OmronHealthDevice.swift create mode 100644 Sources/SpeziOmron/OmronManufacturerData.swift create mode 100644 Sources/SpeziOmron/OmronModel.swift create mode 100644 Tests/SpeziOmronTests/Empty.swift diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 6ed53d0..e14b6fc 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -20,14 +20,14 @@ jobs: name: Build and Test Swift Package iOS uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: - scheme: SpeziDevices + scheme: SpeziDevices-Package resultBundle: SpeziDevices-iOS.xcresult artifactname: SpeziDevices-iOS.xcresult packagewatchos: name: Build and Test Swift Package watchOS uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: - scheme: SpeziDevices + scheme: SpeziDevices-Package destination: 'platform=watchOS Simulator,name=Apple Watch Series 9 (45mm)' resultBundle: SpeziDevices-watchOS.xcresult artifactname: SpeziDevices-watchOS.xcresult @@ -35,7 +35,7 @@ jobs: name: Build and Test Swift Package visionOS uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: - scheme: SpeziDevices + scheme: SpeziDevices-Package destination: 'platform=visionOS Simulator,name=Apple Vision Pro' resultBundle: SpeziDevices-visionOS.xcresult artifactname: SpeziDevices-visionOS.xcresult @@ -43,7 +43,7 @@ jobs: name: Build and Test Swift Package tvOS uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: - scheme: SpeziDevices + scheme: SpeziDevices-Package resultBundle: SpeziDevices-tvOS.xcresult destination: 'platform=tvOS Simulator,name=Apple TV 4K (3rd generation)' artifactname: SpeziDevices-tvOS.xcresult @@ -51,7 +51,7 @@ jobs: name: Build and Test Swift Package macOS uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: - scheme: SpeziDevices + scheme: SpeziDevices-Package resultBundle: SpeziDevices-macOS.xcresult destination: 'platform=macOS,arch=arm64' artifactname: SpeziDevices-macOS.xcresult @@ -110,7 +110,7 @@ jobs: with: codeql: true test: false - scheme: SpeziDevices + scheme: SpeziDevices-Package permissions: security-events: write actions: read diff --git a/Package.swift b/Package.swift index 0af8924..6beec1e 100644 --- a/Package.swift +++ b/Package.swift @@ -12,8 +12,11 @@ import PackageDescription // TODO: DOI in citation.cff +let swiftLintPlugin: Target.PluginUsage = .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLint") + let package = Package( name: "SpeziDevices", + defaultLocalization: "en", platforms: [ .iOS(.v17), .watchOS(.v10), @@ -22,19 +25,51 @@ let package = Package( .macOS(.v14) ], products: [ + .library(name: "SpeziDevices", targets: ["SpeziDevices"]), + .library(name: "SpeziDevicesUI", targets: ["SpeziDevicesUI"]), .library(name: "SpeziOmron", targets: ["SpeziOmron"]) ], dependencies: [ + .package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "1.1.1"), + .package(url: "https://github.com/StanfordSpezi/SpeziViews.git", from: "1.4.0"), .package(url: "https://github.com/StanfordSpezi/SpeziBluetooth", branch: "feature/accessory-discovery"), - .package(url: "https://github.com/StanfordSpezi/SpeziNetworking", from: "2.0.0") + .package(url: "https://github.com/StanfordSpezi/SpeziNetworking", from: "2.0.0"), + .package(url: "https://github.com/JWAutumn/ACarousel", .upToNextMinor(from: "0.2.0")), + .package(url: "https://github.com/realm/SwiftLint.git", .upToNextMinor(from: "0.55.1")) ], targets: [ + .target( + name: "SpeziDevices", + dependencies: [ + .product(name: "SpeziBluetooth", package: "SpeziBluetooth"), + .product(name: "BluetoothServices", package: "SpeziBluetooth"), + .product(name: "BluetoothViews", package: "SpeziBluetooth") // TODO: just because of the One protocol??? + ], + plugins: [swiftLintPlugin] + ), + .target( + name: "SpeziDevicesUI", + dependencies: [ + .target(name: "SpeziDevices"), + .product(name: "SpeziBluetooth", package: "SpeziBluetooth"), + .product(name: "BluetoothViews", package: "SpeziBluetooth"), + .product(name: "SpeziViews", package: "SpeziViews"), + .product(name: "SpeziValidation", package: "SpeziViews"), + .product(name: "ACarousel", package: "ACarousel") + ], + resources: [ + .process("Resources") + ], + plugins: [swiftLintPlugin] + ), .target( name: "SpeziOmron", dependencies: [ + .target(name: "SpeziDevices"), .product(name: "SpeziBluetooth", package: "SpeziBluetooth"), .product(name: "BluetoothServices", package: "SpeziBluetooth") - ] + ], + plugins: [swiftLintPlugin] ), .testTarget( name: "SpeziOmronTests", @@ -42,7 +77,8 @@ let package = Package( .target(name: "SpeziOmron"), .product(name: "SpeziBluetooth", package: "SpeziBluetooth"), .product(name: "XCTByteCoding", package: "SpeziNetworking") - ] + ], + plugins: [swiftLintPlugin] ) ] ) diff --git a/Sources/SpeziDevices/BatteryPoweredDevice.swift b/Sources/SpeziDevices/BatteryPoweredDevice.swift new file mode 100644 index 0000000..1946809 --- /dev/null +++ b/Sources/SpeziDevices/BatteryPoweredDevice.swift @@ -0,0 +1,15 @@ +// +// 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 BluetoothServices +import SpeziBluetooth + + +public protocol BatteryPoweredDevice: BluetoothDevice { + var battery: BatteryService { get } +} diff --git a/Sources/SpeziDevices/DeviceManager.swift b/Sources/SpeziDevices/DeviceManager.swift new file mode 100644 index 0000000..f9ae074 --- /dev/null +++ b/Sources/SpeziDevices/DeviceManager.swift @@ -0,0 +1,222 @@ +// +// 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 BluetoothServices +import OrderedCollections +import Spezi +import SpeziBluetooth +import SwiftUI + +// TODO: Start SpeziDevices generalization +// TODO: Finish SpeziBluetooth refactoring and cleanup "persistent devices" +// TODO: dark mode device images +// TODO: ask for more Omron infos? secret sauce? + +// TODO: move deviceManager to SpeziBluetooth (and measurement manager?) + +@Observable +public class DeviceManager: Module, EnvironmentAccessible, DefaultInitializable { + /// Determines if the device discovery sheet should be presented. + @MainActor public var presentingDevicePairing = false // TODO: "should" naming + @MainActor public private(set) var discoveredDevices: OrderedDictionary = [:] + @MainActor @ObservationIgnored private var _pairedDevices: [PairedDeviceInfo] = [] // TODO: @AppStorage("pairedDevices") + + @MainActor private(set) var peripherals: [UUID: any PairableDevice] = [:] + + @MainActor public var pairedDevices: [PairedDeviceInfo] { + get { + access(keyPath: \.pairedDevices) + return _pairedDevices + } + set { + withMutation(keyPath: \.pairedDevices) { + _pairedDevices = newValue + } + } + } + + + @MainActor public var scanningNearbyDevices: Bool { // TODO: isScanningForNearby! + pairedDevices.isEmpty || presentingDevicePairing + } + + @Application(\.logger) @ObservationIgnored private var logger + @Dependency @ObservationIgnored private var bluetooth: Bluetooth? + + required public init() {} + + public func configure() { + guard let bluetooth else { + self.logger.warning("DeviceManager initialized without Bluetooth dependency!") + return // useful for e.g. previews + } + + // we just reuse the configured Bluetooth devices + let configuredDevices = bluetooth.configuredPairableDevices + + // TODO: bit weird API wise! + // We need to detach to not copy task local values + Task.detached { @MainActor in + // TODO: we need to redo this once bluetooth powers on? + for deviceInfo in self.pairedDevices { + guard self.peripherals[deviceInfo.id] == nil else { + continue + } + + guard let deviceType = configuredDevices[deviceInfo.deviceType] else { + self.logger.error("Unsupported device type \"\(deviceInfo.deviceType)\" for paired device \(deviceInfo.name).") + continue + } + + let device = await deviceType.retrievePeripheral(from: bluetooth, with: deviceInfo.id) + + guard let device else { + // TODO: once spezi bluetooth works (waiting for connected), this is an indication that the device was unpaired???? + self.logger.warning("Device \(deviceInfo.id) \(deviceInfo.name) could not be retrieved!") + continue + } + + assert(self.peripherals[device.id] == nil, "Cannot overwrite peripheral. Device \(deviceInfo) was paired twice.") + self.peripherals[device.id] = device + // TODO: we must store them (remove once we forget about them)? + // TODO: we can instantly store newly paired devices! + await device.connect() // TODO: might want to cancel that? + + // TODO: call connect after device disconnects? + } + } + } + + @MainActor + public func isConnected(device: UUID) -> Bool { + peripherals[device]?.state == .connected + } + + @MainActor + public func isPaired(_ device: Device) -> Bool { + pairedDevices.contains { $0.id == device.id } // TODO: more efficient lookup! + } + + @MainActor + public func handleDeviceStateUpdated(_ device: Device, _ state: PeripheralState) { + guard case .disconnected = state else { + return + } + + 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 + + Task { + // TODO: log? + await device.connect() // TODO: handle something about that?, reuse with configure method? + } + } + + @MainActor + public func nearbyPairableDevice(_ device: Device) { // TODO: rename? + guard discoveredDevices[device.id] == nil else { + return + } + + guard !isPaired(device) else { + return + } + + self.logger.info("Detected nearby \(Device.self) accessory.") + // TODO: previously we logged the manufacturer data! + + discoveredDevices[device.id] = device + presentingDevicePairing = true + } + + + @MainActor + public func registerPairedDevice(_ device: Device) async { + var batteryLevel: UInt8? + if let batteryDevice = device as? any BatteryPoweredDevice { + batteryLevel = batteryDevice.battery.batteryLevel + } + + if device.deviceInformation.modelNumber == nil && device.deviceInformation.$modelNumber.isPresent { + // make sure it isn't just a race condition that we haven't received a value yet + if let readModel = try? await device.deviceInformation.$modelNumber.read() { + self.logger.info("ModelNumber was not present on device \(device.label), was read as \"\(readModel)\".") + } // TODO: log the error? + } + + // TODO: let omronManufacturerData = device.manufacturerData?.users.first?.sequenceNumber (which user to choose from?) + let deviceInfo = PairedDeviceInfo( + id: device.id, + deviceType: Device.deviceTypeIdentifier, + name: device.label, + model: device.deviceInformation.modelNumber, + icon: device.icon, + batteryPercentage: batteryLevel + ) + + pairedDevices.append(deviceInfo) + discoveredDevices[device.id] = nil + + + assert(peripherals[device.id] == nil, "Cannot overwrite peripheral. Device \(deviceInfo) was paired twice.") + peripherals[device.id] = device + } + + @MainActor + public func handleDiscardedDevice(_ device: Device) { + // 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 + public func forgetDevice(id: UUID) { + pairedDevices.removeAll { info in + info.id == id + } + + let device = peripherals.removeValue(forKey: id) + if let device { + Task { + await device.disconnect() + } + } + // TODO: make sure to remove them from discoveredDevices? + } + + @MainActor + public func updateBattery(for device: Device, percentage: UInt8) { + guard let index = pairedDevices.firstIndex(where: { $0.id == device.id }) else { + return + } + pairedDevices[index].lastBatteryPercentage = percentage + } +} + + +extension Bluetooth { + fileprivate nonisolated var configuredPairableDevices: [String: any PairableDevice.Type] { + configuration.reduce(into: [:]) { partialResult, descriptor in + guard let pairableDevice = descriptor.deviceType as? any PairableDevice.Type else { + return + } + partialResult[pairableDevice.deviceTypeIdentifier] = pairableDevice + } + } +} + + +extension PairableDevice { + fileprivate static func retrievePeripheral(from bluetooth: Bluetooth, with id: UUID) async -> Self? { + await bluetooth.retrievePeripheral(for: id, as: Self.self) + } +} diff --git a/Sources/SpeziDevices/DevicePairingError.swift b/Sources/SpeziDevices/DevicePairingError.swift new file mode 100644 index 0000000..4d20c7e --- /dev/null +++ b/Sources/SpeziDevices/DevicePairingError.swift @@ -0,0 +1,47 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import SpeziFoundation + + +public enum DevicePairingError { + case invalidState + /// The device is busy (e.g., already pairing). + case busy + /// The device is not in pairing mode. + case notInPairingMode + case deviceDisconnected +} + + +extension DevicePairingError: LocalizedError { + public var errorDescription: String? { + switch self { + case .invalidState: + String(localized: "Invalid State") + case .busy: + String(localized: "Device Busy") + case .notInPairingMode: + String(localized: "Not Ready") + case .deviceDisconnected: + String(localized: "Pairing Failed") + } + } + + public var failureReason: String? { + switch self { + case .invalidState, .deviceDisconnected: + String(localized: "Failed to pair with device. Please try again.") + case .busy: + String(localized: "The device is busy and failed to complete pairing.") + case .notInPairingMode: + String(localized: "The device was not put into pairing mode.") + } + } +} diff --git a/Sources/SpeziDevices/GenericDevice.swift b/Sources/SpeziDevices/GenericDevice.swift new file mode 100644 index 0000000..83876ea --- /dev/null +++ b/Sources/SpeziDevices/GenericDevice.swift @@ -0,0 +1,35 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import BluetoothServices +import BluetoothViews +import Foundation +import SpeziBluetooth + + +public protocol GenericDevice: BluetoothDevice, GenericBluetoothPeripheral { + var id: UUID { get } + var name: String? { get } + var advertisementData: AdvertisementData { get } + var discarded: Bool { get } + + var deviceInformation: DeviceInformationService { get } + + var icon: ImageReference? { get } +} + + +extension GenericDevice { + public var label: String { + name ?? "Generic Device" + } + + public var icon: ImageReference? { + nil + } +} diff --git a/Sources/SpeziDevices/ImageReference.swift b/Sources/SpeziDevices/ImageReference.swift new file mode 100644 index 0000000..61e2ff4 --- /dev/null +++ b/Sources/SpeziDevices/ImageReference.swift @@ -0,0 +1,80 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +public enum ImageReference { + case system(String) + case asset(String, bundle: Bundle? = nil) +} + + +extension ImageReference { + public var image: Image? { + switch self { + case let .system(name): + return Image(systemName: name) + case let .asset(name, bundle: bundle): + guard UIImage(named: name, in: bundle, with: nil) != nil else { + return nil + } + return Image(name, bundle: bundle) + } + } +} + + +extension ImageReference: Hashable {} + + +extension ImageReference: Codable { + private enum CodingKeys: String, CodingKey { + case type + case name + case bundle + } + + private enum ReferenceType: String, Codable { + case system + case asset + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let type = try container.decode(ReferenceType.self, forKey: .type) + let name = try container.decode(String.self, forKey: .name) + switch type { + case .system: + self = .system(name) + case .asset: + let bundleURL = try container.decodeIfPresent(URL.self, forKey: .bundle) + let bundle = bundleURL.flatMap { Bundle(url: $0) } + + self = .asset(name, bundle: bundle) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case let .system(name): + try container.encode(ReferenceType.system, forKey: .type) + try container.encode(name, forKey: .name) + case let .asset(name, bundle): + try container.encode(ReferenceType.asset, forKey: .type) + try container.encode(name, forKey: .name) + + if let bundle { + try container.encode(bundle.bundleURL, forKey: .bundle) + } + } + } +} diff --git a/Sources/SpeziDevices/PairableDevice.swift b/Sources/SpeziDevices/PairableDevice.swift new file mode 100644 index 0000000..a415a55 --- /dev/null +++ b/Sources/SpeziDevices/PairableDevice.swift @@ -0,0 +1,106 @@ +// +// 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 SpeziBluetooth +import SpeziFoundation + +// TODO: docs all the way! + +public protocol PairableDevice: GenericDevice { + /// Persistent identifier for the device type. + /// + /// This is used to associate pairing information with the implementing device. By default, the type name is used. + static var deviceTypeIdentifier: String { get } + + /// Storage for pairing continuation. + @MainActor var _pairingContinuation: CheckedContinuation? { get set } // swiftlint:disable:this identifier_name + // TODO: do not synchronize via MainActor?? + // TODO: use SPI instead of underscore when moving to SpeziDevices? => avoid swiftlint warning for implementors + + var connect: BluetoothConnectAction { get } + var disconnect: BluetoothDisconnectAction { get } + + var isInPairingMode: Bool { get } + + /// Pair Omron Health Device. + /// + /// This method pairs a currently advertising Omron Health Device. + /// - Note: Make sure that the device is in pairing mode (holding down the Bluetooth button for 3 seconds) and disconnected. + /// + /// This method is implemented by default. In order to support the default implementation, you MUST call `handleDeviceInteraction()` + /// on notifications or indications received from the device. This indicates that pairing was successful. + /// Further, your implementation MUST call `handleDeviceDisconnected()` if the device disconnects to handle pairing issues. + @MainActor // TODO: actor isolation? + func pair() async throws +} + + +extension PairableDevice { + public static var deviceTypeIdentifier: String { + "\(Self.self)" + } +} + + +extension PairableDevice { + @MainActor + public func pair() async throws { + guard _pairingContinuation == nil else { + throw DevicePairingError.busy + } + + guard isInPairingMode else { + throw DevicePairingError.notInPairingMode + } + + guard case .disconnected = state else { + throw DevicePairingError.invalidState + } + + guard !discarded else { + throw DevicePairingError.invalidState + } + + await connect() + + async let _ = withTimeout(of: .seconds(15)) { @MainActor in + resumePairingContinuation(with: .failure(TimeoutError())) + } + + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + self._pairingContinuation = continuation + } + } onCancel: { + Task { @MainActor in + resumePairingContinuation(with: .failure(CancellationError())) + await disconnect() + } + } + // TODO: Self.logger.debug("Device \(self.label) with id \(self.id) is now paired") // TODO: Move logger! + } + + @MainActor + public func handleDeviceInteraction() { + // any kind of messages received from the the device is interpreted as successful pairing. + resumePairingContinuation(with: .success(())) + } + + @MainActor + public func handleDeviceDisconnected() { + resumePairingContinuation(with: .failure(DevicePairingError.deviceDisconnected)) + } + + @MainActor + private func resumePairingContinuation(with result: Result) { + if let pairingContinuation = _pairingContinuation { + pairingContinuation.resume(with: result) + self._pairingContinuation = nil + } + } +} diff --git a/Sources/SpeziDevices/PairedDeviceInfo.swift b/Sources/SpeziDevices/PairedDeviceInfo.swift new file mode 100644 index 0000000..0bce465 --- /dev/null +++ b/Sources/SpeziDevices/PairedDeviceInfo.swift @@ -0,0 +1,134 @@ +// +// 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 Foundation + + +public struct PairedDeviceInfo { // TODO: observable and editable? + public let id: UUID + public let deviceType: String + public let icon: ImageReference? + public let model: String? + + // TODO: make some things have internal setters? + public var name: String + public var lastSeen: Date + public var lastBatteryPercentage: UInt8? + public var lastSequenceNumber: UInt16? + public var userDatabaseNumber: UInt32? // TODO: default value? + // TODO: consent code? + // TODO: last transfer time? + // TODO: handle extensibility? + + public init( // TODO: this is unecessary? + id: UUID, + deviceType: String, + name: String, + model: Model, + icon: ImageReference?, + lastSeen: Date = .now, + batteryPercentage: UInt8? = nil, + lastSequenceNumber: UInt16? = nil, + userDatabaseNumber: UInt32? = nil + ) where Model.RawValue == String { + self.init( + id: id, + deviceType: deviceType, + name: name, + model: model.rawValue, + icon: icon, + lastSeen: lastSeen, + batteryPercentage: batteryPercentage, + lastSequenceNumber: lastSequenceNumber, + userDatabaseNumber: userDatabaseNumber + ) + } + + public init( + id: UUID, + deviceType: String, + name: String, + model: String?, + icon: ImageReference?, + lastSeen: Date = .now, + batteryPercentage: UInt8? = nil, + lastSequenceNumber: UInt16? = nil, + userDatabaseNumber: UInt32? = nil + ) { + self.id = id + self.deviceType = deviceType + self.name = name + self.model = model + self.icon = icon + self.lastSeen = lastSeen + self.lastBatteryPercentage = batteryPercentage + self.lastSequenceNumber = lastSequenceNumber + self.userDatabaseNumber = userDatabaseNumber + } +} + + +extension PairedDeviceInfo: Identifiable, Codable {} + + +extension PairedDeviceInfo: Hashable { + // TODO: EQ implementation? + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + + +#if DEBUG +extension PairedDeviceInfo { + /* + // TODO: bring back those??? + static var mockBP5250: PairedDeviceInfo { + PairedDeviceInfo( + id: UUID(), + deviceType: BloodPressureCuffDevice.deviceTypeIdentifier, + name: "BP5250", + model: OmronModel.bp5250, + icon: .asset("Omron-BP5250") + ) + } + + static var mockSC150: PairedDeviceInfo { + PairedDeviceInfo( + id: UUID(), + deviceType: WeightScaleDevice.deviceTypeIdentifier, + name: "SC-150", + model: OmronModel.sc150, + icon: .asset("Omron-SC-150") + ) + } + */ + + @_spi(TestingSupport) + public static var mockHealthDevice1: PairedDeviceInfo { + PairedDeviceInfo( + id: UUID(), + deviceType: "HealthDevice1", + name: "Health Device 1", + model: "HD1", + icon: nil + ) + } + + @_spi(TestingSupport) + public static var mockHealthDevice2: PairedDeviceInfo { + PairedDeviceInfo( + id: UUID(), + deviceType: "HealthDevice2", + name: "Health Device 2", + model: "HD2", + icon: nil + ) + } +} +#endif diff --git a/Sources/SpeziDevicesUI/AccessoryImageView.swift b/Sources/SpeziDevicesUI/AccessoryImageView.swift new file mode 100644 index 0000000..74322f8 --- /dev/null +++ b/Sources/SpeziDevicesUI/AccessoryImageView.swift @@ -0,0 +1,42 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SpeziDevices +import SwiftUI + + +struct AccessoryImageView: View { + private let device: any GenericDevice + + var body: some View { + let image = device.icon?.image ?? Image(systemName: "sensor") // swiftlint:disable:this accessibility_label_for_image + HStack { + image + .resizable() + .aspectRatio(contentMode: .fit) + .accessibilityHidden(true) + .foregroundStyle(Color.accentColor) // set accent color if one uses sf symbols + .symbolRenderingMode(.hierarchical) // set symbol rendering mode if one uses sf symbols + .frame(maxWidth: 250, maxHeight: 120) + } + .frame(maxWidth: .infinity, maxHeight: 150) // make drag-able area a bit larger + .background(Color(uiColor: .systemBackground)) // we need to set a non-clear color for it to be drag-able + } + + + init(_ device: any GenericDevice) { + self.device = device + } +} + + +#if DEBUG +#Preview { + AccessoryImageView(MockDevice.createMockDevice()) +} +#endif diff --git a/Sources/SpeziDevicesUI/AccessorySetupSheet.swift b/Sources/SpeziDevicesUI/AccessorySetupSheet.swift new file mode 100644 index 0000000..31e3578 --- /dev/null +++ b/Sources/SpeziDevicesUI/AccessorySetupSheet.swift @@ -0,0 +1,89 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SpeziDevices +import SpeziViews +import SwiftUI + + +struct AccessorySetupSheet: View where Collection.Element == any PairableDevice { + private let devices: Collection + + @Environment(DeviceManager.self) private var deviceManager + @Environment(\.dismiss) private var dismiss + + @State private var pairingState: PairingState = .discovery + + var body: some View { + NavigationStack { + VStack { + // TODO: make ONE PaneContent? => animation of image transfer? + if case let .error(error) = pairingState { + PairingFailureView(error) + } else if case let .paired(device) = pairingState { + PairedDeviceView(device) + } else if !devices.isEmpty { + PairDeviceView(devices: devices, state: $pairingState) { device in + try await device.pair() + await deviceManager.registerPairedDevice(device) + } + } else { + DiscoveryView() + } + } + .toolbar { // TODO: where to put that? + DismissButton() + } + } + .presentationDetents([.medium]) + .presentationCornerRadius(25) + .interactiveDismissDisabled() + } + + init(_ devices: Collection) { + self.devices = devices + } +} + + +#if DEBUG +#Preview { + Text(verbatim: "") + .sheet(isPresented: .constant(true)) { + AccessorySetupSheet([MockDevice.createMockDevice()]) + } + .previewWith { + DeviceManager() + } +} + + +#Preview { + Text(verbatim: "") + .sheet(isPresented: .constant(true)) { + let devices: [any PairableDevice] = [ + MockDevice.createMockDevice(name: "Device 1"), + MockDevice.createMockDevice(name: "Device 2") + ] + AccessorySetupSheet(devices) + } + .previewWith { + DeviceManager() + } +} + +#Preview { + Text(verbatim: "") + .sheet(isPresented: .constant(true)) { + AccessorySetupSheet([]) + } + .previewWith { + DeviceManager() + } +} +#endif diff --git a/Sources/SpeziDevicesUI/BatteryIcon.swift b/Sources/SpeziDevicesUI/BatteryIcon.swift new file mode 100644 index 0000000..a3680df --- /dev/null +++ b/Sources/SpeziDevicesUI/BatteryIcon.swift @@ -0,0 +1,96 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + +// TODO: Docs! (bundle) + +public struct BatteryIcon: View { + private let percentage: Int + private let isCharging: Bool + + + public var body: some View { + Label { + Text(verbatim: "\(percentage) %") + } icon: { + batteryIcon // hides accessibility, only text will be shown + .foregroundStyle(.primary) + } + .accessibilityRepresentation { + if !isCharging { + Text(verbatim: "\(percentage) %") + } else { + Text(verbatim: "\(percentage) %, is charging") + } + } + } + + + @ViewBuilder private var batteryIcon: some View { + Group { + if isCharging { + Image(systemName: "battery.100percent.bolt") + } else if percentage >= 90 { + Image(systemName: "battery.100") + } else if percentage >= 65 { + Image(systemName: "battery.75") + } else if percentage >= 40 { + Image(systemName: "battery.50") + } else if percentage >= 15 { + Image(systemName: "battery.25") + } else if percentage > 3 { + Image(systemName: "battery.25") + .symbolRenderingMode(.palette) + .foregroundStyle(.red, .primary) + } else { + Image(systemName: "battery.0") + .foregroundColor(.red) + } + } + .accessibilityHidden(true) + } + + + public init(percentage: Int, isCharging: Bool) { + self.percentage = percentage + self.isCharging = isCharging + } + + public init(percentage: Int) { + // isCharging=false is the same behavior as having no charging information + self.init(percentage: percentage, isCharging: false) + } +} + + +#if DEBUG +#Preview { + BatteryIcon(percentage: 100) +} + +#Preview { + BatteryIcon(percentage: 85, isCharging: true) +} + +#Preview { + BatteryIcon(percentage: 70) +} + +#Preview { + BatteryIcon(percentage: 50) +} + +#Preview { + BatteryIcon(percentage: 25) +} + +#Preview { + BatteryIcon(percentage: 10) +} +#endif diff --git a/Sources/SpeziDevicesUI/CarouselDots.swift b/Sources/SpeziDevicesUI/CarouselDots.swift new file mode 100644 index 0000000..4c9d9b0 --- /dev/null +++ b/Sources/SpeziDevicesUI/CarouselDots.swift @@ -0,0 +1,100 @@ +// +// 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 SwiftUI + + +struct CarouselDots: View { + private static let hStackSpacing: CGFloat = 10 + private static let circleDiameter: CGFloat = 7 + private static let padding: CGFloat = 10 + + private let count: Int + @Binding private var selectedIndex: Int + + @State private var isDragging = false + + private var pageNumber: Binding { + .init { + selectedIndex + 1 + } set: { newValue in + selectedIndex = newValue - 1 + } + } + + private var totalWidth: CGFloat { + CGFloat(count) * Self.circleDiameter + CGFloat((count - 1)) * Self.hStackSpacing + 2 * Self.padding + } + + var body: some View { + HStack(spacing: Self.hStackSpacing) { + ForEach(0..) { + self.count = count + self._selectedIndex = selectedIndex + } + + + private func updateIndexBasedOnDrag(_ location: CGPoint) { + guard count > 0 else { // swiftlint:disable:this empty_count + return // swiftlint false positive + } + + let pointWidths = totalWidth / CGFloat(count) + let relativePosition = location.x + + let index = max(0, min(count - 1, Int(relativePosition / pointWidths))) + selectedIndex = index // TODO: this should not animate? + } +} + + +#if DEBUG +#Preview { + CarouselDots(count: 3, selectedIndex: .constant(0)) +} +#endif diff --git a/Sources/SpeziDevicesUI/DeviceDetailsView.swift b/Sources/SpeziDevicesUI/DeviceDetailsView.swift new file mode 100644 index 0000000..4530727 --- /dev/null +++ b/Sources/SpeziDevicesUI/DeviceDetailsView.swift @@ -0,0 +1,152 @@ +// +// 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 SpeziDevices +import SpeziViews +import SwiftUI + + +struct DeviceDetailsView: View { + @Environment(\.dismiss) private var dismiss + @Environment(DeviceManager.self) private var deviceManager + + @Binding private var deviceInfo: PairedDeviceInfo + @State private var presentForgetConfirmation = false + + private var image: Image { + deviceInfo.icon?.image ?? Image(systemName: "sensor") // swiftlint:disable:this accessibility_label_for_image + } + + private var lastSeenToday: Bool { + Calendar.current.isDateInToday(deviceInfo.lastSeen) + } + + var body: some View { + List { + Section { + imageHeader + } + + Section { + infoSection + } + + if let percentage = deviceInfo.lastBatteryPercentage { + Section { + ListRow("Battery") { + BatteryIcon(percentage: Int(percentage)) + .labelStyle(.reverse) + } + } + } + + Section { + Button("Forget This Device") { + presentForgetConfirmation = true + } + } footer: { + if deviceManager.isConnected(device: deviceInfo.id) { + Text("Synchronizing ...") + } else if lastSeenToday { + Text("This device was last seen at \(Text(deviceInfo.lastSeen, style: .time))") + } else { + Text("This device was last seen on \(Text(deviceInfo.lastSeen, style: .date)) at \(Text(deviceInfo.lastSeen, style: .time))") + } + } + } + .navigationTitle("Device Details") + .navigationBarTitleDisplayMode(.inline) + .confirmationDialog("Do you really want to forget this device?", isPresented: $presentForgetConfirmation, titleVisibility: .visible) { + Button("Forget Device", role: .destructive) { + ForgetDeviceTip.hasRemovedPairedDevice = true + deviceManager.forgetDevice(id: deviceInfo.id) + dismiss() + } + Button("Cancel", role: .cancel) {} + } + .toolbar { + if deviceManager.isConnected(device: deviceInfo.id) { + ToolbarItem(placement: .primaryAction) { + ProgressView() + } + } + } + } + + @ViewBuilder private var imageHeader: some View { + VStack(alignment: .center) { + image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 180, maxHeight: 120) + .accessibilityHidden(true) + } + .frame(maxWidth: .infinity) + } + + @ViewBuilder private var infoSection: some View { + NavigationLink { + NameEditView($deviceInfo) + } label: { + ListRow("Name") { + Text(deviceInfo.name) + } + } + + if let model = deviceInfo.model, model != deviceInfo.name { + ListRow("Model") { + Text(model) + } + } + } + + + init(_ deviceInfo: Binding) { + self._deviceInfo = deviceInfo + } +} + + +#if DEBUG +#Preview { + NavigationStack { + DeviceDetailsView(.constant( + PairedDeviceInfo( + id: UUID(), + deviceType: MockDevice.deviceTypeIdentifier, + name: "Blood Pressure Monitor", + model: "BP5250", + icon: .asset("Omron-BP5250"), + batteryPercentage: 100 + ) + )) + } + .previewWith { + DeviceManager() + } +} + +#Preview { + NavigationStack { + DeviceDetailsView(.constant( + PairedDeviceInfo( + id: UUID(), + deviceType: MockDevice.deviceTypeIdentifier, + name: "Weight Scale", + model: "SC-150", + icon: .asset("Omron-SC-150"), + lastSeen: .now.addingTimeInterval(-60 * 60 * 24), + batteryPercentage: 85 + ) + )) + } + .previewWith { + DeviceManager() + } +} +#endif diff --git a/Sources/SpeziDevicesUI/DeviceTile.swift b/Sources/SpeziDevicesUI/DeviceTile.swift new file mode 100644 index 0000000..8371800 --- /dev/null +++ b/Sources/SpeziDevicesUI/DeviceTile.swift @@ -0,0 +1,99 @@ +// +// 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 +// + +#if DEBUG +@_spi(TestingSupport) +#endif +import SpeziDevices +import SwiftUI + + +struct DeviceTile: View { + private let deviceInfo: PairedDeviceInfo + + @Environment(DeviceManager.self) private var deviceManager + + private var image: Image { + deviceInfo.icon?.image ?? Image(systemName: "sensor") // swiftlint:disable:this accessibility_label_for_image + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .top, spacing: 0) { + 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 + .accessibilityHidden(true) + .frame(minWidth: 0, maxWidth: 100, minHeight: 0, maxHeight: 120, alignment: .topLeading) + Spacer() + + if deviceManager.isConnected(device: deviceInfo.id) { + ProgressView() + } + } + .accessibilityHidden(true) + Spacer() + HStack { + Text(deviceInfo.name) + .foregroundStyle(.primary) + .font(.callout) + .fontWeight(.semibold) + .lineLimit(1) + .truncationMode(.tail) + Spacer() + if let percentage = deviceInfo.lastBatteryPercentage { + BatteryIcon(percentage: Int(percentage)) + .labelStyle(.iconOnly) + } + } + } + .padding(16) + .background { + RoundedRectangle(cornerSize: CGSize(width: 25, height: 25)) + .foregroundStyle(Color(uiColor: .secondarySystemGroupedBackground)) + } + .aspectRatio(1.0, contentMode: .fit) // explicit aspect ratio to ensure tile is always square + .accessibilityElement(children: .combine) // TODO: review accessibility! + } + + init(_ deviceInfo: PairedDeviceInfo) { + self.deviceInfo = deviceInfo + } +} + + +#if DEBUG +#Preview { + VStack(spacing: 0) { + HStack(spacing: 16) { + Group { + DeviceTile(.mockHealthDevice2) + DeviceTile(.mockHealthDevice1) + } + .background(Color(uiColor: .systemGroupedBackground)) + .frame(maxHeight: 190) + } + HStack(spacing: 16) { + Group { + DeviceTile(.mockHealthDevice2) + DeviceTile(.mockHealthDevice1) + } + .background(Color(uiColor: .systemGroupedBackground)) + .frame(maxHeight: 190) + } + } + .padding([.leading, .trailing], 12) + .frame(maxHeight: .infinity) + .background(Color(uiColor: .systemGroupedBackground)) + .previewWith { + DeviceManager() + } +} +#endif diff --git a/Sources/SpeziDevicesUI/DevicesGrid.swift b/Sources/SpeziDevicesUI/DevicesGrid.swift new file mode 100644 index 0000000..432da8a --- /dev/null +++ b/Sources/SpeziDevicesUI/DevicesGrid.swift @@ -0,0 +1,127 @@ +// +// 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 +// + +#if DEBUG +@_spi(TestingSupport) +#endif +import SpeziDevices +import SwiftUI +import TipKit + + +struct DevicesGrid: View { + @Binding private var devices: [PairedDeviceInfo] + @Binding private var navigationPath: NavigationPath + @Binding private var presentingDevicePairing: Bool + + + private var gridItems = [ + GridItem(.adaptive(minimum: 120, maximum: 800), spacing: 12), + GridItem(.adaptive(minimum: 120, maximum: 800), spacing: 12) + ] + + + var body: some View { + Group { + if devices.isEmpty { + ZStack { + VStack { + TipView(ForgetDeviceTip.instance) + .padding([.leading, .trailing], 20) + Spacer() + } + DevicesUnavailableView(presentingDevicePairing: $presentingDevicePairing) + } + } else { + ScrollView(.vertical) { + VStack(spacing: 16) { + TipView(ForgetDeviceTip.instance) + .tipBackground(Color(uiColor: .secondarySystemGroupedBackground)) + + LazyVGrid(columns: gridItems) { + ForEach($devices) { device in + Button { + navigationPath.append(device) + } label: { + DeviceTile(device.wrappedValue) + } + .foregroundStyle(.primary) + } + } + } + .padding([.leading, .trailing], 20) + } + .background(Color(uiColor: .systemGroupedBackground)) + } + } + .navigationTitle("Devices") + .navigationDestination(for: Binding.self) { deviceInfo in + DeviceDetailsView(deviceInfo) // TODO: prevents updates :( + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("Add Device", systemImage: "plus") { + presentingDevicePairing = true + } + } + } + } + + + init(devices: Binding<[PairedDeviceInfo]>, navigation: Binding, presentingDevicePairing: Binding) { + self._devices = devices + self._navigationPath = navigation + self._presentingDevicePairing = presentingDevicePairing + } +} + + +// TODO: does that hurt? probably!!! we need to remove it anyways (for update issues) +extension Binding: Hashable, Equatable where Value: Hashable { + public static func == (lhs: Binding, rhs: Binding) -> Bool { + lhs.wrappedValue == rhs.wrappedValue + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(wrappedValue) + } +} + + +#if DEBUG +#Preview { + NavigationStack { + DevicesGrid(devices: .constant([]), navigation: .constant(NavigationPath()), presentingDevicePairing: .constant(false)) + } + .onAppear { + Tips.showAllTipsForTesting() + try? Tips.configure() + } + .previewWith { + DeviceManager() + } +} + +#Preview { + let devices: [PairedDeviceInfo] = [ + .mockHealthDevice1, + .mockHealthDevice2 + ] + + return NavigationStack { + DevicesGrid(devices: .constant(devices), navigation: .constant(NavigationPath()), presentingDevicePairing: .constant(false)) + } + .onAppear { + Tips.showAllTipsForTesting() + try? Tips.configure() + } + .previewWith { + DeviceManager() + } +} +#endif diff --git a/Sources/SpeziDevicesUI/DevicesUnavailableView.swift b/Sources/SpeziDevicesUI/DevicesUnavailableView.swift new file mode 100644 index 0000000..a8c726d --- /dev/null +++ b/Sources/SpeziDevicesUI/DevicesUnavailableView.swift @@ -0,0 +1,39 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +struct DevicesUnavailableView: View { + @Binding private var presentingDevicePairing: Bool + + var body: some View { + ContentUnavailableView { + Text("No Devices") + .fontWeight(.semibold) + } description: { + Text("Paired devices will appear here once set up.") + } actions: { + Button("Pair New Device") { + presentingDevicePairing = true + } + } + } + + + init(presentingDevicePairing: Binding) { + self._presentingDevicePairing = presentingDevicePairing + } +} + + +#if DEBUG +#Preview { + DevicesUnavailableView(presentingDevicePairing: .constant(false)) +} +#endif diff --git a/Sources/SpeziDevicesUI/DiscoveryView.swift b/Sources/SpeziDevicesUI/DiscoveryView.swift new file mode 100644 index 0000000..46ae7c6 --- /dev/null +++ b/Sources/SpeziDevicesUI/DiscoveryView.swift @@ -0,0 +1,32 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +struct DiscoveryView: View { + var body: some View { + PaneContent( + title: "Discovering", + subtitle: "Hold down the Bluetooth button for 3 seconds to put the device into pairing mode." + ) { + ProgressView() + .controlSize(.large) + .accessibilityHidden(true) + } + } +} + + +#if DEBUG +#Preview { + SheetPreview { + DiscoveryView() + } +} +#endif diff --git a/Sources/SpeziDevicesUI/NameEditView.swift b/Sources/SpeziDevicesUI/NameEditView.swift new file mode 100644 index 0000000..8b336a3 --- /dev/null +++ b/Sources/SpeziDevicesUI/NameEditView.swift @@ -0,0 +1,72 @@ +// +// 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 SpeziDevices +import SpeziValidation +import SwiftUI + + +struct NameEditView: View { + @Environment(\.dismiss) private var dismiss + + @Binding private var deviceInfo: PairedDeviceInfo + @State private var name: String + + @ValidationState private var validation + + var body: some View { + List { + VerifiableTextField("enter device name", text: $name) + .validate(input: name, rules: [.nonEmpty, .deviceNameMaxLength]) + .receiveValidation(in: $validation) + .autocapitalization(.words) + } + .navigationTitle("Name") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + Button("Done") { + deviceInfo.name = name + dismiss() + } + .disabled(deviceInfo.name == name || !validation.allInputValid) + } + } + + + init(_ deviceInfo: Binding) { + self._deviceInfo = deviceInfo + self._name = State(wrappedValue: deviceInfo.wrappedValue.name) + } +} + + +extension ValidationRule { + static var deviceNameMaxLength: ValidationRule { + ValidationRule(rule: { input in + input.count <= 50 + }, message: "The device name cannot be longer than 50 characters.") + } +} + + +#if DEBUG +#Preview { + NavigationStack { + NameEditView(.constant( + PairedDeviceInfo( + id: UUID(), + deviceType: MockDevice.deviceTypeIdentifier, + name: "Blood Pressure Monitor", + model: "BP5250", + icon: .asset("Omron-BP5250"), + batteryPercentage: 100 + ) + )) + } +} +#endif diff --git a/Sources/SpeziDevicesUI/PairDeviceView.swift b/Sources/SpeziDevicesUI/PairDeviceView.swift new file mode 100644 index 0000000..9d06477 --- /dev/null +++ b/Sources/SpeziDevicesUI/PairDeviceView.swift @@ -0,0 +1,105 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import ACarousel +import SpeziDevices +import SpeziViews +import SwiftUI + + +struct PairDeviceView: View where Collection.Element == any PairableDevice { + private let devices: Collection + private let pairClosure: (any PairableDevice) async throws -> Void + + @Environment(\.dismiss) private var dismiss + + @Binding private var pairingState: PairingState + @State private var selectedDeviceIndex: Int = 0 + + @AccessibilityFocusState private var isHeaderFocused: Bool + + private var selectedDevice: (any PairableDevice)? { + guard selectedDeviceIndex < devices.count else { + return nil + } + let index = devices.index(devices.startIndex, offsetBy: selectedDeviceIndex) // TODO: compare that against end index? + return devices[index] + } + + private var selectedDeviceName: String { + selectedDevice.map { "\"\($0.label)\"" } ?? "the accessory" + } + + var body: some View { + // TODO: replace application Name everywhere! + PaneContent(title: "Pair Accessory", subtitle: "Do you want to pair \(selectedDeviceName) with the ENGAGE app?") { + if devices.count > 1 { + ACarousel(devices, id: \.id, index: $selectedDeviceIndex, spacing: 0, headspace: 0) { device in + AccessoryImageView(device) + } + .frame(maxHeight: 150) + CarouselDots(count: devices.count, selectedIndex: $selectedDeviceIndex) + } else if let device = devices.first { + AccessoryImageView(device) + } + } action: { + AsyncButton { + guard let selectedDevice else { + return + } + + guard case .discovery = pairingState else { + return + } + + pairingState = .pairing + + do { + try await pairClosure(selectedDevice) + pairingState = .paired(selectedDevice) + } catch { + print(error) // TODO: logger? + pairingState = .error(AnyLocalizedError(error: error)) + } + } label: { + Text("Pair") + .frame(maxWidth: .infinity, maxHeight: 35) + } + .buttonStyle(.borderedProminent) + .padding([.leading, .trailing], 36) + } + } + + + init(devices: Collection, state: Binding, pair: @escaping (any PairableDevice) async throws -> Void) { + self.devices = devices + self._pairingState = state + self.pairClosure = pair + } +} + + +#if DEBUG +#Preview { + SheetPreview { + PairDeviceView(devices: [MockDevice.createMockDevice()], state: .constant(.discovery)) { _ in + } + } +} + +#Preview { + SheetPreview { + let device: [any PairableDevice] = [ + MockDevice.createMockDevice(name: "Device 1"), + MockDevice.createMockDevice(name: "Device 2") + ] + PairDeviceView(devices: device, state: .constant(.discovery)) { _ in + } + } +} +#endif diff --git a/Sources/SpeziDevicesUI/PairedDeviceView.swift b/Sources/SpeziDevicesUI/PairedDeviceView.swift new file mode 100644 index 0000000..a63d4e9 --- /dev/null +++ b/Sources/SpeziDevicesUI/PairedDeviceView.swift @@ -0,0 +1,46 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SpeziDevices +import SwiftUI + + +struct PairedDeviceView: View { + private let device: any PairableDevice + + @Environment(\.dismiss) private var dismiss + + var body: some View { + PaneContent(title: "Accessory Paired", subtitle: "\"\(device.label)\" was successfully paired with the ENGAGE app.") { + AccessoryImageView(device) + } action: { + Button { + dismiss() + } label: { + Text("Done") + .frame(maxWidth: .infinity, maxHeight: 35) + } + .buttonStyle(.borderedProminent) + .padding([.leading, .trailing], 36) + } + } + + + init(_ device: any PairableDevice) { + self.device = device + } +} + + +#if DEBUG +#Preview { + SheetPreview { + PairedDeviceView(MockDevice.createMockDevice()) + } +} +#endif diff --git a/Sources/SpeziDevicesUI/PairingFailureView.swift b/Sources/SpeziDevicesUI/PairingFailureView.swift new file mode 100644 index 0000000..e2070e1 --- /dev/null +++ b/Sources/SpeziDevicesUI/PairingFailureView.swift @@ -0,0 +1,58 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SpeziDevices +import SwiftUI + + +struct PairingFailureView: View { + private let error: any LocalizedError + + private var message: String { + error.failureReason ?? error.errorDescription + ?? String(localized: "Failed to pair accessory.") + } + + @Environment(\.dismiss) private var dismiss + + + var body: some View { + PaneContent(title: Text("Pairing Failed"), subtitle: Text(message)) { + Image(systemName: "exclamationmark.triangle.fill") + .symbolRenderingMode(.hierarchical) + .resizable() + .aspectRatio(contentMode: .fit) + .accessibilityHidden(true) + .frame(maxWidth: 250, maxHeight: 120) + .foregroundStyle(.red) + } action: { + Button { + dismiss() + } label: { + Text("OK") + .frame(maxWidth: .infinity, maxHeight: 35) + } + .buttonStyle(.borderedProminent) + .padding([.leading, .trailing], 36) + } + } + + + init(_ error: any LocalizedError) { + self.error = error + } +} + + +#if DEBUG +#Preview { + SheetPreview { + PairingFailureView(DevicePairingError.notInPairingMode) + } +} +#endif diff --git a/Sources/SpeziDevicesUI/PairingSheet.swift b/Sources/SpeziDevicesUI/PairingSheet.swift new file mode 100644 index 0000000..90bb7f9 --- /dev/null +++ b/Sources/SpeziDevicesUI/PairingSheet.swift @@ -0,0 +1,50 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SpeziBluetooth +import SpeziDevices +import SwiftUI + + +struct PairingSheet: View { + @Environment(Bluetooth.self) private var bluetooth + @Environment(DeviceManager.self) private var deviceManager + + @State private var path = NavigationPath() + + var body: some View { + @Bindable var deviceManager = deviceManager + + NavigationStack(path: $path) { + DevicesGrid(devices: $deviceManager.pairedDevices, navigation: $path, presentingDevicePairing: $deviceManager.presentingDevicePairing) + .scanNearbyDevices(enabled: deviceManager.scanningNearbyDevices, with: bluetooth) // automatically search if no devices are paired + .sheet(isPresented: $deviceManager.presentingDevicePairing) { + AccessorySetupSheet(deviceManager.discoveredDevices.values) + } + .toolbar { + // indicate that we are scanning in the background + if deviceManager.scanningNearbyDevices && !deviceManager.presentingDevicePairing { + ToolbarItem(placement: .cancellationAction) { + ProgressView() + } + } + } + } + } +} + + +#if DEBUG +#Preview { + PairingSheet() + .previewWith { + Bluetooth {} + DeviceManager() + } +} +#endif diff --git a/Sources/SpeziDevicesUI/PairingState.swift b/Sources/SpeziDevicesUI/PairingState.swift new file mode 100644 index 0000000..77413c7 --- /dev/null +++ b/Sources/SpeziDevicesUI/PairingState.swift @@ -0,0 +1,19 @@ +// +// 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 Foundation +import SpeziDevices + + +enum PairingState { + case discovery + case pairing + case paired(any PairableDevice) + case error(LocalizedError) +} + diff --git a/Sources/SpeziDevicesUI/PaneContent.swift b/Sources/SpeziDevicesUI/PaneContent.swift new file mode 100644 index 0000000..6ef70c1 --- /dev/null +++ b/Sources/SpeziDevicesUI/PaneContent.swift @@ -0,0 +1,114 @@ +// +// 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 SpeziViews +import SwiftUI + + +#if DEBUG +struct SheetPreview: View { + private let content: Content + + @State private var isPresented = true + + var body: some View { + Text(verbatim: "") + .sheet(isPresented: $isPresented) { + NavigationStack { + content + .toolbar { + DismissButton() + } + } + .presentationDetents([.medium]) + .presentationCornerRadius(25) + } + } + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } +} +#endif + + +struct PaneContent: View { + private let title: Text + private let subtitle: Text + private let content: Content + private let action: Action + + @AccessibilityFocusState private var isHeaderFocused: Bool + + var body: some View { + VStack { + VStack { + title + .bold() + .font(.largeTitle) + .accessibilityAddTraits(.isHeader) + .accessibilityFocused($isHeaderFocused) + subtitle + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding([.leading, .trailing], 20) + .multilineTextAlignment(.center) + + Spacer() + content + Spacer() + + action + } + .onAppear { + isHeaderFocused = true // TODO: doesn't work too great? + } + } + + init(title: Text, subtitle: Text, @ViewBuilder content: () -> Content, @ViewBuilder action: () -> Action = { EmptyView() }) { + self.title = title + self.subtitle = subtitle + self.content = content() + self.action = action() + } + + init( + title: LocalizedStringResource, + subtitle: LocalizedStringResource, + @ViewBuilder content: () -> Content, + @ViewBuilder action: () -> Action = { EmptyView() } + ) { + self.init(title: Text(title), subtitle: Text(subtitle), content: content, action: action) + } +} + + +#if DEBUG +#Preview { + SheetPreview { + PaneContent(title: "The Title", subtitle: "The Subtitle") { + Image(systemName: "person.crop.square.badge.camera.fill") + .symbolRenderingMode(.hierarchical) + .resizable() + .aspectRatio(contentMode: .fit) + .accessibilityHidden(true) + .frame(maxWidth: 250, maxHeight: 120) + .foregroundStyle(.red) + } action: { + Button { + } label: { + Text("Button") + .frame(maxWidth: .infinity, maxHeight: 35) + } + .buttonStyle(.borderedProminent) + .padding([.leading, .trailing], 36) + } + } +} +#endif \ No newline at end of file diff --git a/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings b/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings new file mode 100644 index 0000000..132ee7c --- /dev/null +++ b/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings @@ -0,0 +1,131 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "\"%@\" was successfully paired with the ENGAGE app." : { + + }, + "Accessory Paired" : { + + }, + "Add Device" : { + + }, + "Battery" : { + + }, + "Button" : { + + }, + "Cancel" : { + + }, + "Device Details" : { + + }, + "Devices" : { + + }, + "Discovering" : { + + }, + "Do you really want to forget this device?" : { + + }, + "Do you want to pair %@ with the ENGAGE app?" : { + + }, + "Done" : { + + }, + "enter device name" : { + + }, + "Failed to pair accessory." : { + + }, + "Forget Device" : { + + }, + "Forget This Device" : { + + }, + "Fully Unpair Device" : { + + }, + "Hold down the Bluetooth button for 3 seconds to put the device into pairing mode." : { + + }, + "Make sure to to remove the device from the Bluetooth settings to fully unpair the device." : { + + }, + "Model" : { + + }, + "Name" : { + + }, + "No Devices" : { + + }, + "OK" : { + + }, + "Open Settings" : { + + }, + "Page" : { + + }, + "Page %lld of %lld" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Page %1$lld of %2$lld" + } + } + } + }, + "Pair" : { + + }, + "Pair Accessory" : { + + }, + "Pair New Device" : { + + }, + "Paired devices will appear here once set up." : { + + }, + "Pairing Failed" : { + + }, + "Synchronizing ..." : { + + }, + "The device name cannot be longer than 50 characters." : { + + }, + "The Subtitle" : { + + }, + "The Title" : { + + }, + "This device was last seen at %@" : { + + }, + "This device was last seen on %@ at %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "This device was last seen on %1$@ at %2$@" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Sources/SpeziDevicesUI/Testing/MockDevice.swift b/Sources/SpeziDevicesUI/Testing/MockDevice.swift new file mode 100644 index 0000000..77283f3 --- /dev/null +++ b/Sources/SpeziDevicesUI/Testing/MockDevice.swift @@ -0,0 +1,76 @@ +// +// 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 BluetoothServices +import Foundation +@_spi(TestingSupport) import SpeziBluetooth +import SpeziDevices + + +#if DEBUG +final class MockDevice: PairableDevice, Identifiable { + @DeviceState(\.id) + var id + @DeviceState(\.name) + var name + @DeviceState(\.state) + var state + @DeviceState(\.advertisementData) + var advertisementData + @DeviceState(\.discarded) + var discarded + + @DeviceAction(\.connect) + var connect + @DeviceAction(\.disconnect) + var disconnect + + + @Service + var deviceInformation = DeviceInformationService() + + @MainActor var _pairingContinuation: CheckedContinuation? + + var isInPairingMode = false // TODO: control + + // TODO: mandatory setup? +} + + +extension MockDevice { + static func createMockDevice(name: String = "Mock Device", state: PeripheralState = .disconnected) -> MockDevice { + let device = MockDevice() + + device.deviceInformation.$manufacturerName.inject("Mock Company") + device.deviceInformation.$modelNumber.inject("MD1") + device.deviceInformation.$hardwareRevision.inject("2") + device.deviceInformation.$firmwareRevision.inject("1.0") + + device.$id.inject(UUID()) + device.$name.inject(name) + device.$state.inject(state) + + device.$connect.inject { @MainActor [weak device] in + device?.$state.inject(.connecting) + // TODO: await device?.handleStateChange(.connecting) + + try? await Task.sleep(for: .seconds(1)) + + device?.$state.inject(.connected) + // TODO: await device?.handleStateChange(.connected) + } + + device.$disconnect.inject { @MainActor [weak device] in + device?.$state.inject(.disconnected) + // TODO: await device?.handleStateChange(.disconnected) + } + + return device + } +} +#endif diff --git a/Sources/SpeziDevicesUI/Tips/ForgetDeviceTip.swift b/Sources/SpeziDevicesUI/Tips/ForgetDeviceTip.swift new file mode 100644 index 0000000..48fd644 --- /dev/null +++ b/Sources/SpeziDevicesUI/Tips/ForgetDeviceTip.swift @@ -0,0 +1,47 @@ +// +// 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 SwiftUI +import TipKit + + +struct ForgetDeviceTip: Tip { // TODO: document that the user needs to set up tip kit? We could just create a Module for that? + static let instance = ForgetDeviceTip() + + @Parameter static var hasRemovedPairedDevice: Bool = false + + var title: Text { + Text("Fully Unpair Device") + } + + var message: Text? { + Text("Make sure to to remove the device from the Bluetooth settings to fully unpair the device.") + } + + var actions: [Action] { + Action { + guard let url = URL(string: "App-Prefs:root=General") else { + return + } + UIApplication.shared.open(url) + } _: { + Text("Open Settings") + } + } + + var image: Image? { + Image(systemName: "exclamationmark.triangle.fill") + .symbolRenderingMode(.hierarchical) + } + + var rules: [Rule] { + #Rule(Self.$hasRemovedPairedDevice) { + $0 == true + } + } +} diff --git a/Sources/SpeziOmron/ManufacturerIdentifier+Omron.swift b/Sources/SpeziOmron/ManufacturerIdentifier+Omron.swift new file mode 100644 index 0000000..245e846 --- /dev/null +++ b/Sources/SpeziOmron/ManufacturerIdentifier+Omron.swift @@ -0,0 +1,17 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SpeziBluetooth + + +extension ManufacturerIdentifier { + /// Bluetooth manufacturer code for "Omron Healthcare Co., Ltd.". + static var omronHealthcareCoLtd: ManufacturerIdentifier { + ManufacturerIdentifier(rawValue: 0x020E) + } +} diff --git a/Sources/SpeziOmron/OmronHealthDevice.swift b/Sources/SpeziOmron/OmronHealthDevice.swift new file mode 100644 index 0000000..cc21cb1 --- /dev/null +++ b/Sources/SpeziOmron/OmronHealthDevice.swift @@ -0,0 +1,37 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SpeziBluetooth +import SpeziDevices + + +protocol OmronHealthDevice: PairableDevice {} + + +extension OmronHealthDevice { + var model: OmronModel { + OmronModel(deviceInformation.modelNumber ?? "Generic Health Device") + } + + var manufacturerData: OmronManufacturerData? { + guard let manufacturerData = advertisementData.manufacturerData else { + return nil + } + return OmronManufacturerData(data: manufacturerData) + } +} + + +extension OmronHealthDevice { + var isInPairingMode: Bool { + if case .pairingMode = manufacturerData?.pairingMode { + return true + } + return false + } +} diff --git a/Sources/SpeziOmron/OmronManufacturerData.swift b/Sources/SpeziOmron/OmronManufacturerData.swift new file mode 100644 index 0000000..f24ea0f --- /dev/null +++ b/Sources/SpeziOmron/OmronManufacturerData.swift @@ -0,0 +1,164 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import ByteCoding +import NIOCore +import SpeziBluetooth + + +struct OmronManufacturerData { + enum PairingMode { + case transferMode + case pairingMode + } + + enum StreamingMode { + case dataCommunication + case streaming + } + + struct UserSlot { + let id: UInt8 + let sequenceNumber: UInt16 + let recordsNumber: UInt8 + } + + enum Mode { + case bluetoothStandard + case omronExtension + } + + fileprivate struct Flags: OptionSet { + static let timeNotSet = Flags(rawValue: 1 << 2) + static let pairingMode = Flags(rawValue: 1 << 3) + static let streamingMode = Flags(rawValue: 1 << 4) + static let wlpStp = Flags(rawValue: 1 << 5) + + let rawValue: UInt8 + + var numberOfUsers: UInt8 { + rawValue & 0x3 + 1 + } + + init(rawValue: UInt8) { + self.rawValue = rawValue + } + + init(numberOfUsers: UInt8) { + precondition(numberOfUsers > 0 && numberOfUsers <= 4, "Only 4 users are supported and at least one.") + self.rawValue = numberOfUsers - 1 + } + } + + let timeSet: Bool + let pairingMode: PairingMode + let streamingMode: StreamingMode + let mode: Mode + + let users: [UserSlot] // max 4 slots + + + init( // swiftlint:disable:this function_default_parameter_at_end + timeSet: Bool = true, + pairingMode: PairingMode, + streamingMode: StreamingMode = .dataCommunication, + mode: Mode = .bluetoothStandard, + users: [UserSlot] + ) { + // swiftlint:disable:next empty_count + precondition(users.count > 0 && users.count <= 4, "Only 4 users are supported and at least one.") + self.timeSet = timeSet + self.pairingMode = pairingMode + self.streamingMode = streamingMode + self.mode = mode + self.users = users + } +} + + +extension OmronManufacturerData.Flags: ByteCodable { + init?(from byteBuffer: inout ByteBuffer) { + guard let rawValue = UInt8(from: &byteBuffer) else { + return nil + } + self.init(rawValue: rawValue) + } + + func encode(to byteBuffer: inout ByteBuffer) { + rawValue.encode(to: &byteBuffer) + } +} + + +extension OmronManufacturerData: ByteCodable { + init?(from byteBuffer: inout ByteBuffer) { + guard let companyIdentifier = ManufacturerIdentifier(from: &byteBuffer) else { + return nil + } + + guard companyIdentifier == .omronHealthcareCoLtd else { + return nil + } + + guard let dataType = UInt8(from: &byteBuffer), + dataType == 0x01 else { // 0x01 signifies start of "Each User Data" + return nil + } + + guard let flags = Flags(from: &byteBuffer) else { + return nil + } + + self.timeSet = !flags.contains(.timeNotSet) + self.pairingMode = flags.contains(.pairingMode) ? .pairingMode : .transferMode + self.streamingMode = flags.contains(.streamingMode) ? .streaming : .dataCommunication + self.mode = flags.contains(.wlpStp) ? .bluetoothStandard : .omronExtension + + var userSlots: [UserSlot] = [] + for userNumber in 1...flags.numberOfUsers { + guard let sequenceNumber = UInt16(from: &byteBuffer), + let numberOfData = UInt8(from: &byteBuffer) else { + return nil + } + + let userData = UserSlot(id: userNumber, sequenceNumber: sequenceNumber, recordsNumber: numberOfData) + userSlots.append(userData) + } + self.users = userSlots + } + + func encode(to byteBuffer: inout ByteBuffer) { + ManufacturerIdentifier.omronHealthcareCoLtd.encode(to: &byteBuffer) + UInt8(0x01).encode(to: &byteBuffer) + + var flags = Flags(numberOfUsers: UInt8(users.count)) + + if !timeSet { + flags.insert(.timeNotSet) + } + + if case .pairingMode = pairingMode { + flags.insert(.pairingMode) + } + + if case .streaming = streamingMode { + flags.insert(.streamingMode) + } + + if case .bluetoothStandard = mode { + flags.insert(.wlpStp) + } + + flags.encode(to: &byteBuffer) + + for user in users { + user.sequenceNumber.encode(to: &byteBuffer) + user.recordsNumber.encode(to: &byteBuffer) + } + } +} diff --git a/Sources/SpeziOmron/OmronModel.swift b/Sources/SpeziOmron/OmronModel.swift new file mode 100644 index 0000000..821686b --- /dev/null +++ b/Sources/SpeziOmron/OmronModel.swift @@ -0,0 +1,41 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + + +struct OmronModel: RawRepresentable { + let rawValue: String + + init(_ model: String) { + self.init(rawValue: model) + } + + init(rawValue: String) { + self.rawValue = rawValue + } +} + + +extension OmronModel { + /// The Omron SC150 weight scale. + static let sc150 = OmronModel("SC-150") + /// The Omron BP5250 blood pressure monitor. + static let bp5250 = OmronModel("BP5250") +} + + +extension OmronModel: Codable { + init(from decoder: any Decoder) throws { + let decoder = try decoder.singleValueContainer() + self.rawValue = try decoder.decode(String.self) + } + + func encode(to encoder: any Encoder) throws { + var encoder = encoder.singleValueContainer() + try encoder.encode(rawValue) + } +} diff --git a/Sources/SpeziOmron/OmronOptionService.swift b/Sources/SpeziOmron/OmronOptionService.swift index e392362..3e91258 100644 --- a/Sources/SpeziOmron/OmronOptionService.swift +++ b/Sources/SpeziOmron/OmronOptionService.swift @@ -21,6 +21,10 @@ 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/SpeziOmronTests/Empty.swift b/Tests/SpeziOmronTests/Empty.swift new file mode 100644 index 0000000..1a0ef8d --- /dev/null +++ b/Tests/SpeziOmronTests/Empty.swift @@ -0,0 +1,9 @@ +// +// 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 Foundation From 6687758fb7922ce4d839bc96841e08e9925ccada Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 21 Jun 2024 18:39:58 +0200 Subject: [PATCH 05/77] Make some interface public --- Package.swift | 1 + .../SpeziDevicesUI/AccessorySetupSheet.swift | 6 +++--- Sources/SpeziDevicesUI/DeviceDetailsView.swift | 6 +++--- Sources/SpeziDevicesUI/DevicesGrid.swift | 6 +++--- Sources/SpeziDevicesUI/PairingSheet.swift | 6 ++++-- .../SpeziDevicesUI/Testing/MockDevice.swift | 2 ++ .../ManufacturerIdentifier+Omron.swift | 2 +- Sources/SpeziOmron/OmronHealthDevice.swift | 8 ++++---- Sources/SpeziOmron/OmronManufacturerData.swift | 18 +++++++++--------- Sources/SpeziOmron/OmronModel.swift | 16 ++++++++-------- 10 files changed, 38 insertions(+), 33 deletions(-) diff --git a/Package.swift b/Package.swift index 6beec1e..60f0bf5 100644 --- a/Package.swift +++ b/Package.swift @@ -41,6 +41,7 @@ let package = Package( .target( name: "SpeziDevices", dependencies: [ + .product(name: "SpeziFoundation", package: "SpeziFoundation"), .product(name: "SpeziBluetooth", package: "SpeziBluetooth"), .product(name: "BluetoothServices", package: "SpeziBluetooth"), .product(name: "BluetoothViews", package: "SpeziBluetooth") // TODO: just because of the One protocol??? diff --git a/Sources/SpeziDevicesUI/AccessorySetupSheet.swift b/Sources/SpeziDevicesUI/AccessorySetupSheet.swift index 31e3578..ab87da1 100644 --- a/Sources/SpeziDevicesUI/AccessorySetupSheet.swift +++ b/Sources/SpeziDevicesUI/AccessorySetupSheet.swift @@ -11,7 +11,7 @@ import SpeziViews import SwiftUI -struct AccessorySetupSheet: View where Collection.Element == any PairableDevice { +public struct AccessorySetupSheet: View where Collection.Element == any PairableDevice { private let devices: Collection @Environment(DeviceManager.self) private var deviceManager @@ -19,7 +19,7 @@ struct AccessorySetupSheet: View where Colle @State private var pairingState: PairingState = .discovery - var body: some View { + public var body: some View { NavigationStack { VStack { // TODO: make ONE PaneContent? => animation of image transfer? @@ -45,7 +45,7 @@ struct AccessorySetupSheet: View where Colle .interactiveDismissDisabled() } - init(_ devices: Collection) { + public init(_ devices: Collection) { self.devices = devices } } diff --git a/Sources/SpeziDevicesUI/DeviceDetailsView.swift b/Sources/SpeziDevicesUI/DeviceDetailsView.swift index 4530727..e4c2916 100644 --- a/Sources/SpeziDevicesUI/DeviceDetailsView.swift +++ b/Sources/SpeziDevicesUI/DeviceDetailsView.swift @@ -11,7 +11,7 @@ import SpeziViews import SwiftUI -struct DeviceDetailsView: View { +public struct DeviceDetailsView: View { @Environment(\.dismiss) private var dismiss @Environment(DeviceManager.self) private var deviceManager @@ -26,7 +26,7 @@ struct DeviceDetailsView: View { Calendar.current.isDateInToday(deviceInfo.lastSeen) } - var body: some View { + public var body: some View { List { Section { imageHeader @@ -106,7 +106,7 @@ struct DeviceDetailsView: View { } - init(_ deviceInfo: Binding) { + public init(_ deviceInfo: Binding) { self._deviceInfo = deviceInfo } } diff --git a/Sources/SpeziDevicesUI/DevicesGrid.swift b/Sources/SpeziDevicesUI/DevicesGrid.swift index 432da8a..ffd588d 100644 --- a/Sources/SpeziDevicesUI/DevicesGrid.swift +++ b/Sources/SpeziDevicesUI/DevicesGrid.swift @@ -14,7 +14,7 @@ import SwiftUI import TipKit -struct DevicesGrid: View { +public struct DevicesGrid: View { @Binding private var devices: [PairedDeviceInfo] @Binding private var navigationPath: NavigationPath @Binding private var presentingDevicePairing: Bool @@ -26,7 +26,7 @@ struct DevicesGrid: View { ] - var body: some View { + public var body: some View { Group { if devices.isEmpty { ZStack { @@ -73,7 +73,7 @@ struct DevicesGrid: View { } - init(devices: Binding<[PairedDeviceInfo]>, navigation: Binding, presentingDevicePairing: Binding) { + public init(devices: Binding<[PairedDeviceInfo]>, navigation: Binding, presentingDevicePairing: Binding) { self._devices = devices self._navigationPath = navigation self._presentingDevicePairing = presentingDevicePairing diff --git a/Sources/SpeziDevicesUI/PairingSheet.swift b/Sources/SpeziDevicesUI/PairingSheet.swift index 90bb7f9..8399a19 100644 --- a/Sources/SpeziDevicesUI/PairingSheet.swift +++ b/Sources/SpeziDevicesUI/PairingSheet.swift @@ -11,13 +11,13 @@ import SpeziDevices import SwiftUI -struct PairingSheet: View { +public struct PairingSheet: View { @Environment(Bluetooth.self) private var bluetooth @Environment(DeviceManager.self) private var deviceManager @State private var path = NavigationPath() - var body: some View { + public var body: some View { @Bindable var deviceManager = deviceManager NavigationStack(path: $path) { @@ -36,6 +36,8 @@ struct PairingSheet: View { } } } + + public init() {} } diff --git a/Sources/SpeziDevicesUI/Testing/MockDevice.swift b/Sources/SpeziDevicesUI/Testing/MockDevice.swift index 77283f3..086bc17 100644 --- a/Sources/SpeziDevicesUI/Testing/MockDevice.swift +++ b/Sources/SpeziDevicesUI/Testing/MockDevice.swift @@ -34,6 +34,8 @@ final class MockDevice: PairableDevice, Identifiable { @Service var deviceInformation = DeviceInformationService() + // TODO: swiftlint thingy! + // swiftlint:disable:next identifier_name @MainActor var _pairingContinuation: CheckedContinuation? var isInPairingMode = false // TODO: control diff --git a/Sources/SpeziOmron/ManufacturerIdentifier+Omron.swift b/Sources/SpeziOmron/ManufacturerIdentifier+Omron.swift index 245e846..be6f4fc 100644 --- a/Sources/SpeziOmron/ManufacturerIdentifier+Omron.swift +++ b/Sources/SpeziOmron/ManufacturerIdentifier+Omron.swift @@ -11,7 +11,7 @@ import SpeziBluetooth extension ManufacturerIdentifier { /// Bluetooth manufacturer code for "Omron Healthcare Co., Ltd.". - static var omronHealthcareCoLtd: ManufacturerIdentifier { + public static var omronHealthcareCoLtd: ManufacturerIdentifier { ManufacturerIdentifier(rawValue: 0x020E) } } diff --git a/Sources/SpeziOmron/OmronHealthDevice.swift b/Sources/SpeziOmron/OmronHealthDevice.swift index cc21cb1..6bb8b45 100644 --- a/Sources/SpeziOmron/OmronHealthDevice.swift +++ b/Sources/SpeziOmron/OmronHealthDevice.swift @@ -10,15 +10,15 @@ import SpeziBluetooth import SpeziDevices -protocol OmronHealthDevice: PairableDevice {} +public protocol OmronHealthDevice: PairableDevice {} extension OmronHealthDevice { - var model: OmronModel { + public var model: OmronModel { OmronModel(deviceInformation.modelNumber ?? "Generic Health Device") } - var manufacturerData: OmronManufacturerData? { + public var manufacturerData: OmronManufacturerData? { guard let manufacturerData = advertisementData.manufacturerData else { return nil } @@ -28,7 +28,7 @@ extension OmronHealthDevice { extension OmronHealthDevice { - var isInPairingMode: Bool { + public var isInPairingMode: Bool { if case .pairingMode = manufacturerData?.pairingMode { return true } diff --git a/Sources/SpeziOmron/OmronManufacturerData.swift b/Sources/SpeziOmron/OmronManufacturerData.swift index f24ea0f..2626bcf 100644 --- a/Sources/SpeziOmron/OmronManufacturerData.swift +++ b/Sources/SpeziOmron/OmronManufacturerData.swift @@ -11,24 +11,24 @@ import NIOCore import SpeziBluetooth -struct OmronManufacturerData { - enum PairingMode { +public struct OmronManufacturerData { + public enum PairingMode { case transferMode case pairingMode } - enum StreamingMode { + public enum StreamingMode { case dataCommunication case streaming } - struct UserSlot { + public struct UserSlot { let id: UInt8 let sequenceNumber: UInt16 let recordsNumber: UInt8 } - enum Mode { + public enum Mode { case bluetoothStandard case omronExtension } @@ -82,21 +82,21 @@ struct OmronManufacturerData { extension OmronManufacturerData.Flags: ByteCodable { - init?(from byteBuffer: inout ByteBuffer) { + public init?(from byteBuffer: inout ByteBuffer) { guard let rawValue = UInt8(from: &byteBuffer) else { return nil } self.init(rawValue: rawValue) } - func encode(to byteBuffer: inout ByteBuffer) { + public func encode(to byteBuffer: inout ByteBuffer) { rawValue.encode(to: &byteBuffer) } } extension OmronManufacturerData: ByteCodable { - init?(from byteBuffer: inout ByteBuffer) { + public init?(from byteBuffer: inout ByteBuffer) { guard let companyIdentifier = ManufacturerIdentifier(from: &byteBuffer) else { return nil } @@ -132,7 +132,7 @@ extension OmronManufacturerData: ByteCodable { self.users = userSlots } - func encode(to byteBuffer: inout ByteBuffer) { + public func encode(to byteBuffer: inout ByteBuffer) { ManufacturerIdentifier.omronHealthcareCoLtd.encode(to: &byteBuffer) UInt8(0x01).encode(to: &byteBuffer) diff --git a/Sources/SpeziOmron/OmronModel.swift b/Sources/SpeziOmron/OmronModel.swift index 821686b..2623b26 100644 --- a/Sources/SpeziOmron/OmronModel.swift +++ b/Sources/SpeziOmron/OmronModel.swift @@ -7,14 +7,14 @@ // -struct OmronModel: RawRepresentable { - let rawValue: String +public struct OmronModel: RawRepresentable { + public let rawValue: String - init(_ model: String) { + public init(_ model: String) { self.init(rawValue: model) } - init(rawValue: String) { + public init(rawValue: String) { self.rawValue = rawValue } } @@ -22,19 +22,19 @@ struct OmronModel: RawRepresentable { extension OmronModel { /// The Omron SC150 weight scale. - static let sc150 = OmronModel("SC-150") + public static let sc150 = OmronModel("SC-150") /// The Omron BP5250 blood pressure monitor. - static let bp5250 = OmronModel("BP5250") + public static let bp5250 = OmronModel("BP5250") } extension OmronModel: Codable { - init(from decoder: any Decoder) throws { + public init(from decoder: any Decoder) throws { let decoder = try decoder.singleValueContainer() self.rawValue = try decoder.decode(String.self) } - func encode(to encoder: any Encoder) throws { + public func encode(to encoder: any Encoder) throws { var encoder = encoder.singleValueContainer() try encoder.encode(rawValue) } From b9e1856d09b83f5788f3ec7b937b05cdb3dbb7d2 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 21 Jun 2024 18:52:03 +0200 Subject: [PATCH 06/77] Add dedicated HealthDevice protocol --- .../Health/HealthDevice+HKDevice.swift | 25 +++++++++++++++++++ .../SpeziDevices/Health/HealthDevice.swift | 12 +++++++++ Sources/SpeziDevices/ImageReference.swift | 2 +- Sources/SpeziDevicesUI/PaneContent.swift | 4 +-- Sources/SpeziOmron/OmronHealthDevice.swift | 2 +- .../SpeziOmron/OmronManufacturerData.swift | 12 ++++----- 6 files changed, 47 insertions(+), 10 deletions(-) create mode 100644 Sources/SpeziDevices/Health/HealthDevice+HKDevice.swift create mode 100644 Sources/SpeziDevices/Health/HealthDevice.swift diff --git a/Sources/SpeziDevices/Health/HealthDevice+HKDevice.swift b/Sources/SpeziDevices/Health/HealthDevice+HKDevice.swift new file mode 100644 index 0000000..5a08116 --- /dev/null +++ b/Sources/SpeziDevices/Health/HealthDevice+HKDevice.swift @@ -0,0 +1,25 @@ +// +// 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 + + +extension HealthDevice { + public var hkDevice: HKDevice { // TODO: doesn't necessarily need to be public if we move MeasurementManager! + HKDevice( + name: name, + manufacturer: deviceInformation.manufacturerName, + model: deviceInformation.modelNumber, + hardwareVersion: deviceInformation.hardwareRevision, + firmwareVersion: deviceInformation.firmwareRevision, + softwareVersion: deviceInformation.softwareRevision, + localIdentifier: nil, + udiDeviceIdentifier: nil + ) + } +} diff --git a/Sources/SpeziDevices/Health/HealthDevice.swift b/Sources/SpeziDevices/Health/HealthDevice.swift new file mode 100644 index 0000000..36047e8 --- /dev/null +++ b/Sources/SpeziDevices/Health/HealthDevice.swift @@ -0,0 +1,12 @@ +// +// 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 + + +public protocol HealthDevice: GenericDevice {} // TODO: docs! diff --git a/Sources/SpeziDevices/ImageReference.swift b/Sources/SpeziDevices/ImageReference.swift index 61e2ff4..d97ca8b 100644 --- a/Sources/SpeziDevices/ImageReference.swift +++ b/Sources/SpeziDevices/ImageReference.swift @@ -9,7 +9,7 @@ import SwiftUI -public enum ImageReference { +public enum ImageReference { // TODO: SpeziViews candidate! case system(String) case asset(String, bundle: Bundle? = nil) } diff --git a/Sources/SpeziDevicesUI/PaneContent.swift b/Sources/SpeziDevicesUI/PaneContent.swift index 6ef70c1..13eb175 100644 --- a/Sources/SpeziDevicesUI/PaneContent.swift +++ b/Sources/SpeziDevicesUI/PaneContent.swift @@ -37,7 +37,7 @@ struct SheetPreview: View { #endif -struct PaneContent: View { +struct PaneContent: View { // TODO: SpeziViews candidate? private let title: Text private let subtitle: Text private let content: Content @@ -111,4 +111,4 @@ struct PaneContent: View { } } } -#endif \ No newline at end of file +#endif diff --git a/Sources/SpeziOmron/OmronHealthDevice.swift b/Sources/SpeziOmron/OmronHealthDevice.swift index 6bb8b45..d3c6649 100644 --- a/Sources/SpeziOmron/OmronHealthDevice.swift +++ b/Sources/SpeziOmron/OmronHealthDevice.swift @@ -10,7 +10,7 @@ import SpeziBluetooth import SpeziDevices -public protocol OmronHealthDevice: PairableDevice {} +public protocol OmronHealthDevice: HealthDevice, PairableDevice {} extension OmronHealthDevice { diff --git a/Sources/SpeziOmron/OmronManufacturerData.swift b/Sources/SpeziOmron/OmronManufacturerData.swift index 2626bcf..2edb47d 100644 --- a/Sources/SpeziOmron/OmronManufacturerData.swift +++ b/Sources/SpeziOmron/OmronManufacturerData.swift @@ -55,15 +55,15 @@ public struct OmronManufacturerData { } } - let timeSet: Bool - let pairingMode: PairingMode - let streamingMode: StreamingMode - let mode: Mode + public let timeSet: Bool + public let pairingMode: PairingMode + public let streamingMode: StreamingMode + public let mode: Mode - let users: [UserSlot] // max 4 slots + public let users: [UserSlot] // max 4 slots - init( // swiftlint:disable:this function_default_parameter_at_end + public init( // swiftlint:disable:this function_default_parameter_at_end timeSet: Bool = true, pairingMode: PairingMode, streamingMode: StreamingMode = .dataCommunication, From f914a4a129f8317d9cc7199c3cc1567e7f6bd4df Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 21 Jun 2024 18:54:13 +0200 Subject: [PATCH 07/77] Ensure UserSlot is publicy accessible --- Sources/SpeziOmron/OmronManufacturerData.swift | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/Sources/SpeziOmron/OmronManufacturerData.swift b/Sources/SpeziOmron/OmronManufacturerData.swift index 2edb47d..369c4e1 100644 --- a/Sources/SpeziOmron/OmronManufacturerData.swift +++ b/Sources/SpeziOmron/OmronManufacturerData.swift @@ -23,9 +23,16 @@ public struct OmronManufacturerData { } public struct UserSlot { - let id: UInt8 - let sequenceNumber: UInt16 - let recordsNumber: UInt8 + public let id: UInt8 + public let sequenceNumber: UInt16 + public let recordsNumber: UInt8 + + + public init(id: UInt8, sequenceNumber: UInt16, recordsNumber: UInt8) { + self.id = id + self.sequenceNumber = sequenceNumber + self.recordsNumber = recordsNumber + } } public enum Mode { @@ -81,6 +88,9 @@ public struct OmronManufacturerData { } +extension OmronManufacturerData.UserSlot: Identifiable {} + + extension OmronManufacturerData.Flags: ByteCodable { public init?(from byteBuffer: inout ByteBuffer) { guard let rawValue = UInt8(from: &byteBuffer) else { From ae86e0ee3b8b4a5783e6e0f826d91f74221ce580 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 21 Jun 2024 22:03:24 +0200 Subject: [PATCH 08/77] A bunch of documentation and adjustments --- Package.swift | 2 + Sources/SpeziDevices/DeviceManager.swift | 6 +- .../{ => Devices}/BatteryPoweredDevice.swift | 8 ++ .../SpeziDevices/Devices/GenericDevice.swift | 75 +++++++++++ .../{Health => Devices}/HealthDevice.swift | 3 +- .../SpeziDevices/Devices/PairableDevice.swift | 119 ++++++++++++++++++ Sources/SpeziDevices/GenericDevice.swift | 35 ------ .../{ => Model}/DevicePairingError.swift | 13 +- .../{ => Model}/ImageReference.swift | 6 + .../{ => Model}/PairedDeviceInfo.swift | 66 +++++----- .../Model/PairingContinuation.swift | 82 ++++++++++++ Sources/SpeziDevices/PairableDevice.swift | 106 ---------------- .../SpeziDevices.docc/SpeziDevices.md | 33 +++++ .../{ => Devices}/BatteryIcon.swift | 8 +- .../{ => Devices}/DeviceDetailsView.swift | 3 + .../{ => Devices}/DeviceTile.swift | 7 +- .../{ => Devices}/DevicesGrid.swift | 7 ++ .../DevicesTab.swift} | 19 ++- .../DevicesUnavailableView.swift | 0 .../{ => Devices}/NameEditView.swift | 0 .../{ => Pairing}/AccessoryImageView.swift | 0 .../{ => Pairing}/AccessorySetupSheet.swift | 29 +++-- .../{ => Pairing}/DiscoveryView.swift | 0 .../Model/PairingViewState.swift} | 8 +- .../{ => Pairing}/PairDeviceView.swift | 10 +- .../{ => Pairing}/PairedDeviceView.swift | 8 +- .../{ => Pairing}/PairingFailureView.swift | 0 .../SpeziDevicesUI.docc/SpeziDevicesUI.md | 23 ++++ .../SpeziDevicesUI/Testing/MockDevice.swift | 37 ++---- .../{ => Utils}/CarouselDots.swift | 0 .../{ => Utils}/PaneContent.swift | 0 Sources/SpeziOmron/OmronHealthDevice.swift | 9 ++ .../SpeziOmron/OmronManufacturerData.swift | 61 ++++++--- Sources/SpeziOmron/OmronOptionService.swift | 3 +- .../SpeziOmron/SpeziOmron.docc/SpeziOmron.md | 2 + 35 files changed, 542 insertions(+), 246 deletions(-) rename Sources/SpeziDevices/{ => Devices}/BatteryPoweredDevice.swift (50%) create mode 100644 Sources/SpeziDevices/Devices/GenericDevice.swift rename Sources/SpeziDevices/{Health => Devices}/HealthDevice.swift (72%) create mode 100644 Sources/SpeziDevices/Devices/PairableDevice.swift delete mode 100644 Sources/SpeziDevices/GenericDevice.swift rename Sources/SpeziDevices/{ => Model}/DevicePairingError.swift (70%) rename Sources/SpeziDevices/{ => Model}/ImageReference.swift (90%) rename Sources/SpeziDevices/{ => Model}/PairedDeviceInfo.swift (60%) create mode 100644 Sources/SpeziDevices/Model/PairingContinuation.swift delete mode 100644 Sources/SpeziDevices/PairableDevice.swift create mode 100644 Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md rename Sources/SpeziDevicesUI/{ => Devices}/BatteryIcon.swift (86%) rename Sources/SpeziDevicesUI/{ => Devices}/DeviceDetailsView.swift (96%) rename Sources/SpeziDevicesUI/{ => Devices}/DeviceTile.swift (93%) rename Sources/SpeziDevicesUI/{ => Devices}/DevicesGrid.swift (91%) rename Sources/SpeziDevicesUI/{PairingSheet.swift => Devices/DevicesTab.swift} (67%) rename Sources/SpeziDevicesUI/{ => Devices}/DevicesUnavailableView.swift (100%) rename Sources/SpeziDevicesUI/{ => Devices}/NameEditView.swift (100%) rename Sources/SpeziDevicesUI/{ => Pairing}/AccessoryImageView.swift (100%) rename Sources/SpeziDevicesUI/{ => Pairing}/AccessorySetupSheet.swift (65%) rename Sources/SpeziDevicesUI/{ => Pairing}/DiscoveryView.swift (100%) rename Sources/SpeziDevicesUI/{PairingState.swift => Pairing/Model/PairingViewState.swift} (58%) rename Sources/SpeziDevicesUI/{ => Pairing}/PairDeviceView.swift (87%) rename Sources/SpeziDevicesUI/{ => Pairing}/PairedDeviceView.swift (76%) rename Sources/SpeziDevicesUI/{ => Pairing}/PairingFailureView.swift (100%) create mode 100644 Sources/SpeziDevicesUI/SpeziDevicesUI.docc/SpeziDevicesUI.md rename Sources/SpeziDevicesUI/{ => Utils}/CarouselDots.swift (100%) rename Sources/SpeziDevicesUI/{ => Utils}/PaneContent.swift (100%) diff --git a/Package.swift b/Package.swift index 60f0bf5..a517a82 100644 --- a/Package.swift +++ b/Package.swift @@ -30,6 +30,7 @@ let package = Package( .library(name: "SpeziOmron", targets: ["SpeziOmron"]) ], dependencies: [ + .package(url: "https://github.com/apple/swift-collections.git", from: "1.1.1"), .package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "1.1.1"), .package(url: "https://github.com/StanfordSpezi/SpeziViews.git", from: "1.4.0"), .package(url: "https://github.com/StanfordSpezi/SpeziBluetooth", branch: "feature/accessory-discovery"), @@ -41,6 +42,7 @@ let package = Package( .target( name: "SpeziDevices", dependencies: [ + .product(name: "OrderedCollections", package: "swift-collections"), .product(name: "SpeziFoundation", package: "SpeziFoundation"), .product(name: "SpeziBluetooth", package: "SpeziBluetooth"), .product(name: "BluetoothServices", package: "SpeziBluetooth"), diff --git a/Sources/SpeziDevices/DeviceManager.swift b/Sources/SpeziDevices/DeviceManager.swift index f9ae074..130abb8 100644 --- a/Sources/SpeziDevices/DeviceManager.swift +++ b/Sources/SpeziDevices/DeviceManager.swift @@ -20,7 +20,7 @@ import SwiftUI // TODO: move deviceManager to SpeziBluetooth (and measurement manager?) @Observable -public class DeviceManager: Module, EnvironmentAccessible, DefaultInitializable { +public final class DeviceManager: Module, EnvironmentAccessible, DefaultInitializable { /// Determines if the device discovery sheet should be presented. @MainActor public var presentingDevicePairing = false // TODO: "should" naming @MainActor public private(set) var discoveredDevices: OrderedDictionary = [:] @@ -48,7 +48,7 @@ public class DeviceManager: Module, EnvironmentAccessible, DefaultInitializable @Application(\.logger) @ObservationIgnored private var logger @Dependency @ObservationIgnored private var bluetooth: Bluetooth? - required public init() {} + required public init() {} // TODO: configure automatic search without devices paired! public func configure() { guard let bluetooth else { @@ -169,6 +169,8 @@ public class DeviceManager: Module, EnvironmentAccessible, DefaultInitializable assert(peripherals[device.id] == nil, "Cannot overwrite peripheral. Device \(deviceInfo) was paired twice.") peripherals[device.id] = device + + self.logger.debug("Device \(device.label) with id \(device.id) is now paired!") } @MainActor diff --git a/Sources/SpeziDevices/BatteryPoweredDevice.swift b/Sources/SpeziDevices/Devices/BatteryPoweredDevice.swift similarity index 50% rename from Sources/SpeziDevices/BatteryPoweredDevice.swift rename to Sources/SpeziDevices/Devices/BatteryPoweredDevice.swift index 1946809..d0ded50 100644 --- a/Sources/SpeziDevices/BatteryPoweredDevice.swift +++ b/Sources/SpeziDevices/Devices/BatteryPoweredDevice.swift @@ -10,6 +10,14 @@ import BluetoothServices import SpeziBluetooth +/// A battery powered Bluetooth device. public protocol BatteryPoweredDevice: BluetoothDevice { + /// The battery service of the peripheral. + /// + /// Use the [`@Service`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/service) property wrapper to + /// declare this property. + /// ```swift + /// @Service var deviceInformation = BatteryService() + /// ``` var battery: BatteryService { get } } diff --git a/Sources/SpeziDevices/Devices/GenericDevice.swift b/Sources/SpeziDevices/Devices/GenericDevice.swift new file mode 100644 index 0000000..a612317 --- /dev/null +++ b/Sources/SpeziDevices/Devices/GenericDevice.swift @@ -0,0 +1,75 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import BluetoothServices +import BluetoothViews +import Foundation +import SpeziBluetooth + + +// TODO: move GenericBluetoothPeripheral to here? +// TODO: => merge BluetoothViews into SpeziDevicesUI? + +/// A generic Bluetooth device. +/// +/// A generic Bluetooth device that provides access to basic device information. +public protocol GenericDevice: BluetoothDevice, GenericBluetoothPeripheral { + /// The device identifier. + /// + /// Use the [`DeviceState`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/devicestate) property wrapper to + /// declare this property. + /// ```swift + /// @DeviceState(\.id) var id + /// ``` + var id: UUID { get } + /// The device name. + /// + /// Use the [`DeviceState`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/devicestate) property wrapper to + /// declare this property. + /// ```swift + /// @DeviceState(\.name) var name + /// ``` + var name: String? { get } + /// The advertisement data received in the latest advertisement. + /// + /// Use the [`DeviceState`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/devicestate) property wrapper to + /// declare this property. + /// ```swift + /// @DeviceState(\.advertisementData) var advertisementData + /// ``` + var advertisementData: AdvertisementData { get } + + /// The device information service of the peripheral. + /// + /// Use the [`@Service`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/service) property wrapper to + /// declare this property. + /// ```swift + /// @Service var deviceInformation = DeviceInformationService() + /// ``` + var deviceInformation: DeviceInformationService { get } + + /// An icon that is used to visually present the device to the user. + var icon: ImageReference? { get } +} + + +extension GenericDevice { + /// Default label implementation. + /// + /// Returns `"Generic Device"` if the peripheral doesn't expose a ``name``. + public var label: String { + name ?? "Generic Device" + } + + /// Default icon implementation. + /// + /// Returns `nil` by default. Results in a generic icon to be presented. + public var icon: ImageReference? { + nil + } +} diff --git a/Sources/SpeziDevices/Health/HealthDevice.swift b/Sources/SpeziDevices/Devices/HealthDevice.swift similarity index 72% rename from Sources/SpeziDevices/Health/HealthDevice.swift rename to Sources/SpeziDevices/Devices/HealthDevice.swift index 36047e8..b5752c9 100644 --- a/Sources/SpeziDevices/Health/HealthDevice.swift +++ b/Sources/SpeziDevices/Devices/HealthDevice.swift @@ -9,4 +9,5 @@ import HealthKit -public protocol HealthDevice: GenericDevice {} // TODO: docs! +/// A generic Bluetooth Health device. +public protocol HealthDevice: GenericDevice {} diff --git a/Sources/SpeziDevices/Devices/PairableDevice.swift b/Sources/SpeziDevices/Devices/PairableDevice.swift new file mode 100644 index 0000000..ccda7bd --- /dev/null +++ b/Sources/SpeziDevices/Devices/PairableDevice.swift @@ -0,0 +1,119 @@ +// +// 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 SpeziBluetooth +import SpeziFoundation + + +/// A Bluetooth device that is pairable. +public protocol PairableDevice: GenericDevice { + /// Persistent identifier for the device type. + /// + /// This is used to associate pairing information with the implementing device. By default, the type name is used. + static var deviceTypeIdentifier: String { get } + + /// Indicate that the device was discarded. + /// + /// Use the [`DeviceState`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/devicestate) property wrapper to + /// declare this property. + /// ```swift + /// @DeviceState(\.discarded) var discarded + /// ``` + var discarded: 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 + /// declare this property. + /// ```swift + /// @DeviceAction(\.connect) var connect + /// ``` + var connect: BluetoothConnectAction { get } + /// Disconnect action. + /// + /// Use the [`DeviceAction`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/deviceaction) property wrapper to + /// declare this property. + /// ```swift + /// @DeviceAction(\.disconnect) var disconnect + /// ``` + var disconnect: BluetoothDisconnectAction { get } + + /// Determines if the device is currently able to get paired. + /// + /// 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 +} + + +extension PairableDevice { + /// Default persistent identifier for the device type. + /// + /// Defaults to the Swift type name. + public static var deviceTypeIdentifier: String { + "\(Self.self)" + } +} + + +extension PairableDevice { + /// Default pairing implementation. + /// + /// The default implementation verifies that the device ``isInPairingMode``, is currently disconnected and not ``discarded``. + /// 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 { + guard isInPairingMode else { + throw DevicePairingError.notInPairingMode + } + + guard case .disconnected = state else { + throw DevicePairingError.invalidState + } + + guard !discarded else { + throw DevicePairingError.invalidState + } + + + try await pairing.pairingSession { + 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/GenericDevice.swift b/Sources/SpeziDevices/GenericDevice.swift deleted file mode 100644 index 83876ea..0000000 --- a/Sources/SpeziDevices/GenericDevice.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-project -// -// SPDX-FileCopyrightText: 2024 Stanford University -// -// SPDX-License-Identifier: MIT -// - -import BluetoothServices -import BluetoothViews -import Foundation -import SpeziBluetooth - - -public protocol GenericDevice: BluetoothDevice, GenericBluetoothPeripheral { - var id: UUID { get } - var name: String? { get } - var advertisementData: AdvertisementData { get } - var discarded: Bool { get } - - var deviceInformation: DeviceInformationService { get } - - var icon: ImageReference? { get } -} - - -extension GenericDevice { - public var label: String { - name ?? "Generic Device" - } - - public var icon: ImageReference? { - nil - } -} diff --git a/Sources/SpeziDevices/DevicePairingError.swift b/Sources/SpeziDevices/Model/DevicePairingError.swift similarity index 70% rename from Sources/SpeziDevices/DevicePairingError.swift rename to Sources/SpeziDevices/Model/DevicePairingError.swift index 4d20c7e..7d3cce9 100644 --- a/Sources/SpeziDevices/DevicePairingError.swift +++ b/Sources/SpeziDevices/Model/DevicePairingError.swift @@ -10,12 +10,23 @@ import Foundation import SpeziFoundation +/// A device pairing error. public enum DevicePairingError { + /// Device is currently in an invalid state. + /// + /// For example the device is not disconnected or the advertisement was already discarded. case invalidState - /// The device is busy (e.g., already pairing). + /// The device is busy. + /// + /// For example the device is already within a pairing session case busy /// The device is not in pairing mode. + /// + /// The ``PairableDevice/isInPairingMode`` reports that the device is not pairable. case notInPairingMode + /// The device disconnected while pairing. + /// + /// The device disconnecting indicated that the pairing failed. case deviceDisconnected } diff --git a/Sources/SpeziDevices/ImageReference.swift b/Sources/SpeziDevices/Model/ImageReference.swift similarity index 90% rename from Sources/SpeziDevices/ImageReference.swift rename to Sources/SpeziDevices/Model/ImageReference.swift index d97ca8b..5af98b4 100644 --- a/Sources/SpeziDevices/ImageReference.swift +++ b/Sources/SpeziDevices/Model/ImageReference.swift @@ -9,13 +9,19 @@ import SwiftUI +/// Reference an Image Resource. public enum ImageReference { // TODO: SpeziViews candidate! + /// Provides the system name for an image. case system(String) + /// Reference an image from the asset catalog of a bundle. case asset(String, bundle: Bundle? = nil) } extension ImageReference { + /// Retrieve Image. + /// + /// Returns nil if the image resource could not be located. public var image: Image? { switch self { case let .system(name): diff --git a/Sources/SpeziDevices/PairedDeviceInfo.swift b/Sources/SpeziDevices/Model/PairedDeviceInfo.swift similarity index 60% rename from Sources/SpeziDevices/PairedDeviceInfo.swift rename to Sources/SpeziDevices/Model/PairedDeviceInfo.swift index 0bce465..d6b995d 100644 --- a/Sources/SpeziDevices/PairedDeviceInfo.swift +++ b/Sources/SpeziDevices/Model/PairedDeviceInfo.swift @@ -9,46 +9,45 @@ import Foundation -public struct PairedDeviceInfo { // TODO: observable and editable? +/// Persistent information stored of a paired device. +public struct PairedDeviceInfo { // TODO: observablen => resolves UI update issue! + /// The CoreBluetooth device identifier. public let id: UUID - public let deviceType: String + /// The device type. + /// + /// Stores the associated ``PairableDevice/deviceTypeIdentifier-9wsed`` device type used to locate the device implementation. + public let deviceType: String // TODO: verify link + /// Visual representation of the device. public let icon: ImageReference? - public let model: String? + /// A model string of the device. + public let model: String? // TODO: this one as well! // TODO: make some things have internal setters? + /// The user edit-able name of the device. public var name: String - public var lastSeen: Date - public var lastBatteryPercentage: UInt8? + /// The date the device was last seen. + public var lastSeen: Date // TODO: don't set within the device class itself + /// The last reported battery percentage of the device. + public var lastBatteryPercentage: UInt8? // TODO: update those values based on the Observation framework? + + public var lastSequenceNumber: UInt16? public var userDatabaseNumber: UInt32? // TODO: default value? // TODO: consent code? // TODO: last transfer time? // TODO: handle extensibility? - public init( // TODO: this is unecessary? - id: UUID, - deviceType: String, - name: String, - model: Model, - icon: ImageReference?, - lastSeen: Date = .now, - batteryPercentage: UInt8? = nil, - lastSequenceNumber: UInt16? = nil, - userDatabaseNumber: UInt32? = nil - ) where Model.RawValue == String { - self.init( - id: id, - deviceType: deviceType, - name: name, - model: model.rawValue, - icon: icon, - lastSeen: lastSeen, - batteryPercentage: batteryPercentage, - lastSequenceNumber: lastSequenceNumber, - userDatabaseNumber: userDatabaseNumber - ) - } - + /// Create new paired device information. + /// - Parameters: + /// - id: The CoreBluetooth device identifier + /// - deviceType: The device type. + /// - name: The device name. + /// - model: A model string. + /// - icon: The device icon. + /// - lastSeen: The date the device was last seen. + /// - batteryPercentage: The last known battery percentage of the device. + /// - lastSequenceNumber: // TODO: docs + /// - userDatabaseNumber: // TODO: docs public init( id: UUID, deviceType: String, @@ -77,7 +76,6 @@ extension PairedDeviceInfo: Identifiable, Codable {} extension PairedDeviceInfo: Hashable { - // TODO: EQ implementation? public func hash(into hasher: inout Hasher) { hasher.combine(id) } @@ -109,8 +107,8 @@ extension PairedDeviceInfo { } */ - @_spi(TestingSupport) - public static var mockHealthDevice1: PairedDeviceInfo { + /// Mock Health Device 1 Data. + @_spi(TestingSupport) public static var mockHealthDevice1: PairedDeviceInfo { PairedDeviceInfo( id: UUID(), deviceType: "HealthDevice1", @@ -120,8 +118,8 @@ extension PairedDeviceInfo { ) } - @_spi(TestingSupport) - public static var mockHealthDevice2: PairedDeviceInfo { + /// Mock Health Device 2 Data. + @_spi(TestingSupport) public static var mockHealthDevice2: PairedDeviceInfo { PairedDeviceInfo( id: UUID(), deviceType: "HealthDevice2", diff --git a/Sources/SpeziDevices/Model/PairingContinuation.swift b/Sources/SpeziDevices/Model/PairingContinuation.swift new file mode 100644 index 0000000..b5ca2cb --- /dev/null +++ b/Sources/SpeziDevices/Model/PairingContinuation.swift @@ -0,0 +1,82 @@ +// +// 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 Foundation +import SpeziFoundation + + +/// Stores pairing state information. +public final class PairingContinuation { + private let lock = NSLock() + + private var isInSession = false + private var pairingContinuation: CheckedContinuation? + + public init() {} + + func pairingSession(_ 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) { + if lock.try() { + lock.unlock() + preconditionFailure("Tried to assign continuation outside of calling pairingSession(_:)") + } + self.pairingContinuation = continuation + } + + private func resumePairingContinuation(with result: Result) { + lock.withLock { + if let pairingContinuation { + pairingContinuation.resume(with: result) + self.pairingContinuation = nil + } + } + } + + func signalTimeout() { + resumePairingContinuation(with: .failure(TimeoutError())) + } + + func signalCancellation() { + resumePairingContinuation(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(())) + } + + /// 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)) + } +} + + +extension PairingContinuation: @unchecked Sendable {} diff --git a/Sources/SpeziDevices/PairableDevice.swift b/Sources/SpeziDevices/PairableDevice.swift deleted file mode 100644 index a415a55..0000000 --- a/Sources/SpeziDevices/PairableDevice.swift +++ /dev/null @@ -1,106 +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 SpeziBluetooth -import SpeziFoundation - -// TODO: docs all the way! - -public protocol PairableDevice: GenericDevice { - /// Persistent identifier for the device type. - /// - /// This is used to associate pairing information with the implementing device. By default, the type name is used. - static var deviceTypeIdentifier: String { get } - - /// Storage for pairing continuation. - @MainActor var _pairingContinuation: CheckedContinuation? { get set } // swiftlint:disable:this identifier_name - // TODO: do not synchronize via MainActor?? - // TODO: use SPI instead of underscore when moving to SpeziDevices? => avoid swiftlint warning for implementors - - var connect: BluetoothConnectAction { get } - var disconnect: BluetoothDisconnectAction { get } - - var isInPairingMode: Bool { get } - - /// Pair Omron Health Device. - /// - /// This method pairs a currently advertising Omron Health Device. - /// - Note: Make sure that the device is in pairing mode (holding down the Bluetooth button for 3 seconds) and disconnected. - /// - /// This method is implemented by default. In order to support the default implementation, you MUST call `handleDeviceInteraction()` - /// on notifications or indications received from the device. This indicates that pairing was successful. - /// Further, your implementation MUST call `handleDeviceDisconnected()` if the device disconnects to handle pairing issues. - @MainActor // TODO: actor isolation? - func pair() async throws -} - - -extension PairableDevice { - public static var deviceTypeIdentifier: String { - "\(Self.self)" - } -} - - -extension PairableDevice { - @MainActor - public func pair() async throws { - guard _pairingContinuation == nil else { - throw DevicePairingError.busy - } - - guard isInPairingMode else { - throw DevicePairingError.notInPairingMode - } - - guard case .disconnected = state else { - throw DevicePairingError.invalidState - } - - guard !discarded else { - throw DevicePairingError.invalidState - } - - await connect() - - async let _ = withTimeout(of: .seconds(15)) { @MainActor in - resumePairingContinuation(with: .failure(TimeoutError())) - } - - try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - self._pairingContinuation = continuation - } - } onCancel: { - Task { @MainActor in - resumePairingContinuation(with: .failure(CancellationError())) - await disconnect() - } - } - // TODO: Self.logger.debug("Device \(self.label) with id \(self.id) is now paired") // TODO: Move logger! - } - - @MainActor - public func handleDeviceInteraction() { - // any kind of messages received from the the device is interpreted as successful pairing. - resumePairingContinuation(with: .success(())) - } - - @MainActor - public func handleDeviceDisconnected() { - resumePairingContinuation(with: .failure(DevicePairingError.deviceDisconnected)) - } - - @MainActor - private func resumePairingContinuation(with result: Result) { - if let pairingContinuation = _pairingContinuation { - pairingContinuation.resume(with: result) - self._pairingContinuation = nil - } - } -} diff --git a/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md b/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md new file mode 100644 index 0000000..4d80a8b --- /dev/null +++ b/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md @@ -0,0 +1,33 @@ +# ``SpeziDevices`` + +Summary + + + +## Overview + +Text + +## Topics + +### Devices + +- ``GenericDevice`` +- ``BatteryPoweredDevice`` +- ``PairableDevice`` +- ``HealthDevice`` + +### Device Pairing + +- ``DeviceManager`` +- ``PairedDeviceInfo`` +- ``DevicePairingError`` +- ``ImageReference`` diff --git a/Sources/SpeziDevicesUI/BatteryIcon.swift b/Sources/SpeziDevicesUI/Devices/BatteryIcon.swift similarity index 86% rename from Sources/SpeziDevicesUI/BatteryIcon.swift rename to Sources/SpeziDevicesUI/Devices/BatteryIcon.swift index a3680df..66b1e3b 100644 --- a/Sources/SpeziDevicesUI/BatteryIcon.swift +++ b/Sources/SpeziDevicesUI/Devices/BatteryIcon.swift @@ -8,8 +8,8 @@ import SwiftUI -// TODO: Docs! (bundle) +/// Battery Icon with optional label. public struct BatteryIcon: View { private let percentage: Int private let isCharging: Bool @@ -57,11 +57,17 @@ public struct BatteryIcon: View { } + /// Create a new battery icon with charging indication. + /// - Parameters: + /// - percentage: The current battery percentage. + /// - isCharging: Indicate if the device is currently charging. public init(percentage: Int, isCharging: Bool) { self.percentage = percentage self.isCharging = isCharging } + /// Create a new battery icon. + /// - Parameter percentage: The current battery percentage. public init(percentage: Int) { // isCharging=false is the same behavior as having no charging information self.init(percentage: percentage, isCharging: false) diff --git a/Sources/SpeziDevicesUI/DeviceDetailsView.swift b/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift similarity index 96% rename from Sources/SpeziDevicesUI/DeviceDetailsView.swift rename to Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift index e4c2916..13c87d8 100644 --- a/Sources/SpeziDevicesUI/DeviceDetailsView.swift +++ b/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift @@ -11,6 +11,7 @@ import SpeziViews import SwiftUI +/// Show the device details of a paired device. public struct DeviceDetailsView: View { @Environment(\.dismiss) private var dismiss @Environment(DeviceManager.self) private var deviceManager @@ -106,6 +107,8 @@ public struct DeviceDetailsView: View { } + /// Create a new device details view. + /// - Parameter deviceInfo: The device info of the paired device. public init(_ deviceInfo: Binding) { self._deviceInfo = deviceInfo } diff --git a/Sources/SpeziDevicesUI/DeviceTile.swift b/Sources/SpeziDevicesUI/Devices/DeviceTile.swift similarity index 93% rename from Sources/SpeziDevicesUI/DeviceTile.swift rename to Sources/SpeziDevicesUI/Devices/DeviceTile.swift index 8371800..85a51d0 100644 --- a/Sources/SpeziDevicesUI/DeviceTile.swift +++ b/Sources/SpeziDevicesUI/Devices/DeviceTile.swift @@ -13,7 +13,8 @@ import SpeziDevices import SwiftUI -struct DeviceTile: View { +/// A tile showing a paired device. +public struct DeviceTile: View { private let deviceInfo: PairedDeviceInfo @Environment(DeviceManager.self) private var deviceManager @@ -63,7 +64,9 @@ struct DeviceTile: View { .accessibilityElement(children: .combine) // TODO: review accessibility! } - init(_ deviceInfo: PairedDeviceInfo) { + /// Create a new device tile view. + /// - Parameter deviceInfo: The paired device information. + public init(_ deviceInfo: PairedDeviceInfo) { self.deviceInfo = deviceInfo } } diff --git a/Sources/SpeziDevicesUI/DevicesGrid.swift b/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift similarity index 91% rename from Sources/SpeziDevicesUI/DevicesGrid.swift rename to Sources/SpeziDevicesUI/Devices/DevicesGrid.swift index ffd588d..ef4cc62 100644 --- a/Sources/SpeziDevicesUI/DevicesGrid.swift +++ b/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift @@ -14,6 +14,7 @@ import SwiftUI import TipKit +/// Grid view of paired devices. public struct DevicesGrid: View { @Binding private var devices: [PairedDeviceInfo] @Binding private var navigationPath: NavigationPath @@ -73,7 +74,13 @@ public struct DevicesGrid: View { } + /// Create a new devices grid. + /// - Parameters: + /// - devices: The list of paired devices to display. + /// - navigation: Binding for the navigation path. + /// - presentingDevicePairing: Binding to indicate if the device discovery menu should be presented. public init(devices: Binding<[PairedDeviceInfo]>, navigation: Binding, presentingDevicePairing: Binding) { + // TODO: This Interface is probably not great for public interface self._devices = devices self._navigationPath = navigation self._presentingDevicePairing = presentingDevicePairing diff --git a/Sources/SpeziDevicesUI/PairingSheet.swift b/Sources/SpeziDevicesUI/Devices/DevicesTab.swift similarity index 67% rename from Sources/SpeziDevicesUI/PairingSheet.swift rename to Sources/SpeziDevicesUI/Devices/DevicesTab.swift index 8399a19..d4db91d 100644 --- a/Sources/SpeziDevicesUI/PairingSheet.swift +++ b/Sources/SpeziDevicesUI/Devices/DevicesTab.swift @@ -11,25 +11,28 @@ import SpeziDevices import SwiftUI -public struct PairingSheet: View { +/// Devices tab showing grid of paired devices and functionality to pair new devices. +public struct DevicesTab: View { + private let appName: String + @Environment(Bluetooth.self) private var bluetooth @Environment(DeviceManager.self) private var deviceManager - @State private var path = NavigationPath() + @State private var path = NavigationPath() // TODO: can we remove that? if so, might want to remove the NavigationStack from view! public var body: some View { @Bindable var deviceManager = deviceManager - NavigationStack(path: $path) { + NavigationStack(path: $path) { // TODO: not really reusable because of the navigation stack!!! DevicesGrid(devices: $deviceManager.pairedDevices, navigation: $path, presentingDevicePairing: $deviceManager.presentingDevicePairing) .scanNearbyDevices(enabled: deviceManager.scanningNearbyDevices, with: bluetooth) // automatically search if no devices are paired .sheet(isPresented: $deviceManager.presentingDevicePairing) { - AccessorySetupSheet(deviceManager.discoveredDevices.values) + AccessorySetupSheet(deviceManager.discoveredDevices.values, appName: appName) } .toolbar { // indicate that we are scanning in the background if deviceManager.scanningNearbyDevices && !deviceManager.presentingDevicePairing { - ToolbarItem(placement: .cancellationAction) { + ToolbarItem(placement: .cancellationAction) { // TODO: shall we do primary action (what about order then?) ProgressView() } } @@ -37,7 +40,11 @@ public struct PairingSheet: View { } } - public init() {} + /// Create a new devices tab + /// - Parameter appName: The name of the application to show in the pairing UI. + public init(appName: String) { + self.appName = appName + } } diff --git a/Sources/SpeziDevicesUI/DevicesUnavailableView.swift b/Sources/SpeziDevicesUI/Devices/DevicesUnavailableView.swift similarity index 100% rename from Sources/SpeziDevicesUI/DevicesUnavailableView.swift rename to Sources/SpeziDevicesUI/Devices/DevicesUnavailableView.swift diff --git a/Sources/SpeziDevicesUI/NameEditView.swift b/Sources/SpeziDevicesUI/Devices/NameEditView.swift similarity index 100% rename from Sources/SpeziDevicesUI/NameEditView.swift rename to Sources/SpeziDevicesUI/Devices/NameEditView.swift diff --git a/Sources/SpeziDevicesUI/AccessoryImageView.swift b/Sources/SpeziDevicesUI/Pairing/AccessoryImageView.swift similarity index 100% rename from Sources/SpeziDevicesUI/AccessoryImageView.swift rename to Sources/SpeziDevicesUI/Pairing/AccessoryImageView.swift diff --git a/Sources/SpeziDevicesUI/AccessorySetupSheet.swift b/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift similarity index 65% rename from Sources/SpeziDevicesUI/AccessorySetupSheet.swift rename to Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift index ab87da1..399a472 100644 --- a/Sources/SpeziDevicesUI/AccessorySetupSheet.swift +++ b/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift @@ -6,14 +6,18 @@ // SPDX-License-Identifier: MIT // +import SpeziBluetooth import SpeziDevices import SpeziViews import SwiftUI +/// Accessory Setup view displayed in a sheet. public struct AccessorySetupSheet: View where Collection.Element == any PairableDevice { private let devices: Collection + private let appName: String + @Environment(Bluetooth.self) private var bluetooth @Environment(DeviceManager.self) private var deviceManager @Environment(\.dismiss) private var dismiss @@ -26,9 +30,9 @@ public struct AccessorySetupSheet: View wher if case let .error(error) = pairingState { PairingFailureView(error) } else if case let .paired(device) = pairingState { - PairedDeviceView(device) + PairedDeviceView(device, appName: appName) } else if !devices.isEmpty { - PairDeviceView(devices: devices, state: $pairingState) { device in + PairDeviceView(devices: devices, appName: appName, state: $pairingState) { device in try await device.pair() await deviceManager.registerPairedDevice(device) } @@ -40,13 +44,19 @@ public struct AccessorySetupSheet: View wher DismissButton() } } - .presentationDetents([.medium]) - .presentationCornerRadius(25) - .interactiveDismissDisabled() + .scanNearbyDevices(with: bluetooth) + .presentationDetents([.medium]) + .presentationCornerRadius(25) + .interactiveDismissDisabled() } - public init(_ devices: Collection) { + /// Create a new Accessory Setup sheet. + /// - Parameters: + /// - devices: The collection of nearby devices which are available for pairing. + /// - appName: The name of the application to show in the pairing UI. + public init(_ devices: Collection, appName: String) { self.devices = devices + self.appName = appName } } @@ -55,7 +65,7 @@ public struct AccessorySetupSheet: View wher #Preview { Text(verbatim: "") .sheet(isPresented: .constant(true)) { - AccessorySetupSheet([MockDevice.createMockDevice()]) + AccessorySetupSheet([MockDevice.createMockDevice()], appName: "Example") } .previewWith { DeviceManager() @@ -70,7 +80,7 @@ public struct AccessorySetupSheet: View wher MockDevice.createMockDevice(name: "Device 1"), MockDevice.createMockDevice(name: "Device 2") ] - AccessorySetupSheet(devices) + AccessorySetupSheet(devices, appName: "Example") } .previewWith { DeviceManager() @@ -80,9 +90,10 @@ public struct AccessorySetupSheet: View wher #Preview { Text(verbatim: "") .sheet(isPresented: .constant(true)) { - AccessorySetupSheet([]) + AccessorySetupSheet([], appName: "Example") } .previewWith { + Bluetooth() DeviceManager() } } diff --git a/Sources/SpeziDevicesUI/DiscoveryView.swift b/Sources/SpeziDevicesUI/Pairing/DiscoveryView.swift similarity index 100% rename from Sources/SpeziDevicesUI/DiscoveryView.swift rename to Sources/SpeziDevicesUI/Pairing/DiscoveryView.swift diff --git a/Sources/SpeziDevicesUI/PairingState.swift b/Sources/SpeziDevicesUI/Pairing/Model/PairingViewState.swift similarity index 58% rename from Sources/SpeziDevicesUI/PairingState.swift rename to Sources/SpeziDevicesUI/Pairing/Model/PairingViewState.swift index 77413c7..15b2da0 100644 --- a/Sources/SpeziDevicesUI/PairingState.swift +++ b/Sources/SpeziDevicesUI/Pairing/Model/PairingViewState.swift @@ -10,10 +10,14 @@ import Foundation import SpeziDevices -enum PairingState { +/// Pairing view state. +enum PairingViewState { + /// View is currently in discovery. case discovery + /// Pairing is currently in progress. case pairing + /// Device is paired and shown to the user for acknowledgment. case paired(any PairableDevice) + /// Pairing error occurred and is displayed to the user. case error(LocalizedError) } - diff --git a/Sources/SpeziDevicesUI/PairDeviceView.swift b/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift similarity index 87% rename from Sources/SpeziDevicesUI/PairDeviceView.swift rename to Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift index 9d06477..acbc1f6 100644 --- a/Sources/SpeziDevicesUI/PairDeviceView.swift +++ b/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift @@ -14,6 +14,7 @@ import SwiftUI struct PairDeviceView: View where Collection.Element == any PairableDevice { private let devices: Collection + private let appName: String private let pairClosure: (any PairableDevice) async throws -> Void @Environment(\.dismiss) private var dismiss @@ -37,7 +38,7 @@ struct PairDeviceView: View where Collection var body: some View { // TODO: replace application Name everywhere! - PaneContent(title: "Pair Accessory", subtitle: "Do you want to pair \(selectedDeviceName) with the ENGAGE app?") { + PaneContent(title: "Pair Accessory", subtitle: "Do you want to pair \(selectedDeviceName) with the \(appName) app?") { if devices.count > 1 { ACarousel(devices, id: \.id, index: $selectedDeviceIndex, spacing: 0, headspace: 0) { device in AccessoryImageView(device) @@ -76,8 +77,9 @@ struct PairDeviceView: View where Collection } - init(devices: Collection, state: Binding, pair: @escaping (any PairableDevice) async throws -> Void) { + init(devices: Collection, appName: String, state: Binding, pair: @escaping (any PairableDevice) async throws -> Void) { self.devices = devices + self.appName = appName self._pairingState = state self.pairClosure = pair } @@ -87,7 +89,7 @@ struct PairDeviceView: View where Collection #if DEBUG #Preview { SheetPreview { - PairDeviceView(devices: [MockDevice.createMockDevice()], state: .constant(.discovery)) { _ in + PairDeviceView(devices: [MockDevice.createMockDevice()], appName: "Example", state: .constant(.discovery)) { _ in } } } @@ -98,7 +100,7 @@ struct PairDeviceView: View where Collection MockDevice.createMockDevice(name: "Device 1"), MockDevice.createMockDevice(name: "Device 2") ] - PairDeviceView(devices: device, state: .constant(.discovery)) { _ in + PairDeviceView(devices: device, appName: "Example", state: .constant(.discovery)) { _ in } } } diff --git a/Sources/SpeziDevicesUI/PairedDeviceView.swift b/Sources/SpeziDevicesUI/Pairing/PairedDeviceView.swift similarity index 76% rename from Sources/SpeziDevicesUI/PairedDeviceView.swift rename to Sources/SpeziDevicesUI/Pairing/PairedDeviceView.swift index a63d4e9..c4ddb71 100644 --- a/Sources/SpeziDevicesUI/PairedDeviceView.swift +++ b/Sources/SpeziDevicesUI/Pairing/PairedDeviceView.swift @@ -12,11 +12,12 @@ import SwiftUI struct PairedDeviceView: View { private let device: any PairableDevice + private let appName: String @Environment(\.dismiss) private var dismiss var body: some View { - PaneContent(title: "Accessory Paired", subtitle: "\"\(device.label)\" was successfully paired with the ENGAGE app.") { + PaneContent(title: "Accessory Paired", subtitle: "\"\(device.label)\" was successfully paired with the \(appName) app.") { AccessoryImageView(device) } action: { Button { @@ -31,8 +32,9 @@ struct PairedDeviceView: View { } - init(_ device: any PairableDevice) { + init(_ device: any PairableDevice, appName: String) { self.device = device + self.appName = appName } } @@ -40,7 +42,7 @@ struct PairedDeviceView: View { #if DEBUG #Preview { SheetPreview { - PairedDeviceView(MockDevice.createMockDevice()) + PairedDeviceView(MockDevice.createMockDevice(), appName: "Example") } } #endif diff --git a/Sources/SpeziDevicesUI/PairingFailureView.swift b/Sources/SpeziDevicesUI/Pairing/PairingFailureView.swift similarity index 100% rename from Sources/SpeziDevicesUI/PairingFailureView.swift rename to Sources/SpeziDevicesUI/Pairing/PairingFailureView.swift diff --git a/Sources/SpeziDevicesUI/SpeziDevicesUI.docc/SpeziDevicesUI.md b/Sources/SpeziDevicesUI/SpeziDevicesUI.docc/SpeziDevicesUI.md new file mode 100644 index 0000000..c42235a --- /dev/null +++ b/Sources/SpeziDevicesUI/SpeziDevicesUI.docc/SpeziDevicesUI.md @@ -0,0 +1,23 @@ +# ``SpeziDevicesUI`` + +Summary + + + +## Overview + +Text + +## Topics + +### Group + +- ``Symbol`` diff --git a/Sources/SpeziDevicesUI/Testing/MockDevice.swift b/Sources/SpeziDevicesUI/Testing/MockDevice.swift index 086bc17..5bafa07 100644 --- a/Sources/SpeziDevicesUI/Testing/MockDevice.swift +++ b/Sources/SpeziDevicesUI/Testing/MockDevice.swift @@ -13,31 +13,20 @@ import SpeziDevices #if DEBUG -final class MockDevice: PairableDevice, Identifiable { - @DeviceState(\.id) - var id - @DeviceState(\.name) - var name - @DeviceState(\.state) - var state - @DeviceState(\.advertisementData) - var advertisementData - @DeviceState(\.discarded) - var discarded - - @DeviceAction(\.connect) - var connect - @DeviceAction(\.disconnect) - var disconnect - - - @Service - var deviceInformation = DeviceInformationService() - - // TODO: swiftlint thingy! - // swiftlint:disable:next identifier_name - @MainActor var _pairingContinuation: CheckedContinuation? +final class MockDevice: PairableDevice, Identifiable { + @DeviceState(\.id) var id + @DeviceState(\.name) var name + @DeviceState(\.state) var state + @DeviceState(\.advertisementData) var advertisementData + @DeviceState(\.discarded) var discarded + @DeviceAction(\.connect) var connect + @DeviceAction(\.disconnect) var disconnect + + + @Service var deviceInformation = DeviceInformationService() + + let pairing = PairingContinuation() var isInPairingMode = false // TODO: control // TODO: mandatory setup? diff --git a/Sources/SpeziDevicesUI/CarouselDots.swift b/Sources/SpeziDevicesUI/Utils/CarouselDots.swift similarity index 100% rename from Sources/SpeziDevicesUI/CarouselDots.swift rename to Sources/SpeziDevicesUI/Utils/CarouselDots.swift diff --git a/Sources/SpeziDevicesUI/PaneContent.swift b/Sources/SpeziDevicesUI/Utils/PaneContent.swift similarity index 100% rename from Sources/SpeziDevicesUI/PaneContent.swift rename to Sources/SpeziDevicesUI/Utils/PaneContent.swift diff --git a/Sources/SpeziOmron/OmronHealthDevice.swift b/Sources/SpeziOmron/OmronHealthDevice.swift index d3c6649..4b83734 100644 --- a/Sources/SpeziOmron/OmronHealthDevice.swift +++ b/Sources/SpeziOmron/OmronHealthDevice.swift @@ -10,14 +10,20 @@ import SpeziBluetooth import SpeziDevices +/// An Omron Health Device. +/// +/// An Omron Health Device is a `HealthDevice` that is pairable. +/// Further, it might adopt the `BatteryPoweredDevice` protocol if the Omron device supports the battery service. public protocol OmronHealthDevice: HealthDevice, PairableDevice {} extension OmronHealthDevice { + /// The Omron model string. public var model: OmronModel { OmronModel(deviceInformation.modelNumber ?? "Generic Health Device") } + /// The Omron Manufacturer data observed in the Bluetooth advertisement. public var manufacturerData: OmronManufacturerData? { guard let manufacturerData = advertisementData.manufacturerData else { return nil @@ -28,6 +34,9 @@ extension OmronHealthDevice { extension OmronHealthDevice { + /// Default implementation determining if device is in pairing mode. + /// + /// Pairing mode is advertised by the device through the ``manufacturerData`` in the Bluetooth advertisement. public var isInPairingMode: Bool { if case .pairingMode = manufacturerData?.pairingMode { return true diff --git a/Sources/SpeziOmron/OmronManufacturerData.swift b/Sources/SpeziOmron/OmronManufacturerData.swift index 369c4e1..2ec02e9 100644 --- a/Sources/SpeziOmron/OmronManufacturerData.swift +++ b/Sources/SpeziOmron/OmronManufacturerData.swift @@ -11,23 +11,47 @@ import NIOCore import SpeziBluetooth +/// Omron Manufacturer Data format. public struct OmronManufacturerData { + /// The device's pairing mode. public enum PairingMode { + /// The device is advertising to transfer data. case transferMode + /// The device is advertising to get paired. case pairingMode } + /// The streaming mode. public enum StreamingMode { + /// Data Communication. case dataCommunication + /// Streaming. case streaming } + /// The services mode. + public enum ServiceMode { + /// Uses Bluetooth standard services and characteristics. + case bluetoothStandard + /// Uses services and characteristics of the Omron Extension. + case omronExtension + } + + /// Metadata of a user slot. public struct UserSlot { + /// The user slot number. public let id: UInt8 + /// The current record sequence number. public let sequenceNumber: UInt16 + /// The amount of records currently stored on the device. public let recordsNumber: UInt8 + /// Create a new user slot. + /// - Parameters: + /// - id: The user slot number. + /// - sequenceNumber: The current record sequence number. + /// - recordsNumber: The amount of records currently stored on the device. public init(id: UInt8, sequenceNumber: UInt16, recordsNumber: UInt8) { self.id = id self.sequenceNumber = sequenceNumber @@ -35,11 +59,6 @@ public struct OmronManufacturerData { } } - public enum Mode { - case bluetoothStandard - case omronExtension - } - fileprivate struct Flags: OptionSet { static let timeNotSet = Flags(rawValue: 1 << 2) static let pairingMode = Flags(rawValue: 1 << 3) @@ -62,19 +81,33 @@ public struct OmronManufacturerData { } } + /// Indicate if the time was set on the device. public let timeSet: Bool + /// Determine the pairing mode the device is currently in. public let pairingMode: PairingMode + /// The type of data transmission mode. public let streamingMode: StreamingMode - public let mode: Mode - - public let users: [UserSlot] // max 4 slots - - + /// The type of services the peripheral is exposing. + public let servicesMode: ServiceMode + + /// The advertised user slots. + /// + /// - Important: Exposes at least one, and a maximum of four slots. + public let users: [UserSlot] + + + /// Create new Omron Manufacture Data + /// - Parameters: + /// - timeSet: Indicate if the time was set. + /// - pairingMode: The pairing mode. + /// - streamingMode: The streaming mode. + /// - servicesMode: The services mode. + /// - users: The list of users. At least one, maximum four. public init( // swiftlint:disable:this function_default_parameter_at_end timeSet: Bool = true, pairingMode: PairingMode, streamingMode: StreamingMode = .dataCommunication, - mode: Mode = .bluetoothStandard, + servicesMode: Mode = .bluetoothStandard, users: [UserSlot] ) { // swiftlint:disable:next empty_count @@ -82,7 +115,7 @@ public struct OmronManufacturerData { self.timeSet = timeSet self.pairingMode = pairingMode self.streamingMode = streamingMode - self.mode = mode + self.mode = servicesMode self.users = users } } @@ -127,7 +160,7 @@ extension OmronManufacturerData: ByteCodable { self.timeSet = !flags.contains(.timeNotSet) self.pairingMode = flags.contains(.pairingMode) ? .pairingMode : .transferMode self.streamingMode = flags.contains(.streamingMode) ? .streaming : .dataCommunication - self.mode = flags.contains(.wlpStp) ? .bluetoothStandard : .omronExtension + self.servicesMode = flags.contains(.wlpStp) ? .bluetoothStandard : .omronExtension var userSlots: [UserSlot] = [] for userNumber in 1...flags.numberOfUsers { @@ -160,7 +193,7 @@ extension OmronManufacturerData: ByteCodable { flags.insert(.streamingMode) } - if case .bluetoothStandard = mode { + if case .bluetoothStandard = servicesMode { flags.insert(.wlpStp) } diff --git a/Sources/SpeziOmron/OmronOptionService.swift b/Sources/SpeziOmron/OmronOptionService.swift index 3e91258..9278aac 100644 --- a/Sources/SpeziOmron/OmronOptionService.swift +++ b/Sources/SpeziOmron/OmronOptionService.swift @@ -18,8 +18,7 @@ public final class OmronOptionService: BluetoothService, @unchecked Sendable { public static let id = CBUUID(string: "5DF5E817-A945-4F81-89C0-3D4E9759C07C") - @Characteristic(id: "2A52", notify: true) - private var recordAccessControlPoint: RecordAccessControlPoint? + @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. diff --git a/Sources/SpeziOmron/SpeziOmron.docc/SpeziOmron.md b/Sources/SpeziOmron/SpeziOmron.docc/SpeziOmron.md index 5ed1d19..132b099 100644 --- a/Sources/SpeziOmron/SpeziOmron.docc/SpeziOmron.md +++ b/Sources/SpeziOmron/SpeziOmron.docc/SpeziOmron.md @@ -1,5 +1,7 @@ # ``SpeziOmron`` +Summary + Group +### Presenting nearby devices -- ``Symbol`` +Views that are helpful when building a nearby devices view. + +- ``BluetoothUnavailableView`` +- ``NearbyDeviceRow`` +- ``LoadingSectionHeader`` diff --git a/Sources/SpeziDevicesUI/Testing/MockBluetoothPeripheral.swift b/Sources/SpeziDevicesUI/Testing/MockBluetoothPeripheral.swift new file mode 100644 index 0000000..3531dca --- /dev/null +++ b/Sources/SpeziDevicesUI/Testing/MockBluetoothPeripheral.swift @@ -0,0 +1,24 @@ +// +// 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 SpeziBluetooth +import SpeziDevices + + +/// Mock peripheral used for internal previews. +struct MockBluetoothPeripheral: GenericBluetoothPeripheral { + var label: String + var state: PeripheralState + var requiresUserAttention: Bool + + init(label: String, state: PeripheralState, requiresUserAttention: Bool = false) { + self.label = label + self.state = state + self.requiresUserAttention = requiresUserAttention + } +} diff --git a/Sources/SpeziDevicesUI/Testing/MockDevice.swift b/Sources/SpeziDevicesUI/Testing/MockDevice.swift index 5bafa07..0b37f05 100644 --- a/Sources/SpeziDevicesUI/Testing/MockDevice.swift +++ b/Sources/SpeziDevicesUI/Testing/MockDevice.swift @@ -6,14 +6,14 @@ // SPDX-License-Identifier: MIT // -import BluetoothServices import Foundation @_spi(TestingSupport) import SpeziBluetooth +import SpeziBluetoothServices import SpeziDevices #if DEBUG -final class MockDevice: PairableDevice, Identifiable { +final class MockDevice: PairableDevice, Identifiable { @DeviceState(\.id) var id @DeviceState(\.name) var name @DeviceState(\.state) var state diff --git a/Sources/SpeziDevicesUI/Tips/ConfigureTipKit.swift b/Sources/SpeziDevicesUI/Tips/ConfigureTipKit.swift new file mode 100644 index 0000000..6b19eb6 --- /dev/null +++ b/Sources/SpeziDevicesUI/Tips/ConfigureTipKit.swift @@ -0,0 +1,36 @@ +// +// 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 Spezi +@_spi(TestingSupport) import SpeziFoundation +import TipKit + + +class ConfigureTipKit: Module, DefaultInitializable { // TODO: move to SpeziViews! + @Application(\.logger) private var logger + + + required init() {} + + func configure() { + if RuntimeConfig.testingTips || ProcessInfo.processInfo.isPreviewSimulator { + Tips.showAllTipsForTesting() + } + do { + try Tips.configure() + } catch { + Self.logger.error("Failed to configure TipKit: \(error)") + } + } +} + + +extension RuntimeConfig { + /// Enable testing tips + static let testingTips = CommandLine.arguments.contains("--testTips") +} diff --git a/Sources/SpeziDevicesUI/Utils/PaneContent.swift b/Sources/SpeziDevicesUI/Utils/PaneContent.swift index afe5236..4b9c3f1 100644 --- a/Sources/SpeziDevicesUI/Utils/PaneContent.swift +++ b/Sources/SpeziDevicesUI/Utils/PaneContent.swift @@ -93,7 +93,7 @@ struct PaneContent: View { #if DEBUG #Preview { SheetPreview { - PaneContent(title: "The Title", subtitle: "The Subtitle") { + PaneContent(title: Text(verbatim: "The Title"), subtitle: Text(verbatim: "The Subtitle")) { Image(systemName: "person.crop.square.badge.camera.fill") .symbolRenderingMode(.hierarchical) .resizable() @@ -104,7 +104,7 @@ struct PaneContent: View { } action: { Button { } label: { - Text("Button") + Text(verbatim: "Button") .frame(maxWidth: .infinity, maxHeight: 35) } .buttonStyle(.borderedProminent) diff --git a/Sources/SpeziOmron/Characteristic/CharacteristicAccessor+OmronRecordAccess.swift b/Sources/SpeziOmron/Characteristic/CharacteristicAccessor+OmronRecordAccess.swift index 1cfcc7e..9a31350 100644 --- a/Sources/SpeziOmron/Characteristic/CharacteristicAccessor+OmronRecordAccess.swift +++ b/Sources/SpeziOmron/Characteristic/CharacteristicAccessor+OmronRecordAccess.swift @@ -6,8 +6,9 @@ // SPDX-License-Identifier: MIT // -@_spi(APISupport) import BluetoothServices // swiftlint:disable:this attributes import SpeziBluetooth +@_spi(APISupport) +import SpeziBluetoothServices extension CharacteristicAccessor where Value == RecordAccessControlPoint { diff --git a/Sources/SpeziOmron/Characteristic/OmronRecordAccessOperand.swift b/Sources/SpeziOmron/Characteristic/OmronRecordAccessOperand.swift index 2336125..e2db4d9 100644 --- a/Sources/SpeziOmron/Characteristic/OmronRecordAccessOperand.swift +++ b/Sources/SpeziOmron/Characteristic/OmronRecordAccessOperand.swift @@ -6,8 +6,8 @@ // SPDX-License-Identifier: MIT // -import BluetoothServices import NIOCore +import SpeziBluetoothServices /// The Record Access Operand format for the Omron Record Access Control Point characteristic. diff --git a/Sources/SpeziOmron/Characteristic/RecordAccessControlPoint+Omron.swift b/Sources/SpeziOmron/Characteristic/RecordAccessControlPoint+Omron.swift index 81556d9..731d942 100644 --- a/Sources/SpeziOmron/Characteristic/RecordAccessControlPoint+Omron.swift +++ b/Sources/SpeziOmron/Characteristic/RecordAccessControlPoint+Omron.swift @@ -6,7 +6,7 @@ // SPDX-License-Identifier: MIT // -import BluetoothServices +import SpeziBluetoothServices extension RecordAccessControlPoint { diff --git a/Sources/SpeziOmron/Characteristic/RecordAccessOpCode+Omron.swift b/Sources/SpeziOmron/Characteristic/RecordAccessOpCode+Omron.swift index 2aa53d1..830f4b1 100644 --- a/Sources/SpeziOmron/Characteristic/RecordAccessOpCode+Omron.swift +++ b/Sources/SpeziOmron/Characteristic/RecordAccessOpCode+Omron.swift @@ -6,7 +6,7 @@ // SPDX-License-Identifier: MIT // -import BluetoothServices +import SpeziBluetoothServices extension RecordAccessOpCode { diff --git a/Sources/SpeziOmron/OmronOptionService.swift b/Sources/SpeziOmron/OmronOptionService.swift index 9278aac..d4d45e0 100644 --- a/Sources/SpeziOmron/OmronOptionService.swift +++ b/Sources/SpeziOmron/OmronOptionService.swift @@ -6,9 +6,9 @@ // SPDX-License-Identifier: MIT // -import BluetoothServices import class CoreBluetooth.CBUUID import SpeziBluetooth +import SpeziBluetoothServices /// The Omron Option Service. From 4aa2d0a6661b0b6ac53b2bf1864abf2383c60fe0 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 21 Jun 2024 23:28:01 +0200 Subject: [PATCH 11/77] Minor fixes --- Sources/SpeziDevices/DeviceManager.swift | 2 +- Sources/SpeziDevicesUI/Tips/ConfigureTipKit.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SpeziDevices/DeviceManager.swift b/Sources/SpeziDevices/DeviceManager.swift index efabf6d..aeed79a 100644 --- a/Sources/SpeziDevices/DeviceManager.swift +++ b/Sources/SpeziDevices/DeviceManager.swift @@ -46,7 +46,7 @@ public final class DeviceManager: Module, EnvironmentAccessible, DefaultInitiali } @Application(\.logger) @ObservationIgnored private var logger - @Dependency @ObservationIgnored private var tipKit: ConfigureTipKit + @Dependency @ObservationIgnored private var bluetooth: Bluetooth? required public init() {} // TODO: configure automatic search without devices paired! diff --git a/Sources/SpeziDevicesUI/Tips/ConfigureTipKit.swift b/Sources/SpeziDevicesUI/Tips/ConfigureTipKit.swift index 6b19eb6..9c61ea0 100644 --- a/Sources/SpeziDevicesUI/Tips/ConfigureTipKit.swift +++ b/Sources/SpeziDevicesUI/Tips/ConfigureTipKit.swift @@ -24,7 +24,7 @@ class ConfigureTipKit: Module, DefaultInitializable { // TODO: move to SpeziView do { try Tips.configure() } catch { - Self.logger.error("Failed to configure TipKit: \(error)") + logger.error("Failed to configure TipKit: \(error)") } } } From 6ca3147e05214cdf8f6c0e3356b26724d7212943 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Sat, 22 Jun 2024 10:28:28 +0200 Subject: [PATCH 12/77] Fix pairing session check --- Sources/SpeziDevices/Devices/PairableDevice.swift | 2 +- Sources/SpeziDevices/Model/PairingContinuation.swift | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Sources/SpeziDevices/Devices/PairableDevice.swift b/Sources/SpeziDevices/Devices/PairableDevice.swift index ccda7bd..69c984b 100644 --- a/Sources/SpeziDevices/Devices/PairableDevice.swift +++ b/Sources/SpeziDevices/Devices/PairableDevice.swift @@ -97,7 +97,7 @@ extension PairableDevice { } - try await pairing.pairingSession { + try await pairing.withPairingSession { await connect() async let _ = withTimeout(of: .seconds(15)) { diff --git a/Sources/SpeziDevices/Model/PairingContinuation.swift b/Sources/SpeziDevices/Model/PairingContinuation.swift index 7851d5e..cd105b3 100644 --- a/Sources/SpeziDevices/Model/PairingContinuation.swift +++ b/Sources/SpeziDevices/Model/PairingContinuation.swift @@ -20,7 +20,7 @@ public final class PairingContinuation { /// Create a new pairing continuation management object. public init() {} - func pairingSession(_ action: () async throws -> T) async throws -> T { + func withPairingSession(_ action: () async throws -> T) async throws -> T { try lock.withLock { guard !isInSession else { throw DevicePairingError.busy @@ -40,11 +40,12 @@ public final class PairingContinuation { } func assign(continuation: CheckedContinuation) { - if lock.try() { - lock.unlock() - preconditionFailure("Tried to assign continuation outside of calling pairingSession(_:)") + lock.withLock { + guard isInSession else { + preconditionFailure("Tried to assign continuation outside of calling withPairingSession(_:)") + } + self.pairingContinuation = continuation } - self.pairingContinuation = continuation } private func resumePairingContinuation(with result: Result) { From 4ac675731b06102fc1ad61d0197c2227add28c85 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Sat, 22 Jun 2024 11:38:47 +0200 Subject: [PATCH 13/77] Move HealthMeasurements to SpeziDevices --- Sources/SpeziDevices/DeviceManager.swift | 6 +- .../SpeziDevices/Devices/GenericDevice.swift | 2 +- .../BloodPressureMeasurement+HKSample.swift | 95 ++++++++++++ ...BloodPressureMeasurement.Unit+HKUnit.swift | 25 ++++ .../Health/WeightMeasurement+HKSample.swift | 43 ++++++ .../WeightMeasurement.Unit+HKUnit.swift | 35 +++++ .../Measurements/HealthMeasurement.swift | 15 ++ .../Measurements/HealthMeasurements.swift | 121 +++++++++++++++ .../HealthMeasurementsConstraint.swift | 15 ++ .../ProcessedHealthMeasurement.swift | 27 ++++ .../Testing/MockBluetoothPeripheral.swift | 15 +- Sources/SpeziDevices/Testing/MockDevice.swift | 140 ++++++++++++++++++ .../Devices/DeviceDetailsView.swift | 3 + .../SpeziDevicesUI/Devices/DevicesGrid.swift | 4 +- .../SpeziDevicesUI/Devices/NameEditView.swift | 3 + .../BloodPressureMeasurementLabel.swift | 72 +++++++++ .../Measurements/CloseButtonLayer.swift | 45 ++++++ .../ConfirmMeasurementButton.swift | 66 +++++++++ .../Measurements/MeasurementLayer.swift | 55 +++++++ .../MeasurementRecordedView.swift | 89 +++++++++++ .../Measurements/WeightMeasurementLabel.swift | 38 +++++ .../Pairing/AccessoryImageView.swift | 3 + .../Pairing/AccessorySetupSheet.swift | 3 + .../Pairing/PairDeviceView.swift | 3 + .../Pairing/PairedDeviceView.swift | 3 + .../Resources/Localizable.xcstrings | 28 ++++ .../Scanning/NearbyDeviceRow.swift | 3 + .../SpeziDevicesUI/Testing/MockDevice.swift | 67 --------- .../Testing/TestMeasurementStandard.swift | 20 +++ .../SpeziDevicesUI/Tips/ConfigureTipKit.swift | 36 ----- 30 files changed, 963 insertions(+), 117 deletions(-) create mode 100644 Sources/SpeziDevices/Health/BloodPressureMeasurement+HKSample.swift create mode 100644 Sources/SpeziDevices/Health/BloodPressureMeasurement.Unit+HKUnit.swift create mode 100644 Sources/SpeziDevices/Health/WeightMeasurement+HKSample.swift create mode 100644 Sources/SpeziDevices/Health/WeightMeasurement.Unit+HKUnit.swift create mode 100644 Sources/SpeziDevices/Measurements/HealthMeasurement.swift create mode 100644 Sources/SpeziDevices/Measurements/HealthMeasurements.swift create mode 100644 Sources/SpeziDevices/Measurements/HealthMeasurementsConstraint.swift create mode 100644 Sources/SpeziDevices/Measurements/ProcessedHealthMeasurement.swift rename Sources/{SpeziDevicesUI => SpeziDevices}/Testing/MockBluetoothPeripheral.swift (55%) create mode 100644 Sources/SpeziDevices/Testing/MockDevice.swift create mode 100644 Sources/SpeziDevicesUI/Measurements/BloodPressureMeasurementLabel.swift create mode 100644 Sources/SpeziDevicesUI/Measurements/CloseButtonLayer.swift create mode 100644 Sources/SpeziDevicesUI/Measurements/ConfirmMeasurementButton.swift create mode 100644 Sources/SpeziDevicesUI/Measurements/MeasurementLayer.swift create mode 100644 Sources/SpeziDevicesUI/Measurements/MeasurementRecordedView.swift create mode 100644 Sources/SpeziDevicesUI/Measurements/WeightMeasurementLabel.swift delete mode 100644 Sources/SpeziDevicesUI/Testing/MockDevice.swift create mode 100644 Sources/SpeziDevicesUI/Testing/TestMeasurementStandard.swift delete mode 100644 Sources/SpeziDevicesUI/Tips/ConfigureTipKit.swift diff --git a/Sources/SpeziDevices/DeviceManager.swift b/Sources/SpeziDevices/DeviceManager.swift index aeed79a..1312f5c 100644 --- a/Sources/SpeziDevices/DeviceManager.swift +++ b/Sources/SpeziDevices/DeviceManager.swift @@ -12,15 +12,11 @@ import SpeziBluetooth import SpeziBluetoothServices import SwiftUI -// TODO: Start SpeziDevices generalization // TODO: Finish SpeziBluetooth refactoring and cleanup "persistent devices" // TODO: dark mode device images -// TODO: ask for more Omron infos? secret sauce? - -// TODO: move deviceManager to SpeziBluetooth (and measurement manager?) @Observable -public final class DeviceManager: Module, EnvironmentAccessible, DefaultInitializable { +public final class DeviceManager: Module, EnvironmentAccessible, DefaultInitializable { // TODO: "PairedDevices" rename? /// Determines if the device discovery sheet should be presented. @MainActor public var presentingDevicePairing = false // TODO: "should" naming @MainActor public private(set) var discoveredDevices: OrderedDictionary = [:] diff --git a/Sources/SpeziDevices/Devices/GenericDevice.swift b/Sources/SpeziDevices/Devices/GenericDevice.swift index 3ead72d..27ea049 100644 --- a/Sources/SpeziDevices/Devices/GenericDevice.swift +++ b/Sources/SpeziDevices/Devices/GenericDevice.swift @@ -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 { +public protocol GenericDevice: BluetoothDevice, GenericBluetoothPeripheral, Identifiable { /// The device identifier. /// /// Use the [`DeviceState`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/devicestate) property wrapper to diff --git a/Sources/SpeziDevices/Health/BloodPressureMeasurement+HKSample.swift b/Sources/SpeziDevices/Health/BloodPressureMeasurement+HKSample.swift new file mode 100644 index 0000000..d1a4bdf --- /dev/null +++ b/Sources/SpeziDevices/Health/BloodPressureMeasurement+HKSample.swift @@ -0,0 +1,95 @@ +// +// 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 SpeziBluetoothServices + + +extension BloodPressureMeasurement { + public func bloodPressureSample(source device: HKDevice?) -> HKCorrelation? { + guard systolicValue.isFinite, diastolicValue.isFinite else { + return nil + } + let unit: HKUnit = unit.hkUnit + + let systolic = HKQuantity(unit: unit, doubleValue: systolicValue.double) + let diastolic = HKQuantity(unit: unit, doubleValue: diastolicValue.double) + + let systolicType = HKQuantityType(.bloodPressureSystolic) + let diastolicType = HKQuantityType(.bloodPressureDiastolic) + let correlationType = HKCorrelationType(.bloodPressure) + + let date = timeStamp?.date ?? .now + + let systolicSample = HKQuantitySample(type: systolicType, quantity: systolic, start: date, end: date, device: device, metadata: nil) + let diastolicSample = HKQuantitySample(type: diastolicType, quantity: diastolic, start: date, end: date, device: device, metadata: nil) + + + let bloodPressure = HKCorrelation( + type: correlationType, + start: date, + end: date, + objects: [systolicSample, diastolicSample], + device: device, + metadata: nil + ) + + return bloodPressure + } +} + + +extension BloodPressureMeasurement { + public func heartRateSample(source device: HKDevice?) -> HKQuantitySample? { + guard let pulseRate, pulseRate.isFinite else { + return nil + } + + // beats per minute + let bpm: HKUnit = .count().unitDivided(by: .minute()) + let pulseQuantityType = HKQuantityType(.heartRate) + + let pulse = HKQuantity(unit: bpm, doubleValue: pulseRate.double) + let date = timeStamp?.date ?? .now + + return HKQuantitySample( + type: pulseQuantityType, + quantity: pulse, + start: date, + end: date, + device: device, + metadata: nil + ) + } +} + + +#if DEBUG || TEST +extension HKCorrelation { + @_spi(TestingSupport) + public static var mockBloodPressureSample: HKCorrelation { + let measurement = BloodPressureMeasurement(systolic: 117, diastolic: 76, meanArterialPressure: 67, unit: .mmHg, pulseRate: 68) + guard let sample = measurement.bloodPressureSample(source: nil) else { + preconditionFailure("Mock sample was unexpectedly invalid!") + } + return sample + } +} + +extension HKQuantitySample { + @_spi(TestingSupport) + public static var mockHeartRateSample: HKQuantitySample { + let measurement = BloodPressureMeasurement(systolic: 117, diastolic: 76, meanArterialPressure: 67, unit: .mmHg, pulseRate: 68) + guard let sample = measurement.heartRateSample(source: nil) else { + preconditionFailure("Mock sample was unexpectedly invalid!") + } + return sample + } +} +#endif diff --git a/Sources/SpeziDevices/Health/BloodPressureMeasurement.Unit+HKUnit.swift b/Sources/SpeziDevices/Health/BloodPressureMeasurement.Unit+HKUnit.swift new file mode 100644 index 0000000..b62f1c2 --- /dev/null +++ b/Sources/SpeziDevices/Health/BloodPressureMeasurement.Unit+HKUnit.swift @@ -0,0 +1,25 @@ +// +// 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 SpeziBluetoothServices + + +extension BloodPressureMeasurement.Unit { + /// The unit represented as a `HKUnit`. + public var hkUnit: HKUnit { + switch self { + case .mmHg: + return .millimeterOfMercury() + case .kPa: + return .pascalUnit(with: .kilo) + } + } +} diff --git a/Sources/SpeziDevices/Health/WeightMeasurement+HKSample.swift b/Sources/SpeziDevices/Health/WeightMeasurement+HKSample.swift new file mode 100644 index 0000000..d5ef6de --- /dev/null +++ b/Sources/SpeziDevices/Health/WeightMeasurement+HKSample.swift @@ -0,0 +1,43 @@ +// +// 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 SpeziBluetoothServices + + +extension WeightMeasurement { + public func quantitySample(source device: HKDevice?, resolution: WeightScaleFeature.WeightResolution?) -> HKQuantitySample { + let quantityType = HKQuantityType(.bodyMass) + + let value = weight(of: resolution ?? .unspecified) + + let quantity = HKQuantity(unit: unit.massUnit, doubleValue: value) + let date = timeStamp?.date ?? .now + + return HKQuantitySample( + type: quantityType, + quantity: quantity, + start: date, + end: date, + device: device, + metadata: nil + ) + } +} + +#if DEBUG || TEST +extension HKQuantitySample { + @_spi(TestingSupport) + public static var mockWeighSample: HKQuantitySample { + let measurement = WeightMeasurement(weight: 8400, unit: .si) + + return measurement.quantitySample(source: nil, resolution: .resolution5g) + } +} +#endif diff --git a/Sources/SpeziDevices/Health/WeightMeasurement.Unit+HKUnit.swift b/Sources/SpeziDevices/Health/WeightMeasurement.Unit+HKUnit.swift new file mode 100644 index 0000000..439a526 --- /dev/null +++ b/Sources/SpeziDevices/Health/WeightMeasurement.Unit+HKUnit.swift @@ -0,0 +1,35 @@ +// +// 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 SpeziBluetoothServices + + +extension WeightMeasurement.Unit { + /// The mass unit represented as a `HKUnit`. + public var massUnit: HKUnit { + switch self { + case .si: + return .gramUnit(with: .kilo) + case .imperial: + return .pound() + } + } + + + /// The length unit represented as a `HKUnit`. + public var lengthUnit: HKUnit { + switch self { + case .si: + return .meter() + case .imperial: + return .inch() + } + } +} diff --git a/Sources/SpeziDevices/Measurements/HealthMeasurement.swift b/Sources/SpeziDevices/Measurements/HealthMeasurement.swift new file mode 100644 index 0000000..be405b4 --- /dev/null +++ b/Sources/SpeziDevices/Measurements/HealthMeasurement.swift @@ -0,0 +1,15 @@ +// +// 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 + + +public enum HealthMeasurement { + case weight(WeightMeasurement, WeightScaleFeature) + case bloodPressure(BloodPressureMeasurement, BloodPressureFeature) +} diff --git a/Sources/SpeziDevices/Measurements/HealthMeasurements.swift b/Sources/SpeziDevices/Measurements/HealthMeasurements.swift new file mode 100644 index 0000000..e39f33c --- /dev/null +++ b/Sources/SpeziDevices/Measurements/HealthMeasurements.swift @@ -0,0 +1,121 @@ +// +// 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 Foundation +import OSLog +import Spezi +import SpeziBluetooth +#if DEBUG +@_spi(TestingSupport) +#endif +import SpeziDevices + + +/// Manage and process incoming health measurements. +@Observable +public class HealthMeasurements: Module, EnvironmentAccessible, DefaultInitializable { + private let logger = Logger(subsystem: "ENGAGEHF", category: "HealthMeasurements") + + var newMeasurement: ProcessedHealthMeasurement? + + @StandardActor @ObservationIgnored private var standard: any HealthMeasurementsConstraint + @Dependency @ObservationIgnored private var bluetooth: Bluetooth? + + required public init() {} + + // TODO: rename! + public func handleNewMeasurement(_ measurement: HealthMeasurement, from device: Device) { + let hkDevice = device.hkDevice + + switch measurement { + case let .weight(measurement, feature): + let sample = measurement.quantitySample(source: hkDevice, resolution: feature.weightResolution) + logger.debug("Measurement loaded: \(measurement.weight)") + + newMeasurement = .weight(sample) + case let .bloodPressure(measurement, _): + let bloodPressureSample = measurement.bloodPressureSample(source: hkDevice) + let heartRateSample = measurement.heartRateSample(source: hkDevice) + + guard let bloodPressureSample else { + logger.debug("Discarding invalid blood pressure measurement ...") + return + } + + logger.debug("Measurement loaded: \(String(describing: measurement))") + + newMeasurement = .bloodPressure(bloodPressureSample, heartRate: heartRateSample) + } + } + + // TODO: docs everywhere! + + 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 + } + + logger.info("Saving the following measurement: \(String(describing: measurement))") + do { + switch measurement { + case let .weight(sample): + try await standard.addMeasurement(sample: sample) + case let .bloodPressure(bloodPressureSample, heartRateSample): + try await standard.addMeasurement(sample: bloodPressureSample) + if let heartRateSample { + try await standard.addMeasurement(sample: heartRateSample) + } + } + } catch { + logger.error("Failed to save measurement samples: \(error)") + throw error + } + + logger.info("Save successful!") + newMeasurement = nil + } +} + + +#if DEBUG || TEST +extension HealthMeasurements { + /// Call in preview simulator wrappers. + /// + /// Loads a mock measurement to display in preview. + @_spi(TestingSupport) + public func loadMockWeightMeasurement() { + let device = MockDevice.createMockDevice() + + guard let measurement = device.weightScale.weightMeasurement else { + preconditionFailure("Mock Weight Measurement was never injected!") + } + + handleNewMeasurement(.weight(measurement, device.weightScale.features ?? []), from: device) + } + + /// Call in preview simulator wrappers. + /// + /// Loads a mock measurement to display in preview. + @_spi(TestingSupport) + public func loadMockBloodPressureMeasurement() { + let device = MockDevice.createMockDevice() + + guard let measurement = device.bloodPressure.bloodPressureMeasurement else { + preconditionFailure("Mock Blood Pressure Measurement was never injected!") + } + + handleNewMeasurement(.bloodPressure(measurement, device.bloodPressure.features ?? []), from: device) + } +} +#endif diff --git a/Sources/SpeziDevices/Measurements/HealthMeasurementsConstraint.swift b/Sources/SpeziDevices/Measurements/HealthMeasurementsConstraint.swift new file mode 100644 index 0000000..32115bc --- /dev/null +++ b/Sources/SpeziDevices/Measurements/HealthMeasurementsConstraint.swift @@ -0,0 +1,15 @@ +// +// 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 + + +public protocol HealthMeasurementsConstraint: Standard { + func addMeasurement(sample: HKSample) async throws +} diff --git a/Sources/SpeziDevices/Measurements/ProcessedHealthMeasurement.swift b/Sources/SpeziDevices/Measurements/ProcessedHealthMeasurement.swift new file mode 100644 index 0000000..b706a57 --- /dev/null +++ b/Sources/SpeziDevices/Measurements/ProcessedHealthMeasurement.swift @@ -0,0 +1,27 @@ +// +// 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 + + +public enum ProcessedHealthMeasurement { // TODO: HealthKitSample with Hint? => StructuredHKSample? + case weight(HKQuantitySample) + case bloodPressure(HKCorrelation, heartRate: HKQuantitySample? = nil) +} + + +extension ProcessedHealthMeasurement: Identifiable { + public var id: UUID { + switch self { + case let .weight(sample): + sample.uuid + case let .bloodPressure(sample, _): + sample.uuid + } + } +} diff --git a/Sources/SpeziDevicesUI/Testing/MockBluetoothPeripheral.swift b/Sources/SpeziDevices/Testing/MockBluetoothPeripheral.swift similarity index 55% rename from Sources/SpeziDevicesUI/Testing/MockBluetoothPeripheral.swift rename to Sources/SpeziDevices/Testing/MockBluetoothPeripheral.swift index 3531dca..cd6d07e 100644 --- a/Sources/SpeziDevicesUI/Testing/MockBluetoothPeripheral.swift +++ b/Sources/SpeziDevices/Testing/MockBluetoothPeripheral.swift @@ -7,18 +7,21 @@ // import SpeziBluetooth -import SpeziDevices +#if DEBUG || TEST /// Mock peripheral used for internal previews. -struct MockBluetoothPeripheral: GenericBluetoothPeripheral { - var label: String - var state: PeripheralState - var requiresUserAttention: Bool +@_spi(TestingSupport) +public struct MockBluetoothPeripheral: GenericBluetoothPeripheral { + public let label: String + public let state: PeripheralState + public let requiresUserAttention: Bool - init(label: String, state: PeripheralState, requiresUserAttention: Bool = false) { + + public init(label: String, state: PeripheralState, requiresUserAttention: Bool = false) { self.label = label self.state = state self.requiresUserAttention = requiresUserAttention } } +#endif diff --git a/Sources/SpeziDevices/Testing/MockDevice.swift b/Sources/SpeziDevices/Testing/MockDevice.swift new file mode 100644 index 0000000..75ad5ab --- /dev/null +++ b/Sources/SpeziDevices/Testing/MockDevice.swift @@ -0,0 +1,140 @@ +// +// 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 Foundation +@_spi(TestingSupport) import SpeziBluetooth +import SpeziBluetoothServices +import SpeziNumerics + + +#if DEBUG || TEST +@_spi(TestingSupport) +public final class MockDevice: PairableDevice, HealthDevice { + @DeviceState(\.id) public var id + @DeviceState(\.name) public var name + @DeviceState(\.state) public var state + @DeviceState(\.advertisementData) public var advertisementData + @DeviceState(\.discarded) public var discarded + + @DeviceAction(\.connect) public var connect + @DeviceAction(\.disconnect) public var disconnect + + + @Service public var deviceInformation = DeviceInformationService() + + // Some mock health measurement services + @Service public var bloodPressure = BloodPressureService() + @Service public var weightScale = WeightScaleService() + + public let pairing = PairingContinuation() + public var isInPairingMode = false // TODO: control + + // TODO: mandatory setup? + public init() {} +} + + +extension MockDevice { + @_spi(TestingSupport) + public static func createMockDevice( + name: String = "Mock Device", + state: PeripheralState = .disconnected, + bloodPressureMeasurement: BloodPressureMeasurement = .mock(), + weightMeasurement: WeightMeasurement = .mock(), + weightResolution: WeightScaleFeature.WeightResolution = .resolution5g, + heightResolution: WeightScaleFeature.HeightResolution = .resolution1mm + ) -> MockDevice { + let device = MockDevice() + + device.deviceInformation.$manufacturerName.inject("Mock Company") + device.deviceInformation.$modelNumber.inject("MD1") + device.deviceInformation.$hardwareRevision.inject("2") + device.deviceInformation.$firmwareRevision.inject("1.0") + + device.$id.inject(UUID()) + device.$name.inject(name) + device.$state.inject(state) + + device.bloodPressure.$features.inject([ + .bodyMovementDetectionSupported, + .irregularPulseDetectionSupported + ]) + device.bloodPressure.$bloodPressureMeasurement.inject(bloodPressureMeasurement) + + device.weightScale.$features.inject(WeightScaleFeature( + weightResolution: weightResolution, + heightResolution: heightResolution, + options: .timeStampSupported + )) + device.weightScale.$weightMeasurement.inject(weightMeasurement) + + device.$connect.inject { @MainActor [weak device] in + device?.$state.inject(.connecting) + // TODO: await device?.handleStateChange(.connecting) + + try? await Task.sleep(for: .seconds(1)) + + device?.$state.inject(.connected) + // TODO: await device?.handleStateChange(.connected) + } + + device.$disconnect.inject { @MainActor [weak device] in + device?.$state.inject(.disconnected) + // TODO: await device?.handleStateChange(.disconnected) + } + + return device + } +} + + +extension BloodPressureMeasurement { + @_spi(TestingSupport) + public static func mock( + systolic: MedFloat16 = 103, + diastolic: MedFloat16 = 64, + meanArterialPressure: MedFloat16 = 77, + unit: BloodPressureMeasurement.Unit = .mmHg, + timeStamp: DateTime? = DateTime(year: 2024, month: .june, day: 5, hours: 12, minutes: 33, seconds: 11), + pulseRate: MedFloat16 = 62, + userId: UInt8 = 1, + status: BloodPressureMeasurement.Status = [] + ) -> BloodPressureMeasurement { + BloodPressureMeasurement( + systolic: systolic, + diastolic: diastolic, + meanArterialPressure: meanArterialPressure, + unit: unit, + timeStamp: timeStamp, + pulseRate: pulseRate, + userId: userId, + measurementStatus: status + ) + } +} + + +extension WeightMeasurement { + @_spi(TestingSupport) + public static func mock( + weight: UInt16 = 8400, + unit: WeightMeasurement.Unit = .si, + timeStamp: DateTime? = DateTime(year: 2024, month: .june, day: 5, hours: 12, minutes: 33, seconds: 11), + userId: UInt8? = nil, + additionalInfo: AdditionalInfo? = nil + ) -> WeightMeasurement { + WeightMeasurement( + weight: weight, + unit: unit, + timeStamp: timeStamp, + userId: userId, + additionalInfo: additionalInfo + ) + } +} +#endif diff --git a/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift b/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift index 606b9bc..d12e370 100644 --- a/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift +++ b/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift @@ -6,6 +6,9 @@ // SPDX-License-Identifier: MIT // +#if DEBUG +@_spi(TestingSupport) +#endif import SpeziDevices import SpeziViews import SwiftUI diff --git a/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift b/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift index 9b909b0..cecbb2a 100644 --- a/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift +++ b/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift @@ -108,7 +108,7 @@ extension Binding: Hashable, Equatable where Value: Hashable { } .onAppear { Tips.showAllTipsForTesting() - try? Tips.configure() + try? Tips.configure() // TODO: use ConfigureTipKit Module } .previewWith { DeviceManager() @@ -126,7 +126,7 @@ extension Binding: Hashable, Equatable where Value: Hashable { } .onAppear { Tips.showAllTipsForTesting() - try? Tips.configure() + try? Tips.configure() // TODO: use ConfigureTipKit Module } .previewWith { DeviceManager() diff --git a/Sources/SpeziDevicesUI/Devices/NameEditView.swift b/Sources/SpeziDevicesUI/Devices/NameEditView.swift index 8b336a3..9487ab3 100644 --- a/Sources/SpeziDevicesUI/Devices/NameEditView.swift +++ b/Sources/SpeziDevicesUI/Devices/NameEditView.swift @@ -6,6 +6,9 @@ // SPDX-License-Identifier: MIT // +#if DEBUG +@_spi(TestingSupport) +#endif import SpeziDevices import SpeziValidation import SwiftUI diff --git a/Sources/SpeziDevicesUI/Measurements/BloodPressureMeasurementLabel.swift b/Sources/SpeziDevicesUI/Measurements/BloodPressureMeasurementLabel.swift new file mode 100644 index 0000000..cdd3ca7 --- /dev/null +++ b/Sources/SpeziDevicesUI/Measurements/BloodPressureMeasurementLabel.swift @@ -0,0 +1,72 @@ +// +// 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 +#if DEBUG +@_spi(TestingSupport) +#endif +import SpeziDevices +import SwiftUI + + +struct BloodPressureMeasurementLabel: View { + private let bloodPressureSample: HKCorrelation + private let heartRateSample: HKQuantitySample? + + @ScaledMetric private var measurementTextSize: CGFloat = 50 + + private var bloodPressureQuantitySamples: [HKQuantitySample] { + bloodPressureSample.objects + .compactMap { sample in + sample as? HKQuantitySample + } + } + + private var systolic: HKQuantitySample? { + bloodPressureQuantitySamples + .first(where: { $0.quantityType == HKQuantityType(.bloodPressureSystolic) }) + } + + private var diastolic: HKQuantitySample? { + bloodPressureQuantitySamples + .first(where: { $0.quantityType == HKQuantityType(.bloodPressureDiastolic) }) + } + + var body: some View { + if let systolic, + let diastolic { + VStack(spacing: 5) { + Text("\(Int(systolic.quantity.doubleValue(for: .millimeterOfMercury())))/\(Int(diastolic.quantity.doubleValue(for: .millimeterOfMercury()))) mmHg") + .font(.system(size: measurementTextSize, weight: .bold, design: .rounded)) + + if let heartRateSample { + Text("\(Int(heartRateSample.quantity.doubleValue(for: .count().unitDivided(by: .minute())))) BPM") + .font(.title) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + } + } + } else { + Text("Invalid Sample") + .italic() + } + } + + + init(_ bloodPressureSample: HKCorrelation, heartRate heartRateSample: HKQuantitySample? = nil) { + self.bloodPressureSample = bloodPressureSample + self.heartRateSample = heartRateSample + } +} + + +#if DEBUG +#Preview { + BloodPressureMeasurementLabel(.mockBloodPressureSample, heartRate: .mockHeartRateSample) +} +#endif diff --git a/Sources/SpeziDevicesUI/Measurements/CloseButtonLayer.swift b/Sources/SpeziDevicesUI/Measurements/CloseButtonLayer.swift new file mode 100644 index 0000000..c48e0ea --- /dev/null +++ b/Sources/SpeziDevicesUI/Measurements/CloseButtonLayer.swift @@ -0,0 +1,45 @@ +// +// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SpeziViews +import SwiftUI + + +struct CloseButtonLayer: View { + @Environment(\.dismiss) private var dismiss + @Binding private var viewState: ViewState + + + var body: some View { + HStack { + Button( + action: { + dismiss() + }, + label: { + Text("Close", comment: "For closing sheets.") + .foregroundStyle(Color.accentColor) + } + ) + .buttonStyle(PlainButtonStyle()) + .disabled(viewState != .idle) + + Spacer() + } + .padding() + } + + + init(viewState: Binding) { + self._viewState = viewState + } +} + +#Preview { + CloseButtonLayer(viewState: .constant(.idle)) +} diff --git a/Sources/SpeziDevicesUI/Measurements/ConfirmMeasurementButton.swift b/Sources/SpeziDevicesUI/Measurements/ConfirmMeasurementButton.swift new file mode 100644 index 0000000..e1a730f --- /dev/null +++ b/Sources/SpeziDevicesUI/Measurements/ConfirmMeasurementButton.swift @@ -0,0 +1,66 @@ +// +// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SpeziViews +import SpeziDevices +import SwiftUI + + +struct DiscardButton: View { + @Environment(\.dismiss) var dismiss + @Binding var viewState: ViewState + + + var body: some View { + Button { + dismiss() + } label: { + Text("Discard") + .foregroundStyle(viewState == .idle ? Color.red : Color.gray) + } + .disabled(viewState != .idle) + } +} + + +struct ConfirmMeasurementButton: View { + private let confirm: () async throws -> Void + + @ScaledMetric private var buttonHeight: CGFloat = 38 + @Binding var viewState: ViewState + + var body: some View { + VStack { + AsyncButton(state: $viewState, action: confirm) { + Text("Save") + .frame(maxWidth: .infinity, maxHeight: buttonHeight) + .font(.title2) + .bold() + } + .buttonStyle(.borderedProminent) + + DiscardButton(viewState: $viewState) + .padding(.top, 10) + } + .padding() + } + + init(viewState: Binding, confirm: @escaping () async throws -> Void) { + self._viewState = viewState + self.confirm = confirm + } +} + + +#if DEBUG +#Preview { + ConfirmMeasurementButton(viewState: .constant(.idle)) { + print("Save") + } +} +#endif diff --git a/Sources/SpeziDevicesUI/Measurements/MeasurementLayer.swift b/Sources/SpeziDevicesUI/Measurements/MeasurementLayer.swift new file mode 100644 index 0000000..088b928 --- /dev/null +++ b/Sources/SpeziDevicesUI/Measurements/MeasurementLayer.swift @@ -0,0 +1,55 @@ +// +// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import HealthKit +#if DEBUG +@_spi(TestingSupport) +#endif +import SpeziDevices +import SwiftUI + + +struct MeasurementLayer: View { + private let measurement: ProcessedHealthMeasurement + + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + var body: some View { + VStack(spacing: 15) { + switch measurement { + case let .weight(sample): + WeightMeasurementLabel(sample) + case let .bloodPressure(bloodPressure, heartRate): + BloodPressureMeasurementLabel(bloodPressure, heartRate: heartRate) + } + + if dynamicTypeSize < .accessibility4 { + Text("Measurement Recorded") + .font(.title3) + .foregroundStyle(.secondary) + } + } + .multilineTextAlignment(.center) + } + + + init(measurement: ProcessedHealthMeasurement) { + self.measurement = measurement + } +} + + +#if DEBUG +#Preview { + MeasurementLayer(measurement: .weight(.mockWeighSample)) +} + +#Preview { + MeasurementLayer(measurement: .bloodPressure(.mockBloodPressureSample, heartRate: .mockHeartRateSample)) +} +#endif diff --git a/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedView.swift b/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedView.swift new file mode 100644 index 0000000..197b004 --- /dev/null +++ b/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedView.swift @@ -0,0 +1,89 @@ +// +// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SpeziViews +#if DEBUG +@_spi(TestingSupport) +#endif +import SpeziDevices +import SwiftUI + + +struct MeasurementRecordedView: View { // TODO: Sheet! + private let measurement: ProcessedHealthMeasurement + + @Environment(HealthMeasurements.self) private var measurements + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @State private var viewState = ViewState.idle + + + private var dynamicDetents: PresentationDetent { + switch dynamicTypeSize { + case .xSmall, .small: + return .fraction(0.35) + case .medium, .large: + return .fraction(0.45) + case .xLarge, .xxLarge, .xxxLarge: + return .fraction(0.65) + case .accessibility1, .accessibility2, .accessibility3, .accessibility4, .accessibility5: + return .large + default: + return .fraction(0.45) + } + } + + + var body: some View { + NavigationStack { + VStack { + MeasurementLayer(measurement: measurement) + Spacer() + ConfirmMeasurementButton(viewState: $viewState) { + try await measurements.saveMeasurement() + } + } + .viewStateAlert(state: $viewState) + .interactiveDismissDisabled(viewState != .idle) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + CloseButtonLayer(viewState: $viewState) + .disabled(viewState != .idle) + } + } + } + .presentationDetents([dynamicDetents]) + } + + + init(measurement: ProcessedHealthMeasurement) { + self.measurement = measurement + } +} + + +#if DEBUG +#Preview { + Text(verbatim: "") + .sheet(isPresented: .constant(true)) { + MeasurementRecordedView(measurement: .weight(.mockWeighSample)) + } + .previewWith(standard: TestMeasurementStandard()) { + HealthMeasurements() + } +} + +#Preview { + Text(verbatim: "") + .sheet(isPresented: .constant(true)) { + MeasurementRecordedView(measurement: .bloodPressure(.mockBloodPressureSample, heartRate: .mockHeartRateSample)) + } + .previewWith(standard: TestMeasurementStandard()) { + HealthMeasurements() + } +} +#endif diff --git a/Sources/SpeziDevicesUI/Measurements/WeightMeasurementLabel.swift b/Sources/SpeziDevicesUI/Measurements/WeightMeasurementLabel.swift new file mode 100644 index 0000000..a1d0496 --- /dev/null +++ b/Sources/SpeziDevicesUI/Measurements/WeightMeasurementLabel.swift @@ -0,0 +1,38 @@ +// +// 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 +#if DEBUG +@_spi(TestingSupport) +#endif +import SpeziDevices +import SwiftUI + + +struct WeightMeasurementLabel: View { + private let sample: HKQuantitySample + + @ScaledMetric private var measurementTextSize: CGFloat = 60 + + var body: some View { + Text(sample.quantity.description) + .font(.system(size: measurementTextSize, weight: .bold, design: .rounded)) + } + + + init(_ sample: HKQuantitySample) { + self.sample = sample + } +} + + +#if DEBUG +#Preview { + WeightMeasurementLabel(.mockWeighSample) +} +#endif diff --git a/Sources/SpeziDevicesUI/Pairing/AccessoryImageView.swift b/Sources/SpeziDevicesUI/Pairing/AccessoryImageView.swift index 74322f8..7d4ed9b 100644 --- a/Sources/SpeziDevicesUI/Pairing/AccessoryImageView.swift +++ b/Sources/SpeziDevicesUI/Pairing/AccessoryImageView.swift @@ -6,6 +6,9 @@ // SPDX-License-Identifier: MIT // +#if DEBUG +@_spi(TestingSupport) +#endif import SpeziDevices import SwiftUI diff --git a/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift b/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift index 1d2fc26..e0e3708 100644 --- a/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift +++ b/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift @@ -7,6 +7,9 @@ // import SpeziBluetooth +#if DEBUG +@_spi(TestingSupport) +#endif import SpeziDevices import SpeziViews import SwiftUI diff --git a/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift b/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift index 426cc3f..4d11758 100644 --- a/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift +++ b/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift @@ -7,6 +7,9 @@ // import ACarousel +#if DEBUG +@_spi(TestingSupport) +#endif import SpeziDevices import SpeziViews import SwiftUI diff --git a/Sources/SpeziDevicesUI/Pairing/PairedDeviceView.swift b/Sources/SpeziDevicesUI/Pairing/PairedDeviceView.swift index c4ddb71..d756e66 100644 --- a/Sources/SpeziDevicesUI/Pairing/PairedDeviceView.swift +++ b/Sources/SpeziDevicesUI/Pairing/PairedDeviceView.swift @@ -6,6 +6,9 @@ // SPDX-License-Identifier: MIT // +#if DEBUG +@_spi(TestingSupport) +#endif import SpeziDevices import SwiftUI diff --git a/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings b/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings index 40b2842..4a8fc6e 100644 --- a/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings +++ b/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings @@ -21,6 +21,19 @@ } } }, + "%lld BPM" : { + + }, + "%lld/%lld mmHg" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld/%2$lld mmHg" + } + } + } + }, "Accessory Paired" : { "localizations" : { "en" : { @@ -131,6 +144,9 @@ } } }, + "Close" : { + "comment" : "For closing sheets." + }, "Connected" : { "localizations" : { "en" : { @@ -170,6 +186,9 @@ } } } + }, + "Discard" : { + }, "Disconnecting" : { "localizations" : { @@ -290,6 +309,9 @@ } } } + }, + "Invalid Sample" : { + }, "Make sure to to remove the device from the Bluetooth settings to fully unpair the device." : { "localizations" : { @@ -300,6 +322,9 @@ } } } + }, + "Measurement Recorded" : { + }, "Model" : { "localizations" : { @@ -430,6 +455,9 @@ } } } + }, + "Save" : { + }, "Synchronizing ..." : { "localizations" : { diff --git a/Sources/SpeziDevicesUI/Scanning/NearbyDeviceRow.swift b/Sources/SpeziDevicesUI/Scanning/NearbyDeviceRow.swift index 049161e..59626ff 100644 --- a/Sources/SpeziDevicesUI/Scanning/NearbyDeviceRow.swift +++ b/Sources/SpeziDevicesUI/Scanning/NearbyDeviceRow.swift @@ -8,6 +8,9 @@ import SpeziBluetooth +#if DEBUG +@_spi(TestingSupport) +#endif import SpeziDevices import SpeziViews import SwiftUI diff --git a/Sources/SpeziDevicesUI/Testing/MockDevice.swift b/Sources/SpeziDevicesUI/Testing/MockDevice.swift deleted file mode 100644 index 0b37f05..0000000 --- a/Sources/SpeziDevicesUI/Testing/MockDevice.swift +++ /dev/null @@ -1,67 +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 Foundation -@_spi(TestingSupport) import SpeziBluetooth -import SpeziBluetoothServices -import SpeziDevices - - -#if DEBUG -final class MockDevice: PairableDevice, Identifiable { - @DeviceState(\.id) var id - @DeviceState(\.name) var name - @DeviceState(\.state) var state - @DeviceState(\.advertisementData) var advertisementData - @DeviceState(\.discarded) var discarded - - @DeviceAction(\.connect) var connect - @DeviceAction(\.disconnect) var disconnect - - - @Service var deviceInformation = DeviceInformationService() - - let pairing = PairingContinuation() - var isInPairingMode = false // TODO: control - - // TODO: mandatory setup? -} - - -extension MockDevice { - static func createMockDevice(name: String = "Mock Device", state: PeripheralState = .disconnected) -> MockDevice { - let device = MockDevice() - - device.deviceInformation.$manufacturerName.inject("Mock Company") - device.deviceInformation.$modelNumber.inject("MD1") - device.deviceInformation.$hardwareRevision.inject("2") - device.deviceInformation.$firmwareRevision.inject("1.0") - - device.$id.inject(UUID()) - device.$name.inject(name) - device.$state.inject(state) - - device.$connect.inject { @MainActor [weak device] in - device?.$state.inject(.connecting) - // TODO: await device?.handleStateChange(.connecting) - - try? await Task.sleep(for: .seconds(1)) - - device?.$state.inject(.connected) - // TODO: await device?.handleStateChange(.connected) - } - - device.$disconnect.inject { @MainActor [weak device] in - device?.$state.inject(.disconnected) - // TODO: await device?.handleStateChange(.disconnected) - } - - return device - } -} -#endif diff --git a/Sources/SpeziDevicesUI/Testing/TestMeasurementStandard.swift b/Sources/SpeziDevicesUI/Testing/TestMeasurementStandard.swift new file mode 100644 index 0000000..a1c4c78 --- /dev/null +++ b/Sources/SpeziDevicesUI/Testing/TestMeasurementStandard.swift @@ -0,0 +1,20 @@ +// +// 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(sample: HKSample) async throws { + print("Adding sample \(sample)") + } +} +#endif diff --git a/Sources/SpeziDevicesUI/Tips/ConfigureTipKit.swift b/Sources/SpeziDevicesUI/Tips/ConfigureTipKit.swift deleted file mode 100644 index 9c61ea0..0000000 --- a/Sources/SpeziDevicesUI/Tips/ConfigureTipKit.swift +++ /dev/null @@ -1,36 +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 Spezi -@_spi(TestingSupport) import SpeziFoundation -import TipKit - - -class ConfigureTipKit: Module, DefaultInitializable { // TODO: move to SpeziViews! - @Application(\.logger) private var logger - - - required init() {} - - func configure() { - if RuntimeConfig.testingTips || ProcessInfo.processInfo.isPreviewSimulator { - Tips.showAllTipsForTesting() - } - do { - try Tips.configure() - } catch { - logger.error("Failed to configure TipKit: \(error)") - } - } -} - - -extension RuntimeConfig { - /// Enable testing tips - static let testingTips = CommandLine.arguments.contains("--testTips") -} From deeb620d361068ecb3081d0de4698fb59140a4c9 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Sat, 22 Jun 2024 11:42:40 +0200 Subject: [PATCH 14/77] Make accessible --- ...cordedView.swift => MeasurementRecordedSheet.swift} | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) rename Sources/SpeziDevicesUI/Measurements/{MeasurementRecordedView.swift => MeasurementRecordedSheet.swift} (86%) diff --git a/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedView.swift b/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift similarity index 86% rename from Sources/SpeziDevicesUI/Measurements/MeasurementRecordedView.swift rename to Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift index 197b004..e6c9289 100644 --- a/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedView.swift +++ b/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift @@ -14,7 +14,7 @@ import SpeziDevices import SwiftUI -struct MeasurementRecordedView: View { // TODO: Sheet! +public struct MeasurementRecordedSheet: View { private let measurement: ProcessedHealthMeasurement @Environment(HealthMeasurements.self) private var measurements @@ -38,7 +38,7 @@ struct MeasurementRecordedView: View { // TODO: Sheet! } - var body: some View { + public var body: some View { NavigationStack { VStack { MeasurementLayer(measurement: measurement) @@ -60,7 +60,7 @@ struct MeasurementRecordedView: View { // TODO: Sheet! } - init(measurement: ProcessedHealthMeasurement) { + public init(measurement: ProcessedHealthMeasurement) { // TODO: docs! self.measurement = measurement } } @@ -70,7 +70,7 @@ struct MeasurementRecordedView: View { // TODO: Sheet! #Preview { Text(verbatim: "") .sheet(isPresented: .constant(true)) { - MeasurementRecordedView(measurement: .weight(.mockWeighSample)) + MeasurementRecordedSheet(measurement: .weight(.mockWeighSample)) } .previewWith(standard: TestMeasurementStandard()) { HealthMeasurements() @@ -80,7 +80,7 @@ struct MeasurementRecordedView: View { // TODO: Sheet! #Preview { Text(verbatim: "") .sheet(isPresented: .constant(true)) { - MeasurementRecordedView(measurement: .bloodPressure(.mockBloodPressureSample, heartRate: .mockHeartRateSample)) + MeasurementRecordedSheet(measurement: .bloodPressure(.mockBloodPressureSample, heartRate: .mockHeartRateSample)) } .previewWith(standard: TestMeasurementStandard()) { HealthMeasurements() From 0d633cbb45cb3d56d92a9bee9aa01e62adb33adb Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Sat, 22 Jun 2024 11:45:03 +0200 Subject: [PATCH 15/77] Provide access to state --- Sources/SpeziDevices/Measurements/HealthMeasurements.swift | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Sources/SpeziDevices/Measurements/HealthMeasurements.swift b/Sources/SpeziDevices/Measurements/HealthMeasurements.swift index e39f33c..d3d534c 100644 --- a/Sources/SpeziDevices/Measurements/HealthMeasurements.swift +++ b/Sources/SpeziDevices/Measurements/HealthMeasurements.swift @@ -10,10 +10,6 @@ import Foundation import OSLog import Spezi import SpeziBluetooth -#if DEBUG -@_spi(TestingSupport) -#endif -import SpeziDevices /// Manage and process incoming health measurements. @@ -21,7 +17,7 @@ import SpeziDevices public class HealthMeasurements: Module, EnvironmentAccessible, DefaultInitializable { private let logger = Logger(subsystem: "ENGAGEHF", category: "HealthMeasurements") - var newMeasurement: ProcessedHealthMeasurement? + public private(set) var newMeasurement: ProcessedHealthMeasurement? // TODO: support array of new measurements? @StandardActor @ObservationIgnored private var standard: any HealthMeasurementsConstraint @Dependency @ObservationIgnored private var bluetooth: Bluetooth? From f3212fa9b712971ebf05a619958daa60603fdb30 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Sat, 22 Jun 2024 11:45:28 +0200 Subject: [PATCH 16/77] Item bidning fix --- Sources/SpeziDevices/Measurements/HealthMeasurements.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SpeziDevices/Measurements/HealthMeasurements.swift b/Sources/SpeziDevices/Measurements/HealthMeasurements.swift index d3d534c..d0cc391 100644 --- a/Sources/SpeziDevices/Measurements/HealthMeasurements.swift +++ b/Sources/SpeziDevices/Measurements/HealthMeasurements.swift @@ -17,7 +17,7 @@ import SpeziBluetooth public class HealthMeasurements: Module, EnvironmentAccessible, DefaultInitializable { private let logger = Logger(subsystem: "ENGAGEHF", category: "HealthMeasurements") - public private(set) var newMeasurement: ProcessedHealthMeasurement? // TODO: support array of new measurements? + public var newMeasurement: ProcessedHealthMeasurement? // TODO: support array of new measurements? (item binding needs write access :/) @StandardActor @ObservationIgnored private var standard: any HealthMeasurementsConstraint @Dependency @ObservationIgnored private var bluetooth: Bluetooth? From e451b2db2044743ba7cad6c9c42734dac635ae65 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Mon, 24 Jun 2024 16:40:33 +0200 Subject: [PATCH 17/77] A bunch of progress, updating to latest SpeziBluetooth prototype --- Sources/SpeziDevices/DeviceManager.swift | 120 ++++++++++++------ .../SpeziDevices/Devices/PairableDevice.swift | 12 +- .../BloodPressureMeasurement+HKSample.swift | 22 +++- ...BloodPressureMeasurement.Unit+HKUnit.swift | 2 - .../Health/WeightMeasurement+HKSample.swift | 84 ++++++++++-- ...swift => BluetoothHealthMeasurement.swift} | 7 +- .../Measurements/HealthKitMeasurement.swift | 55 ++++++++ .../Measurements/HealthMeasurements.swift | 34 ++--- .../HealthMeasurementsConstraint.swift | 14 +- .../ProcessedHealthMeasurement.swift | 27 ---- .../Model/DevicePairingError.swift | 2 +- .../SpeziDevices.docc/HealthKit.md | 31 +++++ .../SpeziDevices.docc/SpeziDevices.md | 9 ++ Sources/SpeziDevices/Testing/MockDevice.swift | 31 ++++- .../Devices/DeviceDetailsView.swift | 5 +- .../SpeziDevicesUI/Devices/DeviceTile.swift | 7 +- .../SpeziDevicesUI/Devices/DevicesGrid.swift | 5 +- .../SpeziDevicesUI/Devices/NameEditView.swift | 5 +- .../BloodPressureMeasurementLabel.swift | 6 +- .../ConfirmMeasurementButton.swift | 7 +- .../Measurements/MeasurementLayer.swift | 21 +-- .../MeasurementRecordedSheet.swift | 72 +++++++---- .../Measurements/WeightMeasurementLabel.swift | 53 +++++++- .../Pairing/AccessoryImageView.swift | 5 +- .../Pairing/AccessorySetupSheet.swift | 9 +- .../Pairing/PairDeviceView.swift | 5 +- .../Pairing/PairedDeviceView.swift | 5 +- .../Resources/Localizable.xcstrings | 3 + .../Scanning/BluetoothUnavailableView.swift | 7 +- .../Scanning/NearbyDeviceRow.swift | 5 +- .../Testing/TestMeasurementStandard.swift | 4 +- .../SpeziDevicesUI/Utils/PaneContent.swift | 35 +++-- ...acteristicAccessor+OmronRecordAccess.swift | 3 +- 33 files changed, 500 insertions(+), 212 deletions(-) rename Sources/SpeziDevices/Measurements/{HealthMeasurement.swift => BluetoothHealthMeasurement.swift} (55%) create mode 100644 Sources/SpeziDevices/Measurements/HealthKitMeasurement.swift delete mode 100644 Sources/SpeziDevices/Measurements/ProcessedHealthMeasurement.swift create mode 100644 Sources/SpeziDevices/SpeziDevices.docc/HealthKit.md diff --git a/Sources/SpeziDevices/DeviceManager.swift b/Sources/SpeziDevices/DeviceManager.swift index 1312f5c..754cb8b 100644 --- a/Sources/SpeziDevices/DeviceManager.swift +++ b/Sources/SpeziDevices/DeviceManager.swift @@ -12,8 +12,6 @@ import SpeziBluetooth import SpeziBluetoothServices import SwiftUI -// TODO: Finish SpeziBluetooth refactoring and cleanup "persistent devices" -// TODO: dark mode device images @Observable public final class DeviceManager: Module, EnvironmentAccessible, DefaultInitializable { // TODO: "PairedDevices" rename? @@ -45,7 +43,15 @@ public final class DeviceManager: Module, EnvironmentAccessible, DefaultInitiali @Dependency @ObservationIgnored private var bluetooth: Bluetooth? - required public init() {} // TODO: configure automatic search without devices paired! + + private var stateSubscriptionTask: Task? { + willSet { + stateSubscriptionTask?.cancel() + } + } + + + public required init() {} // TODO: configure automatic search without devices paired! public func configure() { guard let bluetooth else { @@ -53,38 +59,27 @@ public final class DeviceManager: Module, EnvironmentAccessible, DefaultInitiali return // useful for e.g. previews } - // we just reuse the configured Bluetooth devices - let configuredDevices = bluetooth.configuredPairableDevices - // TODO: bit weird API wise! - // We need to detach to not copy task local values + // We need to detach to not copy task local values Task.detached { @MainActor in - // TODO: we need to redo this once bluetooth powers on? - for deviceInfo in self.pairedDevices { - guard self.peripherals[deviceInfo.id] == nil else { - continue - } - - guard let deviceType = configuredDevices[deviceInfo.deviceType] else { - self.logger.error("Unsupported device type \"\(deviceInfo.deviceType)\" for paired device \(deviceInfo.name).") - continue - } - - let device = await deviceType.retrievePeripheral(from: bluetooth, with: deviceInfo.id) - - guard let device else { - // TODO: once spezi bluetooth works (waiting for connected), this is an indication that the device was unpaired???? - self.logger.warning("Device \(deviceInfo.id) \(deviceInfo.name) could not be retrieved!") - continue + // TODO: cancel all that if the last device is forgotten? + self.stateSubscriptionTask = Task.detached { [weak self] in + for await nextState in await bluetooth.stateSubscription { + print("Bluetooth Module state is now \(nextState)") + // TODO: asdasd, do something with that! + // TODO: actually do something with that! } + } - assert(self.peripherals[device.id] == nil, "Cannot overwrite peripheral. Device \(deviceInfo) was paired twice.") - self.peripherals[device.id] = device - // TODO: we must store them (remove once we forget about them)? - // TODO: we can instantly store newly paired devices! - await device.connect() // TODO: might want to cancel that? + guard !self.pairedDevices.isEmpty else { + return // no devices paired, no need to power up central + } + // TODO: we need to redo this once bluetooth powers on? + // TODO: stateSubcription + powerOn() call! - // TODO: call connect after device disconnects? + await bluetooth.powerOn() // TODO: should we do that on first connected device? + if case .poweredOn = bluetooth.state { + await self.handleCentralPoweredOn() } } } @@ -145,12 +140,14 @@ public final class DeviceManager: Module, EnvironmentAccessible, DefaultInitiali if device.deviceInformation.modelNumber == nil && device.deviceInformation.$modelNumber.isPresent { // make sure it isn't just a race condition that we haven't received a value yet - if let readModel = try? await device.deviceInformation.$modelNumber.read() { + do { + let readModel = try await device.deviceInformation.$modelNumber.read() self.logger.info("ModelNumber was not present on device \(device.label), was read as \"\(readModel)\".") - } // TODO: log the error? + } catch { + logger.debug("Failed to retrieve model number for device \(Device.self): \(error)") + } } - // TODO: let omronManufacturerData = device.manufacturerData?.users.first?.sequenceNumber (which user to choose from?) let deviceInfo = PairedDeviceInfo( id: device.id, deviceType: Device.deviceTypeIdentifier, @@ -165,13 +162,14 @@ public final class DeviceManager: Module, EnvironmentAccessible, DefaultInitiali assert(peripherals[device.id] == nil, "Cannot overwrite peripheral. Device \(deviceInfo) was paired twice.") + // TODO: convert to persistent device! peripherals[device.id] = device self.logger.debug("Device \(device.label) with id \(device.id) is now paired!") } @MainActor - public func handleDiscardedDevice(_ device: Device) { + public 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 @@ -189,7 +187,7 @@ public final class DeviceManager: Module, EnvironmentAccessible, DefaultInitiali await device.disconnect() } } - // TODO: make sure to remove them from discoveredDevices? + // TODO: make sure to remove them from discoveredDevices? => should happen automatically? } @MainActor @@ -199,6 +197,54 @@ public final class DeviceManager: Module, EnvironmentAccessible, DefaultInitiali } pairedDevices[index].lastBatteryPercentage = percentage } + + private func handleBluetoothStateChanged(_ state: BluetoothState) { + + } + + @MainActor + private func handleCentralPoweredOn() async { + guard let bluetooth else { + return + } + + // TODO: check powered on? + + // we just reuse the configured Bluetooth devices + let configuredDevices = bluetooth.configuredPairableDevices + + for deviceInfo in self.pairedDevices { + guard self.peripherals[deviceInfo.id] == nil else { + continue + } + + guard let deviceType = configuredDevices[deviceInfo.deviceType] else { + self.logger.error("Unsupported device type \"\(deviceInfo.deviceType)\" for paired device \(deviceInfo.name).") + continue + } + + let device = await deviceType.retrieveDevice(from: bluetooth, with: deviceInfo.id) + + guard let device else { + // TODO: once spezi bluetooth works (waiting for connected), this is an indication that the device was unpaired???? => we know it is powered on! + self.logger.warning("Device \(deviceInfo.id) \(deviceInfo.name) could not be retrieved!") + continue + } + + assert(self.peripherals[device.id] == nil, "Cannot overwrite peripheral. Device \(deviceInfo) was paired twice.") + self.peripherals[device.id] = device + + // TODO: make task group! + // TODO: create tasks and store them? + await device.connect() // TODO: might want to cancel that? + + // TODO: call connect after device disconnects? + } + } + + deinit { + _peripherals.removeAll() // TODO: clear any other state? + } } @@ -215,7 +261,7 @@ extension Bluetooth { extension PairableDevice { - fileprivate static func retrievePeripheral(from bluetooth: Bluetooth, with id: UUID) async -> Self? { - await bluetooth.retrievePeripheral(for: id, as: Self.self) + fileprivate static func retrieveDevice(from bluetooth: Bluetooth, with id: UUID) async -> Self? { + await bluetooth.retrieveDevice(for: id) } } diff --git a/Sources/SpeziDevices/Devices/PairableDevice.swift b/Sources/SpeziDevices/Devices/PairableDevice.swift index 69c984b..a468e51 100644 --- a/Sources/SpeziDevices/Devices/PairableDevice.swift +++ b/Sources/SpeziDevices/Devices/PairableDevice.swift @@ -17,14 +17,14 @@ public protocol PairableDevice: GenericDevice { /// This is used to associate pairing information with the implementing device. By default, the type name is used. static var deviceTypeIdentifier: String { get } - /// Indicate that the device was discarded. + /// Indicate that the device is nearby. /// /// Use the [`DeviceState`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/devicestate) property wrapper to /// declare this property. /// ```swift - /// @DeviceState(\.discarded) var discarded + /// @DeviceState(\.nearby) var nearby /// ``` - var discarded: Bool { get } + var nearby: Bool { get } /// Storage for pairing continuation. var pairing: PairingContinuation { get } @@ -59,7 +59,7 @@ public protocol PairableDevice: GenericDevice { /// /// 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`` + /// Particularly, you must call the ``PairingContinuation/signalPaired()`` and ``PairingContinuation/signalDisconnect()`` /// methods when appropriate. /// - Throws: Throws a ``DevicePairingError`` if not successful. func pair() async throws @@ -79,7 +79,7 @@ extension PairableDevice { extension PairableDevice { /// Default pairing implementation. /// - /// The default implementation verifies that the device ``isInPairingMode``, is currently disconnected and not ``discarded``. + /// 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. @@ -92,7 +92,7 @@ extension PairableDevice { throw DevicePairingError.invalidState } - guard !discarded else { + guard nearby else { throw DevicePairingError.invalidState } diff --git a/Sources/SpeziDevices/Health/BloodPressureMeasurement+HKSample.swift b/Sources/SpeziDevices/Health/BloodPressureMeasurement+HKSample.swift index d1a4bdf..8859727 100644 --- a/Sources/SpeziDevices/Health/BloodPressureMeasurement+HKSample.swift +++ b/Sources/SpeziDevices/Health/BloodPressureMeasurement+HKSample.swift @@ -12,6 +12,12 @@ import SpeziBluetoothServices extension BloodPressureMeasurement { + /// Convert the blood pressure measurement to the HealthKit representation. + /// + /// Converts the content of the blood pressure measurement to a `HKCorrelation`. + /// - Parameter device: The device information to reference with the `HKCorrelation`. + /// You may use ``HealthDevice/hkDevice`` to retrieve the device information from a ``HealthDevice``. + /// - Returns: Returns the `HKCorrelation` with two samples for systolic and diastolic values. Returns `nil` if either of the blood pressure samples is non-finite. public func bloodPressureSample(source device: HKDevice?) -> HKCorrelation? { guard systolicValue.isFinite, diastolicValue.isFinite else { return nil @@ -46,6 +52,12 @@ extension BloodPressureMeasurement { extension BloodPressureMeasurement { + /// Convert the heart rate measurement to the HealthKit representation. + /// + /// Converts the hear rate measurement of the blood pressure measurement to a `HKQuantitySample`. + /// - Parameter device: The device information to reference with the `HKQuantitySample`. + /// You may use ``HealthDevice/hkDevice`` to retrieve the device information from a ``HealthDevice``. + /// - Returns: Returns the `HKQuantitySample` with the heart rate value. Returns `nil` if no pulse rate is present or contains a non-finite value. public func heartRateSample(source device: HKDevice?) -> HKQuantitySample? { guard let pulseRate, pulseRate.isFinite else { return nil @@ -70,10 +82,9 @@ extension BloodPressureMeasurement { } -#if DEBUG || TEST extension HKCorrelation { - @_spi(TestingSupport) - public static var mockBloodPressureSample: HKCorrelation { + /// Retrieve a mock blood pressure sample. + @_spi(TestingSupport) public static var mockBloodPressureSample: HKCorrelation { let measurement = BloodPressureMeasurement(systolic: 117, diastolic: 76, meanArterialPressure: 67, unit: .mmHg, pulseRate: 68) guard let sample = measurement.bloodPressureSample(source: nil) else { preconditionFailure("Mock sample was unexpectedly invalid!") @@ -83,8 +94,8 @@ extension HKCorrelation { } extension HKQuantitySample { - @_spi(TestingSupport) - public static var mockHeartRateSample: HKQuantitySample { + /// Retrieve a mock heart rate sample. + @_spi(TestingSupport) public static var mockHeartRateSample: HKQuantitySample { let measurement = BloodPressureMeasurement(systolic: 117, diastolic: 76, meanArterialPressure: 67, unit: .mmHg, pulseRate: 68) guard let sample = measurement.heartRateSample(source: nil) else { preconditionFailure("Mock sample was unexpectedly invalid!") @@ -92,4 +103,3 @@ extension HKQuantitySample { return sample } } -#endif diff --git a/Sources/SpeziDevices/Health/BloodPressureMeasurement.Unit+HKUnit.swift b/Sources/SpeziDevices/Health/BloodPressureMeasurement.Unit+HKUnit.swift index b62f1c2..ea97069 100644 --- a/Sources/SpeziDevices/Health/BloodPressureMeasurement.Unit+HKUnit.swift +++ b/Sources/SpeziDevices/Health/BloodPressureMeasurement.Unit+HKUnit.swift @@ -6,8 +6,6 @@ // SPDX-License-Identifier: MIT // - - import HealthKit import SpeziBluetoothServices diff --git a/Sources/SpeziDevices/Health/WeightMeasurement+HKSample.swift b/Sources/SpeziDevices/Health/WeightMeasurement+HKSample.swift index d5ef6de..06ce332 100644 --- a/Sources/SpeziDevices/Health/WeightMeasurement+HKSample.swift +++ b/Sources/SpeziDevices/Health/WeightMeasurement+HKSample.swift @@ -12,11 +12,18 @@ import SpeziBluetoothServices extension WeightMeasurement { - public func quantitySample(source device: HKDevice?, resolution: WeightScaleFeature.WeightResolution?) -> HKQuantitySample { - let quantityType = HKQuantityType(.bodyMass) - - let value = weight(of: resolution ?? .unspecified) + /// Convert the weight measurement to the HealthKit representation. + /// + /// Converts the weight measurement to a `HKQuantitySample`. + /// - Parameters: + /// - device: The device information to reference with the `HKQuantitySample`. + /// You may use ``HealthDevice/hkDevice`` to retrieve the device information from a ``HealthDevice``. + /// - resolution: The resolution provided by the `WeightScaleFeature` characteristic. Otherwise, assumes default resolution. + /// - Returns: Returns the `HKQuantitySample` with the weight value. + public func weightSample(source device: HKDevice?, resolution: WeightScaleFeature.WeightResolution = .unspecified) -> HKQuantitySample { + let value = weight(of: resolution) + let quantityType = HKQuantityType(.bodyMass) let quantity = HKQuantity(unit: unit.massUnit, doubleValue: value) let date = timeStamp?.date ?? .now @@ -29,15 +36,74 @@ extension WeightMeasurement { metadata: nil ) } + + /// Convert the BMI measurement to the HealthKit representation. + /// + /// Converts the BMI measurement to a `HKQuantitySample`. + /// - Parameter device: The device information to reference with the `HKQuantitySample`. + /// You may use ``HealthDevice/hkDevice`` to retrieve the device information from a ``HealthDevice``. + /// - Returns: Returns the `HKQuantitySample` with the BMI value. Returns `nil` if the measurement didn't contain a BMI value. + public func bmiSample(source device: HKDevice?) -> HKQuantitySample? { + guard let bmi = additionalInfo?.bmi else { + return nil + } + + // `bmi` is in units of 0.1 kg/m2 + let bmiValue = Double(bmi) * 0.1 + + let unit: HKUnit = .count() // HealthKit uses count unit for BMI + let quantityType = HKQuantityType(.bodyMassIndex) + let quantity = HKQuantity(unit: unit, doubleValue: bmiValue) + + let date = timeStamp?.date ?? .now + + return HKQuantitySample(type: quantityType, quantity: quantity, start: date, end: date, device: device, metadata: nil) + } + + /// Convert the height measurement to the HealthKit representation. + /// + /// Converts the height measurement to a `HKQuantitySample`. + /// - Parameters: + /// - device: The device information to reference with the `HKQuantitySample`. + /// You may use ``HealthDevice/hkDevice`` to retrieve the device information from a ``HealthDevice``. + /// - resolution: The resolution provided by the `WeightScaleFeature` characteristic. Otherwise, assumes default resolution. + /// - Returns: Returns the `HKQuantitySample` with the height value. Returns `nil` if the measurement didn't contain a height value. + public func heightSample(source device: HKDevice?, resolution: WeightScaleFeature.HeightResolution = .unspecified) -> HKQuantitySample? { + guard let height = height(of: resolution) else { + return nil + } + + let quantityType = HKQuantityType(.height) + let quantity = HKQuantity(unit: unit.lengthUnit, doubleValue: height) + let date = timeStamp?.date ?? .now + + return HKQuantitySample(type: quantityType, quantity: quantity, start: date, end: date, device: device, metadata: nil) + } } -#if DEBUG || TEST extension HKQuantitySample { - @_spi(TestingSupport) - public static var mockWeighSample: HKQuantitySample { + /// Retrieve a mock weight sample. + @_spi(TestingSupport) public static var mockWeighSample: HKQuantitySample { let measurement = WeightMeasurement(weight: 8400, unit: .si) - return measurement.quantitySample(source: nil, resolution: .resolution5g) + return measurement.weightSample(source: nil, resolution: .resolution5g) + } + + /// Retrieve a mock bmi sample. + @_spi(TestingSupport) public static var mockBmiSample: HKQuantitySample { + let measurement = WeightMeasurement(weight: 8400, unit: .si, additionalInfo: .init(bmi: 230, height: 1750)) + guard let sample = measurement.bmiSample(source: nil) else { + preconditionFailure("Mock sample was unexpectedly invalid!") + } + return sample + } + + /// Retrieve a mock height sample: + @_spi(TestingSupport) public static var mockHeightSample: HKQuantitySample { + let measurement = WeightMeasurement(weight: 8400, unit: .si, additionalInfo: .init(bmi: 230, height: 1750)) + guard let sample = measurement.heightSample(source: nil, resolution: .resolution1mm) else { + preconditionFailure("Mock sample was unexpectedly invalid!") + } + return sample } } -#endif diff --git a/Sources/SpeziDevices/Measurements/HealthMeasurement.swift b/Sources/SpeziDevices/Measurements/BluetoothHealthMeasurement.swift similarity index 55% rename from Sources/SpeziDevices/Measurements/HealthMeasurement.swift rename to Sources/SpeziDevices/Measurements/BluetoothHealthMeasurement.swift index be405b4..f62d275 100644 --- a/Sources/SpeziDevices/Measurements/HealthMeasurement.swift +++ b/Sources/SpeziDevices/Measurements/BluetoothHealthMeasurement.swift @@ -9,7 +9,12 @@ import SpeziBluetoothServices -public enum HealthMeasurement { +/// A measurement retrieved from a Bluetooth device. +/// +/// Bluetooth Measurements are represented using standardized measurement characteristics. +public enum BluetoothHealthMeasurement { + /// A weight measurement and its context. case weight(WeightMeasurement, WeightScaleFeature) + /// A blood pressure measurement and its context. case bloodPressure(BloodPressureMeasurement, BloodPressureFeature) } diff --git a/Sources/SpeziDevices/Measurements/HealthKitMeasurement.swift b/Sources/SpeziDevices/Measurements/HealthKitMeasurement.swift new file mode 100644 index 0000000..f299427 --- /dev/null +++ b/Sources/SpeziDevices/Measurements/HealthKitMeasurement.swift @@ -0,0 +1,55 @@ +// +// 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 + + +/// A collection of HealthKit samples that form a measurement. +public enum HealthKitMeasurement { + /// A weight measurement with optional BMI and height samples. + case weight(HKQuantitySample, bmi: HKQuantitySample? = nil, height: HKQuantitySample? = nil) + /// A blood pressure correlation with an optional heart rate sample. + case bloodPressure(HKCorrelation, heartRate: HKQuantitySample? = nil) +} + + +extension HealthKitMeasurement { + /// The collection of HealthKit samples contained in the measurement. + public var samples: [HKSample] { + var samples: [HKSample] = [] + switch self { + case let .weight(sample, bmi, height): + samples.append(sample) + if let bmi { + samples.append(bmi) + } + if let height { + samples.append(height) + } + case let .bloodPressure(sample, heartRate): + samples.append(sample) + if let heartRate { + samples.append(heartRate) + } + } + + return samples + } +} + + +extension HealthKitMeasurement: Identifiable { + public var id: UUID { + switch self { + case let .weight(sample, _, _): + sample.uuid + case let .bloodPressure(sample, _): + sample.uuid + } + } +} diff --git a/Sources/SpeziDevices/Measurements/HealthMeasurements.swift b/Sources/SpeziDevices/Measurements/HealthMeasurements.swift index d0cc391..aa0bf73 100644 --- a/Sources/SpeziDevices/Measurements/HealthMeasurements.swift +++ b/Sources/SpeziDevices/Measurements/HealthMeasurements.swift @@ -14,10 +14,13 @@ import SpeziBluetooth /// Manage and process incoming health measurements. @Observable -public class HealthMeasurements: Module, EnvironmentAccessible, DefaultInitializable { +public class HealthMeasurements { // TODO: code example? private let logger = Logger(subsystem: "ENGAGEHF", category: "HealthMeasurements") - public var newMeasurement: ProcessedHealthMeasurement? // TODO: support array of new measurements? (item binding needs write access :/) + // 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? @StandardActor @ObservationIgnored private var standard: any HealthMeasurementsConstraint @Dependency @ObservationIgnored private var bluetooth: Bluetooth? @@ -25,15 +28,17 @@ public class HealthMeasurements: Module, EnvironmentAccessible, DefaultInitializ required public init() {} // TODO: rename! - public func handleNewMeasurement(_ measurement: HealthMeasurement, from device: Device) { + public func handleNewMeasurement(_ measurement: BluetoothHealthMeasurement, from device: Device) { let hkDevice = device.hkDevice switch measurement { case let .weight(measurement, feature): - let sample = measurement.quantitySample(source: hkDevice, resolution: feature.weightResolution) - logger.debug("Measurement loaded: \(measurement.weight)") + let sample = measurement.weightSample(source: hkDevice, resolution: feature.weightResolution) + let bmiSample = measurement.bmiSample(source: hkDevice) + let heightSample = measurement.heightSample(source: hkDevice, resolution: feature.heightResolution) + logger.debug("Measurement loaded: \(String(describing: measurement))") - newMeasurement = .weight(sample) + newMeasurement = .weight(sample, bmi: bmiSample, height: heightSample) case let .bloodPressure(measurement, _): let bloodPressureSample = measurement.bloodPressureSample(source: hkDevice) let heartRateSample = measurement.heartRateSample(source: hkDevice) @@ -49,8 +54,7 @@ public class HealthMeasurements: Module, EnvironmentAccessible, DefaultInitializ } } - // TODO: docs everywhere! - + // 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)) @@ -63,16 +67,9 @@ public class HealthMeasurements: Module, EnvironmentAccessible, DefaultInitializ } logger.info("Saving the following measurement: \(String(describing: measurement))") + do { - switch measurement { - case let .weight(sample): - try await standard.addMeasurement(sample: sample) - case let .bloodPressure(bloodPressureSample, heartRateSample): - try await standard.addMeasurement(sample: bloodPressureSample) - if let heartRateSample { - try await standard.addMeasurement(sample: heartRateSample) - } - } + try await standard.addMeasurement(samples: measurement.samples) } catch { logger.error("Failed to save measurement samples: \(error)") throw error @@ -84,6 +81,9 @@ public class HealthMeasurements: Module, EnvironmentAccessible, DefaultInitializ } +extension HealthMeasurements: Module, EnvironmentAccessible, DefaultInitializable {} + + #if DEBUG || TEST extension HealthMeasurements { /// Call in preview simulator wrappers. diff --git a/Sources/SpeziDevices/Measurements/HealthMeasurementsConstraint.swift b/Sources/SpeziDevices/Measurements/HealthMeasurementsConstraint.swift index 32115bc..b3e15d3 100644 --- a/Sources/SpeziDevices/Measurements/HealthMeasurementsConstraint.swift +++ b/Sources/SpeziDevices/Measurements/HealthMeasurementsConstraint.swift @@ -10,6 +10,18 @@ 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(sample: HKSample) async throws + 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/ProcessedHealthMeasurement.swift b/Sources/SpeziDevices/Measurements/ProcessedHealthMeasurement.swift deleted file mode 100644 index b706a57..0000000 --- a/Sources/SpeziDevices/Measurements/ProcessedHealthMeasurement.swift +++ /dev/null @@ -1,27 +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 - - -public enum ProcessedHealthMeasurement { // TODO: HealthKitSample with Hint? => StructuredHKSample? - case weight(HKQuantitySample) - case bloodPressure(HKCorrelation, heartRate: HKQuantitySample? = nil) -} - - -extension ProcessedHealthMeasurement: Identifiable { - public var id: UUID { - switch self { - case let .weight(sample): - sample.uuid - case let .bloodPressure(sample, _): - sample.uuid - } - } -} diff --git a/Sources/SpeziDevices/Model/DevicePairingError.swift b/Sources/SpeziDevices/Model/DevicePairingError.swift index 7d3cce9..da9da67 100644 --- a/Sources/SpeziDevices/Model/DevicePairingError.swift +++ b/Sources/SpeziDevices/Model/DevicePairingError.swift @@ -14,7 +14,7 @@ import SpeziFoundation public enum DevicePairingError { /// Device is currently in an invalid state. /// - /// For example the device is not disconnected or the advertisement was already discarded. + /// For example the device is not disconnected or the advertisement was not nearby. case invalidState /// The device is busy. /// diff --git a/Sources/SpeziDevices/SpeziDevices.docc/HealthKit.md b/Sources/SpeziDevices/SpeziDevices.docc/HealthKit.md new file mode 100644 index 0000000..4e20bd0 --- /dev/null +++ b/Sources/SpeziDevices/SpeziDevices.docc/HealthKit.md @@ -0,0 +1,31 @@ +# HealthKit + +Convert Bluetooth measurement types to HealthKit samples. + +## Overview + +Text + +### Section header + +Text + +## Topics + +### Device + +- ``HealthDevice/hkDevice`` + +### Blood Pressure Measurement + +- ``SpeziBluetoothServices/BloodPressureMeasurement/Unit/hkUnit`` +- ``SpeziBluetoothServices/BloodPressureMeasurement/bloodPressureSample(source:)`` +- ``SpeziBluetoothServices/BloodPressureMeasurement/heartRateSample(source:)`` + +### Weight Measurement + +- ``SpeziBluetoothServices/WeightMeasurement/Unit/massUnit`` +- ``SpeziBluetoothServices/WeightMeasurement/Unit/lengthUnit`` +- ``SpeziBluetoothServices/WeightMeasurement/weightSample(source:resolution:)`` +- ``SpeziBluetoothServices/WeightMeasurement/bmiSample(source:)`` +- ``SpeziBluetoothServices/WeightMeasurement/heightSample(source:resolution:)`` diff --git a/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md b/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md index 6ac12af..36b33d0 100644 --- a/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md +++ b/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md @@ -31,4 +31,13 @@ SPDX-License-Identifier: MIT - ``DeviceManager`` - ``PairedDeviceInfo`` - ``DevicePairingError`` +- ``PairingContinuation`` - ``ImageReference`` + +### Processing Measurements + +- ``HealthMeasurements`` +- ``HealthMeasurementsConstraint`` +- ``HealthMeasurement`` +- ``ProcessedHealthMeasurement`` +- diff --git a/Sources/SpeziDevices/Testing/MockDevice.swift b/Sources/SpeziDevices/Testing/MockDevice.swift index 75ad5ab..e34581f 100644 --- a/Sources/SpeziDevices/Testing/MockDevice.swift +++ b/Sources/SpeziDevices/Testing/MockDevice.swift @@ -19,7 +19,7 @@ public final class MockDevice: PairableDevice, HealthDevice { @DeviceState(\.name) public var name @DeviceState(\.state) public var state @DeviceState(\.advertisementData) public var advertisementData - @DeviceState(\.discarded) public var discarded + @DeviceState(\.nearby) public var nearby @DeviceAction(\.connect) public var connect @DeviceAction(\.disconnect) public var disconnect @@ -40,6 +40,16 @@ public final class MockDevice: PairableDevice, HealthDevice { extension MockDevice { + /// Create a new Mock Device instance. + /// + /// - Parameters: + /// - name: The name of the device. + /// - state: The initial peripheral state. + /// - bloodPressureMeasurement: The blood pressure measurement loaded into the device. + /// - weightMeasurement: The weight measurement loaded into the device. + /// - weightResolution: The weight resolution to use. + /// - heightResolution: The height resolution to use. + /// - Returns: Returns the initialoued Mock Device. @_spi(TestingSupport) public static func createMockDevice( name: String = "Mock Device", @@ -94,6 +104,17 @@ extension MockDevice { extension BloodPressureMeasurement { + /// Create a mock blood pressure measurement. + /// - Parameters: + /// - systolic: The systolic value. + /// - diastolic: The diastolic value. + /// - meanArterialPressure: The mean arterial perssure. + /// - unit: The unit. + /// - timeStamp: The timestamp. + /// - pulseRate: The pulse rate. + /// - userId: The associated user id. + /// - status: The measurement status. + /// - Returns: @_spi(TestingSupport) public static func mock( systolic: MedFloat16 = 103, @@ -120,6 +141,14 @@ extension BloodPressureMeasurement { extension WeightMeasurement { + /// Create a mock weight measurement. + /// - Parameters: + /// - weight: The weight value. + /// - unit: The unit. + /// - timeStamp: The timestamp. + /// - userId: The associated user id. + /// - additionalInfo: Additional measurement information like BMI and height. + /// - Returns: @_spi(TestingSupport) public static func mock( weight: UInt16 = 8400, diff --git a/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift b/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift index d12e370..b7197f2 100644 --- a/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift +++ b/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift @@ -6,10 +6,7 @@ // SPDX-License-Identifier: MIT // -#if DEBUG -@_spi(TestingSupport) -#endif -import SpeziDevices +@_spi(TestingSupport) import SpeziDevices import SpeziViews import SwiftUI diff --git a/Sources/SpeziDevicesUI/Devices/DeviceTile.swift b/Sources/SpeziDevicesUI/Devices/DeviceTile.swift index 82547b6..4090e6d 100644 --- a/Sources/SpeziDevicesUI/Devices/DeviceTile.swift +++ b/Sources/SpeziDevicesUI/Devices/DeviceTile.swift @@ -6,10 +6,7 @@ // SPDX-License-Identifier: MIT // -#if DEBUG -@_spi(TestingSupport) -#endif -import SpeziDevices +@_spi(TestingSupport) import SpeziDevices import SwiftUI @@ -61,7 +58,7 @@ public struct DeviceTile: View { .foregroundStyle(Color(uiColor: .secondarySystemGroupedBackground)) } .aspectRatio(1.0, contentMode: .fit) // explicit aspect ratio to ensure tile is always square - .accessibilityElement(children: .combine) // TODO: review accessibility! + .accessibilityElement(children: .combine) } /// Create a new device tile view. diff --git a/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift b/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift index cecbb2a..c0024bc 100644 --- a/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift +++ b/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift @@ -6,10 +6,7 @@ // SPDX-License-Identifier: MIT // -#if DEBUG -@_spi(TestingSupport) -#endif -import SpeziDevices +@_spi(TestingSupport) import SpeziDevices import SwiftUI import TipKit diff --git a/Sources/SpeziDevicesUI/Devices/NameEditView.swift b/Sources/SpeziDevicesUI/Devices/NameEditView.swift index 9487ab3..65c0c13 100644 --- a/Sources/SpeziDevicesUI/Devices/NameEditView.swift +++ b/Sources/SpeziDevicesUI/Devices/NameEditView.swift @@ -6,10 +6,7 @@ // SPDX-License-Identifier: MIT // -#if DEBUG -@_spi(TestingSupport) -#endif -import SpeziDevices +@_spi(TestingSupport) import SpeziDevices import SpeziValidation import SwiftUI diff --git a/Sources/SpeziDevicesUI/Measurements/BloodPressureMeasurementLabel.swift b/Sources/SpeziDevicesUI/Measurements/BloodPressureMeasurementLabel.swift index cdd3ca7..95f5f57 100644 --- a/Sources/SpeziDevicesUI/Measurements/BloodPressureMeasurementLabel.swift +++ b/Sources/SpeziDevicesUI/Measurements/BloodPressureMeasurementLabel.swift @@ -7,10 +7,7 @@ // import HealthKit -#if DEBUG -@_spi(TestingSupport) -#endif -import SpeziDevices +@_spi(TestingSupport) import SpeziDevices import SwiftUI @@ -43,6 +40,7 @@ struct BloodPressureMeasurementLabel: View { VStack(spacing: 5) { Text("\(Int(systolic.quantity.doubleValue(for: .millimeterOfMercury())))/\(Int(diastolic.quantity.doubleValue(for: .millimeterOfMercury()))) mmHg") .font(.system(size: measurementTextSize, weight: .bold, design: .rounded)) + .fixedSize(horizontal: false, vertical: true) if let heartRateSample { Text("\(Int(heartRateSample.quantity.doubleValue(for: .count().unitDivided(by: .minute())))) BPM") diff --git a/Sources/SpeziDevicesUI/Measurements/ConfirmMeasurementButton.swift b/Sources/SpeziDevicesUI/Measurements/ConfirmMeasurementButton.swift index e1a730f..d829c60 100644 --- a/Sources/SpeziDevicesUI/Measurements/ConfirmMeasurementButton.swift +++ b/Sources/SpeziDevicesUI/Measurements/ConfirmMeasurementButton.swift @@ -6,8 +6,8 @@ // SPDX-License-Identifier: MIT // -import SpeziViews import SpeziDevices +import SpeziViews import SwiftUI @@ -38,12 +38,13 @@ struct ConfirmMeasurementButton: View { VStack { AsyncButton(state: $viewState, action: confirm) { Text("Save") - .frame(maxWidth: .infinity, maxHeight: buttonHeight) + .frame(maxWidth: .infinity, maxHeight: 35) .font(.title2) .bold() } .buttonStyle(.borderedProminent) - + .padding([.leading, .trailing], 36) + DiscardButton(viewState: $viewState) .padding(.top, 10) } diff --git a/Sources/SpeziDevicesUI/Measurements/MeasurementLayer.swift b/Sources/SpeziDevicesUI/Measurements/MeasurementLayer.swift index 088b928..7def121 100644 --- a/Sources/SpeziDevicesUI/Measurements/MeasurementLayer.swift +++ b/Sources/SpeziDevicesUI/Measurements/MeasurementLayer.swift @@ -7,38 +7,35 @@ // import HealthKit -#if DEBUG -@_spi(TestingSupport) -#endif -import SpeziDevices +@_spi(TestingSupport) import SpeziDevices import SwiftUI struct MeasurementLayer: View { - private let measurement: ProcessedHealthMeasurement + private let measurement: HealthKitMeasurement @Environment(\.dynamicTypeSize) private var dynamicTypeSize var body: some View { VStack(spacing: 15) { switch measurement { - case let .weight(sample): - WeightMeasurementLabel(sample) + case let .weight(sample, bmiSample, heightSample): + WeightMeasurementLabel(sample, bmi: bmiSample, height: heightSample) case let .bloodPressure(bloodPressure, heartRate): BloodPressureMeasurementLabel(bloodPressure, heartRate: heartRate) } - + /* if dynamicTypeSize < .accessibility4 { Text("Measurement Recorded") .font(.title3) .foregroundStyle(.secondary) - } + }*/ } .multilineTextAlignment(.center) } - init(measurement: ProcessedHealthMeasurement) { + init(measurement: HealthKitMeasurement) { self.measurement = measurement } } @@ -49,6 +46,10 @@ struct MeasurementLayer: View { MeasurementLayer(measurement: .weight(.mockWeighSample)) } +#Preview { + MeasurementLayer(measurement: .weight(.mockWeighSample, bmi: .mockBmiSample, height: .mockHeightSample)) +} + #Preview { MeasurementLayer(measurement: .bloodPressure(.mockBloodPressureSample, heartRate: .mockHeartRateSample)) } diff --git a/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift b/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift index e6c9289..3f53d73 100644 --- a/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift +++ b/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift @@ -6,61 +6,71 @@ // SPDX-License-Identifier: MIT // +@_spi(TestingSupport) import SpeziDevices import SpeziViews -#if DEBUG -@_spi(TestingSupport) -#endif -import SpeziDevices import SwiftUI +/// A sheet view displaying a newly recorded measurement. +/// +/// Make sure to pass the ``ProcessedHealthMeasurement`` from the ``HealthMeasurements/newMeasurement``. public struct MeasurementRecordedSheet: View { - private let measurement: ProcessedHealthMeasurement + private let measurement: HealthKitMeasurement @Environment(HealthMeasurements.self) private var measurements @Environment(\.dynamicTypeSize) private var dynamicTypeSize @State private var viewState = ViewState.idle + @State private var dynamicDetent: PresentationDetent = .medium - private var dynamicDetents: PresentationDetent { - switch dynamicTypeSize { - case .xSmall, .small: - return .fraction(0.35) - case .medium, .large: - return .fraction(0.45) - case .xLarge, .xxLarge, .xxxLarge: - return .fraction(0.65) - case .accessibility1, .accessibility2, .accessibility3, .accessibility4, .accessibility5: - return .large - default: - return .fraction(0.45) + private var supportedTypeSize: ClosedRange { + switch measurement { + case .weight: + DynamicTypeSize.xSmall...DynamicTypeSize.accessibility4 + case .bloodPressure: + DynamicTypeSize.xSmall...DynamicTypeSize.accessibility3 } } - public var body: some View { NavigationStack { - VStack { + PaneContent { + Text("Measurement Recorded") + .font(.title) + .fixedSize(horizontal: false, vertical: true) + } content: { MeasurementLayer(measurement: measurement) - Spacer() + } action: { ConfirmMeasurementButton(viewState: $viewState) { try await measurements.saveMeasurement() } } + .background { + GeometryReader { proxy in + Color.clear + .task { + dynamicDetent = .height(proxy.size.height) + } + } + } .viewStateAlert(state: $viewState) .interactiveDismissDisabled(viewState != .idle) .toolbar { - ToolbarItem(placement: .cancellationAction) { - CloseButtonLayer(viewState: $viewState) - .disabled(viewState != .idle) - } + DismissButton() + /*ToolbarItem(placement: .cancellationAction) { + CloseButtonLayer(viewState: $viewState) + .disabled(viewState != .idle) + }*/ } + .dynamicTypeSize(supportedTypeSize) } - .presentationDetents([dynamicDetents]) + .presentationDetents([dynamicDetent]) } - public init(measurement: ProcessedHealthMeasurement) { // TODO: docs! + /// Create a new measurement sheet. + /// - Parameter measurement: The processed measurement to display. + public init(measurement: HealthKitMeasurement) { self.measurement = measurement } } @@ -77,6 +87,16 @@ public struct MeasurementRecordedSheet: View { } } +#Preview { + Text(verbatim: "") + .sheet(isPresented: .constant(true)) { + MeasurementRecordedSheet(measurement: .weight(.mockWeighSample, bmi: .mockBmiSample, height: .mockHeightSample)) + } + .previewWith(standard: TestMeasurementStandard()) { + HealthMeasurements() + } +} + #Preview { Text(verbatim: "") .sheet(isPresented: .constant(true)) { diff --git a/Sources/SpeziDevicesUI/Measurements/WeightMeasurementLabel.swift b/Sources/SpeziDevicesUI/Measurements/WeightMeasurementLabel.swift index a1d0496..d10fadb 100644 --- a/Sources/SpeziDevicesUI/Measurements/WeightMeasurementLabel.swift +++ b/Sources/SpeziDevicesUI/Measurements/WeightMeasurementLabel.swift @@ -7,26 +7,53 @@ // import HealthKit -#if DEBUG -@_spi(TestingSupport) -#endif -import SpeziDevices +@_spi(TestingSupport) import SpeziDevices import SwiftUI struct WeightMeasurementLabel: View { private let sample: HKQuantitySample + private let bmiSample: HKQuantitySample? + private let heightSample: HKQuantitySample? @ScaledMetric private var measurementTextSize: CGFloat = 60 + private var additionalMeasurements: String? { + var string: String? + if let heightSample { + string = "\(Int(heightSample.quantity.doubleValue(for: .meterUnit(with: .centi)))) cm" + } + + if let bmiSample { + string = (string.map { $0 + ", " } ?? "") + + "\(Int(bmiSample.quantity.doubleValue(for: .count()))) BMI" + } + + return string + } + var body: some View { - Text(sample.quantity.description) - .font(.system(size: measurementTextSize, weight: .bold, design: .rounded)) + VStack(spacing: 5) { + Text(sample.quantity.description) + .font(.system(size: measurementTextSize, weight: .bold, design: .rounded)) + .fixedSize(horizontal: false, vertical: true) + + if let additionalMeasurements { + Text(additionalMeasurements) + .accessibilityElement(children: .combine) + .font(.title) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } } - init(_ sample: HKQuantitySample) { + init(_ sample: HKQuantitySample, bmi bmiSample: HKQuantitySample? = nil, height heightSample: HKQuantitySample? = nil) { self.sample = sample + self.bmiSample = bmiSample + self.heightSample = heightSample } } @@ -35,4 +62,16 @@ struct WeightMeasurementLabel: View { #Preview { WeightMeasurementLabel(.mockWeighSample) } + +#Preview { + WeightMeasurementLabel(.mockWeighSample, bmi: .mockBmiSample, height: .mockHeightSample) +} + +#Preview { + WeightMeasurementLabel(.mockWeighSample, bmi: .mockBmiSample) +} + +#Preview { + WeightMeasurementLabel(.mockWeighSample, height: .mockHeightSample) +} #endif diff --git a/Sources/SpeziDevicesUI/Pairing/AccessoryImageView.swift b/Sources/SpeziDevicesUI/Pairing/AccessoryImageView.swift index 7d4ed9b..2efdd36 100644 --- a/Sources/SpeziDevicesUI/Pairing/AccessoryImageView.swift +++ b/Sources/SpeziDevicesUI/Pairing/AccessoryImageView.swift @@ -6,10 +6,7 @@ // SPDX-License-Identifier: MIT // -#if DEBUG -@_spi(TestingSupport) -#endif -import SpeziDevices +@_spi(TestingSupport) import SpeziDevices import SwiftUI diff --git a/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift b/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift index e0e3708..ef01b40 100644 --- a/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift +++ b/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift @@ -7,10 +7,7 @@ // import SpeziBluetooth -#if DEBUG -@_spi(TestingSupport) -#endif -import SpeziDevices +@_spi(TestingSupport) import SpeziDevices import SpeziViews import SwiftUI @@ -43,7 +40,7 @@ public struct AccessorySetupSheet: View wher DiscoveryView() } } - .toolbar { // TODO: where to put that? + .toolbar { DismissButton() } } @@ -71,6 +68,7 @@ public struct AccessorySetupSheet: View wher AccessorySetupSheet([MockDevice.createMockDevice()], appName: "Example") } .previewWith { + Bluetooth {} DeviceManager() } } @@ -86,6 +84,7 @@ public struct AccessorySetupSheet: View wher AccessorySetupSheet(devices, appName: "Example") } .previewWith { + Bluetooth {} DeviceManager() } } diff --git a/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift b/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift index 4d11758..ec12bbb 100644 --- a/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift +++ b/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift @@ -7,10 +7,7 @@ // import ACarousel -#if DEBUG -@_spi(TestingSupport) -#endif -import SpeziDevices +@_spi(TestingSupport) import SpeziDevices import SpeziViews import SwiftUI diff --git a/Sources/SpeziDevicesUI/Pairing/PairedDeviceView.swift b/Sources/SpeziDevicesUI/Pairing/PairedDeviceView.swift index d756e66..3c1bf6d 100644 --- a/Sources/SpeziDevicesUI/Pairing/PairedDeviceView.swift +++ b/Sources/SpeziDevicesUI/Pairing/PairedDeviceView.swift @@ -6,10 +6,7 @@ // SPDX-License-Identifier: MIT // -#if DEBUG -@_spi(TestingSupport) -#endif -import SpeziDevices +@_spi(TestingSupport) import SpeziDevices import SwiftUI diff --git a/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings b/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings index 4a8fc6e..3af7142 100644 --- a/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings +++ b/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings @@ -23,6 +23,9 @@ }, "%lld BPM" : { + }, + "%lld cm" : { + }, "%lld/%lld mmHg" : { "localizations" : { diff --git a/Sources/SpeziDevicesUI/Scanning/BluetoothUnavailableView.swift b/Sources/SpeziDevicesUI/Scanning/BluetoothUnavailableView.swift index 67bbdb2..2a522ad 100644 --- a/Sources/SpeziDevicesUI/Scanning/BluetoothUnavailableView.swift +++ b/Sources/SpeziDevicesUI/Scanning/BluetoothUnavailableView.swift @@ -10,7 +10,8 @@ import SpeziBluetooth import SwiftUI -public struct BluetoothUnavailableView: View { // TODO: missing docs on views! +/// Informational view displaying the reason why Bluetooth is currently not available. +public struct BluetoothUnavailableView: View { private let state: BluetoothState private var titleMessage: LocalizedStringResource? { @@ -63,7 +64,7 @@ public struct BluetoothUnavailableView: View { // TODO: missing docs on views! case .poweredOff, .unauthorized: #if os(iOS) || os(visionOS) || os(tvOS) Button(action: { - if let url = URL(string: UIApplication.openSettingsURLString) { // TODO: this is wrong? + if let url = URL(string: "App-Prefs:root=General") { UIApplication.shared.open(url) } }) { @@ -84,6 +85,8 @@ public struct BluetoothUnavailableView: View { // TODO: missing docs on views! } + /// Display Bluetooth Unavailable View based on the current Bluetooth State. + /// - Parameter state: The current Bluetooth state. public init(_ state: BluetoothState) { self.state = state } diff --git a/Sources/SpeziDevicesUI/Scanning/NearbyDeviceRow.swift b/Sources/SpeziDevicesUI/Scanning/NearbyDeviceRow.swift index 59626ff..9fd4ce2 100644 --- a/Sources/SpeziDevicesUI/Scanning/NearbyDeviceRow.swift +++ b/Sources/SpeziDevicesUI/Scanning/NearbyDeviceRow.swift @@ -8,10 +8,7 @@ import SpeziBluetooth -#if DEBUG -@_spi(TestingSupport) -#endif -import SpeziDevices +@_spi(TestingSupport) import SpeziDevices import SpeziViews import SwiftUI diff --git a/Sources/SpeziDevicesUI/Testing/TestMeasurementStandard.swift b/Sources/SpeziDevicesUI/Testing/TestMeasurementStandard.swift index a1c4c78..c306f01 100644 --- a/Sources/SpeziDevicesUI/Testing/TestMeasurementStandard.swift +++ b/Sources/SpeziDevicesUI/Testing/TestMeasurementStandard.swift @@ -13,8 +13,8 @@ import SpeziDevices #if DEBUG || TEST actor TestMeasurementStandard: Standard, HealthMeasurementsConstraint { - func addMeasurement(sample: HKSample) async throws { - print("Adding sample \(sample)") + func addMeasurement(samples: [HKSample]) async throws { + print("Adding sample \(samples)") } } #endif diff --git a/Sources/SpeziDevicesUI/Utils/PaneContent.swift b/Sources/SpeziDevicesUI/Utils/PaneContent.swift index 4b9c3f1..54dc256 100644 --- a/Sources/SpeziDevicesUI/Utils/PaneContent.swift +++ b/Sources/SpeziDevicesUI/Utils/PaneContent.swift @@ -37,9 +37,9 @@ struct SheetPreview: View { #endif -struct PaneContent: View { - private let title: Text - private let subtitle: Text +struct PaneContent: View { + private let title: Title + private let subtitle: Subtitle? private let content: Content private let action: Action @@ -53,9 +53,11 @@ struct PaneContent: View { .font(.largeTitle) .accessibilityAddTraits(.isHeader) .accessibilityFocused($isHeaderFocused) - subtitle - .font(.subheadline) - .foregroundStyle(.secondary) + if let subtitle { + subtitle + .font(.subheadline) + .foregroundStyle(.secondary) + } } .padding([.leading, .trailing], 20) .multilineTextAlignment(.center) @@ -72,7 +74,20 @@ struct PaneContent: View { } } - init(title: Text, subtitle: Text, @ViewBuilder content: () -> Content, @ViewBuilder action: () -> Action = { EmptyView() }) { + init( + @ViewBuilder title: () -> Title, + @ViewBuilder subtitle: () -> Subtitle = { EmptyView() }, + @ViewBuilder content: () -> Content, + @ViewBuilder action: () -> Action = { EmptyView() } + ) { + self.title = title() + self.subtitle = subtitle() + self.content = content() + self.action = action() + } + + init(title: Text, subtitle: Text? = nil, @ViewBuilder content: () -> Content, @ViewBuilder action: () -> Action = { EmptyView() }) + where Title == Text, Subtitle == Text { self.title = title self.subtitle = subtitle self.content = content() @@ -81,11 +96,11 @@ struct PaneContent: View { init( title: LocalizedStringResource, - subtitle: LocalizedStringResource, + subtitle: LocalizedStringResource? = nil, @ViewBuilder content: () -> Content, @ViewBuilder action: () -> Action = { EmptyView() } - ) { - self.init(title: Text(title), subtitle: Text(subtitle), content: content, action: action) + ) where Title == Text, Subtitle == Text { + self.init(title: Text(title), subtitle: subtitle.map { Text($0) }, content: content, action: action) } } diff --git a/Sources/SpeziOmron/Characteristic/CharacteristicAccessor+OmronRecordAccess.swift b/Sources/SpeziOmron/Characteristic/CharacteristicAccessor+OmronRecordAccess.swift index 9a31350..4ede3af 100644 --- a/Sources/SpeziOmron/Characteristic/CharacteristicAccessor+OmronRecordAccess.swift +++ b/Sources/SpeziOmron/Characteristic/CharacteristicAccessor+OmronRecordAccess.swift @@ -7,8 +7,7 @@ // import SpeziBluetooth -@_spi(APISupport) -import SpeziBluetoothServices +@_spi(APISupport) import SpeziBluetoothServices extension CharacteristicAccessor where Value == RecordAccessControlPoint { From df765bacf3fdfcc860ffef2d5a0f3d6e8a78d39c Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Mon, 24 Jun 2024 18:03:58 +0200 Subject: [PATCH 18/77] Rename and provide new configure interface --- .../SpeziDevices/Devices/PairableDevice.swift | 4 +- .../HealthMeasurements.swift | 0 ...eviceManager.swift => PairedDevices.swift} | 127 ++++++++++++++---- .../SpeziDevices.docc/SpeziDevices.md | 2 +- .../Devices/DeviceDetailsView.swift | 12 +- .../SpeziDevicesUI/Devices/DeviceTile.swift | 7 +- .../SpeziDevicesUI/Devices/DevicesGrid.swift | 4 +- .../SpeziDevicesUI/Devices/DevicesTab.swift | 19 +-- .../Pairing/AccessorySetupSheet.swift | 12 +- .../Pairing/PairDeviceView.swift | 1 - .../SpeziDevicesUI/Utils/CarouselDots.swift | 2 +- .../SpeziDevicesUI/Utils/PaneContent.swift | 4 +- 12 files changed, 133 insertions(+), 61 deletions(-) rename Sources/SpeziDevices/{Measurements => }/HealthMeasurements.swift (100%) rename Sources/SpeziDevices/{DeviceManager.swift => PairedDevices.swift} (68%) diff --git a/Sources/SpeziDevices/Devices/PairableDevice.swift b/Sources/SpeziDevices/Devices/PairableDevice.swift index a468e51..6613347 100644 --- a/Sources/SpeziDevices/Devices/PairableDevice.swift +++ b/Sources/SpeziDevices/Devices/PairableDevice.swift @@ -62,7 +62,7 @@ public protocol PairableDevice: GenericDevice { /// Particularly, you must call the ``PairingContinuation/signalPaired()`` and ``PairingContinuation/signalDisconnect()`` /// methods when appropriate. /// - Throws: Throws a ``DevicePairingError`` if not successful. - func pair() async throws + func pair() async throws // TODO: make a pair(with:) (passing the DevicePairings?) so the PairedDevicesx module manages the continuations? } @@ -83,7 +83,7 @@ extension PairableDevice { /// 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 { + public func pair() async throws { // TODO: just move the whole method to the PairedDevices thing! guard isInPairingMode else { throw DevicePairingError.notInPairingMode } diff --git a/Sources/SpeziDevices/Measurements/HealthMeasurements.swift b/Sources/SpeziDevices/HealthMeasurements.swift similarity index 100% rename from Sources/SpeziDevices/Measurements/HealthMeasurements.swift rename to Sources/SpeziDevices/HealthMeasurements.swift diff --git a/Sources/SpeziDevices/DeviceManager.swift b/Sources/SpeziDevices/PairedDevices.swift similarity index 68% rename from Sources/SpeziDevices/DeviceManager.swift rename to Sources/SpeziDevices/PairedDevices.swift index 754cb8b..58b1ffc 100644 --- a/Sources/SpeziDevices/DeviceManager.swift +++ b/Sources/SpeziDevices/PairedDevices.swift @@ -14,14 +14,15 @@ import SwiftUI @Observable -public final class DeviceManager: Module, EnvironmentAccessible, DefaultInitializable { // TODO: "PairedDevices" rename? +public final class PairedDevices: Module, EnvironmentAccessible, DefaultInitializable { /// Determines if the device discovery sheet should be presented. - @MainActor public var presentingDevicePairing = false // TODO: "should" naming + @MainActor public var shouldPresentDevicePairing = false @MainActor public private(set) var discoveredDevices: OrderedDictionary = [:] - @MainActor @ObservationIgnored private var _pairedDevices: [PairedDeviceInfo] = [] // TODO: @AppStorage("pairedDevices") @MainActor private(set) var peripherals: [UUID: any PairableDevice] = [:] + // TODO: @AppStorage("pairedDevices") => we are missing the RawRepresentable extension from the TemplateApp! + @MainActor @ObservationIgnored private var _pairedDevices: [PairedDeviceInfo] = [] @MainActor public var pairedDevices: [PairedDeviceInfo] { get { access(keyPath: \.pairedDevices) @@ -35,8 +36,8 @@ public final class DeviceManager: Module, EnvironmentAccessible, DefaultInitiali } - @MainActor public var scanningNearbyDevices: Bool { // TODO: isScanningForNearby! - pairedDevices.isEmpty || presentingDevicePairing + @MainActor public var isScanningForNearbyDevices: Bool { + pairedDevices.isEmpty || shouldPresentDevicePairing } @Application(\.logger) @ObservationIgnored private var logger @@ -54,34 +55,55 @@ public final class DeviceManager: Module, EnvironmentAccessible, DefaultInitiali public required init() {} // TODO: configure automatic search without devices paired! public func configure() { - guard let bluetooth else { - self.logger.warning("DeviceManager initialized without Bluetooth dependency!") + guard bluetooth != nil else { + self.logger.warning("PairedDevices Module initialized without Bluetooth dependency!") return // useful for e.g. previews } - // TODO: bit weird API wise! - // We need to detach to not copy task local values + // We need to detach to not copy task local values Task.detached { @MainActor in - // TODO: cancel all that if the last device is forgotten? - self.stateSubscriptionTask = Task.detached { [weak self] in - for await nextState in await bluetooth.stateSubscription { - print("Bluetooth Module state is now \(nextState)") - // TODO: asdasd, do something with that! - // TODO: actually do something with that! - } - } - guard !self.pairedDevices.isEmpty else { return // no devices paired, no need to power up central } - // TODO: we need to redo this once bluetooth powers on? - // TODO: stateSubcription + powerOn() call! - await bluetooth.powerOn() // TODO: should we do that on first connected device? - if case .poweredOn = bluetooth.state { - await self.handleCentralPoweredOn() + await self.setupBluetoothStateSubscription() + } + } + + // TODO: move to subscription handling extensions + @MainActor + private func setupBluetoothStateSubscription() async { + assert(!pairedDevices.isEmpty, "Bluetooth State subscription doesn't need to be set up without any paired devices.") + + guard let bluetooth else { + return + } + + // If Bluetooth is currently turned off in control center or not authorized anymore, we would want to keep central allocated + // such that we are notified about the bluetooth state changing. + await bluetooth.powerOn() + + self.stateSubscriptionTask = Task.detached { [weak self] in + for await nextState in await bluetooth.stateSubscription { + guard let self else { + return + } + await self.handleBluetoothStateChanged(nextState) } } + + if case .poweredOn = bluetooth.state { + await self.handleCentralPoweredOn() + } + } + + @MainActor + private func cancelSubscription() async { + assert(pairedDevices.isEmpty, "Bluetooth State subscription was tried to be cancelled even though devices were still paired.") + assert(peripherals.isEmpty, "Peripherals were unexpectedly not empty.") + + stateSubscriptionTask = nil + await bluetooth?.powerOff() } @MainActor @@ -94,6 +116,33 @@ public final class DeviceManager: Module, EnvironmentAccessible, DefaultInitiali pairedDevices.contains { $0.id == device.id } // TODO: more efficient lookup! } + /// Configure a device to be managed by this PairedDevices instance. + public func configure( // TODO: docs code example, docs parameters + device: Device, + accessing state: DeviceStateAccessor, + _ advertisements: DeviceStateAccessor, + _ nearby: DeviceStateAccessor + ) { + state.onChange { [weak self, weak device] state in + if let device { + await self?.handleDeviceStateUpdated(device, state) + } + } + advertisements.onChange(initial: true) { [weak self, weak device] _ in + guard let device else { + return + } + if device.isInPairingMode { + await self?.nearbyPairableDevice(device) + } + } + nearby.onChange { [weak self, weak device] nearby in + if let device, !nearby { + await self?.handleDiscardedDevice(device) + } + } + } + @MainActor public func handleDeviceStateUpdated(_ device: Device, _ state: PeripheralState) { guard case .disconnected = state else { @@ -108,7 +157,6 @@ public final class DeviceManager: Module, EnvironmentAccessible, DefaultInitiali pairedDevices[deviceInfoIndex].lastSeen = .now Task { - // TODO: log? await device.connect() // TODO: handle something about that?, reuse with configure method? } } @@ -127,12 +175,12 @@ public final class DeviceManager: Module, EnvironmentAccessible, DefaultInitiali // TODO: previously we logged the manufacturer data! discoveredDevices[device.id] = device - presentingDevicePairing = true + shouldPresentDevicePairing = true } @MainActor - public func registerPairedDevice(_ device: Device) async { + public func registerPairedDevice(_ device: Device) async { // TODO: registerPaired(device:)? var batteryLevel: UInt8? if let batteryDevice = device as? any BatteryPoweredDevice { batteryLevel = batteryDevice.battery.batteryLevel @@ -166,6 +214,10 @@ public final class DeviceManager: Module, EnvironmentAccessible, DefaultInitiali peripherals[device.id] = device self.logger.debug("Device \(device.label) with id \(device.id) is now paired!") + + if stateSubscriptionTask == nil { + await setupBluetoothStateSubscription() + } } @MainActor @@ -187,19 +239,34 @@ public final class DeviceManager: Module, EnvironmentAccessible, DefaultInitiali await device.disconnect() } } + + if pairedDevices.isEmpty { + Task { + await cancelSubscription() + } + } // TODO: make sure to remove them from discoveredDevices? => should happen automatically? } @MainActor public func updateBattery(for device: Device, percentage: UInt8) { + // TODO: with new model we can register our own onChange listeners! guard let index = pairedDevices.firstIndex(where: { $0.id == device.id }) else { return } pairedDevices[index].lastBatteryPercentage = percentage } - private func handleBluetoothStateChanged(_ state: BluetoothState) { - + @MainActor + private func handleBluetoothStateChanged(_ state: BluetoothState) async { + logger.debug("Bluetooth Module state is now \(state)") + + switch state { + case .poweredOn: + await handleCentralPoweredOn() + default: + peripherals.removeAll() // TODO: is that correct? + } } @MainActor @@ -208,7 +275,9 @@ public final class DeviceManager: Module, EnvironmentAccessible, DefaultInitiali return } - // TODO: check powered on? + guard case .poweredOn = bluetooth.state else { + return + } // we just reuse the configured Bluetooth devices let configuredDevices = bluetooth.configuredPairableDevices diff --git a/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md b/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md index 36b33d0..cef0823 100644 --- a/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md +++ b/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md @@ -28,7 +28,7 @@ SPDX-License-Identifier: MIT ### Device Pairing -- ``DeviceManager`` +- ``PairableDevices`` - ``PairedDeviceInfo`` - ``DevicePairingError`` - ``PairingContinuation`` diff --git a/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift b/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift index b7197f2..0454169 100644 --- a/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift +++ b/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift @@ -14,7 +14,7 @@ import SwiftUI /// Show the device details of a paired device. public struct DeviceDetailsView: View { @Environment(\.dismiss) private var dismiss - @Environment(DeviceManager.self) private var deviceManager + @Environment(PairedDevices.self) private var pairedDevices @Binding private var deviceInfo: PairedDeviceInfo @State private var presentForgetConfirmation = false @@ -51,7 +51,7 @@ public struct DeviceDetailsView: View { presentForgetConfirmation = true } } footer: { - if deviceManager.isConnected(device: deviceInfo.id) { + if pairedDevices.isConnected(device: deviceInfo.id) { Text("Synchronizing ...") } else if lastSeenToday { Text("This device was last seen at \(Text(deviceInfo.lastSeen, style: .time))") @@ -66,13 +66,13 @@ public struct DeviceDetailsView: View { Button("Forget Device", role: .destructive) { // TODO: message to check for ConfigureTipKit dependency! ForgetDeviceTip.hasRemovedPairedDevice = true - deviceManager.forgetDevice(id: deviceInfo.id) + pairedDevices.forgetDevice(id: deviceInfo.id) dismiss() } Button("Cancel", role: .cancel) {} } .toolbar { - if deviceManager.isConnected(device: deviceInfo.id) { + if pairedDevices.isConnected(device: deviceInfo.id) { ToolbarItem(placement: .primaryAction) { ProgressView() } @@ -131,7 +131,7 @@ public struct DeviceDetailsView: View { )) } .previewWith { - DeviceManager() + PairedDevices() } } @@ -150,7 +150,7 @@ public struct DeviceDetailsView: View { )) } .previewWith { - DeviceManager() + PairedDevices() } } #endif diff --git a/Sources/SpeziDevicesUI/Devices/DeviceTile.swift b/Sources/SpeziDevicesUI/Devices/DeviceTile.swift index 4090e6d..23e1ffb 100644 --- a/Sources/SpeziDevicesUI/Devices/DeviceTile.swift +++ b/Sources/SpeziDevicesUI/Devices/DeviceTile.swift @@ -14,7 +14,7 @@ import SwiftUI public struct DeviceTile: View { private let deviceInfo: PairedDeviceInfo - @Environment(DeviceManager.self) private var deviceManager + @Environment(PairedDevices.self) private var pairedDevices private var image: Image { deviceInfo.icon?.image ?? Image(systemName: "sensor") // swiftlint:disable:this accessibility_label_for_image @@ -32,7 +32,7 @@ public struct DeviceTile: View { .frame(minWidth: 0, maxWidth: 100, minHeight: 0, maxHeight: 120, alignment: .topLeading) Spacer() - if deviceManager.isConnected(device: deviceInfo.id) { + if pairedDevices.isConnected(device: deviceInfo.id) { ProgressView() } } @@ -59,6 +59,7 @@ public struct DeviceTile: View { } .aspectRatio(1.0, contentMode: .fit) // explicit aspect ratio to ensure tile is always square .accessibilityElement(children: .combine) + .accessibilityRemoveTraits(.isImage) // otherwise Voice Over will try to read text recognized in the image } /// Create a new device tile view. @@ -93,7 +94,7 @@ public struct DeviceTile: View { .frame(maxHeight: .infinity) .background(Color(uiColor: .systemGroupedBackground)) .previewWith { - DeviceManager() + PairedDevices() } } #endif diff --git a/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift b/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift index c0024bc..e8f0a1d 100644 --- a/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift +++ b/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift @@ -108,7 +108,7 @@ extension Binding: Hashable, Equatable where Value: Hashable { try? Tips.configure() // TODO: use ConfigureTipKit Module } .previewWith { - DeviceManager() + PairedDevices() } } @@ -126,7 +126,7 @@ extension Binding: Hashable, Equatable where Value: Hashable { try? Tips.configure() // TODO: use ConfigureTipKit Module } .previewWith { - DeviceManager() + PairedDevices() } } #endif diff --git a/Sources/SpeziDevicesUI/Devices/DevicesTab.swift b/Sources/SpeziDevicesUI/Devices/DevicesTab.swift index 772f144..96560dc 100644 --- a/Sources/SpeziDevicesUI/Devices/DevicesTab.swift +++ b/Sources/SpeziDevicesUI/Devices/DevicesTab.swift @@ -16,22 +16,25 @@ public struct DevicesTab: View { private let appName: String @Environment(Bluetooth.self) private var bluetooth - @Environment(DeviceManager.self) private var deviceManager + @Environment(PairedDevices.self) private var pairedDevices @State private var path = NavigationPath() // TODO: can we remove that? if so, might want to remove the NavigationStack from view! public var body: some View { - @Bindable var deviceManager = deviceManager + @Bindable var pairedDevices = pairedDevices NavigationStack(path: $path) { // TODO: not really reusable because of the navigation stack!!! - DevicesGrid(devices: $deviceManager.pairedDevices, navigation: $path, presentingDevicePairing: $deviceManager.presentingDevicePairing) - .scanNearbyDevices(enabled: deviceManager.scanningNearbyDevices, with: bluetooth) // automatically search if no devices are paired - .sheet(isPresented: $deviceManager.presentingDevicePairing) { - AccessorySetupSheet(deviceManager.discoveredDevices.values, appName: appName) + DevicesGrid(devices: $pairedDevices.pairedDevices, navigation: $path, presentingDevicePairing: $pairedDevices.shouldPresentDevicePairing) + // TODO: advertisementStaleInterval: 15 + // automatically search if no devices are paired + .scanNearbyDevices(enabled: pairedDevices.isScanningForNearbyDevices, with: bluetooth) + // TODO: automatic pop-up is a bit unexpected! + .sheet(isPresented: $pairedDevices.shouldPresentDevicePairing) { + AccessorySetupSheet(pairedDevices.discoveredDevices.values, appName: appName) } .toolbar { // indicate that we are scanning in the background - if deviceManager.scanningNearbyDevices && !deviceManager.presentingDevicePairing { + if pairedDevices.isScanningForNearbyDevices && !pairedDevices.shouldPresentDevicePairing { ToolbarItem(placement: .cancellationAction) { // TODO: shall we do primary action (what about order then?) ProgressView() } @@ -53,7 +56,7 @@ public struct DevicesTab: View { DevicesTab(appName: "Example") .previewWith { Bluetooth {} - DeviceManager() + PairedDevices() } } #endif diff --git a/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift b/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift index ef01b40..6ad9521 100644 --- a/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift +++ b/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift @@ -18,7 +18,7 @@ public struct AccessorySetupSheet: View wher private let appName: String @Environment(Bluetooth.self) private var bluetooth - @Environment(DeviceManager.self) private var deviceManager + @Environment(PairedDevices.self) private var pairedDevices @Environment(\.dismiss) private var dismiss @State private var pairingState: PairingViewState = .discovery @@ -34,7 +34,7 @@ public struct AccessorySetupSheet: View wher } else if !devices.isEmpty { PairDeviceView(devices: devices, appName: appName, state: $pairingState) { device in try await device.pair() - await deviceManager.registerPairedDevice(device) + await pairedDevices.registerPairedDevice(device) } } else { DiscoveryView() @@ -44,7 +44,7 @@ public struct AccessorySetupSheet: View wher DismissButton() } } - .scanNearbyDevices(with: bluetooth) + .scanNearbyDevices(with: bluetooth) // TODO: advertisementStaleInterval: 15 .presentationDetents([.medium]) .presentationCornerRadius(25) .interactiveDismissDisabled() @@ -69,7 +69,7 @@ public struct AccessorySetupSheet: View wher } .previewWith { Bluetooth {} - DeviceManager() + PairedDevices() } } @@ -85,7 +85,7 @@ public struct AccessorySetupSheet: View wher } .previewWith { Bluetooth {} - DeviceManager() + PairedDevices() } } @@ -96,7 +96,7 @@ public struct AccessorySetupSheet: View wher } .previewWith { Bluetooth {} - DeviceManager() + PairedDevices() } } #endif diff --git a/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift b/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift index ec12bbb..62ccd05 100644 --- a/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift +++ b/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift @@ -37,7 +37,6 @@ struct PairDeviceView: View where Collection } var body: some View { - // TODO: replace application Name everywhere! PaneContent(title: "Pair Accessory", subtitle: "Do you want to pair \(selectedDeviceName) with the \(appName) app?") { if devices.count > 1 { ACarousel(devices, id: \.id, index: $selectedDeviceIndex, spacing: 0, headspace: 0) { device in diff --git a/Sources/SpeziDevicesUI/Utils/CarouselDots.swift b/Sources/SpeziDevicesUI/Utils/CarouselDots.swift index 5785db8..7350ee2 100644 --- a/Sources/SpeziDevicesUI/Utils/CarouselDots.swift +++ b/Sources/SpeziDevicesUI/Utils/CarouselDots.swift @@ -86,7 +86,7 @@ struct CarouselDots: View { let relativePosition = location.x let index = max(0, min(count - 1, Int(relativePosition / pointWidths))) - selectedIndex = index // TODO: this should not animate? + selectedIndex = index } } diff --git a/Sources/SpeziDevicesUI/Utils/PaneContent.swift b/Sources/SpeziDevicesUI/Utils/PaneContent.swift index 54dc256..1bf06c6 100644 --- a/Sources/SpeziDevicesUI/Utils/PaneContent.swift +++ b/Sources/SpeziDevicesUI/Utils/PaneContent.swift @@ -69,8 +69,8 @@ struct PaneContent: Vi action } .task { - try? await Task.sleep(for: .milliseconds(500)) - isHeaderFocused = true // TODO: doesn't work too great? + try? await Task.sleep(for: .milliseconds(300)) + isHeaderFocused = true } } From 02fd0c9810638c900d88cc14c38e5e0ecdc4a8e9 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Mon, 24 Jun 2024 18:13:46 +0200 Subject: [PATCH 19/77] Provide similar mechnaism for health measurements --- Sources/SpeziDevices/HealthMeasurements.swift | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/Sources/SpeziDevices/HealthMeasurements.swift b/Sources/SpeziDevices/HealthMeasurements.swift index aa0bf73..4fbeb3a 100644 --- a/Sources/SpeziDevices/HealthMeasurements.swift +++ b/Sources/SpeziDevices/HealthMeasurements.swift @@ -10,6 +10,7 @@ import Foundation import OSLog import Spezi import SpeziBluetooth +import SpeziBluetoothServices /// Manage and process incoming health measurements. @@ -27,7 +28,25 @@ public class HealthMeasurements { // TODO: code example? required public init() {} - // TODO: rename! + public func configureReceivingMeasurements(for device: Device, on service: WeightScaleService) { + service.$weightMeasurement.onChange { [weak self, weak device, weak service] measurement in + guard let device, let service else { + return + } + self?.handleNewMeasurement(.weight(measurement, service.features ?? []), from: device) + } + } + + public func configureReceivingMeasurements(for device: Device, on service: BloodPressureService) { + service.$bloodPressureMeasurement.onChange { [weak self, weak device, weak service] measurement in + guard let device, let service else { + return + } + self?.handleNewMeasurement(.bloodPressure(measurement, service.features ?? []), from: device) + } + } + + // TODO: rename! make private? public func handleNewMeasurement(_ measurement: BluetoothHealthMeasurement, from device: Device) { let hkDevice = device.hkDevice From dbc96bf2962eaa0e1c220d5a6b7cf1052f1ff330 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Mon, 24 Jun 2024 23:52:34 +0200 Subject: [PATCH 20/77] Resolve a lot of todos and restructure some parts --- Package.swift | 18 +- .../SpeziDevices/Devices/HealthDevice.swift | 24 +- .../Health/HealthDevice+HKDevice.swift | 26 -- Sources/SpeziDevices/HealthMeasurements.swift | 63 ++-- .../SpeziDevices/Model/PairedDeviceInfo.swift | 63 +++- .../Model/SavableCollection.swift | 85 ++++++ Sources/SpeziDevices/PairedDevices.swift | 270 +++++++++++------- Sources/SpeziDevices/Testing/MockDevice.swift | 8 +- .../Devices/DeviceDetailsView.swift | 48 ++-- .../SpeziDevicesUI/Devices/DevicesGrid.swift | 47 +-- .../SpeziDevicesUI/Devices/DevicesTab.swift | 45 ++- .../SpeziDevicesUI/Devices/NameEditView.swift | 33 ++- .../MeasurementRecordedSheet.swift | 2 + .../Pairing/AccessorySetupSheet.swift | 14 +- .../Pairing/PairDeviceView.swift | 3 +- .../SpeziDevicesUI/Tips/ForgetDeviceTip.swift | 2 +- 16 files changed, 477 insertions(+), 274 deletions(-) delete mode 100644 Sources/SpeziDevices/Health/HealthDevice+HKDevice.swift create mode 100644 Sources/SpeziDevices/Model/SavableCollection.swift diff --git a/Package.swift b/Package.swift index 93eaff9..f92d649 100644 --- a/Package.swift +++ b/Package.swift @@ -12,7 +12,6 @@ import PackageDescription // TODO: DOI in citation.cff -let swiftLintPlugin: Target.PluginUsage = .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLint") let package = Package( name: "SpeziDevices", @@ -32,7 +31,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-collections.git", from: "1.1.1"), .package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "1.1.1"), - .package(url: "https://github.com/StanfordSpezi/SpeziViews.git", from: "1.4.0"), + .package(url: "https://github.com/StanfordSpezi/SpeziViews.git", branch: "feature/configure-tipkit-module"), .package(url: "https://github.com/StanfordSpezi/SpeziBluetooth", branch: "feature/accessory-discovery"), .package(url: "https://github.com/StanfordSpezi/SpeziNetworking", from: "2.0.0"), .package(url: "https://github.com/JWAutumn/ACarousel", .upToNextMinor(from: "0.2.0")), @@ -47,7 +46,7 @@ let package = Package( .product(name: "SpeziBluetooth", package: "SpeziBluetooth"), .product(name: "SpeziBluetoothServices", package: "SpeziBluetooth") ], - plugins: [swiftLintPlugin] + plugins: [.swiftLintPlugin] ), .target( name: "SpeziDevicesUI", @@ -61,7 +60,7 @@ let package = Package( resources: [ .process("Resources") ], - plugins: [swiftLintPlugin] + plugins: [.swiftLintPlugin] ), .target( name: "SpeziOmron", @@ -70,7 +69,7 @@ let package = Package( .product(name: "SpeziBluetooth", package: "SpeziBluetooth"), .product(name: "SpeziBluetoothServices", package: "SpeziBluetooth") ], - plugins: [swiftLintPlugin] + plugins: [.swiftLintPlugin] ), .testTarget( name: "SpeziOmronTests", @@ -79,7 +78,14 @@ let package = Package( .product(name: "SpeziBluetooth", package: "SpeziBluetooth"), .product(name: "XCTByteCoding", package: "SpeziNetworking") ], - plugins: [swiftLintPlugin] + plugins: [.swiftLintPlugin] ) ] ) + + +extension Target.PluginUsage { + static var swiftLintPlugin: Target.PluginUsage { + .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLint") + } +} diff --git a/Sources/SpeziDevices/Devices/HealthDevice.swift b/Sources/SpeziDevices/Devices/HealthDevice.swift index b5752c9..c0b0bde 100644 --- a/Sources/SpeziDevices/Devices/HealthDevice.swift +++ b/Sources/SpeziDevices/Devices/HealthDevice.swift @@ -10,4 +10,26 @@ import HealthKit /// A generic Bluetooth Health device. -public protocol HealthDevice: GenericDevice {} +public protocol HealthDevice: GenericDevice { + /// The HealthKit device description. + var hkDevice: HKDevice { get } +} + + +extension HealthDevice { + /// The HealthKit device description. + /// + /// Default implementation using the `DeviceInformationService`. + public var hkDevice: HKDevice { + HKDevice( + name: name, + manufacturer: deviceInformation.manufacturerName, + model: deviceInformation.modelNumber, + hardwareVersion: deviceInformation.hardwareRevision, + firmwareVersion: deviceInformation.firmwareRevision, + softwareVersion: deviceInformation.softwareRevision, + localIdentifier: nil, + udiDeviceIdentifier: nil + ) + } +} diff --git a/Sources/SpeziDevices/Health/HealthDevice+HKDevice.swift b/Sources/SpeziDevices/Health/HealthDevice+HKDevice.swift deleted file mode 100644 index bf0b7d8..0000000 --- a/Sources/SpeziDevices/Health/HealthDevice+HKDevice.swift +++ /dev/null @@ -1,26 +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 - - -extension HealthDevice { - /// The HealthKit Device description. - public var hkDevice: HKDevice { - HKDevice( - name: name, - manufacturer: deviceInformation.manufacturerName, - model: deviceInformation.modelNumber, - hardwareVersion: deviceInformation.hardwareRevision, - firmwareVersion: deviceInformation.firmwareRevision, - softwareVersion: deviceInformation.softwareRevision, - localIdentifier: nil, - udiDeviceIdentifier: nil - ) - } -} diff --git a/Sources/SpeziDevices/HealthMeasurements.swift b/Sources/SpeziDevices/HealthMeasurements.swift index 4fbeb3a..82e0a80 100644 --- a/Sources/SpeziDevices/HealthMeasurements.swift +++ b/Sources/SpeziDevices/HealthMeasurements.swift @@ -7,6 +7,7 @@ // import Foundation +import HealthKit import OSLog import Spezi import SpeziBluetooth @@ -14,6 +15,12 @@ import SpeziBluetoothServices /// Manage and process incoming health measurements. +/// +/// ## Topics +/// +/// ### Register Devices +/// - ``configureReceivingMeasurements(for:on:)-8cbd0`` +/// - ``configureReceivingMeasurements(for:on:)-87sgc`` @Observable public class HealthMeasurements { // TODO: code example? private let logger = Logger(subsystem: "ENGAGEHF", category: "HealthMeasurements") @@ -26,41 +33,61 @@ public class HealthMeasurements { // TODO: code example? @StandardActor @ObservationIgnored private var standard: any HealthMeasurementsConstraint @Dependency @ObservationIgnored private var bluetooth: Bluetooth? - required public init() {} + /// Initialize the Health Measurements Module. + public required init() {} + /// Configure receiving and processing weight measurements from the provided service. + /// + /// Configures the device's weight measurements to be processed by the Health Measurements module. + /// + /// - Parameters: + /// - device: The device on which the service is present. + /// - service: The Weight Scale service to register. public func configureReceivingMeasurements(for device: Device, on service: WeightScaleService) { - service.$weightMeasurement.onChange { [weak self, weak device, weak service] measurement in - guard let device, let service else { + let hkDevice = device.hkDevice + + // make sure to not capture the device + service.$weightMeasurement.onChange { [weak self, weak service] measurement in + guard let self, let service else { return } - self?.handleNewMeasurement(.weight(measurement, service.features ?? []), from: device) + logger.debug("Received new weight measurement: \(String(describing: measurement))") + handleNewMeasurement(.weight(measurement, service.features ?? []), from: hkDevice) } } + /// Configure receiving and processing blood pressure measurements form the provided service. + /// + /// Configures the device's blood pressure measurements to be processed by the Health Measurements module. + /// + /// - Parameters: + /// - device: The device on which the service is present. + /// - service: The Blood Pressure service to register. public func configureReceivingMeasurements(for device: Device, on service: BloodPressureService) { - service.$bloodPressureMeasurement.onChange { [weak self, weak device, weak service] measurement in - guard let device, let service else { + let hkDevice = device.hkDevice + + // make sure to not capture the device + service.$bloodPressureMeasurement.onChange { [weak self, weak service] measurement in + guard let self, let service else { return } - self?.handleNewMeasurement(.bloodPressure(measurement, service.features ?? []), from: device) + logger.debug("Received new blood pressure measurement: \(String(describing: measurement))") + handleNewMeasurement(.bloodPressure(measurement, service.features ?? []), from: hkDevice) } } - // TODO: rename! make private? - public func handleNewMeasurement(_ measurement: BluetoothHealthMeasurement, from device: Device) { - let hkDevice = device.hkDevice - + private func handleNewMeasurement(_ measurement: BluetoothHealthMeasurement, from source: HKDevice) { switch measurement { case let .weight(measurement, feature): - let sample = measurement.weightSample(source: hkDevice, resolution: feature.weightResolution) - let bmiSample = measurement.bmiSample(source: hkDevice) - let heightSample = measurement.heightSample(source: hkDevice, resolution: feature.heightResolution) + let sample = measurement.weightSample(source: source, resolution: feature.weightResolution) + let bmiSample = measurement.bmiSample(source: source) + let heightSample = measurement.heightSample(source: source, resolution: feature.heightResolution) logger.debug("Measurement loaded: \(String(describing: measurement))") newMeasurement = .weight(sample, bmi: bmiSample, height: heightSample) case let .bloodPressure(measurement, _): - let bloodPressureSample = measurement.bloodPressureSample(source: hkDevice) - let heartRateSample = measurement.heartRateSample(source: hkDevice) + let bloodPressureSample = measurement.bloodPressureSample(source: source) + let heartRateSample = measurement.heartRateSample(source: source) guard let bloodPressureSample else { logger.debug("Discarding invalid blood pressure measurement ...") @@ -116,7 +143,7 @@ extension HealthMeasurements { preconditionFailure("Mock Weight Measurement was never injected!") } - handleNewMeasurement(.weight(measurement, device.weightScale.features ?? []), from: device) + handleNewMeasurement(.weight(measurement, device.weightScale.features ?? []), from: device.hkDevice) } /// Call in preview simulator wrappers. @@ -130,7 +157,7 @@ extension HealthMeasurements { preconditionFailure("Mock Blood Pressure Measurement was never injected!") } - handleNewMeasurement(.bloodPressure(measurement, device.bloodPressure.features ?? []), from: device) + handleNewMeasurement(.bloodPressure(measurement, device.bloodPressure.features ?? []), from: device.hkDevice) } } #endif diff --git a/Sources/SpeziDevices/Model/PairedDeviceInfo.swift b/Sources/SpeziDevices/Model/PairedDeviceInfo.swift index 8be3768..9fc0304 100644 --- a/Sources/SpeziDevices/Model/PairedDeviceInfo.swift +++ b/Sources/SpeziDevices/Model/PairedDeviceInfo.swift @@ -10,12 +10,8 @@ import Foundation /// Persistent information stored of a paired device. -public struct PairedDeviceInfo { - // TODO: observablen => resolves UI update issue! - // TODO: update properties (model, lastSeen, battery) with Observation framework and not via explicit calls in the device class - // => make some things have internal setters(?) - // TODO: additionalData: lastSequenceNumber: UInt16?, userDatabaseNumber: UInt32?, consentCode: UIntX - +@Observable +public class PairedDeviceInfo { /// The CoreBluetooth device identifier. public let id: UUID /// The device type. @@ -28,13 +24,14 @@ public struct PairedDeviceInfo { public let model: String? /// The user edit-able name of the device. - public var name: String + public internal(set) var name: String /// The date the device was last seen. - public var lastSeen: Date + public internal(set) var lastSeen: Date /// The last reported battery percentage of the device. - public var lastBatteryPercentage: UInt8? + 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: @@ -62,13 +59,59 @@ public struct PairedDeviceInfo { self.lastSeen = lastSeen self.lastBatteryPercentage = batteryPercentage } + + public required convenience init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + try self.init( + id: container.decode(UUID.self, forKey: .id), + deviceType: container.decode(String.self, forKey: .deviceType), + name: container.decode(String.self, forKey: .name), + model: container.decodeIfPresent(String.self, forKey: .name), + icon: container.decodeIfPresent(ImageReference.self, forKey: .icon), + lastSeen: container.decode(Date.self, forKey: .lastSeen), + batteryPercentage: container.decodeIfPresent(UInt8.self, forKey: .batteryPercentage) + ) + } } -extension PairedDeviceInfo: Identifiable, Codable {} +extension PairedDeviceInfo: Identifiable, Codable { + fileprivate enum CodingKeys: String, CodingKey { + case id + case deviceType + case name + case model + case icon + case lastSeen + case batteryPercentage + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(id, forKey: .id) + try container.encode(deviceType, forKey: .deviceType) + try container.encode(name, forKey: .name) + try container.encodeIfPresent(model, forKey: .model) + try container.encodeIfPresent(icon, forKey: .icon) + try container.encode(lastSeen, forKey: .lastSeen) + try container.encodeIfPresent(lastBatteryPercentage, forKey: .batteryPercentage) + } +} extension PairedDeviceInfo: Hashable { + public static func == (lhs: PairedDeviceInfo, rhs: PairedDeviceInfo) -> Bool { + lhs.id == rhs.id + && lhs.deviceType == rhs.deviceType + && lhs.name == rhs.name + && lhs.model == rhs.model + && lhs.icon == rhs.icon + && lhs.lastSeen == rhs.lastSeen + && lhs.lastBatteryPercentage == rhs.lastBatteryPercentage + } + public func hash(into hasher: inout Hasher) { hasher.combine(id) } diff --git a/Sources/SpeziDevices/Model/SavableCollection.swift b/Sources/SpeziDevices/Model/SavableCollection.swift new file mode 100644 index 0000000..a192bb2 --- /dev/null +++ b/Sources/SpeziDevices/Model/SavableCollection.swift @@ -0,0 +1,85 @@ +// +// 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 SavableCollection { + private let storage: [Element] + + var values: [Element] { + storage + } + + init(_ elements: [Element] = []) { + self.storage = elements + } +} + + +extension SavableCollection: RandomAccessCollection { + public var startIndex: Int { + storage.startIndex + } + + public var endIndex: Int { + storage.endIndex + } + + public func index(after index: Int) -> Int { + storage.index(after: index) + } + + public subscript(position: Int) -> Element { + storage[position] + } +} + + +extension SavableCollection: ExpressibleByArrayLiteral { + init(arrayLiteral elements: Element...) { + self.init(elements) + } +} + + +extension SavableCollection: 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([Element].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 58b1ffc..8d6cb0f 100644 --- a/Sources/SpeziDevices/PairedDevices.swift +++ b/Sources/SpeziDevices/PairedDevices.swift @@ -10,40 +10,44 @@ import OrderedCollections import Spezi import SpeziBluetooth import SpeziBluetoothServices +import SpeziViews import SwiftUI @Observable -public final class PairedDevices: Module, EnvironmentAccessible, DefaultInitializable { +public final class PairedDevices: Module, EnvironmentAccessible, DefaultInitializable { // TODO: Docs all interfaces /// Determines if the device discovery sheet should be presented. @MainActor public var shouldPresentDevicePairing = false @MainActor public private(set) var discoveredDevices: OrderedDictionary = [:] @MainActor private(set) var peripherals: [UUID: any PairableDevice] = [:] - // TODO: @AppStorage("pairedDevices") => we are missing the RawRepresentable extension from the TemplateApp! - @MainActor @ObservationIgnored private var _pairedDevices: [PairedDeviceInfo] = [] + @AppStorage @MainActor @ObservationIgnored private var _pairedDevices: SavableCollection @MainActor public var pairedDevices: [PairedDeviceInfo] { get { access(keyPath: \.pairedDevices) - return _pairedDevices + return _pairedDevices.values } set { withMutation(keyPath: \.pairedDevices) { - _pairedDevices = newValue + _pairedDevices = SavableCollection(newValue) } } } + @MainActor @ObservationIgnored private var pendingConnectionAttempts: [UUID: Task] = [:] + + @AppStorage("edu.stanford.spezi.SpeziDevices.ever-paired-once") @MainActor @ObservationIgnored private var everPairedDevice = false - @MainActor public var isScanningForNearbyDevices: Bool { - pairedDevices.isEmpty || shouldPresentDevicePairing - } @Application(\.logger) @ObservationIgnored private var logger - + @Dependency @ObservationIgnored private var bluetooth: Bluetooth? + @Dependency @ObservationIgnored private var tipKit: ConfigureTipKit + @MainActor public var isScanningForNearbyDevices: Bool { + (pairedDevices.isEmpty && !everPairedDevice) || shouldPresentDevicePairing + } private var stateSubscriptionTask: Task? { willSet { @@ -52,7 +56,14 @@ public final class PairedDevices: Module, EnvironmentAccessible, DefaultInitiali } - public required init() {} // TODO: configure automatic search without devices paired! + // TODO: configure automatic search without devices paired! + public required convenience init() { + self.init("edu.stanford.spezi.SpeziDevices.PairedDevices.devices-default") + } + + public init(_ storageKey: String) { + self.__pairedDevices = AppStorage(wrappedValue: [], storageKey) + } public func configure() { guard bluetooth != nil else { @@ -70,42 +81,6 @@ public final class PairedDevices: Module, EnvironmentAccessible, DefaultInitiali } } - // TODO: move to subscription handling extensions - @MainActor - private func setupBluetoothStateSubscription() async { - assert(!pairedDevices.isEmpty, "Bluetooth State subscription doesn't need to be set up without any paired devices.") - - guard let bluetooth else { - return - } - - // If Bluetooth is currently turned off in control center or not authorized anymore, we would want to keep central allocated - // such that we are notified about the bluetooth state changing. - await bluetooth.powerOn() - - self.stateSubscriptionTask = Task.detached { [weak self] in - for await nextState in await bluetooth.stateSubscription { - guard let self else { - return - } - await self.handleBluetoothStateChanged(nextState) - } - } - - if case .poweredOn = bluetooth.state { - await self.handleCentralPoweredOn() - } - } - - @MainActor - private func cancelSubscription() async { - assert(pairedDevices.isEmpty, "Bluetooth State subscription was tried to be cancelled even though devices were still paired.") - assert(peripherals.isEmpty, "Peripherals were unexpectedly not empty.") - - stateSubscriptionTask = nil - await bluetooth?.powerOff() - } - @MainActor public func isConnected(device: UUID) -> Bool { peripherals[device]?.state == .connected @@ -113,7 +88,13 @@ public final class PairedDevices: Module, EnvironmentAccessible, DefaultInitiali @MainActor public func isPaired(_ device: Device) -> Bool { - pairedDevices.contains { $0.id == device.id } // TODO: more efficient lookup! + pairedDevices.contains { $0.id == device.id } + } + + @MainActor + public func updateName(for deviceInfo: PairedDeviceInfo, name: String) { + deviceInfo.name = name + _pairedDevices = _pairedDevices // update app storage } /// Configure a device to be managed by this PairedDevices instance. @@ -133,7 +114,7 @@ public final class PairedDevices: Module, EnvironmentAccessible, DefaultInitiali return } if device.isInPairingMode { - await self?.nearbyPairableDevice(device) + await self?.discoveredPairableDevice(device) } } nearby.onChange { [weak self, weak device] nearby in @@ -141,28 +122,19 @@ public final class PairedDevices: Module, EnvironmentAccessible, DefaultInitiali await self?.handleDiscardedDevice(device) } } - } - @MainActor - public func handleDeviceStateUpdated(_ device: Device, _ state: PeripheralState) { - guard case .disconnected = state else { - return - } - - 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 - - Task { - await device.connect() // TODO: handle something about that?, reuse with configure method? + if let batteryPowered = device as? any BatteryPoweredDevice { + batteryPowered.battery.$batteryLevel.onChange { [weak self, weak device] value in + guard let device, let self else { + return + } + await updateBattery(for: device, percentage: value) + } } } @MainActor - public func nearbyPairableDevice(_ device: Device) { // TODO: rename? + private func discoveredPairableDevice(_ device: Device) { guard discoveredDevices[device.id] == nil else { return } @@ -171,8 +143,9 @@ public final class PairedDevices: Module, EnvironmentAccessible, DefaultInitiali return } - self.logger.info("Detected nearby \(Device.self) accessory.") - // TODO: previously we logged the manufacturer data! + self.logger.info( + "Detected nearby \(Device.self) accessory\(device.advertisementData.manufacturerData.map { " with manufacturer data \($0)" } ?? "")" + ) discoveredDevices[device.id] = device shouldPresentDevicePairing = true @@ -180,7 +153,9 @@ public final class PairedDevices: Module, EnvironmentAccessible, DefaultInitiali @MainActor - public func registerPairedDevice(_ device: Device) async { // TODO: registerPaired(device:)? + public func registerPairedDevice(_ device: Device) async { + everPairedDevice = true + var batteryLevel: UInt8? if let batteryDevice = device as? any BatteryPoweredDevice { batteryLevel = batteryDevice.battery.batteryLevel @@ -210,7 +185,6 @@ public final class PairedDevices: Module, EnvironmentAccessible, DefaultInitiali assert(peripherals[device.id] == nil, "Cannot overwrite peripheral. Device \(deviceInfo) was paired twice.") - // TODO: convert to persistent device! peripherals[device.id] = device self.logger.debug("Device \(device.label) with id \(device.id) is now paired!") @@ -220,13 +194,6 @@ public final class PairedDevices: Module, EnvironmentAccessible, DefaultInitiali } } - @MainActor - public 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 public func forgetDevice(id: UUID) { pairedDevices.removeAll { info in @@ -248,15 +215,104 @@ public final class PairedDevices: Module, EnvironmentAccessible, DefaultInitiali // 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) { - // TODO: with new model we can register our own onChange listeners! 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 + } +} + + +// MARK: - Paired Peripheral Management + +extension PairedDevices { + @MainActor + private func setupBluetoothStateSubscription() async { + assert(!pairedDevices.isEmpty, "Bluetooth State subscription doesn't need to be set up without any paired devices.") + + guard let bluetooth else { + return + } + + // If Bluetooth is currently turned off in control center or not authorized anymore, we would want to keep central allocated + // such that we are notified about the bluetooth state changing. + await bluetooth.powerOn() + + self.stateSubscriptionTask = Task.detached { [weak self] in + for await nextState in await bluetooth.stateSubscription { + guard let self else { + return + } + await self.handleBluetoothStateChanged(nextState) + } + } + + if case .poweredOn = bluetooth.state { + await self.handleCentralPoweredOn() + } + } + + @MainActor + private func cancelSubscription() async { + assert(pairedDevices.isEmpty, "Bluetooth State subscription was tried to be cancelled even though devices were still paired.") + assert(peripherals.isEmpty, "Peripherals were unexpectedly not empty.") + + stateSubscriptionTask = nil + await bluetooth?.powerOff() + } + @MainActor private func handleBluetoothStateChanged(_ state: BluetoothState) async { logger.debug("Bluetooth Module state is now \(state)") @@ -265,7 +321,10 @@ public final class PairedDevices: Module, EnvironmentAccessible, DefaultInitiali case .poweredOn: await handleCentralPoweredOn() default: - peripherals.removeAll() // TODO: is that correct? + for device in peripherals.values { + cancelConnectionAttempt(for: device) + } + peripherals.removeAll() } } @@ -282,37 +341,42 @@ public final class PairedDevices: Module, EnvironmentAccessible, DefaultInitiali // we just reuse the configured Bluetooth devices let configuredDevices = bluetooth.configuredPairableDevices - for deviceInfo in self.pairedDevices { - guard self.peripherals[deviceInfo.id] == nil else { - continue - } - - guard let deviceType = configuredDevices[deviceInfo.deviceType] else { - self.logger.error("Unsupported device type \"\(deviceInfo.deviceType)\" for paired device \(deviceInfo.name).") - continue - } - - let device = await deviceType.retrieveDevice(from: bluetooth, with: deviceInfo.id) - - guard let device else { - // TODO: once spezi bluetooth works (waiting for connected), this is an indication that the device was unpaired???? => we know it is powered on! - self.logger.warning("Device \(deviceInfo.id) \(deviceInfo.name) could not be retrieved!") - continue + await withDiscardingTaskGroup { group in + for deviceInfo in self.pairedDevices { + group.addTask { @MainActor in + guard self.peripherals[deviceInfo.id] == nil else { + return + } + + guard let deviceType = configuredDevices[deviceInfo.deviceType] else { + self.logger.error("Unsupported device type \"\(deviceInfo.deviceType)\" for paired device \(deviceInfo.name).") + return + } + await self.handleDeviceRetrieval(for: deviceInfo, deviceType: deviceType) + } } + } + } - assert(self.peripherals[device.id] == nil, "Cannot overwrite peripheral. Device \(deviceInfo) was paired twice.") - self.peripherals[device.id] = device + @MainActor + private func handleDeviceRetrieval(for deviceInfo: PairedDeviceInfo, deviceType: any PairableDevice.Type) async { + guard let bluetooth else { + return + } - // TODO: make task group! - // TODO: create tasks and store them? - await device.connect() // TODO: might want to cancel that? + let device = await deviceType.retrieveDevice(from: bluetooth, with: deviceInfo.id) - // TODO: call connect after device disconnects? + guard let device else { + // TODO: once spezi bluetooth works (waiting for connected), this is an indication that the device was unpaired???? => we know it is powered on! + // => automatically remove that pairing? + self.logger.warning("Device \(deviceInfo.id) \(deviceInfo.name) could not be retrieved!") + return } - } - deinit { - _peripherals.removeAll() // TODO: clear any other state? + assert(self.peripherals[device.id] == nil, "Cannot overwrite peripheral. Device \(deviceInfo) was paired twice.") + self.peripherals[device.id] = device + + connectionAttempt(for: device) } } diff --git a/Sources/SpeziDevices/Testing/MockDevice.swift b/Sources/SpeziDevices/Testing/MockDevice.swift index e34581f..2cbffb1 100644 --- a/Sources/SpeziDevices/Testing/MockDevice.swift +++ b/Sources/SpeziDevices/Testing/MockDevice.swift @@ -32,9 +32,8 @@ public final class MockDevice: PairableDevice, HealthDevice { @Service public var weightScale = WeightScaleService() public let pairing = PairingContinuation() - public var isInPairingMode = false // TODO: control + public var isInPairingMode = false - // TODO: mandatory setup? public init() {} } @@ -49,7 +48,7 @@ extension MockDevice { /// - weightMeasurement: The weight measurement loaded into the device. /// - weightResolution: The weight resolution to use. /// - heightResolution: The height resolution to use. - /// - Returns: Returns the initialoued Mock Device. + /// - Returns: Returns the initialized Mock Device. @_spi(TestingSupport) public static func createMockDevice( name: String = "Mock Device", @@ -85,17 +84,14 @@ extension MockDevice { device.$connect.inject { @MainActor [weak device] in device?.$state.inject(.connecting) - // TODO: await device?.handleStateChange(.connecting) try? await Task.sleep(for: .seconds(1)) device?.$state.inject(.connected) - // TODO: await device?.handleStateChange(.connected) } device.$disconnect.inject { @MainActor [weak device] in device?.$state.inject(.disconnected) - // TODO: await device?.handleStateChange(.disconnected) } return device diff --git a/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift b/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift index 0454169..5323126 100644 --- a/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift +++ b/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift @@ -13,10 +13,11 @@ import SwiftUI /// Show the device details of a paired device. public struct DeviceDetailsView: View { + private let deviceInfo: PairedDeviceInfo + @Environment(\.dismiss) private var dismiss @Environment(PairedDevices.self) private var pairedDevices - @Binding private var deviceInfo: PairedDeviceInfo @State private var presentForgetConfirmation = false private var image: Image { @@ -64,7 +65,6 @@ public struct DeviceDetailsView: View { .navigationBarTitleDisplayMode(.inline) .confirmationDialog("Do you really want to forget this device?", isPresented: $presentForgetConfirmation, titleVisibility: .visible) { Button("Forget Device", role: .destructive) { - // TODO: message to check for ConfigureTipKit dependency! ForgetDeviceTip.hasRemovedPairedDevice = true pairedDevices.forgetDevice(id: deviceInfo.id) dismiss() @@ -91,9 +91,11 @@ public struct DeviceDetailsView: View { .frame(maxWidth: .infinity) } - @ViewBuilder private var infoSection: some View { + @ViewBuilder @MainActor private var infoSection: some View { NavigationLink { - NameEditView($deviceInfo) + NameEditView(deviceInfo) { name in + pairedDevices.updateName(for: deviceInfo, name: name) + } } label: { ListRow("Name") { Text(deviceInfo.name) @@ -110,8 +112,8 @@ public struct DeviceDetailsView: View { /// Create a new device details view. /// - Parameter deviceInfo: The device info of the paired device. - public init(_ deviceInfo: Binding) { - self._deviceInfo = deviceInfo + public init(_ deviceInfo: PairedDeviceInfo) { + self.deviceInfo = deviceInfo } } @@ -119,15 +121,13 @@ public struct DeviceDetailsView: View { #if DEBUG #Preview { NavigationStack { - DeviceDetailsView(.constant( - PairedDeviceInfo( - id: UUID(), - deviceType: MockDevice.deviceTypeIdentifier, - name: "Blood Pressure Monitor", - model: "BP5250", - icon: .asset("Omron-BP5250"), - batteryPercentage: 100 - ) + DeviceDetailsView(PairedDeviceInfo( + id: UUID(), + deviceType: MockDevice.deviceTypeIdentifier, + name: "Blood Pressure Monitor", + model: "BP5250", + icon: .asset("Omron-BP5250"), + batteryPercentage: 100 )) } .previewWith { @@ -137,16 +137,14 @@ public struct DeviceDetailsView: View { #Preview { NavigationStack { - DeviceDetailsView(.constant( - PairedDeviceInfo( - id: UUID(), - deviceType: MockDevice.deviceTypeIdentifier, - name: "Weight Scale", - model: "SC-150", - icon: .asset("Omron-SC-150"), - lastSeen: .now.addingTimeInterval(-60 * 60 * 24), - batteryPercentage: 85 - ) + DeviceDetailsView(PairedDeviceInfo( + id: UUID(), + deviceType: MockDevice.deviceTypeIdentifier, + name: "Weight Scale", + model: "SC-150", + icon: .asset("Omron-SC-150"), + lastSeen: .now.addingTimeInterval(-60 * 60 * 24), + batteryPercentage: 85 )) } .previewWith { diff --git a/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift b/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift index e8f0a1d..6255263 100644 --- a/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift +++ b/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift @@ -13,10 +13,11 @@ import TipKit /// Grid view of paired devices. public struct DevicesGrid: View { - @Binding private var devices: [PairedDeviceInfo] - @Binding private var navigationPath: NavigationPath + private let devices: [PairedDeviceInfo] @Binding private var presentingDevicePairing: Bool + @State private var detailedDeviceInfo: PairedDeviceInfo? + private var gridItems = [ GridItem(.adaptive(minimum: 120, maximum: 800), spacing: 12), @@ -29,7 +30,6 @@ public struct DevicesGrid: View { if devices.isEmpty { ZStack { VStack { - // TODO: message to check for ConfigureTipKit dependency! TipView(ForgetDeviceTip.instance) .padding([.leading, .trailing], 20) Spacer() @@ -43,11 +43,11 @@ public struct DevicesGrid: View { .tipBackground(Color(uiColor: .secondarySystemGroupedBackground)) LazyVGrid(columns: gridItems) { - ForEach($devices) { device in + ForEach(devices) { device in Button { - navigationPath.append(device) + detailedDeviceInfo = device } label: { - DeviceTile(device.wrappedValue) + DeviceTile(device) } .foregroundStyle(.primary) } @@ -59,8 +59,8 @@ public struct DevicesGrid: View { } } .navigationTitle("Devices") - .navigationDestination(for: Binding.self) { deviceInfo in - DeviceDetailsView(deviceInfo) // TODO: prevents updates :( + .navigationDestination(item: $detailedDeviceInfo) { deviceInfo in + DeviceDetailsView(deviceInfo) } .toolbar { ToolbarItem(placement: .primaryAction) { @@ -75,38 +75,19 @@ public struct DevicesGrid: View { /// Create a new devices grid. /// - Parameters: /// - devices: The list of paired devices to display. - /// - navigation: Binding for the navigation path. /// - presentingDevicePairing: Binding to indicate if the device discovery menu should be presented. - public init(devices: Binding<[PairedDeviceInfo]>, navigation: Binding, presentingDevicePairing: Binding) { - // TODO: This Interface is probably not great for public interface - self._devices = devices - self._navigationPath = navigation + public init(devices: [PairedDeviceInfo], presentingDevicePairing: Binding) { + self.devices = devices self._presentingDevicePairing = presentingDevicePairing } } -// TODO: does that hurt? probably!!! we need to remove it anyways (for update issues) -extension Binding: Hashable, Equatable where Value: Hashable { - public static func == (lhs: Binding, rhs: Binding) -> Bool { - lhs.wrappedValue == rhs.wrappedValue - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(wrappedValue) - } -} - - #if DEBUG #Preview { NavigationStack { - DevicesGrid(devices: .constant([]), navigation: .constant(NavigationPath()), presentingDevicePairing: .constant(false)) + DevicesGrid(devices: [], presentingDevicePairing: .constant(false)) } - .onAppear { - Tips.showAllTipsForTesting() - try? Tips.configure() // TODO: use ConfigureTipKit Module - } .previewWith { PairedDevices() } @@ -119,12 +100,8 @@ extension Binding: Hashable, Equatable where Value: Hashable { ] return NavigationStack { - DevicesGrid(devices: .constant(devices), navigation: .constant(NavigationPath()), presentingDevicePairing: .constant(false)) + DevicesGrid(devices: devices, presentingDevicePairing: .constant(false)) } - .onAppear { - Tips.showAllTipsForTesting() - try? Tips.configure() // TODO: use ConfigureTipKit Module - } .previewWith { PairedDevices() } diff --git a/Sources/SpeziDevicesUI/Devices/DevicesTab.swift b/Sources/SpeziDevicesUI/Devices/DevicesTab.swift index 96560dc..99c08be 100644 --- a/Sources/SpeziDevicesUI/Devices/DevicesTab.swift +++ b/Sources/SpeziDevicesUI/Devices/DevicesTab.swift @@ -12,35 +12,30 @@ import SwiftUI /// Devices tab showing grid of paired devices and functionality to pair new devices. +/// +/// - Note: Make sure to place this view into an `NavigationStack`. public struct DevicesTab: View { private let appName: String @Environment(Bluetooth.self) private var bluetooth @Environment(PairedDevices.self) private var pairedDevices - @State private var path = NavigationPath() // TODO: can we remove that? if so, might want to remove the NavigationStack from view! - public var body: some View { @Bindable var pairedDevices = pairedDevices - NavigationStack(path: $path) { // TODO: not really reusable because of the navigation stack!!! - DevicesGrid(devices: $pairedDevices.pairedDevices, navigation: $path, presentingDevicePairing: $pairedDevices.shouldPresentDevicePairing) - // TODO: advertisementStaleInterval: 15 - // automatically search if no devices are paired - .scanNearbyDevices(enabled: pairedDevices.isScanningForNearbyDevices, with: bluetooth) - // TODO: automatic pop-up is a bit unexpected! - .sheet(isPresented: $pairedDevices.shouldPresentDevicePairing) { - AccessorySetupSheet(pairedDevices.discoveredDevices.values, appName: appName) - } - .toolbar { - // indicate that we are scanning in the background - if pairedDevices.isScanningForNearbyDevices && !pairedDevices.shouldPresentDevicePairing { - ToolbarItem(placement: .cancellationAction) { // TODO: shall we do primary action (what about order then?) - ProgressView() - } - } + DevicesGrid(devices: pairedDevices.pairedDevices, presentingDevicePairing: $pairedDevices.shouldPresentDevicePairing) + // automatically search if no devices are paired + .scanNearbyDevices(enabled: pairedDevices.isScanningForNearbyDevices, with: bluetooth, advertisementStaleInterval: 15) + // TODO: advertisementStaleInterval: 15 + .sheet(isPresented: $pairedDevices.shouldPresentDevicePairing) { + AccessorySetupSheet(pairedDevices.discoveredDevices.values, appName: appName) + } + .toolbar { + // indicate that we are scanning in the background + if pairedDevices.isScanningForNearbyDevices && !pairedDevices.shouldPresentDevicePairing { + ProgressView() } - } + } } /// Create a new devices tab @@ -53,10 +48,12 @@ public struct DevicesTab: View { #if DEBUG #Preview { - DevicesTab(appName: "Example") - .previewWith { - Bluetooth {} - PairedDevices() - } + NavigationStack { + DevicesTab(appName: "Example") + .previewWith { + Bluetooth {} + PairedDevices() + } + } } #endif diff --git a/Sources/SpeziDevicesUI/Devices/NameEditView.swift b/Sources/SpeziDevicesUI/Devices/NameEditView.swift index 65c0c13..f5e5ccd 100644 --- a/Sources/SpeziDevicesUI/Devices/NameEditView.swift +++ b/Sources/SpeziDevicesUI/Devices/NameEditView.swift @@ -12,9 +12,11 @@ import SwiftUI struct NameEditView: View { + private let deviceInfo: PairedDeviceInfo + private let save: (String) -> Void + @Environment(\.dismiss) private var dismiss - @Binding private var deviceInfo: PairedDeviceInfo @State private var name: String @ValidationState private var validation @@ -30,7 +32,7 @@ struct NameEditView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { Button("Done") { - deviceInfo.name = name + save(name) dismiss() } .disabled(deviceInfo.name == name || !validation.allInputValid) @@ -38,9 +40,10 @@ struct NameEditView: View { } - init(_ deviceInfo: Binding) { - self._deviceInfo = deviceInfo - self._name = State(wrappedValue: deviceInfo.wrappedValue.name) + init(_ deviceInfo: PairedDeviceInfo, save: @escaping (String) -> Void) { + self.deviceInfo = deviceInfo + self.save = save + self._name = State(wrappedValue: deviceInfo.name) } } @@ -57,16 +60,16 @@ extension ValidationRule { #if DEBUG #Preview { NavigationStack { - NameEditView(.constant( - PairedDeviceInfo( - id: UUID(), - deviceType: MockDevice.deviceTypeIdentifier, - name: "Blood Pressure Monitor", - model: "BP5250", - icon: .asset("Omron-BP5250"), - batteryPercentage: 100 - ) - )) + NameEditView(PairedDeviceInfo( + id: UUID(), + deviceType: MockDevice.deviceTypeIdentifier, + name: "Blood Pressure Monitor", + model: "BP5250", + icon: .asset("Omron-BP5250"), + batteryPercentage: 100 + )) { name in + print("New Name is \(name)") + } } } #endif diff --git a/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift b/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift index 3f53d73..0d8ee0f 100644 --- a/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift +++ b/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift @@ -38,7 +38,9 @@ public struct MeasurementRecordedSheet: View { 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) { diff --git a/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift b/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift index 6ad9521..cef802e 100644 --- a/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift +++ b/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift @@ -6,6 +6,7 @@ // SPDX-License-Identifier: MIT // +import OSLog import SpeziBluetooth @_spi(TestingSupport) import SpeziDevices import SpeziViews @@ -14,6 +15,10 @@ import SwiftUI /// Accessory Setup view displayed in a sheet. public struct AccessorySetupSheet: View where Collection.Element == any PairableDevice { + private static var logger: Logger { + Logger(subsystem: "edu.stanford.sepzi.SpeziDevices", category: "AccessorySetupSheet") + } + private let devices: Collection private let appName: String @@ -33,7 +38,12 @@ public struct AccessorySetupSheet: View wher PairedDeviceView(device, appName: appName) } else if !devices.isEmpty { PairDeviceView(devices: devices, appName: appName, state: $pairingState) { device in - try await device.pair() + do { + try await device.pair() + } catch { + Self.logger.error("Failed to pair device \(device.id), \(device.name ?? "unnamed"): \(error)") + throw error + } await pairedDevices.registerPairedDevice(device) } } else { @@ -44,7 +54,7 @@ public struct AccessorySetupSheet: View wher DismissButton() } } - .scanNearbyDevices(with: bluetooth) // TODO: advertisementStaleInterval: 15 + .scanNearbyDevices(with: bluetooth, advertisementStaleInterval: 15) // TODO: advertisementStaleInterval: 15 .presentationDetents([.medium]) .presentationCornerRadius(25) .interactiveDismissDisabled() diff --git a/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift b/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift index 62ccd05..ea89f99 100644 --- a/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift +++ b/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift @@ -28,7 +28,7 @@ struct PairDeviceView: View where Collection guard selectedDeviceIndex < devices.count else { return nil } - let index = devices.index(devices.startIndex, offsetBy: selectedDeviceIndex) // TODO: compare that against end index? + let index = devices.index(devices.startIndex, offsetBy: selectedDeviceIndex) return devices[index] } @@ -63,7 +63,6 @@ struct PairDeviceView: View where Collection try await pairClosure(selectedDevice) pairingState = .paired(selectedDevice) } catch { - print(error) // TODO: logger? pairingState = .error(AnyLocalizedError(error: error)) } } label: { diff --git a/Sources/SpeziDevicesUI/Tips/ForgetDeviceTip.swift b/Sources/SpeziDevicesUI/Tips/ForgetDeviceTip.swift index 48fd644..caf1020 100644 --- a/Sources/SpeziDevicesUI/Tips/ForgetDeviceTip.swift +++ b/Sources/SpeziDevicesUI/Tips/ForgetDeviceTip.swift @@ -10,7 +10,7 @@ import SwiftUI import TipKit -struct ForgetDeviceTip: Tip { // TODO: document that the user needs to set up tip kit? We could just create a Module for that? +struct ForgetDeviceTip: Tip { static let instance = ForgetDeviceTip() @Parameter static var hasRemovedPairedDevice: Bool = false From 1c6450f7bcbbc3c77df58ed7c5d0d2bba3188b32 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 25 Jun 2024 16:57:40 +0200 Subject: [PATCH 21/77] Final touches on the API design --- Package.swift | 10 +- .../SpeziDevices/Devices/PairableDevice.swift | 60 ---- Sources/SpeziDevices/HealthMeasurements.swift | 164 +++++++-- .../BluetoothHealthMeasurement.swift | 48 +++ .../Measurements/HealthKitMeasurement.swift | 3 + .../HealthMeasurementsConstraint.swift | 27 -- .../Measurements/StoredMeasurement.swift | 74 +++++ .../SpeziDevices/Model/PairedDeviceInfo.swift | 4 +- .../Model/PairingContinuation.swift | 58 +--- .../Model/SavableCollection.swift | 17 +- .../Model/SavableDictionary.swift | 105 ++++++ Sources/SpeziDevices/PairedDevices.swift | 314 ++++++++++++++---- .../SpeziDevices.docc/SpeziDevices.md | 23 +- Sources/SpeziDevices/Testing/MockDevice.swift | 3 +- .../ConfirmMeasurementButton.swift | 22 +- .../Measurements/MeasurementLayer.swift | 9 +- .../MeasurementRecordedSheet.swift | 170 +++++++--- .../Pairing/AccessorySetupSheet.swift | 3 +- .../Pairing/PairDeviceView.swift | 2 +- .../Resources/Localizable.xcstrings | 6 + .../Testing/TestMeasurementStandard.swift | 20 -- Sources/SpeziOmron/OmronOptionService.swift | 4 - .../SpeziDevicesTests/SpeziDevicesTests.swift | 75 +++++ 23 files changed, 889 insertions(+), 332 deletions(-) delete mode 100644 Sources/SpeziDevices/Measurements/HealthMeasurementsConstraint.swift create mode 100644 Sources/SpeziDevices/Measurements/StoredMeasurement.swift create mode 100644 Sources/SpeziDevices/Model/SavableDictionary.swift delete mode 100644 Sources/SpeziDevicesUI/Testing/TestMeasurementStandard.swift create mode 100644 Tests/SpeziDevicesTests/SpeziDevicesTests.swift 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] + ) + ) + } +} From c66bcea6a7aa875d058a618179a6445b2ef0ced4 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 25 Jun 2024 17:23:59 +0200 Subject: [PATCH 22/77] Fix closure --- .../SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift b/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift index 53cc05e..5b2d3e1 100644 --- a/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift +++ b/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift @@ -134,7 +134,7 @@ public struct MeasurementRecordedSheet: View { /// Create a new measurement sheet. - public init(save saveSamples: @escaping ([HKSample]) -> Void) { + public init(save saveSamples: @escaping ([HKSample]) async throws -> Void) { self.saveSamples = saveSamples } } From 45d34988e8894af810349fcf54f02e40cd1e98e6 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 25 Jun 2024 17:33:31 +0200 Subject: [PATCH 23/77] signalDevicePaired --- Sources/SpeziDevices/PairedDevices.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/SpeziDevices/PairedDevices.swift b/Sources/SpeziDevices/PairedDevices.swift index 644c6c0..a104d7a 100644 --- a/Sources/SpeziDevices/PairedDevices.swift +++ b/Sources/SpeziDevices/PairedDevices.swift @@ -390,6 +390,10 @@ extension PairedDevices { await registerPairedDevice(device) } + @MainActor public func signalDevicePaired(_ device: some PairableDevice) { + ongoingPairings.removeValue(forKey: device.id)?.signalPaired() + } + @MainActor private func registerPairedDevice(_ device: Device) async { everPairedDevice = true From 797c72fe20f2e11aad1beab4b6e0e474d3a2bcff Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 25 Jun 2024 18:11:08 +0200 Subject: [PATCH 24/77] Only update last seen if we were previously connected --- Sources/SpeziDevices/PairedDevices.swift | 29 +++++++++++++++--------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/Sources/SpeziDevices/PairedDevices.swift b/Sources/SpeziDevices/PairedDevices.swift index a104d7a..1db71b4 100644 --- a/Sources/SpeziDevices/PairedDevices.swift +++ b/Sources/SpeziDevices/PairedDevices.swift @@ -198,9 +198,9 @@ public final class PairedDevices { _ advertisements: DeviceStateAccessor, _ nearby: DeviceStateAccessor ) { - state.onChange { [weak self, weak device] state in + state.onChange { [weak self, weak device] oldValue, newValue in if let device { - await self?.handleDeviceStateUpdated(device, state) + await self?.handleDeviceStateUpdated(device, old: oldValue, new: newValue) } } advertisements.onChange(initial: true) { [weak self, weak device] _ in @@ -228,21 +228,24 @@ public final class PairedDevices { } @MainActor - private func handleDeviceStateUpdated(_ device: Device, _ state: PeripheralState) { - switch state { + private func handleDeviceStateUpdated(_ device: Device, old oldState: PeripheralState, new newState: PeripheralState) { + switch newState { case .connected: cancelConnectionAttempt(for: device) // just clear the entry updateLastSeen(for: device) case .disconnecting: - updateLastSeen(for: device) + if case .connected = oldState { + 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) + if case .connected = oldState { + updateLastSeen(for: device) } + + // long-running reconnect (if applicable) + connectionAttempt(for: device) default: break } @@ -295,7 +298,7 @@ public final class PairedDevices { @MainActor private func connectionAttempt(for device: some PairableDevice) { - guard isPaired(device) else { + guard case .poweredOn = bluetooth?.state, isPaired(device) else { return } @@ -391,7 +394,11 @@ extension PairedDevices { } @MainActor public func signalDevicePaired(_ device: some PairableDevice) { - ongoingPairings.removeValue(forKey: device.id)?.signalPaired() + guard let continuation = ongoingPairings.removeValue(forKey: device.id) else { + return + } + logger.debug("Device \(device.label), \(device.id) signaled it is fully paired.") + continuation.signalPaired() } @MainActor From 3519a0617976c2a0b978fb060f30fb524de2ca76 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 25 Jun 2024 20:20:05 +0200 Subject: [PATCH 25/77] Small changes --- Sources/SpeziDevices/Model/PairedDeviceInfo.swift | 1 + Sources/SpeziDevices/PairedDevices.swift | 7 +++++-- Sources/SpeziDevicesUI/Devices/DevicesTab.swift | 3 +-- Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Sources/SpeziDevices/Model/PairedDeviceInfo.swift b/Sources/SpeziDevices/Model/PairedDeviceInfo.swift index 8e3b028..25b53c2 100644 --- a/Sources/SpeziDevices/Model/PairedDeviceInfo.swift +++ b/Sources/SpeziDevices/Model/PairedDeviceInfo.swift @@ -29,6 +29,7 @@ public class PairedDeviceInfo { public internal(set) var lastSeen: Date /// The last reported battery percentage of the device. public internal(set) var lastBatteryPercentage: UInt8? + public internal(set) var notLocatable: Bool = false // TODO: update name // TODO: docs /// Create new paired device information. /// - Parameters: diff --git a/Sources/SpeziDevices/PairedDevices.swift b/Sources/SpeziDevices/PairedDevices.swift index 1db71b4..d1125fd 100644 --- a/Sources/SpeziDevices/PairedDevices.swift +++ b/Sources/SpeziDevices/PairedDevices.swift @@ -393,7 +393,8 @@ extension PairedDevices { await registerPairedDevice(device) } - @MainActor public func signalDevicePaired(_ device: some PairableDevice) { + @MainActor + public func signalDevicePaired(_ device: some PairableDevice) { guard let continuation = ongoingPairings.removeValue(forKey: device.id) else { return } @@ -560,9 +561,11 @@ extension PairedDevices { let device = await deviceType.retrieveDevice(from: bluetooth, with: deviceInfo.id) guard let device else { + self.logger.warning("Device \(deviceInfo.id) \(deviceInfo.name) could not be retrieved!") + deviceInfo.notLocatable = true + // TODO: no need to flush, no need to store? flush() // TODO: once spezi bluetooth works (waiting for connected), this is an indication that the device was unpaired???? => we know it is powered on! // => automatically remove that pairing? - self.logger.warning("Device \(deviceInfo.id) \(deviceInfo.name) could not be retrieved!") return } diff --git a/Sources/SpeziDevicesUI/Devices/DevicesTab.swift b/Sources/SpeziDevicesUI/Devices/DevicesTab.swift index 99c08be..be38fa2 100644 --- a/Sources/SpeziDevicesUI/Devices/DevicesTab.swift +++ b/Sources/SpeziDevicesUI/Devices/DevicesTab.swift @@ -25,8 +25,7 @@ public struct DevicesTab: View { DevicesGrid(devices: pairedDevices.pairedDevices, presentingDevicePairing: $pairedDevices.shouldPresentDevicePairing) // automatically search if no devices are paired - .scanNearbyDevices(enabled: pairedDevices.isScanningForNearbyDevices, with: bluetooth, advertisementStaleInterval: 15) - // TODO: advertisementStaleInterval: 15 + .scanNearbyDevices(enabled: pairedDevices.isScanningForNearbyDevices, with: bluetooth) .sheet(isPresented: $pairedDevices.shouldPresentDevicePairing) { AccessorySetupSheet(pairedDevices.discoveredDevices.values, appName: appName) } diff --git a/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift b/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift index 7460e86..f12a943 100644 --- a/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift +++ b/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift @@ -53,7 +53,7 @@ public struct AccessorySetupSheet: View wher DismissButton() } } - .scanNearbyDevices(with: bluetooth, advertisementStaleInterval: 15) // TODO: advertisementStaleInterval: 15 + .scanNearbyDevices(with: bluetooth) .presentationDetents([.medium]) .presentationCornerRadius(25) .interactiveDismissDisabled() From 6927155a0bbce93083de3786688ff22bbb91b407 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 25 Jun 2024 21:03:48 +0200 Subject: [PATCH 26/77] Debug --- Sources/SpeziDevicesUI/Devices/NameEditView.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Sources/SpeziDevicesUI/Devices/NameEditView.swift b/Sources/SpeziDevicesUI/Devices/NameEditView.swift index f5e5ccd..07d1c48 100644 --- a/Sources/SpeziDevicesUI/Devices/NameEditView.swift +++ b/Sources/SpeziDevicesUI/Devices/NameEditView.swift @@ -15,7 +15,7 @@ struct NameEditView: View { private let deviceInfo: PairedDeviceInfo private let save: (String) -> Void - @Environment(\.dismiss) private var dismiss + // TODO: @Environment(\.dismiss) private var dismiss @State private var name: String @@ -33,7 +33,7 @@ struct NameEditView: View { .toolbar { Button("Done") { save(name) - dismiss() + // TODO: dismiss() } .disabled(deviceInfo.name == name || !validation.allInputValid) } @@ -41,9 +41,10 @@ struct NameEditView: View { init(_ deviceInfo: PairedDeviceInfo, save: @escaping (String) -> Void) { + let name = deviceInfo.name self.deviceInfo = deviceInfo self.save = save - self._name = State(wrappedValue: deviceInfo.name) + self._name = State(wrappedValue: name) } } From cc0ac9382951a6ce173471b5a0988581d4ade4c9 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 25 Jun 2024 21:11:14 +0200 Subject: [PATCH 27/77] Move NavigationLink into dedicated view? --- .../Devices/DeviceDetailsView.swift | 22 +------ .../Devices/DeviceInfoSection.swift | 58 +++++++++++++++++++ 2 files changed, 59 insertions(+), 21 deletions(-) create mode 100644 Sources/SpeziDevicesUI/Devices/DeviceInfoSection.swift diff --git a/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift b/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift index 5323126..f022e9e 100644 --- a/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift +++ b/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift @@ -34,9 +34,7 @@ public struct DeviceDetailsView: View { imageHeader } - Section { - infoSection - } + DeviceInfoSection(deviceInfo: deviceInfo) if let percentage = deviceInfo.lastBatteryPercentage { Section { @@ -91,24 +89,6 @@ public struct DeviceDetailsView: View { .frame(maxWidth: .infinity) } - @ViewBuilder @MainActor private var infoSection: some View { - NavigationLink { - NameEditView(deviceInfo) { name in - pairedDevices.updateName(for: deviceInfo, name: name) - } - } label: { - ListRow("Name") { - Text(deviceInfo.name) - } - } - - if let model = deviceInfo.model, model != deviceInfo.name { - ListRow("Model") { - Text(model) - } - } - } - /// Create a new device details view. /// - Parameter deviceInfo: The device info of the paired device. diff --git a/Sources/SpeziDevicesUI/Devices/DeviceInfoSection.swift b/Sources/SpeziDevicesUI/Devices/DeviceInfoSection.swift new file mode 100644 index 0000000..d3f3f87 --- /dev/null +++ b/Sources/SpeziDevicesUI/Devices/DeviceInfoSection.swift @@ -0,0 +1,58 @@ +// +// File.swift +// +// +// Created by Andreas Bauer on 25.06.24. +// + +import SpeziDevices +import SpeziViews +import SwiftUI + + +struct DeviceInfoSection: View { + private let deviceInfo: PairedDeviceInfo + + @Environment(PairedDevices.self) private var pairedDevices + + var body: some View { + Section { + NavigationLink { + NameEditView(deviceInfo) { name in + pairedDevices.updateName(for: deviceInfo, name: name) + } + } label: { + ListRow("Name") { + Text(deviceInfo.name) + } + } + + if let model = deviceInfo.model, model != deviceInfo.name { + ListRow("Model") { + Text(model) + } + } + } + } + + + init(deviceInfo: PairedDeviceInfo) { + self.deviceInfo = deviceInfo + } +} + + +#if DEBUG +#Preview { + List { + DeviceInfoSection(deviceInfo: PairedDeviceInfo( + id: UUID(), + deviceType: "MockDevice", + name: "Blood Pressure Monitor", + model: "BP5250", + icon: .asset("Omron-BP5250"), + batteryPercentage: 100 + )) + } +} +#endif From 55ac1eef2f75cc3c66dc24c3a14ab3f9572566fe Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 25 Jun 2024 21:56:28 +0200 Subject: [PATCH 28/77] Resolve all todos --- .../SpeziDevices/Model/PairedDeviceInfo.swift | 3 +- Sources/SpeziDevices/PairedDevices.swift | 36 ++++++++++--------- Sources/SpeziDevices/Testing/MockDevice.swift | 31 +++++++++++++--- .../SpeziDevicesUI/Devices/NameEditView.swift | 7 ++-- .../MeasurementRecordedSheet.swift | 2 +- .../Pairing/AccessorySetupSheet.swift | 1 - 6 files changed, 52 insertions(+), 28 deletions(-) diff --git a/Sources/SpeziDevices/Model/PairedDeviceInfo.swift b/Sources/SpeziDevices/Model/PairedDeviceInfo.swift index 25b53c2..759f834 100644 --- a/Sources/SpeziDevices/Model/PairedDeviceInfo.swift +++ b/Sources/SpeziDevices/Model/PairedDeviceInfo.swift @@ -29,7 +29,8 @@ public class PairedDeviceInfo { public internal(set) var lastSeen: Date /// The last reported battery percentage of the device. public internal(set) var lastBatteryPercentage: UInt8? - public internal(set) var notLocatable: Bool = false // TODO: update name // TODO: docs + /// Could not retrieve the device from the Bluetooth central. + public internal(set) var notLocatable: Bool = false /// Create new paired device information. /// - Parameters: diff --git a/Sources/SpeziDevices/PairedDevices.swift b/Sources/SpeziDevices/PairedDevices.swift index d1125fd..446b619 100644 --- a/Sources/SpeziDevices/PairedDevices.swift +++ b/Sources/SpeziDevices/PairedDevices.swift @@ -52,6 +52,10 @@ import SwiftUI /// func configure() { /// pairedDevices?.configure(device: self, accessing: $state, $advertisementData, $nearby) /// } +/// +/// func handleSuccessfulPairing() { // called on events where a device can be considered paired (e.g., incoming notifications) +/// pairedDevices?.signalDevicePaired(self) +/// } /// } /// ``` /// @@ -335,26 +339,22 @@ extension PairedDevices: Module, EnvironmentAccessible, DefaultInitializable {} // MARK: - Device Pairing extension PairedDevices { - /// Start pairing procedure with the device. + /// Pair with a recently discovered device. /// /// This method pairs with a currently advertising Bluetooth device. - /// - Note: The ``isInPairingMode`` property determines if the device is currently pairable. + /// - Note: The ``PairableDevice/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. + /// The implementation verifies that the device is ``PairableDevice/isInPairingMode``, is currently disconnected and ``PairableDevice/nearby``. + /// It automatically connects to the device to start pairing. Pairing has a 15 second timeout by default. + /// Pairing is considered successful once ``signalDevicePaired(_:)`` is called by the device. It is considered unsuccessful if the device + /// disconnects prior to this call. + /// - Important: A successful pairing cannot be determined automatically and is specific to a device. You must manually call + /// ``signalDevicePaired(_:)`` to signal that a device is successfully paired (e.g., every time the device sends a notification for + /// a given characteristic). + /// - Parameter device: The device to pair with this module. /// - 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 } @@ -393,6 +393,11 @@ extension PairedDevices { await registerPairedDevice(device) } + /// Signal that a device is considered paired. + /// + /// You call this method from your device implementation on events that indicate that the device was successfully paired. + /// - Note: This method does nothing if there is currently no ongoing pairing session for a device. + /// - Parameter device: The device that can be considered paired and might have an ongoing pairing session. @MainActor public func signalDevicePaired(_ device: some PairableDevice) { guard let continuation = ongoingPairings.removeValue(forKey: device.id) else { @@ -563,9 +568,6 @@ extension PairedDevices { guard let device else { self.logger.warning("Device \(deviceInfo.id) \(deviceInfo.name) could not be retrieved!") deviceInfo.notLocatable = true - // TODO: no need to flush, no need to store? flush() - // TODO: once spezi bluetooth works (waiting for connected), this is an indication that the device was unpaired???? => we know it is powered on! - // => automatically remove that pairing? return } diff --git a/Sources/SpeziDevices/Testing/MockDevice.swift b/Sources/SpeziDevices/Testing/MockDevice.swift index 58b1aab..067f899 100644 --- a/Sources/SpeziDevices/Testing/MockDevice.swift +++ b/Sources/SpeziDevices/Testing/MockDevice.swift @@ -31,9 +31,21 @@ public final class MockDevice: PairableDevice, HealthDevice { @Service public var bloodPressure = BloodPressureService() @Service public var weightScale = WeightScaleService() - public var isInPairingMode = true + @Dependency private var pairedDevices: PairedDevices? + + public var isInPairingMode: Bool = true public init() {} + + + fileprivate func handleStateChange(_ state: PeripheralState) { + if case .connected = state { + Task { @MainActor in + try await Task.sleep(for: .seconds(2)) + pairedDevices?.signalDevicePaired(self) + } + } + } } @@ -52,6 +64,7 @@ extension MockDevice { public static func createMockDevice( name: String = "Mock Device", state: PeripheralState = .disconnected, + nearby: Bool = true, bloodPressureMeasurement: BloodPressureMeasurement = .mock(), weightMeasurement: WeightMeasurement = .mock(), weightResolution: WeightScaleFeature.WeightResolution = .resolution5g, @@ -67,6 +80,7 @@ extension MockDevice { device.$id.inject(UUID()) device.$name.inject(name) device.$state.inject(state) + device.$nearby.inject(nearby) device.bloodPressure.$features.inject([ .bodyMovementDetectionSupported, @@ -82,15 +96,24 @@ extension MockDevice { device.weightScale.$weightMeasurement.inject(weightMeasurement) device.$connect.inject { @MainActor [weak device] in - device?.$state.inject(.connecting) + guard let device else { + return + } + device.$state.inject(.connecting) + device.handleStateChange(.connecting) try? await Task.sleep(for: .seconds(1)) - device?.$state.inject(.connected) + device.$state.inject(.connected) + device.handleStateChange(.connected) } device.$disconnect.inject { @MainActor [weak device] in - device?.$state.inject(.disconnected) + guard let device else { + return + } + device.$state.inject(.disconnected) + device.handleStateChange(.connected) } return device diff --git a/Sources/SpeziDevicesUI/Devices/NameEditView.swift b/Sources/SpeziDevicesUI/Devices/NameEditView.swift index 07d1c48..f5e5ccd 100644 --- a/Sources/SpeziDevicesUI/Devices/NameEditView.swift +++ b/Sources/SpeziDevicesUI/Devices/NameEditView.swift @@ -15,7 +15,7 @@ struct NameEditView: View { private let deviceInfo: PairedDeviceInfo private let save: (String) -> Void - // TODO: @Environment(\.dismiss) private var dismiss + @Environment(\.dismiss) private var dismiss @State private var name: String @@ -33,7 +33,7 @@ struct NameEditView: View { .toolbar { Button("Done") { save(name) - // TODO: dismiss() + dismiss() } .disabled(deviceInfo.name == name || !validation.allInputValid) } @@ -41,10 +41,9 @@ struct NameEditView: View { init(_ deviceInfo: PairedDeviceInfo, save: @escaping (String) -> Void) { - let name = deviceInfo.name self.deviceInfo = deviceInfo self.save = save - self._name = State(wrappedValue: name) + self._name = State(wrappedValue: deviceInfo.name) } } diff --git a/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift b/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift index 5b2d3e1..6513133 100644 --- a/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift +++ b/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift @@ -64,7 +64,7 @@ public struct MeasurementRecordedSheet: View { .font(.title) .fixedSize(horizontal: false, vertical: true) } subtitle: { - EmptyView() // TODO: do we have date information? + EmptyView() } content: { content } action: { diff --git a/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift b/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift index f12a943..7a44b2a 100644 --- a/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift +++ b/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift @@ -31,7 +31,6 @@ public struct AccessorySetupSheet: View wher public var body: some View { NavigationStack { VStack { - // TODO: make ONE PaneContent? => animation of image transfer? if case let .error(error) = pairingState { PairingFailureView(error) } else if case let .paired(device) = pairingState { From 5011ef27c5a688d9ac79d98f3ee1642a2c2ca082 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 25 Jun 2024 22:07:22 +0200 Subject: [PATCH 29/77] Some docs structuring and REUSE --- .../SpeziDevices.docc/HealthKit.md | 13 ++++++++++--- .../SpeziDevices.docc/SpeziDevices.md | 2 +- .../Devices/DeviceInfoSection.swift | 7 ++++--- .../SpeziDevicesUI.docc/SpeziDevicesUI.md | 16 ++++++++++++++++ .../SpeziOmron/SpeziOmron.docc/SpeziOmron.md | 18 ++++++++++++++++-- 5 files changed, 47 insertions(+), 9 deletions(-) diff --git a/Sources/SpeziDevices/SpeziDevices.docc/HealthKit.md b/Sources/SpeziDevices/SpeziDevices.docc/HealthKit.md index 4e20bd0..0599757 100644 --- a/Sources/SpeziDevices/SpeziDevices.docc/HealthKit.md +++ b/Sources/SpeziDevices/SpeziDevices.docc/HealthKit.md @@ -2,14 +2,21 @@ Convert Bluetooth measurement types to HealthKit samples. -## Overview +Text +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 -### Section header +--> + +## Overview Text + ## Topics ### Device diff --git a/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md b/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md index c06deaf..d77275d 100644 --- a/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md +++ b/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md @@ -14,7 +14,7 @@ SPDX-License-Identifier: MIT ## Overview -Text +Below is a list of important symbols of SpeziDevices. ## Topics diff --git a/Sources/SpeziDevicesUI/Devices/DeviceInfoSection.swift b/Sources/SpeziDevicesUI/Devices/DeviceInfoSection.swift index d3f3f87..5640956 100644 --- a/Sources/SpeziDevicesUI/Devices/DeviceInfoSection.swift +++ b/Sources/SpeziDevicesUI/Devices/DeviceInfoSection.swift @@ -1,8 +1,9 @@ // -// File.swift -// +// This source file is part of the Stanford Spezi open-source project // -// Created by Andreas Bauer on 25.06.24. +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT // import SpeziDevices diff --git a/Sources/SpeziDevicesUI/SpeziDevicesUI.docc/SpeziDevicesUI.md b/Sources/SpeziDevicesUI/SpeziDevicesUI.docc/SpeziDevicesUI.md index d370945..c1e5e30 100644 --- a/Sources/SpeziDevicesUI/SpeziDevicesUI.docc/SpeziDevicesUI.md +++ b/Sources/SpeziDevicesUI/SpeziDevicesUI.docc/SpeziDevicesUI.md @@ -25,3 +25,19 @@ Views that are helpful when building a nearby devices view. - ``BluetoothUnavailableView`` - ``NearbyDeviceRow`` - ``LoadingSectionHeader`` + +### Pairing Devices + +- ``AccessorySetupSheet`` + +### Paired Devices + +- ``DevicesTab`` +- ``DevicesGrid`` +- ``DeviceTile`` +- ``DeviceDetailsView`` +- ``BatteryIcon`` + +### Measurements + +- ``MeasurementRecordedSheet`` diff --git a/Sources/SpeziOmron/SpeziOmron.docc/SpeziOmron.md b/Sources/SpeziOmron/SpeziOmron.docc/SpeziOmron.md index 132b099..7dbf746 100644 --- a/Sources/SpeziOmron/SpeziOmron.docc/SpeziOmron.md +++ b/Sources/SpeziOmron/SpeziOmron.docc/SpeziOmron.md @@ -18,6 +18,20 @@ ## Topics -### Group +### Omron Devices -- ``Symbol`` +- ``OmronHealthDevice`` +- ``OmronModel`` +- ``OmronManufacturerData`` +- ``SpeziBluetooth/ManufacturerIdentifier/omronHealthcareCoLtd`` + +### Omron Services + +- ``OmronOptionService`` + +### Omron Record Access + +- ``SpeziBluetooth/CharacteristicAccessor/reportStoredRecords(_:)`` +- ``SpeziBluetooth/CharacteristicAccessor/reportNumberOfStoredRecords(_:)`` +- ``SpeziBluetooth/CharacteristicAccessor/reportSequenceNumberOfLatestRecords()`` +- ``OmronRecordAccessOperand`` From 77d93dbc1c50bb08d7f7fdf97d225fac83cb13e3 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 25 Jun 2024 22:13:57 +0200 Subject: [PATCH 30/77] Remove support for macOS, tvOS and watchOS --- .github/workflows/build-and-test.yml | 46 +------------------ Package.swift | 5 +- .../SpeziDevices/Model/ImageReference.swift | 6 +++ .../Pairing/AccessoryImageView.swift | 4 +- 4 files changed, 10 insertions(+), 51 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index e14b6fc..ecb6df4 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -23,14 +23,6 @@ jobs: scheme: SpeziDevices-Package resultBundle: SpeziDevices-iOS.xcresult artifactname: SpeziDevices-iOS.xcresult - packagewatchos: - name: Build and Test Swift Package watchOS - uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 - with: - scheme: SpeziDevices-Package - destination: 'platform=watchOS Simulator,name=Apple Watch Series 9 (45mm)' - resultBundle: SpeziDevices-watchOS.xcresult - artifactname: SpeziDevices-watchOS.xcresult packagevisionos: name: Build and Test Swift Package visionOS uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 @@ -39,22 +31,6 @@ jobs: destination: 'platform=visionOS Simulator,name=Apple Vision Pro' resultBundle: SpeziDevices-visionOS.xcresult artifactname: SpeziDevices-visionOS.xcresult - packagetvos: - name: Build and Test Swift Package tvOS - uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 - with: - scheme: SpeziDevices-Package - resultBundle: SpeziDevices-tvOS.xcresult - destination: 'platform=tvOS Simulator,name=Apple TV 4K (3rd generation)' - artifactname: SpeziDevices-tvOS.xcresult - packagemacos: - name: Build and Test Swift Package macOS - uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 - with: - scheme: SpeziDevices-Package - resultBundle: SpeziDevices-macOS.xcresult - destination: 'platform=macOS,arch=arm64' - artifactname: SpeziDevices-macOS.xcresult ios: name: Build and Test iOS uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 @@ -74,16 +50,6 @@ jobs: destination: 'platform=iOS Simulator,name=iPad Air (5th generation)' resultBundle: TestApp-iPadOS.xcresult artifactname: TestApp-iPadOS.xcresult - watchos: - name: Build and Test watchOS - uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 - with: - runsonlabels: '["macOS", "self-hosted"]' - path: 'Tests/UITests' - scheme: TestAppWatchApp - destination: 'platform=watchOS Simulator,name=Apple Watch Series 9 (45mm)' - resultBundle: TestApp-watchOS.xcresult - artifactname: TestApp-watchOS.xcresult visionos: name: Build and Test visionOS uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 @@ -94,16 +60,6 @@ jobs: destination: 'platform=visionOS Simulator,name=Apple Vision Pro' resultBundle: TestApp-visionOS.xcresult artifactname: TestApp-visionOS.xcresult - tvos: - name: Build and Test tvOS - uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 - with: - runsonlabels: '["macOS", "self-hosted"]' - path: 'Tests/UITests' - scheme: TestApp - destination: 'platform=tvOS Simulator,name=Apple TV 4K (3rd generation)' - resultBundle: TestApp-tvOS.xcresult - artifactname: TestApp-tvOS.xcresult codeql: name: CodeQL uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 @@ -119,6 +75,6 @@ jobs: needs: [packageios, packagewatchos, packagevisionos, packagetvos, packagemacos, ios, ipados, watchos, visionos, tvos] uses: StanfordBDHG/.github/.github/workflows/create-and-upload-coverage-report.yml@v2 with: - coveragereports: SpeziDevices-iOS.xcresult SpeziDevices-watchOS.xcresult SpeziDevices-visionOS.xcresult SpeziDevices-tvOS.xcresult SpeziDevices-macOS.xcresult TestApp-iOS.xcresult TestApp-iPadOS.xcresult TestApp-watchOS.xcresult TestApp-visionOS.xcresult TestApp-tvOS.xcresult + coveragereports: SpeziDevices-iOS.xcresult SpeziDevices-visionOS.xcresult TestApp-iOS.xcresult TestApp-iPadOS.xcresult TestApp-visionOS.xcresult secrets: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/Package.swift b/Package.swift index decde89..9c0f399 100644 --- a/Package.swift +++ b/Package.swift @@ -18,10 +18,7 @@ let package = Package( defaultLocalization: "en", platforms: [ .iOS(.v17), - .watchOS(.v10), - .visionOS(.v1), - .tvOS(.v17), - .macOS(.v14) + .visionOS(.v1) ], products: [ .library(name: "SpeziDevices", targets: ["SpeziDevices"]), diff --git a/Sources/SpeziDevices/Model/ImageReference.swift b/Sources/SpeziDevices/Model/ImageReference.swift index 715f577..dda3bc5 100644 --- a/Sources/SpeziDevices/Model/ImageReference.swift +++ b/Sources/SpeziDevices/Model/ImageReference.swift @@ -27,9 +27,15 @@ extension ImageReference { case let .system(name): return Image(systemName: name) case let .asset(name, bundle: bundle): + #if os(iOS) || os(visionOS) || os(tvOS) guard UIImage(named: name, in: bundle, with: nil) != nil else { return nil } + #elseif os(macOS) + guard NSImage(named: name) != nil else { + return nil + } + #endif return Image(name, bundle: bundle) } } diff --git a/Sources/SpeziDevicesUI/Pairing/AccessoryImageView.swift b/Sources/SpeziDevicesUI/Pairing/AccessoryImageView.swift index 2efdd36..cac7ac7 100644 --- a/Sources/SpeziDevicesUI/Pairing/AccessoryImageView.swift +++ b/Sources/SpeziDevicesUI/Pairing/AccessoryImageView.swift @@ -24,8 +24,8 @@ struct AccessoryImageView: View { .symbolRenderingMode(.hierarchical) // set symbol rendering mode if one uses sf symbols .frame(maxWidth: 250, maxHeight: 120) } - .frame(maxWidth: .infinity, maxHeight: 150) // make drag-able area a bit larger - .background(Color(uiColor: .systemBackground)) // we need to set a non-clear color for it to be drag-able + .frame(maxWidth: .infinity, maxHeight: 150) // make drag-able area a bit larger + .background(Color(uiColor: .systemBackground)) // we need to set a non-clear color for it to be drag-able } From 151e1647b5b08560538140604d8882d5df93a994 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 25 Jun 2024 22:15:21 +0200 Subject: [PATCH 31/77] Fix workflow --- .github/workflows/build-and-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index ecb6df4..1e0be64 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -72,7 +72,7 @@ jobs: actions: read uploadcoveragereport: name: Upload Coverage Report - needs: [packageios, packagewatchos, packagevisionos, packagetvos, packagemacos, ios, ipados, watchos, visionos, tvos] + needs: [packageios, packagevisionos, ios, ipados, visionos] uses: StanfordBDHG/.github/.github/workflows/create-and-upload-coverage-report.yml@v2 with: coveragereports: SpeziDevices-iOS.xcresult SpeziDevices-visionOS.xcresult TestApp-iOS.xcresult TestApp-iPadOS.xcresult TestApp-visionOS.xcresult From 561eee26401c36e58a65d63517b013506e091f44 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 25 Jun 2024 22:21:51 +0200 Subject: [PATCH 32/77] Setup UITest App --- Tests/UITests/TestApp.xctestplan | 4 +- Tests/UITests/TestApp.xctestplan.license | 2 +- .../Contents.json.license | 2 +- .../AppIcon.appiconset/Contents.json.license | 2 +- .../Assets.xcassets/Contents.json.license | 2 +- Tests/UITests/TestApp/TestApp.swift | 7 +- .../TestAppUITests/TestAppUITests.swift | 6 +- Tests/UITests/TestAppWatchApp.xctestplan | 37 -- .../TestAppWatchApp.xctestplan.license | 2 +- .../UITests/UITests.xcodeproj/project.pbxproj | 361 +----------------- .../UITests.xcodeproj/project.pbxproj.license | 2 +- .../contents.xcworkspacedata.license | 2 +- .../IDEWorkspaceChecks.plist.license | 2 +- .../xcshareddata/xcschemes/TestApp.xcscheme | 6 +- .../xcschemes/TestApp.xcscheme.license | 2 +- .../xcschemes/TestAppWatchApp.xcscheme | 122 ------ .../TestAppWatchApp.xcscheme.license | 2 +- 17 files changed, 34 insertions(+), 529 deletions(-) delete mode 100644 Tests/UITests/TestAppWatchApp.xctestplan delete mode 100644 Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestAppWatchApp.xcscheme diff --git a/Tests/UITests/TestApp.xctestplan b/Tests/UITests/TestApp.xctestplan index 54e441a..f1d5b7c 100644 --- a/Tests/UITests/TestApp.xctestplan +++ b/Tests/UITests/TestApp.xctestplan @@ -13,8 +13,8 @@ "targets" : [ { "containerPath" : "container:..\/..", - "identifier" : "TemplatePackage", - "name" : "TemplatePackage" + "identifier" : "SpeziDevices", + "name" : "SpeziDevices" } ] }, diff --git a/Tests/UITests/TestApp.xctestplan.license b/Tests/UITests/TestApp.xctestplan.license index d77e33d..7f16969 100644 --- a/Tests/UITests/TestApp.xctestplan.license +++ b/Tests/UITests/TestApp.xctestplan.license @@ -1,4 +1,4 @@ -This source file is part of the TemplatePackage open-source project +This source file is part of the Stanford Spezi open-source project SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) diff --git a/Tests/UITests/TestApp/Assets.xcassets/AccentColor.colorset/Contents.json.license b/Tests/UITests/TestApp/Assets.xcassets/AccentColor.colorset/Contents.json.license index d77e33d..7f16969 100644 --- a/Tests/UITests/TestApp/Assets.xcassets/AccentColor.colorset/Contents.json.license +++ b/Tests/UITests/TestApp/Assets.xcassets/AccentColor.colorset/Contents.json.license @@ -1,4 +1,4 @@ -This source file is part of the TemplatePackage open-source project +This source file is part of the Stanford Spezi open-source project SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) diff --git a/Tests/UITests/TestApp/Assets.xcassets/AppIcon.appiconset/Contents.json.license b/Tests/UITests/TestApp/Assets.xcassets/AppIcon.appiconset/Contents.json.license index d77e33d..7f16969 100644 --- a/Tests/UITests/TestApp/Assets.xcassets/AppIcon.appiconset/Contents.json.license +++ b/Tests/UITests/TestApp/Assets.xcassets/AppIcon.appiconset/Contents.json.license @@ -1,4 +1,4 @@ -This source file is part of the TemplatePackage open-source project +This source file is part of the Stanford Spezi open-source project SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) diff --git a/Tests/UITests/TestApp/Assets.xcassets/Contents.json.license b/Tests/UITests/TestApp/Assets.xcassets/Contents.json.license index d77e33d..7f16969 100644 --- a/Tests/UITests/TestApp/Assets.xcassets/Contents.json.license +++ b/Tests/UITests/TestApp/Assets.xcassets/Contents.json.license @@ -1,4 +1,4 @@ -This source file is part of the TemplatePackage open-source project +This source file is part of the Stanford Spezi open-source project SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) diff --git a/Tests/UITests/TestApp/TestApp.swift b/Tests/UITests/TestApp/TestApp.swift index 90fdb20..4ade30a 100644 --- a/Tests/UITests/TestApp/TestApp.swift +++ b/Tests/UITests/TestApp/TestApp.swift @@ -1,20 +1,19 @@ // -// This source file is part of the TemplatePackage open-source project +// This source file is part of the Stanford Spezi open-source project // -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) // // SPDX-License-Identifier: MIT // import SwiftUI -import TemplatePackage @main struct UITestsApp: App { var body: some Scene { WindowGroup { - Text(TemplatePackage().stanford) + Text("Hello World") } } } diff --git a/Tests/UITests/TestAppUITests/TestAppUITests.swift b/Tests/UITests/TestAppUITests/TestAppUITests.swift index d422843..6bf88e2 100644 --- a/Tests/UITests/TestAppUITests/TestAppUITests.swift +++ b/Tests/UITests/TestAppUITests/TestAppUITests.swift @@ -1,7 +1,7 @@ // -// This source file is part of the TemplatePackage open-source project +// This source file is part of the Stanford Spezi open-source project // -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) // // SPDX-License-Identifier: MIT // @@ -20,6 +20,6 @@ class TestAppUITests: XCTestCase { func testTemplatePackage() throws { let app = XCUIApplication() app.launch() - XCTAssert(app.staticTexts["Stanford University"].waitForExistence(timeout: 0.1)) + XCTAssert(true) } } diff --git a/Tests/UITests/TestAppWatchApp.xctestplan b/Tests/UITests/TestAppWatchApp.xctestplan deleted file mode 100644 index 6f6f2dd..0000000 --- a/Tests/UITests/TestAppWatchApp.xctestplan +++ /dev/null @@ -1,37 +0,0 @@ -{ - "configurations" : [ - { - "id" : "B8537494-39D3-45EC-98D4-B3C417844ADD", - "name" : "Default", - "options" : { - - } - } - ], - "defaultOptions" : { - "codeCoverage" : { - "targets" : [ - { - "containerPath" : "container:..\/..", - "identifier" : "TemplatePackage", - "name" : "TemplatePackage" - } - ] - }, - "targetForVariableExpansion" : { - "containerPath" : "container:UITests.xcodeproj", - "identifier" : "2F9CBEA52A76C40E009818FF", - "name" : "TestAppWatchApp" - } - }, - "testTargets" : [ - { - "target" : { - "containerPath" : "container:UITests.xcodeproj", - "identifier" : "2F9CBEBE2A76C412009818FF", - "name" : "TestAppWatchAppUITests" - } - } - ], - "version" : 1 -} diff --git a/Tests/UITests/TestAppWatchApp.xctestplan.license b/Tests/UITests/TestAppWatchApp.xctestplan.license index d77e33d..7f16969 100644 --- a/Tests/UITests/TestAppWatchApp.xctestplan.license +++ b/Tests/UITests/TestAppWatchApp.xctestplan.license @@ -1,4 +1,4 @@ -This source file is part of the TemplatePackage open-source project +This source file is part of the Stanford Spezi open-source project SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 25c3d4a..9680683 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -7,14 +7,9 @@ objects = { /* Begin PBXBuildFile section */ - 2F68C3C8292EA52000B3E12C /* TemplatePackage in Frameworks */ = {isa = PBXBuildFile; productRef = 2F68C3C7292EA52000B3E12C /* TemplatePackage */; }; + 2F68C3C8292EA52000B3E12C /* SpeziDevices in Frameworks */ = {isa = PBXBuildFile; productRef = 2F68C3C7292EA52000B3E12C /* SpeziDevices */; }; 2F6D139A28F5F386007C25D6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2F6D139928F5F386007C25D6 /* Assets.xcassets */; }; 2F8A431329130A8C005D2B8F /* TestAppUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F8A431229130A8C005D2B8F /* TestAppUITests.swift */; }; - 2F9CBEC92A76C412009818FF /* TestApp Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 2F9CBEA62A76C40E009818FF /* TestApp Watch App.app */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 2F9CBED72A76C752009818FF /* TestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */; }; - 2F9CBED82A76C75E009818FF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2F6D139928F5F386007C25D6 /* Assets.xcassets */; }; - 2F9CBEDA2A76C795009818FF /* TemplatePackage in Frameworks */ = {isa = PBXBuildFile; productRef = 2F9CBED92A76C795009818FF /* TemplatePackage */; }; - 2F9CBEDB2A76C7EC009818FF /* TestAppUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F8A431229130A8C005D2B8F /* TestAppUITests.swift */; }; 2FA7382C290ADFAA007ACEB9 /* TestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */; }; /* End PBXBuildFile section */ @@ -26,27 +21,6 @@ remoteGlobalIDString = 2F6D139128F5F384007C25D6; remoteInfo = Example; }; - 2F9CBEC02A76C412009818FF /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 2F6D138A28F5F384007C25D6 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 2F9CBEA52A76C40E009818FF; - remoteInfo = "TestAppWatchOS Watch App"; - }; - 2F9CBEC72A76C412009818FF /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 2F6D138A28F5F384007C25D6 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 2F9CBEA52A76C40E009818FF; - remoteInfo = "TestAppWatchOS Watch App"; - }; - 2FF8922F2A770DE200903A5A /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 2F6D138A28F5F384007C25D6 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 2F6D139128F5F384007C25D6; - remoteInfo = TestApp; - }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -56,7 +30,6 @@ dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; dstSubfolderSpec = 16; files = ( - 2F9CBEC92A76C412009818FF /* TestApp Watch App.app in Embed Watch Content */, ); name = "Embed Watch Content"; runOnlyForDeploymentPostprocessing = 0; @@ -64,16 +37,13 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 2F68C3C6292E9F8F00B3E12C /* TemplatePackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TemplatePackage; path = ../..; sourceTree = ""; }; + 2F68C3C6292E9F8F00B3E12C /* SpeziDevices */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = SpeziDevices; path = ../..; sourceTree = ""; }; 2F6D139228F5F384007C25D6 /* TestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TestApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 2F6D139928F5F386007C25D6 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 2F6D13AC28F5F386007C25D6 /* TestAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TestAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 2F8A431229130A8C005D2B8F /* TestAppUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAppUITests.swift; sourceTree = ""; }; - 2F9CBEA62A76C40E009818FF /* TestApp Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "TestApp Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; - 2F9CBEBF2A76C412009818FF /* TestAppWatchAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TestAppWatchAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestApp.swift; sourceTree = ""; }; 2FB0758A299DDB9000C0B37F /* TestApp.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestApp.xctestplan; sourceTree = ""; }; - 2FF8922E2A770D4200903A5A /* TestAppWatchApp.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestAppWatchApp.xctestplan; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -81,7 +51,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 2F68C3C8292EA52000B3E12C /* TemplatePackage in Frameworks */, + 2F68C3C8292EA52000B3E12C /* SpeziDevices in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -92,21 +62,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 2F9CBEA32A76C40E009818FF /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 2F9CBEDA2A76C795009818FF /* TemplatePackage in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 2F9CBEBC2A76C412009818FF /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -114,8 +69,7 @@ isa = PBXGroup; children = ( 2FB0758A299DDB9000C0B37F /* TestApp.xctestplan */, - 2FF8922E2A770D4200903A5A /* TestAppWatchApp.xctestplan */, - 2F68C3C6292E9F8F00B3E12C /* TemplatePackage */, + 2F68C3C6292E9F8F00B3E12C /* SpeziDevices */, 2F6D139428F5F384007C25D6 /* TestApp */, 2F6D13AF28F5F386007C25D6 /* TestAppUITests */, 2F6D139328F5F384007C25D6 /* Products */, @@ -128,8 +82,6 @@ children = ( 2F6D139228F5F384007C25D6 /* TestApp.app */, 2F6D13AC28F5F386007C25D6 /* TestAppUITests.xctest */, - 2F9CBEA62A76C40E009818FF /* TestApp Watch App.app */, - 2F9CBEBF2A76C412009818FF /* TestAppWatchAppUITests.xctest */, ); name = Products; sourceTree = ""; @@ -173,11 +125,10 @@ buildRules = ( ); dependencies = ( - 2F9CBEC82A76C412009818FF /* PBXTargetDependency */, ); name = TestApp; packageProductDependencies = ( - 2F68C3C7292EA52000B3E12C /* TemplatePackage */, + 2F68C3C7292EA52000B3E12C /* SpeziDevices */, ); productName = Example; productReference = 2F6D139228F5F384007C25D6 /* TestApp.app */; @@ -201,45 +152,6 @@ productReference = 2F6D13AC28F5F386007C25D6 /* TestAppUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; - 2F9CBEA52A76C40E009818FF /* TestAppWatchApp */ = { - isa = PBXNativeTarget; - buildConfigurationList = 2F9CBECA2A76C412009818FF /* Build configuration list for PBXNativeTarget "TestAppWatchApp" */; - buildPhases = ( - 2F9CBEA22A76C40E009818FF /* Sources */, - 2F9CBEA32A76C40E009818FF /* Frameworks */, - 2F9CBEA42A76C40E009818FF /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = TestAppWatchApp; - packageProductDependencies = ( - 2F9CBED92A76C795009818FF /* TemplatePackage */, - ); - productName = "TestAppWatchOS Watch App"; - productReference = 2F9CBEA62A76C40E009818FF /* TestApp Watch App.app */; - productType = "com.apple.product-type.application"; - }; - 2F9CBEBE2A76C412009818FF /* TestAppWatchAppUITests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 2F9CBED32A76C412009818FF /* Build configuration list for PBXNativeTarget "TestAppWatchAppUITests" */; - buildPhases = ( - 2F9CBEBB2A76C412009818FF /* Sources */, - 2F9CBEBC2A76C412009818FF /* Frameworks */, - 2F9CBEBD2A76C412009818FF /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 2F9CBEC12A76C412009818FF /* PBXTargetDependency */, - 2FF892302A770DE200903A5A /* PBXTargetDependency */, - ); - name = TestAppWatchAppUITests; - productName = "TestAppWatchOS Watch AppUITests"; - productReference = 2F9CBEBF2A76C412009818FF /* TestAppWatchAppUITests.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; - }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -257,13 +169,6 @@ CreatedOnToolsVersion = 14.1; TestTargetID = 2F6D139128F5F384007C25D6; }; - 2F9CBEA52A76C40E009818FF = { - CreatedOnToolsVersion = 15.0; - }; - 2F9CBEBE2A76C412009818FF = { - CreatedOnToolsVersion = 15.0; - TestTargetID = 2F9CBEA52A76C40E009818FF; - }; }; }; buildConfigurationList = 2F6D138D28F5F384007C25D6 /* Build configuration list for PBXProject "UITests" */; @@ -281,8 +186,6 @@ targets = ( 2F6D139128F5F384007C25D6 /* TestApp */, 2F6D13AB28F5F386007C25D6 /* TestAppUITests */, - 2F9CBEA52A76C40E009818FF /* TestAppWatchApp */, - 2F9CBEBE2A76C412009818FF /* TestAppWatchAppUITests */, ); }; /* End PBXProject section */ @@ -303,21 +206,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 2F9CBEA42A76C40E009818FF /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 2F9CBED82A76C75E009818FF /* Assets.xcassets in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 2F9CBEBD2A76C412009818FF /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -337,22 +225,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 2F9CBEA22A76C40E009818FF /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 2F9CBED72A76C752009818FF /* TestApp.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 2F9CBEBB2A76C412009818FF /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 2F9CBEDB2A76C7EC009818FF /* TestAppUITests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -361,22 +233,6 @@ target = 2F6D139128F5F384007C25D6 /* TestApp */; targetProxy = 2F6D13AD28F5F386007C25D6 /* PBXContainerItemProxy */; }; - 2F9CBEC12A76C412009818FF /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 2F9CBEA52A76C40E009818FF /* TestAppWatchApp */; - targetProxy = 2F9CBEC02A76C412009818FF /* PBXContainerItemProxy */; - }; - 2F9CBEC82A76C412009818FF /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - platformFilter = ios; - target = 2F9CBEA52A76C40E009818FF /* TestAppWatchApp */; - targetProxy = 2F9CBEC72A76C412009818FF /* PBXContainerItemProxy */; - }; - 2FF892302A770DE200903A5A /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 2F6D139128F5F384007C25D6 /* TestApp */; - targetProxy = 2FF8922F2A770DE200903A5A /* PBXContainerItemProxy */; - }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -527,7 +383,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.templatepackage.testapp; + PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezidevices.testapp; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTS_MACCATALYST = NO; @@ -563,7 +419,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.templatepackage.testapp; + PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezidevices.testapp; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTS_MACCATALYST = NO; @@ -585,7 +441,7 @@ DEVELOPMENT_TEAM = 637867499T; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.templatepackage.testapp.uitests; + PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezidevices.testapp.testapp.uitests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = ""; SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; @@ -608,7 +464,7 @@ DEVELOPMENT_TEAM = 637867499T; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.templatepackage.testapp.uitests; + PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezidevices.testapp.testapp.uitests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = ""; SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; @@ -622,173 +478,6 @@ }; name = Release; }; - 2F9CBECB2A76C412009818FF /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 637867499T; - ENABLE_PREVIEWS = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - INFOPLIST_KEY_WKCompanionAppBundleIdentifier = edu.stanford.templatepackage.testapp; - INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = YES; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.templatepackage.testapp.watchkitapp; - PRODUCT_NAME = "TestApp Watch App"; - SDKROOT = watchos; - SKIP_INSTALL = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 4; - }; - name = Debug; - }; - 2F9CBECC2A76C412009818FF /* Test */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 637867499T; - ENABLE_PREVIEWS = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - INFOPLIST_KEY_WKCompanionAppBundleIdentifier = edu.stanford.templatepackage.testapp; - INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = YES; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.templatepackage.testapp.watchkitapp; - PRODUCT_NAME = "TestApp Watch App"; - SDKROOT = watchos; - SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 4; - }; - name = Test; - }; - 2F9CBECD2A76C412009818FF /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 637867499T; - ENABLE_PREVIEWS = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - INFOPLIST_KEY_WKCompanionAppBundleIdentifier = edu.stanford.templatepackage.testapp; - INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = YES; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.templatepackage.testapp.watchkitapp; - PRODUCT_NAME = "TestApp Watch App"; - SDKROOT = watchos; - SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 4; - }; - name = Release; - }; - 2F9CBED42A76C412009818FF /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 637867499T; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.templatepackage.testapp.watchkitapp.uitests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = watchos; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 4; - TEST_TARGET_NAME = TestAppWatchApp; - }; - name = Debug; - }; - 2F9CBED52A76C412009818FF /* Test */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 637867499T; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.templatepackage.testapp.watchkitapp.uitests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = watchos; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 4; - TEST_TARGET_NAME = TestAppWatchApp; - }; - name = Test; - }; - 2F9CBED62A76C412009818FF /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 637867499T; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.templatepackage.testapp.watchkitapp.uitests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = watchos; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 4; - TEST_TARGET_NAME = TestAppWatchApp; - }; - name = Release; - }; 2FB07587299DDB6000C0B37F /* Test */ = { isa = XCBuildConfiguration; buildSettings = { @@ -877,7 +566,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.templatepackage.testapp; + PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezidevices.testapp; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTS_MACCATALYST = NO; @@ -899,7 +588,7 @@ DEVELOPMENT_TEAM = 637867499T; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.templatepackage.testapp.uitests; + PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezidevices.testapp.testapp.uitests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = ""; SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; @@ -946,36 +635,12 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 2F9CBECA2A76C412009818FF /* Build configuration list for PBXNativeTarget "TestAppWatchApp" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 2F9CBECB2A76C412009818FF /* Debug */, - 2F9CBECC2A76C412009818FF /* Test */, - 2F9CBECD2A76C412009818FF /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 2F9CBED32A76C412009818FF /* Build configuration list for PBXNativeTarget "TestAppWatchAppUITests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 2F9CBED42A76C412009818FF /* Debug */, - 2F9CBED52A76C412009818FF /* Test */, - 2F9CBED62A76C412009818FF /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ - 2F68C3C7292EA52000B3E12C /* TemplatePackage */ = { - isa = XCSwiftPackageProductDependency; - productName = TemplatePackage; - }; - 2F9CBED92A76C795009818FF /* TemplatePackage */ = { + 2F68C3C7292EA52000B3E12C /* SpeziDevices */ = { isa = XCSwiftPackageProductDependency; - productName = TemplatePackage; + productName = SpeziDevices; }; /* End XCSwiftPackageProductDependency section */ }; diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj.license b/Tests/UITests/UITests.xcodeproj/project.pbxproj.license index d77e33d..7f16969 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj.license +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj.license @@ -1,4 +1,4 @@ -This source file is part of the TemplatePackage open-source project +This source file is part of the Stanford Spezi open-source project SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) diff --git a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/contents.xcworkspacedata.license b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/contents.xcworkspacedata.license index d77e33d..7f16969 100644 --- a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/contents.xcworkspacedata.license +++ b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/contents.xcworkspacedata.license @@ -1,4 +1,4 @@ -This source file is part of the TemplatePackage open-source project +This source file is part of the Stanford Spezi open-source project SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) diff --git a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist.license b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist.license index d77e33d..7f16969 100644 --- a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist.license +++ b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist.license @@ -1,4 +1,4 @@ -This source file is part of the TemplatePackage open-source project +This source file is part of the Stanford Spezi open-source project SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) diff --git a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme index 2d60ede..288073b 100644 --- a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme +++ b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme @@ -14,9 +14,9 @@ buildForAnalyzing = "NO"> diff --git a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme.license b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme.license index d77e33d..7f16969 100644 --- a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme.license +++ b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme.license @@ -1,4 +1,4 @@ -This source file is part of the TemplatePackage open-source project +This source file is part of the Stanford Spezi open-source project SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) diff --git a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestAppWatchApp.xcscheme b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestAppWatchApp.xcscheme deleted file mode 100644 index 50c8406..0000000 --- a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestAppWatchApp.xcscheme +++ /dev/null @@ -1,122 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestAppWatchApp.xcscheme.license b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestAppWatchApp.xcscheme.license index d77e33d..7f16969 100644 --- a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestAppWatchApp.xcscheme.license +++ b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestAppWatchApp.xcscheme.license @@ -1,4 +1,4 @@ -This source file is part of the TemplatePackage open-source project +This source file is part of the Stanford Spezi open-source project SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) From 5835b20beadcfcc1b6b6abfd96274b0558c81c48 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 25 Jun 2024 22:26:59 +0200 Subject: [PATCH 33/77] Run with StanfordSpezi workflows --- .github/workflows/build-and-test.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 1e0be64..2d371e8 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -18,22 +18,24 @@ on: jobs: packageios: name: Build and Test Swift Package iOS - uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: + runsonlabels: '["macOS", "self-hosted"]' scheme: SpeziDevices-Package resultBundle: SpeziDevices-iOS.xcresult artifactname: SpeziDevices-iOS.xcresult packagevisionos: name: Build and Test Swift Package visionOS - uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: + runsonlabels: '["macOS", "self-hosted"]' scheme: SpeziDevices-Package destination: 'platform=visionOS Simulator,name=Apple Vision Pro' resultBundle: SpeziDevices-visionOS.xcresult artifactname: SpeziDevices-visionOS.xcresult ios: name: Build and Test iOS - uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: runsonlabels: '["macOS", "self-hosted"]' path: 'Tests/UITests' @@ -42,7 +44,7 @@ jobs: artifactname: TestApp-iOS.xcresult ipados: name: Build and Test iPadOS - uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: runsonlabels: '["macOS", "self-hosted"]' path: 'Tests/UITests' @@ -52,7 +54,7 @@ jobs: artifactname: TestApp-iPadOS.xcresult visionos: name: Build and Test visionOS - uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: runsonlabels: '["macOS", "self-hosted"]' path: 'Tests/UITests' @@ -62,7 +64,7 @@ jobs: artifactname: TestApp-visionOS.xcresult codeql: name: CodeQL - uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: codeql: true test: false From 8e28290241da5722599aac19035a902c63ab65a7 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 25 Jun 2024 22:38:19 +0200 Subject: [PATCH 34/77] Project setup --- .github/workflows/build-and-test.yml | 14 ++------------ Package.swift | 2 -- Tests/UITests/UITests.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 8 insertions(+), 20 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 2d371e8..f53f872 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -30,7 +30,7 @@ jobs: with: runsonlabels: '["macOS", "self-hosted"]' scheme: SpeziDevices-Package - destination: 'platform=visionOS Simulator,name=Apple Vision Pro' + destination: 'platform=visionOS Simulator' resultBundle: SpeziDevices-visionOS.xcresult artifactname: SpeziDevices-visionOS.xcresult ios: @@ -42,16 +42,6 @@ jobs: scheme: TestApp resultBundle: TestApp-iOS.xcresult artifactname: TestApp-iOS.xcresult - ipados: - name: Build and Test iPadOS - uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 - with: - runsonlabels: '["macOS", "self-hosted"]' - path: 'Tests/UITests' - scheme: TestApp - destination: 'platform=iOS Simulator,name=iPad Air (5th generation)' - resultBundle: TestApp-iPadOS.xcresult - artifactname: TestApp-iPadOS.xcresult visionos: name: Build and Test visionOS uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 @@ -59,7 +49,7 @@ jobs: runsonlabels: '["macOS", "self-hosted"]' path: 'Tests/UITests' scheme: TestApp - destination: 'platform=visionOS Simulator,name=Apple Vision Pro' + destination: 'platform=visionOS Simulator' resultBundle: TestApp-visionOS.xcresult artifactname: TestApp-visionOS.xcresult codeql: diff --git a/Package.swift b/Package.swift index 9c0f399..659d8f2 100644 --- a/Package.swift +++ b/Package.swift @@ -10,8 +10,6 @@ import PackageDescription -// TODO: DOI in citation.cff - let package = Package( name: "SpeziDevices", diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 9680683..2d75df0 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -385,14 +385,14 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezidevices.testapp; PRODUCT_NAME = "$(TARGET_NAME)"; - SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,3,7"; + TARGETED_DEVICE_FAMILY = "1,2,7"; }; name = Debug; }; @@ -421,14 +421,14 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezidevices.testapp; PRODUCT_NAME = "$(TARGET_NAME)"; - SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,3,7"; + TARGETED_DEVICE_FAMILY = "1,2,7"; }; name = Release; }; @@ -568,14 +568,14 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezidevices.testapp; PRODUCT_NAME = "$(TARGET_NAME)"; - SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,3,7"; + TARGETED_DEVICE_FAMILY = "1,2,7"; }; name = Test; }; From 52606e19a59a2727e5cc18b5ae04d71008723ce2 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 25 Jun 2024 22:39:14 +0200 Subject: [PATCH 35/77] fix --- .github/workflows/build-and-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index f53f872..ac3dee8 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -64,7 +64,7 @@ jobs: actions: read uploadcoveragereport: name: Upload Coverage Report - needs: [packageios, packagevisionos, ios, ipados, visionos] + needs: [packageios, packagevisionos, ios, visionos] uses: StanfordBDHG/.github/.github/workflows/create-and-upload-coverage-report.yml@v2 with: coveragereports: SpeziDevices-iOS.xcresult SpeziDevices-visionOS.xcresult TestApp-iOS.xcresult TestApp-iPadOS.xcresult TestApp-visionOS.xcresult From ef1115644603ca40694eabf36bbd8b63c592d74f Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 25 Jun 2024 22:42:12 +0200 Subject: [PATCH 36/77] Other name? --- .github/workflows/build-and-test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index ac3dee8..0461859 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -30,7 +30,7 @@ jobs: with: runsonlabels: '["macOS", "self-hosted"]' scheme: SpeziDevices-Package - destination: 'platform=visionOS Simulator' + destination: 'platform=visionOS Simulator,name=Any visionOS Simulator Device' resultBundle: SpeziDevices-visionOS.xcresult artifactname: SpeziDevices-visionOS.xcresult ios: @@ -49,7 +49,7 @@ jobs: runsonlabels: '["macOS", "self-hosted"]' path: 'Tests/UITests' scheme: TestApp - destination: 'platform=visionOS Simulator' + destination: 'platform=visionOS Simulator,name=Any visionOS Simulator Device' resultBundle: TestApp-visionOS.xcresult artifactname: TestApp-visionOS.xcresult codeql: From 7f7d6e79c628f36d352266f1e9cb330aa280cab7 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 25 Jun 2024 22:47:49 +0200 Subject: [PATCH 37/77] Can't run visionOS simulator right now --- .github/workflows/build-and-test.yml | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 0461859..72b7aec 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -24,15 +24,6 @@ jobs: scheme: SpeziDevices-Package resultBundle: SpeziDevices-iOS.xcresult artifactname: SpeziDevices-iOS.xcresult - packagevisionos: - name: Build and Test Swift Package visionOS - uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 - with: - runsonlabels: '["macOS", "self-hosted"]' - scheme: SpeziDevices-Package - destination: 'platform=visionOS Simulator,name=Any visionOS Simulator Device' - resultBundle: SpeziDevices-visionOS.xcresult - artifactname: SpeziDevices-visionOS.xcresult ios: name: Build and Test iOS uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 @@ -42,16 +33,6 @@ jobs: scheme: TestApp resultBundle: TestApp-iOS.xcresult artifactname: TestApp-iOS.xcresult - visionos: - name: Build and Test visionOS - uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 - with: - runsonlabels: '["macOS", "self-hosted"]' - path: 'Tests/UITests' - scheme: TestApp - destination: 'platform=visionOS Simulator,name=Any visionOS Simulator Device' - resultBundle: TestApp-visionOS.xcresult - artifactname: TestApp-visionOS.xcresult codeql: name: CodeQL uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 @@ -64,7 +45,7 @@ jobs: actions: read uploadcoveragereport: name: Upload Coverage Report - needs: [packageios, packagevisionos, ios, visionos] + needs: [packageios, ios] uses: StanfordBDHG/.github/.github/workflows/create-and-upload-coverage-report.yml@v2 with: coveragereports: SpeziDevices-iOS.xcresult SpeziDevices-visionOS.xcresult TestApp-iOS.xcresult TestApp-iPadOS.xcresult TestApp-visionOS.xcresult From 1e3a0849e2710ff6188d0b7d016870c565ba230d Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 25 Jun 2024 22:52:14 +0200 Subject: [PATCH 38/77] Fix coverage reports --- .github/workflows/build-and-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 72b7aec..0666dd5 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -48,6 +48,6 @@ jobs: needs: [packageios, ios] uses: StanfordBDHG/.github/.github/workflows/create-and-upload-coverage-report.yml@v2 with: - coveragereports: SpeziDevices-iOS.xcresult SpeziDevices-visionOS.xcresult TestApp-iOS.xcresult TestApp-iPadOS.xcresult TestApp-visionOS.xcresult + coveragereports: SpeziDevices-iOS.xcresult TestApp-iOS.xcresult secrets: token: ${{ secrets.CODECOV_TOKEN }} From 3f2a44086c8b300c80d223b2cea27a268fb79898 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Wed, 26 Jun 2024 18:16:43 +0200 Subject: [PATCH 39/77] Add HealthMeasurements Tests --- Package.swift | 55 +++- Sources/SpeziDevices/HealthMeasurements.swift | 8 + .../Model/SavableDictionary.swift | 26 +- Sources/SpeziDevices/Testing/MockDevice.swift | 22 +- .../BloodPressureMeasurementLabel.swift | 17 +- .../HealthMeasurementsTests.swift | 248 ++++++++++++++++++ .../SpeziDevicesTests/SpeziDevicesTests.swift | 75 ------ 7 files changed, 341 insertions(+), 110 deletions(-) create mode 100644 Tests/SpeziDevicesTests/HealthMeasurementsTests.swift delete mode 100644 Tests/SpeziDevicesTests/SpeziDevicesTests.swift diff --git a/Package.swift b/Package.swift index 659d8f2..f817602 100644 --- a/Package.swift +++ b/Package.swift @@ -8,9 +8,17 @@ // SPDX-License-Identifier: MIT // +import class Foundation.ProcessInfo import PackageDescription +#if swift(<6) +let swiftConcurrency: SwiftSetting = .enableExperimentalFeature("SwiftConcurrency") +#else +let swiftConcurrency: SwiftSetting = .enableUpcomingFeature("SwiftConcurrency") +#endif + + let package = Package( name: "SpeziDevices", defaultLocalization: "en", @@ -29,9 +37,8 @@ let package = Package( .package(url: "https://github.com/StanfordSpezi/SpeziViews.git", branch: "feature/configure-tipkit-module"), .package(url: "https://github.com/StanfordSpezi/SpeziBluetooth", branch: "feature/accessory-discovery"), .package(url: "https://github.com/StanfordSpezi/SpeziNetworking", from: "2.0.0"), - .package(url: "https://github.com/JWAutumn/ACarousel", .upToNextMinor(from: "0.2.0")), - .package(url: "https://github.com/realm/SwiftLint.git", .upToNextMinor(from: "0.55.1")) - ], + .package(url: "https://github.com/JWAutumn/ACarousel", .upToNextMinor(from: "0.2.0")) + ] + swiftLintPackage(), targets: [ .target( name: "SpeziDevices", @@ -42,7 +49,10 @@ let package = Package( .product(name: "SpeziBluetoothServices", package: "SpeziBluetooth"), .product(name: "SpeziViews", package: "SpeziViews") ], - plugins: [.swiftLintPlugin] + swiftSettings: [ + swiftConcurrency + ], + plugins: [] + swiftLintPlugin() ), .target( name: "SpeziDevicesUI", @@ -56,7 +66,10 @@ let package = Package( resources: [ .process("Resources") ], - plugins: [.swiftLintPlugin] + swiftSettings: [ + swiftConcurrency + ], + plugins: [] + swiftLintPlugin() ), .target( name: "SpeziOmron", @@ -65,14 +78,20 @@ let package = Package( .product(name: "SpeziBluetooth", package: "SpeziBluetooth"), .product(name: "SpeziBluetoothServices", package: "SpeziBluetooth") ], - plugins: [.swiftLintPlugin] + swiftSettings: [ + swiftConcurrency + ], + plugins: [] + swiftLintPlugin() ), .testTarget( name: "SpeziDevicesTests", dependencies: [ .target(name: "SpeziDevices") ], - plugins: [.swiftLintPlugin] + swiftSettings: [ + swiftConcurrency + ], + plugins: [] + swiftLintPlugin() ), .testTarget( name: "SpeziOmronTests", @@ -81,14 +100,28 @@ let package = Package( .product(name: "SpeziBluetooth", package: "SpeziBluetooth"), .product(name: "XCTByteCoding", package: "SpeziNetworking") ], - plugins: [.swiftLintPlugin] + swiftSettings: [ + swiftConcurrency + ], + plugins: [] + swiftLintPlugin() ) ] ) -extension Target.PluginUsage { - static var swiftLintPlugin: Target.PluginUsage { - .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLint") +func swiftLintPlugin() -> [Target.PluginUsage] { + // Fully quit Xcode and open again with `open --env SPEZI_DEVELOPMENT_SWIFTLINT /Applications/Xcode.app` + if ProcessInfo.processInfo.environment["SPEZI_DEVELOPMENT_SWIFTLINT"] != nil { + [.plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLint")] + } else { + [] + } +} + +func swiftLintPackage() -> [PackageDescription.Package.Dependency] { + if ProcessInfo.processInfo.environment["SPEZI_DEVELOPMENT_SWIFTLINT"] != nil { + [.package(url: "https://github.com/realm/SwiftLint.git", .upToNextMinor(from: "0.55.1"))] + } else { + [] } } diff --git a/Sources/SpeziDevices/HealthMeasurements.swift b/Sources/SpeziDevices/HealthMeasurements.swift index f43c290..53f38d7 100644 --- a/Sources/SpeziDevices/HealthMeasurements.swift +++ b/Sources/SpeziDevices/HealthMeasurements.swift @@ -88,6 +88,7 @@ public class HealthMeasurements { @MainActor public var shouldPresentMeasurements = false /// The current queue of pending measurements. /// + /// The newest measurement is always prepended. /// To clear pending measurements call ``discardMeasurement(_:)``. @MainActor public private(set) var pendingMeasurements: [HealthKitMeasurement] = [] @MainActor @AppStorage @ObservationIgnored private var storedMeasurements: SavableDictionary @@ -114,6 +115,13 @@ public class HealthMeasurements { self.pendingMeasurements = measurements } + /// Clears all currently stored records on disk. + @_spi(TestingSupport) + @MainActor + public func clearStorage() { + storedMeasurements.removeAll() + } + /// Configure the Module. @_documentation(visibility: internal) public func configure() { diff --git a/Sources/SpeziDevices/Model/SavableDictionary.swift b/Sources/SpeziDevices/Model/SavableDictionary.swift index d0248af..2fc0d8a 100644 --- a/Sources/SpeziDevices/Model/SavableDictionary.swift +++ b/Sources/SpeziDevices/Model/SavableDictionary.swift @@ -6,17 +6,18 @@ // SPDX-License-Identifier: MIT // +import OrderedCollections import OSLog struct SavableDictionary { - private var storage: [Key: Value] + private var storage: OrderedDictionary - var keys: Dictionary.Keys { + var keys: OrderedSet { storage.keys } - var values: Dictionary.Values { + var values: OrderedDictionary.Values { storage.values } @@ -35,6 +36,10 @@ struct SavableDictionary { storage[key] = newValue } } + + mutating func removeAll() { + storage.removeAll() + } } @@ -48,22 +53,22 @@ extension SavableDictionary: ExpressibleByDictionaryLiteral { extension SavableDictionary: Collection { - public typealias Index = Dictionary.Index - public typealias Element = Dictionary.Iterator.Element + public typealias Index = OrderedDictionary.Index + public typealias Element = OrderedDictionary.Iterator.Element public var startIndex: Index { - storage.startIndex + storage.elements.startIndex } public var endIndex: Index { - storage.endIndex + storage.elements.endIndex } public func index(after index: Index) -> Index { - storage.index(after: index) + storage.elements.index(after: index) } public subscript(position: Index) -> Element { - storage[position] + storage.elements[position] } } @@ -96,7 +101,8 @@ extension SavableDictionary: RawRepresentable { } do { - self.storage = try JSONDecoder().decode([Key: Value].self, from: data) + // TODO: is the order maintained? + self.storage = try JSONDecoder().decode(OrderedDictionary.self, from: data) } catch { Self.logger.error("Failed to decode \(Self.self): \(error)") return nil diff --git a/Sources/SpeziDevices/Testing/MockDevice.swift b/Sources/SpeziDevices/Testing/MockDevice.swift index 067f899..d15f03b 100644 --- a/Sources/SpeziDevices/Testing/MockDevice.swift +++ b/Sources/SpeziDevices/Testing/MockDevice.swift @@ -38,6 +38,13 @@ public final class MockDevice: PairableDevice, HealthDevice { public init() {} + public func configure() { + $state.onChange { [weak self] state in + self?.handleStateChange(state) + } + } + + fileprivate func handleStateChange(_ state: PeripheralState) { if case .connected = state { Task { @MainActor in @@ -100,12 +107,10 @@ extension MockDevice { return } device.$state.inject(.connecting) - device.handleStateChange(.connecting) try? await Task.sleep(for: .seconds(1)) device.$state.inject(.connected) - device.handleStateChange(.connected) } device.$disconnect.inject { @MainActor [weak device] in @@ -113,9 +118,20 @@ extension MockDevice { return } device.$state.inject(.disconnected) - device.handleStateChange(.connected) } + device.$state.enableSubscriptions() + device.$advertisementData.enableSubscriptions() + device.$nearby.enableSubscriptions() + + device.bloodPressure.$bloodPressureMeasurement.enableSubscriptions() + device.bloodPressure.$bloodPressureMeasurement.enablePeripheralSimulation() + + device.weightScale.$weightMeasurement.enableSubscriptions() + device.weightScale.$weightMeasurement.enablePeripheralSimulation() + + device.configure() + return device } } diff --git a/Sources/SpeziDevicesUI/Measurements/BloodPressureMeasurementLabel.swift b/Sources/SpeziDevicesUI/Measurements/BloodPressureMeasurementLabel.swift index 95f5f57..ccc0765 100644 --- a/Sources/SpeziDevicesUI/Measurements/BloodPressureMeasurementLabel.swift +++ b/Sources/SpeziDevicesUI/Measurements/BloodPressureMeasurementLabel.swift @@ -17,21 +17,16 @@ struct BloodPressureMeasurementLabel: View { @ScaledMetric private var measurementTextSize: CGFloat = 50 - private var bloodPressureQuantitySamples: [HKQuantitySample] { - bloodPressureSample.objects - .compactMap { sample in - sample as? HKQuantitySample - } - } - private var systolic: HKQuantitySample? { - bloodPressureQuantitySamples - .first(where: { $0.quantityType == HKQuantityType(.bloodPressureSystolic) }) + bloodPressureSample + .objects(for: HKQuantityType(.bloodPressureSystolic)) + .first as? HKQuantitySample } private var diastolic: HKQuantitySample? { - bloodPressureQuantitySamples - .first(where: { $0.quantityType == HKQuantityType(.bloodPressureDiastolic) }) + bloodPressureSample + .objects(for: HKQuantityType(.bloodPressureDiastolic)) + .first as? HKQuantitySample } var body: some View { diff --git a/Tests/SpeziDevicesTests/HealthMeasurementsTests.swift b/Tests/SpeziDevicesTests/HealthMeasurementsTests.swift new file mode 100644 index 0000000..b1206c3 --- /dev/null +++ b/Tests/SpeziDevicesTests/HealthMeasurementsTests.swift @@ -0,0 +1,248 @@ +// +// 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 +@_spi(TestingSupport) import SpeziBluetooth +import SpeziBluetoothServices +@_spi(TestingSupport) import SpeziDevices +import XCTest + + +final class HealthMeasurementsTests: XCTestCase { + @MainActor + func testReceivingWeightMeasurements() async throws { + let device = MockDevice.createMockDevice(weightMeasurement: .mock(additionalInfo: .init(bmi: 230, height: 1790))) + let measurements = HealthMeasurements() + defer { + measurements.clearStorage() + } + + measurements.configureReceivingMeasurements(for: device, on: device.weightScale) + + // just inject the same value again to trigger on change! + let measurement = try XCTUnwrap(device.weightScale.weightMeasurement) + device.weightScale.$weightMeasurement.inject(measurement) + + try await Task.sleep(for: .milliseconds(50)) + + XCTAssertTrue(measurements.shouldPresentMeasurements) + XCTAssertEqual(measurements.pendingMeasurements.count, 1) + + let weightMeasurement = try XCTUnwrap(measurements.pendingMeasurements.first) + guard case let .weight(sample, bmi0, height0) = weightMeasurement else { + XCTFail("Unexpected type of measurement: \(weightMeasurement)") + return + } + + let bmi = try XCTUnwrap(bmi0) + let height = try XCTUnwrap(height0) + + let expectedDate = try XCTUnwrap(device.weightScale.weightMeasurement?.timeStamp?.date) + + XCTAssertEqual(sample.quantityType, HKQuantityType(.bodyMass)) + XCTAssertEqual(sample.startDate, expectedDate) + XCTAssertEqual(sample.endDate, sample.startDate) + XCTAssertEqual(sample.quantity.doubleValue(for: .gramUnit(with: .kilo)), 42.0) + XCTAssertEqual(sample.device?.name, "Mock Device") + + + XCTAssertEqual(bmi.quantityType, HKQuantityType(.bodyMassIndex)) + XCTAssertEqual(bmi.startDate, expectedDate) + XCTAssertEqual(bmi.endDate, sample.startDate) + XCTAssertEqual(bmi.quantity.doubleValue(for: .count()), 23) + XCTAssertEqual(bmi.device?.name, "Mock Device") + + XCTAssertEqual(height.quantityType, HKQuantityType(.height)) + XCTAssertEqual(height.startDate, expectedDate) + XCTAssertEqual(height.endDate, sample.startDate) + XCTAssertEqual(height.quantity.doubleValue(for: .meterUnit(with: .centi)), 179.0) + XCTAssertEqual(height.device?.name, "Mock Device") + } + + @MainActor + func testReceivingBloodPressureMeasurements() async throws { + let device = MockDevice.createMockDevice() + let measurements = HealthMeasurements() + defer { + measurements.clearStorage() + } + + measurements.configureReceivingMeasurements(for: device, on: device.bloodPressure) + + // just inject the same value again to trigger on change! + let measurement = try XCTUnwrap(device.bloodPressure.bloodPressureMeasurement) + device.bloodPressure.$bloodPressureMeasurement.inject(measurement) + + try await Task.sleep(for: .milliseconds(50)) + + XCTAssertTrue(measurements.shouldPresentMeasurements) + XCTAssertEqual(measurements.pendingMeasurements.count, 1) + + let bloodPressureMeasurement = try XCTUnwrap(measurements.pendingMeasurements.first) + guard case let .bloodPressure(sample, heartRate0) = bloodPressureMeasurement else { + XCTFail("Unexpected type of measurement: \(bloodPressureMeasurement)") + return + } + + let expectedDate = try XCTUnwrap(device.weightScale.weightMeasurement?.timeStamp?.date) + let heartRate = try XCTUnwrap(heartRate0) + + + XCTAssertEqual(heartRate.quantityType, HKQuantityType(.heartRate)) + XCTAssertEqual(heartRate.startDate, expectedDate) + XCTAssertEqual(heartRate.endDate, sample.startDate) + XCTAssertEqual(heartRate.quantity.doubleValue(for: .count().unitDivided(by: .minute())), 62) + XCTAssertEqual(heartRate.device?.name, "Mock Device") + + XCTAssertEqual(sample.objects.count, 2) + let systolic = try XCTUnwrap(sample.objects(for: HKQuantityType(.bloodPressureSystolic)).first as? HKQuantitySample) + let diastolic = try XCTUnwrap(sample.objects(for: HKQuantityType(.bloodPressureDiastolic)).first as? HKQuantitySample) + + XCTAssertEqual(systolic.quantityType, HKQuantityType(.bloodPressureSystolic)) + XCTAssertEqual(systolic.startDate, expectedDate) + XCTAssertEqual(systolic.endDate, sample.startDate) + XCTAssertEqual(systolic.quantity.doubleValue(for: .millimeterOfMercury()), 103.0) + XCTAssertEqual(systolic.device?.name, "Mock Device") + + XCTAssertEqual(diastolic.quantityType, HKQuantityType(.bloodPressureDiastolic)) + XCTAssertEqual(diastolic.startDate, expectedDate) + XCTAssertEqual(diastolic.endDate, sample.startDate) + XCTAssertEqual(diastolic.quantity.doubleValue(for: .millimeterOfMercury()), 64.0) + XCTAssertEqual(diastolic.device?.name, "Mock Device") + } + + @MainActor + func testMeasurementStorage() async throws { + let measurements1 = HealthMeasurements() + let measurements2 = HealthMeasurements() + defer { + measurements2.clearStorage() + } + + measurements1.loadMockWeightMeasurement() + measurements1.loadMockBloodPressureMeasurement() + + XCTAssertEqual(measurements1.pendingMeasurements.count, 2) + XCTAssertEqual(measurements2.pendingMeasurements.count, 0) + + measurements2.configure() + try await Task.sleep(for: .milliseconds(50)) + XCTAssertEqual(measurements2.pendingMeasurements.count, 2) + // tests that order stays same over storage retrieval + + // Restoring from disk doesn't preserve HealthKit UUIDs + guard case .bloodPressure = measurements1.pendingMeasurements.first, + case .bloodPressure = measurements2.pendingMeasurements.first, + case .weight = measurements1.pendingMeasurements.last, + case .weight = measurements2.pendingMeasurements.last else { + XCTFail("Order of measurements doesn't match: \(measurements1.pendingMeasurements) vs. \(measurements2.pendingMeasurements)") + return + } + } + + @MainActor + func testDiscardingMeasurements() async throws { + let device = MockDevice.createMockDevice() + let measurements = HealthMeasurements() + defer { + measurements.clearStorage() + } + + measurements.configureReceivingMeasurements(for: device, on: device.bloodPressure) + measurements.configureReceivingMeasurements(for: device, on: device.weightScale) + + let measurement1 = try XCTUnwrap(device.weightScale.weightMeasurement) + device.weightScale.$weightMeasurement.inject(measurement1) + let measurement0 = try XCTUnwrap(device.bloodPressure.bloodPressureMeasurement) + device.bloodPressure.$bloodPressureMeasurement.inject(measurement0) + + try await Task.sleep(for: .milliseconds(50)) + + XCTAssertTrue(measurements.shouldPresentMeasurements) + XCTAssertEqual(measurements.pendingMeasurements.count, 2) + + let bloodPressureMeasurement = try XCTUnwrap(measurements.pendingMeasurements.first) + + // measurements are prepended + guard case .bloodPressure = bloodPressureMeasurement else { + XCTFail("Unexpected type of measurement: \(bloodPressureMeasurement)") + return + } + + measurements.discardMeasurement(bloodPressureMeasurement) + XCTAssertTrue(measurements.shouldPresentMeasurements) + XCTAssertEqual(measurements.pendingMeasurements.count, 1) + + let weightMeasurement = try XCTUnwrap(measurements.pendingMeasurements.first) + guard case .weight = weightMeasurement else { + XCTFail("Unexpected type of measurement: \(weightMeasurement)") + return + } + } + + 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] + ) + ) + } +} diff --git a/Tests/SpeziDevicesTests/SpeziDevicesTests.swift b/Tests/SpeziDevicesTests/SpeziDevicesTests.swift deleted file mode 100644 index 1c18f88..0000000 --- a/Tests/SpeziDevicesTests/SpeziDevicesTests.swift +++ /dev/null @@ -1,75 +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 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] - ) - ) - } -} From f4f0591d08a3bb76d7fd6dc95e966d966b840b1f Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Wed, 26 Jun 2024 18:40:13 +0200 Subject: [PATCH 40/77] Update SpeziViews --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index f817602..3a44773 100644 --- a/Package.swift +++ b/Package.swift @@ -34,7 +34,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-collections.git", from: "1.1.1"), .package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "1.1.1"), - .package(url: "https://github.com/StanfordSpezi/SpeziViews.git", branch: "feature/configure-tipkit-module"), + .package(url: "https://github.com/StanfordSpezi/SpeziViews.git", from: "1.5.0"), .package(url: "https://github.com/StanfordSpezi/SpeziBluetooth", branch: "feature/accessory-discovery"), .package(url: "https://github.com/StanfordSpezi/SpeziNetworking", from: "2.0.0"), .package(url: "https://github.com/JWAutumn/ACarousel", .upToNextMinor(from: "0.2.0")) From e7f132861d6279f72ce35132a0cbf78dbb95b4ae Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Wed, 26 Jun 2024 20:10:25 +0200 Subject: [PATCH 41/77] Add PairedDevices tests --- Package.swift | 10 +- Sources/SpeziDevices/HealthMeasurements.swift | 2 +- .../BluetoothHealthMeasurement.swift | 2 +- .../Measurements/HealthKitMeasurement.swift | 2 +- .../Measurements/StoredMeasurement.swift | 2 +- .../Model/SavableDictionary.swift | 9 +- Sources/SpeziDevices/PairedDevices.swift | 19 +- Sources/SpeziDevices/Testing/MockDevice.swift | 26 ++- .../BloodPressureMeasurementLabel.swift | 2 +- .../Measurements/CloseButtonLayer.swift | 2 +- .../ConfirmMeasurementButton.swift | 2 +- .../Measurements/MeasurementLayer.swift | 2 +- .../MeasurementRecordedSheet.swift | 2 +- .../Measurements/WeightMeasurementLabel.swift | 2 +- .../HealthMeasurementsTests.swift | 9 +- .../ImageReferenceTests.swift | 37 ++++ .../PairedDevicesTests.swift | 203 ++++++++++++++++++ 17 files changed, 301 insertions(+), 32 deletions(-) create mode 100644 Tests/SpeziDevicesTests/ImageReferenceTests.swift create mode 100644 Tests/SpeziDevicesTests/PairedDevicesTests.swift diff --git a/Package.swift b/Package.swift index 3a44773..3b035c7 100644 --- a/Package.swift +++ b/Package.swift @@ -34,6 +34,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-collections.git", from: "1.1.1"), .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", branch: "feature/accessory-discovery"), .package(url: "https://github.com/StanfordSpezi/SpeziNetworking", from: "2.0.0"), @@ -47,7 +48,8 @@ let package = Package( .product(name: "SpeziFoundation", package: "SpeziFoundation"), .product(name: "SpeziBluetooth", package: "SpeziBluetooth"), .product(name: "SpeziBluetoothServices", package: "SpeziBluetooth"), - .product(name: "SpeziViews", package: "SpeziViews") + .product(name: "SpeziViews", package: "SpeziViews"), + .product(name: "Spezi", package: "Spezi") ], swiftSettings: [ swiftConcurrency @@ -86,7 +88,11 @@ let package = Package( .testTarget( name: "SpeziDevicesTests", dependencies: [ - .target(name: "SpeziDevices") + .target(name: "SpeziDevices"), + .product(name: "SpeziFoundation", package: "SpeziFoundation"), + .product(name: "XCTSpezi", package: "Spezi"), + .product(name: "SpeziBluetooth", package: "SpeziBluetooth"), + .product(name: "SpeziBluetoothServices", package: "SpeziBluetooth") ], swiftSettings: [ swiftConcurrency diff --git a/Sources/SpeziDevices/HealthMeasurements.swift b/Sources/SpeziDevices/HealthMeasurements.swift index 53f38d7..80d700d 100644 --- a/Sources/SpeziDevices/HealthMeasurements.swift +++ b/Sources/SpeziDevices/HealthMeasurements.swift @@ -1,5 +1,5 @@ // -// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project +// This source file is part of the Stanford SpeziDevices open source project // // SPDX-FileCopyrightText: 2024 Stanford University // diff --git a/Sources/SpeziDevices/Measurements/BluetoothHealthMeasurement.swift b/Sources/SpeziDevices/Measurements/BluetoothHealthMeasurement.swift index 8fa6eb2..7f61b97 100644 --- a/Sources/SpeziDevices/Measurements/BluetoothHealthMeasurement.swift +++ b/Sources/SpeziDevices/Measurements/BluetoothHealthMeasurement.swift @@ -1,5 +1,5 @@ // -// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project +// This source file is part of the Stanford SpeziDevices open source project // // SPDX-FileCopyrightText: 2024 Stanford University // diff --git a/Sources/SpeziDevices/Measurements/HealthKitMeasurement.swift b/Sources/SpeziDevices/Measurements/HealthKitMeasurement.swift index d3cdf96..57f0577 100644 --- a/Sources/SpeziDevices/Measurements/HealthKitMeasurement.swift +++ b/Sources/SpeziDevices/Measurements/HealthKitMeasurement.swift @@ -1,5 +1,5 @@ // -// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project +// This source file is part of the Stanford SpeziDevices open source project // // SPDX-FileCopyrightText: 2024 Stanford University // diff --git a/Sources/SpeziDevices/Measurements/StoredMeasurement.swift b/Sources/SpeziDevices/Measurements/StoredMeasurement.swift index e5b5e93..1f680da 100644 --- a/Sources/SpeziDevices/Measurements/StoredMeasurement.swift +++ b/Sources/SpeziDevices/Measurements/StoredMeasurement.swift @@ -1,5 +1,5 @@ // -// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project +// This source file is part of the Stanford SpeziDevices open source project // // SPDX-FileCopyrightText: 2024 Stanford University // diff --git a/Sources/SpeziDevices/Model/SavableDictionary.swift b/Sources/SpeziDevices/Model/SavableDictionary.swift index 2fc0d8a..329d3a3 100644 --- a/Sources/SpeziDevices/Model/SavableDictionary.swift +++ b/Sources/SpeziDevices/Model/SavableDictionary.swift @@ -25,6 +25,10 @@ struct SavableDictionary { self.storage = [:] } + mutating func removeAll() { + storage.removeAll() + } + subscript(key: Key) -> Value? { get { storage[key] @@ -36,10 +40,6 @@ struct SavableDictionary { storage[key] = newValue } } - - mutating func removeAll() { - storage.removeAll() - } } @@ -101,7 +101,6 @@ extension SavableDictionary: RawRepresentable { } do { - // TODO: is the order maintained? self.storage = try JSONDecoder().decode(OrderedDictionary.self, from: data) } catch { Self.logger.error("Failed to decode \(Self.self): \(error)") diff --git a/Sources/SpeziDevices/PairedDevices.swift b/Sources/SpeziDevices/PairedDevices.swift index 446b619..5e00a09 100644 --- a/Sources/SpeziDevices/PairedDevices.swift +++ b/Sources/SpeziDevices/PairedDevices.swift @@ -74,11 +74,9 @@ import SwiftUI /// - ``shouldPresentDevicePairing`` /// - ``discoveredDevices`` /// - ``isScanningForNearbyDevices`` +/// - ``pair(with:timeout:)`` /// - ``pairedDevices`` /// -/// ### Add Paired Device -/// - ``registerPairedDevice(_:)`` -/// /// ### Forget Paired Device /// - ``forgetDevice(id:)`` /// @@ -164,6 +162,13 @@ public final class PairedDevices { } } + /// Clears all currently stored paired devices. + @_spi(TestingSupport) + @MainActor + public func clearStorage() { + pairedDevices.removeAll() + } + /// 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. @@ -351,10 +356,12 @@ extension PairedDevices { /// - Important: A successful pairing cannot be determined automatically and is specific to a device. You must manually call /// ``signalDevicePaired(_:)`` to signal that a device is successfully paired (e.g., every time the device sends a notification for /// a given characteristic). - /// - Parameter device: The device to pair with this module. + /// - Parameters: + /// - device: The device to pair with this module. + /// - timeout: The duration after which the pairing attempt times out. /// - Throws: Throws a ``DevicePairingError`` if not successful. @MainActor - public func pair(with device: some PairableDevice) async throws { + public func pair(with device: some PairableDevice, timeout: Duration = .seconds(15)) async throws { guard ongoingPairings[device.id] == nil else { throw DevicePairingError.busy } @@ -374,7 +381,7 @@ extension PairedDevices { await device.connect() let id = device.id - async let _ = withTimeout(of: .seconds(15)) { @MainActor in + async let _ = withTimeout(of: timeout) { @MainActor in ongoingPairings.removeValue(forKey: id)?.signalTimeout() } diff --git a/Sources/SpeziDevices/Testing/MockDevice.swift b/Sources/SpeziDevices/Testing/MockDevice.swift index d15f03b..583728e 100644 --- a/Sources/SpeziDevices/Testing/MockDevice.swift +++ b/Sources/SpeziDevices/Testing/MockDevice.swift @@ -14,7 +14,7 @@ import SpeziNumerics #if DEBUG || TEST @_spi(TestingSupport) -public final class MockDevice: PairableDevice, HealthDevice { +public final class MockDevice: PairableDevice, HealthDevice, BatteryPoweredDevice { @DeviceState(\.id) public var id @DeviceState(\.name) public var name @DeviceState(\.state) public var state @@ -26,6 +26,7 @@ public final class MockDevice: PairableDevice, HealthDevice { @Service public var deviceInformation = DeviceInformationService() + @Service public var battery = BatteryService() // Some mock health measurement services @Service public var bloodPressure = BloodPressureService() @@ -46,10 +47,16 @@ public final class MockDevice: PairableDevice, HealthDevice { fileprivate func handleStateChange(_ state: PeripheralState) { - if case .connected = state { - Task { @MainActor in - try await Task.sleep(for: .seconds(2)) - pairedDevices?.signalDevicePaired(self) + if isInPairingMode { // automatically respond to pairing event + if case .connected = state { + Task { @MainActor in + try await Task.sleep(for: .seconds(2)) + + guard case .connected = self.state else { + return + } + pairedDevices?.signalDevicePaired(self) + } } } } @@ -84,6 +91,8 @@ extension MockDevice { device.deviceInformation.$hardwareRevision.inject("2") device.deviceInformation.$firmwareRevision.inject("1.0") + device.battery.$batteryLevel.inject(85) + device.$id.inject(UUID()) device.$name.inject(name) device.$state.inject(state) @@ -110,7 +119,9 @@ extension MockDevice { try? await Task.sleep(for: .seconds(1)) - device.$state.inject(.connected) + if case .connecting = device.state { + device.$state.inject(.connected) + } } device.$disconnect.inject { @MainActor [weak device] in @@ -124,6 +135,9 @@ extension MockDevice { device.$advertisementData.enableSubscriptions() device.$nearby.enableSubscriptions() + device.battery.$batteryLevel.enableSubscriptions() + device.battery.$batteryLevel.enablePeripheralSimulation() + device.bloodPressure.$bloodPressureMeasurement.enableSubscriptions() device.bloodPressure.$bloodPressureMeasurement.enablePeripheralSimulation() diff --git a/Sources/SpeziDevicesUI/Measurements/BloodPressureMeasurementLabel.swift b/Sources/SpeziDevicesUI/Measurements/BloodPressureMeasurementLabel.swift index ccc0765..9ab92c1 100644 --- a/Sources/SpeziDevicesUI/Measurements/BloodPressureMeasurementLabel.swift +++ b/Sources/SpeziDevicesUI/Measurements/BloodPressureMeasurementLabel.swift @@ -1,5 +1,5 @@ // -// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project +// This source file is part of the Stanford SpeziDevices open source project // // SPDX-FileCopyrightText: 2024 Stanford University // diff --git a/Sources/SpeziDevicesUI/Measurements/CloseButtonLayer.swift b/Sources/SpeziDevicesUI/Measurements/CloseButtonLayer.swift index c48e0ea..bee5763 100644 --- a/Sources/SpeziDevicesUI/Measurements/CloseButtonLayer.swift +++ b/Sources/SpeziDevicesUI/Measurements/CloseButtonLayer.swift @@ -1,5 +1,5 @@ // -// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project +// This source file is part of the Stanford SpeziDevices open source project // // SPDX-FileCopyrightText: 2023 Stanford University // diff --git a/Sources/SpeziDevicesUI/Measurements/ConfirmMeasurementButton.swift b/Sources/SpeziDevicesUI/Measurements/ConfirmMeasurementButton.swift index 1982f2e..f52a833 100644 --- a/Sources/SpeziDevicesUI/Measurements/ConfirmMeasurementButton.swift +++ b/Sources/SpeziDevicesUI/Measurements/ConfirmMeasurementButton.swift @@ -1,5 +1,5 @@ // -// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project +// This source file is part of the Stanford SpeziDevices open source project // // SPDX-FileCopyrightText: 2023 Stanford University // diff --git a/Sources/SpeziDevicesUI/Measurements/MeasurementLayer.swift b/Sources/SpeziDevicesUI/Measurements/MeasurementLayer.swift index ac33985..3fa5161 100644 --- a/Sources/SpeziDevicesUI/Measurements/MeasurementLayer.swift +++ b/Sources/SpeziDevicesUI/Measurements/MeasurementLayer.swift @@ -1,5 +1,5 @@ // -// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project +// This source file is part of the Stanford SpeziDevices open source project // // SPDX-FileCopyrightText: 2023 Stanford University // diff --git a/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift b/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift index 6513133..11523f0 100644 --- a/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift +++ b/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift @@ -1,5 +1,5 @@ // -// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project +// This source file is part of the Stanford SpeziDevices open source project // // SPDX-FileCopyrightText: 2023 Stanford University // diff --git a/Sources/SpeziDevicesUI/Measurements/WeightMeasurementLabel.swift b/Sources/SpeziDevicesUI/Measurements/WeightMeasurementLabel.swift index d10fadb..5fa5bb9 100644 --- a/Sources/SpeziDevicesUI/Measurements/WeightMeasurementLabel.swift +++ b/Sources/SpeziDevicesUI/Measurements/WeightMeasurementLabel.swift @@ -1,5 +1,5 @@ // -// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project +// This source file is part of the Stanford SpeziDevices open source project // // SPDX-FileCopyrightText: 2024 Stanford University // diff --git a/Tests/SpeziDevicesTests/HealthMeasurementsTests.swift b/Tests/SpeziDevicesTests/HealthMeasurementsTests.swift index b1206c3..6c0f214 100644 --- a/Tests/SpeziDevicesTests/HealthMeasurementsTests.swift +++ b/Tests/SpeziDevicesTests/HealthMeasurementsTests.swift @@ -1,5 +1,5 @@ // -// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project +// This source file is part of the Stanford SpeziDevices open source project // // SPDX-FileCopyrightText: 2024 Stanford University // @@ -41,9 +41,10 @@ final class HealthMeasurementsTests: XCTestCase { let bmi = try XCTUnwrap(bmi0) let height = try XCTUnwrap(height0) - let expectedDate = try XCTUnwrap(device.weightScale.weightMeasurement?.timeStamp?.date) + XCTAssertEqual(weightMeasurement.samples, [sample, bmi, height]) + XCTAssertEqual(sample.quantityType, HKQuantityType(.bodyMass)) XCTAssertEqual(sample.startDate, expectedDate) XCTAssertEqual(sample.endDate, sample.startDate) @@ -89,8 +90,10 @@ final class HealthMeasurementsTests: XCTestCase { return } - let expectedDate = try XCTUnwrap(device.weightScale.weightMeasurement?.timeStamp?.date) let heartRate = try XCTUnwrap(heartRate0) + let expectedDate = try XCTUnwrap(device.weightScale.weightMeasurement?.timeStamp?.date) + + XCTAssertEqual(bloodPressureMeasurement.samples, [sample, heartRate]) XCTAssertEqual(heartRate.quantityType, HKQuantityType(.heartRate)) diff --git a/Tests/SpeziDevicesTests/ImageReferenceTests.swift b/Tests/SpeziDevicesTests/ImageReferenceTests.swift new file mode 100644 index 0000000..b15be6a --- /dev/null +++ b/Tests/SpeziDevicesTests/ImageReferenceTests.swift @@ -0,0 +1,37 @@ +// +// This source file is part of the Stanford SpeziDevices open source project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +@testable import SpeziDevices +import XCTest + + +class ImageReferenceTests: XCTestCase { + func testAssetCodable() throws { + for bundle in Bundle.allBundles { + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let value = ImageReference.asset("ref", bundle: bundle) + let referenceString = try encoder.encode(value) + let reference = try decoder.decode(ImageReference.self, from: referenceString) + + XCTAssertEqual(reference, value, "Failed to encode/decode asset for bundle \(String(describing: bundle.bundleIdentifier))") + } + } + + func testSystemCodable() throws { + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let value = ImageReference.system("sensor") + let referenceString = try encoder.encode(value) + let reference = try decoder.decode(ImageReference.self, from: referenceString) + + XCTAssertEqual(reference, value) + } +} diff --git a/Tests/SpeziDevicesTests/PairedDevicesTests.swift b/Tests/SpeziDevicesTests/PairedDevicesTests.swift new file mode 100644 index 0000000..f8dd453 --- /dev/null +++ b/Tests/SpeziDevicesTests/PairedDevicesTests.swift @@ -0,0 +1,203 @@ +// +// This source file is part of the Stanford SpeziDevices open source project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +@_spi(TestingSupport) import SpeziBluetooth +import SpeziBluetoothServices +@_spi(TestingSupport) import SpeziDevices +import SpeziFoundation +import XCTest +import XCTSpezi + + +final class PairedDevicesTests: XCTestCase { + @MainActor + func testPairDevice() async throws { + let device = MockDevice.createMockDevice() + let devices = PairedDevices() + defer { + devices.clearStorage() + } + + + // ensure PairedDevices gets injected into the MockDevice + withDependencyResolution { + device + devices + } + + + XCTAssertFalse(devices.isConnected(device: device.id)) + XCTAssertFalse(devices.isPaired(device)) + + devices.configure(device: device, accessing: device.$state, device.$advertisementData, device.$nearby) + + try await devices.pair(with: device) + + + XCTAssertTrue(devices.isPaired(device)) + XCTAssertTrue(devices.isConnected(device: device.id)) + + XCTAssertEqual(devices.pairedDevices.count, 1) + let deviceInfo = try XCTUnwrap(devices.pairedDevices.first) + + XCTAssertEqual(deviceInfo.id, device.id) + XCTAssertEqual(deviceInfo.deviceType, MockDevice.deviceTypeIdentifier) + XCTAssertNil(deviceInfo.icon) + XCTAssertEqual(deviceInfo.model, device.deviceInformation.modelNumber) + XCTAssertEqual(deviceInfo.name, device.name) + XCTAssertEqual(deviceInfo.lastBatteryPercentage, 85) + + let initialLastSeen = deviceInfo.lastSeen + + device.battery.$batteryLevel.inject(71) + await device.disconnect() + device.$nearby.inject(false) + + XCTAssertEqual(device.state, .disconnected) + + try await Task.sleep(for: .milliseconds(50)) + + + XCTAssertTrue(deviceInfo.lastSeen > initialLastSeen) // should be later and updated on disconnect + XCTAssertEqual(deviceInfo.lastBatteryPercentage, 71) // should have captured the updated battery + + + devices.updateName(for: deviceInfo, name: "Custom Name") + XCTAssertEqual(deviceInfo.name, "Custom Name") + + let recentLastSeen = deviceInfo.lastSeen + try { // test storage persistence! + let devices2 = PairedDevices() + XCTAssertEqual(devices2.pairedDevices.count, 1) + let info0 = try XCTUnwrap(devices2.pairedDevices.first) + XCTAssertEqual(info0.name, "Custom Name") + XCTAssertEqual(info0.lastBatteryPercentage, 71) + XCTAssertEqual(info0.lastSeen, recentLastSeen) + }() + + + await device.connect() + try await Task.sleep(for: .seconds(1.1)) + XCTAssertEqual(device.state, .connected) + + + devices.forgetDevice(id: device.id) + try await Task.sleep(for: .milliseconds(50)) + + XCTAssertEqual(device.state, .disconnected) + XCTAssertTrue(devices.pairedDevices.isEmpty) + XCTAssertTrue(devices.discoveredDevices.isEmpty) + } + + @MainActor + func testPairingErrors() async throws { + let device = MockDevice.createMockDevice() + let devices = PairedDevices() + defer { + devices.clearStorage() + } + + withDependencyResolution { + devices + } + + device.$nearby.inject(false) + try await XCTAssertThrowsErrorAsync(await devices.pair(with: device)) { error in + XCTAssertEqual(try XCTUnwrap(error as? DevicePairingError), .invalidState) + } + device.$nearby.inject(true) + + await device.connect() + try await XCTAssertThrowsErrorAsync(await devices.pair(with: device)) { error in + XCTAssertEqual(try XCTUnwrap(error as? DevicePairingError), .invalidState) + } + await device.disconnect() + + device.isInPairingMode = false + try await XCTAssertThrowsErrorAsync(await devices.pair(with: device)) { error in + XCTAssertEqual(try XCTUnwrap(error as? DevicePairingError), .notInPairingMode) + } + device.isInPairingMode = true + + try await XCTAssertThrowsErrorAsync(await devices.pair(with: device, timeout: .milliseconds(200))) { error in + XCTAssertTrue(error is TimeoutError) + } + } + + @MainActor + func testPairingCancellation() async throws { + let device = MockDevice.createMockDevice() + let devices = PairedDevices() + defer { + devices.clearStorage() + } + + withDependencyResolution { + devices + } + + let task = Task { + try await devices.pair(with: device) + } + + try await Task.sleep(for: .milliseconds(50)) + task.cancel() + + try await XCTAssertThrowsErrorAsync(await task.value) { error in + XCTAssertTrue(error is CancellationError) + } + + XCTAssertEqual(device.state, .disconnected) + } + + @MainActor + func testFailedPairing() async throws { + let device = MockDevice.createMockDevice() + let devices = PairedDevices() + defer { + devices.clearStorage() + } + + withDependencyResolution { + device + devices + } + + devices.configure(device: device, accessing: device.$state, device.$advertisementData, device.$nearby) + + let task = Task { + try await devices.pair(with: device) + } + + try await Task.sleep(for: .milliseconds(1150)) + await device.disconnect() + + try await XCTAssertThrowsErrorAsync(await task.value) { error in + print(error) + XCTAssertEqual(try XCTUnwrap(error as? DevicePairingError), .deviceDisconnected) + } + + XCTAssertEqual(device.state, .disconnected) + } +} + + +func XCTAssertThrowsErrorAsync( + _ expression: @autoclosure () async throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, + line: UInt = #line, + _ errorHandler: (Error) throws -> Void = { _ in } +) async rethrows { + do { + _ = try await expression() + XCTFail(message(), file: file, line: line) + } catch { + try errorHandler(error) + } +} From 8e3698bdeebe9da04a6a198612d02d3178b2fb14 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Wed, 26 Jun 2024 22:17:20 +0200 Subject: [PATCH 42/77] Add SpeziOmron tests and some fixes --- .../OmronRecordAccessOperand.swift | 45 +++-- Sources/SpeziOmron/OmronHealthDevice.swift | 2 +- .../SpeziOmron/OmronManufacturerData.swift | 15 ++ Sources/SpeziOmron/OmronModel.swift | 30 ++-- Sources/SpeziOmron/OmronOptionService.swift | 2 +- .../PairedDevicesTests.swift | 2 +- Tests/SpeziOmronTests/Empty.swift | 9 - Tests/SpeziOmronTests/SpeziOmronTests.swift | 166 ++++++++++++++++++ 8 files changed, 224 insertions(+), 47 deletions(-) delete mode 100644 Tests/SpeziOmronTests/Empty.swift create mode 100644 Tests/SpeziOmronTests/SpeziOmronTests.swift diff --git a/Sources/SpeziOmron/Characteristic/OmronRecordAccessOperand.swift b/Sources/SpeziOmron/Characteristic/OmronRecordAccessOperand.swift index e2db4d9..d2c3e08 100644 --- a/Sources/SpeziOmron/Characteristic/OmronRecordAccessOperand.swift +++ b/Sources/SpeziOmron/Characteristic/OmronRecordAccessOperand.swift @@ -15,13 +15,7 @@ public enum OmronRecordAccessOperand { // REQUEST /// Specify filter criteria for supported requests. - /// - /// For more information refer to ``RecordAccessGenericOperand``. - case filterCriteria(RecordAccessFilterCriteria) - /// Specify range-based filter criteria for supported requests. - /// - /// For more information refer to ``RecordAccessGenericOperand``. - case rangeFilterCriteria(RecordAccessRangeFilterCriteria) + case sequenceNumberFilter(UInt16) // RESPONSE @@ -34,6 +28,14 @@ public enum OmronRecordAccessOperand { } +extension OmronRecordAccessOperand: Hashable, Sendable {} + + +extension RecordAccessFilterType { + static let omronSequenceNumber = RecordAccessFilterType(rawValue: 0x04) +} + + extension OmronRecordAccessOperand: RecordAccessOperand { public var generalResponse: RecordAccessGeneralResponse? { guard case let .generalResponse(response) = self else { @@ -56,15 +58,12 @@ extension OmronRecordAccessOperand: RecordAccessOperand { case .reportStoredRecords, .deleteStoredRecords, .reportNumberOfStoredRecords: switch `operator` { case .lessThanOrEqualTo, .greaterThanOrEqual: - guard let filterCriteria = RecordAccessFilterCriteria(from: &byteBuffer) else { + guard let filterType = RecordAccessFilterType(from: &byteBuffer), + case .omronSequenceNumber = filterType, + let sequenceNumber = UInt16(from: &byteBuffer) else { return nil } - self = .filterCriteria(filterCriteria) - case .withinInclusiveRangeOf: - guard let filterCriteria = RecordAccessRangeFilterCriteria(from: &byteBuffer) else { - return nil - } - self = .rangeFilterCriteria(filterCriteria) + self = .sequenceNumberFilter(sequenceNumber) default: return nil } @@ -87,12 +86,22 @@ extension OmronRecordAccessOperand: RecordAccessOperand { switch self { case let .generalResponse(response): response.encode(to: &byteBuffer) - case let .filterCriteria(criteria): - criteria.encode(to: &byteBuffer) - case let .rangeFilterCriteria(criteria): - criteria.encode(to: &byteBuffer) + case let .sequenceNumberFilter(value): + RecordAccessFilterType.omronSequenceNumber.encode(to: &byteBuffer) + value.encode(to: &byteBuffer) case let .numberOfRecords(value), let .sequenceNumber(value): value.encode(to: &byteBuffer) } } } + + +extension RecordAccessOperationContent where Operand == OmronRecordAccessOperand { + /// Records that are greater than or equal to the specified sequence number. + /// + /// - Parameter sequenceNumber: The sequence number to use as a filter criteria. + /// - Returns: The operation content. + public static func greaterThanOrEqualTo(sequenceNumber: UInt16) -> RecordAccessOperationContent { + RecordAccessOperationContent(operator: .greaterThanOrEqual, operand: .sequenceNumberFilter(sequenceNumber)) + } +} diff --git a/Sources/SpeziOmron/OmronHealthDevice.swift b/Sources/SpeziOmron/OmronHealthDevice.swift index 4b83734..7bd6e23 100644 --- a/Sources/SpeziOmron/OmronHealthDevice.swift +++ b/Sources/SpeziOmron/OmronHealthDevice.swift @@ -20,7 +20,7 @@ public protocol OmronHealthDevice: HealthDevice, PairableDevice {} extension OmronHealthDevice { /// The Omron model string. public var model: OmronModel { - OmronModel(deviceInformation.modelNumber ?? "Generic Health Device") + OmronModel(rawValue: deviceInformation.modelNumber ?? "Generic Health Device") } /// The Omron Manufacturer data observed in the Bluetooth advertisement. diff --git a/Sources/SpeziOmron/OmronManufacturerData.swift b/Sources/SpeziOmron/OmronManufacturerData.swift index fd9b1eb..502bc1c 100644 --- a/Sources/SpeziOmron/OmronManufacturerData.swift +++ b/Sources/SpeziOmron/OmronManufacturerData.swift @@ -124,6 +124,21 @@ public struct OmronManufacturerData { extension OmronManufacturerData.UserSlot: Identifiable {} +extension OmronManufacturerData.UserSlot: Hashable, Sendable {} + + +extension OmronManufacturerData: Hashable, Sendable {} + + +extension OmronManufacturerData.PairingMode: Hashable, Sendable {} + + +extension OmronManufacturerData.StreamingMode: Hashable, Sendable {} + + +extension OmronManufacturerData.ServicesMode: Hashable, Sendable {} + + extension OmronManufacturerData.Flags: ByteCodable { public init?(from byteBuffer: inout ByteBuffer) { guard let rawValue = UInt8(from: &byteBuffer) else { diff --git a/Sources/SpeziOmron/OmronModel.swift b/Sources/SpeziOmron/OmronModel.swift index 2623b26..27265da 100644 --- a/Sources/SpeziOmron/OmronModel.swift +++ b/Sources/SpeziOmron/OmronModel.swift @@ -7,13 +7,13 @@ // -public struct OmronModel: RawRepresentable { +/// Omron Model. +public struct OmronModel { + /// The raw model number. public let rawValue: String - public init(_ model: String) { - self.init(rawValue: model) - } - + /// Initialize from raw value. + /// - Parameter rawValue: The raw model number string. public init(rawValue: String) { self.rawValue = rawValue } @@ -22,20 +22,16 @@ public struct OmronModel: RawRepresentable { extension OmronModel { /// The Omron SC150 weight scale. - public static let sc150 = OmronModel("SC-150") + public static let sc150 = OmronModel(rawValue: "SC-150") /// The Omron BP5250 blood pressure monitor. - public static let bp5250 = OmronModel("BP5250") + public static let bp5250 = OmronModel(rawValue: "BP5250") } -extension OmronModel: Codable { - public init(from decoder: any Decoder) throws { - let decoder = try decoder.singleValueContainer() - self.rawValue = try decoder.decode(String.self) - } +extension OmronModel: RawRepresentable {} - public func encode(to encoder: any Encoder) throws { - var encoder = encoder.singleValueContainer() - try encoder.encode(rawValue) - } -} + +extension OmronModel: Hashable, Sendable {} + + +extension OmronModel: Codable {} diff --git a/Sources/SpeziOmron/OmronOptionService.swift b/Sources/SpeziOmron/OmronOptionService.swift index 7f7b2cb..86cebfc 100644 --- a/Sources/SpeziOmron/OmronOptionService.swift +++ b/Sources/SpeziOmron/OmronOptionService.swift @@ -18,7 +18,7 @@ public final class OmronOptionService: BluetoothService, @unchecked Sendable { public static let id = CBUUID(string: "5DF5E817-A945-4F81-89C0-3D4E9759C07C") - @Characteristic(id: "2A52", notify: true) private var recordAccessControlPoint: RecordAccessControlPoint? + @Characteristic(id: "2A52", notify: true) var recordAccessControlPoint: RecordAccessControlPoint? public init() {} diff --git a/Tests/SpeziDevicesTests/PairedDevicesTests.swift b/Tests/SpeziDevicesTests/PairedDevicesTests.swift index f8dd453..ad78159 100644 --- a/Tests/SpeziDevicesTests/PairedDevicesTests.swift +++ b/Tests/SpeziDevicesTests/PairedDevicesTests.swift @@ -187,7 +187,7 @@ final class PairedDevicesTests: XCTestCase { } -func XCTAssertThrowsErrorAsync( +func XCTAssertThrowsErrorAsync( // TODO: use from XCTestExtensions (also in SpeziBluetooth)! _ expression: @autoclosure () async throws -> T, _ message: @autoclosure () -> String = "", file: StaticString = #filePath, diff --git a/Tests/SpeziOmronTests/Empty.swift b/Tests/SpeziOmronTests/Empty.swift deleted file mode 100644 index 1a0ef8d..0000000 --- a/Tests/SpeziOmronTests/Empty.swift +++ /dev/null @@ -1,9 +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 Foundation diff --git a/Tests/SpeziOmronTests/SpeziOmronTests.swift b/Tests/SpeziOmronTests/SpeziOmronTests.swift new file mode 100644 index 0000000..2395d8f --- /dev/null +++ b/Tests/SpeziOmronTests/SpeziOmronTests.swift @@ -0,0 +1,166 @@ +// +// 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 +// + +@_spi(TestingSupport) import ByteCoding +import CoreBluetooth +@_spi(TestingSupport) import SpeziBluetooth +import SpeziBluetoothServices +@_spi(TestingSupport) import SpeziDevices +@testable import SpeziOmron +import XCTByteCoding +import XCTest + + +typealias RACP = RecordAccessControlPoint + +final class SpeziOmronTests: XCTestCase { + func testModelCodable() throws { + let string = "\"SC-150\"" + let data = try XCTUnwrap(string.data(using: .utf8)) + let decoded = try JSONDecoder().decode(OmronModel.self, from: data) + XCTAssertEqual(decoded, .sc150) + } + + func testOmronManufacturerData() throws { + try testIdentity(from: OmronManufacturerData( + timeSet: true, + pairingMode: .pairingMode, + streamingMode: .dataCommunication, + servicesMode: .bluetoothStandard, + users: [.init(id: 1, sequenceNumber: 3, recordsNumber: 8)] + )) + + try testIdentity(from: OmronManufacturerData( + timeSet: false, + pairingMode: .transferMode, + streamingMode: .streaming, + servicesMode: .omronExtension, + users: [ + .init(id: 1, sequenceNumber: 3, recordsNumber: 8), + .init(id: 2, sequenceNumber: 0, recordsNumber: 0) + ] + )) + + try testIdentity(from: OmronManufacturerData( + timeSet: false, + pairingMode: .transferMode, + streamingMode: .streaming, + servicesMode: .omronExtension, + users: [ + .init(id: 1, sequenceNumber: 3, recordsNumber: 8), + .init(id: 2, sequenceNumber: 0, recordsNumber: 0), + .init(id: 3, sequenceNumber: 5, recordsNumber: 0) + ] + )) + + try testIdentity(from: OmronManufacturerData( + timeSet: false, + pairingMode: .transferMode, + streamingMode: .streaming, + servicesMode: .omronExtension, + users: [ + .init(id: 1, sequenceNumber: 3, recordsNumber: 8), + .init(id: 2, sequenceNumber: 0, recordsNumber: 0), + .init(id: 3, sequenceNumber: 5, recordsNumber: 0), + .init(id: 4, sequenceNumber: 9, recordsNumber: 0) + ] + )) + } + + func testOmronHealthDevice() throws { + let manufacturerData = OmronManufacturerData(pairingMode: .pairingMode, users: [.init(id: 1, sequenceNumber: 3, recordsNumber: 8)]) + + let device = MockDevice.createMockDevice() + device.$advertisementData.inject(AdvertisementData([ + CBAdvertisementDataManufacturerDataKey: manufacturerData.encode() + ])) + + XCTAssertTrue(device.isInPairingMode) + + let manufacturerData0 = OmronManufacturerData(pairingMode: .transferMode, users: [.init(id: 1, sequenceNumber: 3, recordsNumber: 8)]) + device.$advertisementData.inject(AdvertisementData([ + CBAdvertisementDataManufacturerDataKey: manufacturerData0.encode() + ])) + + XCTAssertFalse(device.isInPairingMode) + + + device.deviceInformation.$modelNumber.inject(OmronModel.bp5250.rawValue) + + XCTAssertEqual(device.model, .bp5250) + } + + func testRACPReportStoredRecords() throws { + try testIdentity(from: RACP.reportStoredRecords(.allRecords)) + try testIdentity(from: RACP.reportStoredRecords(.lastRecord)) + try testIdentity(from: RACP.reportStoredRecords(.firstRecord)) + try testIdentity(from: RACP.reportStoredRecords(.greaterThanOrEqualTo(sequenceNumber: 12))) + + try testIdentity(of: RACP.self, from: XCTUnwrap(Data(hex: "0x0101"))) // Report All Records + try testIdentity(of: RACP.self, from: XCTUnwrap(Data(hex: "0x010304FFFF"))) // Report greater than or equal to + try testIdentity(of: RACP.self, from: XCTUnwrap(Data(hex: "0x06000101"))) // SUCCESS + try testIdentity(of: RACP.self, from: XCTUnwrap(Data(hex: "0x06000106"))) // no records found + } + + func testRACPReportNumberOfStoredRecords() throws { + try testIdentity(from: RACP.reportNumberOfStoredRecords(.allRecords)) + try testIdentity(from: RACP.reportNumberOfStoredRecords(.lastRecord)) + try testIdentity(from: RACP.reportNumberOfStoredRecords(.firstRecord)) + try testIdentity(from: RACP.reportNumberOfStoredRecords(.greaterThanOrEqualTo(sequenceNumber: 12))) + + try testIdentity(of: RACP.self, from: XCTUnwrap(Data(hex: "0x0401"))) // Report All Records + try testIdentity(of: RACP.self, from: XCTUnwrap(Data(hex: "0x040304FFFF"))) // Report greater than or equal to + try testIdentity(of: RACP.self, from: XCTUnwrap(Data(hex: "0x0500FFFF"))) // Response + } + + func testRACPNumberOfLatestRecords() throws { + try testIdentity(from: RACP.reportSequenceNumberOfLatestRecords()) + + try testIdentity(of: RACP.self, from: XCTUnwrap(Data(hex: "0x1100FFFF"))) // Response + } + + func testRACPReportRecordsRequest() async throws { + let service = OmronOptionService() + + service.$recordAccessControlPoint.onRequest { _ in + RACP(opCode: .responseCode, operator: .null, operand: .generalResponse(.init(requestOpCode: .reportStoredRecords, response: .success))) + } + try await service.reportStoredRecords(.allRecords) + + // TODO: async throws throwing (no records found!) + } + + func testRACPReportNumberOfStoredRecordsRequest() async throws { + let service = OmronOptionService() + + service.$recordAccessControlPoint.onRequest { _ in + RACP(opCode: .numberOfStoredRecordsResponse, operator: .null, operand: .numberOfRecords(1234)) + } + + let count = try await service.reportNumberOfStoredRecords(.allRecords) + XCTAssertEqual(count, 1234) + + // TODO: test error unexpected operand! + } + + func testRACPReportSequenceNumberOfLatestRecords() async throws { + let service = OmronOptionService() + + service.$recordAccessControlPoint.onRequest { _ in + RACP(opCode: .omronSequenceNumberOfLatestRecordsResponse, operator: .null, operand: .sequenceNumber(1234)) + } + + let count = try await service.reportSequenceNumberOfLatestRecords() + XCTAssertEqual(count, 1234) + + // TODO: test error unexpected operand! + } +} + + +extension MockDevice: OmronHealthDevice {} From 13218c324febb66ef92d5459b090d72dde54baf0 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Wed, 26 Jun 2024 23:43:36 +0200 Subject: [PATCH 43/77] Add UI test app --- Sources/SpeziDevices/HealthMeasurements.swift | 1 + Sources/SpeziDevices/Testing/MockDevice.swift | 6 +- Tests/UITests/TestApp/ContentView.swift | 39 +++++++++ Tests/UITests/TestApp/DevicesTestView.swift | 80 +++++++++++++++++++ .../TestApp/Health/HKCorrelationView.swift | 39 +++++++++ .../TestApp/Health/HKQuantitySampleView.swift | 61 ++++++++++++++ .../UITests/TestApp/Health/HKSampleView.swift | 63 +++++++++++++++ .../TestApp/MeasurementsTestView.swift | 75 +++++++++++++++++ Tests/UITests/TestApp/TestApp.swift | 26 +++++- .../UITests/UITests.xcodeproj/project.pbxproj | 41 ++++++++++ 10 files changed, 426 insertions(+), 5 deletions(-) create mode 100644 Tests/UITests/TestApp/ContentView.swift create mode 100644 Tests/UITests/TestApp/DevicesTestView.swift create mode 100644 Tests/UITests/TestApp/Health/HKCorrelationView.swift create mode 100644 Tests/UITests/TestApp/Health/HKQuantitySampleView.swift create mode 100644 Tests/UITests/TestApp/Health/HKSampleView.swift create mode 100644 Tests/UITests/TestApp/MeasurementsTestView.swift diff --git a/Sources/SpeziDevices/HealthMeasurements.swift b/Sources/SpeziDevices/HealthMeasurements.swift index 80d700d..1adffc9 100644 --- a/Sources/SpeziDevices/HealthMeasurements.swift +++ b/Sources/SpeziDevices/HealthMeasurements.swift @@ -53,6 +53,7 @@ import SwiftUI /// @Environment(HealthMeasurements.self) private var measurements /// /// var body: some View { +/// @Bindable var measurements = measurements /// ContentView() /// .sheet(isPresented: $measurements.shouldPresentMeasurements) { /// MeasurementRecordedSheet { measurement in diff --git a/Sources/SpeziDevices/Testing/MockDevice.swift b/Sources/SpeziDevices/Testing/MockDevice.swift index 583728e..92b7d9f 100644 --- a/Sources/SpeziDevices/Testing/MockDevice.swift +++ b/Sources/SpeziDevices/Testing/MockDevice.swift @@ -34,7 +34,7 @@ public final class MockDevice: PairableDevice, HealthDevice, BatteryPoweredDevic @Dependency private var pairedDevices: PairedDevices? - public var isInPairingMode: Bool = true + public var isInPairingMode: Bool = false public init() {} @@ -202,8 +202,8 @@ extension WeightMeasurement { weight: UInt16 = 8400, unit: WeightMeasurement.Unit = .si, timeStamp: DateTime? = DateTime(year: 2024, month: .june, day: 5, hours: 12, minutes: 33, seconds: 11), - userId: UInt8? = nil, - additionalInfo: AdditionalInfo? = nil + userId: UInt8? = 1, + additionalInfo: AdditionalInfo? = .init(bmi: 230, height: 1790) ) -> WeightMeasurement { WeightMeasurement( weight: weight, diff --git a/Tests/UITests/TestApp/ContentView.swift b/Tests/UITests/TestApp/ContentView.swift new file mode 100644 index 0000000..038c143 --- /dev/null +++ b/Tests/UITests/TestApp/ContentView.swift @@ -0,0 +1,39 @@ +// +// 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 SpeziBluetooth +import SpeziDevices +import SpeziDevicesUI +import SwiftUI + + +struct ContentView: View { + var body: some View { + TabView { + DevicesTestView() + .tabItem { + Label("Devices", systemImage: "sensor.fill") + } + MeasurementsTestView() + .tabItem { + Label("Measurements", systemImage: "list.bullet.clipboard.fill") + } + } + } +} + + +#Preview { + ContentView() + .previewWith { + MockDeviceLoading() + PairedDevices() + HealthMeasurements() + Bluetooth {} + } +} diff --git a/Tests/UITests/TestApp/DevicesTestView.swift b/Tests/UITests/TestApp/DevicesTestView.swift new file mode 100644 index 0000000..a72ee6a --- /dev/null +++ b/Tests/UITests/TestApp/DevicesTestView.swift @@ -0,0 +1,80 @@ +// +// 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 +// + +@_spi(APISupport) import Spezi +@_spi(TestingSupport) import SpeziBluetooth +@_spi(TestingSupport) import SpeziDevices +import SpeziDevicesUI +import SpeziViews +import SwiftUI + + +class MockDeviceLoading: Module, EnvironmentAccessible { + @Application(\.spezi) private var spezi + + init() {} + + func loadMockDevice(_ device: some PairableDevice) { + spezi.loadModule(device, ownership: .external) + } +} + + +struct DevicesTestView: View { + @Environment(PairedDevices.self) private var pairedDevices + @Environment(MockDeviceLoading.self) private var moduleLoading + + @State private var didRegister = false + @State private var device = MockDevice.createMockDevice() + + var body: some View { + NavigationStack { + DevicesTab(appName: "TestApp") + .toolbar { + ToolbarItemGroup(placement: .secondaryAction) { + Button("Discover Device", systemImage: "plus.rectangle.fill.on.rectangle.fill") { + device.isInPairingMode = true + device.$advertisementData.inject(AdvertisementData([:])) // trigger onChange advertisement + } + AsyncButton { + await device.connect() + } label: { + Label("Connect", systemImage: "cable.connector") + } + AsyncButton { + await device.disconnect() + } label: { + Label("Disconnect", systemImage: "cable.connector.slash") + } + } + } + } + .onAppear { + pairedDevices.clearStorage() // we clear storage for testing purposes + + guard !didRegister else { + return + } + + moduleLoading.loadMockDevice(device) + // simulator this being called in the configure method of the device + pairedDevices.configure(device: device, accessing: device.$state, device.$advertisementData, device.$nearby) + didRegister = true + } + } +} + + +#Preview { + DevicesTestView() + .previewWith { + PairedDevices() + MockDeviceLoading() + Bluetooth {} + } +} diff --git a/Tests/UITests/TestApp/Health/HKCorrelationView.swift b/Tests/UITests/TestApp/Health/HKCorrelationView.swift new file mode 100644 index 0000000..683a161 --- /dev/null +++ b/Tests/UITests/TestApp/Health/HKCorrelationView.swift @@ -0,0 +1,39 @@ +// +// 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 +@_spi(TestingSupport) import SpeziDevices +import SpeziDevicesUI +import SpeziViews +import SwiftUI + + +struct HKCorrelationView: View { + private let correlation: HKCorrelation + + var body: some View { + if let systolic = correlation.objects(for: HKQuantityType(.bloodPressureSystolic)).first as? HKQuantitySample { + HKQuantitySampleView(systolic) + } + if let diastolic = correlation.objects(for: HKQuantityType(.bloodPressureDiastolic)).first as? HKQuantitySample { + HKQuantitySampleView(diastolic) + } + } + + init(_ correlation: HKCorrelation) { + self.correlation = correlation + } +} + + +#Preview { + List { + HKCorrelationView(.mockBloodPressureSample) + } +} diff --git a/Tests/UITests/TestApp/Health/HKQuantitySampleView.swift b/Tests/UITests/TestApp/Health/HKQuantitySampleView.swift new file mode 100644 index 0000000..5644510 --- /dev/null +++ b/Tests/UITests/TestApp/Health/HKQuantitySampleView.swift @@ -0,0 +1,61 @@ +// +// 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 +@_spi(TestingSupport) import SpeziDevices +import SpeziDevicesUI +import SpeziViews +import SwiftUI + + +struct HKQuantitySampleView: View { + private let sample: HKQuantitySample + + var body: some View { + VStack(alignment: .leading) { + ListRow(sample.quantity.description) { + Text(sample.startDate, style: .time) + } + if let device = sample.device, let name = device.name { + Text(name) + .foregroundStyle(.secondary) + .font(.caption2) + } + } + } + + init(_ sample: HKQuantitySample) { + self.sample = sample + } +} + + +#Preview { + List { + HKQuantitySampleView(HKQuantitySample.mockWeighSample) + } +} + +#Preview { + List { + HKQuantitySampleView(HKQuantitySample.mockBmiSample) + } +} + +#Preview { + List { + HKQuantitySampleView(HKQuantitySample.mockHeightSample) + } +} + +#Preview { + List { + HKQuantitySampleView(HKQuantitySample.mockHeartRateSample) + } +} diff --git a/Tests/UITests/TestApp/Health/HKSampleView.swift b/Tests/UITests/TestApp/Health/HKSampleView.swift new file mode 100644 index 0000000..7817c1a --- /dev/null +++ b/Tests/UITests/TestApp/Health/HKSampleView.swift @@ -0,0 +1,63 @@ +// +// 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 +@_spi(TestingSupport) import SpeziDevices +import SwiftUI + + +struct HKSampleView: View { + private let sample: HKSample + + var body: some View { + switch sample.sampleType { + case HKQuantityType(.heartRate), HKQuantityType(.bodyMass), HKQuantityType(.bodyMassIndex), HKQuantityType(.height): + HKQuantitySampleView(sample as! HKQuantitySample) // swiftlint:disable:this force_cast + case HKCorrelationType(.bloodPressure): + HKCorrelationView(sample as! HKCorrelation) // swiftlint:disable:this force_cast + default: + Text("Unknown sample type: \(sample.sampleType)") + } + } + + + init(_ sample: HKSample) { + self.sample = sample + } +} + + +#Preview { + List { + HKSampleView(HKQuantitySample.mockWeighSample) + } +} + +#Preview { + List { + HKSampleView(HKQuantitySample.mockBmiSample) + } +} + +#Preview { + List { + HKSampleView(HKQuantitySample.mockHeightSample) + } +} + +#Preview { + List { + HKSampleView(HKQuantitySample.mockHeartRateSample) + } +} + +#Preview { + List { + HKSampleView(HKCorrelation.mockBloodPressureSample) + } +} diff --git a/Tests/UITests/TestApp/MeasurementsTestView.swift b/Tests/UITests/TestApp/MeasurementsTestView.swift new file mode 100644 index 0000000..f17f522 --- /dev/null +++ b/Tests/UITests/TestApp/MeasurementsTestView.swift @@ -0,0 +1,75 @@ +// +// 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 +@_spi(TestingSupport) import SpeziDevices +import SpeziDevicesUI +import SwiftUI + + +struct MeasurementsTestView: View { + @Environment(HealthMeasurements.self) private var healthMeasurements + + @State private var samples: [HKSample] = [] + + var body: some View { + @Bindable var healthMeasurements = healthMeasurements + NavigationStack { // swiftlint:disable:this closure_body_length + Group { + if samples.isEmpty { + ContentUnavailableView( + "No Samples", + systemImage: "heart.text.square", + description: Text("Please add new measurements.") + ) + } else { + List { + ForEach(samples, id: \.uuid) { sample in + HKSampleView(sample) + } + } + } + } + .navigationTitle("Measurements") + .sheet(isPresented: $healthMeasurements.shouldPresentMeasurements) { + MeasurementRecordedSheet { samples in + self.samples.append(contentsOf: samples) + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("Add Measurement", systemImage: "plus") { + healthMeasurements.shouldPresentMeasurements = true + } + } + ToolbarItemGroup(placement: .secondaryAction) { + Button("Simulate Weight", systemImage: "scalemass.fill") { + healthMeasurements.loadMockWeightMeasurement() + } + Button("Simulate Blood Pressure", systemImage: "heart.fill") { + healthMeasurements.loadMockBloodPressureMeasurement() + } + } + } + } + .onAppear { + healthMeasurements.clearStorage() + } + } + + init() {} +} + + +#Preview { + MeasurementsTestView() + .previewWith { + HealthMeasurements() + } +} diff --git a/Tests/UITests/TestApp/TestApp.swift b/Tests/UITests/TestApp/TestApp.swift index 4ade30a..049549f 100644 --- a/Tests/UITests/TestApp/TestApp.swift +++ b/Tests/UITests/TestApp/TestApp.swift @@ -6,14 +6,36 @@ // SPDX-License-Identifier: MIT // +import Spezi +import SpeziBluetooth +import SpeziBluetoothServices +@_spi(TestingSupport) import SpeziDevices +import SpeziDevicesUI import SwiftUI +class TestAppDelegate: SpeziAppDelegate { + override var configuration: Configuration { + Configuration { + Bluetooth { + Discover(MockDevice.self, by: .accessory(manufacturer: .init(rawValue: 0x01), advertising: BloodPressureService.self)) + } + PairedDevices() + HealthMeasurements() + MockDeviceLoading() + } + } +} + + @main -struct UITestsApp: App { +struct TestApp: App { + @ApplicationDelegateAdaptor(TestAppDelegate.self) private var delegate + var body: some Scene { WindowGroup { - Text("Hello World") + ContentView() + .spezi(delegate) } } } diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 2d75df0..1b99d23 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -11,6 +11,13 @@ 2F6D139A28F5F386007C25D6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2F6D139928F5F386007C25D6 /* Assets.xcassets */; }; 2F8A431329130A8C005D2B8F /* TestAppUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F8A431229130A8C005D2B8F /* TestAppUITests.swift */; }; 2FA7382C290ADFAA007ACEB9 /* TestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */; }; + A922BB1A2C2CB072009DD0E1 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A922BB192C2CB072009DD0E1 /* ContentView.swift */; }; + A922BB1C2C2CB0AB009DD0E1 /* SpeziDevicesUI in Frameworks */ = {isa = PBXBuildFile; productRef = A922BB1B2C2CB0AB009DD0E1 /* SpeziDevicesUI */; }; + A922BB1E2C2CB276009DD0E1 /* DevicesTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A922BB1D2C2CB276009DD0E1 /* DevicesTestView.swift */; }; + A922BB202C2CB280009DD0E1 /* MeasurementsTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A922BB1F2C2CB280009DD0E1 /* MeasurementsTestView.swift */; }; + A959B7E62C2CBF0900ACA775 /* HKSampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A959B7E52C2CBF0900ACA775 /* HKSampleView.swift */; }; + A959B7E82C2CBF1400ACA775 /* HKQuantitySampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A959B7E72C2CBF1400ACA775 /* HKQuantitySampleView.swift */; }; + A959B7EB2C2CC05A00ACA775 /* HKCorrelationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A959B7EA2C2CC05A00ACA775 /* HKCorrelationView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -44,6 +51,12 @@ 2F8A431229130A8C005D2B8F /* TestAppUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAppUITests.swift; sourceTree = ""; }; 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestApp.swift; sourceTree = ""; }; 2FB0758A299DDB9000C0B37F /* TestApp.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestApp.xctestplan; sourceTree = ""; }; + A922BB192C2CB072009DD0E1 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + A922BB1D2C2CB276009DD0E1 /* DevicesTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesTestView.swift; sourceTree = ""; }; + A922BB1F2C2CB280009DD0E1 /* MeasurementsTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeasurementsTestView.swift; sourceTree = ""; }; + A959B7E52C2CBF0900ACA775 /* HKSampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKSampleView.swift; sourceTree = ""; }; + A959B7E72C2CBF1400ACA775 /* HKQuantitySampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKQuantitySampleView.swift; sourceTree = ""; }; + A959B7EA2C2CC05A00ACA775 /* HKCorrelationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKCorrelationView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -51,6 +64,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + A922BB1C2C2CB0AB009DD0E1 /* SpeziDevicesUI in Frameworks */, 2F68C3C8292EA52000B3E12C /* SpeziDevices in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -89,8 +103,12 @@ 2F6D139428F5F384007C25D6 /* TestApp */ = { isa = PBXGroup; children = ( + A922BB192C2CB072009DD0E1 /* ContentView.swift */, + A922BB1D2C2CB276009DD0E1 /* DevicesTestView.swift */, + A922BB1F2C2CB280009DD0E1 /* MeasurementsTestView.swift */, 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */, 2F6D139928F5F386007C25D6 /* Assets.xcassets */, + A959B7E92C2CC04400ACA775 /* Health */, ); path = TestApp; sourceTree = ""; @@ -110,6 +128,16 @@ name = Frameworks; sourceTree = ""; }; + A959B7E92C2CC04400ACA775 /* Health */ = { + isa = PBXGroup; + children = ( + A959B7E52C2CBF0900ACA775 /* HKSampleView.swift */, + A959B7E72C2CBF1400ACA775 /* HKQuantitySampleView.swift */, + A959B7EA2C2CC05A00ACA775 /* HKCorrelationView.swift */, + ); + path = Health; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -129,6 +157,7 @@ name = TestApp; packageProductDependencies = ( 2F68C3C7292EA52000B3E12C /* SpeziDevices */, + A922BB1B2C2CB0AB009DD0E1 /* SpeziDevicesUI */, ); productName = Example; productReference = 2F6D139228F5F384007C25D6 /* TestApp.app */; @@ -180,6 +209,8 @@ Base, ); mainGroup = 2F6D138928F5F384007C25D6; + packageReferences = ( + ); productRefGroup = 2F6D139328F5F384007C25D6 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -213,6 +244,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A959B7E82C2CBF1400ACA775 /* HKQuantitySampleView.swift in Sources */, + A922BB1A2C2CB072009DD0E1 /* ContentView.swift in Sources */, + A959B7EB2C2CC05A00ACA775 /* HKCorrelationView.swift in Sources */, + A922BB202C2CB280009DD0E1 /* MeasurementsTestView.swift in Sources */, + A922BB1E2C2CB276009DD0E1 /* DevicesTestView.swift in Sources */, + A959B7E62C2CBF0900ACA775 /* HKSampleView.swift in Sources */, 2FA7382C290ADFAA007ACEB9 /* TestApp.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -642,6 +679,10 @@ isa = XCSwiftPackageProductDependency; productName = SpeziDevices; }; + A922BB1B2C2CB0AB009DD0E1 /* SpeziDevicesUI */ = { + isa = XCSwiftPackageProductDependency; + productName = SpeziDevicesUI; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 2F6D138A28F5F384007C25D6 /* Project object */; From ca16a2fe6445b7a3901ad7c44073dd59b4e2414f Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 27 Jun 2024 11:59:49 +0200 Subject: [PATCH 44/77] Add UI tests --- Sources/SpeziDevices/HealthMeasurements.swift | 1 + .../MeasurementRecordedSheet.swift | 1 + .../HealthMeasurementsTests.swift | 145 +++++++++++++++ .../TestAppUITests/PairedDevicesTests.swift | 166 ++++++++++++++++++ .../TestAppUITests/TestAppUITests.swift | 25 --- .../UITests/UITests.xcodeproj/project.pbxproj | 42 ++++- .../xcshareddata/xcschemes/TestApp.xcscheme | 8 +- 7 files changed, 357 insertions(+), 31 deletions(-) create mode 100644 Tests/UITests/TestAppUITests/HealthMeasurementsTests.swift create mode 100644 Tests/UITests/TestAppUITests/PairedDevicesTests.swift delete mode 100644 Tests/UITests/TestAppUITests/TestAppUITests.swift diff --git a/Sources/SpeziDevices/HealthMeasurements.swift b/Sources/SpeziDevices/HealthMeasurements.swift index 1adffc9..21e666f 100644 --- a/Sources/SpeziDevices/HealthMeasurements.swift +++ b/Sources/SpeziDevices/HealthMeasurements.swift @@ -121,6 +121,7 @@ public class HealthMeasurements { @MainActor public func clearStorage() { storedMeasurements.removeAll() + pendingMeasurements.removeAll() } /// Configure the Module. diff --git a/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift b/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift index 11523f0..82a673d 100644 --- a/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift +++ b/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift @@ -88,6 +88,7 @@ public struct MeasurementRecordedSheet: View { } } .presentationDetents([dynamicDetent]) + .presentationCornerRadius(25) } diff --git a/Tests/UITests/TestAppUITests/HealthMeasurementsTests.swift b/Tests/UITests/TestAppUITests/HealthMeasurementsTests.swift new file mode 100644 index 0000000..d78ca80 --- /dev/null +++ b/Tests/UITests/TestAppUITests/HealthMeasurementsTests.swift @@ -0,0 +1,145 @@ +// +// 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 XCTest + + +class HealthMeasurementsTests: XCTestCase { + override func setUpWithError() throws { + try super.setUpWithError() + + continueAfterFailure = false + } + + + @MainActor + func testNoMeasurements() { + let app = XCUIApplication() + app.launch() + + XCTAssert(app.buttons["Measurements"].waitForExistence(timeout: 2.0)) + app.buttons["Measurements"].tap() + + XCTAssert(app.staticTexts["No Samples"].waitForExistence(timeout: 0.5)) + app.staticTexts["No Samples"].tap() + + XCTAssert(app.navigationBars.buttons["Add Measurement"].exists) + app.navigationBars.buttons["Add Measurement"].tap() + + XCTAssert(app.staticTexts["No Pending Measurements"].waitForExistence(timeout: 2.0)) + XCTAssert(app.navigationBars.buttons["Dismiss"].exists) + app.navigationBars.buttons["Dismiss"].tap() + } + + @MainActor + func testWeightMeasurement() { + let app = XCUIApplication() + app.launch() + + XCTAssert(app.buttons["Measurements"].waitForExistence(timeout: 2.0)) + app.buttons["Measurements"].tap() + + XCTAssert(app.navigationBars.buttons["More"].exists) + app.navigationBars.buttons["More"].tap() + XCTAssert(app.buttons["Simulate Weight"].waitForExistence(timeout: 0.5)) + app.buttons["Simulate Weight"].tap() + + XCTAssert(app.staticTexts["Measurement Recorded"].waitForExistence(timeout: 2.0)) + XCTAssert(app.staticTexts["42 kg"].exists) + XCTAssert(app.staticTexts["179 cm, 23 BMI"].exists) + XCTAssert(app.buttons["Save"].exists) + XCTAssert(app.buttons["Discard"].exists) + + app.buttons["Save"].tap() + + XCTAssert(app.staticTexts["42 kg"].waitForExistence(timeout: 0.5)) + XCTAssert(app.staticTexts["23 count"].exists) + XCTAssert(app.staticTexts["1.79 m"].exists) + XCTAssert(app.staticTexts["Mock Device"].exists) + } + + @MainActor + func testBloodPressureMeasurement() { + let app = XCUIApplication() + app.launch() + + XCTAssert(app.buttons["Measurements"].waitForExistence(timeout: 2.0)) + app.buttons["Measurements"].tap() + + XCTAssert(app.navigationBars.buttons["More"].exists) + app.navigationBars.buttons["More"].tap() + XCTAssert(app.buttons["Simulate Blood Pressure"].waitForExistence(timeout: 0.5)) + app.buttons["Simulate Blood Pressure"].tap() + + XCTAssert(app.staticTexts["Measurement Recorded"].waitForExistence(timeout: 2.0)) + XCTAssert(app.staticTexts["103/64 mmHg"].exists) + XCTAssert(app.staticTexts["62 BPM"].exists) + XCTAssert(app.buttons["Save"].exists) + XCTAssert(app.buttons["Discard"].exists) + + app.buttons["Save"].tap() + + XCTAssert(app.staticTexts["103 mmHg"].waitForExistence(timeout: 0.5)) + XCTAssert(app.staticTexts["64 mmHg"].exists) + XCTAssert(app.staticTexts["62 count/min"].exists) + XCTAssert(app.staticTexts["Mock Device"].exists) + } + + @MainActor + func testMultiMeasurementsAndDiscarding() throws { + let app = XCUIApplication() + app.launch() + + XCTAssert(app.buttons["Measurements"].waitForExistence(timeout: 2.0)) + app.buttons["Measurements"].tap() + + XCTAssert(app.navigationBars.buttons["More"].exists) + app.navigationBars.buttons["More"].tap() + XCTAssert(app.buttons["Simulate Weight"].waitForExistence(timeout: 0.5)) + app.buttons["Simulate Weight"].tap() + + XCTAssert(app.navigationBars.buttons["Dismiss"].waitForExistence(timeout: 0.5)) + app.navigationBars.buttons["Dismiss"].tap() + + XCTAssert(app.navigationBars.buttons["More"].exists) + app.navigationBars.buttons["More"].tap() + XCTAssert(app.buttons["Simulate Blood Pressure"].waitForExistence(timeout: 0.5)) + app.buttons["Simulate Blood Pressure"].tap() + + XCTAssert(app.staticTexts["Measurement Recorded"].waitForExistence(timeout: 2.0)) + XCTAssert(app.staticTexts["103/64 mmHg"].exists) + XCTAssert(app.staticTexts["62 BPM"].exists) + XCTAssert(app.buttons["Save"].exists) + XCTAssert(app.buttons["Discard"].exists) + + XCTAssert(app.steppers["Page"].exists) + let page1Value = try XCTUnwrap(app.steppers["Page"].value as? String, "Unexpected value \(String(describing: app.steppers["Page"].value))") + XCTAssertEqual(page1Value, "Page 1 of 2") + app.steppers["Page"].coordinate(withNormalizedOffset: .init(dx: 0.8, dy: 0.5)).tap() + let page2Value = try XCTUnwrap(app.steppers["Page"].value as? String, "Unexpected value \(String(describing: app.steppers["Page"].value))") + XCTAssertEqual(page2Value, "Page 2 of 2") + + XCTAssert(app.staticTexts["Measurement Recorded"].waitForExistence(timeout: 2.0)) + XCTAssert(app.staticTexts["42 kg"].exists) + XCTAssert(app.staticTexts["179 cm, 23 BMI"].exists) + XCTAssert(app.buttons["Save"].exists) + XCTAssert(app.buttons["Discard"].exists) + + XCTAssert(app.navigationBars.buttons["Dismiss"].exists) + app.navigationBars.buttons["Dismiss"].tap() + + XCTAssert(app.navigationBars.buttons["Add Measurement"].waitForExistence(timeout: 0.5)) + app.navigationBars.buttons["Add Measurement"].tap() + + XCTAssert(app.buttons["Discard"].waitForExistence(timeout: 0.5)) + app.buttons["Discard"].tap() + + XCTAssert(app.staticTexts["42 kg"].waitForExistence(timeout: 2.0)) + XCTAssert(app.staticTexts["179 cm, 23 BMI"].exists) + } +} diff --git a/Tests/UITests/TestAppUITests/PairedDevicesTests.swift b/Tests/UITests/TestAppUITests/PairedDevicesTests.swift new file mode 100644 index 0000000..b5dfe58 --- /dev/null +++ b/Tests/UITests/TestAppUITests/PairedDevicesTests.swift @@ -0,0 +1,166 @@ +// +// 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 XCTest +import XCTestExtensions + + +class PairedDevicesTests: XCTestCase { + override func setUpWithError() throws { + try super.setUpWithError() + + continueAfterFailure = false + } + + + @MainActor + func testTipsView() throws { + let app = XCUIApplication() + app.launchArguments = ["--testTips"] + app.launch() + + XCTAssert(app.buttons["Devices"].waitForExistence(timeout: 2.0)) + app.buttons["Devices"].tap() + + + XCTAssert(app.staticTexts["Fully Unpair Device"].waitForExistence(timeout: 0.5)) + XCTAssert(app.buttons["Open Settings"].exists) + app.buttons["Open Settings"].tap() + + let settingsApp = XCUIApplication(bundleIdentifier: "com.apple.Preferences") + XCTAssertEqual(settingsApp.state, .runningForeground) + } + + @MainActor + func testDiscoveringView() throws { + let app = XCUIApplication() + app.launch() + + XCTAssert(app.buttons["Devices"].waitForExistence(timeout: 2.0)) + app.buttons["Devices"].tap() + + + XCTAssert(app.staticTexts["No Devices"].exists) + XCTAssert(app.buttons["Pair New Device"].exists) + app.buttons["Pair New Device"].tap() + + XCTAssert(app.staticTexts["Discovering"].waitForExistence(timeout: 0.5)) + XCTAssert(app.navigationBars.buttons["Dismiss"].exists) + app.navigationBars.buttons["Dismiss"].tap() + + XCTAssert(app.staticTexts["No Devices"].waitForExistence(timeout: 0.5)) + // TODO: customize the pairing hint! + } + + @MainActor + func testPairDevice() throws { + let app = XCUIApplication() + app.launch() + + XCTAssert(app.buttons["Devices"].waitForExistence(timeout: 2.0)) + app.buttons["Devices"].tap() + + XCTAssert(app.navigationBars.buttons["More"].exists) + app.navigationBars.buttons["More"].tap() + + XCTAssert(app.buttons["Discover Device"].waitForExistence(timeout: 0.5)) + app.buttons["Discover Device"].tap() + + XCTAssert(app.staticTexts["Pair Accessory"].waitForExistence(timeout: 2.0)) + XCTAssert(app.staticTexts["Do you want to pair \"Mock Device\" with the TestApp app?"].exists) + XCTAssert(app.buttons["Pair"].exists) + app.buttons["Pair"].tap() + + XCTAssert(app.staticTexts["Accessory Paired"].waitForExistence(timeout: 5.0)) + XCTAssert(app.staticTexts["\"Mock Device\" was successfully paired with the TestApp app."].exists) + XCTAssert(app.buttons["Done"].exists) + app.buttons["Done"].tap() + + XCTAssert(app.buttons["Mock Device, 85 %"].waitForExistence(timeout: 0.5)) + app.buttons["Mock Device, 85 %"].tap() + + XCTAssert(app.navigationBars.staticTexts["Device Details"].waitForExistence(timeout: 2.0)) + XCTAssert(app.buttons["Name, Mock Device"].exists) + XCTAssert(app.staticTexts["Model, MD1"].exists) + XCTAssert(app.staticTexts["Battery, 85 %"].exists) + XCTAssert(app.buttons["Forget This Device"].exists) + XCTAssert(app.staticTexts["Synchronizing ..."].exists) // assert device currently connected + + app.buttons["Name, Mock Device"].tap() + + XCTAssert(app.textFields["enter device name"].exists) + app.textFields["enter device name"].tap() + app.typeText("2") + + app.dismissKeyboard() + + XCTAssert(app.navigationBars.buttons["Done"].waitForExistence(timeout: 0.5)) + app.navigationBars.buttons["Done"].tap() + + XCTAssert(app.staticTexts["Name, Mock Device2"].waitForExistence(timeout: 0.5)) + XCTAssert(app.navigationBars.buttons["Devices"].exists) + app.navigationBars.buttons["Devices"].tap() + + XCTAssert(app.buttons["Mock Device2, 85 %"].waitForExistence(timeout: 2.0)) + + XCTAssert(app.navigationBars.buttons["More"].exists) + app.navigationBars.buttons["More"].tap() + XCTAssert(app.buttons["Disconnect"].waitForExistence(timeout: 0.5)) + app.buttons["Disconnect"].tap() + sleep(1) + + app.buttons["Mock Device2, 85 %"].tap() + XCTAssert(app.navigationBars.buttons["Devices"].waitForExistence(timeout: 2.0)) + app.navigationBars.buttons["Devices"].tap() + + XCTAssert(app.navigationBars.buttons["More"].exists) + app.navigationBars.buttons["More"].tap() + XCTAssert(app.buttons["Connect"].waitForExistence(timeout: 0.5)) + app.buttons["Connect"].tap() + sleep(3) + + + XCTAssert(app.buttons["Mock Device2, 85 %"].waitForExistence(timeout: 0.5)) + app.buttons["Mock Device2, 85 %"].tap() + + XCTAssert(app.buttons["Forget This Device"].waitForExistence(timeout: 2.0)) + app.buttons["Forget This Device"].tap() + + XCTAssert(app.buttons["Forget Device"].waitForExistence(timeout: 2.0)) + app.buttons["Forget Device"].tap() + + + XCTAssert(app.staticTexts["Fully Unpair Device"].waitForExistence(timeout: 2.0)) + } + + @MainActor + func testPlusButton() { + let app = XCUIApplication() + app.launch() + + XCTAssert(app.buttons["Devices"].waitForExistence(timeout: 2.0)) + app.buttons["Devices"].tap() + + XCTAssert(app.navigationBars.buttons["More"].exists) + app.navigationBars.buttons["More"].tap() + + XCTAssert(app.buttons["Discover Device"].waitForExistence(timeout: 0.5)) + app.buttons["Discover Device"].tap() + + XCTAssert(app.staticTexts["Pair Accessory"].waitForExistence(timeout: 2.0)) + XCTAssert(app.buttons["Dismiss"].exists) + app.buttons["Dismiss"].tap() + + XCTAssert(app.navigationBars.buttons["Add Device"].exists) + app.navigationBars.buttons["Add Device"].tap() + + XCTAssert(app.staticTexts["Pair Accessory"].waitForExistence(timeout: 2.0)) + } + + // TODO: forget devices test +} diff --git a/Tests/UITests/TestAppUITests/TestAppUITests.swift b/Tests/UITests/TestAppUITests/TestAppUITests.swift deleted file mode 100644 index 6bf88e2..0000000 --- a/Tests/UITests/TestAppUITests/TestAppUITests.swift +++ /dev/null @@ -1,25 +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 XCTest - - -class TestAppUITests: XCTestCase { - override func setUpWithError() throws { - try super.setUpWithError() - - continueAfterFailure = false - } - - - func testTemplatePackage() throws { - let app = XCUIApplication() - app.launch() - XCTAssert(true) - } -} diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 1b99d23..d5f62c3 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -9,7 +9,7 @@ /* Begin PBXBuildFile section */ 2F68C3C8292EA52000B3E12C /* SpeziDevices in Frameworks */ = {isa = PBXBuildFile; productRef = 2F68C3C7292EA52000B3E12C /* SpeziDevices */; }; 2F6D139A28F5F386007C25D6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2F6D139928F5F386007C25D6 /* Assets.xcassets */; }; - 2F8A431329130A8C005D2B8F /* TestAppUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F8A431229130A8C005D2B8F /* TestAppUITests.swift */; }; + 2F8A431329130A8C005D2B8F /* HealthMeasurementsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F8A431229130A8C005D2B8F /* HealthMeasurementsTests.swift */; }; 2FA7382C290ADFAA007ACEB9 /* TestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */; }; A922BB1A2C2CB072009DD0E1 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A922BB192C2CB072009DD0E1 /* ContentView.swift */; }; A922BB1C2C2CB0AB009DD0E1 /* SpeziDevicesUI in Frameworks */ = {isa = PBXBuildFile; productRef = A922BB1B2C2CB0AB009DD0E1 /* SpeziDevicesUI */; }; @@ -18,6 +18,8 @@ A959B7E62C2CBF0900ACA775 /* HKSampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A959B7E52C2CBF0900ACA775 /* HKSampleView.swift */; }; A959B7E82C2CBF1400ACA775 /* HKQuantitySampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A959B7E72C2CBF1400ACA775 /* HKQuantitySampleView.swift */; }; A959B7EB2C2CC05A00ACA775 /* HKCorrelationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A959B7EA2C2CC05A00ACA775 /* HKCorrelationView.swift */; }; + A959B7F02C2D602C00ACA775 /* PairedDevicesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A959B7EF2C2D602C00ACA775 /* PairedDevicesTests.swift */; }; + A959B7F32C2D646500ACA775 /* XCTestExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = A959B7F22C2D646500ACA775 /* XCTestExtensions */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -48,7 +50,7 @@ 2F6D139228F5F384007C25D6 /* TestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TestApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 2F6D139928F5F386007C25D6 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 2F6D13AC28F5F386007C25D6 /* TestAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TestAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 2F8A431229130A8C005D2B8F /* TestAppUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAppUITests.swift; sourceTree = ""; }; + 2F8A431229130A8C005D2B8F /* HealthMeasurementsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthMeasurementsTests.swift; sourceTree = ""; }; 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestApp.swift; sourceTree = ""; }; 2FB0758A299DDB9000C0B37F /* TestApp.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestApp.xctestplan; sourceTree = ""; }; A922BB192C2CB072009DD0E1 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -57,6 +59,7 @@ A959B7E52C2CBF0900ACA775 /* HKSampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKSampleView.swift; sourceTree = ""; }; A959B7E72C2CBF1400ACA775 /* HKQuantitySampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKQuantitySampleView.swift; sourceTree = ""; }; A959B7EA2C2CC05A00ACA775 /* HKCorrelationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKCorrelationView.swift; sourceTree = ""; }; + A959B7EF2C2D602C00ACA775 /* PairedDevicesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PairedDevicesTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -73,6 +76,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + A959B7F32C2D646500ACA775 /* XCTestExtensions in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -116,7 +120,8 @@ 2F6D13AF28F5F386007C25D6 /* TestAppUITests */ = { isa = PBXGroup; children = ( - 2F8A431229130A8C005D2B8F /* TestAppUITests.swift */, + 2F8A431229130A8C005D2B8F /* HealthMeasurementsTests.swift */, + A959B7EF2C2D602C00ACA775 /* PairedDevicesTests.swift */, ); path = TestAppUITests; sourceTree = ""; @@ -177,6 +182,9 @@ 2F6D13AE28F5F386007C25D6 /* PBXTargetDependency */, ); name = TestAppUITests; + packageProductDependencies = ( + A959B7F22C2D646500ACA775 /* XCTestExtensions */, + ); productName = ExampleUITests; productReference = 2F6D13AC28F5F386007C25D6 /* TestAppUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; @@ -189,7 +197,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1500; - LastUpgradeCheck = 1500; + LastUpgradeCheck = 1540; TargetAttributes = { 2F6D139128F5F384007C25D6 = { CreatedOnToolsVersion = 14.1; @@ -210,6 +218,7 @@ ); mainGroup = 2F6D138928F5F384007C25D6; packageReferences = ( + A959B7F12C2D646500ACA775 /* XCRemoteSwiftPackageReference "XCTestExtensions" */, ); productRefGroup = 2F6D139328F5F384007C25D6 /* Products */; projectDirPath = ""; @@ -258,7 +267,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 2F8A431329130A8C005D2B8F /* TestAppUITests.swift in Sources */, + A959B7F02C2D602C00ACA775 /* PairedDevicesTests.swift in Sources */, + 2F8A431329130A8C005D2B8F /* HealthMeasurementsTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -310,6 +320,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -332,6 +343,7 @@ SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; TVOS_DEPLOYMENT_TARGET = 17.0; WATCHOS_DEPLOYMENT_TARGET = 10.0; XROS_DEPLOYMENT_TARGET = 1.0; @@ -375,6 +387,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -390,6 +403,7 @@ SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_STRICT_CONCURRENCY = complete; TVOS_DEPLOYMENT_TARGET = 17.0; VALIDATE_PRODUCT = YES; WATCHOS_DEPLOYMENT_TARGET = 10.0; @@ -552,6 +566,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -574,6 +589,7 @@ SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = TEST; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; TVOS_DEPLOYMENT_TARGET = 17.0; WATCHOS_DEPLOYMENT_TARGET = 10.0; XROS_DEPLOYMENT_TARGET = 1.0; @@ -674,6 +690,17 @@ }; /* End XCConfigurationList section */ +/* Begin XCRemoteSwiftPackageReference section */ + A959B7F12C2D646500ACA775 /* XCRemoteSwiftPackageReference "XCTestExtensions" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/StanfordBDHG/XCTestExtensions.git"; + requirement = { + kind = upToNextMinorVersion; + minimumVersion = 0.4.10; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ 2F68C3C7292EA52000B3E12C /* SpeziDevices */ = { isa = XCSwiftPackageProductDependency; @@ -683,6 +710,11 @@ isa = XCSwiftPackageProductDependency; productName = SpeziDevicesUI; }; + A959B7F22C2D646500ACA775 /* XCTestExtensions */ = { + isa = XCSwiftPackageProductDependency; + package = A959B7F12C2D646500ACA775 /* XCRemoteSwiftPackageReference "XCTestExtensions" */; + productName = XCTestExtensions; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 2F6D138A28F5F384007C25D6 /* Project object */; diff --git a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme index 288073b..4d7654b 100644 --- a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme +++ b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme @@ -1,6 +1,6 @@ + + + + Date: Thu, 27 Jun 2024 12:02:57 +0200 Subject: [PATCH 45/77] Include other targets in coverage reports as well --- Tests/UITests/TestApp.xctestplan | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Tests/UITests/TestApp.xctestplan b/Tests/UITests/TestApp.xctestplan index f1d5b7c..1bf139f 100644 --- a/Tests/UITests/TestApp.xctestplan +++ b/Tests/UITests/TestApp.xctestplan @@ -15,6 +15,16 @@ "containerPath" : "container:..\/..", "identifier" : "SpeziDevices", "name" : "SpeziDevices" + }, + { + "containerPath" : "container:..\/..", + "identifier" : "SpeziDevicesUI", + "name" : "SpeziDevicesUI" + }, + { + "containerPath" : "container:..\/..", + "identifier" : "SpeziOmron", + "name" : "SpeziOmron" } ] }, From 833e57d991142b2caeb06dd510e84e353f7b7191 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 27 Jun 2024 12:51:20 +0200 Subject: [PATCH 46/77] Add UI tests for generic views, fix unit tests --- .../Measurements/CloseButtonLayer.swift | 45 ----------- .../Resources/Localizable.xcstrings | 3 - .../HealthMeasurementsTests.swift | 3 + .../PairedDevicesTests.swift | 8 ++ Tests/SpeziOmronTests/SpeziOmronTests.swift | 4 +- .../UITests/TestApp/BluetoothViewsTest.swift | 55 ++++++++++++++ Tests/UITests/TestApp/ContentView.swift | 4 + .../Views/BluetoothUnavailableSection.swift | 42 +++++++++++ .../TestApp/Views/MockDeviceDetailsView.swift | 54 +++++++++++++ .../TestAppUITests/BluetoothViewsTests.swift | 75 +++++++++++++++++++ .../TestAppUITests/PairedDevicesTests.swift | 33 +++++++- .../UITests/UITests.xcodeproj/project.pbxproj | 28 ++++++- 12 files changed, 301 insertions(+), 53 deletions(-) delete mode 100644 Sources/SpeziDevicesUI/Measurements/CloseButtonLayer.swift create mode 100644 Tests/UITests/TestApp/BluetoothViewsTest.swift create mode 100644 Tests/UITests/TestApp/Views/BluetoothUnavailableSection.swift create mode 100644 Tests/UITests/TestApp/Views/MockDeviceDetailsView.swift create mode 100644 Tests/UITests/TestAppUITests/BluetoothViewsTests.swift diff --git a/Sources/SpeziDevicesUI/Measurements/CloseButtonLayer.swift b/Sources/SpeziDevicesUI/Measurements/CloseButtonLayer.swift deleted file mode 100644 index bee5763..0000000 --- a/Sources/SpeziDevicesUI/Measurements/CloseButtonLayer.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// This source file is part of the Stanford SpeziDevices open source project -// -// SPDX-FileCopyrightText: 2023 Stanford University -// -// SPDX-License-Identifier: MIT -// - -import SpeziViews -import SwiftUI - - -struct CloseButtonLayer: View { - @Environment(\.dismiss) private var dismiss - @Binding private var viewState: ViewState - - - var body: some View { - HStack { - Button( - action: { - dismiss() - }, - label: { - Text("Close", comment: "For closing sheets.") - .foregroundStyle(Color.accentColor) - } - ) - .buttonStyle(PlainButtonStyle()) - .disabled(viewState != .idle) - - Spacer() - } - .padding() - } - - - init(viewState: Binding) { - self._viewState = viewState - } -} - -#Preview { - CloseButtonLayer(viewState: .constant(.idle)) -} diff --git a/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings b/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings index 1d790d5..200af4b 100644 --- a/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings +++ b/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings @@ -147,9 +147,6 @@ } } }, - "Close" : { - "comment" : "For closing sheets." - }, "Connected" : { "localizations" : { "en" : { diff --git a/Tests/SpeziDevicesTests/HealthMeasurementsTests.swift b/Tests/SpeziDevicesTests/HealthMeasurementsTests.swift index 6c0f214..b38a838 100644 --- a/Tests/SpeziDevicesTests/HealthMeasurementsTests.swift +++ b/Tests/SpeziDevicesTests/HealthMeasurementsTests.swift @@ -161,6 +161,9 @@ final class HealthMeasurementsTests: XCTestCase { let measurement1 = try XCTUnwrap(device.weightScale.weightMeasurement) device.weightScale.$weightMeasurement.inject(measurement1) + + try await Task.sleep(for: .milliseconds(50)) + let measurement0 = try XCTUnwrap(device.bloodPressure.bloodPressureMeasurement) device.bloodPressure.$bloodPressureMeasurement.inject(measurement0) diff --git a/Tests/SpeziDevicesTests/PairedDevicesTests.swift b/Tests/SpeziDevicesTests/PairedDevicesTests.swift index ad78159..f3e9ee3 100644 --- a/Tests/SpeziDevicesTests/PairedDevicesTests.swift +++ b/Tests/SpeziDevicesTests/PairedDevicesTests.swift @@ -30,6 +30,8 @@ final class PairedDevicesTests: XCTestCase { devices } + device.isInPairingMode = true + XCTAssertFalse(devices.isConnected(device: device.id)) XCTAssertFalse(devices.isPaired(device)) @@ -106,6 +108,8 @@ final class PairedDevicesTests: XCTestCase { devices } + device.isInPairingMode = true + device.$nearby.inject(false) try await XCTAssertThrowsErrorAsync(await devices.pair(with: device)) { error in XCTAssertEqual(try XCTUnwrap(error as? DevicePairingError), .invalidState) @@ -141,6 +145,8 @@ final class PairedDevicesTests: XCTestCase { devices } + device.isInPairingMode = true + let task = Task { try await devices.pair(with: device) } @@ -168,6 +174,8 @@ final class PairedDevicesTests: XCTestCase { devices } + device.isInPairingMode = true + devices.configure(device: device, accessing: device.$state, device.$advertisementData, device.$nearby) let task = Task { diff --git a/Tests/SpeziOmronTests/SpeziOmronTests.swift b/Tests/SpeziOmronTests/SpeziOmronTests.swift index 2395d8f..83d3023 100644 --- a/Tests/SpeziOmronTests/SpeziOmronTests.swift +++ b/Tests/SpeziOmronTests/SpeziOmronTests.swift @@ -80,14 +80,14 @@ final class SpeziOmronTests: XCTestCase { CBAdvertisementDataManufacturerDataKey: manufacturerData.encode() ])) - XCTAssertTrue(device.isInPairingMode) + XCTAssertEqual(device.manufacturerData?.pairingMode, .pairingMode) let manufacturerData0 = OmronManufacturerData(pairingMode: .transferMode, users: [.init(id: 1, sequenceNumber: 3, recordsNumber: 8)]) device.$advertisementData.inject(AdvertisementData([ CBAdvertisementDataManufacturerDataKey: manufacturerData0.encode() ])) - XCTAssertFalse(device.isInPairingMode) + XCTAssertEqual(device.manufacturerData?.pairingMode, .transferMode) device.deviceInformation.$modelNumber.inject(OmronModel.bp5250.rawValue) diff --git a/Tests/UITests/TestApp/BluetoothViewsTest.swift b/Tests/UITests/TestApp/BluetoothViewsTest.swift new file mode 100644 index 0000000..284b26f --- /dev/null +++ b/Tests/UITests/TestApp/BluetoothViewsTest.swift @@ -0,0 +1,55 @@ +// +// 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 +// + +@_spi(TestingSupport) import SpeziDevices +import SpeziDevicesUI +import SwiftUI + + +struct BluetoothViewsTest: View { + @State private var device = MockDevice.createMockDevice() + @State private var presentDeviceDetails = false + + var body: some View { + NavigationStack { + List { + BluetoothUnavailableSection() + + Section { + NearbyDeviceRow(peripheral: device, primaryAction: tapAction) { + presentDeviceDetails = true + } + } header: { + LoadingSectionHeader("Devices", loading: true) + } + } + .navigationTitle("Views") + .navigationDestination(isPresented: $presentDeviceDetails) { + MockDeviceDetailsView(device) + } + } + } + + + @MainActor + private func tapAction() { + Task { + switch device.state { + case .disconnected, .disconnecting: + await device.connect() + case .connecting, .connected: + await device.disconnect() + } + } + } +} + + +#Preview { + BluetoothViewsTest() +} diff --git a/Tests/UITests/TestApp/ContentView.swift b/Tests/UITests/TestApp/ContentView.swift index 038c143..226611f 100644 --- a/Tests/UITests/TestApp/ContentView.swift +++ b/Tests/UITests/TestApp/ContentView.swift @@ -23,6 +23,10 @@ struct ContentView: View { .tabItem { Label("Measurements", systemImage: "list.bullet.clipboard.fill") } + BluetoothViewsTest() + .tabItem { + Label("Views", systemImage: "macwindow") + } } } } diff --git a/Tests/UITests/TestApp/Views/BluetoothUnavailableSection.swift b/Tests/UITests/TestApp/Views/BluetoothUnavailableSection.swift new file mode 100644 index 0000000..0654a78 --- /dev/null +++ b/Tests/UITests/TestApp/Views/BluetoothUnavailableSection.swift @@ -0,0 +1,42 @@ +// +// 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 SpeziDevicesUI +import SwiftUI + + +struct BluetoothUnavailableSection: View { + var body: some View { + Section("Bluetooth Unavailable") { + NavigationLink("Bluetooth Powered Off") { + BluetoothUnavailableView(.poweredOff) + } + NavigationLink("Bluetooth Powered On") { + BluetoothUnavailableView(.poweredOn) + } + NavigationLink("Bluetooth Unauthorized") { + BluetoothUnavailableView(.unauthorized) + } + NavigationLink("Bluetooth Unsupported") { + BluetoothUnavailableView(.unsupported) + } + NavigationLink("Bluetooth Unknown") { + BluetoothUnavailableView(.unknown) + } + } + } +} + + +#Preview { + NavigationStack { + List { + BluetoothUnavailableSection() + } + } +} diff --git a/Tests/UITests/TestApp/Views/MockDeviceDetailsView.swift b/Tests/UITests/TestApp/Views/MockDeviceDetailsView.swift new file mode 100644 index 0000000..aed67a3 --- /dev/null +++ b/Tests/UITests/TestApp/Views/MockDeviceDetailsView.swift @@ -0,0 +1,54 @@ +// +// 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 +// + +@_spi(TestingSupport) import SpeziDevices +import SpeziDevicesUI +import SpeziViews +import SwiftUI + + +struct MockDeviceDetailsView: View { + private let device: MockDevice + + var body: some View { + List { + ListRow("Name") { + Text(device.label) + } + if let model = device.deviceInformation.modelNumber { + ListRow("Model") { + Text(model) + } + } + if let firmwareVersion = device.deviceInformation.firmwareRevision { + ListRow("Firmware Version") { + Text(firmwareVersion) + } + } + if let battery = device.battery.batteryLevel { + ListRow("Battery") { + BatteryIcon(percentage: Int(battery)) + .labelStyle(.reverse) + } + } + } + .navigationTitle(device.label) + .navigationBarTitleDisplayMode(.inline) + } + + init(_ device: MockDevice) { + self.device = device + } +} + + +#Preview { + NavigationStack { + MockDeviceDetailsView(MockDevice.createMockDevice()) + } +} diff --git a/Tests/UITests/TestAppUITests/BluetoothViewsTests.swift b/Tests/UITests/TestAppUITests/BluetoothViewsTests.swift new file mode 100644 index 0000000..d67d3a4 --- /dev/null +++ b/Tests/UITests/TestAppUITests/BluetoothViewsTests.swift @@ -0,0 +1,75 @@ +// +// 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 XCTest + + +class BluetoothViewsTests: XCTestCase { + override func setUpWithError() throws { + try super.setUpWithError() + + continueAfterFailure = false + } + + @MainActor + func testBluetoothUnavailableViews() { + let app = XCUIApplication() + app.launch() + + XCTAssert(app.buttons["Views"].waitForExistence(timeout: 2.0)) + app.buttons["Views"].tap() + + func navigateUnavailableView(name: String, expected: String?, back: Bool = true) { + XCTAssert(app.buttons[name].waitForExistence(timeout: 2.0)) + app.buttons[name].tap() + if let expected { + XCTAssert(app.staticTexts[expected].waitForExistence(timeout: 2.0)) + } + if back { + XCTAssert(app.navigationBars.buttons["Views"].exists) + app.navigationBars.buttons["Views"].tap() + } + } + + navigateUnavailableView(name: "Bluetooth Powered On", expected: nil) + navigateUnavailableView(name: "Bluetooth Unauthorized", expected: "Bluetooth Prohibited") + navigateUnavailableView(name: "Bluetooth Unsupported", expected: "Bluetooth Unsupported") + navigateUnavailableView(name: "Bluetooth Unknown", expected: "Bluetooth Failure") + navigateUnavailableView(name: "Bluetooth Powered Off", expected: "Bluetooth Off", back: false) + + XCTAssert(app.buttons["Open Settings"].exists) + app.buttons["Open Settings"].tap() + + let settingsApp = XCUIApplication(bundleIdentifier: "com.apple.Preferences") + XCTAssertEqual(settingsApp.state, .runningForeground) + } + + @MainActor + func testNearbyDeviceRow() { + let app = XCUIApplication() + app.launch() + + XCTAssert(app.buttons["Views"].waitForExistence(timeout: 2.0)) + app.buttons["Views"].tap() + + XCTAssert(app.staticTexts["DEVICES"].exists) + + XCTAssert(app.buttons["Mock Device"].exists) + app.buttons["Mock Device"].tap() + + XCTAssert(app.buttons["Mock Device, Connected"].waitForExistence(timeout: 5.0)) + XCTAssert(app.buttons["Device Details"].exists) + app.buttons["Device Details"].tap() + + XCTAssert(app.navigationBars.staticTexts["Mock Device"].waitForExistence(timeout: 2.0)) + XCTAssert(app.staticTexts["Name, Mock Device"].exists) + XCTAssert(app.staticTexts["Model, MD1"].exists) + XCTAssert(app.staticTexts["Firmware Version, 1.0"].exists) + XCTAssert(app.staticTexts["Battery, 85 %"].exists) + } +} diff --git a/Tests/UITests/TestAppUITests/PairedDevicesTests.swift b/Tests/UITests/TestAppUITests/PairedDevicesTests.swift index b5dfe58..32691bc 100644 --- a/Tests/UITests/TestAppUITests/PairedDevicesTests.swift +++ b/Tests/UITests/TestAppUITests/PairedDevicesTests.swift @@ -162,5 +162,36 @@ class PairedDevicesTests: XCTestCase { XCTAssert(app.staticTexts["Pair Accessory"].waitForExistence(timeout: 2.0)) } - // TODO: forget devices test + @MainActor + func testPairingFailed() { + let app = XCUIApplication() + app.launch() + + XCTAssert(app.buttons["Devices"].waitForExistence(timeout: 2.0)) + app.buttons["Devices"].tap() + + XCTAssert(app.navigationBars.buttons["More"].exists) + app.navigationBars.buttons["More"].tap() + XCTAssert(app.buttons["Connect"].exists) + app.buttons["Connect"].tap() + + XCTAssert(app.navigationBars.buttons["More"].exists) + app.navigationBars.buttons["More"].tap() + XCTAssert(app.buttons["Discover Device"].exists) + app.buttons["Discover Device"].tap() + + XCTAssert(app.staticTexts["Pair Accessory"].waitForExistence(timeout: 2.0)) + XCTAssert(app.buttons["Pair"].exists) + app.buttons["Pair"].tap() + + XCTAssert(app.staticTexts["Pairing Failed"].waitForExistence(timeout: 2.0)) + XCTAssert(app.staticTexts["Failed to pair with device. Please try again."].exists) + XCTAssert(app.buttons["OK"].exists) + app.buttons["OK"].tap() + + XCTAssert(app.navigationBars.buttons["Add Device"].waitForExistence(timeout: 0.5)) + app.navigationBars.buttons["Add Device"].tap() + + XCTAssert(app.staticTexts["Pair Accessory"].waitForExistence(timeout: 2.0)) + } } diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index d5f62c3..15c8bcf 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -20,6 +20,10 @@ A959B7EB2C2CC05A00ACA775 /* HKCorrelationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A959B7EA2C2CC05A00ACA775 /* HKCorrelationView.swift */; }; A959B7F02C2D602C00ACA775 /* PairedDevicesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A959B7EF2C2D602C00ACA775 /* PairedDevicesTests.swift */; }; A959B7F32C2D646500ACA775 /* XCTestExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = A959B7F22C2D646500ACA775 /* XCTestExtensions */; }; + A959B7F52C2D72A500ACA775 /* BluetoothViewsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A959B7F42C2D72A500ACA775 /* BluetoothViewsTest.swift */; }; + A959B7F82C2D75B400ACA775 /* BluetoothUnavailableSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A959B7F72C2D75B400ACA775 /* BluetoothUnavailableSection.swift */; }; + A959B7FA2C2D75F300ACA775 /* MockDeviceDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A959B7F92C2D75F300ACA775 /* MockDeviceDetailsView.swift */; }; + A959B7FC2C2D769A00ACA775 /* BluetoothViewsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A959B7FB2C2D769A00ACA775 /* BluetoothViewsTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -60,6 +64,10 @@ A959B7E72C2CBF1400ACA775 /* HKQuantitySampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKQuantitySampleView.swift; sourceTree = ""; }; A959B7EA2C2CC05A00ACA775 /* HKCorrelationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKCorrelationView.swift; sourceTree = ""; }; A959B7EF2C2D602C00ACA775 /* PairedDevicesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PairedDevicesTests.swift; sourceTree = ""; }; + A959B7F42C2D72A500ACA775 /* BluetoothViewsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothViewsTest.swift; sourceTree = ""; }; + A959B7F72C2D75B400ACA775 /* BluetoothUnavailableSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothUnavailableSection.swift; sourceTree = ""; }; + A959B7F92C2D75F300ACA775 /* MockDeviceDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDeviceDetailsView.swift; sourceTree = ""; }; + A959B7FB2C2D769A00ACA775 /* BluetoothViewsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothViewsTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -107,12 +115,14 @@ 2F6D139428F5F384007C25D6 /* TestApp */ = { isa = PBXGroup; children = ( + A959B7F42C2D72A500ACA775 /* BluetoothViewsTest.swift */, A922BB192C2CB072009DD0E1 /* ContentView.swift */, A922BB1D2C2CB276009DD0E1 /* DevicesTestView.swift */, A922BB1F2C2CB280009DD0E1 /* MeasurementsTestView.swift */, 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */, 2F6D139928F5F386007C25D6 /* Assets.xcassets */, A959B7E92C2CC04400ACA775 /* Health */, + A959B7F62C2D759700ACA775 /* Views */, ); path = TestApp; sourceTree = ""; @@ -122,6 +132,7 @@ children = ( 2F8A431229130A8C005D2B8F /* HealthMeasurementsTests.swift */, A959B7EF2C2D602C00ACA775 /* PairedDevicesTests.swift */, + A959B7FB2C2D769A00ACA775 /* BluetoothViewsTests.swift */, ); path = TestAppUITests; sourceTree = ""; @@ -136,13 +147,22 @@ A959B7E92C2CC04400ACA775 /* Health */ = { isa = PBXGroup; children = ( - A959B7E52C2CBF0900ACA775 /* HKSampleView.swift */, - A959B7E72C2CBF1400ACA775 /* HKQuantitySampleView.swift */, A959B7EA2C2CC05A00ACA775 /* HKCorrelationView.swift */, + A959B7E72C2CBF1400ACA775 /* HKQuantitySampleView.swift */, + A959B7E52C2CBF0900ACA775 /* HKSampleView.swift */, ); path = Health; sourceTree = ""; }; + A959B7F62C2D759700ACA775 /* Views */ = { + isa = PBXGroup; + children = ( + A959B7F72C2D75B400ACA775 /* BluetoothUnavailableSection.swift */, + A959B7F92C2D75F300ACA775 /* MockDeviceDetailsView.swift */, + ); + path = Views; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -256,10 +276,13 @@ A959B7E82C2CBF1400ACA775 /* HKQuantitySampleView.swift in Sources */, A922BB1A2C2CB072009DD0E1 /* ContentView.swift in Sources */, A959B7EB2C2CC05A00ACA775 /* HKCorrelationView.swift in Sources */, + A959B7FA2C2D75F300ACA775 /* MockDeviceDetailsView.swift in Sources */, + A959B7F82C2D75B400ACA775 /* BluetoothUnavailableSection.swift in Sources */, A922BB202C2CB280009DD0E1 /* MeasurementsTestView.swift in Sources */, A922BB1E2C2CB276009DD0E1 /* DevicesTestView.swift in Sources */, A959B7E62C2CBF0900ACA775 /* HKSampleView.swift in Sources */, 2FA7382C290ADFAA007ACEB9 /* TestApp.swift in Sources */, + A959B7F52C2D72A500ACA775 /* BluetoothViewsTest.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -267,6 +290,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A959B7FC2C2D769A00ACA775 /* BluetoothViewsTests.swift in Sources */, A959B7F02C2D602C00ACA775 /* PairedDevicesTests.swift in Sources */, 2F8A431329130A8C005D2B8F /* HealthMeasurementsTests.swift in Sources */, ); From 0143dd2376de3cb07a848d7c6ef07cdc4e974544 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 27 Jun 2024 13:22:08 +0200 Subject: [PATCH 47/77] Resolve last few todos --- Package.swift | 7 +- .../Pairing/AccessorySetupSheet.swift | 43 +++++++++-- .../Pairing/DiscoveryView.swift | 27 +++++-- .../Resources/Localizable.xcstrings | 74 +++++++++++++++---- .../PairedDevicesTests.swift | 17 +---- Tests/SpeziOmronTests/SpeziOmronTests.swift | 22 +++++- .../TestAppUITests/PairedDevicesTests.swift | 1 - .../UITests/UITests.xcodeproj/project.pbxproj | 4 +- 8 files changed, 146 insertions(+), 49 deletions(-) diff --git a/Package.swift b/Package.swift index 3b035c7..379b0ae 100644 --- a/Package.swift +++ b/Package.swift @@ -38,6 +38,7 @@ let package = Package( .package(url: "https://github.com/StanfordSpezi/SpeziViews.git", from: "1.5.0"), .package(url: "https://github.com/StanfordSpezi/SpeziBluetooth", branch: "feature/accessory-discovery"), .package(url: "https://github.com/StanfordSpezi/SpeziNetworking", from: "2.0.0"), + .package(url: "https://github.com/StanfordBDHG/XCTestExtensions.git", branch: "feature/xctassert-throws-async"), .package(url: "https://github.com/JWAutumn/ACarousel", .upToNextMinor(from: "0.2.0")) ] + swiftLintPackage(), targets: [ @@ -92,7 +93,8 @@ let package = Package( .product(name: "SpeziFoundation", package: "SpeziFoundation"), .product(name: "XCTSpezi", package: "Spezi"), .product(name: "SpeziBluetooth", package: "SpeziBluetooth"), - .product(name: "SpeziBluetoothServices", package: "SpeziBluetooth") + .product(name: "SpeziBluetoothServices", package: "SpeziBluetooth"), + .product(name: "XCTestExtensions", package: "XCTestExtensions") ], swiftSettings: [ swiftConcurrency @@ -104,7 +106,8 @@ let package = Package( dependencies: [ .target(name: "SpeziOmron"), .product(name: "SpeziBluetooth", package: "SpeziBluetooth"), - .product(name: "XCTByteCoding", package: "SpeziNetworking") + .product(name: "XCTByteCoding", package: "SpeziNetworking"), + .product(name: "XCTestExtensions", package: "XCTestExtensions") ], swiftSettings: [ swiftConcurrency diff --git a/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift b/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift index 7a44b2a..cc1a25e 100644 --- a/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift +++ b/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift @@ -14,13 +14,14 @@ import SwiftUI /// Accessory Setup view displayed in a sheet. -public struct AccessorySetupSheet: View where Collection.Element == any PairableDevice { +public struct AccessorySetupSheet: View where Collection.Element == any PairableDevice { private static var logger: Logger { Logger(subsystem: "edu.stanford.sepzi.SpeziDevices", category: "AccessorySetupSheet") } private let devices: Collection private let appName: String + private let pairingHint: PairingHint @Environment(Bluetooth.self) private var bluetooth @Environment(PairedDevices.self) private var pairedDevices @@ -45,7 +46,9 @@ public struct AccessorySetupSheet: View wher } } } else { - DiscoveryView() + DiscoveryView { + pairingHint + } } } .toolbar { @@ -62,9 +65,31 @@ public struct AccessorySetupSheet: View wher /// - Parameters: /// - devices: The collection of nearby devices which are available for pairing. /// - appName: The name of the application to show in the pairing UI. - public init(_ devices: Collection, appName: String) { + /// - pairingHint: The pairing hint to display in the Discovery view. + public init(_ devices: Collection, appName: String, @ViewBuilder pairingHint: () -> PairingHint = { EmptyView() }) { self.devices = devices self.appName = appName + self.pairingHint = pairingHint() + } + + /// Create a new Accessory Setup sheet. + /// - Parameters: + /// - devices: The collection of nearby devices which are available for pairing. + /// - appName: The name of the application to show in the pairing UI. + /// - pairingHint: The pairing hint to display in the Discovery view. + public init(_ devices: Collection, appName: String, pairingHint: Text) where PairingHint == Text { + self.init(devices, appName: appName) { + pairingHint + } + } + + /// Create a new Accessory Setup sheet. + /// - Parameters: + /// - devices: The collection of nearby devices which are available for pairing. + /// - appName: The name of the application to show in the pairing UI. + /// - pairingHint: The pairing hint to display in the Discovery view. + public init(_ devices: Collection, appName: String, pairingHint: LocalizedStringResource) where PairingHint == Text { + self.init(devices, appName: appName, pairingHint: Text(pairingHint)) } } @@ -73,7 +98,9 @@ public struct AccessorySetupSheet: View wher #Preview { Text(verbatim: "") .sheet(isPresented: .constant(true)) { - AccessorySetupSheet([MockDevice.createMockDevice()], appName: "Example") + AccessorySetupSheet([MockDevice.createMockDevice()], appName: "Example") { + Text(verbatim: "Make sure to enable pairing mode on the device.") + } } .previewWith { Bluetooth {} @@ -89,7 +116,9 @@ public struct AccessorySetupSheet: View wher MockDevice.createMockDevice(name: "Device 1"), MockDevice.createMockDevice(name: "Device 2") ] - AccessorySetupSheet(devices, appName: "Example") + AccessorySetupSheet(devices, appName: "Example") { + Text(verbatim: "Make sure to enable pairing mode on the device.") + } } .previewWith { Bluetooth {} @@ -100,7 +129,9 @@ public struct AccessorySetupSheet: View wher #Preview { Text(verbatim: "") .sheet(isPresented: .constant(true)) { - AccessorySetupSheet([], appName: "Example") + AccessorySetupSheet([], appName: "Example") { + Text(verbatim: "Make sure to enable pairing mode on the device.") + } } .previewWith { Bluetooth {} diff --git a/Sources/SpeziDevicesUI/Pairing/DiscoveryView.swift b/Sources/SpeziDevicesUI/Pairing/DiscoveryView.swift index 46ae7c6..679a9fc 100644 --- a/Sources/SpeziDevicesUI/Pairing/DiscoveryView.swift +++ b/Sources/SpeziDevicesUI/Pairing/DiscoveryView.swift @@ -9,17 +9,34 @@ import SwiftUI -struct DiscoveryView: View { +struct DiscoveryView: View { + private let pairingHint: Hint + var body: some View { - PaneContent( - title: "Discovering", - subtitle: "Hold down the Bluetooth button for 3 seconds to put the device into pairing mode." - ) { + PaneContent { + Text("Discovering") + } subtitle: { + pairingHint + } content: { ProgressView() .controlSize(.large) .accessibilityHidden(true) } } + + init(@ViewBuilder pairingHint: () -> Hint = { EmptyView() }) { + self.pairingHint = pairingHint() + } + + init(pairingHint: Text) where Hint == Text { + self.init { + pairingHint + } + } + + init(pairingHint: LocalizedStringResource) where Hint == Text { + self.init(pairingHint: Text(pairingHint)) + } } diff --git a/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings b/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings index 200af4b..cf658dc 100644 --- a/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings +++ b/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings @@ -22,10 +22,24 @@ } }, "%lld BPM" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld BPM" + } + } + } }, "%lld cm" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld cm" + } + } + } }, "%lld/%lld mmHg" : { "localizations" : { @@ -188,7 +202,14 @@ } }, "Discard" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Discard" + } + } + } }, "Disconnecting" : { "localizations" : { @@ -290,28 +311,25 @@ } } }, - "Hold down the Bluetooth button for 3 seconds to put the device into pairing mode." : { + "Intervention Required" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Hold down the Bluetooth button for 3 seconds to put the device into pairing mode." + "value" : "Intervention Required" } } } }, - "Intervention Required" : { + "Invalid Sample" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Intervention Required" + "value" : "Invalid Sample" } } } - }, - "Invalid Sample" : { - }, "Make sure to to remove the device from the Bluetooth settings to fully unpair the device." : { "localizations" : { @@ -324,7 +342,14 @@ } }, "Measurement Recorded" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Measurement Recorded" + } + } + } }, "Model" : { "localizations" : { @@ -357,7 +382,14 @@ } }, "No Pending Measurements" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No Pending Measurements" + } + } + } }, "OK" : { "localizations" : { @@ -460,7 +492,14 @@ } }, "Save" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Save" + } + } + } }, "Synchronizing ..." : { "localizations" : { @@ -483,7 +522,14 @@ } }, "There are currently no pending measurements. Conduct a measurement with a paired device while nearby." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "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/Tests/SpeziDevicesTests/PairedDevicesTests.swift b/Tests/SpeziDevicesTests/PairedDevicesTests.swift index f3e9ee3..14d4a15 100644 --- a/Tests/SpeziDevicesTests/PairedDevicesTests.swift +++ b/Tests/SpeziDevicesTests/PairedDevicesTests.swift @@ -11,6 +11,7 @@ import SpeziBluetoothServices @_spi(TestingSupport) import SpeziDevices import SpeziFoundation import XCTest +import XCTestExtensions import XCTSpezi @@ -193,19 +194,3 @@ final class PairedDevicesTests: XCTestCase { XCTAssertEqual(device.state, .disconnected) } } - - -func XCTAssertThrowsErrorAsync( // TODO: use from XCTestExtensions (also in SpeziBluetooth)! - _ expression: @autoclosure () async throws -> T, - _ message: @autoclosure () -> String = "", - file: StaticString = #filePath, - line: UInt = #line, - _ errorHandler: (Error) throws -> Void = { _ in } -) async rethrows { - do { - _ = try await expression() - XCTFail(message(), file: file, line: line) - } catch { - try errorHandler(error) - } -} diff --git a/Tests/SpeziOmronTests/SpeziOmronTests.swift b/Tests/SpeziOmronTests/SpeziOmronTests.swift index 83d3023..d295a0b 100644 --- a/Tests/SpeziOmronTests/SpeziOmronTests.swift +++ b/Tests/SpeziOmronTests/SpeziOmronTests.swift @@ -14,6 +14,7 @@ import SpeziBluetoothServices @testable import SpeziOmron import XCTByteCoding import XCTest +import XCTestExtensions typealias RACP = RecordAccessControlPoint @@ -132,7 +133,12 @@ final class SpeziOmronTests: XCTestCase { } try await service.reportStoredRecords(.allRecords) - // TODO: async throws throwing (no records found!) + service.$recordAccessControlPoint.onRequest { _ in + RACP(opCode: .responseCode, operator: .null, operand: .generalResponse(.init(requestOpCode: .reportStoredRecords, response: .noRecordsFound))) + } + try await XCTAssertThrowsErrorAsync(await service.reportStoredRecords(.allRecords)) { error in + try XCTAssertEqual(XCTUnwrap(error as? RecordAccessResponseCode), .noRecordsFound) + } } func testRACPReportNumberOfStoredRecordsRequest() async throws { @@ -145,7 +151,12 @@ final class SpeziOmronTests: XCTestCase { let count = try await service.reportNumberOfStoredRecords(.allRecords) XCTAssertEqual(count, 1234) - // TODO: test error unexpected operand! + service.$recordAccessControlPoint.onRequest { _ in + RACP(opCode: .numberOfStoredRecordsResponse, operator: .null, operand: .sequenceNumber(1234)) + } + try await XCTAssertThrowsErrorAsync(await service.reportNumberOfStoredRecords(.allRecords)) { error in + try XCTAssertEqual(XCTUnwrap(error as? RecordAccessResponseFormatError).reason, .unexpectedOperand) + } } func testRACPReportSequenceNumberOfLatestRecords() async throws { @@ -158,7 +169,12 @@ final class SpeziOmronTests: XCTestCase { let count = try await service.reportSequenceNumberOfLatestRecords() XCTAssertEqual(count, 1234) - // TODO: test error unexpected operand! + service.$recordAccessControlPoint.onRequest { _ in + RACP(opCode: .omronSequenceNumberOfLatestRecordsResponse, operator: .null, operand: .numberOfRecords(1234)) + } + try await XCTAssertThrowsErrorAsync(await service.reportSequenceNumberOfLatestRecords()) { error in + try XCTAssertEqual(XCTUnwrap(error as? RecordAccessResponseFormatError).reason, .unexpectedOperand) + } } } diff --git a/Tests/UITests/TestAppUITests/PairedDevicesTests.swift b/Tests/UITests/TestAppUITests/PairedDevicesTests.swift index 32691bc..4d08b23 100644 --- a/Tests/UITests/TestAppUITests/PairedDevicesTests.swift +++ b/Tests/UITests/TestAppUITests/PairedDevicesTests.swift @@ -54,7 +54,6 @@ class PairedDevicesTests: XCTestCase { app.navigationBars.buttons["Dismiss"].tap() XCTAssert(app.staticTexts["No Devices"].waitForExistence(timeout: 0.5)) - // TODO: customize the pairing hint! } @MainActor diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 15c8bcf..e5a0aef 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -719,8 +719,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/StanfordBDHG/XCTestExtensions.git"; requirement = { - kind = upToNextMinorVersion; - minimumVersion = 0.4.10; + branch = "feature/xctassert-throws-async"; + kind = branch; }; }; /* End XCRemoteSwiftPackageReference section */ From 8e193091dd90f79624b979291aa011858cf8a30c Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 27 Jun 2024 13:28:06 +0200 Subject: [PATCH 48/77] Test pairing hint --- .../SpeziDevicesUI/Devices/DevicesTab.swift | 32 ++++++++++++++++--- .../Pairing/DiscoveryView.swift | 10 ------ Tests/UITests/TestApp/DevicesTestView.swift | 2 +- .../TestAppUITests/PairedDevicesTests.swift | 1 + 4 files changed, 30 insertions(+), 15 deletions(-) diff --git a/Sources/SpeziDevicesUI/Devices/DevicesTab.swift b/Sources/SpeziDevicesUI/Devices/DevicesTab.swift index be38fa2..9807271 100644 --- a/Sources/SpeziDevicesUI/Devices/DevicesTab.swift +++ b/Sources/SpeziDevicesUI/Devices/DevicesTab.swift @@ -14,8 +14,9 @@ import SwiftUI /// Devices tab showing grid of paired devices and functionality to pair new devices. /// /// - Note: Make sure to place this view into an `NavigationStack`. -public struct DevicesTab: View { +public struct DevicesTab: View { private let appName: String + private let pairingHint: PairingHint @Environment(Bluetooth.self) private var bluetooth @Environment(PairedDevices.self) private var pairedDevices @@ -27,7 +28,9 @@ public struct DevicesTab: View { // automatically search if no devices are paired .scanNearbyDevices(enabled: pairedDevices.isScanningForNearbyDevices, with: bluetooth) .sheet(isPresented: $pairedDevices.shouldPresentDevicePairing) { - AccessorySetupSheet(pairedDevices.discoveredDevices.values, appName: appName) + AccessorySetupSheet(pairedDevices.discoveredDevices.values, appName: appName) { + pairingHint + } } .toolbar { // indicate that we are scanning in the background @@ -38,9 +41,30 @@ public struct DevicesTab: View { } /// Create a new devices tab - /// - Parameter appName: The name of the application to show in the pairing UI. - public init(appName: String) { + /// - Parameters: + /// - appName: The name of the application to show in the pairing UI. + /// - pairingHint: The pairing hint to display in the Discovery view. + public init(appName: String, @ViewBuilder pairingHint: () -> PairingHint = { EmptyView() }) { self.appName = appName + self.pairingHint = pairingHint() + } + + /// Create a new devices tab + /// - Parameters: + /// - appName: The name of the application to show in the pairing UI. + /// - pairingHint: The pairing hint to display in the Discovery view. + public init(appName: String, pairingHint: Text) where PairingHint == Text { + self.init(appName: appName) { + pairingHint + } + } + + /// Create a new devices tab + /// - Parameters: + /// - appName: The name of the application to show in the pairing UI. + /// - pairingHint: The pairing hint to display in the Discovery view. + public init(appName: String, pairingHint: LocalizedStringResource) where PairingHint == Text { + self.init(appName: appName, pairingHint: Text(pairingHint)) } } diff --git a/Sources/SpeziDevicesUI/Pairing/DiscoveryView.swift b/Sources/SpeziDevicesUI/Pairing/DiscoveryView.swift index 679a9fc..c1b6b28 100644 --- a/Sources/SpeziDevicesUI/Pairing/DiscoveryView.swift +++ b/Sources/SpeziDevicesUI/Pairing/DiscoveryView.swift @@ -27,16 +27,6 @@ struct DiscoveryView: View { init(@ViewBuilder pairingHint: () -> Hint = { EmptyView() }) { self.pairingHint = pairingHint() } - - init(pairingHint: Text) where Hint == Text { - self.init { - pairingHint - } - } - - init(pairingHint: LocalizedStringResource) where Hint == Text { - self.init(pairingHint: Text(pairingHint)) - } } diff --git a/Tests/UITests/TestApp/DevicesTestView.swift b/Tests/UITests/TestApp/DevicesTestView.swift index a72ee6a..08c14c9 100644 --- a/Tests/UITests/TestApp/DevicesTestView.swift +++ b/Tests/UITests/TestApp/DevicesTestView.swift @@ -34,7 +34,7 @@ struct DevicesTestView: View { var body: some View { NavigationStack { - DevicesTab(appName: "TestApp") + DevicesTab(appName: "TestApp", pairingHint: "Enable pairing mode on the device.") .toolbar { ToolbarItemGroup(placement: .secondaryAction) { Button("Discover Device", systemImage: "plus.rectangle.fill.on.rectangle.fill") { diff --git a/Tests/UITests/TestAppUITests/PairedDevicesTests.swift b/Tests/UITests/TestAppUITests/PairedDevicesTests.swift index 4d08b23..df9282d 100644 --- a/Tests/UITests/TestAppUITests/PairedDevicesTests.swift +++ b/Tests/UITests/TestAppUITests/PairedDevicesTests.swift @@ -50,6 +50,7 @@ class PairedDevicesTests: XCTestCase { app.buttons["Pair New Device"].tap() XCTAssert(app.staticTexts["Discovering"].waitForExistence(timeout: 0.5)) + XCTAssert(app.staticTexts["Enable pairing mode on the device."].exists) XCTAssert(app.navigationBars.buttons["Dismiss"].exists) app.navigationBars.buttons["Dismiss"].tap() From 0d2c3b1a85375be9e32f37e3e9f35ad53d06b300 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 27 Jun 2024 13:35:34 +0200 Subject: [PATCH 49/77] Fix swiftlint --- Tests/SpeziDevicesTests/PairedDevicesTests.swift | 2 +- Tests/SpeziOmronTests/SpeziOmronTests.swift | 6 +++++- Tests/UITests/TestAppUITests/PairedDevicesTests.swift | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Tests/SpeziDevicesTests/PairedDevicesTests.swift b/Tests/SpeziDevicesTests/PairedDevicesTests.swift index 14d4a15..a5d6f67 100644 --- a/Tests/SpeziDevicesTests/PairedDevicesTests.swift +++ b/Tests/SpeziDevicesTests/PairedDevicesTests.swift @@ -17,7 +17,7 @@ import XCTSpezi final class PairedDevicesTests: XCTestCase { @MainActor - func testPairDevice() async throws { + func testPairDevice() async throws { // swiftlint:disable:this function_body_length let device = MockDevice.createMockDevice() let devices = PairedDevices() defer { diff --git a/Tests/SpeziOmronTests/SpeziOmronTests.swift b/Tests/SpeziOmronTests/SpeziOmronTests.swift index d295a0b..f002656 100644 --- a/Tests/SpeziOmronTests/SpeziOmronTests.swift +++ b/Tests/SpeziOmronTests/SpeziOmronTests.swift @@ -134,7 +134,11 @@ final class SpeziOmronTests: XCTestCase { try await service.reportStoredRecords(.allRecords) service.$recordAccessControlPoint.onRequest { _ in - RACP(opCode: .responseCode, operator: .null, operand: .generalResponse(.init(requestOpCode: .reportStoredRecords, response: .noRecordsFound))) + RACP( + opCode: .responseCode, + operator: .null, + operand: .generalResponse(.init(requestOpCode: .reportStoredRecords, response: .noRecordsFound)) + ) } try await XCTAssertThrowsErrorAsync(await service.reportStoredRecords(.allRecords)) { error in try XCTAssertEqual(XCTUnwrap(error as? RecordAccessResponseCode), .noRecordsFound) diff --git a/Tests/UITests/TestAppUITests/PairedDevicesTests.swift b/Tests/UITests/TestAppUITests/PairedDevicesTests.swift index df9282d..7eef0b6 100644 --- a/Tests/UITests/TestAppUITests/PairedDevicesTests.swift +++ b/Tests/UITests/TestAppUITests/PairedDevicesTests.swift @@ -58,7 +58,7 @@ class PairedDevicesTests: XCTestCase { } @MainActor - func testPairDevice() throws { + func testPairDevice() throws { // swiftlint:disable:this function_body_length let app = XCUIApplication() app.launch() From c349ac0f48a9e5cb69dff6a93019c089a57a0ab2 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 27 Jun 2024 14:47:09 +0200 Subject: [PATCH 50/77] Allow to detect if a pairing was successful. --- Sources/SpeziDevices/PairedDevices.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Sources/SpeziDevices/PairedDevices.swift b/Sources/SpeziDevices/PairedDevices.swift index 5e00a09..ac99fe9 100644 --- a/Sources/SpeziDevices/PairedDevices.swift +++ b/Sources/SpeziDevices/PairedDevices.swift @@ -405,13 +405,16 @@ extension PairedDevices { /// You call this method from your device implementation on events that indicate that the device was successfully paired. /// - Note: This method does nothing if there is currently no ongoing pairing session for a device. /// - Parameter device: The device that can be considered paired and might have an ongoing pairing session. + /// - Returns: Returns `true` if there was an ongoing pairing session and the device is now paired. @MainActor - public func signalDevicePaired(_ device: some PairableDevice) { + @discardableResult + public func signalDevicePaired(_ device: some PairableDevice) -> Bool { guard let continuation = ongoingPairings.removeValue(forKey: device.id) else { - return + return false } logger.debug("Device \(device.label), \(device.id) signaled it is fully paired.") continuation.signalPaired() + return true } @MainActor From f23dd2b6949ac8c2cab3a22b883da6edfa5576ad Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 27 Jun 2024 16:43:18 +0200 Subject: [PATCH 51/77] Don't do if debug || test --- Sources/SpeziDevices/HealthMeasurements.swift | 2 -- Sources/SpeziDevices/Testing/MockBluetoothPeripheral.swift | 2 -- Sources/SpeziDevices/Testing/MockDevice.swift | 2 -- 3 files changed, 6 deletions(-) diff --git a/Sources/SpeziDevices/HealthMeasurements.swift b/Sources/SpeziDevices/HealthMeasurements.swift index 21e666f..b030377 100644 --- a/Sources/SpeziDevices/HealthMeasurements.swift +++ b/Sources/SpeziDevices/HealthMeasurements.swift @@ -234,7 +234,6 @@ public class HealthMeasurements { extension HealthMeasurements: Module, EnvironmentAccessible, DefaultInitializable {} -#if DEBUG || TEST extension HealthMeasurements { /// Call in preview simulator wrappers. /// @@ -266,4 +265,3 @@ extension HealthMeasurements { handleNewMeasurement(.bloodPressure(measurement, device.bloodPressure.features ?? []), from: device.hkDevice) } } -#endif diff --git a/Sources/SpeziDevices/Testing/MockBluetoothPeripheral.swift b/Sources/SpeziDevices/Testing/MockBluetoothPeripheral.swift index cd6d07e..e6ff466 100644 --- a/Sources/SpeziDevices/Testing/MockBluetoothPeripheral.swift +++ b/Sources/SpeziDevices/Testing/MockBluetoothPeripheral.swift @@ -9,7 +9,6 @@ import SpeziBluetooth -#if DEBUG || TEST /// Mock peripheral used for internal previews. @_spi(TestingSupport) public struct MockBluetoothPeripheral: GenericBluetoothPeripheral { @@ -24,4 +23,3 @@ public struct MockBluetoothPeripheral: GenericBluetoothPeripheral { self.requiresUserAttention = requiresUserAttention } } -#endif diff --git a/Sources/SpeziDevices/Testing/MockDevice.swift b/Sources/SpeziDevices/Testing/MockDevice.swift index 92b7d9f..c8fc968 100644 --- a/Sources/SpeziDevices/Testing/MockDevice.swift +++ b/Sources/SpeziDevices/Testing/MockDevice.swift @@ -12,7 +12,6 @@ import SpeziBluetoothServices import SpeziNumerics -#if DEBUG || TEST @_spi(TestingSupport) public final class MockDevice: PairableDevice, HealthDevice, BatteryPoweredDevice { @DeviceState(\.id) public var id @@ -214,4 +213,3 @@ extension WeightMeasurement { ) } } -#endif From c617acc6212a27808939e927f9876e5fe24b574e Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 27 Jun 2024 17:13:45 +0200 Subject: [PATCH 52/77] Move Omron devices to SpeziDevices --- Package.swift | 3 + .../SpeziDevices.docc/SpeziDevices.md | 1 - Sources/SpeziDevices/Testing/MockDevice.swift | 8 +- .../Devices/OmronBloodPressureCuff.swift | 180 ++++++++++++++++++ .../SpeziOmron/Devices/OmronWeightScale.swift | 178 +++++++++++++++++ .../Resources/Media.xcassets/Contents.json | 6 + .../Media.xcassets/Contents.json.license | 6 + .../Omron-BP5250.imageset/Contents.json | 21 ++ .../Contents.json.license | 6 + .../Omron-BP5250.imageset/Omron-BP5250.jpg | Bin 0 -> 26234 bytes .../Omron-BP5250.jpg.license | 6 + .../Omron-SC-150.imageset/Contents.json | 21 ++ .../Contents.json.license | 6 + .../Omron-SC-150.imageset/Omron-SC-150.jpg | Bin 0 -> 23520 bytes .../Omron-SC-150.jpg.license | 6 + .../SpeziOmron/SpeziOmron.docc/SpeziOmron.md | 5 + 16 files changed, 446 insertions(+), 7 deletions(-) create mode 100644 Sources/SpeziOmron/Devices/OmronBloodPressureCuff.swift create mode 100644 Sources/SpeziOmron/Devices/OmronWeightScale.swift create mode 100644 Sources/SpeziOmron/Resources/Media.xcassets/Contents.json create mode 100644 Sources/SpeziOmron/Resources/Media.xcassets/Contents.json.license create mode 100644 Sources/SpeziOmron/Resources/Media.xcassets/Omron-BP5250.imageset/Contents.json create mode 100644 Sources/SpeziOmron/Resources/Media.xcassets/Omron-BP5250.imageset/Contents.json.license create mode 100644 Sources/SpeziOmron/Resources/Media.xcassets/Omron-BP5250.imageset/Omron-BP5250.jpg create mode 100644 Sources/SpeziOmron/Resources/Media.xcassets/Omron-BP5250.imageset/Omron-BP5250.jpg.license create mode 100644 Sources/SpeziOmron/Resources/Media.xcassets/Omron-SC-150.imageset/Contents.json create mode 100644 Sources/SpeziOmron/Resources/Media.xcassets/Omron-SC-150.imageset/Contents.json.license create mode 100644 Sources/SpeziOmron/Resources/Media.xcassets/Omron-SC-150.imageset/Omron-SC-150.jpg create mode 100644 Sources/SpeziOmron/Resources/Media.xcassets/Omron-SC-150.imageset/Omron-SC-150.jpg.license diff --git a/Package.swift b/Package.swift index 379b0ae..6132fb0 100644 --- a/Package.swift +++ b/Package.swift @@ -81,6 +81,9 @@ let package = Package( .product(name: "SpeziBluetooth", package: "SpeziBluetooth"), .product(name: "SpeziBluetoothServices", package: "SpeziBluetooth") ], + resources: [ + .process("Resources") + ], swiftSettings: [ swiftConcurrency ], diff --git a/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md b/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md index d77275d..cde5efa 100644 --- a/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md +++ b/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md @@ -23,7 +23,6 @@ Below is a list of important symbols of SpeziDevices. - ``PairedDevices`` - ``PairedDeviceInfo`` - ``DevicePairingError`` -- ``PairingContinuation`` - ``ImageReference`` ### Devices diff --git a/Sources/SpeziDevices/Testing/MockDevice.swift b/Sources/SpeziDevices/Testing/MockDevice.swift index c8fc968..dc1d914 100644 --- a/Sources/SpeziDevices/Testing/MockDevice.swift +++ b/Sources/SpeziDevices/Testing/MockDevice.swift @@ -68,6 +68,7 @@ extension MockDevice { /// - Parameters: /// - name: The name of the device. /// - state: The initial peripheral state. + /// - nearby: The nearby state. /// - bloodPressureMeasurement: The blood pressure measurement loaded into the device. /// - weightMeasurement: The weight measurement loaded into the device. /// - weightResolution: The weight resolution to use. @@ -124,10 +125,7 @@ extension MockDevice { } device.$disconnect.inject { @MainActor [weak device] in - guard let device else { - return - } - device.$state.inject(.disconnected) + device?.$state.inject(.disconnected) } device.$state.enableSubscriptions() @@ -143,8 +141,6 @@ extension MockDevice { device.weightScale.$weightMeasurement.enableSubscriptions() device.weightScale.$weightMeasurement.enablePeripheralSimulation() - device.configure() - return device } } diff --git a/Sources/SpeziOmron/Devices/OmronBloodPressureCuff.swift b/Sources/SpeziOmron/Devices/OmronBloodPressureCuff.swift new file mode 100644 index 0000000..aa7a0d3 --- /dev/null +++ b/Sources/SpeziOmron/Devices/OmronBloodPressureCuff.swift @@ -0,0 +1,180 @@ +// +// This source file is part of the Stanford SpeziDevices open source project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import CoreBluetooth +import Foundation +import OSLog +@_spi(TestingSupport) import SpeziBluetooth +import SpeziBluetoothServices +import SpeziDevices +import SpeziNumerics + + +/// Implementation of Omron BP5250 Blood Pressure Cuff. +public class OmronBloodPressureCuff: BluetoothDevice, Identifiable, OmronHealthDevice, BatteryPoweredDevice { + private static let logger = Logger(subsystem: "ENGAGEHF", category: "BloodPressureCuffDevice") + + @DeviceState(\.id) public var id: UUID + @DeviceState(\.name) public var name: String? + @DeviceState(\.state) public var state: PeripheralState + @DeviceState(\.advertisementData) public var advertisementData: AdvertisementData + @DeviceState(\.nearby) public var nearby + + @Service public var deviceInformation = DeviceInformationService() + + @Service public var time = CurrentTimeService() + @Service public var battery = BatteryService() + @Service public var bloodPressure = BloodPressureService() + + @DeviceAction(\.connect) public var connect + @DeviceAction(\.disconnect) public var disconnect + + @Dependency private var measurements: HealthMeasurements? + @Dependency private var pairedDevices: PairedDevices? + + public var icon: ImageReference? { + .asset("Omron-BP5250", bundle: .module) + } + + /// Initialize the device. + public required init() {} + + public func configure() { + $state.onChange { [weak self] value in + await self?.handleStateChange(value) + } + + battery.$batteryLevel.onChange { [weak self] value in + await self?.handleBatteryChange(value) + } + time.$currentTime.onChange { [weak self] value in + await self?.handleCurrentTimeChange(value) + } + + if let pairedDevices { + pairedDevices.configure(device: self, accessing: $state, $advertisementData, $nearby) + } + if let measurements { + measurements.configureReceivingMeasurements(for: self, on: bloodPressure) + } + } + + private func handleStateChange(_ state: PeripheralState) async { + if case .connected = state, + case .transferMode = manufacturerData?.pairingMode { + time.synchronizeDeviceTime() + } + } + + @MainActor + private func handleBatteryChange(_ level: UInt8) { + pairedDevices?.signalDevicePaired(self) + } + + @MainActor + private func handleCurrentTimeChange(_ time: CurrentTime) { + Self.logger.debug("Received updated device time for \(self.label) is \(String(describing: time))") + let paired = pairedDevices?.signalDevicePaired(self) + + if paired == true { + self.time.synchronizeDeviceTime() + } + } +} + + +@_spi(TestingSupport) +extension OmronBloodPressureCuff { + /// Create a mock instance. + /// - Parameters: + /// - systolic: The mock systolic value. + /// - diastolic: The mock diastolic value. + /// - pulseRate: The mock pulse rate value. + /// - state: The initial state. + /// - nearby: The nearby state. + /// - manufacturerData: The initial manufacturer data. + /// - Returns: Returns the mock device instance. + public static func createMockDevice( // swiftlint:disable:this function_body_length + systolic: MedFloat16 = 103, + diastolic: MedFloat16 = 64, + pulseRate: MedFloat16 = 62, + state: PeripheralState = .disconnected, + nearby: Bool = true, + manufacturerData: OmronManufacturerData = OmronManufacturerData(pairingMode: .pairingMode, users: [ + .init(id: 1, sequenceNumber: 2, recordsNumber: 1) + ]) + ) -> OmronBloodPressureCuff { + let device = OmronBloodPressureCuff() + + device.$id.inject(UUID()) + device.$name.inject("Mock Blood Pressure Cuff") + device.$state.inject(state) + device.$nearby.inject(nearby) + + device.deviceInformation.$manufacturerName.inject("Mock Blood Pressure Cuff") + device.deviceInformation.$modelNumber.inject(OmronModel.bp5250.rawValue) + device.deviceInformation.$hardwareRevision.inject("2") + device.deviceInformation.$firmwareRevision.inject("1.0") + + device.battery.$batteryLevel.inject(85) + + let features: BloodPressureFeature = [ + .bodyMovementDetectionSupported, + .irregularPulseDetectionSupported + ] + + let measurement = BloodPressureMeasurement( + systolic: systolic, + diastolic: diastolic, + meanArterialPressure: 77, + unit: .mmHg, + timeStamp: DateTime(year: 2024, month: .june, day: 5, hours: 12, minutes: 33, seconds: 11), + pulseRate: pulseRate, + userId: 1, + measurementStatus: [] + ) + + device.bloodPressure.$features.inject(features) + device.bloodPressure.$bloodPressureMeasurement.inject(measurement) + + let advertisementData = AdvertisementData([ + CBAdvertisementDataManufacturerDataKey: manufacturerData.encode() + ]) + device.$advertisementData.inject(advertisementData) + + device.$connect.inject { @MainActor [weak device] in + guard let device else { + return + } + + device.$state.inject(.connecting) + + try? await Task.sleep(for: .seconds(1)) + + if case .connecting = device.state { + device.$state.inject(.connected) + } + } + + device.$disconnect.inject { @MainActor [weak device] in + device?.$state.inject(.disconnected) + } + + device.$state.enableSubscriptions() + device.$advertisementData.enableSubscriptions() + device.$nearby.enableSubscriptions() + + device.battery.$batteryLevel.enableSubscriptions() + device.battery.$batteryLevel.enablePeripheralSimulation() + + device.bloodPressure.$bloodPressureMeasurement.enableSubscriptions() + device.bloodPressure.$bloodPressureMeasurement.enablePeripheralSimulation() + + return device + } +} diff --git a/Sources/SpeziOmron/Devices/OmronWeightScale.swift b/Sources/SpeziOmron/Devices/OmronWeightScale.swift new file mode 100644 index 0000000..c727915 --- /dev/null +++ b/Sources/SpeziOmron/Devices/OmronWeightScale.swift @@ -0,0 +1,178 @@ +// +// 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 CoreBluetooth +import Foundation +import OSLog +@_spi(TestingSupport) import SpeziBluetooth +import SpeziBluetoothServices +import SpeziDevices + + +/// Implementation of Omron SC150 Weight Scale. +public class OmronWeightScale: BluetoothDevice, Identifiable, OmronHealthDevice { + private static let logger = Logger(subsystem: "ENGAGEHF", category: "WeightScale") + + @DeviceState(\.id) public var id: UUID + @DeviceState(\.name) public var name: String? + @DeviceState(\.state) public var state: PeripheralState + @DeviceState(\.advertisementData) public var advertisementData: AdvertisementData + @DeviceState(\.nearby) public var nearby + + @Service public var deviceInformation = DeviceInformationService() + + @Service public var time = CurrentTimeService() + @Service public var weightScale = WeightScaleService() + + @DeviceAction(\.connect) public var connect + @DeviceAction(\.disconnect) public var disconnect + + @Dependency private var measurements: HealthMeasurements? + @Dependency private var pairedDevices: PairedDevices? + + private var dateOfConnection: Date? + + public var icon: ImageReference? { + .asset("Omron-SC-150", bundle: .module) + } + + /// Initialize the device. + public required init() {} + + public func configure() { + $state.onChange { [weak self] value in + await self?.handleStateChange(value) + } + + time.$currentTime.onChange { [weak self] value in + await self?.handleCurrentTimeChange(value) + } + + if let pairedDevices { + pairedDevices.configure(device: self, accessing: $state, $advertisementData, $nearby) + } + if let measurements { + measurements.configureReceivingMeasurements(for: self, on: weightScale) + } + } + + private func handleStateChange(_ state: PeripheralState) async { + switch state { + case .connected: + switch manufacturerData?.pairingMode { + case .pairingMode: + print("Device connection is NOW!") + dateOfConnection = .now + case .transferMode: + time.synchronizeDeviceTime() + case nil: + break + } + default: + break + } + } + + @MainActor + private func handleCurrentTimeChange(_ time: CurrentTime) { + if case .pairingMode = manufacturerData?.pairingMode, + let dateOfConnection, + abs(Date.now.timeIntervalSince1970 - dateOfConnection.timeIntervalSince1970) < 1 { + // if its pairing mode, and we just connected, we ignore the first current time notification as its triggered + // because of the notification registration. + return + } + + Self.logger.debug("Received updated device time for \(self.label): \(String(describing: time))") + let paired = pairedDevices?.signalDevicePaired(self) == true + if paired { + dateOfConnection = nil + self.time.synchronizeDeviceTime() + } + } +} + + +extension OmronWeightScale { + /// Create a mock instance. + /// - Parameters: + /// - weight: The weight value. + /// - resolution: The weight resolution. + /// - state: The initial state. + /// - nearby: The nearby state. + /// - manufacturerData: The initial manufacturer data. + /// - Returns: Returns the mock device instance. + public static func createMockDevice( + weight: UInt16 = 8400, + resolution: WeightScaleFeature.WeightResolution = .resolution5g, + state: PeripheralState = .disconnected, + nearby: Bool = true, + manufacturerData: OmronManufacturerData = OmronManufacturerData(pairingMode: .pairingMode, users: [ + .init(id: 1, sequenceNumber: 2, recordsNumber: 1) + ]) + ) -> OmronWeightScale { + let device = OmronWeightScale() + + device.$id.inject(UUID()) + device.$name.inject("Mock Health Scale") + device.$state.inject(state) + device.$nearby.inject(nearby) + + device.deviceInformation.$manufacturerName.inject("Mock Weight Scale") + device.deviceInformation.$modelNumber.inject(OmronModel.sc150.rawValue) + device.deviceInformation.$hardwareRevision.inject("2") + device.deviceInformation.$firmwareRevision.inject("1.0") + + // mocks the values as reported by the real device + let features = WeightScaleFeature( + weightResolution: resolution, + heightResolution: .unspecified, + options: .timeStampSupported + ) + + let measurement = WeightMeasurement( + weight: weight, + unit: .si + ) + + device.weightScale.$features.inject(features) + device.weightScale.$weightMeasurement.inject(measurement) + + let advertisementData = AdvertisementData([ + CBAdvertisementDataManufacturerDataKey: manufacturerData.encode() + ]) + device.$advertisementData.inject(advertisementData) + + device.$connect.inject { @MainActor [weak device] in + guard let device else { + return + } + + device.$state.inject(.connecting) + + try? await Task.sleep(for: .seconds(1)) + + if case .connecting = device.state { + device.$state.inject(.connected) + } + } + + device.$disconnect.inject { @MainActor [weak device] in + device?.$state.inject(.disconnected) + } + + device.$state.enableSubscriptions() + device.$advertisementData.enableSubscriptions() + device.$nearby.enableSubscriptions() + + device.weightScale.$weightMeasurement.enableSubscriptions() + device.weightScale.$weightMeasurement.enablePeripheralSimulation() + + return device + } +} diff --git a/Sources/SpeziOmron/Resources/Media.xcassets/Contents.json b/Sources/SpeziOmron/Resources/Media.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Sources/SpeziOmron/Resources/Media.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/SpeziOmron/Resources/Media.xcassets/Contents.json.license b/Sources/SpeziOmron/Resources/Media.xcassets/Contents.json.license new file mode 100644 index 0000000..6becaa6 --- /dev/null +++ b/Sources/SpeziOmron/Resources/Media.xcassets/Contents.json.license @@ -0,0 +1,6 @@ + +This source file is part of the Stanford SpeziDevices open source project + +SPDX-FileCopyrightText: 2024 Stanford University + +SPDX-License-Identifier: MIT diff --git a/Sources/SpeziOmron/Resources/Media.xcassets/Omron-BP5250.imageset/Contents.json b/Sources/SpeziOmron/Resources/Media.xcassets/Omron-BP5250.imageset/Contents.json new file mode 100644 index 0000000..61bac38 --- /dev/null +++ b/Sources/SpeziOmron/Resources/Media.xcassets/Omron-BP5250.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Omron-BP5250.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/SpeziOmron/Resources/Media.xcassets/Omron-BP5250.imageset/Contents.json.license b/Sources/SpeziOmron/Resources/Media.xcassets/Omron-BP5250.imageset/Contents.json.license new file mode 100644 index 0000000..6becaa6 --- /dev/null +++ b/Sources/SpeziOmron/Resources/Media.xcassets/Omron-BP5250.imageset/Contents.json.license @@ -0,0 +1,6 @@ + +This source file is part of the Stanford SpeziDevices open source project + +SPDX-FileCopyrightText: 2024 Stanford University + +SPDX-License-Identifier: MIT diff --git a/Sources/SpeziOmron/Resources/Media.xcassets/Omron-BP5250.imageset/Omron-BP5250.jpg b/Sources/SpeziOmron/Resources/Media.xcassets/Omron-BP5250.imageset/Omron-BP5250.jpg new file mode 100644 index 0000000000000000000000000000000000000000..253cad15dc4ab189170433e5f0d72b6c13a2b757 GIT binary patch literal 26234 zcmdSAbyQqWyC%A!fyNzzJHZn?ctZ%9K;sZx1BBoNhem=1cL>2X1PBg6gS#~Wf;++8 z8<_rm-^`i0=iIgCu37i~ajRF=>RP+@?)`REz4E+IJ;Q-`QO;4$K`2ZBDiI2Z2<5RGphvzZI?BHa@ZSpxDhLf71B{7< zje{IeM+l&zfIz5dAaryzG~{SMcT-3-j+Ov)b^pNq--vg#L^#>6p; zfVoQ$7B=}43Q8*0r)=yToPt8aFGNJeWM9e2D<~={Yieoh=<2=Ix3IK&XKiEq-qr1+ zyN9Qjckri>(6I1`$b`hC*y4T_&)}Sp-*EPyP|@Uy}Vl z6D;WeNV5MB?0?EN2jGHGkdp@@0wBP}AbVZ__Wxfh7r6W3*(8Dwc)>Q>8ut!|y?<$4 zWMO&n%_BgB;PGijMBF=1bY`{vUh6q-v&44CSfMRzpxKn= z6}fdjQod5~6whNg^^n4p*uGZW-g`q}X%az4V=i+x6|2|h|CQq6K-cV~FZ5l<-Md~% zQ1ALFqjL6W%PSQkarHdU`Au^J3!+_Jd=a)RuglWuB^?&|Z$r&4)%}zTv{)Efql?JJW%-avByr4h zZuK!~bC)JbUKC>MGt%$iya~gv+1)Crt1^ve_ObE{U#953nlHeV7wTuu4LYR8{BTGn za6%lseJexn*+DNOQP3=12xR2|QuY6DE7{m}R}Yb=g(`90_qmW?e18n8R?{q=T{G;z ziCV9aUKcIjVTqwwdaK9cTx#vwfsLWWHjaIB)%_E(#QeKLh(k%wf3rXVWXY-8Ap`NM zX=>W_;|sukGLyKl(0YLwarJYXFO2&^DbkC90jR@4fRo5K`FC%z=eOQMsmJ8#7uB<; zCae30O*ti&Z*11lO16p0P?jauzANB5N1^wEx4bU)9{iI!zVWamZY4)Z&cNxK)(QM zc#3)MIMgOK-(u~?Pih9I1fQ4coN0!`Dy+eG*Z!FoA2NW&>S-Z6ldRq?y|XFleSvXA zk0%>_nD;{Mb@UpSp%jpq zr4#XROqN(_7y)`0Ao&u$mQB%8t>IJapjliKN*}trP;(u&T2^_HFklZ)SBvv=nsB7? zS!*$}t}#Vj;&AeO%WP@rLx_3Lu|}ExCY)G~RwA7&oK|Me9TpAvq1%7A)&0a%zE52B zH-ahU5omMGo~+Ovm2&Tj)0ZGhjUxrFL>U!!?Ucv5#d-ozM<29}`>TChyq_KeLTA==!^^)|^(8w8@K-Z{&pw+W`W^uj|Gu@a1!+&FpHn=rg>=EzD@f?CesiHL zPZ%B)#~_}2)Z$FwG{~h>LSFQaJ_1YSLq0>VdzNh_T#K*rK04o&3Ze*+s!OB)zgTHZ zx(`fu9_Sy&Lh2WGqc?EreB(pY?lBn&?tZVG@&{m_PGp8iAQnPsd@>)%=6qFuOD(Qx z+ECn3x!Do^)gAVI%}h|jSpcgsG}hkG%uaeZ6WPg5Mxp(Ymqz@H@YVX3>i}Dms3_Zv zAOeG8tR0d{YwLxUxpHf9yeo>Zt$;GrJMTRBu>(s2{X1il4{d2h8`iT-;h9reRWR`vmY zR?^q1SVM1uvsisohcMAc03xeQeSUPi6+qj-;v8G4z89=}ZUojkp%*CB3v5VLc zXNoXjeEkF0Wp)KnI`4Q|^Es}t8K!?BNe8=@lcJQWw2X9os>$aXd(lArou~$1=5(r( z_T)&K^61!qUs_+%jB1+_bDZp@A5Mf`;8DYI1-0&^5G{_Hy@1x|p-kV2M)8DKf9cpd z7O*bSJb=lHhRRE9--(M{LupWk#oU#FZa`YF)<@_SVtrm5A zmvFFXBMx=%r;P*Fu8-R`IRwv=SpwB7m^|0xK-52zbv4IxO;PAprmPqpg6X#_l`oN& z&NFhT!&^Anf~T}4I@unLF=&n50(a>7W3PcAV8F5#iE=+LasdUuDJ5%s)*WM!^x{+xsMJ%xa&1WVUaR?=g9{mS;7(O zIHQ+8dN-`V;CfLurfh;P2$9jw^jvi2^d!$PhZBRlTfA=%KFJ|jKNxL84NOVfn@f1V zux&#j%GgejM&-RUoK9>Y-@VRVR@EvqIGs|qdhhsh?zS~5WumEb*j>V^nIl2?Bied^ zs$ZE%6iH(I-WzaHu8l}Kn~5C$TbUJ0@CP@#?8yRy@OKPYYcw0F@;c`r#-H9NYE}j@ z1x_yLLOBs(i+%9Z9Gu8%@ppm?ivrYT{-Wkdd3*hFVu};W?cMWnQQpG90J!SmE^5q2nK4ovGE% zU21@a;i?A1(qD#(dB|_{HC4}jZLu3Ef@OUDqhCL|B6L> zc++(Y_wE11AlxgN7T|d?Kr;@8lj@F6dv8^lD$F`=MaXPw$j@iBl(541jO4$ZC>Yl; zek6^&WRTx7Wlp3E%%?z1P5z+kl5-J*bjK3x8d&^%2S22~wtfV3ptbo?uom=>@dGp^ z)!o0=ohXeI8G^@yM3@3-#|Oegc?8_%hup4t-~Ugz)W29Zp%aY+-~S1!fy%7Vcsre(cySA~~8J_g@URHP5O_l7M-bl~U_*zFMy5Qr@j$m%$? zjqBwfSVM@)4+r`}hbj z(@m|8O4E1~j0QXbOJ!vbrZPMN7e6PhX}t>jc6RP&!TJ55a(?XZ|oE2pY^qrf-6d-zZc&IG{t-^W14DE{QGI5 zs%Cc0&HIGpy|7GSsRDV)D*1-;ow-LKT#z1YBQNT9XHZhyobb$S{(X^L{mXVPb~(|G zUMGQHed)D{EvVBa*ct?p-k(2Ol*K$OxQhP{8fNIH!Cni(xWR1#Rj6 z8#DPrCNTf?3w;M6pW}z^R_}*8YrL!27BNzcl) zzqeU7MBXT{%qxq~E437j{lI8y{$lWjBV+P*>9t)UkXH0EIyep!Ulc6{bG_VyPT*uF zTyTtZac<{{xwwH`qyUIXOnjf1PdwFW^D>inN8k8`+cW~D_&Vs4HB1f1Q9?#Rv~jMQ zi6mui&|@9aDKMEvXDshuob?Dq^Y&1pCDY9o2RR=q++5@%GJp#EL{YTh*VWFQ{fe|G zh~}-LyRF!5nvdvBnk zy3Iz%kh`j1LyIA(or{rzA$T5~OSYFyfN=PjOeOq%{Z4~*m_JKuJdz)Jw~GNS*`AK0 zUHm`w)4Upb?hfjIzDpB5WA#Y|l@PHcx@1+(c z{Rsghct@Hh(ZZG)B8mkE0(<~Tfd?UWFvZ>sefC~tFvx{{J80pyK{Z=b$4No8l^4S6 zbx{OzAuvY7@6FJ1e7d}cjZJ1Pij1{ys~;j@W32$?Be3U@&)_-_N2~=ATt5Pwz#+d1 zAkH=~`3T(J50O%Q8(eH$H=v6AHGIMzg2wVZefC@9v2Vhe1B>-86I%UkQZ$*0fS=Plx1 zS8p;Acg8EBcqd(QF?`&z(T&fukKf_KXR>jRfj;&L~5qc^5p%FS0VQKEgKbH7PNdHGbjAABrQaBc77wZ`LNc=D|9p)~== z?xW#;B$LswQdZad#9bxHy}`?G=QQ}C*`!y(hgLONuWd0oji1=e2LD!iiH`>;l^Q9P z?WU%xAtv8H;g>QXWm~JA*tvZcojV_0m5#$OhN!8JdPVvOEU&<@&$tZ2 zs+t>G8$e@q@lQVyB`;HBk#O?$u86GF`pT{uDfGNhGul-mbusAMWRj*}uS$=-2mBSL?lexu8g91oN4+!n6&?Zo02Vg*aRI_L zwl|JsSMo0nu3HW|GXqRNI*FGt)bITpVjn--Vit=Ze_{GS#A{}B5$0=Ggx&)&EPhYt z;E1idDOj!Ak#lQtTJK8`h+~vM%?}fzT+wf&hrcM57s3$GvF_RBy6|y*DL^W2qE7K+ zIVW5z!2Wef*Z!z`gThPC0Ub1dGVex$PpJn|(H_N3Z2PW<(y)-H)b)Wot$Gha%acG} zB>Vrq21)PTXwqq`8iqUqo{s?IQUJZ2w%u&l%aMn_9Y}Zdn}FZ_>?0ry86rjTjvRfF zKo_1J51CFpaP?+DKyJ@b^4=b)9`bs#|bp@JCHN@PI7egRj*vKKF-5?fYw^ zWx6C84>8t_N`W@heE)6x!P`F=!G34dhtv=< z52Pq75v%Pa>3Uzuukn${6?;Fu7VXIEM*wp>VxaF*=r8Cnn+e+W&Arn7PW!*#;`eR+ z?h(j&1h9c)*VRf=*f`aB%zA&~eEcaKwn}vZy+?6`9n(P{p0@5Cyr5rx(E4BRP+uJ* zc?8Hm`vpD%s*~A{hg7!X2+MXvIfeK4BfW>Ib3SS`Th|YhlK#X4{qf~GRwtA z9CSaF?&V%hU&WuWejfy1<(nZoDs%n+$H3?FZ1mj_MrnGn6t1iIuqtpJTh>feOd6Q#bZR)|-udwlbs$-*RK2~)p$(ml~HB*a# zWb2e8-k>uJe`)m8Eq@~#RgD>bwlklK>P79fbK0Q}i4zD`AKu?#kxQ9_oldJjL6dz_ z;1B}65t@m`+DW;jdRK83@zIe5-a5#U0I8ib=$(0X+{Y=$L>OPqi(V7=D@?fy?nZr} zKPdXa!q%ZMOEE2zg-8y~d4L-(Zpt9#vXVF(fYk$7CTR>R4$#Bn{5X!d6Pmn?3VhMg z2j0!)a8{^#Y6B><07qd`&9}IDC5>H|CR(Sil42#?%T1jv4hCC7CF1_V*YbbnnL z4ew~E00$p}j!AlDe#~=Hx&`w9RH)|dy-2?34!nev-mx>0xfr%BjWa(Nd;qUJ-&495 zQ{R)u8C#co>hs_`d5R7^Qxb)2lY*2UGGz}w{4&EPK8*Gj~Bs3_kf zU^rXy2uvjJk!>u4&3Is?3+^!#arc(*%#iC^_xt~DS7TxGdt(Dwpv1|XlvOp$OmFY( zoDzp;PEJx+W;=&|ySXe0!!A$_8&vC`$&Ju>zqXUxjNsCNjjV#n327`j9#SBw_8({| z2^-Mo6s`DPUO>38D2qE1T{XbOOmp5@g;C&Fg_z`hwbvSs6Fk0gG6JxY(a-J2B3gg- znJg7Q0%RJ@Ea5C6(|QBF=SWS+-$V-3YRMmV(@-~p@9UrsoJDg=C|(Qv1umG9m^o;9 zd3V~e*_S4Dzb-S^`_D9o$BIVgYp%5t1f3p%%8%i0!%?KxaBew(hgDd#e4EuYLsSnp zIr?{uq*DR>FHlhUPg0^F2R-*~+$KfoWkF#M_H9vm!QS|1KZlWL(iAbK!h`AC?0ca! zxAdbe@a}f}3tCFLM?mS_)Oq1%DrI9htUf&*J7FDiU}FN_>Nzk=0zbM;I>TW$}|MINL&@0!C%bZOa0~_MNJqEa|T2r#3*DPKw*b>aU{^ z`IURpwDJwb^M>UICpz)9-H|iH@Z5lcQ<0R9&FMU8a%4@^<}b0#0d5!U-#nU~aNBsO zGv4_V+xJ!^y?Kh8^w#M-)>aBCAv#K=p!O^$d^~eNJy^a+@vj2KhA_V*Cir+n(c#D# zizmaw#tOb`g~ z9l_$E+2bMfGjX$Ku8&{FVX!-vWq>n<@-81089pzDw5W|a3w7=Ct6quKCItC*M$N>@ zy+3I>{e8$B)ig`(Ji3w-32dW)qt5q~{T>0T9gE3YJUR2vP-4kpEQS;uQ5-|#G_%@; ziNargQD-V#c%?z%vxbo|&+-3^+{bBH<2bWczqipU`>q@9>`dC<{WGXOE@&u<af0p1f)D3N-*}sh_2F1d zZ0IaC+BVO$Z0D(6ebsfaJ9ha7(P%MB7W0p&jZje*Lg!vcNu?+}bVxCO#Q}FA{wkuM zflicyvW%sN5_m()VocnhAVqqCoR0z&vT+imhs~kFKe& z)gF}s6<=n9$QVoLJ~^DIUw(_8`#t}x=yowE2203M{-mcj+k^g3hFpWf@btdF0u2U= z%ge;QJ-L7@Y>s&4#O}mb9tuohvbz+P^!@N1KwXaXzf^7UFR_P@KnJY1xG~Y(p4Q;T zUN@hdL zOmNLs?tl8)h`QSwNLqMndn$WrOP!G32;}zB>gKF7+)n@W)VUm&pptKJwl-sAnNNPE zE9oxQpEgzk+-{XW0-ZuuriAUYL*7XL1TD;7J--ciLkh-6lB@5emCou-k zpk-Wcvm)8X;f;k7U)+PS?=m;j>5J&}KfCX{Y;#*==)-;YDOP#?QXRqKy+;Bb^su5i zVB;K9;sr(o&qKT2R`HxZdb;+!&FQ+8>}GreOD4&3D_eHyjxlK??@h||iy1R++_0+% zhuu>FR59=zhJs02Op~NkVvfO1hS6xeE8Bc|#bTDx(B|=AX#fbq{7z;JyrWWejBuYb zyk)%@XonYw=juRtR<}I{cSo|n;EK>}SM>nAp445v!+JMw42uyku>HpR{X2op^)}&k z`mZi$#?X@McRkMyM(>{GU%DlowLzXcp=GIpEOsInAAwJ*k3j#!DAhs7WL3ldyp-ef z;?(BhUcO%8^|^{=PnXF()!NDZ4Pp07mx!c;v$wMtqx;n^J=M^iNMPv1J+{c^lIVSg z1ieLTL!%5$RgD|t7-Iyj7VNg{yK4e?2;3m_N_AWn&7M`8b$o%y$|0W;Trpv9+vW$L1=XX7saF@|-juR?1TScfds8>;!a zgJd58o+R1OE*O2gG7WYe(j{k;_^{6~E~69b&mH zO6NJlw{)(!RpcEcSUs^)CXbYo0_(~P4EhBo($|~#2EoSUvWPNQgA?lr!D!VznHlry zX^RHfLhJoEslRF9=&4s0lvd25;EYkR_MoE(s?cDmtnDLck>MdCrxBHv=5mCWD;v+R zAa+dGB}eXtqwL4ucEa<|=5t!bYLn^ifwtTp^)X*N=ZBgZ zVio^_V$fdrw~m09>WhB6MZB}~^N}XkRhCXr5zQFX0#(?*BktC_y3t9G#ptAxd0On- zmsWl$^8s92eb~tM03@MA{W92e@Wyq21SF=8jSc4=?<9KABL8-kaB&Jg5BLM5{Ym1b z1di>X55!#5eiL(^Fcky;&JCz5JFRH?w(Je(J*OA*WY!$lA`}UzeYNI1d~(Sz{&FQq z_M34Z(@J0_Rv7(P!1Qvx_~t5ST>`lxWYys1j6Z_)Co3|J-ZZ8kd>M@$$e^5fm1R)k zMfOV`mr_-nhH4Zn$&Lb37Jmf9pqt9B7m|O0y$eZOkrCj*&2_#I5DFjlLB0YVE5uSk1pyDI+@_|%2iN1^m!aYxx`(klL-f|!Ht9Y60sM#G+Rr12Y0#nRp9xy{ z`Ca$=Dhp)=LvAx-rCls7!BO;+3$i1F}F#-KwJF5S5O#XdELlc4YFqz^?X%&!;>OXt}4UKUW zIaX4XK3{o0q!aYszG%*b8S%llM1~Ze%#qN#q7Y~LBx`W>Xl=^u^{GObrKv`Wu8p0> z9LonBmb=goG6RtIsj$v=rv1wHa*6IY&*s}&9^=8+DJ#@mn?V)_5z)2Th30V(lpaQD zZ#bwnTjM&!8Z)??O82rS5+&`(o_tNn*zUPUpitGRUw)Tv@pbjgwEfr# zNrYDB#i~N9x&ETD-i!3&g%KNM7C1;g{Os@(jh~(B>w5tsZRDm5{+RU*AHT+)!Yy6t z5l9_!V}Feg^P(SC&=$$TY4En%4*S5|{BuI^-h4pGrmoR4V+}myyp8!TniuB-b-c{Z zz=*ZXn#zY=)cXtS5@vU(V64uvBlqT1#i=*FntYM4x}>{J+`Pho|S%8Mjr9gp}-z(!^Kaem$Pb1TDq=DA^X7=FEYdUE0~P%;ygM!j$SjqIeh z{I2j7`!#FnucS?OGJxn%XoI(n$cV@fW|!>p7a=St=LO(a-)AaqxHXPP1LBrf_j|EB zMjWzt^cPaoccojEX|$8*;+x#b9@Hosla%6-{Gg*4=PYpLmK}zQtlCf3qDCizV|jP>%g%L3 zjEZcD@{E4rRM@p)ciRwsKi@?!jLO%UujsXT(BfkIQU2@1`%A=#GJ~VC)aX)Dw;ZyI zBu7M3LY5tek@&vSrN?>x{*g4%#v|a@E+7xp*avoB)1Q<|jXor6YS%5ARm&V#H+lw0#-7aAGyZha=UQS~}G)uNeD$Q}yV98CoMXFN0 z*AM0Bc&W4aDBK@&6Z=ntdWCH*6F4-4gG3Fy6fXmsL>UC0VQJkw$=gn`M9*!^bZ{J= zQa742B(9%{5HA_fE7WDM#9h_Pw4B@(^S#RW_&XDg1>SwqyRWFGM~Fj)w*Hqhb56Uk zD7Qcm3%&MklNc@5Vjo0XTOzzAbYW zR=$9&OLb?{GN&Tt6PtzkbR`fdG2`w2uzU3@`(e>+{ub>LhJrRFvSqcYU)R;le190C5%wuR6tE@rf18-r?sZp->3%LA zE^$mC614qlYE$gfw-fRy^QOSHE)FeEM6<@EgXz3PRUDy(PiEE%mDP{=H(@ZNFEP$= zkx}QY^Ynja^|yf$xqnR59H@4`MI9lzxsTV(R(nQc!$1fCUwH~~{ ztx9@SXg$v@gRiaL#6L{B-D8#C$}!sHB?Nv(u67VxgabpuV%vWIM8m{XX=S#$aw+&* zCefi}h#wA|UfmcAMju~l@)At6@FoEEbDtfxYCY1i@ez7%D2@?q;*Fst2ER@DHgoYv;m?~ z3~==&8BG4SgP@8At?J^dK>k$neW%3E^$&6FXoOu;G&D5MYD@RQ2Q~FcEJi|^b>CEC z`P5FyevDmtgA$x)GJsFF8Ooa30LhbVzRN*Kj&7Z2>VSs z<)Ft#%I$eCE-DWHD+RRN@1X$J0*NmZ(m^1?CC?sUVnDvqYo&ku10?oSHl2{{f1HD_ z#JKDs?mf7DW}zQ9A0Vb`n+hgQ*3=KpqEUyU5@Z*7c#N3mg{NVKme4g}@>Hs?QO*MeIN=|@ zS8gmZb8mmRO3dPOBq0oT)B$gSic+!#8-$Cz9PFQyHW3-l5}R28P8}`HhBQH-EMCu1 zfCN^c8NR` z<0pnsb10or9u03ejgkNj4Z#28t}n2I#X*TaWfN-@=@6RFU%PWbdH)#Jl?@iMAafrS zf08aV25hj%a?uNPE>8>;vY(;pjb|xop~$e!png{m9#=y%Ry!|w_oE@p$1Yv#X8OaJ zL&q^r{;Sonm)H$k;C1;&pohvYHk?~RIDfRc#k2vxIQ&KMCKtZTuycGbSYoTvPCv?< zF43cGh%()tkT;yR`A@oq+$cWEJIeJ!fO}e_uI87u(8 zj$RCQSf_Bp{E}SBqLP=Z-$sN(B#R}E^_eN1p(#ffou2~D#|p9S-X+2Gp5Ay|4=Ue$ zH~#s#+ApwH(k6b=cmmvlTi(Isr$papP~6BN+k>3 z(WJO1a+q?7=+_~-?(Q+IT(U#7cA3Xxk%x6EO0~4=X7(>dsJyHj2J`yA=ggC_2J@F$ zr4b4w1MAMaVwxCon*_=dh3BO>LRLpt+HMsfdg<}6-?Y;*wJI`tD z>VqjJ2$BHumq))g-FcRSy|92cFH#vGfOhMK+3jpz2W!6+y)*2C=$b>m`b69nBd#h* zPu^o9G`~InKHutpT6T`G0kOuGA`MyA`6%B-G0`m?`sPw{gcntga$o?_YAbrQ+)CT7 z4B$gJ35t=lk=u!xI^adg?c@b0%G+CgY+FehC-CA{<7ypoRj>k6Hj$AAx9dm$bb*cC zkg>Hp{Kjqih0I_x-;9GaH`SmH&Ii+ZhF5zU?YA}OecL_!e2HW4rN#C(2`ld?5wC?| zaV1_ttTO`iRx*!3Or)tGfU>9P|2Mz15aOl=u0v3NxHeveHTyi33Kg*%w;S`v2O*Os zi*WZB72s7;$hvXJf8`8nwnKLNW^T0y3;q2q=>i^VQB)&2Y>>bT5>hRMws3H09$^F9 zW+b(5rfWXG{k4#an>=g8s%P+ zl4-!n80dcj6#4RS{CHc|D%1H5{9NY{tx#$!jR6{&m{}P%)4dqy1Qh?k=}0j(=0Pbg z*EM#(@67Z!#NHQ8ePf2u7z8VMl~8^4NA?dUSMK&?6SE0;-LV|KO0Y~sZ)c0XCU*i#I1c^^2^{Vo9Y&A$2*$C0&y%)9@bZ;~7UAAeok7rb} zh;uZ>N12cLs`6}o-^`c8T?O^d(WmPX$jhrZoYOr^&01@LshoUR0tcSk89cA`s=SPN z_Uw#4%}85TIzB-n@5!cppVM~KS_NCry}U5)Px}gmI~Q2=6Kd5hSXYdMX%1|RI`ct$ z&$`@eCcrH`f94UW%a6}h;wem!l-k2_jld@0dl|qIMsPY1vfvuBF7&s!98+U~gTt01 zoCA-CF$V()iO|PoBV+k-{aQO=$TcEt7j{mfFQ52K9G#W4Hk6fzR1KB>r(G|?tqAgD z*&-kF%UJ~1AE@lP(E2h_>d=W{6lAloh3A&+5%^;R${d5jk8{qBmUrIZ?djCc+g#xE z)FiBlzp{#Nn-JjaEhPF15~wa&>UgTOE_(0=lh^x_^oyTp`R8ox-IRT7L4IktiaqTB z?3u8SCBO3gCU!1=MWhlEKtZjk_%4LQPkcD1SSTJI6;;o?ImEe9qCPO@;O#0+cDLTj z2H%U6UJaG`1(^(x*_jR?C3tAcpG#ypi!?mX0UhVh(@gZbN{i;8=JK4)&BQ5{0cr#?h#nspYJc9c<%N zz^@-jE?~Rzg#K97KVM|@akSovC8Q(-V1w1=K*1TnX>7#)$Xba!^za~eR-l7*LqfO0 z5XqQ>l|Fpjf)Tm#y!7+#(hQN9f4z>Lkvytl-;L({Fe|FL96#6kY&BT9+l?hd)`v+< z>#j2)SrHejh&spI#^%+5CLZ2P-xEHD>1PCkU<~U%s{G=NPt=w%*Y# z4Nn?~zoc7ysyqZSarSVSL%c#32Bi+AA;K%d7T0z8@~fj?-K4j_!w}j}NL0699A7F? z_iicCv?Q|5r0pwlD{{Xk84#u2n>Hw|e0Izl+_Nr}@swmvsP;;2fxW|~;C_|`hQn1q ztvL}$b5W@-se;McsIDZ(ht617Ac#T*>ziV`ul#GLIBkC7N^rMUJEs4pWSy`fiA}RMpO&Ge&@z@=BP~VSzozt zavpX*Ts+;=&V6Xc4qJ3u4hiTTrYL3bv&Z1+VLs)!8}m_rrh-K9SrDy_c+hWMSM zrtxJ+!@x@c&vtc~RkB3Lcez?mSfY8Fxq2)9=*GG^tQ;V*%?;C)cct5PF90X@5kNcx z!?rn^o%tjO&BeJ-YHA^vQ7RMug~reJ^AO3k8p!Gq(91c;hVE(T$1cfB>cPY!ZsP8q zLpzJ>?{*HxE^@FNN2oD(q>F=l7^s^$W%iW4@63ZR5sLYgAJo9(%LL4Po-dSj_NwNO zZ7oe<%tk!#Jl~_(uO2)Dg!gO1m>2RAZ!_RIsFCdd*IH9aa5PpAi7TfXZhiz}!w`_T z9L$meByy~e92p-&GPy07GthTgvN~uM*OcuxB)hwpTz!P19AMvN3GKxnOY{@$5A&4r z$5_B9^_d=qD|eAV0#X=sTxsU2gYymgj!0!r@$~iDnx?iS@|bB#Ha9TZcV$ZkdM9>N zqk?nN501)6?RBl(;#!{5a*U{CI&Sdxt7}ocJc@03A%8fb6r>%&LR0#e^2>exiFK`R z_pM(HN!IZ<97SPp%@c{@DEtyL=k$_w5t0WPwkfJTleu%K30krgw`Hh!Kb55WZf8 z;2Mf17%?q0)6{S#4M-$@;^fFa4!wQ@8>XX2_&x$JDrj5e*6%g%Uv!G|s;Wz$PGW2$=f3DkC7IspX3Sl8^?C>&kIu|}tTA%9 z!+nBPwLRNIQHtFk?8!-`Bdy3lhtC?@{UF%LbxEtB(yT<$LmDr?Tj_+cSx4ul9?$YQ z;Y)z-FYKOB4nxtme+$gN1RI70AauW7Cv4p=YxDhRh-Qt*hjK?%>43qRCk7eGmEp2b zFSF7f^iS&P{==6;ECEc1PSsBg4}0YmD0_ITJRXdZ8l&PfVo&YjJMFXcT^)Ug0yX)W z*QDhXqChqs%C;RTL0i}Y%Wks0S-`3PirZG*G?{F;9o zyLKS$Bz_0oav_IBC@u<5KN~}E)*^&vD~(*(Kyg1jj}64g@8eU|3)S4Y)ihpU&|n!o z%)e{NqAqsu?7te7lg5sdY#MV?fSz(#01&iX2Yv$e{*BCw94uS`P+;h;Jb~dyL}|Jy zzgBFC;F?i{U&oKTeQ)I16QrV~x3vg0JL5RmTV`WM+D20+TUQNP8?AEx5w_ z_H0J7l#=JtkvbUWMik_^k~ObB+;xzQ*OKrdVNTEMB?Y4d3;M%& zX|jNU!kw38?t%wV3W5P|EK6>)2aDhb8K<{6mXx*yT594cj0$HZvDEE<4E@h0Ef;0&9Tm5BpcHh$#R2#FCBdc zE6u@BmtzHZNHx?@r(81)-oSRgAOowX1tk^j>ys;w0Gti!*r@=naNEiE?#>2XkUvGC zHWlVytTR9zYjPO;196I!Tjer&qg0nZcfBoBj`k0|4zFGl%K>MYimNYS5ub^NU1NUZ z==~}&=tJ+vBj$67%1w??h;q)T+c2@~feZUAm$ca(nNq^f0mCRa_^Mhjsg^Xe>-B;LV=>JVAFYeqt zcvU@QM~zEik^K3FI>LO_uxde7)oVwsC--glalg@U${y;gb>Q2)BX&NTg_PN?sd?%v zxQS9t!W@)aFaZlyHae1=y}YA8hER#{kcD9*yA&8lqOFA5v~I}oT>Lxztuylfn|QAK ztu5cd(QRlCjnVjU$%ZIada}14$3gnQNxg6yaq7oZwsebkPYpuPHkw*jRC=d;J_ZhYd7@zui#X)OB-{8JO_LrP;Bd#JpTVvVo=~e) zSAMENrlDwIds|2z!`0zhGbF;~ltU$Jh7h@mw8uQfp`qJ7ymogPqe>w?gF6i@Ue#U` zKht@_ffkb%o~C$j(de1pnyh0h}pmQ68ux*B^LLE z500O(*Luy=2ifxE;e8#f-1y;k>-ghW4hiDb0%nTl6cre5rmd5LTq1aNbH)pEi++_v zVcl0hig?2DOLXQInrVk+6}tnPL12_v$cIOo__bEIp%h4Zf?RcI&Z)cH_PzDk5o#u z(3fg}a`xJQq)=;0wpHWKrPJHgzBKza#m^QZxE9)@0=@6KzH%uvCyjX(>o2HwU}V@Z z#gAfOyq!u6;2X%%rK?hKn7%yj{vk`kq<|g$Emsjm`XKVRq#aLV0)rTa8aM~diA(Jw zH-=G;RD&Ig-y*s%2TFA7>PH4TKr*(zCl#!*QYHl>*}Ev<`vX)-kkC!#QZK~lur ziv#dwx6jAU3k@yH0D&l^T5l~uiBZ7&#a1M;v#_D59G?PMlsVtv|0xx|9Kdd|D11sm zRC~}jTr2`H_VyIx|U8&gc|q z{e0+=uHjQd0>KC0d(46;D|s=W`}ay(C6os8e(GtuADx$Mpy*=EA zu1>bHAx{7qXt%U6bD%fIO2rBUK@`WfjhzSuBPi}lFzn_Z$ZdP!U6sD;+wJLJp|EF= zMRw-@;{Dip=u@`VJmAh@IDQYNJpxUBXgyP zDQ&b{41l1+*=RdfRf4|EqVW@b?;4?9e9J`TejD4LF>asoqa&$5_WY;cBkl&CK$fBi z()LEu^VjRahUX#u4@mzs@IM7 z@Io7c4|#6g7{YM!dF3_7&-)p&wH=B+5mU>NY7%Nt>B}O2%BkOv5|&o3IbCfn0^AZI z_Kbc`TD?+M*5@E4RY!t#hR_}%`s0{a+Yr``R~Ad6a_)J^OhQD}(Q#Myp<(Br^WCO< z8&Lme4w?Y*lRgt=W4<6sWUgP5T}^c%IKJQU1YxU6O1ls^3Mnq+r9R!j|5`IsNi%6_ z&0-n2H}VWSCcUrn{74>Oi4r&+SqdO%kFO;`>J%pL$sjD?VqfX}5s<14V{luKk-oSr z92m@!Ik?!#R+q-^W7$}-raYIbO*iLt&fJEqi1fUm2GCAL`}_Qx3R&n3;%8m6jhh4k z3=xK7Z+g{b2=a5{>}fVzV>!kgkm@UcnX}Im10u>U z$5iFZ`ZNx5A4+Jhs$GZACEDm{?FBc*!i)DbRAOuU^EtZ@=~mm!ZmBF^eVv4P9+>>M zX1+VDsio_8(-kR#p(CM65mc%mU4;a4=tZh1J@g_VAcmqe>0LmA^o}49kS-;lBB6-V zi}Wg0YPg%@DewKhbKmRM}lYh`{15uNZ0^!i)9B3S0+R4xH0TGD+J+_!l?eS!r8uzjschpq*p={vG$+8R7 zNgTUlUZ@uP(RVn)zJEZF=}Y^~_^CPQOSt!d_<*xj0HRtGNj5=DIr^kcXOKDM_;D|O zV;4ez1{aI5QD=QaMvp`Pc%b;Fyql=EYLE5wrFcG!j^2W+@R7Rv^KUML<}RCB-xrUk z>oB+6RH%ijH!}F<^*Ne@K1usgn|xtPgCwd-*RMo!B32BDF&PehXUMq+x0i?FPOKwqCxpk?-=Vk>FwxoT+GNIHMPqBGr4qMXqX>RLai0*|4Q zRMd~ImSPVkR@xhg`1U3d+_h4Mjv3DDR`Aj_x`$(3hWdsPN$t<<#^ka3k^93Jn^l2Q#jJrGCEiZT3Yqf6SaBPB>9BkxDc3Kn2+8S~0<;&Fq9%j)q>r#E=DA(m%9RW`b#5`AQH#?4;?Z zAQoU&H@tMHo}AseiPR6Xi*sxBqV=(vcf-EQme~$i6zFjskq|H-=N>t|>wV4Rs7=BYCib8R zH<%W73E{6y&FwW)))Z2D*KNo#c3c&+nDfQ!ZF7VryZ2yIzXN0t*I-R`p?PV(sjSt~G}BYl^LUvZK+UDSKl&D(=wC+ixZU5i!sGPXVua(Af~}5Q|CX;uC?zv(Uul1m+a3#lQ-fFXl4eD4Uy>imW=BthbCW`qLp!_|ADcOUgPEQvHP(Z?be| zzVDe+o)GH*5{PvKj=+Eo6y0L4>z@Li)F*twn74>6bORCfZ4t>?7e7ki;Xbp}S_4aN z$1O2!$CnKl5(Bb~dpg0bZC43p6?@z#X{R$xb=oRR9QwTTxY&scJmW+P>+|5m0jAr+ z)`D2>UGeV6U)xiY!n|{kdnEKMOM2@sW2A4WTqI`M%4A(&x50&zVwg5vnbk;foJrQr zN}Ac+N#PCvS*eGSOPtVk^>9s#IB5f#?+@-=6;vgEBNKS8mGn&$`^r5$%Ye}Jon(V` zrI|t-y%@;WykM=1D_SdP(cZJ)!ov#bIVG5UqeCpbZiM;ny$5yNQ1$ zekLa<0d$axFe`&zj+%(4;zHu6?_1C!5Df^}BVEzc1;%hts-=8%IWI#+)UPT*%~V z$|>-Uj|iLG>Wobk44){;jP5e85^E+5wJ}c1+n&`)Ao0wU4KqoPy`Lvmi~SKE>p%y( zm@KS{`IRuMt3C3pSz!doV}%0uiuqD9$@n_ttJxt{{{Sb10K@WMnve1i^c9!wjhuHi z@Jo2T_`feH_#~x3GpTTyAa`YKP5{E_Qf!nescd;{yxg!8c#;**hQ1qJMT}uMYicLa8m`^#Zk#%rrPBdmKUiTnB#u z@k{-qiCVV=u*4)kvdVbedPBLnf>5dIm3dvL-<%5fZ63Cw+JnYhJKdMR4wp9nPDX&U z)2+K;^I6jtFZQF666IMfNjc_MtK+;`q&Z)FOpvL(c%Hn$20$xl&_$DVn9B8!OnjD= zFpL}G4!9yx#6+xmQ3{l%-Y|83k9sM-R7~*;IIW?&Xs zBBNw+KAev%+1m;0ey37O0&W11tKisrwqitb8)@>iv8g!Q*RDad76XK7XI#&mku_6+ z{>7{j)Vk(O={p6UgvJtGjD@I7#nW&bg#PTmm3Z(XrR{m`5UsKD;Wy@3G5$KoPefg>pYF7mH4wY!R_xFYm zeuI1@sLE3texZm%Kc(H3H-VAqcEwf9>}O;4fVguqLr+Ghsn9m>g6FkKGnS;1xUaX9 z%_ne}w^YTJZC24f)xK&=n?ut|M@;Wo)9?KTR_)J0YEbg9Y3qH2YA3aeKhcCgzlPwF zV2<4lEJ32b_7qKZO4sXGEF>t;ltmFmCeF+_`}trMGwm9RBCJoBMI_a*1ygF^cl6%|S&qTMktpZrkpB-!EuPw}e zXWHlhjGj12GO`7yDRS@rgYE7gL$qZ>0A0s+9C|Ngn~n#@d-$!Xc~Fyd?SJT+PLV<% zmf*ofB$prl`G}``gG~-xcI`t6Xs=D^CtcYBLiF%8R3mgA^**}ND_P#$J@pS)~4{nz;cI#ApvmTk`W|nok z(c8}WE5E*h#$PGxQ-CyeyPhn9ZQ&t)aSKE8ASCgxPi(+`Cj>Tp&ClYvcVBOWk1+Yy zGe{no`-|eTzM!*K+*hFUlgDb%KR;47^>_a7Y@Gr=zgqVqjbQ!<7JiXRN&J5-TZ0fF zR+Hrl(IAA2T&~-F84Y#}c^x8XUz6ne@7)Hn^apQ0ifcP~0G&U(@2vVS`b={&$x*-4 zD1WR*+W%iTKI>FG9c1V$d6z++)S~)h?2&l3=J+Es?4c&f@;^?L|NDeu;T=DQZPQWP zrJB|4N6!n;^#8isI$O?#V-sB%B8@i@8c%d(o_8B$DtA$fQlzI}E|7A5n8a{XAaLz{ zLAP96sm-FmQ6{oM4;YP z!bI^-$T%=?336pYd2%G_sd z)L~HIxkbfUs88Hm2c6Y-o%vQ!`hLNii;YQ$ATjr%fEJQWTByFY>!o%{F+#mf!QsB;U#PM@S8U`U! zM#K;H<^VYx;L`4z{vu)Yo&?vLuZ_|Is3U^4pPI*7gB1s7fRNt*l#$#wZHGfifSRPMSK&{;6sliP za;`VeA_@}Qa&(Lh!roSyBKk~VQJ@lBH#mE5J*lFD)nbuHWfGiV#{xnW-tUieP`f|9 zbd=ERZAh+>+u=TIoT69c8&-V>*2}K-PxsS~LaxzqUGrw*PK3$eAwW4*scrpQ9&;d- z(Zm$DTkPb=RPnpLnU<}x1|G%q$u4FJ7>S&eZh;Ri#o2`;n_c;dR$RnviP~+Nbs4jF zFrS~zfpVZrW-Dvim#&E%s%Lv$+DI(@JABuEv^K`u>VDAYYGGyQc!rb$Zy&w?buF0B zFK_(14Re19I8P938Xf%{njU8KLx(SJSp{*!0RW|tFirCuzwItp2qPbmob?F>K; zI&WzA`|%Md=##Ko89a#Ad@TNIdck=$rNmmQen8mb5w-RYfr5CpxV&M8;pet`()DDi z9!(YK)zrX(h>w^jZk^{jrDn$J-^Z!Ov?jy{1-AI#)a_5!{&t5X7H+z+O{|U(L*n3W zH~d2KsGcF88?*{uBpJgBx^^05rE=MkOxtmw1|>mNlF)%#g3^+Hw`sT#g%fVxI5ztZ8x_BPNP6?+6nJrLOK4wgQ+K1vU5xSq z{e(X~dojm_+Kbe(E=Zhls{_*mwcO8^ZV2g&mtN+E(N1djj;wP}0fo*}pm`G&$x-)Z zaxS=&FZ$;?@09QJFsK(U@eoHW$H{3bxpbkYvrH|HZ<#BG#`QWTnQCF75b)rvMm!lV zqBrucNkxp-^~`4rtl9_G(@Jig51)|1B)tSO5E7*Gl8kilb|* zZo0|Lg*eCEd(^TB@D}8oA^S}~^@NUG(PK0xA{3#zdFPtQxj7d*`%%X7XAY*B@BHH( zz364SK3cf21nBFxjFp^|@d84J9IY?X+dp>tA(cudOfQ$mbcY0FCD@<)O_jZdn1jL4 z?RqVyoTFll;5sDUpA1K!7jR@2`U3mumg(Eps~)7jdBLU`oszG${c<1&@-^#&oq0kM zTtD;lkM17%KGRnUW9f_9#P=9hS7RzM#>TqC#JvpVM?4zw^ZhN4WmC# z&ES=FYV3jT7s{_=W`&s-3V$*z@*_dGRI$cS*jD~uw)cNDP5f6cjz9hrT>&NNr`ZSL zp^yGrvZ{cQ>K_o3q_}Xvq~E3yqDd@do9`253Af_zCQl?sxP9Y6P_}PZhg7R!6>g9L zRQ$-lBY)(AN)^MEcrh)73-K!PN_tI$LOYwZAIgP#TKVmf=O_hBNa1?i0NJMK#p1DW z0MoEGpF$ve$KvPf2DbRb;j3)#oI&MvpF=k=iUwOyS zee%iUc-ZhR`rs4@It2)f!PCi>a6`;p7vFpK)~_0|DtnGLcoe7&wCIDbV5va8if-DG z&zLjRy4h?djBd@c1wZZ0tIYdG8dh8Auu;0vuk0^^xZv$2u~jx~{)BX7+5KDKIc}OR z?u)jB9e>KNcRYSFtmx=6gq&-R1WaF?XrtcAC*F!BrGaG5kpnlAzem#=l!R3O>UsiQ%c1+>&2~u} zCMnkTVV0O`FiK4|CN@h+18MkM&aSk-6uEE_+UM;P>CXZAZ1W_%%idxHIqtgEEZwEd zbP+kOU98<60y43$P3tvFtl;BuUbFzV(J1w?(7>mHvcy6 zdokyy+k}$d?h5DgmXlKhqHf@1;@alxBBAsfonI1|sHWP}9~4gE z8N?2puW`j0*MtUe$8xquG<0yae?mwvc#Z{h$}Tt1+9vfl{g`ZL9)Z3Mw2oegu&|BK zP>qd^u;3jWd~<(^ez6bhN1*S`Bb6%Oc(tnJR+I=9Lg2e(_%7XgVtv3cXfDw*s(+RG zz}{Ipbns?_c5TchoDu?cGg178 zzH`W9oP+KzeDB5%pK9zrjS>6Qk^cRp_nmtychlyCxG^;pJ_w3+t3*Zojwt+t!eTD9 z5AbE_w~R+Mn521y-(0fc)5-?VNJ5F~TJmF%jKa$2Mp*;tHecmeV41MFfL{34y1j)< zQh_=VyJ2E%LInDi?v`|f1uivGTq*}s6YHp$v7Ki7q%|^)`1!0~^EHA_fLHSPvQ>U{ z$-o%A&pVmC;G;7`JoJOXhSjyX^!1gDbZwN)hon8>R*}-4Kw=fs3tW*I#!eBp(%G^p zTX!=7J2%9vP-)qL%_XNO*!hamZ!pd{qChWJ3cWPrf(=LW`cOI*<=ZtP(idIel3W!X zy3FGBC+VgR@8C}s5_uv-x_R2pGrdoLPVhFMm#n<>2c3c*ftE5I3?OI$Gzn9)K?Svo z{uKDSx@ECaHB;_xc$l!+BfnfI0@j>r$*tjF;SI zOycNft=P5bmf7poumh_2GXIAGdpSUt6j5tR^YbQALlT9OFd%~BauX0g^#pw<%dIj;fb?wic^3N1{cB*7HR@MwYylBeL z!Kgw}(7_$97{eW|#F6+0-X#;L*u~AXm`Q$~s?K>UcZ{JT`kj-xu-Yj=6i4(1or6%{ z_BaI&_A0@hJPdWfT-FDw_uEet!5hpWvwa#+o8ao3-w(q3l{Ek0G{Y+wSAkmX&35Sa z8i>pcpgje)#MzufC4IkGg}J&URWO;wJ-&+vFZ`&D zuJML{k?GdpU1wj$jvm_!-Ng(X47ddOXJ}gcMT*;U5?4cADTwIgDR0QN&2-)XD_mf8eW~ zC{-t%lXPagCtVLT`s$yG%T!pubrK@uQKnu}VBY7-7?0#}UvhP|`FT0pOrxPWmiVb; zvCE3cjgOqF@(m$S^aO;USV_lJfAD1Yk-P$Io(iQ3!V_S4qwmXS<`^7FxB!Q@HEQl1 zo)`K#c}*fe2ker`JD}vK%AaMRp`r;6UC90bhGTIVMFVzUZSQUQTc&ybEK1T3cd{*f zG>L5U9qc4-<^mGoo^8q_fFq=VD(C5=wCu~KC}BM4GOi}7aO)%EA&3@d)~GUydJAGG z{Kx@6I+}*6%hqOXyazG7vkr-KA{)-WOKnb45^R1IlOrwE20&?$Q@hsGkMGnNBL2Afx`-DC;apWZHM zeNfovno(Vpl(MFi`{r^1VS@g*wR;vJbhp~V6D>LbVXltfio-RlruYEDSIU3Ly=R7{ z|CisHZCNZ+oaHv9ni8NV@x4FAF(lWtgAqcYFcPimJPgaVb%Z!V2wv?eF2RP$a&6Zj zX(WKnNsN!SUyP4_R4d|*CaE)6aQ&=s_0OsZu(}ZoXsiY_kJxu|kN9?qN6-Fg(`Wam x3x(twx8$~Dl4p(i=sY7VF^|wB=HyVP2?u3(TO$+gJ_XYMM1%kTvCir6{{odl%-{e3 literal 0 HcmV?d00001 diff --git a/Sources/SpeziOmron/Resources/Media.xcassets/Omron-BP5250.imageset/Omron-BP5250.jpg.license b/Sources/SpeziOmron/Resources/Media.xcassets/Omron-BP5250.imageset/Omron-BP5250.jpg.license new file mode 100644 index 0000000..6becaa6 --- /dev/null +++ b/Sources/SpeziOmron/Resources/Media.xcassets/Omron-BP5250.imageset/Omron-BP5250.jpg.license @@ -0,0 +1,6 @@ + +This source file is part of the Stanford SpeziDevices open source project + +SPDX-FileCopyrightText: 2024 Stanford University + +SPDX-License-Identifier: MIT diff --git a/Sources/SpeziOmron/Resources/Media.xcassets/Omron-SC-150.imageset/Contents.json b/Sources/SpeziOmron/Resources/Media.xcassets/Omron-SC-150.imageset/Contents.json new file mode 100644 index 0000000..552caff --- /dev/null +++ b/Sources/SpeziOmron/Resources/Media.xcassets/Omron-SC-150.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Omron-SC-150.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/SpeziOmron/Resources/Media.xcassets/Omron-SC-150.imageset/Contents.json.license b/Sources/SpeziOmron/Resources/Media.xcassets/Omron-SC-150.imageset/Contents.json.license new file mode 100644 index 0000000..6becaa6 --- /dev/null +++ b/Sources/SpeziOmron/Resources/Media.xcassets/Omron-SC-150.imageset/Contents.json.license @@ -0,0 +1,6 @@ + +This source file is part of the Stanford SpeziDevices open source project + +SPDX-FileCopyrightText: 2024 Stanford University + +SPDX-License-Identifier: MIT diff --git a/Sources/SpeziOmron/Resources/Media.xcassets/Omron-SC-150.imageset/Omron-SC-150.jpg b/Sources/SpeziOmron/Resources/Media.xcassets/Omron-SC-150.imageset/Omron-SC-150.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1188aa2574fa03660a2c4bc999624445766e7c39 GIT binary patch literal 23520 zcmeFZcUV*F(>A)22ys&_6bUUG8;XJmNRbj$#DWx&BGLqjO{b2Z zJdR*6ScDJ$khLBx>EzL)CYon8j-OOhfgK2fJ)nH-hzfQSf?Tt8c08+kgs6YsfQYX{ zHX_W(Rzw0he9_#=URhI94S~hh&h|3;`JcWJu(+(O%pTg-{}12LexP4&T8pM%3|G}Z+y1$c zxr?Lg`Wgl+fgu08^?$Ary$wM$o+HSH<$tzG1|tY{AA+zy{Ab%PaRk{Uh9HH}Yu}Nh z2;&9@MurWHj0}u;JmW^DO-xLG{K3S*#?G>dn`7HHZVoOk0{>1y0`GP{E-vCe;`Uv; z_w3!nBe?&-e&GWYUTP`!8SBb4eaKtL@<$! z!C~MraGMb&Wc9^w+5gYy|Ixtz-!)LhQX+vnfK;&*S8=1b{Xbo*Ho*lqz~yR4H6*yC zVKGD1|MZ#|3G5}F$PHCn52zKRg0hr2;ST7B+U(%?pNhO`izU(G5>M3@3!z;WWPJGjNgWdZQk`h}US@$!>pVF^g9mkFDnR`spdY0_fAI{pjs5*0#lw_g<(%E!BJ8~Hryu*xpDHWOtS&J>dEWgC zPmUDfRAurYOB*40r)95@Eb14 z;rj&}yk(36YxftJ;~UBP`Pnfo+Z3aDxnoSd6}b};r)c(s_cL8=4t_IVeZsImCwv~W zH;@l~RXTlpn8fQP_B_6UZ1k49K?~NdDm{a>LlM3wRbw%(UwswhWOxmg;xwYSUb#IJ z=Bdg2=EK6wLUF6s^zgJ_R{Ws$AVUlKjx&yn5wJJ1@p1Mj{&M&0v|qGqF;Cf5%zyh3 zb!cUTm#HN`)e^BpEWmVN{SW$a*g<~k|I(H)#40eh@`Ef@l$yq-mcXZz2PQ_Eqq3`9 zEjKS|i?ZL!&(8nwu|hOCz}z)eHMd$nA+U&$Q7zDAcXqrqWm+}YSv0LPsClR?cT;oz zr2mZdoM_m3Luf~%hv4EG@=JS+p*cTp#dm4XoG9h&=x}dcVw>OVyhQ__u{e9)!x_(? z`VKda88Oz34ht{kd6$bmZ)v!=(oVE7TG=Jo-jK(Ac6_jVrCFE|?yJN;*2Q|EDCXvz zCUc6*bf(V;(wz6RWGM-!JD!anqjKwxF0$v5rEIg;kj-i9CLId9?Z8sfG}%*!@8)H$|>d}O_l5otH)+mrXZ;g{mnqEhrkaYD-0 z=Q4cG*5zd%e(SpW*HSfHT6NNOMKDrvS~WE=b~8BeFE{-aFK_#bzQ4P9#70f6$A0J za05NTqm;!JI8tjyMk%AiZ?gmKGmm>gm42je*a{o_=v>qxU*SbQ!Ip7Jv8j-!?^k@L zkE|iEMNB@5ytfx~Bhdg;d-DG7rw%dn|J2w8vAY9YfGnmm$@aXN~ zb2kP6@3|>u=LRhPOsE)Jw zD}FHJz@F&A-BKYXwFP}d0N>#iIm zR5Nq_J_xS$`!L!;2hn%*2K0+mGe_|Fp)XO>T=eZFu`fZz>DUaex}n*3>w)d9btYTl z-YorT-_!AqEA*cy`h(FX)a(+Ui%zJFO%k6I@naJkbL;E3E1cKQNrWyey;!!sXt-HO zij&DgXn@hwf{}i83&v}-`-xt?WoiMfES?*%_X-w$LmwrO_Q1ade*I?zp%|4aGX)xO z{AObAje$D!L^#b%)GQJ`8(l>uKZdT*o+0z|jOWy$1SFSya?y2Nuuo|@D$C9`UTEqv%=qL92@{GC z5JVGUz&uGHZ9Sk%MkE*zmf}qun;0OL8?Fa(P2eBU<~|_7fJSzP>U&Nc2*CHUaL}Y->}pxc6K-7+ZqKk?w>VCT6lEzB1s}y}pAb`A4XfegcU$2ZA9X+=q@al0l7z#h^+?^43GD{H0P{ zzvgr1BS$VaN>!;EYt}70X|xTW5p&Vq+1?=~S+y$4#cz;R&#$#;gK^P?StR4!kP{Zo9Ow)j|Pa%%pFQMS5UhUlw3K|;Tb6H zK2rLy{``z%Jd@HIax6CdyaF!^`+FH3GpiMGoo0?19bGQPw;2}R3ixOFF|K>-^_=T< zbgY^0Bt+EMq-+&V*q&}F)o`t^=3af4j=0|5O!JZ4x!updoWAWF|8j4D{esR-DUEaO zmq!%{pMUt^i=q=GZ#E6`d@SA3FaF`V%U0Wj`-u}qlIr*S8C;`Q#=Z+!f2n*WQKO+{ zF)m=&oyN1ZzT$^c(Fk-cPiO>YvxRNp83Z% zwy>OIlgq}X1QJJjPsq8y!KE2_BO^U}AGtc-Sy$dPd3msF)Yz?IKyHR|1_?%liNZo; zBpFAn!;uams2w7h$46g0f6-F!=Dhr4&kYxm{)sGgw)4%$bmWbqFMq!;sZ&(nI6%;^ z*Ih$YG$Obpjf(2E2R8dMn0j+6nr>I=^5$2(s5fDZH-=Ro@Aj0_W{!3bT)z?yvqc{c z-sx|=q3!tcUZLob=3d$UIK9)dM#op>-MrtW_q(4PW*anM>J^Zhbd|WRqunTxa;}kQ z_0G*Q|L8SD=k5@HP3pq=D->O}UHo?+6h|lJMoB$QmK#dno?xDSct_4phl-y0XvMCU z=9V+!6(Yh>iTf7Pe17@z`i8G6y5-&ylN@^yKVyOaWWH6PBH44&LMU}K<*kKv&Ef6B zYS*9F*zp&xA^AUbtU6BUYg-@Qn=bYuAUQF8Pl8QlkY)JM^NV+VIeyK@o^%r_?Z4$$ z(&#F8szuebx5a4dHDgwt5goOD-PDhBuNB1kFGNO+e}7(EI{c|peITdpR^5#C_3G_c zo;j5!;+PqOm|omTH?t5)ksZEM>1Np9Wa#pvOi3hh*!%rc=Mbj?Lb^q&p&DV&DbNW6R?UWW6Ck#@9dJ!uH7nF zOcKW>0u9v>4ifr_QPF4gGv$58SKmRQ0kiNTn?{YR#IM0-<`8^B-QOmPA$}tDWZ28) ze>{6JDAgl)w zEE&NfxEo6el>$l!4PwLuE*-7w=KfRdX5i*H{gkkcFR|%IlnAR^3R(I(mr9jho{lRY zpIj;Ykdx}$?@H<2R@8bf$054NX2~k3|Lv8i4yBOyOIo8mzM^hY!(-nrt|41;*N`b0 zjc-G>vp)XII~QfeslJ_~tJMYbgOjfBR=ZxVArD)XR(g8B8_LA({xMzjmzyeazi*F> zPxZ+-?w--`c{$Zp!&UE(!3064B_Gmw8sP`P(8E#$oX~D;OKh$pT zq=i1Dkwjz?-hSi;oGpwzB_riH5`s`jOl-V0kJap6?-((bFZbl5GVLu+FwJDeO|6c5 z#WX}YDO-IH-h4%;$!b|m&CqL~V)U6)FYeSt`RJZkciv=SWs-rF6Wyh=`az2!0AxW3&x$RlDJ7XDl$$#(s z*urt#^QF#Kp4v;6Eee~yI5~s_j1;g9@;Brj{w$!{YGt%rXFy?qao2vA7Hdk@`(@A9 z6?{QUw$Ttb35%(Nw&Q>2a^>sQE?3fZ=x(6^|r zKb3#fQSp6XjI@)aPGIc&?@UrJU%u*0(YWMN)z5637g6axpRw%Hm*GDgk{dtwphjwb zbXsh-GN1G9qq-cOo>t>w`F$Qw&G()5v~I4o*7zLqDXw(b|L2Hhc4Ok`-IPV?GtFto zcN)Du_+z?5VU(w)qVsd($n7XO*(OaZL{s;i6~ajVZFgjS22QFzekXC@?u>x$qqvVZ z;ye192IF7UnqhZ^J`&Y)j(P8N+LHr}DFSbg)YcIO2~I(z=Gsn+y2z2&rR< zKXjJ1+-;^?UK;QoVJevlWom?q4FK{*IZ8r^xb3&5dm=h7=Qb61+*4p1a}*X%FwLF8 zL`W3~M!LE7D?aS|^;i9uMMBTDD-E1I7khf^7fx9x2su9WQTLdsnUf!jvA^GGmpEo} zJs7Bg6?00e!KCQkw@n#aUeqKfL?uKIcn%8RdRZ7*`9)XO!>BwBSJ7%;#PY4Gx7#gT zhV$y=Q^l*zyu;nH6XmbYUUu*rV0TbdZShx;AY9lJ;a@SmhBV6yt)5zL$>q9V`gZKi z6>~e?($XurS%F@P0TvMfuc8WFOaiw?&kU4GcRa4~jVU_1x|wilj1(OkWA}Ab)LL9KFH^dI zWp|%}FLyV)>(Wo|X9KC%nX5&(T(l$_Zyvj>rp|w1D55eY{_W1d@!cn-?sg{CIuQM>qR|AMGr>k@Za{aN$bOsW+1^-E~YZxgOVSiZU0mGYS@8 zV2ZLRyiwVsyQ&_iT`@Fx>lT%;fqW%ND@->GO%Z_O+Y6KTVQ<_hM%tMAp7Fixpj0%p z_jz9ySVMMsacZPjSkr0Sy~CkSR!r0H zl9di-oZBgIbI^-Uc#x+ENf<#;U`}*_h7QvC7f4L456apS7ij}oE&CB_|j#BE`wwkBMw)=BS-!E^a_t=m%Bva?- z>A2MxrHR#Nv5Q8=&N0PP&SiajtumgxoPSvKAhDTUBtGVevN9E;qKRCx24!JL42TL@ zQ=fsX&q&qVN$dw#Ru5l}_BLH)J3YTED`p=^qDD(mJ?R<(FQYTbb8IYj`>Kr>l{j&@+}*fe^{k`^X7C)WuojzNV?jqhThu`x`f9To+rV1%2tsP(Tfg~ zJj=nW!qfRdUv+%jkGGs`J=XKJQRP#+OPPAb3;*C4qX%hvs#h-`h?u()^F_{ka3bhs zSwUaIolkGyN1wSH+MFVq=DBOT>_|}W_4AVT6xIAAj-D8-*wcnGfz=W42boeocgz|hw{YZ1@TCb;F~1%BeP*?1&ebqx%<`$Fy77gfk@2y~(c*-WoDb1zqZL9z zJ_#>p6<)^W4=?D6m~-FvZjpTZDDQnppXVvtv`mNHdeP12u4r#w$W?bb=Y667@yO2t z+sP(x_EjZolU1dsYe=e@Smbc zSz$|4zEdT?LX533akGBA)ewT`PG2ucaT-ZoQD(5#J%!SO7c z)wpt>b;#${`ytf6>>E}~XPX4_$iJ-(*}~GC192nNd2+F2ply~?kvEEe=ybTG&j{ki zazu*4ZoLN?P2rU9w48PrLxtc3H^Pw6Af<^xv`8D^T4sYi^Fk$982>q~Xsd^)E9jRmE z{{BM|R$gPfi5`C%Y3mq9)Ss`lU^9MUUMe*hKAZ1zWJ(pcMdW>7O+Z4y*3HnU7neExFh>kx@9)g2JRbhA`v-Os2@7q!I4J1Yj(YngPV@m{{1 z#-np+Ub0?F^-`5{Mfrv73u-eLbQ|>Qzj>+C7{bH)T2>}-^F0u;q5tX->>Z}3NG3Cq z$x7OXqBSu8vWAs0(xNCGR+LBo;iU*mO_zh-7q(a>efjS(4eUZAZj{ftFvEh65NcM|&AHC=MvaAdI5h4||;mc`OogcCv5waPFr|Uc2|q z2;9#ZyGhMTi%E-BeAawi=b|sSI;Xh0dpM^{bK^O!FlRMYzUQ&uAGv>B`c`S+SSy-3 z_jZ6JoXm_+q25v~g;4AM+PM7vOpmtRb1`$mz^H1e;NAo2FGVxcMn0zCu_;&Mx#B)W zMAYn(sBj#;EjOyLhD`p74$Xg8*!MOlui@S~`As>kdXW&5zZAa zd$LHwz>TFK7#t?*LRo`>wq$FU_TgT(h&W04vAZN24AlyAT+2X{Gn7WTAcs0;r3pxF zv+gy$*4{H^TPBvg#dh`X=h?h4>)A?a()`*pxOQe6Pke4k&}s#NTH+)dJy;*%&~amdahZvQ8$AnTUuiE zb?>re#2RvP4Y67HIB9&j{!wH6h)5 zEZ{%j9$mY5Vn(~#OOLj}yjot}a$$171Wu8RixV)f5%cGIJ>6U7@o`fB4ShqPVXKdq zM~Fig1nKUTq}lMlMn@z!T)rs2veWo2btdc|rG{F#;R4b#X>epXh7Wc-Ojm2R zQvrYM33X57qMar$gclFjEE<*>ghDk9!B96aY|x~es+KaM$*=(dI7BWH(ApbnzX+@` zphZkgP3UkH%#-s7feXrVv<~OOsMuiWviT=S*Z+$Dg36Y~{V{p!q33r>{XhMURgUue zWZZ>H)^B8s5B31bj5O3Jr3EovKc*CY8#z7puAFpNygyeMF5dEtfS~u@Oobb9z`aok z;988zPpCYMH_>}jK{A?P7%Gv2gMer<|?s?`uz;FzV zw`RP6l6Fdk-l#46NqBqJ`RlCHEZ44OQME*boU zhw&D`K8FffpEHSp?AXAjKd#(keRqldXIYxqA1%#8O8z?iBD2~59z$7YiNnAWEA#OF zG_|QuxMw@n6Ms4$@@9f|eu^Dq&O{lpe5TVWEJH2rIJTpa`k_ zX)r5NE{R2+-atJFj?M%oSw}^%uUMf0#FA+P&Xlwdr^$9oxtf1SUO}Ss;!4i!^KQAt zYW(Ehg6BSOyJpUe5j6ePOyhR3>+Cf2$ z>z$_~MjKd>I`o#<;s9YLqA*mci3o@R!U}I!Bhy7Yh)80wN!WGKus4cnr<7AAMTm6> zD+X};P7@QLwd@|e^k@9-gzL*<*#cRWe~pehbAPm~(EEAX-V>ht_i{p9Q^r9W_dFy| zxxXFvu>c?F^o5;k+L@AD-bwFM!80`3&a#4JA|yBuZt@gv572SIH=876tA9xk126SzVC zEf|o7b8LKPiigd|LOt{V`|L11MknO2)MUCY@ODQPJoI^1fT~UQ?i+Y;5CsK49El2@ z$;yO4*FrG~>Yc){ixrhd7>iIbAUw>Kg9nj>&8d_l#k8lPLVTk(;U0xLsd}>G&$!L| z+A3Iw|IyN7C3dGaETi?&f9uX3Tl{H3XG%rxDBj2)TXG-135hyxXNA^IGfpPa>z$N4$9Nn2 zP57?-SJ}2G2jrD}2+ZN<^DdrBUo7b{o~3-q>m*KQ$d!EWGr5&KrL*`zls);kbO`1N z=@J1A=&0gA3@c3x1|7CSF!un7e~X79Z34m8#X}%aFhW#7rGrQX65Wt%LqOPKE%=K*mrRK%@6eL7`S? z37YGzlQ%x^$Napg7dvAaWv?G%+9@ zfq?;P&wN?7D6HLlJ~+S3H+OB%Y=Yr#6C=8G-~Yo;cK~zxMC$qkj%qKTQkdx;PMrWL zdyQQnTxGDL;cAzmpw4dq0feYS-45IjTTGNB?!7^VWEbjJ02JktlI4)~B9bV?P!S)(T%4fZi(W~ih|BQ6jVfx>|g?6naVTksA$}R;9!Lp`;4a~ zD2e&?({*@}D$lDC_VHJLjshoKfxc`3HsX(G+^sL?zU0^64*mOX6i5;_k@l4KzZBeY2`C66 zItjc(7?_bH0^Yy`Qlc)x44P^rg7&}w1B8ii~$45V?7 z`zR$1@1zf{o{clO{NCjk0bdg4U7%r`@b6elfI!z^x!FzB%T%)jF;xti1)YTtuP49OAD>C~4$JeKJ$eZ5aW zp%chJz(gA)XOXB6x6^zWl4&v`3|5FR;f?^8q#G9z8YssDwxR}>2_D=C+6CPLK_G)A z0t=;Liy@OeDwHL;0jJ4~3{d%shyq*>=bMYx0p<~9!bQ-w$mRm25PE{X{O;f!et8IM zkT`am*oM-jL#avt1YPwa3%dEiM1mI#R#sp|JcvFzfdlnV)CN%y-b?~zaUcw!ugeC| z0Q&?DLt}$J$0k&_5Oh^GSYb{uK!pf0Gb&#bE*$OMBHrw&u*F8vtr@TDd{{p$=>Wmw zpIsLZrm6Fw0LZ5fY1kaD5)kmF_ZN1?ad-LaH3blNHEs=lZuwCGte_B`=0l zST!DGdW%`&lON3V>*c2q=Ou)nQ+Od*DbYU8X{pZorcFl9^fV0MSFhH6+W? zqtGD|EHu!ns!;L1gUNL$83i^PMRr^!yL+Bedi34HfF|PCq5nG(+t^ruDEE%x!-4cM zExJ)k@`1jLqx41Zw;GcR$NNEzG@Kw`qCqqdiZ%p97wupxsKn6Fn?q9tR0e4p4T(^+ z0Tly3wG~IfExPR=^`brFKJ>XyzE-RbH%rgC`$j))u!~uBwIApt@yVVF_-|q zfNWfbKUktY9cl8`k;Z!+X+}|`0hER!MpG8#F~HkdnQ%f`EWuckFsjfj1P#J84iAb% zgoUXrfla6uv1Hs)*Z}H-sElMXfsP?g?=6N<4DlEj$8IlMctU>vpOfG1)&mLuA6->q z=|wh1#Bn%-zF={8sb*=#qd@C51eE)YTqy6~4OIznep$eWxP4(87xI!;1ZO!r;jJ?V z^NCVmJ`n&OOsJqjP6%C&_BEOA&qQYEap+)>5cOVgU09cd$`(inQD!6b^bHt&Me{Zk z3B6>8$WDXv0rLDiK|~RawCEi5!m=ao(#)e7t>Ma@>jJ|0p@4*=ZRmuc=OM(Y^h9?W z+URLF5~oBLOWJ>TwAQRN4Q=)VtdOaY;tHdrAhVnb#OB~W5Tv}31nCAOv>-JzNL`LQ zgCl~xumSd>jg6Tb!9+J;izS82)1dMPw4n+2m<8p8Y!BvVGak}eP3&J2w%jcyZ$$rb z7?_(o#<+t|r(EtU6sU%KYbHGOE<6+1iNbcw6#K66RQ`@*^R`|wK2^_uI}J)$go=|9 zBoz`|bnqVt7!4ecpgw`h1mOiTOfrpd(R-T;a*{!>keEXzF9rP$lm=pQIiB)tRpCm% zHH{WpKauCXIotB{H1(gJJ|Urra#o_;e2nQZjGb5JwG$`Cvrov2Kv)r?%jZawuWRw~ z>ME3f9^E3NIS`Olol9t`$BGA%6Apx4q#z-hP=8WEfZbfuN8>X}u^E-A2qaP%4qBE3 z9F_rv9A#6$MTmOE4ez+$cIeWhUMqV^go%-HQ#RfMEQ-EBxxh@hL|<Mp0(`{=ibQGbi*O&sWBHLx?y0sR7px%b;)P8HFedJ3GHat68)&aPvjn;sen!9a1OcO;4hV6ovJ0hj&}a8SrgR*{yeugB)^M^OJnh$5te0S!T9)EiKT zVP%5+9Oc^dEdd)zs9&QJ(c(0U-(=uA`_Sh>AtwduJpZz<7|)!nq>E|}L>(p1_`oEpopGa3tZ^Wf~RC8ol0Hp#ZNL8510ca39Iv#{d0uvF$szaDIQlF9!?!$sx3!w#@@;X+iyddxZ zXoUfaAH`5mz!vYLd$gbuO-k$8_b3TlWCnAz`A;5kLR@zOphpC641qeYWB{s?v_L)R zMJb0=#qFLVZ`^kOk-unCqEQeWnj{Rl2>=}mARq<3QUtjRbofT-Ug&ug7*S24baK7F zfjol1a0u)IU8a%t0ba>wmQ+tEPcL5h#~@qrB@wJ{AOYFfEUS zkaQz~_K-CRP__gQf{|8Q4$Fn1*H1az$fY!^#9`qb8tgpnz!)sOKbpfkt)+P)wkr-V0&^9MS5mr1d1<3PS~YM#~Tgkl$w#buQUJExP&^n_Vb-TtgB6 z+o>!XEqhZZz)YuQ0s(Lv+JYehr%gijIMM@=Mgc)9BHbn~ln*(h_y^$IYlx7%l*zq} zU%QTuWaQ7G@RJD;19fkP4N%&Jnm7jV50D1UV<6W9;Q@F6gEnX~5Z8GHI)VsKg%S$_ zqY|L70~GwbyXhGrgd$8j6|8}(L$ zcAbX5ty5%)|Jm}h(%y3~JTz_DK#{V=^OHu+U{=pIeqJ(-pk?Hh^cd1)d{Q35($aTj zb>tFcSw&9#`cOdvnz(_;fgx!@EMY|HHp(06baB1I!CE0Cr4PSL8GSwa2M4P%mMVMjYH=qs)0Q?T1 zHFK|)0d0*4ps@SlBbNi&Bxphno)-Qxr}-Z7-duE+?aWq`oJwR8l2Hu99Sg* z1LGbXe~;myr84Nt&5&22!{5prnvnm)0cd6P7HYVt--GWkpd%zu=={5{3BOaWH+|zb zhNdFWgmpOSZy_Ii`A3n##Zz|DC**k{XAK9GRQakPaCF{sYk3RJAeR=L_^sgPBnH$g z&TfDK9pF7sr2$h!-I>II<_thFAT_`kYy#K>mT~bO+S$5f^nhasT3aL%U=)Y?Tf&$| z!M_E33j!uhMZ~q9YZHhf|CaC%1ygiBKY6&Pj3md+>(^(DCavwJlcH{v^89w5M2kR!Kz*QGkj36wM@bM8EEB{8ZJ|{X630eP&te8(I-p4~ zUexm;u>mYc{Tj7MP-NL>e9iFPz!Zwa=wfk136R2OP`}AV7he_|^1> zLOtdVgPbmu!Ew}WW|Xg|OIa-mCk& zfYKK8{~)HJai+%{?=t}f7<^)e4tGO4yi;F6^r*Y})^fek=fc2tSI`o26c4RW(HZGP z@Li}lVeXMY!=Nir<|RlML0wQ(p&J1w31%Mw*@Rt;WOOhept6gL0WG1>(fqpFjA7S$ zirqS8S8~on4XyFaLPxDHCjotWVay_3OkaK}xB*Ic&Y(%C!b28TmXD7wHvvTwR-6+I z*EQJy!(71s!A;Su8BJkWY1tx~t|e5FpktH|fzQJ*8XQ5J_YV36kKcg31GyKZ46Gzj zqZMA>jk-`RMXNKWLZSQG=k%DZs!#_Vv0>LC{g!b9aB>H|9DYzEAo&UT>(veHu@G_m zU3g1++bIow(znFDZu;b%4M{4ZW!Y^^2+GJ1axwy)3H+4_WoVFu(S<~WcbTHJC)W?< zxJ)8wsf;;`;BVbz*o4lZ zX7D>l%e){5LW`ng@No{r7NeZR31y#ElA?K7%x0#L?7pze)cs~|tC9-?mcP>F^q_Wx4e1uISkwSE}TS%(h|tDJEACP z%0bcrVuQ+v)=*$^alZ8+vp%orFT1pBE_r6+FNGrG>fgQCfBKT+d1HgluA2S9Ul?jQ z>{1wWlDCgmI#~E$?soFHVx3Ymmbrg6zde12R$O!J)alcgMky0D0jtiLt1ngGdD%_M z$3JR0q2Jzf^{3c1@k!~Ci{{!o##Uj?fjRxTxy5fjv>KUrn_e7zRJh+k&GK7xR3~fs z$SQBWf~Ou#U(}E9D3p?qe|OXWxG1MggjBu%ZN2M8oGRyPn-2uL1XiY8;q@qcms9e# zWMTfzC-KF(`*!|UV_ckVAJ@xia9@shj*0mqR`vdqe@b=X>3zG-=4W$nJCq|4=n{O} z@8;I97XNeFYCe%WzS-stukzQeAvLW^i=PB$CtvjQFY~F%U)}3qFE0G)eagEGwxs09 z#e~g$`ra$3^qph7PLx_<~YIuwE0JLPbkM%PKCca=UuzAfce@lU-;gXUM3Y) zGrHg;a<+v!=&}_Obwy$05B|+$0CS3UtZ&zDYyG`o=35J9!C$qFa8qLBa-asAKR8^IU^m^u9hA@ zJ*G0J_@STwrkq^O-SKN=D*u7BLal9@ z}2akfD=M!@&zyJGQ#r5ZJoCP?=v>9gF)>$>Hhyux&-?$pee5`Adz$_| zwV9DY9oe#>=+7T3hs)YE?s@V)kmD*HJ=Serdgzd3cgvfPEkSQ2hJy0dq&+P&k{lE7 zc^*FID5&a4Q86>@GVb@&-8~sOC{?;>=`FD(;=5<=$?Z-3U+$GQDwH`ZtPJS1ne}#G z+gn+fEERuYra!8;sq*glj{L`tPN$|!R631Jjm}h8zmti0sO6ACXp9}Id=k?+YMj{k zdF+at96mhC{OEV1X16Ey6O)sBlOvbU2MO1R1my|2HA?WdR*p!7d{6uQj?<-GNxftE zjI>MO&G?@qcM{pz`vx1;`8O+Y^z%y`u6ot7aOHsMw;s-n>RN^eE;=U_4J9UK;E#JQah2tlq(+AZhn4&{^AQKYUONVW$hLp zxrpV=$TGJrJ;*b^Z*cg8$#+8?myk2shV>zmr4HxHj$N+LeQdkf^uW-?1HY{;Vl3YG zbN-cpNoN-)Be^%fKFKvTKN?DwR=?0%c~AUj$k#uspIf7&Q~)fH-w+@q0u8!_5+DrV z4N4veeui1Nms6Oj<>%waz&5?`cC)IERIsEtWcGGNT*F3gY%gdl>2 zngsusVSJ!F`{~dQ&UNE_w1nE`OqOIhITN*iTICCpW?_~g-5GgOKfTa-(TQhu-ab~c-3 z!|CSqn3-nYoHxBi4srWE)5Ap<^yITkU-!Wb?ENn%lTx_EcjXN?N1uv+DyMj6b97Zz zZ06&{g0Xo17D;b{P|JOSTtH9WwQI@mzbrIzHbyT@tGVa8%r6FiLGf4Kh<((Gdx60g!99Pn1cnS8lUgq_tbtkAvgFP zPgM=y2|sqE%0h9(Fl|=g+Td=7g~M%TQ({TcB9-|C zEM97RI&Z6cXsbc*!*Xu$(2H)xk)kj(99(JkkX|86<7 zlHezOxvx;PM5n_@KlbY~IX>YYu2mqgvkPT$+Jb0662mu`_(05rIcsQPALuy*M##>H zw5bOW53NHXT$1qN&li-Q%+%~Kvp&On#U+rR_tbDj(W!%xMxt%4yN_A@WWT8^5b!cP zDX^9<~zmd(L=#T?R?zESu&H7$AdoB z&%17mQv8y_C;R1ThyC=2gzIKXYEgkj*)}aWrWPDCwuLE(k43oo!-uB_6Yl)DbIiql zMl|3<;ow(;Lk)rL)){u{d@7!eg-$&-qN8yq^v@5AxED7Zis05i+!JIYloXQkdS<_W zwzkTY+mWHvYj1<=bzQ%<>xW$&Jm;+~+rPL~`=^`n&Gxi+V+{J-IotHMajL0x_cW%d zRoO;AQ+bnJoRuAqZN#(Vuy^O!*0(dATN4RN_flF;Y0c=ZAz`$6 zfKX}yO7xqApk$p4NJZ~(lpVo8nu()4-A|dsNQR;d01M=lFzHScov29G;zAfpk~k|# zhc|cN26B(`G;6d1*Nn5v>Fc}vG&BB{?>idrQJ{yw{S{zo(QM^Hmw=#R$V2q53HY02RyZ|^b3`anP5Wb0xph$;6 zf(|%`A{<&31mX-^(J=`$MTa~HrN&R8I4H@CvSKr+9drsGUQ}%Ito-nEPLA;7j!~p8L>MZQ(^+M80w%{B)3C=^@klk5V(7fv35OiMwXMunm1&-V)Gu= zd|#zfs9`n|n@#{lN540pC3=-|WT5;g31FA!m8fYZe1rZ*7SAhmMFu#*X18uvI^Io>LObQ-lF#iY+gAi{&#Jy=Rq@oMu0IljJt0o) zO_sxy_#-2|@C>6-PE`U5?L32Z>;j`K0xDnFR>`iidu|KfOpaHKnn+35{?>E1So0ot zg_I>vn+*AGNL5Sjer0?s{vEk${S#-dVZ}?h4!?!*>&6fIs%U9!-62W8ZIb1prJ+W_ z#N({QeDudM{9ZoehTqNDtL%#Yk_PwX|JGbXB5;>A7RZOr-T3ihy*0`3P5UG3LwPu9 z5;h4Q5Uq$MUVYI!0Ncx|7-1!U?{(yx_E+ZDiJ9D%8k-I|-}o{7181pW$}4vrR8*w-JClYMUkj@C)RAt-L5?*#c263%6$01>Oh=Bl7xI(Bm6MYoa9olvLcst#`6zL zQ3|C`t2qsbZ7cC9fftnB%O!q~XH_%<76zSKq&4UKlKr39*%$bU8k zT~x=SZY=L@R1~K*{U#FC=F4Czd`P<#u3J5FGGw(~Fwa`Dh)J?GsJ6X+DdB0{-%U!l zV&r7xQl64?o!9P0V=28Sa53)gLs65-@0ieyyVf_RQ_-fh3G7dxbc;q!=B?27??5-k z!%6IaMO`TqnXy`FRGtVw<36xEUSllEZ{or4Ci`EWSIWfYo0fv{?-itba|&&|OK-+% z4QKFwn${kZ-2HyP)cVGGm4szC?%C_XB(!y%7h3rD`=xuocBZshDQ>nd5-43U?mext3kzm<$ z`px;H3Cq5MH6XGa9JI1sdE)k&`BkN7c^muL9#1a&o~ZNsv(ZN>WSGF+{m+KDnr*B1 zsPjh?m7+xzwjWvkvoWrQTj1|4JJ6HRE%%2^{*rTAc;DL~xw(R0cDdUDy$)mas(sE3 zJP3UYW7J(u>)Ju)kQ4RKgO%WtwhG&iKa%_vz3Ko*5gZ#6od;DHkMuUc_~qk9Z5S1W z&1@Z95egCHq}|MDSHZ_y$C5hNlg?Dt1u7u_k$N z^|{ZqrrU(W%iTUj9W6_x)Uk_t=f=M*wE}Ui`>CbyQYj%X@mKn71zE2O%h4L;v=~m+ z-isHj91<}0nFo?GF0kX-0`je&3MqfRL(lwCN5d-b!uXTTlB)Q&g#mgWH=UlGcwI{s za47|h^T^)DyNDSuya4|8IOJLpm7nE;F*2%mrI=5`x|rv&I#ShYzu~bWQuS?O!+>wp zABPMvvf%?aoW!bek@oOnfQ`&mMUop|bun`pVm#sy**L`5vY0z^0=sV>J1w6_9`M4L z)sp#Y$zl>+c!@6Ns(DNm>`_FVXs-g=zJ2E>aFG)X16~Lm?h%d%{XYEfGvFjl)fytn zjox?tNMr!e0*;n_y|OUCh-o7qqM3mqCy|DO2 z29`3-*N;BMsGB0Z07k?5~mAKT;L6*cki z7sEgSGEji=$Uuxihx8XbWfb%~ZHHM4swBJ=XwMx3%3fCz=;?3maYbW15)s)%L_e!* zg8>*SZQa(3WPDmn zmf~{w%Gzk%n8TaUNR~}{)5TWGpQWmKc$+&PruH}sE-Fe0lk<%CU%p7w5oX60(`^xq z7SSaijJL_nM|g~rU0xn=$Gz|;v%PWO{FQAlDC+LDd8|4fQMW5*qrJEvPhj_h*Z3e# zK1e|)d9z(HY{ESTi6_6}rO8HQzD}~3G}+jWpIp#^3@pP7<5A#>o}q$%xc{IZ2^#Co zgllr6YA-Y0cL8hyuGn#yc0v)NpWTI*zFTZ;hd8-23{23EMz498emVCa=xJiowCix~ z@699x!lmI7`H0b9*dIi$14QnN$kpwc+~)k`CR4bB>C6~oFEd$s8>ugmjmR6Iob!xu zhw;_l_VT-dL2=5$l+Q&gLPq2Rp27V*Lo@}E+&)*+s8Sq#<}?c=VlgQFo0HV$2yoYmMZWDm^xg2 z05kwt4gs6MdX-thh*<+j zG{o!xI+AgM9!M#wbC7uoD#sEg1Dz(xl)^bd3g|(QM`vvSIU@^XI?S6uAA?Ow0XuOI zV=2f=aj;z)tfv@G@H9XXNDP4(P9R%`oefm)(`eLS#2g~5V8n8Qrx9*IOv4Frpub`R rKwi>dJ;4ByVTD+P-7Es4ApJla8i1|>BOo7W2Tub5Z5Yz*|8D{S$G#e+ literal 0 HcmV?d00001 diff --git a/Sources/SpeziOmron/Resources/Media.xcassets/Omron-SC-150.imageset/Omron-SC-150.jpg.license b/Sources/SpeziOmron/Resources/Media.xcassets/Omron-SC-150.imageset/Omron-SC-150.jpg.license new file mode 100644 index 0000000..6becaa6 --- /dev/null +++ b/Sources/SpeziOmron/Resources/Media.xcassets/Omron-SC-150.imageset/Omron-SC-150.jpg.license @@ -0,0 +1,6 @@ + +This source file is part of the Stanford SpeziDevices open source project + +SPDX-FileCopyrightText: 2024 Stanford University + +SPDX-License-Identifier: MIT diff --git a/Sources/SpeziOmron/SpeziOmron.docc/SpeziOmron.md b/Sources/SpeziOmron/SpeziOmron.docc/SpeziOmron.md index 7dbf746..7e9bdc0 100644 --- a/Sources/SpeziOmron/SpeziOmron.docc/SpeziOmron.md +++ b/Sources/SpeziOmron/SpeziOmron.docc/SpeziOmron.md @@ -20,6 +20,11 @@ ### Omron Devices +- ``OmronBloodPressureCuff`` +- ``OmronWeightScale`` + +### Omron Device + - ``OmronHealthDevice`` - ``OmronModel`` - ``OmronManufacturerData`` From 947e7c46b4ebc901046b390b97a827bdb3630840 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 27 Jun 2024 17:29:02 +0200 Subject: [PATCH 53/77] Make sure to automatically update stored ImageReference --- .../SpeziDevices/Devices/GenericDevice.swift | 20 ++++++------- .../SpeziDevices/Model/PairedDeviceInfo.swift | 2 +- Sources/SpeziDevices/PairedDevices.swift | 29 ++++++++++++++++++- .../Pairing/AccessoryImageView.swift | 9 +++++- .../Devices/OmronBloodPressureCuff.swift | 8 ++--- .../SpeziOmron/Devices/OmronWeightScale.swift | 8 ++--- 6 files changed, 55 insertions(+), 21 deletions(-) diff --git a/Sources/SpeziDevices/Devices/GenericDevice.swift b/Sources/SpeziDevices/Devices/GenericDevice.swift index 27ea049..2693d93 100644 --- a/Sources/SpeziDevices/Devices/GenericDevice.swift +++ b/Sources/SpeziDevices/Devices/GenericDevice.swift @@ -15,6 +15,9 @@ import SpeziBluetoothServices /// /// A generic Bluetooth device that provides access to basic device information. public protocol GenericDevice: BluetoothDevice, GenericBluetoothPeripheral, Identifiable { + /// An icon that is used to visually present the device to the user. + static var icon: ImageReference? { get } + /// The device identifier. /// /// Use the [`DeviceState`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/devicestate) property wrapper to @@ -48,24 +51,21 @@ public protocol GenericDevice: BluetoothDevice, GenericBluetoothPeripheral, Iden /// @Service var deviceInformation = DeviceInformationService() /// ``` var deviceInformation: DeviceInformationService { get } - - /// An icon that is used to visually present the device to the user. - var icon: ImageReference? { get } } extension GenericDevice { + /// Default icon implementation. + /// + /// Returns `nil` by default. Results in a generic icon to be presented. + public static var icon: ImageReference? { + nil + } + /// Default label implementation. /// /// Returns `"Generic Device"` if the peripheral doesn't expose a ``name``. public var label: String { name ?? "Generic Device" } - - /// Default icon implementation. - /// - /// Returns `nil` by default. Results in a generic icon to be presented. - public var icon: ImageReference? { - nil - } } diff --git a/Sources/SpeziDevices/Model/PairedDeviceInfo.swift b/Sources/SpeziDevices/Model/PairedDeviceInfo.swift index 759f834..1e0b714 100644 --- a/Sources/SpeziDevices/Model/PairedDeviceInfo.swift +++ b/Sources/SpeziDevices/Model/PairedDeviceInfo.swift @@ -19,7 +19,7 @@ public class PairedDeviceInfo { /// Stores the associated ``PairableDevice/deviceTypeIdentifier-9wsed`` device type used to locate the device implementation. public let deviceType: String /// Visual representation of the device. - public let icon: ImageReference? + public var icon: ImageReference? /// A model string of the device. public let model: String? diff --git a/Sources/SpeziDevices/PairedDevices.swift b/Sources/SpeziDevices/PairedDevices.swift index ac99fe9..7ae53e1 100644 --- a/Sources/SpeziDevices/PairedDevices.swift +++ b/Sources/SpeziDevices/PairedDevices.swift @@ -158,6 +158,8 @@ public final class PairedDevices { return // no devices paired, no need to power up central } + self.syncDeviceIcons() // make sure assets are up to date + await self.setupBluetoothStateSubscription() } } @@ -441,7 +443,7 @@ extension PairedDevices { deviceType: Device.deviceTypeIdentifier, name: device.label, model: device.deviceInformation.modelNumber, - icon: device.icon, + icon: Device.icon, batteryPercentage: batteryLevel ) @@ -487,6 +489,31 @@ extension PairedDevices { // MARK: - Paired Peripheral Management extension PairedDevices { + @MainActor + private func syncDeviceIcons() { + guard let bluetooth else { + return + } + + let configuredDevices = bluetooth.configuredPairableDevices + + var didUpdate = false + for deviceInfo in pairedDevices { + guard let deviceType = configuredDevices[deviceInfo.deviceType] else { + continue + } + + if deviceInfo.icon != deviceType.icon { + deviceInfo.icon = deviceType.icon + didUpdate = true + } + } + + if didUpdate { + flush() + } + } + @MainActor private func setupBluetoothStateSubscription() async { assert(!pairedDevices.isEmpty, "Bluetooth State subscription doesn't need to be set up without any paired devices.") diff --git a/Sources/SpeziDevicesUI/Pairing/AccessoryImageView.swift b/Sources/SpeziDevicesUI/Pairing/AccessoryImageView.swift index cac7ac7..0a67c68 100644 --- a/Sources/SpeziDevicesUI/Pairing/AccessoryImageView.swift +++ b/Sources/SpeziDevicesUI/Pairing/AccessoryImageView.swift @@ -14,7 +14,7 @@ struct AccessoryImageView: View { private let device: any GenericDevice var body: some View { - let image = device.icon?.image ?? Image(systemName: "sensor") // swiftlint:disable:this accessibility_label_for_image + let image = device.anyIcon?.image ?? Image(systemName: "sensor") // swiftlint:disable:this accessibility_label_for_image HStack { image .resizable() @@ -35,6 +35,13 @@ struct AccessoryImageView: View { } +extension GenericDevice { + fileprivate var anyIcon: ImageReference? { + Self.icon + } +} + + #if DEBUG #Preview { AccessoryImageView(MockDevice.createMockDevice()) diff --git a/Sources/SpeziOmron/Devices/OmronBloodPressureCuff.swift b/Sources/SpeziOmron/Devices/OmronBloodPressureCuff.swift index aa7a0d3..b4ad55c 100644 --- a/Sources/SpeziOmron/Devices/OmronBloodPressureCuff.swift +++ b/Sources/SpeziOmron/Devices/OmronBloodPressureCuff.swift @@ -18,6 +18,10 @@ import SpeziNumerics /// Implementation of Omron BP5250 Blood Pressure Cuff. public class OmronBloodPressureCuff: BluetoothDevice, Identifiable, OmronHealthDevice, BatteryPoweredDevice { private static let logger = Logger(subsystem: "ENGAGEHF", category: "BloodPressureCuffDevice") + + public static var icon: ImageReference? { + .asset("Omron-BP5250", bundle: .module) + } @DeviceState(\.id) public var id: UUID @DeviceState(\.name) public var name: String? @@ -37,10 +41,6 @@ public class OmronBloodPressureCuff: BluetoothDevice, Identifiable, OmronHealthD @Dependency private var measurements: HealthMeasurements? @Dependency private var pairedDevices: PairedDevices? - public var icon: ImageReference? { - .asset("Omron-BP5250", bundle: .module) - } - /// Initialize the device. public required init() {} diff --git a/Sources/SpeziOmron/Devices/OmronWeightScale.swift b/Sources/SpeziOmron/Devices/OmronWeightScale.swift index c727915..ac90b78 100644 --- a/Sources/SpeziOmron/Devices/OmronWeightScale.swift +++ b/Sources/SpeziOmron/Devices/OmronWeightScale.swift @@ -18,6 +18,10 @@ import SpeziDevices public class OmronWeightScale: BluetoothDevice, Identifiable, OmronHealthDevice { private static let logger = Logger(subsystem: "ENGAGEHF", category: "WeightScale") + public var icon: ImageReference? { + .asset("Omron-SC-150", bundle: .module) + } + @DeviceState(\.id) public var id: UUID @DeviceState(\.name) public var name: String? @DeviceState(\.state) public var state: PeripheralState @@ -37,10 +41,6 @@ public class OmronWeightScale: BluetoothDevice, Identifiable, OmronHealthDevice private var dateOfConnection: Date? - public var icon: ImageReference? { - .asset("Omron-SC-150", bundle: .module) - } - /// Initialize the device. public required init() {} From 7113feea82ceeadbfbb7d1507d5e3eeb987550ef Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 27 Jun 2024 17:33:41 +0200 Subject: [PATCH 54/77] Logging --- Sources/SpeziDevices/HealthMeasurements.swift | 2 ++ Sources/SpeziDevices/PairedDevices.swift | 2 ++ 2 files changed, 4 insertions(+) diff --git a/Sources/SpeziDevices/HealthMeasurements.swift b/Sources/SpeziDevices/HealthMeasurements.swift index b030377..6e562d6 100644 --- a/Sources/SpeziDevices/HealthMeasurements.swift +++ b/Sources/SpeziDevices/HealthMeasurements.swift @@ -172,6 +172,8 @@ public class HealthMeasurements { logger.debug("Received new blood pressure measurement: \(String(describing: measurement))") await handleNewMeasurement(.bloodPressure(measurement, service.features ?? []), from: hkDevice) } + + logger.debug("Registered device \(device.label), \(device.id) with HealthMeasurements") } @MainActor diff --git a/Sources/SpeziDevices/PairedDevices.swift b/Sources/SpeziDevices/PairedDevices.swift index 7ae53e1..00734ef 100644 --- a/Sources/SpeziDevices/PairedDevices.swift +++ b/Sources/SpeziDevices/PairedDevices.swift @@ -236,6 +236,8 @@ public final class PairedDevices { await updateBattery(for: device, percentage: value) } } + + logger.debug("Registered device \(device.label), \(device.id) with PairedDevices") } @MainActor From 59d9265953fdd17d0be73448f63091834d4e3a71 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 27 Jun 2024 19:20:07 +0200 Subject: [PATCH 55/77] Fix double storage --- Sources/SpeziDevices/HealthMeasurements.swift | 12 ++++++++---- .../Measurements/MeasurementRecordedSheet.swift | 5 +++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/Sources/SpeziDevices/HealthMeasurements.swift b/Sources/SpeziDevices/HealthMeasurements.swift index 6e562d6..de40435 100644 --- a/Sources/SpeziDevices/HealthMeasurements.swift +++ b/Sources/SpeziDevices/HealthMeasurements.swift @@ -178,13 +178,17 @@ public class HealthMeasurements { @MainActor private func handleNewMeasurement(_ measurement: BluetoothHealthMeasurement, from source: HKDevice) { - loadMeasurement(measurement, form: source) + guard let healthKitMeasurement = loadMeasurement(measurement, form: source) else { + return + } + + storedMeasurements[healthKitMeasurement.id] = StoredMeasurement(measurement: measurement, device: source) shouldPresentMeasurements = true } @MainActor - private func loadMeasurement(_ measurement: BluetoothHealthMeasurement, form source: HKDevice) { + private func loadMeasurement(_ measurement: BluetoothHealthMeasurement, form source: HKDevice) -> HealthKitMeasurement? { let healthKitMeasurement: HealthKitMeasurement switch measurement { case let .weight(measurement, feature): @@ -200,7 +204,7 @@ public class HealthMeasurements { guard let bloodPressureSample else { logger.debug("Discarding invalid blood pressure measurement ...") - return + return nil } logger.debug("Measurement loaded: \(String(describing: measurement))") @@ -210,7 +214,7 @@ public class HealthMeasurements { // prepend to pending measurements pendingMeasurements.insert(healthKitMeasurement, at: 0) - storedMeasurements[healthKitMeasurement.id] = StoredMeasurement(measurement: measurement, device: source) + return healthKitMeasurement } /// Discard a pending measurement. diff --git a/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift b/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift index 82a673d..30fd939 100644 --- a/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift +++ b/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift @@ -95,6 +95,7 @@ public struct MeasurementRecordedSheet: View { @ViewBuilder @MainActor private var content: some View { if measurements.pendingMeasurements.count > 1 { HStack { + // TODO: carousel might crash if the index is not right after deleting! (change the test to test this?) ACarousel(measurements.pendingMeasurements, index: $selectedMeasurementIndex, spacing: 0, headspace: 0) { measurement in MeasurementLayer(measurement: measurement) } @@ -121,14 +122,14 @@ public struct MeasurementRecordedSheet: View { measurements.discardMeasurement(selectedMeasurement) logger.info("Saved measurement: \(String(describing: selectedMeasurement))") - dismiss() + dismiss() // TODO: maintain to show last measurement when dismissing! } discard: { guard let selectedMeasurement else { return } measurements.discardMeasurement(selectedMeasurement) if measurements.pendingMeasurements.isEmpty { - dismiss() + dismiss() // TODO: maintain to show last measurement when dismissing! } } } From 8cc8ae73bdc135d5c36b122881d1b83e0dcead20 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 27 Jun 2024 19:24:57 +0200 Subject: [PATCH 56/77] Discaridng? --- Sources/SpeziDevices/HealthMeasurements.swift | 3 ++- Sources/SpeziDevices/Model/SavableDictionary.swift | 8 ++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Sources/SpeziDevices/HealthMeasurements.swift b/Sources/SpeziDevices/HealthMeasurements.swift index de40435..7a7b9e4 100644 --- a/Sources/SpeziDevices/HealthMeasurements.swift +++ b/Sources/SpeziDevices/HealthMeasurements.swift @@ -231,7 +231,8 @@ public class HealthMeasurements { return false } let element = self.pendingMeasurements.remove(at: index) - storedMeasurements[element.id] = nil + + storedMeasurements.removeValue(forKey: element.id) return true } } diff --git a/Sources/SpeziDevices/Model/SavableDictionary.swift b/Sources/SpeziDevices/Model/SavableDictionary.swift index 329d3a3..c7c99b2 100644 --- a/Sources/SpeziDevices/Model/SavableDictionary.swift +++ b/Sources/SpeziDevices/Model/SavableDictionary.swift @@ -33,13 +33,17 @@ struct SavableDictionary { get { storage[key] } - _modify { + mutating _modify { yield &storage[key] } - set { + mutating set { storage[key] = newValue } } + + mutating func removeValue(forKey key: Key) { + storage.removeValue(forKey: key) + } } From 0571017fc3e18dd6ae82a829b255e936863f2d6c Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 27 Jun 2024 19:59:13 +0200 Subject: [PATCH 57/77] Minor fixes and new todos --- .../Model/SavableDictionary.swift | 3 ++- .../MeasurementRecordedSheet.swift | 14 ++++++++++++- .../Pairing/PairDeviceView.swift | 6 ++++++ Sources/SpeziDevicesUI/Utils/IndexCount.swift | 21 +++++++++++++++++++ .../Devices/OmronBloodPressureCuff.swift | 2 +- .../SpeziOmron/Devices/OmronWeightScale.swift | 2 +- 6 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 Sources/SpeziDevicesUI/Utils/IndexCount.swift diff --git a/Sources/SpeziDevices/Model/SavableDictionary.swift b/Sources/SpeziDevices/Model/SavableDictionary.swift index c7c99b2..cc3696e 100644 --- a/Sources/SpeziDevices/Model/SavableDictionary.swift +++ b/Sources/SpeziDevices/Model/SavableDictionary.swift @@ -41,7 +41,8 @@ struct SavableDictionary { } } - mutating func removeValue(forKey key: Key) { + @discardableResult + mutating func removeValue(forKey key: Key) -> Value? { storage.removeValue(forKey: key) } } diff --git a/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift b/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift index 30fd939..f8dc585 100644 --- a/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift +++ b/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift @@ -95,7 +95,6 @@ public struct MeasurementRecordedSheet: View { @ViewBuilder @MainActor private var content: some View { if measurements.pendingMeasurements.count > 1 { HStack { - // TODO: carousel might crash if the index is not right after deleting! (change the test to test this?) ACarousel(measurements.pendingMeasurements, index: $selectedMeasurementIndex, spacing: 0, headspace: 0) { measurement in MeasurementLayer(measurement: measurement) } @@ -120,6 +119,7 @@ public struct MeasurementRecordedSheet: View { } measurements.discardMeasurement(selectedMeasurement) + ensureCorrectIndex() logger.info("Saved measurement: \(String(describing: selectedMeasurement))") dismiss() // TODO: maintain to show last measurement when dismissing! @@ -128,6 +128,8 @@ public struct MeasurementRecordedSheet: View { return } measurements.discardMeasurement(selectedMeasurement) + ensureCorrectIndex() + if measurements.pendingMeasurements.isEmpty { dismiss() // TODO: maintain to show last measurement when dismissing! } @@ -139,6 +141,16 @@ public struct MeasurementRecordedSheet: View { public init(save saveSamples: @escaping ([HKSample]) async throws -> Void) { self.saveSamples = saveSamples } + + + @MainActor + @discardableResult + private func ensureCorrectIndex() -> EmptyView { // TODO: not protected from changes from the outside? + if selectedMeasurementIndex >= measurements.pendingMeasurements.count { + selectedMeasurementIndex = max(0, measurements.pendingMeasurements.count - 1) + } + return EmptyView() + } } diff --git a/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift b/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift index deb72e9..3693d4c 100644 --- a/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift +++ b/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift @@ -39,6 +39,7 @@ struct PairDeviceView: View where Collection var body: some View { PaneContent(title: "Pair Accessory", subtitle: "Do you want to pair \(selectedDeviceName) with the \(appName) app?") { if devices.count > 1 { + // TODO: what happens if device disappears! ACarousel(devices, id: \.id, index: $selectedDeviceIndex, spacing: 0, headspace: 0) { device in AccessoryImageView(device) } @@ -72,6 +73,11 @@ struct PairDeviceView: View where Collection .buttonStyle(.borderedProminent) .padding([.leading, .trailing], 36) } + .onChange(of: IndexCount(selectedDeviceIndex, devices.count)) { + if selectedDeviceIndex >= devices.count { + selectedDeviceIndex = max(0, devices.count - 1) + } + } } diff --git a/Sources/SpeziDevicesUI/Utils/IndexCount.swift b/Sources/SpeziDevicesUI/Utils/IndexCount.swift new file mode 100644 index 0000000..aacb8a0 --- /dev/null +++ b/Sources/SpeziDevicesUI/Utils/IndexCount.swift @@ -0,0 +1,21 @@ +// +// This source file is part of the Stanford SpeziDevices open source project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + + +struct IndexCount { + let index: Int + let count: Int + + init(_ index: Int, _ count: Int) { + self.index = index + self.count = count + } +} + + +extension IndexCount: Hashable {} diff --git a/Sources/SpeziOmron/Devices/OmronBloodPressureCuff.swift b/Sources/SpeziOmron/Devices/OmronBloodPressureCuff.swift index b4ad55c..190be7c 100644 --- a/Sources/SpeziOmron/Devices/OmronBloodPressureCuff.swift +++ b/Sources/SpeziOmron/Devices/OmronBloodPressureCuff.swift @@ -18,7 +18,7 @@ import SpeziNumerics /// Implementation of Omron BP5250 Blood Pressure Cuff. public class OmronBloodPressureCuff: BluetoothDevice, Identifiable, OmronHealthDevice, BatteryPoweredDevice { private static let logger = Logger(subsystem: "ENGAGEHF", category: "BloodPressureCuffDevice") - + public static var icon: ImageReference? { .asset("Omron-BP5250", bundle: .module) } diff --git a/Sources/SpeziOmron/Devices/OmronWeightScale.swift b/Sources/SpeziOmron/Devices/OmronWeightScale.swift index ac90b78..2e32eea 100644 --- a/Sources/SpeziOmron/Devices/OmronWeightScale.swift +++ b/Sources/SpeziOmron/Devices/OmronWeightScale.swift @@ -18,7 +18,7 @@ import SpeziDevices public class OmronWeightScale: BluetoothDevice, Identifiable, OmronHealthDevice { private static let logger = Logger(subsystem: "ENGAGEHF", category: "WeightScale") - public var icon: ImageReference? { + public static var icon: ImageReference? { .asset("Omron-SC-150", bundle: .module) } From d1d1a8295b73856258f721c2488c3cfffd345909 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 27 Jun 2024 20:03:18 +0200 Subject: [PATCH 58/77] Less todos and always update icon --- Sources/SpeziDevices/PairedDevices.swift | 6 ++---- .../Measurements/MeasurementRecordedSheet.swift | 2 +- Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift | 1 - 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/Sources/SpeziDevices/PairedDevices.swift b/Sources/SpeziDevices/PairedDevices.swift index 00734ef..db52019 100644 --- a/Sources/SpeziDevices/PairedDevices.swift +++ b/Sources/SpeziDevices/PairedDevices.swift @@ -505,10 +505,8 @@ extension PairedDevices { continue } - if deviceInfo.icon != deviceType.icon { - deviceInfo.icon = deviceType.icon - didUpdate = true - } + deviceInfo.icon = deviceType.icon + didUpdate = true } if didUpdate { diff --git a/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift b/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift index f8dc585..de3add2 100644 --- a/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift +++ b/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift @@ -145,7 +145,7 @@ public struct MeasurementRecordedSheet: View { @MainActor @discardableResult - private func ensureCorrectIndex() -> EmptyView { // TODO: not protected from changes from the outside? + private func ensureCorrectIndex() -> EmptyView { if selectedMeasurementIndex >= measurements.pendingMeasurements.count { selectedMeasurementIndex = max(0, measurements.pendingMeasurements.count - 1) } diff --git a/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift b/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift index 3693d4c..0539318 100644 --- a/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift +++ b/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift @@ -39,7 +39,6 @@ struct PairDeviceView: View where Collection var body: some View { PaneContent(title: "Pair Accessory", subtitle: "Do you want to pair \(selectedDeviceName) with the \(appName) app?") { if devices.count > 1 { - // TODO: what happens if device disappears! ACarousel(devices, id: \.id, index: $selectedDeviceIndex, spacing: 0, headspace: 0) { device in AccessoryImageView(device) } From 15306ed804dbf6a618f2b127bba28810aea6de74 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 27 Jun 2024 20:05:45 +0200 Subject: [PATCH 59/77] Minor tests --- Sources/SpeziDevices/HealthMeasurements.swift | 1 + Sources/SpeziDevices/PairedDevices.swift | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/SpeziDevices/HealthMeasurements.swift b/Sources/SpeziDevices/HealthMeasurements.swift index 7a7b9e4..b7bc6d6 100644 --- a/Sources/SpeziDevices/HealthMeasurements.swift +++ b/Sources/SpeziDevices/HealthMeasurements.swift @@ -233,6 +233,7 @@ public class HealthMeasurements { let element = self.pendingMeasurements.remove(at: index) storedMeasurements.removeValue(forKey: element.id) + storedMeasurements = storedMeasurements // TODO: remove? return true } } diff --git a/Sources/SpeziDevices/PairedDevices.swift b/Sources/SpeziDevices/PairedDevices.swift index db52019..4ca986f 100644 --- a/Sources/SpeziDevices/PairedDevices.swift +++ b/Sources/SpeziDevices/PairedDevices.swift @@ -505,12 +505,13 @@ extension PairedDevices { continue } - deviceInfo.icon = deviceType.icon + // TODO: just never store it and always update if we can't keep the promise? + // TODO: deviceInfo.icon = deviceType.icon didUpdate = true } if didUpdate { - flush() + // TODO: flush() } } From 9b00baaaa11c9eba960ed859628c1fb1f05c002e Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 27 Jun 2024 20:36:40 +0200 Subject: [PATCH 60/77] Release versions --- Package.swift | 4 ++-- Sources/SpeziDevices/PairedDevices.swift | 5 ++--- Tests/UITests/UITests.xcodeproj/project.pbxproj | 4 ++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/Package.swift b/Package.swift index 6132fb0..96328db 100644 --- a/Package.swift +++ b/Package.swift @@ -36,9 +36,9 @@ 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", branch: "feature/accessory-discovery"), + .package(url: "https://github.com/StanfordSpezi/SpeziBluetooth", from: "2.0.0"), .package(url: "https://github.com/StanfordSpezi/SpeziNetworking", from: "2.0.0"), - .package(url: "https://github.com/StanfordBDHG/XCTestExtensions.git", branch: "feature/xctassert-throws-async"), + .package(url: "https://github.com/StanfordBDHG/XCTestExtensions.git", .upToNextMinor(from: "0.4.11")), .package(url: "https://github.com/JWAutumn/ACarousel", .upToNextMinor(from: "0.2.0")) ] + swiftLintPackage(), targets: [ diff --git a/Sources/SpeziDevices/PairedDevices.swift b/Sources/SpeziDevices/PairedDevices.swift index 4ca986f..db52019 100644 --- a/Sources/SpeziDevices/PairedDevices.swift +++ b/Sources/SpeziDevices/PairedDevices.swift @@ -505,13 +505,12 @@ extension PairedDevices { continue } - // TODO: just never store it and always update if we can't keep the promise? - // TODO: deviceInfo.icon = deviceType.icon + deviceInfo.icon = deviceType.icon didUpdate = true } if didUpdate { - // TODO: flush() + flush() } } diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index e5a0aef..f69e7cb 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -719,8 +719,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/StanfordBDHG/XCTestExtensions.git"; requirement = { - branch = "feature/xctassert-throws-async"; - kind = branch; + kind = upToNextMinorVersion; + minimumVersion = 0.4.11; }; }; /* End XCRemoteSwiftPackageReference section */ From 7da6d65cafbc2ea84ae0e38eea88ec48f8b4f32b Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 27 Jun 2024 22:09:36 +0200 Subject: [PATCH 61/77] Always determine icon at runtime --- Sources/SpeziDevices/HealthMeasurements.swift | 8 ++-- .../SpeziDevices/Model/ImageReference.swift | 47 ------------------- .../SpeziDevices/Model/PairedDeviceInfo.swift | 12 ++--- .../Model/SavableDictionary.swift | 10 ++-- Sources/SpeziDevices/PairedDevices.swift | 10 +--- .../SpeziDevicesUI/Utils/CarouselDots.swift | 2 +- .../ImageReferenceTests.swift | 37 --------------- 7 files changed, 19 insertions(+), 107 deletions(-) delete mode 100644 Tests/SpeziDevicesTests/ImageReferenceTests.swift diff --git a/Sources/SpeziDevices/HealthMeasurements.swift b/Sources/SpeziDevices/HealthMeasurements.swift index b7bc6d6..41ebfc9 100644 --- a/Sources/SpeziDevices/HealthMeasurements.swift +++ b/Sources/SpeziDevices/HealthMeasurements.swift @@ -188,6 +188,7 @@ public class HealthMeasurements { } @MainActor + @discardableResult private func loadMeasurement(_ measurement: BluetoothHealthMeasurement, form source: HKDevice) -> HealthKitMeasurement? { let healthKitMeasurement: HealthKitMeasurement switch measurement { @@ -231,9 +232,10 @@ public class HealthMeasurements { return false } let element = self.pendingMeasurements.remove(at: index) - - storedMeasurements.removeValue(forKey: element.id) - storedMeasurements = storedMeasurements // TODO: remove? + let value = storedMeasurements.removeValue(forKey: element.id) + if let value { + logger.debug("Discarding measurement \(String(describing: value.measurement))") + } return true } } diff --git a/Sources/SpeziDevices/Model/ImageReference.swift b/Sources/SpeziDevices/Model/ImageReference.swift index dda3bc5..d0514b8 100644 --- a/Sources/SpeziDevices/Model/ImageReference.swift +++ b/Sources/SpeziDevices/Model/ImageReference.swift @@ -43,50 +43,3 @@ extension ImageReference { extension ImageReference: Hashable {} - - -extension ImageReference: Codable { - private enum CodingKeys: String, CodingKey { - case type - case name - case bundle - } - - private enum ReferenceType: String, Codable { - case system - case asset - } - - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - let type = try container.decode(ReferenceType.self, forKey: .type) - let name = try container.decode(String.self, forKey: .name) - switch type { - case .system: - self = .system(name) - case .asset: - let bundleURL = try container.decodeIfPresent(URL.self, forKey: .bundle) - let bundle = bundleURL.flatMap { Bundle(url: $0) } - - self = .asset(name, bundle: bundle) - } - } - - public func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - switch self { - case let .system(name): - try container.encode(ReferenceType.system, forKey: .type) - try container.encode(name, forKey: .name) - case let .asset(name, bundle): - try container.encode(ReferenceType.asset, forKey: .type) - try container.encode(name, forKey: .name) - - if let bundle { - try container.encode(bundle.bundleURL, forKey: .bundle) - } - } - } -} diff --git a/Sources/SpeziDevices/Model/PairedDeviceInfo.swift b/Sources/SpeziDevices/Model/PairedDeviceInfo.swift index 1e0b714..a5f158a 100644 --- a/Sources/SpeziDevices/Model/PairedDeviceInfo.swift +++ b/Sources/SpeziDevices/Model/PairedDeviceInfo.swift @@ -18,8 +18,6 @@ public class PairedDeviceInfo { /// /// Stores the associated ``PairableDevice/deviceTypeIdentifier-9wsed`` device type used to locate the device implementation. public let deviceType: String - /// Visual representation of the device. - public var icon: ImageReference? /// A model string of the device. public let model: String? @@ -29,8 +27,13 @@ public class PairedDeviceInfo { public internal(set) var lastSeen: Date /// The last reported battery percentage of the device. public internal(set) var lastBatteryPercentage: UInt8? + + // NOT STORED ON DISK + /// Could not retrieve the device from the Bluetooth central. public internal(set) var notLocatable: Bool = false + /// Visual representation of the device. + public var icon: ImageReference? /// Create new paired device information. /// - Parameters: @@ -46,7 +49,7 @@ public class PairedDeviceInfo { deviceType: String, name: String, model: String?, - icon: ImageReference?, + icon: ImageReference? = nil, lastSeen: Date = .now, batteryPercentage: UInt8? = nil ) { @@ -68,7 +71,6 @@ public class PairedDeviceInfo { deviceType: container.decode(String.self, forKey: .deviceType), name: container.decode(String.self, forKey: .name), model: container.decodeIfPresent(String.self, forKey: .name), - icon: container.decodeIfPresent(ImageReference.self, forKey: .icon), lastSeen: container.decode(Date.self, forKey: .lastSeen), batteryPercentage: container.decodeIfPresent(UInt8.self, forKey: .batteryPercentage) ) @@ -82,7 +84,6 @@ extension PairedDeviceInfo: Identifiable, Codable { case deviceType case name case model - case icon case lastSeen case batteryPercentage } @@ -94,7 +95,6 @@ extension PairedDeviceInfo: Identifiable, Codable { try container.encode(deviceType, forKey: .deviceType) try container.encode(name, forKey: .name) try container.encodeIfPresent(model, forKey: .model) - try container.encodeIfPresent(icon, forKey: .icon) try container.encode(lastSeen, forKey: .lastSeen) try container.encodeIfPresent(lastBatteryPercentage, forKey: .batteryPercentage) } diff --git a/Sources/SpeziDevices/Model/SavableDictionary.swift b/Sources/SpeziDevices/Model/SavableDictionary.swift index cc3696e..6d133fd 100644 --- a/Sources/SpeziDevices/Model/SavableDictionary.swift +++ b/Sources/SpeziDevices/Model/SavableDictionary.swift @@ -29,6 +29,11 @@ struct SavableDictionary { storage.removeAll() } + @discardableResult + mutating func removeValue(forKey key: Key) -> Value? { + storage.removeValue(forKey: key) + } + subscript(key: Key) -> Value? { get { storage[key] @@ -40,11 +45,6 @@ struct SavableDictionary { storage[key] = newValue } } - - @discardableResult - mutating func removeValue(forKey key: Key) -> Value? { - storage.removeValue(forKey: key) - } } diff --git a/Sources/SpeziDevices/PairedDevices.swift b/Sources/SpeziDevices/PairedDevices.swift index db52019..a81682f 100644 --- a/Sources/SpeziDevices/PairedDevices.swift +++ b/Sources/SpeziDevices/PairedDevices.swift @@ -154,12 +154,12 @@ public final class PairedDevices { // We need to detach to not copy task local values Task.detached { @MainActor in + self.syncDeviceIcons() // make sure assets are up to date + guard !self.pairedDevices.isEmpty else { return // no devices paired, no need to power up central } - self.syncDeviceIcons() // make sure assets are up to date - await self.setupBluetoothStateSubscription() } } @@ -499,18 +499,12 @@ extension PairedDevices { let configuredDevices = bluetooth.configuredPairableDevices - var didUpdate = false for deviceInfo in pairedDevices { guard let deviceType = configuredDevices[deviceInfo.deviceType] else { continue } deviceInfo.icon = deviceType.icon - didUpdate = true - } - - if didUpdate { - flush() } } diff --git a/Sources/SpeziDevicesUI/Utils/CarouselDots.swift b/Sources/SpeziDevicesUI/Utils/CarouselDots.swift index 7350ee2..654569a 100644 --- a/Sources/SpeziDevicesUI/Utils/CarouselDots.swift +++ b/Sources/SpeziDevicesUI/Utils/CarouselDots.swift @@ -59,7 +59,7 @@ struct CarouselDots: View { private var dragGesture: some Gesture { - DragGesture(minimumDistance: 2) + DragGesture(minimumDistance: 0) .onChanged { value in isDragging = true updateIndexBasedOnDrag(value.location) diff --git a/Tests/SpeziDevicesTests/ImageReferenceTests.swift b/Tests/SpeziDevicesTests/ImageReferenceTests.swift deleted file mode 100644 index b15be6a..0000000 --- a/Tests/SpeziDevicesTests/ImageReferenceTests.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// This source file is part of the Stanford SpeziDevices open source project -// -// SPDX-FileCopyrightText: 2024 Stanford University -// -// SPDX-License-Identifier: MIT -// - -@testable import SpeziDevices -import XCTest - - -class ImageReferenceTests: XCTestCase { - func testAssetCodable() throws { - for bundle in Bundle.allBundles { - let encoder = JSONEncoder() - let decoder = JSONDecoder() - - let value = ImageReference.asset("ref", bundle: bundle) - let referenceString = try encoder.encode(value) - let reference = try decoder.decode(ImageReference.self, from: referenceString) - - XCTAssertEqual(reference, value, "Failed to encode/decode asset for bundle \(String(describing: bundle.bundleIdentifier))") - } - } - - func testSystemCodable() throws { - let encoder = JSONEncoder() - let decoder = JSONDecoder() - - let value = ImageReference.system("sensor") - let referenceString = try encoder.encode(value) - let reference = try decoder.decode(ImageReference.self, from: referenceString) - - XCTAssertEqual(reference, value) - } -} From bce9494a3b25055162977733884c9e1afcac49b3 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 27 Jun 2024 22:26:49 +0200 Subject: [PATCH 62/77] Fix HealthMeasurements Storage issue --- Sources/SpeziDevices/HealthMeasurements.swift | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/Sources/SpeziDevices/HealthMeasurements.swift b/Sources/SpeziDevices/HealthMeasurements.swift index 41ebfc9..0636e82 100644 --- a/Sources/SpeziDevices/HealthMeasurements.swift +++ b/Sources/SpeziDevices/HealthMeasurements.swift @@ -128,9 +128,25 @@ public class HealthMeasurements { @_documentation(visibility: internal) public func configure() { Task.detached { @MainActor in - for measurement in self.storedMeasurements.values { + // Note, we index `storedMeasurements` by the HealthKit sample UUID. + // However, when we redo the conversion, the identifier changes. Therefore, + // we store the current container in local scope, clear everything and completely rebuild the dictionary. + let storedMeasurements = self.storedMeasurements + self.storedMeasurements.removeAll() + + for measurement in storedMeasurements.values { self.loadMeasurement(measurement.measurement, form: measurement.device) } + + // assert check that they have the same count. + assert(self.storedMeasurements.count == self.pendingMeasurements.count, "Non-matching count after initialization. Storage out of sync.") + // assert that both key sets are equal. + assert( + Set(self.storedMeasurements.keys) + .union(self.pendingMeasurements.reduce(into: Set(), { $0.insert($1.id) })) + .count == self.storedMeasurements.count, + "Inconsistent key storage in health store. Storage out of sync." + ) } } @@ -178,18 +194,12 @@ public class HealthMeasurements { @MainActor private func handleNewMeasurement(_ measurement: BluetoothHealthMeasurement, from source: HKDevice) { - guard let healthKitMeasurement = loadMeasurement(measurement, form: source) else { - return - } - - storedMeasurements[healthKitMeasurement.id] = StoredMeasurement(measurement: measurement, device: source) - + loadMeasurement(measurement, form: source) shouldPresentMeasurements = true } @MainActor - @discardableResult - private func loadMeasurement(_ measurement: BluetoothHealthMeasurement, form source: HKDevice) -> HealthKitMeasurement? { + private func loadMeasurement(_ measurement: BluetoothHealthMeasurement, form source: HKDevice) { let healthKitMeasurement: HealthKitMeasurement switch measurement { case let .weight(measurement, feature): @@ -205,7 +215,7 @@ public class HealthMeasurements { guard let bloodPressureSample else { logger.debug("Discarding invalid blood pressure measurement ...") - return nil + return } logger.debug("Measurement loaded: \(String(describing: measurement))") @@ -215,7 +225,7 @@ public class HealthMeasurements { // prepend to pending measurements pendingMeasurements.insert(healthKitMeasurement, at: 0) - return healthKitMeasurement + storedMeasurements[healthKitMeasurement.id] = StoredMeasurement(measurement: measurement, device: source) } /// Discard a pending measurement. @@ -235,6 +245,8 @@ public class HealthMeasurements { let value = storedMeasurements.removeValue(forKey: element.id) if let value { logger.debug("Discarding measurement \(String(describing: value.measurement))") + } else { + logger.error("Couldn't locate stored measurements when discarding pending measurement.") } return true } From e123dfe8950507406ee6ea8efcb077b464b86066 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 28 Jun 2024 11:02:35 +0200 Subject: [PATCH 63/77] Move to SwiftData for paired devices --- .../SpeziDevices/Model/PairedDeviceInfo.swift | 47 +------ Sources/SpeziDevices/PairedDevices.swift | 117 ++++++++++-------- .../PairedDevicesTests.swift | 14 +-- Tests/UITests/TestApp/DevicesTestView.swift | 2 - 4 files changed, 75 insertions(+), 105 deletions(-) diff --git a/Sources/SpeziDevices/Model/PairedDeviceInfo.swift b/Sources/SpeziDevices/Model/PairedDeviceInfo.swift index a5f158a..6138a62 100644 --- a/Sources/SpeziDevices/Model/PairedDeviceInfo.swift +++ b/Sources/SpeziDevices/Model/PairedDeviceInfo.swift @@ -7,13 +7,14 @@ // import Foundation +import SwiftData /// Persistent information stored of a paired device. -@Observable +@Model public class PairedDeviceInfo { /// The CoreBluetooth device identifier. - public let id: UUID + @Attribute(.unique) public let id: UUID /// The device type. /// /// Stores the associated ``PairableDevice/deviceTypeIdentifier-9wsed`` device type used to locate the device implementation. @@ -28,12 +29,10 @@ public class PairedDeviceInfo { /// The last reported battery percentage of the device. public internal(set) var lastBatteryPercentage: UInt8? - // NOT STORED ON DISK - /// Could not retrieve the device from the Bluetooth central. - public internal(set) var notLocatable: Bool = false + @Transient public internal(set) var notLocatable: Bool = false /// Visual representation of the device. - public var icon: ImageReference? + @Transient public var icon: ImageReference? /// Create new paired device information. /// - Parameters: @@ -61,44 +60,10 @@ public class PairedDeviceInfo { self.lastSeen = lastSeen self.lastBatteryPercentage = batteryPercentage } - - /// Initialize from decoder. - public required convenience init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - try self.init( - id: container.decode(UUID.self, forKey: .id), - deviceType: container.decode(String.self, forKey: .deviceType), - name: container.decode(String.self, forKey: .name), - model: container.decodeIfPresent(String.self, forKey: .name), - lastSeen: container.decode(Date.self, forKey: .lastSeen), - batteryPercentage: container.decodeIfPresent(UInt8.self, forKey: .batteryPercentage) - ) - } } -extension PairedDeviceInfo: Identifiable, Codable { - fileprivate enum CodingKeys: String, CodingKey { - case id - case deviceType - case name - case model - case lastSeen - case batteryPercentage - } - - public func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - try container.encode(id, forKey: .id) - try container.encode(deviceType, forKey: .deviceType) - try container.encode(name, forKey: .name) - try container.encodeIfPresent(model, forKey: .model) - try container.encode(lastSeen, forKey: .lastSeen) - try container.encodeIfPresent(lastBatteryPercentage, forKey: .batteryPercentage) - } -} +extension PairedDeviceInfo: Identifiable {} extension PairedDeviceInfo: Hashable { diff --git a/Sources/SpeziDevices/PairedDevices.swift b/Sources/SpeziDevices/PairedDevices.swift index a81682f..9e37f39 100644 --- a/Sources/SpeziDevices/PairedDevices.swift +++ b/Sources/SpeziDevices/PairedDevices.swift @@ -10,8 +10,9 @@ import OrderedCollections import Spezi import SpeziBluetooth import SpeziBluetoothServices -import SpeziFoundation +@_spi(TestingSupport) import SpeziFoundation import SpeziViews +import SwiftData import SwiftUI @@ -88,24 +89,18 @@ import SwiftUI 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] = [:] - - /// Device Information of paired devices. + /// The collection of paired devices that are persisted on disk. @MainActor public var pairedDevices: [PairedDeviceInfo] { - get { - access(keyPath: \.pairedDevices) - return _pairedDevices.values - } - set { - withMutation(keyPath: \.pairedDevices) { - _pairedDevices = SavableCollection(newValue) - } - } + Array(_pairedDevices.values) } - @AppStorage @MainActor @ObservationIgnored private var _pairedDevices: SavableCollection + + @MainActor private var _pairedDevices: OrderedDictionary = [:] + + /// Bluetooth Peripheral instances of paired devices. + @MainActor private(set) var peripherals: [UUID: any PairableDevice] = [:] @MainActor @ObservationIgnored private var pendingConnectionAttempts: [UUID: Task] = [:] @MainActor @ObservationIgnored private var ongoingPairings: [UUID: PairingContinuation] = [:] @@ -118,6 +113,8 @@ public final class PairedDevices { @Dependency @ObservationIgnored private var bluetooth: Bluetooth? @Dependency @ObservationIgnored private var tipKit: ConfigureTipKit + private var modelContainer: ModelContainer? + /// 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. @@ -133,27 +130,34 @@ public final class PairedDevices { /// 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) - } + public required init() {} /// Configures the Module. @_documentation(visibility: internal) public func configure() { - guard bluetooth != nil else { + if bluetooth != nil { self.logger.warning("PairedDevices Module initialized without Bluetooth dependency!") - return // useful for e.g. previews + } + + var configuration: ModelConfiguration +#if targetEnvironment(simulator) + configuration = ModelConfiguration(isStoredInMemoryOnly: true) +#else + configuration = ModelConfiguration() +#endif + + do { + self.modelContainer = try ModelContainer(for: PairedDeviceInfo.self, configurations: configuration) + } catch { + self.modelContainer = nil + self.logger.error("PairedDevices failed to initialize ModelContainer: \(error)") } // We need to detach to not copy task local values Task.detached { @MainActor in + self.fetchAllPairedInfos() + self.syncDeviceIcons() // make sure assets are up to date guard !self.pairedDevices.isEmpty else { @@ -164,13 +168,6 @@ public final class PairedDevices { } } - /// Clears all currently stored paired devices. - @_spi(TestingSupport) - @MainActor - public func clearStorage() { - pairedDevices.removeAll() - } - /// 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. @@ -193,8 +190,8 @@ public final class PairedDevices { /// - name: The new name. @MainActor public func updateName(for deviceInfo: PairedDeviceInfo, name: String) { + logger.debug("Updated name for paired device \(deviceInfo.id): \(name) %") deviceInfo.name = name - flush() } /// Configure a device to be managed by this PairedDevices instance. @@ -284,22 +281,20 @@ public final class PairedDevices { @MainActor private func updateBattery(for device: Device, percentage: UInt8) { - guard let index = pairedDevices.firstIndex(where: { $0.id == device.id }) else { + guard let deviceInfo = _pairedDevices[device.id] else { return } logger.debug("Updated battery level for \(device.label): \(percentage) %") - pairedDevices[index].lastBatteryPercentage = percentage - flush() + deviceInfo.lastBatteryPercentage = percentage } @MainActor private func updateLastSeen(for device: Device, lastSeen: Date = .now) { - guard let index = pairedDevices.firstIndex(where: { $0.id == device.id }) else { - return // not paired + guard let deviceInfo = _pairedDevices[device.id] else { + return } logger.debug("Updated lastSeen for \(device.label): \(lastSeen) %") - pairedDevices[index].lastSeen = lastSeen - flush() + deviceInfo.lastSeen = lastSeen } @MainActor @@ -331,11 +326,6 @@ public final class PairedDevices { return task } - @MainActor - private func flush() { - _pairedDevices = _pairedDevices // update app storage - } - deinit { _peripherals.removeAll() stateSubscriptionTask = nil @@ -449,7 +439,13 @@ extension PairedDevices { batteryPercentage: batteryLevel ) - pairedDevices.append(deviceInfo) + _pairedDevices[deviceInfo.id] = deviceInfo + if let modelContainer { + modelContainer.mainContext.insert(deviceInfo) + } else { + logger.warning("PairedDevice \(device.label), \(device.id) could not be persisted on disk due to missing ModelContainer!") + } + discoveredDevices[device.id] = nil @@ -467,12 +463,15 @@ extension PairedDevices { /// - Parameter id: The Bluetooth peripheral identifier of a paired device. @MainActor public func forgetDevice(id: UUID) { - pairedDevices.removeAll { info in - info.id == id + let removed = _pairedDevices.removeValue(forKey: id) + if let removed { + modelContainer?.mainContext.delete(removed) } + discoveredDevices.removeValue(forKey: id) let device = peripherals.removeValue(forKey: id) + if let device { Task { await device.disconnect() @@ -491,6 +490,26 @@ extension PairedDevices { // MARK: - Paired Peripheral Management extension PairedDevices { + @MainActor + private func fetchAllPairedInfos() { + guard let modelContainer else { + return + } + + let context = modelContainer.mainContext + var allPairedDevices = FetchDescriptor() + allPairedDevices.includePendingChanges = true + + do { + let pairedDevices = try context.fetch(allPairedDevices) + self._pairedDevices = pairedDevices.reduce(into: [:]) { partialResult, deviceInfo in + partialResult[deviceInfo.id] = deviceInfo + } + } catch { + logger.error("Failed to fetch paired device info from disk: \(error)") + } + } + @MainActor private func syncDeviceIcons() { guard let bluetooth else { diff --git a/Tests/SpeziDevicesTests/PairedDevicesTests.swift b/Tests/SpeziDevicesTests/PairedDevicesTests.swift index a5d6f67..4cbc909 100644 --- a/Tests/SpeziDevicesTests/PairedDevicesTests.swift +++ b/Tests/SpeziDevicesTests/PairedDevicesTests.swift @@ -17,12 +17,9 @@ import XCTSpezi final class PairedDevicesTests: XCTestCase { @MainActor - func testPairDevice() async throws { // swiftlint:disable:this function_body_length + func testPairDevice() async throws { let device = MockDevice.createMockDevice() let devices = PairedDevices() - defer { - devices.clearStorage() - } // ensure PairedDevices gets injected into the MockDevice @@ -101,9 +98,6 @@ final class PairedDevicesTests: XCTestCase { func testPairingErrors() async throws { let device = MockDevice.createMockDevice() let devices = PairedDevices() - defer { - devices.clearStorage() - } withDependencyResolution { devices @@ -138,9 +132,6 @@ final class PairedDevicesTests: XCTestCase { func testPairingCancellation() async throws { let device = MockDevice.createMockDevice() let devices = PairedDevices() - defer { - devices.clearStorage() - } withDependencyResolution { devices @@ -166,9 +157,6 @@ final class PairedDevicesTests: XCTestCase { func testFailedPairing() async throws { let device = MockDevice.createMockDevice() let devices = PairedDevices() - defer { - devices.clearStorage() - } withDependencyResolution { device diff --git a/Tests/UITests/TestApp/DevicesTestView.swift b/Tests/UITests/TestApp/DevicesTestView.swift index 08c14c9..b43665c 100644 --- a/Tests/UITests/TestApp/DevicesTestView.swift +++ b/Tests/UITests/TestApp/DevicesTestView.swift @@ -55,8 +55,6 @@ struct DevicesTestView: View { } } .onAppear { - pairedDevices.clearStorage() // we clear storage for testing purposes - guard !didRegister else { return } From 0340294f01ad6b04ae35fd2a7ca091a7bc581500 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 28 Jun 2024 11:26:12 +0200 Subject: [PATCH 64/77] Revert some changes for now --- Sources/SpeziOmron/Devices/OmronWeightScale.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Sources/SpeziOmron/Devices/OmronWeightScale.swift b/Sources/SpeziOmron/Devices/OmronWeightScale.swift index 2e32eea..b6ca11d 100644 --- a/Sources/SpeziOmron/Devices/OmronWeightScale.swift +++ b/Sources/SpeziOmron/Devices/OmronWeightScale.swift @@ -66,7 +66,6 @@ public class OmronWeightScale: BluetoothDevice, Identifiable, OmronHealthDevice case .connected: switch manufacturerData?.pairingMode { case .pairingMode: - print("Device connection is NOW!") dateOfConnection = .now case .transferMode: time.synchronizeDeviceTime() @@ -79,7 +78,7 @@ public class OmronWeightScale: BluetoothDevice, Identifiable, OmronHealthDevice } @MainActor - private func handleCurrentTimeChange(_ time: CurrentTime) { + private func handleCurrentTimeChange(_ time: CurrentTime) {/* if case .pairingMode = manufacturerData?.pairingMode, let dateOfConnection, abs(Date.now.timeIntervalSince1970 - dateOfConnection.timeIntervalSince1970) < 1 { @@ -87,7 +86,7 @@ public class OmronWeightScale: BluetoothDevice, Identifiable, OmronHealthDevice // because of the notification registration. return } - +*/ Self.logger.debug("Received updated device time for \(self.label): \(String(describing: time))") let paired = pairedDevices?.signalDevicePaired(self) == true if paired { From 1fc7fad32b758f17f50b8ce4aef48f3a27f07bed Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 28 Jun 2024 14:49:02 +0200 Subject: [PATCH 65/77] Some progress with SwiftData --- Sources/SpeziDevices/HealthMeasurements.swift | 149 ++++++++++++------ .../BluetoothHealthMeasurement.swift | 147 ++++++++++++++--- .../Measurements/StoredMeasurement.swift | 36 +++-- .../Model/SavableDictionary.swift | 2 +- Sources/SpeziDevices/PairedDevices.swift | 43 +++-- .../SpeziDevicesUI/Devices/DevicesGrid.swift | 53 ++++--- .../HealthMeasurementsTests.swift | 44 +++--- .../PairedDevicesTests.swift | 18 ++- 8 files changed, 345 insertions(+), 147 deletions(-) diff --git a/Sources/SpeziDevices/HealthMeasurements.swift b/Sources/SpeziDevices/HealthMeasurements.swift index 0636e82..0a03600 100644 --- a/Sources/SpeziDevices/HealthMeasurements.swift +++ b/Sources/SpeziDevices/HealthMeasurements.swift @@ -6,11 +6,13 @@ // SPDX-License-Identifier: MIT // +import OrderedCollections import HealthKit import OSLog import Spezi import SpeziBluetooth import SpeziBluetoothServices +import SwiftData import SwiftUI @@ -92,20 +94,13 @@ public class HealthMeasurements { /// The newest measurement is always prepended. /// To clear pending measurements call ``discardMeasurement(_:)``. @MainActor public private(set) var pendingMeasurements: [HealthKitMeasurement] = [] - @MainActor @AppStorage @ObservationIgnored private var storedMeasurements: SavableDictionary @Dependency @ObservationIgnored private var bluetooth: Bluetooth? - /// Initialize the Health Measurements Module. - public required convenience init() { - self.init("edu.stanford.spezi.SpeziDevices.HealthMeasurements.measurements-default") - } + private var modelContainer: ModelContainer? - /// 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. + public required init() {} /// Initialize the Health Measurements Module with mock measurements. /// - Parameter measurements: The list of measurements to inject. @@ -116,37 +111,27 @@ public class HealthMeasurements { self.pendingMeasurements = measurements } - /// Clears all currently stored records on disk. - @_spi(TestingSupport) - @MainActor - public func clearStorage() { - storedMeasurements.removeAll() - pendingMeasurements.removeAll() - } - /// Configure the Module. @_documentation(visibility: internal) public func configure() { - Task.detached { @MainActor in - // Note, we index `storedMeasurements` by the HealthKit sample UUID. - // However, when we redo the conversion, the identifier changes. Therefore, - // we store the current container in local scope, clear everything and completely rebuild the dictionary. - let storedMeasurements = self.storedMeasurements - self.storedMeasurements.removeAll() - - for measurement in storedMeasurements.values { - self.loadMeasurement(measurement.measurement, form: measurement.device) - } + let configuration: ModelConfiguration +#if targetEnvironment(simulator) + configuration = ModelConfiguration(isStoredInMemoryOnly: true) +#else + configuration = ModelConfiguration() +#endif + + do { + self.modelContainer = try ModelContainer(for: StoredMeasurement.self, configurations: configuration) + } catch { + self.modelContainer = nil + self.logger.error("HealthMeasurements failed to initialize ModelContainer: \(error)") + return + } - // assert check that they have the same count. - assert(self.storedMeasurements.count == self.pendingMeasurements.count, "Non-matching count after initialization. Storage out of sync.") - // assert that both key sets are equal. - assert( - Set(self.storedMeasurements.keys) - .union(self.pendingMeasurements.reduce(into: Set(), { $0.insert($1.id) })) - .count == self.storedMeasurements.count, - "Inconsistent key storage in health store. Storage out of sync." - ) + + Task.detached { @MainActor in + self.fetchMeasurements() } } @@ -194,12 +179,23 @@ public class HealthMeasurements { @MainActor private func handleNewMeasurement(_ measurement: BluetoothHealthMeasurement, from source: HKDevice) { - loadMeasurement(measurement, form: source) + let id = loadMeasurement(measurement, form: source) + guard let id else { + return + } + + if let modelContainer { + let storeMeasurement = StoredMeasurement(associatedMeasurement: id, measurement: .init(from: measurement), device: source) + modelContainer.mainContext.insert(storeMeasurement) + } else { + logger.warning("Measurement \(id) could not be persisted on disk due to missing ModelContainer!") + } + shouldPresentMeasurements = true } @MainActor - private func loadMeasurement(_ measurement: BluetoothHealthMeasurement, form source: HKDevice) { + private func loadMeasurement(_ measurement: BluetoothHealthMeasurement, form source: HKDevice) -> UUID? { let healthKitMeasurement: HealthKitMeasurement switch measurement { case let .weight(measurement, feature): @@ -215,7 +211,7 @@ public class HealthMeasurements { guard let bloodPressureSample else { logger.debug("Discarding invalid blood pressure measurement ...") - return + return nil } logger.debug("Measurement loaded: \(String(describing: measurement))") @@ -225,7 +221,7 @@ public class HealthMeasurements { // prepend to pending measurements pendingMeasurements.insert(healthKitMeasurement, at: 0) - storedMeasurements[healthKitMeasurement.id] = StoredMeasurement(measurement: measurement, device: source) + return healthKitMeasurement.id } /// Discard a pending measurement. @@ -242,12 +238,17 @@ public class HealthMeasurements { return false } let element = self.pendingMeasurements.remove(at: index) - let value = storedMeasurements.removeValue(forKey: element.id) - if let value { - logger.debug("Discarding measurement \(String(describing: value.measurement))") - } else { - logger.error("Couldn't locate stored measurements when discarding pending measurement.") + + let id = element.id // we need to capture id, element.id results in #Predicate to not compile + do { + try modelContainer?.mainContext.delete( + model: StoredMeasurement.self, + where: #Predicate { $0.associatedMeasurement == id } + ) + } catch { + logger.error("Failed to remove measurement from storage: \(error)") } + return true } } @@ -256,6 +257,62 @@ public class HealthMeasurements { extension HealthMeasurements: Module, EnvironmentAccessible, DefaultInitializable {} +extension HealthMeasurements { + @MainActor + func refreshFetchingMeasurements() throws { + pendingMeasurements.removeAll() + if let modelContainer, modelContainer.mainContext.hasChanges { + try modelContainer.mainContext.save() + } + fetchMeasurements() + } + + @MainActor + private func fetchMeasurements() { + guard let modelContainer else { + return + } + + var fetchAll = FetchDescriptor() + // TODO: fetchAll.includePendingChanges = true + + let context = modelContainer.mainContext + let storedMeasurements: [StoredMeasurement] + do { + storedMeasurements = try context.fetch(fetchAll) + } catch { + logger.error("Failed to retrieve stored measurements from disk \(error)") + 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)") + + return + /* + guard let id = loadMeasurement(storedMeasurement.measurement, form: storedMeasurement.device) else { + context.delete(storedMeasurement) + continue + } + + // Note, we associate `storedMeasurements` by the HealthKit sample UUID. + // 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? + } +} + + extension HealthMeasurements { /// Call in preview simulator wrappers. /// diff --git a/Sources/SpeziDevices/Measurements/BluetoothHealthMeasurement.swift b/Sources/SpeziDevices/Measurements/BluetoothHealthMeasurement.swift index 7f61b97..c38728a 100644 --- a/Sources/SpeziDevices/Measurements/BluetoothHealthMeasurement.swift +++ b/Sources/SpeziDevices/Measurements/BluetoothHealthMeasurement.swift @@ -17,6 +17,103 @@ 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. + // TODO: 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. + // TODO: public let diastolicValue: MedFloat16 + /// The Mean Arterial Pressure (MAP) + /// + /// The unit of this value is defined by the ``unit-swift.property`` property. + // TODO: 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. + // TODO: public let timeStamp: DateTime? + + /// The pulse rate in beats per minute. + // TODO: 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. + // TODO: public let userId: UInt8? + + /// Additional metadata information of a blood pressure measurement. + // TOOD: public let measurementStatus: UInt16? + + + init(from measurement: BloodPressureMeasurement) { + // TODO: self.systolicValue = measurement.systolicValue + // TODO: self.diastolicValue = measurement.diastolicValue + // TODO: self.meanArterialPressure = measurement.meanArterialPressure + self.unit = measurement.unit.rawValue + // TODO: self.timeStamp = measurement.timeStamp + // TODO: self.pulseRate = measurement.pulseRate + // TODO: self.userId = measurement.userId + // TODO: self.measurementStatus = measurement.measurementStatus?.rawValue + } +} + +struct SwiftDataBluetoothHealthMeasurementWorkaroundContainer: Codable { + enum MeasurementType: String, Codable { + case weight + case bloodPressure + } + + private let type: MeasurementType + + private var bloodPressureMeasurement2: BloodPressureMeasurementCopy? + private var bloodPressureFeatures: BloodPressureFeature.RawValue? // TODO: non-transient! + + // TODO: private var weightMeasurement: WeightMeasurement? + private var weightScaleFeatures: WeightScaleFeature.RawValue? + + init(from measurement: BluetoothHealthMeasurement) { + switch measurement { + case let .bloodPressure(measurement, feature): + type = .bloodPressure + bloodPressureMeasurement2 = BloodPressureMeasurementCopy(from: measurement) + // TODO: bloodPressureMeasurement = .init(from: measurement) + bloodPressureFeatures = feature.rawValue + case let .weight(measurement, features): + type = .weight + // bloodPressureMeasurement2 = BloodPressureMeasurementCopy(from: .mock()) + // TODO: 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.bloodPressureMeasurement2, forKey: .bloodPressureMeasurement2) + try container.encodeIfPresent(self.bloodPressureFeatures, forKey: .bloodPressureFeatures) + // TOOD: try container.encodeIfPresent(self.weightScaleFeatures, forKey: .weightScaleFeatures) + } } @@ -24,45 +121,55 @@ extension BluetoothHealthMeasurement: Hashable, Sendable {} extension BluetoothHealthMeasurement: Codable { + enum MeasurementType: String, Codable { + case weight + case bloodPressure + } + private enum CodingKeys: String, CodingKey { case type - case measurement - case features - } + case bloodPressure + case bloodPressureFeatures - private enum MeasurementType: String, Codable { case weight - case bloodPressure + case weightScaleFeatures } public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) + do { + print("Decoding \(Self.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: .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) + 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 } } 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: .measurement) - try container.encode(feature, forKey: .features) + try container.encode(measurement, forKey: .bloodPressure) + try container.encode(feature, forKey: .bloodPressureFeatures) case let .bloodPressure(measurement, feature): try container.encode(MeasurementType.bloodPressure, forKey: .type) - try container.encode(measurement, forKey: .measurement) - try container.encode(feature, forKey: .features) + try container.encode(measurement, forKey: .weight) + try container.encode(feature, forKey: .weightScaleFeatures) } } } diff --git a/Sources/SpeziDevices/Measurements/StoredMeasurement.swift b/Sources/SpeziDevices/Measurements/StoredMeasurement.swift index 1f680da..a67dc0f 100644 --- a/Sources/SpeziDevices/Measurements/StoredMeasurement.swift +++ b/Sources/SpeziDevices/Measurements/StoredMeasurement.swift @@ -8,6 +8,7 @@ import HealthKit +import SwiftData private struct CodableHKDevice { @@ -21,16 +22,23 @@ private struct CodableHKDevice { let udiDeviceIdentifier: String? } +private struct CodableHKQuantitySample { -struct StoredMeasurement { - let measurement: BluetoothHealthMeasurement +} + + +@Model +final class StoredMeasurement { + @Attribute(.unique) var associatedMeasurement: UUID + let measurement: SwiftDataBluetoothHealthMeasurementWorkaroundContainer fileprivate let codableDevice: CodableHKDevice var device: HKDevice { codableDevice.hkDevice } - init(measurement: BluetoothHealthMeasurement, device: HKDevice) { + init(associatedMeasurement: UUID, measurement: SwiftDataBluetoothHealthMeasurementWorkaroundContainer, device: HKDevice) { + self.associatedMeasurement = associatedMeasurement self.measurement = measurement self.codableDevice = CodableHKDevice(from: device) } @@ -39,13 +47,6 @@ struct StoredMeasurement { extension CodableHKDevice: Codable {} -extension StoredMeasurement: Codable { - private enum CodingKeys: String, CodingKey { - case measurement - case codableDevice = "device" - } -} - extension CodableHKDevice { var hkDevice: HKDevice { @@ -72,3 +73,18 @@ extension CodableHKDevice { self.udiDeviceIdentifier = hkDevice.udiDeviceIdentifier } } + + +/* +extension CodableHKQuantitySample { + var hkSample: HKQuantitySample { + HKQuantitySample( + type: <#T##HKQuantityType#>, + quantity: <#T##HKQuantity#>, + start: <#T##Date#>, + end: <#T##Date#>, + device: <#T##HKDevice?#>, + metadata: <#T##[String : Any]?#> + ) + } +}*/ diff --git a/Sources/SpeziDevices/Model/SavableDictionary.swift b/Sources/SpeziDevices/Model/SavableDictionary.swift index 6d133fd..36d92ca 100644 --- a/Sources/SpeziDevices/Model/SavableDictionary.swift +++ b/Sources/SpeziDevices/Model/SavableDictionary.swift @@ -10,7 +10,7 @@ import OrderedCollections import OSLog -struct SavableDictionary { +struct SavableDictionary { // TODO: remove both! private var storage: OrderedDictionary var keys: OrderedSet { diff --git a/Sources/SpeziDevices/PairedDevices.swift b/Sources/SpeziDevices/PairedDevices.swift index 9e37f39..e45b441 100644 --- a/Sources/SpeziDevices/PairedDevices.swift +++ b/Sources/SpeziDevices/PairedDevices.swift @@ -93,11 +93,14 @@ public final class PairedDevices { /// Collection of discovered devices indexed by their Bluetooth identifier. @MainActor public private(set) var discoveredDevices: OrderedDictionary = [:] /// The collection of paired devices that are persisted on disk. - @MainActor public var pairedDevices: [PairedDeviceInfo] { - Array(_pairedDevices.values) + @MainActor public var pairedDevices: [PairedDeviceInfo]? { // swiftlint:disable:this discouraged_optional_collection + didLoadDevices + ? Array(_pairedDevices.values) + : nil } @MainActor private var _pairedDevices: OrderedDictionary = [:] + @MainActor private var didLoadDevices = false /// Bluetooth Peripheral instances of paired devices. @MainActor private(set) var peripherals: [UUID: any PairableDevice] = [:] @@ -119,7 +122,7 @@ public final class PairedDevices { /// /// 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 + (pairedDevices?.isEmpty == true && !everPairedDevice) || shouldPresentDevicePairing } private var stateSubscriptionTask: Task? { @@ -136,11 +139,11 @@ public final class PairedDevices { /// Configures the Module. @_documentation(visibility: internal) public func configure() { - if bluetooth != nil { + if bluetooth == nil { self.logger.warning("PairedDevices Module initialized without Bluetooth dependency!") } - var configuration: ModelConfiguration + let configuration: ModelConfiguration #if targetEnvironment(simulator) configuration = ModelConfiguration(isStoredInMemoryOnly: true) #else @@ -160,7 +163,7 @@ public final class PairedDevices { self.syncDeviceIcons() // make sure assets are up to date - guard !self.pairedDevices.isEmpty else { + guard !self._pairedDevices.isEmpty else { return // no devices paired, no need to power up central } @@ -181,7 +184,7 @@ public final class PairedDevices { /// - Returns: Returns `true` if the given device is paired. @MainActor public func isPaired(_ device: Device) -> Bool { - pairedDevices.contains { $0.id == device.id } + _pairedDevices[device.id] != nil } /// Update the user-chosen name of a paired device. @@ -478,7 +481,7 @@ extension PairedDevices { } } - if pairedDevices.isEmpty { + if _pairedDevices.isEmpty { Task { await cancelSubscription() } @@ -505,9 +508,24 @@ extension PairedDevices { self._pairedDevices = pairedDevices.reduce(into: [:]) { partialResult, deviceInfo in partialResult[deviceInfo.id] = deviceInfo } + didLoadDevices = true + logger.debug("Initialized PairedDevices with \(self._pairedDevices.count) paired devices!") } catch { logger.error("Failed to fetch paired device info from disk: \(error)") } + didLoadDevices = true + } + + @MainActor + func refreshPairedDevices() throws { + _pairedDevices.removeAll() + didLoadDevices = false + + if let modelContainer, modelContainer.mainContext.hasChanges { + try modelContainer.mainContext.save() + } + + fetchAllPairedInfos() } @MainActor @@ -518,7 +536,7 @@ extension PairedDevices { let configuredDevices = bluetooth.configuredPairableDevices - for deviceInfo in pairedDevices { + for deviceInfo in _pairedDevices.values { guard let deviceType = configuredDevices[deviceInfo.deviceType] else { continue } @@ -529,7 +547,7 @@ extension PairedDevices { @MainActor private func setupBluetoothStateSubscription() async { - assert(!pairedDevices.isEmpty, "Bluetooth State subscription doesn't need to be set up without any paired devices.") + assert(!_pairedDevices.isEmpty, "Bluetooth State subscription doesn't need to be set up without any paired devices.") guard let bluetooth else { return @@ -555,7 +573,7 @@ extension PairedDevices { @MainActor private func cancelSubscription() async { - assert(pairedDevices.isEmpty, "Bluetooth State subscription was tried to be cancelled even though devices were still paired.") + assert(_pairedDevices.isEmpty, "Bluetooth State subscription was tried to be cancelled even though devices were still paired.") assert(peripherals.isEmpty, "Peripherals were unexpectedly not empty.") stateSubscriptionTask = nil @@ -591,7 +609,7 @@ extension PairedDevices { let configuredDevices = bluetooth.configuredPairableDevices await withDiscardingTaskGroup { group in - for deviceInfo in self.pairedDevices { + for deviceInfo in self._pairedDevices.values { group.addTask { @MainActor in guard self.peripherals[deviceInfo.id] == nil else { return @@ -599,6 +617,7 @@ extension PairedDevices { guard let deviceType = configuredDevices[deviceInfo.deviceType] else { self.logger.error("Unsupported device type \"\(deviceInfo.deviceType)\" for paired device \(deviceInfo.name).") + deviceInfo.notLocatable = true return } await self.handleDeviceRetrieval(for: deviceInfo, deviceType: deviceType) diff --git a/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift b/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift index 6255263..aa049ab 100644 --- a/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift +++ b/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift @@ -13,7 +13,7 @@ import TipKit /// Grid view of paired devices. public struct DevicesGrid: View { - private let devices: [PairedDeviceInfo] + private let devices: [PairedDeviceInfo]? // swiftlint:disable:this discouraged_optional_collection @Binding private var presentingDevicePairing: Bool @State private var detailedDeviceInfo: PairedDeviceInfo? @@ -27,35 +27,39 @@ public struct DevicesGrid: View { public var body: some View { Group { - if devices.isEmpty { - ZStack { - VStack { - TipView(ForgetDeviceTip.instance) - .padding([.leading, .trailing], 20) - Spacer() + if let devices { + if devices.isEmpty { + ZStack { + VStack { + TipView(ForgetDeviceTip.instance) + .padding([.leading, .trailing], 20) + Spacer() + } + DevicesUnavailableView(presentingDevicePairing: $presentingDevicePairing) } - DevicesUnavailableView(presentingDevicePairing: $presentingDevicePairing) - } - } else { - ScrollView(.vertical) { - VStack(spacing: 16) { - TipView(ForgetDeviceTip.instance) - .tipBackground(Color(uiColor: .secondarySystemGroupedBackground)) + } else { + ScrollView(.vertical) { + VStack(spacing: 16) { + TipView(ForgetDeviceTip.instance) + .tipBackground(Color(uiColor: .secondarySystemGroupedBackground)) - LazyVGrid(columns: gridItems) { - ForEach(devices) { device in - Button { - detailedDeviceInfo = device - } label: { - DeviceTile(device) - } + LazyVGrid(columns: gridItems) { + ForEach(devices) { device in + Button { + detailedDeviceInfo = device + } label: { + DeviceTile(device) + } .foregroundStyle(.primary) + } } } - } .padding([.leading, .trailing], 20) - } + } .background(Color(uiColor: .systemGroupedBackground)) + } + } else { + ProgressView() } } .navigationTitle("Devices") @@ -76,7 +80,8 @@ public struct DevicesGrid: View { /// - Parameters: /// - devices: The list of paired devices to display. /// - presentingDevicePairing: Binding to indicate if the device discovery menu should be presented. - public init(devices: [PairedDeviceInfo], presentingDevicePairing: Binding) { + public init(devices: [PairedDeviceInfo]?, presentingDevicePairing: Binding) { + // swiftlint:disable:previous discouraged_optional_collection self.devices = devices self._presentingDevicePairing = presentingDevicePairing } diff --git a/Tests/SpeziDevicesTests/HealthMeasurementsTests.swift b/Tests/SpeziDevicesTests/HealthMeasurementsTests.swift index b38a838..f563a08 100644 --- a/Tests/SpeziDevicesTests/HealthMeasurementsTests.swift +++ b/Tests/SpeziDevicesTests/HealthMeasurementsTests.swift @@ -9,7 +9,7 @@ import HealthKit @_spi(TestingSupport) import SpeziBluetooth import SpeziBluetoothServices -@_spi(TestingSupport) import SpeziDevices +@_spi(TestingSupport) @testable import SpeziDevices import XCTest @@ -18,9 +18,6 @@ final class HealthMeasurementsTests: XCTestCase { func testReceivingWeightMeasurements() async throws { let device = MockDevice.createMockDevice(weightMeasurement: .mock(additionalInfo: .init(bmi: 230, height: 1790))) let measurements = HealthMeasurements() - defer { - measurements.clearStorage() - } measurements.configureReceivingMeasurements(for: device, on: device.weightScale) @@ -69,9 +66,6 @@ final class HealthMeasurementsTests: XCTestCase { func testReceivingBloodPressureMeasurements() async throws { let device = MockDevice.createMockDevice() let measurements = HealthMeasurements() - defer { - measurements.clearStorage() - } measurements.configureReceivingMeasurements(for: device, on: device.bloodPressure) @@ -121,29 +115,30 @@ final class HealthMeasurementsTests: XCTestCase { @MainActor func testMeasurementStorage() async throws { - let measurements1 = HealthMeasurements() - let measurements2 = HealthMeasurements() - defer { - measurements2.clearStorage() - } + let measurements = HealthMeasurements() + + measurements.configure() // init model container + try await Task.sleep(for: .milliseconds(50)) - measurements1.loadMockWeightMeasurement() - measurements1.loadMockBloodPressureMeasurement() + measurements.loadMockWeightMeasurement() + measurements.loadMockBloodPressureMeasurement() - XCTAssertEqual(measurements1.pendingMeasurements.count, 2) - XCTAssertEqual(measurements2.pendingMeasurements.count, 0) + XCTAssertEqual(measurements.pendingMeasurements.count, 2) + + print("WE ARE HERE!") + + // TODO: let previousMeasurements = measurements.pendingMeasurements - measurements2.configure() + try measurements.refreshFetchingMeasurements() // clear pending measurements and fetch again from storage try await Task.sleep(for: .milliseconds(50)) - XCTAssertEqual(measurements2.pendingMeasurements.count, 2) + + XCTAssertEqual(measurements.pendingMeasurements.count, 2) // tests that order stays same over storage retrieval // Restoring from disk doesn't preserve HealthKit UUIDs - guard case .bloodPressure = measurements1.pendingMeasurements.first, - case .bloodPressure = measurements2.pendingMeasurements.first, - case .weight = measurements1.pendingMeasurements.last, - case .weight = measurements2.pendingMeasurements.last else { - XCTFail("Order of measurements doesn't match: \(measurements1.pendingMeasurements) vs. \(measurements2.pendingMeasurements)") + guard case .bloodPressure = measurements.pendingMeasurements.first, + case .weight = measurements.pendingMeasurements.last else { + XCTFail("Order of measurements doesn't match: \(measurements.pendingMeasurements)") return } } @@ -152,9 +147,6 @@ final class HealthMeasurementsTests: XCTestCase { func testDiscardingMeasurements() async throws { let device = MockDevice.createMockDevice() let measurements = HealthMeasurements() - defer { - measurements.clearStorage() - } measurements.configureReceivingMeasurements(for: device, on: device.bloodPressure) measurements.configureReceivingMeasurements(for: device, on: device.weightScale) diff --git a/Tests/SpeziDevicesTests/PairedDevicesTests.swift b/Tests/SpeziDevicesTests/PairedDevicesTests.swift index 4cbc909..00d75c9 100644 --- a/Tests/SpeziDevicesTests/PairedDevicesTests.swift +++ b/Tests/SpeziDevicesTests/PairedDevicesTests.swift @@ -8,7 +8,7 @@ @_spi(TestingSupport) import SpeziBluetooth import SpeziBluetoothServices -@_spi(TestingSupport) import SpeziDevices +@_spi(TestingSupport) @testable import SpeziDevices import SpeziFoundation import XCTest import XCTestExtensions @@ -42,8 +42,8 @@ final class PairedDevicesTests: XCTestCase { XCTAssertTrue(devices.isPaired(device)) XCTAssertTrue(devices.isConnected(device: device.id)) - XCTAssertEqual(devices.pairedDevices.count, 1) - let deviceInfo = try XCTUnwrap(devices.pairedDevices.first) + XCTAssertEqual(devices.pairedDevices?.count, 1) + let deviceInfo = try XCTUnwrap(devices.pairedDevices?.first) XCTAssertEqual(deviceInfo.id, device.id) XCTAssertEqual(deviceInfo.deviceType, MockDevice.deviceTypeIdentifier) @@ -71,10 +71,12 @@ final class PairedDevicesTests: XCTestCase { XCTAssertEqual(deviceInfo.name, "Custom Name") let recentLastSeen = deviceInfo.lastSeen - try { // test storage persistence! - let devices2 = PairedDevices() - XCTAssertEqual(devices2.pairedDevices.count, 1) - let info0 = try XCTUnwrap(devices2.pairedDevices.first) + + // test storage persistence! + try devices.refreshPairedDevices() + try { + XCTAssertEqual(devices.pairedDevices?.count, 1) + let info0 = try XCTUnwrap(devices.pairedDevices?.first) XCTAssertEqual(info0.name, "Custom Name") XCTAssertEqual(info0.lastBatteryPercentage, 71) XCTAssertEqual(info0.lastSeen, recentLastSeen) @@ -90,7 +92,7 @@ final class PairedDevicesTests: XCTestCase { try await Task.sleep(for: .milliseconds(50)) XCTAssertEqual(device.state, .disconnected) - XCTAssertTrue(devices.pairedDevices.isEmpty) + XCTAssertEqual(devices.pairedDevices?.isEmpty, true) XCTAssertTrue(devices.discoveredDevices.isEmpty) } From 169da422b08bfe24ce2a894b176c409fb3b7ae2e Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 28 Jun 2024 15:10:31 +0200 Subject: [PATCH 66/77] Seems to consistently NOT crash for now --- Sources/SpeziDevices/HealthMeasurements.swift | 6 +- .../BluetoothHealthMeasurement.swift | 132 +++++++++++++++--- .../HealthMeasurementsTests.swift | 1 + 3 files changed, 113 insertions(+), 26 deletions(-) diff --git a/Sources/SpeziDevices/HealthMeasurements.swift b/Sources/SpeziDevices/HealthMeasurements.swift index 0a03600..41fcc3b 100644 --- a/Sources/SpeziDevices/HealthMeasurements.swift +++ b/Sources/SpeziDevices/HealthMeasurements.swift @@ -294,9 +294,7 @@ extension HealthMeasurements { print("Otherwise asdf: \(storedMeasurement.device)") print("Sample: \(storedMeasurement.measurement)") - return - /* - guard let id = loadMeasurement(storedMeasurement.measurement, form: storedMeasurement.device) else { + guard let id = loadMeasurement(storedMeasurement.measurement.measurement, form: storedMeasurement.device) else { context.delete(storedMeasurement) continue } @@ -306,7 +304,7 @@ extension HealthMeasurements { // Therefore, we need to make sure to update all associated ids after loading. storedMeasurement.associatedMeasurement = id print("hasChanges1: \(context.hasChanges == true)") - try? context.save()*/ + try? context.save() } // TODO: save all? } diff --git a/Sources/SpeziDevices/Measurements/BluetoothHealthMeasurement.swift b/Sources/SpeziDevices/Measurements/BluetoothHealthMeasurement.swift index c38728a..43f9a0c 100644 --- a/Sources/SpeziDevices/Measurements/BluetoothHealthMeasurement.swift +++ b/Sources/SpeziDevices/Measurements/BluetoothHealthMeasurement.swift @@ -34,25 +34,25 @@ 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. - // TODO: public let systolicValue: MedFloat16 + 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. - // TODO: public let diastolicValue: MedFloat16 + public let diastolicValue: MedFloat16 /// The Mean Arterial Pressure (MAP) /// /// The unit of this value is defined by the ``unit-swift.property`` property. - // TODO: public let meanArterialPressure: MedFloat16 + 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. - // TODO: public let timeStamp: DateTime? + public let timeStamp: DateTime? /// The pulse rate in beats per minute. - // TODO: public let pulseRate: MedFloat16? + public let pulseRate: UInt16? /// The associated user of the blood pressure measurement. /// @@ -60,21 +60,69 @@ private struct BloodPressureMeasurementCopy: Codable { /// - 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. - // TODO: public let userId: UInt8? + public let userId: UInt8? /// Additional metadata information of a blood pressure measurement. - // TOOD: public let measurementStatus: UInt16? + public let measurementStatus: UInt16? + var measurement: BloodPressureMeasurement { + .init( + systolic: systolicValue, + diastolic: diastolicValue, + meanArterialPressure: meanArterialPressure, + unit: .init(rawValue: unit) ?? .mmHg, + timeStamp: timeStamp, + pulseRate: pulseRate.map { MedFloat16(bitPattern: $0) }, + userId: userId, + measurementStatus: measurementStatus.map { .init(rawValue: $0) } + ) + } + init(from measurement: BloodPressureMeasurement) { - // TODO: self.systolicValue = measurement.systolicValue - // TODO: self.diastolicValue = measurement.diastolicValue - // TODO: self.meanArterialPressure = measurement.meanArterialPressure + self.systolicValue = measurement.systolicValue + self.diastolicValue = measurement.diastolicValue + self.meanArterialPressure = measurement.meanArterialPressure self.unit = measurement.unit.rawValue - // TODO: self.timeStamp = measurement.timeStamp - // TODO: self.pulseRate = measurement.pulseRate - // TODO: self.userId = measurement.userId - // TODO: self.measurementStatus = measurement.measurementStatus?.rawValue + self.timeStamp = measurement.timeStamp + self.pulseRate = measurement.pulseRate?.bitPattern + 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(UInt16.self, forKey: .pulseRate) + self.userId = try container.decodeIfPresent(UInt8.self, forKey: .userId) + self.measurementStatus = try container.decodeIfPresent(UInt16.self, forKey: .measurementStatus) } } @@ -86,23 +134,41 @@ struct SwiftDataBluetoothHealthMeasurementWorkaroundContainer: Codable { private let type: MeasurementType - private var bloodPressureMeasurement2: BloodPressureMeasurementCopy? - private var bloodPressureFeatures: BloodPressureFeature.RawValue? // TODO: non-transient! + private var bloodPressureMeasurement: BloodPressureMeasurementCopy? + private var bloodPressureFeatures: BloodPressureFeature.RawValue? - // TODO: private var weightMeasurement: WeightMeasurement? + 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 - bloodPressureMeasurement2 = BloodPressureMeasurementCopy(from: measurement) - // TODO: bloodPressureMeasurement = .init(from: measurement) + 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()) - // TODO: weightMeasurement = measurement + weightMeasurement = measurement weightScaleFeatures = features.rawValue } } @@ -110,9 +176,31 @@ struct SwiftDataBluetoothHealthMeasurementWorkaroundContainer: Codable { 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.bloodPressureMeasurement2, forKey: .bloodPressureMeasurement2) + try container.encodeIfPresent(self.bloodPressureMeasurement, forKey: .bloodPressureMeasurement) try container.encodeIfPresent(self.bloodPressureFeatures, forKey: .bloodPressureFeatures) - // TOOD: try container.encodeIfPresent(self.weightScaleFeatures, forKey: .weightScaleFeatures) + 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) + } } } diff --git a/Tests/SpeziDevicesTests/HealthMeasurementsTests.swift b/Tests/SpeziDevicesTests/HealthMeasurementsTests.swift index f563a08..f2e9b67 100644 --- a/Tests/SpeziDevicesTests/HealthMeasurementsTests.swift +++ b/Tests/SpeziDevicesTests/HealthMeasurementsTests.swift @@ -136,6 +136,7 @@ final class HealthMeasurementsTests: XCTestCase { // tests that order stays same over storage retrieval // Restoring from disk doesn't preserve HealthKit UUIDs + // TODO: order is not always guaranteed! => probably also a problem for the PairedDevices thingy! guard case .bloodPressure = measurements.pendingMeasurements.first, case .weight = measurements.pendingMeasurements.last else { XCTFail("Order of measurements doesn't match: \(measurements.pendingMeasurements)") From a4046a82dc8b99be048c6c884af254a9b5f157cf Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Mon, 1 Jul 2024 13:52:04 +0200 Subject: [PATCH 67/77] Replace carousel with TabView --- .spi.yml | 4 +- Package.swift | 2 +- .../BluetoothHealthMeasurement.swift | 8 ++-- .../ConfirmMeasurementButton.swift | 1 - .../MeasurementRecordedSheet.swift | 45 ++++++++++--------- .../Pairing/PairDeviceView.swift | 11 +++++ 6 files changed, 44 insertions(+), 27 deletions(-) diff --git a/.spi.yml b/.spi.yml index c7bba65..50cfabb 100644 --- a/.spi.yml +++ b/.spi.yml @@ -11,4 +11,6 @@ builder: configs: - platform: ios documentation_targets: - - SpeziOmron + - SpeziDevices + - SpeziDevicesUI + - SpeziOmron diff --git a/Package.swift b/Package.swift index 96328db..c310507 100644 --- a/Package.swift +++ b/Package.swift @@ -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", from: "2.0.0"), + .package(url: "https://github.com/StanfordSpezi/SpeziNetworking", branch: "feature/raw-representable"), .package(url: "https://github.com/StanfordBDHG/XCTestExtensions.git", .upToNextMinor(from: "0.4.11")), .package(url: "https://github.com/JWAutumn/ACarousel", .upToNextMinor(from: "0.2.0")) ] + swiftLintPackage(), diff --git a/Sources/SpeziDevices/Measurements/BluetoothHealthMeasurement.swift b/Sources/SpeziDevices/Measurements/BluetoothHealthMeasurement.swift index 43f9a0c..abdfe7d 100644 --- a/Sources/SpeziDevices/Measurements/BluetoothHealthMeasurement.swift +++ b/Sources/SpeziDevices/Measurements/BluetoothHealthMeasurement.swift @@ -52,7 +52,7 @@ private struct BloodPressureMeasurementCopy: Codable { public let timeStamp: DateTime? /// The pulse rate in beats per minute. - public let pulseRate: UInt16? + public let pulseRate: MedFloat16? /// The associated user of the blood pressure measurement. /// @@ -73,7 +73,7 @@ private struct BloodPressureMeasurementCopy: Codable { meanArterialPressure: meanArterialPressure, unit: .init(rawValue: unit) ?? .mmHg, timeStamp: timeStamp, - pulseRate: pulseRate.map { MedFloat16(bitPattern: $0) }, + pulseRate: pulseRate, userId: userId, measurementStatus: measurementStatus.map { .init(rawValue: $0) } ) @@ -85,7 +85,7 @@ private struct BloodPressureMeasurementCopy: Codable { self.meanArterialPressure = measurement.meanArterialPressure self.unit = measurement.unit.rawValue self.timeStamp = measurement.timeStamp - self.pulseRate = measurement.pulseRate?.bitPattern + self.pulseRate = measurement.pulseRate self.userId = measurement.userId self.measurementStatus = measurement.measurementStatus?.rawValue } @@ -120,7 +120,7 @@ private struct BloodPressureMeasurementCopy: Codable { 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(UInt16.self, forKey: .pulseRate) + 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) } diff --git a/Sources/SpeziDevicesUI/Measurements/ConfirmMeasurementButton.swift b/Sources/SpeziDevicesUI/Measurements/ConfirmMeasurementButton.swift index f52a833..4380ddf 100644 --- a/Sources/SpeziDevicesUI/Measurements/ConfirmMeasurementButton.swift +++ b/Sources/SpeziDevicesUI/Measurements/ConfirmMeasurementButton.swift @@ -53,7 +53,6 @@ struct ConfirmMeasurementButton: View { DiscardButton(viewState: $viewState, discard: discard) .padding(.top, 8) } - .padding() } init(viewState: Binding, confirm: @escaping () async throws -> Void, discard: @escaping () -> Void) { diff --git a/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift b/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift index de3add2..b719968 100644 --- a/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift +++ b/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift @@ -25,15 +25,26 @@ public struct MeasurementRecordedSheet: View { @Environment(\.dismiss) private var dismiss @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @State private var selectedMeasurement: HealthKitMeasurement? + @State private var viewState = ViewState.idle - @State private var selectedMeasurementIndex: Int = 0 @State private var dynamicDetent: PresentationDetent = .medium - @MainActor private var selectedMeasurement: HealthKitMeasurement? { - guard selectedMeasurementIndex < measurements.pendingMeasurements.count else { - return nil + @MainActor + private var forcedUnwrappedMeasurement: Binding { + Binding { + guard let selectedMeasurement else { + guard let selectedMeasurement = measurements.pendingMeasurements.first else { + preconditionFailure("Entered code path where selectedMeasurement was not set.") + } + self.selectedMeasurement = selectedMeasurement + return selectedMeasurement + } + return selectedMeasurement + } set: { newValue in + selectedMeasurement = newValue } - return measurements.pendingMeasurements[selectedMeasurementIndex] + } @MainActor private var supportedTypeSize: ClosedRange { @@ -79,6 +90,7 @@ public struct MeasurementRecordedSheet: View { GeometryReader { proxy in Color.clear .task { + // TODO: doesn't work? dynamicDetent = .height(proxy.size.height) } } @@ -94,12 +106,16 @@ public struct MeasurementRecordedSheet: View { @ViewBuilder @MainActor private var content: some View { if measurements.pendingMeasurements.count > 1 { - HStack { - ACarousel(measurements.pendingMeasurements, index: $selectedMeasurementIndex, spacing: 0, headspace: 0) { measurement in + TabView(selection: forcedUnwrappedMeasurement) { + ForEach(measurements.pendingMeasurements) { measurement in MeasurementLayer(measurement: measurement) + .padding(.bottom, 35) + .tag(measurement) } } - CarouselDots(count: measurements.pendingMeasurements.count, selectedIndex: $selectedMeasurementIndex) + .scaledToFill() + .tabViewStyle(.page) + .indexViewStyle(.page(backgroundDisplayMode: .always)) } else if let measurement = measurements.pendingMeasurements.first { MeasurementLayer(measurement: measurement) } @@ -119,7 +135,6 @@ public struct MeasurementRecordedSheet: View { } measurements.discardMeasurement(selectedMeasurement) - ensureCorrectIndex() logger.info("Saved measurement: \(String(describing: selectedMeasurement))") dismiss() // TODO: maintain to show last measurement when dismissing! @@ -128,7 +143,6 @@ public struct MeasurementRecordedSheet: View { return } measurements.discardMeasurement(selectedMeasurement) - ensureCorrectIndex() if measurements.pendingMeasurements.isEmpty { dismiss() // TODO: maintain to show last measurement when dismissing! @@ -141,16 +155,6 @@ public struct MeasurementRecordedSheet: View { public init(save saveSamples: @escaping ([HKSample]) async throws -> Void) { self.saveSamples = saveSamples } - - - @MainActor - @discardableResult - private func ensureCorrectIndex() -> EmptyView { - if selectedMeasurementIndex >= measurements.pendingMeasurements.count { - selectedMeasurementIndex = max(0, measurements.pendingMeasurements.count - 1) - } - return EmptyView() - } } @@ -197,6 +201,7 @@ public struct MeasurementRecordedSheet: View { MeasurementRecordedSheet { samples in print("Saving samples \(samples)") } + .dynamicTypeSize(.accessibility3) } .previewWith { HealthMeasurements(mock: [ diff --git a/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift b/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift index 0539318..5f34443 100644 --- a/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift +++ b/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift @@ -39,11 +39,22 @@ struct PairDeviceView: View where Collection var body: some View { PaneContent(title: "Pair Accessory", subtitle: "Do you want to pair \(selectedDeviceName) with the \(appName) app?") { if devices.count > 1 { + TabView { // TODO: tab index + ForEach(devices, id: \.id) { device in + AccessoryImageView(device) + .padding(.bottom, 35) + } + } + .tabViewStyle(.page) + .indexViewStyle(.page(backgroundDisplayMode: .always)) + /* + TODO: carousel ACarousel(devices, id: \.id, index: $selectedDeviceIndex, spacing: 0, headspace: 0) { device in AccessoryImageView(device) } .frame(maxHeight: 150) CarouselDots(count: devices.count, selectedIndex: $selectedDeviceIndex) + */ } else if let device = devices.first { AccessoryImageView(device) } From a7283f9badca5deab634343fe44633d02cc93f40 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Mon, 1 Jul 2024 16:25:25 +0200 Subject: [PATCH 68/77] Remove ACarousel, replacing with TabView --- Package.swift | 6 +- .../Model/SavableCollection.swift | 100 ------------------ .../MeasurementRecordedSheet.swift | 38 ++----- .../Pairing/PairDeviceView.swift | 53 +++++----- .../Resources/Localizable.xcstrings | 20 ---- .../SpeziDevicesUI/Utils/CarouselDots.swift | 98 ----------------- Sources/SpeziDevicesUI/Utils/IndexCount.swift | 21 ---- 7 files changed, 39 insertions(+), 297 deletions(-) delete mode 100644 Sources/SpeziDevices/Model/SavableCollection.swift delete mode 100644 Sources/SpeziDevicesUI/Utils/CarouselDots.swift delete mode 100644 Sources/SpeziDevicesUI/Utils/IndexCount.swift diff --git a/Package.swift b/Package.swift index c310507..88b55ee 100644 --- a/Package.swift +++ b/Package.swift @@ -38,8 +38,7 @@ let package = Package( .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/StanfordBDHG/XCTestExtensions.git", .upToNextMinor(from: "0.4.11")), - .package(url: "https://github.com/JWAutumn/ACarousel", .upToNextMinor(from: "0.2.0")) + .package(url: "https://github.com/StanfordBDHG/XCTestExtensions.git", .upToNextMinor(from: "0.4.11")) ] + swiftLintPackage(), targets: [ .target( @@ -63,8 +62,7 @@ let package = Package( .target(name: "SpeziDevices"), .product(name: "SpeziViews", package: "SpeziViews"), .product(name: "SpeziValidation", package: "SpeziViews"), - .product(name: "SpeziBluetooth", package: "SpeziBluetooth"), - .product(name: "ACarousel", package: "ACarousel") + .product(name: "SpeziBluetooth", package: "SpeziBluetooth") ], resources: [ .process("Resources") diff --git a/Sources/SpeziDevices/Model/SavableCollection.swift b/Sources/SpeziDevices/Model/SavableCollection.swift deleted file mode 100644 index 49aa3ca..0000000 --- a/Sources/SpeziDevices/Model/SavableCollection.swift +++ /dev/null @@ -1,100 +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 OSLog - - -struct SavableCollection { - private var storage: [Element] - - var values: [Element] { - storage - } - - init(_ elements: [Element] = []) { - self.storage = elements - } -} - - -extension SavableCollection: RandomAccessCollection { - public var startIndex: Int { - storage.startIndex - } - - public var endIndex: Int { - storage.endIndex - } - - public func index(after index: Int) -> Int { - storage.index(after: index) - } - - public subscript(position: Int) -> Element { - storage[position] - } -} - - -extension SavableCollection: ExpressibleByArrayLiteral { - init(arrayLiteral elements: Element...) { - self.init(elements) - } -} - - -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)") - } - - 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([Element].self, from: data) - } catch { - Self.logger.error("Failed to decode \(Self.self): \(error)") - return nil - } - } -} diff --git a/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift b/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift index b719968..8920a8e 100644 --- a/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift +++ b/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift @@ -6,7 +6,6 @@ // SPDX-License-Identifier: MIT // -import ACarousel import HealthKit import OSLog @_spi(TestingSupport) import SpeziDevices @@ -28,7 +27,6 @@ public struct MeasurementRecordedSheet: View { @State private var selectedMeasurement: HealthKitMeasurement? @State private var viewState = ViewState.idle - @State private var dynamicDetent: PresentationDetent = .medium @MainActor private var forcedUnwrappedMeasurement: Binding { @@ -47,19 +45,6 @@ public struct MeasurementRecordedSheet: View { } - @MainActor private var supportedTypeSize: ClosedRange { - let upperBound: DynamicTypeSize = switch selectedMeasurement { - case .weight: - .accessibility4 - case .bloodPressure: - .accessibility3 - case nil: - .accessibility5 - } - - return DynamicTypeSize.xSmall...upperBound - } - public var body: some View { NavigationStack { Group { @@ -83,23 +68,14 @@ public struct MeasurementRecordedSheet: View { } .viewStateAlert(state: $viewState) .interactiveDismissDisabled(viewState != .idle) - .dynamicTypeSize(supportedTypeSize) + .dynamicTypeSize(.xSmall...DynamicTypeSize.accessibility3) } } - .background { - GeometryReader { proxy in - Color.clear - .task { - // TODO: doesn't work? - dynamicDetent = .height(proxy.size.height) - } - } - } .toolbar { DismissButton() } } - .presentationDetents([dynamicDetent]) + .presentationDetents([.fraction(0.45), .fraction(0.6), .large]) .presentationCornerRadius(25) } @@ -108,12 +84,15 @@ public struct MeasurementRecordedSheet: View { if measurements.pendingMeasurements.count > 1 { TabView(selection: forcedUnwrappedMeasurement) { ForEach(measurements.pendingMeasurements) { measurement in - MeasurementLayer(measurement: measurement) - .padding(.bottom, 35) + VStack { + MeasurementLayer(measurement: measurement) + Spacer() + .frame(minHeight: 30, idealHeight: 45, maxHeight: 60) + .fixedSize() + } .tag(measurement) } } - .scaledToFill() .tabViewStyle(.page) .indexViewStyle(.page(backgroundDisplayMode: .always)) } else if let measurement = measurements.pendingMeasurements.first { @@ -201,7 +180,6 @@ public struct MeasurementRecordedSheet: View { MeasurementRecordedSheet { samples in print("Saving samples \(samples)") } - .dynamicTypeSize(.accessibility3) } .previewWith { HealthMeasurements(mock: [ diff --git a/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift b/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift index 5f34443..667240d 100644 --- a/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift +++ b/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift @@ -6,7 +6,6 @@ // SPDX-License-Identifier: MIT // -import ACarousel @_spi(TestingSupport) import SpeziDevices import SpeziViews import SwiftUI @@ -20,16 +19,24 @@ struct PairDeviceView: View where Collection @Environment(\.dismiss) private var dismiss @Binding private var pairingState: PairingViewState - @State private var selectedDeviceIndex: Int = 0 - @AccessibilityFocusState private var isHeaderFocused: Bool - private var selectedDevice: (any PairableDevice)? { - guard selectedDeviceIndex < devices.count else { - return nil + @State private var selectedDeviceId: UUID? + @State private var selectedDevice: (any PairableDevice)? + + private var forcedUnwrappedDeviceId: Binding { + Binding { + guard let selectedDeviceId else { + guard let selectedDeviceId = devices.first?.id else { + preconditionFailure("Entered code path where selectedMeasurement was not set.") + } + self.selectedDeviceId = selectedDeviceId + return selectedDeviceId + } + return selectedDeviceId + } set: { newValue in + selectedDeviceId = newValue } - let index = devices.index(devices.startIndex, offsetBy: selectedDeviceIndex) - return devices[index] } private var selectedDeviceName: String { @@ -39,24 +46,27 @@ struct PairDeviceView: View where Collection var body: some View { PaneContent(title: "Pair Accessory", subtitle: "Do you want to pair \(selectedDeviceName) with the \(appName) app?") { if devices.count > 1 { - TabView { // TODO: tab index + TabView(selection: forcedUnwrappedDeviceId) { ForEach(devices, id: \.id) { device in - AccessoryImageView(device) - .padding(.bottom, 35) + VStack { + AccessoryImageView(device) + Spacer() + .frame(minHeight: 30, idealHeight: 45, maxHeight: 60) + .fixedSize() + } + .tag(device.id) } } + .onChange(of: selectedDeviceId) { + selectedDevice = devices.first(where: { $0.id == selectedDeviceId }) + } .tabViewStyle(.page) .indexViewStyle(.page(backgroundDisplayMode: .always)) - /* - TODO: carousel - ACarousel(devices, id: \.id, index: $selectedDeviceIndex, spacing: 0, headspace: 0) { device in - AccessoryImageView(device) - } - .frame(maxHeight: 150) - CarouselDots(count: devices.count, selectedIndex: $selectedDeviceIndex) - */ } else if let device = devices.first { AccessoryImageView(device) + .onAppear { + selectedDevice = device + } } } action: { AsyncButton { @@ -83,11 +93,6 @@ struct PairDeviceView: View where Collection .buttonStyle(.borderedProminent) .padding([.leading, .trailing], 36) } - .onChange(of: IndexCount(selectedDeviceIndex, devices.count)) { - if selectedDeviceIndex >= devices.count { - selectedDeviceIndex = max(0, devices.count - 1) - } - } } diff --git a/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings b/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings index cf658dc..0acdebd 100644 --- a/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings +++ b/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings @@ -411,26 +411,6 @@ } } }, - "Page" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Page" - } - } - } - }, - "Page %lld of %lld" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Page %1$lld of %2$lld" - } - } - } - }, "Pair" : { "localizations" : { "en" : { diff --git a/Sources/SpeziDevicesUI/Utils/CarouselDots.swift b/Sources/SpeziDevicesUI/Utils/CarouselDots.swift deleted file mode 100644 index 654569a..0000000 --- a/Sources/SpeziDevicesUI/Utils/CarouselDots.swift +++ /dev/null @@ -1,98 +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 SwiftUI - - -struct CarouselDots: View { - private static let hStackSpacing: CGFloat = 10 - private static let circleDiameter: CGFloat = 7 - private static let padding: CGFloat = 10 - - private let count: Int - @Binding private var selectedIndex: Int - - @State private var isDragging = false - - private var pageNumber: Binding { - .init { - selectedIndex + 1 - } set: { newValue in - selectedIndex = newValue - 1 - } - } - - private var totalWidth: CGFloat { - CGFloat(count) * Self.circleDiameter + CGFloat((count - 1)) * Self.hStackSpacing + 2 * Self.padding - } - - var body: some View { - HStack(spacing: Self.hStackSpacing) { - ForEach(0..) { - self.count = count - self._selectedIndex = selectedIndex - } - - - private func updateIndexBasedOnDrag(_ location: CGPoint) { - guard count > 0 else { // swiftlint:disable:this empty_count - return // swiftlint false positive - } - - let pointWidths = totalWidth / CGFloat(count) - let relativePosition = location.x - - let index = max(0, min(count - 1, Int(relativePosition / pointWidths))) - selectedIndex = index - } -} - - -#if DEBUG -#Preview { - CarouselDots(count: 3, selectedIndex: .constant(0)) -} -#endif diff --git a/Sources/SpeziDevicesUI/Utils/IndexCount.swift b/Sources/SpeziDevicesUI/Utils/IndexCount.swift deleted file mode 100644 index aacb8a0..0000000 --- a/Sources/SpeziDevicesUI/Utils/IndexCount.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// This source file is part of the Stanford SpeziDevices open source project -// -// SPDX-FileCopyrightText: 2024 Stanford University -// -// SPDX-License-Identifier: MIT -// - - -struct IndexCount { - let index: Int - let count: Int - - init(_ index: Int, _ count: Int) { - self.index = index - self.count = count - } -} - - -extension IndexCount: Hashable {} From 2a54884193c64326cf9a043b81772745e4f6e632 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Mon, 1 Jul 2024 16:29:10 +0200 Subject: [PATCH 69/77] feedback --- Sources/SpeziDevices/Devices/GenericDevice.swift | 2 ++ .../SpeziDevices/Devices/PairableDevice.swift | 1 + Sources/SpeziDevices/HealthMeasurements.swift | 4 ++-- ...eet.swift => MeasurementsRecordedSheet.swift} | 16 ++++++++-------- .../SpeziDevicesUI.docc/SpeziDevicesUI.md | 2 +- Tests/UITests/TestApp/MeasurementsTestView.swift | 2 +- 6 files changed, 15 insertions(+), 12 deletions(-) rename Sources/SpeziDevicesUI/Measurements/{MeasurementRecordedSheet.swift => MeasurementsRecordedSheet.swift} (93%) diff --git a/Sources/SpeziDevices/Devices/GenericDevice.swift b/Sources/SpeziDevices/Devices/GenericDevice.swift index 2693d93..4a657c3 100644 --- a/Sources/SpeziDevices/Devices/GenericDevice.swift +++ b/Sources/SpeziDevices/Devices/GenericDevice.swift @@ -26,6 +26,7 @@ public protocol GenericDevice: BluetoothDevice, GenericBluetoothPeripheral, Iden /// @DeviceState(\.id) var id /// ``` var id: UUID { get } + /// The device name. /// /// Use the [`DeviceState`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/devicestate) property wrapper to @@ -34,6 +35,7 @@ public protocol GenericDevice: BluetoothDevice, GenericBluetoothPeripheral, Iden /// @DeviceState(\.name) var name /// ``` var name: String? { get } + /// The advertisement data received in the latest advertisement. /// /// Use the [`DeviceState`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/devicestate) property wrapper to diff --git a/Sources/SpeziDevices/Devices/PairableDevice.swift b/Sources/SpeziDevices/Devices/PairableDevice.swift index fa4fff1..6786d67 100644 --- a/Sources/SpeziDevices/Devices/PairableDevice.swift +++ b/Sources/SpeziDevices/Devices/PairableDevice.swift @@ -32,6 +32,7 @@ public protocol PairableDevice: GenericDevice { /// ```swift /// @DeviceAction(\.connect) var connect /// ``` + /// var connect: BluetoothConnectAction { get } /// Disconnect action. /// diff --git a/Sources/SpeziDevices/HealthMeasurements.swift b/Sources/SpeziDevices/HealthMeasurements.swift index 41fcc3b..52d2db0 100644 --- a/Sources/SpeziDevices/HealthMeasurements.swift +++ b/Sources/SpeziDevices/HealthMeasurements.swift @@ -44,7 +44,7 @@ import SwiftUI /// } /// ``` /// -/// To display new measurements to the user and save them to your external data store, you can use ``MeasurementRecordedSheet``. +/// To display new measurements to the user and save them to your external data store, you can use ``MeasurementsRecordedSheet``. /// Below is a short code example. /// /// ```swift @@ -58,7 +58,7 @@ import SwiftUI /// @Bindable var measurements = measurements /// ContentView() /// .sheet(isPresented: $measurements.shouldPresentMeasurements) { -/// MeasurementRecordedSheet { measurement in +/// MeasurementsRecordedSheet { measurement in /// // handle saving the measurement /// } /// } diff --git a/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift b/Sources/SpeziDevicesUI/Measurements/MeasurementsRecordedSheet.swift similarity index 93% rename from Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift rename to Sources/SpeziDevicesUI/Measurements/MeasurementsRecordedSheet.swift index 8920a8e..b7b4120 100644 --- a/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedSheet.swift +++ b/Sources/SpeziDevicesUI/Measurements/MeasurementsRecordedSheet.swift @@ -13,11 +13,11 @@ import SpeziViews import SwiftUI -/// A sheet view displaying a newly recorded measurement. +/// A sheet view displaying one or many newly recorded measurements. /// /// This view retrieves the pending measurements from the ``HealthMeasurements`` Module that is present in the SwiftUI environment. -public struct MeasurementRecordedSheet: View { - private let logger = Logger(subsystem: "edu.stanford.spezi.SpeziDevices", category: "MeasurementRecordedSheet") +public struct MeasurementsRecordedSheet: View { + private let logger = Logger(subsystem: "edu.stanford.spezi.SpeziDevices", category: "MeasurementsRecordedSheet") private let saveSamples: ([HKSample]) async throws -> Void @Environment(HealthMeasurements.self) private var measurements @@ -141,7 +141,7 @@ public struct MeasurementRecordedSheet: View { #Preview { Text(verbatim: "") .sheet(isPresented: .constant(true)) { - MeasurementRecordedSheet { samples in + MeasurementsRecordedSheet { samples in print("Saving samples \(samples)") } } @@ -153,7 +153,7 @@ public struct MeasurementRecordedSheet: View { #Preview { Text(verbatim: "") .sheet(isPresented: .constant(true)) { - MeasurementRecordedSheet { samples in + MeasurementsRecordedSheet { samples in print("Saving samples \(samples)") } } @@ -165,7 +165,7 @@ public struct MeasurementRecordedSheet: View { #Preview { Text(verbatim: "") .sheet(isPresented: .constant(true)) { - MeasurementRecordedSheet { samples in + MeasurementsRecordedSheet { samples in print("Saving samples \(samples)") } } @@ -177,7 +177,7 @@ public struct MeasurementRecordedSheet: View { #Preview { Text(verbatim: "") .sheet(isPresented: .constant(true)) { - MeasurementRecordedSheet { samples in + MeasurementsRecordedSheet { samples in print("Saving samples \(samples)") } } @@ -193,7 +193,7 @@ public struct MeasurementRecordedSheet: View { #Preview { Text(verbatim: "") .sheet(isPresented: .constant(true)) { - MeasurementRecordedSheet { samples in + MeasurementsRecordedSheet { samples in print("Saving samples \(samples)") } } diff --git a/Sources/SpeziDevicesUI/SpeziDevicesUI.docc/SpeziDevicesUI.md b/Sources/SpeziDevicesUI/SpeziDevicesUI.docc/SpeziDevicesUI.md index c1e5e30..20c8313 100644 --- a/Sources/SpeziDevicesUI/SpeziDevicesUI.docc/SpeziDevicesUI.md +++ b/Sources/SpeziDevicesUI/SpeziDevicesUI.docc/SpeziDevicesUI.md @@ -40,4 +40,4 @@ Views that are helpful when building a nearby devices view. ### Measurements -- ``MeasurementRecordedSheet`` +- ``MeasurementsRecordedSheet`` diff --git a/Tests/UITests/TestApp/MeasurementsTestView.swift b/Tests/UITests/TestApp/MeasurementsTestView.swift index f17f522..7106a59 100644 --- a/Tests/UITests/TestApp/MeasurementsTestView.swift +++ b/Tests/UITests/TestApp/MeasurementsTestView.swift @@ -38,7 +38,7 @@ struct MeasurementsTestView: View { } .navigationTitle("Measurements") .sheet(isPresented: $healthMeasurements.shouldPresentMeasurements) { - MeasurementRecordedSheet { samples in + MeasurementsRecordedSheet { samples in self.samples.append(contentsOf: samples) } } From 5daa2c122db5d30586f405b7908e251220dfc776 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Mon, 1 Jul 2024 22:23:04 +0200 Subject: [PATCH 70/77] Make storage work --- Package.swift | 6 +- .../SpeziDevices/Devices/GenericDevice.swift | 2 +- Sources/SpeziDevices/HealthMeasurements.swift | 34 ++- .../BluetoothHealthMeasurement.swift | 229 ++---------------- .../Measurements/StoredMeasurement.swift | 185 ++++++++++++-- .../SpeziDevices/Model/PairedDeviceInfo.swift | 7 +- .../Model/SavableDictionary.swift | 115 --------- Sources/SpeziDevices/PairedDevices.swift | 6 +- Sources/SpeziDevices/Testing/MockDevice.swift | 2 +- .../MeasurementsRecordedSheet.swift | 6 +- .../SpeziDevicesUI/Tips/ForgetDeviceTip.swift | 4 +- .../Devices/OmronBloodPressureCuff.swift | 8 +- .../SpeziOmron/Devices/OmronWeightScale.swift | 12 +- Sources/SpeziOmron/OmronOptionService.swift | 6 +- .../HealthMeasurementsTests.swift | 5 - 15 files changed, 237 insertions(+), 390 deletions(-) delete mode 100644 Sources/SpeziDevices/Model/SavableDictionary.swift diff --git a/Package.swift b/Package.swift index 88b55ee..f6f9f61 100644 --- a/Package.swift +++ b/Package.swift @@ -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 @@ -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: [ diff --git a/Sources/SpeziDevices/Devices/GenericDevice.swift b/Sources/SpeziDevices/Devices/GenericDevice.swift index 4a657c3..7bb98a2 100644 --- a/Sources/SpeziDevices/Devices/GenericDevice.swift +++ b/Sources/SpeziDevices/Devices/GenericDevice.swift @@ -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 } diff --git a/Sources/SpeziDevices/HealthMeasurements.swift b/Sources/SpeziDevices/HealthMeasurements.swift index 52d2db0..2ae0d64 100644 --- a/Sources/SpeziDevices/HealthMeasurements.swift +++ b/Sources/SpeziDevices/HealthMeasurements.swift @@ -6,8 +6,7 @@ // SPDX-License-Identifier: MIT // -import OrderedCollections -import HealthKit +@preconcurrency import HealthKit import OSLog import Spezi import SpeziBluetooth @@ -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. @@ -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!") @@ -273,8 +272,10 @@ extension HealthMeasurements { return } - var fetchAll = FetchDescriptor() - // TODO: fetchAll.includePendingChanges = true + var fetchAll = FetchDescriptor( + sortBy: [SortDescriptor(\.storageDate)] + ) + fetchAll.includePendingChanges = true let context = modelContainer.mainContext let storedMeasurements: [StoredMeasurement] @@ -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 } @@ -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)") + } + } } } diff --git a/Sources/SpeziDevices/Measurements/BluetoothHealthMeasurement.swift b/Sources/SpeziDevices/Measurements/BluetoothHealthMeasurement.swift index abdfe7d..6739a5f 100644 --- a/Sources/SpeziDevices/Measurements/BluetoothHealthMeasurement.swift +++ b/Sources/SpeziDevices/Measurements/BluetoothHealthMeasurement.swift @@ -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) - } - } } @@ -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) } } } diff --git a/Sources/SpeziDevices/Measurements/StoredMeasurement.swift b/Sources/SpeziDevices/Measurements/StoredMeasurement.swift index a67dc0f..92b8fb5 100644 --- a/Sources/SpeziDevices/Measurements/StoredMeasurement.swift +++ b/Sources/SpeziDevices/Measurements/StoredMeasurement.swift @@ -8,6 +8,8 @@ import HealthKit +import SpeziBluetoothServices +import SpeziNumerics import SwiftData @@ -22,25 +24,120 @@ private struct CodableHKDevice { let udiDeviceIdentifier: String? } -private struct CodableHKQuantitySample { +/// Copy of the `BloodPressureMeasurement` type that just uses plain RawValue types to work around SwiftData coding issues and crashes. +private struct BloodPressureMeasurementCopy { + let systolicValue: UInt16 + let diastolicValue: UInt16 + let meanArterialPressure: UInt16 + let unit: String + + + let timeStamp: DateTime? + let pulseRate: UInt16? + + let userId: UInt8? + let measurementStatus: UInt16? + + + var measurement: BloodPressureMeasurement { + .init( + systolic: MedFloat16(bitPattern: systolicValue), + diastolic: MedFloat16(bitPattern: diastolicValue), + meanArterialPressure: MedFloat16(bitPattern: meanArterialPressure), + unit: .init(rawValue: unit) ?? .mmHg, + timeStamp: timeStamp, + pulseRate: pulseRate.map { MedFloat16(bitPattern: $0) }, + userId: userId, + measurementStatus: measurementStatus.map { .init(rawValue: $0) } + ) + } + + init(from measurement: BloodPressureMeasurement) { + self.systolicValue = measurement.systolicValue.rawValue + self.diastolicValue = measurement.diastolicValue.rawValue + self.meanArterialPressure = measurement.meanArterialPressure.rawValue + self.unit = measurement.unit.rawValue + self.timeStamp = measurement.timeStamp + self.pulseRate = measurement.pulseRate?.rawValue + self.userId = measurement.userId + self.measurementStatus = measurement.measurementStatus?.rawValue + } +} + + +// swiftlint:disable:next type_name +private struct SwiftDataBluetoothHealthMeasurementWorkaroundContainer { + 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 + } + } } @Model final class StoredMeasurement { @Attribute(.unique) var associatedMeasurement: UUID - let measurement: SwiftDataBluetoothHealthMeasurementWorkaroundContainer + + private let measurement: SwiftDataBluetoothHealthMeasurementWorkaroundContainer fileprivate let codableDevice: CodableHKDevice + + let storageDate: Date var device: HKDevice { codableDevice.hkDevice } - init(associatedMeasurement: UUID, measurement: SwiftDataBluetoothHealthMeasurementWorkaroundContainer, device: HKDevice) { + var healthMeasurement: BluetoothHealthMeasurement { + measurement.measurement + } + + init(associatedMeasurement: UUID, measurement: BluetoothHealthMeasurement, device: HKDevice) { self.associatedMeasurement = associatedMeasurement - self.measurement = measurement + self.measurement = .init(from: measurement) self.codableDevice = CodableHKDevice(from: device) + self.storageDate = .now } } @@ -75,16 +172,72 @@ extension CodableHKDevice { } -/* -extension CodableHKQuantitySample { - var hkSample: HKQuantitySample { - HKQuantitySample( - type: <#T##HKQuantityType#>, - quantity: <#T##HKQuantity#>, - start: <#T##Date#>, - end: <#T##Date#>, - device: <#T##HKDevice?#>, - metadata: <#T##[String : Any]?#> - ) +extension BloodPressureMeasurementCopy: Codable { + 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(UInt16.self, forKey: .systolicValue) + self.diastolicValue = try container.decode(UInt16.self, forKey: .diastolicValue) + self.meanArterialPressure = try container.decode(UInt16.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(UInt16.self, forKey: .pulseRate) + self.userId = try container.decodeIfPresent(UInt8.self, forKey: .userId) + self.measurementStatus = try container.decodeIfPresent(UInt16.self, forKey: .measurementStatus) + } + + 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) + } +} + + +extension SwiftDataBluetoothHealthMeasurementWorkaroundContainer: Codable { + 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) + } + } + + 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) + } +} diff --git a/Sources/SpeziDevices/Model/PairedDeviceInfo.swift b/Sources/SpeziDevices/Model/PairedDeviceInfo.swift index 6138a62..64ca7da 100644 --- a/Sources/SpeziDevices/Model/PairedDeviceInfo.swift +++ b/Sources/SpeziDevices/Model/PairedDeviceInfo.swift @@ -12,7 +12,7 @@ import SwiftData /// Persistent information stored of a paired device. @Model -public class PairedDeviceInfo { +public final class PairedDeviceInfo { /// The CoreBluetooth device identifier. @Attribute(.unique) public let id: UUID /// The device type. @@ -29,6 +29,9 @@ public class PairedDeviceInfo { /// The last reported battery percentage of the device. public internal(set) var lastBatteryPercentage: UInt8? + /// The date at which the device was paired. + public let pairedAt: Date + /// Could not retrieve the device from the Bluetooth central. @Transient public internal(set) var notLocatable: Bool = false /// Visual representation of the device. @@ -59,6 +62,8 @@ public class PairedDeviceInfo { self.icon = icon self.lastSeen = lastSeen self.lastBatteryPercentage = batteryPercentage + + self.pairedAt = .now } } diff --git a/Sources/SpeziDevices/Model/SavableDictionary.swift b/Sources/SpeziDevices/Model/SavableDictionary.swift deleted file mode 100644 index 36d92ca..0000000 --- a/Sources/SpeziDevices/Model/SavableDictionary.swift +++ /dev/null @@ -1,115 +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 OrderedCollections -import OSLog - - -struct SavableDictionary { // TODO: remove both! - private var storage: OrderedDictionary - - var keys: OrderedSet { - storage.keys - } - - var values: OrderedDictionary.Values { - storage.values - } - - init() { - self.storage = [:] - } - - mutating func removeAll() { - storage.removeAll() - } - - @discardableResult - mutating func removeValue(forKey key: Key) -> Value? { - storage.removeValue(forKey: key) - } - - subscript(key: Key) -> Value? { - get { - storage[key] - } - mutating _modify { - yield &storage[key] - } - mutating 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 = OrderedDictionary.Index - public typealias Element = OrderedDictionary.Iterator.Element - - public var startIndex: Index { - storage.elements.startIndex - } - public var endIndex: Index { - storage.elements.endIndex - } - - public func index(after index: Index) -> Index { - storage.elements.index(after: index) - } - - public subscript(position: Index) -> Element { - storage.elements[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(OrderedDictionary.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 e45b441..e5df929 100644 --- a/Sources/SpeziDevices/PairedDevices.swift +++ b/Sources/SpeziDevices/PairedDevices.swift @@ -86,7 +86,7 @@ import SwiftUI /// - ``isConnected(device:)`` /// - ``updateName(for:name:)`` @Observable -public final class PairedDevices { +public final class PairedDevices: @unchecked Sendable { /// Determines if the device discovery sheet should be presented. @MainActor public var shouldPresentDevicePairing = false @@ -500,7 +500,9 @@ extension PairedDevices { } let context = modelContainer.mainContext - var allPairedDevices = FetchDescriptor() + var allPairedDevices = FetchDescriptor( + sortBy: [SortDescriptor(\.pairedAt)] + ) allPairedDevices.includePendingChanges = true do { diff --git a/Sources/SpeziDevices/Testing/MockDevice.swift b/Sources/SpeziDevices/Testing/MockDevice.swift index dc1d914..bd2e405 100644 --- a/Sources/SpeziDevices/Testing/MockDevice.swift +++ b/Sources/SpeziDevices/Testing/MockDevice.swift @@ -13,7 +13,7 @@ import SpeziNumerics @_spi(TestingSupport) -public final class MockDevice: PairableDevice, HealthDevice, BatteryPoweredDevice { +public final class MockDevice: PairableDevice, HealthDevice, BatteryPoweredDevice, @unchecked Sendable { @DeviceState(\.id) public var id @DeviceState(\.name) public var name @DeviceState(\.state) public var state diff --git a/Sources/SpeziDevicesUI/Measurements/MeasurementsRecordedSheet.swift b/Sources/SpeziDevicesUI/Measurements/MeasurementsRecordedSheet.swift index b7b4120..d070e9d 100644 --- a/Sources/SpeziDevicesUI/Measurements/MeasurementsRecordedSheet.swift +++ b/Sources/SpeziDevicesUI/Measurements/MeasurementsRecordedSheet.swift @@ -6,7 +6,7 @@ // SPDX-License-Identifier: MIT // -import HealthKit +@preconcurrency import HealthKit import OSLog @_spi(TestingSupport) import SpeziDevices import SpeziViews @@ -28,8 +28,7 @@ public struct MeasurementsRecordedSheet: View { @State private var viewState = ViewState.idle - @MainActor - private var forcedUnwrappedMeasurement: Binding { + @MainActor private var forcedUnwrappedMeasurement: Binding { Binding { guard let selectedMeasurement else { guard let selectedMeasurement = measurements.pendingMeasurements.first else { @@ -42,7 +41,6 @@ public struct MeasurementsRecordedSheet: View { } set: { newValue in selectedMeasurement = newValue } - } public var body: some View { diff --git a/Sources/SpeziDevicesUI/Tips/ForgetDeviceTip.swift b/Sources/SpeziDevicesUI/Tips/ForgetDeviceTip.swift index caf1020..13aead1 100644 --- a/Sources/SpeziDevicesUI/Tips/ForgetDeviceTip.swift +++ b/Sources/SpeziDevicesUI/Tips/ForgetDeviceTip.swift @@ -28,7 +28,9 @@ struct ForgetDeviceTip: Tip { guard let url = URL(string: "App-Prefs:root=General") else { return } - UIApplication.shared.open(url) + Task { @MainActor in + UIApplication.shared.open(url) + } } _: { Text("Open Settings") } diff --git a/Sources/SpeziOmron/Devices/OmronBloodPressureCuff.swift b/Sources/SpeziOmron/Devices/OmronBloodPressureCuff.swift index 190be7c..f4240e7 100644 --- a/Sources/SpeziOmron/Devices/OmronBloodPressureCuff.swift +++ b/Sources/SpeziOmron/Devices/OmronBloodPressureCuff.swift @@ -16,13 +16,13 @@ import SpeziNumerics /// Implementation of Omron BP5250 Blood Pressure Cuff. -public class OmronBloodPressureCuff: BluetoothDevice, Identifiable, OmronHealthDevice, BatteryPoweredDevice { - private static let logger = Logger(subsystem: "ENGAGEHF", category: "BloodPressureCuffDevice") - +public final class OmronBloodPressureCuff: BluetoothDevice, Identifiable, OmronHealthDevice, BatteryPoweredDevice, @unchecked Sendable { public static var icon: ImageReference? { .asset("Omron-BP5250", bundle: .module) } + private let logger = Logger(subsystem: "ENGAGEHF", category: "BloodPressureCuffDevice") + @DeviceState(\.id) public var id: UUID @DeviceState(\.name) public var name: String? @DeviceState(\.state) public var state: PeripheralState @@ -78,7 +78,7 @@ public class OmronBloodPressureCuff: BluetoothDevice, Identifiable, OmronHealthD @MainActor private func handleCurrentTimeChange(_ time: CurrentTime) { - Self.logger.debug("Received updated device time for \(self.label) is \(String(describing: time))") + logger.debug("Received updated device time for \(self.label) is \(String(describing: time))") let paired = pairedDevices?.signalDevicePaired(self) if paired == true { diff --git a/Sources/SpeziOmron/Devices/OmronWeightScale.swift b/Sources/SpeziOmron/Devices/OmronWeightScale.swift index b6ca11d..5d3e6d2 100644 --- a/Sources/SpeziOmron/Devices/OmronWeightScale.swift +++ b/Sources/SpeziOmron/Devices/OmronWeightScale.swift @@ -15,13 +15,13 @@ import SpeziDevices /// Implementation of Omron SC150 Weight Scale. -public class OmronWeightScale: BluetoothDevice, Identifiable, OmronHealthDevice { - private static let logger = Logger(subsystem: "ENGAGEHF", category: "WeightScale") - +public final class OmronWeightScale: BluetoothDevice, Identifiable, OmronHealthDevice, @unchecked Sendable { public static var icon: ImageReference? { .asset("Omron-SC-150", bundle: .module) } + private let logger = Logger(subsystem: "ENGAGEHF", category: "WeightScale") + @DeviceState(\.id) public var id: UUID @DeviceState(\.name) public var name: String? @DeviceState(\.state) public var state: PeripheralState @@ -78,7 +78,9 @@ public class OmronWeightScale: BluetoothDevice, Identifiable, OmronHealthDevice } @MainActor - private func handleCurrentTimeChange(_ time: CurrentTime) {/* + private func handleCurrentTimeChange(_ time: CurrentTime) { + /* + TODO: what to do now with pairing? if case .pairingMode = manufacturerData?.pairingMode, let dateOfConnection, abs(Date.now.timeIntervalSince1970 - dateOfConnection.timeIntervalSince1970) < 1 { @@ -87,7 +89,7 @@ public class OmronWeightScale: BluetoothDevice, Identifiable, OmronHealthDevice return } */ - Self.logger.debug("Received updated device time for \(self.label): \(String(describing: time))") + logger.debug("Received updated device time for \(self.label): \(String(describing: time))") let paired = pairedDevices?.signalDevicePaired(self) == true if paired { dateOfConnection = nil diff --git a/Sources/SpeziOmron/OmronOptionService.swift b/Sources/SpeziOmron/OmronOptionService.swift index 86cebfc..aaa79db 100644 --- a/Sources/SpeziOmron/OmronOptionService.swift +++ b/Sources/SpeziOmron/OmronOptionService.swift @@ -6,7 +6,7 @@ // SPDX-License-Identifier: MIT // -import class CoreBluetooth.CBUUID +import CoreBluetooth.CBUUID import SpeziBluetooth import SpeziBluetoothServices @@ -15,7 +15,9 @@ import SpeziBluetoothServices /// /// Please refer to the respective Developer Guide for more information. public final class OmronOptionService: BluetoothService, @unchecked Sendable { - public static let id = CBUUID(string: "5DF5E817-A945-4F81-89C0-3D4E9759C07C") + public static var id: CBUUID { + CBUUID(string: "5DF5E817-A945-4F81-89C0-3D4E9759C07C") + } @Characteristic(id: "2A52", notify: true) var recordAccessControlPoint: RecordAccessControlPoint? diff --git a/Tests/SpeziDevicesTests/HealthMeasurementsTests.swift b/Tests/SpeziDevicesTests/HealthMeasurementsTests.swift index f2e9b67..5a445e1 100644 --- a/Tests/SpeziDevicesTests/HealthMeasurementsTests.swift +++ b/Tests/SpeziDevicesTests/HealthMeasurementsTests.swift @@ -125,10 +125,6 @@ final class HealthMeasurementsTests: XCTestCase { XCTAssertEqual(measurements.pendingMeasurements.count, 2) - print("WE ARE HERE!") - - // TODO: let previousMeasurements = measurements.pendingMeasurements - try measurements.refreshFetchingMeasurements() // clear pending measurements and fetch again from storage try await Task.sleep(for: .milliseconds(50)) @@ -136,7 +132,6 @@ final class HealthMeasurementsTests: XCTestCase { // tests that order stays same over storage retrieval // Restoring from disk doesn't preserve HealthKit UUIDs - // TODO: order is not always guaranteed! => probably also a problem for the PairedDevices thingy! guard case .bloodPressure = measurements.pendingMeasurements.first, case .weight = measurements.pendingMeasurements.last else { XCTFail("Order of measurements doesn't match: \(measurements.pendingMeasurements)") From 8af78d0416b7f4f70d670dbd92ceeb85e927b0bc Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 2 Jul 2024 11:15:32 +0200 Subject: [PATCH 71/77] Documentation progress and resolved some feebdack --- Package.swift | 2 +- .../SpeziDevices/Devices/PairableDevice.swift | 1 + Sources/SpeziDevices/HealthMeasurements.swift | 4 +- .../Measurements/StoredMeasurement.swift | 11 +- .../Model/PairingContinuation.swift | 2 +- Sources/SpeziDevices/PairedDevices.swift | 13 ++- .../SpeziDevices.docc/HealthKit.md | 24 +++- .../SpeziDevices.docc/SpeziDevices.md | 110 +++++++++++++++++- .../SpeziDevicesUI/Devices/DevicesGrid.swift | 9 +- .../{DevicesTab.swift => DevicesView.swift} | 22 ++-- .../Pairing/PairDeviceView.swift | 1 + .../SpeziDevicesUI.docc/SpeziDevicesUI.md | 6 +- .../SpeziOmron/SpeziOmron.docc/SpeziOmron.md | 13 ++- .../PairedDevicesTests.swift | 1 - Tests/UITests/TestApp/DevicesTestView.swift | 2 +- 15 files changed, 182 insertions(+), 39 deletions(-) rename Sources/SpeziDevicesUI/Devices/{DevicesTab.swift => DevicesView.swift} (75%) diff --git a/Package.swift b/Package.swift index f6f9f61..49e2ed8 100644 --- a/Package.swift +++ b/Package.swift @@ -38,7 +38,7 @@ let package = Package( .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", from: "2.1.1"), - .package(url: "https://github.com/StanfordBDHG/XCTestExtensions.git", .upToNextMinor(from: "0.4.11")) + .package(url: "https://github.com/StanfordBDHG/XCTestExtensions.git", .upToNextMinor(from: "0.4.12")) ] + swiftLintPackage(), targets: [ .target( diff --git a/Sources/SpeziDevices/Devices/PairableDevice.swift b/Sources/SpeziDevices/Devices/PairableDevice.swift index 6786d67..0698764 100644 --- a/Sources/SpeziDevices/Devices/PairableDevice.swift +++ b/Sources/SpeziDevices/Devices/PairableDevice.swift @@ -34,6 +34,7 @@ public protocol PairableDevice: GenericDevice { /// ``` /// var connect: BluetoothConnectAction { get } + /// Disconnect action. /// /// Use the [`DeviceAction`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/deviceaction) property wrapper to diff --git a/Sources/SpeziDevices/HealthMeasurements.swift b/Sources/SpeziDevices/HealthMeasurements.swift index 2ae0d64..1be11c1 100644 --- a/Sources/SpeziDevices/HealthMeasurements.swift +++ b/Sources/SpeziDevices/HealthMeasurements.swift @@ -17,7 +17,7 @@ import SwiftUI /// 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 +/// Use the `HealthMeasurements` module to collect health measurements from nearby Bluetooth devices 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). @@ -66,7 +66,7 @@ import SwiftUI /// ``` /// /// - Important: Don't forget to configure the `HealthMeasurements` module in -/// your [`SpeziAppDelegate`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/speziappdelegate) +/// your [`SpeziAppDelegate`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/speziappdelegate). /// /// ## Topics /// diff --git a/Sources/SpeziDevices/Measurements/StoredMeasurement.swift b/Sources/SpeziDevices/Measurements/StoredMeasurement.swift index 92b8fb5..93a2322 100644 --- a/Sources/SpeziDevices/Measurements/StoredMeasurement.swift +++ b/Sources/SpeziDevices/Measurements/StoredMeasurement.swift @@ -26,7 +26,7 @@ private struct CodableHKDevice { /// Copy of the `BloodPressureMeasurement` type that just uses plain RawValue types to work around SwiftData coding issues and crashes. -private struct BloodPressureMeasurementCopy { +private struct BloodPressureMeasurementSwiftDataWorkaroundContainer { // swiftlint:disable:this type_name let systolicValue: UInt16 let diastolicValue: UInt16 let meanArterialPressure: UInt16 @@ -75,7 +75,7 @@ private struct SwiftDataBluetoothHealthMeasurementWorkaroundContainer { private let type: MeasurementType - private var bloodPressureMeasurement: BloodPressureMeasurementCopy? + private var bloodPressureMeasurement: BloodPressureMeasurementSwiftDataWorkaroundContainer? private var bloodPressureFeatures: BloodPressureFeature.RawValue? private var weightMeasurement: WeightMeasurement? @@ -172,7 +172,7 @@ extension CodableHKDevice { } -extension BloodPressureMeasurementCopy: Codable { +extension BloodPressureMeasurementSwiftDataWorkaroundContainer: Codable { enum CodingKeys: CodingKey { case systolicValue case diastolicValue @@ -224,7 +224,10 @@ extension SwiftDataBluetoothHealthMeasurementWorkaroundContainer: Codable { self.type = try container.decode(SwiftDataBluetoothHealthMeasurementWorkaroundContainer.MeasurementType.self, forKey: .type) switch type { case .bloodPressure: - self.bloodPressureMeasurement = try container.decodeIfPresent(BloodPressureMeasurementCopy.self, forKey: .bloodPressureMeasurement) + self.bloodPressureMeasurement = try container.decodeIfPresent( + BloodPressureMeasurementSwiftDataWorkaroundContainer.self, + forKey: .bloodPressureMeasurement + ) self.bloodPressureFeatures = try container.decodeIfPresent(BloodPressureFeature.RawValue.self, forKey: .bloodPressureFeatures) case .weight: self.weightMeasurement = try container.decodeIfPresent(WeightMeasurement.self, forKey: .weightMeasurement) diff --git a/Sources/SpeziDevices/Model/PairingContinuation.swift b/Sources/SpeziDevices/Model/PairingContinuation.swift index 242b952..de1dddf 100644 --- a/Sources/SpeziDevices/Model/PairingContinuation.swift +++ b/Sources/SpeziDevices/Model/PairingContinuation.swift @@ -43,4 +43,4 @@ final class PairingContinuation { } -extension PairingContinuation: @unchecked Sendable {} +extension PairingContinuation {} diff --git a/Sources/SpeziDevices/PairedDevices.swift b/Sources/SpeziDevices/PairedDevices.swift index e5df929..2fc8be1 100644 --- a/Sources/SpeziDevices/PairedDevices.swift +++ b/Sources/SpeziDevices/PairedDevices.swift @@ -18,15 +18,19 @@ 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 +/// Use the `PairedDevices` module to discover and pair ``PairableDevice``s 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`. +/// To support `PairedDevices`, you need to adopt the ``PairableDevice`` protocol for your device. +/// Optionally you can adopt ``BatteryPoweredDevice`` if your device supports the +/// [`BatteryService`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetoothservices/batteryservice). /// Once your device is loaded, register it with the `PairedDevices` module by calling the ``configure(device:accessing:_:_:)`` method. /// +/// - Important: Don't forget to configure the `PairedDevices` module in +/// your [`SpeziAppDelegate`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/speziappdelegate). +/// /// ```swift /// import SpeziDevices /// @@ -60,13 +64,12 @@ import SwiftUI /// } /// ``` /// -/// To display and manage paired devices and support adding new paired devices, you can use the full-featured ``DevicesTab`` view. +/// - Tip: 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:_:_:)`` diff --git a/Sources/SpeziDevices/SpeziDevices.docc/HealthKit.md b/Sources/SpeziDevices/SpeziDevices.docc/HealthKit.md index 0599757..4a736b4 100644 --- a/Sources/SpeziDevices/SpeziDevices.docc/HealthKit.md +++ b/Sources/SpeziDevices/SpeziDevices.docc/HealthKit.md @@ -14,14 +14,34 @@ SPDX-License-Identifier: MIT ## Overview -Text +SpeziDevices helps developers converting measurements received from Bluetooth devices to HealthKit sample types. +### Device Information + +As soon as you conform your [SpeziBluetooth `BluetoothDevice`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetoothdevice) +to the ``HealthDevice`` protocol and implement the [`DeviceInformationService`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetoothservices/deviceinformationservice), +you can access the [`HKDevice`](https://developer.apple.com/documentation/healthkit/hkdevice) +description using the ``HealthDevice/hkDevice-32s1d`` property + +### Converting Measurements + +SpeziDevices can convert your Bluetooth Health Measurement characteristics into HealthKit samples. +This is support for characteristics like [`BloodPressureMeasurement`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetoothservices/bloodpressuremeasurement) +or [`WeightMeasurement`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetoothservices/weightmeasurement). + +Use methods like ``SpeziBluetoothServices/BloodPressureMeasurement/bloodPressureSample(source:)`` or +``SpeziBluetoothServices/WeightMeasurement/weightSample(source:resolution:)`` to convert these measurements to their respective HealthKit Sample +representation. + +> Tip: By using the [`resource`](https://swiftpackageindex.com/stanfordbdhg/healthkitonfhir/documentation/healthkitonfhir/healthkit/hksample/resource) + provided through [`HealthKitOnFHIR`](https://swiftpackageindex.com/StanfordBDHG/HealthKitOnFHIR/documentation/healthkitonfhir) you can convert + your Bluetooth measurements to [HL7 FHIR Observation Resources](http://hl7.org/fhir/R4/observation.html). ## Topics ### Device -- ``HealthDevice/hkDevice`` +- ``HealthDevice/hkDevice-32s1d`` ### Blood Pressure Measurement diff --git a/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md b/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md index cde5efa..aea6080 100644 --- a/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md +++ b/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md @@ -1,6 +1,6 @@ # ``SpeziDevices`` -Summary +Support interactions with Bluetooth Devices. Summary +Visualize Bluetooth device interactions. Text +SpeziDevicesUI helps you to visualize Bluetooth device state and communicate interactions to the user. ## Topics @@ -32,7 +32,7 @@ Views that are helpful when building a nearby devices view. ### Paired Devices -- ``DevicesTab`` +- ``DevicesView`` - ``DevicesGrid`` - ``DeviceTile`` - ``DeviceDetailsView`` diff --git a/Sources/SpeziOmron/SpeziOmron.docc/SpeziOmron.md b/Sources/SpeziOmron/SpeziOmron.docc/SpeziOmron.md index 7e9bdc0..73f844d 100644 --- a/Sources/SpeziOmron/SpeziOmron.docc/SpeziOmron.md +++ b/Sources/SpeziOmron/SpeziOmron.docc/SpeziOmron.md @@ -1,6 +1,6 @@ # ``SpeziOmron`` -Summary +Support interactions with Omron Bluetooth Devices. Text +SpeziOmron extends SpeziDevices with support for Omron devices. This includes Omron-specific models, characteristics, services and fully reusable +device support. + +### Omron Devices + + + +- models (e.g., manufcaturer data) +- characteristic & services +- device implementations ## Topics diff --git a/Tests/SpeziDevicesTests/PairedDevicesTests.swift b/Tests/SpeziDevicesTests/PairedDevicesTests.swift index 00d75c9..57cc2e5 100644 --- a/Tests/SpeziDevicesTests/PairedDevicesTests.swift +++ b/Tests/SpeziDevicesTests/PairedDevicesTests.swift @@ -177,7 +177,6 @@ final class PairedDevicesTests: XCTestCase { await device.disconnect() try await XCTAssertThrowsErrorAsync(await task.value) { error in - print(error) XCTAssertEqual(try XCTUnwrap(error as? DevicePairingError), .deviceDisconnected) } diff --git a/Tests/UITests/TestApp/DevicesTestView.swift b/Tests/UITests/TestApp/DevicesTestView.swift index b43665c..6172694 100644 --- a/Tests/UITests/TestApp/DevicesTestView.swift +++ b/Tests/UITests/TestApp/DevicesTestView.swift @@ -34,7 +34,7 @@ struct DevicesTestView: View { var body: some View { NavigationStack { - DevicesTab(appName: "TestApp", pairingHint: "Enable pairing mode on the device.") + DevicesView(appName: "TestApp", pairingHint: "Enable pairing mode on the device.") .toolbar { ToolbarItemGroup(placement: .secondaryAction) { Button("Discover Device", systemImage: "plus.rectangle.fill.on.rectangle.fill") { From dc365b1d4ecf084b7d220adcf6f6d172a10b850f Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 2 Jul 2024 11:35:16 +0200 Subject: [PATCH 72/77] Dedicated storage location --- Sources/SpeziDevices/HealthMeasurements.swift | 3 ++- Sources/SpeziDevices/PairedDevices.swift | 9 ++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Sources/SpeziDevices/HealthMeasurements.swift b/Sources/SpeziDevices/HealthMeasurements.swift index 1be11c1..2bd6c3c 100644 --- a/Sources/SpeziDevices/HealthMeasurements.swift +++ b/Sources/SpeziDevices/HealthMeasurements.swift @@ -117,7 +117,8 @@ public final class HealthMeasurements: @unchecked Sendable { #if targetEnvironment(simulator) configuration = ModelConfiguration(isStoredInMemoryOnly: true) #else - configuration = ModelConfiguration() + let storageUrl = URL.documentsDirectory.appending(path: "edu.stanford.spezidevices.health-measurements.sqlite") + configuration = ModelConfiguration(url: storageUrl) #endif do { diff --git a/Sources/SpeziDevices/PairedDevices.swift b/Sources/SpeziDevices/PairedDevices.swift index 2fc8be1..0a1dd97 100644 --- a/Sources/SpeziDevices/PairedDevices.swift +++ b/Sources/SpeziDevices/PairedDevices.swift @@ -150,7 +150,8 @@ public final class PairedDevices: @unchecked Sendable { #if targetEnvironment(simulator) configuration = ModelConfiguration(isStoredInMemoryOnly: true) #else - configuration = ModelConfiguration() + let storageUrl = URL.documentsDirectory.appending(path: "edu.stanford.spezidevices.paired-devices.sqlite") + configuration = ModelConfiguration(url: storageUrl) #endif do { @@ -498,6 +499,10 @@ extension PairedDevices { extension PairedDevices { @MainActor private func fetchAllPairedInfos() { + defer { + didLoadDevices = true + } + guard let modelContainer else { return } @@ -513,12 +518,10 @@ extension PairedDevices { self._pairedDevices = pairedDevices.reduce(into: [:]) { partialResult, deviceInfo in partialResult[deviceInfo.id] = deviceInfo } - didLoadDevices = true logger.debug("Initialized PairedDevices with \(self._pairedDevices.count) paired devices!") } catch { logger.error("Failed to fetch paired device info from disk: \(error)") } - didLoadDevices = true } @MainActor From 7d27b5362f5ed2c43ce021ec892cc7b60cfe2aee Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 2 Jul 2024 17:02:00 +0200 Subject: [PATCH 73/77] Full workarounds --- .../Measurements/StoredMeasurement.swift | 78 +++++++++++++++++-- .../SpeziDevicesUI/Devices/DevicesView.swift | 2 +- .../SpeziOmron/Devices/OmronWeightScale.swift | 1 + 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/Sources/SpeziDevices/Measurements/StoredMeasurement.swift b/Sources/SpeziDevices/Measurements/StoredMeasurement.swift index 93a2322..d3d84b7 100644 --- a/Sources/SpeziDevices/Measurements/StoredMeasurement.swift +++ b/Sources/SpeziDevices/Measurements/StoredMeasurement.swift @@ -66,6 +66,42 @@ private struct BloodPressureMeasurementSwiftDataWorkaroundContainer { // swiftli } +private struct WeightMeasurementSwiftDataWorkaroundContainer { // swiftlint:disable:this type_name + let weight: UInt16 + let unit: String + + let timestamp: DateTime? + + let userId: UInt8? + let bmi: UInt16? + let height: UInt16? + + var measurement: WeightMeasurement { + var info: WeightMeasurement.AdditionalInfo? + if let bmi, let height { + info = .init(bmi: bmi, height: height) + } + + return WeightMeasurement( + weight: weight, + unit: .init(rawValue: unit) ?? .si, + timeStamp: timestamp, + userId: userId, + additionalInfo: info + ) + } + + init(from measurement: WeightMeasurement) { + self.weight = measurement.weight + self.unit = measurement.unit.rawValue + self.timestamp = measurement.timeStamp + self.userId = measurement.userId + self.bmi = measurement.additionalInfo?.bmi + self.height = measurement.additionalInfo?.height + } +} + + // swiftlint:disable:next type_name private struct SwiftDataBluetoothHealthMeasurementWorkaroundContainer { enum MeasurementType: String, Codable { @@ -78,7 +114,7 @@ private struct SwiftDataBluetoothHealthMeasurementWorkaroundContainer { private var bloodPressureMeasurement: BloodPressureMeasurementSwiftDataWorkaroundContainer? private var bloodPressureFeatures: BloodPressureFeature.RawValue? - private var weightMeasurement: WeightMeasurement? + private var weightMeasurement: WeightMeasurementSwiftDataWorkaroundContainer? private var weightScaleFeatures: WeightScaleFeature.RawValue? var measurement: BluetoothHealthMeasurement { @@ -92,7 +128,7 @@ private struct SwiftDataBluetoothHealthMeasurementWorkaroundContainer { guard let weightMeasurement, let weightScaleFeatures else { preconditionFailure("Inconsistent type") } - return .weight(weightMeasurement, .init(rawValue: weightScaleFeatures)) + return .weight(weightMeasurement.measurement, .init(rawValue: weightScaleFeatures)) } } @@ -108,8 +144,7 @@ private struct SwiftDataBluetoothHealthMeasurementWorkaroundContainer { type = .weight bloodPressureMeasurement = nil bloodPressureFeatures = nil - // bloodPressureMeasurement2 = BloodPressureMeasurementCopy(from: .mock()) - weightMeasurement = measurement + weightMeasurement = .init(from: measurement) weightScaleFeatures = features.rawValue } } @@ -210,6 +245,39 @@ extension BloodPressureMeasurementSwiftDataWorkaroundContainer: Codable { } +extension WeightMeasurementSwiftDataWorkaroundContainer: Codable { + enum CodingKeys: CodingKey { + case weight + case unit + case timestamp + case userId + case bmi + case height + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.weight = try container.decode(UInt16.self, forKey: .weight) + self.unit = try container.decode(String.self, forKey: .unit) + self.timestamp = try container.decodeIfPresent(DateTime.self, forKey: .timestamp) + self.userId = try container.decodeIfPresent(UInt8.self, forKey: .userId) + self.bmi = try container.decodeIfPresent(UInt16.self, forKey: .bmi) + self.height = try container.decodeIfPresent(UInt16.self, forKey: .height) + } + + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.weight, forKey: .weight) + try container.encode(self.unit, forKey: .unit) + try container.encodeIfPresent(self.timestamp, forKey: .timestamp) + try container.encodeIfPresent(self.userId, forKey: .userId) + try container.encodeIfPresent(self.bmi, forKey: .bmi) + try container.encodeIfPresent(self.height, forKey: .height) + } +} + + extension SwiftDataBluetoothHealthMeasurementWorkaroundContainer: Codable { enum CodingKeys: CodingKey { case type @@ -230,7 +298,7 @@ extension SwiftDataBluetoothHealthMeasurementWorkaroundContainer: Codable { ) self.bloodPressureFeatures = try container.decodeIfPresent(BloodPressureFeature.RawValue.self, forKey: .bloodPressureFeatures) case .weight: - self.weightMeasurement = try container.decodeIfPresent(WeightMeasurement.self, forKey: .weightMeasurement) + self.weightMeasurement = try container.decodeIfPresent(WeightMeasurementSwiftDataWorkaroundContainer.self, forKey: .weightMeasurement) self.weightScaleFeatures = try container.decodeIfPresent(WeightScaleFeature.RawValue.self, forKey: .weightScaleFeatures) } } diff --git a/Sources/SpeziDevicesUI/Devices/DevicesView.swift b/Sources/SpeziDevicesUI/Devices/DevicesView.swift index ab9847f..776f53f 100644 --- a/Sources/SpeziDevicesUI/Devices/DevicesView.swift +++ b/Sources/SpeziDevicesUI/Devices/DevicesView.swift @@ -33,7 +33,7 @@ public struct DevicesView: View { pairingHint } } - .toolbar { // TODO: verify order! + .toolbar { ToolbarItem(placement: .primaryAction) { // indicate that we are scanning in the background if pairedDevices.isScanningForNearbyDevices && !pairedDevices.shouldPresentDevicePairing { diff --git a/Sources/SpeziOmron/Devices/OmronWeightScale.swift b/Sources/SpeziOmron/Devices/OmronWeightScale.swift index 5d3e6d2..d1ee394 100644 --- a/Sources/SpeziOmron/Devices/OmronWeightScale.swift +++ b/Sources/SpeziOmron/Devices/OmronWeightScale.swift @@ -57,6 +57,7 @@ public final class OmronWeightScale: BluetoothDevice, Identifiable, OmronHealthD pairedDevices.configure(device: self, accessing: $state, $advertisementData, $nearby) } if let measurements { + // TODO: measurements are doubled? measurements.configureReceivingMeasurements(for: self, on: weightScale) } } From 59e35a3d2b24324a78cff071ce58cf5abf260519 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 2 Jul 2024 17:07:29 +0200 Subject: [PATCH 74/77] Fix last measurement appearing --- .../Measurements/MeasurementsRecordedSheet.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/SpeziDevicesUI/Measurements/MeasurementsRecordedSheet.swift b/Sources/SpeziDevicesUI/Measurements/MeasurementsRecordedSheet.swift index d070e9d..34cb041 100644 --- a/Sources/SpeziDevicesUI/Measurements/MeasurementsRecordedSheet.swift +++ b/Sources/SpeziDevicesUI/Measurements/MeasurementsRecordedSheet.swift @@ -95,6 +95,9 @@ public struct MeasurementsRecordedSheet: View { .indexViewStyle(.page(backgroundDisplayMode: .always)) } else if let measurement = measurements.pendingMeasurements.first { MeasurementLayer(measurement: measurement) + .onAppear { + selectedMeasurement = measurement + } } } @@ -117,6 +120,7 @@ public struct MeasurementsRecordedSheet: View { dismiss() // TODO: maintain to show last measurement when dismissing! } discard: { guard let selectedMeasurement else { + print("None selected?") return } measurements.discardMeasurement(selectedMeasurement) From cee44d8840365ff9b53bde28e17f4689886e6eb2 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 2 Jul 2024 17:45:15 +0200 Subject: [PATCH 75/77] Docs and README --- README.md | 233 ++++++++++++++++-- Sources/SpeziDevices/HealthMeasurements.swift | 5 +- .../SpeziDevices.docc/SpeziDevices.md | 2 +- .../MeasurementsRecordedSheet.swift | 1 + .../Pairing/PairDeviceView.swift | 5 +- .../Scanning/LoadingSectionHeader.swift | 4 + .../Scanning/NearbyDeviceRow.swift | 8 + .../SpeziDevicesUI.docc/SpeziDevicesUI.md | 49 ++++ ...acteristicAccessor+OmronRecordAccess.swift | 12 +- Sources/SpeziOmron/OmronOptionService.swift | 12 +- .../SpeziOmron/SpeziOmron.docc/SpeziOmron.md | 32 ++- .../TestApp/MeasurementsTestView.swift | 3 - 12 files changed, 323 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index b9fc5a8..e3bfda5 100644 --- a/README.md +++ b/README.md @@ -2,53 +2,240 @@ This source file is part of the Stanford SpeziDevices open source project -SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) SPDX-License-Identifier: MIT --> -# TemplatePackage +# SpeziDevices [![Build and Test](https://github.com/StanfordSpezi/SpeziDevices/actions/workflows/build-and-test.yml/badge.svg)](https://github.com/StanfordSpezi/SpeziDevices/actions/workflows/build-and-test.yml) [![codecov](https://codecov.io/gh/StanfordSpezi/SpeziDevices/graph/badge.svg?token=pZeJyWYhAk)](https://codecov.io/gh/StanfordSpezi/SpeziDevices) +Support interactions with Bluetooth Devices. -## How To Use This Template +## Overview -The template repository contains a template Swift Package, including a continuous integration setup. +SpeziDevices provides three different targets: `SpeziDevices`, `SpeziDevicesUI` and `SpeziOmron`. -Follow these steps to customize it to your needs: -1. Rename the Swift Package. Be sure that you update the name in the `build-and-test.yml` GitHub Action accordingly. If you have multiple targets in your Swift Package, you need to pass the name of the Swift Package followed by an `-Package` as the scheme to the GitHub Action, e.g., `StanfordProject-Package` if your Swift Package is named `StanfordProject`. -2. If your Swift Package does not provide any user interface or does not require an iOS application environment to function, you can remove the `UITests` application from the `Tests` folder. You need to update the `build-and-test.yml` GitHub Action accordingly by removing the GitHub Action that builds and tests the application, removing the dependency from the code coverage upload step, and removing the UI test `.xresult` input from the code coverage test. -3. If your Swift Package uses UI test, you need to ... - - ... add it to the scheme editor (*Scheme > Edit Scheme*) and your targets to the "Build" configuration and ensure that it is built before the test app target when building for the "Test" configuration. It is not required to enable building for other configurations like "Analyze", "Run", "Profile", or "Archive". - - ... add it as a linked framework in the main target configuration (In your Xcode project settings, select your *test app target > General > Frameworks, Libraries, and Embedded Comments*). - - ... add ensure that the targets are all added in the code coverage settings of your .xctestplan file in the Xcode Project (*Shared Settings > Code Coverage > Code Coverage*). -4. You will either need to add the [CodeCov GitHub App](https://github.com/apps/codecov) or add a codecov.io token to your [GitHub Actions Secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets#creating-encrypted-secrets-for-an-environment) following the instructions of the [Codecov GitHub Action](https://github.com/marketplace/actions/codecov#usage). The StanfordBDHG organization already has the [CodeCov GitHub App](https://github.com/apps/codecov) installed. If you do not want to cover test coverage data, you can remove the code coverage job in the `build-and-test.yml` GitHub Action. -5. Adjust this README.md to describe your project and adjust the badges at the top to point to the correct GitHub Action of your repository and Codecov badge. -6. The Swift Package template includes a Swift Package Index configuration file to automatically build the package and [host the documentation on the Swift Package Index website](https://blog.swiftpackageindex.com/posts/auto-generating-auto-hosting-and-auto-updating-docc-documentation/). Adjust the `.spi.yml` file to include all targets that you want to build documentation for. You can follow the [instructions of the Swift Package Index](https://swiftpackageindex.com/add-a-package) to include your Swift Package in the Swift Package Index. You can link to the [API documentation](https://swiftpackageindex.com/StanfordBDHG/SwiftPackageTemplate/documentation) from your README file. -7. Adjust the CITATION.cff file to amend information about the new Swift Package ([learn more about CITATION files on GitHub](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-citation-files)) and [register the Swift Package on Zenodo](https://docs.github.com/en/repositories/archiving-a-github-repository/referencing-and-citing-content). +### SpeziDevices +SpeziDevices abstracts common interactions with Bluetooth devices that are implemented using +[SpeziBluetooth](https://swiftpackageindex.com/StanfordSpezi/SpeziBluetooth/documentation/spezibluetooth). +It supports pairing with devices and process health measurements. -## Installation +#### Pairing Devices -The project can be added to your Xcode project or Swift Package using the [Swift Package Manager](https://github.com/apple/swift-package-manager). +Pairing devices is a good way of making sure that your application only connects to fixed set of devices and doesn't accept data from +non-authorized devices. +Further, it might be necessary to ensure certain operations stay secure. -**Xcode:** For an Xcode project, follow the instructions on [adding package dependencies to your app](https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app). +Use the ``PairedDevices`` module to discover and pair ``PairableDevice``s and automatically manage connection establishment +of connected devices. -**Swift Package:** You can follow the [Swift Package Manager documentation about defining dependencies](https://github.com/apple/swift-package-manager/blob/main/Documentation/Usage.md#defining-dependencies) to add this project as a dependency to your Swift Package. +To support `PairedDevices`, you need to adopt the ``PairableDevice`` protocol for your device. +Optionally you can adopt the ``BatteryPoweredDevice`` protocol, if your device supports the +[`BatteryService`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetoothservices/batteryservice). +Once your device is loaded, register it with the `PairedDevices` module by calling the ``PairedDevices/configure(device:accessing:_:_:)`` method. +> [!IMPORTANT] +> Don't forget to configure the `PairedDevices` module in + your [`SpeziAppDelegate`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/speziappdelegate). + +```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) + } + + func handleSuccessfulPairing() { // called on events where a device can be considered paired (e.g., incoming notifications) + pairedDevices?.signalDevicePaired(self) + } +} +``` + +> [!TIP] +> To display and manage paired devices and support adding new paired devices, you can use the full-featured ``DevicesView`` view. + +#### Health Measurements + +Use the ``HealthMeasurements`` module to collect health measurements from nearby Bluetooth devices like connected weight scales or +blood pressure cuffs. + +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 ``MeasurementsRecordedSheet``. +Below is a short code example. + +```swift +import SpeziDevices +import SpeziDevicesUI + +struct MyHomeView: View { + @Environment(HealthMeasurements.self) private var measurements + + var body: some View { + @Bindable var measurements = measurements + ContentView() + .sheet(isPresented: $measurements.shouldPresentMeasurements) { + MeasurementsRecordedSheet { 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). + +### SpeziDevicesUI + +SpeziDevicesUI helps you to visualize Bluetooth device state and communicate interactions to the user. + +#### Displaying paired devices + +When managing paired devices using ``PairedDevices``, SpeziDevicesUI provides reusable View components to display paired devices. + +The ``DevicesView`` provides everything you need to pair and manage paired devices. +It shows already paired devices in a grid layout using the ``DevicesGrid``. Additionally, it places an add button in the toolbar +to discover new devices using the ``AccessorySetupSheet`` view. + +```swift +struct MyHomeView: View { + var body: some View { + TabView { + NavigationStack { + DevicesView(appName: "Example") { + Text("Provide helpful pairing instructions to the user.") + } + } + .tabItem { + Label("Devices", systemImage: "sensor.fill") + } + } + } +} +``` + +#### Displaying Measurements + +When managing measurements using ``HealthMeasurements``, you can use the ``MeasurementsRecordedSheet`` to display pending measurements. +Below is a short code example on how you would configure this view. + +```swift +struct MyHomeView: View { + @Environment(HealthMeasurements.self) private var measurements + + var body: some View { + @Bindable var measurements = measurements + ContentView() + .sheet(isPresented: $measurements.shouldPresentMeasurements) { + MeasurementsRecordedSheet { samples in + // save the array of HKSamples + } + } + } +} +``` + +> [!IMPORTANT] +> Don't forget to configure the `HealthMeasurements` module in + your [`SpeziAppDelegate`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/speziappdelegate). + +### SpeziOmron + +SpeziOmron extends SpeziDevices with support for Omron devices. This includes Omron-specific models, characteristics, services and fully reusable +device support. + +#### Omron Devices + +The ``OmronBloodPressureCuff`` and ``OmronWeightScale`` devices provide reusable device implementations for the Omron `BP5250` blood pressure cuff +and the Omron `SC-150` weight scale. +Both devices automatically integrate with the ``HealthMeasurements`` and ``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) +module. + +```swift +import SpeziBluetooth +import SpeziBluetoothServices +import SpeziDevices +import SpeziOmron + +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)) + } + + // If required, configure the PairedDevices and HealthMeasurements modules + PairedDevices() + HealthMeasurements() + } + } +} +``` + +## Setup + +You need to add the SpeziDevices Swift package to +[your app in Xcode](https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app#) or +[Swift package](https://developer.apple.com/documentation/xcode/creating-a-standalone-swift-package-with-xcode#Add-a-dependency-on-another-Swift-package). + ## License -This project is licensed under the MIT License. See [Licenses](https://github.com/StanfordBDHG/TemplatePackage/tree/main/LICENSES) for more information. +This project is licensed under the MIT License. See [Licenses](https://github.com/StanfordSpezi/SpeziDevices/tree/main/LICENSES) for more information. ## Contributors This project is developed as part of the Stanford Byers Center for Biodesign at Stanford University. -See [CONTRIBUTORS.md](https://github.com/StanfordBDHG/TemplatePackage/tree/main/CONTRIBUTORS.md) for a full list of all TemplatePackage contributors. +See [CONTRIBUTORS.md](https://github.com/StanfordSpezi/SpeziDevices/tree/main/CONTRIBUTORS.md) for a full list of all TemplatePackage contributors. -![Stanford Byers Center for Biodesign Logo](https://raw.githubusercontent.com/StanfordBDHG/.github/main/assets/biodesign-footer-light.png#gh-light-mode-only) -![Stanford Byers Center for Biodesign Logo](https://raw.githubusercontent.com/StanfordBDHG/.github/main/assets/biodesign-footer-dark.png#gh-dark-mode-only) +![Stanford Byers Center for Biodesign Logo](https://raw.githubusercontent.com/StanfordSpezi/.github/main/assets/Footer.png#gh-light-mode-only) +![Stanford Byers Center for Biodesign Logo](https://raw.githubusercontent.com/StanfordSpezi/.github/main/assets/Footer~dark.png#gh-dark-mode-only) diff --git a/Sources/SpeziDevices/HealthMeasurements.swift b/Sources/SpeziDevices/HealthMeasurements.swift index 2bd6c3c..d7a0bd6 100644 --- a/Sources/SpeziDevices/HealthMeasurements.swift +++ b/Sources/SpeziDevices/HealthMeasurements.swift @@ -57,8 +57,8 @@ import SwiftUI /// @Bindable var measurements = measurements /// ContentView() /// .sheet(isPresented: $measurements.shouldPresentMeasurements) { -/// MeasurementsRecordedSheet { measurement in -/// // handle saving the measurement +/// MeasurementsRecordedSheet { samples in +/// // save the array of HKSamples /// } /// } /// } @@ -72,7 +72,6 @@ import SwiftUI /// /// ### Configuring Health Measurements /// - ``init()`` -/// - ``init(_:)`` /// /// ### Register Devices /// - ``configureReceivingMeasurements(for:on:)-8cbd0`` diff --git a/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md b/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md index aea6080..5d8fc92 100644 --- a/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md +++ b/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md @@ -30,7 +30,7 @@ of connected devices. To support `PairedDevices`, you need to adopt the ``PairableDevice`` protocol for your device. Optionally you can adopt the ``BatteryPoweredDevice`` protocol, if your device supports the [`BatteryService`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetoothservices/batteryservice). -Once your device is loaded, register it with the `PairedDevices` module by calling the ``configure(device:accessing:_:_:)`` method. +Once your device is loaded, register it with the `PairedDevices` module by calling the ``PairedDevices/configure(device:accessing:_:_:)`` method. > Important: Don't forget to configure the `PairedDevices` module in diff --git a/Sources/SpeziDevicesUI/Measurements/MeasurementsRecordedSheet.swift b/Sources/SpeziDevicesUI/Measurements/MeasurementsRecordedSheet.swift index 34cb041..5ce9912 100644 --- a/Sources/SpeziDevicesUI/Measurements/MeasurementsRecordedSheet.swift +++ b/Sources/SpeziDevicesUI/Measurements/MeasurementsRecordedSheet.swift @@ -34,6 +34,7 @@ public struct MeasurementsRecordedSheet: View { guard let selectedMeasurement = measurements.pendingMeasurements.first else { preconditionFailure("Entered code path where selectedMeasurement was not set.") } + // TODO: modifying state while view update! self.selectedMeasurement = selectedMeasurement return selectedMeasurement } diff --git a/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift b/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift index c02234a..29dad69 100644 --- a/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift +++ b/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift @@ -31,6 +31,7 @@ struct PairDeviceView: View where Collection guard let selectedDeviceId = devices.first?.id else { preconditionFailure("Entered code path where selectedMeasurement was not set.") } + // TODO: modifying state while view update! self.selectedDeviceId = selectedDeviceId return selectedDeviceId } @@ -91,8 +92,8 @@ struct PairDeviceView: View where Collection Text("Pair") .frame(maxWidth: .infinity, maxHeight: 35) } - .buttonStyle(.borderedProminent) - .padding([.leading, .trailing], 36) + .buttonStyle(.borderedProminent) + .padding([.leading, .trailing], 36) } } diff --git a/Sources/SpeziDevicesUI/Scanning/LoadingSectionHeader.swift b/Sources/SpeziDevicesUI/Scanning/LoadingSectionHeader.swift index b38ba87..edde206 100644 --- a/Sources/SpeziDevicesUI/Scanning/LoadingSectionHeader.swift +++ b/Sources/SpeziDevicesUI/Scanning/LoadingSectionHeader.swift @@ -9,6 +9,10 @@ import SwiftUI +/// A section header that displays a title and an optional loading indicator. +/// +/// This view is useful to, e.g., render the Section header of a list of nearby peripherals. The ProgressView can be used to +/// communicate that the application is currently scanning for nearby Bluetooth peripherals. public struct LoadingSectionHeader: View { private let text: Text private let loading: Bool diff --git a/Sources/SpeziDevicesUI/Scanning/NearbyDeviceRow.swift b/Sources/SpeziDevicesUI/Scanning/NearbyDeviceRow.swift index 9fd4ce2..9f4973c 100644 --- a/Sources/SpeziDevicesUI/Scanning/NearbyDeviceRow.swift +++ b/Sources/SpeziDevicesUI/Scanning/NearbyDeviceRow.swift @@ -13,6 +13,7 @@ import SpeziViews import SwiftUI +/// A row that displays information of a nearby Bluetooth peripheral in a List view. public struct NearbyDeviceRow: View { private let peripheral: any GenericBluetoothPeripheral private let devicePrimaryActionClosure: () -> Void @@ -110,6 +111,13 @@ public struct NearbyDeviceRow: View { } + /// Create a new nearby device row. + /// - Parameters: + /// - peripheral: The nearby peripheral. + /// - primaryAction: The action that is executed when tapping the peripheral. + /// It is recommended to connect or disconnect devices when tapping on them. + /// - secondaryAction: The action that is executed when the device details button is pressed. + /// The device details button is displayed once the peripheral is connected. public init( peripheral: any GenericBluetoothPeripheral, primaryAction: @escaping () -> Void, diff --git a/Sources/SpeziDevicesUI/SpeziDevicesUI.docc/SpeziDevicesUI.md b/Sources/SpeziDevicesUI/SpeziDevicesUI.docc/SpeziDevicesUI.md index f8e49c3..773a6f4 100644 --- a/Sources/SpeziDevicesUI/SpeziDevicesUI.docc/SpeziDevicesUI.md +++ b/Sources/SpeziDevicesUI/SpeziDevicesUI.docc/SpeziDevicesUI.md @@ -16,6 +16,55 @@ SPDX-License-Identifier: MIT SpeziDevicesUI helps you to visualize Bluetooth device state and communicate interactions to the user. +### Displaying paired devices + +When managing paired devices using ``PairedDevices``, SpeziDevicesUI provides reusable View components to display paired devices. + +The ``DevicesView`` provides everything you need to pair and manage paired devices. +It shows already paired devices in a grid layout using the ``DevicesGrid``. Additionally, it places an add button in the toolbar +to discover new devices using the ``AccessorySetupSheet`` view. + +```swift +struct MyHomeView: View { + var body: some View { + TabView { + NavigationStack { + DevicesView(appName: "Example") { + Text("Provide helpful pairing instructions to the user.") + } + } + .tabItem { + Label("Devices", systemImage: "sensor.fill") + } + } + } +} +``` + +### Displaying Measurements + +When managing measurements using ``HealthMeasurements``, you can use the ``MeasurementsRecordedSheet`` to display pending measurements. +Below is a short code example on how you would configure this view. + +```swift +struct MyHomeView: View { + @Environment(HealthMeasurements.self) private var measurements + + var body: some View { + @Bindable var measurements = measurements + ContentView() + .sheet(isPresented: $measurements.shouldPresentMeasurements) { + MeasurementsRecordedSheet { samples in + // save the array of HKSamples + } + } + } +} +``` + +> Important: Don't forget to configure the `HealthMeasurements` module in + your [`SpeziAppDelegate`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/speziappdelegate). + ## Topics ### Presenting nearby devices diff --git a/Sources/SpeziOmron/Characteristic/CharacteristicAccessor+OmronRecordAccess.swift b/Sources/SpeziOmron/Characteristic/CharacteristicAccessor+OmronRecordAccess.swift index 4ede3af..00f2619 100644 --- a/Sources/SpeziOmron/Characteristic/CharacteristicAccessor+OmronRecordAccess.swift +++ b/Sources/SpeziOmron/Characteristic/CharacteristicAccessor+OmronRecordAccess.swift @@ -17,7 +17,9 @@ extension CharacteristicAccessor where Value == RecordAccessControlPoint) async throws { try await sendRequestExpectingGeneralResponse(.reportStoredRecords(content)) } @@ -26,7 +28,9 @@ extension CharacteristicAccessor where Value == RecordAccessControlPoint) async throws -> UInt16 { try await sendRequestExpectingValueResponse( .reportNumberOfStoredRecords(content), @@ -42,7 +46,9 @@ extension CharacteristicAccessor where Value == RecordAccessControlPoint UInt16 { try await sendRequestExpectingValueResponse( .reportSequenceNumberOfLatestRecords(), diff --git a/Sources/SpeziOmron/OmronOptionService.swift b/Sources/SpeziOmron/OmronOptionService.swift index aaa79db..415a1db 100644 --- a/Sources/SpeziOmron/OmronOptionService.swift +++ b/Sources/SpeziOmron/OmronOptionService.swift @@ -31,7 +31,9 @@ public final class OmronOptionService: BluetoothService, @unchecked Sendable { /// Once all records were notified, the method returns. /// /// - Parameter content: Select the records the request applies to. - /// - Throws: Throws a ``RecordAccessResponseFormatError`` if there was an unexpected response or a ``RecordAccessResponseCode`` if the request failed. + /// - Throws: Throws a [`RecordAccessResponseFormatError`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetoothservices/recordaccessresponseformaterror) + /// if there was an unexpected response or a [`RecordAccessResponseCode`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetoothservices/recordaccessresponsecode) + /// if the request failed. public func reportStoredRecords(_ content: RecordAccessOperationContent) async throws { try await $recordAccessControlPoint.reportStoredRecords(content) } @@ -40,7 +42,9 @@ public final class OmronOptionService: BluetoothService, @unchecked Sendable { /// /// - Parameter content: Select the records the request applies to. /// - Returns: The number of stored records. - /// - Throws: Throws a ``RecordAccessResponseFormatError`` if there was an unexpected response or a ``RecordAccessResponseCode`` if the request failed. + /// - Throws: Throws a [`RecordAccessResponseFormatError`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetoothservices/recordaccessresponseformaterror) + /// if there was an unexpected response or a [`RecordAccessResponseCode`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetoothservices/recordaccessresponsecode) + /// if the request failed. public func reportNumberOfStoredRecords(_ content: RecordAccessOperationContent) async throws -> UInt16 { try await $recordAccessControlPoint.reportNumberOfStoredRecords(content) } @@ -48,7 +52,9 @@ public final class OmronOptionService: BluetoothService, @unchecked Sendable { /// Request the sequence number of the latest records. /// /// - Returns: The sequence number of the latest record. - /// - Throws: Throws a ``RecordAccessResponseFormatError`` if there was an unexpected response or a ``RecordAccessResponseCode`` if the request failed. + /// - Throws: Throws a [`RecordAccessResponseFormatError`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetoothservices/recordaccessresponseformaterror) + /// if there was an unexpected response or a [`RecordAccessResponseCode`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetoothservices/recordaccessresponsecode) + /// if the request failed. public func reportSequenceNumberOfLatestRecords() async throws -> UInt16 { try await $recordAccessControlPoint.reportSequenceNumberOfLatestRecords() } diff --git a/Sources/SpeziOmron/SpeziOmron.docc/SpeziOmron.md b/Sources/SpeziOmron/SpeziOmron.docc/SpeziOmron.md index 73f844d..1f432ff 100644 --- a/Sources/SpeziOmron/SpeziOmron.docc/SpeziOmron.md +++ b/Sources/SpeziOmron/SpeziOmron.docc/SpeziOmron.md @@ -19,11 +19,33 @@ device support. ### Omron Devices - - -- models (e.g., manufcaturer data) -- characteristic & services -- device implementations +The ``OmronBloodPressureCuff`` and ``OmronWeightScale`` devices provide reusable device implementations for the Omron `BP5250` blood pressure cuff +and the Omron `SC-150` weight scale. +Both devices automatically integrate with the ``HealthMeasurements`` and ``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) +module. + +```swift +import SpeziBluetooth +import SpeziBluetoothServices +import SpeziDevices +import SpeziOmron + +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)) + } + + // If required, configure the PairedDevices and HealthMeasurements modules + PairedDevices() + HealthMeasurements() + } + } +} +``` ## Topics diff --git a/Tests/UITests/TestApp/MeasurementsTestView.swift b/Tests/UITests/TestApp/MeasurementsTestView.swift index 7106a59..1eec233 100644 --- a/Tests/UITests/TestApp/MeasurementsTestView.swift +++ b/Tests/UITests/TestApp/MeasurementsTestView.swift @@ -58,9 +58,6 @@ struct MeasurementsTestView: View { } } } - .onAppear { - healthMeasurements.clearStorage() - } } init() {} From 2b500c7e938f87812e6996a210f056d4d25eae7c Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 2 Jul 2024 18:13:55 +0200 Subject: [PATCH 76/77] Final touches --- Sources/SpeziDevices/HealthMeasurements.swift | 2 +- .../MeasurementsRecordedSheet.swift | 43 ++++++++++++------- .../Pairing/PairDeviceView.swift | 17 ++++---- .../SpeziOmron/Devices/OmronWeightScale.swift | 11 ----- 4 files changed, 37 insertions(+), 36 deletions(-) diff --git a/Sources/SpeziDevices/HealthMeasurements.swift b/Sources/SpeziDevices/HealthMeasurements.swift index d7a0bd6..6396394 100644 --- a/Sources/SpeziDevices/HealthMeasurements.swift +++ b/Sources/SpeziDevices/HealthMeasurements.swift @@ -233,7 +233,7 @@ public final class HealthMeasurements: @unchecked Sendable { @MainActor @discardableResult public func discardMeasurement(_ measurement: HealthKitMeasurement) -> Bool { - guard let index = self.pendingMeasurements.firstIndex(where: { $0.id == measurement.id }) else { + guard let index = self.pendingMeasurements.firstIndex(of: measurement) else { return false } let element = self.pendingMeasurements.remove(at: index) diff --git a/Sources/SpeziDevicesUI/Measurements/MeasurementsRecordedSheet.swift b/Sources/SpeziDevicesUI/Measurements/MeasurementsRecordedSheet.swift index 5ce9912..e9ce9c6 100644 --- a/Sources/SpeziDevicesUI/Measurements/MeasurementsRecordedSheet.swift +++ b/Sources/SpeziDevicesUI/Measurements/MeasurementsRecordedSheet.swift @@ -30,13 +30,8 @@ public struct MeasurementsRecordedSheet: View { @MainActor private var forcedUnwrappedMeasurement: Binding { Binding { - guard let selectedMeasurement else { - guard let selectedMeasurement = measurements.pendingMeasurements.first else { - preconditionFailure("Entered code path where selectedMeasurement was not set.") - } - // TODO: modifying state while view update! - self.selectedMeasurement = selectedMeasurement - return selectedMeasurement + guard let selectedMeasurement = selectedMeasurement ?? measurements.pendingMeasurements.first else { + preconditionFailure("Entered code path where selectedMeasurement was not set.") } return selectedMeasurement } set: { newValue in @@ -68,6 +63,11 @@ public struct MeasurementsRecordedSheet: View { .viewStateAlert(state: $viewState) .interactiveDismissDisabled(viewState != .idle) .dynamicTypeSize(.xSmall...DynamicTypeSize.accessibility3) + .onChange(of: selectedMeasurement, initial: true) { + if selectedMeasurement == nil { + selectedMeasurement = measurements.pendingMeasurements.first + } + } } } .toolbar { @@ -96,9 +96,6 @@ public struct MeasurementsRecordedSheet: View { .indexViewStyle(.page(backgroundDisplayMode: .always)) } else if let measurement = measurements.pendingMeasurements.first { MeasurementLayer(measurement: measurement) - .onAppear { - selectedMeasurement = measurement - } } } @@ -115,20 +112,21 @@ public struct MeasurementsRecordedSheet: View { throw error } - measurements.discardMeasurement(selectedMeasurement) logger.info("Saved measurement: \(String(describing: selectedMeasurement))") - dismiss() // TODO: maintain to show last measurement when dismissing! + dismiss() + + discardSelectedMeasurement(selectedMeasurement) } discard: { guard let selectedMeasurement else { - print("None selected?") return } - measurements.discardMeasurement(selectedMeasurement) if measurements.pendingMeasurements.isEmpty { - dismiss() // TODO: maintain to show last measurement when dismissing! + dismiss() } + + discardSelectedMeasurement(selectedMeasurement) } } @@ -137,6 +135,21 @@ public struct MeasurementsRecordedSheet: View { public init(save saveSamples: @escaping ([HKSample]) async throws -> Void) { self.saveSamples = saveSamples } + + + @MainActor + private func discardSelectedMeasurement(_ measurement: HealthKitMeasurement) { + guard let index = measurements.pendingMeasurements.firstIndex(of: measurement) else { + return + } + + measurements.discardMeasurement(measurement) + if index >= measurements.pendingMeasurements.count { + selectedMeasurement = measurements.pendingMeasurements.last + } else { + selectedMeasurement = measurements.pendingMeasurements[index] + } + } } diff --git a/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift b/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift index 29dad69..8567d7c 100644 --- a/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift +++ b/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift @@ -27,13 +27,8 @@ struct PairDeviceView: View where Collection private var forcedUnwrappedDeviceId: Binding { Binding { - guard let selectedDeviceId else { - guard let selectedDeviceId = devices.first?.id else { - preconditionFailure("Entered code path where selectedMeasurement was not set.") - } - // TODO: modifying state while view update! - self.selectedDeviceId = selectedDeviceId - return selectedDeviceId + guard let selectedDeviceId = selectedDeviceId ?? devices.first?.id else { + preconditionFailure("Entered code path where selectedMeasurement was not set.") } return selectedDeviceId } set: { newValue in @@ -59,7 +54,10 @@ struct PairDeviceView: View where Collection .tag(device.id) } } - .onChange(of: selectedDeviceId) { + .onChange(of: selectedDeviceId, initial: true) { + if selectedDeviceId == nil { + self.selectedDeviceId = devices.first?.id + } selectedDevice = devices.first(where: { $0.id == selectedDeviceId }) } .tabViewStyle(.page) @@ -121,7 +119,8 @@ struct PairDeviceView: View where Collection MockDevice.createMockDevice(name: "Device 1"), MockDevice.createMockDevice(name: "Device 2") ] - PairDeviceView(devices: device, appName: "Example", state: .constant(.discovery)) { _ in + PairDeviceView(devices: device, appName: "Example", state: .constant(.discovery)) { device in + print("Pairing \(device.label)") } } } diff --git a/Sources/SpeziOmron/Devices/OmronWeightScale.swift b/Sources/SpeziOmron/Devices/OmronWeightScale.swift index d1ee394..3a35342 100644 --- a/Sources/SpeziOmron/Devices/OmronWeightScale.swift +++ b/Sources/SpeziOmron/Devices/OmronWeightScale.swift @@ -57,7 +57,6 @@ public final class OmronWeightScale: BluetoothDevice, Identifiable, OmronHealthD pairedDevices.configure(device: self, accessing: $state, $advertisementData, $nearby) } if let measurements { - // TODO: measurements are doubled? measurements.configureReceivingMeasurements(for: self, on: weightScale) } } @@ -80,16 +79,6 @@ public final class OmronWeightScale: BluetoothDevice, Identifiable, OmronHealthD @MainActor private func handleCurrentTimeChange(_ time: CurrentTime) { - /* - TODO: what to do now with pairing? - if case .pairingMode = manufacturerData?.pairingMode, - let dateOfConnection, - abs(Date.now.timeIntervalSince1970 - dateOfConnection.timeIntervalSince1970) < 1 { - // if its pairing mode, and we just connected, we ignore the first current time notification as its triggered - // because of the notification registration. - return - } -*/ logger.debug("Received updated device time for \(self.label): \(String(describing: time))") let paired = pairedDevices?.signalDevicePaired(self) == true if paired { From 50a6c0a2d8562bf84546cb60367661e0ec5d286f Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 2 Jul 2024 18:50:33 +0200 Subject: [PATCH 77/77] Update page indicator tests --- .../HealthMeasurementsTests.swift | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/Tests/UITests/TestAppUITests/HealthMeasurementsTests.swift b/Tests/UITests/TestAppUITests/HealthMeasurementsTests.swift index d78ca80..94442e2 100644 --- a/Tests/UITests/TestAppUITests/HealthMeasurementsTests.swift +++ b/Tests/UITests/TestAppUITests/HealthMeasurementsTests.swift @@ -117,12 +117,12 @@ class HealthMeasurementsTests: XCTestCase { XCTAssert(app.buttons["Save"].exists) XCTAssert(app.buttons["Discard"].exists) - XCTAssert(app.steppers["Page"].exists) - let page1Value = try XCTUnwrap(app.steppers["Page"].value as? String, "Unexpected value \(String(describing: app.steppers["Page"].value))") - XCTAssertEqual(page1Value, "Page 1 of 2") - app.steppers["Page"].coordinate(withNormalizedOffset: .init(dx: 0.8, dy: 0.5)).tap() - let page2Value = try XCTUnwrap(app.steppers["Page"].value as? String, "Unexpected value \(String(describing: app.steppers["Page"].value))") - XCTAssertEqual(page2Value, "Page 2 of 2") + XCTAssert(app.pageIndicators.firstMatch.exists) + let page1Value = try XCTUnwrap(app.pageIndicators.firstMatch.value as? String, "Unexpected value \(String(describing: app.steppers["Page"].value))") + XCTAssertEqual(page1Value, "page 1 of 2") + app.pageIndicators.firstMatch.coordinate(withNormalizedOffset: .init(dx: 0.8, dy: 0.5)).tap() + let page2Value = try XCTUnwrap(app.pageIndicators.firstMatch.value as? String, "Unexpected value \(String(describing: app.steppers["Page"].value))") + XCTAssertEqual(page2Value, "page 2 of 2") XCTAssert(app.staticTexts["Measurement Recorded"].waitForExistence(timeout: 2.0)) XCTAssert(app.staticTexts["42 kg"].exists) @@ -141,5 +141,12 @@ class HealthMeasurementsTests: XCTestCase { XCTAssert(app.staticTexts["42 kg"].waitForExistence(timeout: 2.0)) XCTAssert(app.staticTexts["179 cm, 23 BMI"].exists) + + XCTAssert(app.buttons["Discard"].waitForExistence(timeout: 0.5)) + app.buttons["Discard"].tap() + + XCTAssert(app.staticTexts["No Pending Measurements"].waitForExistence(timeout: 2.0)) + XCTAssert(app.navigationBars.buttons["Dismiss"].exists) + app.navigationBars.buttons["Dismiss"].tap() } }