From 5fb8885c998de9ae06b7eec15377a416dab33c1a Mon Sep 17 00:00:00 2001 From: ryanbr Date: Mon, 27 Jul 2026 00:16:21 -0700 Subject: [PATCH 1/3] Let a user stop a sync that is part-way through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NOOP has been able to START an offload since day one (SEND_HISTORICAL_DATA, 22) with no way to stop one. A session ended on HISTORY_COMPLETE, the 15-minute backfill timeout, or a dropped link — so on a strap with a large banked history the only escape was walking out of Bluetooth range. ABORT_HISTORICAL_TRANSMITS (20) is the counterpart, named in whoop_protocol.json since the table was imported and never wired up. Found by comparing against OpenStrap Edge, which sends it on WHOOP 4.0 as `Cmd.abortHistoricalTransmits` with body [0x00] — matched here rather than guessing an empty body, since that is the only hands-on use of the opcode and sendHistoricalData already proved an empty body does not work. NOTHING IS LOST. This is a stop, not a trim: the strap frees banked records when NOOP acks a HISTORY_END, so acked records are persisted and unacked ones stay in flash and re-offload on the next sync. No cursor moves and nothing is deleted. The local teardown does NOT depend on the strap honouring the opcode. 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 20 degrades to exactly today's behaviour rather than leaving the UI wedged in "Syncing". That matters because the opcode is confirmed in use on 4.0 by Edge but is NOT hardware-confirmed here, on either family. Send ordering is load-bearing, not stylistic: the 5/MG allow-list admits opcode 20 only while `backfilling` is still true, so the command goes out before the teardown flips it. The menu entry appears only while that strap is actually offloading. Not Test Centre gated — this is an ordinary escape hatch, not a probe — and the in-flight condition is a tighter gate than a settings toggle anyway. canAbortSync is a pure predicate beside canRequestSync, for the same reason that one exists: the menu's visibility and the client's own guard must not drift apart. It deliberately does not require a live link — a user can cancel a session whose link already dropped, and then there is simply no opcode to send. Two tests, including one asserting the start and stop gates are mutually exclusive. Strings added to the Apple catalog in all eight languages and to all six Android locale files. NOT VERIFIED ON HARDWARE, on either family, and no CI job compiles either half. Every symbol was checked against its declaration by hand. Two stacked KDoc blocks were caught by source-hygiene on the way — inserting between an existing doc comment and its declaration, twice in one change. --- Strand/BLE/BLEManager.swift | 43 +++++++++++++++ Strand/BLE/Commands.swift | 14 +++++ Strand/Resources/Localizable.xcstrings | 52 +++++++++++++++++++ Strand/Screens/DevicesView.swift | 15 +++++- .../main/java/com/noop/ble/WhoopBleClient.kt | 46 ++++++++++++++++ .../src/main/java/com/noop/protocol/Enums.kt | 6 +++ .../src/main/java/com/noop/ui/AppViewModel.kt | 3 ++ .../main/java/com/noop/ui/DevicesScreen.kt | 14 +++++ .../app/src/main/res/values-de/strings.xml | 1 + .../app/src/main/res/values-es/strings.xml | 1 + .../app/src/main/res/values-fr/strings.xml | 1 + .../src/main/res/values-pt-rPT/strings.xml | 1 + .../app/src/main/res/values-zh/strings.xml | 1 + android/app/src/main/res/values/strings.xml | 1 + .../test/java/com/noop/ble/SyncNowGateTest.kt | 31 +++++++++++ 15 files changed, 229 insertions(+), 1 deletion(-) diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index 1cb53e3967..124ff3d586 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,44 @@ 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") + } + 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..1eec836d93 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,37 @@ 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") + } + 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..6d138018b4 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 && + 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, + ) + } + } +} From 73d22e67117bf73ba5fdb318c1274e2e58a0049d Mon Sep 17 00:00:00 2001 From: ryanbr Date: Mon, 27 Jul 2026 00:22:00 -0700 Subject: [PATCH 2/3] Make the menu actually share canAbortSync, as its own doc claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-reviewing this PR: the KDoc on canAbortSync says it is pure so that "the menu's visibility and the client's own guard must not drift apart" — and then the menu tested `live.backfilling` directly and never called it. The predicate was used only by the client and its tests, so the stated reason for its existence was not true of the code. That is not how the precedent works. canRequestSync earns the same claim honestly: TodayProvenance.kt calls it from the UI layer and WhoopBleClient calls it internally, so the two genuinely cannot diverge. The menu now calls canAbortSync the same way. Verified while re-reading rather than assumed: the Kotlin allow-list is a NEGATIVE chain (`if (cmd != A && … ) drop`) where the Swift one is a POSITIVE guard, so the two clauses had to be written in opposite polarity — `!(cmd == ABORT && backfilling)` against `|| (command == .abortHistoricalTransmits && backfilling)`. Both reduce to "admit opcode 20 only mid-offload", checked by simulating the reduced expression. backfilling, send() and abortBackfill() are all members of WhoopBleClient; LiveState carries `backfilling`; the WhoopBleClient import was already present and is not duplicated. Comment-and-wiring only, no behaviour change: the menu condition is the same boolean, now routed through the predicate that documents it. --- android/app/src/main/java/com/noop/ui/DevicesScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 6d138018b4..0a9b751e98 100644 --- a/android/app/src/main/java/com/noop/ui/DevicesScreen.kt +++ b/android/app/src/main/java/com/noop/ui/DevicesScreen.kt @@ -281,7 +281,7 @@ fun DevicesScreen( // 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 && - live.backfilling && SourceCoordinator.isWhoop(device) + WhoopBleClient.canAbortSync(live.backfilling) && SourceCoordinator.isWhoop(device) ) { { viewModel.abortBackfill() } } else null, ) } From 2775d506163c2fcf360291e6804b847c0e2204ad Mon Sep 17 00:00:00 2001 From: ryanbr Date: Mon, 27 Jul 2026 00:25:41 -0700 Subject: [PATCH 3/3] Pin why "aborted by user" must stay an unclassified exit reason MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third re-review pass. The outcome side of exitBackfilling turned out to be correct but ACCIDENTALLY so, which is the same shape as the last finding: right behaviour resting on something unstated. Both platforms classify the teardown reason. Only "HISTORY_COMPLETE" stamps lastSyncAt/lastSyncedAt and clears the error; only "timeout" raises "the strap went quiet"; every other reason falls through untouched. That fall-through 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. Verified on both sides, including that Swift renders a sensible "aborted by user rows=N" in the Test Centre offload line. But it holds only because the string happens not to match a case. Someone adding a case, or making the else stricter, would silently start reporting a cancelled sync as a completed one — stamping a lastSyncAt the strap never earned. Comment says so at both call sites now. Also checked and fine: historicalKickSent resets at session START, so an abort cannot leave the next offload unable to kick. Comments only. --- Strand/BLE/BLEManager.swift | 5 +++++ android/app/src/main/java/com/noop/ble/WhoopBleClient.kt | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index 124ff3d586..430e9535a3 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -1805,6 +1805,11 @@ public final class BLEManager: NSObject, ObservableObject { } 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") } 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 1eec836d93..10a03424e8 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -6092,6 +6092,11 @@ class WhoopBleClient( } 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") }