Skip to content
Merged
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
30 changes: 24 additions & 6 deletions Strand/BLE/FrameRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -484,10 +484,12 @@ public final class FrameRouter {
}

/// Extract the armed-alarm epoch from a GET_ALARM_TIME (cmd 67) COMMAND_RESPONSE, defensively.
/// The WHOOP 4.0 response layout is UNDOCUMENTED, so this tries the two shapes the firmware could
/// plausibly answer with - the SET_ALARM_TIME mirror (`[form 0x01][u32 LE epoch]…`, matching the
/// 9-byte payload we arm with) first, then a bare leading u32 LE - and accepts a candidate only when
/// it passes `isPlausibleAlarmEpoch`. Anything else returns nil and the caller logs raw hex instead.
/// The WHOOP 4.0 response layout is UNDOCUMENTED, so this tries the shapes the firmware has been
/// seen to answer with - the 11-byte GET readback captured on fw 41.17.6.0
/// (`[form 0x01][stored flag][u32 LE epoch][00 00][04 00 20]`, epoch at offset 2) first, then the
/// SET_ALARM_TIME mirror (`[form 0x01][u32 LE epoch]…`, matching the 9-byte payload we arm with),
/// then a bare leading u32 LE - and accepts a candidate only when it passes
/// `isPlausibleAlarmEpoch`. Anything else returns nil and the caller logs raw hex instead.
/// Pure and CoreBluetooth-free so golden tests pin it (AlarmReadbackDecodeTests).
nonisolated static func armedAlarmEpoch(in frame: [UInt8]) -> UInt32? {
guard let payload = commandResponsePayload(in: frame) else { return nil }
Expand All @@ -498,14 +500,29 @@ public final class FrameRouter {
| (UInt32(payload[i + 2]) << 16)
| (UInt32(payload[i + 3]) << 24)
}
// The GET readback (fw 41.17.6.0, three arm/readback captures 2026-08-26..28, #34/#1706): the
// epoch sits ONE byte further than in the SET mirror, because the readback carries a stored
// flag (0x00 = nothing stored, 0x01 = stored) the arm payload does not. The mirror-offset read
// of this shape returns the epoch's LOW THREE bytes shifted up a byte, plus the flag — wrong
// by roughly 256x and free to land anywhere in u32 range. In all three captures it landed on
// a 2045 date INSIDE the 2017..2100 plausibility window (an arm for 2026-08-26 read back as
// 2045-09-24), so the gate did not catch it and a MISMATCH was counted against a strap whose
// register is fine. So on this shape the mirror offsets are known-wrong and must NOT be tried:
// offset 2 decodes, or the payload falls to the raw-hex line.
if payload.count == 11, payload.first == 0x01 {
if let e = u32le(at: 2), isPlausibleAlarmEpoch(e) { return e }
return nil
}
if payload.first == 0x01, let e = u32le(at: 1), isPlausibleAlarmEpoch(e) { return e }
if let e = u32le(at: 0), isPlausibleAlarmEpoch(e) { return e }
return nil
}

/// True when a GET_ALARM_TIME readback explicitly reports NO alarm stored — the epoch field decodes
/// to 0 in the same shapes `armedAlarmEpoch` reads (SET-mirror `[0x01][u32=0]` first, then a bare
/// leading `u32=0`). This is the strap's "nothing armed" sentinel, distinct from a genuinely
/// to 0 in the same shapes `armedAlarmEpoch` reads (the 11-byte GET readback `[0x01][flag][u32=0]…`
/// first — the #34 field-report payload `01 00 00 00 00 00 00 00 04 00 20` is exactly this shape
/// with the stored flag 0x00 — then the SET-mirror `[0x01][u32=0]`, then a bare leading `u32=0`).
/// This is the strap's "nothing armed" sentinel, distinct from a genuinely
/// unparseable payload: an arm the strap silently dropped reads back as epoch 0, so labelling it
/// "unrecognised" hid the real signal (#34). Only consulted AFTER `armedAlarmEpoch` returns nil, so a
/// plausible armed epoch never reaches here. Pure/CoreBluetooth-free so AlarmReadbackDecodeTests pin it.
Expand All @@ -518,6 +535,7 @@ public final class FrameRouter {
| (UInt32(payload[i + 2]) << 16)
| (UInt32(payload[i + 3]) << 24)
}
if payload.count == 11, payload.first == 0x01, let e = u32le(at: 2) { return e == 0 }
if payload.first == 0x01, let e = u32le(at: 1) { return e == 0 }
if let e = u32le(at: 0) { return e == 0 }
return false
Expand Down
41 changes: 41 additions & 0 deletions StrandTests/AlarmReadbackDecodeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,40 @@ final class AlarmReadbackDecodeTests: XCTestCase {
XCTAssertEqual(FrameRouter.armedAlarmEpoch(in: frame), 1_750_990_944)
}

