diff --git a/Strand/BLE/FrameRouter.swift b/Strand/BLE/FrameRouter.swift index 83643d4440..95f2caf328 100644 --- a/Strand/BLE/FrameRouter.swift +++ b/Strand/BLE/FrameRouter.swift @@ -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 } @@ -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. @@ -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 diff --git a/StrandTests/AlarmReadbackDecodeTests.swift b/StrandTests/AlarmReadbackDecodeTests.swift index 2e4092d94f..e21b8eda4c 100644 --- a/StrandTests/AlarmReadbackDecodeTests.swift +++ b/StrandTests/AlarmReadbackDecodeTests.swift @@ -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() { @@ -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() { 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 759e30f5fc..08e42424f6 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -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? { @@ -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 } } @@ -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`. @@ -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 } diff --git a/android/app/src/test/java/com/noop/ble/AlarmReadbackDecodeTest.kt b/android/app/src/test/java/com/noop/ble/AlarmReadbackDecodeTest.kt index 3b16408fc1..198b0500b8 100644 --- a/android/app/src/test/java/com/noop/ble/AlarmReadbackDecodeTest.kt +++ b/android/app/src/test/java/com/noop/ble/AlarmReadbackDecodeTest.kt @@ -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() { @@ -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() {