Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 13 additions & 10 deletions Strand/BLE/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down
7 changes: 7 additions & 0 deletions Strand/BLE/Commands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
11 changes: 10 additions & 1 deletion Strand/BLE/FrameRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
8 changes: 4 additions & 4 deletions Strand/BLE/PuffinExperiment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
16 changes: 15 additions & 1 deletion Strand/Screens/DataSourcesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) }
}
}

Expand Down
23 changes: 23 additions & 0 deletions StrandTests/BroadcastHrCommandTests.swift
Original file line number Diff line number Diff line change
@@ -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() }
}
6 changes: 3 additions & 3 deletions android/app/src/main/java/com/noop/ble/PuffinExperiment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
36 changes: 25 additions & 11 deletions android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions android/app/src/main/java/com/noop/protocol/Enums.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 54 additions & 1 deletion android/app/src/main/java/com/noop/ui/DataSourcesScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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))
}
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
Loading
Loading