Skip to content

0x61 debug_data` is subtype-multiplexed — structure, per-subtype stats and a 10,208-record CSV from a Gen 3 #10

Description

@pipiche38

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

  1. 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.
  2. 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

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