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.
| 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).
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 |
SleepU* appears (or the service UUID is advertised).0734594a-…) before the first write.8b00ace7-…), no write-response.0x01: protocol error (see below). Do not decode as live data.0x00 and LENGTH is 13: live payload. Publish SpO₂ / pulse only when spo2 != 0 (also treat pr == 0 as no value).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.
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 (0x17 → 0xE8; 0x00 → 0xFF; 0x01 → 0xFE). |
| 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 | 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 |
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
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.
Poll at 1 Hz. Empty payload.
aa 17 e8 00 00 00 00 1b
CRC(aa 17 e8 00 00 00 00) = 0x1b
0x550x00, XOR 0xFF0x000013 (0x0d 00)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.
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.
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.
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.
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 |
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. |
SleepU* or service 14839ac4-7d7e-415c-9a42-167340cf23398b00ace7-… and notify 0734594a-…0x17 writeaa 17 e8 00 00 00 00 1b at 1 Hz, no response7 + LENGTH + 1 (live 21 B = 20+1; error 12 B; INFO 520 B)0x01: u32 LE error code — not live data0x00, LENGTH 13, CRC-8spo2 != 0 (worn bit can be 1 while spo2 is still 0)