How the Oura Android app decides when to use each ring data channel, from the
decompiled state machine (com/ouraring/oura/data/device/ring/). This is the
behavior an independent client should replicate.
| Channel | Wire | When the app uses it | Needed to mirror app data? |
|---|---|---|---|
| History events (NORMAL buffer) | GetEvent 0x10 / ExtGetEvent 0x2f, bufferId 0 |
every sync (connect, foreground, background) | yes -- this is the whole game |
| Live / realtime | SetFeatureMode CONNECTED_LIVE, realtime 0x06 |
only while a specific UI screen is open | only for a live HR readout |
| RData bulk raw | RDataStart/GetPage 0x03, RAW_DATA buffer |
never by default (r_data_autosync=false) |
no, unless you want raw waveforms |
Everything the user sees (sleep stages, last-night HR/HRV/SpO2, MET/steps) flows through the history-event drain. RData is a research/diagnostics opt-in.
Driven by RingStateMachine / DefaultRingStateMachine$Operations
(states: CONNECTING -> AUTHENTICATING -> CHECK_CAPABILITIES ->
APP_LEVEL_AUTHENTICATING -> FOREGROUND_SYNC | BACKGROUND_SYNC):
- CONNECT (BLE connect + bond)
- AUTHENTICATE (nonce -> AES ->
Authenticate) - GET_CAPABILITIES (decides extended vs legacy event sync)
- APP_LEVEL_AUTHENTICATE
- SYNC_TIMESTAMPS (
SyncTime, write phone UTC) - ENABLE_NOTIFICATION (
SetNotification) - BATTERY_LEVEL / PRODUCT_INFO / RING_VERSION (metadata)
- feature ENABLE_* toggles (conditional on capabilities + user flags)
- SYNC_EVENTS (main drain)
- SYNC_R_DATA (only if
r_data_autosync)
Load-bearing for a client: time-sync, notification-enable, and capabilities all precede event sync.
cursor = store.get_next_event_to_sync() # deciseconds (100 ms units)
loop:
if extended_supported:
summary = ExtGetEvent(start_ms = cursor*100, max_events = 65535, buffer = NORMAL)
else:
summary = GetEvent(start_deciseconds = cursor, max_events = 255, flags = -1)
decode + persist events from the response
if summary.events_received > 0:
cursor = cursor + 1
store.set_next_event_to_sync(cursor) # incremental-sync bookmark
if summary.bytes_left > 0: # ring has more data
repeat
else:
done
The ring reports bytes_left in the 0x11 / extended confirmation packet; loop
until it reaches 0. The persisted cursor (nextEventToSync) makes sync
incremental. sleepAnalysisProgress is surfaced as progress only, not a block.
- On connect: full handshake then SYNC_EVENTS automatically.
- Foreground: user-triggered
triggerForegroundSync(). - Background: on app backgrounded.
- A small periodic worker refreshes battery only; the real data sync is connection-/lifecycle-triggered, not a fixed timer.
- Skips if a sync is already ongoing.
- Routes around *_SYNC during onboarding.
- No hard low-battery block on the event path; RData (heavier) is the gated one.
- Connect + bond; subscribe to the notify characteristic.
- Authenticate with the stored 16-byte key.
- GetCapabilities -> choose extended vs legacy event path.
- SyncTime + SetNotification.
- (optional) firmware / product / battery for metadata.
- Drain history events from the persisted cursor; persist each event + advance
the cursor; stop when
bytes_left == 0.
Do not issue any RData (0x03) for a normal pull.