Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,17 @@ import Foundation
/// result is `.insufficient` with the surviving/needed counts so the UI can say so plainly (never a
/// fabricated value, unknown stays the "no value" glyph).
/// - The caveat (`caveatFor`) is source-aware: a 60 s spot reading is not the overnight baseline, it
/// needs enough beats, and R-R derived from a WHOOP 5/MG's optical PPG is noisier than a chest
/// needs enough beats, and R-R derived from a WHOOP strap's optical PPG is noisier than a chest
/// strap's electrical R-R. Pure strings, US-neutral, no em-dashes.
///
/// Pure arithmetic + small value types, no I/O — fully unit-testable against a known RR series.
public enum SpotHrvReading {

/// Where the live R-R intervals came from — drives the honesty caveat (optical PPG is noisier).
public enum Source: Sendable {
/// WHOOP 5/MG: R-R is derived from the optical PPG waveform — beat-to-beat, but noisier.
/// Any WHOOP strap (4.0, 5.0, MG): R-R is derived from the optical PPG via 0x2A37 — beat-to-beat, but noisier.
case opticalPPG
/// WHOOP 4 or a chest strap (e.g. Polar H10) over the standard 0x2A37 profile — electrical R-R.
/// An electrical chest strap (e.g. Polar H10) over the standard 0x2A37 profile.
case chestStrap
/// Source not known (generic / unspecified strap).
case unknown
Expand Down Expand Up @@ -89,8 +89,8 @@ public enum SpotHrvReading {
"only a reading with enough clean beats is shown."
switch source {
case .opticalPPG:
return base + " On a WHOOP 5.0/MG the intervals come from the optical pulse signal, which is " +
"noisier than a chest strap, so treat the number as a rough estimate."
return base + " On a WHOOP strap (4.0, 5.0 or MG) the intervals come from the optical pulse signal, which is " +
"noisier than an electrical chest strap such as a Polar H10, so treat the number as a rough estimate."
case .chestStrap, .unknown:
return base
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public enum BodyLocationProbe {
if resultCode == 3 {
verdict = "opcode 84 REJECTED by firmware (UNSUPPORTED)"
} else if hasPayload {
verdict = "opcode 84 ACCEPTED — \(pay.count)-byte payload"
verdict = "opcode 84 answered — \(pay.count)-byte payload (not a success verdict; see the bytes below)"
} else {
verdict = "opcode 84 answered with a bare stub — ambiguous"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public enum ExtendedBatteryProbe {
if resultCode == 3 {
verdict = "opcode 98 REJECTED by firmware (UNSUPPORTED) — evidence for the decompile's 87"
} else if hasPayload {
verdict = "opcode 98 ACCEPTED — \(pay.count)-byte payload"
verdict = "opcode 98 answered — \(pay.count)-byte payload (not a success verdict; see the bytes below)"
} else {
verdict = "opcode 98 answered with a bare stub — ambiguous"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ final class BodyLocationProbeTests: XCTestCase {
func testWhoop4_acceptedDecodesTheFourFields() {
let (text, payHex) = BodyLocationProbe.format(frame: hexToBytes(wristFrame), cmdOff: 6, isWhoop5: false, prevPayloadHex: nil)
XCTAssertTrue(text.contains("WHOOP 4.0"))
XCTAssertTrue(text.contains("opcode 84 ACCEPTED — 4-byte payload"))
XCTAssertTrue(text.contains("opcode 84 answered — 4-byte payload (not a success verdict; see the bytes below)"))
XCTAssertTrue(text.contains(wristFrame)) // full raw hex on one copyable line
XCTAssertTrue(text.contains(" @00 01 01 5a 00")) // offset-labelled hex grid
XCTAssertTrue(text.contains("revision: 1"))
Expand Down Expand Up @@ -79,7 +79,7 @@ final class BodyLocationProbeTests: XCTestCase {
let (text, _) = BodyLocationProbe.format(frame: hexToBytes(wristFrame), cmdOff: 6, isWhoop5: false, prevPayloadHex: nil)
let golden = [
"#690 BODY-LOCATION PROBE — WHOOP 4.0",
"Verdict: opcode 84 ACCEPTED — 4-byte payload",
"Verdict: opcode 84 answered — 4-byte payload (not a success verdict; see the bytes below)",
"",
"Raw frame (15 B):",
"aa09000024005401015a00deadbeef",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ final class ExtendedBatteryProbeTests: XCTestCase {
func testWhoop4RealCapture_acceptedWithVoltageAndGrid() {
let (text, payHex) = ExtendedBatteryProbe.format(frame: hexToBytes(realFrame), cmdOff: 6, isWhoop5: false, prevPayloadHex: nil)
XCTAssertTrue(text.contains("WHOOP 4.0"))
XCTAssertTrue(text.contains("opcode 98 ACCEPTED — 29-byte payload"))
XCTAssertTrue(text.contains("opcode 98 answered — 29-byte payload (not a success verdict; see the bytes below)"))
XCTAssertTrue(text.contains(realFrame)) // full raw hex on one copyable line
XCTAssertTrue(text.contains("Voltage: 3.97 V")) // pay[7..8] = 0x0f82 = 3970 mV
XCTAssertTrue(text.contains("(mV=3970 @07)"))
Expand Down
28 changes: 20 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,9 @@ shared cross-platform code.
| **Data Sources** | One-tap import of a WHOOP CSV export, an Apple Health export, or a **nutrition CSV** (Cronometer / MacroFactor), plus live-strap status. "Bring your history in once, then it's yours." |
| **Notifications** | Configure local notifications and thresholds (`Strand/Data/NotificationSettingsStore.swift`). |
| **Automations** | Turn the strap's physical inputs and live biometrics into Mac actions — all on-device (see below). |
| **Alarms** | Your strap wake-alarm and the evening wind-down reminder, in one place (see [Alarms](#alarms-on-device) below). |
| **Coach** | An optional **AI Coach** you can ask about your data in plain language. It is off until you add your own key — Anthropic, OpenAI, or any OpenAI-compatible endpoint including a local/self-hosted model (Ollama, LM Studio) — and it sends only a short text summary of recent metrics plus your question, never raw streams or identifiers. With a local model the conversation never leaves your machine. Available on macOS, Android, and iOS. See [`docs/PRIVACY_SECURITY.md`](docs/PRIVACY_SECURITY.md). |
| **Settings** | Profile, preferences, **step calibration** (tune the stride/step estimate to your own walking), unit choices, the in-app **What's new** changelog, and an opt-in **Experimental** section (WHOOP 5/MG protocol probes). On **iOS**, also **Export for Shortcuts** — a HealthKit-free path that hands your metrics to Apple Health via the Shortcuts app. |
| **Settings** | Profile, preferences, **step calibration** (tune the stride/step estimate to your own walking), unit choices, the in-app **What's new** changelog, and **Test Centre**, which holds the opt-in WHOOP 5/MG protocol probes (Test Centre → 5/MG protocol diagnostics → Protocol probes). On **iOS**, also **Export for Shortcuts** — a HealthKit-free path that hands your metrics to Apple Health via the Shortcuts app. |

There is also a **menu-bar extra** (`Strand/MenuBar/MenuBarContent.swift`) with a
glanceable live HR readout and a compact popover, a first-run **onboarding wizard**
Expand All @@ -216,9 +217,18 @@ and an in-app **"What's new"** changelog shown after each update.
- **Inactivity reminder.** An optional gentle wrist buzz after you've been sitting
still too long — your idle threshold, your active hours, a re-nudge cooldown,
respects quiet hours, **off by default**.
- **Smart alarm.** Arms the strap's own **firmware** alarm to buzz at your wake
time (still fires if the Mac is asleep or NOOP is closed), with an optional
light-sleep wake window when the Mac stays awake and connected.

### Alarms (on-device)

`Strand/Screens/SmartAlarmView.swift` (the **Alarms** screen; Android has a matching Alarms screen):

- **Strap wake-alarm.** Arms the strap's own **firmware** alarm to buzz at your wake time, so it still
fires if the Mac is asleep or NOOP is closed. Confirmed on WHOOP 4.0. On WHOOP 5.0 / MG it is
experimental: it is only armed when **Protocol probes** is on (Test Centre → 5/MG protocol
diagnostics), and even then a wake is not guaranteed, so keep a backup alarm.
- **Wind-down reminder.** Lives on the same screen, so every wake and wind-down control sits together.
- **No light-sleep early wake on Apple platforms.** The phone-based smart wake (light-sleep detection)
is Android-only.

---

Expand Down Expand Up @@ -249,7 +259,7 @@ NOOP is an independent, **experimental** project — capable, but a work in prog
| Strap | Status |
|---|---|
| **WHOOP 4.0** | ✅ The tested, supported path. Live HR, recovery, strain, sleep, history offload — the full experience. (v1.95 also unlocked sleep + recovery on the newer "v25" 4.0 firmware layout that earlier versions could only read live HR from.) |
| **WHOOP 5.0 / MG** | 🧪 **Live heart rate works** (confirmed on real hardware). Pick "WHOOP 5.0 / MG" before connecting — and see the pairing note below, because you can't just scan for it. Deeper 5/MG metrics (recovery, strain, sleep) are still being mapped; there's an opt-in **Settings → Experimental** toggle for 5/MG owners who want to help document the protocol. |
| **WHOOP 5.0 / MG** | 🧪 **Live heart rate works** (confirmed on real hardware). Pick "WHOOP 5.0 / MG" before connecting — and see the pairing note below, because you can't just scan for it. Sleep, recovery and strain are scored on-device, with the overnight-input limits listed under "WHOOP 5.0 / MG analysis limits" below; there's an opt-in **Protocol probes** switch (Test Centre → 5/MG protocol diagnostics) for 5/MG owners who want to help document the protocol. |
| **Oura Ring (Gen 3 measured; Ring 5 reported)** | 🧪 **Experimental, not a supported strap.** Pairs on **iOS / Android only** and reads real overnight data — heart rate, sleep stages, skin temperature, SpO₂, motion. The ring's night feeds the scorer, so a ring-only day gets a **Sleep** and a **Strain** score but **no Recovery** (it needs an HRV baseline the ring cannot provide), and some metrics are permanently out of reach. See **Oura ring support** below before you expect anything from it. |

> ### WHOOP 5.0 / MG analysis limits
Expand All @@ -262,7 +272,7 @@ NOOP is an independent, **experimental** project — capable, but a work in prog
> | Sleep duration / detection | Experimental; can fall back to heart rate when motion is sparse |
> | Sleep stages | Approximate and not reliable while full overnight motion and cardiorespiratory inputs remain incomplete |
> | Skin temperature | Raw values decode on supported historical layouts; not available consistently across 5/MG firmware |
> | Blood oxygen / SpO₂ | Not recoverable offline from current time-multiplexed PPG data |
> | Blood oxygen / SpO₂ | No verified value. NOOP cannot recover SpO₂ from the current time-multiplexed PPG data. The strap's own @82 SpO₂ candidate decodes only during sleep and is unverified. It appears only in the Blood Oxygen tile, as an "estimate", behind the default-off "Blood Oxygen: strap estimate" toggle, and never feeds recovery or illness scoring. |
> | Overnight HRV and respiratory rate | Incomplete unless sufficient R-R intervals are captured |
>
> In short: seeing the Sleep, Health, Readiness, or Insights screens doesn't mean their deepest
Expand Down Expand Up @@ -291,8 +301,10 @@ NOOP is an independent, **experimental** project — capable, but a work in prog
> **Only one device at a time.** Because the strap holds a single bond, don't leave it connected to your
> phone *and* your Mac (or the WHOOP app) at once — live heart rate will still show on all of them
> (that rides the bond-free standard profile), but **none** of them will have the real encrypted bond.
> If HR streams fine yet **buzz, alarm, double-tap and history don't work**, that's the tell: the strap
> isn't truly bonded to this device. Free it from everything else, then pair here.
> If HR streams fine yet **buzz, double-tap and history don't work**, that's the tell: the strap
> isn't truly bonded to this device. Free it from everything else, then pair here. (The 5/MG strap
> alarm is separate and experimental: even on a properly bonded strap it stays off until you turn on
> **Protocol probes** in Test Centre → 5/MG protocol diagnostics, and even then a wake is not guaranteed.)
>
> Bonding to NOOP may take the strap's bond away from the WHOOP app, so the official app might need to
> re-pair afterwards. This is the **hardest part of 5/MG support** — if it refuses, you're almost
Expand Down
18 changes: 11 additions & 7 deletions Strand/BLE/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ struct BondRefusalGiveUp {
///
/// Pure. Byte-identical to the Kotlin `BondRefusalGiveUp.helloSuppressedHint`.
static func helloSuppressedHint() -> String {
"The secure handshake with your strap never completes, and the attempt itself is what drops the link. NOOP has switched it off for this strap so live heart rate keeps streaming. History sync stays unavailable until it pairs, and so do motion, skin temperature, SpO₂ and respiratory rate, so sleep is staged from heart rate alone. Some straps have paired again after being put in pairing mode. Tap until the LEDs flash blue, then tap Connect."
"The secure handshake with your strap never completes, and the attempt itself is what drops the link. NOOP has switched it off for this strap so live heart rate keeps streaming. History sync stays unavailable until it pairs, and so do motion and skin temperature, so sleep is staged from heart rate alone. Some straps have paired again after being put in pairing mode. Tap until the LEDs flash blue, then tap Connect."
}

/// The paused hint for a bond that failed WITHOUT the strap ever answering (#1635).
Expand Down Expand Up @@ -3489,7 +3489,7 @@ public final class BLEManager: NSObject, ObservableObject {
log("Deep-data: needs a WHOOP 5.0/MG strap selected — ignored."); return
}
guard PuffinExperiment.deepDataEnabled else {
log("Deep-data: the deep-data experiment is off — enable it in Settings → Experimental first."); return
log("Deep-data: the deep-data experiment is off — turn on 'Legacy R22 feature-flag experiment' in Test Centre → 5/MG protocol diagnostics first."); return
}
guard state.connected, state.encryptedBond else {
// The R22 SET_CONFIG writes go over the encrypted command channel, so the live-HR-only
Expand Down Expand Up @@ -3778,7 +3778,7 @@ public final class BLEManager: NSObject, ObservableObject {
log("ECG gate (#891): needs a WHOOP 5/MG strap selected — ignored."); return
}
guard PuffinExperiment.ecgRawDataEnabled else {
log("ECG gate (#891): the experiment is off — enable it in Settings → Experimental first."); return
log("ECG gate (#891): the experiment is off — turn on 'WHOOP MG ECG raw-data gate' in Test Centre → 5/MG protocol diagnostics first."); return
}
let variant = whoop5Variant
guard variant.isMG else {
Expand Down Expand Up @@ -4074,7 +4074,7 @@ public final class BLEManager: NSObject, ObservableObject {
// branch, and 4.0 has no allowlist. So this guard is not merely belt-and-braces on every family —
// for the family most likely to run this first, it is the belt.
guard TestCentre.active(.connection) else {
log("Feature-flag probe (#761) ignored — Test Centre → Connection is off")
log("Feature-flag probe (#761) ignored — Test Centre → Connection & Sync is off")
return
}
guard featureFlagReport == nil else {
Expand Down Expand Up @@ -4156,7 +4156,7 @@ public final class BLEManager: NSObject, ObservableObject {
// Defence in depth: the menu entry is already Test-Centre gated, but the sender re-checks so no
// other path can start a probe on a default install.
guard TestCentre.active(.connection) else {
log("Device-config read probe (#103) ignored — Test Centre → Connection is off")
log("Device-config read probe (#103) ignored — Test Centre → Connection & Sync is off")
return
}
guard deviceConfigReport == nil else {
Expand Down Expand Up @@ -6814,8 +6814,12 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
// counting silently; recordRefusal() below stays false (latched), so no epitaph spam.
log("WHOOP 5/MG: bond still refused during a paused-state probe (streak \(bondRefusalStreak)) - the give-up stays latched")
} else if bondRefusalStreak >= 2 {
state.pairingHint = "NOOP can see your strap but it's refusing to pair - it's likely still bonded to the official WHOOP app, or your phone is holding an old pairing. To fix it: (1) fully close the WHOOP app, (2) on a 5.0/MG, tap the band repeatedly until the LEDs flash blue (pairing mode), (3) if your strap is listed under iPhone Settings → Bluetooth, tap it and choose Forget This Device, then reconnect in NOOP."
log("WHOOP 5/MG: bond refused \(bondRefusalStreak)× with no successful bond — the strap is refusing the encrypted link (WHOOP app holds it, or a stale iOS pairing). Surfacing pairing-mode + forget-device guidance (#78).")
#if os(macOS)
state.pairingHint = "NOOP can see your strap but it's refusing to pair. It is likely still held by the official WHOOP app, or this Mac is holding an old pairing. To fix it: (1) fully close the WHOOP app, (2) tap the band repeatedly until the LEDs flash blue (pairing mode), (3) if your strap is listed in System Settings → Bluetooth, open its details and choose Forget This Device, then reconnect in NOOP."
#else
state.pairingHint = "NOOP can see your strap but it's refusing to pair. It is likely still held by the official WHOOP app, or your iPhone is holding an old pairing. To fix it: (1) fully close the WHOOP app, (2) tap the band repeatedly until the LEDs flash blue (pairing mode), (3) if your strap is listed under iPhone Settings → Bluetooth, tap it and choose Forget This Device, then reconnect in NOOP."
#endif
log("WHOOP 5/MG: bond refused \(bondRefusalStreak)× with no successful bond — the strap is refusing the encrypted link (WHOOP app holds it, or a stale OS pairing). Surfacing pairing-mode + forget-device guidance (#78).")
} else {
log("WHOOP 5/MG: bond write refused (insufficient) — retrying once; will surface pairing-mode guidance if it persists (#78).")
}
Expand Down
2 changes: 1 addition & 1 deletion Strand/Onboarding/OnboardingWizard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -641,7 +641,7 @@ private struct ScanStep: View {
.foregroundStyle(StrandPalette.textPrimary)
}

Text("WHOOP straps don't appear in your \(Platform.deviceNoun)'s Bluetooth settings. They advertise on a custom profile that only apps like NOOP can find, so there's nothing to pair there, and you shouldn't try.")
Text("You don't pair a WHOOP strap from your \(Platform.deviceNoun)'s Bluetooth settings. It advertises on a custom profile that only apps like NOOP can find, so don't try to pair it there. If a WHOOP is already listed there from an earlier pairing, leave it unless NOOP's re-pair steps ask you to forget it.")
.font(StrandFont.subhead)
.foregroundStyle(StrandPalette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
Expand Down
Loading