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
96 changes: 96 additions & 0 deletions android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,11 @@ interface GattOps {
/** Request a GATT connection priority (battery, #477). Mirrors `BluetoothGatt`'s boolean contract;
* the stack no-ops a request equal to the current interval. */
fun requestConnectionPriorityCompat(priority: Int): Boolean

/** Ask the controller to prefer a PHY for this link (#533). Mirrors `BluetoothGatt.setPreferredPhy`,
* which is VOID and fire-and-forget: the real outcome arrives on `onPhyUpdate`, and the peer can
* decline. Masks (not single values) so the controller may fall back. API 26 = our minSdk. */
fun setPreferredPhyCompat(txPhy: Int, rxPhy: Int, phyOptions: Int)
}

/**
Expand Down Expand Up @@ -332,6 +337,8 @@ class RealGattOps(private val gatt: BluetoothGatt) : GattOps {
override fun readRemoteRssiCompat(): Boolean = gatt.readRemoteRssi()
override fun discoverServicesCompat(): Boolean = gatt.discoverServices()
override fun requestConnectionPriorityCompat(priority: Int): Boolean = gatt.requestConnectionPriority(priority)
override fun setPreferredPhyCompat(txPhy: Int, rxPhy: Int, phyOptions: Int) =
gatt.setPreferredPhy(txPhy, rxPhy, phyOptions)
}

class WhoopBleClient(
Expand Down Expand Up @@ -505,6 +512,34 @@ class WhoopBleClient(
fun releasesConnectionPriority(wasEnabled: Boolean, nowEnabled: Boolean): Boolean =
wasEnabled && !nowEnabled

/** #533: the PHY mask to ask the controller for. NOOP has never called `setPreferredPhy`, so every
* offload has run on the 1M PHY. LE 2M doubles the symbol rate, which for a bulk transfer means the
* SAME bytes spend HALF the air-time — unlike the connection-interval lever above it should cost
* LESS radio energy per byte, not more. The two are orthogonal and stack.
*
* Always a MASK INCLUDING 1M, never 2M alone: this is a preference, and leaving 1M in it lets the
* controller fall back rather than cling to a 2M link that has gone marginal (2M trades range for
* speed). Off → plain 1M, byte-for-byte today's link. The peer still has the final say, and
* `onPhyUpdate` reports what was actually negotiated.
*
* Android-only by necessity, exactly like [connectionPriorityFor]: CoreBluetooth exposes no
* app-side PHY API — Apple's stack negotiates the PHY itself and gives apps no say — so there is
* no Swift twin. A deliberate platform divergence, not a parity gap. (It also makes iOS/macOS a
* useful control: their link parameters are chosen for them, so a Mac draining a backlog faster
* than Android would show the strap is not the bottleneck.) */
fun preferredPhyMask(fastLinkEnabled: Boolean): Int =
if (fastLinkEnabled) BluetoothDevice.PHY_LE_1M_MASK or BluetoothDevice.PHY_LE_2M_MASK
else BluetoothDevice.PHY_LE_1M_MASK

/** Human-readable PHY for the strap log (#533). `onPhyUpdate` reports a PHY_LE_* VALUE (1/2/3),
* not the *_MASK constants used to request one — don't compare the two. */
fun phyLabel(phy: Int): String = when (phy) {
BluetoothDevice.PHY_LE_1M -> "1M"
BluetoothDevice.PHY_LE_2M -> "2M"
BluetoothDevice.PHY_LE_CODED -> "coded"
else -> "unknown($phy)"
}

/** Stretched periodic-offload interval while the STRAP is low on battery (#477). The offload tick
* is a PURE sync timer (the live-stream keep-alive is separate), so stretching it can't affect
* link health — worst case is data arriving in slightly larger batches; the strap banks
Expand Down Expand Up @@ -1250,6 +1285,57 @@ class WhoopBleClient(
}
}

/** #533 (EXPERIMENTAL, default off): ask for the LE 2M PHY around the historical offload. See
* [preferredPhyMask]. Requested at offload START rather than on connect ON PURPOSE: the connect
* handshake is fragile — an extra GATT op before `requestMtu` can make it return false, which skips
* the MTU bump and caps the very offload this is trying to speed up (#85/#50). The offload burst is
* where the throughput matters anyway, and PHY is a link-level setting that persists once negotiated. */
@Volatile private var fastLinkPhyEnabled: Boolean = false

