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
78 changes: 78 additions & 0 deletions Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Ecg.swift
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,84 @@ public enum Whoop5Ecg {
return end <= payload.count && payload.count - end <= maxPadding
}

// MARK: - The type-43 REALTIME_RAW_DATA record: the live ECG sample carrier (#891/#1100)
//
// OBSERVED on one WHOOP MG (WS50_r00, fw 50.39.1.0), not attested by any vendor document: once
// TOGGLE_LABRADOR_FILTERED(139)=1 has opened the master gate, the strap emits fixed-size 240-byte
// REALTIME_RAW_DATA (type 43) records whose body carries an i16-LE series.
//
// Twin of the Kotlin `Whoop5Ecg` helpers of the same names. Android grew the ECG app layer that consumes
// these first; the decode is a PROTOCOL fact, so it lands on both platforms together rather than living
// only where it happens to be called today. Nothing here decodes a status field, an HR, or a rhythm
// classification — only the sample series and a byte-fill count. Callers gate on the frame's CRC first.

/// Every REALTIME_RAW_DATA record OBSERVED on the MG was exactly this long.
public static let rawRecordLength = 240

/// Frame offset of the inner record's type byte on 5/MG (`[8]type [9]seq [10]cmd`).
public static let rawTypeOffset = 8

/// The inner record type byte for REALTIME_RAW_DATA.
public static let rawRecordType: UInt8 = 43

/// First body byte considered by `realtimeRawBodyNonZeroBytes` — excludes the constant sub-header.
public static let rawBodyStart = 24

/// First waveform byte. Bytes 24..33 are a constant 5x i16 sub-header that is NOT waveform.
public static let rawWaveformStart = 34

/// One past the last body byte; the remaining 4 bytes are the frame's CRC32 trailer.
public static let rawBodyEnd = 236

/// Samples one record carries: 101. Load-bearing beyond arithmetic — a fixed sample count per record is
/// exactly the shape that makes autocorrelation manufacture a peak at the record period, so anything
/// estimating a rate from these samples must exclude this lag. See the #194 withdrawal for what
/// happens when that is not done.
public static let samplesPerRawRecord = (rawBodyEnd - rawWaveformStart) / 2

/// Non-zero body bytes above which a record is treated as carrying a waveform rather than baseline.
public static let rawBodyActiveNonZeroBytes = 20

/// True for a frame shaped like a REALTIME_RAW_DATA record. Shape only — says nothing about CRC.
public static func isRealtimeRawRecord(_ frame: [UInt8]) -> Bool {
frame.count == rawRecordLength && frame[rawTypeOffset] == rawRecordType
}

/// The record's i16-LE sample series, or nil when `frame` is not a REALTIME_RAW_DATA record.
///
/// Signed two's-complement, little-endian, exactly `samplesPerRawRecord` values. Zero samples are
/// returned as zeros and never trimmed: for a research artifact a trailing run of zeros is evidence
/// about the record, not padding to be tidied away.
public static func realtimeRawSamples(_ frame: [UInt8]) -> [Int]? {
guard isRealtimeRawRecord(frame) else { return nil }
var out = [Int]()
out.reserveCapacity(samplesPerRawRecord)
var i = rawWaveformStart
while i + 1 < rawBodyEnd {
let raw = Int(frame[i]) | (Int(frame[i + 1]) << 8)
out.append(raw >= 0x8000 ? raw - 0x10000 : raw)
i += 2
}
return out
}

/// Non-zero bytes in the body region, or nil when `frame` is not a REALTIME_RAW_DATA record.
public static func realtimeRawBodyNonZeroBytes(_ frame: [UInt8]) -> Int? {
guard isRealtimeRawRecord(frame) else { return nil }
var n = 0
for i in rawBodyStart..<rawBodyEnd where frame[i] != 0 { n += 1 }
return n
}

