A reverse-engineered reference for the Bluetooth LE protocol used by the Wellue O2Ring-S pulse oximeter (model code T8520, also marketed as "Checkme O2Ring-S" / "OxyLink"). End-to-end working: device info, time set, live SpO2/HR streaming, stored-file listing, and file download — all without bonding or vendor-app round-trips.
This protocol is not the same as the legacy O2Ring (PO1/PO2/PO3 and
older T-series) protocol documented at
farolone/wellue-o2ring-protocol.
The legacy protocol uses GATT service 14839ac4-...; the T8520 doesn't
expose that service at all and instead implements a separate protocol
Wellue calls "OxyII" internally. Every existing open-source O2Ring tool
(MackeyStingray/o2r, farolone/wellue-o2ring-protocol,
ecostech/viatom-ble) targets the legacy service and silently fails
against the T8520.
This document is a partial answer to MackeyStingray/o2r#5, open since 2025-10-16.
Provided for educational and interoperability purposes. This documentation describes a Bluetooth LE protocol implemented by a device the author legally owns. It is published to enable interoperability with software the device-owner runs on their own systems, consistent with 17 U.S.C. § 1201(f) and equivalent provisions elsewhere. The author is not affiliated with Shenzhen Viatom Technology or Wellue. "O2Ring," "Wellue," "Viatom," and related marks are property of their respective owners.
| Capability | Status |
|---|---|
| Discover & connect (no bond) | Verified |
| GET_INFO (serial, fw version, datetime) | Verified |
| GET_BATTERY | Verified |
| SET_UTC_TIME | Verified (round-tripped, byte-exact) |
| GET_FILE_LIST | Verified |
| READ_FILE (start / data / end) | Verified, byte-equivalent to ViHealth export |
| Live SpO2 + HR stream | Verified |
cmd=0xFF auth derivation |
Verified (algorithm reproduced from scratch) |
cmd=0x10 setup step |
Send-and-ack only, exact purpose unknown |
| GET_CONFIG / SET_CONFIG | Verified (plaintext on this firmware; AES path unused) |
| Real-time waveform / PPG | Documented; not yet exercised |
FACTORY_RESET (cmd=0xE3) |
Verified — wipes settings AND recordings (no settings-only path) |
FACTORY_RESET_ALL (cmd=0xEE) |
Verified — powers ring off, needs USB to wake; do not issue |
| OTA | Documented; not yet exercised |
End-to-end byte equivalence between BLE-pulled files and the vendor app's USB export was verified via SHA-256 across two real recordings (763 B and 2647 B; matched in both formats with zero sample mismatches across 22,541 samples on a separate recording).
The T8520 advertises in two distinct modes depending on state:
- Recording mode (worn on a finger, recording in progress):
advertised as a public-style address with local name
T8520_<last4>(e.g.T8520_e85a). Manufacturer ID0x036F(Viatom). The GATT layout exposed in this mode is stripped — OxyII service is not reliably discoverable. - OxyII / sync mode (idle, or briefly after a recording finalizes):
advertised as a Random Static address with local name
S8-AW <suffix>and manufacturer ID0xF34E. This is the mode that exposes the full OxyII service and supports file transfer.
The Random Static address rotates on every factory reset, so any client must scan-and-match by service UUID, manufacturer ID, or name prefix — do not hardcode a MAC.
The user does not need to "trigger finalization" or any special action for OxyII mode. The ring exposes it whenever it's awake — wearing it or pressing the button is enough.
The OxyII service:
| Role | UUID |
|---|---|
| Service | E8FB0001-A14B-98F9-831B-4E2941D01248 |
| Write (write-without-response) | E8FB0002-A14B-98F9-831B-4E2941D01248 |
| Notify | E8FB0003-A14B-98F9-831B-4E2941D01248 |
Connection requirements:
- LE link only. No SMP pairing or bonding required.
own_address_type = PUBLICworks against modern controllers (Intel BT 5.4 verified). Random own-addresses also accepted in tests.- ATT MTU = 517 must be negotiated before file transfer. This is the single most non-obvious gotcha in the entire protocol — see below.
- CCCD on the notify characteristic must be written
0x0100(Notification only). Some BLE stacks default to0x0001-LE which the ring's state machine silently rejects.
READ_FILE_DATA (cmd=0xF3) replies are 512-byte chunks. If the central
hasn't negotiated an ATT MTU large enough to hold a chunk in one PDU,
the ring silently drops READ_FILE_START (cmd=0xF2) requests before
they can produce a reply. Every other command in the protocol has
≤60-byte replies and works fine at the default MTU=23, which masks the
problem and produces the misleading symptom "everything works except
file transfer."
The fix: immediately after connecting, before any GATT discovery, issue an ATT MTU exchange requesting 517. Vendor app requests 517 / accepts 247; either is sufficient.
In Bumble:
peer = Peer(connection)
await peer.request_mtu(517)Some BLE stacks (notably Bumble) do not auto-negotiate MTU; others
(notably Bleak on macOS / iOS) do. Whichever stack you use, verify with
btmon or Wireshark that an ATT Exchange MTU Request packet is on the
wire shortly after the LE connection is established. If it isn't,
file-transfer commands will fail silently.
The "silent drop" mechanism was confirmed on firmware 2D010002 via
a control test that connected to the ring without issuing
ATT Exchange MTU Request. With the MTU left at the default 23:
cmd=0x10,cmd=0xC0,cmd=0x00, andcmd=0xF1all returned full replies — including a 48-bytecmd=0x00fingerprint frame and a 73-bytecmd=0xF1 GET_FILE_LISTframe for four files. So notifications larger than the 20-byte ATT cap are reassembled transparently by BlueZ/Bumble above the ATT layer; "MTU=23 truncates big replies" is not the gating mechanism.cmd=0xF2 READ_FILE_STARTproduced literal zero bytes for all four files within a four-second window — not a partial buffer, not a truncated reply. The ring is silent. Whatever check Wellue's firmware does at the F2 entry point, it gates on something MTU- or DLE-related at the link/ATT layer that satisfies a 517-byte exchange but not a 23-byte one. F2's own reply is small enough to fit either way — the gate appears to anticipate the 512-byte F3 chunks that follow.
A field report on firmware 2D010003
(issue #1)
describes a different gate: cmd=0xF2 succeeds at MTU=23 there, and
cmd=0xF3 chunks reassemble fine via BlueZ; the cap on that firmware
manifests as a per-BLE-connection F3 throughput limit (the budget runs
out mid-transfer; reconnect-and-resume completes the file). The two
observations are not necessarily contradictory — the F2 gate may have
been relaxed between 2D010002 and 2D010003, with a separate per-
connection budget always present and only visible once F2 is no
longer the blocker.
Practically, requesting MTU=517 immediately after connect is the universally workable path on every firmware tested so far.
When MTU is 517, every reply observed in this protocol fits in a single
ATT Handle Value Notification PDU — including the 512-byte
READ_FILE_DATA chunks. A simple "decode each notify as one frame"
loop works. If you negotiate a smaller MTU than 517, you will need to
reassemble multi-PDU replies before decoding.
Every request and response uses the same envelope:
+------+-----+------+------+-----+--------+--------+----------+-----+
| 0xA5 | cmd | ~cmd | flag | seq | len_lo | len_hi | payload | crc |
+------+-----+------+------+-----+--------+--------+----------+-----+
1 1 1 1 1 1 1 len bytes 1
| Field | Size | Description |
|---|---|---|
| Lead | 1 byte | Always 0xA5. |
cmd |
1 byte | Opcode. |
~cmd |
1 byte | Bitwise complement of cmd. The device validates this. |
flag |
1 byte | 0x00 for app→device requests; 0x01 for device→app responses. |
seq |
1 byte | Counter the host sets per request. The device echoes the value back in its reply, but does not enforce monotonicity — observed traffic reuses values across requests (e.g. seq=0 for both cmd=0xFF and cmd=0x10, seq=1 for both cmd=0xC0 and cmd=0x00). Re-implementations may either increment per request or set it to a constant; both work. |
len |
2 bytes | Little-endian payload length (excludes header and CRC). |
payload |
len bytes |
Command-specific. May be plaintext or AES-encrypted (see below). |
crc |
1 byte | CRC-8 over the full frame including the 0xA5 lead and excluding only the trailing CRC byte itself. |
Header is 7 bytes, total frame overhead is 8 bytes.
Polynomial 0x07, init 0x00, no input/output reflection, no XOR-out.
Same as standard CRC-8 / "ITU" CRC-8.
def crc8(data: bytes) -> int:
crc = 0
for b in data:
crc ^= b
for _ in range(8):
crc = ((crc << 1) ^ 0x07) if (crc & 0x80) else (crc << 1)
crc &= 0xFF
return crcA common mistake (one this author made) is to use XOR instead — that matches the legacy O2Ring's checksum, not OxyII. The two are completely different. Verify your CRC against this 5-byte fixture:
| Bytes | CRC |
|---|---|
A5 E1 1E 00 02 00 00 (GET_INFO request, no payload, seq=2) |
BF |
Encryption is per-command, not "session-wide after auth." Each command is sent either plaintext or with its payload AES-128-ECB-PKCS7 encrypted. The frame envelope (header + CRC) is computed over whatever payload bytes (plaintext or ciphertext) end up on the wire.
In practice, every command verified against the T8520 is sent
plaintext, including GET_CONFIG / SET_CONFIG. The only exception
is cmd=0xFF, a one-way auth/handshake message that uses a XOR scheme
rather than AES. The vendor SDK has AES paths for SET_CONFIG and a few
other administrative commands, but they only activate when cmd=0xFF
returns a session key — which T8520 firmware 2D010001 (and, per the
upstream author's captures, 2D010002) never does, so the SDK's
plaintext fallback is what ends up on the wire. Other firmware variants
may behave differently.
cmd=0xFF is a one-way message (no reply ever observed) sent
immediately after connect to put the ring's state machine into the mode
that accepts file-transfer commands. The 16-byte payload is constructed
as follows:
LEPUCLOUD_MD5 = MD5("lepucloud") # 16-byte constant
session_key = derive_session_key(serial_prefix, ts)
auth_payload = bytewise XOR(session_key, LEPUCLOUD_MD5)
Where derive_session_key is:
def derive_session_key(serial_prefix: str, ts: int) -> bytes:
"""16 bytes:
[0..7] = MD5("lepucloud") at even indices [0,2,4,6,8,10,12,14]
[8..11] = first 4 ASCII bytes of `serial_prefix`
[12..15] = (ts >> 0), (ts >> 1), (ts >> 2), (ts >> 3)
"""
md5 = hashlib.md5(b"lepucloud").digest()
key = bytearray(16)
for i in range(8):
key[i] = md5[i * 2]
key[8:12] = serial_prefix[:4].encode("ascii")
for n in range(4):
key[12 + n] = (ts >> n) & 0xFF
return bytes(key)serial_prefix is a 4-byte ASCII string. The recommended portable
default is the literal string "0000", which the device accepts
without a prior GET_INFO. The vendor app sometimes substitutes the
first 4 characters of the device's actual serial number (obtainable
from a prior unencrypted GET_INFO call); either form works.
ts is the current Unix epoch in seconds.
The peculiar >> 0, 1, 2, 3 pattern (rather than the usual >> 0, 8, 16, 24 byte-extract) is a faithful port of the vendor implementation.
Whether this is a bug in their code or an intentional weak-time-coupling
scheme is unknown; either way, both sides compute it the same way and
the ring accepts it, so re-implementations should match.
The frame is sent as cmd=0xFF, plaintext envelope, 16-byte XOR'd
payload, no reply. Then the ring is in a state that accepts cmd=0xF1 /
cmd=0xF2 / cmd=0xF3 / cmd=0xF4.
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
def aes_encrypt(plaintext: bytes, key: bytes) -> bytes:
return AES.new(key, AES.MODE_ECB).encrypt(pad(plaintext, 16))
def aes_decrypt(ciphertext: bytes, key: bytes) -> bytes:
return unpad(AES.new(key, AES.MODE_ECB).decrypt(ciphertext), 16)Key is always 16 bytes. PKCS7 padding. ECB mode (no IV).
| Opcode | Name | Payload | Reply | Notes |
|---|---|---|---|---|
0x00 |
GET_CONFIG | empty | 40 bytes plaintext | Ring settings struct (brightness, alarms, motor, etc). See section below. The reply is stable across sessions on a given device only because settings don't change unless you flip them in the vendor app. |
0x01 |
SET_CONFIG | 8 bytes plaintext | ack | Writes one field of the settings struct. See section below. |
0x03 |
LIVE_SAMPLES_A | empty | 6-byte header + N bytes | Real-time PPG waveform, ~100 samples/sec. Header is 00 00 00 00 <count_lo> <count_hi>. No parsed SpO₂/HR. |
0x04 |
LIVE_SAMPLES_B | empty | 24-byte header + 2-byte count + N bytes | 24-byte header carries the parsed live SpO₂ / HR / motion / battery (see section below). Body is the same ~100 Hz PPG waveform as 0x03. Do not call before cmd=0xF2 in a file-transfer flow — puts the ring in a state that gates out file commands until disconnect. |
0x05 |
(history?) | empty | 922 bytes | u8 count + 102 × 9-byte records, each starting 03 00 .... Live (changes per call). Purpose unknown. |
0x10 |
(setup) | 1 byte 0x00 |
0-byte ack | Required in the post-auth handshake. Purpose unknown. |
0xC0 |
SET_UTC_TIME | 8 bytes (see below) | ack | Sets the ring's RTC. |
0xE1 |
GET_INFO | empty | 60 bytes plaintext | Serial, firmware version, datetime, etc. |
0xE3 |
FACTORY_RESET | empty | empty ack (sometimes drops link) | Wipes settings AND every recording on flash — no settings-only path. The vendor app's "Restore factory settings" button. See section below. |
0xE4 |
GET_BATTERY | empty | 4 bytes | Battery level + status. |
0xEE |
FACTORY_RESET_ALL | empty | none | Do not issue. Powers the ring off and refuses to re-advertise until woken by USB. See section below. |
0xF1 |
GET_FILE_LIST | empty | u8 count + N × 16 bytes | Each slot: 14-byte ASCII timestamp + 2 zero pad. |
0xF2 |
READ_FILE_START | 20 bytes (see below) | 4 bytes file size + metadata | Opens a file for reading. Requires MTU ≥ 517. |
0xF3 |
READ_FILE_DATA | 4-byte LE offset | up to 512-byte chunk | Loop until empty reply or offset + len >= file_size. |
0xF4 |
READ_FILE_END | empty | ack | Closes the current file. |
0xFF |
AUTH | 16-byte XOR payload | none | Required before file transfer. See encryption section. |
The vendor SDK exposes additional commands that haven't been exercised end-to-end here:
| Opcode (per SDK) | Name | Notes |
|---|---|---|
| (not yet captured) | GET_RT_PARAM | Real-time parameters (one-shot). |
| (not yet captured) | GET_RT_WAVE | Real-time waveform stream. |
| (not yet captured) | GET_RT_PPG | Real-time PPG (raw photoplethysmogram). |
| (not yet captured) | RESET | Soft reset. |
These names correspond to features the vendor app exposes. Their opcode bytes and payload layouts will need to be captured from an HCI snoop of the vendor app exercising the corresponding feature; that work hasn't been done here.
60-byte plaintext payload:
| Offset | Size | Field |
|---|---|---|
| 0–1 | 2 | u16 size/count marker (0x0042 on observed firmware) |
| 2–3 | 2 | u16 protocol version |
| 4–7 | 4 | flags / type bits |
| 8 | 1 | separator (0x00) |
| 9–16 | 8 | firmware version, ASCII (e.g. "2D010002") |
| 17 | 1 | separator (0x01) |
| 18–19 | 2 | u16 LE — battery / capacity |
| 20–21 | 2 | u16 LE — storage / total samples |
| 22–23 | 2 | flags |
| 24–31 | 8 | datetime: year-LE (2 bytes), month, day, hour, minute, second, byte-7 (purpose unclear; safe to ignore) |
| 32–35 | 4 | build / model code |
| 36 | 1 | reserved |
| 37 | 1 | u8 serial-number length (typically 0x0A = 10) |
| 38…37+sn_len | sn_len | serial as ASCII (e.g. "25B2303210") |
| remainder | … | zero pad |
Re-implementations should treat any field not listed here as opaque and not relied upon. Some ranges may carry data on firmware variants this author hasn't tested.
Returns the ring's current configuration struct — not a fixed device fingerprint, as previously suspected. The reply is 40 bytes; the first 20 are decoded below. Bytes 20+ have not been characterised; treat as opaque.
[0] u8 packed alarm-routing flags
bit 0 SpO₂ alarm vibrates motor
bit 1 SpO₂ alarm sounds buzzer
bit 4 HR alarm vibrates motor
bit 5 HR alarm sounds buzzer
[1] u8 SpO₂ low alarm threshold (percent)
[2] u8 HR low alarm threshold (bpm)
[3] u8 HR high alarm threshold (bpm)
[4] u8 motor (vibration intensity)
[5] u8 buzzer (volume level)
[6] u8 display mode (orientation / layout enum)
[7] u8 brightness mode (enum)
[8] u8 storage interval (recording sample period enum)
[9] u8 time-zone byte (interpretation unclear)
[10] u8 autoSwitch
[11] u8 algAvgTime (SpO₂ averaging window)
[12] u8 countDownTime
[13] u8 lrModel (worn on left vs right hand)
[14] u8 motorSwitch
[15] u8 motorThr (motion detection threshold)
[16] u8 invalidSignalSwitch
[17..18] u16 LE invalidSignalTimeThr (seconds)
[19] u8 funcSwitch
[20..] bytes reserved / firmware-variant (treat as opaque)
See example_config.py for an end-to-end read.
8 bytes, little-endian: [field_index, 0, 0, 0, value, 0, 0, 0].
On T8520 firmware 2D010001 the payload goes plaintext.
The vendor SDK has an AES-128/ECB/PKCS7 path keyed by the AUTH session
key, but this firmware doesn't reply to AUTH with one, so the SDK's
fallback (plaintext) is what actually goes on the wire. Sending an
AES-encrypted payload anyway is silently dropped — the frame acks but
the value doesn't change.
Field indices are a separate enum from the GET_CONFIG byte offsets above. Verified writeable:
| Index | Field | Notes |
|---|---|---|
| 1 | SPO2_SWITCH | toggles motor/buzzer bits in alarm_flags (byte 0) |
| 2 | SPO2_LOW | SpO₂ low alarm threshold (percent) |
| 3 | HR_SWITCH | toggles motor/buzzer bits in alarm_flags (byte 0) |
| 4 | HR_LOW | HR low alarm threshold (bpm) |
| 5 | HR_HIGH | HR high alarm threshold (bpm) |
| 6 | MOTOR | vibration intensity |
| 8 | DISPLAY_MODE | screen layout / orientation enum |
| 9 | BRIGHTNESS | 0=Low, 1=Medium, 2=High |
| 10 | INTERVAL | recording sample period enum |
Value ranges other than brightness aren't documented here — read
GET_CONFIG before and after a write to discover them empirically. See
example_config.py.
Empty payload, plaintext. The ring usually returns an empty ack and stays connected; sometimes it drops the link as it finishes the wipe. Either way, callers should be prepared to re-scan and re-handshake.
What it does on firmware 2D010001: wipes the settings struct AND
every stored recording on flash. There is no settings-only
path — both bits of state always get cleared together, despite the
naming. This is the opcode behind the vendor app's user-facing
"Restore factory settings" button.
Empty payload, no reply. Observed against firmware 2D010001: the
ring powers itself off and refuses to re-advertise. The only way to
bring it back was to plug it into USB power.
The vendor app does not expose this; it appears to be a factory / RMA path. Documented here only so the opcode isn't accidentally reused for something else.
Field offsets identified by polling cmd=0x04 and matching bytes
against the SpO₂ / HR / battery values shown on the ring's display:
[0..1] u16 LE packet counter (increments by 1 per reply)
[2..4] bytes 00 00 02 constant in observed traffic
[5] u8 ring state flag
0x01 = idle (file commands work)
0x03 = a file handle is open (cmd=0xF1 silently hangs;
see "the F1 wedge" below)
[6] u8 SpO₂ percent (matches on-ring display)
[7] u8 motion / activity level (≈10 at rest, ≥50 when shaking)
[8] u8 heart rate bpm (matches on-ring display)
[9] u8 pad (always 0)
[10] u8 0x44 constant in our captures (possibly perfusion)
[11] u8 secondary motion-correlated counter
[12] u8 pad (always 0)
[13] u8 battery percent (matches cmd=0xE4 byte[1])
[14..23] zeros padding
[24..25] u16 LE PPG sample count
[26..] bytes PPG samples (same encoding as cmd=0x03's body)
cmd=0x03 returns the same waveform body with only a 6-byte preamble
(00 00 00 00 <count_lo> <count_hi>) and no parsed metrics — use
cmd=0x04 if you want the live values that the display shows.
8 bytes:
| Offset | Size | Field |
|---|---|---|
| 0–1 | 2 | u16 LE — year |
| 2 | 1 | month (1–12) |
| 3 | 1 | day (1–31) |
| 4 | 1 | hour (0–23) |
| 5 | 1 | minute (0–59) |
| 6 | 1 | second (0–59) |
| 7 | 1 | unknown — vendor app sends 0xCE; 0x00 also accepted with no observable side-effect |
Empirical observation (set/read/diff against the ring's RTC, both via
the protocol's own datetime field in GET_INFO and visually against the
ring's display): the ring stores the time fields verbatim. There is
no internal timezone conversion. Whatever wall-clock value you send is
what the ring's display reads back, what the next recording's filename
will be (YYYYMMDDhhmmss), and what subsequent GET_INFO calls return.
If you want machine-friendly filenames, send UTC. If you want a display-readable clock, send local time. The ring itself doesn't care which.
[0] u8 file count
[1..] N × 16-byte slots
Each slot:
bytes 0..13 ASCII timestamp YYYYMMDDhhmmss
bytes 14..15 zero pad
The timestamp is the recording start time in whatever wall-clock timezone the ring was set to at recording time — same convention as SET_UTC_TIME.
File size is not in this reply. It's reported by READ_FILE_START.
20 bytes:
| Offset | Size | Field |
|---|---|---|
| 0–15 | 16 | filename slot. The 14-byte ASCII timestamp returned by GET_FILE_LIST (e.g. 20260427105949) occupies bytes 0–13; bytes 14–15 are zero pad. |
| 16–19 | 4 | u32 LE — file type (only the low byte is ever set in observed traffic) |
File type values (from vendor SDK):
| Value | Name | Description |
|---|---|---|
| 0 | OXY | Oximetry (SpO2 + HR + motion) — the main sleep recording |
| 1 | PPG | Raw photoplethysmogram |
| 2 | (reserved) | Observed in SDK constants; purpose unknown |
The reply's first 4 bytes are a u32 LE file size. Remaining bytes are metadata (TBD; appears to include sample count and a status flag) — for straight file pulls, only the size is needed.
Send a 4-byte LE offset starting at 0. The ring replies with up to
512 bytes (less for the final chunk). Increment your offset by the
number of bytes received; continue until the reply is empty or your
offset reaches the file size advertised by READ_FILE_START.
collected = bytearray()
offset = 0
while offset < file_size:
chunk = await read_file_data(offset)
if not chunk:
break
collected.extend(chunk)
offset += len(chunk)Empty payload. The ring acks. Required before a subsequent READ_FILE_START on a different file — without it, the second open is silently rejected.
This is the post-MTU-exchange flow that the author has verified
end-to-end against a T8520 with firmware 2D010002:
1. ATT MTU exchange (517)
2. Service discovery
3. CCCD write 0x0100 on notify characteristic
4. cmd=0xFF (auth, 16-byte XOR payload, seq=0) no reply
5. cmd=0x10 (1-byte 0x00, seq=0) 0-byte ack
6. cmd=0xC0 SET_UTC_TIME (8 bytes, seq=1) ack
7. cmd=0x00 GET_CONFIG (empty, seq=1) 40-byte config struct
8. cmd=0xF4 READ_FILE_END (empty, seq=2) ack ← clears any phantom open handle
9. cmd=0xF1 GET_FILE_LIST (empty, seq=3) count + N × 16-byte slots
10. For each file:
cmd=0xF2 READ_FILE_START (20 bytes, seq=N) file size + metadata
loop:
cmd=0xF3 READ_FILE_DATA (4-byte offset, seq=N+1) ≤512-byte chunk
until offset >= file_size
cmd=0xF4 READ_FILE_END (empty, seq=M) ack
Calling cmd=0x04 (live samples) before cmd=0xF2 puts the ring into a
live-streaming state that gates out file commands until disconnect.
Either do live sampling or file transfer in a given session, not
both.
cmd=0xE1 GET_INFO can be issued at any point in the flow without
disrupting state.
The 40-byte plaintext reply to cmd=0x00 should be consumed before
entering the F3 read-loop. Implementations that send cmd=0xF2 and
cmd=0x00 concurrently and then loop on cmd=0xF3 have been observed
mistaking the 40-byte reply for an F3 chunk with block_len=40. The
underlying transfer still completes (the reply lands at file offset 0
and gets overwritten by F3 chunks starting at offset 40), but it's a
confusing edge case worth avoiding by ordering the post-auth handshake
strictly before the file-transfer loop.
After the ring writes its own overnight recording (autonomously, while
worn — i.e. the typical case for a sync), it leaves a file handle open
in firmware. While this bit is set, cmd=0xF1 GET_FILE_LIST is silently
dropped — no GATT error, no reply, just a 5 s timeout. The wedge
survives BLE disconnect/reconnect and persists indefinitely until either
a ring power-cycle or an explicit cmd=0xF4 READ_FILE_END.
cmd=0xF4 is a no-op when nothing is open, so sending it unconditionally
at the top of every sync is safe and avoids the wedge entirely.
The state is also observable in cmd=0x04's 24-byte header at offset
[5]: 0x01 means idle, 0x03 means a file handle is open. (See the
LIVE_SAMPLES_B header layout above.) Verified on firmware 2D010001.
Two SpO2-recording formats are seen in the wild from this device family:
10-byte header followed by 3-byte sample records, 1 sample/second.
Header (10 bytes):
01 03 00 00 00 00 00 00 04 00
Body (3 bytes per record):
byte 0 spo2 (percent, 0–100, 0 = invalid)
byte 1 heart rate (bpm, 0 = invalid; firmware also emits 0xFF / 255
as a no-finger-contact sentinel — clamp HR to ~25..220, or a
255 spike appears in charts at session edges)
byte 2 status flags (low bits = invalid/motion/etc; nonzero = sample
should be treated as suspect)
The 04 00 at offset 8–9 of the header appears to be the sample
interval in some unit (possibly tenths-of-a-second), but values other
than 04 00 haven't been observed.
Every finalised Format A file ends with a 48-byte session-stats trailer
that the vendor app uses for its session-summary PDF. Field offsets are
relative to the start of the trailer (file_size − 48).
| Offset | Size | Field |
|---|---|---|
| 0–3 | 4 | opaque per-recording bytes (variable across files; byte 3 always 0x00; likely a hash or per-recording id) |
| 4–7 | 4 | sub-magic 48 12 5a da |
| 8–9 | 2 | opaque per-recording bytes (variable) |
| 10–11 | 2 | u16 LE counter — increments occasionally; not strictly per-recording (see below) |
| 12–13 | 2 | u16 LE — total samples = total seconds of the recording |
| 14–15 | 2 | reserved (always 0x00 0x00; consistent with bytes 12–15 forming a u32 sample count) |
| 16–18 | 3 | format-version stamp 01 01 03 |
| 19–33 | 15 | reserved (zero) |
| 34 | 1 | avg SpO₂ (rounded integer) |
| 35 | 1 | min SpO₂ (matches body-min byte-exact) |
| 36 | 1 | count of desats ≥ 3% |
| 37 | 1 | count of desats ≥ 4% |
| 38 | 1 | reserved (zero) |
| 39–40 | 2 | u16 LE — total seconds with SpO₂ < 90% |
| 41 | 1 | distinct desat episodes < 90% |
| 42 | 1 | O₂ Score × 10 (0xFF = N/A; e.g. on short sessions) |
| 43–46 | 4 | reserved (zero) |
| 47 | 1 | avg HR (rounded integer) |
This mapping was contributed by @knifebunny in
issue #1
(firmware 2D010003) with cross-validation work by Ilya across 27
vendor-app PDF exports. Verified independently against eight separate
recordings on this author's 2D010002 ring: sub-magic offset,
total-samples, format stamp, min-SpO₂, drop counts, and N/A
score-x10 are byte-exact; avg-SpO₂ and avg-HR agree with body-derived
means within ±1.
On the offset-10 counter. The originating report describes it as "monotonic per recording." On the verification captures used here it incremented exactly once across eight recordings spanning twenty-two hours, staying flat across seven back-to-back recordings within a single day. The likeliest interpretation is a per-power-on or per-wake counter rather than a recording id. Use it as a non-decreasing clustering hint, not as a unique recording key.
Anchor as the finalisation predicate. The ring will sometimes
report a file's full byte count via cmd=0xF2 before the trailer
has flushed. Size-equality alone is not a reliable "this file is
complete" check — the presence of the 48 12 5a da sub-magic at
file_size − 44 is. Files that hit full size without the anchor
should be re-pulled in a later sync cycle.
Exists in older firmware and other Wellue/Viatom oximeters. 40-byte
header with structured datetime/duration, then 5-byte records [spo2, hr, invalid, motion, vibration] at 1 sample / 4 seconds.
The T8520's BLE READ_FILE flow has been observed only producing format A. If you see format B from a T8520, please open an issue on this repo.
oxyii_protocol.py is a pure-function reference:
frame codec, CRC, AES helpers, derive_session_key, opcode constants,
and parsers for GET_INFO and GET_FILE_LIST. No I/O, no BLE library
dependency — drop into a project, layer your BLE library of choice on
top. Tested under Python 3.10+, depends on pycryptodome for AES.
example_pull.py is a minimal end-to-end example
that uses Bumble to pull all stored
recordings off a ring. Roughly 300 lines including BLE connection
plumbing.
example_config.py demonstrates cmd=0x00 GET_CONFIG (decoded against the layout above) and, behind an opt-in
--set-brightness flag, cmd=0x01 SET_CONFIG for the brightness
field. Read-only by default.
A handful of fields are observed but their meaning is not verified. Listed here so re-implementers can treat them as opaque rather than guessing:
cmd=0x10semantics. Required in the post-auth handshake — skip it andcmd=0xF2is silently rejected — but the 1-byte0x00payload carries no obvious information.- Byte 7 of
SET_UTC_TIME. Both0xCE(what the vendor app sends) and0x00are accepted with no observable difference in display, filename format, or RTC behavior. Treat as unused. GET_INFOoffsets 4–7, 22–23, 32–35. Likely model code, flag bits, and capacity descriptors, but values do not vary across the captures available to this author. Keep them in therawfield and parse only what you need.
farolone/wellue-o2ring-protocol— protocol writeup for the legacy O2Ring (different protocol; useful context).MackeyStingray/o2r— legacy O2Ring CLI; issue #5 is where this writeup answers from.- Bluetooth SIG company identifiers —
0x036F(Viatom, used in recording-mode advertising),0xF34E(used in OxyII-mode advertising; unassigned in the SIG database, presumed vendor-internal).
Issues and PRs welcome. Particularly interested in:
- Captures from firmware variants this writeup hasn't covered.
- HCI snoops of GET_CONFIG / SET_CONFIG / RT_WAVE flows from the vendor app, to fill in the unverified opcodes.
- Confirmation (or contradiction) on devices other than the author's
T8520 with firmware
2D010002.
When opening an issue with a snoop, please redact your serial number and any portion of the OxyII Random Static address that could uniquely identify your hardware.