====== 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