diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift
index 1cb53e3967..430e9535a3 100644
--- a/Strand/BLE/BLEManager.swift
+++ b/Strand/BLE/BLEManager.swift
@@ -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
@@ -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
diff --git a/Strand/BLE/Commands.swift b/Strand/BLE/Commands.swift
index e3c67cb3ac..2345235d74 100644
--- a/Strand/BLE/Commands.swift
+++ b/Strand/BLE/Commands.swift
@@ -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
@@ -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"
diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings
index 6f0cc70b45..df004a5603 100644
--- a/Strand/Resources/Localizable.xcstrings
+++ b/Strand/Resources/Localizable.xcstrings
@@ -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": {
diff --git a/Strand/Screens/DevicesView.swift b/Strand/Screens/DevicesView.swift
index 28ecc5a54e..a72b9a39e8 100644
--- a/Strand/Screens/DevicesView.swift
+++ b/Strand/Screens/DevicesView.swift
@@ -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)
}
@@ -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
@@ -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 {
diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
index 7d5a73e95b..10a03424e8 100644
--- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
+++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
@@ -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?
*
@@ -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 &&
@@ -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
diff --git a/android/app/src/main/java/com/noop/protocol/Enums.kt b/android/app/src/main/java/com/noop/protocol/Enums.kt
index 2b0c50589f..4efe34d639 100644
--- a/android/app/src/main/java/com/noop/protocol/Enums.kt
+++ b/android/app/src/main/java/com/noop/protocol/Enums.kt
@@ -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.
diff --git a/android/app/src/main/java/com/noop/ui/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt
index a88980ccc9..585ab2eb22 100644
--- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt
+++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt
@@ -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
diff --git a/android/app/src/main/java/com/noop/ui/DevicesScreen.kt b/android/app/src/main/java/com/noop/ui/DevicesScreen.kt
index efc832e6da..0a9b751e98 100644
--- a/android/app/src/main/java/com/noop/ui/DevicesScreen.kt
+++ b/android/app/src/main/java/com/noop/ui/DevicesScreen.kt
@@ -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
@@ -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,
)
}
@@ -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
@@ -625,6 +634,7 @@ private fun DeviceCard(
onBatteryProbe = onBatteryProbe,
onBodyLocationProbe = onBodyLocationProbe,
onFeatureFlagProbe = onFeatureFlagProbe,
+ onAbortSync = onAbortSync,
)
}
}
@@ -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(
@@ -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() }
}
diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml
index c1f915e9e7..de5a115022 100644
--- a/android/app/src/main/res/values-de/strings.xml
+++ b/android/app/src/main/res/values-de/strings.xml
@@ -391,6 +391,7 @@
Ergebnis des Körperpositions-Tests (#690)
Sendet den nur lesenden Körperpositions-Befehl (0x54) und schreibt die vollständige Rohantwort des Bands ins Strap-Log, wobei der Datensatz (Revision / Position / Konfidenz / Status) auf der WHOOP 4.0 dekodiert wird. Es wird nichts auf das Band geschrieben, und Trageerkennung oder Bewertung werden nie verändert.
Feature-Flag-Test (#761 RE)…
+ Sync stoppen
Ergebnis des Feature-Flag-Tests (#761)
Fragt das Band nach den NAMEN der Feature-Flags, die seine eigene Firmware kennt: START_FF_KEY_EXCHANGE (0x75), dann SEND_NEXT_FF (0x76), bis das Band das Ende meldet. Nur lesend — es wird kein Flag-Wert geschrieben, und die SET-Befehle werden von diesem Test nie gesendet. Die Liste wird hier angezeigt und ins Strap-Log geschrieben.
Zwei unabhängige Protokolltabellen widersprechen sich beim Extended-Battery-Opcode (98 vs. 87). Dies sendet das kuratierte, nur lesende 98 und schreibt die vollständige Rohantwort des Bands ins Strap-Log. Eine batterieartige Nutzlast bestätigt 98 auf deiner Firmware; ein kurzer Stub bleibt mehrdeutig. Es wird nichts auf das Band geschrieben. Bitte exportiere und teile danach das Strap-Log.
diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml
index 77f1140e1f..d24fb769b4 100644
--- a/android/app/src/main/res/values-es/strings.xml
+++ b/android/app/src/main/res/values-es/strings.xml
@@ -231,6 +231,7 @@
Resultado de la sonda de posición corporal (#690)
Envía el comando de posición corporal de solo lectura (0x54) y vuelca la respuesta cruda completa de la banda al registro, decodificando el registro (revisión / posición / confianza / estado) en la WHOOP 4.0. No se escribe nada en la banda y nunca cambia la detección de uso ni la puntuación.
Sonda de indicadores de función (#761 RE)…
+ Detener sincronización
Resultado de la sonda de indicadores de función (#761)
Pide a la banda que enumere los NOMBRES de los indicadores de función que conoce su propio firmware: START_FF_KEY_EXCHANGE (0x75) y luego SEND_NEXT_FF (0x76) hasta que la banda indique que ha terminado. Solo lectura — no se escribe ningún valor y esta sonda nunca envía los comandos SET. La lista se muestra aquí y se escribe en el registro de la banda.
Dos tablas de protocolo independientes discrepan sobre el opcode de batería extendida (98 vs 87). Esto envía el 98 curado y de solo lectura y vuelca la respuesta cruda completa de la banda al registro. Una carga tipo batería confirma 98 en tu firmware; un stub corto lo deja ambiguo. No se escribe nada en la banda. Exporta y comparte el registro después.
diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml
index 4c3623be9b..2e186ec2ca 100644
--- a/android/app/src/main/res/values-fr/strings.xml
+++ b/android/app/src/main/res/values-fr/strings.xml
@@ -231,6 +231,7 @@
Résultat de la sonde de position corporelle (#690)
Envoie la commande de position corporelle en lecture seule (0x54) et consigne la réponse brute complète du bracelet dans le journal, en décodant l\'enregistrement (révision / position / confiance / état) sur la WHOOP 4.0. Rien n\'est écrit sur le bracelet, et la détection de port ou la notation ne sont jamais modifiées.
Sonde des indicateurs de fonction (#761 RE)…
+ Arrêter la synchronisation
Résultat de la sonde des indicateurs de fonction (#761)
Demande au bracelet la liste des NOMS des indicateurs de fonction que son propre micrologiciel connaît : START_FF_KEY_EXCHANGE (0x75) puis SEND_NEXT_FF (0x76) jusqu\'à ce que le bracelet signale la fin. Lecture seule — aucune valeur n\'est écrite, et les commandes SET ne sont jamais envoyées par cette sonde. La liste s\'affiche ici et est consignée dans le journal du bracelet.
Deux tables de protocole indépendantes divergent sur l\'opcode de batterie étendue (98 contre 87). Ceci envoie le 98 curé en lecture seule et consigne la réponse brute complète du bracelet dans le journal. Une charge de type batterie confirme 98 sur votre firmware ; un stub court le laisse ambigu. Rien n\'est écrit sur le bracelet. Exportez et partagez le journal ensuite.
diff --git a/android/app/src/main/res/values-pt-rPT/strings.xml b/android/app/src/main/res/values-pt-rPT/strings.xml
index ece4e3259a..2a9a73055a 100644
--- a/android/app/src/main/res/values-pt-rPT/strings.xml
+++ b/android/app/src/main/res/values-pt-rPT/strings.xml
@@ -374,6 +374,7 @@
Resultado da sonda de localização corporal (#690)
Envia o comando body-location de leitura apenas (0x54) e regista a resposta bruta completa do bracelete no registo da bracelete, descodificando o registo (revisão/localização/confiança/estado) no WHOOP 4.0. Nada é escrito na bracelete e nunca altera a deteção ou pontuação de desgaste.
Sonda de sinalizadores de funcionalidade (#761 RE)…
+ Parar sincronização
Resultado da sonda de sinalizadores de funcionalidade (#761)
Pede à bracelete a lista dos NOMES dos sinalizadores de funcionalidade que o seu próprio firmware conhece: START_FF_KEY_EXCHANGE (0x75) e depois SEND_NEXT_FF (0x76) até a bracelete indicar que terminou. Apenas leitura — nenhum valor é escrito e os comandos SET nunca são enviados por esta sonda. A lista é mostrada aqui e registada no registo da bracelete.
Duas tabelas de protocolo independentes discordam quanto ao opcode de bateria alargada (98 vs 87). Isto envia o 98 de leitura, validado e regista a resposta bruta completa do bracelete no registo da bracelete. Uma carga útil tipo bateria confirma 98 no teu firmware; um esboço curto significa que permanece ambíguo. Nada é escrito na bracelete. Exporte e partilhe o registo da bracelete posteriormente.
diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml
index 78d5ae0cf9..bb65b473df 100644
--- a/android/app/src/main/res/values-zh/strings.xml
+++ b/android/app/src/main/res/values-zh/strings.xml
@@ -368,6 +368,7 @@
电量 %1$s%%
取消
功能标志探测(#761 RE)…
+ 停止同步
功能标志探测结果(#761)
请求手环列出其固件已知的功能标志名称:先发送 START_FF_KEY_EXCHANGE (0x75),再重复发送 SEND_NEXT_FF (0x76),直到手环表示已列完。只读——不会写入任何标志值,本探测也从不发送 SET 命令。列表会显示在此处并写入手环日志。
%1$s%%
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 4c27a0f1f9..213a987ce1 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -400,6 +400,7 @@
Body-location probe result (#690)
Sends the read-only body-location command (0x54) and dumps the strap\'s full raw reply to the strap log, decoding the record (revision / location / confidence / status) on WHOOP 4.0. Nothing is written to the strap, and it never changes wear detection or scoring.
Feature-flag probe (#761 RE)…
+ Stop sync
Feature-flag probe result (#761)
Asks the strap to list the feature-flag NAMES its own firmware knows: START_FF_KEY_EXCHANGE (0x75) then SEND_NEXT_FF (0x76) until the strap says it\'s done. Read-only — no flag value is written, and the SET commands are never sent from this probe. The list is shown here and written to the strap log.
Two independent protocol tables disagree on the extended-battery opcode (98 vs 87). This sends the curated read-only 98 and dumps the strap\'s full raw reply to the strap log. A battery-style payload confirms 98 on your firmware; a short stub means it stays ambiguous. Nothing is written to the strap. Please export and share the strap log afterwards.
diff --git a/android/app/src/test/java/com/noop/ble/SyncNowGateTest.kt b/android/app/src/test/java/com/noop/ble/SyncNowGateTest.kt
index 07df9b77f4..b284c09b93 100644
--- a/android/app/src/test/java/com/noop/ble/SyncNowGateTest.kt
+++ b/android/app/src/test/java/com/noop/ble/SyncNowGateTest.kt
@@ -1,6 +1,7 @@
package com.noop.ble
import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -44,3 +45,33 @@ class SyncNowGateTest {
assertFalse(WhoopBleClient.canRequestSync(connected = false, bonded = false, backfilling = true))
}
}
+
+/**
+ * #ABORT: the mirror of [WhoopBleClient.canRequestSync]. A sync may only be STOPPED while one is
+ * actually running, and — unlike starting one — stopping deliberately does not require a live link:
+ * the local teardown is the point, and a strap that never hears the opcode simply keeps streaming
+ * into a session NOOP no longer treats as active.
+ */
+class AbortSyncGateTest {
+
+ @Test
+ fun abortIsOfferedOnlyWhileAnOffloadIsRunning() {
+ assertTrue(WhoopBleClient.canAbortSync(backfilling = true))
+ assertFalse(WhoopBleClient.canAbortSync(backfilling = false))
+ }
+
+ @Test
+ fun startAndStopGatesAreMutuallyExclusive() {
+ // Whatever the link state, exactly one of "you may start a sync" and "you may stop one" holds
+ // for a bonded, connected strap — they must never both be true or both be false.
+ for (backfilling in listOf(true, false)) {
+ val canStart = WhoopBleClient.canRequestSync(
+ connected = true, bonded = true, backfilling = backfilling,
+ )
+ val canStop = WhoopBleClient.canAbortSync(backfilling = backfilling)
+ assertNotEquals(
+ "start and stop gates disagree for backfilling=$backfilling", canStart, canStop,
+ )
+ }
+ }
+}