wellue_ble_protocol

Differences

This shows you the differences between two versions of the page.

Link to this comparison view

wellue_ble_protocol [2026/08/20 22:01] – created fabriciowellue_ble_protocol [2026/08/20 22:43] (current) fabricio
Line 1: Line 1:
 ====== SleepU — live BLE protocol ====== ====== 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'').+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, and decode live readings. It does **not** cover overnight file download, PPG/waveform, or device settings.+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 ===== ===== Device =====
Line 10: Line 10:
 | Product | SleepU Sleep Oxygen Monitor (SpO₂ + heartbeat) | | Product | SleepU Sleep Oxygen Monitor (SpO₂ + heartbeat) |
 | BLE name | Starts with ''SleepU'' (observed ''SleepU 0920'') | | 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) | | Family | Lepu / Viatom **Oxy** (same GATT as O2Ring / KidsO2 / Checkme O2) |
 | Connections | **One** GATT client. Disconnect ViHealth / other apps first. | | Connections | **One** GATT client. Disconnect ViHealth / other apps first. |
 | Encryption | Optional OEM path in the vendor SDK. Retail unit captured here is **plaintext**. | | 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. |+| 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.+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₂. Off-finger packets still arrive; ''worn''''spo2==0'' tell you to hide the value.+Put the probe on a finger before expecting valid SpO₂. Packets still arrive with no usable pulsehide the value when ''spo2 == 0'' (the worn bit can stay 1).
  
 ===== GATT ===== ===== GATT =====
Line 32: Line 33:
   - Scan until a device named ''SleepU*'' appears (or the service UUID is advertised).   - Scan until a device named ''SleepU*'' appears (or the service UUID is advertised).
   - Connect as central; discover the service and the two characteristics.   - Connect as central; discover the service and the two characteristics.
-  - Subscribe to **Notify** (''0734594a-…'').+  - Subscribe to **Notify** (''0734594a-…''**before** the first write.
   - Every **1 second**, write the 8-byte live-param request on **Write** (''8b00ace7-…''), no write-response.   - Every **1 second**, write the 8-byte live-param request on **Write** (''8b00ace7-…''), no write-response.
   - Reassemble notify chunks into one frame (see MTU).   - Reassemble notify chunks into one frame (see MTU).
-  - Validate header, CMD XOR, length, and CRC-8, then decode the 13-byte payload+  - Check header, CMD XOR, length, and CRC-8
-  - Publish SpO₂ / pulse only when ''worn == 1'' and ''spo2 != 0''.+  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).   - 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.+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 ===== ===== Packet frame =====
Line 47: Line 49:
 ^ Offset ^ Size ^ Field ^ Notes ^ ^ Offset ^ Size ^ Field ^ Notes ^
 | 0 | 1 | HEADER | ''0xAA'' = host → device (request). ''0x55'' = device → host (response). | | 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''). | +| 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''). | +| 2 | 1 | CMD XOR | Must equal ''CMD XOR 0xFF'' (''0x17'' → ''0xE8''; ''0x00'' → ''0xFF''; ''0x01'' → ''0xFE''). | 
-| 3 | 2 | BLOCK | ''0x0000'' for live-param. |+| 3 | 2 | BLOCK | ''0x0000'' for live-param and errors. |
 | 5 | 2 | LENGTH | Payload length in bytes. | | 5 | 2 | LENGTH | Payload length in bytes. |
 | 7 | LENGTH | PAYLOAD | Command-specific. | | 7 | LENGTH | PAYLOAD | Command-specific. |
 | 7+LENGTH | 1 | CRC | CRC-8 of all bytes **before** this one (header through payload). | | 7+LENGTH | 1 | CRC | CRC-8 of all bytes **before** this one (header through payload). |
  
-Total frame size = ''8 + LENGTH''.+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 ==== ==== CRC-8 ====
Line 77: Line 85:
 ==== MTU / notify reassembly ==== ==== MTU / notify reassembly ====
  
-Default ATT payload is **20 bytes**. A live response is **21 bytes**, so it arrives as **20 + 1**.+Default ATT payload is **20 bytes**.
  
-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.+^ 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 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. Writes larger than 20 bytes must be chunked with a short delay. The live request is 8 bytes and fits in one write.
Line 109: Line 122:
  
 ^ Payload ^ Frame ^ Type ^ Field ^ Meaning ^ ^ Payload ^ Frame ^ Type ^ Field ^ Meaning ^
-| 0 | 7 | u8 | spo2 | SpO₂ percent. ''0'' = invalid. | +| 0 | 7 | u8 | spo2 | SpO₂ percent. ''0'' = invalid — hide the reading. | 
-| 1–2 | 8–9 | u16 LE | pr | Pulse rate (bpm). |+| 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. | | 3–6 | 10–13 | u32 LE | steps | Step counter. Unused for live view. Observed 0. |
-| 7 | 14 | u8 | battery | 0–100 percent (vendor docs). | +| 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 battery. | +| 8 | 15 | u8 | batteryState | 0 none, 1 charging, 2 complete, **3 low**. INFO ''CurBatState'' ''3'' matches. | 
-| 9 | 16 | u8 | vector | Motion indicator. | +| 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. | +| 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 | ''1'' on finger, ''0'' = off (lead off)Other state values = error. |+| 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. | | 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. | | 12 | 19 | packed | IV flags | bits 0–1 invalid, 2–3 SpO₂, 4–5 HR, 6–7 vector. Observed 0. |
Line 122: Line 135:
 **PI** is perfusion index (pulse strength at the probe), not a second oxygen channel. Use it as signal-quality / confidence. **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''+==== Valid reading (finger on, settled) ====
- +
-===== Worked example =====+
  
 Host wrote ''aa 17 e8 00 00 00 00 1b''. Device notified (reassembled): Host wrote ''aa 17 e8 00 00 00 00 1b''. Device notified (reassembled):
Line 142: Line 153:
 | vector | 4 | | vector | 4 |
 | pi | ''0x08 / 10'' = **0.8** | | pi | ''0x08 / 10'' = **0.8** |
-| worn | bit 0 of ''0x01''**true** |+| worn | bit 0 of ''0x01''|
 | CRC | ''0x21'' = ''crc8(frame without last byte)'' | | 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.+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. 
 + 
 +<code> 
 +55 00 ff 00 00 0d 00 00 00 00 00 00 00 00 06 03 00 c8 01 00 8f 
 +</code> 
 + 
 +^ 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): 
 + 
 +<code> 
 +aa 14 eb 00 00 00 00 c6 
 +</code> 
 + 
 +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): 
 + 
 +<code> 
 +{"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,"
 +</code> 
 + 
 +^ 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 ===== ===== 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.+Do not send these during the 1 Hz poll.
  
 ^ CMD ^ Name ^ Notes ^ ^ CMD ^ Name ^ Notes ^
-| ''0x14'' | INFO | JSON device info / file listVendor SDK also writes time (''SetTIME'') on connect. Live ''0x17'' worked here **without** that. | +| ''0x14'' | INFO | Captured aboveOptional. | 
-| ''0x03'' | FILE_OPEN | Overnight ''*.vld'' download. | +| ''0x03'' | FILE_OPEN | Overnight ''*.vld'' download. **Not captured** on this unit. | 
-| ''0x04'' | FILE_READ | | +| ''0x04'' | FILE_READ | Not captured. 
-| ''0x05'' | FILE_CLOSE | | +| ''0x05'' | FILE_CLOSE | Not captured. 
-| ''0x1B'' (27) | RT wave | ''oxyGetRtWave''+| ''0x1B'' (27) | RT wave | ''oxyGetRtWave''. Not captured. 
-| ''0x1C'' (28) | PPG | ''oxyGetPpgRt'' |+| ''0x1C'' (28) | PPG | ''oxyGetPpgRt''. Not captured. |
  
 ===== Implementation checklist ===== ===== Implementation checklist =====
Line 165: Line 240:
   * Enable notify **before** the first ''0x17'' write   * Enable notify **before** the first ''0x17'' write
   * Write ''aa 17 e8 00 00 00 00 1b'' at 1 Hz, no response   * Write ''aa 17 e8 00 00 00 00 1b'' at 1 Hz, no response
-  * Reassemble 20+1 notify chunks into 21-byte ''0x55'' frames +  * Reassemble until ''7 + LENGTH + 1'' (live 21 B = 20+1; error 12 B; INFO 520 B) 
-  * Check ''CMD XOR'', length 13, CRC-8 +  * If CMD is ''0x01'': u32 LE error code — not live data 
-  * Decode spo2 u8, pr u16 LE, pi u8/10, worn = payload[11] & 1 +  * Live data is CMD ''0x00'', LENGTH 13, CRC-8 
-  * Gate display on worn + nonzero spo2 +  * Decode spo2 u8, pr u16 LE, pi u8/10, battery u8 
-  * Single outstanding command; disconnect when idle+  * 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
  
  • wellue_ble_protocol.txt
  • Last modified: 2026/08/20 22:43
  • by fabricio