/// The armed GET readback, captured verbatim on fw 41.17.6.0 (2026-08-26, #34/#1706): 11 bytes,
/// `[form 0x01][stored 0x01][u32 LE epoch][00 00][04 00 20]`, epoch at OFFSET 2. The SET-mirror
/// read at offset 1 decodes this very payload to 2_389_889_025 — 2045-09-24, inside the
/// plausibility window — so the gate cannot catch the misread and only this fixture pins the
/// offset. (The arm that produced it sent epoch 1_787_720_400; the strap stored it exactly.)
func testGetReadbackPayload_decodesEpochAtOffsetTwo() {
let frame = responseFrame(payload: [0x01, 0x01, 0xD0, 0x72, 0x8E, 0x6A, 0x00, 0x00, 0x04, 0x00, 0x20])
XCTAssertEqual(FrameRouter.armedAlarmEpoch(in: frame), 1_787_720_400)
}

/// All three arm/readback pairs from the same capture set (2026-08-26..28): each readback carries
/// exactly the epoch the arm sent, at offset 2. Three independent values rule out coincidence —
/// under the offset-1 misread each decodes to the value the app actually logged (2389889025 /
/// 2412007425 / 2434125825: the sent epoch's low three bytes shifted up a byte, plus the flag).
func testGetReadback_allThreeCaptures_decodeTheSentEpoch() {
let captures: [([UInt8], UInt32)] = [
([0x01, 0x01, 0xD0, 0x72, 0x8E, 0x6A, 0x00, 0x00, 0x04, 0x00, 0x20], 1_787_720_400),
([0x01, 0x01, 0x50, 0xC4, 0x8F, 0x6A, 0x00, 0x00, 0x04, 0x00, 0x20], 1_787_806_800),
([0x01, 0x01, 0xD0, 0x15, 0x91, 0x6A, 0x00, 0x00, 0x04, 0x00, 0x20], 1_787_893_200),
]
for (payload, sentEpoch) in captures {
XCTAssertEqual(FrameRouter.armedAlarmEpoch(in: responseFrame(payload: payload)), sentEpoch)
}
}

/// An 11-byte readback whose epoch field is implausible falls to the raw-hex line, NOT to the
/// mirror offsets — those are known-wrong for this shape (they fold the stored flag into the
/// epoch's low byte), so trying them here could resurrect the misread this shape exists to kill.
func testGetReadback_implausibleEpoch_fallsToRawHexNotMirrorRead() {
let frame = responseFrame(payload: [0x01, 0x01, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00, 0x20])
XCTAssertNil(FrameRouter.armedAlarmEpoch(in: frame))
XCTAssertFalse(FrameRouter.readbackReportsNoAlarm(in: frame))
}

/// A result-style single byte (e.g. an UNSUPPORTED echo) must not decode; the router falls back to
/// the raw-hex line.
func testShortGarbagePayload_decodesNil() {
Expand Down Expand Up @@ -84,6 +118,13 @@ final class AlarmReadbackDecodeTests: XCTestCase {
XCTAssertFalse(FrameRouter.readbackReportsNoAlarm(in: frame))
}

/// An armed 11-byte GET readback is NOT "no alarm" — same mutual exclusion, readback shape.
func testArmedGetReadback_isNotReportedAsNoAlarm() {
let frame = responseFrame(payload: [0x01, 0x01, 0xD0, 0x72, 0x8E, 0x6A, 0x00, 0x00, 0x04, 0x00, 0x20])
XCTAssertEqual(FrameRouter.armedAlarmEpoch(in: frame), 1_787_720_400)
XCTAssertFalse(FrameRouter.readbackReportsNoAlarm(in: frame))
}

/// A short result-style payload (0x03) is neither an armed epoch NOR the epoch-0 sentinel — it's
/// genuinely unparseable, so it still falls through to the raw-hex "unrecognised" branch.
func testShortGarbage_isNotReportedAsNoAlarm() {
Expand Down
31 changes: 25 additions & 6 deletions android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9714,10 +9714,12 @@ internal fun isPlausibleAlarmEpoch(epoch: Long): Boolean = epoch in 1_500_000_00

/**
* Extract the armed-alarm epoch from a GET_ALARM_TIME (cmd 67) COMMAND_RESPONSE, defensively (#401
* close-out). The WHOOP 4.0 response layout is UNDOCUMENTED, so this tries the two shapes the firmware
* could plausibly answer with - the SET_ALARM_TIME mirror (`[form 0x01][u32 LE epoch]…`, matching the
* 9-byte payload we arm with) first, then a bare leading u32 LE - and accepts a candidate only when it
* passes [isPlausibleAlarmEpoch]. Anything else returns null and the caller logs raw hex instead.
* close-out). The WHOOP 4.0 response layout is UNDOCUMENTED, so this tries the shapes the firmware has
* been seen to answer with - the 11-byte GET readback captured on fw 41.17.6.0
* (`[form 0x01][stored flag][u32 LE epoch][00 00][04 00 20]`, epoch at offset 2) first, then the
* SET_ALARM_TIME mirror (`[form 0x01][u32 LE epoch]…`, matching the 9-byte payload we arm with), then
* a bare leading u32 LE - and accepts a candidate only when it passes [isPlausibleAlarmEpoch].
* Anything else returns null and the caller logs raw hex instead.
* Pinned by `AlarmReadbackDecodeTest`; twin of the Swift `FrameRouter.armedAlarmEpoch`.
*/
internal fun whoop4ArmedAlarmEpoch(frame: ByteArray): Long? {
Expand All @@ -9729,6 +9731,18 @@ internal fun whoop4ArmedAlarmEpoch(frame: ByteArray): Long? {
((payload[at + 2].toLong() and 0xFFL) shl 16) or
((payload[at + 3].toLong() and 0xFFL) shl 24)
}
// The GET readback (fw 41.17.6.0, three arm/readback captures 2026-08-26..28, #34/#1706): the
// epoch sits ONE byte further than in the SET mirror, because the readback carries a stored flag
// (0x00 = nothing stored, 0x01 = stored) the arm payload does not. The mirror-offset read of this
// shape returns the epoch's LOW THREE bytes shifted up a byte, plus the flag — wrong by roughly
// 256x and free to land anywhere in u32 range. In all three captures it landed on a 2045 date
// INSIDE the 2017..2100 plausibility window (an arm for 2026-08-26 read back as 2045-09-24), so
// the gate did not catch it and a MISMATCH was counted against a strap whose register is fine. So
// on this shape the mirror offsets are known-wrong and must NOT be tried: offset 2 decodes, or
// the payload falls to the raw-hex line.
if (payload.size == 11 && payload[0] == 0x01.toByte()) {
return u32le(2)?.takeIf { isPlausibleAlarmEpoch(it) }
}
if (payload.isNotEmpty() && payload[0] == 0x01.toByte()) {
u32le(1)?.takeIf { isPlausibleAlarmEpoch(it) }?.let { return it }
}
Expand All @@ -9737,8 +9751,10 @@ internal fun whoop4ArmedAlarmEpoch(frame: ByteArray): Long? {

/**
* True when a GET_ALARM_TIME readback explicitly reports NO alarm stored — the epoch field decodes to
* 0 in the same shapes [whoop4ArmedAlarmEpoch] reads (SET-mirror `[0x01][u32=0]` first, then a bare
* leading `u32=0`). This is the strap's "nothing armed" sentinel, distinct from a genuinely unparseable
* 0 in the same shapes [whoop4ArmedAlarmEpoch] reads (the 11-byte GET readback `[0x01][flag][u32=0]…`
* first — the #34 field-report payload `01 00 00 00 00 00 00 00 04 00 20` is exactly this shape with
* the stored flag 0x00 — then the SET-mirror `[0x01][u32=0]`, then a bare leading `u32=0`). This is
* the strap's "nothing armed" sentinel, distinct from a genuinely unparseable
* payload: an arm the strap silently dropped reads back as epoch 0, so labelling it "unrecognised" hid
* the real signal (#34). Only consulted AFTER [whoop4ArmedAlarmEpoch] returns null. Twin of the Swift
* `FrameRouter.readbackReportsNoAlarm`; pinned by `AlarmReadbackDecodeTest`.
Expand All @@ -9752,6 +9768,9 @@ internal fun whoop4ReadbackReportsNoAlarm(frame: ByteArray): Boolean {
((payload[at + 2].toLong() and 0xFFL) shl 16) or
((payload[at + 3].toLong() and 0xFFL) shl 24)
}
if (payload.size == 11 && payload[0] == 0x01.toByte()) {
return u32le(2)?.let { it == 0L } ?: false
}
if (payload.isNotEmpty() && payload[0] == 0x01.toByte()) {
return u32le(1)?.let { it == 0L } ?: false
}
Expand Down
51 changes: 51 additions & 0 deletions android/app/src/test/java/com/noop/ble/AlarmReadbackDecodeTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,49 @@ class AlarmReadbackDecodeTest {
assertEquals(1_750_990_944L, whoop4ArmedAlarmEpoch(frame))
}

/**
* The armed GET readback, captured verbatim on fw 41.17.6.0 (2026-08-26, #34/#1706): 11 bytes,
* `[form 0x01][stored 0x01][u32 LE epoch][00 00][04 00 20]`, epoch at OFFSET 2. The SET-mirror
* read at offset 1 decodes this very payload to 2_389_889_025 — 2045-09-24, inside the
* plausibility window — so the gate cannot catch the misread and only this fixture pins the
* offset. (The arm that produced it sent epoch 1_787_720_400; the strap stored it exactly.)
*/
@Test
fun getReadbackPayload_decodesEpochAtOffsetTwo() {
val frame = responseFrame(payload = bytes(0x01, 0x01, 0xD0, 0x72, 0x8E, 0x6A, 0x00, 0x00, 0x04, 0x00, 0x20))
assertEquals(1_787_720_400L, whoop4ArmedAlarmEpoch(frame))
}

/**
* All three arm/readback pairs from the same capture set (2026-08-26..28): each readback carries
* exactly the epoch the arm sent, at offset 2. Three independent values rule out coincidence —
* under the offset-1 misread each decodes to the value the app actually logged (2389889025 /
* 2412007425 / 2434125825: the sent epoch's low three bytes shifted up a byte, plus the flag).
*/
@Test
fun getReadback_allThreeCaptures_decodeTheSentEpoch() {
val captures = listOf(
bytes(0x01, 0x01, 0xD0, 0x72, 0x8E, 0x6A, 0x00, 0x00, 0x04, 0x00, 0x20) to 1_787_720_400L,
bytes(0x01, 0x01, 0x50, 0xC4, 0x8F, 0x6A, 0x00, 0x00, 0x04, 0x00, 0x20) to 1_787_806_800L,
bytes(0x01, 0x01, 0xD0, 0x15, 0x91, 0x6A, 0x00, 0x00, 0x04, 0x00, 0x20) to 1_787_893_200L,
)
for ((payload, sentEpoch) in captures) {
assertEquals(sentEpoch, whoop4ArmedAlarmEpoch(responseFrame(payload = payload)))
}
}

/**
* An 11-byte readback whose epoch field is implausible falls to the raw-hex line, NOT to the
* mirror offsets — those are known-wrong for this shape (they fold the stored flag into the
* epoch's low byte), so trying them here could resurrect the misread this shape exists to kill.
*/
@Test
fun getReadback_implausibleEpoch_fallsToRawHexNotMirrorRead() {
val frame = responseFrame(payload = bytes(0x01, 0x01, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00, 0x20))
assertNull(whoop4ArmedAlarmEpoch(frame))
assertFalse(whoop4ReadbackReportsNoAlarm(frame))
}

/** A result-style single byte (e.g. an UNSUPPORTED echo) must not decode - raw-hex fallback. */
@Test
fun shortGarbagePayload_decodesNull() {
Expand Down Expand Up @@ -131,6 +174,14 @@ class AlarmReadbackDecodeTest {
assertFalse(whoop4ReadbackReportsNoAlarm(frame))
}

/** An armed 11-byte GET readback is NOT "no alarm" — same mutual exclusion, readback shape. */
@Test
fun armedGetReadback_isNotReportedAsNoAlarm() {
val frame = responseFrame(payload = bytes(0x01, 0x01, 0xD0, 0x72, 0x8E, 0x6A, 0x00, 0x00, 0x04, 0x00, 0x20))
assertEquals(1_787_720_400L, whoop4ArmedAlarmEpoch(frame))
assertFalse(whoop4ReadbackReportsNoAlarm(frame))
}

/** A short result-style payload (0x03) is neither an armed epoch NOR the epoch-0 sentinel. */
@Test
fun shortGarbage_isNotReportedAsNoAlarm() {
Expand Down
Loading