diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8875e54..37ac0b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,35 @@ jobs: - name: Test run: swift test + # Compile-only gate for the four non-macOS platforms declared in Package.swift. Tests stay macOS-only + # because they need the CoreBluetoothMock harness, not a real radio — but without this leg a change + # could silently break a declared platform. + platform-build: + name: Build (${{ matrix.platform }}) + runs-on: macos-15 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + platform: [iOS, tvOS, watchOS, visionOS] + steps: + - uses: actions/checkout@v4 + + - name: Select Xcode + run: | + if [ ! -d /Applications/Xcode_16.4.app ]; then + echo "Xcode 16.4 not found. Installed Xcode versions:" + ls /Applications | grep Xcode || true + exit 1 + fi + sudo xcode-select -s /Applications/Xcode_16.4.app/Contents/Developer + + - name: Build + run: | + xcodebuild build \ + -scheme ReliaBLE \ + -destination 'generic/platform=${{ matrix.platform }}' + docc: name: DocC catalog runs-on: macos-15 @@ -61,14 +90,18 @@ jobs: # Builds the static-hosting site AND acts as the DocC validation gate on # every PR via --warnings-as-errors. The extra static-hosting flags don't # affect validation; they just shape the output for GitHub Pages. + # + # Output goes to ./user-docs, NOT ./docs. Publishing is by Pages artifact (below), so the + # directory name is arbitrary — and ./docs holds hand-written plans, designs, and reviews that + # generate-documentation would wipe out when this command is run locally. - name: Build DocC catalog run: | - swift package --allow-writing-to-directory ./docs \ + swift package --allow-writing-to-directory ./user-docs \ generate-documentation --target ReliaBLE \ --disable-indexing \ --transform-for-static-hosting \ --hosting-base-path ReliaBLE \ - --output-path ./docs \ + --output-path ./user-docs \ --warnings-as-errors # Only publish from master; PRs validate but do not deploy. @@ -76,7 +109,7 @@ jobs: if: github.ref == 'refs/heads/master' uses: actions/upload-pages-artifact@v3 with: - path: ./docs + path: ./user-docs deploy-docs: name: Deploy documentation diff --git a/.gitignore b/.gitignore index 70a8d53..05c2cad 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,9 @@ Packages/ docc-output/ DerivedData/ +# Generated DocC static-hosting site (see the docc job in .github/workflows/ci.yml). +user-docs/ + # CocoaPods # # We recommend against adding the Pods directory to your .gitignore. However diff --git a/AGENTS.md b/AGENTS.md index afc1380..29fa97c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,3 +57,4 @@ The library is built with Swift 6 and **complete concurrency checking**. The `Re - Public API on `ReliaBLEManager` is the supported surface for external consumers. Adding/removing methods there is a breaking change. - `forceMock: true` is currently passed to `CBCentralManagerFactory.instance(...)` in `BluetoothActor`. The production factory ignores this parameter; the mock factory honors it. Don't "clean it up" — it's load-bearing for the test target. - DocC catalog lives at `Sources/ReliaBLE/Documentation.docc/`. The `swift-docc-plugin` is a package dep so `swift package generate-documentation` works. This documentation **must** be kept up to date with the public API on `ReliaBLEManager` and the overall architecture and usage patterns. +- **Generated DocC output goes to `./user-docs` (gitignored), never `./docs`.** `./docs` is AI agent managed plans, designs, investigations, and reviews. \ No newline at end of file diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift index c8bbd16..f96a8db 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift @@ -278,15 +278,11 @@ private struct DeviceDetailView: View { } Button(action: { + let handle = reliaBLE.peripheral(id: device.id) if isActive { - Task { try? await reliaBLE.disconnect(from: Peripheral(id: device.id)) } + Task { try? await handle.disconnect() } } else { - Task { - try? await reliaBLE.connect( - to: Peripheral(id: device.id), - autoReconnect: autoReconnect - ) - } + Task { try? await handle.connect(autoReconnect: autoReconnect) } } }) { Text(isActive ? "Disconnect" : "Connect") diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/DeviceStoreActor.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/DeviceStoreActor.swift index 5994f9f..f4ea9f0 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/DeviceStoreActor.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/DeviceStoreActor.swift @@ -65,7 +65,7 @@ actor DeviceStoreActor: ModelActor { try? modelContext.save() } - func syncDevices(_ peripherals: [Peripheral]) { + func syncDevices(_ peripherals: [DiscoveredPeripheral]) { assertWritesOffMainThread() do { diff --git a/PRD.md b/PRD.md index 0924dce..43123a9 100644 --- a/PRD.md +++ b/PRD.md @@ -31,7 +31,7 @@ The library is **unshipped** and under active development. This PRD is the **v1 | Type | Role | |---|---| | **`ReliaBLEManager`** | Façade: authorization, Bluetooth state, scanning, peripheral registry, configuration. | -| **`Peripheral`** | Long-lived **control handle**, interned by id per manager. Primary type for wearables/IoT: sticky discovery filter, connection/readiness, command queue, Advanced connect/disconnect, last-seen / last-advertisement metadata. | +| **`Peripheral`** | Long-lived **control handle**, interned by id per manager. Primary type for wearables/IoT: sticky discovery filter, connection/readiness, command queue, Manual connect/disconnect, last-seen / last-advertisement metadata. | | **`DiscoveredPeripheral`** | Sendable **scan snapshot** (advertisement, rssi, lastSeen, id, …). Manager-stamped. Exposes **`peripheral`** syntactic sugar resolving to the interned `Peripheral` handle. | - Live `CBPeripheral` / GATT objects remain inside the library’s Bluetooth isolation domain only. @@ -44,12 +44,12 @@ Detail and rationale: `docs/designs/discovered-peripheral-vs-peripheral-2026-07- ### Connection model (work-driven primary) -- **Primary path:** work drives the link. A non-empty per-`Peripheral` command queue causes auto-connect (and discovery to *ready* when required). When the queue is empty and there is no Advanced app hold, start **idle disconnect** (global config, **default 5 seconds**). -- **Advanced app hold:** `Peripheral.connect(autoReconnect:)` / `disconnect()` suppress idle teardown while held. Documented as Advanced; expected to be rare. Same ensure-linked path as work-driven connect—not a second connection stack or either-or mode enum. +- **Primary path:** work drives the link. A non-empty per-`Peripheral` command queue causes auto-connect (and discovery to *ready* when required). When the queue is empty and there is no manual-connect hold, start **idle disconnect** (global config, **default 5 seconds**). +- **Manual connect:** `Peripheral.connect(autoReconnect:)` / `disconnect()` set a manual-connect hold that suppresses idle teardown while held. Documented as Advanced; expected to be rare. Same ensure-linked path as work-driven connect—not a second connection stack or either-or mode enum. - **Reconnect (Approach B):** - **Tier-0** (OS `CBConnectPeripheralOptionEnableAutoReconnect`): enabled on work-driven connects while the link is up; **ended** when idle teardown or intentional disconnect cancels the connection. - - **Tier-1** (library exponential-backoff ladder): armed on unexpected disconnect **only while** the command queue is non-empty (or Advanced hold with reconnect desired). Disarmed when the queue is empty and there is no such hold. - - Accepted gap: during the idle grace window, Tier-0 may reconnect once with an empty queue; if still quiet and no hold, cancel again. + - **Tier-1** (library exponential-backoff ladder): armed on unexpected disconnect **only while** the command queue is non-empty (or a manual-connect hold with reconnect desired). Disarmed when the queue is empty and there is no such hold. + - Accepted gap: during the idle grace window, Tier-0 may reconnect once with an empty queue; if still quiet and no manual-connect hold, cancel again. - **PoweredOn:** work submission (scan, connect, command/`run`) **awaits** a usable radio (`PoweredOn`) rather than silently no-op’ing. Terminal states (unauthorized, unsupported, powered off per policy) **fail** promptly with typed errors. Bluetooth state remains observable for UI gating. - Manager-level `connect(to:)` as the primary app API is a **refactor target**: connect/disconnect/run/discovery belong on **`Peripheral`**. @@ -71,28 +71,31 @@ Detail and rationale: `docs/designs/discovered-peripheral-vs-peripheral-2026-07- 1. Reliability of Communication: - FR-1.1: Implement error detection and correction mechanisms for each BLE transaction (command/watchdog layer; builds on FR-4/FR-5 after FR-10). -- FR-1.2: Ensure automatic reconnection per the connection model (Approach B: Tier-0 while linked on work-driven connects; Tier-1 ladder with exponential backoff while work is pending or Advanced hold requests reconnect). On reconnection, services and characteristics must be re-discovered rather than reused, as part of returning to a discovery-*ready* state (FR-10.6, FR-10.3); a re-established link alone is not sufficient to resume characteristic I/O. Command-layer reconnect-and-rerun (FR-4/FR-5) depends on this ready transition rather than treating "connected again" as enough. (Tier-1 backoff substrate exists; queue/hold gating, idle cancel of Tier-0, and discovery re-run remain open.) +- FR-1.2: Ensure automatic reconnection per the connection model (Approach B: Tier-0 while linked on work-driven connects; Tier-1 ladder with exponential backoff while work is pending or a manual-connect hold requests reconnect). On reconnection, services and characteristics must be re-discovered rather than reused, as part of returning to a discovery-*ready* state (FR-10.6, FR-10.3); a re-established link alone is not sufficient to resume characteristic I/O. Command-layer reconnect-and-rerun (FR-4/FR-5) depends on this ready transition rather than treating "connected again" as enough. (Tier-1 backoff substrate exists; queue/hold gating, idle cancel of Tier-0, and discovery re-run remain open.) - FR-1.3: Provide status updates on connection stability and data transmission integrity. - ✅ FR-1.3.1: Provide status updates on connection stability (e.g. connected, disconnected, reconnecting), exposed in a device-centric way on `Peripheral` (and/or equivalent streams) as the type model lands. - FR-1.3.2: Provide status updates on data transmission integrity (command/transaction layer). - FR-1.4: **PoweredOn gating for work:** Scan, connect, and command submission must await `PoweredOn` (or equivalent usable state) instead of silently no-op’ing when the radio is not ready. Terminal unusable states fail with typed errors. Observability of Bluetooth state for UI remains required. -- FR-1.5: **Idle disconnect:** When a `Peripheral` has no pending/queued commands and no Advanced app hold, disconnect after a configurable idle interval. Default interval is **5 seconds**. Configuration is **global** (not per-peripheral) unless a future requirement explicitly adds per-device overrides. +- FR-1.5: **Idle disconnect:** When a `Peripheral` has no pending/queued commands and no manual-connect hold, disconnect after a configurable idle interval. Default interval is **5 seconds**. Configuration is **global** (not per-peripheral) unless a future requirement explicitly adds per-device overrides. 2. Public Interface for Easy Integration: - FR-2.1: Design a clear, documented API for developers to interact with BLE functionality without UI components, centered on **`Peripheral`** for device work and **`ReliaBLEManager`** for process-wide concerns (auth, scan, registry). -- FR-2.2: Include example usage showing: known-id `Peripheral`, scan → `DiscoveredPeripheral.peripheral`, work-driven `run` (when commands exist), Advanced connect, and discovery readiness—without requiring CoreBluetooth expertise. +- FR-2.2: Include example usage showing: known-id `Peripheral`, scan → `DiscoveredPeripheral.peripheral`, work-driven `run` (when commands exist), Manual connect, and discovery readiness—without requiring CoreBluetooth expertise. - FR-2.3: Provide streams (or equivalent) for asynchronous events: - ✅ FR-2.3.1: Connection-state changes (connection, disconnection, connection failure / reconnecting). Migrate primary consumption to `Peripheral` as the handle model lands. - FR-2.3.2: Data received from peripherals (command/notify path after FR-4/FR-10). - FR-2.3.3: Discovery/readiness changes distinct from connection state (FR-10.3.2). - FR-2.4: **Public type model:** - - FR-2.4.1: **`Peripheral`** is a long-lived handle interned by id per manager. It is the unit of connection policy, GATT discovery filter, readiness, subscriptions, command queue, and Advanced connect/disconnect. - - FR-2.4.2: **`DiscoveredPeripheral`** is a Sendable scan snapshot (advertisement metadata). It must not be the only way to obtain a `Peripheral`. - - FR-2.4.3: **`DiscoveredPeripheral.peripheral`** (or equivalent sugar) resolves to the interned handle via the vending manager (manager-stamped discovery). - - FR-2.4.4: **`manager.peripheral(id:)`** (or equivalent) creates or returns a handle for a known id before any advertisement is seen; later discovery binds the live radio to that handle. - - FR-2.4.5: Support a **tracked / “my devices”** view of handles with last-discovery metadata on `Peripheral` (rssi, lastSeen, optional last advertisement). Do not invent fake `DiscoveredPeripheral` entries for offline devices. Raw discovery streams remain for nearby/scanner UX. + - FR-2.4.1: **`Peripheral`** is a long-lived handle interned by id per manager. It is the unit of connection policy, GATT discovery filter, readiness, subscriptions, command queue, and Manual connect/disconnect. + - ✅ FR-2.4.2: **`DiscoveredPeripheral`** is a Sendable scan snapshot (advertisement metadata). It must not be the only way to obtain a `Peripheral`. + - ✅ FR-2.4.3: **`DiscoveredPeripheral.peripheral`** (or equivalent sugar) resolves to the interned handle through the vending manager's registry. Repeated advertisements for the same device must resolve to the same handle, and a snapshot vended by one manager must never resolve against another manager's registry. The mechanism that carries the manager association is an implementation choice. + - ✅ FR-2.4.4: **`manager.peripheral(id:)`** (or equivalent) creates or returns a handle for a known id before any advertisement is seen; later discovery binds the live radio to that handle. + - FR-2.4.5: Support a **tracked / “my devices”** view of handles built from last-discovery metadata on `Peripheral` (rssi, lastSeen, optional last advertisement). Do not invent fake `DiscoveredPeripheral` entries for offline devices. Raw discovery streams remain for nearby/scanner UX: + - ✅ FR-2.4.5.1: Last-discovery metadata is carried on the `Peripheral` handle itself and must be readable without awaiting the Bluetooth isolation domain, so a single long-lived object can back synchronous list rendering. This is the substrate the tracked view is built on. + - ✅ FR-2.4.5.2: A dedicated tracked-handles feed is **not** required. Apps may compose the view from `manager.peripheral(id:)` and `DiscoveredPeripheral.peripheral`, which is what keeps offline devices representable without fake discoveries. A first-class tracked feed remains open. + - ✅ FR-2.4.5.3: Because handle metadata mutates in place on a long-lived reference rather than arriving as fresh values, a **change-notification affordance is required for the view to update**. Until a first-class one exists, the documented contract is that the discovery stream doubles as the change signal and apps re-read handle metadata within it. A per-handle notification (e.g. a metadata-update stream, or the `PeripheralUpdate` shape in `docs/designs/discovered-peripheral-vs-peripheral-2026-07-15.md`) remains open; documentation must not present the interim contract as if the view self-updates. - FR-2.4.6: Do not use the public type name **`Device`** for library types (reserved for integrating apps, e.g. multi-transport). - FR-2.4.7: Never expose live CoreBluetooth objects (`CBPeripheral`, `CBService`, `CBCharacteristic`, etc.) in the public API. - FR-2.5: **API placement:** Connect, disconnect, discovery filter, readiness observation, subscriptions, and command `run` are exposed on **`Peripheral`**. `ReliaBLEManager` owns authorization, Bluetooth state, scanning start/stop, and handle registry. Manager-only connect as the primary documented path is a temporary milestone to be refactored away. @@ -117,7 +120,7 @@ Detail and rationale: `docs/designs/discovered-peripheral-vs-peripheral-2026-07- - FR-4.2.3: Read-write (both read from and write to peripherals). - FR-4.2.4: Write-only (send data to peripherals). - FR-4.3: Implement parsing of responses from peripherals into a usable Swift data structure (app-supplied decode as appropriate). -- FR-4.4: **Work-driven link:** Enqueueing/running a command on a disconnected `Peripheral` must auto-connect (and run discovery to ready as needed) without requiring a prior Advanced `connect`, unless product policy for never-seen ids chooses fail-fast (implementation planning). +- FR-4.4: **Work-driven link:** Enqueueing/running a command on a disconnected `Peripheral` must auto-connect (and run discovery to ready as needed) without requiring a prior Manual `connect`, unless product policy for never-seen ids chooses fail-fast (implementation planning). - FR-4.5: **Reconnect-and-rerun:** On unexpected disconnect with commands still queued or in flight, after link recovery and return to discovery-*ready*, retry/resume command execution so transient drops do not require the app to re-drive the queue (idempotent command design preferred). - FR-4.6: **Exactly-once completion:** Each command finishes with a single terminal success or failure (no double completion). - FR-4.7: **Watchdogs:** Enforce per-step (or per-command) timeouts; streaming/multi-frame commands may reset the watchdog per frame as specified in implementation. @@ -154,6 +157,7 @@ Detail and rationale: `docs/designs/discovered-peripheral-vs-peripheral-2026-07- - FR-8.1.1: Allow scanning to be targeted at specific BLE services by providing UUIDs. - FR-8.1.2: Provide an API to start, stop, and update the list of services for which to scan, allowing dynamic adjustment during runtime. - FR-8.1.3: Option to enable reporting of every advertisement packet (discovery) for detailed tracking, which can be toggled on or off by the integrating app. + - FR-8.1.4: The per-advertisement feed is keyed by the CoreBluetooth peripheral identifier, which is deliberately **not** the app-facing peripheral identity used by `Peripheral` / `DiscoveredPeripheral`. This distinction must be documented in the public API. Correlating the raw feed to a handle is settled by FR-8.5.4. - FR-8.2: Support continuous scanning: - FR-8.2.1: Allow the library to scan continuously for BLE peripherals, providing real-time updates about nearby devices as **`DiscoveredPeripheral`** values (and/or equivalent), each resolvable to a `Peripheral` handle. @@ -171,6 +175,7 @@ Detail and rationale: `docs/designs/discovered-peripheral-vs-peripheral-2026-07- - FR-8.5.1: Provide an option for the integrating app to process manufacturing data to derive a unique identifier for each peripheral. - FR-8.5.2: Include an API method or property where the integrating app can return this identifier back to the library for more accurate peripheral identification and management. - FR-8.5.3: Once identified, maintain this mapping of the unique identifier to the peripheral's BLE address or other identifying characteristics to ensure consistent tracking across sessions or reconnections. Handle interning and discovery matching (FR-2.4, FR-10.6.2) must adopt this identity model when available. + - FR-8.5.4: Settle how the raw advertisement feed (FR-8.1.3, FR-8.1.4) correlates to the identity model — specifically whether `PeripheralDiscoveryEvent` (or equivalent) exposes the app-facing peripheral id alongside the CoreBluetooth identifier. This is deferred here deliberately: resolving it before FR-8.5 would bake in the interim name-derived identity (`name → localName → uuidString`) and force a second breaking change once manufacturer-data identity lands. - FR-8.6: Scanning respects FR-1.4 (await PoweredOn / fail terminal states)—no silent no-op when the radio is not ready. @@ -189,7 +194,7 @@ Detail and rationale: `docs/designs/discovered-peripheral-vs-peripheral-2026-07- - Command successes or failures - ✅ Scanning start/stop - Service/characteristic discovery events (discovery start/completion/failure, readiness transitions, GATT table changes, subscription state changes) - - Idle connect/disconnect and Advanced hold connect/disconnect + - Idle connect/disconnect and Manual connect/disconnect - Security events (e.g., encryption initiation or failure) - Data chunking operations @@ -253,10 +258,10 @@ only where the gate must be honored. 11. Connection Lifecycle on `Peripheral`: -- FR-11.1: **Work-driven connect:** When work requires a link (non-empty command queue, or other library-defined work that needs a connection), the library connects the `Peripheral` without a prior Advanced `connect` call. -- FR-11.2: **Advanced app hold:** `Peripheral.connect(autoReconnect: Bool)` sets an app hold (suppresses idle teardown). `Peripheral.disconnect()` clears the hold and intentionally cancels the connection. The `autoReconnect` flag controls whether Tier-0/Tier-1 apply for that hold, consistent with Approach B. Primary docs emphasize work-driven usage; hold APIs are Advanced. -- FR-11.3: **Idle teardown:** Per FR-1.5—only when queue empty and no app hold; cancel connection (drops Tier-0). -- FR-11.4: **Single state machine:** Work-driven connect and Advanced connect share one ensure-linked implementation (PoweredOn await, connect, discover to ready). No parallel connection stacks. +- FR-11.1: **Work-driven connect:** When work requires a link (non-empty command queue, or other library-defined work that needs a connection), the library connects the `Peripheral` without a prior Manual `connect` call. +- FR-11.2: **Manual connect:** `Peripheral.connect(autoReconnect: Bool)` sets a manual-connect hold (suppresses idle teardown). `Peripheral.disconnect()` clears the hold and intentionally cancels the connection. The `autoReconnect` flag controls whether Tier-0/Tier-1 apply for that hold, consistent with Approach B. Primary docs emphasize work-driven usage; the manual-connect APIs are documented as Advanced. +- FR-11.3: **Idle teardown:** Per FR-1.5—only when queue empty and no manual-connect hold; cancel connection (drops Tier-0). +- FR-11.4: **Single state machine:** Work-driven connect and Manual connect share one ensure-linked implementation (PoweredOn await, connect, discover to ready). No parallel connection stacks. - FR-11.5: Connection-state observation remains available (FR-1.3.1) and must distinguish intentional disconnect, unexpected drop, and reconnecting where applicable. @@ -290,7 +295,7 @@ only where the gate must be honored. 5. Documentation: - NFR-5.1: Provide comprehensive documentation for all public APIs, including usage examples, parameters, return values, error handling, command types, discovery readiness, and chunking. -- NFR-5.2: Getting Started emphasizes work-driven `Peripheral` usage for wearables/IoT; Advanced section covers app-hold connect/disconnect and raw scanner flows. +- NFR-5.2: Getting Started emphasizes work-driven `Peripheral` usage for wearables/IoT; Advanced section covers Manual connect/disconnect and raw scanner flows. - NFR-5.3: Document OS GATT cache / Service Changed limitations (FR-10.5.3) for integrating apps and firmware partners. diff --git a/Package.swift b/Package.swift index 7dd4d51..7dd69ce 100644 --- a/Package.swift +++ b/Package.swift @@ -4,9 +4,16 @@ import PackageDescription let package = Package( name: "ReliaBLE", + // Every platform is pinned to the lowest OS version that ships the `Synchronization` module, because + // `Peripheral` and `PeripheralHandleRegistry` guard their mutable state with `Synchronization.Mutex`. + // Declaring each platform explicitly is what makes that safe: an undeclared platform would otherwise + // inherit SPM's default floor and fail on `import Synchronization`. platforms: [ .iOS(.v18), - .macOS(.v10_15) + .macOS(.v15), + .tvOS(.v18), + .watchOS(.v11), + .visionOS(.v2) ], products: [ .library( diff --git a/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index 92dc081..bdd07e3 100644 --- a/Sources/ReliaBLE/BluetoothActor.swift +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -176,12 +176,12 @@ actor BluetoothActor { var log: LoggingService? - /// Value snapshots of all discovered peripherals, keyed implicitly by ``Peripheral/id``. - var discoveredPeripherals: [Peripheral] = [] + /// Value snapshots of all discovered peripherals, keyed implicitly by ``DiscoveredPeripheral/id``. + var discoveredPeripherals: [DiscoveredPeripheral] = [] - /// Live `CBPeripheral` references keyed by ``Peripheral/id``. + /// Live `CBPeripheral` references keyed by ``DiscoveredPeripheral/id``. /// - /// This mutable, non-`Sendable` reference map never escapes the actor. ``Peripheral`` snapshots carry only an + /// This mutable, non-`Sendable` reference map never escapes the actor. Snapshots and handles carry only an /// `id`; operations that need the live peripheral look it up here. private var cbPeripherals: [String: CBPeripheral] = [:] @@ -195,7 +195,7 @@ actor BluetoothActor { private var stateContinuations: [UUID: AsyncStream.Continuation] = [:] private var discoveryContinuations: [UUID: AsyncStream.Continuation] = [:] - private var peripheralsContinuations: [UUID: AsyncStream<[Peripheral]>.Continuation] = [:] + private var peripheralsContinuations: [UUID: AsyncStream<[DiscoveredPeripheral]>.Continuation] = [:] /// Per-peripheral connection states, keyed by ``Peripheral/id``. var connectionStates: [String: ConnectionState] = [:] @@ -214,11 +214,26 @@ actor BluetoothActor { // MARK: - Initialization + /// Bridge to the owning manager's handle registry. + /// + /// The actor resolves identity and owns live references; the registry owns ``Peripheral`` instances. Every + /// call into this bridge happens on the actor's executor, before the corresponding broadcast — see the + /// apply-before-broadcast invariant on ``resolveAndUpsertDiscovered(cbPeripheral:name:rssi:lastSeen:advertisement:)``. + /// + /// This reference must never lead back to the manager strongly; see ``PeripheralRegistryBridge``. + private let registry: PeripheralRegistryBridge + /// Creates an actor with configuration only — no `CBCentralManager` is created here. - init(log: LoggingService, reconnectPolicy: ReconnectPolicy, restoreIdentifier: String?) { + init( + log: LoggingService, + reconnectPolicy: ReconnectPolicy, + restoreIdentifier: String?, + registry: PeripheralRegistryBridge + ) { self.log = log self.reconnectPolicy = reconnectPolicy self.restoreIdentifier = restoreIdentifier + self.registry = registry } deinit { @@ -256,7 +271,11 @@ actor BluetoothActor { delegateShim = nil cbPeripherals.removeAll() discoveredPeripherals.removeAll() - connectionStates.removeAll() + clearConnectionStates() + // Drop interned handles along with the stack they belong to. Handles the app still holds keep working, + // orphaned, throwing `.bluetoothUnavailable`. A radio reset (`invalidatePeripherals`) deliberately does not + // do this — a handle must survive one with its metadata intact. + registry.removeAllHandles() reconnectEnabled.removeAll() intentionalDisconnects.removeAll() reconnectAttempts.removeAll() @@ -306,7 +325,7 @@ actor BluetoothActor { /// /// The current list is replayed as the first element (`.bufferingNewest(1)`, latest-wins), so /// a new subscriber immediately observes the peripherals already discovered. - nonisolated func discoveredPeripheralsStream() -> AsyncStream<[Peripheral]> { + nonisolated func discoveredPeripheralsStream() -> AsyncStream<[DiscoveredPeripheral]> { AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in Task { await self.register(peripheralsContinuation: continuation) } } @@ -349,7 +368,7 @@ actor BluetoothActor { } } - private func register(peripheralsContinuation continuation: AsyncStream<[Peripheral]>.Continuation) { + private func register(peripheralsContinuation continuation: AsyncStream<[DiscoveredPeripheral]>.Continuation) { guard !isShutdown else { continuation.finish(); return } let id = UUID() continuation.yield(discoveredPeripherals) @@ -716,8 +735,6 @@ actor BluetoothActor { "Restoring BLE state: \(restoredPeripherals.count) peripheral(s), scanServices=\(restoredScanServices ?? [])" ) - // Empty advertisement placeholder — restoration carries no advertisement payload. - let emptyAdvertisement = AdvertisementData(rawAdvertisementData: [:]) let now = Date() var didMutatePeripherals = false @@ -725,47 +742,20 @@ actor BluetoothActor { let persistedIntent = persistedReconnectIntent() for cbPeripheral in restoredPeripherals { - // Restored peripherals arrive with no delegate; re-associate into actor-owned maps - // using the same identity rules as discovery. Peripheral-level GATT callbacks are not - // yet used by the library, so no `CBPeripheralDelegate` is attached here. - let identifier = cbPeripheral.name ?? cbPeripheral.identifier.uuidString - let cbIdentifier = cbPeripheral.identifier - let name = cbPeripheral.name - - let resolvedId: String - if let idx = discoveredPeripherals.firstIndex(where: { $0.id == identifier }) { - resolvedId = identifier - discoveredPeripherals[idx] = Peripheral( - id: resolvedId, - cbIdentifier: cbIdentifier, - name: name, - rssi: discoveredPeripherals[idx].rssi, - lastSeen: now, - advertisement: discoveredPeripherals[idx].advertisement ?? emptyAdvertisement - ) - } else if let idx = discoveredPeripherals.firstIndex(where: { $0.cbIdentifier == cbIdentifier }) { - resolvedId = discoveredPeripherals[idx].id - discoveredPeripherals[idx] = Peripheral( - id: resolvedId, - cbIdentifier: cbIdentifier, - name: name ?? discoveredPeripherals[idx].name, - rssi: discoveredPeripherals[idx].rssi, - lastSeen: now, - advertisement: discoveredPeripherals[idx].advertisement ?? emptyAdvertisement - ) - } else { - resolvedId = identifier - discoveredPeripherals.append( - Peripheral( - id: resolvedId, - cbIdentifier: cbIdentifier, - name: name, - rssi: nil, - lastSeen: now, - advertisement: emptyAdvertisement - ) - ) - } + // Restored peripherals arrive with no delegate; re-associate into actor-owned maps using the same + // identity rules as discovery — literally the same helper, so the two paths cannot drift and a + // restored device interns the same handle it had before termination. Peripheral-level GATT callbacks + // are not yet used by the library, so no `CBPeripheralDelegate` is attached here. + // + // `rssi` and `advertisement` are passed as `nil` to invoke the helper's keep-existing merge rule: + // restoration carries neither, and a device discovered before termination must not have them wiped. + let resolvedId = resolveAndUpsertDiscovered( + cbPeripheral: cbPeripheral, + name: cbPeripheral.name, + rssi: nil, + lastSeen: now, + advertisement: nil + ) cbPeripherals[resolvedId] = cbPeripheral didMutatePeripherals = true @@ -793,11 +783,7 @@ actor BluetoothActor { } if let connectionState { - connectionStates[resolvedId] = connectionState - broadcast( - ConnectionStateChange(peripheralId: resolvedId, state: connectionState), - to: connectionStateChangesContinuations - ) + setConnectionState(connectionState, for: resolvedId) } } @@ -880,7 +866,7 @@ actor BluetoothActor { ) { // Extract the untyped advertisement dictionary into a typed, Sendable snapshot exactly once. The raw // `[String: Any]` does not leave this actor; the same `AdvertisementData` feeds both the discovery event - // and the stored `Peripheral` snapshot. + // and the stored `DiscoveredPeripheral` snapshot. let advertisement = AdvertisementData(rawAdvertisementData: advertisementData) // Emit lightweight discovery feed. @@ -890,73 +876,124 @@ actor BluetoothActor { to: discoveryContinuations ) - // Derive the app-facing `id` from the advertised name, falling back to the local name and - // finally the CoreBluetooth identifier string. - // + // Identity resolution and the snapshot upsert live in the shared helper, so discovery and state + // restoration cannot drift apart. + let resolvedId = resolveAndUpsertDiscovered( + cbPeripheral: cbPeripheral, + name: cbPeripheral.name ?? advertisement.localName, + rssi: rssi, + lastSeen: Date(), + advertisement: advertisement + ) + + // Stash the live reference under the resolved id. Never escapes the actor. + cbPeripherals[resolvedId] = cbPeripheral + broadcast(discoveredPeripherals, to: peripheralsContinuations) + } + + /// Resolves the app-facing id for a peripheral, upserts its snapshot into ``discoveredPeripherals``, and + /// mirrors the merged result onto the interned handle. Returns the resolved id. + /// + /// This is the library's **single identity-resolution site**, shared by discovery and state restoration. Those + /// two paths previously carried near-duplicate copies of these rules, which had already drifted apart; keeping + /// them together is what guarantees both return the same handle for the same device. + /// + /// Resolution is: derive `cbPeripheral.name ?? advertisement.localName ?? cbPeripheral.identifier.uuidString`, + /// then match an existing entry by `id`, else by `cbIdentifier` (preserving that entry's original `id`, so a + /// device that renames itself keeps its identity), else append. + /// + /// **Merge rule: `nil` means keep.** A `nil` `name`, `rssi`, or `advertisement` preserves the existing value + /// rather than clearing it, falling back to `nil` (or an empty advertisement) when there is no existing entry. + /// Discovery always passes real values, so the rule is a no-op there. Restoration depends on it: a restored + /// peripheral carries no advertisement payload and no RSSI, and wiping those would silently downgrade a device + /// the app had already discovered — visibly, now that the handle republishes them. + /// + /// `lastSeen` is always stamped by the caller. For restoration that means "when the live reference was last + /// bound" rather than "last heard from", which is why the public property is documented as *last bound or seen*. + /// + /// This helper deliberately does **not** broadcast and does **not** emit a ``PeripheralDiscoveryEvent``. + /// Discovery broadcasts once per advertisement and emits its event before resolution even begins; restoration + /// broadcasts once after its whole loop and never emits on the advertisement feed. Leaving both to the callers + /// preserves each of those contracts for free. + /// + /// - Important: The handle is updated *before* the caller broadcasts, so a consumer that receives snapshot *N* + /// can never read handle metadata older than *N*. Any future change that broadcasts earlier to shave latency + /// would break that guarantee. + private func resolveAndUpsertDiscovered( + cbPeripheral: CBPeripheral, + name: String?, + rssi: Int?, + lastSeen: Date, + advertisement: AdvertisementData? + ) -> String { // TODO: FR-8.5 — Unique Identifier from Manufacturing Data. - // KNOWN LIMITATION: advertised names are not unique. Two distinct physical devices that - // advertise the same name resolve to the same `identifier` here, so they collapse into a - // single `discoveredPeripherals` entry and a single `cbPeripherals` slot — the later - // discovery overwrites the earlier device's live `CBPeripheral`, so `connect(id:)` may target - // whichever was seen last. FR-8.5 will replace this with a stable identity derived from - // manufacturing data; until then the dedup key is best-effort. The `cbIdentifier` fallback - // below only rescues a *single* device whose advertised name changes, not the same-name - // collision between *different* devices. + // KNOWN LIMITATION: advertised names are not unique. Two distinct physical devices that advertise the same + // name resolve to the same `identifier` here, so they collapse into a single `discoveredPeripherals` entry + // and a single `cbPeripherals` slot — the later discovery overwrites the earlier device's live + // `CBPeripheral`, so `connect(id:)` may target whichever was seen last. FR-8.5 will replace this with a + // stable identity derived from manufacturing data; until then the dedup key is best-effort. The + // `cbIdentifier` fallback below only rescues a *single* device whose advertised name changes, not the + // same-name collision between *different* devices. let identifier = cbPeripheral.name - ?? advertisement.localName + ?? advertisement?.localName ?? cbPeripheral.identifier.uuidString - let cbIdentifier = cbPeripheral.identifier - let name = cbPeripheral.name ?? advertisement.localName - let now = Date() - // Resolve the id to store under. Prefer an existing entry matching the app-facing `identifier`; otherwise - // fall back to an existing entry for the same `CBPeripheral` (whose resolved `id` may differ if the name has - // since changed), preserving that entry's original `id`. Otherwise this is a brand-new peripheral. + let existingIndex: Int? let resolvedId: String if let idx = discoveredPeripherals.firstIndex(where: { $0.id == identifier }) { + existingIndex = idx resolvedId = identifier - discoveredPeripherals[idx] = Peripheral( - id: resolvedId, - cbIdentifier: cbIdentifier, - name: name, - rssi: rssi, - lastSeen: now, - advertisement: advertisement - ) } else if let idx = discoveredPeripherals.firstIndex(where: { $0.cbIdentifier == cbIdentifier }) { + existingIndex = idx resolvedId = discoveredPeripherals[idx].id - discoveredPeripherals[idx] = Peripheral( - id: resolvedId, - cbIdentifier: cbIdentifier, - name: name, - rssi: rssi, - lastSeen: now, - advertisement: advertisement - ) } else { + existingIndex = nil resolvedId = identifier - let new = Peripheral( - id: resolvedId, - cbIdentifier: cbIdentifier, - name: name, - rssi: rssi, - lastSeen: now, - advertisement: advertisement - ) - log?.debug(tags: [.category(.scanning), .peripheral(new.id)], "Adding newly discovered peripheral") - discoveredPeripherals.append(new) } - // Stash the live reference under the resolved id. Never escapes the actor. - cbPeripherals[resolvedId] = cbPeripheral - broadcast(discoveredPeripherals, to: peripheralsContinuations) + let existing = existingIndex.map { discoveredPeripherals[$0] } + let mergedName = name ?? existing?.name + let mergedRSSI = rssi ?? existing?.rssi + // `??` is lazily evaluated, so the empty placeholder is only built on the restore-a-never-seen-device path. + let mergedAdvertisement = advertisement + ?? existing?.advertisement + ?? AdvertisementData(rawAdvertisementData: [:]) + + let snapshot = DiscoveredPeripheral( + id: resolvedId, + cbIdentifier: cbIdentifier, + name: mergedName, + rssi: mergedRSSI, + lastSeen: lastSeen, + advertisement: mergedAdvertisement, + registry: registry + ) + + if let existingIndex { + discoveredPeripherals[existingIndex] = snapshot + } else { + log?.debug(tags: [.category(.scanning), .peripheral(resolvedId)], "Adding newly discovered peripheral") + discoveredPeripherals.append(snapshot) + } + + // Apply before the caller broadcasts — see the Important note above. + registry.applyDiscovery( + id: resolvedId, + cbIdentifier: cbIdentifier, + name: mergedName, + rssi: mergedRSSI, + lastSeen: lastSeen, + advertisement: mergedAdvertisement + ) + + return resolvedId } private func invalidatePeripherals() { // The value snapshots hold no CoreBluetooth reference to clear; drop the live registry instead. cbPeripherals.removeAll() - connectionStates.removeAll() + clearConnectionStates() taskRegistry.cancelAll() reconnectAttempts.removeAll() reconnectEnabled.removeAll() @@ -1049,8 +1086,7 @@ actor BluetoothActor { } persistReconnectIntent() intentionalDisconnects.remove(id) - connectionStates[id] = .connecting - broadcast(ConnectionStateChange(peripheralId: id, state: .connecting), to: connectionStateChangesContinuations) + setConnectionState(.connecting, for: id) var options: [String: Any]? if #available(macOS 14.0, iOS 17.0, *) { @@ -1088,11 +1124,47 @@ actor BluetoothActor { taskRegistry.cancel(id) reconnectAttempts[id] = nil - connectionStates[id] = .disconnecting - broadcast(ConnectionStateChange(peripheralId: id, state: .disconnecting), to: connectionStateChangesContinuations) + setConnectionState(.disconnecting, for: id) centralManager.cancelPeripheralConnection(cbPeripheral) } + /// The single write path for per-peripheral connection state. + /// + /// Records the state, mirrors it onto the interned handle so ``Peripheral/connectionState`` stays in step, and + /// broadcasts the change. Every transition must go through here — a bare `connectionStates[id] = …` would + /// leave the handle's cached value silently stale. + private func setConnectionState(_ state: ConnectionState, for id: String) { + connectionStates[id] = state + registry.applyConnectionState(id: id, state: state) + broadcast(ConnectionStateChange(peripheralId: id, state: state), to: connectionStateChangesContinuations) + } + + /// Drops all tracked connection state, mirroring the clear onto every affected handle and broadcasting a + /// terminal transition for each. + /// + /// A bare `connectionStates.removeAll()` would leave handles reporting a state the library no longer believes — + /// a handle stuck on `.connected` after the radio was invalidated is worse than one reporting nothing, because + /// unlike the metadata properties it is not merely stale, it is known to be false. + /// + /// Clearing is silent on the handle (``Peripheral/connectionState`` reverts to `nil`, meaning "not tracked") + /// but **not** on the stream: a subscriber that only ever learns about transitions from + /// `connectionStateChanges` would otherwise keep rendering `.connected` forever, since a cleared peripheral + /// produces no further events. `.disconnected(reason: .bluetoothUnavailable)` is emitted instead — true at the + /// moment it is sent, and the reason a consumer needs to react to. + /// + /// `shutdown()` also routes through here, but it finishes and drops every continuation first, so the broadcast + /// is a no-op there — a torn-down stack ends its streams rather than emitting a final state into them. + private func clearConnectionStates() { + for id in connectionStates.keys { + registry.applyConnectionState(id: id, state: nil) + broadcast( + ConnectionStateChange(peripheralId: id, state: .disconnected(reason: .bluetoothUnavailable)), + to: connectionStateChangesContinuations + ) + } + connectionStates.removeAll() + } + /// Resolves a ``Peripheral/id`` from the live `CBPeripheral` reference using reverse object-identity lookup. /// /// Derives nothing — it reads back the key that ``handlePeripheralDiscovered(_:advertisementData:rssi:)`` @@ -1111,9 +1183,8 @@ actor BluetoothActor { clearReconnectState(for: id) - connectionStates[id] = .connected log?.info(tags: [.peripheral(id), .category(.connection)], "Peripheral connected") - broadcast(ConnectionStateChange(peripheralId: id, state: .connected), to: connectionStateChangesContinuations) + setConnectionState(.connected, for: id) } private func handleDidDisconnect(_ payload: ConnectionPayload) { @@ -1128,11 +1199,8 @@ actor BluetoothActor { // explicit `cancelPeripheralConnection`, so we intentionally ignore `payload.error` here // and always report a clean disconnect — otherwise the app/Demo would misclassify an // intentional disconnect as an error drop. - let state: ConnectionState = .disconnected(reason: nil) - connectionStates[id] = state log?.info(tags: [.peripheral(id), .category(.connection)], "Peripheral disconnected (explicit)") - - broadcast(ConnectionStateChange(peripheralId: id, state: state), to: connectionStateChangesContinuations) + setConnectionState(.disconnected(reason: nil), for: id) return } @@ -1142,25 +1210,20 @@ actor BluetoothActor { // (library) cannot overlap under odd callback ordering. taskRegistry.cancel(id) - connectionStates[id] = .reconnecting(source: .system, attempt: nil, nextRetryAt: nil) log?.info(tags: [.peripheral(id), .category(.connection)], "System auto-reconnect in progress") - - broadcast(ConnectionStateChange(peripheralId: id, state: .reconnecting(source: .system, attempt: nil, nextRetryAt: nil)), to: connectionStateChangesContinuations) + setConnectionState(.reconnecting(source: .system, attempt: nil, nextRetryAt: nil), for: id) return } let mappedError: PeripheralError? = payload.error.map { ($0 as? CBError).map(PeripheralError.fromCBError) ?? .unknown } - let state: ConnectionState = .disconnected(reason: mappedError) - connectionStates[id] = state - if let error = mappedError { log?.warn(tags: [.peripheral(id), .category(.connection)], "Peripheral disconnected with error: \(error)") } else { log?.info(tags: [.peripheral(id), .category(.connection)], "Peripheral disconnected") } - broadcast(ConnectionStateChange(peripheralId: id, state: state), to: connectionStateChangesContinuations) + setConnectionState(.disconnected(reason: mappedError), for: id) armReconnect(id: id) } @@ -1171,12 +1234,9 @@ actor BluetoothActor { } let mappedError: PeripheralError? = payload.error.map { ($0 as? CBError).map(PeripheralError.fromCBError) ?? .unknown } - let state: ConnectionState = .failed(reason: mappedError) - connectionStates[id] = state - log?.warn(tags: [.peripheral(id), .category(.connection)], "Peripheral connection failed with error: \(mappedError ?? .unknown)") - broadcast(ConnectionStateChange(peripheralId: id, state: state), to: connectionStateChangesContinuations) + setConnectionState(.failed(reason: mappedError), for: id) armReconnect(id: id) } @@ -1223,8 +1283,7 @@ actor BluetoothActor { let sleepNanos: UInt64 = nanosDouble >= Double(UInt64.max) ? .max : UInt64(nanosDouble) let nextRetryAt = Date().addingTimeInterval(delaySeconds) - connectionStates[id] = .reconnecting(source: .library, attempt: attempt, nextRetryAt: nextRetryAt) - broadcast(ConnectionStateChange(peripheralId: id, state: .reconnecting(source: .library, attempt: attempt, nextRetryAt: nextRetryAt)), to: connectionStateChangesContinuations) + setConnectionState(.reconnecting(source: .library, attempt: attempt, nextRetryAt: nextRetryAt), for: id) let task = Task { [weak self] in do { @@ -1257,10 +1316,8 @@ actor BluetoothActor { } catch { let reason = (error as? PeripheralError) ?? .unknown clearReconnectState(for: id) - let state: ConnectionState = .failed(reason: reason) - connectionStates[id] = state log?.warn(tags: [.peripheral(id), .category(.connection)], "Reconnect attempt failed: \(reason)") - broadcast(ConnectionStateChange(peripheralId: id, state: state), to: connectionStateChangesContinuations) + setConnectionState(.failed(reason: reason), for: id) } } diff --git a/Sources/ReliaBLE/Documentation.docc/Documentation.md b/Sources/ReliaBLE/Documentation.docc/Documentation.md index dd8934b..a770afe 100644 --- a/Sources/ReliaBLE/Documentation.docc/Documentation.md +++ b/Sources/ReliaBLE/Documentation.docc/Documentation.md @@ -40,7 +40,7 @@ rather than juggling a central manager for day-to-day tasks. - **Scanning with rich advertisement data.** Scan for all peripherals or filter by service UUID, and consume results as strongly-typed ``AdvertisementData`` snapshots through `AsyncStream`s — either per-advertisement (``PeripheralDiscoveryEvent``) or as a - de-duplicated list of ``Peripheral`` values. Background scanning and state restoration + de-duplicated list of ``DiscoveredPeripheral`` snapshots. Background scanning and state restoration are supported. - **Multiple simultaneous peripherals.** Maintain connections to many devices at once, each with its own connection state and (in the v1 target) its own command queue. @@ -67,6 +67,7 @@ and open your first connection. ### Peripherals - ``Peripheral`` +- ``DiscoveredPeripheral`` - ``AdvertisementData`` - ``PeripheralDiscoveryEvent`` - ``PeripheralError`` diff --git a/Sources/ReliaBLE/Documentation.docc/GettingStarted.md b/Sources/ReliaBLE/Documentation.docc/GettingStarted.md index b4fc592..d94320f 100644 --- a/Sources/ReliaBLE/Documentation.docc/GettingStarted.md +++ b/Sources/ReliaBLE/Documentation.docc/GettingStarted.md @@ -127,35 +127,87 @@ You can monitor the ``ReliaBLEManager/state`` stream (as shown in the Authorizin While scanning, ReliaBLE surfaces results two ways: - ``ReliaBLEManager/peripheralDiscoveries`` emits a lightweight ``PeripheralDiscoveryEvent`` for every advertisement received — useful when you need to process individual advertisement packets. -- ``ReliaBLEManager/discoveredPeripherals`` emits the current de-duplicated list of ``Peripheral`` values each time it changes. +- ``ReliaBLEManager/discoveredPeripherals`` emits the current de-duplicated list of ``DiscoveredPeripheral`` snapshots each time it changes. Both are `AsyncStream`s. Each property access returns a *fresh, independent* stream, so multiple subscribers are supported by design — consume each with `for await`, typically inside a SwiftUI `.task { … }` (which cancels the loop automatically when the view disappears). ``ReliaBLEManager/state`` and ``ReliaBLEManager/discoveredPeripherals`` replay their latest value to every new subscriber; ``ReliaBLEManager/peripheralDiscoveries`` does **not** replay, so subscribe before you start scanning to avoid missing early advertisements. The discoveries feed is also bounded, so a subscriber that consumes slower than advertisements arrive drops the oldest pending events rather than growing memory without bound. -A ``Peripheral`` is an immutable, `Sendable` value snapshot: it carries the peripheral's ``Peripheral/id``, ``Peripheral/name``, ``Peripheral/rssi``, ``Peripheral/lastSeen``, and a strongly-typed ``AdvertisementData`` rather than a raw `[String: Any]` dictionary. Because it is a value type, it is safe to hand directly to your UI. +A ``DiscoveredPeripheral`` is an immutable, `Sendable` value snapshot: it carries the device's ``DiscoveredPeripheral/id``, ``DiscoveredPeripheral/name``, ``DiscoveredPeripheral/rssi``, ``DiscoveredPeripheral/lastSeen``, and a strongly-typed ``AdvertisementData`` rather than a raw `[String: Any]` dictionary. Because it is a value type, it is safe to diff and to hand straight to your UI. + +To *act* on a device — connect, disconnect, or read its last-known metadata — cross over to its control handle via ``DiscoveredPeripheral/peripheral``: ```swift for await peripherals in bleManager.discoveredPeripherals { - for peripheral in peripherals { - print(peripheral.name ?? peripheral.id, peripheral.advertisement?.serviceUUIDs ?? []) + for snapshot in peripherals { + print(snapshot.name ?? snapshot.id, snapshot.advertisement?.serviceUUIDs ?? []) + let handle = snapshot.peripheral // same object `manager.peripheral(id:)` returns } } ``` -If your app already knows a peripheral's identity ahead of time — for example, a wearable bound to the user's account — you can construct a ``Peripheral`` directly with ``Peripheral/init(id:)``. Such a snapshot has no ``Peripheral/advertisement`` until ReliaBLE matches it against the corresponding device during discovery. +If your app already knows a peripheral's identity ahead of time — for example, a wearable bound to the user's account — obtain a handle directly through ``ReliaBLEManager/peripheral(id:)``: + +```swift +let band = bleManager.peripheral(id: "user-band") +``` + +Such a handle carries no ``Peripheral/advertisement`` and throws ``PeripheralError/notFound`` from ``Peripheral/connect(autoReconnect:)`` until discovery or state restoration matches it to a real device. + +## Connecting & Managing Peripherals + +All actions — connect, disconnect, and reading last-known metadata — live on a ``Peripheral`` **handle**, not on the manager. Obtain a handle from a discovery snapshot or from a known identifier (see the previous section), then act on it directly: + +```swift +let band = bleManager.peripheral(id: "user-band") +try await band.connect() +// … session … +try await band.disconnect() +``` + +``Peripheral/connect(autoReconnect:)`` is an `async throws` call that throws ``PeripheralError/notFound`` when the device has never been discovered and ``PeripheralError/bluetoothUnavailable`` when the manager that vended the handle has been deallocated or shut down. + +### Reading handle metadata + +A ``Peripheral`` handle carries synchronous, cached, **last-known** metadata: + +- ``Peripheral/name``, ``Peripheral/rssi``, ``Peripheral/lastSeen``, ``Peripheral/advertisement`` — from the most recent discovery +- ``Peripheral/connectionState`` — mirrored from the library's internal connection tracking + +All of these are synchronous (no `await`) so they read inline from SwiftUI row bodies. -## Connecting to a Peripheral +> **Important: there is no change notification.** ``Peripheral`` is a plain `Sendable` class — it is not `@Observable` and publishes nothing. A view that reads handle metadata directly will render once and go stale. Re-read metadata inside the ``ReliaBLEManager/discoveredPeripherals`` loop (which emits on every advertisement, serving as the "something changed" tick), and re-read ``Peripheral/connectionState`` inside a ``ReliaBLEManager/connectionStateChanges`` loop. Without this your "my devices" list stops updating after the first render. -Use ``ReliaBLEManager/connect(to:autoReconnect:)`` to initiate a connection to a discovered ``Peripheral``. Consume ``ReliaBLEManager/connectionStateChanges`` (an `AsyncStream`) to observe the full lifecycle for any peripheral; filter by `peripheralId` for a specific device. Call ``ReliaBLEManager/disconnect(from:)`` to end the session. +### Observing connection state -Reconnection is **on by default** via the `autoReconnect` parameter (default `true`). When active, ReliaBLE uses a **two-tier model**: +Consume ``ReliaBLEManager/connectionStateChanges`` (an `AsyncStream`) to observe the full lifecycle — filter by ``ConnectionStateChange/peripheralId`` for a specific device: + +```swift +for await change in manager.connectionStateChanges where change.peripheralId == band.id { + switch change.state { + case .connected: + print("Connected to \(band.id)") + case .disconnected(let reason): + print("Disconnected", reason ?? "clean") + case .reconnecting(let source, _, _): + print("Reconnecting (\(source))") + default: + break + } +} +``` + +Each access to `connectionStateChanges` yields a fresh stream with no replay, so begin iteration before calling ``Peripheral/connect(autoReconnect:)``. + +### Reconnection + +Reconnection is **on by default** via the `autoReconnect` parameter (default `true`). ReliaBLE uses a **two-tier model**: 1. **Tier 0 — System-managed (primary).** The connection request includes `CBConnectPeripheralOptionEnableAutoReconnect`, which asks the iOS daemon to re-establish the link itself after an unexpected drop. This is power-efficient, daemon-held, and keeps trying across app suspension. While the system retries, ReliaBLE emits ``ConnectionState/reconnecting(source:attempt:nextRetryAt:)`` with ``ReconnectSource/system`` (`attempt` and `nextRetryAt` are both `nil` — iOS exposes neither). 2. **Tier 1 — Library-managed (supplement).** Covers what the OS option doesn't: initial-connect failures and drops where the OS gives up. The library arms an exponential-backoff ladder governed by ``ReconnectPolicy``, emitting ``ReconnectSource/library`` with populated `attempt` and `nextRetryAt` so your UI can show a countdown. -To disable auto-reconnect for a one-shot connection (a sensor you pair with briefly, then disconnect), pass `autoReconnect: false`: +To disable auto-reconnect for a one-shot connection, pass `autoReconnect: false`: ```swift -try await bleManager.connect(to: peripheral, autoReconnect: false) +try await band.connect(autoReconnect: false) ``` Tune the library backoff via ``ReliaBLEConfig/reconnectPolicy``: @@ -169,52 +221,7 @@ config.reconnectPolicy.jitter = 0.2 // ±20% randomization let bleManager = ReliaBLEManager(config: config) ``` -> Note: iOS's Tier-0 auto-reconnect give-up budget and timing (`CBConnectPeripheralOptionEnableAutoReconnect`) are not publicly documented by Apple. The exact retry duration and failure threshold still require on-device verification — this is deliberately deferred follow-up work. - -```swift -do { - let changes = bleManager.connectionStateChanges - - let observer = Task { - for await change in changes where change.peripheralId == peripheral.id { - switch change.state { - case .connected: - print("Connected to \(peripheral.id)") - return - case .reconnecting(let source, let attempt, let nextRetryAt): - switch source { - case .system: - print("System reconnecting…") - case .library: - print("Reconnecting attempt \(attempt ?? 0), next retry at \(nextRetryAt ?? .now)") - } - case .disconnected(let reason): - print("Disconnected", reason ?? "clean") - return - case .failed(let reason): - print("Failed", reason ?? "") - return - default: - break - } - } - } - defer { observer.cancel() } - - try await bleManager.connect(to: peripheral, autoReconnect: true) - - // Later, when you're done with the session: - try await bleManager.disconnect(from: peripheral) -} catch PeripheralError.notFound { - // The snapshot is stale — its underlying peripheral reference was invalidated - // (for example, after a Bluetooth reset). Re-scan to rediscover it. -} catch PeripheralError.bluetoothUnavailable { - // Bluetooth has not been set up yet (for example, not authorized). Authorize and wait - // for the `.ready` state before retrying. -} -``` - -Each access to `connectionStateChanges` yields a fresh stream; it does not replay, so begin iteration before calling ``ReliaBLEManager/connect(to:autoReconnect:)``. The `ConnectionState` values are ``ConnectionState/connecting``, ``ConnectionState/connected``, ``ConnectionState/disconnecting``, ``ConnectionState/disconnected(reason:)``, ``ConnectionState/failed(reason:)``, and ``ConnectionState/reconnecting(source:attempt:nextRetryAt:)``. Terminal states carry an optional ``PeripheralError`` reason. +> Note: iOS's Tier-0 auto-reconnect give-up budget and timing are not publicly documented. The exact retry duration and failure threshold still require on-device verification. For keeping a session alive while your app is backgrounded or after it is terminated by the system, see . diff --git a/Sources/ReliaBLE/Documentation.docc/Topics/Background.md b/Sources/ReliaBLE/Documentation.docc/Topics/Background.md index b08e077..5e95785 100644 --- a/Sources/ReliaBLE/Documentation.docc/Topics/Background.md +++ b/Sources/ReliaBLE/Documentation.docc/Topics/Background.md @@ -70,8 +70,8 @@ those connections are restored automatically. You do **not** call a separate restoration API — restored peripherals appear on the same streams you already use: -- ``ReliaBLEManager/discoveredPeripherals`` emits restored peripherals (along - with any newly discovered ones). +- ``ReliaBLEManager/discoveredPeripherals`` emits restored devices as + ``DiscoveredPeripheral`` snapshots (alongside newly discovered ones). - ``ReliaBLEManager/connectionStateChanges`` emits the rehydrated connection states — ``ConnectionState/connected`` for preserved connections, ``ConnectionState/connecting`` for in-progress attempts. @@ -84,8 +84,8 @@ advertisements. The Tier-0 system-managed reconnection (``ReconnectSource/system``) survives app termination because it runs in the iOS daemon. Tier-1 library-managed reconnection (``ReconnectSource/library``) does not, so ReliaBLE persists your -per-connect intent: when you call ``ReliaBLEManager/connect(to:autoReconnect:)`` -with `autoReconnect: true` (and a ``ReliaBLEConfig/restoreIdentifier`` is +per-connect intent: when you call ``Peripheral/connect(autoReconnect:)`` with +`autoReconnect: true` (and a ``ReliaBLEConfig/restoreIdentifier`` is configured), that intent is stored in `UserDefaults` and re-armed for the restored connection on relaunch, so a post-relaunch drop still triggers the exponential-backoff ladder governed by ``ReconnectPolicy``. Connections made @@ -103,3 +103,11 @@ rehydrated — but reconnection stays disarmed. > post a system alert when your app is not running and a connection or > disconnection occurs. They can be added non-breakingly in a future release > if a use case emerges. + +> Important: Background scanning and state restoration are constrained on +> tvOS and watchOS. tvOS has no `bluetooth-central` background mode; +> watchOS background BLE is limited. ``ReliaBLEConfig/restoreIdentifier`` +> will not deliver the same background behavior on those platforms as it +> does on iOS and macOS. The library's central-role API compiles for all +> five platforms, but apps targeting tvOS or watchOS should not rely on +> background-preserved connections or relaunch-restored sessions. diff --git a/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md b/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md index 4381196..06bcd32 100644 --- a/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md +++ b/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md @@ -58,7 +58,8 @@ All mutating actions are `async` and hop onto the Bluetooth actor for you: - ``ReliaBLEManager/authorizeBluetooth()`` - ``ReliaBLEManager/startScanning(services:)`` - ``ReliaBLEManager/stopScanning()`` -- ``ReliaBLEManager/connect(to:autoReconnect:)`` +- ``Peripheral/connect(autoReconnect:)`` +- ``Peripheral/disconnect()`` The current Bluetooth state is exposed as an `async` getter, ``ReliaBLEManager/currentState``: @@ -95,8 +96,25 @@ Replay semantics differ per stream: Because each call returns an independent stream, multiple parts of your app can observe the same surface concurrently without interfering with one another. -### Value types +### Value types and handles -The model types you receive — ``Peripheral``, ``AdvertisementData``, and -``PeripheralDiscoveryEvent`` — are `Sendable` value structs, so they cross +The value types you receive from streams — ``DiscoveredPeripheral``, ``AdvertisementData``, +and ``PeripheralDiscoveryEvent`` — are `Sendable` value types, so they cross isolation boundaries freely. + +``Peripheral`` is a checked `Sendable` `final class`, *not* a value type. It is +interned one-per-id-per-manager and carries synchronous, cached, last-known +metadata behind a single `Mutex`. The contract: + +- No `CBPeripheral` or other CoreBluetooth object is ever stored on a handle. +- Metadata writes happen only from library-ordered paths (the Bluetooth actor's + discovery and restore pipelines); the lock exists to make *reads* safe from any + concurrency domain, not to order writes. +- The lock is never held across a suspension — `withLock` closures are + non-`async`, and no CoreBluetooth call or `Task` creation occurs inside them. +- Per-property reads are **not** one atomic snapshot: reading ``Peripheral/name`` + then ``Peripheral/rssi`` can straddle two discovery updates. + +Handles are not `@Observable` and publish nothing. Use +``ReliaBLEManager/discoveredPeripherals`` as the change-signal tick and re-read +handle metadata inside that loop. diff --git a/Sources/ReliaBLE/Documentation.docc/Topics/Multi-Manager.md b/Sources/ReliaBLE/Documentation.docc/Topics/Multi-Manager.md index 2b3148b..daec129 100644 --- a/Sources/ReliaBLE/Documentation.docc/Topics/Multi-Manager.md +++ b/Sources/ReliaBLE/Documentation.docc/Topics/Multi-Manager.md @@ -16,7 +16,15 @@ Each ``ReliaBLEManager`` is a **fully isolated stack**: its own actor, streams. Constructing a second manager gives you a second, independent stack — their discovered peripherals, connection state, and streams never cross over. Two managers scanning the same physical device each hold their own -``Peripheral`` snapshot; there is no shared, cross-manager discovered list. +``DiscoveredPeripheral`` snapshots and distinct ``Peripheral`` handle instances; +there is no shared, cross-manager discovered list. + +Handles are per-manager (the handle registry lives on the manager, not in a +process-global singleton). Two handles for the same physical device id vended by +different managers compare `==` (equality keys on ``Peripheral/id``), but they +are **not** `===` — they are different objects on different registries, and each +can only talk to its own manager. Multi-manager apps must not pass handles +between stacks. When identity matters, use `===`. Configuration — including `ReconnectPolicy` and logging — is applied **per manager**. The config you pass to `init(config:)` governs only that instance. diff --git a/Sources/ReliaBLE/Models/ConnectionState.swift b/Sources/ReliaBLE/Models/ConnectionState.swift index 00566d2..4ee18b8 100644 --- a/Sources/ReliaBLE/Models/ConnectionState.swift +++ b/Sources/ReliaBLE/Models/ConnectionState.swift @@ -73,7 +73,10 @@ public enum ReconnectSource: Sendable, Equatable, Hashable { /// A subscriber that only cares about one peripheral filters with /// `where $0.peripheralId == targetId`. public struct ConnectionStateChange: Sendable, Equatable, Hashable { - /// The ``Peripheral/id`` of the peripheral whose connection state changed. + /// The ``Peripheral/id`` of the handle whose connection state changed. +/// +/// Filter the stream with `change.peripheralId == peripheral.id` to observe a single +/// peripheral. public let peripheralId: String /// The new connection state. public let state: ConnectionState diff --git a/Sources/ReliaBLE/Models/DiscoveredPeripheral.swift b/Sources/ReliaBLE/Models/DiscoveredPeripheral.swift new file mode 100644 index 0000000..738f091 --- /dev/null +++ b/Sources/ReliaBLE/Models/DiscoveredPeripheral.swift @@ -0,0 +1,126 @@ +// +// DiscoveredPeripheral.swift +// ReliaBLE +// +// Created by Justin Bergen on 8/1/25. +// +// Copyright (c) 2025 Five3 Apps, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +/// An immutable, `Sendable` value snapshot of a peripheral as it appeared during a scan. +/// +/// `DiscoveredPeripheral` is the element type of ``ReliaBLEManager/discoveredPeripherals`` — the de-duplicated +/// "nearby" list. Each element captures what the library knew about a device at the moment the list was emitted. +/// It is a pure value: freely sendable, safe to diff, and safe to hand straight to SwiftUI as list data. +/// +/// Snapshots represent *real* observations only. A peripheral the app knows about but has not seen — one obtained +/// via ``ReliaBLEManager/peripheral(id:)`` — never appears here as a synthetic row. Peripherals recovered through +/// state restoration do appear, with an empty ``advertisement`` and a `nil` ``rssi``. +/// +/// To act on a snapshot, cross over to its control handle: +/// +/// ```swift +/// for await peripherals in manager.discoveredPeripherals { +/// for snapshot in peripherals where snapshot.name == "MyBand" { +/// try await snapshot.peripheral.connect() +/// } +/// } +/// ``` +/// +/// A `DiscoveredPeripheral` carries no reference to the underlying `CBPeripheral`. The live object is owned +/// exclusively by the library in an ``id``-keyed map that never escapes its internal concurrency domain. +public struct DiscoveredPeripheral: Sendable, Identifiable, Hashable { + /// Unique, app-facing identifier for the peripheral. + /// + /// Resolved at discovery time from the peripheral's advertised name, its local name, or — as a fallback — the + /// CoreBluetooth identifier string. This is the same identifier ``Peripheral/id`` carries, and is **not** the + /// CoreBluetooth `UUID` reported by ``PeripheralDiscoveryEvent/id``. + public let id: String + + /// The CoreBluetooth identifier for the peripheral, used to re-resolve the live peripheral after invalidation. + public let cbIdentifier: UUID? + + /// The name advertised by the peripheral, if available. + public let name: String? + + /// Signal strength indicator (RSSI) of the most recent advertisement. + /// + /// `nil` for a peripheral recovered through state restoration, which carries no advertisement payload. + public let rssi: Int? + + /// The timestamp when the peripheral was last seen, or last bound through state restoration. + public let lastSeen: Date? + + /// The typed advertisement data from the most recent discovery. + /// + /// Empty for a peripheral recovered through state restoration. Advertisement data is transient, per-discovery + /// information; it is not the peripheral's connected GATT service catalog. + public let advertisement: AdvertisementData? + + /// The registry that vended this snapshot, which is what makes ``peripheral`` resolve to the *same* handle the + /// producing manager would return. Excluded from `==` and `hash`. + /// + /// Held strongly, and deliberately so: a snapshot that outlives its manager keeps the registry — and therefore + /// the interning guarantee — alive, so `snapshot.peripheral === snapshot.peripheral` still holds. The handle's + /// own manager reference is weak, so connecting through an orphaned snapshot fails cleanly with + /// ``PeripheralError/bluetoothUnavailable`` rather than silently minting fresh handles. + /// + /// The guarantee is scoped to a registry generation. `BluetoothActor.shutdown()` empties the handle table, so a + /// handle resolved before a shutdown is *not* `===` the one re-minted after it. Interning still holds on either + /// side of that boundary; only identity across it is given up, along with the stack the handles belonged to. + let registry: any PeripheralRegistryBridge + + /// The interned control handle for this peripheral, on the manager that produced the snapshot. + /// + /// Returns the very same object as `manager.peripheral(id: snapshot.id)` — repeated accesses yield an + /// identical (`===`) instance. + public var peripheral: Peripheral { registry.peripheral(id: id) } + + /// Creates a snapshot. Internal — snapshots originate from the library's scan and restore paths only. + init( + id: String, + cbIdentifier: UUID? = nil, + name: String? = nil, + rssi: Int? = nil, + lastSeen: Date? = nil, + advertisement: AdvertisementData? = nil, + registry: any PeripheralRegistryBridge + ) { + self.id = id + self.cbIdentifier = cbIdentifier + self.name = name + self.rssi = rssi + self.lastSeen = lastSeen + self.advertisement = advertisement + self.registry = registry + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + /// Equality keys on ``id`` only — the identifier is what the library uses to match a snapshot to its live + /// `CBPeripheral`, and the vending registry is an implementation detail. + public static func == (lhs: DiscoveredPeripheral, rhs: DiscoveredPeripheral) -> Bool { + return lhs.id == rhs.id + } +} diff --git a/Sources/ReliaBLE/Models/Events/PeripheralDiscoveryEvent.swift b/Sources/ReliaBLE/Models/Events/PeripheralDiscoveryEvent.swift index a09b3fe..560d17d 100644 --- a/Sources/ReliaBLE/Models/Events/PeripheralDiscoveryEvent.swift +++ b/Sources/ReliaBLE/Models/Events/PeripheralDiscoveryEvent.swift @@ -29,9 +29,14 @@ import CoreBluetooth /// A lightweight, `Sendable` event emitted for each advertisement received while scanning. public struct PeripheralDiscoveryEvent: Identifiable, Hashable, Sendable { - /// Unique identifier for the peripheral as set by CoreBluetooth + /// CoreBluetooth `UUID` identifier assigned by the system. + /// + /// This is **not** the app-facing peripheral identifier (which is a `String` carried by + /// ``Peripheral/id`` and ``DiscoveredPeripheral/id``). Until FR-8.5 provides direct + /// advertisement-to-id correlation, map via ``DiscoveredPeripheral`` or its + /// ``DiscoveredPeripheral/peripheral`` handle. public let id: UUID - + /// The name advertised by the peripheral, if available public let name: String? @@ -42,6 +47,11 @@ public struct PeripheralDiscoveryEvent: Identifiable, Hashable, Sendable { public let advertisement: AdvertisementData /// Create a discovered peripheral event from CoreBluetooth information. + /// + /// - Parameters: + /// - cbPeripheral: The CoreBluetooth peripheral. + /// - advertisement: Parsed advertisement data. + /// - rssi: Signal strength of the advertisement. init(cbPeripheral: CBPeripheral, advertisement: AdvertisementData, rssi: Int) { self.id = cbPeripheral.identifier self.name = cbPeripheral.name ?? advertisement.localName diff --git a/Sources/ReliaBLE/Models/Peripheral.swift b/Sources/ReliaBLE/Models/Peripheral.swift index c700a3a..8bafcb2 100644 --- a/Sources/ReliaBLE/Models/Peripheral.swift +++ b/Sources/ReliaBLE/Models/Peripheral.swift @@ -25,92 +25,209 @@ // SOFTWARE. import Foundation +import Synchronization -/// An immutable, `Sendable` value snapshot of a Bluetooth peripheral and its metadata. +/// A long-lived control handle for a Bluetooth peripheral. /// -/// A `Peripheral` carries no reference to the underlying CoreBluetooth `CBPeripheral`. The live `CBPeripheral` is -/// owned exclusively by the library in an `id`-keyed registry that never escapes its internal concurrency domain. -/// Operations that need the live peripheral (such as ``ReliaBLEManager/connect(to:autoReconnect:)``) forward the snapshot's ``id``; -/// the actor looks up the live reference and throws ``PeripheralError/notFound`` if the snapshot has since gone stale. +/// A `Peripheral` is the object an app holds onto and acts through: it owns ``connect(autoReconnect:)`` and +/// ``disconnect()``, and it exposes the last-known metadata for the device it represents. Unlike a +/// ``DiscoveredPeripheral`` — a per-scan value snapshot — a handle is *stable*: there is exactly **one handle +/// instance per ``id`` per ``ReliaBLEManager``, so it is safe to store one in a view model and rely on reference +/// identity (`===`). /// -/// The integrating app can also construct a `Peripheral` from a known identifier *before* it has been discovered — -/// for example, a wearable bound to the user's account — using ``init(id:)``. Such a snapshot has no -/// ``advertisement`` (and no live reference) until ReliaBLE matches it against a discovered `CBPeripheral`. +/// ## Obtaining a handle /// -/// Because it is a pure value type, a `Peripheral` is freely sendable across isolation domains and safe to hand to -/// the integrating app for UI display. -public struct Peripheral: Sendable, Identifiable, Hashable { - /// Unique identifier for the peripheral. +/// Handles are never constructed directly. Obtain one either from a known identifier, before the device has ever +/// been seen: +/// +/// ```swift +/// let band = manager.peripheral(id: "user-band") +/// ``` +/// +/// …or from a discovery snapshot: +/// +/// ```swift +/// for await discovered in manager.discoveredPeripherals { +/// for snapshot in discovered { +/// let handle = snapshot.peripheral // the same instance `peripheral(id:)` returns +/// } +/// } +/// ``` +/// +/// ## Metadata is last-known, not live +/// +/// ``name``, ``rssi``, ``lastSeen``, ``advertisement``, and ``connectionState`` are synchronous, cached reads of +/// the most recent value the library resolved for this ``id``. They are deliberately *not* `async`, so SwiftUI row +/// bodies can read them inline. Two consequences follow: +/// +/// - **Reads are per-property, not one atomic snapshot.** Reading ``name`` then ``rssi`` can straddle two +/// discovery updates. This is benign for display but is a real part of the contract. +/// - **There is no change notification.** `Peripheral` is a plain `Sendable` class — it is not `@Observable` and +/// publishes nothing. Use ``ReliaBLEManager/discoveredPeripherals`` as the "something changed" tick and re-read +/// handle metadata inside that loop; likewise re-read ``connectionState`` inside a +/// ``ReliaBLEManager/connectionStateChanges`` loop. +/// +/// Metadata survives a radio reset: after the library invalidates its live CoreBluetooth references, the last-known +/// values remain readable while ``connect(autoReconnect:)`` throws ``PeripheralError/notFound`` until the device is +/// rediscovered. +/// +/// ## Concurrency +/// +/// `Peripheral` is a checked `Sendable` class. All mutable state lives inside a single `Mutex`; every other stored +/// property is immutable. The handle holds **no** `CBPeripheral` — live CoreBluetooth objects never leave the +/// library's internal isolation domain, and operations forward by ``id``. +/// +/// The handle's reference to its manager is **weak**: a handle can legitimately outlive the manager that vended it. +/// An orphaned handle keeps its metadata but throws ``PeripheralError/bluetoothUnavailable`` from +/// ``connect(autoReconnect:)`` and ``disconnect()``. +public final class Peripheral: Sendable, Identifiable, Hashable { + /// Everything mutable about a handle, guarded by a single lock. + /// + /// The `weak` manager reference is sound here specifically *because* it is boxed: every read and write happens + /// under the mutex, and the runtime's weak load/zeroing is atomic with respect to deallocation. A bare + /// `weak var` on an `@unchecked Sendable` class would not be. + private struct State { + weak var manager: ReliaBLEManager? + var cbIdentifier: UUID? + var name: String? + var rssi: Int? + var lastSeen: Date? + var advertisement: AdvertisementData? + var connectionState: ConnectionState? + } + + /// Unique, app-facing identifier for the peripheral. /// - /// When provided by the integrating app via ``init(id:)`` this is the app's own identifier. When resolved at - /// discovery time it is the peripheral's advertised name, its local name, or — as a fallback — the CoreBluetooth - /// identifier string. + /// When the app creates the handle via ``ReliaBLEManager/peripheral(id:)`` this is the app's own identifier. + /// When the library resolves it at discovery time it is the peripheral's advertised name, its local name, or — + /// as a fallback — the CoreBluetooth identifier string. public let id: String - /// The CoreBluetooth identifier for the peripheral, used to retrieve it after invalidation. + private let state: Mutex + + /// Creates a handle. **Only ``PeripheralHandleRegistry`` may call this** — the registry is what guarantees the + /// one-instance-per-id invariant, and a second construction site would silently break it. This is enforced by + /// review convention rather than by access control, since the registry lives in its own file. + init(id: String, manager: ReliaBLEManager?) { + self.id = id + self.state = Mutex(State(manager: manager)) + } + + // MARK: - Last-known metadata + + /// The CoreBluetooth identifier for the peripheral, used to re-resolve the live peripheral after invalidation. /// - /// `nil` for an app-constructed peripheral that has not yet been discovered. - public let cbIdentifier: UUID? + /// `nil` until the peripheral has been discovered or restored. + public var cbIdentifier: UUID? { state.withLock { $0.cbIdentifier } } - /// The name advertised by the peripheral, if available. - public let name: String? + /// The name most recently advertised by the peripheral, if any. + public var name: String? { state.withLock { $0.name } } - /// Signal strength indicator (RSSI) of the most recent advertisement. - public let rssi: Int? + /// Signal strength indicator (RSSI) from the most recent advertisement. + public var rssi: Int? { state.withLock { $0.rssi } } - /// The timestamp when the peripheral was last seen. - public let lastSeen: Date? + /// When the peripheral was last seen — or, for a peripheral recovered through state restoration, when its live + /// reference was last bound. + public var lastSeen: Date? { state.withLock { $0.lastSeen } } /// The typed advertisement data from the most recent discovery. /// - /// `nil` until the peripheral has been discovered. Advertisement data is transient, per-discovery information; it - /// is not the peripheral's connected GATT service catalog. - public let advertisement: AdvertisementData? + /// `nil` until the peripheral has been discovered. Advertisement data is transient, per-discovery information; + /// it is not the peripheral's connected GATT service catalog. + public var advertisement: AdvertisementData? { state.withLock { $0.advertisement } } + + /// The last-known connection state for this peripheral, or `nil` if the library is not tracking one. + /// + /// Mirrored from the library's internal connection tracking, including when that tracking is *cleared*: unlike + /// the metadata properties above, this reverts to `nil` when the library drops its connection state (for + /// example after Bluetooth is powered off and live references are invalidated), rather than reporting a state + /// that is known to be false. As with the other cached properties there is no change notification — re-read it + /// inside a ``ReliaBLEManager/connectionStateChanges`` loop. + /// + /// That loop is a complete tick: a clear emits ``ConnectionState/disconnected(reason:)`` carrying + /// ``PeripheralError/bluetoothUnavailable``, so re-reading on every event is sufficient and this property never + /// strands a stale `.connected`. Note the deliberate asymmetry — the event describes the transition that + /// happened, while the handle reports `nil` for "the library is no longer tracking this peripheral." + public var connectionState: ConnectionState? { state.withLock { $0.connectionState } } + + // MARK: - Connection - /// Registers a known peripheral before it has been discovered. + /// Initiates a connection to this peripheral. /// - /// Use this when the integrating app already has a stable identifier for a peripheral — such as a device bound to - /// the user's account — and wants ReliaBLE to match it against the corresponding `CBPeripheral` once discovered. - /// The resulting snapshot has no ``cbIdentifier``, ``name``, ``rssi``, ``lastSeen``, or ``advertisement`` until - /// discovery populates them. + /// - Parameter autoReconnect: When `true` (the default), the library passes + /// `CBConnectPeripheralOptionEnableAutoReconnect` to the system and arms the app-side exponential-backoff + /// ladder for cases the OS option doesn't cover. Set to `false` for one-shot connections where reconnection + /// is not desired. + /// - Throws: ``PeripheralError/notFound`` if the library holds no live reference for this ``id`` — either it + /// has never been discovered, or its reference was invalidated. ``PeripheralError/bluetoothUnavailable`` if + /// Bluetooth has not been set up (for example, not yet authorized), or if the manager that vended this handle + /// has been deallocated or shut down. + public func connect(autoReconnect: Bool = true) async throws { + guard let manager = state.withLock({ $0.manager }) else { throw PeripheralError.bluetoothUnavailable } + + await manager.bluetooth.ensureCentralManager() + try await manager.bluetooth.connect(id: id, autoReconnect: autoReconnect) + } + + /// Initiates a disconnection from this peripheral. /// - /// - Parameter id: The integrating app's unique identifier for the peripheral. - public init(id: String) { - self.init(id: id, cbIdentifier: nil, name: nil, rssi: nil, lastSeen: nil, advertisement: nil) + /// - Throws: ``PeripheralError/notFound`` if the library holds no live reference for this ``id``, or + /// ``PeripheralError/bluetoothUnavailable`` if Bluetooth has not been set up or the vending manager is gone. + public func disconnect() async throws { + guard let manager = state.withLock({ $0.manager }) else { throw PeripheralError.bluetoothUnavailable } + + await manager.bluetooth.ensureCentralManager() + try await manager.bluetooth.disconnect(id: id) } - /// Creates a fully-specified peripheral snapshot. Used internally at discovery time. + // MARK: - Internal mutation + // + // Both entry points are called from `PeripheralHandleRegistry` on behalf of `BluetoothActor`, so writes are + // already serialized by the actor's executor. The lock exists to make *reads* from arbitrary isolation domains + // safe, not to order writes. + // + // FR-10 (#52) will attach the sticky discovery filter, command queue, and GATT readiness state to this type. + // Nothing for it is stored here yet. + + /// Mirrors the resolved discovery/restore snapshot onto the handle. /// - /// - Parameters: - /// - id: Unique identifier for the peripheral. - /// - cbIdentifier: The CoreBluetooth identifier, used to re-resolve the live peripheral after invalidation. - /// - name: The name advertised by the peripheral, if available. - /// - rssi: Signal strength indicator (RSSI) of the most recent advertisement. - /// - lastSeen: The timestamp when the peripheral was last seen. - /// - advertisement: The typed advertisement data from the most recent discovery. - init( - id: String, - cbIdentifier: UUID? = nil, - name: String? = nil, - rssi: Int? = nil, - lastSeen: Date? = nil, - advertisement: AdvertisementData? = nil + /// Values are applied exactly as given: the caller has already merged them (see + /// `BluetoothActor.resolveAndUpsertDiscovered`), so the handle and the snapshot list never disagree. + func applyMetadata( + cbIdentifier: UUID?, + name: String?, + rssi: Int?, + lastSeen: Date?, + advertisement: AdvertisementData? ) { - self.id = id - self.cbIdentifier = cbIdentifier - self.name = name - self.rssi = rssi - self.lastSeen = lastSeen - self.advertisement = advertisement + state.withLock { + $0.cbIdentifier = cbIdentifier + $0.name = name + $0.rssi = rssi + $0.lastSeen = lastSeen + $0.advertisement = advertisement + } } + /// Mirrors a connection-state transition onto the handle. `nil` clears it, for when the library stops tracking + /// a connection state for this peripheral entirely. + func applyConnectionState(_ connectionState: ConnectionState?) { + state.withLock { $0.connectionState = connectionState } + } + + // MARK: - Hashable + public func hash(into hasher: inout Hasher) { hasher.combine(id) } + /// Equality keys on ``id`` only, matching the id-keyed connection-state tracking used throughout the library. + /// + /// Within a single manager this is equivalent to identity, because handles are interned. Across *two* managers + /// it is not: two handles for the same ``id`` vended by different managers compare `==` but are distinct + /// objects on distinct registries, and each can only talk to its own manager. Multi-manager apps must not mix + /// them; use `===` when identity is what you mean. public static func == (lhs: Peripheral, rhs: Peripheral) -> Bool { - // Equality keys on `id` only: the identifier is unique and the matching between `Peripheral` snapshots and - // their live `CBPeripheral` is handled internally by the library. return lhs.id == rhs.id } } diff --git a/Sources/ReliaBLE/Models/PeripheralError.swift b/Sources/ReliaBLE/Models/PeripheralError.swift index 3a4d04f..546acf1 100644 --- a/Sources/ReliaBLE/Models/PeripheralError.swift +++ b/Sources/ReliaBLE/Models/PeripheralError.swift @@ -27,21 +27,24 @@ import CoreBluetooth -/// Errors thrown by peripheral operations such as ``ReliaBLEManager/connect(to:autoReconnect:)``. +/// Errors thrown by peripheral operations on ``Peripheral`` handles, such as +/// ``Peripheral/connect(autoReconnect:)`` and ``Peripheral/disconnect()``. public enum PeripheralError: Error, Sendable, Equatable { /// The peripheral is no longer known to the library. /// - /// A ``Peripheral`` is a value snapshot captured at discovery time. The live CoreBluetooth peripheral it refers to - /// is held internally by the library keyed by ``Peripheral/id``. If that reference has since been invalidated (for - /// example, after Bluetooth reset) the snapshot is stale and operations that require the live peripheral throw - /// this error. + /// Thrown from ``Peripheral/connect(autoReconnect:)`` and ``Peripheral/disconnect()`` when + /// the library holds no live `CBPeripheral` reference for this handle's ``Peripheral/id`` — + /// either the device has never been discovered, or its reference was invalidated (for + /// example, after a Bluetooth reset). case notFound /// Bluetooth is unavailable, so the operation could not be performed. /// - /// Thrown when a peripheral operation is attempted before the underlying `CBCentralManager` exists — for example, - /// because Bluetooth has not been authorized yet. Call ``ReliaBLEManager/authorizeBluetooth()`` and wait for a - /// ready state before retrying. + /// Thrown when a peripheral operation is attempted before the underlying `CBCentralManager` + /// exists — for example, because Bluetooth has not been authorized yet — **or** when the + /// ``ReliaBLEManager`` that vended the ``Peripheral`` handle has been deallocated or shut + /// down. Call ``ReliaBLEManager/authorizeBluetooth()`` and wait for a ready state before + /// retrying; if the manager is gone, create a new one and obtain a fresh handle. case bluetoothUnavailable /// The connection to the peripheral failed. diff --git a/Sources/ReliaBLE/PeripheralHandleRegistry.swift b/Sources/ReliaBLE/PeripheralHandleRegistry.swift new file mode 100644 index 0000000..abee214 --- /dev/null +++ b/Sources/ReliaBLE/PeripheralHandleRegistry.swift @@ -0,0 +1,208 @@ +// +// PeripheralHandleRegistry.swift +// ReliaBLE +// +// Created by Justin Bergen on 8/1/25. +// +// Copyright (c) 2025 Five3 Apps, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation +import Synchronization + +/// The seam through which ``BluetoothActor`` pushes resolved peripheral data out to handles. +/// +/// The actor owns identity resolution, the live `CBPeripheral` map, and connection state; it does **not** own +/// handle instances. This protocol inverts that dependency so the actor can update handles without knowing about +/// ``ReliaBLEManager`` or ``PeripheralHandleRegistry``. +/// +/// ## Contract for implementations +/// +/// Every method is invoked **from the actor's executor**, synchronously. That has consequences: +/// +/// - Implementations must be non-blocking and allocation-light. Whatever they do, the actor's executor waits. +/// - They must **not** spawn `Task { await actor… }`. It compiles fine inside a synchronous function and reorders +/// arbitrarily against the ordered discovery stream. +/// - They must not invoke app-supplied callbacks while holding a lock. +/// - Nothing reachable from this protocol may retain ``ReliaBLEManager`` strongly. The actor stores the bridge, and +/// live stream subscribers retain the actor — a strong manager reference here would pin the manager, its central, +/// and every handle for the lifetime of a single un-terminated `for await`. +/// +/// Direct re-entry into the actor is already impossible: these methods are synchronous and non-throwing, so +/// `await` cannot appear inside them. +protocol PeripheralRegistryBridge: Sendable { + /// Returns the interned handle for `id`, creating it with empty metadata if this is the first request. + /// + /// This is what ``DiscoveredPeripheral/peripheral`` resolves through, which is why it lives on the bridge + /// rather than only on the concrete registry: a snapshot carries this seam and nothing else. + func peripheral(id: String) -> Peripheral + + /// Interns the handle for `id` if it does not exist yet, then mirrors the resolved snapshot onto it. + /// + /// This is deliberately one create-or-update call rather than a separate "ensure" plus "update": it runs once + /// per advertisement per device, which is the library's hottest path, and splitting it would only double lock + /// acquisitions. + func applyDiscovery( + id: String, + cbIdentifier: UUID?, + name: String?, + rssi: Int?, + lastSeen: Date?, + advertisement: AdvertisementData? + ) + + /// Interns the handle for `id` if needed, then mirrors a connection-state transition onto it. + /// + /// A `nil` state means the library no longer tracks a connection state for `id` — pass it when the actor's + /// tracking is cleared, so a handle cannot keep reporting a state that is known to be false. Clearing is the + /// one case that does **not** intern: it corrects an existing handle if there is one, and is otherwise a no-op. + func applyConnectionState(id: String, state: ConnectionState?) + + /// Drops every interned handle. Called from terminal teardown only. + func removeAllHandles() +} + +/// The per-manager store of ``Peripheral`` handles. +/// +/// This type is what makes "one handle instance per id per manager" true. It is owned by ``ReliaBLEManager`` rather +/// than by ``BluetoothActor``, because ``ReliaBLEManager/peripheral(id:)`` must be synchronous — callable from a +/// SwiftUI body, and callable before any `CBCentralManager` exists. Interning on the actor would force `async` on +/// the library's most basic entry point. +/// +/// There is deliberately **no** process-global registry. Each manager is an independent BLE stack, so two managers +/// mean two registries and two distinct handles for the same physical device. +/// +/// ## Growth +/// +/// Handles are held **strongly** and keyed by resolved id, and every discovery interns one — including for devices +/// the app never asks about. Nothing evicts them: `BluetoothActor.shutdown()` empties the table, but that is +/// test/harness teardown, so in a shipping app **the registry retains one handle per distinct id observed for the +/// lifetime of the manager**. A long-running background scan in a dense RF environment is therefore the case to +/// watch; each entry is small (an id plus last-known metadata) and bounded by the number of distinct devices seen, +/// not by advertisement volume. +/// +/// Weak-value storage (`[String: WeakBox]` with prune-on-insert) is the known stronger answer — +/// interning identity would then last exactly as long as the app holds a reference — and is deferred rather than +/// rejected. Revisit it if the observed-device count per session becomes unbounded in practice. +/// +/// ## Retain graph +/// +/// ```text +/// manager → registry (strong) → handles (strong) → manager (weak) +/// manager → actor (strong) → registry-as-bridge (strong) → manager (weak) +/// snapshot → registry (strong) +/// ``` +/// +/// Acyclic in every direction. The registry conforms to ``PeripheralRegistryBridge`` itself, rather than through a +/// separate adapter object, which is what makes the "nothing actor-reachable retains the manager strongly" rule +/// hold automatically — the registry's only manager reference is already weak. +final class PeripheralHandleRegistry: PeripheralRegistryBridge, Sendable { + private struct Storage { + weak var manager: ReliaBLEManager? + var handles: [String: Peripheral] = [:] + var isAttached = false + } + + private let storage = Mutex(Storage()) + + /// Creates an unattached registry. + /// + /// The manager is supplied afterwards via ``attach(manager:)``. This two-phase construction is not stylistic: + /// `ReliaBLEManager.init` cannot pass `self` to the registry before all of its stored properties are + /// initialized, and `bluetooth` needs the registry — so the registry must exist first and learn about its + /// manager second. + init() {} + + /// Completes construction by binding the owning manager. Called once, at the end of `ReliaBLEManager.init`. + /// + /// Handles capture their weak manager reference at creation time, from this stored reference, so nothing may + /// call ``peripheral(id:)`` before this runs. + func attach(manager: ReliaBLEManager) { + storage.withLock { + $0.manager = manager + $0.isAttached = true + } + } + + // MARK: - PeripheralRegistryBridge + + /// Returns the interned handle for `id`, creating it with empty metadata if this is the first request. + /// + /// Synchronous and lock-protected: no actor hop, and safe to call before the central manager exists. + func peripheral(id: String) -> Peripheral { + storage.withLock { storage in + assert( + storage.isAttached, + "PeripheralHandleRegistry.peripheral(id:) called before attach(manager:); the handle would be " + + "permanently orphaned." + ) + + if let existing = storage.handles[id] { return existing } + + let handle = Peripheral(id: id, manager: storage.manager) + storage.handles[id] = handle + + return handle + } + } + + func applyDiscovery( + id: String, + cbIdentifier: UUID?, + name: String?, + rssi: Int?, + lastSeen: Date?, + advertisement: AdvertisementData? + ) { + // Lock order is registry → handle, and this avoids nesting entirely: take the handle out from under the + // registry lock, release it, and only then touch the handle's own lock. + let handle = peripheral(id: id) + handle.applyMetadata( + cbIdentifier: cbIdentifier, + name: name, + rssi: rssi, + lastSeen: lastSeen, + advertisement: advertisement + ) + } + + func applyConnectionState(id: String, state: ConnectionState?) { + // A clear is the one case that must not intern. Minting a handle purely to write `nil` onto it would grow + // the table with entries nobody requested and nobody can observe — a handle created later reads `nil` + // anyway. A real state, by contrast, must intern: a handle obtained after the transition has to report it + // rather than diverge from the actor's tracking. + guard let handle = state == nil ? existingHandle(id: id) : peripheral(id: id) else { return } + handle.applyConnectionState(state) + } + + /// Returns the handle for `id` only if one has already been interned, without creating one. + private func existingHandle(id: String) -> Peripheral? { + storage.withLock { $0.handles[id] } + } + + /// Drops every interned handle. Called from `BluetoothActor.shutdown()`. + /// + /// Handles the app still holds keep working — orphaned, throwing ``PeripheralError/bluetoothUnavailable`` — but + /// the registry stops growing along with a stack that is already dead. Note that a radio reset + /// (`invalidatePeripherals`) deliberately does *not* do this: a handle must survive one, metadata intact. + func removeAllHandles() { + storage.withLock { $0.handles.removeAll() } + } +} diff --git a/Sources/ReliaBLE/ReliaBLEConfig.swift b/Sources/ReliaBLE/ReliaBLEConfig.swift index 248b248..818a1c3 100644 --- a/Sources/ReliaBLE/ReliaBLEConfig.swift +++ b/Sources/ReliaBLE/ReliaBLEConfig.swift @@ -48,8 +48,8 @@ public struct ReliaBLEConfig: Sendable { public var loggingEnabled = false /// Policy controlling the behavior of the library-side exponential backoff supplement - /// for automatic reconnection. Enable/disable is a per-connect choice on - /// ``ReliaBLEManager/connect(to:autoReconnect:)``; this policy governs *how* the + /// for automatic reconnection. The enable/disable decision is a per-connect choice on + /// ``Peripheral/connect(autoReconnect:)``; this policy governs *how* the /// library retries when auto-reconnect is active. public var reconnectPolicy = ReconnectPolicy() diff --git a/Sources/ReliaBLE/ReliaBLEManager.swift b/Sources/ReliaBLE/ReliaBLEManager.swift index 1eeae57..14081c9 100644 --- a/Sources/ReliaBLE/ReliaBLEManager.swift +++ b/Sources/ReliaBLE/ReliaBLEManager.swift @@ -45,6 +45,11 @@ public final class ReliaBLEManager: Sendable { /// capture `[weak self]`. Live stream subscribers retain the actor until terminated. let bluetooth: BluetoothActor + /// Per-manager store of ``Peripheral`` handles, which is what makes ``peripheral(id:)`` synchronous and what + /// guarantees one handle instance per id per manager. Owned here rather than by the actor because handles must + /// be obtainable from a SwiftUI body and before any `CBCentralManager` exists. + let handleRegistry: PeripheralHandleRegistry + /// Initializes the ReliaBLEManager with the provided configuration, or a default configuration if none is provided. /// /// Initializing a ReliaBLEManager does not start the `CBCentralManager` unless the user has already authorized @@ -57,12 +62,20 @@ public final class ReliaBLEManager: Sendable { loggingService = LoggingService(levels: config.logLevels, writers: config.logWriters, queue: config.logQueue) loggingService.enabled = config.loggingEnabled + // Two-phase construction, and it has to be this way: `self` is unusable until every stored property is + // initialized, so the registry cannot be handed its manager up front — and `bluetooth` needs the registry. + // Create the registry unattached, inject it, then bind the manager once initialization is complete. + handleRegistry = PeripheralHandleRegistry() + bluetooth = BluetoothActor( log: loggingService, reconnectPolicy: config.reconnectPolicy, - restoreIdentifier: config.restoreIdentifier + restoreIdentifier: config.restoreIdentifier, + registry: handleRegistry ) + handleRegistry.attach(manager: self) + // `init` stays synchronous and kicks off central creation via a fire-and-forget `Task` // rather than awaiting it, so the initializer never blocks. To prevent an operation invoked // immediately after `init` from racing ahead of that setup, every operational entry point @@ -156,7 +169,7 @@ public final class ReliaBLEManager: Sendable { /// A multi-subscriber `AsyncStream` that emits the current de-duplicated list of discovered /// peripherals each time it changes. Each property access returns a fresh, independent stream; /// the current list is replayed as the first element on subscription. - public var discoveredPeripherals: AsyncStream<[Peripheral]> { + public var discoveredPeripherals: AsyncStream<[DiscoveredPeripheral]> { bluetooth.discoveredPeripheralsStream() } @@ -178,37 +191,33 @@ public final class ReliaBLEManager: Sendable { await bluetooth.stopScanning() } - // MARK: - Connection + // MARK: - Peripherals - /// Initiates a connection to a previously discovered peripheral. + /// Returns the control handle for a peripheral identifier, creating it on first request. /// - /// The ``Peripheral`` is a value snapshot captured at discovery time. This method forwards its ``Peripheral/id`` - /// to the live CoreBluetooth peripheral held internally and requests a connection. + /// This is the entry point for acting on a peripheral. Connecting, disconnecting, and reading last-known + /// metadata all live on the returned ``Peripheral``: /// - /// - Parameter peripheral: A peripheral previously delivered via ``discoveredPeripherals``. - /// - Parameter autoReconnect: When `true` (the default), the library passes - /// `CBConnectPeripheralOptionEnableAutoReconnect` to the system and arms the app-side - /// exponential-backoff ladder for cases the OS option doesn't cover. Set to `false` for - /// one-shot connections where reconnection is not desired. - /// - Throws: ``PeripheralError/notFound`` if the peripheral's live reference has been invalidated (a stale - /// snapshot), or ``PeripheralError/bluetoothUnavailable`` if Bluetooth has not been set up (for example, not - /// yet authorized). - public func connect(to peripheral: Peripheral, autoReconnect: Bool = true) async throws { - await bluetooth.ensureCentralManager() - try await bluetooth.connect(id: peripheral.id, autoReconnect: autoReconnect) - } - - /// Initiates a disconnection from a previously connected peripheral. + /// ```swift + /// let band = manager.peripheral(id: "user-band") + /// try await band.connect() + /// ``` /// - /// The ``Peripheral`` is a value snapshot. This forwards its ``Peripheral/id`` to the live - /// CoreBluetooth peripheral and cancels the connection. + /// Handles are **interned per manager**: calling this repeatedly with the same `id` returns the identical + /// (`===`) object, and it is the same object ``DiscoveredPeripheral/peripheral`` resolves to. That makes a + /// handle safe to store in a view model and rely on as a stable identity. /// - /// - Parameter peripheral: A peripheral previously delivered via ``discoveredPeripherals``. - /// - Throws: ``PeripheralError/notFound`` if the peripheral's live reference has been - /// invalidated, or ``PeripheralError/bluetoothUnavailable`` if Bluetooth has not been set up. - public func disconnect(from peripheral: Peripheral) async throws { - await bluetooth.ensureCentralManager() - try await bluetooth.disconnect(id: peripheral.id) + /// The call is synchronous and requires no Bluetooth setup, so a peripheral the app already knows about — a + /// wearable bound to the user's account, say — can be represented before it has ever been seen. Such a handle + /// carries no metadata and throws ``PeripheralError/notFound`` from ``Peripheral/connect(autoReconnect:)`` until + /// discovery or state restoration matches it to a real device. Matching is by identifier: a handle created as + /// `"MyBand"` binds to a device that advertises the name `MyBand`. + /// + /// Two managers are two independent stacks, so each vends its own distinct handle for the same `id`. + /// + /// - Parameter id: The app-facing peripheral identifier. + public func peripheral(id: String) -> Peripheral { + handleRegistry.peripheral(id: id) } } diff --git a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index a30a224..4ff1060 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -62,12 +62,15 @@ struct ReliaBLEManagerTests { // Compile-time proof that stream factories stay `nonisolated` — sync getters must not require `await`. let _: AsyncStream = manager.state let _: AsyncStream = manager.peripheralDiscoveries - let _: AsyncStream<[Peripheral]> = manager.discoveredPeripherals + let _: AsyncStream<[DiscoveredPeripheral]> = manager.discoveredPeripherals let _: AsyncStream = manager.connectionStateChanges + + // `peripheral(id:)` is synchronous and nonisolated — callable from any isolation domain. + _ = manager.peripheral(id: "unused") + await manager.startScanning() await manager.startScanning(services: []) await manager.stopScanning() - try? await manager.connect(to: Peripheral(id: "unused")) // `authorizeBluetooth()` suspends until the authorization decision resolves; under the mock's // undetermined default that never happens, so drive it from a child task and cancel after a @@ -81,9 +84,10 @@ struct ReliaBLEManagerTests { } @Test func peripheralIsSendable() async throws { - let peripheral = Peripheral(id: "sendable-id") + let manager = await Mock.makeManager() + let peripheral = manager.peripheral(id: "sendable-id") - // Capturing the value in a `Task.detached` closure is a compile-time proof that + // Capturing the class handle in a `Task.detached` closure is a compile-time proof that // `Peripheral` is `Sendable` — the closure crosses an isolation boundary. let capturedId = await Task.detached { peripheral.id }.value @@ -106,25 +110,22 @@ struct ReliaBLEManagerTests { #expect(BluetoothState.unauthorized(.allowedAlways).description == "Unauthorized") } - @Test func peripheralEqualityAndHashKeyOnIDOnly() { - let a = Peripheral(id: "shared-id") - let b = Peripheral(id: "shared-id") - let c = Peripheral(id: "other-id") + @Test func peripheralIdInternsSingleInstance() async { + let manager = await Mock.makeManager() - // `init(id:)` leaves every discovery-populated field empty. + // Under interning, repeated calls with the same id return the identical object. + let a = manager.peripheral(id: "shared-id") + let b = manager.peripheral(id: "shared-id") + #expect(a === b) + #expect(a.id == "shared-id") + #expect(b.id == "shared-id") + + // Fresh handle carries empty metadata. #expect(a.cbIdentifier == nil) #expect(a.name == nil) #expect(a.rssi == nil) #expect(a.lastSeen == nil) #expect(a.advertisement == nil) - - // Equality and hashing key on `id` only. - #expect(a == b) - #expect(a != c) - #expect(a.hashValue == b.hashValue) - - let set: Set = [a, b, c] - #expect(set.count == 2) } @Test func advertisementDataExtractsTypedValues() { @@ -327,14 +328,15 @@ struct ReliaBLEManagerTests { await manager.startScanning() - let peripheral = await Mock.waitForPeripheral( + let discovered = await Mock.waitForDiscovered( id: Mock.testPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 ) - #expect(peripheral?.id == Mock.testPeripheralID) - #expect(peripheral?.advertisement?.localName == Mock.testPeripheralID) - #expect(peripheral?.cbIdentifier != nil) + #expect(discovered?.id == Mock.testPeripheralID) + // Preserve at least one assertion against snapshot fields — the snapshot the stream emitted. + #expect(discovered?.advertisement?.localName == Mock.testPeripheralID) + #expect(discovered?.cbIdentifier != nil) // The mock's connection-lifecycle peripheral advertises concurrently on the same shared // central, so filter for this test's peripheral rather than racing on whichever arrives first. @@ -377,7 +379,7 @@ struct ReliaBLEManagerTests { await Mock.ensureReady(manager) await manager.startScanning() - _ = await Mock.waitForPeripheral( + _ = await Mock.waitForDiscovered( id: Mock.testPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 @@ -408,7 +410,7 @@ struct ReliaBLEManagerTests { await Mock.ensureReady(manager) await manager.startScanning() - _ = await Mock.waitForPeripheral( + _ = await Mock.waitForDiscovered( id: Mock.testPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 @@ -424,6 +426,93 @@ struct ReliaBLEManagerTests { #expect(await Mock.waitForState("Ready", on: manager)) } + @Test func discoveredPeripheralSugarReturnsInternedHandle() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.testPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let snapshot = try #require(snap) + await manager.stopScanning() + + // The `.peripheral` sugar returns the very same instance `peripheral(id:)` would. + let fromSugar = snapshot.peripheral + let fromManager = manager.peripheral(id: snapshot.id) + #expect(fromSugar === fromManager) + #expect(fromSugar.id == Mock.testPeripheralID) + } + + @Test func knownIdHandleReceivesMetadataOnDiscovery() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + // Create the handle first — before any discovery. + let handle = manager.peripheral(id: Mock.testPeripheralID) + #expect(handle.rssi == nil) + #expect(handle.lastSeen == nil) + #expect(handle.advertisement == nil) + + await manager.startScanning() + _ = await Mock.waitForDiscovered( + id: Mock.testPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + await manager.stopScanning() + + // The same instance now carries metadata from discovery. + #expect(handle.rssi != nil) + #expect(handle.lastSeen != nil) + #expect(handle.advertisement != nil) + #expect(handle.advertisement?.localName == Mock.testPeripheralID) + } + + @Test func discoveredPeripheralsStreamElementType() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + await manager.startScanning() + _ = await Mock.waitForDiscovered( + id: Mock.testPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + await manager.stopScanning() + + // The replayed list element type is [DiscoveredPeripheral]. + var iterator = manager.discoveredPeripherals.makeAsyncIterator() + let replayed: [DiscoveredPeripheral]? = await iterator.next() + #expect(replayed?.contains(where: { $0.id == Mock.testPeripheralID }) == true) + } + + @Test func handleMetadataAppliedBeforeBroadcast() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + let handle = manager.peripheral(id: Mock.testPeripheralID) + let stream = manager.discoveredPeripherals + + await manager.startScanning() + + // On the first element containing the test id, immediately assert the handle + // carries metadata — no polling, just a direct assertion after the element arrives. + var found = false + for await list in stream { + if list.contains(where: { $0.id == Mock.testPeripheralID }) { + #expect(handle.rssi != nil) + found = true + break + } + } + #expect(found) + + await manager.stopScanning() + } + // MARK: - Connection @Test func connectToDiscoveredPeripheralSucceeds() async throws { @@ -431,7 +520,7 @@ struct ReliaBLEManagerTests { await Mock.ensureReady(manager) await manager.startScanning() - let peripheral = await Mock.waitForPeripheral( + let discovered = await Mock.waitForDiscovered( id: Mock.testPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 @@ -440,27 +529,67 @@ struct ReliaBLEManagerTests { // Stop scanning before any potential throw so leaked scan state can't affect later tests. await manager.stopScanning() - let discovered = try #require(peripheral) + let handle = try #require(discovered).peripheral // The live `CBPeripheral` is registered under this snapshot's id, so connect must not throw. - try await manager.connect(to: discovered) + try await handle.connect() } @Test func connectToUnknownPeripheralThrows() async throws { let manager = await Mock.makeManager() - let staleSnapshot = Peripheral(id: "never-discovered") + // ensureReady brings the central online so `.notFound` is deterministic. + await Mock.ensureReady(manager) + let handle = manager.peripheral(id: "never-discovered") - // Connecting to a peripheral that was never discovered must throw. Which `PeripheralError` is - // thrown depends on whether a central manager exists in the shared actor at the time: `.notFound` - // when it does (the id is simply not in the live registry) or `.bluetoothUnavailable` when it - // does not (Bluetooth was never set up). Either is a correct outcome. do { - try await manager.connect(to: staleSnapshot) - Issue.record("Expected connect(to:) to throw for an unknown peripheral") + try await handle.connect() + Issue.record("Expected connect() to throw for an unknown peripheral") } catch let error as PeripheralError { - #expect(error == .notFound || error == .bluetoothUnavailable) + #expect(error == .notFound) } } + @Test func handleConnectDisconnectLifecycle() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + let subscriberBaseline = await manager.bluetooth.testConnectionStateSubscriberCount() + var changes = manager.connectionStateChanges.makeAsyncIterator() + #expect(await Mock.waitForConnectionSubscription(on: manager, above: subscriberBaseline)) + + await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + try await handle.connect() + + let connecting = await changes.next() + #expect(connecting?.peripheralId == handle.id) + #expect(connecting?.state == .connecting) + #expect(handle.connectionState == .connecting) + + let connected = await changes.next() + #expect(connected?.peripheralId == handle.id) + #expect(connected?.state == .connected) + #expect(handle.connectionState == .connected) + + try await handle.disconnect() + + let disconnecting = await changes.next() + #expect(disconnecting?.state == .disconnecting) + #expect(handle.connectionState == .disconnecting) + + let disconnected = await changes.next() + #expect(disconnected?.state == .disconnected(reason: nil)) + #expect(handle.connectionState == .disconnected(reason: nil)) + } + // MARK: - Logging @Test func loggingEnabledExercisesLogPaths() async throws { @@ -568,28 +697,28 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager() await Mock.ensureReady(manager) + let subscriberBaseline = await manager.bluetooth.testConnectionStateSubscriberCount() var changes = manager.connectionStateChanges.makeAsyncIterator() - // Force an actor hop so registration completes before we connect. - await manager.bluetooth.updateState() + #expect(await Mock.waitForConnectionSubscription(on: manager, above: subscriberBaseline)) // Discover the connectable test peripheral. await manager.startScanning() - let peripheral = await Mock.waitForPeripheral( + let discovered = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 ) - let discovered = try #require(peripheral) + let handle = try #require(discovered).peripheral await manager.stopScanning() - try await manager.connect(to: discovered) + try await handle.connect() let connecting = await changes.next() - #expect(connecting?.peripheralId == discovered.id) + #expect(connecting?.peripheralId == handle.id) #expect(connecting?.state == .connecting) let connected = await changes.next() - #expect(connected?.peripheralId == discovered.id) + #expect(connected?.peripheralId == handle.id) #expect(connected?.state == .connected) } @@ -599,32 +728,33 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager() await Mock.ensureReady(manager) + let subscriberBaseline = await manager.bluetooth.testConnectionStateSubscriberCount() var changes = manager.connectionStateChanges.makeAsyncIterator() - await manager.bluetooth.updateState() + #expect(await Mock.waitForConnectionSubscription(on: manager, above: subscriberBaseline)) await manager.startScanning() - let peripheral = await Mock.waitForPeripheral( + let discovered = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 ) - let discovered = try #require(peripheral) + let handle = try #require(discovered).peripheral await manager.stopScanning() - try await manager.connect(to: discovered) + try await handle.connect() // Drain .connecting and .connected. _ = await changes.next() _ = await changes.next() - try await manager.disconnect(from: discovered) + try await handle.disconnect() let disconnecting = await changes.next() - #expect(disconnecting?.peripheralId == discovered.id) + #expect(disconnecting?.peripheralId == handle.id) #expect(disconnecting?.state == .disconnecting) let disconnected = await changes.next() - #expect(disconnected?.peripheralId == discovered.id) + #expect(disconnected?.peripheralId == handle.id) #expect(disconnected?.state == .disconnected(reason: nil)) } @@ -637,16 +767,17 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager() await Mock.ensureReady(manager) + let subscriberBaseline = await manager.bluetooth.testConnectionStateSubscriberCount() var changes = manager.connectionStateChanges.makeAsyncIterator() - await manager.bluetooth.updateState() + #expect(await Mock.waitForConnectionSubscription(on: manager, above: subscriberBaseline)) await manager.startScanning() - let peripheral = await Mock.waitForPeripheral( + let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 ) - let discovered = try #require(peripheral) + let handle = try #require(snap).peripheral await manager.stopScanning() // Force a clean disconnection on the spec to reset any lingering @@ -658,14 +789,14 @@ struct ReliaBLEManagerTests { // interfere with mock advertising while the previous stack tears down. Mock.connectionTestDelegate.connectionResult = .failure(CBMError(.connectionTimeout)) - try await manager.connect(to: discovered) + try await handle.connect() let connecting = await changes.next() let failed = await changes.next() - #expect(connecting?.peripheralId == discovered.id) + #expect(connecting?.peripheralId == handle.id) #expect(connecting?.state == .connecting) - #expect(failed?.peripheralId == discovered.id) + #expect(failed?.peripheralId == handle.id) #expect(failed?.state == .failed(reason: .connectionTimeout)) } @@ -683,15 +814,15 @@ struct ReliaBLEManagerTests { _ = await manager.currentConnectionStates await manager.startScanning() - let peripheral = await Mock.waitForPeripheral( + let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 ) - let discovered = try #require(peripheral) + let handle = try #require(snap).peripheral await manager.stopScanning() - try await manager.connect(to: discovered) + try await handle.connect() // Both subscribers see the .connecting event. let a1 = await subscriberA.next() @@ -723,19 +854,20 @@ struct ReliaBLEManagerTests { await Mock.ensureReady(manager) await manager.bluetooth.setReconnectPolicy(Self.testReconnectPolicy) + let subscriberBaseline = await manager.bluetooth.testConnectionStateSubscriberCount() var changes = manager.connectionStateChanges.makeAsyncIterator() - await manager.bluetooth.updateState() + #expect(await Mock.waitForConnectionSubscription(on: manager, above: subscriberBaseline)) await manager.startScanning() - let peripheral = await Mock.waitForPeripheral( + let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 ) - let discovered = try #require(peripheral) + let handle = try #require(snap).peripheral await manager.stopScanning() - try await manager.connect(to: discovered) + try await handle.connect() // Drain .connecting and .connected. let c1 = await changes.next() @@ -767,7 +899,7 @@ struct ReliaBLEManagerTests { #expect(!libraryActive, "Expected no .library reconnect state, got \(states)") // Cleanup: explicit disconnect to cancel any pending reconnect state. - try? await manager.disconnect(from: discovered) + try? await handle.disconnect() try? await Task.sleep(nanoseconds: 200_000_000) } @@ -789,19 +921,19 @@ struct ReliaBLEManagerTests { let changes = manager.connectionStateChanges await manager.startScanning() - let peripheral = await Mock.waitForPeripheral( + let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 ) - let discovered = try #require(peripheral) + let handle = try #require(snap).peripheral await manager.stopScanning() // Force a clean disconnection to reset any lingering mock state. Mock.connectionTestSpec.simulateDisconnection() try? await Task.sleep(nanoseconds: 100_000_000) - try await manager.connect(to: discovered) + try await handle.connect() let events = await drainConnectionStateChanges(from: changes, withinNanoseconds: 5_000_000_000) let states = events.map { $0.state } @@ -850,7 +982,7 @@ struct ReliaBLEManagerTests { // Cleanup: the ladder has exhausted its attempts, but an explicit disconnect // removes the id from reconnectEnabled so no stray event can re-arm it. - try? await manager.disconnect(from: discovered) + try? await handle.disconnect() var cleanup = ReconnectPolicy() cleanup.maxAttempts = 0 await manager.bluetooth.setReconnectPolicy(cleanup) @@ -867,22 +999,22 @@ struct ReliaBLEManagerTests { let changes = manager.connectionStateChanges await manager.startScanning() - let peripheral = await Mock.waitForPeripheral( + let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 ) - let discovered = try #require(peripheral) + let handle = try #require(snap).peripheral await manager.stopScanning() - try await manager.connect(to: discovered) + try await handle.connect() // Drain .connecting and .connected. _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // Explicit disconnect. - try await manager.disconnect(from: discovered) + try await handle.disconnect() let events = await drainConnectionStateChanges(from: changes, withinNanoseconds: 3_000_000_000) let states = events.map { $0.state } @@ -920,19 +1052,19 @@ struct ReliaBLEManagerTests { let changes = manager.connectionStateChanges await manager.startScanning() - let peripheral = await Mock.waitForPeripheral( + let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 ) - let discovered = try #require(peripheral) + let handle = try #require(snap).peripheral await manager.stopScanning() // Force a clean disconnection to reset any lingering mock state. Mock.connectionTestSpec.simulateDisconnection() try? await Task.sleep(nanoseconds: 100_000_000) - try await manager.connect(to: discovered) + try await handle.connect() let events = await drainConnectionStateChanges(from: changes, withinNanoseconds: 5_000_000_000) let states = events.map { $0.state } @@ -954,7 +1086,7 @@ struct ReliaBLEManagerTests { // Cleanup: cancel the pending reconnect task via explicit disconnect, then // prevent further arming so no stray reconnect fires during subsequent tests. - try? await manager.disconnect(from: discovered) + try? await handle.disconnect() var cleanup = ReconnectPolicy() cleanup.maxAttempts = 0 await manager.bluetooth.setReconnectPolicy(cleanup) @@ -977,17 +1109,17 @@ struct ReliaBLEManagerTests { let changes = manager.connectionStateChanges await manager.startScanning() - let peripheral = await Mock.waitForPeripheral( + let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 ) - let discovered = try #require(peripheral) + let handle = try #require(snap).peripheral await manager.stopScanning() // Connect with autoReconnect: false — the OS option is NOT passed, and the // library ladder is NOT armed. - try await manager.connect(to: discovered, autoReconnect: false) + try await handle.connect(autoReconnect: false) // Drain .connecting and .connected. _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) @@ -995,7 +1127,7 @@ struct ReliaBLEManagerTests { // Inject an unexpected drop via the test hook (mock would emit isReconnecting: false anyway // since the OS option wasn't passed, but we use the hook for explicitness). - await manager.bluetooth.testInjectDisconnect(for: discovered.id, isReconnecting: false) + await manager.bluetooth.testInjectDisconnect(for: handle.id, isReconnecting: false) let events = await drainConnectionStateChanges(from: changes, withinNanoseconds: 3_000_000_000) let states = events.map { $0.state } @@ -1012,7 +1144,7 @@ struct ReliaBLEManagerTests { #expect(!hasReconnecting, "Expected no .reconnecting events when autoReconnect is false") // Cleanup: explicit disconnect and prevent further reconnect attempts. - try? await manager.disconnect(from: discovered) + try? await handle.disconnect() var cleanup = ReconnectPolicy() cleanup.maxAttempts = 0 await manager.bluetooth.setReconnectPolicy(cleanup) @@ -1029,25 +1161,25 @@ struct ReliaBLEManagerTests { let changes = manager.connectionStateChanges await manager.startScanning() - let peripheral = await Mock.waitForPeripheral( + let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 ) - let discovered = try #require(peripheral) + let handle = try #require(snap).peripheral await manager.stopScanning() // Connect with autoReconnect: true (default). The library ladder is armed // and the OS option is passed. We inject an OS give-up (isReconnecting: false) // via the test hook to simulate the OS giving up on its own reconnect. - try await manager.connect(to: discovered) + try await handle.connect() // Drain .connecting and .connected. _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // Inject OS give-up: isReconnecting: false unexpected disconnect. - await manager.bluetooth.testInjectDisconnect(for: discovered.id, isReconnecting: false) + await manager.bluetooth.testInjectDisconnect(for: handle.id, isReconnecting: false) // Observe .disconnected(reason:). let c1 = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) @@ -1067,7 +1199,7 @@ struct ReliaBLEManagerTests { #expect(nextRetryAt != nil) // Cleanup: cancel the pending reconnect task via explicit disconnect. - try? await manager.disconnect(from: discovered) + try? await handle.disconnect() var cleanup = ReconnectPolicy() cleanup.maxAttempts = 0 await manager.bluetooth.setReconnectPolicy(cleanup) @@ -1093,21 +1225,21 @@ struct ReliaBLEManagerTests { let changes = manager.connectionStateChanges await manager.startScanning() - let peripheral = await Mock.waitForPeripheral( + let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 ) - let discovered = try #require(peripheral) + let handle = try #require(snap).peripheral await manager.stopScanning() - try await manager.connect(to: discovered) + try await handle.connect() _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // Arm the library ladder, then cancel it mid-sleep with an explicit disconnect. - await manager.bluetooth.testInjectDisconnect(for: discovered.id, isReconnecting: false) + await manager.bluetooth.testInjectDisconnect(for: handle.id, isReconnecting: false) let disconnected = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) guard case .disconnected = disconnected?.state else { @@ -1122,7 +1254,7 @@ struct ReliaBLEManagerTests { } #expect(attempt == 1) - try await manager.disconnect(from: discovered) + try await handle.disconnect() let events = await drainConnectionStateChanges(from: changes, withinNanoseconds: 1_000_000_000) let states = events.map(\.state) @@ -1152,23 +1284,23 @@ struct ReliaBLEManagerTests { let changes = manager.connectionStateChanges await manager.startScanning() - let peripheral = await Mock.waitForPeripheral( + let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 ) - let discovered = try #require(peripheral) + let handle = try #require(snap).peripheral await manager.stopScanning() - try await manager.connect(to: discovered) + try await handle.connect() _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // .connecting _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // .connected // Simulate an explicit disconnect that CoreBluetooth reports WITH a benign underlying error. // The contract is that an app-initiated disconnect reports `reason: nil` regardless. - await manager.bluetooth.testSeedIntentionalDisconnect(discovered.id) + await manager.bluetooth.testSeedIntentionalDisconnect(handle.id) await manager.bluetooth.testInjectDisconnect( - for: discovered.id, + for: handle.id, isReconnecting: false, error: NSError(domain: "test.explicit", code: 1) ) @@ -1202,20 +1334,20 @@ struct ReliaBLEManagerTests { let changes = manager.connectionStateChanges await manager.startScanning() - let peripheral = await Mock.waitForPeripheral( + let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 ) - let discovered = try #require(peripheral) + let handle = try #require(snap).peripheral await manager.stopScanning() - try await manager.connect(to: discovered) + try await handle.connect() _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // .connecting _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // .connected // Unexpected drop arms the library ladder; scheduling must survive the non-finite delay. - await manager.bluetooth.testInjectDisconnect(for: discovered.id, isReconnecting: false) + await manager.bluetooth.testInjectDisconnect(for: handle.id, isReconnecting: false) _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // .disconnected let reconnecting = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) @@ -1225,7 +1357,7 @@ struct ReliaBLEManagerTests { } #expect(attempt == 1) - try await manager.disconnect(from: discovered) + try await handle.disconnect() await manager.bluetooth.setReconnectPolicy(ReconnectPolicy(maxAttempts: 0)) try? await Task.sleep(nanoseconds: 200_000_000) } @@ -1240,21 +1372,21 @@ struct ReliaBLEManagerTests { let changes = manager.connectionStateChanges await manager.startScanning() - let peripheral = await Mock.waitForPeripheral( + let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 ) - let discovered = try #require(peripheral) + let handle = try #require(snap).peripheral await manager.stopScanning() - try await manager.connect(to: discovered) + try await handle.connect() _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // First unexpected drop → ladder attempt 1, then successful reconnect. - await manager.bluetooth.testInjectDisconnect(for: discovered.id, isReconnecting: false) + await manager.bluetooth.testInjectDisconnect(for: handle.id, isReconnecting: false) _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // .disconnected let firstLadder = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) @@ -1271,7 +1403,7 @@ struct ReliaBLEManagerTests { #expect(reconnectConnected?.state == .connected) // Second unexpected drop must start a fresh ladder at attempt 1, not continue at 2. - await manager.bluetooth.testInjectDisconnect(for: discovered.id, isReconnecting: false) + await manager.bluetooth.testInjectDisconnect(for: handle.id, isReconnecting: false) _ = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) // .disconnected let secondLadder = await firstConnectionStateChange(from: changes, withinNanoseconds: 5_000_000_000) @@ -1281,7 +1413,7 @@ struct ReliaBLEManagerTests { } #expect(secondAttempt == 1) - try? await manager.disconnect(from: discovered) + try? await handle.disconnect() await manager.bluetooth.setReconnectPolicy(ReconnectPolicy(maxAttempts: 0)) try? await Task.sleep(nanoseconds: 200_000_000) } @@ -1318,18 +1450,18 @@ struct ReliaBLEManagerTests { _ = await manager.currentConnectionStates await manager.startScanning() - let peripheral = await Mock.waitForPeripheral( + let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 ) - let discovered = try #require(peripheral) + let handle = try #require(snap).peripheral await manager.stopScanning() Mock.connectionTestSpec.simulateDisconnection() try? await Task.sleep(nanoseconds: 100_000_000) - try await manager.connect(to: discovered) + try await handle.connect() // Exhaust the single-attempt ladder: // connecting → failed → reconnecting(1) → connecting → failed (give-up). @@ -1359,7 +1491,7 @@ struct ReliaBLEManagerTests { } // Intent survives give-up: a later unexpected drop must arm a fresh ladder at attempt 1. - await manager.bluetooth.testInjectDisconnect(for: discovered.id, isReconnecting: false) + await manager.bluetooth.testInjectDisconnect(for: handle.id, isReconnecting: false) let s5 = await changes.next() guard case .disconnected = s5?.state else { @@ -1374,7 +1506,7 @@ struct ReliaBLEManagerTests { } #expect(a2 == 1) - try? await manager.disconnect(from: discovered) + try? await handle.disconnect() await manager.bluetooth.setReconnectPolicy(ReconnectPolicy(maxAttempts: 0)) try? await Task.sleep(nanoseconds: 200_000_000) } @@ -1444,19 +1576,19 @@ struct ReliaBLEManagerTests { await manager1.bluetooth.testClearPersistedReconnectIntent() await manager1.startScanning() - let peripheral = await Mock.waitForPeripheral( + let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager1, withinNanoseconds: 3_000_000_000 ) - let discovered = try #require(peripheral) + let handle = try #require(snap).peripheral await manager1.stopScanning() - try await manager1.connect(to: discovered) + try await handle.connect() _ = await pollUntil(timeout: 3.0) { - await manager1.currentConnectionStates[discovered.id] == .connected + await manager1.currentConnectionStates[handle.id] == .connected } - #expect(await manager1.bluetooth.testPersistedReconnectIntent().contains(discovered.id)) + #expect(await manager1.bluetooth.testPersistedReconnectIntent().contains(handle.id)) // Cold relaunch: shut down stack 1. Central deinit may zero virtualConnections, so // re-mark the spec connected before install — persisted intent survives in UserDefaults. @@ -1539,15 +1671,15 @@ struct ReliaBLEManagerTests { await manager1.bluetooth.testClearPersistedReconnectIntent() await manager1.startScanning() - let connectionPeripheral = await Mock.waitForPeripheral( + let connectionPeripheral = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager1, withinNanoseconds: 3_000_000_000 ) - let connected = try #require(connectionPeripheral) + let connected = try #require(connectionPeripheral).peripheral await manager1.stopScanning() - try await manager1.connect(to: connected) + try await connected.connect() _ = await pollUntil(timeout: 3.0) { await manager1.currentConnectionStates[connected.id] == .connected } @@ -1585,19 +1717,19 @@ struct ReliaBLEManagerTests { await manager1.bluetooth.testClearPersistedReconnectIntent() await manager1.startScanning() - let peripheral = await Mock.waitForPeripheral( + let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager1, withinNanoseconds: 3_000_000_000 ) - let discovered = try #require(peripheral) + let handle = try #require(snap).peripheral await manager1.stopScanning() - try await manager1.connect(to: discovered, autoReconnect: false) + try await handle.connect(autoReconnect: false) _ = await pollUntil(timeout: 3.0) { - await manager1.currentConnectionStates[discovered.id] == .connected + await manager1.currentConnectionStates[handle.id] == .connected } - #expect(!(await manager1.bluetooth.testPersistedReconnectIntent().contains(discovered.id))) + #expect(!(await manager1.bluetooth.testPersistedReconnectIntent().contains(handle.id))) await Mock.tearDown(manager1, resetMockConnections: false) Mock.connectionTestSpec.simulateConnection() @@ -1644,17 +1776,17 @@ struct ReliaBLEManagerTests { await manager1.bluetooth.testClearPersistedReconnectIntent() await manager1.startScanning() - let peripheral = await Mock.waitForPeripheral( + let snap = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: manager1, withinNanoseconds: 3_000_000_000 ) - let discovered = try #require(peripheral) + let handle = try #require(snap).peripheral await manager1.stopScanning() - try await manager1.connect(to: discovered) + try await handle.connect() _ = await pollUntil(timeout: 3.0) { - await manager1.currentConnectionStates[discovered.id] == .connected + await manager1.currentConnectionStates[handle.id] == .connected } await Mock.tearDown(manager1, resetMockConnections: false) @@ -1690,6 +1822,93 @@ struct ReliaBLEManagerTests { await Mock.tearDown(manager2) } + @Test func invalidatePeripheralsClearsHandleConnectionStateButKeepsMetadata() async throws { + // The two halves of a radio reset pull in opposite directions, and the handle must honor both: + // last-known metadata survives (it is still the best thing known about the device), while connection + // state must NOT — a handle stuck reporting `.connected` after the library tore the connection down is + // not stale, it is false. + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + try await handle.connect() + #expect(await pollUntil(timeout: 3.0) { handle.connectionState == .connected }) + + let priorName = handle.name + let priorRSSI = try #require(handle.rssi) + + await manager.bluetooth.testInvalidatePeripherals() + + #expect(handle.connectionState == nil) + #expect(await manager.currentConnectionStates[handle.id] == nil) + #expect(handle.name == priorName) + #expect(handle.rssi == priorRSSI) + } + + @Test func invalidatePeripheralsEmitsTerminalConnectionStateChange() async throws { + // Clearing tracked connection state is the one transition a subscriber cannot infer on its own: a cleared + // peripheral produces no further events, so without an explicit emit a UI driven only by + // `connectionStateChanges` renders `.connected` forever after a radio reset. The handle reverting to `nil` + // is not enough — nothing tells the app to go re-read it. + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let handle = try #require(snap).peripheral + await manager.stopScanning() + + try await handle.connect() + #expect(await pollUntil(timeout: 3.0) { handle.connectionState == .connected }) + + // Subscribe before invalidating — `connectionStateChanges` has no replay, so a stream created afterwards + // would miss the very event under test, and creating one only *enqueues* registration. + let subscriberBaseline = await manager.bluetooth.testConnectionStateSubscriberCount() + let changes = manager.connectionStateChanges + #expect(await Mock.waitForConnectionSubscription(on: manager, above: subscriberBaseline)) + + let id = handle.id + let collector = Task { () -> ConnectionStateChange? in + for await change in changes + where change.peripheralId == id && change.state == .disconnected(reason: .bluetoothUnavailable) { + return change + } + return nil + } + + await manager.bluetooth.testInvalidatePeripherals() + + // Bound the wait: a regression that drops the emit must fail this test, not hang the suite. + let watchdog = Task { + try? await Task.sleep(nanoseconds: 3_000_000_000) + collector.cancel() + } + let terminal = await collector.value + watchdog.cancel() + + #expect(terminal?.state == .disconnected(reason: .bluetoothUnavailable)) + // The event describes the transition; the handle reports "no longer tracked". + #expect(handle.connectionState == nil) + } + @Test func willRestoreDefersScanUntilPoweredOn() async throws { // Direct-handler unit test: CoreBluetoothMock sets `isScanning = true` synchronously // inside central init when scan services are restored, so a faithful cold-relaunch cannot @@ -1731,23 +1950,61 @@ struct ReliaBLEManagerTests { await Mock.ensureReady(manager) await manager.startScanning() - let peripheral = await Mock.waitForPeripheral( + let snap = await Mock.waitForDiscovered( id: Mock.testPeripheralID, on: manager, withinNanoseconds: 3_000_000_000 ) - let discovered = try #require(peripheral) + let handle = try #require(snap).peripheral await manager.stopScanning() - #expect(await manager.currentConnectionStates[discovered.id] == nil) - #expect(!(await manager.bluetooth.testIsReconnectEnabled(discovered.id))) + #expect(await manager.currentConnectionStates[handle.id] == nil) + #expect(!(await manager.bluetooth.testIsReconnectEnabled(handle.id))) - await manager.bluetooth.testHandleWillRestoreState(peripheralIds: [discovered.id]) + await manager.bluetooth.testHandleWillRestoreState(peripheralIds: [handle.id]) - #expect(await manager.currentConnectionStates[discovered.id] == nil) - #expect(!(await manager.bluetooth.testIsReconnectEnabled(discovered.id))) + #expect(await manager.currentConnectionStates[handle.id] == nil) + #expect(!(await manager.bluetooth.testIsReconnectEnabled(handle.id))) // Live reference remains registered from discovery. - #expect(await manager.bluetooth.testContainsCBPeripheral(discovered.id)) + #expect(await manager.bluetooth.testContainsCBPeripheral(handle.id)) + } + + @Test func restorePathInternsSameHandle() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + // Pre-create the handle — before any discovery or restore. + let handle = manager.peripheral(id: Mock.testPeripheralID) + #expect(handle.cbIdentifier == nil) + + // First discover so live refs and prior metadata exist. + await manager.startScanning() + let snap = await Mock.waitForDiscovered( + id: Mock.testPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let discovered = try #require(snap) + await manager.stopScanning() + let priorRSSI = discovered.rssi + let priorAd = discovered.advertisement + #expect(handle.rssi != nil) + + // Drive restore via the test hook — this re-binds the live CBPeripheral. + await manager.bluetooth.testHandleWillRestoreState(peripheralIds: [Mock.testPeripheralID]) + + // The same handle instance received cbIdentifier metadata from restore. + #expect(handle.cbIdentifier != nil) + #expect(await manager.bluetooth.testContainsCBPeripheral(Mock.testPeripheralID)) + + // Regression guard for the shared-helper merge rule: restoration carries no advertisement payload and no + // RSSI, so it must KEEP the values the earlier discovery established rather than wiping them. `#require` + // rather than `if let` — if discovery stopped producing these, the guard would silently pass and stop + // protecting anything. + let requiredRSSI = try #require(priorRSSI) + let requiredAd = try #require(priorAd) + #expect(handle.rssi == requiredRSSI) + #expect(handle.advertisement == requiredAd) } // MARK: - Multi-Manager Isolation @@ -1773,7 +2030,7 @@ struct ReliaBLEManagerTests { // A discovers while B is idle — B's discovered list must stay empty. await managerA.startScanning() - let discoveredOnA = await Mock.waitForPeripheral( + let discoveredOnA = await Mock.waitForDiscovered( id: Mock.testPeripheralID, on: managerA, withinNanoseconds: 3_000_000_000 @@ -1786,7 +2043,7 @@ struct ReliaBLEManagerTests { // B discovers independently into its own maps. await managerB.startScanning() - let discoveredOnB = await Mock.waitForPeripheral( + let discoveredOnB = await Mock.waitForDiscovered( id: Mock.testPeripheralID, on: managerB, withinNanoseconds: 3_000_000_000 @@ -1797,15 +2054,15 @@ struct ReliaBLEManagerTests { // Connect only on A; B must not observe connection state for that peripheral. await managerA.startScanning() - let connectableA = await Mock.waitForPeripheral( + let connectableA = await Mock.waitForDiscovered( id: Mock.connectionTestPeripheralID, on: managerA, withinNanoseconds: 3_000_000_000 ) - let peripheralA = try #require(connectableA) + let peripheralA = try #require(connectableA).peripheral await managerA.stopScanning() - try await managerA.connect(to: peripheralA) + try await peripheralA.connect() #expect(await pollUntil(timeout: 3.0) { await managerA.currentConnectionStates[peripheralA.id] == .connected }) @@ -1854,6 +2111,46 @@ struct ReliaBLEManagerTests { await Mock.tearDown(managerB) } + @Test func twoManagersIndependentHandleRegistries() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let managerA = await Mock.makeManager(tearDownPrevious: true) + await Mock.ensureReady(managerA) + + let managerB = await Mock.makeManager(tearDownPrevious: false) + await Mock.ensureReady(managerB) + + // Same id string → two distinct handle instances. + let handleA = managerA.peripheral(id: Mock.testPeripheralID) + let handleB = managerB.peripheral(id: Mock.testPeripheralID) + #expect(handleA !== handleB) + #expect(handleA == handleB) + #expect(handleA.hashValue == handleB.hashValue) + + let set: Set = [handleA, handleB] + #expect(set.count == 1, "id-only equality means same-id handles from different managers count as one in a Set") + + // Discover only on A. + await managerA.startScanning() + _ = await Mock.waitForDiscovered( + id: Mock.testPeripheralID, + on: managerA, + withinNanoseconds: 3_000_000_000 + ) + await managerA.stopScanning() + + #expect(await managerA.bluetooth.testContainsCBPeripheral(Mock.testPeripheralID)) + #expect(!(await managerB.bluetooth.testContainsCBPeripheral(Mock.testPeripheralID))) + + // Only A's handle has live metadata. + #expect(handleA.rssi != nil) + #expect(handleB.rssi == nil) + + await Mock.tearDown(managerA) + await Mock.tearDown(managerB) + } + // MARK: - Event Stream Broadcaster @Test func stateStreamReplaysToConcurrentSubscribers() async throws { @@ -1892,6 +2189,35 @@ struct ReliaBLEManagerTests { #expect(broadcastA != nil) #expect(broadcastB != nil) } + + @Test func handleOrphansWhenManagerDeallocates() async throws { + // This test is the retain-graph leak detector: if anything reachable from the + // actor holds the manager strongly, the manager won't deallocate and this fails. + + let orphanedHandle: Peripheral = await Task { + let manager = await Mock.makeManager(tearDownPrevious: true) + let handle = manager.peripheral(id: "orphaned-by-deinit") + // Shut down the actor so every stream subscription ends — a live subscriber retains the actor by + // design, and the actor is the thing that would drag the manager along if the retain graph were wrong. + await manager.bluetooth.shutdown() + // Drop the harness's own strong reference; otherwise `activeManager` alone keeps the manager alive and + // this test would silently prove nothing. + Mock.releaseActiveManager() + + return handle + }.value + + // The manager has now fallen out of every scope that held it. If it deallocated, the handle's weak manager + // reference is nil and `connect()` reports `.bluetoothUnavailable`. Anything else — notably `.notFound`, + // which means the manager is somehow still alive and reachable — indicates something reachable from the + // actor is retaining the manager strongly, which is the leak this test exists to catch. + do { + try await orphanedHandle.connect() + Issue.record("Expected connect() on orphaned handle to throw") + } catch let error as PeripheralError { + #expect(error == .bluetoothUnavailable) + } + } } // MARK: - Logging Test Support @@ -1989,6 +2315,16 @@ enum Mock { /// they advertise again for the next test. Pass `false` for cold-relaunch so /// `CBMPeripheralSpec.virtualConnections` stays set and `simulateStateRestoration` can /// restore peripherals as `.connected`. + /// Drops the suite's strong reference to the active stack **without** shutting it down. + /// + /// ``makeManager(loggingEnabled:reconnectPolicy:restoreIdentifier:tearDownPrevious:)`` parks every manager it + /// creates in ``activeManager`` for serialized-suite teardown. That reference is a harness artifact, and it is + /// enough on its own to keep a manager alive — which would defeat any test that needs one to actually + /// deallocate. Call this to hand sole ownership back to the caller. + static func releaseActiveManager() { + activeManager = nil + } + static func tearDown(_ manager: ReliaBLEManager, resetMockConnections: Bool = true) async { if resetMockConnections { connectionTestSpec.simulateDisconnection() @@ -2166,13 +2502,30 @@ enum Mock { } } + /// Waits until a `connectionStateChanges` subscription created after `baseline` is visible to the actor. + /// + /// The stream factory is `nonisolated` and dispatches `register(...)` as an unstructured `Task`, so awaiting + /// any *other* actor method does not order the two jobs — it only proves that unrelated method ran. Because + /// this feed never replays, a test that triggers its transition before the subscription lands misses the event + /// outright. The failure mode is a silent timeout that only appears under load, so prefer this over any + /// incidental "force an actor hop" call. + static func waitForConnectionSubscription( + on manager: ReliaBLEManager, + above baseline: Int, + timeout: Double = 3.0 + ) async -> Bool { + await pollUntil(timeout: timeout) { + await manager.bluetooth.testConnectionStateSubscriberCount() > baseline + } + } + /// Waits for `discoveredPeripherals` to contain a peripheral with the given `id`. - static func waitForPeripheral( + static func waitForDiscovered( id: String, on manager: ReliaBLEManager, withinNanoseconds nanoseconds: UInt64 - ) async -> Peripheral? { - await withTaskGroup(of: Peripheral?.self) { group in + ) async -> DiscoveredPeripheral? { + await withTaskGroup(of: DiscoveredPeripheral?.self) { group in group.addTask { for await list in manager.discoveredPeripherals { if let match = list.first(where: { $0.id == id }) { diff --git a/docs/designs/discovered-peripheral-vs-peripheral-2026-07-15.md b/docs/designs/discovered-peripheral-vs-peripheral-2026-07-15.md index 08d1fcb..ffb4830 100644 --- a/docs/designs/discovered-peripheral-vs-peripheral-2026-07-15.md +++ b/docs/designs/discovered-peripheral-vs-peripheral-2026-07-15.md @@ -41,7 +41,7 @@ That was a good milestone for **scan lists + Swift 6 safety**. It is an awkward - readiness / subscription intent, - a per-device command queue, - work-driven auto-connect and idle teardown, -- Advanced explicit `connect`/`disconnect` (“app hold”). +- Manual explicit `connect`/`disconnect` (the manual-connect hold). ### Two jobs that fight each other @@ -129,7 +129,7 @@ DiscoveredPeripheral (Sendable value) Peripheral (long-lived handle, interned by id) ├── discoveryFilter (sticky; FR-10) ├── run(command) / ready / connection streams - ├── Advanced connect(autoReconnect:) / disconnect() + ├── Manual connect(autoReconnect:) / disconnect() └── lastSeen / rssi / lastAdvertisement? // option A metadata for “my devices” UI ``` @@ -185,7 +185,7 @@ Whether `run` on a never-seen handle **implicitly scans until match** vs **fails For full connection/reconnect/FR-10 discussion see the investigation doc. Short version: -- **Work-driven default:** non-empty command queue → auto-connect; idle teardown (global default 5s) when quiet; Advanced `connect` is rare **app hold**. +- **Work-driven default:** non-empty command queue → auto-connect; idle teardown (global default 5s) when quiet; Manual `connect` is the rare **manual-connect hold**. - **FR-10:** GATT discovery/readiness on **`Peripheral`**; sticky UUID filter on the handle; **connected ≠ ready**. - **Commands (after FR-10):** `peripheral.run(...)`; serial per-device queue; reconnect-and-rerun after ready. diff --git a/docs/investigations/objc-ble-vs-reliable-architecture-2026-07-15.md b/docs/investigations/objc-ble-vs-reliable-architecture-2026-07-15.md index 628c7c7..80a35bc 100644 --- a/docs/investigations/objc-ble-vs-reliable-architecture-2026-07-15.md +++ b/docs/investigations/objc-ble-vs-reliable-architecture-2026-07-15.md @@ -7,7 +7,7 @@ ReliaBLE is the modern, improved equivalent of the ObjC **Core** layer (`CCBTCentralManager` / `CCBTPeripheral` / `CCBTCommand`). The reference protocol facade and app layers are **usage context**, not something ReliaBLE should re-implement. The PRD is the v1 target; the library is unshipped and not intended for use before PRD completion. -**Shipped code today** is a strong **link manager** (scan, multi-device connect, two-tier reconnect, lifecycle streams, background restore). The ObjC reliability moat was **PoweredOn gate → demand-driven connect → discovery readiness → serial command queue → pause/reconnect/rerun**. PR #47 specifies the discovery gate (FR-10). Commands (FR-4/5) follow FR-10. Product direction re-centers **work-driven connection as the only primary model**: non-empty command queue drives auto-connect; idle teardown when the queue is empty. Reconnect is **Approach B**: Tier-0 (OS) enabled while the work-driven link is up, cancelled on idle teardown; Tier-1 armed only while work is pending. Explicit `connect()`/`disconnect()` is an **Advanced app-hold** path (rarely used), not an either-or policy mode. Public device model is **two types**: long-lived **`Peripheral`** (control handle; primary for wearables/IoT) and value **`DiscoveredPeripheral`** (scan snapshot; manager-stamped `.peripheral` sugar). Not `manager.connect(id:)` as the primary API. See also `docs/designs/discovered-peripheral-vs-peripheral-2026-07-15.md`. +**Shipped code today** is a strong **link manager** (scan, multi-device connect, two-tier reconnect, lifecycle streams, background restore). The ObjC reliability moat was **PoweredOn gate → demand-driven connect → discovery readiness → serial command queue → pause/reconnect/rerun**. PR #47 specifies the discovery gate (FR-10). Commands (FR-4/5) follow FR-10. Product direction re-centers **work-driven connection as the only primary model**: non-empty command queue drives auto-connect; idle teardown when the queue is empty. Reconnect is **Approach B**: Tier-0 (OS) enabled while the work-driven link is up, cancelled on idle teardown; Tier-1 armed only while work is pending. Explicit `connect()`/`disconnect()` is a **Manual connect** path (rarely used), not an either-or policy mode. Public device model is **two types**: long-lived **`Peripheral`** (control handle; primary for wearables/IoT) and value **`DiscoveredPeripheral`** (scan snapshot; manager-stamped `.peripheral` sugar). Not `manager.connect(id:)` as the primary API. See also `docs/designs/discovered-peripheral-vs-peripheral-2026-07-15.md`. **Full PRD + agreed product direction clears (and in places exceeds) the ObjC Core bar**, provided FR-10, work-driven default, and command reconnect-and-rerun actually ship. Framing/CRC stay app-owned by design (OSS multi-device). @@ -18,9 +18,9 @@ ReliaBLE is the modern, improved equivalent of the ObjC **Core** layer (`CCBTCen | Topic | Decision | |---|---| | **Layering** | ReliaBLE = modern Core only. Protocol/app layers in ObjC are context for how Core is consumed. | -| **Connection model** | **Work-driven only as the primary path:** auto-connect when the command queue is non-empty; idle teardown when empty. **Reconnect Approach B:** work-driven connects pass Tier-0 (OS auto-reconnect) while linked; idle teardown / intentional cancel drops OS reconnect; Tier-1 armed only while queue non-empty (exact “no work ⇒ no library ladder”). Explicit `connect(autoReconnect:)` / `disconnect()` is **Advanced app-hold** (docs only; rarely used): same ensure-linked path, suppresses idle while held; `autoReconnect` Bool remains on explicit `connect`. **No either-or `ConnectionPolicy.mode`.** | +| **Connection model** | **Work-driven only as the primary path:** auto-connect when the command queue is non-empty; idle teardown when empty. **Reconnect Approach B:** work-driven connects pass Tier-0 (OS auto-reconnect) while linked; idle teardown / intentional cancel drops OS reconnect; Tier-1 armed only while queue non-empty (exact “no work ⇒ no library ladder”). Explicit `connect(autoReconnect:)` / `disconnect()` is **Manual connect** (docs only; rarely used): same ensure-linked path, suppresses idle while held; `autoReconnect` Bool remains on explicit `connect`. **No either-or `ConnectionPolicy.mode`.** | | **Connect API placement** | Hang connect / discovery / commands off **`Peripheral`** (long-lived handle), matching ObjC `CCBTPeripheral` / `runCommand:` ergonomics — not only `ReliaBLEManager.connect(to:)`. | -| **Public device types** | **`Peripheral`** = control handle (stable id, sticky discovery filter, ready, queue, Advanced connect, last-seen metadata). **`DiscoveredPeripheral`** = scan snapshot (ads/rssi); manager-stamped; **`discovered.peripheral`** sugar → interned handle. Leave **`Device`** free for multi-transport app layers. | +| **Public device types** | **`Peripheral`** = control handle (stable id, sticky discovery filter, ready, queue, Manual connect, last-seen metadata). **`DiscoveredPeripheral`** = scan snapshot (ads/rssi); manager-stamped; **`discovered.peripheral`** sugar → interned handle. Leave **`Device`** free for multi-transport app layers. | | **Known id / “my devices”** | App obtains `manager.peripheral(id:)` up front; discovery later binds live CB to the same handle. Primary UI for IoT = tracked **`Peripheral`s** (option A: last discovery metadata on the handle). Raw discovery stream = scanner/provisioning. No fake placeholder discoveries. Optional later: thin `PeripheralUpdate` event (option B). | | **Discovery API (GATT FR-10)** | On **`Peripheral`**, not a global id-keyed manager API. | | **UUID declaration** | Sticky on **`Peripheral`** (e.g. `discoveryFilter`); required before auto-discovery / first work (fail-closed if empty). | @@ -47,7 +47,7 @@ ReliaBLE is the modern, improved equivalent of the ObjC **Core** layer (`CCBTCen - ObjC docs: `Bluetooth/docs/BLE-Architecture.md`, `Swift6-Translation.md` - ObjC Core: `CCBTCentralManager`, `CCBTPeripheral`, `CCBTCommand` - ReliaBLE: `PRD.md` (incl. FR-10 from PR #47), `Sources/ReliaBLE/*`, DocC -- Product feedback: layering, PoweredOn, work-driven + Advanced hold (Approach B reconnect), Peripheral-scoped APIs, PRD bar, actor layout +- Product feedback: layering, PoweredOn, work-driven + Manual connect (Approach B reconnect), Peripheral-scoped APIs, PRD bar, actor layout --- @@ -60,7 +60,7 @@ ReliaBLE **is** the modern Core. Mapping: | ObjC Core | ReliaBLE (target) | |---|---| | `CCBTCentralManager` | Central concerns on `ReliaBLEManager` + internal `BluetoothActor` (scan, radio state, multi-device registry) | -| `CCBTPeripheral` | **`Peripheral`** handle (work-driven link, discovery/ready, command queue, idle teardown, optional app-hold, last-seen metadata) | +| `CCBTPeripheral` | **`Peripheral`** handle (work-driven link, discovery/ready, command queue, idle teardown, optional manual-connect hold, last-seen metadata) | | *(scan row / ads)* | **`DiscoveredPeripheral`** value snapshot + `.peripheral` sugar | | `CCBTCommand` | App-supplied command protocol executed by **`Peripheral`** (FR-4/5, after FR-10) | @@ -153,43 +153,43 @@ For a **work-driven default** targeting wearables/IoT: **Swift 6 shape** (from `Swift6-Translation.md:76-100`): parked work is a suspended `await`, not an `NSOperationQueue.suspended` flag — same product behavior, better structured cancellation/timeouts. -### 2.4 Work-driven link + Advanced app-hold (no either-or policy) +### 2.4 Work-driven link + Manual connect (no either-or policy) -**Product decision (KISS):** one connection state machine. The primary model is ObjC-style work-driven. Explicit connect is not a second “mode” — it is an optional **app hold** on the same machine. +**Product decision (KISS):** one connection state machine. The primary model is ObjC-style work-driven. Explicit connect is not a second “mode” — it is an optional **manual-connect hold** on the same machine. -**Target devices** (wearables, IoT sensors, smart-home): sessions are “do a job,” not always-connected notify. Always-connected notify is out of primary scope; app-hold is documented under **Advanced** and expected to be rare. +**Target devices** (wearables, IoT sensors, smart-home): sessions are “do a job,” not always-connected notify. Always-connected notify is out of primary scope; Manual connect is documented under **Advanced** and expected to be rare. #### Rules | Signal | Meaning | |---|---| | **Command queue non-empty** | Reason to be linked: auto-connect if down; Tier-0 enabled at that connect; Tier-1 armed on unexpected drop | -| **Command queue empty** | Disarm Tier-1; start idle teardown timer (if no app hold) — cancel connection ends Tier-0 | -| **App hold** (Advanced) | Set by `connect(autoReconnect:)`; cleared by `disconnect()`. Suppresses idle teardown while held. Same ensure-linked path as work-driven connect | -| **Idle teardown** | Runs only when **queue empty and no app hold**; cancel connection drops OS Tier-0 | +| **Command queue empty** | Disarm Tier-1; start idle teardown timer (if no manual-connect hold) — cancel connection ends Tier-0 | +| **Manual-connect hold** | Set by `connect(autoReconnect:)`; cleared by `disconnect()`. Suppresses idle teardown while held. Same ensure-linked path as work-driven connect | +| **Idle teardown** | Runs only when **queue empty and no manual-connect hold**; cancel connection drops OS Tier-0 | ```text -ensureLinked() ← called from run(command) and from Advanced connect() +ensureLinked() ← called from run(command) and from Manual connect() await PoweredOn connect if needed // work-driven: pass Tier-0 EnableAutoReconnect (Approach B) discover to ready (FR-10) on unexpected disconnect: - if queue non-empty OR (app hold && autoReconnect): + if queue non-empty OR (manual-connect hold && autoReconnect): arm Tier-1 ladder // exact “work pending ⇒ library reconnect” else: stay down // no Tier-1 when quiet -on queue drained && !appHold: +on queue drained && !manualHold: disarm Tier-1 start idle timer → cancel connection // cancels Tier-0 as well -on Advanced connect(autoReconnect:): - set appHold; ensureLinked() - Tier-0 / Tier-1 follow the autoReconnect Bool while hold remains +on Manual connect(autoReconnect:): + set manualHold; ensureLinked() + Tier-0 / Tier-1 follow the autoReconnect Bool while a manual-connect hold remains -on Advanced disconnect(): - clear appHold; intentional cancel; disarm reconnect +on Manual disconnect(): + clear manualHold; intentional cancel; disarm reconnect ``` #### Reconnect: Approach B @@ -197,18 +197,18 @@ on Advanced disconnect(): | Path | Tier-0 (OS `EnableAutoReconnect`) | Tier-1 (library ladder) | |---|---|---| | **Work-driven** (default) | **On while linked** — passed at auto-connect for work; **dropped when idle teardown (or intentional cancel) cancels the connection** | **On only while queue non-empty** (mid-job drop with work remaining) | -| **Advanced `connect(autoReconnect: true)`** | On for that hold | Armed while app hold remains | -| **Advanced `connect(autoReconnect: false)`** | Off | Off for that hold | -| **Queue empty, no hold** | Ended by cancel on idle teardown | **Disarmed** | +| **Manual `connect(autoReconnect: true)`** | On for that hold | Armed while manual-connect hold remains | +| **Manual `connect(autoReconnect: false)`** | Off | Off for that hold | +| **Queue empty, no manual-connect hold** | Ended by cancel on idle teardown | **Disarmed** | -Rationale: Tier-0 is fixed at connect time and cannot track “queue just emptied” without cancelling. Approach B keeps OS help **during an active job** (link is up for work) and relies on **idle teardown cancel** to end Tier-0 when the queue is quiet. Exact “no work ⇒ no library reconnect” stays on **Tier-1** (arm/disarm from queue). Accepted gap: during the idle grace window (e.g. 5s after last command) Tier-0 might still bring the link back once with an empty queue — then idle logic should cancel again if still quiet and no hold. KISS: do not re-`connect` to flip options when the queue toggles. +Rationale: Tier-0 is fixed at connect time and cannot track “queue just emptied” without cancelling. Approach B keeps OS help **during an active job** (link is up for work) and relies on **idle teardown cancel** to end Tier-0 when the queue is quiet. Exact “no work ⇒ no library reconnect” stays on **Tier-1** (arm/disarm from queue). Accepted gap: during the idle grace window (e.g. 5s after last command) Tier-0 might still bring the link back once with an empty queue — then idle logic should cancel again if still quiet and no manual-connect hold. KISS: do not re-`connect` to flip options when the queue toggles. #### Docs shape - **Primary docs / Getting Started:** only `run(command)` (after FR-10/4); connection is an implementation detail. -- **Advanced:** `connect(autoReconnect:)` / `disconnect()` as app hold — keep link up without pending work; `autoReconnect` semantics as today. +- **Manual connect:** `connect(autoReconnect:)` / `disconnect()` as a manual-connect hold — keep link up without pending work; `autoReconnect` semantics as today. -**Complexity:** one ensure-linked path + `appHold` + `queuedWork` flags + idle cancel. No `ConnectionPolicy.mode` enum. +**Complexity:** one ensure-linked path + `manualHold` + `queuedWork` flags + idle cancel. No `ConnectionPolicy.mode` enum. ### 2.5 Public types: `Peripheral` (handle) + `DiscoveredPeripheral` (snapshot) @@ -216,7 +216,7 @@ Rationale: Tier-0 is fixed at connect time and cannot track “queue just emptie | Type | Kind | Role | |---|---|---| -| **`Peripheral`** | Long-lived handle (interned by id in the manager) | Primary app type for wearables/IoT: sticky `discoveryFilter`, work-driven `run`, ready/connection streams, Advanced `connect`/`disconnect`, last-seen / last-advertisement metadata (option A) | +| **`Peripheral`** | Long-lived handle (interned by id in the manager) | Primary app type for wearables/IoT: sticky `discoveryFilter`, work-driven `run`, ready/connection streams, Manual `connect`/`disconnect`, last-seen / last-advertisement metadata (option A) | | **`DiscoveredPeripheral`** | Sendable value snapshot | Scan row: ads, rssi, lastSeen; **manager-stamped**; **`discovered.peripheral`** → same interned handle | | **`ReliaBLEManager`** | Façade | Auth, scan, registry, `peripheral(id:)`, tracked-peripherals feed | @@ -260,7 +260,7 @@ for await d in manager.peripheralDiscoveries { // ... } -// Advanced hold +// Manual connect hold try await p.connect(autoReconnect: true) ``` @@ -307,7 +307,7 @@ API: `device.run(command)` (or equivalent), not manager-global. | PoweredOn gating | Not explicit as park/await; state stream exists | **Partial** — should specify await/park for work submission (product §2.3) | | Discovery readiness gate | **FR-10.3** (stronger: streams, timeout, fail-closed, distinct from connection) | **Yes — exceeds** | | Re-discover on reconnect / modify | **FR-10.5 / 10.6**, FR-1.2 amended | **Yes — exceeds** (restore + subscription intent) | -| Work-driven + idle; Approach B reconnect (Tier-0 while linked, cancel on idle; Tier-1 while queue non-empty) | Not yet first-class in PRD | **Gap** — product model; document work-driven + Advanced hold; Approach B | +| Work-driven + idle; Approach B reconnect (Tier-0 while linked, cancel on idle; Tier-1 while queue non-empty) | Not yet first-class in PRD | **Gap** — product model; document work-driven + Manual connect; Approach B | | Serial command queue | FR-5.2.1 FIFO | **Yes** | | Reconnect-and-rerun commands | FR-1.2 + FR-4/5 depend on ready | **Yes if implemented** as retry-after-ready | | Exactly-once + watchdogs | FR-1.1 / implied | **Yes if specified tightly in command design** | @@ -320,7 +320,7 @@ API: `device.run(command)` (or equivalent), not manager-global. **Yes — the full PRD target clears the ObjC Core reliability bar**, and exceeds it on discovery observability, restore, multi-device, and connection recovery — **if** implementation follows FR-10 → commands with retry-after-ready, and product adds: -1. Work-driven link + idle teardown; Approach B reconnect (Tier-0 while linked, cancel on idle; Tier-1 only while queue non-empty); Advanced app-hold `connect`/`disconnect` only. +1. Work-driven link + idle teardown; Approach B reconnect (Tier-0 while linked, cancel on idle; Tier-1 only while queue non-empty); Manual `connect`/`disconnect` only. 2. Await/park (not silent no-op) for PoweredOn on work paths. 3. Device-handle API so the Core is usable without re-learning CB. @@ -396,9 +396,9 @@ Command FIFO: explicit gate per device id inside the actor (semaphore/queue), no | Central scan + PoweredOn gate | Await/park on work paths (recommend PRD note) | Scan no-op; partial | | `Peripheral` handle + `DiscoveredPeripheral` snapshot | **Product: yes** (option A metadata on handle) | Today single snapshot type + manager.connect | | Work-driven connect + idle teardown | **Product: primary** | Absent | -| Advanced app-hold `connect`/`disconnect` | **Product: Advanced docs; rare** | Only path today (to be demoted) | +| Manual `connect`/`disconnect` | **Product: Advanced docs; rare** | Only path today (to be demoted) | | Work-driven: Tier-0 while linked + cancel on idle; Tier-1 while queue non-empty | **Product: Approach B** | Today two-tier on manager connect; not yet queue/idle-gated | -| Two-tier reconnect + streams (Advanced hold / general substrate) | Exceeds ObjC | **Done** (wire to queue/hold rules) | +Two-tier reconnect + streams (Manual connect / general substrate) | Exceeds ObjC | **Done** (wire to queue/hold rules) | | Discovery readiness gate | FR-10 | Absent | | Ready stream on device | FR-10.3.2 + product | Absent | | Subscription intent re-arm | FR-10.4.5 | Absent | @@ -418,7 +418,7 @@ Command FIFO: explicit gate per device id inside the actor (semaphore/queue), no 2. Leaving scan as silent no-op while marketing work-driven Core → mysterious “sync did nothing.” 3. Implementing full FR-5.2 priority before serial + ready + rerun. 4. Per-peripheral actors holding CB objects → Sendable / ownership bugs. -5. Treating Advanced `connect` as a second connection stack instead of app-hold on one machine. +5. Treating Manual `connect` as a second connection stack instead of a manual-connect hold on one machine. 6. PRD/docs not updated for work-driven primary → implementers treat manager.connect as the “real” API. 7. Enabling Tier-0 on work-driven connects **without** idle/intentional cancel → OS reconnects indefinitely with empty queue (violates Approach B). Idle grace may allow one OS reconnect; must re-cancel if still quiet. @@ -429,16 +429,16 @@ Command FIFO: explicit gate per device id inside the actor (semaphore/queue), no ### PRD / design updates 1. Document **work-driven primary path**: auto-connect on non-empty command queue; idle teardown when empty; **Approach B reconnect** — Tier-0 while linked (cancel on idle), Tier-1 only while work pending. Idle duration as a simple config default (e.g. 5s), not a mode enum. -2. Document **Advanced app-hold**: `connect(autoReconnect:)` / `disconnect()` suppress idle while held; existing `autoReconnect` Bool controls Tier-0/Tier-1 for that hold. Rare; Advanced docs only. +2. Document **Manual connect**: `connect(autoReconnect:)` / `disconnect()` suppress idle while held; existing `autoReconnect` Bool controls Tier-0/Tier-1 for that hold. Rare; Advanced docs only. 3. Specify **PoweredOn**: work submission awaits ready radio; terminal states fail; no silent scan no-op. -4. Specify **`Peripheral` / `DiscoveredPeripheral` split**: handle-centric API (`run` / discovery filter / Advanced connect); `discovered.peripheral` sugar; `peripheral(id:)` for known devices; tracked feed with last-discovery metadata (option A); manager keeps auth, scan, registry. +4. Specify **`Peripheral` / `DiscoveredPeripheral` split**: handle-centric API (`run` / discovery filter / Manual connect); `discovered.peripheral` sugar; `peripheral(id:)` for known devices; tracked feed with last-discovery metadata (option A); manager keeps auth, scan, registry. 5. Keep FR-10 before FR-4/5 (unchanged). 6. Document reliability unit: **command completion after ready** (not connection alone). ### Build sequence 1. **`Peripheral` handle + `DiscoveredPeripheral` snapshot** rename/split; registry intern by id; `discovered.peripheral` sugar; `peripheral(id:)`; move link APIs onto `Peripheral`. -2. **Work-driven link rules**: ensure-linked from `run` with Tier-0 on; idle when queue empty && !appHold (cancel drops Tier-0); Tier-1 arm/disarm from queue (Approach B); Advanced hold via `connect`/`disconnect`. +2. **Work-driven link rules**: ensure-linked from `run` with Tier-0 on; idle when queue empty && !manualHold (cancel drops Tier-0); Tier-1 arm/disarm from queue (Approach B); Manual connect via `connect`/`disconnect`. 3. **PoweredOn await** on scan/connect/work paths (replace scan no-op). 4. **FR-10** on handle (SM, ready stream, filtered discovery, subscriptions, didModifyServices, restore rediscover). 5. **Serial command queue** + run-after-ready + reconnect-and-rerun + watchdogs. @@ -462,14 +462,14 @@ Command FIFO: explicit gate per device id inside the actor (semaphore/queue), no 5. **Work-driven + never-seen known id** — on `run`, implicit filtered scan-until-match vs fail-fast “not discovered”? (Product choice; both fit the type model.) 6. **`Peripheral` reference semantics** — class vs struct-holding-id façade (both forward to actor); pick at implementation for identity/`===`/UI binding ergonomics. -**Settled (connections + types):** Work-driven primary; no either-or `ConnectionPolicy`; Approach B reconnect; Advanced app-hold only; idle **global 5s**; ready = catalog-ready; subscriptions = intent + re-arm; **`Peripheral` / `DiscoveredPeripheral`** split with option A metadata. +**Settled (connections + types):** Work-driven primary; no either-or `ConnectionPolicy`; Approach B reconnect; Manual connect only; idle **global 5s**; ready = catalog-ready; subscriptions = intent + re-arm; **`Peripheral` / `DiscoveredPeripheral`** split with option A metadata. --- ## Conclusion - **Layering:** ReliaBLE = improved ObjC Core; band protocol is consumer context only. -- **Connections:** Prefer **await/park PoweredOn** for work paths (not only boot; not silent no-op). **Work-driven primary** (queue drives auto-connect; idle when empty); **Approach B** reconnect (Tier-0 while linked, cancel on idle; Tier-1 while work pending); **Advanced app-hold** `connect`/`disconnect` only — no either-or policy. Demote manager-only connect. +- **Connections:** Prefer **await/park PoweredOn** for work paths (not only boot; not silent no-op). **Work-driven primary** (queue drives auto-connect; idle when empty); **Approach B** reconnect (Tier-0 while linked, cancel on idle; Tier-1 while work pending); **Manual connect** `connect`/`disconnect` only — no either-or policy. Demote manager-only connect. - **Types:** **`Peripheral`** (handle, IoT primary) + **`DiscoveredPeripheral`** (scan snapshot + `.peripheral` sugar); tracked “my devices” via handles + option A metadata; leave `Device` for apps. - **Discovery:** FR-10 on **`Peripheral`**; sticky UUID filter on the handle. - **Commands:** After FR-10 only. diff --git a/docs/plans/peripheral-handle-type-model-2026-07-31.md b/docs/plans/peripheral-handle-type-model-2026-07-31.md new file mode 100644 index 0000000..a1c6f5e --- /dev/null +++ b/docs/plans/peripheral-handle-type-model-2026-07-31.md @@ -0,0 +1,741 @@ +# Phase 1: Public Type Model — `Peripheral` Handle + `DiscoveredPeripheral`: Plan + +Tracking: [#50](https://github.com/Five3Apps/ReliaBLE/issues/50) (parent) → [#53](https://github.com/Five3Apps/ReliaBLE/issues/53), [#54](https://github.com/Five3Apps/ReliaBLE/issues/54), [#55](https://github.com/Five3Apps/ReliaBLE/issues/55), [#56](https://github.com/Five3Apps/ReliaBLE/issues/56) + +## Goal + +Split today's single public `Peripheral` value type into two: `DiscoveredPeripheral` (Sendable scan snapshot) and `Peripheral` (long-lived, per-manager interned control handle that owns connect/disconnect). Deliver the PRD's handle-centric public API without ever exposing live CoreBluetooth objects, and decide whether sub-issues #53–#56 ship as one branch/PR or four. + +## Out of scope + +Work-driven auto-connect, idle teardown, and Approach B queue gating (#51); FR-10 GATT discovery / readiness / subscriptions (#52); PoweredOn await gating (#57); FR-4/5 commands. + +**#57 deserves an explicit note** because `Peripheral.connect()` is a new public entry point and reviewers will ask: it does **not** await PoweredOn. It preserves today's contract exactly — ensure the central, then throw `.bluetoothUnavailable` if the central is missing or `.notFound` if no live reference exists (`BluetoothActor.swift:1031-1050`). FR-1.4 / FR-8.6 gating arrives with #57. + +## Background + +### Current public types (`Sources/ReliaBLE/Models/`) + +- `Peripheral.swift` — `public struct Peripheral: Sendable, Identifiable, Hashable` (`:42`). All-`let` stored properties: `id: String`, `cbIdentifier: UUID?`, `name: String?`, `rssi: Int?`, `lastSeen: Date?`, `advertisement: AdvertisementData?`. `public init(id: String)` (`:78`, app-constructed known-id, all other fields nil) plus an internal full init (`:91`). `hash(into:)` (`:107-109`) and `==` (`:111-115`) key on `id` **only**. Holds no `CBPeripheral`; the doc comment already states the live object is actor-owned and looked up by id. **Option A metadata already lives on this type.** +- `Events/PeripheralDiscoveryEvent.swift` — `public let id: UUID` (from `cbPeripheral.identifier`, **not** the app-facing `Peripheral.id` String), `name`, `rssi: Int`, `advertisement: AdvertisementData`; internal `init(cbPeripheral:advertisement:rssi:)`. +- `AdvertisementData.swift` — `Sendable, Hashable`; `localName`, `serviceUUIDs: [CBUUID]`, `manufacturerData`, `txPowerLevel`, `isConnectable`, `serviceData`, `overflowServiceUUIDs`, `solicitedServiceUUIDs`. Internal `init(rawAdvertisementData:)` extracts from the CB dict exactly once. +- `ConnectionState.swift` — `ConnectionState` enum (`.connecting`, `.reconnecting(source:attempt:nextRetryAt:)`, `.connected`, `.disconnecting`, `.disconnected(reason:)`, `.failed(reason:)`), `ReconnectSource { system, library }`, and `ConnectionStateChange { peripheralId: String, state: ConnectionState }` — **keyed by String id, not by handle**. +- `PeripheralError.swift` — `.notFound`, `.bluetoothUnavailable`, `.connectionFailed`, `.connectionTimeout`, `.peripheralDisconnected`, `.unknown`, plus `fromCBError(_:)`. + +### Discovery pipeline (`Sources/ReliaBLE/BluetoothActor.swift`) + +- Actor-isolated storage: `var discoveredPeripherals: [Peripheral]` (`:180`, value snapshots), `private var cbPeripherals: [String: CBPeripheral]` (`:186`, live refs keyed by the same String id, never escapes), `var connectionStates: [String: ConnectionState]` (`:201`). +- Flow: CB delegate shims (`:1486`, `:1540`) → `DelegateEventForwarder` (ordered `AsyncStream`) → `process` → `handlePeripheralDiscovered` (`:876`). +- `handlePeripheralDiscovered` builds `AdvertisementData` once, broadcasts `PeripheralDiscoveryEvent`, then derives `identifier = cbPeripheral.name ?? advertisement.localName ?? cbPeripheral.identifier.uuidString`, resolves against the existing list (match `id`, else `cbIdentifier`, else append), updates `discoveredPeripherals`, sets `cbPeripherals[resolvedId]`, and broadcasts the updated `[Peripheral]`. +- `handleWillRestoreState` (`:709-807`) duplicates the id-resolution logic to re-bind restored `CBPeripheral`s, but **not identically** — it preserves prior `rssi` (`:742`), falls back to the prior `advertisement` (`:744`, `:754`), and coalesces `name ?? existing` in the `cbIdentifier`-match branch (`:751`) where discovery (`:930`) overwrites. It broadcasts **once** after the loop, guarded by `didMutatePeripherals` (`:722`, `:771`, `:804`). Restored peripherals are deliberately **not** emitted on the `peripheralDiscoveries` ad feed. +- A **third** live-reference binding site exists: `refreshPeripherals()` (`:999-1016`) re-binds `cbPeripherals[p.id]` from `retrievePeripherals(withIdentifiers:)` after a power cycle and broadcasts the list. It resolves no new ids and changes no metadata. +- `shutdown()` clears `cbPeripherals`, `discoveredPeripherals`, `connectionStates`, `reconnectEnabled`, and `intentionalDisconnects` (`:255-261`). `invalidatePeripherals()` (`:956-968`) clears only the live map and connection state. +- Reverse lookup `id(for:)` (~:1099) uses `===` over `cbPeripherals`. +- Identity is name-first and best-effort; FR-8.5 (manufacturer-data unique id) is an open TODO noted in the actor and will later change interning/matching. + +### Public API surface (`Sources/ReliaBLE/ReliaBLEManager.swift`) + +`public final class ReliaBLEManager: Sendable`, with `let bluetooth: BluetoothActor` already **internal** (`:46`) and a fire-and-forget `Task { await bluetooth.ensureCentralManager() }` in `init`. Today: `loggingService`; `state: AsyncStream` (replays); `currentState`; `connectionStateChanges: AsyncStream` (no replay); `currentConnectionStates: [String: ConnectionState]`; `peripheralDiscoveries: AsyncStream`; `discoveredPeripherals: AsyncStream<[Peripheral]>` (replays); `authorizeBluetooth()`; `startScanning(services:)`; `stopScanning()`; `connect(to:autoReconnect:)`; `disconnect(from:)`. Streams are `nonisolated` factories that schedule registration on the actor; operational methods are `async` and forward `peripheral.id` to the actor after `ensureCentralManager()`. No `peripheral(id:)`, no `.peripheral` sugar, connect is still manager-primary. + +### Prior decisions already settled (do not re-litigate) + +From `docs/designs/discovered-peripheral-vs-peripheral-2026-07-15.md` and PRD Architecture "Public types" / FR-2.1 / FR-2.4 / FR-2.5 / FR-8.2.1 / NFR-1.3: + +- Split is chosen; snapshot-only and grow-one-type options were rejected. Control type is named `Peripheral`, snapshot is `DiscoveredPeripheral`. `Device` is rejected (reserved for app multi-transport types). +- **One handle per id per manager.** `manager.peripheral(id:)` creates or returns the interned handle; `discovered.peripheral` is sugar over a manager stamp + registry lookup, "multi-manager safe because the stamp identifies the registry". (D7 keeps the multi-manager guarantee but satisfies it by carrying the registry itself rather than a separate stamp object.) +- **Option A** = last-discovery metadata (`lastSeen`, `rssi`, optional last advertisement) lives on the handle so "my devices" UI binds a single object. Option B (a later thin `PeripheralUpdate { peripheral, discovery }` stream) is explicitly deferred, and is "not a third control type". +- UI rules: "My devices" comes from tracked handles + Option A metadata; "Nearby" comes from the real `DiscoveredPeripheral` stream only. **Never vend synthetic `DiscoveredPeripheral` rows for offline bound devices.** +- Connect/disconnect belong on `Peripheral` (`connect(autoReconnect:)`, `disconnect()`); manager-level `connect(to:)` as the primary app API is an explicit refactor target (FR-2.5: "a temporary milestone to be refactored away"). +- NFR-1.3: all CoreBluetooth objects stay in one internal isolation domain; public handles forward by id. **No per-peripheral actors owning `CBPeripheral`.** +- NFR-1.2 leaves `Peripheral` as "class or id-façade — implementation choice". + +From `docs/designs/bluetoothactor-instance-isolation-2026-07-19.md`: one stack per manager; each manager owns its actor, central, snapshots, connection state, and streams. Two managers scanning the same device each hold their own snapshots and live-reference map — there is no cross-manager discovered list. The registry must therefore be per-manager, never global. + +From `docs/plans/background-scanning-state-restoration-2026-07-13.md`: `handleWillRestoreState` re-binds restored `CBPeripheral`s using the same identity logic as discovery, seeds connection state, and re-arms persisted reconnect intent. Whatever the registry becomes, restoration must intern the same handles. + +### Blast radius + +- **Tests** (`Tests/ReliaBLETests/ReliaBLEManagerTests.swift`, single file): swift-testing (`@Suite(.serialized)`, `@Test`, `#expect`, `#require`), `@preconcurrency import CoreBluetoothMock` + `@testable import ReliaBLEMock`, `CBMCentralManagerMock.simulate*` driven through a one-time `SimulationConfig` actor. `Tests/ReliaBLETests/Mocks/` is effectively empty — all fixtures are inline (~:2170+: `makeTestPeripheralSpec`, `connectionTestSpec`, `waitForPeripheral(...) -> Peripheral?`, `pollUntil`, `firstEvent`, `drain*`). Deterministic ids: `Mock.testPeripheralID = "ReliaBLE-Test-Peripheral"` (`:1964`) and `connectionTestPeripheralID` (`:1969`). Two simultaneous managers are supported via `makeManager(tearDownPrevious: false)` (`:2072`+, used at `:1808`), and restore is directly drivable through `bluetooth.testHandleWillRestoreState` (`:1628`, `:1679`, `:1704`, `:1745`). +- **`connect(to:)` / `disconnect(from:)` occupy exactly 36 lines**: `:70`, `:445`, `:457`, `:585`, `:614`, `:620`, `:661`, `:694`, `:738`, `:770`, `:804`, `:853`, `:878`, `:885`, `:935`, `:957`, `:990`, `:1015`, `:1043`, `:1070`, `:1104`, `:1125`, `:1163`, `:1213`, `:1228`, `:1251`, `:1284`, `:1332`, `:1377`, `:1455`, `:1550`, `:1596`, `:1655`, `:1808`. Other clusters: `:64-:126` (Sendable/equality proofs), `:320-:349` (discovery assertions), `:432-:466` (stale-snapshot connect). +- **Demo** (~10–12 library-type sites): `Central/DeviceStoreActor.swift:55` (`PeripheralDiscoveryEvent` param), `:68` (`syncDevices(_ peripherals: [Peripheral])`), `:73-78`; `Central/CentralView.swift:183/188/193` (stream subscriptions), `:282` (`disconnect(from: Peripheral(id: device.id))`), `:286` (`connect(to: Peripheral(id: device.id), ...)`). `CentralViewModel.swift` uses String ids + `ConnectionStateChange` only. The Demo's own `Device`/`DiscoveryEvent` types are unaffected in name. +- **DocC** (~50 sites, all stale on rename): `Documentation.md:18,22,35,42,69,71`; `GettingStarted.md:129-130,134,137-139,144,148,158,161,172,200+`; `Topics/Background.md:73,79-80,87,95+`; `Topics/Concurrency.md:88,90,100-101`; `Topics/Multi-Manager.md:19,45-46`. CI runs `generate-documentation --warnings-as-errors`, so stale symbol links fail the build. +- **`Sources/ReliaBLEMock/CoreBluetoothMockAliases.swift`**: zero impact (only `CB*` → `CBM*` rebindings). + +### Repo workflow conventions + +- Default branch `master` (clean, tracking `origin/master`). Branch naming is `-` (`65-bluetoothactor-instance-isolation`, `38-background-scanning`, `37-auto-reconnect`). +- Merge commits, **not** squash: `Merge pull request #NN from owner/`. +- **One issue per PR ("Closes #NN"), with related sub-work bundled inside as sequential green checkpoints.** Recent feature PRs: #66 (15 files, 14 commits, "Each commit is an independently green checkpoint"), #44 (14 files, 4 commits), #41 (15 files, 5 commits), #39 (10 files, 4 commits). PR bodies follow a loose template: Closes/refs, plan or design doc link, What changed, Verification, Notes. +- CI (`.github/workflows/ci.yml`): on every PR and on push to `master`; macos-15 / Xcode 16.4; `build-test` job = `swift build` + `swift test` (library + ReliaBLETests only, no Demo, no matrix); `docc` job = `generate-documentation --warnings-as-errors`; `deploy-docs` only on master push. No lint job. + +## Delivery strategy: one branch, one PR + +**Branch `50-peripheral-handle-type-model` → one PR (`Closes #50`, body referencing #53–#56 as delivered checkpoints).** Not four PRs. + +The four sub-issues are totally coupled at the type level. #53 alone renames today's `Peripheral` snapshot without a replacement handle type, which leaves `BluetoothActor`, `ReliaBLEManager`, the entire test file, and every DocC symbol link non-compiling — there is no green intermediate state where the snapshot exists but the handle does not. #55's `.peripheral` sugar is meaningless without #54's registry, and #54's registry is unobservable without #56's handle-side connect. Splitting would require throwaway shim APIs on each of three PRs. + +This matches observed repo convention: one issue per PR with related sub-work bundled as sequential green checkpoints inside (PR #66 = 15 files / 14 commits, "Each commit is an independently green checkpoint"; #44 = 14 files / 4 commits; #41 = 15 files / 5 commits). Sub-issues #53–#56 stay as tracking/checkpoint markers and are closed manually when #50's PR merges. + +## Approach + +Replace the single public value type `Peripheral` (immutable discovery snapshot serving as both list row and connect argument) with two types: + +- **`DiscoveredPeripheral`** — Sendable scan snapshot, carrying a reference to its vending manager's handle registry (PRD FR-2.4.3). +- **`Peripheral`** — long-lived, per-manager-interned `final class` control handle. + +Handle instances are interned in a **manager-owned** `PeripheralHandleRegistry`, not on the actor, so `manager.peripheral(id:)` can be synchronous and usable before any `CBCentralManager` exists. The actor keeps owning `cbPeripherals`, `connectionStates`, and the discovered snapshot list, and pushes metadata into handles through an injected `PeripheralRegistryBridge`. Handles are thin id façades: they forward connect/disconnect by id and expose **synchronous, lock-protected cached** last-discovery metadata for SwiftUI. `discoveredPeripherals` becomes `AsyncStream<[DiscoveredPeripheral]>`; `peripheralDiscoveries` keeps `PeripheralDiscoveryEvent` as the per-advertisement feed. Manager-level connect/disconnect are **removed** outright. Discovery and restoration share one id-resolution helper so the same handle instance is always returned for a given id on a given manager. + +### What blocks the PRD shape today + +- An immutable snapshot struct cannot own a sticky discovery filter, a command queue, a manual-connect hold, or stable reference identity for a "my devices" list. +- Manager-primary `connect(to:)` teaches the wrong API (FR-2.5 calls it a temporary milestone). +- There is no registry, so known-id-before-scan (`Peripheral(id:)` today) produces an orphan value that can only fail. +- `DiscoveredPeripheral` and the `.peripheral` sugar don't exist. +- Id-resolution logic is duplicated between `handlePeripheralDiscovered` and `handleWillRestoreState`; adding a registry to both sites without factoring it first guarantees divergence. + +### What is reused unchanged + +`AdvertisementData`, `ConnectionState` / `ReconnectSource` / `ConnectionStateChange`, `PeripheralError`, the stream-broadcaster pattern, the actor's `connect(id:)` / `disconnect(id:)` methods, the reconnect ladder, restore-intent persistence in `UserDefaults`, the three-target SPM mocking trick, and `forceMock: true` in the factory call. + +## Design decisions + +### D0 — Declare all five Apple platforms, pinned to the `Synchronization.Mutex` floor + +`Package.swift:8-9` currently declares only `.iOS(.v18)` and `.macOS(.v10_15)`. The macOS value is leftover, not a real support target. Replace the whole block with explicit support for every Apple platform, each pinned to the lowest version that ships `Synchronization`: + +```swift +platforms: [ + .iOS(.v18), + .macOS(.v15), + .tvOS(.v18), + .watchOS(.v11), + .visionOS(.v2) +], +``` + +These are the exact floors, read from the SDK rather than from memory — `Synchronization.swiftinterface` annotates `Mutex` as: + +``` +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +@frozen @_staticExclusiveOnly public struct Mutex : ~Copyable where Value : ~Copyable +``` + +Declaring platforms explicitly is what makes this safe. Previously, a consumer building for tvOS/watchOS/visionOS would inherit the SPM *default* floor for those platforms and fail on `import Synchronization`; pinning each one to its `Mutex` floor removes that failure mode instead of documenting around it. + +This is load-bearing for D1: with these floors, `Mutex` is available unconditionally — no `@available` annotations, no shimmed fallback — so every new type in this phase can be a **checked** `Sendable` rather than `@unchecked`. + +**Verified, not assumed** (against the Xcode 26.5 SDKs): + +| Platform | Check | Result | +|----------|-------|--------| +| watchOS 11 | `xcodebuild -scheme ReliaBLE -destination generic/platform=watchOS` on a scratch copy with the new platform block | `** BUILD SUCCEEDED **` | +| tvOS 18 | `swiftc -swift-version 6 -strict-concurrency=complete -target arm64-apple-tvos18.0 -typecheck` over all of `Sources/ReliaBLE` (Willow built from source for the same triple) | clean | +| visionOS 2 | same, `-target arm64-apple-xros2.0` | clean | +| all five | `Mutex` + `weak var` + checked `Sendable` typechecked per-triple | clean on iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2 | + +tvOS and visionOS could not be driven through `xcodebuild` on this machine — their SDKs are present but the platform *runtime components* are not installed, so no destination resolves. The direct `swiftc` typecheck against each SDK is the equivalent compile-level evidence; a full link on those two platforms is still unproven and should be confirmed in CI (see below). + +**Why this is safe for a central-role library.** CoreBluetooth's only `API_UNAVAILABLE(watchos, tvos)` symbols are peripheral-role: `CBPeripheralManager`'s initializers and the designated initializers on `CBMutableService` / `CBMutableCharacteristic` / `CBMutableDescriptor`. ReliaBLE uses none of them — a grep across `Sources/` for `CBPeripheralManager|CBMutable*|CBATTRequest` returns only a doc-comment mention in `CoreBluetoothMockAliases.swift:153`. `CBCentralManager`, `CBCentralManagerOptionRestoreIdentifierKey` (`NS_AVAILABLE(10_13, 7_0)`), and `centralManager:willRestoreState:` carry no platform exclusions. + +**Dependency floors are not a blocker:** CoreBluetoothMock declares macOS 10.14 / iOS 12 / watchOS 4 / tvOS 12 and Willow declares macOS 10.12 / iOS 10 / tvOS 10 / watchOS 3 — all below ours, and neither declares visionOS, so SPM applies its default floor rather than a conflicting one. Note both are `ReliaBLEMock`/test-target dependencies; the production `ReliaBLE` target depends only on Willow. + +**Runtime caveat to document, not a compile issue.** Background scanning and state restoration — this library's headline feature — are constrained on tvOS and watchOS: tvOS has no `bluetooth-central` background mode, and watchOS background BLE is limited. The API compiles and the central role works; `restoreIdentifier` will simply not deliver the same background behavior there. State this in `Topics/Background.md` rather than silently implying parity. + +**CI consequence.** `.github/workflows/ci.yml` builds and tests on macOS only, so these four extra platform declarations are unverified by CI as written. Add a compile-only matrix leg (`xcodebuild -scheme ReliaBLE -destination 'generic/platform=

