diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 5a7cf716..0082d622 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -20,20 +20,35 @@ jobs: name: Build and Test Swift Package iOS uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: - runsonlabels: '["macOS", "self-hosted"]' + runsonlabels: '["macOS", "self-hosted", "spezi"]' scheme: SpeziBluetooth-Package artifactname: SpeziBluetooth-Package.xcresult ios: name: Build and Test iOS uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: - runsonlabels: '["macOS", "self-hosted"]' + runsonlabels: '["macOS", "self-hosted", "spezi"]' + setupSimulators: true path: 'Tests/UITests' scheme: TestApp - artifactname: TestApp.xcresult + artifactname: TestApp-iOS.xcresult + resultBundle: TestApp-iOS.xcresult + macos: + name: Build and Test macOS + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 + permissions: + contents: read + with: + runsonlabels: '["macOS", "self-hosted", "bluetooth"]' + setupsigning: true + path: 'Tests/UITests' + artifactname: TestApp-macOS.xcresult + resultBundle: TestApp-macOS.xcresult + customcommand: "set -o pipefail && xcodebuild test -scheme 'TestApp' -configuration 'Test' -destination 'platform=macOS,arch=arm64,variant=Mac Catalyst' -derivedDataPath '.derivedData' -resultBundlePath 'TestApp-macOS.xcresult' | xcpretty" + secrets: inherit uploadcoveragereport: name: Upload Coverage Report - needs: [packageios, ios] + needs: [packageios, ios, macos] uses: StanfordSpezi/.github/.github/workflows/create-and-upload-coverage-report.yml@v2 with: - coveragereports: SpeziBluetooth-Package.xcresult TestApp.xcresult + coveragereports: SpeziBluetooth-Package.xcresult TestApp-iOS.xcresult TestApp-macOS.xcresult diff --git a/.spi.yml b/.spi.yml index 5a0abcfe..529e8782 100644 --- a/.spi.yml +++ b/.spi.yml @@ -11,4 +11,6 @@ builder: configs: - platform: ios documentation_targets: + - BluetoothServices - SpeziBluetooth + - XCTBluetooth diff --git a/Package.swift b/Package.swift index 39a02b72..05be6fb4 100644 --- a/Package.swift +++ b/Package.swift @@ -15,14 +15,20 @@ let package = Package( name: "SpeziBluetooth", defaultLocalization: "en", platforms: [ - .iOS(.v17) + .iOS(.v17), + .macCatalyst(.v17), + .macOS(.v14) ], products: [ + .library(name: "BluetoothServices", targets: ["BluetoothServices"]), + .library(name: "BluetoothViews", targets: ["BluetoothViews"]), .library(name: "SpeziBluetooth", targets: ["SpeziBluetooth"]), .library(name: "XCTBluetooth", targets: ["XCTBluetooth"]) ], dependencies: [ - .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.0.0"), + .package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "1.0.0"), + .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.2.0"), + .package(url: "https://github.com/StanfordSpezi/SpeziViews", from: "1.3.0"), .package(url: "https://github.com/apple/swift-nio.git", from: "2.59.0"), .package(url: "https://github.com/apple/swift-collections.git", from: "1.0.4") ], @@ -33,21 +39,46 @@ let package = Package( .product(name: "Spezi", package: "Spezi"), .product(name: "NIO", package: "swift-nio"), .product(name: "NIOFoundationCompat", package: "swift-nio"), - .product(name: "OrderedCollections", package: "swift-collections") + .product(name: "OrderedCollections", package: "swift-collections"), + // We have an issue in Xcode projects when importing XCTBluetooth in a test target that it fails + // to link with SpeziFoundation. lol. :) + .product(name: "SpeziFoundation", package: "SpeziFoundation") ], resources: [ .process("Resources") ] ), .target( - name: "XCTBluetooth", + name: "BluetoothServices", dependencies: [ .target(name: "SpeziBluetooth") ] ), + .target( + name: "BluetoothViews", + dependencies: [ + .target(name: "SpeziBluetooth"), + .product(name: "SpeziViews", package: "SpeziViews") + ] + ), + .target( + name: "XCTBluetooth", + dependencies: [ + .target(name: "SpeziBluetooth"), + .product(name: "NIO", package: "swift-nio") + ] + ), + .executableTarget( + name: "TestPeripheral", + dependencies: [ + .target(name: "SpeziBluetooth"), + .target(name: "BluetoothServices") + ] + ), .testTarget( name: "SpeziBluetoothTests", dependencies: [ + .target(name: "BluetoothServices"), .target(name: "SpeziBluetooth"), .target(name: "XCTBluetooth"), .product(name: "NIO", package: "swift-nio") diff --git a/README.md b/README.md index bc4d67e3..20d864b2 100644 --- a/README.md +++ b/README.md @@ -105,8 +105,7 @@ class MyDevice: BluetoothDevice { @DeviceState(\.state) var state: PeripheralState - @Service(id: "180A") - var deviceInformation = DeviceInformationService() + @Service var deviceInformation = DeviceInformationService() @DeviceAction(\.connect) var connect @@ -130,7 +129,7 @@ class ExampleDelegate: SpeziAppDelegate { Configuration { Bluetooth { // Define which devices type to discover by what criteria . - // In this case we search for some custom FFF0 characteristic that is advertised. + // In this case we search for some custom FFF0 service that is advertised. Discover(MyDevice.self, by: .advertisedService("FFF0")) } } diff --git a/Sources/BluetoothServices/BluetoothServices.docc/BluetoothServices.md b/Sources/BluetoothServices/BluetoothServices.docc/BluetoothServices.md new file mode 100644 index 00000000..2523247e --- /dev/null +++ b/Sources/BluetoothServices/BluetoothServices.docc/BluetoothServices.md @@ -0,0 +1,27 @@ +# ``BluetoothServices`` + +Reusable Bluetooth Service implementations. + + + +## Overview + +This target provides reusable Bluetooth service implementations of standardized Bluetooth services. + +## Topics + +### Core Services + +- ``DeviceInformationService`` + +### Health Domain + +- ``HealthThermometerService`` diff --git a/Sources/BluetoothServices/Characteristics/DateTime.swift b/Sources/BluetoothServices/Characteristics/DateTime.swift new file mode 100644 index 00000000..f8643c3f --- /dev/null +++ b/Sources/BluetoothServices/Characteristics/DateTime.swift @@ -0,0 +1,138 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import NIO +import SpeziBluetooth + + +/// Date Time characteristic to represent date and time. +/// +/// Refer to GATT Specification Supplement, 3.70 Date Time. +public struct DateTime { + public enum Month: UInt8 { + /// Unknown month. + case unknown + /// The month January. + case january + /// The month February. + case february + /// The month March. + case march + /// The month April. + case april + /// The month Mai. + case mai + /// The month June. + case june + /// The month July. + case july + /// The month August. + case august + /// The month September. + case september + /// The month October. + case october + /// The month November. + case november + /// The month December. + case december + } + + /// Year as defined by the Gregorian calendar. + /// + /// Valid range 1582 to 9999. + /// A value of 0 means that the year is not known. All other values are Reserved for Future Use. + public let year: UInt16 + /// Month of the year as defined by the Gregorian calendar. + /// + /// Valid range 1 (January) to 12 (December). + /// A value of 0 means that the month is not known. + public let month: Month + /// Day of the month as defined by the Gregorian calendar. + /// + /// Valid range 1 to 31. + /// A value of 0 means that the day of month is not known. + public let day: UInt8 + /// Number of hours past midnight. + /// + /// Valid range 0 to 23. + public let hours: UInt8 + /// Number of minutes since the start of the hour. + /// + /// Valid range 0 to 59. + public let minutes: UInt8 + /// Number of seconds since the start of the minute. + /// + /// Valid range 0 to 59. + public let seconds: UInt8 + + + /// Create a new Date Time. + /// - Parameters: + /// - year: The year. + /// - month: The month. + /// - day: The day. + /// - hours: The hours. + /// - minutes: The minutes. + /// - seconds: The seconds. + public init(year: UInt16 = 0, month: Month = .unknown, day: UInt8 = 0, hours: UInt8, minutes: UInt8, seconds: UInt8) { + // swiftlint:disable:previous function_default_parameter_at_end + self.year = year + self.month = month + self.day = day + self.hours = hours + self.minutes = minutes + self.seconds = seconds + } +} + + +extension DateTime.Month: Equatable {} + + +extension DateTime: Equatable {} + + +extension DateTime.Month: ByteCodable { + public init?(from byteBuffer: inout ByteBuffer) { + guard let value = UInt8(from: &byteBuffer) else { + return nil + } + + self.init(rawValue: value) + } + + public func encode(to byteBuffer: inout ByteBuffer) { + rawValue.encode(to: &byteBuffer) + } +} + + +extension DateTime: ByteCodable { + public init?(from byteBuffer: inout ByteBuffer) { + guard let year = UInt16(from: &byteBuffer), + let month = Month(from: &byteBuffer), + let day = UInt8(from: &byteBuffer), + let hours = UInt8(from: &byteBuffer), + let minutes = UInt8(from: &byteBuffer), + let seconds = UInt8(from: &byteBuffer) else { + return nil + } + + self.init(year: year, month: month, day: day, hours: hours, minutes: minutes, seconds: seconds) + } + + public func encode(to byteBuffer: inout ByteBuffer) { + year.encode(to: &byteBuffer) + month.encode(to: &byteBuffer) + day.encode(to: &byteBuffer) + hours.encode(to: &byteBuffer) + minutes.encode(to: &byteBuffer) + seconds.encode(to: &byteBuffer) + } +} diff --git a/Sources/BluetoothServices/Characteristics/MeasurementInterval.swift b/Sources/BluetoothServices/Characteristics/MeasurementInterval.swift new file mode 100644 index 00000000..c0e6d605 --- /dev/null +++ b/Sources/BluetoothServices/Characteristics/MeasurementInterval.swift @@ -0,0 +1,60 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import NIO +import SpeziBluetooth + + +/// Represents the time between measurements. +/// +/// Refer to GATT Specification Supplement, 3.150 Measurement Interval. +public enum MeasurementInterval { + /// No periodic measurement + case noPeriodicMeasurement + /// Duration of measurement interval. + case duration(_ seconds: UInt16) +} + + +extension MeasurementInterval: Equatable {} + + +extension MeasurementInterval: RawRepresentable { + public var rawValue: UInt16 { + switch self { + case .noPeriodicMeasurement: + 0 + case let .duration(seconds): + seconds + } + } + + public init(rawValue: UInt16) { + switch rawValue { + case 0: + self = .noPeriodicMeasurement + default: + self = .duration(rawValue) + } + } +} + + +extension MeasurementInterval: ByteCodable { + public init?(from byteBuffer: inout ByteBuffer) { + guard let value = UInt16(from: &byteBuffer) else { + return nil + } + + self.init(rawValue: value) + } + + public func encode(to byteBuffer: inout ByteBuffer) { + rawValue.encode(to: &byteBuffer) + } +} diff --git a/Sources/BluetoothServices/Characteristics/PnPID.swift b/Sources/BluetoothServices/Characteristics/PnPID.swift new file mode 100644 index 00000000..6c57c965 --- /dev/null +++ b/Sources/BluetoothServices/Characteristics/PnPID.swift @@ -0,0 +1,132 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import NIO +import SpeziBluetooth + + +/// Bluetooth Vendor ID Source. +/// +/// Refer to GATT Specification Supplement, 3.170.1 Vendor ID Source field. +public enum VendorIDSource { + /// Assigned Company Identifier value from the Bluetooth SIG Assigned Numbers. + case bluetoothSIGAssigned + /// USB Implementer’s Forum assigned Vendor ID value. + case usbImplementersForumAssigned + /// Reserved value range. + case reserved(_ value: UInt8) + + /// String representation of the vendor id source. + public var label: String { + switch self { + case .bluetoothSIGAssigned: + return "SIG assigned" + case .usbImplementersForumAssigned: + return "USB assigned" + case let .reserved(value): + return String(format: "%02X", value) + } + } +} + + +/// The Plug and Play (PnP) Vendor ID and Product ID. +/// +/// Refer to GATT Specification Supplement, 3.170 PnP ID. +public struct PnPID { + /// The vendor id source. + public let vendorIdSource: VendorIDSource + /// Identifies the product vendor from the namespace in the Vendor ID Source. + public let vendorId: UInt16 + /// Manufacturer managed identifier for this product. + public let productId: UInt16 + /// Manufacturer managed version for this product. + public let productVersion: UInt16 + + + /// Create a new PnP ID. + /// - Parameters: + /// - vendorIdSource: The vendor id source. + /// - vendorId: The vendor id. + /// - productId: The product id. + /// - productVersion: The product version. + public init(vendorIdSource: VendorIDSource, vendorId: UInt16, productId: UInt16, productVersion: UInt16) { + self.vendorIdSource = vendorIdSource + self.vendorId = vendorId + self.productId = productId + self.productVersion = productVersion + } +} + + +extension VendorIDSource: Equatable {} + + +extension PnPID: Equatable {} + + +extension VendorIDSource: RawRepresentable { + public var rawValue: UInt8 { + switch self { + case .bluetoothSIGAssigned: + 1 + case .usbImplementersForumAssigned: + 2 + case let .reserved(value): + value + } + } + + + public init(rawValue: UInt8) { + switch rawValue { + case 1: + self = .bluetoothSIGAssigned + case 2: + self = .usbImplementersForumAssigned + default: + self = .reserved(rawValue) + } + } +} + + +extension VendorIDSource: ByteCodable { + public init?(from byteBuffer: inout ByteBuffer) { + guard let source = UInt8(from: &byteBuffer) else { + return nil + } + + self.init(rawValue: source) + } + + public func encode(to byteBuffer: inout ByteBuffer) { + rawValue.encode(to: &byteBuffer) + } +} + + +extension PnPID: ByteCodable { + public init?(from byteBuffer: inout ByteBuffer) { + guard let vendorIdSource = VendorIDSource(from: &byteBuffer), + let vendorId = UInt16(from: &byteBuffer), + let productId = UInt16(from: &byteBuffer), + let productVersion = UInt16(from: &byteBuffer) else { + return nil + } + + self.init(vendorIdSource: vendorIdSource, vendorId: vendorId, productId: productId, productVersion: productVersion) + } + + public func encode(to byteBuffer: inout ByteBuffer) { + vendorIdSource.encode(to: &byteBuffer) + vendorId.encode(to: &byteBuffer) + productId.encode(to: &byteBuffer) + productVersion.encode(to: &byteBuffer) + } +} diff --git a/Sources/BluetoothServices/Characteristics/TemperatureMeasurement.swift b/Sources/BluetoothServices/Characteristics/TemperatureMeasurement.swift new file mode 100644 index 00000000..59c1c92f --- /dev/null +++ b/Sources/BluetoothServices/Characteristics/TemperatureMeasurement.swift @@ -0,0 +1,132 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import NIO +import SpeziBluetooth + + +/// A temperature measurement. +/// +/// Refer to GATT Specification Supplement, 3.216 Temperature Measurement. +public struct TemperatureMeasurement { + /// The temperature value encoded as `medfloat32`. + public enum Value { + /// The temperature value in celsius. + case celsius(_ medfloat32: Data) + /// The temperature value in fahrenheit. + case fahrenheit(_ medfloat32: Data) + + var data: Data { + switch self { + case let .fahrenheit(data): + data + case let .celsius(data): + data + } + } + } + + /// The temperature value encoded as a `medfloat32`. + public let value: Value + /// The timestamp of the recording. + public let timeStamp: DateTime? + /// The location of the temperature measurement. + public let temperatureType: TemperatureType? + + + /// Create a new temperature measurement. + /// - Parameters: + /// - value: The measurement value. + /// - timeStamp: The timestamp of the measurement. + /// - temperatureType: The type of the measurement. + public init(value: Value, timeStamp: DateTime? = nil, temperatureType: TemperatureType? = nil) { + self.value = value + self.timeStamp = timeStamp + self.temperatureType = temperatureType + assert(value.data.count == 4, "medFloat32 must be of length 4. Found \(value.data.count) bytes!") + } +} + + +extension TemperatureMeasurement { + private enum FlagsField { + static let isFahrenheitTemperature: UInt8 = 0x01 + static let isTimeStampPresent: UInt8 = 0x02 + static let isTemperatureTypePresent: UInt8 = 0x04 + } +} + + +extension TemperatureMeasurement.Value: Equatable {} + + +extension TemperatureMeasurement: Equatable {} + + +extension TemperatureMeasurement: ByteCodable { + public init?(from byteBuffer: inout ByteBuffer) { + guard let flags = UInt8(from: &byteBuffer), + let medFloat32 = byteBuffer.readData(length: 4) else { + return nil + } + + let measurement: Value + var timeStamp: DateTime? + var temperatureType: TemperatureType? + + if flags & FlagsField.isFahrenheitTemperature > 0 { + measurement = .fahrenheit(medFloat32) + } else { + measurement = .celsius(medFloat32) + } + + if flags & FlagsField.isTimeStampPresent > 0 { + guard let dateTime = DateTime(from: &byteBuffer) else { + return nil + } + timeStamp = dateTime + } + + if flags & FlagsField.isTemperatureTypePresent > 0 { + guard let type = TemperatureType(from: &byteBuffer) else { + return nil + } + temperatureType = type + } + + self.init(value: measurement, timeStamp: timeStamp, temperatureType: temperatureType) + } + + public func encode(to byteBuffer: inout ByteBuffer) { + let flagsIndex = byteBuffer.writerIndex + var flags: UInt8 = 0 + + flags.encode(to: &byteBuffer) // write for now + + switch value { + case let .fahrenheit(data): + flags |= FlagsField.isFahrenheitTemperature + data.encode(to: &byteBuffer) + case let .celsius(data): + data.encode(to: &byteBuffer) + } + + if let timeStamp { + flags |= FlagsField.isTimeStampPresent + timeStamp.encode(to: &byteBuffer) + } + + if let temperatureType { + flags |= FlagsField.isTemperatureTypePresent + temperatureType.encode(to: &byteBuffer) + } + + byteBuffer.setInteger(flags, at: flagsIndex) // finally update the flags field + } +} diff --git a/Sources/BluetoothServices/Characteristics/TemperatureType.swift b/Sources/BluetoothServices/Characteristics/TemperatureType.swift new file mode 100644 index 00000000..08a06765 --- /dev/null +++ b/Sources/BluetoothServices/Characteristics/TemperatureType.swift @@ -0,0 +1,55 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import NIO +import SpeziBluetooth + + +/// The location of a temperature measurement. +/// +/// Refer to GATT Specification Supplement, 3.219 Temperature Type. +public enum TemperatureType: UInt8, CaseIterable { + /// Reserved for future use. + case reserved + /// Armpit. + case armpit + /// Body (general). + case body + /// Ear (usually earlobe). + case ear + /// Finger. + case finger + /// Gastrointestinal Tract. + case gastrointestinalTract + /// Mouth. + case mouth + /// Rectum. + case rectum + /// Toe. + case toe + /// Tympanum (ear drum). + case tympanum +} + + +extension TemperatureType: Equatable {} + + +extension TemperatureType: ByteCodable { + public init?(from byteBuffer: inout ByteBuffer) { + guard let value = UInt8(from: &byteBuffer) else { + return nil + } + + self.init(rawValue: value) + } + + public func encode(to byteBuffer: inout ByteBuffer) { + rawValue.encode(to: &byteBuffer) + } +} diff --git a/Sources/BluetoothServices/DeviceInformationService.swift b/Sources/BluetoothServices/DeviceInformationService.swift new file mode 100644 index 00000000..8a359876 --- /dev/null +++ b/Sources/BluetoothServices/DeviceInformationService.swift @@ -0,0 +1,103 @@ +// +// This source file is part of the Stanford Spezi open source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import class CoreBluetooth.CBUUID +import Foundation +import SpeziBluetooth + + +/// Bluetooth Device Information Service implementation. +/// +/// This class implements the Bluetooth [Device Information Service 1.1.](https://www.bluetooth.com/specifications/specs/device-information-service-1-1/). +/// All characteristics are read-only and optional to implement. +/// It is possible that none are implemented at all. +/// For more information refer to the specification. +/// +/// ## Topics +/// +/// ### Structures +/// - ``PnPID`` +/// - ``VendorIDSource`` +public final class DeviceInformationService: BluetoothService, @unchecked Sendable { + public static let id = CBUUID(string: "180A") + + /// The manufacturer name string. + @Characteristic(id: "2A29") + public var manufacturerName: String? + /// The model number string. + @Characteristic(id: "2A24") + public var modelNumber: String? + /// The serial number string. + @Characteristic(id: "2A25") + public var serialNumber: String? + + /// The hardware revision string. + @Characteristic(id: "2A27") + public var hardwareRevision: String? + /// The firmware revision string. + @Characteristic(id: "2A26") + public var firmwareRevision: String? + /// The software revision string. + @Characteristic(id: "2A28") + public var softwareRevision: String? + + /// Represents the extended unique identifier (EUI) of the system. + /// + /// This 64-bit structure is an EUI-64 which consists of an Organizationally Unique Identifier (OUI) + /// concatenated with a manufacturer-defined identifier. The OUI is issued by the IEEE Registration Authority. + @Characteristic(id: "2A23") + public var systemID: UInt64? + /// Represents regulatory and certification information for the product in a list defined in IEEE 11073-20601. + /// + /// The content of this characteristic is determined by the authorizing organization that provides certifications. + @Characteristic(id: "2A2A") + public var regulatoryCertificationDataList: Data? + /// A set of values that shall be used to create a device ID value that is unique for this device. + /// + /// Included in the characteristic are a Vendor ID source field, a Vendor ID field, a Product ID field, and a Product Version field. + /// These values are used to identify all devices of a given type/model/version using numbers. + @Characteristic(id: "2A50") + public var pnpID: PnPID? + + + public init() {} + + + /// Queries all present device information. + public func retrieveDeviceInformation() async throws { + if $manufacturerName.isPresent { + try await self.$manufacturerName.read() + } + if $modelNumber.isPresent { + try await self.$modelNumber.read() + } + if $serialNumber.isPresent { + try await self.$serialNumber.read() + } + + if $hardwareRevision.isPresent { + try await self.$hardwareRevision.read() + } + if $firmwareRevision.isPresent { + try await self.$firmwareRevision.read() + } + if $softwareRevision.isPresent { + try await self.$softwareRevision.read() + } + + if $systemID.isPresent { + try await self.$systemID.read() + } + if $regulatoryCertificationDataList.isPresent { + try await self.$regulatoryCertificationDataList.read() + } + if $pnpID.isPresent { + try await self.$pnpID.read() + } + } +} diff --git a/Sources/BluetoothServices/HealthThermometerService.swift b/Sources/BluetoothServices/HealthThermometerService.swift new file mode 100644 index 00000000..1f4f9cb2 --- /dev/null +++ b/Sources/BluetoothServices/HealthThermometerService.swift @@ -0,0 +1,56 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import class CoreBluetooth.CBUUID +import SpeziBluetooth + + +/// Bluetooth Health Thermometer Service implementation. +/// +/// This class implements the Bluetooth [Health Thermometer Service 1.0](https://www.bluetooth.com/specifications/specs/health-thermometer-service-1-0). +/// +/// ## Topics +/// +/// ### Structures +/// - ``TemperatureMeasurement`` +/// - ``TemperatureType`` +/// - ``MeasurementInterval`` +/// - ``DateTime`` +public final class HealthThermometerService: BluetoothService, @unchecked Sendable { + public static let id = CBUUID(string: "1809") + + /// Receive temperature measurements. + /// + /// - Note: This is characteristic required and indicate-only. + @Characteristic(id: "2A1C", notify: true) + public var temperatureMeasurement: TemperatureMeasurement? + /// The body location of the temperature measurement. + /// + /// Either use this static property or dynamically set it within ``TemperatureMeasurement/temperatureType``. + /// Don't use both. Either of one is required. + /// + /// - Note: This is characteristic optional and read-only. + @Characteristic(id: "2A1D") + public var temperatureType: TemperatureType? + /// Receive intermediate temperature values to a device for display purposes while a measurement is in progress. + /// + /// - Note: This is characteristic optional and notify-only. + @Characteristic(id: "2A1E", notify: true) + public var intermediateTemperature: TemperatureMeasurement? + /// The measurement interval between two measurements. + /// + /// Describes the measurements of ``temperatureMeasurement``. + /// + /// - Note: This is characteristic optional and read-only. + /// Optionally it might indicate and writeable. + @Characteristic(id: "2A21") + public var measurementInterval: MeasurementInterval? + + + public init() {} +} diff --git a/Sources/BluetoothServices/TestingSupport/CBUUID+Characteristics.swift b/Sources/BluetoothServices/TestingSupport/CBUUID+Characteristics.swift new file mode 100644 index 00000000..b42fb3ef --- /dev/null +++ b/Sources/BluetoothServices/TestingSupport/CBUUID+Characteristics.swift @@ -0,0 +1,49 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import CoreBluetooth + + +@_spi(TestingSupport) +extension CBUUID { + private static let prefix = "0000" + private static let suffix = "-0000-1000-8000-00805F9B34FB" + + /// The test service. + public static let testService: CBUUID = .uuid(ofCustom: "F001") + + /// An event log of events of the test peripheral implementation. + public static let eventLogCharacteristic: CBUUID = .uuid(ofCustom: "F002") + /// A string characteristic that you can read. + public static let readStringCharacteristic: CBUUID = .uuid(ofCustom: "F003") + /// A string characteristic that you can write. + public static let writeStringCharacteristic: CBUUID = .uuid(ofCustom: "F004") + /// A string characteristic that you can read and write. + public static let readWriteStringCharacteristic: CBUUID = .uuid(ofCustom: "F005") + /// Reset peripheral state to default settings. + public static let resetCharacteristic: CBUUID = .uuid(ofCustom: "F006") + + + private static func uuid(ofCustom: String) -> CBUUID { + precondition(ofCustom.count == 4, "Unexpected length of \(ofCustom.count)") + return CBUUID(string: "\(prefix)\(ofCustom)\(suffix)") + } + + /// Get a short uuid representation of your custom uuid base. + /// - Parameter uuid: The uuid with the SpeziBluetooth base id. + /// - Returns: Short uuid format. + public static func toCustomShort(_ uuid: CBUUID) -> String { + var string = uuid.uuidString + assert(string.hasPrefix(prefix), "unexpected uuid format") + assert(string.hasSuffix(suffix), "unexpected uuid format") + string.removeFirst(prefix.count) + string.removeLast(suffix.count) + assert(string.count == 4, "unexpected uuid string length") + return string + } +} diff --git a/Sources/BluetoothServices/TestingSupport/EventLog.swift b/Sources/BluetoothServices/TestingSupport/EventLog.swift new file mode 100644 index 00000000..2bca682c --- /dev/null +++ b/Sources/BluetoothServices/TestingSupport/EventLog.swift @@ -0,0 +1,133 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +@preconcurrency import CoreBluetooth +import NIO +@_spi(TestingSupport) +import SpeziBluetooth + + +/// An event emitted by the test peripheral. +/// +/// Those events always imply to happen on characteristics of the `TestService`. +@_spi(TestingSupport) +public enum EventLog: Sendable { + /// No event happened yet. + case none + /// Central subscribed to the notifications of the given characteristic. + case subscribedToNotification(_ characteristic: CBUUID) + /// Central unsubscribed to the notifications of the given characteristic. + case unsubscribedToNotification(_ characteristic: CBUUID) + /// The peripheral received a read request for the given characteristic. + case receivedRead(_ characteristic: CBUUID) + /// The peripheral received a write request for the given characteristic and data. + case receivedWrite(_ characteristic: CBUUID, value: Data) +} + + +@_spi(TestingSupport) +extension EventLog: Equatable {} + + +@_spi(TestingSupport) +extension EventLog: CustomStringConvertible { + public var description: String { + switch self { + case .none: + "none" + case let .subscribedToNotification(characteristic): + "Subscribed to notifications for \(characteristic)" + case let .unsubscribedToNotification(characteristic): + "Unsubscribed from notifications for \(characteristic)" + case let .receivedRead(characteristic): + "Received read request for \(characteristic)" + case let .receivedWrite(characteristic, value): + "Received write request for \(characteristic): \(value.hexString())" + } + } +} + + +@_spi(TestingSupport) +extension EventLog: ByteCodable { + private enum EventType: UInt8 { + case none + case subscribed + case unsubscribed + case read + case write + } + + private var type: EventType { + switch self { + case .none: + return .none + case .subscribedToNotification: + return .subscribed + case .unsubscribedToNotification: + return .unsubscribed + case .receivedRead: + return .read + case .receivedWrite: + return .write + } + } + + public init?(from byteBuffer: inout ByteBuffer) { + guard let rawValue = UInt8(from: &byteBuffer), + let type = EventType(rawValue: rawValue) else { + return nil + } + + if case type = .none { + // non has no characteristic to read, so skip here. Makes it easier below. + self = .none + return + } + + guard let data = byteBuffer.readData(length: 16) else { // 128-bit UUID + return nil + } + + let characteristic = CBUUID(data: data) + + + switch type { + case .none: + self = .none + case .subscribed: + self = .subscribedToNotification(characteristic) + case .unsubscribed: + self = .unsubscribedToNotification(characteristic) + case .read: + self = .receivedRead(characteristic) + case .write: + guard let value = byteBuffer.readData(length: byteBuffer.readableBytes) else { + return nil + } + self = .receivedWrite(characteristic, value: value) + } + } + + public func encode(to byteBuffer: inout ByteBuffer) { + type.rawValue.encode(to: &byteBuffer) + switch self { + case .none: + break + case let .subscribedToNotification(characteristic): + characteristic.data.encode(to: &byteBuffer) + case let .unsubscribedToNotification(characteristic): + characteristic.data.encode(to: &byteBuffer) + case let .receivedRead(characteristic): + characteristic.data.encode(to: &byteBuffer) + case let .receivedWrite(characteristic, value): + characteristic.data.encode(to: &byteBuffer) + byteBuffer.writeData(value) + } + } +} diff --git a/Sources/BluetoothServices/TestingSupport/TestService.swift b/Sources/BluetoothServices/TestingSupport/TestService.swift new file mode 100644 index 00000000..0de9bfa9 --- /dev/null +++ b/Sources/BluetoothServices/TestingSupport/TestService.swift @@ -0,0 +1,34 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import CoreBluetooth +import SpeziBluetooth + + +@_spi(TestingSupport) +public final class TestService: BluetoothService, @unchecked Sendable { + public static let id: CBUUID = .testService + + @Characteristic(id: .eventLogCharacteristic, notify: true) + public var eventLog: EventLog? + + + @Characteristic(id: .readStringCharacteristic) + public var readString: String? + + @Characteristic(id: .writeStringCharacteristic) + public var writeString: String? + + @Characteristic(id: .readWriteStringCharacteristic) + public var readWriteString: String? + + @Characteristic(id: .resetCharacteristic) + public var reset: Bool? // swiftlint:disable:this discouraged_optional_boolean + + public init() {} +} diff --git a/Sources/BluetoothViews/BluetoothStateHint.swift b/Sources/BluetoothViews/BluetoothStateHint.swift new file mode 100644 index 00000000..7b0a7966 --- /dev/null +++ b/Sources/BluetoothViews/BluetoothStateHint.swift @@ -0,0 +1,129 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziBluetooth +import SwiftUI + + +public struct BluetoothStateHint: View { + private let state: BluetoothState + + private var titleMessage: LocalizedStringResource? { + switch state { + case .poweredOn: + return nil + case .poweredOff: + return .init("Bluetooth Off", bundle: .atURL(from: .module)) + case .unauthorized: + return .init("Bluetooth Prohibited", bundle: .atURL(from: .module)) + case .unsupported: + return .init("Bluetooth Unsupported", bundle: .atURL(from: .module)) + case .unknown: + return .init("Bluetooth Failure", bundle: .atURL(from: .module)) + } + } + + private var subtitleMessage: LocalizedStringResource? { + switch state { + case .poweredOn: + return nil + case .poweredOff: + return .init("Bluetooth is turned off. ...", bundle: .atURL(from: .module)) + case .unauthorized: + return .init("Bluetooth is required to make connections to nearby devices. ...", bundle: .atURL(from: .module)) + case .unknown: + return .init("We have trouble with the Bluetooth communication. Please try again.", bundle: .atURL(from: .module)) + case .unsupported: + return .init("Bluetooth is unsupported on this device!", bundle: .atURL(from: .module)) + } + } + + + public var body: some View { + if titleMessage != nil || subtitleMessage != nil { + ContentUnavailableView { + if let titleMessage { + Label { + Text(titleMessage) + } icon: { + EmptyView() + } + } + } description: { + if let subtitleMessage { + Text(subtitleMessage) + } + } actions: { + switch state { + case .poweredOff, .unauthorized: + #if os(iOS) || os(visionOS) || os(tvOS) + Button(action: { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + }) { + Text("Open Settings", bundle: .module) + } + #else + EmptyView() + #endif + default: + EmptyView() + } + } + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets(top: -15, leading: 0, bottom: 0, trailing: 0)) + } else { + EmptyView() + } + } + + + public init(_ state: BluetoothState) { + self.state = state + } +} + + +#if DEBUG +#Preview { + GeometryReader { proxy in + List { + BluetoothStateHint(.poweredOff) + .frame(height: proxy.size.height - 100) + } + } +} + +#Preview { + GeometryReader { proxy in + List { + BluetoothStateHint(.unauthorized) + .frame(height: proxy.size.height - 100) + } + } +} + +#Preview { + GeometryReader { proxy in + List { + BluetoothStateHint(.unsupported) + .frame(height: proxy.size.height - 100) + } + } +} + +#Preview { + GeometryReader { proxy in + List { + BluetoothStateHint(.unknown) + .frame(height: proxy.size.height - 100) + } + } +} +#endif diff --git a/Sources/BluetoothViews/BluetoothViews.docc/BluetoothViews.md b/Sources/BluetoothViews/BluetoothViews.docc/BluetoothViews.md new file mode 100644 index 00000000..b1092718 --- /dev/null +++ b/Sources/BluetoothViews/BluetoothViews.docc/BluetoothViews.md @@ -0,0 +1,31 @@ +# ``BluetoothViews`` + +Reusable view components for interaction with Bluetooth devices. + + + +## Overview + +This target provides reusable view components that can be helpful when designing user interaction with Bluetooth devices. + +## Topics + +### Presenting nearby devices + +Views that are helpful when building a nearby devices view. + +- ``BluetoothStateHint`` +- ``NearbyDeviceRow`` +- ``LoadingSectionHeaderView`` + +### Peripheral Model + +- ``GenericBluetoothPeripheral`` diff --git a/Sources/BluetoothViews/LoadingSectionHeaderView.swift b/Sources/BluetoothViews/LoadingSectionHeaderView.swift new file mode 100644 index 00000000..56dc0ee4 --- /dev/null +++ b/Sources/BluetoothViews/LoadingSectionHeaderView.swift @@ -0,0 +1,60 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +public struct LoadingSectionHeaderView: View { + private let text: Text + private let loading: Bool + + public var body: some View { + HStack { + text + if loading { + ProgressView() + .padding(.leading, 4) + .accessibilityRemoveTraits(.updatesFrequently) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel(Text("\(text), Searching", bundle: .module)) + } + + @_disfavoredOverload + public init(verbatim: String, loading: Bool) { + self.init(Text(verbatim), loading: loading) + } + + public init(_ title: LocalizedStringResource, loading: Bool) { + self.init(Text(title), loading: loading) + } + + + public init(_ text: Text, loading: Bool) { + self.text = text + self.loading = loading + } +} + + +#if DEBUG +#Preview { + List { + Section { + Text(verbatim: "...") + } header: { + LoadingSectionHeaderView(verbatim: "Devices", loading: true) + } + } +} + +#Preview { + LoadingSectionHeaderView(verbatim: "Devices", loading: true) +} +#endif diff --git a/Sources/BluetoothViews/Model/BluetoothPeripheral+GenericBluetoothPeripheral.swift b/Sources/BluetoothViews/Model/BluetoothPeripheral+GenericBluetoothPeripheral.swift new file mode 100644 index 00000000..60182077 --- /dev/null +++ b/Sources/BluetoothViews/Model/BluetoothPeripheral+GenericBluetoothPeripheral.swift @@ -0,0 +1,16 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziBluetooth + + +extension BluetoothPeripheral: GenericBluetoothPeripheral { + public nonisolated var label: String { + name ?? "unknown device" + } +} diff --git a/Sources/BluetoothViews/Model/GenericBluetoothPeripheral.swift b/Sources/BluetoothViews/Model/GenericBluetoothPeripheral.swift new file mode 100644 index 00000000..f2aab657 --- /dev/null +++ b/Sources/BluetoothViews/Model/GenericBluetoothPeripheral.swift @@ -0,0 +1,46 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziBluetooth + + +/// A generic bluetooth peripheral representation used within UI components. +public protocol GenericBluetoothPeripheral { + /// The user-visible label. + /// + /// This label is used to communicate information about this device to the user. + var label: String { get } + + /// An optional accessibility label. + /// + /// This label is used as the accessibility label within views when + /// communicate information about this device to the user. + var accessibilityLabel: String { get } + + /// The current peripheral state. + var state: PeripheralState { get } + + /// Mark the device to require user attention. + /// + /// Marks the device to require user attention. The user should navigate to the details + /// view to get more information about the device. + var requiresUserAttention: Bool { get } +} + + +extension GenericBluetoothPeripheral { + /// Default implementation using the devices `label`. + public var accessibilityLabel: String { + label + } + + /// By default the peripheral doesn't require user attention. + public var requiresUserAttention: Bool { + false + } +} diff --git a/Sources/BluetoothViews/Model/MockBluetoothDevice.swift b/Sources/BluetoothViews/Model/MockBluetoothDevice.swift new file mode 100644 index 00000000..37a14c42 --- /dev/null +++ b/Sources/BluetoothViews/Model/MockBluetoothDevice.swift @@ -0,0 +1,23 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziBluetooth + + +/// Mock peripheral used for internal previews. +struct MockBluetoothDevice: GenericBluetoothPeripheral { + var label: String + var state: PeripheralState + var requiresUserAttention: Bool + + init(label: String, state: PeripheralState, requiresUserAttention: Bool = false) { + self.label = label + self.state = state + self.requiresUserAttention = requiresUserAttention + } +} diff --git a/Sources/BluetoothViews/NearbyDeviceRow.swift b/Sources/BluetoothViews/NearbyDeviceRow.swift new file mode 100644 index 00000000..4f59093b --- /dev/null +++ b/Sources/BluetoothViews/NearbyDeviceRow.swift @@ -0,0 +1,159 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +import SpeziViews +import SwiftUI + + +public struct NearbyDeviceRow: View { + private let peripheral: any GenericBluetoothPeripheral + private let devicePrimaryActionClosure: () -> Void + private let secondaryActionClosure: (() -> Void)? + + + var showDetailsButton: Bool { + secondaryActionClosure != nil && peripheral.state == .connected + } + + var localizationSecondaryLabel: LocalizedStringResource? { + if peripheral.requiresUserAttention { + return .init("Intervention Required", bundle: .atURL(from: .module)) + } + switch peripheral.state { + case .connecting: + return .init("Connecting", bundle: .atURL(from: .module)) + case .connected: + return .init("Connected", bundle: .atURL(from: .module)) + case .disconnecting: + return .init("Disconnecting", bundle: .atURL(from: .module)) + case .disconnected: + return nil + } + } + + public var body: some View { + let stack = HStack { + Button(action: devicePrimaryAction) { + HStack { + ListRow(verbatim: peripheral.label) { + deviceSecondaryLabel + } + if peripheral.state == .connecting || peripheral.state == .disconnecting { + ProgressView() + .accessibilityRemoveTraits(.updatesFrequently) + } + } + } + + if showDetailsButton { + Button(action: deviceDetailsAction) { + Label { + Text("Device Details", bundle: .module) + } icon: { + Image(systemName: "info.circle") // swiftlint:disable:this accessibility_label_for_image + } + } + .labelStyle(.iconOnly) + .font(.title3) + .buttonStyle(.plain) // ensure button is clickable next to the other button + .foregroundColor(.accentColor) + } + } + + #if TEST || targetEnvironment(simulator) + // accessibility actions cannot be unit tested + stack + #else + stack.accessibilityRepresentation { + accessibilityRepresentation + } + #endif + } + + @ViewBuilder var accessibilityRepresentation: some View { + let button = Button(action: devicePrimaryAction) { + Text(verbatim: peripheral.accessibilityLabel) + if let localizationSecondaryLabel { + Text(localizationSecondaryLabel) + } + } + + if showDetailsButton { + button + .accessibilityAction(named: Text("Device Details", bundle: .module), deviceDetailsAction) + } else { + button + } + } + + @ViewBuilder var deviceSecondaryLabel: some View { + if peripheral.requiresUserAttention { + Text("Requires Attention", bundle: .module) + } else { + switch peripheral.state { + case .connecting, .disconnecting: + EmptyView() + case .connected: + Text("Connected", bundle: .module) + case .disconnected: + EmptyView() + } + } + } + + + public init( + peripheral: any GenericBluetoothPeripheral, + primaryAction: @escaping () -> Void, + secondaryAction: (() -> Void)? = nil + ) { + self.peripheral = peripheral + self.devicePrimaryActionClosure = primaryAction + self.secondaryActionClosure = secondaryAction + } + + + private func devicePrimaryAction() { + devicePrimaryActionClosure() + } + + private func deviceDetailsAction() { + if let secondaryActionClosure { + secondaryActionClosure() + } + } +} + + +#if DEBUG +#Preview { + List { + NearbyDeviceRow(peripheral: MockBluetoothDevice(label: "MyDevice 1", state: .connecting)) { + print("Clicked") + } secondaryAction: { + } + NearbyDeviceRow(peripheral: MockBluetoothDevice(label: "MyDevice 2", state: .connected)) { + print("Clicked") + } secondaryAction: { + } + NearbyDeviceRow(peripheral: MockBluetoothDevice(label: "Long MyDevice 3", state: .connected, requiresUserAttention: true)) { + print("Clicked") + } secondaryAction: { + } + NearbyDeviceRow(peripheral: MockBluetoothDevice(label: "MyDevice 4", state: .disconnecting)) { + print("Clicked") + } secondaryAction: { + } + NearbyDeviceRow(peripheral: MockBluetoothDevice(label: "MyDevice 5", state: .disconnected)) { + print("Clicked") + } secondaryAction: { + } + } +} +#endif diff --git a/Sources/BluetoothViews/Resources/Localizable.xcstrings b/Sources/BluetoothViews/Resources/Localizable.xcstrings new file mode 100644 index 00000000..91d7dd74 --- /dev/null +++ b/Sources/BluetoothViews/Resources/Localizable.xcstrings @@ -0,0 +1,166 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "%@, Searching" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@, Searching" + } + } + } + }, + "Bluetooth Failure" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth Failure" + } + } + } + }, + "Bluetooth is required to make connections to nearby devices. ..." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth is required to make connections to a nearby device. Please allow Bluetooth connections in your Privacy settings." + } + } + } + }, + "Bluetooth is turned off. ..." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth is turned off. Please turn on Bluetooth in Control Center or Settings, in order to connect to a nearby device." + } + } + } + }, + "Bluetooth is unsupported on this device!" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth is unsupported on this device!" + } + } + } + }, + "Bluetooth Off" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth Off" + } + } + } + }, + "Bluetooth Prohibited" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth Prohibited" + } + } + } + }, + "Bluetooth Unsupported" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth Unsupported" + } + } + } + }, + "Connected" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connected" + } + } + } + }, + "Connecting" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connecting" + } + } + } + }, + "Device Details" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Device Details" + } + } + } + }, + "Disconnecting" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disconnecting" + } + } + } + }, + "Intervention Required" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intervention REquired" + } + } + } + }, + "Open Settings" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Settings" + } + } + } + }, + "Requires Attention" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Requires Attention" + } + } + } + }, + "We have trouble with the Bluetooth communication. Please try again." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "We have trouble with the Bluetooth communication. Please try again." + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Sources/BluetoothViews/Resources/Localizable.xcstrings.license b/Sources/BluetoothViews/Resources/Localizable.xcstrings.license new file mode 100644 index 00000000..28f53d0d --- /dev/null +++ b/Sources/BluetoothViews/Resources/Localizable.xcstrings.license @@ -0,0 +1,5 @@ +This source file is part of the Stanford Spezi open-source project + +SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) + +SPDX-License-Identifier: MIT \ No newline at end of file diff --git a/Sources/SpeziBluetooth/Bluetooth.swift b/Sources/SpeziBluetooth/Bluetooth.swift index 5e6c1e76..255b7fcd 100644 --- a/Sources/SpeziBluetooth/Bluetooth.swift +++ b/Sources/SpeziBluetooth/Bluetooth.swift @@ -6,6 +6,7 @@ // SPDX-License-Identifier: MIT // +import OrderedCollections import OSLog import Spezi @@ -29,6 +30,8 @@ import Spezi /// /// ```swift /// class DeviceInformationService: BluetoothService { +/// static let id = CBUUID(string: "180A") +/// /// @Characteristic(id: "2A29") /// var manufacturer: String? /// @Characteristic(id: "2A26") @@ -50,8 +53,7 @@ import Spezi /// @DeviceState(\.state) /// var state: PeripheralState /// -/// @Service(id: "180A") -/// var deviceInformation = DeviceInformationService() +/// @Service var deviceInformation = DeviceInformationService() /// /// @DeviceAction(\.connect) /// var connect @@ -75,7 +77,7 @@ import Spezi /// Configuration { /// Bluetooth { /// // Define which devices type to discover by what criteria . -/// // In this case we search for some custom FFF0 characteristic that is advertised. +/// // In this case we search for some custom FFF0 service that is advertised. /// Discover(MyDevice.self, by: .advertisedService("FFF0")) /// } /// } @@ -153,40 +155,57 @@ import Spezi /// - ``nearbyDevices(for:)`` /// - ``scanNearbyDevices(autoConnect:)`` /// - ``stopScanning()`` -@Observable -public class Bluetooth: Module, EnvironmentAccessible, BluetoothScanner { +public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, BluetoothActor { + @Observable + class Storage { + var nearbyDevices: OrderedDictionary = [:] + } + static let logger = Logger(subsystem: "edu.stanford.spezi.bluetooth", category: "Bluetooth") + /// The Bluetooth Executor from the underlying BluetoothManager. + let bluetoothQueue: DispatchSerialQueue + private let bluetoothManager: BluetoothManager private let deviceConfigurations: Set + private let _storage = Storage() + private var logger: Logger { Self.logger } /// Represents the current state of Bluetooth. - public var state: BluetoothState { + nonisolated public var state: BluetoothState { bluetoothManager.state } /// Whether or not we are currently scanning for nearby devices. - public var isScanning: Bool { + nonisolated public var isScanning: Bool { bluetoothManager.isScanning } + /// Support for the auto connect modifier. @_documentation(visibility: internal) - public var hasConnectedDevices: Bool { - connectedDevicesModel.hasConnectedDevices + nonisolated public var hasConnectedDevices: Bool { + bluetoothManager.hasConnectedDevices } - @MainActor private var nearbyDevices: [UUID: BluetoothDevice] = [:] + private var nearbyDevices: OrderedDictionary { + get { + _storage.nearbyDevices + } + set { + _storage.nearbyDevices = newValue + } + } /// Stores the connected device instance for every configured ``BluetoothDevice`` type. - @Model @ObservationIgnored private var connectedDevicesModel = ConnectedDevices() + @Model private var connectedDevicesModel = ConnectedDevices() /// Injects the ``BluetoothDevice`` instances from the `ConnectedDevices` model into the SwiftUI environment. - @Modifier @ObservationIgnored private var devicesInjector: ConnectedDevicesEnvironmentModifier + @Modifier private var devicesInjector: ConnectedDevicesEnvironmentModifier /// Configure the Bluetooth Module. @@ -196,7 +215,7 @@ public class Bluetooth: Module, EnvironmentAccessible, BluetoothScanner { /// /// ```swift /// Bluetooth { - /// Discover(ExampleDevice.self, by: .advertisedService("...")) + /// Discover(ExampleDevice.self, by: .advertisedService(MyExampleService.self)) /// } /// ``` /// @@ -208,30 +227,46 @@ public class Bluetooth: Module, EnvironmentAccessible, BluetoothScanner { public init( minimumRSSI: Int = BluetoothManager.Defaults.defaultMinimumRSSI, advertisementStaleInterval: TimeInterval = BluetoothManager.Defaults.defaultStaleTimeout, - @DiscoveryConfigurationBuilder _ devices: () -> Set + @DiscoveryConfigurationBuilder _ devices: @Sendable () -> Set ) { let configuration = devices() let deviceTypes = configuration.deviceTypes - self.bluetoothManager = BluetoothManager( - devices: configuration.parseDeviceDescription(), + let devices = ClosureRegistrar.$writeableView.withValue(.init()) { + // we provide a closure registrar just to silence any out-of-band usage warnings! + configuration.parseDeviceDescription() + } + + let bluetoothManager = BluetoothManager( + devices: devices, minimumRSSI: minimumRSSI, advertisementStaleInterval: advertisementStaleInterval ) + + self.bluetoothQueue = bluetoothManager.bluetoothQueue + self.bluetoothManager = bluetoothManager self.deviceConfigurations = configuration self._devicesInjector = Modifier(wrappedValue: ConnectedDevicesEnvironmentModifier(configuredDeviceTypes: deviceTypes)) - observeNearbyDevices() // register observation tracking + Task { + await self.observeDiscoveredDevices() + } } - private func observeNearbyDevices() { - withObservationTracking { - _ = bluetoothManager.discoveredPeripherals - } onChange: { [weak self] in - Task { @MainActor [weak self] in - self?.handleNearbyDevicesChange() + private func observeDiscoveredDevices() { + self.assertIsolated("This didn't move to the actor even if it should.") + bluetoothManager.assumeIsolated { manager in + manager.onChange(of: \.discoveredPeripherals) { [weak self] discoveredDevices in + guard let self = self else { + return + } + + self.assertIsolated("BluetoothManager peripherals change closure was unexpectedly not called on the Bluetooth SerialExecutor.") + self.assumeIsolated { bluetooth in + bluetooth.observeDiscoveredDevices() + bluetooth.handleUpdatedNearbyDevicesChange(discoveredDevices) + } } - self?.observeNearbyDevices() } } @@ -239,46 +274,54 @@ public class Bluetooth: Module, EnvironmentAccessible, BluetoothScanner { // We must make sure that we don't capture the `peripheral` within the `onChange` closure as otherwise // this would require a reference cycle within the `BluetoothPeripheral` class. // Therefore, we have this indirection via the uuid here. - guard let peripheral = bluetoothManager.discoveredPeripherals[uuid] else { + guard let peripheral = bluetoothManager.assumeIsolated({ $0.discoveredPeripherals[uuid] }) else { return } - withObservationTracking { - _ = peripheral.state - } onChange: { [weak self] in - Task { @MainActor [weak self] in - self?.handlePeripheralStateChange() - } + peripheral.assumeIsolated { peripheral in + peripheral.onChange(of: \.state) { [weak self] _ in + guard let self = self else { + return + } - self?.observePeripheralState(of: uuid) + self.assumeIsolated { bluetooth in + bluetooth.observePeripheralState(of: uuid) + bluetooth.handlePeripheralStateChange() + } + } } } - @MainActor - private func handleNearbyDevicesChange() { - let discoveredDevices = bluetoothManager.discoveredPeripherals - + private func handleUpdatedNearbyDevicesChange(_ discoveredDevices: OrderedDictionary) { var checkForConnected = false // remove all delete keys for key in nearbyDevices.keys where discoveredDevices[key] == nil { checkForConnected = true - nearbyDevices.removeValue(forKey: key) + let device = nearbyDevices.removeValue(forKey: key) + device?.clearState(isolatedTo: self) } // add devices for new keys for (uuid, peripheral) in discoveredDevices where nearbyDevices[uuid] == nil { - guard let configuration = deviceConfigurations.find(for: peripheral.advertisementData, logger: logger) else { + let advertisementData = peripheral.advertisementData + guard let configuration = deviceConfigurations.find(for: advertisementData, logger: logger) else { logger.warning("Ignoring peripheral \(peripheral.debugDescription) that cannot be mapped to a device class.") continue } - let device = configuration.anyDeviceType.init() - device.inject(peripheral: peripheral) - nearbyDevices[uuid] = device + + let closures = ClosureRegistrar() + let device = ClosureRegistrar.$writeableView.withValue(closures) { + configuration.anyDeviceType.init() + } + ClosureRegistrar.$readableView.withValue(closures) { + device.inject(peripheral: peripheral) + nearbyDevices[uuid] = device + } checkForConnected = true - observePeripheralState(of: uuid) + observePeripheralState(of: uuid) // register \.state onChange closure } if checkForConnected { @@ -287,12 +330,11 @@ public class Bluetooth: Module, EnvironmentAccessible, BluetoothScanner { } } - @MainActor private func handlePeripheralStateChange() { // check for active connected device - let connectedDevices = bluetoothManager.discoveredPeripherals + let connectedDevices = bluetoothManager.assumeIsolated { $0.discoveredPeripherals } .filter { _, value in - value.state == .connected + value.assumeIsolated { $0.state } == .connected } .compactMap { key, _ in (key, nearbyDevices[key]) // map them to their devices class @@ -301,7 +343,10 @@ public class Bluetooth: Module, EnvironmentAccessible, BluetoothScanner { result[tuple.0] = tuple.1 } - self.connectedDevicesModel.update(with: connectedDevices) + let connectedDevicesModel = connectedDevicesModel + Task { @MainActor in + connectedDevicesModel.update(with: connectedDevices) + } } /// Retrieve nearby devices. @@ -310,9 +355,8 @@ public class Bluetooth: Module, EnvironmentAccessible, BluetoothScanner { /// return nearby devices that are of the provided ``BluetoothDevice`` type. /// - Parameter device: The device type to filter for. /// - Returns: A list of nearby devices of a given ``BluetoothDevice`` type. - @MainActor - public func nearbyDevices(for device: Device.Type = Device.self) -> [Device] { - nearbyDevices.values.compactMap { device in + public nonisolated func nearbyDevices(for device: Device.Type = Device.self) -> [Device] { + _storage.nearbyDevices.values.compactMap { device in device as? Device } } @@ -330,12 +374,25 @@ public class Bluetooth: Module, EnvironmentAccessible, BluetoothScanner { /// /// - Parameter autoConnect: If enabled, the bluetooth manager will automatically connect to /// the nearby device if only one is found for a given time threshold. - public func scanNearbyDevices(autoConnect: Bool = false) async { - await bluetoothManager.scanNearbyDevices(autoConnect: autoConnect) + public func scanNearbyDevices(autoConnect: Bool = false) { + bluetoothManager.assumeIsolated { manager in + manager.scanNearbyDevices(autoConnect: autoConnect) + } + } + + /// If scanning, toggle the auto-connect feature. + /// - Parameter autoConnect: Flag to turn on or off auto-connect + @_documentation(visibility: internal) + public func setAutoConnect(_ autoConnect: Bool) { + bluetoothManager.assumeIsolated { manager in + manager.setAutoConnect(autoConnect) + } } /// Stop scanning for nearby bluetooth devices. - public func stopScanning() async { - await bluetoothManager.stopScanning() + public func stopScanning() { + bluetoothManager.assumeIsolated { manager in + manager.stopScanning() + } } } diff --git a/Sources/SpeziBluetooth/Bridging/BluetoothScanner.swift b/Sources/SpeziBluetooth/Bridging/BluetoothScanner.swift index 7946a625..93368d2b 100644 --- a/Sources/SpeziBluetooth/Bridging/BluetoothScanner.swift +++ b/Sources/SpeziBluetooth/Bridging/BluetoothScanner.swift @@ -8,14 +8,10 @@ /// Any kind of Bluetooth Scanner. -public protocol BluetoothScanner { - /// The current state of the bluetooth scanner. - var state: BluetoothState { get } - - /// Whether or not we are currently scanning for nearby devices. - var isScanning: Bool { get } - +public protocol BluetoothScanner: Identifiable where ID: Hashable { /// Indicates if there is at least one connected peripheral. + /// + /// Make sure this tracks observability of all devices classes. var hasConnectedDevices: Bool { get } /// Scan for nearby bluetooth devices. @@ -26,6 +22,20 @@ public protocol BluetoothScanner { /// the nearby device if only one is found for a given time threshold. func scanNearbyDevices(autoConnect: Bool) async + /// Updates the auto-connect capability if currently scanning. + /// + /// Does nothing if not currently scanning. + /// - Parameter autoConnect: Flag if auto-connect should be enabled. + func setAutoConnect(_ autoConnect: Bool) async + /// Stop scanning for nearby bluetooth devices. func stopScanning() async } + + +extension BluetoothScanner where Self: AnyObject { + /// Default id based on `ObjectIdentifier`. + public var id: ObjectIdentifier { + ObjectIdentifier(self) + } +} diff --git a/Sources/SpeziBluetooth/Coding/FixedWithInteger+ByteCodable.swift b/Sources/SpeziBluetooth/Coding/FixedWithInteger+ByteCodable.swift index bb48c675..d8857456 100644 --- a/Sources/SpeziBluetooth/Coding/FixedWithInteger+ByteCodable.swift +++ b/Sources/SpeziBluetooth/Coding/FixedWithInteger+ByteCodable.swift @@ -43,7 +43,7 @@ extension FixedWidthByteCodable { /// /// - Note: For reference, the basic types in Bluetooth are illustrated in /// Bluetooth Core Specification, Volume 1, Part E, 3.9.1 Basic types. - /// - Parameter byteBuffer: The bytebuffer to decode from. + /// - Parameter byteBuffer: The bytebuffer to decode to. public func encode(to byteBuffer: inout ByteBuffer) { byteBuffer.writeInteger(self, endianness: .little) } diff --git a/Sources/SpeziBluetooth/Coding/Float+ByteCodable.swift b/Sources/SpeziBluetooth/Coding/Float+ByteCodable.swift new file mode 100644 index 00000000..ade081e6 --- /dev/null +++ b/Sources/SpeziBluetooth/Coding/Float+ByteCodable.swift @@ -0,0 +1,76 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import NIO + + +extension Float32: ByteCodable { + /// Decodes a float from its byte representation. + /// + /// Decodes a `Float32` from a `ByteBuffer`. + /// + /// This covers the `float32` type of Bluetooth. + /// + /// - Note: For reference, the basic types in Bluetooth are illustrated in + /// Bluetooth Core Specification, Volume 1, Part E, 3.9.1 Basic types. + /// - Parameter byteBuffer: The bytebuffer to decode from. + public init?(from byteBuffer: inout ByteBuffer) { + guard let bitPattern = UInt32(from: &byteBuffer) else { + return nil + } + + self.init(bitPattern: bitPattern) + } + + /// Encodes a float to its byte representation. + /// + /// Encodes a `Float32` into a `ByteBuffer`. + /// + /// This covers the `float32` type of Bluetooth. + /// + /// - Note: For reference, the basic types in Bluetooth are illustrated in + /// Bluetooth Core Specification, Volume 1, Part E, 3.9.1 Basic types. + /// - Parameter byteBuffer: The bytebuffer to decode to. + public func encode(to byteBuffer: inout ByteBuffer) { + bitPattern.encode(to: &byteBuffer) + } +} + + +extension Float64: ByteCodable { + /// Decodes a float from its byte representation. + /// + /// Decodes a `Float64` from a `ByteBuffer`. + /// + /// This covers the `float64` type of Bluetooth. + /// + /// - Note: For reference, the basic types in Bluetooth are illustrated in + /// Bluetooth Core Specification, Volume 1, Part E, 3.9.1 Basic types. + /// - Parameter byteBuffer: The bytebuffer to decode from. + public init?(from byteBuffer: inout ByteBuffer) { + guard let bitPattern = UInt64(from: &byteBuffer) else { + return nil + } + + self.init(bitPattern: bitPattern) + } + + /// Encodes a float to its byte representation. + /// + /// Encodes a `Float64` into a `ByteBuffer`. + /// + /// This covers the `float64` type of Bluetooth. + /// + /// - Note: For reference, the basic types in Bluetooth are illustrated in + /// Bluetooth Core Specification, Volume 1, Part E, 3.9.1 Basic types. + /// - Parameter byteBuffer: The bytebuffer to decode to. + public func encode(to byteBuffer: inout ByteBuffer) { + bitPattern.encode(to: &byteBuffer) + } +} diff --git a/Sources/SpeziBluetooth/Configuration/DiscoveryConfiguration.swift b/Sources/SpeziBluetooth/Configuration/DiscoveryConfiguration.swift index f47286f1..15ecadd2 100644 --- a/Sources/SpeziBluetooth/Configuration/DiscoveryConfiguration.swift +++ b/Sources/SpeziBluetooth/Configuration/DiscoveryConfiguration.swift @@ -8,7 +8,7 @@ /// Describes how to discover a given ``BluetoothDevice``. -public struct DiscoveryConfiguration { +public struct DiscoveryConfiguration: Sendable { let discoveryCriteria: DiscoveryCriteria let anyDeviceType: any BluetoothDevice.Type diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift index 29012278..257ed477 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift @@ -6,6 +6,7 @@ // SPDX-License-Identifier: MIT // + import CoreBluetooth import NIO import Observation @@ -13,11 +14,6 @@ import OrderedCollections import OSLog -private struct IsRunningBluetoothQueue { - init() {} -} - - /// Connect and communicate with Bluetooth devices. /// /// This module allows to connect and communicate with Bluetooth devices using modern programming paradigms. @@ -50,7 +46,7 @@ private struct IsRunningBluetoothQueue { /// ### Searching for nearby devices /// /// You can scan for nearby devices using the ``scanNearbyDevices(autoConnect:)`` and stop scanning with ``stopScanning()``. -/// All discovered peripherals will be populated through the ``nearbyPeripherals`` or ``nearbyPeripheralsView`` properties. +/// All discovered peripherals will be populated through the ``nearbyPeripherals`` properties. /// /// Refer to the documentation of ``BluetoothPeripheral`` on how to interact with a Bluetooth peripheral. /// @@ -71,15 +67,37 @@ private struct IsRunningBluetoothQueue { /// /// ### Discovering nearby Peripherals /// - ``nearbyPeripherals`` -/// - ``nearbyPeripheralsView`` /// - ``scanNearbyDevices(autoConnect:)`` /// - ``stopScanning()`` -@Observable -public class BluetoothManager { +public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable:this type_body_length + @Observable + class ObservableStorage: ValueObservable { + var state: BluetoothState = .unknown { + didSet { + _$simpleRegistrar.triggerDidChange(for: \.state, on: self) + } + } + var isScanning = false { + didSet { + _$simpleRegistrar.triggerDidChange(for: \.isScanning, on: self) + } + } + var discoveredPeripherals: OrderedDictionary = [:] { + didSet { + _$simpleRegistrar.triggerDidChange(for: \.discoveredPeripherals, on: self) + } + } + + // swiftlint:disable:next identifier_name + @ObservationIgnored var _$simpleRegistrar = ValueObservationRegistrar() + + init() {} + } + private let logger = Logger(subsystem: "edu.stanford.spezi.bluetooth", category: "BluetoothManager") - /// The dispatch queue for all Bluetooth related functionality. This is serial (not `.concurrent`) to ensure synchronization. - private let dispatchQueue = DispatchQueue(label: "edu.stanford.spezi.bluetooth", qos: .userInitiated) - private let isRunningBluetoothQueueKey = DispatchSpecificKey() + /// The serial executor for all Bluetooth related functionality. + let bluetoothQueue: DispatchSerialQueue + /// The device descriptions describing how nearby devices are discovered. private let configuredDevices: Set @@ -88,54 +106,81 @@ public class BluetoothManager { /// The time interval after which an advertisement is considered stale and the device is removed. private let advertisementStaleInterval: TimeInterval - @Lazy @ObservationIgnored private var centralManager: CBCentralManager - @ObservationIgnored private var centralDelegate: Delegate? // swiftlint:disable:this weak_delegate - @ObservationIgnored private var isScanningObserver: KVOStateObserver? + @Lazy private var centralManager: CBCentralManager + private var centralDelegate: Delegate? + private var isScanningObserver: KVOStateObserver? + + private let _storage: ObservableStorage /// Represents the current state of the Bluetooth Manager. - public private(set) var state: BluetoothState + nonisolated public private(set) var state: BluetoothState { + get { + _storage.state + } + set { + _storage.state = newValue + } + } /// Whether or not we are currently scanning for nearby devices. - public private(set) var isScanning = false + nonisolated public private(set) var isScanning: Bool { + get { + _storage.isScanning + } + set { + _storage.isScanning = newValue + } + } /// The list of discovered and connected bluetooth devices indexed by their identifier UUID. /// The state is isolated to our `dispatchQueue`. - private(set) var discoveredPeripherals: OrderedDictionary = [:] + var discoveredPeripherals: OrderedDictionary { + get { + _storage.discoveredPeripherals + } + _modify { + yield &_storage.discoveredPeripherals + } + set { + _storage.discoveredPeripherals = newValue + } + } + /// Track if we should be scanning. This is important to check which resources should stay allocated. private var shouldBeScanning = false /// The identifier of the last manually disconnected device. /// This is to avoid automatically reconnecting to a device that was manually disconnected. private var lastManuallyDisconnectedDevice: UUID? - @ObservationIgnored private var autoConnect = false - @ObservationIgnored private var autoConnectItem: DispatchWorkItem? - @ObservationIgnored private var staleTimer: DiscoveryStaleTimer? + private var autoConnect = false + private var autoConnectItem: BluetoothWorkItem? + private var staleTimer: DiscoveryStaleTimer? /// Checks and determines the device candidate for auto-connect. /// - /// Checks if there is exactly one, disconnected peripheral that can be used for the auto-connect feature. + /// This will deliver a matching candidate with the lowest RSSI if we don't have a device already connected, + /// and there wasn't a device manually disconnected. private var autoConnectDeviceCandidate: BluetoothPeripheral? { - guard discoveredPeripherals.count == 1, - let firstDevice = discoveredPeripherals.values.first, - firstDevice.state == .disconnected, - firstDevice.id != lastManuallyDisconnectedDevice else { + guard autoConnect else { + return nil // auto-connect is disabled + } + + guard lastManuallyDisconnectedDevice == nil && !hasConnectedDevices else { return nil } - return firstDevice + let sortedCandidates = discoveredPeripherals.values + .filter { $0.cbPeripheral.state == .disconnected } + .sorted { lhs, rhs in + lhs.assumeIsolated { $0.rssi } < rhs.assumeIsolated { $0.rssi } + } + + return sortedCandidates.first } /// The list of nearby bluetooth devices. /// /// This array contains all discovered bluetooth peripherals and those with which we are currently connected. - public var nearbyPeripherals: [BluetoothPeripheral] { - Array(discoveredPeripherals.values) - } - - /// The list of nearby bluetooth devices as a view. - /// - /// This is similar to the ``nearbyPeripherals``. However, it doesn't copy all elements into its own array - /// but exposes the `Values` type of the underlying Dictionary implementation. - public var nearbyPeripheralsView: OrderedDictionary.Values { - discoveredPeripherals.values + nonisolated public var nearbyPeripherals: [BluetoothPeripheral] { + Array(_storage.discoveredPeripherals.values) } /// The set of serviceIds we request to discover upon scanning. @@ -148,10 +193,6 @@ public class BluetoothManager { return discoveryIds.isEmpty ? nil : discoveryIds } - private var isRunningWithinQueue: Bool { - DispatchQueue.getSpecific(key: isRunningBluetoothQueueKey) != nil - } - /// Initialize a new Bluetooth Manager with provided device description and optional configuration options. /// - Parameters: @@ -164,73 +205,66 @@ public class BluetoothManager { minimumRSSI: Int = Defaults.defaultMinimumRSSI, advertisementStaleInterval: TimeInterval = Defaults.defaultStaleTimeout ) { + let dispatchQueue = DispatchQueue(label: "edu.stanford.spezi.bluetooth", qos: .userInitiated) + guard let serialQueue = dispatchQueue as? DispatchSerialQueue else { + preconditionFailure("Dispatch queue \(dispatchQueue.label) was not initialized to be serial!") + } + + self.bluetoothQueue = serialQueue + self.configuredDevices = devices self.minimumRSSI = minimumRSSI self.advertisementStaleInterval = max(1, advertisementStaleInterval) - self.state = .unknown + self._storage = ObservableStorage() - self.centralDelegate = Delegate(self) - - // This helps us later to identity that we are running within the bluetooth dispatch queue! - self.dispatchQueue.setSpecific(key: isRunningBluetoothQueueKey, value: IsRunningBluetoothQueue()) + let delegate = Delegate() + self.centralDelegate = delegate + self._centralManager = Lazy() // The Bluetooth permission alert shows every time when a CBCentralManager is initialized. // If we already have permissions the a power alert will be shown if the user has Bluetooth disabled. // To have those alerts shown at the right time (and repeatedly), we lazily initialize the CBCentralManager and also deinit it // once we don't use it anymore (we are not scanning and no device is currently connected). // All this state handling happens here within the closures passed to the `Lazy` property wrapper. - _centralManager = Lazy { [weak self] in + _centralManager.supply { [weak self] in + // As `centralManager` is actor isolated, the initializer closure and the onCleanup closure + // can both be assumed to be isolated to the BluetoothManager. + let centralDelegate = self?.assumeIsolated { $0.centralDelegate } let central = CBCentralManager( - delegate: self?.centralDelegate, - queue: self?.dispatchQueue, + delegate: centralDelegate, + queue: serialQueue, options: [CBCentralManagerOptionShowPowerAlertKey: true] ) - if let self = self { - self.isScanningObserver = KVOStateObserver(receiver: self, entity: central, property: \.isScanning) + self?.assumeIsolated { manager in + manager.isScanningObserver = KVOStateObserver(receiver: manager, entity: central, property: \.isScanning) } - self?.logger.debug("Initialized CBCentralManager.") + self?.logger.debug("Initialized the underlying CBCentralManager.") return central } onCleanup: { [weak self] in - self?.logger.debug("Destroyed CBCentralManager.") - self?.isScanningObserver = nil + self?.logger.debug("Destroyed the underlying CBCentralManager.") + self?.assumeIsolated { manager in + manager.isScanningObserver = nil + } } + + // delay using self so we don't leave isolation + delegate.initManager(self) } /// Scan for nearby bluetooth devices. /// /// Scans on nearby devices based on the ``DeviceDescription`` provided in the initializer. - /// All discovered devices can be accessed through the ``nearbyPeripherals`` or ``nearbyPeripheralsView`` property. + /// All discovered devices can be accessed through the ``nearbyPeripherals`` property. /// /// - Tip: Scanning for nearby devices can easily be managed via the ``SwiftUI/View/scanNearbyDevices(enabled:with:autoConnect:)`` /// modifier. /// /// - Parameter autoConnect: If enabled, the bluetooth manager will automatically connect to /// the nearby device if only one is found for a given time threshold. - public func scanNearbyDevices(autoConnect: Bool = false) async { - await withCheckedContinuation { continuation in - dispatchQueue.async { - self._scanNearbyDevices(autoConnect: autoConnect) - continuation.resume() - } - } - } - - /// Stop scanning for nearby bluetooth devices. - public func stopScanning() async { - await withCheckedContinuation { continuation in - dispatchQueue.async { - self._stopScanning() - continuation.resume() - } - } - } - - private func _scanNearbyDevices(autoConnect: Bool) { - assert(isRunningWithinQueue, "\(#function) was run outside the bluetooth queue. This introduces data races.") - + public func scanNearbyDevices(autoConnect: Bool = false) { guard !isScanning else { return } @@ -249,16 +283,17 @@ public class BluetoothManager { } } - /// Reactive scan upon powered on. - private func handlePoweredOn() { - if shouldBeScanning && !isScanning { - _scanNearbyDevices(autoConnect: autoConnect) + /// If scanning, toggle the auto-connect feature. + /// - Parameter autoConnect: Flag to turn on or off auto-connect + @_documentation(visibility: internal) + public func setAutoConnect(_ autoConnect: Bool) { + if self.shouldBeScanning { + self.autoConnect = autoConnect } } - private func _stopScanning(deinit isDeinit: Bool = false) { - assert(isDeinit || isRunningWithinQueue, "\(#function) was run outside the bluetooth queue. This introduces data races.") - + /// Stop scanning for nearby bluetooth devices. + public func stopScanning() { if isScanning { // transitively checks for state == .poweredOn centralManager.stopScan() isScanning = centralManager.isScanning // ensure this is synced @@ -271,13 +306,22 @@ public class BluetoothManager { } } - private func handleStoppedScanning() { - assert(isRunningWithinQueue, "\(#function) was run outside the bluetooth queue. This introduces data races.") + func onChange(of keyPath: KeyPath, perform closure: @escaping (Value) -> Void) { + _storage.onChange(of: keyPath, perform: closure) + } + + /// Reactive scan upon powered on. + private func handlePoweredOn() { + if shouldBeScanning && !isScanning { + scanNearbyDevices(autoConnect: autoConnect) + } + } + private func handleStoppedScanning() { self.autoConnect = false - let devices = nearbyPeripheralsView.filter { device in - device.state == .disconnected + let devices = discoveredPeripherals.values.filter { device in + device.cbPeripheral.state == .disconnected } for device in devices { @@ -290,17 +334,17 @@ public class BluetoothManager { } private func clearDiscoveredPeripheral(forKey id: UUID) { - assert(isRunningWithinQueue, "\(#function) was run outside the bluetooth queue. This introduces data races.") - discoveredPeripherals.removeValue(forKey: id) + if lastManuallyDisconnectedDevice == id { + lastManuallyDisconnectedDevice = nil + } + checkForCentralDeinit() } /// De-initializes the Bluetooth Central if we currently don't use it. private func checkForCentralDeinit() { - assert(isRunningWithinQueue, "\(#function) was run outside the bluetooth queue. This introduces data races.") - if !shouldBeScanning && discoveredPeripherals.isEmpty { _centralManager.destroy() self.state = .unknown @@ -308,25 +352,15 @@ public class BluetoothManager { } } - func connect(peripheral: BluetoothPeripheral) async { + func connect(peripheral: BluetoothPeripheral) { logger.debug("Trying to connect to \(peripheral.cbPeripheral.debugIdentifier) ...") - - await withCheckedContinuation { continuation in - dispatchQueue.async { [weak self] in - guard let self = self else { - return - } - let cancelled = self.cancelStaleTask(for: peripheral) + let cancelled = self.cancelStaleTask(for: peripheral) - self.centralManager.connect(peripheral.cbPeripheral, options: nil) + self.centralManager.connect(peripheral.cbPeripheral, options: nil) - if cancelled { - self.scheduleStaleTaskForOldestActivityDevice(ignore: peripheral) - } - - continuation.resume() - } + if cancelled { + self.scheduleStaleTaskForOldestActivityDevice(ignore: peripheral) } } @@ -334,44 +368,31 @@ public class BluetoothManager { logger.debug("Disconnecting peripheral \(peripheral.cbPeripheral.debugIdentifier) ...") // stale timer is handled in the delegate method centralManager.cancelPeripheralConnection(peripheral.cbPeripheral) - lastManuallyDisconnectedDevice = peripheral.id - } - func findDeviceDescription(for advertisementData: AdvertisementData) -> DeviceDescription? { - configuredDevices.find(for: advertisementData, logger: logger) + self.lastManuallyDisconnectedDevice = peripheral.id } // MARK: - Auto Connect private func kickOffAutoConnect() { - assert(isRunningWithinQueue, "\(#function) was run outside the bluetooth queue. This introduces data races.") - - guard autoConnect else { - return // auto connect is disabled - } - guard autoConnectItem == nil && autoConnectDeviceCandidate != nil else { return } - let item = DispatchWorkItem { [weak self] in - guard let self = self else { - return - } - - self.autoConnectItem = nil + let item = BluetoothWorkItem(manager: self) { manager in + manager.autoConnectItem = nil - guard let candidate = self.autoConnectDeviceCandidate else { + guard let candidate = manager.autoConnectDeviceCandidate else { return } - Task { - await candidate.connect() + candidate.assumeIsolated { peripheral in + peripheral.connect() } } autoConnectItem = item - dispatchQueue.asyncAfter(deadline: .now() + .seconds(Defaults.defaultAutoConnectDebounce), execute: item) + bluetoothQueue.schedule(for: .now() + .seconds(Defaults.defaultAutoConnectDebounce), execute: item) } // MARK: - Stale Advertisement Timeout @@ -381,19 +402,19 @@ public class BluetoothManager { /// - device: The device for which the timer is scheduled for. /// - timeout: The timeout for which the timer is scheduled for. private func scheduleStaleTask(for device: BluetoothPeripheral, withTimeout timeout: TimeInterval) { - assert(isRunningWithinQueue, "\(#function) was run outside the bluetooth queue. This introduces data races.") - - let timer = DiscoveryStaleTimer(device: device.id) { [weak self] in - self?.handleStaleTask() + let timer = DiscoveryStaleTimer(device: device.id, manager: self) { manager in + manager.handleStaleTask() } self.staleTimer = timer - timer.schedule(for: timeout, in: dispatchQueue) + timer.schedule(for: timeout, in: bluetoothQueue) } private func scheduleStaleTaskForOldestActivityDevice(ignore device: BluetoothPeripheral? = nil) { if let oldestActivityDevice = oldestActivityDevice(ignore: device) { - let intervalSinceLastActivity = Date.now.timeIntervalSince(oldestActivityDevice.lastActivity) + let lastActivity = oldestActivityDevice.assumeIsolated { $0.lastActivity } + + let intervalSinceLastActivity = Date.now.timeIntervalSince(lastActivity) let nextTimeout = max(0, advertisementStaleInterval - intervalSinceLastActivity) scheduleStaleTask(for: oldestActivityDevice, withTimeout: nextTimeout) @@ -401,8 +422,6 @@ public class BluetoothManager { } private func cancelStaleTask(for device: BluetoothPeripheral) -> Bool { - assert(isRunningWithinQueue, "\(#function) was run outside the bluetooth queue. This introduces data races.") - guard let staleTimer, staleTimer.targetDevice == device.id else { return false } @@ -415,22 +434,28 @@ public class BluetoothManager { /// The device with the oldest device activity. /// - Parameter device: The device to ignore. private func oldestActivityDevice(ignore device: BluetoothPeripheral? = nil) -> BluetoothPeripheral? { - assert(isRunningWithinQueue, "\(#function) was run outside the bluetooth queue. This introduces data races.") - // when we are just interested in the min device, this operation is a bit cheaper then sorting the whole list - return nearbyPeripheralsView - .filter { $0.state == .disconnected && $0.id != device?.id } + discoveredPeripherals.values + // it's important to access the underlying state here + .filter { + $0.cbPeripheral.state == .disconnected && $0.id != device?.id + } .min { lhs, rhs in - lhs.lastActivity < rhs.lastActivity + lhs.assumeIsolated { + $0.lastActivity + } < rhs.assumeIsolated { + $0.lastActivity + } } } private func handleStaleTask() { - assert(isRunningWithinQueue, "\(#function) was run outside the bluetooth queue. This introduces data races.") staleTimer = nil // reset the timer - let staleDevices = nearbyPeripheralsView.filter { device in - device.isConsideredStale(interval: advertisementStaleInterval) + let staleDevices = discoveredPeripherals.values.filter { device in + device.assumeIsolated { isolated in + isolated.isConsideredStale(interval: advertisementStaleInterval) + } } for device in staleDevices { @@ -444,16 +469,46 @@ public class BluetoothManager { scheduleStaleTaskForOldestActivityDevice() } - + private func discardDevice(device: BluetoothPeripheral) { + if !isScanning { + device.assumeIsolated { device in + device.markLastActivity() + device.handleDisconnect() + } + clearDiscoveredPeripheral(forKey: device.id) + } else { + // we will keep discarded devices for max 2s before the stale timer kicks off + let interval = max(0, advertisementStaleInterval - 2) + + device.assumeIsolated { device in + device.markLastActivity(.now - interval) + device.handleDisconnect() + } + + // We just schedule the new timer if there is a device to schedule one for. + scheduleStaleTaskForOldestActivityDevice() + } + } + + private func isolatedUpdate(of keyPath: WritableKeyPath, _ value: Value) { + var manager = self + manager[keyPath: keyPath] = value + } + deinit { - _stopScanning(deinit: true) staleTimer?.cancel() autoConnectItem?.cancel() - self.state = .poweredOff + // non-isolated workaround for calling stopScanning() + if isScanning { + isScanning = false + _centralManager.wrappedValue.stopScan() + logger.debug("Scanning stopped") + } - discoveredPeripherals = [:] - self.centralDelegate = nil + state = .unknown + _storage.discoveredPeripherals = [:] + centralDelegate = nil logger.debug("BluetoothManager destroyed") } @@ -461,14 +516,12 @@ public class BluetoothManager { extension BluetoothManager: KVOReceiver { - func observeChange(of keyPath: KeyPath, value: V) async { + func observeChange(of keyPath: KeyPath, value: V) { switch keyPath { case \CBCentralManager.isScanning: - dispatchQueue.async { - self.isScanning = value as! Bool // swiftlint:disable:this force_cast - if !self.isScanning { - self.handleStoppedScanning() - } + self.isolatedUpdate(of: \.isScanning, value as! Bool) // swiftlint:disable:this force_cast + if !self.isScanning { + self.handleStoppedScanning() } default: break @@ -478,9 +531,14 @@ extension BluetoothManager: KVOReceiver { extension BluetoothManager: BluetoothScanner { + /// Support for the auto connect modifier. @_documentation(visibility: internal) - public var hasConnectedDevices: Bool { - !discoveredPeripherals.isEmpty + public nonisolated var hasConnectedDevices: Bool { + // We make sure to loop over all peripherals here. This ensures observability subscribes to all changing states. + // swiftlint:disable:next reduce_boolean + _storage.discoveredPeripherals.values.reduce(into: false) { partialResult, peripheral in + partialResult = partialResult || (peripheral.unsafeState.state != .disconnected) + } } } @@ -490,11 +548,11 @@ extension BluetoothManager { /// Set of default values used within the Bluetooth Manager public enum Defaults { /// The default timeout after which stale advertisements are removed. - public static let defaultStaleTimeout: TimeInterval = 10 + public static let defaultStaleTimeout: TimeInterval = 8 /// The minimum rssi of a peripheral to consider it for discovery. public static let defaultMinimumRSSI = -80 /// The default time in seconds after which we check for auto connectable devices after the initial advertisement. - public static let defaultAutoConnectDebounce: Int = 2 + public static let defaultAutoConnectDebounce: Int = 1 } } @@ -507,48 +565,51 @@ extension BluetoothManager { private weak var manager: BluetoothManager? - init(_ manager: BluetoothManager) { - self.manager = manager + override init() { super.init() } + func initManager(_ manager: BluetoothManager) { + self.manager = manager + } + + func centralManagerDidUpdateState(_ central: CBCentralManager) { + let state = BluetoothState(from: central.state) - func centralManagerDidUpdateState(_ central: CBCentralManager) { // swiftlint:disable:this cyclomatic_complexity guard let manager else { return } - switch central.state { - case .poweredOn: - // Start working with the peripheral - logger.info("CBManager is powered on") - manager.state = .poweredOn - manager.handlePoweredOn() - case .poweredOff: - logger.info("CBManager is not powered on") - manager.state = .poweredOff - case .resetting: - logger.info("CBManager is resetting") - manager.state = .poweredOff - case .unauthorized: - switch CBManager.authorization { - case .denied: - logger.log("You are not authorized to use Bluetooth") - case .restricted: - logger.log("Bluetooth is restricted") - default: - logger.log("Unexpected authorization") + // All these delegate methods are actually running on the DispatchQueue the Actor is isolated though. + // So in theory we should just be able to spring into isolation with assumeIsolated(). + // However, executing a scheduled Job is different to just running a scheduled Job in the dispatch queue + // form a Swift Runtime perspective. + // Refer to _isCurrentExecutor (checked in assumeIsolated): + // https://github.com/apple/swift/blob/9e2b97c0fd675efaa5b815748d8567d781415c8c/stdlib/public/Concurrency/Actor.cpp#L317 + // Also refer to te implementation of assumeIsolated: + // https://github.com/apple/swift/blob/a1062d06e9f33512b0005d589e3b086a89cfcbd1/stdlib/public/Concurrency/ExecutorAssertions.swift#L351-L372. + // We could just cast the closure to be isolated (nothing else does assumeIsolated), however we would not have the + // same Runtime state as an executing Task that is actor isolated. + // So whats the solution? We schedule onto a background SerialExecutor (@SpeziBluetooth) so we maintain execution + // order and make sure to capture all important state before that. + Task { @SpeziBluetooth in + await manager.isolated { manager in + manager.isolatedUpdate(of: \.state, state) + logger.info("BluetoothManager central state is now \(manager.state)") + + if case .poweredOn = state { + manager.handlePoweredOn() + } else if case .unauthorized = state { + switch CBCentralManager.authorization { + case .denied: + logger.log("Unauthorized reason: Access to Bluetooth was denied.") + case .restricted: + logger.log("Unauthorized reason: Bluetooth is restricted.") + default: + break + } + } } - manager.state = .unauthorized - case .unknown: - logger.log("CBManager state is unknown") - manager.state = .unknown - case .unsupported: - logger.log("Bluetooth is not supported on this device") - manager.state = .unsupported - @unknown default: - logger.log("A previously unknown central manager state occurred") - manager.state = .unsupported } } @@ -560,50 +621,62 @@ extension BluetoothManager { // swiftlint:disable:next legacy_objc_type rssi: NSNumber ) { - guard let manager, manager.isScanning else { + guard let manager else { return } - // rssi of 127 is a magic value signifying unavailability of the value. - guard rssi.intValue >= manager.minimumRSSI, rssi.intValue != 127 else { // ensure the signal strength is not too low - guard let device = manager.discoveredPeripherals[peripheral.identifier], - device.state == .disconnected else { - return - } + Task { @SpeziBluetooth in + await manager.isolated { manager in + guard manager.isScanning else { + return + } - // device is now out of range, just clear it immediately. - manager.clearDiscoveredPeripheral(forKey: device.id) - return // logging this would just be to verbose, so we don't. - } + // rssi of 127 is a magic value signifying unavailability of the value. + guard rssi.intValue >= manager.minimumRSSI, rssi.intValue != 127 else { // ensure the signal strength is not too low + return // logging this would just be to verbose, so we don't. + } - let data = AdvertisementData(advertisementData: advertisementData) + let data = AdvertisementData(advertisementData: advertisementData) - // check if we already seen this device! - if let device = manager.discoveredPeripherals[peripheral.identifier] { - device.update(advertisement: data, rssi: rssi.intValue) + // check if we already seen this device! - if manager.cancelStaleTask(for: device) { - // current device was earliest to go stale, schedule timeout for next oldest device - manager.scheduleStaleTaskForOldestActivityDevice() - } + let discoveredPeripherals = manager.discoveredPeripherals + if let device = discoveredPeripherals[peripheral.identifier] { + device.assumeIsolated { device in + device.markLastActivity() + device.update(advertisement: data, rssi: rssi.intValue) + } - manager.kickOffAutoConnect() - return - } + if manager.cancelStaleTask(for: device) { + // current device was earliest to go stale, schedule timeout for next oldest device + manager.scheduleStaleTaskForOldestActivityDevice() + } - logger.debug("Discovered peripheral \(peripheral.debugIdentifier) at \(rssi.intValue) dB (data: \(advertisementData))") + manager.kickOffAutoConnect() + return + } - let device = BluetoothPeripheral(manager: manager, peripheral: peripheral, advertisementData: data, rssi: rssi.intValue) - manager.discoveredPeripherals[peripheral.identifier] = device // save local-copy, such CB doesn't deallocate it + logger.debug("Discovered peripheral \(peripheral.debugIdentifier) at \(rssi.intValue) dB (data: \(advertisementData))") + let device = BluetoothPeripheral( + manager: manager, + peripheral: peripheral, + advertisementData: data, + rssi: rssi.intValue + ) + // save local-copy, such CB doesn't deallocate it + manager.discoveredPeripherals.updateValue(device, forKey: peripheral.identifier) - if manager.staleTimer == nil { - // There is no stale timer running. So new device will be the one with the oldest activity. Schedule ... - manager.scheduleStaleTask(for: device, withTimeout: manager.advertisementStaleInterval) - } - manager.kickOffAutoConnect() + if manager.staleTimer == nil { + // There is no stale timer running. So new device will be the one with the oldest activity. Schedule ... + manager.scheduleStaleTask(for: device, withTimeout: manager.advertisementStaleInterval) + } + + manager.kickOffAutoConnect() + } + } } func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { @@ -611,15 +684,20 @@ extension BluetoothManager { return } - guard let device = manager.discoveredPeripherals[peripheral.identifier] else { - logger.error("Received didConnect for unknown peripheral \(peripheral.debugIdentifier). Cancelling connection ...") - manager.centralManager.cancelPeripheralConnection(peripheral) - return - } - - logger.debug("Peripheral \(peripheral.debugIdentifier) connected.") - Task { - await device.handleConnect() + Task { @SpeziBluetooth in + await manager.isolated { manager in + let discoveredPeripherals = manager.discoveredPeripherals + guard let device = discoveredPeripherals[peripheral.identifier] else { + logger.error("Received didConnect for unknown peripheral \(peripheral.debugIdentifier). Cancelling connection ...") + manager.centralManager.cancelPeripheralConnection(peripheral) + return + } + + logger.debug("Peripheral \(peripheral.debugIdentifier) connected.") + device.assumeIsolated { device in + device.handleConnect(consider: manager.configuredDevices) + } + } } } @@ -631,22 +709,27 @@ extension BluetoothManager { // Documentation reads: "Because connection attempts don’t time out, a failed connection usually indicates a transient issue, // in which case you may attempt connecting to the peripheral again." - guard let device = manager.discoveredPeripherals[peripheral.identifier] else { - logger.warning("Unknown peripheral \(peripheral.debugIdentifier) failed with error: \(String(describing: error))") - manager.centralManager.cancelPeripheralConnection(peripheral) - return - } - - if let error { - logger.error("Failed to connect to \(peripheral): \(error)") - } else { - logger.error("Failed to connect to \(peripheral)") + Task { @SpeziBluetooth in + await manager.isolated { manager in + let discoveredPeripherals = manager.discoveredPeripherals + guard let device = discoveredPeripherals[peripheral.identifier] else { + logger.warning("Unknown peripheral \(peripheral.debugIdentifier) failed with error: \(String(describing: error))") + manager.centralManager.cancelPeripheralConnection(peripheral) + return + } + + if let error { + logger.error("Failed to connect to \(peripheral): \(error)") + } else { + logger.error("Failed to connect to \(peripheral)") + } + + // just to make sure + manager.centralManager.cancelPeripheralConnection(device.cbPeripheral) + + manager.discardDevice(device: device) + } } - - // just to make sure - manager.centralManager.cancelPeripheralConnection(device.cbPeripheral) - - discardDevice(device: device) } @@ -655,38 +738,22 @@ extension BluetoothManager { return } - guard let device = manager.discoveredPeripherals[peripheral.identifier] else { - logger.error("Received didDisconnect for unknown peripheral \(peripheral.debugIdentifier).") - return - } - - if let error { - logger.debug("Peripheral \(peripheral.debugIdentifier) disconnected due to an error: \(error)") - } else { - logger.debug("Peripheral \(peripheral.debugIdentifier) disconnected.") - } - - discardDevice(device: device) - } - - - private func discardDevice(device: BluetoothPeripheral) { - guard let manager else { - return - } - - assert(manager.isRunningWithinQueue, "\(#function) was run outside the bluetooth queue. This introduces data races.") - - if !manager.isScanning { - device.handleDisconnect() - manager.clearDiscoveredPeripheral(forKey: device.id) - } else { - // we will keep discarded devices for 500ms before the stale timer kicks off - let interval = max(0, manager.advertisementStaleInterval - 0.5) - device.handleDisconnect(disconnectActivityInterval: interval) - - // We just schedule the new timer if there is a device to schedule one for. - manager.scheduleStaleTaskForOldestActivityDevice() + Task { @SpeziBluetooth in + await manager.isolated { manager in + let discoveredPeripherals = manager.discoveredPeripherals + guard let device = discoveredPeripherals[peripheral.identifier] else { + logger.error("Received didDisconnect for unknown peripheral \(peripheral.debugIdentifier).") + return + } + + if let error { + logger.debug("Peripheral \(peripheral.debugIdentifier) disconnected due to an error: \(error)") + } else { + logger.debug("Peripheral \(peripheral.debugIdentifier) disconnected.") + } + + manager.discardDevice(device: device) + } } } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift index fe9d864a..412bddc6 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift @@ -11,6 +11,12 @@ import Foundation import OSLog +enum CharacteristicOnChangeHandler { + case value(_ closure: (Data) -> Void) + case instance(_ closure: (GATTCharacteristic?) -> Void) +} + + /// A nearby Bluetooth peripheral. /// /// This class represents a nearby Bluetooth peripheral. @@ -24,7 +30,11 @@ import OSLog /// - ``state`` /// - ``rssi`` /// - ``advertisementData`` +/// +/// ### Accessing Services /// - ``services`` +/// - ``getService(id:)`` +/// - ``getCharacteristic(id:on:)`` /// /// ### Managing Connection /// - ``connect()`` @@ -37,16 +47,18 @@ import OSLog /// - ``write(data:for:)`` /// - ``writeWithoutResponse(data:for:)`` /// -/// ### Notifications -/// - ``registerNotifications(service:characteristic:_:)`` -/// - ``registerNotifications(for:_:)`` -/// - ``CharacteristicNotification`` -/// - ``BluetoothNotificationHandler`` +/// ### Notifications and handling changes +/// - ``enableNotifications(_:serviceId:characteristicId:)`` +/// - ``registerOnChangeHandler(service:characteristic:_:)`` +/// - ``registerOnChangeHandler(for:_:)`` +/// - ``OnChangeRegistration`` /// /// ### Retrieving the latest signal strength /// - ``readRSSI()`` -public actor BluetoothPeripheral { +public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this type_body_length private let logger = Logger(subsystem: "edu.stanford.spezi.bluetooth", category: "BluetoothDevice") + /// The serial DispatchQueue shared by the Bluetooth Manager. + let bluetoothQueue: DispatchSerialQueue private weak var manager: BluetoothManager? private let peripheral: CBPeripheral @@ -54,65 +66,138 @@ public actor BluetoothPeripheral { private let delegate: Delegate private let stateObserver: KVOStateObserver - /// Ongoing accessed indexed by characteristic uuid. - private var ongoingAccesses: [CBCharacteristic: CharacteristicAccessContinuation] = [:] + /// Observable state container for local state. + private let _storage: PeripheralStorage + + + /// Ongoing accessed per characteristic. + private var characteristicAccesses = CharacteristicAccesses() + /// Protecting concurrent access to an ongoing write without response. + private let writeWithoutResponseAccess = AsyncSemaphore() /// Continuation for the current write without response access. - private var writeWithoutResponseAccess: [CheckedContinuation] = [] + private var writeWithoutResponseContinuation: CheckedContinuation? + /// Protecting concurrent access to an ongoing rssi read access. + private let rssiAccess = AsyncSemaphore() /// Continuation for a currently ongoing rssi read access. - private var rssiReadAccess: [CheckedContinuation] = [] + private var rssiContinuation: CheckedContinuation? - private var notificationHandlers: [CharacteristicLocator: [UUID: BluetoothNotificationHandler]] = [:] + /// On-change handler registrations for all characteristics. + private var onChangeHandlers: [CharacteristicLocator: [UUID: CharacteristicOnChangeHandler]] = [:] + /// The list of characteristics that are requested to enable notifications. + private var notifyRequested: Set = [] - /// Observable state container for local state. - private let stateContainer: PeripheralStateContainer + + /// The list of requested characteristic uuids indexed by service uuids. + private var requestedCharacteristics: [CBUUID: Set?]? // swiftlint:disable:this discouraged_optional_collection + /// A set of service ids we are currently awaiting characteristics discovery for + private var servicesAwaitingCharacteristicsDiscovery: Set = [] nonisolated var cbPeripheral: CBPeripheral { peripheral } + nonisolated var unsafeState: PeripheralStorage { + _storage + } + /// The name of the peripheral. - public nonisolated var name: String? { - stateContainer.name + nonisolated public var name: String? { + _storage.name + } + + nonisolated private(set) var localName: String? { + get { + _storage.localName + } + set { + _storage.update(localName: newValue) + } + } + + nonisolated private(set) var peripheralName: String? { + get { + _storage.peripheralName + } + set { + _storage.update(peripheralName: newValue) + } } /// The current signal strength. - public nonisolated var rssi: Int { - stateContainer.rssi + /// + /// This value is automatically updated when the device is advertising. + /// Once the device establishes a connection this has to be manually updated. + nonisolated public private(set) var rssi: Int { + get { + _storage.rssi + } + set { + _storage.update(rssi: newValue) + } } /// The advertisement data of the last bluetooth advertisement. - public nonisolated var advertisementData: AdvertisementData { - stateContainer.advertisementData + nonisolated public private(set) var advertisementData: AdvertisementData { + get { + _storage.advertisementData + } + set { + _storage.update(advertisementData: newValue) + } } /// The current peripheral device state. - public nonisolated var state: PeripheralState { - stateContainer.state + nonisolated public internal(set) var state: PeripheralState { + get { + _storage.state + } + set { + _storage.update(state: newValue) + } } /// The list of discovered services. /// /// Services are discovered automatically upon connection - public nonisolated var services: [CBService]? { // swiftlint:disable:this discouraged_optional_collection - stateContainer.services + nonisolated public private(set) var services: [GATTService]? { // swiftlint:disable:this discouraged_optional_collection + get { + _storage.services + } + set { + if let newValue { + _storage.assign(services: newValue) + } + } } - nonisolated var lastActivity: Date { - if case .disconnected = state { - stateContainer.lastActivity - } else { - // we are currently connected or connecting/disconnecting, therefore last activity is defined as "now" - .now + private(set) var lastActivity: Date { + get { + if case .disconnected = peripheral.state { + _storage.lastActivity + } else { + // we are currently connected or connecting/disconnecting, therefore last activity is defined as "now" + .now + } + } + set { + _storage.update(lastActivity: newValue) } } - init(manager: BluetoothManager, peripheral: CBPeripheral, advertisementData: AdvertisementData, rssi: Int) { + init( + manager: BluetoothManager, + peripheral: CBPeripheral, + advertisementData: AdvertisementData, + rssi: Int + ) { + self.bluetoothQueue = manager.bluetoothQueue + self.manager = manager self.peripheral = peripheral - self.stateContainer = PeripheralStateContainer( - name: peripheral.name, + self._storage = PeripheralStorage( + peripheralName: peripheral.name, rssi: rssi, advertisementData: advertisementData, state: peripheral.state @@ -140,13 +225,15 @@ public actor BluetoothPeripheral { /// not wait till the connection was completed successfully. /// /// - Note: You might want to verify via the ``AdvertisementData/isConnectable`` property that the device is connectable. - public func connect() async { + public func connect() { guard let manager else { logger.warning("Tried to connect an orphaned bluetooth peripheral!") return } - await manager.connect(peripheral: self) + manager.assumeIsolated { manager in + manager.connect(peripheral: self) + } } /// Disconnect the ongoing connection to the peripheral. @@ -160,18 +247,39 @@ public actor BluetoothPeripheral { removeAllNotifications() - manager.disconnect(peripheral: self) + manager.assumeIsolated { manager in + manager.disconnect(peripheral: self) + } + // ensure that it is updated instantly. + self.isolatedUpdate(of: \.state, PeripheralState(from: peripheral.state)) } - func handleConnect() { - guard let manager else { - logger.warning("Tried handling connection attempt for an orphaned bluetooth peripheral!") - return + /// Retrieve a service. + /// - Parameter id: The Bluetooth service id. + /// - Returns: The service instance if present. + public func getService(id: CBUUID) -> GATTService? { + services?.first { service in + service.uuid == id } + } + + /// Retrieve a characteristic. + /// - Parameters: + /// - characteristicId: The Bluetooth characteristic id. + /// - serviceId: The Bluetooth service id. + /// - Returns: The characteristic instance if present. + public func getCharacteristic(id characteristicId: CBUUID, on serviceId: CBUUID) -> GATTCharacteristic? { + getService(id: serviceId)?.getCharacteristic(id: characteristicId) + } + + func onChange(of keyPath: KeyPath, perform closure: @escaping (Value) -> Void) { + _storage.onChange(of: keyPath, perform: closure) + } - if let description = manager.findDeviceDescription(for: advertisementData), + func handleConnect(consider configuredDevices: Set) { + if let description = configuredDevices.find(for: advertisementData, logger: logger), let services = description.services { - stateContainer.requestedCharacteristics = services.reduce(into: [CBUUID: Set?]()) { result, configuration in + requestedCharacteristics = services.reduce(into: [CBUUID: Set?]()) { result, configuration in if let characteristics = configuration.characteristics { result[configuration.serviceId, default: []]?.formUnion(characteristics) } else if result[configuration.serviceId] == nil { @@ -180,63 +288,58 @@ public actor BluetoothPeripheral { } } else { // all services will be discovered - stateContainer.requestedCharacteristics = nil + requestedCharacteristics = nil } - self.stateContainer.state = .init(from: peripheral.state) // ensure that it is updated instantly. + // ensure that it is updated instantly. + self.isolatedUpdate(of: \.state, PeripheralState(from: peripheral.state)) logger.debug("Discovering services for \(self.peripheral.debugIdentifier) ...") - peripheral.discoverServices(stateContainer.requestedCharacteristics.map { Array($0.keys) }) + let services = requestedCharacteristics.map { Array($0.keys) } + + if let services, services.isEmpty { + _storage.signalFullyDiscovered() + } else { + peripheral.discoverServices(requestedCharacteristics.map { Array($0.keys) }) + } } /// Handles a disconnect or failed connection attempt. - nonisolated func handleDisconnect(disconnectActivityInterval: TimeInterval = 0) { - self.stateContainer.state = .init(from: peripheral.state) // ensure that it is updated instantly. - self.stateContainer.lastActivity = Date.now - disconnectActivityInterval + func handleDisconnect() { + // ensure that it is updated instantly. + self.isolatedUpdate(of: \.state, PeripheralState(from: peripheral.state)) - Task { - await clearAccesses() - } - } + // clear all the ongoing access - func clearAccesses() { - for continuation in writeWithoutResponseAccess { - continuation.resume() - } - writeWithoutResponseAccess.removeAll() + self.requestedCharacteristics = nil + self.servicesAwaitingCharacteristicsDiscovery.removeAll() - for continuation in rssiReadAccess { - continuation.resume(throwing: BluetoothError.notPresent) + if let services { + self.invalidateServices(Set(services.map { $0.uuid })) } - rssiReadAccess.removeAll() - let ongoingAccesses = ongoingAccesses - self.ongoingAccesses.removeAll() + characteristicAccesses.cancelAll() + writeWithoutResponseAccess.cancelAll() + rssiAccess.cancelAll() - for (_, access) in ongoingAccesses { - switch access { - case let .read(continuations, queued): - for continuation in continuations { - continuation.resume(throwing: BluetoothError.notPresent) - } - for queue in queued { - queue.resume() - } - case let .write(continuation, queued): - continuation.resume(throwing: BluetoothError.notPresent) - for queue in queued { - queue.resume() - } - } + if let writeWithoutResponseContinuation { + self.writeWithoutResponseContinuation = nil + writeWithoutResponseContinuation.resume() + } + if let rssiContinuation { + self.rssiContinuation = nil + rssiContinuation.resume(throwing: CancellationError()) } } - nonisolated func update(advertisement: AdvertisementData, rssi: Int) { - self.stateContainer.lastActivity = .now // fine to be non-isolated. We always just write the latest data + func markLastActivity(_ lastActivity: Date = .now) { + self.lastActivity = lastActivity + } - // this could be a problem to be non-isolated, however, we know this will always come from the Bluetooth queue that is serial. - stateContainer.advertisementData = advertisement - stateContainer.rssi = rssi + func update(advertisement: AdvertisementData, rssi: Int) { + self.isolatedUpdate(of: \.localName, advertisement.localName) + self.isolatedUpdate(of: \.advertisementData, advertisement) + self.isolatedUpdate(of: \.rssi, rssi) } /// Determines if the device is considered stale. @@ -245,78 +348,111 @@ public actor BluetoothPeripheral { /// the provided interval. /// - Parameter interval: The time interval after which the device is considered stale. /// - Returns: True if the device is considered stale given the above criteria. - nonisolated func isConsideredStale(interval: TimeInterval) -> Bool { - state == .disconnected && lastActivity.addingTimeInterval(interval) < .now + func isConsideredStale(interval: TimeInterval) -> Bool { + peripheral.state == .disconnected && lastActivity.addingTimeInterval(interval) < .now } - /// Register a notification handler for a characteristic. + /// Register a on-change handler for a characteristic. /// - /// This method registers a notification handler for the provided characteristic. + /// This method registers a on-change handler for the provided characteristic. /// /// - Note: Make sure that you don't create a retain cycle if the provided closure captures `self`. /// /// - Parameters: /// - characteristic: The characteristic to register notifications for. - /// - handler: The notification handler. - /// - @Returns: Returns the ``CharacteristicNotification`` that can be used to cancel and deregister the notification handler. - public func registerNotifications( - for characteristic: CBCharacteristic, - _ handler: @escaping BluetoothNotificationHandler - ) throws -> CharacteristicNotification { + /// - onChange: The on-change handler. + /// - @Returns: Returns the ``OnChangeRegistration`` that can be used to cancel and deregister the on-change handler. + public func registerOnChangeHandler( + for characteristic: GATTCharacteristic, + _ onChange: @escaping (Data) -> Void + ) throws -> OnChangeRegistration { guard let service = characteristic.service else { - throw BluetoothError.notPresent + throw BluetoothError.notPresent(service: nil, characteristic: characteristic.uuid) } - return registerNotifications(service: service.uuid, characteristic: characteristic.uuid, handler) + return registerOnChangeHandler(service: service.uuid, characteristic: characteristic.uuid, onChange) } - /// Register a notification handler for a characteristic. + /// Register a on-change handler for a characteristic. /// - /// This method registers a notification handler for the provide service and characteristic id. - /// - /// - Tip: It is not required that the device is connected. Notifications will be automatically enabled for the - /// respective characteristic upon device discovery. + /// This method registers a on-change handler for the provide service and characteristic id. /// /// - Note: Make sure that you don't create a retain cycle if the provided closure captures `self`. /// /// - Parameters: /// - service: The service uuid. /// - characteristic: The characteristic uuid. - /// - handler: The notification handler. - /// - @Returns: Returns the ``CharacteristicNotification`` that can be used to cancel and deregister the notification handler. - public func registerNotifications( + /// - onChange: The on-change handler. + /// - @Returns: Returns the ``OnChangeRegistration`` that can be used to cancel and deregister the on-change handler. + public func registerOnChangeHandler( service: CBUUID, characteristic: CBUUID, - _ handler: @escaping BluetoothNotificationHandler - ) -> CharacteristicNotification { - let locator = CharacteristicLocator(serviceId: service, characteristicId: characteristic) - let id = UUID() // notification handler id, used internally + _ onChange: @escaping (Data) -> Void + ) -> OnChangeRegistration { + registerCharacteristicOnChange(service: service, characteristic: characteristic, .value(onChange)) + } - notificationHandlers[locator, default: [:]] - .updateValue(handler, forKey: id) + func registerOnChangeCharacteristicHandler( + service: CBUUID, + characteristic: CBUUID, + _ onChange: @escaping (GATTCharacteristic?) -> Void + ) -> OnChangeRegistration { + registerCharacteristicOnChange(service: service, characteristic: characteristic, .instance(onChange)) + } + private func registerCharacteristicOnChange( + service: CBUUID, + characteristic: CBUUID, + _ onChange: CharacteristicOnChangeHandler + ) -> OnChangeRegistration { + let locator = CharacteristicLocator(serviceId: service, characteristicId: characteristic) + let id = UUID() // on-change handler id, used internally - // if setting notify doesn't work here, we do it upon discovery of the characteristics - trySettingNotifyValue(true, serviceId: service, characteristicId: characteristic) + let replaced = onChangeHandlers[locator, default: [:]] + .updateValue(onChange, forKey: id) + assert(replaced == nil, "onChangeHandlers are forced to be unique and shouldn't replace previous values.") - return CharacteristicNotification(peripheral: self, locator: locator, handlerId: id) + return OnChangeRegistration(peripheral: self, locator: locator, handlerId: id) } - func deregisterNotification(_ notification: CharacteristicNotification) { - deregisterNotification(locator: notification.locator, handlerId: notification.handlerId) + /// Enable or disable notifications for a given characteristic. + /// + /// - Tip: It is not required that the device is connected. Notifications will be automatically enabled for the + /// respective characteristic upon device discovery. + /// + /// - Parameters: + /// - enabled: Enable or disable notifications. + /// - serviceId: The service the characteristic lives on. + /// - characteristicId: The characteristic to notify about. + public func enableNotifications(_ enabled: Bool = true, serviceId: CBUUID, characteristicId: CBUUID) { + // swiftlint:disable:previous function_default_parameter_at_end + let id = CharacteristicLocator(serviceId: serviceId, characteristicId: characteristicId) + + if enabled { + notifyRequested.insert(id) + } else { + notifyRequested.remove(id) + } + + // if setting notify doesn't work here, we do it upon discovery of the characteristics + trySettingNotifyValue(enabled, serviceId: serviceId, characteristicId: characteristicId) } - func deregisterNotification(locator: CharacteristicLocator, handlerId: UUID) { - notificationHandlers[locator]?.removeValue(forKey: handlerId) + func deregisterOnChange(_ registration: OnChangeRegistration) { + deregisterOnChange(locator: registration.locator, handlerId: registration.handlerId) + } - trySettingNotifyValue(false, serviceId: locator.serviceId, characteristicId: locator.characteristicId) + func deregisterOnChange(locator: CharacteristicLocator, handlerId: UUID) { + onChangeHandlers[locator]?.removeValue(forKey: handlerId) } private func trySettingNotifyValue(_ notify: Bool, serviceId: CBUUID, characteristicId: CBUUID) { - if let service = services?.first(where: { $0.uuid == serviceId }), - let characteristic = service.characteristics?.first(where: { $0.uuid == characteristicId }), - characteristic.properties.contains(.notify) { - peripheral.setNotifyValue(notify, for: characteristic) + guard let characteristic = getCharacteristic(id: characteristicId, on: serviceId) else { + return + } + + if characteristic.properties.supportsNotifications { + peripheral.setNotifyValue(notify, for: characteristic.underlyingCharacteristic) } } @@ -348,14 +484,13 @@ public actor BluetoothPeripheral { /// - characteristic: The characteristic to which the value is written. /// - Returns: The response from the device. /// - Throws: Throws an `CBError` or `CBATTError` if the write fails. - public func write(data: Data, for characteristic: CBCharacteristic) async throws { - while ongoingAccesses[characteristic] != nil { - await queueRWAccess(for: characteristic) - } + public func write(data: Data, for characteristic: GATTCharacteristic) async throws { + let characteristic = characteristic.underlyingCharacteristic + let access = characteristicAccesses.makeAccess(for: characteristic) + try await access.waitCheckingCancellation() try await withCheckedThrowingContinuation { continuation in - // using updateValue as of https://github.com/apple/swift/issues/63156. Revert to subscript access with Swift 5.10 - ongoingAccesses.updateValue(.write(continuation), forKey: characteristic) + access.store(.write(continuation)) peripheral.writeValue(data, for: characteristic, type: .withResponse) } } @@ -370,17 +505,18 @@ public actor BluetoothPeripheral { /// - Parameters: /// - data: The value to write. /// - characteristic: The characteristic to which the value is written. - public func writeWithoutResponse(data: Data, for characteristic: CBCharacteristic) async { - guard writeWithoutResponseAccess.isEmpty else { - await withCheckedContinuation { continuation in - writeWithoutResponseAccess.append(continuation) - } + public func writeWithoutResponse(data: Data, for characteristic: GATTCharacteristic) async { + do { + try await writeWithoutResponseAccess.waitCheckingCancellation() + } catch { + // task got cancelled, so just throw away the written value return } await withCheckedContinuation { continuation in - writeWithoutResponseAccess.append(continuation) - peripheral.writeValue(data, for: characteristic, type: .withoutResponse) + assert(writeWithoutResponseContinuation == nil, "writeWithoutResponseAccess was unexpectedly not nil") + writeWithoutResponseContinuation = continuation + peripheral.writeValue(data, for: characteristic.underlyingCharacteristic, type: .withoutResponse) } } @@ -391,24 +527,14 @@ public actor BluetoothPeripheral { /// - Parameter characteristic: The characteristic for which you want to read the value. /// - Returns: The value that the peripheral was returned. /// - Throws: Throws an `CBError` or `CBATTError` if the read fails. - public func read(characteristic: CBCharacteristic) async throws -> Data { - // if there is already a read for this characteristic, we just piggy back onto it - if case .read(var continuations, let queued) = ongoingAccesses[characteristic] { - return try await withCheckedThrowingContinuation { continuation in - continuations.append(continuation) - // using updateValue as of https://github.com/apple/swift/issues/63156. Revert to subscript access with Swift 5.10 - ongoingAccesses.updateValue(.read(continuations, queued: queued), forKey: characteristic) - } - } + public func read(characteristic: GATTCharacteristic) async throws -> Data { + let characteristic = characteristic.underlyingCharacteristic - while ongoingAccesses[characteristic] != nil { - // otherwise there is a write and we wait for its completion before we read again - await queueRWAccess(for: characteristic) - } + let access = characteristicAccesses.makeAccess(for: characteristic) + try await access.waitCheckingCancellation() return try await withCheckedThrowingContinuation { continuation in - // using updateValue as of https://github.com/apple/swift/issues/63156. Revert to subscript access with Swift 5.10 - ongoingAccesses.updateValue(.read([continuation]), forKey: characteristic) + access.store(.read(continuation)) peripheral.readValue(for: characteristic) } } @@ -419,38 +545,103 @@ public actor BluetoothPeripheral { /// - Returns: The read rssi value. /// - Throws: Throws an `CBError` or `CBATTError` if the read fails. public func readRSSI() async throws -> Int { - guard rssiReadAccess.isEmpty else { - return try await withCheckedThrowingContinuation { continuation in - rssiReadAccess.append(continuation) - } - } + try await rssiAccess.waitCheckingCancellation() return try await withCheckedThrowingContinuation { continuation in - rssiReadAccess.append(continuation) + assert(rssiContinuation == nil, "rssiAccess was unexpectedly not nil") + rssiContinuation = continuation peripheral.readRSSI() } } - private func queueRWAccess(for characteristic: CBCharacteristic) async { - guard let access = ongoingAccesses[characteristic] else { + private func synchronizeModel(for service: CBService) { + guard let gattService = getService(id: service.uuid) else { + logger.error("Failed to retrieve service \(service.uuid) of discovered characteristics!") + return + } + + // update our model with latest characteristics! + let changeProtocol = gattService.synchronizeModel() + + for uuid in changeProtocol.removedCharacteristics { + let locator = CharacteristicLocator(serviceId: service.uuid, characteristicId: uuid) + for handler in onChangeHandlers[locator, default: [:]].values { + if case let .instance(onChange) = handler { + onChange(nil) // signal removed characteristic! + } + } + } + + for characteristic in changeProtocol.updatedCharacteristics { + let locator = CharacteristicLocator(serviceId: service.uuid, characteristicId: characteristic.uuid) + for handler in onChangeHandlers[locator, default: [:]].values { + if case let .instance(onChange) = handler { + onChange(characteristic) + } + } + } + } + + private func synchronizeModel(for characteristic: CBCharacteristic, capture: CBCharacteristicCapture) { + guard let service = characteristic.service, + let gattCharacteristic = getCharacteristic(id: characteristic.uuid, on: service.uuid) else { + logger.error("Failed to locate GATTCharacteristic for provided one \(characteristic.uuid)") + return + } + + gattCharacteristic.synchronizeModel(capture: capture) + } + + private func invalidateServices(_ ids: Set) { + guard let services else { return } - switch access { - case .read(let readContinuation, var queued): - await withCheckedContinuation { continuation in - queued.append(continuation) - // using updateValue as of https://github.com/apple/swift/issues/63156. Revert to subscript access with Swift 5.10 - ongoingAccesses.updateValue(.read(readContinuation, queued: queued), forKey: characteristic) + for (index, service) in zip(services.indices, services).reversed() { + guard ids.contains(service.uuid) else { + continue + } + + // Note: we iterate over the zipped array in reverse such that the indices stay valid if remove elements + + // the service was invalidated! + self.services?.remove(at: index) + + // make sure we notify subscribed handlers about removed services! + for characteristic in service.characteristics { + let locator = CharacteristicLocator(serviceId: service.uuid, characteristicId: characteristic.uuid) + for handler in onChangeHandlers[locator, default: [:]].values { + if case let .instance(onChange) = handler { + onChange(nil) // signal removed characteristic! + } + } } - case .write(let writeContinuation, var queued): - await withCheckedContinuation { continuation in - queued.append(continuation) - // using updateValue as of https://github.com/apple/swift/issues/63156. Revert to subscript access with Swift 5.10 - ongoingAccesses.updateValue(.write(writeContinuation, queued: queued), forKey: characteristic) + } + } + + private func discovered(services: [CBService]) { + // ids of currently maintained ids + let existingServices = Set(self.services?.map { $0.uuid } ?? []) + + // if we re-discover services (e.g., if ones got invalidated), services might still be present. So only add new ones + let addedServices = services + .filter { !existingServices.contains($0.uuid) } + .map { + // we will discover characteristics for all services after that. + GATTService(service: $0) } + + if let services = self.services { + isolatedUpdate(of: \.services, services + addedServices) + } else { + isolatedUpdate(of: \.services, addedServices) } } + + private func isolatedUpdate(of keyPath: WritableKeyPath, _ value: Value) { + var peripheral = self + peripheral[keyPath: keyPath] = value + } } @@ -462,11 +653,11 @@ extension BluetoothPeripheral: Identifiable { } extension BluetoothPeripheral: KVOReceiver { - func observeChange(of keyPath: KeyPath, value: V) async { + func observeChange(of keyPath: KeyPath, value: V) { switch keyPath { case \CBPeripheral.state: // force cast is okay as we implicitly verify the type using the KeyPath in the case statement. - self.stateContainer.state = .init(from: value as! CBPeripheralState) // swiftlint:disable:this force_cast + self.isolatedUpdate(of: \.state, PeripheralState(from: value as! CBPeripheralState)) // swiftlint:disable:this force_cast default: break } @@ -476,43 +667,34 @@ extension BluetoothPeripheral: KVOReceiver { // MARK: Delegate Accessors extension BluetoothPeripheral { - fileprivate func update(name: String?) { - self.stateContainer.name = name - } - - fileprivate func update(rssi: Int, error: Error?) { - stateContainer.rssi = rssi - - let result: Result - if let error { - result = .failure(error) - } else { - result = .success(rssi) - } - - for continuation in rssiReadAccess { - continuation.resume(with: result) + private func discovered(service: CBService) { + guard let characteristics = service.characteristics else { + logger.warning("Characteristic discovery for service \(service.uuid) resulted in an empty list.") + return } - self.rssiReadAccess.removeAll() - } + logger.debug("Discovered \(characteristics.count) characteristic(s) for service \(service.uuid): \(characteristics)") - fileprivate func discovered(characteristics: [CBCharacteristic], for service: CBService) { // automatically subscribe to discovered characteristics for which we have a handler subscribed! for characteristic in characteristics { - guard characteristic.properties.contains(.notify) else { - continue + // pull initial value + if characteristic.properties.contains(.read) { + peripheral.readValue(for: characteristic) } - let locator = CharacteristicLocator(serviceId: service.uuid, characteristicId: characteristic.uuid) + // enable notifications if registered + if characteristic.properties.supportsNotifications { + let locator = CharacteristicLocator(serviceId: service.uuid, characteristicId: characteristic.uuid) - if notificationHandlers[locator] != nil { - peripheral.setNotifyValue(true, for: characteristic) + if notifyRequested.contains(locator) { + logger.debug("Automatically subscribing to discovered characteristic \(locator)...") + peripheral.setNotifyValue(true, for: characteristic) + } } } // check if we discover descriptors - guard let requestedCharacteristics = stateContainer.requestedCharacteristics, + guard let requestedCharacteristics = requestedCharacteristics, let descriptions = requestedCharacteristics[service.uuid] else { return } @@ -523,68 +705,65 @@ extension BluetoothPeripheral { } if description.discoverDescriptors { + logger.debug("Discovering descriptors for \(characteristic.debugIdentifier)...") peripheral.discoverDescriptors(for: characteristic) } } } - fileprivate func receivedReadyNotification() { - for continuation in writeWithoutResponseAccess { - continuation.resume() - } - writeWithoutResponseAccess.removeAll() - } - - fileprivate func receivedUpdatedValue(for characteristic: CBCharacteristic, result: Result) async { - if case let .read(continuations, queued) = ongoingAccesses[characteristic] { - ongoingAccesses[characteristic] = nil - + private func receivedUpdatedValue(for characteristic: CBCharacteristic, result: Result) { + if case let .read(continuation) = characteristicAccesses.retrieveAccess(for: characteristic) { if case let .failure(error) = result { logger.debug("Characteristic read for \(characteristic.debugIdentifier) returned with error: \(error)") } - for continuation in continuations { - continuation.resume(with: result) - } - - for queue in queued { - queue.resume() - } + continuation.resume(with: result) + } else if case let .failure(error) = result { + logger.debug("Received unsolicited value update error for \(characteristic.debugIdentifier): \(error)") } - switch result { - case let .success(data): - guard let service = characteristic.service else { - break - } + // notification handling + guard case let .success(data) = result else { + return + } - let locator = CharacteristicLocator(serviceId: service.uuid, characteristicId: characteristic.uuid) + guard let service = characteristic.service else { + logger.warning("Received updated value for characteristic \(characteristic.debugIdentifier) without associated service!") + return + } - for handler in notificationHandlers[locator, default: [:]].values { - await handler(data) + let locator = CharacteristicLocator(serviceId: service.uuid, characteristicId: characteristic.uuid) + for onChange in onChangeHandlers[locator, default: [:]].values { + guard case let .value(handler) = onChange else { + continue } - case let .failure(error): - logger.debug("Received unsolicited value update error for \(characteristic.debugIdentifier): \(error)") + handler(data) } } - fileprivate func receivedWriteResponse(for characteristic: CBCharacteristic, result: Result) { - guard case let .write(continuation, queued) = ongoingAccesses[characteristic] else { - logger.warning("Received write response for \(characteristic.debugIdentifier) without an ongoing access. Discarding write ...") + private func receivedWriteResponse(for characteristic: CBCharacteristic, result: Result) { + guard case let .write(continuation) = characteristicAccesses.retrieveAccess(for: characteristic) else { + switch result { + case .success: + logger.warning("Received write response for \(characteristic.debugIdentifier) without an ongoing access. Discarding write ...") + case let .failure(error): + logger.warning("Received erroneous write response for \(characteristic.debugIdentifier) without an ongoing access: \(error)") + } return } - ongoingAccesses[characteristic] = nil - if case let .failure(error) = result { logger.debug("Characteristic write for \(characteristic.debugIdentifier) returned with error: \(error)") } continuation.resume(with: result) + } +} - for queue in queued { - queue.resume() - } + +extension BluetoothPeripheral: CustomDebugStringConvertible { + public nonisolated var debugDescription: String { + cbPeripheral.debugIdentifier } } @@ -614,132 +793,243 @@ extension BluetoothPeripheral { } - func initDevice(_ device: BluetoothPeripheral) { + nonisolated func initDevice(_ device: BluetoothPeripheral) { self.device = device } func peripheralDidUpdateName(_ peripheral: CBPeripheral) { - Task { - await device.update(name: peripheral.name) + guard let device else { + return + } + + let name = peripheral.name + + Task { @SpeziBluetooth in + await device.isolated { device in + device.isolatedUpdate(of: \.peripheralName, name) + } } } func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) { - Task { - await device.update(rssi: RSSI.intValue, error: error) + guard let device else { + return + } + + Task { @SpeziBluetooth in + await device.isolated { device in + let rssi = RSSI.intValue + device.isolatedUpdate(of: \.rssi, rssi) + + let result: Result = error.map { .failure($0) } ?? .success(rssi) + + guard let rssiContinuation = device.rssiContinuation else { + return + } + + device.rssiContinuation = nil + rssiContinuation.resume(with: result) + assert(device.rssiAccess.signal(), "Signaled rssiAccess though no one was waiting") + } } } func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) { + guard let device else { + return + } + // this is called if ... // 1) The peripheral removes a service from its database. // 2) The peripheral adds a new service to its database. // 3) The peripheral adds back a previously-removed service, but at a different location in the database. - // so a service we requested might be gone now. Or might just have changed location. So, discover them to check if they moved location? + // so a service we requested might be gone now. Or might just have changed location. + // So, discover them to check if they moved location? let serviceIds = invalidatedServices.map { $0.uuid } logger.debug("Services modified, invalidating \(serviceIds)") - // update our local model - device.stateContainer.services?.removeAll(where: { invalidatedServices.contains($0) }) + Task { @SpeziBluetooth in + await device.isolated { device in + // update our local model! + device.invalidateServices(Set(serviceIds)) - peripheral.discoverServices(serviceIds) + // make sure we try to rediscover those services though + peripheral.discoverServices(serviceIds) + } + } } func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + guard let device else { + return + } + if let error { logger.error("Error discovering services: \(error.localizedDescription)") return } guard let services = peripheral.services else { + logger.error("Discovered services but they weren't present!") return } - // update our local model for observability - device.stateContainer.services = services + Task { @SpeziBluetooth in + await device.isolated { device in + device.discovered(services: services) - logger.debug("Discovered \(services) services for peripheral \(peripheral.debugIdentifier)") + logger.debug("Discovered \(services) services for peripheral \(device.peripheral.debugIdentifier)") - for service in services { - guard let requestedCharacteristicsDescriptions = device.stateContainer.requestedCharacteristics?[service.uuid] else { - continue - } + for service in services { + guard let requestedCharacteristicsDic = device.requestedCharacteristics, + let requestedCharacteristicsDescriptions = requestedCharacteristicsDic[service.uuid] else { + continue + } + + let requestedCharacteristics = requestedCharacteristicsDescriptions?.map { $0.characteristicId } + + if let requestedCharacteristics, requestedCharacteristics.isEmpty { + continue + } - let requestedCharacteristics = requestedCharacteristicsDescriptions?.map { $0.characteristicId } + device.servicesAwaitingCharacteristicsDiscovery.insert(service.uuid) + peripheral.discoverCharacteristics(requestedCharacteristics, for: service) + } - // see peripheral(_:didDiscoverCharacteristicsFor:error:) - peripheral.discoverCharacteristics(requestedCharacteristics, for: service) + if device.servicesAwaitingCharacteristicsDiscovery.isEmpty { + device._storage.signalFullyDiscovered() + } + } } } func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { - if let error = error { - logger.error("Error discovering characteristics: \(error.localizedDescription)") + guard let device else { return } - guard let characteristics = service.characteristics else { - return - } + Task { @SpeziBluetooth in + await device.isolated { device in + // update our model with latest characteristics! + device.synchronizeModel(for: service) + + // ensure we keep track of all discoveries, set .connected state + device.servicesAwaitingCharacteristicsDiscovery.remove(service.uuid) + if device.servicesAwaitingCharacteristicsDiscovery.isEmpty { + device._storage.signalFullyDiscovered() + } - logger.debug("Discovered \(characteristics.count) characteristic(s) for service \(service.uuid)") + if let error { + logger.error("Error discovering characteristics: \(error.localizedDescription)") + return + } - Task { - await device.discovered(characteristics: characteristics, for: service) + // handle auto-subscribe and discover descriptors + device.discovered(service: service) + } } } func peripheral(_ peripheral: CBPeripheral, didDiscoverDescriptorsFor characteristic: CBCharacteristic, error: Error?) { + guard let device else { + return + } + guard let descriptors = characteristic.descriptors else { return } logger.debug("Discovered descriptors for characteristic \(characteristic.debugIdentifier): \(descriptors)") + + let capture = CBCharacteristicCapture(from: characteristic) + + Task { @SpeziBluetooth in + await device.isolated { device in + device.synchronizeModel(for: characteristic, capture: capture) + } + } } func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { - Task { - if let error { - await device.receivedUpdatedValue(for: characteristic, result: .failure(error)) - } else if let value = characteristic.value { - await device.receivedUpdatedValue(for: characteristic, result: .success(value)) + guard let device else { + return + } + + let capture = CBCharacteristicCapture(from: characteristic) + + Task { @SpeziBluetooth in + await device.isolated { device in + // make sure value is propagated beforehand + device.synchronizeModel(for: characteristic, capture: capture) + + if let error { + device.receivedUpdatedValue(for: characteristic, result: .failure(error)) + } else if let value = capture.value { + device.receivedUpdatedValue(for: characteristic, result: .success(value)) + } } } } func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { - Task { - if let error { - await device.receivedWriteResponse(for: characteristic, result: .failure(error)) - } else { - await device.receivedWriteResponse(for: characteristic, result: .success(())) + guard let device else { + return + } + + let capture = CBCharacteristicCapture(from: characteristic) + + Task { @SpeziBluetooth in + await device.isolated { device in + device.synchronizeModel(for: characteristic, capture: capture) + + let result: Result = error.map { .failure($0) } ?? .success(()) + device.receivedWriteResponse(for: characteristic, result: result) } } } func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { - Task { - await device.receivedReadyNotification() + guard let device else { + return + } + + Task { @SpeziBluetooth in + await device.isolated { device in + guard let writeWithoutResponseContinuation = device.writeWithoutResponseContinuation else { + return + } + + device.writeWithoutResponseContinuation = nil + writeWithoutResponseContinuation.resume() + assert(device.writeWithoutResponseAccess.signal(), "Signaled writeWithoutResponseAccess though no one was waiting") + } } } func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { + guard let device else { + return + } + if let error = error { - logger.error("Error changing notification state: \(error.localizedDescription)") + logger.error("Error changing notification state for \(characteristic.uuid): \(error)") return } + let capture = CBCharacteristicCapture(from: characteristic) - if characteristic.isNotifying { - logger.log("Notification began on \(characteristic.uuid.uuidString)") + Task { @SpeziBluetooth in + await device.isolated { device in + device.synchronizeModel(for: characteristic, capture: capture) - if characteristic.properties.contains(.read) { // read the initial value - peripheral.readValue(for: characteristic) + if capture.isNotifying { + logger.log("Notification began on \(characteristic.debugIdentifier)") + } else { + logger.log("Notification stopped on \(characteristic.debugIdentifier).") + } } - } else { - logger.log("Notification stopped on \(characteristic.uuid.uuidString).") } } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift index 72b246ff..95fdd785 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift @@ -6,11 +6,11 @@ // SPDX-License-Identifier: MIT // -import CoreBluetooth +@preconcurrency import class CoreBluetooth.CBUUID /// A characteristic description. -public struct CharacteristicDescription { +public struct CharacteristicDescription: Sendable { /// The characteristic id. public let characteristicId: CBUUID /// Flag indicating if descriptors should be discovered for this characteristic. diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift index 40bb3f33..ce41c1bd 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift @@ -13,7 +13,7 @@ import OSLog /// /// Describes what services we expect to be present for a certain type of device. /// The ``BluetoothManager`` uses that to determine what devices to discover and what services and characteristics to expect. -public struct DeviceDescription { +public struct DeviceDescription: Sendable { /// The criteria by which we identify a discovered device. public let discoveryCriteria: DiscoveryCriteria /// The set of service configurations we expect from the device. diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift index fc0fe52b..65d05a0c 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift @@ -6,7 +6,7 @@ // SPDX-License-Identifier: MIT // -import CoreBluetooth +@preconcurrency import CoreBluetooth /// The criteria by which we identify a discovered device. @@ -14,9 +14,10 @@ import CoreBluetooth /// ## Topics /// /// ### Criteria +/// - ``advertisedService(_:)-5o92s`` +/// - ``advertisedService(_:)-3pnr6`` /// - ``advertisedService(_:)-swift.enum.case`` -/// - ``advertisedService(_:)-swift.type.method`` -public enum DiscoveryCriteria { +public enum DiscoveryCriteria: Sendable { /// Identify a device by their advertised service. case advertisedService(_ uuid: CBUUID) @@ -29,6 +30,16 @@ public enum DiscoveryCriteria { } + func matches(_ advertisementData: AdvertisementData) -> Bool { + switch self { + case let .advertisedService(uuid): + return advertisementData.serviceUUIDs?.contains(uuid) ?? false + } + } +} + + +extension DiscoveryCriteria { /// Identify a device by their advertised service. /// - Parameter uuid: The Bluetooth ServiceId in string format. /// - Returns: A ``DiscoveryCriteria/advertisedService(_:)-swift.enum.case`` criteria. @@ -36,15 +47,15 @@ public enum DiscoveryCriteria { .advertisedService(CBUUID(string: uuid)) } - - func matches(_ advertisementData: AdvertisementData) -> Bool { - switch self { - case let .advertisedService(uuid): - return advertisementData.serviceUUIDs?.contains(uuid) ?? false - } + /// Identify a device by their advertised service. + /// - Parameter service: The service type. + /// - Returns: A ``DiscoveryCriteria/advertisedService(_:)-swift.enum.case`` criteria. + public static func advertisedService(_ service: Service.Type) -> DiscoveryCriteria { + .advertisedService(Service.id) } } + extension DiscoveryCriteria: Hashable, CustomStringConvertible { public var description: String { switch self { diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift index 01536222..e794379b 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift @@ -6,13 +6,13 @@ // SPDX-License-Identifier: MIT // -import CoreBluetooth +@preconcurrency import CoreBluetooth /// A service description for a certain device. /// /// Describes what characteristics we expect to be present for a certain service. -public struct ServiceDescription { +public struct ServiceDescription: Sendable { /// The service id. public let serviceId: CBUUID /// The description of characteristics present on the service. diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Extensions/CBCharacteristicProperties+Props.swift b/Sources/SpeziBluetooth/CoreBluetooth/Extensions/CBCharacteristicProperties+Props.swift new file mode 100644 index 00000000..c4e7a505 --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Extensions/CBCharacteristicProperties+Props.swift @@ -0,0 +1,17 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import CoreBluetooth + + +extension CBCharacteristicProperties { + var supportsNotifications: Bool { + contains(.notify) || contains(.notifyEncryptionRequired) + || contains(.indicate) || contains(.indicateEncryptionRequired) // indicate is notify whith an ACK + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Extensions/CBPeripheral+DebugIdentifier.swift b/Sources/SpeziBluetooth/CoreBluetooth/Extensions/CBPeripheral+DebugIdentifier.swift index 13bf81d2..a39df6e6 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Extensions/CBPeripheral+DebugIdentifier.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Extensions/CBPeripheral+DebugIdentifier.swift @@ -19,10 +19,3 @@ extension CBPeripheral { } } } - - -extension BluetoothPeripheral: CustomDebugStringConvertible { - public nonisolated var debugDescription: String { - cbPeripheral.debugIdentifier - } -} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothError.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothError.swift index bcda0778..77c3aa50 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothError.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothError.swift @@ -6,16 +6,17 @@ // SPDX-License-Identifier: MIT // +import CoreBluetooth import Foundation /// Represents errors that can occur during Bluetooth operations. -public enum BluetoothError: String, Error, CustomStringConvertible, LocalizedError { +public enum BluetoothError: Error, CustomStringConvertible, LocalizedError { /// Could not decode the ByteBuffer into the provided ByteDecodable. case incompatibleDataFormat /// Thrown when accessing a ``Characteristic`` that was not present. /// Either because the device wasn't connected or the characteristic is not present on the connected device. - case notPresent + case notPresent(service: CBUUID? = nil, characteristic: CBUUID) /// Provides a human-readable description of the error. @@ -39,8 +40,8 @@ public enum BluetoothError: String, Error, CustomStringConvertible, LocalizedErr switch self { case .incompatibleDataFormat: String(localized: "Could not decode byte representation into provided format.", bundle: .module) - case .notPresent: - String(localized: "The request characteristic was not present on the device.", bundle: .module) + case let .notPresent(service, characteristic): + String(localized: "The requested characteristic \(characteristic) on \(service?.uuidString ?? "?") was not present on the device.", bundle: .module) } } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothState.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothState.swift index ddbcac26..8661b68f 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothState.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothState.swift @@ -6,6 +6,8 @@ // SPDX-License-Identifier: MIT // +import CoreBluetooth + /// Represents the various states of Bluetooth. public enum BluetoothState: UInt8 { @@ -40,3 +42,26 @@ extension BluetoothState: CustomStringConvertible, Sendable { } } } + + +extension BluetoothState { + /// Derive peripheral state from CoreBluetooth + public init(from state: CBManagerState) { + switch state { + case .unknown: + self = .unknown + case .resetting: + self = .poweredOff + case .unsupported: + self = .unsupported + case .unauthorized: + self = .unauthorized + case .poweredOff: + self = .poweredOff + case .poweredOn: + self = .poweredOn + @unknown default: + self = .unknown + } + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccessContinuation.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccessContinuation.swift deleted file mode 100644 index d85142c6..00000000 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccessContinuation.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import Foundation - - -enum CharacteristicAccessContinuation { - case read(_ continuation: [CheckedContinuation], queued: [CheckedContinuation] = []) - case write(_ continuation: CheckedContinuation, queued: [CheckedContinuation] = []) -} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccesses.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccesses.swift new file mode 100644 index 00000000..75f8ef27 --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccesses.swift @@ -0,0 +1,93 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import CoreBluetooth +import Foundation + + +class CharacteristicAccess { + enum Access { + case read(CheckedContinuation) + case write(CheckedContinuation) + } + + + private let id: CBUUID + private let semaphore = AsyncSemaphore() + private(set) var access: Access? + + + fileprivate init(id: CBUUID) { + self.id = id + } + + + func waitCheckingCancellation() async throws { + try await semaphore.waitCheckingCancellation() + } + + func store(_ access: Access) { + precondition(self.access == nil, "Access was unexpectedly not nil") + self.access = access + } + + func receive() -> Access? { + let access = access + self.access = nil + semaphore.signal() + return access + } + + func cancelAll() { + semaphore.cancelAll() + let access = access + self.access = nil + + switch access { + case let .read(continuation): + continuation.resume(throwing: CancellationError()) + case let .write(continuation): + continuation.resume(throwing: CancellationError()) + case .none: + break + } + } +} + + +struct CharacteristicAccesses { + private var ongoingAccesses: [CBCharacteristic: CharacteristicAccess] = [:] + + mutating func makeAccess(for characteristic: CBCharacteristic) -> CharacteristicAccess { + let access: CharacteristicAccess + if let existing = ongoingAccesses[characteristic] { + access = existing + } else { + access = CharacteristicAccess(id: characteristic.uuid) + self.ongoingAccesses[characteristic] = access + } + return access + } + + func retrieveAccess(for characteristic: CBCharacteristic) -> CharacteristicAccess.Access? { + guard let access = ongoingAccesses[characteristic] else { + return nil + } + + return access.receive() + } + + mutating func cancelAll() { + let accesses = ongoingAccesses + ongoingAccesses.removeAll() + + for access in accesses.values { + access.cancelAll() + } + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicLocator.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicLocator.swift index 2633d1df..aef0c92c 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicLocator.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicLocator.swift @@ -9,7 +9,20 @@ import CoreBluetooth -struct CharacteristicLocator: Hashable { +struct CharacteristicLocator { let serviceId: CBUUID let characteristicId: CBUUID } + + +extension CharacteristicLocator: Hashable {} + +extension CharacteristicLocator: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { + "\(characteristicId)@\(serviceId)" + } + + public var debugDescription: String { + "CharacteristicLocator(service: \(serviceId), characteristic: \(characteristicId))" + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTCharacteristic.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTCharacteristic.swift new file mode 100644 index 00000000..0795fbad --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTCharacteristic.swift @@ -0,0 +1,99 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import CoreBluetooth +import Foundation + + +struct CBCharacteristicCapture { + let isNotifying: Bool + let value: Data? + let descriptors: [CBDescriptor]? // swiftlint:disable:this discouraged_optional_collection + + init(from characteristic: CBCharacteristic) { + self.isNotifying = characteristic.isNotifying + self.value = characteristic.value + self.descriptors = characteristic.descriptors + } +} + + +/// A Bluetooth characteristic of a service. +/// +/// ## Topics +/// +/// ### Instance Properties +/// - ``uuid`` +/// - ``value`` +/// - ``isNotifying`` +/// - ``properties`` +/// - ``descriptors`` +/// - ``service`` +@Observable +public class GATTCharacteristic { + let underlyingCharacteristic: CBCharacteristic + + /// The associated service if still available. + public private(set) weak var service: GATTService? + + /// Whether the characteristic is currently notifying or not. + public private(set) var isNotifying: Bool + /// The value of the characteristic. + public private(set) var value: Data? + /// A list of the descriptors that have so far been discovered in this characteristic. + public private(set) var descriptors: [CBDescriptor]? // swiftlint:disable:this discouraged_optional_collection + + /// The Bluetooth UUID of the characteristic. + public var uuid: CBUUID { + underlyingCharacteristic.uuid + } + + /// The properties of the characteristic. + public var properties: CBCharacteristicProperties { + underlyingCharacteristic.properties + } + + init(characteristic: CBCharacteristic, service: GATTService) { + self.underlyingCharacteristic = characteristic + self.service = service + self.isNotifying = characteristic.isNotifying + self.value = characteristic.value + self.descriptors = characteristic.descriptors + } + + + func synchronizeModel(capture: CBCharacteristicCapture) { + if capture.isNotifying != isNotifying { + isNotifying = capture.isNotifying + } + if capture.value != value { + value = capture.value + } + if capture.descriptors != descriptors { + descriptors = capture.descriptors + } + } +} + + +extension GATTCharacteristic: CustomDebugStringConvertible { + public var debugDescription: String { + underlyingCharacteristic.debugIdentifier + } +} + + +extension GATTCharacteristic: Hashable { + public static func == (lhs: GATTCharacteristic, rhs: GATTCharacteristic) -> Bool { + lhs.underlyingCharacteristic == rhs.underlyingCharacteristic + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(underlyingCharacteristic) + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTService.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTService.swift new file mode 100644 index 00000000..c8387619 --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTService.swift @@ -0,0 +1,106 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import CoreBluetooth +import Foundation + +struct ServiceChangeProtocol { + let removedCharacteristics: Set + let updatedCharacteristics: [GATTCharacteristic] +} + + +/// A Bluetooth service of a device. +/// +/// ## Topics +/// +/// ### Instance Properties +/// - ``uuid`` +/// - ``isPrimary`` +/// - ``characteristics`` +@Observable +public class GATTService { + let underlyingService: CBService + /// The stored characteristics, indexed by their uuid. + private var _characteristics: [CBUUID: GATTCharacteristic] + + /// The Bluetooth UUID of the service. + public var uuid: CBUUID { + underlyingService.uuid + } + + /// The type of the service (primary or secondary). + public var isPrimary: Bool { + underlyingService.isPrimary + } + + /// A list of characteristics that have been discovered in this service. + public var characteristics: [GATTCharacteristic] { + Array(_characteristics.values) + } + + + init(service: CBService) { + self.underlyingService = service + self._characteristics = [:] + self._characteristics = service.characteristics?.reduce(into: [:], { result, characteristic in + result[characteristic.uuid] = GATTCharacteristic(characteristic: characteristic, service: self) + }) ?? [:] + } + + + /// Retrieve a characteristic. + /// - Parameter id: The Bluetooth characteristic id. + /// - Returns: The characteristic instance if present. + public func getCharacteristic(id: CBUUID) -> GATTCharacteristic? { + characteristics.first { characteristics in + characteristics.uuid == id + } + } + + /// Signal from the BluetoothManager to update your stored representations. + func synchronizeModel() -> ServiceChangeProtocol { + var removedCharacteristics = Set(_characteristics.keys) + var updatedCharacteristics: [GATTCharacteristic] = [] + + for cbCharacteristic in underlyingService.characteristics ?? [] { + let characteristic = _characteristics[cbCharacteristic.uuid] + if characteristic != nil { + // The characteristic is there. Mark it as not removed. + removedCharacteristics.remove(cbCharacteristic.uuid) + } + + + // either the characteristic does not exists, or the underlying reference changed + if characteristic == nil || characteristic?.underlyingCharacteristic !== cbCharacteristic { + // create/replace it + let characteristic = GATTCharacteristic(characteristic: cbCharacteristic, service: self) + updatedCharacteristics.append(characteristic) + _characteristics[cbCharacteristic.uuid] = characteristic + } + } + + // remove all characteristics we haven't found in the + for removedId in removedCharacteristics { + _characteristics.removeValue(forKey: removedId) + } + + return ServiceChangeProtocol(removedCharacteristics: removedCharacteristics, updatedCharacteristics: updatedCharacteristics) + } +} + + +extension GATTService: Hashable { + public static func == (lhs: GATTService, rhs: GATTService) -> Bool { + lhs.underlyingService == rhs.underlyingService + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(underlyingService) + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicNotification.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/OnChangeRegistration.swift similarity index 53% rename from Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicNotification.swift rename to Sources/SpeziBluetooth/CoreBluetooth/Model/OnChangeRegistration.swift index 362cf8db..fc0aed13 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicNotification.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/OnChangeRegistration.swift @@ -9,13 +9,13 @@ import Foundation -/// An active registration of a notification handler. +/// An active registration of a on-change handler. /// -/// This object represents an active registration of an notification handler. Primarily, this can be used to keep -/// track of a notification handler and cancel the registration at a later point. +/// This object represents an active registration of an on-change handler. Primarily, this can be used to keep +/// track of a on-change handler and cancel the registration at a later point. /// -/// - Tip: The notification handler will be automatically unregistered when this object is deallocated. -public class CharacteristicNotification { +/// - Tip: The on-change handler will be automatically unregistered when this object is deallocated. +public class OnChangeRegistration { private weak var peripheral: BluetoothPeripheral? let locator: CharacteristicLocator let handlerId: UUID @@ -28,9 +28,11 @@ public class CharacteristicNotification { } - /// Cancel the notification handler registration. - public func cancel() async { - await peripheral?.deregisterNotification(self) + /// Cancel the on-change handler registration. + public func cancel() { + Task { @SpeziBluetooth in + await peripheral?.deregisterOnChange(self) + } } @@ -40,8 +42,8 @@ public class CharacteristicNotification { let locator = locator let handlerId = handlerId - Task { - await peripheral?.deregisterNotification(locator: locator, handlerId: handlerId) + Task { @SpeziBluetooth in + await peripheral?.deregisterOnChange(locator: locator, handlerId: handlerId) } } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralState.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralState.swift index c23fb1bf..19b72989 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralState.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralState.swift @@ -39,7 +39,8 @@ extension PeripheralState: CustomStringConvertible, Sendable { extension PeripheralState { - init(from state: CBPeripheralState) { + /// Derive peripheral state from CoreBluetooth + public init(from state: CBPeripheralState) { switch state { case .disconnected: self = .disconnected diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStateContainer.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStateContainer.swift deleted file mode 100644 index 806ca4d7..00000000 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStateContainer.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import CoreBluetooth -import Foundation - - -/// A dedicated state container for a ``BluetoothPeripheral``. -/// -/// Main motivation is to have `BluetoothPeripheral` be implemented as an actor and moving state -/// into a separate state container that is `@Observable`. -@Observable -final class PeripheralStateContainer { - var name: String? - var rssi: Int - var advertisementData: AdvertisementData - var state: PeripheralState - var lastActivity: Date - - var services: [CBService]? // swiftlint:disable:this discouraged_optional_collection - - /// The list of requested characteristic uuids indexed by service uuids. - var requestedCharacteristics: [CBUUID: Set?]? // swiftlint:disable:this discouraged_optional_collection - - init(name: String?, rssi: Int, advertisementData: AdvertisementData, state: CBPeripheralState, lastActivity: Date = .now) { - self.name = name - self.advertisementData = advertisementData - self.rssi = rssi - self.state = .init(from: state) - self.lastActivity = lastActivity - } -} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift new file mode 100644 index 00000000..f425a9a8 --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift @@ -0,0 +1,111 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import CoreBluetooth +import Foundation + + +/// A dedicated, observable storage container for a ``BluetoothPeripheral``. +/// +/// Main motivation is to have `BluetoothPeripheral` be implemented as an actor and moving state +/// into a separate state container that is `@Observable`. +@Observable +final class PeripheralStorage: ValueObservable { + var name: String? { + localName ?? peripheralName + } + + private(set) var peripheralName: String? { + didSet { + _$simpleRegistrar.triggerDidChange(for: \.peripheralName, on: self) + } + } + private(set) var localName: String? { + didSet { + _$simpleRegistrar.triggerDidChange(for: \.localName, on: self) + } + } + private(set) var rssi: Int { + didSet { + _$simpleRegistrar.triggerDidChange(for: \.rssi, on: self) + } + } + private(set) var advertisementData: AdvertisementData { + didSet { + _$simpleRegistrar.triggerDidChange(for: \.advertisementData, on: self) + } + } + private(set) var state: PeripheralState { + didSet { + _$simpleRegistrar.triggerDidChange(for: \.state, on: self) + } + } + private(set) var services: [GATTService]? { // swiftlint:disable:this discouraged_optional_collection + didSet { + _$simpleRegistrar.triggerDidChange(for: \.services, on: self) + } + } + @ObservationIgnored var lastActivity: Date + + // swiftlint:disable:next identifier_name + @ObservationIgnored var _$simpleRegistrar = ValueObservationRegistrar() + + init(peripheralName: String?, rssi: Int, advertisementData: AdvertisementData, state: CBPeripheralState, lastActivity: Date = .now) { + self.peripheralName = peripheralName + self.localName = advertisementData.localName + self.advertisementData = advertisementData + self.rssi = rssi + self.state = .init(from: state) + self.lastActivity = lastActivity + } + + func update(localName: String?) { + if self.localName != localName { + self.localName = localName + } + } + + func update(peripheralName: String?) { + if self.peripheralName != peripheralName { + self.peripheralName = peripheralName + } + } + + func update(rssi: Int) { + if self.rssi != rssi { + self.rssi = rssi + } + } + + func update(advertisementData: AdvertisementData) { + self.advertisementData = advertisementData // not equatable + } + + func update(state: PeripheralState) { + if self.state != state { + if self.state == .connecting && state == .connected { + return // we set connected on our own! + } + self.state = state + } + } + + func signalFullyDiscovered() { + if state == .connecting { + state = .connected + } + } + + func update(lastActivity: Date = .now) { + self.lastActivity = lastActivity + } + + func assign(services: [GATTService]) { + self.services = services + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothActor.swift b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothActor.swift new file mode 100644 index 00000000..262479ec --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothActor.swift @@ -0,0 +1,26 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + +protocol BluetoothActor: Actor { + nonisolated var bluetoothQueue: DispatchSerialQueue { get } + + func isolated(perform: (isolated Self) -> Void) +} + +extension BluetoothActor { + /// Default implementation returning the unknown serial executor of the dispatch queue. + public nonisolated var unownedExecutor: UnownedSerialExecutor { + bluetoothQueue.asUnownedSerialExecutor() + } + + func isolated(perform: (isolated Self) -> Void) { + perform(self) + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothNotificationHandler.swift b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothNotificationHandler.swift deleted file mode 100644 index 2c151b56..00000000 --- a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothNotificationHandler.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// This source file is part of the Stanford Spezi open source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import Foundation - - -/// Notification handler for a change value of a specified characteristic. -public typealias BluetoothNotificationHandler = (_ data: Data) async -> Void diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothWorkItem.swift b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothWorkItem.swift new file mode 100644 index 00000000..82a52360 --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothWorkItem.swift @@ -0,0 +1,40 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +class BluetoothWorkItem { + let workItem: DispatchWorkItem + + init(manager: BluetoothManager, handler: @escaping (isolated BluetoothManager) -> Void) { + self.workItem = DispatchWorkItem { [weak manager] in + guard let manager else { + return + } + + // We are running on the dispatch queue, however we are not running in the task. + // So sadly, we can't just jump into the actor isolation. But no big deal here for synchronization. + + Task { @SpeziBluetooth in + await manager.isolated(perform: handler) + } + } + } + + func cancel() { + workItem.cancel() + } +} + + +extension DispatchSerialQueue { + func schedule(for deadline: DispatchTime, execute: BluetoothWorkItem) { + asyncAfter(deadline: deadline, execute: execute.workItem) + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/DiscoveryStaleTimer.swift b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/DiscoveryStaleTimer.swift index 44b67de7..e1e1c96e 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/DiscoveryStaleTimer.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/DiscoveryStaleTimer.swift @@ -12,15 +12,13 @@ import Foundation class DiscoveryStaleTimer { let targetDevice: UUID /// The dispatch work item that schedules the next stale timer. - private let workItem: DispatchWorkItem + private let workItem: BluetoothWorkItem - init(device: UUID, handler: @escaping () -> Void) { + init(device: UUID, manager: BluetoothManager, handler: @escaping (isolated BluetoothManager) -> Void) { // make sure that you don't create a reference cycle through the closure above! self.targetDevice = device - self.workItem = DispatchWorkItem { // we do not capture self here!! - handler() - } + self.workItem = BluetoothWorkItem(manager: manager, handler: handler) } @@ -28,10 +26,10 @@ class DiscoveryStaleTimer { workItem.cancel() } - func schedule(for timeout: TimeInterval, in queue: DispatchQueue) { + func schedule(for timeout: TimeInterval, in queue: DispatchSerialQueue) { // `DispatchTime` only allows for integer time let milliSeconds = Int(timeout * 1000) - queue.asyncAfter(deadline: .now() + .milliseconds(milliSeconds), execute: workItem) + queue.schedule(for: .now() + .milliseconds(milliSeconds), execute: workItem) } deinit { diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/KVOStateObserver.swift b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/KVOStateObserver.swift index f1239080..d0ad3a18 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/KVOStateObserver.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/KVOStateObserver.swift @@ -35,7 +35,7 @@ class KVOStateObserver: NSObject { } func observeChange(of keyPath: KeyPath, value: V) { - Task { + Task { @SpeziBluetooth in await receiver?.observeChange(of: keyPath, value: value) } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/SpeziBluetoothActor.swift b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/SpeziBluetoothActor.swift new file mode 100644 index 00000000..71ebc499 --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/SpeziBluetoothActor.swift @@ -0,0 +1,16 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +/// Global, framework-internal actor to schedule work that is exectued serially. +@globalActor +actor SpeziBluetooth { + static let shared = SpeziBluetooth() +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/ValueObservable.swift b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/ValueObservable.swift new file mode 100644 index 00000000..bc634b05 --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/ValueObservable.swift @@ -0,0 +1,73 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +protocol AnyValueObservation {} + + +/// Internal value observation registrar. +/// +/// Holds the registered closure till the next value update happens. +/// Inspired by Apple's Observation framework but with more power! +class ValueObservationRegistrar { + struct ValueObservation: AnyValueObservation { + let keyPath: KeyPath + let handler: (Value) -> Void + } + + private var id: UInt64 = 0 + private var observations: [UInt64: AnyValueObservation] = [:] + private var keyPathIndex: [AnyKeyPath: Set] = [:] + + private func nextId() -> UInt64 { + defer { + id &+= 1 // add with overflow operator + } + return id + } + + func onChange(of keyPath: KeyPath, perform closure: @escaping (Value) -> Void) { + let id = nextId() + observations[id] = ValueObservation(keyPath: keyPath, handler: closure) + keyPathIndex[keyPath, default: []].insert(id) + } + + func triggerDidChange(for keyPath: KeyPath, on observable: Observable) { + guard let ids = keyPathIndex.removeValue(forKey: keyPath) else { + return + } + + for id in ids { + guard let anyObservation = observations.removeValue(forKey: id), + let observation = anyObservation as? ValueObservation else { + continue + } + + let value = observable[keyPath: keyPath] + observation.handler(value) + } + } +} + + +/// A model with value observable properties. +protocol ValueObservable: AnyObject { + // swiftlint:disable:next identifier_name + var _$simpleRegistrar: ValueObservationRegistrar { get set } + + func onChange(of keyPath: KeyPath, perform closure: @escaping (Value) -> Void) +} + + +extension ValueObservable { + func onChange(of keyPath: KeyPath, perform closure: @escaping (Value) -> Void) { + _$simpleRegistrar.onChange(of: keyPath, perform: closure) + } +} diff --git a/Sources/SpeziBluetooth/Environment/SurroundingScanModifiers.swift b/Sources/SpeziBluetooth/Environment/SurroundingScanModifiers.swift new file mode 100644 index 00000000..4828953c --- /dev/null +++ b/Sources/SpeziBluetooth/Environment/SurroundingScanModifiers.swift @@ -0,0 +1,47 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +class SurroundingScanModifiers: EnvironmentKey { + static let defaultValue = SurroundingScanModifiers() + + @MainActor private var registeredModifiers: [AnyHashable: Set] = [:] + + @MainActor + func setModifierScanningState(enabled: Bool, with scanner: Scanner, modifierId: UUID) { + if enabled { + registeredModifiers[AnyHashable(scanner.id), default: []] + .insert(modifierId) + } else { + registeredModifiers[AnyHashable(scanner.id), default: []] + .remove(modifierId) + } + } + + @MainActor + func hasPersistentInterest(for scanner: Scanner) -> Bool { + guard let ids = registeredModifiers[AnyHashable(scanner.id)] else { + return false + } + return !ids.isEmpty + } +} + + +extension EnvironmentValues { + var surroundingScanModifiers: SurroundingScanModifiers { + get { + self[SurroundingScanModifiers.self] + } + set { + self[SurroundingScanModifiers.self] = newValue + } + } +} diff --git a/Sources/SpeziBluetooth/Model/Actions/BluetoothConnectAction.swift b/Sources/SpeziBluetooth/Model/Actions/BluetoothConnectAction.swift index 92af41fc..df56c3bf 100644 --- a/Sources/SpeziBluetooth/Model/Actions/BluetoothConnectAction.swift +++ b/Sources/SpeziBluetooth/Model/Actions/BluetoothConnectAction.swift @@ -11,15 +11,22 @@ /// /// For more information refer to ``DeviceActions/connect`` public struct BluetoothConnectAction: _BluetoothPeripheralAction { - private let peripheral: BluetoothPeripheral + public typealias ClosureType = () async -> Void + + private let content: _PeripheralActionContent @_documentation(visibility: internal) - public init(from peripheral: BluetoothPeripheral) { - self.peripheral = peripheral + public init(_ content: _PeripheralActionContent) { + self.content = content } public func callAsFunction() async { - await peripheral.connect() + switch content { + case let .peripheral(peripheral): + await peripheral.connect() + case let .injected(closure): + await closure() + } } } diff --git a/Sources/SpeziBluetooth/Model/Actions/BluetoothDisconnectAction.swift b/Sources/SpeziBluetooth/Model/Actions/BluetoothDisconnectAction.swift index 122dfb70..e6f4d385 100644 --- a/Sources/SpeziBluetooth/Model/Actions/BluetoothDisconnectAction.swift +++ b/Sources/SpeziBluetooth/Model/Actions/BluetoothDisconnectAction.swift @@ -11,14 +11,21 @@ /// /// For more information refer to ``DeviceActions/disconnect`` public struct BluetoothDisconnectAction: _BluetoothPeripheralAction { - private let peripheral: BluetoothPeripheral + public typealias ClosureType = () async -> Void + + private let content: _PeripheralActionContent @_documentation(visibility: internal) - public init(from peripheral: BluetoothPeripheral) { - self.peripheral = peripheral + public init(_ content: _PeripheralActionContent) { + self.content = content } public func callAsFunction() async { - await peripheral.disconnect() + switch content { + case let .peripheral(peripheral): + await peripheral.disconnect() + case let .injected(closure): + await closure() + } } } diff --git a/Sources/SpeziBluetooth/Model/Actions/BluetoothPeripheralAction.swift b/Sources/SpeziBluetooth/Model/Actions/BluetoothPeripheralAction.swift index 2a51e1d7..dc5a214d 100644 --- a/Sources/SpeziBluetooth/Model/Actions/BluetoothPeripheralAction.swift +++ b/Sources/SpeziBluetooth/Model/Actions/BluetoothPeripheralAction.swift @@ -6,13 +6,24 @@ // SPDX-License-Identifier: MIT // +/// The content of an implemented peripheral action. +public enum _PeripheralActionContent { // swiftlint:disable:this type_name file_types_order + /// Execute the action on the provided bluetooth peripheral. + case peripheral(BluetoothPeripheral) + /// Execute the injected closure instead. + case injected(ClosureType) +} + /// A action that can be reference using ``DeviceAction``. /// /// To implement a device action, implement a conforming type that implements /// a `callAsFunction()` method and declare the respective extension to ``DeviceActions``. public protocol _BluetoothPeripheralAction { // swiftlint:disable:this type_name + /// The closure type of the action. + associatedtype ClosureType + /// Create a new action for a given peripheral instance. - /// - Parameter peripheral: The bluetooth peripheral instance. - init(from peripheral: BluetoothPeripheral) + /// - Parameter content: The action content. + init(_ content: _PeripheralActionContent) } diff --git a/Sources/SpeziBluetooth/Model/Actions/ReadRSSIAction.swift b/Sources/SpeziBluetooth/Model/Actions/ReadRSSIAction.swift index d23cc727..62925cee 100644 --- a/Sources/SpeziBluetooth/Model/Actions/ReadRSSIAction.swift +++ b/Sources/SpeziBluetooth/Model/Actions/ReadRSSIAction.swift @@ -6,21 +6,27 @@ // SPDX-License-Identifier: MIT // - /// Read the current RSSI from the Bluetooth peripheral. /// /// For more information refer to ``DeviceActions/readRSSI`` public struct ReadRSSIAction: _BluetoothPeripheralAction { - private let peripheral: BluetoothPeripheral + public typealias ClosureType = () async throws -> Int + + private let content: _PeripheralActionContent @_documentation(visibility: internal) - public init(from peripheral: BluetoothPeripheral) { - self.peripheral = peripheral + public init(_ content: _PeripheralActionContent) { + self.content = content } @discardableResult public func callAsFunction() async throws -> Int { - try await peripheral.readRSSI() + switch content { + case let .peripheral(peripheral): + try await peripheral.readRSSI() + case let .injected(closure): + try await closure() + } } } diff --git a/Sources/SpeziBluetooth/Model/BluetoothDevice.swift b/Sources/SpeziBluetooth/Model/BluetoothDevice.swift index 2059f820..15b26380 100644 --- a/Sources/SpeziBluetooth/Model/BluetoothDevice.swift +++ b/Sources/SpeziBluetooth/Model/BluetoothDevice.swift @@ -22,10 +22,8 @@ import Spezi /// /// ```swift /// class MyDevice: BluetoothDevice { -/// @Service(id: "180A") -/// var deviceInformation = DeviceInformationService() -/// @Service(id: "180D") -/// var heartRate = HeartRateService() +/// @Service var deviceInformation = DeviceInformationService() +/// @Service var heartRate = HeartRateService() /// /// init() {} /// } @@ -35,7 +33,7 @@ public protocol BluetoothDevice: AnyObject, EnvironmentAccessible { /// /// This initializer is called automatically when a peripheral of this type connects. /// - /// - Important: All property wrappers are only available after the initializer returned. + /// The initializer is called on the Bluetooth Task. /// /// - Note: This initializer is also called upon configuration to inspect the device structure. /// You might want to make sure to not perform any heavy processing within the initializer. diff --git a/Sources/SpeziBluetooth/Model/BluetoothService.swift b/Sources/SpeziBluetooth/Model/BluetoothService.swift index 083a54ac..98840e40 100644 --- a/Sources/SpeziBluetooth/Model/BluetoothService.swift +++ b/Sources/SpeziBluetooth/Model/BluetoothService.swift @@ -6,6 +6,8 @@ // SPDX-License-Identifier: MIT // +import class CoreBluetooth.CBUUID + /// A Bluetooth service implementation. /// @@ -19,10 +21,15 @@ /// /// ```swift /// class DeviceInformationService: BluetoothService { +/// static let id = CBUUID(string: "180A") +/// /// @Characteristic(id: "2A29") /// var manufacturer: String? /// @Characteristic(id: "2A26") /// var firmwareRevision: String? /// } /// ``` -public protocol BluetoothService: AnyObject {} +public protocol BluetoothService: AnyObject { + /// The Bluetooth service id. + static var id: CBUUID { get } +} diff --git a/Sources/SpeziBluetooth/Model/Characteristic/CharacteristicAccessor.swift b/Sources/SpeziBluetooth/Model/Characteristic/CharacteristicAccessor.swift deleted file mode 100644 index 970bc4c1..00000000 --- a/Sources/SpeziBluetooth/Model/Characteristic/CharacteristicAccessor.swift +++ /dev/null @@ -1,145 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import CoreBluetooth - - -/// Interact with a given Characteristic. -/// -/// This type allows you to interact with a Characteristic you previously declared using the ``Characteristic`` property wrapper. -/// -/// ## Topics -/// -/// ### Characteristic properties -/// - ``isPresent`` -/// - ``properties`` -/// - ``descriptors`` -/// -/// ### Reading a value -/// - ``read()`` -/// -/// ### Writing a value -/// - ``write(_:)`` -/// - ``writeWithoutResponse(_:)`` -/// -/// ### Controlling notifications -/// - ``isNotifying`` -/// - ``enableNotifications(_:)`` -public struct CharacteristicAccessors { - let id: CBUUID - fileprivate let context: CharacteristicContext - - - init(id: CBUUID, context: CharacteristicContext) { - self.id = id - self.context = context - } -} - - -extension CharacteristicAccessors { - /// Determine if the characteristic is available. - /// - /// Returns true if the characteristic is available for the current device. - /// It is ture if (a) the device is connected and (b) the device exposes the requested characteristic. - public var isPresent: Bool { - context.characteristic != nil - } - - /// Properties of the characteristic. - /// - /// Nil if device is not connected. - public var properties: CBCharacteristicProperties? { - context.characteristic?.properties - } - - /// Descriptors of the characteristic. - /// - /// Nil if device is not connected or descriptors are not yet discovered. - public var descriptors: [CBDescriptor]? { // swiftlint:disable:this discouraged_optional_collection - context.characteristic?.descriptors - } -} - - -extension CharacteristicAccessors where Value: ByteDecodable { - /// Characteristic is currently notifying about updated values. - /// - /// This is false if device is not connected. - public var isNotifying: Bool { - context.characteristic?.isNotifying ?? false - } - - - /// Enable or disable characteristic notifications. - /// - Parameter enable: Flag indicating if notifications should be enabled. - public func enableNotifications(_ enable: Bool = true) async { - if enable { - await context.enableNotifications() - } else { - await context.disableNotifications() - } - } - - /// Read the current characteristic value from the remote peripheral. - /// - Returns: The value that was read. - /// - Throws: Throws an `CBError` or `CBATTError` if the read fails. - /// It might also throw a ``BluetoothError/notPresent`` or ``BluetoothError/incompatibleDataFormat`` error. - @discardableResult - public func read() async throws -> Value { - guard let characteristic = context.characteristic else { - throw BluetoothError.notPresent - } - - let data = try await context.peripheral.read(characteristic: characteristic) - guard let value = Value(data: data) else { - throw BluetoothError.incompatibleDataFormat - } - return value - } -} - - -extension CharacteristicAccessors where Value: ByteEncodable { - /// Write the value of a characteristic expecting a confirmation. - /// - /// Writes the value of a characteristic expecting a confirmation from the peripheral. - /// - /// - Note: The write operation is specified in Bluetooth Core Specification, Volume 3, - /// Part G, 4.9.3 Write Characteristic Value. - /// - /// - Parameter value: The value you want to write. - /// - Throws: Throws an `CBError` or `CBATTError` if the write fails. - /// It might also throw a ``BluetoothError/notPresent`` error. - public func write(_ value: Value) async throws { - guard let characteristic = context.characteristic else { - throw BluetoothError.notPresent - } - - let requestData = value.encode() - try await context.peripheral.write(data: requestData, for: characteristic) - } - - /// Write the value of a characteristic without expecting a confirmation. - /// - /// Writes the value of a characteristic without expecting a confirmation from the peripheral. - /// - /// - Note: The write operation is specified in Bluetooth Core Specification, Volume 3, - /// Part G, 4.9.1 Write Without Response. - /// - Parameter value: The value you want to write. - /// - Throws: Throws an `CBError` or `CBATTError` if the write fails. - /// It might also throw a ``BluetoothError/notPresent`` error. - public func writeWithoutResponse(_ value: Value) async throws { - guard let characteristic = context.characteristic else { - throw BluetoothError.notPresent - } - - let data = value.encode() - await context.peripheral.writeWithoutResponse(data: data, for: characteristic) - } -} diff --git a/Sources/SpeziBluetooth/Model/Characteristic/CharacteristicContext.swift b/Sources/SpeziBluetooth/Model/Characteristic/CharacteristicContext.swift deleted file mode 100644 index 0bedc1db..00000000 --- a/Sources/SpeziBluetooth/Model/Characteristic/CharacteristicContext.swift +++ /dev/null @@ -1,164 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import CoreBluetooth - - -/// Indirect storage box to support a write-only lock with eventual consistent reads. -class OptionalBox { - fileprivate(set) var value: Value? - - init(value: Value?) { - self.value = value - } -} - - -private protocol DecodableCharacteristic { - func handleUpdateValueAssumingIsolation(_ data: Data?) -} - - -/// Captures and synchronizes access to the state of a ``Characteristic`` property wrapper. -actor CharacteristicContext { - let peripheral: BluetoothPeripheral - let characteristicId: CBUUID - let serviceId: CBUUID - - private let characteristicBox: OptionalBox - private let valueBox: OptionalBox - - private var notify = false - private var registration: CharacteristicNotification? - - nonisolated var characteristic: CBCharacteristic? { // nil if device is not connected yet - characteristicBox.value - } - - nonisolated var value: Value? { - valueBox.value - } - - - init( - peripheral: BluetoothPeripheral, - serviceId: CBUUID, - characteristicId: CBUUID, - characteristic: CBCharacteristic? - ) { - self.peripheral = peripheral - self.serviceId = serviceId - self.characteristicId = characteristicId - self.characteristicBox = OptionalBox(value: characteristic) - self.valueBox = OptionalBox(value: nil) - } - - /// Setup the context. Must be called after initialization to set up all handlers and write the initial value. - /// - Parameter defaultNotify: Flag indicating if notification handlers should be registered immediately. - func setup(defaultNotify: Bool) async { - trackServicesUpdates() // enable observation tracking for peripheral.services - - if let instance = self as? DecodableCharacteristic { // Value is ByteDecodable! - // handle assigning the initial value! - if let characteristic, - let value = characteristic.value { - instance.handleUpdateValueAssumingIsolation(value) - } - - if defaultNotify { - await enableNotifications() - } - } - } - - /// Enable notifications (if not already) for the characteristic. - func enableNotifications() async { - guard !notify else { - return - } - - self.notify = true - - let registration = await peripheral - .registerNotifications(service: serviceId, characteristic: characteristicId) { [weak self] data in - Task { [weak self] in - await self?.handleNotification(data) - } - } - - // we have a suspension point above, so we need to double check that our `notify` property is still true to catch any race conditions - - if notify { - self.registration = registration - } else { - // notifications were disabled in the meantime. Remove our registration again. - await registration.cancel() - } - } - - /// Disable notifications (if not already) for the characteristic. - func disableNotifications() async { - guard notify else { - return - } - - let registration = self.registration - - self.notify = false - self.registration = nil - - await registration?.cancel() - } - - private nonisolated func trackServicesUpdates() { - withObservationTracking { - _ = peripheral.services - } onChange: { [weak self] in - Task { [weak self] in - await self?.handleServicesChange() - } - self?.trackServicesUpdates() - } - } - - private func handleServicesChange() { - let service = peripheral.services?.first(where: { $0.uuid == serviceId }) - let characteristic = service?.characteristics?.first(where: { $0.uuid == characteristicId }) - - characteristicBox.value = characteristic - - if characteristic == nil { // device disconnected, remove the value - valueBox.value = nil - } - } - - private func handleNotification(_ data: Data?) { - guard let decodable = self as? DecodableCharacteristic else { - return - } - - decodable.handleUpdateValueAssumingIsolation(data) - } -} - - -extension CharacteristicContext: DecodableCharacteristic where Value: ByteDecodable { - nonisolated func handleUpdateValueAssumingIsolation(_ data: Data?) { - // assumes this is called with actor isolation! - if let data { - guard let value = Value(data: data) else { - Bluetooth.logger.error("Could decode updated value for characteristic \(self.characteristic?.debugIdentifier ?? self.characteristicId.uuidString). Invalid format!") - return - } - - self.valueBox.value = value - } else { - self.valueBox.value = nil - } - } -} diff --git a/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift b/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift index 3e4cfcd8..261f8d28 100644 --- a/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift +++ b/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift @@ -18,13 +18,15 @@ import Foundation /// If your device is connected, the characteristic value is automatically updated upon a characteristic read or a notify. /// /// - Note: Every `Characteristic` is [Observable](https://developer.apple.com/documentation/Observation) out of the box. -/// So you can easily use the characteristic value within your SwiftUI view and it will be automatically re-rendered +/// You can easily use the characteristic value within your SwiftUI view and the view will be automatically re-rendered /// when the characteristic value is updated. /// /// The below code example demonstrates declaring the Firmware Revision characteristic of the Device Information service. /// /// ```swift /// class DeviceInformationService: BluetoothService { +/// static let id = CBUUID(string: "180A") +/// /// @Characteristic(id: "2A26") /// var firmwareRevision: String? /// } @@ -35,22 +37,36 @@ import Foundation /// If your characteristic supports notifications, you can automatically subscribe to characteristic notifications /// by supplying the `notify` initializer argument. /// +/// - Tip: If you want to react to every change of the characteristic value, you can use +/// ``CharacteristicAccessor/onChange(perform:)`` to set up your action. +/// /// The below code example uses the [Bluetooth Heart Rate Service](https://www.bluetooth.com/specifications/specs/heart-rate-service-1-0) /// to demonstrate the automatic notifications feature for the Heart Rate Measurement characteristic. /// +/// - Important: This closure is called from the Bluetooth Serial Executor, if you don't pass in an async method +/// that has an annotated actor isolation (e.g., `@MainActor` or actor isolated methods). +/// /// ```swift /// class HeartRateService: BluetoothService { +/// static let id = CBUUID(string: "180D") +/// /// @Characteristic(id: "2A37", notify: true) /// var heartRateMeasurement: HeartRateMeasurement? /// -/// init() {} +/// init() { +/// $heartRateMeasurement.onChange(perform: processMeasurement) +/// } +/// +/// func processMeasurement(_ measurement: HeartRateMeasurement) { +/// // process measurements ... +/// } /// } /// ``` /// /// ### Characteristic Interactions /// /// To interact with a characteristic to read or write a value or enable or disable notifications, -/// you can use the ``projectedValue`` (`$` notation) to retrieve a temporary ``CharacteristicAccessors`` instance. +/// you can use the ``projectedValue`` (`$` notation) to retrieve a temporary ``CharacteristicAccessor`` instance. /// /// Do demonstrate this functionality, we completed the implementation of our Heart Rate Service /// according to its [Specification](https://www.bluetooth.com/specifications/specs/heart-rate-service-1-0). @@ -59,6 +75,8 @@ import Foundation /// /// ```swift /// class HeartRateService: BluetoothService { +/// static let id = CBUUID(string: "180D") +/// /// @Characteristic(id: "2A37", notify: true) /// var heartRateMeasurement: HeartRateMeasurement? /// @Characteristic(id: "2A38") @@ -107,87 +125,101 @@ import Foundation /// - ``init(wrappedValue:id:notify:discoverDescriptors:)-9zex3`` /// /// ### Inspecting a Characteristic -/// - ``CharacteristicAccessors/isPresent`` -/// - ``CharacteristicAccessors/properties`` -/// - ``CharacteristicAccessors/descriptors`` +/// - ``CharacteristicAccessor/isPresent`` +/// - ``CharacteristicAccessor/properties`` +/// - ``CharacteristicAccessor/descriptors`` /// /// ### Reading a value -/// - ``CharacteristicAccessors/read()`` +/// - ``CharacteristicAccessor/read()`` +/// +/// ### Writing a value +/// - ``CharacteristicAccessor/write(_:)`` +/// - ``CharacteristicAccessor/writeWithoutResponse(_:)`` /// /// ### Controlling notifications -/// - ``CharacteristicAccessors/isNotifying`` -/// - ``CharacteristicAccessors/enableNotifications(_:)`` +/// - ``CharacteristicAccessor/isNotifying`` +/// - ``CharacteristicAccessor/enableNotifications(_:)`` /// -/// ### Writing a value -/// - ``CharacteristicAccessors/write(_:)`` -/// - ``CharacteristicAccessors/writeWithoutResponse(_:)`` +/// ### Get notified about changes +/// - ``CharacteristicAccessor/onChange(perform:)`` /// /// ### Property wrapper access /// - ``wrappedValue`` /// - ``projectedValue`` -/// - ``CharacteristicAccessors`` -@Observable +/// - ``CharacteristicAccessor`` @propertyWrapper -public class Characteristic { - private let id: CBUUID - private let discoverDescriptors: Bool +public final class Characteristic: @unchecked Sendable { + class Configuration { + let id: CBUUID + let discoverDescriptors: Bool - private let defaultValue: Value? - private let defaultNotify: Bool + var defaultNotify: Bool + + /// Memory address as an identifier for this Characteristic instance. + var objectId: ObjectIdentifier { + ObjectIdentifier(self) + } + + init(id: CBUUID, discoverDescriptors: Bool, defaultNotify: Bool) { + self.id = id + self.discoverDescriptors = discoverDescriptors + self.defaultNotify = defaultNotify + } + } + + let configuration: Configuration + private let _value: ObservableBox + private(set) var injection: CharacteristicPeripheralInjection? var description: CharacteristicDescription { - CharacteristicDescription(id: id, discoverDescriptors: discoverDescriptors) + CharacteristicDescription(id: configuration.id, discoverDescriptors: configuration.discoverDescriptors) } /// Access the current characteristic value. /// /// This is either the last read value or the latest notified value. public var wrappedValue: Value? { - guard let context else { - return defaultValue - } - return context.value + _value.value } /// Retrieve a temporary accessors instance. - public var projectedValue: CharacteristicAccessors { - guard let context else { - preconditionFailure( - """ - Failed to access bluetooth characteristic. Make sure your @Characteristic is only declared within your bluetooth device class \ - that is managed by SpeziBluetooth. - """ - ) - } - return CharacteristicAccessors(id: id, context: context) + /// + /// This type allows you to interact with a Characteristic. + /// + /// - Note: The accessor captures the characteristic instance upon creation. Within the same `CharacteristicAccessor` instance + /// the view on the characteristic is consistent (characteristic exists vs. it doesn't, the underlying values themselves might still change). + /// However, if you project a new `CharacteristicAccessor` instance right after your access, + /// the view on the characteristic might have changed due to the asynchronous nature of SpeziBluetooth. + public var projectedValue: CharacteristicAccessor { + CharacteristicAccessor(configuration: configuration, injection: injection, value: _value) } - private var context: CharacteristicContext? - fileprivate init(wrappedValue: Value? = nil, characteristic: CBUUID, notify: Bool, discoverDescriptors: Bool = false) { // swiftlint:disable:previous function_default_parameter_at_end - self.defaultValue = wrappedValue - self.id = characteristic - self.defaultNotify = notify - self.discoverDescriptors = discoverDescriptors + self.configuration = .init(id: characteristic, discoverDescriptors: discoverDescriptors, defaultNotify: notify) + self._value = ObservableBox(wrappedValue) } - @MainActor - func inject(peripheral: BluetoothPeripheral, serviceId: CBUUID, service: CBService?) { - let characteristic = service?.characteristics?.first(where: { $0.uuid == self.id }) + func inject(peripheral: BluetoothPeripheral, serviceId: CBUUID, service: GATTService?) { + let characteristic = service?.getCharacteristic(id: configuration.id) + + // Any potential onChange closure registration that happened within the initializer. Forward them to the injection. + let onChangeClosure = ClosureRegistrar.readableView?.retrieve(for: configuration.objectId, value: Value.self) - let context = CharacteristicContext( + let injection = CharacteristicPeripheralInjection( peripheral: peripheral, serviceId: serviceId, - characteristicId: self.id, - characteristic: characteristic + characteristicId: configuration.id, + value: _value, + characteristic: characteristic, + onChangeClosure: onChangeClosure ) - self.context = context - - Task { - await context.setup(defaultNotify: defaultNotify) + // mutual access with `CharacteristicAccessor/enableNotifications` + self.injection = injection + injection.assumeIsolated { injection in + injection.setup(defaultNotify: configuration.defaultNotify) } } } diff --git a/Sources/SpeziBluetooth/Model/Properties/DeviceAction.swift b/Sources/SpeziBluetooth/Model/Properties/DeviceAction.swift index cbe51e02..d4064d6a 100644 --- a/Sources/SpeziBluetooth/Model/Properties/DeviceAction.swift +++ b/Sources/SpeziBluetooth/Model/Properties/DeviceAction.swift @@ -15,7 +15,7 @@ /// Below is a short code example that demonstrates the usage of the `DeviceAction` property wrapper to /// execute the connect and disconnect actions of a device. /// -/// - Note: The `@DeviceAction` property wrapper can only be accessed after the initializer returned. Accessing within the initializer will result in a runtime crash. +/// - Important: The `@DeviceAction` property wrapper can only be accessed after the initializer returned. Accessing within the initializer will result in a runtime crash. /// /// ```swift /// class ExampleDevice: BluetoothDevice { @@ -39,24 +39,35 @@ /// /// ## Topics /// +/// ### Declaring a device action +/// - ``init(_:)`` +/// /// ### Available Device Actions /// - ``DeviceActions/connect`` /// - ``DeviceActions/disconnect`` /// - ``DeviceActions/readRSSI`` /// -/// ### Declaring a device action -/// - ``init(_:)`` -/// /// ### Property wrapper access /// - ``wrappedValue`` +/// - ``projectedValue`` +/// - ``DeviceActionAccessor`` /// /// ### Device Actions /// - ``DeviceActions`` @propertyWrapper -public class DeviceAction { +public final class DeviceAction: @unchecked Sendable { + private var peripheral: BluetoothPeripheral? + /// Support injection of closures for testing support. + private let _injectedClosure = Box(nil) + + /// Access the device action. public var wrappedValue: Action { guard let peripheral else { + if let injectedClosure = _injectedClosure.value { + return Action(.injected(injectedClosure)) + } + preconditionFailure( """ Failed to access bluetooth device action. Make sure your @DeviceAction is only declared within your bluetooth device class \ @@ -64,10 +75,13 @@ public class DeviceAction { """ ) } - return Action(from: peripheral) + return Action(.peripheral(peripheral)) } - private var peripheral: BluetoothPeripheral? + /// Retrieve a temporary accessors instance. + public var projectedValue: DeviceActionAccessor { + DeviceActionAccessor(_injectedClosure) + } /// Provide a `KeyPath` to the device action you want to access. diff --git a/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift b/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift index 06d5b9f1..2eb1f4af 100644 --- a/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift +++ b/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift @@ -6,16 +6,22 @@ // SPDX-License-Identifier: MIT // +import Observation + /// Retrieve state of a Bluetooth peripheral. /// /// This property wrapper can be used within your ``BluetoothDevice`` or ``BluetoothService`` models to /// get access to the state of your Bluetooth peripheral. /// +/// - Note: Every `DeviceState` is [Observable](https://developer.apple.com/documentation/Observation) out of the box. +/// You can easily use the state value within your SwiftUI view and the view will be automatically re-rendered +/// when the state value is updated. +/// /// Below is a short code example that demonstrate the usage of the `DeviceState` property wrapper to retrieve the name and current ``BluetoothState`` /// of a device. /// -/// - Note: The `@DeviceState` property wrapper can only be accessed after the initializer returned. Accessing within the initializer will result in a runtime crash. +/// - Important: The `wrappedValue` of the property wrapper can only be safely accessed after the initializer returned. Accessing within the initializer will result in a runtime crash. /// /// ```swift /// class ExampleDevice: BluetoothDevice { @@ -31,8 +37,34 @@ /// } /// ``` /// +/// ### Handling changes +/// +/// While the `DeviceState` property wrapper is fully compatible with Apples Observation framework, it might be +/// useful to explicitly handle updates to the a device state. +/// You can register a change handler via the accessor type obtained through the projected value. +/// +/// The below code examples demonstrates this approach: +/// +/// ```swift +/// class MyDevice: BluetoothDevice { +/// @DeviceState(\.state) +/// var state: PeripheralState +/// +/// init() { +/// $state.onChange(perform: handleStateChange) +/// } +/// +/// handleStateChange(_ state: PeripheralState) { +/// // ... +/// } +/// } +/// ``` +/// /// ## Topics /// +/// ### Declaring device state +/// - ``init(_:)`` +/// /// ### Available Device States /// - ``BluetoothPeripheral/id`` /// - ``BluetoothPeripheral/name`` @@ -40,19 +72,31 @@ /// - ``BluetoothPeripheral/rssi`` /// - ``BluetoothPeripheral/advertisementData`` /// -/// ### Declaring device state -/// - ``init(_:)`` +/// ### Get notified about changes +/// - ``DeviceStateAccessor/onChange(perform:)`` /// /// ### Property wrapper access /// - ``wrappedValue`` +/// - ``projectedValue`` +/// - ``DeviceStateAccessor`` +@Observable @propertyWrapper -public class DeviceState { +public final class DeviceState: @unchecked Sendable { private let keyPath: KeyPath - private var peripheral: BluetoothPeripheral? + private(set) var injection: DeviceStatePeripheralInjection? + private var _injectedValue = ObservableBox(nil) + + var objectId: ObjectIdentifier { + ObjectIdentifier(self) + } /// Access the device state. public var wrappedValue: Value { - guard let peripheral else { + guard let injection else { + if let defaultValue { // better support previews with some default values + return defaultValue + } + preconditionFailure( """ Failed to access bluetooth device state. Make sure your @DeviceState is only declared within your bluetooth device class \ @@ -60,7 +104,13 @@ public class DeviceState { """ ) } - return peripheral[keyPath: keyPath] + return injection.peripheral[keyPath: keyPath] + } + + + /// Retrieve a temporary accessors instance. + public var projectedValue: DeviceStateAccessor { + DeviceStateAccessor(id: objectId, injection: injection, injectedValue: _injectedValue) } @@ -72,7 +122,14 @@ public class DeviceState { func inject(peripheral: BluetoothPeripheral) { - self.peripheral = peripheral + let changeClosure = ClosureRegistrar.readableView?.retrieve(for: objectId, value: Value.self) + + let injection = DeviceStatePeripheralInjection(peripheral: peripheral, keyPath: keyPath, onChangeClosure: changeClosure) + self.injection = injection + + injection.assumeIsolated { injection in + injection.setup() + } } } @@ -86,3 +143,38 @@ extension DeviceState: DeviceVisitable, ServiceVisitable { visitor.visit(self) } } + + +extension DeviceState { + var defaultValue: Value? { + if let injected = _injectedValue.value { + return injected + } + + let value: Any? = switch keyPath { + case \.id: + nil // we cannot provide a stable id? + case \.name: + Optional.none as Any + case \.state: + PeripheralState.disconnected + case \.advertisementData: + AdvertisementData(advertisementData: [:]) + case \.rssi: + Int(UInt8.max) + case \.services: + Optional<[GATTService]>.none as Any + default: + nil + } + + guard let value else { + return nil + } + + guard let value = value as? Value else { + preconditionFailure("Default value \(value) was not the expected type for \(keyPath)") + } + return value + } +} diff --git a/Sources/SpeziBluetooth/Model/Properties/Service.swift b/Sources/SpeziBluetooth/Model/Properties/Service.swift index c8628d03..cf015f38 100644 --- a/Sources/SpeziBluetooth/Model/Properties/Service.swift +++ b/Sources/SpeziBluetooth/Model/Properties/Service.swift @@ -18,44 +18,60 @@ import CoreBluetooth /// Below is a short code example on how you would declare your [Bluetooth Heart Rate Service](https://www.bluetooth.com/specifications/specs/heart-rate-service-1-0) /// implementation within your Bluetooth device. /// -/// ``swift +/// ```swift /// class MyDevice: BluetoothDevice { -/// @Service(id: "180D") -/// var heartRate = HeartRateService() +/// @Service var heartRate = HeartRateService() /// } /// ``` /// /// ## Topics /// /// ### Declaring a Service -/// - ``init(wrappedValue:id:)-2mo8b`` -/// - ``init(wrappedValue:id:)-1if8d`` +/// - ``init(wrappedValue:)`` +/// +/// ### Inspecting a Service +/// - ``ServiceAccessor/isPresent`` +/// - ``ServiceAccessor/isPrimary`` /// /// ### Property wrapper access /// - ``wrappedValue`` +/// - ``projectedValue`` +/// - ``ServiceAccessor`` @propertyWrapper -public class Service { - let id: CBUUID +public final class Service: @unchecked Sendable { + var id: CBUUID { + S.id + } + private var injection: ServicePeripheralInjection? /// Access the service instance. public let wrappedValue: S - - /// Declare a service. - /// - Parameters: - /// - wrappedValue: The service instance. - /// - id: The service id. - public convenience init(wrappedValue: S, id: String) { - self.init(wrappedValue: wrappedValue, id: CBUUID(string: id)) + /// Retrieve a temporary accessors instance. + /// + /// This type allows you to interact with a Service's properties. + /// + /// - Note: The accessor captures the service instance upon creation. Within the same `ServiceAccessor` instance + /// the view on the service is consistent. However, if you project a new `ServiceAccessor` instance right + /// after your access, the view on the service might have changed due to the asynchronous nature of SpeziBluetooth. + public var projectedValue: ServiceAccessor { + ServiceAccessor(id: id, injection: injection) } /// Declare a service. /// - Parameters: /// - wrappedValue: The service instance. - /// - id: The service id. - public init(wrappedValue: S, id: CBUUID) { + public init(wrappedValue: S) { self.wrappedValue = wrappedValue - self.id = id + } + + + func inject(peripheral: BluetoothPeripheral, service: GATTService?) { + let injection = ServicePeripheralInjection(peripheral: peripheral, serviceId: id, service: service) + self.injection = injection + injection.assumeIsolated { injection in + injection.setup() + } } } diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift new file mode 100644 index 00000000..aa2e02c8 --- /dev/null +++ b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift @@ -0,0 +1,217 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import CoreBluetooth + + +/// Interact with a given Characteristic. +/// +/// This type allows you to interact with a Characteristic you previously declared using the ``Characteristic`` property wrapper. +/// +/// - Note: The accessor captures the characteristic instance upon creation. Within the same `CharacteristicAccessor` instance +/// the view on the characteristic is consistent (characteristic exists vs. it doesn't, the underlying values themselves might still change). +/// However, if you project a new `CharacteristicAccessor` instance right after your access, +/// the view on the characteristic might have changed due to the asynchronous nature of SpeziBluetooth. +/// +/// ## Topics +/// +/// ### Characteristic properties +/// - ``isPresent`` +/// - ``properties`` +/// - ``descriptors`` +/// +/// ### Reading a value +/// - ``read()`` +/// +/// ### Writing a value +/// - ``write(_:)`` +/// - ``writeWithoutResponse(_:)`` +/// +/// ### Controlling notifications +/// - ``isNotifying`` +/// - ``enableNotifications(_:)`` +/// +/// ### Get notified about changes +/// - ``onChange(perform:)`` +public struct CharacteristicAccessor { + private let configuration: Characteristic.Configuration + private let injection: CharacteristicPeripheralInjection? + /// We keep track of this for testing support. + private let _value: ObservableBox + /// Capture of the characteristic. + private let characteristic: GATTCharacteristic? + + + init(configuration: Characteristic.Configuration, injection: CharacteristicPeripheralInjection?, value: ObservableBox) { + self.configuration = configuration + self.injection = injection + self._value = value + self.characteristic = injection?.unsafeCharacteristic + } +} + + +extension CharacteristicAccessor { + /// Determine if the characteristic is available. + /// + /// Returns `true` if the characteristic is available for the current device. + /// It is `true` if (a) the device is connected and (b) the device exposes the requested characteristic. + public var isPresent: Bool { + characteristic != nil + } + + /// Properties of the characteristic. + /// + /// `nil` if device is not connected. + public var properties: CBCharacteristicProperties? { + characteristic?.properties + } + + /// Descriptors of the characteristic. + /// + /// `nil` if device is not connected or descriptors are not yet discovered. + public var descriptors: [CBDescriptor]? { // swiftlint:disable:this discouraged_optional_collection + characteristic?.descriptors + } +} + + +extension CharacteristicAccessor where Value: ByteDecodable { + /// Characteristic is currently notifying about updated values. + /// + /// This is also `false` if device is not connected. + public var isNotifying: Bool { + characteristic?.isNotifying ?? false + } + + + /// Perform action whenever the characteristic value changes. + /// + /// - Important: This closure is called from the Bluetooth Serial Executor, if you don't pass in an async method + /// that has an annotated actor isolation (e.g., `@MainActor` or actor isolated methods). + /// + /// - Note: It is perfectly fine if you capture strongly self within your closure. The framework will + /// resolve any reference cycles for you. + /// - Parameter perform: The change handler to register. + public func onChange(perform: @escaping (Value) async -> Void) { + guard let injection else { + guard let closures = ClosureRegistrar.writeableView else { + Bluetooth.logger.warning( + """ + Tried to register onChange(perform:) closure out-of-band. Make sure to register your onChange closure \ + within the initializer or when the peripheral is fully injected. This is expected if you manually initialized your device. \ + The closure was discarded and won't have any effect. + """ + ) + return + } + // We save the instance in the global registrar if its available. + // It will be available if we are instantiated through the Bluetooth module. + // This indirection is required to support self referencing closures without encountering a strong reference cycle. + closures.insert(for: configuration.objectId, closure: perform) + return + } + + // global actor ensures these tasks are queued serially and are executed in order. + Task { @SpeziBluetooth in + await injection.setOnChangeClosure(perform) + } + } + + + /// Enable or disable characteristic notifications. + /// - Parameter enabled: Flag indicating if notifications should be enabled. + public func enableNotifications(_ enabled: Bool = true) async { + guard let injection else { + // this value will be populated to the injection once it is set up + configuration.defaultNotify = enabled + + if ClosureRegistrar.writeableView == nil { + Bluetooth.logger.warning( + """ + Tried to \(enabled ? "enable" : "disable") notifications out-of-band. Make sure to change notification settings \ + within the initializer or when the peripheral is fully injected. This is expected if you manually initialized your device. \ + The change was discarded and won't have any effect. + """ + ) + } + return + } + + await injection.enableNotifications(enabled) + } + + /// Read the current characteristic value from the remote peripheral. + /// - Returns: The value that was read. + /// - Throws: Throws an `CBError` or `CBATTError` if the read fails. + /// It might also throw a ``BluetoothError/notPresent(service:characteristic:)`` or ``BluetoothError/incompatibleDataFormat`` error. + @discardableResult + public func read() async throws -> Value { + guard let injection else { + throw BluetoothError.notPresent(characteristic: configuration.id) + } + + return try await injection.read() + } +} + + +extension CharacteristicAccessor where Value: ByteEncodable { + /// Write the value of a characteristic expecting a confirmation. + /// + /// Writes the value of a characteristic expecting a confirmation from the peripheral. + /// + /// - Note: The write operation is specified in Bluetooth Core Specification, Volume 3, + /// Part G, 4.9.3 Write Characteristic Value. + /// + /// - Parameter value: The value you want to write. + /// - Throws: Throws an `CBError` or `CBATTError` if the write fails. + /// It might also throw a ``BluetoothError/notPresent(service:characteristic:)`` error. + public func write(_ value: Value) async throws { + guard let injection else { + throw BluetoothError.notPresent(characteristic: configuration.id) + } + + try await injection.write(value) + } + + /// Write the value of a characteristic without expecting a confirmation. + /// + /// Writes the value of a characteristic without expecting a confirmation from the peripheral. + /// + /// - Note: The write operation is specified in Bluetooth Core Specification, Volume 3, + /// Part G, 4.9.1 Write Without Response. + /// - Parameter value: The value you want to write. + /// - Throws: Throws an `CBError` or `CBATTError` if the write fails. + /// It might also throw a ``BluetoothError/notPresent(service:characteristic:)`` error. + public func writeWithoutResponse(_ value: Value) async throws { + guard let injection else { + throw BluetoothError.notPresent(characteristic: configuration.id) + } + + try await injection.writeWithoutResponse(value) + } +} + +// MARK: - Testing Support + +@_spi(TestingSupport) +extension CharacteristicAccessor { + /// Inject a custom value for previewing purposes. + /// + /// This method can be used to inject a custom characteristic value. + /// This is particularly helpful when writing SwiftUI previews or doing UI testing. + /// + /// - Note: `onChange` closures are currently not supported. If required, you should + /// call your onChange closures manually. + /// + /// - Parameter value: The value to inject. + public func inject(_ value: Value) { + _value.value = value + } +} diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift new file mode 100644 index 00000000..f356125d --- /dev/null +++ b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift @@ -0,0 +1,264 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import CoreBluetooth + + +private protocol DecodableCharacteristic: Actor { + func handleUpdateValueAssumingIsolation(_ data: Data?) +} + + +/// Captures and synchronizes access to the state of a ``Characteristic`` property wrapper. +actor CharacteristicPeripheralInjection: BluetoothActor { + let bluetoothQueue: DispatchSerialQueue + + let peripheral: BluetoothPeripheral + let serviceId: CBUUID + let characteristicId: CBUUID + + /// Observable value. Don't access directly. + private let _value: ObservableBox + /// Don't access directly. Observable for the properties of ``CharacteristicAccessor``. + private let _characteristic: WeakObservableBox + + /// The user supplied onChange closure we use to forward notifications. + private var onChangeClosure: ChangeClosure + /// The registration object we received from the ``BluetoothPeripheral`` for our instance onChange handler. + private var instanceRegistration: OnChangeRegistration? + /// The registration object we received from the ``BluetoothPeripheral`` for our value onChange handler. + private var valueRegistration: OnChangeRegistration? + + + private(set) var value: Value? { + get { + _value.value + } + set { + _value.value = newValue + } + } + + private var characteristic: GATTCharacteristic? { + get { + _characteristic.value + } + set { + _characteristic.value = newValue + } + } + + nonisolated var unsafeCharacteristic: GATTCharacteristic? { + _characteristic.value + } + + + init( + peripheral: BluetoothPeripheral, + serviceId: CBUUID, + characteristicId: CBUUID, + value: ObservableBox, + characteristic: GATTCharacteristic?, + onChangeClosure: ((Value) async -> Void)? + ) { + self.bluetoothQueue = peripheral.bluetoothQueue + self.peripheral = peripheral + self.serviceId = serviceId + self.characteristicId = characteristicId + self._value = value + self._characteristic = .init(characteristic) + self.onChangeClosure = onChangeClosure.map { .value($0) } ?? .none + } + + /// Setup the injection. Must be called after initialization to set up all handlers and write the initial value. + /// - Parameter defaultNotify: Flag indicating if notification handlers should be registered immediately. + func setup(defaultNotify: Bool) { + registerCharacteristicInstanceChanges() + + guard let instance = self as? DecodableCharacteristic else { + return + } + // value is readable! + + // handle assigning the initial value! + if let characteristic, + let value = characteristic.value { + instance.assumeIsolated { $0.handleUpdateValueAssumingIsolation(value) } + } + + self.registerCharacteristicValueChanges() + + if defaultNotify { + enableNotifications() + } + } + + /// Signal from the Bluetooth state to cleanup the device + func clearState() { + self.instanceRegistration?.cancel() + self.instanceRegistration = nil + self.valueRegistration?.cancel() + self.valueRegistration = nil + self.onChangeClosure = .cleared // might contain a self reference, so we need to clear that! + } + + + func setOnChangeClosure(_ closure: @escaping (Value) async -> Void) { + if case .cleared = onChangeClosure { + // object is about to be cleared. Make sure we don't create a self reference last minute. + return + } + self.onChangeClosure = .value(closure) + } + + /// Enable or disable notifications for the characteristic. + /// - Parameter enabled: Flag indicating if notifications should be enabled. + func enableNotifications(_ enabled: Bool = true) { + peripheral.assumeIsolated { peripheral in + peripheral.enableNotifications(enabled, serviceId: serviceId, characteristicId: characteristicId) + } + } + + private func registerCharacteristicInstanceChanges() { + self.instanceRegistration = peripheral.assumeIsolated { peripheral in + peripheral.registerOnChangeCharacteristicHandler( + service: serviceId, + characteristic: characteristicId + ) { [weak self] characteristic in + guard let self = self else { + return + } + + self.assertIsolated("BluetoothPeripheral onChange handler was unexpectedly executed outside the peripheral actor!") + self.assumeIsolated { injection in + injection.handleChangedCharacteristic(characteristic) + } + } + } + } + + private func registerCharacteristicValueChanges() { + self.valueRegistration = peripheral.assumeIsolated { peripheral in + peripheral.registerOnChangeHandler(service: serviceId, characteristic: characteristicId) { [weak self] data in + guard let self = self else { + return + } + self.assertIsolated("BluetoothPeripheral onChange handler was unexpectedly executed outside the peripheral actor!") + self.assumeIsolated { injection in + injection.handleUpdatedValue(data) + } + } + } + } + + private func handleChangedCharacteristic(_ characteristic: GATTCharacteristic?) { + // This the characteristic reference change? + let instanceChanged = switch (self.characteristic, characteristic) { + case let (.some(lhs), .some(rhs)): + lhs.underlyingCharacteristic !== rhs.underlyingCharacteristic + case (.some, .none): + true + case (.none, .some): + true + case (.none, .none): + false + } + + if self.characteristic != characteristic { + self.characteristic = characteristic + } + + if instanceChanged { + if let characteristic { + handleUpdatedValue(characteristic.value) + } else { + // we must make sure to not override the default value is one is present + self.value = nil + } + } + } + + private func handleUpdatedValue(_ data: Data?) { + guard let decodable = self as? DecodableCharacteristic else { + return + } + + decodable.assumeIsolated { decodable in + decodable.handleUpdateValueAssumingIsolation(data) + } + } + + private func dispatchChangeHandler(_ value: Value, with onChangeClosure: ChangeClosure) async { + guard case let .value(closure) = onChangeClosure else { + return + } + + await closure(value) + } +} + + +extension CharacteristicPeripheralInjection: DecodableCharacteristic where Value: ByteDecodable { + func handleUpdateValueAssumingIsolation(_ data: Data?) { + if let data { + guard let value = Value(data: data) else { + Bluetooth.logger.error("Could decode updated value for characteristic \(self.characteristic?.debugDescription ?? self.characteristicId.uuidString). Invalid format!") + return + } + + self.value = value + let onChangeClosure = onChangeClosure // make sure we capture it now not later where it might have changed. + Task { @SpeziBluetooth in + await self.dispatchChangeHandler(value, with: onChangeClosure) + } + } else { + self.value = nil + } + } +} + + +// MARK: - Accessors Support + +extension CharacteristicPeripheralInjection where Value: ByteDecodable { + func read() async throws -> Value { + guard let characteristic else { + throw BluetoothError.notPresent(service: serviceId, characteristic: characteristicId) + } + + let data = try await peripheral.read(characteristic: characteristic) + guard let value = Value(data: data) else { + throw BluetoothError.incompatibleDataFormat + } + + return value + } +} + + +extension CharacteristicPeripheralInjection where Value: ByteEncodable { + func write(_ value: Value) async throws { + guard let characteristic else { + throw BluetoothError.notPresent(service: serviceId, characteristic: characteristicId) + } + + let requestData = value.encode() + try await peripheral.write(data: requestData, for: characteristic) + self.value = value + } + + func writeWithoutResponse(_ value: Value) async throws { + guard let characteristic else { + throw BluetoothError.notPresent(service: serviceId, characteristic: characteristicId) + } + + let data = value.encode() + await peripheral.writeWithoutResponse(data: data, for: characteristic) + self.value = value + } +} diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/ClosureRegistrar.swift b/Sources/SpeziBluetooth/Model/PropertySupport/ClosureRegistrar.swift new file mode 100644 index 00000000..d4555d64 --- /dev/null +++ b/Sources/SpeziBluetooth/Model/PropertySupport/ClosureRegistrar.swift @@ -0,0 +1,39 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +/// Tracking notification closure registrations for ``Characteristic`` when peripheral is not available yet. +final class ClosureRegistrar { + struct Entry { + let closure: (Value) async -> Void + } + + // task local value ensures nobody is interfering here and resolves thread safety + // we maintain two different states for different processes (init vs. setup). + @TaskLocal static var writeableView: ClosureRegistrar? + @TaskLocal static var readableView: ClosureRegistrar? + + + private var registrations: [ObjectIdentifier: Any] = [:] + + init() {} + + func insert(for object: ObjectIdentifier, closure: @escaping (Value) async -> Void) { + registrations[object] = Entry(closure: closure) + } + + func retrieve(for object: ObjectIdentifier, value: Value.Type = Value.self) -> ((Value) async -> Void)? { + guard let optionalEntry = registrations[object], + let entry = optionalEntry as? Entry else { + return nil + } + return entry.closure + } +} diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceActionAccessor.swift b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceActionAccessor.swift new file mode 100644 index 00000000..4c14b19f --- /dev/null +++ b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceActionAccessor.swift @@ -0,0 +1,29 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +/// Interact with a Device Action. +public struct DeviceActionAccessor { + private let _injectedClosure: Box + + init(_ injectedClosure: Box) { + self._injectedClosure = injectedClosure + } + + + /// Inject a custom action handler for previewing purposes. + /// + /// This method can be used to inject a custom handler for the device action. + /// This is particularly helpful when writing SwiftUI previews or doing UI testing. + /// + /// - Parameter action: The action to inject. + @_spi(TestingSupport) + public func inject(_ action: ClosureType) { + _injectedClosure.value = action + } +} diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift new file mode 100644 index 00000000..79d92d9a --- /dev/null +++ b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift @@ -0,0 +1,82 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +/// Interact with a given device state. +/// +/// This type allows you to interact with a device state you previously declared using the ``DeviceState`` property wrapper. +/// +/// ## Topics +/// +/// ### Get notified about changes +/// - ``onChange(perform:)`` +public struct DeviceStateAccessor { + private let id: ObjectIdentifier + private let injection: DeviceStatePeripheralInjection? + /// To support testing support. + private let _injectedValue: ObservableBox + + + init(id: ObjectIdentifier, injection: DeviceStatePeripheralInjection?, injectedValue: ObservableBox) { + self.id = id + self.injection = injection + self._injectedValue = injectedValue + } + + + /// Perform action whenever the state value changes. + /// + /// - Important: This closure is called from the Bluetooth Serial Executor, if you don't pass in an async method + /// that has an annotated actor isolation (e.g., `@MainActor` or actor isolated methods). + /// + /// - Note: It is perfectly fine if you capture strongly self within your closure. The framework will + /// resolve any reference cycles for you. + /// - Parameter perform: The change handler to register. + public func onChange(perform: @escaping (Value) async -> Void) { + guard let injection else { + guard let closures = ClosureRegistrar.writeableView else { + Bluetooth.logger.warning( + """ + Tried to register onChange(perform:) closure out-of-band. Make sure to register your onChange closure \ + within the initializer or when the peripheral is fully injected. This is expected if you manually initialized your device. \ + The closure was discarded and won't have any effect. + """ + ) + return + } + // Similar to CharacteristicAccessor/onChange(perform:), we save it in a global registrar + // to avoid reference cycles we can't control. + closures.insert(for: id, closure: perform) + return + } + + // global actor ensures these tasks are queued serially and are executed in order. + Task { @SpeziBluetooth in + await injection.setOnChangeClosure(perform) + } + } +} + + +// MARK: - Testing Support + +@_spi(TestingSupport) +extension DeviceStateAccessor { + /// Inject a custom value for previewing purposes. + /// + /// This method can be used to inject a custom device state value. + /// This is particularly helpful when writing SwiftUI previews or doing UI testing. + /// + /// - Note: `onChange` closures are currently not supported. If required, you should + /// call your onChange closures manually. + /// + /// - Parameter value: The value to inject. + public func inject(_ value: Value) { + _injectedValue.value = value + } +} diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift new file mode 100644 index 00000000..d2d173cb --- /dev/null +++ b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift @@ -0,0 +1,115 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +actor DeviceStatePeripheralInjection: BluetoothActor { + let bluetoothQueue: DispatchSerialQueue + + let peripheral: BluetoothPeripheral + private let accessKeyPath: KeyPath + private let observationKeyPath: KeyPath? + private var onChangeClosure: ChangeClosure + + + init(peripheral: BluetoothPeripheral, keyPath: KeyPath, onChangeClosure: ((Value) async -> Void)?) { + self.bluetoothQueue = peripheral.bluetoothQueue + self.peripheral = peripheral + self.accessKeyPath = keyPath + self.observationKeyPath = keyPath.storageEquivalent() + self.onChangeClosure = onChangeClosure.map { .value($0) } ?? .none + } + + func setup() { + trackStateUpdate() + } + + private func trackStateUpdate() { + guard let observationKeyPath else { + return + } + + peripheral.assumeIsolated { peripheral in + peripheral.onChange(of: observationKeyPath) { [weak self] value in + guard let self = self else { + return + } + + self.assumeIsolated { injection in + injection.trackStateUpdate() + + // The onChange handler of global Bluetooth module is called right after this to clear this + // injection if the state changed to `disconnected`. So we must capture the onChangeClosure before + // that to still be able to deliver `disconnected` events. + let onChangeClosure = injection.onChangeClosure + Task { @SpeziBluetooth in + await injection.dispatchChangeHandler(value, with: onChangeClosure) + } + } + } + } + } + + /// Returns once the change handler completes. + private func dispatchChangeHandler(_ value: Value, with onChangeClosure: ChangeClosure) async { + guard case let .value(closure) = onChangeClosure else { + return + } + + await closure(value) + } + + func setOnChangeClosure(_ closure: @escaping (Value) async -> Void) { + if case .cleared = onChangeClosure { + // object is about to be cleared. Make sure we don't create a self reference last minute. + return + } + + self.onChangeClosure = .value(closure) + } + + /// Remove any onChangeClosure and mark injection as cleared. + /// + /// This important to ensure to clear any potential reference cycles because of a captured self in the closure. + func clearOnChangeClosure() { + onChangeClosure = .cleared + } +} + + +extension KeyPath where Root == BluetoothPeripheral { + func storageEquivalent() -> KeyPath? { + let anyKeyPath: AnyKeyPath? = switch self { + case \.name: + \PeripheralStorage.name + case \.rssi: + \PeripheralStorage.rssi + case \.advertisementData: + \PeripheralStorage.advertisementData + case \.state: + \PeripheralStorage.state + case \.services: + \PeripheralStorage.services + case \.id: + nil + default: + preconditionFailure("Could not find a observable translation for peripheral KeyPath \(self)") + } + + guard let anyKeyPath else { + return nil + } + + guard let keyPath = anyKeyPath as? KeyPath else { + preconditionFailure("Failed to cast KeyPath \(anyKeyPath) to \(KeyPath.self)") + } + + return keyPath + } +} diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/ServiceAccessor.swift b/Sources/SpeziBluetooth/Model/PropertySupport/ServiceAccessor.swift new file mode 100644 index 00000000..c88700ec --- /dev/null +++ b/Sources/SpeziBluetooth/Model/PropertySupport/ServiceAccessor.swift @@ -0,0 +1,50 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import CoreBluetooth + + +/// Interact with a given Service. +/// +/// This type allows you to interact with a Service you previously declared using the ``Service`` property wrapper. +/// +/// - Note: The accessor captures the service instance upon creation. Within the same `ServiceAccessor` instance +/// the view on the service is consistent. However, if you project a new `ServiceAccessor` instance right +/// after your access, the view on the service might have changed due to the asynchronous nature of SpeziBluetooth. +/// +/// ## Topics +/// +/// ### Service properties +/// - ``isPresent`` +/// - ``isPrimary`` +public struct ServiceAccessor { + private let id: CBUUID + private let injection: ServicePeripheralInjection? + /// Capture of the service. + private let service: GATTService? + + /// Determine if the service is available. + /// + /// Returns `true` if the device is connected and the service is available and discovered. + public var isPresent: Bool { + service != nil + } + + /// The type of the service (primary or secondary). + /// + /// Returns `false` if service is not available. + public var isPrimary: Bool { + service?.isPrimary == true + } + + init(id: CBUUID, injection: ServicePeripheralInjection?) { + self.id = id + self.injection = injection + self.service = injection?.unsafeService + } +} diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/ServicePeripheralInjection.swift b/Sources/SpeziBluetooth/Model/PropertySupport/ServicePeripheralInjection.swift new file mode 100644 index 00000000..9efa7de6 --- /dev/null +++ b/Sources/SpeziBluetooth/Model/PropertySupport/ServicePeripheralInjection.swift @@ -0,0 +1,62 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import CoreBluetooth + + +actor ServicePeripheralInjection: BluetoothActor { + let bluetoothQueue: DispatchSerialQueue + + private let peripheral: BluetoothPeripheral + private let serviceId: CBUUID + + /// Do not access directly. + private let _service: WeakObservableBox + + + private(set) var service: GATTService? { + get { + _service.value + } + set { + _service.value = newValue + } + } + + nonisolated var unsafeService: GATTService? { + _service.value + } + + + init(peripheral: BluetoothPeripheral, serviceId: CBUUID, service: GATTService?) { + self.bluetoothQueue = peripheral.bluetoothQueue + self.peripheral = peripheral + self.serviceId = serviceId + self._service = WeakObservableBox(service) + } + + func setup() { + trackServicesUpdate() + } + + private func trackServicesUpdate() { + peripheral.assumeIsolated { peripheral in + peripheral.onChange(of: \.services) { [weak self] services in + guard let self = self, + let service = services?.first(where: { $0.uuid == self.serviceId }) else { + return + } + + self.assumeIsolated { injection in + injection.trackServicesUpdate() + injection.service = service + } + } + } + } +} diff --git a/Sources/SpeziBluetooth/Model/SemanticModel/ClearStateDeviceVisitor.swift b/Sources/SpeziBluetooth/Model/SemanticModel/ClearStateDeviceVisitor.swift new file mode 100644 index 00000000..cee7f6b4 --- /dev/null +++ b/Sources/SpeziBluetooth/Model/SemanticModel/ClearStateDeviceVisitor.swift @@ -0,0 +1,47 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +private struct ClearStateServiceVisitor: ServiceVisitor { + func visit(_ characteristic: Characteristic) { + characteristic.injection?.assumeIsolated { injection in + injection.clearState() + } + } + + func visit(_ state: DeviceState) { + state.injection?.assumeIsolated { injection in + injection.clearOnChangeClosure() + } + } +} + + +private struct ClearStateDeviceVisitor: DeviceVisitor { + func visit(_ service: Service) { + var visitor = ClearStateServiceVisitor() + service.wrappedValue.accept(&visitor) + } + + func visit(_ state: DeviceState) { + state.injection?.assumeIsolated { injection in + injection.clearOnChangeClosure() + } + } +} + + +extension BluetoothDevice { + func clearState(isolatedTo bluetooth: isolated Bluetooth) { + bluetooth.bluetoothQueue.assertIsolated("ClearStateDeviceVisitor must be called within the Bluetooth SerialExecutor!") + var visitor = ClearStateDeviceVisitor() + accept(&visitor) + } +} diff --git a/Sources/SpeziBluetooth/Model/SemanticModel/SetupDeviceVisitor.swift b/Sources/SpeziBluetooth/Model/SemanticModel/SetupDeviceVisitor.swift index 37e6308f..ff55e437 100644 --- a/Sources/SpeziBluetooth/Model/SemanticModel/SetupDeviceVisitor.swift +++ b/Sources/SpeziBluetooth/Model/SemanticModel/SetupDeviceVisitor.swift @@ -12,17 +12,15 @@ import CoreBluetooth private struct SetupServiceVisitor: ServiceVisitor { private let peripheral: BluetoothPeripheral private let serviceId: CBUUID - private let service: CBService? + private let service: GATTService? - init(peripheral: BluetoothPeripheral, serviceId: CBUUID, service: CBService?) { + init(peripheral: BluetoothPeripheral, serviceId: CBUUID, service: GATTService?) { self.peripheral = peripheral self.serviceId = serviceId self.service = service } - - @MainActor func visit(_ characteristic: Characteristic) { characteristic.inject(peripheral: peripheral, serviceId: serviceId, service: service) } @@ -47,9 +45,10 @@ private struct SetupDeviceVisitor: DeviceVisitor { func visit(_ service: Service) { - let cbService = peripheral.services?.first(where: { $0.uuid == service.id }) + let blService = peripheral.assumeIsolated { $0.getService(id: service.id) } + service.inject(peripheral: peripheral, service: blService) - var visitor = SetupServiceVisitor(peripheral: peripheral, serviceId: service.id, service: cbService) + var visitor = SetupServiceVisitor(peripheral: peripheral, serviceId: service.id, service: blService) service.wrappedValue.accept(&visitor) } @@ -65,6 +64,7 @@ private struct SetupDeviceVisitor: DeviceVisitor { extension BluetoothDevice { func inject(peripheral: BluetoothPeripheral) { + peripheral.bluetoothQueue.assertIsolated("SetupDeviceVisitor must be called within the Bluetooth SerialExecutor!") var visitor = SetupDeviceVisitor(peripheral: peripheral) accept(&visitor) } diff --git a/Sources/SpeziBluetooth/Modifier/ScanNearbyDevicesModifier.swift b/Sources/SpeziBluetooth/Modifier/ScanNearbyDevicesModifier.swift index e2cae6dd..d7f600a4 100644 --- a/Sources/SpeziBluetooth/Modifier/ScanNearbyDevicesModifier.swift +++ b/Sources/SpeziBluetooth/Modifier/ScanNearbyDevicesModifier.swift @@ -15,6 +15,12 @@ private struct ScanNearbyDevicesModifier: ViewModifie private let scanner: Scanner private let autoConnect: Bool + @Environment(\.scenePhase) + private var scenePhase + @Environment(\.surroundingScanModifiers) + private var surroundingModifiers + + @State private var modifierId = UUID() init(enabled: Bool, scanner: Scanner, autoConnect: Bool) { self.enabled = enabled @@ -22,22 +28,36 @@ private struct ScanNearbyDevicesModifier: ViewModifie self.autoConnect = autoConnect } - func body(content: Content) -> some View { content .onAppear(perform: onForeground) .onDisappear(perform: onBackground) - .onReceive(NotificationCenter.default.publisher(for: UIScene.willEnterForegroundNotification)) { _ in - onForeground() // onAppear is coupled with view rendering only and won't get fired when putting app into the foreground + .onChange(of: scenePhase) { previous, _ in + if scenePhase == .background { + onBackground() // app switched into the background + } else if previous == .background { + onForeground() // app got out of the background again + } + // we don't care about active <-> inactive transition (e.g., happens when pulling down notification center) + } + .onChange(of: enabled, initial: false) { + if enabled { + onForeground() + } else { + onBackground() + } } - .onReceive(NotificationCenter.default.publisher(for: UIScene.didEnterBackgroundNotification)) { _ in - onBackground() // onDisappear is coupled with view rendering only and won't get fired when putting app into the background + .onChange(of: autoConnect, initial: false) { + Task { + await scanner.setAutoConnect(autoConnect) + } } } @MainActor private func onForeground() { if enabled { + surroundingModifiers.setModifierScanningState(enabled: true, with: scanner, modifierId: modifierId) Task { await scanner.scanNearbyDevices(autoConnect: autoConnect) } @@ -46,6 +66,12 @@ private struct ScanNearbyDevicesModifier: ViewModifie @MainActor private func onBackground() { + surroundingModifiers.setModifierScanningState(enabled: false, with: scanner, modifierId: modifierId) + + if surroundingModifiers.hasPersistentInterest(for: scanner) { + return // don't stop scanning if a surrounding modifier is expecting a scan to continue + } + Task { await scanner.stopScanning() } diff --git a/Sources/SpeziBluetooth/Resources/Localizable.xcstrings b/Sources/SpeziBluetooth/Resources/Localizable.xcstrings index 29667c1a..b05285ca 100644 --- a/Sources/SpeziBluetooth/Resources/Localizable.xcstrings +++ b/Sources/SpeziBluetooth/Resources/Localizable.xcstrings @@ -32,6 +32,7 @@ } }, "The request characteristic was not present on the device." : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -40,6 +41,16 @@ } } } + }, + "The requested characteristic %@ on %@ was not present on the device." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The requested characteristic %1$@ on %2$@ was not present on the device." + } + } + } } }, "version" : "1.0" diff --git a/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md b/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md index 4b77b9cd..9e52a068 100644 --- a/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md +++ b/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md @@ -4,15 +4,14 @@ # # This source file is part of the Stanford Spezi open source project # -# SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +# SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) # # SPDX-License-Identifier: MIT -# +# --> Connect and communicate with Bluetooth devices using modern programming paradigms. - ## Overview The Spezi Bluetooth module provides a convenient way to handle state management with a Bluetooth device, @@ -74,6 +73,8 @@ Note that the value types needs to be optional and conform to ``ByteEncodable``, ```swift class DeviceInformationService: BluetoothService { + static let id = CBUUID(string: "180A") + @Characteristic(id: "2A29") var manufacturer: String? @Characteristic(id: "2A26") @@ -95,8 +96,7 @@ class MyDevice: BluetoothDevice { @DeviceState(\.state) var state: PeripheralState - @Service(id: "180A") - var deviceInformation = DeviceInformationService() + @Service var deviceInformation = DeviceInformationService() @DeviceAction(\.connect) var connect @@ -185,6 +185,19 @@ struct MyView: View { } ``` +### Thread Model + +Every instance of ``BluetoothManager`` (or ``Bluetooth``) creates an `SerialExecutor` to dispatch any Bluetooth related action. +All state is manipulated from this executor. This serial executor is shared with `CoreBluetooth` as well. +All ``BluetoothPeripheral`` actors (or your ``BluetoothDevice`` implementation) share the `SerialExecutor` from the respective Bluetooth Manager as well. + +Note that this includes all state within your ``Characteristic``, ``Service`` or ``DeviceState`` properties as well. + +> Tip: To ensure that values stay consistent over a certain operation (e.g., within a view body) you need to establish those guarantees yourself. + +For example, when displaying nearby devices, store the result of ``Bluetooth/nearbyDevices(for:)`` once and use it for all your computation +(e.g., check for non emptiness and then displaying them). Two consecutive calls to ``Bluetooth/nearbyDevices(for:)`` might deliver different results +due to their async nature. ## Topics @@ -218,6 +231,8 @@ struct MyView: View { - ``BluetoothManager`` - ``BluetoothPeripheral`` +- ``GATTService`` +- ``GATTCharacteristic`` - ``BluetoothState`` - ``PeripheralState`` - ``BluetoothError`` diff --git a/Sources/SpeziBluetooth/TestingSupport/Data+HexString.swift b/Sources/SpeziBluetooth/TestingSupport/Data+HexString.swift index 93da6759..57986675 100644 --- a/Sources/SpeziBluetooth/TestingSupport/Data+HexString.swift +++ b/Sources/SpeziBluetooth/TestingSupport/Data+HexString.swift @@ -57,4 +57,14 @@ extension Data { } self.init(bytes) } + + + /// Create hex string from Data. + /// - Returns: The hex formatted data string + @_spi(TestingSupport) + public func hexString() -> String { + map { character in + String(format: "%02hhx", character) + }.joined() + } } diff --git a/Sources/SpeziBluetooth/Utils/AsyncSempahore.swift b/Sources/SpeziBluetooth/Utils/AsyncSempahore.swift new file mode 100644 index 00000000..dd35a4ef --- /dev/null +++ b/Sources/SpeziBluetooth/Utils/AsyncSempahore.swift @@ -0,0 +1,145 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +final class AsyncSemaphore { + private enum Suspension { + case cancelable(UnsafeContinuation) + case regular(UnsafeContinuation) + + func resume() { + switch self { + case let .regular(continuation): + continuation.resume() + case let .cancelable(continuation): + continuation.resume() + } + } + } + + private var value: Int + private var suspendedTasks: [Suspension] = [] + private let nsLock = NSLock() + + init(value: Int = 1) { + precondition(value >= 0) + self.value = value + } + + func lock() { + nsLock.lock() + } + + func unlock() { + nsLock.unlock() + } + + func wait() async { + lock() + + value -= 1 + if value >= 0 { + unlock() + return + } + + await withUnsafeContinuation { continuation in + suspendedTasks.append(.regular(continuation)) + unlock() + } + } + + func waitCheckingCancellation() async throws { + try Task.checkCancellation() // check if we are already cancelled + + lock() + + do { + // check if we got cancelled while acquiring the lock + try Task.checkCancellation() + } catch { + unlock() + throw error + } + + value -= 1 // decrease the value + if value >= 0 { + unlock() + return + } + + + try await withUnsafeThrowingContinuation { (continuation: UnsafeContinuation) in + if Task.isCancelled { + value += 1 // restore the value + unlock() + + continuation.resume(throwing: CancellationError()) + } else { + suspendedTasks.append(.cancelable(continuation)) + unlock() + } + } + } + + + @discardableResult + func signal() -> Bool { + lock() + + value += 1 + + guard let first = suspendedTasks.first else { + unlock() + return false + } + + suspendedTasks.removeFirst() + unlock() + + first.resume() + return true + } + + func signalAll() { + lock() + + value += suspendedTasks.count + + let tasks = suspendedTasks + self.suspendedTasks.removeAll() + + unlock() + + for task in tasks { + task.resume() + } + } + + func cancelAll() { + lock() + + value += suspendedTasks.count + + let tasks = suspendedTasks + self.suspendedTasks.removeAll() + + unlock() + + for task in tasks { + switch task { + case .regular: + preconditionFailure("Tried to cancel a task that was not cancellable!") + case let .cancelable(continuation): + continuation.resume(throwing: CancellationError()) + } + } + } +} diff --git a/Sources/SpeziBluetooth/Utils/Box.swift b/Sources/SpeziBluetooth/Utils/Box.swift new file mode 100644 index 00000000..0444be9d --- /dev/null +++ b/Sources/SpeziBluetooth/Utils/Box.swift @@ -0,0 +1,39 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Observation + + +@Observable +class WeakObservableBox { + weak var value: Value? + + init(_ value: Value? = nil) { + self.value = value + } +} + + +@Observable +class ObservableBox { + var value: Value + + + init(_ value: Value) { + self.value = value + } +} + + +class Box { + var value: Value + + init(_ value: Value) { + self.value = value + } +} diff --git a/Sources/SpeziBluetooth/Utils/ChangeClosure.swift b/Sources/SpeziBluetooth/Utils/ChangeClosure.swift new file mode 100644 index 00000000..8362608f --- /dev/null +++ b/Sources/SpeziBluetooth/Utils/ChangeClosure.swift @@ -0,0 +1,14 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +enum ChangeClosure { + case none + case value(_ closure: (Value) async -> Void) + case cleared +} diff --git a/Sources/SpeziBluetooth/Utils/ConnectedDevices.swift b/Sources/SpeziBluetooth/Utils/ConnectedDevices.swift index 1c00ee1d..c8414a18 100644 --- a/Sources/SpeziBluetooth/Utils/ConnectedDevices.swift +++ b/Sources/SpeziBluetooth/Utils/ConnectedDevices.swift @@ -11,6 +11,7 @@ import Foundation @Observable class ConnectedDevices { + /// We track the first connected device for every BluetoothDevice type. @MainActor private var connectedDevices: [ObjectIdentifier: BluetoothDevice] = [:] @MainActor private var connectedDeviceIds: [ObjectIdentifier: UUID] = [:] diff --git a/Sources/SpeziBluetooth/Utils/Lazy.swift b/Sources/SpeziBluetooth/Utils/Lazy.swift index d59894fe..25b85b44 100644 --- a/Sources/SpeziBluetooth/Utils/Lazy.swift +++ b/Sources/SpeziBluetooth/Utils/Lazy.swift @@ -9,8 +9,8 @@ @propertyWrapper class Lazy { - private let initializer: () -> Value - private let onCleanup: () -> Void + private var initializer: (() -> Value)? + private var onCleanup: (() -> Void)? private var storedValue: Value? @@ -20,6 +20,10 @@ class Lazy { return storedValue } + guard let initializer else { + preconditionFailure("Forgot to initialize \(Self.self) lazy property!") + } + let value = initializer() storedValue = value return value @@ -27,11 +31,7 @@ class Lazy { /// Support lazy initialization of lazy property. - convenience init() { - self.init { - preconditionFailure("Forgot to initialize \(Self.self) lazy property!") - } - } + init() {} init(initializer: @escaping () -> Value, onCleanup: @escaping () -> Void = {}) { @@ -39,11 +39,16 @@ class Lazy { self.onCleanup = onCleanup } + func supply(initializer: @escaping () -> Value, onCleanup: @escaping () -> Void = {}) { + self.initializer = initializer + self.onCleanup = onCleanup + } + func destroy() { let wasStored = storedValue != nil storedValue = nil - if wasStored { + if wasStored, let onCleanup { onCleanup() } } diff --git a/Sources/TestPeripheral/TestPeripheral.docc/Service-Setup.md b/Sources/TestPeripheral/TestPeripheral.docc/Service-Setup.md new file mode 100644 index 00000000..66aa7faf --- /dev/null +++ b/Sources/TestPeripheral/TestPeripheral.docc/Service-Setup.md @@ -0,0 +1,99 @@ +# Running as a Service + +Setting up the Test Peripheral as a launchd service. + + + +## Overview + +This guides provides an overview on how to deploy the test peripheral as a launchd launch agent on macOS. + +> 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) + guide or other resources like [What is launchd?](https://launchd.info). + +### Build the TestPeripheral + +1. Clone the repository and open it in Xcode. +2. Select the `TestPeripheral` scheme and `Any Mac` as the destination. +3. Run `Product > Archive` to build and archive a release build. +4. Open the Xcode Organizer and distribute the build products of the archive from the previous step. +5. Move the `TestPeripheral` binary into the `/Applications` folder. + + +> Important: Make sure to run the TestPeripheral manually once. + You might need to navigate to `Settings > Privacy & Security` to allow TestPeripheral to bypass your notarization settings. + +### Setup as a Service + +We provide a small script to run the test peripheral as a service using `launchd` on macOS.# +Follow the following steps to install and run the service. +We assume that you placed the `TestPeripheral` binary in the `/Applications` folder as per the previous steps. + +#### Install Service + +To install the launchd service run the following command in the root folder of the SpeziBluetooth project: + +``` +./bin/service-launchd.sh install +``` + +#### Start Service + +To load the service into launchd run the following command: + +``` +./bin/service-launchd.sh start +``` + +>Tip: If the peripheral doesn't show up, toggle Bluetooth off and on again as a troubleshooting step. + +#### Stop Service + +To unload the service from launchd run the following command: + +``` +./bin/service-launchd.sh stop +``` + +#### Status + +You can get the current status of the launch agent using the following command: +``` +./bin/service-launchd.sh status +``` + +If the service is running, you will get output similar to the one below. +The first column is the PID of the application (or `-` if not running) and the second column is the last exit code. + +``` +Started: +9314 0 edu.stanford.spezi.bluetooth.testperipheral´ +``` + +#### Uninstall Service + +To completely uninstall the launchd launch agent, run the following command: + +``` +./bin/service-launchd.sh uninstall +``` + + +### UI Test Setup + +When trying to run SpeziBluetooth UI tests on a macOS runner with the test peripheral running nearby, +there are a few things to consider: + +1. You need to setup signing for the TestApp. +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. +3. Always allow UI automation testing by running `automationmodetool enable-automationmode-without-authentication`. +4. Disable anything interfering with the runner (e.g., disabling screen saver). + diff --git a/Sources/TestPeripheral/TestPeripheral.docc/TestPeripheral.md b/Sources/TestPeripheral/TestPeripheral.docc/TestPeripheral.md new file mode 100644 index 00000000..32ab5ff0 --- /dev/null +++ b/Sources/TestPeripheral/TestPeripheral.docc/TestPeripheral.md @@ -0,0 +1,31 @@ +# ``TestPeripheral`` + +Bluetooth Peripheral for Spezi Bluetooth tests. + + + +## Overview + +This module implements a Bluetooth Peripheral to is used for UI tests in SpeziBluetooth. + +Deploy this application to a macOS machine that is physically close to your test runner. +Ensure that the UI tests have exclusive access to the peripheral by running all UI tests sequentially. + +## Topics + +### Peripheral + +- ``TestPeripheral`` +- ``TestService`` + +### Setup Guides + +- diff --git a/Sources/TestPeripheral/TestPeripheral.swift b/Sources/TestPeripheral/TestPeripheral.swift new file mode 100644 index 00000000..c4b06e23 --- /dev/null +++ b/Sources/TestPeripheral/TestPeripheral.swift @@ -0,0 +1,241 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +@_spi(TestingSupport) +import BluetoothServices +import CoreBluetooth +import OSLog +import SpeziBluetooth + + +@main +class TestPeripheral: NSObject, CBPeripheralManagerDelegate { + private let logger = Logger(subsystem: "edu.stanford.spezi.bluetooth", category: "TestPeripheral") + private let dispatchQueue = DispatchQueue(label: "edu.stanford.spezi.bluetooth-peripheral", qos: .userInitiated) + + private var peripheralManager: CBPeripheralManager! // swiftlint:disable:this implicitly_unwrapped_optional + + private(set) var testService: TestService? + private(set) var state: CBManagerState = .unknown + + @MainActor private var queuedUpdates: [CheckedContinuation] = [] + + override init() { + super.init() + peripheralManager = CBPeripheralManager(delegate: self, queue: DispatchQueue.main) + } + + static func main() { + let peripheral = TestPeripheral() + peripheral.logger.info("Initialized") + + RunLoop.main.run() + } + + func startAdvertising() { + guard let testService else { + logger.error("Service was not available after starting advertising!") + return + } + + print("Starting to advertise service...") + // Please read the docs of startAdvertising: https://developer.apple.com/documentation/corebluetooth/cbperipheralmanager/1393252-startadvertising. + // Basically: advertising is best effort and we have roughly 28 bytes in the initial advertising (shared with other apps!) + // >As we are using a custom UUID we take a up lot of that< + // Might be that the local name is moved to the scan response if it is too long. + peripheralManager.startAdvertising([ + CBAdvertisementDataLocalNameKey: "Spezi", + CBAdvertisementDataServiceUUIDsKey: [testService.service.uuid] + ]) + } + + func stopAdvertising() { + peripheralManager.stopAdvertising() + } + + @MainActor + func updateValue(_ value: Value, for characteristic: CBMutableCharacteristic, for centrals: [CBCentral]? = nil) async { + // swiftlint:disable:previous discouraged_optional_collection + + let data = value.encode() + characteristic.value = data + + while !peripheralManager.updateValue(data, for: characteristic, onSubscribedCentrals: centrals) { + // if false is returned, queue is full and we need to wait for flush signal. + await withCheckedContinuation { continuation in + logger.warning("Peripheral update failed!") + queuedUpdates.append(continuation) + } + } + } + + @MainActor + private func receiveManagerIsReady() { + logger.debug("Received manager is ready.") + let elements = queuedUpdates + queuedUpdates.removeAll() + + for element in elements { + element.resume() + } + } + + private func addServices() { + peripheralManager.removeAllServices() + + let service = TestService(peripheral: self) + self.testService = service + + peripheralManager.add(service.service) + } + + // MARK: - CBPeripheralManagerDelegate + + func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { + print("PeripheralManager state is now \("\(peripheral.state)")") + state = peripheral.state + + if case .poweredOn = state { + addServices() + } + } + + func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) { + if let error = error { + logger.error("Error adding service \(service.uuid): \(error.localizedDescription)") + return + } + + print("Service \(service.uuid) was added!") + startAdvertising() + } + + func peripheralManagerDidStartAdvertising(_ peripheral: CBPeripheralManager, error: Error?) { + if let error = error { + logger.error("Error starting advertising: \(error.localizedDescription)") + } else { + print("Peripheral advertising started successfully!") + } + } + + // MARK: - Interactions + + func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) { + guard let testService else { + logger.error("Service was not available within \(#function)") + return + } + + Task { @MainActor in + await testService.logEvent(.subscribedToNotification(characteristic.uuid)) + } + } + + func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) { + guard let testService else { + logger.error("Service was not available within \(#function)") + return + } + + Task { @MainActor in + await testService.logEvent(.unsubscribedToNotification(characteristic.uuid)) + } + } + + func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) { + Task { @MainActor in + receiveManagerIsReady() + } + } + + func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveRead request: CBATTRequest) { + guard let testService else { + logger.error("Service was not available within \(#function)") + peripheral.respond(to: request, withResult: .attributeNotFound) + return + } + + guard request.characteristic.service?.uuid == testService.service.uuid else { + logger.error("Received request for unexpected service \(request.characteristic.service?.uuid)") + peripheral.respond(to: request, withResult: .attributeNotFound) + return + } + + Task { @MainActor in + await testService.logEvent(.receivedRead(request.characteristic.uuid)) + } + + guard request.offset == 0 else { + logger.error("Characteristic read requested a non-zero offset \(request.offset) for \(request.characteristic.uuid)!") + // we currently don't support that on the test device, no clue how it works. We don't need it. + peripheral.respond(to: request, withResult: .invalidOffset) + return + } + + Task { @MainActor in + let result = testService.handleRead(for: request) + peripheral.respond(to: request, withResult: result) + } + } + + func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) { + guard let first = requests.first else { + logger.error("Received invalid write request from the central. Zero elements!") + return + } + + guard let testService else { + logger.error("Service was not available within \(#function)") + peripheral.respond(to: first, withResult: .attributeNotFound) + return + } + + for request in requests { + guard request.characteristic.service?.uuid == testService.service.uuid else { + logger.error("Received request for unexpected service \(request.characteristic.service?.uuid)") + peripheral.respond(to: first, withResult: .attributeNotFound) + return + } + } + + for request in requests { + guard let value = request.value else { + continue + } + + Task { @MainActor in + await testService.logEvent(.receivedWrite(request.characteristic.uuid, value: value)) + } + } + + guard requests.allSatisfy({ $0.offset == 0 }) else { + logger.error("Characteristic write requested a non-zero offset!") + // we currently don't support that on the test device, no clue how it works. We don't need it. + peripheral.respond(to: first, withResult: .invalidOffset) + return + } + + + Task { @MainActor in + // The following is mentioned in the docs: + // Always respond with the first request. + // Treat it as a multi request otherwise. + // If you can't fulfill a single one, don't fulfill any of them (we are not exactly supporting the transactions part of that). + for request in requests { + let result = testService.handleWrite(for: request) + + if result != .success { + peripheral.respond(to: first, withResult: result) + return + } + } + + peripheral.respond(to: first, withResult: .success) + } + } +} diff --git a/Sources/TestPeripheral/TestService.swift b/Sources/TestPeripheral/TestService.swift new file mode 100644 index 00000000..14d1552b --- /dev/null +++ b/Sources/TestPeripheral/TestService.swift @@ -0,0 +1,125 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +@_spi(TestingSupport) +import BluetoothServices +import CoreBluetooth +import OSLog + + +class TestService { + private let logger = Logger(subsystem: "edu.stanford.spezi.bluetooth", category: "TestService") + + private weak var peripheral: TestPeripheral? + let service: CBMutableService + + let eventLog: CBMutableCharacteristic + /// We only provide the value. + let readString: CBMutableCharacteristic + /// We only receive a value. + let writeString: CBMutableCharacteristic + /// Bidirectional storage value. + let readWriteString: CBMutableCharacteristic + /// Reset peripheral state to default settings + let reset: CBMutableCharacteristic + + private var readStringCount: UInt = 1 + private var readStringValue: String { + defer { + readStringCount += 1 + } + return "Hello World (\(readStringCount))" + } + + private var lastEvent: EventLog = .none + private var readWriteStringValue: String + + init(peripheral: TestPeripheral) { + self.peripheral = peripheral + self.service = CBMutableService(type: .testService, primary: true) + + self.readWriteStringValue = "" + + self.eventLog = CBMutableCharacteristic(type: .eventLogCharacteristic, properties: [.indicate, .read], value: nil, permissions: [.readable]) + self.readString = CBMutableCharacteristic(type: .readStringCharacteristic, properties: [.read], value: nil, permissions: [.readable]) + self.writeString = CBMutableCharacteristic(type: .writeStringCharacteristic, properties: [.write], value: nil, permissions: [.writeable]) + self.readWriteString = CBMutableCharacteristic( + type: .readWriteStringCharacteristic, + properties: [.read, .write], + value: nil, + permissions: [.readable, .writeable] + ) + self.reset = CBMutableCharacteristic(type: .resetCharacteristic, properties: [.write], value: nil, permissions: [.writeable]) + + service.characteristics = [eventLog, readString, writeString, readWriteString, reset] + + resetState() + } + + private func resetState() { + self.readStringCount = 1 + self.readWriteStringValue = "Hello Spezi" + } + + + @MainActor + func logEvent(_ event: EventLog) async { + guard let peripheral else { + logger.error("Couldn't log event with missing peripheral!") + return + } + + logger.info("Logging event \(event)") + self.lastEvent = event + await peripheral.updateValue(event, for: eventLog) + } + + @MainActor + func handleRead(for request: CBATTRequest) -> CBATTError.Code { + switch request.characteristic.uuid { + case eventLog.uuid: + request.value = self.lastEvent.encode() + case writeString.uuid, reset.uuid: + return .readNotPermitted + case readString.uuid: + let value = readStringValue + request.value = value.encode() + case readWriteString.uuid: + request.value = readWriteStringValue.encode() + default: + return .attributeNotFound + } + + return .success + } + + @MainActor + func handleWrite(for request: CBATTRequest) -> CBATTError.Code { + guard let value = request.value else { + return .attributeNotFound + } + + switch request.characteristic.uuid { + case eventLog.uuid, readString.uuid: + return .writeNotPermitted + case writeString.uuid: + break // we don't store the value anywhere, so we can just discard it :) + case reset.uuid: + self.resetState() + case readWriteString.uuid: + guard let string = String(data: value) else { + return .unlikelyError + } + readWriteStringValue = string + default: + return .attributeNotFound + } + + return .success + } +} diff --git a/Sources/XCTBluetooth/TestIdentity.swift b/Sources/XCTBluetooth/TestIdentity.swift index 4af402bb..603b77de 100644 --- a/Sources/XCTBluetooth/TestIdentity.swift +++ b/Sources/XCTBluetooth/TestIdentity.swift @@ -18,7 +18,7 @@ import XCTest /// decodes it back into the value and asserts its equality using `XCTAssertEqual`. /// /// - Parameter value: The value to encode and decode. -/// - Throws: Failed test. +/// - Throws: Failed to decode. public func testIdentity(from value: T) throws { let data = value.encode() @@ -37,7 +37,7 @@ public func testIdentity(from value: T) throws { /// - Parameters: /// - type: The type to test. /// - data: The data representation to decode. -/// - Throws: Failed test. +/// - Throws: Failed to decode. public func testIdentity(of type: T.Type, from data: Data) throws { var decodingBuffer = ByteBuffer(data: data) diff --git a/Sources/XCTBluetooth/XCTBluetooth.docc/XCTBluetooth.md b/Sources/XCTBluetooth/XCTBluetooth.docc/XCTBluetooth.md new file mode 100644 index 00000000..bdf5f8a0 --- /dev/null +++ b/Sources/XCTBluetooth/XCTBluetooth.docc/XCTBluetooth.md @@ -0,0 +1,25 @@ +# ``XCTBluetooth`` + +Unit testing utilities for Spezi Bluetooth. + + + +## Overview + +This package provides several utilities that make your life easier when testing your Bluetooth implementation +that uses `SpeziBluetooth`. + +## Topics + +### Testing Byte Codable + +- ``testIdentity(from:)`` +- ``testIdentity(of:from:)`` diff --git a/Tests/SpeziBluetoothTests/BluetoothServicesCodingTests.swift b/Tests/SpeziBluetoothTests/BluetoothServicesCodingTests.swift new file mode 100644 index 00000000..e7cd1647 --- /dev/null +++ b/Tests/SpeziBluetoothTests/BluetoothServicesCodingTests.swift @@ -0,0 +1,74 @@ +// +// This source file is part of the Stanford Spezi open source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +@_spi(TestingSupport) +@testable import BluetoothServices +import CoreBluetooth +import NIO +@_spi(TestingSupport) +@testable import SpeziBluetooth +import XCTBluetooth +import XCTest + + +final class BluetoothServicesTests: XCTestCase { + func testDateTime() throws { + try testIdentity(from: DateTime(year: 2005, month: .december, day: 27, hours: 12, minutes: 31, seconds: 40)) + try testIdentity(from: DateTime(hours: 23, minutes: 50, seconds: 40)) + } + + func testMeasurementInterval() throws { + try testIdentity(from: MeasurementInterval.noPeriodicMeasurement) + try testIdentity(from: MeasurementInterval.duration(24)) + } + + func testTemperatureMeasurement() throws { + let data = try XCTUnwrap(Data(hex: "0xAFAFAFAF")) // 4 bytes for the medfloat + let time = DateTime(hours: 13, minutes: 12, seconds: 12) + + try testIdentity(from: TemperatureMeasurement(value: .celsius(data))) + try testIdentity(from: TemperatureMeasurement(value: .fahrenheit(data))) + + try testIdentity(from: TemperatureMeasurement(value: .celsius(data), timeStamp: time, temperatureType: .ear)) + try testIdentity(from: TemperatureMeasurement(value: .celsius(data), temperatureType: .ear)) + try testIdentity(from: TemperatureMeasurement(value: .celsius(data), timeStamp: time)) + } + + func testTemperatureType() throws { + for type in TemperatureType.allCases { + try testIdentity(from: type) + } + } + + func testPnPID() throws { + try testIdentity(from: VendorIDSource.bluetoothSIGAssigned) + try testIdentity(from: VendorIDSource.usbImplementersForumAssigned) + try testIdentity(from: VendorIDSource.reserved(23)) + + try testIdentity(from: PnPID(vendorIdSource: .bluetoothSIGAssigned, vendorId: 24, productId: 1, productVersion: 56)) + } + + func testEventLog() throws { + try testIdentity(from: EventLog.none) + try testIdentity(from: EventLog.subscribedToNotification(.eventLogCharacteristic)) + try testIdentity(from: EventLog.unsubscribedToNotification(.eventLogCharacteristic)) + try testIdentity(from: EventLog.receivedRead(.readStringCharacteristic)) + try testIdentity(from: EventLog.receivedWrite(.writeStringCharacteristic, value: "Hello World".encode())) + } + + func testCharacteristics() async throws { + _ = TestService() + _ = HealthThermometerService() + let info = DeviceInformationService() + try await info.retrieveDeviceInformation() + } + + func testUUID() { + XCTAssertEqual(CBUUID.toCustomShort(.testService), "F001") + } +} diff --git a/Tests/SpeziBluetoothTests/ByteCodableTests.swift b/Tests/SpeziBluetoothTests/ByteCodableTests.swift index 9965cd76..3f9f0a91 100644 --- a/Tests/SpeziBluetoothTests/ByteCodableTests.swift +++ b/Tests/SpeziBluetoothTests/ByteCodableTests.swift @@ -7,7 +7,8 @@ // import NIO -@testable @_spi(TestingSupport) import SpeziBluetooth // swiftlint:disable:this attributes +@_spi(TestingSupport) +@testable import SpeziBluetooth import XCTBluetooth import XCTest @@ -17,6 +18,9 @@ final class ByteCodableTests: XCTestCase { let data = try XCTUnwrap(Data(hex: "0xAABBCCDDEE")) try testIdentity(of: Data.self, from: data) + + let data0 = Data(data: data) + XCTAssertEqual(data0, data) } func testBoolean() throws { @@ -80,4 +84,16 @@ final class ByteCodableTests: XCTestCase { try testIdentity(from: UInt64.max) try testIdentity(from: UInt64.min) } + + func testFloat32() throws { + try testIdentity(from: Float32.pi) + try testIdentity(from: Float32.infinity) + try testIdentity(from: Float32(17.2783912)) + } + + func testFloat64() throws { + try testIdentity(from: Float64.pi) + try testIdentity(from: Float64.infinity) + try testIdentity(from: Float64(23712.2123123)) + } } diff --git a/Tests/UITests/TestApp/BluetoothManagerView.swift b/Tests/UITests/TestApp/BluetoothManagerView.swift index 366c82aa..c413ab83 100644 --- a/Tests/UITests/TestApp/BluetoothManagerView.swift +++ b/Tests/UITests/TestApp/BluetoothManagerView.swift @@ -6,6 +6,7 @@ // SPDX-License-Identifier: MIT // +import BluetoothViews import SpeziBluetooth import SwiftUI @@ -15,22 +16,20 @@ struct BluetoothManagerView: View { var body: some View { List { - BluetoothStateSection(scanner: bluetooth) + BluetoothStateSection(state: bluetooth.state, isScanning: bluetooth.isScanning) - if bluetooth.nearbyPeripheralsView.isEmpty { - SearchingNearbyDevicesView() - } else { - Section { - ForEach(bluetooth.nearbyPeripheralsView) { peripheral in - DeviceRowView(peripheral: peripheral) - } - } header: { - DevicesHeader(loading: bluetooth.isScanning) + Section { + ForEach(bluetooth.nearbyPeripherals) { peripheral in + DeviceRowView(peripheral: peripheral) } + } header: { + LoadingSectionHeaderView(verbatim: "Devices", loading: bluetooth.isScanning) + } footer: { + Text(verbatim: "This is a list of nearby Bluetooth peripherals.") } } - .scanNearbyDevices(with: bluetooth) - .navigationTitle("Nearby Devices") + .scanNearbyDevices(with: bluetooth) + .navigationTitle("Nearby Devices") } } diff --git a/Tests/UITests/TestApp/BluetoothModuleView.swift b/Tests/UITests/TestApp/BluetoothModuleView.swift index 68b0271c..77aba65c 100644 --- a/Tests/UITests/TestApp/BluetoothModuleView.swift +++ b/Tests/UITests/TestApp/BluetoothModuleView.swift @@ -6,7 +6,7 @@ // SPDX-License-Identifier: MIT // - +import BluetoothViews import SpeziBluetooth import SwiftUI @@ -19,38 +19,35 @@ struct BluetoothModuleView: View { var body: some View { List { - BluetoothStateSection(scanner: bluetooth) + BluetoothStateSection(state: bluetooth.state, isScanning: bluetooth.isScanning) let nearbyDevices = bluetooth.nearbyDevices(for: TestDevice.self) - if nearbyDevices.isEmpty { - SearchingNearbyDevicesView() - } else { - Section { - ForEach(nearbyDevices) { device in - DeviceRowView(peripheral: device) - } - } header: { - DevicesHeader(loading: bluetooth.isScanning) + Section { + ForEach(nearbyDevices) { device in + DeviceRowView(peripheral: device) } + } header: { + LoadingSectionHeaderView(verbatim: "Devices", loading: bluetooth.isScanning) + } footer: { + Text(verbatim: "This is a list of nearby test peripherals. Auto connect is enabled.") } if let device { - Section { - Text("Device State: \(device.state.description)") - Text("RSSI: \(device.rssi)") + NavigationLink("Test Interactions") { + TestDeviceView(device: device) } } } .scanNearbyDevices(with: bluetooth, autoConnect: true) - .navigationTitle("Auto Connect Device") + .navigationTitle("Nearby Devices") } } #Preview { NavigationStack { - BluetoothManagerView() + BluetoothModuleView() .previewWith { Bluetooth { Discover(TestDevice.self, by: .advertisedService("FFF0")) diff --git a/Tests/UITests/TestApp/TestApp.entitlements.license b/Tests/UITests/TestApp/TestApp.entitlements.license new file mode 100644 index 00000000..28f53d0d --- /dev/null +++ b/Tests/UITests/TestApp/TestApp.entitlements.license @@ -0,0 +1,5 @@ +This source file is part of the Stanford Spezi open-source project + +SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) + +SPDX-License-Identifier: MIT \ No newline at end of file diff --git a/Tests/UITests/TestApp/TestApp.swift b/Tests/UITests/TestApp/TestApp.swift index 7124c9a1..291919f5 100644 --- a/Tests/UITests/TestApp/TestApp.swift +++ b/Tests/UITests/TestApp/TestApp.swift @@ -7,24 +7,28 @@ // import Spezi -import SpeziBluetooth import SwiftUI +struct NearbyDevices: View { + var body: some View { + BluetoothManagerView() // we use this indirection to trigger BluetoothManager deinit! + } +} @main struct UITestsApp: App { @UIApplicationDelegateAdaptor(TestAppDelegate.self) var appDelegate - + var body: some Scene { WindowGroup { NavigationStack { List { NavigationLink("Nearby Devices") { - BluetoothManagerView() + NearbyDevices() } - NavigationLink("Auto Connect Device") { + NavigationLink("Test Peripheral") { BluetoothModuleView() } } diff --git a/Tests/UITests/TestApp/TestAppDelegate.swift b/Tests/UITests/TestApp/TestAppDelegate.swift index 5042114b..775ebbf0 100644 --- a/Tests/UITests/TestApp/TestAppDelegate.swift +++ b/Tests/UITests/TestApp/TestAppDelegate.swift @@ -6,6 +6,8 @@ // SPDX-License-Identifier: MIT // +@_spi(TestingSupport) +import BluetoothServices import Spezi import SpeziBluetooth import SwiftUI @@ -15,7 +17,7 @@ class TestAppDelegate: SpeziAppDelegate { override var configuration: Configuration { Configuration { Bluetooth { - Discover(TestDevice.self, by: .advertisedService("FFF0")) + Discover(TestDevice.self, by: .advertisedService(TestService.self)) } } } diff --git a/Tests/UITests/TestApp/TestDevice.swift b/Tests/UITests/TestApp/TestDevice.swift index 7522e654..4f82a00d 100644 --- a/Tests/UITests/TestApp/TestDevice.swift +++ b/Tests/UITests/TestApp/TestDevice.swift @@ -1,26 +1,17 @@ // // This source file is part of the Stanford Spezi open-source project // -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) // // SPDX-License-Identifier: MIT // +@_spi(TestingSupport) +import BluetoothServices import Foundation import SpeziBluetooth -protocol SomePeripheral { - var id: UUID { get } - var name: String? { get } - var state: PeripheralState { get } - var rssi: Int { get } - - func connect() async - func disconnect() async -} - - class TestDevice: BluetoothDevice, Identifiable, SomePeripheral { @DeviceState(\.id) var id @@ -36,6 +27,9 @@ class TestDevice: BluetoothDevice, Identifiable, SomePeripheral { @DeviceAction(\.disconnect) var disconnect + @Service var deviceInformation = DeviceInformationService() + @Service var testService = TestService() + required init() {} diff --git a/Tests/UITests/TestApp/TestDeviceView.swift b/Tests/UITests/TestApp/TestDeviceView.swift new file mode 100644 index 00000000..d7998fce --- /dev/null +++ b/Tests/UITests/TestApp/TestDeviceView.swift @@ -0,0 +1,51 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +@_spi(TestingSupport) +import BluetoothServices +@_spi(TestingSupport) +import SpeziBluetooth +import SwiftUI + + +struct TestDeviceView: View { + private let device: TestDevice + + var body: some View { + List { + DeviceInformationView(device.deviceInformation) + + TestServiceView(device.testService) + } + .navigationTitle("Interactions") + .navigationBarTitleDisplayMode(.inline) + } + + init(device: TestDevice) { + self.device = device + } +} + + +#if DEBUG +#Preview { + let device = TestDevice() + device.deviceInformation.$manufacturerName.inject("Apple Inc.") + device.deviceInformation.$modelNumber.inject("MacBookPro18,1") + + let service = device.testService + service.$eventLog.inject(.receivedWrite(.readWriteStringCharacteristic, value: "Hello Spezi".encode())) + + service.$readString.inject("Hello World (1)") + service.$readWriteString.inject("Hello World") + + return NavigationStack { + TestDeviceView(device: device) + } +} +#endif diff --git a/Tests/UITests/TestApp/Utiltities/DevicesHeader.swift b/Tests/UITests/TestApp/Utiltities/DevicesHeader.swift deleted file mode 100644 index 59208f2c..00000000 --- a/Tests/UITests/TestApp/Utiltities/DevicesHeader.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import SwiftUI - - -struct DevicesHeader: View { - private let loading: Bool - - - var body: some View { - HStack { - Text("Devices") - .padding(.trailing, 10) - if loading { - ProgressView() - } - } - } - - - init(loading: Bool) { - self.loading = loading - } -} diff --git a/Tests/UITests/TestApp/Utiltities/BluetoothStateSection.swift b/Tests/UITests/TestApp/Views/BluetoothStateSection.swift similarity index 57% rename from Tests/UITests/TestApp/Utiltities/BluetoothStateSection.swift rename to Tests/UITests/TestApp/Views/BluetoothStateSection.swift index a55254e3..106f06ea 100644 --- a/Tests/UITests/TestApp/Utiltities/BluetoothStateSection.swift +++ b/Tests/UITests/TestApp/Views/BluetoothStateSection.swift @@ -10,30 +10,35 @@ import SpeziBluetooth import SwiftUI -struct BluetoothStateSection: View { - private let scanner: BluetoothScanner +struct BluetoothStateSection: View { + private let state: BluetoothState + private let isScanning: Bool + var body: some View { - Section("State") { + Section { HStack { - Text("Scanning") + Text(verbatim: "Scanning") Spacer() - Text(scanner.isScanning ? "Yes" : "No") + Text(verbatim: isScanning ? "Yes" : "No") .foregroundColor(.secondary) } .accessibilityElement(children: .combine) HStack { - Text("State") + Text(verbatim: "State") Spacer() - Text(scanner.state.description) + Text(state.description) .foregroundColor(.secondary) } .accessibilityElement(children: .combine) + } header: { + Text(verbatim: "State") } } - init(scanner: Scanner) { - self.scanner = scanner + init(state: BluetoothState, isScanning: Bool) { + self.state = state + self.isScanning = isScanning } } diff --git a/Tests/UITests/TestApp/Views/DeviceInformationView.swift b/Tests/UITests/TestApp/Views/DeviceInformationView.swift new file mode 100644 index 00000000..e1e65fef --- /dev/null +++ b/Tests/UITests/TestApp/Views/DeviceInformationView.swift @@ -0,0 +1,98 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import BluetoothServices +@_spi(TestingSupport) +import SpeziBluetooth +import SpeziViews +import SwiftUI + + +struct DeviceInformationView: View { + private let deviceInformation: DeviceInformationService + + + var body: some View { + Section("Device Information") { // swiftlint:disable:this closure_body_length + if let manufacturerName = deviceInformation.manufacturerName { + ListRow("Manufacturer") { + Text(manufacturerName) + } + } + if let modelNumber = deviceInformation.modelNumber { + ListRow("Model") { + Text(modelNumber) + } + } + if let serialNumber = deviceInformation.serialNumber { + ListRow("Serial Number") { + Text(serialNumber) + } + } + + if let firmwareRevision = deviceInformation.firmwareRevision { + ListRow("Firmware Revision") { + Text(firmwareRevision) + } + } + if let softwareRevision = deviceInformation.softwareRevision { + ListRow("Software Revision") { + Text(softwareRevision) + } + } + if let hardwareRevision = deviceInformation.hardwareRevision { + ListRow("Hardware Revision") { + Text(hardwareRevision) + } + } + + if let systemID = deviceInformation.systemID { + ListRow("System Id") { + Text(String(format: "%02X", systemID)) + } + } + if let regulatoryCertificationDataList = deviceInformation.regulatoryCertificationDataList { + ListRow("Regulatory Certification Data") { + Text(regulatoryCertificationDataList.hexString()) + } + } + } + + if let pnpID = deviceInformation.pnpID { + Section("Plug and Play") { + ListRow("Vendor Id") { + Text(verbatim: "\(String(format: "%02X", pnpID.vendorId)) (\(pnpID.vendorIdSource.label))") + } + ListRow("Product Id") { + Text(String(format: "%02X", pnpID.productId)) + } + ListRow("Product Version") { + Text(String(format: "%02X", pnpID.productVersion)) + } + } + } + } + + init(_ deviceInformation: DeviceInformationService) { + self.deviceInformation = deviceInformation + } +} + + +#if DEBUG +#Preview { + let service = DeviceInformationService() + service.$manufacturerName.inject("Stanford Spezi") + service.$systemID.inject(1231213) + service.$pnpID.inject(PnPID(vendorIdSource: .bluetoothSIGAssigned, vendorId: 123, productId: 23, productVersion: 42)) + + return List { + DeviceInformationView(service) + } +} +#endif diff --git a/Tests/UITests/TestApp/Utiltities/DeviceRowView.swift b/Tests/UITests/TestApp/Views/DeviceRowView.swift similarity index 66% rename from Tests/UITests/TestApp/Utiltities/DeviceRowView.swift rename to Tests/UITests/TestApp/Views/DeviceRowView.swift index 73762b00..b844472f 100644 --- a/Tests/UITests/TestApp/Utiltities/DeviceRowView.swift +++ b/Tests/UITests/TestApp/Views/DeviceRowView.swift @@ -6,10 +6,22 @@ // SPDX-License-Identifier: MIT // +@_spi(TestingSupport) import SpeziBluetooth import SwiftUI +protocol SomePeripheral { + var id: UUID { get } + var name: String? { get } + var state: PeripheralState { get } + var rssi: Int { get } + + func connect() async + func disconnect() async +} + + struct DeviceRowView: View { private let peripheral: Peripheral @@ -18,20 +30,20 @@ struct DeviceRowView: View { VStack { HStack { if let name = peripheral.name { - Text("\(name)") + Text(verbatim: "\(name)") } else { - Text("unknown") + Text(verbatim: "unknown") .italic() } Spacer() - Text("\(peripheral.rssi) dB") + Text(verbatim: "\(peripheral.rssi) dB") .foregroundColor(.secondary) } .foregroundColor(.primary) HStack { Text(peripheral.id.uuidString) Spacer() - Text("\(peripheral.state.description)") + Text(peripheral.state.description) } .font(.caption2) .foregroundColor(.secondary) @@ -57,3 +69,16 @@ struct DeviceRowView: View { } } } + + +#Preview { + let testDevice = TestDevice() + testDevice.$id.inject(UUID()) + testDevice.$name.inject("Test Device") + testDevice.$rssi.inject(-46) + testDevice.$state.inject(.connected) + + return List { + DeviceRowView(peripheral: testDevice) + } +} diff --git a/Tests/UITests/TestApp/Utiltities/SearchingNearbyDevicesView.swift b/Tests/UITests/TestApp/Views/SearchingNearbyDevicesView.swift similarity index 100% rename from Tests/UITests/TestApp/Utiltities/SearchingNearbyDevicesView.swift rename to Tests/UITests/TestApp/Views/SearchingNearbyDevicesView.swift diff --git a/Tests/UITests/TestApp/Views/TestServiceView.swift b/Tests/UITests/TestApp/Views/TestServiceView.swift new file mode 100644 index 00000000..b56d7863 --- /dev/null +++ b/Tests/UITests/TestApp/Views/TestServiceView.swift @@ -0,0 +1,198 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +@_spi(TestingSupport) +import BluetoothServices +import BluetoothViews +import CoreBluetooth +@_spi(TestingSupport) +import SpeziBluetooth +import SpeziViews +import SwiftUI + + +struct EventLogView: View { + private let log: EventLog + private let notifying: Bool + + private var event: String { + switch log { + case .none: + return "none" + case .subscribedToNotification: + return "subscribed" + case .unsubscribedToNotification: + return "unsubscribed" + case .receivedRead: + return "read" + case .receivedWrite: + return "write" + } + } + + private var characteristic: String? { + let characteristic: CBUUID? = switch log { + case .none: + nil + case let .subscribedToNotification(characteristic): + characteristic + case let .unsubscribedToNotification(characteristic): + characteristic + case let .receivedRead(characteristic): + characteristic + case let .receivedWrite(characteristic, _): + characteristic + } + + guard let characteristic else { + return nil + } + return CBUUID.toCustomShort(characteristic) + } + + private var value: String? { + switch log { + case let .receivedWrite(characteristic, value): + if characteristic == .resetCharacteristic { + value.hexString() + } else { + String(data: value) + } + default: + nil + } + } + + var body: some View { + if !notifying { + ListRow(verbatim: "Notifications") { + Text(verbatim: "Off") + } + } else { + ListRow(verbatim: "Event") { + Text(event) + } + if let characteristic { + ListRow(verbatim: "Characteristic") { + Text(characteristic) + } + } + if let value { + ListRow(verbatim: "Value") { + Text(value) + } + } + } + } + + init(log: EventLog, notifying: Bool) { + self.log = log + self.notifying = notifying + } +} + + +struct TestServiceView: View { + private let testService: TestService + + @State private var viewState: ViewState = .idle + @State private var input: String = "" + @State private var lastRead: String? + + private var notifications: Binding { + Binding { + testService.$eventLog.isNotifying + } set: { newValue in + let service = testService + Task { @MainActor in + await service.$eventLog.enableNotifications(newValue) + } + } + } + + var body: some View { + Section("Event Log") { + if let eventLog = testService.eventLog { + EventLogView(log: eventLog, notifying: testService.$eventLog.isNotifying) + } + } + + Section("State") { + if let readString = testService.readString { + ListRow(verbatim: "Read Value") { + Text(verbatim: readString) + } + } + + if let lastRead, lastRead != testService.readString { + ListRow(verbatim: "Read value differs") { + Text(lastRead) + } + } + + if let readWriteString = testService.readWriteString { + ListRow(verbatim: "RW Value") { + Text(verbatim: readWriteString) + } + } + } + + Section("Input") { + TextField("enter input", text: $input) + } + + + Section("Controls") { + Toggle("EventLog Notifications", isOn: notifications) + AsyncButton(role: .destructive, state: $viewState, action: { + try await testService.$reset.write(true) + }) { + Text(verbatim: "Reset Peripheral State") + } + AsyncButton(state: $viewState, action: { + lastRead = try await testService.$readString.read() + }) { + Text(verbatim: "Read Current String Value (R)") + } + AsyncButton(state: $viewState, action: { + try await testService.$readWriteString.read() + }) { + Text(verbatim: "Read Current String Value (RW)") + } + AsyncButton(state: $viewState, action: { + try await testService.$readWriteString.write(input) + }) { + Text(verbatim: "Write Input to read-write") + } + AsyncButton(state: $viewState, action: { + try await testService.$writeString.write(input) + }) { + Text(verbatim: "Write Input to write-only") + } + } + } + + init(_ testService: TestService) { + self.testService = testService + } +} + + +#if DEBUG +#Preview { + let service = TestService() + service.$eventLog.inject(.receivedWrite(.readWriteStringCharacteristic, value: "Hello Spezi".encode())) + + service.$readString.inject("Hello World (1)") + service.$readWriteString.inject("Hello World") + + return List { + TestServiceView(service) + } +} +#endif diff --git a/Tests/UITests/TestAppUITests/BluetoothManagerTests.swift b/Tests/UITests/TestAppUITests/BluetoothManagerTests.swift new file mode 100644 index 00000000..91175777 --- /dev/null +++ b/Tests/UITests/TestAppUITests/BluetoothManagerTests.swift @@ -0,0 +1,40 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import XCTest +import XCTestExtensions + + +final class BluetoothManagerTests: XCTestCase { + override func setUpWithError() throws { + try super.setUpWithError() + + continueAfterFailure = false + } + + func testSpeziBluetooth() throws { + let app = XCUIApplication() + app.launch() + + XCTAssert(app.staticTexts["Spezi Bluetooth"].waitForExistence(timeout: 2)) + + XCTAssert(app.buttons["Nearby Devices"].exists) + XCTAssert(app.buttons["Test Peripheral"].exists) + + app.buttons["Nearby Devices"].tap() + + XCTAssert(app.navigationBars.staticTexts["Nearby Devices"].waitForExistence(timeout: 2.0)) + try app.assertMinimalSimulatorInformation() + + sleep(15) // this goes through stale timer and everything! + + XCTAssert(app.navigationBars.buttons["Spezi Bluetooth"].exists) + app.navigationBars.buttons["Spezi Bluetooth"].tap() + sleep(1) + } +} diff --git a/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift b/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift index 31d22ac0..a05dddf4 100644 --- a/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift +++ b/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift @@ -1,46 +1,156 @@ // // This source file is part of the Stanford Spezi open-source project // -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) // // SPDX-License-Identifier: MIT // +@_spi(TestingSupport) +import BluetoothServices +import CoreBluetooth import XCTest import XCTestExtensions final class SpeziBluetoothTests: XCTestCase { - func testSpeziBluetooth() throws { + override func setUpWithError() throws { + try super.setUpWithError() + + continueAfterFailure = false + } + + func testTestPeripheral() throws { // swiftlint:disable:this function_body_length let app = XCUIApplication() app.launch() - - XCTAssert(app.staticTexts["Spezi Bluetooth"].waitForExistence(timeout: 2)) - XCTAssert(app.buttons["Nearby Devices"].exists) - XCTAssert(app.buttons["Auto Connect Device"].exists) + XCTAssert(app.staticTexts["Spezi Bluetooth"].waitForExistence(timeout: 2)) - app.buttons["Nearby Devices"].tap() + XCTAssert(app.buttons["Test Peripheral"].exists) + app.buttons["Test Peripheral"].tap() XCTAssert(app.navigationBars.staticTexts["Nearby Devices"].waitForExistence(timeout: 2.0)) - assertMinimalSimulatorInformation(app) + try app.assertMinimalSimulatorInformation() - XCTAssert(app.navigationBars.buttons["Spezi Bluetooth"].exists) - app.navigationBars.buttons["Spezi Bluetooth"].tap() + // wait till the device is automatically connected. + XCTAssert(app.staticTexts["Spezi"].waitForExistence(timeout: 1.0)) // our peripheral name + XCTAssert(app.staticTexts["connected"].waitForExistence(timeout: 10.0)) + + XCTAssert(app.buttons["Test Interactions"].exists) + app.buttons["Test Interactions"].tap() + + XCTAssert(app.navigationBars.staticTexts["Interactions"].waitForExistence(timeout: 2.0)) + + XCTAssert(app.staticTexts["Manufacturer, Apple Inc."].exists) + XCTAssert(app.staticTexts["Model"].exists) // we just check for existence of the model row + + // by checking if event row is there to verify auto notify enabled. + XCTAssert(app.staticTexts["Event"].exists) + + // reset state + XCTAssert(app.buttons["Reset Peripheral State"].exists) + app.buttons["Reset Peripheral State"].tap() + app.assert(event: "write", characteristic: .resetCharacteristic, value: "01") + + // disable events and re-enable + #if os(macOS) + XCTAssert(app.checkBoxes["EventLog Notifications"].exists) + XCTAssertEqual(app.checkBoxes["EventLog Notifications"].value as? String, "1") + app.checkBoxes["EventLog Notifications"].tap() + XCTAssert(app.staticTexts["Notifications, Off"].waitForExistence(timeout: 2.0)) + + app.checkBoxes["EventLog Notifications"].tap() + app.assert(event: "subscribed", characteristic: .eventLogCharacteristic) + #else + let offset = 0.98 + + XCTAssert(app.switches["EventLog Notifications"].exists) + XCTAssertEqual(app.switches["EventLog Notifications"].value as? String, "1") + app.switches["EventLog Notifications"] + .coordinate(withNormalizedOffset: .init(dx: offset, dy: 0.5)) + .tap() + XCTAssert(app.staticTexts["Notifications, Off"].waitForExistence(timeout: 2.0)) + + app.switches["EventLog Notifications"] + .coordinate(withNormalizedOffset: .init(dx: offset, dy: 0.5)) + .tap() + app.assert(event: "subscribed", characteristic: .eventLogCharacteristic) + #endif - XCTAssert(app.buttons["Auto Connect Device"].waitForExistence(timeout: 2.0)) - app.buttons["Auto Connect Device"].tap() + // enter text we use for all validations + try app.textFields["enter input"].enter(value: "Hello Bluetooth!") - XCTAssert(app.navigationBars.staticTexts["Auto Connect Device"].waitForExistence(timeout: 2.0)) - assertMinimalSimulatorInformation(app) + XCTAssert(app.buttons["Read Current String Value (R)"].waitForExistence(timeout: 2.0)) + app.buttons["Read Current String Value (R)"].tap() + XCTAssert(app.staticTexts["Read Value, Hello World (1)"].waitForExistence(timeout: 2.0)) + app.assert(event: "read", characteristic: .readStringCharacteristic) + XCTAssertFalse(app.staticTexts["Read value differs"].waitForExistence(timeout: 2.0)) // ensure it is consistent - XCTAssert(app.navigationBars.buttons["Spezi Bluetooth"].exists) + app.buttons["Read Current String Value (R)"].tap() + XCTAssert(app.staticTexts["Read Value, Hello World (2)"].waitForExistence(timeout: 2.0)) + app.assert(event: "read", characteristic: .readStringCharacteristic) + XCTAssertFalse(app.staticTexts["Read value differs"].waitForExistence(timeout: 2.0)) // ensure it is consistent + + + XCTAssert(app.buttons["Write Input to write-only"].exists) + app.buttons["Write Input to write-only"].tap() + app.assert(event: "write", characteristic: .writeStringCharacteristic, value: "Hello Bluetooth!") + + XCTAssert(app.buttons["Write Input to read-write"].exists) + app.buttons["Write Input to read-write"].tap() + // ensure write values are saved in the property wrapper + XCTAssert(app.staticTexts["RW Value, Hello Bluetooth!"].waitForExistence(timeout: 2.0)) + app.assert(event: "write", characteristic: .readWriteStringCharacteristic, value: "Hello Bluetooth!") + + // check if the value stays the same if we read the characteristic + XCTAssert(app.buttons["Read Current String Value (RW)"].exists) + app.buttons["Read Current String Value (RW)"].tap() + XCTAssert(app.staticTexts["RW Value, Hello Bluetooth!"].waitForExistence(timeout: 2.0)) + app.assert(event: "read", characteristic: .readWriteStringCharacteristic) + + + XCTAssert(app.navigationBars.buttons["Nearby Devices"].exists) + app.navigationBars.buttons["Nearby Devices"].tap() + try app.assertMinimalSimulatorInformation() // ensure we are back to scanning! + + XCTAssert(app.navigationBars.buttons["Spezi Bluetooth"].waitForExistence(timeout: 2.0)) app.navigationBars.buttons["Spezi Bluetooth"].tap() + + XCTAssert(app.buttons["Test Peripheral"].waitForExistence(timeout: 2.0)) + app.buttons["Test Peripheral"].tap() // check that the device is still there if we go back + + XCTAssert(app.staticTexts["connected"].waitForExistence(timeout: 2.0)) + try app.assertMinimalSimulatorInformation() // ensure we are scanning + + // manually disconnect device and ensure it doesn't automatically reconnect to manually disconnected devices + app.staticTexts["connected"].tap() + + XCTAssert(app.staticTexts["disconnected"].waitForExistence(timeout: 2.0)) + sleep(5) + // check that it stays disconnected + XCTAssert(app.staticTexts["disconnected"].waitForExistence(timeout: 2.0)) + } +} + + +extension XCUIApplication { + func assertMinimalSimulatorInformation() throws { +#if targetEnvironment(simulator) + XCTAssert(staticTexts["Scanning, No"].waitForExistence(timeout: 1.0)) + XCTAssert(staticTexts["State, unsupported"].waitForExistence(timeout: 1.0) + || staticTexts["State, unknown"].waitForExistence(timeout: 1.0)) + throw XCTSkip("Bluetooth tests are not supported in simulator.") +#else + XCTAssert(staticTexts["Scanning, Yes"].waitForExistence(timeout: 2.0)) + XCTAssert(staticTexts["State, poweredOn"].exists) +#endif } - private func assertMinimalSimulatorInformation(_ app: XCUIApplication) { - XCTAssert(app.staticTexts["Scanning, No"].exists) - XCTAssert(app.staticTexts["State, unsupported"].exists) - XCTAssert(app.staticTexts["Searching for nearby devices ..."].exists) + func assert(event: String, characteristic: CBUUID, value: String? = nil) { + XCTAssert(staticTexts["Event, \(event)"].waitForExistence(timeout: 5.0)) + XCTAssert(staticTexts["Characteristic, \(CBUUID.toCustomShort(characteristic))"].waitForExistence(timeout: 2.0)) + if let value { + XCTAssert(staticTexts["Value, \(value)"].waitForExistence(timeout: 2.0)) + } } } diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index b717856c..6b1b6781 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -11,15 +11,21 @@ 2F64EA882A86B36C006789D0 /* TestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F64EA872A86B36C006789D0 /* TestApp.swift */; }; 2F64EA8B2A86B3DE006789D0 /* XCTestExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 2F64EA8A2A86B3DE006789D0 /* XCTestExtensions */; }; 2F6D139A28F5F386007C25D6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2F6D139928F5F386007C25D6 /* Assets.xcassets */; }; - 2FA43E922AE057CA009B1B2C /* SpeziBluetoothTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA43E912AE057CA009B1B2C /* SpeziBluetoothTests.swift */; }; + 2FA43E922AE057CA009B1B2C /* BluetoothManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA43E912AE057CA009B1B2C /* BluetoothManagerTests.swift */; }; + A91E672E2B75A500009A1E02 /* BluetoothViews in Frameworks */ = {isa = PBXBuildFile; productRef = A91E672D2B75A500009A1E02 /* BluetoothViews */; }; A92802B72B5081F200874D0A /* SpeziBluetooth in Frameworks */ = {isa = PBXBuildFile; productRef = A92802B62B5081F200874D0A /* SpeziBluetooth */; }; A92802B92B50823600874D0A /* BluetoothManagerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A92802B82B50823600874D0A /* BluetoothManagerView.swift */; }; A92802BD2B51CBBE00874D0A /* DeviceRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A92802BC2B51CBBE00874D0A /* DeviceRowView.swift */; }; + A93B82D42B78C20700C5DF3D /* BluetoothStateSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A93B82D32B78C20700C5DF3D /* BluetoothStateSection.swift */; }; + A93B82D62B78C2D100C5DF3D /* DeviceInformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A93B82D52B78C2D100C5DF3D /* DeviceInformationView.swift */; }; + A93B82D82B78C31E00C5DF3D /* TestServiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A93B82D72B78C31E00C5DF3D /* TestServiceView.swift */; }; + A93B82DA2B78D0D200C5DF3D /* TestDeviceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A93B82D92B78D0D200C5DF3D /* TestDeviceView.swift */; }; + A93B82DC2B79D67800C5DF3D /* SpeziBluetoothTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A93B82DB2B79D67800C5DF3D /* SpeziBluetoothTests.swift */; }; A95542B42B5E3E210066646D /* BluetoothModuleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95542B32B5E3E210066646D /* BluetoothModuleView.swift */; }; - A95542B62B5E3EAC0066646D /* BluetoothStateSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95542B52B5E3EAC0066646D /* BluetoothStateSection.swift */; }; A95542B92B5E3F490066646D /* SearchingNearbyDevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95542B82B5E3F490066646D /* SearchingNearbyDevicesView.swift */; }; - A95542BB2B5E3F7B0066646D /* DevicesHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95542BA2B5E3F7B0066646D /* DevicesHeader.swift */; }; A95542BD2B5E40DF0066646D /* TestDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95542BC2B5E40DF0066646D /* TestDevice.swift */; }; + A97851BF2B79E01D007BCBE3 /* BluetoothServices in Frameworks */ = {isa = PBXBuildFile; productRef = A97851BE2B79E01D007BCBE3 /* BluetoothServices */; }; + A9C17DEA2B5F1EAA00976924 /* BluetoothServices in Frameworks */ = {isa = PBXBuildFile; productRef = A9C17DE92B5F1EAA00976924 /* BluetoothServices */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -52,15 +58,18 @@ 2F6D139228F5F384007C25D6 /* TestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TestApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 2F6D139928F5F386007C25D6 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 2F6D13AC28F5F386007C25D6 /* TestAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TestAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 2FA43E912AE057CA009B1B2C /* SpeziBluetoothTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SpeziBluetoothTests.swift; sourceTree = ""; }; + 2FA43E912AE057CA009B1B2C /* BluetoothManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BluetoothManagerTests.swift; sourceTree = ""; }; 2FB0758A299DDB9000C0B37F /* TestApp.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestApp.xctestplan; sourceTree = ""; }; A92802B82B50823600874D0A /* BluetoothManagerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothManagerView.swift; sourceTree = ""; }; A92802BA2B5085BE00874D0A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; A92802BC2B51CBBE00874D0A /* DeviceRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRowView.swift; sourceTree = ""; }; + A93B82D32B78C20700C5DF3D /* BluetoothStateSection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BluetoothStateSection.swift; sourceTree = ""; }; + A93B82D52B78C2D100C5DF3D /* DeviceInformationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceInformationView.swift; sourceTree = ""; }; + A93B82D72B78C31E00C5DF3D /* TestServiceView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestServiceView.swift; sourceTree = ""; }; + A93B82D92B78D0D200C5DF3D /* TestDeviceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestDeviceView.swift; sourceTree = ""; }; + A93B82DB2B79D67800C5DF3D /* SpeziBluetoothTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeziBluetoothTests.swift; sourceTree = ""; }; A95542B32B5E3E210066646D /* BluetoothModuleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothModuleView.swift; sourceTree = ""; }; - A95542B52B5E3EAC0066646D /* BluetoothStateSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothStateSection.swift; sourceTree = ""; }; A95542B82B5E3F490066646D /* SearchingNearbyDevicesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchingNearbyDevicesView.swift; sourceTree = ""; }; - A95542BA2B5E3F7B0066646D /* DevicesHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesHeader.swift; sourceTree = ""; }; A95542BC2B5E40DF0066646D /* TestDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestDevice.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -70,6 +79,8 @@ buildActionMask = 2147483647; files = ( A92802B72B5081F200874D0A /* SpeziBluetooth in Frameworks */, + A91E672E2B75A500009A1E02 /* BluetoothViews in Frameworks */, + A9C17DEA2B5F1EAA00976924 /* BluetoothServices in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -78,6 +89,7 @@ buildActionMask = 2147483647; files = ( 2F64EA8B2A86B3DE006789D0 /* XCTestExtensions in Frameworks */, + A97851BF2B79E01D007BCBE3 /* BluetoothServices in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -108,14 +120,15 @@ 2F6D139428F5F384007C25D6 /* TestApp */ = { isa = PBXGroup; children = ( + A95542B72B5E3F260066646D /* Views */, A92802BA2B5085BE00874D0A /* Info.plist */, A92802B82B50823600874D0A /* BluetoothManagerView.swift */, A95542B32B5E3E210066646D /* BluetoothModuleView.swift */, 2F64EA872A86B36C006789D0 /* TestApp.swift */, 2F64EA812A86B346006789D0 /* TestAppDelegate.swift */, - 2F6D139928F5F386007C25D6 /* Assets.xcassets */, - A95542B72B5E3F260066646D /* Utiltities */, A95542BC2B5E40DF0066646D /* TestDevice.swift */, + 2F6D139928F5F386007C25D6 /* Assets.xcassets */, + A93B82D92B78D0D200C5DF3D /* TestDeviceView.swift */, ); path = TestApp; sourceTree = ""; @@ -123,7 +136,8 @@ 2F6D13AF28F5F386007C25D6 /* TestAppUITests */ = { isa = PBXGroup; children = ( - 2FA43E912AE057CA009B1B2C /* SpeziBluetoothTests.swift */, + 2FA43E912AE057CA009B1B2C /* BluetoothManagerTests.swift */, + A93B82DB2B79D67800C5DF3D /* SpeziBluetoothTests.swift */, ); path = TestAppUITests; sourceTree = ""; @@ -135,15 +149,16 @@ name = Frameworks; sourceTree = ""; }; - A95542B72B5E3F260066646D /* Utiltities */ = { + A95542B72B5E3F260066646D /* Views */ = { isa = PBXGroup; children = ( - A95542B52B5E3EAC0066646D /* BluetoothStateSection.swift */, + A93B82D72B78C31E00C5DF3D /* TestServiceView.swift */, + A93B82D52B78C2D100C5DF3D /* DeviceInformationView.swift */, + A93B82D32B78C20700C5DF3D /* BluetoothStateSection.swift */, A92802BC2B51CBBE00874D0A /* DeviceRowView.swift */, - A95542BA2B5E3F7B0066646D /* DevicesHeader.swift */, A95542B82B5E3F490066646D /* SearchingNearbyDevicesView.swift */, ); - path = Utiltities; + path = Views; sourceTree = ""; }; /* End PBXGroup section */ @@ -165,6 +180,8 @@ name = TestApp; packageProductDependencies = ( A92802B62B5081F200874D0A /* SpeziBluetooth */, + A9C17DE92B5F1EAA00976924 /* BluetoothServices */, + A91E672D2B75A500009A1E02 /* BluetoothViews */, ); productName = Example; productReference = 2F6D139228F5F384007C25D6 /* TestApp.app */; @@ -186,6 +203,7 @@ name = TestAppUITests; packageProductDependencies = ( 2F64EA8A2A86B3DE006789D0 /* XCTestExtensions */, + A97851BE2B79E01D007BCBE3 /* BluetoothServices */, ); productName = ExampleUITests; productReference = 2F6D13AC28F5F386007C25D6 /* TestAppUITests.xctest */; @@ -199,7 +217,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1500; - LastUpgradeCheck = 1500; + LastUpgradeCheck = 1520; TargetAttributes = { 2F6D139128F5F384007C25D6 = { CreatedOnToolsVersion = 14.1; @@ -255,14 +273,16 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A93B82D82B78C31E00C5DF3D /* TestServiceView.swift in Sources */, + A93B82DA2B78D0D200C5DF3D /* TestDeviceView.swift in Sources */, A95542B92B5E3F490066646D /* SearchingNearbyDevicesView.swift in Sources */, 2F64EA852A86B347006789D0 /* TestAppDelegate.swift in Sources */, - A95542BB2B5E3F7B0066646D /* DevicesHeader.swift in Sources */, + A93B82D42B78C20700C5DF3D /* BluetoothStateSection.swift in Sources */, + A93B82D62B78C2D100C5DF3D /* DeviceInformationView.swift in Sources */, A95542BD2B5E40DF0066646D /* TestDevice.swift in Sources */, A92802BD2B51CBBE00874D0A /* DeviceRowView.swift in Sources */, A92802B92B50823600874D0A /* BluetoothManagerView.swift in Sources */, 2F64EA882A86B36C006789D0 /* TestApp.swift in Sources */, - A95542B62B5E3EAC0066646D /* BluetoothStateSection.swift in Sources */, A95542B42B5E3E210066646D /* BluetoothModuleView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -271,7 +291,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 2FA43E922AE057CA009B1B2C /* SpeziBluetoothTests.swift in Sources */, + A93B82DC2B79D67800C5DF3D /* SpeziBluetoothTests.swift in Sources */, + 2FA43E922AE057CA009B1B2C /* BluetoothManagerTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -280,6 +301,10 @@ /* Begin PBXTargetDependency section */ 2F6D13AE28F5F386007C25D6 /* PBXTargetDependency */ = { isa = PBXTargetDependency; + platformFilters = ( + ios, + maccatalyst, + ); target = 2F6D139128F5F384007C25D6 /* TestApp */; targetProxy = 2F6D13AD28F5F386007C25D6 /* PBXContainerItemProxy */; }; @@ -338,12 +363,14 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + XROS_DEPLOYMENT_TARGET = 1.0; }; name = Debug; }; @@ -393,12 +420,14 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; VALIDATE_PRODUCT = YES; + XROS_DEPLOYMENT_TARGET = 1.0; }; name = Release; }; @@ -411,7 +440,8 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = 484YT3X9X7; + DEVELOPMENT_TEAM = 637867499T; + ENABLE_HARDENED_RUNTIME = NO; ENABLE_PREVIEWS = YES; ENABLE_TESTING_SEARCH_PATHS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -428,10 +458,10 @@ ); MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.bluetooth.testapp; + PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.bluetooth.testapplication; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; - SUPPORTS_MACCATALYST = NO; + SUPPORTS_MACCATALYST = YES; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; @@ -447,10 +477,13 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = 484YT3X9X7; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = 637867499T; + ENABLE_HARDENED_RUNTIME = NO; ENABLE_PREVIEWS = YES; ENABLE_TESTING_SEARCH_PATHS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -467,10 +500,11 @@ ); MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.bluetooth.testapp; + PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.bluetooth.testapplication; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; - SUPPORTS_MACCATALYST = NO; + SUPPORTS_MACCATALYST = YES; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; @@ -490,12 +524,12 @@ GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.bluetooth.testapp.uitests; + PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.bluetooth.testapplication.uitests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SUPPORTS_MACCATALYST = NO; - SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; @@ -508,18 +542,22 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 637867499T; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = 637867499T; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.bluetooth.testapp.uitests; + PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.bluetooth.testapplication; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = ""; + PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SUPPORTS_MACCATALYST = NO; - SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; @@ -580,12 +618,14 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = TEST; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + XROS_DEPLOYMENT_TARGET = 1.0; }; name = Test; }; @@ -595,10 +635,13 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = 484YT3X9X7; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = 637867499T; + ENABLE_HARDENED_RUNTIME = NO; ENABLE_PREVIEWS = YES; ENABLE_TESTING_SEARCH_PATHS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -615,10 +658,11 @@ ); MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.bluetooth.testapp; + PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.bluetooth.testapplication; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; - SUPPORTS_MACCATALYST = NO; + SUPPORTS_MACCATALYST = YES; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; @@ -632,18 +676,22 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 637867499T; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = 637867499T; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.bluetooth.testapp.uitests; + PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.bluetooth.testapplication; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = ""; + PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SUPPORTS_MACCATALYST = NO; - SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; @@ -693,7 +741,7 @@ repositoryURL = "https://github.com/StanfordBDHG/XCTestExtensions.git"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 0.4.6; + minimumVersion = 0.4.10; }; }; /* End XCRemoteSwiftPackageReference section */ @@ -704,10 +752,22 @@ package = 2F64EA892A86B3DE006789D0 /* XCRemoteSwiftPackageReference "XCTestExtensions" */; productName = XCTestExtensions; }; + A91E672D2B75A500009A1E02 /* BluetoothViews */ = { + isa = XCSwiftPackageProductDependency; + productName = BluetoothViews; + }; A92802B62B5081F200874D0A /* SpeziBluetooth */ = { isa = XCSwiftPackageProductDependency; productName = SpeziBluetooth; }; + A97851BE2B79E01D007BCBE3 /* BluetoothServices */ = { + isa = XCSwiftPackageProductDependency; + productName = BluetoothServices; + }; + A9C17DE92B5F1EAA00976924 /* BluetoothServices */ = { + isa = XCSwiftPackageProductDependency; + productName = BluetoothServices; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 2F6D138A28F5F384007C25D6 /* Project object */; diff --git a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme index 5637e905..e7449281 100644 --- a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme +++ b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme @@ -1,6 +1,6 @@ + + + + Label + {{Label}} + UserName + {{User}} + InitGroups + + WorkingDirectory + {{UserHome}} + ProgramArguments + + /Applications/TestPeripheral + + StandardOutPath + {{UserHome}}/Library/Logs/{{Label}}/stdout.log + StandardErrorPath + {{UserHome}}/Library/Logs/{{Label}}/stderr.log + RunAtLoad + + SessionCreate + + ProcessType + Interactive + + diff --git a/bin/edu.stanford.spezi.bluetooth.testperipheral.plist.template.license b/bin/edu.stanford.spezi.bluetooth.testperipheral.plist.template.license new file mode 100644 index 00000000..28f53d0d --- /dev/null +++ b/bin/edu.stanford.spezi.bluetooth.testperipheral.plist.template.license @@ -0,0 +1,5 @@ +This source file is part of the Stanford Spezi open-source project + +SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) + +SPDX-License-Identifier: MIT \ No newline at end of file diff --git a/bin/service-launchd.sh b/bin/service-launchd.sh new file mode 100755 index 00000000..a14da328 --- /dev/null +++ b/bin/service-launchd.sh @@ -0,0 +1,136 @@ +#!/bin/zsh +# +# This source file is part of the Stanford Spezi open-source project +# +# SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +# +# SPDX-License-Identifier: MIT +# + +# Script to install, start, stop, status or uninstall launchd agent + +user_id=$(id -u) + +# launchctl should not run as sudo for launch runners +if [ "$user_id" -eq 0 ]; then + echo "Must not run with sudo" + exit 1 +fi + +CMD=$1 + +script_dir=$(dirname "$0") + +APPLICATION_PATH="/Applications/TestPeripheral" +SERVICE_LABEL="edu.stanford.spezi.bluetooth.testperipheral" +LAUNCH_PATH="${HOME}/Library/LaunchAgents" +PLIST_PATH="${LAUNCH_PATH}/${SERVICE_LABEL}.plist" +TEMPLATE_PATH=${script_dir}/edu.stanford.spezi.bluetooth.testperipheral.plist.template +TEMP_PATH=${script_dir}/edu.stanford.spezi.bluetooth.testperipheral.plist.temp + +function failed() +{ + local error=${1:-Undefined error} + echo "Failed: $error" >&2 + exit 1 +} + +if [ ! -f "${TEMPLATE_PATH}" ]; then + failed "service template file doesn't exist" +fi + +function install() +{ + echo "Creating launchd agent in ${PLIST_PATH}" + + if [ ! -f "${APPLICATION_PATH}" ]; then + failed "test peripheral binary is not installed at ${APPLICATION_PATH}" + fi + + if [ ! -d "${LAUNCH_PATH}" ]; then + mkdir "${LAUNCH_PATH}" + fi + + if [ -f "${PLIST_PATH}" ]; then + failed "already exists ${PLIST_PATH}" + fi + + if [ -f "${TEMP_PATH}" ]; then + rm "${TEMP_PATH}" || failed "failed to delete ${TEMP_PATH}" + fi + + log_path="${HOME}/Library/Logs/${SERVICE_LABEL}" + echo "Creating ${log_path}" + mkdir -p "${log_path}" || failed "failed to create ${log_path}" + + echo "Creating ${PLIST_PATH}" + sed "s/{{User}}/${USER:-$SUDO_USER}/g; s/{{Label}}/$SERVICE_LABEL/g; s@{{UserHome}}@$HOME@g;" "${TEMPLATE_PATH}" > "${TEMP_PATH}" || failed "failed to derive service file from template" + mv "${TEMP_PATH}" "${PLIST_PATH}" || failed "failed to copy service plist" + + echo "service install complete" +} + +function start() +{ + echo "starting ${SERVICE_LABEL}" + launchctl load -w "${PLIST_PATH}" || failed "failed to load ${PLIST_PATH}" + status +} + +function stop() +{ + echo "stopping ${SERVICE_LABEL}" + launchctl unload "${PLIST_PATH}" || failed "failed to unload ${PLIST_PATH}" + status +} + +function uninstall() +{ + echo "uninstalling ${SERVICE_LABEL}" + stop + rm "${PLIST_PATH}" || failed "failed to delete ${PLIST_PATH}" +} + +function status() +{ + echo "status ${SERVICE_LABEL}:" + if [ -f "${PLIST_PATH}" ]; then + echo + echo "${PLIST_PATH}" + else + echo + echo "not installed" + echo + return + fi + + echo + status_out=$(launchctl list | grep "${SERVICE_LABEL}") + if [ -n "$status_out" ]; then + echo Started: + echo "$status_out" + echo + else + echo Stopped + echo + fi +} + +function usage() +{ + echo + echo Usage: + echo "./service-launchd.sh [install, start, stop, status, uninstall]" + echo +} + +case $CMD in + "install") install;; + "status") status;; + "uninstall") uninstall;; + "start") start;; + "stop") stop;; + *) usage;; +esac + +exit 0