Skip to content

RWfit ring family (#130), rebuilt from the vendor app - #46

Merged
foureight84 merged 3 commits into
mainfrom
feat/rwfit-vendor-rebuild
Aug 9, 2026
Merged

RWfit ring family (#130), rebuilt from the vendor app#46
foureight84 merged 3 commits into
mainfrom
feat/rwfit-vendor-rebuild

Conversation

@foureight84

Copy link
Copy Markdown
Owner

Rebuild of iOS #130 (RWfit ring family) from the vendor app.

Why this is a rebuild

The first version of this driver landed in PR #45 and was backed out (4027235) before that PR merged. It hadn't come from the vendor app at all — every wire-level constant was invented, so none of it could ever have talked to a real ring:

vendor app backed-out version
write / notify characteristics b002 / b003 a002 / a003
legacy frame 7E 01 cmd flags dataLen serHi serLo xor 7E len cmd payload xor
deviceInfo / battery / setTime 0x00 / 0x01 / 0x21 0x01 / 0x02 / 0x03
history 0xA0 manifest + 0xA10xA7 invented 0x10 / 0x11
unbind 0x44 0xFF
0xFE/0xFF ACK handshake mandatory absent
recognition advertisement only name.startsWith("RW")

Full detail in docs/ios-sync.md § "RWfit (#130) — backed out".

This version

Everything is read out of decompiled-rwfit-official/ (package com.rw.revivalfit) and cited to the file it came from, per the root AGENTS.md rule that the vendor app beats iOS parity. iOS's RWfitProtocol.swift cites the same decompile, and every claim it makes was re-derived rather than trusted — it checks out, with one exception below.

Things the extraction turned up that iOS's summary didn't carry:

  • Battery percent is payload byte 2, not byte 0 (PowerBean is [lowPowerFlag, powerStatus, percent]). Reading byte 0 gets a boolean, which is why the old driver reported every ring as 0 % or 1 %.
  • setTime is not a shared encoder — legacy sends the full four-digit year as a BE u16 (p.java u()), JieLi sends year - 2000 in one byte (p.java v()). Sharing it would set the ring's clock ~2000 years out, and every history timestamp with it.
  • Temperature is offset-encoded: °C = (raw + 200) / 10.
  • Sleep stage types confirmed from the vendor's own aggregation (s1.java:1636-1645, which sums them into wakeupCount/light/deep/REM) rather than guessed from the byte values: 0 awake, 1 light, 2 deep, 3 REM.
  • A vendor timestamp bug, replicated deliberately. The correction uses useDaylightTime() — "does this zone ever observe DST" — not inDaylightTime(date), so the vendor is an hour out for half the year in DST zones. Matched exactly: being "correct" here would put us an hour off what the ring and the vendor app agree on.

One iOS error found

iOS records the JieLi unbind triple as {0x03, 0x01, 0x30}. The vendor's table (y5/c.java) has no such entry — the only other group-3 0x20 triple is {0x03, 0x02, 0x20}. Left as JieLi.UNBIND_UNCONFIRMED and not sent: guessing inside the bind group risks re-binding or factory-resetting someone's ring. Worth an upstream issue on PulseLoopiOS.

Scope

Complete — legacy 0x7E: framing, serials, XOR, the mandatory 0xFE/0xFF ACK handshake with the vendor's ack-before-parse ordering, multi-packet reassembly, all six history streams, battery, and the manifest-gated cascade.

Partial — JieLi 0xAB: framing, handshake, battery, time sync and the ACK discipline work. The 05-group history bodies have their own per-type layouts that have not been extracted, so the sync engine does not request history on a JieLi link and the driver logs those frames rather than guessing at them.

Not done, and marked as such rather than faked:

  • Feature bitmap (x5/b.java i()SupportMenuBean) isn't decoded, so bitmapGatedCapabilities is declared but nothing grants from it yet. Manual/realtime measurement stays ungranted — the vendor app has no legacy on-demand measurement command at all, so a Measure button on a 0x7E link could only ever time out.
  • Step intraday buckets: the per-item index can't be placed on a wall clock without the ring's bucket width, which the vendor never states. Day totals only.

Blast radius outside the RWfit* files

  • WearableDriver gains servicesDiscovered(serviceUUIDs) with a no-op default. RWfit needs it because the wire framing isn't in the advertisement — the vendor picks it post-connect from sibling services (r5/b.java onServicesDiscovered).
  • RingBLEClient.onServicesDiscovered calls it, before any GATT work.
  • RingDeviceType.RWFIT, a WearableModel catalog entry (empty advertisedNamePatterns — recognition is advertisement-only, on purpose), the coordinator registration, and a DeviceHeroCard arm.
  • testOptions.unitTests.isReturnDefaultValues = true, so JVM tests can cover code that logs. Without it android.util.Log throws "not mocked", which would mean choosing between testing the ring drivers and being able to diagnose them in the field.

Testing

49 new unit tests — RWfitCodecTest (20), RWfitDecoderTest (16), RWfitDriverTest (13) — asserting vendor byte layouts rather than the implementation. Every assertion in the codec and decoder files fails against the backed-out driver. Suite 812 → 866, all passing; debug APK builds.

⚠️ No hardware validation

Nothing here has talked to a real RWfit ring. The tests pin the wire format to the vendor's own bytes, which is the strongest check available without a device, but it is not the same thing. Recommend shipping this in its own release rather than folded into 2.5.0, so any connect regression report has one unambiguous suspect.

… app

Rebuild of #130 from decompiled-rwfit-official/ instead of guesswork. Every
constant carries the vendor file it was read from.

Verified against the decompile (and confirming iOS RWfitProtocol.swift's
reading, which the backed-out port had ignored):

- y5/a.java: service a00a, write b002, notify b003; framing discriminators
  ae00 (JieLi), ff00 (PixArt OTA), 00010203-...-1912 (Telink OTA)
- r5/b.java:700-727: framing is chosen post-connect from those sibling
  services, never from the advertisement
- x5/d.java: legacy 0x7E header is 8 bytes
  `7E 01 cmd flags dataLen serHi serLo xor`, XOR over the payload only,
  serials 1..65535 wrapping, 12-byte multi-packet variant with totalBE/
  currentBE at [8..11], mandatory 0xFE device-ACK / 0xFF app-ACK handshake
  with ack-before-parse ordering
- x5/c.java + r5/b.java:386-476: JieLi 0xAB header is 6 bytes
  `AB flag lenHi lenLo crcHi crcLo`, CRC-16/ARC big-endian, length and CRC
  both covering the {cmd,key,keyFlag} triple that opens the body; flag 0x11
  marks ACKs, and the 06/09 realtime-measure ACK carries a trailing 0x00
- y5/c.java: the full triple table (setTime 2/1/0, battery 2/3/10,
  deviceInfo 2/4/10, profile 2/6/0, goal 2/7/0, units 2/17/0, bind 3/1/20,
  history 5/<type>/10) and the 05-group data types
- x5/b.java a() + the senders: deviceInfo 0x00, battery 0x01 (percent is
  payload byte 2, not byte 0), bindStatus 0x02, features 0x03, bind 0x20,
  setTime 0x21 (full 4-digit year BE, unlike the JieLi variant), units 0x24,
  profile 0x2E, unbind 0x44, manifest 0xA0, histories 0xA1-0xA7 with empty
  payloads
- r5/d.java c(): recognition is four raw-advertisement signatures, with a
  non-empty name required but never matched on

iOS records the JieLi unbind as 3/1/30; the vendor table has no such entry,
so it is left flagged as unconfirmed rather than guessed.

20 unit tests pin the frame layouts, checksums, ACK shapes and reassembly to
the vendor's bytes. Every one of them fails against the reverted driver.

Decoders, driver, sync engine and coordinator still to come; RWFIT is not yet
re-registered in RingDeviceType/WearableModel/RingBLEClient.
Six history streams plus battery and the sync manifest, each read out of the
vendor decoder it mirrors.

- 0xA0 manifest (v0): [count u16][flagsA][flagsB]; flagsA bits 0..7 are
  step/sleep/hr/bp/spo2/temp/breathe/ecg, flagsB bit 0 is sport. Every per-stream
  request is gated on these, exactly as blesdk/service/l.java does.
- 0x01 battery (a(), case 1||96): PowerBean is
  [lowPowerFlag, powerStatus, percent] — the percentage is byte 2. The reverted
  driver read byte 0, so every ring reported 0% or 1%.
- 0xA3 HR (w0), 0xA5 SpO2 (r0), 0xA4 BP (s0), 0xA6 temperature (u0) share one
  loop: [dayTs u32][itemCount u16] then fixed-size items. HR/SpO2/temp items are
  5 bytes, BP is 6 (systolic + diastolic). Temperature is offset-encoded —
  °C = (raw + 200) / 10.
- 0xA1 steps (C0): 15-byte day header
  [dayTs u32][steps u24][calories u24][distance u24][itemCount u16] then 8-byte
  items. Only day totals are emitted: the per-item `index` doesn't pin a
  wall-clock bucket without knowing the ring's bucket width, which the vendor
  never states. Flagged rather than assumed.
- 0xA2 sleep (A0): 16-byte day header
  [dayTs u32][totalMinutes u16][asleepTs u32][awakeTs u32][itemCount u16] then
  2-byte [minutes][stageType] runs, expanded per-minute. Stage types confirmed
  from the vendor's own aggregation in s1.java:1636-1645 (0 awake, 1 light,
  2 deep, 3 REM) rather than guessed from the byte values.

Timestamps: the ring stamps local wall-clock as if it were UTC and the vendor
subtracts the zone's raw offset plus a flat hour when the zone observes DST at
all. Replicated exactly, quirk included — useDaylightTime() asks whether the
zone ever uses DST, not whether this timestamp is inside it, so the vendor is an
hour out for half the year. Matching it keeps us aligned with what the ring and
the vendor app agree on.

Every decoder stops on a truncated record rather than reading past the buffer.

16 tests. Suite 812 → 849. RWFIT is still not registered — driver, sync engine
and coordinator are next.
Completes the #130 rebuild. RWFIT is registered again — RingDeviceType,
WearableModel catalog, RingBLEClient coordinator list, DeviceHeroCard.

RWfitDriver owns the awkward part: the wire framing isn't in the advertisement.
Adds an optional WearableDriver.servicesDiscovered() hook (no-op for every other
family) so RingBLEClient can hand over the GATT service table before the first
write, and picks JieLi when AE00 / Telink OTA / PixArt FF00 is present, legacy
otherwise — exactly r5/b.java:700-727. Legacy is the default because a frame in
the wrong format is ignored rather than misread, so a wrong guess is inert.

The legacy path is complete: the driver app-ACKs every inbound frame before
parsing it (x5/d.java's ack-before-parse order, which the ring retransmits
until it sees), NACKs a bad checksum with status 2 so the ring resends, and
never echoes a 0xFE.

RWfitSyncEngine follows the vendor's own cascade (u1.java g() →
blesdk/service/l.java): device info, clock, battery, then the 0xA0 manifest, and
pulls only the streams the manifest claims — one at a time, each request fired
by the previous stream's reply. Every per-stream method in l.java opens with an
isHasXData() check, so a ring without a temperature sensor is never asked for
temperature history.

RWfitCoordinator matches only the A00A advertisement or the d6050200/d6054154/
d6060200 manufacturer prefixes. No name matching, deliberately: the vendor
requires a non-empty name but never reads it, because the name is the one field
rebranders change. Manual/realtime measurement sits in bitmapGatedCapabilities,
not the baseline — the vendor app has no legacy on-demand measurement command,
so a Measure button on a 0x7E link could only ever time out.

Not done, and marked as such rather than faked:
- JieLi 05-group history bodies aren't decoded, so the engine doesn't request
  history on a JieLi link and the driver logs those frames.
- The feature bitmap (x5/b.java i()) isn't decoded, so nothing grants from
  bitmapGatedCapabilities yet.
- Step intraday buckets: the per-item index can't be placed on a clock without
  the ring's bucket width, which the vendor never states. Day totals only.
- JieLi unbind: iOS records {03,01,30}; the vendor table has no such triple, so
  it is not sent.

testOptions.unitTests.isReturnDefaultValues so JVM tests can cover code that
logs — the alternative was choosing between test coverage and being able to
diagnose a driver we have no hardware for.

13 driver tests (framing selection, ACK handshake, manifest cascade, coordinator
matching). Suite 849 → 866, all passing. Still no hardware validation.
@foureight84
foureight84 merged commit ca9dc5a into main Aug 9, 2026
1 check passed
@foureight84
foureight84 deleted the feat/rwfit-vendor-rebuild branch August 9, 2026 06:33
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