From 6ed0407af51350cac495a48c5d1f02b2d08bd3f8 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sat, 18 Jul 2026 12:47:38 -0600 Subject: [PATCH] Lock PRD architecture: work-driven core, Peripheral split, FR-10 sequencing Document the settled v1 product shape from the ObjC Core comparison: Peripheral/DiscoveredPeripheral public types, work-driven connection with Approach B reconnect, FR-10 before commands, and supporting design notes. --- PRD.md | 185 +++++-- ...red-peripheral-vs-peripheral-2026-07-15.md | 251 +++++++++ ...ble-vs-reliable-architecture-2026-07-15.md | 479 ++++++++++++++++++ 3 files changed, 866 insertions(+), 49 deletions(-) create mode 100644 docs/designs/discovered-peripheral-vs-peripheral-2026-07-15.md create mode 100644 docs/investigations/objc-ble-vs-reliable-architecture-2026-07-15.md diff --git a/PRD.md b/PRD.md index 416a5ed..d226db7 100644 --- a/PRD.md +++ b/PRD.md @@ -4,19 +4,64 @@ ReliaBLE is a Swift package that provides a reliable, modern, yet easy to use interface for developers building apps that interact with peripheral devices over Bluetooth Low Energy (BLE). +It is a **device-protocol-agnostic BLE Central core**: the library owns link lifecycle, GATT discovery/readiness, and command execution machinery; the integrating app owns domain packet content (framing, CRC, application semantics). Primary targets are **wearables, IoT sensors, and smart-home** accessories—apps that think in **“my devices”** first. Generic scanner UIs are supported but secondary. + +The library is **unshipped** and under active development. This PRD is the **v1 target**. The package is not intended for production use until the requirements herein are implemented. Checkmarks (✅) mark areas that already match the target in current code; unmarked items remain open. + ## High-level Requirements - Open-source package designed to be integrated into many iOS applications. -- Primary focus on reliability of communication. -- Exposes a public interface that is easy for other developers to integrate into a wide variety of iOS apps. -- No UI -- Acts as a BLE Central using CoreBluetooth. -- Supports background scanning. -- Provides the ability to interact with peripherals via a "command" style protocol that allows for dependencies. -- Supports simultaneous connection to multiple devices. -- Supports options to be used non-secure, with BLE security features, or application layer security provided by the application it is integrated into. -- Modern Swift 6 architecture and implementation. -- Includes support for high test coverage. +- Primary focus on reliability of communication (connection **and** discovery-ready **and** command completion). +- Exposes a public interface that is easy for developers who may have little CoreBluetooth experience: **device-centric** (`Peripheral`) rather than central-manager-shaped for day-to-day work. +- No UI. +- Acts as a BLE Central using CoreBluetooth; never vends live CoreBluetooth objects to the app. +- Supports background scanning and state restoration. +- Provides a **command**-style protocol for peripheral I/O, with dependencies (after GATT readiness exists). +- Supports simultaneous use of multiple peripherals. +- Supports non-secure use, BLE security features, and hooks for application-layer security. +- Modern Swift 6 architecture (strict concurrency); single internal isolation domain for CoreBluetooth. +- High test coverage (including CoreBluetooth mocking). +- Leaves the type name **`Device`** free for integrating apps (e.g. multi-transport BLE + Matter + Wi‑Fi models). + + +## Architecture (normative product shape) + +### Public types + +| 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. | +| **`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. +- **Known id:** `manager.peripheral(id:)` creates or returns a handle before any scan; a later matching discovery binds the live radio to that same handle. +- **“My devices” UI:** tracked `Peripheral` handles enriched with last discovery metadata (option A). **Do not** vend synthetic/fake `DiscoveredPeripheral` rows for offline bound devices. +- **“Nearby” UI:** real `DiscoveredPeripheral` stream only (scanner / provisioning). +- Optional later: thin `PeripheralUpdate` stream events (option B)—not a third control type. + +Detail and rationale: `docs/designs/discovered-peripheral-vs-peripheral-2026-07-15.md`. + +### 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. +- **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. +- **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`**. + +### Implementation sequencing (v1) + +1. Public type split + registry (`Peripheral` / `DiscoveredPeripheral`) and demotion of manager-only connect. +2. Work-driven link rules + idle teardown + Approach B reconnect + PoweredOn await. +3. **FR-10** GATT discovery/readiness/subscriptions on `Peripheral`. +4. **FR-4 / FR-5** commands (serial per-peripheral queue first; dependencies/prioritization after). +5. FR-6, FR-7, FR-8.5, and remaining polish as needed. + +**Commands (FR-4/FR-5) must not ship before FR-10.** A re-established link is not sufficient for I/O until discovery-*ready* (FR-1.2, FR-10). ## Detailed Requirements @@ -25,50 +70,69 @@ ReliaBLE is a Swift package that provides a reliable, modern, yet easy to use in 1. Reliability of Communication: -- FR-1.1: Implement error detection and correction mechanisms for each BLE transaction. -- FR-1.2: Ensure automatic reconnection attempts with exponential backoff for failed connections. 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. Any future command-layer reconnect-and-rerun (FR-4/FR-5) depends on this ready transition rather than treating "connected again" as enough. (Reconnection with exponential backoff is implemented; the discovery re-run tie-in remains open pending FR-10.) +- 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.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). - - FR-1.3.2: Provide status updates on 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. 2. Public Interface for Easy Integration: -- FR-2.1: Design a clear, documented API for developers to interact with BLE functionality without UI components. -- FR-2.2: Include example usage in documentation showing how to connect, send commands, and receive responses. -- FR-2.3: Provide delegate methods or callbacks for asynchronous operations like connection changes or data received. - - ✅ FR-2.3.1: Provide callbacks/streams for connection changes (connection, disconnection, connection failure). - - FR-2.3.2: Provide callbacks/streams for data received from peripherals. +- 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.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.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. 3. BLE Central Using CoreBluetooth: - ✅ FR-3.1: Use CBCentralManager to manage BLE central activities. - ✅ FR-3.2: Implement scanning for peripherals with customizable scan options (e.g., services, UUIDs). +- FR-3.3: Isolate all CoreBluetooth usage in a single internal concurrency domain (e.g. `@BluetoothActor`); public façades remain callable without forcing `@MainActor`. +- FR-3.4: Lazy central setup under app-controlled authorization, except where state restoration requires eager setup when a restore identifier is configured and authorization is already granted. 4. Command Style Protocol for Interacting with Peripherals: -- FR-4.1: Define a flexible command protocol where the content and functionality of commands are provided by the integrating app. Commands target services and characteristics by UUID (CBUUID), and may only execute against a peripheral that is discovery-*ready* for the targeted UUIDs (FR-10.3): targeting an undiscovered or not-ready characteristic must await readiness (bounded by a timeout) while discovery is in progress, or fail fast when discovery has already failed. This gate is inherited from FR-10 so the command queue does not re-litigate it. +**Depends on FR-10.** Do not implement command execution against characteristics before discovery readiness exists. +- FR-4.1: Define a flexible command protocol where the content and functionality of commands are provided by the integrating app. Commands are submitted on a **`Peripheral`** (e.g. `run`). Commands target services and characteristics by UUID (`CBUUID`), and may only execute when that peripheral is discovery-*ready* for the targeted UUIDs (FR-10.3): targeting an undiscovered or not-ready characteristic must await readiness (bounded by a timeout) while discovery is in progress, or fail fast when discovery has already failed. This gate is inherited from FR-10 so the command queue does not re-litigate discovery. - FR-4.2: Support various command types: - FR-4.2.1: Notify-only (peripherals notify with updates). - FR-4.2.2: Read-only (retrieve data from peripherals). - 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. +- 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.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. 5. Simultaneous Connection to Multiple Devices: -- ✅ FR-5.1: Ensure the system can maintain connections to multiple peripherals concurrently. +- ✅ FR-5.1: Ensure the system can maintain connections to multiple peripherals concurrently (per-id connection state substrate exists; align with `Peripheral` handles). -- FR-5.2: Implement a queue system for command execution with the following capabilities: - - FR-5.2.1: Support for scheduling commands in a queue, where commands are executed in FIFO order unless dependencies dictate otherwise. - - FR-5.2.2: Allow commands to declare dependencies, ensuring that dependent commands are only executed once their prerequisites have completed successfully. - - FR-5.2.3: Manage conflicts and ensure that commands affecting the same peripheral or characteristic are executed in the correct sequence based on their dependencies. - - FR-5.2.4: Provide mechanisms to handle command failures within the queue, such as skipping or retrying dependent commands, or marking the entire dependency chain as failed. - - FR-5.2.5: Support prioritization within the queue to allow for urgent commands to bypass others where necessary without breaking dependency chains. +- FR-5.2: Implement a **per-`Peripheral`** command execution queue: + - FR-5.2.1: Schedule commands FIFO on that peripheral unless dependencies dictate otherwise. **Minimum viable queue is serial (one-wide) per peripheral**—ship that before prioritization. + - FR-5.2.2: Allow commands to declare dependencies, ensuring that dependent commands run only after prerequisites complete successfully. + - FR-5.2.3: Manage conflicts so commands affecting the same peripheral or characteristic run in a correct order based on dependencies / serial rules. + - FR-5.2.4: Handle command failures in the queue (skip, retry, or fail dependency chains) with documented policy. + - FR-5.2.5: Support prioritization so urgent commands may bypass others without breaking dependency chains (**after** serial queue + ready gate + reconnect-and-rerun). 6. Security Options: @@ -92,10 +156,10 @@ ReliaBLE is a Swift package that provides a reliable, modern, yet easy to use in - 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.2: Support continuous scanning: - - FR-8.2.1: Allow the library to scan continuously for BLE peripherals, providing real-time updates to the integrating app about nearby and connectable devices. + - 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. - FR-8.2.2: Provide an interface for the app to start, stop, and check the status of the continuous scanning process. -- FR-8.3: Background Scanning: +- FR-8.3: Background Scanning: - FR-8.3.1: Implement background scanning capabilities, ensuring compliance with iOS background execution rules. - FR-8.3.2: Notify the integrating app when new devices come into range even when the app is not in the foreground, using appropriate iOS background modes like bluetooth-central. @@ -103,10 +167,12 @@ ReliaBLE is a Swift package that provides a reliable, modern, yet easy to use in - ✅ FR-8.4.1: Extract and make available manufacturing data from advertisement packets to the integrating app. - ✅ FR-8.4.2: Allow the integrating app to parse this data, potentially providing callbacks or data structures for easy access to specific fields like manufacturer-specific data. -- FR-8.5: Unique Identifier from Manufacturing Data: +- FR-8.5: Unique Identifier from Manufacturing Data: - 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. + - 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.6: Scanning respects FR-1.4 (await PoweredOn / fail terminal states)—no silent no-op when the radio is not ready. 9. Logging Support: @@ -123,13 +189,14 @@ ReliaBLE is a Swift package that provides a reliable, modern, yet easy to use in - 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 - Security events (e.g., encryption initiation or failure) - Data chunking operations 10. Service and Characteristic Discovery and Management: -The layer between establishing a connection (FR-3, FR-5) and reading or writing data +The layer between establishing a connection and reading or writing data (FR-4, FR-7). After a peripheral connects, its GATT services and characteristics must be discovered before any interaction, and that discovered view must be kept correct across firmware changes and reconnections. This section covers discovery and characteristic setup @@ -144,10 +211,12 @@ foundation that the command queue (FR-4, FR-5) and reconnect-and-rerun behavior to build on; those command-layer mechanics are out of scope for this section and referenced only where the gate must be honored. +**API surface:** Discovery filter, readiness observation, catalogs, and subscriptions are exposed on **`Peripheral`** (FR-2.4, FR-2.5), not as a global manager API keyed only by bare id strings (ids remain the internal interning key). + - FR-10.1: Service Discovery: - FR-10.1.1: Provide an API to discover services on a connected peripheral, requiring the integrating app to declare the specific service UUIDs of interest. Do not expose unfiltered "discover all services" as the default path — Apple explicitly discourages it because enumerating an entire remote GATT table negatively affects battery life and time-to-ready. - - FR-10.1.2: Support both automatic discovery on connection and explicit, on-demand discovery. The integrating app must declare the desired service/characteristic UUID set that discovery targets; this declaration is supplied at or around connection (e.g., via `connect` options and/or a sticky per-peripheral registration on the manager) and/or as configuration defaults. The exact API surface is finalized in implementation, but the declaration is mandatory: if automatic discovery is requested with no declared UUID set, that is a hard error (fail-closed), not a silent leave-not-ready — consistent with the "no unfiltered default" rule (FR-10.1.1). - - FR-10.1.3: Expose discovered services to the integrating app as Sendable value snapshots keyed by peripheral identifier (per the identity model in FR-10.6.2) and service UUID (CBUUID), mirroring the existing peripheral-snapshot pattern. Never surface live CoreBluetooth objects. + - FR-10.1.2: Support both automatic discovery on connection and explicit, on-demand discovery. The integrating app must declare the desired service/characteristic UUID set as a **sticky filter on `Peripheral`** (e.g. `discoveryFilter`), settable when obtaining the handle and/or before first work. The declaration is mandatory for automatic discovery: if automatic discovery is required with no declared UUID set, that is a hard error (fail-closed), not a silent leave-not-ready — consistent with FR-10.1.1. + - FR-10.1.3: Expose discovered services to the integrating app as Sendable value snapshots keyed by peripheral identity (FR-10.6.2) and service UUID (CBUUID). Never surface live CoreBluetooth objects. - FR-10.2: Characteristic and Descriptor Discovery: - FR-10.2.1: Provide an API to discover characteristics for a discovered service, allowing the integrating app to declare the characteristic UUIDs of interest. Enforce CoreBluetooth's ordering constraint: services are discovered before characteristics, and characteristics before descriptors. @@ -157,18 +226,19 @@ only where the gate must be honored. - FR-10.3: Discovery Lifecycle and Readiness: - FR-10.3.1: Model discovery as an explicit, per-peripheral state machine driven entirely by delegate callbacks. Never assume synchronous availability of services or characteristics after initiating discovery. - - FR-10.3.2: Expose discovery/readiness state through a feed distinct from the connection-state stream (FR-1.3.1 / `ConnectionState`), so that connection-recovery observability (a library strength) and GATT readiness are not conflated. Readiness must be observable and awaitable (e.g., a discovery/readiness state stream and/or an awaitable ready signal), reporting progress, completion, and errors. `connected` must never be presented to the app as `ready`. + - FR-10.3.2: Expose discovery/readiness state on **`Peripheral`** through a feed distinct from the connection-state stream (FR-1.3.1), so that connection-recovery observability and GATT readiness are not conflated. Readiness must be observable and awaitable, reporting progress, completion, and errors. `connected` must never be presented to the app as `ready`. - FR-10.3.3: Enforce a configurable discovery timeout so that a stalled or interrupted discovery surfaces as an error rather than an indefinite wait. - - FR-10.3.4: Handle interrupted or partial discovery: if a peripheral disconnects mid-discovery, treat it as a hard reset of the discovery state — discard partial results and any pending discovery bookkeeping — and re-run discovery on the next connection (see FR-10.6.1). On unexpected disconnect, the app-visible discovery snapshot must be invalidated (cleared, or marked stale until the next ready) with explicit, documented timing, matching the existing peripheral-snapshot refresh philosophy. + - FR-10.3.4: Handle interrupted or partial discovery: if a peripheral disconnects mid-discovery, treat it as a hard reset of the discovery state — discard partial results and any pending discovery bookkeeping — and re-run discovery on the next connection (see FR-10.6.1). On unexpected disconnect, the app-visible discovery catalog must be invalidated (cleared, or marked stale until the next ready) with explicit, documented timing. - FR-10.3.5: Partial-discovery policy is fail-closed. If a declared (required) service or characteristic UUID is absent — narrow filter, wrong firmware, or a stale cache — discovery is a failure: the peripheral does not become ready and the missing UUID(s) are reported. Map non-nil errors from each discovery callback into the library's error type, distinguishing common causes where feasible (e.g., missing-UUID versus disconnection during discovery). - - FR-10.3.6: Readiness is an enforced gate, not merely an observable signal. Characteristic I/O and command execution (FR-4, and any future read/write/notify API) against a peripheral must wait until it is ready for the targeted UUIDs: await readiness (bounded by the FR-10.3.3 timeout) while discovery is pending or in progress, and fail fast if discovery has already failed. The full command-queue behavior that consumes this gate is specified in FR-4/FR-5 (out of scope here); this requirement only fixes the gate contract those layers must honor. + - FR-10.3.6: Readiness is an enforced gate, not merely an observable signal. Characteristic I/O and command execution (FR-4) against a peripheral must wait until it is ready for the targeted UUIDs: await readiness (bounded by the FR-10.3.3 timeout) while discovery is pending or in progress, and fail fast if discovery has already failed. + - FR-10.3.7: **Default ready = catalog-ready:** declared services/characteristics have been successfully discovered. Ready does **not** mean every notify-capable characteristic is subscribed. Subscriptions are separate (FR-10.4). - FR-10.4: Characteristic Subscription Management (Notify/Indicate): - - FR-10.4.1: Provide an API to enable and disable notifications or indications on a characteristic using the platform's subscribe mechanism. Never write the Client Characteristic Configuration Descriptor (CCCD, 0x2902) directly — CoreBluetooth manages the CCCD write internally and forbids writing it directly. - - FR-10.4.2: Confirm the subscription state-change callback before treating a subscription as active, and sequence subscription-enable before issuing any command that causes the peripheral to emit data, so that initial notifications are not lost. Where a peripheral's readiness depends on active subscriptions, re-subscribe (FR-10.4.5) must complete before the readiness gate (FR-10.3.6) is released for notification-dependent work. + - FR-10.4.1: Provide an API on **`Peripheral`** to enable and disable notifications or indications using the platform's subscribe mechanism. Never write the Client Characteristic Configuration Descriptor (CCCD, 0x2902) directly — CoreBluetooth manages the CCCD write internally and forbids writing it directly. + - FR-10.4.2: Confirm the subscription state-change callback before treating a subscription as active, and sequence subscription-enable before issuing any command that causes the peripheral to emit data, so that initial notifications are not lost. Commands that require notify await confirm for **those** characteristic UUIDs; do not block all I/O on unrelated subscriptions. Optional later convenience: auto-subscribe a declared UUID set on the handle, still tracked as intent (FR-10.4.5). - FR-10.4.3: Expose subscription state and its changes to the integrating app. Do not rely solely on the platform's `isNotifying` flag, which can lag the actual state. - FR-10.4.4: Notifications are unacknowledged at the GATT layer and may be lost or reordered above the link layer; application-layer framing/sequencing of multi-packet notification streams is out of scope for this section and handled per FR-7 (chunking and reassembly) and the command protocol (FR-4). - - FR-10.4.5: The library tracks requested subscription intent per characteristic. After reconnection, state restoration (FR-10.6.3), or `didModifyServices` re-discovery (FR-10.5.1), previously requested subscriptions must be re-armed — or explicitly reported as inactive for the app to re-request. Re-discovery alone does not restore notify/indicate intent, and notify-only integrations (FR-4.2.1) break without this. + - FR-10.4.5: The library tracks requested **subscription intent** per characteristic. After reconnection, state restoration (FR-10.6.3), or `didModifyServices` re-discovery (FR-10.5.1), previously requested subscriptions must be re-armed — or explicitly reported as inactive for the app to re-request. Re-discovery alone does not restore notify/indicate intent, and notify-only integrations (FR-4.2.1) break without this. - FR-10.5: GATT Cache Invalidation and Re-discovery: - FR-10.5.1: On a peripheral GATT-table change (the `didModifyServices` callback), detection alone is insufficient. The library must immediately mark the peripheral *not ready* (re-gating work per FR-10.3.6), park or fail any in-flight discovery-dependent I/O, re-run discovery for the affected (or all declared) services, return to *ready* only after that completes, re-arm subscription intent (FR-10.4.5), and surface the event on the discovery/readiness stream. @@ -177,8 +247,17 @@ only where the gate must be honored. - FR-10.6: Reconnection, State Restoration, and Stale-Reference Handling: - FR-10.6.1: Re-discover services and characteristics on every new connection. Never reuse a service or characteristic reference obtained from a prior connection. Re-discovery is part of returning to the *ready* state (FR-10.3.6): a re-established link is not sufficient to resume characteristic I/O until discovery completes (see FR-1.2). - - FR-10.6.2: Internally discard all live CoreBluetooth service/characteristic references on disconnect and reset the discovery state. The app-facing catalog must be keyed by the library's peripheral identity model (`Peripheral.id`, which resolves name → localName → `cbIdentifier`), not by held CoreBluetooth objects and not by `cbIdentifier` alone — so discovery keys stay consistent with the rest of the library. (Stable identity from manufacturer data, FR-8.5, is still open; this keying must adopt whatever FR-8.5 settles on.) This is consistent with the library's existing invalidation/refresh behavior on Bluetooth power cycling. - - FR-10.6.3: On CoreBluetooth central state restoration (`willRestoreState`), re-associate the restored peripherals, discard any prior discovery catalog, re-run discovery for peripherals the app still intends to use, and re-apply subscription intent (FR-10.4.5). A restored link is not ready: the same readiness gate (FR-10.3.6) as a fresh connection applies. + - FR-10.6.2: Internally discard all live CoreBluetooth service/characteristic references on disconnect and reset the discovery state. The app-facing catalog and handle registry are keyed by the library's peripheral identity model (`Peripheral.id`), not by held CoreBluetooth objects and not by `cbIdentifier` alone. (Stable identity from manufacturer data, FR-8.5, is still open; keying must adopt whatever FR-8.5 settles on.) Consistent with invalidation/refresh on Bluetooth power cycling. + - FR-10.6.3: On CoreBluetooth central state restoration (`willRestoreState`), re-associate restored peripherals to interned **`Peripheral`** handles, discard any prior discovery catalog, re-run discovery for peripherals the app still intends to use, and re-apply subscription intent (FR-10.4.5). A restored link is not ready: the same readiness gate (FR-10.3.6) as a fresh connection applies. + + +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.5: Connection-state observation remains available (FR-1.3.1) and must distinguish intentional disconnect, unexpected drop, and reconnecting where applicable. ### Non-Functional Requirements @@ -186,7 +265,8 @@ only where the gate must be honored. 1. Modern Swift 6 Architecture: - ✅ NFR-1.1: Use Swift 6 language features like concurrency with async/await for handling BLE operations. -- ✅ NFR-1.2: Adhere to Swift best practices like protocol-oriented programming and value types where feasible. +- ✅ NFR-1.2: Adhere to Swift best practices like protocol-oriented programming and value types where feasible (`DiscoveredPeripheral` and catalogs as values; `Peripheral` may be a class or id-façade—implementation choice—as long as Sendable/isolation contracts hold). +- NFR-1.3: Keep all CoreBluetooth objects inside a single internal isolation domain; public handles forward by id. Do not use per-peripheral actors that own `CBPeripheral`. 2. High Test Coverage: @@ -198,24 +278,26 @@ only where the gate must be honored. 3. Performance: - NFR-3.1: Ensure low latency in command execution and response handling to meet real-time application needs. -- NFR-3.2: Optimize for battery life on iOS devices by minimizing unnecessary BLE activity. +- NFR-3.2: Optimize for battery life on iOS devices by minimizing unnecessary BLE activity (including idle disconnect and filtered discovery). 4. Scalability and Maintainability: - NFR-4.1: Design with future extensibility in mind, allowing easy addition of new command types or security protocols. -- NFR-4.2: Ensure clear separation of concerns by modularizing code into components for BLE management and security. +- NFR-4.2: Ensure clear separation of concerns: library = link + discovery readiness + command machinery; app = domain protocol/framing. 5. Documentation: -- NFR-5.1: Provide comprehensive documentation for all public APIs, including usage examples, parameters, return values, error handling, and specifics on command types and data chunking. +- 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.3: Document OS GATT cache / Service Changed limitations (FR-10.5.3) for integrating apps and firmware partners. 6. Compatibility: - NFR-6.1: Ensure compatibility with the latest iOS versions and CoreBluetooth API changes. -- NFR-6.2: Consider backward compatibility where it does not compromise security or performance. +- NFR-6.2: Consider backward compatibility where it does not compromise security or performance. Pre-1.0 API may break as the `Peripheral` / `DiscoveredPeripheral` refactor lands. 7. Energy Efficiency in Scanning: @@ -231,7 +313,7 @@ only where the gate must be honored. 9. Performance with Logging: -- NFR-9.1: Ensure that logging, when enabled, does not significantly impact the performance or real-time capabilities of BLE operations. +- NFR-9.1: Ensure that logging, when enabled, does not significantly impact the performance or real-time capabilities of BLE operations. - NFR-9.2: Optimize logging for minimal overhead, possibly through techniques like lazy logging where logs are only formatted if logging is enabled. @@ -253,3 +335,8 @@ only where the gate must be honored. 11. Continuous Integration + +## References (design / investigation) + +- `docs/designs/discovered-peripheral-vs-peripheral-2026-07-15.md` — public type split +- `docs/investigations/objc-ble-vs-reliable-architecture-2026-07-15.md` — ObjC Core parity, connection model, FR-10 sequencing diff --git a/docs/designs/discovered-peripheral-vs-peripheral-2026-07-15.md b/docs/designs/discovered-peripheral-vs-peripheral-2026-07-15.md new file mode 100644 index 0000000..08d1fcb --- /dev/null +++ b/docs/designs/discovered-peripheral-vs-peripheral-2026-07-15.md @@ -0,0 +1,251 @@ +# Design note: `DiscoveredPeripheral` vs `Peripheral` + +**Date:** 2026-07-15 +**Status:** Proposed product direction (not yet implemented) +**Audience:** iOS / BLE engineers reviewing ReliaBLE’s public API shape +**Repo:** [ReliaBLE](https://github.com/Five3Apps/ReliaBLE) (public; unshipped, PRD-driven) + +Related: `docs/investigations/objc-ble-vs-reliable-architecture-2026-07-15.md`, `PRD.md` (incl. FR-10 service/characteristic discovery), `Sources/ReliaBLE/Models/Peripheral.swift` (current snapshot type). + +--- + +## Why this note exists + +We need a second opinion on how ReliaBLE should expose **“a device I talk to”** vs **“something I just saw advertising”** under **Swift 6 strict concurrency**, without teaching every integrating developer CoreBluetooth. + +This document summarizes constraints, options we considered, and where we landed. Feedback welcome—especially from people who have shipped multi-device BLE apps or multi-transport IoT stacks (BLE + Matter + Wi‑Fi, etc.). + +--- + +## Context: what ReliaBLE is + +**ReliaBLE** is an open-source Swift package that aims to be a **reliable BLE Central** layer for iOS (and related Apple platforms): scan, connection lifecycle, (planned) GATT discovery/readiness, and a command-style API for peripheral I/O. It is intentionally **device-protocol-agnostic**—command *content* (framing, CRC, domain packets) stays in the integrating app (`PRD` FR-4.1). Think “modern CoreBluetooth Core,” not a band-specific SDK. + +**Status:** In active development; **not intended for production use until the PRD is implemented**. Today’s shipped surface is roughly: authorize, scan streams, connect/disconnect, connection-state streams, reconnect policy, logging, background restore hooks. **GATT discovery and commands are not implemented yet** (FR-10 and FR-4/5). + +**Primary product targets:** wearables, IoT sensors, smart-home accessories—apps that think in **“my devices”** first. Generic “BLE scanner” UIs are supported but secondary. + +**Historical inspiration:** An older in-house ObjC stack used a device-centric Core (`CCBTPeripheral` + `runCommand:`) with demand-driven connect and a discovery gate before work. ReliaBLE wants that ergonomics in Swift 6, not a central-manager-shaped API that forces every app to re-learn CB. + +--- + +## The problem + +### What we have today + +`Peripheral` is an **immutable, `Sendable` value snapshot**: id, optional `cbIdentifier`, name, rssi, lastSeen, advertisement. Live `CBPeripheral` never leaves an internal actor; `ReliaBLEManager.connect(to:)` takes a snapshot and looks up the live object by id. + +That was a good milestone for **scan lists + Swift 6 safety**. It is an awkward long-term home for: + +- sticky GATT discovery filters (FR-10), +- readiness / subscription intent, +- a per-device command queue, +- work-driven auto-connect and idle teardown, +- Advanced explicit `connect`/`disconnect` (“app hold”). + +### Two jobs that fight each other + +| Job | Needs | +|---|---| +| **Nearby / scan UI** | Cheap copies, `Sendable` across actors, high churn (rssi/ads every advertisement) | +| **Device control** | Stable identity across ads and sessions, long-lived config (UUID filter, hold), streams, `run(command)` | + +ObjC solved this with a **reference-type peripheral wrapper** on a serial queue. Swift 6 + non-`Sendable` CoreBluetooth types push us to split **public values** from **internal CB ownership**, and to be deliberate about what the app holds. + +### Constraints (non-negotiable) + +1. **`CBCentralManager` / `CBPeripheral` / GATT objects are not `Sendable`.** They must stay inside one isolation domain (ReliaBLE uses an internal `@BluetoothActor` + delegate shim). Public API must not vending live CB objects. +2. **Complete concurrency checking** (Swift 6) — public models crossing `@MainActor` UI and library code must be safe. +3. **Ergonomics for developers who may never have used CoreBluetooth** — “my device does work,” not “central connects to peripheral identifier.” +4. **Don’t block multi-transport apps** — many IoT products wrap BLE + Matter + Wi‑Fi. The library should not steal the name **`Device`** if apps need it for that umbrella type. +5. **Known-id devices before first scan** — account-bound locks/bands must be representable offline, then matched when advertising appears (and eventually FR-8.5 manufacturer-data identity). + +--- + +## Options we considered + +### Option 1 — One type: keep `Peripheral` as snapshot; all control on `ReliaBLEManager` + +```text +manager.connect(to: peripheral) +manager.run(command, on: peripheral.id) // hypothetical +``` + +| Pros | Cons | +|---|---| +| One public model type | Teaches central-manager shape; poor match to work-driven “device does work” | +| Snapshot stays pure | Sticky filter / queue / ready have no natural home | +| | Easy to pass stale snapshots; id-keyed manager APIs everywhere | + +**Rejected** as the primary long-term API (may remain as internal/advanced plumbing). + +### Option 2 — One type: make `Peripheral` both snapshot and controller + +Grow methods on the current struct/class and use it in lists and for `run`. + +| Pros | Cons | +|---|---| +| Single noun | Either loses pure value semantics (harder lists/SwiftUI) or pretends a snapshot can own queues/filters | +| | High churn ads vs stable control state on the same type gets messy | + +**Rejected** without a split: the two jobs above don’t share a single good representation under Sendable + CB isolation. + +### Option 3 — Two types: `Peripheral` (snapshot) + `Device` / `PeripheralHandle` (control) + +| Pros | Cons | +|---|---| +| Clear separation of jobs | “Device” is vague and often wanted by multi-transport app layers | +| | Primary noun for control isn’t Apple’s BLE term | +| | Risk apps say `Device` for BLE-only and paint into a corner later | + +**Rejected naming** (`Device` as library type). Split of jobs is right; names were not. + +### Option 4 — Two types: `DiscoveredPeripheral` (snapshot) + `Peripheral` (control handle) ✅ + +| Pros | Cons | +|---|---| +| Control object uses Apple-aligned **Peripheral** | Rename vs today’s snapshot-named `Peripheral` (breaking for unshipped API — acceptable) | +| Snapshot name signals ephemeral “what we heard” | Two types to learn (mitigated by sugar and docs) | +| Leaves **`Device`** free for app multi-transport models | | +| Matches wearables/IoT: hold a `Peripheral`, optionally show discoveries | | + +**Chosen.** + +--- + +## Chosen design (summary) + +```text +ReliaBLEManager + ├── scan → AsyncStream // “nearby” + ├── peripheral(id:) → Peripheral // “my device” (known id, may be unseen) + └── tracked peripherals feed (optional) // handles + last-seen metadata + +DiscoveredPeripheral (Sendable value) + ├── advertisement, rssi, lastSeen, id, … + ├── stamped with vending manager + └── var peripheral: Peripheral // sugar → interned handle + +Peripheral (long-lived handle, interned by id) + ├── discoveryFilter (sticky; FR-10) + ├── run(command) / ready / connection streams + ├── Advanced connect(autoReconnect:) / disconnect() + └── lastSeen / rssi / lastAdvertisement? // option A metadata for “my devices” UI +``` + +### Interning + +All paths resolve to **one handle per id per manager**: + +- `manager.peripheral(id:)` creates or returns the interned handle. +- `discovered.peripheral` is **syntactic sugar** over the same registry (not a new instance per ad). +- Discovery matching binds the live `CBPeripheral` (actor-internal) onto that handle when ids align. + +### Sugar: `discovered.peripheral` + +Preferred over forcing `manager.peripheral(for: discovered)` at every call site. Implementation: manager-stamped discovery + registry lookup by id. Multi-manager safe because the stamp identifies the registry. + +### Known id without scan + +```swift +let lock = manager.peripheral(id: "user-bound-lock-id") +// configure filter, show in “My devices” UI even if offline +// later: advertising matches → same Peripheral gains live radio +try await lock.run(SyncCommand()) +``` + +Whether `run` on a never-seen handle **implicitly scans until match** vs **fails fast** is a separate product choice; the type model supports both. + +### UI: don’t fake discoveries + +| List | Source | +|---|---| +| **My devices** (primary for IoT) | Known/tracked **`Peripheral`** handles | +| **Nearby** (scanner / provisioning) | Real **`DiscoveredPeripheral`** stream only | + +**Do not** vend synthetic `DiscoveredPeripheral` rows for offline bound devices (lies about advertisements, pollutes “nearby”). Enrich handles when a real match arrives. + +### Option A now, option B later (tracked devices feed) + +**Option A (planned now):** put **last discovery metadata** on `Peripheral` (e.g. lastSeen, rssi, optional last advertisement snapshot) so “my devices” UI can bind a single object. + +**Option B (later, if needed):** a thin stream payload such as `PeripheralUpdate { peripheral, discovery: DiscoveredPeripheral? }` for fully immutable event logs—**not** a third control type. + +### What we are not doing + +- Vending `CBPeripheral` / GATT objects publicly. +- Making `Device` a ReliaBLE type (reserved for apps). +- Treating the raw discovery stream as the only device list. +- Placeholder/fake discoveries for known ids. +- Per-peripheral actors that own `CBPeripheral` (CB stays on the single Bluetooth actor; handles forward by id). + +--- + +## How this fits connection & discovery direction (brief) + +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**. +- **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. + +The type split is what makes that API teachable without CB. + +--- + +## Conceptual app usage + +```swift +// --- Wearables / IoT primary path --- +let band = manager.peripheral(id: account.deviceId) +band.discoveryFilter = .init(services: [...], characteristics: [...]) + +// “My devices” UI observes tracked peripherals / handle metadata (option A) +for await p in manager.trackedPeripherals { /* rssi, lastSeen, connection/ready */ } + +try await band.run(SyncDayCommand()) // connect + discover to ready + work (planned) + +// --- Scanner / add-device path --- +for await discovered in manager.peripheralDiscoveries { + let p = discovered.peripheral + // show ads; on user pick, keep `p` as the bound handle +} +``` + +--- + +## Questions for reviewers + +1. Does **`Peripheral` = handle** and **`DiscoveredPeripheral` = scan row** read cleanly if you’ve never used this library? +2. Is **`discovered.peripheral` sugar** worth it, or do you prefer an explicit `manager.peripheral(for:)` only? +3. For **known-id + never seen**, do you prefer **implicit scan-on-`run`** or **fail-fast** until the app has seen an advertisement? +4. Option A metadata **on the handle** vs pushing sooner to option B **update events**—any strong preference for SwiftUI list diffing? +5. Any naming collision or confusion with Apple’s `CBPeripheral` in docs/API (we never expose CB types, but the word overlaps)? +6. Multi-transport: does leaving **`Device`** free match how you’d structure an app above ReliaBLE? + +--- + +## References in-repo + +| Item | Path | +|---|---| +| Current snapshot type (to be split/renamed) | `Sources/ReliaBLE/Models/Peripheral.swift` | +| Manager façade | `Sources/ReliaBLE/ReliaBLEManager.swift` | +| Concurrency model | `Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md` | +| PRD (incl. FR-10 discovery gate) | `PRD.md` | +| Broader ObjC Core vs ReliaBLE investigation | `docs/investigations/objc-ble-vs-reliable-architecture-2026-07-15.md` | + +--- + +## Decision log (short) + +| Decision | Choice | +|---|---| +| Two public layers | Yes — required by scan-value vs control-handle jobs under Swift 6 | +| Control type name | **`Peripheral`** | +| Scan type name | **`DiscoveredPeripheral`** | +| Multi-transport name | Leave **`Device`** to apps | +| Cross-link sugar | **`discovered.peripheral`** via manager stamp + id registry | +| Known id | **`manager.peripheral(id:)`** first; discovery binds later | +| “My devices” UI | Tracked **handles** + option A last-discovery metadata; no fake discoveries | +| Future | Optional option B `PeripheralUpdate` stream; not a third control object | 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 new file mode 100644 index 0000000..628c7c7 --- /dev/null +++ b/docs/investigations/objc-ble-vs-reliable-architecture-2026-07-15.md @@ -0,0 +1,479 @@ +# Investigation: ObjC BLE Architecture vs ReliaBLE + +**Updated:** 2026-07-15 +**Includes:** PR #47 (FR-10), product feedback on layering / connections / Peripheral API / concurrency + +## Summary + +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`. + +**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). + +--- + +## Product decisions (from feedback) + +| 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`.** | +| **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. | +| **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). | +| **Commands** | Not before FR-10. | +| **Target devices** | Wearables, IoT sensors, smart-home (“my devices” first). Always-connected notify is **not** a primary use case. Scanner apps secondary. | +| **Maturity** | Unshipped; PRD = v1 target; no pre-PRD production use. | +| **Idle disconnect** | Global config (not per-peripheral); default **5s**. | +| **Ready vs subscriptions** | Default ready = **catalog-ready**. Subscriptions = explicit intent + re-arm (FR-10.4); not auto-enable-all-notify; commands needing notify await those UUIDs. | + +--- + +## Hypotheses + +1. ReliaBLE Core should match ObjC Core guarantees with Swift 6 mechanisms — **confirmed as intent**. +2. Current code implements connection/scan foundations; discovery (FR-10) and commands (FR-4/5) are greenfield — **confirmed**. +3. Work-driven connection was the original plan; manager-level `connect` was a placeholder detour — **confirmed by feedback**. +4. FR-10 is the required middle layer before commands — **confirmed (PR #47 + feedback §4)**. +5. Full PRD + product direction clears ObjC Core reliability bar — **yes, with gaps noted in §5**. + +--- + +## Background + +- 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 + +--- + +## 1. Layering + +### Intent (product) + +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) | +| *(scan row / ads)* | **`DiscoveredPeripheral`** value snapshot + `.peripheral` sugar | +| `CCBTCommand` | App-supplied command protocol executed by **`Peripheral`** (FR-4/5, after FR-10) | + +ObjC protocol facade (`CCLDBand*`, packets, CRC) and app UI are **consumers of Core** — useful for understanding ergonomics and end-to-end reliability, not library scope. Framing stays with the integrating app (FR-4.1). + +### Current code vs intent + +- Shipped: central/scan/connect-on-manager + connection streams. +- Missing Core pieces: `Peripheral` as control handle (today’s type is a snapshot), `DiscoveredPeripheral` rename/split, FR-10, command queue, work-driven default. +- Placeholder smell: `ReliaBLEManager.connect(to:)` as the only connection entry point; today’s `Peripheral` is the wrong shape for control methods. + +### Pros / cons of pure-Core scope + +| | Pros | Cons | +|---|---|---| +| Core-only (ReliaBLE intent) | Multi-device OSS; no vendor framing; clear package boundary | Apps supply protocol; library must still ship discovery + command *machinery* so reliability isn’t N× reinvented | +| Core + protocol (ObjC full stack) | One-liner domain API for one device family | Not a general package | + +**Verdict:** Scope is right. Success metric is **ObjC Core parity (or better)**, not device-specific band-protocol parity. + +--- + +## 2. Connections + +### 2.1 ObjC model (evidence) + +| Mechanism | Evidence | +|---|---| +| Demand-driven connect | Side effect of `runCommand:` when disconnected — `CCBTPeripheral` / architecture §2.2 | +| Idle disconnect | 5 s after queue empty while connected | +| Retry budget + 5 s connect timeout | `CCBTPeripheral` constants / timers | +| PoweredOn gate | Central `commandOperationQueue` created suspended (`CCBTCentralManager.m:45`); unsuspend only on PoweredOn (`m:214-218`); **scan and connect ops are enqueued on that queue** (`searchForPeripherals` `:155-156`, `connectToPeripheral` `:177-184`) | +| Explicit connect exists lower-level | `connectToPeripheral:options:` on central — used by peripheral’s auto-connect path, not the app’s primary API | + +### 2.2 ReliaBLE today + +| Mechanism | Status | +|---|---| +| Explicit `manager.connect` / `disconnect` | Done (placeholder path) | +| Two-tier reconnect + streams + restore | Done (ahead of ObjC) | +| PoweredOn: scan no-op; connect throws if no central | Different from park | +| Work-driven + idle teardown | Absent (original plan; product wants as default) | + +### 2.3 PoweredOn park-until-ready vs fail/no-op — deep dive + +#### What ObjC actually parks + +Not “all BLE forever,” but **central operations submitted to `commandOperationQueue`**: primarily **scan** and **connect/cancel-connect**. The queue starts suspended and only runs when `central.state == PoweredOn`. If state leaves PoweredOn, the queue re-suspends. Terminal bad states (unauthorized / powered off / unsupported, after Resetting) **fail outstanding search completions** rather than parking forever (`CCBTCentralManager.m:220-231`). + +So park is: *“hold radio work until the radio can do it; fail terminal unrecoverable states.”* + +#### Is it only useful at initial boot? + +**No.** Boot is the *most common* case, but not the only one. + +| Scenario | Park / await-ready | Fail / no-op | +|---|---|---| +| **Cold start** — app launches, CB still `.unknown`/`.resetting`, user (or code) starts scan/sync immediately | Work runs when PoweredOn; no app retry loop | App must observe `state` and re-issue scan/connect | +| **Authorize-then-work** — user grants permission, central becomes usable moments later | Natural fit with lazy central: first work waits for PoweredOn | Race: first call throws/no-ops if app doesn’t sequence carefully | +| **User toggles Bluetooth off then on mid-session** | ObjC re-suspends queue on non-PoweredOn; when On again, parked ops can proceed (if still queued). In-flight peripheral work has its own disconnect path | ReliaBLE today: scan no-ops; app must notice `state` and restart scan. Connect throws if central missing | +| **iOS radio reset / brief `.resetting`** | Park absorbs blip for newly submitted central ops | Flaky “why didn’t scan start?” unless app retries | +| **Background relaunch + restore** | Await PoweredOn before re-scan / re-connect intent is cleaner | Fail-fast forces restore path to carefully order setup | +| **User intentionally left BT off; app keeps submitting work** | Risk: silent queue of stale work (ObjC mitigates for search via fail on terminal states; still can surprise) | Better UX: immediate error → prompt “Turn on Bluetooth” | +| **UI that shows BT status and disables Sync** | Park less necessary; UI already gates | Fail-fast matches UI | +| **Work-driven Core (wearable sync)** — app only calls `run(command)` | **Park (or `await ready`) is essential** so Core can hide radio readiness | Forces every call site to handle “not ready” | + +#### ReliaBLE’s current fail/no-op specifically + +- **Scan** (`BluetoothActor.swift:503-506`): if not PoweredOn → log warn, **return** (silent no-op from app’s perspective aside from logs). +- **Connect**: throws `bluetoothUnavailable` if no central — **fail-fast**, not no-op. +- **State stream**: app *can* observe PoweredOn and retry — good for UI, boilerplate for work-driven Core. + +Silent scan no-op is the weakest variant: neither “park until ready” nor a clear error. + +#### Recommendation for ReliaBLE Core (aligned with product) + +For a **work-driven default** targeting wearables/IoT: + +1. **Prefer await/park semantics for work submission**, not silent no-op: + - `await waitUntilPoweredOn()` (or equivalent) inside scan / connect / `run(command)` paths. + - **Throw promptly** on terminal states: unauthorized, unsupported, poweredOff if policy says don’t wait forever (configurable timeout optional). +2. **Keep the `state` stream** for apps that want UI gating — observability and park are complementary, not alternatives. +3. **Avoid silent no-op** for scan; either await PoweredOn or throw `bluetoothNotReady`. + +| Prefer await/park when… | Prefer fail-fast when… | +|---|---| +| Default Core path: app submits work without managing radio | Settings/debug UI; user must turn BT on now | +| Transient `.unknown` / `.resetting` / post-authorize | Policy: never queue work while powered off | +| Work-driven + multi-step command chains | Tests asserting immediate errors | + +**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) + +**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. + +**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. + +#### 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 | + +```text +ensureLinked() ← called from run(command) and from Advanced 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): + arm Tier-1 ladder // exact “work pending ⇒ library reconnect” + else: + stay down // no Tier-1 when quiet + +on queue drained && !appHold: + 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 Advanced disconnect(): + clear appHold; intentional cancel; disarm reconnect +``` + +#### Reconnect: Approach B + +| 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** | + +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. + +#### 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. + +**Complexity:** one ensure-linked path + `appHold` + `queuedWork` flags + idle cancel. No `ConnectionPolicy.mode` enum. + +### 2.5 Public types: `Peripheral` (handle) + `DiscoveredPeripheral` (snapshot) + +**Settled.** Two public layers are required under Swift 6 (list-safe scan values vs long-lived control). Naming puts Apple’s noun on the control object. + +| 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) | +| **`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 | + +**Why not methods on today’s snapshot-only `Peripheral`:** that type was built as an immutable discovery row. Control needs stable identity across ads, queue, filter, hold. Renaming the snapshot to `DiscoveredPeripheral` and making **`Peripheral` the handle** keeps one primary noun for implementers. + +**Why not `Device`:** leave free for multi-transport app models (BLE + Matter + Wi‑Fi). + +**Known id:** `manager.peripheral(id:)` creates/returns handle before any scan; matching discovery binds live CB. No library fake discoveries for UI — “my devices” = tracked handles; “nearby” = real `DiscoveredPeripheral` stream. + +**Option A now / B later:** enrich `Peripheral` with last discovery metadata for IoT lists; optional later thin `PeripheralUpdate` stream payload without a third control type. + +Full write-up for external review: `docs/designs/discovered-peripheral-vs-peripheral-2026-07-15.md`. + +**Verdict:** Demote manager-only connect; device-centric API on **`Peripheral`**; scan sugar via **`DiscoveredPeripheral.peripheral`**. + +--- + +## 3. Discovery & readiness (FR-10) + +### ObjC + +Discovery **gates the per-peripheral command queue** (queue created suspended; unsuspend after services + characteristics; `didModifyServices` re-suspends and rediscovers). App doesn’t call “discover” as a global API — it’s part of the device becoming usable for `runCommand:`. + +### PRD (FR-10) + +Discovery is a **reliability gate**, not a catalog: **connected ≠ ready**. Filtered UUIDs, state machine, distinct readiness stream, timeout, fail-closed missing UUIDs, subscription intent re-arm, re-discover every connection, restore re-discover, document OS GATT cache limits. All greenfield in code. + +### Product API placement + +**GATT discovery / ready / subscriptions hang off `Peripheral`**, not `manager.discover(id:)`: + +```text +// Known / bound device (primary IoT path) +let p = manager.peripheral(id: "user-lock-42") +p.discoveryFilter = ... // sticky; fail-closed if empty when work needs discovery +try await p.run(MyCommand()) // ensureLinked → discover → ready → run + +// Scanner path +for await d in manager.peripheralDiscoveries { + let p = d.peripheral // sugar → interned handle + // ... +} + +// Advanced hold +try await p.connect(autoReconnect: true) +``` + +UUID declaration (FR-10.1.2) is sticky on **`Peripheral`**. + +**Verdict:** FR-10 requirements stand; surface them on **`Peripheral`**. Actor isolation still owns CB objects (see §6). + +--- + +## 4. Commands & queues + +**Will not be implemented prior to FR-10** (product). + +Target after FR-10: + +- Serial-per-peripheral FIFO (ObjC one-wide) as MVP. +- App-owned command content (FR-4.1); target characteristics by UUID; **inherit ready gate** (don’t re-litigate discovery in the queue). +- Reconnect-and-rerun after **ready**, not merely after link-up (FR-1.2 / FR-10.6). +- FR-5.2 deps/priority **after** serial + rerun work — avoid over-build. + +API: `device.run(command)` (or equivalent), not manager-global. + +--- + +## 5. Reliability — does the full PRD clear the ObjC bar? + +### ObjC Core guarantees (the bar) + +| Guarantee | ObjC mechanism | +|---|---| +| Don’t run radio work before PoweredOn | Suspended central op queue | +| Don’t run commands before GATT known | Peripheral queue suspended until discovery | +| Work-driven link + idle teardown | `runCommand` → connect; idle timer | +| Mid-transfer drop recovery | Pause → reconnect → rediscover → resume/rerun | +| Exactly-once completion | Command completion contract | +| Step timeouts | Watchdog (resettable for streams) | +| Serialized work per device | One-wide queue | +| Protocol integrity | Protocol layer CRC (above Core) | + +### Full PRD (incl. FR-10) vs bar + +| ObjC Core guarantee | Full PRD coverage | Clears bar? | +|---|---|---| +| 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 | +| 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** | +| Multi-device concurrent | FR-5.1 | **Yes — exceeds** ObjC practical focus | +| Connection observability | FR-1.3.1 | **Exceeds** (streams, reconnect sources) | +| Background / restore | FR-8.3, FR-10.6.3 | **Exceeds** | +| CRC / framing | App-owned FR-4.1 | **Different bar** (correct for OSS Core) | + +### Verdict + +**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. +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. + +Without those, PRD still beats “connection-only ReliaBLE” on paper via FR-10/4/5, but would miss ObjC’s **ergonomic** reliability (app never manages the link). + +Framing/CRC: ObjC protocol layer is **out of Core scope**; apps can still meet or beat band integrity. Core’s job is not to own packet formats. + +--- + +## 6. Concurrency — one actor vs per-device actors + +### Question + +Should discovery state machines and readiness streams live on **new actors**, or stay on a **single `@BluetoothActor`** with one CB delegate shim? + +### Constraints + +- `CBCentralManager` / `CBPeripheral` / `CBService` / `CBCharacteristic` are **not Sendable**. +- CoreBluetooth delivers callbacks **serially on the queue you pass at init** (ReliaBLE already uses a dedicated queue + shim → actor hop). +- Actor isolation **≠** FIFO across `await` (reentrancy): a per-device command gate is still required even inside one actor (`Swift6-Translation.md:112-118`). + +### Recommendation: **single Bluetooth isolation domain; per-device state machines as data, not separate actors holding CB objects** + +```text +ReliaBLEManager (nonisolated, Sendable façade) + │ + ▼ +@BluetoothActor (sole owner of all CB* objects + registry) + - central, scan, reconnect ladders + - devices[id]: DeviceState + readiness, discovery SM, command FIFO gate, + subscription intent, idle timer bookkeeping + │ + ▼ +Public DeviceHandle (Sendable id + thin async façade) + - forwards connect/run/discovery streams → actor +``` + +| Approach | Pros | Cons | +|---|---|---| +| **Single `@BluetoothActor` + per-id state** | One place for non-Sendable CB refs; one shim; matches CB’s single serial queue; simpler restore; FR-10 SM is just state | Actor can become large; need disciplined `DeviceState` types | +| **Actor per peripheral holding CBPeripheral** | Attractive modularity | **CBPeripheral not Sendable** — illegal/unsafe without shared executor hacks; multi-actor hops on every notify | +| **Peripheral actor that only holds `id`, central owns CB** | Mental model of “peripheral actor” | Extra hops; still need central for all CB calls; easy to pretend isolation you don’t have | +| **Custom executor shared with CB queue** | Callbacks already on actor | More plumbing; optional later if profiling demands | + +**Discovery SM + readiness streams:** implement as **per-device state inside `@BluetoothActor`**, exposed via handle-scoped `AsyncStream`s (each subscriber gets a stream filtered/keyed by device). That is *not* overcomplicating — it’s the minimum that respects Sendable. Separate actors per device **for CB ownership** would overcomplicate and fight the type system. + +Command FIFO: explicit gate per device id inside the actor (semaphore/queue), not “actor isolation alone.” + +--- + +## 7. Multi-device, scan, background (brief) + +- ReliaBLE **ahead** on multi-connection substrate, connection streams, OS auto-reconnect, state restoration (link level). +- FR-10.6.3 extends restore to re-discover + re-arm subscriptions — required for Core parity after FR-10. +- Scan stays on manager; post-connection lifecycle on device handle. +- FR-8.5 identity still open; FR-10.6.2 keys catalog by `Peripheral.id`. + +--- + +## 8. Security & framing + +- Library: link + discovery + command lifecycle + subscription setup. +- App: packet bytes, CRC, domain protocol (FR-4.1). +- FR-6 security modes: still PRD-future; not required to clear ObjC Core bar (ObjC Core didn’t own them either). + +--- + +## Parity matrix (Core-focused) + +| ObjC Core capability | PRD / product target | Code today | +|---|---|---| +| 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) | +| 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) | +| 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 | +| didModifyServices re-gate | FR-10.5 | Absent | +| Serial command queue | FR-5.2 (after FR-10) | Absent | +| Reconnect-and-rerun after ready | FR-1.2 + FR-4/5 | Absent | +| Background restore (link) | FR-8 / config | **Done** | +| Restore → rediscover/resubscribe | FR-10.6.3 | Absent | +| App-owned framing | FR-4.1 | N/A until commands | +| Swift 6 single CB actor | Product §6 | **Done** (manager façade) | + +--- + +## Risks + +1. Shipping GATT/commands without device-handle + ready gate → CB-shaped API, races ObjC already fixed. +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. +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. + +--- + +## Recommendations + +### 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. +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. +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`. +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. +6. Then FR-5.2 deps/priority, FR-7, FR-6, FR-8.5 as needed. + +### Concurrency + +- Keep **one `@BluetoothActor`** owning all CB objects. +- Per-device **state machines + FIFO gates** inside it. +- Public **`Peripheral`** handles forward by id; **`DiscoveredPeripheral`** stays a pure value (+ manager stamp for sugar). +- Don’t add per-device actors that own `CBPeripheral`. + +--- + +## Open questions (remaining) + +1. ~~**Device API shape / naming**~~ — **Settled:** `Peripheral` (handle) + `DiscoveredPeripheral` (snapshot); `discovered.peripheral` sugar; known-id via `manager.peripheral(id:)`; option A last-discovery metadata on handle; option B `PeripheralUpdate` later. Detail: `docs/designs/discovered-peripheral-vs-peripheral-2026-07-15.md`. +2. ~~**Idle disconnect default duration**~~ — **Settled:** global config, default **5s**. +3. ~~**Ready vs subscriptions**~~ — **Settled:** catalog-ready; subscriptions = intent + re-arm. +4. ~~**UUID declaration API**~~ — **Settled:** sticky on **`Peripheral`** (e.g. `discoveryFilter` / set at obtain time); fail-closed if empty when discovery is required. +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. + +--- + +## 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. +- **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. +- **Reliability bar:** Full PRD **clears ObjC Core** if work-driven default, PoweredOn await, FR-10, and command retry-after-ready ship; exceeds on restore/multi-device/observability; framing stays app-owned. +- **Concurrency:** **Single `@BluetoothActor`** + per-device state/FIFO; not separate CB-owning actors. + +Until FR-10, the `Peripheral`/`DiscoveredPeripheral` split, and the work-driven path land, code remains a link-manager milestone on the way to Core parity — acceptable only because the library is unshipped and PRD-complete is the v1 bar.