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
66 changes: 64 additions & 2 deletions android/app/src/main/java/com/noop/protocol/Enums.kt
Original file line number Diff line number Diff line change
Expand Up @@ -43,21 +43,83 @@ enum class MetadataType(val rawValue: Int) {
}
}

/** EVENT frame event code (offset 6 in an EVENT frame). */
/**
* EVENT frame event code (offset 6 in an EVENT frame).
*
* The FULL shared `EventNumber` catalogue, kept in lockstep with
* `WhoopProtocol/Resources/whoop_protocol.json` — the cross-platform contract says a frame must label
* identically on both platforms. Only 13 of these were listed before, so everything else rendered as a
* bare `0x44(68)` in an Android strap log while iOS named it; #791 was filed partly on the strength of an
* "uncatalogued" event that iOS already knew. That gap makes an Android bug report unable to say what the
* strap is reporting — RTC_LOST, BOOT, BLE_SYSTEM_RESET and EXTENDED_BATTERY_INFORMATION all arrived
* anonymous.
*
* Names only. Event PAYLOAD decoding stays exactly as it was: family-gated and deliberately conservative,
* because several of these payloads have no on-device 5.0 ground truth and are left raw rather than ported
* from 4.0 on faith (see `decodeWhoop5Event`). A name here does not imply a decoder.
*
* A number absent from the schema still falls through to `hexLabel`, and is NEVER given an invented name —
* event 68 (`0x44`) observed on a real 4.0 in #791 is unknown to both platforms and stays unknown.
*/
enum class EventNumber(val rawValue: Int) {
UNDEFINED(0),
ERROR(1),
CONSOLE_OUTPUT(2),
BATTERY_LEVEL(3),
SYSTEM_CONTROL(4),
EXTERNAL_5V_ON(5),
EXTERNAL_5V_OFF(6),
CHARGING_ON(7),
CHARGING_OFF(8),
WRIST_ON(9),
WRIST_OFF(10),
BLE_CONNECTION_UP(11),
BLE_CONNECTION_DOWN(12),
RTC_LOST(13),
DOUBLE_TAP(14),
BOOT(15),
SET_RTC(16),
TEMPERATURE_LEVEL(17),
PAIRING_MODE(18),
SERIAL_HEAD_CONNECTED(19),
SERIAL_HEAD_REMOVED(20),
BATTERY_PACK_CONNECTED(21),
BATTERY_PACK_REMOVED(22),
BLE_BONDED(23),
BLE_HR_PROFILE_ENABLED(24),
BLE_HR_PROFILE_DISABLED(25),
TRIM_ALL_DATA(26),
TRIM_ALL_DATA_ENDED(27),
FLASH_INIT_COMPLETE(28),
STRAP_CONDITION_REPORT(29),
BOOT_REPORT(30),
EXIT_VIRGIN_MODE(31),
CAPTOUCH_AUTOTHRESHOLD_ACTION(32),
BLE_REALTIME_HR_ON(33),
BLE_REALTIME_HR_OFF(34),
ACCELEROMETER_RESET(35),
AFE_RESET(36),
SHIP_MODE_ENABLED(37),
SHIP_MODE_DISABLED(38),
SHIP_MODE_BOOT(39),
CH1_SATURATION_DETECTED(40),
CH2_SATURATION_DETECTED(41),
ACCELEROMETER_SATURATION_DETECTED(42),
BLE_SYSTEM_RESET(43),
BLE_SYSTEM_ON(44),
BLE_SYSTEM_INITIALIZED(45),
RAW_DATA_COLLECTION_ON(46),
RAW_DATA_COLLECTION_OFF(47),
STRAP_DRIVEN_ALARM_SET(56),
STRAP_DRIVEN_ALARM_EXECUTED(57),
APP_DRIVEN_ALARM_EXECUTED(58),
HAPTICS_FIRED(60);
STRAP_DRIVEN_ALARM_DISABLED(59),
HAPTICS_FIRED(60),
EXTENDED_BATTERY_INFORMATION(63),
HIGH_FREQ_SYNC_PROMPT(96),
HIGH_FREQ_SYNC_ENABLED(97),
HIGH_FREQ_SYNC_DISABLED(98),
HAPTICS_TERMINATED(100);

companion object {
private val byRaw = entries.associateBy { it.rawValue }
Expand Down
114 changes: 114 additions & 0 deletions android/app/src/test/java/com/noop/protocol/EventCatalogueTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package com.noop.protocol

import java.io.File
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test

/**
* Holds [EventNumber] in lockstep with the shared schema
* (`Packages/WhoopProtocol/Sources/WhoopProtocol/Resources/whoop_protocol.json`), which iOS reads directly
* at runtime.
*
* Android used to name only 13 of the 58 events, so every other event rendered as a bare `0x44(68)` in an
* Android strap log while iOS named it. #791 was filed partly on the strength of an "uncatalogued" event
* that iOS already knew. A label divergence breaks the cross-platform contract as surely as a value
* divergence, and it stays invisible until someone is reading a log.
*
* This reads the schema rather than restating it, on purpose. The bug being fixed is precisely "the schema
* grew and Android did not follow", so a test carrying its own copy of the catalogue would pass straight
* through a recurrence — it could only catch a deletion or a renumber.
*
* The lockstep check `Assume`-skips when the Swift tree is absent, following [DecoderOracleTest]'s
* convention, so it is a drift guard for a full checkout and not a hard gate everywhere. The targeted checks
* below never skip, so the #791 events and the revived RTC diagnostic stay pinned either way.
*/
class EventCatalogueTest {

/**
* Locates the schema the same way [DecoderOracleTest.oracleCopiesAreIdentical] locates the Swift oracle
* copy — `user.dir` is the module dir or the repo root depending on how the runner was launched — and
* skips gracefully by the same convention if the Swift tree is not present.
*/
@Test
fun everyEventInTheSharedSchemaIsNamedOnAndroid() {
val rel = "Packages/WhoopProtocol/Sources/WhoopProtocol/Resources/whoop_protocol.json"
val userDir = File(System.getProperty("user.dir"))
val schemaFile = listOf(File(userDir, rel), File(userDir, "../../$rel")).firstOrNull { it.exists() }
org.junit.Assume.assumeTrue(
"shared schema not found from user.dir=$userDir — skipping the catalogue lockstep check",
schemaFile != null,
)
val events = JSONObject(schemaFile!!.readText()).getJSONObject("enums").getJSONObject("EventNumber")
val schema = events.keys().asSequence().associate { it.toInt() to events.getString(it) }
assertTrue("EventNumber block parsed empty", schema.isNotEmpty())

val kotlin = EventNumber.entries.associate { it.rawValue to it.name }
assertEquals("Android is missing events the schema names", emptySet<Int>(), schema.keys - kotlin.keys)
assertEquals("Android names events the schema does not", emptySet<Int>(), kotlin.keys - schema.keys)
for ((raw, name) in schema) {
assertEquals("event $raw", name, kotlin[raw])
}
}

/** A copy-paste renumber would otherwise shadow an event silently. */
@Test
fun rawValuesAreUnique() {
assertEquals(
EventNumber.entries.size,
EventNumber.entries.map { it.rawValue }.toSet().size,
)
}

/** The events from #791 that an Android reporter could not previously name. */
@Test
fun theEventsThatMatterForAndroidBugReportsAreNamed() {
assertEquals("EXTENDED_BATTERY_INFORMATION", EventNumber.fromRaw(63)?.name) // untested battery path
assertEquals("RTC_LOST", EventNumber.fromRaw(13)?.name) // scrambled RTC
assertEquals("SET_RTC", EventNumber.fromRaw(16)?.name) // SET_CLOCK not sticking
assertEquals("BOOT", EventNumber.fromRaw(15)?.name) // brownout/reboot
assertEquals("BOOT_REPORT", EventNumber.fromRaw(30)?.name)
assertEquals("BLE_SYSTEM_RESET", EventNumber.fromRaw(43)?.name)
}

/**
* The behavioural bug the missing names caused, not just the cosmetic one.
*
* `DroppedRtcEvent.isRtcStateKind` matches the event LABEL by prefix, and the label is built from this
* enum (`"NAME(raw)"`, pinned by [FramingTest]). While events 13/15/16/30 were unnamed on Android their
* labels were `0x0D(13)` and friends, which match nothing — so the #324 dropped-RTC diagnostic was dead
* code on Android even though it mirrors Swift and iOS has captured it since #324
* (`HistoricalTimestampGateTests` pins the very string asserted below). That is the diagnostic which
* explains a future-dated strap, exactly the symptom reported in #791.
*/
@Test
fun rtcStateDiagnosticCanNowActuallyFire() {
// The exact label the Swift twin's test asserts.
assertTrue(DroppedRtcEvent.isRtcStateKind("RTC_LOST(13)"))
assertTrue(DroppedRtcEvent.isRtcStateKind("BOOT(15)"))
assertTrue(DroppedRtcEvent.isRtcStateKind("SET_RTC(16)"))
assertTrue(DroppedRtcEvent.isRtcStateKind("BOOT_REPORT(30)"))

// What Android produced before, and why the diagnostic never fired there.
assertFalse(DroppedRtcEvent.isRtcStateKind("0x0D(13)"))
// Still correctly excluded: not an RTC-state event.
assertFalse(DroppedRtcEvent.isRtcStateKind("BATTERY_LEVEL(3)"))
assertFalse(DroppedRtcEvent.isRtcStateKind("0x44(68)"))
}

/**
* An unnamed number must stay unnamed. Event 68 (`0x44`) was observed on a real WHOOP 4.0 in #791 and is
* unknown to both platforms; inventing a name — or borrowing one from `CommandNumber`, where 68 is a
* different thing entirely — would put a fiction in the log, which is worse than a hex code.
*/
@Test
fun unknownEventsAreNotGivenInventedNames() {
assertNull(EventNumber.fromRaw(68))
assertNull(EventNumber.fromRaw(48))
assertNull(EventNumber.fromRaw(123))
assertNull(EventNumber.fromRaw(255))
}
}
8 changes: 5 additions & 3 deletions android/app/src/test/java/com/noop/protocol/FramingTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -482,13 +482,15 @@ class FramingTest {

@Test
fun whoop5_event_decodesAtPlus4AndPreservesPayload() {
// Real 5/MG capture: uncatalogued event 0x1D(29) with a 16-byte payload — kept as hex so
// protocol research can classify it later.
// Real 5/MG capture: event 29 with a 16-byte payload. The shared catalogue names it
// STRAP_CONDITION_REPORT — event NAMES are family-independent (#791) — but its PAYLOAD has no
// on-device 5.0 ground truth, so it stays raw hex for protocol research rather than being decoded
// on faith. The payload assertion below is what pins that distinction.
val frame = fromHex("aa011c00010023d130c61d00e61ab7698a390c000e0000000000e8020b000100d2803585")
val parsed = Framing.parseFrame(frame, DeviceFamily.WHOOP5)
assertEquals("EVENT", parsed.typeName)
assertEquals(true, parsed.crcOk)
assertEquals("0x1D(29)", parsed.parsed["event"])
assertEquals("STRAP_CONDITION_REPORT(29)", parsed.parsed["event"])
assertEquals(1773607654, parsed.parsed["event_timestamp"])
assertEquals("8a390c000e0000000000e8020b000100", parsed.parsed["event_payload_hex"])
}
Expand Down
Loading