diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Ecg.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Ecg.swift index 0a674be35b..68fb8d068b 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Ecg.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Ecg.swift @@ -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.. 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], diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5EcgRawRecordTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5EcgRawRecordTests.swift new file mode 100644 index 0000000000..55ff493be8 --- /dev/null +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5EcgRawRecordTests.swift @@ -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..= 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 /** diff --git a/android/app/src/test/java/com/noop/protocol/Whoop5EcgRawRecordTest.kt b/android/app/src/test/java/com/noop/protocol/Whoop5EcgRawRecordTest.kt new file mode 100644 index 0000000000..b8d3c86af8 --- /dev/null +++ b/android/app/src/test/java/com/noop/protocol/Whoop5EcgRawRecordTest.kt @@ -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 }) + } +}