You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
Connection lifecycle — flesh out connect(to:) / add disconnect(from:) and wire the CoreBluetooth delegate paths:
centralManager(_:didConnect:)
centralManager(_:didDisconnectPeripheral:error:)
centralManager(_:didFailToConnect:error:)
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.
Multi-device aware — the connection registry must support multiple simultaneous connections (FR-5.1), even if higher-level orchestration comes later.
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)
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.
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).
Connect/disconnect UI on the device-detail screen — replace the current static "Device Details" text in the deviceListNavigationLink 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.
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 deviceListNavigationLink.
Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift — @Observable view model + @MainActorupdateState(_:).
Demo/ReliaBLE Demo/ReliaBLE Demo/Central/DeviceStoreActor.swift — keep persistence scoped to discovery/devices.
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:
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
connect(to:)/ adddisconnect(from:)and wire the CoreBluetooth delegate paths:centralManager(_:didConnect:)centralManager(_:didDisconnectPeripheral:error:)centralManager(_:didFailToConnect:error:)AsyncStream, consistent with the existingstateStream()/peripheralDiscoveriesStream()patterns onBluetoothActor. Model aConnectionState(e.g..connecting,.connected,.disconnecting,.disconnected,.failed) keyed per peripheral.Out of scope
Acceptance criteria
connect(to:)and a disconnect API drive real connect/disconnect/fail transitions through the mock central.AsyncStreamemits per-peripheral connection-state changes.CBCentralManagerFactory.instance(...),forceMock: trueretained).References
AGENTS.md(@BluetoothActor, three-target mock trick, lazy central init)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
ConnectionStatestream the same way it already consumesmanager.state/manager.discoveredPeripherals.Scope (Demo)
group.addTaskinCentralView's.taskthat loopsfor await event in manager.connectionStateChanges(final name TBD) and routes each event to the view model on@MainActor, mirroring the existingupdateState(_:)pattern.var connectionStates: [String: ConnectionState](keyed byPeripheral.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. KeepDeviceStoreActorstrictly for discovery/device persistence (preserves the "reads on@MainActorvia@Query, writes off-main via the actor" contract).deviceListNavigationLinkwith a real detail view that:ConnectionStatefor that peripheral (.connecting/.connected/.disconnecting/.disconnected/.failed), andconnect(to:)/ the new disconnect API..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)
.taskloop and reflects per-peripheral state on the device-detail screen.connect(to:)/ disconnect APIs.References (Demo)
Demo/CLAUDE.md— Demo conventions and required build tooling (XcodeBuildMCP, not rawxcodebuild); read first.Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift—.taskstream-consumption pattern anddeviceListNavigationLink.Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift—@Observableview model +@MainActorupdateState(_:).Demo/ReliaBLE Demo/ReliaBLE Demo/Central/DeviceStoreActor.swift— keep persistence scoped to discovery/devices.