This is an old revision of the document!
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).
This page is enough to scan, connect, poll, and decode live readings. 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) |
| 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 | Device does not handle overlapping commands. Stop the live poll before any other command. |
Scan by local name containing SleepU, or by the service UUID below.
Put the probe on a finger before expecting valid SpO₂. Off-finger packets still arrive; worn / spo2==0 tell you to hide the value.
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
- Scan until a device named
SleepU*appears (or the service UUID is advertised). - Connect as central; discover the service and the two characteristics.
- Subscribe to Notify (
0734594a-…). - Every 1 second, write the 8-byte live-param request on Write (
8b00ace7-…), no write-response. - Reassemble notify chunks into one frame (see MTU).
- Validate header, CMD XOR, length, and CRC-8, then decode the 13-byte payload.
- Publish SpO₂ / pulse only when
worn == 1andspo2 != 0. - 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.
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 | See commands. Live request is 0x17. Live response CMD is 0x00 (not an echo of 0x17). |
| 2 | 1 | CMD XOR | Must equal CMD XOR 0xFF (0x17 → 0xE8; 0x00 → 0xFF). |
| 3 | 2 | BLOCK | 0x0000 for live-param. |
| 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.
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. A live response is 21 bytes, so it arrives as 20 + 1.
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 the 20-byte prefix as a full packet.
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)
- HEADER
0x55 - CMD
0x00, XOR0xFF - BLOCK
0x0000 - LENGTH
13(0x0d 00) - 13-byte payload
- CRC
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. |
| 1–2 | 8–9 | u16 LE | pr | Pulse rate (bpm). |
| 3–6 | 10–13 | u32 LE | steps | Step counter. Unused for live view. Observed 0. |
| 7 | 14 | u8 | battery | 0–100 percent (vendor docs). |
| 8 | 15 | u8 | batteryState | 0 none, 1 charging, 2 complete, 3 low battery. |
| 9 | 16 | u8 | vector | Motion indicator. |
| 10 | 17 | u8 | pi | Perfusion index × 10. Display as raw / 10.0. 0 = invalid. |
| 11 bit 0 | 18 | flag | worn / state | 1 = on finger, 0 = off (lead off). Other state values = error. |
| 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.
Hide the SpO₂ reading when worn == 0 or spo2 == 0.
Worked example
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 = true |
| 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; worn true; CRC valid each time.
Commands not needed for live view
These exist on the Oxy family. Not captured on this SleepU for this page. Do not send them during the live poll.
| CMD | Name | Notes |
|---|---|---|
0x14 | INFO | JSON device info / file list. Vendor SDK also writes time (SetTIME) on connect. Live 0x17 worked here without that. |
0x03 | FILE_OPEN | Overnight *.vld download. |
0x04 | FILE_READ | |
0x05 | FILE_CLOSE | |
0x1B (27) | RT wave | oxyGetRtWave |
0x1C (28) | PPG | oxyGetPpgRt |
Implementation checklist
- Filter scan: name
SleepU*or service14839ac4-7d7e-415c-9a42-167340cf2339 - Connect, discover write
8b00ace7-…and notify0734594a-… - Enable notify before the first
0x17write - Write
aa 17 e8 00 00 00 00 1bat 1 Hz, no response - Reassemble 20+1 notify chunks into 21-byte
0x55frames - Check
CMD XOR, length 13, CRC-8 - Decode spo2 u8, pr u16 LE, pi u8/10, worn = payload[11] & 1
- Gate display on worn + nonzero spo2
- Single outstanding command; disconnect when idle