/// ONE definition of "this record carries a waveform", so every consumer agrees about the same record.
///
/// What it cannot tell you: a flat record means the electrode circuit is open OR generation is off. It is
/// a byte-fill observation, never a statement about the wearer.
public static func realtimeRawSignalPresent(_ frame: [UInt8]) -> Bool? {
guard let n = realtimeRawBodyNonZeroBytes(frame) else { return nil }
return n > rawBodyActiveNonZeroBytes
}

/// The frame-level form of `plausibleFilteredPayload`, CRC-gated. This is what the app layer runs
/// over unclassified 5/MG frames while an ECG probe is armed.
public static func plausibleFilteredFrame(_ frame: [UInt8],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import XCTest
@testable import WhoopProtocol

/// #891/#1100: the type-43 REALTIME_RAW_DATA sample decoder, Swift side.
///
/// Twin of the Kotlin `Whoop5EcgRawRecordTest`. The offsets are a protocol fact, so both platforms carry the
/// decode and both pin it — the parity contract is that these two cannot drift, and a test only on the side
/// that happens to call it today would not stop Swift drifting.
final class Whoop5EcgRawRecordTests: XCTestCase {

/// A 240-byte record with `samples` written i16-LE from the waveform offset.
private func rawRecord(samples: [Int] = [], type: UInt8 = Whoop5Ecg.rawRecordType) -> [UInt8] {
var f = [UInt8](repeating: 0, count: Whoop5Ecg.rawRecordLength)
f[Whoop5Ecg.rawTypeOffset] = type
var i = Whoop5Ecg.rawWaveformStart
for v in samples {
if i + 1 >= Whoop5Ecg.rawBodyEnd { break }
let u = UInt16(bitPattern: Int16(truncatingIfNeeded: v))
f[i] = UInt8(u & 0xFF)
f[i + 1] = UInt8((u >> 8) & 0xFF)
i += 2
}
return f
}

func testTheRecordCarriesExactly101Samples() {
// Load-bearing beyond arithmetic: this is the lag the BPM estimator has to refuse.
XCTAssertEqual(Whoop5Ecg.samplesPerRawRecord, 101)
XCTAssertEqual(Whoop5Ecg.realtimeRawSamples(rawRecord())?.count, 101)
}

func testOnlyA240ByteType43FrameIsARawRecord() {
XCTAssertTrue(Whoop5Ecg.isRealtimeRawRecord(rawRecord()))
XCTAssertFalse(Whoop5Ecg.isRealtimeRawRecord(rawRecord(type: 0x2F)))
XCTAssertFalse(Whoop5Ecg.isRealtimeRawRecord(Array(rawRecord().prefix(200))))
XCTAssertNil(Whoop5Ecg.realtimeRawSamples(rawRecord(type: 0x2F)))
XCTAssertNil(Whoop5Ecg.realtimeRawBodyNonZeroBytes([0, 0, 0, 0]))
XCTAssertNil(Whoop5Ecg.realtimeRawSignalPresent([]))
}

func testSamplesAreSignedLittleEndian() {
let injected = [0, 1, -1, 32767, -32768, 258, -258]
let got = Whoop5Ecg.realtimeRawSamples(rawRecord(samples: injected))!
XCTAssertEqual(Array(got.prefix(injected.count)), injected)
// Everything past what was written stays zero rather than being trimmed away.
XCTAssertEqual(got[injected.count], 0)
}

func testZeroSamplesAreKeptNotTrimmed() {
let got = Whoop5Ecg.realtimeRawSamples(rawRecord(samples: [50, 0, 0, 0]))!
XCTAssertEqual(got.count, 101)
XCTAssertEqual(got[0], 50)
XCTAssertTrue(got.dropFirst().allSatisfy { $0 == 0 })
}

func testSignalPresentIsOneRuleSharedByEveryConsumer() {
XCTAssertEqual(Whoop5Ecg.realtimeRawBodyNonZeroBytes(rawRecord()), 0)
XCTAssertEqual(Whoop5Ecg.realtimeRawSignalPresent(rawRecord()), false)

// Just AT the threshold is still not "present" — the rule is strictly greater.
var atThreshold = rawRecord()
for i in 0..<Whoop5Ecg.rawBodyActiveNonZeroBytes { atThreshold[Whoop5Ecg.rawBodyStart + i] = 7 }
XCTAssertEqual(Whoop5Ecg.realtimeRawBodyNonZeroBytes(atThreshold), Whoop5Ecg.rawBodyActiveNonZeroBytes)
XCTAssertEqual(Whoop5Ecg.realtimeRawSignalPresent(atThreshold), false)

var over = atThreshold
over[Whoop5Ecg.rawBodyStart + Whoop5Ecg.rawBodyActiveNonZeroBytes] = 7
XCTAssertEqual(Whoop5Ecg.realtimeRawSignalPresent(over), true)
}

func testTheSubHeaderIsCountedForFillButNeverDecodedAsWaveform() {
// Bytes 24..33 are a constant sub-header: they count toward "is this record filled" but must not
// appear in the sample series, or the trace opens with five bogus spikes every record.
var f = rawRecord()
for i in Whoop5Ecg.rawBodyStart..<Whoop5Ecg.rawWaveformStart { f[i] = 0x7F }
XCTAssertEqual(
Whoop5Ecg.realtimeRawBodyNonZeroBytes(f),
Whoop5Ecg.rawWaveformStart - Whoop5Ecg.rawBodyStart
)
XCTAssertTrue(Whoop5Ecg.realtimeRawSamples(f)!.allSatisfy { $0 == 0 })
}
}
84 changes: 84 additions & 0 deletions android/app/src/main/java/com/noop/protocol/Whoop5Ecg.kt
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,90 @@ object Whoop5Ecg {
fun decodeRawFrame(frame: ByteArray, bytesPerSample: Int, payloadStart: Int = PUFFIN_PAYLOAD_START): RawLabradorPacket? =
innerPayload(frame, payloadStart)?.let { decodeRaw(it, bytesPerSample) }

// ---- The type-43 REALTIME_RAW_DATA record: the live ECG sample carrier (#891/#1100) -----------
//
// OBSERVED on one WHOOP MG (WS50_r00, fw 50.39.1.0), not attested by any vendor document: once
// TOGGLE_LABRADOR_FILTERED(139)=1 has opened the master gate, the strap emits fixed-size 240-byte
// REALTIME_RAW_DATA (type 43) records whose body carries an i16-LE series. The offsets below are the
// OBSERVED layout, and they live here — with the other Labrador protocol facts — rather than in the app
// layer, so the three consumers (live view, signal classifier, waveform export) cannot drift apart and
// so the decode is unit-testable without a strap.
//
// What this layout is NOT: the documented 17-byte status header ([HEADER_LENGTH]) is the FILTERED
// packet's, and it is a separate question whether it also sits inside this record (see #891 §7). Nothing
// here decodes a status field, an HR, or a rhythm classification — only the sample series and a byte-fill
// count. A record that fails CRC must never reach these helpers: callers gate on the frame's CRC first.

/** Every REALTIME_RAW_DATA record OBSERVED on the MG was exactly this long. */
const val RAW_RECORD_LENGTH = 240

/** Frame offset of the inner record's type byte on 5/MG (`[8]type [9]seq [10]cmd`). */
const val RAW_TYPE_OFFSET = 8

/** First body byte considered by [realtimeRawBodyNonZeroBytes] — excludes the constant sub-header. */
const val RAW_BODY_START = 24

/** First waveform byte. Bytes 24..33 are a constant 5x i16 sub-header that is NOT waveform. */
const val RAW_WAVEFORM_START = 34

/** One past the last body byte; the remaining 4 bytes are the frame's CRC32 trailer. */
const val RAW_BODY_END = 236

/**
* Samples one record carries: 101. Load-bearing beyond arithmetic — a fixed sample count per record is
* exactly the shape that makes autocorrelation manufacture a peak at the record period, so anything
* estimating a rate from these samples must exclude this lag. See the #194 withdrawal.
*/
const val SAMPLES_PER_RAW_RECORD = (RAW_BODY_END - RAW_WAVEFORM_START) / 2

/** Non-zero body bytes above which a record is treated as carrying a waveform rather than baseline. */
const val RAW_BODY_ACTIVE_NONZERO_BYTES = 20

/** True for a frame shaped like a REALTIME_RAW_DATA record. Shape only — says nothing about CRC. */
fun isRealtimeRawRecord(frame: ByteArray): Boolean =
frame.size == RAW_RECORD_LENGTH &&
(frame[RAW_TYPE_OFFSET].toInt() and 0xFF) == PacketType.REALTIME_RAW_DATA.rawValue

/**
* The record's i16-LE sample series, or null when [frame] is not a REALTIME_RAW_DATA record.
*
* Signed two's-complement, little-endian, exactly [SAMPLES_PER_RAW_RECORD] values. Zero samples are
* returned as zeros and never trimmed: for a research artifact a trailing run of zeros is evidence
* about the record, not padding to be tidied away.
*/
fun realtimeRawSamples(frame: ByteArray): IntArray? {
if (!isRealtimeRawRecord(frame)) return null
val out = IntArray(SAMPLES_PER_RAW_RECORD)
var i = RAW_WAVEFORM_START
var n = 0
while (i + 1 < RAW_BODY_END) {
var v = (frame[i].toInt() and 0xFF) or ((frame[i + 1].toInt() and 0xFF) shl 8)
if (v >= 0x8000) v -= 0x10000
out[n] = v
n += 1
i += 2
}
return out
}

/** Non-zero bytes in the body region, or null when [frame] is not a REALTIME_RAW_DATA record. */
fun realtimeRawBodyNonZeroBytes(frame: ByteArray): Int? {
if (!isRealtimeRawRecord(frame)) return null
var n = 0
for (i in RAW_BODY_START until RAW_BODY_END) if (frame[i].toInt() != 0) n += 1
return n
}

/**
* ONE definition of "this record carries a waveform", so the live view, the command-lab signal line and
* the reliability scorer cannot disagree about the same record. Null when not such a record.
*
* What it cannot tell you: a flat record means the electrode circuit is open OR generation is off. It is
* a byte-fill observation, never a statement about the wearer.
*/
fun realtimeRawSignalPresent(frame: ByteArray): Boolean? =
realtimeRawBodyNonZeroBytes(frame)?.let { it > RAW_BODY_ACTIVE_NONZERO_BYTES }

// Discovery

/**
Expand Down
100 changes: 100 additions & 0 deletions android/app/src/test/java/com/noop/protocol/Whoop5EcgRawRecordTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package com.noop.protocol

import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test

/**
* #891/#1100: the type-43 REALTIME_RAW_DATA sample decoder.
*
* These offsets used to be written out three times in the app layer — live view, signal classifier, waveform
* export — so the three could disagree about the same record and none of them was testable without a strap.
* They now live in [Whoop5Ecg]; this pins the layout, the sign handling, and the ONE signal-present rule.
*/
class Whoop5EcgRawRecordTest {

/** A 240-byte record with [samples] written i16-LE from the waveform offset. */
private fun rawRecord(samples: IntArray = IntArray(0), type: Int = PacketType.REALTIME_RAW_DATA.rawValue): ByteArray {
val f = ByteArray(Whoop5Ecg.RAW_RECORD_LENGTH)
f[Whoop5Ecg.RAW_TYPE_OFFSET] = type.toByte()
var i = Whoop5Ecg.RAW_WAVEFORM_START
for (v in samples) {
if (i + 1 >= Whoop5Ecg.RAW_BODY_END) break
f[i] = (v and 0xFF).toByte()
f[i + 1] = ((v shr 8) and 0xFF).toByte()
i += 2
}
return f
}

@Test
fun theRecordCarriesExactly101Samples() {
// Load-bearing beyond arithmetic: this is the lag the BPM estimator has to refuse.
assertEquals(101, Whoop5Ecg.SAMPLES_PER_RAW_RECORD)
assertEquals(101, Whoop5Ecg.realtimeRawSamples(rawRecord())!!.size)
}

@Test
fun onlyA240ByteType43FrameIsARawRecord() {
assertTrue(Whoop5Ecg.isRealtimeRawRecord(rawRecord()))
// Wrong type byte, right length.
assertFalse(Whoop5Ecg.isRealtimeRawRecord(rawRecord(type = 0x2F)))
// Right type byte, wrong length.
assertFalse(Whoop5Ecg.isRealtimeRawRecord(rawRecord().copyOf(200)))
assertNull(Whoop5Ecg.realtimeRawSamples(rawRecord(type = 0x2F)))
assertNull(Whoop5Ecg.realtimeRawBodyNonZeroBytes(ByteArray(4)))
assertNull(Whoop5Ecg.realtimeRawSignalPresent(ByteArray(0)))
}

@Test
fun samplesAreSignedLittleEndian() {
val injected = intArrayOf(0, 1, -1, 32767, -32768, 258, -258)
val got = Whoop5Ecg.realtimeRawSamples(rawRecord(injected))!!
assertArrayEquals(injected, got.copyOf(injected.size))
// Everything past what was written stays zero rather than being trimmed away.
assertEquals(0, got[injected.size])
}

@Test
fun zeroSamplesAreKeptNotTrimmed() {
// A trailing run of zeros in a real record is evidence about the record. The export used to strip
// it, which silently edited the artifact.
val got = Whoop5Ecg.realtimeRawSamples(rawRecord(intArrayOf(50, 0, 0, 0)))!!
assertEquals(101, got.size)
assertEquals(50, got[0])
assertTrue(got.drop(1).all { it == 0 })
}

@Test
fun signalPresentIsOneRuleSharedByEveryConsumer() {
// A record of pure baseline: no body bytes set at all.
assertEquals(0, Whoop5Ecg.realtimeRawBodyNonZeroBytes(rawRecord()))
assertFalse(Whoop5Ecg.realtimeRawSignalPresent(rawRecord())!!)

// Just AT the threshold is still not "present" — the rule is strictly greater.
val atThreshold = rawRecord()
for (i in 0 until Whoop5Ecg.RAW_BODY_ACTIVE_NONZERO_BYTES) atThreshold[Whoop5Ecg.RAW_BODY_START + i] = 7
assertEquals(Whoop5Ecg.RAW_BODY_ACTIVE_NONZERO_BYTES, Whoop5Ecg.realtimeRawBodyNonZeroBytes(atThreshold))
assertFalse(Whoop5Ecg.realtimeRawSignalPresent(atThreshold)!!)

val overThreshold = atThreshold.copyOf()
overThreshold[Whoop5Ecg.RAW_BODY_START + Whoop5Ecg.RAW_BODY_ACTIVE_NONZERO_BYTES] = 7
assertTrue(Whoop5Ecg.realtimeRawSignalPresent(overThreshold)!!)
}

@Test
fun theSubHeaderIsCountedForFillButNeverDecodedAsWaveform() {
// Bytes 24..33 are a constant sub-header: they count toward "is this record filled" but must not
// appear in the sample series, or the trace opens with five bogus spikes every record.
val f = rawRecord()
for (i in Whoop5Ecg.RAW_BODY_START until Whoop5Ecg.RAW_WAVEFORM_START) f[i] = 0x7F
assertEquals(
Whoop5Ecg.RAW_WAVEFORM_START - Whoop5Ecg.RAW_BODY_START,
Whoop5Ecg.realtimeRawBodyNonZeroBytes(f),
)
assertTrue(Whoop5Ecg.realtimeRawSamples(f)!!.all { it == 0 })
}
}
Loading