Skip to content

WHOOP 5 MG: all three TOGGLE_LABRADOR (ECG) commands return SUCCESS and produce no data — six hypotheses, and which ones you can help rule out #891

Open

Description

@vishk23

All three TOGGLE_LABRADOR_* commands return SUCCESS on a WHOOP 5 MG and produce no data. Posting the raw frames plus six hypotheses, because I can't distinguish them alone.

I want to be upfront that the headline here is a null result with an ambiguous cause, and that I am deliberately not leading with the interpretation my own tooling printed. More on that below.


What was run

Three commands sent to my WHOOP 5 MG (WS50_r03), one at a time, each followed by a 30-second listen on the realtime channels:

opcode name in the CommandNumber table result reply frame
139 TOGGLE_LABRADOR_FILTERED SUCCESS(1) aa010c000100271124148b2f01010000845a1705
125 TOGGLE_LABRADOR_RAW_SAVE SUCCESS(1) aa010c000100271124157d300101000067ab1b82
124 TOGGLE_LABRADOR_DATA_GENERATION SUCCESS(1) aa010c000100271124167c3101010000ef4bcf45

Zero ECG-shaped packets arrived in any of the three 30-second windows.

The opcode numbers come from Packages/WhoopProtocol/Sources/WhoopProtocol/Resources/whoop_protocol.json (CommandNumber) — already in the tree, credited by docs/PROTOCOL.md to the johnmiddleton12/my-whoop (4.0) and b-nnett/goose (5.0 puffin) community reverse-engineering efforts. I did not derive them.

The frames, aligned

139   aa 01 0c 00 01 00 27 11 24 14 8b 2f 01 01 00 00  84 5a 17 05
125   aa 01 0c 00 01 00 27 11 24 15 7d 30 01 01 00 00  67 ab 1b 82
124   aa 01 0c 00 01 00 27 11 24 16 7c 31 01 01 00 00  ef 4b cf 45
                                 ^^ ^^ ^^              ^^^^^^^^^^^
                                  9 10 11              trailing 4

CONFIRMED (directly observable, re-derivable from the hex above by anyone):

  • The three replies are byte-identical except for bytes 9, 10, 11 and the trailing four.
  • Byte 10 is the echoed opcode — 8b/7d/7c = 139/125/124.
  • Bytes 9 and 11 each increment by exactly 1 across the three replies, consistent with sequence counters.
  • Byte 8 is 0x24 = 36 = COMMAND_RESPONSE in the repo's own PacketType enum.
  • The probe's decoder read result = SUCCESS(1) from each reply.
  • No ECG-shaped traffic followed.

INFERRED, labelled as such: the trailing 01 bytes look like an echoed on-state. I have not verified that, and I would not build on it.


The interpretation I am not publishing as the conclusion

My tooling's verdict string said "Consistent with a device-flag block applied as a silent no-op."

That is one hypothesis, not the finding. It happens to be the one my code was written to print, which is exactly why it deserves the least trust here. This project has publicly retracted two overclaims already and I'd rather not add a third by promoting a verdict string to a conclusion. At least five other explanations fit the same evidence, and one of them is a free retest I simply did not perform.


Six hypotheses, presented neutrally

(a) The electrode circuit was never closed — and this retest is free

Single-lead ECG needs two electrodes on opposite sides of the heart. A wrist device supplies one; the wearer must hold a second contact with the opposite hand for the entire recording. If that didn't happen there is no circuit, no signal, and firmware would plausibly return SUCCESS to the enable command and then produce nothing at all.

I did not do this. Nobody held anything. This costs one repeat run and it is the first thing that should be ruled out before anyone reads anything into the null.

(b) It isn't a realtime stream

One of the three commands is literally named RAW_SAVE, which implies flash rather than a live channel. If ECG lands in the historical offload as a record type NOOP does not decode, a 30-second realtime listen sees nothing even on complete success.

→ Ask: has anyone seen a historical record type / packet type on a 5 MG that NOOP does not recognise? That single observation would move this a lot.

(c) The opcode mapping is itself a hypothesis

docs/PROTOCOL.md says so in as many words: the strap "exposes an ECG/HeartKey command family (ECG_MAIN_CONTROL, ECG_SEND_RAW, ECG_SAVE_RAW, ECG_SAVE_FILTERED, ECG_SELECT_WRIST; five consecutive codes around 0x7B–0x8B)" and then: "Exact codes for these are unconfirmed."

Three problems follow:

  • Five names, four codes. The table has 123 SELECT_WRIST, 124 TOGGLE_LABRADOR_DATA_GENERATION, 125 TOGGLE_LABRADOR_RAW_SAVE, 139 TOGGLE_LABRADOR_FILTERED. ECG_SEND_RAW is unaccounted for.
  • 139 is not contiguous with 123–125, despite the doc's "five consecutive codes".
  • The 5/MG demonstrably remaps opcodes. docs/PROTOCOL.md records that on MAVERICK the clock family answers in the high space — SET_CLOCK at 146, GET_CLOCK at 147, GET_HELLO at 145 — versus 10/11 on the 4.0.

So the strap may well have accepted three valid commands that mean something else on this firmware, and returned SUCCESS entirely honestly.

(d) Nothing was told to start

All three names are TOGGLE_* — they read as enables. ECG_MAIN_CONTROL and ECG_SEND_RAW are the two names in the doc's family that sound like session control, and neither has a code in the table. It is entirely possible the three toggles armed a subsystem correctly and no recording was ever initiated.

(e) A device-flag block applied as a silent no-op

The tooling's own verdict. Listed here as a peer of the others, not above them. It predicts the same SUCCESS-and-silence as (a) through (d), which is precisely why it can't be asserted from this evidence.

(f) An account / entitlement gate

WHOOP gates ECG to MG hardware plus a higher membership tier. My strap is subscription-free.

This is the same shape as the @82 result in #103: on my sub-free strap the v18 SpO2-adjacent byte is 0x00 on 100% of records including 103 deep-sleep ones, while a subscriber's strap carries values that track the app's nightly number at corr +0.99. If ECG generation is entitlement-gated strap-side, the strap could accept the toggle and simply never run the subsystem — which looks identical to (e) from outside.


What would falsify what

# hypothesis what kills it who can do it
a electrode circuit open rerun holding the opposite-hand contact for the whole window me, or any MG owner — free
b data goes to flash, not realtime run a full history offload after the toggles and look for an unrecognised record type any MG owner
c wrong opcodes a capture showing what the official app actually sends when an ECG reading is taken on an MG an MG owner with a subscription
d no start command sent same capture as (c); it would show the ordering and any missing verb same
e silent flag block can only be reached by eliminating a–d and f —
f entitlement gate an MG owner with an active subscription runs the exact same three toggles an MG subscriber

(f) is the cheapest decisive one and needs no capture tooling at all — if a subscribed MG produces data from the same three commands that produced silence on my unsubscribed one, that's the answer, and it's a ten-second run.

What "it worked" would look like: a packet type NOOP doesn't recognise, or a REALTIME_RAW_DATA variant carrying a high-rate single-channel int16 waveform (ECG is one channel, unlike the 3-axis IMU and unlike the optical blocks). If you have ever seen an unexplained high-rate single-channel stream on an MG, please say so here.


