diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 24104f93..3725de8b 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -16,14 +16,32 @@ on: workflow_dispatch: jobs: - packageios: + package_ios: name: Build and Test Swift Package iOS uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: runsonlabels: '["macOS", "self-hosted", "spezi"]' scheme: SpeziBluetooth-Package - artifactname: SpeziBluetooth-Package.xcresult - resultBundle: SpeziBluetooth-Package.xcresult + artifactname: SpeziBluetooth-iOS.xcresult + resultBundle: SpeziBluetooth-iOS.xcresult + buildandtest_visionos: + name: Build and Test Swift Package visionOS + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 + with: + runsonlabels: '["macOS", "self-hosted"]' + scheme: SpeziBluetooth-Package + destination: 'platform=visionOS Simulator,name=Apple Vision Pro' + resultBundle: SpeziBluetooth-visionOS.xcresult + artifactname: SpeziBluetooth-visionOS.xcresult + buildandtest_watchos: + name: Build and Test Swift Package watchOS + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 + with: + runsonlabels: '["macOS", "self-hosted"]' + scheme: SpeziBluetooth-Package + destination: 'platform=watchOS Simulator,name=Apple Watch Series 10 (46mm)' + resultBundle: SpeziBluetooth-watchOS.xcresult + artifactname: SpeziBluetooth-watchOS.xcresult ios: name: Build and Test iOS uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 @@ -48,9 +66,9 @@ jobs: secrets: inherit uploadcoveragereport: name: Upload Coverage Report - needs: [packageios, ios, macos] + needs: [package_ios, buildandtest_visionos, buildandtest_watchos, ios, macos] uses: StanfordSpezi/.github/.github/workflows/create-and-upload-coverage-report.yml@v2 with: - coveragereports: SpeziBluetooth-Package.xcresult TestApp-iOS.xcresult TestApp-macOS.xcresult + coveragereports: SpeziBluetooth-iOS.xcresult SpeziBluetooth-visionOS.xcresult SpeziBluetooth-watchOS.xcresult TestApp-iOS.xcresult TestApp-macOS.xcresult secrets: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/Package.swift b/Package.swift index cdd31ac8..db3a3276 100644 --- a/Package.swift +++ b/Package.swift @@ -2,9 +2,9 @@ // // 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 // @@ -18,16 +18,20 @@ let package = Package( platforms: [ .iOS(.v17), .macCatalyst(.v17), - .macOS(.v14) + .macOS(.v14), + .visionOS(.v1), + .watchOS(.v10), + .tvOS(.v17) ], products: [ .library(name: "SpeziBluetoothServices", targets: ["SpeziBluetoothServices"]), .library(name: "SpeziBluetooth", targets: ["SpeziBluetooth"]) ], dependencies: [ - .package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "2.0.0-beta.1"), - .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.7.1"), - .package(url: "https://github.com/StanfordSpezi/SpeziNetworking", from: "2.1.0"), + .package(url: "https://github.com/StanfordSpezi/SpeziFoundation.git", from: "2.0.0"), + .package(url: "https://github.com/StanfordSpezi/Spezi.git", from: "1.8.0"), + .package(url: "https://github.com/StanfordSpezi/SpeziViews.git", from: "1.7.0"), + .package(url: "https://github.com/StanfordSpezi/SpeziNetworking.git", 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-atomics.git", from: "1.2.0"), @@ -38,6 +42,7 @@ let package = Package( name: "SpeziBluetooth", dependencies: [ .product(name: "Spezi", package: "Spezi"), + .product(name: "SpeziViews", package: "SpeziViews"), .product(name: "NIO", package: "swift-nio"), .product(name: "OrderedCollections", package: "swift-collections"), .product(name: "SpeziFoundation", package: "SpeziFoundation"), diff --git a/Sources/SpeziBluetooth/AccessorySetupKit/ASAccessoryEventType+Description.swift b/Sources/SpeziBluetooth/AccessorySetupKit/ASAccessoryEventType+Description.swift new file mode 100644 index 00000000..8cc0a269 --- /dev/null +++ b/Sources/SpeziBluetooth/AccessorySetupKit/ASAccessoryEventType+Description.swift @@ -0,0 +1,51 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(AccessorySetupKit) && !os(macOS) +import AccessorySetupKit + + +extension ASAccessoryEventType: @retroactive CustomStringConvertible, @retroactive CustomDebugStringConvertible { + public var description: String { + switch self { + case .unknown: + "unknown" + case .activated: + "activated" + case .invalidated: + "invalidated" + case .migrationComplete: + "migrationComplete" + case .accessoryAdded: + "accessoryAdded" + case .accessoryRemoved: + "accessoryRemoved" + case .accessoryChanged: + "accessoryChanged" + case .pickerDidPresent: + "pickerDidPresent" + case .pickerDidDismiss: + "pickerDidDismiss" + case .pickerSetupBridging: + "pickerSetupBridging" + case .pickerSetupFailed: + "pickerSetupFailed" + case .pickerSetupPairing: + "pickerSetupPairing" + case .pickerSetupRename: + "pickerSetupRename" + @unknown default: + "ASAccessoryEventType(rawValue: \(rawValue))" + } + } + + public var debugDescription: String { + description + } +} +#endif diff --git a/Sources/SpeziBluetooth/AccessorySetupKit/ASDiscoveryDescriptor.Range+Description.swift b/Sources/SpeziBluetooth/AccessorySetupKit/ASDiscoveryDescriptor.Range+Description.swift new file mode 100644 index 00000000..ecbae841 --- /dev/null +++ b/Sources/SpeziBluetooth/AccessorySetupKit/ASDiscoveryDescriptor.Range+Description.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 +// + +#if canImport(AccessorySetupKit) && !os(macOS) +import AccessorySetupKit + + +@available(iOS 18, *) +@available(macCatalyst, unavailable) +@available(visionOS, unavailable) +extension ASDiscoveryDescriptor.Range: @retroactive CustomStringConvertible, @retroactive CustomDebugStringConvertible { + public var description: String { + switch self { + case .default: + "default" + case .immediate: + "immediate" + @unknown default: + "Range(rawValue: \(rawValue))" + } + } + + public var debugDescription: String { + description + } +} +#endif diff --git a/Sources/SpeziBluetooth/AccessorySetupKit/AccessoryEventRegistration.swift b/Sources/SpeziBluetooth/AccessorySetupKit/AccessoryEventRegistration.swift new file mode 100644 index 00000000..31363a82 --- /dev/null +++ b/Sources/SpeziBluetooth/AccessorySetupKit/AccessoryEventRegistration.swift @@ -0,0 +1,51 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +/// An event handler registration for accessory events. +/// +/// It automatically cancels the subscription once this value is de-initialized. +public struct AccessoryEventRegistration: ~Copyable, Sendable { + private let id: UUID + private weak var setupKit: (AnyObject & Sendable)? // type erased as AccessorySetupKit is only available on iOS 18 platform. + + @available(iOS 18.0, *) + @available(macCatalyst, unavailable) + init(id: UUID, setupKit: AccessorySetupKit?) { + self.id = id + self.setupKit = setupKit + } + + static func cancel(id: UUID, setupKit: (AnyObject & Sendable)?, isolation: isolated (any Actor)? = #isolation) { +#if os(iOS) && !targetEnvironment(macCatalyst) + guard #available(iOS 18, *) else { + return + } + + guard let setupKit, let typedSetupKit = setupKit as? AccessorySetupKit else { + return + } + + typedSetupKit.cancelHandler(for: id) +#else + preconditionFailure("Not available on this platform!") +#endif + } + + /// Cancel the subscription. + /// - Parameter isolation: Inherits the current actor isolation. If running on the MainActor cancellation is processed instantly. + public func cancel(isolation: isolated (any Actor)? = #isolation) { + Self.cancel(id: id, setupKit: setupKit) + } + + deinit { + Self.cancel(id: id, setupKit: setupKit) + } +} diff --git a/Sources/SpeziBluetooth/AccessorySetupKit/AccessorySetupKit.swift b/Sources/SpeziBluetooth/AccessorySetupKit/AccessorySetupKit.swift new file mode 100644 index 00000000..a9163026 --- /dev/null +++ b/Sources/SpeziBluetooth/AccessorySetupKit/AccessorySetupKit.swift @@ -0,0 +1,403 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(AccessorySetupKit) && !os(macOS) +import AccessorySetupKit +#endif +import Foundation +import Spezi + + +/// Enable privacy-preserving discovery and configuration of accessories through Apple's AccessorySetupKit. +/// +/// This module enables to discover and configure Bluetooth or Wi-Fi accessories using Apple's [AccessorySetupKit](https://developer.apple.com/documentation/accessorysetupkit). +/// +/// - Important: Make sure to follow all the setup instructions in [Declare your app's accessories](https://developer.apple.com/documentation/accessorysetupkit/discovering-and-configuring-accessories#Declare-your-apps-accessories) +/// to declare all the necessary accessory information in your `Info.plist` file. +/// +/// ## Topics +/// +/// ### Configuration +/// - ``init()`` +/// +/// ### Discovered Accessories +/// - ``accessories`` +/// +/// ### Displaying an accessory picker +/// - ``showPicker(for:)`` +/// - ``pickerPresented`` +/// +/// ### Managing Accessories +/// - ``renameAccessory(_:options:)`` +/// - ``removeAccessory(_:)`` +/// +/// ### Managing Authorization +/// - ``finishAuthorization(for:settings:)`` +/// - ``failAuthorization(for:)`` +/// +/// ### Determine Support +/// - ``supportedProtocols`` +/// - ``SupportedProtocol`` +@SpeziBluetooth +@available(iOS 18.0, *) +public final class AccessorySetupKit { + @MainActor + @Observable + fileprivate final class State { + var pickerPresented = false + + let accessories: Void = () + + nonisolated init() {} + } + + @Application(\.logger) + private var logger + +#if canImport(AccessorySetupKit) && !targetEnvironment(macCatalyst) && !os(macOS) + private let session = ASAccessorySession() +#endif + private let state = State() + + /// Determine if the accessory picker is currently being presented. + @MainActor public var pickerPresented: Bool { + state.pickerPresented + } + +#if canImport(AccessorySetupKit) && !targetEnvironment(macCatalyst) && !os(macOS) + /// Previously selected accessories for this application. + @available(macCatalyst, unavailable) + public var accessories: [ASAccessory] { + state.access(keyPath: \.accessories) + return session.accessories + } + + private nonisolated(unsafe) var accessoryChangeHandlers: [UUID: @SpeziBluetooth (AccessoryEvent) -> Void] = [:] + private nonisolated(unsafe) var accessoryChangeSubscriptions: [UUID: AsyncStream.Continuation] = [:] + private let subscriptionLock = NSLock() + + /// Subscribe to accessory events. + /// + /// - Note: If you need to act on accessory events synchronously, you can register an event handler using ``registerHandler(eventHandler:)``. + @available(macCatalyst, unavailable) + public nonisolated var accessoryChanges: AsyncStream { + AsyncStream { continuation in + let id = UUID() + + subscriptionLock.withLock { + accessoryChangeSubscriptions[id] = continuation + } + + continuation.onTermination = { [weak self] _ in + guard let self else { + return + } + subscriptionLock.withLock { + _ = self.accessoryChangeSubscriptions.removeValue(forKey: id) + } + } + } + } +#endif + + /// Initialize the accessory setup kit. + public nonisolated init() {} + + /// Configure the Module. + @_documentation(visibility: internal) + @MainActor + public func configure() { + Task { @SpeziBluetooth in +#if canImport(AccessorySetupKit) && !targetEnvironment(macCatalyst) && !os(macOS) + self.session.activate(on: SpeziBluetooth.shared.dispatchQueue) { [weak self] event in + guard let self else { + return + } + SpeziBluetooth.assumeIsolated { + self.handleSessionEvent(event: event) + } + } +#endif + } + } + + + /// Register an event handler for `AccessoryEvent`s. + /// - Parameter eventHandler: The event handler that receives the ``AccessoryEvent``s. + /// - Returns: Returns a ``AccessoryEventRegistration`` that you should keep track of and allows to cancel the event handler. + @available(visionOS, unavailable) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + @available(macOS, unavailable) + @available(macCatalyst, unavailable) + public nonisolated func registerHandler(eventHandler: @SpeziBluetooth @escaping (AccessoryEvent) -> Void) -> AccessoryEventRegistration { +#if canImport(AccessorySetupKit) && !targetEnvironment(macCatalyst) && !os(macOS) + let id = UUID() + subscriptionLock.withLock { + accessoryChangeHandlers[id] = eventHandler + } + return AccessoryEventRegistration(id: id, setupKit: self) +#else + preconditionFailure("\(#function) is unavailable on this platform.") +#endif + } + + nonisolated func cancelHandler(for id: UUID) { +#if canImport(AccessorySetupKit) && !targetEnvironment(macCatalyst) && !os(macOS) + subscriptionLock.withLock { + _ = accessoryChangeHandlers.removeValue(forKey: id) + } +#endif + } + +#if canImport(AccessorySetupKit) && !targetEnvironment(macCatalyst) && !os(macOS) + /// Discover display items in picker. + /// - Parameter items: The known display items to discover. + @available(macCatalyst, unavailable) + public func showPicker(for items: [ASPickerDisplayItem]) async throws { + // session is not Sendable (explicitly marked as non-Sendable), therefore we cannot call async functions on that type. + // Even though they exist, we cannot call them in Swift 6(! ... Apple), and thus we need to manually create a continuation + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + session.showPicker(for: items) { error in + if let error { + continuation.resume(throwing: AccessorySetupKitError.mapError(error)) + } else { + continuation.resume() + } + } + } + } + + /// Rename accessory. + /// + /// Calling this method will show a picker view that allows to rename the accessory. + /// - Parameters: + /// - accessory: The accessory. + /// - renameOptions: The rename options. + @available(macCatalyst, unavailable) + public func renameAccessory(_ accessory: ASAccessory, options renameOptions: ASAccessory.RenameOptions = []) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + session.renameAccessory(accessory, options: renameOptions) { error in + if let error { + continuation.resume(throwing: AccessorySetupKitError.mapError(error)) + } else { + continuation.resume() + } + } + } + } + + /// Remove an accessory from the application. + /// + /// If this application is the last one to access the accessory, it will be permanently un-paired from the device. + /// - Parameter accessory: The accessory to remove or forget. + @available(macCatalyst, unavailable) + public func removeAccessory(_ accessory: ASAccessory) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + session.removeAccessory(accessory) { error in + if let error { + continuation.resume(throwing: AccessorySetupKitError.mapError(error)) + } else { + continuation.resume() + } + } + } + } + + /// Finish accessory setup awaiting authorization. + /// - Parameters: + /// - accessory: The accessory awaiting authorization. + /// - settings: The accessory settings. + @available(macCatalyst, unavailable) + public func finishAuthorization(for accessory: ASAccessory, settings: ASAccessorySettings) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + session.finishAuthorization(for: accessory, settings: settings) { error in + if let error { + continuation.resume(throwing: AccessorySetupKitError.mapError(error)) + } else { + continuation.resume() + } + } + } + } + + /// Fail accessory setup awaiting authorization. + /// - Parameter accessory: The accessory awaiting authorization. + @available(macCatalyst, unavailable) + public func failAuthorization(for accessory: ASAccessory) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + session.failAuthorization(for: accessory) { error in + if let error { + continuation.resume(throwing: AccessorySetupKitError.mapError(error)) + } else { + continuation.resume() + } + } + } + } + + private func callHandler(with event: AccessoryEvent) { + let (subscriptions, handlers) = subscriptionLock.withLock { + (Array(accessoryChangeSubscriptions.values), Array(accessoryChangeHandlers.values)) + } + + for subscription in subscriptions { + subscription.yield(event) + } + + for handler in handlers { + handler(event) + } + } + + private func handleSessionEvent(event: ASAccessoryEvent) { // swiftlint:disable:this cyclomatic_complexity + if let accessory = event.accessory { + logger.debug("Dispatching Accessory Session event \(event.eventType) for accessory \(accessory)") + } else { + logger.debug("Dispatching Accessory Session event \(event.eventType)") + } + + switch event.eventType { + case .activated: + state.withMutation(keyPath: \.accessories) {} + callHandler(with: .available) + case .invalidated: + break + case .migrationComplete: + break + case .accessoryAdded: + guard let accessory = event.accessory else { + return + } + state.withMutation(keyPath: \.accessories) {} + callHandler(with: .added(accessory)) + case .accessoryRemoved: + guard let accessory = event.accessory else { + return + } + state.withMutation(keyPath: \.accessories) {} + callHandler(with: .removed(accessory)) + case .accessoryChanged: + guard let accessory = event.accessory else { + return + } + state.withMutation(keyPath: \.accessories) {} + callHandler(with: .changed(accessory)) + case .pickerDidPresent: + Task { @MainActor in + state.pickerPresented = true + } + case .pickerDidDismiss: + Task { @MainActor in + state.pickerPresented = false + } + case .pickerSetupBridging, .pickerSetupFailed, .pickerSetupPairing, .pickerSetupRename: + break + case .unknown: + break + @unknown default: + logger.warning("The Accessory Setup session is unknown: \(event.eventType)") + } + } +#endif +} + + +@available(iOS 18.0, *) +extension AccessorySetupKit: Module, DefaultInitializable, Sendable {} + + +@available(iOS 18.0, *) +@available(macCatalyst, unavailable) +@available(visionOS, unavailable) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +@available(macOS, unavailable) +extension AccessorySetupKit { + /// Accessory-related events. + public enum AccessoryEvent { + /// The ``AccessorySetupKit/accessories`` property is now available. + case available +#if canImport(AccessorySetupKit) && !os(macOS) + /// New accessory was successfully added. + case added(ASAccessory) + /// An accessory was removed. + case removed(ASAccessory) + /// An accessory was changed. + case changed(ASAccessory) +#endif + } +} + + +@available(iOS 18.0, *) +@available(macOS, unavailable) +@available(macCatalyst, unavailable) +@available(visionOS, unavailable) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +extension AccessorySetupKit.AccessoryEvent: Sendable, Hashable {} + + +@available(iOS 18.0, *) +@available(macOS, unavailable) +@available(macCatalyst, unavailable) +@available(visionOS, unavailable) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +extension AccessorySetupKit.AccessoryEvent: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { + switch self { + case .available: + "available" +#if canImport(AccessorySetupKit) && !os(macOS) + case let .added(accessory): + ".added(\(accessory))" + case let .removed(accessory): + ".removed(\(accessory))" + case let .changed(accessory): + ".changed(\(accessory))" +#endif + } + } + + public var debugDescription: String { + description + } +} + + +@available(iOS 18.0, *) +@available(macCatalyst, unavailable) +extension AccessorySetupKit { + /// A supported protocol of the accessory setup kit. + public struct SupportedProtocol: RawRepresentable, Hashable, Sendable { + /// The raw value identifier. + public let rawValue: String + + /// Initialize a new supported protocol. + /// - Parameter rawValue: The raw value identifier. + public init(rawValue: String) { + self.rawValue = rawValue + } + } + + /// Retrieve the supported protocols that are defined in the `Info.plist` of the `main` bundle. + public static nonisolated var supportedProtocols: [SupportedProtocol] { + (Bundle.main.object(forInfoDictionaryKey: "NSAccessorySetupKitSupports") as? [String] ?? []).map { .init(rawValue: $0) } + } +} + +@available(iOS 18.0, *) +@available(macCatalyst, unavailable) +extension AccessorySetupKit.SupportedProtocol { + /// Discover accessories using Bluetooth or Bluetooth Low Energy. + public static let bluetooth = AccessorySetupKit.SupportedProtocol(rawValue: "Bluetooth") + /// Discover accessories using wifi SSIDs. + public static let wifi = AccessorySetupKit.SupportedProtocol(rawValue: "WiFi") +} diff --git a/Sources/SpeziBluetooth/AccessorySetupKit/AccessorySetupKitError.swift b/Sources/SpeziBluetooth/AccessorySetupKit/AccessorySetupKitError.swift new file mode 100644 index 00000000..15fc16f0 --- /dev/null +++ b/Sources/SpeziBluetooth/AccessorySetupKit/AccessorySetupKitError.swift @@ -0,0 +1,191 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(AccessorySetupKit) && !os(macOS) +import AccessorySetupKit +#endif +import Foundation + + +/// The `ASError` from `AccessorySetupKit` mapped to Swift. +/// +/// ## Topics +/// ### Activation Errors +/// - ``activationFailed`` +/// +/// ### Lifecycle Errors +/// - ``invalidated`` +/// +/// ### Configuration Errors +/// - ``extensionNotFound`` +/// - ``invalidRequest`` +/// +/// ### Picker Errors +/// - ``pickerRestricted`` +/// - ``pickerAlreadyActive`` +/// +/// ### Cancellation and Permission Errors +/// - ``userCancelled`` +/// - ``userRestricted`` +/// +/// ### Communication Errors +/// - ``connectionFailed`` +/// - ``discoveryTimeout`` +/// +/// ### Success Case +/// - ``success`` +/// +/// ### Unknown Error +/// - ``unknown`` +public enum AccessorySetupKitError { + /// Success + case success + /// Unknown + case unknown + /// Unable to activate discovery session. + case activationFailed + /// Unable to establish connection with accessory. + case connectionFailed + /// Discovery timed out. + case discoveryTimeout + /// Unable to find App Extension. + case extensionNotFound + /// Invalidate was called before the operation completed normally. + case invalidated + /// Invalid request. + case invalidRequest + /// Picker already active. + case pickerAlreadyActive + /// Picker restricted due to the application being in background. + case pickerRestricted + /// User cancelled. + case userCancelled + /// Access restricted by user. + case userRestricted +} + + +extension AccessorySetupKitError: Error {} + + +#if canImport(AccessorySetupKit) && !os(macOS) +@available(iOS 18, *) +@available(macCatalyst, unavailable) +extension AccessorySetupKitError { + /// Create a new error from an `ASError`. + /// - Parameter error: The `ASError`. + public init(from error: ASError) { // swiftlint:disable:this cyclomatic_complexity + switch error.code { + case .success: + self = .success + case .unknown: + self = .unknown + case .activationFailed: + self = .activationFailed + case .connectionFailed: + self = .connectionFailed + case .discoveryTimeout: + self = .discoveryTimeout + case .extensionNotFound: + self = .extensionNotFound + case .invalidated: + self = .invalidated + case .invalidRequest: + self = .invalidRequest + case .pickerAlreadyActive: + self = .pickerAlreadyActive + case .pickerRestricted: + self = .pickerRestricted + case .userCancelled: + self = .userCancelled + case .userRestricted: + self = .userRestricted + @unknown default: + Bluetooth.logger.warning("Detected unknown ASError code: \(error.code.rawValue)") + self = .unknown + } + } + + + static func mapError(_ error: Error) -> Error { + if let asError = error as? ASError { + AccessorySetupKitError(from: asError) + } else { + error + } + } +} +#endif + +extension AccessorySetupKitError: LocalizedError { + public var errorDescription: String? { + String(localized: errorDescriptionLocalization, bundle: .module) + } + + public var failureReason: String? { + String(localized: failureReasonLocalization, bundle: .module) + } + + private var errorDescriptionLocalization: String.LocalizationValue { + switch self { + case .success: + "Success" + case .unknown: + "Unknown" + case .activationFailed: + "Activation Failed" + case .connectionFailed: + "Connection Failed" + case .discoveryTimeout: + "Discovery Timeout" + case .extensionNotFound: + "Extension Not Found" + case .invalidated: + "Invalidated" + case .invalidRequest: + "Invalid Request" + case .pickerAlreadyActive: + "Busy" + case .pickerRestricted: + "Restricted" + case .userCancelled: + "Cancelled" + case .userRestricted: + "Restricted" + } + } + + private var failureReasonLocalization: String.LocalizationValue { + switch self { + case .success: + "Operation completed successfully." + case .unknown: + "Unknown error occurred." + case .activationFailed: + "Unable to activate discovery session." + case .connectionFailed: + "Unable to establish connection with the accessory." + case .discoveryTimeout: + "Discovery session timed out." + case .extensionNotFound: + "Unable to locate the App Extension." + case .invalidated: + "Invalidate was called before the operation completed normally." + case .invalidRequest: + "Received an invalid request." + case .pickerAlreadyActive: + "The picker is already active." + case .pickerRestricted: + "The picker is restricted due to the application being in the background." + case .userCancelled: + "The user cancelled the discovery." + case .userRestricted: + "Access was restricted by the user." + } + } +} diff --git a/Sources/SpeziBluetooth/AccessorySetupKit/DescriptorAspect+ASDiscoveryDescriptor.swift b/Sources/SpeziBluetooth/AccessorySetupKit/DescriptorAspect+ASDiscoveryDescriptor.swift new file mode 100644 index 00000000..bac82468 --- /dev/null +++ b/Sources/SpeziBluetooth/AccessorySetupKit/DescriptorAspect+ASDiscoveryDescriptor.swift @@ -0,0 +1,80 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(AccessorySetupKit) && !os(macOS) +import AccessorySetupKit +import SpeziFoundation + + +@available(iOS 18.0, *) +@available(macCatalyst, unavailable) +extension DescriptorAspect { + func apply(to descriptor: ASDiscoveryDescriptor) { + switch self { + case let .nameSubstring(substring): + descriptor.bluetoothNameSubstring = substring + case let .service(uuid, serviceData): + descriptor.bluetoothServiceUUID = uuid.cbuuid + descriptor.bluetoothServiceDataBlob = serviceData?.data + descriptor.bluetoothServiceDataMask = serviceData?.mask + case let .manufacturer(id, manufacturerData): + descriptor.bluetoothCompanyIdentifier = id.bluetoothCompanyIdentifier + descriptor.bluetoothManufacturerDataBlob = manufacturerData?.data + descriptor.bluetoothManufacturerDataMask = manufacturerData?.mask + case let .bluetoothRange(range): + guard let range = ASDiscoveryDescriptor.Range(rawValue: range) else { + preconditionFailure("Inconsistent state. ASDiscoveryDescriptor.Range could not be reconstructed from rawValue \(range)") + } + descriptor.bluetoothRange = range + case let .supportOptions(options): + descriptor.supportedOptions = .init(rawValue: options) + } + } + + func matches(_ descriptor: ASDiscoveryDescriptor) -> Bool { + switch self { + case let .nameSubstring(substring): + descriptor.bluetoothNameSubstring == substring + case let .service(uuid, serviceData): + if let serviceData { + serviceData == DataDescriptor( + dataProperty: descriptor.bluetoothServiceDataBlob, + maskProperty: descriptor.bluetoothServiceDataMask + ) + && descriptor.bluetoothServiceUUID == uuid.cbuuid + } else { + descriptor.bluetoothServiceUUID == uuid.cbuuid + } + case let .manufacturer(id, manufacturerData): + if let manufacturerData { + manufacturerData == DataDescriptor( + dataProperty: descriptor.bluetoothManufacturerDataBlob, + maskProperty: descriptor.bluetoothManufacturerDataMask + ) + && descriptor.bluetoothCompanyIdentifier == id.bluetoothCompanyIdentifier + } else { + descriptor.bluetoothCompanyIdentifier == id.bluetoothCompanyIdentifier + } + case let .bluetoothRange(value): + descriptor.bluetoothRange.rawValue == value + case let .supportOptions(value): + descriptor.supportedOptions.contains(ASAccessory.SupportOptions(rawValue: value)) + } + } +} + + +extension DataDescriptor { + fileprivate init?(dataProperty: Data?, maskProperty: Data?) { + guard let dataProperty, let maskProperty, dataProperty.count == maskProperty.count else { + return nil + } + self.init(data: dataProperty, mask: maskProperty) + } +} +#endif diff --git a/Sources/SpeziBluetooth/AccessorySetupKit/DeviceVariantCriteria+ASDiscoveryDescriptor.swift b/Sources/SpeziBluetooth/AccessorySetupKit/DeviceVariantCriteria+ASDiscoveryDescriptor.swift new file mode 100644 index 00000000..c87d9aa3 --- /dev/null +++ b/Sources/SpeziBluetooth/AccessorySetupKit/DeviceVariantCriteria+ASDiscoveryDescriptor.swift @@ -0,0 +1,33 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(AccessorySetupKit) && !os(macOS) +import AccessorySetupKit + + +@available(iOS 18, *) +@available(macCatalyst, unavailable) +extension DeviceVariantCriteria { + /// Apply criteria to a `ASDiscoveryDescriptor`. + /// - Parameter descriptor: The descriptor. + public func apply(to descriptor: ASDiscoveryDescriptor) { + for aspect in aspects { + aspect.apply(to: descriptor) + } + } + + /// Determine if a discovery descriptor matches the device variant criteria. + /// - Parameter descriptor: The discovery descriptor. + /// - Returns: Returns `true` if all discovery aspects are present and matching on the discovery descriptor. The discovery descriptor might have other fields set. + public func matches(descriptor: ASDiscoveryDescriptor) -> Bool { + aspects.allSatisfy { aspect in + aspect.matches(descriptor) + } + } +} +#endif diff --git a/Sources/SpeziBluetooth/AccessorySetupKit/DiscoveryCriteria+Descriptor.swift b/Sources/SpeziBluetooth/AccessorySetupKit/DiscoveryCriteria+Descriptor.swift new file mode 100644 index 00000000..2e47c7f6 --- /dev/null +++ b/Sources/SpeziBluetooth/AccessorySetupKit/DiscoveryCriteria+Descriptor.swift @@ -0,0 +1,45 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(AccessorySetupKit) && !os(macOS) +import AccessorySetupKit + + +@available(iOS 18.0, *) +@available(macCatalyst, unavailable) +extension DiscoveryCriteria { + /// Retrieve the `ASDiscoveryDescriptor` representation for the discovery criteria. + public var discoveryDescriptor: ASDiscoveryDescriptor { + let descriptor = ASDiscoveryDescriptor() + + if aspects.count(where: { $0.isServiceId }) > 1 { + Bluetooth.logger.warning( + """ + DiscoveryCriteria has multiple service uuids specified. This is not supported by AccessorySetupKit and only the first one \ + will be used with the ASDiscoveryDescriptor: \(self). + """ + ) + } + + for aspect in aspects { + aspect.apply(to: descriptor) + } + + return descriptor + } + + /// Determine if a discovery descriptor matches the discovery criteria. + /// - Parameter descriptor: The discovery descriptor. + /// - Returns: Returns `true` if all discovery aspects are present and matching on the discovery descriptor. The discovery descriptor might have other fields set. + public func matches(descriptor: ASDiscoveryDescriptor) -> Bool { + aspects.allSatisfy { aspect in + aspect.matches(descriptor) + } + } +} +#endif diff --git a/Sources/SpeziBluetooth/AccessorySetupKit/ManufacturerIdentifier+Identifier.swift b/Sources/SpeziBluetooth/AccessorySetupKit/ManufacturerIdentifier+Identifier.swift new file mode 100644 index 00000000..039a3b2a --- /dev/null +++ b/Sources/SpeziBluetooth/AccessorySetupKit/ManufacturerIdentifier+Identifier.swift @@ -0,0 +1,20 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(AccessorySetupKit) && !os(macOS) +import AccessorySetupKit + + +extension ManufacturerIdentifier { + /// Retrieve the `ASBluetoothCompanyIdentifier` representation for the manufacturer identifier. + @available(iOS 18.0, *) + public var bluetoothCompanyIdentifier: ASBluetoothCompanyIdentifier { + ASBluetoothCompanyIdentifier(rawValue) + } +} +#endif diff --git a/Sources/SpeziBluetooth/Bluetooth.swift b/Sources/SpeziBluetooth/Bluetooth.swift index c53f388b..37b8ba0a 100644 --- a/Sources/SpeziBluetooth/Bluetooth.swift +++ b/Sources/SpeziBluetooth/Bluetooth.swift @@ -93,8 +93,8 @@ 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:minimumRSSI:advertisementStaleInterval:autoConnect:)`` -/// and ``SwiftUI/View/autoConnect(enabled:with:minimumRSSI:advertisementStaleInterval:)`` +/// You can use the ``SwiftUICore/View/scanNearbyDevices(enabled:with:minimumRSSI:advertisementStaleInterval:autoConnect:)`` +/// and ``SwiftUICore/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(minimumRSSI:advertisementStaleInterval:autoConnect:)`` and ``stopScanning()``. /// @@ -231,7 +231,7 @@ import Spezi /// - ``powerOn()`` /// - ``powerOff()`` @SpeziBluetooth -public final class Bluetooth: Module, EnvironmentAccessible, Sendable { +public final class Bluetooth: Module, EnvironmentAccessible, @unchecked Sendable { @Observable class Storage { @MainActor var nearbyDevices: OrderedDictionary = [:] @@ -286,7 +286,7 @@ public final class Bluetooth: Module, EnvironmentAccessible, Sendable { /// Subscribe to changes of the `state` property. /// /// Creates an `AsyncStream` that yields all **future** changes to the ``state`` property. - public var stateSubscription: AsyncStream { + public nonisolated var stateSubscription: AsyncStream { bluetoothManager.stateSubscription } @@ -345,7 +345,6 @@ public final class Bluetooth: Module, EnvironmentAccessible, Sendable { /// /// - Note : The underlying `CBCentralManager` is lazily allocated and deallocated once it isn't needed anymore. /// This is used to delay Bluetooth permission and power prompts to the latest possible moment avoiding unexpected interruptions. - @SpeziBluetooth public func powerOn() { bluetoothManager.powerOn() } @@ -356,12 +355,14 @@ public final class Bluetooth: Module, EnvironmentAccessible, Sendable { /// /// - Note : The underlying `CBCentralManager` is lazily allocated and deallocated once it isn't needed anymore. /// This is used to delay Bluetooth permission and power prompts to the latest possible moment avoiding unexpected interruptions. - @SpeziBluetooth public func powerOff() { bluetoothManager.powerOff() } - @SpeziBluetooth + public func registerStateHandler(_ eventHandler: @escaping (BluetoothState) -> Void) -> StateRegistration { + bluetoothManager.registerStateHandler(eventHandler) + } + private func observeDiscoveredDevices() { bluetoothManager.onChange(of: \.discoveredPeripherals) { [weak self] discoveredDevices in guard let self = self else { @@ -377,7 +378,6 @@ public final class Bluetooth: Module, EnvironmentAccessible, Sendable { // And we don't care about the rest. } - @SpeziBluetooth private func handleUpdatedNearbyDevicesChange(_ discoveredDevices: OrderedDictionary) { var newlyPreparedDevices: Set = [] // track for which device instances we need to call Spezi/loadModule(...) @@ -391,7 +391,8 @@ public final class Bluetooth: Module, EnvironmentAccessible, Sendable { device = persistedDevice } else { let advertisementData = entry.value.advertisementData - guard let configuration = configuration.find(for: advertisementData, logger: logger) else { + let name = entry.value.name + guard let configuration = configuration.find(name: name, advertisementData: advertisementData, logger: logger) else { logger.warning("Ignoring peripheral \(entry.value) that cannot be mapped to a device class.") return } @@ -440,12 +441,10 @@ public final class Bluetooth: Module, EnvironmentAccessible, Sendable { @_spi(Internal) - @SpeziBluetooth public func _initializedDevicesCount() -> Int { // swiftlint:disable:this identifier_name initializedDevices.count } - @SpeziBluetooth 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. @@ -464,7 +463,6 @@ public final class Bluetooth: Module, EnvironmentAccessible, Sendable { } } - @SpeziBluetooth private func handlePeripheralStateChange() { // check for active connected device let connectedDevices = bluetoothManager.knownPeripherals @@ -520,7 +518,6 @@ public final class Bluetooth: Module, EnvironmentAccessible, Sendable { /// - 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. - @SpeziBluetooth public func retrieveDevice( for uuid: UUID, as device: Device.Type = Device.self @@ -571,7 +568,7 @@ public final class Bluetooth: Module, EnvironmentAccessible, Sendable { /// 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:minimumRSSI:advertisementStaleInterval:autoConnect:)`` + /// - Tip: Scanning for nearby devices can easily be managed via the ``SwiftUICore/View/scanNearbyDevices(enabled:with:minimumRSSI:advertisementStaleInterval:autoConnect:)`` /// modifier. /// /// - Parameters: @@ -580,7 +577,6 @@ public final class Bluetooth: Module, EnvironmentAccessible, Sendable { /// 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. - @SpeziBluetooth public func scanNearbyDevices( minimumRSSI: Int? = nil, advertisementStaleInterval: TimeInterval? = nil, @@ -595,7 +591,6 @@ public final class Bluetooth: Module, EnvironmentAccessible, Sendable { } /// Stop scanning for nearby bluetooth devices. - @SpeziBluetooth public func stopScanning() { bluetoothManager.stopScanning() } @@ -635,7 +630,6 @@ extension Bluetooth: BluetoothScanner { // MARK: - Device Handling extension Bluetooth { - @SpeziBluetooth func prepareDevice(id uuid: UUID, _ device: Device.Type, peripheral: BluetoothPeripheral) -> Device { let device = device.init() @@ -669,7 +663,6 @@ extension Bluetooth { } - @SpeziBluetooth private func _notifyDeviceDeinit(for uuid: UUID) { #if DEBUG || TEST Task { @MainActor in diff --git a/Sources/SpeziBluetooth/Configuration/Apperance/Appearance.swift b/Sources/SpeziBluetooth/Configuration/Apperance/Appearance.swift new file mode 100644 index 00000000..c5782cc9 --- /dev/null +++ b/Sources/SpeziBluetooth/Configuration/Apperance/Appearance.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 +// + +import SpeziViews + + +/// Describes how a bluetooth device should be visually presented to the user. +public struct Appearance { + /// Provides a user-friendly name for the device. + /// + /// This might be treated as the "initial" name. A user might be allowed to rename the device locally. + public let name: String + /// An icon that is used to refer to the device. + public let icon: ImageReference + + /// Create a new device appearance. + /// - Parameters: + /// - name: Provides a user-friendly name for the device. + /// - icon: An icon that is used to refer to the device. + public init(name: String, icon: ImageReference = .system("sensor")) { + self.name = name + self.icon = icon + } +} + + +extension Appearance: Hashable, Sendable {} diff --git a/Sources/SpeziBluetooth/Configuration/Apperance/DeviceAppearance.swift b/Sources/SpeziBluetooth/Configuration/Apperance/DeviceAppearance.swift new file mode 100644 index 00000000..6c2baba8 --- /dev/null +++ b/Sources/SpeziBluetooth/Configuration/Apperance/DeviceAppearance.swift @@ -0,0 +1,61 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziViews + + +/// Describes the appearances and variants of a device. +public enum DeviceAppearance { + /// The appearance for the device. + case appearance(Appearance) + /// The device represents multiple different device variants that have different appearances. + /// + /// The `variants` describe how to identify each variant and its appearance. + /// The `defaultAppearance` appearance is used if the the variant cannot be determined. + case variants(defaultAppearance: Appearance, variants: [Variant]) +} + + +extension DeviceAppearance: Hashable, Sendable {} + + +extension DeviceAppearance { + /// Retrieve the appearance for a device. + /// - Parameter variantPredicate: If the device has different variants, this predicate will be used to match the desired variant. + /// - Returns: Returns the device `appearance` and optionally the `variantId`, if the appearance of a variant was returned. + public func appearance(where variantPredicate: (Variant) -> Bool) -> (appearance: Appearance, variantId: String?) { + switch self { + case let .appearance(appearance): + (appearance, nil) + case let .variants(defaultAppearance, variants): + if let variant = variants.first(where: variantPredicate) { + (Appearance(name: variant.name, icon: variant.icon), variant.id) + } else { + (defaultAppearance, nil) + } + } + } + + /// Retrieve the icon appearance of a device. + /// - Parameter variantId: The optional variant id to query. This id will be used to selected the device variant, if the device declares different variant appearances. + /// - Returns: Returns the device icon. + public func deviceIcon(variantId: String?) -> ImageReference { + appearance { variant in + variant.id == variantId + }.appearance.icon + } + + /// Retrieve the name of a device. + /// - Parameter variantId: The optional variant id to query. This id will be used to selected the device variant, if the device declares different variant appearances. + /// - Returns: Returns the device name. + public func deviceName(variantId: String?) -> String { + appearance { variant in + variant.id == variantId + }.appearance.name + } +} diff --git a/Sources/SpeziBluetooth/Configuration/Apperance/DeviceVariantCriteria.swift b/Sources/SpeziBluetooth/Configuration/Apperance/DeviceVariantCriteria.swift new file mode 100644 index 00000000..6ebaedcb --- /dev/null +++ b/Sources/SpeziBluetooth/Configuration/Apperance/DeviceVariantCriteria.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 SpeziFoundation +import SpeziViews + + +/// Describes the identifying criteria for a device variant. +/// +/// For more information refer to ``Variant``. +/// +/// ## Topics +/// +/// ### Criteria +/// - ``service(_:serviceData:)-8g3u6`` +/// - ``service(_:serviceData:)-7fadh`` +/// - ``nameSubstring(_:)`` +/// - ``manufacturer(_:manufacturerData:)`` +/// +/// ### Match against discovery information +/// - ``matches(name:advertisementData:)`` +public struct DeviceVariantCriteria { + let aspects: [DescriptorAspect] + + init(_ aspects: [DescriptorAspect]) { + self.aspects = aspects + } + + init(_ aspect: DescriptorAspect) { + self.init([aspect]) + } + + init(from criteria: [DeviceVariantCriteria]) { + aspects = criteria.flatMap { $0.aspects } + } + + /// Determine if the criteria matches a given device discovery information. + /// - Parameters: + /// - name: The device name. `nil` if not available. + /// - advertisementData: The advertisement data of the device. + /// - Returns: Returns `true` if the criteria matches the device. + public func matches(name: String?, advertisementData: AdvertisementData) -> Bool { + aspects.allSatisfy { aspect in + aspect.matches(name: name, advertisementData: advertisementData) + } + } +} + + +extension DeviceVariantCriteria: Sendable, Hashable {} + + +extension DeviceVariantCriteria { + /// Match against the device name. + /// + /// If there is a ``AdvertisementData/localName`` present in the advertisement, the name substring matches against the advertisement name. + /// If the localName is not present (any only then), the substring is matched against the GAP device name. + /// - Parameter substring: The name substring that has to be part of the advertised name. + /// - Returns: Returns the `DeviceVariantCriteria`. + public static func nameSubstring(_ substring: String) -> DeviceVariantCriteria { + DeviceVariantCriteria(.nameSubstring(substring)) + } + + /// Match against a advertised service. + /// - Parameters: + /// - uuid: The service uuid. + /// - serviceData: Optional service data that has to be advertised. + /// - Returns: Returns the `DeviceVariantCriteria`. + public static func service(_ uuid: BTUUID, serviceData: DataDescriptor? = nil) -> DeviceVariantCriteria { + DeviceVariantCriteria(.service(uuid: uuid, serviceData: serviceData)) + } + + /// Match against a advertised service. + /// - Parameters: + /// - service: The service type. + /// - serviceData: Optional service data that has to be advertised. + /// - Returns: Returns the `DeviceVariantCriteria`. + public static func service(_ service: Service.Type, serviceData: DataDescriptor? = nil) -> DeviceVariantCriteria { + .service(service.id, serviceData: serviceData) + } + + /// Match against advertised manufacturer information. + /// - Parameters: + /// - id: The manufacturer identifier. + /// - manufacturerData: Optional manufacturer data that has to be advertised. + /// - Returns: Returns the `DeviceVariantCriteria`. + public static func manufacturer(_ id: ManufacturerIdentifier, manufacturerData: DataDescriptor? = nil) -> DeviceVariantCriteria { + DeviceVariantCriteria(.manufacturer(id: id, manufacturerData: manufacturerData)) + } +} diff --git a/Sources/SpeziBluetooth/Configuration/Apperance/Variant.swift b/Sources/SpeziBluetooth/Configuration/Apperance/Variant.swift new file mode 100644 index 00000000..afac010b --- /dev/null +++ b/Sources/SpeziBluetooth/Configuration/Apperance/Variant.swift @@ -0,0 +1,47 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziViews + + +/// Describes the appearance of a device variant and criteria that identify the variant. +public struct Variant { + /// A unique and persistent identifier for the device variant. + /// + /// As the ``criteria`` can only be used upon discovery to identify a device variant, the `id` can be used in persistent storage to + /// identify the variant of a device. Make sure this identifier doesn't change and is unique for the device. + public let id: String + /// Provides a user-friendly name for the device. + /// + /// This might be treated as the "initial" name. A user might be allowed to rename the device locally. + public let name: String + /// An icon that is used to refer to the device. + public let icon: ImageReference + /// The criteria that identify a device variant and distinguish the variant from other device variants. + public let criteria: DeviceVariantCriteria + + /// Create a new device variant. + /// - Parameters: + /// - id: A unique and persistent identifier for the device variant. + /// - name: A user-friendly name for the device. + /// - icon: An icon that is used to refer to the device. + /// - criteria: The criteria that identify a device variant and distinguish the variant from other device variants. + /// - Precondition: You have to provide at least one device variant criteria: `!criteria.isEmpty` + public init(id: String, name: String, icon: ImageReference = .system("sensor"), criteria: DeviceVariantCriteria...) { + // swiftlint:disable:previous function_default_parameter_at_end + precondition(!criteria.isEmpty, "At least one device variant criteria must be provided") + + self.id = id + self.name = name + self.icon = icon + self.criteria = DeviceVariantCriteria(from: criteria) + } +} + + +extension Variant: Hashable, Sendable, Identifiable {} diff --git a/Sources/SpeziBluetooth/Configuration/DeviceDiscoveryDescriptor.swift b/Sources/SpeziBluetooth/Configuration/DeviceDiscoveryDescriptor.swift index f3f9a846..a499e97e 100644 --- a/Sources/SpeziBluetooth/Configuration/DeviceDiscoveryDescriptor.swift +++ b/Sources/SpeziBluetooth/Configuration/DeviceDiscoveryDescriptor.swift @@ -11,15 +11,14 @@ /// /// Provides a strategy on how to discovery given ``BluetoothDevice`` device 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 + /// The criteria by which we identify a discovered device. + public let discoveryCriteria: DiscoveryCriteria - - init(discoveryCriteria: DiscoveryCriteria, deviceType: any BluetoothDevice.Type) { - self.discoveryCriteria = discoveryCriteria - self.deviceType = deviceType + init(from discoverExpression: Discover) { + self.deviceType = discoverExpression.deviceType + self.discoveryCriteria = discoverExpression.discoveryCriteria } } diff --git a/Sources/SpeziBluetooth/Configuration/Discover.swift b/Sources/SpeziBluetooth/Configuration/Discover.swift index b80402e1..5c3aef9e 100644 --- a/Sources/SpeziBluetooth/Configuration/Discover.swift +++ b/Sources/SpeziBluetooth/Configuration/Discover.swift @@ -13,10 +13,13 @@ /// /// - Important: The discovery criteria must be unique across all discovery configurations. Not doing so will result in undefined behavior. /// +/// ```swift +/// Discover(MyBluetoothDevice.self, by: .advertisedService(WeightScaleService.self)) +/// ``` +/// /// ## Topics /// /// ### Discovering a device -/// /// - ``init(_:by:)`` /// /// ### Semantic Model @@ -26,7 +29,6 @@ public struct Discover { let deviceType: Device.Type let discoveryCriteria: DiscoveryCriteria - /// Create a discovery for a given device type. /// - Parameters: /// - device: The type of a ``BluetoothDevice`` implementation. diff --git a/Sources/SpeziBluetooth/Configuration/DiscoveryDescriptorBuilder.swift b/Sources/SpeziBluetooth/Configuration/DiscoveryDescriptorBuilder.swift index 583393fd..71b50716 100644 --- a/Sources/SpeziBluetooth/Configuration/DiscoveryDescriptorBuilder.swift +++ b/Sources/SpeziBluetooth/Configuration/DiscoveryDescriptorBuilder.swift @@ -7,12 +7,12 @@ // -/// Building a set of ``Discover`` expressions to express what peripherals to discover. +/// 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, deviceType: Device.self)] + [DeviceDiscoveryDescriptor(from: expression)] } /// Build a block of ``DeviceDiscoveryDescriptor``s. diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift index 431e0d4e..a2addeed 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift @@ -49,8 +49,8 @@ import OSLog /// /// 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:discovery:minimumRSSI:advertisementStaleInterval:autoConnect:)`` -/// and ``SwiftUI/View/autoConnect(enabled:with:discovery:minimumRSSI:advertisementStaleInterval:)`` +/// - Tip: You can also use the ``SwiftUICore/View/scanNearbyDevices(enabled:with:discovery:minimumRSSI:advertisementStaleInterval:autoConnect:)`` +/// and ``SwiftUICore/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. /// @@ -65,13 +65,14 @@ import OSLog /// - ``state`` /// - ``isScanning`` /// - ``stateSubscription`` +/// - ``StateRegistration`` /// /// ### Discovering nearby Peripherals /// - ``nearbyPeripherals`` /// - ``scanNearbyDevices(discovery:minimumRSSI:advertisementStaleInterval:autoConnect:)`` /// - ``stopScanning()`` -/// - ``SwiftUI/View/scanNearbyDevices(enabled:with:discovery:minimumRSSI:advertisementStaleInterval:autoConnect:)`` -/// - ``SwiftUI/View/autoConnect(enabled:with:discovery:minimumRSSI:advertisementStaleInterval:)`` +/// - ``SwiftUICore/View/scanNearbyDevices(enabled:with:discovery:minimumRSSI:advertisementStaleInterval:autoConnect:)`` +/// - ``SwiftUICore/View/autoConnect(enabled:with:discovery:minimumRSSI:advertisementStaleInterval:)`` /// /// ### Retrieving known Peripherals /// - ``retrievePeripheral(for:with:)`` @@ -128,6 +129,8 @@ public class BluetoothManager: Observable, Sendable, Identifiable { // swiftlint /// Subscribe to changes of the `state` property. /// /// Creates an `AsyncStream` that yields all **future** changes to the ``state`` property. + /// + /// - Note: If you need to instantly react to state changes, you can use the ``registerStateHandler(_:)`` method. public nonisolated var stateSubscription: AsyncStream { storage.stateSubscription } @@ -198,10 +201,13 @@ public class BluetoothManager: Observable, Sendable, Identifiable { // swiftlint } func cleanupCBCentral() { + let hadCentral = _centralManager != nil _centralManager = nil isScanningObserver = nil - lastManuallyDisconnectedDevice = nil - logger.debug("Destroyed the underlying CBCentralManager.") + + if hadCentral { + logger.debug("Destroyed the underlying CBCentralManager.") + } } /// Request to power up the Bluetooth Central. @@ -226,13 +232,22 @@ public class BluetoothManager: Observable, Sendable, Identifiable { // swiftlint keepPoweredOn = false checkForCentralDeinit() } + + /// Register a Bluetooth state handler that is synchronously executed. + /// + /// - Note: Use the ``stateSubscription`` to retrieve an async stream of state subscription. + /// - Parameter eventHandler: The event handler closure that is executed synchronously. Do not perform expensive operations within this event handler. + /// - Returns: Returns the registration of the state handler. + public func registerStateHandler(_ eventHandler: @escaping (BluetoothState) -> Void) -> StateRegistration { + storage.subscribe(eventHandler) + } /// Scan for nearby bluetooth devices. /// /// 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:discovery:minimumRSSI:advertisementStaleInterval:autoConnect:)`` + /// - Tip: Scanning for nearby devices can easily be managed via the ``SwiftUICore/View/scanNearbyDevices(enabled:with:discovery:minimumRSSI:advertisementStaleInterval:autoConnect:)`` /// modifier. /// /// - Parameters: @@ -326,6 +341,12 @@ public class BluetoothManager: Observable, Sendable, Identifiable { // swiftlint } } + private func handlePoweredOff() { + for peripheral in knownPeripherals.values { + discardDevice(device: peripheral, error: CancellationError()) + } + } + private func handleStoppedScanning() { let devices = discoveredPeripherals.values.filter { device in device.cbPeripheral.state == .disconnected @@ -548,7 +569,7 @@ extension BluetoothManager: BluetoothScanner { storage.maHasConnectedDevices } - @SpeziBluetooth var sbHasConnectedDevices: Bool { + var sbHasConnectedDevices: Bool { storage.hasConnectedDevices // support for DiscoverySession } @@ -611,19 +632,24 @@ extension BluetoothManager { // form a Swift Runtime perspective. // Refer to _isCurrentExecutor (checked in assumeIsolated): // https://github.com/apple/swift/blob/9e2b97c0fd675efaa5b815748d8567d781415c8c/stdlib/public/Concurrency/Actor.cpp#L317 - // Also refer to te implementation of assumeIsolated: + // Also refer to the implementation of assumeIsolated: // https://github.com/apple/swift/blob/a1062d06e9f33512b0005d589e3b086a89cfcbd1/stdlib/public/Concurrency/ExecutorAssertions.swift#L351-L372. // We could just cast the closure to be isolated (nothing else does assumeIsolated), however we would not have the // same Runtime state as an executing Task that is actor isolated. // So whats the solution? We schedule onto a background SerialExecutor (@SpeziBluetooth) so we maintain execution // order and make sure to capture all important state before that. - Task { @SpeziBluetooth [logger] in + // + // Note: this is now possible in Swift 6 when running on iOS 18 versions. However, we currently maintain backwards compatibility. + SpeziBluetooth.assumeIsolatedIfAvailableOrTask { [logger] in manager.storage.update(state: state) logger.info("BluetoothManager central state is now \(manager.state)") - if case .poweredOn = state { + switch state { + case .poweredOn: manager.handlePoweredOn() - } else if case .unauthorized = state { + case .poweredOff: + manager.handlePoweredOff() + case .unauthorized: switch CBCentralManager.authorization { case .denied: logger.log("Unauthorized reason: Access to Bluetooth was denied.") @@ -632,6 +658,8 @@ extension BluetoothManager { default: break } + case .unsupported, .unknown: + break } } } @@ -652,7 +680,7 @@ extension BluetoothManager { let peripheral = CBInstance(instantiatedOnDispatchQueue: peripheral) - Task { @SpeziBluetooth [logger, data] in + SpeziBluetooth.assumeIsolatedIfAvailableOrTask { [logger, data] in guard let session = manager.discoverySession, manager.isScanning else { return @@ -678,12 +706,14 @@ extension BluetoothManager { logger.debug("Discovered peripheral \(peripheral.debugIdentifier) at \(rssi.intValue) dB with data \(data)") - let descriptor = session.configuredDevices.find(for: data, logger: logger) + guard let descriptor = session.configuredDevices.find(name: peripheral.name, advertisementData: data, logger: logger) else { + return // we searched for the serviceId, but other aspects do not match + } let device = BluetoothPeripheral( manager: manager, peripheral: peripheral.cbObject, - configuration: descriptor?.device ?? DeviceDescription(), + configuration: descriptor.device, advertisementData: data, rssi: rssi.intValue ) @@ -701,7 +731,7 @@ extension BluetoothManager { } let peripheral = CBInstance(instantiatedOnDispatchQueue: peripheral) - Task { @SpeziBluetooth [logger] in + SpeziBluetooth.assumeIsolatedIfAvailableOrTask { [logger] in guard let device = manager.knownPeripheral(for: peripheral.identifier) else { logger.error("Received didConnect for unknown peripheral \(peripheral.debugIdentifier). Cancelling connection ...") manager.centralManager.cancelPeripheralConnection(peripheral.cbObject) @@ -709,9 +739,11 @@ extension BluetoothManager { } logger.debug("Peripheral \(device) connected.") - await manager.storage.cbDelegateSignal(connected: true, for: peripheral.identifier) + manager.storage.cbDelegateSignal(connected: true, for: peripheral.identifier) - await manager.handledConnected(device: device) + Task { @SpeziBluetooth in + await manager.handledConnected(device: device) + } } } @@ -725,7 +757,7 @@ extension BluetoothManager { let peripheral = CBInstance(instantiatedOnDispatchQueue: peripheral) - Task { @SpeziBluetooth [logger] in + SpeziBluetooth.assumeIsolatedIfAvailableOrTask { [logger] in 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.cbObject) @@ -752,7 +784,7 @@ extension BluetoothManager { } let peripheral = CBInstance(instantiatedOnDispatchQueue: peripheral) - Task { @SpeziBluetooth [logger] in + SpeziBluetooth.assumeIsolatedIfAvailableOrTask { [logger] in guard let device = manager.knownPeripheral(for: peripheral.identifier) else { logger.error("Received didDisconnect for unknown peripheral \(peripheral.debugIdentifier).") return @@ -765,7 +797,7 @@ extension BluetoothManager { } manager.discardDevice(device: device, error: error) - await manager.storage.cbDelegateSignal(connected: false, for: peripheral.identifier) + manager.storage.cbDelegateSignal(connected: false, for: peripheral.identifier) } } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift index 1710afac..2b3cb798 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift @@ -35,7 +35,7 @@ import SpeziFoundation /// /// ### Managing Connection /// - ``connect()`` -/// - ``disconnect()`` +/// - ``disconnect()-1nrzk`` /// /// ### Reading a value /// - ``read(characteristic:)`` @@ -67,17 +67,19 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length /// Observable state container for local state. private let storage: PeripheralStorage - /// Manage asynchronous accesses for an ongoing connection attempt. + /// Managed asynchronous accesses for an ongoing connection attempt. private let connectAccess = ManagedAsynchronousAccess() + /// Managed asynchronous accesses for an ongoing disconnect attempt. + private let disconnectAccess = ManagedAsynchronousAccess() /// Manage asynchronous accesses per characteristic. private let characteristicAccesses = CharacteristicAccesses() - /// Manage asynchronous accesses for an ongoing writhe without response. + /// Managed asynchronous accesses for an ongoing writhe without response. private let writeWithoutResponseAccess = ManagedAsynchronousAccess() - /// Manage asynchronous accesses for the rssi read action. + /// Managed asynchronous accesses for the rssi read action. private let rssiAccess = ManagedAsynchronousAccess() - /// Manage asynchronous accesses for service discovery. + /// Managed asynchronous accesses for service discovery. private let discoverServicesAccess = ManagedAsynchronousAccess<[BTUUID], Error>() - /// Manage asynchronous accesses for characteristic discovery of a given service. + /// Managed asynchronous accesses for characteristic discovery of a given service. private var discoverCharacteristicAccesses: [BTUUID: ManagedAsynchronousAccess] = [:] /// On-change handler registrations for all characteristics. @@ -191,14 +193,19 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length return } + guard manager.state == .poweredOn else { + // CoreBluetooth only prints a "API MISUSE" log warning if one attempts to connect while not being poweredOn + throw BluetoothError.invalidState(manager.state) + } + try await withTaskCancellationHandler { try await connectAccess.perform { manager.connect(peripheral: self) } } onCancel: { Task { @SpeziBluetooth in - if connectAccess.isRunning { - disconnect() + if connectAccess.ongoingAccess { + await disconnect() } } } @@ -207,7 +214,18 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length /// Disconnect the ongoing connection to the peripheral. /// /// Cancels an active or pending connection to a peripheral. + @available(*, deprecated, message: "Please migrate to the async version of disconnect().") + @_documentation(visibility: internal) public func disconnect() { + Task { + await disconnect() + } + } + + /// Disconnect the ongoing connection to the peripheral. + /// + /// Cancels an active or pending connection to a peripheral. + public func disconnect() async { guard let manager else { logger.warning("Tried to disconnect an orphaned bluetooth peripheral!") return @@ -215,9 +233,25 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length removeAllNotifications() - manager.disconnect(peripheral: self) - // ensure that it is updated instantly. - storage.update(state: PeripheralState(from: cbPeripheral.state)) + guard case .poweredOn = manager.state else { + // CoreBluetooth only prints a "API MISUSE" log warning if one attempts to connect while not being poweredOn + return + } + + if case .disconnected = state { + manager.disconnect(peripheral: self) // just be save and call it anyways + return // the delegate will not be called if already disconnected + } + + do { + try await disconnectAccess.perform { + manager.disconnect(peripheral: self) + // ensure that it is updated instantly. + storage.update(state: PeripheralState(from: cbPeripheral.state)) + } + } catch { + // "perform" just throws because of cancellation + } } /// Retrieve a service. @@ -275,7 +309,7 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length } catch { logger.error("Failed to discover initial services: \(error)") connectAccess.resume(throwing: error) - disconnect() + await disconnect() return } @@ -422,6 +456,8 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length self.invalidateServices(Set(serviceIds)) } + disconnectAccess.resume() // the error describes the disconnect reason, but the disconnect itself cannot throw + connectAccess.cancelAll(error: error) writeWithoutResponseAccess.cancelAll() rssiAccess.cancelAll(error: error) @@ -842,7 +878,7 @@ extension BluetoothPeripheral { let name = peripheral.name - Task { @SpeziBluetooth in + SpeziBluetooth.assumeIsolatedIfAvailableOrTask { device.storage.peripheralName = name } } @@ -852,7 +888,7 @@ extension BluetoothPeripheral { return } - Task { @SpeziBluetooth in + SpeziBluetooth.assumeIsolatedIfAvailableOrTask { let rssi = RSSI.intValue device.storage.rssi = rssi @@ -878,7 +914,7 @@ extension BluetoothPeripheral { logger.debug("Services modified, invalidating \(serviceIds)") let peripheral = CBInstance(instantiatedOnDispatchQueue: peripheral) - Task { @SpeziBluetooth in + SpeziBluetooth.assumeIsolatedIfAvailableOrTask { // update our local model! device.invalidateServices(Set(serviceIds)) @@ -906,7 +942,7 @@ extension BluetoothPeripheral { result = .success([]) } - Task { @SpeziBluetooth in + SpeziBluetooth.assumeIsolatedIfAvailableOrTask { if let cbServices { device.discovered(services: cbServices.cbObject) } @@ -934,7 +970,7 @@ extension BluetoothPeripheral { } let service = CBInstance(instantiatedOnDispatchQueue: service) - Task { @SpeziBluetooth in + SpeziBluetooth.assumeIsolatedIfAvailableOrTask { // update our model with latest characteristics! device.synchronizeModel(for: service.cbObject) @@ -962,7 +998,7 @@ extension BluetoothPeripheral { let capture = GATTCharacteristicCapture(from: characteristic) let characteristic = CBInstance(instantiatedOnDispatchQueue: characteristic) - Task { @SpeziBluetooth in + SpeziBluetooth.assumeIsolatedIfAvailableOrTask { device.synchronizeModel(for: characteristic.cbObject, capture: capture) } } @@ -975,7 +1011,7 @@ extension BluetoothPeripheral { let capture = GATTCharacteristicCapture(from: characteristic) let characteristic = CBInstance(instantiatedOnDispatchQueue: characteristic) - Task { @SpeziBluetooth [logger] in + SpeziBluetooth.assumeIsolatedIfAvailableOrTask { [logger] in // make sure value is propagated beforehand device.synchronizeModel(for: characteristic.cbObject, capture: capture) @@ -996,7 +1032,7 @@ extension BluetoothPeripheral { let capture = GATTCharacteristicCapture(from: characteristic) let characteristic = CBInstance(instantiatedOnDispatchQueue: characteristic) - Task { @SpeziBluetooth [logger] in + SpeziBluetooth.assumeIsolatedIfAvailableOrTask { [logger] in device.synchronizeModel(for: characteristic.cbObject, capture: capture) let result: Result @@ -1020,7 +1056,7 @@ extension BluetoothPeripheral { return } - Task { @SpeziBluetooth in + SpeziBluetooth.assumeIsolatedIfAvailableOrTask { device.writeWithoutResponseAccess.resume() } } @@ -1042,7 +1078,7 @@ extension BluetoothPeripheral { let capture = GATTCharacteristicCapture(from: characteristic) let characteristic = CBInstance(instantiatedOnDispatchQueue: characteristic) - Task { @SpeziBluetooth [logger] in + SpeziBluetooth.assumeIsolatedIfAvailableOrTask { [logger] in device.synchronizeModel(for: characteristic.cbObject, capture: capture) if error == nil { diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DescriptorAspect.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DescriptorAspect.swift new file mode 100644 index 00000000..af970541 --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DescriptorAspect.swift @@ -0,0 +1,135 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(AccessorySetupKit) && !os(macOS) +import AccessorySetupKit +#endif +import SpeziFoundation + + +enum DescriptorAspect { + /// Matches the ``AdvertisementData/localName``if it is present. If not (and only then) it matches against the GAP name. + case nameSubstring(String) + case service(uuid: BTUUID, serviceData: DataDescriptor? = nil) + case manufacturer(id: ManufacturerIdentifier, manufacturerData: DataDescriptor? = nil) + case bluetoothRange(Int) // need to store the rawValue to support previous versions + case supportOptions(UInt) // need to store the rawValue to support previous versions + + var isServiceId: Bool { + if case .service = self { + true + } else { + false + } + } +} + + +#if canImport(AccessorySetupKit) && !os(macOS) +@available(iOS 18.0, *) +@available(macCatalyst, unavailable) +extension DescriptorAspect { + static func bluetoothRange(_ range: ASDiscoveryDescriptor.Range) -> DescriptorAspect { + .bluetoothRange(range.rawValue) + } + + static func supportOptions(_ options: ASAccessory.SupportOptions) -> DescriptorAspect { + .supportOptions(options.rawValue) + } +} +#endif + + +extension DescriptorAspect: Sendable, Hashable {} + + +extension DescriptorAspect { + func matches(name: String?, advertisementData: AdvertisementData) -> Bool { // swiftlint:disable:this cyclomatic_complexity + switch self { + case let .nameSubstring(substring): + // This is (sadly) the behavior of the accessory setup kit. + // If there is a local name in the advertisement it matches (and only matches!) against the local name. + // Otherwise, it uses the accessory name. + + return if let localName = advertisementData.localName { + localName.contains(substring) + } else if let name { + name.contains(substring) + } else { + false + } + case let .service(uuid, serviceData): + guard advertisementData.serviceUUIDs?.contains(uuid) ?? advertisementData.overflowServiceUUIDs?.contains(uuid) ?? false else { + return false + } + + if let serviceData { + guard let advertisedServiceData = advertisementData.serviceData?[uuid] else { + return false + } + return serviceData.matches(advertisedServiceData) + } + + return true + case let .manufacturer(id, manufacturerData): + guard let advertisedManufacturerData = advertisementData.manufacturerData, + let identifier = ManufacturerIdentifier(data: advertisedManufacturerData) else { + return false + } + + guard identifier == id else { + return false + } + + if let manufacturerData { + let suffix = advertisedManufacturerData[2...] // cut await the first two bytes for the manufacturer identifier + return manufacturerData.matches(suffix) + } + + return true + case .bluetoothRange: + return true // range doesn't match against advertisement data + case .supportOptions: + return true // options doesn't match against advertisement data + } + } +} + + +extension DescriptorAspect: CustomStringConvertible { + var description: String { + switch self { + case let .nameSubstring(substring): + ".name(\(substring))" + case let .service(uuid, serviceData): + ".service(\(uuid)\(serviceData.map { ", serviceData: \($0.description)" } ?? ""))" + case let .manufacturer(id, manufacturerData): + ".manufacturer(\(id)\(manufacturerData.map { ", manufacturerData: \($0.description)" } ?? ""))" + case let .bluetoothRange(value): +#if os(iOS) && !targetEnvironment(macCatalyst) + if #available(iOS 18, *), let range = ASDiscoveryDescriptor.Range(rawValue: value) { + ".bluetoothRange(\(range))" + } else { + ".bluetoothRange(rawValue: \(value))" + } +#else + ".bluetoothRange(rawValue: \(value))" +#endif + case let .supportOptions(value): +#if os(iOS) && !targetEnvironment(macCatalyst) + if #available(iOS 18, *) { + ".supportOptions(\(ASAccessory.SupportOptions(rawValue: value)))" + } else { + ".supportOptions(rawValue: \(value))" + } +#else + ".supportOptions(rawValue: \(value))" +#endif + } + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift index ce921299..712b61f5 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift @@ -51,16 +51,18 @@ extension DeviceDescription: Hashable {} extension Collection where Element: Identifiable, Element.ID == DiscoveryCriteria { - func find(for advertisementData: AdvertisementData, logger: Logger) -> Element? { + func find(name: String?, advertisementData: AdvertisementData, logger: Logger) -> Element? { let configurations = filter { configuration in - configuration.id.matches(advertisementData) + configuration.id.matches(name: name, advertisementData: advertisementData) } if configurations.count > 1 { - let criteria = configurations - .map { $0.id.description } - .joined(separator: ", ") - logger.warning("Found ambiguous discovery configuration for peripheral. Peripheral matched all these criteria: \(criteria)") + logger.warning( + """ + Found ambiguous discovery configuration for peripheral. Using for of all matched criteria: \ + \(configurations.map { $0.id.description }.joined(separator: ", ")) + """ + ) } return configurations.first diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift index b502ceff..d7865648 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift @@ -6,61 +6,65 @@ // SPDX-License-Identifier: MIT // +#if canImport(AccessorySetupKit) && !os(macOS) +import AccessorySetupKit +#endif +import SpeziFoundation + /// The criteria by which we identify a discovered device. /// /// ## Topics /// -/// ### Criteria -/// - ``advertisedService(_:)-79pid`` -/// - ``advertisedService(_:)-5o92s`` -/// - ``advertisedServices(_:)-swift.type.method`` -/// - ``advertisedServices(_:)-swift.enum.case`` +/// ### Discovery by Service Type +/// - ``advertisedService(_:serviceData:)-446yf`` /// - ``advertisedServices(_:_:)`` -/// - ``accessory(manufacturer:advertising:)-swift.type.method`` -/// - ``accessory(manufacturer:advertising:)-swift.enum.case`` -/// - ``accessory(manufacturer:advertising:_:)`` -public enum DiscoveryCriteria { - /// Identify a device by their advertised services. - /// - /// All supplied services need to be present in the advertisement. - case advertisedServices(_ uuids: [BTUUID]) - /// Identify a device by its manufacturer and advertised services. - /// - /// All supplied services need to be present in the advertisement. - case accessory(manufacturer: ManufacturerIdentifier, advertising: [BTUUID]) - +/// +/// ### Discovery by Service UUID +/// +/// - ``advertisedService(_:serviceData:)-7ye2y`` +/// - ``advertisedServices(_:)-2ymt0`` +/// - ``advertisedServices(_:)-1s760`` +/// +/// ### Discovery an Accessory by Service Type +/// - ``accessory(advertising:serviceData:nameSubstring:)-2uola`` +/// - ``accessory(advertising:serviceData:manufacturer:manufacturerData:nameSubstring:)-4xehl`` +/// - ``accessory(advertising:serviceData:nameSubstring:range:supportOptions:)-z6kr`` +/// - ``accessory(advertising:serviceData:manufacturer:manufacturerData:nameSubstring:range:supportOptions:)-5yvyv`` +/// +/// ### Discovery an Accessory by Service UUID +/// +/// - ``accessory(advertising:serviceData:nameSubstring:)-5rzd3`` +/// - ``accessory(advertising:serviceData:manufacturer:manufacturerData:nameSubstring:)-7zwso`` +/// - ``accessory(advertising:serviceData:nameSubstring:range:supportOptions:)-61h91`` +/// - ``accessory(advertising:serviceData:manufacturer:manufacturerData:nameSubstring:range:supportOptions:)-5gotr`` +/// +/// ### Discovery an Accessory that advertise multiple Services +/// - ``accessory(manufacturer:manufacturerData:nameSubstring:advertising:)-5xdh2`` +/// - ``accessory(manufacturer:manufacturerData:nameSubstring:advertising:)-1j9zn`` +/// - ``accessory(manufacturer:manufacturerData:nameSubstring:advertising:_:)`` +public struct DiscoveryCriteria { + let aspects: [DescriptorAspect] var discoveryIds: [BTUUID] { - switch self { - case let .advertisedServices(uuids): - uuids - case let .accessory(_, serviceIds): - serviceIds + aspects.reduce(into: []) { partialResult, aspect in + if case let .service(uuid, _) = aspect { + partialResult.append(uuid) + } } } + init(_ aspects: [DescriptorAspect]) { + self.aspects = aspects + } - func matches(_ advertisementData: AdvertisementData) -> Bool { - switch self { - case let .advertisedServices(uuids): - return uuids.allSatisfy { uuid in - advertisementData.serviceUUIDs?.contains(uuid) ?? advertisementData.overflowServiceUUIDs?.contains(uuid) ?? false - } - case let .accessory(manufacturer, serviceIds): - guard let manufacturerData = advertisementData.manufacturerData, - let identifier = ManufacturerIdentifier(data: manufacturerData) else { - return false - } - - guard identifier == manufacturer else { - return false - } - + init(_ aspect: DescriptorAspect) { + self.aspects = [aspect] + } - return serviceIds.allSatisfy { uuid in - advertisementData.serviceUUIDs?.contains(uuid) ?? advertisementData.overflowServiceUUIDs?.contains(uuid) ?? false - } + func matches(name: String?, advertisementData: AdvertisementData) -> Bool { + aspects.allSatisfy { aspect in + aspect.matches(name: name, advertisementData: advertisementData) } } } @@ -71,28 +75,43 @@ extension DiscoveryCriteria: Sendable {} extension DiscoveryCriteria { /// Identity a device by their advertised service. - /// - Parameter uuid: The service uuid the service advertises. - /// - Returns: A ``DiscoveryCriteria/advertisedServices(_:)-swift.enum.case`` criteria. - public static func advertisedService(_ uuid: BTUUID) -> DiscoveryCriteria { - .advertisedServices([uuid]) + /// - Parameters: + /// - uuid: The service uuid the service advertises. + /// - serviceData: The optional data descriptor for the service data that the device has to advertise. + /// - Returns: The `DiscoveryCriteria` identifying an accessory with the specified criteria. + public static func advertisedService(_ uuid: BTUUID, serviceData: DataDescriptor? = nil) -> DiscoveryCriteria { + DiscoveryCriteria(.service(uuid: uuid, serviceData: serviceData)) + } + + /// Identify a device by their advertised services. + /// + /// All supplied services need to be present in the advertisement. + /// - Parameter uuids: The service uuids the service advertises. + /// - Returns: The `DiscoveryCriteria` identifying an accessory with the specified criteria. + public static func advertisedServices(_ uuids: [BTUUID]) -> DiscoveryCriteria { + // we reverse the internal representation to make sure that the first uuid is used with the ASDiscoveryDescriptor + DiscoveryCriteria(uuids.map { .service(uuid: $0) }.reversed()) } /// Identity a device by their advertised service. /// /// All supplied services need to be present in the advertisement. /// - Parameter uuid: The service uuids the service advertises. - /// - Returns: A ``DiscoveryCriteria/advertisedServices(_:)-swift.enum.case`` criteria. + /// - Returns: The `DiscoveryCriteria` identifying an accessory with the specified criteria. public static func advertisedServices(_ uuid: BTUUID...) -> DiscoveryCriteria { .advertisedServices(uuid) } /// Identify a device by their advertised service. - /// - Parameter service: The service type. - /// - Returns: A ``DiscoveryCriteria/advertisedServices(_:)-swift.enum.case`` criteria. + /// - Parameters: + /// - service: The service type. + /// - serviceData: The optional data descriptor for the service data that the device has to advertise. + /// - Returns: The `DiscoveryCriteria` identifying an accessory with the specified criteria. public static func advertisedService( - _ service: Service.Type + _ service: Service.Type, + serviceData: DataDescriptor? = nil ) -> DiscoveryCriteria { - .advertisedServices(service.id) + .advertisedService(service.id, serviceData: serviceData) } /// Identify a device by their advertised services. @@ -101,7 +120,7 @@ extension DiscoveryCriteria { /// - Parameters: /// - service: The service type. /// - additionalService: An optional parameter pack argument to supply additional service types the accessory is expected to advertise. - /// - Returns: A ``DiscoveryCriteria/advertisedServices(_:)-swift.enum.case`` criteria. + /// - Returns: The `DiscoveryCriteria` identifying an accessory with the specified criteria. public static func advertisedServices( _ service: Service.Type, _ additionalService: repeat (each S).Type @@ -115,15 +134,302 @@ extension DiscoveryCriteria { extension DiscoveryCriteria { + private static func accessory( + uuid: BTUUID, + serviceData: DataDescriptor? = nil, + manufacturer: ManufacturerIdentifier? = nil, + manufacturerData: DataDescriptor? = nil, + nameSubstring: String? = nil, + range: Int? = nil, + supportOptions: UInt? = nil + ) -> DiscoveryCriteria { + var aspects: [DescriptorAspect] = [ + .service(uuid: uuid, serviceData: serviceData) + ] + + if let manufacturer { + aspects.append(.manufacturer(id: manufacturer, manufacturerData: manufacturerData)) + } + + if let nameSubstring { + aspects.append(.nameSubstring(nameSubstring)) + } + + if let range { + aspects.append(.bluetoothRange(range)) + } + + if let supportOptions { + aspects.append(.supportOptions(supportOptions)) + } + + return DiscoveryCriteria(aspects) + } + + /// Identify an accessory by its service, manufacturer and name. + /// - Parameters: + /// - uuid: The service uuid that the accessory advertises. + /// - serviceData: An optional data descriptor that matches against the service data advertised for the given service uuid. + /// - manufacturer: The manufacturer identifier the accessory has to advertise. + /// - manufacturerData: An optional data descriptor that matches against the rest of the manufacturer data. + /// - nameSubstring: Require a given string to be present in the accessory name. + /// The substring is matched against the ``AdvertisementData/localName`` if it is present. + /// If it is not present, the substring will be matched against the GAP device name. + /// - Returns: The `DiscoveryCriteria` identifying an accessory with the specified criteria. + public static func accessory( // swiftlint:disable:this function_default_parameter_at_end + advertising uuid: BTUUID, + serviceData: DataDescriptor? = nil, + manufacturer: ManufacturerIdentifier, + manufacturerData: DataDescriptor? = nil, + nameSubstring: String? = nil + ) -> DiscoveryCriteria { + .accessory( + uuid: uuid, + serviceData: serviceData, + manufacturer: manufacturer, + manufacturerData: manufacturerData, + nameSubstring: nameSubstring + ) + } + + /// Identify an accessory by its service and name. + /// - Parameters: + /// - uuid: The service uuid that the accessory advertises. + /// - serviceData: An optional data descriptor that matches against the service data advertised for the given service uuid. + /// - nameSubstring: Require a given string to be present in the accessory name. + /// The substring is matched against the ``AdvertisementData/localName`` if it is present. + /// If it is not present, the substring will be matched against the GAP device name. + /// - Returns: The `DiscoveryCriteria` identifying an accessory with the specified criteria. + public static func accessory( + advertising uuid: BTUUID, + serviceData: DataDescriptor? = nil, + nameSubstring: String? = nil + ) -> DiscoveryCriteria { + .accessory( + uuid: uuid, + serviceData: serviceData, + nameSubstring: nameSubstring + ) + } + + /// Identify an accessory by its service, manufacturer and name. + /// - Parameters: + /// - service: The service type that the accessory advertises. + /// - serviceData: An optional data descriptor that matches against the service data advertised for the given service uuid. + /// - manufacturer: The manufacturer identifier the accessory has to advertise. + /// - manufacturerData: An optional data descriptor that matches against the rest of the manufacturer data. + /// - nameSubstring: Require a given string to be present in the accessory name. + /// The substring is matched against the ``AdvertisementData/localName`` if it is present. + /// If it is not present, the substring will be matched against the GAP device name. + /// - Returns: The `DiscoveryCriteria` identifying an accessory with the specified criteria. + public static func accessory( // swiftlint:disable:this function_default_parameter_at_end + advertising service: Service.Type, + serviceData: DataDescriptor? = nil, + manufacturer: ManufacturerIdentifier, + manufacturerData: DataDescriptor? = nil, + nameSubstring: String? = nil + ) -> DiscoveryCriteria { + .accessory( + uuid: service.id, + serviceData: serviceData, + manufacturer: manufacturer, + manufacturerData: manufacturerData, + nameSubstring: nameSubstring + ) + } + + /// Identify an accessory by its service and name. + /// - Parameters: + /// - service: The service type that the accessory advertises. + /// - serviceData: An optional data descriptor that matches against the service data advertised for the given service uuid. + /// - nameSubstring: Require a given string to be present in the accessory name. + /// The substring is matched against the ``AdvertisementData/localName`` if it is present. + /// If it is not present, the substring will be matched against the GAP device name. + /// - Returns: The `DiscoveryCriteria` identifying an accessory with the specified criteria. + public static func accessory( + advertising service: Service.Type, + serviceData: DataDescriptor? = nil, + nameSubstring: String? = nil + ) -> DiscoveryCriteria { + .accessory( + uuid: service.id, + serviceData: serviceData, + nameSubstring: nameSubstring + ) + } + +#if canImport(AccessorySetupKit) && !os(macOS) + /// Identify an accessory by its service, manufacturer and name with additional options for the AccessorySetupKit. + /// - Parameters: + /// - uuid: The service uuid that the accessory advertises. + /// - serviceData: An optional data descriptor that matches against the service data advertised for the given service uuid. + /// - manufacturer: The manufacturer identifier the accessory has to advertise. + /// - manufacturerData: An optional data descriptor that matches against the rest of the manufacturer data. + /// - nameSubstring: Require a given string to be present in the accessory name. + /// The substring is matched against the ``AdvertisementData/localName`` if it is present. + /// If it is not present, the substring will be matched against the GAP device name. + /// - range: A discovery range that is used with the AccessorySetupKit. + /// - supportOptions: Additional accessory support options which are used with the AccessorySetupKit. + /// - Returns: The `DiscoveryCriteria` identifying an accessory with the specified criteria. + @available(iOS 18, *) + @available(macCatalyst, unavailable) + public static func accessory( // swiftlint:disable:this function_default_parameter_at_end + advertising uuid: BTUUID, + serviceData: DataDescriptor? = nil, + manufacturer: ManufacturerIdentifier, + manufacturerData: DataDescriptor? = nil, + nameSubstring: String? = nil, + range: ASDiscoveryDescriptor.Range = .default, + supportOptions: ASAccessory.SupportOptions = [] + ) -> DiscoveryCriteria { + .accessory( + uuid: uuid, + serviceData: serviceData, + manufacturer: manufacturer, + manufacturerData: manufacturerData, + nameSubstring: nameSubstring, + range: range.rawValue, + supportOptions: supportOptions.rawValue + ) + } + + /// Identify an accessory by its service and name with additional options for the AccessorySetupKit. + /// - Parameters: + /// - uuid: The service uuid that the accessory advertises. + /// - serviceData: An optional data descriptor that matches against the service data advertised for the given service uuid. + /// - nameSubstring: Require a given string to be present in the accessory name. + /// The substring is matched against the ``AdvertisementData/localName`` if it is present. + /// If it is not present, the substring will be matched against the GAP device name. + /// - range: A discovery range that is used with the AccessorySetupKit. + /// - supportOptions: Additional accessory support options which are used with the AccessorySetupKit. + /// - Returns: The `DiscoveryCriteria` identifying an accessory with the specified criteria. + @available(iOS 18, *) + @available(macCatalyst, unavailable) + public static func accessory( + advertising uuid: BTUUID, + serviceData: DataDescriptor? = nil, + nameSubstring: String? = nil, + range: ASDiscoveryDescriptor.Range = .default, + supportOptions: ASAccessory.SupportOptions = [] + ) -> DiscoveryCriteria { + .accessory( + uuid: uuid, + serviceData: serviceData, + nameSubstring: nameSubstring, + range: range.rawValue, + supportOptions: supportOptions.rawValue + ) + } + + /// Identify an accessory by its service, manufacturer and name with additional options for the AccessorySetupKit. + /// - Parameters: + /// - service: The service type that the accessory advertises. + /// - serviceData: An optional data descriptor that matches against the service data advertised for the given service uuid. + /// - manufacturer: The manufacturer identifier the accessory has to advertise. + /// - manufacturerData: An optional data descriptor that matches against the rest of the manufacturer data. + /// - nameSubstring: Require a given string to be present in the accessory name. + /// The substring is matched against the ``AdvertisementData/localName`` if it is present. + /// If it is not present, the substring will be matched against the GAP device name. + /// - range: A discovery range that is used with the AccessorySetupKit. + /// - supportOptions: Additional accessory support options which are used with the AccessorySetupKit. + /// - Returns: The `DiscoveryCriteria` identifying an accessory with the specified criteria. + @available(iOS 18, *) + @available(macCatalyst, unavailable) + public static func accessory( // swiftlint:disable:this function_default_parameter_at_end + advertising service: Service.Type, + serviceData: DataDescriptor? = nil, + manufacturer: ManufacturerIdentifier, + manufacturerData: DataDescriptor? = nil, + nameSubstring: String? = nil, + range: ASDiscoveryDescriptor.Range = .default, + supportOptions: ASAccessory.SupportOptions = [] + ) -> DiscoveryCriteria { + .accessory( + uuid: service.id, + serviceData: serviceData, + manufacturer: manufacturer, + manufacturerData: manufacturerData, + nameSubstring: nameSubstring, + range: range.rawValue, + supportOptions: supportOptions.rawValue + ) + } + + /// Identify an accessory by its service and name with additional options for the AccessorySetupKit. + /// - Parameters: + /// - service: The service type that the accessory advertises. + /// - serviceData: An optional data descriptor that matches against the service data advertised for the given service uuid. + /// - nameSubstring: Require a given string to be present in the accessory name. + /// The substring is matched against the ``AdvertisementData/localName`` if it is present. + /// If it is not present, the substring will be matched against the GAP device name. + /// - range: A discovery range that is used with the AccessorySetupKit. + /// - supportOptions: Additional accessory support options which are used with the AccessorySetupKit. + /// - Returns: The `DiscoveryCriteria` identifying an accessory with the specified criteria. + @available(iOS 18, *) + @available(macCatalyst, unavailable) + public static func accessory( + advertising service: Service.Type, + serviceData: DataDescriptor? = nil, + nameSubstring: String? = nil, + range: ASDiscoveryDescriptor.Range = .default, + supportOptions: ASAccessory.SupportOptions = [] + ) -> DiscoveryCriteria { + .accessory( + uuid: service.id, + serviceData: serviceData, + nameSubstring: nameSubstring, + range: range.rawValue, + supportOptions: supportOptions.rawValue + ) + } +#endif + /// Identify a device by its manufacturer and advertised services. /// /// All supplied services need to be present in the advertisement. /// - Parameters: /// - manufacturer: The Bluetooth SIG-assigned manufacturer identifier. + /// - manufacturerData: An optional data descriptor that matches against the rest of the manufacturer data. + /// - nameSubstring: Require a given string to be present in the accessory name. + /// The substring is matched against the ``AdvertisementData/localName`` if it is present. + /// If it is not present, the substring will be matched against the GAP device name. + /// - uuids: The service uuids the service advertises. + /// - Returns: The `DiscoveryCriteria` identifying an accessory with the specified criteria. + public static func accessory( // swiftlint:disable:this function_default_parameter_at_end + manufacturer: ManufacturerIdentifier, + manufacturerData: DataDescriptor? = nil, + nameSubstring: String? = nil, + advertising uuids: BTUUID... + ) -> DiscoveryCriteria { + .accessory(manufacturer: manufacturer, manufacturerData: manufacturerData, nameSubstring: nameSubstring, advertising: uuids) + } + + /// Identify a device by its manufacturer and advertised services. + /// + /// All supplied services need to be present in the advertisement. + /// - Parameters: + /// - manufacturer: The Bluetooth SIG-assigned manufacturer identifier. + /// - manufacturerData: An optional data descriptor that matches against the rest of the manufacturer data. + /// - nameSubstring: Require a given string to be present in the accessory name. + /// The substring is matched against the ``AdvertisementData/localName`` if it is present. + /// If it is not present, the substring will be matched against the GAP device name. /// - uuids: The service uuids the service advertises. - /// - Returns: A ``DiscoveryCriteria/accessory(manufacturer:advertising:)-swift.enum.case`` criteria. - public static func accessory(manufacturer: ManufacturerIdentifier, advertising uuids: BTUUID...) -> DiscoveryCriteria { - .accessory(manufacturer: manufacturer, advertising: uuids) + /// - Returns: The `DiscoveryCriteria` identifying an accessory with the specified criteria. + public static func accessory( // swiftlint:disable:this function_default_parameter_at_end + manufacturer: ManufacturerIdentifier, + manufacturerData: DataDescriptor? = nil, + nameSubstring: String? = nil, + advertising uuids: [BTUUID] + ) -> DiscoveryCriteria { + var aspects: [DescriptorAspect] = uuids.map { .service(uuid: $0) }.reversed() + + aspects.append(.manufacturer(id: manufacturer, manufacturerData: manufacturerData)) + + if let nameSubstring { + aspects.append(.nameSubstring(nameSubstring)) + } + + return DiscoveryCriteria(aspects) } /// Identify a device by its manufacturer and advertised service. @@ -131,29 +437,34 @@ extension DiscoveryCriteria { /// All supplied services need to be present in the advertisement. /// - Parameters: /// - manufacturer: The Bluetooth SIG-assigned manufacturer identifier. + /// - manufacturerData: An optional data descriptor that matches against the rest of the manufacturer data. + /// - nameSubstring: Require a given string to be present in the accessory name. + /// The substring is matched against the ``AdvertisementData/localName`` if it is present. + /// If it is not present, the substring will be matched against the GAP device name. /// - service: The service type. /// - additionalService: An optional parameter pack argument to supply additional service types the accessory is expected to advertise. - /// - Returns: A ``DiscoveryCriteria/accessory(manufacturer:advertising:)-swift.enum.case`` criteria. - public static func accessory( + /// - Returns: The `DiscoveryCriteria` identifying an accessory with the specified criteria. + public static func accessory( // swiftlint:disable:this function_default_parameter_at_end manufacturer: ManufacturerIdentifier, + manufacturerData: DataDescriptor? = nil, + nameSubstring: String? = nil, advertising service: Service.Type, _ additionalService: repeat (each S).Type ) -> DiscoveryCriteria { var serviceIds: [BTUUID] = [service.id] repeat serviceIds.append((each additionalService).id) - return .accessory(manufacturer: manufacturer, advertising: serviceIds) + return .accessory(manufacturer: manufacturer, manufacturerData: manufacturerData, nameSubstring: nameSubstring, advertising: serviceIds) } } -extension DiscoveryCriteria: Hashable, CustomStringConvertible { +extension DiscoveryCriteria: Hashable, CustomStringConvertible, CustomDebugStringConvertible { public var description: String { - switch self { - case let .advertisedServices(uuids): - ".advertisedServices(\(uuids))" - case let .accessory(manufacturer, service): - "accessory(company: \(manufacturer), advertised: \(service))" - } + "DiscoveryCriteria(\(aspects.map { $0.description }.joined(separator: ", ")))" + } + + public var debugDescription: String { + description } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothError.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothError.swift index bd92d964..3dc2dcf1 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothError.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothError.swift @@ -22,6 +22,10 @@ public enum BluetoothError: Error, CustomStringConvertible, LocalizedError { /// Request is in progress. /// Request was sent to a control point characteristic while a different request is waiting for a response. case controlPointInProgress(service: BTUUID, characteristic: BTUUID) + /// Trying to interact with BluetoothManager while not being in the ``BluetoothState/poweredOn`` state. + /// + /// You cannot connect or disconnect peripherals if the underlying central manager is not powered on. + case invalidState(BluetoothState) /// Provides a human-readable description of the error. @@ -39,6 +43,8 @@ public enum BluetoothError: Error, CustomStringConvertible, LocalizedError { String(localized: "Not Present", bundle: .module) case .controlPointRequiresNotifying, .controlPointInProgress: String(localized: "Control Point Error", bundle: .module) + case .invalidState: + String(localized: "Invalid State", bundle: .module) } } @@ -53,6 +59,8 @@ public enum BluetoothError: Error, CustomStringConvertible, LocalizedError { String(localized: "Control point request was sent to \(characteristic.description) on \(service.description) but notifications weren't enabled for that characteristic.", bundle: .module) case let .controlPointInProgress(service, characteristic): String(localized: "Control point request was sent to \(characteristic.description) on \(service.description) while waiting for a response to a previous request.", bundle: .module) + case .invalidState: + String(localized: "Bluetooth must be powered on for this operation.", bundle: .module) } } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothManagerStorage.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothManagerStorage.swift index e44fc7b1..4f8ef7ba 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothManagerStorage.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothManagerStorage.swift @@ -9,6 +9,7 @@ import Atomics import Foundation import OrderedCollections +import SpeziFoundation @Observable @@ -25,6 +26,7 @@ final class BluetoothManagerStorage: ValueObservable, Sendable { } } @SpeziBluetooth @ObservationIgnored private var subscribedContinuations: [UUID: AsyncStream.Continuation] = [:] + @SpeziBluetooth @ObservationIgnored private var subscribedEventHandlers: [UUID: (BluetoothState) -> Void] = [:] /// Note: we track, based on the CoreBluetooth reported connected state. @SpeziBluetooth var connectedDevices: Set = [] @@ -65,6 +67,9 @@ final class BluetoothManagerStorage: ValueObservable, Sendable { for continuation in subscribedContinuations.values { continuation.yield(state) } + for handler in subscribedEventHandlers.values { + handler(state) + } } } @@ -114,24 +119,33 @@ final class BluetoothManagerStorage: ValueObservable, Sendable { return id } + @SpeziBluetooth + func subscribe(_ handler: @escaping (BluetoothState) -> Void) -> StateRegistration { + let id = UUID() + subscribedEventHandlers[id] = handler + return StateRegistration(id: id, storage: self) + } + @SpeziBluetooth func unsubscribe(for id: UUID) { subscribedContinuations[id] = nil + subscribedEventHandlers[id] = nil } @SpeziBluetooth - func cbDelegateSignal(connected: Bool, for id: UUID) async { + func cbDelegateSignal(connected: Bool, for id: UUID) { if connected { connectedDevices.insert(id) } else { connectedDevices.remove(id) } - await updateMainActorConnectedDevices(hasConnectedDevices: !connectedDevices.isEmpty) + updateMainActorConnectedDevices(hasConnectedDevices: !connectedDevices.isEmpty) } - @MainActor private func updateMainActorConnectedDevices(hasConnectedDevices: Bool) { - maHasConnectedDevices = hasConnectedDevices + Task { @MainActor in + maHasConnectedDevices = hasConnectedDevices + } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTCharacteristic.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTCharacteristic.swift index c4391b41..9ad124ec 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTCharacteristic.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTCharacteristic.swift @@ -8,6 +8,7 @@ import CoreBluetooth import Foundation +import SpeziFoundation struct CharacteristicAccessorCapture: Sendable { diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/ManagedAsynchronousAccess.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/ManagedAsynchronousAccess.swift deleted file mode 100644 index 89719975..00000000 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/ManagedAsynchronousAccess.swift +++ /dev/null @@ -1,114 +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 SpeziFoundation - - -@SpeziBluetooth -final class ManagedAsynchronousAccess { - private let access: AsyncSemaphore - private var continuation: CheckedContinuation? - - var isRunning: Bool { - continuation != nil - } - - init(_ value: Int = 1) { - self.access = AsyncSemaphore(value: value) - } - -#if compiler(>=6) - @discardableResult - func resume(with result: sending Result) -> Bool { - if let continuation { - self.continuation = nil - let didSignalAnyone = access.signal() - continuation.resume(with: result) - return didSignalAnyone - } - - return false - } - - @discardableResult - func resume(returning value: sending Value) -> Bool { - resume(with: .success(value)) - } -#else - // sending keyword is new with Swift 6 - @discardableResult - func resume(with result: Result) -> Bool { - if let continuation { - self.continuation = nil - let didSignalAnyone = access.signal() - continuation.resume(with: result) - return didSignalAnyone - } - - return false - } - - @discardableResult - func resume(returning value: Value) -> Bool { - resume(with: .success(value)) - } -#endif - - func resume(throwing error: E) { - resume(with: .failure(error)) - } -} - - -extension ManagedAsynchronousAccess where Value == Void { - func resume() { - self.resume(returning: ()) - } -} - - -extension ManagedAsynchronousAccess where E == Error { - func perform(action: () -> Void) async throws -> Value { - try await access.waitCheckingCancellation() - - return try await withCheckedThrowingContinuation { continuation in - assert(self.continuation == nil, "continuation was unexpectedly not nil") - self.continuation = continuation - action() - } - } - - func cancelAll(error: E? = nil) { - if let continuation { - self.continuation = nil - continuation.resume(throwing: error ?? CancellationError()) - } - access.cancelAll() - } -} - - -extension ManagedAsynchronousAccess where Value == Void, E == Never { - func perform(action: () -> Void) async throws { - try await access.waitCheckingCancellation() - - await withCheckedContinuation { continuation in - assert(self.continuation == nil, "continuation was unexpectedly not nil") - self.continuation = continuation - action() - } - } - - func cancelAll() { - if let continuation { - self.continuation = nil - continuation.resume() - } - access.cancelAll() - } -} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/OnChangeRegistration.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/OnChangeRegistration.swift index 4fa8392e..de68c3b2 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/OnChangeRegistration.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/OnChangeRegistration.swift @@ -28,24 +28,25 @@ public final class OnChangeRegistration { self.handlerId = handlerId } + private static func cancel(peripheral: BluetoothPeripheral?, locator: CharacteristicLocator, handlerId: UUID) { + guard let peripheral else { + return + } + Task.detached { @SpeziBluetooth in + peripheral.deregisterOnChange(locator: locator, handlerId: handlerId) + } + } + /// Cancel the on-change handler registration. public func cancel() { - Task { @SpeziBluetooth in - peripheral?.deregisterOnChange(self) - } + Self.cancel(peripheral: peripheral, locator: locator, handlerId: handlerId) } deinit { // make sure we don't capture self after this deinit - let peripheral = peripheral - let locator = locator - let handlerId = handlerId - - Task.detached { @Sendable @SpeziBluetooth in - peripheral?.deregisterOnChange(locator: locator, handlerId: handlerId) - } + Self.cancel(peripheral: peripheral, locator: locator, handlerId: handlerId) } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift index 95b28da4..113737dc 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift @@ -8,6 +8,7 @@ import Atomics import Foundation +import SpeziFoundation /// A dedicated, observable storage container for a ``BluetoothPeripheral``. @@ -167,11 +168,12 @@ final class PeripheralStorage: ValueObservable, Sendable { @SpeziBluetooth func update(state: PeripheralState) { let current = self.state - if current != state { - // we set connected on our own! See `signalFullyDiscovered` - if !(current == .connecting && state == .connected) { - self.state = state - } + switch (current, state) { + case (.connecting, .connected): + // we set the connected state transition on our own! See `signalFullyDiscovered` + break + default: + self.state = state } if current == .connecting || current == .connected { diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/StateRegistration.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/StateRegistration.swift new file mode 100644 index 00000000..be015782 --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/StateRegistration.swift @@ -0,0 +1,46 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +/// An state change handler registration for the Bluetooth state. +/// +/// It automatically cancels the subscription once this value is de-initialized. +public struct StateRegistration: ~Copyable { + private let id: UUID + private weak var storage: BluetoothManagerStorage? + + init(id: UUID, storage: BluetoothManagerStorage? = nil) { + self.id = id + self.storage = storage + } + + private static func cancel(id: UUID, storage: BluetoothManagerStorage?) { + guard let storage else { + return + } + + let id = id + Task.detached { @SpeziBluetooth in + storage.unsubscribe(for: id) + } + } + + /// Cancels the subscription. + public func cancel() { + Self.cancel(id: id, storage: storage) + } + + deinit { + Self.cancel(id: id, storage: storage) + } +} + + +extension StateRegistration: Sendable {} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothWorkItem.swift b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothWorkItem.swift index 21dc9827..d71596bd 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothWorkItem.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothWorkItem.swift @@ -14,7 +14,7 @@ final class BluetoothWorkItem { init(handler: @SpeziBluetooth @escaping @Sendable () -> Void) { self.workItem = DispatchWorkItem { - Task { @SpeziBluetooth in + SpeziBluetooth.assumeIsolatedIfAvailableOrTask { handler() } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/RWLock.swift b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/RWLock.swift deleted file mode 100644 index 485e3891..00000000 --- a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/RWLock.swift +++ /dev/null @@ -1,197 +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 Atomics -import Foundation - - -private protocol PThreadReadWriteLock: AnyObject { - // We need the unsafe mutable pointer, as otherwise we need to pass the property as inout parameter which isn't thread safe. - var rwLock: UnsafeMutablePointer { get } -} - - -final class RecursiveRWLock: PThreadReadWriteLock, @unchecked Sendable { - fileprivate let rwLock: UnsafeMutablePointer - - private let writerThread = ManagedAtomic(nil) - private var writerCount = 0 - private var readerCount = 0 - - init() { - rwLock = Self.pthreadInit() - } - - - private func writeLock() { - let selfThread = pthread_self() - - if let writer = writerThread.load(ordering: .relaxed), - pthread_equal(writer, selfThread) != 0 { - // we know that the writerThread is us, so access to `writerCount` is synchronized (its us that holds the rwLock). - writerCount += 1 - assert(writerCount > 1, "Synchronization issue. Writer count is unexpectedly low: \(writerCount)") - return - } - - pthreadWriteLock() - - writerThread.store(selfThread, ordering: .relaxed) - writerCount = 1 - } - - private func writeUnlock() { - // we assume this is called while holding the write lock, so access to `writerCount` is safe - if writerCount > 1 { - writerCount -= 1 - return - } - - // otherwise it is the last unlock - writerThread.store(nil, ordering: .relaxed) - writerCount = 0 - - pthreadUnlock() - } - - private func readLock() { - let selfThread = pthread_self() - - if let writer = writerThread.load(ordering: .relaxed), - pthread_equal(writer, selfThread) != 0 { - // we know that the writerThread is us, so access to `readerCount` is synchronized (its us that holds the rwLock). - readerCount += 1 - assert(readerCount > 0, "Synchronization issue. Reader count is unexpectedly low: \(readerCount)") - return - } - - pthreadReadLock() - } - - private func readUnlock() { - // we assume this is called while holding the reader lock, so access to `readerCount` is safe - if readerCount > 0 { - // fine to go down to zero (we still hold the lock in write mode) - readerCount -= 1 - return - } - - pthreadUnlock() - } - - - func withWriteLock(body: () throws -> T) rethrows -> T { - writeLock() - defer { - writeUnlock() - } - return try body() - } - - func withReadLock(body: () throws -> T) rethrows -> T { - readLock() - defer { - readUnlock() - } - return try body() - } - - deinit { - pthreadDeinit() - } -} - - -/// Read-Write Lock using `pthread_rwlock`. -/// -/// Looking at https://www.vadimbulavin.com/benchmarking-locking-apis, using `pthread_rwlock` -/// is favorable over using dispatch queues. -final class RWLock: PThreadReadWriteLock, @unchecked Sendable { - fileprivate let rwLock: UnsafeMutablePointer - - init() { - rwLock = Self.pthreadInit() - } - - /// Call `body` with a reading lock. - /// - /// - parameter body: A function that reads a value while locked. - /// - returns: The value returned from the given function. - func withReadLock(body: () throws -> T) rethrows -> T { - pthreadWriteLock() - defer { - pthreadUnlock() - } - return try body() - } - - /// Call `body` with a writing lock. - /// - /// - parameter body: A function that writes a value while locked, then returns some value. - /// - returns: The value returned from the given function. - func withWriteLock(body: () throws -> T) rethrows -> T { - pthreadWriteLock() - defer { - pthreadUnlock() - } - return try body() - } - - func isWriteLocked() -> Bool { - let status = pthread_rwlock_trywrlock(rwLock) - - // see status description https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/pthread_rwlock_trywrlock.3.html - switch status { - case 0: - pthreadUnlock() - return false - case EBUSY: // The calling thread is not able to acquire the lock without blocking. - return false // means we aren't locked - case EDEADLK: // The calling thread already owns the read/write lock (for reading or writing). - return true - default: - preconditionFailure("Unexpected status from pthread_rwlock_tryrdlock: \(status)") - } - } - - deinit { - pthreadDeinit() - } -} - - -extension PThreadReadWriteLock { - static func pthreadInit() -> UnsafeMutablePointer { - let lock: UnsafeMutablePointer = .allocate(capacity: 1) - let status = pthread_rwlock_init(lock, nil) - precondition(status == 0, "pthread_rwlock_init failed with status \(status)") - return lock - } - - func pthreadWriteLock() { - let status = pthread_rwlock_wrlock(rwLock) - assert(status == 0, "pthread_rwlock_wrlock failed with statusĀ \(status)") - } - - func pthreadReadLock() { - let status = pthread_rwlock_rdlock(rwLock) - assert(status == 0, "pthread_rwlock_rdlock failed with status \(status)") - } - - func pthreadUnlock() { - let status = pthread_rwlock_unlock(rwLock) - assert(status == 0, "pthread_rwlock_unlock failed with status \(status)") - } - - func pthreadDeinit() { - let status = pthread_rwlock_destroy(rwLock) - assert(status == 0) - rwLock.deallocate() - } -} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/SpeziBluetoothActor.swift b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/SpeziBluetoothActor.swift index 2efb035b..d5859ceb 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/SpeziBluetoothActor.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/SpeziBluetoothActor.swift @@ -9,13 +9,6 @@ import Foundation -private struct SpeziBluetoothDispatchQueueKey: Sendable, Hashable { - static let shared = SpeziBluetoothDispatchQueueKey() - static let key = DispatchSpecificKey() - private init() {} -} - - /// A lot of the CB objects are not sendable. This is fine. /// However, Swift is not smart enough to know that CB delegate methods (e.g., CBCentralManagerDelete or the CBPeripheralDelegate) are called /// on the SpeziBluetooth actor's dispatch queue and therefore are never sent over actor boundaries. @@ -28,9 +21,7 @@ struct CBInstance: Sendable { } init(instantiatedOnDispatchQueue object: Value, file: StaticString = #fileID, line: UInt = #line) { - guard SpeziBluetooth.shared.isSync else { - fatalError("Incorrect actor executor assumption; Expected same executor as \(SpeziBluetooth.shared).", file: file, line: line) - } + dispatchPrecondition(condition: .onQueue(SpeziBluetooth.shared.dispatchQueue)) self.object = object } @@ -72,25 +63,69 @@ public actor SpeziBluetooth { public static let shared = SpeziBluetooth() /// The underlying dispatch queue that runs the actor Jobs. - nonisolated let dispatchQueue: DispatchSerialQueue + @usableFromInline nonisolated let dispatchQueue: DispatchSerialQueue /// The underlying unowned serial executor. public nonisolated var unownedExecutor: UnownedSerialExecutor { dispatchQueue.asUnownedSerialExecutor() } - nonisolated var isSync: Bool { - DispatchQueue.getSpecific(key: SpeziBluetoothDispatchQueueKey.key) == SpeziBluetoothDispatchQueueKey.shared - } - private 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!") } - serialQueue.setSpecific(key: SpeziBluetoothDispatchQueueKey.key, value: SpeziBluetoothDispatchQueueKey.shared) - self.dispatchQueue = serialQueue } } + + +extension SpeziBluetooth { + /// Assume isolation to the global `SpeziBluetooth` actor. + /// - Parameters: + /// - operation: The operation that should be executed with assumed isolation. + /// - file: The file in which this method is called. + /// - line: The line in which this method is called. + /// - Returns: Returns `T` from the `operation`. + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + @_alwaysEmitIntoClient + public static func assumeIsolated( + _ operation: @SpeziBluetooth () throws -> T, + file: StaticString = #fileID, + line: UInt = #line + ) rethrows -> T { + // Starting from iOS 18 onwards, a `SerialExecutor` executor can implement the `checkIsolated()` method and GCD does that. + // Therefore, we can do the below isolation assumption for the global actor. On prior versions this will fail as the assumeIsolated check + // won't succeed. + // So, this is just available when running with Swift 6 runtime for now. On Swift 5 runtimes this won't fail as long as you do not call + // a `assumeIsolated` or similar within the `operation`. However, we want to be save for now. + + typealias YesActor = @SpeziBluetooth () throws -> T + typealias NoActor = () throws -> T + + dispatchPrecondition(condition: .onQueue(SpeziBluetooth.shared.dispatchQueue)) + + // To do the unsafe cast, we have to pretend it's @escaping. + return try withoutActuallyEscaping(operation) { (_ function: @escaping YesActor) throws -> T in + let rawFn = unsafeBitCast(function, to: NoActor.self) + return try rawFn() + } + } +} + + +extension SpeziBluetooth { + @_alwaysEmitIntoClient + static func assumeIsolatedIfAvailableOrTask( + _ operation: @SpeziBluetooth @escaping () -> Void, + file: StaticString = #fileID, + line: UInt = #line + ) { + if #available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) { + assumeIsolated(operation, file: file, line: line) + } else { + Task(operation: operation) + } + } +} diff --git a/Sources/SpeziBluetooth/Model/BluetoothDevice.swift b/Sources/SpeziBluetooth/Model/BluetoothDevice.swift index 117610d2..f4d63d64 100644 --- a/Sources/SpeziBluetooth/Model/BluetoothDevice.swift +++ b/Sources/SpeziBluetooth/Model/BluetoothDevice.swift @@ -28,7 +28,44 @@ import Spezi /// init() {} /// } /// ``` +/// +/// ### Describing Device Appearance +/// +/// You can use the ``appearance`` property to customize the ``Appearance`` of your device and how UI components might present +/// the device to the user. +/// +/// Your device might implement the logic for multiple device variants that might have a different appearance. Provide a ``DeviceAppearance`` to describe the appearance of your device +/// +/// ```swift +/// final class MyBluetoothDevice: BluetoothDevice { +/// static let appearance: DeviceAppearance = .variants(defaultAppearance: Appearance(name: "Weight Scale"), variants: [ +/// Variant(id: "model-p1", name: "Weight Scale P1", icon: .asset("Model-P1"), criteria: .nameSubstring("WS-P1")), +/// Variant(id: "model-x2", name: "Weight Scale X2", icon: .asset("Model-X2"), criteria: .nameSubstring("WS-X2")) +/// ]) +/// +/// init() {} +/// } +/// ``` +/// +/// ## Topics +/// ### Initializer +/// - ``init()`` +/// +/// ### Appearance +/// - ``appearance`` +/// - ``DeviceAppearance`` +/// - ``Appearance`` +/// - ``Variant`` +/// - ``DeviceVariantCriteria`` public protocol BluetoothDevice: AnyObject, Module, Observable, Sendable { + /// Describes the visual appearance of the device. + /// + /// The device appearance can be used to visually present the device to the user. + /// + /// A device implementation might be used with multiple variants of a given device class (e.g., multiple models of a blood pressure cuff). + /// You can provide additional variants using ``DeviceAppearance/variants(defaultAppearance:variants:)`` to describe the visual appearance of the different device variants. + static var appearance: DeviceAppearance { get } + /// Initializes the Bluetooth Device. /// /// This initializer is called automatically when a peripheral of this type connects. @@ -39,3 +76,11 @@ public protocol BluetoothDevice: AnyObject, Module, Observable, Sendable { /// You might want to make sure to not perform any heavy processing within the initializer. init() } + + +extension BluetoothDevice { + /// Default device appearance that uses the type name as the name. + public static var appearance: DeviceAppearance { + .appearance(Appearance(name: "\(Self.self)")) + } +} diff --git a/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift b/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift index a58704fd..9297e0a9 100644 --- a/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift +++ b/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift @@ -10,6 +10,7 @@ import Atomics import ByteCoding import CoreBluetooth import Foundation +import SpeziFoundation /// Declare a characteristic within a Bluetooth service. diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift index 98d61d4a..dac2694a 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift @@ -322,7 +322,7 @@ extension CharacteristicPeripheralInjection where Value: ControlPointCharacteris throw error } - + async let _ = withTimeout(of: timeout) { await transaction.signalTimeout() } diff --git a/Sources/SpeziBluetooth/Modifier/BluetoothScanningOptionsModifier.swift b/Sources/SpeziBluetooth/Modifier/BluetoothScanningOptionsModifier.swift index f959216f..6a0f56a8 100644 --- a/Sources/SpeziBluetooth/Modifier/BluetoothScanningOptionsModifier.swift +++ b/Sources/SpeziBluetooth/Modifier/BluetoothScanningOptionsModifier.swift @@ -38,12 +38,12 @@ extension View { /// /// This view modifier can be used to set scanning options for the view hierarchy. /// This will overwrite values passed to modifiers like - /// ``SwiftUI/View/scanNearbyDevices(enabled:with:discovery:minimumRSSI:advertisementStaleInterval:autoConnect:)``. + /// ``SwiftUICore/View/scanNearbyDevices(enabled:with:discovery:minimumRSSI:advertisementStaleInterval:autoConnect:)``. /// /// ## Topics /// ### Accessing Scanning Options - /// - ``SwiftUI/EnvironmentValues/minimumRSSI`` - /// - ``SwiftUI/EnvironmentValues/advertisementStaleInterval`` + /// - ``SwiftUICore/EnvironmentValues/minimumRSSI`` + /// - ``SwiftUICore/EnvironmentValues/advertisementStaleInterval`` /// /// - Parameters: /// - 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. diff --git a/Sources/SpeziBluetooth/Modifier/ScanNearbyDevicesModifier.swift b/Sources/SpeziBluetooth/Modifier/ScanNearbyDevicesModifier.swift index 629de183..81dfb369 100644 --- a/Sources/SpeziBluetooth/Modifier/ScanNearbyDevicesModifier.swift +++ b/Sources/SpeziBluetooth/Modifier/ScanNearbyDevicesModifier.swift @@ -137,7 +137,7 @@ 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:minimumRSSI:advertisementStaleInterval:)`` modifier instead. + /// you might want to use the ``SwiftUICore/View/autoConnect(enabled:with:minimumRSSI:advertisementStaleInterval:)`` modifier instead. /// /// - Parameters: /// - enabled: Flag indicating if nearby device scanning is enabled. @@ -174,7 +174,7 @@ 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:discovery:minimumRSSI:advertisementStaleInterval:)`` modifier instead. + /// you might want to use the ``SwiftUICore/View/autoConnect(enabled:with:discovery:minimumRSSI:advertisementStaleInterval:)`` modifier instead. /// /// - Parameters: /// - enabled: Flag indicating if nearby device scanning is enabled. diff --git a/Sources/SpeziBluetooth/Resources/Localizable.xcstrings b/Sources/SpeziBluetooth/Resources/Localizable.xcstrings index fed94407..0f6376a0 100644 --- a/Sources/SpeziBluetooth/Resources/Localizable.xcstrings +++ b/Sources/SpeziBluetooth/Resources/Localizable.xcstrings @@ -1,6 +1,66 @@ { "sourceLanguage" : "en", "strings" : { + "Access was restricted by the user." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Access was restricted by the user." + } + } + } + }, + "Activation Failed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activation Failed" + } + } + } + }, + "Bluetooth must be powered on for this operation." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth must be powered on for this operation." + } + } + } + }, + "Busy" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Busy" + } + } + } + }, + "Cancelled" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancelled" + } + } + } + }, + "Connection Failed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connection Failed" + } + } + } + }, "Control Point Error" : { "localizations" : { "en" : { @@ -51,6 +111,76 @@ } } }, + "Discovery session timed out." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Discovery session timed out." + } + } + } + }, + "Discovery Timeout" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Discovery Timeout" + } + } + } + }, + "Extension Not Found" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extension Not Found" + } + } + } + }, + "Invalid Request" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invalid Request" + } + } + } + }, + "Invalid State" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invalid State" + } + } + } + }, + "Invalidate was called before the operation completed normally." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invalidate was called before the operation completed normally." + } + } + } + }, + "Invalidated" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invalidated" + } + } + } + }, "Not Present" : { "localizations" : { "en" : { @@ -61,6 +191,66 @@ } } }, + "Operation completed successfully." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Operation completed successfully." + } + } + } + }, + "Received an invalid request." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Received an invalid request." + } + } + } + }, + "Restricted" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restricted" + } + } + } + }, + "Success" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Success" + } + } + } + }, + "The picker is already active." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The picker is already active." + } + } + } + }, + "The picker is restricted due to the application being in the background." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The picker is restricted due to the application being in the background." + } + } + } + }, "The requested characteristic %@ on %@ was not present on the device." : { "localizations" : { "en" : { @@ -70,6 +260,66 @@ } } } + }, + "The user cancelled the discovery." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The user cancelled the discovery." + } + } + } + }, + "Unable to activate discovery session." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to activate discovery session." + } + } + } + }, + "Unable to establish connection with the accessory." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to establish connection with the accessory." + } + } + } + }, + "Unable to locate the App Extension." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to locate the App Extension." + } + } + } + }, + "Unknown" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unknown" + } + } + } + }, + "Unknown error occurred." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unknown error occurred." + } + } + } } }, "version" : "1.0" diff --git a/Sources/SpeziBluetooth/SpeziBluetooth.docc/AccessorySetupKit-Framework.md b/Sources/SpeziBluetooth/SpeziBluetooth.docc/AccessorySetupKit-Framework.md new file mode 100644 index 00000000..b3102301 --- /dev/null +++ b/Sources/SpeziBluetooth/SpeziBluetooth.docc/AccessorySetupKit-Framework.md @@ -0,0 +1,52 @@ +# AccessorySetupKit + +Integration with Apple's AccessorySetupKit. + + + +## Overview + +Apple's [AccessorySetupKit](https://developer.apple.com/documentation/accessorysetupkit) enables +privacy-preserving discovery and configuration of accessories. +SpeziBluetooth integrates with + +## Topics + + +### Interact with AccessorySetupKit + +- ``AccessorySetupKit-swift.class`` +- ``AccessorySetupKitError`` + +### Observe Accessory Changes +- ``AccessorySetupKit-swift.class/AccessoryEvent`` +- ``AccessoryEventRegistration`` + +### Discovery Descriptor + +Convert a SpeziBluetooth ``DiscoveryCriteria`` into its AccessorySetupKit `ASDiscoveryDescriptor` representation. + +- ``DiscoveryCriteria/discoveryDescriptor`` +- ``DiscoveryCriteria/matches(descriptor:)`` + +### Company Identifier + +Convert a SpeziBluetooth ``ManufacturerIdentifier`` into its AccessorySetupKit `ASBluetoothCompanyIdentifier` representation. + +- ``ManufacturerIdentifier/bluetoothCompanyIdentifier`` + + +### Device Variant Criteria + +Apply a SpeziBluetooth ``DeviceVariantCriteria`` to a AccessorySetupKit `ASDiscoveryDescriptor`. + +- ``DeviceVariantCriteria/apply(to:)`` +- ``DeviceVariantCriteria/matches(descriptor:)`` diff --git a/Sources/SpeziBluetooth/SpeziBluetooth.docc/CoreBluetooth-Framework.md b/Sources/SpeziBluetooth/SpeziBluetooth.docc/CoreBluetooth-Framework.md new file mode 100644 index 00000000..da9d7421 --- /dev/null +++ b/Sources/SpeziBluetooth/SpeziBluetooth.docc/CoreBluetooth-Framework.md @@ -0,0 +1,45 @@ +# CoreBluetooth + + + +Interact with CoreBluetooth through modern programming language paradigms. + +## Overview + +[CoreBluetooth](https://developer.apple.com/documentation/corebluetooth) is Apple's framework to interact with Bluetooth and Bluetooth Low-Energy +devices on Apple platforms. +SpeziBluetooth provides easy-to-use mechanisms to perform operations on a Bluetooth central. + +## Topics + +### Central + +- ``BluetoothManager`` +- ``BluetoothState`` +- ``BluetoothError`` + +### Configuration + +- ``DiscoveryDescription`` +- ``DeviceDescription`` +- ``ServiceDescription`` +- ``CharacteristicDescription`` + +### Peripheral + +- ``BluetoothPeripheral`` +- ``PeripheralState`` +- ``GATTService`` +- ``GATTCharacteristic`` +- ``AdvertisementData`` +- ``ManufacturerIdentifier`` +- ``WriteType`` +- ``BTUUID`` diff --git a/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md b/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md index 1a2eccc0..de564766 100644 --- a/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md +++ b/Sources/SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md @@ -136,8 +136,8 @@ 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:minimumRSSI:advertisementStaleInterval:autoConnect:)`` -and ``SwiftUI/View/autoConnect(enabled:with:minimumRSSI:advertisementStaleInterval:)`` +You can use the ``SwiftUICore/View/scanNearbyDevices(enabled:with:minimumRSSI:advertisementStaleInterval:autoConnect:)`` +and ``SwiftUICore/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(minimumRSSI:advertisementStaleInterval:autoConnect:)`` and ``Bluetooth/stopScanning()``. @@ -275,9 +275,9 @@ due to their async nature. ### Discovering nearby devices -- ``SwiftUI/View/scanNearbyDevices(enabled:with:minimumRSSI:advertisementStaleInterval:autoConnect:)`` -- ``SwiftUI/View/autoConnect(enabled:with:minimumRSSI:advertisementStaleInterval:)`` -- ``SwiftUI/View/bluetoothScanningOptions(minimumRSSI:advertisementStaleInterval:)`` +- ``SwiftUICore/View/scanNearbyDevices(enabled:with:minimumRSSI:advertisementStaleInterval:autoConnect:)`` +- ``SwiftUICore/View/autoConnect(enabled:with:minimumRSSI:advertisementStaleInterval:)`` +- ``SwiftUICore/View/bluetoothScanningOptions(minimumRSSI:advertisementStaleInterval:)`` - ``ConnectedDevices`` ### Declaring a Bluetooth Device @@ -293,23 +293,7 @@ due to their async nature. - ``SpeziBluetooth/SpeziBluetooth`` -### Core Bluetooth - -- ``BluetoothManager`` -- ``BluetoothPeripheral`` -- ``GATTService`` -- ``GATTCharacteristic`` -- ``BluetoothState`` -- ``PeripheralState`` -- ``BluetoothError`` -- ``AdvertisementData`` -- ``ManufacturerIdentifier`` -- ``WriteType`` -- ``BTUUID`` - -### Configuring Core Bluetooth - -- ``DiscoveryDescription`` -- ``DeviceDescription`` -- ``ServiceDescription`` -- ``CharacteristicDescription`` +### Frameworks + +- +- diff --git a/Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift b/Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift index 379ae5c3..0eeb6c69 100644 --- a/Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift +++ b/Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift @@ -8,6 +8,7 @@ import Foundation import OrderedCollections +import SpeziFoundation final class ChangeSubscriptions: Sendable { @@ -47,10 +48,8 @@ final class ChangeSubscriptions: Sendable { return } - Task { @SpeziBluetooth in - self.lock.withWriteLock { - self.continuations.removeValue(forKey: id) - } + self.lock.withWriteLock { + _ = self.continuations.removeValue(forKey: id) } } } diff --git a/Sources/TestPeripheral/TestPeripheral.swift b/Sources/TestPeripheral/TestPeripheral.swift index 9d3ed965..c8b85808 100644 --- a/Sources/TestPeripheral/TestPeripheral.swift +++ b/Sources/TestPeripheral/TestPeripheral.swift @@ -16,6 +16,9 @@ import SpeziBluetoothServices @main @MainActor +@available(visionOS, unavailable) +@available(watchOS, unavailable) +@available(tvOS, unavailable) final class TestPeripheral: NSObject, CBPeripheralManagerDelegate { @MainActor class QueueUpdates: Sendable { @@ -272,3 +275,29 @@ final class TestPeripheral: NSObject, CBPeripheralManagerDelegate { peripheral.respond(to: first, withResult: .success) } } + + +extension CBManagerState: @retroactive CustomStringConvertible, @retroactive CustomDebugStringConvertible { + public var description: String { + switch self { + case .unknown: + "unknown" + case .resetting: + "resetting" + case .unsupported: + "unsupported" + case .unauthorized: + "unauthorized" + case .poweredOff: + "poweredOff" + case .poweredOn: + "poweredOn" + @unknown default: + "CBManagerState(rawValue: \(rawValue))" + } + } + + public var debugDescription: String { + description + } +} diff --git a/Sources/TestPeripheral/TestService.swift b/Sources/TestPeripheral/TestService.swift index 74e07e0c..722486de 100644 --- a/Sources/TestPeripheral/TestService.swift +++ b/Sources/TestPeripheral/TestService.swift @@ -23,6 +23,9 @@ struct ATTErrorCode: Error, Sendable { @MainActor +@available(visionOS, unavailable) +@available(watchOS, unavailable) +@available(tvOS, unavailable) final class TestService: Sendable { private var logger: Logger { Logger(subsystem: "edu.stanford.spezi.bluetooth", category: "TestService") diff --git a/Tests/SpeziBluetoothServicesTests/RWLockTests.swift b/Tests/SpeziBluetoothServicesTests/RWLockTests.swift deleted file mode 100644 index 7f874e99..00000000 --- a/Tests/SpeziBluetoothServicesTests/RWLockTests.swift +++ /dev/null @@ -1,256 +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 -// - -@testable import SpeziBluetooth -import XCTest - - -final class RWLockTests: XCTestCase { - func testConcurrentReads() { - let lock = RWLock() - let expectation1 = self.expectation(description: "First read") - let expectation2 = self.expectation(description: "Second read") - - Task.detached { - lock.withReadLock { - usleep(100_000) // Simulate read delay (200ms) - expectation1.fulfill() - } - } - - Task.detached { - lock.withReadLock { - usleep(100_000) // Simulate read delay (200ms) - expectation2.fulfill() - } - } - - wait(for: [expectation1, expectation2], timeout: 1.0) - } - - func testWriteBlocksOtherWrites() { - let lock = RWLock() - let expectation1 = self.expectation(description: "First write") - let expectation2 = self.expectation(description: "Second write") - - Task.detached { - lock.withWriteLock { - usleep(200_000) // Simulate write delay (200ms) - expectation1.fulfill() - } - } - - Task.detached { - try await Task.sleep(for: .milliseconds(100)) - lock.withWriteLock { - expectation2.fulfill() - } - } - - wait(for: [expectation1, expectation2], timeout: 1.0) - } - - func testWriteBlocksReads() { - let lock = RWLock() - let expectation1 = self.expectation(description: "Write") - let expectation2 = self.expectation(description: "Read") - - Task.detached { - lock.withWriteLock { - usleep(200_000) // Simulate write delay (200ms) - expectation1.fulfill() - } - } - - Task.detached { - try await Task.sleep(for: .milliseconds(100)) - lock.withReadLock { - expectation2.fulfill() - } - } - - wait(for: [expectation1, expectation2], timeout: 1.0) - } - - func testIsWriteLocked() { - let lock = RWLock() - - Task.detached { - lock.withWriteLock { - XCTAssertTrue(lock.isWriteLocked()) - usleep(100_000) // Simulate write delay (100ms) - } - } - - usleep(50_000) // Give the other thread time to lock (50ms) - XCTAssertFalse(lock.isWriteLocked()) - } - - func testMultipleLocksAcquired() { - let lock1 = RWLock() - let lock2 = RWLock() - let expectation1 = self.expectation(description: "Read") - - Task.detached { - lock1.withReadLock { - lock2.withReadLock { - expectation1.fulfill() - } - } - } - - wait(for: [expectation1], timeout: 1.0) - } - - - func testConcurrentReadsRecursive() { - let lock = RecursiveRWLock() - let expectation1 = self.expectation(description: "First read") - let expectation2 = self.expectation(description: "Second read") - - Task.detached { - lock.withReadLock { - usleep(100_000) // Simulate read delay 100 ms - expectation1.fulfill() - } - } - - Task.detached { - lock.withReadLock { - usleep(100_000) // Simulate read delay 100ms - expectation2.fulfill() - } - } - - wait(for: [expectation1, expectation2], timeout: 1.0) - } - - func testWriteBlocksOtherWritesRecursive() { - let lock = RecursiveRWLock() - let expectation1 = self.expectation(description: "First write") - let expectation2 = self.expectation(description: "Second write") - - Task.detached { - lock.withWriteLock { - usleep(200_000) // Simulate write delay 200ms - expectation1.fulfill() - } - } - - Task.detached { - try await Task.sleep(for: .milliseconds(100)) - lock.withWriteLock { - expectation2.fulfill() - } - } - - wait(for: [expectation1, expectation2], timeout: 1.0) - } - - func testWriteBlocksReadsRecursive() { - let lock = RecursiveRWLock() - let expectation1 = self.expectation(description: "Write") - let expectation2 = self.expectation(description: "Read") - - Task.detached { - lock.withWriteLock { - usleep(200_000) // Simulate write delay 200 ms - expectation1.fulfill() - } - } - - Task.detached { - try await Task.sleep(for: .milliseconds(100)) - lock.withReadLock { - expectation2.fulfill() - } - } - - wait(for: [expectation1, expectation2], timeout: 1.0) - } - - func testMultipleLocksAcquiredRecursive() { - let lock1 = RecursiveRWLock() - let lock2 = RecursiveRWLock() - let expectation1 = self.expectation(description: "Read") - - Task.detached { - lock1.withReadLock { - lock2.withReadLock { - expectation1.fulfill() - } - } - } - - wait(for: [expectation1], timeout: 1.0) - } - - func testRecursiveReadReadAcquisition() { - let lock = RecursiveRWLock() - let expectation1 = self.expectation(description: "Read") - - Task.detached { - lock.withReadLock { - lock.withReadLock { - expectation1.fulfill() - } - } - } - - wait(for: [expectation1], timeout: 1.0) - } - - func testRecursiveWriteRecursiveAcquisition() { - let lock = RecursiveRWLock() - let expectation1 = self.expectation(description: "Read") - let expectation2 = self.expectation(description: "ReadWrite") - let expectation3 = self.expectation(description: "WriteRead") - let expectation4 = self.expectation(description: "Write") - - let expectation5 = self.expectation(description: "Race") - - Task.detached { - lock.withWriteLock { - usleep(50_000) // Simulate write delay 50 ms - lock.withReadLock { - expectation1.fulfill() - usleep(200_000) // Simulate write delay 200 ms - lock.withWriteLock { - expectation2.fulfill() - } - } - - lock.withWriteLock { - usleep(200_000) // Simulate write delay 200 ms - lock.withReadLock { - expectation3.fulfill() - } - expectation4.fulfill() - } - } - } - - Task.detached { - await withDiscardingTaskGroup { group in - for _ in 0..<10 { - group.addTask { - // random sleep up to 50 ms - try? await Task.sleep(nanoseconds: UInt64.random(in: 0...50_000_000)) - lock.withWriteLock { - _ = usleep(100) - } - } - } - } - - expectation5.fulfill() - } - - wait(for: [expectation1, expectation2, expectation3, expectation4, expectation5], timeout: 20.0) - } -} diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 0cf5d7dc..d82f5565 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -447,6 +447,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; @@ -486,6 +487,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; @@ -638,6 +640,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "";