├── 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 |
--------------------------------------------------------------------------------