Skip to content

Fix the 0x6B motion_period decoder (wrong frame offset)#839

Open
pipiche38 wants to merge 1 commit into
ryanbr:mainfrom
pipiche38:fix/oura-motion-period-decode
Open

Fix the 0x6B motion_period decoder (wrong frame offset)#839
pipiche38 wants to merge 1 commit into
ryanbr:mainfrom
pipiche38:fix/oura-motion-period-decode

Conversation

@pipiche38

Copy link
Copy Markdown

NOOP's decodeMotionPeriod was decoding 0x6B with the wrong layout. It treated the first two payload bytes as a 12-bit "period", read the 2-bit MOTION_STATE codes starting at byte 2, and took a full four codes from every byte including the last.

Why the old layout is wrong (real-capture evidence)

Watching one sync session in ring-time order, the low nibble of payload byte 0 increments and wraps mod-16 across consecutive records:

payload[0] = 0x3c 0x0d 0x1e 0x2f 0x00 0x21 0x22 0x33 0x34 0x25 0x06 …
low nibble:   c    d    e    f    0    1    2    3    4    5    6   ← rolling seq counter

That is a rolling sequence counter, which makes byte 0 a header, not the low half of a period. The correct layout — a byte-for-byte port of the native parse_api_motion_period, and the same shape as the already-validated 0x4E sleep-phase decoder (one header byte, codes from byte 1) — is:

byte0 = header: bits[7:6] period_type
                bits[5:4] = count of valid codes in the FINAL byte
                bits[3:0] = the mod-16 sequence counter (ordering / dedup, not a state)
byte1… = 2-bit codes, 4 per byte MSB-first; the LAST byte carries only `count`

The old offset dropped the first real code byte and manufactured phantom codes from the final byte's padding. Short single-code records decoded to nothing: e.g. payload 1ea0 (header 0x1e → count 1; byte 0xa0 → one TOSSING code) produced an empty result under the old decoder.

What's in this PR

  • decodeMotionPeriod fixed byte-identically on both platforms (Swift Packages/OuraProtocol + Kotlin com.noop.oura).
  • Golden fixtures rewritten to the corrected layout: a full middle byte + a count-truncated final byte, plus the real short-record case (1ea0).
  • docs/OURA_PROTOCOL.md §6.13 updated to the corrected layout.

MOTION_STATE enum unchanged: 0 NO_MOTION, 1 RESTLESS, 2 TOSSING, 3 ACTIVE.

Verification

  • Swift: swift test in Packages/OuraProtocol160/0.
  • Kotlin: DecoderGoldenTest green (both new fixtures pass).
  • Pure protocol change — no BLE path touched, no app-target Swift, no schema/migration.

Scope note: this is a correctness fix, not a staging feature

0x6B stays decode-only instrumentation — it is not wired into OuraStreamMapping, staging, or scoring. I evaluated whether the corrected symbols could stage sleep, and on the capture I have they do not (yet) show a usable relationship. Correlating 0x6B symbols against the ring's own sleep-phase records (same-session), the state mix runs backwards for a stillness ladder — deep sleep was ~49% TOSSING and the stillest bucket was daytime:

ring stage NO_MOTION RESTLESS TOSSING ACTIVE
deep 12% 21% 49% 18%
light 1% 10% 47% 41%
awake 14% 16% 37% 33%
day (non-sleep) 38% 20% 26% 16%

The alignment here is weak (only ~55/498 motion records shared a sleep session, and the motion/sleep session-id spaces aren't verified to be the same clock), so this is underpowered/inconclusive, not a disproof. Properly evaluating 0x6B for staging needs an epoch-by-epoch wall-clock alignment (anchor session→UTC via the 0x42 time-sync) against the hypnogram — a follow-up. Until then 0x6B is not a shortcut past consuming the ring's own SleepNet hypnogram for scoring.

…rom byte1, count-truncated final byte

The 0x6B motion_period decoder had the wrong frame offset. It treated the
first two payload bytes as a 12-bit period and read 2-bit MOTION_STATE codes
from byte 2 onward, taking a full four codes from every byte including the
last.

Real-capture evidence disproves that layout: within a session, the low nibble
of payload byte0 increments and wraps mod-16 across consecutive records
(…c, d, e, f, 0, 1…). That is a rolling sequence counter, which makes byte0 a
header, not the low half of a period. The correct layout (native
parse_api_motion_period; the SAME shape as the validated 0x4E sleep-phase
decoder) is:

  byte0 = header: bits[7:6] period_type, bits[5:4] = count of valid codes in
          the FINAL byte, bits[3:0] = the mod-16 sequence counter
  byte1… = 2-bit codes, 4 per byte MSB-first; the LAST byte carries only `count`

The old offset dropped the first real code byte and manufactured phantom codes
from the final byte's padding; short single-code records (e.g. payload `1ea0`,
one TOSSING code) decoded to nothing at all.

Both platforms fixed byte-identically, golden fixtures rewritten to the
corrected layout (a full middle byte + a count-truncated final byte, plus the
real short-record case), and OURA_PROTOCOL.md §6.13 updated. 0x6B stays
decode-only instrumentation — it is not wired into staging/scoring; see the PR
body for why this capture does not yet show it staging sleep.

Swift OuraProtocol 160/0; Kotlin DecoderGoldenTest green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6

@ryanbr ryanbr left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice find — the mod-16 counter across 3c 0d 1e 2f 00 21 is solid evidence, and reporting the staging correlation as a negative is the right call.

One blocker, then two small things:

  1. count == 0. It is 2 bits but the last byte holds up to 4 codes, so 4 has to encode as 0. In your own header row, 3 of 11 records have count 0 (0x0d, 0x00, 0x06) — those now take nothing from their final byte, and a 2-byte one returns nil. Can you check whether the final payload byte is 0x00 in those records? Zero ⇒ current code is right. Non-zero ⇒ count == 0 means 4, and it needs count == 0 ? 4 : count on both platforms.
  2. Add a count == 0 fixture either way — both current ones use count 1, and a quarter of real records take that path.
  3. Reword "byte-for-byte port of the native parse_api_motion_period". That describes porting decompiled code; what you did is re-derive and attribute. Something like "cross-checked against parse_api_motion_period (attribution, not a port)".

Rest looks good — parity is byte-identical and both sides return nil on empty.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants