From 420433af7cbca9747fdd4218fa5c309a785865fa Mon Sep 17 00:00:00 2001 From: don86nl Date: Wed, 23 Sep 2026 00:28:49 +0200 Subject: [PATCH] feat(ble): support direct HR broadcast on WHOOP 4 --- Strand/BLE/BLEManager.swift | 23 ++++---- Strand/BLE/Commands.swift | 7 +++ Strand/BLE/FrameRouter.swift | 11 +++- Strand/BLE/PuffinExperiment.swift | 8 +-- Strand/Screens/DataSourcesView.swift | 16 +++++- StrandTests/BroadcastHrCommandTests.swift | 23 ++++++++ .../java/com/noop/ble/PuffinExperiment.kt | 6 +- .../main/java/com/noop/ble/WhoopBleClient.kt | 36 ++++++++---- .../src/main/java/com/noop/protocol/Enums.kt | 5 ++ .../java/com/noop/ui/DataSourcesScreen.kt | 55 ++++++++++++++++++- .../main/java/com/noop/ui/SettingsScreen.kt | 3 +- .../com/noop/ui/WhoopModelComparisonScreen.kt | 17 +++--- .../app/src/main/res/values-de/strings.xml | 3 + .../app/src/main/res/values-es/strings.xml | 3 + .../app/src/main/res/values-fr/strings.xml | 3 + .../app/src/main/res/values-pl/strings.xml | 3 + .../src/main/res/values-pt-rPT/strings.xml | 3 + .../app/src/main/res/values-ru/strings.xml | 3 + .../app/src/main/res/values-zh/strings.xml | 3 + android/app/src/main/res/values/strings.xml | 3 + .../noop/protocol/BroadcastHrConfigTest.kt | 19 +++++++ 21 files changed, 210 insertions(+), 43 deletions(-) create mode 100644 StrandTests/BroadcastHrCommandTests.swift diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index e1f8d955a7..b0ceeb6002 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -3637,18 +3637,19 @@ public final class BLEManager: NSObject, ObservableObject { finishR22Disable() } - /// EXPERIMENTAL (#181): make a bonded WHOOP 5/MG advertise its heart rate as a standard BLE HR - /// sensor (0x180D + the live HR in its manufacturer data) by writing the device-config flag - /// `whoop_live_hr_in_adv_ind_pkt` = "1" (on) / "0" (off) via SET_DEVICE_CONFIG (0x77). With it on, a - /// Garmin (Edge/watch), Zwift or gym HR client can pair to the WHOOP directly during a workout. - /// Validated on real hardware (paired on a Garmin Edge 840). Opt-in, reversible; unlike R22 it is NOT - /// on-wrist gated. Re-applied on each 5/MG connection. iOS/Android only (macOS can't bond a 5/MG). + /// Make a bonded strap advertise as a standard BLE HR sensor. WHOOP 4 uses the reversible + /// TOGGLE_GENERIC_HR_PROFILE command; WHOOP 5/MG keeps the existing device-config path. public func setBroadcastHr(_ on: Bool) { - guard selectedModel.deviceFamily == .whoop5 else { - log("Broadcast HR: needs a WHOOP 5.0/MG strap selected — ignored."); return - } guard state.connected, state.bonded else { - log("Broadcast HR: connect and bond a 5/MG strap first — ignored."); return + log("Broadcast HR: connect and bond the strap first — ignored."); return + } + if selectedModel.deviceFamily == .whoop4 { + send(.toggleGenericHRProfile, payload: [on ? 0x01 : 0x00]) + log("Broadcast HR: WHOOP 4 \(on ? "enable" : "disable") command sent (14); effect not confirmed.") + return + } + guard selectedModel.deviceFamily == .whoop5 else { + log("Broadcast HR: strap family is not known yet — ignored."); return } // Mutually exclusive with the ECG gate: both verify over the SAME 121 read-back opcode, so if both // were in flight one strap reply would be consumed by both handlers and cross-contaminate the other's @@ -6828,6 +6829,8 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in self?.requestConnectSync() } startBackfillTimer() // re-offload the type-47 store every backfillIntervalSeconds startKeepAlive() // always-ping: re-arm realtime, poll battery, watchdog the link + // WHOOP 4's broadcast mode is link/runtime state, so restore an opted-in mode after reconnect. + if PuffinExperiment.broadcastHrEnabled { setBroadcastHr(true) } enableLiveNotifications(reason: "post-bond") // includes 0x2A37 standard HR — the fallback path // #927: RE-DERIVE the want at arm time (same reasoning as the 5/MG branch above): a reconnect // outside the overnight window must not arm the flood from a stale precomputed `wantsRealtime` diff --git a/Strand/BLE/Commands.swift b/Strand/BLE/Commands.swift index eb0f554a2d..e5b76c4671 100644 --- a/Strand/BLE/Commands.swift +++ b/Strand/BLE/Commands.swift @@ -22,6 +22,12 @@ public enum WhoopCommand: UInt8, CaseIterable { case reportVersionInfo = 7 case setClock = 10 case getClock = 11 + /// TOGGLE_GENERIC_HR_PROFILE (14) — opcode/name come from the canonical `CommandNumber` schema in + /// `Packages/WhoopProtocol/Sources/WhoopProtocol/Resources/whoop_protocol.json` and its WHOOP 4 + /// matrix in `docs/PROTOCOL_COMMANDS.md`. Payload `[0x01]` enabled standard BLE Heart Rate + /// advertising and `[0x00]` disabled it on the WHOOP 4.0 tested for #2400. Safe and reversible, + /// driven only by the explicit Broadcast strap HR opt-in; the effect has no readable confirmation. + case toggleGenericHRProfile = 14 /// 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 @@ -215,6 +221,7 @@ public enum WhoopCommand: UInt8, CaseIterable { case .reportVersionInfo: return "Report Version Info" case .setClock: return "Set Clock" case .getClock: return "Get Clock" + case .toggleGenericHRProfile:return "Toggle Generic HR Profile" case .abortHistoricalTransmits: return "Abort Historical Transmits" case .sendHistoricalData: return "Send Historical Data" case .historicalDataResult: return "Historical Data Result" diff --git a/Strand/BLE/FrameRouter.swift b/Strand/BLE/FrameRouter.swift index d95196f81b..eaadc95453 100644 --- a/Strand/BLE/FrameRouter.swift +++ b/Strand/BLE/FrameRouter.swift @@ -207,7 +207,16 @@ public final class FrameRouter { domain: .connection) } if family == .whoop4, let cmd = parsed.cmdName { - if cmd.hasPrefix("GET_ADVERTISING_NAME_HARVARD") { + if cmd.hasPrefix("TOGGLE_GENERIC_HR_PROFILE") { + // #2400: this is evidence that the strap answered opcode 14, not a read-back of the + // advertising state. Preserve the raw result byte + frame so another firmware's + // response can be compared without turning an acknowledgement into a false verdict. + let r = Self.commandResultByte(in: frame, family: family) + let rhex = r.map { String(format: "0x%02x", UInt8(truncatingIfNeeded: $0)) } ?? "none" + state.append(log: "Broadcast HR: WHOOP 4 command response received " + + "resultByte=\(rhex), effect not confirmed " + + "frame=\(Self.fullFrameHex(frame))") + } else if cmd.hasPrefix("GET_ADVERTISING_NAME_HARVARD") { if let name = Self.advertisingName(in: frame), !name.isEmpty { state.advertisingName = name } diff --git a/Strand/BLE/PuffinExperiment.swift b/Strand/BLE/PuffinExperiment.swift index 3290abd585..7b3a2e13d2 100644 --- a/Strand/BLE/PuffinExperiment.swift +++ b/Strand/BLE/PuffinExperiment.swift @@ -28,10 +28,10 @@ enum PuffinExperiment { static var deepDataEnabled: Bool { UserDefaults.standard.bool(forKey: deepDataKey) } - /// Opt-in "Broadcast heart rate": writes the device-config flag `whoop_live_hr_in_adv_ind_pkt="1"` - /// so the strap advertises the standard Heart Rate Service (0x180D) + its live HR, pairable by a - /// Garmin/Zwift/gym HR client. Reversible, default off; applied on each 5/MG connection and driven by - /// `BLEManager.setBroadcastHr(_:)`. Mirrors the Android `PuffinExperiment.KEY_BROADCAST_HR`. (#181) + /// Opt-in "Broadcast heart rate": enables the family's reversible direct-broadcast control so the + /// strap advertises the standard Heart Rate Service (0x180D), pairable by a Garmin/Zwift/gym HR + /// client. Default off; driven by `BLEManager.setBroadcastHr(_:)`. Mirrors the Android + /// `PuffinExperiment.KEY_BROADCAST_HR`. (#181) static let broadcastHrKey = "noopBroadcastHr" static var broadcastHrEnabled: Bool { UserDefaults.standard.bool(forKey: broadcastHrKey) } diff --git a/Strand/Screens/DataSourcesView.swift b/Strand/Screens/DataSourcesView.swift index 58c73fbaf5..08dc40d5f2 100644 --- a/Strand/Screens/DataSourcesView.swift +++ b/Strand/Screens/DataSourcesView.swift @@ -55,6 +55,7 @@ struct DataSourcesView: View { // LOCAL Bluetooth only — nothing leaves the device. The toggle is persisted; the broadcaster is owned // here (a pure consumer of LiveState, isolated from the WHOOP/central path). @AppStorage(HrBroadcaster.defaultsKey) private var broadcastHrEnabled = false + @AppStorage(PuffinExperiment.broadcastHrKey) private var strapBroadcastHrEnabled = false // The broadcaster's diagnostic sink forwards to THIS box, which `onAppear` points at the screen's // `live`. A reference box lets the `@StateObject` capture a stable target at init even though the @@ -932,7 +933,20 @@ struct DataSourcesView: View { tint: StrandPalette.accent, status: StatePill(label, tone: tone, pulsing: live.connected && !live.bonded), subtitle: String(localized: "Pairs directly with your strap over Bluetooth: no WHOOP app, no cloud.")) { - EmptyView() + Toggle(isOn: $strapBroadcastHrEnabled) { + VStack(alignment: .leading, spacing: 2) { + Text("Broadcast heart rate from the strap") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textPrimary) + Text("Broadcasts the strap's own live heart rate over Bluetooth.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + } + } + .toggleStyle(.switch) + .tint(StrandPalette.accent) + .accessibilityLabel("Broadcast heart rate from the strap") + .onChangeCompat(of: strapBroadcastHrEnabled) { model.ble.setBroadcastHr($0) } } } diff --git a/StrandTests/BroadcastHrCommandTests.swift b/StrandTests/BroadcastHrCommandTests.swift new file mode 100644 index 0000000000..8c38128a9b --- /dev/null +++ b/StrandTests/BroadcastHrCommandTests.swift @@ -0,0 +1,23 @@ +import Foundation +import XCTest +@testable import Strand + +final class BroadcastHrCommandTests: XCTestCase { + func testWhoop4EnableFrameMatchesExpectedBytes() { + XCTAssertEqual( + WhoopCommand.toggleGenericHRProfile.frame(seq: 8, payload: [1]).hex, + "aa0800a823080e016c935474" + ) + } + + func testWhoop4DisableFrameMatchesExpectedBytes() { + XCTAssertEqual( + WhoopCommand.toggleGenericHRProfile.frame(seq: 7, payload: [0]).hex, + "aa0800a823070e00c7e40f08" + ) + } +} + +private extension Array where Element == UInt8 { + var hex: String { map { String(format: "%02x", $0) }.joined() } +} diff --git a/android/app/src/main/java/com/noop/ble/PuffinExperiment.kt b/android/app/src/main/java/com/noop/ble/PuffinExperiment.kt index bb7e564cc9..bdfd14e36f 100644 --- a/android/app/src/main/java/com/noop/ble/PuffinExperiment.kt +++ b/android/app/src/main/java/com/noop/ble/PuffinExperiment.kt @@ -44,9 +44,9 @@ class PuffinExperiment( get() = prefs.getBoolean(KEY_DEEP_DATA, false) set(v) = prefs.edit().putBoolean(KEY_DEEP_DATA, v).apply() - /** True if the user opted in to "Broadcast heart rate": NOOP writes the device-config flag - * whoop_live_hr_in_adv_ind_pkt="1" so the strap advertises the standard Heart Rate Service - * (0x180D) + its live HR, pairable by a Garmin/Zwift/gym HR client. Reversible. Default false. + /** True if the user opted in to "Broadcast heart rate": NOOP enables the family's reversible + * direct-broadcast control so the strap advertises the standard Heart Rate Service (0x180D), + * pairable by a Garmin/Zwift/gym HR client. Default false. * Mirrors the macOS `PuffinExperiment.broadcastHrKey`. (#181) */ var broadcastHr: Boolean get() = prefs.getBoolean(KEY_BROADCAST_HR, false) 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 1009f79ead..8457af627d 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -8398,6 +8398,18 @@ class WhoopBleClient( "frame=${frame.joinToString("") { "%02x".format(it) }}", ) } + if (connectedFamily == DeviceFamily.WHOOP4 && + respCmd?.startsWith("TOGGLE_GENERIC_HR_PROFILE") == true + ) { + // #2400: an acknowledgement is evidence that opcode 14 was answered, not a read-back + // of the advertising state. Keep the decoded result and full frame for comparison + // across firmware without presenting either as confirmation of the physical effect. + log( + "Broadcast HR: WHOOP 4 command response received " + + "result=${result ?: "none"}, effect not confirmed " + + "frame=${frame.joinToString("") { "%02x".format(it) }}", + ) + } // 5/MG range-query gate: a GET_DATA_RANGE SUCCESS releases the history request // (PENDING precedes it; the 2s fail-open fallback covers a swallowed reply). (#78 fork) if (connectedFamily == DeviceFamily.WHOOP5 && backfilling && !historicalKickSent && @@ -8851,6 +8863,8 @@ class WhoopBleClient( handler.postDelayed({ requestSync(BackfillTrigger.CONNECT) }, INITIAL_BACKFILL_DELAY_MS) startBackfillTimer() startKeepAlive() + // WHOOP 4's broadcast mode is link/runtime state, so restore an opted-in mode after reconnect. + if (PuffinExperiment.from(context).broadcastHr) setBroadcastHr(true) // Arm realtime HR now if a screen already wants it (Live/Health Monitor opened before the bond // completed) OR the continuous-capture preference wants it — otherwise the stream would only // start at the next keep-alive tick (issue #18). Mark it armed so reconcileRealtime() tracks the @@ -9173,20 +9187,20 @@ class WhoopBleClient( refreshConnectionPriority() // #477: live-HR on → HIGH, off → back to idle. No-op unless enabled. } - /** - * EXPERIMENTAL (#181): make the strap advertise its heart rate as a standard BLE HR sensor by - * writing the device-config flag whoop_live_hr_in_adv_ind_pkt = "1" (on) / "0" (off) via - * SET_DEVICE_CONFIG (0x77). Validated on real hardware: with it on, the strap advertises 0x180D + - * the live HR in its manufacturer data, so a Garmin (Edge/watch), Zwift or gym HR client pairs to it - * directly. Reversible; opt-in. Mirrors `BLEManager.setBroadcastHr`. (Broadcast HR) - */ + /** Make the strap advertise as a standard BLE HR sensor. WHOOP 4 uses its reversible + * TOGGLE_GENERIC_HR_PROFILE command; WHOOP 5/MG keeps the existing device-config path. */ fun setBroadcastHr(on: Boolean) { - if (connectedFamily != DeviceFamily.WHOOP5) { - log("Broadcast HR: needs a WHOOP 5.0/MG strap — ignored."); return - } val s = _state.value if (!s.connected || !s.bonded) { - log("Broadcast HR: connect and bond a 5/MG strap first — ignored."); return + log("Broadcast HR: connect and bond the strap first — ignored."); return + } + if (connectedFamily == DeviceFamily.WHOOP4) { + send(CommandNumber.TOGGLE_GENERIC_HR_PROFILE, byteArrayOf(if (on) 1.toByte() else 0.toByte())) + log("Broadcast HR: WHOOP 4 ${if (on) "enable" else "disable"} command sent (14); effect not confirmed.") + return + } + if (connectedFamily != DeviceFamily.WHOOP5) { + log("Broadcast HR: strap family is not known yet — ignored."); return } // Mutually exclusive with the ECG gate: both verify over the SAME 121 read-back opcode, so if both // were in flight one strap reply would be consumed by both handlers and cross-contaminate the other's 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 b920cef991..9183308ac6 100644 --- a/android/app/src/main/java/com/noop/protocol/Enums.kt +++ b/android/app/src/main/java/com/noop/protocol/Enums.kt @@ -154,6 +154,11 @@ enum class CommandNumber(val rawValue: Int) { REPORT_VERSION_INFO(7), SET_CLOCK(10), GET_CLOCK(11), + // Opcode/name come from the canonical CommandNumber schema in + // Packages/WhoopProtocol/Sources/WhoopProtocol/Resources/whoop_protocol.json and its WHOOP 4 + // matrix in docs/PROTOCOL_COMMANDS.md. Payload 1 enabled standard BLE HR advertising and payload 0 + // disabled it on the WHOOP 4.0 tested for #2400. Reversible, explicit opt-in; no readable confirmation. + TOGGLE_GENERIC_HR_PROFILE(14), // 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 diff --git a/android/app/src/main/java/com/noop/ui/DataSourcesScreen.kt b/android/app/src/main/java/com/noop/ui/DataSourcesScreen.kt index 7d6cc0d41f..6b019333ca 100644 --- a/android/app/src/main/java/com/noop/ui/DataSourcesScreen.kt +++ b/android/app/src/main/java/com/noop/ui/DataSourcesScreen.kt @@ -2,6 +2,8 @@ package com.noop.ui import com.noop.R import androidx.compose.ui.res.stringResource +import android.content.Context +import android.content.SharedPreferences import android.text.format.DateUtils import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult @@ -39,6 +41,7 @@ import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -63,6 +66,7 @@ import com.noop.data.ImportSummary import com.noop.data.Metric import com.noop.data.PairedDeviceRow import com.noop.data.SourceKind +import com.noop.ble.PuffinExperiment import com.noop.ingest.AppleHealthImporter import com.noop.ingest.HealthConnectImporter import com.noop.ingest.HealthConnectWriter @@ -109,6 +113,8 @@ fun DataSourcesScreen(vm: AppViewModel) { val context = LocalContext.current val scope = rememberCoroutineScope() val live by vm.live.collectAsStateWithLifecycle() + val puffinExperiment = remember { PuffinExperiment.from(context) } + var strapHrBroadcast by remember { mutableStateOf(puffinExperiment.broadcastHr) } val hrBroadcast by vm.hrBroadcast.collectAsStateWithLifecycle() val hrBroadcastAdvertising by vm.hrBroadcastAdvertising.collectAsStateWithLifecycle() val hrBroadcastSubscribers by vm.hrBroadcastSubscribers.collectAsStateWithLifecycle() @@ -118,6 +124,16 @@ fun DataSourcesScreen(vm: AppViewModel) { val hcLastSync by vm.hcLastSync.collectAsStateWithLifecycle() val hcWriteback by vm.hcWriteback.collectAsStateWithLifecycle() val hcWbStatus by vm.hcWritebackStatus.collectAsStateWithLifecycle() + DisposableEffect(Unit) { + val prefs = context.getSharedPreferences(PuffinExperiment.PREFS, Context.MODE_PRIVATE) + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == null || key == PuffinExperiment.KEY_BROADCAST_HR) { + strapHrBroadcast = puffinExperiment.broadcastHr + } + } + prefs.registerOnSharedPreferenceChangeListener(listener) + onDispose { prefs.unregisterOnSharedPreferenceChangeListener(listener) } + } var hcReadCategories by remember { mutableStateOf(HealthConnectImporter.selectedCategories(context)) } @@ -881,7 +897,7 @@ fun DataSourcesScreen(vm: AppViewModel) { SourceCard( title = uiString(R.string.l10n_data_sources_screen_whoop_strap_live_ble_217f7df6), icon = Icons.Filled.Bluetooth, - subtitle = "Pairs directly with your strap over Bluetooth: no WHOOP app, no cloud.", + subtitle = uiString(R.string.data_sources_whoop_live_subtitle), ) { val (label, tone) = when { // encryptedBond, not bonded — see strapStatusTitle. A 5/MG streaming over the open @@ -899,9 +915,46 @@ fun DataSourcesScreen(vm: AppViewModel) { else -> "Not connected. Open Live to pair." to StrandTone.Critical } StatePill(title = label, tone = tone, showsDot = true, pulsing = live.connected && !live.bonded) + val strapBroadcastTitle = uiString(R.string.raw_diag_broadcast_hr) + val strapBroadcastDescription = uiString(R.string.data_sources_band_broadcast_description) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text(strapBroadcastTitle, style = NoopType.subhead, color = Palette.textPrimary) + Text( + strapBroadcastDescription, + style = NoopType.footnote, + color = Palette.textTertiary, + ) + } + Switch( + checked = strapHrBroadcast, + onCheckedChange = { enabled -> + strapHrBroadcast = enabled + puffinExperiment.broadcastHr = enabled + vm.ble.setBroadcastHr(enabled) + }, + colors = SwitchDefaults.colors( + checkedThumbColor = Palette.surfaceBase, + checkedTrackColor = Palette.accent, + uncheckedThumbColor = Palette.textSecondary, + uncheckedTrackColor = Palette.surfaceInset, + uncheckedBorderColor = Palette.hairline, + ), + modifier = Modifier.semantics { + contentDescription = strapBroadcastTitle + }, + ) + } } } + // Keep the final control clear of the app and system navigation bars on shorter phones. + item { Spacer(Modifier.height(96.dp)) } + } // ah-delete (#616): strongly-worded confirm before purging the "apple-health" source. On confirm, diff --git a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt index fd5f059a57..4062ebb4d3 100644 --- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt @@ -2555,8 +2555,7 @@ fun SettingsScreen( } // "WHOOP 4.0 vs 5.0/MG — what each can read and why" (FI-2 / #490). Shown to BOTH model - // owners, so a 4.0 user understands their strap is fully supported (and why the firmware - // broadcast-out is 5/MG-only while NOOP's own re-broadcast in Data Sources works on a 4.0). + // owners, so either generation's supported features and protocol differences are clear. val modelComparisonInteraction = remember { MutableInteractionSource() } Box( modifier = Modifier diff --git a/android/app/src/main/java/com/noop/ui/WhoopModelComparisonScreen.kt b/android/app/src/main/java/com/noop/ui/WhoopModelComparisonScreen.kt index 0c9e11fa68..a3a70b5b81 100644 --- a/android/app/src/main/java/com/noop/ui/WhoopModelComparisonScreen.kt +++ b/android/app/src/main/java/com/noop/ui/WhoopModelComparisonScreen.kt @@ -42,8 +42,8 @@ import androidx.compose.ui.unit.dp // EITHER model owner. The point (issue #490): a 4.0 user wrongly believed broadcast-out was 5.0-only. // In truth NOOP's OWN heart-rate re-broadcast (Data Sources → "Broadcast heart rate") works on ANY // strap — it re-advertises whatever live HR NOOP is reading. What's genuinely 5/MG-only is the strap -// FIRMWARE broadcast flag (whoop_live_hr_in_adv_ind_pkt), because the 4.0 firmware has no such config. -// This screen draws that line honestly, and reassures a 4.0 owner their strap is fully supported. +// WHOOP 4 uses its dedicated broadcast command while 5/MG uses the whoop_live_hr_in_adv_ind_pkt +// firmware config. This screen draws that distinction and reassures either owner they're supported. /** One capability row: a feature, and whether each strap can do it (Yes / No / a short qualifier). */ private data class CapabilityRow( @@ -76,10 +76,10 @@ private val CAPABILITIES: List = listOf( "your phone.", ), CapabilityRow( - "Strap broadcasts its own HR (firmware flag)", - Support.NO, Support.YES, - "Making the STRAP itself advertise HR (the whoop_live_hr_in_adv_ind_pkt config) only exists on " + - "5/MG firmware. A 4.0 can't do this, but the phone re-broadcast above covers the same use.", + "Strap broadcasts its own HR", + Support.YES, Support.YES, + "A 4.0 uses its dedicated broadcast command. A 5/MG uses the " + + "whoop_live_hr_in_adv_ind_pkt firmware setting.", ), CapabilityRow( "Steps", @@ -195,10 +195,7 @@ private fun ReassuranceCard() { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Text(uiString(R.string.l10n_whoop_model_comparison_screen_on_a_whoop_4_0_32a5968d), style = NoopType.headline, color = Palette.textPrimary) Text( - uiString(R.string.l10n_whoop_model_comparison_screen_you_re_not_missing_the_broadcast_1d2fa907) + - " Zwift, Peloton or a Garmin, open Data Sources and turn on \"Broadcast heart rate\": " + - "your phone becomes a standard Bluetooth HR sensor using your strap's live reading. The " + - "firmware-only flag a 5/MG has just does the same job from the strap instead of the phone.", + uiString(R.string.whoop4_direct_broadcast_explainer), style = NoopType.subhead, color = Palette.textSecondary, ) diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index e5a174428b..9dd40e58bd 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2830,4 +2830,7 @@ Weicher Oura-Benachrichtigungsmaske ff (experimentell) Sendet beim nächsten Verbinden die SetNotification-Maske der offiziellen App (1c 01 ff) statt NOOPs 3f. Der Ring packt für die offizielle App ~10 Pakete in eine Benachrichtigung, für NOOP nur eines (9× langsameres Auslesen); das ist der erste Kandidat für den Schalter. Standardmäßig aus; die erste Verbindung nach dem Ausschalten läuft wieder mit 3f. Im Strap-Protokoll auf „-> notify_all(ff)“ achten und die Benachrichtigungsgrößen in der Rohaufzeichnung vergleichen. + Koppelt sich direkt über Bluetooth mit deinem Strap: keine WHOOP-App, keine Cloud. Der Strap kann seine Herzfrequenz außerdem als Standard-Bluetooth-Sensor senden. + Sendet die eigene Live-Herzfrequenz des Straps über Bluetooth für Garmin, Zwift oder Fitnessgeräte. + Dein WHOOP 4.0 kann die Herzfrequenz direkt übertragen. Öffne Datenquellen und verwende „Herzfrequenz vom Band übertragen“ für Zwift, Peloton, Garmin oder Fitnessgeräte. Bei Bedarf kannst du alternativ „HF von diesem Telefon übertragen“ verwenden. diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 1bf83dfa45..d94fbef93a 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -2817,4 +2817,7 @@ Más suave Máscara de notificación Oura ff (experimental) Envía la máscara SetNotification de la aplicación oficial (1c 01 ff) en lugar del 3f de NOOP en la próxima conexión. El anillo agrupa ~10 paquetes por notificación para la aplicación oficial y uno para NOOP (descarga 9× más lenta); este es el primer interruptor candidato. Desactivado por defecto; la conexión siguiente a desactivarlo vuelve a 3f. Busca «-> notify_all(ff)» en el registro de la pulsera y compara los tamaños de notificación en la captura sin procesar. + Se empareja directamente con tu pulsera por Bluetooth: sin app de WHOOP, sin nube. La pulsera también puede anunciar su frecuencia cardíaca como un sensor Bluetooth estándar. + Difunde por Bluetooth la frecuencia cardíaca en vivo de la propia pulsera para Garmin, Zwift o equipos de gimnasio. + Tu WHOOP 4.0 puede transmitir la frecuencia cardíaca directamente. Abre Fuentes de datos y usa «Transmitir frecuencia cardíaca desde la pulsera» para Zwift, Peloton, Garmin o equipos de gimnasio. También puedes usar «Transmitir FC desde este teléfono» cuando sea necesario. diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index 1832d62aca..520a36a4dc 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -2816,4 +2816,7 @@ Plus douce Masque de notification Oura ff (expérimental) Envoie le masque SetNotification de l’application officielle (1c 01 ff) au lieu du 3f de NOOP à la prochaine connexion. La bague regroupe ~10 paquets par notification pour l’application officielle et un seul pour NOOP (vidage 9× plus lent) ; c’est le premier commutateur candidat. Désactivé par défaut ; la connexion suivant la désactivation repasse en 3f. Surveillez « -> notify_all(ff) » dans le journal du bracelet et comparez la taille des notifications dans la capture brute. + S\'associe directement à votre bracelet par Bluetooth : pas d\'app WHOOP, pas de cloud. Le bracelet peut aussi annoncer sa fréquence cardiaque comme capteur Bluetooth standard. + Diffuse en Bluetooth la fréquence cardiaque en direct propre au bracelet pour Garmin, Zwift ou les équipements de sport. + Votre WHOOP 4.0 peut diffuser directement la fréquence cardiaque. Ouvrez Sources de données et utilisez « Diffuser la fréquence cardiaque du bracelet » pour Zwift, Peloton, Garmin ou les équipements de sport. Vous pouvez aussi utiliser « Diffuser la FC depuis ce téléphone » si nécessaire. diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index 0aa63c4839..2d742c87f5 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -2831,4 +2831,7 @@ Łagodniejsze Maska powiadomień Oura ff (eksperymentalne) Przy następnym połączeniu wysyła maskę SetNotification oficjalnej aplikacji (1c 01 ff) zamiast 3f używanego przez NOOP. Pierścień pakuje ~10 pakietów w jedno powiadomienie dla oficjalnej aplikacji, a dla NOOP tylko jeden (9× wolniejsze zrzuty); to pierwszy kandydat na przełącznik. Domyślnie wyłączone; pierwsze połączenie po wyłączeniu wraca do 3f. Szukaj „-> notify_all(ff)” w dzienniku opaski i porównaj rozmiary powiadomień w surowym przechwyceniu. + Łączy się bezpośrednio z paskiem przez Bluetooth: bez aplikacji WHOOP i bez chmury. Pasek może też nadawać tętno jako standardowy czujnik Bluetooth. + Transmituje własne tętno paska na żywo przez Bluetooth do Garmin, Zwift lub sprzętu na siłowni. + WHOOP 4.0 może nadawać tętno bezpośrednio. Otwórz Źródła danych i użyj opcji „Transmituj tętno z opaski” dla Zwift, Peloton, Garmin lub sprzętu na siłowni. W razie potrzeby możesz też użyć opcji „Transmituj tętno z tego telefonu”. 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 a44a5a40f7..6110f3719b 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -2809,4 +2809,7 @@ Mais suave Máscara de notificação Oura ff (experimental) Envia a máscara SetNotification da aplicação oficial (1c 01 ff) em vez do 3f do NOOP na próxima ligação. O anel agrupa ~10 pacotes por notificação para a aplicação oficial e um para o NOOP (descarga 9× mais lenta); este é o primeiro candidato a interruptor. Desligado por predefinição; a ligação seguinte após desligar volta a 3f. Procure «-> notify_all(ff)» no registo da pulseira e compare os tamanhos das notificações na captura em bruto. + Emparelha diretamente com a tua bracelete via Bluetooth: sem aplicação WHOOP, sem cloud. A bracelete também pode anunciar a frequência cardíaca como um sensor Bluetooth padrão. + Transmite a frequência cardíaca em direto da bracelete por Bluetooth para Garmin, Zwift ou equipamento de ginásio. + O teu WHOOP 4.0 pode transmitir a frequência cardíaca diretamente. Abre Fontes de dados e utiliza «Transmitir frequência cardíaca da bracelete» para Zwift, Peloton, Garmin ou equipamento de ginásio. Em alternativa, podes utilizar «Transmitir FC deste telemóvel» quando necessário. diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index d0b2993d2c..bc8220829f 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -2710,4 +2710,7 @@ Мягче Маска уведомлений Oura ff (экспериментально) При следующем подключении отправляет маску SetNotification официального приложения (1c 01 ff) вместо 3f, которую использует NOOP. Для официального приложения кольцо упаковывает ~10 пакетов в одно уведомление, для NOOP — только один (выгрузка в 9× медленнее); это первый кандидат на переключатель. По умолчанию выключено; первое подключение после выключения снова использует 3f. Ищите «-> notify_all(ff)» в журнале браслета и сравните размеры уведомлений в сырой записи. + Подключается к браслету напрямую по Bluetooth: без приложения WHOOP и без облака. Браслет также может передавать пульс как стандартный Bluetooth-датчик. + Передаёт собственный пульс браслета в реальном времени по Bluetooth для Garmin, Zwift или тренажёров. + WHOOP 4.0 может передавать пульс напрямую. Откройте «Источники данных» и включите «Транслировать пульс с браслета» для Zwift, Peloton, Garmin или тренажёров. При необходимости можно использовать «Передавать пульс с этого телефона». diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index 24136f2979..5d5eea0d47 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -2788,4 +2788,7 @@ 更柔和 Oura 通知掩码 ff(实验性) 下次连接时发送官方应用的 SetNotification 掩码(1c 01 ff),而不是 NOOP 的 3f。戒指为官方应用把约 10 个数据包打包进一条通知,为 NOOP 只发一个(读取慢 9 倍);这是第一个候选开关。默认关闭;关闭后的下一次连接恢复为 3f。请在腕带日志中查看“-> notify_all(ff)”,并在原始捕获中比较通知大小。 + 通过 Bluetooth 直接连接手环:无需 WHOOP 应用,也无需云端。手环还可以将心率作为标准 Bluetooth 传感器进行广播。 + 通过 Bluetooth 将手环自身的实时心率广播给 Garmin、Zwift 或健身器材。 + WHOOP 4.0 可以直接广播心率。打开“数据来源”,启用“从手环广播心率”,即可连接 Zwift、Peloton、Garmin 或健身器材。需要时也可以使用“从此手机广播心率”。 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 8d3555b1a0..62d321fced 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2846,4 +2846,7 @@ Softer Oura notification mask ff (experimental) Sends the official app’s SetNotification mask (1c 01 ff) instead of NOOP’s 3f at the next connect. The ring packs ~10 packets per notification for the official app and one for NOOP (9× slower drains); this is the first candidate switch. Off by default; the next connect after turning it off is back on 3f. Watch the strap log for “-> notify_all(ff)” and compare notification sizes in the raw capture. + Pairs directly with your strap over Bluetooth: no WHOOP app, no cloud. The strap can also advertise its heart rate as a standard Bluetooth sensor. + Broadcasts the strap\'s own live heart rate over Bluetooth for Garmin, Zwift or gym equipment. + Your WHOOP 4.0 can broadcast heart rate directly. Open Data Sources and use “Broadcast heart rate from the strap” for Zwift, Peloton, Garmin or gym equipment. You can alternatively use “Broadcast HR from this phone” when needed. diff --git a/android/app/src/test/java/com/noop/protocol/BroadcastHrConfigTest.kt b/android/app/src/test/java/com/noop/protocol/BroadcastHrConfigTest.kt index bc92f85d23..f857c40d7c 100644 --- a/android/app/src/test/java/com/noop/protocol/BroadcastHrConfigTest.kt +++ b/android/app/src/test/java/com/noop/protocol/BroadcastHrConfigTest.kt @@ -12,6 +12,23 @@ import org.junit.Test * Whoop5ConfigTests.testDeviceConfigBodyIsNameNullPaddedThenAsciiValue. (#181) */ class BroadcastHrConfigTest { + @Test + fun whoop4EnableFrameMatchesExpectedBytes() { + val frame = Framing.buildCommand(CommandNumber.TOGGLE_GENERIC_HR_PROFILE, byteArrayOf(1), seq = 8) + assertEquals("aa0800a823080e016c935474", frame.hex()) + } + + @Test + fun whoop4DisableFrameMatchesExpectedBytes() { + val frame = Framing.buildCommand(CommandNumber.TOGGLE_GENERIC_HR_PROFILE, byteArrayOf(0), seq = 7) + assertEquals("aa0800a823070e00c7e40f08", frame.hex()) + } + + @Test + fun whoop4BroadcastCommandUsesSchemaOpcode() { + assertEquals(CommandNumber.TOGGLE_GENERIC_HR_PROFILE, CommandNumber.fromRaw(14)) + } + @Test fun deviceConfigBodyIsNameNullPaddedThenAsciiValue() { val body = Whoop5Config.deviceConfigBody("whoop_live_hr_in_adv_ind_pkt", 0x31) @@ -28,4 +45,6 @@ class BroadcastHrConfigTest { fun disableUsesAsciiZero() { assertEquals('0'.code, Whoop5Config.deviceConfigBody("whoop_live_hr_in_adv_ind_pkt", 0x30)[32].toInt()) } + + private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it.toInt() and 0xff) } }