diff --git a/Package.swift b/Package.swift
index dcd027a3..82b79b5c 100644
--- a/Package.swift
+++ b/Package.swift
@@ -32,8 +32,8 @@ let package = Package(
         .library(name: "SpeziBluetooth", targets: ["SpeziBluetooth"])
     ],
     dependencies: [
-        .package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "1.1.0"),
-        .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.6.0"),
+        .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/apple/swift-nio.git", from: "2.59.0"),
         .package(url: "https://github.com/apple/swift-collections.git", from: "1.0.4"),
@@ -55,7 +55,8 @@ let package = Package(
                 .process("Resources")
             ],
             swiftSettings: [
-                swiftConcurrency
+                swiftConcurrency,
+                .enableUpcomingFeature("InferSendableFromCaptures")
             ],
             plugins: [] + swiftLintPlugin()
         ),
@@ -123,7 +124,7 @@ func swiftLintPlugin() -> [Target.PluginUsage] {
 
 func swiftLintPackage() -> [PackageDescription.Package.Dependency] {
     if ProcessInfo.processInfo.environment["SPEZI_DEVELOPMENT_SWIFTLINT"] != nil {
-        [.package(url: "https://github.com/realm/SwiftLint.git", .upToNextMinor(from: "0.55.1"))]
+        [.package(url: "https://github.com/realm/SwiftLint.git", from: "0.55.1")]
     } else {
         []
     }
diff --git a/Sources/SpeziBluetooth/Bluetooth.swift b/Sources/SpeziBluetooth/Bluetooth.swift
index 6e28f055..440641c6 100644
--- a/Sources/SpeziBluetooth/Bluetooth.swift
+++ b/Sources/SpeziBluetooth/Bluetooth.swift
@@ -293,10 +293,10 @@ public final class Bluetooth: Module, EnvironmentAccessible, Sendable {
     /// Devices might be part of `nearbyDevices` as well or just retrieved devices that are eventually connected.
     /// Values are stored weakly. All properties (like `@Characteristic`, `@DeviceState` or `@DeviceAction`) store a reference to `Bluetooth` and report once they are de-initialized
     /// to clear the respective initialized devices from this dictionary.
-    @SpeziBluetooth private var initializedDevices: OrderedDictionary<UUID, AnyWeakDeviceReference> = [:]
+    private var initializedDevices: OrderedDictionary<UUID, AnyWeakDeviceReference> = [:]
 
     @Application(\.spezi)
-    @MainActor private var spezi
+    private var spezi
 
     private nonisolated var logger: Logger {
         Self.logger
@@ -389,7 +389,7 @@ public final class Bluetooth: Module, EnvironmentAccessible, Sendable {
             } else {
                 let advertisementData = entry.value.advertisementData
                 guard let configuration = configuration.find(for: advertisementData, logger: logger) else {
-                    logger.warning("Ignoring peripheral \(entry.value.debugDescription) that cannot be mapped to a device class.")
+                    logger.warning("Ignoring peripheral \(entry.value) that cannot be mapped to a device class.")
                     return
                 }
 
@@ -402,6 +402,7 @@ public final class Bluetooth: Module, EnvironmentAccessible, Sendable {
         }
 
 
+        let spezi = spezi
         Task { @MainActor [newlyPreparedDevices] in
             var checkForConnected = false
 
@@ -548,6 +549,7 @@ public final class Bluetooth: Module, EnvironmentAccessible, Sendable {
         let device = prepareDevice(id: uuid, Device.self, peripheral: peripheral)
         // We load the module with external ownership. Meaning, Spezi won't keep any strong references to the Module and deallocation of
         // the module is possible, freeing all Spezi related resources.
+        let spezi = spezi
         await spezi.loadModule(device, ownership: .external)
 
         // The semantics of retrievePeripheral is as follows: it returns a BluetoothPeripheral that is weakly allocated by the BluetoothManager.ยด
diff --git a/Sources/SpeziBluetooth/Configuration/Discover.swift b/Sources/SpeziBluetooth/Configuration/Discover.swift
index ff183320..b80402e1 100644
--- a/Sources/SpeziBluetooth/Configuration/Discover.swift
+++ b/Sources/SpeziBluetooth/Configuration/Discover.swift
@@ -36,3 +36,6 @@ public struct Discover<Device: BluetoothDevice> {
         self.discoveryCriteria = discoveryCriteria
     }
 }
+
+
+extension Discover: Sendable {}
diff --git a/Sources/SpeziBluetooth/Configuration/DiscoveryDescriptorBuilder.swift b/Sources/SpeziBluetooth/Configuration/DiscoveryDescriptorBuilder.swift
index 40f7c83d..583393fd 100644
--- a/Sources/SpeziBluetooth/Configuration/DiscoveryDescriptorBuilder.swift
+++ b/Sources/SpeziBluetooth/Configuration/DiscoveryDescriptorBuilder.swift
@@ -12,7 +12,7 @@
 public enum DiscoveryDescriptorBuilder {
     /// Build a ``Discover`` expression to define a ``DeviceDiscoveryDescriptor``.
     public static func buildExpression<Device: BluetoothDevice>(_ expression: Discover<Device>) -> Set<DeviceDiscoveryDescriptor> {
-        [DeviceDiscoveryDescriptor(discoveryCriteria: expression.discoveryCriteria, deviceType: expression.deviceType)]
+        [DeviceDiscoveryDescriptor(discoveryCriteria: expression.discoveryCriteria, deviceType: Device.self)]
     }
 
     /// Build a block of ``DeviceDiscoveryDescriptor``s.
diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift
index 3c3b37ce..0aa83dde 100644
--- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift
+++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift
@@ -6,7 +6,6 @@
 // SPDX-License-Identifier: MIT
 //
 
-@preconcurrency import class CoreBluetooth.CBCentralManager // swiftlint:disable:this duplicate_imports
 import CoreBluetooth
 import NIO
 import Observation
@@ -84,15 +83,15 @@ import OSLog
 public class BluetoothManager: Observable, Sendable, Identifiable { // swiftlint:disable:this type_body_length
     private let logger = Logger(subsystem: "edu.stanford.spezi.bluetooth", category: "BluetoothManager")
 
-    private var _centralManager: CBCentralManager?
+    private var _centralManager: CBInstance<CBCentralManager>?
 
     private var centralManager: CBCentralManager {
         guard let centralManager = _centralManager else {
             let centralManager = supplyCBCentral()
-            self._centralManager = centralManager
+            self._centralManager = CBInstance(instantiatedOnDispatchQueue: centralManager)
             return centralManager
         }
-        return centralManager
+        return centralManager.cbObject
     }
 
     private lazy var centralDelegate: Delegate = { // swiftlint:disable:this weak_delegate
@@ -472,7 +471,7 @@ public class BluetoothManager: Observable, Sendable, Identifiable { // swiftlint
     }
 
     func connect(peripheral: BluetoothPeripheral) {
-        logger.debug("Trying to connect to \(peripheral.debugDescription) ...")
+        logger.debug("Trying to connect to \(peripheral) ...")
 
         let cancelled = discoverySession?.cancelStaleTask(for: peripheral)
 
@@ -484,18 +483,18 @@ public class BluetoothManager: Observable, Sendable, Identifiable { // swiftlint
     }
 
     func disconnect(peripheral: BluetoothPeripheral) {
-        logger.debug("Disconnecting peripheral \(peripheral.debugDescription) ...")
+        logger.debug("Disconnecting peripheral \(peripheral) ...")
         // stale timer is handled in the delegate method
         centralManager.cancelPeripheralConnection(peripheral.cbPeripheral)
 
         discoverySession?.deviceManuallyDisconnected(id: peripheral.id)
     }
 
-    private func handledConnected(device: BluetoothPeripheral) {
-        device.handleConnect()
-
+    private func handledConnected(device: BluetoothPeripheral) async {
         // we might have connected a bluetooth peripheral that was weakly referenced
         ensurePeripheralReference(device)
+        
+        await device.handleConnect()
     }
 
     private func discardDevice(device: BluetoothPeripheral, error: Error?) {
@@ -522,7 +521,7 @@ public class BluetoothManager: Observable, Sendable, Identifiable { // swiftlint
         Task { @SpeziBluetooth [storage, _centralManager, isScanning, logger] in
             if isScanning {
                 storage.isScanning = false
-                _centralManager?.stopScan()
+                _centralManager?.cbObject.stopScan()
                 logger.debug("Scanning stopped")
             }
 
@@ -575,6 +574,8 @@ extension BluetoothManager {
         static let defaultMinimumRSSI = -80
         /// The default time in seconds after which we check for auto connectable devices after the initial advertisement.
         static let defaultAutoConnectDebounce: Int = 1
+        /// The amount of times we try to automatically (if enabled) subscribe to a notify characteristic.
+        static let autoSubscribeAttempts = 3
     }
 }
 
@@ -673,7 +674,7 @@ extension BluetoothManager {
                     return
                 }
 
-                logger.debug("Discovered peripheral \(peripheral.debugIdentifier) at \(rssi.intValue) dB (data: \(String(describing: data))")
+                logger.debug("Discovered peripheral \(peripheral.debugIdentifier) at \(rssi.intValue) dB with data \(data)")
 
                 let descriptor = session.configuredDevices.find(for: data, logger: logger)
 
@@ -705,10 +706,10 @@ extension BluetoothManager {
                     return
                 }
 
-                logger.debug("Peripheral \(peripheral.debugIdentifier) connected.")
-                manager.handledConnected(device: device)
-
+                logger.debug("Peripheral \(device) connected.")
                 await manager.storage.cbDelegateSignal(connected: true, for: peripheral.identifier)
+
+                await manager.handledConnected(device: device)
             }
         }
 
@@ -730,9 +731,9 @@ extension BluetoothManager {
                 }
 
                 if let error {
-                    logger.error("Failed to connect to \(peripheral.debugDescription): \(error)")
+                    logger.error("Failed to connect to \(device): \(error)")
                 } else {
-                    logger.error("Failed to connect to \(peripheral.debugDescription)")
+                    logger.error("Failed to connect to \(device)")
                 }
 
                 // just to make sure
@@ -756,9 +757,9 @@ extension BluetoothManager {
                 }
 
                 if let error {
-                    logger.debug("Peripheral \(peripheral.debugIdentifier) disconnected due to an error: \(error)")
+                    logger.debug("Peripheral \(device) disconnected due to an error: \(error)")
                 } else {
-                    logger.debug("Peripheral \(peripheral.debugIdentifier) disconnected.")
+                    logger.debug("Peripheral \(device) disconnected.")
                 }
 
                 manager.discardDevice(device: device, error: error)
diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift
index 8856b25c..d45bd073 100644
--- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift
+++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift
@@ -46,6 +46,7 @@ import SpeziFoundation
 ///
 /// ### Notifications and handling changes
 /// - ``enableNotifications(_:serviceId:characteristicId:)``
+/// - ``setNotifications(_:for:)``
 /// - ``registerOnChangeHandler(service:characteristic:_:)``
 /// - ``registerOnChangeHandler(for:_:)``
 /// - ``OnChangeRegistration``
@@ -66,29 +67,25 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length
     /// Observable state container for local state.
     private let storage: PeripheralStorage
 
-    /// Protecting concurrent access to an ongoing connect attempt.
-    private let connectAccess = AsyncSemaphore()
-    /// Continuation for a currently ongoing connect attempt.
-    private var connectContinuation: CheckedContinuation<Void, Error>?
-    /// Ongoing accessed per characteristic.
-    private var characteristicAccesses = CharacteristicAccesses()
-    /// Protecting concurrent access to an ongoing write without response.
-    private let writeWithoutResponseAccess = AsyncSemaphore()
-    /// Continuation for the current write without response access.
-    private var writeWithoutResponseContinuation: CheckedContinuation<Void, Never>?
-    /// Protecting concurrent access to an ongoing rssi read access.
-    private let rssiAccess = AsyncSemaphore()
-    /// Continuation for a currently ongoing rssi read access.
-    private var rssiContinuation: CheckedContinuation<Int, Error>?
+    /// Manage asynchronous accesses for an ongoing connection attempt.
+    private let connectAccess = ManagedAsynchronousAccess<Void, Error>()
+    /// Manage asynchronous accesses per characteristic.
+    private let characteristicAccesses = CharacteristicAccesses()
+    /// Manage asynchronous accesses for an ongoing writhe without response.
+    private let writeWithoutResponseAccess = ManagedAsynchronousAccess<Void, Never>()
+    /// Manage asynchronous accesses for the rssi read action.
+    private let rssiAccess = ManagedAsynchronousAccess<Int, Error>()
+    /// Manage asynchronous accesses for service discovery.
+    private let discoverServicesAccess = ManagedAsynchronousAccess<[BTUUID], Error>()
+    /// Manage asynchronous accesses for characteristic discovery of a given service.
+    private var discoverCharacteristicAccesses: [BTUUID: ManagedAsynchronousAccess<Void, Error>] = [:]
 
     /// On-change handler registrations for all characteristics.
     private var onChangeHandlers: [CharacteristicLocator: [UUID: CharacteristicOnChangeHandler]] = [:]
     /// The list of characteristics that are requested to enable notifications.
     private var notifyRequested: Set<CharacteristicLocator> = []
-
-
-    /// A set of service ids we are currently awaiting characteristics discovery for
-    private var servicesAwaitingCharacteristicsDiscovery: Set<BTUUID> = []
+    /// A set of characteristics identifier which is populated while the initial value is being read.
+    private var currentlyReadingInitialValue: Set<CharacteristicLocator> = []
 
     /// The internally managed identifier for the peripheral.
     public nonisolated let id: UUID
@@ -122,7 +119,7 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length
     ///
     /// Services are discovered automatically upon connection
     public var services: [GATTService]? { // swiftlint:disable:this discouraged_optional_collection
-        storage.services
+        storage.services.map { Array($0.values) }
     }
 
     /// The last device activity.
@@ -183,7 +180,9 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length
 
     /// Establish a connection to the peripheral and wait until it is connected.
     ///
-    /// Make a connection to the peripheral.
+    /// Make a connection to the peripheral. The method returns once the device is connected and fully discovered according to
+    /// the ``DeviceDescription`` (e.g., enabling notifications for certain characteristics).
+    /// If service or characteristic discovery fails, this method will throw the respective error and automatically disconnect the device.
     ///
     /// - Note: You might want to verify via the ``AdvertisementData/isConnectable`` property that the device is connectable.
     public func connect() async throws {
@@ -192,17 +191,15 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length
             return
         }
 
-        try await connectAccess.waitCheckingCancellation()
-
         try await withTaskCancellationHandler {
-            try await withCheckedThrowingContinuation { continuation in
-                assert(connectContinuation == nil, "connectContinuation was unexpectedly not nil")
-                connectContinuation = continuation
+            try await connectAccess.perform {
                 manager.connect(peripheral: self)
             }
         } onCancel: {
             Task { @SpeziBluetooth in
-                disconnect()
+                if connectAccess.isRunning {
+                    disconnect()
+                }
             }
         }
     }
@@ -227,9 +224,7 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length
     /// - Parameter id: The Bluetooth service id.
     /// - Returns: The service instance if present.
     public func getService(id: BTUUID) -> GATTService? {
-        services?.first { service in
-            service.uuid == id
-        }
+        storage.services?[id]
     }
 
     /// Retrieve a characteristic.
@@ -245,19 +240,174 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length
         storage.onChange(of: keyPath, perform: closure)
     }
 
-    func handleConnect() {
+    func isReadingInitialValue(for characteristicId: BTUUID, on serviceId: BTUUID) -> Bool {
+        let locator = CharacteristicLocator(serviceId: serviceId, characteristicId: characteristicId)
+        return currentlyReadingInitialValue.contains(locator)
+    }
+
+    func handleConnect() async {
         // ensure that it is updated instantly.
         storage.update(state: PeripheralState(from: cbPeripheral.state))
 
-        logger.debug("Discovering services for \(self.cbPeripheral.debugIdentifier) ...")
+        logger.debug("Discovering services for \(self) ...")
         let serviceIds = configuration.services?.reduce(into: Set()) { result, description in
-            result.insert(description.serviceId.cbuuid)
+            result.insert(description.serviceId)
+        }
+
+        do {
+            let discoveredServices = try await self.discoverServices(serviceIds)
+            let serviceDiscoveries = try await discoverCharacteristics(for: discoveredServices)
+
+            // handle auto-subscribe and discover descriptors if descriptions exist
+            try await withThrowingDiscardingTaskGroup { group in
+                for (service, descriptions) in serviceDiscoveries {
+                    group.addTask { @Sendable @SpeziBluetooth in
+                        try await self.enableNotificationsForDiscoveredCharacteristics(for: service)
+                    }
+
+                    if let descriptions {
+                        group.addTask { @Sendable @SpeziBluetooth in
+                            try await self.handleDiscoveredCharacteristic(descriptions, for: service)
+                        }
+                    }
+                }
+            }
+        } catch {
+            logger.error("Failed to discover initial services: \(error)")
+            connectAccess.resume(throwing: error)
+            disconnect()
+            return
         }
-        
-        if let serviceIds, serviceIds.isEmpty {
-            signalFullyDiscovered()
+
+        storage.signalFullyDiscovered()
+        connectAccess.resume()
+    }
+
+    private func discoverServices(_ services: Set<BTUUID>?) async throws -> [BTUUID] { // swiftlint:disable:this discouraged_optional_collection
+        let cbServiceIds = services.map { $0.map { $0.cbuuid } }
+
+        if let services {
+            logger.debug("Discovering services for peripheral \(self): \(services)")
         } else {
-            cbPeripheral.discoverServices(serviceIds.map { Array($0) })
+            logger.debug("Discovering all services for peripheral \(self)")
+        }
+
+        return try await discoverServicesAccess.perform {
+            cbPeripheral.discoverServices(cbServiceIds)
+        }
+    }
+
+    private func discoverCharacteristics(
+        for discoveredServices: [BTUUID]
+    ) async throws -> [(service: GATTService, characteristics: Set<CharacteristicDescription>?)] {
+        // swiftlint:disable:previous discouraged_optional_collection
+
+        // swiftlint:disable:next discouraged_optional_collection
+        let discoveryJobs: [(service: GATTService, characteristics: Set<CharacteristicDescription>?)] = discoveredServices
+            .reduce(into: []) { partialResult, serviceId in
+                guard let service = getService(id: serviceId),
+                      let serviceDescription = configuration.description(for: serviceId) else {
+                    return
+                }
+
+                partialResult.append((service, serviceDescription.characteristics))
+            }
+
+        try await withThrowingTaskGroup(of: Void.self) { group in
+            for job in discoveryJobs {
+                group.addTask { @Sendable @SpeziBluetooth in
+                    let characteristicIds = job.characteristics.map { Set($0.map { $0.characteristicId }) }
+                    try await self.discoverCharacteristic(characteristicIds, for: job.service)
+                }
+            }
+
+            try await group.waitForAll()
+        }
+
+        return discoveryJobs
+    }
+
+    private func discoverCharacteristic(_ characteristics: Set<BTUUID>?, for service: GATTService) async throws {
+        // swiftlint:disable:previous discouraged_optional_collection
+        let cbCharacteristicIds = characteristics.map { Array($0.map { $0.cbuuid }) }
+
+        if let characteristics {
+            logger.debug("Discovering characteristics on \(service) for peripheral \(self): \(characteristics)")
+        } else {
+            logger.debug("Discovering all characteristics on \(service) for peripheral \(self)")
+        }
+
+        let access: ManagedAsynchronousAccess<Void, Error>
+        if let existing = discoverCharacteristicAccesses[service.id] {
+            access = existing
+        } else {
+            access = .init()
+            discoverCharacteristicAccesses[service.id] = access
+        }
+
+        try await access.perform {
+            cbPeripheral.discoverCharacteristics(cbCharacteristicIds, for: service.underlyingService)
+        }
+    }
+
+    private func handleDiscoveredCharacteristic(_ descriptions: Set<CharacteristicDescription>, for service: GATTService) async throws {
+        try await withThrowingDiscardingTaskGroup { group in
+            for description in descriptions {
+                guard let characteristic = getCharacteristic(id: description.characteristicId, on: service.id) else {
+                    continue
+                }
+
+                // pull initial value if none is present
+                if description.autoRead && characteristic.value == nil && characteristic.properties.contains(.read) {
+                    group.addTask { @Sendable  @SpeziBluetooth in
+                        let locator = CharacteristicLocator(serviceId: service.id, characteristicId: characteristic.id)
+                        let (inserted, _) = self.currentlyReadingInitialValue.insert(locator)
+                        do {
+                            _ = try await self.read(characteristic: characteristic)
+                        } catch {
+                            self.logger.warning("Failed to read the initial value of \(characteristic): \(error)")
+                        }
+                        if inserted {
+                            self.currentlyReadingInitialValue.remove(locator)
+                        }
+                    }
+                }
+
+                if description.discoverDescriptors {
+                    logger.debug("Discovering descriptors for \(characteristic)...")
+                    // Currently descriptor interactions aren't really supported by SpeziBluetooth. However, we support the initial
+                    // discovery of descriptors. Therefore, it is fine that this operation is currently not made fully async.
+                    cbPeripheral.discoverDescriptors(for: characteristic.underlyingCharacteristic)
+                }
+            }
+        }
+    }
+
+    private func enableNotificationsForDiscoveredCharacteristics(for service: GATTService) async throws {
+        try await withThrowingDiscardingTaskGroup { group in
+            for characteristic in service.characteristics {
+                guard characteristic.properties.supportsNotifications,
+                      didRequestNotifications(serviceId: service.id, characteristicId: characteristic.id) else {
+                    continue
+                }
+
+                group.addTask { @Sendable @SpeziBluetooth in
+                    self.logger.debug("Automatically subscribing to discovered characteristic \(characteristic.id) on \(service.id)...")
+
+                    var attempts = BluetoothManager.Defaults.autoSubscribeAttempts
+                    while true {
+                        do {
+                            try await self.setNotifications(true, for: characteristic)
+                            break
+                        } catch {
+                            attempts -= 1
+                            if attempts <= 0 {
+                                throw error
+                            }
+                        }
+                    }
+                }
+            }
         }
     }
 
@@ -268,29 +418,21 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length
 
         // clear all the ongoing access
 
-        self.servicesAwaitingCharacteristicsDiscovery.removeAll()
-
-        if let services {
-            self.invalidateServices(Set(services.map { $0.uuid }))
+        if let serviceIds = storage.services?.keys {
+            self.invalidateServices(Set(serviceIds))
         }
 
-        connectAccess.cancelAll()
+        connectAccess.cancelAll(error: error)
         writeWithoutResponseAccess.cancelAll()
-        rssiAccess.cancelAll()
+        rssiAccess.cancelAll(error: error)
+        discoverServicesAccess.cancelAll(error: error)
 
         characteristicAccesses.cancelAll(disconnectError: error)
 
-        if let connectContinuation {
-            self.connectContinuation = nil
-            connectContinuation.resume(throwing: error ?? CancellationError())
-        }
-        if let writeWithoutResponseContinuation {
-            self.writeWithoutResponseContinuation = nil
-            writeWithoutResponseContinuation.resume()
-        }
-        if let rssiContinuation {
-            self.rssiContinuation = nil
-            rssiContinuation.resume(throwing: error ?? CancellationError())
+        let discoverCharacteristicAccesses = discoverCharacteristicAccesses
+        self.discoverCharacteristicAccesses.removeAll()
+        for access in discoverCharacteristicAccesses.values {
+            access.cancelAll(error: error)
         }
     }
 
@@ -333,10 +475,10 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length
         _ onChange: @escaping (Data) -> Void
     ) throws -> OnChangeRegistration {
         guard let service = characteristic.service else {
-            throw BluetoothError.notPresent(service: nil, characteristic: characteristic.uuid)
+            throw BluetoothError.notPresent(service: nil, characteristic: characteristic.id)
         }
 
-        return registerOnChangeHandler(service: service.uuid, characteristic: characteristic.uuid, onChange)
+        return registerOnChangeHandler(service: service.id, characteristic: characteristic.id, onChange)
     }
 
     /// Register a on-change handler for a characteristic.
@@ -381,9 +523,17 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length
         return OnChangeRegistration(peripheral: self, locator: locator, handlerId: id)
     }
 
+    func deregisterOnChange(_ registration: OnChangeRegistration) {
+        deregisterOnChange(locator: registration.locator, handlerId: registration.handlerId)
+    }
+
+    func deregisterOnChange(locator: CharacteristicLocator, handlerId: UUID) {
+        onChangeHandlers[locator]?.removeValue(forKey: handlerId)
+    }
+
     /// Enable or disable notifications for a given characteristic.
     ///
-    /// - Tip: It is not required that the device is connected. Notifications will be automatically enabled for the
+    /// It is not required that the device is connected. Notifications will be automatically enabled for the
     /// respective characteristic upon device discovery.
     ///
     /// - Parameters:
@@ -401,7 +551,15 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length
         }
 
         // if setting notify doesn't work here, we do it upon discovery of the characteristics
-        trySettingNotifyValue(enabled, serviceId: serviceId, characteristicId: characteristicId)
+        guard let characteristic = getCharacteristic(id: characteristicId, on: serviceId) else {
+            return
+        }
+
+        if characteristic.properties.supportsNotifications {
+            Task {
+                try? await setNotifications(enabled, for: characteristic)
+            }
+        }
     }
 
     func didRequestNotifications(serviceId: BTUUID, characteristicId: BTUUID) -> Bool {
@@ -409,24 +567,22 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length
         return notifyRequested.contains(id)
     }
 
-    func deregisterOnChange(_ registration: OnChangeRegistration) {
-        deregisterOnChange(locator: registration.locator, handlerId: registration.handlerId)
-    }
-
-    func deregisterOnChange(locator: CharacteristicLocator, handlerId: UUID) {
-        onChangeHandlers[locator]?.removeValue(forKey: handlerId)
-    }
-
-    private func trySettingNotifyValue(_ notify: Bool, serviceId: BTUUID, characteristicId: BTUUID) {
-        guard let characteristic = getCharacteristic(id: characteristicId, on: serviceId) else {
-            return
-        }
-
-        if characteristic.properties.supportsNotifications {
-            cbPeripheral.setNotifyValue(notify, for: characteristic.underlyingCharacteristic)
+    /// Set notification value for a given characteristic.
+    ///
+    /// In contrast to ``enableNotifications(_:serviceId:characteristicId:)`` this method instantly sends the command to the peripheral and awaits the response.
+    /// Therefore, the device must be connected when calling this method.
+    ///
+    /// - Parameters:
+    ///   - enabled: Enable or disable notifications.
+    ///   - characteristic: The characteristic for which to enable notifications.
+    public func setNotifications(_ enabled: Bool, for characteristic: GATTCharacteristic) async throws {
+        try await characteristicAccesses.performNotify(for: characteristic.underlyingCharacteristic) {
+            cbPeripheral.setNotifyValue(enabled, for: characteristic.underlyingCharacteristic)
         }
     }
 
+    /// Reset all notification values back to `false`.
+    ///
     /// Call this when things either go wrong, or you're done with the connection.
     /// This cancels any subscriptions if there are any, or straight disconnects if not.
     /// (didUpdateNotificationStateForCharacteristic will cancel the connection if a subscription is involved)
@@ -453,16 +609,10 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length
     /// - Parameters:
     ///   - data: The value to write.
     ///   - characteristic: The characteristic to which the value is written.
-    /// - Returns: The response from the device.
     /// - Throws: Throws an `CBError` or `CBATTError` if the write fails.
     public func write(data: Data, for characteristic: GATTCharacteristic) async throws {
-        let characteristic = characteristic.underlyingCharacteristic
-        let access = characteristicAccesses.makeAccess(for: characteristic)
-        try await access.waitCheckingCancellation()
-
-        try await withCheckedThrowingContinuation { continuation in
-            access.store(.write(continuation))
-            cbPeripheral.writeValue(data, for: characteristic, type: .withResponse)
+        try await characteristicAccesses.performWrite(for: characteristic.underlyingCharacteristic) {
+            cbPeripheral.writeValue(data, for: characteristic.underlyingCharacteristic, type: .withResponse)
         }
     }
 
@@ -478,17 +628,13 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length
     ///   - characteristic: The characteristic to which the value is written.
     public func writeWithoutResponse(data: Data, for characteristic: GATTCharacteristic) async {
         do {
-            try await writeWithoutResponseAccess.waitCheckingCancellation()
+            try await writeWithoutResponseAccess.perform {
+                cbPeripheral.writeValue(data, for: characteristic.underlyingCharacteristic, type: .withoutResponse)
+            }
         } catch {
             // task got cancelled, so just throw away the written value
             return
         }
-
-        await withCheckedContinuation { continuation in
-            assert(writeWithoutResponseContinuation == nil, "writeWithoutResponseAccess was unexpectedly not nil")
-            writeWithoutResponseContinuation = continuation
-            cbPeripheral.writeValue(data, for: characteristic.underlyingCharacteristic, type: .withoutResponse)
-        }
     }
 
     /// Read the value of a characteristic.
@@ -499,14 +645,8 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length
     /// - Returns: The value that the peripheral was returned.
     /// - Throws: Throws an `CBError` or `CBATTError` if the read fails.
     public func read(characteristic: GATTCharacteristic) async throws -> Data {
-        let characteristic = characteristic.underlyingCharacteristic
-
-        let access = characteristicAccesses.makeAccess(for: characteristic)
-        try await access.waitCheckingCancellation()
-
-        return try await withCheckedThrowingContinuation { continuation in
-            access.store(.read(continuation))
-            cbPeripheral.readValue(for: characteristic)
+        try await characteristicAccesses.performRead(for: characteristic.underlyingCharacteristic) {
+            cbPeripheral.readValue(for: characteristic.underlyingCharacteristic)
         }
     }
 
@@ -516,11 +656,7 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length
     /// - Returns: The read rssi value.
     /// - Throws: Throws an `CBError` or `CBATTError` if the read fails.
     public func readRSSI() async throws -> Int {
-        try await rssiAccess.waitCheckingCancellation()
-
-        return try await withCheckedThrowingContinuation { continuation in
-            assert(rssiContinuation == nil, "rssiAccess was unexpectedly not nil")
-            rssiContinuation = continuation
+        try await rssiAccess.perform {
             cbPeripheral.readRSSI()
         }
     }
@@ -545,7 +681,7 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length
         }
 
         for characteristic in changeProtocol.updatedCharacteristics {
-            let locator = CharacteristicLocator(serviceId: uuid, characteristicId: characteristic.uuid)
+            let locator = CharacteristicLocator(serviceId: uuid, characteristicId: characteristic.id)
             for handler in onChangeHandlers[locator, default: [:]].values {
                 if case let .instance(onChange) = handler {
                     onChange(characteristic)
@@ -565,25 +701,18 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length
     }
 
     private func invalidateServices(_ ids: Set<BTUUID>) {
-        guard let services else {
+        guard storage.services != nil else {
             return
         }
 
-        for (index, service) in zip(services.indices, services).reversed() {
-            guard ids.contains(service.uuid) else {
+        for id in ids {
+            guard let service = storage.services?.removeValue(forKey: id) else {
                 continue
             }
 
-            // Note: we iterate over the zipped array in reverse such that the indices stay valid if remove elements
-
-            // the service was invalidated!
-            var services = self.services
-            services?.remove(at: index)
-            self.storage.services = services
-
             // make sure we notify subscribed handlers about removed services!
             for characteristic in service.characteristics {
-                let locator = CharacteristicLocator(serviceId: service.uuid, characteristicId: characteristic.uuid)
+                let locator = CharacteristicLocator(serviceId: service.id, characteristicId: characteristic.id)
                 for handler in onChangeHandlers[locator, default: [:]].values {
                     if case let .instance(onChange) = handler {
                         onChange(nil) // signal removed characteristic!
@@ -594,21 +723,26 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length
     }
 
     private func discovered(services: [CBService]) {
-        // ids of currently maintained ids
-        let existingServices = Set(self.services?.map { $0.uuid } ?? [])
+        let discoveredIds = Set(services.map { BTUUID(from: $0.uuid) })
+        let removedServiceIds = self.storage.services?.keys.filter { uuid in
+            !discoveredIds.contains(uuid)
+        }
 
-        // if we re-discover services (e.g., if ones got invalidated), services might still be present. So only add new ones
-        let addedServices = services
-            .filter { !existingServices.contains(BTUUID(from: $0.uuid)) }
-            .map {
-                // we will discover characteristics for all services after that.
-                GATTService(service: $0)
-            }
+        if let removedServiceIds {
+            invalidateServices(Set(removedServiceIds))
+        }
+
+        let discoveredServices: [BTUUID: GATTService] = services.reduce(into: [:]) { partialResult, cbService in
+            let service = GATTService(service: cbService)
+            partialResult[service.id] = service
+        }
 
-        if let services = self.services {
-            storage.services = services + addedServices
+        if let services = self.storage.services {
+            storage.services = services.merging(discoveredServices) { previous, _ in
+                previous // just discard service instances that would override previous instance!
+            }
         } else {
-            storage.services = addedServices
+            storage.services = discoveredServices
         }
     }
 
@@ -623,7 +757,7 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length
 
         self.logger.debug("Device \(id), \(name ?? "unnamed") was de-initialized...")
 
-        Task.detached { @SpeziBluetooth [storage, nearby] in
+        Task.detached { @Sendable @SpeziBluetooth [storage, nearby] in
             if nearby { // make sure signal is sent
                 storage.nearby = false
             }
@@ -636,103 +770,20 @@ public class BluetoothPeripheral { // swiftlint:disable:this type_body_length
 
 // MARK: Delegate Accessors
 extension BluetoothPeripheral {
-    private func discovered(service: CBService) {
-        guard let characteristics = service.characteristics else {
-            logger.warning("Characteristic discovery for service \(service.uuid) resulted in an empty list.")
-            return
-        }
-
-        logger.debug("Discovered \(characteristics.count) characteristic(s) for service \(service.uuid): \(characteristics)")
-
-        // automatically subscribe to discovered characteristics for which we have a handler subscribed!
-        for characteristic in characteristics {
-            let serviceId = BTUUID(from: service.uuid)
-            let characteristicId = BTUUID(from: characteristic.uuid)
-
-            let description = configuration.description(for: serviceId)?.description(for: characteristicId)
-
-            // pull initial value if none is present
-            if description?.autoRead != false && characteristic.value == nil && characteristic.properties.contains(.read) {
-                cbPeripheral.readValue(for: characteristic)
-            }
-
-            // enable notifications if registered
-            if characteristic.properties.supportsNotifications {
-                let locator = CharacteristicLocator(serviceId: serviceId, characteristicId: characteristicId)
-
-                if notifyRequested.contains(locator) {
-                    logger.debug("Automatically subscribing to discovered characteristic \(locator)...")
-                    cbPeripheral.setNotifyValue(true, for: characteristic)
-                }
-            }
-
-            if description?.discoverDescriptors == true {
-                logger.debug("Discovering descriptors for \(characteristic.debugIdentifier)...")
-                cbPeripheral.discoverDescriptors(for: characteristic)
-            }
-        }
-    }
-
-    private func signalFullyDiscovered() {
-        storage.signalFullyDiscovered()
-
-        if let connectContinuation {
-            connectContinuation.resume()
-            self.connectContinuation = nil
-            connectAccess.signal() // balance async semaphore.
-        }
-    }
-
     private func receivedUpdatedValue(for characteristic: CBCharacteristic, result: Result<Data, Error>) {
-        if let access = characteristicAccesses.retrieveAccess(for: characteristic),
-           case let .read(continuation) = access.value {
-            if case let .failure(error) = result {
-                logger.debug("Characteristic read for \(characteristic.debugIdentifier) returned with error: \(error)")
-            }
-
-            access.consume()
-            continuation.resume(with: result)
-        } else if case let .failure(error) = result {
-            logger.debug("Received unsolicited value update error for \(characteristic.debugIdentifier): \(error)")
-        }
-
-        // notification handling
-        guard case let .success(data) = result else {
-            return
-        }
+        if case let .success(data) = result,
+           let service = characteristic.service {
+            let locator = CharacteristicLocator(serviceId: BTUUID(from: service.uuid), characteristicId: BTUUID(from: characteristic.uuid))
 
-        guard let service = characteristic.service else {
-            logger.warning("Received updated value for characteristic \(characteristic.debugIdentifier) without associated service!")
-            return
-        }
-
-        let locator = CharacteristicLocator(serviceId: BTUUID(from: service.uuid), characteristicId: BTUUID(from: characteristic.uuid))
-        for onChange in onChangeHandlers[locator, default: [:]].values {
-            guard case let .value(handler) = onChange else {
-                continue
-            }
-            handler(data)
-        }
-    }
-
-    private func receivedWriteResponse(for characteristic: CBCharacteristic, result: Result<Void, Error>) {
-        guard let access = characteristicAccesses.retrieveAccess(for: characteristic),
-              case let .write(continuation) = access.value else {
-            switch result {
-            case .success:
-                logger.warning("Received write response for \(characteristic.debugIdentifier) without an ongoing access. Discarding write ...")
-            case let .failure(error):
-                logger.warning("Received erroneous write response for \(characteristic.debugIdentifier) without an ongoing access: \(error)")
+            for onChange in onChangeHandlers[locator, default: [:]].values {
+                guard case let .value(handler) = onChange else {
+                    continue
+                }
+                handler(data)
             }
-            return
-        }
-
-        if case let .failure(error) = result {
-            logger.debug("Characteristic write for \(characteristic.debugIdentifier) returned with error: \(error)")
         }
 
-        access.consume()
-        continuation.resume(with: result)
+        characteristicAccesses.resumeRead(with: result, for: characteristic)
     }
 }
 
@@ -740,14 +791,18 @@ extension BluetoothPeripheral {
 extension BluetoothPeripheral: Identifiable, Sendable {}
 
 
-extension BluetoothPeripheral: CustomDebugStringConvertible {
-    public nonisolated var debugDescription: String {
+extension BluetoothPeripheral: CustomStringConvertible, CustomDebugStringConvertible {
+    public nonisolated var description: String {
         if let name {
-            "'\(name)' @ \(id)"
+            "'\(name)'@\(id)"
         } else {
             "\(id)"
         }
     }
+
+    public nonisolated var debugDescription: String {
+        description
+    }
 }
 
 
@@ -802,14 +857,7 @@ extension BluetoothPeripheral {
                 device.storage.rssi = rssi
 
                 let result: Result<Int, Error> = error.map { .failure($0) } ?? .success(rssi)
-
-                guard let rssiContinuation = device.rssiContinuation else {
-                    return
-                }
-
-                device.rssiContinuation = nil
-                rssiContinuation.resume(with: result)
-                assert(device.rssiAccess.signal(), "Signaled rssiAccess though no one was waiting")
+                device.rssiAccess.resume(with: result)
             }
         }
 
@@ -844,46 +892,26 @@ extension BluetoothPeripheral {
                 return
             }
 
+            let cbServices = peripheral.services.map { CBInstance(instantiatedOnDispatchQueue: $0) }
+            let result: Result<[BTUUID], Error>
+
             if let error {
                 logger.error("Error discovering services: \(error.localizedDescription)")
-                return
-            }
-
-            guard let services = peripheral.services else {
-                logger.error("Discovered services but they weren't present!")
-                return
+                result = .failure(error)
+            } else if let services = peripheral.services {
+                logger.debug("Successfully discovered services for peripheral \(device): \(services.map { $0.uuid })")
+                result = .success(services.map { BTUUID(from: $0.uuid) })
+            } else {
+                logger.debug("Discovered zero services for peripheral \(device)")
+                result = .success([])
             }
 
-            let peripheral = CBInstance(instantiatedOnDispatchQueue: peripheral)
-            let cbServices = CBInstance(instantiatedOnDispatchQueue: services)
-
-            Task { @SpeziBluetooth [logger] in
-                device.discovered(services: cbServices.cbObject)
-
-                logger.debug("Discovered \(cbServices.cbObject) services for peripheral \(device.debugDescription)")
-
-                for service in cbServices.cbObject {
-                    let serviceId = BTUUID(from: service.uuid)
-
-                    guard let serviceDescription = device.configuration.description(for: serviceId) else {
-                        continue
-                    }
-
-                    let characteristicIds = serviceDescription.characteristics?.reduce(into: Set()) { partialResult, description in
-                        partialResult.insert(description.characteristicId)
-                    }
-
-                    if let characteristicIds, characteristicIds.isEmpty {
-                        continue
-                    }
-
-                    device.servicesAwaitingCharacteristicsDiscovery.insert(serviceId)
-                    peripheral.cbObject.discoverCharacteristics(characteristicIds.map { Array($0.map { $0.cbuuid }) }, for: service)
+            Task { @SpeziBluetooth in
+                if let cbServices {
+                    device.discovered(services: cbServices.cbObject)
                 }
 
-                if device.servicesAwaitingCharacteristicsDiscovery.isEmpty {
-                    device.signalFullyDiscovered()
-                }
+                device.discoverServicesAccess.resume(with: result)
             }
         }
 
@@ -892,24 +920,31 @@ extension BluetoothPeripheral {
                 return
             }
 
+            let result: Result<Void, Error>
+            if let error {
+                logger.error("Error discovering characteristics for service \(service.uuid): \(error.localizedDescription)")
+                result = .failure(error)
+            } else {
+                if let characteristics = service.characteristics, !characteristics.isEmpty {
+                    logger.debug("Successfully discovered characteristics for service \(service.uuid): \(characteristics.map { $0.uuid })")
+                } else {
+                    logger.debug("Discovered zero characteristics for service \(service.uuid)")
+                }
+                result = .success(())
+            }
+
             let service = CBInstance(instantiatedOnDispatchQueue: service)
-            Task { @SpeziBluetooth [logger] in
+            Task { @SpeziBluetooth in
                 // update our model with latest characteristics!
                 device.synchronizeModel(for: service.cbObject)
 
-                // ensure we keep track of all discoveries, set .connected state
-                device.servicesAwaitingCharacteristicsDiscovery.remove(BTUUID(from: service.uuid))
-                if device.servicesAwaitingCharacteristicsDiscovery.isEmpty {
-                    device.signalFullyDiscovered()
-                }
-
-                if let error {
-                    logger.error("Error discovering characteristics: \(error.localizedDescription)")
-                    return
+                let id = BTUUID(from: service.uuid)
+                if let access = device.discoverCharacteristicAccesses[id] {
+                    let stillRequired = access.resume(with: result)
+                    if !stillRequired { // no one was waiting for discovery on that characteristic, thus we can remove it safely
+                        device.discoverCharacteristicAccesses.removeValue(forKey: id)
+                    }
                 }
-
-                // handle auto-subscribe and discover descriptors
-                device.discovered(service: service.cbObject)
             }
         }
 
@@ -922,7 +957,7 @@ extension BluetoothPeripheral {
                 return
             }
 
-            logger.debug("Discovered descriptors for characteristic \(characteristic.debugIdentifier): \(descriptors)")
+            logger.debug("Discovered descriptors for characteristic \(characteristic.uuid): \(descriptors)")
 
             let capture = GATTCharacteristicCapture(from: characteristic)
             let characteristic = CBInstance(instantiatedOnDispatchQueue: characteristic)
@@ -940,11 +975,12 @@ extension BluetoothPeripheral {
             let capture = GATTCharacteristicCapture(from: characteristic)
             let characteristic = CBInstance(instantiatedOnDispatchQueue: characteristic)
 
-            Task { @SpeziBluetooth in
+            Task { @SpeziBluetooth [logger] in
                 // make sure value is propagated beforehand
                 device.synchronizeModel(for: characteristic.cbObject, capture: capture)
 
                 if let error {
+                    logger.debug("Characteristic read for \(characteristic.uuid) returned with error: \(error)")
                     device.receivedUpdatedValue(for: characteristic.cbObject, result: .failure(error))
                 } else if let value = capture.value {
                     device.receivedUpdatedValue(for: characteristic.cbObject, result: .success(value))
@@ -960,11 +996,22 @@ extension BluetoothPeripheral {
             let capture = GATTCharacteristicCapture(from: characteristic)
             let characteristic = CBInstance(instantiatedOnDispatchQueue: characteristic)
 
-            Task { @SpeziBluetooth in
+            Task { @SpeziBluetooth [logger] in
                 device.synchronizeModel(for: characteristic.cbObject, capture: capture)
 
-                let result: Result<Void, Error> = error.map { .failure($0) } ?? .success(())
-                device.receivedWriteResponse(for: characteristic.cbObject, result: result)
+                let result: Result<Void, Error>
+                if let error {
+                    result = .failure(error)
+                    logger.warning("Received erroneous write response for \(characteristic.uuid) without an ongoing access: \(error)")
+                } else {
+                    result = .success(())
+                    logger.debug("Characteristic write for \(characteristic.uuid) returned with error: \(error)")
+                }
+
+                let didHandle = device.characteristicAccesses.resumeWrite(with: result, for: characteristic.cbObject)
+                if !didHandle {
+                    logger.warning("Write response for \(characteristic.uuid) was received without an ongoing access!")
+                }
             }
         }
 
@@ -974,13 +1021,7 @@ extension BluetoothPeripheral {
             }
 
             Task { @SpeziBluetooth in
-                guard let writeWithoutResponseContinuation = device.writeWithoutResponseContinuation else {
-                    return
-                }
-
-                device.writeWithoutResponseContinuation = nil
-                writeWithoutResponseContinuation.resume()
-                assert(device.writeWithoutResponseAccess.signal(), "Signaled writeWithoutResponseAccess though no one was waiting")
+                device.writeWithoutResponseAccess.resume()
             }
         }
 
@@ -989,9 +1030,13 @@ extension BluetoothPeripheral {
                 return
             }
 
-            if let error = error {
+
+            let result: Result<Void, Error>
+            if let error {
                 logger.error("Error changing notification state for \(characteristic.uuid): \(error)")
-                return
+                result = .failure(error)
+            } else {
+                result = .success(())
             }
 
             let capture = GATTCharacteristicCapture(from: characteristic)
@@ -1000,12 +1045,21 @@ extension BluetoothPeripheral {
             Task { @SpeziBluetooth [logger] in
                 device.synchronizeModel(for: characteristic.cbObject, capture: capture)
 
-                if capture.isNotifying {
-                    logger.log("Notification began on \(characteristic.debugIdentifier)")
-                } else {
-                    logger.log("Notification stopped on \(characteristic.debugIdentifier).")
+                if error == nil {
+                    if capture.isNotifying {
+                        logger.log("Notification began on \(characteristic.uuid)")
+                    } else {
+                        logger.log("Notification stopped on \(characteristic.uuid).")
+                    }
+                }
+
+                let didHandle = device.characteristicAccesses.resumeNotify(with: result, for: characteristic.cbObject)
+                if !didHandle {
+                    logger.warning("Notification state update for \(characteristic.uuid) was received without an ongoing access!")
                 }
             }
         }
     }
-} // swiftlint:disable:this file_length
+}
+
+// swiftlint:disable:this file_length
diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift
index a891f431..b25dbdc6 100644
--- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift
+++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/CharacteristicDescription.swift
@@ -8,7 +8,7 @@
 
 
 /// A characteristic description.
-public struct CharacteristicDescription: Sendable {
+public struct CharacteristicDescription {
     /// The characteristic id.
     public let characteristicId: BTUUID
     /// Flag indicating if descriptors should be discovered for this characteristic.
@@ -30,6 +30,9 @@ public struct CharacteristicDescription: Sendable {
 }
 
 
+extension CharacteristicDescription: Sendable {}
+
+
 extension CharacteristicDescription: ExpressibleByStringLiteral {
     public init(stringLiteral value: StringLiteralType) {
         self.init(id: BTUUID(stringLiteral: value))
diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift
index 79e86568..ce921299 100644
--- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift
+++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DeviceDescription.swift
@@ -17,6 +17,7 @@ public struct DeviceDescription {
     /// The set of service configurations we expect from the device.
     ///
     /// This will be the list of services we are interested in and we try to discover.
+    /// - Note: If `nil`, we discover all services on a device.
     public var services: Set<ServiceDescription>? { // swiftlint:disable:this discouraged_optional_collection
         let values: Dictionary<BTUUID, ServiceDescription>.Values? = _services?.values
         return values.map { Set($0) }
diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift
index f760a4e8..b502ceff 100644
--- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift
+++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/DiscoveryCriteria.swift
@@ -12,32 +12,42 @@
 /// ## Topics
 ///
 /// ### Criteria
-/// - ``advertisedService(_:)-swift.type.method``
-/// - ``advertisedService(_:)-swift.enum.case``
+/// - ``advertisedService(_:)-79pid``
+/// - ``advertisedService(_:)-5o92s``
+/// - ``advertisedServices(_:)-swift.type.method``
+/// - ``advertisedServices(_:)-swift.enum.case``
+/// - ``advertisedServices(_:_:)``
 /// - ``accessory(manufacturer:advertising:)-swift.type.method``
 /// - ``accessory(manufacturer:advertising:)-swift.enum.case``
-public enum DiscoveryCriteria: Sendable {
-    /// Identify a device by their advertised service.
-    case advertisedService(_ uuid: BTUUID)
-    /// Identify a device by its manufacturer and advertised service.
-    case accessory(manufacturer: ManufacturerIdentifier, advertising: BTUUID)
+/// - ``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])
 
 
-    var discoveryId: BTUUID {
+    var discoveryIds: [BTUUID] {
         switch self {
-        case let .advertisedService(uuid):
-            uuid
-        case let .accessory(_, service):
-            service
+        case let .advertisedServices(uuids):
+            uuids
+        case let .accessory(_, serviceIds):
+            serviceIds
         }
     }
 
 
     func matches(_ advertisementData: AdvertisementData) -> Bool {
         switch self {
-        case let .advertisedService(uuid):
-            return advertisementData.serviceUUIDs?.contains(uuid) ?? advertisementData.overflowServiceUUIDs?.contains(uuid) ?? false
-        case let .accessory(manufacturer, service):
+        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
@@ -48,33 +58,91 @@ public enum DiscoveryCriteria: Sendable {
             }
 
 
-            return advertisementData.serviceUUIDs?.contains(service) ?? false
+            return serviceIds.allSatisfy { uuid in
+                advertisementData.serviceUUIDs?.contains(uuid) ?? advertisementData.overflowServiceUUIDs?.contains(uuid) ?? false
+            }
         }
     }
 }
 
 
+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])
+    }
+
+    /// 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.
+    public static func advertisedServices(_ uuid: BTUUID...) -> DiscoveryCriteria {
+        .advertisedServices(uuid)
+    }
+
     /// Identify a device by their advertised service.
     /// - Parameter service: The service type.
-    /// - Returns: A ``DiscoveryCriteria/advertisedService(_:)-swift.enum.case`` criteria.
-    public static func advertisedService<Service: BluetoothService>(_ service: Service.Type) -> DiscoveryCriteria {
-        .advertisedService(service.id)
+    /// - Returns: A ``DiscoveryCriteria/advertisedServices(_:)-swift.enum.case`` criteria.
+    public static func advertisedService<Service: BluetoothService>(
+        _ service: Service.Type
+    ) -> DiscoveryCriteria {
+        .advertisedServices(service.id)
+    }
+
+    /// Identify a device by their advertised services.
+    ///
+    /// All supplied services need to be present in the advertisement.
+    /// - 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.
+    public static func advertisedServices<Service: BluetoothService, each S: BluetoothService>(
+        _ service: Service.Type,
+        _ additionalService: repeat (each S).Type
+    ) -> DiscoveryCriteria {
+        var serviceIds: [BTUUID] = [service.id]
+        repeat serviceIds.append((each additionalService).id)
+
+        return .advertisedServices(serviceIds)
     }
 }
 
 
 extension DiscoveryCriteria {
+    /// 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.
+    ///   - 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)
+    }
+
     /// Identify a device by its manufacturer and advertised service.
+    ///
+    /// All supplied services need to be present in the advertisement.
     /// - Parameters:
     ///   - manufacturer: The Bluetooth SIG-assigned manufacturer identifier.
     ///   - 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<Service: BluetoothService>(
+    public static func accessory<Service: BluetoothService, each S: BluetoothService>(
         manufacturer: ManufacturerIdentifier,
-        advertising service: Service.Type
+        advertising service: Service.Type,
+        _ additionalService: repeat (each S).Type
     ) -> DiscoveryCriteria {
-        .accessory(manufacturer: manufacturer, advertising: service.id)
+        var serviceIds: [BTUUID] = [service.id]
+        repeat serviceIds.append((each additionalService).id)
+
+        return .accessory(manufacturer: manufacturer, advertising: serviceIds)
     }
 }
 
@@ -82,8 +150,8 @@ extension DiscoveryCriteria {
 extension DiscoveryCriteria: Hashable, CustomStringConvertible {
     public var description: String {
         switch self {
-        case let .advertisedService(uuid):
-            ".advertisedService(\(uuid))"
+        case let .advertisedServices(uuids):
+            ".advertisedServices(\(uuids))"
         case let .accessory(manufacturer, service):
             "accessory(company: \(manufacturer), advertised: \(service))"
         }
diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift
index 14bb54ae..f47d45d1 100644
--- a/Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift
+++ b/Sources/SpeziBluetooth/CoreBluetooth/Configuration/ServiceDescription.swift
@@ -15,8 +15,8 @@ public struct ServiceDescription: Sendable {
     public let serviceId: BTUUID
     /// The description of characteristics present on the service.
     ///
-    /// Those are the characteristics we try to discover. If empty, we discover all characteristics
-    /// on a given service.
+    /// Those are the characteristics we try to discover.
+    /// - Note: If `nil`, we discover all characteristics on a given service.
     public var characteristics: Set<CharacteristicDescription>? { // swiftlint:disable:this discouraged_optional_collection
         let values: Dictionary<BTUUID, CharacteristicDescription>.Values? = _characteristics?.values
         return values.map { Set($0) }
diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Extensions/CBCharacteristic+DebugIdentifier.swift b/Sources/SpeziBluetooth/CoreBluetooth/Extensions/CBCharacteristic+DebugIdentifier.swift
deleted file mode 100644
index bcb10da2..00000000
--- a/Sources/SpeziBluetooth/CoreBluetooth/Extensions/CBCharacteristic+DebugIdentifier.swift
+++ /dev/null
@@ -1,21 +0,0 @@
-//
-// This source file is part of the Stanford Spezi open-source project
-//
-// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md)
-//
-// SPDX-License-Identifier: MIT
-//
-
-import CoreBluetooth
-
-
-// CustomDebugStringConvertible is already implemented for NSObjects. So we just define a custom property
-extension CBCharacteristic {
-    var debugIdentifier: String {
-        if let service {
-            "\(uuid)@\(service)"
-        } else {
-            "\(uuid)"
-        }
-    }
-}
diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/AdvertisementData.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/AdvertisementData.swift
index a5d89b85..b50e1f37 100644
--- a/Sources/SpeziBluetooth/CoreBluetooth/Model/AdvertisementData.swift
+++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/AdvertisementData.swift
@@ -95,4 +95,37 @@ extension AdvertisementData {
 }
 
 
+extension AdvertisementData: CustomStringConvertible {
+    public var description: String {
+        var components: [String] = []
+        if let localName {
+            components.append("localName: \"\(localName)\"")
+        }
+        if let manufacturerData {
+            components.append("manufacturerData: \"\(manufacturerData)\"")
+        }
+        if let serviceData {
+            components.append("serviceData: \(serviceData)")
+        }
+        if let serviceUUIDs {
+            components.append("serviceUUIDs: \(serviceUUIDs)")
+        }
+        if let overflowServiceUUIDs {
+            components.append("overflowServiceUUIDs: \(overflowServiceUUIDs)")
+        }
+        if let txPowerLevel {
+            components.append("txPowerLevel: \(txPowerLevel)")
+        }
+        if let isConnectable {
+            components.append("isConnectable: \(isConnectable)")
+        }
+        if let solicitedServiceUUIDs {
+            components.append("solicitedServiceUUIDs: \(solicitedServiceUUIDs)")
+        }
+
+        return "AdvertisementData(\(components.joined(separator: ", ")))"
+    }
+}
+
+
 extension AdvertisementData: Sendable, Hashable {}
diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothManagerStorage.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothManagerStorage.swift
index 1f207144..a677990b 100644
--- a/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothManagerStorage.swift
+++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothManagerStorage.swift
@@ -151,7 +151,7 @@ extension BluetoothManagerStorage {
                     guard let self = self else {
                         return
                     }
-                    Task.detached { @SpeziBluetooth in
+                    Task.detached { @Sendable @SpeziBluetooth in
                         self.unsubscribe(for: id)
                     }
                 }
diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccesses.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccesses.swift
index 4b7e77e0..6daf8373 100644
--- a/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccesses.swift
+++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccesses.swift
@@ -10,28 +10,20 @@ import CoreBluetooth
 import Foundation
 import SpeziFoundation
 
-
 @SpeziBluetooth
 class CharacteristicAccess: Sendable {
     enum Access {
         case read(CheckedContinuation<Data, Error>)
         case write(CheckedContinuation<Void, Error>)
+        case notify(CheckedContinuation<Void, Error>)
     }
 
 
-    private let id: BTUUID
-    private let semaphore = AsyncSemaphore()
+    fileprivate let semaphore = AsyncSemaphore()
     private(set) var value: Access?
 
 
-    fileprivate init(id: BTUUID) {
-        self.id = id
-    }
-
-
-    func waitCheckingCancellation() async throws {
-        try await semaphore.waitCheckingCancellation()
-    }
+    fileprivate init() {}
 
     func store(_ value: Access) {
         precondition(self.value == nil, "Access was unexpectedly not nil")
@@ -51,7 +43,7 @@ class CharacteristicAccess: Sendable {
         switch access {
         case let .read(continuation):
             continuation.resume(throwing: error ?? CancellationError())
-        case let .write(continuation):
+        case let .write(continuation), let .notify(continuation):
             continuation.resume(throwing: error ?? CancellationError())
         case .none:
             break
@@ -61,25 +53,89 @@ class CharacteristicAccess: Sendable {
 
 
 @SpeziBluetooth
-struct CharacteristicAccesses: Sendable {
+final class CharacteristicAccesses: Sendable {
     private var ongoingAccesses: [CBCharacteristic: CharacteristicAccess] = [:]
 
-    mutating func makeAccess(for characteristic: CBCharacteristic) -> CharacteristicAccess {
+    private func makeAccess(for characteristic: CBCharacteristic) -> CharacteristicAccess {
         let access: CharacteristicAccess
         if let existing = ongoingAccesses[characteristic] {
             access = existing
         } else {
-            access = CharacteristicAccess(id: BTUUID(from: characteristic.uuid))
+            access = CharacteristicAccess()
             self.ongoingAccesses[characteristic] = access
         }
         return access
     }
 
-    func retrieveAccess(for characteristic: CBCharacteristic) -> CharacteristicAccess? {
-        ongoingAccesses[characteristic]
+    private func perform<Value>(
+        for characteristic: CBCharacteristic,
+        returning value: Value.Type = Void.self,
+        action: () -> Void,
+        mapping: (CheckedContinuation<Value, Error>) -> CharacteristicAccess.Access
+    ) async throws -> Value {
+        let access = makeAccess(for: characteristic)
+
+        try await access.semaphore.waitCheckingCancellation()
+        return try await withCheckedThrowingContinuation { continuation in
+            access.store(mapping(continuation))
+            action()
+        }
+    }
+
+    func performRead(for characteristic: CBCharacteristic, action: () -> Void) async throws -> Data {
+        try await self.perform(for: characteristic, returning: Data.self, action: action) { continuation in
+            .read(continuation)
+        }
+    }
+
+    func performWrite(for characteristic: CBCharacteristic, action: () -> Void) async throws {
+        try await self.perform(for: characteristic, action: action) { continuation in
+            .write(continuation)
+        }
+    }
+
+    func performNotify(for characteristic: CBCharacteristic, action: () -> Void) async throws {
+        try await self.perform(for: characteristic, action: action) { continuation in
+            .notify(continuation)
+        }
     }
 
-    mutating func cancelAll(disconnectError error: (any Error)?) {
+
+    @discardableResult
+    func resumeRead(with result: Result<Data, Error>, for characteristic: CBCharacteristic) -> Bool {
+        guard let access = ongoingAccesses[characteristic],
+              case let .read(continuation) = access.value else {
+            return false
+        }
+
+        access.consume()
+        continuation.resume(with: result)
+        return true
+    }
+
+    func resumeWrite(with result: Result<Void, Error>, for characteristic: CBCharacteristic) -> Bool {
+        guard let access = ongoingAccesses[characteristic],
+              case let .write(continuation) = access.value else {
+            return false
+        }
+
+        access.consume()
+        continuation.resume(with: result)
+        return true
+    }
+
+    func resumeNotify(with result: Result<Void, Error>, for characteristic: CBCharacteristic) -> Bool {
+        guard let access = ongoingAccesses[characteristic],
+              case let .notify(continuation) = access.value else {
+            return false
+        }
+
+        access.consume()
+        continuation.resume(with: result)
+        return true
+    }
+
+    func cancelAll(disconnectError error: (any Error)?) {
         let accesses = ongoingAccesses
         ongoingAccesses.removeAll()
 
diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/DiscoverySession.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/DiscoverySession.swift
index 40cdaf19..a454e3c5 100644
--- a/Sources/SpeziBluetooth/CoreBluetooth/Model/DiscoverySession.swift
+++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/DiscoverySession.swift
@@ -126,8 +126,8 @@ class DiscoverySession: Sendable {
     /// The set of serviceIds we request to discover upon scanning.
     /// Returning nil means scanning for all peripherals.
     var serviceDiscoveryIds: [BTUUID]? { // swiftlint:disable:this discouraged_optional_collection
-        let discoveryIds = configuration.configuredDevices.compactMap { configuration in
-            configuration.discoveryCriteria.discoveryId
+        let discoveryIds = configuration.configuredDevices.flatMap { configuration in
+            configuration.discoveryCriteria.discoveryIds
         }
 
         return discoveryIds.isEmpty ? nil : discoveryIds
@@ -321,7 +321,7 @@ extension DiscoverySession {
             }
 
         for device in staleDevices {
-            logger.debug("Removing stale peripheral \(device.debugDescription)")
+            logger.debug("Removing stale peripheral \(device)")
             // we know it won't be connected, therefore we just need to remove it
             manager.clearDiscoveredPeripheral(forKey: device.id)
         }
diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTCharacteristic.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTCharacteristic.swift
index 9128cc59..2d570901 100644
--- a/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTCharacteristic.swift
+++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTCharacteristic.swift
@@ -37,7 +37,7 @@ struct GATTCharacteristicCapture: Sendable {
 /// ## Topics
 ///
 /// ### Instance Properties
-/// - ``uuid``
+/// - ``id``
 /// - ``value``
 /// - ``isNotifying``
 /// - ``properties``
@@ -58,7 +58,7 @@ public final class GATTCharacteristic {
     public private(set) var descriptors: [CBDescriptor]? // swiftlint:disable:this discouraged_optional_collection
 
     /// The Bluetooth UUID of the characteristic.
-    public var uuid: BTUUID {
+    public var id: BTUUID {
         BTUUID(data: underlyingCharacteristic.uuid.data)
     }
 
@@ -114,9 +114,16 @@ public final class GATTCharacteristic {
 extension GATTCharacteristic {}
 
 
-extension GATTCharacteristic: CustomDebugStringConvertible {
+extension GATTCharacteristic: Identifiable {}
+
+
+extension GATTCharacteristic: CustomStringConvertible, CustomDebugStringConvertible {
+    public var description: String {
+        "Characteristic(id: \(id), properties: \(properties), \(value.map { "value: \($0), " } ?? "")isNotifying, \(isNotifying))"
+    }
+
     public var debugDescription: String {
-        underlyingCharacteristic.debugIdentifier
+        description
     }
 }
 
diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTService.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTService.swift
index 39bca035..806a484b 100644
--- a/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTService.swift
+++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTService.swift
@@ -24,7 +24,7 @@ struct GATTServiceCapture: Sendable {
 /// ## Topics
 ///
 /// ### Instance Properties
-/// - ``uuid``
+/// - ``id``
 /// - ``isPrimary``
 /// - ``characteristics``
 @Observable
@@ -34,7 +34,7 @@ public final class GATTService {
     private var _characteristics: [BTUUID: GATTCharacteristic]
 
     /// The Bluetooth UUID of the service.
-    public var uuid: BTUUID {
+    public var id: BTUUID {
         BTUUID(data: underlyingService.uuid.data)
     }
 
@@ -66,9 +66,7 @@ public final class GATTService {
     /// - Parameter id: The Bluetooth characteristic id.
     /// - Returns: The characteristic instance if present.
     public func getCharacteristic(id: BTUUID) -> GATTCharacteristic? {
-        characteristics.first { characteristics in
-            characteristics.uuid == id
-        }
+        _characteristics[id]
     }
 
     /// Signal from the BluetoothManager to update your stored representations.
@@ -104,6 +102,20 @@ public final class GATTService {
 }
 
 
+extension GATTService: Identifiable {}
+
+
+extension GATTService: CustomStringConvertible, CustomDebugStringConvertible {
+    public var description: String {
+        "Service(id: \(id), isPrimary: \(isPrimary))"
+    }
+
+    public var debugDescription: String {
+        description
+    }
+}
+
+
 extension GATTService: Hashable {
     public static func == (lhs: GATTService, rhs: GATTService) -> Bool {
         lhs.underlyingService == rhs.underlyingService
diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/ManagedAsynchronousAccess.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/ManagedAsynchronousAccess.swift
new file mode 100644
index 00000000..89719975
--- /dev/null
+++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/ManagedAsynchronousAccess.swift
@@ -0,0 +1,114 @@
+//
+// 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<Value, E: Error> {
+    private let access: AsyncSemaphore
+    private var continuation: CheckedContinuation<Value, E>?
+
+    var isRunning: Bool {
+        continuation != nil
+    }
+
+    init(_ value: Int = 1) {
+        self.access = AsyncSemaphore(value: value)
+    }
+
+#if compiler(>=6)
+    @discardableResult
+    func resume(with result: sending Result<Value, E>) -> 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<Value, E>) -> 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 d727e14c..4fa8392e 100644
--- a/Sources/SpeziBluetooth/CoreBluetooth/Model/OnChangeRegistration.swift
+++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/OnChangeRegistration.swift
@@ -43,7 +43,7 @@ public final class OnChangeRegistration {
         let locator = locator
         let handlerId = handlerId
 
-        Task.detached { @SpeziBluetooth in
+        Task.detached { @Sendable @SpeziBluetooth in
             peripheral?.deregisterOnChange(locator: locator, handlerId: handlerId)
         }
     }
diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift
index 01f29eb2..d96d8192 100644
--- a/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift
+++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift
@@ -36,7 +36,7 @@ final class PeripheralStorage: ValueObservable, Sendable {
     }
 
 
-    @SpeziBluetooth var services: [GATTService]? { // swiftlint:disable:this discouraged_optional_collection
+    @SpeziBluetooth var services: [BTUUID: GATTService]? { // swiftlint:disable:this discouraged_optional_collection
         didSet {
             _$simpleRegistrar.triggerDidChange(for: \.services, on: self)
         }
diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/SpeziBluetoothActor.swift b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/SpeziBluetoothActor.swift
index 474e9a69..2efb035b 100644
--- a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/SpeziBluetoothActor.swift
+++ b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/SpeziBluetoothActor.swift
@@ -11,7 +11,7 @@ import Foundation
 
 private struct SpeziBluetoothDispatchQueueKey: Sendable, Hashable {
     static let shared = SpeziBluetoothDispatchQueueKey()
-    static nonisolated(unsafe) let key = DispatchSpecificKey<Self>()
+    static let key = DispatchSpecificKey<Self>()
     private init() {}
 }
 
diff --git a/Sources/SpeziBluetooth/Model/Actions/DeviceActions.swift b/Sources/SpeziBluetooth/Model/Actions/DeviceActions.swift
index 2cc7cee3..44fed2a6 100644
--- a/Sources/SpeziBluetooth/Model/Actions/DeviceActions.swift
+++ b/Sources/SpeziBluetooth/Model/Actions/DeviceActions.swift
@@ -28,6 +28,9 @@
 public struct DeviceActions {
     /// Connect to the Bluetooth peripheral.
     ///
+    /// Make a connection to the peripheral. The method returns once the device is connected and fully discovered.
+    /// If service or characteristic discovery fails, this action will throw the respective error and automatically disconnect the device.
+    ///
     /// This action makes a call to ``BluetoothPeripheral/connect()``.
     public var connect: BluetoothConnectAction.Type {
         BluetoothConnectAction.self
diff --git a/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift b/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift
index bd436e48..f1de5a05 100644
--- a/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift
+++ b/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift
@@ -162,8 +162,41 @@ import Foundation
 public struct Characteristic<Value: Sendable>: Sendable {
     /// Storage unit for the property wrapper.
     final class Storage: Sendable {
+        enum DefaultNotifyState: UInt8, AtomicValue {
+            case disabled
+            case enabled
+            case collectedDisabled
+            case collectedEnabled
+
+            var defaultNotify: Bool {
+                switch self {
+                case .disabled, .collectedDisabled:
+                    return false
+                case .enabled, .collectedEnabled:
+                    return true
+                }
+            }
+
+            var completed: Bool {
+                switch self {
+                case .disabled, .enabled:
+                    false
+                case .collectedDisabled, .collectedEnabled:
+                    true
+                }
+            }
+
+            init(from defaultNotify: Bool) {
+                self = defaultNotify ? .enabled : .disabled
+            }
+
+            static func collected(notify: Bool) -> DefaultNotifyState {
+                notify ? .collectedEnabled : .collectedDisabled
+            }
+        }
+
         let id: BTUUID
-        let defaultNotify: ManagedAtomic<Bool>
+        let defaultNotify: ManagedAtomic<DefaultNotifyState>
         let autoRead: ManagedAtomic<Bool>
 
         let injection = ManagedAtomicLazyReference<CharacteristicPeripheralInjection<Value>>()
@@ -173,7 +206,7 @@ public struct Characteristic<Value: Sendable>: Sendable {
 
         init(id: BTUUID, defaultNotify: Bool, autoRead: Bool, initialValue: Value?) {
             self.id = id
-            self.defaultNotify = ManagedAtomic(defaultNotify)
+            self.defaultNotify = ManagedAtomic(DefaultNotifyState(from: defaultNotify))
             self.autoRead = ManagedAtomic(autoRead)
             self.state = State(initialValue: initialValue)
         }
@@ -288,7 +321,27 @@ public struct Characteristic<Value: Sendable>: Sendable {
 
         storage.state.characteristic = service?.getCharacteristic(id: storage.id)
 
-        injection.setup(defaultNotify: storage.defaultNotify.load(ordering: .acquiring))
+#if compiler(<6)
+        var defaultNotify: Bool = false
+#else
+        let defaultNotify: Bool
+#endif
+        while true {
+            let notifyState = storage.defaultNotify.load(ordering: .acquiring)
+            let notify = notifyState.defaultNotify
+
+            let (exchanged, _) = storage.defaultNotify.compareExchange(
+                expected: notifyState,
+                desired: .collected(notify: notify),
+                ordering: .acquiringAndReleasing
+            )
+            if exchanged {
+                defaultNotify = notify
+                break
+            }
+        }
+
+        injection.setup(defaultNotify: defaultNotify)
     }
 }
 
diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift
index 2e5e8528..a0fca730 100644
--- a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift
+++ b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift
@@ -110,22 +110,20 @@ extension CharacteristicAccessor where Value: ByteDecodable {
     ///
     /// Register a change handler with the characteristic that is called every time the value changes.
     ///
-    /// - Note: `onChange` handlers are bound to the lifetime of the device. If you need to control the lifetime yourself refer to using ``subscription``.
+    /// - Note: An `onChange` handler is bound to the lifetime of the device. If you need to control the lifetime yourself refer to using ``subscription``.
     ///
     /// Note that you cannot set up onChange handlers within the initializers.
     /// Use the [`configure()`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module/configure()-5pa83) to set up
     /// all your handlers.
-    /// - Important: You must capture `self` weakly only. Capturing `self` strongly causes a memory leak.
-    ///
-    /// - Note: This closure is called from the ``SpeziBluetooth/SpeziBluetooth`` global actor, if you don't pass in an async method
-    ///     that has an annotated actor isolation (e.g., `@MainActor` or actor isolated methods).
+    /// - Warning: You must capture `self` weakly only. Capturing `self` strongly causes a memory leak.
     ///
     /// - Parameters:
     ///     - initial: Whether the action should be run with the initial characteristic value.
-    ///     Otherwise, the action will only run strictly if the value changes.
+    ///         Otherwise, the action will only run strictly if the value changes.
+    ///         > Important: This parameter has no effect for notify-only characteristics. A initial value will only be read if the characteristic supports read accesses.
     ///     - action: The change handler to register.
     public func onChange(initial: Bool = false, perform action: @escaping @Sendable (_ value: Value) async -> Void) {
-        onChange(initial: initial) { _, newValue in
+        onChange(initial: initial) { @SpeziBluetooth _, newValue in
             await action(newValue)
         }
     }
@@ -134,19 +132,17 @@ extension CharacteristicAccessor where Value: ByteDecodable {
     ///
     /// Register a change handler with the characteristic that is called every time the value changes.
     ///
-    /// - Note: `onChange` handlers are bound to the lifetime of the device. If you need to control the lifetime yourself refer to using ``subscription``.
+    /// - Note: An `onChange` handler is bound to the lifetime of the device. If you need to control the lifetime yourself refer to using ``subscription``.
     ///
     /// Note that you cannot set up onChange handlers within the initializers.
     /// Use the [`configure()`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module/configure()-5pa83) to set up
     /// all your handlers.
-    /// - Important: You must capture `self` weakly only. Capturing `self` strongly causes a memory leak.
-    ///
-    /// - Note: This closure is called from the ``SpeziBluetooth/SpeziBluetooth`` global actor, if you don't pass in an async method
-    ///     that has an annotated actor isolation (e.g., `@MainActor` or actor isolated methods).
+    /// - Warning: You must capture `self` weakly only. Capturing `self` strongly causes a memory leak.
     ///
     /// - Parameters:
     ///     - initial: Whether the action should be run with the initial characteristic value.
-    ///     Otherwise, the action will only run strictly if the value changes.
+    ///         Otherwise, the action will only run strictly if the value changes.
+    ///         > Important: This parameter has no effect for notify-only characteristics. A initial value will only be read if the characteristic supports read accesses.
     ///     - action: The change handler to register, receiving both the old and new value.
     public func onChange(initial: Bool = false, perform action: @escaping @Sendable (_ oldValue: Value, _ newValue: Value) async -> Void) {
         if let subscriptions = storage.testInjections.load()?.subscriptions {
@@ -182,13 +178,30 @@ extension CharacteristicAccessor where Value: ByteDecodable {
     /// Enable or disable characteristic notifications.
     /// - Parameter enabled: Flag indicating if notifications should be enabled.
     public func enableNotifications(_ enabled: Bool = true) async {
-        guard let injection = storage.injection.load() else { // load always reads with acquire order
-            // this value will be populated to the injection once it is set up
-            storage.defaultNotify.store(enabled, ordering: .releasing)
-            return
-        }
+        while true {
+            guard let injection = storage.injection.load() else { // load always reads with acquire order
+                // We use the `defaultNotify` storage to temporary store the enableNotifications value.
+                // This value will be populated to the injection once it is set up
+
+                let state = storage.defaultNotify.load(ordering: .acquiring)
+                if state.completed {
+                    continue // retry loading the injection, it should be present now
+                }
 
-        await injection.enableNotifications(enabled)
+                let (exchanged, _) = storage.defaultNotify.compareExchange(
+                    expected: state,
+                    desired: .init(from: enabled),
+                    ordering: .acquiringAndReleasing
+                )
+                if !exchanged {
+                    continue // retry, value changed while we were setting it
+                }
+                return
+            }
+
+            await injection.enableNotifications(enabled)
+            break
+        }
     }
 
 
diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift
index 481220e0..1eeac52a 100644
--- a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift
+++ b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift
@@ -103,9 +103,8 @@ class CharacteristicPeripheralInjection<Value: Sendable>: Sendable {
     ) {
         let id = subscriptions.newOnChangeSubscription(perform: action)
 
-        // Must be called detached, otherwise it might inherit TaskLocal values which includes Spezi moduleInitContext
-        // which would create a strong reference to the device.
-        Task.detached { @SpeziBluetooth in
+        // avoid accidentally inheriting any task local values
+        Task.detached { @Sendable @SpeziBluetooth in
             await self.handleInitialCall(id: id, initial: initial, action: action)
         }
     }
@@ -138,9 +137,7 @@ class CharacteristicPeripheralInjection<Value: Sendable>: Sendable {
 
     private func registerCharacteristicValueChanges() {
         self.valueRegistration = peripheral.registerOnChangeHandler(service: serviceId, characteristic: characteristicId) { [weak self] data in
-            Task {@SpeziBluetooth [weak self] in
-                self?.handleUpdatedValue(data)
-            }
+            self?.handleUpdatedValue(data)
         }
     }
 
@@ -199,8 +196,14 @@ extension CharacteristicPeripheralInjection: DecodableCharacteristic where Value
             state.value = value
             self.fullFillControlPointRequest(value)
 
-            self.subscriptions.notifySubscribers(with: value, ignoring: nonInitialChangeHandlers ?? [])
-            nonInitialChangeHandlers = nil
+            // The first value is not always the initial value
+            // e.g., error retrieving the initial value, or notify characteristics that don't support read!
+            if let nonInitialChangeHandlers, peripheral.isReadingInitialValue(for: characteristicId, on: serviceId) {
+                self.nonInitialChangeHandlers = nil
+                self.subscriptions.notifySubscribers(with: value, ignoring: nonInitialChangeHandlers)
+            } else {
+                self.subscriptions.notifySubscribers(with: value)
+            }
         } else {
             state.value = nil
         }
diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift
index 56ad710f..fb4b3e16 100644
--- a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift
+++ b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift
@@ -84,7 +84,10 @@ extension DeviceStateAccessor {
     ///     - initial: Whether the action should be run with the initial state value. Otherwise, the action will only run
     ///     strictly if the value changes.
     ///     - action: The change handler to register, receiving both the old and new value.
-    public func onChange(initial: Bool = false, perform action: @escaping @Sendable (_ oldValue: Value, _ newValue: Value) async -> Void) {
+    public func onChange(
+        initial: Bool = false,
+        perform action: @escaping @Sendable (_ oldValue: Value, _ newValue: Value) async -> Void
+    ) {
         if let testInjections = storage.testInjections.load(),
            let subscriptions = testInjections.subscriptions {
             let id = subscriptions.newOnChangeSubscription(perform: action)
diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift
index d56b7c07..962e0054 100644
--- a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift
+++ b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStatePeripheralInjection.swift
@@ -55,7 +55,7 @@ class DeviceStatePeripheralInjection<Value: Sendable>: Sendable {
 
     nonisolated func newOnChangeSubscription(
         initial: Bool,
-        perform action: @escaping @Sendable (_ oldValue: Value, _ newValue: Value) async -> Void
+        perform action: @escaping @Sendable @SpeziBluetooth (_ oldValue: Value, _ newValue: Value) async -> Void
     ) {
         let id = subscriptions.newOnChangeSubscription(perform: action)
 
diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/ServicePeripheralInjection.swift b/Sources/SpeziBluetooth/Model/PropertySupport/ServicePeripheralInjection.swift
index 06ec5ea2..c6757b5c 100644
--- a/Sources/SpeziBluetooth/Model/PropertySupport/ServicePeripheralInjection.swift
+++ b/Sources/SpeziBluetooth/Model/PropertySupport/ServicePeripheralInjection.swift
@@ -36,7 +36,7 @@ class ServicePeripheralInjection<S: BluetoothService>: Sendable {
     private func trackServicesUpdate() {
         peripheral.onChange(of: \.services) { [weak self] services in
             guard let self = self,
-                  let service = services?.first(where: { $0.uuid == self.serviceId }) else {
+                  let service = services?[self.serviceId] else {
                 return
             }
 
diff --git a/Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift b/Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift
index f6bc050a..379ae5c3 100644
--- a/Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift
+++ b/Sources/SpeziBluetooth/Utils/ChangeSubscriptions.swift
@@ -63,13 +63,13 @@ final class ChangeSubscriptions<Value: Sendable>: Sendable {
     }
 
     @discardableResult
-    nonisolated func newOnChangeSubscription(perform action: @escaping @Sendable (_ oldValue: Value, _ newValue: Value) async -> Void) -> UUID {
+    nonisolated func newOnChangeSubscription(
+        perform action: @escaping @Sendable (_ oldValue: Value, _ newValue: Value) async -> Void
+    ) -> UUID {
         let registration = _newSubscription()
 
-        // It's important to use a detached Task here.
-        // Otherwise it might inherit TaskLocal values which might include Spezi moduleInitContext
-        // which would create a strong reference to the device.
-        Task.detached { @SpeziBluetooth [weak self] in
+        // avoid accidentally inheriting any task local values
+        Task.detached { @Sendable @SpeziBluetooth [weak self] in
             var currentValue: Value?
 
             for await element in registration.subscription {
diff --git a/Sources/SpeziBluetoothServices/Characteristics/BloodPressureFeature.swift b/Sources/SpeziBluetoothServices/Characteristics/BloodPressureFeature.swift
index 136d7008..8591de97 100644
--- a/Sources/SpeziBluetoothServices/Characteristics/BloodPressureFeature.swift
+++ b/Sources/SpeziBluetoothServices/Characteristics/BloodPressureFeature.swift
@@ -67,6 +67,45 @@ public struct BloodPressureFeature: OptionSet {
 extension BloodPressureFeature: Hashable, Sendable {}
 
 
+extension BloodPressureFeature: CustomStringConvertible, CustomDebugStringConvertible {
+    public var description: String {
+        var components: [String] = []
+        if contains(.bodyMovementDetectionSupported) {
+            components.append("bodyMovementDetectionSupported")
+        }
+        if contains(.cuffFitDetectionSupported) {
+            components.append("cuffFitDetectionSupported")
+        }
+        if contains(.irregularPulseDetectionSupported) {
+            components.append("irregularPulseDetectionSupported")
+        }
+        if contains(.pulseRateRangeDetectionSupported) {
+            components.append("pulseRateRangeDetectionSupported")
+        }
+        if contains(.measurementPositionDetectionSupported) {
+            components.append("measurementPositionDetectionSupported")
+        }
+        if contains(.multipleBondsSupported) {
+            components.append("multipleBondsSupported")
+        }
+        if contains(.e2eCrcSupported) {
+            components.append("e2eCrcSupported")
+        }
+        if contains(.userDataServiceSupported) {
+            components.append("userDataServiceSupported")
+        }
+        if contains(.userFacingTimeSupported) {
+            components.append("userFacingTimeSupported")
+        }
+        return "[\(components.joined(separator: ", "))]"
+    }
+
+    public var debugDescription: String {
+        "\(Self.self)(rawValue: 0x\(String(format: "%02X", rawValue)))"
+    }
+}
+
+
 extension BloodPressureFeature: ByteCodable {
     public init?(from byteBuffer: inout ByteBuffer) {
         guard let rawValue = UInt16(from: &byteBuffer) else {
diff --git a/Sources/SpeziBluetoothServices/Characteristics/BloodPressureMeasurement.swift b/Sources/SpeziBluetoothServices/Characteristics/BloodPressureMeasurement.swift
index 3e364b61..8e4f544a 100644
--- a/Sources/SpeziBluetoothServices/Characteristics/BloodPressureMeasurement.swift
+++ b/Sources/SpeziBluetoothServices/Characteristics/BloodPressureMeasurement.swift
@@ -144,6 +144,36 @@ extension BloodPressureMeasurement.Status: Sendable, Hashable {}
 extension BloodPressureMeasurement: Sendable, Hashable {}
 
 
+extension BloodPressureMeasurement.Status: CustomStringConvertible, CustomDebugStringConvertible {
+    public var description: String {
+        var components: [String] = []
+        if contains(.bodyMovementDetected) {
+            components.append("bodyMovementDetected")
+        }
+        if contains(.looseCuffFit) {
+            components.append("looseCuffFit")
+        }
+        if contains(.irregularPulse) {
+            components.append("irregularPulse")
+        }
+        if contains(.pulseRateExceedsUpperLimit) {
+            components.append("pulseRateExceedsUpperLimit")
+        }
+        if contains(.pulseRateBelowLowerLimit) {
+            components.append("pulseRateBelowLowerLimit")
+        }
+        if contains(.improperMeasurementPosition) {
+            components.append("improperMeasurementPosition")
+        }
+        return "[\(components.joined(separator: ", "))]"
+    }
+
+    public var debugDescription: String {
+        "\(Self.self)(rawValue: 0x\(String(format: "%02X", rawValue)))"
+    }
+}
+
+
 extension BloodPressureMeasurement.Flags: ByteCodable {
     init?(from byteBuffer: inout ByteBuffer) {
         guard let rawValue = UInt8(from: &byteBuffer) else {
diff --git a/Sources/SpeziBluetoothServices/Characteristics/TemperatureType.swift b/Sources/SpeziBluetoothServices/Characteristics/TemperatureType.swift
index 2ca84c3e..7ea7e7fe 100644
--- a/Sources/SpeziBluetoothServices/Characteristics/TemperatureType.swift
+++ b/Sources/SpeziBluetoothServices/Characteristics/TemperatureType.swift
@@ -52,6 +52,36 @@ extension TemperatureType: RawRepresentable {}
 extension TemperatureType: Hashable, Sendable {}
 
 
+extension TemperatureType: CustomStringConvertible {
+    public var description: String {
+        switch self {
+        case .reserved:
+            "reserved"
+        case .armpit:
+            "armpit"
+        case .body:
+            "body"
+        case .ear:
+            "ear"
+        case .finger:
+            "finger"
+        case .gastrointestinalTract:
+            "gastrointestinalTract"
+        case .mouth:
+            "mouth"
+        case .rectum:
+            "rectum"
+        case .toe:
+            "toe"
+        case .tympanum:
+            "tympanum"
+        default:
+            "\(Self.self)(rawValue: \(rawValue))"
+        }
+    }
+}
+
+
 extension TemperatureType: ByteCodable {
     public init?(from byteBuffer: inout ByteBuffer) {
         guard let value = UInt8(from: &byteBuffer) else {
diff --git a/Sources/SpeziBluetoothServices/Characteristics/Time/CurrentTime.swift b/Sources/SpeziBluetoothServices/Characteristics/Time/CurrentTime.swift
index fff223b4..fe417484 100644
--- a/Sources/SpeziBluetoothServices/Characteristics/Time/CurrentTime.swift
+++ b/Sources/SpeziBluetoothServices/Characteristics/Time/CurrentTime.swift
@@ -55,6 +55,30 @@ public struct CurrentTime {
 extension CurrentTime.AdjustReason: Hashable, Sendable {}
 
 
+extension CurrentTime.AdjustReason: CustomStringConvertible, CustomDebugStringConvertible {
+    public var description: String {
+        var components: [String] = []
+        if contains(.manualTimeUpdate) {
+            components.append("manualTimeUpdate")
+        }
+        if contains(.externalReferenceTimeUpdate) {
+            components.append("externalReferenceTimeUpdate")
+        }
+        if contains(.changeOfTimeZone) {
+            components.append("changeOfTimeZone")
+        }
+        if contains(.changeOfDST) {
+            components.append("changeOfDST")
+        }
+        return "[\(components.joined(separator: ", "))]"
+    }
+
+    public var debugDescription: String {
+        "\(Self.self)(rawValue: 0x\(String(format: "%02X", rawValue)))"
+    }
+}
+
+
 extension CurrentTime: Hashable, Sendable {}
 
 
diff --git a/Sources/SpeziBluetoothServices/Characteristics/Time/DateTime.swift b/Sources/SpeziBluetoothServices/Characteristics/Time/DateTime.swift
index 5a280338..08034595 100644
--- a/Sources/SpeziBluetoothServices/Characteristics/Time/DateTime.swift
+++ b/Sources/SpeziBluetoothServices/Characteristics/Time/DateTime.swift
@@ -173,6 +173,42 @@ extension DateTime.Month: RawRepresentable {}
 extension DateTime.Month: Hashable, Sendable {}
 
 
+extension DateTime.Month: CustomStringConvertible {
+    public var description: String {
+        switch self {
+        case .unknown:
+            "unknown"
+        case .january:
+            "january"
+        case .february:
+            "february"
+        case .march:
+            "march"
+        case .april:
+            "april"
+        case .mai:
+            "mai"
+        case .june:
+            "june"
+        case .july:
+            "july"
+        case .august:
+            "august"
+        case .september:
+            "september"
+        case .october:
+            "october"
+        case .november:
+            "november"
+        case .december:
+            "december"
+        default:
+            "\(Self.self)(rawValue: \(rawValue))"
+        }
+    }
+}
+
+
 extension DateTime: Hashable, Sendable {}
 
 
diff --git a/Sources/SpeziBluetoothServices/Characteristics/Time/DayOfWeek.swift b/Sources/SpeziBluetoothServices/Characteristics/Time/DayOfWeek.swift
index 097ed992..29ab8642 100644
--- a/Sources/SpeziBluetoothServices/Characteristics/Time/DayOfWeek.swift
+++ b/Sources/SpeziBluetoothServices/Characteristics/Time/DayOfWeek.swift
@@ -53,6 +53,32 @@ extension DayOfWeek: RawRepresentable {}
 extension DayOfWeek: Hashable, Sendable {}
 
 
+extension DayOfWeek: CustomStringConvertible {
+    public var description: String {
+        switch self {
+        case .unknown:
+            "unknown"
+        case .monday:
+            "monday"
+        case .tuesday:
+            "tuesday"
+        case .wednesday:
+            "wednesday"
+        case .thursday:
+            "thursday"
+        case .friday:
+            "friday"
+        case .saturday:
+            "saturday"
+        case .sunday:
+            "sunday"
+        default:
+            "\(Self.self)(rawValue: \(rawValue))"
+        }
+    }
+}
+
+
 extension DayOfWeek: ByteCodable {
     public init?(from byteBuffer: inout ByteBuffer) {
         guard let rawValue = UInt8(from: &byteBuffer) else {
diff --git a/Sources/SpeziBluetoothServices/Characteristics/WeightScaleFeature.swift b/Sources/SpeziBluetoothServices/Characteristics/WeightScaleFeature.swift
index 048c4b97..75e11e5b 100644
--- a/Sources/SpeziBluetoothServices/Characteristics/WeightScaleFeature.swift
+++ b/Sources/SpeziBluetoothServices/Characteristics/WeightScaleFeature.swift
@@ -219,6 +219,27 @@ extension WeightScaleFeature.HeightResolution: Hashable, Sendable {}
 extension WeightScaleFeature: Hashable, Sendable {}
 
 
+extension WeightScaleFeature: CustomStringConvertible, CustomDebugStringConvertible {
+    public var description: String {
+        var options: [String] = []
+        if contains(.timeStampSupported) {
+            options.append("timeStampSupported")
+        }
+        if contains(.multipleUsersSupported) {
+            options.append("multipleUsersSupported")
+        }
+        if contains(.bmiSupported) {
+            options.append("bmiSupported")
+        }
+        return "\(Self.self)(weightResolution: \(weightResolution), heightResolution: \(heightResolution), options: \(options.joined(separator: ", ")))"
+    }
+
+    public var debugDescription: String {
+        "\(Self.self)(rawValue: 0x\(String(format: "%02X", rawValue)))"
+    }
+}
+
+
 extension WeightScaleFeature: ByteCodable {
     public init?(from byteBuffer: inout ByteBuffer) {
         guard let rawValue = UInt32(from: &byteBuffer) else {
diff --git a/Sources/SpeziBluetoothServices/Services/CurrentTimeService.swift b/Sources/SpeziBluetoothServices/Services/CurrentTimeService.swift
index 80c070eb..00276545 100644
--- a/Sources/SpeziBluetoothServices/Services/CurrentTimeService.swift
+++ b/Sources/SpeziBluetoothServices/Services/CurrentTimeService.swift
@@ -49,42 +49,41 @@ extension CurrentTimeService {
     /// This method checks the current time of the connected peripheral. If the current time was never set or the time difference
     /// is larger than the specified `threshold`, the peripheral time is updated to `now`.
     ///
-    /// - Note: This method expects that the ``currentTime`` characteristic is current.
+    /// - Note: This method expects that the ``currentTime`` characteristic to be present and current.
     /// - Parameters:
     ///   - now: The `Date` which is perceived as now.
     ///   - threshold: The threshold used to decide if peripheral time should be updated.
     ///     A time difference smaller than the threshold is considered current.
-    public func synchronizeDeviceTime(now: Date = .now, threshold: Duration = .seconds(1)) { // we consider 1 second difference accurate enough
+    /// - Throws: Throws the respective Bluetooth error if the write to the `currentTime` characteristic failed.
+    @SpeziBluetooth
+    public func synchronizeDeviceTime(now: Date = .now, threshold: Duration = .seconds(1)) async throws {
         // check if time update is necessary
         if let currentTime = currentTime,
            let deviceTime = currentTime.time.date {
             let difference = abs(deviceTime.timeIntervalSinceReferenceDate - now.timeIntervalSinceReferenceDate)
             if difference < threshold.timeInterval {
-                return // we consider 1 second difference accurate enough
+                return
             }
 
             Self.logger.debug("Current time difference is \(difference)s. Device time: \(String(describing: currentTime)). Updating time ...")
         } else {
             Self.logger.debug("Unknown current time (\(String(describing: self.currentTime))). Updating time ...")
         }
-
-
+        
         // update time if it isn't present or if it is outdated
-        Task {
-            let exactTime = ExactTime256(from: now)
-            do {
-                try await $currentTime.write(CurrentTime(time: exactTime))
-                Self.logger.debug("Updated device time to \(String(describing: exactTime))")
-            } catch let error as NSError {
-                if error.domain == CBATTError.errorDomain {
-                    let attError = CBATTError(_nsError: error)
-                    if attError.code == CBATTError.Code(rawValue: 0x80) {
-                        Self.logger.debug("Device ignored some date fields. Updated device time to \(String(describing: exactTime)).")
-                        return
-                    }
+        let exactTime = ExactTime256(from: now)
+        do {
+            try await $currentTime.write(CurrentTime(time: exactTime))
+            Self.logger.debug("Updated device time to \(String(describing: exactTime))")
+        } catch let error as NSError {
+            if error.domain == CBATTError.errorDomain {
+                let attError = CBATTError(_nsError: error)
+                if attError.code == CBATTError.Code(rawValue: 0x80) {
+                    Self.logger.debug("Device ignored some date fields. Updated device time to \(String(describing: exactTime)).")
+                    return
                 }
-                Self.logger.warning("Failed to update current time: \(error)")
             }
+            throw error
         }
     }
 }
diff --git a/Sources/TestPeripheral/TestPeripheral.swift b/Sources/TestPeripheral/TestPeripheral.swift
index 03e8b3ad..9d3ed965 100644
--- a/Sources/TestPeripheral/TestPeripheral.swift
+++ b/Sources/TestPeripheral/TestPeripheral.swift
@@ -45,18 +45,20 @@ final class TestPeripheral: NSObject, CBPeripheralManagerDelegate {
 
     private let queuedUpdates = QueueUpdates()
 
-    override init() {
-        super.init()
-        peripheralManager = CBPeripheralManager(delegate: self, queue: DispatchQueue.main)
-    }
+    override private init() {}
 
     static func main() {
         let peripheral = TestPeripheral()
+        peripheral.performInit()
         peripheral.logger.info("Initialized")
 
         RunLoop.main.run()
     }
 
+    private func performInit() {
+        peripheralManager = CBPeripheralManager(delegate: self, queue: DispatchQueue.main)
+    }
+
     func startAdvertising() {
         guard let testService else {
             logger.error("Service was not available after starting advertising!")
diff --git a/Tests/SpeziBluetoothServicesTests/BloodPressureTests.swift b/Tests/SpeziBluetoothServicesTests/BloodPressureTests.swift
index 5e549f03..738d8842 100644
--- a/Tests/SpeziBluetoothServicesTests/BloodPressureTests.swift
+++ b/Tests/SpeziBluetoothServicesTests/BloodPressureTests.swift
@@ -81,4 +81,42 @@ final class BloodPressureTests: XCTestCase {
         try testIdentity(from: features2)
         try testIdentity(from: features3)
     }
+
+    func testBloodPressureFeatureStrings() {
+        let features: BloodPressureFeature = [
+            .bodyMovementDetectionSupported,
+            .cuffFitDetectionSupported,
+            .irregularPulseDetectionSupported,
+            .pulseRateRangeDetectionSupported,
+            .measurementPositionDetectionSupported,
+            .multipleBondsSupported,
+            .e2eCrcSupported,
+            .userDataServiceSupported,
+            .userFacingTimeSupported
+        ]
+
+        XCTAssertEqual(
+            features.description,
+            "[bodyMovementDetectionSupported, cuffFitDetectionSupported, irregularPulseDetectionSupported, pulseRateRangeDetectionSupported, measurementPositionDetectionSupported, multipleBondsSupported, e2eCrcSupported, userDataServiceSupported, userFacingTimeSupported]"
+        ) // swiftlint:disable:previous line_length
+
+        XCTAssertEqual(features.debugDescription, "BloodPressureFeature(rawValue: 0x1FF)")
+    }
+
+    func testBloodPressureStatusStrings() {
+        let status: BloodPressureMeasurement.Status = [
+            .bodyMovementDetected,
+            .looseCuffFit,
+            .irregularPulse,
+            .pulseRateExceedsUpperLimit,
+            .pulseRateBelowLowerLimit,
+            .improperMeasurementPosition
+        ]
+
+        XCTAssertEqual(
+            status.description,
+            "[bodyMovementDetected, looseCuffFit, irregularPulse, pulseRateExceedsUpperLimit, pulseRateBelowLowerLimit, improperMeasurementPosition]"
+        )
+        XCTAssertEqual(status.debugDescription, "Status(rawValue: 0x3F)")
+    }
 }
diff --git a/Tests/SpeziBluetoothServicesTests/CurrentTimeTests.swift b/Tests/SpeziBluetoothServicesTests/CurrentTimeTests.swift
index df4334e5..ead5addf 100644
--- a/Tests/SpeziBluetoothServicesTests/CurrentTimeTests.swift
+++ b/Tests/SpeziBluetoothServicesTests/CurrentTimeTests.swift
@@ -36,7 +36,7 @@ final class CurrentTimeTests: XCTestCase {
         }
 
         let date = try XCTUnwrap(now.date)
-        service.synchronizeDeviceTime(now: date)
+        try await service.synchronizeDeviceTime(now: date)
 
         await fulfillment(of: [writeExpectation])
         try await Task.sleep(for: .milliseconds(500)) // let task complete
@@ -56,7 +56,7 @@ final class CurrentTimeTests: XCTestCase {
         }
 
         let date = try XCTUnwrap(now.date)
-        service.synchronizeDeviceTime(now: date, threshold: .seconds(8))
+        try await service.synchronizeDeviceTime(now: date, threshold: .seconds(8))
 
         await fulfillment(of: [writeExpectation])
         try await Task.sleep(for: .milliseconds(500)) // let task complete
@@ -77,7 +77,7 @@ final class CurrentTimeTests: XCTestCase {
         }
 
         let date = try XCTUnwrap(now.date)
-        service.synchronizeDeviceTime(now: date)
+        try await service.synchronizeDeviceTime(now: date)
 
         await fulfillment(of: [writeExpectation], timeout: 1)
         try await Task.sleep(for: .milliseconds(500)) // let task complete
@@ -100,6 +100,20 @@ final class CurrentTimeTests: XCTestCase {
         try testIdentity(from: DayOfWeek(rawValue: 26)) // test a reserved value
     }
 
+    func testDayOfWeekStrings() throws {
+        let expected = ["unknown", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday", "DayOfWeek(rawValue: 26)"]
+        let values = [DayOfWeek.unknown, .monday, .tuesday, .wednesday, .thursday, .friday, .saturday, .sunday, DayOfWeek(rawValue: 26)]
+        XCTAssertEqual(values.map { $0.description }, expected)
+    }
+
+    func testMonthStrings() throws {
+        // swiftlint:disable line_length
+        let expected = ["unknown", "january", "february", "march", "april", "mai", "june", "july", "august", "september", "october", "november", "december", "Month(rawValue: 23)"]
+        let values = [DateTime.Month.unknown, .january, .february, .march, .april, .mai, .june, .july, .august, .september, .october, .november, .december, .init(rawValue: 23)]
+        // swiftlint:enable line_length
+        XCTAssertEqual(values.map { $0.description }, expected)
+    }
+
     func testDayDateTime() throws {
         let dateTime = DateTime(year: 2005, month: .december, day: 27, hours: 12, minutes: 31, seconds: 40)
         try testIdentity(from: DayDateTime(dateTime: dateTime, dayOfWeek: .tuesday))
@@ -177,4 +191,10 @@ final class CurrentTimeTests: XCTestCase {
         XCTAssertEqual(exactTime.seconds, 27)
         XCTAssertEqual(exactTime.fractions256, 17)
     }
+
+    func testAdjustReasonStrings() {
+        let reasons: CurrentTime.AdjustReason = [.manualTimeUpdate, .externalReferenceTimeUpdate, .changeOfTimeZone, .changeOfDST]
+        XCTAssertEqual(reasons.description, "[manualTimeUpdate, externalReferenceTimeUpdate, changeOfTimeZone, changeOfDST]")
+        XCTAssertEqual(reasons.debugDescription, "AdjustReason(rawValue: 0x0F)")
+    }
 }
diff --git a/Tests/SpeziBluetoothServicesTests/HealthThermometerTests.swift b/Tests/SpeziBluetoothServicesTests/HealthThermometerTests.swift
index 72b5bf1d..6df4f3bc 100644
--- a/Tests/SpeziBluetoothServicesTests/HealthThermometerTests.swift
+++ b/Tests/SpeziBluetoothServicesTests/HealthThermometerTests.swift
@@ -46,4 +46,12 @@ final class HealthThermometerTests: XCTestCase {
         try testIdentity(from: TemperatureType.toe)
         try testIdentity(from: TemperatureType.tympanum)
     }
+
+    func testTemperatureTypeStrings() {
+        // swiftlint:disable line_length
+        let expected = ["reserved", "armpit", "body", "ear", "finger", "gastrointestinalTract", "mouth", "rectum", "toe", "tympanum", "TemperatureType(rawValue: 23)"]
+        let values = [TemperatureType.reserved, .armpit, .body, .ear, .finger, .gastrointestinalTract, .mouth, .rectum, .toe, .tympanum, .init(rawValue: 23)]
+        // swiftlint:enable line_length
+        XCTAssertEqual(values.map { $0.description }, expected)
+    }
 }
diff --git a/Tests/SpeziBluetoothServicesTests/WeightScaleTests.swift b/Tests/SpeziBluetoothServicesTests/WeightScaleTests.swift
index cc221d6f..fda74900 100644
--- a/Tests/SpeziBluetoothServicesTests/WeightScaleTests.swift
+++ b/Tests/SpeziBluetoothServicesTests/WeightScaleTests.swift
@@ -93,4 +93,18 @@ final class WeightMeasurementTests: XCTestCase {
         XCTAssertFalse(features2.contains(.multipleUsersSupported))
         XCTAssertFalse(features2.contains(.timeStampSupported))
     }
+
+    func testWeightScaleFeatureStrings() {
+        let features: WeightScaleFeature = [
+            .bmiSupported,
+            .multipleUsersSupported,
+            .timeStampSupported
+        ]
+
+        XCTAssertEqual(
+            features.description,
+            "WeightScaleFeature(weightResolution: WeightResolution(rawValue: 0), heightResolution: HeightResolution(rawValue: 0), options: timeStampSupported, multipleUsersSupported, bmiSupported)"
+        ) // swiftlint:disable:previous line_length
+        XCTAssertEqual(features.debugDescription, "WeightScaleFeature(rawValue: 0x07)")
+    }
 }
diff --git a/Tests/UITests/TestApp/RetrievePairedDevicesView.swift b/Tests/UITests/TestApp/RetrievePairedDevicesView.swift
index 7f8e3532..5faa8dec 100644
--- a/Tests/UITests/TestApp/RetrievePairedDevicesView.swift
+++ b/Tests/UITests/TestApp/RetrievePairedDevicesView.swift
@@ -21,7 +21,7 @@ struct RetrievePairedDevicesView: View {
     @State private var viewState: ViewState = .idle
 
     var body: some View {
-        Group { // swiftlint:disable:this closure_body_length
+        Group {
             if let pairedDeviceId {
                 List {
                     Section {
@@ -33,30 +33,8 @@ struct RetrievePairedDevicesView: View {
                                 Text(retrievedDevice.state.description)
                             }
                         }
-                        AsyncButton("Unpair Device") {
-                            await retrievedDevice?.disconnect()
-                            retrievedDevice = nil
-                            self.pairedDeviceId = nil
-                        }
-                        if let retrievedDevice {
-                            let state = retrievedDevice.state
 
-                            if state == .disconnected || state == .connecting {
-                                AsyncButton("Connect Device", state: $viewState) {
-                                    try await retrievedDevice.connect()
-                                }
-                            }
-
-                            if state == .connecting || state == .connected || state == .disconnecting {
-                                AsyncButton("Disconnect Device") {
-                                    await retrievedDevice.disconnect()
-                                }
-                            }
-                        } else {
-                            AsyncButton("Retrieve Device") {
-                                retrievedDevice = await bluetooth.retrieveDevice(for: pairedDeviceId)
-                            }
-                        }
+                        deviceButtons(for: pairedDeviceId)
                     }
 
                     if let retrievedDevice, case .connected = retrievedDevice.state {
@@ -79,6 +57,37 @@ struct RetrievePairedDevicesView: View {
         self._pairedDeviceId = pairedDeviceId
         self._retrievedDevice = retrievedDevice
     }
+
+
+    @ViewBuilder
+    @MainActor
+    private func deviceButtons(for pairedDeviceId: UUID) -> some View {
+        AsyncButton("Unpair Device") {
+            await retrievedDevice?.disconnect()
+            retrievedDevice = nil
+            self.pairedDeviceId = nil
+        }
+        if let retrievedDevice {
+            let state = retrievedDevice.state
+
+            if state == .disconnected || state == .connecting {
+                AsyncButton("Connect Device", state: $viewState) {
+                    try await retrievedDevice.connect()
+                }
+            }
+
+            if state == .connecting || state == .connected || state == .disconnecting {
+                AsyncButton("Disconnect Device") {
+                    await retrievedDevice.disconnect()
+                }
+            }
+        } else {
+            AsyncButton("Retrieve Device") {
+                let bluetooth = bluetooth
+                retrievedDevice = await bluetooth.retrieveDevice(for: pairedDeviceId)
+            }
+        }
+    }
 }
 
 
diff --git a/Tests/UITests/TestApp/TestApp.swift b/Tests/UITests/TestApp/TestApp.swift
index df01ab16..845eba92 100644
--- a/Tests/UITests/TestApp/TestApp.swift
+++ b/Tests/UITests/TestApp/TestApp.swift
@@ -27,6 +27,7 @@ struct DeviceCountButton: View {
     var body: some View {
         Section {
             AsyncButton("Query Count") {
+                let bluetooth = bluetooth
                 lastReadCount = await bluetooth._initializedDevicesCount()
             }
             .onDisappear {
diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj
index 1dd7629a..58e65f06 100644
--- a/Tests/UITests/UITests.xcodeproj/project.pbxproj
+++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj
@@ -373,6 +373,7 @@
 				MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
 				MTL_FAST_MATH = YES;
 				ONLY_ACTIVE_ARCH = YES;
+				OTHER_SWIFT_FLAGS = "-enable-upcoming-feature InferSendableFromCaptures";
 				SDKROOT = iphoneos;
 				SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
 				SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -431,6 +432,7 @@
 				MACOSX_DEPLOYMENT_TARGET = 14.0;
 				MTL_ENABLE_DEBUG_INFO = NO;
 				MTL_FAST_MATH = YES;
+				OTHER_SWIFT_FLAGS = "-enable-upcoming-feature InferSendableFromCaptures";
 				SDKROOT = iphoneos;
 				SWIFT_COMPILATION_MODE = wholemodule;
 				SWIFT_OPTIMIZATION_LEVEL = "-O";
@@ -632,6 +634,7 @@
 				MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
 				MTL_FAST_MATH = YES;
 				ONLY_ACTIVE_ARCH = YES;
+				OTHER_SWIFT_FLAGS = "-enable-upcoming-feature InferSendableFromCaptures";
 				SDKROOT = iphoneos;
 				SWIFT_ACTIVE_COMPILATION_CONDITIONS = TEST;
 				SWIFT_OPTIMIZATION_LEVEL = "-Onone";