Add TK5 ring support (SmartHealth protocol)#56
Merged
saksham2001 merged 13 commits intoJul 10, 2026
Conversation
Reverse-engineered from Android btsnoop HCI captures of the SmartHealth app. New driver under PulseLoop/RingProtocol/TK5*.swift mirroring the jring/Colmi Coordinator/Driver/SyncEngine/Decoder/Encoder split, normalizing into the shared RingDecodedEvent: - Protocol: be940 service, length-prefixed CRC16/CCITT-FALSE framing, 2000-epoch timestamps. Command channel (be940001, write+indicate) and async stream (be940003) split; standard 180D/2A37 as an auth-independent fallback HR source. - Decoded + verified against captures: live HR, live SpO2, day steps (activity update), battery, activity history, and HRV (05 18 payload offset 11 + live 06 03 — matched the app's 48ms/79ms readings). Stress is intentionally not claimed: the ring doesn't store it (SmartHealth derives it from HRV). - Connect handshake replays the app's exact sequence and enables all-day monitoring (05 xx 02 burst) so HRV/sleep/etc. get recorded for later sync. Wiring: RingDeviceType.tk5, coordinator registered, WearableModel "TK" catalog entry, and a RingBLEClient discovery tweak so a characteristic that is both the write target and a notify source (be940001) gets subscribed. jring/Colmi unaffected. Tests: PulseLoopTests/TK5DecoderTests.swift (11) validate CRC/framing/decode against captured bytes. Protocol notes in docs/TK5-Protocol.md. Not yet: sleep (no overnight capture exists yet) and the AE00 encrypted login (untested whether it gates streaming/history on-device). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add manual spot readings for the TK5 and fix the live-measurement mode selection, verified on-device. - Live measurement mode: the 03 2f command's payload is [enable][mode]; the mode byte picks the sensor/LED (HR 0x00 green -> 06 01, SpO2 0x02 red/IR -> 06 02, HRV 0x0a -> 06 03). Previously all three used mode 0x00, so on-demand SpO2/HRV lit the wrong LED and failed. Now each uses its own mode. - Add .manualHrv capability + measureHRV() + startHRV/stopHRV on RingSyncEngine (no-op default for other rings); wire a "Measure HRV" button in Vitals and an HRV case through MeasurementSheet. Also declare .manualHeartRate/.manualSpo2 for the TK5 so those buttons appear. - Remove the standard 180D/2A37 subscription: it streamed a cached resting HR (~87 bpm) even off-finger, giving a constant false on-demand reading. Live HR now comes solely from the proprietary 06 01 stream (matches the app). - Gate the SpO2 decode to 70..100 so a warm-up 0 isn't surfaced as a reading. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Multi-record history: history frames concatenate many fixed-size records; the decoder only read the first, hiding periodic data. Now walk all records. 05 15 = packed 6-byte HR records (hr at offset 5; fixes an off-by-one that read the flag byte). 05 18 = packed 20-byte combined-vitals records; emit periodic SpO2 (@9) + HRV (@11) per record (HR from 05 15). Steps here are a cumulative daily counter, not deltas, and sync live, so they're no longer re-emitted as buckets. - Sleep (05 13): reassemble the multi-frame record (header magic af fa, total length at bytes 2..3) and decode header + 8-byte stage segments into a per-minute sleep timeline. Stage tags verified against the app's on-screen breakdown (deep 93 / light 249 / rem 130 min): f1=deep, f2=light, f3=rem, f4=awake. Declare .sleep/.remSleep. Tests: multi-record HR/SpO2/HRV frames and full sleep decode vs the app's displayed minute totals. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BP verified against the app's displayed readings (106/70, 111/74, 112/75). - Periodic BP from 05 18 records at offsets 7 (systolic) / 8 (diastolic). - Live BP from 06 03 in BP mode (03 2f 01 01): [sys][dia][hr?]. The 06 03 frame is shared between BP (mode 0x01) and HRV (mode 0x0a); the decoder distinguishes by whether the leading bytes fall in BP range. - Declare .bloodPressure so the BP card shows; app-side calibration works. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Steps were only coming from the live 06 00 status, which reports 0 right after a midnight reset, so today's ActivityDaily row often never got populated and the Today card (which shows the most recent row) kept displaying yesterday's total. Emit per-day steps from the 05 18 history records as an activityUpdate (per-day max, keyed by each record's timestamp). The step field is a cumulative daily counter (verified: rises through the day, resets to 0 at midnight), so max() is correct and can't overcount. Every sync now populates each day's row, including today's, so the count tracks the ring and resets daily. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Split the long hex test frames (line-length errors that failed the required SwiftLint check) into concatenated literals. - Reduce TK5Decoder.decode cyclomatic complexity by extracting the live-extended and combined-vitals cases into helpers. - Give the HRV measurement its own failure copy (was showing the SpO2 message) and drop the over-long line. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ring only auto-pushes 06 00 status frames (current step count) after it
receives 03 09 01 00 02 ("enable live status auto-push"). The startup sequence
was missing that command, so the app only ever saw the one-time history dump and
today's live step count never updated — it stayed frozen while connected.
Verified in both captures: the first 06 00 frame appears immediately after the
first 03 09, then streams for the rest of the session. Add enableLiveStatus() to
the startup sequence.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root-causes the sleep-page/home-page discrepancy to a timezone double-application bug in TK5Bytes.date, and scopes the fix separately from the general ring periodic-resync gap (tracked for its own branch). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
TK5Encoder.setTime sends the ring local wall-clock fields with no timezone concept, but TK5Bytes.date decoded the ring's reported seconds as if they were UTC — for non-UTC timezones this silently shifted every decoded timestamp by a full UTC-offset, which was enough to flip a sleep session onto the wrong side of the app's 7 PM day boundary (visible on Home, missing from the Sleep page). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
saksham2001
approved these changes
Jul 9, 2026
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.
Summary
Adds a driver for the TK5 smart ring (companion app: SmartHealth,
com.zhuoting.healthyucheng), reverse-engineered from Android btsnoop HCI captures. It mirrors the existing jring/Colmi Coordinator/Driver/SyncEngine/Decoder/Encoder split and normalizes everything into the sharedRingDecodedEvent, so the rest of the app stays device-agnostic.What works (all verified against the SmartHealth app's own readings)
On-demand HR / SpO₂ / HRV are exposed via the existing Vitals "Measure" buttons.
Protocol notes
be940000service; length-prefixed frames with CRC16/CCITT-FALSE; timestamps are seconds since 2000-01-01.be940001, write + indicate) and async stream (be940003). Live measurement uses03 2fwith a mode byte selecting the sensor/LED (HR/BP/SpO₂/HRV).docs/TK5-Protocol.md.One small shared change:
RingBLEClientdiscovery now subscribes a characteristic that is both the write target and a notify source (the TK5'sbe940001); jring/Colmi are unaffected since their write/notify chars are distinct.Testing
PulseLoopTests/TK5DecoderTests.swift(15 tests) validate CRC/framing, per-metric decode, multi-record history, and full sleep decode against captured bytes.Known limitations
AE00login (fedcba/"pass") that is not implemented; it isn't required for the data above (which flows in plaintext onbe940).🤖 Generated with Claude Code