/** Opt into the experimental LE 2M PHY preference (#533). No-op by default; applied at the next offload.
*
* Turning it OFF hands the link back to 1M rather than merely stopping future offloads from asking for
* 2M. A PHY PERSISTS once negotiated, so without this an already-2M link would stay 2M until the next
* reconnect — and this toggle's own copy tells the user to switch it off if syncing goes flaky at
* range, which is exactly the case where 2M is the suspect. Reuses [releasesConnectionPriority]'s
* edge rule, so the default path (re-applying `false` while already off, every launch) issues ZERO
* BLE ops. */
fun setFastLinkPhy(enabled: Boolean) {
val wasEnabled = fastLinkPhyEnabled
fastLinkPhyEnabled = enabled
if (releasesConnectionPriority(wasEnabled, enabled)) handler.post { releasePreferredPhy() }
}

/** #533: ask the controller to prefer 2M for this link (mask always includes 1M so it can fall back).
* Fire-and-forget — `setPreferredPhy` is void and the peer may decline; `onPhyUpdate` logs the PHY
* actually negotiated, which is also how we learn whether WHOOP supports 2M at all. Swallows throws
* like the connection-priority hint: a PHY preference must never tear the link down. No-op when off,
* so the default path issues ZERO extra BLE ops. */
private fun applyPreferredPhy() {
if (!fastLinkPhyEnabled) return
val ops = gattOps ?: return
val mask = preferredPhyMask(true)
try {
ops.setPreferredPhyCompat(mask, mask, BluetoothDevice.PHY_OPTION_NO_PREFERRED)
log("Offload: requested LE 2M PHY preference (#533)")
} catch (t: Throwable) {
log("preferred-PHY request failed (${t.javaClass.simpleName}); skipped")
}
}

/** #533: ask the controller back down to 1M when the experiment is switched off, undoing a 2M link
* still in force. Swallows throws like [applyPreferredPhy]. */
private fun releasePreferredPhy() {
val ops = gattOps ?: return
val mask = preferredPhyMask(false)
try {
ops.setPreferredPhyCompat(mask, mask, BluetoothDevice.PHY_OPTION_NO_PREFERRED)
log("Offload: released the LE 2M PHY preference — back to 1M (#533)")
} catch (t: Throwable) {
log("preferred-PHY release failed (${t.javaClass.simpleName}); skipped")
}
}

/** Battery-% at/below which the periodic offload cadence stretches to
* [LOW_BATTERY_BACKFILL_INTERVAL_MS] while discharging; 0 = never (normal 15-min cadence). DEFAULT
* OFF, so this ships dormant. The Settings picker offers 10/15/20/25/30. */
Expand Down Expand Up @@ -3466,6 +3552,15 @@ class WhoopBleClient(
kickServiceDiscovery(g, "mtu=$mtu")
}

/** #533: what the controller and the strap ACTUALLY settled on — the request is only a preference
* and the peer can decline, so this is the only way to know whether 2M took (and whether WHOOP
* supports it at all). Fires on any PHY change, including a fall back to 1M on a marginal link, so
* a before/after strap log shows the negotiated PHY next to the offload's records/sec.
* Log-only: nothing branches on the PHY. */
override fun onPhyUpdate(g: BluetoothGatt, txPhy: Int, rxPhy: Int, status: Int) {
log("PHY negotiated: tx=${phyLabel(txPhy)} rx=${phyLabel(rxPhy)} (status=$status)")
}

