Skip to content

FR-2.3.1 / FR-1.3.1: Peripheral connection lifecycle + connection-state stream #36

Description

@itsniper

Goal

Build out the peripheral connection lifecycle — the keystone capability that nearly every remaining functional requirement (commands, multi-device, security, chunking, reliability) depends on. Today connect(to:) exists but is thin and there is no way for the integrating app to observe connection state changes.

This issue addresses the connection-oriented halves of two PRD requirements that were split for this purpose:

  • FR-2.3.1 — Provide callbacks/streams for connection changes (connection, disconnection, connection failure).
  • FR-1.3.1 — Provide status updates on connection stability (e.g. connected, disconnected, reconnecting).

The data-transmission halves (FR-2.3.2 data received, FR-1.3.2 transmission integrity) are intentionally out of scope here — they depend on a data/command transport that doesn't exist yet.

Scope

  1. Connection lifecycle — flesh out connect(to:) / add disconnect(from:) and wire the CoreBluetooth delegate paths:
    • centralManager(_:didConnect:)
    • centralManager(_:didDisconnectPeripheral:error:)
    • centralManager(_:didFailToConnect:error:)
  2. Connection state surface (FR-2.3.1 / FR-1.3.1) — expose connection-state changes to the integrating app via an AsyncStream, consistent with the existing stateStream() / peripheralDiscoveriesStream() patterns on BluetoothActor. Model a ConnectionState (e.g. .connecting, .connected, .disconnecting, .disconnected, .failed) keyed per peripheral.
  3. Multi-device aware — the connection registry must support multiple simultaneous connections (FR-5.1), even if higher-level orchestration comes later.
  4. Tests — extend the mock harness (per NFR-2.2: BLE mocking for CI test environments #31) to drive connect/disconnect/fail flows and assert emitted connection-state events. No real radio required.

Out of scope

  • FR-2.3.2 (data received) and FR-1.3.2 (transmission integrity) — deferred until a data/command transport exists.
  • Automatic reconnection / backoff (FR-1.2) — see the follow-up issue, which builds on the status infrastructure delivered here.

Acceptance criteria

  • connect(to:) and a disconnect API drive real connect/disconnect/fail transitions through the mock central.
  • A public AsyncStream emits per-peripheral connection-state changes.
  • Multiple peripherals can be connected concurrently.
  • Mock-driven tests cover the connect success, disconnect, and connection-failure paths.
  • Preserves the three-target SPM constraint (central via CBCentralManagerFactory.instance(...), forceMock: true retained).

References

  • PRD: FR-2.3.1, FR-1.3.1
  • Architecture: AGENTS.md (@BluetoothActor, three-target mock trick, lazy central init)
  • Existing streams: BluetoothActor.stateStream() / peripheralDiscoveriesStream() / discoveredPeripheralsStream()

Demo app (ship alongside this work)

The Demo is the consumer-side proof that the new connection-state stream is ergonomic for a real integrator. It should consume the per-peripheral ConnectionState stream the same way it already consumes manager.state / manager.discoveredPeripherals.

Scope (Demo)

  1. Consume the connection-state stream — add a fourth group.addTask in CentralView's .task that loops for await event in manager.connectionStateChanges (final name TBD) and routes each event to the view model on @MainActor, mirroring the existing updateState(_:) pattern.
  2. Transient connection state in the view model — add var connectionStates: [String: ConnectionState] (keyed by Peripheral.id) to the @Observable CentralViewModel. Do not persist connection state to SwiftData — connections don't survive launches and persisting them would fight CoreBluetooth's reality. Keep DeviceStoreActor strictly for discovery/device persistence (preserves the "reads on @MainActor via @Query, writes off-main via the actor" contract).
  3. Connect/disconnect UI on the device-detail screen — replace the current static "Device Details" text in the deviceList NavigationLink with a real detail view that:
    • shows the live ConnectionState for that peripheral (.connecting / .connected / .disconnecting / .disconnected / .failed), and
    • shows a single context-aware Connect/Disconnect button driven off the streamed state (same state-driven button pattern already used in the sidebar for scanning), calling connect(to:) / the new disconnect API.
  4. Make explicit disconnect vs. unexpected drop legible — the Disconnect button calls the explicit disconnect API and settles to .disconnected; this doubles as the manual setup for the FR-1.2: Automatic reconnection with exponential backoff #37 "explicit disconnects do not trigger reconnection" check.

Acceptance criteria (Demo)

  • The Demo subscribes to the new connection-state stream via a .task loop and reflects per-peripheral state on the device-detail screen.
  • A context-aware Connect/Disconnect button drives the real connect(to:) / disconnect APIs.
  • Connection state lives in transient view-model state only; SwiftData is unchanged except where needed for device rows.

References (Demo)

  • Demo/CLAUDE.md — Demo conventions and required build tooling (XcodeBuildMCP, not raw xcodebuild); read first.
  • Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift.task stream-consumption pattern and deviceList NavigationLink.
  • Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift@Observable view model + @MainActor updateState(_:).
  • Demo/ReliaBLE Demo/ReliaBLE Demo/Central/DeviceStoreActor.swift — keep persistence scoped to discovery/devices.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions