feat(ring): RingConn Gen2 live steps → Steps complication (opt-in) - #36
Merged
Conversation
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>
2 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 facereflects steps the phone missed (phone left behind, wrist-only movement).
What's here
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.
RingConnDescriptor): decodes the three step-bearing frames —0x10spontaneous,0x87fetch reply, and the81 01auth-status reply that ashort-lived connection actually receives.
remembers the selected ring across toggles. Ring merge also wired through
AutoUpdateWorkerso the background push agrees with the foreground refresh (seam thewhole-branch review caught).
PreviewState.Loading(previous)keeps every complication's lastvalue 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 thering's stored records for a true wrist-daily. Protocol already cracked (full plan
committed in this PR):
4cactivity-record body byte[14], u8 per 2.5-min bucket.d0 <ts:u32-be> <body>, ts = seconds since 2020-01-01 (ring-local).02 00 <ts> …; stream pump07 00 00.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