====== 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 =====
- 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-…'') **before** the first write.
- Every **1 second**, write the 8-byte live-param request on **Write** (''8b00ace7-…''), no write-response.
- Reassemble notify chunks into one frame (see MTU).
- Check header, CMD XOR, length, and CRC-8.
- If CMD is ''0x01'': protocol error (see below). Do not decode as live data.
- If CMD is ''0x00'' and LENGTH is 13: live payload. Publish SpO₂ / pulse only when ''spo2 != 0'' (also treat ''pr == 0'' as no value).
- 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'' (''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 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) ====
* HEADER ''0x55''
* CMD ''0x00'', XOR ''0xFF''
* 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 — 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 =====
* Filter scan: name ''SleepU*'' **or** service ''14839ac4-7d7e-415c-9a42-167340cf2339''
* Connect, discover write ''8b00ace7-…'' and notify ''0734594a-…''
* Enable notify **before** the first ''0x17'' write
* Write ''aa 17 e8 00 00 00 00 1b'' at 1 Hz, no response
* Reassemble until ''7 + LENGTH + 1'' (live 21 B = 20+1; error 12 B; INFO 520 B)
* If CMD is ''0x01'': u32 LE error code — not live data
* Live data is CMD ''0x00'', LENGTH 13, CRC-8
* Decode spo2 u8, pr u16 LE, pi u8/10, battery u8
* Show SpO₂ only when ''spo2 != 0'' (worn bit can be 1 while spo2 is still 0)
* Do not mix other commands into the 1 Hz loop; disconnect when idle