From be6b76b774f3b9f86937c0c8a5fec72612a44b5c Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:38:21 +0530 Subject: [PATCH 1/2] ultrahuman ring air: wire format, no envelope, plain command/response fetch-by-index, no auth, no crc. record fields decoded structurally, 2 bytes at the end of the 32-byte record are unaccounted for in the docs and left unread. --- lib/openstrap_protocol.dart | 1 + lib/src/ultrahuman.dart | 193 ++++++++++++++++++++++++++++++++++++ test/ultrahuman_test.dart | 191 +++++++++++++++++++++++++++++++++++ 3 files changed, 385 insertions(+) create mode 100644 lib/src/ultrahuman.dart create mode 100644 test/ultrahuman_test.dart diff --git a/lib/openstrap_protocol.dart b/lib/openstrap_protocol.dart index 10abe3c..5532b6c 100644 --- a/lib/openstrap_protocol.dart +++ b/lib/openstrap_protocol.dart @@ -18,6 +18,7 @@ export 'src/band.dart' show DeviceType, GattProfile, BandProfile; // sharing one barrel must not share a bare verb. export 'src/oura.dart'; export 'src/hrs.dart'; +export 'src/ultrahuman.dart'; // Source 1 — record decoders. export 'src/records.dart' diff --git a/lib/src/ultrahuman.dart b/lib/src/ultrahuman.dart new file mode 100644 index 0000000..a7a5cb1 --- /dev/null +++ b/lib/src/ultrahuman.dart @@ -0,0 +1,193 @@ +// The Ultrahuman Ring Air's wire format, as pure functions. No BLE, no +// database, no crypto — a plain GATT command/response protocol with a single +// opcode byte and no envelope. Everything here takes bytes and returns +// values. +// +// NOTHING HERE HAS MET HARDWARE (ASSUMPTIONS R6). There is no auth, no +// key exchange and no vendor account anywhere in this protocol, so the reason +// this ships EXPERIMENTAL is not a missing credential — it is that nobody has +// checked a single one of these decoders against a real capture. Every field +// below is TYPED BYTE-READING off a documented offset, not a claim that the +// number it produces means what its name says: HRV, activity level and stress +// carry no documented scale or algorithm, and the two trailing response bytes +// are opaque (a plausible checksum, unverified). A decoder that is confidently +// wrong is worse than one that is silent, so nothing here is exported into an +// edge adapter's declared signals — see the adapter for that half. +// +// THE TWO OTHER PROVEN FACTS. There is no envelope: a request is just +// `[opcode, ...body]` with no length byte and no CRC, and a response is +// `[opcode, result, count, payload…, trailer(2)]` delivered as one or more +// notifications. And there is no trim: `0x04` fetches by record index and +// nothing in this protocol deletes on read or acknowledges a fetch, so a +// re-read is safe. + +import 'dart:typed_data'; + +/// Request opcodes. Only the ones this file builds a request for — see the +/// module doc for why the destructive ones (reset, airplane mode, power +/// saving) have no builder here and never will. +const int kUltrahumanOpSetTime = 0x02; +const int kUltrahumanOpGetRecordings = 0x04; +const int kUltrahumanOpGetTime = 0x05; +const int kUltrahumanOpGetEarliestIndex = 0x07; +const int kUltrahumanOpGetLatestIndex = 0x08; + +/// Response result byte. +const int kUltrahumanResultOk = 0x00; +const int kUltrahumanResultEmpty = 0xee; +const int kUltrahumanResultFail = 0xff; + +/// Fixed size of one recording record, and the whole of what a `0x04` +/// notification's payload is an array of. +const int kUltrahumanRecordLen = 32; + +List _u16le(int v) => [v & 0xff, (v >> 8) & 0xff]; + +List _u32le(int v) => [ + v & 0xff, + (v >> 8) & 0xff, + (v >> 16) & 0xff, + (v >> 24) & 0xff, + ]; + +/// Set the ring's real-time clock to [unixSeconds]. +List ultrahumanCmdSetTime(int unixSeconds) => + [kUltrahumanOpSetTime, ..._u32le(unixSeconds)]; + +/// Read the ring's real-time clock. No body. +List ultrahumanCmdGetTime() => const [kUltrahumanOpGetTime]; + +/// Fetch recordings starting at [startIndex], the ring's own record counter — +/// NOT a byte offset and not a timestamp. One request can answer with several +/// notifications, each carrying 0–7 records. +List ultrahumanCmdGetRecordings(int startIndex) => + [kUltrahumanOpGetRecordings, ..._u16le(startIndex)]; + +/// The index of the oldest recording the ring still holds. No body. +List ultrahumanCmdGetEarliestIndex() => + const [kUltrahumanOpGetEarliestIndex]; + +/// The index of the newest recording the ring holds. No body. +List ultrahumanCmdGetLatestIndex() => + const [kUltrahumanOpGetLatestIndex]; + +/// One notification off the response characteristic: +/// `[opcode, result, count, payload…, trailer(2)]`. +/// +/// [trailer] is carried but never checked — "likely a checksum" is +/// unverified, and this file does not build a decoder for a field nobody has +/// confirmed the algorithm of. +class UltrahumanResponse { + final int opcode; + final int result; + final int count; + final Uint8List payload; + final Uint8List trailer; + const UltrahumanResponse( + this.opcode, this.result, this.count, this.payload, this.trailer); + + bool get ok => result == kUltrahumanResultOk; + bool get empty => result == kUltrahumanResultEmpty; +} + +/// Parse one response notification. Null when it is too short to be one — +/// `opcode + result + count + trailer` is 5 bytes, the floor with zero payload. +UltrahumanResponse? parseUltrahumanResponse(List value) { + if (value.length < 5) return null; + final payloadLen = value.length - 5; + final bytes = Uint8List.fromList(value); + return UltrahumanResponse( + bytes[0], + bytes[1], + bytes[2], + Uint8List.sublistView(bytes, 3, 3 + payloadLen), + Uint8List.sublistView(bytes, 3 + payloadLen), + ); +} + +/// One fixed 32-byte recording, decoded structurally. +/// +/// THE DOCUMENTED FIELD TABLE ONLY ACCOUNTS FOR 30 OF THE 32 BYTES — offsets +/// 0-29 below, against a record the spec states is 32 bytes long. Bytes 30-31 +/// are read by nobody here: there is no documented field at that offset, and +/// a made-up one is exactly the failure this file exists to avoid. An adapter +/// archives the whole 32 bytes verbatim, so nothing is lost, only undecoded. +/// +/// EVERY FIELD IS A TYPED READ, NOT A CALIBRATED MEASUREMENT. [hr], [spo2] +/// report 0 for "unmeasured" exactly as the ring's own wire does — this is +/// transcribed, not reinterpreted into null, so a caller checks the same +/// sentinel the device uses. [hrv], [activityLevel] and [stress] have no +/// documented scale or algorithm at all; they are archived by an adapter, not +/// derived from. The three timestamps are independent fields on the wire and +/// are kept independent here — they are known to diverge in workout mode, and +/// collapsing them to one would be a claim nobody has checked. +class UltrahumanRecord { + final int tsA; + final int hr; + final int hrv; + final int spo2; + final int measurementType; + final int tsB; + final double maxSkinTempC; + final double minSkinTempC; + final int tsC; + final int activityLevel; + final int steps; + final int stress; + + const UltrahumanRecord({ + required this.tsA, + required this.hr, + required this.hrv, + required this.spo2, + required this.measurementType, + required this.tsB, + required this.maxSkinTempC, + required this.minSkinTempC, + required this.tsC, + required this.activityLevel, + required this.steps, + required this.stress, + }); +} + +/// Measurement-type byte values documented for [UltrahumanRecord.measurementType]. +const int kUltrahumanMeasureNormal = 1; +const int kUltrahumanMeasureExercise = 5; +const int kUltrahumanMeasureBreathing = 6; +const int kUltrahumanMeasureNotOnFinger = 100; + +/// Decode the record at [offset] in [bytes], or null when +/// `offset + 32 > bytes.length` — a truncated record, never guessed at. +UltrahumanRecord? parseUltrahumanRecord(List bytes, int offset) { + if (offset < 0 || offset + kUltrahumanRecordLen > bytes.length) return null; + final b = Uint8List.fromList(bytes); + final d = b.buffer.asByteData(b.offsetInBytes + offset); + return UltrahumanRecord( + tsA: d.getUint32(0, Endian.little), + hr: d.getUint8(4), + hrv: d.getUint8(5), + spo2: d.getUint8(6), + measurementType: d.getUint8(7), + tsB: d.getUint32(8, Endian.little), + maxSkinTempC: d.getFloat32(12, Endian.little), + minSkinTempC: d.getFloat32(16, Endian.little), + tsC: d.getUint32(20, Endian.little), + activityLevel: d.getUint16(24, Endian.little), + steps: d.getUint16(26, Endian.little), + stress: d.getUint16(28, Endian.little), + ); +} + +/// Every record packed into one response's [payload], in order. Stops at the +/// last complete 32-byte record — a payload whose length is not a multiple of +/// 32 has its remainder ignored rather than read out of bounds. +List parseUltrahumanRecords(Uint8List payload) { + final out = []; + for (var off = 0; off + kUltrahumanRecordLen <= payload.length; + off += kUltrahumanRecordLen) { + final r = parseUltrahumanRecord(payload, off); + if (r != null) out.add(r); + } + return out; +} diff --git a/test/ultrahuman_test.dart b/test/ultrahuman_test.dart new file mode 100644 index 0000000..f495a3d --- /dev/null +++ b/test/ultrahuman_test.dart @@ -0,0 +1,191 @@ +// The Ultrahuman Ring Air wire format, against constructed fixtures. +// +// UNLIKE `oura_test.dart`, THESE BYTES ARE NOT A CAPTURE. Nobody on this +// project owns a ring, so there is no real notification to pin against — the +// fixtures below are built BY HAND to the documented byte layout and exist to +// pin THIS FILE'S decoder against that documented layout, not to assert the +// layout is correct. See `ultrahuman.dart`'s own header for what is and is not +// claimed. + +import 'dart:typed_data'; + +import 'package:test/test.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +Uint8List _u32le(int v) => Uint8List.fromList( + [v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >> 24) & 0xff]); +Uint8List _u16le(int v) => Uint8List.fromList([v & 0xff, (v >> 8) & 0xff]); +Uint8List _f32le(double v) { + final b = ByteData(4)..setFloat32(0, v, Endian.little); + return b.buffer.asUint8List(); +} + +/// One 32-byte record, built field-by-field from the documented offsets. +/// The documented table only fills 30 of the 32 bytes (see `ultrahuman.dart`); +/// the trailing 2 are padding this fixture supplies to reach the real record +/// length, and the decoder never reads them. +List _record({ + int tsA = 1700000000, + int hr = 58, + int hrv = 42, + int spo2 = 97, + int measurementType = 1, + int tsB = 1700000000, + double maxSkinTempC = 34.5, + double minSkinTempC = 33.8, + int tsC = 1700000000, + int activityLevel = 12, + int steps = 30, + int stress = 20, +}) => + [ + ..._u32le(tsA), + hr, + hrv, + spo2, + measurementType, + ..._u32le(tsB), + ..._f32le(maxSkinTempC), + ..._f32le(minSkinTempC), + ..._u32le(tsC), + ..._u16le(activityLevel), + ..._u16le(steps), + ..._u16le(stress), + 0x00, 0x00, // bytes 30-31 — undocumented, not read by the decoder + ]; + +void main() { + group('outbound frames — opcode plus body, no envelope', () { + test('set time is a bare u32-LE unix second', () { + expect(ultrahumanCmdSetTime(1700000000), + [0x02, ..._u32le(1700000000)]); + }); + + test('get time, get earliest and get latest carry no body', () { + expect(ultrahumanCmdGetTime(), [0x05]); + expect(ultrahumanCmdGetEarliestIndex(), [0x07]); + expect(ultrahumanCmdGetLatestIndex(), [0x08]); + }); + + test('get recordings is a u16-LE start index', () { + expect(ultrahumanCmdGetRecordings(300), [0x04, ..._u16le(300)]); + }); + }); + + group('response framing', () { + test('opcode, result, count, payload, then a 2-byte trailer', () { + final rec = _record(); + final value = [0x04, 0x00, 1, ...rec, 0xaa, 0xbb]; + final r = parseUltrahumanResponse(value)!; + expect(r.opcode, 0x04); + expect(r.result, 0x00); + expect(r.ok, isTrue); + expect(r.count, 1); + expect(r.payload, rec); + expect(r.trailer, [0xaa, 0xbb]); + }); + + test('a zero-payload response is still framed (get-index replies)', () { + final value = [0x08, 0x00, 0, 0xaa, 0xbb]; + final r = parseUltrahumanResponse(value)!; + expect(r.payload, isEmpty); + expect(r.trailer, [0xaa, 0xbb]); + }); + + test('result 0xee means empty, 0xff means fail', () { + expect(parseUltrahumanResponse([0x04, 0xee, 0, 0, 0])!.empty, isTrue); + expect(parseUltrahumanResponse([0x04, 0xff, 0, 0, 0])!.ok, isFalse); + }); + + test('shorter than the 5-byte floor is refused, not read out of bounds', + () { + expect(parseUltrahumanResponse([0x04, 0x00, 0, 0]), isNull); + expect(parseUltrahumanResponse(const []), isNull); + }); + }); + + group('the 32-byte record', () { + test('a record is exactly 32 bytes, 2 more than the documented fields ' + 'fill, and those 2 are not read', () { + final bytes = _record(); + expect(bytes.length, kUltrahumanRecordLen); + final r = parseUltrahumanRecord(bytes, 0)!; + expect(r.stress, 20); // the last documented field, at offset 28-29 + }); + + test('every field lands at its documented offset', () { + final bytes = _record( + tsA: 1700000001, + hr: 61, + hrv: 45, + spo2: 98, + measurementType: kUltrahumanMeasureExercise, + tsB: 1700000002, + maxSkinTempC: 35.1, + minSkinTempC: 34.0, + tsC: 1700000003, + activityLevel: 88, + steps: 12, + stress: 40, + ); + final r = parseUltrahumanRecord(bytes, 0)!; + expect(r.tsA, 1700000001); + expect(r.hr, 61); + expect(r.hrv, 45); + expect(r.spo2, 98); + expect(r.measurementType, kUltrahumanMeasureExercise); + expect(r.tsB, 1700000002); + expect(r.maxSkinTempC, closeTo(35.1, 1e-4)); + expect(r.minSkinTempC, closeTo(34.0, 1e-4)); + expect(r.tsC, 1700000003); + expect(r.activityLevel, 88); + expect(r.steps, 12); + expect(r.stress, 40); + }); + + test('the three timestamps are independent, not collapsed to one', () { + final bytes = _record(tsA: 100, tsB: 200, tsC: 300); + final r = parseUltrahumanRecord(bytes, 0)!; + expect((r.tsA, r.tsB, r.tsC), (100, 200, 300)); + }); + + test('0 bpm / 0 SpO2 are transcribed, not reinterpreted as null', () { + final bytes = _record(hr: 0, spo2: 0); + final r = parseUltrahumanRecord(bytes, 0)!; + expect(r.hr, 0); + expect(r.spo2, 0); + }); + + test('a record read past the end of the buffer is refused', () { + final bytes = _record(); + expect(parseUltrahumanRecord(bytes, 1), isNull); + expect(parseUltrahumanRecord(bytes, -1), isNull); + }); + + test('offset finds the second record inside a two-record payload', () { + final payload = [..._record(tsA: 1), ..._record(tsA: 2)]; + final r = parseUltrahumanRecord(payload, kUltrahumanRecordLen)!; + expect(r.tsA, 2); + }); + }); + + group('parseUltrahumanRecords — a whole batch', () { + test('unpacks every record in a payload, in order', () { + final payload = Uint8List.fromList( + [..._record(tsA: 1), ..._record(tsA: 2), ..._record(tsA: 3)]); + final rs = parseUltrahumanRecords(payload); + expect(rs.map((r) => r.tsA), [1, 2, 3]); + }); + + test('a trailing partial record is ignored, not read out of bounds', () { + final payload = + Uint8List.fromList([..._record(tsA: 1), 0x01, 0x02, 0x03]); + final rs = parseUltrahumanRecords(payload); + expect(rs.map((r) => r.tsA), [1]); + }); + + test('an empty payload decodes to no records', () { + expect(parseUltrahumanRecords(Uint8List(0)), isEmpty); + }); + }); +} From 1caf44850c4c4f0eacef7dc60c61369e4123e284 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:56:10 +0530 Subject: [PATCH 2/2] ultrahuman: reject malformed frames instead of silently truncating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _u16le/_u32le now throw on an out-of-range value instead of wrapping — a corrupted stored cursor no longer emits a wrong-but-valid-looking index. parseUltrahumanResponse now rejects an ok 0x04 reply whose count byte doesn't match its actual payload length, rather than handing back a response that silently decodes to fewer records than it claims. --- lib/src/ultrahuman.dart | 48 ++++++++++++++++++++++++++++++--------- test/ultrahuman_test.dart | 21 +++++++++++++++++ 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/lib/src/ultrahuman.dart b/lib/src/ultrahuman.dart index a7a5cb1..5c54c09 100644 --- a/lib/src/ultrahuman.dart +++ b/lib/src/ultrahuman.dart @@ -41,14 +41,24 @@ const int kUltrahumanResultFail = 0xff; /// notification's payload is an array of. const int kUltrahumanRecordLen = 32; -List _u16le(int v) => [v & 0xff, (v >> 8) & 0xff]; +List _u16le(int v) { + if (v < 0 || v > 0xffff) { + throw RangeError.value(v, 'v', 'must fit in an unsigned 16-bit field'); + } + return [v & 0xff, (v >> 8) & 0xff]; +} -List _u32le(int v) => [ - v & 0xff, - (v >> 8) & 0xff, - (v >> 16) & 0xff, - (v >> 24) & 0xff, - ]; +List _u32le(int v) { + if (v < 0 || v > 0xffffffff) { + throw RangeError.value(v, 'v', 'must fit in an unsigned 32-bit field'); + } + return [ + v & 0xff, + (v >> 8) & 0xff, + (v >> 16) & 0xff, + (v >> 24) & 0xff, + ]; +} /// Set the ring's real-time clock to [unixSeconds]. List ultrahumanCmdSetTime(int unixSeconds) => @@ -91,15 +101,31 @@ class UltrahumanResponse { } /// Parse one response notification. Null when it is too short to be one — -/// `opcode + result + count + trailer` is 5 bytes, the floor with zero payload. +/// `opcode + result + count + trailer` is 5 bytes, the floor with zero payload +/// — or when a successful `0x04` reply's `count` byte claims a different +/// number of records than its payload actually holds (e.g. count=1 against a +/// 2-byte payload): a caller reading `count` records out of a payload that +/// doesn't hold that many is exactly the "confidently wrong" failure this +/// file exists to avoid, so the malformed frame is rejected outright rather +/// than silently handed back short. Only checked on `kUltrahumanResultOk` — +/// a fail/empty result's `count` byte is not documented to carry this +/// meaning, and still needs to reach the caller so it can abort properly. UltrahumanResponse? parseUltrahumanResponse(List value) { if (value.length < 5) return null; final payloadLen = value.length - 5; final bytes = Uint8List.fromList(value); + final opcode = bytes[0]; + final result = bytes[1]; + final count = bytes[2]; + if (opcode == kUltrahumanOpGetRecordings && + result == kUltrahumanResultOk && + payloadLen != count * kUltrahumanRecordLen) { + return null; + } return UltrahumanResponse( - bytes[0], - bytes[1], - bytes[2], + opcode, + result, + count, Uint8List.sublistView(bytes, 3, 3 + payloadLen), Uint8List.sublistView(bytes, 3 + payloadLen), ); diff --git a/test/ultrahuman_test.dart b/test/ultrahuman_test.dart index f495a3d..931a381 100644 --- a/test/ultrahuman_test.dart +++ b/test/ultrahuman_test.dart @@ -70,6 +70,13 @@ void main() { test('get recordings is a u16-LE start index', () { expect(ultrahumanCmdGetRecordings(300), [0x04, ..._u16le(300)]); }); + + test('out-of-range values are rejected, not silently truncated', () { + expect(() => ultrahumanCmdGetRecordings(-1), throwsRangeError); + expect(() => ultrahumanCmdGetRecordings(0x10000), throwsRangeError); + expect(() => ultrahumanCmdSetTime(-1), throwsRangeError); + expect(() => ultrahumanCmdSetTime(0x100000000), throwsRangeError); + }); }); group('response framing', () { @@ -102,6 +109,20 @@ void main() { expect(parseUltrahumanResponse([0x04, 0x00, 0, 0]), isNull); expect(parseUltrahumanResponse(const []), isNull); }); + + test( + 'an ok 0x04 reply whose count does not match its payload is refused, ' + 'not silently truncated', () { + // count=1 claims one 32-byte record; payload is 2 bytes. + final value = [0x04, 0x00, 1, 0x11, 0x22, 0xaa, 0xbb]; + expect(parseUltrahumanResponse(value), isNull); + }); + + test('a fail/empty result is still framed even if count looks off', () { + // Result byte governs these, not count/payload agreement. + expect(parseUltrahumanResponse([0x04, 0xff, 9, 0xaa, 0xbb]), isNotNull); + expect(parseUltrahumanResponse([0x04, 0xee, 9, 0xaa, 0xbb]), isNotNull); + }); }); group('the 32-byte record', () {