Show pageOld revisionsBacklinksBack to top This page is read only. You can view the source, but not change it. Ask your administrator if you think this is wrong. ====== Wellue / Viatom SleepU — protocol, capabilities and Somnolinc integration ====== Investigation: **2026-09-08**. Tested retail SleepU: INFO model ''1654'', hardware ''AA'', firmware ''1.3.0'', protocol ''1.4'', file version ''3'', branch ''21070000''. 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. Protocol capabilities and implemented Android features are distinguished below. All command numbers are hexadecimal. ===== Evidence and limits ===== **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. Sources: * [[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. Initial probes were read-only. Subsequent user-authorized write tests temporarily changed oxygen threshold 90 → 91 and motor intensity 20 → 21, then restored both with verified INFO readback. A grouped write of the original values and a clock 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. ===== Capability matrix ===== ^ Capability ^ Result on tested SleepU ^ | Live SpO₂, pulse, motion, PI, worn/countdown, battery | **Verified**, ''0x17'', 13-byte numeric payload | | Live pleth waveform | **Verified**, ''0x1B'', one unsigned 8-bit sample channel, approximately 125 samples/s | | Raw red + infrared optical samples | **Rejected**, ''0x1C'' returns error 11, including the SDK's exact request payload | | List stored recordings | **Verified**, ''0x14'' INFO ''FileList'' | | Download stored recordings | **Verified**, ''0x03'' / ''0x04'' / ''0x05''; version 3 numeric files | | Waveform/PPG in stored files | **Absent in all three downloaded files**; no separate PPG file method recovered for this model | | Interleave numeric + waveform reads | **Verified**, serialized requests over one connection | | 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 | 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. ===== Discovery and GATT ===== 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. ^ Service / role ^ UUID / observed properties ^ | Oxy service | ''14839ac4-7d7e-415c-9a42-167340cf2339'' | | Write | ''8b00ace7-eb0b-49b0-bbe9-9aee0a26e1a3'', write without response | | Notifications | ''0734594a-a8e7-4b1a-a6b1-cd5243059a57'', notify | | DIS | ''0000180a-0000-1000-8000-00805f9b34fb'' | | DIS manufacturer | ''00002a29-0000-1000-8000-00805f9b34fb'', read; returned ''Co.,Ltd.'' | | Update service | ''0000fe59-0000-1000-8000-00805f9b34fb'' | | Update characteristic | ''8ec90003-f315-4f60-9fb8-838830daea50'', write/indicate; not invoked | | GATT service changed | ''0x1801'' / ''0x2A05'', indicate | 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. ===== Transport ===== All multibyte integers below are little endian. Offsets in subsequent tables refer to the payload, excluding the seven-byte frame header. <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 ==== <code python> 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 </code> ==== 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. ===== 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) ==== <code> aa 17 e8 00 00 00 00 1b </code> ''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 ==== Valid reading (finger on, settled) ==== Host wrote ''aa 17 e8 00 00 00 00 1b''. Device notified (reassembled): <code> 55 00 ff 00 00 0d 00 62 41 00 00 00 00 00 07 03 04 08 01 00 21 </code> ^ 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. <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 | 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. ==== 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. ^ 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. ==== Captured INFO example (August 20) ==== 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 | ===== 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 ==== In the live view, open **More → Export recording**, select a completed recording, and press **Export**. This works for any device combination. The app copies the EDF and unchanged ''manifest.json'' into ''Downloads/Somnolinc/<recording-id>_export_<suffix>/''. The EDF is named ''<recording-id>.edf'', retaining the device/date identifier used by the private recording folder. Each export uses a separate folder; the original recording stays in private storage. Active, unfinished, and failed EDF exports are not listed. Exporting an older recording does not stop a current recording. 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. wellue_ble_protocol.txt Last modified: 2026/09/14 12:55by fabricio