Body
Hi — maintainer of the Oura side of NOOP here (fully offline
Gen 3 companion app, clean-room, no shared code with open_oura; your protocol docs are cited in ours
with attribution). We ran a full-tag raw capture and ended up characterising 0x61, which I think is
worth contributing back since it is the highest-rate tag on the wire and easy to mistake for a data
stream.
Your EVENT_TAGS in tools/oura_protocol.py already names it debug_data — that name turned out
to be exactly right, and the measurement below fills in what is inside it.
Capture
One Gen 3, one drain, oura-raw.jsonl written by our own raw logger (logs every tag, decoded or not,
so this is not filtered by our tag enum):
- 84,815 records total, 42 distinct tags, every frame satisfies
len == bytes − 2
- 10,208 ×
0x61 (12 % of the bank — the 3rd most common tag, ahead of 0x60)
- ring-time span 9,391,251 → 10,105,610; wall span 19.4 h (2026-08-04 10:21 → 2026-08-05 05:48 UTC)
Finding 1 — payload byte 0 is a subtype selector, and the length follows the subtype
This is why 0x61 looks like it has a variable, unfittable layout: it is 22 different messages
sharing one tag. Any single example is one message, and averaging them together produces the
"length is 3–14, shape unknown" impression we had in our own notes for months.
Finding 2 — subtype 0x04 is ASCII, which settles what the channel is
04 4d4f5420524920323b32 → "MOT RI 2;2"
04 45485274733b3633 → "EHRts;63"
04 547366733b3131 → "Tsfs;11"
04 626c655f74783a66756c6c → "ble_tx:full"
Same class of firmware log line that 0x43 debug_event carries in our capture (DHR_state:4,
PPG_cont;220, EHRst;1;0;1, CVA_state;2) — so the ring has two diagnostic streams: 0x43
free-text, and 0x61 subtype-keyed with a text subtype among the numeric ones.
Finding 3 — per-subtype layout and cadence
Cadence is the median delta of the record's own rt field between consecutive records of that subtype.
Layouts are what the bytes fit, not confirmed semantics:
| sub |
n |
layout (unconfirmed) |
median rt cadence |
observed range |
0x09 |
2,104 |
3 × u32-LE + 1 tail byte |
11 |
f0 med 3.1 M / max 124 M |
0x28 |
2,070 |
flag + 6 × u16-LE |
1 |
repeated identical values (780,0,0,0,780,780) |
0x33 |
1,399 |
8 B and 14 B variants |
3 |
|
0x0a |
1,066 |
3 × u32-LE |
729 |
f2 always 0 |
0x0d |
1,062 |
3 × u32-LE |
729 |
f1 always 0; f2 max 125,829,080 |
0x0c |
1,060 |
2 × u32-LE + 1 tail byte |
729 |
f0 max 125,829,085 |
0x29 |
507 |
8 B |
658 |
|
0x14 |
435 |
3 × i32-LE (signed) + tail |
4,826 |
f0 ≈ 2.6×10⁸ tight; f1 med −106 |
0x2d |
152 |
13 B |
|
|
0x04 |
63 |
ASCII |
|
log lines, above |
0x24 0x26 0x15 0x3c 0x1a 0x23 0x2a 0x2e 0x25 0x2b 0x32 0x34 |
≤ 49 each |
|
|
|
0x0a/0x0c/0x0d share one ~729-tick period and are mostly zero with a single live field — reads
like one periodic statistics dump with several unused slots. 0x14's ~4,826-tick period, near-constant
~2.6×10⁸ field and small negative deltas look like a clock/drift record.
The one practically useful line: ble_tx:full
The ring reporting its own BLE transmit buffer overflowing. Ten occurrences here (15:34:43,
15:36:25, 15:38:44, 15:57:14, and 05:48:17 — the last second of the drain), each logged twice. For
anyone debugging a truncated bank or a mid-drain gap that is direct ring-side evidence of a drop,
which beats inferring loss from missing rows after the fact.
Two questions back
- Do you see the same subtypes on Gen 4 / Gen 5? Our corpus is a single Gen 3, so the subtype
numbering could well be generation-specific. If debug_data has a stable subtype table across
generations it would be worth documenting as one.
- What emits
spo2_r_pi_event? tools/run_spo2.py (branch docs-models-not-shipped) reads it,
and we document the tag as 0x8b from your calibration notes — but we have 0 occurrences in
84,815 records, and 0x8b is not in your EVENT_TAGS either. On this ring only 0x6F (3,665)
and 0x77 (13,155) appear; 0x7B and 0x70 are also zero. Notably this capture is from a ring
paired with the account's own auth key, so the server-gated bundle is unlocked (Exercise HR
0x73 ×1,658 / 0x74 ×358 and 0x81 ×2,773 all flowing) — and 0x8b still never comes. Is it
Gen 4/5-only, a different transport, or a different tag number than 0x8b? We would happily
confirm on our side if you can say where it should appear.
Attached data
oura-0x61-records.csv — every 0x61 record in the capture, 10,208 rows. Raw-first: payload_hex is
authoritative, the decoded columns are just a convenience view of the hypothesis above.
| column |
meaning |
line |
line number in the source oura-raw.jsonl |
iso_utc |
host wall clock when the frame was received (drain time, not ring time) |
ring_ts |
the record header's u32-LE field |
len_field |
the frame's len byte |
subtype |
payload byte 0 |
payload_len |
bytes after the 6-byte type/len/rt header |
payload_hex |
the raw payload — the authoritative column |
u32_0..u32_2 |
payload bytes 1.. read as u32-LE (meaningless for the ASCII subtype; kept for uniformity) |
tail_hex |
bytes left over after whole u32 groups |
ascii |
decoded text for subtype 0x04, empty otherwise |
The device identifier is redacted at capture time (<device>); the file contains ring diagnostic
counters and log strings only, no personal data.
Happy to run targeted follow-ups on this ring — a longer capture, a specific subtype, or a drain under
conditions you want to see (charging, exercise, takeoff).
oura-0x61-records.csv
Body
Hi — maintainer of the Oura side of NOOP here (fully offline
Gen 3 companion app, clean-room, no shared code with open_oura; your protocol docs are cited in ours
with attribution). We ran a full-tag raw capture and ended up characterising
0x61, which I think isworth contributing back since it is the highest-rate tag on the wire and easy to mistake for a data
stream.
Your
EVENT_TAGSintools/oura_protocol.pyalready names itdebug_data— that name turned outto be exactly right, and the measurement below fills in what is inside it.
Capture
One Gen 3, one drain,
oura-raw.jsonlwritten by our own raw logger (logs every tag, decoded or not,so this is not filtered by our tag enum):
len == bytes − 20x61(12 % of the bank — the 3rd most common tag, ahead of0x60)Finding 1 — payload byte 0 is a subtype selector, and the length follows the subtype
This is why
0x61looks like it has a variable, unfittable layout: it is 22 different messagessharing one tag. Any single example is one message, and averaging them together produces the
"length is 3–14, shape unknown" impression we had in our own notes for months.
Finding 2 — subtype
0x04is ASCII, which settles what the channel isSame class of firmware log line that
0x43 debug_eventcarries in our capture (DHR_state:4,PPG_cont;220,EHRst;1;0;1,CVA_state;2) — so the ring has two diagnostic streams:0x43free-text, and
0x61subtype-keyed with a text subtype among the numeric ones.Finding 3 — per-subtype layout and cadence
Cadence is the median delta of the record's own
rtfield between consecutive records of that subtype.Layouts are what the bytes fit, not confirmed semantics:
0x090x28780,0,0,0,780,780)0x330x0a0x0d0x0c0x290x140x2d0x040x240x260x150x3c0x1a0x230x2a0x2e0x250x2b0x320x340x0a/0x0c/0x0dshare one ~729-tick period and are mostly zero with a single live field — readslike one periodic statistics dump with several unused slots.
0x14's ~4,826-tick period, near-constant~2.6×10⁸ field and small negative deltas look like a clock/drift record.
The one practically useful line:
ble_tx:fullThe ring reporting its own BLE transmit buffer overflowing. Ten occurrences here (15:34:43,
15:36:25, 15:38:44, 15:57:14, and 05:48:17 — the last second of the drain), each logged twice. For
anyone debugging a truncated bank or a mid-drain gap that is direct ring-side evidence of a drop,
which beats inferring loss from missing rows after the fact.
Two questions back
numbering could well be generation-specific. If
debug_datahas a stable subtype table acrossgenerations it would be worth documenting as one.
spo2_r_pi_event?tools/run_spo2.py(branchdocs-models-not-shipped) reads it,and we document the tag as
0x8bfrom your calibration notes — but we have 0 occurrences in84,815 records, and
0x8bis not in yourEVENT_TAGSeither. On this ring only0x6F(3,665)and
0x77(13,155) appear;0x7Band0x70are also zero. Notably this capture is from a ringpaired with the account's own auth key, so the server-gated bundle is unlocked (Exercise HR
0x73×1,658 /0x74×358 and0x81×2,773 all flowing) — and0x8bstill never comes. Is itGen 4/5-only, a different transport, or a different tag number than
0x8b? We would happilyconfirm on our side if you can say where it should appear.
Attached data
oura-0x61-records.csv— every0x61record in the capture, 10,208 rows. Raw-first:payload_hexisauthoritative, the decoded columns are just a convenience view of the hypothesis above.
lineoura-raw.jsonliso_utcring_tslen_fieldlenbytesubtypepayload_lentype/len/rtheaderpayload_hexu32_0..u32_2tail_hexascii0x04, empty otherwiseThe device identifier is redacted at capture time (
<device>); the file contains ring diagnosticcounters and log strings only, no personal data.
Happy to run targeted follow-ups on this ring — a longer capture, a specific subtype, or a drain under
conditions you want to see (charging, exercise, takeoff).
oura-0x61-records.csv