Skip to content

Add TK5 ring support (SmartHealth protocol)#56

Merged
saksham2001 merged 13 commits into
saksham2001:mainfrom
hoveeman:feature/tk5-ring-support
Jul 10, 2026
Merged

Add TK5 ring support (SmartHealth protocol)#56
saksham2001 merged 13 commits into
saksham2001:mainfrom
hoveeman:feature/tk5-ring-support

Conversation

@hoveeman

@hoveeman hoveeman commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

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 shared RingDecodedEvent, so the rest of the app stays device-agnostic.

What works (all verified against the SmartHealth app's own readings)

  • Heart rate — live (on-demand) + hourly history
  • SpO₂ — live (on-demand) + hourly history
  • HRV — live (on-demand) + hourly history (matched the app's 48/79 ms)
  • Blood pressure — periodic + live (matched the app's 106/70, 111/74, 112/75)
  • Sleep — deep/light/REM timeline (matched the app's on-screen breakdown to the minute: deep 93 / light 249 / rem 130 min)
  • Steps (per-day cumulative) and battery

On-demand HR / SpO₂ / HRV are exposed via the existing Vitals "Measure" buttons.

Protocol notes

  • Custom be940000 service; length-prefixed frames with CRC16/CCITT-FALSE; timestamps are seconds since 2000-01-01.
  • Command channel (be940001, write + indicate) and async stream (be940003). Live measurement uses 03 2f with a mode byte selecting the sensor/LED (HR/BP/SpO₂/HRV).
  • History frames pack many fixed-size records; sleep is reassembled across several frames.
  • Full write-up in docs/TK5-Protocol.md.

One small shared change: RingBLEClient discovery now subscribes a characteristic that is both the write target and a notify source (the TK5's be940001); 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.
  • Clean simulator build.

Known limitations

  • The ring exposes a separate encrypted AE00 login (fedcba/"pass") that is not implemented; it isn't required for the data above (which flows in plaintext on be940).
  • Temperature isn't decoded yet (no capture containing it).
  • No on-demand BP button (BP is treated as a passive metric, as with the jring); BP still populates from periodic/live data.

🤖 Generated with Claude Code

hoveeman and others added 5 commits July 6, 2026 13:58
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>
@hoveeman
hoveeman requested a review from saksham2001 as a code owner July 7, 2026 13:00
hoveeman and others added 8 commits July 7, 2026 09:50
- 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
saksham2001 merged commit 440aaf4 into saksham2001:main Jul 10, 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.

2 participants