'` for iOS/tvOS/watchOS/visionOS) so a future change cannot silently break a declared platform. Tests stay macOS-only — they need the CoreBluetoothMock harness, not real radios. + +### D1 — `Peripheral` is a `final class`, not an id-façade struct + +`public final class Peripheral: Sendable, Identifiable, Hashable` + +One interned instance per id per manager gives reference identity (`===`) for SwiftUI and makes `discovered.peripheral` return *the same object* rather than an equal copy. Option A metadata mutates in place without replacing the object, and the future FR-10 sticky filter / queue / readiness state attaches to the same instance. A struct façade would need an external registry of boxes anyway to deliver "same object" semantics, so it buys nothing. + +**Sendable contract — checked, via `Mutex`.** All mutable state lives in a single `Mutex`-wrapped value; every other stored property is a `let`. The compiler verifies `Sendable` conformance; nothing is asserted by hand. + +```swift +import Synchronization + +public final class Peripheral: Sendable, Identifiable, Hashable { + struct State { + weak var manager: ReliaBLEManager? + var cbIdentifier: UUID? + var name: String? + var rssi: Int? + var lastSeen: Date? + var advertisement: AdvertisementData? + } + + public let id: String + private let state: Mutex +} +``` + +A `weak var` inside a `Mutex`-guarded struct is legal and keeps the whole type checked-`Sendable` — this is the pattern to use anywhere a weak manager reference is needed. **Compile-verified** under `-swift-version 6 -strict-concurrency=complete` at `-target arm64-apple-macos15.0`, including a registry whose `withLock` closure creates, inserts, and returns a handle, and a `Task.detached` capturing both types. + +Three rules that govern any future field added to `State`: + +1. **`Mutex` is unconditionally `Sendable`** (SE-0433 achieves safety via `sending` on `init`/`withLock`, not by requiring `Value: Sendable`). The real constraint is on what **escapes**: `withLock` returns `sending Result`, so only `Sendable` or provably-isolated values may be returned. Every Option A field qualifies (`String`, `Int`, `Date`, `UUID`, `AdvertisementData`), as does `ReliaBLEManager`. +2. **`weak` is sound here for a specific reason:** all reads and writes of the weak reference happen under the mutex, and the runtime's weak load/zeroing is itself atomic with respect to deallocation. A bare `weak var` on an `@unchecked Sendable` class would *not* be sound — that is precisely why this design boxes it. +3. **Platform floor mechanics.** `Synchronization` needs macOS 15 / iOS 18 / tvOS 18 / watchOS 11 / visionOS 2 at runtime; CI's `macos-15` runner satisfies it and D0 removes any need for `@available`. D0 declares all five platforms at exactly those floors, so no consumer inherits an SPM default floor that would fail on `import Synchronization`. One consequence to accept explicitly: contributors on macOS 14 can no longer build or test the library locally. + +- `id` is an immutable `let`. +- No `CBPeripheral` is ever stored on the handle. +- Writes happen only via an internal `applyMetadata(...)` called from the actor's discovery/restore paths, so ordering is already actor-serialized. +- The manager reference is **weak, not `unowned`**. Handles can legitimately outlive the manager (the app holds one after dropping the manager); `unowned` would crash. Connect/disconnect on an orphaned handle throw `PeripheralError.bluetoothUnavailable`. + +**Equality/hash key on `id` only**, matching today (`Peripheral.swift:107-109` and `:111-115`) and the String-keyed `connectionStates` / `ConnectionStateChange.peripheralId` maps. `ObjectIdentifier`-based hashing would also work given one instance per id, but id-based keeps existing tests and connection-state correlation natural. Document the consequence: two handles from *different* managers with the same id compare `==` but are different objects on different registries; multi-manager apps must not mix them, and `===` plus the manager stamp distinguish them. + +### D2 — Option A metadata: synchronous cached properties on the handle + +```swift +public let id: String +public var cbIdentifier: UUID? { get } +public var name: String? { get } +public var rssi: Int? { get } +public var lastSeen: Date? { get } +public var advertisement: AdvertisementData? { get } +``` + +No public setters. Values update only through the internal `applyMetadata(...)` path. + +Each getter takes the `Mutex` for a single field read. **Reads of different properties are not one atomic snapshot** — a row reading `name`, then `rssi`, then `lastSeen` can straddle two discovery updates. This is benign for UI but is a public contract: document it, and prefer storing one immutable metadata struct in the `Mutex` so a caller who needs consistency can be offered a single-`withLock` accessor later. + +**Why sync, not async accessors:** SwiftUI `ForEach` row bodies need synchronous reads. Async accessors force a `.task` per row and produce visible flicker on every list update — unacceptable for the "my devices" screen that Option A exists to serve. A metadata-free control handle was also considered and rejected: it would leave offline known devices with no metadata at all, which is exactly the "my devices UI binds a single object" case Option A exists for. + +These are explicitly **last-known** values, not live CB state. After `invalidatePeripherals`/shutdown clears `cbPeripherals`, metadata remains at its last-known value while `connect()` throws `.notFound` until rediscovery rebinds a live reference. Document that contract. + +**Change notification — the missing half.** `Peripheral` is a plain `Sendable` class: not `@Observable`, no `objectWillChange`, and both a metadata-change stream and design Option B (`PeripheralUpdate { peripheral, discovery }`) stay deferred. SwiftUI therefore gets **no signal** that handle metadata changed. The "Nearby" screen re-renders because `discoveredPeripherals` vends fresh *value* snapshots; the "my devices" screen — the entire justification for Option A — would be handed a mutable reference with no invalidation channel and would render once, then go stale. + +**Resolution, zero API cost:** document that `discoveredPeripherals` doubles as the "something changed" tick. It fires on every advertisement, so an app re-reads handle metadata inside that loop. This must appear in D2 *and* in the `GettingStarted.md` DocC checklist item — shipping docs that say "bind your my-devices list to handle metadata" without it would teach a pattern that visibly does not update. A real notification mechanism stays deferred to Option B. + +This is now a PRD requirement rather than a plan-local note: **FR-2.4.5.3** mandates the change-notification affordance, names this interim contract, and explicitly forbids documentation that implies the view self-updates. **FR-2.4.5.1** requires the metadata to be readable without awaiting the isolation domain, which is what makes the sync accessors in this decision a requirement rather than a preference. + +### D3 — Manager connect/disconnect are removed, not deprecated + +Delete `ReliaBLEManager.connect(to:autoReconnect:)` and `ReliaBLEManager.disconnect(from:)` outright. No `@available(*, deprecated)` shim — AGENTS.md says this is pre-release and breaking changes are expected, so a deprecation tier would only preserve the wrong teaching surface. + +```swift +// on Peripheral +public func connect(autoReconnect: Bool = true) async throws +public func disconnect() async throws +``` + +Each resolves `manager` (throwing `.bluetoothUnavailable` if nil), then calls `await manager.bluetooth.ensureCentralManager()` followed by `try await manager.bluetooth.connect(id: id, autoReconnect:)` — the exact sequence `connect(to:)` performs today at `ReliaBLEManager.swift:189`. + +No access-level change and no new helper is required: `bluetooth` is **already** `internal let` (`ReliaBLEManager.swift:46`) and the handle is in the same module. Add an `ensureCentralManagerReady()` wrapper only if a single named seam for handle entry is wanted for its own sake — not to "avoid widening access," which is not a real constraint here. + +### D4 — `discoveredPeripherals` vends `[DiscoveredPeripheral]` + +`public var discoveredPeripherals: AsyncStream<[DiscoveredPeripheral]>` — real scan snapshots only, replayed as today. + +Offline known-id handles never appear here; that is the design doc's "don't fake discoveries" rule. Restored peripherals still appear (the restore path already upserts into this list) with empty advertisement and nil rssi, exactly as today, and are still deliberately absent from the `peripheralDiscoveries` ad feed. + +**No `trackedPeripherals` stream in this phase.** Apps obtain handles from `peripheral(id:)` or `discovered.peripheral`; a tracked feed is design Option B and stays deferred. **PRD FR-2.4.5.2** now states this explicitly — a dedicated tracked feed is not required, and composing the view from those two entry points is what keeps offline devices representable without fake discoveries. + +### D5 — Both discovery feeds survive + +| Feed | Element | Replay | Role | +|------|---------|--------|------| +| `peripheralDiscoveries` | `PeripheralDiscoveryEvent` | No | Every advertisement (CB `UUID` id, rssi, ad) | +| `discoveredPeripherals` | `[DiscoveredPeripheral]` | Yes (latest list) | Deduped nearby list | + +Do not merge them: the event's `id` is the CoreBluetooth `UUID` while the app-facing id is a `String`, and the high-rate ad path should stay small. The Demo continues to consume `insertDiscovery(PeripheralDiscoveryEvent)`. + +`PeripheralDiscoveryEvent`'s fields stay **unchanged** this phase. Adding a `peripheralId: String?` correlation field was considered and **deferred to FR-8.5**, not merely dropped for blast radius: FR-8.5 introduces manufacturer-data-based unique identity, which is what determines how an advertisement maps to an app-facing id in the first place. Adding a correlation field now would bake in today's name-derived resolution (`name ?? localName ?? uuidString`) and then have to change again when FR-8.5 lands. + +This deferral is now recorded in the PRD as **FR-8.5.4**, so FR-8.5 cannot be implemented without settling it. + +Add a doc comment clarifying that `id` is the CoreBluetooth identifier, not the app-facing peripheral id — a contract now required by **PRD FR-8.1.4**. Until FR-8.5, correlate via `DiscoveredPeripheral` or the handle. + +### D6 — `peripheral(id:)` is synchronous and nonisolated; the registry is manager-owned + +```swift +public func peripheral(id: String) -> Peripheral +``` + +This must work before `ensureCentralManager()` — it is the known-id-before-scan entry point — and it must be synchronous so SwiftUI can call it inline. That rules out interning on the actor, which would force `async`. + +**Resolution: split ownership.** + +- `PeripheralHandleRegistry` — a `final class`, checked `Sendable`, holding `Mutex` where `Storage` carries `[String: Peripheral]` plus a `weak var manager`. Created in `ReliaBLEManager.init` and owned by the manager. `peripheral(id:)` takes the lock, returns the existing handle or creates, inserts, and returns a new one with empty metadata. No actor hop. +- `BluetoothActor` does **not** own handle instances. It continues to own `cbPeripherals`, `connectionStates`, and the discovered snapshot list. + +**Construction order — the naive wiring does not compile.** `PeripheralHandleRegistry(manager: self)` cannot appear in `ReliaBLEManager.init` before `bluetooth` is assigned: `self` is unusable in a class initializer until every stored property is initialized, so `registry` (needing `self`) cannot precede `bluetooth` (needing `registry`). Verified — `error: 'self' used before all stored properties are initialized`. Note this diagnostic is a SIL pass, so `-typecheck` alone reports nothing; it surfaces on a real build. + +Use a two-phase attach: + +```swift +public init(config: ReliaBLEConfig = ReliaBLEConfig()) { + loggingService = LoggingService(...) + handleRegistry = PeripheralHandleRegistry() // no manager yet + bluetooth = BluetoothActor(log: ..., reconnectPolicy: ..., restoreIdentifier: ..., + registry: handleRegistry) + handleRegistry.attach(manager: self) // legal: all stored props initialized + Task { await bluetooth.ensureCentralManager() } +} +``` + +Consequence to state rather than leave implicit: handles capture their `weak manager` **at creation, from the registry's stored reference**, so nothing may call `peripheral(id:)` before `attach`. Nothing does today — but the registry should assert it in debug builds. + +(The alternative — passing `manager:` per call to `registry.peripheral(id:manager:)` — avoids `attach` entirely, but then `DiscoveredPeripheral` cannot resolve `.peripheral` from a registry reference alone, which reintroduces the `ManagerStamp` that D7 deletes. The two-phase attach is the cheaper trade.) + +**Bridging actor → registry.** The actor has no manager reference today. Two alternatives were rejected: having the manager observe its own discovered stream and sync handles (racy, and ordering is not guaranteed relative to connect), and having the actor own an id/metadata registry while the manager owns the class instances (splits one concept across two owners and re-creates the divergence hazard). Instead, inject a bridge at actor init: + +```swift +/// Invoked only from BluetoothActor's executor. Create-or-update: interns the handle +/// if absent, then applies metadata. +protocol PeripheralRegistryBridge: Sendable { + func applyDiscovery( + id: String, + cbIdentifier: UUID?, + name: String?, + rssi: Int?, + lastSeen: Date?, + advertisement: AdvertisementData? + ) + func applyConnectionState(id: String, state: ConnectionState) +} +``` + +**One discovery method, not two.** A separate `ensureHandle(id:)` was considered and rejected: it is always called immediately before `applyDiscovery`, which must intern anyway, so it only doubles lock acquisitions on the library's hottest path (one call per advertisement per device — the mock alone advertises every 50 ms across two peripherals, `ReliaBLEManagerTests.swift:2138`, `:2156`). + +**`PeripheralHandleRegistry` conforms to this protocol directly** — no separate adapter object. The protocol exists for the actor's dependency inversion and testability; a second type would add nothing and would create another place to accidentally capture the manager strongly. + +`BluetoothActor.init` gains a `registry: PeripheralRegistryBridge` parameter alongside `log:reconnectPolicy:restoreIdentifier:`. Connect still resolves `cbPeripherals[id]` on the actor — a handle is never needed for CB lookup. + +**Known-id matching semantics are unchanged from today.** If the app calls `peripheral(id: "MyBand")` and a device advertises name `MyBand`, the resolved id is `MyBand` and the live reference binds to the existing handle. If the advertised name differs, no bind occurs — the same limitation `Peripheral(id:)` has today, tracked by FR-8.5 (manufacturer-data unique id). + +### D7 — `DiscoveredPeripheral` holds the registry; the `.peripheral` sugar + +`DiscoveredPeripheral` must stay a `Sendable` struct while being able to reach its vending manager's registry. A dedicated `ManagerStamp` box was considered and **rejected** — the snapshot should simply hold the registry, which is already per-manager, already `Sendable`, and is the very thing being looked up: + +```swift +public struct DiscoveredPeripheral: Sendable, Identifiable, Hashable { + // …public lets… + let registry: PeripheralHandleRegistry // internal; excluded from ==/hash + public var peripheral: Peripheral { registry.peripheral(id: id) } +} +``` + +This is better on four counts: + +1. Deletes a type and one of the design's weak references. +2. Removes an injection dependency — the actor already receives the registry, so nothing extra needs passing to `BluetoothActor.init`. +3. **Preserves the interning invariant after manager death.** With a stamp, `.peripheral` on a snapshot whose manager was freed would return a *fresh* orphan on every call, making `snap.peripheral === snap.peripheral` false — quietly violating this phase's headline guarantee in exactly the state that is hardest to debug. Holding the registry keeps interning alive; the handle's own weak manager is nil, so `connect()` still throws `.bluetoothUnavailable`. Same documented behavior, no second identity rule. +4. Retain graph stays acyclic: manager → registry (strong), registry → handles (strong), handle → manager (weak), snapshot → registry (strong). A retained snapshot outliving its manager keeps the registry alive — which is exactly what makes point 3 work. + +`==`/`hash` on `id` only, consistent with the handle; `registry` is excluded. + +### D8 — Registry shape + +``` +ReliaBLEManager + ├── PeripheralHandleRegistry ← owns handle instances; conforms to + │ Mutex PeripheralRegistryBridge directly + │ ├── handles: [String: Peripheral] (strong) + │ └── manager: ReliaBLEManager? (weak, set via attach) + └── BluetoothActor + ├── registry: PeripheralRegistryBridge (injected at init) + ├── discoveredPeripherals: [DiscoveredPeripheral] (name kept; element type changed) + ├── cbPeripherals: [String: CBPeripheral] + ├── connectionStates: [String: ConnectionState] + └── resolveAndUpsertDiscovered(...) ← single id-resolution site +``` + +**Keep the actor property named `discoveredPeripherals`.** Only its element type changes. Renaming it to `discoveredSnapshots` would break two `@testable` accesses (`ReliaBLEManagerTests.swift:1500`, `:1783`) and four internal DocC links for zero functional gain, inside an already-large commit whose review budget is the scarce resource. If the rename is wanted for clarity, make it a separate trailing commit. + +No process-global registry. Two managers → two registries → two independent handles for the same physical device, per the one-stack-per-manager rule. + +### D9 — One shared id-resolution helper + +Factor the logic currently duplicated at `handlePeripheralDiscovered` (`BluetoothActor.swift:876`) and `handleWillRestoreState` into one private actor method: + +```swift +/// Resolves the app-facing id and upserts the discovered snapshot list. Returns the resolved id. +/// Does NOT broadcast and does NOT emit discovery events — callers keep those responsibilities. +/// Merge rule: a nil `name` / `rssi` / `advertisement` means "keep the existing value, +/// falling back to nil / empty when there is none". +private func resolveAndUpsertDiscovered( + cbPeripheral: CBPeripheral, + name: String?, + rssi: Int?, + lastSeen: Date, + advertisement: AdvertisementData? +) -> String +``` + +Id resolution is identical at both sites: `identifier = cbPeripheral.name ?? advertisement.localName ?? cbPeripheral.identifier.uuidString`; match by `id`, then by `cbIdentifier`, else append. + +**The update branches are not identical, and the helper must not flatten them.** Restore preserves prior values where discovery overwrites: + +| Field | Discovery (`:930`) | Restore (`:742`, `:744`, `:751`, `:754`) | +|-------|--------------------|------------------------------------------| +| `rssi` | new value | **keeps** `discoveredPeripherals[idx].rssi` | +| `advertisement` | new value | **keeps** prior, `?? emptyAdvertisement` | +| `name` | overwrites | `name ?? existing` in the `cbIdentifier`-match branch | + +A helper that made restore pass `nil` rssi and a fresh empty `AdvertisementData` would **regress restore**: a device discovered before termination and then restored would have its RSSI and last advertisement wiped from the snapshot list *and*, now that Option A promotes them, from its handle metadata. That is a user-visible behavior change disguised as a refactor, and no existing test would catch it. Hence the explicit nil-means-keep merge rule above, with discovery always passing non-nil so the rule is a no-op there. **Add a test** asserting a restored, previously-discovered peripheral keeps its RSSI and advertisement. + +**No `emitDiscoveryEvent` flag.** `PeripheralDiscoveryEvent` is broadcast at `:886-890`, *before* id resolution begins, from data the helper never receives. Leaving that broadcast at the discovery call site preserves the "restored peripherals never hit the ad feed" invariant for free. + +**The helper does not broadcast.** Discovery broadcasts once per advertisement (`:953`); restore broadcasts **once after its loop**, guarded by `didMutatePeripherals` (`:722`, `:771`, `:804`), specifically to avoid N broadcasts of partially-updated lists for N restored peripherals. Both callers keep their existing broadcast placement. + +**`lastSeen` on restore.** Today restore stamps `lastSeen: now` for a device that has not actually advertised (`:744-766`). Harmless while it is an internal list field; once Option A makes it a public my-devices field it renders as "Last seen: just now" for a device last heard from before the app was killed. **Decision: keep `now`, and document `lastSeen` as "last bound or seen"** — changing it to preserve the prior value would make restored-but-never-discovered peripherals show nil, which is worse for the list UI. Revisit if it confuses users. + +**`refreshPeripherals()` (`:999-1016`) needs no bridge call.** It re-binds live references after a power cycle but resolves no new ids and changes no metadata. Named here so the "single id-resolution site" claim is accurate and an implementer grepping for snapshot mutation sites does not have to guess. + +### D10 — Connection state: cached sync property on the handle; the stream stays id-keyed + +Issue #56 explicitly leaves this to planning ("stream on handle and/or existing manager streams keyed by id—planning chooses"). The choice: + +- **`public var connectionState: ConnectionState? { get }`** on `Peripheral` — a cached, `Mutex`-backed sync read, mirrored from the actor's `connectionStates` through the same bridge, via `applyConnectionState(id:state:)`. +- **`ConnectionStateChange.peripheralId: String` and `connectionStateChanges` are unchanged.** Apps observe with the existing stream and filter on `change.peripheralId == peripheral.id`. +- **No per-handle `AsyncStream` this phase** — that needs its own broadcaster lifecycle tied to handle deallocation, and is a follow-up. + +Rationale for doing the cached property *now* rather than in a later PR: "is it connected?" is the single most important field on the my-devices row this phase exists to enable, and FR-2.3.1 says connection-state consumption should migrate to `Peripheral` as the handle model lands. The bridge, the lock, and the actor-ordered write path are all already being built here — mirroring a second field costs one protocol method and one property, whereas deferring means reopening the identical seam later. Mirror it wherever `connectionStates[id]` is written on the actor. + +The same change-notification caveat from D2 applies: re-read it inside a `connectionStateChanges` loop. + +### D11 — Public `Peripheral(id:)` is removed + +`manager.peripheral(id:)` replaces it. This is the point: `Peripheral(id:)` today produces an unregistered value with no manager, which can only ever fail to connect. The handle's init becomes internal and is callable only from the registry. Tests and the Demo migrate to `manager.peripheral(id:)`. + +### D12 — Logging unchanged + +Keep `LogTag.peripheral(String)` carrying the handle id. No changes to `LoggingService` or tag shapes. + +### D13 — FR-10 attachment point + +The class choice reserves a home for the future sticky discovery filter, command queue, and readiness state on the handle. **Do not add any public filter or readiness API now, and do not add storage for it** — a comment noting the intended location is sufficient. FR-10 is #52, out of scope. + +### D14 — Registry lifecycle: strong values, cleared on `shutdown()` + +The registry holds handles **strongly**, keyed by resolved id, populated on every discovery. Nothing in the design evicts them, and each handle retains a full `AdvertisementData` (`manufacturerData: Data`, `serviceData: [CBUUID: Data]`, three UUID arrays). For a library whose headline feature is continuous background scanning, unbounded growth is a real leak class: in a crowded environment every distinct advertised name — and every nameless device's UUID string — mints a permanent handle. + +**Decision: clear the registry in `shutdown()`**, matching what the actor already does with `cbPeripherals` / `discoveredPeripherals` / `connectionStates` (`BluetoothActor.swift:255-261`). This costs two lines and harms nothing: handles the app still holds keep working (orphaned, throwing `.bluetoothUnavailable`), and the stack is dead anyway. + +`invalidatePeripherals()` behaves differently and deliberately: it **keeps** handles and their last-known metadata, because the handle must survive a radio reset. + +Weak-value storage (`[String: WeakBox]` with prune-on-insert) was considered as a stronger answer — interning identity would then last exactly as long as the app holds a reference, which is the only window in which `===` is observable. It is **deferred**: it changes the registry's type, raises a metadata side-cache question (a re-created handle would start empty), and is not needed to ship this phase. Revisit alongside FR-8.5 / #51 if profiling shows growth matters. + +## Proposed signatures + +### `Sources/ReliaBLE/Models/DiscoveredPeripheral.swift` (new) + +```swift +public struct DiscoveredPeripheral: Sendable, Identifiable, Hashable { + public let id: String + public let cbIdentifier: UUID? + public let name: String? + public let rssi: Int? + public let lastSeen: Date? + public let advertisement: AdvertisementData? + + /// The interned control handle for the manager that produced this snapshot. + public var peripheral: Peripheral { get } + + // internal — no app-facing init; excluded from ==/hash + let registry: PeripheralHandleRegistry + init(id:cbIdentifier:name:rssi:lastSeen:advertisement:registry:) +} +``` + +### `Sources/ReliaBLE/Models/Peripheral.swift` (rewrite) + +```swift +public final class Peripheral: Sendable, Identifiable, Hashable { + public let id: String + + public var cbIdentifier: UUID? { get } + public var name: String? { get } + public var rssi: Int? { get } + public var lastSeen: Date? { get } + public var advertisement: AdvertisementData? { get } + + /// Last-known connection state, mirrored from the actor. See D10. + public var connectionState: ConnectionState? { get } + + public func connect(autoReconnect: Bool = true) async throws + public func disconnect() async throws + + public static func == (lhs: Peripheral, rhs: Peripheral) -> Bool // id only + public func hash(into hasher: inout Hasher) // id only + + // internal + init(id: String, manager: ReliaBLEManager?) + func applyMetadata(cbIdentifier:name:rssi:lastSeen:advertisement:) + func applyConnectionState(_ state: ConnectionState) +} +``` + +// New file: Sources/ReliaBLE/PeripheralHandleRegistry.swift + +```swift +final class PeripheralHandleRegistry: PeripheralRegistryBridge, Sendable { + struct Storage { + weak var manager: ReliaBLEManager? + var handles: [String: Peripheral] = [:] + } + private let storage: Mutex + + init() // no manager yet — see D6 + func attach(manager: ReliaBLEManager) // phase two of construction + func peripheral(id: String) -> Peripheral // intern (create or return) + func removeAll() // called from shutdown() — D14 + + // PeripheralRegistryBridge — invoked only from the actor's executor + func applyDiscovery(id:cbIdentifier:name:rssi:lastSeen:advertisement:) + func applyConnectionState(id:state:) +} +``` + +Note that `Peripheral`'s init is `internal`, which does **not** enforce "only the registry constructs handles" — anything in the module can call it, including the actor, which is exactly the divergence hazard the registry exists to prevent. Either co-locate `Peripheral` and `PeripheralHandleRegistry` in one file and mark the init `fileprivate`, or keep `internal` and treat the single-construction-site rule as a review-enforced convention. Say which in the code comment. + +### `Sources/ReliaBLE/ReliaBLEManager.swift` + +```swift +// ADD +public func peripheral(id: String) -> Peripheral + +// CHANGED ELEMENT TYPE +public var discoveredPeripherals: AsyncStream<[DiscoveredPeripheral]> { get } + +// REMOVED +// public func connect(to:autoReconnect:) async throws +// public func disconnect(from:) async throws + +// INTERNAL +let handleRegistry: PeripheralHandleRegistry // constructed then attach(manager: self) — D6 +// `let bluetooth: BluetoothActor` is ALREADY internal (:46); handles call +// `manager.bluetooth.ensureCentralManager()` directly. No access widening, no new helper. +``` + +Final public stream surface: `state: AsyncStream`, `peripheralDiscoveries: AsyncStream`, `discoveredPeripherals: AsyncStream<[DiscoveredPeripheral]>`, `connectionStateChanges: AsyncStream`. + +## State and data flow + +**Known-id path.** `manager.peripheral(id: "band-1")` → registry lock → create `Peripheral(id:, weak manager)` with empty metadata → return. No CB, no actor hop. Later, when discovery resolves to `"band-1"`: `resolveAndUpsertDiscovered` → `registry.applyDiscovery` updates that same instance's metadata → `cbPeripherals["band-1"] = cb`. Then `try await p.connect()` → manager ensures central → `actor.connect(id: "band-1")`. + +**Scan / nearby path.** `didDiscover` → `PeripheralDiscoveryEvent` broadcast → `resolveAndUpsertDiscovered` upserts a registry-carrying `DiscoveredPeripheral` → `registry.applyDiscovery` → broadcast `[DiscoveredPeripheral]`. The apply **precedes** the broadcast (invariant, see Concurrency). `discovered.peripheral` returns the same instance `manager.peripheral(id:)` would. + +**Restore path.** `willRestoreState` → `resolveAndUpsertDiscovered` per peripheral (nil rssi/advertisement → prior values kept, per the D9 merge rule; no ad-feed emission because the caller simply doesn't broadcast one) → `registry.applyDiscovery` → seed `connectionStates` and re-arm reconnect intent from persistence → **one** broadcast after the loop, guarded by `didMutatePeripherals`. + +**Shutdown / manager deinit.** `shutdown()` clears actor maps and finishes streams. Handles may remain alive in app code with `manager == nil`; `connect`/`disconnect` then throw `.bluetoothUnavailable`. + +**Duplicate / out-of-order ads.** Latest wins for both the snapshot list and handle metadata; handle identity stays stable; the cb map is last-writer-wins — the same known same-name collapse as today. + +## Error handling and edge cases + +| Case | Behavior | +|------|----------| +| `connect()` on a handle never discovered (no live CB ref) | `PeripheralError.notFound` — actor path unchanged | +| Manager deallocated, or `shutdown()` already ran | `PeripheralError.bluetoothUnavailable` | +| Bluetooth unavailable / unauthorized | `.bluetoothUnavailable` via the existing ensure path | +| Device name changes, same CB UUID | Resolves by `cbIdentifier`, preserving the original `id` (today's behavior) | +| Two devices advertising the same name | Collapse to one id — known limitation, document, FR-8.5 later | +| Handle from manager A used while B also runs | Handle only talks to its own weak manager; it cannot reach B's CB map | +| `discovered.peripheral` after its manager was freed | Orphan handle; `connect` throws `.bluetoothUnavailable` | +| `manager.peripheral(id: "")` | Allowed, treated as a normal id — no special case | +| Two managers, same id string | `==` is `true`, `===` is `false`; documented as an app-level hazard | +| `invalidatePeripherals()` (`:956-968`) | Clears `cbPeripherals` and connection state; **keeps** handles and their last-known metadata — the handle must survive a radio reset | +| `shutdown()` (`:255-261`) | Clears the actor's maps **and** the handle registry (D14). Handles the app still holds keep working, orphaned, throwing `.bluetoothUnavailable` | +| Restored peripheral previously discovered | Keeps its prior RSSI and advertisement (D9 merge rule); `lastSeen` is stamped to now and documented as "last bound or seen" | +| Explicit disconnect | Unchanged nil-reason contract | + +## Concurrency and lifecycle + +- The handle's `Mutex` guards metadata only; the registry's guards the handle table. `Mutex.withLock` is non-async, so `await` cannot appear inside — the "never suspend under the lock" rule is enforced structurally. +- **Lock order: registry → handle, never the reverse.** Better still, avoid nesting entirely: `applyDiscovery` should copy the handle reference out under the registry lock, **release**, then call `handle.applyMetadata(...)`. +- **Bridge contract.** Direct actor re-entry is already impossible — the protocol methods are synchronous and non-throwing, so calling back into the actor would require `await`, which cannot appear. The rules that *are* reachable and therefore matter: implementations must be non-blocking and allocation-light (the actor's executor thread stalls behind whoever holds the registry lock); must **not** spawn `Task { await actor… }`, which compiles fine inside a sync function and reorders arbitrarily against the discovery stream; must not invoke app-supplied callbacks under a lock; and must observe the lock order above. +- **Apply-before-broadcast invariant.** `registry.applyDiscovery(...)` must run **before** the snapshot list is broadcast, so a consumer receiving snapshot *N* never reads handle metadata older than *N*. This is a requirement, not an incidental ordering — a later "broadcast early to cut latency" change would silently break it. Covered by a test (see Verification). +- Streams continue to retain the actor (existing design, unchanged). +- **Retain graph — the complete rule:** manager → registry (strong) → handles (strong) → manager (**weak**); manager → actor → registry-as-bridge (strong) → manager (**weak**); snapshot → registry (strong). App code holds handles strong. + + **Nothing reachable from the actor may hold the manager strongly.** This is the one place a real leak can hide: the actor stores the bridge, so a bridge capturing the manager strongly makes the cycle manager → actor → bridge → manager. And because live stream subscribers retain the actor, a single un-terminated `for await` would then pin the manager, its central, and every handle for the process lifetime — making the entire orphan-handle contract unreachable in production *and* in tests. Having the registry conform to `PeripheralRegistryBridge` itself (D6) satisfies this automatically, since the registry's manager reference is already weak. + +## File-by-file impact + +| File | Change | Driver | +|------|--------|--------| +| `Sources/ReliaBLE/Models/Peripheral.swift` | Rewrite as the `final class` handle; remove public `init(id:)` | D1–D3, D11 | +| `Sources/ReliaBLE/Models/DiscoveredPeripheral.swift` | **New** — snapshot struct carrying the registry + `.peripheral` sugar | D4, D7 | +| `Sources/ReliaBLE/PeripheralHandleRegistry.swift` | **New** — intern registry + `PeripheralRegistryBridge` adapter | D6, D8 | +| `Sources/ReliaBLE/BluetoothActor.swift` | Snapshot list element type (**property name unchanged**); accept `registry:` in `init`; factor `resolveAndUpsertDiscovered` with merge semantics; call bridge from discovery + restore; mirror connection-state writes through the bridge; clear the registry in `shutdown()`; stream element type | D6, D8, D9, D10, D14 | +| `Sources/ReliaBLE/ReliaBLEManager.swift` | Add `peripheral(id:)`; remove connect/disconnect; change stream element type; construct registry then `attach(manager:)` in `init` (two-phase — see D6) | D3–D6 | +| `Sources/ReliaBLE/Models/Events/PeripheralDiscoveryEvent.swift` | Doc only — clarify `id` is the CoreBluetooth identifier | D5 | +| `Sources/ReliaBLE/Models/PeripheralError.swift` | Doc only — errors now surface from handle calls | Docs | +| `Sources/ReliaBLE/Models/ConnectionState.swift` | Doc only — `peripheralId` is the handle's id | Docs | +| `Sources/ReliaBLE/ReliaBLEConfig.swift` | Doc only — references to the connect path | Docs | +| `Tests/ReliaBLETests/ReliaBLEManagerTests.swift` | Migrate all `Peripheral`/connect/disconnect sites; add interning tests | Verification | +| `Sources/ReliaBLE/Documentation.docc/**` | Rewrite the Peripheral narrative across all five files | CI docc gate | +| `Demo/.../Central/DeviceStoreActor.swift:68` | `syncDevices(_ peripherals: [DiscoveredPeripheral])` | Demo compiles | +| `Demo/.../Central/CentralView.swift:282,286` | Connect/disconnect via `reliaBLE.peripheral(id:)` handle | D3, D11 | +| `PRD.md` | **Already updated** during planning: FR-2.4.3 no longer prescribes "manager-stamped" (D7 carries the registry instead); new FR-8.1.4 (ad-feed id contract) and FR-8.5.4 (correlation deferral). Remaining: check off FR-2.4 items once landed | Product | +| `Package.swift:9` | `.macOS(.v10_15)` → `.macOS(.v15)` | D0 | +| `Sources/ReliaBLEMock/CoreBluetoothMockAliases.swift`, `README.md` | **No change** | — | + +## Work items + +Single branch `50-peripheral-handle-type-model`. Each commit must leave `swift build && swift test` green. + +**0 — Platform declarations · Trivial · Depends on: nothing** +- Goal: replace the `platforms:` block in `Package.swift:7-10` with all five Apple platforms pinned to their `Synchronization` floors (D0), unlocking `Mutex` for the types added in item 1. +- Also add the compile-only CI matrix leg for iOS/tvOS/watchOS/visionOS to `.github/workflows/ci.yml`, so the new declarations are actually enforced. +- Done when: `swift build` and `swift test` pass unchanged on macOS, and the new CI legs compile on each declared platform. +- Its own first commit — keeps item 1's diff focused on the type work. + +**1 — Library API cut (#53 + #54 + #55 + #56) · Large · Depends on: 0** +- Goal: Add `DiscoveredPeripheral`, the `Peripheral` class handle, `PeripheralHandleRegistry` + bridge; move the actor's snapshot list to `[DiscoveredPeripheral]`; add `manager.peripheral(id:)`; move connect/disconnect onto the handle and remove the manager versions; factor `resolveAndUpsertDiscovered` and wire it into both discovery and restore; migrate the whole test file in the same commit. +- Key files: `Models/Peripheral.swift`, `Models/DiscoveredPeripheral.swift`, `PeripheralHandleRegistry.swift`, `BluetoothActor.swift`, `ReliaBLEManager.swift`, `Tests/ReliaBLETests/ReliaBLEManagerTests.swift`. +- Done when: `swift build` and `swift test` pass with no manager-level connect and no public `Peripheral(id:)`. +- Note: this is deliberately one large commit. Removing the struct breaks every call site simultaneously, so there is no smaller green slice — attempts to stage it need throwaway shims that cost more than they save. + +**2 — DocC migration · Medium · Depends on: 1** +- Goal: Update all five DocC files plus the doc-only source comments so no symbol link is stale. +- Key files: `Documentation.docc/Documentation.md`, `GettingStarted.md`, `Topics/Background.md`, `Topics/Concurrency.md`, `Topics/Multi-Manager.md`; doc comments in `PeripheralDiscoveryEvent.swift`, `PeripheralError.swift`, `ConnectionState.swift`, `ReliaBLEConfig.swift`. +- Done when: the DocC command below passes with `--warnings-as-errors`. +- **Items 1 and 2 must land together in the PR** — CI runs the docc gate on every PR, and stale ``Peripheral/init(id:)`` links fail it. + +**3 — Demo migration · Small · Depends on: 1** +- Goal: `syncDevices(_ peripherals: [DiscoveredPeripheral])` at `DeviceStoreActor.swift:68`; in `CentralView.swift`'s private `DeviceDetailView` (:248), replace `Peripheral(id: device.id)` connect/disconnect at :282 and :286 with `reliaBLE.peripheral(id: device.id)` + handle calls. The Demo's own SwiftData `Device` and `DiscoveryEvent` types stay as-is — do not introduce a library `Device` type. +- Done when: the Demo builds. **The implementer must read `Demo/AGENTS.md` first and use XcodeBuildMCP, not raw `xcodebuild`.** The Demo is not in CI. + +**4 — New behavior tests · Medium · Depends on: 1** +- Goal: Add the seven interning/sugar/isolation tests listed below. +- Done when: `swift test` green, each new test passing individually. + +**5 — Docs bookkeeping · Small · Depends on: 1–4** +- Goal: Close this plan's decision log; check off PRD FR-2.4 items that are now accurate. + +## Verification + +### Existing tests to rewrite (`Tests/ReliaBLETests/ReliaBLEManagerTests.swift`) + +| Test / helper | Change | +|---------------|--------| +| `waitForPeripheral` (`:2170`) | **Keep a snapshot-returning `waitForDiscovered(id:) -> DiscoveredPeripheral?`** and derive the handle at call sites via `discovered.peripheral`. Silently repointing this helper at the handle would leave `#expect(peripheral?.advertisement?.localName == …)` and `#expect(peripheral?.cbIdentifier != nil)` (`:336-338`) reading whatever the *latest* discovery wrote — they would still pass, but would no longer test the snapshot the stream actually emitted, which is the entire point of those assertions. Preserve at least one assertion against snapshot fields. A handle-returning variant may be added alongside | +| Peripheral Sendable proof (`:84-88`) | Capture the class handle from `manager.peripheral(id:)` across `Task.detached` | +| Equality/hash tests (`:110-126`) | Keep as an **interning** assertion only: `manager.peripheral(id: "x") === manager.peripheral(id: "x")`. Under interning these return the same object, so `==` is satisfied by *any* implementation including default identity — the id-only equality contract cannot be proven here. Move the real `==` assertion and the `Set`-count check into the two-manager test below | +| Manager Sendable proof (`:65-70`) | Stream type is `AsyncStream<[DiscoveredPeripheral]>`; use `peripheral(id:)`; drop the `connect(to: Peripheral(id: "unused"))` reference at `:70` | +| All 36 `connect(to:)` / `disconnect(from:)` sites | `try await handle.connect(autoReconnect:)` / `try await handle.disconnect()`. Full list in Background; the multi-manager and restoration clusters at `:1455`, `:1550`, `:1596`, `:1655`, `:1808` are easy to miss | +| Stale/unknown-peripheral connect (`:445-462`) | The existing test deliberately accepts `.notFound || .bluetoothUnavailable`, because `makeManager()` does not bring the central online (authorization pinned `.notDetermined`, `:2049`) and `init`'s fire-and-forget `Task { await bluetooth.ensureCentralManager() }` may not have completed. **To assert `.notFound` deterministically the test must first `await Mock.ensureReady(manager)`** — otherwise keep the either-or assertion | +| Restoration tests | List element type is `DiscoveredPeripheral`; assert restored handle metadata; `testContainsCBPeripheral` assertions unchanged | +| Multi-manager isolation tests (`:1808`) | Discover on A; `a.peripheral(id:) === listElement.peripheral`; B's same-id handle is `!==` **but** `==`, with the `Set` count check; only A holds the live ref | + +### New tests + +1. `peripheralIdInternsSingleInstance` — `manager.peripheral(id: "x") === manager.peripheral(id: "x")`. +2. `discoveredPeripheralSugarReturnsInternedHandle` — scan, take a snapshot from the stream, assert `snap.peripheral === manager.peripheral(id: snap.id)`. +3. `knownIdHandleReceivesMetadataOnDiscovery` — create the handle *first*, then scan until match; assert `rssi`/`lastSeen`/`advertisement` become non-nil on that **same** instance. +4. `connectOnHandleNeverSeenThrowsNotFound` — ready manager, handle with no discovery; connect throws `.notFound` (central exists, live ref does not). +5. `twoManagersIndependentHandleRegistries` — same id string on both; `a.peripheral(id:) !== b.peripheral(id:)`; discover only on A; only A holds a live ref. +6. `handleConnectDisconnectLifecycle` — full `connecting → connected → disconnecting → disconnected` through handle APIs, asserting the cached `peripheral.connectionState` tracks it (may fold into an existing lifecycle test). +7. `discoveredPeripheralsStreamElementType` — the replayed list is `[DiscoveredPeripheral]` and contains the test id. +8. `restorePathInternsSameHandle` — pre-create `manager.peripheral(id:)`, drive restore via `bluetooth.testHandleWillRestoreState` (the hook already used at `:1628`, `:1679`, `:1704`, `:1745`), assert the **same instance** received `cbIdentifier` and that `testContainsCBPeripheral` holds. Per D9, also assert a previously-discovered peripheral **keeps** its prior RSSI and advertisement across restore — this is the regression guard for the merge rule. +9. `handleMetadataAppliedBeforeBroadcast` — subscribe to `discoveredPeripherals`; on the **first** element containing the test id, immediately assert `manager.peripheral(id: testId).rssi != nil`. No polling — that is what makes it a real ordering assertion (invariant in the Concurrency section). +10. `handleOrphansWhenManagerDeallocates` — create a manager in an inner scope, take a handle, drop the manager and end all stream subscriptions, then assert `handle.connect()` throws `.bluetoothUnavailable`. **This test is the leak detector** for the retain-graph rule: if anything reachable from the actor holds the manager strongly, it fails. + +### Commands + +```sh +swift build +swift test +swift test --filter ReliaBLETests.peripheralIdInternsSingleInstance +swift test --filter ReliaBLETests.handleOrphansWhenManagerDeallocates + +# DocC gate — same invocation CI uses (.github/workflows/ci.yml:65-72) +swift package --allow-writing-to-directory ./user-docs \ + generate-documentation --target ReliaBLE \ + --disable-indexing \ + --transform-for-static-hosting \ + --hosting-base-path ReliaBLE \ + --output-path ./user-docs \ + --warnings-as-errors +``` + +Demo build is not in CI — verify via XcodeBuildMCP per `Demo/AGENTS.md`. + +### DocC migration checklist + +- `Documentation.md` — Peripherals topic lists `Peripheral`, `DiscoveredPeripheral`, `PeripheralDiscoveryEvent`. +- `GettingStarted.md` — remove the snapshot narrative, `Peripheral(id:)`, and manager connect; teach `manager.peripheral(id:)`, scan → `discovered.peripheral`, handle connect, and Option A metadata. **Must state that handle metadata carries no change notification** (D2): apps re-read it inside the `discoveredPeripherals` loop, which doubles as the "something changed" tick. Without this, the my-devices example teaches a list that renders once and goes stale. +- `Topics/Background.md` — restored devices surface on `discoveredPeripherals` as `DiscoveredPeripheral`; connect intent still persists and re-arms. +- `Topics/Concurrency.md` — value types are `DiscoveredPeripheral`, `AdvertisementData`, and the events; the **handle is a `Sendable` class whose mutable metadata lives in a `Mutex`**, with the D1 contract spelled out (no CB on the handle, writes only from actor-ordered paths, lock never held across a suspension). +- `Topics/Multi-Manager.md` — per-manager registry; the same physical device yields two distinct handles. + +## Risks + +| Risk | Mitigation | +|------|------------| +| Retain cycle manager ↔ handle | Handle holds `weak` manager (inside its `Mutex`); registry holds handles strong; manager holds registry strong | +| Raising the macOS floor breaks macOS consumers | Pre-release library, breaking changes expected (AGENTS.md); call it out in the PR body. The declared 10.15 floor was already inconsistent with the iOS 18 floor | +| `Mutex` misuse (holding across suspension) | Structurally prevented — `withLock` is non-async; reviewers check no CB calls and no `Task { }` inside the closure | +| Manager leaks via a bridge that captures it strongly | Registry conforms to the bridge protocol itself and holds the manager weakly; `handleOrphansWhenManagerDeallocates` is the regression test | +| Registry grows unbounded during long background scans | Cleared on `shutdown()` (D14); weak-value eviction deferred with a documented trigger | +| Extracting `resolveAndUpsertDiscovered` silently regresses restore | Explicit nil-means-keep merge rule (D9) plus a restore-preserves-metadata assertion in test 8 | +| Contributors on macOS 14 can no longer build the library | Consequence of D0; call it out in the PR body | +| DocC gate fails the PR | DocC updates land in the same PR as the API cut (work items 1+2 together) | +| Commit 1 is unavoidably large | Accepted; the alternative is throwaway shim APIs. Commits 2–4 stay small and reviewable | +| Bridge accidentally invoked off-actor | Bridge is internal, called only from actor-isolated methods, and documented as such on the protocol | +| Identity still name-derived | Unchanged from today; FR-8.5 will revisit interning and matching | +| Apps carrying the old snapshot mental model | README and GettingStarted lead with handles | + +**Rollback:** revert the PR. No persistence schema change — restore intent still keys on String ids. + +## Decisions (resolved at mid-flow check-in, 2026-07-31) + +| Question | Decision | +|----------|----------| +| One PR vs. four | **One PR** closing #50, four sequential green commits | +| `Package.swift` macOS floor | **Leftover** — raise to `.macOS(.v15)`, use `Synchronization.Mutex`, checked `Sendable` throughout | +| Manager `connect(to:)` / `disconnect(from:)` | **Removed outright** — no deprecation shim, not retained internally | +| Option A metadata access | **Sync cached properties** on the handle | + +Decisions carried from `docs/designs/discovered-peripheral-vs-peripheral-2026-07-15.md` and resolved in the design section above: `Peripheral` is a `final class` (D1); `discoveredPeripherals` vends `[DiscoveredPeripheral]`, not handles (D4); both discovery feeds survive (D5); `peripheral(id:)` is sync/nonisolated over a manager-owned registry (D6); discovery and restore share one id-resolution helper (D9); public `Peripheral(id:)` is removed (D11); a tracked-peripherals feed stays deferred (D4). + +### Resolved by the design critique + +`docs/reviews/peripheral-handle-type-model-plan-critique-2026-07-31.md`. Findings applied: + +| Question | Decision | +|----------|----------| +| Registry ↔ manager wiring | **Two-phase `attach(manager:)`** — the naive `PeripheralHandleRegistry(manager: self)` does not compile (D6) | +| `ManagerStamp` vs. registry reference in the snapshot | **Registry reference** — deletes a type, removes an injection dependency, and keeps interning valid after manager death (D7) | +| Bridge shape | **One `applyDiscovery` method** (create-or-update) plus `applyConnectionState`; registry conforms to the protocol directly, no adapter type (D6) | +| `peripheral.connectionState` now or later | **Now** — #56 leaves the choice to planning, and it is one extra method on a bridge already being built (D10) | +| SwiftUI change signal for handle metadata | **Documentation-only** — `discoveredPeripherals` is the "something changed" tick; no new API (D2) | +| Registry growth / eviction | **Strong values, cleared on `shutdown()`**; weak-value eviction deferred (D14) | +| Rename actor's `discoveredPeripherals` | **No** — element type changes, property name stays (D8) | +| PoweredOn gating in `handle.connect()` | **No** — preserves today's ensure-then-throw; FR-1.4/FR-8.6 arrive with #57 (Out of scope) | +| `lastSeen` semantics on restore | **Keep `now`**, documented as "last bound or seen" (D9) | + +### Resolved after the critique + +| Question | Decision | +|----------|----------| +| Platform support | **All five Apple platforms declared**, each pinned to its `Synchronization.Mutex` floor — iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2. Compile-verified per platform; CI gains a compile-only matrix leg (D0) | +| `PeripheralDiscoveryEvent.peripheralId` correlation field | **Deferred to FR-8.5**, which redefines advertisement→id identity; adding it now would bake in today's name-derived resolution and change again (D5). Recorded in the PRD as FR-8.5.4 | + +## Open Questions + + +*(None blocking. Both questions carried out of the design critique are now resolved — see the decisions table above.)* + +## Definition of done + +- [ ] Public API matches the signatures above; no manager connect/disconnect; no public `Peripheral(id:)`. +- [ ] One handle instance per id per manager; the `.peripheral` sugar returns that instance, including after the manager is deallocated. +- [ ] Discovery, restore, and `refreshPeripherals` all accounted for; one id-resolution helper; restore preserves prior RSSI/advertisement; no synthetic offline discoveries. +- [ ] Connect/disconnect on the handle; lifecycle and reconnect tests green; cached `connectionState` tracks the stream. +- [ ] Registry cleared on `shutdown()`; `handleOrphansWhenManagerDeallocates` passes — nothing reachable from the actor holds the manager strongly. +- [ ] `swift build`, `swift test`, and the DocC `--warnings-as-errors` gate all green. +- [ ] Demo builds and uses handle-centric connect. +- [ ] `Package.swift` declares all five Apple platforms at their `Synchronization` floors, and CI compiles each one. +- [ ] PR closes #50, references #53–#56 as delivered checkpoints, and calls out both breaking changes: the type split and the platform-floor bump. + +## References + +- Design: `docs/designs/discovered-peripheral-vs-peripheral-2026-07-15.md` +- Design: `docs/designs/bluetoothactor-instance-isolation-2026-07-19.md` +- Plan: `docs/plans/background-scanning-state-restoration-2026-07-13.md` +- `PRD.md` — Architecture "Public types", FR-2.1, FR-2.4, FR-2.5, FR-8.2.1, NFR-1.2, NFR-1.3; FR-1.4/FR-8.6 and FR-10 for downstream Phase 2/3 needs +- `AGENTS.md` — three-target SPM mocking trick, `CBCentralManagerFactory` constraint, `BluetoothActor` isolation rules diff --git a/docs/reviews/peripheral-handle-type-model-plan-critique-2026-07-31.md b/docs/reviews/peripheral-handle-type-model-plan-critique-2026-07-31.md new file mode 100644 index 0000000..798b03c --- /dev/null +++ b/docs/reviews/peripheral-handle-type-model-plan-critique-2026-07-31.md @@ -0,0 +1,325 @@ +# Critique — Phase 1 Public Type Model Plan (`Peripheral` handle + `DiscoveredPeripheral`) + +**Subject:** `docs/plans/peripheral-handle-type-model-2026-07-31.md` +**Baseline:** `prompt-exports/oracle-plan-2026-07-31-121912-peripheral-handle-ty-892a.md`, "Generated Plan / Response" section only (from ~line 216). +**Date:** 2026-07-31 + +## Scope + +Focused critique only. Covered: (A) implementation-bearing export content dropped or weakened in the plan; (B) under-specified seams, unresolved decisions, contradictions, bad references, missing dependencies; (C) claims the code disproves, work the task does not require, and places where a named simpler design fully replaces the proposal; (D) requirements/edge cases/architecture absent from **both** documents; (E) questions whose answers change the design or the implementation order. + +**Not re-litigated (owner-decided):** one PR closing #50; `.macOS(.v15)` + `Synchronization.Mutex` + checked `Sendable`; `ReliaBLEManager.connect(to:)`/`disconnect(from:)` removed outright; Option A metadata as synchronous cached properties on the handle. Every finding below is compatible with those four decisions. + +### Verification performed + +- Read `BluetoothActor.swift` discovery (`handlePeripheralDiscovered`, :876–:954), restore (`handleWillRestoreState`, :709–:807), `invalidatePeripherals` (:956–:968), `refreshPeripherals` (:998–:1016), storage declarations (:180–:201). +- Read `ReliaBLEManager.swift` in full, `Models/Peripheral.swift` (:55–:116), and the test harness (`ReliaBLEManagerTests.swift` :55–:129, :320–:349, :432–:466, :1960–:2079, :2126–:2240) plus call-site greps. +- Compile-verified the `Mutex` + `weak var` + checked-`Sendable` question against Swift 6 language mode with `-strict-concurrency=complete` at `-target arm64-apple-macos15.0` (results in D9). +- Compile-verified the `self`-in-`init` ordering question with `-emit-sil` (definite-initialization diagnostics do not run under `-typecheck`) (result in D2). + +--- + +## A. Export content missing, weakened, or over-generalized in the plan + +### A1 — The export's explicit out-of-scope list was dropped · Medium + +Export (Generated Plan, header): *"Out of scope: work-driven connect/idle teardown (#51), FR-10 GATT (#52), PoweredOn gating (#57), commands FR-4/5."* + +The plan has no Out of Scope section. Two of those matter to this diff, not just to the roadmap: + +- **#57 PoweredOn gating.** `Peripheral.connect()` is a *new public entry point*. Today `ReliaBLEManager.connect(to:)` calls `await bluetooth.ensureCentralManager()` and then throws `.bluetoothUnavailable` if the central is missing or `.notFound` if no live ref exists (`BluetoothActor.swift:1031-1050`). A reviewer of the new handle API will reasonably ask "does `connect()` await PoweredOn?" — the plan must say "no, unchanged from today, FR-1.4/FR-8.6/#57." +- **FR-10 attachment (D13)** is stated, but without the out-of-scope frame, D13's "reserve a home" reads as an invitation to add storage now. + +**Correction:** restore the out-of-scope list verbatim into the plan, immediately after the Goal. + +### A2 — The registry's manager reference was silently changed from per-call to stored-weak · High (this is what creates D2) + +Export §4: `final class PeripheralHandleRegistry { func peripheral(id: String, manager: ReliaBLEManager) -> Peripheral }` — the manager is **passed at call time**, by `ReliaBLEManager.peripheral(id:)` which has `self` in hand. + +Plan D6/D8/§Proposed signatures: `init(manager: ReliaBLEManager)` — *"holds weak manager inside `Mutex`"*, and `Storage` *"carries `[String: Peripheral]` plus a `weak var manager`."* + +The plan changed a load-bearing detail without noting it, and the changed form does not compile as written (see **D2**: `PeripheralHandleRegistry(manager: self)` cannot appear in `ReliaBLEManager.init` before `bluetooth` is initialized). The export's per-call form sidesteps the problem entirely and removes one of the three weak references in the design. + +**Correction:** either adopt the export's per-call `manager:` parameter, or specify the two-phase attach in D2 below. Do not leave the plan's current wording. + +### A3 — "Init is internal, callable only from the registry" is unenforceable · Low + +Export D11 hedged: *"`Peripheral` init is `fileprivate`/`package` from registry only."* Plan D11 flattens this to "internal and is callable only from the registry," which `internal` does not enforce — anything in the module can construct a handle, including the actor, which is exactly the divergence hazard the registry exists to prevent. + +**Correction:** either co-locate `Peripheral` and `PeripheralHandleRegistry` in one file and mark the init `fileprivate`, or keep `internal` and state plainly that the single-construction-site rule is a convention enforced by review, not by the compiler. + +*(Content the plan legitimately hardened, listed so it is not "fixed" back: `@unchecked Sendable` → checked `Sendable` via `Mutex`; the export's `ManagerStamp` with a bare `weak var` → `Mutex`-boxed; the export's dithering about `preconditionFailure`/`fatalError` in `.peripheral` → "return an orphan handle"; the export's "DocC maybe incremental" → items 1+2 land together. All four are improvements.)* + +--- + +## B. Under-specified seams, unresolved decisions, bad references, missing dependencies + +### B1 — `resolveAndUpsertDiscovered` merge semantics: the two call sites are **not** identical, and the plan's signature loses the difference · High + +Plan D9 claims *"Logic is identical to today"* and prescribes `(cbPeripheral:name:rssi:lastSeen:advertisement:emitDiscoveryEvent:)` with `rssi: Int?` and non-optional `advertisement`, restore passing *"an empty advertisement and nil rssi."* + +The code disagrees. On the update branches, restore **preserves prior values**: + +- `BluetoothActor.swift:742` — `rssi: discoveredPeripherals[idx].rssi` (keeps prior RSSI) +- `BluetoothActor.swift:744` / `:754` — `advertisement: discoveredPeripherals[idx].advertisement ?? emptyAdvertisement` (keeps prior advertisement) +- `BluetoothActor.swift:751` — `name: name ?? discoveredPeripherals[idx].name` (coalesces name; discovery at `:930` does **not**) + +Implementing the plan's literal signature — restore passes `nil` rssi and a fresh empty `AdvertisementData` — **regresses restore**: a device discovered pre-termination and then restored would have its RSSI and last advertisement wiped from the snapshot list *and*, now, from the handle's Option A metadata. That is a user-visible behavior change smuggled in as a refactor, and it would not be caught by any existing test. + +**Correction:** the helper needs explicit merge semantics, not a flag. Give it `rssi: Int?`, `advertisement: AdvertisementData?`, `name: String?` with a documented rule — *nil means "keep the existing value; fall back to empty/nil when there is none"* — and note that discovery always passes non-nil so the rule is a no-op there. Add a test asserting a restored, previously-discovered peripheral keeps its RSSI/advertisement. + +### B2 — Who broadcasts the snapshot list is unspecified · Medium + +Discovery broadcasts once per advertisement at the end of the handler (`:953`). Restore broadcasts **once after the loop**, guarded by `didMutatePeripherals` (`:804-806`), precisely to avoid N broadcasts for N restored peripherals. If the extracted helper broadcasts, restore regresses to one broadcast per restored peripheral (and each carries a partially-updated list). + +**Correction:** state in D9 that the helper mutates and returns the resolved id only — it neither broadcasts nor emits; both callers keep their existing broadcast placement. + +### B3 — `emitDiscoveryEvent` is a dead parameter · Medium + +`PeripheralDiscoveryEvent` is broadcast at `BluetoothActor.swift:886-890`, **before** id resolution begins, from `cbPeripheral` + `advertisement` + `rssi` — data the helper does not need and a step restore never performs. A helper whose stated job is *"resolves the app-facing id, upserts the discovered snapshot list, and returns the resolved id"* has nothing to gate on this flag. + +**Correction:** drop `emitDiscoveryEvent` from the signature. The "restored peripherals never hit the ad feed" invariant is preserved for free by leaving the broadcast at the discovery call site. + +### B4 — A third live-reference binding site is unaccounted for · Medium + +`refreshPeripherals()` (`BluetoothActor.swift:998-1016`) re-binds `cbPeripherals[p.id]` from `centralManager.retrievePeripherals(withIdentifiers:)` after a power cycle and broadcasts the list. Neither document mentions it. It does not resolve new ids or change metadata, so it likely needs **no** bridge call — but the plan's claim of a *"single id-resolution site"* (D8 diagram) is false until this is addressed, and an implementer scanning for `discoveredPeripherals` mutation sites will hit it and have to guess. + +**Correction:** name it in D9 with an explicit "no bridge call needed — no metadata change, no new id" note. + +### B5 — Nothing says how `BluetoothActor` obtains the `ManagerStamp` · High (missing dependency) + +D7 puts `let stamp: ManagerStamp` inside `DiscoveredPeripheral`, and D8/D9 make the **actor** the site that constructs `DiscoveredPeripheral` values. But D6 adds only `registry:` to `BluetoothActor.init(log:reconnectPolicy:restoreIdentifier:)`. The actor therefore cannot construct a stamped snapshot. The plan is not implementable as written. + +**Correction:** either inject the stamp alongside the bridge, or adopt **C4** (drop `ManagerStamp`; have `DiscoveredPeripheral` hold the registry reference), which removes the dependency instead of adding a fourth init parameter. + +### B6 — The apply-before-broadcast ordering invariant is implied but never stated · Medium + +The whole point of pushing metadata into handles from the actor is that a consumer who receives snapshot *N* never observes handle metadata **older** than *N*. That holds only if `registry.applyDiscovery(...)` runs before `broadcast(list, to: peripheralsContinuations)`. The plan's data-flow section happens to show that order; nothing states it as a requirement, so a later reordering (e.g. "broadcast early to cut latency") would silently break it. + +**Correction:** state it as an invariant in the Concurrency section and cover it with a test (see D8c). + +### B7 — Two locks now exist and no lock order is specified · Medium + +After this change the library holds a registry `Mutex` and a per-handle `Mutex`. The natural implementation of `applyDiscovery` takes the registry lock, finds the handle, then takes the handle lock — nested. Nothing in the plan forbids the reverse order, and a future `handle.connect()` that consults the registry would create one. + +**Correction:** state the rule — *registry lock → handle lock, never the reverse; no user code and no CoreBluetooth call runs under either lock* — or, better, have `applyDiscovery` copy the handle reference out under the registry lock, **release**, then call `handle.applyMetadata(...)`, so the locks never nest at all. + +### B8 — Citation drift · Low (individually), Medium (in aggregate — the file is the implementer's map) + +| Plan says | Actual | +|---|---| +| `discoveredPeripherals: [Peripheral]` (~:212) | `BluetoothActor.swift:180` | +| `cbPeripherals` (~:220) | `BluetoothActor.swift:186` | +| `Peripheral.swift:109-115` (plan line 13) **and** `Peripheral.swift:107-113` (D1) — contradictory | `hash(into:)` :107-109, `==` :111-115 | +| connect sites ":577, :606, :644, :686, :730, :792, :870, :923, :980, :1032, :1096" | 585, 614, 661, 694, 738, 804, 878, 935, 990, 1043, 1104 — every entry 8–10 lines low | +| "~50 direct `Peripheral` references plus ~30 discovery/connect/connection-state sites" | `connect(to:)`/`disconnect(from:)` alone occupy **36** lines, and the plan's list omits `:1455, :1550, :1596, :1655, :1808` (restoration + multi-manager tests) entirely | +| `handlePeripheralDiscovered` (:876), `id(for:)` (~:1099), delegate shims (:1486, :1540), `waitForPeripheral` (:2170), stale-connect (:450-458), Demo `:55/:68/:282/:286` | all correct | + +**Correction:** re-derive the test line numbers (or drop them in favour of the symbol names, which do not rot) and fix the two actor storage refs and the self-contradictory `Peripheral.swift` span. + +--- + +## C. Corrections — disproved by the code, not required, or replaced by a simpler design + +### C1 — `ensureCentralManagerReady()` on the manager is unnecessary, and its stated rationale is wrong · Medium + +D3: *"The handle lives in the same module, so `bluetooth` stays internal — expose a small internal helper on the manager rather than widening access."* + +`bluetooth` is **already** `internal let` (`ReliaBLEManager.swift:42`), and `ensureCentralManager()` is already reachable as `await manager.bluetooth.ensureCentralManager()` — the exact call `connect(to:)` makes today (`ReliaBLEManager.swift:189`). Nothing needs widening, so the helper prevents nothing. + +**Correction:** delete the "widening access" justification. Keep the helper only if you want a single named seam for handles to call (a legitimate but different reason), and say so; otherwise have the handle call `manager.bluetooth` directly and drop `ensureCentralManagerReady()` from the API table. + +### C2 — Renaming the actor's `discoveredPeripherals` to `discoveredSnapshots` is unrequested churn · Low + +The element type must change; the property name need not. The rename breaks two `@testable` accesses (`ReliaBLEManagerTests.swift:1500`, `:1783`) and four internal DocC links (`BluetoothActor.swift`: `discoveredPeripheralsStream()`, `invalidatePeripherals()`, `cbPeripherals` comment neighbourhood), for zero functional gain, inside an already-large commit whose review budget is the scarce resource. + +**Correction:** keep the property name `discoveredPeripherals`; change only its element type. If the rename is wanted for clarity, make it a separate trailing commit. + +### C3 — Collapse `ensureHandle(id:)` + `applyDiscovery(...)` into one call · Medium + +`PeripheralRegistryBridge` has two methods that are always invoked back-to-back (plan D6, State-and-data-flow). `applyDiscovery` must intern anyway (the handle may not exist), so `ensureHandle` is redundant — it doubles lock acquisitions on the hottest path in the library (one advertisement per device per interval; the mock alone advertises at 50 ms across two peripherals, `ReliaBLEManagerTests.swift:2138`, `:2156`). + +**Correction:** one protocol method, `applyDiscovery(id:cbIdentifier:name:rssi:lastSeen:advertisement:)`, documented as create-or-update. This also removes the only reason the bridge protocol has two ordering-sensitive members. + +### C4 — `ManagerStamp` is fully replaced by holding the registry in `DiscoveredPeripheral` · Medium–High + +D7 introduces `ManagerStamp` (a `Mutex`-boxed weak manager) purely so `.peripheral` can find the interning registry. But the registry *is* the thing being looked up, it is already per-manager, and it is already `Sendable`: + +```swift +public struct DiscoveredPeripheral: Sendable, Identifiable, Hashable { + // …public lets… + let registry: PeripheralHandleRegistry // internal, excluded from ==/hash + public var peripheral: Peripheral { registry.peripheral(id: id) } +} +``` + +This is strictly better on four counts: + +1. **Deletes a type** and the third weak reference in the design. +2. **Removes B5** — the actor already receives the registry/bridge; nothing extra to inject. +3. **Removes the "manager freed → detached orphan" special case.** With a stamp, `.peripheral` after manager death returns a *fresh* orphan each call, so `snap.peripheral === snap.peripheral` is false — quietly violating the plan's headline interning invariant in exactly the state it is hardest to debug. With the registry held, interning survives manager death; the handle's own weak manager is nil, so `connect()` still throws `.bluetoothUnavailable` — same documented behavior, no new object identity rule. +4. **Retain graph stays acyclic:** manager → registry (strong), registry → handles (strong), handle → manager (weak), snapshot → registry (strong). The only consequence is that a retained snapshot keeps the registry alive after the manager dies, which is precisely what makes point 3 work. + +**Correction:** replace D7's `ManagerStamp` with the registry reference, or explicitly justify keeping the stamp and then answer point 3 (define `.peripheral`'s identity contract after manager deallocation). + +### C5 — The rewritten equality test proves nothing · Medium + +Verification table: *"Equality/hash tests (:110-126) — Two `manager.peripheral(id:)` for the same id → `===` **and** `==`."* + +Under interning those two calls return the **same object**, so `==` is satisfied by any implementation, including a default identity-based one. The id-only equality contract (D1: *"two handles from different managers with the same id compare `==` but are different objects"*) can only be exercised with **two distinct objects sharing an id**, which by construction requires two managers. + +**Correction:** keep the same-manager test as an interning assertion (`===`), and move the real `==` assertion into `twoManagersIndependentHandleRegistries`: `a.peripheral(id: "x") !== b.peripheral(id: "x")` **and** `a.peripheral(id: "x") == b.peripheral(id: "x")`, plus the `Set` count check there. + +### C6 — Tightening the unknown-peripheral connect test to `.notFound` will flake · Medium + +The plan's rewrite row and new test #4 both demand `.notFound`. The existing test (`ReliaBLEManagerTests.swift:448-462`) deliberately accepts `.notFound || .bluetoothUnavailable`, with a comment explaining why: `makeManager()` does **not** bring the central online (authorization is pinned `.notDetermined`, `SimulationConfig`, `:2049`), and `ReliaBLEManager.init`'s fire-and-forget `Task { await bluetooth.ensureCentralManager() }` (`ReliaBLEManager.swift:70-72`) may or may not have produced a central by the time the assertion runs. + +**Correction:** if the plan wants a deterministic `.notFound`, the test must call `await Mock.ensureReady(manager)` first (which the current test does not). State that in the plan; otherwise keep the either-or assertion. + +--- + +## D. Absent from both the export and the plan + +### D1 — Option A metadata has no change notification, so the "my devices" UI it exists for cannot update · High (architectural) + +`Peripheral` is a plain `Sendable` class. It is not `@Observable`, publishes no `objectWillChange`, and the plan defers both a metadata-change `AsyncStream` and design Option B (D2, D4). SwiftUI therefore has **no signal** that `rssi`/`lastSeen`/`advertisement` changed: a `ForEach` over tracked handles renders once and then shows stale values until some unrelated state change forces a re-render. + +This is not the sync-vs-async question (owner-decided; sync is right and is a precondition for any fix). It is the missing half: the "Nearby" screen re-renders because `discoveredPeripherals` vends fresh **value** snapshots, whereas the "my devices" screen — the sole justification for Option A, and FR-2.4.5 — is handed a mutable reference with no invalidation channel. Shipping DocC that teaches "bind your my-devices list to handle metadata" (plan, DocC checklist for `GettingStarted.md`) teaches a pattern that visibly does not update. + +**Correction, zero API cost:** document that `discoveredPeripherals` doubles as the "something changed" tick — it fires on every advertisement, and an app can re-read handle metadata inside that loop. That is honest, requires no new API, and keeps Option B deferred. Add it to D2 and to the `GettingStarted.md` checklist item. If that is judged insufficient, the alternative is scoping a minimal notification now (see **E1**) — but the plan must not stay silent. + +### D2 — `PeripheralHandleRegistry(manager: self)` in `ReliaBLEManager.init` does not compile · High (blocks implementation) + +Verified with `swiftc -swift-version 6 -emit-sil` (this diagnostic is a SIL pass — `-typecheck` alone reports nothing): + +``` +error: 'self' used before all stored properties are initialized +note: 'self.registry' not initialized +``` + +`self` is unusable in a class initializer until **every** stored property is assigned. The plan requires `registry` (needing `self`) to exist *before* `bluetooth` (needing `registry`) — an unsatisfiable order. + +**Correction (two-phase attach), the minimal fix:** + +```swift +public init(config: ReliaBLEConfig = ReliaBLEConfig()) { + loggingService = LoggingService(...) + handleRegistry = PeripheralHandleRegistry() // no manager yet + bluetooth = BluetoothActor(log:..., reconnectPolicy:..., restoreIdentifier:..., + registry: handleRegistry) + handleRegistry.attach(manager: self) // legal: all stored props initialized + Task { await bluetooth.ensureCentralManager() } +} +``` + +Then state the consequence the plan must not leave implicit: handles capture their `weak manager` **at creation from the registry's stored reference**, so nothing may call `peripheral(id:)` before `attach` (nothing does — but say so). The alternative is A2's per-call `manager:` parameter, which needs no attach step at all. + +### D3 — The retain-graph rule omits the actor → bridge edge, where the only real leak lives · High + +Plan Concurrency: *"manager → registry (strong) → handle (strong) → manager (weak)."* Missing: **manager → actor → bridge → ?**. The actor stores `let registry: PeripheralRegistryBridge` (D6), so if the bridge captures the manager **strongly**, the cycle is manager → actor → bridge → manager and the manager never deallocates. Worse, the plan itself notes that *live stream subscribers retain the actor* (`ReliaBLEManager.swift:40-45`), so a single un-terminated `for await` would pin the manager, its central, and every handle for the process lifetime — and the entire "handles orphan when the manager dies, `connect()` throws `.bluetoothUnavailable`" contract (D1, D7, edge-case table) would never be reachable, in production or in tests. + +**Correction:** extend the rule to actor → bridge → **registry (strong)** → manager (**weak**), and state explicitly that **no** object reachable from the actor may hold the manager strongly. If the registry conforms to `PeripheralRegistryBridge` directly (see below) this is automatic — a further argument for dropping the separate adapter object. Add a teardown test: create a manager in a scope, take a handle, drop the manager, assert `handle.connect()` throws `.bluetoothUnavailable`. That test is the leak detector. + +*(Related simplification: the plan lists both `PeripheralHandleRegistry` and "an adapter conforming to `PeripheralRegistryBridge`". The registry can conform to the protocol itself — keep the protocol for the actor's dependency inversion and testability, drop the separate adapter type.)* + +### D4 — Nothing ever evicts handles; the registry grows without bound and survives `shutdown()` · High + +`[String: Peripheral]` with strong values, keyed by the resolved id, populated on **every** discovery, with no removal path anywhere in the plan. Each handle retains a full `AdvertisementData` — `manufacturerData: Data`, `serviceData: [CBUUID: Data]`, three UUID arrays (`AdvertisementData.swift`). + +The relevant comparison is not "today has no registry" but **what today already clears**: + +- `shutdown()` clears both the snapshot list and the live map (`BluetoothActor.swift:257-259`). +- `invalidatePeripherals()` clears `cbPeripherals` and connection state (`:956-968`). + +The plan's edge-case table says `invalidatePeripherals` *"keeps handles and their last-known metadata"* — correct and intentional (the handle must survive a radio reset). But it never says what `shutdown()` does to the registry, and by omission the answer is "nothing." So a long-running background scanner in a crowded environment (each distinct advertised name, and each nameless device's UUID string, mints a permanent handle) accumulates handles + advertisement payloads for the process lifetime, and tearing the stack down does not reclaim them. For a library whose headline feature is continuous background scanning, this is a genuine leak class, not a theoretical one. + +**Correction — pick one and write it down:** + +1. **Clear the registry in `shutdown()`.** Cheap, matches the actor's existing behavior, and harms nothing: handles the app still holds keep working (orphaned, throwing) and the stack is dead anyway. This should be the default even if you also do (2) or (3). +2. **Weak-value storage** (`[String: WeakBox]` with prune-on-insert). Interning identity then holds exactly as long as the app holds a reference — which is the only window in which `===` is observable. Cost: a handle re-created after eviction starts with empty metadata unless you keep a small id→metadata side cache; and `discoveredPeripheralSugarReturnsInternedHandle` must hold its references across the assertion (it does). +3. **Accept and document** the growth explicitly, with a note that FR-8.5/#51 revisit it. + +Whatever is chosen, add a line to the edge-case table for `shutdown()` — it currently only covers `invalidatePeripherals`. + +### D5 — The re-entrancy contract points at the wrong hazard · Medium + +Plan D6/Concurrency: *"Implementations may take a lock and mutate handles, but must not call back into the actor."* Because the protocol methods are **synchronous and non-throwing** and are called from actor-isolated code, calling back into the actor is *structurally impossible* — it would require `await`, which cannot appear in a sync function. The stated rule is therefore already enforced by the compiler, while the hazards that are actually reachable go unmentioned: + +1. **`Task { await actor… }` inside a bridge call** — permitted, and it reorders arbitrarily relative to the discovery stream. This is the real "must not." +2. **Blocking under the lock** — the actor's executor thread stalls behind whoever holds the registry lock. Fine for dictionary ops; not fine if a future notification hook invokes app code under the lock (the natural place someone will add D1's observability). +3. **Nested lock order** (B7). + +Similarly, *"`Mutex.withLock` is non-async and will not compile with an `await` inside, which enforces this structurally"* is true for `await` but not for `Task { }`, which compiles fine inside the closure. + +**Correction:** restate the contract as: bridge implementations must be non-blocking and allocation-light; must not spawn tasks that touch the actor; must not invoke app-supplied callbacks; must observe registry-before-handle lock order. Note that direct actor re-entry is already impossible by signature. + +### D6 — Multi-property reads are not atomic · Low–Medium + +Five getters, five independent lock acquisitions. A row that reads `name`, then `rssi`, then `lastSeen` can straddle two discovery updates and render a mix. Benign for UI, but it is a public API contract that should be stated — and it is cheap to avoid. + +**Correction:** store one immutable metadata struct inside the `Mutex` and expose the five properties as computed reads over a single `withLock` snapshot, or document "each property is individually consistent; reads of different properties are not a single atomic snapshot." + +### D7 — Restore stamps `lastSeen = now` for a device that was not seen · Low–Medium + +`BluetoothActor.swift:744-766` sets `lastSeen: now` for every restored peripheral. Harmless today (a snapshot-list field), but Option A promotes `lastSeen` to a public "my devices" field where it will be rendered as "Last seen: just now" for a device that has not advertised since before the app was killed. + +**Correction:** decide explicitly in D9 — either preserve the prior `lastSeen` on restore (nil for never-discovered), or keep `now` and document `lastSeen` as "last bound or seen." Either way it belongs in the plan, because the split is what makes it visible. + +### D8 — Testability gaps under the CoreBluetoothMock harness + +The harness supports what the plan needs — ids are deterministic (`Mock.testPeripheralID = "ReliaBLE-Test-Peripheral"`, `:1964`; `connectionTestPeripheralID`, `:1969`), two simultaneous managers are supported (`makeManager(tearDownPrevious: false)`, `:2072`+ and the existing multi-manager test at `:1808`), and the restore path is directly drivable (`bluetooth.testHandleWillRestoreState`, used at `:1628, :1679, :1704, :1745`). Three gaps remain: + +**(a) No test for restore-path interning**, despite it being a stated invariant (plan Background: *"restoration must intern the same handles"*; D9). The `testHandleWillRestoreState` hook makes this cheap: pre-create `manager.peripheral(id:)`, drive restore, assert the same instance received `cbIdentifier`, and assert `testContainsCBPeripheral`. Given B1, also assert prior RSSI/advertisement survived. **Add it to the new-test list.** + +**(b) No test for the apply-before-broadcast ordering of B6.** Feasible: subscribe to `discoveredPeripherals`, and on the first element containing the test id, immediately assert `manager.peripheral(id: testId).rssi != nil` — no polling, which is what makes it a real ordering assertion. + +**(c) `waitForPeripheral` returning a handle silently repoints existing assertions.** Today it returns the snapshot the stream emitted, and callers assert on it: `#expect(peripheral?.advertisement?.localName == …)`, `#expect(peripheral?.cbIdentifier != nil)` (`:336-338`). After the change those read **handle** metadata, i.e. whatever the latest discovery wrote — the assertions still pass, but they no longer test the snapshot that was actually emitted, which is what "the discovery pipeline populates the list" tests are for. + +**Correction:** keep a `waitForDiscovered(id:) -> DiscoveredPeripheral?` and derive the handle at the connect call sites (`try await discovered.peripheral.connect()`), or add the handle-returning variant alongside rather than replacing. Preserve at least one assertion against snapshot fields. + +### D9 — `Mutex` + `weak var` + checked `Sendable`: verified, with three caveats worth writing down · Informational (claim confirmed) + +Compiled successfully under `-swift-version 6 -strict-concurrency=complete -target arm64-apple-macos15.0` (Swift 6.3 locally; CI is Xcode 16.4 / Swift 6.1 on `macos-15`, `ci.yml:19,44`): a `final class … : Sendable` holding `private let state: Mutex` where `State` contains `weak var manager: ReliaBLEManager?` plus the five metadata fields; property getters via `withLock`; a registry whose `withLock` closure creates, inserts, and **returns** a handle; and `Task.detached` capture of both. No errors, no `@unchecked`. The plan's D0/D1 claim holds. + +Caveats the plan should absorb: + +1. **The premise is stronger than stated.** `Mutex` is *unconditionally* `Sendable` — SE-0433 achieves safety through `sending` on `init` and `withLock` rather than by requiring `Value: Sendable`. Verified: a `Mutex` over a struct containing a non-`Sendable` class compiles inside a checked-`Sendable` class. The practical constraint is on the **result**: `withLock` returns `sending Result`, so only `Sendable` (or provably-isolated) values can escape the closure. Every Option A field is `Sendable` (`String`, `Int`, `Date`, `UUID`, `AdvertisementData`), and `ReliaBLEManager` is `Sendable`, so all planned reads are legal. Say this, because it is the rule that governs any future field added to `State`. +2. **`weak` is sound here for a specific reason** worth recording: all reads and writes of the weak reference happen under the mutex, and the Swift runtime's weak load/zeroing is itself atomic with respect to deallocation. A `weak var` in a `Mutex`-guarded struct is safe; a `weak var` as a bare stored property of an `@unchecked Sendable` class (the export's `ManagerStamp`) is not. The plan already made this upgrade — keep the reasoning in the doc. +3. **Platform floor mechanics.** `Synchronization` requires macOS 15 / iOS 18 at runtime; CI's `macos-15` runner satisfies it, and D0 removes the need for `@available`. Two consequences the plan should mention: contributors on macOS 14 can no longer build or run the library's tests locally, and `Package.swift` declares only `.iOS`/`.macOS` — any consumer building for tvOS/watchOS/visionOS gets the SPM default floor and will fail on `import Synchronization`. Add the platforms or state iOS/macOS-only support. + +### D10 — Connection state is the one thing a "handle-centric" API still cannot answer synchronously · Medium + +D10 keeps `ConnectionStateChange.peripheralId: String` and defers per-handle streams — reasonable for a *stream*. But the result is that the my-devices row this whole phase is built for can read `rssi`/`lastSeen`/`advertisement` off the handle and must then join a String-keyed stream (or `await manager.currentConnectionStates`) to answer "is it connected?" — the single most important field on that row. Note also that the plan cites FR-2.1/2.4/2.5/8.2.1/NFR-1.2/1.3 but never **FR-2.3.1**, which says connection-state consumption should *"migrate to `Peripheral` as the handle model lands."* + +The bridge, the lock, and the actor-ordered write path all already exist in this design; mirroring `connectionStates[id]` into a cached `peripheral.connectionState` is the same mechanism applied to a second field, and doing it now avoids reopening the identical seam in a later PR. + +**Correction:** make this an explicit decision in D10 — either in scope (one extra bridge method, one cached property, mirroring the existing `connectionStates` writes) or explicitly deferred **with FR-2.3.1 cited**. Silence is the wrong answer either way. + +--- + +## E. Questions that would materially change the design or implementation order + +1. **Does Phase 1 owe SwiftUI a change signal for handle metadata (D1)?** If the documentation-only answer ("re-read inside the `discoveredPeripherals` loop") is acceptable, the plan is unchanged and only DocC grows. If not, a notification mechanism enters scope and should be designed together with D10's connection state — which changes work item 1 and the bridge protocol. +2. **Strong or evicting registry (D4)?** Strong + clear-on-`shutdown()` is a two-line addition. Weak values change `PeripheralHandleRegistry`'s type, add a metadata side-cache question, and add a test. Decide before writing the registry, not after. +3. **Does `peripheral.connectionState` ship now (D10)?** Answering "yes" means one bridge pass instead of two, and it changes the new-test list. +4. **`ManagerStamp` or registry reference in `DiscoveredPeripheral` (C4)?** Determines whether `BluetoothActor.init` gains one parameter or two (B5), and defines `.peripheral`'s identity contract after manager deallocation. +5. **Which registry/manager wiring (A2 + D2)** — per-call `manager:` parameter, or stored weak manager with a two-phase `attach`? This is the first line of code in work item 1. +6. **Does `handle.connect()` gate on PoweredOn, or preserve today's ensure-then-throw (A1/#57)?** Affects the error contract in the edge-case table and the strictness of new test #4. +7. **Keep or rename the actor's `discoveredPeripherals` (C2)?** Determines whether two `@testable` accessors and four DocC links move inside the already-large commit. + +--- + +## Suggested minimal edits to the plan + +Ordered by cost of getting it wrong, not by size: + +1. **D6/§Proposed signatures** — fix the registry construction seam (A2/D2): per-call `manager:` **or** two-phase `attach(manager:)`, spelled out in code. +2. **Concurrency section** — complete the retain graph with actor → bridge → registry → weak manager, and add the "nothing reachable from the actor holds the manager strongly" rule plus the orphan-handle teardown test (D3). +3. **D9** — replace "logic is identical to today" with explicit merge semantics (B1), drop `emitDiscoveryEvent` (B3), state that the helper does not broadcast (B2), and mention `refreshPeripherals` (B4). +4. **D4/edge-case table** — decide and record the registry's growth/eviction policy and `shutdown()` behavior (D4). +5. **D2** — add the observability paragraph (D1) and, if kept, the non-atomic-multi-read note (D6). +6. **D7** — drop `ManagerStamp` for the registry reference, or answer the post-deallocation identity contract (C4/B5). +7. **D10** — decide connection-state placement, citing FR-2.3.1 (D10). +8. **D1/D3** — delete the "widening access" rationale (C1); add the `Mutex` `sending`-result rule and the platform-floor consequences (D9). +9. **Verification section** — fix the equality test (C5), add `ensureReady` to the `.notFound` test (C6), add restore-interning and ordering tests (D8a/b), keep a snapshot-returning wait helper (D8c). +10. **Background/blast radius** — correct the line citations and the call-site count (B8); restore the out-of-scope list (A1).