From a8cbccb7444a5434cc265a84e8371f1b2c248f43 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Wed, 12 Jun 2024 19:53:10 +0200 Subject: [PATCH 01/58] Add accessory discovery criteria --- .../Configuration/DiscoveryCriteria.swift | 67 ++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift index 65d05a0c..e2be5a96 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift @@ -8,6 +8,48 @@ @preconcurrency import CoreBluetooth +/// Bluetooth SIG-assigned Manufacturer Identifier. +/// +/// Refer to Assigned Numbers 7. Company Identifiers. +public struct ManufacturerIdentifier { + public let rawValue: UInt16 + + public init(rawValue: UInt16) { + self.rawValue = rawValue + } +} + + +extension ManufacturerIdentifier: Hashable, Sendable {} + + +extension ManufacturerIdentifier: RawRepresentable {} + + +extension ManufacturerIdentifier: ExpressibleByIntegerLiteral { + public init(integerLiteral value: UInt16) { + self.init(rawValue: value) + } +} + + +import ByteCoding +import NIOCore +extension ManufacturerIdentifier: ByteCodable { + public init?(from byteBuffer: inout ByteBuffer) { + guard let rawValue = UInt16(from: &byteBuffer) else { + return nil + } + self.init(rawValue: rawValue) + } + + public func encode(to byteBuffer: inout ByteBuffer) { + rawValue.encode(to: &byteBuffer) + } + + +} + /// The criteria by which we identify a discovered device. /// @@ -20,12 +62,18 @@ public enum DiscoveryCriteria: Sendable { /// Identify a device by their advertised service. case advertisedService(_ uuid: CBUUID) + case accessory(company: ManufacturerIdentifier, name: String, service: CBUUID) + // TODO: "company" vs "manufacturer" + // TODO: name as a substring?; not local name! + // TODO: how to communicate the "advertised" service? - var discoveryId: CBUUID { + var discoveryId: CBUUID { // TODO: make that custom able? switch self { case let .advertisedService(uuid): uuid + case let .accessory(_, _, service): + service } } @@ -34,11 +82,26 @@ public enum DiscoveryCriteria: Sendable { switch self { case let .advertisedService(uuid): return advertisementData.serviceUUIDs?.contains(uuid) ?? false + case let .accessory(company, name, service): + guard let manufacturerData = advertisementData.manufacturerData, + let identifier = ManufacturerIdentifier(data: manufacturerData) else { + return false + } + + guard identifier == company else { + return false + } + + // TODO: compare peripheral name! (substring?) + + + return advertisementData.serviceUUIDs?.contains(service) ?? false } } } +// TODO: similar overloads for accessory! extension DiscoveryCriteria { /// Identify a device by their advertised service. /// - Parameter uuid: The Bluetooth ServiceId in string format. @@ -61,6 +124,8 @@ extension DiscoveryCriteria: Hashable, CustomStringConvertible { switch self { case let .advertisedService(uuid): ".advertisedService(\(uuid))" + case let .accessory(company, name, service): + "accessory(company: \(company), name: \(name), service: \(service))" } } } From 603d97f5b6203b609517c4a0f932e53f5dbd647a Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 13 Jun 2024 16:08:04 +0200 Subject: [PATCH 02/58] Support retrieving paired peripherals --- Sources/SpeziBluetooth/Bluetooth.swift | 23 +++++++++++++++++++ .../CoreBluetooth/BluetoothManager.swift | 18 +++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/Sources/SpeziBluetooth/Bluetooth.swift b/Sources/SpeziBluetooth/Bluetooth.swift index 2d3ed085..6238a62e 100644 --- a/Sources/SpeziBluetooth/Bluetooth.swift +++ b/Sources/SpeziBluetooth/Bluetooth.swift @@ -409,6 +409,29 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo } } + + public func retrievePeripheral(for uuid: UUID, as device: Device.Type = Device.self) -> Device? { + // TODO: this doesn't really need isolation? + guard let peripheral = bluetoothManager.assumeIsolated({ $0.retrievePeripheral(for: uuid) }) else { + return nil + } + + + let closures = ClosureRegistrar() + let device = ClosureRegistrar.$writeableView.withValue(closures) { + Device() + } + ClosureRegistrar.$readableView.withValue(closures) { + device.inject(peripheral: peripheral) + // TODO: nearbyDevices[uuid] = device + } + + // TODO: we need to store them int he discoveredPeripherals to properly forward delegate methods!!! + // TODO: however, can we store them weak? => use deinit of the Device object to clean it up once the peripheral looses reference? + // TODO: we are also not hooking this thing up into the Bluetooth module system! + return device + } + /// Scan for nearby bluetooth devices. /// /// Scans on nearby devices based on the ``Discover`` declarations provided in the initializer. diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift index 257ed477..ae9c55be 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift @@ -306,6 +306,24 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable } } + + public func retrievePeripheral(for uuid: UUID) -> BluetoothPeripheral? { + // TODO: does this need isolation? + guard let peripheral = centralManager.retrievePeripherals(withIdentifiers: [uuid]).first else { + checkForCentralDeinit() + return nil + } + + + return BluetoothPeripheral( + manager: self, + peripheral: peripheral, + advertisementData: .init(advertisementData: [:]), // TODO: init for empty? + rssi: 127 // TOOD: what is the unknown value= + ) + // TODO: when to deinit central? + } + func onChange(of keyPath: KeyPath, perform closure: @escaping (Value) -> Void) { _storage.onChange(of: keyPath, perform: closure) } From d97c71800fa9fcc6cd926e46f3120226361b9f3f Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 13 Jun 2024 16:45:23 +0200 Subject: [PATCH 03/58] Treat retrieved peripherals as observed peripherals for now --- Sources/SpeziBluetooth/Bluetooth.swift | 5 +++++ .../CoreBluetooth/BluetoothManager.swift | 18 ++++++++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/Sources/SpeziBluetooth/Bluetooth.swift b/Sources/SpeziBluetooth/Bluetooth.swift index 6238a62e..aabeea64 100644 --- a/Sources/SpeziBluetooth/Bluetooth.swift +++ b/Sources/SpeziBluetooth/Bluetooth.swift @@ -426,6 +426,11 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo // TODO: nearbyDevices[uuid] = device } + observePeripheralState(of: uuid) // register \.state onChange closure + + spezi.loadModule(device) + handlePeripheralStateChange() + // TODO: we need to store them int he discoveredPeripherals to properly forward delegate methods!!! // TODO: however, can we store them weak? => use deinit of the Device object to clean it up once the peripheral looses reference? // TODO: we are also not hooking this thing up into the Bluetooth module system! diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift index ae9c55be..23e2e590 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift @@ -274,6 +274,7 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable shouldBeScanning = true self.autoConnect = autoConnect + // TODO: docs: silently fails if bluetooth isn't powered on! if case .poweredOn = centralManager.state { centralManager.scanForPeripherals( withServices: serviceDiscoveryIds, @@ -308,6 +309,12 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable public func retrievePeripheral(for uuid: UUID) -> BluetoothPeripheral? { + // TODO: only works if state is powered on + guard case .poweredOn = centralManager.state else { + logger.warning("Cannot retrieve peripheral with id \(uuid) while central is not powered on \(state)") + return nil + } + // TODO: does this need isolation? guard let peripheral = centralManager.retrievePeripherals(withIdentifiers: [uuid]).first else { checkForCentralDeinit() @@ -315,13 +322,16 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable } - return BluetoothPeripheral( + let peripheral = BluetoothPeripheral( manager: self, peripheral: peripheral, advertisementData: .init(advertisementData: [:]), // TODO: init for empty? - rssi: 127 // TOOD: what is the unknown value= + rssi: 127 // value of 127 signifies unavailability of RSSI value ) + + discoveredPeripherals.updateValue(device, forKey: peripheral.identifier) // TODO: when to deinit central? + return peripheral } func onChange(of keyPath: KeyPath, perform closure: @escaping (Value) -> Void) { @@ -598,8 +608,8 @@ extension BluetoothManager { return } - // 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(). + // All these delegate methods are actually running on the DispatchQueue the Actor is isolated to. + // So in theory we should just be able to jump 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): From 65c5085fa0f6440967186b282a3f268f10f3f25e Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 13 Jun 2024 16:47:41 +0200 Subject: [PATCH 04/58] Compiles --- Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift index 23e2e590..fd9ae45f 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift @@ -311,7 +311,7 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable public func retrievePeripheral(for uuid: UUID) -> BluetoothPeripheral? { // TODO: only works if state is powered on guard case .poweredOn = centralManager.state else { - logger.warning("Cannot retrieve peripheral with id \(uuid) while central is not powered on \(state)") + logger.warning("Cannot retrieve peripheral with id \(uuid) while central is not powered on \(self.state)") return nil } @@ -322,7 +322,7 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable } - let peripheral = BluetoothPeripheral( + let device = BluetoothPeripheral( manager: self, peripheral: peripheral, advertisementData: .init(advertisementData: [:]), // TODO: init for empty? @@ -331,7 +331,7 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable discoveredPeripherals.updateValue(device, forKey: peripheral.identifier) // TODO: when to deinit central? - return peripheral + return device } func onChange(of keyPath: KeyPath, perform closure: @escaping (Value) -> Void) { From b6aee6c7c7eacf49a6f01d6fe7015d95f2c23605 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 14 Jun 2024 14:53:10 +0200 Subject: [PATCH 05/58] Use new Timeout Error and withTimeout infrastructure --- Package.swift | 2 +- .../CoreBluetooth/BluetoothPeripheral.swift | 1 + .../Configuration/DiscoveryCriteria.swift | 2 +- .../Characteristic/ControlPointSupport.swift | 3 +- .../Errors/ControlPointTimeoutError.swift | 19 ----------- .../CharacteristicPeripheralInjection.swift | 33 +++++++------------ .../SpeziBluetooth.docc/SpeziBluetooth.md | 4 --- 7 files changed, 16 insertions(+), 48 deletions(-) delete mode 100644 Sources/SpeziBluetooth/Model/Errors/ControlPointTimeoutError.swift diff --git a/Package.swift b/Package.swift index 1bc5563a..cee5c2ec 100644 --- a/Package.swift +++ b/Package.swift @@ -25,7 +25,7 @@ let package = Package( .library(name: "SpeziBluetooth", targets: ["SpeziBluetooth"]) ], dependencies: [ - .package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "1.0.4"), + .package(url: "https://github.com/StanfordSpezi/SpeziFoundation", branch: "feature/timeout-support"), .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.3.0"), .package(url: "https://github.com/StanfordSpezi/SpeziNetworking", from: "2.0.1"), .package(url: "https://github.com/StanfordSpezi/SpeziViews", from: "1.3.0"), diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift index f855628b..8c0ab4f4 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift @@ -278,6 +278,7 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ } func handleConnect(consider configuredDevices: Set) { + // TODO: store discovery description within device? if let description = configuredDevices.find(for: advertisementData, logger: logger), let services = description.services { requestedCharacteristics = services.reduce(into: [CBUUID: Set?]()) { result, configuration in diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift index e2be5a96..ffdb458c 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift @@ -82,7 +82,7 @@ public enum DiscoveryCriteria: Sendable { switch self { case let .advertisedService(uuid): return advertisementData.serviceUUIDs?.contains(uuid) ?? false - case let .accessory(company, name, service): + case let .accessory(company, _, service): guard let manufacturerData = advertisementData.manufacturerData, let identifier = ManufacturerIdentifier(data: manufacturerData) else { return false diff --git a/Sources/SpeziBluetooth/Model/Characteristic/ControlPointSupport.swift b/Sources/SpeziBluetooth/Model/Characteristic/ControlPointSupport.swift index c13c5ba5..3fdc05f8 100644 --- a/Sources/SpeziBluetooth/Model/Characteristic/ControlPointSupport.swift +++ b/Sources/SpeziBluetooth/Model/Characteristic/ControlPointSupport.swift @@ -7,6 +7,7 @@ // import Foundation +import SpeziFoundation final class ControlPointTransaction: @unchecked Sendable { @@ -34,7 +35,7 @@ final class ControlPointTransaction: @unchecked Sendable { } func signalTimeout() { - resume(with: .failure(ControlPointTimeoutError())) + resume(with: .failure(TimeoutError())) } func fulfill(_ value: Value) { diff --git a/Sources/SpeziBluetooth/Model/Errors/ControlPointTimeoutError.swift b/Sources/SpeziBluetooth/Model/Errors/ControlPointTimeoutError.swift deleted file mode 100644 index 620e77e2..00000000 --- a/Sources/SpeziBluetooth/Model/Errors/ControlPointTimeoutError.swift +++ /dev/null @@ -1,19 +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 -// - - -/// Timeout occurred with a control point characteristic. -/// -/// This error indicates that there was a timeout while waiting for the response to a request sent to -/// a ``ControlPointCharacteristic``. -public struct ControlPointTimeoutError { - /// Create new timeout error. - public init() {} -} - -extension ControlPointTimeoutError: Error {} diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift index 4302a75f..5ea55976 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift @@ -8,6 +8,7 @@ import ByteCoding import CoreBluetooth +import SpeziFoundation private protocol DecodableCharacteristic: Actor { @@ -341,31 +342,28 @@ extension CharacteristicPeripheralInjection where Value: ControlPointCharacteris let transaction = ControlPointTransaction() self.controlPointTransaction = transaction + defer { + if controlPointTransaction?.id == transaction.id { + controlPointTransaction = nil + } + } + + // make sure we are ready to receive the response async let response = controlPointContinuationTask(transaction) do { try await write(value) } catch { transaction.signalCancellation() - resetControlPointTransaction(with: transaction.id) - _ = try? await response + _ = try? await response // await response to avoid cancellation throw error } - let timeoutTask = Task { - try? await Task.sleep(for: timeout) - if !Task.isCancelled { - transaction.signalTimeout() - resetControlPointTransaction(with: transaction.id) - } - } - - defer { - timeoutTask.cancel() + async let _ = withTimeout(of: timeout) { + transaction.signalTimeout() } - return try await response } @@ -376,15 +374,6 @@ extension CharacteristicPeripheralInjection where Value: ControlPointCharacteris } } onCancel: { transaction.signalCancellation() - Task { - await resetControlPointTransaction(with: transaction.id) - } - } - } - - private func resetControlPointTransaction(with id: UUID) { - if controlPointTransaction?.id == id { - self.controlPointTransaction = nil } } } diff --git a/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md b/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md index cfb82fe2..e489f72b 100644 --- a/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md +++ b/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md @@ -278,7 +278,3 @@ due to their async nature. - ``DeviceDescription`` - ``ServiceDescription`` - ``CharacteristicDescription`` - -### Errors - -- ``ControlPointTimeoutError`` From ed51fb01a5282ae6530b5973b5d00b2201a88c8f Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 18 Jun 2024 13:50:51 +0200 Subject: [PATCH 06/58] Release --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index cee5c2ec..6c0fb28d 100644 --- a/Package.swift +++ b/Package.swift @@ -25,7 +25,7 @@ let package = Package( .library(name: "SpeziBluetooth", targets: ["SpeziBluetooth"]) ], dependencies: [ - .package(url: "https://github.com/StanfordSpezi/SpeziFoundation", branch: "feature/timeout-support"), + .package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "1.1.0"), .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.3.0"), .package(url: "https://github.com/StanfordSpezi/SpeziNetworking", from: "2.0.1"), .package(url: "https://github.com/StanfordSpezi/SpeziViews", from: "1.3.0"), From 262f1d62b6e07cb825b87d32ccfa909872755db1 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 18 Jun 2024 20:36:24 +0200 Subject: [PATCH 07/58] Change behavior of `name`, make `localName` public and add support for `discarded` property --- .../CoreBluetooth/BluetoothManager.swift | 9 +++++++ .../CoreBluetooth/BluetoothPeripheral.swift | 26 +++++++++++++++++-- .../Model/PeripheralStorage.swift | 21 +++++++++++++-- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift index fd9ae45f..864a803c 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift @@ -136,9 +136,11 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable get { _storage.discoveredPeripherals } + /* TODO: reenable! _modify { yield &_storage.discoveredPeripherals } + */ set { _storage.discoveredPeripherals = newValue } @@ -322,6 +324,7 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable } + // TODO: for every peripheral one needs to make sure that discarded is eventually set to true! let device = BluetoothPeripheral( manager: self, peripheral: peripheral, @@ -362,6 +365,12 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable } private func clearDiscoveredPeripheral(forKey id: UUID) { + if let peripheral = discoveredPeripherals[id] { + // `handleDiscarded` must be called before actually removing it from the dictionary to make sure peripherals can react to this event + peripheral.assumeIsolated { device in + device.handleDiscarded() + } + } discoveredPeripherals.removeValue(forKey: id) if lastManuallyDisconnectedDevice == id { diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift index 8c0ab4f4..11657824 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift @@ -31,6 +31,8 @@ enum CharacteristicOnChangeHandler { /// - ``state`` /// - ``rssi`` /// - ``advertisementData`` +/// - ``discarded`` +/// - ``lastActivity`` /// /// ### Accessing Services /// - ``services`` @@ -106,7 +108,12 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ _storage.name } - nonisolated private(set) var localName: String? { + // TODO: change access/default of accessory name + // TODO: provide access to detect if peripheral is no longer considered discovered! + + + // TODO: docs + nonisolated public private(set) var localName: String? { get { _storage.localName } @@ -171,7 +178,8 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ } } - private(set) var lastActivity: Date { + // TODO: docs (and behavior!) + nonisolated public private(set) var lastActivity: Date { get { if case .disconnected = peripheral.state { _storage.lastActivity @@ -185,6 +193,16 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ } } + // TODO: docs! + nonisolated public private(set) var discarded: Bool { + get { + _storage.discarded + } + set { + _storage.update(discarded: newValue) + } + } + init( manager: BluetoothManager, @@ -334,6 +352,10 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ } } + func handleDiscarded() { + isolatedUpdate(of: \.discarded, true) + } + func markLastActivity(_ lastActivity: Date = .now) { self.lastActivity = lastActivity } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift index f425a9a8..e1d1d06d 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift @@ -17,7 +17,7 @@ import Foundation @Observable final class PeripheralStorage: ValueObservable { var name: String? { - localName ?? peripheralName + peripheralName ?? localName } private(set) var peripheralName: String? { @@ -25,32 +25,44 @@ final class PeripheralStorage: ValueObservable { _$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 discarded: Bool { + didSet { + _$simpleRegistrar.triggerDidChange(for: \.discarded, on: self) + } + } + private(set) var services: [GATTService]? { // swiftlint:disable:this discouraged_optional_collection didSet { _$simpleRegistrar.triggerDidChange(for: \.services, on: self) } } - @ObservationIgnored var lastActivity: Date + + @ObservationIgnored var lastActivity: Date // TODO: this needs to be observed now ! // swiftlint:disable:next identifier_name @ObservationIgnored var _$simpleRegistrar = ValueObservationRegistrar() @@ -61,6 +73,7 @@ final class PeripheralStorage: ValueObservable { self.advertisementData = advertisementData self.rssi = rssi self.state = .init(from: state) + self.discarded = false self.lastActivity = lastActivity } @@ -95,6 +108,10 @@ final class PeripheralStorage: ValueObservable { } } + func update(discarded: Bool) { + self.discarded = discarded + } + func signalFullyDiscovered() { if state == .connecting { state = .connected From 07aa4fffded2a56e02f7e873907e2ff8d8b03c5e Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 18 Jun 2024 20:41:53 +0200 Subject: [PATCH 08/58] Add missing keypath translations --- .../PropertySupport/DeviceStatePeripheralInjection.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift index ca3fdef5..454bf3a3 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift @@ -102,6 +102,8 @@ extension KeyPath where Root == BluetoothPeripheral { let anyKeyPath: AnyKeyPath? = switch self { case \.name: \PeripheralStorage.name + case \.localName: + \PeripheralStorage.localName case \.rssi: \PeripheralStorage.rssi case \.advertisementData: @@ -110,6 +112,10 @@ extension KeyPath where Root == BluetoothPeripheral { \PeripheralStorage.state case \.services: \PeripheralStorage.services + case \.discarded: + \PeripheralStorage.discarded + case \.lastActivity: + \PeripheralStorage.lastActivity case \.id: nil default: From b507919eca1fd8c14aeb94eba9161b3279a3e086 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Wed, 19 Jun 2024 11:47:05 +0200 Subject: [PATCH 09/58] Make sure CharacteristicAccess is not accidentally consumed --- .../CoreBluetooth/BluetoothPeripheral.swift | 8 ++++-- .../Model/CharacteristicAccesses.swift | 26 +++++++------------ 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift index 11657824..f21b7677 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift @@ -741,11 +741,13 @@ extension BluetoothPeripheral { } private func receivedUpdatedValue(for characteristic: CBCharacteristic, result: Result) { - if case let .read(continuation) = characteristicAccesses.retrieveAccess(for: characteristic) { + if let access = characteristicAccesses.retrieveAccess(for: characteristic), + case let .read(continuation) = access.value { if case let .failure(error) = result { logger.debug("Characteristic read for \(characteristic.debugIdentifier) returned with error: \(error)") } + access.consume() continuation.resume(with: result) } else if case let .failure(error) = result { logger.debug("Received unsolicited value update error for \(characteristic.debugIdentifier): \(error)") @@ -771,7 +773,8 @@ extension BluetoothPeripheral { } private func receivedWriteResponse(for characteristic: CBCharacteristic, result: Result) { - guard case let .write(continuation) = characteristicAccesses.retrieveAccess(for: characteristic) else { + guard let access = characteristicAccesses.retrieveAccess(for: characteristic), + case let .write(continuation) = access.value else { switch result { case .success: logger.warning("Received write response for \(characteristic.debugIdentifier) without an ongoing access. Discarding write ...") @@ -785,6 +788,7 @@ extension BluetoothPeripheral { logger.debug("Characteristic write for \(characteristic.debugIdentifier) returned with error: \(error)") } + access.consume() continuation.resume(with: result) } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccesses.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccesses.swift index 3b04149b..100a23af 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccesses.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccesses.swift @@ -20,7 +20,7 @@ class CharacteristicAccess { private let id: CBUUID private let semaphore = AsyncSemaphore() - private(set) var access: Access? + private(set) var value: Access? fileprivate init(id: CBUUID) { @@ -32,22 +32,20 @@ class CharacteristicAccess { try await semaphore.waitCheckingCancellation() } - func store(_ access: Access) { - precondition(self.access == nil, "Access was unexpectedly not nil") - self.access = access + func store(_ value: Access) { + precondition(self.value == nil, "Access was unexpectedly not nil") + self.value = value } - func receive() -> Access? { - let access = access - self.access = nil + func consume() { + self.value = nil semaphore.signal() - return access } func cancelAll() { semaphore.cancelAll() - let access = access - self.access = nil + let access = value + self.value = nil switch access { case let .read(continuation): @@ -75,12 +73,8 @@ struct CharacteristicAccesses { return access } - func retrieveAccess(for characteristic: CBCharacteristic) -> CharacteristicAccess.Access? { - guard let access = ongoingAccesses[characteristic] else { - return nil - } - - return access.receive() + func retrieveAccess(for characteristic: CBCharacteristic) -> CharacteristicAccess? { + ongoingAccesses[characteristic] } mutating func cancelAll() { From 0e712a84fa3528acbccfb91d50f2c76fa87e0c84 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 20 Jun 2024 00:01:28 +0200 Subject: [PATCH 10/58] Support retrieving paired devices. --- Package.swift | 20 ++- Sources/SpeziBluetooth/Bluetooth.swift | 24 +-- ....swift => DeviceDiscoveryDescriptor.swift} | 12 +- .../Configuration/Discover.swift | 4 +- .../DiscoveryConfigurationBuilder.swift | 50 ------ .../DiscoveryDescriptorBuilder.swift | 50 ++++++ .../CoreBluetooth/BluetoothManager.swift | 167 ++++++++++++------ .../CoreBluetooth/BluetoothPeripheral.swift | 50 ++++-- .../CharacteristicDescription.swift | 10 +- .../Configuration/DeviceDescription.swift | 30 +--- .../Configuration/DiscoveryCriteria.swift | 87 ++++----- .../Configuration/DiscoveryDescription.swift | 50 ++++++ .../Configuration/ServiceDescription.swift | 10 +- .../Model/ManufacturerIdentifier.swift | 56 ++++++ .../Model/PeripheralStorage.swift | 6 +- .../Model/Properties/DeviceAction.swift | 2 +- .../Model/Properties/DeviceState.swift | 2 +- .../CharacteristicPeripheralInjection.swift | 2 +- .../DeviceStatePeripheralInjection.swift | 7 +- .../DeviceDescriptionParser.swift | 13 +- .../SpeziBluetooth.docc/SpeziBluetooth.md | 1 + Sources/SpeziBluetooth/Utils/Reference.swift | 16 ++ 22 files changed, 426 insertions(+), 243 deletions(-) rename Sources/SpeziBluetooth/Configuration/{DiscoveryConfiguration.swift => DeviceDiscoveryDescriptor.swift} (65%) delete mode 100644 Sources/SpeziBluetooth/Configuration/DiscoveryConfigurationBuilder.swift create mode 100644 Sources/SpeziBluetooth/Configuration/DiscoveryDescriptorBuilder.swift create mode 100644 Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryDescription.swift create mode 100644 Sources/SpeziBluetooth/CoreBluetooth/Model/ManufacturerIdentifier.swift create mode 100644 Sources/SpeziBluetooth/Utils/Reference.swift diff --git a/Package.swift b/Package.swift index 6c0fb28d..130dd2ba 100644 --- a/Package.swift +++ b/Package.swift @@ -11,6 +11,8 @@ import PackageDescription +let swiftLintPlugin: Target.PluginUsage = .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLint") + let package = Package( name: "SpeziBluetooth", defaultLocalization: "en", @@ -30,7 +32,8 @@ let package = Package( .package(url: "https://github.com/StanfordSpezi/SpeziNetworking", from: "2.0.1"), .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") + .package(url: "https://github.com/apple/swift-collections.git", from: "1.0.4"), + .package(url: "https://github.com/realm/SwiftLint.git", .upToNextMinor(from: "0.55.1")) ], targets: [ .target( @@ -44,7 +47,8 @@ let package = Package( ], resources: [ .process("Resources") - ] + ], + plugins: [swiftLintPlugin] ), .target( name: "BluetoothServices", @@ -52,14 +56,16 @@ let package = Package( .target(name: "SpeziBluetooth"), .product(name: "ByteCoding", package: "SpeziNetworking"), .product(name: "SpeziNumerics", package: "SpeziNetworking") - ] + ], + plugins: [swiftLintPlugin] ), .target( name: "BluetoothViews", dependencies: [ .target(name: "SpeziBluetooth"), .product(name: "SpeziViews", package: "SpeziViews") - ] + ], + plugins: [swiftLintPlugin] ), .executableTarget( name: "TestPeripheral", @@ -67,7 +73,8 @@ let package = Package( .target(name: "SpeziBluetooth"), .target(name: "BluetoothServices"), .product(name: "ByteCoding", package: "SpeziNetworking") - ] + ], + plugins: [swiftLintPlugin] ), .testTarget( name: "BluetoothServicesTests", @@ -76,7 +83,8 @@ let package = Package( .target(name: "SpeziBluetooth"), .product(name: "XCTByteCoding", package: "SpeziNetworking"), .product(name: "NIO", package: "swift-nio") - ] + ], + plugins: [swiftLintPlugin] ) ] ) diff --git a/Sources/SpeziBluetooth/Bluetooth.swift b/Sources/SpeziBluetooth/Bluetooth.swift index aabeea64..f3be5190 100644 --- a/Sources/SpeziBluetooth/Bluetooth.swift +++ b/Sources/SpeziBluetooth/Bluetooth.swift @@ -206,7 +206,7 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo let bluetoothQueue: DispatchSerialQueue private let bluetoothManager: BluetoothManager - private let deviceConfigurations: Set + private let deviceConfigurations: Set private let _storage = Storage() @@ -252,7 +252,7 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo /// Configure the Bluetooth Module. /// - /// Configures the Bluetooth Module with the provided set of ``DiscoveryConfiguration``s. + /// Configures the Bluetooth Module with the provided set of ``DeviceDiscoveryDescriptor``s. /// Below is a short code example on how you would discover a `ExampleDevice` by its advertised service id. /// /// ```swift @@ -269,18 +269,18 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo public init( minimumRSSI: Int = BluetoothManager.Defaults.defaultMinimumRSSI, advertisementStaleInterval: TimeInterval = BluetoothManager.Defaults.defaultStaleTimeout, - @DiscoveryConfigurationBuilder _ devices: @Sendable () -> Set + @DiscoveryDescriptorBuilder _ devices: @Sendable () -> Set ) { let configuration = devices() let deviceTypes = configuration.deviceTypes - let devices = ClosureRegistrar.$writeableView.withValue(.init()) { + let discovery = ClosureRegistrar.$writeableView.withValue(.init()) { // we provide a closure registrar just to silence any out-of-band usage warnings! - configuration.parseDeviceDescription() + configuration.parseDeviceDescription() // TODO: rename! } let bluetoothManager = BluetoothManager( - devices: devices, + discovery: discovery, minimumRSSI: minimumRSSI, advertisementStaleInterval: advertisementStaleInterval ) @@ -298,6 +298,7 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo private func observeDiscoveredDevices() { self.assertIsolated("This didn't move to the actor even if it should.") bluetoothManager.assumeIsolated { manager in + // TODO: support retrievedPeripherals manager.onChange(of: \.discoveredPeripherals) { [weak self] discoveredDevices in guard let self = self else { return @@ -342,7 +343,8 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo checkForConnected = true let device = nearbyDevices.removeValue(forKey: key) - if let device { + // TODO: make everything weak and uninject everything once the BluetoothPeripheral itself is deinited? + if let device { // TODO: refactor out! device.clearState(isolatedTo: self) spezi.unloadModule(device) } @@ -410,14 +412,14 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo } - public func retrievePeripheral(for uuid: UUID, as device: Device.Type = Device.self) -> Device? { + public func retrievePeripheral(for uuid: UUID, as device: Device.Type = Device.self) async -> Device? { // TODO: this doesn't really need isolation? - guard let peripheral = bluetoothManager.assumeIsolated({ $0.retrievePeripheral(for: uuid) }) else { + guard let peripheral = await bluetoothManager.retrievePeripheral(for: uuid) else { return nil } - let closures = ClosureRegistrar() + let closures = ClosureRegistrar() // TODO: code duplication!! let device = ClosureRegistrar.$writeableView.withValue(closures) { Device() } @@ -434,6 +436,8 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo // TODO: we need to store them int he discoveredPeripherals to properly forward delegate methods!!! // TODO: however, can we store them weak? => use deinit of the Device object to clean it up once the peripheral looses reference? // TODO: we are also not hooking this thing up into the Bluetooth module system! + + // TODO: this will instantly deinit now!!! BluetoothDevice has weak-only references now? return device } diff --git a/Sources/SpeziBluetooth/Configuration/DiscoveryConfiguration.swift b/Sources/SpeziBluetooth/Configuration/DeviceDiscoveryDescriptor.swift similarity index 65% rename from Sources/SpeziBluetooth/Configuration/DiscoveryConfiguration.swift rename to Sources/SpeziBluetooth/Configuration/DeviceDiscoveryDescriptor.swift index 15ecadd2..6775a2fb 100644 --- a/Sources/SpeziBluetooth/Configuration/DiscoveryConfiguration.swift +++ b/Sources/SpeziBluetooth/Configuration/DeviceDiscoveryDescriptor.swift @@ -7,8 +7,10 @@ // -/// Describes how to discover a given ``BluetoothDevice``. -public struct DiscoveryConfiguration: Sendable { +/// Describes how to discover a given `BluetoothDevice`. +/// +/// Provides a strategy on how to discovery given ``BluetoothDevice`` device type. +public struct DeviceDiscoveryDescriptor: Sendable { let discoveryCriteria: DiscoveryCriteria let anyDeviceType: any BluetoothDevice.Type @@ -20,15 +22,15 @@ public struct DiscoveryConfiguration: Sendable { } -extension DiscoveryConfiguration: Identifiable { +extension DeviceDiscoveryDescriptor: Identifiable { public var id: DiscoveryCriteria { discoveryCriteria } } -extension DiscoveryConfiguration: Hashable { - public static func == (lhs: DiscoveryConfiguration, rhs: DiscoveryConfiguration) -> Bool { +extension DeviceDiscoveryDescriptor: Hashable { + public static func == (lhs: DeviceDiscoveryDescriptor, rhs: DeviceDiscoveryDescriptor) -> Bool { lhs.discoveryCriteria == rhs.discoveryCriteria } diff --git a/Sources/SpeziBluetooth/Configuration/Discover.swift b/Sources/SpeziBluetooth/Configuration/Discover.swift index 3c479dd3..ff183320 100644 --- a/Sources/SpeziBluetooth/Configuration/Discover.swift +++ b/Sources/SpeziBluetooth/Configuration/Discover.swift @@ -20,8 +20,8 @@ /// - ``init(_:by:)`` /// /// ### Semantic Model -/// - ``DiscoveryConfiguration`` -/// - ``DiscoveryConfigurationBuilder`` +/// - ``DeviceDiscoveryDescriptor`` +/// - ``DiscoveryDescriptorBuilder`` public struct Discover { let deviceType: Device.Type let discoveryCriteria: DiscoveryCriteria diff --git a/Sources/SpeziBluetooth/Configuration/DiscoveryConfigurationBuilder.swift b/Sources/SpeziBluetooth/Configuration/DiscoveryConfigurationBuilder.swift deleted file mode 100644 index 03bad5cb..00000000 --- a/Sources/SpeziBluetooth/Configuration/DiscoveryConfigurationBuilder.swift +++ /dev/null @@ -1,50 +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 -// - - -/// Building a set of ``Discover`` expressions to express what peripherals to discover. -@resultBuilder -public enum DiscoveryConfigurationBuilder { - /// Build a ``Discover`` expression to define a ``DiscoveryConfiguration``. - public static func buildExpression(_ expression: Discover) -> Set { - [DiscoveryConfiguration(discoveryCriteria: expression.discoveryCriteria, anyDeviceType: expression.deviceType)] - } - - /// Build a block of ``DiscoveryConfiguration``s. - public static func buildBlock(_ components: Set...) -> Set { - buildArray(components) - } - - /// Build the first block of an conditional ``DiscoveryConfiguration`` component. - public static func buildEither(first component: Set) -> Set { - component - } - - /// Build the second block of an conditional ``DiscoveryConfiguration`` component. - public static func buildEither(second component: Set) -> Set { - component - } - - /// Build an optional ``DiscoveryConfiguration`` component. - public static func buildOptional(_ component: Set?) -> Set { - // swiftlint:disable:previous discouraged_optional_collection - component ?? [] - } - - /// Build an ``DiscoveryConfiguration`` component with limited availability. - public static func buildLimitedAvailability(_ component: Set) -> Set { - component - } - - /// Build an array of ``DiscoveryConfiguration`` components. - public static func buildArray(_ components: [Set]) -> Set { - components.reduce(into: []) { result, component in - result.formUnion(component) - } - } -} diff --git a/Sources/SpeziBluetooth/Configuration/DiscoveryDescriptorBuilder.swift b/Sources/SpeziBluetooth/Configuration/DiscoveryDescriptorBuilder.swift new file mode 100644 index 00000000..3502d42a --- /dev/null +++ b/Sources/SpeziBluetooth/Configuration/DiscoveryDescriptorBuilder.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 +// + + +/// Building a set of ``Discover`` expressions to express what peripherals to discover. +@resultBuilder +public enum DiscoveryDescriptorBuilder { + /// Build a ``Discover`` expression to define a ``DeviceDiscoveryDescriptor``. + public static func buildExpression(_ expression: Discover) -> Set { + [DeviceDiscoveryDescriptor(discoveryCriteria: expression.discoveryCriteria, anyDeviceType: expression.deviceType)] + } + + /// Build a block of ``DeviceDiscoveryDescriptor``s. + public static func buildBlock(_ components: Set...) -> Set { + buildArray(components) + } + + /// Build the first block of an conditional ``DeviceDiscoveryDescriptor`` component. + public static func buildEither(first component: Set) -> Set { + component + } + + /// Build the second block of an conditional ``DeviceDiscoveryDescriptor`` component. + public static func buildEither(second component: Set) -> Set { + component + } + + /// Build an optional ``DeviceDiscoveryDescriptor`` component. + public static func buildOptional(_ component: Set?) -> Set { + // swiftlint:disable:previous discouraged_optional_collection + component ?? [] + } + + /// Build an ``DeviceDiscoveryDescriptor`` component with limited availability. + public static func buildLimitedAvailability(_ component: Set) -> Set { + component + } + + /// Build an array of ``DeviceDiscoveryDescriptor`` components. + public static func buildArray(_ components: [Set]) -> Set { + components.reduce(into: []) { result, component in + result.formUnion(component) + } + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift index 864a803c..28922e14 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift @@ -14,6 +14,10 @@ import OrderedCollections import OSLog +private class DiscoverySession { // TODO: do we wanna tackle that? +} + + /// Connect and communicate with Bluetooth devices. /// /// This module allows to connect and communicate with Bluetooth devices using modern programming paradigms. @@ -23,7 +27,7 @@ import OSLog /// /// To configure the Bluetooth Manager, you need to specify what devices you want to discover and what services and /// characteristics you are interested in. -/// To do so, provide a set of ``DeviceDescription``s upon initialization of the `BluetoothManager`. +/// To do so, provide a set of ``DiscoveryDescription``s upon initialization of the `BluetoothManager`. /// /// Below is a short code example to discover devices with a Heart Rate service. /// @@ -70,37 +74,13 @@ import OSLog /// - ``scanNearbyDevices(autoConnect:)`` /// - ``stopScanning()`` 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 serial executor for all Bluetooth related functionality. let bluetoothQueue: DispatchSerialQueue /// The device descriptions describing how nearby devices are discovered. - private let configuredDevices: Set + private let configuredDevices: Set // TODO: these are all considered for "DiscoverySession"! /// The minimum rssi that is required for a device to be discovered. private let minimumRSSI: Int /// The time interval after which an advertisement is considered stale and the device is removed. @@ -132,7 +112,7 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable } /// The list of discovered and connected bluetooth devices indexed by their identifier UUID. /// The state is isolated to our `dispatchQueue`. - var discoveredPeripherals: OrderedDictionary { + private(set) var discoveredPeripherals: OrderedDictionary { get { _storage.discoveredPeripherals } @@ -146,6 +126,16 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable } } + private(set) var retrievedPeripherals: OrderedDictionary> { + get { + _storage.retrievedPeripherals + } + // TODO: support modify? + set { + _storage.retrievedPeripherals = 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. @@ -198,12 +188,12 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable /// Initialize a new Bluetooth Manager with provided device description and optional configuration options. /// - Parameters: - /// - devices: The set of device description describing **how** to discover **what** to discover. + /// - discovery: The set of device description describing **how** to discover **what** to discover. /// - minimumRSSI: The minimum rssi a nearby peripheral must have to be considered nearby. /// - advertisementStaleInterval: The time interval after which a peripheral advertisement is considered stale /// if we don't hear back from the device. Minimum is 1 second. public init( - devices: Set, + discovery: Set, minimumRSSI: Int = Defaults.defaultMinimumRSSI, advertisementStaleInterval: TimeInterval = Defaults.defaultStaleTimeout ) { @@ -214,7 +204,7 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable self.bluetoothQueue = serialQueue - self.configuredDevices = devices + self.configuredDevices = discovery self.minimumRSSI = minimumRSSI self.advertisementStaleInterval = max(1, advertisementStaleInterval) @@ -258,7 +248,7 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable /// Scan for nearby bluetooth devices. /// - /// Scans on nearby devices based on the ``DeviceDescription`` provided in the initializer. + /// Scans on nearby devices based on the ``DiscoveryDescription`` provided in the initializer. /// 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:)`` @@ -276,7 +266,7 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable shouldBeScanning = true self.autoConnect = autoConnect - // TODO: docs: silently fails if bluetooth isn't powered on! + // using shouldBeScanning we listen for central to power on if it isn't already if case .poweredOn = centralManager.state { centralManager.scanForPeripherals( withServices: serviceDiscoveryIds, @@ -310,33 +300,56 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable } - public func retrievePeripheral(for uuid: UUID) -> BluetoothPeripheral? { - // TODO: only works if state is powered on + public func retrievePeripheral(for uuid: UUID, with description: DeviceDescription = DeviceDescription()) async -> BluetoothPeripheral? { + // TODO: only works if state is powered on => await poweredOn! + await awaitCentralPoweredOn() // TODO: how to notify other modules of that event? + guard case .poweredOn = centralManager.state else { logger.warning("Cannot retrieve peripheral with id \(uuid) while central is not powered on \(self.state)") + checkForCentralDeinit() return nil } - // TODO: does this need isolation? + if let peripheral = knownPeripheral(for: uuid) { + return peripheral // peripheral was already retrieved or was recently discovered + } + guard let peripheral = centralManager.retrievePeripherals(withIdentifiers: [uuid]).first else { checkForCentralDeinit() return nil } - // TODO: for every peripheral one needs to make sure that discarded is eventually set to true! let device = BluetoothPeripheral( manager: self, peripheral: peripheral, - advertisementData: .init(advertisementData: [:]), // TODO: init for empty? + configuration: description, + advertisementData: .init(advertisementData: [:]), // there was no advertisement rssi: 127 // value of 127 signifies unavailability of RSSI value ) - discoveredPeripherals.updateValue(device, forKey: peripheral.identifier) - // TODO: when to deinit central? + retrievedPeripherals.updateValue(WeakReference(device), forKey: peripheral.identifier) + return device } + func knownPeripheral(for uuid: UUID) -> BluetoothPeripheral? { + // TODO: first check for retrieved peripherals? WE MUST maintain uniqueness! + if let peripheral = discoveredPeripherals[uuid] { + return peripheral + } + + guard let reference = retrievedPeripherals[uuid] else { + return nil + } + + guard let peripheral = reference.value else { + retrievedPeripherals.removeValue(forKey: uuid) + return nil + } + return peripheral + } + func onChange(of keyPath: KeyPath, perform closure: @escaping (Value) -> Void) { _storage.onChange(of: keyPath, perform: closure) } @@ -370,7 +383,12 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable peripheral.assumeIsolated { device in device.handleDiscarded() } + + // Users might keep reference to Peripheral object. Therefore, we keep it as a weak reference so we can forward delegate calls. + retrievedPeripherals[id] = WeakReference(peripheral) + // TODO: when does Bluetooth Module uninject stuff? } + discoveredPeripherals.removeValue(forKey: id) if lastManuallyDisconnectedDevice == id { @@ -380,9 +398,25 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable checkForCentralDeinit() } + func handlePeripheralDeinit(id uuid: UUID) { + retrievedPeripherals.removeValue(forKey: uuid) // TODO: assert its the same instance? + + // TODO: also handle lastManuallyDisconnectedDevice?? + + checkForCentralDeinit() + } + + private func awaitCentralPoweredOn() async { + _ = centralManager + try? await Task.sleep(for: .seconds(2)) + + // TODO: somehow implement! + } + /// De-initializes the Bluetooth Central if we currently don't use it. private func checkForCentralDeinit() { - if !shouldBeScanning && discoveredPeripherals.isEmpty { + if !shouldBeScanning && discoveredPeripherals.isEmpty && retrievedPeripherals.isEmpty { + // TODO: check for retrieved peripherals to be empty? more than that? _centralManager.destroy() self.state = .unknown self.lastManuallyDisconnectedDevice = nil @@ -545,6 +579,7 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable state = .unknown _storage.discoveredPeripherals = [:] + // TODO: also reset retrieve peripherals? doesn't make a difference! centralDelegate = nil logger.debug("BluetoothManager destroyed") @@ -552,6 +587,40 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable } +extension BluetoothManager { + @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) + } + } + + var retrievedPeripherals: OrderedDictionary> = [:] { + didSet { + _$simpleRegistrar.triggerDidChange(for: \.retrievedPeripherals, on: self) + } + } + + // swiftlint:disable:next identifier_name + @ObservationIgnored var _$simpleRegistrar = ValueObservationRegistrar() + + init() {} + } +} + extension BluetoothManager: KVOReceiver { func observeChange(of keyPath: KeyPath, value: V) { switch keyPath { @@ -574,6 +643,7 @@ extension BluetoothManager: BluetoothScanner { // 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 + // TODO: also consider retrieved peripherals? partialResult = partialResult || (peripheral.unsafeState.state != .disconnected) } } @@ -677,9 +747,7 @@ extension BluetoothManager { // check if we already seen this device! - - let discoveredPeripherals = manager.discoveredPeripherals - if let device = discoveredPeripherals[peripheral.identifier] { + if let device = manager.knownPeripheral(for: peripheral.identifier) { device.assumeIsolated { device in device.markLastActivity() device.update(advertisement: data, rssi: rssi.intValue) @@ -696,9 +764,11 @@ extension BluetoothManager { logger.debug("Discovered peripheral \(peripheral.debugIdentifier) at \(rssi.intValue) dB (data: \(advertisementData))") + let descriptor = manager.configuredDevices.find(for: data, logger: logger) let device = BluetoothPeripheral( manager: manager, peripheral: peripheral, + configuration: descriptor?.device ?? DeviceDescription(), advertisementData: data, rssi: rssi.intValue ) @@ -723,8 +793,7 @@ extension BluetoothManager { Task { @SpeziBluetooth in await manager.isolated { manager in - let discoveredPeripherals = manager.discoveredPeripherals - guard let device = discoveredPeripherals[peripheral.identifier] else { + guard let device = manager.knownPeripheral(for: peripheral.identifier) else { logger.error("Received didConnect for unknown peripheral \(peripheral.debugIdentifier). Cancelling connection ...") manager.centralManager.cancelPeripheralConnection(peripheral) return @@ -732,7 +801,7 @@ extension BluetoothManager { logger.debug("Peripheral \(peripheral.debugIdentifier) connected.") device.assumeIsolated { device in - device.handleConnect(consider: manager.configuredDevices) + device.handleConnect() } } } @@ -748,8 +817,7 @@ extension BluetoothManager { Task { @SpeziBluetooth in await manager.isolated { manager in - let discoveredPeripherals = manager.discoveredPeripherals - guard let device = discoveredPeripherals[peripheral.identifier] else { + guard let device = manager.knownPeripheral(for: peripheral.identifier) else { logger.warning("Unknown peripheral \(peripheral.debugIdentifier) failed with error: \(String(describing: error))") manager.centralManager.cancelPeripheralConnection(peripheral) return @@ -777,8 +845,7 @@ extension BluetoothManager { Task { @SpeziBluetooth in await manager.isolated { manager in - let discoveredPeripherals = manager.discoveredPeripherals - guard let device = discoveredPeripherals[peripheral.identifier] else { + guard let device = manager.knownPeripheral(for: peripheral.identifier) else { logger.error("Received didDisconnect for unknown peripheral \(peripheral.debugIdentifier).") return } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift index f21b7677..0fcfe419 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift @@ -65,6 +65,7 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ private weak var manager: BluetoothManager? private let peripheral: CBPeripheral + private let configuration: DeviceDescription private let delegate: Delegate private let stateObserver: KVOStateObserver @@ -104,15 +105,14 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ } /// The name of the peripheral. + /// + /// Returns the name reported through the Generic Access Profile, otherwise falls back to the local name. nonisolated public var name: String? { _storage.name } - // TODO: change access/default of accessory name - // TODO: provide access to detect if peripheral is no longer considered discovered! - - // TODO: docs + /// The local name included in the advertisement. nonisolated public private(set) var localName: String? { get { _storage.localName @@ -178,9 +178,13 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ } } - // TODO: docs (and behavior!) + /// The last device activity. + /// + /// Returns the date of the last advertisement received from the device or the point in time the device disconnected. + /// Returns `now` if the device is currently connected. nonisolated public private(set) var lastActivity: Date { get { + // TODO: how does this react to LONG connecting states? generally we need to think of us treating connecting essentially as connected! if case .disconnected = peripheral.state { _storage.lastActivity } else { @@ -193,8 +197,12 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ } } - // TODO: docs! - nonisolated public private(set) var discarded: Bool { + /// Indicates that the peripheral was discarded. + /// + /// For devices that were found through nearby device search, this property indicates that the device was discarded + /// as it was considered stale and no new advertisement was received. This also happens when such a devices disconnects and no new + /// advertisement is received. + nonisolated public private(set) var discarded: Bool { // TODO: revise docs for retrieved peripherals get { _storage.discarded } @@ -207,6 +215,7 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ init( manager: BluetoothManager, peripheral: CBPeripheral, + configuration: DeviceDescription, advertisementData: AdvertisementData, rssi: Int ) { @@ -214,6 +223,7 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ self.manager = manager self.peripheral = peripheral + self.configuration = configuration self._storage = PeripheralStorage( peripheralName: peripheral.name, @@ -295,10 +305,8 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ _storage.onChange(of: keyPath, perform: closure) } - func handleConnect(consider configuredDevices: Set) { - // TODO: store discovery description within device? - if let description = configuredDevices.find(for: advertisementData, logger: logger), - let services = description.services { + func handleConnect() { + if let services = configuration.services { requestedCharacteristics = services.reduce(into: [CBUUID: Set?]()) { result, configuration in if let characteristics = configuration.characteristics { result[configuration.serviceId, default: []]?.formUnion(characteristics) @@ -353,6 +361,9 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ } func handleDiscarded() { + guard !discarded else { + return + } isolatedUpdate(of: \.discarded, true) } @@ -671,6 +682,23 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ var peripheral = self peripheral[keyPath: keyPath] = value } + + deinit { + if !_storage.discarded { // make sure peripheral gets discarded + _storage.update(discarded: true) // TODO: test that this works for retrieved peripherals! + } + + + guard let manager else { + self.logger.warning("Orphaned device \(self.id), \(self.name ?? "unnamed") was deinitialized") + return + } + + let id = id + Task { @SpeziBluetooth in + await manager.handlePeripheralDeinit(id: id) + } + } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift index 95fdd785..6ff45378 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift @@ -35,12 +35,4 @@ extension CharacteristicDescription: ExpressibleByStringLiteral { } -extension CharacteristicDescription: Hashable { - public static func == (lhs: CharacteristicDescription, rhs: CharacteristicDescription) -> Bool { - lhs.characteristicId == rhs.characteristicId - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(characteristicId) - } -} +extension CharacteristicDescription: Hashable {} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift index ce41c1bd..fbcb762c 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift @@ -13,44 +13,26 @@ 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: Sendable { - /// The criteria by which we identify a discovered device. - public let discoveryCriteria: DiscoveryCriteria +public struct DeviceDescription { /// The set of service configurations we expect from the device. /// /// This will be the list of services we are interested in and we try to discover. public let services: Set? // swiftlint:disable:this discouraged_optional_collection - /// Create a new discovery configuration for a certain type of device. - /// - Parameters: - /// - discoveryCriteria: The criteria by which we identify a discovered device. - /// - services: The set of service configurations we expect from the device. - /// Use `nil` to discover all services. - public init(discoverBy discoveryCriteria: DiscoveryCriteria, services: Set?) { + /// Create a new device description. + /// - Parameter services: The set of service descriptions specifying the expected services. + public init(services: Set? = nil) { // swiftlint:disable:previous discouraged_optional_collection - self.discoveryCriteria = discoveryCriteria self.services = services } } -extension DeviceDescription: Identifiable { - public var id: DiscoveryCriteria { - discoveryCriteria - } -} - +extension DeviceDescription: Sendable {} -extension DeviceDescription: Hashable { - public static func == (lhs: DeviceDescription, rhs: DeviceDescription) -> Bool { - lhs.discoveryCriteria == rhs.discoveryCriteria - } - public func hash(into hasher: inout Hasher) { - hasher.combine(discoveryCriteria) - } -} +extension DeviceDescription: Hashable {} extension Collection where Element: Identifiable, Element.ID == DiscoveryCriteria { diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift index ffdb458c..211bc562 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift @@ -8,48 +8,6 @@ @preconcurrency import CoreBluetooth -/// Bluetooth SIG-assigned Manufacturer Identifier. -/// -/// Refer to Assigned Numbers 7. Company Identifiers. -public struct ManufacturerIdentifier { - public let rawValue: UInt16 - - public init(rawValue: UInt16) { - self.rawValue = rawValue - } -} - - -extension ManufacturerIdentifier: Hashable, Sendable {} - - -extension ManufacturerIdentifier: RawRepresentable {} - - -extension ManufacturerIdentifier: ExpressibleByIntegerLiteral { - public init(integerLiteral value: UInt16) { - self.init(rawValue: value) - } -} - - -import ByteCoding -import NIOCore -extension ManufacturerIdentifier: ByteCodable { - public init?(from byteBuffer: inout ByteBuffer) { - guard let rawValue = UInt16(from: &byteBuffer) else { - return nil - } - self.init(rawValue: rawValue) - } - - public func encode(to byteBuffer: inout ByteBuffer) { - rawValue.encode(to: &byteBuffer) - } - - -} - /// The criteria by which we identify a discovered device. /// @@ -62,17 +20,16 @@ extension ManufacturerIdentifier: ByteCodable { public enum DiscoveryCriteria: Sendable { /// Identify a device by their advertised service. case advertisedService(_ uuid: CBUUID) - case accessory(company: ManufacturerIdentifier, name: String, service: CBUUID) - // TODO: "company" vs "manufacturer" - // TODO: name as a substring?; not local name! - // TODO: how to communicate the "advertised" service? + /// Identify a device by its manufacturer and advertised service. + case accessory(manufacturer: ManufacturerIdentifier, advertising: CBUUID) + // TODO: research accessory setup kit, is "name" required? - var discoveryId: CBUUID { // TODO: make that custom able? + var discoveryId: CBUUID { switch self { case let .advertisedService(uuid): uuid - case let .accessory(_, _, service): + case let .accessory(_, service): service } } @@ -82,18 +39,16 @@ public enum DiscoveryCriteria: Sendable { switch self { case let .advertisedService(uuid): return advertisementData.serviceUUIDs?.contains(uuid) ?? false - case let .accessory(company, _, service): + case let .accessory(manufacturer, service): guard let manufacturerData = advertisementData.manufacturerData, let identifier = ManufacturerIdentifier(data: manufacturerData) else { return false } - guard identifier == company else { + guard identifier == manufacturer else { return false } - // TODO: compare peripheral name! (substring?) - return advertisementData.serviceUUIDs?.contains(service) ?? false } @@ -101,10 +56,9 @@ public enum DiscoveryCriteria: Sendable { } -// TODO: similar overloads for accessory! extension DiscoveryCriteria { /// Identify a device by their advertised service. - /// - Parameter uuid: The Bluetooth ServiceId in string format. + /// - Parameter uuid: The Bluetooth service id in string format. /// - Returns: A ``DiscoveryCriteria/advertisedService(_:)-swift.enum.case`` criteria. public static func advertisedService(_ uuid: String) -> DiscoveryCriteria { .advertisedService(CBUUID(string: uuid)) @@ -119,13 +73,34 @@ extension DiscoveryCriteria { } +extension DiscoveryCriteria { + /// Identify a device by its manufacturer and advertised service. + /// - Parameters: + /// - manufacturer: The Bluetooth SIG-assigned manufacturer identifier. + /// - service: The Bluetooth service id in string format. + /// - Returns: A ``DiscoveryCriteria/accessory(manufacturer:advertising:)-swift.enum.case`` criteria. + public static func accessory(manufacturer: ManufacturerIdentifier, advertising service: String) -> DiscoveryCriteria { + .accessory(manufacturer: manufacturer, advertising: CBUUID(string: service)) + } + + /// Identify a device by its manufacturer and advertised service. + /// - Parameters: + /// - manufacturer: The Bluetooth SIG-assigned manufacturer identifier. + /// - service: The service type. + /// - Returns: A ``DiscoveryCriteria/accessory(manufacturer:advertising:)-swift.enum.case`` criteria. + public static func accessory(manufacturer: ManufacturerIdentifier, advertising service: Service) -> DiscoveryCriteria { + .accessory(manufacturer: manufacturer, advertising: Service.id) + } +} + + extension DiscoveryCriteria: Hashable, CustomStringConvertible { public var description: String { switch self { case let .advertisedService(uuid): ".advertisedService(\(uuid))" - case let .accessory(company, name, service): - "accessory(company: \(company), name: \(name), service: \(service))" + case let .accessory(manufacturer, service): + "accessory(company: \(manufacturer), advertised: \(service))" } } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryDescription.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryDescription.swift new file mode 100644 index 00000000..b02eb74e --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryDescription.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 +// + + +/// Description of a device discovery strategy. +/// +/// This type describes how to discover a device and what services and characteristics +/// to expect. +public struct DiscoveryDescription { + /// The criteria by which we identify a discovered device. + public let discoveryCriteria: DiscoveryCriteria + /// Description of the device. + /// + /// Provides guidance how and what to discover of the bluetooth peripheral. + public let device: DeviceDescription + + + /// Create a new discovery configuration for a given type of device. + /// - Parameters: + /// - discoveryCriteria: The criteria by which we identify a discovered device. + /// - services: The set of service configurations we expect from the device. + /// Use `nil` to discover all services. + public init(discoverBy discoveryCriteria: DiscoveryCriteria, device: DeviceDescription) { + // swiftlint:disable:previous discouraged_optional_collection + self.discoveryCriteria = discoveryCriteria + self.device = device + } +} + + +extension DiscoveryDescription: Sendable {} + + +extension DiscoveryDescription: Identifiable { + public var id: DiscoveryCriteria { + discoveryCriteria + } +} + + +extension DiscoveryDescription: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(discoveryCriteria) + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift index e794379b..ac745839 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift @@ -43,12 +43,4 @@ public struct ServiceDescription: Sendable { } -extension ServiceDescription: Hashable { - public static func == (lhs: ServiceDescription, rhs: ServiceDescription) -> Bool { - lhs.serviceId == rhs.serviceId - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(serviceId) - } -} +extension ServiceDescription: Hashable {} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/ManufacturerIdentifier.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/ManufacturerIdentifier.swift new file mode 100644 index 00000000..10b78eb9 --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/ManufacturerIdentifier.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 ByteCoding +import NIOCore + + +/// Bluetooth SIG-assigned Manufacturer Identifier. +/// +/// Refer to Assigned Numbers 7. Company Identifiers. +public struct ManufacturerIdentifier { + /// The raw manufacturer identifier. + public let rawValue: UInt16 + + /// Initialize a new manufacturer identifier form its code. + /// - Parameter code: The Bluetooth SIG-assigned Manufacturer Identifier. + public init(_ code: UInt16) { + self.init(rawValue: code) + } +} + + +extension ManufacturerIdentifier: Hashable, Sendable {} + + +extension ManufacturerIdentifier: RawRepresentable { + public init(rawValue: UInt16) { + self.rawValue = rawValue + } +} + + +extension ManufacturerIdentifier: ExpressibleByIntegerLiteral { + public init(integerLiteral value: UInt16) { + self.init(rawValue: value) + } +} + + +extension ManufacturerIdentifier: ByteCodable { + public init?(from byteBuffer: inout ByteBuffer) { + guard let rawValue = UInt16(from: &byteBuffer) else { + return nil + } + self.init(rawValue: rawValue) + } + + public func encode(to byteBuffer: inout ByteBuffer) { + rawValue.encode(to: &byteBuffer) + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift index e1d1d06d..f7230d59 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift @@ -62,7 +62,11 @@ final class PeripheralStorage: ValueObservable { } } - @ObservationIgnored var lastActivity: Date // TODO: this needs to be observed now ! + private(set) var lastActivity: Date { + didSet { + _$simpleRegistrar.triggerDidChange(for: \.lastActivity, on: self) + } + } // swiftlint:disable:next identifier_name @ObservationIgnored var _$simpleRegistrar = ValueObservationRegistrar() diff --git a/Sources/SpeziBluetooth/Model/Properties/DeviceAction.swift b/Sources/SpeziBluetooth/Model/Properties/DeviceAction.swift index d4064d6a..7704d83e 100644 --- a/Sources/SpeziBluetooth/Model/Properties/DeviceAction.swift +++ b/Sources/SpeziBluetooth/Model/Properties/DeviceAction.swift @@ -56,7 +56,7 @@ /// - ``DeviceActions`` @propertyWrapper public final class DeviceAction: @unchecked Sendable { - private var peripheral: BluetoothPeripheral? + private weak var peripheral: BluetoothPeripheral? /// Support injection of closures for testing support. private let _injectedClosure = Box(nil) diff --git a/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift b/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift index 6a81442b..dc6366b2 100644 --- a/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift +++ b/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift @@ -104,7 +104,7 @@ public final class DeviceState: @unchecked Sendable { """ ) } - return injection.peripheral[keyPath: keyPath] + return injection.value } diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift index 5ea55976..a8d15154 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift @@ -24,7 +24,7 @@ private protocol PrimitiveDecodableCharacteristic { actor CharacteristicPeripheralInjection: BluetoothActor { let bluetoothQueue: DispatchSerialQueue - let peripheral: BluetoothPeripheral + fileprivate let peripheral: BluetoothPeripheral let serviceId: CBUUID let characteristicId: CBUUID diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift index 454bf3a3..41707f07 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift @@ -12,11 +12,15 @@ import Foundation actor DeviceStatePeripheralInjection: BluetoothActor { let bluetoothQueue: DispatchSerialQueue - let peripheral: BluetoothPeripheral + private let peripheral: BluetoothPeripheral private let accessKeyPath: KeyPath private let observationKeyPath: KeyPath? private var onChangeClosure: ChangeClosureState + nonisolated var value: Value { + peripheral[keyPath: accessKeyPath] + } + init(peripheral: BluetoothPeripheral, keyPath: KeyPath, onChangeClosure: OnChangeClosure?) { self.bluetoothQueue = peripheral.bluetoothQueue @@ -98,6 +102,7 @@ actor DeviceStatePeripheralInjection: BluetoothActor { extension KeyPath where Root == BluetoothPeripheral { + // swiftlint:disable:next cyclomatic_complexity func storageEquivalent() -> KeyPath? { let anyKeyPath: AnyKeyPath? = switch self { case \.name: diff --git a/Sources/SpeziBluetooth/Model/SemanticModel/DeviceDescriptionParser.swift b/Sources/SpeziBluetooth/Model/SemanticModel/DeviceDescriptionParser.swift index ee0ff3ea..88b6923b 100644 --- a/Sources/SpeziBluetooth/Model/SemanticModel/DeviceDescriptionParser.swift +++ b/Sources/SpeziBluetooth/Model/SemanticModel/DeviceDescriptionParser.swift @@ -29,25 +29,26 @@ private struct ServiceDescriptionBuilder: DeviceVisitor { } -extension DiscoveryConfiguration { - func parseDeviceDescription() -> DeviceDescription { +extension DeviceDiscoveryDescriptor { + func parseDeviceDescription() -> DiscoveryDescription { let device = anyDeviceType.init() var builder = ServiceDescriptionBuilder() device.accept(&builder) - return DeviceDescription(discoverBy: discoveryCriteria, services: builder.configurations) + let deviceDescription = DeviceDescription(services: builder.configurations) + return DiscoveryDescription(discoverBy: discoveryCriteria, device: deviceDescription) } } -extension Set where Element == DiscoveryConfiguration { +extension Set where Element == DeviceDiscoveryDescriptor { var deviceTypes: [any BluetoothDevice.Type] { map { configuration in configuration.anyDeviceType } } - func parseDeviceDescription() -> Set { - Set(map { $0.parseDeviceDescription() }) + func parseDeviceDescription() -> Set { + Set(map { $0.parseDeviceDescription() }) } } diff --git a/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md b/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md index e489f72b..c36b08de 100644 --- a/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md +++ b/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md @@ -275,6 +275,7 @@ due to their async nature. ### Configuring Core Bluetooth +- ``DiscoveryDescription`` - ``DeviceDescription`` - ``ServiceDescription`` - ``CharacteristicDescription`` diff --git a/Sources/SpeziBluetooth/Utils/Reference.swift b/Sources/SpeziBluetooth/Utils/Reference.swift new file mode 100644 index 00000000..83d002a0 --- /dev/null +++ b/Sources/SpeziBluetooth/Utils/Reference.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 +// + + +struct WeakReference { + weak var value: Value? + + init(_ value: Value? = nil) { + self.value = value + } +} From 8a84ae23caab3ed2748d8e469fa9cbcee8725518 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 20 Jun 2024 09:50:11 +0200 Subject: [PATCH 11/58] Minor fixes --- Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift | 5 ++++- .../CoreBluetooth/Configuration/DiscoveryCriteria.swift | 6 +++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift index 28922e14..702bf55a 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift @@ -300,9 +300,12 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable } + // TODO: docs: weak reference semantics! public func retrievePeripheral(for uuid: UUID, with description: DeviceDescription = DeviceDescription()) async -> BluetoothPeripheral? { // TODO: only works if state is powered on => await poweredOn! - await awaitCentralPoweredOn() // TODO: how to notify other modules of that event? + + // TODO: how should API users generally await for poweredOn state? => Module Events? + await awaitCentralPoweredOn() guard case .poweredOn = centralManager.state else { logger.warning("Cannot retrieve peripheral with id \(uuid) while central is not powered on \(self.state)") diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift index 211bc562..60a8047d 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift @@ -68,7 +68,7 @@ extension DiscoveryCriteria { /// - Parameter service: The service type. /// - Returns: A ``DiscoveryCriteria/advertisedService(_:)-swift.enum.case`` criteria. public static func advertisedService(_ service: Service.Type) -> DiscoveryCriteria { - .advertisedService(Service.id) + .advertisedService(service.id) } } @@ -88,8 +88,8 @@ extension DiscoveryCriteria { /// - manufacturer: The Bluetooth SIG-assigned manufacturer identifier. /// - service: The service type. /// - Returns: A ``DiscoveryCriteria/accessory(manufacturer:advertising:)-swift.enum.case`` criteria. - public static func accessory(manufacturer: ManufacturerIdentifier, advertising service: Service) -> DiscoveryCriteria { - .accessory(manufacturer: manufacturer, advertising: Service.id) + public static func accessory(manufacturer: ManufacturerIdentifier, advertising service: Service.Type) -> DiscoveryCriteria { + .accessory(manufacturer: manufacturer, advertising: service.id) } } From 9a8ce60a698b46c90ec6c6ec5fd9282bacb98fc7 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 20 Jun 2024 09:53:13 +0200 Subject: [PATCH 12/58] Swiftlint fix --- .../CoreBluetooth/Configuration/DiscoveryCriteria.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift index 60a8047d..c5e45c3e 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift @@ -88,7 +88,10 @@ extension DiscoveryCriteria { /// - manufacturer: The Bluetooth SIG-assigned manufacturer identifier. /// - service: The service type. /// - Returns: A ``DiscoveryCriteria/accessory(manufacturer:advertising:)-swift.enum.case`` criteria. - public static func accessory(manufacturer: ManufacturerIdentifier, advertising service: Service.Type) -> DiscoveryCriteria { + public static func accessory( + manufacturer: ManufacturerIdentifier, + advertising service: Service.Type + ) -> DiscoveryCriteria { .accessory(manufacturer: manufacturer, advertising: service.id) } } From 09ab40546edc4da821b8fbc919e7794157e85fee Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 20 Jun 2024 10:12:33 +0200 Subject: [PATCH 13/58] Forward device description to retrieve peripheral --- Sources/SpeziBluetooth/Bluetooth.swift | 12 ++++++++++-- .../CoreBluetooth/BluetoothManager.swift | 2 +- .../DeviceDescriptionParser.swift | 19 +++++++++++++------ 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/Sources/SpeziBluetooth/Bluetooth.swift b/Sources/SpeziBluetooth/Bluetooth.swift index f3be5190..298f132b 100644 --- a/Sources/SpeziBluetooth/Bluetooth.swift +++ b/Sources/SpeziBluetooth/Bluetooth.swift @@ -276,7 +276,7 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo let discovery = ClosureRegistrar.$writeableView.withValue(.init()) { // we provide a closure registrar just to silence any out-of-band usage warnings! - configuration.parseDeviceDescription() // TODO: rename! + configuration.parseDiscoveryDescription() // TODO: rename! } let bluetoothManager = BluetoothManager( @@ -414,7 +414,14 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo public func retrievePeripheral(for uuid: UUID, as device: Device.Type = Device.self) async -> Device? { // TODO: this doesn't really need isolation? - guard let peripheral = await bluetoothManager.retrievePeripheral(for: uuid) else { + let configuration = ClosureRegistrar.$writeableView.withValue(.init()) { + // we provide a closure registrar just to silence any out-of-band usage warnings! + device.parseDeviceDescription() + + // TODO: we could just save the device? + } + + guard let peripheral = await bluetoothManager.retrievePeripheral(for: uuid, with: configuration) else { return nil } @@ -430,6 +437,7 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo observePeripheralState(of: uuid) // register \.state onChange closure + // TODO: spezi currently only allows one module of a type!!!! spezi.loadModule(device) handlePeripheralStateChange() diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift index 702bf55a..691b846c 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift @@ -301,7 +301,7 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable // TODO: docs: weak reference semantics! - public func retrievePeripheral(for uuid: UUID, with description: DeviceDescription = DeviceDescription()) async -> BluetoothPeripheral? { + public func retrievePeripheral(for uuid: UUID, with description: DeviceDescription) async -> BluetoothPeripheral? { // TODO: only works if state is powered on => await poweredOn! // TODO: how should API users generally await for poweredOn state? => Module Events? diff --git a/Sources/SpeziBluetooth/Model/SemanticModel/DeviceDescriptionParser.swift b/Sources/SpeziBluetooth/Model/SemanticModel/DeviceDescriptionParser.swift index 88b6923b..67912f0b 100644 --- a/Sources/SpeziBluetooth/Model/SemanticModel/DeviceDescriptionParser.swift +++ b/Sources/SpeziBluetooth/Model/SemanticModel/DeviceDescriptionParser.swift @@ -29,13 +29,20 @@ private struct ServiceDescriptionBuilder: DeviceVisitor { } -extension DeviceDiscoveryDescriptor { - func parseDeviceDescription() -> DiscoveryDescription { - let device = anyDeviceType.init() +extension BluetoothDevice { + static func parseDeviceDescription() -> DeviceDescription { + let device = Self() var builder = ServiceDescriptionBuilder() device.accept(&builder) - let deviceDescription = DeviceDescription(services: builder.configurations) + return DeviceDescription(services: builder.configurations) + } +} + + +extension DeviceDiscoveryDescriptor { + func parseDiscoveryDescription() -> DiscoveryDescription { + let deviceDescription = anyDeviceType.parseDeviceDescription() return DiscoveryDescription(discoverBy: discoveryCriteria, device: deviceDescription) } } @@ -48,7 +55,7 @@ extension Set where Element == DeviceDiscoveryDescriptor { } } - func parseDeviceDescription() -> Set { - Set(map { $0.parseDeviceDescription() }) + func parseDiscoveryDescription() -> Set { + Set(map { $0.parseDiscoveryDescription() }) } } From 6c41d14dc8a026214fee327ef02600d9d6e5ae38 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 21 Jun 2024 14:48:55 +0200 Subject: [PATCH 14/58] Make init public --- Sources/SpeziBluetooth/Bluetooth.swift | 1 + .../Model/AdvertisementData.swift | 2 +- .../Resources/Localizable.xcstrings | 20 ++++++++----------- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/Sources/SpeziBluetooth/Bluetooth.swift b/Sources/SpeziBluetooth/Bluetooth.swift index 298f132b..55525aed 100644 --- a/Sources/SpeziBluetooth/Bluetooth.swift +++ b/Sources/SpeziBluetooth/Bluetooth.swift @@ -412,6 +412,7 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo } + // TODO: rename to something like "makePersistentDevice" or "withPersistentDevice" => communicates that you need to manually close device! public func retrievePeripheral(for uuid: UUID, as device: Device.Type = Device.self) async -> Device? { // TODO: this doesn't really need isolation? let configuration = ClosureRegistrar.$writeableView.withValue(.init()) { diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/AdvertisementData.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/AdvertisementData.swift index 8cac8a14..4cd499e3 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/AdvertisementData.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/AdvertisementData.swift @@ -63,7 +63,7 @@ public struct AdvertisementData { /// Creates advertisement data based on CoreBluetooth's dictionary. /// - Parameter advertisementData: Core Bluetooth's advertisement data - init(advertisementData: [String: Any]) { + public init(_ advertisementData: [String: Any]) { self.rawAdvertisementData = advertisementData } } diff --git a/Sources/SpeziBluetooth/Resources/Localizable.xcstrings b/Sources/SpeziBluetooth/Resources/Localizable.xcstrings index a7631e47..fed94407 100644 --- a/Sources/SpeziBluetooth/Resources/Localizable.xcstrings +++ b/Sources/SpeziBluetooth/Resources/Localizable.xcstrings @@ -2,7 +2,14 @@ "sourceLanguage" : "en", "strings" : { "Control Point Error" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Control Point Error" + } + } + } }, "Control point request was sent to %@ on %@ but notifications weren't enabled for that characteristic." : { "localizations" : { @@ -54,17 +61,6 @@ } } }, - "The request characteristic was not present on the device." : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The request characteristic was not present on the device." - } - } - } - }, "The requested characteristic %@ on %@ was not present on the device." : { "localizations" : { "en" : { From 0e2118c9e95c2ecd65d64641140c0d399b25562f Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 21 Jun 2024 14:52:23 +0200 Subject: [PATCH 15/58] Fix compilation --- Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift index 691b846c..012feb03 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift @@ -327,7 +327,7 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable manager: self, peripheral: peripheral, configuration: description, - advertisementData: .init(advertisementData: [:]), // there was no advertisement + advertisementData: .init([:]), // there was no advertisement rssi: 127 // value of 127 signifies unavailability of RSSI value ) @@ -746,7 +746,7 @@ extension BluetoothManager { return // logging this would just be to verbose, so we don't. } - let data = AdvertisementData(advertisementData: advertisementData) + let data = AdvertisementData(advertisementData) // check if we already seen this device! From 118b73080e8b006edcbc039b40764e8c9a91269b Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 21 Jun 2024 14:54:43 +0200 Subject: [PATCH 16/58] Fix --- Sources/SpeziBluetooth/Model/Properties/DeviceState.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift b/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift index dc6366b2..a6404d67 100644 --- a/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift +++ b/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift @@ -159,7 +159,7 @@ extension DeviceState { case \.state: PeripheralState.disconnected case \.advertisementData: - AdvertisementData(advertisementData: [:]) + AdvertisementData([:]) case \.rssi: Int(UInt8.max) case \.services: From 61cc66899ed69a41f858e0faa524caece7801675 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 21 Jun 2024 16:10:01 +0200 Subject: [PATCH 17/58] Make Bluetooth module configuration public. --- Sources/SpeziBluetooth/Bluetooth.swift | 12 ++++++++---- .../Configuration/DeviceDiscoveryDescriptor.swift | 15 ++++++++++----- .../DiscoveryDescriptorBuilder.swift | 2 +- .../SemanticModel/DeviceDescriptionParser.swift | 4 ++-- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/Sources/SpeziBluetooth/Bluetooth.swift b/Sources/SpeziBluetooth/Bluetooth.swift index 55525aed..f95a3242 100644 --- a/Sources/SpeziBluetooth/Bluetooth.swift +++ b/Sources/SpeziBluetooth/Bluetooth.swift @@ -206,7 +206,11 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo let bluetoothQueue: DispatchSerialQueue private let bluetoothManager: BluetoothManager - private let deviceConfigurations: Set + + /// The Bluetooth device configuration. + /// + /// Set of configured ``BluetoothDevice`` with their corresponding ``DiscoveryCriteria``. + public let configuration: Set private let _storage = Storage() @@ -287,7 +291,7 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo self.bluetoothQueue = bluetoothManager.bluetoothQueue self.bluetoothManager = bluetoothManager - self.deviceConfigurations = configuration + self.configuration = configuration self._devicesInjector = Modifier(wrappedValue: ConnectedDevicesEnvironmentModifier(configuredDeviceTypes: deviceTypes)) Task { @@ -353,7 +357,7 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo // add devices for new keys for (uuid, peripheral) in discoveredDevices where nearbyDevices[uuid] == nil { let advertisementData = peripheral.advertisementData - guard let configuration = deviceConfigurations.find(for: advertisementData, logger: logger) else { + guard let configuration = configuration.find(for: advertisementData, logger: logger) else { logger.warning("Ignoring peripheral \(peripheral.debugDescription) that cannot be mapped to a device class.") continue } @@ -361,7 +365,7 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo let closures = ClosureRegistrar() let device = ClosureRegistrar.$writeableView.withValue(closures) { - configuration.anyDeviceType.init() + configuration.deviceType.init() } ClosureRegistrar.$readableView.withValue(closures) { device.inject(peripheral: peripheral) diff --git a/Sources/SpeziBluetooth/Configuration/DeviceDiscoveryDescriptor.swift b/Sources/SpeziBluetooth/Configuration/DeviceDiscoveryDescriptor.swift index 6775a2fb..f3f9a846 100644 --- a/Sources/SpeziBluetooth/Configuration/DeviceDiscoveryDescriptor.swift +++ b/Sources/SpeziBluetooth/Configuration/DeviceDiscoveryDescriptor.swift @@ -10,18 +10,23 @@ /// Describes how to discover a given `BluetoothDevice`. /// /// Provides a strategy on how to discovery given ``BluetoothDevice`` device type. -public struct DeviceDiscoveryDescriptor: Sendable { - let discoveryCriteria: DiscoveryCriteria - let anyDeviceType: any BluetoothDevice.Type +public struct DeviceDiscoveryDescriptor { + /// The criteria by which we identify a discovered device. + public let discoveryCriteria: DiscoveryCriteria + /// The associated device type. + public let deviceType: any BluetoothDevice.Type - init(discoveryCriteria: DiscoveryCriteria, anyDeviceType: any BluetoothDevice.Type) { + init(discoveryCriteria: DiscoveryCriteria, deviceType: any BluetoothDevice.Type) { self.discoveryCriteria = discoveryCriteria - self.anyDeviceType = anyDeviceType + self.deviceType = deviceType } } +extension DeviceDiscoveryDescriptor: Sendable {} + + extension DeviceDiscoveryDescriptor: Identifiable { public var id: DiscoveryCriteria { discoveryCriteria diff --git a/Sources/SpeziBluetooth/Configuration/DiscoveryDescriptorBuilder.swift b/Sources/SpeziBluetooth/Configuration/DiscoveryDescriptorBuilder.swift index 3502d42a..40f7c83d 100644 --- a/Sources/SpeziBluetooth/Configuration/DiscoveryDescriptorBuilder.swift +++ b/Sources/SpeziBluetooth/Configuration/DiscoveryDescriptorBuilder.swift @@ -12,7 +12,7 @@ public enum DiscoveryDescriptorBuilder { /// Build a ``Discover`` expression to define a ``DeviceDiscoveryDescriptor``. public static func buildExpression(_ expression: Discover) -> Set { - [DeviceDiscoveryDescriptor(discoveryCriteria: expression.discoveryCriteria, anyDeviceType: expression.deviceType)] + [DeviceDiscoveryDescriptor(discoveryCriteria: expression.discoveryCriteria, deviceType: expression.deviceType)] } /// Build a block of ``DeviceDiscoveryDescriptor``s. diff --git a/Sources/SpeziBluetooth/Model/SemanticModel/DeviceDescriptionParser.swift b/Sources/SpeziBluetooth/Model/SemanticModel/DeviceDescriptionParser.swift index 67912f0b..fa062ce8 100644 --- a/Sources/SpeziBluetooth/Model/SemanticModel/DeviceDescriptionParser.swift +++ b/Sources/SpeziBluetooth/Model/SemanticModel/DeviceDescriptionParser.swift @@ -42,7 +42,7 @@ extension BluetoothDevice { extension DeviceDiscoveryDescriptor { func parseDiscoveryDescription() -> DiscoveryDescription { - let deviceDescription = anyDeviceType.parseDeviceDescription() + let deviceDescription = deviceType.parseDeviceDescription() return DiscoveryDescription(discoverBy: discoveryCriteria, device: deviceDescription) } } @@ -51,7 +51,7 @@ extension DeviceDiscoveryDescriptor { extension Set where Element == DeviceDiscoveryDescriptor { var deviceTypes: [any BluetoothDevice.Type] { map { configuration in - configuration.anyDeviceType + configuration.deviceType } } From 9d46389fdf57426b059d493c7d549baa3eac4de0 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 21 Jun 2024 16:26:19 +0200 Subject: [PATCH 18/58] Make it explicitly nonisolated --- Sources/SpeziBluetooth/Bluetooth.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SpeziBluetooth/Bluetooth.swift b/Sources/SpeziBluetooth/Bluetooth.swift index f95a3242..3fccef84 100644 --- a/Sources/SpeziBluetooth/Bluetooth.swift +++ b/Sources/SpeziBluetooth/Bluetooth.swift @@ -210,7 +210,7 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo /// The Bluetooth device configuration. /// /// Set of configured ``BluetoothDevice`` with their corresponding ``DiscoveryCriteria``. - public let configuration: Set + public nonisolated let configuration: Set private let _storage = Storage() From 480adfda1a68ff6bee84535c24361ee723e9c123 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 21 Jun 2024 22:15:25 +0200 Subject: [PATCH 19/58] Some progress --- Package.swift | 1 + .../CoreBluetooth/BluetoothManager.swift | 17 +++++++++++------ .../CoreBluetooth/BluetoothPeripheral.swift | 7 +++---- .../Configuration/DiscoveryCriteria.swift | 1 - .../Configuration/DiscoveryDescription.swift | 1 - 5 files changed, 15 insertions(+), 12 deletions(-) diff --git a/Package.swift b/Package.swift index 130dd2ba..352b35fe 100644 --- a/Package.swift +++ b/Package.swift @@ -22,6 +22,7 @@ let package = Package( .macOS(.v14) ], products: [ + // TODO: re-evaluate naming: SpeziBluetoothUI, SpeziBluetoothServices .library(name: "BluetoothServices", targets: ["BluetoothServices"]), .library(name: "BluetoothViews", targets: ["BluetoothViews"]), .library(name: "SpeziBluetooth", targets: ["SpeziBluetooth"]) diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift index 012feb03..2e27b1ab 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift @@ -116,11 +116,9 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable get { _storage.discoveredPeripherals } - /* TODO: reenable! _modify { yield &_storage.discoveredPeripherals } - */ set { _storage.discoveredPeripherals = newValue } @@ -130,7 +128,9 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable get { _storage.retrievedPeripherals } - // TODO: support modify? + _modify { + yield &_storage.retrievedPeripherals + } set { _storage.retrievedPeripherals = newValue } @@ -582,7 +582,7 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable state = .unknown _storage.discoveredPeripherals = [:] - // TODO: also reset retrieve peripherals? doesn't make a difference! + _storage.retrievedPeripherals = [:] centralDelegate = nil logger.debug("BluetoothManager destroyed") @@ -646,9 +646,14 @@ extension BluetoothManager: BluetoothScanner { // 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 - // TODO: also consider retrieved peripherals? partialResult = partialResult || (peripheral.unsafeState.state != .disconnected) - } + } || _storage.retrievedPeripherals.values.reduce(into: false, { partialResult, reference in + // swiftlint:disable:previous reduce_boolean + // TODO: observation of weak reference, does that work? + if let peripheral = reference.value { + partialResult = partialResult || (peripheral.unsafeState.state != .disconnected) + } + }) } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift index 0fcfe419..7f7c3c63 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift @@ -184,12 +184,11 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ /// Returns `now` if the device is currently connected. nonisolated public private(set) var lastActivity: Date { get { - // TODO: how does this react to LONG connecting states? generally we need to think of us treating connecting essentially as connected! - if case .disconnected = peripheral.state { - _storage.lastActivity - } else { + if case .connected = state { // we are currently connected or connecting/disconnecting, therefore last activity is defined as "now" .now + } else { + _storage.lastActivity } } set { diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift index c5e45c3e..81d7512d 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift @@ -22,7 +22,6 @@ public enum DiscoveryCriteria: Sendable { case advertisedService(_ uuid: CBUUID) /// Identify a device by its manufacturer and advertised service. case accessory(manufacturer: ManufacturerIdentifier, advertising: CBUUID) - // TODO: research accessory setup kit, is "name" required? var discoveryId: CBUUID { diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryDescription.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryDescription.swift index b02eb74e..5db3be03 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryDescription.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryDescription.swift @@ -26,7 +26,6 @@ public struct DiscoveryDescription { /// - services: The set of service configurations we expect from the device. /// Use `nil` to discover all services. public init(discoverBy discoveryCriteria: DiscoveryCriteria, device: DeviceDescription) { - // swiftlint:disable:previous discouraged_optional_collection self.discoveryCriteria = discoveryCriteria self.device = device } From 0b8b45f5d1fc68b3c8d12a4852d13d02bf8171fe Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 21 Jun 2024 22:52:21 +0200 Subject: [PATCH 20/58] Move BluetoothViews to SpeziDevicesUI, rename BluetoothServices to SpeziBluetoothServices --- Package.swift | 18 +- .../BluetoothViews/BluetoothStateHint.swift | 129 -------------- .../BluetoothViews.docc/BluetoothViews.md | 31 ---- .../LoadingSectionHeaderView.swift | 60 ------- ...eripheral+GenericBluetoothPeripheral.swift | 16 -- .../Model/GenericBluetoothPeripheral.swift | 46 ----- .../Model/MockBluetoothDevice.swift | 23 --- Sources/BluetoothViews/NearbyDeviceRow.swift | 159 ----------------- .../Resources/Localizable.xcstrings | 166 ------------------ .../Resources/Localizable.xcstrings.license | 5 - .../BluetoothServices.md | 0 .../BluetoothServices.docc/Characteristics.md | 0 .../BluetoothServices.docc/Services.md | 0 .../BloodPressureFeature.swift | 0 .../BloodPressureMeasurement.swift | 0 .../IntermediateCuffPressure.swift | 0 .../Characteristics/MeasurementInterval.swift | 0 .../Characteristics/PnPID.swift | 0 ...teristicAccessor+GenericRecordAccess.swift | 0 .../RecordAccessFilterCriteria.swift | 0 .../RecordAccessFilterType.swift | 0 .../RecordAccessGeneralResponse.swift | 0 .../RecordAccessGenericOperand.swift | 0 .../RecordAccessControlPoint+Operations.swift | 0 .../RecordAccessControlPoint.swift | 0 .../RecordAccess/RecordAccessOpCode.swift | 0 .../RecordAccess/RecordAccessOperand.swift | 0 .../RecordAccessOperationContent.swift | 0 .../RecordAccess/RecordAccessOperator.swift | 0 .../RecordAccessResponseCode.swift | 0 .../RecordAccessResponseFormatError.swift | 0 .../TemperatureMeasurement.swift | 0 .../Characteristics/TemperatureType.swift | 0 .../Characteristics/Time/CurrentTime.swift | 0 .../Characteristics/Time/DateTime.swift | 0 .../Characteristics/Time/DayDateTime.swift | 0 .../Characteristics/Time/DayOfWeek.swift | 0 .../Characteristics/Time/ExactTime256.swift | 0 .../Characteristics/WeightMeasurement.swift | 0 .../Characteristics/WeightScaleFeature.swift | 0 .../Services/BatteryService.swift | 0 .../Services/BloodPressureService.swift | 0 .../Services/CurrentTimeService.swift | 0 .../Services/DeviceInformationService.swift | 0 .../Services/HealthThermometerService.swift | 0 .../Services/WeightScaleService.swift | 0 .../CBUUID+Characteristics.swift | 0 .../TestingSupport/EventLog.swift | 0 .../TestingSupport/TestService.swift | 0 Sources/TestPeripheral/TestPeripheral.swift | 4 +- Sources/TestPeripheral/TestService.swift | 4 +- .../BloodPressureTests.swift | 4 +- .../BluetoothServicesTests.swift | 4 +- .../CurrentTimeTests.swift | 4 +- .../DeviceInformationTests.swift | 4 +- .../HealthThermometerTests.swift | 4 +- .../RecordAccessControlPointTests.swift | 4 +- .../WeightScaleTests.swift | 4 +- 58 files changed, 22 insertions(+), 667 deletions(-) delete mode 100644 Sources/BluetoothViews/BluetoothStateHint.swift delete mode 100644 Sources/BluetoothViews/BluetoothViews.docc/BluetoothViews.md delete mode 100644 Sources/BluetoothViews/LoadingSectionHeaderView.swift delete mode 100644 Sources/BluetoothViews/Model/BluetoothPeripheral+GenericBluetoothPeripheral.swift delete mode 100644 Sources/BluetoothViews/Model/GenericBluetoothPeripheral.swift delete mode 100644 Sources/BluetoothViews/Model/MockBluetoothDevice.swift delete mode 100644 Sources/BluetoothViews/NearbyDeviceRow.swift delete mode 100644 Sources/BluetoothViews/Resources/Localizable.xcstrings delete mode 100644 Sources/BluetoothViews/Resources/Localizable.xcstrings.license rename Sources/{BluetoothServices => SpeziBluetoothServices}/BluetoothServices.docc/BluetoothServices.md (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/BluetoothServices.docc/Characteristics.md (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/BluetoothServices.docc/Services.md (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Characteristics/BloodPressureFeature.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Characteristics/BloodPressureMeasurement.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Characteristics/IntermediateCuffPressure.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Characteristics/MeasurementInterval.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Characteristics/PnPID.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Characteristics/RecordAccess/GenericOperand/CharacteristicAccessor+GenericRecordAccess.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Characteristics/RecordAccess/GenericOperand/RecordAccessFilterCriteria.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Characteristics/RecordAccess/GenericOperand/RecordAccessFilterType.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Characteristics/RecordAccess/GenericOperand/RecordAccessGeneralResponse.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Characteristics/RecordAccess/GenericOperand/RecordAccessGenericOperand.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Characteristics/RecordAccess/RecordAccessControlPoint+Operations.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Characteristics/RecordAccess/RecordAccessControlPoint.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Characteristics/RecordAccess/RecordAccessOpCode.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Characteristics/RecordAccess/RecordAccessOperand.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Characteristics/RecordAccess/RecordAccessOperationContent.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Characteristics/RecordAccess/RecordAccessOperator.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Characteristics/RecordAccess/RecordAccessResponseCode.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Characteristics/RecordAccess/RecordAccessResponseFormatError.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Characteristics/TemperatureMeasurement.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Characteristics/TemperatureType.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Characteristics/Time/CurrentTime.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Characteristics/Time/DateTime.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Characteristics/Time/DayDateTime.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Characteristics/Time/DayOfWeek.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Characteristics/Time/ExactTime256.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Characteristics/WeightMeasurement.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Characteristics/WeightScaleFeature.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Services/BatteryService.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Services/BloodPressureService.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Services/CurrentTimeService.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Services/DeviceInformationService.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Services/HealthThermometerService.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/Services/WeightScaleService.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/TestingSupport/CBUUID+Characteristics.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/TestingSupport/EventLog.swift (100%) rename Sources/{BluetoothServices => SpeziBluetoothServices}/TestingSupport/TestService.swift (100%) diff --git a/Package.swift b/Package.swift index 352b35fe..d302f45a 100644 --- a/Package.swift +++ b/Package.swift @@ -22,9 +22,7 @@ let package = Package( .macOS(.v14) ], products: [ - // TODO: re-evaluate naming: SpeziBluetoothUI, SpeziBluetoothServices - .library(name: "BluetoothServices", targets: ["BluetoothServices"]), - .library(name: "BluetoothViews", targets: ["BluetoothViews"]), + .library(name: "SpeziBluetoothServices", targets: ["SpeziBluetoothServices"]), .library(name: "SpeziBluetooth", targets: ["SpeziBluetooth"]) ], dependencies: [ @@ -52,7 +50,7 @@ let package = Package( plugins: [swiftLintPlugin] ), .target( - name: "BluetoothServices", + name: "SpeziBluetoothServices", dependencies: [ .target(name: "SpeziBluetooth"), .product(name: "ByteCoding", package: "SpeziNetworking"), @@ -60,19 +58,11 @@ let package = Package( ], plugins: [swiftLintPlugin] ), - .target( - name: "BluetoothViews", - dependencies: [ - .target(name: "SpeziBluetooth"), - .product(name: "SpeziViews", package: "SpeziViews") - ], - plugins: [swiftLintPlugin] - ), .executableTarget( name: "TestPeripheral", dependencies: [ .target(name: "SpeziBluetooth"), - .target(name: "BluetoothServices"), + .target(name: "SpeziBluetoothServices"), .product(name: "ByteCoding", package: "SpeziNetworking") ], plugins: [swiftLintPlugin] @@ -80,7 +70,7 @@ let package = Package( .testTarget( name: "BluetoothServicesTests", dependencies: [ - .target(name: "BluetoothServices"), + .target(name: "SpeziBluetoothServices"), .target(name: "SpeziBluetooth"), .product(name: "XCTByteCoding", package: "SpeziNetworking"), .product(name: "NIO", package: "swift-nio") diff --git a/Sources/BluetoothViews/BluetoothStateHint.swift b/Sources/BluetoothViews/BluetoothStateHint.swift deleted file mode 100644 index 7b0a7966..00000000 --- a/Sources/BluetoothViews/BluetoothStateHint.swift +++ /dev/null @@ -1,129 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import SpeziBluetooth -import 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 deleted file mode 100644 index b1092718..00000000 --- a/Sources/BluetoothViews/BluetoothViews.docc/BluetoothViews.md +++ /dev/null @@ -1,31 +0,0 @@ -# ``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 deleted file mode 100644 index 56dc0ee4..00000000 --- a/Sources/BluetoothViews/LoadingSectionHeaderView.swift +++ /dev/null @@ -1,60 +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 - - -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 deleted file mode 100644 index 60182077..00000000 --- a/Sources/BluetoothViews/Model/BluetoothPeripheral+GenericBluetoothPeripheral.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import SpeziBluetooth - - -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 deleted file mode 100644 index f2aab657..00000000 --- a/Sources/BluetoothViews/Model/GenericBluetoothPeripheral.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import SpeziBluetooth - - -/// 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 deleted file mode 100644 index 37a14c42..00000000 --- a/Sources/BluetoothViews/Model/MockBluetoothDevice.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import SpeziBluetooth - - -/// 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 deleted file mode 100644 index 4f59093b..00000000 --- a/Sources/BluetoothViews/NearbyDeviceRow.swift +++ /dev/null @@ -1,159 +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 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 deleted file mode 100644 index 91d7dd74..00000000 --- a/Sources/BluetoothViews/Resources/Localizable.xcstrings +++ /dev/null @@ -1,166 +0,0 @@ -{ - "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 deleted file mode 100644 index 28f53d0d..00000000 --- a/Sources/BluetoothViews/Resources/Localizable.xcstrings.license +++ /dev/null @@ -1,5 +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 \ No newline at end of file diff --git a/Sources/BluetoothServices/BluetoothServices.docc/BluetoothServices.md b/Sources/SpeziBluetoothServices/BluetoothServices.docc/BluetoothServices.md similarity index 100% rename from Sources/BluetoothServices/BluetoothServices.docc/BluetoothServices.md rename to Sources/SpeziBluetoothServices/BluetoothServices.docc/BluetoothServices.md diff --git a/Sources/BluetoothServices/BluetoothServices.docc/Characteristics.md b/Sources/SpeziBluetoothServices/BluetoothServices.docc/Characteristics.md similarity index 100% rename from Sources/BluetoothServices/BluetoothServices.docc/Characteristics.md rename to Sources/SpeziBluetoothServices/BluetoothServices.docc/Characteristics.md diff --git a/Sources/BluetoothServices/BluetoothServices.docc/Services.md b/Sources/SpeziBluetoothServices/BluetoothServices.docc/Services.md similarity index 100% rename from Sources/BluetoothServices/BluetoothServices.docc/Services.md rename to Sources/SpeziBluetoothServices/BluetoothServices.docc/Services.md diff --git a/Sources/BluetoothServices/Characteristics/BloodPressureFeature.swift b/Sources/SpeziBluetoothServices/Characteristics/BloodPressureFeature.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/BloodPressureFeature.swift rename to Sources/SpeziBluetoothServices/Characteristics/BloodPressureFeature.swift diff --git a/Sources/BluetoothServices/Characteristics/BloodPressureMeasurement.swift b/Sources/SpeziBluetoothServices/Characteristics/BloodPressureMeasurement.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/BloodPressureMeasurement.swift rename to Sources/SpeziBluetoothServices/Characteristics/BloodPressureMeasurement.swift diff --git a/Sources/BluetoothServices/Characteristics/IntermediateCuffPressure.swift b/Sources/SpeziBluetoothServices/Characteristics/IntermediateCuffPressure.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/IntermediateCuffPressure.swift rename to Sources/SpeziBluetoothServices/Characteristics/IntermediateCuffPressure.swift diff --git a/Sources/BluetoothServices/Characteristics/MeasurementInterval.swift b/Sources/SpeziBluetoothServices/Characteristics/MeasurementInterval.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/MeasurementInterval.swift rename to Sources/SpeziBluetoothServices/Characteristics/MeasurementInterval.swift diff --git a/Sources/BluetoothServices/Characteristics/PnPID.swift b/Sources/SpeziBluetoothServices/Characteristics/PnPID.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/PnPID.swift rename to Sources/SpeziBluetoothServices/Characteristics/PnPID.swift diff --git a/Sources/BluetoothServices/Characteristics/RecordAccess/GenericOperand/CharacteristicAccessor+GenericRecordAccess.swift b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/GenericOperand/CharacteristicAccessor+GenericRecordAccess.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/RecordAccess/GenericOperand/CharacteristicAccessor+GenericRecordAccess.swift rename to Sources/SpeziBluetoothServices/Characteristics/RecordAccess/GenericOperand/CharacteristicAccessor+GenericRecordAccess.swift diff --git a/Sources/BluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessFilterCriteria.swift b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessFilterCriteria.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessFilterCriteria.swift rename to Sources/SpeziBluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessFilterCriteria.swift diff --git a/Sources/BluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessFilterType.swift b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessFilterType.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessFilterType.swift rename to Sources/SpeziBluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessFilterType.swift diff --git a/Sources/BluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessGeneralResponse.swift b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessGeneralResponse.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessGeneralResponse.swift rename to Sources/SpeziBluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessGeneralResponse.swift diff --git a/Sources/BluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessGenericOperand.swift b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessGenericOperand.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessGenericOperand.swift rename to Sources/SpeziBluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessGenericOperand.swift diff --git a/Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessControlPoint+Operations.swift b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessControlPoint+Operations.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessControlPoint+Operations.swift rename to Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessControlPoint+Operations.swift diff --git a/Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessControlPoint.swift b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessControlPoint.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessControlPoint.swift rename to Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessControlPoint.swift diff --git a/Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessOpCode.swift b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessOpCode.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessOpCode.swift rename to Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessOpCode.swift diff --git a/Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessOperand.swift b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessOperand.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessOperand.swift rename to Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessOperand.swift diff --git a/Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessOperationContent.swift b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessOperationContent.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessOperationContent.swift rename to Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessOperationContent.swift diff --git a/Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessOperator.swift b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessOperator.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessOperator.swift rename to Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessOperator.swift diff --git a/Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessResponseCode.swift b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessResponseCode.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessResponseCode.swift rename to Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessResponseCode.swift diff --git a/Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessResponseFormatError.swift b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessResponseFormatError.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/RecordAccess/RecordAccessResponseFormatError.swift rename to Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessResponseFormatError.swift diff --git a/Sources/BluetoothServices/Characteristics/TemperatureMeasurement.swift b/Sources/SpeziBluetoothServices/Characteristics/TemperatureMeasurement.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/TemperatureMeasurement.swift rename to Sources/SpeziBluetoothServices/Characteristics/TemperatureMeasurement.swift diff --git a/Sources/BluetoothServices/Characteristics/TemperatureType.swift b/Sources/SpeziBluetoothServices/Characteristics/TemperatureType.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/TemperatureType.swift rename to Sources/SpeziBluetoothServices/Characteristics/TemperatureType.swift diff --git a/Sources/BluetoothServices/Characteristics/Time/CurrentTime.swift b/Sources/SpeziBluetoothServices/Characteristics/Time/CurrentTime.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/Time/CurrentTime.swift rename to Sources/SpeziBluetoothServices/Characteristics/Time/CurrentTime.swift diff --git a/Sources/BluetoothServices/Characteristics/Time/DateTime.swift b/Sources/SpeziBluetoothServices/Characteristics/Time/DateTime.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/Time/DateTime.swift rename to Sources/SpeziBluetoothServices/Characteristics/Time/DateTime.swift diff --git a/Sources/BluetoothServices/Characteristics/Time/DayDateTime.swift b/Sources/SpeziBluetoothServices/Characteristics/Time/DayDateTime.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/Time/DayDateTime.swift rename to Sources/SpeziBluetoothServices/Characteristics/Time/DayDateTime.swift diff --git a/Sources/BluetoothServices/Characteristics/Time/DayOfWeek.swift b/Sources/SpeziBluetoothServices/Characteristics/Time/DayOfWeek.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/Time/DayOfWeek.swift rename to Sources/SpeziBluetoothServices/Characteristics/Time/DayOfWeek.swift diff --git a/Sources/BluetoothServices/Characteristics/Time/ExactTime256.swift b/Sources/SpeziBluetoothServices/Characteristics/Time/ExactTime256.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/Time/ExactTime256.swift rename to Sources/SpeziBluetoothServices/Characteristics/Time/ExactTime256.swift diff --git a/Sources/BluetoothServices/Characteristics/WeightMeasurement.swift b/Sources/SpeziBluetoothServices/Characteristics/WeightMeasurement.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/WeightMeasurement.swift rename to Sources/SpeziBluetoothServices/Characteristics/WeightMeasurement.swift diff --git a/Sources/BluetoothServices/Characteristics/WeightScaleFeature.swift b/Sources/SpeziBluetoothServices/Characteristics/WeightScaleFeature.swift similarity index 100% rename from Sources/BluetoothServices/Characteristics/WeightScaleFeature.swift rename to Sources/SpeziBluetoothServices/Characteristics/WeightScaleFeature.swift diff --git a/Sources/BluetoothServices/Services/BatteryService.swift b/Sources/SpeziBluetoothServices/Services/BatteryService.swift similarity index 100% rename from Sources/BluetoothServices/Services/BatteryService.swift rename to Sources/SpeziBluetoothServices/Services/BatteryService.swift diff --git a/Sources/BluetoothServices/Services/BloodPressureService.swift b/Sources/SpeziBluetoothServices/Services/BloodPressureService.swift similarity index 100% rename from Sources/BluetoothServices/Services/BloodPressureService.swift rename to Sources/SpeziBluetoothServices/Services/BloodPressureService.swift diff --git a/Sources/BluetoothServices/Services/CurrentTimeService.swift b/Sources/SpeziBluetoothServices/Services/CurrentTimeService.swift similarity index 100% rename from Sources/BluetoothServices/Services/CurrentTimeService.swift rename to Sources/SpeziBluetoothServices/Services/CurrentTimeService.swift diff --git a/Sources/BluetoothServices/Services/DeviceInformationService.swift b/Sources/SpeziBluetoothServices/Services/DeviceInformationService.swift similarity index 100% rename from Sources/BluetoothServices/Services/DeviceInformationService.swift rename to Sources/SpeziBluetoothServices/Services/DeviceInformationService.swift diff --git a/Sources/BluetoothServices/Services/HealthThermometerService.swift b/Sources/SpeziBluetoothServices/Services/HealthThermometerService.swift similarity index 100% rename from Sources/BluetoothServices/Services/HealthThermometerService.swift rename to Sources/SpeziBluetoothServices/Services/HealthThermometerService.swift diff --git a/Sources/BluetoothServices/Services/WeightScaleService.swift b/Sources/SpeziBluetoothServices/Services/WeightScaleService.swift similarity index 100% rename from Sources/BluetoothServices/Services/WeightScaleService.swift rename to Sources/SpeziBluetoothServices/Services/WeightScaleService.swift diff --git a/Sources/BluetoothServices/TestingSupport/CBUUID+Characteristics.swift b/Sources/SpeziBluetoothServices/TestingSupport/CBUUID+Characteristics.swift similarity index 100% rename from Sources/BluetoothServices/TestingSupport/CBUUID+Characteristics.swift rename to Sources/SpeziBluetoothServices/TestingSupport/CBUUID+Characteristics.swift diff --git a/Sources/BluetoothServices/TestingSupport/EventLog.swift b/Sources/SpeziBluetoothServices/TestingSupport/EventLog.swift similarity index 100% rename from Sources/BluetoothServices/TestingSupport/EventLog.swift rename to Sources/SpeziBluetoothServices/TestingSupport/EventLog.swift diff --git a/Sources/BluetoothServices/TestingSupport/TestService.swift b/Sources/SpeziBluetoothServices/TestingSupport/TestService.swift similarity index 100% rename from Sources/BluetoothServices/TestingSupport/TestService.swift rename to Sources/SpeziBluetoothServices/TestingSupport/TestService.swift diff --git a/Sources/TestPeripheral/TestPeripheral.swift b/Sources/TestPeripheral/TestPeripheral.swift index e3db31ba..884a5377 100644 --- a/Sources/TestPeripheral/TestPeripheral.swift +++ b/Sources/TestPeripheral/TestPeripheral.swift @@ -6,12 +6,12 @@ // SPDX-License-Identifier: MIT // -@_spi(TestingSupport) -import BluetoothServices import ByteCoding import CoreBluetooth import OSLog import SpeziBluetooth +@_spi(TestingSupport) +import SpeziBluetoothServices @main diff --git a/Sources/TestPeripheral/TestService.swift b/Sources/TestPeripheral/TestService.swift index 14d1552b..4f42d7e0 100644 --- a/Sources/TestPeripheral/TestService.swift +++ b/Sources/TestPeripheral/TestService.swift @@ -6,10 +6,10 @@ // SPDX-License-Identifier: MIT // -@_spi(TestingSupport) -import BluetoothServices import CoreBluetooth import OSLog +@_spi(TestingSupport) +import SpeziBluetoothServices class TestService { diff --git a/Tests/BluetoothServicesTests/BloodPressureTests.swift b/Tests/BluetoothServicesTests/BloodPressureTests.swift index 5e314711..5e549f03 100644 --- a/Tests/BluetoothServicesTests/BloodPressureTests.swift +++ b/Tests/BluetoothServicesTests/BloodPressureTests.swift @@ -6,12 +6,12 @@ // SPDX-License-Identifier: MIT // -@_spi(TestingSupport) -@testable import BluetoothServices import CoreBluetooth import NIO @_spi(TestingSupport) @testable import SpeziBluetooth +@_spi(TestingSupport) +@testable import SpeziBluetoothServices import XCTByteCoding import XCTest diff --git a/Tests/BluetoothServicesTests/BluetoothServicesTests.swift b/Tests/BluetoothServicesTests/BluetoothServicesTests.swift index 630c6014..1c38f843 100644 --- a/Tests/BluetoothServicesTests/BluetoothServicesTests.swift +++ b/Tests/BluetoothServicesTests/BluetoothServicesTests.swift @@ -6,12 +6,12 @@ // SPDX-License-Identifier: MIT // -@_spi(TestingSupport) -@testable import BluetoothServices import CoreBluetooth import NIO @_spi(TestingSupport) @testable import SpeziBluetooth +@_spi(TestingSupport) +@testable import SpeziBluetoothServices import XCTByteCoding import XCTest diff --git a/Tests/BluetoothServicesTests/CurrentTimeTests.swift b/Tests/BluetoothServicesTests/CurrentTimeTests.swift index bd832686..f777bbb7 100644 --- a/Tests/BluetoothServicesTests/CurrentTimeTests.swift +++ b/Tests/BluetoothServicesTests/CurrentTimeTests.swift @@ -6,12 +6,12 @@ // SPDX-License-Identifier: MIT // -@_spi(TestingSupport) -@testable import BluetoothServices import CoreBluetooth import NIO @_spi(TestingSupport) @testable import SpeziBluetooth +@_spi(TestingSupport) +@testable import SpeziBluetoothServices import XCTByteCoding import XCTest diff --git a/Tests/BluetoothServicesTests/DeviceInformationTests.swift b/Tests/BluetoothServicesTests/DeviceInformationTests.swift index 0a12b260..a3ff3b7c 100644 --- a/Tests/BluetoothServicesTests/DeviceInformationTests.swift +++ b/Tests/BluetoothServicesTests/DeviceInformationTests.swift @@ -6,12 +6,12 @@ // SPDX-License-Identifier: MIT // -@_spi(TestingSupport) -@testable import BluetoothServices import CoreBluetooth import NIO @_spi(TestingSupport) @testable import SpeziBluetooth +@_spi(TestingSupport) +@testable import SpeziBluetoothServices import XCTByteCoding import XCTest diff --git a/Tests/BluetoothServicesTests/HealthThermometerTests.swift b/Tests/BluetoothServicesTests/HealthThermometerTests.swift index fa5621b2..aef08e8d 100644 --- a/Tests/BluetoothServicesTests/HealthThermometerTests.swift +++ b/Tests/BluetoothServicesTests/HealthThermometerTests.swift @@ -6,12 +6,12 @@ // SPDX-License-Identifier: MIT // -@_spi(TestingSupport) -@testable import BluetoothServices import CoreBluetooth import NIO @_spi(TestingSupport) @testable import SpeziBluetooth +@_spi(TestingSupport) +@testable import SpeziBluetoothServices import XCTByteCoding import XCTest diff --git a/Tests/BluetoothServicesTests/RecordAccessControlPointTests.swift b/Tests/BluetoothServicesTests/RecordAccessControlPointTests.swift index 9d848784..12e5696e 100644 --- a/Tests/BluetoothServicesTests/RecordAccessControlPointTests.swift +++ b/Tests/BluetoothServicesTests/RecordAccessControlPointTests.swift @@ -6,12 +6,12 @@ // SPDX-License-Identifier: MIT // -@_spi(TestingSupport) -@testable import BluetoothServices import CoreBluetooth import NIO @_spi(TestingSupport) @testable import SpeziBluetooth +@_spi(TestingSupport) +@testable import SpeziBluetoothServices import XCTByteCoding import XCTest diff --git a/Tests/BluetoothServicesTests/WeightScaleTests.swift b/Tests/BluetoothServicesTests/WeightScaleTests.swift index 1068e27d..cc221d6f 100644 --- a/Tests/BluetoothServicesTests/WeightScaleTests.swift +++ b/Tests/BluetoothServicesTests/WeightScaleTests.swift @@ -6,12 +6,12 @@ // SPDX-License-Identifier: MIT // -@_spi(TestingSupport) -@testable import BluetoothServices import CoreBluetooth import NIO @_spi(TestingSupport) @testable import SpeziBluetooth +@_spi(TestingSupport) +@testable import SpeziBluetoothServices import XCTByteCoding import XCTest From fcb50b95e61270d95fae8b8f1abfd6e6276db78d Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Sat, 22 Jun 2024 11:58:04 +0200 Subject: [PATCH 21/58] Adjust TestApp --- .../TestApp/BluetoothManagerView.swift | 5 +-- .../UITests/TestApp/BluetoothModuleView.swift | 3 +- Tests/UITests/TestApp/TestAppDelegate.swift | 2 +- Tests/UITests/TestApp/TestDevice.swift | 2 +- Tests/UITests/TestApp/TestDeviceView.swift | 2 +- .../TestApp/Views/DeviceInformationView.swift | 2 +- .../TestApp/Views/TestServiceView.swift | 3 +- .../UITests/UITests.xcodeproj/project.pbxproj | 44 ++++++++++++------- 8 files changed, 35 insertions(+), 28 deletions(-) diff --git a/Tests/UITests/TestApp/BluetoothManagerView.swift b/Tests/UITests/TestApp/BluetoothManagerView.swift index c413ab83..e1db5a3c 100644 --- a/Tests/UITests/TestApp/BluetoothManagerView.swift +++ b/Tests/UITests/TestApp/BluetoothManagerView.swift @@ -6,13 +6,12 @@ // SPDX-License-Identifier: MIT // -import BluetoothViews import SpeziBluetooth import SwiftUI struct BluetoothManagerView: View { - @State private var bluetooth = BluetoothManager(devices: []) // discovery any devices! + @State private var bluetooth = BluetoothManager(discovery: []) // discovery any devices! var body: some View { List { @@ -23,7 +22,7 @@ struct BluetoothManagerView: View { DeviceRowView(peripheral: peripheral) } } header: { - LoadingSectionHeaderView(verbatim: "Devices", loading: bluetooth.isScanning) + Text(verbatim: "Devices") } footer: { Text(verbatim: "This is a list of nearby Bluetooth peripherals.") } diff --git a/Tests/UITests/TestApp/BluetoothModuleView.swift b/Tests/UITests/TestApp/BluetoothModuleView.swift index 77aba65c..56f91e44 100644 --- a/Tests/UITests/TestApp/BluetoothModuleView.swift +++ b/Tests/UITests/TestApp/BluetoothModuleView.swift @@ -6,7 +6,6 @@ // SPDX-License-Identifier: MIT // -import BluetoothViews import SpeziBluetooth import SwiftUI @@ -28,7 +27,7 @@ struct BluetoothModuleView: View { DeviceRowView(peripheral: device) } } header: { - LoadingSectionHeaderView(verbatim: "Devices", loading: bluetooth.isScanning) + Text(verbatim: "Devices") } footer: { Text(verbatim: "This is a list of nearby test peripherals. Auto connect is enabled.") } diff --git a/Tests/UITests/TestApp/TestAppDelegate.swift b/Tests/UITests/TestApp/TestAppDelegate.swift index 775ebbf0..278d3828 100644 --- a/Tests/UITests/TestApp/TestAppDelegate.swift +++ b/Tests/UITests/TestApp/TestAppDelegate.swift @@ -7,7 +7,7 @@ // @_spi(TestingSupport) -import BluetoothServices +import SpeziBluetoothServices import Spezi import SpeziBluetooth import SwiftUI diff --git a/Tests/UITests/TestApp/TestDevice.swift b/Tests/UITests/TestApp/TestDevice.swift index 435d1e12..aac6d8b9 100644 --- a/Tests/UITests/TestApp/TestDevice.swift +++ b/Tests/UITests/TestApp/TestDevice.swift @@ -7,7 +7,7 @@ // @_spi(TestingSupport) -import BluetoothServices +import SpeziBluetoothServices import Foundation import SpeziBluetooth diff --git a/Tests/UITests/TestApp/TestDeviceView.swift b/Tests/UITests/TestApp/TestDeviceView.swift index a3a95772..c1c4740a 100644 --- a/Tests/UITests/TestApp/TestDeviceView.swift +++ b/Tests/UITests/TestApp/TestDeviceView.swift @@ -7,7 +7,7 @@ // @_spi(TestingSupport) -import BluetoothServices +import SpeziBluetoothServices @_spi(TestingSupport) import SpeziBluetooth import SwiftUI diff --git a/Tests/UITests/TestApp/Views/DeviceInformationView.swift b/Tests/UITests/TestApp/Views/DeviceInformationView.swift index ee44fc92..03241470 100644 --- a/Tests/UITests/TestApp/Views/DeviceInformationView.swift +++ b/Tests/UITests/TestApp/Views/DeviceInformationView.swift @@ -6,7 +6,7 @@ // SPDX-License-Identifier: MIT // -import BluetoothServices +import SpeziBluetoothServices @_spi(TestingSupport) import SpeziBluetooth import SpeziViews diff --git a/Tests/UITests/TestApp/Views/TestServiceView.swift b/Tests/UITests/TestApp/Views/TestServiceView.swift index b56d7863..e87acc95 100644 --- a/Tests/UITests/TestApp/Views/TestServiceView.swift +++ b/Tests/UITests/TestApp/Views/TestServiceView.swift @@ -7,8 +7,7 @@ // @_spi(TestingSupport) -import BluetoothServices -import BluetoothViews +import SpeziBluetoothServices import CoreBluetooth @_spi(TestingSupport) import SpeziBluetooth diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 6b1b6781..f529cba7 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -12,7 +12,6 @@ 2F64EA8B2A86B3DE006789D0 /* XCTestExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 2F64EA8A2A86B3DE006789D0 /* XCTestExtensions */; }; 2F6D139A28F5F386007C25D6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2F6D139928F5F386007C25D6 /* Assets.xcassets */; }; 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 */; }; @@ -24,8 +23,9 @@ A95542B42B5E3E210066646D /* BluetoothModuleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95542B32B5E3E210066646D /* BluetoothModuleView.swift */; }; A95542B92B5E3F490066646D /* SearchingNearbyDevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95542B82B5E3F490066646D /* SearchingNearbyDevicesView.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 */; }; + A9AAC7ED2C26D6890034088B /* SpeziBluetoothServices in Frameworks */ = {isa = PBXBuildFile; productRef = A9AAC7EC2C26D6890034088B /* SpeziBluetoothServices */; }; + A9AAC7EF2C26D6920034088B /* SpeziBluetoothServices in Frameworks */ = {isa = PBXBuildFile; productRef = A9AAC7EE2C26D6920034088B /* SpeziBluetoothServices */; }; + A9AAC7F22C26D73C0034088B /* SpeziViews in Frameworks */ = {isa = PBXBuildFile; productRef = A9AAC7F12C26D73C0034088B /* SpeziViews */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -79,8 +79,8 @@ buildActionMask = 2147483647; files = ( A92802B72B5081F200874D0A /* SpeziBluetooth in Frameworks */, - A91E672E2B75A500009A1E02 /* BluetoothViews in Frameworks */, - A9C17DEA2B5F1EAA00976924 /* BluetoothServices in Frameworks */, + A9AAC7ED2C26D6890034088B /* SpeziBluetoothServices in Frameworks */, + A9AAC7F22C26D73C0034088B /* SpeziViews in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -89,7 +89,7 @@ buildActionMask = 2147483647; files = ( 2F64EA8B2A86B3DE006789D0 /* XCTestExtensions in Frameworks */, - A97851BF2B79E01D007BCBE3 /* BluetoothServices in Frameworks */, + A9AAC7EF2C26D6920034088B /* SpeziBluetoothServices in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -180,8 +180,8 @@ name = TestApp; packageProductDependencies = ( A92802B62B5081F200874D0A /* SpeziBluetooth */, - A9C17DE92B5F1EAA00976924 /* BluetoothServices */, - A91E672D2B75A500009A1E02 /* BluetoothViews */, + A9AAC7EC2C26D6890034088B /* SpeziBluetoothServices */, + A9AAC7F12C26D73C0034088B /* SpeziViews */, ); productName = Example; productReference = 2F6D139228F5F384007C25D6 /* TestApp.app */; @@ -203,7 +203,7 @@ name = TestAppUITests; packageProductDependencies = ( 2F64EA8A2A86B3DE006789D0 /* XCTestExtensions */, - A97851BE2B79E01D007BCBE3 /* BluetoothServices */, + A9AAC7EE2C26D6920034088B /* SpeziBluetoothServices */, ); productName = ExampleUITests; productReference = 2F6D13AC28F5F386007C25D6 /* TestAppUITests.xctest */; @@ -239,6 +239,7 @@ mainGroup = 2F6D138928F5F384007C25D6; packageReferences = ( 2F64EA892A86B3DE006789D0 /* XCRemoteSwiftPackageReference "XCTestExtensions" */, + A9AAC7F02C26D73C0034088B /* XCRemoteSwiftPackageReference "SpeziViews" */, ); productRefGroup = 2F6D139328F5F384007C25D6 /* Products */; projectDirPath = ""; @@ -744,6 +745,14 @@ minimumVersion = 0.4.10; }; }; + A9AAC7F02C26D73C0034088B /* XCRemoteSwiftPackageReference "SpeziViews" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/StanfordSpezi/SpeziViews.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.4.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -752,21 +761,22 @@ package = 2F64EA892A86B3DE006789D0 /* XCRemoteSwiftPackageReference "XCTestExtensions" */; productName = XCTestExtensions; }; - A91E672D2B75A500009A1E02 /* BluetoothViews */ = { - isa = XCSwiftPackageProductDependency; - productName = BluetoothViews; - }; A92802B62B5081F200874D0A /* SpeziBluetooth */ = { isa = XCSwiftPackageProductDependency; productName = SpeziBluetooth; }; - A97851BE2B79E01D007BCBE3 /* BluetoothServices */ = { + A9AAC7EC2C26D6890034088B /* SpeziBluetoothServices */ = { + isa = XCSwiftPackageProductDependency; + productName = SpeziBluetoothServices; + }; + A9AAC7EE2C26D6920034088B /* SpeziBluetoothServices */ = { isa = XCSwiftPackageProductDependency; - productName = BluetoothServices; + productName = SpeziBluetoothServices; }; - A9C17DE92B5F1EAA00976924 /* BluetoothServices */ = { + A9AAC7F12C26D73C0034088B /* SpeziViews */ = { isa = XCSwiftPackageProductDependency; - productName = BluetoothServices; + package = A9AAC7F02C26D73C0034088B /* XCRemoteSwiftPackageReference "SpeziViews" */; + productName = SpeziViews; }; /* End XCSwiftPackageProductDependency section */ }; From 9d62f3b51944703c0caeb976a037fcf49da13453 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Sat, 22 Jun 2024 16:30:40 +0200 Subject: [PATCH 22/58] Refactor discovery state into DiscoverySession. Support dynamic configuration of discovery options --- Package.swift | 1 - Sources/SpeziBluetooth/Bluetooth.swift | 77 ++-- .../Bridging/BluetoothScanner.swift | 41 -- .../CoreBluetooth/BluetoothManager.swift | 402 +++++++----------- .../Model/DiscoverySession.swift | 279 ++++++++++++ .../Utilities/BluetoothWorkItem.swift | 8 +- .../Utilities/DiscoveryStaleTimer.swift | 4 +- .../Modifier/AutoConnectModifier.swift | 75 ++++ .../Modifier/BluetoothScanner.swift | 32 ++ .../Modifier/DeviceAutoConnectModifier.swift | 48 --- .../Modifier/ScanNearbyDevicesModifier.swift | 87 +++- .../TestApp/BluetoothManagerView.swift | 4 +- .../UITests/UITests.xcodeproj/project.pbxproj | 9 +- .../xcshareddata/xcschemes/TestApp.xcscheme | 2 +- 14 files changed, 674 insertions(+), 395 deletions(-) delete mode 100644 Sources/SpeziBluetooth/Bridging/BluetoothScanner.swift create mode 100644 Sources/SpeziBluetooth/CoreBluetooth/Model/DiscoverySession.swift create mode 100644 Sources/SpeziBluetooth/Modifier/AutoConnectModifier.swift create mode 100644 Sources/SpeziBluetooth/Modifier/BluetoothScanner.swift delete mode 100644 Sources/SpeziBluetooth/Modifier/DeviceAutoConnectModifier.swift diff --git a/Package.swift b/Package.swift index d302f45a..1a9dccbd 100644 --- a/Package.swift +++ b/Package.swift @@ -29,7 +29,6 @@ let package = Package( .package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "1.1.0"), .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.3.0"), .package(url: "https://github.com/StanfordSpezi/SpeziNetworking", from: "2.0.1"), - .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"), .package(url: "https://github.com/realm/SwiftLint.git", .upToNextMinor(from: "0.55.1")) diff --git a/Sources/SpeziBluetooth/Bluetooth.swift b/Sources/SpeziBluetooth/Bluetooth.swift index 3fccef84..475bd533 100644 --- a/Sources/SpeziBluetooth/Bluetooth.swift +++ b/Sources/SpeziBluetooth/Bluetooth.swift @@ -194,7 +194,7 @@ import Spezi /// - ``nearbyDevices(for:)`` /// - ``scanNearbyDevices(autoConnect:)`` /// - ``stopScanning()`` -public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, BluetoothActor { +public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { @Observable class Storage { var nearbyDevices: OrderedDictionary = [:] @@ -211,6 +211,7 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo /// /// Set of configured ``BluetoothDevice`` with their corresponding ``DiscoveryCriteria``. public nonisolated let configuration: Set + private let discoveryConfiguration: Set private let _storage = Storage() @@ -264,15 +265,7 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo /// Discover(ExampleDevice.self, by: .advertisedService(MyExampleService.self)) /// } /// ``` - /// - /// - Parameters: - /// - minimumRSSI: The minimum rssi a nearby peripheral must have to be considered nearby. - /// - advertisementStaleInterval: The time interval after which a peripheral advertisement is considered stale - /// if we don't hear back from the device. Minimum is 1 second. - /// - devices: - public init( - minimumRSSI: Int = BluetoothManager.Defaults.defaultMinimumRSSI, - advertisementStaleInterval: TimeInterval = BluetoothManager.Defaults.defaultStaleTimeout, + public init( // TODO: docs devices @DiscoveryDescriptorBuilder _ devices: @Sendable () -> Set ) { let configuration = devices() @@ -283,15 +276,12 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo configuration.parseDiscoveryDescription() // TODO: rename! } - let bluetoothManager = BluetoothManager( - discovery: discovery, - minimumRSSI: minimumRSSI, - advertisementStaleInterval: advertisementStaleInterval - ) + let bluetoothManager = BluetoothManager() self.bluetoothQueue = bluetoothManager.bluetoothQueue self.bluetoothManager = bluetoothManager self.configuration = configuration + self.discoveryConfiguration = discovery self._devicesInjector = Modifier(wrappedValue: ConnectedDevicesEnvironmentModifier(configuredDeviceTypes: deviceTypes)) Task { @@ -442,8 +432,7 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo observePeripheralState(of: uuid) // register \.state onChange closure - // TODO: spezi currently only allows one module of a type!!!! - spezi.loadModule(device) + spezi.loadModule(device) // TODO: spezi currently only allows one module of a type!!!! handlePeripheralStateChange() // TODO: we need to store them int he discoveredPeripherals to properly forward delegate methods!!! @@ -462,30 +451,60 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothScanner, Bluetoo /// The first connected device can be accessed through the /// [Environment(_:)](https://developer.apple.com/documentation/swiftui/environment/init(_:)-8slkf) in your SwiftUI view. /// - /// - Tip: Scanning for nearby devices can easily be managed via the ``SwiftUI/View/scanNearbyDevices(enabled:with:autoConnect:)`` + /// - Tip: Scanning for nearby devices can easily be managed via the ``SwiftUI/View/scanNearbyDevices(enabled:with:minimumRSSI:advertisementStaleInterval:autoConnect:)`` /// modifier. /// - /// - Parameter autoConnect: If enabled, the bluetooth manager will automatically connect to + /// - Parameters: + /// - minimumRSSI: The minimum rssi a nearby peripheral must have to be considered nearby. + /// - advertisementStaleInterval: The time interval after which a peripheral advertisement is considered stale + /// if we don't hear back from the device. Minimum is 1 second. + /// - 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) { + public func scanNearbyDevices( + minimumRSSI: Int = BluetoothManager.Defaults.defaultMinimumRSSI, + advertisementStaleInterval: TimeInterval = BluetoothManager.Defaults.defaultStaleTimeout, + autoConnect: Bool = false + ) { bluetoothManager.assumeIsolated { manager in - manager.scanNearbyDevices(autoConnect: autoConnect) + manager.scanNearbyDevices( + discovery: discoveryConfiguration, + minimumRSSI: minimumRSSI, + advertisementStaleInterval: advertisementStaleInterval, + 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) { + /// Stop scanning for nearby bluetooth devices. + public func stopScanning() { bluetoothManager.assumeIsolated { manager in - manager.setAutoConnect(autoConnect) + manager.stopScanning() } } +} + + +extension Bluetooth: BluetoothScanner { + func scanNearbyDevices(_ state: BluetoothModuleDiscoveryState) { + scanNearbyDevices( + minimumRSSI: state.minimumRSSI, + advertisementStaleInterval: state.advertisementStaleInterval, + autoConnect: state.autoConnect + ) + } + + func updateScanningState(_ state: BluetoothModuleDiscoveryState) { + let managerState = BluetoothManagerDiscoveryState( + configuredDevices: discoveryConfiguration, + minimumRSSI: state.minimumRSSI, + advertisementStaleInterval: state.advertisementStaleInterval, + autoConnect: state.autoConnect + ) - /// Stop scanning for nearby bluetooth devices. - public func stopScanning() { bluetoothManager.assumeIsolated { manager in - manager.stopScanning() + manager.updateScanningState(managerState) } } } + +// swiftlint:disable:this file_length diff --git a/Sources/SpeziBluetooth/Bridging/BluetoothScanner.swift b/Sources/SpeziBluetooth/Bridging/BluetoothScanner.swift deleted file mode 100644 index 93368d2b..00000000 --- a/Sources/SpeziBluetooth/Bridging/BluetoothScanner.swift +++ /dev/null @@ -1,41 +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 -// - - -/// Any kind of Bluetooth Scanner. -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. - /// - /// How devices are discovered and how they can be accessed is implementation defined. - /// - /// - Parameter autoConnect: If enabled, the bluetooth manager will automatically connect to - /// 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/CoreBluetooth/BluetoothManager.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift index 65548b32..389c8955 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift @@ -14,10 +14,6 @@ import OrderedCollections import OSLog -private class DiscoverySession { // TODO: do we wanna tackle that? -} - - /// Connect and communicate with Bluetooth devices. /// /// This module allows to connect and communicate with Bluetooth devices using modern programming paradigms. @@ -78,20 +74,22 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable /// The serial executor for all Bluetooth related functionality. let bluetoothQueue: DispatchSerialQueue - - /// The device descriptions describing how nearby devices are discovered. - private let configuredDevices: Set // TODO: these are all considered for "DiscoverySession"! - /// The minimum rssi that is required for a device to be discovered. - private let minimumRSSI: Int - /// The time interval after which an advertisement is considered stale and the device is removed. - private let advertisementStaleInterval: TimeInterval - @Lazy private var centralManager: CBCentralManager private var centralDelegate: Delegate? private var isScanningObserver: KVOStateObserver? private let _storage: ObservableStorage + /// Currently ongoing discovery session. + private var discoverySession: DiscoverySession? + + /// The list of nearby bluetooth devices. + /// + /// This array contains all discovered bluetooth peripherals and those with which we are currently connected. + nonisolated public var nearbyPeripherals: [BluetoothPeripheral] { + Array(_storage.discoveredPeripherals.values) + } + /// Represents the current state of the Bluetooth Manager. nonisolated public private(set) var state: BluetoothState { get { @@ -136,67 +134,9 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable } } - /// 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? - - private var autoConnect = false - private var autoConnectItem: BluetoothWorkItem? - private var staleTimer: DiscoveryStaleTimer? - - /// Checks and determines the device candidate for auto-connect. - /// - /// 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 autoConnect else { - return nil // auto-connect is disabled - } - - guard lastManuallyDisconnectedDevice == nil && !hasConnectedDevices else { - return nil - } - - 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. - nonisolated public var nearbyPeripherals: [BluetoothPeripheral] { - Array(_storage.discoveredPeripherals.values) - } - - /// The set of serviceIds we request to discover upon scanning. - /// Returning nil means scanning for all peripherals. - private var serviceDiscoveryIds: [CBUUID]? { // swiftlint:disable:this discouraged_optional_collection - let discoveryIds = configuredDevices.compactMap { configuration in - configuration.discoveryCriteria.discoveryId - } - - return discoveryIds.isEmpty ? nil : discoveryIds - } - /// Initialize a new Bluetooth Manager with provided device description and optional configuration options. - /// - Parameters: - /// - discovery: The set of device description describing **how** to discover **what** to discover. - /// - minimumRSSI: The minimum rssi a nearby peripheral must have to be considered nearby. - /// - advertisementStaleInterval: The time interval after which a peripheral advertisement is considered stale - /// if we don't hear back from the device. Minimum is 1 second. - public init( - discovery: Set, - minimumRSSI: Int = Defaults.defaultMinimumRSSI, - advertisementStaleInterval: TimeInterval = Defaults.defaultStaleTimeout - ) { + public init() { 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!") @@ -204,10 +144,6 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable self.bluetoothQueue = serialQueue - self.configuredDevices = discovery - self.minimumRSSI = minimumRSSI - self.advertisementStaleInterval = max(1, advertisementStaleInterval) - self._storage = ObservableStorage() let delegate = Delegate() @@ -251,38 +187,74 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable /// Scans on nearby devices based on the ``DiscoveryDescription`` provided in the initializer. /// 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:)`` + /// - Tip: Scanning for nearby devices can easily be managed via the ``SwiftUI/View/scanNearbyDevices(enabled:with:discovery:minimumRSSI:advertisementStaleInterval:autoConnect:)`` /// modifier. /// - /// - Parameter autoConnect: If enabled, the bluetooth manager will automatically connect to + /// - Parameters: + /// - discovery: The set of device description describing **how** and **what** to discover. + /// - minimumRSSI: The minimum rssi a nearby peripheral must have to be considered nearby. + /// - advertisementStaleInterval: The time interval after which a peripheral advertisement is considered stale + /// if we don't hear back from the device. Minimum is 1 second. + /// - 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) { - guard !isScanning else { - return + public func scanNearbyDevices( + discovery: Set, + minimumRSSI: Int = Defaults.defaultMinimumRSSI, + advertisementStaleInterval: TimeInterval = Defaults.defaultStaleTimeout, + autoConnect: Bool = false + ) { + let state = BluetoothManagerDiscoveryState( + configuredDevices: discovery, + minimumRSSI: minimumRSSI, + advertisementStaleInterval: advertisementStaleInterval, + autoConnect: autoConnect + ) + scanNearbyDevices(state) + } + + func scanNearbyDevices(_ state: BluetoothManagerDiscoveryState) { + guard discoverySession == nil else { + return // already scanning! } - logger.debug("Starting scanning for nearby devices ...") + logger.debug("Creating discovery session ...") - shouldBeScanning = true - self.autoConnect = autoConnect + let session = DiscoverySession( + boundTo: self, + configuration: state + ) + self.discoverySession = session - // using shouldBeScanning we listen for central to power on if it isn't already + // if powered of, we start scanning later in `handlePoweredOn()` if case .poweredOn = centralManager.state { - centralManager.scanForPeripherals( - withServices: serviceDiscoveryIds, - options: [CBCentralManagerScanOptionAllowDuplicatesKey: true] - ) - isScanning = centralManager.isScanning // ensure this is propagated instantly + _scanForPeripherals(using: session) } } - /// 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 _scanForPeripherals(using session: DiscoverySession) { + guard !isScanning else { + return } + + logger.debug("Starting scanning for nearby devices ...") + centralManager.scanForPeripherals( + withServices: session.assumeIsolated { $0.serviceDiscoveryIds }, + options: [CBCentralManagerScanOptionAllowDuplicatesKey: true] + ) + isScanning = centralManager.isScanning // ensure this is propagated instantly + } + + private func _restartScanning(using session: DiscoverySession) { + guard !isScanning else { + return + } + + centralManager.stopScan() + centralManager.scanForPeripherals( + withServices: session.assumeIsolated { $0.serviceDiscoveryIds }, + options: [CBCentralManagerScanOptionAllowDuplicatesKey: true] + ) + isScanning = centralManager.isScanning // ensure this is propagated instantly } /// Stop scanning for nearby bluetooth devices. @@ -293,8 +265,31 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable logger.debug("Scanning stopped") } - if shouldBeScanning { - shouldBeScanning = false + if discoverySession != nil { + logger.debug("Discovery session cleared.") + discoverySession = nil + checkForCentralDeinit() + } + } + + + /// Reactive scan upon powered on. + private func handlePoweredOn() { + if let discoverySession, !isScanning { + _scanForPeripherals(using: discoverySession) + } + } + + private func handleStoppedScanning() { + let devices = discoveredPeripherals.values.filter { device in + device.cbPeripheral.state == .disconnected + } + + for device in devices { + clearDiscoveredPeripheral(forKey: device.id) + } + + if devices.isEmpty { // otherwise deinit was already called checkForCentralDeinit() } } @@ -357,30 +352,7 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable _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 = discoveredPeripherals.values.filter { device in - device.cbPeripheral.state == .disconnected - } - - for device in devices { - clearDiscoveredPeripheral(forKey: device.id) - } - - if devices.isEmpty { // otherwise deinit was already called - checkForCentralDeinit() - } - } - - private func clearDiscoveredPeripheral(forKey id: UUID) { + func clearDiscoveredPeripheral(forKey id: UUID) { // TODO: access level is not private anymore??? if let peripheral = discoveredPeripherals[id] { // `handleDiscarded` must be called before actually removing it from the dictionary to make sure peripherals can react to this event peripheral.assumeIsolated { device in @@ -394,8 +366,8 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable discoveredPeripherals.removeValue(forKey: id) - if lastManuallyDisconnectedDevice == id { - lastManuallyDisconnectedDevice = nil + discoverySession?.assumeIsolated { session in + session.clearManuallyDisconnectedDevice(for: id) } checkForCentralDeinit() @@ -418,23 +390,31 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable /// De-initializes the Bluetooth Central if we currently don't use it. private func checkForCentralDeinit() { - if !shouldBeScanning && discoveredPeripherals.isEmpty && retrievedPeripherals.isEmpty { - // TODO: check for retrieved peripherals to be empty? more than that? - _centralManager.destroy() - self.state = .unknown - self.lastManuallyDisconnectedDevice = nil + guard discoverySession == nil else { + return + } + + guard discoveredPeripherals.isEmpty && retrievedPeripherals.isEmpty else { + return // TODO: check for empty references? } + + _centralManager.destroy() + self.state = .unknown } func connect(peripheral: BluetoothPeripheral) { logger.debug("Trying to connect to \(peripheral.cbPeripheral.debugIdentifier) ...") - let cancelled = self.cancelStaleTask(for: peripheral) + let cancelled = discoverySession?.assumeIsolated { session in + session.cancelStaleTask(for: peripheral) + } self.centralManager.connect(peripheral.cbPeripheral, options: nil) - if cancelled { - self.scheduleStaleTaskForOldestActivityDevice(ignore: peripheral) + if cancelled == true { + discoverySession?.assumeIsolated { session in + session.scheduleStaleTaskForOldestActivityDevice(ignore: peripheral) + } } } @@ -443,104 +423,9 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable // stale timer is handled in the delegate method centralManager.cancelPeripheralConnection(peripheral.cbPeripheral) - self.lastManuallyDisconnectedDevice = peripheral.id - } - - // MARK: - Auto Connect - - private func kickOffAutoConnect() { - guard autoConnectItem == nil && autoConnectDeviceCandidate != nil else { - return + discoverySession?.assumeIsolated { session in + session.deviceManuallyDisconnected(id: peripheral.id) } - - let item = BluetoothWorkItem(manager: self) { manager in - manager.autoConnectItem = nil - - guard let candidate = manager.autoConnectDeviceCandidate else { - return - } - - candidate.assumeIsolated { peripheral in - peripheral.connect() - } - } - - autoConnectItem = item - bluetoothQueue.schedule(for: .now() + .seconds(Defaults.defaultAutoConnectDebounce), execute: item) - } - - // MARK: - Stale Advertisement Timeout - - /// Schedule a new `DiscoveryStaleTimer`, cancelling any previous one. - /// - Parameters: - /// - 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) { - let timer = DiscoveryStaleTimer(device: device.id, manager: self) { manager in - manager.handleStaleTask() - } - - self.staleTimer = timer - timer.schedule(for: timeout, in: bluetoothQueue) - } - - private func scheduleStaleTaskForOldestActivityDevice(ignore device: BluetoothPeripheral? = nil) { - if let oldestActivityDevice = oldestActivityDevice(ignore: device) { - let lastActivity = oldestActivityDevice.assumeIsolated { $0.lastActivity } - - let intervalSinceLastActivity = Date.now.timeIntervalSince(lastActivity) - let nextTimeout = max(0, advertisementStaleInterval - intervalSinceLastActivity) - - scheduleStaleTask(for: oldestActivityDevice, withTimeout: nextTimeout) - } - } - - private func cancelStaleTask(for device: BluetoothPeripheral) -> Bool { - guard let staleTimer, staleTimer.targetDevice == device.id else { - return false - } - - staleTimer.cancel() - self.staleTimer = nil - return true - } - - /// The device with the oldest device activity. - /// - Parameter device: The device to ignore. - private func oldestActivityDevice(ignore device: BluetoothPeripheral? = nil) -> BluetoothPeripheral? { - // when we are just interested in the min device, this operation is a bit cheaper then sorting the whole list - 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.assumeIsolated { - $0.lastActivity - } < rhs.assumeIsolated { - $0.lastActivity - } - } - } - - private func handleStaleTask() { - staleTimer = nil // reset the timer - - let staleDevices = discoveredPeripherals.values.filter { device in - device.assumeIsolated { isolated in - isolated.isConsideredStale(interval: advertisementStaleInterval) - } - } - - for device in staleDevices { - logger.debug("Removing stale peripheral \(device.cbPeripheral.debugIdentifier)") - // we know it won't be connected, therefore we just need to remove it - clearDiscoveredPeripheral(forKey: device.id) - } - - - // schedule the next timeout for devices in the list - scheduleStaleTaskForOldestActivityDevice() } private func discardDevice(device: BluetoothPeripheral) { @@ -551,16 +436,23 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable } 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) + let backdateInterval: TimeInterval + if let discoverySession { + // we will keep discarded devices for max 2s before the stale timer kicks off + backdateInterval = max(0, discoverySession.assumeIsolated { $0.configuration.advertisementStaleInterval } - 2) + } else { + backdateInterval = 0 + } device.assumeIsolated { device in - device.markLastActivity(.now - interval) + device.markLastActivity(.now - backdateInterval) device.handleDisconnect() } // We just schedule the new timer if there is a device to schedule one for. - scheduleStaleTaskForOldestActivityDevice() + discoverySession?.assumeIsolated { session in + session.scheduleStaleTaskForOldestActivityDevice() + } } } @@ -570,8 +462,7 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable } deinit { - staleTimer?.cancel() - autoConnectItem?.cancel() + discoverySession = nil // non-isolated workaround for calling stopScanning() if isScanning { @@ -640,6 +531,11 @@ extension BluetoothManager: KVOReceiver { extension BluetoothManager: BluetoothScanner { + /// Default id based on `ObjectIdentifier`. + public nonisolated var id: ObjectIdentifier { + ObjectIdentifier(self) + } + /// Support for the auto connect modifier. @_documentation(visibility: internal) public nonisolated var hasConnectedDevices: Bool { @@ -655,6 +551,20 @@ extension BluetoothManager: BluetoothScanner { } }) } + + func updateScanningState(_ state: BluetoothManagerDiscoveryState) { + guard let discoverySession else { + return + } + + let discoveryItemsChanged = discoverySession.assumeIsolated { session in + session.updateConfigurationReportingDiscoveryItemsChanged(state) + } + + if discoveryItemsChanged == true { + _restartScanning(using: discoverySession) + } + } } @@ -742,12 +652,13 @@ extension BluetoothManager { Task { @SpeziBluetooth in await manager.isolated { manager in - guard manager.isScanning else { + guard let session = manager.discoverySession, + manager.isScanning 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 + // ensure the signal strength is not too low + guard session.assumeIsolated({ $0.isInRange(rssi: rssi) }) else { return // logging this would just be to verbose, so we don't. } @@ -761,18 +672,16 @@ extension BluetoothManager { device.update(advertisement: data, rssi: rssi.intValue) } - if manager.cancelStaleTask(for: device) { - // current device was earliest to go stale, schedule timeout for next oldest device - manager.scheduleStaleTaskForOldestActivityDevice() + session.assumeIsolated { session in + session.deviceDiscoveryPostAction(device: device, newlyDiscovered: false) } - - manager.kickOffAutoConnect() return } logger.debug("Discovered peripheral \(peripheral.debugIdentifier) at \(rssi.intValue) dB (data: \(advertisementData))") - let descriptor = manager.configuredDevices.find(for: data, logger: logger) + let descriptor = session.assumeIsolated { $0.configuration.configuredDevices } + .find(for: data, logger: logger) let device = BluetoothPeripheral( manager: manager, peripheral: peripheral, @@ -784,12 +693,9 @@ extension BluetoothManager { 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) + session.assumeIsolated { session in + session.deviceDiscoveryPostAction(device: device, newlyDiscovered: true) } - - manager.kickOffAutoConnect() } } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/DiscoverySession.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/DiscoverySession.swift new file mode 100644 index 00000000..16499c5c --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/DiscoverySession.swift @@ -0,0 +1,279 @@ +// +// 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 class CoreBluetooth.CBUUID +import Foundation +import OSLog + + +struct BluetoothManagerDiscoveryState { + /// The device descriptions describing how nearby devices are discovered. + let configuredDevices: Set + /// The minimum rssi that is required for a device to be considered discovered. + let minimumRSSI: Int + /// The time interval after which an advertisement is considered stale and the device is removed. + let advertisementStaleInterval: TimeInterval + /// Flag indicating if first discovered device should be auto-connected. + let autoConnect: Bool + + + init(configuredDevices: Set, minimumRSSI: Int, advertisementStaleInterval: TimeInterval, autoConnect: Bool) { + self.configuredDevices = configuredDevices + self.minimumRSSI = minimumRSSI + self.advertisementStaleInterval = max(1, advertisementStaleInterval) + self.autoConnect = autoConnect + } +} + + +struct BluetoothModuleDiscoveryState { // intermediate storage object that is later translated to a BluetoothManagerDiscoveryState + /// The minimum rssi that is required for a device to be considered discovered. + let minimumRSSI: Int + /// The time interval after which an advertisement is considered stale and the device is removed. + let advertisementStaleInterval: TimeInterval + /// Flag indicating if first discovered device should be auto-connected. + let autoConnect: Bool + + + init(minimumRSSI: Int, advertisementStaleInterval: TimeInterval, autoConnect: Bool) { + self.minimumRSSI = minimumRSSI + self.advertisementStaleInterval = advertisementStaleInterval + self.autoConnect = autoConnect + } +} + + +actor DiscoverySession: BluetoothActor { + private let logger = Logger(subsystem: "edu.stanford.spezi.bluetooth", category: "DiscoverySession") + let bluetoothQueue: DispatchSerialQueue + + + fileprivate weak var manager: BluetoothManager? + + private(set) var configuration: BluetoothManagerDiscoveryState + + /// The identifier of the last manually disconnected device. + /// This is to avoid automatically reconnecting to a device that was manually disconnected. + private(set) var lastManuallyDisconnectedDevice: UUID? + + private var autoConnectItem: BluetoothWorkItem? + private(set) var staleTimer: DiscoveryStaleTimer? + + + /// The set of serviceIds we request to discover upon scanning. + /// Returning nil means scanning for all peripherals. + var serviceDiscoveryIds: [CBUUID]? { // swiftlint:disable:this discouraged_optional_collection + let discoveryIds = configuration.configuredDevices.compactMap { configuration in + configuration.discoveryCriteria.discoveryId + } + + return discoveryIds.isEmpty ? nil : discoveryIds + } + + + init( + boundTo manager: BluetoothManager, + configuration: BluetoothManagerDiscoveryState + ) { + self.bluetoothQueue = manager.bluetoothQueue + self.manager = manager + self.configuration = configuration + } + + func isInRange(rssi: NSNumber) -> Bool { + // rssi of 127 is a magic value signifying unavailability of the value. + rssi.intValue >= configuration.minimumRSSI && rssi.intValue != 127 + } + + func deviceManuallyDisconnected(id uuid: UUID) { + lastManuallyDisconnectedDevice = uuid + } + + func clearManuallyDisconnectedDevice(for uuid: UUID) { + if lastManuallyDisconnectedDevice == uuid { + lastManuallyDisconnectedDevice = nil + } + } + + func deviceDiscoveryPostAction(device: BluetoothPeripheral, newlyDiscovered: Bool) { + if newlyDiscovered { + if staleTimer == nil { + // There is no stale timer running. So new device will be the one with the oldest activity. Schedule ... + scheduleStaleTask(for: device, withTimeout: configuration.advertisementStaleInterval) + } + } else { + if cancelStaleTask(for: device) { + // current device was earliest to go stale, schedule timeout for next oldest device + scheduleStaleTaskForOldestActivityDevice() + } + } + + kickOffAutoConnect() + } + + func updateConfigurationReportingDiscoveryItemsChanged(_ configuration: BluetoothManagerDiscoveryState) -> Bool { + let discoveryItemsChanged = self.configuration.configuredDevices != configuration.configuredDevices + self.configuration = configuration + return discoveryItemsChanged + } + + deinit { + staleTimer?.cancel() + autoConnectItem?.cancel() + } +} + + +extension BluetoothManagerDiscoveryState: Hashable {} + + +extension BluetoothModuleDiscoveryState: Hashable {} + +// MARK: - Auto Connect + +extension DiscoverySession { + /// Checks and determines the device candidate for auto-connect. + /// + /// 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 let manager else { + return nil // we are orphaned + } + + guard configuration.autoConnect else { + return nil // auto-connect is disabled + } + + guard lastManuallyDisconnectedDevice == nil && !manager.hasConnectedDevices else { + return nil + } + + manager.assertIsolated("\(#function) was not called from within isolation.") + let sortedCandidates = manager.assumeIsolated { $0.discoveredPeripherals } + .values + .filter { $0.cbPeripheral.state == .disconnected } + .sorted { lhs, rhs in + lhs.assumeIsolated { $0.rssi } < rhs.assumeIsolated { $0.rssi } + } + + return sortedCandidates.first + } + + + func kickOffAutoConnect() { + guard autoConnectItem == nil && autoConnectDeviceCandidate != nil else { + return + } + + let item = BluetoothWorkItem(boundTo: self) { session in + session.autoConnectItem = nil + + guard let candidate = session.autoConnectDeviceCandidate else { + return + } + + candidate.assumeIsolated { peripheral in + peripheral.connect() + } + } + + autoConnectItem = item + self.bluetoothQueue.schedule(for: .now() + .seconds(BluetoothManager.Defaults.defaultAutoConnectDebounce), execute: item) + } +} + +// MARK: - Stale Advertisement Timeout + +extension DiscoverySession { + /// Schedule a new `DiscoveryStaleTimer`, cancelling any previous one. + /// - Parameters: + /// - device: The device for which the timer is scheduled for. + /// - timeout: The timeout for which the timer is scheduled for. + func scheduleStaleTask(for device: BluetoothPeripheral, withTimeout timeout: TimeInterval) { + let timer = DiscoveryStaleTimer(device: device.id, boundTo: self) { session in + session.handleStaleTask() + } + + self.staleTimer = timer + timer.schedule(for: timeout, in: self.bluetoothQueue) + } + + func scheduleStaleTaskForOldestActivityDevice(ignore device: BluetoothPeripheral? = nil) { + if let oldestActivityDevice = oldestActivityDevice(ignore: device) { + let lastActivity = oldestActivityDevice.assumeIsolated { $0.lastActivity } + + let intervalSinceLastActivity = Date.now.timeIntervalSince(lastActivity) + let nextTimeout = max(0, configuration.advertisementStaleInterval - intervalSinceLastActivity) + + scheduleStaleTask(for: oldestActivityDevice, withTimeout: nextTimeout) + } + } + + func cancelStaleTask(for device: BluetoothPeripheral) -> Bool { + guard let staleTimer, staleTimer.targetDevice == device.id else { + return false + } + + staleTimer.cancel() + self.staleTimer = nil + return true + } + + /// The device with the oldest device activity. + /// - Parameter device: The device to ignore. + private func oldestActivityDevice(ignore device: BluetoothPeripheral? = nil) -> BluetoothPeripheral? { + guard let manager else { + return nil + } + + // when we are just interested in the min device, this operation is a bit cheaper then sorting the whole list + return manager.assumeIsolated { $0.discoveredPeripherals } + .values + .filter { + // it's important to access the underlying state here + $0.cbPeripheral.state == .disconnected && $0.id != device?.id + } + .min { lhs, rhs in + lhs.assumeIsolated { + $0.lastActivity + } < rhs.assumeIsolated { + $0.lastActivity + } + } + } + + private func handleStaleTask() { + guard let manager else { + return + } + + staleTimer = nil // reset the timer + + let configuration = configuration + let staleDevices = manager.assumeIsolated { $0.discoveredPeripherals } + .values + .filter { device in + device.assumeIsolated { isolated in + isolated.isConsideredStale(interval: configuration.advertisementStaleInterval) + } + } + + for device in staleDevices { + logger.debug("Removing stale peripheral \(device.cbPeripheral.debugIdentifier)") + // we know it won't be connected, therefore we just need to remove it + manager.assumeIsolated { manager in + manager.clearDiscoveredPeripheral(forKey: device.id) + } + } + + + // schedule the next timeout for devices in the list + scheduleStaleTaskForOldestActivityDevice() + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothWorkItem.swift b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothWorkItem.swift index 82a52360..60f5579f 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothWorkItem.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothWorkItem.swift @@ -12,9 +12,9 @@ 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 { + init(boundTo actor: Actor, handler: @escaping (isolated Actor) -> Void) { + self.workItem = DispatchWorkItem { [weak actor] in + guard let actor else { return } @@ -22,7 +22,7 @@ class BluetoothWorkItem { // 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) + await actor.isolated(perform: handler) } } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/DiscoveryStaleTimer.swift b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/DiscoveryStaleTimer.swift index e1e1c96e..b5907b37 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/DiscoveryStaleTimer.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/DiscoveryStaleTimer.swift @@ -14,11 +14,11 @@ class DiscoveryStaleTimer { /// The dispatch work item that schedules the next stale timer. private let workItem: BluetoothWorkItem - init(device: UUID, manager: BluetoothManager, handler: @escaping (isolated BluetoothManager) -> Void) { + init(device: UUID, boundTo actor: Actor, handler: @escaping (isolated Actor) -> Void) { // make sure that you don't create a reference cycle through the closure above! self.targetDevice = device - self.workItem = BluetoothWorkItem(manager: manager, handler: handler) + self.workItem = BluetoothWorkItem(boundTo: actor, handler: handler) } diff --git a/Sources/SpeziBluetooth/Modifier/AutoConnectModifier.swift b/Sources/SpeziBluetooth/Modifier/AutoConnectModifier.swift new file mode 100644 index 00000000..ffbfa295 --- /dev/null +++ b/Sources/SpeziBluetooth/Modifier/AutoConnectModifier.swift @@ -0,0 +1,75 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +extension View { + /// Scan for nearby Bluetooth devices and auto connect. + /// + /// Scans for nearby Bluetooth devices till a device to auto connect to is discovered. + /// Device scanning is automatically started again if the device happens to disconnect. + /// + /// Scans on nearby devices based on the ``Discover`` declarations provided in the initializer. + /// + /// All discovered devices for a given type can be accessed through the ``Bluetooth/nearbyDevices(for:)`` method. + /// The first connected device can be accessed through the + /// [Environment(_:)](https://developer.apple.com/documentation/swiftui/environment/init(_:)-8slkf) in your SwiftUI view. + /// + /// - Parameters: + /// - enabled: Flag indicating if nearby device scanning is enabled. + /// - bluetooth: The Bluetooth Module to use for scanning. + /// - minimumRSSI: The minimum rssi a nearby peripheral must have to be considered nearby. + /// - advertisementStaleInterval: The time interval after which a peripheral advertisement is considered stale + /// if we don't hear back from the device. Minimum is 1 second. + /// - Returns: The modified view. + public func autoConnect( // swiftlint:disable:this function_default_parameter_at_end + enabled: Bool = true, + with bluetooth: Bluetooth, + minimumRSSI: Int = BluetoothManager.Defaults.defaultMinimumRSSI, + advertisementStaleInterval: TimeInterval = BluetoothManager.Defaults.defaultStaleTimeout + ) -> some View { + scanNearbyDevices(enabled: enabled && !bluetooth.hasConnectedDevices, scanner: bluetooth, state: BluetoothModuleDiscoveryState( + minimumRSSI: minimumRSSI, + advertisementStaleInterval: advertisementStaleInterval, + autoConnect: true + )) + } + + + /// Scan for nearby Bluetooth devices and auto connect. + /// + /// Scans for nearby Bluetooth devices till a device to auto connect to is discovered. + /// Device scanning is automatically started again if the device happens to disconnect. + /// + /// Scans on nearby devices based on the ``DiscoveryDescription`` provided in the initializer. + /// All discovered devices can be accessed through the ``BluetoothManager/nearbyPeripherals`` property. + /// + /// - Parameters: + /// - enabled: Flag indicating if nearby device scanning is enabled. + /// - bluetoothManager: The Bluetooth Manager to use for scanning. + /// - discovery: The set of device description describing **how** and **what** to discover. + /// - minimumRSSI: The minimum rssi a nearby peripheral must have to be considered nearby. + /// - advertisementStaleInterval: The time interval after which a peripheral advertisement is considered stale + /// if we don't hear back from the device. Minimum is 1 second. + /// - Returns: The modified view. + public func autoConnect( // swiftlint:disable:this function_default_parameter_at_end + enabled: Bool = true, + with bluetoothManager: BluetoothManager, + discovery: Set, + minimumRSSI: Int = BluetoothManager.Defaults.defaultMinimumRSSI, + advertisementStaleInterval: TimeInterval = BluetoothManager.Defaults.defaultStaleTimeout + ) -> some View { + scanNearbyDevices(enabled: enabled && !bluetoothManager.hasConnectedDevices, scanner: bluetoothManager, state: BluetoothManagerDiscoveryState( + configuredDevices: discovery, + minimumRSSI: minimumRSSI, + advertisementStaleInterval: advertisementStaleInterval, + autoConnect: true + )) + } +} diff --git a/Sources/SpeziBluetooth/Modifier/BluetoothScanner.swift b/Sources/SpeziBluetooth/Modifier/BluetoothScanner.swift new file mode 100644 index 00000000..cd5031b9 --- /dev/null +++ b/Sources/SpeziBluetooth/Modifier/BluetoothScanner.swift @@ -0,0 +1,32 @@ +// +// 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 +// + + +/// Any kind of Bluetooth Scanner. +protocol BluetoothScanner: Identifiable where ID: Hashable { + /// Captures state required to start scanning. + associatedtype ScanningState: Equatable + + /// Indicates if there is at least one connected peripheral. + /// + /// Make sure this tracks observability of all devices. + var hasConnectedDevices: Bool { get } + + /// Scan for nearby bluetooth devices. + /// + /// How devices are discovered and how they can be accessed is implementation defined. + /// + /// - Parameter state: The scanning state. + func scanNearbyDevices(_ state: ScanningState) async + + /// Update the `ScanningState` for an currently ongoing scanning session. + func updateScanningState(_ state: ScanningState) async + + /// Stop scanning for nearby bluetooth devices. + func stopScanning() async +} diff --git a/Sources/SpeziBluetooth/Modifier/DeviceAutoConnectModifier.swift b/Sources/SpeziBluetooth/Modifier/DeviceAutoConnectModifier.swift deleted file mode 100644 index a07b18a3..00000000 --- a/Sources/SpeziBluetooth/Modifier/DeviceAutoConnectModifier.swift +++ /dev/null @@ -1,48 +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 - - -private struct DeviceAutoConnectModifier: ViewModifier { - private let enabled: Bool - private let scanner: Scanner - - private var shouldScan: Bool { - enabled && !scanner.hasConnectedDevices - } - - init(enabled: Bool, scanner: Scanner) { - self.enabled = enabled - self.scanner = scanner - } - - func body(content: Content) -> some View { - content - .scanNearbyDevices(enabled: shouldScan, with: scanner, autoConnect: true) - } -} - - -extension View { - /// Scan for nearby Bluetooth devices and auto connect. - /// - /// Scans for nearby Bluetooth devices till a device to auto connect to is discovered. - /// Device scanning is automatically started again if the device happens to disconnect. - /// - /// How nearby devices are accessed depends on the passed ``BluetoothScanner`` implementation. - /// - /// - Parameters: - /// - enabled: Flag indicating if nearby device scanning is enabled. - /// - scanner: The Bluetooth Manager to use for scanning. - /// - Returns: THe modified view. - public func autoConnect(enabled: Bool = false, with scanner: Scanner) -> some View { - // swiftlint:disable:previous function_default_parameter_at_end - modifier(DeviceAutoConnectModifier(enabled: enabled, scanner: scanner)) - } -} diff --git a/Sources/SpeziBluetooth/Modifier/ScanNearbyDevicesModifier.swift b/Sources/SpeziBluetooth/Modifier/ScanNearbyDevicesModifier.swift index d7f600a4..1bbdcc3d 100644 --- a/Sources/SpeziBluetooth/Modifier/ScanNearbyDevicesModifier.swift +++ b/Sources/SpeziBluetooth/Modifier/ScanNearbyDevicesModifier.swift @@ -13,7 +13,7 @@ import SwiftUI private struct ScanNearbyDevicesModifier: ViewModifier { private let enabled: Bool private let scanner: Scanner - private let autoConnect: Bool + private let state: Scanner.ScanningState @Environment(\.scenePhase) private var scenePhase @@ -22,10 +22,10 @@ private struct ScanNearbyDevicesModifier: ViewModifie @State private var modifierId = UUID() - init(enabled: Bool, scanner: Scanner, autoConnect: Bool) { + init(enabled: Bool, scanner: Scanner, state: Scanner.ScanningState) { self.enabled = enabled self.scanner = scanner - self.autoConnect = autoConnect + self.state = state } func body(content: Content) -> some View { @@ -47,9 +47,9 @@ private struct ScanNearbyDevicesModifier: ViewModifie onBackground() } } - .onChange(of: autoConnect, initial: false) { + .onChange(of: state, initial: false) { Task { - await scanner.setAutoConnect(autoConnect) + await scanner.updateScanningState(state) } } } @@ -59,7 +59,7 @@ private struct ScanNearbyDevicesModifier: ViewModifie if enabled { surroundingModifiers.setModifierScanningState(enabled: true, with: scanner, modifierId: modifierId) Task { - await scanner.scanNearbyDevices(autoConnect: autoConnect) + await scanner.scanNearbyDevices(state) } } } @@ -80,8 +80,18 @@ private struct ScanNearbyDevicesModifier: ViewModifie extension View { + func scanNearbyDevices(enabled: Bool, scanner: Scanner, state: Scanner.ScanningState) -> some View { + modifier(ScanNearbyDevicesModifier(enabled: enabled, scanner: scanner, state: state)) + } + /// Scan for nearby Bluetooth devices. /// + /// Scans on nearby devices based on the ``Discover`` declarations provided in the initializer. + /// + /// All discovered devices for a given type can be accessed through the ``Bluetooth/nearbyDevices(for:)`` method. + /// The first connected device can be accessed through the + /// [Environment(_:)](https://developer.apple.com/documentation/swiftui/environment/init(_:)-8slkf) in your SwiftUI view. + /// /// Nearby device search is automatically paused when the view disappears or if the app enters background and /// is automatically started again when the view appears or the app enters the foreground again. /// Further, scanning is automatically started if Bluetooth is turned on by the user while the view was already presented. @@ -90,22 +100,67 @@ extension View { /// discovered for a short period in time. /// /// - Tip: If you want to continuously search for auto-connectable device in the background, - /// you might want to use the ``SwiftUI/View/autoConnect(enabled:with:)`` modifier instead. - /// - /// How nearby devices are accessed depends on the passed ``BluetoothScanner`` implementation. + /// you might want to use the ``SwiftUI/View/autoConnect(enabled:with:minimumRSSI:advertisementStaleInterval:)`` modifier instead. /// /// - Parameters: /// - enabled: Flag indicating if nearby device scanning is enabled. - /// - scanner: The Bluetooth Manager to use for scanning. + /// - bluetooth: The Bluetooth Module to use for scanning. + /// - minimumRSSI: The minimum rssi a nearby peripheral must have to be considered nearby. + /// - advertisementStaleInterval: The time interval after which a peripheral advertisement is considered stale + /// if we don't hear back from the device. Minimum is 1 second. /// - autoConnect: If enabled, the bluetooth manager will automatically connect to the nearby device if only one is found. /// - Returns: The modified view. + public func scanNearbyDevices( // swiftlint:disable:this function_default_parameter_at_end + enabled: Bool = true, + with bluetooth: Bluetooth, + minimumRSSI: Int = BluetoothManager.Defaults.defaultMinimumRSSI, + advertisementStaleInterval: TimeInterval = BluetoothManager.Defaults.defaultStaleTimeout, + autoConnect: Bool = false + ) -> some View { + scanNearbyDevices(enabled: enabled, scanner: bluetooth, state: BluetoothModuleDiscoveryState( + minimumRSSI: minimumRSSI, + advertisementStaleInterval: advertisementStaleInterval, + autoConnect: autoConnect + )) + } + + /// Scan for nearby Bluetooth devices. /// - /// ## Topics + /// Scans on nearby devices based on the ``DiscoveryDescription`` provided in the initializer. + /// All discovered devices can be accessed through the ``BluetoothManager/nearbyPeripherals`` property. /// - /// ### Bluetooth Scanner - /// - ``BluetoothScanner`` - public func scanNearbyDevices(enabled: Bool = true, with scanner: Scanner, autoConnect: Bool = false) -> some View { - // swiftlint:disable:previous function_default_parameter_at_end - modifier(ScanNearbyDevicesModifier(enabled: enabled, scanner: scanner, autoConnect: autoConnect)) + /// Nearby device search is automatically paused when the view disappears or if the app enters background and + /// is automatically started again when the view appears or the app enters the foreground again. + /// Further, scanning is automatically started if Bluetooth is turned on by the user while the view was already presented. + /// + /// The auto connect feature allows you to automatically connect to a bluetooth peripheral if it is the only device + /// discovered for a short period in time. + /// + /// - Tip: If you want to continuously search for auto-connectable device in the background, + /// you might want to use the ``SwiftUI/View/autoConnect(enabled:with:discovery:minimumRSSI:advertisementStaleInterval:)`` modifier instead. + /// + /// - Parameters: + /// - enabled: Flag indicating if nearby device scanning is enabled. + /// - bluetoothManager: The Bluetooth Manager to use for scanning. + /// - discovery: The set of device description describing **how** and **what** to discover. + /// - minimumRSSI: The minimum rssi a nearby peripheral must have to be considered nearby. + /// - advertisementStaleInterval: The time interval after which a peripheral advertisement is considered stale + /// if we don't hear back from the device. Minimum is 1 second. + /// - autoConnect: If enabled, the bluetooth manager will automatically connect to the nearby device if only one is found. + /// - Returns: The modified view. + public func scanNearbyDevices( // swiftlint:disable:this function_default_parameter_at_end + enabled: Bool = true, + with bluetoothManager: BluetoothManager, + discovery: Set, + minimumRSSI: Int = BluetoothManager.Defaults.defaultMinimumRSSI, + advertisementStaleInterval: TimeInterval = BluetoothManager.Defaults.defaultStaleTimeout, + autoConnect: Bool = false + ) -> some View { + scanNearbyDevices(enabled: enabled, scanner: bluetoothManager, state: BluetoothManagerDiscoveryState( + configuredDevices: discovery, + minimumRSSI: minimumRSSI, + advertisementStaleInterval: advertisementStaleInterval, + autoConnect: autoConnect + )) } } diff --git a/Tests/UITests/TestApp/BluetoothManagerView.swift b/Tests/UITests/TestApp/BluetoothManagerView.swift index e1db5a3c..4b3f809d 100644 --- a/Tests/UITests/TestApp/BluetoothManagerView.swift +++ b/Tests/UITests/TestApp/BluetoothManagerView.swift @@ -11,7 +11,7 @@ import SwiftUI struct BluetoothManagerView: View { - @State private var bluetooth = BluetoothManager(discovery: []) // discovery any devices! + @State private var bluetooth = BluetoothManager() var body: some View { List { @@ -27,7 +27,7 @@ struct BluetoothManagerView: View { Text(verbatim: "This is a list of nearby Bluetooth peripherals.") } } - .scanNearbyDevices(with: bluetooth) + .scanNearbyDevices(with: bluetooth, discovery: []) // discovery any devices! .navigationTitle("Nearby Devices") } } diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index f529cba7..0ae20cd8 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -217,7 +217,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1500; - LastUpgradeCheck = 1520; + LastUpgradeCheck = 1540; TargetAttributes = { 2F6D139128F5F384007C25D6 = { CreatedOnToolsVersion = 14.1; @@ -349,6 +349,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -412,6 +413,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -441,7 +443,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = 637867499T; + DEVELOPMENT_TEAM = 484YT3X9X7; ENABLE_HARDENED_RUNTIME = NO; ENABLE_PREVIEWS = YES; ENABLE_TESTING_SEARCH_PATHS = YES; @@ -459,7 +461,7 @@ ); MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.bluetooth.testapplication; + PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.bluetooth.testapplication2; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; SUPPORTS_MACCATALYST = YES; @@ -604,6 +606,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; diff --git a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme index e7449281..1ee434a9 100644 --- a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme +++ b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme @@ -1,6 +1,6 @@ Date: Sat, 22 Jun 2024 18:05:46 +0200 Subject: [PATCH 23/58] Final design for persistent devices --- Sources/SpeziBluetooth/Bluetooth.swift | 132 ++++++---- .../CoreBluetooth/BluetoothManager.swift | 231 +++++++++++++----- .../CoreBluetooth/BluetoothPeripheral.swift | 11 +- .../Model/PersistentDevice.swift | 26 ++ Sources/SpeziBluetooth/Utils/Lazy.swift | 4 + 5 files changed, 291 insertions(+), 113 deletions(-) create mode 100644 Sources/SpeziBluetooth/Model/PersistentDevice.swift diff --git a/Sources/SpeziBluetooth/Bluetooth.swift b/Sources/SpeziBluetooth/Bluetooth.swift index 475bd533..9a51f3d0 100644 --- a/Sources/SpeziBluetooth/Bluetooth.swift +++ b/Sources/SpeziBluetooth/Bluetooth.swift @@ -189,11 +189,16 @@ import Spezi /// ### Bluetooth State /// - ``state`` /// - ``isScanning`` +/// - ``stateSubscription`` /// /// ### Nearby Devices /// - ``nearbyDevices(for:)`` /// - ``scanNearbyDevices(autoConnect:)`` /// - ``stopScanning()`` +/// +/// ### Manually Manage Powered State +/// - ``powerOn()`` +/// - ``powerOff()`` public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { @Observable class Storage { @@ -225,6 +230,15 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { bluetoothManager.state } + /// Subscribe to changes of the `state` property. + /// + /// Creates an `AsyncStream` that yields all **future** changes to the ``state`` property. + public var stateSubscription: AsyncStream { + bluetoothManager.assumeIsolated { manager in + manager.stateSubscription + } + } + /// Whether or not we are currently scanning for nearby devices. nonisolated public var isScanning: Bool { bluetoothManager.isScanning @@ -265,7 +279,9 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { /// Discover(ExampleDevice.self, by: .advertisedService(MyExampleService.self)) /// } /// ``` - public init( // TODO: docs devices + /// + /// - Parameter devices: The set of configured devices. + public init( @DiscoveryDescriptorBuilder _ devices: @Sendable () -> Set ) { let configuration = devices() @@ -273,7 +289,7 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { let discovery = ClosureRegistrar.$writeableView.withValue(.init()) { // we provide a closure registrar just to silence any out-of-band usage warnings! - configuration.parseDiscoveryDescription() // TODO: rename! + configuration.parseDiscoveryDescription() } let bluetoothManager = BluetoothManager() @@ -289,6 +305,31 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { } } + /// Request to power up the Bluetooth Central. + /// + /// This method manually instantiates the underlying Central Manager and ensure that it stays allocated. + /// Balance this call with a call to ``powerOff()``. + /// + /// - Note : The underlying `CBCentralManager` is lazily allocated and deallocated once it isn't needed anymore. + /// This is used to delay Bluetooth permission prompts to the latest possible moment avoiding to unexpectedly display power alerts. + public func powerOn() { + bluetoothManager.assumeIsolated { manager in + manager.powerOn() + } + } + + /// Request to power down the Bluetooth Central. + /// + /// This method request to power off the central. This is delay if the central is still used (e.g., currently scanning or connected peripherals). + /// + /// - Note : The underlying `CBCentralManager` is lazily allocated and deallocated once it isn't needed anymore. + /// This is used to delay Bluetooth permission prompts to the latest possible moment avoiding to unexpectedly display power alerts. + public func powerOff() { + bluetoothManager.assumeIsolated { manager in + manager.powerOff() + } + } + private func observeDiscoveredDevices() { self.assertIsolated("This didn't move to the actor even if it should.") bluetoothManager.assumeIsolated { manager in @@ -311,7 +352,7 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { // 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.assumeIsolated({ $0.discoveredPeripherals[uuid] }) else { + guard let peripheral = bluetoothManager.assumeIsolated({ $0.discoveredPeripherals[uuid] }) else { // TODO: this might be a retrieved device! return } @@ -337,10 +378,8 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { checkForConnected = true let device = nearbyDevices.removeValue(forKey: key) - // TODO: make everything weak and uninject everything once the BluetoothPeripheral itself is deinited? - if let device { // TODO: refactor out! - device.clearState(isolatedTo: self) - spezi.unloadModule(device) + if let device { + releaseDevice(device) } } @@ -352,20 +391,10 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { continue } - - let closures = ClosureRegistrar() - let device = ClosureRegistrar.$writeableView.withValue(closures) { - configuration.deviceType.init() - } - ClosureRegistrar.$readableView.withValue(closures) { - device.inject(peripheral: peripheral) - nearbyDevices[uuid] = device - } + let device = prepareDevice(configuration.deviceType, peripheral: peripheral) + nearbyDevices[uuid] = device checkForConnected = true - observePeripheralState(of: uuid) // register \.state onChange closure - - spezi.loadModule(device) } if checkForConnected { @@ -406,14 +435,17 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { } - // TODO: rename to something like "makePersistentDevice" or "withPersistentDevice" => communicates that you need to manually close device! - public func retrievePeripheral(for uuid: UUID, as device: Device.Type = Device.self) async -> Device? { - // TODO: this doesn't really need isolation? + // TODO: docs + public func makePersistentDevice( + for uuid: UUID, + as device: Device.Type = Device.self + ) async -> PersistentDevice? { + // TODO: forcing to have these devices known statically known in the configuration would be great! let configuration = ClosureRegistrar.$writeableView.withValue(.init()) { // we provide a closure registrar just to silence any out-of-band usage warnings! device.parseDeviceDescription() - // TODO: we could just save the device? + // TODO: we could just save the device instance? (or just retrieve it from the stored ones?) } guard let peripheral = await bluetoothManager.retrievePeripheral(for: uuid, with: configuration) else { @@ -421,26 +453,19 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { } - let closures = ClosureRegistrar() // TODO: code duplication!! - let device = ClosureRegistrar.$writeableView.withValue(closures) { - Device() - } - ClosureRegistrar.$readableView.withValue(closures) { - device.inject(peripheral: peripheral) - // TODO: nearbyDevices[uuid] = device - } - - observePeripheralState(of: uuid) // register \.state onChange closure - - spezi.loadModule(device) // TODO: spezi currently only allows one module of a type!!!! - handlePeripheralStateChange() + let device = prepareDevice(Device.self, peripheral: peripheral) + // TODO: the connectable devices must be known beforehand for the ConnectedDevices modifier to not flicker! - // TODO: we need to store them int he discoveredPeripherals to properly forward delegate methods!!! - // TODO: however, can we store them weak? => use deinit of the Device object to clean it up once the peripheral looses reference? - // TODO: we are also not hooking this thing up into the Bluetooth module system! + // TODO: is that required? + handlePeripheralStateChange() // ensure that we get notified about, e.g., a connected peripheral that is instantly removed - // TODO: this will instantly deinit now!!! BluetoothDevice has weak-only references now? - return device + // The semantics of retrievePeripheral is as follows: it returns a BluetoothPeripheral that is weakly allocated by the BluetoothManager.´ + // Therefore, the BluetoothPeripheral is owned by the caller and is automatically deallocated if the caller decides to not require the instance anymore. + // We want to replicate this behavior with the Bluetooth Module as well, however `BluetoothDevice`s do have reference cycles and require explicit + // deallocation. Therefore, we introduce this helper RAII structure `PersistentDevice` that equally moves into the ownership of the caller. + // If they happen to release their reference, the deinit of the class is called informing the Bluetooth Module of de-initialization, allowing us + // to clean up the underlying BluetoothDevice instance (removing all self references) and therefore allowing to deinit the underlying BluetoothPeripheral. + return PersistentDevice(self, device) // RAII } /// Scan for nearby bluetooth devices. @@ -507,4 +532,29 @@ extension Bluetooth: BluetoothScanner { } } +// MARK: - Device Handling + +extension Bluetooth { + func prepareDevice(_ device: Device.Type, peripheral: BluetoothPeripheral) -> Device { + let closures = ClosureRegistrar() + let device = ClosureRegistrar.$writeableView.withValue(closures) { + device.init() + } + ClosureRegistrar.$readableView.withValue(closures) { + device.inject(peripheral: peripheral) + } + + observePeripheralState(of: peripheral.id) // register \.state onChange closure + + spezi.loadModule(device) // TODO: spezi currently only allows one module of a type!!!! + + return device + } + + func releaseDevice(_ device: some BluetoothDevice) { + device.clearState(isolatedTo: self) + spezi.unloadModule(device) + } +} + // swiftlint:disable:this file_length diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift index 389c8955..6b4d06c1 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift @@ -64,11 +64,19 @@ import OSLog /// /// - ``state`` /// - ``isScanning`` +/// - ``stateSubscription`` /// /// ### Discovering nearby Peripherals /// - ``nearbyPeripherals`` /// - ``scanNearbyDevices(autoConnect:)`` /// - ``stopScanning()`` +/// +/// ### Retrieving known Peripherals +/// - ``retrievePeripheral(for:with:)`` +/// +/// ### Manually Manage Powered State +/// - ``powerOn()`` +/// - ``powerOff()`` public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable:this type_body_length private let logger = Logger(subsystem: "edu.stanford.spezi.bluetooth", category: "BluetoothManager") /// The serial executor for all Bluetooth related functionality. @@ -79,6 +87,12 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable private var isScanningObserver: KVOStateObserver? private let _storage: ObservableStorage + private var isolatedStorage: ObservableStorage { + _storage + } + + /// Flag indicating that we want the CBCentral to stay allocated. + private var keepPoweredOn = false /// Currently ongoing discovery session. private var discoverySession: DiscoverySession? @@ -91,46 +105,55 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable } /// Represents the current state of the Bluetooth Manager. - nonisolated public private(set) var state: BluetoothState { - get { - _storage.state - } - set { - _storage.state = newValue + nonisolated public var state: BluetoothState { + _storage.state + } + + /// Subscribe to changes of the `state` property. + /// + /// Creates an `AsyncStream` that yields all **future** changes to the ``state`` property. + public var stateSubscription: AsyncStream { + AsyncStream(BluetoothState.self) { continuation in + let id = isolatedStorage.subscribe(continuation) + continuation.onTermination = { @Sendable [weak self] _ in + guard let self = self else { + return + } + Task { @SpeziBluetooth in + await self.isolatedStorage.unsubscribe(for: id) + } + } } } + /// Whether or not we are currently scanning for nearby devices. - nonisolated public private(set) var isScanning: Bool { - get { - _storage.isScanning - } - set { - _storage.isScanning = newValue - } + nonisolated public var isScanning: Bool { + _storage.isScanning } + /// 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 { get { - _storage.discoveredPeripherals + isolatedStorage.discoveredPeripherals } _modify { - yield &_storage.discoveredPeripherals + yield &isolatedStorage.discoveredPeripherals } set { - _storage.discoveredPeripherals = newValue + isolatedStorage.discoveredPeripherals = newValue } } private(set) var retrievedPeripherals: OrderedDictionary> { get { - _storage.retrievedPeripherals + isolatedStorage.retrievedPeripherals } _modify { - yield &_storage.retrievedPeripherals + yield &isolatedStorage.retrievedPeripherals } set { - _storage.retrievedPeripherals = newValue + isolatedStorage.retrievedPeripherals = newValue } } @@ -182,6 +205,29 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable delegate.initManager(self) } + /// Request to power up the Bluetooth Central. + /// + /// This method manually instantiates the underlying Central Manager and ensure that it stays allocated. + /// Balance this call with a call to ``powerOff()``. + /// + /// - Note : The underlying `CBCentralManager` is lazily allocated and deallocated once it isn't needed anymore. + /// This is used to delay Bluetooth permission prompts to the latest possible moment avoiding to unexpectedly display power alerts. + public func powerOn() { + keepPoweredOn = true + _ = centralManager // ensure it is allocated + } + + /// Request to power down the Bluetooth Central. + /// + /// This method request to power off the central. This is delay if the central is still used (e.g., currently scanning or connected peripherals). + /// + /// - Note : The underlying `CBCentralManager` is lazily allocated and deallocated once it isn't needed anymore. + /// This is used to delay Bluetooth permission prompts to the latest possible moment avoiding to unexpectedly display power alerts. + public func powerOff() { + keepPoweredOn = false + checkForCentralDeinit() + } + /// Scan for nearby bluetooth devices. /// /// Scans on nearby devices based on the ``DiscoveryDescription`` provided in the initializer. @@ -241,7 +287,7 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable withServices: session.assumeIsolated { $0.serviceDiscoveryIds }, options: [CBCentralManagerScanOptionAllowDuplicatesKey: true] ) - isScanning = centralManager.isScanning // ensure this is propagated instantly + isolatedStorage.isScanning = centralManager.isScanning // ensure this is propagated instantly } private func _restartScanning(using session: DiscoverySession) { @@ -254,14 +300,14 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable withServices: session.assumeIsolated { $0.serviceDiscoveryIds }, options: [CBCentralManagerScanOptionAllowDuplicatesKey: true] ) - isScanning = centralManager.isScanning // ensure this is propagated instantly + isolatedStorage.isScanning = centralManager.isScanning // ensure this is propagated instantly } /// 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 + isolatedStorage.isScanning = centralManager.isScanning // ensure this is synced logger.debug("Scanning stopped") } @@ -295,12 +341,31 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable } - // TODO: docs: weak reference semantics! + /// Retrieve a known `BluetoothPeripheral` by its identifier. + /// + /// This queries the list of known `BluetoothPeripheral`s (e.g., recently paired peripherals). + /// + /// - Tip: You can use this method to connect to a known peripheral. Retrieve the peripheral using this method and call the ``BluetoothPeripheral/connect()`` method. + /// The `connect()` method doesn't time out and will make sure to connect to the peripheral once it is available without the need for continuous scanning. + /// + /// - Important: Make sure to keep a strong reference to the returned peripheral. The `BluetoothManager` only keeps a weak reference to the peripheral. + /// If you don't need the peripheral anymore, just dereference it. + /// + /// - Parameters: + /// - uuid: The peripheral identifier. + /// - description: The expected device configuration of the peripheral. This is used to discover service and characteristics if you connect to the peripheral- + /// - Returns: The retrieved Peripheral. Returns nil if the Bluetooth Central could not be powered on (e.g., not authorized) or if no peripheral with the requested identifier was found. public func retrievePeripheral(for uuid: UUID, with description: DeviceDescription) async -> BluetoothPeripheral? { - // TODO: only works if state is powered on => await poweredOn! - - // TODO: how should API users generally await for poweredOn state? => Module Events? - await awaitCentralPoweredOn() + if !_centralManager.isInitialized { + _ = centralManager // make sure central is initialized! + + // we are waiting for the next state transition, ideally to poweredOn state! + logger.debug("Waiting for CBCentral to power on, before retrieving peripheral.") + for await nextState in stateSubscription { + logger.debug("CBCentral state transitioned to state \(nextState)") + break + } + } guard case .poweredOn = centralManager.state else { logger.warning("Cannot retrieve peripheral with id \(uuid) while central is not powered on \(self.state)") @@ -331,28 +396,11 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable return device } - func knownPeripheral(for uuid: UUID) -> BluetoothPeripheral? { - // TODO: first check for retrieved peripherals? WE MUST maintain uniqueness! - if let peripheral = discoveredPeripherals[uuid] { - return peripheral - } - - guard let reference = retrievedPeripherals[uuid] else { - return nil - } - - guard let peripheral = reference.value else { - retrievedPeripherals.removeValue(forKey: uuid) - return nil - } - return peripheral - } - func onChange(of keyPath: KeyPath, perform closure: @escaping (Value) -> Void) { _storage.onChange(of: keyPath, perform: closure) } - func clearDiscoveredPeripheral(forKey id: UUID) { // TODO: access level is not private anymore??? + func clearDiscoveredPeripheral(forKey id: UUID) { if let peripheral = discoveredPeripherals[id] { // `handleDiscarded` must be called before actually removing it from the dictionary to make sure peripherals can react to this event peripheral.assumeIsolated { device in @@ -361,7 +409,6 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable // Users might keep reference to Peripheral object. Therefore, we keep it as a weak reference so we can forward delegate calls. retrievedPeripherals[id] = WeakReference(peripheral) - // TODO: when does Bluetooth Module uninject stuff? } discoveredPeripherals.removeValue(forKey: id) @@ -373,33 +420,60 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable checkForCentralDeinit() } - func handlePeripheralDeinit(id uuid: UUID) { - retrievedPeripherals.removeValue(forKey: uuid) // TODO: assert its the same instance? + fileprivate func knownPeripheral(for uuid: UUID) -> BluetoothPeripheral? { + if let peripheral = discoveredPeripherals[uuid] { + return peripheral + } - // TODO: also handle lastManuallyDisconnectedDevice?? + guard let reference = retrievedPeripherals[uuid] else { + return nil + } - checkForCentralDeinit() + guard let peripheral = reference.value else { + retrievedPeripherals.removeValue(forKey: uuid) + return nil + } + return peripheral } - private func awaitCentralPoweredOn() async { - _ = centralManager - try? await Task.sleep(for: .seconds(2)) + fileprivate func ensurePeripheralReference(_ peripheral: BluetoothPeripheral) { + guard retrievedPeripherals[peripheral.id] != nil else { + return // is not weakly referenced + } + + retrievedPeripherals[peripheral.id] = nil + discoveredPeripherals[peripheral.id] = peripheral + } + + /// The peripheral was finally deallocated. + /// + /// This method makes sure that all (weak) references to the de-initialized peripheral are fully cleared. + func handlePeripheralDeinit(id uuid: UUID) { + retrievedPeripherals.removeValue(forKey: uuid) + + discoverySession?.assumeIsolated { session in + session.clearManuallyDisconnectedDevice(for: uuid) + } - // TODO: somehow implement! + checkForCentralDeinit() } /// De-initializes the Bluetooth Central if we currently don't use it. private func checkForCentralDeinit() { + guard !keepPoweredOn else { + return // requested to stay allocated + } + guard discoverySession == nil else { - return + return // discovery is currently running } guard discoveredPeripherals.isEmpty && retrievedPeripherals.isEmpty else { - return // TODO: check for empty references? + return // there are still associated devices } _centralManager.destroy() - self.state = .unknown + isolatedStorage.state = .unknown } func connect(peripheral: BluetoothPeripheral) { @@ -456,22 +530,17 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable } } - private func isolatedUpdate(of keyPath: WritableKeyPath, _ value: Value) { - var manager = self - manager[keyPath: keyPath] = value - } - deinit { discoverySession = nil // non-isolated workaround for calling stopScanning() if isScanning { - isScanning = false + _storage.isScanning = false _centralManager.wrappedValue.stopScan() logger.debug("Scanning stopped") } - state = .unknown + _storage.state = .unknown _storage.discoveredPeripherals = [:] _storage.retrievedPeripherals = [:] centralDelegate = nil @@ -487,6 +556,10 @@ extension BluetoothManager { var state: BluetoothState = .unknown { didSet { _$simpleRegistrar.triggerDidChange(for: \.state, on: self) + + for continuation in subscribedContinuations.values { + continuation.yield(state) + } } } @@ -511,7 +584,29 @@ extension BluetoothManager { // swiftlint:disable:next identifier_name @ObservationIgnored var _$simpleRegistrar = ValueObservationRegistrar() + + private var subscribedContinuations: [UUID: AsyncStream.Continuation] = [:] + init() {} + + + func subscribe(_ continuation: AsyncStream.Continuation) -> UUID { + let id = UUID() + subscribedContinuations[id] = continuation + return id + } + + func unsubscribe(for id: UUID) { + subscribedContinuations[id] = nil + } + + + deinit { + for continuation in subscribedContinuations.values { + continuation.finish() + } + subscribedContinuations.removeAll() + } } } @@ -519,7 +614,7 @@ extension BluetoothManager: KVOReceiver { func observeChange(of keyPath: KeyPath, value: V) { switch keyPath { case \CBCentralManager.isScanning: - self.isolatedUpdate(of: \.isScanning, value as! Bool) // swiftlint:disable:this force_cast + isolatedStorage.isScanning = value as! Bool // swiftlint:disable:this force_cast if !self.isScanning { self.handleStoppedScanning() } @@ -545,7 +640,6 @@ extension BluetoothManager: BluetoothScanner { partialResult = partialResult || (peripheral.unsafeState.state != .disconnected) } || _storage.retrievedPeripherals.values.reduce(into: false, { partialResult, reference in // swiftlint:disable:previous reduce_boolean - // TODO: observation of weak reference, does that work? if let peripheral = reference.value { partialResult = partialResult || (peripheral.unsafeState.state != .disconnected) } @@ -619,7 +713,7 @@ extension BluetoothManager { // order and make sure to capture all important state before that. Task { @SpeziBluetooth in await manager.isolated { manager in - manager.isolatedUpdate(of: \.state, state) + manager.isolatedStorage.state = state logger.info("BluetoothManager central state is now \(manager.state)") if case .poweredOn = state { @@ -672,6 +766,9 @@ extension BluetoothManager { device.update(advertisement: data, rssi: rssi.intValue) } + // we might have discovered a previously "retrieved" peripheral that must be strongly referenced now + manager.ensurePeripheralReference(device) + session.assumeIsolated { session in session.deviceDiscoveryPostAction(device: device, newlyDiscovered: false) } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift index 7f7c3c63..dce7e4f9 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift @@ -201,7 +201,7 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ /// For devices that were found through nearby device search, this property indicates that the device was discarded /// as it was considered stale and no new advertisement was received. This also happens when such a devices disconnects and no new /// advertisement is received. - nonisolated public private(set) var discarded: Bool { // TODO: revise docs for retrieved peripherals + nonisolated public private(set) var discarded: Bool { get { _storage.discarded } @@ -320,6 +320,9 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ // ensure that it is updated instantly. self.isolatedUpdate(of: \.state, PeripheralState(from: peripheral.state)) + if discarded { + self.isolatedUpdate(of: \.discarded, false) + } logger.debug("Discovering services for \(self.peripheral.debugIdentifier) ...") let services = requestedCharacteristics.map { Array($0.keys) } @@ -360,9 +363,6 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ } func handleDiscarded() { - guard !discarded else { - return - } isolatedUpdate(of: \.discarded, true) } @@ -684,12 +684,13 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ deinit { if !_storage.discarded { // make sure peripheral gets discarded + self.logger.debug("Discarding de-initialized peripheral \(self.id), \(self.name ?? "unnamed")") _storage.update(discarded: true) // TODO: test that this works for retrieved peripherals! } guard let manager else { - self.logger.warning("Orphaned device \(self.id), \(self.name ?? "unnamed") was deinitialized") + self.logger.warning("Orphaned device \(self.id), \(self.name ?? "unnamed") was de-initialized") return } diff --git a/Sources/SpeziBluetooth/Model/PersistentDevice.swift b/Sources/SpeziBluetooth/Model/PersistentDevice.swift new file mode 100644 index 00000000..3059721c --- /dev/null +++ b/Sources/SpeziBluetooth/Model/PersistentDevice.swift @@ -0,0 +1,26 @@ +// +// 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 +// + + +public class PersistentDevice { + private let bluetooth: Bluetooth + public let device: Device + + init(_ bluetooth: Bluetooth, _ device: Device) { + self.bluetooth = bluetooth + self.device = device + } + + deinit { + let bluetooth = bluetooth + let device = device + Task { @SpeziBluetooth in + await bluetooth.releaseDevice(device) + } + } +} diff --git a/Sources/SpeziBluetooth/Utils/Lazy.swift b/Sources/SpeziBluetooth/Utils/Lazy.swift index 25b85b44..6cc7574d 100644 --- a/Sources/SpeziBluetooth/Utils/Lazy.swift +++ b/Sources/SpeziBluetooth/Utils/Lazy.swift @@ -30,6 +30,10 @@ class Lazy { } + var isInitialized: Bool { + storedValue != nil + } + /// Support lazy initialization of lazy property. init() {} From 2f3bd4ec8bda6d2311e128a65d3abc84c7c11a29 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Sat, 22 Jun 2024 20:06:51 +0200 Subject: [PATCH 24/58] Docs and other refinements --- README.md | 5 +- Sources/SpeziBluetooth/Bluetooth.swift | 78 +++++++++++++++---- .../CoreBluetooth/BluetoothManager.swift | 28 ++++++- .../Model/PersistentDevice.swift | 9 ++- .../SpeziBluetooth.docc/SpeziBluetooth.md | 10 ++- .../Utils/ConnectedDevices.swift | 2 + .../BluetoothServices.md | 2 +- 7 files changed, 105 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index d43bfb7a..445dca89 100644 --- a/README.md +++ b/README.md @@ -145,9 +145,10 @@ class ExampleDelegate: SpeziAppDelegate { Once you have the `Bluetooth` module configured within your Spezi app, you can access the module within your [`Environment`](https://developer.apple.com/documentation/swiftui/environment). -You can use the [`scanNearbyDevices(enabled:with:autoConnect:)`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/swiftui/view/scanNearbyDevices(enabled:with:autoConnect:)) and [`autoConnect(enabled:with:)`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/swiftui/view/autoConnect(enabled:with:)) +You can use the [`scanNearbyDevices(enabled:with:minimumRSSI:advertisementStaleInterval:autoConnect:)`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/swiftui/view/scanNearbyDevices(enabled:with:minimumRSSI:advertisementStaleInterval:autoConnect:)) +and [`autoConnect(enabled:with:minimumRSSI:advertisementStaleInterval:)`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/swiftui/view/autoConnect(enabled:with:minimumRSSI:advertisementStaleInterval:)) modifiers to scan for nearby devices and/or auto connect to the first available device. Otherwise, you can also manually start and stop scanning for nearby devices -using [`scanNearbyDevices(autoConnect:)`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetooth/scanNearbyDevices(autoConnect:)) and [`stopScanning()`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetooth/stopScanning()). +using [`scanNearbyDevices(minimumRSSI:advertisementStaleInterval:autoConnect:)`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetooth/scanNearbyDevices(minimumRSSI:advertisementStaleInterval:autoConnect:)) and [`stopScanning()`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetooth/stopScanning()). To retrieve the list of nearby devices you may use [`nearbyDevices(for:)`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetooth/nearbyDevices(for:)). diff --git a/Sources/SpeziBluetooth/Bluetooth.swift b/Sources/SpeziBluetooth/Bluetooth.swift index 9a51f3d0..5c29585c 100644 --- a/Sources/SpeziBluetooth/Bluetooth.swift +++ b/Sources/SpeziBluetooth/Bluetooth.swift @@ -10,6 +10,9 @@ import OrderedCollections import OSLog import Spezi +// TODO: re-generate docc bundle! +// TODO: update code examples with scanNearbyDevices? + /// Connect and communicate with Bluetooth devices using modern programming paradigms. /// @@ -92,9 +95,10 @@ import Spezi /// Once you have the `Bluetooth` module configured within your Spezi app, you can access the module within your /// [`Environment`](https://developer.apple.com/documentation/swiftui/environment). /// -/// You can use the ``SwiftUI/View/scanNearbyDevices(enabled:with:autoConnect:)`` and ``SwiftUI/View/autoConnect(enabled:with:)`` +/// You can use the ``SwiftUI/View/scanNearbyDevices(enabled:with:minimumRSSI:advertisementStaleInterval:autoConnect:)`` +/// and ``SwiftUI/View/autoConnect(enabled:with:minimumRSSI:advertisementStaleInterval:)`` /// modifiers to scan for nearby devices and/or auto connect to the first available device. Otherwise, you can also manually start and stop scanning for nearby devices -/// using ``scanNearbyDevices(autoConnect:)`` and ``stopScanning()``. +/// using ``scanNearbyDevices(minimumRSSI:advertisementStaleInterval:autoConnect:)`` and ``stopScanning()``. /// /// To retrieve the list of nearby devices you may use ``nearbyDevices(for:)``. /// @@ -184,7 +188,8 @@ import Spezi /// ## Topics /// /// ### Configure the Bluetooth Module -/// - ``init(minimumRSSI:advertisementStaleInterval:_:)`` +/// - ``init(_:)`` +/// - ``configuration`` /// /// ### Bluetooth State /// - ``state`` @@ -193,9 +198,13 @@ import Spezi /// /// ### Nearby Devices /// - ``nearbyDevices(for:)`` -/// - ``scanNearbyDevices(autoConnect:)`` +/// - ``scanNearbyDevices(minimumRSSI:advertisementStaleInterval:autoConnect:)`` /// - ``stopScanning()`` /// +/// ### Persistent Devices +/// - ``makePersistentDevice(for:as:)`` +/// - ``makePersistentDevice(from:)`` +/// /// ### Manually Manage Powered State /// - ``powerOn()`` /// - ``powerOff()`` @@ -260,6 +269,12 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { } } + /// Nearby devices that should be retained as persistent devices. + /// + /// Devices that are currently in the list of nearby devices but shouldn't be cleared once they leave the nearby devices list as + /// they have been converted to a persistent device using ``makePersistentDevice(from:)``. + private var nearbyDevicesFlaggedForPersistence: Set = [] + @Application(\.spezi) private var spezi @@ -333,7 +348,6 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { private func observeDiscoveredDevices() { self.assertIsolated("This didn't move to the actor even if it should.") bluetoothManager.assumeIsolated { manager in - // TODO: support retrievedPeripherals manager.onChange(of: \.discoveredPeripherals) { [weak self] discoveredDevices in guard let self = self else { return @@ -345,6 +359,10 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { bluetooth.handleUpdatedNearbyDevicesChange(discoveredDevices) } } + + // we currently do not track the `retrievedPeripherals` collection of the BluetoothManager. The assumption is that + // `retrievePeripheral` is always called through the `Bluetooth` module so we are aware of everything anyways. + // And we don't care about the rest. } } @@ -352,7 +370,7 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { // 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.assumeIsolated({ $0.discoveredPeripherals[uuid] }) else { // TODO: this might be a retrieved device! + guard let peripheral = bluetoothManager.assumeIsolated({ $0.knownPeripherals[uuid] }) else { return } @@ -378,8 +396,8 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { checkForConnected = true let device = nearbyDevices.removeValue(forKey: key) - if let device { - releaseDevice(device) + if let device, !nearbyDevicesFlaggedForPersistence.contains(key) { + releaseDevice(device, with: key) } } @@ -405,11 +423,12 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { private func handlePeripheralStateChange() { // check for active connected device - let connectedDevices = bluetoothManager.assumeIsolated { $0.discoveredPeripherals } + let connectedDevices = bluetoothManager.assumeIsolated { $0.knownPeripherals } .filter { _, value in value.assumeIsolated { $0.state } == .connected } .compactMap { key, _ in + // TODO: we need a set of persistent devices! (key, nearbyDevices[key]) // map them to their devices class } .reduce(into: [:]) { result, tuple in @@ -440,12 +459,26 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { for uuid: UUID, as device: Device.Type = Device.self ) async -> PersistentDevice? { - // TODO: forcing to have these devices known statically known in the configuration would be great! + if let anyNearbyDevice = nearbyDevices[uuid] { + guard let nearbyDevice = anyNearbyDevice as? Device else { + preconditionFailure(""" + Tried to make persistent device for nearby device with differing types. \ + Found \(type(of: anyNearbyDevice)), requested \(Device.self) + """) + } + return makePersistentDevice(from: nearbyDevice) + } + + // This condition is fine, every device type that wants to be paired has to be discovered at least once. + // This helps also with building the `ConnectedDevices` statically and have the SwiftUI view hierarchy not re-rendered every time. + precondition( + configuration.contains(where: { $0.deviceType == device }), + "Tried to make persistent device for non-configured device class \(Device.self)" + ) + let configuration = ClosureRegistrar.$writeableView.withValue(.init()) { // we provide a closure registrar just to silence any out-of-band usage warnings! device.parseDeviceDescription() - - // TODO: we could just save the device instance? (or just retrieve it from the stored ones?) } guard let peripheral = await bluetoothManager.retrievePeripheral(for: uuid, with: configuration) else { @@ -454,9 +487,9 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { let device = prepareDevice(Device.self, peripheral: peripheral) - // TODO: the connectable devices must be known beforehand for the ConnectedDevices modifier to not flicker! - // TODO: is that required? + + observePeripheralState(of: uuid) // ensure we observe state changes of these devices! handlePeripheralStateChange() // ensure that we get notified about, e.g., a connected peripheral that is instantly removed // The semantics of retrievePeripheral is as follows: it returns a BluetoothPeripheral that is weakly allocated by the BluetoothManager.´ @@ -465,7 +498,19 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { // deallocation. Therefore, we introduce this helper RAII structure `PersistentDevice` that equally moves into the ownership of the caller. // If they happen to release their reference, the deinit of the class is called informing the Bluetooth Module of de-initialization, allowing us // to clean up the underlying BluetoothDevice instance (removing all self references) and therefore allowing to deinit the underlying BluetoothPeripheral. - return PersistentDevice(self, device) // RAII + return PersistentDevice(self, device, uuid) // RAII + } + + public func makePersistentDevice(from device: Device) -> PersistentDevice { // TODO: docs + guard let (id, _) = nearbyDevices.first(where: { _, value in + ObjectIdentifier(value) == ObjectIdentifier(device) + }) else { + preconditionFailure("Tried to convert device to persistent device for a device we couldn't locate in the list of nearby devices.") + } + + nearbyDevicesFlaggedForPersistence.insert(id) + + return PersistentDevice(self, device, id) } /// Scan for nearby bluetooth devices. @@ -551,7 +596,8 @@ extension Bluetooth { return device } - func releaseDevice(_ device: some BluetoothDevice) { + func releaseDevice(_ device: some BluetoothDevice, with id: UUID) { + nearbyDevicesFlaggedForPersistence.remove(id) device.clearState(isolatedTo: self) spezi.unloadModule(device) } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift index 6b4d06c1..7a21ac3f 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift @@ -45,12 +45,13 @@ import OSLog /// /// ### Searching for nearby devices /// -/// You can scan for nearby devices using the ``scanNearbyDevices(autoConnect:)`` and stop scanning with ``stopScanning()``. +/// You can scan for nearby devices using the ``scanNearbyDevices(discovery:minimumRSSI:advertisementStaleInterval:autoConnect:)`` and stop scanning with ``stopScanning()``. /// All discovered peripherals will be populated through the ``nearbyPeripherals`` properties. /// /// Refer to the documentation of ``BluetoothPeripheral`` on how to interact with a Bluetooth peripheral. /// -/// - Tip: You can also use the ``SwiftUI/View/scanNearbyDevices(enabled:with:autoConnect:)`` and ``SwiftUI/View/autoConnect(enabled:with:)`` +/// - Tip: You can also use the ``SwiftUI/View/scanNearbyDevices(enabled:with:discovery:minimumRSSI:advertisementStaleInterval:autoConnect:)`` +/// and ``SwiftUI/View/autoConnect(enabled:with:discovery:minimumRSSI:advertisementStaleInterval:)`` /// modifiers within your SwiftUI view to automatically manage device scanning and/or auto connect to the /// first available device. /// @@ -58,7 +59,7 @@ import OSLog /// /// ### Create a Bluetooth Manager /// -/// - ``init(devices:minimumRSSI:advertisementStaleInterval:)`` +/// - ``init()`` /// /// ### Bluetooth State /// @@ -68,7 +69,7 @@ import OSLog /// /// ### Discovering nearby Peripherals /// - ``nearbyPeripherals`` -/// - ``scanNearbyDevices(autoConnect:)`` +/// - ``scanNearbyDevices(discovery:minimumRSSI:advertisementStaleInterval:autoConnect:)`` /// - ``stopScanning()`` /// /// ### Retrieving known Peripherals @@ -157,6 +158,25 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable } } + /// The combined collection of `discoveredPeripherals` and `retrievedPeripherals`. + /// + /// Don't store this dictionary as this will accidentally reference retrieved peripherals strongly. + var knownPeripherals: OrderedDictionary { + let keysAndValues = retrievedPeripherals.elements + .map { ($0, $1.value) } + .compactMap { id, value in + if let value { + return (id, value) + } + return nil + } + + return discoveredPeripherals.merging(keysAndValues) { lhs, rhs in + assertionFailure("Peripheral was present in both, discovered and retrieved set, lhs: \(lhs), rhs: \(rhs)") + return lhs + } + } + /// Initialize a new Bluetooth Manager with provided device description and optional configuration options. public init() { diff --git a/Sources/SpeziBluetooth/Model/PersistentDevice.swift b/Sources/SpeziBluetooth/Model/PersistentDevice.swift index 3059721c..d6f20521 100644 --- a/Sources/SpeziBluetooth/Model/PersistentDevice.swift +++ b/Sources/SpeziBluetooth/Model/PersistentDevice.swift @@ -6,21 +6,26 @@ // SPDX-License-Identifier: MIT // +import Foundation + +// TODO: dynamic member lookup? public class PersistentDevice { private let bluetooth: Bluetooth public let device: Device + private let peripheralId: UUID - init(_ bluetooth: Bluetooth, _ device: Device) { + init(_ bluetooth: Bluetooth, _ device: Device, _ id: UUID) { self.bluetooth = bluetooth self.device = device + self.peripheralId = id } deinit { let bluetooth = bluetooth let device = device Task { @SpeziBluetooth in - await bluetooth.releaseDevice(device) + await bluetooth.releaseDevice(device, with: peripheralId) } } } diff --git a/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md b/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md index c36b08de..c091c004 100644 --- a/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md +++ b/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md @@ -136,9 +136,10 @@ class ExampleDelegate: SpeziAppDelegate { Once you have the `Bluetooth` module configured within your Spezi app, you can access the module within your [`Environment`](https://developer.apple.com/documentation/swiftui/environment). -You can use the ``SwiftUI/View/scanNearbyDevices(enabled:with:autoConnect:)`` and ``SwiftUI/View/autoConnect(enabled:with:)`` +You can use the ``SwiftUI/View/scanNearbyDevices(enabled:with:minimumRSSI:advertisementStaleInterval:autoConnect:)`` +and ``SwiftUI/View/autoConnect(enabled:with:minimumRSSI:advertisementStaleInterval:)`` modifiers to scan for nearby devices and/or auto connect to the first available device. Otherwise, you can also manually start and stop scanning for nearby devices -using ``Bluetooth/scanNearbyDevices(autoConnect:)`` and ``Bluetooth/stopScanning()``. +using ``Bluetooth/scanNearbyDevices(minimumRSSI:advertisementStaleInterval:autoConnect:)`` and ``Bluetooth/stopScanning()``. To retrieve the list of nearby devices you may use ``Bluetooth/nearbyDevices(for:)``. @@ -249,8 +250,8 @@ due to their async nature. ### Discovering nearby devices -- ``SwiftUI/View/scanNearbyDevices(enabled:with:autoConnect:)`` -- ``SwiftUI/View/autoConnect(enabled:with:)`` +- ``SwiftUI/View/scanNearbyDevices(enabled:with:minimumRSSI:advertisementStaleInterval:autoConnect:)`` +- ``SwiftUI/View/autoConnect(enabled:with:minimumRSSI:advertisementStaleInterval:)`` ### Declaring a Bluetooth Device @@ -271,6 +272,7 @@ due to their async nature. - ``PeripheralState`` - ``BluetoothError`` - ``AdvertisementData`` +- ``ManufacturerIdentifier`` - ``WriteType`` ### Configuring Core Bluetooth diff --git a/Sources/SpeziBluetooth/Utils/ConnectedDevices.swift b/Sources/SpeziBluetooth/Utils/ConnectedDevices.swift index b39a8cd9..8131692f 100644 --- a/Sources/SpeziBluetooth/Utils/ConnectedDevices.swift +++ b/Sources/SpeziBluetooth/Utils/ConnectedDevices.swift @@ -15,6 +15,8 @@ class ConnectedDevices { @MainActor private var connectedDevices: [ObjectIdentifier: any BluetoothDevice] = [:] @MainActor private var connectedDeviceIds: [ObjectIdentifier: UUID] = [:] + // TODO: provide access to the list of connected devices? + @MainActor func update(with devices: [UUID: any BluetoothDevice]) { diff --git a/Sources/SpeziBluetoothServices/BluetoothServices.docc/BluetoothServices.md b/Sources/SpeziBluetoothServices/BluetoothServices.docc/BluetoothServices.md index f2acf2b0..059bf8c5 100644 --- a/Sources/SpeziBluetoothServices/BluetoothServices.docc/BluetoothServices.md +++ b/Sources/SpeziBluetoothServices/BluetoothServices.docc/BluetoothServices.md @@ -1,4 +1,4 @@ -# ``BluetoothServices`` +# ``SpeziBluetoothServices`` Reusable Bluetooth Service and Characteristic implementations. From 722f00ff577276e00074390cee213bcb81d15d64 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Sun, 23 Jun 2024 21:12:47 +0200 Subject: [PATCH 25/58] Fully implement how BluetoothDevice reference and retrieved devices are handled --- Package.swift | 2 +- README.md | 19 ++ Sources/SpeziBluetooth/Bluetooth.swift | 192 +++++++++++------- .../CoreBluetooth/BluetoothManager.swift | 25 ++- .../CoreBluetooth/BluetoothPeripheral.swift | 26 +-- .../Model/PeripheralStorage.swift | 23 ++- .../CoreBluetooth/Model/WriteType.swift | 2 +- .../Utilities/BluetoothActor.swift | 12 +- .../Model/PersistentDevice.swift | 31 --- .../Model/Properties/Characteristic.swift | 9 +- .../Model/Properties/DeviceAction.swift | 10 +- .../Model/Properties/DeviceState.swift | 6 +- .../CharacteristicAccessor.swift | 73 +++---- .../CharacteristicPeripheralInjection.swift | 64 +++--- .../PropertySupport/ClosureRegistrar.swift | 39 ---- .../DeviceActionPeripheralInjection.swift | 24 +++ .../PropertySupport/DeviceStateAccessor.swift | 59 +++--- .../DeviceStatePeripheralInjection.swift | 63 ++---- .../ClearStateDeviceVisitor.swift | 47 ----- .../SemanticModel/SetupDeviceVisitor.swift | 45 +++- .../SpeziBluetooth.docc/SpeziBluetooth.md | 19 ++ .../Utils/ChangeClosureState.swift | 39 ---- .../Utils/ChangeSubscriptions.swift | 95 +++++++++ .../Utils/ConnectedDevices.swift | 1 + Sources/SpeziBluetooth/Utils/Reference.swift | 17 ++ 25 files changed, 519 insertions(+), 423 deletions(-) delete mode 100644 Sources/SpeziBluetooth/Model/PersistentDevice.swift delete mode 100644 Sources/SpeziBluetooth/Model/PropertySupport/ClosureRegistrar.swift create mode 100644 Sources/SpeziBluetooth/Model/PropertySupport/DeviceActionPeripheralInjection.swift delete mode 100644 Sources/SpeziBluetooth/Model/SemanticModel/ClearStateDeviceVisitor.swift delete mode 100644 Sources/SpeziBluetooth/Utils/ChangeClosureState.swift create mode 100644 Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift diff --git a/Package.swift b/Package.swift index 1a9dccbd..55b1b470 100644 --- a/Package.swift +++ b/Package.swift @@ -27,7 +27,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "1.1.0"), - .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.3.0"), + .package(url: "https://github.com/StanfordSpezi/Spezi", branch: "feature/externally-managed-modules"), .package(url: "https://github.com/StanfordSpezi/SpeziNetworking", from: "2.0.1"), .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"), diff --git a/README.md b/README.md index 445dca89..6de63ba6 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,25 @@ struct MyView: View { } ``` +#### Retrieving Devices + +The previous section explained how to discover nearby devices and retrieve the currently connected one from the environment. +This is great ad-hoc connection establishment with devices currently nearby. +However, this might not be the most efficient approach, if you want to connect to a specific, previously paired device. +In these situations you can use the [`retrieveDevice(for:as:)`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetooth/retrieveDevice(for:as:)) method to retrieve a known device. + +Below is a short code example illustrating this method. + +```swift +let id: UUID = ... // a Bluetooth peripheral identifier (e.g., previously retrieved when pairing the device) + +let device = bluetooth.retrieveDevice(for: id, as: MyDevice.self) + +await device.connect() // assume declaration of @DeviceAction(\.connect) + +// Connect doesn't time out. Connection with the device will be established as soon as the device is in reach. +``` + ### Integration with Spezi Modules A Spezi [`Module`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module) is a great way of structuring your application into diff --git a/Sources/SpeziBluetooth/Bluetooth.swift b/Sources/SpeziBluetooth/Bluetooth.swift index 5c29585c..166e8af9 100644 --- a/Sources/SpeziBluetooth/Bluetooth.swift +++ b/Sources/SpeziBluetooth/Bluetooth.swift @@ -8,11 +8,9 @@ import OrderedCollections import OSLog +@_spi(APISupport) import Spezi -// TODO: re-generate docc bundle! -// TODO: update code examples with scanNearbyDevices? - /// Connect and communicate with Bluetooth devices using modern programming paradigms. /// @@ -148,6 +146,27 @@ import Spezi /// } /// ``` /// +/// // TODO: tip to how to use connected devices list (e.g., we could also do that for nearby devices?) +/// +/// #### Retrieving Devices +/// +/// The previous section explained how to discover nearby devices and retrieve the currently connected one from the environment. +/// This is great ad-hoc connection establishment with devices currently nearby. +/// However, this might not be the most efficient approach, if you want to connect to a specific, previously paired device. +/// In these situations you can use the ``retrieveDevice(for:as:)`` method to retrieve a known device. +/// +/// Below is a short code example illustrating this method. +/// +/// ```swift +/// let id: UUID = ... // a Bluetooth peripheral identifier (e.g., previously retrieved when pairing the device) +/// +/// let device = bluetooth.retrieveDevice(for: id, as: MyDevice.self) +/// +/// await device.connect() // assume declaration of @DeviceAction(\.connect) +/// +/// // Connect doesn't time out. Connection with the device will be established as soon as the device is in reach. +/// ``` +/// /// ### Integration with Spezi Modules /// /// A Spezi [`Module`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module) is a great way of structuring your application into @@ -201,9 +220,8 @@ import Spezi /// - ``scanNearbyDevices(minimumRSSI:advertisementStaleInterval:autoConnect:)`` /// - ``stopScanning()`` /// -/// ### Persistent Devices -/// - ``makePersistentDevice(for:as:)`` -/// - ``makePersistentDevice(from:)`` +/// ### Retrieve Devices +/// - ``retrieveDevice(for:as:)`` /// /// ### Manually Manage Powered State /// - ``powerOn()`` @@ -269,11 +287,12 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { } } - /// Nearby devices that should be retained as persistent devices. + /// Dictionary of all initialized devices. /// - /// Devices that are currently in the list of nearby devices but shouldn't be cleared once they leave the nearby devices list as - /// they have been converted to a persistent device using ``makePersistentDevice(from:)``. - private var nearbyDevicesFlaggedForPersistence: Set = [] + /// Devices might be part of `nearbyDevices` as well or just retrieved devices that are eventually connected. + /// Values are stored weakly. All properties (like `@Characteristic`, `@DeviceState` or `@DeviceAction`) store a reference to `Bluetooth` and report once they are de-initialized + /// to clear the respective initialized devices from this dictionary. + private var initializedDevices: OrderedDictionary = [:] @Application(\.spezi) private var spezi @@ -300,12 +319,9 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { @DiscoveryDescriptorBuilder _ devices: @Sendable () -> Set ) { let configuration = devices() - let deviceTypes = configuration.deviceTypes - let discovery = ClosureRegistrar.$writeableView.withValue(.init()) { - // we provide a closure registrar just to silence any out-of-band usage warnings! - configuration.parseDiscoveryDescription() - } + let deviceTypes = configuration.deviceTypes + let discovery = configuration.parseDiscoveryDescription() let bluetoothManager = BluetoothManager() @@ -394,22 +410,29 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { // remove all delete keys for key in nearbyDevices.keys where discoveredDevices[key] == nil { checkForConnected = true - let device = nearbyDevices.removeValue(forKey: key) - if let device, !nearbyDevicesFlaggedForPersistence.contains(key) { - releaseDevice(device, with: key) - } + nearbyDevices.removeValue(forKey: key) + + // device instances will be automatically deallocated via `notifyDeviceDeinit` } // add devices for new keys for (uuid, peripheral) in discoveredDevices where nearbyDevices[uuid] == nil { - let advertisementData = peripheral.advertisementData - guard let configuration = configuration.find(for: advertisementData, logger: logger) else { - logger.warning("Ignoring peripheral \(peripheral.debugDescription) that cannot be mapped to a device class.") - continue + let device: any BluetoothDevice + + // check if we already now the device! + if let persistentDevice = initializedDevices[uuid]?.anyValue { + device = persistentDevice + } else { + let advertisementData = peripheral.advertisementData + guard let configuration = configuration.find(for: advertisementData, logger: logger) else { + logger.warning("Ignoring peripheral \(peripheral.debugDescription) that cannot be mapped to a device class.") + continue + } + + device = prepareDevice(id: uuid, configuration.deviceType, peripheral: peripheral) } - let device = prepareDevice(configuration.deviceType, peripheral: peripheral) nearbyDevices[uuid] = device checkForConnected = true @@ -427,9 +450,12 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { .filter { _, value in value.assumeIsolated { $0.state } == .connected } - .compactMap { key, _ in - // TODO: we need a set of persistent devices! - (key, nearbyDevices[key]) // map them to their devices class + .compactMap { key, _ -> (UUID, any BluetoothDevice)? in + // map them to their devices class + guard let device = initializedDevices[key]?.anyValue else { + return nil + } + return (key, device) } .reduce(into: [:]) { result, tuple in result[tuple.0] = tuple.1 @@ -443,7 +469,7 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { /// Retrieve nearby devices. /// - /// Use this method to retrieve nearby discovered Bluetooth peripherals. This method will only + /// Use this method to retrieve nearby discovered Bluetooth devices. This method will only /// 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. @@ -454,19 +480,32 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { } - // TODO: docs - public func makePersistentDevice( + /// Retrieve a known `BluetoothDevice` by its identifier. + /// + /// This method queries the list of known ``BluetoothDevice``s (e.g., paired devices). + /// + /// - Tip: You can use this method to connect to a known device. Retrieve the device using this method and use the ``DeviceActions/connect`` action. + /// The `connect` action doesn't time out and will make sure to connect to the device once it is available without the need for continuous scanning. + /// + /// - Important: Make sure to keep a strong reference to the returned device. The `Bluetooth` module only keeps a weak reference to the device. + /// If you don't need the device anymore, ``DeviceActions/disconnect`` and dereference it. + /// + /// - Parameters: + /// - uuid: The Bluetooth peripheral identifier. + /// - device: The device type to use for the peripheral. + /// - Returns: The retrieved device. Returns nil if the Bluetooth Central could not be powered on (e.g., not authorized) or if no peripheral with the requested identifier was found. + public func retrieveDevice( for uuid: UUID, as device: Device.Type = Device.self - ) async -> PersistentDevice? { - if let anyNearbyDevice = nearbyDevices[uuid] { - guard let nearbyDevice = anyNearbyDevice as? Device else { + ) async -> Device? { + if let anyDevice = initializedDevices[uuid]?.anyValue { + guard let device = anyDevice as? Device else { preconditionFailure(""" Tried to make persistent device for nearby device with differing types. \ - Found \(type(of: anyNearbyDevice)), requested \(Device.self) + Found \(type(of: anyDevice)), requested \(Device.self) """) } - return makePersistentDevice(from: nearbyDevice) + return device } // This condition is fine, every device type that wants to be paired has to be discovered at least once. @@ -476,21 +515,14 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { "Tried to make persistent device for non-configured device class \(Device.self)" ) - let configuration = ClosureRegistrar.$writeableView.withValue(.init()) { - // we provide a closure registrar just to silence any out-of-band usage warnings! - device.parseDeviceDescription() - } + let configuration = device.parseDeviceDescription() guard let peripheral = await bluetoothManager.retrievePeripheral(for: uuid, with: configuration) else { return nil } - let device = prepareDevice(Device.self, peripheral: peripheral) - - - observePeripheralState(of: uuid) // ensure we observe state changes of these devices! - handlePeripheralStateChange() // ensure that we get notified about, e.g., a connected peripheral that is instantly removed + let device = prepareDevice(id: uuid, Device.self, peripheral: peripheral) // The semantics of retrievePeripheral is as follows: it returns a BluetoothPeripheral that is weakly allocated by the BluetoothManager.´ // Therefore, the BluetoothPeripheral is owned by the caller and is automatically deallocated if the caller decides to not require the instance anymore. @@ -498,19 +530,7 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { // deallocation. Therefore, we introduce this helper RAII structure `PersistentDevice` that equally moves into the ownership of the caller. // If they happen to release their reference, the deinit of the class is called informing the Bluetooth Module of de-initialization, allowing us // to clean up the underlying BluetoothDevice instance (removing all self references) and therefore allowing to deinit the underlying BluetoothPeripheral. - return PersistentDevice(self, device, uuid) // RAII - } - - public func makePersistentDevice(from device: Device) -> PersistentDevice { // TODO: docs - guard let (id, _) = nearbyDevices.first(where: { _, value in - ObjectIdentifier(value) == ObjectIdentifier(device) - }) else { - preconditionFailure("Tried to convert device to persistent device for a device we couldn't locate in the list of nearby devices.") - } - - nearbyDevicesFlaggedForPersistence.insert(id) - - return PersistentDevice(self, device, id) + return device } /// Scan for nearby bluetooth devices. @@ -580,26 +600,60 @@ extension Bluetooth: BluetoothScanner { // MARK: - Device Handling extension Bluetooth { - func prepareDevice(_ device: Device.Type, peripheral: BluetoothPeripheral) -> Device { - let closures = ClosureRegistrar() - let device = ClosureRegistrar.$writeableView.withValue(closures) { - device.init() - } - ClosureRegistrar.$readableView.withValue(closures) { - device.inject(peripheral: peripheral) + func prepareDevice(id uuid: UUID, _ device: Device.Type, peripheral: BluetoothPeripheral) -> Device { + let device = device.init() + + let didInjectAnything = device.inject(peripheral: peripheral, using: self) + if didInjectAnything { + initializedDevices[uuid] = device.weaklyReference + } else { + logger.warning( + """ + \(Device.self) is an empty device implementation. \ + The same peripheral might be instantiated via multiple \(Device.self) instances if not device property wrappers like + @Characteristic, @DeviceState or @DeviceAction is used. + """ + ) } + observePeripheralState(of: peripheral.id) // register \.state onChange closure - spezi.loadModule(device) // TODO: spezi currently only allows one module of a type!!!! + + precondition(!(device is EnvironmentAccessible), "Cannot load BluetoothDevice \(Device.self) that conforms to \(EnvironmentAccessible.self)!") + + + // We load the module with external ownership. Meaning, Spezi won't keep any strong references to the Module and deallocation of + // the module is possible, freeing all Spezi related resources. + spezi.loadModule(device, ownership: .external) // implicitly calls the configure() method once everything is injected return device } - func releaseDevice(_ device: some BluetoothDevice, with id: UUID) { - nearbyDevicesFlaggedForPersistence.remove(id) - device.clearState(isolatedTo: self) - spezi.unloadModule(device) + + nonisolated func notifyDeviceDeinit(for uuid: UUID) { + Task { @SpeziBluetooth in + await _notifyDeviceDeinit(for: uuid) + } + } + + + private func _notifyDeviceDeinit(for uuid: UUID) { + precondition(nearbyDevices[uuid] == nil, "\(#function) was wrongfully called for a device that is still referenced: \(uuid)") + + // this clears our weak reference that we use to reuse already created device class once they connect + let removedEntry = initializedDevices.removeValue(forKey: uuid) + + if let removedEntry { + logger.debug("\(removedEntry.typeName) device was de-initialized and removed from the Bluetooth module.") + } + } +} + + +extension BluetoothDevice { + fileprivate var weaklyReference: AnyWeakDeviceReference { + WeakReference(self) } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift index 7a21ac3f..ecbe5c14 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift @@ -28,7 +28,9 @@ import OSLog /// Below is a short code example to discover devices with a Heart Rate service. /// /// ```swift -/// let manager = BluetoothManager(devices [ +/// let manager = BluetoothManager() +/// +/// manager.scanNearbyDevices(discovery: devices [ /// DeviceDescription(discoverBy: .advertisedService("180D"), services: [ /// ServiceDescription(serviceId: "180D", characteristics: [ /// "2A37", // heart rate measurement @@ -37,8 +39,6 @@ import OSLog /// ]) /// ]) /// ]) -/// -/// manager.scanNearbyDevices() /// // ... /// manager.stopScanning() /// ``` @@ -363,16 +363,16 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable /// Retrieve a known `BluetoothPeripheral` by its identifier. /// - /// This queries the list of known `BluetoothPeripheral`s (e.g., recently paired peripherals). + /// This method queries the list of known ``BluetoothPeripheral``s (e.g., paired peripherals). /// /// - Tip: You can use this method to connect to a known peripheral. Retrieve the peripheral using this method and call the ``BluetoothPeripheral/connect()`` method. /// The `connect()` method doesn't time out and will make sure to connect to the peripheral once it is available without the need for continuous scanning. /// /// - Important: Make sure to keep a strong reference to the returned peripheral. The `BluetoothManager` only keeps a weak reference to the peripheral. - /// If you don't need the peripheral anymore, just dereference it. + /// If you don't need the peripheral anymore, ``BluetoothPeripheral/disconnect()`` and dereference it. /// /// - Parameters: - /// - uuid: The peripheral identifier. + /// - uuid: The Bluetooth peripheral identifier. /// - description: The expected device configuration of the peripheral. This is used to discover service and characteristics if you connect to the peripheral- /// - Returns: The retrieved Peripheral. Returns nil if the Bluetooth Central could not be powered on (e.g., not authorized) or if no peripheral with the requested identifier was found. public func retrievePeripheral(for uuid: UUID, with description: DeviceDescription) async -> BluetoothPeripheral? { @@ -522,6 +522,15 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable } } + private func handledConnected(device: BluetoothPeripheral) { + device.assumeIsolated { device in + device.handleConnect() + } + + // we might have connected a bluetooth peripheral that was weakly referenced + ensurePeripheralReference(device) + } + private func discardDevice(device: BluetoothPeripheral) { if !isScanning { device.assumeIsolated { device in @@ -831,9 +840,7 @@ extension BluetoothManager { } logger.debug("Peripheral \(peripheral.debugIdentifier) connected.") - device.assumeIsolated { device in - device.handleConnect() - } + manager.handledConnected(device: device) } } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift index dce7e4f9..6fbfdbc2 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift @@ -31,7 +31,7 @@ enum CharacteristicOnChangeHandler { /// - ``state`` /// - ``rssi`` /// - ``advertisementData`` -/// - ``discarded`` +/// - ``nearby`` /// - ``lastActivity`` /// /// ### Accessing Services @@ -196,17 +196,16 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ } } - /// Indicates that the peripheral was discarded. + /// Indicates that the peripheral is nearby. /// - /// For devices that were found through nearby device search, this property indicates that the device was discarded - /// as it was considered stale and no new advertisement was received. This also happens when such a devices disconnects and no new - /// advertisement is received. - nonisolated public private(set) var discarded: Bool { + /// A device is nearby if either we consider it discovered because we are currently scanning or the device is connected. + nonisolated public private(set) var nearby: Bool { get { - _storage.discarded + // TODO: we also consider them nearby currently while connecting => we need to clear discovered devices that are currently connecting? + _storage.nearby } set { - _storage.update(discarded: newValue) + _storage.update(nearby: newValue) } } @@ -320,9 +319,6 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ // ensure that it is updated instantly. self.isolatedUpdate(of: \.state, PeripheralState(from: peripheral.state)) - if discarded { - self.isolatedUpdate(of: \.discarded, false) - } logger.debug("Discovering services for \(self.peripheral.debugIdentifier) ...") let services = requestedCharacteristics.map { Array($0.keys) } @@ -363,7 +359,7 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ } func handleDiscarded() { - isolatedUpdate(of: \.discarded, true) + isolatedUpdate(of: \.nearby, false) } func markLastActivity(_ lastActivity: Date = .now) { @@ -374,6 +370,7 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ self.isolatedUpdate(of: \.localName, advertisement.localName) self.isolatedUpdate(of: \.advertisementData, advertisement) self.isolatedUpdate(of: \.rssi, rssi) + self.isolatedUpdate(of: \.nearby, true) } /// Determines if the device is considered stale. @@ -683,9 +680,8 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ } deinit { - if !_storage.discarded { // make sure peripheral gets discarded - self.logger.debug("Discarding de-initialized peripheral \(self.id), \(self.name ?? "unnamed")") - _storage.update(discarded: true) // TODO: test that this works for retrieved peripherals! + if !_storage.nearby { // make sure signal is sent + _storage.update(nearby: false) } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift index f7230d59..1d6e10a3 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift @@ -50,9 +50,9 @@ final class PeripheralStorage: ValueObservable { } } - private(set) var discarded: Bool { + private(set) var nearby: Bool { didSet { - _$simpleRegistrar.triggerDidChange(for: \.discarded, on: self) + _$simpleRegistrar.triggerDidChange(for: \.nearby, on: self) } } @@ -77,7 +77,7 @@ final class PeripheralStorage: ValueObservable { self.advertisementData = advertisementData self.rssi = rssi self.state = .init(from: state) - self.discarded = false + self.nearby = false self.lastActivity = lastActivity } @@ -105,20 +105,27 @@ final class PeripheralStorage: ValueObservable { func update(state: PeripheralState) { if self.state != state { - if self.state == .connecting && state == .connected { - return // we set connected on our own! + // we set connected on our own! See `signalFullyDiscovered` + if !(self.state == .connecting && state == .connected) { + self.state = state } - self.state = state + } + + if !nearby && (self.state == .connecting || self.state == .connected) { + self.nearby = true } } - func update(discarded: Bool) { - self.discarded = discarded + func update(nearby: Bool) { + if nearby != self.nearby { + self.nearby = nearby + } } func signalFullyDiscovered() { if state == .connecting { state = .connected + update(state: .connected) // ensure other logic is called as well } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/WriteType.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/WriteType.swift index e7f29f96..2f8964e3 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/WriteType.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/WriteType.swift @@ -7,7 +7,7 @@ // -/// Determine the type of Bluetooth write operation. +/// Determine the type of a Bluetooth write operation. public enum WriteType { /// A write expecting an acknowledgment. case withResponse diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothActor.swift b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothActor.swift index 262479ec..92f51887 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothActor.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothActor.swift @@ -11,7 +11,9 @@ import Foundation protocol BluetoothActor: Actor { nonisolated var bluetoothQueue: DispatchSerialQueue { get } - func isolated(perform: (isolated Self) -> Void) + func isolated(perform: (isolated Self) throws -> Void) rethrows + + func isolated(perform: (isolated Self) async throws -> Void) async rethrows } extension BluetoothActor { @@ -20,7 +22,11 @@ extension BluetoothActor { bluetoothQueue.asUnownedSerialExecutor() } - func isolated(perform: (isolated Self) -> Void) { - perform(self) + func isolated(perform: (isolated Self) throws -> Void) rethrows { + try perform(self) + } + + func isolated(perform: (isolated Self) async throws -> Void) async rethrows { + try await perform(self) } } diff --git a/Sources/SpeziBluetooth/Model/PersistentDevice.swift b/Sources/SpeziBluetooth/Model/PersistentDevice.swift deleted file mode 100644 index d6f20521..00000000 --- a/Sources/SpeziBluetooth/Model/PersistentDevice.swift +++ /dev/null @@ -1,31 +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 - - -// TODO: dynamic member lookup? -public class PersistentDevice { - private let bluetooth: Bluetooth - public let device: Device - private let peripheralId: UUID - - init(_ bluetooth: Bluetooth, _ device: Device, _ id: UUID) { - self.bluetooth = bluetooth - self.device = device - self.peripheralId = id - } - - deinit { - let bluetooth = bluetooth - let device = device - Task { @SpeziBluetooth in - await bluetooth.releaseDevice(device, with: peripheralId) - } - } -} diff --git a/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift b/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift index ed5759c1..b98cbb01 100644 --- a/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift +++ b/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift @@ -211,19 +211,16 @@ public final class Characteristic: @unchecked Sendable { } - func inject(peripheral: BluetoothPeripheral, serviceId: CBUUID, service: GATTService?) { + func inject(bluetooth: Bluetooth, 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 injection = CharacteristicPeripheralInjection( + bluetooth: bluetooth, peripheral: peripheral, serviceId: serviceId, characteristicId: configuration.id, value: _value, - characteristic: characteristic, - onChangeClosure: onChangeClosure + characteristic: characteristic ) // mutual access with `CharacteristicAccessor/enableNotifications` diff --git a/Sources/SpeziBluetooth/Model/Properties/DeviceAction.swift b/Sources/SpeziBluetooth/Model/Properties/DeviceAction.swift index 7704d83e..57e4460b 100644 --- a/Sources/SpeziBluetooth/Model/Properties/DeviceAction.swift +++ b/Sources/SpeziBluetooth/Model/Properties/DeviceAction.swift @@ -56,14 +56,14 @@ /// - ``DeviceActions`` @propertyWrapper public final class DeviceAction: @unchecked Sendable { - private weak var peripheral: BluetoothPeripheral? + private var injection: DeviceActionPeripheralInjection? /// Support injection of closures for testing support. private let _injectedClosure = Box(nil) /// Access the device action. public var wrappedValue: Action { - guard let peripheral else { + guard let injection else { if let injectedClosure = _injectedClosure.value { return Action(.injected(injectedClosure)) } @@ -75,7 +75,7 @@ public final class DeviceAction: @unchecked """ ) } - return Action(.peripheral(peripheral)) + return Action(.peripheral(injection.peripheral)) } /// Retrieve a temporary accessors instance. @@ -89,8 +89,8 @@ public final class DeviceAction: @unchecked public init(_ keyPath: KeyPath) {} - func inject(peripheral: BluetoothPeripheral) { - self.peripheral = peripheral + func inject(bluetooth: Bluetooth, peripheral: BluetoothPeripheral) { + self.injection = DeviceActionPeripheralInjection(bluetooth: bluetooth, peripheral: peripheral) } } diff --git a/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift b/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift index a6404d67..ae8f545e 100644 --- a/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift +++ b/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift @@ -121,10 +121,8 @@ public final class DeviceState: @unchecked Sendable { } - func inject(peripheral: BluetoothPeripheral) { - let changeClosure = ClosureRegistrar.readableView?.retrieve(for: objectId, value: Value.self) - - let injection = DeviceStatePeripheralInjection(peripheral: peripheral, keyPath: keyPath, onChangeClosure: changeClosure) + func inject(bluetooth: Bluetooth, peripheral: BluetoothPeripheral) { + let injection = DeviceStatePeripheralInjection(bluetooth: bluetooth, peripheral: peripheral, keyPath: keyPath) self.injection = injection injection.assumeIsolated { injection in diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift index 7ebfc71d..05ed3cb5 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift @@ -9,6 +9,7 @@ import ByteCoding import CoreBluetooth + struct CharacteristicTestInjections { var writeClosure: ((Value, WriteType) async throws -> Void)? var readClosure: (() async throws -> Value)? @@ -114,43 +115,53 @@ extension CharacteristicAccessor where Value: ByteDecodable { } + /// Retrieve a subscription to changes to the characteristic value. + /// + /// This property creates an AsyncStream that yields all future updates to the characteristic value. + public var subscription: AsyncStream { + guard let injection else { + preconditionFailure( + "The `subscription` of a @Characteristic cannot be accessed within the initializer. Defer access to the `configure() method" + ) + } + return injection.newSubscription() + } + + /// 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 + /// Register a change handler with the characteristic that is called every time the value changes. + /// + /// Note that you cannot set up onChange handlers within the initializers. + /// Use the [`configure()`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module/configure()-5pa83) to set up + /// all your handlers. + /// - Important: You must capture `self` weakly only. Capturing `self` strongly causes a memory leak. + /// + /// - Note: 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. /// - Parameters: /// - initial: Whether the action should be run with the initial characteristic value. /// Otherwise, the action will only run strictly if the value changes. /// - action: The change handler to register. - public func onChange(initial: Bool = false, perform action: @escaping (Value) async -> Void) { - let closure = OnChangeClosure(initial: initial, closure: action) - + public func onChange(initial: Bool = false, @_implicitSelfCapture perform action: @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: closure) - return + preconditionFailure( + """ + Register onChange(perform:) inside the initializer is not supported anymore. \ + Further, they no longer support capturing `self` without causing a memory leak. \ + Please migrate your code to register onChange listeners in the `configure()` method and make sure to weakly capture self. + + func configure() { + $state.onChange { [weak self] value in + self?.handleStateChange(value) + } + } + """ + ) } - // global actor ensures these tasks are queued serially and are executed in order. - Task { @SpeziBluetooth in - await injection.setOnChangeClosure(closure) - } + injection.newOnChangeSubscription(initial: initial, perform: action) } @@ -160,16 +171,6 @@ extension CharacteristicAccessor where Value: ByteDecodable { 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 } diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift index a8d15154..7349656b 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift @@ -24,6 +24,7 @@ private protocol PrimitiveDecodableCharacteristic { actor CharacteristicPeripheralInjection: BluetoothActor { let bluetoothQueue: DispatchSerialQueue + private let bluetooth: Bluetooth fileprivate let peripheral: BluetoothPeripheral let serviceId: CBUUID let characteristicId: CBUUID @@ -38,8 +39,14 @@ actor CharacteristicPeripheralInjection: BluetoothActor { /// Fore more information see ``ControlPointCharacteristic``. private var controlPointTransaction: ControlPointTransaction? - /// The user supplied onChange closure we use to forward notifications. - private var onChangeClosure: ChangeClosureState + /// Manages the user supplied subscriptions to the value. + private let subscriptions = ChangeSubscriptions() + /// We track all onChange closure registrations with `initial=false` to make sure to not call them with the initial value. + /// The property is set to nil, once the initial value arrived. + /// + /// The initial value might only arrive later (e.g., only once the device is connected). Therefore, we need to keep track what handlers to call and which not while we are still waiting. + private var nonInitialChangeHandlers: Set? = [] // swiftlint:disable:this discouraged_optional_collection + /// 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. @@ -70,20 +77,20 @@ actor CharacteristicPeripheralInjection: BluetoothActor { init( + bluetooth: Bluetooth, peripheral: BluetoothPeripheral, serviceId: CBUUID, characteristicId: CBUUID, value: ObservableBox, - characteristic: GATTCharacteristic?, - onChangeClosure: OnChangeClosure? + characteristic: GATTCharacteristic? ) { + self.bluetooth = bluetooth 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. @@ -109,28 +116,26 @@ actor CharacteristicPeripheralInjection: BluetoothActor { } } - /// 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! + nonisolated func newSubscription() -> AsyncStream { + subscriptions.newSubscription() } + nonisolated func newOnChangeSubscription(initial: Bool, perform action: @escaping (Value) async -> Void) { + let id = subscriptions.newOnChangeSubscription(perform: action) - func setOnChangeClosure(_ closure: OnChangeClosure) { - if case .cleared = onChangeClosure { - // object is about to be cleared. Make sure we don't create a self reference last minute. - return + Task { @SpeziBluetooth in + await handleInitialCall(id: id, initial: initial, action: action) } - self.onChangeClosure = .value(closure) + } - // if configured as initial, and there is a value, we notify - if let value, closure.initial { - Task { @SpeziBluetooth in - await closure(value) + private func handleInitialCall(id: UUID, initial: Bool, action: (Value) async -> Void) async { + if nonInitialChangeHandlers != nil { + if !initial { + nonInitialChangeHandlers?.insert(id) } + } else if initial, let value { + // nonInitialChangeHandlers is nil, meaning the initial value already arrived and we can call the action instantly if they wanted that + await action(value) } } @@ -213,14 +218,9 @@ actor CharacteristicPeripheralInjection: BluetoothActor { } } - private func dispatchChangeHandler(previous previousValue: Value?, new newValue: Value, with onChangeClosure: ChangeClosureState) async { - guard case let .value(closure) = onChangeClosure else { - return - } - if closure.initial || previousValue != nil { - await closure(newValue) - } + deinit { + bluetooth.notifyDeviceDeinit(for: peripheral.id) } } @@ -233,15 +233,11 @@ extension CharacteristicPeripheralInjection: DecodableCharacteristic where Value return } - let previousValue = self.value self.value = value - self.fullFillControlPointRequest(value) - let onChangeClosure = onChangeClosure // make sure we capture it now, not later where it might have changed. - Task { @SpeziBluetooth in - await self.dispatchChangeHandler(previous: previousValue, new: value, with: onChangeClosure) - } + self.subscriptions.notifySubscribers(with: value, ignoring: nonInitialChangeHandlers ?? []) + nonInitialChangeHandlers = nil } else { self.value = nil } diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/ClosureRegistrar.swift b/Sources/SpeziBluetooth/Model/PropertySupport/ClosureRegistrar.swift deleted file mode 100644 index dcd47fd5..00000000 --- a/Sources/SpeziBluetooth/Model/PropertySupport/ClosureRegistrar.swift +++ /dev/null @@ -1,39 +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 - - -/// Tracking notification closure registrations for ``Characteristic`` when peripheral is not available yet. -final class ClosureRegistrar { - struct Entry { - let closure: OnChangeClosure - } - - // 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: OnChangeClosure) { - registrations[object] = Entry(closure: closure) - } - - func retrieve(for object: ObjectIdentifier, value: Value.Type = Value.self) -> OnChangeClosure? { - guard let optionalEntry = registrations[object], - let entry = optionalEntry as? Entry else { - return nil - } - return entry.closure - } -} diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceActionPeripheralInjection.swift b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceActionPeripheralInjection.swift new file mode 100644 index 00000000..e9f28ac8 --- /dev/null +++ b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceActionPeripheralInjection.swift @@ -0,0 +1,24 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +class DeviceActionPeripheralInjection { + private let bluetooth: Bluetooth + let peripheral: BluetoothPeripheral + + + init(bluetooth: Bluetooth, peripheral: BluetoothPeripheral) { + self.bluetooth = bluetooth + self.peripheral = peripheral + } + + + deinit { + bluetooth.notifyDeviceDeinit(for: peripheral.id) + } +} diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift index c3fc943d..ca80674a 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift @@ -27,43 +27,56 @@ public struct DeviceStateAccessor { self.injection = injection self._injectedValue = injectedValue } +} + +extension DeviceStateAccessor { + /// Retrieve a subscription to changes to the device state. + /// + /// This property creates an AsyncStream that yields all future updates to the device state. + public var subscription: AsyncStream { + guard let injection else { + preconditionFailure( + "The `subscription` of a @DeviceState cannot be accessed within the initializer. Defer access to the `configure() method" + ) + } + return injection.newSubscription() + } /// 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 + /// Register a change handler with the device state that is called every time the value changes. + /// + /// Note that you cannot set up onChange handlers within the initializers. + /// Use the [`configure()`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module/configure()-5pa83) to set up + /// all your handlers. + /// - Important: You must capture `self` weakly only. Capturing `self` strongly causes a memory leak. + /// + /// - Note: 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. /// - Parameters: /// - initial: Whether the action should be run with the initial state value. Otherwise, the action will only run /// strictly if the value changes. /// - action: The change handler to register. public func onChange(initial: Bool = false, perform action: @escaping (Value) async -> Void) { - let closure = OnChangeClosure(initial: initial, closure: action) - 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: closure) - return - } + preconditionFailure( + """ + Register onChange(perform:) inside the initializer is not supported anymore. \ + Further, they no longer support capturing `self` without causing a memory leak. \ + Please migrate your code to register onChange listeners in the `configure()` method and make sure to weakly capture self. - // global actor ensures these tasks are queued serially and are executed in order. - Task { @SpeziBluetooth in - await injection.setOnChangeClosure(closure) + func configure() { + $state.onChange { [weak self] value in + self?.handleStateChange(value) + } + } + """ + ) } + + injection.newOnChangeSubscription(initial: initial, perform: action) } } diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift index 41707f07..f13e805c 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift @@ -12,22 +12,23 @@ import Foundation actor DeviceStatePeripheralInjection: BluetoothActor { let bluetoothQueue: DispatchSerialQueue + private let bluetooth: Bluetooth private let peripheral: BluetoothPeripheral private let accessKeyPath: KeyPath private let observationKeyPath: KeyPath? - private var onChangeClosure: ChangeClosureState + private let subscriptions = ChangeSubscriptions() nonisolated var value: Value { peripheral[keyPath: accessKeyPath] } - init(peripheral: BluetoothPeripheral, keyPath: KeyPath, onChangeClosure: OnChangeClosure?) { + init(bluetooth: Bluetooth, peripheral: BluetoothPeripheral, keyPath: KeyPath) { + self.bluetooth = bluetooth self.bluetoothQueue = peripheral.bluetoothQueue self.peripheral = peripheral self.accessKeyPath = keyPath self.observationKeyPath = keyPath.storageEquivalent() - self.onChangeClosure = onChangeClosure.map { .value($0) } ?? .none } func setup() { @@ -39,8 +40,6 @@ actor DeviceStatePeripheralInjection: BluetoothActor { return } - dispatchOnChangeWithInitialValue() - peripheral.assumeIsolated { peripheral in peripheral.onChange(of: observationKeyPath) { [weak self] value in guard let self = self else { @@ -50,53 +49,31 @@ actor DeviceStatePeripheralInjection: BluetoothActor { 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) - } + self.subscriptions.notifySubscribers(with: value) } } } } - /// Returns once the change handler completes. - private func dispatchChangeHandler(_ value: Value, with onChangeClosure: ChangeClosureState, isInitial: Bool = false) async { - guard case let .value(closure) = onChangeClosure else { - return - } - - if closure.initial || !isInitial { - await closure(value) - } + nonisolated func newSubscription() -> AsyncStream { + subscriptions.newSubscription() } - func setOnChangeClosure(_ closure: OnChangeClosure) { - 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) - dispatchOnChangeWithInitialValue() - } + nonisolated func newOnChangeSubscription(initial: Bool, perform action: @escaping (Value) async -> Void) { + subscriptions.newOnChangeSubscription(perform: action) - private func dispatchOnChangeWithInitialValue() { - // For most values, this just delivers a nil value (e.g., name or localName). - // However, there might be a use case to retrieve the initial value for the deviceState or advertisement data. - let value = peripheral[keyPath: accessKeyPath] - Task { @SpeziBluetooth in - await dispatchChangeHandler(value, with: onChangeClosure, isInitial: true) + if initial { + let value = peripheral[keyPath: accessKeyPath] + Task { @SpeziBluetooth in + await self.isolated { _ in + await action(value) + } + } } } - /// 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 + deinit { + bluetooth.notifyDeviceDeinit(for: peripheral.id) } } @@ -117,8 +94,8 @@ extension KeyPath where Root == BluetoothPeripheral { \PeripheralStorage.state case \.services: \PeripheralStorage.services - case \.discarded: - \PeripheralStorage.discarded + case \.nearby: + \PeripheralStorage.nearby case \.lastActivity: \PeripheralStorage.lastActivity case \.id: diff --git a/Sources/SpeziBluetooth/Model/SemanticModel/ClearStateDeviceVisitor.swift b/Sources/SpeziBluetooth/Model/SemanticModel/ClearStateDeviceVisitor.swift deleted file mode 100644 index cee7f6b4..00000000 --- a/Sources/SpeziBluetooth/Model/SemanticModel/ClearStateDeviceVisitor.swift +++ /dev/null @@ -1,47 +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 - - -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 ff55e437..12f484b7 100644 --- a/Sources/SpeziBluetooth/Model/SemanticModel/SetupDeviceVisitor.swift +++ b/Sources/SpeziBluetooth/Model/SemanticModel/SetupDeviceVisitor.swift @@ -10,37 +10,48 @@ import CoreBluetooth private struct SetupServiceVisitor: ServiceVisitor { + private let bluetooth: Bluetooth private let peripheral: BluetoothPeripheral private let serviceId: CBUUID private let service: GATTService? + private let didInjectAnything: Box - init(peripheral: BluetoothPeripheral, serviceId: CBUUID, service: GATTService?) { + init(bluetooth: Bluetooth, peripheral: BluetoothPeripheral, serviceId: CBUUID, service: GATTService?, didInjectAnything: Box) { + self.bluetooth = bluetooth self.peripheral = peripheral self.serviceId = serviceId self.service = service + self.didInjectAnything = didInjectAnything } func visit(_ characteristic: Characteristic) { - characteristic.inject(peripheral: peripheral, serviceId: serviceId, service: service) + characteristic.inject(bluetooth: bluetooth, peripheral: peripheral, serviceId: serviceId, service: service) + didInjectAnything.value = true } func visit(_ action: DeviceAction) { - action.inject(peripheral: peripheral) + action.inject(bluetooth: bluetooth, peripheral: peripheral) + didInjectAnything.value = true } func visit(_ state: DeviceState) { - state.inject(peripheral: peripheral) + state.inject(bluetooth: bluetooth, peripheral: peripheral) + didInjectAnything.value = true } } private struct SetupDeviceVisitor: DeviceVisitor { + private let bluetooth: Bluetooth private let peripheral: BluetoothPeripheral + private let didInjectAnything: Box - init(peripheral: BluetoothPeripheral) { + init(bluetooth: Bluetooth, peripheral: BluetoothPeripheral, didInjectAnything: Box) { + self.bluetooth = bluetooth self.peripheral = peripheral + self.didInjectAnything = didInjectAnything } @@ -48,24 +59,38 @@ private struct SetupDeviceVisitor: DeviceVisitor { let blService = peripheral.assumeIsolated { $0.getService(id: service.id) } service.inject(peripheral: peripheral, service: blService) - var visitor = SetupServiceVisitor(peripheral: peripheral, serviceId: service.id, service: blService) + var visitor = SetupServiceVisitor( + bluetooth: bluetooth, + peripheral: peripheral, + serviceId: service.id, + service: blService, + didInjectAnything: didInjectAnything + ) service.wrappedValue.accept(&visitor) } func visit(_ action: DeviceAction) { - action.inject(peripheral: peripheral) + action.inject(bluetooth: bluetooth, peripheral: peripheral) + didInjectAnything.value = true } func visit(_ state: DeviceState) { - state.inject(peripheral: peripheral) + state.inject(bluetooth: bluetooth, peripheral: peripheral) + didInjectAnything.value = true } } extension BluetoothDevice { - func inject(peripheral: BluetoothPeripheral) { + func inject(peripheral: BluetoothPeripheral, using bluetooth: Bluetooth) -> Bool { peripheral.bluetoothQueue.assertIsolated("SetupDeviceVisitor must be called within the Bluetooth SerialExecutor!") - var visitor = SetupDeviceVisitor(peripheral: peripheral) + + // if we don't inject anything, we do not need to retain the device + let didInjectAnything = Box(false) + + var visitor = SetupDeviceVisitor(bluetooth: bluetooth, peripheral: peripheral, didInjectAnything: didInjectAnything) accept(&visitor) + + return didInjectAnything.value } } diff --git a/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md b/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md index c091c004..56bd277e 100644 --- a/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md +++ b/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md @@ -189,6 +189,25 @@ struct MyView: View { } ``` +#### Retrieving Devices + +The previous section explained how to discover nearby devices and retrieve the currently connected one from the environment. +This is great ad-hoc connection establishment with devices currently nearby. +However, this might not be the most efficient approach, if you want to connect to a specific, previously paired device. +In these situations you can use the ``Bluetooth/retrieveDevice(for:as:)`` method to retrieve a known device. + +Below is a short code example illustrating this method. + +```swift +let id: UUID = ... // a Bluetooth peripheral identifier (e.g., previously retrieved when pairing the device) + +let device = bluetooth.retrieveDevice(for: id, as: MyDevice.self) + +await device.connect() // assume declaration of @DeviceAction(\.connect) + +// Connect doesn't time out. Connection with the device will be established as soon as the device is in reach. +``` + ### Integration with Spezi Modules A Spezi [`Module`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module) is a great way of structuring your application into diff --git a/Sources/SpeziBluetooth/Utils/ChangeClosureState.swift b/Sources/SpeziBluetooth/Utils/ChangeClosureState.swift deleted file mode 100644 index 25d69a75..00000000 --- a/Sources/SpeziBluetooth/Utils/ChangeClosureState.swift +++ /dev/null @@ -1,39 +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 -// - - -/// A onChange closure instance. -struct OnChangeClosure { - /// The initial flag indicates if the closure should be called with the initial value - /// or strictly only if the value changes. - let initial: Bool - private let closure: (Value) async -> Void - - - init(initial: Bool, closure: @escaping (Value) async -> Void) { - self.initial = initial - self.closure = closure - } - - - func callAsFunction(_ value: Value) async { - await closure(value) - } -} - - -/// State model for an onChange closure property. -enum ChangeClosureState { - /// The is no onChange closure registered. - case none - /// The onChange closure value. - case value(OnChangeClosure) - /// The onChange closure was cleared (e.g., upon a disconnect). - /// This signals that there must not be any new onChange closure registrations to avoid reference cycles. - case cleared -} diff --git a/Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift b/Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift new file mode 100644 index 00000000..f557f12b --- /dev/null +++ b/Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift @@ -0,0 +1,95 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import OrderedCollections + + +class ChangeSubscriptions: @unchecked Sendable { + private struct Registration { + let subscription: AsyncStream + let id: UUID + } + + private var continuations: OrderedDictionary.Continuation> = [:] + private var taskHandles: [UUID: Task] = [:] + private let lock = NSLock() + + init() {} + + func notifySubscribers(with value: Value, ignoring: Set = []) { + for (id, continuation) in continuations where !ignoring.contains(id) { + continuation.yield(value) + } + } + + private func _newSubscription() -> Registration { + let id = UUID() + let stream = AsyncStream { continuation in + lock.withLock { + continuations[id] = continuation + } + + continuation.onTermination = { [weak self] _ in + guard let self else { + return + } + + lock.withLock { + _ = self.continuations.removeValue(forKey: id) + } + } + } + + return Registration(subscription: stream, id: id) + } + + func newSubscription() -> AsyncStream { + _newSubscription().subscription + } + + @discardableResult + func newOnChangeSubscription(perform action: @escaping (Value) async -> Void) -> UUID { + let registration = _newSubscription() + + let task = Task { @SpeziBluetooth [weak self] in + for await element in registration.subscription { + guard self != nil else { + return + } + + await action(element) + } + + self?.lock.withLock { + _ = self?.taskHandles.removeValue(forKey: registration.id) + } + } + + lock.withLock { + taskHandles[registration.id] = task + } + + return registration.id + } + + deinit { + lock.withLock { + for continuation in continuations.values { + continuation.finish() + } + + for task in taskHandles.values { + task.cancel() + } + + continuations.removeAll() + taskHandles.removeAll() + } + } +} diff --git a/Sources/SpeziBluetooth/Utils/ConnectedDevices.swift b/Sources/SpeziBluetooth/Utils/ConnectedDevices.swift index 8131692f..bb029622 100644 --- a/Sources/SpeziBluetooth/Utils/ConnectedDevices.swift +++ b/Sources/SpeziBluetooth/Utils/ConnectedDevices.swift @@ -29,6 +29,7 @@ class ConnectedDevices { // add newly connected devices that are not injected yet for (uuid, device) in devices { guard connectedDevices[device.typeIdentifier] == nil else { + // TODO: just inject all in order! continue // already present, we just inject the first device of a particular type into the environment } diff --git a/Sources/SpeziBluetooth/Utils/Reference.swift b/Sources/SpeziBluetooth/Utils/Reference.swift index 83d002a0..22095201 100644 --- a/Sources/SpeziBluetooth/Utils/Reference.swift +++ b/Sources/SpeziBluetooth/Utils/Reference.swift @@ -6,6 +6,12 @@ // SPDX-License-Identifier: MIT // +protocol AnyWeakDeviceReference { + var anyValue: (any BluetoothDevice)? { get } + + var typeName: String { get } +} + struct WeakReference { weak var value: Value? @@ -14,3 +20,14 @@ struct WeakReference { self.value = value } } + + +extension WeakReference: AnyWeakDeviceReference where Value: BluetoothDevice { + var anyValue: (any BluetoothDevice)? { + value + } + + var typeName: String { + "\(Value.self)" + } +} From 81734bdd4971c15759c7ce0a095b36f20f76133a Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Sun, 23 Jun 2024 23:07:50 +0200 Subject: [PATCH 26/58] Support retrieiving all connected devices from the environment --- README.md | 13 ++- Sources/SpeziBluetooth/Bluetooth.swift | 4 +- .../CoreBluetooth/BluetoothManager.swift | 29 +++---- .../CoreBluetooth/BluetoothPeripheral.swift | 1 - .../ConnectedDevicesEnvironmentModifier.swift | 16 +++- .../SpeziBluetooth.docc/SpeziBluetooth.md | 3 + .../Utils/ConnectedDevices.swift | 79 +++++++++++-------- .../Utils/ConnectedDevicesModel.swift | 58 ++++++++++++++ .../UITests/TestApp/BluetoothModuleView.swift | 14 ++++ Tests/UITests/TestApp/TestAppDelegate.swift | 4 +- Tests/UITests/TestApp/TestDevice.swift | 16 ++-- .../TestApp/Views/TestServiceView.swift | 4 +- .../TestAppUITests/SpeziBluetoothTests.swift | 5 +- .../UITests/UITests.xcodeproj/project.pbxproj | 4 +- 14 files changed, 174 insertions(+), 76 deletions(-) create mode 100644 Sources/SpeziBluetooth/Utils/ConnectedDevicesModel.swift diff --git a/README.md b/README.md index 6de63ba6..2c4cd49e 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,8 @@ class DeviceInformationService: BluetoothService { We can use this Bluetooth service now in the `MyDevice` implementation as follows. -> Tip: We use the [`DeviceState`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/devicestate) and [`DeviceAction`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/deviceaction) property wrappers to get access to the device state and its actions. Those two +> [!TIP] +> We use the [`DeviceState`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/devicestate) and [`DeviceAction`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/deviceaction) property wrappers to get access to the device state and its actions. Those two property wrappers can also be used within a [`BluetoothService`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetoothservice) type. ```swift @@ -152,9 +153,10 @@ using [`scanNearbyDevices(minimumRSSI:advertisementStaleInterval:autoConnect:)`] To retrieve the list of nearby devices you may use [`nearbyDevices(for:)`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetooth/nearbyDevices(for:)). -> Tip: To easily access the first connected device, you can just query the SwiftUI Environment for your `BluetoothDevice` type. -Make sure to declare the property as optional using the respective [`Environment(_:)`](https://developer.apple.com/documentation/swiftui/environment/init(_:)-8slkf) -initializer. +> [!TIP] +> To easily access the first connected device, you can just query the SwiftUI Environment for your `BluetoothDevice` type. + Make sure to declare the property as optional using the respective [`Environment(_:)`](https://developer.apple.com/documentation/swiftui/environment/init(_:)-8slkf) + initializer. The below code example demonstrates all these steps of retrieving the `Bluetooth` module from the environment, listing all nearby devices, auto connecting to the first one and displaying some basic information of the currently connected device. @@ -198,6 +200,9 @@ struct MyView: View { } ``` +> [!TIP] +> Use [`ConnectedDevices`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/ConnectedDevices) to retrieve the full list of connected devices from the SwiftUI environment. + #### Retrieving Devices The previous section explained how to discover nearby devices and retrieve the currently connected one from the environment. diff --git a/Sources/SpeziBluetooth/Bluetooth.swift b/Sources/SpeziBluetooth/Bluetooth.swift index 166e8af9..d865efdf 100644 --- a/Sources/SpeziBluetooth/Bluetooth.swift +++ b/Sources/SpeziBluetooth/Bluetooth.swift @@ -146,7 +146,7 @@ import Spezi /// } /// ``` /// -/// // TODO: tip to how to use connected devices list (e.g., we could also do that for nearby devices?) +/// - Tip: Use ``ConnectedDevices`` to retrieve the full list of connected devices from the SwiftUI environment. /// /// #### Retrieving Devices /// @@ -298,7 +298,7 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { private var spezi /// Stores the connected device instance for every configured ``BluetoothDevice`` type. - @Model private var connectedDevicesModel = ConnectedDevices() + @Model private var connectedDevicesModel = ConnectedDevicesModel() /// Injects the ``BluetoothDevice`` instances from the `ConnectedDevices` model into the SwiftUI environment. @Modifier private var devicesInjector: ConnectedDevicesEnvironmentModifier diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift index ecbe5c14..b6153011 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift @@ -532,20 +532,9 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable } private func discardDevice(device: BluetoothPeripheral) { - if !isScanning { - device.assumeIsolated { device in - device.markLastActivity() - device.handleDisconnect() - } - clearDiscoveredPeripheral(forKey: device.id) - } else { - let backdateInterval: TimeInterval - if let discoverySession { - // we will keep discarded devices for max 2s before the stale timer kicks off - backdateInterval = max(0, discoverySession.assumeIsolated { $0.configuration.advertisementStaleInterval } - 2) - } else { - backdateInterval = 0 - } + if let discoverySession, isScanning { + // we will keep discarded devices for max 2s before the stale timer kicks off + let backdateInterval = max(0, discoverySession.assumeIsolated { $0.configuration.advertisementStaleInterval } - 2) device.assumeIsolated { device in device.markLastActivity(.now - backdateInterval) @@ -553,9 +542,15 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable } // We just schedule the new timer if there is a device to schedule one for. - discoverySession?.assumeIsolated { session in + discoverySession.assumeIsolated { session in session.scheduleStaleTaskForOldestActivityDevice() } + } else { + device.assumeIsolated { device in + device.markLastActivity() + device.handleDisconnect() + } + clearDiscoveredPeripheral(forKey: device.id) } } @@ -899,4 +894,6 @@ extension BluetoothManager { } } } -} // swiftlint:disable:this file_length +} + +// swiftlint:disable:this file_length diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift index 6fbfdbc2..8f99bf69 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift @@ -201,7 +201,6 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ /// A device is nearby if either we consider it discovered because we are currently scanning or the device is connected. nonisolated public private(set) var nearby: Bool { get { - // TODO: we also consider them nearby currently while connecting => we need to clear discovered devices that are currently connecting? _storage.nearby } set { diff --git a/Sources/SpeziBluetooth/Modifier/ConnectedDevicesEnvironmentModifier.swift b/Sources/SpeziBluetooth/Modifier/ConnectedDevicesEnvironmentModifier.swift index ae7fb438..3b4ace85 100644 --- a/Sources/SpeziBluetooth/Modifier/ConnectedDevicesEnvironmentModifier.swift +++ b/Sources/SpeziBluetooth/Modifier/ConnectedDevicesEnvironmentModifier.swift @@ -10,18 +10,26 @@ import SwiftUI private struct ConnectedDeviceEnvironmentModifier: ViewModifier { - @Environment(ConnectedDevices.self) + @Environment(ConnectedDevicesModel.self) var connectedDevices + @State private var devicesList = ConnectedDevices() + init() {} func body(content: Content) -> some View { let connectedDeviceAny = connectedDevices[ObjectIdentifier(Device.self)] - let connectedDevice = connectedDeviceAny as? Device + let firstConnectedDevice = connectedDeviceAny.first as? Device + let connectedDevicesList = connectedDeviceAny.compactMap { device in + device as? Device + } + + devicesList.update(connectedDevicesList) content - .environment(connectedDevice) + .environment(firstConnectedDevice) + .environment(devicesList) } } @@ -29,7 +37,7 @@ private struct ConnectedDeviceEnvironmentModifier: View struct ConnectedDevicesEnvironmentModifier: ViewModifier { private let configuredDeviceTypes: [any BluetoothDevice.Type] - @Environment(ConnectedDevices.self) + @Environment(ConnectedDevicesModel.self) var connectedDevices diff --git a/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md b/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md index 56bd277e..b3184c62 100644 --- a/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md +++ b/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md @@ -189,6 +189,8 @@ struct MyView: View { } ``` +> Tip: Use ``ConnectedDevices`` to retrieve the full list of connected devices from the SwiftUI environment. + #### Retrieving Devices The previous section explained how to discover nearby devices and retrieve the currently connected one from the environment. @@ -271,6 +273,7 @@ due to their async nature. - ``SwiftUI/View/scanNearbyDevices(enabled:with:minimumRSSI:advertisementStaleInterval:autoConnect:)`` - ``SwiftUI/View/autoConnect(enabled:with:minimumRSSI:advertisementStaleInterval:)`` +- ``ConnectedDevices`` ### Declaring a Bluetooth Device diff --git a/Sources/SpeziBluetooth/Utils/ConnectedDevices.swift b/Sources/SpeziBluetooth/Utils/ConnectedDevices.swift index bb029622..f2cdea76 100644 --- a/Sources/SpeziBluetooth/Utils/ConnectedDevices.swift +++ b/Sources/SpeziBluetooth/Utils/ConnectedDevices.swift @@ -6,48 +6,59 @@ // SPDX-License-Identifier: MIT // -import Foundation +import SwiftUI +/// Collection of connected devices. +/// +/// Use this type to retrieve the list of connected devices from the environment for configured ``BluetoothDevice``s. +/// +/// Below is a code example that list all connected devices of the type `MyDevice`. +/// ```swift +/// struct MyView: View { +/// @Environment(ConnectedDevices.self) +/// var connectedDevices +/// +/// var body: some View { +/// List { +/// Section("Connected Devices") { +/// ForEach(connectedDevices) { device in +/// Text("\(device.name ?? "unknown")") +/// } +/// } +/// } +/// } +/// } +/// ``` @Observable -class ConnectedDevices { - /// We track the first connected device for every BluetoothDevice type. - @MainActor private var connectedDevices: [ObjectIdentifier: any BluetoothDevice] = [:] - @MainActor private var connectedDeviceIds: [ObjectIdentifier: UUID] = [:] - - // TODO: provide access to the list of connected devices? - - - @MainActor - func update(with devices: [UUID: any BluetoothDevice]) { - // remove devices that disconnected - for (identifier, uuid) in connectedDeviceIds where devices[uuid] == nil { - connectedDeviceIds.removeValue(forKey: identifier) - connectedDevices.removeValue(forKey: identifier) - } - - // add newly connected devices that are not injected yet - for (uuid, device) in devices { - guard connectedDevices[device.typeIdentifier] == nil else { - // TODO: just inject all in order! - continue // already present, we just inject the first device of a particular type into the environment - } - - // Newly connected device for a type that isn't present yet. Save both device and id. - connectedDevices[device.typeIdentifier] = device - connectedDeviceIds[device.typeIdentifier] = uuid - } +public final class ConnectedDevices { + private var devices: [Device] + + init(_ devices: [Device] = []) { + self.devices = devices } - @MainActor - subscript(_ identifier: ObjectIdentifier) -> (any BluetoothDevice)? { - connectedDevices[identifier] + func update(_ devices: [Device]) -> EmptyView { + self.devices = devices + return EmptyView() } } -extension BluetoothDevice { - fileprivate var typeIdentifier: ObjectIdentifier { - ObjectIdentifier(Self.self) +extension ConnectedDevices: RandomAccessCollection { + public var startIndex: Int { + devices.startIndex + } + + public var endIndex: Int { + devices.endIndex + } + + public func index(after index: Int) -> Int { + devices.index(after: index) + } + + public subscript(position: Int) -> Device { + devices[position] } } diff --git a/Sources/SpeziBluetooth/Utils/ConnectedDevicesModel.swift b/Sources/SpeziBluetooth/Utils/ConnectedDevicesModel.swift new file mode 100644 index 00000000..a99ae703 --- /dev/null +++ b/Sources/SpeziBluetooth/Utils/ConnectedDevicesModel.swift @@ -0,0 +1,58 @@ +// +// 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 OrderedCollections + + +@Observable +class ConnectedDevicesModel { + /// We track the connected device for every BluetoothDevice type and index by peripheral identifier. + @MainActor private var connectedDevices: [ObjectIdentifier: OrderedDictionary] = [:] + + @MainActor + func update(with devices: [UUID: any BluetoothDevice]) { + // remove devices that disconnected + for (identifier, var devicesById) in connectedDevices { + var update = false + for id in devicesById.keys where devices[id] == nil { + devicesById.removeValue(forKey: id) + update = true + } + + if update { + connectedDevices[identifier] = devicesById.isEmpty ? nil : devicesById + } + } + + // add newly connected devices that are not injected yet + for (uuid, device) in devices { + guard connectedDevices[device.typeIdentifier]?[uuid] == nil else { + continue // already present + } + + // Newly connected device + connectedDevices[device.typeIdentifier, default: [:]].updateValue(device, forKey: uuid) + } + } + + @MainActor + subscript(_ identifier: ObjectIdentifier) -> [(any BluetoothDevice)] { + guard let values = connectedDevices[identifier]?.values else { + return [] + } + return Array(values) + } +} + + +extension BluetoothDevice { + fileprivate var typeIdentifier: ObjectIdentifier { + ObjectIdentifier(Self.self) + } +} diff --git a/Tests/UITests/TestApp/BluetoothModuleView.swift b/Tests/UITests/TestApp/BluetoothModuleView.swift index 56f91e44..5b419dd5 100644 --- a/Tests/UITests/TestApp/BluetoothModuleView.swift +++ b/Tests/UITests/TestApp/BluetoothModuleView.swift @@ -15,6 +15,8 @@ struct BluetoothModuleView: View { private var bluetooth @Environment(TestDevice.self) private var device: TestDevice? + @Environment(ConnectedDevices.self) + private var connectedDevices var body: some View { List { @@ -32,6 +34,18 @@ struct BluetoothModuleView: View { Text(verbatim: "This is a list of nearby test peripherals. Auto connect is enabled.") } + if !connectedDevices.isEmpty { + Section { + ForEach(connectedDevices) { device in + Text("Connected \(device.name ?? "unknown")") + } + } header: { + Text("Connected Devices") + } footer: { + Text("This tests the retrieval of connected devices using ConnectedDevices.") + } + } + if let device { NavigationLink("Test Interactions") { TestDeviceView(device: device) diff --git a/Tests/UITests/TestApp/TestAppDelegate.swift b/Tests/UITests/TestApp/TestAppDelegate.swift index 278d3828..a58f5ce3 100644 --- a/Tests/UITests/TestApp/TestAppDelegate.swift +++ b/Tests/UITests/TestApp/TestAppDelegate.swift @@ -6,10 +6,10 @@ // SPDX-License-Identifier: MIT // -@_spi(TestingSupport) -import SpeziBluetoothServices import Spezi import SpeziBluetooth +@_spi(TestingSupport) +import SpeziBluetoothServices import SwiftUI diff --git a/Tests/UITests/TestApp/TestDevice.swift b/Tests/UITests/TestApp/TestDevice.swift index aac6d8b9..6c7075c4 100644 --- a/Tests/UITests/TestApp/TestDevice.swift +++ b/Tests/UITests/TestApp/TestDevice.swift @@ -6,10 +6,10 @@ // SPDX-License-Identifier: MIT // -@_spi(TestingSupport) -import SpeziBluetoothServices import Foundation import SpeziBluetooth +@_spi(TestingSupport) +import SpeziBluetoothServices final class TestDevice: BluetoothDevice, Identifiable, SomePeripheral, @unchecked Sendable { @@ -41,12 +41,14 @@ final class TestDevice: BluetoothDevice, Identifiable, SomePeripheral, @unchecke let testState = State() - required init() { - deviceInformation.$modelNumber.onChange(initial: true) { @MainActor _ in - self.testState.didReceiveModel = true + required init() {} + + func configure() { + deviceInformation.$modelNumber.onChange(initial: true) { @MainActor [weak self] _ in + self?.testState.didReceiveModel = true } - deviceInformation.$manufacturerName.onChange { @MainActor _ in - self.testState.didReceiveManufacturer = true // this should never be called + deviceInformation.$manufacturerName.onChange { @MainActor [weak self] _ in + self?.testState.didReceiveManufacturer = true // this should never be called } } diff --git a/Tests/UITests/TestApp/Views/TestServiceView.swift b/Tests/UITests/TestApp/Views/TestServiceView.swift index e87acc95..fdbf9dde 100644 --- a/Tests/UITests/TestApp/Views/TestServiceView.swift +++ b/Tests/UITests/TestApp/Views/TestServiceView.swift @@ -6,11 +6,11 @@ // SPDX-License-Identifier: MIT // -@_spi(TestingSupport) -import SpeziBluetoothServices import CoreBluetooth @_spi(TestingSupport) import SpeziBluetooth +@_spi(TestingSupport) +import SpeziBluetoothServices import SpeziViews import SwiftUI diff --git a/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift b/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift index b9d8c3f7..e128bc82 100644 --- a/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift +++ b/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift @@ -6,9 +6,9 @@ // SPDX-License-Identifier: MIT // -@_spi(TestingSupport) -import BluetoothServices import CoreBluetooth +@_spi(TestingSupport) +import SpeziBluetoothServices import XCTest import XCTestExtensions @@ -35,6 +35,7 @@ final class SpeziBluetoothTests: XCTestCase { // 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.staticTexts["Connected Spezi"].exists) // tests retrieval via ConnectedDevices XCTAssert(app.buttons["Test Interactions"].exists) app.buttons["Test Interactions"].tap() diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 0ae20cd8..c096944c 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -441,7 +441,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 637867499T; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = 484YT3X9X7; ENABLE_HARDENED_RUNTIME = NO; @@ -461,7 +461,7 @@ ); MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.bluetooth.testapplication2; + PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.bluetooth.testapplication; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; SUPPORTS_MACCATALYST = YES; From a618b790922c9b53aab63b54e2cff13e3dde8f2c Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Sun, 23 Jun 2024 23:14:44 +0200 Subject: [PATCH 27/58] Skip macro and plugin validation in custom command --- .github/workflows/build-and-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index aa714f18..7972628b 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -43,7 +43,7 @@ jobs: 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" + customcommand: "set -o pipefail && xcodebuild test -scheme 'TestApp' -configuration 'Test' -destination 'platform=macOS,arch=arm64,variant=Mac Catalyst' -derivedDataPath '.derivedData' -resultBundlePath 'TestApp-macOS.xcresult' -skipPackagePluginValidation -skipMacroValidation | xcpretty" secrets: inherit uploadcoveragereport: name: Upload Coverage Report From 7912da39822c19cbc389caa423306f947352fa06 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Sun, 23 Jun 2024 23:34:48 +0200 Subject: [PATCH 28/58] Just use a generic name for easier testing --- Tests/UITests/TestApp/BluetoothModuleView.swift | 9 ++++++++- Tests/UITests/TestApp/Views/DeviceRowView.swift | 7 +------ Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift | 4 ++-- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/Tests/UITests/TestApp/BluetoothModuleView.swift b/Tests/UITests/TestApp/BluetoothModuleView.swift index 5b419dd5..1146bb40 100644 --- a/Tests/UITests/TestApp/BluetoothModuleView.swift +++ b/Tests/UITests/TestApp/BluetoothModuleView.swift @@ -37,7 +37,14 @@ struct BluetoothModuleView: View { if !connectedDevices.isEmpty { Section { ForEach(connectedDevices) { device in - Text("Connected \(device.name ?? "unknown")") + VStack { + Text(verbatim: "Connected \(type(of: device))") + if let name = device.name { + Text(name) + .font(.caption2) + .foregroundStyle(.secondary) + } + } } } header: { Text("Connected Devices") diff --git a/Tests/UITests/TestApp/Views/DeviceRowView.swift b/Tests/UITests/TestApp/Views/DeviceRowView.swift index 59a21fd6..cb7c7744 100644 --- a/Tests/UITests/TestApp/Views/DeviceRowView.swift +++ b/Tests/UITests/TestApp/Views/DeviceRowView.swift @@ -29,12 +29,7 @@ struct DeviceRowView: View { Button(action: peripheralAction) { VStack { HStack { - if let name = peripheral.name { - Text(verbatim: "\(name)") - } else { - Text(verbatim: "unknown") - .italic() - } + Text(verbatim: "\(Peripheral.self)") Spacer() Text(verbatim: "\(peripheral.rssi) dB") .foregroundColor(.secondary) diff --git a/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift b/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift index e128bc82..7fe21dc1 100644 --- a/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift +++ b/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift @@ -33,9 +33,9 @@ final class SpeziBluetoothTests: XCTestCase { try app.assertMinimalSimulatorInformation() // wait till the device is automatically connected. - XCTAssert(app.staticTexts["Spezi"].waitForExistence(timeout: 1.0)) // our peripheral name + XCTAssert(app.staticTexts["TestDevice"].waitForExistence(timeout: 1.0)) XCTAssert(app.staticTexts["connected"].waitForExistence(timeout: 10.0)) - XCTAssert(app.staticTexts["Connected Spezi"].exists) // tests retrieval via ConnectedDevices + XCTAssert(app.staticTexts["Connected TestDevice"].exists) // tests retrieval via ConnectedDevices XCTAssert(app.buttons["Test Interactions"].exists) app.buttons["Test Interactions"].tap() From fa5365f3680a5c6c23f25eaa30c53b44ac325321 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Mon, 24 Jun 2024 00:01:43 +0200 Subject: [PATCH 29/58] Fix crash when refreshing view --- .../Modifier/ConnectedDevicesEnvironmentModifier.swift | 2 +- Sources/SpeziBluetooth/Utils/ConnectedDevices.swift | 7 +------ Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift | 1 + 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/Sources/SpeziBluetooth/Modifier/ConnectedDevicesEnvironmentModifier.swift b/Sources/SpeziBluetooth/Modifier/ConnectedDevicesEnvironmentModifier.swift index 3b4ace85..3570cfab 100644 --- a/Sources/SpeziBluetooth/Modifier/ConnectedDevicesEnvironmentModifier.swift +++ b/Sources/SpeziBluetooth/Modifier/ConnectedDevicesEnvironmentModifier.swift @@ -25,7 +25,7 @@ private struct ConnectedDeviceEnvironmentModifier: View device as? Device } - devicesList.update(connectedDevicesList) + let devicesList = ConnectedDevices(connectedDevicesList) content .environment(firstConnectedDevice) diff --git a/Sources/SpeziBluetooth/Utils/ConnectedDevices.swift b/Sources/SpeziBluetooth/Utils/ConnectedDevices.swift index f2cdea76..578e82cf 100644 --- a/Sources/SpeziBluetooth/Utils/ConnectedDevices.swift +++ b/Sources/SpeziBluetooth/Utils/ConnectedDevices.swift @@ -32,16 +32,11 @@ import SwiftUI /// ``` @Observable public final class ConnectedDevices { - private var devices: [Device] + private let devices: [Device] init(_ devices: [Device] = []) { self.devices = devices } - - func update(_ devices: [Device]) -> EmptyView { - self.devices = devices - return EmptyView() - } } diff --git a/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift b/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift index 7fe21dc1..6eed9e77 100644 --- a/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift +++ b/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift @@ -136,6 +136,7 @@ final class SpeziBluetoothTests: XCTestCase { sleep(5) // check that it stays disconnected XCTAssert(app.staticTexts["disconnected"].waitForExistence(timeout: 2.0)) + XCTAssertFalse(app.staticTexts["Connected TestDevice"].waitForExistence(timeout: 0.5)) } } From 21a245a6012662477d684fceaafa9875c045b2c2 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Mon, 24 Jun 2024 14:11:42 +0200 Subject: [PATCH 30/58] Implement testing for retrieiving peripherals and ensure peripherals are deallocated. --- Sources/SpeziBluetooth/Bluetooth.swift | 53 ++++++----- .../CoreBluetooth/BluetoothManager.swift | 5 +- .../CoreBluetooth/BluetoothPeripheral.swift | 6 +- .../Model/OnChangeRegistration.swift | 2 +- .../Actions/BluetoothConnectAction.swift | 4 +- .../Actions/BluetoothDisconnectAction.swift | 4 +- .../Actions/BluetoothPeripheralAction.swift | 5 +- .../Model/Actions/ReadRSSIAction.swift | 4 +- .../CharacteristicPeripheralInjection.swift | 9 +- .../DeviceStatePeripheralInjection.swift | 3 +- .../Utils/ChangeSubscriptions.swift | 26 +++++- .../UITests/TestApp/BluetoothModuleView.swift | 30 ++++-- .../TestApp/RetrievePairedDevicesView.swift | 91 +++++++++++++++++++ Tests/UITests/TestApp/TestApp.swift | 37 +++++++- Tests/UITests/TestApp/TestDevice.swift | 13 +++ .../TestApp/Views/DeviceInformationView.swift | 10 ++ .../TestAppUITests/SpeziBluetoothTests.swift | 66 +++++++++++++- .../UITests/UITests.xcodeproj/project.pbxproj | 8 +- 18 files changed, 320 insertions(+), 56 deletions(-) create mode 100644 Tests/UITests/TestApp/RetrievePairedDevicesView.swift diff --git a/Sources/SpeziBluetooth/Bluetooth.swift b/Sources/SpeziBluetooth/Bluetooth.swift index d865efdf..cc76796e 100644 --- a/Sources/SpeziBluetooth/Bluetooth.swift +++ b/Sources/SpeziBluetooth/Bluetooth.swift @@ -369,6 +369,8 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { return } + print("Discovered peripherals UPDATE: \(discoveredDevices)") + self.assertIsolated("BluetoothManager peripherals change closure was unexpectedly not called on the Bluetooth SerialExecutor.") self.assumeIsolated { bluetooth in bluetooth.observeDiscoveredDevices() @@ -382,28 +384,6 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { } } - private func observePeripheralState(of uuid: UUID) { - // 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.assumeIsolated({ $0.knownPeripherals[uuid] }) else { - return - } - - peripheral.assumeIsolated { peripheral in - peripheral.onChange(of: \.state) { [weak self] _ in - guard let self = self else { - return - } - - self.assumeIsolated { bluetooth in - bluetooth.observePeripheralState(of: uuid) - bluetooth.handlePeripheralStateChange() - } - } - } - } - private func handleUpdatedNearbyDevicesChange(_ discoveredDevices: OrderedDictionary) { var checkForConnected = false @@ -414,6 +394,7 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { nearbyDevices.removeValue(forKey: key) // device instances will be automatically deallocated via `notifyDeviceDeinit` + print("Removed nearby bluetooth device: \(nearbyDevices)") } // add devices for new keys @@ -444,6 +425,34 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { } } + + @_spi(Internal) + public func _initializedDevicesCount() -> Int { // swiftlint:disable:this identifier_name + initializedDevices.count + } + + private func observePeripheralState(of uuid: UUID) { + // 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.assumeIsolated({ $0.knownPeripherals[uuid] }) else { + return + } + + peripheral.assumeIsolated { peripheral in + peripheral.onChange(of: \.state) { [weak self] _ in + guard let self = self else { + return + } + + self.assumeIsolated { bluetooth in + bluetooth.observePeripheralState(of: uuid) + bluetooth.handlePeripheralStateChange() + } + } + } + } + private func handlePeripheralStateChange() { // check for active connected device let connectedDevices = bluetoothManager.assumeIsolated { $0.knownPeripherals } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift index b6153011..1042180a 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift @@ -120,7 +120,7 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable guard let self = self else { return } - Task { @SpeziBluetooth in + Task.detached { @SpeziBluetooth in await self.isolatedStorage.unsubscribe(for: id) } } @@ -489,6 +489,9 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable } guard discoveredPeripherals.isEmpty && retrievedPeripherals.isEmpty else { + let discoveredCount = discoveredPeripherals.count + let retrievedCount = retrievedPeripherals.count + logger.debug("Not deallocating central. Devices are still associated: discovered: \(discoveredCount), retrieved: \(retrievedCount)") return // there are still associated devices } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift index 8f99bf69..5f926bd1 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift @@ -683,14 +683,16 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ _storage.update(nearby: false) } - guard let manager else { self.logger.warning("Orphaned device \(self.id), \(self.name ?? "unnamed") was de-initialized") return } let id = id - Task { @SpeziBluetooth in + let name = name + + self.logger.debug("Device \(id), \(name ?? "unnamed") was de-initialized...") + Task.detached { @SpeziBluetooth in await manager.handlePeripheralDeinit(id: id) } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/OnChangeRegistration.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/OnChangeRegistration.swift index fc0aed13..ae241338 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/OnChangeRegistration.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/OnChangeRegistration.swift @@ -42,7 +42,7 @@ public class OnChangeRegistration { let locator = locator let handlerId = handlerId - Task { @SpeziBluetooth in + Task.detached { @SpeziBluetooth in await peripheral?.deregisterOnChange(locator: locator, handlerId: handlerId) } } diff --git a/Sources/SpeziBluetooth/Model/Actions/BluetoothConnectAction.swift b/Sources/SpeziBluetooth/Model/Actions/BluetoothConnectAction.swift index df56c3bf..97ba2222 100644 --- a/Sources/SpeziBluetooth/Model/Actions/BluetoothConnectAction.swift +++ b/Sources/SpeziBluetooth/Model/Actions/BluetoothConnectAction.swift @@ -10,8 +10,8 @@ /// Connect to the Bluetooth peripheral. /// /// For more information refer to ``DeviceActions/connect`` -public struct BluetoothConnectAction: _BluetoothPeripheralAction { - public typealias ClosureType = () async -> Void +public struct BluetoothConnectAction: _BluetoothPeripheralAction, Sendable { + public typealias ClosureType = @Sendable () async -> Void private let content: _PeripheralActionContent diff --git a/Sources/SpeziBluetooth/Model/Actions/BluetoothDisconnectAction.swift b/Sources/SpeziBluetooth/Model/Actions/BluetoothDisconnectAction.swift index e6f4d385..6d57d2d1 100644 --- a/Sources/SpeziBluetooth/Model/Actions/BluetoothDisconnectAction.swift +++ b/Sources/SpeziBluetooth/Model/Actions/BluetoothDisconnectAction.swift @@ -10,8 +10,8 @@ /// Disconnect from the Bluetooth peripheral. /// /// For more information refer to ``DeviceActions/disconnect`` -public struct BluetoothDisconnectAction: _BluetoothPeripheralAction { - public typealias ClosureType = () async -> Void +public struct BluetoothDisconnectAction: _BluetoothPeripheralAction, Sendable { + public typealias ClosureType = @Sendable () async -> Void private let content: _PeripheralActionContent diff --git a/Sources/SpeziBluetooth/Model/Actions/BluetoothPeripheralAction.swift b/Sources/SpeziBluetooth/Model/Actions/BluetoothPeripheralAction.swift index dc5a214d..7b5ee6d4 100644 --- a/Sources/SpeziBluetooth/Model/Actions/BluetoothPeripheralAction.swift +++ b/Sources/SpeziBluetooth/Model/Actions/BluetoothPeripheralAction.swift @@ -7,7 +7,7 @@ // /// The content of an implemented peripheral action. -public enum _PeripheralActionContent { // swiftlint:disable:this type_name file_types_order +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. @@ -27,3 +27,6 @@ public protocol _BluetoothPeripheralAction { // swiftlint:disable:this type_name /// - Parameter content: The action content. init(_ content: _PeripheralActionContent) } + + +extension _PeripheralActionContent: Sendable {} diff --git a/Sources/SpeziBluetooth/Model/Actions/ReadRSSIAction.swift b/Sources/SpeziBluetooth/Model/Actions/ReadRSSIAction.swift index 62925cee..36334bf8 100644 --- a/Sources/SpeziBluetooth/Model/Actions/ReadRSSIAction.swift +++ b/Sources/SpeziBluetooth/Model/Actions/ReadRSSIAction.swift @@ -9,8 +9,8 @@ /// Read the current RSSI from the Bluetooth peripheral. /// /// For more information refer to ``DeviceActions/readRSSI`` -public struct ReadRSSIAction: _BluetoothPeripheralAction { - public typealias ClosureType = () async throws -> Int +public struct ReadRSSIAction: _BluetoothPeripheralAction, Sendable { + public typealias ClosureType = @Sendable () async throws -> Int private let content: _PeripheralActionContent diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift index 7349656b..dd45121f 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift @@ -40,7 +40,7 @@ actor CharacteristicPeripheralInjection: BluetoothActor { private var controlPointTransaction: ControlPointTransaction? /// Manages the user supplied subscriptions to the value. - private let subscriptions = ChangeSubscriptions() + private let subscriptions: ChangeSubscriptions /// We track all onChange closure registrations with `initial=false` to make sure to not call them with the initial value. /// The property is set to nil, once the initial value arrived. /// @@ -91,6 +91,7 @@ actor CharacteristicPeripheralInjection: BluetoothActor { self.characteristicId = characteristicId self._value = value self._characteristic = .init(characteristic) + self.subscriptions = ChangeSubscriptions(queue: peripheral.bluetoothQueue) } /// Setup the injection. Must be called after initialization to set up all handlers and write the initial value. @@ -123,8 +124,10 @@ actor CharacteristicPeripheralInjection: BluetoothActor { nonisolated func newOnChangeSubscription(initial: Bool, perform action: @escaping (Value) async -> Void) { let id = subscriptions.newOnChangeSubscription(perform: action) - Task { @SpeziBluetooth in - await handleInitialCall(id: id, initial: initial, action: action) + // Must be called detached, otherwise it might inherit TaskLocal values which includes Spezi moduleInitContext + // which would create a strong reference to the device. + Task.detached { @SpeziBluetooth in + await self.handleInitialCall(id: id, initial: initial, action: action) } } diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift index f13e805c..b117f904 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift @@ -16,7 +16,7 @@ actor DeviceStatePeripheralInjection: BluetoothActor { private let peripheral: BluetoothPeripheral private let accessKeyPath: KeyPath private let observationKeyPath: KeyPath? - private let subscriptions = ChangeSubscriptions() + private let subscriptions: ChangeSubscriptions nonisolated var value: Value { peripheral[keyPath: accessKeyPath] @@ -29,6 +29,7 @@ actor DeviceStatePeripheralInjection: BluetoothActor { self.peripheral = peripheral self.accessKeyPath = keyPath self.observationKeyPath = keyPath.storageEquivalent() + self.subscriptions = ChangeSubscriptions(queue: peripheral.bluetoothQueue) } func setup() { diff --git a/Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift b/Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift index f557f12b..a51a2145 100644 --- a/Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift +++ b/Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift @@ -16,11 +16,22 @@ class ChangeSubscriptions: @unchecked Sendable { let id: UUID } + private actor Dispatch: BluetoothActor { + let bluetoothQueue: DispatchSerialQueue + + init(_ bluetoothQueue: DispatchSerialQueue) { + self.bluetoothQueue = bluetoothQueue + } + } + + private let dispatch: Dispatch private var continuations: OrderedDictionary.Continuation> = [:] private var taskHandles: [UUID: Task] = [:] private let lock = NSLock() - init() {} + init(queue: DispatchSerialQueue) { + self.dispatch = Dispatch(queue) + } func notifySubscribers(with value: Value, ignoring: Set = []) { for (id, continuation) in continuations where !ignoring.contains(id) { @@ -31,8 +42,8 @@ class ChangeSubscriptions: @unchecked Sendable { private func _newSubscription() -> Registration { let id = UUID() let stream = AsyncStream { continuation in - lock.withLock { - continuations[id] = continuation + self.lock.withLock { + self.continuations[id] = continuation } continuation.onTermination = { [weak self] _ in @@ -57,13 +68,18 @@ class ChangeSubscriptions: @unchecked Sendable { func newOnChangeSubscription(perform action: @escaping (Value) async -> Void) -> UUID { let registration = _newSubscription() - let task = Task { @SpeziBluetooth [weak self] in + // It's important to use a detached Task here. + // Otherwise it might inherit TaskLocal values which might include Spezi moduleInitContext + // which would create a strong reference to the device. + let task = Task.detached { @SpeziBluetooth [weak self, dispatch] in for await element in registration.subscription { guard self != nil else { return } - await action(element) + await dispatch.isolated { _ in + await action(element) + } } self?.lock.withLock { diff --git a/Tests/UITests/TestApp/BluetoothModuleView.swift b/Tests/UITests/TestApp/BluetoothModuleView.swift index 1146bb40..da6e012d 100644 --- a/Tests/UITests/TestApp/BluetoothModuleView.swift +++ b/Tests/UITests/TestApp/BluetoothModuleView.swift @@ -7,6 +7,7 @@ // import SpeziBluetooth +import SpeziViews import SwiftUI @@ -18,8 +19,10 @@ struct BluetoothModuleView: View { @Environment(ConnectedDevices.self) private var connectedDevices + @Binding private var pairedDeviceId: UUID? + var body: some View { - List { + List { // swiftlint:disable:this closure_body_length BluetoothStateSection(state: bluetooth.state, isScanning: bluetooth.isScanning) let nearbyDevices = bluetooth.nearbyDevices(for: TestDevice.self) @@ -37,14 +40,20 @@ struct BluetoothModuleView: View { if !connectedDevices.isEmpty { Section { ForEach(connectedDevices) { device in - VStack { - Text(verbatim: "Connected \(type(of: device))") - if let name = device.name { - Text(name) - .font(.caption2) - .foregroundStyle(.secondary) + AsyncButton { + pairedDeviceId = device.id + await device.disconnect() + } label: { + VStack { + Text(verbatim: "Pair \(type(of: device))") + if let name = device.name { + Text(name) + .font(.caption2) + .foregroundStyle(.secondary) + } } } + .accessibilityLabel("Pair \(type(of: device))") } } header: { Text("Connected Devices") @@ -62,12 +71,17 @@ struct BluetoothModuleView: View { .scanNearbyDevices(with: bluetooth, autoConnect: true) .navigationTitle("Nearby Devices") } + + + init(pairedDeviceId: Binding) { + self._pairedDeviceId = pairedDeviceId + } } #Preview { NavigationStack { - BluetoothModuleView() + BluetoothModuleView(pairedDeviceId: .constant(nil)) .previewWith { Bluetooth { Discover(TestDevice.self, by: .advertisedService("FFF0")) diff --git a/Tests/UITests/TestApp/RetrievePairedDevicesView.swift b/Tests/UITests/TestApp/RetrievePairedDevicesView.swift new file mode 100644 index 00000000..c344c440 --- /dev/null +++ b/Tests/UITests/TestApp/RetrievePairedDevicesView.swift @@ -0,0 +1,91 @@ +// +// 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 SpeziViews +import SwiftUI + + +struct RetrievePairedDevicesView: View { + @Environment(Bluetooth.self) + private var bluetooth + + @Binding private var pairedDeviceId: UUID? + @Binding private var retrievedDevice: TestDevice? + + var body: some View { + Group { // swiftlint:disable:this closure_body_length + if let pairedDeviceId { + List { // swiftlint:disable:this closure_body_length + Section { + ListRow("Device") { + Text("Paired") + } + if let retrievedDevice { + ListRow("State") { + Text(retrievedDevice.state.description) + } + } + AsyncButton("Unpair Device") { + await retrievedDevice?.disconnect() + retrievedDevice = nil + self.pairedDeviceId = nil + } + if let retrievedDevice { + switch retrievedDevice.state { + case .disconnected: + AsyncButton("Connect Device") { + await retrievedDevice.connect() + } + case .connecting, .connected: + AsyncButton("Disconnect Device") { + await retrievedDevice.disconnect() + } + case .disconnecting: + EmptyView() + } + } else { + AsyncButton("Retrieve Device") { + retrievedDevice = await bluetooth.retrieveDevice(for: pairedDeviceId) + } + } + } + + if let retrievedDevice, case .connected = retrievedDevice.state { + DeviceInformationView(retrievedDevice) + } + } + } else { + ContentUnavailableView( + "No Device Paired", + systemImage: "sensor", + description: Text("Select a connected device in the Test Peripheral view to pair.") + ) + } + } + .navigationTitle("Paired Device") + } + + + init(pairedDeviceId: Binding, retrievedDevice: Binding) { + self._pairedDeviceId = pairedDeviceId + self._retrievedDevice = retrievedDevice + } +} + + +#Preview { + NavigationStack { + RetrievePairedDevicesView(pairedDeviceId: .constant(nil), retrievedDevice: .constant(nil)) + .previewWith { + Bluetooth { + Discover(TestDevice.self, by: .advertisedService("FFF0")) + } + } + } +} diff --git a/Tests/UITests/TestApp/TestApp.swift b/Tests/UITests/TestApp/TestApp.swift index 291919f5..df01ab16 100644 --- a/Tests/UITests/TestApp/TestApp.swift +++ b/Tests/UITests/TestApp/TestApp.swift @@ -7,6 +7,9 @@ // import Spezi +@_spi(Internal) +import SpeziBluetooth +import SpeziViews import SwiftUI struct NearbyDevices: View { @@ -15,11 +18,36 @@ struct NearbyDevices: View { } } +struct DeviceCountButton: View { + @Environment(Bluetooth.self) + private var bluetooth + + @State private var lastReadCount: Int? + + var body: some View { + Section { + AsyncButton("Query Count") { + lastReadCount = await bluetooth._initializedDevicesCount() + } + .onDisappear { + lastReadCount = nil + } + } footer: { + if let lastReadCount { + Text("Currently initialized devices: \(lastReadCount)") + } + } + } +} + @main struct UITestsApp: App { @UIApplicationDelegateAdaptor(TestAppDelegate.self) var appDelegate - + + @State private var pairedDeviceId: UUID? + @State private var retrievedDevice: TestDevice? + var body: some Scene { WindowGroup { @@ -29,8 +57,13 @@ struct UITestsApp: App { NearbyDevices() } NavigationLink("Test Peripheral") { - BluetoothModuleView() + BluetoothModuleView(pairedDeviceId: $pairedDeviceId) + } + NavigationLink("Paired Device") { + RetrievePairedDevicesView(pairedDeviceId: $pairedDeviceId, retrievedDevice: $retrievedDevice) } + + DeviceCountButton() } .navigationTitle("Spezi Bluetooth") } diff --git a/Tests/UITests/TestApp/TestDevice.swift b/Tests/UITests/TestApp/TestDevice.swift index 6c7075c4..d3b4a9c0 100644 --- a/Tests/UITests/TestApp/TestDevice.swift +++ b/Tests/UITests/TestApp/TestDevice.swift @@ -40,16 +40,29 @@ final class TestDevice: BluetoothDevice, Identifiable, SomePeripheral, @unchecke let testState = State() + private(set) var passedRetainCountCheck = false required init() {} func configure() { + let count = CFGetRetainCount(self) + deviceInformation.$modelNumber.onChange(initial: true) { @MainActor [weak self] _ in self?.testState.didReceiveModel = true } deviceInformation.$manufacturerName.onChange { @MainActor [weak self] _ in self?.testState.didReceiveManufacturer = true // this should never be called } + $state.onChange { state in // test DeviceState code path as well, even if its just logging! + print("State is now \(state)") + } + + let newCount = CFGetRetainCount(self) + if count == newCount { + passedRetainCountCheck = true + } else { + print("Failed retain count check, was \(count) now is \(newCount)") + } } diff --git a/Tests/UITests/TestApp/Views/DeviceInformationView.swift b/Tests/UITests/TestApp/Views/DeviceInformationView.swift index 03241470..f2f50c2b 100644 --- a/Tests/UITests/TestApp/Views/DeviceInformationView.swift +++ b/Tests/UITests/TestApp/Views/DeviceInformationView.swift @@ -68,6 +68,16 @@ struct DeviceInformationView: View { Text(regulatoryCertificationDataList.hexString()) } } + + ListRow("Retain Count Check") { + if device.passedRetainCountCheck { + Text("Passed") + .foregroundStyle(.green) + } else { + Text("Failed") + .foregroundStyle(.red) + } + } } header: { Text("Device Information") } footer: { diff --git a/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift b/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift index 6eed9e77..00c4fc92 100644 --- a/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift +++ b/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift @@ -33,9 +33,9 @@ final class SpeziBluetoothTests: XCTestCase { try app.assertMinimalSimulatorInformation() // wait till the device is automatically connected. - XCTAssert(app.staticTexts["TestDevice"].waitForExistence(timeout: 1.0)) + XCTAssert(app.staticTexts["TestDevice"].waitForExistence(timeout: 5.0)) XCTAssert(app.staticTexts["connected"].waitForExistence(timeout: 10.0)) - XCTAssert(app.staticTexts["Connected TestDevice"].exists) // tests retrieval via ConnectedDevices + XCTAssert(app.buttons["Pair TestDevice"].exists) // tests retrieval via ConnectedDevices XCTAssert(app.buttons["Test Interactions"].exists) app.buttons["Test Interactions"].tap() @@ -45,6 +45,9 @@ final class SpeziBluetoothTests: XCTestCase { XCTAssert(app.staticTexts["Manufacturer, Apple Inc."].exists) XCTAssert(app.staticTexts["Model"].exists) // we just check for existence of the model row + // check that onChange registrations in configure() didn't create any unwanted retain cycles + XCTAssert(app.staticTexts["Retain Count Check, Passed"].exists) + // CHECK onChange behavior XCTAssert(app.staticTexts["Manufacturer: false, Model: true"].waitForExistence(timeout: 0.5)) XCTAssert(app.buttons["Fetch"].exists) @@ -138,6 +141,65 @@ final class SpeziBluetoothTests: XCTestCase { XCTAssert(app.staticTexts["disconnected"].waitForExistence(timeout: 2.0)) XCTAssertFalse(app.staticTexts["Connected TestDevice"].waitForExistence(timeout: 0.5)) } + + func testPairedDevice() throws { + let app = XCUIApplication() + app.launch() + + XCTAssert(app.staticTexts["Spezi Bluetooth"].waitForExistence(timeout: 2)) + + XCTAssert(app.buttons["Test Peripheral"].exists) + app.buttons["Test Peripheral"].tap() + + XCTAssert(app.navigationBars.staticTexts["Nearby Devices"].waitForExistence(timeout: 2.0)) + try app.assertMinimalSimulatorInformation() + + // wait till the device is automatically connected. + XCTAssert(app.staticTexts["TestDevice"].waitForExistence(timeout: 5.0)) + XCTAssert(app.staticTexts["connected"].waitForExistence(timeout: 10.0)) + + XCTAssert(app.buttons["Pair TestDevice"].exists) // tests retrieval via ConnectedDevices + app.buttons["Pair TestDevice"].tap() + + XCTAssert(app.navigationBars.buttons["Spezi Bluetooth"].exists) + app.navigationBars.buttons["Spezi Bluetooth"].tap() + + XCTAssert(app.buttons["Query Count"].waitForExistence(timeout: 2.0)) + app.buttons["Query Count"].tap() + XCTAssert(app.staticTexts["Currently initialized devices: 0"].waitForExistence(timeout: 0.5)) // ensure devices got deallocated + + + XCTAssert(app.buttons["Paired Device"].exists) + app.buttons["Paired Device"].tap() + + XCTAssert(app.staticTexts["Device, Paired"].waitForExistence(timeout: 2.0)) + + XCTAssert(app.buttons["Retrieve Device"].exists) + app.buttons["Retrieve Device"].tap() + + XCTAssert(app.staticTexts["State, disconnected"].waitForExistence(timeout: 0.5)) + XCTAssert(app.buttons["Connect Device"].exists) + app.buttons["Connect Device"].tap() + + XCTAssert(app.staticTexts["State, connected"].waitForExistence(timeout: 10.0)) + XCTAssert(app.staticTexts["Manufacturer, Apple Inc."].exists) + XCTAssert(app.staticTexts["Retain Count Check, Passed"].exists) + + XCTAssert(app.buttons["Disconnect Device"].exists) + app.buttons["Disconnect Device"].tap() + + XCTAssert(app.staticTexts["State, disconnected"].waitForExistence(timeout: 0.5)) + + XCTAssert(app.buttons["Unpair Device"].exists) + app.buttons["Unpair Device"].tap() + + XCTAssert(app.navigationBars.buttons["Spezi Bluetooth"].exists) + app.navigationBars.buttons["Spezi Bluetooth"].tap() + + XCTAssert(app.buttons["Query Count"].waitForExistence(timeout: 2.0)) + app.buttons["Query Count"].tap() + XCTAssert(app.staticTexts["Currently initialized devices: 0"].waitForExistence(timeout: 0.5)) // ensure devices got deallocated + } } diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index c096944c..07075e51 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 2F64EA8B2A86B3DE006789D0 /* XCTestExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 2F64EA8A2A86B3DE006789D0 /* XCTestExtensions */; }; 2F6D139A28F5F386007C25D6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2F6D139928F5F386007C25D6 /* Assets.xcassets */; }; 2FA43E922AE057CA009B1B2C /* BluetoothManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA43E912AE057CA009B1B2C /* BluetoothManagerTests.swift */; }; + A909BBA72C29712C00969FC4 /* RetrievePairedDevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A909BBA62C29712C00969FC4 /* RetrievePairedDevicesView.swift */; }; 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 */; }; @@ -60,6 +61,7 @@ 2F6D13AC28F5F386007C25D6 /* TestAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TestAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = ""; }; + A909BBA62C29712C00969FC4 /* RetrievePairedDevicesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetrievePairedDevicesView.swift; 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 = ""; }; @@ -120,15 +122,16 @@ 2F6D139428F5F384007C25D6 /* TestApp */ = { isa = PBXGroup; children = ( - A95542B72B5E3F260066646D /* Views */, A92802BA2B5085BE00874D0A /* Info.plist */, A92802B82B50823600874D0A /* BluetoothManagerView.swift */, A95542B32B5E3E210066646D /* BluetoothModuleView.swift */, + A909BBA62C29712C00969FC4 /* RetrievePairedDevicesView.swift */, 2F64EA872A86B36C006789D0 /* TestApp.swift */, 2F64EA812A86B346006789D0 /* TestAppDelegate.swift */, A95542BC2B5E40DF0066646D /* TestDevice.swift */, - 2F6D139928F5F386007C25D6 /* Assets.xcassets */, A93B82D92B78D0D200C5DF3D /* TestDeviceView.swift */, + 2F6D139928F5F386007C25D6 /* Assets.xcassets */, + A95542B72B5E3F260066646D /* Views */, ); path = TestApp; sourceTree = ""; @@ -281,6 +284,7 @@ A93B82D42B78C20700C5DF3D /* BluetoothStateSection.swift in Sources */, A93B82D62B78C2D100C5DF3D /* DeviceInformationView.swift in Sources */, A95542BD2B5E40DF0066646D /* TestDevice.swift in Sources */, + A909BBA72C29712C00969FC4 /* RetrievePairedDevicesView.swift in Sources */, A92802BD2B51CBBE00874D0A /* DeviceRowView.swift in Sources */, A92802B92B50823600874D0A /* BluetoothManagerView.swift in Sources */, 2F64EA882A86B36C006789D0 /* TestApp.swift in Sources */, From 9f787638ae8250805e9e2fac089851a6aa7fd1b5 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Mon, 24 Jun 2024 16:51:48 +0200 Subject: [PATCH 31/58] Remove forgotten print statements --- Sources/SpeziBluetooth/Bluetooth.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Sources/SpeziBluetooth/Bluetooth.swift b/Sources/SpeziBluetooth/Bluetooth.swift index cc76796e..dded92f8 100644 --- a/Sources/SpeziBluetooth/Bluetooth.swift +++ b/Sources/SpeziBluetooth/Bluetooth.swift @@ -369,8 +369,6 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { return } - print("Discovered peripherals UPDATE: \(discoveredDevices)") - self.assertIsolated("BluetoothManager peripherals change closure was unexpectedly not called on the Bluetooth SerialExecutor.") self.assumeIsolated { bluetooth in bluetooth.observeDiscoveredDevices() @@ -394,7 +392,6 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { nearbyDevices.removeValue(forKey: key) // device instances will be automatically deallocated via `notifyDeviceDeinit` - print("Removed nearby bluetooth device: \(nearbyDevices)") } // add devices for new keys From 48417d054e2f2dbcbb25962ada6638799200ff8f Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Mon, 24 Jun 2024 21:12:57 +0200 Subject: [PATCH 32/58] Minor changes --- Package.swift | 17 +++++++++++------ .../CharacteristicAccessor.swift | 2 ++ .../PropertySupport/DeviceStateAccessor.swift | 2 ++ 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/Package.swift b/Package.swift index 55b1b470..e268d9e0 100644 --- a/Package.swift +++ b/Package.swift @@ -11,8 +11,6 @@ import PackageDescription -let swiftLintPlugin: Target.PluginUsage = .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLint") - let package = Package( name: "SpeziBluetooth", defaultLocalization: "en", @@ -46,7 +44,7 @@ let package = Package( resources: [ .process("Resources") ], - plugins: [swiftLintPlugin] + plugins: [.swiftLintPlugin] ), .target( name: "SpeziBluetoothServices", @@ -55,7 +53,7 @@ let package = Package( .product(name: "ByteCoding", package: "SpeziNetworking"), .product(name: "SpeziNumerics", package: "SpeziNetworking") ], - plugins: [swiftLintPlugin] + plugins: [.swiftLintPlugin] ), .executableTarget( name: "TestPeripheral", @@ -64,7 +62,7 @@ let package = Package( .target(name: "SpeziBluetoothServices"), .product(name: "ByteCoding", package: "SpeziNetworking") ], - plugins: [swiftLintPlugin] + plugins: [.swiftLintPlugin] ), .testTarget( name: "BluetoothServicesTests", @@ -74,7 +72,14 @@ let package = Package( .product(name: "XCTByteCoding", package: "SpeziNetworking"), .product(name: "NIO", package: "swift-nio") ], - plugins: [swiftLintPlugin] + plugins: [.swiftLintPlugin] ) ] ) + + +extension Target.PluginUsage { + static var swiftLintPlugin: Target.PluginUsage { + .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLint") + } +} diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift index 05ed3cb5..7050f4fa 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift @@ -132,6 +132,8 @@ extension CharacteristicAccessor where Value: ByteDecodable { /// /// Register a change handler with the characteristic that is called every time the value changes. /// + /// - Note: `onChange` handlers are bound to the lifetime of the device. If you need to control the lifetime yourself refer to using ``subscription``. + /// /// Note that you cannot set up onChange handlers within the initializers. /// Use the [`configure()`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module/configure()-5pa83) to set up /// all your handlers. diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift index ca80674a..ba053c28 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift @@ -47,6 +47,8 @@ extension DeviceStateAccessor { /// /// Register a change handler with the device state that is called every time the value changes. /// + /// - Note: `onChange` handlers are bound to the lifetime of the device. If you need to control the lifetime yourself refer to using ``subscription``. + /// /// Note that you cannot set up onChange handlers within the initializers. /// Use the [`configure()`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module/configure()-5pa83) to set up /// all your handlers. From 194e2c3b73d2c0ab056c89ad9d12c46098621d15 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 25 Jun 2024 13:28:30 +0200 Subject: [PATCH 33/58] Add Codable conformance for all Characteristics --- Package.swift | 2 +- .../BloodPressureFeature.swift | 3 ++ .../BloodPressureMeasurement.swift | 9 +++++ .../IntermediateCuffPressure.swift | 11 ++++++ .../Characteristics/MeasurementInterval.swift | 3 ++ .../Characteristics/PnPID.swift | 6 +++ .../TemperatureMeasurement.swift | 6 +++ .../Characteristics/TemperatureType.swift | 37 +++++++++++++------ .../Characteristics/Time/CurrentTime.swift | 6 +++ .../Characteristics/Time/DateTime.swift | 6 +++ .../Characteristics/Time/DayDateTime.swift | 3 ++ .../Characteristics/Time/DayOfWeek.swift | 3 ++ .../Characteristics/Time/ExactTime256.swift | 3 ++ .../Characteristics/WeightMeasurement.swift | 9 +++++ .../Characteristics/WeightScaleFeature.swift | 3 ++ .../CurrentTimeTests.swift | 11 ++++++ 16 files changed, 109 insertions(+), 12 deletions(-) diff --git a/Package.swift b/Package.swift index e268d9e0..435782e2 100644 --- a/Package.swift +++ b/Package.swift @@ -26,7 +26,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "1.1.0"), .package(url: "https://github.com/StanfordSpezi/Spezi", branch: "feature/externally-managed-modules"), - .package(url: "https://github.com/StanfordSpezi/SpeziNetworking", from: "2.0.1"), + .package(url: "https://github.com/StanfordSpezi/SpeziNetworking", branch: "feature/medfloat16-codable"), .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"), .package(url: "https://github.com/realm/SwiftLint.git", .upToNextMinor(from: "0.55.1")) diff --git a/Sources/SpeziBluetoothServices/Characteristics/BloodPressureFeature.swift b/Sources/SpeziBluetoothServices/Characteristics/BloodPressureFeature.swift index 52685b68..136d7008 100644 --- a/Sources/SpeziBluetoothServices/Characteristics/BloodPressureFeature.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/BloodPressureFeature.swift @@ -79,3 +79,6 @@ extension BloodPressureFeature: ByteCodable { rawValue.encode(to: &byteBuffer) } } + + +extension BloodPressureFeature: Codable {} diff --git a/Sources/SpeziBluetoothServices/Characteristics/BloodPressureMeasurement.swift b/Sources/SpeziBluetoothServices/Characteristics/BloodPressureMeasurement.swift index 5131ae39..a3d749fc 100644 --- a/Sources/SpeziBluetoothServices/Characteristics/BloodPressureMeasurement.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/BloodPressureMeasurement.swift @@ -266,3 +266,12 @@ extension BloodPressureMeasurement: ByteCodable { byteBuffer.setInteger(flags.rawValue, at: flagsIndex) // finally update the flags field } } + + +extension BloodPressureMeasurement.Unit: Codable {} + + +extension BloodPressureMeasurement.Status: Codable {} + + +extension BloodPressureMeasurement: Codable {} diff --git a/Sources/SpeziBluetoothServices/Characteristics/IntermediateCuffPressure.swift b/Sources/SpeziBluetoothServices/Characteristics/IntermediateCuffPressure.swift index fc07650a..5051f803 100644 --- a/Sources/SpeziBluetoothServices/Characteristics/IntermediateCuffPressure.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/IntermediateCuffPressure.swift @@ -126,3 +126,14 @@ extension IntermediateCuffPressure: ByteCodable { representation.encode(to: &byteBuffer) } } + + +extension IntermediateCuffPressure: Codable { + public init(from decoder: any Decoder) throws { + self.representation = try BloodPressureMeasurement(from: decoder) + } + + public func encode(to encoder: any Encoder) throws { + try representation.encode(to: encoder) + } +} diff --git a/Sources/SpeziBluetoothServices/Characteristics/MeasurementInterval.swift b/Sources/SpeziBluetoothServices/Characteristics/MeasurementInterval.swift index 6ed9ab20..a572bab6 100644 --- a/Sources/SpeziBluetoothServices/Characteristics/MeasurementInterval.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/MeasurementInterval.swift @@ -58,3 +58,6 @@ extension MeasurementInterval: ByteCodable { rawValue.encode(to: &byteBuffer) } } + + +extension MeasurementInterval: Codable {} diff --git a/Sources/SpeziBluetoothServices/Characteristics/PnPID.swift b/Sources/SpeziBluetoothServices/Characteristics/PnPID.swift index fc4a78db..4c9833b9 100644 --- a/Sources/SpeziBluetoothServices/Characteristics/PnPID.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/PnPID.swift @@ -130,3 +130,9 @@ extension PnPID: ByteCodable { productVersion.encode(to: &byteBuffer) } } + + +extension VendorIDSource: Codable {} + + +extension PnPID: Codable {} diff --git a/Sources/SpeziBluetoothServices/Characteristics/TemperatureMeasurement.swift b/Sources/SpeziBluetoothServices/Characteristics/TemperatureMeasurement.swift index 4340a09a..1aee6728 100644 --- a/Sources/SpeziBluetoothServices/Characteristics/TemperatureMeasurement.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/TemperatureMeasurement.swift @@ -144,3 +144,9 @@ extension TemperatureMeasurement: ByteCodable { byteBuffer.setInteger(flags.rawValue, at: flagsIndex) // finally update the flags field } } + + +extension TemperatureMeasurement.Unit: Codable {} + + +extension TemperatureMeasurement: Codable {} diff --git a/Sources/SpeziBluetoothServices/Characteristics/TemperatureType.swift b/Sources/SpeziBluetoothServices/Characteristics/TemperatureType.swift index 60fd9aca..2ca84c3e 100644 --- a/Sources/SpeziBluetoothServices/Characteristics/TemperatureType.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/TemperatureType.swift @@ -13,30 +13,42 @@ import NIO /// The location of a temperature measurement. /// /// Refer to GATT Specification Supplement, 3.219 Temperature Type. -public enum TemperatureType: UInt8, CaseIterable { +public struct TemperatureType { /// Reserved for future use. - case reserved + public static let reserved = TemperatureType(rawValue: 0x00) /// Armpit. - case armpit + public static let armpit = TemperatureType(rawValue: 0x01) /// Body (general). - case body + public static let body = TemperatureType(rawValue: 0x02) /// Ear (usually earlobe). - case ear + public static let ear = TemperatureType(rawValue: 0x03) /// Finger. - case finger + public static let finger = TemperatureType(rawValue: 0x04) /// Gastrointestinal Tract. - case gastrointestinalTract + public static let gastrointestinalTract = TemperatureType(rawValue: 0x05) /// Mouth. - case mouth + public static let mouth = TemperatureType(rawValue: 0x06) /// Rectum. - case rectum + public static let rectum = TemperatureType(rawValue: 0x07) /// Toe. - case toe + public static let toe = TemperatureType(rawValue: 0x08) /// Tympanum (ear drum). - case tympanum + public static let tympanum = TemperatureType(rawValue: 0x09) + + /// The raw value. + public let rawValue: UInt8 + + /// Create temperature type from raw value. + /// - Parameter rawValue: The raw value temperature type. + public init(rawValue: UInt8) { + self.rawValue = rawValue + } } +extension TemperatureType: RawRepresentable {} + + extension TemperatureType: Hashable, Sendable {} @@ -53,3 +65,6 @@ extension TemperatureType: ByteCodable { rawValue.encode(to: &byteBuffer) } } + + +extension TemperatureType: Codable {} diff --git a/Sources/SpeziBluetoothServices/Characteristics/Time/CurrentTime.swift b/Sources/SpeziBluetoothServices/Characteristics/Time/CurrentTime.swift index 04bd3250..fff223b4 100644 --- a/Sources/SpeziBluetoothServices/Characteristics/Time/CurrentTime.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/Time/CurrentTime.swift @@ -87,3 +87,9 @@ extension CurrentTime: ByteCodable { adjustReason.encode(to: &byteBuffer) } } + + +extension CurrentTime.AdjustReason: Codable {} + + +extension CurrentTime: Codable {} diff --git a/Sources/SpeziBluetoothServices/Characteristics/Time/DateTime.swift b/Sources/SpeziBluetoothServices/Characteristics/Time/DateTime.swift index 64ac3dd8..5a280338 100644 --- a/Sources/SpeziBluetoothServices/Characteristics/Time/DateTime.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/Time/DateTime.swift @@ -214,3 +214,9 @@ extension DateTime: ByteCodable { seconds.encode(to: &byteBuffer) } } + + +extension DateTime.Month: Codable {} + + +extension DateTime: Codable {} diff --git a/Sources/SpeziBluetoothServices/Characteristics/Time/DayDateTime.swift b/Sources/SpeziBluetoothServices/Characteristics/Time/DayDateTime.swift index b3058d2f..4573b7d2 100644 --- a/Sources/SpeziBluetoothServices/Characteristics/Time/DayDateTime.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/Time/DayDateTime.swift @@ -112,3 +112,6 @@ extension DayDateTime: ByteCodable { dayOfWeek.encode(to: &byteBuffer) } } + + +extension DayDateTime: Codable {} diff --git a/Sources/SpeziBluetoothServices/Characteristics/Time/DayOfWeek.swift b/Sources/SpeziBluetoothServices/Characteristics/Time/DayOfWeek.swift index 949961b7..097ed992 100644 --- a/Sources/SpeziBluetoothServices/Characteristics/Time/DayOfWeek.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/Time/DayOfWeek.swift @@ -65,3 +65,6 @@ extension DayOfWeek: ByteCodable { rawValue.encode(to: &byteBuffer) } } + + +extension DayOfWeek: Codable {} diff --git a/Sources/SpeziBluetoothServices/Characteristics/Time/ExactTime256.swift b/Sources/SpeziBluetoothServices/Characteristics/Time/ExactTime256.swift index 2ff3c045..f3ea757c 100644 --- a/Sources/SpeziBluetoothServices/Characteristics/Time/ExactTime256.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/Time/ExactTime256.swift @@ -133,3 +133,6 @@ extension ExactTime256: ByteCodable { fractions256.encode(to: &byteBuffer) } } + + +extension ExactTime256: Codable {} diff --git a/Sources/SpeziBluetoothServices/Characteristics/WeightMeasurement.swift b/Sources/SpeziBluetoothServices/Characteristics/WeightMeasurement.swift index c463ed33..ee4e1ade 100644 --- a/Sources/SpeziBluetoothServices/Characteristics/WeightMeasurement.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/WeightMeasurement.swift @@ -240,3 +240,12 @@ extension WeightMeasurement: ByteCodable { byteBuffer.setInteger(flags.rawValue, at: flagsIndex) // finally update the flags field } } + + +extension WeightMeasurement.Unit: Codable {} + + +extension WeightMeasurement.AdditionalInfo: Codable {} + + +extension WeightMeasurement: Codable {} diff --git a/Sources/SpeziBluetoothServices/Characteristics/WeightScaleFeature.swift b/Sources/SpeziBluetoothServices/Characteristics/WeightScaleFeature.swift index 629fc3f2..048c4b97 100644 --- a/Sources/SpeziBluetoothServices/Characteristics/WeightScaleFeature.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/WeightScaleFeature.swift @@ -231,3 +231,6 @@ extension WeightScaleFeature: ByteCodable { rawValue.encode(to: &byteBuffer) } } + + +extension WeightScaleFeature: Codable {} diff --git a/Tests/BluetoothServicesTests/CurrentTimeTests.swift b/Tests/BluetoothServicesTests/CurrentTimeTests.swift index f777bbb7..3f9b5169 100644 --- a/Tests/BluetoothServicesTests/CurrentTimeTests.swift +++ b/Tests/BluetoothServicesTests/CurrentTimeTests.swift @@ -120,6 +120,17 @@ final class CurrentTimeTests: XCTestCase { try testIdentity(from: CurrentTime(time: exactTime, adjustReason: [.manualTimeUpdate, .changeOfTimeZone])) } + func testCurrentTimeCodable() throws { + // test that we are coding from a single value container + let encoded = try JSONEncoder().encode(UInt8(0x01)) + let reason = try JSONDecoder().decode(CurrentTime.AdjustReason.self, from: encoded) + XCTAssertEqual(reason, .manualTimeUpdate) + + let encodedReason = try JSONEncoder().encode(CurrentTime.AdjustReason.manualTimeUpdate) + let rawValue = try JSONDecoder().decode(UInt8.self, from: encodedReason) + XCTAssertEqual(rawValue, 0x01) // TODO: test for all? + } + func testDateConversions() throws { let baseNanoSeconds = Int(255 * (1.0 / 256.0) * 1000_000_000) var components = DateComponents(year: 2024, month: 5, day: 17, hour: 16, minute: 11, second: 26) From 9497780e11eb5ecc86ade1db0f5e887fa9ca0ad7 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 25 Jun 2024 13:38:00 +0200 Subject: [PATCH 34/58] Make Units RawRepresentable and fix swiftlint --- .../Characteristics/BloodPressureMeasurement.swift | 2 +- .../Characteristics/TemperatureMeasurement.swift | 2 +- .../Characteristics/WeightMeasurement.swift | 2 +- Tests/BluetoothServicesTests/CurrentTimeTests.swift | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/SpeziBluetoothServices/Characteristics/BloodPressureMeasurement.swift b/Sources/SpeziBluetoothServices/Characteristics/BloodPressureMeasurement.swift index a3d749fc..3e364b61 100644 --- a/Sources/SpeziBluetoothServices/Characteristics/BloodPressureMeasurement.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/BloodPressureMeasurement.swift @@ -31,7 +31,7 @@ public struct BloodPressureMeasurement { } /// The unit of a blood pressure measurement. - public enum Unit { + public enum Unit: String { /// Blood pressure for Systolic, Diastolic and Mean Arterial Pressure (MAP) is in units of mmHg. case mmHg /// Blood pressure for Systolic, Diastolic and Mean Arterial Pressure (MAP) is in units of kPa. diff --git a/Sources/SpeziBluetoothServices/Characteristics/TemperatureMeasurement.swift b/Sources/SpeziBluetoothServices/Characteristics/TemperatureMeasurement.swift index 1aee6728..bf74fc85 100644 --- a/Sources/SpeziBluetoothServices/Characteristics/TemperatureMeasurement.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/TemperatureMeasurement.swift @@ -28,7 +28,7 @@ public struct TemperatureMeasurement { } /// The unit of a temperature measurement. - public enum Unit { + public enum Unit: String { /// The temperature value is measured in celsius. case celsius /// The temperature value is measured in fahrenheit. diff --git a/Sources/SpeziBluetoothServices/Characteristics/WeightMeasurement.swift b/Sources/SpeziBluetoothServices/Characteristics/WeightMeasurement.swift index ee4e1ade..c70439c3 100644 --- a/Sources/SpeziBluetoothServices/Characteristics/WeightMeasurement.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/WeightMeasurement.swift @@ -29,7 +29,7 @@ public struct WeightMeasurement { } /// Units for a weight measurement. - public enum Unit { + public enum Unit: String { /// SI units (Weight and Mass in units of kilogram (kg) and Height in units of meter). case si // swiftlint:disable:this identifier_name /// Imperial units. (Weight and Mass in units of pound (lb) and Height in units of inch (in)). diff --git a/Tests/BluetoothServicesTests/CurrentTimeTests.swift b/Tests/BluetoothServicesTests/CurrentTimeTests.swift index 3f9b5169..e3619427 100644 --- a/Tests/BluetoothServicesTests/CurrentTimeTests.swift +++ b/Tests/BluetoothServicesTests/CurrentTimeTests.swift @@ -128,7 +128,7 @@ final class CurrentTimeTests: XCTestCase { let encodedReason = try JSONEncoder().encode(CurrentTime.AdjustReason.manualTimeUpdate) let rawValue = try JSONDecoder().decode(UInt8.self, from: encodedReason) - XCTAssertEqual(rawValue, 0x01) // TODO: test for all? + XCTAssertEqual(rawValue, 0x01) } func testDateConversions() throws { From 394c1fd4b7d2fae2ec61490e558003c0fb5f54a7 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 25 Jun 2024 18:07:06 +0200 Subject: [PATCH 35/58] Add support for receiving the previous value as well --- .../Model/Properties/Characteristic.swift | 6 ++-- .../Model/Properties/DeviceState.swift | 3 +- .../CharacteristicAccessor.swift | 29 +++++++++++++++++-- .../CharacteristicPeripheralInjection.swift | 6 ++-- .../PropertySupport/DeviceStateAccessor.swift | 27 ++++++++++++++++- .../DeviceStatePeripheralInjection.swift | 10 ++----- .../Modifier/ScanNearbyDevicesModifier.swift | 2 ++ .../Utils/ChangeSubscriptions.swift | 11 +++++-- 8 files changed, 76 insertions(+), 18 deletions(-) diff --git a/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift b/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift index b98cbb01..06522102 100644 --- a/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift +++ b/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift @@ -42,7 +42,8 @@ import Foundation /// by supplying the `notify` initializer argument. /// /// - Tip: If you want to react to every change of the characteristic value, you can use -/// ``CharacteristicAccessor/onChange(initial:perform:)`` to set up your action. +/// ``CharacteristicAccessor/onChange(initial:perform:)-6ltwk`` or +/// ``CharacteristicAccessor/onChange(initial:perform:)-5awby`` 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. @@ -145,7 +146,8 @@ import Foundation /// - ``CharacteristicAccessor/enableNotifications(_:)`` /// /// ### Get notified about changes -/// - ``CharacteristicAccessor/onChange(initial:perform:)`` +/// - ``CharacteristicAccessor/onChange(initial:perform:)-6ltwk`` +/// - ``CharacteristicAccessor/onChange(initial:perform:)-5awby`` /// /// ### Control Point Characteristics /// - ``ControlPointCharacteristic`` diff --git a/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift b/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift index ae8f545e..44eb4395 100644 --- a/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift +++ b/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift @@ -73,7 +73,8 @@ import Observation /// - ``BluetoothPeripheral/advertisementData`` /// /// ### Get notified about changes -/// - ``DeviceStateAccessor/onChange(initial:perform:)`` +/// - ``DeviceStateAccessor/onChange(initial:perform:)-8x9cj`` +/// - ``DeviceStateAccessor/onChange(initial:perform:)-9igc9`` /// /// ### Property wrapper access /// - ``wrappedValue`` diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift index 7050f4fa..696db5b9 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift @@ -45,7 +45,8 @@ struct CharacteristicTestInjections { /// - ``enableNotifications(_:)`` /// /// ### Get notified about changes -/// - ``onChange(initial:perform:)`` +/// - ``onChange(initial:perform:)-6ltwk`` +/// - ``onChange(initial:perform:)-5awby`` /// /// ### Control Point Characteristics /// - ``sendRequest(_:timeout:)`` @@ -146,7 +147,31 @@ extension CharacteristicAccessor where Value: ByteDecodable { /// - initial: Whether the action should be run with the initial characteristic value. /// Otherwise, the action will only run strictly if the value changes. /// - action: The change handler to register. - public func onChange(initial: Bool = false, @_implicitSelfCapture perform action: @escaping (Value) async -> Void) { + public func onChange(initial: Bool = false, perform action: @escaping (_ value: Value) async -> Void) { + onChange(initial: initial) { _, newValue in + await action(newValue) + } + } + + /// Perform action whenever the characteristic value changes. + /// + /// Register a change handler with the characteristic that is called every time the value changes. + /// + /// - Note: `onChange` handlers are bound to the lifetime of the device. If you need to control the lifetime yourself refer to using ``subscription``. + /// + /// Note that you cannot set up onChange handlers within the initializers. + /// Use the [`configure()`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module/configure()-5pa83) to set up + /// all your handlers. + /// - Important: You must capture `self` weakly only. Capturing `self` strongly causes a memory leak. + /// + /// - Note: 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). + /// + /// - Parameters: + /// - initial: Whether the action should be run with the initial characteristic value. + /// Otherwise, the action will only run strictly if the value changes. + /// - action: The change handler to register, receiving both the old and new value. + public func onChange(initial: Bool = false, perform action: @escaping (_ oldValue: Value, _ newValue: Value) async -> Void) { guard let injection else { preconditionFailure( """ diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift index dd45121f..e5a32f41 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift @@ -121,7 +121,7 @@ actor CharacteristicPeripheralInjection: BluetoothActor { subscriptions.newSubscription() } - nonisolated func newOnChangeSubscription(initial: Bool, perform action: @escaping (Value) async -> Void) { + nonisolated func newOnChangeSubscription(initial: Bool, perform action: @escaping (_ oldValue: Value, _ newValue: Value) async -> Void) { let id = subscriptions.newOnChangeSubscription(perform: action) // Must be called detached, otherwise it might inherit TaskLocal values which includes Spezi moduleInitContext @@ -131,14 +131,14 @@ actor CharacteristicPeripheralInjection: BluetoothActor { } } - private func handleInitialCall(id: UUID, initial: Bool, action: (Value) async -> Void) async { + private func handleInitialCall(id: UUID, initial: Bool, action: (_ oldValue: Value, _ newValue: Value) async -> Void) async { if nonInitialChangeHandlers != nil { if !initial { nonInitialChangeHandlers?.insert(id) } } else if initial, let value { // nonInitialChangeHandlers is nil, meaning the initial value already arrived and we can call the action instantly if they wanted that - await action(value) + subscriptions.notifySubscriber(id: id, with: value) } } diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift index ba053c28..4c5781bb 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift @@ -14,7 +14,8 @@ /// ## Topics /// /// ### Get notified about changes -/// - ``onChange(initial:perform:)`` +/// - ``onChange(initial:perform:)-8x9cj`` +/// - ``onChange(initial:perform:)-9igc9`` public struct DeviceStateAccessor { private let id: ObjectIdentifier private let injection: DeviceStatePeripheralInjection? @@ -62,6 +63,30 @@ extension DeviceStateAccessor { /// strictly if the value changes. /// - action: The change handler to register. public func onChange(initial: Bool = false, perform action: @escaping (Value) async -> Void) { + onChange(initial: true) { _, newValue in + await action(newValue) + } + } + + /// Perform action whenever the state value changes. + /// + /// Register a change handler with the device state that is called every time the value changes. + /// + /// - Note: `onChange` handlers are bound to the lifetime of the device. If you need to control the lifetime yourself refer to using ``subscription``. + /// + /// Note that you cannot set up onChange handlers within the initializers. + /// Use the [`configure()`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module/configure()-5pa83) to set up + /// all your handlers. + /// - Important: You must capture `self` weakly only. Capturing `self` strongly causes a memory leak. + /// + /// - Note: 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). + /// + /// - Parameters: + /// - initial: Whether the action should be run with the initial state value. Otherwise, the action will only run + /// strictly if the value changes. + /// - action: The change handler to register, receiving both the old and new value. + public func onChange(initial: Bool = false, perform action: @escaping (_ oldValue: Value, _ newValue: Value) async -> Void) { guard let injection else { preconditionFailure( """ diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift index b117f904..100a9495 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift @@ -60,16 +60,12 @@ actor DeviceStatePeripheralInjection: BluetoothActor { subscriptions.newSubscription() } - nonisolated func newOnChangeSubscription(initial: Bool, perform action: @escaping (Value) async -> Void) { - subscriptions.newOnChangeSubscription(perform: action) + nonisolated func newOnChangeSubscription(initial: Bool, perform action: @escaping (_ oldValue: Value, _ newValue: Value) async -> Void) { + let id = subscriptions.newOnChangeSubscription(perform: action) if initial { let value = peripheral[keyPath: accessKeyPath] - Task { @SpeziBluetooth in - await self.isolated { _ in - await action(value) - } - } + subscriptions.notifySubscriber(id: id, with: value) } } diff --git a/Sources/SpeziBluetooth/Modifier/ScanNearbyDevicesModifier.swift b/Sources/SpeziBluetooth/Modifier/ScanNearbyDevicesModifier.swift index 1bbdcc3d..3f81c1cf 100644 --- a/Sources/SpeziBluetooth/Modifier/ScanNearbyDevicesModifier.swift +++ b/Sources/SpeziBluetooth/Modifier/ScanNearbyDevicesModifier.swift @@ -117,6 +117,8 @@ extension View { advertisementStaleInterval: TimeInterval = BluetoothManager.Defaults.defaultStaleTimeout, autoConnect: Bool = false ) -> some View { + // TODO: configure options from the environment? + // TODO: how to reduce multiple scanner options? scanNearbyDevices(enabled: enabled, scanner: bluetooth, state: BluetoothModuleDiscoveryState( minimumRSSI: minimumRSSI, advertisementStaleInterval: advertisementStaleInterval, diff --git a/Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift b/Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift index a51a2145..72d41593 100644 --- a/Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift +++ b/Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift @@ -39,6 +39,10 @@ class ChangeSubscriptions: @unchecked Sendable { } } + func notifySubscriber(id: UUID, with value: Value) { + continuations[id]?.yield(value) + } + private func _newSubscription() -> Registration { let id = UUID() let stream = AsyncStream { continuation in @@ -65,21 +69,24 @@ class ChangeSubscriptions: @unchecked Sendable { } @discardableResult - func newOnChangeSubscription(perform action: @escaping (Value) async -> Void) -> UUID { + func newOnChangeSubscription(perform action: @escaping (_ oldValue: Value, _ newValue: Value) async -> Void) -> UUID { let registration = _newSubscription() // It's important to use a detached Task here. // Otherwise it might inherit TaskLocal values which might include Spezi moduleInitContext // which would create a strong reference to the device. let task = Task.detached { @SpeziBluetooth [weak self, dispatch] in + var currentValue: Value? + for await element in registration.subscription { guard self != nil else { return } await dispatch.isolated { _ in - await action(element) + await action(currentValue ?? element, element) } + currentValue = element } self?.lock.withLock { From 41f2ce42725376c7c12cfedbd9bf7bba22c0fc01 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 25 Jun 2024 19:31:32 +0200 Subject: [PATCH 36/58] Merge scanning options from the environment --- .../Model/DiscoverySession.swift | 22 ++++++++++-- .../SurroundingScanModifiers.swift | 35 +++++++++++++++---- .../Modifier/BluetoothScanner.swift | 8 ++++- .../Modifier/ScanNearbyDevicesModifier.swift | 15 +++++--- 4 files changed, 67 insertions(+), 13 deletions(-) diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/DiscoverySession.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/DiscoverySession.swift index 16499c5c..c7107087 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/DiscoverySession.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/DiscoverySession.swift @@ -11,7 +11,7 @@ import Foundation import OSLog -struct BluetoothManagerDiscoveryState { +struct BluetoothManagerDiscoveryState: BluetoothScanningState { /// The device descriptions describing how nearby devices are discovered. let configuredDevices: Set /// The minimum rssi that is required for a device to be considered discovered. @@ -28,10 +28,20 @@ struct BluetoothManagerDiscoveryState { self.advertisementStaleInterval = max(1, advertisementStaleInterval) self.autoConnect = autoConnect } + + func merging(with other: BluetoothManagerDiscoveryState) -> BluetoothManagerDiscoveryState { + BluetoothManagerDiscoveryState( + configuredDevices: configuredDevices.union(other.configuredDevices), + minimumRSSI: max(minimumRSSI, other.minimumRSSI), + advertisementStaleInterval: max(advertisementStaleInterval, other.advertisementStaleInterval), + autoConnect: autoConnect || other.autoConnect + ) + } } -struct BluetoothModuleDiscoveryState { // intermediate storage object that is later translated to a BluetoothManagerDiscoveryState +/// Intermediate storage object that is later translated to a BluetoothManagerDiscoveryState. +struct BluetoothModuleDiscoveryState: BluetoothScanningState { /// The minimum rssi that is required for a device to be considered discovered. let minimumRSSI: Int /// The time interval after which an advertisement is considered stale and the device is removed. @@ -45,6 +55,14 @@ struct BluetoothModuleDiscoveryState { // intermediate storage object that is la self.advertisementStaleInterval = advertisementStaleInterval self.autoConnect = autoConnect } + + func merging(with other: BluetoothModuleDiscoveryState) -> BluetoothModuleDiscoveryState { + BluetoothModuleDiscoveryState( + minimumRSSI: max(minimumRSSI, other.minimumRSSI), + advertisementStaleInterval: max(advertisementStaleInterval, other.advertisementStaleInterval), + autoConnect: autoConnect || other.autoConnect + ) + } } diff --git a/Sources/SpeziBluetooth/Environment/SurroundingScanModifiers.swift b/Sources/SpeziBluetooth/Environment/SurroundingScanModifiers.swift index 4828953c..b486d82b 100644 --- a/Sources/SpeziBluetooth/Environment/SurroundingScanModifiers.swift +++ b/Sources/SpeziBluetooth/Environment/SurroundingScanModifiers.swift @@ -9,20 +9,43 @@ import SwiftUI +@Observable class SurroundingScanModifiers: EnvironmentKey { static let defaultValue = SurroundingScanModifiers() - @MainActor private var registeredModifiers: [AnyHashable: Set] = [:] + @MainActor private var registeredModifiers: [AnyHashable: [UUID: any BluetoothScanningState]] = [:] @MainActor - func setModifierScanningState(enabled: Bool, with scanner: Scanner, modifierId: UUID) { + func setModifierScanningState(enabled: Bool, with scanner: Scanner, modifierId: UUID, state: Scanner.ScanningState) { if enabled { - registeredModifiers[AnyHashable(scanner.id), default: []] - .insert(modifierId) + registeredModifiers[AnyHashable(scanner.id), default: [:]] + .updateValue(state, forKey: modifierId) } else { - registeredModifiers[AnyHashable(scanner.id), default: []] - .remove(modifierId) + registeredModifiers[AnyHashable(scanner.id), default: [:]] + .removeValue(forKey: modifierId) + + if registeredModifiers[AnyHashable(scanner.id)]?.isEmpty == true { + registeredModifiers[AnyHashable(scanner.id)] = nil + } + } + } + + @MainActor + func retrieveReducedScanningState(for scanner: Scanner) -> Scanner.ScanningState? { + guard let entries = registeredModifiers[AnyHashable(scanner.id)] else { + return nil } + + return entries.values + .compactMap { anyState in + anyState as? Scanner.ScanningState + } + .reduce(nil) { partialResult, state in + guard let partialResult else { + return state + } + return partialResult.merging(with: state) + } } @MainActor diff --git a/Sources/SpeziBluetooth/Modifier/BluetoothScanner.swift b/Sources/SpeziBluetooth/Modifier/BluetoothScanner.swift index cd5031b9..85739bc1 100644 --- a/Sources/SpeziBluetooth/Modifier/BluetoothScanner.swift +++ b/Sources/SpeziBluetooth/Modifier/BluetoothScanner.swift @@ -6,11 +6,17 @@ // SPDX-License-Identifier: MIT // +protocol BluetoothScanningState: Equatable { + /// Merge with another state. Order should not matter in the operation. + /// - Parameter other: The other state to merge with + func merging(with other: Self) -> Self +} + /// Any kind of Bluetooth Scanner. protocol BluetoothScanner: Identifiable where ID: Hashable { /// Captures state required to start scanning. - associatedtype ScanningState: Equatable + associatedtype ScanningState: BluetoothScanningState /// Indicates if there is at least one connected peripheral. /// diff --git a/Sources/SpeziBluetooth/Modifier/ScanNearbyDevicesModifier.swift b/Sources/SpeziBluetooth/Modifier/ScanNearbyDevicesModifier.swift index 3f81c1cf..8916b5ff 100644 --- a/Sources/SpeziBluetooth/Modifier/ScanNearbyDevicesModifier.swift +++ b/Sources/SpeziBluetooth/Modifier/ScanNearbyDevicesModifier.swift @@ -48,8 +48,16 @@ private struct ScanNearbyDevicesModifier: ViewModifie } } .onChange(of: state, initial: false) { + if enabled { + surroundingModifiers.setModifierScanningState(enabled: enabled, with: scanner, modifierId: modifierId, state: state) + } + } + .onChange(of: surroundingModifiers.retrieveReducedScanningState(for: scanner)) { _, newValue in + guard let newValue else { + return + } Task { - await scanner.updateScanningState(state) + await scanner.updateScanningState(newValue) } } } @@ -57,7 +65,7 @@ private struct ScanNearbyDevicesModifier: ViewModifie @MainActor private func onForeground() { if enabled { - surroundingModifiers.setModifierScanningState(enabled: true, with: scanner, modifierId: modifierId) + surroundingModifiers.setModifierScanningState(enabled: true, with: scanner, modifierId: modifierId, state: state) Task { await scanner.scanNearbyDevices(state) } @@ -66,7 +74,7 @@ private struct ScanNearbyDevicesModifier: ViewModifie @MainActor private func onBackground() { - surroundingModifiers.setModifierScanningState(enabled: false, with: scanner, modifierId: modifierId) + surroundingModifiers.setModifierScanningState(enabled: false, with: scanner, modifierId: modifierId, state: state) if surroundingModifiers.hasPersistentInterest(for: scanner) { return // don't stop scanning if a surrounding modifier is expecting a scan to continue @@ -118,7 +126,6 @@ extension View { autoConnect: Bool = false ) -> some View { // TODO: configure options from the environment? - // TODO: how to reduce multiple scanner options? scanNearbyDevices(enabled: enabled, scanner: bluetooth, state: BluetoothModuleDiscoveryState( minimumRSSI: minimumRSSI, advertisementStaleInterval: advertisementStaleInterval, From b82f3aa3c8140a95b40a150b68f46a8eef535086 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 25 Jun 2024 20:12:20 +0200 Subject: [PATCH 37/58] Support supplying Bluetooth options via the environment --- Sources/SpeziBluetooth/Bluetooth.swift | 4 +- .../CoreBluetooth/BluetoothManager.swift | 16 ++--- .../Model/DiscoverySession.swift | 69 ++++++++++++++----- ...rtisementStaleIntervalEnvironmentKey.swift | 26 +++++++ .../MinimumRSSIEnvironmentKey.swift | 26 +++++++ .../Modifier/AutoConnectModifier.swift | 16 ++--- .../Modifier/BluetoothScanner.swift | 5 ++ .../Modifier/ScanNearbyDevicesModifier.swift | 44 ++++++++---- 8 files changed, 159 insertions(+), 47 deletions(-) create mode 100644 Sources/SpeziBluetooth/Environment/AdvertisementStaleIntervalEnvironmentKey.swift create mode 100644 Sources/SpeziBluetooth/Environment/MinimumRSSIEnvironmentKey.swift diff --git a/Sources/SpeziBluetooth/Bluetooth.swift b/Sources/SpeziBluetooth/Bluetooth.swift index dded92f8..80907152 100644 --- a/Sources/SpeziBluetooth/Bluetooth.swift +++ b/Sources/SpeziBluetooth/Bluetooth.swift @@ -557,8 +557,8 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { /// - 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( - minimumRSSI: Int = BluetoothManager.Defaults.defaultMinimumRSSI, - advertisementStaleInterval: TimeInterval = BluetoothManager.Defaults.defaultStaleTimeout, + minimumRSSI: Int? = nil, + advertisementStaleInterval: TimeInterval? = nil, autoConnect: Bool = false ) { bluetoothManager.assumeIsolated { manager in diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift index 1042180a..3c8e13d9 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift @@ -265,8 +265,8 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable /// the nearby device if only one is found for a given time threshold. public func scanNearbyDevices( discovery: Set, - minimumRSSI: Int = Defaults.defaultMinimumRSSI, - advertisementStaleInterval: TimeInterval = Defaults.defaultStaleTimeout, + minimumRSSI: Int? = nil, + advertisementStaleInterval: TimeInterval? = nil, autoConnect: Bool = false ) { let state = BluetoothManagerDiscoveryState( @@ -537,7 +537,7 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable private func discardDevice(device: BluetoothPeripheral) { if let discoverySession, isScanning { // we will keep discarded devices for max 2s before the stale timer kicks off - let backdateInterval = max(0, discoverySession.assumeIsolated { $0.configuration.advertisementStaleInterval } - 2) + let backdateInterval = max(0, discoverySession.assumeIsolated { $0.advertisementStaleInterval } - 2) device.assumeIsolated { device in device.markLastActivity(.now - backdateInterval) @@ -692,13 +692,13 @@ extension BluetoothManager: BluetoothScanner { // MARK: Defaults extension BluetoothManager { /// Set of default values used within the Bluetooth Manager - public enum Defaults { + enum Defaults { /// The default timeout after which stale advertisements are removed. - public static let defaultStaleTimeout: TimeInterval = 8 + static let defaultStaleTimeout: TimeInterval = 8 /// The minimum rssi of a peripheral to consider it for discovery. - public static let defaultMinimumRSSI = -80 + 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 = 1 + static let defaultAutoConnectDebounce: Int = 1 } } @@ -804,7 +804,7 @@ extension BluetoothManager { logger.debug("Discovered peripheral \(peripheral.debugIdentifier) at \(rssi.intValue) dB (data: \(advertisementData))") - let descriptor = session.assumeIsolated { $0.configuration.configuredDevices } + let descriptor = session.assumeIsolated { $0.configuredDevices } .find(for: data, logger: logger) let device = BluetoothPeripheral( manager: manager, diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/DiscoverySession.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/DiscoverySession.swift index c7107087..98e8f7c9 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/DiscoverySession.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/DiscoverySession.swift @@ -10,47 +10,63 @@ import class CoreBluetooth.CBUUID import Foundation import OSLog +private func optionalMax(_ lhs: Value?, _ rhs: Value?) -> Value? { + guard let lhs, let rhs else { + return lhs ?? rhs + } + return max(lhs, rhs) +} + struct BluetoothManagerDiscoveryState: BluetoothScanningState { /// The device descriptions describing how nearby devices are discovered. let configuredDevices: Set /// The minimum rssi that is required for a device to be considered discovered. - let minimumRSSI: Int + let minimumRSSI: Int? /// The time interval after which an advertisement is considered stale and the device is removed. - let advertisementStaleInterval: TimeInterval + let advertisementStaleInterval: TimeInterval? /// Flag indicating if first discovered device should be auto-connected. let autoConnect: Bool - init(configuredDevices: Set, minimumRSSI: Int, advertisementStaleInterval: TimeInterval, autoConnect: Bool) { + init(configuredDevices: Set, minimumRSSI: Int?, advertisementStaleInterval: TimeInterval?, autoConnect: Bool) { self.configuredDevices = configuredDevices self.minimumRSSI = minimumRSSI - self.advertisementStaleInterval = max(1, advertisementStaleInterval) + self.advertisementStaleInterval = advertisementStaleInterval.map { max(1, $0) } self.autoConnect = autoConnect } func merging(with other: BluetoothManagerDiscoveryState) -> BluetoothManagerDiscoveryState { BluetoothManagerDiscoveryState( configuredDevices: configuredDevices.union(other.configuredDevices), - minimumRSSI: max(minimumRSSI, other.minimumRSSI), - advertisementStaleInterval: max(advertisementStaleInterval, other.advertisementStaleInterval), + minimumRSSI: optionalMax(minimumRSSI, other.minimumRSSI), + advertisementStaleInterval: optionalMax(advertisementStaleInterval, other.advertisementStaleInterval), autoConnect: autoConnect || other.autoConnect ) } + + func updateOptions(minimumRSSI: Int?, advertisementStaleInterval: TimeInterval?) -> BluetoothManagerDiscoveryState { + BluetoothManagerDiscoveryState( + configuredDevices: configuredDevices, + minimumRSSI: optionalMax(self.minimumRSSI, minimumRSSI), + advertisementStaleInterval: optionalMax(self.advertisementStaleInterval, advertisementStaleInterval), + autoConnect: autoConnect + ) + } } /// Intermediate storage object that is later translated to a BluetoothManagerDiscoveryState. struct BluetoothModuleDiscoveryState: BluetoothScanningState { /// The minimum rssi that is required for a device to be considered discovered. - let minimumRSSI: Int + let minimumRSSI: Int? /// The time interval after which an advertisement is considered stale and the device is removed. - let advertisementStaleInterval: TimeInterval + let advertisementStaleInterval: TimeInterval? /// Flag indicating if first discovered device should be auto-connected. let autoConnect: Bool - init(minimumRSSI: Int, advertisementStaleInterval: TimeInterval, autoConnect: Bool) { + init(minimumRSSI: Int?, advertisementStaleInterval: TimeInterval?, autoConnect: Bool) { self.minimumRSSI = minimumRSSI self.advertisementStaleInterval = advertisementStaleInterval self.autoConnect = autoConnect @@ -58,11 +74,19 @@ struct BluetoothModuleDiscoveryState: BluetoothScanningState { func merging(with other: BluetoothModuleDiscoveryState) -> BluetoothModuleDiscoveryState { BluetoothModuleDiscoveryState( - minimumRSSI: max(minimumRSSI, other.minimumRSSI), - advertisementStaleInterval: max(advertisementStaleInterval, other.advertisementStaleInterval), + minimumRSSI: optionalMax(minimumRSSI, other.minimumRSSI), + advertisementStaleInterval: optionalMax(advertisementStaleInterval, other.advertisementStaleInterval), autoConnect: autoConnect || other.autoConnect ) } + + func updateOptions(minimumRSSI: Int?, advertisementStaleInterval: TimeInterval?) -> BluetoothModuleDiscoveryState { + BluetoothModuleDiscoveryState( + minimumRSSI: optionalMax(self.minimumRSSI, minimumRSSI), + advertisementStaleInterval: optionalMax(self.advertisementStaleInterval, advertisementStaleInterval), + autoConnect: autoConnect + ) + } } @@ -73,7 +97,7 @@ actor DiscoverySession: BluetoothActor { fileprivate weak var manager: BluetoothManager? - private(set) var configuration: BluetoothManagerDiscoveryState + private var configuration: BluetoothManagerDiscoveryState /// The identifier of the last manually disconnected device. /// This is to avoid automatically reconnecting to a device that was manually disconnected. @@ -82,6 +106,17 @@ actor DiscoverySession: BluetoothActor { private var autoConnectItem: BluetoothWorkItem? private(set) var staleTimer: DiscoveryStaleTimer? + var configuredDevices: Set { + configuration.configuredDevices + } + + var minimumRSSI: Int { + configuration.minimumRSSI ?? BluetoothManager.Defaults.defaultMinimumRSSI + } + + var advertisementStaleInterval: TimeInterval { + configuration.advertisementStaleInterval ?? BluetoothManager.Defaults.defaultStaleTimeout + } /// The set of serviceIds we request to discover upon scanning. /// Returning nil means scanning for all peripherals. @@ -105,7 +140,7 @@ actor DiscoverySession: BluetoothActor { func isInRange(rssi: NSNumber) -> Bool { // rssi of 127 is a magic value signifying unavailability of the value. - rssi.intValue >= configuration.minimumRSSI && rssi.intValue != 127 + rssi.intValue >= minimumRSSI && rssi.intValue != 127 } func deviceManuallyDisconnected(id uuid: UUID) { @@ -122,7 +157,7 @@ actor DiscoverySession: BluetoothActor { if newlyDiscovered { if staleTimer == nil { // There is no stale timer running. So new device will be the one with the oldest activity. Schedule ... - scheduleStaleTask(for: device, withTimeout: configuration.advertisementStaleInterval) + scheduleStaleTask(for: device, withTimeout: advertisementStaleInterval) } } else { if cancelStaleTask(for: device) { @@ -227,7 +262,7 @@ extension DiscoverySession { let lastActivity = oldestActivityDevice.assumeIsolated { $0.lastActivity } let intervalSinceLastActivity = Date.now.timeIntervalSince(lastActivity) - let nextTimeout = max(0, configuration.advertisementStaleInterval - intervalSinceLastActivity) + let nextTimeout = max(0, advertisementStaleInterval - intervalSinceLastActivity) scheduleStaleTask(for: oldestActivityDevice, withTimeout: nextTimeout) } @@ -273,12 +308,12 @@ extension DiscoverySession { staleTimer = nil // reset the timer - let configuration = configuration + let staleInternal = advertisementStaleInterval let staleDevices = manager.assumeIsolated { $0.discoveredPeripherals } .values .filter { device in device.assumeIsolated { isolated in - isolated.isConsideredStale(interval: configuration.advertisementStaleInterval) + isolated.isConsideredStale(interval: staleInternal) } } diff --git a/Sources/SpeziBluetooth/Environment/AdvertisementStaleIntervalEnvironmentKey.swift b/Sources/SpeziBluetooth/Environment/AdvertisementStaleIntervalEnvironmentKey.swift new file mode 100644 index 00000000..9c2a586d --- /dev/null +++ b/Sources/SpeziBluetooth/Environment/AdvertisementStaleIntervalEnvironmentKey.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 SwiftUI + + +private struct AdvertisementStaleIntervalEnvironmentKey: EnvironmentKey { + static let defaultValue: TimeInterval? = nil +} + + +extension EnvironmentValues { + public var advertisementStaleInterval: TimeInterval? { + get { + self[AdvertisementStaleIntervalEnvironmentKey.self] + } + set { + self[AdvertisementStaleIntervalEnvironmentKey.self] = newValue + } + } +} diff --git a/Sources/SpeziBluetooth/Environment/MinimumRSSIEnvironmentKey.swift b/Sources/SpeziBluetooth/Environment/MinimumRSSIEnvironmentKey.swift new file mode 100644 index 00000000..395f6b5b --- /dev/null +++ b/Sources/SpeziBluetooth/Environment/MinimumRSSIEnvironmentKey.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 SwiftUI + + +private struct MinimumRSSIEnvironmentKey: EnvironmentKey { + static let defaultValue: Int? = nil +} + + +extension EnvironmentValues { + public var minimumRSSI: Int? { + get { + self[MinimumRSSIEnvironmentKey.self] + } + set { + self[MinimumRSSIEnvironmentKey.self] = newValue + } + } +} diff --git a/Sources/SpeziBluetooth/Modifier/AutoConnectModifier.swift b/Sources/SpeziBluetooth/Modifier/AutoConnectModifier.swift index ffbfa295..4fe3e007 100644 --- a/Sources/SpeziBluetooth/Modifier/AutoConnectModifier.swift +++ b/Sources/SpeziBluetooth/Modifier/AutoConnectModifier.swift @@ -24,15 +24,15 @@ extension View { /// - Parameters: /// - enabled: Flag indicating if nearby device scanning is enabled. /// - bluetooth: The Bluetooth Module to use for scanning. - /// - minimumRSSI: The minimum rssi a nearby peripheral must have to be considered nearby. + /// - minimumRSSI: The minimum rssi a nearby peripheral must have to be considered nearby. Supply `nil` to use default the default value or a value from the environment. /// - advertisementStaleInterval: The time interval after which a peripheral advertisement is considered stale - /// if we don't hear back from the device. Minimum is 1 second. + /// if we don't hear back from the device. Minimum is 1 second. Supply `nil` to use default the default value or a value from the environment. /// - Returns: The modified view. public func autoConnect( // swiftlint:disable:this function_default_parameter_at_end enabled: Bool = true, with bluetooth: Bluetooth, - minimumRSSI: Int = BluetoothManager.Defaults.defaultMinimumRSSI, - advertisementStaleInterval: TimeInterval = BluetoothManager.Defaults.defaultStaleTimeout + minimumRSSI: Int? = nil, + advertisementStaleInterval: TimeInterval? = nil ) -> some View { scanNearbyDevices(enabled: enabled && !bluetooth.hasConnectedDevices, scanner: bluetooth, state: BluetoothModuleDiscoveryState( minimumRSSI: minimumRSSI, @@ -54,16 +54,16 @@ extension View { /// - enabled: Flag indicating if nearby device scanning is enabled. /// - bluetoothManager: The Bluetooth Manager to use for scanning. /// - discovery: The set of device description describing **how** and **what** to discover. - /// - minimumRSSI: The minimum rssi a nearby peripheral must have to be considered nearby. + /// - minimumRSSI: The minimum rssi a nearby peripheral must have to be considered nearby. Supply `nil` to use default the default value or a value from the environment. /// - advertisementStaleInterval: The time interval after which a peripheral advertisement is considered stale - /// if we don't hear back from the device. Minimum is 1 second. + /// if we don't hear back from the device. Minimum is 1 second. Supply `nil` to use default the default value or a value from the environment. /// - Returns: The modified view. public func autoConnect( // swiftlint:disable:this function_default_parameter_at_end enabled: Bool = true, with bluetoothManager: BluetoothManager, discovery: Set, - minimumRSSI: Int = BluetoothManager.Defaults.defaultMinimumRSSI, - advertisementStaleInterval: TimeInterval = BluetoothManager.Defaults.defaultStaleTimeout + minimumRSSI: Int? = nil, + advertisementStaleInterval: TimeInterval? = nil ) -> some View { scanNearbyDevices(enabled: enabled && !bluetoothManager.hasConnectedDevices, scanner: bluetoothManager, state: BluetoothManagerDiscoveryState( configuredDevices: discovery, diff --git a/Sources/SpeziBluetooth/Modifier/BluetoothScanner.swift b/Sources/SpeziBluetooth/Modifier/BluetoothScanner.swift index 85739bc1..ad2728c1 100644 --- a/Sources/SpeziBluetooth/Modifier/BluetoothScanner.swift +++ b/Sources/SpeziBluetooth/Modifier/BluetoothScanner.swift @@ -6,10 +6,15 @@ // SPDX-License-Identifier: MIT // +import Foundation + + protocol BluetoothScanningState: Equatable { /// Merge with another state. Order should not matter in the operation. /// - Parameter other: The other state to merge with func merging(with other: Self) -> Self + + func updateOptions(minimumRSSI: Int?, advertisementStaleInterval: TimeInterval?) -> Self } diff --git a/Sources/SpeziBluetooth/Modifier/ScanNearbyDevicesModifier.swift b/Sources/SpeziBluetooth/Modifier/ScanNearbyDevicesModifier.swift index 8916b5ff..75eb846a 100644 --- a/Sources/SpeziBluetooth/Modifier/ScanNearbyDevicesModifier.swift +++ b/Sources/SpeziBluetooth/Modifier/ScanNearbyDevicesModifier.swift @@ -20,6 +20,11 @@ private struct ScanNearbyDevicesModifier: ViewModifie @Environment(\.surroundingScanModifiers) private var surroundingModifiers + @Environment(\.minimumRSSI) + private var minimumRSSI + @Environment(\.advertisementStaleInterval) + private var advertisementStaleInterval + @State private var modifierId = UUID() init(enabled: Bool, scanner: Scanner, state: Scanner.ScanningState) { @@ -49,7 +54,17 @@ private struct ScanNearbyDevicesModifier: ViewModifie } .onChange(of: state, initial: false) { if enabled { - surroundingModifiers.setModifierScanningState(enabled: enabled, with: scanner, modifierId: modifierId, state: state) + updateScanningState(enabled: enabled) + } + } + .onChange(of: minimumRSSI) { + if enabled { + updateScanningState(enabled: enabled) + } + } + .onChange(of: advertisementStaleInterval) { + if enabled { + updateScanningState(enabled: enabled) } } .onChange(of: surroundingModifiers.retrieveReducedScanningState(for: scanner)) { _, newValue in @@ -65,7 +80,7 @@ private struct ScanNearbyDevicesModifier: ViewModifie @MainActor private func onForeground() { if enabled { - surroundingModifiers.setModifierScanningState(enabled: true, with: scanner, modifierId: modifierId, state: state) + updateScanningState(enabled: true) Task { await scanner.scanNearbyDevices(state) } @@ -74,7 +89,7 @@ private struct ScanNearbyDevicesModifier: ViewModifie @MainActor private func onBackground() { - surroundingModifiers.setModifierScanningState(enabled: false, with: scanner, modifierId: modifierId, state: state) + updateScanningState(enabled: false) if surroundingModifiers.hasPersistentInterest(for: scanner) { return // don't stop scanning if a surrounding modifier is expecting a scan to continue @@ -84,6 +99,12 @@ private struct ScanNearbyDevicesModifier: ViewModifie await scanner.stopScanning() } } + + @MainActor + private func updateScanningState(enabled: Bool) { + let state = state.updateOptions(minimumRSSI: minimumRSSI, advertisementStaleInterval: advertisementStaleInterval) + surroundingModifiers.setModifierScanningState(enabled: enabled, with: scanner, modifierId: modifierId, state: state) + } } @@ -113,19 +134,18 @@ extension View { /// - Parameters: /// - enabled: Flag indicating if nearby device scanning is enabled. /// - bluetooth: The Bluetooth Module to use for scanning. - /// - minimumRSSI: The minimum rssi a nearby peripheral must have to be considered nearby. + /// - minimumRSSI: The minimum rssi a nearby peripheral must have to be considered nearby. Supply `nil` to use default the default value or a value from the environment. /// - advertisementStaleInterval: The time interval after which a peripheral advertisement is considered stale - /// if we don't hear back from the device. Minimum is 1 second. + /// if we don't hear back from the device. Minimum is 1 second. Supply `nil` to use default the default value or a value from the environment. /// - autoConnect: If enabled, the bluetooth manager will automatically connect to the nearby device if only one is found. /// - Returns: The modified view. public func scanNearbyDevices( // swiftlint:disable:this function_default_parameter_at_end enabled: Bool = true, with bluetooth: Bluetooth, - minimumRSSI: Int = BluetoothManager.Defaults.defaultMinimumRSSI, - advertisementStaleInterval: TimeInterval = BluetoothManager.Defaults.defaultStaleTimeout, + minimumRSSI: Int? = nil, + advertisementStaleInterval: TimeInterval? = nil, autoConnect: Bool = false ) -> some View { - // TODO: configure options from the environment? scanNearbyDevices(enabled: enabled, scanner: bluetooth, state: BluetoothModuleDiscoveryState( minimumRSSI: minimumRSSI, advertisementStaleInterval: advertisementStaleInterval, @@ -152,17 +172,17 @@ extension View { /// - enabled: Flag indicating if nearby device scanning is enabled. /// - bluetoothManager: The Bluetooth Manager to use for scanning. /// - discovery: The set of device description describing **how** and **what** to discover. - /// - minimumRSSI: The minimum rssi a nearby peripheral must have to be considered nearby. + /// - minimumRSSI: The minimum rssi a nearby peripheral must have to be considered nearby. Supply `nil` to use default the default value or a value from the environment. /// - advertisementStaleInterval: The time interval after which a peripheral advertisement is considered stale - /// if we don't hear back from the device. Minimum is 1 second. + /// if we don't hear back from the device. Minimum is 1 second. Supply `nil` to use default the default value or a value from the environment. /// - autoConnect: If enabled, the bluetooth manager will automatically connect to the nearby device if only one is found. /// - Returns: The modified view. public func scanNearbyDevices( // swiftlint:disable:this function_default_parameter_at_end enabled: Bool = true, with bluetoothManager: BluetoothManager, discovery: Set, - minimumRSSI: Int = BluetoothManager.Defaults.defaultMinimumRSSI, - advertisementStaleInterval: TimeInterval = BluetoothManager.Defaults.defaultStaleTimeout, + minimumRSSI: Int? = nil, + advertisementStaleInterval: TimeInterval? = nil, autoConnect: Bool = false ) -> some View { scanNearbyDevices(enabled: enabled, scanner: bluetoothManager, state: BluetoothManagerDiscoveryState( From 9644307829a45f51606d074ba0edaea7a29b76f3 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 25 Jun 2024 20:14:54 +0200 Subject: [PATCH 38/58] Docs --- .../AdvertisementStaleIntervalEnvironmentKey.swift | 5 ++++- .../Environment/MinimumRSSIEnvironmentKey.swift | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Sources/SpeziBluetooth/Environment/AdvertisementStaleIntervalEnvironmentKey.swift b/Sources/SpeziBluetooth/Environment/AdvertisementStaleIntervalEnvironmentKey.swift index 9c2a586d..8f024656 100644 --- a/Sources/SpeziBluetooth/Environment/AdvertisementStaleIntervalEnvironmentKey.swift +++ b/Sources/SpeziBluetooth/Environment/AdvertisementStaleIntervalEnvironmentKey.swift @@ -15,12 +15,15 @@ private struct AdvertisementStaleIntervalEnvironmentKey: EnvironmentKey { extension EnvironmentValues { + /// The time interval after which a peripheral advertisement is considered stale if we don't hear back from the device. Minimum is 1 second. public var advertisementStaleInterval: TimeInterval? { get { self[AdvertisementStaleIntervalEnvironmentKey.self] } set { - self[AdvertisementStaleIntervalEnvironmentKey.self] = newValue + if let newValue, newValue >= 1 { + self[AdvertisementStaleIntervalEnvironmentKey.self] = newValue + } } } } diff --git a/Sources/SpeziBluetooth/Environment/MinimumRSSIEnvironmentKey.swift b/Sources/SpeziBluetooth/Environment/MinimumRSSIEnvironmentKey.swift index 395f6b5b..22498756 100644 --- a/Sources/SpeziBluetooth/Environment/MinimumRSSIEnvironmentKey.swift +++ b/Sources/SpeziBluetooth/Environment/MinimumRSSIEnvironmentKey.swift @@ -15,12 +15,15 @@ private struct MinimumRSSIEnvironmentKey: EnvironmentKey { extension EnvironmentValues { + /// The minimum rssi a nearby peripheral must have to be considered nearby. public var minimumRSSI: Int? { get { self[MinimumRSSIEnvironmentKey.self] } set { - self[MinimumRSSIEnvironmentKey.self] = newValue + if let newValue { + self[MinimumRSSIEnvironmentKey.self] = newValue + } } } } From 4ba8359d362a087da1edb24f8dcbdca7aa33edf9 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 25 Jun 2024 20:17:59 +0200 Subject: [PATCH 39/58] Update docs catalog --- Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift | 2 ++ Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift index 3c8e13d9..2a595af1 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift @@ -71,6 +71,8 @@ import OSLog /// - ``nearbyPeripherals`` /// - ``scanNearbyDevices(discovery:minimumRSSI:advertisementStaleInterval:autoConnect:)`` /// - ``stopScanning()`` +/// - ``SwiftUI/View/scanNearbyDevices(enabled:with:discovery:minimumRSSI:advertisementStaleInterval:autoConnect:)`` +/// - ``SwiftUI/View/autoConnect(enabled:with:discovery:minimumRSSI:advertisementStaleInterval:)`` /// /// ### Retrieving known Peripherals /// - ``retrievePeripheral(for:with:)`` diff --git a/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md b/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md index b3184c62..96383eac 100644 --- a/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md +++ b/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md @@ -273,6 +273,8 @@ due to their async nature. - ``SwiftUI/View/scanNearbyDevices(enabled:with:minimumRSSI:advertisementStaleInterval:autoConnect:)`` - ``SwiftUI/View/autoConnect(enabled:with:minimumRSSI:advertisementStaleInterval:)`` +- ``SwiftUI/EnvironmentValues/minimumRSSI`` +- ``SwiftUI/EnvironmentValues/advertisementStaleInterval`` - ``ConnectedDevices`` ### Declaring a Bluetooth Device From 7c91e9e3701f891687c5082e1e7a6c6df8402a62 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Wed, 26 Jun 2024 15:28:28 +0200 Subject: [PATCH 40/58] Update language around power and permission alerts --- Sources/SpeziBluetooth/Bluetooth.swift | 4 ++-- Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/SpeziBluetooth/Bluetooth.swift b/Sources/SpeziBluetooth/Bluetooth.swift index 80907152..eda79651 100644 --- a/Sources/SpeziBluetooth/Bluetooth.swift +++ b/Sources/SpeziBluetooth/Bluetooth.swift @@ -342,7 +342,7 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { /// Balance this call with a call to ``powerOff()``. /// /// - Note : The underlying `CBCentralManager` is lazily allocated and deallocated once it isn't needed anymore. - /// This is used to delay Bluetooth permission prompts to the latest possible moment avoiding to unexpectedly display power alerts. + /// This is used to delay Bluetooth permission and power prompts to the latest possible moment avoiding unexpected interruptions. public func powerOn() { bluetoothManager.assumeIsolated { manager in manager.powerOn() @@ -354,7 +354,7 @@ public actor Bluetooth: Module, EnvironmentAccessible, BluetoothActor { /// This method request to power off the central. This is delay if the central is still used (e.g., currently scanning or connected peripherals). /// /// - Note : The underlying `CBCentralManager` is lazily allocated and deallocated once it isn't needed anymore. - /// This is used to delay Bluetooth permission prompts to the latest possible moment avoiding to unexpectedly display power alerts. + /// This is used to delay Bluetooth permission and power prompts to the latest possible moment avoiding unexpected interruptions. public func powerOff() { bluetoothManager.assumeIsolated { manager in manager.powerOff() diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift index 2a595af1..3177cf7e 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift @@ -233,7 +233,7 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable /// Balance this call with a call to ``powerOff()``. /// /// - Note : The underlying `CBCentralManager` is lazily allocated and deallocated once it isn't needed anymore. - /// This is used to delay Bluetooth permission prompts to the latest possible moment avoiding to unexpectedly display power alerts. + /// This is used to delay Bluetooth permission and power prompts to the latest possible moment avoiding unexpected interruptions. public func powerOn() { keepPoweredOn = true _ = centralManager // ensure it is allocated @@ -244,7 +244,7 @@ public actor BluetoothManager: Observable, BluetoothActor { // swiftlint:disable /// This method request to power off the central. This is delay if the central is still used (e.g., currently scanning or connected peripherals). /// /// - Note : The underlying `CBCentralManager` is lazily allocated and deallocated once it isn't needed anymore. - /// This is used to delay Bluetooth permission prompts to the latest possible moment avoiding to unexpectedly display power alerts. + /// This is used to delay Bluetooth permission and power prompts to the latest possible moment avoiding unexpected interruptions. public func powerOff() { keepPoweredOn = false checkForCentralDeinit() From 9697b2af19ded615d0358f2d5283b7a861d87c53 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Wed, 26 Jun 2024 15:36:32 +0200 Subject: [PATCH 41/58] Enable StrictConcurrency and make SwiftLint not enable by default --- Package.swift | 52 +++++++++++++++---- Sources/SpeziBluetooth/Bluetooth.swift | 2 +- .../BluetoothManagerTests.swift | 1 + .../TestAppUITests/SpeziBluetoothTests.swift | 2 + .../UITests/UITests.xcodeproj/project.pbxproj | 3 ++ 5 files changed, 48 insertions(+), 12 deletions(-) diff --git a/Package.swift b/Package.swift index 435782e2..5e929452 100644 --- a/Package.swift +++ b/Package.swift @@ -8,9 +8,17 @@ // SPDX-License-Identifier: MIT // +import class Foundation.ProcessInfo import PackageDescription +#if swift(<6) +let swiftConcurrency: SwiftSetting = .enableExperimentalFeature("SwiftConcurrency") +#else +let swiftConcurrency: SwiftSetting = .enableUpcomingFeature("SwiftConcurrency") +#endif + + let package = Package( name: "SpeziBluetooth", defaultLocalization: "en", @@ -26,11 +34,10 @@ let package = Package( dependencies: [ .package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "1.1.0"), .package(url: "https://github.com/StanfordSpezi/Spezi", branch: "feature/externally-managed-modules"), - .package(url: "https://github.com/StanfordSpezi/SpeziNetworking", branch: "feature/medfloat16-codable"), + .package(url: "https://github.com/StanfordSpezi/SpeziNetworking", from: "2.1.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"), - .package(url: "https://github.com/realm/SwiftLint.git", .upToNextMinor(from: "0.55.1")) - ], + .package(url: "https://github.com/apple/swift-collections.git", from: "1.0.4") + ] + swiftLintPackage(), targets: [ .target( name: "SpeziBluetooth", @@ -44,7 +51,10 @@ let package = Package( resources: [ .process("Resources") ], - plugins: [.swiftLintPlugin] + swiftSettings: [ + swiftConcurrency + ], + plugins: [] + swiftLintPlugin() ), .target( name: "SpeziBluetoothServices", @@ -53,7 +63,10 @@ let package = Package( .product(name: "ByteCoding", package: "SpeziNetworking"), .product(name: "SpeziNumerics", package: "SpeziNetworking") ], - plugins: [.swiftLintPlugin] + swiftSettings: [ + swiftConcurrency + ], + plugins: [] + swiftLintPlugin() ), .executableTarget( name: "TestPeripheral", @@ -62,7 +75,10 @@ let package = Package( .target(name: "SpeziBluetoothServices"), .product(name: "ByteCoding", package: "SpeziNetworking") ], - plugins: [.swiftLintPlugin] + swiftSettings: [ + swiftConcurrency + ], + plugins: [] + swiftLintPlugin() ), .testTarget( name: "BluetoothServicesTests", @@ -72,14 +88,28 @@ let package = Package( .product(name: "XCTByteCoding", package: "SpeziNetworking"), .product(name: "NIO", package: "swift-nio") ], - plugins: [.swiftLintPlugin] + swiftSettings: [ + swiftConcurrency + ], + plugins: [] + swiftLintPlugin() ) ] ) -extension Target.PluginUsage { - static var swiftLintPlugin: Target.PluginUsage { - .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLint") +func swiftLintPlugin() -> [Target.PluginUsage] { + // Fully quit Xcode and open again with `open --env SPEZI_DEVELOPMENT_SWIFTLINT /Applications/Xcode.app` + if ProcessInfo.processInfo.environment["SPEZI_DEVELOPMENT_SWIFTLINT"] != nil { + [.plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLint")] + } else { + [] + } +} + +func swiftLintPackage() -> [PackageDescription.Package.Dependency] { + if ProcessInfo.processInfo.environment["SPEZI_DEVELOPMENT_SWIFTLINT"] != nil { + [.package(url: "https://github.com/realm/SwiftLint.git", .upToNextMinor(from: "0.55.1"))] + } else { + [] } } diff --git a/Sources/SpeziBluetooth/Bluetooth.swift b/Sources/SpeziBluetooth/Bluetooth.swift index eda79651..6ae7d187 100644 --- a/Sources/SpeziBluetooth/Bluetooth.swift +++ b/Sources/SpeziBluetooth/Bluetooth.swift @@ -616,7 +616,7 @@ extension Bluetooth { logger.warning( """ \(Device.self) is an empty device implementation. \ - The same peripheral might be instantiated via multiple \(Device.self) instances if not device property wrappers like + The same peripheral might be instantiated via multiple \(Device.self) instances if no device property wrappers like @Characteristic, @DeviceState or @DeviceAction is used. """ ) diff --git a/Tests/UITests/TestAppUITests/BluetoothManagerTests.swift b/Tests/UITests/TestAppUITests/BluetoothManagerTests.swift index 609d9b2b..5ff5e291 100644 --- a/Tests/UITests/TestAppUITests/BluetoothManagerTests.swift +++ b/Tests/UITests/TestAppUITests/BluetoothManagerTests.swift @@ -17,6 +17,7 @@ final class BluetoothManagerTests: XCTestCase { continueAfterFailure = false } + @MainActor func testSpeziBluetooth() throws { let app = XCUIApplication() app.launch() diff --git a/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift b/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift index 00c4fc92..d952efca 100644 --- a/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift +++ b/Tests/UITests/TestAppUITests/SpeziBluetoothTests.swift @@ -20,6 +20,7 @@ final class SpeziBluetoothTests: XCTestCase { continueAfterFailure = false } + @MainActor func testTestPeripheral() throws { // swiftlint:disable:this function_body_length let app = XCUIApplication() app.launch() @@ -142,6 +143,7 @@ final class SpeziBluetoothTests: XCTestCase { XCTAssertFalse(app.staticTexts["Connected TestDevice"].waitForExistence(timeout: 0.5)) } + @MainActor func testPairedDevice() throws { let app = XCUIApplication() app.launch() diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 07075e51..dc5de61c 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -376,6 +376,7 @@ SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; XROS_DEPLOYMENT_TARGET = 1.0; }; name = Debug; @@ -433,6 +434,7 @@ SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_STRICT_CONCURRENCY = complete; VALIDATE_PRODUCT = YES; XROS_DEPLOYMENT_TARGET = 1.0; }; @@ -633,6 +635,7 @@ SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = TEST; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; XROS_DEPLOYMENT_TARGET = 1.0; }; name = Test; From b07a47a7d0b420cbde7d3c7e8fd076c16eafb86c Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Wed, 26 Jun 2024 15:40:51 +0200 Subject: [PATCH 42/58] Remove unused code --- .../Modifier/ConnectedDevicesEnvironmentModifier.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/SpeziBluetooth/Modifier/ConnectedDevicesEnvironmentModifier.swift b/Sources/SpeziBluetooth/Modifier/ConnectedDevicesEnvironmentModifier.swift index 3570cfab..73ac328b 100644 --- a/Sources/SpeziBluetooth/Modifier/ConnectedDevicesEnvironmentModifier.swift +++ b/Sources/SpeziBluetooth/Modifier/ConnectedDevicesEnvironmentModifier.swift @@ -13,8 +13,6 @@ private struct ConnectedDeviceEnvironmentModifier: View @Environment(ConnectedDevicesModel.self) var connectedDevices - @State private var devicesList = ConnectedDevices() - init() {} From 1745d298ac096330a6b216f3b973fea912e79053 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Wed, 26 Jun 2024 15:44:31 +0200 Subject: [PATCH 43/58] Fix test compiling --- .../HealthThermometerTests.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/Tests/BluetoothServicesTests/HealthThermometerTests.swift b/Tests/BluetoothServicesTests/HealthThermometerTests.swift index aef08e8d..72b5bf1d 100644 --- a/Tests/BluetoothServicesTests/HealthThermometerTests.swift +++ b/Tests/BluetoothServicesTests/HealthThermometerTests.swift @@ -35,8 +35,15 @@ final class HealthThermometerTests: XCTestCase { } func testTemperatureType() throws { - for type in TemperatureType.allCases { - try testIdentity(from: type) - } + try testIdentity(from: TemperatureType.reserved) + try testIdentity(from: TemperatureType.armpit) + try testIdentity(from: TemperatureType.body) + try testIdentity(from: TemperatureType.ear) + try testIdentity(from: TemperatureType.finger) + try testIdentity(from: TemperatureType.gastrointestinalTract) + try testIdentity(from: TemperatureType.mouth) + try testIdentity(from: TemperatureType.rectum) + try testIdentity(from: TemperatureType.toe) + try testIdentity(from: TemperatureType.tympanum) } } From b10ca56a5c744299617f6ed59dee008c35e31d20 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Wed, 26 Jun 2024 16:52:35 +0200 Subject: [PATCH 44/58] Support more extensive device state and characteristic simulation --- .../Model/Properties/Characteristic.swift | 2 +- .../Model/Properties/DeviceState.swift | 30 +----- .../CharacteristicAccessor.swift | 99 ++++++++++++++++--- .../PropertySupport/DeviceStateAccessor.swift | 91 ++++++++++++++++- Sources/SpeziBluetooth/Utils/Box.swift | 38 +++++++ 5 files changed, 217 insertions(+), 43 deletions(-) diff --git a/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift b/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift index 06522102..59964510 100644 --- a/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift +++ b/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift @@ -181,7 +181,7 @@ public final class Characteristic: @unchecked Sendable { private let _value: ObservableBox private(set) var injection: CharacteristicPeripheralInjection? - private let _testInjections = Box(CharacteristicTestInjections()) + private let _testInjections: Box?> = Box(nil) var description: CharacteristicDescription { CharacteristicDescription(id: configuration.id, discoverDescriptors: configuration.discoverDescriptors) diff --git a/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift b/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift index 44eb4395..c2a254e0 100644 --- a/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift +++ b/Sources/SpeziBluetooth/Model/Properties/DeviceState.swift @@ -85,7 +85,9 @@ import Observation public final class DeviceState: @unchecked Sendable { private let keyPath: KeyPath private(set) var injection: DeviceStatePeripheralInjection? + private var _injectedValue = ObservableBox(nil) + private let _testInjections: Box?> = Box(nil) var objectId: ObjectIdentifier { ObjectIdentifier(self) @@ -111,7 +113,7 @@ public final class DeviceState: @unchecked Sendable { /// Retrieve a temporary accessors instance. public var projectedValue: DeviceStateAccessor { - DeviceStateAccessor(id: objectId, injection: injection, injectedValue: _injectedValue) + DeviceStateAccessor(id: objectId, keyPath: keyPath, injection: injection, injectedValue: _injectedValue, testInjections: _testInjections) } @@ -150,30 +152,6 @@ extension DeviceState { 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([:]) - 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 + return _testInjections.value?.artificialValue(for: keyPath) } } diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift index 696db5b9..b44dfcce 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift @@ -8,12 +8,24 @@ import ByteCoding import CoreBluetooth +import Spezi -struct CharacteristicTestInjections { +struct CharacteristicTestInjections: DefaultInitializable { var writeClosure: ((Value, WriteType) async throws -> Void)? var readClosure: (() async throws -> Value)? var requestClosure: ((Value) async throws -> Value)? + var subscriptions: ChangeSubscriptions? + var simulatePeripheral = false + + init() {} + + mutating func enableSubscriptions() { + // there is no BluetoothManager, so we need to create a queue on the fly + subscriptions = ChangeSubscriptions( + queue: DispatchSerialQueue(label: "edu.stanford.spezi.bluetooth.testing-\(Self.self)", qos: .userInitiated) + ) + } } @@ -59,14 +71,14 @@ public struct CharacteristicAccessor { /// We keep track of this for testing support. private let _value: ObservableBox /// Closure that captures write for testing support. - private let _testInjections: Box> + private let _testInjections: Box?> init( configuration: Characteristic.Configuration, injection: CharacteristicPeripheralInjection?, value: ObservableBox, - testInjections: Box> + testInjections: Box?> ) { self.configuration = configuration self.injection = injection @@ -120,6 +132,10 @@ extension CharacteristicAccessor where Value: ByteDecodable { /// /// This property creates an AsyncStream that yields all future updates to the characteristic value. public var subscription: AsyncStream { + if let subscriptions = _testInjections.value?.subscriptions { + return subscriptions.newSubscription() + } + guard let injection else { preconditionFailure( "The `subscription` of a @Characteristic cannot be accessed within the initializer. Defer access to the `configure() method" @@ -172,6 +188,16 @@ extension CharacteristicAccessor where Value: ByteDecodable { /// Otherwise, the action will only run strictly if the value changes. /// - action: The change handler to register, receiving both the old and new value. public func onChange(initial: Bool = false, perform action: @escaping (_ oldValue: Value, _ newValue: Value) async -> Void) { + if let subscriptions = _testInjections.value?.subscriptions { + let id = subscriptions.newOnChangeSubscription(perform: action) + + if initial, let value = _value.value { + // if there isn't a value already, initial won't work properly with injections + subscriptions.notifySubscriber(id: id, with: value) + } + return + } + guard let injection else { preconditionFailure( """ @@ -210,8 +236,17 @@ extension CharacteristicAccessor where Value: ByteDecodable { /// It might also throw a ``BluetoothError/notPresent(service:characteristic:)`` or ``BluetoothError/incompatibleDataFormat`` error. @discardableResult public func read() async throws -> Value { - if let injectedReadClosure = _testInjections.value.readClosure { - return try await injectedReadClosure() + if let testInjection = _testInjections.value { + if let injectedReadClosure = testInjection.readClosure { + return try await injectedReadClosure() + } + + if testInjection.simulatePeripheral { + guard let value = _value.value else { + throw BluetoothError.notPresent(characteristic: configuration.id) + } + return value + } } guard let injection else { @@ -236,9 +271,16 @@ extension CharacteristicAccessor where Value: ByteEncodable { /// - 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 { - if let injectedWriteClosure = _testInjections.value.writeClosure { - try await injectedWriteClosure(value, .withResponse) - return + if let testInjection = _testInjections.value { + if let injectedWriteClosure = testInjection.writeClosure { + try await injectedWriteClosure(value, .withResponse) + return + } + + if testInjection.simulatePeripheral { + inject(value) + return + } } guard let injection else { @@ -258,9 +300,16 @@ extension CharacteristicAccessor where Value: ByteEncodable { /// - 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 { - if let injectedWriteClosure = _testInjections.value.writeClosure { - try await injectedWriteClosure(value, .withoutResponse) - return + if let testInjection = _testInjections.value { + if let injectedWriteClosure = testInjection.writeClosure { + try await injectedWriteClosure(value, .withoutResponse) + return + } + + if testInjection.simulatePeripheral { + inject(value) + return + } } guard let injection else { @@ -290,7 +339,7 @@ extension CharacteristicAccessor where Value: ControlPointCharacteristic { /// ``BluetoothError/controlPointRequiresNotifying(service:characteristic:)`` or /// ``BluetoothError/controlPointInProgress(service:characteristic:)`` error. public func sendRequest(_ value: Value, timeout: Duration = .seconds(20)) async throws -> Value { - if let injectedRequestClosure = _testInjections.value.requestClosure { + if let injectedRequestClosure = _testInjections.value?.requestClosure { return try await injectedRequestClosure(value) } @@ -306,6 +355,22 @@ extension CharacteristicAccessor where Value: ControlPointCharacteristic { @_spi(TestingSupport) extension CharacteristicAccessor { + /// Enable testing support for subscriptions and onChange handlers. + /// + /// After this method is called, subsequent calls to ``subscription`` and ``onChange(initial:perform:)-6ltwk`` or ``onChange(initial:perform:)-5awby`` + /// will be stored and called when injecting new values via `inject(_:)`. + /// - Note: Make sure to inject a initial value if you want to make the `initial` property work properly + public func enableSubscriptions() { + _testInjections.valueOrInitialize.enableSubscriptions() + } + + /// Simulate a peripheral by automatically mocking read and write commands. + /// + /// - Note: `onWrite(perform:)` and `onRead(return:)` closures take precedence. + public func enablePeripheralSimulation(_ enabled: Bool = true) { + _testInjections.valueOrInitialize.simulatePeripheral = enabled + } + /// Inject a custom value for previewing purposes. /// /// This method can be used to inject a custom characteristic value. @@ -317,6 +382,10 @@ extension CharacteristicAccessor { /// - Parameter value: The value to inject. public func inject(_ value: Value) { _value.value = value + + if let subscriptions = _testInjections.value?.subscriptions { + subscriptions.notifySubscribers(with: value) + } } /// Inject a custom action that sinks all write operations for testing purposes. @@ -326,7 +395,7 @@ extension CharacteristicAccessor { /// /// - Parameter action: The action to inject. Called for every write. public func onWrite(perform action: @escaping (Value, WriteType) async throws -> Void) { - _testInjections.value.writeClosure = action + _testInjections.valueOrInitialize.writeClosure = action } /// Inject a custom action that sinks all read operations for testing purposes. @@ -336,7 +405,7 @@ extension CharacteristicAccessor { /// /// - Parameter action: The action to inject. Called for every read. public func onRead(return action: @escaping () async throws -> Value) { - _testInjections.value.readClosure = action + _testInjections.valueOrInitialize.readClosure = action } /// Inject a custom action that sinks all control point request operations for testing purposes. @@ -346,6 +415,6 @@ extension CharacteristicAccessor { /// /// - Parameter action: The action to inject. Called for every control point request. public func onRequest(perform action: @escaping (Value) async throws -> Value) { - _testInjections.value.requestClosure = action + _testInjections.valueOrInitialize.requestClosure = action } } diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift index 4c5781bb..68610afc 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift @@ -6,6 +6,57 @@ // SPDX-License-Identifier: MIT // +import Foundation +import Spezi + + +struct DeviceStateTestInjections: DefaultInitializable { + var subscriptions: ChangeSubscriptions? + + init() {} + + mutating func enableSubscriptions() { + // there is no BluetoothManager, so we need to create a queue on the fly + subscriptions = ChangeSubscriptions( + queue: DispatchSerialQueue(label: "edu.stanford.spezi.bluetooth.testing-\(Self.self)", qos: .userInitiated) + ) + } + + func artificialValue(for keyPath: KeyPath) -> Value? { + // swiftlint:disable:previous cyclomatic_complexity + + let value: Any? = switch keyPath { + case \.id: + nil // we cannot provide a stable id? + case \.name, \.localName: + Optional.none as Any + case \.state: + PeripheralState.disconnected + case \.advertisementData: + AdvertisementData([:]) + case \.rssi: + Int(UInt8.max) + case \.nearby: + false + case \.lastActivity: + Date.now + 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 + } +} + /// Interact with a given device state. /// @@ -18,15 +69,25 @@ /// - ``onChange(initial:perform:)-9igc9`` public struct DeviceStateAccessor { private let id: ObjectIdentifier + private let keyPath: KeyPath private let injection: DeviceStatePeripheralInjection? /// To support testing support. private let _injectedValue: ObservableBox + private let _testInjections: Box?> - init(id: ObjectIdentifier, injection: DeviceStatePeripheralInjection?, injectedValue: ObservableBox) { + init( + id: ObjectIdentifier, + keyPath: KeyPath, + injection: DeviceStatePeripheralInjection?, + injectedValue: ObservableBox, + testInjections: Box?> + ) { self.id = id + self.keyPath = keyPath self.injection = injection self._injectedValue = injectedValue + self._testInjections = testInjections } } @@ -36,6 +97,10 @@ extension DeviceStateAccessor { /// /// This property creates an AsyncStream that yields all future updates to the device state. public var subscription: AsyncStream { + if let subscriptions = _testInjections.value?.subscriptions { + return subscriptions.newSubscription() + } + guard let injection else { preconditionFailure( "The `subscription` of a @DeviceState cannot be accessed within the initializer. Defer access to the `configure() method" @@ -87,6 +152,17 @@ extension DeviceStateAccessor { /// strictly if the value changes. /// - action: The change handler to register, receiving both the old and new value. public func onChange(initial: Bool = false, perform action: @escaping (_ oldValue: Value, _ newValue: Value) async -> Void) { + if let testInjections = _testInjections.value, + let subscriptions = testInjections.subscriptions { + let id = subscriptions.newOnChangeSubscription(perform: action) + + if initial, let value = _injectedValue.value ?? testInjections.artificialValue(for: keyPath) { + // if there isn't a value already, initial won't work properly with injections + subscriptions.notifySubscriber(id: id, with: value) + } + return + } + guard let injection else { preconditionFailure( """ @@ -115,6 +191,15 @@ extension DeviceStateAccessor: @unchecked Sendable {} @_spi(TestingSupport) extension DeviceStateAccessor { + /// Enable testing support for subscriptions and onChange handlers. + /// + /// After this method is called, subsequent calls to ``subscription`` and ``onChange(initial:perform:)-6ltwk`` or ``onChange(initial:perform:)-5awby`` + /// will be stored and called when injecting new values via `inject(_:)`. + /// - Note: Make sure to inject a initial value if you want to make the `initial` property work properly + public func enableSubscriptions() { + _testInjections.valueOrInitialize.enableSubscriptions() + } + /// Inject a custom value for previewing purposes. /// /// This method can be used to inject a custom device state value. @@ -126,5 +211,9 @@ extension DeviceStateAccessor { /// - Parameter value: The value to inject. public func inject(_ value: Value) { _injectedValue.value = value + + if let subscriptions = _testInjections.value?.subscriptions { + subscriptions.notifySubscribers(with: value) + } } } diff --git a/Sources/SpeziBluetooth/Utils/Box.swift b/Sources/SpeziBluetooth/Utils/Box.swift index 0444be9d..9c62a26d 100644 --- a/Sources/SpeziBluetooth/Utils/Box.swift +++ b/Sources/SpeziBluetooth/Utils/Box.swift @@ -7,6 +7,8 @@ // import Observation +import Spezi +import SpeziFoundation @Observable @@ -37,3 +39,39 @@ class Box { self.value = value } } + + +extension Box where Value: AnyOptional, Value.Wrapped: DefaultInitializable { + var valueOrInitialize: Value.Wrapped { + get { + if let value = value.unwrappedOptional { + return value + } + + let wrapped = Value.Wrapped() + value = wrappedToValue(wrapped) + return wrapped + } + _modify { + if var value = value.unwrappedOptional { + yield &value + self.value = wrappedToValue(value) + return + } + + var wrapped = Value.Wrapped() + yield &wrapped + self.value = wrappedToValue(wrapped) + } + set { + value = wrappedToValue(newValue) + } + } + + private func wrappedToValue(_ value: Value.Wrapped) -> Value { + guard let newValue = Optional.some(value) as? Value else { + preconditionFailure("Value of \(Optional.self) was not equal to \(Value.self).") + } + return newValue + } +} From 442fc0d57bb9c338c1260dd1a9dbbcb4092d50f3 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Wed, 26 Jun 2024 18:33:41 +0200 Subject: [PATCH 45/58] Update to Spezi release --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 5e929452..552ac0ab 100644 --- a/Package.swift +++ b/Package.swift @@ -33,7 +33,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "1.1.0"), - .package(url: "https://github.com/StanfordSpezi/Spezi", branch: "feature/externally-managed-modules"), + .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.4.0"), .package(url: "https://github.com/StanfordSpezi/SpeziNetworking", from: "2.1.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") From 9f281ec0ce450b2c7985960ee352fdc891455cf2 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Wed, 26 Jun 2024 21:40:17 +0200 Subject: [PATCH 46/58] Make init public --- .../RecordAccess/RecordAccessOperationContent.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessOperationContent.swift b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessOperationContent.swift index 7f62bd72..270b7c7f 100644 --- a/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessOperationContent.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/RecordAccessOperationContent.swift @@ -24,7 +24,11 @@ public struct RecordAccessOperationContent { let `operator`: RecordAccessOperator let operand: Operand? - init(operator: RecordAccessOperator, operand: Operand? = nil) { + /// Create a new operation content. + /// - Parameters: + /// - operator: The operator. + /// - operand: The operand. + public init(operator: RecordAccessOperator, operand: Operand? = nil) { self.operator = `operator` self.operand = operand } From c9b59ce6e55a98cc592774de0119b79b03ce4669 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Wed, 26 Jun 2024 21:43:02 +0200 Subject: [PATCH 47/58] docs --- .../GenericOperand/RecordAccessGenericOperand.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessGenericOperand.swift b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessGenericOperand.swift index 59033c55..93b30e01 100644 --- a/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessGenericOperand.swift +++ b/Sources/SpeziBluetoothServices/Characteristics/RecordAccess/GenericOperand/RecordAccessGenericOperand.swift @@ -117,11 +117,17 @@ extension RecordAccessOperationContent where Operand == RecordAccessGenericOpera } /// Records that are greater than or equal to the specified filter criteria value. + /// + /// - Parameter filterCriteria: The filter criteria. + /// - Returns: The operation content. public static func greaterThanOrEqualTo(_ filterCriteria: RecordAccessFilterCriteria) -> RecordAccessOperationContent { RecordAccessOperationContent(operator: .greaterThanOrEqual, operand: .filterCriteria(filterCriteria)) } /// Records that are within the closed range of the specified filter criteria value. + /// + /// - Parameter filterCriteria: The filter criteria. + /// - Returns: The operation content. public static func withinInclusiveRangeOf(_ filterCriteria: RecordAccessRangeFilterCriteria) -> RecordAccessOperationContent { RecordAccessOperationContent(operator: .withinInclusiveRangeOf, operand: .rangeFilterCriteria(filterCriteria)) } From bad334b0c9d3c46046af77f101ab89c9aa2541f2 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Wed, 26 Jun 2024 22:05:30 +0200 Subject: [PATCH 48/58] Remove code that is already present in SpeziNetworking --- .../TestingSupport/Data+HexString.swift | 70 ------------------- 1 file changed, 70 deletions(-) delete mode 100644 Sources/SpeziBluetooth/TestingSupport/Data+HexString.swift diff --git a/Sources/SpeziBluetooth/TestingSupport/Data+HexString.swift b/Sources/SpeziBluetooth/TestingSupport/Data+HexString.swift deleted file mode 100644 index 57986675..00000000 --- a/Sources/SpeziBluetooth/TestingSupport/Data+HexString.swift +++ /dev/null @@ -1,70 +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 - - -extension Data { - /// Create `Data` from a hex string. - /// - /// The hex string may be prefixed with `"0x"` or `"0X"`. - /// - Parameter hex: The hex string. - @_spi(TestingSupport) - public init?(hex: String) { - // while this seems complicated, and you can do it with shorter code, - // this doesn't incur any heap allocations for string. Pretty neat. - - var index = hex.startIndex - - let hexCount: Int - - if hex.hasPrefix("0x") || hex.hasPrefix("0X") { - index = hex.index(index, offsetBy: 2) - hexCount = hex.count - 2 - } else { - hexCount = hex.count - } - - var bytes: [UInt8] = [] - bytes.reserveCapacity(hexCount / 2 + hexCount % 2) - - if !hexCount.isMultiple(of: 2) { - guard let byte = UInt8(String(hex[index]), radix: 16) else { - return nil - } - bytes.append(byte) - - index = hex.index(after: index) - } - - - while index < hex.endIndex { - guard let byte = UInt8(hex[index ... hex.index(after: index)], radix: 16) else { - return nil - } - bytes.append(byte) - - index = hex.index(index, offsetBy: 2) - } - - guard hexCount / bytes.count == 2 else { - return nil - } - 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() - } -} From 6adffbe7ab192c90db7f2f13e2552201fdaf2d5b Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Wed, 26 Jun 2024 22:06:24 +0200 Subject: [PATCH 49/58] Fix import --- Sources/SpeziBluetoothServices/TestingSupport/EventLog.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/SpeziBluetoothServices/TestingSupport/EventLog.swift b/Sources/SpeziBluetoothServices/TestingSupport/EventLog.swift index 16714991..deaf0a27 100644 --- a/Sources/SpeziBluetoothServices/TestingSupport/EventLog.swift +++ b/Sources/SpeziBluetoothServices/TestingSupport/EventLog.swift @@ -6,6 +6,7 @@ // SPDX-License-Identifier: MIT // +@_spi(TestingSupport) import ByteCoding @preconcurrency import CoreBluetooth import NIO From c60947804cb7755a50b441dd6143e60722e8a9cf Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 27 Jun 2024 13:40:27 +0200 Subject: [PATCH 50/58] Use XCTestExtensions preview --- .../RecordAccessControlPointTests.swift | 17 +---------------- .../TestApp/Views/DeviceInformationView.swift | 1 + .../UITests/TestApp/Views/TestServiceView.swift | 1 + Tests/UITests/UITests.xcodeproj/project.pbxproj | 4 ++-- 4 files changed, 5 insertions(+), 18 deletions(-) diff --git a/Tests/BluetoothServicesTests/RecordAccessControlPointTests.swift b/Tests/BluetoothServicesTests/RecordAccessControlPointTests.swift index 12e5696e..785a82af 100644 --- a/Tests/BluetoothServicesTests/RecordAccessControlPointTests.swift +++ b/Tests/BluetoothServicesTests/RecordAccessControlPointTests.swift @@ -14,6 +14,7 @@ import NIO @testable import SpeziBluetoothServices import XCTByteCoding import XCTest +import XCTestExtensions typealias RACP = RecordAccessControlPoint @@ -180,19 +181,3 @@ final class RecordAccessControlPointTests: XCTestCase { await XCTAssertThrowsErrorAsync(try await $controlPoint.reportNumberOfStoredRecords(.allRecords)) } } - - -func XCTAssertThrowsErrorAsync( - _ expression: @autoclosure () async throws -> T, - _ message: @autoclosure () -> String = "", - file: StaticString = #filePath, - line: UInt = #line, - _ errorHandler: (Error) -> Void = { _ in } -) async { - do { - _ = try await expression() - XCTFail(message(), file: file, line: line) - } catch { - errorHandler(error) - } -} diff --git a/Tests/UITests/TestApp/Views/DeviceInformationView.swift b/Tests/UITests/TestApp/Views/DeviceInformationView.swift index f2f50c2b..ddcff3b5 100644 --- a/Tests/UITests/TestApp/Views/DeviceInformationView.swift +++ b/Tests/UITests/TestApp/Views/DeviceInformationView.swift @@ -6,6 +6,7 @@ // SPDX-License-Identifier: MIT // +@_spi(TestingSupport) import ByteCoding import SpeziBluetoothServices @_spi(TestingSupport) import SpeziBluetooth diff --git a/Tests/UITests/TestApp/Views/TestServiceView.swift b/Tests/UITests/TestApp/Views/TestServiceView.swift index fdbf9dde..56032f7f 100644 --- a/Tests/UITests/TestApp/Views/TestServiceView.swift +++ b/Tests/UITests/TestApp/Views/TestServiceView.swift @@ -6,6 +6,7 @@ // SPDX-License-Identifier: MIT // +@_spi(TestingSupport) import ByteCoding import CoreBluetooth @_spi(TestingSupport) import SpeziBluetooth diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index dc5de61c..f9bcc187 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -751,8 +751,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/StanfordBDHG/XCTestExtensions.git"; requirement = { - kind = upToNextMinorVersion; - minimumVersion = 0.4.10; + branch = "feature/xctassert-throws-async"; + kind = branch; }; }; A9AAC7F02C26D73C0034088B /* XCRemoteSwiftPackageReference "SpeziViews" */ = { From 63e8e6131713f5ee1347b5f69c5d004121810df7 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 27 Jun 2024 13:42:23 +0200 Subject: [PATCH 51/58] swiftlint --- Tests/UITests/TestApp/Views/DeviceInformationView.swift | 3 ++- Tests/UITests/TestApp/Views/TestServiceView.swift | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Tests/UITests/TestApp/Views/DeviceInformationView.swift b/Tests/UITests/TestApp/Views/DeviceInformationView.swift index ddcff3b5..6ceb362a 100644 --- a/Tests/UITests/TestApp/Views/DeviceInformationView.swift +++ b/Tests/UITests/TestApp/Views/DeviceInformationView.swift @@ -6,7 +6,8 @@ // SPDX-License-Identifier: MIT // -@_spi(TestingSupport) import ByteCoding +@_spi(TestingSupport) +import ByteCoding import SpeziBluetoothServices @_spi(TestingSupport) import SpeziBluetooth diff --git a/Tests/UITests/TestApp/Views/TestServiceView.swift b/Tests/UITests/TestApp/Views/TestServiceView.swift index 56032f7f..fe9a358e 100644 --- a/Tests/UITests/TestApp/Views/TestServiceView.swift +++ b/Tests/UITests/TestApp/Views/TestServiceView.swift @@ -6,7 +6,8 @@ // SPDX-License-Identifier: MIT // -@_spi(TestingSupport) import ByteCoding +@_spi(TestingSupport) +import ByteCoding import CoreBluetooth @_spi(TestingSupport) import SpeziBluetooth From 06645020a073f77bc3d1eaac80b5f50ba1f63a0d Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 27 Jun 2024 13:50:38 +0200 Subject: [PATCH 52/58] Declare xctestextensions as explicit dependency --- Package.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index 552ac0ab..fc55f893 100644 --- a/Package.swift +++ b/Package.swift @@ -36,7 +36,8 @@ let package = Package( .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.4.0"), .package(url: "https://github.com/StanfordSpezi/SpeziNetworking", from: "2.1.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") + .package(url: "https://github.com/apple/swift-collections.git", from: "1.0.4"), + .package(url: "https://github.com/StanfordBDHG/XCTestExtensions.git", branch: "feature/xctassert-throws-async") ] + swiftLintPackage(), targets: [ .target( @@ -86,7 +87,8 @@ let package = Package( .target(name: "SpeziBluetoothServices"), .target(name: "SpeziBluetooth"), .product(name: "XCTByteCoding", package: "SpeziNetworking"), - .product(name: "NIO", package: "swift-nio") + .product(name: "NIO", package: "swift-nio"), + .product(name: "XCTestExtensions", package: "XCTestExtensions") ], swiftSettings: [ swiftConcurrency From 1447d5cc71bb15ed50aea0c2d857be3059b45057 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 27 Jun 2024 14:08:16 +0200 Subject: [PATCH 53/58] fix --- .../RecordAccessControlPointTests.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Tests/BluetoothServicesTests/RecordAccessControlPointTests.swift b/Tests/BluetoothServicesTests/RecordAccessControlPointTests.swift index 785a82af..37151be5 100644 --- a/Tests/BluetoothServicesTests/RecordAccessControlPointTests.swift +++ b/Tests/BluetoothServicesTests/RecordAccessControlPointTests.swift @@ -103,31 +103,31 @@ final class RecordAccessControlPointTests: XCTestCase { $controlPoint.onRequest { _ in RACP(opCode: .abortOperation, operator: .null) } - await XCTAssertThrowsErrorAsync(try await $controlPoint.abort()) + try await XCTAssertThrowsErrorAsync(await $controlPoint.abort()) // unexpected response operator $controlPoint.onRequest { _ in RACP(opCode: .responseCode, operator: .allRecords) } - await XCTAssertThrowsErrorAsync(try await $controlPoint.abort()) + try await XCTAssertThrowsErrorAsync(await $controlPoint.abort()) // unexpected general response operand format $controlPoint.onRequest { _ in RACP(opCode: .responseCode, operator: .null, operand: .numberOfRecords(1234)) } - await XCTAssertThrowsErrorAsync(try await $controlPoint.abort()) + try await XCTAssertThrowsErrorAsync(await $controlPoint.abort()) // non matching request opcode $controlPoint.onRequest { _ in RACP(opCode: .responseCode, operator: .null, operand: .generalResponse(.init(requestOpCode: .reportStoredRecords, response: .success))) } - await XCTAssertThrowsErrorAsync(try await $controlPoint.abort()) + try await XCTAssertThrowsErrorAsync(await $controlPoint.abort()) // erroneous request $controlPoint.onRequest { _ in RACP(opCode: .responseCode, operator: .null, operand: .generalResponse(.init(requestOpCode: .abortOperation, response: .invalidOperand))) } - await XCTAssertThrowsErrorAsync(try await $controlPoint.abort()) + try await XCTAssertThrowsErrorAsync(await $controlPoint.abort()) } func testRACPReportNumberOfStoredRecordsRequest() async throws { From 94b13eea099ab0c47480fd5fc97224d138abd6b4 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 27 Jun 2024 14:39:06 +0200 Subject: [PATCH 54/58] Fix2 --- .../RecordAccessControlPointTests.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Tests/BluetoothServicesTests/RecordAccessControlPointTests.swift b/Tests/BluetoothServicesTests/RecordAccessControlPointTests.swift index 37151be5..1b5f371f 100644 --- a/Tests/BluetoothServicesTests/RecordAccessControlPointTests.swift +++ b/Tests/BluetoothServicesTests/RecordAccessControlPointTests.swift @@ -144,25 +144,25 @@ final class RecordAccessControlPointTests: XCTestCase { $controlPoint.onRequest { _ in RACP(opCode: .abortOperation, operator: .null) } - await XCTAssertThrowsErrorAsync(try await $controlPoint.reportNumberOfStoredRecords(.allRecords)) + try await XCTAssertThrowsErrorAsync(await $controlPoint.reportNumberOfStoredRecords(.allRecords)) // unexpected response operator $controlPoint.onRequest { _ in RACP(opCode: .responseCode, operator: .allRecords) } - await XCTAssertThrowsErrorAsync(try await $controlPoint.reportNumberOfStoredRecords(.allRecords)) + try await XCTAssertThrowsErrorAsync(await $controlPoint.reportNumberOfStoredRecords(.allRecords)) // unexpected general response operand format $controlPoint.onRequest { _ in RACP(opCode: .responseCode, operator: .null, operand: .filterCriteria(.sequenceNumber(123))) } - await XCTAssertThrowsErrorAsync(try await $controlPoint.reportNumberOfStoredRecords(.allRecords)) + try await XCTAssertThrowsErrorAsync(await $controlPoint.reportNumberOfStoredRecords(.allRecords)) // non matching request opcode $controlPoint.onRequest { _ in RACP(opCode: .responseCode, operator: .null, operand: .generalResponse(.init(requestOpCode: .reportStoredRecords, response: .success))) } - await XCTAssertThrowsErrorAsync(try await $controlPoint.reportNumberOfStoredRecords(.allRecords)) + try await XCTAssertThrowsErrorAsync(await $controlPoint.reportNumberOfStoredRecords(.allRecords)) // erroneous request $controlPoint.onRequest { _ in @@ -172,12 +172,12 @@ final class RecordAccessControlPointTests: XCTestCase { operand: .generalResponse(.init(requestOpCode: .reportNumberOfStoredRecords, response: .invalidOperand)) ) } - await XCTAssertThrowsErrorAsync(try await $controlPoint.reportNumberOfStoredRecords(.allRecords)) + try await XCTAssertThrowsErrorAsync(await $controlPoint.reportNumberOfStoredRecords(.allRecords)) // invalid operator $controlPoint.onRequest { _ in RACP(opCode: .numberOfStoredRecordsResponse, operator: .allRecords, operand: .numberOfRecords(1234)) } - await XCTAssertThrowsErrorAsync(try await $controlPoint.reportNumberOfStoredRecords(.allRecords)) + try await XCTAssertThrowsErrorAsync(await $controlPoint.reportNumberOfStoredRecords(.allRecords)) } } From b45714cbceaf27ab8c513029357e8caebde4dc50 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 27 Jun 2024 19:00:27 +0200 Subject: [PATCH 55/58] Add ability to disable autoread --- .../CoreBluetooth/BluetoothPeripheral.swift | 51 +++++-------------- .../CharacteristicDescription.swift | 6 ++- .../Configuration/DeviceDescription.swift | 19 ++++++- .../Configuration/ServiceDescription.swift | 20 ++++++-- .../Model/Properties/Characteristic.swift | 50 ++++++++++-------- 5 files changed, 82 insertions(+), 64 deletions(-) diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift index 5f926bd1..96857c54 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift @@ -91,8 +91,6 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ private var notifyRequested: Set = [] - /// 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 = [] @@ -303,29 +301,18 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ } func handleConnect() { - if let services = configuration.services { - 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 { - result[configuration.serviceId] = .some(nil) - } - } - } else { - // all services will be discovered - requestedCharacteristics = nil - } - // ensure that it is updated instantly. self.isolatedUpdate(of: \.state, PeripheralState(from: peripheral.state)) logger.debug("Discovering services for \(self.peripheral.debugIdentifier) ...") - let services = requestedCharacteristics.map { Array($0.keys) } + let services = configuration.services?.reduce(into: Set()) { result, description in + result.insert(description.serviceId) + } if let services, services.isEmpty { _storage.signalFullyDiscovered() } else { - peripheral.discoverServices(requestedCharacteristics.map { Array($0.keys) }) + peripheral.discoverServices(services.map { Array($0) }) } } @@ -336,7 +323,6 @@ public actor BluetoothPeripheral: BluetoothActor { // swiftlint:disable:this typ // clear all the ongoing access - self.requestedCharacteristics = nil self.servicesAwaitingCharacteristicsDiscovery.removeAll() if let services { @@ -731,8 +717,10 @@ extension BluetoothPeripheral { // automatically subscribe to discovered characteristics for which we have a handler subscribed! for characteristic in characteristics { + let description = configuration.description(for: service.uuid)?.description(for: characteristic.uuid) + // pull initial value if none is present - if characteristic.value == nil && characteristic.properties.contains(.read) { + if description?.autoRead != false && characteristic.value == nil && characteristic.properties.contains(.read) { peripheral.readValue(for: characteristic) } @@ -745,20 +733,8 @@ extension BluetoothPeripheral { peripheral.setNotifyValue(true, for: characteristic) } } - } - - // check if we discover descriptors - guard let requestedCharacteristics = requestedCharacteristics, - let descriptions = requestedCharacteristics[service.uuid] else { - return - } - - for characteristic in characteristics { - guard let description = descriptions?.first(where: { $0.characteristicId == characteristic.uuid }) else { - continue - } - if description.discoverDescriptors { + if description?.discoverDescriptors == true { logger.debug("Discovering descriptors for \(characteristic.debugIdentifier)...") peripheral.discoverDescriptors(for: characteristic) } @@ -941,19 +917,20 @@ extension BluetoothPeripheral { logger.debug("Discovered \(services) services for peripheral \(device.peripheral.debugIdentifier)") for service in services { - guard let requestedCharacteristicsDic = device.requestedCharacteristics, - let requestedCharacteristicsDescriptions = requestedCharacteristicsDic[service.uuid] else { + guard let serviceDescription = device.configuration.description(for: service.uuid) else { continue } - let requestedCharacteristics = requestedCharacteristicsDescriptions?.map { $0.characteristicId } + let characteristicIds = serviceDescription.characteristics?.reduce(into: Set()) { partialResult, description in + partialResult.insert(description.characteristicId) + } - if let requestedCharacteristics, requestedCharacteristics.isEmpty { + if let characteristicIds, characteristicIds.isEmpty { continue } device.servicesAwaitingCharacteristicsDiscovery.insert(service.uuid) - peripheral.discoverCharacteristics(requestedCharacteristics, for: service) + peripheral.discoverCharacteristics(characteristicIds.map { Array($0) }, for: service) } if device.servicesAwaitingCharacteristicsDiscovery.isEmpty { diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift index 6ff45378..0d566e07 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift @@ -15,15 +15,19 @@ public struct CharacteristicDescription: Sendable { public let characteristicId: CBUUID /// Flag indicating if descriptors should be discovered for this characteristic. public let discoverDescriptors: Bool + /// Flag indicating if SpeziBluetooth should automatically read the initial value from the peripheral. + public let autoRead: Bool /// Create a new characteristic description. /// - Parameters: /// - id: The bluetooth characteristic id. /// - discoverDescriptors: Optional flag to specify that descriptors of this characteristic should be discovered. - public init(id: CBUUID, discoverDescriptors: Bool = false) { + /// - autoRead: Flag indicating if SpeziBluetooth should automatically read the initial value from the peripheral. + public init(id: CBUUID, discoverDescriptors: Bool = false, autoRead: Bool = true) { self.characteristicId = id self.discoverDescriptors = discoverDescriptors + self.autoRead = autoRead } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift index fbcb762c..1edfe627 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift @@ -6,6 +6,7 @@ // SPDX-License-Identifier: MIT // +@preconcurrency import class CoreBluetooth.CBUUID import OSLog @@ -17,14 +18,28 @@ public struct DeviceDescription { /// The set of service configurations we expect from the device. /// /// This will be the list of services we are interested in and we try to discover. - public let services: Set? // swiftlint:disable:this discouraged_optional_collection + public var services: Set? { // swiftlint:disable:this discouraged_optional_collection + let values: Dictionary.Values? = _services?.values + return values.map { Set($0) } + } + private let _services: [CBUUID: ServiceDescription]? // swiftlint:disable:this discouraged_optional_collection /// Create a new device description. /// - Parameter services: The set of service descriptions specifying the expected services. public init(services: Set? = nil) { // swiftlint:disable:previous discouraged_optional_collection - self.services = services + self._services = services?.reduce(into: [:]) { partialResult, description in + partialResult[description.serviceId] = description + } + } + + + /// Retrieve the service description for a given service id. + /// - Parameter serviceId: The Bluetooth service id. + /// - Returns: Returns the service description if present. + public func description(for serviceId: CBUUID) -> ServiceDescription? { + _services?[serviceId] } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift index ac745839..d0a97c32 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift @@ -6,7 +6,7 @@ // SPDX-License-Identifier: MIT // -@preconcurrency import CoreBluetooth +@preconcurrency import class CoreBluetooth.CBUUID /// A service description for a certain device. @@ -19,8 +19,12 @@ public struct ServiceDescription: Sendable { /// /// Those are the characteristics we try to discover. If empty, we discover all characteristics /// on a given service. - public let characteristics: Set? // swiftlint:disable:this discouraged_optional_collection + public var characteristics: Set? { // swiftlint:disable:this discouraged_optional_collection + let values: Dictionary.Values? = _characteristics?.values + return values.map { Set($0) } + } + private let _characteristics: [CBUUID: CharacteristicDescription]? // swiftlint:disable:this discouraged_optional_collection /// Create a new service description. /// - Parameters: @@ -29,7 +33,9 @@ public struct ServiceDescription: Sendable { /// Use `nil` to discover all characteristics. public init(serviceId: CBUUID, characteristics: Set?) { // swiftlint:disable:this discouraged_optional_collection self.serviceId = serviceId - self.characteristics = characteristics + self._characteristics = characteristics?.reduce(into: [:]) { partialResult, description in + partialResult[description.characteristicId] = description + } } /// Create a new service description. @@ -40,6 +46,14 @@ public struct ServiceDescription: Sendable { public init(serviceId: String, characteristics: Set?) { // swiftlint:disable:this discouraged_optional_collection self.init(serviceId: CBUUID(string: serviceId), characteristics: characteristics) } + + + /// Retrieve the characteristic description for a given service id. + /// - Parameter serviceId: The Bluetooth characteristic id. + /// - Returns: Returns the characteristic description if present. + public func description(for characteristicsId: CBUUID) -> CharacteristicDescription? { + _characteristics?[characteristicsId] + } } diff --git a/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift b/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift index 59964510..bbaaeeb7 100644 --- a/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift +++ b/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift @@ -160,9 +160,7 @@ import Foundation @propertyWrapper public final class Characteristic: @unchecked Sendable { class Configuration { - let id: CBUUID - let discoverDescriptors: Bool - + let description: CharacteristicDescription var defaultNotify: Bool /// Memory address as an identifier for this Characteristic instance. @@ -170,9 +168,12 @@ public final class Characteristic: @unchecked Sendable { ObjectIdentifier(self) } - init(id: CBUUID, discoverDescriptors: Bool, defaultNotify: Bool) { - self.id = id - self.discoverDescriptors = discoverDescriptors + var id: CBUUID { + description.characteristicId + } + + init(description: CharacteristicDescription, defaultNotify: Bool) { + self.description = description self.defaultNotify = defaultNotify } } @@ -184,7 +185,7 @@ public final class Characteristic: @unchecked Sendable { private let _testInjections: Box?> = Box(nil) var description: CharacteristicDescription { - CharacteristicDescription(id: configuration.id, discoverDescriptors: configuration.discoverDescriptors) + configuration.description } /// Access the current characteristic value. @@ -206,9 +207,10 @@ public final class Characteristic: @unchecked Sendable { CharacteristicAccessor(configuration: configuration, injection: injection, value: _value, testInjections: _testInjections) } - fileprivate init(wrappedValue: Value? = nil, characteristic: CBUUID, notify: Bool, discoverDescriptors: Bool = false) { + fileprivate init(wrappedValue: Value? = nil, characteristic: CBUUID, notify: Bool, autoRead: Bool = true, discoverDescriptors: Bool = false) { // swiftlint:disable:previous function_default_parameter_at_end - self.configuration = .init(id: characteristic, discoverDescriptors: discoverDescriptors, defaultNotify: notify) + let description = CharacteristicDescription(id: characteristic, discoverDescriptors: discoverDescriptors, autoRead: autoRead) + self.configuration = .init(description: description, defaultNotify: notify) self._value = ObservableBox(wrappedValue) } @@ -239,20 +241,22 @@ extension Characteristic where Value: ByteEncodable { /// - Parameters: /// - wrappedValue: An optional default value. /// - id: The characteristic id. + /// - autoRead: Flag indicating if the initial value should be automatically read from the peripheral. /// - discoverDescriptors: Flag if characteristic descriptors should be discovered automatically. - public convenience init(wrappedValue: Value? = nil, id: String, discoverDescriptors: Bool = false) { + public convenience init(wrappedValue: Value? = nil, id: String, autoRead: Bool = true, discoverDescriptors: Bool = false) { // swiftlint:disable:previous function_default_parameter_at_end - self.init(wrappedValue: wrappedValue, id: CBUUID(string: id), discoverDescriptors: discoverDescriptors) + self.init(wrappedValue: wrappedValue, id: CBUUID(string: id), autoRead: autoRead, discoverDescriptors: discoverDescriptors) } /// Declare a write-only characteristic. /// - Parameters: /// - wrappedValue: An optional default value. /// - id: The characteristic id. + /// - autoRead: Flag indicating if the initial value should be automatically read from the peripheral. /// - discoverDescriptors: Flag if characteristic descriptors should be discovered automatically. - public convenience init(wrappedValue: Value? = nil, id: CBUUID, discoverDescriptors: Bool = false) { + public convenience init(wrappedValue: Value? = nil, id: CBUUID, autoRead: Bool = true, discoverDescriptors: Bool = false) { // swiftlint:disable:previous function_default_parameter_at_end - self.init(wrappedValue: wrappedValue, characteristic: id, notify: false, discoverDescriptors: discoverDescriptors) + self.init(wrappedValue: wrappedValue, characteristic: id, notify: false, autoRead: autoRead, discoverDescriptors: discoverDescriptors) } } @@ -263,10 +267,11 @@ extension Characteristic where Value: ByteDecodable { /// - wrappedValue: An optional default value. /// - id: The characteristic id. /// - notify: Automatically subscribe to characteristic notifications if supported. + /// - autoRead: Flag indicating if the initial value should be automatically read from the peripheral. /// - discoverDescriptors: Flag if characteristic descriptors should be discovered automatically. - public convenience init(wrappedValue: Value? = nil, id: String, notify: Bool = false, discoverDescriptors: Bool = false) { + public convenience init(wrappedValue: Value? = nil, id: String, notify: Bool = false, autoRead: Bool = true, discoverDescriptors: Bool = false) { // swiftlint:disable:previous function_default_parameter_at_end - self.init(wrappedValue: wrappedValue, id: CBUUID(string: id), notify: notify, discoverDescriptors: discoverDescriptors) + self.init(wrappedValue: wrappedValue, id: CBUUID(string: id), notify: notify, autoRead: autoRead, discoverDescriptors: discoverDescriptors) } /// Declare a read-only characteristic. @@ -274,10 +279,11 @@ extension Characteristic where Value: ByteDecodable { /// - wrappedValue: An optional default value. /// - id: The characteristic id. /// - notify: Automatically subscribe to characteristic notifications if supported. + /// - autoRead: Flag indicating if the initial value should be automatically read from the peripheral. /// - discoverDescriptors: Flag if characteristic descriptors should be discovered automatically. - public convenience init(wrappedValue: Value? = nil, id: CBUUID, notify: Bool = false, discoverDescriptors: Bool = false) { + public convenience init(wrappedValue: Value? = nil, id: CBUUID, notify: Bool = false, autoRead: Bool = true, discoverDescriptors: Bool = false) { // swiftlint:disable:previous function_default_parameter_at_end - self.init(wrappedValue: wrappedValue, characteristic: id, notify: notify, discoverDescriptors: discoverDescriptors) + self.init(wrappedValue: wrappedValue, characteristic: id, notify: notify, autoRead: autoRead, discoverDescriptors: discoverDescriptors) } } @@ -288,10 +294,11 @@ extension Characteristic where Value: ByteCodable { // reduce ambiguity /// - wrappedValue: An optional default value. /// - id: The characteristic id. /// - notify: Automatically subscribe to characteristic notifications if supported. + /// - autoRead: Flag indicating if the initial value should be automatically read from the peripheral. /// - discoverDescriptors: Flag if characteristic descriptors should be discovered automatically. - public convenience init(wrappedValue: Value? = nil, id: String, notify: Bool = false, discoverDescriptors: Bool = false) { + public convenience init(wrappedValue: Value? = nil, id: String, notify: Bool = false, autoRead: Bool = true, discoverDescriptors: Bool = false) { // swiftlint:disable:previous function_default_parameter_at_end - self.init(wrappedValue: wrappedValue, id: CBUUID(string: id), notify: notify, discoverDescriptors: discoverDescriptors) + self.init(wrappedValue: wrappedValue, id: CBUUID(string: id), notify: notify, autoRead: autoRead, discoverDescriptors: discoverDescriptors) } /// Declare a read and write characteristic. @@ -299,10 +306,11 @@ extension Characteristic where Value: ByteCodable { // reduce ambiguity /// - wrappedValue: An optional default value. /// - id: The characteristic id. /// - notify: Automatically subscribe to characteristic notifications if supported. + /// - autoRead: Flag indicating if the initial value should be automatically read from the peripheral. /// - discoverDescriptors: Flag if characteristic descriptors should be discovered automatically. - public convenience init(wrappedValue: Value? = nil, id: CBUUID, notify: Bool = false, discoverDescriptors: Bool = false) { + public convenience init(wrappedValue: Value? = nil, id: CBUUID, notify: Bool = false, autoRead: Bool = true, discoverDescriptors: Bool = false) { // swiftlint:disable:previous function_default_parameter_at_end - self.init(wrappedValue: wrappedValue, characteristic: id, notify: notify, discoverDescriptors: discoverDescriptors) + self.init(wrappedValue: wrappedValue, characteristic: id, notify: notify, autoRead: autoRead, discoverDescriptors: discoverDescriptors) } } From b93d32a603534b638eafab1c030b0dcdfc1fc10b Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 27 Jun 2024 19:01:44 +0200 Subject: [PATCH 56/58] test something --- .../SpeziBluetoothServices/Services/WeightScaleService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SpeziBluetoothServices/Services/WeightScaleService.swift b/Sources/SpeziBluetoothServices/Services/WeightScaleService.swift index 175f2e74..5b94d8f0 100644 --- a/Sources/SpeziBluetoothServices/Services/WeightScaleService.swift +++ b/Sources/SpeziBluetoothServices/Services/WeightScaleService.swift @@ -19,7 +19,7 @@ public final class WeightScaleService: BluetoothService, @unchecked Sendable { /// Receive weight measurements. /// /// - Note: This characteristic is required and indicate-only. - @Characteristic(id: "2A9D", notify: true) + @Characteristic(id: "2A9D", notify: true, autoRead: false) // TODO: reenable this public var weightMeasurement: WeightMeasurement? /// Describe supported features and value resolutions of the weight scale. From d9df9b9c5da1168e880364b06785c21084f892e2 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 27 Jun 2024 20:11:19 +0200 Subject: [PATCH 57/58] Release version --- Package.swift | 2 +- Tests/UITests/UITests.xcodeproj/project.pbxproj | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Package.swift b/Package.swift index fc55f893..46b02b8e 100644 --- a/Package.swift +++ b/Package.swift @@ -37,7 +37,7 @@ let package = Package( .package(url: "https://github.com/StanfordSpezi/SpeziNetworking", from: "2.1.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"), - .package(url: "https://github.com/StanfordBDHG/XCTestExtensions.git", branch: "feature/xctassert-throws-async") + .package(url: "https://github.com/StanfordBDHG/XCTestExtensions.git", from: "0.4.11") ] + swiftLintPackage(), targets: [ .target( diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index f9bcc187..e37ea55b 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -751,8 +751,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/StanfordBDHG/XCTestExtensions.git"; requirement = { - branch = "feature/xctassert-throws-async"; - kind = branch; + kind = upToNextMinorVersion; + minimumVersion = 0.4.11; }; }; A9AAC7F02C26D73C0034088B /* XCRemoteSwiftPackageReference "SpeziViews" */ = { From c23f507d96a8fa9190d3d03a2976c138d683cf87 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 27 Jun 2024 20:11:55 +0200 Subject: [PATCH 58/58] revert --- .../SpeziBluetoothServices/Services/WeightScaleService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SpeziBluetoothServices/Services/WeightScaleService.swift b/Sources/SpeziBluetoothServices/Services/WeightScaleService.swift index 5b94d8f0..175f2e74 100644 --- a/Sources/SpeziBluetoothServices/Services/WeightScaleService.swift +++ b/Sources/SpeziBluetoothServices/Services/WeightScaleService.swift @@ -19,7 +19,7 @@ public final class WeightScaleService: BluetoothService, @unchecked Sendable { /// Receive weight measurements. /// /// - Note: This characteristic is required and indicate-only. - @Characteristic(id: "2A9D", notify: true, autoRead: false) // TODO: reenable this + @Characteristic(id: "2A9D", notify: true) public var weightMeasurement: WeightMeasurement? /// Describe supported features and value resolutions of the weight scale.