Skip to content

feat(ring): RingConn Gen2 live steps → Steps complication (opt-in) - #36

Merged
kenblizzardcaron merged 14 commits into
mainfrom
feat/ringconn-steps
Jul 3, 2026
Merged

feat(ring): RingConn Gen2 live steps → Steps complication (opt-in)#36
kenblizzardcaron merged 14 commits into
mainfrom
feat/ringconn-steps

Conversation

@kenblizzardcaron

Copy link
Copy Markdown
Owner

RingConn Gen2 live steps → Steps complication (opt-in)

Reads a paired RingConn Gen2 smart ring's onboard step count over BLE and merges it
into the Steps complication nameplate as max(ring, Health Connect), so the watch face
reflects steps the phone missed (phone left behind, wrist-only movement).

What's here

  • BLE client (AndroidRingStepsSource): short-lived connect → SM3 per-connection auth
    (GB/T 32905, KAT-verified) → read → close, with a process-wide read mutex, the descriptor
    prompt sequenced behind the auth-write ack, and disconnect-before-close hardening.
  • Frame parser (RingConnDescriptor): decodes the three step-bearing frames —
    0x10 spontaneous, 0x87 fetch reply, and the 81 01 auth-status reply that a
    short-lived connection actually receives.
  • Opt-in toggle on the Steps card with bonded-ring auto-discovery; off by default,
    remembers the selected ring across toggles. Ring merge also wired through
    AutoUpdateWorker so the background push agrees with the foreground refresh (seam the
    whole-branch review caught).
  • Generic UX fix: PreviewState.Loading(previous) keeps every complication's last
    value on the LCD with an "Updating…" hint during a re-read, instead of blanking to
    "Loading…".

On-device

Verified on a real RingConn Gen2: daily total tracks the RingConn app closely
(8727 ours vs 8694 app — 0.4%, two different sensors).

Follow-up — records-daily spike (plan included: plans/2026-07-03-ringconn-records-spike.md)

The ring's status-frame step field is a live activity-bout counter, not a daily total;
today the daily comes from Health Connect winning the max(). A follow-up will read the
ring's stored records for a true wrist-daily. Protocol already cracked (full plan
committed in this PR):

  • Step field = 4c activity-record body byte [14], u8 per 2.5-min bucket.
  • Records are d0 <ts:u32-be> <body>, ts = seconds since 2020-01-01 (ring-local).
  • Fetch-since 02 00 <ts> …; stream pump 07 00 00.
  • Remaining before coding: confirm step-field width (>255/bucket run), nail fetch-since
    flags + stream-end signal, add MTU reassembly to the decoder, then sum today's buckets
    and revert the merge to max(ring_daily, HC).

🤖 Generated with Claude Code

kenblizzardcaron and others added 14 commits July 3, 2026 12:41
Parses the ring's onboard step count from the 19-byte status descriptor
(0x10 spontaneous / 0x87 fetch reply), steps at [4:6] big-endian, XOR-validated.
Tests use real btsnoop-captured frames.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…h Connect)

When the RingConn opt-in is on, refreshSteps reads the ring at the same
poll and displays the higher of the two counts; a ring read also rescues
an HC failure as fresh (not stale). Toggle off = ring never consulted,
byte-identical behavior. Steps display helpers become local functions of
refreshSteps (single caller; keeps the controller under detekt's
function-count threshold), and the DI constructor takes the same
justified LongParameterList suppression as AppViewModel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… DI-count comment

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Short-lived connect -> CCCD enable -> status/challenge -> SM3 auth ->
d0 descriptor prompt -> parse steps -> close, on a 4 s timeout; wholly
separate from the watch's WatchLink singleton. Discovery enumerates
bonded devices by the RingConn name prefix (BLUETOOTH_CONNECT only, no
scan). MainActivity wires the source with the ringConnAddress pref.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Android's BluetoothGatt rejects (not queues) a second writeCharacteristic
while one is outstanding, so the back-to-back auth + d0 writes dropped
the descriptor prompt deterministically and every read would have timed
out to null. The prompt is now stashed and sent from onCharacteristicWrite.
Also: disconnect() before close() (WatchLink teardown precedent) and bail
on a non-success onServicesDiscovered status.

Found by the Task 5 independent review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… hardening

The scheduled AutoUpdateWorker steps push read Health Connect directly,
so with the toggle on the watch flip-flopped between max(ring, HC)
(foreground pushes) and HC-only (worker pushes), and the worker's
recordStepsFetch clobbered the merged cache. The ringStepsOrNull gate is
now a shared stepsIfEnabled helper used by both paths; the worker merges
identically and a live ring read rescues an HC failure as a fresh push.

Hardening from the same review: ring reads serialized process-wide with
a mutex (foreground + worker each construct their own source; two
concurrent connectGatt calls to one peripheral risk status 133); the
controller now reads ring and HC concurrently instead of stacking the
ring's 4 s budget after HC; bondedRings() guards adapter.isEnabled like
MainActivity's picker; AppViewModel ring-name lookup deduplicated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0.32.0 is reserved by the open activity-upgrades PR #35.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…actually gets

Task 7 on-device: the full handshake worked (SM3 auth accepted on real
hardware), but the ring ignores d0 00 00 prompts from our session and
the 10/87 descriptor never arrives inside the 4 s window, so every read
timed out to the Health Connect fallback. The btsnoop capture shows the
ring answers the auth command itself with a 38-byte 81 01 full-status
frame: same payload as the 19-byte descriptor shifted by the subtype/pad
header, steps at [6:8] big-endian, XOR trailer valid on all four
captured frames (counts grew 293->447 while walking). parseSteps now
accepts it; the read completes right after auth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PreviewState.Loading now carries the previous Ready value; every card
shows it with an 'Updating…' hint below the LCD instead of wiping to
'Loading…' on each 60 s poll / Update-now tap. Controllers set it via a
refreshing() transition that preserves the value across chained
refreshes. First fetch (no previous value) still shows 'Loading…'.

Requested during Task 7: the ring read made the wipe visibly annoying.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The cracked RingConn Gen2 record-sync protocol — step field = 4c record
body byte[14], d0-delimited timestamps, fetch-since + stream-pump commands
— for the follow-up that reads a true wrist-daily instead of max(bout, HC).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kenblizzardcaron
kenblizzardcaron merged commit deb856d into main Jul 3, 2026
4 checks passed
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.

1 participant