Skip to content

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

Closed

Description

@vishk23

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] : payload

Byte-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 the overallLoop byte 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

  1. Docs only. Note in HapticPayloads.swift and next to BuzzPattern that loops is discarded on 5/MG. Zero risk, and it's the part I'd argue for regardless of the rest.
  2. Delete or mark the dead code. notificationBuzz and buzz(pattern:loops:), and fix the latter's incorrect docstring.
  3. Hide or disable the pattern picker on a 5/MG connection, since it currently offers a choice that does nothing on that hardware.
  4. Test overallLoop != 0 on 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

  1. ryanbr commented on Jul 30, 2026

    @ryanbr
    Owner

    Checked every claim here against the tree; all of them hold. The hardcoded 12-byte payload with byte[11]
    = 0, loops never reaching the wire on 5/MG, notificationBuzz having only two test callers,
    AppModel.buzz(pattern:loops:) having zero, command 80 never being sent, and the alarm path shipping
    overallLoop = 7. The last one is the useful part: it proves the field is real and hardware-accepted
    non-zero, just not on the buzz path.

    One thing to add — the picker is ungated on both platforms, not just Apple:
    NotificationSettingsView.swift:180 and NotificationsSettingsScreen.kt:773 both iterate all four cases
    with 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:

    1. Gate or label the picker on 5/MG. Zero protocol risk, fixes the visible lie. Same for the live-session
      intensity levels.
    2. Delete AppModel.buzz(pattern:loops:) — zero callers and its docstring names a caller that doesn't
      exist — and either give MaverickHaptics.notificationBuzz a Kotlin twin or drop it as test-only.
    3. Leave the payload alone unless someone tests it. The HELD reasoning still stands: the golden frame is
      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.md
    saying 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.

  2. ryanbr commented on Aug 6, 2026

    @ryanbr
    Owner

    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:

    • Swift: 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."
    • Android (parity): NotificationsSettingsScreen.kt, gated on fiveMgSelected || live.whoop5Detected.

    Dead code. AppModel.buzz(pattern:loops:) (zero callers, false docstring) is deleted. MaverickHaptics.notificationBuzz is 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 = 0 stays — the golden frame is hardware-confirmed and the HELD reasoning still stands (no one has tried non-zero on 0x13). 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:

    • "7 presets" — docs/BLE_REVERSE_ENGINEERING.md now states plainly it's a claim about the STRAP's own table (readable via GET_ALL_HAPTICS_PATTERN/command 80), that neither platform has ever sent that command, and that NOOP has never enumerated it — it exposes four BuzzPattern choices, all patternId 2.
    • "no loop field" — documented as false: byte [11] is exactly that, and the alarm path ships it non-zero (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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions