From d84f5a14e7b7896ce2fb177dfbb681e73bdf36b4 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:55:59 +0300 Subject: [PATCH] fix(ble): reject incomplete standard HR measurement fields --- .../WhoopProtocol/StandardHRMeasurement.swift | 12 +++ .../StandardHRMeasurementTests.swift | 35 +++++++ Strand/BLE/StandardHeartRate.swift | 2 +- StrandTests/StandardHeartRateTests.swift | 78 +++++++++++++++ Tools/parity_twin_map.json | 8 +- Tools/test_standard_hr_packet_contract.py | 38 ++++++++ .../java/com/noop/ble/StandardHeartRate.kt | 7 +- .../main/java/com/noop/ble/WhoopBleClient.kt | 5 +- .../noop/protocol/StandardHrMeasurement.kt | 17 ++++ .../com/noop/ble/StandardHeartRateTest.kt | 94 +++++++++++++++++++ .../protocol/StandardHrMeasurementTest.kt | 34 +++++++ 11 files changed, 321 insertions(+), 9 deletions(-) create mode 100644 Packages/WhoopProtocol/Sources/WhoopProtocol/StandardHRMeasurement.swift create mode 100644 Packages/WhoopProtocol/Tests/WhoopProtocolTests/StandardHRMeasurementTests.swift create mode 100644 Tools/test_standard_hr_packet_contract.py create mode 100644 android/app/src/main/java/com/noop/protocol/StandardHrMeasurement.kt create mode 100644 android/app/src/test/java/com/noop/protocol/StandardHrMeasurementTest.kt diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/StandardHRMeasurement.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/StandardHRMeasurement.swift new file mode 100644 index 0000000000..d145a58668 --- /dev/null +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/StandardHRMeasurement.swift @@ -0,0 +1,12 @@ +/// Structural completeness of the standard BLE Heart Rate Measurement (0x2A37). +public enum StandardHRMeasurement { + /// Require the flag-selected HR and energy fields and whole declared R-R words. + /// Reserved flag bits, empty R-R fields and undeclared trailing bytes retain existing compatibility. + /// Kotlin twin: `StandardHrMeasurement.hasCompleteFields`. + public static func hasCompleteFields(_ data: [UInt8]) -> Bool { + guard let flags = data.first else { return false } + let prefixBytes = 1 + (flags & 0x01 != 0 ? 2 : 1) + (flags & 0x08 != 0 ? 2 : 0) + guard data.count >= prefixBytes else { return false } + return flags & 0x10 == 0 || (data.count - prefixBytes) % 2 == 0 + } +} diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/StandardHRMeasurementTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/StandardHRMeasurementTests.swift new file mode 100644 index 0000000000..5998ac3784 --- /dev/null +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/StandardHRMeasurementTests.swift @@ -0,0 +1,35 @@ +import XCTest +@testable import WhoopProtocol + +final class StandardHRMeasurementTests: XCTestCase { + func testCompleteFieldVerdictMatchesSwiftOracleForEveryFlagAndLength() { + // Verbatim production Swift stdout, independently checked against the SIG layout table. + let oracle = """ + 00|00111111111111111111111111111111111111111111111111111111111111111 + 01|00011111111111111111111111111111111111111111111111111111111111111 + 08|00001111111111111111111111111111111111111111111111111111111111111 + 09|00000111111111111111111111111111111111111111111111111111111111111 + 10|00101010101010101010101010101010101010101010101010101010101010101 + 11|00010101010101010101010101010101010101010101010101010101010101010 + 18|00001010101010101010101010101010101010101010101010101010101010101 + 19|00000101010101010101010101010101010101010101010101010101010101010 + """ + var masks: [UInt8: [Character]] = [:] + for row in oracle.split(separator: "\n") { + let columns = row.split(separator: "|") + guard let keyText = columns.first, let key = UInt8(keyText, radix: 16), + let bits = columns.last else { XCTFail("invalid oracle row"); continue } + XCTAssertEqual(bits.count, 65) + masks[key] = Array(bits) + } + for flags in UInt8.min...UInt8.max { + guard let bits = masks[flags & 0x19] else { XCTFail("missing oracle mask"); continue } + for (length, bit) in bits.enumerated() { + var bytes = [UInt8](repeating: 0, count: length) + if !bytes.isEmpty { bytes[0] = flags } + XCTAssertEqual(StandardHRMeasurement.hasCompleteFields(bytes), bit == "1", + "flags=\(flags), length=\(length)") + } + } + } +} diff --git a/Strand/BLE/StandardHeartRate.swift b/Strand/BLE/StandardHeartRate.swift index c1c57e8a57..204c7ad5ee 100644 --- a/Strand/BLE/StandardHeartRate.swift +++ b/Strand/BLE/StandardHeartRate.swift @@ -10,7 +10,7 @@ import WhoopProtocol /// that know they have a WHOOP 5 should use `rrRawTicks` instead of `rr`. public enum StandardHeartRate { public static func parse(_ data: [UInt8]) -> (hr: Int, rr: [Int], rrRawTicks: [Int], contact: StandardHRContact)? { - guard !data.isEmpty else { return nil } + guard StandardHRMeasurement.hasCompleteFields(data) else { return nil } let flags = data[0] let contact = StandardHRContact.fromMeasurementFlags(flags) var idx = 1 diff --git a/StrandTests/StandardHeartRateTests.swift b/StrandTests/StandardHeartRateTests.swift index c9a434ca42..4324baadff 100644 --- a/StrandTests/StandardHeartRateTests.swift +++ b/StrandTests/StandardHeartRateTests.swift @@ -1,3 +1,4 @@ +import Foundation import XCTest import WhoopProtocol @testable import Strand @@ -29,4 +30,81 @@ final class StandardHeartRateTests: XCTestCase { XCTAssertEqual(parsed?.rrRawTicks, [1024]) XCTAssertEqual(parsed?.contact, .supportedDetected) } + + func testIncompleteDeclaredEnergyRefusesWholeReading() { + let heartRates: [[UInt8]] = [[72], [0x40, 1]] + for (format, hr) in heartRates.enumerated() { + for extraFlags: UInt8 in [0x00, 0x06, 0xe0, 0xe6, 0x10, 0x16, 0xf0, 0xf6] { + for energy: [UInt8] in [[], [0xff]] { + let frame = [UInt8(format) | 0x08 | extraFlags] + hr + energy + XCTAssertNil(StandardHeartRate.parse(frame), "incomplete energy: \(frame)") + } + } + } + } + + func testIncompleteRrTailRefusesWholeReading() { + let heartRates: [[UInt8]] = [[72], [0x40, 1]] + for (format, hr) in heartRates.enumerated() { + for energy: [UInt8] in [[], [0x34, 0x12]] { + for extraFlags: UInt8 in [0x00, 0x06, 0xe0, 0xe6] { + for tail: [UInt8] in [[0xff], [0, 4, 0xff], [0, 4, 0xff, 0xff, 0xab]] { + let flags = UInt8(format) | 0x10 | extraFlags | (energy.isEmpty ? 0 : 0x08) + let frame = [flags] + hr + energy + tail + XCTAssertNil(StandardHeartRate.parse(frame), "incomplete R-R: \(frame)") + } + } + } + } + } + + func testCompleteReadingsMatchOriginalSwiftOracle() { + let frames: [[UInt8]] = [ + [0x00, 72], [0x02, 0], [0x04, 220], [0x06, 255], + [0x01, 0x00, 0x01], [0x07, 0xff, 0xff], + [0x08, 72, 0, 0], [0x09, 0x40, 1, 0xff, 0xff], + [0x10, 60], [0x11, 0x40, 1], [0x18, 65, 0xff, 0], [0x19, 0xff, 0xff, 0xff, 0xff], + [0x10, 60, 0, 4], [0x16, 72, 0, 4], [0x18, 65, 0xff, 0, 0, 4], + [0x19, 0x40, 1, 0x34, 0x12, 0, 4], [0x10, 58, 0, 2, 0, 4], + [0x10, 72, 0, 0, 0x40, 0, 0xc0, 0, 0xff, 0xff], + [0xe0, 72], [0xe0, 72, 0xff], [0xf0, 72, 0, 4], [0xf6, 72, 0, 2, 0, 4], + [0xf9, 0x40, 1, 0, 0, 0, 4], [0xfe, 72, 0, 0, 0, 4], + [0xff, 0x40, 1, 0, 0, 1, 0], [0x00, 72, 0xff, 0, 4], [0x01, 0x40, 1, 0xff] +] + let expected = """ + 0048|72|||unsupported + 0200|0|||unsupported + 04dc|220|||supported_not_detected + 06ff|255|||supported_detected + 010001|256|||unsupported + 07ffff|65535|||supported_detected + 08480000|72|||unsupported + 094001ffff|320|||unsupported + 103c|60|||unsupported + 114001|320|||unsupported + 1841ff00|65|||unsupported + 19ffffffff|65535|||unsupported + 103c0004|60|1000|1024|unsupported + 16480004|72|1000|1024|supported_detected + 1841ff000004|65|1000|1024|unsupported + 19400134120004|320|1000|1024|unsupported + 103a00020004|58|500,1000|512,1024|unsupported + 104800004000c000ffff|72|0,63,188,63999|0,64,192,65535|unsupported + e048|72|||unsupported + e048ff|72|||unsupported + f0480004|72|1000|1024|unsupported + f64800020004|72|500,1000|512,1024|supported_detected + f9400100000004|320|1000|1024|unsupported + fe4800000004|72|1000|1024|supported_detected + ff400100000100|320|1|1|supported_detected + 0048ff0004|72|||unsupported + 014001ff|320|||unsupported + """ + let actual = frames.map { data -> String in + let hex = data.map { String(format: "%02x", $0) }.joined() + guard let r = StandardHeartRate.parse(data) else { return "\(hex)|nil" } + return "\(hex)|\(r.hr)|\(r.rr.map(String.init).joined(separator: ","))|\(r.rrRawTicks.map(String.init).joined(separator: ","))|\(r.contact.rawValue)" + }.joined(separator: "\n") + XCTAssertEqual(actual, expected) + } } diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 6d97b4e14d..55615fba14 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -18,12 +18,12 @@ ] }, "authority": { - "files": {"count": 510, "sha256": "b1c73338e79009d984d1313d8b3166e52672712c68074f5fee190c503e299b18"}, - "functions": {"count": 4538, "sha256": "f62a1ca0dfb2122fdca87a2f455ec0ca4435903c24069ae13bc4e75c0c3f32c0"}, + "files": {"count": 512, "sha256": "42d50ad0c9399efdcbb5ab4a5bf0527345cca985088dbcee7c66d5ab848033eb"}, + "functions": {"count": 4540, "sha256": "2fb1ff5bbbafdbc2a566749ce7b407863cfb0ae19c01358dbbf154fd23e3f761"}, "properties": {"count": 488, "sha256": "77d60fe0f6218252233b9e60da8bf5135968e49be3c6d2027d0973ea20ddf792"}, "constants": {"count": 2001, "sha256": "a9c1f36d2acc614dd747fd6cc0b3ea48079b81517afda5591d219f9591e353c3"}, - "file_pairs": {"count": 81, "sha256": "3c94824ccff843da495a212a973d89ce61e536b6795dbee2d0e496a7e7a498f0"}, - "function_pairs": {"count": 226, "sha256": "7c7f2298ad7921101241f39d86ecab774d21eadeb89d52d4d5db0397878905f2"}, + "file_pairs": {"count": 82, "sha256": "49dc412941daac7dbad686e5db922b21bfbd1b42950d8043a4a76e8ed8319ac5"}, + "function_pairs": {"count": 227, "sha256": "68864f6351a7c2f3b7118c2e92c5f39ff58c9ce0b0f62e9ceb7ae4de1dd5789b"}, "property_pairs": {"count": 163, "sha256": "b7d4a29086beec661993370657c0bcdae86f79b221fdc0043206161df5e1117f"}, "constant_pairs": {"count": 702, "sha256": "e3de4c1b84b8500c5700292648bf23dc680e5e14cde055c36c5d1ef4ecc39dc2"}, "unpaired_files": {"count": 370, "sha256": "441451c8f68b68e1dc3d3556c28952b4bdac243de9d6f909d3c18d6ac9732a1e"}, diff --git a/Tools/test_standard_hr_packet_contract.py b/Tools/test_standard_hr_packet_contract.py new file mode 100644 index 0000000000..44ac23ae7d --- /dev/null +++ b/Tools/test_standard_hr_packet_contract.py @@ -0,0 +1,38 @@ +"""The pure complete-field verdict must gate all standard-HR entry points before effects.""" + +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parent.parent + + +def parser_body(relative, signature): + text = (ROOT / relative).read_text(encoding="utf-8") + start = text.index("{", text.index(signature)) + 1 + depth = 1 + end = start + while depth: + depth += (text[end] == "{") - (text[end] == "}") + end += 1 + return text[start:end - 1] + + +class StandardHRPacketContractTests(unittest.TestCase): + def test_apple_parser_starts_with_complete_field_verdict(self): + body = parser_body("Strand/BLE/StandardHeartRate.swift", "public static func parse(") + self.assertRegex(body, r"^\s*guard StandardHRMeasurement\.hasCompleteFields\(data\) else \{ return nil \}") + + def test_android_sensor_parser_starts_with_complete_field_verdict(self): + body = parser_body("android/app/src/main/java/com/noop/ble/StandardHeartRate.kt", "fun parse(") + self.assertRegex(body, r"^\s*if \(!StandardHrMeasurement\.hasCompleteFields\(data\)\) return null") + + def test_android_whoop_parser_rejects_before_decoding_or_effects(self): + body = parser_body("android/app/src/main/java/com/noop/ble/WhoopBleClient.kt", "private fun parseStandardHr(") + self.assertRegex(body, r"^\s*if \(!com\.noop\.protocol\.StandardHrMeasurement\.hasCompleteFields\(data\)\) \{") + verdict = body.split("}", 1)[0] + self.assertIn('log("HR notify parse failed: incomplete measurement fields")', verdict) + self.assertRegex(verdict, r"\breturn\s*$") + + +if __name__ == "__main__": + unittest.main() diff --git a/android/app/src/main/java/com/noop/ble/StandardHeartRate.kt b/android/app/src/main/java/com/noop/ble/StandardHeartRate.kt index 540cda2ff2..e7c3f05525 100644 --- a/android/app/src/main/java/com/noop/ble/StandardHeartRate.kt +++ b/android/app/src/main/java/com/noop/ble/StandardHeartRate.kt @@ -1,6 +1,7 @@ package com.noop.ble import com.noop.protocol.StandardHrContact +import com.noop.protocol.StandardHrMeasurement /** * Pure parser for the standard BLE Heart Rate Measurement characteristic (0x2A37). @@ -9,8 +10,8 @@ import com.noop.protocol.StandardHrContact * R-R intervals (ms). Pure → unit-testable away from android.bluetooth. * * This is a SEPARATE parser from [WhoopBleClient.parseStandardHr] on purpose: the new isolated - * [StandardHrSource] uses THIS one so the WHOOP client's inline parse stays untouched (slight - * duplication is fine — it keeps the hardware-verified WHOOP path from regressing). Both encode the + * [StandardHrSource] uses THIS one so the WHOOP client's hardware-verified field decode stays separate. + * Both use [StandardHrMeasurement] for complete-field validation and encode the * same Bluetooth SIG layout: * - flags bit0 (0x01): HR is u16 (else u8) * - flags bit3 (0x08): Energy-Expended field present → skip its 2 bytes before R-R @@ -31,7 +32,7 @@ object StandardHeartRate { * whose declared HR/R-R bytes run past the buffer), matching the Swift `guard` bounds checks. */ fun parse(data: ByteArray): Reading? { - if (data.isEmpty()) return null + if (!StandardHrMeasurement.hasCompleteFields(data)) return null val flags = data[0].toInt() and 0xFF val contact = StandardHrContact.fromMeasurementFlags(flags) var idx = 1 diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt index 8fd7e63730..d84d40383e 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -9181,7 +9181,10 @@ class WhoopBleClient( * The standard profile is the RELIABLE source for both HR and R-R. */ private fun parseStandardHr(data: ByteArray) { - if (data.isEmpty()) return + if (!com.noop.protocol.StandardHrMeasurement.hasCompleteFields(data)) { + log("HR notify parse failed: incomplete measurement fields") + return + } val flags = data[0].toInt() and 0xFF val hr16 = (flags and 0x01) != 0 val rrPresent = (flags and 0x10) != 0 diff --git a/android/app/src/main/java/com/noop/protocol/StandardHrMeasurement.kt b/android/app/src/main/java/com/noop/protocol/StandardHrMeasurement.kt new file mode 100644 index 0000000000..84fd6d0cc8 --- /dev/null +++ b/android/app/src/main/java/com/noop/protocol/StandardHrMeasurement.kt @@ -0,0 +1,17 @@ +package com.noop.protocol + +/** Structural completeness of the standard BLE Heart Rate Measurement (0x2A37). */ +object StandardHrMeasurement { + /** + * Require the flag-selected HR and energy fields and whole declared R-R words. + * Reserved flag bits, empty R-R fields and undeclared trailing bytes retain existing compatibility. + * Swift twin: `StandardHRMeasurement.hasCompleteFields`. + */ + fun hasCompleteFields(data: ByteArray): Boolean { + if (data.isEmpty()) return false + val flags = data[0].toInt() and 0xFF + val prefixBytes = 1 + (if (flags and 0x01 != 0) 2 else 1) + (if (flags and 0x08 != 0) 2 else 0) + if (data.size < prefixBytes) return false + return flags and 0x10 == 0 || (data.size - prefixBytes) % 2 == 0 + } +} diff --git a/android/app/src/test/java/com/noop/ble/StandardHeartRateTest.kt b/android/app/src/test/java/com/noop/ble/StandardHeartRateTest.kt index e57c56bc41..702b81086c 100644 --- a/android/app/src/test/java/com/noop/ble/StandardHeartRateTest.kt +++ b/android/app/src/test/java/com/noop/ble/StandardHeartRateTest.kt @@ -93,4 +93,98 @@ class StandardHeartRateTest { // flags claim a 16-bit HR but only one HR byte follows → truncated → null. assertNull(StandardHeartRate.parse(bytes(0x01, 0x40))) } + + @Test fun incompleteDeclaredEnergyRefusesWholeReading() { + val heartRates = listOf(bytes(72), bytes(0x40, 1)) + for ((format, hr) in heartRates.withIndex()) { + for (extraFlags in listOf(0x00, 0x06, 0xe0, 0xe6, 0x10, 0x16, 0xf0, 0xf6)) { + for (energy in listOf(ByteArray(0), bytes(0xff))) { + val frame = bytes(format or 0x08 or extraFlags) + hr + energy + assertNull("incomplete energy: ${frame.toList()}", StandardHeartRate.parse(frame)) + } + } + } + } + + @Test fun incompleteRrTailRefusesWholeReading() { + val heartRates = listOf(bytes(72), bytes(0x40, 1)) + for ((format, hr) in heartRates.withIndex()) { + for (energy in listOf(ByteArray(0), bytes(0x34, 0x12))) { + for (extraFlags in listOf(0x00, 0x06, 0xe0, 0xe6)) { + for (tail in listOf(bytes(0xff), bytes(0, 4, 0xff), bytes(0, 4, 0xff, 0xff, 0xab))) { + val flags = format or 0x10 or extraFlags or (if (energy.isEmpty()) 0 else 0x08) + val frame = bytes(flags) + hr + energy + tail + assertNull("incomplete R-R: ${frame.toList()}", StandardHeartRate.parse(frame)) + } + } + } + } + } + + @Test fun completeReadingsMatchOriginalSwiftOracle() { + val frames = listOf( + bytes(0x00, 72), + bytes(0x02, 0), + bytes(0x04, 220), + bytes(0x06, 255), + bytes(0x01, 0x00, 0x01), + bytes(0x07, 0xff, 0xff), + bytes(0x08, 72, 0, 0), + bytes(0x09, 0x40, 1, 0xff, 0xff), + bytes(0x10, 60), + bytes(0x11, 0x40, 1), + bytes(0x18, 65, 0xff, 0), + bytes(0x19, 0xff, 0xff, 0xff, 0xff), + bytes(0x10, 60, 0, 4), + bytes(0x16, 72, 0, 4), + bytes(0x18, 65, 0xff, 0, 0, 4), + bytes(0x19, 0x40, 1, 0x34, 0x12, 0, 4), + bytes(0x10, 58, 0, 2, 0, 4), + bytes(0x10, 72, 0, 0, 0x40, 0, 0xc0, 0, 0xff, 0xff), + bytes(0xe0, 72), + bytes(0xe0, 72, 0xff), + bytes(0xf0, 72, 0, 4), + bytes(0xf6, 72, 0, 2, 0, 4), + bytes(0xf9, 0x40, 1, 0, 0, 0, 4), + bytes(0xfe, 72, 0, 0, 0, 4), + bytes(0xff, 0x40, 1, 0, 0, 1, 0), + bytes(0x00, 72, 0xff, 0, 4), + bytes(0x01, 0x40, 1, 0xff) + ) + val expected = """ + 0048|72|||unsupported + 0200|0|||unsupported + 04dc|220|||supported_not_detected + 06ff|255|||supported_detected + 010001|256|||unsupported + 07ffff|65535|||supported_detected + 08480000|72|||unsupported + 094001ffff|320|||unsupported + 103c|60|||unsupported + 114001|320|||unsupported + 1841ff00|65|||unsupported + 19ffffffff|65535|||unsupported + 103c0004|60|1000|1024|unsupported + 16480004|72|1000|1024|supported_detected + 1841ff000004|65|1000|1024|unsupported + 19400134120004|320|1000|1024|unsupported + 103a00020004|58|500,1000|512,1024|unsupported + 104800004000c000ffff|72|0,63,188,63999|0,64,192,65535|unsupported + e048|72|||unsupported + e048ff|72|||unsupported + f0480004|72|1000|1024|unsupported + f64800020004|72|500,1000|512,1024|supported_detected + f9400100000004|320|1000|1024|unsupported + fe4800000004|72|1000|1024|supported_detected + ff400100000100|320|1|1|supported_detected + 0048ff0004|72|||unsupported + 014001ff|320|||unsupported + """.trimIndent() + val actual = frames.joinToString("\n") { data -> + val hex = data.joinToString("") { java.lang.String.format(java.util.Locale.ROOT, "%02x", it.toInt() and 0xff) } + val r = StandardHeartRate.parse(data) + if (r == null) "$hex|nil" else "$hex|${r.hr}|${r.rr.joinToString(",")}|${r.rrRawTicks.joinToString(",")}|${r.contact.storageValue}" + } + assertEquals(expected, actual) + } } diff --git a/android/app/src/test/java/com/noop/protocol/StandardHrMeasurementTest.kt b/android/app/src/test/java/com/noop/protocol/StandardHrMeasurementTest.kt new file mode 100644 index 0000000000..3bdf573841 --- /dev/null +++ b/android/app/src/test/java/com/noop/protocol/StandardHrMeasurementTest.kt @@ -0,0 +1,34 @@ +package com.noop.protocol + +import org.junit.Assert.assertEquals +import org.junit.Test + +class StandardHrMeasurementTest { + @Test fun completeFieldVerdictMatchesSwiftOracleForEveryFlagAndLength() { + // Verbatim production Swift stdout, independently checked against the SIG layout table. + val oracle = """ + 00|00111111111111111111111111111111111111111111111111111111111111111 + 01|00011111111111111111111111111111111111111111111111111111111111111 + 08|00001111111111111111111111111111111111111111111111111111111111111 + 09|00000111111111111111111111111111111111111111111111111111111111111 + 10|00101010101010101010101010101010101010101010101010101010101010101 + 11|00010101010101010101010101010101010101010101010101010101010101010 + 18|00001010101010101010101010101010101010101010101010101010101010101 + 19|00000101010101010101010101010101010101010101010101010101010101010 + """.trimIndent() + val masks = oracle.lines().associate { row -> + val columns = row.split("|") + assertEquals(65, columns.last().length) + columns.first().toInt(16) to columns.last() + } + for (flags in 0..255) { + val bits = masks.getValue(flags and 0x19) + bits.forEachIndexed { length, bit -> + val bytes = ByteArray(length) + if (bytes.isNotEmpty()) bytes[0] = flags.toByte() + assertEquals("flags=$flags, length=$length", bit == '1', + StandardHrMeasurement.hasCompleteFields(bytes)) + } + } + } +}