Scope and safety, stated explicitly

  • These are writes, not reads. Unlike the Ask the strap which feature flags it knows: a read-only enumeration probe (#761) #872 / Ask the strap what a config key's VALUE is: a read-only device-config read probe (#103) #890 probes, TOGGLE_* commands change strap state. I sent them deliberately as a one-off experiment on my own strap. Nothing in any shipped NOOP code path forms these bytes, and I am not proposing that it should — the 5/MG send() allowlist rejects them today.
  • None of 124/125/139 is on the destructive list in docs/PROTOCOL.md (25 FORCE_TRIM, 32 POWER_CYCLE_STRAP, 36/37/38 firmware, 45 ENTER_BLE_DFU).
  • Caution if you reproduce this: they are toggles, and the replies carry a trailing 01. Whether they persist across reconnect or reboot is UNKNOWN — I did not verify a clean off state afterwards. Assume they might stick.
  • This is a protocol/decode question, not a product proposal. NOOP's rhythm surfaces are deliberately non-diagnostic ("not an ECG", "not a diagnosis", "cannot detect any heart condition") and NOOP is not a medical device. Whether anything ECG-shaped belongs in the product at all is a separate open question in Decide whether possible-arrhythmia notifications are out of scope #752, and nothing here is an argument for changing that answer. Even a fully working waveform would be a research artifact first.
  • Interoperation with hardware the user owns, from observed wire behaviour and names already in this repo's own tables.

The ask

I have one MG, no subscription, and one negative result. Any of these helps:

  1. An MG owner reruns the three toggles while holding the opposite-hand contact. Kills or confirms (a) for free.
  2. An MG owner with an active subscription reruns them unchanged. Kills or confirms (f).
  3. Anyone who has seen an unrecognised record type or packet type in a 5 MG offload — say so, even vaguely. That's (b).
  4. An HCI/PacketLogger capture of an MG taking an ECG reading in the official app. That settles (c) and (d) outright and is the single most valuable artifact. Same privacy guidance as WHOOP 5.0 raw HCI capture — where to submit? (docs reference #174, which doesn't exist) #103: filter to the WHOOP peripheral, and no account identifiers, tokens or HTTPS bodies — only the app→strap command frames matter.

Happy to be wrong about any of this. Posting the raw frames precisely so someone can tell me which hypothesis I've mis-weighted.

(Related: #103 for the @82 / entitlement-gate parallel and the capture guidance, #761 for the 5/MG deep-data roadmap, #872 / #890 for the read-only probes, #752 for the scope boundary.)

Activity

  1. ryanbr commented on Jul 27, 2026

    @ryanbr
    Owner

    Checked the three frames against the repo's own decoder rather than taking the table on faith. They hold up.

    Verified

    All three pass every structural check: declared length u16@[2..4] = 12, total = declLen + 8 = 20 ✓; CRC16-Modbus header over frame[0..6) = 0x1127 ✓; CRC32 tail over the inner record ✓. Run through decodeCommandResponseWhoop5 they decode exactly as reported — type@8 = 36 COMMAND_RESPONSE, resp_cmd@10 = 139 / 125 / 124, result@12 = 1 = SUCCESS. The names at 124/125/139 are in whoop_protocol.json's CommandNumber table as quoted. These are genuine, well-formed replies; nothing here is a misparse.

    One byte reading to correct

    Bytes 9 and 11 aren't two strap counters. Byte 11 is resp_seq, strap-side. Byte 9 is the envelope seq, which is host-assigned — puffinCommandFrame(cmd:seq:) takes it from the caller and BLEManager holds private var seq: UInt8 that it increments per send. The strap echoes it. So 0x14→0x16 describes the sending tool's counter, not the strap's. The repo's real battery fixture shows the split clearly: seq 147 alongside resp_seq 2. Doesn't change the result; it does mean byte 9 carries no information about strap state.

    The part worth more weight than it got

    The replies are not bare SUCCESS. Bytes 12–15 are 01 01 00 00 — byte 12 is the result, and byte 13 = 0x01, identical across all three. Byte 13 is the response-value slot: it is exactly where GET_BATTERY_LEVEL returns its number (the real fixture has 0x2f = 47 there, and the decoder reads battery_pct from @13). Filing that only under the persistence caution undersells it. It is the one piece of positive evidence in the capture that a flag took a value, though it could equally be an echo of the requested value rather than a read-back of state — which is the point.

    That makes (e) falsifiable without a second person. If these toggles are backed by device-config or feature-flag storage, GET_DEVICE_CONFIG_VALUE (121) and GET_FF_VALUE (128) are read-only, already on the send allowlist behind the Test Centre gate, and already have probe infrastructure — DeviceConfigReadProbe, and the flag-name enumeration probe from #761. Read before, toggle, read after:

    Solo, read-only, no subscription, no capture tooling. That is cheaper than (f), which needs another owner — worth doing before either. It also answers the open persistence question directly, which is currently listed as UNKNOWN.

    On (a)

    Worth running first because it is free, but it is a complete explanation only under an assumption worth stating: an open electrode circuit explains a flat or garbage waveform, and explains zero packets only if the firmware suppresses transmission on lead-off detect. Common in single-lead designs, not guaranteed. If the retest is run and still yields nothing, that gap is where the reasoning has to be careful — silence with leads closed rules out more than silence with leads open.

    On (c) — correct, and the doc is internally inconsistent regardless of which codes are right. docs/PROTOCOL.md:413 says "five consecutive codes around 0x7B–0x8B", while the table has 123/124/125 and then a gap to 139. Five names, four codes, non-contiguous. That line should be corrected to match the table and keep the "unconfirmed" caveat, independent of how this resolves.

    Scope — the safety framing checks out. There is no Labrador case in WhoopCommand at all, and the 5/MG path is an explicit allowlist that falls through to "no WHOOP 5/MG framing for this command yet", so nothing shipped can form these bytes. None of the three is on the destructive list.

    Declining to promote the tooling's verdict string to a conclusion is the right call and is what #194 asks for. The null result is worth having on the record either way.

  2. ryanbr commented on Jul 27, 2026

    @ryanbr
    Owner

    Opened #893 for the one thing here that was resolvable without hardware.

    Android labelled command responses from its curated sender enum rather than the shared schema, so these three replies rendered as 0x8B(139) / 0x7D(125) / 0x7C(124) on Android and named on Apple — from identical bytes. Anyone answering the ask above from an Android phone would have posted hex. 46 opcodes were affected; three more printed a different name on each platform.

    Fixed as a read-only label table, not by growing the sender enum — that would have made FORCE_TRIM and the firmware-load family constructible. The three frames are pinned as decode fixtures on both platforms, and byte 13 is deliberately left undecoded.

    Also corrected the docs/PROTOCOL.md line behind hypothesis (c).

    The ECG question itself is untouched — it still needs the retest, a subscribed MG, or a capture.

  3. vishk23 commented on Jul 27, 2026

    @vishk23
    Author

    Three more runs on the same WHOOP 5 MG (WS50_r03, build 215), tonight. One of them answers the byte-13 question @ryanbr raised, on hardware, and it does not answer it the way I expected.

    Fingers were on the clasp electrodes for the full 30 seconds in every run this time — thumb and index on the two indents, the posture WHOOP's own Heart Screener instructions call for. That was the free retest hypothesis (a) asked for, and I should have done it the first time.

    Also: one of these runs is not a test at all, and my tooling said it was. That is at the bottom, with the fix.


    Verification first

    All five frames below (the four new ones plus the earlier run-1 reply) were re-decoded with this repo's own whoop-decode against Packages/WhoopProtocol at current main, not by hand. All five: CRC16-Modbus over frame[0..6) = 0x1127 OK, CRC32 over the inner record OK, packet_type@8 = 36 COMMAND_RESPONSE. Post-#899 the Apple decoder now names resp_seq and result itself, so the field reading is the decoder's, not mine:

    resp_cmd       = SELECT_WRIST(123)  (7b)
    resp_seq       = 129                (81)
    result         = SUCCESS(1)         (01)
    

    Opcodes as before, from whoop_protocol.json's CommandNumber table already in the tree — I derived none of them.

    Using @ryanbr's corrected reading of the envelope (byte 9 = host-assigned seq, byte 11 = strap-side resp_seq):

    run command arg sent seq@9 resp_cmd@10 resp_seq@11 result@12 byte 13
    1 SELECT_WRIST 1 (left) 215 123 129 SUCCESS(1) 1
    2 TOGGLE_LABRADOR_FILTERED 1 241 139 155 SUCCESS(1) 1
    2 TOGGLE_LABRADOR_RAW_SAVE 1 242 125 156 SUCCESS(1) 1
    2 TOGGLE_LABRADOR_DATA_GENERATION 1 (start) 243 124 157 SUCCESS(1) 1
    3 SELECT_WRIST 0 (right) 33 123 204 FAILURE(0) 1
    run 1  aa010c000100271124d77b81010100007ce76722
    run 2  aa010c000100271124f18b9b01010000e07dc8b1
           aa010c000100271124f27d9c010100006941cbf1
           aa010c000100271124f37c9d01010000c8b080a1
    run 3  aa010c000100271124217bcc000100000213163d
    

    resp_seq runs 129 → 155 → 156 → 157 → 204, which independently confirms the runs are in that order — a useful side effect of byte 11 being strap-side rather than ours.

    Zero ECG-shaped packets in every 30-second window.


    1. Byte 13 is a read-back of stored state, not an echo of the request

    @ryanbr identified byte 13 as the response-value slot — the same slot GET_BATTERY_LEVEL returns its number in (Interpreter.swift reads battery_pct from pay[2], i.e. frame offset 13, with the 47%-confirmed fixture) — and named exactly the right caveat: it "could equally be an echo of the requested value rather than a read-back of state — which is the point."

    Every observation up to now was confounded: argument sent = 1, byte 13 back = 1, four times.

    Run 3 breaks the confound. I sent argument 0 and byte 13 came back 1.

    An echo would have returned 0. It returned the value stored by run 1's successful write. So on this reply byte 13 carries strap state, not the request. That is @ryanbr's question, answered on hardware, in his favour — and it means byte 13 is worth the weight he put on it rather than the weight I gave it.

    One residual caveat I would rather name than have pointed out: this is demonstrated on a FAILURE reply. A firmware that echoed the request on success and reported current state on refusal would also fit every frame here. The single-mechanism reading — byte 13 is the stored value throughout — is much the simpler one, but the four SUCCESS replies remain individually confounded because for them the requested and stored values are both 1.

    It also corrects my own guess in the opening post. I wrote that the trailing 01 "look[s] like an echoed on-state". Echo is the reading that just failed.

    2. FAILURE(0), not UNSUPPORTED(3) — and the same opcode succeeded minutes earlier

    The strap knows opcode 123 — it ran it successfully earlier in the same session and refused it later. The only thing that changed is the argument:

    SELECT_WRIST arg=1 (left)  — equals the already-stored value  → SUCCESS(1), byte13=1
    SELECT_WRIST arg=0 (right) — would be a real change           → FAILURE(0), byte13=1
    

    Two readings fit, and I cannot separate them with two data points:

    • (i) A write that changes nothing is accepted; a write that would actually mutate state is refused. This is my leading reading, stated as a hypothesis and nothing more.
    • (ii) 0 is simply not a valid argument on this firmware. docs/PROTOCOL.md has always flagged right=0/left=1 as inferred from the client enum's order, not attested. If the real encoding is something else, run 3 sent a bad argument and got the correct answer to it.

    Worth being precise about what a cheap follow-up buys: re-sending left (arg=1) and getting SUCCESS again would confirm the asymmetry is stable rather than a one-off, but it does not separate (i) from (ii) — both predict SUCCESS. Nor does an out-of-range argument; both predict FAILURE. Separating them properly needs a strap whose stored wrist is the other one. I will run the consistency check regardless, because "it was a transient" is worth eliminating for free.

    Loose end this creates, flagged rather than resolved: the stored wrist value is currently left, and whether that matches the wrist the strap is actually worn on is not observable from the wire — the one command that would change it is being refused.

    3. What this does and does not say about the three toggles

    Reasoning chain, with its assumption named, not a conclusion:

    • Observed: all three toggle replies carry byte 13 = 1.
    • Assumption: byte 13 is stored state for opcodes 124/125/139 too. This is proven only for 123. It is the natural generalisation of a single response-value slot, and it is still an assumption.
    • If that holds: the strap is reporting the ECG flags as ON while emitting nothing. That pushes away from "the write never took" and toward (b) it isn't a realtime stream or a suppression sitting below the flag layer.
    • Double-conditional, flagged as such: if hypothesis (i) above also holds, then the toggles returning SUCCESS means those writes changed nothing either — i.e. the flags were already 1 before run 2 was sent. I am not asserting that; two stacked assumptions is exactly where this thread has gone wrong before. But it is the implication, and I would rather name it than have it found later.

    On (b) specifically, @digitalerdude's full-history capture in #103 remains the strongest evidence against — every historical record a 124 B v18 or 88 B v26 frame, frames >200B = 0 across ~30k contiguous records — with the caveat from the opening post that it was a 5.0 rather than an MG, with Labrador presumably never enabled.

    4. Electrode contact is weakened as an explanation for run 2

    Run 2 had fingers on the clasp electrodes for the entire 30 seconds and still produced nothing. That weakens hypothesis (a) as the explanation for that run.

    Weakens, not eliminates. Nothing on the wire reports whether skin contact was electrically adequate — the heartKeyLeadsAreOn bit lives in the ECG status header, and no ECG packet arrived to carry one. "I held it correctly" is a claim about my hands, not a measurement. And as @ryanbr noted, an open circuit explains zero packets only if the firmware suppresses transmission on lead-off detect; that assumption is untouched by this run.


    5. Run 1 is not a test, and my tooling said it was — twice

    Run 1 sent only SELECT_WRIST(123). No toggle, no data-generation command, nothing that could cause the strap to produce ECG data. Zero packets is the correct and expected outcome, and the run says nothing whatsoever about whether ECG is blocked.

    My probe rendered:

    Verdict: Accepted but SILENT — every command returned SUCCESS yet no ECG packet arrived in 30s. Consistent with a device-flag block applied as a silent no-op.

    That is wrong. And run 3 — also wrist-only — tripped the other verdict:

    Verdict: LIKELY blockedByDeviceFlags

    Both are the same defect: the classifier read "zero ECG-shaped packets" and "a FAILURE arrived" as evidence about the block question without checking whether the run had asked the ECG data path for anything at all. It manufactured support for hypothesis (e) — the one I said in the opening post deserved the least trust precisely because it was the string my code was written to print — out of two runs that could not speak to it. If I had pasted either verdict here unqualified it would have corrupted the thread.

    Fix in flight (#896, pushed): the two verdicts that read silence or refusal as evidence about the feature are now unreachable unless the run sent a command that could actually produce realtime ECG data and the strap acknowledged it. That predicate is decided from the opcode and the argument in the shared protocol layer, mirrored in Kotlin, tested on both platforms — SELECT_WRIST starts nothing on either argument, the OFF sequence asks for the silence it gets, and RAW_SAVE names flash rather than a live channel. A wrist-only run now reports that it sent no data-generation command and cannot speak to the block question; a FAILURE on a configuration write reports as a refusal of that write. Regression tests pin both of tonight's misreadings by their real frames.

    The general rule, since it is not specific to ECG: a probe verdict must not assert a conclusion the run's own inputs could not support. I have audited the other three probes against it and there are hits — I will file those separately rather than bury them here.


    Where that leaves the six hypotheses

    • (a) open electrode circuit — weakened for run 2, not eliminated.
    • (b) not a realtime stream — strengthened, if byte 13 generalises. Best remaining lead.
    • (c) wrong opcode mapping — untouched. Still wants a capture.
    • (d) nothing told to start — untouched.
    • (e) silent flag block — no better supported than before, and two of the three data points that appeared to support it were my own tool's error.
    • (f) entitlement gate — untouched. Still the cheapest decisive one, and still needs a subscribed MG owner: the same three toggles, ten seconds, unchanged.

    The asks from the opening post all stand. Added to them: if anyone with an MG can send SELECT_WRIST with the wrist they are not currently set to, I would like to know whether they also get FAILURE — that separates "this strap refuses mutations" from "something about my strap".

  4. vishk23 commented on Jul 27, 2026

    @vishk23
    Author

    A device-config key named enable_raw_data_w_ecg, and it is '0'

    Cross-referencing rather than restating — the full result is in
    #103, and it bears directly on
    hypothesis (e) here.

    The device-config namespace enumerates on this strap. START_DEVICE_CONFIG_KEY_EXCHANGE(115) →
    SEND_NEXT_DEVICE_CONFIG(116) returned revision=1, count=7 and walked to a clean terminator. One of
    the seven keys is:

    enable_raw_data_w_ecg = '0'
    

    Same strap as the runs above (WS50_r03, build 215), read-only, no writes on that path.

    I want to be careful with this. It is the cleanest explanation anyone has for "three toggles ack
    SUCCESS with the electrodes properly held and zero packets arrive" — but it is a leading
    hypothesis, not a cause
    . What is established is only that a device-config key with that name exists
    on this strap and is set to '0'. Nothing yet connects it to the Labrador data path by observation;
    the name is suggestive and that is all it is. (b) flash-not-realtime, (c) a wrong opcode mapping, the
    missing-start-verb reading, and the entitlement gate are all still live, and the byte-13 result above
    does not pick between them either.

    What it does change is that the flag-block branch is now cheap to test instead of hypothetical:
    the key has a name, SET_DEVICE_CONFIG_VALUE(119) is the write verb for that namespace (confirmed
    separate from SET_FF_VALUE(120) in both directions — see #103), and #103 also establishes on this
    strap that NOOP's config writes actually persist and read back rather than being silently dropped.
    So this is a one-round-trip experiment now, not a research project.

    Two caveats before anyone tries it, both mine to own:

    1. That would be a write, and every probe in this thread so far has been read-only. It needs its
      own gate and its own before/after read, not a bolt-on to the existing probe.
    2. Enumeration only lists keys that have a stored value. The oracle cannot tell "no such key" from
      "key exists, nothing stored" — both return FAILURE — so the list is what is currently set on this
      strap, not the firmware's key space.
  5. ryanbr commented on Jul 27, 2026

    @ryanbr
    Owner

    Re-decoded all five frames independently. Every one verifies — CRC16-Modbus header 0x1127, CRC32 over the inner record, packet_type@8 = 36 — and every field in your table reproduces exactly, including resp_seq 129 → 155 → 156 → 157 → 204 and the FAILURE(0) on run 3. Nothing in the report needs qualifying on the decode.

    Run 3 is a good experiment and §5 is a better one. Two things below: one where I think the conclusion outruns the data by a single step, and one convergence that makes an ask you already have worth more than it looks.

    Byte 13: echo is refuted, read-back is not established

    Sending arg=0 and getting byte 13 = 1 back kills the echo reading outright. That is a real result and it is the right experiment.

    It does not get to read-back yet, though. Across every MG COMMAND_RESPONSE on record — the three in the opening post plus tonight's five — byte 13 has taken exactly one value:

    frames commands results distinct byte-13 values
    8 123, 124, 125, 139 7× SUCCESS, 1× FAILURE {1}

    A constant fits all eight observations as well as stored-state does, and is the simpler of the two. What run 3 established is the narrower claim: byte 13 is not the request. Between "carries state" and "is 1 on this reply family" the data is silent, because the stored value has been 1 in every frame anyone has captured.

    That matters specifically for §3. The chain there — byte 13 = 1 on the toggles → the strap reports the ECG flags ON while emitting nothing → strengthens (b) — needs read-back to be true. Under the constant reading the premise carries no information about flag state and (b) is not strengthened at all. You flagged the generalisation from 123 to 124/125/139 as the assumption; the load-bearing one is a step earlier than that.

    The residual caveat you named — that read-back is demonstrated only on a FAILURE reply, so a firmware that echoes on success and reports state on refusal fits too — is the same gap seen from a different angle, and you were right to name it.

    The convergence: your §2 ask settles byte 13 as well

    You asked for an MG owner to send SELECT_WRIST with the wrist they are not currently set to, to separate "refuses mutations" from "something about my strap".

    That is the same run that separates constant from read-back. A strap whose stored wrist is right replies with a stored value of 0, and byte 13 either follows it or does not. One run, three questions: the (i)/(ii) split, the constant/read-back split, and whether the asymmetry is stable. Worth saying so in the ask — it is currently framed as buying only the first.

    GET_DEVICE_CONFIG_VALUE(121) will not substitute, incidentally: that reply carries its value after the 32-byte echoed name field, not at 13, so it reads a different slot and says nothing about this one.

    The two comments in tension, usefully

    If byte 13 does generalise, the strap reports Labrador flags ON while enable_raw_data_w_ecg reads '0'. Different namespaces, so both can hold — but it is worth noticing that the flag which is observably off is the config one, and the flags that are inferred on are the toggles. If anything that points at the config key being the gate and the toggles being downstream of it, which is a cleaner story than either comment tells alone. Not a claim, and both readings survive it.

    Your caution on the key is right, and the second caveat is the sharper one: enumeration lists keys with a stored value, so count=7 is what is set on that strap, not the firmware's key space. A key absent from the list is not a key that does not exist.

    §5

    The right call, and the fix is the right shape — a verdict that cannot be reached unless the run sent something that could produce the data it is drawing conclusions about. Deciding the predicate from opcode and argument in the shared layer is what makes it hold on both platforms rather than in the UI that happened to launch it.

    Worth stating plainly since it is easy to lose: two of the three data points that appeared to support (e) were produced by a tool printing its own verdict string, and you found that yourself and said so before anyone else read it. That is the failure mode #194 exists for, caught one level up from where it usually is.

    Auditing the other three probes against the same rule is the right follow-up. If it helps scope it: the rule generalises past probes — any report that turns absence of evidence into a named conclusion needs the run's inputs to license the conclusion, and FeatureFlagProbe's "strap names no oxygen flag" line in #103 is the same shape.

  6. added a commit that references this issue on Jul 27, 2026
  7. vishk23 commented on Jul 28, 2026

    @vishk23
    Author

    You are right on both counts. The second one is load-bearing in a way I did not see, and it takes down a chain I wrote.

    Byte 13: I walked one step past the result

    Conceded, without qualification. A constant fits all eight frames as well as read-back does and is the simpler of the two, and I had no business preferring the other one. What run 3 bought is exactly the narrow claim and nothing beyond it: byte 13 is not the request. Echo is refuted. That is where the evidence stops.

    The reason the data cannot go further is worth stating plainly, because it is the whole of it: the stored value has been 1 in every frame anyone has captured, so "carries state" and "is 1 on this reply family" make identical predictions on all eight. Four opcodes, seven SUCCESS and one FAILURE, one distinct value. There is no observation on record that the two readings disagree about.

    The consequence, which is mine to own rather than leave sitting in your comment. §3 of my last post ran:

    byte 13 = 1 on the toggles → the strap reports the ECG flags ON while emitting nothing → strengthens (b)

    That chain needs read-back at step one. Under the constant reading the premise carries no information at all — not weaker information, none — so there is nothing for a generalisation to generalise, and (b) is not strengthened. I flagged 123 → 124/125/139 as the assumption in that section and was pointing at the wrong step: the load-bearing one is earlier, and it is the one I had just told myself was proven. (b) goes back to standing on @digitalerdude's #103 capture and nothing else.

    Thank you for pinning the FAILURE frame in #906 with byte 13 deliberately left undecoded. That is the right call and it is the one I would now have to argue for rather than against.

    The ask, rewritten — it buys three things, not one

    You are right that I under-sold it. Restating it in full, since the earlier framing is what is in the thread and it asks for less than the run actually gives.

    If you own a WHOOP 5 MG: send SELECT_WRIST(123) with the argument for the wrist your strap is not currently set to. One command, no subscription, no capture tooling, ten seconds. Then paste the raw response frame, not just the verdict — the frame is what answers the third question, and a SUCCESS/FAILURE report alone does not.

    One run separates three things that are currently tangled:

    1. Refuses-mutations vs something-about-my-strap. My MG accepted arg=1 (the value already stored) and refused arg=0 minutes later. Whether that is a firmware that refuses real mutations, or an invalid argument, or a quirk of this unit, is unresolved on two data points.
    2. Constant vs read-back — the question above. A strap whose stored wrist is the other one has a stored value of 0. Byte 13 either follows it or it does not. That is the first observation on record where the two readings predict different bytes, and it settles the slot outright in whichever direction it lands.
    3. Whether the asymmetry is stable at all, rather than a transient in one session.

    GET_DEVICE_CONFIG_VALUE(121) will not substitute, and I want that recorded rather than rediscovered: its reply carries the value after the 32-byte echoed name field, not at byte 13. It reads a different slot and says nothing about this one.

    What is actually left standing on ECG

    With the byte-13 premise removed, the honest inventory on this strap (WS50_r03, build 215, n=1 device) is:

    • CONFIRMED. All three TOGGLE_LABRADOR commands are acknowledged SUCCESS(1), and zero ECG-shaped packets arrive in a 30-second realtime listen with fingers on both clasp electrodes for the full window (run 2).
    • CONFIRMED. A device-config key named enable_raw_data_w_ecg exists on this strap and reads '0' (read-only 115/116 enumeration, revision=1 count=7, clean terminator).
    • INFERRED, and now unsupported by byte 13: anything about what state those three flags are actually in. The acks are acks.

    That is less than I claimed last night and it is the correct amount. It does not pick between (b), (c), (d), (e) or (f).

    Why #907 survives this

    Worth saying explicitly, because it is the reason the experiment is still worth running in the shape it is already in: #907 does not depend on byte 13 meaning anything. It writes enable_raw_data_w_ecg and then re-reads the key with GET_DEVICE_CONFIG_VALUE(121) — a different slot, past the echoed name field — and reports only the value that comes back. The write's own COMMAND_RESPONSE is recorded and explicitly not believed; a SUCCESS ack whose read-back did not move is reported as unchanged, not as success.

    That design came out of your earlier point that an ack is not evidence of a state change, and it is what makes it immune to this critique rather than a casualty of it. A confirmed '1' with still no packets removes the leading hypothesis and is exactly as publishable as a positive.

    §5

    Noted on FeatureFlagProbe's "strap names no oxygen flag" line in #103 being the same shape — you are right, and the enumeration caveat makes it sharper than I wrote it: a key absent from the list is a key with no stored value, not a key the firmware does not know. I will fix that one in the same sweep and file the rest against the general rule rather than one-off.

  8. ryanbr commented on Jul 28, 2026

    @ryanbr
    Owner

    Agreed on all of it, and the §3 retraction is the right call — under the constant reading that premise carries no information rather than weaker information, so there is nothing left to generalise. (b) is back to standing on the #103 capture alone.

    One datum to add, because it narrows the question rather than settling it, and neither of us has put it in the thread.

    Byte 13 is not fixed across commands — the slot demonstrably carries data

    Across every real 5/MG COMMAND_RESPONSE in the tree:

    fixture command result byte 13
    battery, 47% GET_BATTERY_LEVEL SUCCESS 47
    data-range GET_DATA_RANGE SUCCESS 1
    wrist, accepted SELECT_WRIST SUCCESS 1
    wrist, refused SELECT_WRIST FAILURE 1
    ECG toggle TOGGLE_LABRADOR_FILTERED SUCCESS 1

    GET_BATTERY_LEVEL puts 47 there — the decoder has read battery_pct from that offset since the port, hardware-confirmed against the app. So the slot is not a fixed field with a fixed value; it is the first byte of the per-command response body, and its meaning is whatever that command's handler writes.

    That does not settle constant-vs-read-back, and I do not want to overstate it the way I would be inviting you to. What it does is narrow the question from "is byte 13 a constant" to "what does the wrist/toggle handler put in a slot that other handlers use for real values". A handler writing a literal 1 into that position is possible; it is just less natural than one writing the state it was asked about. That is a shift in prior, not evidence, and the eight frames still make identical predictions under both readings.

    It also means the opposite-wrist run settles it cleanly in either direction rather than only confirming one: a stored 0 coming back as 0 proves read-back, and coming back as 1 proves the handler writes a literal — both are results, neither is a null.

    On the ask

    The rewritten version is right, and the instruction to paste the raw frame rather than the verdict is the part that matters most — a SUCCESS/FAILURE report genuinely cannot answer question 2, and that is not obvious from outside.

    Recording the GET_DEVICE_CONFIG_VALUE(121) non-substitution was worth doing. Its value sits after the 32-byte echoed name field, so it reads a different slot entirely; anyone reaching for it as a shortcut would get an answer to a different question and not notice.

    #907

    Your reading is right and it is worth being precise about why, since it is the difference between the experiment surviving this and not: #907 never reads byte 13. It writes the key, re-reads it through 121 past the name field, and reports only what comes back — with a SUCCESS ack whose value did not move reported as unchanged. Nothing in that path touches the slot under discussion, so removing the byte-13 premise costs it nothing.

    The review comments on it stand as posted: the six strings.xml conflicts, no CI runs on the branch, and the flash-retention asymmetry in the copy — the flag is reversible in one tap and history trimmed while it was on is not.

    The inventory

    Adopting your three-line version as the state of this issue. It is less than was claimed two comments ago and it is the correct amount, which is the only way this thread stays worth reading.

  9. 43 remaining items

  10. vishk23 commented on Oct 8, 2026

    @vishk23
    Author

    @meta1971 Answering your question about the old r03/empty-R16 report: my #1100 run did include 125=1, but it sent 124=1, labelled “start” by my tooling at the time. The issue body records both literal arguments. We now know 124=1 is Stop and 124=2 is Start. That capture therefore does not establish that r03 cannot populate R16 with the corrected sequence. The “vestigial layout” interpretation in my original report was too strong; I withdraw it as an explanation supported by that run.

    I’ve merged current upstream locally and checked that both Swift and Kotlin use right=1/left=2 and stop=1/start=2. The signed iPhone build is ready, but installation is still blocked by the phone being locked, so I have no new r03 hardware result to add yet.

    Your 38–39 s terminal-frame timing also explains why a 30 s probe can capture live R17 without a completed reading. Those should be reported separately: first valid packet, presence/quality, and terminal state/progress with elapsed time. It would be useful to compare those on r03 with the corrected sequence before assigning a hardware-revision difference.

    For the fixture offer, a short matched R16/R17 stretch would be useful for decoder tests. Please include frame length/layout, declared sample counts, expected signed samples, raw status fields, and relative timing; preserve CRC-valid framing while removing identifying timestamps/identifiers. Relative alignment is particularly useful for the reported offset. I’m speaking as a contributor here; @ryanbr should decide the analysis-layer scope. I don’t have evidence to map the classifier codes or the 0x1D fields.

  11. meta1971 commented on Oct 8, 2026

    @meta1971

    mg_r16_r17_fixture.zip

    @vishk23 Thanks for the clarification on the r03 run. On our side (WS50_r00, fw 50.40.1.0, no membership) the client sends 124 arg 2 for start, and R16 and R17 both populate.

    Fixture attached as mg_r16_r17_fixture.zip: 25 stored R16 records and 24 live R17 frames from one recording, 24 s of quality-3 contact with the terminal frame inside it. It has the CRC-valid raw frames (.bin), a JSON with layout, declared counts, expected signed samples, raw status fields, tag bits, the lead-off trailer and relative R17 arrival times, and verify_fixture.py (numpy only), which re-checks both CRCs, the decode and the alignment.

    Pairing and alignment (CONFIRMED, this recording)

    • R17 counter == R16 record index, same strap second.
    • R17 lags R16 by ~264 ms (+/- 2 ms): 1-25 Hz band-passed, r = 0.982 over 84 quality-3 frames (0.976 on the 24 s stretch in the verify script). R17 sample j of frame c matches R16 sample 5j-132 of record c.

    Timing, separated as you suggested (ms vs the 124=2 TX)

    • First R17 frame: -1247. It is empty (count 0, quality 0, flags 0, state 0). 139=1 alone opens the stream, before any start is sent.
    • First frame with samples: +1486. Count 49, quality 1, flags 0x0a, state 1.
    • First progress > 0: +8496, quality still 1.
    • First quality 3: +10488, progress 10.
    • Terminal frame: +37494. State 2, progress 100, flags 0x0c, result 2, reasons 0x02, avg HR 0, live HR 62. That is 37.0 s after the 124 ack and 38.8 s after the 139=1 ack.
    • Stored R16 flips at the same strap seconds as R17, for both the first sample and the terminal frame.

    Counts

    • R16 opens with declared counts 0, 0, 245. R17 opens with 0, 0, 0, then 49 (245/5), then 100. The attached stretch is all full-count, so these opening frames are not in it. I can add them if useful.

    Sanitising and limits

    • Counters and strap seconds are rebased by one constant on both streams, and CRC32 is recomputed. Bytes 10, 19-20 and 31 are undecoded and left as recorded. No wall-clock time, serial or MAC.
    • One recording, one strap. Result 2 with reasons 0x02 at quality 3 is reported raw and I draw no meaning from the codes.

    This is data only. Whether any of it belongs in the analysis layer is @ryanbr's call.

  12. DX23876 commented on Oct 8, 2026

    @DX23876

    WHOOP MG ECG on fw 50.41.1.0: full readings, an Apple Watch comparison, the amplitude scale, a filter puzzle and interval measurements checked against the QT Database

    Hi all! I spent a long evening on this with my own MG and an Apple Watch on the same wrist, and it turned into more than I expected. Everything below builds on what this thread worked out: @Zebsi235's corrected 124 mapping, @meta1971's R16/R17 layouts, timings and the fixture from yesterday, @ayiskakov's and @vishk23's runs, and the OpenStrap folks, whose ECG flow I ported. Thank you, this would have been impossible without you.

    Labels as @meta1971 uses them: OBSERVED (in my data, re-derivable), INFERRED (my reading), OPEN. Research only, not a medical device, nothing here is a diagnosis.

    TL;DR

    • Full readings work on WS50_r00 / fw 50.41.1.0 with the documented sequence; every command is acknowledged. Verdict 38.0 s after the first live frame, matching @meta1971's 38 to 39 s on 50.40.1.0.
    • Amplitude scale: against a calibrated Apple Watch ECG, live R17 reads 7 to 8 % low as microvolts, and stored R16 times @meta1971's 0.0621 is 6 % low (2 % high once the high-pass below is undone). I think that settles "µV per count" to within about 10 %.
    • Shape: relative to the Apple Watch, the strap's beat looks like it went through a first-order high-pass at about 1.5 Hz (deeper dip after the QRS, T wave roughly halved). This seems to clash with @meta1971's "no 0.5 Hz high-pass in R16". Details and my best guess below; this one is OPEN.
    • Intervals: after undoing that high-pass, strap vs Apple Watch on three back-to-back pairs: PR within 4 to 12 ms, QRS 2 to 10 ms, QT 0 to 20 ms. The measurement code itself was checked against the cardiologist annotations of the PhysioNet QT Database.
    • HRV field: on 50.41.1.0 it usually fills 1 to 2 s after the verdict, but once it was already in the terminal frame (68), and it does not always match RMSSD from the trace. OPEN.

    Setup and how I tested

    • Strap: WHOOP MG, DIS hardware revision WS50_r00, firmware 50.41.1.0, no membership. Phone: iPhone with a local build of the NOOP fork (DX23876/noop) carrying a port of OpenStrap edge v0.10.0's ECG flow (MIT, credited in the fork's ATTRIBUTION.md).
    • Sequence per reading: 123 select wrist (left), 139=1, 125=1, 20 abort historical, 124=2 start. Stop: 124=1, 125=0, 139=0. Each command is sent alone and waits for its reply, matched on opcode and the echoed request sequence (byte 11 of the response).
    • Reference: Apple Watch ECG app, watch on the same (left) wrist about 5 cm away from the strap, right-hand finger on the crown, read back from HealthKit (512 Hz, µV, "similar to lead I"). Strap first, watch within one to two minutes, seated, arm on the table, only one device touched at a time.
    • Analysis: one Swift implementation used for both devices. The watch strip is brought to 100 Hz first (5-tap mean, then linear interpolation), so both go through identical code: baseline removal (two median filters, 200 and 600 ms), slope-energy QRS detection in the spirit of Pan-Tompkins with an amplitude stage, a median beat from beats correlating at least 0.8 with it, then the fiducial points on that median beat.

    1. Command flow and timing (OBSERVED, fw 50.41.1.0)

    All five start commands and all three stop commands came back accepted, every time, within the same second. One reading's strap log (times from the first R17 frame):

    Event Time
    first R17 frame 0.6 s after the start list was acknowledged
    presence flag +1.0 s
    progress > 0 +9.1 s
    terminal frame (state 2, progress 100, flags 0x0c) +38.0 s (39 frames)

    Terminal frame: result 1, avg HR 70, live HR 70, quality 3, reason mask 0, HRV field 68. In the other readings the HRV field was empty on the terminal frame and filled 1 to 2 s later (25, 30, 19, 30).

    Over the day: 11 completed readings, all result 1, average HR 60 to 89, verdict 38 to 40 s after the start tap, 3000 samples each, no lost packets, no contact interruptions (plus one I cancelled myself).

    2. Amplitude scale (OBSERVED, three pairs plus one R16 run)

    R-wave height on the median beat, strap vs Apple Watch:

    Pair Strap R17 Apple Watch Difference
    A 719 µV 774 µV -7 %
    B 679 µV 736 µV -8 %
    C 695 µV 758 µV -8 %

    My own stored R16 for pair C decodes cleanly with @meta1971's layout (all CRCs valid, 500 samples per record, strap seconds at @15), and with 0.0621 per count its R wave is 710 µV against the watch's 758 (-6 %). With the high-pass from section 3 undone, R16 gives 774 µV (+2 %) and R17 813 to 850 µV (+7 to +10 %). So both "R17 is µV" (as OpenStrap labels it) and @meta1971's R16 gain look right to within about 10 %, now on a second strap and a newer firmware.

    3. The shape difference: a high-pass at about 1.5 Hz? (OBSERVED, cause OPEN)

    Overlaying the two median beats, the R wave and the P wave line up nicely, but on the strap the trace drops well below baseline right after the QRS and recovers slowly, and the T wave is about half as tall.

    Test: pass the Apple Watch strip through a first-order high-pass at different cutoffs and compare its median beat with the strap's.

    Cutoff RMS difference to the strap beat (pair A / B / C)
    none 83 / 75 / 116 µV
    0.5 Hz 55 / 48 / 52 µV
    1.0 Hz 38 / 34 / 35 µV
    1.5 Hz 33 / 30 / 31 µV
    2.0 Hz 34 / 32 / 32 µV
    3.0 Hz 40 / 38 / 39 µV

    Each pair on its own lands at 1.5 to 1.75 Hz. The predicted dip after the QRS at 1.5 Hz is -127 µV, the strap shows -121 µV.

    The puzzle. @meta1971 measured no 0.5 Hz high-pass in R16. On @meta1971's fixture I compared R17 with R16 (R16 brought to 100 Hz with an anti-alias filter) and the magnitude ratio R17/R16 is 0.99 to 1.02 from 0.6 to 35 Hz and 0.88 at 0.3 to 0.6 Hz, so R17 and R16 look alike in that band. (Full disclosure: my first pass said "1.5 Hz between R16 and R17" too, but that came from being one sample off in the alignment. With the fixture's own 5j-132 mapping it disappears.)

    So either:

    1. the high-pass sits upstream of both streams (front end or electrode coupling), and the 38 to 70 µV rms that @meta1971 sees at 0.02 to 0.1 Hz is drift that a first-order 1.5 Hz corner would attenuate by about 30x but not remove, or
    2. part of the difference is on the Apple side (its own processing) or comes from dry electrode behaviour, and the strap is less filtered than it looks.

    I lean towards 1, because undoing a 1.5 Hz first-order high-pass on my own R16 brings its QRS width and R height onto the watch's as well, but this is INFERRED and I would love a second opinion. Anyone with a calibrated 12-lead or a Kardia next to their MG could settle it quickly.

    4. Interval measurements, strap vs Apple Watch (OBSERVED, three pairs at rest)

    After undoing a 1.5 Hz first-order high-pass (the cutoff was taken from pair B only and then checked on A and C):

    Pair PR strap / watch QRS strap / watch QT strap / watch QTc (Fridericia) strap / watch
    A 196 / 192 ms 62 / 72 ms 412 / 412 ms 433 / 432 ms
    B 196 / 208 ms 70 / 72 ms 400 / 420 ms 416 / 435 ms
    C 196 / 200 ms 62 / 70 ms 410 / 420 ms 414 / 439 ms

    Without the correction the strap's QRS came out 78 to 110 ms (watch 70 to 72) and, with a slope-based P onset, PR was 40 to 70 ms short. Heart rates were 62 to 70 in all pairs, so this does not yet show that the intervals track a changing heart rate. That needs pairs after exercise (see the to-do list).

    5. Checking the measurement code against cardiologists (OBSERVED)

    Before trusting any of the above I ran the same Swift code on the PhysioNet QT Database (103 records with q1c annotations, a 30 s window around the annotated beats, both leads, resampled to 100 Hz like the strap). Reference = median of the annotated beats in the window. Records split into two halves to keep myself honest:

    Interval Method Covered Bias Median abs error
    QT tangent T end (my first version) 86 % -27 ms 35 ms
    QT trapezium-area T end (Vázquez-Seisdedos et al. 2011) 86 % -8 / +14 ms 18 / 16 ms
    QRS slope thresholds 89 % -10 ms 16 / 12 ms
    PR slope-based P onset 65 % -12 / -5 ms 12 / 14 ms
    PR wavelet P onset (Martínez et al. 2004) 86 % +5 / +14 ms 8 / 12 ms

    Things I learned on the way:

    • The tangent method ends the T wave early, which made my QT about 30 ms short. That was a real part of the strap/watch gap at first, so it was worth checking the code before blaming the strap.
    • The Martínez wavelet thresholds are relative to the search window, so on a beat with no P wave at all (AF-like synthetic test) it still finds one in the noise. I added a gate: no PR unless the P wave reaches 4 % of the R wave on the median beat.
    • NeuroKit2's default dwt delineation was 40 to 80 ms off on the same windows. I may not have used it optimally, so take that with a grain of salt.
    • Running my code at the native 250 Hz was worse than at 100 Hz (QRS 25 ms short), because the flat-slope rules are implicitly tuned to the sample spacing. Worth knowing for anyone hoping R16's 500 Hz gives sharper intervals for free: the rules need to be made rate-independent first. @meta1971's own note that wider bandwidth did not sharpen beat timing points the same way.

    6. Thanks to @meta1971's fixture: a detection bug found

    Running my analysis on the R17 stretch of mg_r16_r17_fixture.zip, muscle noise on the clasp produced slope-energy peaks between beats, and my detector counted them: 72 bpm instead of the strap's 62. A second, amplitude stage (drop candidates under half the median R height, as Pan-Tompkins' signal and noise levels do) brings it to 62 with no irregular beats. The QT Database numbers did not move. Real-world noisy data is worth a lot, thank you!

    7. The hardware and WHOOP's own pipeline (public sources, partly INFERRED)

    • Front end: Analog Devices (Maxim) MAX86176, a combined PPG and ECG AFE (the strap console says "MAX86176: Set ECG ON", as reported above). Public specs for the ECG channel: 15.6 ENOB with 0.6 µVrms noise over 0.05 to 40 Hz, EMI filtering, internal lead biasing, AC and DC lead-off detection, right-leg drive, ultra-low-power DC lead-on detection in standby, a 256-word FIFO shared with PPG, and 20-bit charge-integrating ADCs on the PPG side. The ECG register tables (sample rates, filter options) are not in the public summary, so I could not check the filter question against the chip.
    • INFERRED: R16's samples (18-bit two's complement with a lead-on bit and a fast-recovery bit in the top byte) look very much like the ECG FIFO words of Maxim's biopotential AFEs, and the I/Q trailer @meta1971 decoded fits the chip's impedance measurement.
    • Classification: WHOOP's 510(k) (K243236) says the strap firmware contains B-Secur's HeartKey library (K200884), which does the filtering, QRS detection, rate (40 to 200 bpm), signal quality and rhythm call. The predicate is Apple's ECG app 2.0 (K201525). The output categories are low HR, sinus, high HR without AF, AF, AF with high HR, high HR above 150, inconclusive, unsuccessful.
    • WHOOP's clinical study (about 540 subjects) compared PR, QRS, R amplitude and RR with a 12-lead. QT was not part of it, so nobody, WHOOP included, has claimed QT accuracy for this strap until now. My numbers above are one person, three pairs.

    8. Side note for NOOP's sync (@ryanbr)

    NOOP archives undecodable history records in rejected_history.jsonl (5 MB cap, oldest out). On an MG with ECG raw-save on, that file fills up mostly with R20 (2140 B) and R21 (1244 B) records, and the R16 ECG records of a reading get evicted within a couple of hours. Since R16, R20 and R21 layouts are now known thanks to @meta1971, they may not need to go to the reject archive at all. Just flagging it, no PR from me on that.

    What is still open, and where others could jump in

    1. Pairs after exercise. Anyone with an MG and an Apple Watch (or Kardia): strap reading, then watch within a minute, same wrist, after 20 squats or a flight of stairs. That shows whether the intervals track heart rate and whether the 1.5 Hz holds. I will add mine.
    2. Where does the high-pass live? R16 plus a calibrated reference at the same time would answer it.
    3. HRV field: filled in the terminal frame on 50.41.1.0 vs 0xffff there on 50.40.1.0? And is it RMSSD? My three points: 25 vs 22.7, 22 vs 22, 68 vs 50 (strap vs RMSSD from the 30 s R17 window).
    4. Result codes beyond 1, 2 and 5, and the reason masks. I only ever got 1 so far.
    5. r03: @vishk23, if your retest with 124=2 works, a pair against any reference would be great.
    6. 125 off: does R16 stop banking while R17 keeps streaming?
    7. Rate-independent delineation for R16 at 500 Hz, if anyone fancies it.

    Code

    The port, the analysis and a reproducible QT Database check (Tools/EcgQTDB/run.sh, fetches the database from PhysioNet's open S3 mirror and scores the Swift code on two halves) are on a local branch of my fork. Happy to push it if anyone wants to look or rerun anything.

    Thanks again everyone, this thread is a great example of people chipping away at something until it works. 😊

  13. ryanbr commented on Oct 9, 2026

    @ryanbr
    Owner

    Our probe cannot see the verdict, and your timing is what proves it

    @meta1971 Your verdict timing finds two defects in NOOP's own probe, both platforms. Thanks also @DX23876, @ayiskakov and @Zebsi235.

    1. The window ends before the strap answers. 30 s on both platforms (BLEManager.swift:4303, WhoopBleClient.kt:5150). You measure the terminal frame (state 2, progress 100) at 38 to 39 s in all five sessions; @DX23876's run on 50.41.1.0 reached progress 10 with no terminal packet. Whoop5Ecg.isTerminal already exists and nothing uses it to extend the run. The verdict sheet also lands at 30 s, so Stop gets pressed 8 to 9 s early.

    2. The 12-frame cap keeps the wrong frames. ecgProbeMaxCandidates = 12, R17 at about one frame a second, so the retained evidence is seconds 1 to 12. Result, average HR and the reason mask only populate on the terminal frame. The packet count stays honest; the retained evidence does not.

    Changing: window to 60 s, retain any isTerminal frame regardless of the cap, and keep collecting about 10 s past it, since you measured the HRV field at 0xffff for 2 to 9 s after.

    Confirmed against our decode: all 11 R17 header offsets, the command sequence, the pad-to-4 requirement, and three of the seven result names. The GET_DATA_RANGE offsets were corrected in #818; our own formula reproduces your 1,426.

    Fixtures, if you can: inline hex as in Whoop5EcgTests.swift, a few R16 records plus the R17 frames covering the same seconds, expected values as integer wire counts (the scale is not established, and the Swift and Kotlin pins must be byte-identical), the declared count and header fields per frame, and one terminal frame if you have one. No timestamps, nothing identifying the strap, and please keep the fd4b0007 CBOR out.

    Byte 82: we decode it as a gated 70 to 100 SpO2 candidate, stored, surfaced behind a toggle, never scored, because the evidence is split (Interpreter.swift). Yours is a third device and reproduces the part we assert: in band, nonzero only in SLEEP. The burst cadence is new. Two things would settle it: whether that strap has an active membership (#103 expects 0x00 without one), and the nightly gated mean of @82 against the app's own Blood Oxygen for the same nights.

    An analysis layer is in scope with limits: no rhythm classification of ours, no alerts, no clinical wording. EcgResearchStats.estimateBpm already does BPM on Android, research only; the Swift twin is mine to write.

  14. meta1971 commented on Oct 9, 2026

    @meta1971

    mg_fixture_trimmed.zip

    @ryanbr Fixture, your byte-82 questions, and the other findings from my last runs. One strap: WHOOP MG WS50_r00, firmware 50.40.1.0, custom Android client. I have never had a WHOOP membership or used the official app, but this MG is second-hand, so I can't rule out that a previous owner had a membership or paired it. Labels as in this thread: OBSERVED / INFERRED / OPEN. No rhythm classification or clinical reading anywhere below.

    1. Fixture (attached: mg_fixture_trimmed.zip)
    4 stored R16 records and 3 live R17 frames around the terminal frame: inline hex, integer wire counts (R16 signed 18-bit, R17 int16), declared count and header fields per frame. Counters and strap seconds rebased by one constant on both streams (CRC32 recomputed); no wall-clock time, serial, MAC or fd4b0007 data. Terminal frame at rebased second 1013: state 2, progress 100, flags 0x0c, result 2, reasons 0x02, average HR 0, live HR 62, HRV 0xffff. R16 record 1011 is the lead-in for R17 frame 1012 (R17 lags R16 by about 264 ms). Bytes 19-20 are the sub-second part of the strap time (value ÷ 32768 s); bit 7 of byte 10 clears while raw collection runs; byte 31 undecoded.
    2. Byte 82 (OBSERVED unless marked; 18 history offloads, 3-9 Oct, 194,833 unique R18 records)

    • Non-zero only inside 52 bursts of exactly 30 records (1,560 records). All have band state value 2 (byte 81 bits 4-5; SLEEP in our decode) and bytes 78-80 = 11 114 7. In the other 193,273 records bytes 78-80 = 12 2 12 and byte 82 = 0.
    • Cadence: the first burst starts about 19.6 min after the first SLEEP record of a continuous sleep segment, then every 20 min. All 52 fit within 0.45 min of a multiple of 20; the 11 segments of 20 min or longer predict 52 bursts and 52 were found. The grid restarts after an interruption and is not locked to the timer in item 6.
    • Content: 1,367 of the 1,560 records are in 70-100 (range 86-100, median 96, 10th-90th percentile 95-97), steady within a burst. The other 193 hold small codes using only bits 0x02, 0x08, 0x10, 0x20, 0x80 (0x01 once). INFERRED: a value when the measurement is valid, a status mask when it is not.
    • Status bits: 0x08 is set in 112 code records, and in all 112 bytes 23 and 25 are both 0 (valid records: mostly 1 and 3-4). Both are 0 in about 10% of burst records and 11% of other SLEEP records, so INFERRED: no usable optical data that second. Bytes 33, 40, 109 and 115 also separate valid from code records on the three full nights. OPEN: not decoded.
    • So on this strap the byte is populated. With a second-hand strap I can't say whether membership matters. @vishk23's strap read 0x00 throughout on r03 (WHOOP 5.0 raw HCI capture — where to submit? (docs reference #174, which doesn't exist) #103); hardware revision, firmware, membership or something else could explain it. I can't say the values are SpO2, only that they are in band, sleep-only, scheduled and have a status mask. I have no official Blood Oxygen figure to compare against.

    3. When the HRV field first becomes valid (16 recordings, 50.40.1.0)
    The verdict frame arrived 36 or 37 s after first contact in all 16, and HRV (bytes 29-30) was 0xffff on it in 15. First valid value relative to the verdict: -3 to +17 s in 9 runs (a 10 s window after the verdict would have caught 4, a 20 s window 9), +113 and +120 s in 2, none by the end of the recording in 5 (ended 20 to 144 s after the verdict). The cleaner the first minute, the earlier it filled. Not exact, and I'm not suggesting a number: the delay isn't fixed on this firmware.

    4. Which switch controls which stream (one run each; all replies SUCCESS)

    Sequence live R17 stored R16 R20 (2140 B) R21 (1244 B)
    139=1, 125=1, 124=2 yes 282 280 280
    139=0, 125=1, 124=2 none (0 frames) 190 187 187
    139=1, 125=0, 124=2 yes (184 frames) 0 (two pulls) 183 183

    139 gates the live stream, 125 gates R16, and R20/R21 follow generation (124=2). @DX23876: R20/R21 also fill the reject archive with raw-save off.

    5. Reply bytes (53 replies to 123/124/125/139 across 9 logs)

    • Byte 13 is 1 in all 53, including 4 replies where the request was 0 to a flag that was already off (assuming the preceding stop sequence, whose replies were SUCCESS, left it off). So on this strap it is neither the requested value nor the flag state.
    • Reply byte 11 equals the sequence number we sent, 53 of 53; reply byte 9 never does and counts up on its own (214, 215, 216, 218, 219, 220, 221 in one run). Our TX seq is byte 9 of the sent frame:
      TX AA 01 0C 00 00 01 E7 41 23 03 8B 01 00 00 00 00 EA 6D 24 6A
      RX AA 01 0C 00 01 00 27 11 24 D7 8B 03 01 01 00 00 97 1D D3 FC
      That is the reverse of the earlier reading (byte 9 = host seq, byte 11 = strap resp_seq); @DX23876 also matches on byte 11. One client, one firmware: please check against your own TX bytes before renaming anything.

    6. The 0x1D message (OBSERVED; reading INFERRED)
    8 messages in 5 logs over 48.9 h fit one period of 600.62 s (maximum residual 0.76 s across 293 periods); 4 of the 8 arrived with no ECG session running. INFERRED: a free-running strap timer, not a completion signal. Fields undecoded.

    7. HRV field vs RMSSD, and R17 vs R16 (one 10-minute run)

    • The strap's HRV field matched my RMSSD from the stored R16 within 0.4 to 3.6 ms in 8 usable minutes (6 vs 7.1 and 10 vs 12.3 in two slow-breathing minutes). Not always: 28 vs 21-23 on another run, and @DX23876 saw 68 vs 50.
    • R17 relative to R16 (R16 x 0.0621 as input, delay removed, 529 s of clean data): gain 0.31 at 0.29 Hz, 0.71 at 0.49, about 1.0 from 1 to 30 Hz (median 1.013), 0.90 at 40 Hz. INFERRED: a second-order high-pass near 0.5 Hz is in the R17 path and R16 has none; 0.0621 per count agrees with R17 in microvolts to about 1%.

    8. Result codes (16 recordings)
    Code 2 in 11 (average HR 0; reasons 0x02, once 0x06), code 6 in 3, code 1 in 2 (both with reasons 0x00 and an average HR). 7 back-to-back runs, same posture: all 7 gave code 2. Strap average HR equals mine for code 1 (83 vs 83, 61 vs 61) and is 4-6 bpm higher for code 6 (67 vs 61, 63 vs 58, 69 vs 65). Noise, quality just before the verdict, the timer phase and heart rate did not separate the codes. In a 10-minute run the last live frame was at 599 s with no end marker.

  15. vishk23 commented on Oct 13, 2026

    @vishk23
    Author

    @meta1971 Thanks for both fixtures. I checked mg_fixture_trimmed.zip independently today with a small Python parser (not your verification script): all four R16 records and three R17 frames pass header CRC16-Modbus and inner-record CRC32; frame lengths, declared counts, the listed status fields, and every expected signed sample match. That checks the wire/decode expectations, not the voltage scale or the reported cross-stream correlation.

    The terminal frame at rebased second 1013 also matches the raw fields you listed: state 2, progress 100, flags 0x0c, result 2, reasons 0x02, live HR 62 and HRV 0xffff. Keeping it as integer wire counts makes this directly useful for Swift/Kotlin parity tests.

    @ryanbr Current main (3a0b74b2d) now has the 60 s capture window, terminal-frame retention and 10 s terminal grace. Meta1971’s newer 16-run timing means that grace should be described as an evidence window, not a guarantee that HRV will become valid: the reported delays include +17 s, +113/+120 s and runs with no value before stopping. An unset field at the end remains unavailable, not zero or a failed completed reading.

    The new 139/125 isolation runs are also useful: on that tested unit, live R17 works with raw-save off, while R16 banks with live off. I would keep that provenance with the fixture rather than generalize it to every firmware. The TX/RX pair is a good literal regression fixture too: request seq 0x03 appears at response byte 11, not byte 9; byte 13=1 should stay uninterpreted given the off-command observations.

    @DX23876 Please do push the source branch and reproducible QTDB harness you offered, with the tested commit and exact command. That would let contributors inspect the alignment/resampling and rate-dependent thresholds without needing your personal recordings. I have no new r03 hardware result yet, so I can’t supply the requested reference pair. Your fork’s readings and meta1971’s fixture are attributed evidence, not a retest on my device.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions