Differences

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

Link to this comparison view

Both sides previous revision Previous revision
Next revision
Previous revision
wellue_ble_protocol [2026/08/20 22:43] – fabriciowellue_ble_protocol [2026/09/14 12:55] (current) – [Repository sources and maintenance] fabricio
Line 1: Line 1:
-====== SleepU — live BLE protocol ======+====== Wellue / Viatom SleepU — protocol, capabilities and Somnolinc integration ======
  
-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).+Investigation: 
  
-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.+**2026-09-08**. Tested retail SleepU: INFO model ''1654'', hardware 
 +''AA'', firmware ''1.3.0'', protocol ''1.4'', file version ''3'', branch ''21070000''.
  
-===== Device =====+Consolidated on **2026-09-14** from the August 20 live captures, September 8 
 +investigation, and current repository documentation. No new device experiments 
 +were performed for this consolidation. 
  
-^ Item ^ Value ^ +Protocol capabilities and implemented 
-| Product | SleepU Sleep Oxygen Monitor (SpO₂ + heartbeat) | +Android features are distinguished below. All command numbers are hexadecimal.
-| 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).+===== Evidence and limits =====
  
-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).+**Verified** below means exercised on this particular SleepU. **SDK** means found 
 +in the vendor library/demo, without establishing support on this firmware. 
 +**Unknown** means the evidence is insufficient. This is the complete command 
 +inventory recovered from the standard Oxy command builder, not a claim that every 
 +firmware command or product variant has been discovered.
  
-===== GATT =====+Sources:
  
