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
27 changes: 25 additions & 2 deletions android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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")
}
}

Expand Down
47 changes: 47 additions & 0 deletions android/app/src/test/java/com/noop/ble/MaverickHapticBodyTest.kt
Original file line number Diff line number Diff line change
@@ -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())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down