From c1e96add4cb4c39d94af9095e0846b87f1195eae Mon Sep 17 00:00:00 2001 From: regulapati-n <144737598+regulapati-n@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:06:18 -0400 Subject: [PATCH] feat: add exponential backoff and attempt limits to BLE reconnection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace immediate, unlimited BLE reconnection with exponential backoff (1s → 2s → 4s → 8s → 16s → 32s → 60s cap) and a 10-attempt limit. Previously, when a remembered WHOOP device disconnected, the app would immediately call connect() again with no delay and no upper bound. This caused rapid reconnect loops that drain battery on both phone and band, especially when the device is out of range. Changes: - GooseBLEClient.swift: add reconnectAttemptCount, reconnectNextRetryAt @Published properties and backoff constants (base 1s, multiplier 2x, max 60s, 10 attempts) - GooseBLEClient+Commands.swift: add scheduleReconnectWithBackoff() with exponential delay calculation and resetReconnectBackoff(); wire reset into clearRememberedDevice() - GooseBLEClient+CentralDelegate.swift: replace immediate reconnect in didDisconnectPeripheral with backoff; add backoff retry in didFailToConnect; reset counter in didConnect on success - DeviceView.swift: add ReconnectBackoffBanner showing attempt count, countdown timer, and Retry Now / Stop Retrying buttons Diagnostic log events added: - reconnect.backoff.scheduled (attempt, delay, reason) - reconnect.backoff.reset (previous attempt count) - reconnect.gave_up (after max attempts exhausted) --- GooseSwift/DeviceView.swift | 75 +++++++++++++++++++ .../GooseBLEClient+CentralDelegate.swift | 10 ++- GooseSwift/GooseBLEClient+Commands.swift | 60 +++++++++++++++ GooseSwift/GooseBLEClient.swift | 7 ++ 4 files changed, 149 insertions(+), 3 deletions(-) diff --git a/GooseSwift/DeviceView.swift b/GooseSwift/DeviceView.swift index 78fa48d8..d12ac77d 100644 --- a/GooseSwift/DeviceView.swift +++ b/GooseSwift/DeviceView.swift @@ -322,6 +322,7 @@ private struct DeviceAdvancedPanel: View { } DeviceActionGrid(model: model, ble: ble) + ReconnectBackoffBanner(ble: ble) DiscoveredDeviceList(ble: ble) EventLogPreview(messages: Array(messageStore.messages.prefix(5))) } @@ -469,6 +470,7 @@ private struct DeviceActionGrid: View { .disabled(!ble.canConnect) DeviceActionButton(title: "Reconnect", systemName: "arrow.clockwise") { + ble.resetReconnectBackoff() ble.reconnectRemembered() } .disabled(!ble.canReconnectRemembered) @@ -647,6 +649,73 @@ private struct EventLogPreview: View { } } +private struct ReconnectBackoffBanner: View { + @ObservedObject var ble: GooseBLEClient + + var body: some View { + if ble.reconnectAttemptCount > 0 { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Image(systemName: "antenna.radiowaves.left.and.right.slash") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(reconnectAmber) + Text(reconnectHeadline) + .font(.system(size: 15, weight: .black, design: .default)) + .foregroundStyle(devicePrimaryText) + .lineLimit(2) + .minimumScaleFactor(0.78) + } + + if let retryAt = ble.reconnectNextRetryAt { + Text("Next retry \(retryAt, style: .relative)") + .font(.system(size: 13, weight: .semibold, design: .default)) + .foregroundStyle(secondaryText) + } + + HStack(spacing: 10) { + Button { + ble.resetReconnectBackoff() + ble.reconnectRemembered() + } label: { + Text("Retry Now") + .font(.system(size: 14, weight: .black, design: .default)) + .foregroundStyle(devicePrimaryText) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(controlBackground, in: RoundedRectangle(cornerRadius: 6, style: .continuous)) + } + .buttonStyle(.plain) + + Button { + ble.resetReconnectBackoff() + } label: { + Text("Stop Retrying") + .font(.system(size: 14, weight: .black, design: .default)) + .foregroundStyle(disconnectedRed) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(controlBackground, in: RoundedRectangle(cornerRadius: 6, style: .continuous)) + } + .buttonStyle(.plain) + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(reconnectBannerBackground) + ) + } + } + + private var reconnectHeadline: String { + if ble.reconnectAttemptCount > GooseBLEClient.reconnectMaxAttempts { + return "Reconnection failed after \(GooseBLEClient.reconnectMaxAttempts) attempts" + } + return "Reconnecting \(ble.reconnectAttemptCount)/\(GooseBLEClient.reconnectMaxAttempts)" + } +} + private func relativeSummary(for date: Date?) -> String? { guard let date else { return nil @@ -692,3 +761,9 @@ private let batteryYellow = Color(red: 1.0, green: 0.89, blue: 0.36) private let deviceLabelFont = Font.system(size: 15, weight: .black, design: .default) private let deviceBodyFont = Font.system(size: 17, weight: .bold, design: .default) private let advancedBodyFont = Font.system(size: 17, weight: .regular, design: .default) +private let reconnectAmber = Color(red: 1.0, green: 0.72, blue: 0.18) +private let reconnectBannerBackground = Color(uiColor: UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor(red: 0.14, green: 0.12, blue: 0.08, alpha: 1) + : UIColor(red: 1.0, green: 0.96, blue: 0.88, alpha: 1) +}) diff --git a/GooseSwift/GooseBLEClient+CentralDelegate.swift b/GooseSwift/GooseBLEClient+CentralDelegate.swift index 1f9180f7..d690cc37 100644 --- a/GooseSwift/GooseBLEClient+CentralDelegate.swift +++ b/GooseSwift/GooseBLEClient+CentralDelegate.swift @@ -173,6 +173,7 @@ extension GooseBLEClient: CBCentralManagerDelegate { clientHelloSentForCurrentConnection = false autoReconnectInFlight = false autoReconnectTargetID = nil + resetReconnectBackoff() let reason = pendingConnectionReason ?? "unknown" pendingConnectionReason = nil if !prioritizeLiveCaptureOnReady, @@ -218,8 +219,12 @@ extension GooseBLEClient: CBCentralManagerDelegate { autoConnectForPhysiologyCapture = false pendingConnectionReason = nil updateConnectionState("connect failed") - updateReconnectState("connect failed") record(level: .error, source: "ble", title: "connect.failed", body: error?.localizedDescription ?? "unknown") + if rememberedDeviceID == peripheral.identifier { + scheduleReconnectWithBackoff(peripheral, reason: "auto.connect_failed") + } else { + updateReconnectState("connect failed") + } } func centralManager( @@ -275,8 +280,7 @@ extension GooseBLEClient: CBCentralManagerDelegate { body: "reason=\(reconnectReason) autoHistoricalSync=\(autoHistoricalSyncOnReady) prioritizeLive=\(prioritizeLiveCaptureOnReady)" ) } - updateReconnectState("reconnecting after disconnect") - connect(peripheral, reason: reconnectReason) + scheduleReconnectWithBackoff(peripheral, reason: reconnectReason) } } } diff --git a/GooseSwift/GooseBLEClient+Commands.swift b/GooseSwift/GooseBLEClient+Commands.swift index 2cd70206..00b2d0dd 100644 --- a/GooseSwift/GooseBLEClient+Commands.swift +++ b/GooseSwift/GooseBLEClient+Commands.swift @@ -589,6 +589,7 @@ extension GooseBLEClient { rememberedDeviceValidated = false autoReconnectTargetID = nil autoReconnectInFlight = false + resetReconnectBackoff() if activePeripheral == nil { activeDeviceIdentifier = nil updateActiveDeviceName("WHOOP") @@ -945,4 +946,63 @@ extension GooseBLEClient { record(source: "ble.sync", title: "historical_sync.scheduled", body: reason) } + /// Schedules a reconnection attempt with exponential backoff. + /// Delay doubles each attempt: 1s → 2s → 4s → 8s → 16s → 32s → 60s (capped). + /// Gives up after `reconnectMaxAttempts` consecutive failures. + func scheduleReconnectWithBackoff(_ peripheral: CBPeripheral, reason: String) { + reconnectBackoffWorkItem?.cancel() + + reconnectAttemptCount += 1 + guard reconnectAttemptCount <= Self.reconnectMaxAttempts else { + updateReconnectState("gave up after \(Self.reconnectMaxAttempts) attempts") + record( + level: .warn, + source: "ble", + title: "reconnect.gave_up", + body: "attempts=\(reconnectAttemptCount) reason=\(reason)" + ) + return + } + + let exponent = Double(reconnectAttemptCount - 1) + let delay = min( + Self.reconnectBaseDelay * pow(Self.reconnectBackoffMultiplier, exponent), + Self.reconnectMaxDelay + ) + let retryAt = Date().addingTimeInterval(delay) + reconnectNextRetryAt = retryAt + + updateReconnectState("retry \(reconnectAttemptCount)/\(Self.reconnectMaxAttempts) in \(Int(delay))s") + record( + source: "ble", + title: "reconnect.backoff.scheduled", + body: "attempt=\(reconnectAttemptCount)/\(Self.reconnectMaxAttempts) delay=\(String(format: "%.1f", delay))s reason=\(reason)" + ) + + let workItem = DispatchWorkItem { [weak self, weak peripheral] in + guard let self, let peripheral else { return } + self.reconnectNextRetryAt = nil + self.updateReconnectState("reconnecting (attempt \(self.reconnectAttemptCount))") + self.connect(peripheral, reason: reason) + } + reconnectBackoffWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) + } + + /// Resets the backoff counter after a successful connection or manual forget. + func resetReconnectBackoff() { + reconnectBackoffWorkItem?.cancel() + reconnectBackoffWorkItem = nil + let previousAttempts = reconnectAttemptCount + reconnectAttemptCount = 0 + reconnectNextRetryAt = nil + if previousAttempts > 0 { + record( + source: "ble", + title: "reconnect.backoff.reset", + body: "after \(previousAttempts) attempt(s)" + ) + } + } + } diff --git a/GooseSwift/GooseBLEClient.swift b/GooseSwift/GooseBLEClient.swift index 7d55a07b..7e06fae7 100644 --- a/GooseSwift/GooseBLEClient.swift +++ b/GooseSwift/GooseBLEClient.swift @@ -241,6 +241,9 @@ final class GooseBLEClient: NSObject, ObservableObject { var autoReconnectTargetID: UUID? var autoReconnectInFlight = false var startupReconnectAttempted = false + @Published var reconnectAttemptCount = 0 + @Published var reconnectNextRetryAt: Date? + var reconnectBackoffWorkItem: DispatchWorkItem? var pendingConnectionReason: String? var pendingAutomaticHistoricalSyncReason: String? var clientHelloSentForCurrentConnection = false @@ -350,6 +353,10 @@ final class GooseBLEClient: NSObject, ObservableObject { static let historicalPacketCountPublishInterval: TimeInterval = 1 static let historicalProgressCallbackInterval: TimeInterval = 1 static let strapClockAutoSyncThresholdSeconds: TimeInterval = 5 + static let reconnectBaseDelay: TimeInterval = 1.0 + static let reconnectMaxDelay: TimeInterval = 60.0 + static let reconnectBackoffMultiplier: Double = 2.0 + static let reconnectMaxAttempts = 10 static let diagnosticLogFormatter: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]