RWfit ring family (#130), rebuilt from the vendor app - #46
Merged
Conversation
… 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.
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.
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:b002/b003a002/a0037E 01 cmd flags dataLen serHi serLo xor7E len cmd payload xor0x00/0x01/0x210x01/0x02/0x030xA0manifest +0xA1–0xA70x10/0x110x440xFF0xFE/0xFFACK handshakename.startsWith("RW")Full detail in
docs/ios-sync.md§ "RWfit (#130) — backed out".This version
Everything is read out of
decompiled-rwfit-official/(packagecom.rw.revivalfit) and cited to the file it came from, per the rootAGENTS.mdrule that the vendor app beats iOS parity. iOS'sRWfitProtocol.swiftcites 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:
PowerBeanis[lowPowerFlag, powerStatus, percent]). Reading byte 0 gets a boolean, which is why the old driver reported every ring as 0 % or 1 %.setTimeis not a shared encoder — legacy sends the full four-digit year as a BE u16 (p.java u()), JieLi sendsyear - 2000in one byte (p.java v()). Sharing it would set the ring's clock ~2000 years out, and every history timestamp with it.°C = (raw + 200) / 10.s1.java:1636-1645, which sums them into wakeupCount/light/deep/REM) rather than guessed from the byte values:0awake,1light,2deep,3REM.useDaylightTime()— "does this zone ever observe DST" — notinDaylightTime(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-30x20triple is{0x03, 0x02, 0x20}. Left asJieLi.UNBIND_UNCONFIRMEDand 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 mandatory0xFE/0xFFACK 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. The05-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:
x5/b.java i()→SupportMenuBean) isn't decoded, sobitmapGatedCapabilitiesis 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 a0x7Elink could only ever time out.indexcan'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*filesWearableDrivergainsservicesDiscovered(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.onServicesDiscoveredcalls it, before any GATT work.RingDeviceType.RWFIT, aWearableModelcatalog entry (emptyadvertisedNamePatterns— recognition is advertisement-only, on purpose), the coordinator registration, and aDeviceHeroCardarm.testOptions.unitTests.isReturnDefaultValues = true, so JVM tests can cover code that logs. Without itandroid.util.Logthrows "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.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.