Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
f282e0b
Added planning docs
itsniper Aug 8, 2026
2294e2b
Config and error additions for work-driven lifecycle
itsniper Aug 8, 2026
ec3c554
PoweredOn await infrastructure
itsniper Aug 8, 2026
2bb34e1
Await a usable radio for scan and connect (#57)
itsniper Aug 8, 2026
0c4e1cf
Demand substrate: work leases, manual-connect holds, ensureLinked
itsniper Aug 8, 2026
39df14b
Idle disconnect: grace window and teardown (#58)
itsniper Aug 8, 2026
a33db1e
Reconnect gating, radio-drop projection, and durable holds (#59, #60)
itsniper Aug 9, 2026
353347b
DocC and PRD updates for the work-driven connection lifecycle
itsniper Aug 9, 2026
a2b2fc6
Demo: adopt async throws scanning and idle interval
itsniper Aug 9, 2026
235b9bf
Fix teardown cancellation, reconnect gating, and scan waiter superses…
itsniper Aug 9, 2026
0efbcdf
Make scan waiter cancellation waiter-specific
itsniper Aug 9, 2026
8441988
Make the scan-cancel regression test actually falsifiable
itsniper Aug 9, 2026
4ad6972
Make idle-cancel and ladder-radio-gate tests falsifiable
itsniper Aug 9, 2026
bbff3d1
Evaluate teardown cancel predicate before settling state
itsniper Aug 9, 2026
12275a1
Pin the manual-disconnect cached Tier-0 cancel path
itsniper Aug 9, 2026
1fecc25
Serialize CoreBluetoothMock simulation on the main actor
itsniper Aug 9, 2026
d0ac073
Make shared test-harness state thread-safe
itsniper Aug 9, 2026
7164d69
Added manual test plan
itsniper Aug 9, 2026
b7e13fa
Cover untrusted-Tier-0, notFound surfacing, and ladder radio gates
itsniper Aug 9, 2026
ea2f80f
Make the Tier-0 limbo test deterministic
itsniper Aug 9, 2026
4e8e37e
Replace the racy Tier-0 limbo test with an on-device check
itsniper Aug 9, 2026
58bda40
Updated manual test plan to include faraday box
itsniper Aug 9, 2026
b781750
Updated PRD based on recent work and decisions
itsniper Aug 9, 2026
db329c5
Re-organized test-only code in BluetoothActor
itsniper Aug 9, 2026
82d9a3d
Skip terminal rewrite on radio invalidate; surface connect failures
itsniper Aug 11, 2026
eed3de4
Harden flaky ladder and invalidate unit tests for CI
itsniper Aug 11, 2026
7085875
Improved log messages and added note to PRD
itsniper Aug 11, 2026
17ccb94
Fix connection state projection for radio-outage hold and disconnect
itsniper Aug 11, 2026
e8a1d03
Improved connection drop logging
itsniper Aug 11, 2026
bb7ed2a
Rehydrate durable holds after force-quit and stabilize identity
itsniper Aug 12, 2026
e594c06
Fix identity upgrade stranding demand under an obsolete id
itsniper Aug 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ The library is built with Swift 6 and **complete concurrency checking**. The `Re
## Notes for editing

- **This library is in pre-release development stage.** Breaking changes are expected. Do not reference behavior history in any library documentation or code comments (noting in planning docs is acceptable and expected). Do not waste time thinking about mitigating breaking changes. Focus on the current design and implementation.
- Public API on `ReliaBLEManager` is the supported surface for external consumers. Adding/removing methods there is a breaking change.
- `forceMock: true` is currently passed to `CBCentralManagerFactory.instance(...)` in `BluetoothActor`. The production factory ignores this parameter; the mock factory honors it. Don't "clean it up" — it's load-bearing for the test target.
- Public API on `ReliaBLEManager` is the supported surface for external consumers. Adding/removing methods there is a breaking change that likely requires updating Demo app.
- Do not reference specific sections or decision identifiers ("F5.2", "D-restore", "Option B", etc) from `./docs` or agent turns in the library's public API documentation or code comments. The library's public API is the supported surface for external consumers; `./docs` is for internal design and planning only. It *is* OK to reference these in planning docs or chat sessions, but not in the library's public API documentation or code comments.
- Be sure to add log messages to all new code, and to update existing log messages when the code changes. Observability is critical for BLE debugging in production. Use `LoggingService`, log levels, and the `tags` parameter to categorize logs appropriately.
- DocC catalog lives at `Sources/ReliaBLE/Documentation.docc/`. The `swift-docc-plugin` is a package dep so `swift package generate-documentation` works. This documentation **must** be kept up to date with the public API on `ReliaBLEManager` and the overall architecture and usage patterns.
- **Generated DocC output goes to `./user-docs` (gitignored), never `./docs`.** `./docs` is AI agent managed plans, designs, investigations, and reviews.
65 changes: 62 additions & 3 deletions Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,14 @@ extension ConnectionState {
case .reconnecting(let source, let attempt, _):
switch source {
case .system: "System reconnecting…"
case .library: "Reconnecting (attempt \(attempt ?? 0))"
case .library:
// `attempt == nil` is the library AwaitingRadio projection (radio off / not yet
// usable), not ladder attempt 0.
if attempt == nil {
"Waiting for Bluetooth…"
} else {
"Reconnecting (attempt \(attempt, default: "0"))"
}
}
case .connected: "Connected"
case .disconnecting: "Disconnecting"
Expand Down Expand Up @@ -109,6 +116,13 @@ struct CentralView: View {
NavigationSplitView {
Text("ReliaBLE state: \(viewModel.currentState.description)")

if let scanError = viewModel.scanError {
Text(scanError)
.foregroundStyle(.red)
.font(.caption)
.padding(.horizontal)
}

if case BluetoothState.unauthorized(let authState) = viewModel.currentState, authState == .notDetermined {
Button("Authorize Bluetooth") {
viewModel.authorizeBluetooth()
Expand Down Expand Up @@ -190,6 +204,10 @@ struct CentralView: View {
}
}
group.addTask {
// Seed first so force-quit relaunch / restore that already linked is visible
// immediately; `connectionStateChanges` does not replay current state.
let initial = await reliaBLE.currentConnectionStates
await viewModel.seedConnectionStates(initial)
for await change in reliaBLE.connectionStateChanges {
await viewModel.updateConnectionState(change)
}
Expand Down Expand Up @@ -251,6 +269,9 @@ private struct DeviceDetailView: View {
let reliaBLE: ReliaBLEManager

@State private var autoReconnect = true
/// Surfaces thrown errors from `connect` / `disconnect` (e.g. `bluetoothPoweredOff`).
/// Stream-driven connection captions do not carry those fail-fast throws.
@State private var actionError: String?

private var connectionState: ConnectionState? {
viewModel.connectionStates[device.id]
Expand All @@ -277,12 +298,36 @@ private struct DeviceDetailView: View {
.foregroundStyle(.secondary)
}

if let actionError {
Text(actionError)
.foregroundStyle(.red)
.font(.caption)
.multilineTextAlignment(.center)
}

Button(action: {
let handle = reliaBLE.peripheral(id: device.id)
actionError = nil
if isActive {
Task { try? await handle.disconnect() }
Task {
do {
try await handle.disconnect()
} catch {
await MainActor.run {
actionError = "Disconnect Failed: \(error)"
}
}
}
} else {
Task { try? await handle.connect(autoReconnect: autoReconnect) }
Task {
do {
try await handle.connect(autoReconnect: autoReconnect)
} catch {
await MainActor.run {
actionError = "Connect Failed: \(error)"
}
}
}
}
}) {
Text(isActive ? "Disconnect" : "Connect")
Expand All @@ -300,6 +345,20 @@ private struct DeviceDetailView: View {
}
}
.padding()
.task(id: device.id) {
// Detail can open before the Central stream task seeds; pull current state for this id.
let states = await reliaBLE.currentConnectionStates
if let state = states[device.id] {
viewModel.seedConnectionStates([device.id: state])
}
}
.onChange(of: connectionState?.isActiveConnection) { _, isActiveConnection in
// A later stream transition to an in-progress/linked state means the radio recovered
// (or a new attempt started) — drop a stale fail-fast caption from an earlier tap.
if isActiveConnection == true {
actionError = nil
}
}
}
}

Expand Down
22 changes: 21 additions & 1 deletion Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import ReliaBLE
var currentState: BluetoothState = .unknown
/// Defaults to the Demo peripheral service UUID so background scans have a required filter.
var servicesInput = "12345678-90AB-CDEF-1234-567890ABCDEF"
var scanError: String?
var connectionStates: [String: ConnectionState] = [:]

private var deviceStore: DeviceStoreActor?
Expand All @@ -59,13 +60,32 @@ import ReliaBLE
connectionStates[change.peripheralId] = change.state
}

/// Seeds captions from ``ReliaBLEManager/currentConnectionStates`` so restore/reconnect that
/// already completed (or is in flight) is visible before the next stream event. Stream updates
/// still win afterward via ``updateConnectionState``.
@MainActor
func seedConnectionStates(_ states: [String: ConnectionState]) {
for (id, state) in states {
connectionStates[id] = state
}
}

func authorizeBluetooth() {
Task { try? await reliaBLE?.authorizeBluetooth() }
}

func startScanning() {
let services = parseServices(from: servicesInput)
Task { await reliaBLE?.startScanning(services: services) }
scanError = nil
Task {
do {
try await reliaBLE?.startScanning(services: services)
} catch {
await MainActor.run {
scanError = String(describing: error)
}
}
}
}

func stopScanning() {
Expand Down
27 changes: 24 additions & 3 deletions Demo/ReliaBLE Demo/ReliaBLE Demo/Central/DeviceStoreActor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,35 @@ actor DeviceStoreActor: ModelActor {
assertWritesOffMainThread()

do {
let allDevices = try modelContext.fetch(FetchDescriptor<Device>())
var allDevices = try modelContext.fetch(FetchDescriptor<Device>())
for peripheral in peripherals {
if let existingDevice = allDevices.first(where: { $0.id == peripheral.id }) {
let cbUUID = peripheral.cbIdentifier?.uuidString

// Prefer exact library id match; else same radio via CoreBluetooth UUID (name-derived
// id often drifts: "ReliaBLE Demo" / UUID-string / "iPhone" for one peripheral).
let existingDevice =
allDevices.first(where: { $0.id == peripheral.id })
?? cbUUID.flatMap { uuid in allDevices.first(where: { $0.cbUUID == uuid }) }

if let existingDevice {
// Identity drift: keep one SwiftData row, adopt the live library id for connect().
if existingDevice.id != peripheral.id {
existingDevice.id = peripheral.id
}
if let cbUUID {
existingDevice.cbUUID = cbUUID
}
existingDevice.name = peripheral.name
existingDevice.lastSeen = peripheral.lastSeen
} else {
let newDevice = Device(id: peripheral.id, name: peripheral.name, lastSeen: peripheral.lastSeen)
let newDevice = Device(
id: peripheral.id,
name: peripheral.name,
lastSeen: peripheral.lastSeen,
cbUUID: cbUUID
)
modelContext.insert(newDevice)
allDevices.append(newDevice)
}
}
try modelContext.save()
Expand Down
7 changes: 6 additions & 1 deletion Demo/ReliaBLE Demo/ReliaBLE Demo/Central/Models/Device.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,18 @@ import SwiftData

@Model
final class Device {
/// Library ``Peripheral/id`` (name-derived today). May change across launches when GAP/local
/// name availability changes; ``cbUUID`` is the stable merge key.
var id: String
/// CoreBluetooth `CBPeripheral.identifier` when known — stable per central for the same radio.
var cbUUID: String?
var name: String?
var lastSeen: Date?

init(id: String, name: String?, lastSeen: Date?) {
init(id: String, name: String?, lastSeen: Date?, cbUUID: String? = nil) {
self.id = id
self.name = name
self.lastSeen = lastSeen
self.cbUUID = cbUUID
}
}
2 changes: 2 additions & 0 deletions Demo/ReliaBLE Demo/ReliaBLE Demo/ReliaBLE_DemoApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ struct ReliaBLE_DemoApp: App {
reconnectPolicy.jitter = defaults.object(forKey: "reconnectPolicy.jitter") as? Double ?? 0.2
config.reconnectPolicy = reconnectPolicy

config.idleDisconnectInterval = defaults.object(forKey: "idleDisconnectInterval") as? TimeInterval ?? 5.0

return ReliaBLEManager(config: config)
}()

Expand Down
14 changes: 14 additions & 0 deletions Demo/ReliaBLE Demo/ReliaBLE Demo/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ struct SettingsView: View {
@AppStorage("reconnectPolicy.initialDelay") private var initialDelay = 1.0
@AppStorage("reconnectPolicy.maxDelay") private var maxDelay = 30.0
@AppStorage("reconnectPolicy.jitter") private var jitter = 0.2
@AppStorage("idleDisconnectInterval") private var idleDisconnectInterval = 5.0

var body: some View {
NavigationView {
Expand Down Expand Up @@ -72,6 +73,19 @@ struct SettingsView: View {
} header: {
Text("Reconnect Policy")
}

Section {
VStack(alignment: .leading, spacing: 4) {
Text("Idle Disconnect: \(String(format: "%.1f", idleDisconnectInterval))s")
Slider(value: $idleDisconnectInterval, in: 0...30, step: 0.5)
}

Text("Changes take effect on next app launch.")
.font(.caption)
.foregroundStyle(.secondary)
} header: {
Text("Connection Lifecycle")
}
}
.navigationTitle("Settings")
}
Expand Down
Loading
Loading