Repository navigation
ecg: correct the MG Labrador R17 layout, wrist values and START list - #2594
Merged
Merged
Conversation
The WHOOP MG ECG decode in this repo was built from static analysis of the vendor client, with the packet type and several field offsets marked in the source as unattested inferences. A third-party implementation has since closed those questions against the official Android 5.458.0 Labrador parser and the 50.41.1.0 firmware constructor, and run the result on a physical MG. Three of the inferences were wrong. The layout. Fields were read from frame[11] as a 17-byte status block. The record's status fields actually begin six bytes later, and the sample block starts at inner[26]. The structural triage built on that offset required four bytes to be 0 or 1; two of them land on this record's quality byte (0..3) and its flags byte, whose electrode-contact bit is 0x08. Every packet reporting contact, which is every packet carrying a reading, failed the triage and was dropped. That is sufficient on its own to explain the acceptedButSilent verdict the probe keeps returning. What says the correction is right rather than merely different: rawWaveformStart was measured on an MG at 34 before any of this was decoded, and 34 is exactly rawTypeOffset + inner[26]. A test on each platform pins that identity, so an edit to either constant fails. The wrist values. SELECT_WRIST carried right=0/left=1, read off the client enum's declaration order. The wire values are right=1/left=2. This is the one command in the family that writes PERSISTENT strap state, so the old values did not merely fail, they wrote a wrong persistent selection or were refused. The same reading of the same enum's order was already wrong once for ControlSignal (#896), and that lesson was recorded in a comment beside a test that still asserted the wrist bytes through the symbol rather than as literals. The START list. ABORT_HISTORICAL_TRANSMITS (20) belongs to START, immediately ahead of 124, and goes out on every run. It was sent early and only when an offload happened to be draining, which left the common case without the clear the sequence calls for. The send allowlist gates opcode 20 on `backfilling`, which is right for the abort-a-sync button and wrong for a member of this sequence, so both platforms widen it for the one synchronous send. Also replaces FilteredLabradorPacket and its decode with LabradorR17, carrying the flags byte, the unreadable mask, and the terminal and invalid conditions. The probe log now reports sequence, presence and progress, so a strap log shows whether a reading progressed rather than only that packets arrived. EcgStatusHeader survives on the RAW (revision 16) path, whose body layout is still unconfirmed; the comments claiming both records share it are corrected. Not changed here: samplesPerRawRecord is 101 where the wire carries 100, but it feeds the #194 autocorrelation exclude-lag and needs its own change. Android still has no wrist-selection surface, so it sends an incomplete PREPARE list. LabradorOperation restart=3 exists and is not implemented. Verification: WhoopProtocol 812 tests, Android full unit suite, both green with fresh results. doc_comment_lint and i18n_audit --ci clean. BLEManager.swift is app-target Swift that no local or default-CI leg compiles, so app-build is the only thing that covers it. Not exercised on hardware yet. Refs #891, #1100. Tools/parity_twin_map.json moves in this commit rather than a follow-up: the derived sets change because the source does, and rebase-merge puts every commit on main on its own, so splitting them would land one that fails the ratchet. The refresh pairs all four new functions (function_pairs 211 to 215, unpaired_functions 4108 to 4100) rather than suppressing a finding. The probe report is one text on both platforms. The candidate line carried the not-a-diagnosis disclaimer inside it on Apple and appended it at log time on Android, so the two stored different strings for the same packet, and the per-packet notes landed after a closing parenthesis on one side. The report footer and the result sheet each already say it, so the per-line copy is gone and both platforms now store identical text. The verdict headline and the two section headings, which described a shape heuristic that no longer exists, are rewritten on both sides together.
ryanbr
force-pushed
the
fix/ecg-r17-layout-and-start-list
branch
from
September 29, 2026 13:28
3a3f283 to
37bdfda
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The MG ECG decode here was built from static analysis of the vendor client, and the source said so: the
packet type and several field offsets were marked as unattested inferences. A third-party implementation
has since closed those questions against the official Android 5.458.0 Labrador parser and the 50.41.1.0
firmware constructor, and driven a physical MG through a complete reading with the result. Three of the
inferences were wrong.
The layout, and why the probe kept reporting silence
Fields were read from
frame[11]as a 17-byte status block. The record's status fields begin six byteslater, and its sample block starts at
inner[26].The structural triage built on that offset required four bytes to be 0 or 1. Two of them land on this
record's quality byte (0...3) and its flags byte, whose electrode-contact bit is
0x08. So every packetreporting contact, which is every packet carrying a reading, failed the triage and was discarded. That is
sufficient on its own to explain the
acceptedButSilentverdict the probe has been returning, withoutneeding the strap to have done anything wrong.
What says the new layout is right rather than merely different:
rawWaveformStartwas measured on an MGat 34, before any of this was decoded, and 34 is exactly
rawTypeOffset + inner[26]. The offsetobserved on hardware and the source-closed layout describe the same bytes. A test on each platform pins
that identity, so an edit to either constant fails.
FilteredLabradorPacketand its decode are replaced byLabradorR17, which also carries the flags byte,the unreadable mask, and the strap's terminal and invalid conditions. The probe log now reports sequence,
presence and progress, so a strap log shows whether a reading PROGRESSED rather than only that packets
arrived.
The wrist values
SELECT_WRISTcarriedright=0 / left=1, read off the client enum's declaration order. The wire valuesare
right=1 / left=2.This is the one command in the family that writes PERSISTENT strap state, so the old values did not merely
fail: they wrote a wrong persistent selection, or were refused outright, on every strap that ran the probe.
The same reading of the same enum's order was already wrong once, for
ControlSignal(#896). That lessonwas written into a comment right beside a test that still asserted the wrist bytes through
.rawValuerather than as literals, so it would have moved with the enum and stayed green. Both platforms' wrist pins
are now literal wire bytes, with the reason spelled out.
The START list
ABORT_HISTORICAL_TRANSMITS(20) belongs to START, immediately ahead of124, and goes out on every run.It was sent early and only when an offload happened to be draining, which left the common case without the
clear the sequence calls for. My own comment describing where a third-party implementation puts it was
wrong, and is corrected.
The send allowlist gates opcode 20 on
backfilling. That is the right gate for the abort-a-sync button andthe wrong one for a member of this sequence, so both platforms widen it for exactly one synchronous send.
The two routes stay distinct: through
abortBackfill()while draining, because that also ends the localsession, and a bare send otherwise, because there is no session to tear down.
Two defects I introduced and caught in re-review
Both are named because they are the same shape as the bug being fixed.
The new frame decode bypassed the file's one verified seam.
r17FromFrameslicedframe[8...]to theend of the buffer, so the four-byte CRC32 trailer landed in
tailand was reported as record bytes.innerPayloadexists to stop at the declared length, and the file's own doc says every frame-level entrygoes through it. Now pinned twice: an empty tail on an aligned record, and exactly
[0xAB, 0xCD]on onewith real trailing bytes, plus an assertion that the frame's last four bytes are absent from it.
isLabradorR17Framehad no production caller. The probe callsr17FromFrameand uses the packet, sothe boolean wrapper existed only for its own tests. The parity ledger flagged precisely this
(
test-only-callsite, both platforms) while I was finding it by hand. Removed; the tests use a file-localpredicate that says in its doc why it is not shipped.
Also from that pass:
strapTimeandisLivehad no readers and are gone rather than shipped as "thereducer will want them"; the flag and unreadable accessors now have production readers because the probe
log renders them; Kotlin's
parseR17was still carrying the olddecodeFilteredprose; and four commentson both platforms still claimed the two Labrador records share a status block, which is now true only of
the RAW path.
Deliberately NOT changed
samplesPerRawRecordis 101 where the wire carries 100. Samples end atframe[234]; 234-235 aretail. It feeds the Widget (iOS): throttle the live-HR republish to 60s (#114 follow-up) #194 autocorrelation exclude-lag and
EcgResearchStats, so correcting it is its ownchange with its own re-derivation, not a rider on this one.
new confirmation UI for a persistent strap write plus localized copy. Flagged in the code, not built.
LabradorOperation restart = 3exists, and there is no plain resend of start. Not implemented,because nothing here retries.
EcgStatusHeaderand the RAW decode stay as they are. That record's body layout is not source-closedon either side, so replacing one unproven reading with another would buy nothing.
Verification
WhoopProtocol812 tests. Android full unit suite 774 classes / 6575 tests, run with--no-build-cache --rerun-tasksand every result XML checked against the clock. Both 0 failures.doc_comment_lintandi18n_audit --ciclean. The wrist copy changed, so its nine locale translations changed with it.Tools/parity_twin_map.jsonmoves in the same commit rather than a follow-up: the derived sets changebecause the source does, and rebase-merge puts every commit on main on its own. The refresh PAIRS the four
new functions rather than suppressing a finding (
function_pairs211 to 215,unpaired_functions4108 to4100).
Not exercised on hardware.
BLEManager.swiftis app-target Swift, which neither the local loop nor thedefault CI legs compile, so
app-buildis the only thing covering it here. And the wrist correction is apersistent strap write whose new values match a third-party implementation that does drive a physical MG,
but that this repo has not itself confirmed. If it should land without the wrist change, that commit splits
out cleanly. Refs #891, #1100.