Table of Contents

SleepU — live BLE protocol

Wellue / Viatom SleepU (sleep oxygen monitor). Host-side contract for a live SpO₂ + pulse connection. Captured on-device 2026-08-20 (advertised SleepU 0920, SN 25062B0920, FW 1.3.0).

This page is enough to scan, connect, poll, decode live readings, and handle protocol errors. It does not cover overnight file download, PPG/waveform, or device settings.

Device

Item Value
Product SleepU Sleep Oxygen Monitor (SpO₂ + heartbeat)
BLE name Starts with SleepU (observed SleepU 0920)
Identity (INFO) Model 1654, SN 25062B0920, SoftwareVer 1.3.0, SPCPVer 1.4
Family Lepu / Viatom Oxy (same GATT as O2Ring / KidsO2 / Checkme O2)
Connections One GATT client. Disconnect ViHealth / other apps first.
Encryption Optional OEM path in the vendor SDK. Retail unit captured here is plaintext.
Concurrency Do not mix command types while the 1 Hz live poll is running. Two back-to-back valid 0x17 writes both got live replies; unknown / bad-CRC writes return an error frame (CMD 0x01).

Scan by local name containing SleepU, or by the service UUID below. Do not store the MAC as the only selector (address type not verified).

Put the probe on a finger before expecting valid SpO₂. Packets still arrive with no usable pulse; hide the value when spo2 == 0 (the worn bit can stay 1).

GATT

Custom service (not a SIG pulse-oximeter profile).

Role UUID Properties to use
Service 14839ac4-7d7e-415c-9a42-167340cf2339
Write (host → device) 8b00ace7-eb0b-49b0-bbe9-9aee0a26e1a3 Write without response
Notify (device → host) 0734594a-a8e7-4b1a-a6b1-cd5243059a57 Notify; enable CCCD

Connection sequence

  1. Scan until a device named SleepU* appears (or the service UUID is advertised).
  2. Connect as central; discover the service and the two characteristics.
  3. Subscribe to Notify (0734594a-…) before the first write.
  4. Every 1 second, write the 8-byte live-param request on Write (8b00ace7-…), no write-response.
  5. Reassemble notify chunks into one frame (see MTU).
  6. Check header, CMD XOR, length, and CRC-8.
  7. If CMD is 0x01: protocol error (see below). Do not decode as live data.
  8. If CMD is 0x00 and LENGTH is 13: live payload. Publish SpO₂ / pulse only when spo2 != 0 (also treat pr == 0 as no value).
  9. Unsubscribe and disconnect when the live view stops (the device stays awake while connected).

Do not send INFO, file, or settings commands while the 1 Hz loop is running. INFO is optional and is not required to start live polling.

Packet frame

Every request and response uses this layout. Integers are little-endian.

Offset Size Field Notes
0 1 HEADER 0xAA = host → device (request). 0x55 = device → host (response).
1 1 CMD Request live-param = 0x17. Response is not an echo: 0x00 = data (live or INFO), 0x01 = protocol error.
2 1 CMD XOR Must equal CMD XOR 0xFF (0x170xE8; 0x000xFF; 0x010xFE).
3 2 BLOCK 0x0000 for live-param and errors.
5 2 LENGTH Payload length in bytes.
7 LENGTH PAYLOAD Command-specific.
7+LENGTH 1 CRC CRC-8 of all bytes before this one (header through payload).

Total frame size = 8 + LENGTH (live 21, error 12, INFO 520).

Response CMD dispatch

Response CMD Meaning Typical LENGTH
0x00 Data. Live params if LENGTH 13; INFO JSON if LENGTH 512. 13 or 512
0x01 Protocol error. Payload is a u32 LE code. Never parse as SpO₂. 4

CRC-8

def crc8(data: bytes) -> int:
    crc = 0
    for byte in data:
        chk = crc ^ byte
        crc = 0
        if chk & 0x01: crc = 0x07
        if chk & 0x02: crc ^= 0x0E
        if chk & 0x04: crc ^= 0x1C
        if chk & 0x08: crc ^= 0x38
        if chk & 0x10: crc ^= 0x70
        if chk & 0x20: crc ^= 0xE0
        if chk & 0x40: crc ^= 0xC7
        if chk & 0x80: crc ^= 0x89
    return crc

MTU / notify reassembly

Default ATT payload is 20 bytes.

Frame Size How it arrives
Live 0x17 data 21 B 20 + 1
Error CMD 0x01 12 B One notify
INFO 0x14 520 B Many 20-byte notifies (LENGTH 512 + 8-byte header/CRC)

Buffer notify data. A frame is complete when you have at least 7 bytes and len(buffer) >= 7 + LENGTH + 1. Then slice that many bytes and parse. Do not treat a 20-byte prefix as a full live packet. Do not cap LENGTH at 20 — INFO is 512.

Writes larger than 20 bytes must be chunked with a short delay. The live request is 8 bytes and fits in one write.

Live parameter command (0x17)

Poll at 1 Hz. Empty payload.

Request (8 bytes)

aa 17 e8 00 00 00 00 1b

CRC(aa 17 e8 00 00 00 00) = 0x1b

Response (21 bytes)

Payload (13 bytes, after the 7-byte header)

Offsets below are into the payload. Full-frame index = payload offset + 7.