-Custom service (not a SIG pulse-oximeter profile).+  * [[https://github.com/viatom-develop/LepuDemo/tree/94201dbed1619ec49b10d1a3dfb2a63ecb9a0b75|Vendor LepuDemo, pinned revision]], including its Oxy-family README and ''OxyActivity.kt''. 
 +  * That checkout contains ''app/libs/lepu-blepro-1.3.9.aar'' (the README's dependency version is older). SHA-256: ''a578425905179cf5433aa6c9323b77fe8b809e7ecd5acf91a13c81711f7cf966''. Inspected with CFR 0.152. Relevant classes: ''doad.ifgm'' (commands), ''doah.doab'' (OxyCommonBleInterface), ''doad.goto'' (wave), ''doad.try'' and ''doad.case'' (PPG), ''doae.dofd'' (file), ''doad.for'' (INFO). Obfuscated names apply only to this artifact. No vendor binary or decompiled source is included here. 
 +  * [[https://getwellue.com/pages/sleepu-oxygen-monitor|SleepU specifications]] and [[https://www.medm.com/assets/files/SleepU.pdf|manufacturer manual]] describe standalone measurement, vibration alerts, internal storage, and live readings. 
 +  * Direct BLE captures and three downloaded files, kept outside Git in ''local/sleepu-protocol-2026-09-08/''. These contain personal device/health data. ''plans/ble/05-sleepu-protocol/analyze.py'' checks captured frames and file structure.
  
-^ Role ^ UUID ^ Properties to use ^ +Initial probes were read-only. Subsequent user-authorized write tests temporarily 
-| Service | ''14839ac4-7d7e-415c-9a42-167340cf2339'' | — | +changed oxygen threshold 90 → 91 and motor intensity 20 → 21, then restored both 
-| Write (host → device) | ''8b00ace7-eb0b-49b0-bbe9-9aee0a26e1a3'' | Write **without** response | +with verified INFO readback. A grouped write of the original values and a clock 
-| Notify (device → host) | ''0734594a-a8e7-4b1a-a6b1-cd5243059a57'' | Notify; enable CCCD |+write using the freshly read device time were also acknowledged. Factory data, 
 +firmware and stored recordings were not erased or rewritten. File reads open and 
 +close a transfer session.
  
-===== Connection sequence =====+===== Capability matrix =====
  
-  - Scan until a device named ''SleepU*'' appears (or the service UUID is advertised). +^ Capability ^ Result on tested SleepU ^ 
-  - Connect as central; discover the service and the two characteristics. +| Live SpO₂, pulse, motion, PI, worn/countdown, battery | **Verified**, ''0x17'', 13-byte numeric payload | 
-  - Subscribe to **Notify** (''0734594a-…'') **before** the first write. +| Live pleth waveform | **Verified**, ''0x1B'', one unsigned 8-bit sample channel, approximately 125 samples/s | 
-  - Every **1 second**, write the 8-byte live-param request on **Write** (''8b00ace7-…''), no write-response. +| Raw red + infrared optical samples | **Rejected**, ''0x1C'' returns error 11, including the SDK's exact request payload | 
-  - Reassemble notify chunks into one frame (see MTU). +| List stored recordings | **Verified**, ''0x14'' INFO ''FileList'' | 
-  - Check header, CMD XOR, length, and CRC-8. +| Download stored recordings | **Verified**, ''0x03'' / ''0x04'' / ''0x05''; version 3 numeric files | 
-  - If CMD is ''0x01'': protocol error (see below). Do not decode as live data. +| Waveform/PPG in stored files | **Absent in all three downloaded files**; no separate PPG file method recovered for this model | 
-  - If CMD is ''0x00'' and LENGTH is 13: live payload. Publish SpO₂ / pulse only when ''spo2 != 0'' (also treat ''pr == 0'' as no value). +| Interleave numeric + waveform reads | **Verified**, serialized requests over one connection | 
-  - Unsubscribe and disconnect when the live view stops (the device stays awake while connected).+| Download older files while measuring | **Verified**, files downloaded with INFO state 2 and valid live readings | 
 +| Internal recording alongside live BLE | Measurement remained active; final saved-file continuity still needs a controlled finalize/download test | 
 +| Long-term phone waveform recording | Technically possible from ''0x1B''; overnight reliability, battery cost, and loss rate remain untested | 
 +| Identity/version/clock/current settings | **Verified**, INFO JSON; DIS itself only exposes a generic manufacturer string | 
 +| Set clock / alarm settings | **Verified**, JSON ''0x16'': threshold/motor changes and restoration; same-time clock write acknowledged | 
 +| Factory reset / factory provisioning | **SDK**, ''0x18'' / ''0x1A''; not executed | 
 +| Box information | **Rejected**, ''0x1D'' error 11 | 
 +| Ping | **Verified**, ''0x15''; four-zero acknowledgement, not payload echo | 
 +| Optional OEM encryption negotiation | **SDK**, ''0xFF''; unnecessary for tested plaintext retail unit | 
 +| Firmware update | Update-like GATT service present; update procedure/image compatibility not verified | 
 +| Raw three-axis acceleration / automatic push mode | No verified SleepU command; SDK's S3 auto-stream API belongs to another model | 
 +| Remote start/stop measurement, individual file deletion | No dedicated command recovered in this SleepU command builder |
  
-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.+The SDK's OxyII family has additional APIs and file types. Those are not evidence 
 +that this SleepU supports them. Likewise, the shared Oxy README's 150 Hz raw-PPG 
 +description must not be applied to the observed 125 Hz 8-bit waveform.
  
-===== Packet frame =====+===== Discovery and GATT =====
  
-Every request and response uses this layout. Integers are **little-endian**.+Scan using the advertised ''SleepU'' name and/or the custom Oxy service, then read 
 +INFO to establish the model/version. The service is shared across Oxy products. 
 +Only one central was used; disconnect ViHealth/Somnolinc before a host probe.
  
-^ Offset ^ Size ^ Field ^ Notes ^ +^ Service / role ^ UUID / observed properties ^ 
-| 0 | 1 | HEADER | ''0xAA'' = host → device (request). ''0x55'' = device → host (response). | +| Oxy service | ''14839ac4-7d7e-415c-9a42-167340cf2339'' | 
-| 1 | 1 | CMD | **Request** live-param = ''0x17''. **Response** is **not** an echo: ''0x00'' = data (live or INFO), ''0x01'' = protocol error. | +| Write | ''8b00ace7-eb0b-49b0-bbe9-9aee0a26e1a3'', write without response | 
-| 2 | 1 | CMD XOR | Must equal ''CMD XOR 0xFF'' (''0x17'' → ''0xE8''; ''0x00'' → ''0xFF''; ''0x01'' → ''0xFE''). | +| Notifications | ''0734594a-a8e7-4b1a-a6b1-cd5243059a57'', notify | 
-| 3 | 2 | BLOCK | ''0x0000'' for live-param and errors. | +| DIS | ''0000180a-0000-1000-8000-00805f9b34fb'' | 
-| 5 | 2 | LENGTH | Payload length in bytes. | +| DIS manufacturer | ''00002a29-0000-1000-8000-00805f9b34fb'', read; returned ''Co.,Ltd.'' | 
-| 7 | LENGTH | PAYLOAD | Command-specific. | +| Update service | ''0000fe59-0000-1000-8000-00805f9b34fb'' | 
-| 7+LENGTH | 1 | CRC | CRC-8 of all bytes **before** this one (header through payload). |+| Update characteristic | ''8ec90003-f315-4f60-9fb8-838830daea50'', write/indicate; not invoked | 
 +| GATT service changed | ''0x1801'' / ''0x2A05'', indicate |
  
-Total frame size = ''8 + LENGTH'' (live 21, error 12, INFO 520).+No standard Battery Service, DIS serial/model/version characteristics, or extra 
 +sensor characteristics were exposed in this discovery. Battery comes from the 
 +custom protocol; identity comes from INFO. RSSI is available from the BLE stack, 
 +not from an Oxy payload. Neither RSSI nor a device name authenticates a device.
  
-==== Response CMD dispatch ====+===== Transport =====
  
-^ Response CMD ^ Meaning ^ Typical LENGTH ^ +All multibyte integers below are little endian. Offsets in subsequent tables 
-| ''0x00'' | Data. Live params if LENGTH 13; INFO JSON if LENGTH 512. | 13 or 512 | +refer to the payload, excluding the seven-byte frame header. 
-| ''0x01'' | Protocol error. Payload is a u32 LE code. **Never** parse as SpO₂. | 4 |+ 
 +<code text> 
 +Request:  AA command (command XOR FF) block:u16 length:u16 payload CRC8 
 +Response: 55 status  (status  XOR FF) block:u16 length:u16 payload CRC8 
 +</code> 
 + 
 +Total length is ''8 + payload length''. CRC covers everything except its own byte: 
 +polynomial ''0x07'', initial value 0, MSB first, no reflection or final XOR. 
 +Response status ''0'' is success; ''1'' is an error with a four-byte error code. 
 +The response does **not** echo the request command. 
 + 
 +Subscribe before writing. Reassemble across notification boundaries, validate 
 +complement/length/CRC, and retain extra complete frames. The probe split writes 
 +into at most 20 bytes and successfully received fragmented 520-byte INFO/file 
 +frames. Do not limit protocol payloads to the ATT payload size. 
 + 
 +Use **one outstanding request across all command types**. Dispatch by that 
 +request, not response length: a waveform, file block, and INFO can have overlapping 
 +lengths. File requests also carry a block counter. A timeout leaves response 
 +ownership ambiguous; stop the transaction and recover the connection instead of 
 +immediately issuing a different command that could consume the late reply. 
 + 
 +==== Complete recovered standard-Oxy command inventory ==== 
 + 
 +^ Command ^ Request payload ^ Success payload / status ^ 
 +| ''03'' file open | ASCII filename followed by one NUL | Verified: total file byte size, u32 | 
 +| ''04'' file block | Empty; block counter starts 0 | Verified: file bytes, up to 512 observed | 
 +| ''05'' file close | Empty | Verified: four zero bytes | 
 +| ''14'' INFO | Empty | Verified: JSON with NUL padding to 512 bytes | 
 +| ''15'' ping | SDK accepts arbitrary bytes | Verified: four zero bytes even for a nonempty request | 
 +| ''16'' settings | JSON object, string values, no trailing NUL | Verified: four zero bytes; threshold/motor readback confirmed | 
 +| ''17'' numeric live | Empty in SDK | Verified: 13 bytes; one-zero-byte request also accepted | 
 +| ''18'' factory reset | Empty | SDK only; destructive | 
 +| ''1A'' factory information programming | Opaque factory bytes | SDK only; provisioning layout unresolved | 
 +| ''1B'' waveform | One byte ''00'' in SDK | Verified: 12-byte header + N waveform bytes; empty request also accepted | 
 +| ''1C'' raw PPG | One byte ''00'' in SDK | Error 11 on this unit, with both SDK payload and empty payload | 
 +| ''1D'' box information | Empty | Error 11 on this unit | 
 +| ''FF'' encryption negotiation | SDK-generated negotiation bytes | Optional OEM path; not exercised, not required here | 
 + 
 +The SDK resets its block counter for non-block commands, increments it for ''04'', 
 +and wraps before 65535. Do not interleave file transfer requests with other 
 +commands when following this implementation. Finish/close the file transaction, 
 +then resume live polling; waveform continuity across that pause is not guaranteed. 
 + 
 +Error codes verified: **1** after a bad request CRC (August capture), **11** after 
 +unknown command ''99'', raw PPG, and box-info requests. Treat 11 as rejection of the 
 +request on this firmware, not proof about every variant. Other error code meanings 
 +are unverified; preserve the numeric code. The earlier notes mention family error 
 +9 for a malformed filename but it was not established in these tests. 
 + 
 +===== CRC and fragmentation examples =====
  
 ==== CRC-8 ==== ==== CRC-8 ====
Line 96: Line 171:
 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.
  
-===== Live parameter command (0x17) ===== 
  
-Poll at 1 Hz. Empty payload.+===== INFO (''14'') ===== 
 + 
 +Strip trailing NUL padding, parse JSON, retain strings and unknown keys. These 
 +keys were actually returned: 
 + 
 +^ Keys ^ Interpretation ^ 
 +| ''Region'', ''Model'', ''HardwareVer'', ''SoftwareVer'', ''BootloaderVer'' | Product/firmware identity; tested model 1654, firmware 1.3.0 | 
 +| ''FileVer'', ''SPCPVer'', ''BranchCode'' | File version 3, protocol 1.4, firmware branch | 
 +| ''SN'' | Unit serial; preserve text and leading zeros | 
 +| ''CurTIME'' | ''yyyy-MM-dd,HH:mm:ss'', device-local wall time, no timezone | 
 +| ''CurBAT'', ''CurBatState'' | Percent string and numeric-state string; matched live readings | 
 +| ''CurOxiThr'', ''CurMotor'' | Current oxygen alert threshold and motor setting | 
 +| ''CurPedtar'' | SDK step-target field; meaning/use on SleepU not verified | 
 +| ''CurMode'' | SDK: 0 sleep, 1 monitor; observed 0 | 
 +| ''CurState'' | SDK: 0 preparing, 1 ready, 2 measuring; observed 2 during tests | 
 +| ''FileList'' | Comma-separated recording names with trailing comma; drop empty entries | 
 + 
 +SDK also recognizes ''OxiSwitch'', ''HRSwitch'', ''HRLowThr'', ''HRHighThr'', 
 +''LightingMode'', ''LightStr'', ''SpO2SW'', ''CurBuzzer'', ''MtSW'', ''MtThr'', ''IvSW'', 
 +''IvThr'', and ''SleepTime''. They were absent here. An absent key is not evidence 
 +of a supported feature being disabled; avoid converting it into a false/zero 
 +capability claim. INFO is not required before basic live reads and the clock need 
 +not be synchronized to read data. Re-read identity/capabilities after reconnect. 
 + 
 +===== Numeric live data (''17'') ===== 
 + 
 +^ Offset ^ Encoding ^ Meaning ^ 
 +| 0 | u8 | SpO₂ %, zero invalid | 
 +| 1–2 | u16 | Pulse bpm, zero invalid | 
 +| 3–6 | u32 | Steps | 
 +| 7 | u8 | Battery percent | 
 +| 8 | u8 | Battery state: 0 not charging, 1 charging, 2 complete, 3 low | 
 +| 9 | u8 | Motion scalar, not three-axis IMU data | 
 +| 10 | u8 / 10 | Perfusion index; unreliable when reading invalid | 
 +| 11 | packed | Bit 0 worn; high nibble countdown; other bits unresolved | 
 +| 12 | packed | Two-bit fields: invalid 0–1, SpO₂ 2–3, pulse 4–5, motion 6–7; meanings of individual field values not verified | 
 + 
 +One-second polling is adequate for existing numeric live display. It retrieves 
 +the current values, not a backlog. Timestamp replies on the host; the payload has 
 +no sample clock or sequence. Worn can remain true while SpO₂/pulse are invalid. 
 +Valid/invalid golden frames are included below. The wire fields above describe 
 +the complete payload; the current Android recording stores a subset, documented 
 +in the Somnolinc section below. 
 + 
 +===== Numeric live and error golden fixtures =====
  
 ==== Request (8 bytes) ==== ==== Request (8 bytes) ====
Line 116: Line 234:
   * 13-byte payload   * 13-byte payload
   * CRC   * 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) ==== ==== Valid reading (finger on, settled) ====
Line 173: Line 273:
 | CRC | ''0x8F'' ok | | CRC | ''0x8F'' ok |
  
-Gate: show only if ''spo2 != 0''. Do **not** require worn, and do not treat this packet as a protocol error.+SpO₂ validity gate: show only if ''spo2 != 0''. The worn bit alone does not establish validity. Pulse zero is independently invalid; PI is unreliable while readings are invalid. Do not treat this packet as a protocol error.
  
-===== Error responses (CMD 0x01) =====+==== Captured error responses (status 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. 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.
Line 198: Line 298:
 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. 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 =====+==== Captured INFO example (August 20) ====
  
 Not required for live view. Live ''0x17'' worked with no prior INFO or ''SetTIME''. Not required for live view. Live ''0x17'' worked with no prior INFO or ''SetTIME''.
Line 222: Line 322:
 | FileList | Overnight recordings; ignore for live view | | FileList | Overnight recordings; ignore for live view |
  
-===== Commands not needed for live view ===== 
  
-Do not send these during the 1 Hz poll.+===== Pleth waveform (''1B'') ===== 
 + 
 +^ Offset ^ Encoding ^ Meaning ^ 
 +| 0 | u8 | SpO₂ | 
 +| 1–2 | u16 | Pulse bpm | 
 +| 3 | u8 | Battery percent | 
 +| 4 | u8 | Battery state | 
 +| 5 | u8 / 10 | SDK PI field | 
 +| 6 | u8 | SDK state field | 
 +| 7 | u8 | SDK internal ''sleepState''; semantics not verified, not surfaced by public RtWave DTO | 
 +| 8–9 | bytes | Not interpreted by inspected parser | 
 +| 10–11 | u16 | Sample count N | 
 +| 12 onward | N × u8 | Single waveform channel, uncalibrated amplitude | 
 + 
 +Require exactly ''12 + N'' bytes. The SDK silently clamps malformed lengths; a 
 +recording implementation should report malformed packets instead. The waveform 
 +PI/state fields were zero even with valid numeric PI/worn values, so continue 
 +using ''17'' for those measures. There is no per-sample timestamp or sequence number. 
 + 
 +The SDK preserves original ''waveByte'' but replaces values **156** and **246** 
 +with neighbors (or their average) in its display array. Their protocol meaning is 
 +unknown. Keep every original byte in recordings; any interpolation belongs only 
 +in visualization. Do not describe this 8-bit stream as raw red/infrared ADC data. 
 +The initial reply included suspicious repeated filler-like patterns; mark startup 
 +data as uncertain rather than treating the first batch as settled physiology. 
 + 
 +==== Measured timing and buffering ==== 
 + 
 +^ Host polling experiment ^ Observed result ^ 
 +| 0.25 s sleep after reply | Approximately 38–45 samples per reply, around 0.30–0.33 s between replies | 
 +| 1 s sleep after reply | 125 samples, despite approximately 1.1 s between replies | 
 +| 2 s sleep after reply | Still 125 samples, despite approximately 2.1 s between replies | 
 +| Sustained 0.2 s sleep, numeric read every 20 waveform replies | 120 waveform replies; after first reply, 3,675 samples over 29.342 s = 125.25 samples/s; subsequent batches 29–41 samples | 
 + 
 +All 131 replies in the sustained session completed with valid CRCs: 120 waveform, 
 +6 numeric, 2 INFO, file open/close, and one deliberately rejected unknown command. 
 +INFO remained in measuring state and battery percent was unchanged across this 
 +short session. This is not an overnight endurance test. 
 + 
 +**Inference:** the device drains a short waveform buffer capped at roughly one 
 +second/125 samples. Slow polling cannot retrieve all elapsed data. Which samples 
 +are retained during overflow, exact latency, and precise oscillator rate are 
 +unknown. Schedule waveform requests approximately every 200–250 ms with a single 
 +command arbiter, accounting for BLE latency. Preserve host monotonic request/reply 
 +times and batch boundaries. Flag stalls/125-sample saturation as possible loss; 
 +do not promise lossless reconstruction or invent exact sample timestamps. 
 + 
 +===== Raw PPG (''1C''), SDK layout only ===== 
 + 
 +The SDK decoder reads N as u16 at offset 0, followed by N nine-byte records: 
 +four little-endian bytes for infrared, four for red, one motion byte. Expected 
 +length is ''2 + 9*N''. The SDK stores optical words in Java ints; ADC bit depth, 
 +signedness and physical calibration are not established here. 
 + 
 +The Oxy-family README describes 150 Hz for this API. This SleepU rejected it 
 +repeatedly while worn and measuring, including payload ''00''. There is no evidence 
 +of a separate mode that enables it. Do not expose this as an available capability 
 +or retry continuously after rejection on the same firmware. 
 + 
 +===== Stored recordings (''03'' / ''04'' / ''05'') ===== 
 + 
 +  - Read INFO and take a snapshot of nonempty ''FileList'' entries. 
 +  - Open an exact listed name plus NUL, block 0. Require a four-byte size response and a bounded, plausible size before allocating. 
 +  - Request block 0, then 1, etc. Append verified payloads until the declared size; validate returned block numbers and reject empty/oversized/missing blocks. 
 +  - Close the transfer, including on errors where the connection remains usable. 
 +  - Validate header size/version/date, record divisibility and requested identity before accepting or deduplicating the recording. Preserve the original bytes. 
 + 
 +Observed files were **75, 530 and 170 bytes**, with **7, 98 and 26 samples** and 
 +header durations **28, 392 and 104 seconds** respectively. Thus all have exactly 
 +**4 seconds per numeric record**. The 530-byte file arrived as 512 + 18 payload 
 +bytes. This raw binary transfer is not necessarily identical to a ViHealth export 
 +container or a file with a ''.vld'' extension. 
 + 
 +**Filename quirk:** opening nonexistent ''20000101000000'' returned success with 
 +size 170 instead of an error. That transfer was closed without reading its body; 
 +whether it aliases a previous file or uses a fallback is unresolved. Never trust 
 +the open acknowledgement alone or silently label downloaded data with the request 
 +name. Validate the embedded start date against the requested date. 
 + 
 +==== Version 3 header (40 bytes) ==== 
 + 
 +^ Offset ^ Type ^ SDK field / interpretation ^ 
 +| 0 | u8 | Version; observed 3 | 
 +| 1 | u8 | Operation mode; observed 0 (sleep) | 
 +| 2–3 | u16 | Year | 
 +| 4–8 | five u8 | Month, day, hour, minute, second | 
 +| 9–12 | u32 | Entire file size including header | 
 +| 13–14 | u16 | Recording duration, seconds | 
 +| 15–16 | u16 | Asleep time; SDK says reserved | 
 +| 17 | u8 | Average SpO₂ | 
 +| 18 | u8 | Minimum SpO₂ | 
 +| 19 | u8 | Count of baseline drops by 3 percentage points | 
 +| 20 | u8 | Count of baseline drops by 4 percentage points | 
 +| 21 | u8 | ''asleepTimePercent'', SDK describes T90 percent | 
 +| 22–23 | u16 | Time below 90%; SDK duration field, units not independently checked | 
 +| 24 | u8 | Drops below 90%; SDK says reserved | 
 +| 25 | u8 | O₂ score, SDK range 0–100, divide by 10 for 0–10 display; observed 255, out of range/unavailable | 
 +| 26–29 | u32 | Step counter | 
 +| 30–39 | bytes | Reserved/uninterpreted | 
 + 
 +Summary-statistic names come from the SDK; their algorithms and alert thresholds 
 +were not independently validated. Do not interpret reserved sleep fields as sleep 
 +staging. Dates have no timezone; retain original wall time and explicit timezone 
 +assumptions separately. The four-second cadence does not establish whether each 
 +timestamp denotes an interval's beginning, end, or aggregation time. 
 + 
 +==== Version 3 records (5 bytes each, beginning at offset 40) ==== 
 + 
 +^ Record offset ^ Type ^ Meaning ^ 
 +| 0 | u8 | SpO₂; 255 observed as invalid sentinel | 
 +| 1–2 | u16 | Pulse; 65535 observed as invalid sentinel | 
 +| 3 | u8 | Motion scalar | 
 +| 4 | u8 | Flags, preserve raw | 
 + 
 +The invalid sentinels differ from numeric live data's zero values. Do not graph 
 +255% or 65,535 bpm, and reject out-of-range summary values. There are no waveform 
 +bytes in this layout. 
 + 
 +The decompiled SDK labels flag masks ''80'', ''40'', ''20'', ''10'' as oxygen, pulse, 
 +motion and invalid warnings, but compares the masked results to **1**, which 
 +would always yield false. It also derives sleep state from ''(flags & 30) >> 4'', 
 +overlapping those masks. All captured record flags were zero. This mapping is 
 +unverified and the SDK decoder is suspect; retain the byte without asserting 
 +warning or sleep-state semantics. 
 + 
 +===== Settings and maintenance ===== 
 + 
 +''16'' sends an ASCII-compatible JSON object with **string** values, e.g. 
 +''{"SetTIME":"2026-09-08,22:00:00"}''. The builder casts characters to bytes rather 
 +than providing a general Unicode encoding. Use documented ASCII keys/values only. 
 +The SDK supports one key or a group of settings in the same object. 
 + 
 +^ Key ^ Vendor family documentation ^ SleepU evidence ^ 
 +| ''SetTIME'' | Device-local ''yyyy-MM-dd,HH:mm:ss'' | Same-time write acknowledged; immediate readback matched; a deliberate clock offset/persistence test was not performed | 
 +| ''SetOxiThr'' | 80–95 | 90 → 91 → 90 verified through INFO | 
 +| ''SetMotor'' | Model-dependent ranges, including 0–35 and 0–100 | 20 → 21 → 20 verified; full SleepU range not established | 
 +| ''SetOxiSwitch'' | 0/1 for single alert mechanism; 0–3 for combined sound/vibration | Corresponding key absent; support unknown | 
 +| ''SetHRSwitch'' | Same family switch encodings | Absent; support unknown | 
 +| ''SetHRLowThr'', ''SetHRHighThr'' | 30–250 | Absent; support unknown | 
 +| ''SetBuzzer'' | CheckO2Plus-specific 0–100 | Not established for SleepU | 
 +| ''SetLightingMode'', ''SetLightStr'' | SDK Oxy base-interface keys; INFO documents values 0–2 | Corresponding INFO fields absent; not tested | 
 +| ''SetSpO2SW'', ''SetMtSW'', ''SetMtThr'', ''SetIvSW'', ''SetIvThr'' | Keys present in SDK Oxy base interface (''doab.doen''); some variant branches have no-op handling | Semantics/ranges and SleepU support not established | 
 + 
 +Do not infer writable keys merely by replacing ''Cur'' with ''Set''. In particular, 
 +display settings, operating-mode changes and sensor-start/stop commands were not 
 +established by this investigation. Re-read INFO after a future deliberate settings 
 +write; an acknowledgement alone need not prove the requested value was applied. 
 + 
 +All six tested setting requests returned success with payload ''00 00 00 00''. 
 +The grouped request contained original threshold/motor values; it verifies 
 +acceptance of that JSON shape, not atomic application of two changed values. 
 +The tests establish configuration read/write behavior, not the physical alert's 
 +trigger timing, vibration strength calibration, or persistence through power-off. 
 +Settings without readable original values were not toggled because their state 
 +could not be reliably restored. 
 + 
 +''18'' is factory reset, **not** ''1A''. ''1A'' programs factory information and ''FF'' 
 +negotiates optional encryption. Payload schemas for provisioning and the complete 
 +OEM security exchange remain unresolved. None is needed for retail live/file 
 +reads. The exposed update service is not sufficient to establish an upgrade 
 +recipe, supported image, rollback behavior or permission to flash firmware. 
 + 
 +===== Consequences for Somnolinc ===== 
 + 
 +  * Numeric ''17'' phone recording is implemented. Its timing, channel selection, validity handling and identity limits are documented below. 
 +  * A separate optional ''1B'' track can preserve the live 8-bit pleth waveform. Give it its own format/sample-rate provenance, batch timestamps and gap markers; keep visual interpolation out of the saved bytes. 
 +  * Use one BLE request scheduler for INFO, numeric data, waveform and downloads. A file transfer can interrupt the short waveform buffer; schedule downloads outside waveform recording or explicitly mark the resulting gap. 
 +  * Import version 3 files as four-second SpO₂/pulse/motion history. They cannot backfill lost waveform, PI or battery samples. 
 +  * Do not advertise raw dual-channel PPG, raw accelerometry or remote recording control for this firmware. Cache rejections by firmware identity, not forever by Bluetooth address. 
 + 
 +Remaining device experiments: finalize and retrieve a recording covering BLE 
 +streaming; check waveform continuity under movement/contact loss/reconnect; 
 +measure extended Android/background operation and power use; resolve filename 
 +fallback and nonzero file flags. These are explicit evidence gaps, not implied 
 +capabilities. Further configuration and DFU tests would be separate deliberate operations. 
 + 
 +A subsequent roughly three-minute finalization watch continued to receive valid 
 +live data, but INFO stayed in measuring state and its file list did not change. 
 +The controlled internal-recording test therefore remains pending sensor removal 
 +and completion of the device's countdown; no new file was available to validate. 
 + 
 +===== Offline validation ===== 
 + 
 +The analyzer uses Python's standard library and does not connect to a device: 
 + 
 +<code bash> 
 +python3 -m unittest discover -s plans/ble/05-sleepu-protocol -p 'test_*.py' 
 +python3 plans/ble/05-sleepu-protocol/analyze.py capture local/sleepu-protocol-2026-09-08/sustained-wave-capture.json 
 +python3 plans/ble/05-sleepu-protocol/analyze.py file local/sleepu-protocol-2026-09-08/*.bin 
 +</code> 
 + 
 +The capture command expects September probe events with ''kind=request/notify'', 
 +hex bytes and notification times. It checks every request/response CRC, block 
 +matching, serialization and complete transactions before reporting counts and 
 +waveform timing. The file command validates version, calendar, byte size and 
 +record alignment. It reports aggregate validity counts without printing dates, 
 +serials or physiological sample values. Five regression tests cover a captured 
 +fragmented live frame, corruption/truncation, overlapping/unsolicited replies, 
 +waveform count validation/raw marker preservation, and file integrity/sentinels. 
 + 
 +===== Current Somnolinc implementation (2026-09-14) ===== 
 + 
 +Connect the SpO₂ finger sensor in the live view and press **Record**. The phone 
 +records its live BLE readings together with any connected headband/chestbelt. 
 +SleepU can also be the only connected recording device. **Stop record** finalizes 
 +the capture and EDF+ export while leaving the live sensor connection active. 
 + 
 +Connecting alone leaves the display frozen. **Start** resumes all connected live 
 +displays, including SleepU; **Freeze** holds their plots without interrupting an 
 +active recording. **Record** also resumes the displays, and **Stop record** freezes 
 +them, matching the headband/chestbelt controls. Resuming appends to the retained 
 +display history with a gray vertical separator in both SpO₂ and pulse. The traces 
 +do not connect across the omitted pause interval. BLE 
 +polling remains active while connected, independently of display updates. Turning 
 +the screen off or backgrounding the app freezes all live displays and keeps the 
 +connections and any active recording. Press **Start** to resume after returning. 
 + 
 +This is phone recording of the existing approximately 1 Hz numeric feed. It does 
 +not download SleepU's internal files or request pleth/raw PPG waveform data. 
 + 
 +The source ''sleepu-1'' / stream ''live'' records SpO₂, pulse, PI, motion, battery 
 +percentage, worn, and countdown. Battery state, packed IV flags, and the step 
 +counter still arrive in the shared BLE packet but are omitted from new captures 
 +and EDF exports. Older recordings retain their original channel descriptors. 
 +Raw numeric invalid sentinels are retained in capture; EDF marks invalid 
 +oxygen/pulse/PI as missing, preserving other status channels. 
 +EDF uses its existing gap annotations and physical-minimum filler convention; 
 +consumers must honor the annotations rather than treating filler as a valid value. 
 + 
 +Timestamps use Android elapsed realtime when the notification completing the 
 +frame arrives. SleepU sends no sample timestamps or sequence numbers. The capture 
 +retains each observation time; EDF uses a nominal 1 Hz grid, with indices rounded 
 +from elapsed time since the first reading. Missed polls and reconnect gaps remain 
 +holes, and competing observations in the same grid cell use the existing EDF 
 +collision handling. This timing is not the sensor's exact physiological sampling 
 +time. The firmware-segment fields describe one synthetic host-clock segment here. 
 + 
 +Recording retains the SleepU connection when the app goes into the background. 
 +Changing a recorded SleepU connection stops and saves the current recording first. 
 +Automatic reconnect keeps the timeline; terminal connection loss follows the same 
 +stop behavior as the other recorded devices. Display visibility does not govern 
 +recording ownership: connected devices are selected when recording starts. 
 + 
 +The current client does not read INFO identity. The descriptor's device identifier 
 +is a connection address, not a durable unit key or authenticated identity. Recording 
 +rejects a different connection address while attached. Serial/version metadata can 
 +be added when INFO support is implemented. 
 + 
 +The Android client currently implements numeric polling only. Its frame codec 
 +caps payloads at 64 bytes; INFO/file/waveform support requires extending that 
 +limit and adding request-aware dispatch. The device capability matrix above 
 +does not imply those features are exposed by the app. 
 + 
 +==== Copy a recording to Downloads ====
  
-^ CMD ^ Name ^ Notes ^ +In the live view, open **More → Export recording**, select a completed recording, 
-| ''0x14'' | INFO | Captured above. Optional. | +and press **Export**. This works for any device combination. The app copies the 
-| ''0x03'' | FILE_OPEN | Overnight ''*.vld'' download. **Not captured** on this unit. | +EDF and unchanged ''manifest.json'' into ''Downloads/Somnolinc/<recording-id>_export_<suffix>/''. 
-| ''0x04'' | FILE_READ | Not captured. | +The EDF is named ''<recording-id>.edf'', retaining the device/date identifier used 
-| ''0x05'' | FILE_CLOSE | Not captured. | +by the private recording folder. Each export uses a separate folder; the original 
-| ''0x1B'' (27) | RT wave | ''oxyGetRtWave''. Not captured. | +recording stays in private storage. Active, unfinished, and failed EDF exports 
-| ''0x1C'' (28) | PPG | ''oxyGetPpgRt''. Not captured. |+are not listed. Exporting an older recording does not stop a current recording.
  
-===== Implementation checklist =====+Android 10+ uses [[https://developer.android.com/training/data-storage/shared/media|MediaStore Downloads]] 
 +without a storage permission. Android 8–9 requests storage permission when exporting. 
 +The UI reports success only after both files have been copied and published.
  
-  * 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 
  
  • wellue_ble_protocol.1787265828.txt.gz
  • Last modified: 2026/08/20 22:43
  • by fabricio