Repository navigation
Haptics: on a 5/MG the buzz payload is hardcoded — loops is discarded, so BuzzPattern and the live-session intensity levels are no-ops #926
Description
Filing this as a capability finding rather than a PR: it's a description of what the code currently does, and whether anything should change is a maintainer call. Everything below is read off main @ e9326000. No hardware was probed and nothing here is decompile-derived — it's all in this repository.
Short version: on a 5/MG the haptic frame is a hardcoded 12-byte literal, and the caller's loops argument is silently discarded. That makes all four BuzzPattern choices and both live-session intensity levels indistinguishable on that hardware. It also means a couple of things people have been saying about strap haptics — including things I've said — are wrong, so I want to put the actual numbers somewhere findable.
The mechanism
send() intercepts runHapticsPattern on the 5/MG path and substitutes its own payload:
// Strand/BLE/BLEManager.swift:1552-1554
let isHaptics = command == .runHapticsPattern
let puffinCmd: UInt8 = isHaptics ? 0x13 : command.rawValue
let puffinPayload: [UInt8] = isHaptics ? [0x01, 47, 152, 0, 0, 0, 0, 0, 0, 0, 0, 0] : payloadByte-identical in Kotlin at WhoopBleClient.kt:2878-2881.
Per the layout documented at HapticPayloads.swift:22 — [REVISION_1][effects×8][loopControl u16 LE][overallLoop] — byte [11] is overallLoop, and it is 0. Pinned by the golden frame aa0114000001e1e1230113012f980000000000000000000098cb83a5 (DeviceFamilyFramingTests.swift:295-309, Kotlin twin FramingTest.kt:140-147).
Every caller passes [2, loops, 0, 0, 0] — e.g. buzzStrapOnce() sends [2, 3, 0, 0, 0] (patternId 2, 3 loops) — and on 5/MG none of that reaches the wire. buzz(loops: 1) and buzz(loops: 5) emit the same bytes.
This was a deliberate decision, not an oversight, and the reasoning is already in the tree:
Named maverick buzz refactor (HELD): review catch — the PR's
notificationBuzz(loops=1)sets theoverallLoopbyte to 1, but our shipped golden frame … has it 0. The buzz already works; changing a proven payload for a refactor is regression risk for zero user value. Kept our inline buzz.
—CHANGELOG.md:2218-2221
So this issue isn't "someone made a mistake". It's that the consequence of that reasonable call isn't recorded anywhere a person building on haptics would look.
What's affected
BuzzPattern is a no-op on 5/MG. Four cases mapping to loop counts 1/2/3/5 (NotificationSettingsStore.swift:10-30); its own header comment says "All map to the on-device-confirmed graduated buzz (patternId 2); only the repeat count varies." On a 5/MG the repeat count is the part that's discarded, so all four are the same buzz. The picker still offers the choice.
The live-session intensity distinction is a no-op on 5/MG. LiveSessionRunner.swift:217 (let loops = pulse.isLong ? 2 : 1), Kotlin LiveSessionRunner.kt:179, plus BreathingView.swift:644 and BreathPacer.swift:36 (inhale ? 1 : 2). All of these are effectively 4.0-only.
Two dead functions. MaverickHaptics.notificationBuzz(loops:) (HapticPayloads.swift:24) is the only API that would vary overallLoop, and an exhaustive git grep finds its only callers are two assertions in DeviceFamilyFramingTests.swift:322,324 — no production call site, and no Kotlin equivalent exists at all. Separately, AppModel.buzz(pattern:loops:) (AppModel.swift:1024) has zero callers, and its docstring claims it's "Used by the notification-pattern picker", which is false — the picker calls buzz(loops:) at NotificationSettingsView.swift:209.
Two corrections, since both were circulating and I'd repeated them
"There are 7 haptic presets." There is no enumeration of 7 presets anywhere in either codebase. "7" appears only in prose — docs/BLE_REVERSE_ENGINEERING.md:744 ("7 presets on the WHOOP 4.0 (Harvard), indexed 0–6") and an AppModel.swift:1022 comment. Both describe the strap's own table, readable via GET_ALL_HAPTICS_PATTERN (command 80) — and command 80 is never sent by either platform; it exists only as a name in Enums.kt:192,314 and a doc reference at Commands.swift:140. NOOP has never enumerated that table. The number the code actually exposes is 4 (BuzzPattern), all sharing patternId 2. The 7 is a WHOOP 4.0 hardware fact, not a NOOP capability, and it is unverified in this repo.
"There is no loop/repeat parameter in the frame." There is. Byte [11] is exactly that, and it ships non-zero elsewhere in this same codebase — the alarm payload sets it to 7:
// HapticPayloads.swift:49
+ [0x00, 0x00, 0x07, 30] // loopControl u16 LE, overallLoop=7, duration=30 s(Kotlin: AlarmPayload.kt:21,64, OVERALL_LOOP: Byte = 7.) BLEManager.swift:3385-3387 even names the alternative form in a comment as "a note for future refinement".
So the accurate statement is narrower than what I'd been saying: the repeat field exists and is hardware-shipped at 7 on the alarm path, but the buzz path hardcodes it to 0 and exposes no way to vary it. As far as I can tell nobody has tested overallLoop != 0 on the 0x13 buzz — I have not, and I'd rather say so than imply the field is known not to work.
Swift/Kotlin divergence
| Swift | Kotlin | |
|---|---|---|
MaverickHaptics / notificationBuzz |
exists, test-only | does not exist |
buzzStrapOnce() on 5/MG |
2 frames — 0x13 + RUN_ALARM rev2 (BLEManager.swift:3391-3395) |
1 frame — 0x13, early return (WhoopBleClient.kt:2924-2928) |
RUN_ALARM in the 5/MG allowlist |
yes (BLEManager.swift:1493) |
no (WhoopBleClient.kt:2820-2834) |
buzz(pattern:loops:) |
exists, 0 callers | no equivalent |
| 0x13 remap + the 12-byte literal | identical | identical |
The Kotlin comment says that gate is intentional ("the maverick buzz alone is the confirmed 5/MG one-shot"), so I'm flagging it as a documented divergence, not a bug. Worth knowing that "one buzz" is literally true on Android and not quite on Apple.
The practical consequence
Anyone building a haptic ladder on a 5/MG has to send N separate buzzes spaced app-side, because the single frame's repeat field is pinned to 0. The app already does this where it needs to — LiveSessionRunner.swift:216-222 and LiveSessionRunner.kt:177-182 walk a pulse list with asyncAfter/delay. That's the working pattern; it just isn't written down as the required one.
Possible follow-ups, in increasing order of risk
- Docs only. Note in
HapticPayloads.swiftand next toBuzzPatternthatloopsis discarded on 5/MG. Zero risk, and it's the part I'd argue for regardless of the rest. - Delete or mark the dead code.
notificationBuzzandbuzz(pattern:loops:), and fix the latter's incorrect docstring. - Hide or disable the pattern picker on a 5/MG connection, since it currently offers a choice that does nothing on that hardware.
- Test
overallLoop != 0on the 0x13 buzz. This is the only one that touches a proven payload, and the CHANGELOG's reasoning for not doing it still stands. Would need someone with a 5/MG willing to try it, and it should stay behind a flag if anyone does.
Happy to do (1) and (2) as a PR if that's useful — I didn't want to send a change to a hardware-confirmed payload path uninvited. Filing this mostly so the next person who reads BuzzPattern and expects four distinct buzzes finds out here instead of on-wrist.
Credit where it's due: the golden frame and the "keep the working payload" call are @ryanbr's, and the CHANGELOG note is what let me reconstruct the intent rather than guess at it.
Activity
ryanbr commentedon Jul 30, 2026
Checked every claim here against the tree; all of them hold. The hardcoded 12-byte payload with byte[11]
= 0,
loopsnever reaching the wire on 5/MG,notificationBuzzhaving only two test callers,AppModel.buzz(pattern:loops:)having zero, command 80 never being sent, and the alarm path shippingoverallLoop = 7. The last one is the useful part: it proves the field is real and hardware-acceptednon-zero, just not on the buzz path.
One thing to add — the picker is ungated on both platforms, not just Apple:
NotificationSettingsView.swift:180andNotificationsSettingsScreen.kt:773both iterate all four caseswith no family check. So a 5/MG owner is offered Single/Double/Triple/Long and gets the same buzz from
each. That's the part I'd treat as the actual bug: a settings control that does nothing is worse than an
absent one, because the user concludes their strap is broken rather than the feature is unsupported.
What I'd do, in order:
intensity levels.
AppModel.buzz(pattern:loops:)— zero callers and its docstring names a caller that doesn'texist — and either give
MaverickHaptics.notificationBuzza Kotlin twin or drop it as test-only.hardware-confirmed at 0, nobody has tried non-zero on 0x13, and you say so yourself. If it's worth
trying, it wants the staged shape you used in Make the R22 deep-data unlock actually reversible: a disable sequence with a mandatory read-back (#174) #932 — vary one byte, read back, stop on refusal — not a
blanket change to a proven payload.
The two corrections are worth having on the record. "7 presets" describing a 4.0 hardware table NOOP has
never enumerated is exactly the kind of thing that becomes folklore, and
docs/BLE_REVERSE_ENGINEERING.mdsaying it without saying "unread by us" is how it spread.
Happy to take 1 and 2 as a PR if you'd rather stay on the protocol side.
Haptics: stop the app claiming things the 5/MG buzz cannot do (#926) (#…
ryanbr commentedon Aug 6, 2026
Resolved on
main— every actionable finding here has landed, both platforms. Mapping each to where it was fixed:Picker no longer lies on 5/MG (the actual bug). Rather than hide the control — the choice is stored per app and genuinely applies to a WHOOP 4.0, so hiding it would drop a real setting — both platforms now caption the 5/MG limitation:
NotificationSettingsView.swift— "Every pattern buzzes the same on a WHOOP 5/MG — the repeat count isn't sent to that strap. Your choice is saved and applies to a WHOOP 4.0."NotificationsSettingsScreen.kt, gated onfiveMgSelected || live.whoop5Detected.Dead code.
AppModel.buzz(pattern:loops:)(zero callers, false docstring) is deleted.MaverickHaptics.notificationBuzzis kept as the framing golden-frame test's only user but doc-flagged "NOTHING SHIPS THIS (#926)" so nobody mistakes it for a live path.The payload is deliberately left alone. The hardcoded 12-byte literal with
overallLoop = 0stays — the golden frame is hardware-confirmed and the HELD reasoning still stands (no one has tried non-zero on0x13). If it's ever worth trying, it wants the staged one-byte-at-a-time shape from #932, not a blanket change.Both corrections are now on the record:
docs/BLE_REVERSE_ENGINEERING.mdnow states plainly it's a claim about the STRAP's own table (readable viaGET_ALL_HAPTICS_PATTERN/command 80), that neither platform has ever sent that command, and that NOOP has never enumerated it — it exposes fourBuzzPatternchoices, all patternId 2.overallLoop = 7), proving the field is real and hardware-accepted — just hardcoded to 0 on the buzz path.The live-session intensity distinction (
isLong ? 2 : 1) is an internal inhale/exhale behavior with no user-facing control, so there's nothing to label there — it's just a documented 5/MG no-op.Thanks for filing it as a capability finding with the exact line references — that's what made it quick to verify and fix without touching the proven payload. Closing as done.
Haptics: stop the app claiming things the 5/MG buzz cannot do (ryanbr…
fix: remove the now-false 5/MG buzz caption on iOS too (#926)
fix(ble): Apple one-shot buzz on WHOOP 5/MG no longer sends RUN_ALARM