I wanted a small buzz on my arm when certain people, like family, messaged me through WhatsApp or Messages. I’m not on my phone all the time, and it’s useful to know they’ve got in touch when I’ve left it somewhere else. My WHOOP 4.0 already has a vibration motor for alarms, so I wanted to see whether I could use it for that too.
The condition was that the official WHOOP app had to keep working. I still wanted it connected, collecting my measurements and syncing normally. I wasn’t looking to build another health app or manage my WHOOP account. I wanted a small Android app that could ask the band to buzz and otherwise leave it alone.
I then started with NOOP, which already documents a lot of the WHOOP Bluetooth protocol. Its scope is much broader: it is a stand-alone companion with health-data access and other device operations. I only needed a small part of that. Its source uses a PolyForm Noncommercial license, so I used the protocol findings as a reference and (GPT-6.1 Sol) wrote the implementation rather than copying its client.
Before adding notifications, I built a screen with device discovery, connection status, logs, a test button and a disconnect button. On Android, I could inspect devices already connected through GATT, the BLE interface that exposes services and characteristics. From there, the app could open its own client to an already-bonded WHOOP. That list doesn’t tell me which app owns a connection, so the official app still needed to be checked on the phone.
The first write used command 79, RUN_HAPTICS_PATTERN, with the documented
payload:
02 03 00 00 00
That selects preset 2 with a loop byte of 3. The WHOOP 4 protocol reference gave me the framing, sequence handling and checksums needed to send it. The encoder adds the command packet type, sequence number, CRC-8 and CRC-32. I kept writes acknowledged and serialised, so the app waits for one operation to finish before submitting another.
An acknowledged write only told me that the Bluetooth write completed. I still
had to feel the vibration. The first phone tests worked, and the official app
continued connecting and syncing without asking me to pair again. I moved
connection ownership out of the screen and added an opt-in connectedDevice
foreground service. It keeps the existing client alive and shows an ongoing
notification with a Disconnect action.
The screen can now stay closed while that service is active. After Bluetooth loss, a force-stop or a process restart, I still need to reopen the app and reconnect. I haven’t added automatic scanning or an endless reconnect loop. If the band is unavailable, skipped alerts stay skipped.
The app itself is Kotlin, with Compose and Material 3 for the UI and DataStore
for preferences. Android’s NotificationListenerService passes notification
metadata to a policy layer, which decides whether to create a HapticRequest.
That request goes through the same queued controller as a manual preview. The
listener never writes directly to Bluetooth.
Notification access is a broad Android permission, but my app only uses packages, identities, flags and event times. It ignores its own notifications, ongoing service notifications and group summaries. Selected apps, quiet hours and cooldowns decide what reaches the controller. There is a shared ten-second minimum between vibrations, with notification budgets of three per minute and twenty per hour. Extra alerts are dropped rather than saved up for later. The installed app has no Internet permission.
Once the usual vibration was working, I wanted something shorter for some apps. I had added one-, two- and three-cycle choices, but one cycle of a preset isn’t necessarily one little tap. I wanted a brief single vibration, and changing the repeat count wasn’t giving me that.
I tried investigating the other presets from my Mac, with Bluetooth turned off on the phone. This was the part that went wrong. The Mac connected and sent the test requests without showing a pairing prompt. When I restored phone Bluetooth, the official WHOOP app couldn’t reconnect to the band.
Android’s logs showed a missing encryption key, and macOS listed WHOOP as paired. The evidence pointed to the Mac having silently established or replaced the bonding state. Avoiding an explicit pairing call hadn’t protected the phone’s pairing. I had to remove the Mac bond and re-pair through the official app on the phone. Connection and sync recovered afterwards. The experiment had failed my no-re-pairing requirement, even though the individual vibration tests worked.
I disabled connection actions in the Mac tool after that. The later tests used the phone’s existing pairing. I kept the failed experiment in the notes because it changes what I’d consider safe to repeat with another Bluetooth host.
Those tests did give me some useful observations. Preset 0 produced a long single
buzz, preset 1 multiple pulses, and preset 2 the familiar vibration. Requests for
presets 3 through 6 were rejected on my band. A query response started with 07,
which an older client comment described as seven presets. My results didn’t
support treating that byte as a count; it could be a bitmask for the first three
IDs. I left that interpretation unresolved.
Another research client used a loop byte of zero and called the result a short pulse. That was worth trying. On my band, preset 2 with zero gave me two short pulses, a little “tum tum.” Preset 0 with zero was a single buzz, but still too long. Preset 1 also failed to give me the brief single tap I wanted.
Checking the official app’s packet builder confirmed that this request has a preset byte, a loop byte and three padding bytes. There wasn’t a millisecond duration field hiding in those zeros. I needed to try a different approach.
The next experiment started preset 0, waited for the start write to be
acknowledged, then sent command 122, STOP_HAPTICS, with body 00. This legacy
stop request is described in the
haptics reference,
with incomplete firmware coverage. I kept it in a separate manual diagnostic
and tested it when no official alarm was ringing or due.
I first added a 100-ms delay before the stop. The buzz was shorter, but still too long. The logs helped explain the timing: roughly 100 ms to receive the start acknowledgement, then my added pause, then another 135 ms until the stop acknowledgement. A 100-ms delay in the app was clearly not a measurement of a 100-ms vibration.
I removed the added pause and submitted the stop as soon as the start was acknowledged. The log showed a 2-ms turnaround between those two host events. Bluetooth and the motor still take time, so that doesn’t mean a 2-ms tap. What I felt was a single tap I could use, although it was still longer than the tiny click I’d originally imagined.
There is a catch with that approach: the stop is global. It has no way to say “stop only the vibration my app started.” It could cut off an official alarm ringing at the same time. The app doesn’t send alarm-scheduling or alarm-disabling commands, but that alone doesn’t satisfy my original requirement that alarms keep working without interference.
So Short single tap is an explicit per-app choice, with a stop opt-in and a physical preview before it can be saved. The five earlier choices still use command 79 alone. The stop sequence also waits for the first acknowledgement, checks the same connection session before the second write, and never retries a failed stop. If it fails, the buzz may finish normally.
My hardware tests have been on a Nothing A069 running Android 16. The early phone tests included checking official connection, sync and live data. I didn’t repeat those checks for every later timing test, and I haven’t validated scheduled alarms or measured extended battery use. I also haven’t recorded the band’s firmware version. Those gaps remain in the test notes.
Ordinary notification forwarding has been useful in daily use; physical notification delivery with the new tap is still something to try after assigning it. I can leave the app minimised with its connection service active, and I have a visible way to stop it when I don’t want it running.
If anyone is interested, let me know and I will open-source it.