Skip to content
This repository was archived by the owner on Jun 16, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
75 changes: 75 additions & 0 deletions GooseSwift/DeviceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
}
Expand Down Expand Up @@ -469,6 +470,7 @@ private struct DeviceActionGrid: View {
.disabled(!ble.canConnect)

DeviceActionButton(title: "Reconnect", systemName: "arrow.clockwise") {
ble.resetReconnectBackoff()
ble.reconnectRemembered()
}
.disabled(!ble.canReconnectRemembered)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
})
10 changes: 7 additions & 3 deletions GooseSwift/GooseBLEClient+CentralDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ extension GooseBLEClient: CBCentralManagerDelegate {
clientHelloSentForCurrentConnection = false
autoReconnectInFlight = false
autoReconnectTargetID = nil
resetReconnectBackoff()
let reason = pendingConnectionReason ?? "unknown"
pendingConnectionReason = nil
if !prioritizeLiveCaptureOnReady,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
}
}
}
60 changes: 60 additions & 0 deletions GooseSwift/GooseBLEClient+Commands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,7 @@ extension GooseBLEClient {
rememberedDeviceValidated = false
autoReconnectTargetID = nil
autoReconnectInFlight = false
resetReconnectBackoff()
if activePeripheral == nil {
activeDeviceIdentifier = nil
updateActiveDeviceName("WHOOP")
Expand Down Expand Up @@ -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)"
)
}
}

}
7 changes: 7 additions & 0 deletions GooseSwift/GooseBLEClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down