Protocol
Full ledger·
The single-file protocol ledger (formerly PROTOCOL.md), in the order the findings were made — every section verbatim, collapsed. The organised reference lives under Protocol.
In 10 seconds
The lab notebook: every finding in the order it was made on 2026-09-19, verbatim, as the audit trail — early notes that were later corrected are flagged in the table. The organised reference is Protocol.
One screen of the ledger·
| § | what it established | tag | superseded / current home |
|---|---|---|---|
| 0 | LoraFit = four radio stacks; the ring path is "JiangYun ring" (hr7/hy7), selected by 56FF |
APK | chip "unknown" → Dialog DA14xxx (§7b) |
| 1 | advertising: name <model>-<mac4>, silent while held |
APK | XOR-0xAD layout does not apply (§7b) |
| 2 | GATT 56FF · write 33F3 · notify 33F4 · BAS 2A19 |
APK | BAS lies, use cmd 6 (§7b) |
| 3 | frame FE FC · cmd · total · index · len, no checksum; ts6 local seconds |
APK | field order corrected the same day |
| 4 · 4b | 40+ command ids with payloads; connect sequence | APK | Commands — 28 now ✅ |
| 5 | audio path: 0x2F/0x30/0x3D/0x40/0x34/0x36, Opus 16 kHz 40 B frames | APK | proven §7c · Audio |
| 6 | sample contract proposal | — | shipped as tiny → Health platform |
| 7 | vendor-app HCI capture matched the APK read byte for byte | CAPTURE | Lab → Live capture |
| 7b | first live session from the Mac: info, name, features, battery, HR, terminators, alarms empty | LIVE | evidence/*.jsonl |
| 7c | a 6-minute memo pulled: 1522 packets, counter semantics, Ogg muxing | LIVE | Audio |
| 7d | macOS bonds the ring as a Mouse — and that is the fastest transport | LIVE | macOS transport |
| 8–9 | open items of the day | — | current list: 3 minutes → still open |
The ledger, verbatim·
Status legend: [APK] = derived from decompiled LoraFit com.wq.lorafit (base.apk pulled from Pixel via adb,
jadx 1.5.x, see docs/raw/); [LIVE] = confirmed against a real ring (evidence/*.jsonl); [FCC] = FCC filing.
Anything marked only [APK] is not yet proven on the metal.
§0. Identity / family
- App code path: Kotlin source
com/watchfun/bleManager/device/JiangYunRingDevice.kt(obfuscateddefpackage/hr7), protocol builder/parserBLEProtocolManager(defpackage/hy7, log tagJY RING). Vendor SDK namespace watchfun, family name JiangYun (JY) ring. [APK: docs/raw/hy7_BLEProtocolManager.java] - LoraFit is a multi-product app: the same binary also carries Jieli RCSP (earbuds/glasses, svc AE00/AE01/AE02,
libjl_*.so), a "JieTuo" ring protocol (svc 6800/6801/6802,defpackage/vr7) and acom.huahen.btutilearbud layer (svc DF97/DF98/DF99, adv filter FDB3/DF87). None of those is the SR116 — the SR116 path is selected when the GATT table contains a service whose UUID string contains56FF(defpackage/t01.checkService). - Chipset: unknown. The brief's FCC ID "2BSR6-SR116" is NOT printed on the packaging (only an FC logo — see ~/.tiny/ring-20260919/photos) and the FCC OET database returns "no applications on file" for grantee 2BSR6 / product SR116 (tools/fcc.mjs, docs/fcc/apps.fcc.gov-search.html, 2026-09-19). fccid.io / fcc.report are behind Cloudflare Turnstile even for headed Chrome. → chipset will come from the live GATT (DIS 0x180A strings, vendor services present, OTA service UUID) once ring 2 is reachable. The JY protocol itself is chip-agnostic (plain GATT write/notify, no RCSP framing).
§1. Advertising [APK]
- Name
SR116-088D(last 4 hex of MAC). Owner's ring 1 MAC 73:01:06:00:08:8D (from LoraFit iOS). - Manufacturer-specific AD (0xFF) as parsed by
BleVendorDataParser(huahen layer — may or may not apply to the ring; verify LIVE):cid u16lemust be 0x22EB,bid u16le,pid u16le,state u8(0 = free, 1 = connected),mac[6] XOR 0xAD,reserve[16]; min 29 bytes. [APK: docs/raw/BleVendorDataParser.java] - The ring does not advertise while connected to a phone (observed: 12 s scan, LoraFit holding ring 1).
§2. GATT [APK]
| Role | UUID (16-bit, Bluetooth base) | Notes |
|---|---|---|
| Service | 0x56FF → 000056ff-0000-1000-8000-00805f9b34fb |
selected by substring match "56FF" |
| Write (app → ring) | 0x33F3 |
writeChara in mz0.delegateConnect |
| Notify (ring → app) | 0x33F4 |
notifyChara; CCCD 0x2902 enable |
| Battery | 0x180F / 0x2A19 |
standard BAS, read (hr7.getBattery), also JY cmd 6 |
| Device Info | 0x180A |
standard DIS strings present in APK string table (2A24..2A29) — verify |
MTU: app requests MTU (getMTU cmd 3); packets are chunked to the negotiated MTU-3.
§3. Frame format [APK: hy7.buildPacket / parseReceivedData]
All little-endian, no checksum, no CRC:
offset size field
0 2 magic 0xFCFE → bytes FE FC on the wire (short -770)
2 2 cmd command id (see §4)
4 2 total total packets in this logical message; requests always 1
6 2 idx packet index (1-based); requests always 1 ← order CORRECTED 2026-09-19 (was swapped in v1;
hy7 logs "BLE包进度 %d/%d" with (field6, field4) = index/total; credit lane S)
8 2 len payload length N
10 N payload
Receiver rejects frames where len(frame) != 10 + len. Multi-packet replies (cmd 33/34/36/43/45/52) carry
idx/total and are reassembled by the app; idx == total = last chunk.
Timestamps in payloads are 6-byte LE "device time" = local wall-clock seconds (UTC + tz offset);
convert with utc = dev - tz_offset_seconds (BleCommonUtils.deviceTimestampToUtcTimestamp).
§4. Command matrix (request payloads) [APK: hy7]
| cmd | name (APK) | payload | reply |
|---|---|---|---|
| 1 | setTime | ts6 (device time) + 0x00 | 1: year u16, month, day, hour, min, sec |
| 2 | setUserInfo | u8 ×5 (sex, age, height, weight, ?) + u64 | ack |
| 3 | getMTU | 00 00 | ? |
| 4 | vibrateLED(mode) | u8 | ack — find-ring candidate |
| 6 | getBattery | 00 00 | 6: battery_percent u8, charging u8 |
| 7 | openHeartRate | – | 7 ack; live HR then streams as cmd 11 notifications |
| 8 | closeHeartRate | – | ack |
| 9 | getSportStatus | 00 00 | ? |
| 10 | pauseOrResumeSport(u8) | u8, 0 | 10: current_sport_id u8, pause_status u8 |
| 11 | (stream) live heart rate | – | ts6, heart_rate u8 — APK self-test vector FEFC 0B00 0100 0100 0700 29FA656900 00 5F = 95 bpm |
| 12 | setSportTarget | u32 steps | ack |
| 13 | setAuxFunction | 13 bytes of flags | ack |
| 14 | setTimeFormat | u8 (0 = 24h) | ack |
| 15 | openCloseBpBsHrv(a,b) | u8 a, u8 b | ? (no parser branch → BP/HRV likely unsupported on SR116) |
| 19 | setBpCalibration | u16 sys, u16 dia | ack |
| 20 | openCloseTemperature | u8 | 21: ts6, temperature_raw i16 (/100 °C) |
| 23 | openCloseSpo2 | u8 | 24: ts6, spo2 u8 |
| 26 | setAppInfo | 0x00 + 12-byte app id (SimpleDeviceId from app UUID) | ack |
| 27 | (stream) multi-sport realtime | – | ts6 start, sport_type u8, ts6 now, duration u16, steps u32, calories u16, distance u16, heart_rate u8, temp i16 (/10), spo2 u8 (26 B) |
| 28 | setAppRunningInfo | u32 | ack |
| 29 | getStepInfo | – | 29: ts6, steps u32, calories u16, distance u16 |
| 30 | setAutoHeartRateSetting | start_h, start_m, end_h, end_m, enable u8, interval_min u8 (app: 0,0,23,59,on,5) | ack |
| 33 | getStepCountInfo(day) | u8 day-offset | items[16 B]: ts6, duration u16, steps u32, calories u16, distance u16 (0xFF-filled = end) |
| 34 | getSleepData(day) | u8 day-offset | items[8 B]: ts6, sleepQuality u8, moveIntensity u8 |
| 36 | getHealthRecord(day) | u8 day-offset | items[14 B]: ts6, heart_rate u8, spo2 u8, temperature_tenths i16, u32 (unused) |
| 37 | getDeviceInfo | – | firmware_version u16, mac[6], manufacturer_code u16, product_model u16 |
| 38 | controlMultiSport(u8) | u8 | 38 |
| 39 | deviceOperation(u8) | u8 | – |
| 40 | (event) button | – | button_event u8 |
| 41 | sendPaste | 01 | – (HID paste trigger for translation flow) |
| 42/43 | (event) sleep / step-history sync finished | – | ts6 |
| 45 | (event) health record sync finished | – | ts6 |
| 47 | queryAudioState | 00 00 | audio_state, ext_value |
| 48 | controlAudioMode(u8) | u8, 0 | 48/49: action, final_state |
| 49 | pauseOrResumeAudio(u8) | u8, 0 | |
| 50/51/52 | offline audio stream | – | 52 = getOfflineAudioData → Opus packets (40 B frames) via parseAudioPacketData |
| 53 | offline audio clear reply | – | |
| 54 | ? | – | streaming, audio_source |
| 55 | queryDeviceFeature | 00 00 | remote_recording_enabled, recording_storage_enabled, classic_bt_supported, alarm_supported (u8 each) |
| 61 | queryOfflineAudioFileCount | – | offline_file_count u16 |
| 62 | sendAppHeartbeat | – | – (sent every 2 s by the app once features are known) |
| 63 | stopOfflineRecord | – | – |
| 64 | stopOfflineRecordTransfer | – | – |
| 65 | setAlarmList | 5 × (enabled u8, hour, minute, repeatMask&0x7F) = 20 B | alarm_set_result |
| 66 | ? | – | android_connect_request / ios_connect_request / classic_bt_address |
| 67 | requestDeviceUnbind | – | unbind_result |
| 69 | queryAlarmList | – | alarms[] (4 B each) |
| 68 | (event) device name | – | len u8 + ASCII BLE name (LIVE; not in APK parser) |
| 0x36 (54) | clearOfflineAudioData(u16) | u16 remaining (echo what the ring pushed) | LIVE: ring pushes 0x36 <remaining> after the last 0x34 packet; app echoes it → file deleted |
§4b. Connection sequence [APK: CardManager2.onConnected / hr7.enableNotifications]
- GATT connect,
requestConnectionPriority(HIGH), read BAS 0x2A19, enable CCCD on 0x33F4 (≈100 ms delays between). - +1000 ms:
setTime(1) →setAppInfo(26:00+ 12-byte app id derived from the app's install UUID — identity, not a secret) →openHeartRate(7) if the user enabled live HR, elsecloseHeartRate(8) →setAutoHeartRateSetting(30) =00 00 17 3B <enabled> <interval_min>(window 00:00–23:59, default 5 min). queryDeviceFeature(55); when recording is supported the app startssendAppHeartbeat(62) every 2000 ms.- Writes are queued and paced ≥300 ms apart (150 ms during audio transfer); a write that fails 3× is dropped. No pairing/bonding, no challenge–response was found anywhere in the JY path.
§5. Audio ("AI recording") path [APK]
The ring records to on-board flash (recording_storage_enabled) as Opus, 40-byte frames, and streams them
over the same 33F4 notify char in cmd-52 multi-packet messages (parseAudioPacketData: totalPackets /
currentPacket, rate log "离线音频同步速率"). Live "remote recording" (remote_recording_enabled) uses cmd 48/49.
Translation itself happens in the phone (Zego / AIKIT / ChatGPT libs) — the ring is only a mic + storage.
§6. Normalized sample contract (proposal to lane H)
{ts, device_id, metric, value, unit, source:"sr116", raw} with metrics: heart_rate bpm, spo2 %,
temperature °C, steps count, calories kcal, distance m, sleep_stage (sleepQuality enum), battery %.
§7. Live capture corroboration (lane DYN, Pixel HCI snoop of LoraFit ↔ ring 2, 2026-09-19)
Independent of the APK read, the on-air bytes confirm: write handle 0x0017 / notify 0x0019, FE FC op 00 01 00 01 00
len16 payload, ops 0x25 devinfo (MAC echoed), 0x06 battery (63 01 = 99 %, charging), 0x01 set-time → ring echoes
Y/M/D/h/m/s, 0x3E heartbeat every ~2 s, 0x21→0x2A history, 0x07 open HR, 0x1E auto-HR, 0x08, 0x1A app-info (13 B),
0x2F, 0x3D. One op 0x44 (68) ("name") is not in the APK's BLEProtocolManager — bytes requested from DYN.
Full dumps: captures/ + docs/LIVE-CAPTURE.md (DYN).
§7b. LIVE from the Mac mini (bleak, 2026-09-19 15:2x–15:4xZ, evidence/{gatt,handshake,hr,sync,alarms,find}.jsonl)
Connected to ring 1 SR116-088D (73:01:06:00:08:8D, on charger, fw 0x0098=152, LoraFit had released it) and
observed ring 2 advertising as JY-Y3-011A (73:01:06:00:01:1A). Facts now [LIVE]:
- Advertising: name
SR116-088D/JY-Y3-011A(per-unit label, NOT a fixed prefix — filter on 0x594A mfr id or FEF5 svc); service UUIDs 0xFEF5 + 0x1812 (HID); manufacturer data company id 0x594A ("JY") =mac[6] | fw u16 | 3a00 | 4800 | 01 01 | 00×4(MAC in clear, no XOR — the huahen 0x22EB layout does NOT apply). - Chipset: Dialog/Renesas DA145xx — service 0xFEF5 with the standard SUOTA characteristic set (5f78df94-…
status notify, 8082caa8 mem-dev, 724249f0 gpio-map, 6c53db25 mem-info, 9d84b9a3 patch-len, 457871e8 patch-data,
64b4e8b5/42c3dfdd/b7de1eea version/mtu/pd). Never write these (OTA). No DIS strings; 0x2A50 PnP ID =
01 ac05 2002 1001(Bluetooth SIG vendor 0x05AC, product 0x0220, version 0x0110). Extra vendor svc 0xFF12 (FF15 read/write-wo-resp, FF14 notify) — unknown, untouched. - GATT handles: 33F3 = h22, 33F4 = h24 (matches DYN's 0x0017/0x0019 att handles incl. CCCD), BAS 2A19 = h43
(read
64= 100 % while the JY cmd 6 said 77 % — BAS is not reliable; use cmd 6). MTU negotiated 512 (CoreBluetooth). - On connect (unsolicited, in this order): cmd 37 devinfo
9800 73010600088d daa5 01a0(fw 152, mfr 0xA5DA, model 0xA001), a headerless 13-B frame0c 9800 73010600088d 3a00 4800, cmd 68 name0a "SR116-088D", cmd 55 features00 01(remote-recording off, storage on), cmd 29 step snapshot, cmd 6 battery4d 01(77 %, charging). - Echo semantics: settings commands (1, 8, 26, 30) are acknowledged by the ring echoing the SAME frame back
(set-time echoes decoded Y/M/D/h/m/s
ea07 09 13 0b 1b 33= 2026-09-19 11:27:51 local). - History syncs:
33/34/36 + day→ item packets (eachidx=1,total=1, several items per packet) → terminator 42/43/45 carrying the day's 00:00 local timestamp (80d0ad6a0000= 2026-09-19 00:00 EDT). For the in-progress day the sleep sync returned items but no 43 → driver idle-closes after 3 s. Sleep bucket = 300 s (036aae6a…→2f6bae6a…), quality 0 + motion 2 while on the charger. - Proven acks: cmd 69 alarms → empty list; cmd 4 vibrate mode 1 → echo
01(physical vibration not yet confirmed by a human); cmd 7 open HR → ack, no cmd-11 samples while on charger / off finger (expected). - Not yet proven (need the ring on a finger): live HR values, SpO2 (23→24), temperature (20→21), health records (36), step buckets (33) with non-zero data, button events (40), audio (52).
§8–9. Open items
- LIVE proof of the on-finger rows (§7b last bullet) — needs a ring worn for ~10 min near the Mac mini.
- cmd 9 payload; exact
vibrateLEDmodes (owner to confirm buzz); which byte in cmd 2 is which; svc 0xFF12 purpose. - Chipset detail (DA14531 vs DA14585) — SUOTA version char read returned empty; ask the ring for 0x2A50 only.
§7c. Audio path — LIVE PROVEN 2026-09-19 (ring 2 JY-Y3-011A, on charger)
| step | wire | result |
|---|---|---|
2F 00 00 queryAudioState |
← 2F 00 00 |
idle; 3D → 00 00 files |
30 01 controlAudioMode(1 = online) |
← 30 01 00 ack, then 2F → 40 00 for the whole window |
no 0x32/0x33 frames on the charger — but the ring started an offline recording to flash at that instant (state 0x40 = hr7 Z; sa0 maps 64 → onOfflineRecordingCompleted) |
30 00 stop |
← 30 00 00 |
recording continued on-ring (see below), file count → 1 |
40 stopOfflineRecordTransfer · 1 s · 34 getOfflineAudioData |
← 1522 × 34 packets, header total=1522 index=1..1522, 486-B payload each |
18 264 Opus frames = 6 min 05 s; then ring pushes 36 01 00 (upload completed, remaining=1) |
36 01 00 (echo the ring's number, like LoraFit clearOfflineAudioData(remaining)) |
3D → 00 00 |
file deleted |
Audio packet payload (all 3 audio cmds): u32 LE seconds | u16 0000 | 12 × 40 B Opus. The u32 is the ring clock
(local-epoch, same base as cmd 1) at recording start, then +1 per packet — a sequence counter seeded with the start
time, not a per-packet timestamp (1522 packets = 240 ms of audio each, counter advanced 1522). First packet of the
capture read 1789818700 = 11:51:40 local = the second my 30 01 was written. Opus TOC byte is 0xB8 on every frame
(CELT fullband, 20 ms, mono) — libopus decodes it; ffmpeg accepts our Ogg wrap (ring.audio.write_ogg_opus).
Content check: -31.6 dBFS mean, clicks + a harmonic burst around 100 s → the mic IS live in the charger cradle.
So: "mic gated by charging" is wrong; what is gated is streaming — on the charger mode 1 falls back to on-ring recording.
Open: how long the on-ring recording ran (6 min for an 8-s request → probably until the 0x2F poll/idle-close or a fixed cap; retest worn).
Driver: ring-cli audio-state | audio-record --seconds N | audio-pull --out memo.opus (+ .wav via ffmpeg). Audio
cmds 0x32/0x33/0x34 bypass the transport's multi-packet reassembly (each packet has its own header; PASSTHROUGH_CMDS).
§7d. macOS bonds the ring as a "Mouse" — and that is the BEST transport on a Mac
The ring advertises HID 0x1812 and reports PnP ID VID 0x05AC / PID 0x0220 (Apple's IDs). During any CoreBluetooth
session the RING sends an SMP Security Request (HID habit); bluetoothd pairs Just-Works silently, files the ring as a
Mouse and from then on auto-connects it whenever it advertises (system_profiler SPBluetoothDataType → Connected,
Minor Type Mouse; blueutil --paired | grep -iE 'JY|SR116'). Re-bonding happened within 3 min of "Forget" with no BAS
read, so it is not something the driver can avoid.
Consequences:
- Phones/LoraFit/other centrals cannot see the ring while the Mac holds it. To hand it over: System Settings →
Bluetooth → ⓘ → Forget This Device (blueutil --unpair/--disconnect silently no-op on LE bonds), then keep the Mac's
CoreBluetooth apps off it, or turn the Mac's Bluetooth off.
- On the Mac it is a gift: CBCentralManager.retrieveConnectedPeripheralsWithServices([56FF]) returns the held
ring and BleakClient(device).connect() attaches in ~10 ms — no scan, no advertising needed, MTU 512, ring answers
immediately (ring-cli battery end-to-end 0.28 s vs ~10 s via scan). Several processes can attach at once; detaching
(disconnect()) does not drop the system link. ring.transport.system_connected_rings() implements it and
Ring.__aenter__ tries it first (evidence via: system_link), falling back to a 45-s scan.
- The bonded ring stays connected on the charger and off it, as long as it is in range → the bridge never needs to
reconnect; battery cost on the ring side is the HID connection interval macOS negotiates (unmeasured yet).
Decision for the owner: the Mac mini becomes the ring's hub (tiny is the app) — or LoraFit keeps the ring and the Mac only borrows it while the phone is out of range. Both can't hold it at once.