Skip to content

ecg: correct the MG Labrador R17 layout, wrist values and START list - #2594

Merged
ryanbr merged 1 commit into
mainfrom
fix/ecg-r17-layout-and-start-list
Sep 29, 2026
Merged

ryanbr merged 1 commit into
mainfrom
fix/ecg-r17-layout-and-start-list

Conversation

@ryanbr

@ryanbr ryanbr commented Sep 29, 2026

Copy link
Copy Markdown
Owner

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 bytes
later, 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 packet
reporting contact, which is every packet carrying a reading, failed the triage and was discarded. That is
sufficient on its own to explain the acceptedButSilent verdict the probe has been returning, without
needing the strap to have done anything wrong.

What says the new layout 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]. The offset
observed 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.

FilteredLabradorPacket and its decode are replaced by LabradorR17, 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_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 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 lesson
was written into a comment right beside a test that still asserted the wrist bytes through .rawValue
rather 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 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. 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 and
the 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 local
session, 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. r17FromFrame sliced frame[8...] to the
end of the buffer, so the four-byte CRC32 trailer landed in tail and was reported as record bytes.
innerPayload exists to stop at the declared length, and the file's own doc says every frame-level entry
goes through it. Now pinned twice: an empty tail on an aligned record, and exactly [0xAB, 0xCD] on one
with real trailing bytes, plus an assertion that the frame's last four bytes are absent from it.

isLabradorR17Frame had no production caller. The probe calls r17FromFrame and uses the packet, so
the 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-local
predicate that says in its doc why it is not shipped.

Also from that pass: strapTime and isLive had no readers and are gone rather than shipped as "the
reducer will want them"; the flag and unreadable accessors now have production readers because the probe
log renders them; Kotlin's parseR17 was still carrying the old decodeFiltered prose; and four comments
on both platforms still claimed the two Labrador records share a status block, which is now true only of
the RAW path.

Deliberately NOT changed

  • samplesPerRawRecord is 101 where the wire carries 100. Samples end at frame[234]; 234-235 are
    tail. 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 own
    change with its own re-derivation, not a rider on this one.
  • Android has no wrist-selection surface, so it sends an incomplete PREPARE list. Closing that means a
    new confirmation UI for a persistent strap write plus localized copy. Flagged in the code, not built.
  • LabradorOperation restart = 3 exists, and there is no plain resend of start. Not implemented,
    because nothing here retries.
  • EcgStatusHeader and the RAW decode stay as they are. That record's body layout is not source-closed
    on either side, so replacing one unproven reading with another would buy nothing.

Verification

WhoopProtocol 812 tests. Android full unit suite 774 classes / 6575 tests, run with --no-build-cache --rerun-tasks and every result XML checked against the clock. Both 0 failures. doc_comment_lint and
i18n_audit --ci clean. The wrist copy changed, so its nine locale translations changed with it.

Tools/parity_twin_map.json moves in the same 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. The refresh PAIRS the four
new functions rather than suppressing a finding (function_pairs 211 to 215, unpaired_functions 4108 to
4100).

Not exercised on hardware. BLEManager.swift is app-target Swift, which neither the local loop nor the
default CI legs compile, so app-build is the only thing covering it here. And the wrist correction is a
persistent 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.

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
ryanbr force-pushed the fix/ecg-r17-layout-and-start-list branch from 3a3f283 to 37bdfda Compare September 29, 2026 13:28
@ryanbr
ryanbr merged commit 0bec556 into main Sep 29, 2026
18 checks passed
@ryanbr
ryanbr deleted the fix/ecg-r17-layout-and-start-list branch September 29, 2026 13:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant