From 3706eb652cfe159844400588337c99c18bdb7d6a Mon Sep 17 00:00:00 2001 From: c0dingnoobi Date: Wed, 26 Aug 2026 04:27:46 +0000 Subject: [PATCH] fix(ble): carry the buzz repeat count into the 5/MG haptic body RustCodec.buzzFrame takes only a seq, so overallLoop went out as 0 and every BuzzPattern gave a single identical pulse on a 5/MG. Build the body here and send it through the generic command builder instead. Verified on a WHOOP 5/MG: double, triple and long now differ. --- .../main/java/com/noop/ble/WhoopBleClient.kt | 27 ++++++++++- .../com/noop/ble/MaverickHapticBodyTest.kt | 47 +++++++++++++++++++ .../com/noop/protocol/SendFrameParityTest.kt | 2 + 3 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 android/app/src/test/java/com/noop/ble/MaverickHapticBodyTest.kt diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt index ab653522e1..51a4dde51e 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -383,6 +383,24 @@ class WhoopBleClient( */ const val DEFAULT_DEVICE_ID = "my-whoop" + /** + * 5/MG haptic body `[revision][effects x8][loopControl u16 LE][overallLoop]`, built from the + * 4.0-shaped `[patternId, loops, …]` payload [buzz] sends. overallLoop counts repeats AFTER + * the first pulse, so `loops - 1`, clamped 1..8; a payload with no count gives one pulse. + */ + internal fun maverickHapticBody(payload: ByteArray): ByteArray { + val loops = if (payload.size >= 2) (payload[1].toInt() and 0xFF).coerceIn(1, 8) else 1 + return byteArrayOf( + 0x01, // [0] revision + 47, 152.toByte(), 0, 0, 0, 0, 0, 0, // [1..8] effects x8 ("notify" preset) + 0, 0, // [9..10] loopControl u16 LE + (loops - 1).toByte(), // [11] overallLoop + ) + } + + /** Maverick haptic opcode; a 5/MG rejects RUN_HAPTICS_PATTERN. */ + internal const val MAVERICK_HAPTIC_CMD = 0x13 + // GATT UUIDs. WHOOP 4.0 custom service + its four characteristics. The shared contract also // lists a WHOOP5 service UUID; we scan for both so a v5 strap is discoverable, but the verified // characteristic/bond flow is the v4 layout (the only hardware-verified path). @@ -2045,16 +2063,21 @@ class WhoopBleClient( // WHOOP 5/MG haptics differ from 4.0 on opcode AND payload: cmd 0x13 (not 79, which a real MG // rejects) + the maverick "notify" preset body. Everything else builds via the generic FFI // command builder; the frame bytes are byte-identical to the former Kotlin envelope (test-locked). + // [maverickHapticBody] builds that body here because RustCodec.buzzFrame takes no payload and so + // carries no repeat count; the generic builder pads the inner body to a 4-byte boundary, which + // this 12-byte body already satisfies. val isHaptics = cmd == CommandNumber.RUN_HAPTICS_PATTERN + val hapticBody = if (gen5 && isHaptics) maverickHapticBody(payload) else payload val sent = sendCommand(cmd, withResponse) { s -> when { - gen5 && isHaptics -> RustCodec.buzzFrame(s) + gen5 && isHaptics -> + RustCodec.commandFrame(gen, seq = s, cmd = MAVERICK_HAPTIC_CMD, payload = hapticBody) else -> RustCodec.commandFrame(gen, seq = s, cmd = cmd.rawValue, payload = payload) } } if (sent) { val note = if (gen5) (if (isHaptics) " (puffin cmd=0x13)" else " (puffin)") else "" - log("→ ${cmd.name} payload=${payload.toHex()}$note") + log("→ ${cmd.name} payload=${hapticBody.toHex()}$note") } } diff --git a/android/app/src/test/java/com/noop/ble/MaverickHapticBodyTest.kt b/android/app/src/test/java/com/noop/ble/MaverickHapticBodyTest.kt new file mode 100644 index 0000000000..489c409d6f --- /dev/null +++ b/android/app/src/test/java/com/noop/ble/MaverickHapticBodyTest.kt @@ -0,0 +1,47 @@ +package com.noop.ble + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Byte-lock for the 5/MG haptic body [WhoopBleClient.maverickHapticBody] builds. Pure Kotlin, so it + * runs without the host libwhoop_ffi that SendFrameParityTest loads. + */ +class MaverickHapticBodyTest { + + private fun h(b: ByteArray) = b.joinToString("") { "%02x".format(it) } + + /** The body [WhoopBleClient.buzz] hands in: `[patternId=2, loops, 0, 0, 0]`. */ + private fun body(loops: Int) = + WhoopBleClient.maverickHapticBody(byteArrayOf(2, loops.toByte(), 0, 0, 0)) + + @Test + fun `one pulse leaves overallLoop at zero`() = + assertEquals("012f98000000000000000000", h(body(1))) + + @Test + fun `overallLoop counts the repeats after the first pulse`() { + assertEquals(1, body(2)[11].toInt()) // double + assertEquals(2, body(3)[11].toInt()) // triple + assertEquals(4, body(5)[11].toInt()) // the long cue IntervalsScreen sends + } + + @Test + fun `effects preset and loopControl are untouched by the repeat count`() { + assertEquals("012f980000000000000000", h(body(6).copyOfRange(0, 11))) + assertEquals(12, body(6).size) + } + + @Test + fun `loops is clamped, not wrapped`() { + assertEquals(7, body(8)[11].toInt()) + assertEquals(7, body(255)[11].toInt()) + assertEquals(0, body(0)[11].toInt()) + } + + @Test + fun `a payload too short to carry a count gives one pulse`() { + assertEquals(0, WhoopBleClient.maverickHapticBody(byteArrayOf(2))[11].toInt()) + assertEquals(0, WhoopBleClient.maverickHapticBody(byteArrayOf())[11].toInt()) + } +} diff --git a/android/app/src/test/java/com/noop/protocol/SendFrameParityTest.kt b/android/app/src/test/java/com/noop/protocol/SendFrameParityTest.kt index 50277e4637..51d746d4f0 100644 --- a/android/app/src/test/java/com/noop/protocol/SendFrameParityTest.kt +++ b/android/app/src/test/java/com/noop/protocol/SendFrameParityTest.kt @@ -24,6 +24,8 @@ class SendFrameParityTest { fun `gen4 generic command frame`() = assertEquals("aa0800a8230703008a9aa1bd", h(RustCodec.commandFrame(Gen.GEN4, seq, 3, byteArrayOf(0))!!)) + // Off the send path: buzzFrame takes no payload, so WhoopBleClient.maverickHapticBody builds the + // body and commandFrame carries it. Locked here as the single-pulse byte reference. @Test fun `gen5 maverick buzz frame`() = assertEquals("aa0114000001e1e1230713012f98000000000000000000006a1f7987", h(RustCodec.buzzFrame(seq)))