Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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)")
}
}
}
}
2 changes: 1 addition & 1 deletion Strand/BLE/StandardHeartRate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions StrandTests/StandardHeartRateTests.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Foundation
import XCTest
import WhoopProtocol
@testable import Strand
Expand Down Expand Up @@ -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)
}
}
8 changes: 4 additions & 4 deletions Tools/parity_twin_map.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
38 changes: 38 additions & 0 deletions Tools/test_standard_hr_packet_contract.py
Original file line number Diff line number Diff line change
@@ -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()
7 changes: 4 additions & 3 deletions android/app/src/main/java/com/noop/ble/StandardHeartRate.kt
Original file line number Diff line number Diff line change
@@ -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).
Expand All @@ -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
Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
94 changes: 94 additions & 0 deletions android/app/src/test/java/com/noop/ble/StandardHeartRateTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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))
}
}
}
}
Loading