Repository navigation
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
Description
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_RESPONSEin the repo's ownPacketTypeenum. - 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.
- Against: @digitalerdude's full history-backfill capture in WHOOP 5.0 raw HCI capture — where to submit? (docs reference #174, which doesn't exist) #103 found every historical record was a 124 B v18 or 88 B v26 frame, with
frames >200B = 0across ~30k contiguous records. - But that doesn't cover this case: that was a WHOOP 5.0, not an MG, ECG is MG-only, and Labrador was presumably never enabled on it.
- The R-series record numbering is real and observable in the flag names themselves —
enable_r19_packetson a 4.0 (the WHOOP 5/MG deep-data roadmap: capability labels, validated decode and guarded probes #761 dump),enable_r22_*/enable_write_r24_packets/enable_write_r25_packets/disable_pip_r26_packetson 5/MG. Nothing below R19 has been observed by anyone in this repo.
→ 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, 124TOGGLE_LABRADOR_DATA_GENERATION, 125TOGGLE_LABRADOR_RAW_SAVE, 139TOGGLE_LABRADOR_FILTERED.ECG_SEND_RAWis unaccounted for. - 139 is not contiguous with 123–125, despite the doc's "five consecutive codes".
- The 5/MG demonstrably remaps opcodes.
docs/PROTOCOL.mdrecords that on MAVERICK the clock family answers in the high space —SET_CLOCKat 146,GET_CLOCKat 147,GET_HELLOat 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/MGsend()allowlist rejects them today. - None of 124/125/139 is on the destructive list in
docs/PROTOCOL.md(25FORCE_TRIM, 32POWER_CYCLE_STRAP, 36/37/38 firmware, 45ENTER_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:
- An MG owner reruns the three toggles while holding the opposite-hand contact. Kills or confirms (a) for free.
- An MG owner with an active subscription reruns them unchanged. Kills or confirms (f).
- Anyone who has seen an unrecognised record type or packet type in a 5 MG offload — say so, even vaguely. That's (b).
- 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
ryanbr commentedon Jul 27, 2026
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 overframe[0..6)=0x1127✓; CRC32 tail over the inner record ✓. Run throughdecodeCommandResponseWhoop5they decode exactly as reported — type@8 = 36COMMAND_RESPONSE,resp_cmd@10 = 139 / 125 / 124,result@12 = 1 = SUCCESS. The names at 124/125/139 are inwhoop_protocol.json'sCommandNumbertable 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 envelopeseq, which is host-assigned —puffinCommandFrame(cmd:seq:)takes it from the caller andBLEManagerholdsprivate var seq: UInt8that 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:seq147 alongsideresp_seq2. 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 whereGET_BATTERY_LEVELreturns its number (the real fixture has0x2f= 47 there, and the decoder readsbattery_pctfrom @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) andGET_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:413says "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
WhoopCommandat 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.
ryanbr commentedon Jul 27, 2026
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_TRIMand 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.mdline behind hypothesis (c).The ECG question itself is untouched — it still needs the retest, a subscribed MG, or a capture.
Name every command the schema names in Android strap logs (#891)
Name every command the schema names in Android strap logs (#891)
Name every command the schema names in Android strap logs (#891)
Name every command the schema names in Android strap logs (#891) (#893)
Publish the command result code on Apple, as Android already does (#894)
Publish the command result code on Apple, as Android already does (#894)
Publish the command result code on Apple, as Android already does (#894…
vishk23 commentedon Jul 27, 2026
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-decodeagainstPackages/WhoopProtocolat currentmain, not by hand. All five: CRC16-Modbus overframe[0..6)=0x1127OK, CRC32 over the inner record OK,packet_type@8 = 36 COMMAND_RESPONSE. Post-#899 the Apple decoder now namesresp_seqandresultitself, so the field reading is the decoder's, not mine:Opcodes as before, from
whoop_protocol.json'sCommandNumbertable 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-sideresp_seq):seq@9resp_cmd@10resp_seq@11result@12SELECT_WRISTTOGGLE_LABRADOR_FILTEREDTOGGLE_LABRADOR_RAW_SAVETOGGLE_LABRADOR_DATA_GENERATIONSELECT_WRISTresp_seqruns 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_LEVELreturns its number in (Interpreter.swiftreadsbattery_pctfrompay[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), notUNSUPPORTED(3)— and the same opcode succeeded minutes earlierThe 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:
Two readings fit, and I cannot separate them with two data points:
0is simply not a valid argument on this firmware.docs/PROTOCOL.mdhas 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:
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 = 0across ~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
heartKeyLeadsAreOnbit 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:
That is wrong. And run 3 — also wrist-only — tripped the other verdict:
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_WRISTstarts nothing on either argument, the OFF sequence asks for the silence it gets, andRAW_SAVEnames 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
The asks from the opening post all stand. Added to them: if anyone with an MG can send
SELECT_WRISTwith the wrist they are not currently set to, I would like to know whether they also getFAILURE— that separates "this strap refuses mutations" from "something about my strap".vishk23 commentedon Jul 27, 2026
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)returnedrevision=1, count=7and walked to a clean terminator. One ofthe seven keys is:
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
SUCCESSwith the electrodes properly held and zero packets arrive" — but it is a leadinghypothesis, 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 (confirmedseparate from
SET_FF_VALUE(120)in both directions — see #103), and #103 also establishes on thisstrap 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:
own gate and its own before/after read, not a bolt-on to the existing probe.
"key exists, nothing stored" — both return
FAILURE— so the list is what is currently set on thisstrap, not the firmware's key space.
ryanbr commentedon Jul 27, 2026
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, includingresp_seq129 → 155 → 156 → 157 → 204 and theFAILURE(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_RESPONSEon record — the three in the opening post plus tonight's five — byte 13 has taken exactly one value: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_WRISTwith 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_ecgreads'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=7is 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.Pin the first real 5/MG FAILURE response, from the #891 wrist pair (#906
vishk23 commentedon Jul 28, 2026
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
1in every frame anyone has captured, so "carries state" and "is1on 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:
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/139as 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:
arg=1(the value already stored) and refusedarg=0minutes 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.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.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:TOGGLE_LABRADORcommands are acknowledgedSUCCESS(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).enable_raw_data_w_ecgexists on this strap and reads'0'(read-only 115/116 enumeration,revision=1 count=7, clean terminator).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_ecgand then re-reads the key withGET_DEVICE_CONFIG_VALUE(121)— a different slot, past the echoed name field — and reports only the value that comes back. The write's ownCOMMAND_RESPONSEis recorded and explicitly not believed; aSUCCESSack whose read-back did not move is reported asunchanged, 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.ryanbr commentedon Jul 28, 2026
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_RESPONSEin the tree:GET_BATTERY_LEVELGET_DATA_RANGESELECT_WRISTSELECT_WRISTTOGGLE_LABRADOR_FILTEREDGET_BATTERY_LEVELputs 47 there — the decoder has readbattery_pctfrom 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
1into 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
0coming back as0proves read-back, and coming back as1proves 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.xmlconflicts, 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.
43 remaining items
vishk23 commentedon Oct 8, 2026
@meta1971 Answering your question about the old r03/empty-R16 report: my #1100 run did include
125=1, but it sent124=1, labelled “start” by my tooling at the time. The issue body records both literal arguments. We now know124=1is Stop and124=2is 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.
meta1971 commentedon Oct 8, 2026
mg_r16_r17_fixture.zip
@vishk23 Thanks for the clarification on the r03 run. On our side (
WS50_r00, fw50.40.1.0, no membership) the client sends124arg2for 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, andverify_fixture.py(numpy only), which re-checks both CRCs, the decode and the alignment.Pairing and alignment (CONFIRMED, this recording)
Timing, separated as you suggested (ms vs the
124=2TX)139=1alone opens the stream, before any start is sent.0x0a, state 1.0x0c, result 2, reasons0x02, avg HR 0, live HR 62. That is 37.0 s after the124ack and 38.8 s after the139=1ack.Counts
Sanitising and limits
0x02at 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.
DX23876 commentedon Oct 8, 2026
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
Setup and how I tested
WS50_r00, firmware50.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).123select wrist (left),139=1,125=1,20abort historical,124=2start. 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).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):
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:
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.
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:
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):
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
q1cannotations, 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:Things I learned on the way:
dwtdelineation 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.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)
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
0xffffthere 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).124=2works, a pair against any reference would be great.125off: does R16 stop banking while R17 keeps streaming?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. 😊
ryanbr commentedon Oct 9, 2026
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.isTerminalalready 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
isTerminalframe regardless of the cap, and keep collecting about 10 s past it, since you measured the HRV field at0xfffffor 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 thefd4b0007CBOR 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 expects0x00without one), and the nightly gated mean of@82against 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.estimateBpmalready does BPM on Android, research only; the Swift twin is mine to write.fix(ecg): keep a capture's frames when the user stops it
refactor(ecg): keep the listen window in one place
fix(ecg): let a probe run reach the frame that carries the result
fix(ecg): keep a capture's frames when the user stops it
refactor(ecg): keep the listen window in one place
build: 12.1.0 test beta (555 / 436)
meta1971 commentedon Oct 9, 2026
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
fd4b0007data. Terminal frame at rebased second 1013: state 2, progress 100, flags0x0c, result 2, reasons0x02, average HR 0, live HR 62, HRV0xffff. 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)
11 114 7. In the other 193,273 records bytes 78-80 =12 2 12and byte 82 = 0.0x02,0x08,0x10,0x20,0x80(0x01once). INFERRED: a value when the measurement is valid, a status mask when it is not.0x08is 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.0x00throughout onr03(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
0xffffon 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)
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)
1in 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.TX
AA 01 0C 00 00 01 E7 41 23 03 8B 01 00 00 00 00 EA 6D 24 6ARX
AA 01 0C 00 01 00 27 11 24 D7 8B 03 01 01 00 00 97 1D D3 FCThat 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)
8. Result codes (16 recordings)
Code 2 in 11 (average HR 0; reasons
0x02, once0x06), code 6 in 3, code 1 in 2 (both with reasons0x00and 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.ecg: log the reading timeline and strap identity; compare with Apple …
ecg: reject noise bursts as beats by height, accept beats correlating…
ecg: seconds left in the strap's 30 s recording
vishk23 commentedon Oct 13, 2026
@meta1971 Thanks for both fixtures. I checked
mg_fixture_trimmed.zipindependently 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.