diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5RawOptical.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5RawOptical.swift index 0b778b3d04..0017579736 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5RawOptical.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5RawOptical.swift @@ -1,29 +1,103 @@ import Foundation -// WHOOP 5.0/MG historical layout-v20 (2,140-byte) optical-buffer decoder. +// WHOOP 5.0/MG historical layout-v20 ("R20", 2,140-byte) optical-buffer decoder. // -// The record body is five repeated 422-byte blocks beginning at frame offset 26. Each block is: +// The record body is five repeated 422-byte blocks beginning at frame offset 26, then a 4-byte CRC32: // -// 21-byte header | 200-byte channel slot 0 | 200-byte channel slot 1 | 1 reserved byte +// [ 26-byte header ][ 5 x 422-byte block ][ 4-byte CRC32 ] +// 0...25 26 448 870 1292 1714 2136...2139 // -// Header byte 0 is the sample count for both channel slots (0...50). The remaining 20 header bytes -// split consistently into six block-level bytes and seven bytes per channel. That split is exposed as -// raw metadata: its register meanings and the LED wavelength assigned to each block are not yet proven. +// 26 + 5*422 = 2136. Each block is: // -// RE notes (issue #423, NOT authoritative — no labelled/moving v20 capture exists): the record's config -// header carries an LED-current field @28 and per-photodiode offset DACs @38/@45. Under the split above -// those land as ONE drive current in a block's shared bytes (@27...32 for block 0) and ONE offset DAC in -// each of its two 7-byte channel descriptors (@33...39 and @40...46) — i.e. one LED per block, one detector -// per channel. That aligns with the "two readout channels share one measurement config" model, and is a -// line of evidence for treating the slots as a paired detector/readout rather than two wavelengths. +// 21-byte head | 200-byte reading slot A | 200-byte reading slot B | 1 reserved byte // -// In the 29,203-record corpus used to establish this layout, block counts are always [25, 0, 0, 25, -// 25]. Active channels contain 25 signed 20-bit readings in sign-extended i32 containers; slots 25...49 -// are zero padding. Two channels in the same block therefore belong to one shared measurement/config, -// and must not be labelled as two different wavelengths merely because their amplitudes differ. +// The 21-byte head is fully accounted for by eleven fields (1+1+2+1+2+1+4+2+1+4+2 = 21) with no +// residue. Both detector groups are 7 bytes with the identical internal shape `select:1, range:4, +// offset:2`, which is what makes the 7-byte stride visible in a raw hex dump of block 0 (both `range` +// fields there read 32). +// +// PROVENANCE. Every offset, width, endianness and value set below was measured from NOOP's own BLE +// captures: 29,203 records of this type recorded by the #454 deep-buffer recorder from a WHOOP 5.0/MG +// on fw 50.40.1.0. A validator runs 54 structural assertions over all 29,203 and accounts for +// 2140/2140 bytes with none left over. The full derivation, the negative controls and the refuted +// alternatives are published at issue #423. The field names here are exactly the neutral names used in +// that public write-up: they describe what each field is observed to DO in the capture and assert +// nothing about registers, silicon, units, or emitted wavelength. Rename freely. +// +// WHAT IS *NOT* CLAIMED. `drive*` is named only because a block with both drive fields at zero +// produces a dark measurement with no pulse (block 3, the corpus's built-in negative control); its +// units are not established. `source*` values {1,2,3,4} select something, but what each selects is +// unknown, and NOTHING in the corpus identifies an emission band. The two slots under one head belong +// to ONE shared measurement configuration and must not be labelled as two wavelengths. +// +// Baseline configuration over the 29,203-record corpus (constant unless marked *varies*): +// +// | | blk0 | blk1 | blk2 | blk3 | blk4 | +// | sampleCount | 25 | 0 | 0 | 25 | 25 | +// | sourceA | 1 | 3 | 2 | 2 | 2 | +// | driveA | *varies* | 12750 | 6650 | 0 | 200 | {1150,1400,1750,2200,2750,3350} +// | sourceB | 4 | 4 | 4 | 4 | 4 | +// | driveB | 2*driveA | 0 | 0 | 0 | 0 | +// | detectorASelect | 3 | 1 | 1 | 3 | 3 | +// | rangeA | 32 | 16 | 32 | 32 | 32 | +// | offsetA | *varies* | 0 | 1600 | 0 | 0 | {0,800,1600,2400} +// | detectorBSelect | 4 | 2 | 2 | 4 | 1 | +// | rangeB | 32 | 16 | 32 | 32 | 32 | +// | offsetB | *varies* | 0 | 2400 | 0 | 0 | {0,800,1600,2400} +// +// The `drive` family (steps of 250/350/450/550/600) and the `offset` family (steps of 800) are +// DIFFERENT fields at different offsets; they were conflated in earlier notes on this thread. + +/// The eleven fields of a block's 21-byte head, in wire order. +/// +/// Names match the public write-up in issue #423 one-for-one, so code and published record agree: +/// `sample_count, source_a, drive_a, source_b, drive_b, detector_a_select, range_a, offset_a, +/// detector_b_select, range_b, offset_b`. +public struct Whoop5OpticalBlockConfig: Equatable, Codable, Sendable { + /// rel 0, u8. Readings populated in EACH slot; 0...50. `[25, 0, 0, 25, 25]` across the corpus. + public let sampleCount: Int + /// rel 1, u8. Emitter selector for the block's first drive field. Meaning of {1,2,3,4} unknown. + public let sourceA: UInt8 + /// rel 2:3, u16 LE. Six values in 29,203/29,203 block-0 records: {1150,1400,1750,2200,2750,3350}. + public let driveA: UInt16 + /// rel 4, u8. Emitter selector for the second drive field; 4 in every block of every record. + public let sourceB: UInt8 + /// rel 5:6, u16 LE. `driveB == 2 * driveA` in 29,203/29,203 block-0 records; 0 in blocks 1...4. + public let driveB: UInt16 + /// rel 7, u8. Detector routing for slot A. Differs from `detectorBSelect` in every block. + public let detectorASelect: UInt8 + /// rel 8:11, u32 LE. 32, or 16 in block 1. The three high bytes are zero in every record, so the + /// u32 width holds. Units unknown. + public let rangeA: UInt32 + /// rel 12:13, i16 LE. Always a multiple of 800, from {0, 800, 1600, 2400}. Units unknown. + public let offsetA: Int16 + /// rel 14, u8. Detector routing for slot B. + public let detectorBSelect: UInt8 + /// rel 15:18, u32 LE. Mirrors `rangeA`'s domain. + public let rangeB: UInt32 + /// rel 19:20, i16 LE. Mirrors `offsetA`'s domain. + public let offsetB: Int16 + + public init(sampleCount: Int, sourceA: UInt8, driveA: UInt16, sourceB: UInt8, driveB: UInt16, + detectorASelect: UInt8, rangeA: UInt32, offsetA: Int16, + detectorBSelect: UInt8, rangeB: UInt32, offsetB: Int16) { + self.sampleCount = sampleCount + self.sourceA = sourceA + self.driveA = driveA + self.sourceB = sourceB + self.driveB = driveB + self.detectorASelect = detectorASelect + self.rangeA = rangeA + self.offsetA = offsetA + self.detectorBSelect = detectorBSelect + self.rangeB = rangeB + self.offsetB = offsetB + } +} public struct RawOpticalChannel: Equatable, Codable, Sendable { - /// Seven raw per-channel header bytes. Semantics are intentionally not asserted. + /// Seven raw per-channel header bytes, retained so the 21-byte head round-trips losslessly. + /// The named reading of these bytes is on the block's `config`. public let metadata: [UInt8] /// Signed ADC containers, limited by the block's sample-count byte. public let samples: [Int32] @@ -37,37 +111,56 @@ public struct RawOpticalChannel: Equatable, Codable, Sendable { public struct Whoop5OpticalBlock: Equatable, Codable, Sendable { public let index: Int public let sampleCount: Int - /// Header bytes 1...6, shared by both channel slots. Semantics are intentionally not asserted. + /// Header bytes 1...6, shared by both reading slots. Named reading on `config`. public let sharedMetadata: [UInt8] /// Always two physical slots, even when `sampleCount == 0`. public let channels: [RawOpticalChannel] - /// The final byte of the 422-byte block; zero throughout the current corpus. + /// The final byte of the 422-byte block; zero in 29,203/29,203 records of every block. public let reserved: UInt8 + /// The named reading of the 21 head bytes. Derived from exactly the bytes above — `rawHeader` + /// stays the authoritative lossless view; this is the interpretation of it. + public let config: Whoop5OpticalBlockConfig public init(index: Int, sampleCount: Int, sharedMetadata: [UInt8], - channels: [RawOpticalChannel], reserved: UInt8) { + channels: [RawOpticalChannel], reserved: UInt8, + config: Whoop5OpticalBlockConfig) { self.index = index self.sampleCount = sampleCount self.sharedMetadata = sharedMetadata self.channels = channels self.reserved = reserved + self.config = config } /// The complete 21-byte header reconstructed losslessly. public var rawHeader: [UInt8] { [UInt8(sampleCount)] + sharedMetadata + channels.flatMap(\.metadata) } + + /// Slot A readings — `readings_a` in the #423 write-up. Empty when `sampleCount == 0`. + public var readingsA: [Int32] { channels.first?.samples ?? [] } + /// Slot B readings — `readings_b` in the #423 write-up. Empty when `sampleCount == 0`. + public var readingsB: [Int32] { channels.count > 1 ? channels[1].samples : [] } } public struct Whoop5OpticalFrame: Equatable, Codable, Sendable { + /// u32 LE @11. Advances +1 per record; the corpus's 1 Hz clock. public let recordIndex: Int + /// u32 LE @15. Equals the recorder's own `strap_ts` in 29,203/29,203 records. public let baseTs: Int public let blocks: [Whoop5OpticalBlock] + /// u8 @9. 20 for this record type (21 is the IMU record, 18 the older frame). + public let layoutVersion: UInt8 + /// u32 LE @2136 — the frame's CRC32 trailer, verified before any field above was read. + public let checksum: UInt32 - public init(recordIndex: Int, baseTs: Int, blocks: [Whoop5OpticalBlock]) { + public init(recordIndex: Int, baseTs: Int, blocks: [Whoop5OpticalBlock], + layoutVersion: UInt8 = Whoop5RawOptical.layoutVersion, checksum: UInt32 = 0) { self.recordIndex = recordIndex self.baseTs = baseTs self.blocks = blocks + self.layoutVersion = layoutVersion + self.checksum = checksum } } @@ -79,14 +172,45 @@ public enum Whoop5RawOptical { public static let headerLength = 21 public static let channelSlotLength = 200 public static let channelCapacity = 50 + /// u8 @9 identifying this record layout. + public static let layoutVersion: UInt8 = 20 + /// u8 @8. Shared by the v18, v20 and v21 record types; also where the CRC32 input starts. + public static let recordClass: UInt8 = 0x2F + /// Offset of the u32 LE CRC32 trailer. Also the end of the block tiling: 26 + 5*422. + public static let checksumOffset = 2136 + + /// Observed ADC domain: signed 20-bit. The maximum over all 730,075 block-0 slot-A samples in the + /// corpus is EXACTLY 2^19-1, reached in 1.57% of them and never exceeded — a hardware saturation + /// rail. Nothing ever goes below -2^19 (deepest observed: -148,044). + public static let sampleMax: Int32 = 524_287 // 2^19 - 1 + public static let sampleMin: Int32 = -524_288 // -2^19 - /// Decode a complete layout-v20 historical record. This performs strict structural gating; callers - /// receiving bytes from the wire should also use the normal envelope CRC verification path. + /// Decode a complete layout-v20 historical record, or nil. + /// + /// CRC-GATED. Bad bytes never become data: the frame's envelope and BOTH checksums are verified + /// before a single payload field is read, and a frame that fails is rejected outright rather than + /// decoded "best effort". + /// + /// The checksum rule for this record is the ordinary WHOOP 5.0 envelope rule the repo already + /// implements — there is no v20-specific CRC: + /// + /// crc16Modbus(frame[0..<6]) == u16 LE @6 (29,203/29,203) + /// crc32(frame[8..<2136]) == u32 LE @2136 (29,203/29,203) + /// + /// `verifyFrame(_:family:.whoop5)` computes exactly that, because @2:3 declares 2132 and the + /// envelope's payload span is `[8, declaredLength+8-4)` = `[8, 2136)`. + /// + /// CORRECTION TO THE PUBLISHED RECORD: an earlier note in issue #423 gave the CRC32 input as + /// `[26:2136]`. That is wrong. It is `[8:2136]` — from the record-class byte, not from the start of + /// the block tiling. Corroborated independently at a different frame size by @digitalerdude's v26 + /// result (CRC-32 over bytes 8..83 for all 1,080 of their records), so byte 8 is the frame-family + /// rule rather than a v20 quirk. Anything built against `[26:2136]` rejects every real record. public static func decode(_ frame: [UInt8]) -> Whoop5OpticalFrame? { guard frame.count == bufferLength, frame[0] == 0xAA, - frame[8] == 0x2F, - frame[9] == 20 else { return nil } + verifyFrame(frame, family: .whoop5).ok, + frame[8] == recordClass, + frame[9] == layoutVersion else { return nil } var blocks: [Whoop5OpticalBlock] = [] blocks.reserveCapacity(blockCount) @@ -116,13 +240,35 @@ public enum Whoop5RawOptical { sampleCount: sampleCount, sharedMetadata: sharedMetadata, channels: channels, - reserved: frame[start + blockLength - 1])) + reserved: frame[start + blockLength - 1], + config: Whoop5OpticalBlockConfig( + sampleCount: sampleCount, + sourceA: frame[start + 1], + driveA: u16(frame, start + 2), + sourceB: frame[start + 4], + driveB: u16(frame, start + 5), + detectorASelect: frame[start + 7], + rangeA: u32(frame, start + 8), + offsetA: i16(frame, start + 12), + detectorBSelect: frame[start + 14], + rangeB: u32(frame, start + 15), + offsetB: i16(frame, start + 19)))) } return Whoop5OpticalFrame( recordIndex: Int(u32(frame, 11)), baseTs: Int(u32(frame, 15)), - blocks: blocks) + blocks: blocks, + layoutVersion: frame[9], + checksum: u32(frame, checksumOffset)) + } + + @inline(__always) private static func u16(_ frame: [UInt8], _ offset: Int) -> UInt16 { + UInt16(frame[offset]) | (UInt16(frame[offset + 1]) << 8) + } + + @inline(__always) private static func i16(_ frame: [UInt8], _ offset: Int) -> Int16 { + Int16(bitPattern: u16(frame, offset)) } @inline(__always) private static func u32(_ frame: [UInt8], _ offset: Int) -> UInt32 { @@ -130,6 +276,17 @@ public enum Whoop5RawOptical { | (UInt32(frame[offset + 2]) << 16) | (UInt32(frame[offset + 3]) << 24) } + /// Readings are signed 20-bit values already SIGN-EXTENDED into a 4-byte little-endian container, + /// so a plain signed 32-bit read is the whole decode — no masking, no manual sign extension. + /// + /// This is measured, not assumed: across all 4,380,450 populated containers in the corpus the + /// fourth byte is only ever 0x00 (4,057,474) or 0xFF (322,976), never anything else, and the + /// decoded values span [-148,044, 524,287] — inside the signed 20-bit domain with the positive rail + /// exactly on 2^19-1. + /// + /// The trap: masking to 20 bits and zero-extending, or reading the container unsigned, turns the + /// real sample `-22,101` (wire `ab a9 ff ff`) into `1,048,491` or `4,294,945,195`. Both are wrong, + /// and neither is obviously wrong from the numbers alone. @inline(__always) private static func i32(_ frame: [UInt8], _ offset: Int) -> Int32 { Int32(bitPattern: u32(frame, offset)) } diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/OpticalExperimentAnalysisTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/OpticalExperimentAnalysisTests.swift index 652476eccb..d6b1fe942a 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/OpticalExperimentAnalysisTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/OpticalExperimentAnalysisTests.swift @@ -97,9 +97,11 @@ final class OpticalExperimentAnalysisTests: XCTestCase { var c = frame(baseTs: 112, block0Value: 10, block0HeaderByte1: 2, block1SampleCount: 0) let header = Whoop5RawOptical.blockStart - a[header + 2] = 3 - b[header + 2] = 4 - c[header + 2] = 3 + // Re-seal after mutating a header byte: the decoder CRC-gates, so an unsealed edit would be + // rejected as corruption rather than analysed as a differing configuration. + a[header + 2] = 3; a = Whoop5RawOpticalTests.reseal(a) + b[header + 2] = 4; b = Whoop5RawOpticalTests.reseal(b) + c[header + 2] = 3; c = Whoop5RawOpticalTests.reseal(c) let jsonl = [ markerLine("baseline", unixTs: 100, loggedAtMs: 100_100), bufferLine(a, strapTs: 110), @@ -141,10 +143,16 @@ final class OpticalExperimentAnalysisTests: XCTestCase { "{\"ts_ms\":999999,\"strap_ts\":\(strapTs),\"size\":\(frame.count),\"offload\":true,\"char\":\"fd4b\",\"hex\":\"\(hex(frame))\"}" } + /// Builds a synthetic v20 record. `Whoop5RawOptical.decode` CRC-gates, so the frame is sealed with + /// real checksums on the way out — a hand-built frame has to carry them exactly like a strap's. private func frame(baseTs: Int, block0Value: Int32, block0HeaderByte1: UInt8, block1SampleCount: Int) -> [UInt8] { var bytes = [UInt8](repeating: 0, count: Whoop5RawOptical.bufferLength) bytes[0] = 0xAA + bytes[1] = 0x01 + bytes[2] = 0x54 // declared length 2132 = 2140 - 8 + bytes[3] = 0x08 + bytes[4] = 0x01 bytes[8] = 0x2F bytes[9] = 20 write(UInt32(7), to: &bytes, at: 11) @@ -168,7 +176,7 @@ final class OpticalExperimentAnalysisTests: XCTestCase { write(UInt32(bitPattern: 55), to: &bytes, at: samples + Whoop5RawOptical.channelSlotLength) } - return bytes + return Whoop5RawOpticalTests.reseal(bytes) } private func write(_ value: UInt32, to bytes: inout [UInt8], at offset: Int) { diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Resources/r20_optical_oracle.json b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Resources/r20_optical_oracle.json new file mode 100644 index 0000000000..ab3163e7a7 --- /dev/null +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Resources/r20_optical_oracle.json @@ -0,0 +1,597 @@ +{ + "note": "Golden vectors for the WHOOP 5/MG layout-v20 (R20, 2,140-byte) optical record. Both `hex` strings are REAL records from a NOOP deep-buffer capture (issue #423); the expected values were produced by an independent third implementation of the published byte layout, so Swift and Kotlin each have to reproduce them rather than agreeing with each other by construction. Regenerate with Tools/gen_r20_oracle.py, which writes both copies at once \u2014 they MUST stay byte-identical.", + "layout": { + "buffer_length": 2140, + "block_bases": [ + 26, + 448, + 870, + 1292, + 1714 + ], + "block_length": 422, + "head_length": 21, + "slot_length": 200, + "slot_capacity": 50, + "checksum_offset": 2136, + "crc32_input": [ + 8, + 2136 + ], + "crc16_input": [ + 0, + 6 + ], + "sample_min": -524288, + "sample_max": 524287 + }, + "records": [ + { + "name": "blk0_active_negatives", + "hex": "aa0154080100b5b32f1481ac62af00f480566ab85e04001900001901160d042c1a0320000000000004200000002003a2ce0100abcb0100daca010004cd0100ffd00100ded50100fedb010019e001003be201004ae50100d0e90100d9ef0100acf601004afd01005d040200ce0e0200db180200622402005f2e020075320200be34020037390200e03c02004a3f02003a3f020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000aba9ffff57a8ffff09a7ffff8ca5ffff25a3ffffeca1ffff30a1ffffe6a0ffff8fa1fffff6a3ffffeaa7fffffaacffff0db3ffffdebaffff78c0ffff70c5fffffdd6ffff3fe1ffffeae3ffff6eedffff0debffff64e0ffff17d9ffffbad4ffff39cfffff00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003ce31040000011000000000000210000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002fa1904000001200000004006022000000060090000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000190200000400000320000000000004200000000000b9000000c0000000ae000000bd000000b3000000b0000000ba000000bc000000c6000000a0000000b7000000b9000000b0000000bb000000af000000a6000000aa000000b9000000b8000000b7000000bb0000009f000000ae000000ac000000ab00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c3000000b7000000c8000000bb000000a9000000bb000000c7000000ac000000ca000000a2000000bd000000bc000000b9000000bf000000b7000000bc000000b5000000b8000000ae000000b7000000c0000000b6000000ba000000be000000aa00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001902c8000400000320000000000001200000000000b0860100ca8601001d87010018880100b5880100c08801005d8901006c89010016890100f688010010890100b4890100b18a0100fe8b01003d8d0100be8f0100e5920100c29501001e980100ab9a0100bb9e010010a201008ba301002fa40100fba3010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f82600000f2700002d27000058270000a2270000b5270000cb270000c6270000a727000090270000892700009c270000c0270000e7270000172800008a280000ca280000f8280000822900005f2a00008d2b00003f2c00008d2c0000942c0000362c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a9a4a5c0", + "expect": { + "layout_version": 20, + "record_class": 47, + "record_index": 11494060, + "base_ts": 1784054004, + "checksum": 3232081065, + "sample_counts": [ + 25, + 0, + 0, + 25, + 25 + ], + "blocks": [ + { + "index": 0, + "sample_count": 25, + "source_a": 1, + "drive_a": 3350, + "source_b": 4, + "drive_b": 6700, + "detector_a_select": 3, + "range_a": 32, + "offset_a": 0, + "detector_b_select": 4, + "range_b": 32, + "offset_b": 800, + "reserved": 0, + "readings_a": [ + 118434, + 117675, + 117466, + 118020, + 119039, + 120286, + 121854, + 122905, + 123451, + 124234, + 125392, + 126937, + 128684, + 130378, + 132189, + 134862, + 137435, + 140386, + 142943, + 143989, + 144574, + 145719, + 146656, + 147274, + 147258 + ], + "readings_b": [ + -22101, + -22441, + -22775, + -23156, + -23771, + -24084, + -24272, + -24346, + -24177, + -23562, + -22550, + -21254, + -19699, + -17698, + -16264, + -14992, + -10499, + -7873, + -7190, + -4754, + -5363, + -8092, + -9961, + -11078, + -12487 + ] + }, + { + "index": 1, + "sample_count": 0, + "source_a": 3, + "drive_a": 12750, + "source_b": 4, + "drive_b": 0, + "detector_a_select": 1, + "range_a": 16, + "offset_a": 0, + "detector_b_select": 2, + "range_b": 16, + "offset_b": 0, + "reserved": 0, + "readings_a": [], + "readings_b": [] + }, + { + "index": 2, + "sample_count": 0, + "source_a": 2, + "drive_a": 6650, + "source_b": 4, + "drive_b": 0, + "detector_a_select": 1, + "range_a": 32, + "offset_a": 1600, + "detector_b_select": 2, + "range_b": 32, + "offset_b": 2400, + "reserved": 0, + "readings_a": [], + "readings_b": [] + }, + { + "index": 3, + "sample_count": 25, + "source_a": 2, + "drive_a": 0, + "source_b": 4, + "drive_b": 0, + "detector_a_select": 3, + "range_a": 32, + "offset_a": 0, + "detector_b_select": 4, + "range_b": 32, + "offset_b": 0, + "reserved": 0, + "readings_a": [ + 185, + 192, + 174, + 189, + 179, + 176, + 186, + 188, + 198, + 160, + 183, + 185, + 176, + 187, + 175, + 166, + 170, + 185, + 184, + 183, + 187, + 159, + 174, + 172, + 171 + ], + "readings_b": [ + 195, + 183, + 200, + 187, + 169, + 187, + 199, + 172, + 202, + 162, + 189, + 188, + 185, + 191, + 183, + 188, + 181, + 184, + 174, + 183, + 192, + 182, + 186, + 190, + 170 + ] + }, + { + "index": 4, + "sample_count": 25, + "source_a": 2, + "drive_a": 200, + "source_b": 4, + "drive_b": 0, + "detector_a_select": 3, + "range_a": 32, + "offset_a": 0, + "detector_b_select": 1, + "range_b": 32, + "offset_b": 0, + "reserved": 0, + "readings_a": [ + 100016, + 100042, + 100125, + 100376, + 100533, + 100544, + 100701, + 100716, + 100630, + 100598, + 100624, + 100788, + 101041, + 101374, + 101693, + 102334, + 103141, + 103874, + 104478, + 105131, + 106171, + 107024, + 107403, + 107567, + 107515 + ], + "readings_b": [ + 9976, + 9999, + 10029, + 10072, + 10146, + 10165, + 10187, + 10182, + 10151, + 10128, + 10121, + 10140, + 10176, + 10215, + 10263, + 10378, + 10442, + 10488, + 10626, + 10847, + 11149, + 11327, + 11405, + 11412, + 11318 + ] + } + ] + } + }, + { + "name": "rails_saturation_and_negatives", + "hex": "aa0154080100b5b32f1481d6f1af001f10576a850b04001900001901780504f00a0320000000200304200000000000bd03ffffcb03ffff25370000ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e70901008b09010023080100b5b70200c11d0600ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003ce31040000011000000000000210000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002fa1904000001200000004006022000000060090000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000190200000400000320000000000004200000000000be000000bf000000b9000000d6000000d2000000d9000000e2000000eb000000dd000000e4000000ce000000d5000000fa000000e6000000d1000000de000000d2000000c7000000d3000000cc000000dd000000db000000e1000000cf000000cf00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d7000000bb000000da000000d4000000d8000000e9000000d7000000db000000d5000000de000000c3000000d3000000d7000000e0000000ef000000d8000000d1000000f1000000e3000000d4000000c9000000cc000000d4000000d6000000d200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001902c8000400000320000000000001200000000000a1ee0100fcee01005735020092240300bd760300c18c030020b7030070b60300e7a203006eb40300e59d030052a703007bae030066af03004ea50300b39d0300dd8b030058a203007fa0030027a70300088f030095690300f45803008e4b0300f430030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ac1600008e160000dc170000dc200000f82b00007a310000313c0000bf400000e03f000065410000b2400000f54000008b420000f34200000f430000054500002b46000093470000ed4400002f3d00008e3100004a24000055200000491f0000781d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001dd5463b", + "expect": { + "layout_version": 20, + "record_class": 47, + "record_index": 11530710, + "base_ts": 1784090655, + "checksum": 994497821, + "sample_counts": [ + 25, + 0, + 0, + 25, + 25 + ], + "blocks": [ + { + "index": 0, + "sample_count": 25, + "source_a": 1, + "drive_a": 1400, + "source_b": 4, + "drive_b": 2800, + "detector_a_select": 3, + "range_a": 32, + "offset_a": 800, + "detector_b_select": 4, + "range_b": 32, + "offset_b": 0, + "reserved": 0, + "readings_a": [ + -64579, + -64565, + 14117, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287 + ], + "readings_b": [ + 68071, + 67979, + 67619, + 178101, + 400833, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287 + ] + }, + { + "index": 1, + "sample_count": 0, + "source_a": 3, + "drive_a": 12750, + "source_b": 4, + "drive_b": 0, + "detector_a_select": 1, + "range_a": 16, + "offset_a": 0, + "detector_b_select": 2, + "range_b": 16, + "offset_b": 0, + "reserved": 0, + "readings_a": [], + "readings_b": [] + }, + { + "index": 2, + "sample_count": 0, + "source_a": 2, + "drive_a": 6650, + "source_b": 4, + "drive_b": 0, + "detector_a_select": 1, + "range_a": 32, + "offset_a": 1600, + "detector_b_select": 2, + "range_b": 32, + "offset_b": 2400, + "reserved": 0, + "readings_a": [], + "readings_b": [] + }, + { + "index": 3, + "sample_count": 25, + "source_a": 2, + "drive_a": 0, + "source_b": 4, + "drive_b": 0, + "detector_a_select": 3, + "range_a": 32, + "offset_a": 0, + "detector_b_select": 4, + "range_b": 32, + "offset_b": 0, + "reserved": 0, + "readings_a": [ + 190, + 191, + 185, + 214, + 210, + 217, + 226, + 235, + 221, + 228, + 206, + 213, + 250, + 230, + 209, + 222, + 210, + 199, + 211, + 204, + 221, + 219, + 225, + 207, + 207 + ], + "readings_b": [ + 215, + 187, + 218, + 212, + 216, + 233, + 215, + 219, + 213, + 222, + 195, + 211, + 215, + 224, + 239, + 216, + 209, + 241, + 227, + 212, + 201, + 204, + 212, + 214, + 210 + ] + }, + { + "index": 4, + "sample_count": 25, + "source_a": 2, + "drive_a": 200, + "source_b": 4, + "drive_b": 0, + "detector_a_select": 3, + "range_a": 32, + "offset_a": 0, + "detector_b_select": 1, + "range_b": 32, + "offset_b": 0, + "reserved": 0, + "readings_a": [ + 126625, + 126716, + 144727, + 205970, + 227005, + 232641, + 243488, + 243312, + 238311, + 242798, + 237029, + 239442, + 241275, + 241510, + 238926, + 236979, + 232413, + 238168, + 237695, + 239399, + 233224, + 223637, + 219380, + 215950, + 209140 + ], + "readings_b": [ + 5804, + 5774, + 6108, + 8412, + 11256, + 12666, + 15409, + 16575, + 16352, + 16741, + 16562, + 16629, + 17035, + 17139, + 17167, + 17669, + 17963, + 18323, + 17645, + 15663, + 12686, + 9290, + 8277, + 8009, + 7544 + ] + } + ] + } + } + ], + "crc_rejection": { + "source": "blk0_active_negatives", + "cases": [ + { + "name": "reading_bit_flip", + "offset": 47, + "xor": 1, + "why": "a bit in blk0 readings_a[0] \u2014 CRC32 payload", + "crc32_ok": false, + "crc16_ok": true + }, + { + "name": "sample_count_bit_flip", + "offset": 26, + "xor": 1, + "why": "blk0 sample_count, the byte that sets the parse shape \u2014 CRC32 payload", + "crc32_ok": false, + "crc16_ok": true + }, + { + "name": "drive_bit_flip", + "offset": 28, + "xor": 1, + "why": "blk0 drive_a low byte \u2014 CRC32 payload", + "crc32_ok": false, + "crc16_ok": true + }, + { + "name": "checksum_bit_flip", + "offset": 2136, + "xor": 1, + "why": "the CRC32 trailer itself", + "crc32_ok": false, + "crc16_ok": true + }, + { + "name": "declared_length_bit_flip", + "offset": 2, + "xor": 1, + "why": "the declared length @2:3 \u2014 CRC16-Modbus header", + "crc32_ok": true, + "crc16_ok": false + } + ] + } +} diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5RawOpticalTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5RawOpticalTests.swift index b8fc4e5e6f..f12e34af88 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5RawOpticalTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5RawOpticalTests.swift @@ -1,29 +1,365 @@ import XCTest @testable import WhoopProtocol +/// R20 (layout-v20, 2,140-byte) optical decoder. +/// +/// The golden vectors live in `Resources/r20_optical_oracle.json`, a byte-identical copy of +/// `android/app/src/test/resources/r20_optical_oracle.json`. Both platforms decode the same REAL +/// captured records and assert the same expected values, which were produced by an independent third +/// implementation of the published byte layout — so a one-sided offset edit fails on the platform that +/// moved, not silently on both. Same idiom as `DecoderOracleTests`. final class Whoop5RawOpticalTests: XCTestCase { - func testRealFramePreservesFiveBlockStructure() throws { - let raw = Whoop5HistoricalV2021Tests.realFrameV20Bytes - let decoded = try XCTUnwrap(Whoop5RawOptical.decode(raw)) - - XCTAssertEqual(decoded.recordIndex, 11_494_060) - XCTAssertEqual(decoded.baseTs, 1_784_054_004) - XCTAssertEqual(decoded.blocks.map(\.sampleCount), [25, 0, 0, 25, 25]) - XCTAssertTrue(decoded.blocks.allSatisfy { $0.channels.count == 2 }) - XCTAssertTrue(decoded.blocks.allSatisfy { $0.rawHeader.count == 21 }) - XCTAssertTrue(decoded.blocks.allSatisfy { $0.reserved == 0 }) - - XCTAssertEqual(decoded.blocks[0].rawHeader, [ - 0x19, 0x01, 0x16, 0x0d, 0x04, 0x2c, 0x1a, - 0x03, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x04, 0x20, 0x00, 0x00, 0x00, 0x20, 0x03, - ]) - XCTAssertEqual(decoded.blocks[4].sharedMetadata, [0x02, 0xc8, 0x00, 0x04, 0x00, 0x00]) - XCTAssertEqual(decoded.blocks[4].channels[0].metadata, [0x03, 0x20, 0, 0, 0, 0, 0]) - XCTAssertEqual(decoded.blocks[4].channels[1].metadata, [0x01, 0x20, 0, 0, 0, 0, 0]) - XCTAssertEqual(decoded.blocks[4].channels[0].samples.count, 25) - XCTAssertEqual(decoded.blocks[4].channels[1].samples.count, 25) - XCTAssertEqual(decoded.blocks[4].channels[1].samples.last, 11_318) + + // MARK: - Oracle fixture + + private struct Oracle: Decodable { + let layout: Layout + let records: [Record] + let crcRejection: CRCRejection + enum CodingKeys: String, CodingKey { + case layout, records + case crcRejection = "crc_rejection" + } + } + private struct Layout: Decodable { + let bufferLength: Int + let blockBases: [Int] + let blockLength: Int + let headLength: Int + let slotLength: Int + let slotCapacity: Int + let checksumOffset: Int + let sampleMin: Int + let sampleMax: Int + enum CodingKeys: String, CodingKey { + case bufferLength = "buffer_length" + case blockBases = "block_bases" + case blockLength = "block_length" + case headLength = "head_length" + case slotLength = "slot_length" + case slotCapacity = "slot_capacity" + case checksumOffset = "checksum_offset" + case sampleMin = "sample_min" + case sampleMax = "sample_max" + } + } + private struct Record: Decodable { + let name: String + let hex: String + let expect: Expect + } + private struct Expect: Decodable { + let layoutVersion: Int + let recordClass: Int + let recordIndex: Int + let baseTs: Int + let checksum: UInt32 + let sampleCounts: [Int] + let blocks: [BlockExpect] + enum CodingKeys: String, CodingKey { + case layoutVersion = "layout_version" + case recordClass = "record_class" + case recordIndex = "record_index" + case baseTs = "base_ts" + case checksum + case sampleCounts = "sample_counts" + case blocks + } + } + private struct BlockExpect: Decodable { + let index: Int + let sampleCount: Int + let sourceA: Int + let driveA: Int + let sourceB: Int + let driveB: Int + let detectorASelect: Int + let rangeA: Int + let offsetA: Int + let detectorBSelect: Int + let rangeB: Int + let offsetB: Int + let reserved: Int + let readingsA: [Int32] + let readingsB: [Int32] + enum CodingKeys: String, CodingKey { + case index + case sampleCount = "sample_count" + case sourceA = "source_a" + case driveA = "drive_a" + case sourceB = "source_b" + case driveB = "drive_b" + case detectorASelect = "detector_a_select" + case rangeA = "range_a" + case offsetA = "offset_a" + case detectorBSelect = "detector_b_select" + case rangeB = "range_b" + case offsetB = "offset_b" + case reserved + case readingsA = "readings_a" + case readingsB = "readings_b" + } + } + private struct CRCRejection: Decodable { + let source: String + let cases: [CRCCase] + } + private struct CRCCase: Decodable { + let name: String + let offset: Int + let xor: UInt8 + let why: String + } + + private func loadOracle() throws -> Oracle { + let url = try XCTUnwrap(Bundle.module.url(forResource: "r20_optical_oracle", withExtension: "json"), + "missing r20_optical_oracle.json test resource") + return try JSONDecoder().decode(Oracle.self, from: Data(contentsOf: url)) + } + + private func hexToBytes(_ s: String) -> [UInt8] { + var out = [UInt8](); out.reserveCapacity(s.count / 2) + var i = s.startIndex + while i < s.endIndex { + let j = s.index(i, offsetBy: 2) + out.append(UInt8(s[i.. = [1150, 1400, 1750, 2200, 2750, 3350] + var total = 0, decoded = 0, mismatched = 0 + var failures: [String] = [] + + text.enumerateLines { line, _ in + guard let data = line.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let size = obj["size"] as? Int, size == Whoop5RawOptical.bufferLength, + let hex = obj["hex"] as? String else { return } + total += 1 + let bytes = self.hexToBytes(hex) + guard let frame = Whoop5RawOptical.decode(bytes) else { + if failures.count < 5 { failures.append("record \(total) failed to decode") } + return + } + decoded += 1 + + var problems: [String] = [] + // The recorder's own envelope timestamp must agree with the decoded field. + if let strapTs = obj["strap_ts"] as? Int, frame.baseTs != strapTs { + problems.append("base_ts \(frame.baseTs) != strap_ts \(strapTs)") + } + if frame.blocks.map(\.sampleCount) != [25, 0, 0, 25, 25] { problems.append("block pattern") } + let blk0 = frame.blocks[0].config + if !driveSet.contains(blk0.driveA) { problems.append("drive_a \(blk0.driveA) outside the six-value set") } + if Int(blk0.driveB) != Int(blk0.driveA) * 2 { problems.append("drive_b != 2 * drive_a") } + for block in frame.blocks { + if block.reserved != 0 { problems.append("blk\(block.index) reserved != 0") } + if block.config.offsetA % 800 != 0 || block.config.offsetB % 800 != 0 { + problems.append("blk\(block.index) offset not a multiple of 800") + } + if block.readingsA.count != block.sampleCount || block.readingsB.count != block.sampleCount { + problems.append("blk\(block.index) reading count != sample_count") + } + if let out = (block.readingsA + block.readingsB).first(where: { + $0 < Whoop5RawOptical.sampleMin || $0 > Whoop5RawOptical.sampleMax + }) { + problems.append("blk\(block.index) sample \(out) outside the signed 20-bit domain") + } + } + if !problems.isEmpty { + mismatched += 1 + if failures.count < 5 { failures.append("record \(total): \(problems.joined(separator: ", "))") } + } + } + + print("R20 corpus: \(decoded)/\(total) decoded, \(mismatched) invariant mismatches") + XCTAssertGreaterThan(total, 0, "no 2,140-byte records found in \(path)") + XCTAssertEqual(decoded, total, "every record must decode. first failures: \(failures)") + XCTAssertEqual(mismatched, 0, "published invariants must hold on every record: \(failures)") + } + + // MARK: - Structural gates + + func testStrictShapeAndCountGates() { + var frame = Self.sealedSyntheticFrame() + XCTAssertNotNil(Whoop5RawOptical.decode(frame)) + + // A sample count beyond the 50-slot capacity cannot be honoured, even with valid checksums. + frame[Whoop5RawOptical.blockStart] = UInt8(Whoop5RawOptical.channelCapacity + 1) + XCTAssertNil(Whoop5RawOptical.decode(Self.reseal(frame))) + + // Wrong length, wrong record class, wrong layout version. + XCTAssertNil(Whoop5RawOptical.decode(Self.sealedSyntheticFrame() + [0])) + var wrongClass = Self.sealedSyntheticFrame(); wrongClass[8] = 0x2E + XCTAssertNil(Whoop5RawOptical.decode(Self.reseal(wrongClass))) + var wrongVersion = Self.sealedSyntheticFrame(); wrongVersion[9] = 21 + XCTAssertNil(Whoop5RawOptical.decode(Self.reseal(wrongVersion))) } func testInterpreterExposesHeadersWithoutWavelengthLabels() { @@ -38,17 +374,56 @@ final class Whoop5RawOpticalTests: XCTestCase { XCTAssertNil(parsed["ir"]) } - func testStrictShapeAndCountGates() { + /// The Swift and Android copies of the oracle MUST be byte-identical, so neither platform can edit + /// its fixture without the other. Mirrors `DecoderOracleTests.testOracleCopiesAreIdentical`. + func testOracleCopiesAreIdentical() throws { + let swiftURL = try XCTUnwrap(Bundle.module.url(forResource: "r20_optical_oracle", withExtension: "json")) + let swiftData = try Data(contentsOf: swiftURL) + + let repoRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // WhoopProtocolTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // WhoopProtocol + .deletingLastPathComponent() // Packages + .deletingLastPathComponent() // repo root + let androidURL = repoRoot.appendingPathComponent("android/app/src/test/resources/r20_optical_oracle.json") + guard FileManager.default.fileExists(atPath: androidURL.path) else { + throw XCTSkip("android oracle copy not present at \(androidURL.path)") + } + XCTAssertEqual(swiftData, try Data(contentsOf: androidURL), + "r20_optical_oracle.json copies differ — keep Swift and Android in lockstep") + } + + // MARK: - Synthetic frame helpers + + /// A minimal, structurally valid, checksum-sealed v20 frame. Sealing is not optional any more: + /// the decoder CRC-gates, so a hand-built frame has to carry real checksums exactly like a strap's. + static func sealedSyntheticFrame() -> [UInt8] { var frame = [UInt8](repeating: 0, count: Whoop5RawOptical.bufferLength) frame[0] = 0xAA - frame[8] = 0x2F - frame[9] = 20 - XCTAssertNotNil(Whoop5RawOptical.decode(frame)) + frame[1] = 0x01 + frame[2] = 0x54 // declared length 2132 = 2140 - 8 + frame[3] = 0x08 + frame[4] = 0x01 + frame[8] = Whoop5RawOptical.recordClass + frame[9] = Whoop5RawOptical.layoutVersion + return reseal(frame) + } - frame[Whoop5RawOptical.blockStart] = UInt8(Whoop5RawOptical.channelCapacity + 1) - XCTAssertNil(Whoop5RawOptical.decode(frame)) - frame[Whoop5RawOptical.blockStart] = 0 - frame.append(0) - XCTAssertNil(Whoop5RawOptical.decode(frame)) + /// Recompute both checksums after mutating a frame, so a test asserts the gate it means to. + static func reseal(_ frame: [UInt8]) -> [UInt8] { + var out = frame + guard out.count >= 12 else { return out } + let headerCRC = crc16Modbus(out, 0, 6) + out[6] = UInt8(headerCRC & 0xFF) + out[7] = UInt8((headerCRC >> 8) & 0xFF) + let end = Whoop5RawOptical.checksumOffset + guard out.count >= end + 4 else { return out } + let payloadCRC = crc32(out, 8, end) + out[end] = UInt8(payloadCRC & 0xFF) + out[end + 1] = UInt8((payloadCRC >> 8) & 0xFF) + out[end + 2] = UInt8((payloadCRC >> 16) & 0xFF) + out[end + 3] = UInt8((payloadCRC >> 24) & 0xFF) + return out } } diff --git a/Tools/gen_r20_oracle.py b/Tools/gen_r20_oracle.py new file mode 100755 index 0000000000..dcb9c4bed2 --- /dev/null +++ b/Tools/gen_r20_oracle.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""Regenerate `r20_optical_oracle.json` from a WHOOP 5/MG deep-buffer capture. + +The R20 (layout-v20, 2,140-byte) optical golden vectors are checked in twice — once for Swift, once +for Android — and the two copies must stay byte-identical. This script writes both from the same +buffer, so they cannot drift. + +The expected values are produced here by a THIRD, independent implementation of the byte layout +(plain `struct.unpack` against the published offsets from issue #423). Swift and Kotlin then both have +to reproduce them, which is what makes the fixture a drift guard rather than a snapshot of whichever +decoder happened to run last. + +Usage: + + Tools/gen_r20_oracle.py /path/to/buffers2140.jsonl [--lines 0,16475] + +The input is the #454 deep-buffer recorder's own JSONL — one object per line with `hex`, `size` and +`strap_ts`. Default line numbers are the two records currently committed: an ordinary active record +carrying negative readings, and one that also hits the positive saturation rail. +""" +import argparse +import collections +import json +import struct +import zlib +from pathlib import Path + +BLOCK_BASES = [26, 448, 870, 1292, 1714] +DEFAULT_NAMES = ["blk0_active_negatives", "rails_saturation_and_negatives"] +REPO_ROOT = Path(__file__).resolve().parent.parent +DESTS = [ + REPO_ROOT / "Packages/WhoopProtocol/Tests/WhoopProtocolTests/Resources/r20_optical_oracle.json", + REPO_ROOT / "android/app/src/test/resources/r20_optical_oracle.json", +] + + +def u8(b, o): return b[o] +def u16(b, o): return struct.unpack_from("> 1) ^ 0xA001 if crc & 1 else crc >> 1 + return crc + + +def decode(b): + """Independent reference decode, CRC-gated exactly as the shipped decoders are.""" + assert len(b) == 2140, f"expected 2140 bytes, got {len(b)}" + assert b[0] == 0xAA, "sync byte" + assert zlib.crc32(b[8:2136]) == u32(b, 2136), "CRC32 over [8:2136]" + assert crc16_modbus(b[0:6]) == u16(b, 6), "CRC16-Modbus over [0:6]" + assert b[8] == 0x2F and b[9] == 20, "record class / layout version" + blocks = [] + for i, base in enumerate(BLOCK_BASES): + n = u8(b, base) + assert n <= 50, "sample count beyond slot capacity" + blocks.append(collections.OrderedDict([ + ("index", i), + ("sample_count", n), + ("source_a", u8(b, base + 1)), + ("drive_a", u16(b, base + 2)), + ("source_b", u8(b, base + 4)), + ("drive_b", u16(b, base + 5)), + ("detector_a_select", u8(b, base + 7)), + ("range_a", u32(b, base + 8)), + ("offset_a", i16(b, base + 12)), + ("detector_b_select", u8(b, base + 14)), + ("range_b", u32(b, base + 15)), + ("offset_b", i16(b, base + 19)), + ("reserved", u8(b, base + 421)), + ("readings_a", [i32(b, base + 21 + s * 4) for s in range(n)]), + ("readings_b", [i32(b, base + 221 + s * 4) for s in range(n)]), + ])) + return collections.OrderedDict([ + ("layout_version", b[9]), + ("record_class", b[8]), + ("record_index", u32(b, 11)), + ("base_ts", u32(b, 15)), + ("checksum", u32(b, 2136)), + ("sample_counts", [blk["sample_count"] for blk in blocks]), + ("blocks", blocks), + ]) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("corpus", help="deep-buffer JSONL from the #454 recorder") + ap.add_argument("--lines", default="0,16475", + help="comma-separated 0-based line numbers to lift as golden vectors") + ap.add_argument("--names", default=",".join(DEFAULT_NAMES), + help="comma-separated names, one per line number") + args = ap.parse_args() + + line_nos = [int(x) for x in args.lines.split(",")] + names = args.names.split(",") + assert len(line_nos) == len(names), "--lines and --names must be the same length" + wanted = dict(zip(line_nos, names)) + + found = {} + with open(args.corpus) as f: + for i, line in enumerate(f): + if i in wanted: + found[i] = json.loads(line) + if len(found) == len(wanted): + break + missing = set(wanted) - set(found) + assert not missing, f"line numbers not present in {args.corpus}: {sorted(missing)}" + + out_records = [] + for line_no in line_nos: + raw = found[line_no] + b = bytes.fromhex(raw["hex"]) + exp = decode(b) + assert exp["base_ts"] == raw["strap_ts"], "u32LE@15 must equal the recorder's strap_ts" + out_records.append(collections.OrderedDict([ + ("name", wanted[line_no]), + ("hex", raw["hex"]), + ("expect", exp), + ])) + + # Single-bit corruptions that MUST be rejected, applied to the first record. Each names the gate + # it trips, so a regression tells you which check was lost. + rejections = [ + ("reading_bit_flip", 47, 0x01, "a bit in blk0 readings_a[0] — CRC32 payload"), + ("sample_count_bit_flip", 26, 0x01, + "blk0 sample_count, the byte that sets the parse shape — CRC32 payload"), + ("drive_bit_flip", 28, 0x01, "blk0 drive_a low byte — CRC32 payload"), + ("checksum_bit_flip", 2136, 0x01, "the CRC32 trailer itself"), + ("declared_length_bit_flip", 2, 0x01, "the declared length @2:3 — CRC16-Modbus header"), + ] + base_bytes = bytes.fromhex(out_records[0]["hex"]) + reject_cases = [] + for name, off, mask, why in rejections: + mutated = bytearray(base_bytes) + mutated[off] ^= mask + m = bytes(mutated) + # Prove here that the corruption really does break a checksum, so the fixture can never + # silently assert "rejected" for a frame that is in fact valid. + crc32_ok = zlib.crc32(m[8:2136]) == u32(m, 2136) + crc16_ok = crc16_modbus(m[0:6]) == u16(m, 6) + assert not (crc32_ok and crc16_ok), f"{name} did not break a checksum" + reject_cases.append(collections.OrderedDict([ + ("name", name), + ("offset", off), + ("xor", mask), + ("why", why), + ("crc32_ok", crc32_ok), + ("crc16_ok", crc16_ok), + ])) + + doc = collections.OrderedDict([ + ("note", + "Golden vectors for the WHOOP 5/MG layout-v20 (R20, 2,140-byte) optical record. Both `hex` " + "strings are REAL records from a NOOP deep-buffer capture (issue #423); the expected values " + "were produced by an independent third implementation of the published byte layout, so Swift " + "and Kotlin each have to reproduce them rather than agreeing with each other by construction. " + "Regenerate with Tools/gen_r20_oracle.py, which writes both copies at once — they MUST stay " + "byte-identical."), + ("layout", collections.OrderedDict([ + ("buffer_length", 2140), + ("block_bases", BLOCK_BASES), + ("block_length", 422), + ("head_length", 21), + ("slot_length", 200), + ("slot_capacity", 50), + ("checksum_offset", 2136), + ("crc32_input", [8, 2136]), + ("crc16_input", [0, 6]), + ("sample_min", -524288), + ("sample_max", 524287), + ])), + ("records", out_records), + ("crc_rejection", collections.OrderedDict([ + ("source", out_records[0]["name"]), + ("cases", reject_cases), + ])), + ]) + + blob = json.dumps(doc, indent=2) + "\n" + for d in DESTS: + d.write_text(blob) + print(f"wrote {len(blob)} bytes to {len(DESTS)} destinations") + for r in out_records: + e = r["expect"] + readings = [v for blk in e["blocks"] for v in blk["readings_a"] + blk["readings_b"]] + print(f" {r['name']}: idx={e['record_index']} ts={e['base_ts']} " + f"counts={e['sample_counts']} min={min(readings)} max={max(readings)}") + print(f" {len(reject_cases)} CRC rejection cases") + + +if __name__ == "__main__": + main() diff --git a/android/app/src/main/java/com/noop/protocol/Whoop5RawOptical.kt b/android/app/src/main/java/com/noop/protocol/Whoop5RawOptical.kt index a1dc45b674..33a213bdcf 100644 --- a/android/app/src/main/java/com/noop/protocol/Whoop5RawOptical.kt +++ b/android/app/src/main/java/com/noop/protocol/Whoop5RawOptical.kt @@ -1,8 +1,49 @@ package com.noop.protocol +/** + * The eleven fields of a block's 21-byte head, in wire order. Kotlin twin of Swift's + * `Whoop5OpticalBlockConfig`. + * + * Names match the public write-up in issue #423 one-for-one, so code and published record agree: + * `sample_count, source_a, drive_a, source_b, drive_b, detector_a_select, range_a, offset_a, + * detector_b_select, range_b, offset_b`. They describe what each field is observed to DO in NOOP's + * own capture and assert nothing about registers, units, or emitted wavelength. + * + * Widths are unsigned on the wire; Kotlin has no convenient unsigned types here, so `driveA`/`driveB` + * (u16) and `rangeA`/`rangeB` (u32) are widened to `Int`/`Long` and always carry only their real bits. + * `offsetA`/`offsetB` are genuinely signed 16-bit. + */ +data class Whoop5OpticalBlockConfig( + /** rel 0, u8. Readings populated in EACH slot; 0..50. `[25, 0, 0, 25, 25]` across the corpus. */ + val sampleCount: Int, + /** rel 1, u8. Emitter selector for the block's first drive field. Meaning of {1,2,3,4} unknown. */ + val sourceA: Int, + /** rel 2:3, u16 LE. Six values in 29,203/29,203 block-0 records: {1150,1400,1750,2200,2750,3350}. */ + val driveA: Int, + /** rel 4, u8. Emitter selector for the second drive field; 4 in every block of every record. */ + val sourceB: Int, + /** rel 5:6, u16 LE. `driveB == 2 * driveA` in 29,203/29,203 block-0 records; 0 in blocks 1..4. */ + val driveB: Int, + /** rel 7, u8. Detector routing for slot A. Differs from [detectorBSelect] in every block. */ + val detectorASelect: Int, + /** rel 8:11, u32 LE. 32, or 16 in block 1; the three high bytes are zero in every record. */ + val rangeA: Long, + /** rel 12:13, i16 LE. Always a multiple of 800, from {0, 800, 1600, 2400}. Units unknown. */ + val offsetA: Int, + /** rel 14, u8. Detector routing for slot B. */ + val detectorBSelect: Int, + /** rel 15:18, u32 LE. Mirrors [rangeA]'s domain. */ + val rangeB: Long, + /** rel 19:20, i16 LE. Mirrors [offsetA]'s domain. */ + val offsetB: Int, +) + /** A single raw readout channel in a WHOOP 5/MG layout-v20 optical block. */ data class RawOpticalChannel( - /** Seven raw per-channel header bytes. No register or wavelength semantics are asserted. */ + /** + * Seven raw per-channel header bytes, retained so the 21-byte head round-trips losslessly. The + * named reading of these bytes is on the block's [Whoop5OpticalBlock.config]. + */ val metadata: List, /** Signed ADC containers, limited by the block's sample-count byte. */ val samples: List, @@ -12,40 +53,82 @@ data class RawOpticalChannel( data class Whoop5OpticalBlock( val index: Int, val sampleCount: Int, - /** Header bytes 1..6, shared by both channel slots. */ + /** Header bytes 1..6, shared by both reading slots. Named reading on [config]. */ val sharedMetadata: List, /** Always two slots, including when [sampleCount] is zero. */ val channels: List, - /** Final byte of the block; zero throughout the current capture corpus. */ + /** Final byte of the block; zero in 29,203/29,203 records of every block. */ val reserved: Int, + /** + * The named reading of the 21 head bytes. Derived from exactly the bytes above — [rawHeader] + * stays the authoritative lossless view; this is the interpretation of it. + */ + val config: Whoop5OpticalBlockConfig, ) { val rawHeader: List get() = listOf(sampleCount) + sharedMetadata + channels.flatMap { it.metadata } + + /** Slot A readings — `readings_a` in the #423 write-up. Empty when [sampleCount] is zero. */ + val readingsA: List get() = channels.getOrNull(0)?.samples ?: emptyList() + + /** Slot B readings — `readings_b` in the #423 write-up. Empty when [sampleCount] is zero. */ + val readingsB: List get() = channels.getOrNull(1)?.samples ?: emptyList() } data class Whoop5OpticalFrame( + /** u32 LE @11. Advances +1 per record; the corpus's 1 Hz clock. */ val recordIndex: Long, + /** u32 LE @15. Equals the recorder's own `strap_ts` in 29,203/29,203 records. */ val baseTs: Long, val blocks: List, + /** u8 @9. 20 for this record type (21 is the IMU record, 18 the older frame). */ + val layoutVersion: Int = Whoop5RawOptical.LAYOUT_VERSION, + /** u32 LE @2136 — the frame's CRC32 trailer, verified before any field above was read. */ + val checksum: Long = 0L, ) /** - * Structural decoder for WHOOP 5/MG historical layout v20 (exactly 2,140 bytes). + * Decoder for WHOOP 5.0/MG historical layout v20 ("R20", exactly 2,140 bytes). Kotlin twin of Swift's + * `Whoop5RawOptical` — the two are independent implementations of one byte layout and are held to + * byte-equality by the shared `r20_optical_oracle.json` fixture. * - * The body is five repetitions of: + * ``` + * [ 26-byte header ][ 5 x 422-byte block ][ 4-byte CRC32 ] + * 0..25 26 448 870 1292 1714 2136..2139 + * ``` * - * `21-byte header | 200-byte channel slot 0 | 200-byte channel slot 1 | 1 reserved byte`. + * `26 + 5*422 = 2136`. Each block is `21-byte head | 200-byte slot A | 200-byte slot B | 1 reserved`. + * The head is fully accounted for by eleven fields (`1+1+2+1+2+1+4+2+1+4+2 = 21`) with no residue. + * Both detector groups are 7 bytes with the identical internal shape `select:1, range:4, offset:2`, + * which is what makes the 7-byte stride visible in a raw hex dump of block 0. * - * Header byte zero is the shared sample count (0..50). The remaining bytes are kept as neutral raw - * metadata. In the 29,203-record corpus used to establish this layout, counts are always - * `[25, 0, 0, 25, 25]`; active slots contain 25 sign-extended i32 readings followed by zero padding. - * The two slots under one header must not be named as two wavelengths without independent evidence. + * PROVENANCE. Every offset, width, endianness and value set was measured from NOOP's own BLE captures: + * 29,203 records of this type recorded by the #454 deep-buffer recorder from a WHOOP 5.0/MG on fw + * 50.40.1.0, with 54 structural assertions passing on all 29,203 and 2140/2140 bytes accounted for. + * The derivation, negative controls and refuted alternatives are published at issue #423. * - * RE notes (issue #423, NOT authoritative): the record's config header carries an LED-current field @28 - * and per-photodiode offset DACs @38/@45. Under the split above those land as ONE drive current in a - * block's shared bytes (@27..32 for block 0) and ONE offset DAC in each of its two 7-byte channel - * descriptors (@33..39 and @40..46) — one LED per block, one detector per channel, which aligns with the - * "two readout channels share one measurement config" model. Kotlin twin of the Swift note. + * WHAT IS *NOT* CLAIMED. `drive*` is named only because a block with both drive fields at zero produces + * a dark measurement with no pulse (block 3, the corpus's built-in negative control); its units are not + * established. `source*` values {1,2,3,4} select something, but what each selects is unknown, and + * NOTHING in the corpus identifies an emission band. The two slots under one head belong to ONE shared + * measurement configuration and must not be labelled as two wavelengths. + * + * Baseline configuration over the corpus (constant unless marked *varies*): + * + * ``` + * | | blk0 | blk1 | blk2 | blk3 | blk4 | + * | sampleCount | 25 | 0 | 0 | 25 | 25 | + * | sourceA | 1 | 3 | 2 | 2 | 2 | + * | driveA | *varies* | 12750 | 6650 | 0 | 200 | {1150,1400,1750,2200,2750,3350} + * | sourceB | 4 | 4 | 4 | 4 | 4 | + * | driveB | 2*driveA | 0 | 0 | 0 | 0 | + * | detectorASelect | 3 | 1 | 1 | 3 | 3 | + * | rangeA | 32 | 16 | 32 | 32 | 32 | + * | offsetA | *varies* | 0 | 1600 | 0 | 0 | {0,800,1600,2400} + * | detectorBSelect | 4 | 2 | 2 | 4 | 1 | + * | rangeB | 32 | 16 | 32 | 32 | 32 | + * | offsetB | *varies* | 0 | 2400 | 0 | 0 | {0,800,1600,2400} + * ``` */ object Whoop5RawOptical { const val BUFFER_LENGTH = 2140 @@ -56,10 +139,51 @@ object Whoop5RawOptical { const val CHANNEL_SLOT_LENGTH = 200 const val CHANNEL_CAPACITY = 50 + /** u8 @9 identifying this record layout. */ + const val LAYOUT_VERSION = 20 + + /** u8 @8. Shared by the v18, v20 and v21 record types; also where the CRC32 input starts. */ + const val RECORD_CLASS = 0x2F + + /** Offset of the u32 LE CRC32 trailer. Also the end of the block tiling: `26 + 5*422`. */ + const val CHECKSUM_OFFSET = 2136 + + /** + * Observed ADC domain: signed 20-bit. The maximum over all 730,075 block-0 slot-A samples in the + * corpus is EXACTLY `2^19-1`, reached in 1.57% of them and never exceeded — a hardware saturation + * rail. Nothing ever goes below `-2^19` (deepest observed: -148,044). + */ + const val SAMPLE_MAX = 524_287 + const val SAMPLE_MIN = -524_288 + + /** + * Decode a complete layout-v20 historical record, or null. + * + * CRC-GATED. Bad bytes never become data: the frame's envelope and BOTH checksums are verified + * before a single payload field is read, and a frame that fails is rejected outright rather than + * decoded "best effort". + * + * The checksum rule for this record is the ordinary WHOOP 5.0 envelope rule the repo already + * implements — there is no v20-specific CRC: + * + * ``` + * crc16Modbus(frame[0 until 6]) == u16 LE @6 (29,203/29,203) + * crc32(frame[8 until 2136]) == u32 LE @2136 (29,203/29,203) + * ``` + * + * [Framing.frameCrcOk] with [DeviceFamily.WHOOP5] computes exactly that, because @2:3 declares + * 2132 and the envelope's payload span is `[8, declaredLength+8-4)` = `[8, 2136)`. + * + * CORRECTION TO THE PUBLISHED RECORD: an earlier note in issue #423 gave the CRC32 input as + * `[26:2136]`. That is wrong. It is `[8:2136]` — from the record-class byte, not from the start of + * the block tiling. Corroborated independently at a different frame size by @digitalerdude's v26 + * result (CRC-32 over bytes 8..83 for all 1,080 of their records), so byte 8 is the frame-family + * rule rather than a v20 quirk. Anything built against `[26:2136]` rejects every real record. + */ fun decode(frame: ByteArray): Whoop5OpticalFrame? { - if (frame.size != BUFFER_LENGTH || frame.u8(0) != 0xAA || - frame.u8(8) != 0x2F || frame.u8(9) != 20 - ) return null + if (frame.size != BUFFER_LENGTH || frame.u8(0) != 0xAA) return null + if (!Framing.frameCrcOk(frame, DeviceFamily.WHOOP5)) return null + if (frame.u8(8) != RECORD_CLASS || frame.u8(9) != LAYOUT_VERSION) return null val blocks = ArrayList(BLOCK_COUNT) for (index in 0 until BLOCK_COUNT) { @@ -82,6 +206,19 @@ object Whoop5RawOptical { sharedMetadata = sharedMetadata, channels = channels, reserved = frame.u8(start + BLOCK_LENGTH - 1), + config = Whoop5OpticalBlockConfig( + sampleCount = sampleCount, + sourceA = frame.u8(start + 1), + driveA = frame.u16(start + 2), + sourceB = frame.u8(start + 4), + driveB = frame.u16(start + 5), + detectorASelect = frame.u8(start + 7), + rangeA = frame.u32(start + 8), + offsetA = frame.i16(start + 12), + detectorBSelect = frame.u8(start + 14), + rangeB = frame.u32(start + 15), + offsetB = frame.i16(start + 19), + ), ) } @@ -89,17 +226,36 @@ object Whoop5RawOptical { recordIndex = frame.u32(11), baseTs = frame.u32(15), blocks = blocks, + layoutVersion = frame.u8(9), + checksum = frame.u32(CHECKSUM_OFFSET), ) } private fun ByteArray.u8(offset: Int): Int = this[offset].toInt() and 0xFF + private fun ByteArray.u16(offset: Int): Int = u8(offset) or (u8(offset + 1) shl 8) + + private fun ByteArray.i16(offset: Int): Int = u16(offset).toShort().toInt() + private fun ByteArray.u32(offset: Int): Long = (u8(offset).toLong() or (u8(offset + 1).toLong() shl 8) or (u8(offset + 2).toLong() shl 16) or (u8(offset + 3).toLong() shl 24)) + /** + * Readings are signed 20-bit values already SIGN-EXTENDED into a 4-byte little-endian container, + * so a plain signed 32-bit read is the whole decode — no masking, no manual sign extension. + * + * This is measured, not assumed: across all 4,380,450 populated containers in the corpus the + * fourth byte is only ever 0x00 (4,057,474) or 0xFF (322,976), never anything else, and the decoded + * values span [-148,044, 524,287] — inside the signed 20-bit domain with the positive rail exactly + * on `2^19-1`. + * + * The trap: masking to 20 bits and zero-extending, or reading the container unsigned, turns the + * real sample `-22,101` (wire `ab a9 ff ff`) into `1,048,491` or `4,294,945,195`. Both are wrong, + * and neither is obviously wrong from the numbers alone. + */ private fun ByteArray.i32(offset: Int): Int = u8(offset) or (u8(offset + 1) shl 8) or (u8(offset + 2) shl 16) or (u8(offset + 3) shl 24) diff --git a/android/app/src/test/java/com/noop/protocol/Whoop5RawOpticalTest.kt b/android/app/src/test/java/com/noop/protocol/Whoop5RawOpticalTest.kt index e382715f6f..573190a970 100644 --- a/android/app/src/test/java/com/noop/protocol/Whoop5RawOpticalTest.kt +++ b/android/app/src/test/java/com/noop/protocol/Whoop5RawOpticalTest.kt @@ -1,59 +1,382 @@ package com.noop.protocol +import org.json.JSONArray +import org.json.JSONObject import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue +import org.junit.Assume import org.junit.Test +import java.io.File +/** + * R20 (layout-v20, 2,140-byte) optical decoder — the Kotlin half of a shared Swift<->Kotlin drift guard. + * + * `src/test/resources/r20_optical_oracle.json` is byte-identical to + * `Packages/WhoopProtocol/Tests/WhoopProtocolTests/Resources/r20_optical_oracle.json`, and Swift's + * `Whoop5RawOpticalTests` runs the same assertions through its own decoder. Both `hex` strings are REAL + * captured records; the expected values came from an independent third implementation of the published + * byte layout (issue #423), so neither platform can drift without failing. Same idiom as + * [DecoderOracleTest]. + */ class Whoop5RawOpticalTest { - private fun frame(): ByteArray = ByteArray(Whoop5RawOptical.BUFFER_LENGTH).also { - it[0] = 0xAA.toByte() - it[8] = 0x2F - it[9] = 20 - putU32(it, 11, 11_494_060) - putU32(it, 15, 1_784_054_004) - - val block = Whoop5RawOptical.BLOCK_START + 4 * Whoop5RawOptical.BLOCK_LENGTH - it[block] = 2 - listOf(2, 200, 0, 4, 0, 0).forEachIndexed { i, value -> it[block + 1 + i] = value.toByte() } - listOf(3, 32, 0, 0, 0, 0, 0).forEachIndexed { i, value -> it[block + 7 + i] = value.toByte() } - listOf(1, 32, 0, 0, 0, 0, 0).forEachIndexed { i, value -> it[block + 14 + i] = value.toByte() } - putI32(it, block + Whoop5RawOptical.HEADER_LENGTH, -123_456) - putI32(it, block + Whoop5RawOptical.HEADER_LENGTH + 4, 234_567) - putI32(it, block + Whoop5RawOptical.HEADER_LENGTH + Whoop5RawOptical.CHANNEL_SLOT_LENGTH, 9_876) - putI32(it, block + Whoop5RawOptical.HEADER_LENGTH + Whoop5RawOptical.CHANNEL_SLOT_LENGTH + 4, 11_318) - } - - @Test fun decodesRepeatedBlockAndPairedChannels() { - val decoded = requireNotNull(Whoop5RawOptical.decode(frame())) - assertEquals(11_494_060L, decoded.recordIndex) - assertEquals(1_784_054_004L, decoded.baseTs) - assertEquals(listOf(0, 0, 0, 0, 2), decoded.blocks.map { it.sampleCount }) - - val block = decoded.blocks[4] - assertEquals(listOf(2, 200, 0, 4, 0, 0), block.sharedMetadata) - assertEquals(listOf(3, 32, 0, 0, 0, 0, 0), block.channels[0].metadata) - assertEquals(listOf(1, 32, 0, 0, 0, 0, 0), block.channels[1].metadata) - assertEquals(listOf(-123_456, 234_567), block.channels[0].samples) - assertEquals(listOf(9_876, 11_318), block.channels[1].samples) - assertEquals(21, block.rawHeader.size) - assertTrue(decoded.blocks.all { it.channels.size == 2 }) - } - - @Test fun rejectsWrongShapeOrImpossibleCount() { - assertNull(Whoop5RawOptical.decode(frame() + byteArrayOf(0))) - assertNull(Whoop5RawOptical.decode(frame().also { it[9] = 21 })) - assertNull(Whoop5RawOptical.decode(frame().also { - it[Whoop5RawOptical.BLOCK_START] = (Whoop5RawOptical.CHANNEL_CAPACITY + 1).toByte() - })) - } - - private fun putU32(frame: ByteArray, offset: Int, value: Int) { - frame[offset] = value.toByte() - frame[offset + 1] = (value ushr 8).toByte() - frame[offset + 2] = (value ushr 16).toByte() - frame[offset + 3] = (value ushr 24).toByte() - } - - private fun putI32(frame: ByteArray, offset: Int, value: Int) = putU32(frame, offset, value) + + private fun hexToBytes(s: String): ByteArray = + ByteArray(s.length / 2) { ((s[it * 2].digitToInt(16) shl 4) or s[it * 2 + 1].digitToInt(16)).toByte() } + + private fun loadOracle(): JSONObject { + val stream = javaClass.classLoader!!.getResourceAsStream("r20_optical_oracle.json") + assertNotNull("r20_optical_oracle.json missing from test classpath", stream) + return JSONObject(stream!!.bufferedReader().use { it.readText() }) + } + + private fun JSONArray.toIntList(): List = (0 until length()).map { getInt(it) } + + // MARK: - Golden vectors + + /** Every field of every block of every fixture record. This is the whole byte map executing. */ + @Test + fun oracleRecordsDecodeToPublishedLayout() { + val oracle = loadOracle() + val layout = oracle.getJSONObject("layout") + val bases = layout.getJSONArray("block_bases").toIntList() + val headLength = layout.getInt("head_length") + val records = oracle.getJSONArray("records") + assertTrue("no oracle records loaded", records.length() > 0) + + for (r in 0 until records.length()) { + val record = records.getJSONObject(r) + val name = record.getString("name") + val bytes = hexToBytes(record.getString("hex")) + assertEquals("$name: length", layout.getInt("buffer_length"), bytes.size) + + val decoded = Whoop5RawOptical.decode(bytes) + assertNotNull("$name failed to decode", decoded) + decoded!! + + val expect = record.getJSONObject("expect") + assertEquals("$name.layout_version", expect.getInt("layout_version"), decoded.layoutVersion) + assertEquals("$name.record_index", expect.getLong("record_index"), decoded.recordIndex) + assertEquals("$name.base_ts", expect.getLong("base_ts"), decoded.baseTs) + assertEquals("$name.checksum", expect.getLong("checksum"), decoded.checksum) + assertEquals( + "$name.sample_counts", + expect.getJSONArray("sample_counts").toIntList(), + decoded.blocks.map { it.sampleCount }, + ) + + val blocks = expect.getJSONArray("blocks") + assertEquals("$name: block count", blocks.length(), decoded.blocks.size) + for (b in 0 until blocks.length()) { + val want = blocks.getJSONObject(b) + val index = want.getInt("index") + val block = decoded.blocks[index] + val cfg = block.config + val label = "$name.blk$index" + assertEquals("$label.index", index, block.index) + assertEquals("$label.sample_count", want.getInt("sample_count"), cfg.sampleCount) + assertEquals("$label.source_a", want.getInt("source_a"), cfg.sourceA) + assertEquals("$label.drive_a", want.getInt("drive_a"), cfg.driveA) + assertEquals("$label.source_b", want.getInt("source_b"), cfg.sourceB) + assertEquals("$label.drive_b", want.getInt("drive_b"), cfg.driveB) + assertEquals("$label.detector_a_select", want.getInt("detector_a_select"), cfg.detectorASelect) + assertEquals("$label.range_a", want.getLong("range_a"), cfg.rangeA) + assertEquals("$label.offset_a", want.getInt("offset_a"), cfg.offsetA) + assertEquals("$label.detector_b_select", want.getInt("detector_b_select"), cfg.detectorBSelect) + assertEquals("$label.range_b", want.getLong("range_b"), cfg.rangeB) + assertEquals("$label.offset_b", want.getInt("offset_b"), cfg.offsetB) + assertEquals("$label.reserved", want.getInt("reserved"), block.reserved) + assertEquals("$label.readings_a", want.getJSONArray("readings_a").toIntList(), block.readingsA) + assertEquals("$label.readings_b", want.getJSONArray("readings_b").toIntList(), block.readingsB) + + // The 21-byte head is fully consumed by the eleven named fields, and the raw view + // still round-trips. + assertEquals("$label.rawHeader", headLength, block.rawHeader.size) + val base = bases[index] + assertEquals( + "$label: rawHeader must reproduce the on-wire head bytes", + (base until base + headLength).map { bytes[it].toInt() and 0xFF }, + block.rawHeader, + ) + } + } + } + + /** + * The block pattern that defines this record: three sampling blocks and two whose entire 400-byte + * reading area is zero. `sample_count == 0` must yield NO readings, not 50 zeros. + */ + @Test + fun blockPatternIsThreeActiveTwoDark() { + val records = loadOracle().getJSONArray("records") + for (r in 0 until records.length()) { + val record = records.getJSONObject(r) + val name = record.getString("name") + val decoded = requireNotNull(Whoop5RawOptical.decode(hexToBytes(record.getString("hex")))) + assertEquals(name, listOf(25, 0, 0, 25, 25), decoded.blocks.map { it.sampleCount }) + for (block in decoded.blocks) { + assertEquals("$name.blk${block.index}", 2, block.channels.size) + assertEquals("$name.blk${block.index}.a", block.sampleCount, block.readingsA.size) + assertEquals("$name.blk${block.index}.b", block.sampleCount, block.readingsB.size) + assertEquals("$name.blk${block.index}.reserved", 0, block.reserved) + } + // Block 3 is the corpus's built-in dark control: sampling, but with both drives at zero. + assertEquals(name, 25, decoded.blocks[3].config.sampleCount) + assertEquals(name, 0, decoded.blocks[3].config.driveA) + assertEquals(name, 0, decoded.blocks[3].config.driveB) + } + } + + /** + * Sign extension. The containers are already sign-extended on the wire, so the failure this guards + * is a decoder that reads them unsigned or re-extends from bit 19: either turns `ab a9 ff ff` into + * a large positive number instead of -22,101. + */ + @Test + fun negativeReadingsSignExtend() { + val records = loadOracle().getJSONArray("records") + var sawNegative = false + for (r in 0 until records.length()) { + val record = records.getJSONObject(r) + val name = record.getString("name") + val decoded = requireNotNull(Whoop5RawOptical.decode(hexToBytes(record.getString("hex")))) + for (block in decoded.blocks) { + for (value in block.readingsA + block.readingsB) { + assertTrue("$name: below the signed 20-bit floor", value >= Whoop5RawOptical.SAMPLE_MIN) + assertTrue("$name: above the signed 20-bit rail", value <= Whoop5RawOptical.SAMPLE_MAX) + if (value < 0) sawNegative = true + } + } + } + assertTrue("fixture must contain negative readings or it proves nothing", sawNegative) + assertEquals(-524_288, Whoop5RawOptical.SAMPLE_MIN) + assertEquals(524_287, Whoop5RawOptical.SAMPLE_MAX) + + val first = requireNotNull( + Whoop5RawOptical.decode(hexToBytes(records.getJSONObject(0).getString("hex"))), + ) + assertEquals( + "wire ab a9 ff ff is -22,101; 1,048,491 or 4,294,945,195 means the sign was dropped", + -22_101, + first.blocks[0].readingsB.first(), + ) + } + + /** + * The positive rail: exactly `2^19-1`, never exceeded anywhere in the corpus. A decoder that + * mis-sizes the container would land somewhere else entirely. + */ + @Test + fun saturationRailIsExactly2Pow19Minus1() { + val records = loadOracle().getJSONArray("records") + val record = (0 until records.length()) + .map { records.getJSONObject(it) } + .first { it.getString("name") == "rails_saturation_and_negatives" } + val decoded = requireNotNull(Whoop5RawOptical.decode(hexToBytes(record.getString("hex")))) + val all = decoded.blocks.flatMap { it.readingsA + it.readingsB } + assertEquals("positive rail", 524_287, all.max()) + assertTrue("record must actually saturate", all.count { it == 524_287 } > 0) + assertTrue("record must also carry negatives", all.min() < 0) + } + + /** + * CRC gating. Bad bytes never become data: every single-bit corruption in the fixture must be + * REJECTED, not decoded best-effort. Each case names the checksum it trips so a regression + * identifies which gate was lost. + */ + @Test + fun singleBitCorruptionIsRejected() { + val oracle = loadOracle() + val rejection = oracle.getJSONObject("crc_rejection") + val records = oracle.getJSONArray("records") + val source = (0 until records.length()) + .map { records.getJSONObject(it) } + .first { it.getString("name") == rejection.getString("source") } + val clean = hexToBytes(source.getString("hex")) + assertNotNull("control: the unmodified record must decode", Whoop5RawOptical.decode(clean)) + + val cases = rejection.getJSONArray("cases") + assertTrue("no rejection cases loaded", cases.length() > 0) + for (i in 0 until cases.length()) { + val c = cases.getJSONObject(i) + val corrupted = clean.copyOf() + val off = c.getInt("offset") + corrupted[off] = (corrupted[off].toInt() xor c.getInt("xor")).toByte() + assertNotEquals("${c.getString("name")}: fixture flipped nothing", clean[off], corrupted[off]) + assertNull( + "${c.getString("name")} (${c.getString("why")}) decoded despite a broken checksum", + Whoop5RawOptical.decode(corrupted), + ) + } + } + + /** + * The CRC input range, asserted directly, because the previously published value was `[26:2136]` + * and anything built on it rejects every real record. This is the standard WHOOP 5 envelope rule. + */ + @Test + fun checksumCoversByte8ThroughPayloadEnd() { + val bytes = hexToBytes(loadOracle().getJSONArray("records").getJSONObject(0).getString("hex")) + val trailer = (0 until 4).fold(0L) { acc, i -> + acc or ((bytes[2136 + i].toLong() and 0xFF) shl (8 * i)) + } + assertEquals("CRC32 input is [8:2136]", trailer, Crc.crc32(bytes, 8, 2136)) + assertNotEquals("the retracted [26:2136] range must NOT match", trailer, Crc.crc32(bytes, 26, 2136)) + // Header bytes 6:7, listed as unidentified in the write-up, are the envelope's CRC16-Modbus. + val headerCrc = (bytes[6].toInt() and 0xFF) or ((bytes[7].toInt() and 0xFF) shl 8) + assertEquals("header CRC16-Modbus over [0:6]", headerCrc, Crc.crc16Modbus(bytes, 0, 6)) + assertTrue( + "both checksums verify as one envelope check", + Framing.frameCrcOk(bytes, DeviceFamily.WHOOP5), + ) + } + + // MARK: - Full-corpus verification + + /** + * Runs the decoder over a whole deep-buffer capture and asserts the published invariants on every + * record — the Kotlin twin of Swift's `testFullCorpusDecodesWithPublishedInvariants`. Two golden + * vectors prove the offsets; this proves they hold at scale, on both platforms. + * + * Opt-in, because the capture is far too large to commit: + * + * ``` + * WHOOP_R20_CORPUS=/path/to/buffers2140.jsonl ./gradlew :app:testFullDebugUnitTest \ + * --tests '*Whoop5RawOpticalTest.fullCorpus*' + * ``` + * + * Verified against a 29,203-record WHOOP 5.0/MG capture: 29,203/29,203 decode, 0 mismatches. + */ + @Test + fun fullCorpusDecodesWithPublishedInvariants() { + val path = System.getenv("WHOOP_R20_CORPUS") + Assume.assumeTrue( + "set WHOOP_R20_CORPUS to a deep-buffer JSONL to run the full-corpus check", + path != null && File(path).exists(), + ) + + val driveSet = setOf(1150, 1400, 1750, 2200, 2750, 3350) + var total = 0 + var decodedCount = 0 + var mismatched = 0 + val failures = mutableListOf() + + File(path!!).forEachLine { line -> + if (line.isBlank()) return@forEachLine + val obj = JSONObject(line) + if (obj.optInt("size", -1) != Whoop5RawOptical.BUFFER_LENGTH) return@forEachLine + val hex = obj.optString("hex", "") + if (hex.isEmpty()) return@forEachLine + total++ + + val frame = Whoop5RawOptical.decode(hexToBytes(hex)) + if (frame == null) { + if (failures.size < 5) failures += "record $total failed to decode" + return@forEachLine + } + decodedCount++ + + val problems = mutableListOf() + // The recorder's own envelope timestamp must agree with the decoded field. + if (obj.has("strap_ts") && frame.baseTs != obj.getLong("strap_ts")) { + problems += "base_ts ${frame.baseTs} != strap_ts ${obj.getLong("strap_ts")}" + } + if (frame.blocks.map { it.sampleCount } != listOf(25, 0, 0, 25, 25)) problems += "block pattern" + val blk0 = frame.blocks[0].config + if (blk0.driveA !in driveSet) problems += "drive_a ${blk0.driveA} outside the six-value set" + if (blk0.driveB != blk0.driveA * 2) problems += "drive_b != 2 * drive_a" + for (block in frame.blocks) { + if (block.reserved != 0) problems += "blk${block.index} reserved != 0" + if (block.config.offsetA % 800 != 0 || block.config.offsetB % 800 != 0) { + problems += "blk${block.index} offset not a multiple of 800" + } + if (block.readingsA.size != block.sampleCount || block.readingsB.size != block.sampleCount) { + problems += "blk${block.index} reading count != sample_count" + } + val out = (block.readingsA + block.readingsB) + .firstOrNull { it < Whoop5RawOptical.SAMPLE_MIN || it > Whoop5RawOptical.SAMPLE_MAX } + if (out != null) problems += "blk${block.index} sample $out outside the signed 20-bit domain" + } + if (problems.isNotEmpty()) { + mismatched++ + if (failures.size < 5) failures += "record $total: ${problems.joinToString(", ")}" + } + } + + println("R20 corpus: $decodedCount/$total decoded, $mismatched invariant mismatches") + assertTrue("no 2,140-byte records found in $path", total > 0) + assertEquals("every record must decode. first failures: $failures", total, decodedCount) + assertEquals("published invariants must hold on every record: $failures", 0, mismatched) + } + + // MARK: - Structural gates + + @Test + fun rejectsWrongShapeOrImpossibleCount() { + assertNotNull(Whoop5RawOptical.decode(sealedSyntheticFrame())) + + // A sample count beyond the 50-slot capacity cannot be honoured, even with valid checksums. + assertNull( + Whoop5RawOptical.decode( + reseal( + sealedSyntheticFrame().also { + it[Whoop5RawOptical.BLOCK_START] = (Whoop5RawOptical.CHANNEL_CAPACITY + 1).toByte() + }, + ), + ), + ) + // Wrong length, wrong record class, wrong layout version. + assertNull(Whoop5RawOptical.decode(sealedSyntheticFrame() + byteArrayOf(0))) + assertNull(Whoop5RawOptical.decode(reseal(sealedSyntheticFrame().also { it[8] = 0x2E }))) + assertNull(Whoop5RawOptical.decode(reseal(sealedSyntheticFrame().also { it[9] = 21 }))) + } + + /** + * The Swift and Android copies of the oracle MUST be byte-identical, so neither platform can edit + * its fixture without the other. Skips when the Swift tree is not present in this checkout. + */ + @Test + fun oracleCopiesAreIdentical() { + val android = File("src/test/resources/r20_optical_oracle.json") + val swift = File("../Packages/WhoopProtocol/Tests/WhoopProtocolTests/Resources/r20_optical_oracle.json") + if (!android.exists() || !swift.exists()) return + assertTrue( + "r20_optical_oracle.json copies differ — keep Swift and Android in lockstep", + android.readBytes().contentEquals(swift.readBytes()), + ) + } + + // MARK: - Synthetic frame helpers + + /** + * A minimal, structurally valid, checksum-sealed v20 frame. Sealing is not optional any more: the + * decoder CRC-gates, so a hand-built frame has to carry real checksums exactly like a strap's. + */ + private fun sealedSyntheticFrame(): ByteArray = + reseal( + ByteArray(Whoop5RawOptical.BUFFER_LENGTH).also { + it[0] = 0xAA.toByte() + it[1] = 0x01 + it[2] = 0x54 // declared length 2132 = 2140 - 8 + it[3] = 0x08 + it[4] = 0x01 + it[8] = Whoop5RawOptical.RECORD_CLASS.toByte() + it[9] = Whoop5RawOptical.LAYOUT_VERSION.toByte() + }, + ) + + /** Recompute both checksums after mutating a frame, so a test asserts the gate it means to. */ + private fun reseal(frame: ByteArray): ByteArray { + if (frame.size < 12) return frame + val headerCrc = Crc.crc16Modbus(frame, 0, 6) + frame[6] = (headerCrc and 0xFF).toByte() + frame[7] = ((headerCrc shr 8) and 0xFF).toByte() + val end = Whoop5RawOptical.CHECKSUM_OFFSET + if (frame.size < end + 4) return frame + val payloadCrc = Crc.crc32(frame, 8, end) + for (i in 0 until 4) frame[end + i] = ((payloadCrc shr (8 * i)) and 0xFF).toByte() + return frame + } } diff --git a/android/app/src/test/resources/r20_optical_oracle.json b/android/app/src/test/resources/r20_optical_oracle.json new file mode 100644 index 0000000000..ab3163e7a7 --- /dev/null +++ b/android/app/src/test/resources/r20_optical_oracle.json @@ -0,0 +1,597 @@ +{ + "note": "Golden vectors for the WHOOP 5/MG layout-v20 (R20, 2,140-byte) optical record. Both `hex` strings are REAL records from a NOOP deep-buffer capture (issue #423); the expected values were produced by an independent third implementation of the published byte layout, so Swift and Kotlin each have to reproduce them rather than agreeing with each other by construction. Regenerate with Tools/gen_r20_oracle.py, which writes both copies at once \u2014 they MUST stay byte-identical.", + "layout": { + "buffer_length": 2140, + "block_bases": [ + 26, + 448, + 870, + 1292, + 1714 + ], + "block_length": 422, + "head_length": 21, + "slot_length": 200, + "slot_capacity": 50, + "checksum_offset": 2136, + "crc32_input": [ + 8, + 2136 + ], + "crc16_input": [ + 0, + 6 + ], + "sample_min": -524288, + "sample_max": 524287 + }, + "records": [ + { + "name": "blk0_active_negatives", + "hex": "aa0154080100b5b32f1481ac62af00f480566ab85e04001900001901160d042c1a0320000000000004200000002003a2ce0100abcb0100daca010004cd0100ffd00100ded50100fedb010019e001003be201004ae50100d0e90100d9ef0100acf601004afd01005d040200ce0e0200db180200622402005f2e020075320200be34020037390200e03c02004a3f02003a3f020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000aba9ffff57a8ffff09a7ffff8ca5ffff25a3ffffeca1ffff30a1ffffe6a0ffff8fa1fffff6a3ffffeaa7fffffaacffff0db3ffffdebaffff78c0ffff70c5fffffdd6ffff3fe1ffffeae3ffff6eedffff0debffff64e0ffff17d9ffffbad4ffff39cfffff00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003ce31040000011000000000000210000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002fa1904000001200000004006022000000060090000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000190200000400000320000000000004200000000000b9000000c0000000ae000000bd000000b3000000b0000000ba000000bc000000c6000000a0000000b7000000b9000000b0000000bb000000af000000a6000000aa000000b9000000b8000000b7000000bb0000009f000000ae000000ac000000ab00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c3000000b7000000c8000000bb000000a9000000bb000000c7000000ac000000ca000000a2000000bd000000bc000000b9000000bf000000b7000000bc000000b5000000b8000000ae000000b7000000c0000000b6000000ba000000be000000aa00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001902c8000400000320000000000001200000000000b0860100ca8601001d87010018880100b5880100c08801005d8901006c89010016890100f688010010890100b4890100b18a0100fe8b01003d8d0100be8f0100e5920100c29501001e980100ab9a0100bb9e010010a201008ba301002fa40100fba3010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f82600000f2700002d27000058270000a2270000b5270000cb270000c6270000a727000090270000892700009c270000c0270000e7270000172800008a280000ca280000f8280000822900005f2a00008d2b00003f2c00008d2c0000942c0000362c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a9a4a5c0", + "expect": { + "layout_version": 20, + "record_class": 47, + "record_index": 11494060, + "base_ts": 1784054004, + "checksum": 3232081065, + "sample_counts": [ + 25, + 0, + 0, + 25, + 25 + ], + "blocks": [ + { + "index": 0, + "sample_count": 25, + "source_a": 1, + "drive_a": 3350, + "source_b": 4, + "drive_b": 6700, + "detector_a_select": 3, + "range_a": 32, + "offset_a": 0, + "detector_b_select": 4, + "range_b": 32, + "offset_b": 800, + "reserved": 0, + "readings_a": [ + 118434, + 117675, + 117466, + 118020, + 119039, + 120286, + 121854, + 122905, + 123451, + 124234, + 125392, + 126937, + 128684, + 130378, + 132189, + 134862, + 137435, + 140386, + 142943, + 143989, + 144574, + 145719, + 146656, + 147274, + 147258 + ], + "readings_b": [ + -22101, + -22441, + -22775, + -23156, + -23771, + -24084, + -24272, + -24346, + -24177, + -23562, + -22550, + -21254, + -19699, + -17698, + -16264, + -14992, + -10499, + -7873, + -7190, + -4754, + -5363, + -8092, + -9961, + -11078, + -12487 + ] + }, + { + "index": 1, + "sample_count": 0, + "source_a": 3, + "drive_a": 12750, + "source_b": 4, + "drive_b": 0, + "detector_a_select": 1, + "range_a": 16, + "offset_a": 0, + "detector_b_select": 2, + "range_b": 16, + "offset_b": 0, + "reserved": 0, + "readings_a": [], + "readings_b": [] + }, + { + "index": 2, + "sample_count": 0, + "source_a": 2, + "drive_a": 6650, + "source_b": 4, + "drive_b": 0, + "detector_a_select": 1, + "range_a": 32, + "offset_a": 1600, + "detector_b_select": 2, + "range_b": 32, + "offset_b": 2400, + "reserved": 0, + "readings_a": [], + "readings_b": [] + }, + { + "index": 3, + "sample_count": 25, + "source_a": 2, + "drive_a": 0, + "source_b": 4, + "drive_b": 0, + "detector_a_select": 3, + "range_a": 32, + "offset_a": 0, + "detector_b_select": 4, + "range_b": 32, + "offset_b": 0, + "reserved": 0, + "readings_a": [ + 185, + 192, + 174, + 189, + 179, + 176, + 186, + 188, + 198, + 160, + 183, + 185, + 176, + 187, + 175, + 166, + 170, + 185, + 184, + 183, + 187, + 159, + 174, + 172, + 171 + ], + "readings_b": [ + 195, + 183, + 200, + 187, + 169, + 187, + 199, + 172, + 202, + 162, + 189, + 188, + 185, + 191, + 183, + 188, + 181, + 184, + 174, + 183, + 192, + 182, + 186, + 190, + 170 + ] + }, + { + "index": 4, + "sample_count": 25, + "source_a": 2, + "drive_a": 200, + "source_b": 4, + "drive_b": 0, + "detector_a_select": 3, + "range_a": 32, + "offset_a": 0, + "detector_b_select": 1, + "range_b": 32, + "offset_b": 0, + "reserved": 0, + "readings_a": [ + 100016, + 100042, + 100125, + 100376, + 100533, + 100544, + 100701, + 100716, + 100630, + 100598, + 100624, + 100788, + 101041, + 101374, + 101693, + 102334, + 103141, + 103874, + 104478, + 105131, + 106171, + 107024, + 107403, + 107567, + 107515 + ], + "readings_b": [ + 9976, + 9999, + 10029, + 10072, + 10146, + 10165, + 10187, + 10182, + 10151, + 10128, + 10121, + 10140, + 10176, + 10215, + 10263, + 10378, + 10442, + 10488, + 10626, + 10847, + 11149, + 11327, + 11405, + 11412, + 11318 + ] + } + ] + } + }, + { + "name": "rails_saturation_and_negatives", + "hex": "aa0154080100b5b32f1481d6f1af001f10576a850b04001900001901780504f00a0320000000200304200000000000bd03ffffcb03ffff25370000ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e70901008b09010023080100b5b70200c11d0600ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff0700ffff070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003ce31040000011000000000000210000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002fa1904000001200000004006022000000060090000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000190200000400000320000000000004200000000000be000000bf000000b9000000d6000000d2000000d9000000e2000000eb000000dd000000e4000000ce000000d5000000fa000000e6000000d1000000de000000d2000000c7000000d3000000cc000000dd000000db000000e1000000cf000000cf00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d7000000bb000000da000000d4000000d8000000e9000000d7000000db000000d5000000de000000c3000000d3000000d7000000e0000000ef000000d8000000d1000000f1000000e3000000d4000000c9000000cc000000d4000000d6000000d200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001902c8000400000320000000000001200000000000a1ee0100fcee01005735020092240300bd760300c18c030020b7030070b60300e7a203006eb40300e59d030052a703007bae030066af03004ea50300b39d0300dd8b030058a203007fa0030027a70300088f030095690300f45803008e4b0300f430030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ac1600008e160000dc170000dc200000f82b00007a310000313c0000bf400000e03f000065410000b2400000f54000008b420000f34200000f430000054500002b46000093470000ed4400002f3d00008e3100004a24000055200000491f0000781d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001dd5463b", + "expect": { + "layout_version": 20, + "record_class": 47, + "record_index": 11530710, + "base_ts": 1784090655, + "checksum": 994497821, + "sample_counts": [ + 25, + 0, + 0, + 25, + 25 + ], + "blocks": [ + { + "index": 0, + "sample_count": 25, + "source_a": 1, + "drive_a": 1400, + "source_b": 4, + "drive_b": 2800, + "detector_a_select": 3, + "range_a": 32, + "offset_a": 800, + "detector_b_select": 4, + "range_b": 32, + "offset_b": 0, + "reserved": 0, + "readings_a": [ + -64579, + -64565, + 14117, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287 + ], + "readings_b": [ + 68071, + 67979, + 67619, + 178101, + 400833, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287, + 524287 + ] + }, + { + "index": 1, + "sample_count": 0, + "source_a": 3, + "drive_a": 12750, + "source_b": 4, + "drive_b": 0, + "detector_a_select": 1, + "range_a": 16, + "offset_a": 0, + "detector_b_select": 2, + "range_b": 16, + "offset_b": 0, + "reserved": 0, + "readings_a": [], + "readings_b": [] + }, + { + "index": 2, + "sample_count": 0, + "source_a": 2, + "drive_a": 6650, + "source_b": 4, + "drive_b": 0, + "detector_a_select": 1, + "range_a": 32, + "offset_a": 1600, + "detector_b_select": 2, + "range_b": 32, + "offset_b": 2400, + "reserved": 0, + "readings_a": [], + "readings_b": [] + }, + { + "index": 3, + "sample_count": 25, + "source_a": 2, + "drive_a": 0, + "source_b": 4, + "drive_b": 0, + "detector_a_select": 3, + "range_a": 32, + "offset_a": 0, + "detector_b_select": 4, + "range_b": 32, + "offset_b": 0, + "reserved": 0, + "readings_a": [ + 190, + 191, + 185, + 214, + 210, + 217, + 226, + 235, + 221, + 228, + 206, + 213, + 250, + 230, + 209, + 222, + 210, + 199, + 211, + 204, + 221, + 219, + 225, + 207, + 207 + ], + "readings_b": [ + 215, + 187, + 218, + 212, + 216, + 233, + 215, + 219, + 213, + 222, + 195, + 211, + 215, + 224, + 239, + 216, + 209, + 241, + 227, + 212, + 201, + 204, + 212, + 214, + 210 + ] + }, + { + "index": 4, + "sample_count": 25, + "source_a": 2, + "drive_a": 200, + "source_b": 4, + "drive_b": 0, + "detector_a_select": 3, + "range_a": 32, + "offset_a": 0, + "detector_b_select": 1, + "range_b": 32, + "offset_b": 0, + "reserved": 0, + "readings_a": [ + 126625, + 126716, + 144727, + 205970, + 227005, + 232641, + 243488, + 243312, + 238311, + 242798, + 237029, + 239442, + 241275, + 241510, + 238926, + 236979, + 232413, + 238168, + 237695, + 239399, + 233224, + 223637, + 219380, + 215950, + 209140 + ], + "readings_b": [ + 5804, + 5774, + 6108, + 8412, + 11256, + 12666, + 15409, + 16575, + 16352, + 16741, + 16562, + 16629, + 17035, + 17139, + 17167, + 17669, + 17963, + 18323, + 17645, + 15663, + 12686, + 9290, + 8277, + 8009, + 7544 + ] + } + ] + } + } + ], + "crc_rejection": { + "source": "blk0_active_negatives", + "cases": [ + { + "name": "reading_bit_flip", + "offset": 47, + "xor": 1, + "why": "a bit in blk0 readings_a[0] \u2014 CRC32 payload", + "crc32_ok": false, + "crc16_ok": true + }, + { + "name": "sample_count_bit_flip", + "offset": 26, + "xor": 1, + "why": "blk0 sample_count, the byte that sets the parse shape \u2014 CRC32 payload", + "crc32_ok": false, + "crc16_ok": true + }, + { + "name": "drive_bit_flip", + "offset": 28, + "xor": 1, + "why": "blk0 drive_a low byte \u2014 CRC32 payload", + "crc32_ok": false, + "crc16_ok": true + }, + { + "name": "checksum_bit_flip", + "offset": 2136, + "xor": 1, + "why": "the CRC32 trailer itself", + "crc32_ok": false, + "crc16_ok": true + }, + { + "name": "declared_length_bit_flip", + "offset": 2, + "xor": 1, + "why": "the declared length @2:3 \u2014 CRC16-Modbus header", + "crc32_ok": true, + "crc16_ok": false + } + ] + } +}