override fun onReadRemoteRssi(g: BluetoothGatt, rssi: Int, status: Int) {
// Signal strength at connect — diagnoses weak-link syncs (drops/busy storms/timeouts) that
// otherwise look mysterious in the log. Only on a clean read; a failure just stays silent.
Expand Down Expand Up @@ -4955,6 +5050,7 @@ class WhoopBleClient(
historicalKickSent = false
_state.update { it.copy(backfilling = true, syncChunksThisSession = 0) }
refreshConnectionPriority() // #477: escalate to HIGH for the offload burst (faster sync). No-op unless enabled.
applyPreferredPhy() // #533: prefer LE 2M for the burst (halves air-time). No-op unless enabled.
// Opt-in raw capture (research aid): pref read fresh per session, like the probes gate.
if (connectedFamily == DeviceFamily.WHOOP5 && PuffinExperiment.from(context).isCaptureEnabled) {
startWhoop5BackfillCapture()
Expand Down
11 changes: 11 additions & 0 deletions android/app/src/main/java/com/noop/ui/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -910,6 +910,10 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
enabled = NoopPrefs.fastHistorySync(appContext),
idleThrottleBatteryPct = 0,
)
// #533: the second, orthogonal sync-speed lever — prefer LE 2M around the offload burst. Also
// independent of the Power-saving master, and its own toggle so a field report can tell the two
// apart (they have opposite battery profiles). No-op unless on.
ble.setFastLinkPhy(NoopPrefs.fastLinkPhy(appContext))
ble.setLowBatteryOffloadThrottle(if (on) NoopPrefs.powerSavingBatteryPct(appContext) else 0)
// HRV pause is a sub-option: only effective while the master is on (defaults on when it is), and
// now battery-%-aware like the offload lever — pass the same threshold.
Expand Down Expand Up @@ -944,6 +948,13 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
applyPowerSaving()
}

/** Flip the experimental LE 2M PHY preference (#533). Persists + pushes it to the client; it applies
* at the next offload burst, and switching it off releases an already-2M link back to 1M. */
fun setFastLinkPhy(enabled: Boolean) {
NoopPrefs.setFastLinkPhy(appContext, enabled)
applyPowerSaving()
}

/** The effective continuous-HRV want: the user's "Continuous HRV capture" preference AND
* "Keep connected in the background" — the latter is what holds the link up for the stream to ride,
* so continuous capture is meaningless without it. */
Expand Down
18 changes: 18 additions & 0 deletions android/app/src/main/java/com/noop/ui/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,24 @@ object NoopPrefs {
of(context).edit().putBoolean(KEY_FAST_HISTORY_SYNC, enabled).apply()
}

/** EXPERIMENTAL (#533): prefer the LE 2M PHY around the historical offload. LE 2M doubles the symbol
* rate, so the same bytes spend half the air-time — it should cost LESS radio energy per byte, not
* more (unlike [KEY_FAST_HISTORY_SYNC]'s connection-interval lever). NOOP has never called
* setPreferredPhy, so every offload to date has run on 1M. Orthogonal to that lever; they stack, and
* they are separate toggles so a field report can attribute which one did what.
*
* DEFAULT OFF: it is a preference the strap may decline, 2M trades range for speed, and BLE behaviour
* can't be CI-tested — the negotiated PHY and the speedup both need real-strap field reports. The
* request always allows 1M too, so the controller can fall back. */
const val KEY_FAST_LINK_PHY = "noop.fastLinkPhy"

fun fastLinkPhy(context: Context): Boolean =
of(context).getBoolean(KEY_FAST_LINK_PHY, false)

fun setFastLinkPhy(context: Context, enabled: Boolean) {
of(context).edit().putBoolean(KEY_FAST_LINK_PHY, enabled).apply()
}

/** #836, the raw-HR fingerprint ("count:maxTs") the last COMPLETED idle rescore scored against. The
* 15-min backstop tick skips when the current fingerprint equals this; cleared implicitly by any HR
* insert/delete (the fingerprint moves). Mirrors the Swift `analyzeWatermark` UserDefaults key. */
Expand Down
40 changes: 40 additions & 0 deletions android/app/src/main/java/com/noop/ui/SettingsScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,7 @@ fun SettingsScreen(
// on. SharedPreferences isn't reactive, so the Switch mirrors into a local state.
var backgroundConnection by remember { mutableStateOf(NoopPrefs.backgroundConnection(context)) }
var fastHistorySync by remember { mutableStateOf(NoopPrefs.fastHistorySync(context)) }
var fastLinkPhy by remember { mutableStateOf(NoopPrefs.fastLinkPhy(context)) }

// "Continuous HRV capture" — hold the dense realtime stream armed 24/7 (better overnight HRV) at the
// cost of more battery. Default OFF; only does anything with background connection on. Local mirror.
Expand Down Expand Up @@ -1318,6 +1319,45 @@ fun SettingsScreen(
)
}

// "Faster Bluetooth link" (#533, EXPERIMENTAL): the other, orthogonal sync-speed lever —
// prefer the LE 2M PHY around the offload. Same bytes in half the airtime, so unlike the
// interval lever above it should cost LESS strap radio energy, not more. Separate toggle so
// a field report can attribute which lever did what. Off by default: the strap may decline
// it, 2M trades range for speed, and BLE behaviour can't be CI-tested. The negotiated PHY
// lands in the strap log (onPhyUpdate).
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
stringResource(R.string.fast_link_phy),
style = NoopType.subhead,
color = Palette.textPrimary,
)
Text(
stringResource(R.string.fast_link_phy_desc),
style = NoopType.footnote,
color = Palette.textTertiary,
)
}
Switch(
checked = fastLinkPhy,
onCheckedChange = {
fastLinkPhy = it
vm.setFastLinkPhy(it)
},
colors = SwitchDefaults.colors(
checkedThumbColor = Palette.surfaceBase,
checkedTrackColor = Palette.accent,
uncheckedThumbColor = Palette.textSecondary,
uncheckedTrackColor = Palette.surfaceInset,
uncheckedBorderColor = Palette.hairline,
),
)
}

