Skip to content
Merged
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
48 changes: 48 additions & 0 deletions Strand/BLE/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1510,6 +1510,11 @@ public final class BLEManager: NSObject, ObservableObject {
// from this path. Driven only by probeFeatureFlags() (user-initiated, Test Centre gated).
|| ((command == .startFeatureFlagKeyExchange || command == .sendNextFeatureFlag)
&& featureFlagReport != nil)
// ABORT_HISTORICAL_TRANSMITS (20) over puffin: stop an offload already in flight. Allowed
// ONLY while one actually is, so a default install can never form these bytes on a 5/MG —
// and the gate is the same state the command is about. Non-destructive: the strap frees
// records on our HISTORY_END ack, not on this, so an aborted drain re-offloads intact.
|| (command == .abortHistoricalTransmits && backfilling)
|| command == .sendHistoricalData || command == .historicalDataResult
|| command == .setClock || command == .getClock
// SET_CONFIG (the R22 deep-stream unlock) is allowed ONLY while the deep-data
Expand Down Expand Up @@ -1765,6 +1770,49 @@ public final class BLEManager: NSObject, ObservableObject {
/// is the primary metric source now, mirroring how WHOOP syncs. Live HR is opt-in only (the
/// manual "Start HR" button in LiveView). Between backfills the Collector sees only the live
/// type-43 flood, which extractStreams ignores — the data comes from the next periodic offload.
/// Stop an offload that is part-way through, at the user's request.
///
/// NOOP has been able to START a drain since day one (`sendHistoricalData`) with no way to stop it:
/// a session ran to HISTORY_COMPLETE, the backfill timeout, or a dropped link. On a strap with a
/// large banked history that is a long time to be stuck, and the only escape was walking out of
/// Bluetooth range.
///
/// NOTHING IS LOST. The strap frees banked records when NOOP acks a HISTORY_END, so records already
/// acked are persisted and records not yet acked stay in flash and re-offload on the next sync. This
/// is a stop, not a trim, and no cursor moves.
///
/// The local teardown does NOT depend on the strap honouring opcode 20. `exitBackfilling` runs
/// either way — the same path the timeout and disconnect already use, including the drain loop's
/// `!backfilling` check that discards queued frames — so a firmware that ignores the abort degrades
/// to exactly today's behaviour (frames keep arriving and are dropped) rather than leaving the UI
/// wedged in "Syncing". That is deliberate: the opcode is confirmed in use on WHOOP 4.0 by OpenStrap
/// Edge but is NOT hardware-confirmed here, on either family.
///
/// Twin of Android `abortBackfill()`.
public func abortBackfill() {
guard backfilling else {
log("Abort sync ignored — no offload in flight")
return
}
// Send before tearing down: the 5/MG allowlist admits opcode 20 only while `backfilling` is
// still true, so ordering here is load-bearing, not stylistic.
if state.connected {
// Body `[0x00]`, matching the only hands-on use of this opcode (OpenStrap Edge sends
// `const [0x00]`) rather than an empty body — the same b3 convention `sendHistoricalData`
// already uses here, where an empty body was verified NOT to work.
send(.abortHistoricalTransmits, payload: [0x00], writeType: .withResponse)
log("Abort sync: ABORT_HISTORICAL_TRANSMITS (20) sent; unacked records stay on the strap")
} else {
log("Abort sync: not connected — tearing down the local session only")
}
// The reason string is deliberately NOT one `exitBackfilling` classifies. Only "HISTORY_COMPLETE"
// stamps `lastSyncedAt` and only "timeout" raises a sync error; everything else falls through
// leaving both untouched — which is exactly right for an abort. A cancelled sync is neither a
// success nor a failure: nothing was lost, and the next sync re-offloads what was left.
// If a future edit starts classifying more reasons, this one must stay in the fall-through.
exitBackfilling(reason: "aborted by user")
}

private func exitBackfilling(reason: String) {
guard backfilling else { return }
backfilling = false
Expand Down
14 changes: 14 additions & 0 deletions Strand/BLE/Commands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,19 @@ public enum WhoopCommand: UInt8, CaseIterable {
case reportVersionInfo = 7
case setClock = 10
case getClock = 11
/// ABORT_HISTORICAL_TRANSMITS (20) — ask the strap to stop streaming the offload it is part-way
/// through. NON-DESTRUCTIVE, and specifically not a trim: the strap only frees banked records when
/// NOOP acks a HISTORY_END, so anything unacked when the abort lands stays in flash and re-offloads
/// on the next sync. Nothing is deleted and no cursor moves.
///
/// The counterpart to `sendHistoricalData` (22), which NOOP has always had with no way to stop it:
/// until now a drain ran to completion, the 15-minute timeout, or a dropped link.
///
/// `BLEManager.abortBackfill()` tears the session down LOCALLY whether or not the strap honours the
/// opcode, so a firmware that ignores 20 degrades to exactly today's behaviour rather than leaving
/// the UI stuck. Confirmed in use on WHOOP 4.0 by OpenStrap Edge (`Cmd.abortHistoricalTransmits`);
/// the 5/MG form is the same opcode over puffin framing and is NOT hardware-confirmed here.
case abortHistoricalTransmits = 20
case sendHistoricalData = 22
case historicalDataResult = 23
case getBatteryLevel = 26
Expand Down Expand Up @@ -146,6 +159,7 @@ public enum WhoopCommand: UInt8, CaseIterable {
case .reportVersionInfo: return "Report Version Info"
case .setClock: return "Set Clock"
case .getClock: return "Get Clock"
case .abortHistoricalTransmits: return "Abort Historical Transmits"
case .sendHistoricalData: return "Send Historical Data"
case .historicalDataResult: return "Historical Data Result"
case .getBatteryLevel: return "Get Battery Level"
Expand Down
52 changes: 52 additions & 0 deletions Strand/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -127078,6 +127078,58 @@
}
}
},
"Stop sync": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Sync stoppen"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Detener sincronización"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Arrêter la synchronisation"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Interrompi sincronizzazione"
}
},
"pt-PT": {
"stringUnit": {
"state": "translated",
"value": "Parar sincronização"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Остановить синхронизацию"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "停止同步"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "停止同步"
}
}
}
},
"Stops reading a cycle phase from your nightly temperature. You can turn it back on here any time.": {
"localizations": {
"de": {
Expand Down
15 changes: 14 additions & 1 deletion Strand/Screens/DevicesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,14 @@ private struct DevicesContent: View {
onBodyLocationProbe: probeGate ? { bodyLocationProbeTarget = device } : nil,
// #761 feature-flag ENUMERATION probe: read-only (names only, nothing written), both
// families. Same Test Centre → Connection gate.
onFeatureFlagProbe: probeGate ? { featureFlagProbeTarget = device } : nil)
onFeatureFlagProbe: probeGate ? { featureFlagProbeTarget = device } : nil,
// Stop an offload already in flight. Offered ONLY while one is running on this
// strap — not a Test Centre probe but an ordinary escape hatch, because until now
// a long drain could only be ended by the 15-minute timeout or walking out of
// range. Nothing is lost: unacked records stay on the strap.
onAbortSync: (device.status == .active && live.connected && live.backfilling
&& SourceCoordinator.isWhoop(device))
? { model.ble.abortBackfill() } : nil)
.staggeredAppear(index: idx)
}

Expand Down Expand Up @@ -483,6 +490,7 @@ private struct DeviceCard: View {
/// #761 feature-flag ENUMERATION probe (Test Centre → Connection, both WHOOP families). Read-only:
/// it reads the flag NAMES the strap's firmware knows and writes nothing.
var onFeatureFlagProbe: (() -> Void)? = nil
var onAbortSync: (() -> Void)? = nil
/// Removed-section affordances (re-add as active / delete its data).
var onReAdd: (() -> Void)? = nil
var onDeleteData: (() -> Void)? = nil
Expand Down Expand Up @@ -721,6 +729,11 @@ private struct DeviceCard: View {
if isLiveConnected, SourceCoordinator.isWhoop(device), let onReboot {
Button { onReboot() } label: { Label("Restart strap…", systemImage: "arrow.clockwise") }
}
// Stop a sync that is part-way through. Present only while this strap is actually
// offloading; the parent owns that condition.
if let onAbortSync {
Button { onAbortSync() } label: { Label("Stop sync", systemImage: "stop.circle") }
}
// 4.0 reboot probe (RE): only present when the parent passed a closure (Test Centre →
// Connection on + a live WHOOP 4.0). Finds the real reboot frame the 4.0 accepts (#235).
if let onRebootProbe {
Expand Down
51 changes: 51 additions & 0 deletions android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -865,6 +865,16 @@ class WhoopBleClient(
fun canRequestSync(connected: Boolean, bonded: Boolean, backfilling: Boolean): Boolean =
connected && bonded && !backfilling

/**
* The mirror gate for stopping one: an abort is only meaningful while an offload is actually
* running. Pure for the same reason as [canRequestSync] — the menu's visibility and the
* client's own guard must not drift apart, and neither needs a live GATT stack to test.
*
* Note it does NOT require [connected]. A user can abort a session whose link has already
* dropped; there is simply no opcode to send, and the local teardown is the whole point.
*/
fun canAbortSync(backfilling: Boolean): Boolean = backfilling

/**
* #314: should a Throwable that escaped a raw GATT call trigger a full link teardown?
*
Expand Down Expand Up @@ -2764,6 +2774,11 @@ class WhoopBleClient(
// (whoop5HistoricalAckFrame = puffinCommandFrame(23, [0x01]+endData)). (#78)
if (cmd != CommandNumber.TOGGLE_REALTIME_HR && cmd != CommandNumber.RUN_HAPTICS_PATTERN &&
cmd != CommandNumber.SEND_HISTORICAL_DATA && cmd != CommandNumber.HISTORICAL_DATA_RESULT &&
// ABORT_HISTORICAL_TRANSMITS (20) over puffin: stop an offload already in flight. Allowed
// ONLY while one actually is, so a default install can never form these bytes on a 5/MG —
// and the gate is the same state the command is about. Non-destructive: the strap frees
// records on our HISTORY_END ack, not on this, so an aborted drain re-offloads intact.
!(cmd == CommandNumber.ABORT_HISTORICAL_TRANSMITS && backfilling) &&
cmd != CommandNumber.SET_CLOCK && cmd != CommandNumber.GET_CLOCK &&
cmd != CommandNumber.GET_DATA_RANGE &&
cmd != CommandNumber.SET_ALARM_TIME && cmd != CommandNumber.DISABLE_ALARM &&
Expand Down Expand Up @@ -6049,6 +6064,42 @@ class WhoopBleClient(
exitBackfilling("timeout")
}

/**
* Stop an offload that is part-way through, at the user's request.
*
* NOOP has been able to START a drain since day one (SEND_HISTORICAL_DATA) with no way to stop it:
* a session ran to HISTORY_COMPLETE, the backfill timeout, or a dropped link.
*
* NOTHING IS LOST. The strap frees banked records when we ack a HISTORY_END, so acked records are
* persisted and unacked ones stay in flash and re-offload next sync. A stop, not a trim.
*
* The local teardown does NOT depend on the strap honouring opcode 20 — [exitBackfilling] runs
* either way, the same path the timeout and disconnect already use — so a firmware that ignores
* the abort degrades to today's behaviour rather than wedging the UI in "Syncing". Deliberate: the
* opcode is confirmed in use on WHOOP 4.0 by OpenStrap Edge, and is NOT hardware-confirmed here on
* either family. Twin of Swift `BLEManager.abortBackfill()`.
*/
fun abortBackfill() {
if (!canAbortSync(backfilling)) {
log("Abort sync ignored — no offload in flight")
return
}
// Send BEFORE tearing down: the 5/MG allow-list admits opcode 20 only while `backfilling` is
// still true, so the ordering here is load-bearing, not stylistic.
if (_state.value.connected) {
send(CommandNumber.ABORT_HISTORICAL_TRANSMITS, byteArrayOf(0), withResponse = true)
log("Abort sync: ABORT_HISTORICAL_TRANSMITS (20) sent; unacked records stay on the strap")
} else {
log("Abort sync: not connected — tearing down the local session only")
}
// The reason string is deliberately NOT one [exitBackfilling] classifies. Only "HISTORY_COMPLETE"
// stamps lastSyncAt and only "timeout" raises a sync error; everything else hits the `else` branch
// and leaves both untouched — exactly right for an abort. A cancelled sync is neither a success
// nor a failure: nothing was lost, and the next sync re-offloads what was left. If a future edit
// starts classifying more reasons, this one must stay in the fall-through. Twin of the Swift note.
exitBackfilling("aborted by user")
}

/** Tear down the backfill session. Port of `exitBackfilling`. Does NOT auto-start live HR. */
private fun exitBackfilling(reason: String) {
if (!backfilling) return
Expand Down
6 changes: 6 additions & 0 deletions android/app/src/main/java/com/noop/protocol/Enums.kt
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,12 @@ enum class CommandNumber(val rawValue: Int) {
REPORT_VERSION_INFO(7),
SET_CLOCK(10),
GET_CLOCK(11),
// ABORT_HISTORICAL_TRANSMITS (20) — stop an offload part-way through. NON-DESTRUCTIVE and NOT a
// trim: the strap frees banked records when we ack a HISTORY_END, so anything unacked when the
// abort lands stays in flash and re-offloads next sync. Body [0x00], matching the only hands-on
// use of the opcode (OpenStrap Edge on a WHOOP 4.0). Mirrors Swift
// `WhoopCommand.abortHistoricalTransmits`.
ABORT_HISTORICAL_TRANSMITS(20),
SEND_HISTORICAL_DATA(22),
// The historical-offload trim/ack command. Sent (with response) to confirm one HISTORY_END
// chunk so the strap may trim it; payload = [0x01] + the verbatim 8-byte HISTORY_END end_data.
Expand Down
3 changes: 3 additions & 0 deletions android/app/src/main/java/com/noop/ui/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1878,6 +1878,9 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
* User-initiated, Test-Centre-gated in DevicesScreen; the report goes to the dialog + strap log. */
fun probeFeatureFlags() = ble.probeFeatureFlags()

/** Stop an offload part-way through (#ABORT). Twin of Swift `AppModel`/`BLEManager.abortBackfill()`. */
fun abortBackfill() = ble.abortBackfill()

/** #761 probe report text (null until the walk finishes; waiting sentinel while it runs). */
val featureFlagProbe = ble.featureFlagProbe

Expand Down
14 changes: 14 additions & 0 deletions android/app/src/main/java/com/noop/ui/DevicesScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.RemoveCircleOutline
import androidx.compose.material.icons.filled.StopCircle
import androidx.compose.material.icons.filled.Watch
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
Expand Down Expand Up @@ -275,6 +276,13 @@ fun DevicesScreen(
SourceCoordinator.isWhoop(device) &&
TestCentre.from(context).active(TestDomain.CONNECTION)
) { { featureFlagProbeTarget = device } } else null,
// Stop an offload already in flight. Offered ONLY while one is running on this strap —
// not a Test Centre probe but an ordinary escape hatch, because until now a long drain
// could only be ended by the timeout or walking out of range. Nothing is lost: unacked
// records stay on the strap.
onAbortSync = if (device.status == DeviceStatus.active.name && live.connected &&
WhoopBleClient.canAbortSync(live.backfilling) && SourceCoordinator.isWhoop(device)
) { { viewModel.abortBackfill() } } else null,
)
}

Expand Down Expand Up @@ -508,6 +516,7 @@ private fun DeviceCard(
/** #761 feature-flag ENUMERATION probe (Test Centre → Connection, both WHOOP families). Read-only:
* it reads the flag NAMES the strap's firmware knows and writes nothing. */
onFeatureFlagProbe: (() -> Unit)? = null,
onAbortSync: (() -> Unit)? = null,
) {
val profile = deviceProfile(device)
// The per-device actions menu's open state is hoisted here so the WHOLE card is a tap target that opens
Expand Down Expand Up @@ -625,6 +634,7 @@ private fun DeviceCard(
onBatteryProbe = onBatteryProbe,
onBodyLocationProbe = onBodyLocationProbe,
onFeatureFlagProbe = onFeatureFlagProbe,
onAbortSync = onAbortSync,
)
}
}
Expand Down Expand Up @@ -748,6 +758,7 @@ private fun DeviceActionsMenu(
/** #761 feature-flag ENUMERATION probe (Test Centre → Connection, both WHOOP families). Read-only:
* it reads the flag NAMES the strap's firmware knows and writes nothing. */
onFeatureFlagProbe: (() -> Unit)? = null,
onAbortSync: (() -> Unit)? = null,
) {
Box {
IconButton(
Expand Down Expand Up @@ -806,6 +817,9 @@ private fun DeviceActionsMenu(
MenuItem(uiString(R.string.l10n_devices_screen_body_location_probe_690_re_7def8c39), Icons.Filled.BugReport) { onOpenChange(false); onBodyLocationProbe() }
}
// #761 feature-flag ENUMERATION probe (RE): read-only key-name listing, both families.
if (onAbortSync != null) {
MenuItem(uiString(R.string.l10n_devices_screen_stop_sync_4a1f2b6e), Icons.Filled.StopCircle) { onOpenChange(false); onAbortSync() }
}
if (onFeatureFlagProbe != null) {
MenuItem(uiString(R.string.l10n_devices_screen_feature_flag_probe_761_re_21241d68), Icons.Filled.BugReport) { onOpenChange(false); onFeatureFlagProbe() }
}
Expand Down
Loading