Payload Frame Type Field Meaning
0 7 u8 spo2 SpO₂ percent. 0 = invalid — hide the reading.
1–2 8–9 u16 LE pr Pulse rate (bpm). Treat 0 as no value.
3–6 10–13 u32 LE steps Step counter. Unused for live view. Observed 0.
7 14 u8 battery 0–100 percent. Confirmed by INFO CurBAT (live 6 ↔ 6%).
8 15 u8 batteryState 0 none, 1 charging, 2 complete, 3 low. INFO CurBatState 3 matches.
9 16 u8 vector Motion indicator. Observed 0–4 while sitting.
10 17 u8 pi Perfusion index × 10. Display as raw / 10.0. 0 = invalid. Invalid live often has PI 20.0 (raw 0xC8).
11 bit 0 18 flag worn / state Vendor: 1 lead on, 0 lead off. Not sufficient to show SpO₂ — can stay 1 while spo2 is 0.
11 bits 4–7 18 u4 countDown Observed 0 while measuring.
12 19 packed IV flags bits 0–1 invalid, 2–3 SpO₂, 4–5 HR, 6–7 vector. Observed 0.

PI is perfusion index (pulse strength at the probe), not a second oxygen channel. Use it as signal-quality / confidence.

Valid reading (finger on, settled)

Host wrote aa 17 e8 00 00 00 00 1b. Device notified (reassembled):

55 00 ff 00 00 0d 00 62 41 00 00 00 00 00 07 03 04 08 01 00 21
Field Decode
header / cmd / xor 0x55 / 0x00 / 0xFF
length 13
payload 62 41 00 00 00 00 00 07 03 04 08 01 00
spo2 0x62 = 98 %
pr 0x0041 = 65 bpm
steps 0
battery / batteryState 7 / 3 (low)
vector 4
pi 0x08 / 10 = 0.8
worn bit 0 of 0x01 = 1
CRC 0x21 = crc8(frame without last byte)

Same session, five 1 s polls: SpO₂ 98, 98, 98, 98, 99; PR 65, 64, 63, 59, 58; PI 0.8 then 0.7; CRC valid each time.

Invalid live (still CMD 0x00 — not an error frame)

No usable pulse / not settled. Same 21-byte live frame, CRC ok. Hide this in the UI.

55 00 ff 00 00 0d 00 00 00 00 00 00 00 00 06 03 00 c8 01 00 8f
Field Decode
spo2 / pr 0 / 0
battery / batteryState 6 / 3 (low) — matches INFO CurBAT 6%
pi 0xC8 / 10 = 20.0 (filler, not a real PI)
worn / state bit 0 still 1
CRC 0x8F ok

Gate: show only if spo2 != 0. Do not require worn, and do not treat this packet as a protocol error.

Error responses (CMD 0x01)

Protocol errors are not text. They are a 12-byte 0x55 frame with CMD 0x01 (XOR 0xFE), LENGTH 4, payload = u32 little-endian error code. Fits in one notify.

Offset Size Field
0 1 HEADER 0x55
1 1 CMD 0x01
2 1 XOR 0xFE
3–4 2 BLOCK 0
5–6 2 LENGTH 4
7–10 4 error code, u32 LE
11 1 CRC

Captured on this SleepU (2026-08-20):

Trigger Frame Code
Unknown command 0x99 (aa 99 66 00 00 00 00 8b) 55 01 fe 00 00 04 00 0b 00 00 00 71 11 (0x0B)
Live 0x17 with bad CRC (aa 17 e8 00 00 00 00 e4) 55 01 fe 00 00 04 00 01 00 00 00 ed 1 (0x01)

Family note (not captured here): missing NUL on FILE_OPEN is error 9. Other codes not exercised.

Two back-to-back valid 0x17 writes both got live replies (no NACK). Bad CRC / unknown CMD do return 0x01. Still treat mixed command types as single-outstanding.

INFO (0x14), optional

Not required for live view. Live 0x17 worked with no prior INFO or SetTIME.

Request (empty payload):

aa 14 eb 00 00 00 00 c6

Response: HEADER 0x55, CMD 0x00, LENGTH 512, JSON then NUL padding. Total 520 bytes, many 20-byte notifies. Reassemble using LENGTH; do not drop frames larger than 21 bytes.

Observed JSON (this unit, 2026-08-20):

{"Region":"CE","Model":"1654","HardwareVer":"AA","SoftwareVer":"1.3.0","BootloaderVer":"0.1.0.0","FileVer":"3","SPCPVer":"1.4","SN":"25062B0920","CurTIME":"2026-08-21,00:17:15","CurBAT":"6%","CurBatState":"3","CurOxiThr":"90","CurMotor":"20","CurPedtar":"99999","CurMode":"0","CurState":"1","BranchCode":"21070000","FileList":"20260816015317,20260819023106,20260820155126,20260820235052,"}
Key Meaning here
CurBAT / CurBatState 6% / 3 — matches live battery 6 / state 3 (low)
CurMode 0 = sleep mode
CurState 1 = ready (INFO field; not the live worn bit)
FileList Overnight recordings; ignore for live view

Commands not needed for live view

Do not send these during the 1 Hz poll.

CMD Name Notes
0x14 INFO Captured above. Optional.
0x03 FILE_OPEN Overnight *.vld download. Not captured on this unit.
0x04 FILE_READ Not captured.
0x05 FILE_CLOSE Not captured.
0x1B (27) RT wave oxyGetRtWave. Not captured.
0x1C (28) PPG oxyGetPpgRt. Not captured.

Implementation checklist