From ef24229a0f002292ed34b8d87e9eee452486e913 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:39:49 +0530 Subject: [PATCH 1/3] o2ring: frame envelope + info reply wellue o2ring wire format. header/cmd/cmdxor/block/len/data/crc8, same crc8 poly whoop already uses, just over a wider span. verified against a published byte-exact real-hardware request vector. skips the file open/read/close commands on purpose - the only doc for them disagrees with itself on where the reply fields land, and guessing a byte order to drive a read loop is not something this repo does. info command + its json reply (battery/model/serial/file list) only. --- lib/openstrap_protocol.dart | 1 + lib/src/o2ring.dart | 147 ++++++++++++++++++++++++++++++++++++ test/o2ring_test.dart | 81 ++++++++++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 lib/src/o2ring.dart create mode 100644 test/o2ring_test.dart diff --git a/lib/openstrap_protocol.dart b/lib/openstrap_protocol.dart index 10abe3c..871c408 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/o2ring.dart'; // Source 1 — record decoders. export 'src/records.dart' diff --git a/lib/src/o2ring.dart b/lib/src/o2ring.dart new file mode 100644 index 0000000..14ae47d --- /dev/null +++ b/lib/src/o2ring.dart @@ -0,0 +1,147 @@ +// Wellue O2Ring pulse-oximeter ring: wire envelope only. +// +// NOTHING HERE HAS MET HARDWARE. Nobody on this project owns one, so this is +// verified the same way `oura.dart` is: independent published documentation, +// one byte-exact test vector, and the compiler. It ships EXPERIMENTAL until an +// owner cross-confirms it. +// +// THE ENVELOPE IS PROVEN, NOT GUESSED. `[0xAA][cmd][cmd^0xFF][block +// u16LE][len u16LE][data...][crc8]` and the trailing byte is [crc8] over +// everything before it — the SAME CRC-8 (poly 0x07, non-reflected) this +// package already ships for WHOOP's header, just applied over a wider span. +// The no-payload request this file builds for opcode 0x17 comes out +// byte-for-byte `AA 17 E8 00 00 00 00 1B`, which is an independently +// published, hardware-captured request for this exact ring family — the one +// external fact this module is checked against. +// +// WHAT IS DELIBERATELY NOT HERE. Every documented account of this ring's file +// commands (open/read/close a stored recording) disagrees with the envelope +// above on where the status and size fields land inside the reply — one +// reading only makes sense if "byte 1" means something other than the +// envelope's own byte 1. Guessing a byte order to drive a read-until-done +// loop is exactly the fabricated-decoder failure this project refuses, so +// there is no file-open/file-read/file-close builder here. Only the frame +// envelope and the one command whose reply is unambiguous prose (INFO's JSON) +// are exposed. A future session with a real ring should confirm the file +// commands against it before this module grows them. +// +// NO PHYSIOLOGICAL FIELD IS DECODED, ANYWHERE IN THIS FILE, ON PURPOSE. This +// ring's real-time reply layout (SpO2, pulse, perfusion index) is published +// and even hardware-tested elsewhere, and none of it is reproduced here: this +// project's own rule is that "documented" is not "hardware-verified", and the +// only thing this module hands back from a notification is the frame it +// arrived in, for the caller to archive verbatim. + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'crc.dart' show crc8; + +/// One frame off the notify characteristic, with its CRC already checked. +class O2RingFrame { + /// The command byte this frame answers (or carries, for a request the + /// caller built with [buildO2RingCommand]). + final int cmd; + + /// The block field. Meaningful only for file commands, which this module + /// does not build — carried through unconditionally since it costs nothing + /// to keep. + final int block; + + /// The payload after the 7-byte header and before the trailing CRC byte. + final Uint8List data; + + const O2RingFrame(this.cmd, this.block, this.data); +} + +/// Parse one notification into a [O2RingFrame]. Null when the buffer is too +/// short, the header markers do not match, the declared length runs past the +/// buffer, or the trailing CRC-8 does not check out. +/// +/// The CRC covers every byte from the leading `0xAA` through the end of +/// `data` — i.e. everything except the CRC byte itself, which is the reading +/// the byte-exact `0x17` test vector confirms (see this file's own header). +O2RingFrame? parseO2RingFrame(List value) { + if (value.length < 8) return null; + if (value[0] != 0xAA) return null; + final cmd = value[1]; + if (value[2] != (cmd ^ 0xFF) & 0xFF) return null; + final block = value[3] | (value[4] << 8); + final len = value[5] | (value[6] << 8); + final total = 7 + len + 1; + if (value.length < total) return null; + final body = value.sublist(0, 7 + len); + if (crc8(body) != value[7 + len]) return null; + return O2RingFrame(cmd, block, Uint8List.fromList(value.sublist(7, 7 + len))); +} + +/// Build one outbound command frame: header, [data], then the CRC-8 trailer. +/// The complete frame, ready for `link.write`. +List buildO2RingCommand(int cmd, {int block = 0, List data = const []}) { + final len = data.length; + final body = [ + 0xAA, + cmd & 0xFF, + (cmd ^ 0xFF) & 0xFF, + block & 0xFF, + (block >> 8) & 0xFF, + len & 0xFF, + (len >> 8) & 0xFF, + ...data, + ]; + return [...body, crc8(body)]; +} + +/// Device info / battery / stored-file list. No payload. +const int kO2RingCmdInfo = 0x14; + +/// `buildO2RingCommand(kO2RingCmdInfo)`, named for the one call site. +List o2ringCmdInfo() => buildO2RingCommand(kO2RingCmdInfo); + +/// The ring's answer to [o2ringCmdInfo]: battery, model, serial and the +/// stored-file list, as a plain JSON object — not a bit-packed layout, so +/// unlike everything physiological this ring emits, there is no byte order to +/// get wrong here. +class O2RingInfo { + final int? batteryPct; + final String? model; + final String? serial; + + /// Stored recording file names, oldest call order preserved. + final List files; + + const O2RingInfo({ + this.batteryPct, + this.model, + this.serial, + this.files = const [], + }); +} + +/// Decode an INFO reply's payload. Null when it is not valid JSON or not a +/// JSON object — a caller should archive the frame either way and treat this +/// as best-effort metadata, never a gate on doing so. +O2RingInfo? parseO2RingInfo(List data) { + Object? obj; + try { + obj = jsonDecode(utf8.decode(data)); + } catch (_) { + return null; + } + if (obj is! Map) return null; + final bat = obj['CurBAT']; + final pct = bat is String ? int.tryParse(bat.replaceAll('%', '')) : null; + final rawFiles = obj['FileList']; + final files = rawFiles is String && rawFiles.isNotEmpty + ? [ + for (final f in rawFiles.split(',')) + if (f.trim().isNotEmpty) f.trim(), + ] + : const []; + return O2RingInfo( + batteryPct: pct, + model: obj['Model'] as String?, + serial: obj['SN'] as String?, + files: files, + ); +} diff --git a/test/o2ring_test.dart b/test/o2ring_test.dart new file mode 100644 index 0000000..91ad354 --- /dev/null +++ b/test/o2ring_test.dart @@ -0,0 +1,81 @@ +import 'package:openstrap_protocol/openstrap_protocol.dart'; +import 'package:test/test.dart'; + +void main() { + group('O2Ring frame envelope', () { + test('builds the published, hardware-captured 0x17 request byte-exact', () { + // AA 17 E8 00 00 00 00 1B — an independently published request for + // this ring family, captured off real hardware. This is the one + // external fact this module is checked against. + expect( + buildO2RingCommand(0x17), + equals([0xAA, 0x17, 0xE8, 0x00, 0x00, 0x00, 0x00, 0x1B]), + ); + }); + + test('round-trips a command with a payload through parse', () { + final built = buildO2RingCommand(0x14, block: 7, data: [1, 2, 3]); + final f = parseO2RingFrame(built); + expect(f, isNotNull); + expect(f!.cmd, 0x14); + expect(f.block, 7); + expect(f.data, equals([1, 2, 3])); + }); + + test('refuses a bad header marker', () { + final built = buildO2RingCommand(0x14)..[0] = 0xAB; + expect(parseO2RingFrame(built), isNull); + }); + + test('refuses a mismatched cmd/cmd-xor pair', () { + final built = buildO2RingCommand(0x14)..[2] = 0x00; + expect(parseO2RingFrame(built), isNull); + }); + + test('refuses a flipped data byte (CRC catches it)', () { + final built = buildO2RingCommand(0x14, data: [0x55]); + built[7] ^= 0xFF; + expect(parseO2RingFrame(built), isNull); + }); + + test('refuses a declared length longer than the buffer', () { + final built = buildO2RingCommand(0x14, data: [1, 2, 3]); + final truncated = built.sublist(0, built.length - 2); + expect(parseO2RingFrame(truncated), isNull); + }); + + test('refuses a buffer shorter than the minimum frame', () { + expect(parseO2RingFrame([0xAA, 0x14, 0xEB]), isNull); + }); + }); + + group('INFO reply', () { + test('decodes battery, model, serial and the file list', () { + const json = '{"CurBAT":"75%","FileList":' + '"20260116233312.vld,20260115221045.vld",' + '"Model":"O2Ring","SN":"ABC123"}'; + final info = parseO2RingInfo(json.codeUnits); + expect(info, isNotNull); + expect(info!.batteryPct, 75); + expect(info.model, 'O2Ring'); + expect(info.serial, 'ABC123'); + expect(info.files, equals([ + '20260116233312.vld', + '20260115221045.vld', + ])); + }); + + test('an empty file list decodes to no files, not one blank entry', () { + const json = '{"CurBAT":"50%","FileList":"","Model":"O2Ring","SN":"X"}'; + expect(parseO2RingInfo(json.codeUnits)!.files, isEmpty); + }); + + test('refuses non-JSON rather than guessing at fields', () { + expect(parseO2RingInfo([0xAA, 0x14, 0x00]), isNull); + }); + + test('refuses a JSON value that is not an object', () { + expect(parseO2RingInfo('[1,2,3]'.codeUnits), isNull); + }); + }); +} From fbda904267b6bb456cc218a7f8db8f254412d53c Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:59:09 +0530 Subject: [PATCH 2/3] o2ring: guard Model/SN types instead of casting an open JSON value cast with 'as String?' throws on a non-string, non-null value instead of falling back to null - breaks this function's own never-throws contract from inside a BLE notification callback. also accept a bare numeric battery, not just a percent string. --- lib/src/o2ring.dart | 19 ++++++++++++++++--- test/o2ring_test.dart | 16 ++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/lib/src/o2ring.dart b/lib/src/o2ring.dart index 14ae47d..34edfaa 100644 --- a/lib/src/o2ring.dart +++ b/lib/src/o2ring.dart @@ -130,7 +130,14 @@ O2RingInfo? parseO2RingInfo(List data) { } if (obj is! Map) return null; final bat = obj['CurBAT']; - final pct = bat is String ? int.tryParse(bat.replaceAll('%', '')) : null; + // A percent string is the documented shape; a bare number is accepted too + // rather than refused, since nothing here depends on which one a given + // firmware sends. + final pct = switch (bat) { + num n => n.toInt(), + String s => int.tryParse(s.replaceAll('%', '').trim()), + _ => null, + }; final rawFiles = obj['FileList']; final files = rawFiles is String && rawFiles.isNotEmpty ? [ @@ -138,10 +145,16 @@ O2RingInfo? parseO2RingInfo(List data) { if (f.trim().isNotEmpty) f.trim(), ] : const []; + // GUARDED, NOT CAST. An open JSON value that turns out not to be a string + // must fall back to null like every other field here, never throw a + // TypeError past this function's own null-on-anything-unusable contract — + // the caller runs this from inside a BLE notification callback. + final model = obj['Model']; + final serial = obj['SN']; return O2RingInfo( batteryPct: pct, - model: obj['Model'] as String?, - serial: obj['SN'] as String?, + model: model is String ? model : null, + serial: serial is String ? serial : null, files: files, ); } diff --git a/test/o2ring_test.dart b/test/o2ring_test.dart index 91ad354..0467ad4 100644 --- a/test/o2ring_test.dart +++ b/test/o2ring_test.dart @@ -70,6 +70,22 @@ void main() { expect(parseO2RingInfo(json.codeUnits)!.files, isEmpty); }); + test('a numeric battery is accepted, not just a percent string', () { + const json = '{"CurBAT":75,"FileList":"","Model":"O2Ring","SN":"X"}'; + expect(parseO2RingInfo(json.codeUnits)!.batteryPct, 75); + }); + + test('a non-string Model or SN falls back to null, never throws', () { + const json = '{"CurBAT":"75%","FileList":"","Model":123,"SN":false}'; + final info = parseO2RingInfo(json.codeUnits); + expect(info, isNotNull); + expect(info!.model, isNull); + expect(info.serial, isNull); + // The rest of the object still decodes — one bad field does not sink + // the whole reply. + expect(info.batteryPct, 75); + }); + test('refuses non-JSON rather than guessing at fields', () { expect(parseO2RingInfo([0xAA, 0x14, 0x00]), isNull); }); From 3d7a4387e45be95b380f18205d78df0e4ef5afaa Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:25:39 +0530 Subject: [PATCH 3/3] o2ring: pin the INFO opcode against its actual call site kO2RingCmdInfo was only exercised through opcode-agnostic round-trip tests. Add a test on o2ringCmdInfo() itself pinning the byte-exact frame, and note in the header/const doc that 0x14 is independently documented as this ring's INFO opcode, separate from the proven envelope math. --- lib/src/o2ring.dart | 16 +++++++++++++++- test/o2ring_test.dart | 15 +++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/lib/src/o2ring.dart b/lib/src/o2ring.dart index 34edfaa..c1cd7f6 100644 --- a/lib/src/o2ring.dart +++ b/lib/src/o2ring.dart @@ -12,7 +12,17 @@ // The no-payload request this file builds for opcode 0x17 comes out // byte-for-byte `AA 17 E8 00 00 00 00 1B`, which is an independently // published, hardware-captured request for this exact ring family — the one -// external fact this module is checked against. +// external fact the envelope math itself is checked against. +// +// THE ONE OPCODE THIS FILE ACTUALLY USES IS ALSO INDEPENDENTLY DOCUMENTED, +// SEPARATELY FROM THE ENVELOPE MATH. 0x14 is named INFO (empty request, +// JSON reply) in independently published protocol notes for this ring +// family, matching this file's own doc comment and its `O2RingInfo` field +// names below. That is a second, distinct external fact — the opcode value +// itself — layered on top of the byte-order proof above, not a substitute +// for it: nobody on this project has confirmed a real ring answers `AA 14 +// EB 00 00 00 00 C6` (the deterministic frame the proven envelope produces +// for that opcode with no payload). // // WHAT IS DELIBERATELY NOT HERE. Every documented account of this ring's file // commands (open/read/close a stored recording) disagrees with the envelope @@ -93,6 +103,10 @@ List buildO2RingCommand(int cmd, {int block = 0, List data = const []} } /// Device info / battery / stored-file list. No payload. +/// +/// 0x14 is independently documented elsewhere as this ring family's INFO +/// opcode — see this file's header for what that external fact does and +/// does not cover. const int kO2RingCmdInfo = 0x14; /// `buildO2RingCommand(kO2RingCmdInfo)`, named for the one call site. diff --git a/test/o2ring_test.dart b/test/o2ring_test.dart index 0467ad4..40ceb91 100644 --- a/test/o2ring_test.dart +++ b/test/o2ring_test.dart @@ -13,6 +13,21 @@ void main() { ); }); + test('the actual INFO call site builds the documented opcode byte-exact', + () { + // AA 14 EB 00 00 00 00 C6 — cmd 0x14 (independently documented + // elsewhere as this ring family's INFO opcode, see o2ring.dart's + // header) run through the envelope math the 0x17 vector above + // proves. Exercises o2ringCmdInfo() itself, the only call site this + // module actually uses, so a typo or later edit to kO2RingCmdInfo + // fails here instead of passing silently through an opcode-agnostic + // round-trip. + expect( + o2ringCmdInfo(), + equals([0xAA, 0x14, 0xEB, 0x00, 0x00, 0x00, 0x00, 0xC6]), + ); + }); + test('round-trips a command with a payload through parse', () { final built = buildO2RingCommand(0x14, block: 7, data: [1, 2, 3]); final f = parseO2RingFrame(built);