// "Keep NOOP alive overnight" (#386): the battery-optimisation whitelist. Shown ONLY while
// background connection is on (meaningless otherwise), so it never adds noise on a
// foreground-only setup. `checked` reflects the LIVE system exempt state, so an already-exempt
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@
<string name="timeline_other_sources_no_offload">Andere Quellen übertragen keine rohen Sekundendaten auf das Gerät.</string>

<!-- Power saving (#477) -->
<string name="fast_link_phy">Schnellere Bluetooth-Verbindung (experimentell)</string>
<string name="fast_link_phy_desc">Fordert einen schnelleren Bluetooth-Funkmodus an, während Ihr Riemen seinen gespeicherten Verlauf überträgt. Dieselben Daten in kürzerer Sendezeit — möglicherweise schnellere Synchronisierung und etwas weniger Akkuverbrauch am Riemen. Ihr Riemen muss dies unterstützen, und es geht etwas Reichweite verloren. Neu und unerprobt: Schalten Sie es aus, wenn die Synchronisierung auf Distanz unzuverlässig wird.</string>
<string name="fast_history_sync">Schnellere Verlaufssynchronisierung (experimentell)</string>
<string name="fast_history_sync_desc">Fordert eine schnellere Bluetooth-Verbindung an, während Ihr Riemen seinen gespeicherten Verlauf überträgt, damit ein großer Rückstand schneller aufholt. Nur während der Synchronisierung — Ihre Live-Herzfrequenz und der nächtliche Datenstrom bleiben unberührt. Neu und unerprobt: Wenn es nicht hilft oder zu viel Akku kostet, schalten Sie es wieder aus.</string>
<string name="power_saving">Energie sparen</string>
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/res/values-es/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1591,6 +1591,8 @@
<string name="timeline_empty_metric">No %1$s aquí</string>
<string name="timeline_nothing_offloaded">Aún no se ha descargado nada para esta ventana.</string>
<string name="timeline_other_sources_no_offload">Otras fuentes no descargan datos brutos por segundo en el dispositivo.</string>
<string name="fast_link_phy">Conexión Bluetooth más rápida (experimental)</string>
<string name="fast_link_phy_desc">Solicita un modo de radio Bluetooth más rápido mientras su correa transfiere su historial almacenado. Los mismos datos en menos tiempo de emisión: puede sincronizar antes y consumir algo menos de batería de la correa. Su correa debe admitirlo, y se sacrifica algo de alcance. Nuevo y no probado: si la sincronización falla cuando el teléfono está lejos, vuelva a desactivarlo.</string>
<string name="fast_history_sync">Sincronización de historial más rápida (experimental)</string>
<string name="fast_history_sync_desc">Solicita una conexión Bluetooth más rápida mientras su correa transfiere su historial almacenado, para que un gran retraso se ponga al día antes. Solo durante la sincronización: su frecuencia cardíaca en vivo y el flujo nocturno no se ven afectados. Nuevo y no probado: si no ayuda o consume demasiada batería, vuelva a desactivarlo.</string>
<string name="power_saving">Salvamento de energía</string>
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/res/values-fr/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1591,6 +1591,8 @@
<string name="timeline_empty_metric">Pas de %1$s ici</string>
<string name="timeline_nothing_offloaded">Rien de déchargé pour cette fenêtre pour l\'instant.</string>
<string name="timeline_other_sources_no_offload">D\'autres sources ne déchargent pas les données brutes par seconde sur les appareils.</string>
<string name="fast_link_phy">Liaison Bluetooth plus rapide (expérimental)</string>
<string name="fast_link_phy_desc">Demande un mode radio Bluetooth plus rapide pendant que votre sangle transfère son historique stocké. Les mêmes données en moins de temps d\'antenne : la synchronisation peut être plus rapide et consommer un peu moins de batterie. Votre sangle doit le prendre en charge, et cela réduit un peu la portée. Nouveau et non éprouvé : si la synchronisation devient instable à distance, désactivez-le.</string>
<string name="fast_history_sync">Synchronisation de l\'historique plus rapide (expérimental)</string>
<string name="fast_history_sync_desc">Demande une connexion Bluetooth plus rapide pendant que votre sangle transfère son historique stocké, afin qu\'un gros retard se rattrape plus vite. Uniquement pendant la synchronisation : votre fréquence cardiaque en direct et le flux nocturne ne sont pas affectés. Nouveau et non éprouvé : si cela n\'aide pas ou consomme trop de batterie, désactivez-le.</string>
<string name="power_saving">Économie d\'énergie</string>
Expand Down
Loading
Loading