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]