====== 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.
Request: AA command (command XOR FF) block:u16 length:u16 payload CRC8
Response: 55 status (status XOR FF) block:u16 length:u16 payload CRC8
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 ====
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.
===== 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) ====
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
==== 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 |
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):
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 |
===== 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:
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
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/_export_/''.
The EDF is named ''.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.