├── Tests ├── UITests │ ├── TestApp │ │ ├── Assets.xcassets │ │ │ ├── Contents.json │ │ │ ├── AccentColor.colorset │ │ │ │ ├── Contents.json │ │ │ │ └── Contents.json.license │ │ │ ├── Contents.json.license │ │ │ └── AppIcon.appiconset │ │ │ │ ├── Contents.json.license │ │ │ │ └── Contents.json │ │ ├── Info.plist │ │ ├── Info.plist.license │ │ ├── TestApp.entitlements.license │ │ ├── Views │ │ │ ├── SearchingNearbyDevicesView.swift │ │ │ ├── BluetoothStateSection.swift │ │ │ └── DeviceRowView.swift │ │ ├── TestAppDelegate.swift │ │ ├── BluetoothManagerView.swift │ │ ├── TestDeviceView.swift │ │ ├── TestApp.swift │ │ ├── TestDevice.swift │ │ ├── BluetoothModuleView.swift │ │ └── RetrievePairedDevicesView.swift │ ├── UITests.xcodeproj │ │ ├── project.xcworkspace │ │ │ ├── contents.xcworkspacedata │ │ │ ├── contents.xcworkspacedata.license │ │ │ └── xcshareddata │ │ │ │ ├── IDEWorkspaceChecks.plist.license │ │ │ │ └── IDEWorkspaceChecks.plist │ │ ├── project.pbxproj.license │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ └── TestApp.xcscheme.license │ ├── TestApp.xctestplan.license │ ├── TestApp.xctestplan │ └── TestAppUITests │ │ └── BluetoothManagerTests.swift └── SpeziBluetoothServicesTests │ ├── DeviceInformationTests.swift │ ├── BluetoothServicesTests.swift │ └── HealthThermometerTests.swift ├── Sources ├── SpeziBluetooth │ ├── Resources │ │ └── Localizable.xcstrings.license │ ├── Utils │ │ ├── Box.swift │ │ ├── Reference.swift │ │ ├── ConnectedDevices.swift │ │ ├── ConnectedDevicesModel.swift │ │ ├── BTUUID.swift │ │ └── ChangeSubscriptions.swift │ ├── CoreBluetooth │ │ ├── Utilities │ │ │ ├── CharacteristicOnChangeHandler.swift │ │ │ ├── BluetoothWorkItem.swift │ │ │ ├── DiscoveryStaleTimer.swift │ │ │ ├── KVOStateDidChangeObserver.swift │ │ │ └── ValueObservable.swift │ │ ├── Model │ │ │ ├── WriteType.swift │ │ │ ├── CharacteristicLocator.swift │ │ │ ├── StateRegistration.swift │ │ │ ├── ManufacturerIdentifier.swift │ │ │ ├── PeripheralState.swift │ │ │ ├── OnChangeRegistration.swift │ │ │ ├── BluetoothState.swift │ │ │ ├── ManagedAtomicMainActorBuffered.swift │ │ │ └── MainActorBuffered.swift │ │ ├── Extensions │ │ │ ├── CBCharacteristicProperties+Props.swift │ │ │ ├── CBPeripheral+DebugIdentifier.swift │ │ │ └── CBError+LocalizedError.swift │ │ └── Configuration │ │ │ ├── DiscoveryDescription.swift │ │ │ ├── CharacteristicDescription.swift │ │ │ ├── ServiceDescription.swift │ │ │ └── DeviceDescription.swift │ ├── Model │ │ ├── Visitor │ │ │ ├── BaseVisitor.swift │ │ │ ├── ServiceVisitor.swift │ │ │ └── DeviceVisitor.swift │ │ ├── PropertySupport │ │ │ ├── DeviceActionPeripheralInjection.swift │ │ │ ├── DeviceActionTestInjections.swift │ │ │ ├── DeviceActionAccessor.swift │ │ │ ├── ServicePeripheralInjection.swift │ │ │ ├── ServiceAccessor.swift │ │ │ ├── DeviceStateTestInjections.swift │ │ │ ├── CharacteristicTestInjections.swift │ │ │ └── DeviceStatePeripheralInjection.swift │ │ ├── Characteristic │ │ │ ├── ControlPointCharacteristic.swift │ │ │ └── ControlPointSupport.swift │ │ ├── Actions │ │ │ ├── BluetoothConnectAction.swift │ │ │ ├── BluetoothDisconnectAction.swift │ │ │ ├── ReadRSSIAction.swift │ │ │ ├── BluetoothPeripheralAction.swift │ │ │ └── DeviceActions.swift │ │ ├── BluetoothService.swift │ │ ├── SemanticModel │ │ │ └── DeviceDescriptionParser.swift │ │ └── BluetoothDevice.swift │ ├── AccessorySetupKit │ │ ├── ManufacturerIdentifier+Identifier.swift │ │ ├── ASDiscoveryDescriptor.Range+Description.swift │ │ ├── DeviceVariantCriteria+ASDiscoveryDescriptor.swift │ │ ├── ASAccessoryEventType+Description.swift │ │ ├── DiscoveryCriteria+Descriptor.swift │ │ ├── AccessoryEventRegistration.swift │ │ └── DescriptorAspect+ASDiscoveryDescriptor.swift │ ├── Environment │ │ ├── MinimumRSSIEnvironmentKey.swift │ │ ├── AdvertisementStaleIntervalEnvironmentKey.swift │ │ └── SurroundingScanModifiers.swift │ ├── Configuration │ │ ├── Apperance │ │ │ ├── Appearance.swift │ │ │ ├── Variant.swift │ │ │ └── DeviceAppearance.swift │ │ ├── DeviceDiscoveryDescriptor.swift │ │ ├── Discover.swift │ │ └── DiscoveryDescriptorBuilder.swift │ ├── SpeziBluetooth.docc │ │ ├── CoreBluetooth-Framework.md │ │ └── AccessorySetupKit-Framework.md │ └── Modifier │ │ ├── BluetoothScanner.swift │ │ ├── BluetoothScanningOptionsModifier.swift │ │ └── ConnectedDevicesEnvironmentModifier.swift ├── SpeziBluetoothServices │ ├── BluetoothServices.docc │ │ ├── BluetoothServices.md │ │ ├── Services.md │ │ └── Characteristics.md │ ├── TestingSupport │ │ ├── TestService.swift │ │ └── CBUUID+Characteristics.swift │ ├── Services │ │ ├── BatteryService.swift │ │ ├── WeightScaleService.swift │ │ ├── PulseOximeterService.swift │ │ ├── BloodPressureService.swift │ │ ├── HealthThermometerService.swift │ │ └── DeviceInformationService.swift │ └── Characteristics │ │ ├── MeasurementInterval.swift │ │ ├── RecordAccess │ │ ├── GenericOperand │ │ │ ├── RecordAccessFilterType.swift │ │ │ └── RecordAccessGeneralResponse.swift │ │ ├── RecordAccessResponseFormatError.swift │ │ ├── RecordAccessOperationContent.swift │ │ ├── RecordAccessOperand.swift │ │ ├── RecordAccessOperator.swift │ │ ├── RecordAccessControlPoint+Operations.swift │ │ ├── RecordAccessResponseCode.swift │ │ └── RecordAccessControlPoint.swift │ │ ├── Time │ │ └── DayOfWeek.swift │ │ └── TemperatureType.swift └── TestPeripheral │ └── TestPeripheral.docc │ ├── TestPeripheral.md │ └── Service-Setup.md ├── bin ├── edu.stanford.spezi.bluetooth.testperipheral.plist.template.license └── edu.stanford.spezi.bluetooth.testperipheral.plist.template ├── .spi.yml ├── CONTRIBUTORS.md ├── .github └── workflows │ ├── monthly-markdown-link-check.yml │ ├── static-analysis.yml │ └── build-and-test.yml ├── .gitignore ├── CITATION.cff ├── LICENSE.md └── LICENSES └── MIT.txt /Tests/UITests/TestApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Tests/UITests/TestApp/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Tests/UITests/UITests.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Tests/UITests/TestApp.xctestplan.license: -------------------------------------------------------------------------------- 1 | This source file is part of the Stanford Spezi open-source project 2 | 3 | SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) 4 | 5 | SPDX-License-Identifier: MIT -------------------------------------------------------------------------------- /Tests/UITests/TestApp/Info.plist.license: -------------------------------------------------------------------------------- 1 | This source file is part of the Stanford Spezi open-source project 2 | 3 | SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 4 | 5 | SPDX-License-Identifier: MIT -------------------------------------------------------------------------------- /Tests/UITests/TestApp/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Tests/UITests/TestApp/TestApp.entitlements.license: -------------------------------------------------------------------------------- 1 | This source file is part of the Stanford Spezi open-source project 2 | 3 | SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 4 | 5 | SPDX-License-Identifier: MIT -------------------------------------------------------------------------------- /Tests/UITests/UITests.xcodeproj/project.pbxproj.license: -------------------------------------------------------------------------------- 1 | This source file is part of the Stanford Spezi open-source project 2 | 3 | SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) 4 | 5 | SPDX-License-Identifier: MIT -------------------------------------------------------------------------------- /Tests/UITests/TestApp/Assets.xcassets/Contents.json.license: -------------------------------------------------------------------------------- 1 | This source file is part of the Stanford Spezi open-source project 2 | 3 | SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) 4 | 5 | SPDX-License-Identifier: MIT -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Resources/Localizable.xcstrings.license: -------------------------------------------------------------------------------- 1 | This source file is part of the Stanford Spezi open-source project 2 | 3 | SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) 4 | 5 | SPDX-License-Identifier: MIT -------------------------------------------------------------------------------- /bin/edu.stanford.spezi.bluetooth.testperipheral.plist.template.license: -------------------------------------------------------------------------------- 1 | This source file is part of the Stanford Spezi open-source project 2 | 3 | SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 4 | 5 | SPDX-License-Identifier: MIT -------------------------------------------------------------------------------- /Tests/UITests/TestApp/Assets.xcassets/AccentColor.colorset/Contents.json.license: -------------------------------------------------------------------------------- 1 | This source file is part of the Stanford Spezi open-source project 2 | 3 | SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) 4 | 5 | SPDX-License-Identifier: MIT -------------------------------------------------------------------------------- /Tests/UITests/TestApp/Assets.xcassets/AppIcon.appiconset/Contents.json.license: -------------------------------------------------------------------------------- 1 | This source file is part of the Stanford Spezi open-source project 2 | 3 | SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) 4 | 5 | SPDX-License-Identifier: MIT -------------------------------------------------------------------------------- /Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme.license: -------------------------------------------------------------------------------- 1 | This source file is part of the Stanford Spezi open-source project 2 | 3 | SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) 4 | 5 | SPDX-License-Identifier: MIT -------------------------------------------------------------------------------- /Tests/UITests/UITests.xcodeproj/project.xcworkspace/contents.xcworkspacedata.license: -------------------------------------------------------------------------------- 1 | This source file is part of the Stanford Spezi open-source project 2 | 3 | SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) 4 | 5 | SPDX-License-Identifier: MIT -------------------------------------------------------------------------------- /Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist.license: -------------------------------------------------------------------------------- 1 | This source file is part of the Stanford Spezi open-source project 2 | 3 | SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) 4 | 5 | SPDX-License-Identifier: MIT -------------------------------------------------------------------------------- /Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Tests/UITests/TestApp/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "platform" : "watchos", 11 | "size" : "1024x1024" 12 | } 13 | ], 14 | "info" : { 15 | "author" : "xcode", 16 | "version" : 1 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | # 2 | # This source file is part of the Stanford Spezi open source project 3 | # 4 | # SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | # 6 | # SPDX-License-Identifier: MIT 7 | # 8 | 9 | version: 1 10 | builder: 11 | configs: 12 | - platform: ios 13 | documentation_targets: 14 | - SpeziBluetooth 15 | - SpeziBluetoothServices 16 | - TestPeripheral 17 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Utils/Box.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import Foundation 10 | import SpeziFoundation 11 | 12 | 13 | class Box { 14 | var value: Value 15 | 16 | init(_ value: Value) { 17 | self.value = value 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/CoreBluetooth/Utilities/CharacteristicOnChangeHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import Foundation 10 | 11 | 12 | enum CharacteristicOnChangeHandler { 13 | case value(_ closure: (Data) -> Void) 14 | case instance(_ closure: (GATTCharacteristic?) -> Void) 15 | } 16 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/CoreBluetooth/Model/WriteType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | 10 | /// Determine the type of a Bluetooth write operation. 11 | public enum WriteType { 12 | /// A write expecting an acknowledgment. 13 | case withResponse 14 | /// An unacknowledged write. 15 | case withoutResponse 16 | } 17 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | 12 | 13 | SpeziBluetooth contributors 14 | ==================== 15 | 16 | * [Andreas Bauer](https://github.com/bauer-andreas) 17 | * [Lukas Kollmer](https://github.com/lukaskollmer) 18 | * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) 19 | -------------------------------------------------------------------------------- /.github/workflows/monthly-markdown-link-check.yml: -------------------------------------------------------------------------------- 1 | # 2 | # This source file is part of the Stanford Spezi open source project 3 | # 4 | # SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | # 6 | # SPDX-License-Identifier: MIT 7 | # 8 | 9 | name: Monthly Markdown Link Check 10 | 11 | on: 12 | # Runs at midnight on the first of every month 13 | schedule: 14 | - cron: "0 0 1 * *" 15 | 16 | jobs: 17 | markdown_link_check: 18 | name: Markdown Link Check 19 | uses: StanfordBDHG/.github/.github/workflows/markdown-link-check.yml@v2 -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/CoreBluetooth/Extensions/CBCharacteristicProperties+Props.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import CoreBluetooth 10 | 11 | 12 | extension CBCharacteristicProperties { 13 | var supportsNotifications: Bool { 14 | contains(.notify) || contains(.notifyEncryptionRequired) 15 | || contains(.indicate) || contains(.indicateEncryptionRequired) // indicate is notify whith an ACK 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # 2 | # This source file is part of the Stanford Spezi open source project 3 | # 4 | # SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | # 6 | # SPDX-License-Identifier: MIT 7 | # 8 | 9 | # Swift Package Manager 10 | Package.resolved 11 | *.xcodeproj 12 | .swiftpm 13 | .build 14 | .xcodebuild 15 | .derivedData 16 | coverage.lcov 17 | *.xcresult 18 | 19 | # IDE related folders 20 | .idea 21 | 22 | # Xcode User settings 23 | xcuserdata/ 24 | 25 | # Other files 26 | .DS_Store 27 | .env 28 | 29 | # Documentation generation 30 | *.doccarchive 31 | docs/ 32 | 33 | # UITests Project 34 | !UITests.xcodeproj 35 | -------------------------------------------------------------------------------- /Tests/UITests/TestApp/Views/SearchingNearbyDevicesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import SwiftUI 10 | 11 | 12 | struct SearchingNearbyDevicesView: View { 13 | var body: some View { 14 | VStack { 15 | Text("Searching for nearby devices ...") 16 | .foregroundColor(.secondary) 17 | ProgressView() 18 | } 19 | .frame(maxWidth: .infinity) 20 | .listRowBackground(Color.clear) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | # 2 | # This source file is part of the Stanford Spezi open source project 3 | # 4 | # SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | # 6 | # SPDX-License-Identifier: MIT 7 | # 8 | 9 | cff-version: 1.2.0 10 | message: "If you use this software, please cite it as below." 11 | authors: 12 | - family-names: "Schmiedmayer" 13 | given-names: "Paul" 14 | orcid: "https://orcid.org/0000-0002-8607-9148" 15 | - family-names: "Bauer" 16 | given-names: "Andreas" 17 | orcid: "https://orcid.org/0000-0002-1680-237X" 18 | title: "SpeziBluetooth" 19 | doi: 10.5281/zenodo.10020080 20 | url: "https://github.com/StanfordSpezi/SpeziBluetooth" 21 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/CoreBluetooth/Extensions/CBPeripheral+DebugIdentifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import CoreBluetooth 10 | 11 | 12 | // CustomDebugStringConvertible is already implemented for NSObjects. So we just define a custom property 13 | extension CBPeripheral { 14 | var debugIdentifier: String { 15 | if let name { 16 | "'\(name)' @ \(identifier)" 17 | } else { 18 | "\(identifier)" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Tests/UITests/TestApp/TestAppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import Spezi 10 | import SpeziBluetooth 11 | @_spi(TestingSupport) 12 | import SpeziBluetoothServices 13 | import SwiftUI 14 | 15 | 16 | class TestAppDelegate: SpeziAppDelegate { 17 | override var configuration: Configuration { 18 | Configuration { 19 | Bluetooth { 20 | Discover(TestDevice.self, by: .advertisedService(TestService.self)) 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Model/Visitor/BaseVisitor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | 10 | @SpeziBluetooth 11 | protocol BaseVisitor { 12 | mutating func visit(_ action: DeviceAction) 13 | 14 | mutating func visit(_ state: DeviceState) 15 | } 16 | 17 | 18 | extension BaseVisitor { 19 | func visit(_ action: DeviceAction) {} 20 | 21 | func visit(_ state: DeviceState) {} 22 | } 23 | -------------------------------------------------------------------------------- /Sources/SpeziBluetoothServices/BluetoothServices.docc/BluetoothServices.md: -------------------------------------------------------------------------------- 1 | # ``SpeziBluetoothServices`` 2 | 3 | Reusable Bluetooth Service and Characteristic implementations. 4 | 5 | 14 | 15 | ## Overview 16 | 17 | The `BluetoothServices` target provides several reusable components when developing Bluetooth peripherals 18 | with standardized services and characteristics. 19 | 20 | ## Topics 21 | 22 | ### Articles 23 | 24 | - 25 | - 26 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/AccessorySetupKit/ManufacturerIdentifier+Identifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | #if canImport(AccessorySetupKit) && !os(macOS) 10 | import AccessorySetupKit 11 | 12 | 13 | extension ManufacturerIdentifier { 14 | /// Retrieve the `ASBluetoothCompanyIdentifier` representation for the manufacturer identifier. 15 | @available(iOS 18.0, *) 16 | public var bluetoothCompanyIdentifier: ASBluetoothCompanyIdentifier { 17 | ASBluetoothCompanyIdentifier(rawValue) 18 | } 19 | } 20 | #endif 21 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Model/PropertySupport/DeviceActionPeripheralInjection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | 10 | final class DeviceActionPeripheralInjection: Sendable { 11 | private let bluetooth: Bluetooth 12 | let peripheral: BluetoothPeripheral 13 | 14 | 15 | init(bluetooth: Bluetooth, peripheral: BluetoothPeripheral) { 16 | self.bluetooth = bluetooth 17 | self.peripheral = peripheral 18 | } 19 | 20 | 21 | deinit { 22 | bluetooth.notifyDeviceDeinit(for: peripheral.id) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/SpeziBluetoothServices/BluetoothServices.docc/Services.md: -------------------------------------------------------------------------------- 1 | # Services 2 | 3 | Reusable implementations of standardized Bluetooth Services. 4 | 5 | 14 | 15 | ## Overview 16 | 17 | Below is a list of reusable Bluetooth service implementations of standardized Bluetooth services. 18 | 19 | ## Topics 20 | 21 | ### Core Services 22 | 23 | - ``BatteryService`` 24 | - ``CurrentTimeService`` 25 | - ``DeviceInformationService`` 26 | 27 | ### Health Domain 28 | 29 | - ``BloodPressureService`` 30 | - ``HealthThermometerService`` 31 | - ``WeightScaleService`` 32 | - ``PulseOximeterService`` 33 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicLocator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | 10 | struct CharacteristicLocator { 11 | let serviceId: BTUUID 12 | let characteristicId: BTUUID 13 | } 14 | 15 | 16 | extension CharacteristicLocator: Hashable, Sendable {} 17 | 18 | extension CharacteristicLocator: CustomStringConvertible, CustomDebugStringConvertible { 19 | public var description: String { 20 | "\(characteristicId)@\(serviceId)" 21 | } 22 | 23 | public var debugDescription: String { 24 | "CharacteristicLocator(service: \(serviceId), characteristic: \(characteristicId))" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Utils/Reference.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | protocol AnyWeakDeviceReference { 10 | var anyValue: (any BluetoothDevice)? { get } 11 | 12 | var typeName: String { get } 13 | } 14 | 15 | 16 | struct WeakReference { 17 | weak var value: Value? 18 | 19 | init(_ value: Value? = nil) { 20 | self.value = value 21 | } 22 | } 23 | 24 | 25 | extension WeakReference: AnyWeakDeviceReference where Value: BluetoothDevice { 26 | var anyValue: (any BluetoothDevice)? { 27 | value 28 | } 29 | 30 | var typeName: String { 31 | "\(Value.self)" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/TestPeripheral/TestPeripheral.docc/TestPeripheral.md: -------------------------------------------------------------------------------- 1 | # ``TestPeripheral`` 2 | 3 | Bluetooth Peripheral for Spezi Bluetooth tests. 4 | 5 | 14 | 15 | ## Overview 16 | 17 | This module implements a Bluetooth Peripheral to is used for UI tests in SpeziBluetooth. 18 | 19 | Deploy this application to a macOS machine that is physically close to your test runner. 20 | Ensure that the UI tests have exclusive access to the peripheral by running all UI tests sequentially. 21 | 22 | ## Topics 23 | 24 | ### Peripheral 25 | 26 | - ``TestPeripheral`` 27 | - ``TestService`` 28 | 29 | ### Setup Guides 30 | 31 | - 32 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Environment/MinimumRSSIEnvironmentKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import SwiftUI 10 | 11 | 12 | private struct MinimumRSSIEnvironmentKey: EnvironmentKey { 13 | static let defaultValue: Int? = nil 14 | } 15 | 16 | 17 | extension EnvironmentValues { 18 | /// The minimum rssi a nearby peripheral must have to be considered nearby. 19 | public internal(set) var minimumRSSI: Int? { 20 | get { 21 | self[MinimumRSSIEnvironmentKey.self] 22 | } 23 | set { 24 | if let newValue { 25 | self[MinimumRSSIEnvironmentKey.self] = newValue 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Model/PropertySupport/DeviceActionTestInjections.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import Foundation 10 | 11 | 12 | final class DeviceActionTestInjections: Sendable { 13 | private nonisolated(unsafe) var _injectedClosure: ClosureType? 14 | private let lock = NSLock() // protects property above 15 | 16 | var injectedClosure: ClosureType? { 17 | get { 18 | lock.withLock { 19 | _injectedClosure 20 | } 21 | } 22 | set { 23 | lock.withLock { 24 | _injectedClosure = newValue 25 | } 26 | } 27 | } 28 | 29 | init() {} 30 | } 31 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Model/Characteristic/ControlPointCharacteristic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import ByteCoding 10 | 11 | 12 | /// Mark a characteristic to have control point semantics. 13 | /// 14 | /// Control Point Characteristics are special Characteristics that encode a special request and response flow. 15 | /// Such characteristics use `write` permissions to send the request and `indicate` permissions to send the response to a request. 16 | /// 17 | /// This protocol is a marker protocol making additional controls available with the ``CharacteristicAccessor``, 18 | /// to more easily interact with control point characteristics. 19 | public protocol ControlPointCharacteristic: ByteCodable {} 20 | -------------------------------------------------------------------------------- /bin/edu.stanford.spezi.bluetooth.testperipheral.plist.template: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Label 6 | {{Label}} 7 | UserName 8 | {{User}} 9 | InitGroups 10 | 11 | WorkingDirectory 12 | {{UserHome}} 13 | ProgramArguments 14 | 15 | /Applications/TestPeripheral 16 | 17 | StandardOutPath 18 | {{UserHome}}/Library/Logs/{{Label}}/stdout.log 19 | StandardErrorPath 20 | {{UserHome}}/Library/Logs/{{Label}}/stderr.log 21 | RunAtLoad 22 | 23 | SessionCreate 24 | 25 | ProcessType 26 | Interactive 27 | 28 | 29 | -------------------------------------------------------------------------------- /Tests/UITests/TestApp.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "074FA9C1-7635-4C64-BF5D-90402604CC46", 5 | "name" : "Default", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "codeCoverage" : { 13 | "targets" : [ 14 | { 15 | "containerPath" : "container:..\/..", 16 | "identifier" : "SpeziBluetooth", 17 | "name" : "SpeziBluetooth" 18 | } 19 | ] 20 | }, 21 | "targetForVariableExpansion" : { 22 | "containerPath" : "container:UITests.xcodeproj", 23 | "identifier" : "2F6D139128F5F384007C25D6", 24 | "name" : "TestApp" 25 | } 26 | }, 27 | "testTargets" : [ 28 | { 29 | "target" : { 30 | "containerPath" : "container:UITests.xcodeproj", 31 | "identifier" : "2F6D13AB28F5F386007C25D6", 32 | "name" : "TestAppUITests" 33 | } 34 | } 35 | ], 36 | "version" : 1 37 | } 38 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothWorkItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import Foundation 10 | 11 | 12 | final class BluetoothWorkItem { 13 | private let workItem: DispatchWorkItem 14 | 15 | init(handler: @SpeziBluetooth @escaping @Sendable () -> Void) { 16 | self.workItem = DispatchWorkItem { 17 | SpeziBluetooth.assumeIsolatedIfAvailableOrTask { 18 | handler() 19 | } 20 | } 21 | } 22 | 23 | func schedule(for deadline: DispatchTime) { 24 | SpeziBluetooth.shared.dispatchQueue.asyncAfter(deadline: deadline, execute: workItem) 25 | } 26 | 27 | func cancel() { 28 | workItem.cancel() 29 | } 30 | 31 | deinit { 32 | workItem.cancel() 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/AccessorySetupKit/ASDiscoveryDescriptor.Range+Description.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | #if canImport(AccessorySetupKit) && !os(macOS) 10 | import AccessorySetupKit 11 | 12 | 13 | @available(iOS 18, *) 14 | @available(macCatalyst, unavailable) 15 | @available(visionOS, unavailable) 16 | extension ASDiscoveryDescriptor.Range: @retroactive CustomStringConvertible, @retroactive CustomDebugStringConvertible { 17 | public var description: String { 18 | switch self { 19 | case .default: 20 | "default" 21 | case .immediate: 22 | "immediate" 23 | @unknown default: 24 | "Range(rawValue: \(rawValue))" 25 | } 26 | } 27 | 28 | public var debugDescription: String { 29 | description 30 | } 31 | } 32 | #endif 33 | -------------------------------------------------------------------------------- /Tests/SpeziBluetoothServicesTests/DeviceInformationTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import ByteCodingTesting 10 | import CoreBluetooth 11 | import NIOCore 12 | @_spi(TestingSupport) 13 | @testable import SpeziBluetooth 14 | @_spi(TestingSupport) 15 | @testable import SpeziBluetoothServices 16 | import Testing 17 | 18 | 19 | @Suite("DeviceInformation Service") 20 | struct DeviceInformationTests { 21 | @Test("PnPID") 22 | func testPnPID() throws { 23 | try testIdentity(from: VendorIDSource.bluetoothSIGAssigned) 24 | try testIdentity(from: VendorIDSource.usbImplementersForumAssigned) 25 | try testIdentity(from: VendorIDSource.reserved(23)) 26 | 27 | try testIdentity(from: PnPID(vendorIdSource: .bluetoothSIGAssigned, vendorId: 24, productId: 1, productVersion: 56)) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Environment/AdvertisementStaleIntervalEnvironmentKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import SwiftUI 10 | 11 | 12 | private struct AdvertisementStaleIntervalEnvironmentKey: EnvironmentKey { 13 | static let defaultValue: TimeInterval? = nil 14 | } 15 | 16 | 17 | extension EnvironmentValues { 18 | /// The time interval after which a peripheral advertisement is considered stale if we don't hear back from the device. Minimum is 1 second. 19 | public internal(set) var advertisementStaleInterval: TimeInterval? { 20 | get { 21 | self[AdvertisementStaleIntervalEnvironmentKey.self] 22 | } 23 | set { 24 | if let newValue, newValue >= 1 { 25 | self[AdvertisementStaleIntervalEnvironmentKey.self] = newValue 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Model/Actions/BluetoothConnectAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | 10 | /// Connect to the Bluetooth peripheral. 11 | /// 12 | /// For more information refer to ``DeviceActions/connect`` 13 | public struct BluetoothConnectAction: _BluetoothPeripheralAction, Sendable { 14 | public typealias ClosureType = @Sendable () async -> Void 15 | 16 | private let content: _PeripheralActionContent 17 | 18 | @_documentation(visibility: internal) 19 | public init(_ content: _PeripheralActionContent) { 20 | self.content = content 21 | } 22 | 23 | 24 | public func callAsFunction() async throws { 25 | switch content { 26 | case let .peripheral(peripheral): 27 | try await peripheral.connect() 28 | case let .injected(closure): 29 | await closure() 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Model/Actions/BluetoothDisconnectAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | 10 | /// Disconnect from the Bluetooth peripheral. 11 | /// 12 | /// For more information refer to ``DeviceActions/disconnect`` 13 | public struct BluetoothDisconnectAction: _BluetoothPeripheralAction, Sendable { 14 | public typealias ClosureType = @Sendable () async -> Void 15 | 16 | private let content: _PeripheralActionContent 17 | 18 | @_documentation(visibility: internal) 19 | public init(_ content: _PeripheralActionContent) { 20 | self.content = content 21 | } 22 | 23 | public func callAsFunction() async { 24 | switch content { 25 | case let .peripheral(peripheral): 26 | await peripheral.disconnect() 27 | case let .injected(closure): 28 | await closure() 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/SpeziBluetoothServices/TestingSupport/TestService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import CoreBluetooth 10 | import SpeziBluetooth 11 | 12 | 13 | @_spi(TestingSupport) 14 | public struct TestService: BluetoothService, Sendable { 15 | public static let id: BTUUID = .testService 16 | 17 | @Characteristic(id: .eventLogCharacteristic, notify: true) 18 | public var eventLog: EventLog? 19 | 20 | 21 | @Characteristic(id: .readStringCharacteristic) 22 | public var readString: String? 23 | 24 | @Characteristic(id: .writeStringCharacteristic) 25 | public var writeString: String? 26 | 27 | @Characteristic(id: .readWriteStringCharacteristic) 28 | public var readWriteString: String? 29 | 30 | @Characteristic(id: .resetCharacteristic) 31 | public var reset: Bool? // swiftlint:disable:this discouraged_optional_boolean 32 | 33 | public init() {} 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Stanford University and the project authors (see CONTRIBUTORS.md) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /Sources/SpeziBluetoothServices/Services/BatteryService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import SpeziBluetooth 10 | 11 | 12 | /// Bluetooth Battery Service. 13 | /// 14 | /// This class partially implements the Bluetooth [Battery Service 1.1](https://www.bluetooth.com/specifications/specs/battery-service). 15 | /// - Note: The current implementation only implements mandatory characteristics. 16 | public struct BatteryService: BluetoothService, Sendable { 17 | public static let id: BTUUID = "180F" 18 | 19 | 20 | /// Battery Level in percent. 21 | /// 22 | /// Battery Level in percent (range 0 to 100). 23 | /// 100 represents fully charged, 0 represents fully discharged. 24 | /// All other values are reserved. 25 | @Characteristic(id: "2A19", notify: true) 26 | public var batteryLevel: UInt8? 27 | 28 | 29 | /// Initialize a new Battery Service. 30 | public init() {} 31 | } 32 | -------------------------------------------------------------------------------- /LICENSES/MIT.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Stanford University and the project authors (see CONTRIBUTORS.md) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Model/Actions/ReadRSSIAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | /// Read the current RSSI from the Bluetooth peripheral. 10 | /// 11 | /// For more information refer to ``DeviceActions/readRSSI`` 12 | public struct ReadRSSIAction: _BluetoothPeripheralAction, Sendable { 13 | public typealias ClosureType = @Sendable () async throws -> Int 14 | 15 | private let content: _PeripheralActionContent 16 | 17 | @_documentation(visibility: internal) 18 | public init(_ content: _PeripheralActionContent) { 19 | self.content = content 20 | } 21 | 22 | 23 | @discardableResult 24 | public func callAsFunction() async throws -> Int { 25 | switch content { 26 | case let .peripheral(peripheral): 27 | try await peripheral.readRSSI() 28 | case let .injected(closure): 29 | try await closure() 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/static-analysis.yml: -------------------------------------------------------------------------------- 1 | # 2 | # This source file is part of the Stanford Spezi open-source project 3 | # 4 | # SPDX-FileCopyrightText: 2025 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | # 6 | # SPDX-License-Identifier: MIT 7 | # 8 | 9 | name: Static Analysis 10 | 11 | on: 12 | push: 13 | branches: 14 | - main 15 | pull_request: 16 | workflow_dispatch: 17 | 18 | concurrency: 19 | group: Static-Analysis-${{ github.ref }} 20 | cancel-in-progress: true 21 | 22 | jobs: 23 | reuse_action: 24 | name: REUSE Compliance Check 25 | uses: StanfordSpezi/.github/.github/workflows/reuse.yml@v2 26 | swiftlint: 27 | name: SwiftLint 28 | uses: StanfordSpezi/.github/.github/workflows/swiftlint.yml@v2 29 | markdown_link_check: 30 | name: Markdown Link Check 31 | uses: StanfordBDHG/.github/.github/workflows/markdown-link-check.yml@v2 32 | breaking_changes: 33 | name: Diagnose Breaking Changes 34 | uses: StanfordSpezi/.github/.github/workflows/breaking-changes.yml@v2 35 | if: ${{ github.ref_name != 'main' }} 36 | with: 37 | runsonlabels: '["macOS", "self-hosted", "spezi"]' 38 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Configuration/Apperance/Appearance.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import SpeziViews 10 | 11 | 12 | /// Describes how a bluetooth device should be visually presented to the user. 13 | public struct Appearance { 14 | /// Provides a user-friendly name for the device. 15 | /// 16 | /// This might be treated as the "initial" name. A user might be allowed to rename the device locally. 17 | public let name: String 18 | /// An icon that is used to refer to the device. 19 | public let icon: ImageReference 20 | 21 | /// Create a new device appearance. 22 | /// - Parameters: 23 | /// - name: Provides a user-friendly name for the device. 24 | /// - icon: An icon that is used to refer to the device. 25 | public init(name: String, icon: ImageReference = .system("sensor")) { 26 | self.name = name 27 | self.icon = icon 28 | } 29 | } 30 | 31 | 32 | extension Appearance: Hashable, Sendable {} 33 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Model/PropertySupport/DeviceActionAccessor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | 10 | /// Interact with a Device Action. 11 | public struct DeviceActionAccessor { 12 | private let storage: DeviceAction.Storage 13 | 14 | init(_ storage: DeviceAction.Storage) { 15 | self.storage = storage 16 | } 17 | 18 | 19 | /// Inject a custom action handler for previewing purposes. 20 | /// 21 | /// This method can be used to inject a custom handler for the device action. 22 | /// This is particularly helpful when writing SwiftUI previews or doing UI testing. 23 | /// 24 | /// - Parameter action: The action to inject. 25 | @_spi(TestingSupport) 26 | public func inject(_ action: Action.ClosureType) { 27 | storage.testInjections.storeIfNilThenLoad(.init()).injectedClosure = action 28 | } 29 | } 30 | 31 | 32 | extension DeviceActionAccessor: Sendable {} 33 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Model/Visitor/ServiceVisitor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | 10 | protocol ServiceVisitable { 11 | @SpeziBluetooth 12 | func accept(_ visitor: inout Visitor) 13 | } 14 | 15 | 16 | @SpeziBluetooth 17 | protocol ServiceVisitor: BaseVisitor { 18 | mutating func visit(_ characteristic: Characteristic) 19 | } 20 | 21 | 22 | extension BluetoothService { 23 | @SpeziBluetooth 24 | func accept(_ visitor: inout Visitor) { 25 | let mirror = Mirror(reflecting: self) 26 | for (_, child) in mirror.children { 27 | if let visitable = child as? ServiceVisitable { 28 | visitable.accept(&visitor) 29 | } else if child is DeviceVisitable { 30 | preconditionFailure("@Service declaration found in \(Self.self). @Service cannot be used within BluetoothService classes!") 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Model/Visitor/DeviceVisitor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | 10 | protocol DeviceVisitable { 11 | @SpeziBluetooth 12 | func accept(_ visitor: inout Visitor) 13 | } 14 | 15 | 16 | @SpeziBluetooth 17 | protocol DeviceVisitor: BaseVisitor { 18 | mutating func visit(_ service: Service) 19 | } 20 | 21 | 22 | extension BluetoothDevice { 23 | @SpeziBluetooth 24 | func accept(_ visitor: inout Visitor) { 25 | let mirror = Mirror(reflecting: self) 26 | for (_, child) in mirror.children { 27 | if let visitable = child as? DeviceVisitable { 28 | visitable.accept(&visitor) 29 | } else if child is ServiceVisitable { 30 | preconditionFailure("@Characteristic declaration found in \(Self.self). @Characteristic cannot be used within the BluetoothDevice class!") 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/CoreBluetooth/Extensions/CBError+LocalizedError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import CoreBluetooth 10 | import Foundation 11 | 12 | 13 | #if compiler(>=6) 14 | extension CBError: @retroactive LocalizedError {} 15 | extension CBATTError: @retroactive LocalizedError {} 16 | #else 17 | extension CBError: LocalizedError {} 18 | extension CBATTError: LocalizedError {} 19 | #endif 20 | 21 | extension CBError { 22 | /// The error description. 23 | public var errorDescription: String? { 24 | "CoreBluetooth Error" 25 | } 26 | 27 | /// The localized failure reason. 28 | public var failureReason: String? { 29 | localizedDescription 30 | } 31 | } 32 | 33 | 34 | extension CBATTError { 35 | /// The error description. 36 | public var errorDescription: String? { 37 | "CoreBluetooth ATT Error" 38 | } 39 | 40 | /// The localized failure reason. 41 | public var failureReason: String? { 42 | localizedDescription 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/CoreBluetooth/Utilities/DiscoveryStaleTimer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import Foundation 10 | 11 | 12 | final class DiscoveryStaleTimer { 13 | let targetDevice: UUID 14 | /// The dispatch work item that schedules the next stale timer. 15 | private let workItem: BluetoothWorkItem 16 | 17 | init(device: UUID, handler: @SpeziBluetooth @escaping @Sendable () -> Void) { 18 | // make sure that you don't create a reference cycle through the closure above! 19 | 20 | self.targetDevice = device 21 | self.workItem = BluetoothWorkItem(handler: handler) 22 | } 23 | 24 | 25 | func cancel() { 26 | workItem.cancel() 27 | } 28 | 29 | func schedule(for timeout: TimeInterval, in queue: DispatchSerialQueue) { 30 | // `DispatchTime` only allows for integer time 31 | let milliSeconds = Int(timeout * 1000) 32 | workItem.schedule(for: .now() + .milliseconds(milliSeconds)) 33 | } 34 | 35 | deinit { 36 | cancel() 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/CoreBluetooth/Utilities/KVOStateDidChangeObserver.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import Foundation 10 | 11 | 12 | @SpeziBluetooth 13 | final class KVOStateDidChangeObserver: NSObject, Sendable { 14 | private var observation: NSKeyValueObservation? 15 | 16 | private let entity: Entity 17 | private let keyPath: KeyPath 18 | 19 | @SpeziBluetooth 20 | init(entity: Entity, property: KeyPath, perform action: @SpeziBluetooth @Sendable @escaping (Value) async -> Void) { 21 | self.entity = entity 22 | self.keyPath = property 23 | super.init() 24 | 25 | observation = entity.observe(property) { [weak self] _, _ in 26 | Task { @SpeziBluetooth [weak self] in 27 | guard let self else { 28 | return 29 | } 30 | let value = self.entity[keyPath: self.keyPath] 31 | await action(value) 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/SpeziBluetooth.docc/CoreBluetooth-Framework.md: -------------------------------------------------------------------------------- 1 | # CoreBluetooth 2 | 3 | 12 | 13 | Interact with CoreBluetooth through modern programming language paradigms. 14 | 15 | ## Overview 16 | 17 | [CoreBluetooth](https://developer.apple.com/documentation/corebluetooth) is Apple's framework to interact with Bluetooth and Bluetooth Low-Energy 18 | devices on Apple platforms. 19 | SpeziBluetooth provides easy-to-use mechanisms to perform operations on a Bluetooth central. 20 | 21 | ## Topics 22 | 23 | ### Central 24 | 25 | - ``BluetoothManager`` 26 | - ``BluetoothState`` 27 | - ``BluetoothError`` 28 | 29 | ### Configuration 30 | 31 | - ``DiscoveryDescription`` 32 | - ``DeviceDescription`` 33 | - ``ServiceDescription`` 34 | - ``CharacteristicDescription`` 35 | 36 | ### Peripheral 37 | 38 | - ``BluetoothPeripheral`` 39 | - ``PeripheralState`` 40 | - ``GATTService`` 41 | - ``GATTCharacteristic`` 42 | - ``AdvertisementData`` 43 | - ``ManufacturerIdentifier`` 44 | - ``WriteType`` 45 | - ``BTUUID`` 46 | -------------------------------------------------------------------------------- /Sources/SpeziBluetoothServices/Services/WeightScaleService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import SpeziBluetooth 10 | 11 | 12 | /// Bluetooth Weight Scale Service implementation. 13 | /// 14 | /// This type implements the Bluetooth [Weight Scale Service 1.0](https://www.bluetooth.com/specifications/specs/weight-scale-service-1-0). 15 | public struct WeightScaleService: BluetoothService, Sendable { 16 | public static let id: BTUUID = "181D" 17 | 18 | /// Receive weight measurements. 19 | /// 20 | /// - Note: This characteristic is required and indicate-only. 21 | @Characteristic(id: "2A9D", notify: true, autoRead: false) 22 | public var weightMeasurement: WeightMeasurement? 23 | 24 | /// Describe supported features and value resolutions of the weight scale. 25 | /// 26 | /// - Note: This characteristic is required and read-only. 27 | @Characteristic(id: "2A9E") 28 | public var features: WeightScaleFeature? 29 | 30 | 31 | /// Initialize a new Weight Scale Service. 32 | public init() {} 33 | } 34 | -------------------------------------------------------------------------------- /Tests/UITests/TestApp/BluetoothManagerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import SpeziBluetooth 10 | import SwiftUI 11 | 12 | 13 | struct BluetoothManagerView: View { 14 | @State private var bluetooth = BluetoothManager() 15 | 16 | var body: some View { 17 | List { 18 | BluetoothStateSection(state: bluetooth.state, isScanning: bluetooth.isScanning) 19 | 20 | Section { 21 | ForEach(bluetooth.nearbyPeripherals) { peripheral in 22 | DeviceRowView(peripheral: peripheral) 23 | } 24 | } header: { 25 | Text(verbatim: "Devices") 26 | } footer: { 27 | Text(verbatim: "This is a list of nearby Bluetooth peripherals.") 28 | } 29 | } 30 | .scanNearbyDevices(with: bluetooth, discovery: []) // discovery any devices! 31 | .navigationTitle("Nearby Devices") 32 | } 33 | } 34 | 35 | 36 | #Preview { 37 | NavigationStack { 38 | BluetoothManagerView() 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Model/Characteristic/ControlPointSupport.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import Foundation 10 | import SpeziFoundation 11 | 12 | 13 | @SpeziBluetooth 14 | final class ControlPointTransaction: Sendable { 15 | let id: UUID 16 | 17 | private(set) var continuation: CheckedContinuation? 18 | 19 | init(id: UUID = UUID()) { 20 | self.id = id 21 | } 22 | 23 | func assignContinuation(_ continuation: CheckedContinuation) { 24 | self.continuation = continuation 25 | } 26 | 27 | func signalCancellation() { 28 | resume(with: .failure(CancellationError())) 29 | } 30 | 31 | func signalTimeout() { 32 | resume(with: .failure(TimeoutError())) 33 | } 34 | 35 | func fulfill(_ value: Value) { 36 | resume(with: .success(value)) 37 | } 38 | 39 | private func resume(with result: Result) { 40 | guard let continuation else { 41 | return 42 | } 43 | 44 | continuation.resume(with: result) 45 | self.continuation = nil 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Tests/UITests/TestApp/Views/BluetoothStateSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import SpeziBluetooth 10 | import SwiftUI 11 | 12 | 13 | struct BluetoothStateSection: View { 14 | private let state: BluetoothState 15 | private let isScanning: Bool 16 | 17 | 18 | var body: some View { 19 | Section { 20 | HStack { 21 | Text(verbatim: "Scanning") 22 | Spacer() 23 | Text(verbatim: isScanning ? "Yes" : "No") 24 | .foregroundColor(.secondary) 25 | } 26 | .accessibilityElement(children: .combine) 27 | HStack { 28 | Text(verbatim: "State") 29 | Spacer() 30 | Text(state.description) 31 | .foregroundColor(.secondary) 32 | } 33 | .accessibilityElement(children: .combine) 34 | } header: { 35 | Text(verbatim: "State") 36 | } 37 | } 38 | 39 | 40 | init(state: BluetoothState, isScanning: Bool) { 41 | self.state = state 42 | self.isScanning = isScanning 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Model/Actions/BluetoothPeripheralAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | /// The content of an implemented peripheral action. 10 | public enum _PeripheralActionContent { // swiftlint:disable:this type_name file_types_order 11 | /// Execute the action on the provided bluetooth peripheral. 12 | case peripheral(BluetoothPeripheral) 13 | /// Execute the injected closure instead. 14 | case injected(ClosureType) 15 | } 16 | 17 | 18 | /// A action that can be reference using ``DeviceAction``. 19 | /// 20 | /// To implement a device action, implement a conforming type that implements 21 | /// a `callAsFunction()` method and declare the respective extension to ``DeviceActions``. 22 | public protocol _BluetoothPeripheralAction { // swiftlint:disable:this type_name 23 | /// The closure type of the action. 24 | associatedtype ClosureType: Sendable 25 | 26 | /// Create a new action for a given peripheral instance. 27 | /// - Parameter content: The action content. 28 | init(_ content: _PeripheralActionContent) 29 | } 30 | 31 | 32 | extension _PeripheralActionContent: Sendable {} 33 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/CoreBluetooth/Model/StateRegistration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import Foundation 10 | 11 | 12 | /// An state change handler registration for the Bluetooth state. 13 | /// 14 | /// It automatically cancels the subscription once this value is de-initialized. 15 | public struct StateRegistration: ~Copyable { 16 | private let id: UUID 17 | private weak var storage: BluetoothManagerStorage? 18 | 19 | init(id: UUID, storage: BluetoothManagerStorage? = nil) { 20 | self.id = id 21 | self.storage = storage 22 | } 23 | 24 | private static func cancel(id: UUID, storage: BluetoothManagerStorage?) { 25 | guard let storage else { 26 | return 27 | } 28 | 29 | let id = id 30 | Task.detached { @SpeziBluetooth in 31 | storage.unsubscribe(for: id) 32 | } 33 | } 34 | 35 | /// Cancels the subscription. 36 | public func cancel() { 37 | Self.cancel(id: id, storage: storage) 38 | } 39 | 40 | deinit { 41 | Self.cancel(id: id, storage: storage) 42 | } 43 | } 44 | 45 | 46 | extension StateRegistration: Sendable {} 47 | -------------------------------------------------------------------------------- /Tests/UITests/TestAppUITests/BluetoothManagerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import XCTest 10 | import XCTestExtensions 11 | 12 | 13 | final class BluetoothManagerTests: XCTestCase { 14 | override func setUpWithError() throws { 15 | try super.setUpWithError() 16 | 17 | continueAfterFailure = false 18 | } 19 | 20 | @MainActor 21 | func testSpeziBluetooth() throws { 22 | let app = XCUIApplication() 23 | app.launch() 24 | 25 | XCTAssert(app.staticTexts["Spezi Bluetooth"].waitForExistence(timeout: 2)) 26 | 27 | XCTAssert(app.buttons["Nearby Devices"].exists) 28 | XCTAssert(app.buttons["Test Peripheral"].exists) 29 | 30 | app.buttons["Nearby Devices"].tap() 31 | 32 | XCTAssert(app.navigationBars.staticTexts["Nearby Devices"].waitForExistence(timeout: 2.0)) 33 | try app.assertMinimalSimulatorInformation() 34 | 35 | sleep(10) // this goes through stale timer and everything! 36 | 37 | XCTAssert(app.navigationBars.buttons["Spezi Bluetooth"].exists) 38 | app.navigationBars.buttons["Spezi Bluetooth"].tap() 39 | sleep(3) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/AccessorySetupKit/DeviceVariantCriteria+ASDiscoveryDescriptor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | #if canImport(AccessorySetupKit) && !os(macOS) 10 | import AccessorySetupKit 11 | 12 | 13 | @available(iOS 18, *) 14 | @available(macCatalyst, unavailable) 15 | extension DeviceVariantCriteria { 16 | /// Apply criteria to a `ASDiscoveryDescriptor`. 17 | /// - Parameter descriptor: The descriptor. 18 | @available(iOS 18.0, *) 19 | public func apply(to descriptor: ASDiscoveryDescriptor) { 20 | for aspect in aspects { 21 | aspect.apply(to: descriptor) 22 | } 23 | } 24 | 25 | /// Determine if a discovery descriptor matches the device variant criteria. 26 | /// - Parameter descriptor: The discovery descriptor. 27 | /// - Returns: Returns `true` if all discovery aspects are present and matching on the discovery descriptor. The discovery descriptor might have other fields set. 28 | @available(iOS 18.0, *) 29 | public func matches(descriptor: ASDiscoveryDescriptor) -> Bool { 30 | aspects.allSatisfy { aspect in 31 | aspect.matches(descriptor) 32 | } 33 | } 34 | } 35 | #endif 36 | -------------------------------------------------------------------------------- /Tests/UITests/TestApp/TestDeviceView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | @_spi(TestingSupport) 10 | import SpeziBluetoothServices 11 | @_spi(TestingSupport) 12 | import SpeziBluetooth 13 | import SwiftUI 14 | 15 | 16 | struct TestDeviceView: View { 17 | private let device: TestDevice 18 | 19 | var body: some View { 20 | List { 21 | DeviceInformationView(device) 22 | 23 | TestServiceView(device.testService) 24 | } 25 | .navigationTitle("Interactions") 26 | .navigationBarTitleDisplayMode(.inline) 27 | } 28 | 29 | init(device: TestDevice) { 30 | self.device = device 31 | } 32 | } 33 | 34 | 35 | #if DEBUG 36 | #Preview { 37 | let device = TestDevice() 38 | device.deviceInformation.$manufacturerName.inject("Apple Inc.") 39 | device.deviceInformation.$modelNumber.inject("MacBookPro18,1") 40 | 41 | let service = device.testService 42 | service.$eventLog.inject(.receivedWrite(.readWriteStringCharacteristic, value: "Hello Spezi".encode())) 43 | 44 | service.$readString.inject("Hello World (1)") 45 | service.$readWriteString.inject("Hello World") 46 | 47 | return NavigationStack { 48 | TestDeviceView(device: device) 49 | } 50 | } 51 | #endif 52 | -------------------------------------------------------------------------------- /Sources/SpeziBluetoothServices/Services/PulseOximeterService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import ByteCoding 10 | import Foundation 11 | import NIOCore 12 | import SpeziBluetooth 13 | import SpeziNumerics 14 | 15 | 16 | /// Bluetooth Pulse Oximeter (PLX) Service implementation. 17 | /// 18 | /// This type implements the Bluetooth [Pulse Oximeter Service 1.0.1](https://www.bluetooth.com/specifications/specs/plxs-html/). 19 | public struct PulseOximeterService: BluetoothService, Sendable { 20 | public static let id: BTUUID = "1822" 21 | 22 | /// Defines the features suppored by the pulse oximeter. 23 | /// 24 | /// - Note: This characteristic is required and read-only. 25 | @Characteristic(id: "2A60") 26 | public var features: PLXFeatures? 27 | 28 | /// Read a (usually one-time) spot-check measurement. 29 | @Characteristic(id: "2A5E", notify: true, autoRead: false) 30 | public var spotCheckMeasurement: PLXSpotCheckMeasurement? 31 | 32 | /// Receive continuous PLX (i.e., bood oxygen saturation and pulse rate) measurements. 33 | @Characteristic(id: "2A5F", notify: true, autoRead: false) 34 | public var continuousMeasurement: PLXContinuousMeasurement? 35 | 36 | /// Create a new Pulse Oximeter Service. 37 | public init() {} 38 | } 39 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Configuration/DeviceDiscoveryDescriptor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | 10 | /// Describes how to discover a given `BluetoothDevice`. 11 | /// 12 | /// Provides a strategy on how to discovery given ``BluetoothDevice`` device type. 13 | public struct DeviceDiscoveryDescriptor { 14 | /// The associated device type. 15 | public let deviceType: any BluetoothDevice.Type 16 | /// The criteria by which we identify a discovered device. 17 | public let discoveryCriteria: DiscoveryCriteria 18 | 19 | init(from discoverExpression: Discover) { 20 | self.deviceType = discoverExpression.deviceType 21 | self.discoveryCriteria = discoverExpression.discoveryCriteria 22 | } 23 | } 24 | 25 | 26 | extension DeviceDiscoveryDescriptor: Sendable {} 27 | 28 | 29 | extension DeviceDiscoveryDescriptor: Identifiable { 30 | public var id: DiscoveryCriteria { 31 | discoveryCriteria 32 | } 33 | } 34 | 35 | 36 | extension DeviceDiscoveryDescriptor: Hashable { 37 | public static func == (lhs: DeviceDiscoveryDescriptor, rhs: DeviceDiscoveryDescriptor) -> Bool { 38 | lhs.discoveryCriteria == rhs.discoveryCriteria 39 | } 40 | 41 | public func hash(into hasher: inout Hasher) { 42 | hasher.combine(discoveryCriteria) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Configuration/Discover.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | 10 | /// Declare how a bluetooth device is discovered. 11 | /// 12 | /// Declares by which ``DiscoveryCriteria`` a given ``BluetoothDevice`` implementation is discovered. 13 | /// 14 | /// - Important: The discovery criteria must be unique across all discovery configurations. Not doing so will result in undefined behavior. 15 | /// 16 | /// ```swift 17 | /// Discover(MyBluetoothDevice.self, by: .advertisedService(WeightScaleService.self)) 18 | /// ``` 19 | /// 20 | /// ## Topics 21 | /// 22 | /// ### Discovering a device 23 | /// - ``init(_:by:)`` 24 | /// 25 | /// ### Semantic Model 26 | /// - ``DeviceDiscoveryDescriptor`` 27 | /// - ``DiscoveryDescriptorBuilder`` 28 | public struct Discover { 29 | let deviceType: Device.Type 30 | let discoveryCriteria: DiscoveryCriteria 31 | 32 | /// Create a discovery for a given device type. 33 | /// - Parameters: 34 | /// - device: The type of a ``BluetoothDevice`` implementation. 35 | /// - discoveryCriteria: The criteria by which the device is discovered. 36 | public init(_ device: Device.Type, by discoveryCriteria: DiscoveryCriteria) { 37 | self.deviceType = device 38 | self.discoveryCriteria = discoveryCriteria 39 | } 40 | } 41 | 42 | 43 | extension Discover: Sendable {} 44 | -------------------------------------------------------------------------------- /Tests/SpeziBluetoothServicesTests/BluetoothServicesTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import ByteCodingTesting 10 | import NIOCore 11 | @_spi(TestingSupport) 12 | @testable import SpeziBluetooth 13 | @_spi(TestingSupport) 14 | @testable import SpeziBluetoothServices 15 | import Testing 16 | 17 | 18 | @Suite("Bluetooth Services") 19 | struct BluetoothServicesTests { 20 | @Test("Services init") 21 | func testServices() { 22 | _ = TestService() 23 | _ = HealthThermometerService() 24 | _ = DeviceInformationService() 25 | _ = WeightScaleService() 26 | _ = BloodPressureService() 27 | _ = BatteryService() 28 | _ = CurrentTimeService() 29 | _ = PulseOximeterService() 30 | } 31 | 32 | @Test("BT UUID") 33 | func testUUID() { 34 | #expect(BTUUID.toCustomShort(.testService) == "F001") 35 | } 36 | 37 | @Test("Event Log") 38 | func testEventLog() throws { 39 | try testIdentity(from: EventLog.none) 40 | try testIdentity(from: EventLog.subscribedToNotification(.eventLogCharacteristic)) 41 | try testIdentity(from: EventLog.unsubscribedToNotification(.eventLogCharacteristic)) 42 | try testIdentity(from: EventLog.receivedRead(.readStringCharacteristic)) 43 | try testIdentity(from: EventLog.receivedWrite(.writeStringCharacteristic, value: "Hello World".encode())) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Model/PropertySupport/ServicePeripheralInjection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | 10 | @SpeziBluetooth 11 | class ServicePeripheralInjection: Sendable { 12 | private let bluetooth: Bluetooth 13 | let peripheral: BluetoothPeripheral 14 | private let serviceId: BTUUID 15 | private let state: Service.State 16 | 17 | private weak var service: GATTService? { 18 | didSet { 19 | state.serviceState = .init(from: service) 20 | } 21 | } 22 | 23 | 24 | init(bluetooth: Bluetooth, peripheral: BluetoothPeripheral, serviceId: BTUUID, service: GATTService?, state: Service.State) { 25 | self.bluetooth = bluetooth 26 | self.peripheral = peripheral 27 | self.serviceId = serviceId 28 | self.state = state 29 | self.service = service 30 | } 31 | 32 | func setup() { 33 | trackServicesUpdate() 34 | } 35 | 36 | private func trackServicesUpdate() { 37 | peripheral.onChange(of: \.services) { [weak self] services in 38 | guard let self = self, 39 | let service = services?[self.serviceId] else { 40 | return 41 | } 42 | 43 | self.trackServicesUpdate() 44 | self.service = service 45 | } 46 | } 47 | 48 | deinit { 49 | bluetooth.notifyDeviceDeinit(for: peripheral.id) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/SpeziBluetooth.docc/AccessorySetupKit-Framework.md: -------------------------------------------------------------------------------- 1 | # AccessorySetupKit 2 | 3 | Integration with Apple's AccessorySetupKit. 4 | 5 | 14 | 15 | ## Overview 16 | 17 | Apple's [AccessorySetupKit](https://developer.apple.com/documentation/accessorysetupkit) enables 18 | privacy-preserving discovery and configuration of accessories. 19 | SpeziBluetooth integrates with 20 | 21 | ## Topics 22 | 23 | 24 | ### Interact with AccessorySetupKit 25 | 26 | - ``AccessorySetupKit-swift.class`` 27 | - ``AccessorySetupKitError`` 28 | 29 | ### Observe Accessory Changes 30 | - ``AccessorySetupKit-swift.class/AccessoryEvent`` 31 | - ``AccessoryEventRegistration`` 32 | 33 | ### Discovery Descriptor 34 | 35 | Convert a SpeziBluetooth ``DiscoveryCriteria`` into its AccessorySetupKit `ASDiscoveryDescriptor` representation. 36 | 37 | - ``DiscoveryCriteria/discoveryDescriptor`` 38 | - ``DiscoveryCriteria/matches(descriptor:)`` 39 | 40 | ### Company Identifier 41 | 42 | Convert a SpeziBluetooth ``ManufacturerIdentifier`` into its AccessorySetupKit `ASBluetoothCompanyIdentifier` representation. 43 | 44 | - ``ManufacturerIdentifier/bluetoothCompanyIdentifier`` 45 | 46 | 47 | ### Device Variant Criteria 48 | 49 | Apply a SpeziBluetooth ``DeviceVariantCriteria`` to a AccessorySetupKit `ASDiscoveryDescriptor`. 50 | 51 | - ``DeviceVariantCriteria/apply(to:)`` 52 | - ``DeviceVariantCriteria/matches(descriptor:)`` 53 | -------------------------------------------------------------------------------- /Sources/SpeziBluetoothServices/Services/BloodPressureService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import SpeziBluetooth 10 | 11 | 12 | /// Bluetooth Blood Pressure Service implementation. 13 | /// 14 | /// This class partially implements the Bluetooth [Blood Pressure Service 1.1](https://www.bluetooth.com/specifications/specs/blood-pressure-service-1-1-1). 15 | /// - Note: The Enhance Blood Pressure Service is currently not supported. 16 | public struct BloodPressureService: BluetoothService, Sendable { 17 | public static let id: BTUUID = "1810" 18 | 19 | /// Receive blood pressure measurements 20 | /// 21 | /// - Note: This characteristic is required and indicate-only. 22 | @Characteristic(id: "2A35", notify: true, autoRead: false) 23 | public var bloodPressureMeasurement: BloodPressureMeasurement? 24 | 25 | /// Describe supported features of the blood pressure cuff. 26 | /// 27 | /// - Note: This characteristic is required and read-only (optionally supports indicate). 28 | @Characteristic(id: "2A49", notify: true) 29 | public var features: BloodPressureFeature? 30 | 31 | /// Receive intermediate cuff pressure. 32 | /// 33 | /// - Note: This characteristic is optional and notify-only. 34 | @Characteristic(id: "2A36", notify: true) 35 | public var intermediateCuffPressure: IntermediateCuffPressure? 36 | 37 | 38 | /// Initialize a new Blood Pressure Service. 39 | public init() {} 40 | } 41 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryDescription.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | 10 | /// Description of a device discovery strategy. 11 | /// 12 | /// This type describes how to discover a device and what services and characteristics 13 | /// to expect. 14 | public struct DiscoveryDescription { 15 | /// The criteria by which we identify a discovered device. 16 | public let discoveryCriteria: DiscoveryCriteria 17 | /// Description of the device. 18 | /// 19 | /// Provides guidance how and what to discover of the bluetooth peripheral. 20 | public let device: DeviceDescription 21 | 22 | 23 | /// Create a new discovery configuration for a given type of device. 24 | /// - Parameters: 25 | /// - discoveryCriteria: The criteria by which we identify a discovered device. 26 | /// - device: The description of the device. 27 | public init(discoverBy discoveryCriteria: DiscoveryCriteria, device: DeviceDescription) { 28 | self.discoveryCriteria = discoveryCriteria 29 | self.device = device 30 | } 31 | } 32 | 33 | 34 | extension DiscoveryDescription: Sendable {} 35 | 36 | 37 | extension DiscoveryDescription: Identifiable { 38 | public var id: DiscoveryCriteria { 39 | discoveryCriteria 40 | } 41 | } 42 | 43 | 44 | extension DiscoveryDescription: Hashable { 45 | public func hash(into hasher: inout Hasher) { 46 | hasher.combine(discoveryCriteria) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/CoreBluetooth/Model/ManufacturerIdentifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import ByteCoding 10 | import NIOCore 11 | 12 | 13 | /// Bluetooth SIG-assigned Manufacturer Identifier. 14 | /// 15 | /// Refer to Assigned Numbers 7. Company Identifiers. 16 | public struct ManufacturerIdentifier { 17 | /// The raw manufacturer identifier. 18 | public let rawValue: UInt16 19 | 20 | /// Initialize a new manufacturer identifier form its code. 21 | /// - Parameter code: The Bluetooth SIG-assigned Manufacturer Identifier. 22 | public init(_ code: UInt16) { 23 | self.init(rawValue: code) 24 | } 25 | } 26 | 27 | 28 | extension ManufacturerIdentifier: Hashable, Sendable {} 29 | 30 | 31 | extension ManufacturerIdentifier: RawRepresentable { 32 | public init(rawValue: UInt16) { 33 | self.rawValue = rawValue 34 | } 35 | } 36 | 37 | 38 | extension ManufacturerIdentifier: ExpressibleByIntegerLiteral { 39 | public init(integerLiteral value: UInt16) { 40 | self.init(rawValue: value) 41 | } 42 | } 43 | 44 | 45 | extension ManufacturerIdentifier: ByteCodable { 46 | public init?(from byteBuffer: inout ByteBuffer) { 47 | guard let rawValue = UInt16(from: &byteBuffer) else { 48 | return nil 49 | } 50 | self.init(rawValue: rawValue) 51 | } 52 | 53 | public func encode(to byteBuffer: inout ByteBuffer) { 54 | rawValue.encode(to: &byteBuffer) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/AccessorySetupKit/ASAccessoryEventType+Description.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | #if canImport(AccessorySetupKit) && !os(macOS) 10 | import AccessorySetupKit 11 | 12 | 13 | extension ASAccessoryEventType: @retroactive CustomStringConvertible, @retroactive CustomDebugStringConvertible { 14 | public var description: String { 15 | switch self { 16 | case .unknown: 17 | "unknown" 18 | case .activated: 19 | "activated" 20 | case .invalidated: 21 | "invalidated" 22 | case .migrationComplete: 23 | "migrationComplete" 24 | case .accessoryAdded: 25 | "accessoryAdded" 26 | case .accessoryRemoved: 27 | "accessoryRemoved" 28 | case .accessoryChanged: 29 | "accessoryChanged" 30 | case .pickerDidPresent: 31 | "pickerDidPresent" 32 | case .pickerDidDismiss: 33 | "pickerDidDismiss" 34 | case .pickerSetupBridging: 35 | "pickerSetupBridging" 36 | case .pickerSetupFailed: 37 | "pickerSetupFailed" 38 | case .pickerSetupPairing: 39 | "pickerSetupPairing" 40 | case .pickerSetupRename: 41 | "pickerSetupRename" 42 | @unknown default: 43 | "ASAccessoryEventType(rawValue: \(rawValue))" 44 | } 45 | } 46 | 47 | public var debugDescription: String { 48 | description 49 | } 50 | } 51 | #endif 52 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Modifier/BluetoothScanner.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import Foundation 10 | 11 | 12 | @_spi(APISupport) 13 | public protocol BluetoothScanningState: Equatable, Sendable { 14 | /// Merge with another state. Order should not matter in the operation. 15 | /// - Parameter other: The other state to merge with 16 | func merging(with other: Self) -> Self 17 | 18 | func updateOptions(minimumRSSI: Int?, advertisementStaleInterval: TimeInterval?) -> Self 19 | } 20 | 21 | 22 | /// Any kind of Bluetooth Scanner. 23 | @_spi(APISupport) 24 | public protocol BluetoothScanner: Identifiable, Sendable where ID: Hashable { 25 | /// Captures state required to start scanning. 26 | associatedtype ScanningState: BluetoothScanningState 27 | 28 | /// Indicates if there is at least one connected peripheral. 29 | /// 30 | /// Make sure this tracks observability of all devices. 31 | @MainActor var hasConnectedDevices: Bool { get } 32 | 33 | /// Scan for nearby bluetooth devices. 34 | /// 35 | /// How devices are discovered and how they can be accessed is implementation defined. 36 | /// 37 | /// - Parameter state: The scanning state. 38 | func scanNearbyDevices(_ state: ScanningState) async 39 | 40 | /// Update the `ScanningState` for an currently ongoing scanning session. 41 | func updateScanningState(_ state: ScanningState) async 42 | 43 | /// Stop scanning for nearby bluetooth devices. 44 | func stopScanning() async 45 | } 46 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Model/PropertySupport/ServiceAccessor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | 10 | /// Interact with a given Service. 11 | /// 12 | /// This type allows you to interact with a Service you previously declared using the ``Service`` property wrapper. 13 | /// 14 | /// - Note: The accessor captures the service instance upon creation. Within the same `ServiceAccessor` instance 15 | /// the view on the service is consistent. However, if you project a new `ServiceAccessor` instance right 16 | /// after your access, the view on the service might have changed due to the asynchronous nature of SpeziBluetooth. 17 | /// 18 | /// ## Topics 19 | /// 20 | /// ### Service properties 21 | /// - ``isPresent`` 22 | /// - ``isPrimary`` 23 | public struct ServiceAccessor { 24 | private let serviceState: Service.State.ServiceState 25 | 26 | /// Determine if the service is available. 27 | /// 28 | /// Returns `true` if the device is connected and the service is available and discovered. 29 | public var isPresent: Bool { 30 | serviceState != .notPresent 31 | } 32 | 33 | /// The type of the service (primary or secondary). 34 | /// 35 | /// Returns `false` if service is not available. 36 | public var isPrimary: Bool { 37 | serviceState == .presentPrimary 38 | } 39 | 40 | init(_ storage: Service.Storage) { 41 | self.serviceState = storage.state.serviceState 42 | } 43 | } 44 | 45 | 46 | extension ServiceAccessor: Sendable {} 47 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | 10 | /// A characteristic description. 11 | public struct CharacteristicDescription { 12 | /// The characteristic id. 13 | public let characteristicId: BTUUID 14 | /// Flag indicating if descriptors should be discovered for this characteristic. 15 | public let discoverDescriptors: Bool 16 | /// Flag indicating if SpeziBluetooth should automatically read the initial value from the peripheral. 17 | public let autoRead: Bool 18 | 19 | 20 | /// Create a new characteristic description. 21 | /// - Parameters: 22 | /// - id: The bluetooth characteristic id. 23 | /// - discoverDescriptors: Optional flag to specify that descriptors of this characteristic should be discovered. 24 | /// - autoRead: Flag indicating if SpeziBluetooth should automatically read the initial value from the peripheral. 25 | public init(id: BTUUID, discoverDescriptors: Bool = false, autoRead: Bool = true) { 26 | self.characteristicId = id 27 | self.discoverDescriptors = discoverDescriptors 28 | self.autoRead = autoRead 29 | } 30 | } 31 | 32 | 33 | extension CharacteristicDescription: Sendable {} 34 | 35 | 36 | extension CharacteristicDescription: ExpressibleByStringLiteral { 37 | public init(stringLiteral value: StringLiteralType) { 38 | self.init(id: BTUUID(stringLiteral: value)) 39 | } 40 | } 41 | 42 | 43 | extension CharacteristicDescription: Hashable {} 44 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Utils/ConnectedDevices.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import SwiftUI 10 | 11 | 12 | /// Collection of connected devices. 13 | /// 14 | /// Use this type to retrieve the list of connected devices from the environment for configured ``BluetoothDevice``s. 15 | /// 16 | /// Below is a code example that list all connected devices of the type `MyDevice`. 17 | /// ```swift 18 | /// struct MyView: View { 19 | /// @Environment(ConnectedDevices.self) 20 | /// var connectedDevices 21 | /// 22 | /// var body: some View { 23 | /// List { 24 | /// Section("Connected Devices") { 25 | /// ForEach(connectedDevices) { device in 26 | /// Text("\(device.name ?? "unknown")") 27 | /// } 28 | /// } 29 | /// } 30 | /// } 31 | /// } 32 | /// ``` 33 | public final class ConnectedDevices: Observable { 34 | let devices: [Device] 35 | 36 | @MainActor 37 | init(_ devices: [Device] = []) { 38 | self.devices = devices 39 | } 40 | } 41 | 42 | 43 | extension ConnectedDevices: RandomAccessCollection { 44 | public var startIndex: Int { 45 | devices.startIndex 46 | } 47 | 48 | public var endIndex: Int { 49 | devices.endIndex 50 | } 51 | 52 | public func index(after index: Int) -> Int { 53 | devices.index(after: index) 54 | } 55 | 56 | public subscript(position: Int) -> Device { 57 | devices[position] 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/SpeziBluetoothServices/Characteristics/MeasurementInterval.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import ByteCoding 10 | import NIOCore 11 | 12 | 13 | /// Represents the time between measurements. 14 | /// 15 | /// Refer to GATT Specification Supplement, 3.150 Measurement Interval. 16 | public enum MeasurementInterval { 17 | /// No periodic measurement 18 | case noPeriodicMeasurement 19 | /// Duration of measurement interval. 20 | case duration(_ seconds: UInt16) 21 | } 22 | 23 | 24 | extension MeasurementInterval: Hashable, Sendable {} 25 | 26 | 27 | extension MeasurementInterval: RawRepresentable { 28 | public var rawValue: UInt16 { 29 | switch self { 30 | case .noPeriodicMeasurement: 31 | 0 32 | case let .duration(seconds): 33 | seconds 34 | } 35 | } 36 | 37 | public init(rawValue: UInt16) { 38 | switch rawValue { 39 | case 0: 40 | self = .noPeriodicMeasurement 41 | default: 42 | self = .duration(rawValue) 43 | } 44 | } 45 | } 46 | 47 | 48 | extension MeasurementInterval: ByteCodable { 49 | public init?(from byteBuffer: inout ByteBuffer) { 50 | guard let value = UInt16(from: &byteBuffer) else { 51 | return nil 52 | } 53 | 54 | self.init(rawValue: value) 55 | } 56 | 57 | public func encode(to byteBuffer: inout ByteBuffer) { 58 | rawValue.encode(to: &byteBuffer) 59 | } 60 | } 61 | 62 | 63 | extension MeasurementInterval: Codable {} 64 | -------------------------------------------------------------------------------- /Sources/SpeziBluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessFilterType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import ByteCoding 10 | import Foundation 11 | import NIOCore 12 | 13 | 14 | /// Filter types used with the generic operand. 15 | /// 16 | /// These filter types are used with the ``RecordAccessGenericOperand``. 17 | public struct RecordAccessFilterType { 18 | /// Reserved for future use. 19 | public static let reserved = RecordAccessFilterType(rawValue: 0x00) 20 | /// Filter for a record's sequence number. 21 | public static let sequenceNumber = RecordAccessFilterType(rawValue: 0x01) 22 | /// Filter for a record's user facing time. 23 | public static let userFacingTime = RecordAccessFilterType(rawValue: 0x02) 24 | 25 | 26 | /// The raw value filter type. 27 | public let rawValue: UInt8 28 | 29 | /// Initialize using a raw value filter type. 30 | /// - Parameter rawValue: The filter type. 31 | public init(rawValue: UInt8) { 32 | self.rawValue = rawValue 33 | } 34 | } 35 | 36 | 37 | extension RecordAccessFilterType: RawRepresentable {} 38 | 39 | 40 | extension RecordAccessFilterType: Hashable, Sendable {} 41 | 42 | 43 | extension RecordAccessFilterType: ByteCodable { 44 | public init?(from byteBuffer: inout ByteBuffer) { 45 | guard let rawValue = UInt8(from: &byteBuffer) else { 46 | return nil 47 | } 48 | self.init(rawValue: rawValue) 49 | } 50 | 51 | public func encode(to byteBuffer: inout ByteBuffer) { 52 | rawValue.encode(to: &byteBuffer) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/AccessorySetupKit/DiscoveryCriteria+Descriptor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | #if canImport(AccessorySetupKit) && !os(macOS) 10 | import AccessorySetupKit 11 | 12 | 13 | @available(iOS 18.0, *) 14 | @available(macCatalyst, unavailable) 15 | extension DiscoveryCriteria { 16 | /// Retrieve the `ASDiscoveryDescriptor` representation for the discovery criteria. 17 | @available(iOS 18.0, *) 18 | public var discoveryDescriptor: ASDiscoveryDescriptor { 19 | let descriptor = ASDiscoveryDescriptor() 20 | 21 | if aspects.count(where: { $0.isServiceId }) > 1 { 22 | Bluetooth.logger.warning( 23 | """ 24 | DiscoveryCriteria has multiple service uuids specified. This is not supported by AccessorySetupKit and only the first one \ 25 | will be used with the ASDiscoveryDescriptor: \(self). 26 | """ 27 | ) 28 | } 29 | 30 | for aspect in aspects { 31 | aspect.apply(to: descriptor) 32 | } 33 | 34 | return descriptor 35 | } 36 | 37 | /// Determine if a discovery descriptor matches the discovery criteria. 38 | /// - Parameter descriptor: The discovery descriptor. 39 | /// - Returns: Returns `true` if all discovery aspects are present and matching on the discovery descriptor. The discovery descriptor might have other fields set. 40 | @available(iOS 18.0, *) 41 | public func matches(descriptor: ASDiscoveryDescriptor) -> Bool { 42 | aspects.allSatisfy { aspect in 43 | aspect.matches(descriptor) 44 | } 45 | } 46 | } 47 | #endif 48 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import Atomics 10 | import CoreBluetooth 11 | 12 | 13 | /// Describes the state of a Bluetooth peripheral. 14 | public enum PeripheralState: UInt8 { 15 | /// The peripheral is disconnected. 16 | case disconnected 17 | /// The peripheral is currently establishing a connection. 18 | case connecting 19 | /// The peripheral is connected. 20 | case connected 21 | /// The peripheral is currently disconnecting. 22 | case disconnecting 23 | } 24 | 25 | 26 | extension PeripheralState: RawRepresentable, AtomicValue {} 27 | 28 | 29 | extension PeripheralState: Hashable, Sendable {} 30 | 31 | 32 | extension PeripheralState: CustomStringConvertible { 33 | public var description: String { 34 | switch self { 35 | case .disconnected: 36 | "disconnected" 37 | case .connecting: 38 | "connecting" 39 | case .connected: 40 | "connected" 41 | case .disconnecting: 42 | "disconnecting" 43 | } 44 | } 45 | } 46 | 47 | 48 | extension PeripheralState { 49 | /// Derive peripheral state from CoreBluetooth 50 | public init(from state: CBPeripheralState) { 51 | switch state { 52 | case .disconnected: 53 | self = .disconnected 54 | case .connecting: 55 | self = .connecting 56 | case .connected: 57 | self = .connected 58 | case .disconnecting: 59 | self = .disconnecting 60 | @unknown default: 61 | self = .disconnected 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/SpeziBluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessGeneralResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import ByteCoding 10 | import Foundation 11 | import NIOCore 12 | 13 | 14 | /// The description of a general response. 15 | /// 16 | /// Used to represent the content of a ``RecordAccessOpCode/responseCode`` operation. 17 | public struct RecordAccessGeneralResponse { 18 | /// The operation code of the request this response is triggered from. 19 | public let requestOpCode: RecordAccessOpCode 20 | /// The response code. 21 | public let response: RecordAccessResponseCode 22 | 23 | 24 | /// Initialize a new general response. 25 | /// - Parameters: 26 | /// - requestOpCode: The request code. 27 | /// - response: The response code. 28 | public init(requestOpCode: RecordAccessOpCode, response: RecordAccessResponseCode) { 29 | self.requestOpCode = requestOpCode 30 | self.response = response 31 | } 32 | } 33 | 34 | 35 | extension RecordAccessGeneralResponse: Hashable, Sendable {} 36 | 37 | 38 | extension RecordAccessGeneralResponse: ByteCodable { 39 | public init?(from byteBuffer: inout ByteBuffer) { 40 | guard let requestOpCode = RecordAccessOpCode(from: &byteBuffer), 41 | let response = RecordAccessResponseCode(from: &byteBuffer) else { 42 | return nil 43 | } 44 | 45 | self.init(requestOpCode: requestOpCode, response: response) 46 | } 47 | 48 | public func encode(to byteBuffer: inout ByteBuffer) { 49 | requestOpCode.encode(to: &byteBuffer) 50 | response.encode(to: &byteBuffer) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/AccessorySetupKit/AccessoryEventRegistration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import Foundation 10 | 11 | 12 | /// An event handler registration for accessory events. 13 | /// 14 | /// It automatically cancels the subscription once this value is de-initialized. 15 | public struct AccessoryEventRegistration: ~Copyable, Sendable { 16 | private let id: UUID 17 | private weak var setupKit: (AnyObject & Sendable)? // type erased as AccessorySetupKit is only available on iOS 18 platform. 18 | 19 | @available(iOS 18.0, *) 20 | @available(macCatalyst, unavailable) 21 | init(id: UUID, setupKit: AccessorySetupKit?) { 22 | self.id = id 23 | self.setupKit = setupKit 24 | } 25 | 26 | static func cancel(id: UUID, setupKit: (AnyObject & Sendable)?, isolation: isolated (any Actor)? = #isolation) { 27 | #if os(iOS) && !targetEnvironment(macCatalyst) 28 | guard #available(iOS 18, *) else { 29 | return 30 | } 31 | 32 | guard let setupKit, let typedSetupKit = setupKit as? AccessorySetupKit else { 33 | return 34 | } 35 | 36 | typedSetupKit.cancelHandler(for: id) 37 | #else 38 | preconditionFailure("Not available on this platform!") 39 | #endif 40 | } 41 | 42 | /// Cancel the subscription. 43 | /// - Parameter isolation: Inherits the current actor isolation. If running on the MainActor cancellation is processed instantly. 44 | public func cancel(isolation: isolated (any Actor)? = #isolation) { 45 | Self.cancel(id: id, setupKit: setupKit) 46 | } 47 | 48 | deinit { 49 | Self.cancel(id: id, setupKit: setupKit) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Model/Actions/DeviceActions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | 10 | /// Collection of device actions available on a ``BluetoothPeripheral``. 11 | /// 12 | /// Exposes the meta-types of all available actions of a Bluetooth Device as properties of this type. 13 | /// 14 | /// ## Topics 15 | /// 16 | /// ### Managing Connection 17 | /// - ``connect`` 18 | /// - ``disconnect`` 19 | /// 20 | /// ### Retrieving current signal strength 21 | /// - ``readRSSI`` 22 | /// 23 | /// ### Implementations 24 | /// 25 | /// - ``BluetoothConnectAction`` 26 | /// - ``BluetoothDisconnectAction`` 27 | /// - ``ReadRSSIAction`` 28 | public struct DeviceActions { 29 | /// Connect to the Bluetooth peripheral. 30 | /// 31 | /// Make a connection to the peripheral. The method returns once the device is connected and fully discovered. 32 | /// If service or characteristic discovery fails, this action will throw the respective error and automatically disconnect the device. 33 | /// 34 | /// This action makes a call to ``BluetoothPeripheral/connect()``. 35 | public var connect: BluetoothConnectAction.Type { 36 | BluetoothConnectAction.self 37 | } 38 | 39 | /// Disconnect from the Bluetooth peripheral. 40 | /// 41 | /// This action makes a call to ``BluetoothPeripheral/disconnect()``. 42 | public var disconnect: BluetoothDisconnectAction.Type { 43 | BluetoothDisconnectAction.self 44 | } 45 | 46 | /// Retrieve the current signal strength. 47 | /// 48 | /// This action makes a call to ``BluetoothPeripheral/readRSSI()`` 49 | public var readRSSI: ReadRSSIAction.Type { 50 | ReadRSSIAction.self 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Model/BluetoothService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | 10 | /// A Bluetooth service implementation. 11 | /// 12 | /// This protocol allows you to decoratively define a service of a given Bluetooth peripheral. 13 | /// Use the ``Characteristic`` property wrapper to declare all characteristics of your service. 14 | /// 15 | /// - Tip: You may also use the ``DeviceState`` and ``DeviceAction`` property wrappers within your service implementation 16 | /// to interact with the Bluetooth device the service is used on. 17 | /// 18 | /// Below is a short code example that implements some parts of the Device Information service. 19 | /// 20 | /// ```swift 21 | /// struct DeviceInformationService: BluetoothService { 22 | /// static let id: BTUUID = "180A" 23 | /// 24 | /// @Characteristic(id: "2A29") 25 | /// var manufacturer: String? 26 | /// @Characteristic(id: "2A26") 27 | /// var firmwareRevision: String? 28 | /// } 29 | /// ``` 30 | /// 31 | /// ## Topics 32 | /// 33 | /// ### Bluetooth UUID 34 | /// - ``id`` 35 | /// 36 | /// ### Configuration 37 | /// - ``configure()`` 38 | public protocol BluetoothService { 39 | /// The Bluetooth service id. 40 | static var id: BTUUID { get } 41 | 42 | /// Configure the bluetooth service. 43 | /// 44 | /// Use this method to perform initial configuration of the service (e.g., set up `onChange` handlers). 45 | /// This method is called by the ``Bluetooth`` module, once the device is getting configured. 46 | @SpeziBluetooth 47 | func configure() 48 | } 49 | 50 | 51 | extension BluetoothService { 52 | /// Empty default configure method. 53 | public func configure() {} 54 | } 55 | -------------------------------------------------------------------------------- /Sources/SpeziBluetoothServices/BluetoothServices.docc/Characteristics.md: -------------------------------------------------------------------------------- 1 | # Characteristics 2 | 3 | Reusable implementations of standardized Bluetooth Characteristics. 4 | 5 | 14 | 15 | ## Overview 16 | 17 | SpeziBluetooth provides a collection of Bluetooth characteristics already out of the box. 18 | This includes generic characteristics like 19 | ``DateTime`` and a collection of characteristics from the health domain. 20 | 21 | Below is a list of already implemented characteristics. 22 | Encoding and decoding is handled using [`ByteCodable`](https://swiftpackageindex.com/StanfordSpezi/SpeziFileFormats/documentation/bytecoding) 23 | which natively integrates with SpeziBluetooth-defined services. 24 | 25 | ## Topics 26 | 27 | ### Device Information 28 | 29 | - ``PnPID`` 30 | - ``VendorIDSource`` 31 | 32 | ### Time 33 | 34 | - ``DateTime`` 35 | - ``DayOfWeek`` 36 | - ``DayDateTime`` 37 | - ``ExactTime256`` 38 | - ``CurrentTime`` 39 | 40 | ### Pulse Oximetry 41 | 42 | - ``PLXContinuousMeasurement`` 43 | - ``PLXSpotCheckMeasurement`` 44 | - ``PLXFeatures`` 45 | 46 | ### Blood Pressure 47 | 48 | - ``BloodPressureMeasurement`` 49 | - ``BloodPressureFeature`` 50 | - ``IntermediateCuffPressure`` 51 | 52 | ### Temperature Measurement 53 | 54 | - ``TemperatureMeasurement`` 55 | - ``TemperatureType`` 56 | - ``MeasurementInterval`` 57 | 58 | ### Weight Measurement 59 | 60 | - ``WeightMeasurement`` 61 | - ``WeightScaleFeature`` 62 | 63 | 64 | ### Record Access Control Point 65 | 66 | - ``RecordAccessControlPoint`` 67 | - ``RecordAccessOpCode`` 68 | - ``RecordAccessOperator`` 69 | - ``RecordAccessOperand`` 70 | - ``RecordAccessResponseCode`` 71 | - ``RecordAccessOperationContent`` 72 | - ``RecordAccessGenericOperand`` 73 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/CoreBluetooth/Model/OnChangeRegistration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import Foundation 10 | 11 | 12 | /// An active registration of a on-change handler. 13 | /// 14 | /// This object represents an active registration of an on-change handler. Primarily, this can be used to keep 15 | /// track of a on-change handler and cancel the registration at a later point. 16 | /// 17 | /// - Tip: The on-change handler will be automatically unregistered when this object is deallocated. 18 | public final class OnChangeRegistration { 19 | // reference counting is atomic, so non-isolated(unsafe) is fine, we never mutate 20 | private nonisolated(unsafe) weak var peripheral: BluetoothPeripheral? 21 | let locator: CharacteristicLocator 22 | let handlerId: UUID 23 | 24 | 25 | init(peripheral: BluetoothPeripheral?, locator: CharacteristicLocator, handlerId: UUID) { 26 | self.peripheral = peripheral 27 | self.locator = locator 28 | self.handlerId = handlerId 29 | } 30 | 31 | private static func cancel(peripheral: BluetoothPeripheral?, locator: CharacteristicLocator, handlerId: UUID) { 32 | guard let peripheral else { 33 | return 34 | } 35 | Task.detached { @SpeziBluetooth in 36 | peripheral.deregisterOnChange(locator: locator, handlerId: handlerId) 37 | } 38 | } 39 | 40 | 41 | /// Cancel the on-change handler registration. 42 | public func cancel() { 43 | Self.cancel(peripheral: peripheral, locator: locator, handlerId: handlerId) 44 | } 45 | 46 | 47 | deinit { 48 | // make sure we don't capture self after this deinit 49 | Self.cancel(peripheral: peripheral, locator: locator, handlerId: handlerId) 50 | } 51 | } 52 | 53 | 54 | extension OnChangeRegistration: Sendable {} 55 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Utils/ConnectedDevicesModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import Foundation 10 | import OrderedCollections 11 | 12 | 13 | @Observable 14 | @MainActor 15 | class ConnectedDevicesModel: Sendable { 16 | /// We track the connected device for every BluetoothDevice type and index by peripheral identifier. 17 | private var connectedDevices: [ObjectIdentifier: OrderedDictionary] = [:] 18 | 19 | nonisolated init() {} 20 | 21 | func update(with devices: [UUID: any BluetoothDevice]) { 22 | // remove devices that disconnected 23 | for (identifier, var devicesById) in connectedDevices { 24 | var update = false 25 | for id in devicesById.keys where devices[id] == nil { 26 | devicesById.removeValue(forKey: id) 27 | update = true 28 | } 29 | 30 | if update { 31 | connectedDevices[identifier] = devicesById.isEmpty ? nil : devicesById 32 | } 33 | } 34 | 35 | // add newly connected devices that are not injected yet 36 | for (uuid, device) in devices { 37 | guard connectedDevices[device.typeIdentifier]?[uuid] == nil else { 38 | continue // already present 39 | } 40 | 41 | // Newly connected device 42 | connectedDevices[device.typeIdentifier, default: [:]].updateValue(device, forKey: uuid) 43 | } 44 | } 45 | 46 | subscript(_ identifier: ObjectIdentifier) -> [(any BluetoothDevice)] { 47 | guard let values = connectedDevices[identifier]?.values else { 48 | return [] 49 | } 50 | return Array(values) 51 | } 52 | } 53 | 54 | 55 | extension BluetoothDevice { 56 | fileprivate var typeIdentifier: ObjectIdentifier { 57 | ObjectIdentifier(Self.self) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessResponseFormatError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | 10 | /// Error when receiving the response of a Record Access Control Point value. 11 | /// 12 | /// The value returned from the Record Access Control Point characteristic for a previously sent 13 | /// request has unexpected format. 14 | public struct RecordAccessResponseFormatError { 15 | /// The reason for the format error. 16 | public enum Reason { 17 | /// The response had an unexpected opcode. 18 | case unexpectedOpcode 19 | /// The response had an unexpected operator. 20 | case unexpectedOperator 21 | /// The operand had an unexpected format. 22 | case unexpectedOperand 23 | /// The response indicated that it is the response to a different request opcode than anticipated. 24 | case invalidResponse 25 | } 26 | 27 | /// The opcode of the response received. 28 | public let responseCode: RecordAccessOpCode 29 | /// The operator of the response received. 30 | public let responseOperator: RecordAccessOperator 31 | /// The operand of the response received. 32 | public let responseOperand: (any RecordAccessOperand)? 33 | /// The reason of the error. 34 | public let reason: Reason 35 | 36 | 37 | /// Initialize a new error. 38 | /// - Parameters: 39 | /// - response: The response for which this error occurred. 40 | /// - reason: The reason for the error. 41 | public init(response: Response, reason: Reason) { 42 | self.responseCode = response.opCode 43 | self.responseOperator = response.operator 44 | self.responseOperand = response.operand 45 | self.reason = reason 46 | } 47 | } 48 | 49 | 50 | extension RecordAccessResponseFormatError.Reason: Sendable, Hashable {} 51 | 52 | 53 | extension RecordAccessResponseFormatError: Error {} 54 | -------------------------------------------------------------------------------- /Sources/SpeziBluetoothServices/Services/HealthThermometerService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import SpeziBluetooth 10 | 11 | 12 | /// Bluetooth Health Thermometer Service implementation. 13 | /// 14 | /// This class implements the Bluetooth [Health Thermometer Service 1.0](https://www.bluetooth.com/specifications/specs/health-thermometer-service-1-0). 15 | public struct HealthThermometerService: BluetoothService, Sendable { 16 | public static let id: BTUUID = "1809" 17 | 18 | /// Receive temperature measurements. 19 | /// 20 | /// - Note: This characteristic is required and indicate-only. 21 | @Characteristic(id: "2A1C", notify: true, autoRead: false) 22 | public var temperatureMeasurement: TemperatureMeasurement? 23 | /// The body location of the temperature measurement. 24 | /// 25 | /// Either use this static property or dynamically set it within ``TemperatureMeasurement/temperatureType``. 26 | /// Don't use both. Either of one is required. 27 | /// 28 | /// - Note: This characteristic is optional and read-only. 29 | @Characteristic(id: "2A1D") 30 | public var temperatureType: TemperatureType? 31 | /// Receive intermediate temperature values to a device for display purposes while a measurement is in progress. 32 | /// 33 | /// - Note: This characteristic is optional and notify-only. 34 | @Characteristic(id: "2A1E", notify: true) 35 | public var intermediateTemperature: TemperatureMeasurement? 36 | /// The measurement interval between two measurements. 37 | /// 38 | /// Describes the measurements of ``temperatureMeasurement``. 39 | /// 40 | /// - Note: This characteristic is optional and read-only. 41 | /// Optionally it might indicate and writeable. 42 | @Characteristic(id: "2A21") 43 | public var measurementInterval: MeasurementInterval? 44 | 45 | 46 | /// Initialize a new Health Thermometer Service. 47 | public init() {} 48 | } 49 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Model/SemanticModel/DeviceDescriptionParser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | 10 | private struct CharacteristicsBuilder: ServiceVisitor { 11 | var characteristics: Set = [] 12 | 13 | mutating func visit(_ characteristic: Characteristic) { 14 | characteristics.insert(characteristic.description) 15 | } 16 | } 17 | 18 | 19 | private struct ServiceDescriptionBuilder: DeviceVisitor { 20 | var configurations: Set = [] 21 | 22 | mutating func visit(_ service: Service) { 23 | var visitor = CharacteristicsBuilder() 24 | service.wrappedValue.accept(&visitor) 25 | 26 | let configuration = ServiceDescription(serviceId: service.id, characteristics: visitor.characteristics) 27 | configurations.insert(configuration) 28 | } 29 | } 30 | 31 | 32 | extension BluetoothDevice { 33 | @SpeziBluetooth 34 | static func parseDeviceDescription() -> DeviceDescription { 35 | let device = Self() 36 | 37 | var builder = ServiceDescriptionBuilder() 38 | device.accept(&builder) 39 | return DeviceDescription(services: builder.configurations) 40 | } 41 | } 42 | 43 | 44 | extension DeviceDiscoveryDescriptor { 45 | @SpeziBluetooth 46 | func parseDiscoveryDescription() -> DiscoveryDescription { 47 | let deviceDescription = deviceType.parseDeviceDescription() 48 | return DiscoveryDescription(discoverBy: discoveryCriteria, device: deviceDescription) 49 | } 50 | } 51 | 52 | 53 | extension Set where Element == DeviceDiscoveryDescriptor { 54 | var deviceTypes: [any BluetoothDevice.Type] { 55 | map { configuration in 56 | configuration.deviceType 57 | } 58 | } 59 | 60 | @SpeziBluetooth 61 | func parseDiscoveryDescription() -> Set { 62 | Set(map { $0.parseDiscoveryDescription() }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessOperationContent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | 10 | /// Describes the content of a operation. 11 | /// 12 | /// Describes the content of a operation identified by a ``RecordAccessOpCode``. 13 | /// The content describes the union of an ``RecordAccessOperator`` and an ``RecordAccessOperand``. 14 | /// 15 | /// - Note: The content format is defined by the service and suitable static members may be available depending on your imports. 16 | /// 17 | /// ## Topics 18 | /// 19 | /// ### General Content 20 | /// - ``allRecords`` 21 | /// - ``firstRecord`` 22 | /// - ``lastRecord`` 23 | public struct RecordAccessOperationContent { 24 | let `operator`: RecordAccessOperator 25 | let operand: Operand? 26 | 27 | /// Create a new operation content. 28 | /// - Parameters: 29 | /// - operator: The operator. 30 | /// - operand: The operand. 31 | public init(operator: RecordAccessOperator, operand: Operand? = nil) { 32 | self.operator = `operator` 33 | self.operand = operand 34 | } 35 | } 36 | 37 | 38 | extension RecordAccessOperationContent { 39 | /// All records. 40 | /// 41 | /// Operation applies to all records (e.g., report all records). 42 | public static var allRecords: RecordAccessOperationContent { 43 | RecordAccessOperationContent(operator: .allRecords) 44 | } 45 | 46 | /// The first record. 47 | /// 48 | /// Returns the first record (e.g., oldest record). 49 | /// No operand is used. 50 | public static var firstRecord: RecordAccessOperationContent { 51 | RecordAccessOperationContent(operator: .firstRecord) 52 | } 53 | 54 | /// The last record. 55 | /// 56 | /// Returns the last record (e.g., most recent record). 57 | /// No operand is used. 58 | public static var lastRecord: RecordAccessOperationContent { 59 | RecordAccessOperationContent(operator: .lastRecord) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open source project 3 | // 4 | // SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import Atomics 10 | import CoreBluetooth 11 | 12 | 13 | /// Represents the various states of Bluetooth. 14 | public enum BluetoothState: UInt8 { 15 | /// The Bluetooth state is unknown. 16 | /// 17 | /// The state will become known once you start scanning for nearby devices or use Bluetooth. 18 | case unknown 19 | /// Bluetooth module is powered off. 20 | case poweredOff 21 | /// Bluetooth is unsupported on this device (e.g., on simulator devices). 22 | case unsupported 23 | /// The application does not have permission to use Bluetooth features. 24 | case unauthorized 25 | /// Bluetooth is powered on and usable. 26 | case poweredOn 27 | } 28 | 29 | 30 | extension BluetoothState: RawRepresentable, AtomicValue {} 31 | 32 | 33 | extension BluetoothState: Hashable, Sendable {} 34 | 35 | 36 | extension BluetoothState: CustomStringConvertible { 37 | public var description: String { 38 | switch self { 39 | case .unknown: 40 | "unknown" 41 | case .poweredOff: 42 | "poweredOff" 43 | case .unsupported: 44 | "unsupported" 45 | case .unauthorized: 46 | "unauthorized" 47 | case .poweredOn: 48 | "poweredOn" 49 | } 50 | } 51 | } 52 | 53 | 54 | extension BluetoothState { 55 | /// Derive peripheral state from CoreBluetooth 56 | public init(from state: CBManagerState) { 57 | switch state { 58 | case .unknown: 59 | self = .unknown 60 | case .resetting: 61 | self = .poweredOff 62 | case .unsupported: 63 | self = .unsupported 64 | case .unauthorized: 65 | self = .unauthorized 66 | case .poweredOff: 67 | self = .poweredOff 68 | case .poweredOn: 69 | self = .poweredOn 70 | @unknown default: 71 | self = .unknown 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Configuration/Apperance/Variant.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import SpeziViews 10 | 11 | 12 | /// Describes the appearance of a device variant and criteria that identify the variant. 13 | public struct Variant { 14 | /// A unique and persistent identifier for the device variant. 15 | /// 16 | /// As the ``criteria`` can only be used upon discovery to identify a device variant, the `id` can be used in persistent storage to 17 | /// identify the variant of a device. Make sure this identifier doesn't change and is unique for the device. 18 | public let id: String 19 | /// Provides a user-friendly name for the device. 20 | /// 21 | /// This might be treated as the "initial" name. A user might be allowed to rename the device locally. 22 | public let name: String 23 | /// An icon that is used to refer to the device. 24 | public let icon: ImageReference 25 | /// The criteria that identify a device variant and distinguish the variant from other device variants. 26 | public let criteria: DeviceVariantCriteria 27 | 28 | /// Create a new device variant. 29 | /// - Parameters: 30 | /// - id: A unique and persistent identifier for the device variant. 31 | /// - name: A user-friendly name for the device. 32 | /// - icon: An icon that is used to refer to the device. 33 | /// - criteria: The criteria that identify a device variant and distinguish the variant from other device variants. 34 | /// - Precondition: You have to provide at least one device variant criteria: `!criteria.isEmpty` 35 | public init(id: String, name: String, icon: ImageReference = .system("sensor"), criteria: DeviceVariantCriteria...) { 36 | // swiftlint:disable:previous function_default_parameter_at_end 37 | precondition(!criteria.isEmpty, "At least one device variant criteria must be provided") 38 | 39 | self.id = id 40 | self.name = name 41 | self.icon = icon 42 | self.criteria = DeviceVariantCriteria(from: criteria) 43 | } 44 | } 45 | 46 | 47 | extension Variant: Hashable, Sendable, Identifiable {} 48 | -------------------------------------------------------------------------------- /Tests/UITests/TestApp/Views/DeviceRowView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | @_spi(TestingSupport) 10 | import SpeziBluetooth 11 | import SwiftUI 12 | 13 | 14 | protocol SomePeripheral: Sendable { 15 | var id: UUID { get } 16 | var name: String? { get } 17 | var state: PeripheralState { get } 18 | var rssi: Int { get } 19 | 20 | func connect() async throws 21 | func disconnect() async 22 | } 23 | 24 | 25 | struct DeviceRowView: View { 26 | private let peripheral: Peripheral 27 | 28 | var body: some View { 29 | Button(action: peripheralAction) { 30 | VStack { 31 | HStack { 32 | Text(verbatim: "\(Peripheral.self)") 33 | Spacer() 34 | Text(verbatim: "\(peripheral.rssi) dB") 35 | .foregroundColor(.secondary) 36 | } 37 | .foregroundColor(.primary) 38 | HStack { 39 | Text(peripheral.id.uuidString) 40 | Spacer() 41 | Text(peripheral.state.description) 42 | } 43 | .font(.caption2) 44 | .foregroundColor(.secondary) 45 | } 46 | } 47 | } 48 | 49 | init(peripheral: Peripheral) { 50 | self.peripheral = peripheral 51 | } 52 | 53 | 54 | @MainActor 55 | func peripheralAction() { 56 | let state = peripheral.state 57 | Task { 58 | switch state { 59 | case .disconnected, .disconnecting: 60 | try await self.peripheral.connect() 61 | case .connecting, .connected: 62 | await self.peripheral.disconnect() 63 | } 64 | } 65 | } 66 | } 67 | 68 | 69 | #Preview { 70 | let testDevice = TestDevice() 71 | testDevice.$id.inject(UUID()) 72 | testDevice.$name.inject("Test Device") 73 | testDevice.$rssi.inject(-46) 74 | testDevice.$state.inject(.connected) 75 | 76 | return List { 77 | DeviceRowView(peripheral: testDevice) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Tests/UITests/TestApp/TestApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import Spezi 10 | @_spi(TestingSupport) 11 | import SpeziBluetooth 12 | import SpeziViews 13 | import SwiftUI 14 | 15 | struct NearbyDevices: View { 16 | var body: some View { 17 | BluetoothManagerView() // we use this indirection to trigger BluetoothManager deinit! 18 | } 19 | } 20 | 21 | struct DeviceCountButton: View { 22 | @Environment(Bluetooth.self) 23 | private var bluetooth 24 | 25 | @State private var lastReadCount: Int? 26 | 27 | var body: some View { 28 | Section { 29 | AsyncButton("Query Count") { 30 | let bluetooth = bluetooth 31 | lastReadCount = await bluetooth._initializedDevicesCount() 32 | } 33 | .onDisappear { 34 | lastReadCount = nil 35 | } 36 | } footer: { 37 | if let lastReadCount { 38 | Text("Currently initialized devices: \(lastReadCount)") 39 | } 40 | } 41 | } 42 | } 43 | 44 | @main 45 | struct UITestsApp: App { 46 | @UIApplicationDelegateAdaptor(TestAppDelegate.self) 47 | var appDelegate 48 | 49 | @State private var pairedDeviceId: UUID? 50 | @State private var retrievedDevice: TestDevice? 51 | 52 | 53 | var body: some Scene { 54 | WindowGroup { 55 | NavigationStack { 56 | List { 57 | NavigationLink("Nearby Devices") { 58 | NearbyDevices() 59 | } 60 | NavigationLink("Test Peripheral") { 61 | BluetoothModuleView(pairedDeviceId: $pairedDeviceId) 62 | } 63 | NavigationLink("Paired Device") { 64 | RetrievePairedDevicesView(pairedDeviceId: $pairedDeviceId, retrievedDevice: $retrievedDevice) 65 | } 66 | 67 | DeviceCountButton() 68 | } 69 | .navigationTitle("Spezi Bluetooth") 70 | } 71 | .spezi(appDelegate) 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | 10 | /// A service description for a certain device. 11 | /// 12 | /// Describes what characteristics we expect to be present for a certain service. 13 | public struct ServiceDescription: Sendable { 14 | /// The service id. 15 | public let serviceId: BTUUID 16 | /// The description of characteristics present on the service. 17 | /// 18 | /// Those are the characteristics we try to discover. 19 | /// - Note: If `nil`, we discover all characteristics on a given service. 20 | public var characteristics: Set? { // swiftlint:disable:this discouraged_optional_collection 21 | let values: Dictionary.Values? = _characteristics?.values 22 | return values.map { Set($0) } 23 | } 24 | 25 | private let _characteristics: [BTUUID: CharacteristicDescription]? // swiftlint:disable:this discouraged_optional_collection 26 | 27 | /// Create a new service description. 28 | /// - Parameters: 29 | /// - serviceId: The bluetooth service id. 30 | /// - characteristics: The description of characteristics we expect to be present on the service. 31 | /// Use `nil` to discover all characteristics. 32 | public init(serviceId: BTUUID, characteristics: Set?) { // swiftlint:disable:this discouraged_optional_collection 33 | self.serviceId = serviceId 34 | self._characteristics = characteristics?.reduce(into: [:]) { partialResult, description in 35 | partialResult[description.characteristicId] = description 36 | } 37 | } 38 | 39 | 40 | /// Retrieve the characteristic description for a given service id. 41 | /// - Parameter characteristicsId: The Bluetooth characteristic id. 42 | /// - Returns: Returns the characteristic description if present. 43 | public func description(for characteristicsId: BTUUID) -> CharacteristicDescription? { 44 | _characteristics?[characteristicsId] 45 | } 46 | } 47 | 48 | 49 | extension ServiceDescription: Hashable {} 50 | -------------------------------------------------------------------------------- /Sources/SpeziBluetoothServices/TestingSupport/CBUUID+Characteristics.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import SpeziBluetooth 10 | 11 | 12 | @_spi(TestingSupport) 13 | extension BTUUID { 14 | private static let prefix = "0000" 15 | private static let suffix = "-0000-1000-8000-00805F9B34FB" 16 | 17 | /// The test service. 18 | public static var testService: BTUUID { 19 | .uuid(ofCustom: "F001") 20 | } 21 | 22 | /// An event log of events of the test peripheral implementation. 23 | public static var eventLogCharacteristic: BTUUID { 24 | .uuid(ofCustom: "F002") 25 | } 26 | /// A string characteristic that you can read. 27 | public static var readStringCharacteristic: BTUUID { 28 | .uuid(ofCustom: "F003") 29 | } 30 | /// A string characteristic that you can write. 31 | public static var writeStringCharacteristic: BTUUID { 32 | .uuid(ofCustom: "F004") 33 | } 34 | /// A string characteristic that you can read and write. 35 | public static var readWriteStringCharacteristic: BTUUID { 36 | .uuid(ofCustom: "F005") 37 | } 38 | /// Reset peripheral state to default settings. 39 | public static var resetCharacteristic: BTUUID { 40 | .uuid(ofCustom: "F006") 41 | } 42 | 43 | 44 | private static func uuid(ofCustom: String) -> BTUUID { 45 | precondition(ofCustom.count == 4, "Unexpected length of \(ofCustom.count)") 46 | return BTUUID(string: "\(prefix)\(ofCustom)\(suffix)") 47 | } 48 | 49 | /// Get a short uuid representation of your custom uuid base. 50 | /// - Parameter uuid: The uuid with the SpeziBluetooth base id. 51 | /// - Returns: Short uuid format. 52 | public static func toCustomShort(_ uuid: BTUUID) -> String { 53 | var string = uuid.uuidString 54 | assert(string.hasPrefix(prefix), "unexpected uuid format") 55 | assert(string.hasSuffix(suffix), "unexpected uuid format") 56 | string.removeFirst(prefix.count) 57 | string.removeLast(suffix.count) 58 | assert(string.count == 4, "unexpected uuid string length") 59 | return string 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Configuration/DiscoveryDescriptorBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | 10 | /// Building a set of `Discover` expressions to express what peripherals to discover. 11 | @resultBuilder 12 | public enum DiscoveryDescriptorBuilder { 13 | /// Build a ``Discover`` expression to define a ``DeviceDiscoveryDescriptor``. 14 | public static func buildExpression(_ expression: Discover) -> Set { 15 | [DeviceDiscoveryDescriptor(from: expression)] 16 | } 17 | 18 | /// Build a block of ``DeviceDiscoveryDescriptor``s. 19 | public static func buildBlock(_ components: Set...) -> Set { 20 | buildArray(components) 21 | } 22 | 23 | /// Build the first block of an conditional ``DeviceDiscoveryDescriptor`` component. 24 | public static func buildEither(first component: Set) -> Set { 25 | component 26 | } 27 | 28 | /// Build the second block of an conditional ``DeviceDiscoveryDescriptor`` component. 29 | public static func buildEither(second component: Set) -> Set { 30 | component 31 | } 32 | 33 | /// Build an optional ``DeviceDiscoveryDescriptor`` component. 34 | public static func buildOptional(_ component: Set?) -> Set { 35 | // swiftlint:disable:previous discouraged_optional_collection 36 | component ?? [] 37 | } 38 | 39 | /// Build an ``DeviceDiscoveryDescriptor`` component with limited availability. 40 | public static func buildLimitedAvailability(_ component: Set) -> Set { 41 | component 42 | } 43 | 44 | /// Build an array of ``DeviceDiscoveryDescriptor`` components. 45 | public static func buildArray(_ components: [Set]) -> Set { 46 | components.reduce(into: []) { result, component in 47 | result.formUnion(component) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Tests/UITests/TestApp/TestDevice.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import Foundation 10 | import SpeziBluetooth 11 | @_spi(TestingSupport) 12 | import SpeziBluetoothServices 13 | 14 | 15 | final class TestDevice: BluetoothDevice, Identifiable, SomePeripheral, @unchecked Sendable { 16 | @Observable 17 | class State { 18 | @MainActor fileprivate(set) var didReceiveManufacturer = false 19 | @MainActor fileprivate(set) var didReceiveModel = false 20 | 21 | init() {} 22 | } 23 | 24 | @DeviceState(\.id) 25 | var id 26 | @DeviceState(\.name) 27 | var name 28 | @DeviceState(\.state) 29 | var state 30 | @DeviceState(\.rssi) 31 | var rssi 32 | 33 | @DeviceAction(\.connect) 34 | var connect 35 | @DeviceAction(\.disconnect) 36 | var disconnect 37 | 38 | @Service var deviceInformation = DeviceInformationService() 39 | @Service var testService = TestService() 40 | 41 | 42 | let testState = State() 43 | private(set) var passedRetainCountCheck = false 44 | 45 | required init() {} 46 | 47 | func configure() { 48 | let count = CFGetRetainCount(self) 49 | 50 | deviceInformation.$modelNumber.onChange(initial: true) { @MainActor [weak self] _ in 51 | self?.testState.didReceiveModel = true 52 | } 53 | deviceInformation.$manufacturerName.onChange { @MainActor [weak self] _ in 54 | self?.testState.didReceiveManufacturer = true // this should never be called 55 | } 56 | $state.onChange { state in // test DeviceState code path as well, even if its just logging! 57 | print("State is now \(state)") 58 | } 59 | 60 | let newCount = CFGetRetainCount(self) 61 | if count == newCount { 62 | passedRetainCountCheck = true 63 | } else { 64 | print("Failed retain count check, was \(count) now is \(newCount)") 65 | } 66 | } 67 | 68 | 69 | func connect() async throws { 70 | try await self.connect() 71 | } 72 | 73 | func disconnect() async { 74 | await self.disconnect() 75 | } 76 | } 77 | 78 | 79 | extension BluetoothPeripheral: SomePeripheral {} 80 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateTestInjections.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import Foundation 10 | 11 | 12 | @Observable 13 | final class DeviceStateTestInjections: Sendable { 14 | @ObservationIgnored private nonisolated(unsafe) var _subscriptions: ChangeSubscriptions? 15 | private let _injectedValue: MainActorBuffered = .init(nil) 16 | private let lock = NSLock() // protects both properties above 17 | 18 | var subscriptions: ChangeSubscriptions? { 19 | get { 20 | lock.withLock { 21 | _subscriptions 22 | } 23 | } 24 | set { 25 | lock.withLock { 26 | _subscriptions = newValue 27 | } 28 | } 29 | } 30 | 31 | var injectedValue: Value? { 32 | get { 33 | access(keyPath: \.injectedValue) 34 | return _injectedValue.load(using: lock) 35 | } 36 | set { 37 | _injectedValue.store(newValue, using: lock) { @Sendable mutation in 38 | self.withMutation(keyPath: \.injectedValue, mutation) 39 | } 40 | } 41 | } 42 | 43 | static func artificialValue(for keyPath: KeyPath) -> Value? { 44 | let value: Any? = switch keyPath { 45 | case \.id: 46 | nil // we cannot provide a stable id? 47 | case \.name: 48 | Optional.none as Any 49 | case \.state: 50 | PeripheralState.disconnected 51 | case \.advertisementData: 52 | AdvertisementData([:]) 53 | case \.rssi: 54 | Int(UInt8.max) 55 | case \.nearby: 56 | false 57 | case \.lastActivity: 58 | Date.now 59 | default: 60 | nil 61 | } 62 | 63 | guard let value else { 64 | return nil 65 | } 66 | 67 | guard let value = value as? Value else { 68 | preconditionFailure("Default value \(value) was not the expected type for \(keyPath)") 69 | } 70 | return value 71 | } 72 | 73 | func enableSubscriptions() { 74 | subscriptions = ChangeSubscriptions() 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Environment/SurroundingScanModifiers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import SwiftUI 10 | 11 | 12 | @Observable 13 | final class SurroundingScanModifiers: EnvironmentKey, Sendable { 14 | static let defaultValue = SurroundingScanModifiers() 15 | 16 | @MainActor private var registeredModifiers: [AnyHashable: [UUID: any BluetoothScanningState]] = [:] 17 | 18 | init() {} 19 | 20 | @MainActor 21 | func setModifierScanningState(enabled: Bool, with scanner: Scanner, modifierId: UUID, state: Scanner.ScanningState) { 22 | if enabled { 23 | registeredModifiers[AnyHashable(scanner.id), default: [:]] 24 | .updateValue(state, forKey: modifierId) 25 | } else { 26 | registeredModifiers[AnyHashable(scanner.id), default: [:]] 27 | .removeValue(forKey: modifierId) 28 | 29 | if registeredModifiers[AnyHashable(scanner.id)]?.isEmpty == true { 30 | registeredModifiers[AnyHashable(scanner.id)] = nil 31 | } 32 | } 33 | } 34 | 35 | @MainActor 36 | func retrieveReducedScanningState(for scanner: Scanner) -> Scanner.ScanningState? { 37 | guard let entries = registeredModifiers[AnyHashable(scanner.id)] else { 38 | return nil 39 | } 40 | 41 | return entries.values 42 | .compactMap { anyState in 43 | anyState as? Scanner.ScanningState 44 | } 45 | .reduce(nil) { partialResult, state in 46 | guard let partialResult else { 47 | return state 48 | } 49 | return partialResult.merging(with: state) 50 | } 51 | } 52 | 53 | @MainActor 54 | func hasPersistentInterest(for scanner: Scanner) -> Bool { 55 | guard let ids = registeredModifiers[AnyHashable(scanner.id)] else { 56 | return false 57 | } 58 | return !ids.isEmpty 59 | } 60 | } 61 | 62 | 63 | extension EnvironmentValues { 64 | var surroundingScanModifiers: SurroundingScanModifiers { 65 | get { 66 | self[SurroundingScanModifiers.self] 67 | } 68 | set { 69 | self[SurroundingScanModifiers.self] = newValue 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Modifier/BluetoothScanningOptionsModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | 10 | import SwiftUI 11 | 12 | 13 | private struct BluetoothScanningOptionsModifier: ViewModifier { 14 | private let minimumRSSI: Int? 15 | private let advertisementStaleInterval: TimeInterval? 16 | 17 | @Environment(\.minimumRSSI) 18 | private var parentMinimumRSSI 19 | @Environment(\.advertisementStaleInterval) 20 | private var parentAdvertisementStaleInterval 21 | 22 | init(minimumRSSI: Int?, advertisementStaleInterval: TimeInterval?) { 23 | self.minimumRSSI = minimumRSSI 24 | self.advertisementStaleInterval = advertisementStaleInterval 25 | } 26 | 27 | 28 | func body(content: Content) -> some View { 29 | content 30 | .environment(\.minimumRSSI, minimumRSSI ?? parentMinimumRSSI) 31 | .environment(\.advertisementStaleInterval, advertisementStaleInterval ?? parentAdvertisementStaleInterval) 32 | } 33 | } 34 | 35 | 36 | extension View { 37 | /// Define bluetooth scanning options in the view hierarchy. 38 | /// 39 | /// This view modifier can be used to set scanning options for the view hierarchy. 40 | /// This will overwrite values passed to modifiers like 41 | /// ``SwiftUICore/View/scanNearbyDevices(enabled:with:discovery:minimumRSSI:advertisementStaleInterval:autoConnect:)``. 42 | /// 43 | /// ## Topics 44 | /// ### Accessing Scanning Options 45 | /// - ``SwiftUICore/EnvironmentValues/minimumRSSI`` 46 | /// - ``SwiftUICore/EnvironmentValues/advertisementStaleInterval`` 47 | /// 48 | /// - Parameters: 49 | /// - minimumRSSI: The minimum rssi a nearby peripheral must have to be considered nearby. Supply `nil` to use default the default value or a value from the environment. 50 | /// - advertisementStaleInterval: The time interval after which a peripheral advertisement is considered stale 51 | /// if we don't hear back from the device. Minimum is 1 second. Supply `nil` to use default the default value or a value from the environment. 52 | public func bluetoothScanningOptions(minimumRSSI: Int? = nil, advertisementStaleInterval: TimeInterval? = nil) -> some View { 53 | modifier(BluetoothScanningOptionsModifier(minimumRSSI: minimumRSSI, advertisementStaleInterval: advertisementStaleInterval)) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Modifier/ConnectedDevicesEnvironmentModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import SwiftUI 10 | 11 | 12 | private struct ConnectedDeviceEnvironmentModifier: ViewModifier { 13 | @Environment(ConnectedDevicesModel.self) 14 | var connectedDevices 15 | 16 | @State private var devicesList = ConnectedDevices() 17 | 18 | init() {} 19 | 20 | 21 | func body(content: Content) -> some View { 22 | let connectedDeviceAny = connectedDevices[ObjectIdentifier(Device.self)] 23 | let firstConnectedDevice = connectedDeviceAny.first as? Device 24 | let connectedDevicesList = connectedDeviceAny.compactMap { device in 25 | device as? Device 26 | } 27 | 28 | content 29 | .environment(firstConnectedDevice) 30 | .environment(ConnectedDevices(connectedDevicesList)) 31 | } 32 | } 33 | 34 | 35 | struct ConnectedDevicesEnvironmentModifier: ViewModifier { 36 | private let configuredDeviceTypes: [any BluetoothDevice.Type] 37 | 38 | @Environment(ConnectedDevicesModel.self) 39 | var connectedDevices 40 | 41 | 42 | nonisolated init(configuredDeviceTypes: [any BluetoothDevice.Type]) { 43 | self.configuredDeviceTypes = configuredDeviceTypes 44 | } 45 | 46 | nonisolated init(from configuration: Set) { 47 | self.init(configuredDeviceTypes: configuration.deviceTypes) 48 | } 49 | 50 | 51 | func body(content: Content) -> some View { 52 | let modifiers = configuredDeviceTypes.map { $0.deviceEnvironmentModifier } 53 | 54 | modifiers.modify(content) 55 | } 56 | } 57 | 58 | 59 | extension BluetoothDevice { 60 | @MainActor fileprivate static var deviceEnvironmentModifier: any ViewModifier { 61 | ConnectedDeviceEnvironmentModifier() 62 | } 63 | } 64 | 65 | 66 | extension Array where Element == any ViewModifier { 67 | @MainActor 68 | fileprivate func modify(_ view: V) -> AnyView { 69 | var view = AnyView(view) 70 | for modifier in self { 71 | view = modifier.modify(view) 72 | } 73 | return view 74 | } 75 | } 76 | 77 | 78 | extension ViewModifier { 79 | fileprivate func modify(_ view: AnyView) -> AnyView { 80 | AnyView(view.modifier(self)) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicTestInjections.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import Foundation 10 | 11 | 12 | final class CharacteristicTestInjections: Sendable { 13 | private nonisolated(unsafe) var _writeClosure: ((Value, WriteType) async throws -> Void)? 14 | private nonisolated(unsafe) var _readClosure: (() async throws -> Value)? 15 | private nonisolated(unsafe) var _requestClosure: ((Value) async throws -> Value)? 16 | private nonisolated(unsafe) var _subscriptions: ChangeSubscriptions? 17 | private nonisolated(unsafe) var _simulatePeripheral = false 18 | private let lock = NSLock() 19 | 20 | var writeClosure: ((Value, WriteType) async throws -> Void)? { 21 | get { 22 | lock.withLock { 23 | _writeClosure 24 | } 25 | } 26 | set { 27 | lock.withLock { 28 | _writeClosure = newValue 29 | } 30 | } 31 | } 32 | 33 | var readClosure: (() async throws -> Value)? { 34 | get { 35 | lock.withLock { 36 | _readClosure 37 | } 38 | } 39 | set { 40 | lock.withLock { 41 | _readClosure = newValue 42 | } 43 | } 44 | } 45 | 46 | var requestClosure: ((Value) async throws -> Value)? { 47 | get { 48 | lock.withLock { 49 | _requestClosure 50 | } 51 | } 52 | set { 53 | lock.withLock { 54 | _requestClosure = newValue 55 | } 56 | } 57 | } 58 | 59 | var subscriptions: ChangeSubscriptions? { 60 | get { 61 | lock.withLock { 62 | _subscriptions 63 | } 64 | } 65 | set { 66 | lock.withLock { 67 | _subscriptions = newValue 68 | } 69 | } 70 | } 71 | 72 | var simulatePeripheral: Bool { 73 | get { 74 | lock.withLock { 75 | _simulatePeripheral 76 | } 77 | } 78 | set { 79 | lock.withLock { 80 | _simulatePeripheral = newValue 81 | } 82 | } 83 | } 84 | 85 | init() {} 86 | 87 | func enableSubscriptions() { 88 | subscriptions = ChangeSubscriptions() 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessOperand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import ByteCoding 10 | import NIOCore 11 | 12 | 13 | /// The operand format. 14 | /// 15 | /// The operand defines the content of a operation in combination with the ``RecordAccessOpCode`` and the ``RecordAccessOperator``. 16 | /// - Note: The format of the operand might differ depending on the op code and operator used. 17 | /// Therefore, a typical implementation is done using a enum with associated values. 18 | /// 19 | /// The format of a operand is defined by the Service specification using the ``RecordAccessControlPoint`` characteristic. 20 | /// 21 | /// Refer to GATT Specification Supplement, 3.178.3 Operand field. 22 | public protocol RecordAccessOperand: ByteEncodable, Sendable { 23 | /// General Response representation. 24 | /// 25 | /// The operand format with the code ``RecordAccessOpCode/responseCode`` contains at least the information modeled with 26 | /// ``RecordAccessGeneralResponse``. This property returns this information in the format of a ``RecordAccessGeneralResponse`` type 27 | /// if the operand is modeling the content of a response with the code ``RecordAccessOpCode/responseCode``. 28 | /// 29 | /// - Note: This property is optional to implement and returns `nil` by default. 30 | var generalResponse: RecordAccessGeneralResponse? { get } 31 | 32 | /// Decode a operand form the `ByteBuffer`. 33 | /// 34 | /// Initialize a new instance using the byte representation provided by the `ByteBuffer`. 35 | /// This call should move the `readerIndex` forwards. 36 | /// 37 | /// The ``RecordAccessOpCode`` and ``RecordAccessOperator`` might be relevant to decide the byte representation of the operand. 38 | /// 39 | /// - Parameters: 40 | /// - byteBuffer: The ByteBuffer to read from. 41 | /// - opCode: The opcode of the ``RecordAccessControlPoint`` this operand is being decoded for. 42 | /// - operator: The operator of the ``RecordAccessControlPoint`` this operand is being decoded for. 43 | init?( 44 | from byteBuffer: inout ByteBuffer, 45 | opCode: RecordAccessOpCode, 46 | `operator`: RecordAccessOperator 47 | ) 48 | } 49 | 50 | 51 | extension RecordAccessOperand { 52 | /// Default implementation returning nil. 53 | public var generalResponse: RecordAccessGeneralResponse? { 54 | nil 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/SpeziBluetoothServices/Characteristics/Time/DayOfWeek.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import ByteCoding 10 | import Foundation 11 | import NIOCore 12 | 13 | 14 | /// The day of week. 15 | /// 16 | /// Specifies the day within a seven-day week as specified in IOS 8601. 17 | /// 18 | /// Refer to GATT Specification Supplement, 3.73 Day of Week. 19 | public struct DayOfWeek { 20 | /// Unknown day of week. 21 | public static let unknown = DayOfWeek(rawValue: 0) 22 | /// Monday. 23 | public static let monday = DayOfWeek(rawValue: 1) 24 | /// Tuesday. 25 | public static let tuesday = DayOfWeek(rawValue: 2) 26 | /// Wednesday. 27 | public static let wednesday = DayOfWeek(rawValue: 3) 28 | /// Thursday. 29 | public static let thursday = DayOfWeek(rawValue: 4) 30 | /// Friday. 31 | public static let friday = DayOfWeek(rawValue: 5) 32 | /// Saturday. 33 | public static let saturday = DayOfWeek(rawValue: 6) 34 | /// Sunday. 35 | public static let sunday = DayOfWeek(rawValue: 7) 36 | 37 | 38 | /// The raw value. 39 | public let rawValue: UInt8 40 | 41 | 42 | /// Initialize using a raw value day of week. 43 | /// - Parameter rawValue: The day of week. 44 | public init(rawValue: UInt8) { 45 | self.rawValue = rawValue 46 | } 47 | } 48 | 49 | 50 | extension DayOfWeek: RawRepresentable {} 51 | 52 | 53 | extension DayOfWeek: Hashable, Sendable {} 54 | 55 | 56 | extension DayOfWeek: CustomStringConvertible { 57 | public var description: String { 58 | switch self { 59 | case .unknown: 60 | "unknown" 61 | case .monday: 62 | "monday" 63 | case .tuesday: 64 | "tuesday" 65 | case .wednesday: 66 | "wednesday" 67 | case .thursday: 68 | "thursday" 69 | case .friday: 70 | "friday" 71 | case .saturday: 72 | "saturday" 73 | case .sunday: 74 | "sunday" 75 | default: 76 | "\(Self.self)(rawValue: \(rawValue))" 77 | } 78 | } 79 | } 80 | 81 | 82 | extension DayOfWeek: ByteCodable { 83 | public init?(from byteBuffer: inout ByteBuffer) { 84 | guard let rawValue = UInt8(from: &byteBuffer) else { 85 | return nil 86 | } 87 | self.init(rawValue: rawValue) 88 | } 89 | 90 | public func encode(to byteBuffer: inout ByteBuffer) { 91 | rawValue.encode(to: &byteBuffer) 92 | } 93 | } 94 | 95 | 96 | extension DayOfWeek: Codable {} 97 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Configuration/Apperance/DeviceAppearance.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import SpeziViews 10 | 11 | 12 | /// Describes the appearances and variants of a device. 13 | public enum DeviceAppearance { 14 | /// The appearance for the device. 15 | case appearance(Appearance) 16 | /// The device represents multiple different device variants that have different appearances. 17 | /// 18 | /// The `variants` describe how to identify each variant and its appearance. 19 | /// The `defaultAppearance` appearance is used if the the variant cannot be determined. 20 | case variants(defaultAppearance: Appearance, variants: [Variant]) 21 | } 22 | 23 | 24 | extension DeviceAppearance: Hashable, Sendable {} 25 | 26 | 27 | extension DeviceAppearance { 28 | /// Retrieve the appearance for a device. 29 | /// - Parameter variantPredicate: If the device has different variants, this predicate will be used to match the desired variant. 30 | /// - Returns: Returns the device `appearance` and optionally the `variantId`, if the appearance of a variant was returned. 31 | public func appearance(where variantPredicate: (Variant) -> Bool) -> (appearance: Appearance, variantId: String?) { 32 | switch self { 33 | case let .appearance(appearance): 34 | (appearance, nil) 35 | case let .variants(defaultAppearance, variants): 36 | if let variant = variants.first(where: variantPredicate) { 37 | (Appearance(name: variant.name, icon: variant.icon), variant.id) 38 | } else { 39 | (defaultAppearance, nil) 40 | } 41 | } 42 | } 43 | 44 | /// Retrieve the icon appearance of a device. 45 | /// - Parameter variantId: The optional variant id to query. This id will be used to selected the device variant, if the device declares different variant appearances. 46 | /// - Returns: Returns the device icon. 47 | public func deviceIcon(variantId: String?) -> ImageReference { 48 | appearance { variant in 49 | variant.id == variantId 50 | }.appearance.icon 51 | } 52 | 53 | /// Retrieve the name of a device. 54 | /// - Parameter variantId: The optional variant id to query. This id will be used to selected the device variant, if the device declares different variant appearances. 55 | /// - Returns: Returns the device name. 56 | public func deviceName(variantId: String?) -> String { 57 | appearance { variant in 58 | variant.id == variantId 59 | }.appearance.name 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/SpeziBluetoothServices/Services/DeviceInformationService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import Foundation 10 | import SpeziBluetooth 11 | 12 | 13 | /// Bluetooth Device Information Service implementation. 14 | /// 15 | /// This class implements the Bluetooth [Device Information Service 1.1.](https://www.bluetooth.com/specifications/specs/device-information-service-1-1/). 16 | /// All characteristics are read-only and optional to implement. 17 | /// It is possible that none are implemented at all. 18 | /// For more information refer to the specification. 19 | public struct DeviceInformationService: BluetoothService, Sendable { 20 | public static let id: BTUUID = "180A" 21 | 22 | /// The manufacturer name string. 23 | @Characteristic(id: "2A29") 24 | public var manufacturerName: String? 25 | /// The model number string. 26 | @Characteristic(id: "2A24") 27 | public var modelNumber: String? 28 | /// The serial number string. 29 | @Characteristic(id: "2A25") 30 | public var serialNumber: String? 31 | 32 | /// The hardware revision string. 33 | @Characteristic(id: "2A27") 34 | public var hardwareRevision: String? 35 | /// The firmware revision string. 36 | @Characteristic(id: "2A26") 37 | public var firmwareRevision: String? 38 | /// The software revision string. 39 | @Characteristic(id: "2A28") 40 | public var softwareRevision: String? 41 | 42 | /// Represents the extended unique identifier (EUI) of the system. 43 | /// 44 | /// This 64-bit structure is an EUI-64 which consists of an Organizationally Unique Identifier (OUI) 45 | /// concatenated with a manufacturer-defined identifier. The OUI is issued by the IEEE Registration Authority. 46 | @Characteristic(id: "2A23") 47 | public var systemID: UInt64? 48 | /// Represents regulatory and certification information for the product in a list defined in IEEE 11073-20601. 49 | /// 50 | /// The content of this characteristic is determined by the authorizing organization that provides certifications. 51 | @Characteristic(id: "2A2A") 52 | public var regulatoryCertificationDataList: Data? 53 | /// A set of values that shall be used to create a device ID value that is unique for this device. 54 | /// 55 | /// Included in the characteristic are a Vendor ID source field, a Vendor ID field, a Product ID field, and a Product Version field. 56 | /// These values are used to identify all devices of a given type/model/version using numbers. 57 | @Characteristic(id: "2A50") 58 | public var pnpID: PnPID? 59 | 60 | 61 | public init() {} 62 | } 63 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/CoreBluetooth/Utilities/ValueObservable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import Foundation 10 | 11 | 12 | protocol AnyValueObservation {} 13 | 14 | 15 | /// Internal value observation registrar. 16 | /// 17 | /// Holds the registered closure till the next value update happens. 18 | /// Inspired by Apple's Observation framework but with more power! 19 | final class ValueObservationRegistrar: Sendable { 20 | struct ValueObservation: AnyValueObservation { 21 | let keyPath: KeyPath 22 | let handler: (Value) -> Void 23 | } 24 | 25 | @SpeziBluetooth private var id: UInt64 = 0 26 | @SpeziBluetooth private var observations: [UInt64: AnyValueObservation] = [:] 27 | @SpeziBluetooth private var keyPathIndex: [AnyKeyPath: Set] = [:] 28 | 29 | init() {} 30 | 31 | @SpeziBluetooth 32 | private func nextId() -> UInt64 { 33 | defer { 34 | id &+= 1 // add with overflow operator 35 | } 36 | return id 37 | } 38 | 39 | @SpeziBluetooth 40 | func onChange(of keyPath: KeyPath, perform closure: @escaping (Value) -> Void) { 41 | let id = nextId() 42 | observations[id] = ValueObservation(keyPath: keyPath, handler: closure) 43 | keyPathIndex[keyPath, default: []].insert(id) 44 | } 45 | 46 | @SpeziBluetooth 47 | func triggerDidChange(for keyPath: KeyPath, on observable: Observable) { 48 | guard let ids = keyPathIndex.removeValue(forKey: keyPath) else { 49 | return 50 | } 51 | 52 | for id in ids { 53 | guard let anyObservation = observations.removeValue(forKey: id), 54 | let observation = anyObservation as? ValueObservation else { 55 | continue 56 | } 57 | 58 | let value = observable[keyPath: keyPath] 59 | observation.handler(value) 60 | } 61 | } 62 | } 63 | 64 | 65 | /// A model with value observable properties. 66 | protocol ValueObservable: AnyObject, Sendable { 67 | // swiftlint:disable:next identifier_name 68 | var _$simpleRegistrar: ValueObservationRegistrar { get } 69 | 70 | @SpeziBluetooth 71 | func onChange(of keyPath: KeyPath, perform closure: @escaping (Value) -> Void) 72 | } 73 | 74 | 75 | extension ValueObservable { 76 | @SpeziBluetooth 77 | func onChange(of keyPath: KeyPath, perform closure: @escaping (Value) -> Void) { 78 | _$simpleRegistrar.onChange(of: keyPath, perform: closure) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Tests/SpeziBluetoothServicesTests/HealthThermometerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import ByteCodingTesting 10 | import CoreBluetooth 11 | import NIOCore 12 | @_spi(TestingSupport) 13 | @testable import SpeziBluetooth 14 | @_spi(TestingSupport) 15 | @testable import SpeziBluetoothServices 16 | import Testing 17 | 18 | 19 | @Suite("HealthThermometer Service") 20 | struct HealthThermometerTests { 21 | @Test("MeasurementInterval") 22 | func testMeasurementInterval() throws { 23 | try testIdentity(from: MeasurementInterval.noPeriodicMeasurement) 24 | try testIdentity(from: MeasurementInterval.duration(24)) 25 | } 26 | 27 | @Test("TemperatureMeasurement") 28 | func testTemperatureMeasurement() throws { 29 | let data: UInt32 = 0xAFAFAFAF // 4 bytes for the medfloat 30 | let time = DateTime(hours: 13, minutes: 12, seconds: 12) 31 | 32 | try testIdentity(from: TemperatureMeasurement(temperature: data, unit: .celsius)) 33 | try testIdentity(from: TemperatureMeasurement(temperature: data, unit: .fahrenheit)) 34 | 35 | try testIdentity(from: TemperatureMeasurement(temperature: data, unit: .celsius, timeStamp: time, temperatureType: .ear)) 36 | try testIdentity(from: TemperatureMeasurement(temperature: data, unit: .celsius, temperatureType: .ear)) 37 | try testIdentity(from: TemperatureMeasurement(temperature: data, unit: .celsius, timeStamp: time)) 38 | } 39 | 40 | @Test("TemperatureType") 41 | func testTemperatureType() throws { 42 | try testIdentity(from: TemperatureType.reserved) 43 | try testIdentity(from: TemperatureType.armpit) 44 | try testIdentity(from: TemperatureType.body) 45 | try testIdentity(from: TemperatureType.ear) 46 | try testIdentity(from: TemperatureType.finger) 47 | try testIdentity(from: TemperatureType.gastrointestinalTract) 48 | try testIdentity(from: TemperatureType.mouth) 49 | try testIdentity(from: TemperatureType.rectum) 50 | try testIdentity(from: TemperatureType.toe) 51 | try testIdentity(from: TemperatureType.tympanum) 52 | } 53 | 54 | @Test("TemperatureType Description") 55 | func testTemperatureTypeStrings() { 56 | // swiftlint:disable line_length 57 | let expected = ["reserved", "armpit", "body", "ear", "finger", "gastrointestinalTract", "mouth", "rectum", "toe", "tympanum", "TemperatureType(rawValue: 23)"] 58 | let values = [TemperatureType.reserved, .armpit, .body, .ear, .finger, .gastrointestinalTract, .mouth, .rectum, .toe, .tympanum, .init(rawValue: 23)] 59 | // swiftlint:enable line_length 60 | #expect(values.map { $0.description } == expected) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import OSLog 10 | 11 | 12 | /// The description for a certain type of device. 13 | /// 14 | /// Describes what services we expect to be present for a certain type of device. 15 | /// The ``BluetoothManager`` uses that to determine what devices to discover and what services and characteristics to expect. 16 | public struct DeviceDescription { 17 | /// The set of service configurations we expect from the device. 18 | /// 19 | /// This will be the list of services we are interested in and we try to discover. 20 | /// - Note: If `nil`, we discover all services on a device. 21 | public var services: Set? { // swiftlint:disable:this discouraged_optional_collection 22 | let values: Dictionary.Values? = _services?.values 23 | return values.map { Set($0) } 24 | } 25 | 26 | private let _services: [BTUUID: ServiceDescription]? // swiftlint:disable:this discouraged_optional_collection 27 | 28 | /// Create a new device description. 29 | /// - Parameter services: The set of service descriptions specifying the expected services. 30 | public init(services: Set? = nil) { 31 | // swiftlint:disable:previous discouraged_optional_collection 32 | self._services = services?.reduce(into: [:]) { partialResult, description in 33 | partialResult[description.serviceId] = description 34 | } 35 | } 36 | 37 | 38 | /// Retrieve the service description for a given service id. 39 | /// - Parameter serviceId: The Bluetooth service id. 40 | /// - Returns: Returns the service description if present. 41 | public func description(for serviceId: BTUUID) -> ServiceDescription? { 42 | _services?[serviceId] 43 | } 44 | } 45 | 46 | 47 | extension DeviceDescription: Sendable {} 48 | 49 | 50 | extension DeviceDescription: Hashable {} 51 | 52 | 53 | extension Collection where Element: Identifiable, Element.ID == DiscoveryCriteria { 54 | func find(name: String?, advertisementData: AdvertisementData, logger: Logger) -> Element? { 55 | let configurations = filter { configuration in 56 | configuration.id.matches(name: name, advertisementData: advertisementData) 57 | } 58 | 59 | if configurations.count > 1 { 60 | logger.warning( 61 | """ 62 | Found ambiguous discovery configuration for peripheral. Using for of all matched criteria: \ 63 | \(configurations.map { $0.id.description }.joined(separator: ", ")) 64 | """ 65 | ) 66 | } 67 | 68 | return configurations.first 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/CoreBluetooth/Model/ManagedAtomicMainActorBuffered.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import Atomics 10 | import Foundation 11 | 12 | 13 | final class ManagedAtomicMainActorBuffered: Sendable where Value.AtomicRepresentation.Value == Value { 14 | private let managedValue: ManagedAtomic 15 | @MainActor private var mainActorValue: Value? 16 | 17 | init(_ value: Value) { 18 | self.managedValue = ManagedAtomic(value) 19 | self.mainActorValue = value 20 | } 21 | 22 | @_semantics("atomics.requires_constant_orderings") 23 | @inlinable 24 | func load(ordering: AtomicLoadOrdering = .relaxed) -> Value { 25 | if Thread.isMainThread { 26 | MainActor.assumeIsolated { 27 | mainActorValue 28 | } ?? managedValue.load(ordering: ordering) 29 | } else { 30 | managedValue.load(ordering: ordering) 31 | } 32 | } 33 | 34 | @_semantics("atomics.requires_constant_orderings") 35 | private func mutateMainActorBuffer( 36 | _ newValue: Value, 37 | mutation: sending @MainActor @escaping (@MainActor () -> Void) -> Void 38 | ) { 39 | if Thread.isMainThread { 40 | MainActor.assumeIsolated { 41 | let valueMutation = { @MainActor in 42 | self.mainActorValue = newValue 43 | } 44 | mutation(valueMutation) 45 | } 46 | } else { 47 | Task { @MainActor in 48 | let valueMutation = { @MainActor in 49 | self.mainActorValue = newValue 50 | } 51 | 52 | mutation(valueMutation) 53 | } 54 | } 55 | } 56 | 57 | @_semantics("atomics.requires_constant_orderings") 58 | @inlinable 59 | func store( 60 | _ newValue: Value, 61 | ordering: AtomicStoreOrdering = .relaxed, 62 | mutation: sending @MainActor @escaping (@MainActor () -> Void) -> Void 63 | ) { 64 | managedValue.store(newValue, ordering: ordering) 65 | mutateMainActorBuffer(newValue, mutation: mutation) 66 | } 67 | } 68 | 69 | 70 | extension ManagedAtomicMainActorBuffered where Value: Equatable { 71 | @_semantics("atomics.requires_constant_orderings") 72 | @inlinable 73 | func storeAndCompare( 74 | _ newValue: Value, 75 | ordering: AtomicUpdateOrdering = .relaxed, 76 | mutation: sending @MainActor @escaping (@MainActor () -> Void) -> Void 77 | ) -> Bool { 78 | let previousValue = managedValue.exchange(newValue, ordering: ordering) 79 | mutateMainActorBuffer(newValue, mutation: mutation) 80 | 81 | return previousValue != newValue 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/CoreBluetooth/Model/MainActorBuffered.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import Foundation 10 | import SpeziFoundation 11 | 12 | 13 | final class MainActorBuffered: Sendable { 14 | private nonisolated(unsafe) var unsafeValue: Value 15 | @MainActor private(set) var mainActorValue: Value? 16 | 17 | init(_ value: Value) { 18 | self.unsafeValue = value 19 | self.mainActorValue = value 20 | } 21 | 22 | func loadUnsafe() -> Value { 23 | loadIfMainActor() ?? unsafeValue 24 | } 25 | 26 | func load(using lock: NSLock) -> Value { 27 | loadIfMainActor() ?? lock.withLock { 28 | unsafeValue 29 | } 30 | } 31 | 32 | func load(using lock: RWLock) -> Value { 33 | loadIfMainActor() ?? lock.withReadLock { 34 | unsafeValue 35 | } 36 | } 37 | 38 | private func loadIfMainActor() -> Value? { 39 | if Thread.isMainThread { 40 | MainActor.assumeIsolated { 41 | mainActorValue 42 | } 43 | } else { 44 | nil 45 | } 46 | } 47 | 48 | private func _store(_ newValue: Value, mutation: sending @MainActor @escaping (@MainActor () -> Void) -> Void) { 49 | if Thread.isMainThread { 50 | MainActor.assumeIsolated { 51 | let valueMutation = { @MainActor in 52 | self.mainActorValue = newValue 53 | } 54 | mutation(valueMutation) 55 | } 56 | } else { 57 | let valueMutation = { @MainActor in 58 | self.mainActorValue = newValue 59 | } 60 | Task { @MainActor in 61 | mutation(valueMutation) 62 | } 63 | } 64 | } 65 | 66 | func store(_ newValue: Value, using lock: NSLock, mutation: sending @MainActor @escaping (@MainActor () -> Void) -> Void) { 67 | lock.withLock { 68 | unsafeValue = newValue 69 | } 70 | _store(newValue, mutation: mutation) 71 | } 72 | 73 | func store(_ newValue: Value, using lock: RWLock, mutation: sending @MainActor @escaping (@MainActor () -> Void) -> Void) { 74 | lock.withWriteLock { 75 | unsafeValue = newValue 76 | } 77 | _store(newValue, mutation: mutation) 78 | } 79 | } 80 | 81 | 82 | extension MainActorBuffered where Value: Equatable { 83 | func storeAndCompare(_ newValue: Value, using lock: RWLock, mutation: sending @MainActor @escaping (@MainActor () -> Void) -> Void) -> Bool { 84 | let didChange = lock.withWriteLock { 85 | let didChange = unsafeValue != newValue 86 | unsafeValue = newValue 87 | return didChange 88 | } 89 | _store(newValue, mutation: mutation) 90 | 91 | return didChange 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessOperator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import ByteCoding 10 | import NIOCore 11 | 12 | 13 | /// The operator applying to the operand. 14 | /// 15 | /// The operator applies semantics to the ``RecordAccessOperand``. 16 | /// - Note: The applicable values are specified by the respective Service specification. 17 | /// 18 | /// Refer to GATT Specification Supplement, 3.178.2 Operator field. 19 | public struct RecordAccessOperator { 20 | /// Null operator. 21 | public static let null = RecordAccessOperator(rawValue: 0x00) 22 | /// All records. 23 | /// 24 | /// Operation applies to all records (e.g., report all records). 25 | public static let allRecords = RecordAccessOperator(rawValue: 0x01) 26 | /// Less than or equal to a maximum value. 27 | /// 28 | /// The maximum value is specified within the ``RecordAccessOperand`` format. 29 | /// - Note: The Operand might specify additional filtering semantics. 30 | public static let lessThanOrEqualTo = RecordAccessOperator(rawValue: 0x02) 31 | /// Greater than or equal to a minimum value. 32 | /// 33 | /// The minimum value is specified within the ``RecordAccessOperand`` format. 34 | /// - Note: The Operand might specify additional filtering semantics. 35 | public static let greaterThanOrEqual = RecordAccessOperator(rawValue: 0x03) 36 | /// Within a closed range of a value pair. 37 | /// 38 | /// The minimum and maximum values are specified within the ``RecordAccessOperand`` format. 39 | /// - Note: The Operand might specify additional filtering semantics. 40 | public static let withinInclusiveRangeOf = RecordAccessOperator(rawValue: 0x04) 41 | /// The first record. 42 | /// 43 | /// Returns the first record (e.g., oldest record). 44 | /// No operand is used. 45 | public static let firstRecord = RecordAccessOperator(rawValue: 0x05) 46 | /// The last record. 47 | /// 48 | /// Returns the last record (e.g., most recent record). 49 | /// No operand is used. 50 | public static let lastRecord = RecordAccessOperator(rawValue: 0x06) 51 | 52 | 53 | /// The raw value operator. 54 | public let rawValue: UInt8 55 | 56 | 57 | /// Initialize using a raw value operator. 58 | /// - Parameter rawValue: The operator. 59 | public init(rawValue: UInt8) { 60 | self.rawValue = rawValue 61 | } 62 | } 63 | 64 | 65 | extension RecordAccessOperator: RawRepresentable {} 66 | 67 | 68 | extension RecordAccessOperator: Hashable, Sendable {} 69 | 70 | 71 | extension RecordAccessOperator: ByteCodable { 72 | public init?(from byteBuffer: inout ByteBuffer) { 73 | guard let rawValue = UInt8(from: &byteBuffer) else { 74 | return nil 75 | } 76 | self.init(rawValue: rawValue) 77 | } 78 | 79 | public func encode(to byteBuffer: inout ByteBuffer) { 80 | rawValue.encode(to: &byteBuffer) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/SpeziBluetoothServices/Characteristics/TemperatureType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import ByteCoding 10 | import NIOCore 11 | 12 | 13 | /// The location of a temperature measurement. 14 | /// 15 | /// Refer to GATT Specification Supplement, 3.219 Temperature Type. 16 | public struct TemperatureType { 17 | /// Reserved for future use. 18 | public static let reserved = TemperatureType(rawValue: 0x00) 19 | /// Armpit. 20 | public static let armpit = TemperatureType(rawValue: 0x01) 21 | /// Body (general). 22 | public static let body = TemperatureType(rawValue: 0x02) 23 | /// Ear (usually earlobe). 24 | public static let ear = TemperatureType(rawValue: 0x03) 25 | /// Finger. 26 | public static let finger = TemperatureType(rawValue: 0x04) 27 | /// Gastrointestinal Tract. 28 | public static let gastrointestinalTract = TemperatureType(rawValue: 0x05) 29 | /// Mouth. 30 | public static let mouth = TemperatureType(rawValue: 0x06) 31 | /// Rectum. 32 | public static let rectum = TemperatureType(rawValue: 0x07) 33 | /// Toe. 34 | public static let toe = TemperatureType(rawValue: 0x08) 35 | /// Tympanum (ear drum). 36 | public static let tympanum = TemperatureType(rawValue: 0x09) 37 | 38 | /// The raw value. 39 | public let rawValue: UInt8 40 | 41 | /// Create temperature type from raw value. 42 | /// - Parameter rawValue: The raw value temperature type. 43 | public init(rawValue: UInt8) { 44 | self.rawValue = rawValue 45 | } 46 | } 47 | 48 | 49 | extension TemperatureType: RawRepresentable {} 50 | 51 | 52 | extension TemperatureType: Hashable, Sendable {} 53 | 54 | 55 | extension TemperatureType: CustomStringConvertible { 56 | public var description: String { 57 | switch self { 58 | case .reserved: 59 | "reserved" 60 | case .armpit: 61 | "armpit" 62 | case .body: 63 | "body" 64 | case .ear: 65 | "ear" 66 | case .finger: 67 | "finger" 68 | case .gastrointestinalTract: 69 | "gastrointestinalTract" 70 | case .mouth: 71 | "mouth" 72 | case .rectum: 73 | "rectum" 74 | case .toe: 75 | "toe" 76 | case .tympanum: 77 | "tympanum" 78 | default: 79 | "\(Self.self)(rawValue: \(rawValue))" 80 | } 81 | } 82 | } 83 | 84 | 85 | extension TemperatureType: ByteCodable { 86 | public init?(from byteBuffer: inout ByteBuffer) { 87 | guard let value = UInt8(from: &byteBuffer) else { 88 | return nil 89 | } 90 | 91 | self.init(rawValue: value) 92 | } 93 | 94 | public func encode(to byteBuffer: inout ByteBuffer) { 95 | rawValue.encode(to: &byteBuffer) 96 | } 97 | } 98 | 99 | 100 | extension TemperatureType: Codable {} 101 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Utils/BTUUID.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import CoreBluetooth 10 | 11 | 12 | /// A universally unique identifier, as defined by Bluetooth standards. 13 | /// 14 | /// The `BTUUID` type mirrors the functionality of the [`CBUUID`](https://developer.apple.com/documentation/corebluetooth/cbuuid) 15 | /// class of CoreBluetooth. However, `BTUUID` is [`Sendable`](https://developer.apple.com/documentation/swift/sendable). 16 | /// `CBUUID` is by its definition not `Sendable`. Not because of its implementation not-being thread-safe, but it being declared as an open class with the open properties 17 | /// ``uuidString`` and ``data``. By wrapping the `CBUUID` type, and making sure no-one can inject a non-thread-safe sub-class, we create a effectively sendable version 18 | /// of `CBUUID`. 19 | public struct BTUUID { 20 | /// The CoreBluetooth UUID. 21 | public nonisolated(unsafe) let cbuuid: CBUUID 22 | 23 | /// The UUID represented as a string. 24 | public var uuidString: String { 25 | cbuuid.uuidString 26 | } 27 | 28 | /// The data of the UUID. 29 | public var data: Data { 30 | cbuuid.data 31 | } 32 | 33 | 34 | /// Create a Bluetooth UUID from a 16-, 32-, or 128-bit UUID string. 35 | /// - Parameter string: A string containing a 16-, 32-, or 128-bit UUID. 36 | public init(string: String) { 37 | self.cbuuid = CBUUID(string: string) 38 | } 39 | 40 | /// Create a Bluetooth UUID from a 16-, 32-, or 128-bit UUID data container. 41 | /// - Parameter data: Data containing a 16-, 32-, or 128-bit UUID. 42 | public init(data: Data) { 43 | self.cbuuid = CBUUID(data: data) 44 | } 45 | 46 | /// Create a Bluetooth UUID from a UUID. 47 | /// - Parameter nsuuid: The uuid. 48 | public init(nsuuid: UUID) { 49 | self.cbuuid = CBUUID(nsuuid: nsuuid) 50 | } 51 | 52 | /// Create a Bluetooth UUID from a CoreBluetooth UUID. 53 | /// - Parameter uuid: The CoreBluetooth UUID. 54 | public init(from uuid: CBUUID) { 55 | self.cbuuid = CBUUID(data: uuid.data) // this makes sure we do not accidentally inject a subclass 56 | } 57 | } 58 | 59 | 60 | extension BTUUID: Sendable {} 61 | 62 | 63 | extension BTUUID: ExpressibleByStringLiteral { 64 | /// Create a Bluetooth UUID from a 16-, 32-, or 128-bit UUID string. 65 | /// - Parameter value: A string containing a 16-, 32-, or 128-bit UUID. 66 | public init(stringLiteral value: StringLiteralType) { 67 | self.init(string: value) 68 | } 69 | } 70 | 71 | 72 | extension BTUUID: Hashable {} 73 | 74 | 75 | extension BTUUID: CustomStringConvertible, CustomDebugStringConvertible { 76 | public var description: String { 77 | cbuuid.description 78 | } 79 | 80 | public var debugDescription: String { 81 | cbuuid.debugDescription 82 | } 83 | } 84 | 85 | 86 | extension CBUUID { 87 | convenience init(from btuuid: BTUUID) { 88 | self.init(data: btuuid.data) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Tests/UITests/TestApp/BluetoothModuleView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import SpeziBluetooth 10 | import SpeziViews 11 | import SwiftUI 12 | 13 | 14 | struct BluetoothModuleView: View { 15 | @Environment(Bluetooth.self) 16 | private var bluetooth 17 | @Environment(TestDevice.self) 18 | private var device: TestDevice? 19 | @Environment(ConnectedDevices.self) 20 | private var connectedDevices 21 | 22 | @Binding private var pairedDeviceId: UUID? 23 | 24 | var body: some View { 25 | List { // swiftlint:disable:this closure_body_length 26 | BluetoothStateSection(state: bluetooth.state, isScanning: bluetooth.isScanning) 27 | 28 | let nearbyDevices = bluetooth.nearbyDevices(for: TestDevice.self) 29 | 30 | Section { 31 | ForEach(nearbyDevices) { device in 32 | DeviceRowView(peripheral: device) 33 | } 34 | } header: { 35 | Text(verbatim: "Devices") 36 | } footer: { 37 | Text(verbatim: "This is a list of nearby test peripherals. Auto connect is enabled.") 38 | } 39 | 40 | if !connectedDevices.isEmpty { 41 | Section { 42 | ForEach(connectedDevices) { device in 43 | AsyncButton { 44 | pairedDeviceId = device.id 45 | await device.disconnect() 46 | } label: { 47 | VStack { 48 | Text(verbatim: "Pair \(type(of: device))") 49 | if let name = device.name { 50 | Text(name) 51 | .font(.caption2) 52 | .foregroundStyle(.secondary) 53 | } 54 | } 55 | } 56 | .accessibilityLabel("Pair \(type(of: device))") 57 | } 58 | } header: { 59 | Text("Connected Devices") 60 | } footer: { 61 | Text("This tests the retrieval of connected devices using ConnectedDevices.") 62 | } 63 | } 64 | 65 | if let device { 66 | NavigationLink("Test Interactions") { 67 | TestDeviceView(device: device) 68 | } 69 | } 70 | } 71 | .scanNearbyDevices(with: bluetooth, autoConnect: true) 72 | .navigationTitle("Nearby Devices") 73 | } 74 | 75 | 76 | init(pairedDeviceId: Binding) { 77 | self._pairedDeviceId = pairedDeviceId 78 | } 79 | } 80 | 81 | 82 | #Preview { 83 | NavigationStack { 84 | BluetoothModuleView(pairedDeviceId: .constant(nil)) 85 | .previewWith { 86 | Bluetooth { 87 | Discover(TestDevice.self, by: .advertisedService("FFF0")) 88 | } 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessControlPoint+Operations.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | 10 | extension RecordAccessControlPoint { 11 | /// Report stored records operation. 12 | /// 13 | /// Reports the requested set of stored records via notify of the respective measurement characteristic. 14 | /// - Note: The service specification specifies valid ``RecordAccessOperator`` and the ``RecordAccessOperand`` format. 15 | /// 16 | /// After record transmission completed, the control point responds with the ``RecordAccessOpCode/responseCode`` code. 17 | /// 18 | /// - Parameter content: The content of the operation. 19 | /// - Returns: The Record Access Control Point value. 20 | public static func reportStoredRecords(_ content: RecordAccessOperationContent) -> RecordAccessControlPoint { 21 | RecordAccessControlPoint(opCode: .reportStoredRecords, operator: content.operator, operand: content.operand) 22 | } 23 | 24 | /// Delete stored records. 25 | /// 26 | /// Delete the requested set of stored records. 27 | /// - Note: The service specification specifies valid ``RecordAccessOperator`` and the ``RecordAccessOperand`` format. 28 | /// 29 | /// After record transmission is completed, the control point responds with the ``RecordAccessOpCode/responseCode`` code . 30 | /// 31 | /// - Parameter content: The content of the operation. 32 | /// - Returns: The Record Access Control Point value. 33 | public static func deleteStoredRecords(_ content: RecordAccessOperationContent) -> RecordAccessControlPoint { 34 | RecordAccessControlPoint(opCode: .deleteStoredRecords, operator: content.operator, operand: content.operand) 35 | } 36 | 37 | /// Abort the current operation. 38 | /// 39 | /// The operator is ``RecordAccessOperator/null`` and no operand is used. 40 | /// 41 | /// The control point responds with the ``RecordAccessOpCode/responseCode`` code. 42 | /// 43 | /// - Returns: The Record Access Control Point value. 44 | public static func abort() -> RecordAccessControlPoint { 45 | RecordAccessControlPoint(opCode: .abortOperation, operator: .null) 46 | } 47 | 48 | /// Report the number of stored records. 49 | /// 50 | /// Reports the number of stored records on the peripheral. 51 | /// - Note: The service specification specifies valid ``RecordAccessOperator`` and the ``RecordAccessOperand`` format. 52 | /// 53 | /// The number of stored records is returned using ``RecordAccessOpCode/numberOfStoredRecordsResponse``. 54 | /// Erroneous conditions are returned using the ``RecordAccessOpCode/responseCode`` code. 55 | /// 56 | /// - Parameter content: The content of the operation. 57 | /// - Returns: The Record Access Control Point value. 58 | public static func reportNumberOfStoredRecords(_ content: RecordAccessOperationContent) -> RecordAccessControlPoint { 59 | RecordAccessControlPoint(opCode: .reportNumberOfStoredRecords, operator: content.operator, operand: content.operand) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yml: -------------------------------------------------------------------------------- 1 | # 2 | # This source file is part of the Stanford Spezi open source project 3 | # 4 | # SPDX-FileCopyrightText: 2025 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | # 6 | # SPDX-License-Identifier: MIT 7 | # 8 | 9 | name: Build and Test 10 | 11 | on: 12 | push: 13 | branches: 14 | - main 15 | pull_request: 16 | workflow_dispatch: 17 | 18 | concurrency: 19 | group: Build-and-Test-${{ github.ref }} 20 | cancel-in-progress: true 21 | 22 | jobs: 23 | package_tests: 24 | name: Build and Test Swift Package ${{ matrix.platform.name }} (${{ matrix.config }}) 25 | uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 26 | strategy: 27 | matrix: 28 | config: [Debug, Release] 29 | platform: 30 | - name: iOS 31 | destination: 'platform=iOS Simulator,name=iPhone 17 Pro' 32 | - name: macOS 33 | destination: 'platform=macOS,arch=arm64' 34 | - name: 'Mac Catalyst' 35 | destination: 'platform=macOS,arch=arm64,variant=Mac Catalyst' 36 | - name: watchOS 37 | destination: 'platform=watchOS Simulator,name=Apple Watch Series 11 (46mm)' 38 | - name: visionOS 39 | destination: 'platform=visionOS Simulator,name=Apple Vision Pro' 40 | - name: tvOS 41 | destination: 'platform=tvOS Simulator,name=Apple TV 4K (3rd generation)' 42 | fail-fast: false 43 | with: 44 | runsonlabels: '["macOS", "self-hosted", "spezi"]' 45 | scheme: SpeziBluetooth-Package 46 | destination: ${{ matrix.platform.destination }} 47 | buildConfig: ${{ matrix.config }} 48 | resultBundle: ${{ format('SpeziBluetooth-{0}-{1}.xcresult', matrix.platform.name, matrix.config) }} 49 | artifactname: ${{ format('SpeziBluetooth-{0}-{1}.xcresult', matrix.platform.name, matrix.config) }} 50 | ui_tests: 51 | name: Build and Test UI Tests ${{ matrix.platform.name }} (${{ matrix.config }}) 52 | uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 53 | strategy: 54 | matrix: 55 | config: [Debug, Release] 56 | platform: 57 | - name: macOS 58 | destination: 'platform=iOS Simulator,name=iPhone 17 Pro' 59 | fail-fast: false 60 | with: 61 | runsonlabels: '["macOS", "self-hosted", "bluetooth"]' 62 | path: 'Tests/UITests' 63 | setupsigning: true 64 | customcommand: "set -o pipefail && xcodebuild test -scheme 'TestApp' -configuration '${{ matrix.config }}' -destination 'platform=macOS,arch=arm64,variant=Mac Catalyst' -derivedDataPath '.derivedData' -resultBundlePath '${{ format('TestApp-{0}-{1}.xcresult', matrix.platform.name, matrix.config) }}' -skipPackagePluginValidation -skipMacroValidation | xcbeautify" 65 | resultBundle: ${{ format('TestApp-{0}-{1}.xcresult', matrix.platform.name, matrix.config) }} 66 | artifactname: ${{ format('TestApp-{0}-{1}.xcresult', matrix.platform.name, matrix.config) }} 67 | secrets: inherit 68 | uploadcoveragereport: 69 | name: Upload Coverage Report 70 | needs: [package_tests, ui_tests] 71 | uses: StanfordBDHG/.github/.github/workflows/create-and-upload-coverage-report.yml@v2 72 | with: 73 | coveragereports: SpeziBluetooth-*.xcresult TestApp-*.xcresult 74 | secrets: 75 | token: ${{ secrets.CODECOV_TOKEN }} 76 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import Foundation 10 | import OrderedCollections 11 | import SpeziFoundation 12 | 13 | 14 | final class ChangeSubscriptions: Sendable { 15 | private struct Registration: Sendable { 16 | let subscription: AsyncStream 17 | let id: UUID 18 | } 19 | 20 | private nonisolated(unsafe) var continuations: OrderedDictionary.Continuation> = [:] 21 | private let lock = RWLock() 22 | 23 | nonisolated init() {} 24 | 25 | func notifySubscribers(with value: Value, ignoring: Set = []) { 26 | lock.withReadLock { 27 | for (id, continuation) in continuations where !ignoring.contains(id) { 28 | continuation.yield(value) 29 | } 30 | } 31 | } 32 | 33 | func notifySubscriber(id: UUID, with value: Value) { 34 | lock.withReadLock { 35 | _ = continuations[id]?.yield(value) 36 | } 37 | } 38 | 39 | private nonisolated func _newSubscription() -> Registration { 40 | let id = UUID() 41 | let stream = AsyncStream { continuation in 42 | lock.withWriteLock { 43 | self.continuations[id] = continuation 44 | } 45 | 46 | continuation.onTermination = { [weak self] _ in 47 | guard let self else { 48 | return 49 | } 50 | 51 | self.lock.withWriteLock { 52 | _ = self.continuations.removeValue(forKey: id) 53 | } 54 | } 55 | } 56 | 57 | return Registration(subscription: stream, id: id) 58 | } 59 | 60 | nonisolated func newSubscription() -> AsyncStream { 61 | _newSubscription().subscription 62 | } 63 | 64 | @discardableResult 65 | nonisolated func newOnChangeSubscription( 66 | perform action: @escaping @Sendable (_ oldValue: Value, _ newValue: Value) async -> Void 67 | ) -> UUID { 68 | let registration = _newSubscription() 69 | 70 | // avoid accidentally inheriting any task local values 71 | Task.detached { @Sendable @SpeziBluetooth [weak self] in 72 | var currentValue: Value? 73 | 74 | for await element in registration.subscription { 75 | guard self != nil else { 76 | return 77 | } 78 | 79 | await action(currentValue ?? element, element) 80 | currentValue = element 81 | } 82 | } 83 | 84 | // There is no need to save this Task handle (makes it easier for use as we are in an non-isolated context right here). 85 | // The task will automatically cleanup itself, once it the AsyncStream is getting cancelled/finished. 86 | 87 | return registration.id 88 | } 89 | 90 | deinit { 91 | lock.withWriteLock { 92 | for continuation in continuations.values { 93 | continuation.finish() 94 | } 95 | 96 | continuations.removeAll() 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Model/BluetoothDevice.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import Observation 10 | import Spezi 11 | 12 | 13 | /// A Bluetooth device implementation. 14 | /// 15 | /// This protocol allows you to decoratively define your Bluetooth peripheral. 16 | /// Use the ``Service`` property wrapper to declare all services of your device. 17 | /// 18 | /// - Tip: You can use the ``DeviceState`` and ``DeviceAction`` property wrappers to retrieve device state 19 | /// or interact with your Bluetooth device. 20 | /// 21 | /// Below is a short code example of a device that implements a Device Information and Heart Rate service. 22 | /// 23 | /// ```swift 24 | /// class MyDevice: BluetoothDevice { 25 | /// @Service var deviceInformation = DeviceInformationService() 26 | /// @Service var heartRate = HeartRateService() 27 | /// 28 | /// init() {} 29 | /// } 30 | /// ``` 31 | /// 32 | /// ### Describing Device Appearance 33 | /// 34 | /// You can use the ``appearance`` property to customize the ``Appearance`` of your device and how UI components might present 35 | /// the device to the user. 36 | /// 37 | /// Your device might implement the logic for multiple device variants that might have a different appearance. Provide a ``DeviceAppearance`` to describe the appearance of your device 38 | /// 39 | /// ```swift 40 | /// final class MyBluetoothDevice: BluetoothDevice { 41 | /// static let appearance: DeviceAppearance = .variants(defaultAppearance: Appearance(name: "Weight Scale"), variants: [ 42 | /// Variant(id: "model-p1", name: "Weight Scale P1", icon: .asset("Model-P1"), criteria: .nameSubstring("WS-P1")), 43 | /// Variant(id: "model-x2", name: "Weight Scale X2", icon: .asset("Model-X2"), criteria: .nameSubstring("WS-X2")) 44 | /// ]) 45 | /// 46 | /// init() {} 47 | /// } 48 | /// ``` 49 | /// 50 | /// ## Topics 51 | /// ### Initializer 52 | /// - ``init()`` 53 | /// 54 | /// ### Appearance 55 | /// - ``appearance`` 56 | /// - ``DeviceAppearance`` 57 | /// - ``Appearance`` 58 | /// - ``Variant`` 59 | /// - ``DeviceVariantCriteria`` 60 | public protocol BluetoothDevice: AnyObject, Module, Observable, Sendable { 61 | /// Describes the visual appearance of the device. 62 | /// 63 | /// The device appearance can be used to visually present the device to the user. 64 | /// 65 | /// A device implementation might be used with multiple variants of a given device class (e.g., multiple models of a blood pressure cuff). 66 | /// You can provide additional variants using ``DeviceAppearance/variants(defaultAppearance:variants:)`` to describe the visual appearance of the different device variants. 67 | static var appearance: DeviceAppearance { get } 68 | 69 | /// Initializes the Bluetooth Device. 70 | /// 71 | /// This initializer is called automatically when a peripheral of this type connects. 72 | /// 73 | /// The initializer is called on the Bluetooth Task. 74 | /// 75 | /// - Note: This initializer is also called upon configuration to inspect the device structure. 76 | /// You might want to make sure to not perform any heavy processing within the initializer. 77 | init() 78 | } 79 | 80 | 81 | extension BluetoothDevice { 82 | /// Default device appearance that uses the type name as the name. 83 | public static var appearance: DeviceAppearance { 84 | .appearance(Appearance(name: "\(Self.self)")) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Tests/UITests/TestApp/RetrievePairedDevicesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import SpeziBluetooth 10 | import SpeziViews 11 | import SwiftUI 12 | 13 | 14 | struct RetrievePairedDevicesView: View { 15 | @Environment(Bluetooth.self) 16 | private var bluetooth 17 | 18 | @Binding private var pairedDeviceId: UUID? 19 | @Binding private var retrievedDevice: TestDevice? 20 | 21 | @State private var viewState: ViewState = .idle 22 | 23 | var body: some View { 24 | Group { 25 | if let pairedDeviceId { 26 | List { 27 | Section { 28 | ListRow("Device") { 29 | Text("Paired") 30 | } 31 | if let retrievedDevice { 32 | ListRow("State") { 33 | Text(retrievedDevice.state.description) 34 | } 35 | } 36 | 37 | deviceButtons(for: pairedDeviceId) 38 | } 39 | 40 | if let retrievedDevice, case .connected = retrievedDevice.state { 41 | DeviceInformationView(retrievedDevice) 42 | } 43 | } 44 | } else { 45 | ContentUnavailableView( 46 | "No Device Paired", 47 | systemImage: "sensor", 48 | description: Text("Select a connected device in the Test Peripheral view to pair.") 49 | ) 50 | } 51 | } 52 | .navigationTitle("Paired Device") 53 | } 54 | 55 | 56 | init(pairedDeviceId: Binding, retrievedDevice: Binding) { 57 | self._pairedDeviceId = pairedDeviceId 58 | self._retrievedDevice = retrievedDevice 59 | } 60 | 61 | 62 | @ViewBuilder 63 | @MainActor 64 | private func deviceButtons(for pairedDeviceId: UUID) -> some View { 65 | AsyncButton("Unpair Device") { 66 | await retrievedDevice?.disconnect() 67 | retrievedDevice = nil 68 | self.pairedDeviceId = nil 69 | } 70 | if let retrievedDevice { 71 | let state = retrievedDevice.state 72 | 73 | if state == .disconnected || state == .connecting { 74 | AsyncButton("Connect Device", state: $viewState) { 75 | try await retrievedDevice.connect() 76 | } 77 | } 78 | 79 | if state == .connecting || state == .connected || state == .disconnecting { 80 | AsyncButton("Disconnect Device") { 81 | await retrievedDevice.disconnect() 82 | } 83 | } 84 | } else { 85 | AsyncButton("Retrieve Device") { 86 | let bluetooth = bluetooth 87 | retrievedDevice = await bluetooth.retrieveDevice(for: pairedDeviceId) 88 | } 89 | } 90 | } 91 | } 92 | 93 | 94 | #Preview { 95 | NavigationStack { 96 | RetrievePairedDevicesView(pairedDeviceId: .constant(nil), retrievedDevice: .constant(nil)) 97 | .previewWith { 98 | Bluetooth { 99 | Discover(TestDevice.self, by: .advertisedService("FFF0")) 100 | } 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/AccessorySetupKit/DescriptorAspect+ASDiscoveryDescriptor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | #if canImport(AccessorySetupKit) && !os(macOS) 10 | import AccessorySetupKit 11 | import SpeziFoundation 12 | 13 | 14 | @available(iOS 18.0, *) 15 | @available(macCatalyst, unavailable) 16 | extension DescriptorAspect { 17 | @available(iOS 18.0, *) 18 | func apply(to descriptor: ASDiscoveryDescriptor) { 19 | switch self { 20 | case let .nameSubstring(substring): 21 | descriptor.bluetoothNameSubstring = substring 22 | case let .service(uuid, serviceData): 23 | descriptor.bluetoothServiceUUID = uuid.cbuuid 24 | descriptor.bluetoothServiceDataBlob = serviceData?.data 25 | descriptor.bluetoothServiceDataMask = serviceData?.mask 26 | case let .manufacturer(id, manufacturerData): 27 | descriptor.bluetoothCompanyIdentifier = id.bluetoothCompanyIdentifier 28 | descriptor.bluetoothManufacturerDataBlob = manufacturerData?.data 29 | descriptor.bluetoothManufacturerDataMask = manufacturerData?.mask 30 | case let .bluetoothRange(range): 31 | guard let range = ASDiscoveryDescriptor.Range(rawValue: range) else { 32 | preconditionFailure("Inconsistent state. ASDiscoveryDescriptor.Range could not be reconstructed from rawValue \(range)") 33 | } 34 | descriptor.bluetoothRange = range 35 | case let .supportOptions(options): 36 | descriptor.supportedOptions = .init(rawValue: options) 37 | } 38 | } 39 | 40 | @available(iOS 18.0, *) 41 | func matches(_ descriptor: ASDiscoveryDescriptor) -> Bool { 42 | switch self { 43 | case let .nameSubstring(substring): 44 | descriptor.bluetoothNameSubstring == substring 45 | case let .service(uuid, serviceData): 46 | if let serviceData { 47 | serviceData == DataDescriptor( 48 | dataProperty: descriptor.bluetoothServiceDataBlob, 49 | maskProperty: descriptor.bluetoothServiceDataMask 50 | ) 51 | && descriptor.bluetoothServiceUUID == uuid.cbuuid 52 | } else { 53 | descriptor.bluetoothServiceUUID == uuid.cbuuid 54 | } 55 | case let .manufacturer(id, manufacturerData): 56 | if let manufacturerData { 57 | manufacturerData == DataDescriptor( 58 | dataProperty: descriptor.bluetoothManufacturerDataBlob, 59 | maskProperty: descriptor.bluetoothManufacturerDataMask 60 | ) 61 | && descriptor.bluetoothCompanyIdentifier == id.bluetoothCompanyIdentifier 62 | } else { 63 | descriptor.bluetoothCompanyIdentifier == id.bluetoothCompanyIdentifier 64 | } 65 | case let .bluetoothRange(value): 66 | descriptor.bluetoothRange.rawValue == value 67 | case let .supportOptions(value): 68 | descriptor.supportedOptions.contains(ASAccessory.SupportOptions(rawValue: value)) 69 | } 70 | } 71 | } 72 | 73 | 74 | extension DataDescriptor { 75 | fileprivate init?(dataProperty: Data?, maskProperty: Data?) { 76 | guard let dataProperty, let maskProperty, dataProperty.count == maskProperty.count else { 77 | return nil 78 | } 79 | self.init(data: dataProperty, mask: maskProperty) 80 | } 81 | } 82 | #endif 83 | -------------------------------------------------------------------------------- /Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import Foundation 10 | 11 | 12 | @SpeziBluetooth 13 | class DeviceStatePeripheralInjection: Sendable { 14 | private let bluetooth: Bluetooth 15 | let peripheral: BluetoothPeripheral 16 | private let accessKeyPath: DeviceState.KeyPathType 17 | private let observationKeyPath: KeyPath? 18 | private let subscriptions: ChangeSubscriptions 19 | 20 | nonisolated var value: Value { 21 | peripheral[keyPath: accessKeyPath] 22 | } 23 | 24 | 25 | init(bluetooth: Bluetooth, peripheral: BluetoothPeripheral, keyPath: DeviceState.KeyPathType) { 26 | self.bluetooth = bluetooth 27 | self.peripheral = peripheral 28 | self.accessKeyPath = keyPath 29 | self.observationKeyPath = keyPath.storageEquivalent() 30 | self.subscriptions = ChangeSubscriptions() 31 | } 32 | 33 | func setup() { 34 | trackStateUpdate() 35 | } 36 | 37 | private func trackStateUpdate() { 38 | guard let observationKeyPath else { 39 | return 40 | } 41 | 42 | peripheral.onChange(of: observationKeyPath) { [weak self] value in 43 | guard let self = self else { 44 | return 45 | } 46 | 47 | self.trackStateUpdate() 48 | self.subscriptions.notifySubscribers(with: value) 49 | } 50 | } 51 | 52 | nonisolated func newSubscription() -> AsyncStream { 53 | subscriptions.newSubscription() 54 | } 55 | 56 | nonisolated func newOnChangeSubscription( 57 | initial: Bool, 58 | perform action: @escaping @Sendable @SpeziBluetooth (_ oldValue: Value, _ newValue: Value) async -> Void 59 | ) { 60 | let id = subscriptions.newOnChangeSubscription(perform: action) 61 | 62 | if initial { 63 | let value = peripheral[keyPath: accessKeyPath] 64 | subscriptions.notifySubscriber(id: id, with: value) 65 | } 66 | } 67 | 68 | deinit { 69 | bluetooth.notifyDeviceDeinit(for: peripheral.id) 70 | } 71 | } 72 | 73 | 74 | extension KeyPath where Root == BluetoothPeripheral { 75 | @SpeziBluetooth 76 | func storageEquivalent() -> KeyPath? { 77 | let anyKeyPath: AnyKeyPath? = switch self { 78 | case \.name: 79 | \PeripheralStorage.name 80 | case \.rssi: 81 | \PeripheralStorage.rssi 82 | case \.advertisementData: 83 | \PeripheralStorage.advertisementData 84 | case \.state: 85 | \PeripheralStorage.state 86 | case \.nearby: 87 | \PeripheralStorage.nearby 88 | case \.lastActivity: 89 | \PeripheralStorage.lastActivity 90 | case \.id: 91 | nil 92 | default: 93 | preconditionFailure("Could not find a observable translation for peripheral KeyPath \(self)") 94 | } 95 | 96 | guard let anyKeyPath else { 97 | return nil 98 | } 99 | 100 | guard let keyPath = anyKeyPath as? KeyPath else { 101 | preconditionFailure("Failed to cast KeyPath \(anyKeyPath) to \(KeyPath.self)") 102 | } 103 | 104 | return keyPath 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Sources/TestPeripheral/TestPeripheral.docc/Service-Setup.md: -------------------------------------------------------------------------------- 1 | # Running as a Service 2 | 3 | Setting up the Test Peripheral as a launchd service. 4 | 5 | 14 | 15 | ## Overview 16 | 17 | This guides provides an overview on how to deploy the test peripheral as a launchd launch agent on macOS. 18 | 19 | > Tip: For more information on `launchd` refer to the [Creating Launch Daemons and Agents](https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html) 20 | guide or other resources like [What is launchd?](https://launchd.info). 21 | 22 | ### Build the TestPeripheral 23 | 24 | 1. Clone the repository and open it in Xcode. 25 | 2. Select the `TestPeripheral` scheme and `Any Mac` as the destination. 26 | 3. Run `Product > Archive` to build and archive a release build. 27 | 4. Open the Xcode Organizer and distribute the build products of the archive from the previous step. 28 | 5. Move the `TestPeripheral` binary into the `/Applications` folder. 29 | 30 | 31 | > Important: Make sure to run the TestPeripheral manually once. 32 | You might need to navigate to `Settings > Privacy & Security` to allow TestPeripheral to bypass your notarization settings. 33 | 34 | ### Setup as a Service 35 | 36 | We provide a small script to run the test peripheral as a service using `launchd` on macOS.# 37 | Follow the following steps to install and run the service. 38 | We assume that you placed the `TestPeripheral` binary in the `/Applications` folder as per the previous steps. 39 | 40 | #### Install Service 41 | 42 | To install the launchd service run the following command in the root folder of the SpeziBluetooth project: 43 | 44 | ``` 45 | ./bin/service-launchd.sh install 46 | ``` 47 | 48 | #### Start Service 49 | 50 | To load the service into launchd run the following command: 51 | 52 | ``` 53 | ./bin/service-launchd.sh start 54 | ``` 55 | 56 | >Tip: If the peripheral doesn't show up, toggle Bluetooth off and on again as a troubleshooting step. 57 | 58 | #### Stop Service 59 | 60 | To unload the service from launchd run the following command: 61 | 62 | ``` 63 | ./bin/service-launchd.sh stop 64 | ``` 65 | 66 | #### Status 67 | 68 | You can get the current status of the launch agent using the following command: 69 | ``` 70 | ./bin/service-launchd.sh status 71 | ``` 72 | 73 | If the service is running, you will get output similar to the one below. 74 | The first column is the PID of the application (or `-` if not running) and the second column is the last exit code. 75 | 76 | ``` 77 | Started: 78 | 9314 0 edu.stanford.spezi.bluetooth.testperipheral´ 79 | ``` 80 | 81 | #### Uninstall Service 82 | 83 | To completely uninstall the launchd launch agent, run the following command: 84 | 85 | ``` 86 | ./bin/service-launchd.sh uninstall 87 | ``` 88 | 89 | 90 | ### UI Test Setup 91 | 92 | When trying to run SpeziBluetooth UI tests on a macOS runner with the test peripheral running nearby, 93 | there are a few things to consider: 94 | 95 | 1. You need to setup signing for the TestApp. 96 | 2. Run the UI tests manually once (or observe the first run) to a) allow UI automation testing and b) allow Bluetooth access for the TestApp. 97 | 3. Always allow UI automation testing by running `automationmodetool enable-automationmode-without-authentication`. 98 | 4. Disable anything interfering with the runner (e.g., disabling screen saver). 99 | 100 | -------------------------------------------------------------------------------- /Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessResponseCode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import ByteCoding 10 | import NIOCore 11 | 12 | 13 | /// Semantics of a generic response. 14 | /// 15 | /// The response code defines the semantics of a general Record Access Control Point response. 16 | /// This information is transported as part of messages with the ``RecordAccessOpCode/responseCode`` code. 17 | /// 18 | /// Refer to GATT Specification Supplement, 3.178.4 Response Code Values. 19 | public struct RecordAccessResponseCode { 20 | /// Reserved for future use. 21 | public static let reserved = RecordAccessResponseCode(rawValue: 0x00) 22 | /// Successful operation. 23 | public static let success = RecordAccessResponseCode(rawValue: 0x01) 24 | /// The received op code is not supported. 25 | public static let opCodeNotSupported = RecordAccessResponseCode(rawValue: 0x02) 26 | /// The received operator is not valid. 27 | /// 28 | /// This response code is used if an invalid operator was received (e.g., when null was expected). 29 | public static let invalidOperator = RecordAccessResponseCode(rawValue: 0x03) 30 | /// The received operator is not supported. 31 | public static let operatorNotSupported = RecordAccessResponseCode(rawValue: 0x04) 32 | /// The received operand is invalid. 33 | public static let invalidOperand = RecordAccessResponseCode(rawValue: 0x05) 34 | /// No records found. 35 | /// 36 | /// This error indicates that no records where found for the criteria of the request 37 | /// (e.g., when responding to a operation with code ``RecordAccessOpCode/reportStoredRecords``. 38 | /// - Note: The operation ``RecordAccessOpCode/reportNumberOfStoredRecords`` returns 39 | /// ``RecordAccessOpCode/numberOfStoredRecordsResponse`` with a value of zero when no records are found 40 | /// and doesn't used this error code. 41 | public static let noRecordsFound = RecordAccessResponseCode(rawValue: 0x06) 42 | /// Abort was unsuccessful. 43 | /// 44 | /// The ``RecordAccessOpCode/abortOperation`` operation was unsuccessful. 45 | public static let abortUnsuccessful = RecordAccessResponseCode(rawValue: 0x07) 46 | /// Procedure cannot be completed. 47 | public static let procedureNotCompleted = RecordAccessResponseCode(rawValue: 0x08) 48 | /// The requested operand is not supported. 49 | public static let operandNotSupported = RecordAccessResponseCode(rawValue: 0x09) 50 | /// The server is busy and cannot process the requested operation. 51 | public static let serverBusy = RecordAccessResponseCode(rawValue: 0x0A) 52 | 53 | 54 | /// The raw value response code. 55 | public let rawValue: UInt8 56 | 57 | /// Initialize using a raw value response code. 58 | /// - Parameter rawValue: The response code. 59 | public init(rawValue: UInt8) { 60 | self.rawValue = rawValue 61 | } 62 | } 63 | 64 | 65 | extension RecordAccessResponseCode: RawRepresentable {} 66 | 67 | 68 | extension RecordAccessResponseCode: Hashable, Sendable {} 69 | 70 | 71 | extension RecordAccessResponseCode: Error {} 72 | 73 | 74 | extension RecordAccessResponseCode: ByteCodable { 75 | public init?(from byteBuffer: inout ByteBuffer) { 76 | guard let rawValue = UInt8(from: &byteBuffer) else { 77 | return nil 78 | } 79 | self.init(rawValue: rawValue) 80 | } 81 | 82 | public func encode(to byteBuffer: inout ByteBuffer) { 83 | rawValue.encode(to: &byteBuffer) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessControlPoint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This source file is part of the Stanford Spezi open-source project 3 | // 4 | // SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) 5 | // 6 | // SPDX-License-Identifier: MIT 7 | // 8 | 9 | import ByteCoding 10 | import Foundation 11 | import NIOCore 12 | import SpeziBluetooth 13 | 14 | 15 | /// Protocol for the Record Access Control Point characteristic. 16 | public protocol _RecordAccessControlPoint: ControlPointCharacteristic { // swiftlint:disable:this type_name 17 | /// The operand format. 18 | associatedtype Operand: RecordAccessOperand 19 | 20 | /// The operation code. 21 | var opCode: RecordAccessOpCode { get } 22 | /// The operator. 23 | var `operator`: RecordAccessOperator { get } 24 | /// The operand. 25 | var operand: Operand? { get } 26 | } 27 | 28 | 29 | /// Service-specific operations to manage a set of data records. 30 | /// 31 | /// The Record Access Control Point characteristic implements request and response operations to manage a set of data records 32 | /// (e.g., blood pressure measurements). 33 | /// - Note: The exact format is specified by the Service. 34 | /// 35 | /// Refer to GATT Specification Supplement, 3.178 Record Access Control Point. 36 | /// 37 | /// ## Topics 38 | /// 39 | /// ### Operations 40 | /// - ``reportStoredRecords(_:)`` 41 | /// - ``deleteStoredRecords(_:)`` 42 | /// - ``abort()`` 43 | /// - ``reportNumberOfStoredRecords(_:)`` 44 | public struct RecordAccessControlPoint { 45 | /// The operation code. 46 | public let opCode: RecordAccessOpCode 47 | /// The operator. 48 | public let `operator`: RecordAccessOperator 49 | /// The operand. 50 | public let operand: Operand? 51 | 52 | 53 | /// Initialize a new operation. 54 | /// - Parameters: 55 | /// - opCode: The opcode. 56 | /// - operator: The operator. 57 | /// - operand: The operand. 58 | public init(opCode: RecordAccessOpCode, `operator`: RecordAccessOperator, operand: Operand? = nil) { 59 | self.opCode = opCode 60 | self.operator = `operator` 61 | self.operand = operand 62 | } 63 | } 64 | 65 | 66 | extension RecordAccessControlPoint: _RecordAccessControlPoint {} 67 | 68 | 69 | extension RecordAccessControlPoint: Equatable where Operand: Equatable {} 70 | 71 | 72 | extension RecordAccessControlPoint: Hashable where Operand: Hashable {} 73 | 74 | 75 | extension RecordAccessControlPoint: Sendable where Operand: Sendable {} 76 | 77 | 78 | extension RecordAccessControlPoint: ControlPointCharacteristic {} 79 | 80 | 81 | extension RecordAccessControlPoint: ByteCodable { 82 | public init?(from byteBuffer: inout ByteBuffer) { 83 | guard let opCode = RecordAccessOpCode(from: &byteBuffer), 84 | let `operator` = RecordAccessOperator(from: &byteBuffer) else { 85 | return nil 86 | } 87 | 88 | 89 | // If an operand is required is dependent on the op code and operator. 90 | // This might be implementation specific (e.g., custom op codes). Therefore, we can't enforce anything here. 91 | // The receiver would need to unwrap the optional anyways. 92 | let operand = Operand(from: &byteBuffer, opCode: opCode, operator: `operator`) 93 | 94 | self.init(opCode: opCode, operator: `operator`, operand: operand) 95 | } 96 | 97 | public func encode(to byteBuffer: inout ByteBuffer) { 98 | opCode.encode(to: &byteBuffer) 99 | `operator`.encode(to: &byteBuffer) 100 | 101 | operand?.encode(to: &byteBuffer) 102 | } 103 | } 104 | --------------------------------------------------------------------------------