From ed93304b3d8bc314802b62302eb0099568da227f Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:22:31 +0530 Subject: [PATCH 1/5] Multi-device decoders: add new families, drop undecoded ones, fix layouts Add decoders for Colmi rings, Garmin GFDI file transfer and FIT health records, Pebble health datalog, Mi Band 2/3 activity history, Mi scales and the standard health thermometer. Remove decoders for families that only archived raw bytes (DaFit, Lefun, O2Ring, R11M, RingConn, WearFit, ZeTime). Layout fixes after a field-by-field review: - Ultrahuman: byte 28 is SDNN HRV (no stress field), skin vs ambient temperature, u16 activity, temp quality byte, ring-state quality enum, lenient record framing, non-OK status handling. - Colmi: both temperature encodings, slot interval from page 0, stress day offset, SpO2 max/min order, calorie scale, page end conditions. - Garmin: compact message types in every parser, transaction ids, single replies, capability set, chunk status codes, frame splitting. - Mi Band / Mi scales: activity count is in samples, scale unit bits, little-endian history user id, overload value, impedance validity. - Polar PMD: invalid PP intervals and skin-contact bits. --- lib/openstrap_protocol.dart | 14 +- lib/src/colmi.dart | 612 +++++++++++++++++++++++++++++++ lib/src/dafit.dart | 192 ---------- lib/src/garmin.dart | 225 +++++++++--- lib/src/garmin_fit.dart | 323 ++++++++++++++++ lib/src/garmin_transfer.dart | 233 ++++++++++++ lib/src/health_thermometer.dart | 119 ++++++ lib/src/hrs.dart | 9 +- lib/src/huami_legacy.dart | 161 ++++++++ lib/src/lefun.dart | 128 ------- lib/src/miscale.dart | 176 +++++++++ lib/src/o2ring.dart | 174 --------- lib/src/pebble.dart | 216 +++++++++++ lib/src/polar_pmd.dart | 58 ++- lib/src/ring11m.dart | 310 ---------------- lib/src/ringconn.dart | 384 ------------------- lib/src/ultrahuman.dart | 164 +++++---- lib/src/wearfit.dart | 108 ------ lib/src/zetime.dart | 95 ----- test/colmi_test.dart | 287 +++++++++++++++ test/dafit_test.dart | 111 ------ test/garmin_fit_test.dart | 115 ++++++ test/garmin_test.dart | 121 +++++- test/garmin_transfer_test.dart | 101 +++++ test/hrs_test.dart | 13 +- test/huami_legacy_test.dart | 64 ++++ test/lefun_test.dart | 126 ------- test/o2ring_test.dart | 112 ------ test/pebble_test.dart | 61 +++ test/polar_pmd_test.dart | 29 ++ test/ring11m_test.dart | 183 --------- test/ringconn_test.dart | 308 ---------------- test/thermometer_scale_test.dart | 159 ++++++++ test/ultrahuman_test.dart | 103 ++++-- test/wearfit_test.dart | 116 ------ test/zetime_test.dart | 54 --- 36 files changed, 3173 insertions(+), 2591 deletions(-) create mode 100644 lib/src/colmi.dart delete mode 100644 lib/src/dafit.dart create mode 100644 lib/src/garmin_fit.dart create mode 100644 lib/src/garmin_transfer.dart create mode 100644 lib/src/health_thermometer.dart create mode 100644 lib/src/huami_legacy.dart delete mode 100644 lib/src/lefun.dart create mode 100644 lib/src/miscale.dart delete mode 100644 lib/src/o2ring.dart create mode 100644 lib/src/pebble.dart delete mode 100644 lib/src/ring11m.dart delete mode 100644 lib/src/ringconn.dart delete mode 100644 lib/src/wearfit.dart delete mode 100644 lib/src/zetime.dart create mode 100644 test/colmi_test.dart delete mode 100644 test/dafit_test.dart create mode 100644 test/garmin_fit_test.dart create mode 100644 test/garmin_transfer_test.dart create mode 100644 test/huami_legacy_test.dart delete mode 100644 test/lefun_test.dart delete mode 100644 test/o2ring_test.dart create mode 100644 test/pebble_test.dart delete mode 100644 test/ring11m_test.dart delete mode 100644 test/ringconn_test.dart create mode 100644 test/thermometer_scale_test.dart delete mode 100644 test/wearfit_test.dart delete mode 100644 test/zetime_test.dart diff --git a/lib/openstrap_protocol.dart b/lib/openstrap_protocol.dart index 0bf901b..72885d0 100644 --- a/lib/openstrap_protocol.dart +++ b/lib/openstrap_protocol.dart @@ -18,16 +18,16 @@ 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/ring11m.dart'; export 'src/garmin.dart'; export 'src/ultrahuman.dart'; -export 'src/wearfit.dart'; -export 'src/o2ring.dart'; -export 'src/ringconn.dart'; -export 'src/zetime.dart'; -export 'src/dafit.dart'; export 'src/polar_pmd.dart'; -export 'src/lefun.dart'; +export 'src/colmi.dart'; +export 'src/health_thermometer.dart'; +export 'src/miscale.dart'; +export 'src/huami_legacy.dart'; +export 'src/pebble.dart'; +export 'src/garmin_fit.dart'; +export 'src/garmin_transfer.dart'; // Source 1 — record decoders. export 'src/records.dart' diff --git a/lib/src/colmi.dart b/lib/src/colmi.dart new file mode 100644 index 0000000..915842f --- /dev/null +++ b/lib/src/colmi.dart @@ -0,0 +1,612 @@ +// Colmi smart ring family (R02/R03/R06/R07/R09/R10/R12 and siblings): the +// wire format, request builders and history decoders. +// +// TWO CHANNELS ON ONE RING. +// * Service A (6e40fff0…): fixed 16-byte frames, `[cmd][payload x14][sum]`, +// where `sum` is the low byte of bytes 0-14. Battery, clock, and the paged +// HR / stress / HRV / activity history walks live here. +// * Service B (de5bf728…): "big data", `[0xbc][type][u16 len][u16 crc] +// [payload]`, crc = CRC-16/MODBUS of the payload, all little-endian. A +// reply larger than one notification is split across several and has to +// be reassembled ([ColmiBigDataReassembler]). Sleep, SpO2 and temperature +// history live here. Whether the ring fills a REPLY's crc the same way is +// unverified, so [colmiBigDataCrcOk] is a check to log, not to drop on. +// +// NO HANDSHAKE, NO KEY. Connect, subscribe, write. +// +// TIME IS LOCAL WALL CLOCK, NOT UTC. The ring keeps the wall-clock time the +// host last set (BCD, no zone), and every history reply is relative to a +// local calendar day: "N days ago" plus a minute-of-day. This file never turns +// either into an epoch — it has no zone to do it with. Decoders return +// `daysAgo` + `minuteOfDay` and the host resolves them against its own local +// calendar, which is the only place a DST day can be handled correctly. +// +// VALUES ARE THE RING'S OWN. Stress, HRV, SpO2 and temperature are numbers the +// ring computed with methods nobody outside the vendor can describe. They are +// decoded faithfully here; what a consumer is allowed to do with them is not +// this file's call. + +import 'crc.dart' show crc16Modbus; + +// ── Service A opcodes ──────────────────────────────────────────────────────── + +const int kColmiCmdSetTime = 0x01; +const int kColmiCmdBattery = 0x03; +const int kColmiCmdReboot = 0x08; +const int kColmiCmdHrHistory = 0x15; +const int kColmiCmdStressHistory = 0x37; +const int kColmiCmdHrvHistory = 0x39; +const int kColmiCmdActivityHistory = 0x43; + +/// Per-day sleep detail on Service A, for a ring whose SetTime reply says it +/// does not speak the big-data sleep protocol (see [colmiNewSleepProtocol]). +const int kColmiCmdSleepDetails = 0x44; +const int kColmiCmdFactoryReset = 0xff; + +/// The ring answers a request it rejects with the request's id OR'd with this. +const int kColmiErrorFlag = 0x80; + +/// Service A commands this host must never send: a reboot or a factory reset +/// (which wipes every stored day). +const Set kColmiDestructiveCmds = {kColmiCmdReboot, kColmiCmdFactoryReset}; + +/// True when [frame] is a Service A frame carrying a destructive command. +bool colmiIsDestructive(List frame) => + frame.isNotEmpty && kColmiDestructiveCmds.contains(frame[0] & 0xff); + +// ── Service B ──────────────────────────────────────────────────────────────── + +const int kColmiCmdBigData = 0xbc; +const int kColmiBigTemperature = 0x25; +const int kColmiBigSleep = 0x27; +const int kColmiBigSpo2 = 0x2a; + +/// Daytime naps. Same day-record layout as [kColmiBigSleep]; a ring that +/// records naps sends this as a second reply after the sleep reply. +const int kColmiBigNap = 0x3e; + +/// `[0xbc][type][u16 len][u16 crc]` — the fixed part before the payload. +const int kColmiBigHeaderLength = 6; + +/// Sleep stage codes as the ring sends them. +const int kColmiStageLight = 0x02; +const int kColmiStageDeep = 0x03; +const int kColmiStageRem = 0x04; +const int kColmiStageAwake = 0x05; + +/// The one-byte page index that means "nothing stored for this request". +const int kColmiNoData = 0xff; + +// ── Framing ────────────────────────────────────────────────────────────────── + +/// One 16-byte Service A frame: `[cmd][payload, zero-padded to 14][sum]`. +List colmiFrame(int cmd, [List payload = const []]) { + if (payload.length > 14) { + throw ArgumentError('Colmi payload is at most 14 bytes, got ' + '${payload.length}'); + } + final f = List.filled(16, 0); + f[0] = cmd & 0xff; + for (var i = 0; i < payload.length; i++) { + f[1 + i] = payload[i] & 0xff; + } + var sum = 0; + for (var i = 0; i < 15; i++) { + sum += f[i]; + } + f[15] = sum & 0xff; + return f; +} + +/// True when [f] is a well-formed Service A frame (length and checksum). +bool colmiFrameValid(List f) { + if (f.length != 16) return false; + var sum = 0; + for (var i = 0; i < 15; i++) { + sum += f[i]; + } + return (sum & 0xff) == f[15]; +} + +/// One Service B request: `[0xbc][type][u16 len][u16 crc16Modbus][payload]`. +List colmiBigDataRequest(int type, List payload) { + final crc = crc16Modbus(payload); + return [ + kColmiCmdBigData, + type & 0xff, + payload.length & 0xff, + (payload.length >> 8) & 0xff, + crc & 0xff, + (crc >> 8) & 0xff, + ...payload, + ]; +} + +/// Sleep history: every night the ring still holds. The trailing 0x01 also +/// asks for naps, which a ring that records them sends as a separate +/// [kColmiBigNap] reply after the sleep reply. +List colmiSleepRequest() => + colmiBigDataRequest(kColmiBigSleep, [0xff, 0x01]); + +/// SpO2 history: hourly max/min for every day the ring still holds. +List colmiSpo2Request() => colmiBigDataRequest(kColmiBigSpo2, [0xff]); + +/// Temperature history (models with a skin-temperature sensor only). +/// [mode] 6 asks for every stored day, 2 for what is new since today was +/// last read, 0 for today only. The ring answers with one reply per day. +List colmiTemperatureRequest([int mode = 6]) => + colmiBigDataRequest(kColmiBigTemperature, [mode & 0xff]); + +/// Reassembles one Service B reply split across notifications. +/// +/// Feed every Service B notification in arrival order; [add] returns the +/// complete reply (header included) once `len + 6` bytes have arrived, else +/// null. A notification that does not start with 0xbc while nothing is +/// pending is not a reply this decoder understands and is dropped. +class ColmiBigDataReassembler { + List? _pending; + int _want = 0; + + List? add(List chunk) { + if (_pending == null) { + if (chunk.length < kColmiBigHeaderLength || + chunk[0] != kColmiCmdBigData) { + return null; + } + _want = kColmiBigHeaderLength + (chunk[2] | (chunk[3] << 8)); + _pending = []; + } + _pending!.addAll(chunk); + if (_pending!.length < _want) return null; + final whole = _pending!.sublist(0, _want); + _pending = null; + return whole; + } + + bool get pending => _pending != null; +} + +/// True when a reassembled Service B reply's payload matches its CRC. +bool colmiBigDataCrcOk(List reply) { + if (reply.length < kColmiBigHeaderLength) return false; + final len = reply[2] | (reply[3] << 8); + if (reply.length < kColmiBigHeaderLength + len) return false; + final crc = reply[4] | (reply[5] << 8); + return crc16Modbus( + reply.sublist(kColmiBigHeaderLength, kColmiBigHeaderLength + len)) == + crc; +} + +// ── Service A requests ─────────────────────────────────────────────────────── + +int _bcd(int v) => ((v ~/ 10) << 4) | (v % 10); +int _unbcd(int b) => ((b >> 4) & 0x0f) * 10 + (b & 0x0f); + +/// Set the ring's clock to a LOCAL wall-clock time, BCD-encoded, then the +/// ring's display [language] (0 = Simplified Chinese, 1 = English). +List colmiSetTimeRequest(DateTime local, {int language = 1}) => + colmiFrame(kColmiCmdSetTime, [ + _bcd(local.year % 100), + _bcd(local.month), + _bcd(local.day), + _bcd(local.hour), + _bcd(local.minute), + _bcd(local.second), + language & 0xff, + ]); + +/// Whether the ring's SetTime reply says it serves sleep over the big-data +/// request ([colmiSleepRequest]); false means per-day [kColmiCmdSleepDetails] +/// on Service A. Null for a frame that is not a valid SetTime reply. +bool? colmiNewSleepProtocol(List f) => + (colmiFrameValid(f) && f[0] == kColmiCmdSetTime) ? f[9] == 1 : null; + +List colmiBatteryRequest() => colmiFrame(kColmiCmdBattery); + +/// Activity history for one local day, [daysAgo] = 0 for today. +List colmiActivityRequest(int daysAgo) => colmiFrame( + kColmiCmdActivityHistory, [daysAgo & 0xff, 0x0f, 0x00, 0x5f, 0x01]); + +/// HR history from [localWallClockSec]: the LOCAL wall-clock instant written +/// as if it were UTC seconds (local midnight for a past day, now for today). +List colmiHrHistoryRequest(int localWallClockSec) => + colmiFrame(kColmiCmdHrHistory, _u32le(localWallClockSec)); + +/// Stress history for one local day, [daysAgo] = 0 for today. +List colmiStressRequest(int daysAgo) => + colmiFrame(kColmiCmdStressHistory, [daysAgo & 0xff]); + +/// Service A sleep detail for one local day (0-29 days ago), every +/// quarter-hour slot of it (0x00-0x5f). +List colmiSleepDetailsRequest(int daysAgo) => + colmiFrame(kColmiCmdSleepDetails, [daysAgo & 0xff, 0x0f, 0x00, 0x5f]); + +/// HRV history for one local day. +List colmiHrvRequest(int daysAgo) => + colmiFrame(kColmiCmdHrvHistory, _u32le(daysAgo)); + +List _u32le(int v) => + [v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >> 24) & 0xff]; + +// ── Service A decoders ─────────────────────────────────────────────────────── + +/// Battery percentage from a battery reply, or null. +int? colmiBatteryPct(List f) => + (colmiFrameValid(f) && f[0] == kColmiCmdBattery && f[1] <= 100) + ? f[1] + : null; + +/// One time-of-day reading from a paged history walk. +class ColmiPoint { + final int minuteOfDay; + final int value; + const ColmiPoint(this.minuteOfDay, this.value); + @override + String toString() => 'ColmiPoint($minuteOfDay, $value)'; +} + +/// Page 0 of a paged HR / stress / HRV walk: how many pages the walk has +/// (header included, so the last page is `pages - 1`) and the slot interval +/// in minutes. The HR interval is user-settable on the ring, so it is read +/// from here, never assumed. +typedef ColmiPagedHeader = ({int pages, int slotMinutes}); + +/// The page-0 header of a [cmd] walk, or null for any other frame. A zero +/// interval (never seen from a ring that measures) is left for the caller's +/// default. +ColmiPagedHeader? colmiPagedHeader(List f, int cmd) { + if (!colmiFrameValid(f) || f[0] != cmd || f[1] != 0) return null; + return (pages: f[2], slotMinutes: f[3]); +} + +/// The local wall-clock instant page 1 of an HR walk is stamped with, written +/// as if it were UTC seconds (the same convention as the request). Null for +/// any other frame, or a zero stamp. +int? colmiHrPageTimestamp(List f) { + if (!colmiFrameValid(f) || f[0] != kColmiCmdHrHistory || f[1] != 1) { + return null; + } + final ts = f[2] | (f[3] << 8) | (f[4] << 16) | (f[5] << 24); + return ts == 0 ? null : ts; +} + +/// The day page 1 of a stress or HRV walk is for, as "days ago". Null for any +/// other frame. +int? colmiPagedDaysAgo(List f) { + if (!colmiFrameValid(f) || + (f[0] != kColmiCmdStressHistory && f[0] != kColmiCmdHrvHistory) || + f[1] != 1) { + return null; + } + return f[2]; +} + +/// Shared paged layout of the HR, stress and HRV walks. +/// +/// Page 0 is a header ([colmiPagedHeader]), page 0xff means "nothing +/// stored", and data pages follow. Page 1's values start at [firstStart] (the +/// bytes before it are header), every later page's at byte 2; each page +/// carries values up to byte 14 (byte 15 is the checksum). A zero value is an +/// unmeasured slot. +List _pagedPoints( + List f, { + required int cmd, + required int firstStart, + required int slotMinutes, +}) { + if (!colmiFrameValid(f) || f[0] != cmd) return const []; + final page = f[1]; + if (page == 0 || page == kColmiNoData || slotMinutes <= 0) return const []; + final firstCount = 15 - firstStart; + final start = page == 1 ? firstStart : 2; + final before = page == 1 ? 0 : firstCount + (page - 2) * 13; + final out = []; + for (var i = start; i < 15; i++) { + final v = f[i]; + if (v == 0) continue; + final minute = (before + (i - start)) * slotMinutes; + if (minute >= 1440) break; + out.add(ColmiPoint(minute, v)); + } + return out; +} + +/// HR history page: page 1 carries a 4-byte timestamp first. [slotMinutes] +/// comes from the walk's page 0; 5 is the ring's default. +List colmiHrHistoryPoints(List f, {int slotMinutes = 5}) => + _pagedPoints(f, + cmd: kColmiCmdHrHistory, firstStart: 6, slotMinutes: slotMinutes); + +/// Stress history page: the ring's own 0-100 score; page 1 carries the +/// day first. [slotMinutes] comes from page 0; 30 is the ring's default. +List colmiStressPoints(List f, {int slotMinutes = 30}) => + _pagedPoints(f, + cmd: kColmiCmdStressHistory, firstStart: 3, slotMinutes: slotMinutes); + +/// HRV history page: the ring's own value (ms); same layout as stress. +List colmiHrvPoints(List f, {int slotMinutes = 30}) => + _pagedPoints(f, + cmd: kColmiCmdHrvHistory, firstStart: 3, slotMinutes: slotMinutes); + +/// One 15-minute activity slot. +class ColmiActivitySlot { + final int year, month, day; + final int minuteOfDay; + final int steps; + + /// Ring units, already multiplied by 10 when the walk's header said so + /// (see [colmiActivityCaloriesX10]). + final int calories; + + /// Raw ring units (believed metres); scale unconfirmed. + final int distance; + final int page, pages; + const ColmiActivitySlot({ + required this.year, + required this.month, + required this.day, + required this.minuteOfDay, + required this.steps, + required this.calories, + required this.distance, + required this.page, + required this.pages, + }); + + bool get isLast => page >= pages - 1; +} + +/// For the 0xf0 header frame that opens an activity walk: whether the walk's +/// calorie counters are in tenths, i.e. must be multiplied by 10 (header byte +/// 3 == 1). Null for any other frame. +bool? colmiActivityCaloriesX10(List f) => + (colmiFrameValid(f) && f[0] == kColmiCmdActivityHistory && f[1] == 0xf0) + ? f[3] == 1 + : null; + +/// One activity reply, or null for a header / no-data / malformed frame. +/// +/// Date bytes are BCD (`0x24 0x08 0x18` = 2024-08-18); byte 4 is the +/// quarter-hour slot of the day (0-95). [caloriesX10] is the walk header's +/// [colmiActivityCaloriesX10]. +ColmiActivitySlot? colmiActivitySlot(List f, {bool caloriesX10 = false}) { + if (!colmiFrameValid(f) || f[0] != kColmiCmdActivityHistory) return null; + if (f[1] == kColmiNoData || f[1] == 0xf0) return null; + final slot = f[4]; + if (slot > 95) return null; + final month = _unbcd(f[2]), day = _unbcd(f[3]); + if (month < 1 || month > 12 || day < 1 || day > 31) return null; + return ColmiActivitySlot( + year: 2000 + _unbcd(f[1]), + month: month, + day: day, + minuteOfDay: slot * 15, + calories: (f[7] | (f[8] << 8)) * (caloriesX10 ? 10 : 1), + steps: f[9] | (f[10] << 8), + distance: f[11] | (f[12] << 8), + page: f[5], + pages: f[6], + ); +} + +/// True when [f] ends an activity walk: "no data", or the last page. +bool colmiActivityDone(List f) { + if (!colmiFrameValid(f) || f[0] != kColmiCmdActivityHistory) return false; + if (f[1] == kColmiNoData) return true; + return colmiActivitySlot(f)?.isLast ?? false; +} + +/// One row of a Service A sleep-detail walk ([colmiSleepDetailsRequest]). +/// +/// UNVERIFIED: no capture from a ring on this protocol exists, and how the +/// seven quality bytes map to sleep stages is not known, so rows are decoded +/// and banked but never turned into a hypnogram. +class ColmiSleepDetailRow { + final int year, month, day; + + /// Quarter-hour slot of the day the row starts at (0-95). + final int timeIndex; + + /// The ring's seven raw sleep-quality bytes for this row. + final List qualities; + final int page, pages; + const ColmiSleepDetailRow({ + required this.year, + required this.month, + required this.day, + required this.timeIndex, + required this.qualities, + required this.page, + required this.pages, + }); +} + +/// One sleep-detail reply, or null for the 0xf0 header, "no data" (0xff) or +/// a malformed frame. Date bytes are BCD, as in the activity walk. +ColmiSleepDetailRow? colmiSleepDetailRow(List f) { + if (!colmiFrameValid(f) || f[0] != kColmiCmdSleepDetails) return null; + if (f[1] == kColmiNoData || f[1] == 0xf0) return null; + final month = _unbcd(f[2]), day = _unbcd(f[3]); + if (month < 1 || month > 12 || day < 1 || day > 31) return null; + return ColmiSleepDetailRow( + year: 2000 + _unbcd(f[1]), + month: month, + day: day, + timeIndex: f[4], + page: f[5], + pages: f[6], + qualities: f.sublist(7, 14), + ); +} + +/// True when [f] ends a sleep-detail walk: "no data", or the last row. +bool colmiSleepDetailsDone(List f) { + if (!colmiFrameValid(f) || f[0] != kColmiCmdSleepDetails) return false; + if (f[1] == kColmiNoData) return true; + final r = colmiSleepDetailRow(f); + return r != null && r.page == r.pages - 1; +} + +// ── Service B decoders ─────────────────────────────────────────────────────── + +/// One stage block of a night: [stage] for [minutes]. +class ColmiStageBlock { + final int stage; + final int minutes; + const ColmiStageBlock(this.stage, this.minutes); +} + +/// One night (or, with [nap], one daytime nap) as the ring reports it. +/// [startMinute] and [endMinute] are minutes after LOCAL midnight of the day +/// [daysAgo]; a start greater than the end means the night began the evening +/// before. +/// +/// For a night the end and the blocks are what to trust: the night starts +/// [totalMinutes] before [endMinute], which is how the ring's own data is +/// laid out, and the start field can disagree with it. A nap's blocks run +/// forward from its [startMinute], and a block of stage 0 is a gap between +/// naps rather than sleep. +class ColmiSleepNight { + final int daysAgo; + final int startMinute; + final int endMinute; + final List blocks; + final bool nap; + const ColmiSleepNight( + this.daysAgo, this.startMinute, this.endMinute, this.blocks, + {this.nap = false}); + + bool get startsDayBefore => startMinute > endMinute; + + /// Sum of every block's minutes. + int get totalMinutes => blocks.fold(0, (s, b) => s + b.minutes); +} + +/// Decodes a reassembled sleep ([kColmiBigSleep]) or nap ([kColmiBigNap]) +/// reply. Malformed tails are dropped, never guessed. +List colmiSleepNights(List r) { + if (r.length < kColmiBigHeaderLength + 1 || + r[0] != kColmiCmdBigData || + (r[1] != kColmiBigSleep && r[1] != kColmiBigNap)) { + return const []; + } + final nap = r[1] == kColmiBigNap; + final len = r[2] | (r[3] << 8); + if (len < 2) return const []; + final end = (kColmiBigHeaderLength + len).clamp(0, r.length); + final nights = []; + final count = r[6]; + var i = 7; + for (var n = 0; n < count; n++) { + if (i + 6 > end) break; + final daysAgo = r[i]; + final dayBytes = r[i + 1]; + final start = r[i + 2] | (r[i + 3] << 8); + final stop = r[i + 4] | (r[i + 5] << 8); + i += 6; + final blockBytes = dayBytes - 4; + if (blockBytes < 0 || i + blockBytes > end) break; + final blocks = []; + for (var j = 0; j + 1 < blockBytes; j += 2) { + final minutes = r[i + j + 1]; + if (minutes > 0) blocks.add(ColmiStageBlock(r[i + j], minutes)); + } + i += blockBytes; + if (start < 1440 && stop < 1440) { + nights.add(ColmiSleepNight(daysAgo, start, stop, blocks, nap: nap)); + } + } + return nights; +} + +/// One hour of SpO2: the ring's min and max reading in that hour. +class ColmiSpo2Hour { + final int daysAgo; + final int hour; + final int min; + final int max; + const ColmiSpo2Hour(this.daysAgo, this.hour, this.min, this.max); +} + +/// Bytes per SpO2 day block: `daysAgo` then 24 x (max, min). +const int kColmiSpo2DayBytes = 49; + +/// Decodes a reassembled SpO2 reply: whole 49-byte day blocks, each a +/// `daysAgo` byte then 24 x (max, min). Blocks come in no promised order; a +/// partial trailing block is dropped. +List colmiSpo2Hours(List r) { + if (r.length < kColmiBigHeaderLength || + r[0] != kColmiCmdBigData || + r[1] != kColmiBigSpo2) { + return const []; + } + final end = (kColmiBigHeaderLength + (r[2] | (r[3] << 8))).clamp(0, r.length); + final days = (end - kColmiBigHeaderLength) ~/ kColmiSpo2DayBytes; + final out = []; + for (var d = 0; d < days; d++) { + final b = kColmiBigHeaderLength + d * kColmiSpo2DayBytes; + final daysAgo = r[b]; + for (var h = 0; h < 24; h++) { + final hi = r[b + 1 + 2 * h], lo = r[b + 2 + 2 * h]; + if (lo > 0 && hi > 0 && lo <= 100 && hi <= 100) { + out.add(ColmiSpo2Hour(daysAgo, h, lo, hi)); + } + } + } + return out; +} + +/// One skin-temperature reading, degrees Celsius. +class ColmiTemperature { + final int daysAgo; + final int minuteOfDay; + final double celsius; + const ColmiTemperature(this.daysAgo, this.minuteOfDay, this.celsius); +} + +/// How a temperature reply's value bytes are encoded. FIRMWARE-DEPENDENT and +/// not announced anywhere on the wire, so it cannot be detected from a reply: +/// * [plus20]: one byte per slot, `byte / 10 + 20` degC, 0 = no reading. +/// * [runLengthPlus32]: a byte over 128 stands for `byte - 128` empty slots +/// in a row; any byte under 128 is one reading, `byte / 10 + 32` degC. +/// [plus20] is the default; the raw reply is always banked, so a ring that +/// turns out to speak the other form can be re-decoded. +enum ColmiTempEncoding { plus20, runLengthPlus32 } + +/// Decodes ONE reassembled temperature reply, which carries one day: +/// `[daysAgo][timeSpan][values…]`, where slot k is the reading at minute +/// `k * timeSpan` and a day has `1440 / timeSpan` slots. A reply cut short +/// still yields the slots it has. +List colmiTemperatures(List r, + {ColmiTempEncoding encoding = ColmiTempEncoding.plus20}) { + if (r.length < kColmiBigHeaderLength || + r[0] != kColmiCmdBigData || + r[1] != kColmiBigTemperature) { + return const []; + } + final end = + (kColmiBigHeaderLength + (r[2] | (r[3] << 8))).clamp(0, r.length); + if (end - kColmiBigHeaderLength <= 2) return const []; + final daysAgo = r[kColmiBigHeaderLength]; + final span = r[kColmiBigHeaderLength + 1]; + if (span == 0) return const []; + final slots = 1440 ~/ span; + final out = []; + var k = 0; + for (var i = kColmiBigHeaderLength + 2; i < end && k < slots; i++) { + final b = r[i]; + if (encoding == ColmiTempEncoding.runLengthPlus32) { + if (b > 128) { + k += b - 128; + continue; + } + // 0 is a real 32.0 degC here (empty slots are runs); 128 would decode + // to 19.2 degC as a signed byte, which no skin reads, so it is skipped. + if (b < 128) out.add(ColmiTemperature(daysAgo, k * span, b / 10 + 32)); + } else if (b > 0) { + out.add(ColmiTemperature(daysAgo, k * span, b / 10 + 20)); + } + k++; + } + return out; +} diff --git a/lib/src/dafit.dart b/lib/src/dafit.dart deleted file mode 100644 index 193b199..0000000 --- a/lib/src/dafit.dart +++ /dev/null @@ -1,192 +0,0 @@ -// DaFit / MOYOUNG-V2 wire format — the framing spoken by a large cluster of -// unbranded Chinese OEM watches, paired through the "DaFit" or "MOYOUNG" -// companion app and sold under dozens of storefront names. Bytes only: no -// BLE, no Flutter, no database. -// -// NOTHING HERE HAS MET HARDWARE. Ships EXPERIMENTAL (ASSUMPTIONS R6): this -// file decodes NO physiological signal from this family's frames, only the -// framing needed to hold a session open and bank what the band sends. A -// data-bearing reply that goes unacknowledged is documented to leave the -// band stalled and draining its battery fast, so the minimal ack this file -// builds is protocol plumbing, not a derived metric — nothing here turns -// bytes into a heart rate, a step count or a sleep stage. -// -// TRANSPORT is an otherwise-generic Nordic UART Service link (one write -// characteristic, one notify characteristic, no CRC, no encryption). -// -// DATA FRAME (header 0xCD), app<->band, big-endian length fields: -// [0] header (0xCD) -// [1..2] outer length: byte count from [3] to the end of payload, -// i.e. 5 + payload.length -// [3] command group -// [4] protocol version/delimiter — always 0x01 -// [5] command -// [6..7] payload length -// [8..] payload -// -// ACK FRAME (header 0xDC), a fixed 8 bytes, sent to keep a data-bearing -// exchange moving: -// [0] 0xDC [1..2] 0x00 0x05 (fixed) [3] command group [4] 0x01 -// [5..6] (outer length of the frame being acked) + 3 -// [7] 0x01 -// This shape does not nest inside the data-frame layout above — it is -// parsed and built separately, never through [parseDafitFrame]. - -import 'dart:typed_data'; - -const int kDafitDataHeader = 0xCD; -const int kDafitAckHeader = 0xDC; - -const int kDafitGroupGeneral = 0x12; -const int kDafitGroupRequestData = 0x1a; -const int kDafitGroupBandInfo = 0x20; - -const int kDafitCmdSetDateTime = 0x01; -const int kDafitCmdSetLanguage = 0x15; -const int kDafitCmdInit1 = 0x0a; -const int kDafitCmdInit2 = 0x0c; -const int kDafitCmdInit3 = 0xff; - -/// group [kDafitGroupRequestData]. -const int kDafitCmdGetHwInfo = 0x10; - -/// group [kDafitGroupBandInfo]. -const int kDafitCmdGetBandInfo = 0x02; - -const int kDafitValueOn = 0x01; -const int kDafitLangEnglish = 0x01; - -/// One parsed data frame ([kDafitDataHeader]). Null for anything else, -/// including this family's own ACK frames — see [parseDafitFrame]. -class DafitFrame { - final int group; - final int command; - final Uint8List payload; - - /// The frame's own declared outer length (bytes [1..2]) — kept verbatim, - /// not recomputed from [payload], so [buildDafitAck] echoes exactly what - /// the band itself declared rather than this file's own arithmetic. - final int outerLen; - - const DafitFrame(this.group, this.command, this.payload, this.outerLen); -} - -/// Parse one notification as a data frame. Null when it cannot be one — -/// too short, the wrong header (an ACK frame reads `0xDC` here and is -/// correctly rejected), a payload length longer than the bytes actually -/// delivered, or an outer length that disagrees with the payload length — -/// the two are supposed to describe the same frame (`outerLen == 5 + -/// payloadLen`), and a frame where they disagree is malformed, not merely -/// unfamiliar. Accepting it anyway would hand [buildDafitAck] a length to -/// echo back that this frame never actually had. -DafitFrame? parseDafitFrame(List value) { - if (value.length < 8 || value[0] != kDafitDataHeader) return null; - final outerLen = (value[1] << 8) | value[2]; - final payloadLen = (value[6] << 8) | value[7]; - if (value.length - 8 < payloadLen || outerLen != 5 + payloadLen) { - return null; - } - return DafitFrame( - value[3], - value[5], - Uint8List.fromList(value.sublist(8, 8 + payloadLen)), - outerLen, - ); -} - -/// Build one outbound data frame. -Uint8List buildDafitFrame( - int group, - int command, [ - List payload = const [], -]) { - final out = Uint8List(8 + payload.length); - final outerLen = 5 + payload.length; - out[0] = kDafitDataHeader; - out[1] = (outerLen >> 8) & 0xff; - out[2] = outerLen & 0xff; - out[3] = group & 0xff; - out[4] = 0x01; - out[5] = command & 0xff; - out[6] = (payload.length >> 8) & 0xff; - out[7] = payload.length & 0xff; - out.setRange(8, 8 + payload.length, payload); - return out; -} - -/// Build the fixed 8-byte ACK for a received data frame, so a data-bearing -/// exchange keeps moving instead of stalling. Takes the frame being -/// acknowledged directly — its [DafitFrame.outerLen] is the only input, so -/// there is no separate length argument to drift out of sync with it. -Uint8List buildDafitAck(DafitFrame acked) { - final ackSize = acked.outerLen + 3; - return Uint8List.fromList([ - kDafitAckHeader, 0x00, 0x05, // - acked.group, 0x01, - (ackSize >> 8) & 0xff, ackSize & 0xff, - 0x01, - ]); -} - -/// True for the fixed 8-byte ACK shape ([kDafitAckHeader]) — the direction -/// this family also uses band-to-app, interspersed with data frames during -/// the same exchange. Not decoded further: nothing downstream needs more -/// than "this notification was an ack, not data". -/// -/// Checks every fixed byte the shape actually has ([1], [2], [4] and [7] are -/// all constant — see [buildDafitAck]), not just the header: an 8-byte -/// notification that happens to start with 0xDC but disagrees with the rest -/// of the shape is not an ack, it is something this file does not recognise. -bool isDafitAckFrame(List value) => - value.length == 8 && - value[0] == kDafitAckHeader && - value[1] == 0x00 && - value[2] == 0x05 && - value[4] == 0x01 && - value[7] == 0x01; - -/// Pack a local date/time into this family's bit-packed SET_DATE_TIME field: -/// seconds, then (year-2000), month, day, hour, minute each shifted into -/// their own bit range of one big-endian u32. -/// -/// `year - 2000` has to fit the field's own six bits (0-63, i.e. 2000-2063). -/// Outside that range the later shift would silently drop the high bits and -/// write a DIFFERENT year to the band's clock — a wrong date it would then -/// act on with total confidence — so this throws instead. -int packDafitDateTime(DateTime t) { - final yearOffset = t.year - 2000; - if (yearOffset < 0 || yearOffset > 63) { - throw ArgumentError.value(t.year, 'year', - 'this family\'s clock field only represents 2000-2063'); - } - return t.second | - (yearOffset << 26) | - (t.month << 22) | - (t.day << 17) | - (t.hour << 12) | - (t.minute << 6); -} - -/// Handshake frames, in the order this family expects them. Sending them -/// unconditionally is documented behaviour, not a guess: skipping this -/// sequence is reported to leave later fetches refused and the band's -/// battery draining fast, and each step is a control write, not a request -/// for any physiological data. A caller pauses between writes; timing is a -/// session concern and stays out of this file. -List dafitInitSequence(DateTime now) => [ - buildDafitFrame(kDafitGroupGeneral, kDafitCmdInit1, const [0x02]), - buildDafitFrame( - kDafitGroupGeneral, - kDafitCmdSetDateTime, - (ByteData(4)..setUint32(0, packDafitDateTime(now) & 0xffffffff)) - .buffer - .asUint8List(), - ), - buildDafitFrame(kDafitGroupRequestData, kDafitCmdInit1), - buildDafitFrame(kDafitGroupRequestData, kDafitCmdInit2), - buildDafitFrame( - kDafitGroupGeneral, kDafitCmdSetLanguage, const [kDafitLangEnglish]), - buildDafitFrame(kDafitGroupGeneral, kDafitCmdInit3, const [kDafitValueOn]), - buildDafitFrame(kDafitGroupRequestData, kDafitCmdGetHwInfo), - buildDafitFrame(kDafitGroupBandInfo, kDafitCmdGetBandInfo), - ]; diff --git a/lib/src/garmin.dart b/lib/src/garmin.dart index 5962f18..6ee4c4a 100644 --- a/lib/src/garmin.dart +++ b/lib/src/garmin.dart @@ -10,9 +10,12 @@ // THREE LAYERS, each with its own framing: // // 1. Multi-Link (ML/MLR) multiplexes several logical services over one -// characteristic pair. The routing byte's bit 7 marks a watch-to-host MLR -// data frame with the handle in bits 6:4; the host addresses a handle -// with a bare byte. Handles are assigned per session by +// characteristic pair. On a handle registered non-reliable (flags 0, the +// only kind this code asks for) both directions address it with a bare +// routing byte. A routing byte with bit 7 set (handle in bits 6:4) is an +// MLR frame, sent only on a handle registered reliable (flags 2); its +// bytes after byte 0 carry the reliable layer's own header, not COBS, so +// they are never decoded here. Handles are assigned per session by // CLOSE_ALL_REQ (evict whatever a previous session left registered) then // REGISTER_ML_REQ per service code (GFDI = 1). // 2. COBS wraps the GFDI byte stream inside one ML handle — the public, @@ -29,9 +32,10 @@ // RESPONSE (5043/5044, wrapping a small hand-rolled protobuf reader — just // enough fields to ask for and read `DeviceStatusService`'s battery status), // CURRENT_TIME_REQUEST (5052, the watch asking for wall-clock time) and -// RESPONSE/STATUS (5000, both the plain ack every inbound GFDI message gets -// and the fuller time-answer shape). SYSTEM_EVENT (5030) is parsed for -// logging only. +// RESPONSE/STATUS (5000: every inbound message gets exactly one, a plain +// ack or the fuller shape its type calls for, echoing a compact-form +// request's transaction id). SYSTEM_EVENT (5030) is parsed for logging, and +// sent once as HANDSHAKE_COMPLETE. // // CUT ON PURPOSE: chunked protobuf reassembly (`data_offset > 0` is logged // and abstained on, never guessed at), every OTHER numbered sub-service in @@ -49,6 +53,10 @@ import 'dart:typed_data'; /// Service codes this file registers a handle for. Real watches expose many /// more (six numbered real-time streams among them) — untouched this pass. const int kGarminServiceGfdi = 1; + +/// The REGISTRATION service, whose handle answers which multi-link services a +/// watch supports. Deliberately skipped: this code registers GFDI directly +/// and handles the ALREADY_IN_USE redirect on that answer instead. const int kGarminServiceRegistration = 4; /// This host's client identifier on the control channel. Any value the watch @@ -57,8 +65,6 @@ const int kGarminServiceRegistration = 4; const int _kGarminClientId = 2; const int _kMlrFlag = 0x80; -const int _kMlrHandleMask = 0x70; -const int _kMlrHandleShift = 4; /// Widest handle a bare routing byte can address without setting the MLR /// flag and being read back as a different handle. @@ -66,12 +72,17 @@ const int kGarminMaxHandle = 0x7f; const int _kReqRegisterMl = 0x00; const int _kRespRegisterMl = 0x01; +const int _kHandleClosed = 0x03; +const int _kInvalidHandle = 0x04; const int _kReqCloseAll = 0x05; const int _kRespCloseAll = 0x06; +/// REGISTER_ML_RESP statuses with a meaning beyond "refused". +const int kGarminRegisterPendingAuth = 2; +const int kGarminRegisterAlreadyInUse = 3; + /// Frame one ML control or data payload for the host-to-watch direction. The -/// host always addresses a handle with a bare byte — only the watch sets the -/// MLR flag on what it sends back. +/// host always addresses a handle with a bare byte. Uint8List garminEncodeTx(int handle, List payload) { if (handle < 0 || handle > kGarminMaxHandle) { throw ArgumentError.value(handle, 'handle', 'must be 0-$kGarminMaxHandle'); @@ -99,59 +110,89 @@ class GarminCloseAllAck extends GarminMlrPacket { /// REGISTER_ML_RESP: the watch's answer to one handle request. `status == 0` /// is acceptance; anything else is a refusal and [handle] is meaningless. +/// [kGarminRegisterPendingAuth] means the watch is still deciding, and +/// [kGarminRegisterAlreadyInUse] names, in [alternateChar], the 16-bit part +/// (`6A4Exxxx`) of another multi-link characteristic to register on instead. class GarminRegisterMlResponse extends GarminMlrPacket { final int service; final int status; final int handle; - const GarminRegisterMlResponse(this.service, this.status, this.handle); + final int? alternateChar; + const GarminRegisterMlResponse(this.service, this.status, this.handle, + {this.alternateChar}); bool get accepted => status == 0; } +/// HANDLE_CLOSED (type 3) or INVALID_HANDLE (type 4): [handle] is gone and +/// nothing more will arrive on it. +class GarminHandleClosed extends GarminMlrPacket { + final int handle; + const GarminHandleClosed(this.handle); +} + /// A control-channel frame this file has no specific decode for (an -/// unregistered request type, or a CLOSE_HANDLE reply this pass never -/// sends). Named so a caller can tell "recognised but uninteresting" apart +/// unregistered request type, or an answer addressed to another client id). +/// Named so a caller can tell "recognised but uninteresting" apart /// from [garminDecodeMlr] answering null for genuinely malformed input. class GarminMlrControlOther extends GarminMlrPacket { final int type; const GarminMlrControlOther(this.type); } -/// Route one raw notification. Null only for input too short to be anything -/// — everything else lands in one of the [GarminMlrPacket] arms above. +/// Route one raw notification. Null for input too short to be anything, a +/// control frame under 13 bytes, and an MLR (bit 7) frame, whose bytes this +/// file does not decode — everything else lands in one of the +/// [GarminMlrPacket] arms above. GarminMlrPacket? garminDecodeMlr(List data) { if (data.isEmpty) return null; - if ((data[0] & _kMlrFlag) != 0) { - final handle = (data[0] & _kMlrHandleMask) >> _kMlrHandleShift; - return GarminMlrData(handle, Uint8List.fromList(data.sublist(1))); - } - // Byte 0 clear and non-zero is the watch addressing a handle with a bare - // byte (how it answers a handle registered non-reliable, and the only form - // for handles above the 3-bit flagged range). + // Only on a handle registered reliable, which this code never asks for. + if ((data[0] & _kMlrFlag) != 0) return null; + // Byte 0 non-zero is the watch addressing a handle with a bare byte, which + // is how it sends on a handle registered non-reliable. if (data[0] != 0) { if (data.length < 2) return null; return GarminMlrData(data[0], Uint8List.fromList(data.sublist(1))); } - if (data.length < 2) return null; + // Every control frame is `0 | type | client id i64 | ...`, 13 bytes at + // least. + if (data.length < 13) return null; final type = data[1]; + // INVALID_HANDLE names no client: it applies whoever registered the handle. + if (type == _kInvalidHandle) return GarminHandleClosed(data[12]); + final v = ByteData.sublistView(Uint8List.fromList(data)); + if (v.getInt64(2, Endian.little) != _kGarminClientId) { + return GarminMlrControlOther(type); // another client's answer + } if (type == _kRespCloseAll) return const GarminCloseAllAck(); if (type == _kRespRegisterMl) { - if (data.length < 14) return null; - final service = ByteData.sublistView(Uint8List.fromList(data), 10, 12) - .getInt16(0, Endian.little); - return GarminRegisterMlResponse(service, data[12], data[13]); + final service = v.getInt16(10, Endian.little); + final status = data[12]; + if (status == 0) { + if (data.length < 14) return null; + return GarminRegisterMlResponse(service, status, data[13]); + } + if (status == kGarminRegisterAlreadyInUse) { + if (data.length < 15) return null; + return GarminRegisterMlResponse(service, status, 0, + alternateChar: v.getUint16(13, Endian.little)); + } + return GarminRegisterMlResponse(service, status, 0); + } + if (type == _kHandleClosed && data.length >= 14) { + return GarminHandleClosed(data[12]); } return GarminMlrControlOther(type); } -/// CLOSE_ALL_REQ payload: type(u8) + client id(i64) + reserved(u16=0) + -/// reserved(u8=0). Client id sits right after the type, as in -/// REGISTER_ML_REQ. Wipes any handle a previous session left registered. +/// CLOSE_ALL_REQ payload: type(u8) + client id(i64) + reserved(u16=0), 12 +/// bytes on the wire with the control handle byte. Client id sits right +/// after the type, as in REGISTER_ML_REQ. Wipes any handle a previous +/// session left registered. Uint8List garminCloseAllRequest() { - final b = ByteData(12) + final b = ByteData(11) ..setUint8(0, _kReqCloseAll) ..setInt64(1, _kGarminClientId, Endian.little) - ..setUint16(9, 0, Endian.little) - ..setUint8(11, 0); + ..setUint16(9, 0, Endian.little); return b.buffer.asUint8List(); } @@ -252,6 +293,11 @@ class GarminCobsReassembler { } _buf.removeRange(0, lead); } + // A run of delimiters: the frame starts at the last one, so a stray + // 0x00 never pairs with the next frame's leading delimiter. + while (_buf.length > 1 && _buf[1] == 0x00) { + _buf.removeAt(0); + } final end = _buf.indexOf(0x00, 1); if (end < 0) break; // frame not complete yet final candidate = _buf.sublist(0, end + 1); @@ -298,8 +344,31 @@ const int kGarminMsgProtobufRequest = 5043; const int kGarminMsgProtobufResponse = 5044; const int kGarminMsgCurrentTimeRequest = 5052; +/// A GFDI message type with the compact form expanded: a type with bit 15 +/// set means `(type & 0xff) + 5000`. +int garminMessageType(int type) => + type & 0x8000 != 0 ? (type & 0xff) + 5000 : type; + +/// The transaction id a compact-form type carries (bits 12:8), or null for a +/// plain u16 type. +int? garminTransactionId(int type) => + type & 0x8000 != 0 ? (type >> 8) & 0x1f : null; + +/// The type field of a RESPONSE answering a request whose raw type was +/// [requestType]: compact with the same transaction id when the request had +/// one, so the watch can match the answer, else a plain 5000. +int garminResponseType(int requestType) { + final txn = garminTransactionId(requestType); + return txn == null ? kGarminMsgResponse : 0x8000 | (txn << 8); +} + +/// RESPONSE statuses. +const int kGarminStatusAck = 0; +const int kGarminStatusUnknown = 2; + /// One decoded GFDI frame: its message type and the payload after the -/// 4-byte header, with the CRC already verified. +/// 4-byte header, with the CRC already verified. [type] is the raw field; +/// [garminMessageType] expands it. class GarminGfdiFrame { final int type; final Uint8List payload; @@ -334,16 +403,30 @@ GarminGfdiFrame? garminParseGfdiFrame(List frame) { return GarminGfdiFrame(type, bytes.sublist(4, size - 2)); } -/// Build the plain ack every inbound GFDI message (other than a RESPONSE -/// itself) is documented to need: `ref_msg_type:u16 | status:i8(0)`, wrapped -/// as a RESPONSE (5000) frame. -Uint8List garminBuildStatusAck(int refMsgType) { +/// Build the plain answer an inbound GFDI message with no richer reply gets: +/// `ref_msg_type:u16 | status:i8 | extra`, wrapped as a RESPONSE frame of +/// [responseType] (see [garminResponseType]). [status] is +/// [kGarminStatusAck], or [kGarminStatusUnknown] for a message this code +/// does not handle. +Uint8List garminBuildStatusAck(int refMsgType, + {int status = kGarminStatusAck, + int responseType = kGarminMsgResponse, + List extra = const []}) { final b = ByteData(3) ..setUint16(0, refMsgType, Endian.little) - ..setInt8(2, 0); - return garminBuildGfdiFrame(kGarminMsgResponse, b.buffer.asUint8List()); + ..setInt8(2, status); + return garminBuildGfdiFrame( + responseType, [...b.buffer.asUint8List(), ...extra]); } +/// Build SYSTEM_EVENT (5030) `[event_type, value]`. +Uint8List garminBuildSystemEvent(int type, [int value = 0]) => + garminBuildGfdiFrame(kGarminMsgSystemEvent, [type, value]); + +/// SYSTEM_EVENT type the host sends once it has answered the watch's +/// configuration. +const int kGarminEventHandshakeComplete = 8; + /// One decoded RESPONSE (5000) frame: which message it answers, and whether /// that was accepted (`status == 0`). class GarminStatusAck { @@ -354,21 +437,24 @@ class GarminStatusAck { } GarminStatusAck? garminParseStatusAck(GarminGfdiFrame f) { - if (f.type != kGarminMsgResponse || f.payload.length < 3) return null; + if (garminMessageType(f.type) != kGarminMsgResponse || + f.payload.length < 3) { + return null; + } final view = ByteData.sublistView(f.payload); return GarminStatusAck( view.getUint16(0, Endian.little), view.getInt8(2)); } -/// Seconds between the Unix epoch and Garmin's own epoch (1990-01-01 -/// 00:00:00 UTC), which every Garmin timestamp field on this wire is counted -/// from. +/// Seconds between the Unix epoch and Garmin's own epoch +/// (1989-12-31T00:00:00Z), which every Garmin timestamp field on this wire is +/// counted from. const int kGarminEpochOffset = 631065600; -/// Build the full answer to a CURRENT_TIME_REQUEST (5052) [request]: a -/// RESPONSE (5000) frame shaped as `ref_msg_type:u16(5052) | status:i8(0) | +/// Build the full answer to a CURRENT_TIME_REQUEST (5052) [request]: the one +/// RESPONSE it gets, shaped as `ref_msg_type:u16(5052) | status:i8(0) | /// reference_id:u32 | garmin_timestamp:u32 | utc_offset_sec:i32 | -/// dst_end:i32(0) | dst_start:i32(0)`. `reference_id` echoes the request's +/// dst_start:u32(0) | dst_end:u32(0)`. `reference_id` echoes the request's /// own payload u32 so the watch can match the answer to what it asked. Null /// when [request] is not a 5052 frame carrying that id. DST transitions are /// left at 0 — this pass states the current UTC offset and nothing about a @@ -378,7 +464,7 @@ Uint8List? garminBuildTimeResponse( required int nowUnixSeconds, required int utcOffsetSeconds, }) { - if (request.type != kGarminMsgCurrentTimeRequest || + if (garminMessageType(request.type) != kGarminMsgCurrentTimeRequest || request.payload.length < 4) { return null; } @@ -392,7 +478,8 @@ Uint8List? garminBuildTimeResponse( ..setInt32(11, utcOffsetSeconds, Endian.little) ..setInt32(15, 0, Endian.little) ..setInt32(19, 0, Endian.little); - return garminBuildGfdiFrame(kGarminMsgResponse, b.buffer.asUint8List()); + return garminBuildGfdiFrame( + garminResponseType(request.type), b.buffer.asUint8List()); } /// The watch's self-description (5024): a fixed 12-byte header, then three @@ -435,7 +522,8 @@ class GarminDeviceInformation { } GarminDeviceInformation? garminParseDeviceInformation(GarminGfdiFrame f) { - if (f.type != kGarminMsgDeviceInformation || f.payload.length < 12) { + if (garminMessageType(f.type) != kGarminMsgDeviceInformation || + f.payload.length < 12) { return null; } final view = ByteData.sublistView(f.payload); @@ -463,7 +551,10 @@ GarminDeviceInformation? garminParseDeviceInformation(GarminGfdiFrame f) { /// `(event_type, value)` out of a SYSTEM_EVENT (5030) frame — logged only, /// never acted on this pass. (int, int)? garminParseSystemEvent(GarminGfdiFrame f) { - if (f.type != kGarminMsgSystemEvent || f.payload.isEmpty) return null; + if (garminMessageType(f.type) != kGarminMsgSystemEvent || + f.payload.isEmpty) { + return null; + } return (f.payload[0], f.payload.length > 1 ? f.payload[1] : 0); } @@ -509,10 +600,26 @@ Uint8List garminBuildProtobufRequest({ [...head.buffer.asUint8List(), ...protoBytes]); } +/// The one RESPONSE an inbound PROTOBUF_REQUEST/RESPONSE [f] gets: +/// `ref | status 0 | request_id:u16 | data_offset:u32 | chunk_status:u8 +/// (0 ok, 1 error) | status_code:u8 (0 no error)`. +Uint8List garminBuildProtobufAck(GarminGfdiFrame f, GarminProtobufFrame pf, + {bool ok = true}) { + final b = ByteData(8) + ..setUint16(0, pf.requestId, Endian.little) + ..setUint32(2, pf.dataOffset, Endian.little) + ..setUint8(6, ok ? 0 : 1) + ..setUint8(7, 0); + return garminBuildStatusAck(garminMessageType(f.type), + responseType: garminResponseType(f.type), + extra: b.buffer.asUint8List()); +} + /// Parse the protobuf envelope out of a PROTOBUF_REQUEST/RESPONSE frame. GarminProtobufFrame? garminParseProtobufFrame(GarminGfdiFrame f) { - if ((f.type != kGarminMsgProtobufRequest && - f.type != kGarminMsgProtobufResponse) || + final type = garminMessageType(f.type); + if ((type != kGarminMsgProtobufRequest && + type != kGarminMsgProtobufResponse) || f.payload.length < 14) { return null; } @@ -523,7 +630,7 @@ GarminProtobufFrame? garminParseProtobufFrame(GarminGfdiFrame f) { final protoLen = view.getInt32(10, Endian.little); if (protoLen < 0 || 14 + protoLen > f.payload.length) return null; return GarminProtobufFrame( - messageType: f.type, + messageType: type, requestId: requestId, dataOffset: dataOffset, totalLength: totalLength, @@ -539,8 +646,9 @@ GarminProtobufFrame? garminParseProtobufFrame(GarminGfdiFrame f) { // `Smart.device_status_service` (field 8) -> // `DeviceStatusService.remote_device_battery_status_request` (field 2, sent // empty) / `...response` (field 3, read back) -> -// `RemoteDeviceBatteryStatusResponse.status` (field 1, varint) / -// `.current_battery_level` (field 2, varint, plain `int32` not zigzag). +// `RemoteDeviceBatteryStatusResponse.status` (field 1, required enum +// ResponseStatus: 0 UNKNOWN_RESPONSE_STATUS, 1 OK, 2 NO_REMOTE_DEVICE) / +// `.current_battery_level` (field 2, optional `uint32` varint). void _writeVarint(BytesBuilder out, int value) { var v = value; @@ -627,8 +735,9 @@ Iterable<_ProtoField> _protoFields(Uint8List data) sync* { Uint8List garminBatteryRequestProto() => _protoLenDelim(8, _protoLenDelim(2, const [])); -/// One `RemoteDeviceBatteryStatusResponse`. `status` is null when the watch -/// omitted the field — not the same as a healthy reading. +/// One `RemoteDeviceBatteryStatusResponse`. `status` is the ResponseStatus +/// enum (not the battery's charging state), null when the watch omitted the +/// field — not the same as a healthy reading. class GarminBatteryStatus { final int? status; final int level; diff --git a/lib/src/garmin_fit.dart b/lib/src/garmin_fit.dart new file mode 100644 index 0000000..09b223c --- /dev/null +++ b/lib/src/garmin_fit.dart @@ -0,0 +1,323 @@ +// FIT file decoding — the format Garmin watches store health data in. Written +// from the published FIT file format (header, definition and data records, +// base types, compressed timestamps), not from any SDK. +// +// FILE: `u8 headerSize (12|14) | u8 protocol | u16 profile | u32 dataSize | +// ".FIT" | [u16 headerCrc]`, then `dataSize` bytes of records, then a u16 CRC. +// +// RECORD HEADER: +// bit 7 set -> compressed-timestamp data record: local type bits 6-5, +// time offset bits 4-0 (seconds, rolling over every 32) +// otherwise -> bit 6 definition / data, bit 5 developer fields (on a +// definition), local type bits 3-0 +// DEFINITION: `u8 reserved | u8 arch (0 LE, 1 BE) | u16 global | u8 n | +// n x (u8 field, u8 size, u8 baseType) | [u8 nDev | nDev x 3 bytes]`. +// +// TIME: field 253 is seconds since 1989-12-31T00:00Z (the FIT epoch); every +// timestamp returned here is already Unix seconds. + +import 'dart:typed_data'; + +/// Seconds between the Unix epoch and the FIT epoch. +const int kFitEpochOffset = 631065600; + +const int kFitMsgFileId = 0; +const int kFitMsgMonitoring = 55; +const int kFitMsgMonitoringHrData = 211; +const int kFitMsgStressLevel = 227; +const int kFitMsgSpo2Data = 269; +const int kFitMsgSleepLevel = 275; +const int kFitMsgRespirationRate = 297; +const int kFitMsgHrvStatusSummary = 370; + +/// One decoded data message: its global number, its fields by field number +/// (scalars as num, arrays as List, strings as String; invalid values are +/// absent), and its timestamp in Unix seconds when it has one. +class FitMessage { + final int global; + final Map fields; + final int? timestamp; + const FitMessage(this.global, this.fields, this.timestamp); + + num? num_(int field) { + final v = fields[field]; + return v is num ? v : null; + } +} + +class _Field { + final int num, size, base; + const _Field(this.num, this.size, this.base); +} + +class _Def { + final int global; + final Endian endian; + final List<_Field> fields; + final int devBytes; + const _Def(this.global, this.endian, this.fields, this.devBytes); +} + +// Base type (low 5 bits) -> (size, signed, float, invalid, isZ). +const List<(int, bool, bool, int)> _bases = [ + (1, false, false, 0xff), // 0 enum + (1, true, false, 0x7f), // 1 sint8 + (1, false, false, 0xff), // 2 uint8 + (2, true, false, 0x7fff), // 3 sint16 + (2, false, false, 0xffff), // 4 uint16 + (4, true, false, 0x7fffffff), // 5 sint32 + (4, false, false, 0xffffffff), // 6 uint32 + (1, false, false, 0), // 7 string + (4, false, true, 0), // 8 float32 + (8, false, true, 0), // 9 float64 + (1, false, false, 0), // 10 uint8z + (2, false, false, 0), // 11 uint16z + (4, false, false, 0), // 12 uint32z + (1, false, false, 0xff), // 13 byte + (8, true, false, 0x7fffffffffffffff), // 14 sint64 + (8, false, false, -1), // 15 uint64 + (8, false, false, 0), // 16 uint64z +]; + +num? _read(ByteData d, int at, int base, Endian e) { + if (base >= _bases.length) return null; + final (size, signed, isFloat, invalid) = _bases[base]; + num v; + switch (size) { + case 1: + v = signed ? d.getInt8(at) : d.getUint8(at); + if ((v.toInt() & 0xff) == invalid) return null; + case 2: + v = signed ? d.getInt16(at, e) : d.getUint16(at, e); + if ((v.toInt() & 0xffff) == invalid) return null; + case 4: + if (isFloat) { + if (d.getUint32(at, e) == 0xffffffff) return null; + return d.getFloat32(at, e); + } + v = signed ? d.getInt32(at, e) : d.getUint32(at, e); + if ((v.toInt() & 0xffffffff) == invalid) return null; + default: + if (isFloat) return d.getFloat64(at, e); + v = signed ? d.getInt64(at, e) : d.getUint64(at, e); + if (v == invalid) return null; + } + return v; +} + +/// Decodes every data message in a FIT file. Throws [FormatException] on a +/// file that is not FIT or is truncated mid-record; an undecodable file must +/// not be half-trusted. +List parseFit(List bytes) { + final b = bytes is Uint8List ? bytes : Uint8List.fromList(bytes); + if (b.length < 12) throw const FormatException('FIT: too short'); + final headerSize = b[0]; + if ((headerSize != 12 && headerSize != 14) || + String.fromCharCodes(b.sublist(8, 12)) != '.FIT') { + throw const FormatException('FIT: bad header'); + } + final dataSize = b[4] | (b[5] << 8) | (b[6] << 16) | (b[7] << 24); + final end = headerSize + dataSize; + if (end > b.length) throw const FormatException('FIT: truncated'); + final d = ByteData.sublistView(b); + final defs = {}; + final out = []; + int? lastTs; // FIT seconds + var i = headerSize; + while (i < end) { + final h = b[i++]; + if (h & 0x80 != 0) { + // Compressed-timestamp data record. + final local = (h >> 5) & 0x3; + final offset = h & 0x1f; + if (lastTs != null) { + lastTs = offset >= (lastTs & 0x1f) + ? (lastTs & ~0x1f) + offset + : (lastTs & ~0x1f) + offset + 0x20; + } + final def = defs[local]; + if (def == null) throw const FormatException('FIT: undefined local'); + final (msg, next, ts) = _data(d, i, def, lastTs); + out.add(msg); + i = next; + if (ts != null) lastTs = ts; + continue; + } + final local = h & 0x0f; + if (h & 0x40 != 0) { + if (i + 5 > end) throw const FormatException('FIT: truncated def'); + final endian = b[i + 1] == 1 ? Endian.big : Endian.little; + final global = d.getUint16(i + 2, endian); + final n = b[i + 4]; + i += 5; + final fields = <_Field>[]; + for (var f = 0; f < n; f++) { + fields.add(_Field(b[i], b[i + 1], b[i + 2] & 0x1f)); + i += 3; + } + var devBytes = 0; + if (h & 0x20 != 0) { + final nDev = b[i++]; + for (var f = 0; f < nDev; f++) { + devBytes += b[i + 1]; + i += 3; + } + } + defs[local] = _Def(global, endian, fields, devBytes); + } else { + final def = defs[local]; + if (def == null) throw const FormatException('FIT: undefined local'); + final (msg, next, ts) = _data(d, i, def, lastTs); + out.add(msg); + i = next; + if (ts != null) lastTs = ts; + } + if (i > end) throw const FormatException('FIT: record overruns data'); + } + return out; +} + +(FitMessage, int, int?) _data(ByteData d, int at, _Def def, int? lastTs) { + final fields = {}; + var i = at; + for (final f in def.fields) { + if (i + f.size > d.lengthInBytes) { + throw const FormatException('FIT: truncated record'); + } + if (f.base == 7) { + final s = d.buffer.asUint8List(d.offsetInBytes + i, f.size); + final z = s.indexOf(0); + final str = String.fromCharCodes(z < 0 ? s : s.sublist(0, z)); + if (str.isNotEmpty) fields[f.num] = str; + } else { + final size = f.base < _bases.length ? _bases[f.base].$1 : 1; + if (f.size == size) { + final v = _read(d, i, f.base, def.endian); + if (v != null) fields[f.num] = v; + } else if (f.size % size == 0) { + final list = [ + for (var k = 0; k < f.size; k += size) + _read(d, i + k, f.base, def.endian), + ]; + if (list.any((x) => x != null)) fields[f.num] = list; + } + } + i += f.size; + } + i += def.devBytes; + int? ts = (fields[253] as num?)?.toInt(); + // timestamp_16 (monitoring field 26): low 16 bits relative to the last + // full timestamp. + final ts16 = def.global == kFitMsgMonitoring ? fields[26] as num? : null; + if (ts == null && ts16 != null && lastTs != null) { + ts = lastTs + ((ts16.toInt() - (lastTs & 0xffff)) & 0xffff); + } + final unix = (ts ?? lastTs) == null ? null : (ts ?? lastTs)! + kFitEpochOffset; + return (FitMessage(def.global, fields, unix), i, ts); +} + +// ── typed views ───────────────────────────────────────────────────────── + +/// Heart-rate samples from monitoring messages: (Unix seconds, bpm). +List<(int, int)> fitMonitoringHr(List m) => [ + for (final x in m) + if (x.global == kFitMsgMonitoring && + x.timestamp != null && + (x.num_(27) ?? 0) > 0) + (x.timestamp!, x.num_(27)!.toInt()), + ]; + +/// Steps per local day: monitoring `cycles` (field 3) is a running count per +/// activity type (field 5, or the low 5 bits of the packed field 24 when +/// field 5 is absent) that resets each day, so a day's steps are the +/// sum over walking (6) and running (1) of each type's largest count. +Map fitDailySteps(List m) { + final maxBy = <(DateTime, int), int>{}; + for (final x in m) { + if (x.global != kFitMsgMonitoring || x.timestamp == null) continue; + final packed = x.num_(24)?.toInt(); + final type = x.num_(5)?.toInt() ?? (packed == null ? null : packed & 0x1f); + final cycles = x.num_(3)?.toInt(); + if (cycles == null || (type != 6 && type != 1)) continue; + final t = DateTime.fromMillisecondsSinceEpoch(x.timestamp! * 1000); + final key = (DateTime(t.year, t.month, t.day), type!); + if (cycles > (maxBy[key] ?? -1)) maxBy[key] = cycles; + } + final out = {}; + maxBy.forEach((k, v) => out[k.$1] = (out[k.$1] ?? 0) + v); + return out; +} + +/// The watch's sleep stages as (startSec, endSec, stage) with stage one of +/// 'wake' | 'light' | 'deep' | 'rem'; each level runs until the next record +/// (the last for [lastLevelSeconds]). Unmeasurable or invalid periods are +/// dropped but still end the level before them. +List<(int, int, String)> fitSleepStages(List m, + {int lastLevelSeconds = 60}) { + // A record with no valid level still ends the level before it. + final levels = [ + for (final x in m) + if (x.global == kFitMsgSleepLevel && x.timestamp != null) + (x.timestamp!, x.num_(0)?.toInt() ?? -1), + ]..sort((a, b) => a.$1.compareTo(b.$1)); + final out = <(int, int, String)>[]; + for (var k = 0; k < levels.length; k++) { + final (t, level) = levels[k]; + final end = k + 1 < levels.length ? levels[k + 1].$1 : t + lastLevelSeconds; + final stage = switch (level) { + 1 => 'wake', + 2 => 'light', + 3 => 'deep', + 4 => 'rem', + _ => null, + }; + if (stage != null && end > t) out.add((t, end, stage)); + } + return out; +} + +/// Last night's average HRV in ms from an HRV status summary (field 1, +/// stored /128), with the summary's timestamp. +List<(int, double)> fitHrvLastNight(List m) => [ + for (final x in m) + if (x.global == kFitMsgHrvStatusSummary && + x.timestamp != null && + x.num_(1) != null) + (x.timestamp!, x.num_(1)! / 128), + ]; + +/// The watch's resting HR (field 1 current-day, else field 0). +List<(int, int)> fitRestingHr(List m) => [ + for (final x in m) + if (x.global == kFitMsgMonitoringHrData && + x.timestamp != null && + (x.num_(1) ?? x.num_(0)) != null) + (x.timestamp!, (x.num_(1) ?? x.num_(0))!.toInt()), + ]; + +/// SpO2 readings in percent (field 0), positive only. +List<(int, int)> fitSpo2(List m) => [ + for (final x in m) + if (x.global == kFitMsgSpo2Data && + x.timestamp != null && + (x.num_(0) ?? 0) > 0) + (x.timestamp!, x.num_(0)!.toInt()), + ]; + +/// Respiration rate in breaths/min (field 0, stored /100), positive only. +List<(int, double)> fitRespiration(List m) => [ + for (final x in m) + if (x.global == kFitMsgRespirationRate && + x.timestamp != null && + (x.num_(0) ?? 0) > 0) + (x.timestamp!, x.num_(0)! / 100), + ]; + +/// The watch's stress score 0-100 (field 0); negative values (no reading, +/// activity) dropped. Its own time is field 1 when present. +List<(int, int)> fitStress(List m) => [ + for (final x in m) + if (x.global == kFitMsgStressLevel && (x.num_(0) ?? -1) >= 0) + if ((x.num_(1)?.toInt() ?? x.timestamp) case final t?) + (x.num_(1) != null ? t + kFitEpochOffset : t, x.num_(0)!.toInt()), + ]; diff --git a/lib/src/garmin_transfer.dart b/lib/src/garmin_transfer.dart new file mode 100644 index 0000000..28da42d --- /dev/null +++ b/lib/src/garmin_transfer.dart @@ -0,0 +1,233 @@ +// Garmin GFDI file transfer and the two handshake replies a watch waits for +// before it will sync: everything needed to list the watch's files and +// download the FIT files that hold its health data. +// +// DOWNLOAD (phone -> watch, 5002): `u16 index | u32 offset | u8 type (1 new, +// 0 continue) | u16 crcSeed | u32 size (0 = whole file) | u8 flags 0`. +// The watch answers with a RESPONSE for 5002: +// `u16 ref | u8 status | u8 downloadStatus | u32 fileSize`, downloadStatus +// one of 0 ok, 1 no such index, 2 not readable, 3 not ready (ask again +// shortly), 4 invalid request, 5 CRC mismatch, 6 exceeds size. Then it +// streams FILE_TRANSFER_DATA (5004): `u8 flags | u16 crc | u32 offset | data`, +// where `crc` is the running CRC over the file so far (seeded with the +// previous chunk's). Every chunk is answered with a RESPONSE for 5004: +// `u16 ref | u8 status 0 | u8 transferStatus | u32 nextOffset`, +// transferStatus one of 0 ok (also a repeat of the last accepted chunk), +// 2 abort, 3 CRC mismatch, 4 offset mismatch. +// +// DIRECTORY: file index 0 lists the watch's files after a 16-byte header +// record, 16 bytes each: `u16 index | u8 dataType | u8 subType | +// u16 fileNumber | u8 specific | u8 flags | u32 size | u32 timestamp (Garmin +// epoch)`. FIT files are dataType 128; subType names the content +// (32 monitoring, 49 sleep, 68 HRV status, ...). +// +// NOT HERE: the archive flag (SET_FILE_FLAG). It removes a file from the +// watch's "new" list; this app never sends it and tracks what it has read +// per file (index, timestamp, size) instead, so the watch is left exactly as +// it was. + +import 'dart:convert' show utf8; +import 'dart:typed_data'; + +import 'garmin.dart'; + +const int kGarminMsgDownloadRequest = 5002; +const int kGarminMsgFileTransferData = 5004; +const int kGarminMsgConfiguration = 5050; + +const int kGarminFileTypeFit = 128; +const int kGarminFitMonitor = 32; +const int kGarminFitSleep = 49; +const int kGarminFitHrvStatus = 68; + +/// The FIT sub-types worth downloading for health data. +const Set kGarminHealthFitSubTypes = { + kGarminFitMonitor, + kGarminFitSleep, + kGarminFitHrvStatus, +}; + +Uint8List garminBuildDownloadRequest(int index, + {int offset = 0, bool newTransfer = true, int crcSeed = 0}) { + final b = ByteData(14) + ..setUint16(0, index, Endian.little) + ..setUint32(2, offset, Endian.little) + ..setUint8(6, newTransfer ? 1 : 0) + ..setUint16(7, crcSeed, Endian.little) + ..setUint32(9, 0, Endian.little) + ..setUint8(13, 0); + return garminBuildGfdiFrame( + kGarminMsgDownloadRequest, b.buffer.asUint8List()); +} + +/// Download statuses with a meaning beyond "refused". +const int kGarminDownloadNoSuchIndex = 1; +const int kGarminDownloadNotReadable = 2; +const int kGarminDownloadNotReady = 3; + +/// The watch's answer to a download request: whether it will send, its +/// download status, and how many bytes. Null when [f] is not that answer. +({bool ok, int downloadStatus, int size})? garminParseDownloadStatus( + GarminGfdiFrame f) { + if (garminMessageType(f.type) != kGarminMsgResponse || + f.payload.length < 8) { + return null; + } + final v = ByteData.sublistView(f.payload); + if (garminMessageType(v.getUint16(0, Endian.little)) != + kGarminMsgDownloadRequest) { + return null; + } + return ( + ok: v.getUint8(2) == 0 && v.getUint8(3) == 0, + downloadStatus: v.getUint8(3), + size: v.getUint32(4, Endian.little), + ); +} + +/// One chunk of a file being downloaded. +class GarminFileChunk { + final int crc; + final int offset; + final Uint8List data; + const GarminFileChunk(this.crc, this.offset, this.data); +} + +GarminFileChunk? garminParseFileChunk(GarminGfdiFrame f) { + if (garminMessageType(f.type) != kGarminMsgFileTransferData || + f.payload.length < 7) { + return null; + } + final v = ByteData.sublistView(f.payload); + return GarminFileChunk(v.getUint16(1, Endian.little), + v.getUint32(3, Endian.little), f.payload.sublist(7)); +} + +/// Chunk transfer statuses. +const int kGarminTransferOk = 0; +const int kGarminTransferAbort = 2; +const int kGarminTransferCrcMismatch = 3; +const int kGarminTransferOffsetMismatch = 4; + +/// Acknowledge a chunk with [status] and the next offset expected, as a +/// RESPONSE of [responseType] (see [garminResponseType]). +Uint8List garminBuildChunkAck(int nextOffset, + {int status = kGarminTransferOk, + int responseType = kGarminMsgResponse}) { + final b = ByteData(8) + ..setUint16(0, kGarminMsgFileTransferData, Endian.little) + ..setUint8(2, 0) + ..setUint8(3, status) + ..setUint32(4, nextOffset, Endian.little); + return garminBuildGfdiFrame(responseType, b.buffer.asUint8List()); +} + +/// One directory entry. +class GarminFileEntry { + final int index; + final int dataType; + final int subType; + final int fileNumber; + final int size; + + /// Unix seconds. + final int timestamp; + const GarminFileEntry(this.index, this.dataType, this.subType, + this.fileNumber, this.size, this.timestamp); + + bool get isHealthFit => + dataType == kGarminFileTypeFit && + kGarminHealthFitSubTypes.contains(subType); +} + +/// The entries of a directory file, after its 16-byte header record. +List garminParseDirectory(List bytes) { + final b = Uint8List.fromList(bytes); + final v = ByteData.sublistView(b); + return [ + for (var i = 16; i + 16 <= b.length; i += 16) + GarminFileEntry( + v.getUint16(i, Endian.little), + b[i + 2], + b[i + 3], + v.getUint16(i + 4, Endian.little), + v.getUint32(i + 8, Endian.little), + v.getUint32(i + 12, Endian.little) + kGarminEpochOffset, + ), + ]; +} + +/// The protocol version the phone states: 113, the version before +/// transaction ids. This code echoes a request's transaction id but never +/// numbers its own requests, so it does not claim 150. +const int kGarminHostProtocolVersion = 113; + +/// The phone's own device information, the one RESPONSE the watch's 5024 +/// gets: protocol version, product / unit / software ids, max packet, then +/// host name, manufacturer and model as length-prefixed strings (each cut +/// to 255 bytes), then a 0x01. +Uint8List garminBuildDeviceInfoReply({ + String name = 'OpenStrap', + String manufacturer = '', + String model = '', + int appVersion = 1, + int responseType = kGarminMsgResponse, +}) { + List str(String s) { + final u = utf8.encode(s); + final cut = u.length > 255 ? u.sublist(0, 255) : u; + return [cut.length, ...cut]; + } + + final head = ByteData(15) + ..setUint16(0, kGarminMsgDeviceInformation, Endian.little) + ..setUint8(2, 0) + ..setUint16(3, kGarminHostProtocolVersion, Endian.little) + ..setUint16(5, 0xffff, Endian.little) // product + ..setUint32(7, 0xffffffff, Endian.little) // unit id + ..setUint16(11, appVersion & 0xffff, Endian.little) // software version + ..setUint16(13, 0xffff, Endian.little); // max packet size + return garminBuildGfdiFrame(responseType, [ + ...head.buffer.asUint8List(), + ...str(name), + ...str(manufacturer), + ...str(model), + 1, + ]); +} + +/// Capability bits (CONFIGURATION, 5050). +const int kGarminCapDeviceInitiatesSync = 4; +const int kGarminCapCurrentTimeRequest = 71; +const int kGarminCapSync2 = 90; + +/// The capabilities this phone implements: answering the watch's time +/// request. Nothing else is claimed. +const Set kGarminHostCapabilities = {kGarminCapCurrentTimeRequest}; + +/// The capability bits a CONFIGURATION (5050) frame carries: +/// `u8 length | bitset`, bit n in byte n ~/ 8, bit n % 8. Null when [f] is +/// not one. +Set? garminParseConfiguration(GarminGfdiFrame f) { + if (garminMessageType(f.type) != kGarminMsgConfiguration || + f.payload.isEmpty) { + return null; + } + final n = f.payload[0].clamp(0, f.payload.length - 1); + return { + for (var i = 0; i < n * 8; i++) + if (f.payload[1 + i ~/ 8] & (1 << (i % 8)) != 0) i, + }; +} + +/// The phone's own CONFIGURATION (5050): `u8 length | bitset` with only +/// [bits] set, length the fewest bytes that hold the highest one. +Uint8List garminBuildConfigurationReply( + [Set bits = kGarminHostCapabilities]) { + final top = bits.fold(0, (a, b) => b > a ? b : a); + final set = List.filled(top ~/ 8 + 1, 0); + for (final b in bits) { + set[b ~/ 8] |= 1 << (b % 8); + } + return garminBuildGfdiFrame(kGarminMsgConfiguration, [set.length, ...set]); +} diff --git a/lib/src/health_thermometer.dart b/lib/src/health_thermometer.dart new file mode 100644 index 0000000..22ec951 --- /dev/null +++ b/lib/src/health_thermometer.dart @@ -0,0 +1,119 @@ +// The Bluetooth SIG Health Thermometer profile (service 0x1809) and the +// Current Time characteristic (0x2A2B) — standard GATT, so this decoder works +// for any compliant thermometer, the Femometer Vinca 2 basal thermometer +// included. +// +// Temperature Measurement (0x2A1C), indicated: +// flags u8 — bit 0: Fahrenheit (else Celsius) · bit 1: timestamp present · +// bit 2: temperature type present +// value — IEEE-11073 32-bit FLOAT: 24-bit signed mantissa + 8-bit +// signed base-10 exponent, little-endian +// [timestamp] 7 bytes — year u16 LE, month, day, hour, minute, second +// [type] u8 — measurement site +// +// TIMESTAMPS ARE THE DEVICE'S LOCAL WALL CLOCK, carried here as components, +// never as an epoch: the host resolves them against its own local calendar. + +/// GATT UUIDs (128-bit form of the SIG 16-bit ids). +const String kHtpService = '00001809-0000-1000-8000-00805f9b34fb'; +const String kHtpTemperatureMeasurement = + '00002a1c-0000-1000-8000-00805f9b34fb'; +const String kHtpMeasurementInterval = '00002a21-0000-1000-8000-00805f9b34fb'; +const String kCurrentTimeChar = '00002a2b-0000-1000-8000-00805f9b34fb'; + +/// An IEEE-11073 32-bit FLOAT, or null for its reserved NaN / NRes / ±INF +/// mantissas. +double? ieee11073Float32(List b, int offset) { + if (offset + 4 > b.length) return null; + var mantissa = b[offset] | (b[offset + 1] << 8) | (b[offset + 2] << 16); + if (mantissa >= 0x7ffffe && mantissa <= 0x800002) return null; + if (mantissa & 0x800000 != 0) mantissa -= 0x1000000; + final exponent = b[offset + 3] >= 0x80 ? b[offset + 3] - 0x100 : b[offset + 3]; + var v = mantissa.toDouble(); + if (exponent >= 0) { + for (var i = 0; i < exponent; i++) { + v *= 10; + } + } else { + for (var i = 0; i < -exponent; i++) { + v /= 10; + } + } + return v; +} + +/// Local wall-clock components as the device sent them. +typedef WallClock = ({int year, int month, int day, int hour, int minute, int second}); + +/// A standard 7-byte date-time (year u16 LE, month, day, hour, min, sec), or +/// null when it is the "unknown" pattern (year 0 / month 0 / day 0) or out of +/// range — a day the month does not have (Feb 31) included, since building a +/// `DateTime` from it would silently roll over into the next month. +WallClock? gattDateTime(List b, int offset) { + if (offset + 7 > b.length) return null; + final year = b[offset] | (b[offset + 1] << 8); + final month = b[offset + 2], day = b[offset + 3]; + final hour = b[offset + 4], minute = b[offset + 5], second = b[offset + 6]; + if (year < 1582 || year > 9999 || month < 1 || month > 12 || day < 1) { + return null; + } + if (day > DateTime.utc(year, month + 1, 0).day) return null; + if (hour > 23 || minute > 59 || second > 59) return null; + return ( + year: year, + month: month, + day: day, + hour: hour, + minute: minute, + second: second, + ); +} + +class HtpMeasurement { + /// Always Celsius — a Fahrenheit reading is converted. + final double celsius; + final WallClock? at; + final int? site; + const HtpMeasurement(this.celsius, this.at, this.site); +} + +/// Decodes one Temperature Measurement value, or null when it is malformed +/// or carries a reserved (non-numeric) value. A field the flags declare but +/// the value does not carry is a truncated measurement and refuses the whole +/// value: a stored reading whose 7-byte stamp was cut off would otherwise +/// land at arrival time, which for a synced reading is the wrong day. +HtpMeasurement? parseHtpMeasurement(List b) { + if (b.length < 5) return null; + final flags = b[0]; + final raw = ieee11073Float32(b, 1); + if (raw == null) return null; + final celsius = flags & 0x01 != 0 ? (raw - 32) * 5 / 9 : raw; + var i = 5; + WallClock? at; + if (flags & 0x02 != 0) { + if (i + 7 > b.length) return null; + at = gattDateTime(b, i); + i += 7; + } + int? site; + if (flags & 0x04 != 0) { + if (i >= b.length) return null; + site = b[i]; + } + return HtpMeasurement(celsius, at, site); +} + +/// A Current Time (0x2A2B) value for [local]: date-time, day of week +/// (1 = Monday), fractions of a second in 1/256, adjust reason 0. +List currentTimeValue(DateTime local) => [ + local.year & 0xff, + local.year >> 8, + local.month, + local.day, + local.hour, + local.minute, + local.second, + local.weekday, + local.millisecond * 256 ~/ 1000, + 0, + ]; diff --git a/lib/src/hrs.dart b/lib/src/hrs.dart index 052086d..99f7335 100644 --- a/lib/src/hrs.dart +++ b/lib/src/hrs.dart @@ -79,9 +79,12 @@ HrsSample? parseHeartRateMeasurement(List value) { i += 2; // 1024 ticks = 1 s. Round to the nearest millisecond. final ms = (ticks * 1000 + 512) ~/ 1024; - // 250-3000 ms is 20-240 bpm. Outside that the value is not a beat - // interval, and a chest strap emits exactly this junk on a dropped beat. - if (ms >= 250 && ms <= 3000) rr.add(ms); + // Zero ticks is no interval at all. Every other value passes through + // unfiltered: the physiological bound lives in ONE place downstream + // (`plausibleRrOrNull` in edge's compute), which refuses a beat on every + // ingest path alike. A second, wider gate here only made the two + // disagree about which beats a strap sent. + if (ticks != 0) rr.add(ms); } } return HrsSample(hr: hr, rrMs: rr, contact: contact); diff --git a/lib/src/huami_legacy.dart b/lib/src/huami_legacy.dart new file mode 100644 index 0000000..82eb050 --- /dev/null +++ b/lib/src/huami_legacy.dart @@ -0,0 +1,161 @@ +// Huami legacy activity history (Mi Band 2 and Mi Band 3): clock set, the +// paged activity fetch and the one-sample-per-minute record. Runs after the +// band's AES auth (which lives with the edge adapter). +// +// CLOCK — Current Time (0x2A2B), 11 bytes: year u16 LE, month, day, hour, +// minute, second, day of week (0 = Sunday … 6 = Saturday), milliseconds / 4, +// adjust reason (8 while daylight saving time is in effect, else 0), then the +// UTC offset (DST included) as a signed byte in 15-minute units. +// +// FETCH — on the activity-control characteristic (…0004): +// host `01 01 00 ` start, since +// band `10 01 01 ` start ok; +// count is the number of 4-byte minute samples, not bytes +// host `02` send it +// band data on …0005: `[counter][payload]`, counter 0,1,2… wrapping; a +// packet may be repeated verbatim, which is not a gap +// band `10 02 xx` done (on the control characteristic) +// The next round starts at the header's time plus count minutes. +// The band's `03` acknowledgement tells it to DROP what it sent; nothing here +// builds it. Without it the band keeps its history and the host tracks its +// own resume point. +// +// SAMPLE — 4 bytes per minute: kind, intensity, steps, heart rate (0 and 0xFF +// mean no reading). Kind is the low nibble; 0 (no change) and 10 (ignore) +// inherit the previous minute's kind. Kinds: 1 activity, 2 running, +// 3 not worn, 4 cycling, 6 charging, 9 light sleep, 11 deep sleep, 12 woke up. + +const String kHuamiActivityControlChar = + '00000004-0000-3512-2118-0009af100700'; +const String kHuamiActivityDataChar = '00000005-0000-3512-2118-0009af100700'; + +const int kHuamiKindNotWorn = 3; +const int kHuamiKindCharging = 6; +const int kHuamiKindLightSleep = 9; +const int kHuamiKindDeepSleep = 11; + +int _tzQuarters(DateTime local) { + final q = local.timeZoneOffset.inMinutes ~/ 15; + return q & 0xff; +} + +/// The band's clock value for [local]. +List huamiTimeValue(DateTime local) => [ + local.year & 0xff, + local.year >> 8, + local.month, + local.day, + local.hour, + local.minute, + local.second, + local.weekday % 7, + local.millisecond ~/ 4, + _inDst(local) ? 8 : 0, + _tzQuarters(local), + ]; + +/// Whether [local] is in daylight saving time: its offset is ahead of the +/// smaller of the January and July offsets of its year. +bool _inDst(DateTime local) { + final jan = DateTime(local.year).timeZoneOffset; + final jul = DateTime(local.year, 7).timeZoneOffset; + return local.timeZoneOffset > (jan < jul ? jan : jul); +} + +/// Start an activity fetch from [sinceLocal] (minute resolution). +List huamiActivityFetchStart(DateTime sinceLocal) => [ + 0x01, + 0x01, + sinceLocal.year & 0xff, + sinceLocal.year >> 8, + sinceLocal.month, + sinceLocal.day, + sinceLocal.hour, + sinceLocal.minute, + 0x00, + _tzQuarters(sinceLocal), + ]; + +/// "Send the data now". +const List kHuamiActivityFetchData = [0x02]; + +/// Most minute samples one round can announce (90 days). +const int kHuamiMaxFetchCount = 129600; + +/// The band's answer to a fetch start: how many minute samples it will send +/// and the instant of the first, or null when the start was refused or +/// announces an impossible count. +({int count, int startSec})? parseHuamiFetchStart(List b) { + if (b.length < 3 || b[0] != 0x10 || b[1] != 0x01 || b[2] != 0x01) { + return null; + } + if (b.length < 7) return null; + final count = b[3] | (b[4] << 8) | (b[5] << 16) | (b[6] << 24); + if (count == 0) return (count: 0, startSec: 0); + if (count > kHuamiMaxFetchCount) return null; + if (b.length < 15) return null; + final tz = b[14] >= 0x80 ? b[14] - 0x100 : b[14]; + final wall = DateTime.utc(b[7] | (b[8] << 8), b[9], b[10], b[11], b[12], b[13]); + return ( + count: count, + startSec: wall.millisecondsSinceEpoch ~/ 1000 - tz * 15 * 60, + ); +} + +/// True when [b] (on the control characteristic) ends the data transfer. +bool huamiFetchDone(List b) => + b.length >= 3 && b[0] == 0x10 && b[1] == 0x02; + +/// One minute of activity. +class HuamiMinute { + final int tsSec; + final int kind; + final int intensity; + final int steps; + + /// Beats per minute, or null when the band took no reading. + final int? hr; + const HuamiMinute(this.tsSec, this.kind, this.intensity, this.steps, this.hr); + + bool get asleep => kind == kHuamiKindLightSleep || kind == kHuamiKindDeepSleep; +} + +/// Reassembles one transfer's data packets. [ok] goes false on a counter gap — +/// the transfer is then not trustworthy and must not be decoded. An exact +/// repeat of the previous packet is dropped, not a gap. +class HuamiActivityBuffer { + final List bytes = []; + int _next = 0; + List? _last; + bool ok = true; + + void add(List packet) { + if (packet.isEmpty) return; + final last = _last; + if (last != null && + last.length == packet.length && + Iterable.generate(packet.length) + .every((i) => last[i] == packet[i])) { + return; + } + if (packet[0] != (_next & 0xff)) ok = false; + _next++; + _last = List.of(packet); + bytes.addAll(packet.skip(1)); + } + + /// The minutes in this transfer, starting at [startSec]. A partial + /// trailing sample is ignored. + List minutes(int startSec, {int previousKind = 1}) { + final out = []; + var kind = previousKind; + for (var i = 0; i + 4 <= bytes.length; i += 4) { + final k = bytes[i] & 0x0f; + if (k != 0 && k != 10) kind = k; + final hr = bytes[i + 3]; + out.add(HuamiMinute(startSec + (i ~/ 4) * 60, kind, bytes[i + 1], + bytes[i + 2], hr == 0 || hr == 0xff ? null : hr)); + } + return out; + } +} diff --git a/lib/src/lefun.dart b/lib/src/lefun.dart deleted file mode 100644 index fe712d3..0000000 --- a/lib/src/lefun.dart +++ /dev/null @@ -1,128 +0,0 @@ -// A shared OEM wire format used across a family of white-label BLE fitness -// rings and bands sold under many storefront names on one common reference -// design. One write/notify characteristic pair, one small envelope, plain -// unencrypted GATT. -// -// NOTHING HERE HAS MET HARDWARE (ASSUMPTIONS R6). The envelope and its -// checksum are the only things this file decodes with confidence — battery -// level is the one report this file turns into a value, because it is a -// single bounded byte with an unambiguous meaning. Steps, sleep, heart rate -// and PPG all ride the same envelope under their own report codes, and none -// of them has a decoder here: a layout is not hardware-verified just because -// it is documented, and a wrong number is worse than no number. -// -// PLAIN, UNENCRYPTED GATT. There is no nonce, no key and no challenge/response -// anywhere in this envelope — a session is a bare write/notify pair from the -// moment the link connects. - -/// Host to device. -const int kLefunRequestMarker = 0xAB; - -/// Device to host. -const int kLefunResponseMarker = 0x5A; - -/// Marker + length + report code + trailing checksum — the fixed part of -/// every frame regardless of direction. -const int kLefunHeaderLength = 4; - -/// One GATT write/notify value never carries more than this many bytes. -const int kLefunMaxFrameLength = 20; - -/// Report code: battery level. Same code both ways — a bare request with no -/// arguments, a one-byte percentage in reply. -const int kLefunReportBattery = 0x03; - -/// Report code: firmware/type info. Request only; this file does not decode -/// the reply. -const int kLefunReportFirmwareInfo = 0x00; - -/// The reflected CRC-8 this family's checksum byte turns out to be — bit 0 of -/// the running value against bit 0 of each input byte, shifting the byte out -/// LSB-first and feeding 0x18 back in on a mismatch. Computed bit-by-bit -/// rather than table-driven: the table costs 256 entries to save a function -/// this small ever needs to call more than once per frame. -int _lefunChecksum(List data) { - var crc = 0; - for (final byte in data) { - var b = byte & 0xff; - for (var bit = 0; bit < 8; bit++) { - if (((b ^ crc) & 1) == 0) { - crc >>= 1; - } else { - crc = ((crc ^ 0x18) >> 1) | 0x80; - } - b >>= 1; - } - } - return crc & 0xff; -} - -/// One decoded envelope: a report code and its argument bytes, checksum -/// already verified. -class LefunFrame { - final int report; - final List params; - const LefunFrame(this.report, this.params); -} - -/// Throws unless [b] is a single wire byte. Every field this envelope carries -/// — the report code and every argument — is one GATT-frame byte, and a -/// caller passing something outside 0-255 would otherwise silently produce a -/// frame with a value no receiving device's byte reader can represent. -void _checkByte(int b, String name) { - if (b < 0 || b > 0xff) { - throw ArgumentError.value(b, name, 'must be a single byte (0-255)'); - } -} - -/// Build one host-to-device frame. Throws if [params] would push the frame -/// past [kLefunMaxFrameLength] — there is no multi-segment chunking here, so a -/// command that cannot fit one write has no builder in this file — or if -/// [report] or any of [params] falls outside 0-255. -List buildLefunFrame(int report, {List params = const []}) { - final length = kLefunHeaderLength + params.length; - if (length > kLefunMaxFrameLength) { - throw ArgumentError('Lefun frame does not fit one GATT write'); - } - _checkByte(report, 'report'); - for (final p in params) { - _checkByte(p, 'params'); - } - final body = [kLefunRequestMarker, length, report, ...params]; - return [...body, _lefunChecksum(body)]; -} - -/// Parse one device-to-host frame. Null for anything too short, missing the -/// response marker, declaring a length outside `kLefunHeaderLength .. -/// kLefunMaxFrameLength`, or whose checksum does not match — any of those -/// means a partial or corrupted delivery, never a value to patch up and keep. -/// -/// A DECLARED LENGTH SHORTER THAN THE BUFFER IS ACCEPTED, deliberately: this -/// codec has not been checked against a real capture, and a device in this -/// family appending bytes past its own declared length — the way the Oura -/// ring is known to (see `oura.dart`) — would otherwise have every one of its -/// notifications refused outright instead of parsed with the extra bytes -/// available to whoever archives the raw delivery. -LefunFrame? parseLefunFrame(List value) { - if (value.length < kLefunHeaderLength) return null; - if (value[0] != kLefunResponseMarker) return null; - final length = value[1]; - if (length < kLefunHeaderLength || - length > kLefunMaxFrameLength || - length > value.length) { - return null; - } - final body = value.sublist(0, length - 1); - if (_lefunChecksum(body) != value[length - 1]) return null; - return LefunFrame(value[2], value.sublist(3, length - 1)); -} - -/// Decode a battery report's single-byte argument. Null when the argument -/// count is wrong or the value falls outside 0-100 — either means a misread, -/// not a percentage to clamp and keep. -int? decodeLefunBattery(List params) { - if (params.length != 1) return null; - final level = params[0]; - if (level < 0 || level > 100) return null; - return level; -} diff --git a/lib/src/miscale.dart b/lib/src/miscale.dart new file mode 100644 index 0000000..f8030f2 --- /dev/null +++ b/lib/src/miscale.dart @@ -0,0 +1,176 @@ +// Xiaomi Mi scales: the Mi Body Composition Scale (advertises "MIBCS" / +// "MIBFS") and the Mi Smart Scale 2 ("MI SCALE2"). Plain GATT, no auth. +// +// BODY COMPOSITION SCALE — Body Composition Measurement (0x2A9C), notify, +// 13 bytes, little-endian: +// 0 flags A · 1 flags B · 2-3 year · 4 month · 5 day · 6 hour · 7 minute · +// 8 second · 9-10 impedance (ohm) · 11-12 weight (raw) +// Flags A: bit 0 pounds. Flags B: bit 1 impedance stable, bit 5 weight +// stable, bit 6 jin, bit 7 measurement finished (a finished frame is kept). +// Impedance 65533 means the measurement failed and 65534 that it is still +// running; only an impedance-stable frame carries a real value. Several +// stable frames of one weighing share its timestamp, and the impedance +// arrives on a later one than the weight. +// +// SMART SCALE 2 — Weight Measurement (0x2A9D), 10-byte records (several may +// share one notification): +// 0 flags · 1-2 weight (raw) · 3-4 year · 5 month · 6 day · 7 hour · +// 8 minute · 9 second +// Flags: bits 0 and 4 the unit (neither kg, bit 0 lb, bit 4 jin, both +// stone); bit 5 set and bit 7 clear = a stabilised live reading, anything +// else is still settling and is ignored. +// +// Weight = raw/200 kg, raw/100 lb, raw/100 jin (half a kilogram each), +// raw/100 stone. Raw 0xFFF0 is the overload marker, never a weight. +// +// STORED HISTORY (both scales) — on [kMiScaleHistoryChar], notify + write: +// host `01 ` how many records for this user +// scale `01 ` +// host `02` send them (skipped when count is 0) +// scale records: 10-byte Scale 2 records, or 13-byte frames laid out as +// 0x2A9C on the composition scale; a notification of one byte or +// less ends them +// host `03` stop; sent at the end of every session +// `04 ` acknowledges the transfer and makes the scale DELETE +// those records. Nothing here builds it: without it the scale keeps its +// history and re-sends it next time, which a timestamp-keyed upsert absorbs. +// +// TIMESTAMPS ARE UTC wall-clock components: the scale's clock is set in UTC +// ([miScaleClockValue]) and its stamps are read back the same way. + +import 'health_thermometer.dart' show WallClock, gattDateTime; + +const String kMiScaleBodyCompositionService = + '0000181b-0000-1000-8000-00805f9b34fb'; +const String kMiScaleBodyCompositionChar = + '00002a9c-0000-1000-8000-00805f9b34fb'; +const String kMiScaleWeightService = '0000181d-0000-1000-8000-00805f9b34fb'; +const String kMiScaleWeightChar = '00002a9d-0000-1000-8000-00805f9b34fb'; + +/// Stored-history characteristic on both scales (notify + write). +const String kMiScaleHistoryChar = '00002a2f-0000-3512-2118-0009af100700'; + +/// Scale mode (read + write, u16 LE). A scale reporting mode 3 is switched +/// to user mode with [miScaleUserModeCommand] on connect. +const String kMiScaleModeChar = '00001542-0000-3512-2118-0009af100700'; + +class MiScaleReading { + final double kg; + final WallClock? at; + + /// Bio-impedance in ohms (body composition scale only; null otherwise). + final int? impedanceOhm; + const MiScaleReading(this.kg, this.at, this.impedanceOhm); +} + +/// Raw weight the scales send when the load is beyond their range. +const int _kOverload = 0xFFF0; + +/// Smart Scale 2 weight: the unit is flags bits 0 and 4 together. +double _scale2Kg(int raw, int flags) => switch (flags & 0x11) { + 0x01 => raw / 100 * 0.45359237, + 0x10 => raw / 100 * 0.5, + 0x11 => raw / 100 * 6.35029318, + _ => raw / 200, + }; + +/// One body-composition frame (live, or one 13-byte history record), or null +/// if the weight is overloaded, malformed, or (live only) not yet stable. +/// A [stored] record carries no live stability state, so it is not filtered +/// on it; only its zero weight is skipped. Impedance is set only on an +/// impedance-stable frame with a real value. +MiScaleReading? parseMiBodyComposition(List b, {bool stored = false}) { + if (b.length < 13 || (!stored && b[1] & 0x20 == 0)) return null; + final raw = b[11] | (b[12] << 8); + if (raw == _kOverload || (stored && raw == 0)) return null; + final kg = b[0] & 0x01 != 0 + ? raw / 100 * 0.45359237 + : b[1] & 0x40 != 0 + ? raw / 100 * 0.5 + : raw / 200; + final impedance = b[9] | (b[10] << 8); + return MiScaleReading( + kg, + gattDateTime(b, 2), + b[1] & 0x02 != 0 && impedance != 0 && impedance < 65533 ? impedance : null, + ); +} + +/// Every 13-byte record in one composition-scale history notification. No +/// stability filter: see [parseMiBodyComposition]'s `stored`. +List parseMiBodyCompositionRecords(List b) => [ + for (var i = 0; i + 13 <= b.length; i += 13) + if (parseMiBodyComposition(b.sublist(i, i + 13), stored: true) + case final r?) + r, + ]; + +/// Every stabilised 10-byte record in one live Smart Scale 2 notification. +/// An unstabilised or overloaded record is skipped, not the ones after it. +List parseMiScale2Records(List b) { + final out = []; + for (var i = 0; i + 10 <= b.length; i += 10) { + final flags = b[i], raw = b[i + 1] | (b[i + 2] << 8); + if (flags & 0x20 == 0 || flags & 0x80 != 0 || raw == _kOverload) continue; + out.add( + MiScaleReading(_scale2Kg(raw, flags), gattDateTime(b, i + 3), null)); + } + return out; +} + +/// Every stored record in one Smart Scale 2 history notification. Stored +/// records carry no live stability state: only a record with no flags at +/// all, a zero weight (written at a reboot) or the overload marker is +/// skipped. +List parseMiScale2History(List b) { + final out = []; + for (var i = 0; i + 10 <= b.length; i += 10) { + final flags = b[i], raw = b[i + 1] | (b[i + 2] << 8); + if (flags == 0 || raw == 0 || raw == _kOverload) continue; + out.add( + MiScaleReading(_scale2Kg(raw, flags), gattDateTime(b, i + 3), null)); + } + return out; +} + +/// History: how many records the scale holds for [userId] (u32 LE). +List miScaleHistoryRequest(int userId) => [ + 0x01, + userId & 0xff, + (userId >> 8) & 0xff, + (userId >> 16) & 0xff, + (userId >> 24) & 0xff, + ]; + +/// The record count in the scale's `01 ` answer, or null. +int? miScaleHistoryCount(List b) => + b.length == 3 && b[0] == 0x01 ? b[1] | (b[2] << 8) : null; + +const List kMiScaleHistorySend = [0x02]; + +/// Ends a history session. Not an acknowledgement: the records stay. +const List kMiScaleHistoryStop = [0x03]; + +/// A notification of one byte or less ends the scale's history records. +bool miScaleHistoryDone(List b) => b.length <= 1; + +/// The user-mode command for a scale whose [kMiScaleModeChar] reads 3. +List miScaleUserModeCommand({required bool composition}) => + composition + ? const [0x06, 0x0B, 0x00, 0x00] + : const [0x03, 0x01, 0x00, 0x00, 0x00]; + +/// The scales' Current Time (0x2A2B) value for [utc]: date-time, day of week +/// (1 = Sunday … 7 = Saturday), then two zero bytes. +List miScaleClockValue(DateTime utc) => [ + utc.year & 0xff, + utc.year >> 8, + utc.month, + utc.day, + utc.hour, + utc.minute, + utc.second, + utc.weekday % 7 + 1, + 0, + 0, + ]; diff --git a/lib/src/o2ring.dart b/lib/src/o2ring.dart deleted file mode 100644 index c1cd7f6..0000000 --- a/lib/src/o2ring.dart +++ /dev/null @@ -1,174 +0,0 @@ -// 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 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 -// 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. -/// -/// 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. -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']; - // 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 - ? [ - for (final f in rawFiles.split(',')) - 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: model is String ? model : null, - serial: serial is String ? serial : null, - files: files, - ); -} diff --git a/lib/src/pebble.dart b/lib/src/pebble.dart new file mode 100644 index 0000000..627e688 --- /dev/null +++ b/lib/src/pebble.dart @@ -0,0 +1,216 @@ +// Pebble Protocol over PPoGATT (Pebble 2 / 2 SE, BLE client mode): inner +// framing, the minimal phone handshake, data logging, and the health records +// the watch logs. +// +// PPoGATT packet: one header byte, `serial << 3 | command` (0 data, 1 ack, +// 2 reset), then payload. Serials are 5-bit and wrap. +// +// INNER FRAME (spans packets): `u16 BE length (payload only) | u16 BE +// endpoint | payload`. +// +// HANDSHAKE: the watch asks on endpoint 17 (`[0x00]`); the phone answers with +// its version and capabilities. Then the phone sets the time (endpoint 11) +// and asks data logging to report its sessions (endpoint 6778, `[0x84]`). +// +// DATA LOGGING (endpoint 6778, all little-endian after the frame header): +// 0x01 open `sid | uuid[16] | u32 ts | u32 tag | u8 itemType | u16 itemSize` +// 0x02 data `sid | u32 itemsLeft | u32 crc | items…` +// 0x03 close `sid` 0x07 timeout `sid` +// Every message is answered ACK `[0x85, sid]` or NACK `[0x86, sid]`; a NACKed +// message is re-sent. An ACKed one is not — the phone owns it from then on. +// +// HEALTH SESSIONS (tag): 81 steps/minute records, 83 sleep, 84 activity +// overlays (sleep / deep sleep), 85 HR (not decoded; HR rides in the v7+ +// minute record). Timestamps are Unix seconds. + +import 'dart:typed_data'; + +const int kPebbleEndpointTime = 11; +const int kPebbleEndpointPhoneVersion = 17; +const int kPebbleEndpointDatalog = 6778; + +const int kPebbleDatalogOpen = 0x01; +const int kPebbleDatalogData = 0x02; +const int kPebbleDatalogClose = 0x03; +const int kPebbleDatalogTimeout = 0x07; + +const int kPebbleTagSteps = 81; +const int kPebbleTagSleep = 83; +const int kPebbleTagOverlay = 84; + +const int kPebbleOverlaySleep = 1; +const int kPebbleOverlayDeepSleep = 2; + +// ── framing ────────────────────────────────────────────────────────────── + +/// One inner frame: `u16 BE length | u16 BE endpoint | payload`. +List pebbleFrame(int endpoint, List payload) => [ + (payload.length >> 8) & 0xff, + payload.length & 0xff, + (endpoint >> 8) & 0xff, + endpoint & 0xff, + ...payload, + ]; + +/// Splits an inner frame into PPoGATT data packets of at most [maxPacket] +/// bytes (header included), numbering them from [firstSerial]. +List> pebblePpogattPackets(List frame, int firstSerial, + {int maxPacket = 20}) { + final out = >[]; + var serial = firstSerial; + for (var i = 0; i < frame.length; i += maxPacket - 1) { + final end = i + maxPacket - 1 < frame.length ? i + maxPacket - 1 : frame.length; + out.add([(serial & 0x1f) << 3, ...frame.sublist(i, end)]); + serial++; + } + return out; +} + +/// Reassembles inner frames from consecutive PPoGATT data payloads. +class PebbleFrameReassembler { + final List _buf = []; + + /// Feeds one data payload; returns every complete (endpoint, payload). + List<(int, Uint8List)> add(List payload) { + _buf.addAll(payload); + final out = <(int, Uint8List)>[]; + while (_buf.length >= 4) { + final len = (_buf[0] << 8) | _buf[1]; + if (_buf.length < 4 + len) break; + final endpoint = (_buf[2] << 8) | _buf[3]; + out.add((endpoint, Uint8List.fromList(_buf.sublist(4, 4 + len)))); + _buf.removeRange(0, 4 + len); + } + return out; + } +} + +// ── handshake ──────────────────────────────────────────────────────────── + +/// The phone-version reply (endpoint 17): response byte, session caps, +/// platform flags, remote OS (Android = 2), app version 2.4.4.2, then the +/// 8-byte little-endian protocol-capability mask. +List pebblePhoneVersionReply() { + const caps = 0x1 | 0x2 | 0x4 | 0x8 | 0x20 | 0x80 | 0x100 | 0x800 | 0x2000; + return [ + 0x01, + 0xff, 0xff, 0xff, 0xff, // session capabilities + 0x00, 0x00, 0x00, 0x00, // platform flags + 0x00, 0x00, 0x00, 0x02, // remote OS + 0x02, 0x04, 0x04, 0x02, // app version magic, major, minor, patch + for (var i = 0; i < 8; i++) (caps >> (8 * i)) & 0xff, + ]; +} + +/// Set the watch's clock (endpoint 11): UTC seconds, the local UTC offset in +/// minutes (DST included), and a time-zone name (may be empty). +List pebbleSetTimeUtc(DateTime now, {String tzName = ''}) { + final utc = now.millisecondsSinceEpoch ~/ 1000; + final offset = now.timeZoneOffset.inMinutes & 0xffff; + final name = tzName.codeUnits.take(31).toList(); + return [ + 0x03, + (utc >> 24) & 0xff, (utc >> 16) & 0xff, (utc >> 8) & 0xff, utc & 0xff, + (offset >> 8) & 0xff, offset & 0xff, + name.length, + ...name, + ]; +} + +const List kPebbleDatalogReportSessions = [0x84]; +List pebbleDatalogAck(int sid) => [0x85, sid]; +List pebbleDatalogNack(int sid) => [0x86, sid]; + +// ── data logging ───────────────────────────────────────────────────────── + +int _u16(List b, int i) => b[i] | (b[i + 1] << 8); +int _u32(List b, int i) => + b[i] | (b[i + 1] << 8) | (b[i + 2] << 16) | (b[i + 3] << 24); + +/// A data-logging session as opened by the watch. +class PebbleDatalogSession { + final int sid; + final int tag; + final int itemSize; + const PebbleDatalogSession(this.sid, this.tag, this.itemSize); +} + +/// The session announced by an open message, or null if malformed. +PebbleDatalogSession? parsePebbleDatalogOpen(List p) { + if (p.length < 29 || p[0] != kPebbleDatalogOpen) return null; + return PebbleDatalogSession(p[1], _u32(p, 22), _u16(p, 27)); +} + +/// The items of a data message, or null if malformed or not a whole number +/// of items. +List? parsePebbleDatalogItems(List p, int itemSize) { + if (p.length < 10 || p[0] != kPebbleDatalogData || itemSize <= 0) { + return null; + } + final data = p.sublist(10); + if (data.length % itemSize != 0) return null; + return [ + for (var i = 0; i < data.length; i += itemSize) + Uint8List.fromList(data.sublist(i, i + itemSize)), + ]; +} + +// ── health records ─────────────────────────────────────────────────────── + +/// One minute of the watch's health log. +class PebbleMinute { + final int tsSec; + final int steps; + final int orientation; + final int intensity; + final int light; + + /// Beats per minute (record version 7+), or null when absent or 0. + final int? hr; + const PebbleMinute( + this.tsSec, this.steps, this.orientation, this.intensity, this.light, + this.hr); +} + +const Set kPebbleStepsVersions = {5, 6, 7, 12, 13, 14}; + +/// Decodes one steps item: `u16 version | u32 ts | ?u8 | u8 recordLength | +/// u8 recordCount | records`, one record per minute from `ts`. Null for an +/// unknown version — the caller must not ACK what it cannot read. +List? parsePebbleStepsItem(List b) { + if (b.length < 9) return null; + final version = _u16(b, 0); + if (!kPebbleStepsVersions.contains(version)) return null; + final ts = _u32(b, 2); + final recLen = b[7], count = b[8]; + // steps u8 · orientation u8 · intensity u16 · light u8 · flags u8 (v5+) · + // resting/active calories, distance cm u16 each (v6+) · HR u8 (v7+) · … + final need = version >= 7 ? 13 : version >= 6 ? 12 : 6; + if (recLen < need) return null; + final out = []; + for (var r = 0; r < count; r++) { + final o = 9 + r * recLen; + if (o + recLen > b.length) break; + final hr = version >= 7 ? b[o + 12] : 0; + out.add(PebbleMinute(ts + r * 60, b[o], b[o + 1], _u16(b, o + 2), b[o + 4], + hr == 0 ? null : hr)); + } + return out; +} + +/// One activity overlay: a sleep (1) or deep-sleep (2) period. +class PebbleOverlay { + final int type; + final int startSec; + final int durationSec; + const PebbleOverlay(this.type, this.startSec, this.durationSec); +} + +/// Decodes one overlay item: `u16 version | u16 ? | u16 type | u32 offset | +/// u32 start | u32 duration`. Null for an unknown version. +PebbleOverlay? parsePebbleOverlayItem(List b) { + if (b.length < 18) return null; + final version = _u16(b, 0); + if (version != 1 && version != 3) return null; + return PebbleOverlay(_u16(b, 4), _u32(b, 10), _u32(b, 14)); +} diff --git a/lib/src/polar_pmd.dart b/lib/src/polar_pmd.dart index 04591e5..c9348dc 100644 --- a/lib/src/polar_pmd.dart +++ b/lib/src/polar_pmd.dart @@ -1,6 +1,7 @@ // Polar's PMD (measurement data) service, PPI stream only — plain functions, // no crypto, no key exchange. Any Polar optical sensor that exposes this GATT -// service (armband, ring, chest strap). +// service and offers PPI (armband, ring). H10/H9 chest straps are ECG-based +// and refuse PPI start; they go through ble_hrs instead. // // NOTHING HERE HAS MET HARDWARE. Nobody on this project owns one and // `flutter_blue_plus` has no simulator path, so this is verified by the wire @@ -25,6 +26,33 @@ const int kPolarPmdMeasTypePpi = 0x03; /// First byte of every control-point INDICATE reply. const int kPolarPmdControlPointResponseCode = 0xF0; +/// First byte of the sensor's own unsolicited "these online streams have +/// stopped" control-point indication, followed by one measurement-type byte +/// per stopped stream. +const int kPolarPmdOnlineMeasurementStopped = 0x01; + +/// First byte of the control point's READ value: the feature set, followed by +/// one byte whose bit N is set when measurement type N is offered. +const int kPolarPmdFeatureReadCode = 0x0F; + +/// Whether a control-point READ value says the sensor offers PPI: true or +/// false from its feature bitmap, or null when the value is not a feature +/// reply at all (too short, or not starting with [kPolarPmdFeatureReadCode]). +/// A sensor with the PMD service and no PPI (an ECG chest strap) answers +/// false here and refuses PPI start. +bool? polarPmdSupportsPpi(List value) { + if (value.length < 2 || value[0] != kPolarPmdFeatureReadCode) return null; + return (value[1] & (1 << kPolarPmdMeasTypePpi)) != 0; +} + +/// Whether one control-point indication is the sensor ending the PPI stream +/// by itself ([kPolarPmdOnlineMeasurementStopped] listing PPI among its +/// types). False for every other indication, a START reply included. +bool polarPmdPpiStopped(List value) => + value.isNotEmpty && + value[0] == kPolarPmdOnlineMeasurementStopped && + value.skip(1).any((t) => (t & 0x3F) == kPolarPmdMeasTypePpi); + /// The bytes to write to the control point to start online PPI streaming. /// `(recording << 7) | measType` with `recording` clear (online streaming, not /// on-sensor recording) and no setting blocks — PPI has none to negotiate. @@ -78,19 +106,22 @@ class PolarPpiSample { /// The sensor's own error estimate for [ppiMs], in milliseconds. final int errorEstimateMs; - /// Flags byte, bit 0. Documented as marking a reading that should be - /// dropped from an HRV computation (a "blocker" sample), but — like - /// [skinContactBits] below — this comes from the same never-tested flags - /// byte, so which bit is blocker is NOT independently confirmed against - /// hardware. + /// Flags byte, bit 0: the sensor marks this interval invalid (motion + /// blocked the measurement). The interval is not a beat; the HR in the same + /// record carries no such mark. final bool blocker; - /// Flags byte, bits 1-2, verbatim. These are documented as carrying - /// skin-contact information, but which value means contact and which means - /// none is NOT independently confirmed against hardware — captured under - /// its own name rather than gated on. + /// Flags byte, bits 1-2, as `status | supported << 1`: bit 1 of the flags + /// is skin-contact status (1 = contact), bit 2 is skin-contact supported. + /// Same encoding as 0x2A37's contact bits, so 2 is "supported, no contact". + /// See [contact]. final int skinContactBits; + /// The sensor's own contact claim, as `HrsSample.contact` reads 0x2A37: + /// null when it does not support the field, else true/false. + bool? get contact => + (skinContactBits & 0x02) == 0 ? null : (skinContactBits & 0x01) == 1; + const PolarPpiSample({ required this.hr, required this.ppiMs, @@ -103,9 +134,10 @@ class PolarPpiSample { /// Parse one PMD data-characteristic notification as a PPI frame. /// /// Layout: byte 0 measurement type (low 6 bits); bytes 1-8 a u64 LE PMD -/// timestamp (unused here — PPI carries no clock this decoder needs, see -/// [PolarPpiSample]'s field list); byte 9 frame type; bytes 10+ one or more -/// fixed 6-byte PPI records: +/// timestamp in ns which, when non-zero, stamps the LAST record of the frame +/// (earlier records sit one interval back each) — deliberately unused: the +/// session anchors on arrival time instead; byte 9 frame type; bytes 10+ +/// one or more fixed 6-byte PPI records: /// `[hr][ppiMs u16 LE][errorEstimateMs u16 LE][flags]`. /// /// Returns null when the frame is too short, is not measurement type PPI, is diff --git a/lib/src/ring11m.dart b/lib/src/ring11m.dart deleted file mode 100644 index 6b6c3f4..0000000 --- a/lib/src/ring11m.dart +++ /dev/null @@ -1,310 +0,0 @@ -// A generic white-label smart ring's wire format ("R11M"/"R10M", also sold as -// "TK5"). Bytes only: no BLE, no Flutter, no database. -// -// NOT the Colmi R11/R12 — that is a different, unrelated official product on -// a different 16-byte fixed-packet protocol. This file is for the separate -// white-label cluster (many storefront names, no vendor in common) that -// speaks the framing below instead. -// -// NOTHING HERE HAS MET HARDWARE (ASSUMPTIONS R6). Ships EXPERIMENTAL: this -// file decodes only what a real captured example frame proves, and archives -// everything else verbatim rather than guessing a layout nobody has checked -// against a byte. -// -// PROVEN, because a real example frame is checked against it below: the -// header shape, the length field, and the CRC — the model-query request -// `02 03 08 00 47 50 ef 20` decodes as group 0x02, command 0x03, declared -// length 8, payload `47 50`, and the trailing `ef 20` (LE 0x20ef) is exactly -// CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no reflection) over the six -// bytes before it — verified by direct computation against that frame, not -// assumed from a name. `_crc16CcittFalse` below is pinned to it. -// -// NOT INDEPENDENTLY VERIFIED, and left undone rather than guessed: which -// wire command actually starts a history transfer for a given data type (no -// concrete command byte for any history "query key" was available to check -// against a real frame), and every history record's internal field layout -// (widths are stated, offsets are not). So this file parses the framing that -// IS pinned — the outer envelope, and the block terminator with its own -// packet count and CRC-16/CCITT trailer — and stops there: a completed -// history block is archived as one undecoded blob, never split into records -// this file cannot actually place a field in. -// -// AUTH: none. No handshake, no key material — session bring-up is plain -// request/response. - -import 'dart:typed_data'; - -// ── command groups ────────────────────────────────────────────────────── -const int kRing11mGroupSetting = 0x01; -const int kRing11mGroupDeviceInfo = 0x02; -const int kRing11mGroupAppControl = 0x03; -const int kRing11mGroupHealthHistory = 0x05; -const int kRing11mGroupRealtime = 0x06; - -// ── commands, by group ────────────────────────────────────────────────── -/// group [kRing11mGroupDeviceInfo]. -const int kRing11mCmdModelQuery = 0x03; -const int kRing11mCmdBatteryQuery = 0x00; -const int kRing11mCmdCapabilityQuery = 0x01; - -/// group [kRing11mGroupSetting]. -const int kRing11mCmdSetTime = 0x00; -const int kRing11mCmdAutoHrToggle = 0x0c; -const int kRing11mCmdAutoSpo2Toggle = 0x26; - -/// group [kRing11mGroupAppControl]. -const int kRing11mCmdFindDevice = 0x00; -const int kRing11mCmdManualMeasurement = 0x2f; -const int kRing11mCmdLiveActivityTotals = 0x09; - -/// group [kRing11mGroupHealthHistory]. The frame that closes one history -/// block — see [Ring11mHistoryTerminator]. This is the only command in this -/// group with a byte value this file actually has; the per-type request -/// ("query key") is not pinned down (see the header note) and has no -/// builder here. -const int kRing11mCmdHistoryTerminator = 0x80; - -/// group [kRing11mGroupRealtime]. Unsolicited pushes, archived verbatim — -/// see the header note on why no body here is decoded. -const int kRing11mCmdRealtimeStatus = 0x00; -const int kRing11mCmdRealtimeHr = 0x01; -const int kRing11mCmdRealtimeSpo2 = 0x02; -const int kRing11mCmdRealtimeVitals = 0x03; -const int kRing11mCmdRealtimeBattery = 0x15; - -/// [ring11mCmdManualMeasurement]'s `kind` byte. -const int kRing11mMeasureHr = 0x00; -const int kRing11mMeasureBp = 0x01; -const int kRing11mMeasureSpo2 = 0x02; - -/// CRC-16/CCITT-FALSE: poly 0x1021, init 0xFFFF, no reflection, no final XOR. -/// See the header note for the real frame this is checked against. -int _crc16CcittFalse(List data) { - int crc = 0xFFFF; - for (final b in data) { - crc ^= (b & 0xFF) << 8; - for (var i = 0; i < 8; i++) { - crc = (crc & 0x8000) != 0 ? ((crc << 1) ^ 0x1021) & 0xFFFF : (crc << 1) & 0xFFFF; - } - } - return crc & 0xFFFF; -} - -/// One frame on the command/reply characteristic, both directions: -/// `[group:1][command:1][totalLen:2 LE][payload:N][crc16:2 LE]`. -/// -/// `totalLen` counts the WHOLE frame, header and CRC included, so a -/// well-formed frame is always exactly `totalLen` bytes long. -class Ring11mFrame { - final int group; - final int command; - final Uint8List payload; - const Ring11mFrame(this.group, this.command, this.payload); -} - -/// Parse one notification/write value as a frame. Null when it is too short, -/// its declared length disagrees with what was actually delivered, or its -/// trailing CRC does not match — a length or CRC mismatch is a malformed or -/// foreign frame, not a short one to hand back anyway. -Ring11mFrame? parseRing11mFrame(List value) { - if (value.length < 6) return null; - final totalLen = value[2] | (value[3] << 8); - if (totalLen != value.length) return null; - final crcExpected = value[totalLen - 2] | (value[totalLen - 1] << 8); - final crcActual = _crc16CcittFalse(value.sublist(0, totalLen - 2)); - if (crcExpected != crcActual) return null; - return Ring11mFrame( - value[0], - value[1], - Uint8List.fromList(value.sublist(4, totalLen - 2)), - ); -} - -/// The most a payload may be: `totalLen` is a u16 LE field counting the whole -/// frame, header and CRC included, so anything past this overflows it. -const int kRing11mMaxPayloadLen = 0xffff - 6; - -/// Build one outbound frame, header, payload and CRC all included. -Uint8List buildRing11mFrame(int group, int command, [List payload = const []]) { - if (payload.length > kRing11mMaxPayloadLen) { - throw ArgumentError.value(payload.length, 'payload.length', - 'would overflow the frame\'s own u16 length field'); - } - final totalLen = 6 + payload.length; - final out = Uint8List(totalLen); - out[0] = group & 0xff; - out[1] = command & 0xff; - out[2] = totalLen & 0xff; - out[3] = (totalLen >> 8) & 0xff; - out.setRange(4, 4 + payload.length, payload); - final crc = _crc16CcittFalse(out.sublist(0, totalLen - 2)); - out[totalLen - 2] = crc & 0xff; - out[totalLen - 1] = (crc >> 8) & 0xff; - return out; -} - -// ── device info ───────────────────────────────────────────────────────── -/// The verified example frame's own payload, `47 50` — see the header note. -/// Not decoded as anything meaningful on its own (no ASCII reading of two -/// bytes is offered here), just reproduced exactly rather than guessed empty. -Uint8List ring11mCmdModelQuery() => - buildRing11mFrame(kRing11mGroupDeviceInfo, kRing11mCmdModelQuery, const [0x47, 0x50]); - -/// The ASCII model string (e.g. `"R11M"`), or null when [f] is not this -/// reply or is not printable ASCII. -String? parseRing11mModel(Ring11mFrame f) { - if (f.group != kRing11mGroupDeviceInfo || - f.command != kRing11mCmdModelQuery || - f.payload.isEmpty) { - return null; - } - for (final b in f.payload) { - if (b < 0x20 || b > 0x7e) return null; - } - return String.fromCharCodes(f.payload); -} - -Uint8List ring11mCmdBatteryQuery() => - buildRing11mFrame(kRing11mGroupDeviceInfo, kRing11mCmdBatteryQuery); - -/// Battery percent at payload offset 0 — the one field this file has -/// confidence in placing, since the reply is documented as carrying the -/// level "at a fixed offset" with nothing else described. Bounded 0-100 as a -/// physical fact, not an encoding one: a decoder reading the wrong byte -/// fails this instead of sailing through. -int? parseRing11mBattery(Ring11mFrame f) { - if (f.group != kRing11mGroupDeviceInfo || - f.command != kRing11mCmdBatteryQuery || - f.payload.isEmpty) { - return null; - } - final pct = f.payload[0]; - return pct <= 100 ? pct : null; -} - -Uint8List ring11mCmdCapabilityQuery() => - buildRing11mFrame(kRing11mGroupDeviceInfo, kRing11mCmdCapabilityQuery); - -/// The raw capability bitmask bytes, UNDECODED. The reply is documented as a -/// bitmask naming fourteen capabilities but not their bit positions, and a -/// guessed bit order silently reports the wrong feature set — so this hands -/// back the bytes for `raw_archive` and nothing more. Null when [f] is not -/// this reply. -Uint8List? parseRing11mCapabilitiesRaw(Ring11mFrame f) { - if (f.group != kRing11mGroupDeviceInfo || f.command != kRing11mCmdCapabilityQuery) { - return null; - } - return f.payload; -} - -// ── setting ────────────────────────────────────────────────────────────── -/// Set the ring's clock from local wall-clock fields: `[year:2 LE][month] -/// [day][hour][minute][second][weekday]`, `weekday` as Dart's `DateTime. -/// weekday` (1 = Monday .. 7 = Sunday). -Uint8List ring11mCmdSetTime(DateTime local) { - final b = Uint8List(8); - b[0] = local.year & 0xff; - b[1] = (local.year >> 8) & 0xff; - b[2] = local.month; - b[3] = local.day; - b[4] = local.hour; - b[5] = local.minute; - b[6] = local.second; - b[7] = local.weekday; - return buildRing11mFrame(kRing11mGroupSetting, kRing11mCmdSetTime, b); -} - -/// Toggle automatic HR or SpO2 monitoring. [intervalMinutes] is snapped to -/// the only two values the firmware combination is documented to accept. -/// -/// THE PAYLOAD SHAPE IS AN INFERENCE, not a captured example: the source -/// material states the command exists and the interval is snapped to 30 or -/// 60 minutes, but does not show a body. `[enable, intervalMinutes]` mirrors -/// [ring11mCmdManualMeasurement]'s own `[enable, kind]` shape on the -/// sibling app-control command — the same firmware family's nearest -/// documented pattern — rather than an unrelated guess. Treat this one as -/// unverified until checked against a real device. -Uint8List ring11mCmdAutoToggle(int command, bool enable, {int intervalMinutes = 30}) { - if (command != kRing11mCmdAutoHrToggle && command != kRing11mCmdAutoSpo2Toggle) { - throw ArgumentError.value(command, 'command', - 'must be kRing11mCmdAutoHrToggle or kRing11mCmdAutoSpo2Toggle'); - } - final snapped = intervalMinutes >= 45 ? 60 : 30; - return buildRing11mFrame( - kRing11mGroupSetting, - command, - [enable ? 0x01 : 0x00, snapped], - ); -} - -// ── app control ────────────────────────────────────────────────────────── -Uint8List ring11mCmdFindDevice() => - buildRing11mFrame(kRing11mGroupAppControl, kRing11mCmdFindDevice); - -Uint8List ring11mCmdManualMeasurement(bool start, int kind) { - if (kind != kRing11mMeasureHr && - kind != kRing11mMeasureBp && - kind != kRing11mMeasureSpo2) { - throw ArgumentError.value(kind, 'kind', - 'must be kRing11mMeasureHr, kRing11mMeasureBp or kRing11mMeasureSpo2'); - } - return buildRing11mFrame( - kRing11mGroupAppControl, - kRing11mCmdManualMeasurement, - [start ? 0x01 : 0x00, kind], - ); -} - -Uint8List ring11mCmdLiveActivityTotals() => - buildRing11mFrame(kRing11mGroupAppControl, kRing11mCmdLiveActivityTotals); - -// ── health/history transfer ─────────────────────────────────────────────── -/// The frame that closes one history block: `[packetCount:2 LE][…] -/// [crc16:2 LE]`, the trailing CRC over the block's accumulated data bytes — -/// CRC-16/CCITT-FALSE, same construction as the outer frame (poly 0x1021, -/// init 0xFFFF). What sits between the count and the trailer is not -/// described anywhere available and is not read here. -class Ring11mHistoryTerminator { - final int packetCount; - final int crc16; - const Ring11mHistoryTerminator(this.packetCount, this.crc16); -} - -/// The terminator carried by [f], or null when [f] is not one. -/// -/// STRUCTURE ONLY — this does not and cannot check the CRC itself: that -/// requires the block's own accumulated data bytes, which live with whatever -/// is collecting them, not with one frame. [ring11mHistoryBlockCrcOk] is the -/// paired check every caller should run before treating a block as good. -Ring11mHistoryTerminator? parseRing11mHistoryTerminator(Ring11mFrame f) { - if (f.group != kRing11mGroupHealthHistory || - f.command != kRing11mCmdHistoryTerminator || - f.payload.length < 4) { - return null; - } - final n = f.payload.length; - return Ring11mHistoryTerminator( - f.payload[0] | (f.payload[1] << 8), - f.payload[n - 2] | (f.payload[n - 1] << 8), - ); -} - -/// CRC-16/CCITT-FALSE over [blockData] — the same check a completed history -/// block's terminator carries. Exposed so a caller can compare it against -/// [Ring11mHistoryTerminator.crc16] without a second implementation of the -/// algorithm. -int ring11mHistoryCrc(List blockData) => _crc16CcittFalse(blockData); - -/// Whether [blockData] matches the CRC [term] declares — the check every -/// caller must run before treating a completed block as good. A parsed -/// [Ring11mHistoryTerminator] on its own asserts nothing about correctness; -/// this is what does. -bool ring11mHistoryBlockCrcOk(List blockData, Ring11mHistoryTerminator term) => - ring11mHistoryCrc(blockData) == term.crc16; - -/// The host's ack/nack of one history block — NOT run through -/// [buildRing11mFrame]: the source material gives these as a fixed 3-byte -/// sequence in their own right, `05 80 00` (ack) or `05 80 04` (nack), with -/// no length field or CRC of their own. -Uint8List buildRing11mHistoryAck(bool ok) => - Uint8List.fromList([kRing11mGroupHealthHistory, kRing11mCmdHistoryTerminator, ok ? 0x00 : 0x04]); diff --git a/lib/src/ringconn.dart b/lib/src/ringconn.dart deleted file mode 100644 index 79eb8e9..0000000 --- a/lib/src/ringconn.dart +++ /dev/null @@ -1,384 +0,0 @@ -// The RingConn ring's wire format, as pure functions. No BLE, no Flutter, no -// database — everything here takes bytes and returns values, so the whole of -// it is exercised by `test/ringconn_test.dart` with no hardware in the room. -// -// SM3 IS HERE AND NOT ONE LAYER UP, unlike Oura's AES-128/ECB. Oura's cipher -// needed a runtime dependency this package deliberately has none of; SM3 is a -// published algorithm (GB/T 32905-2016 — the same public-standard footing as -// AES or SHA-256) with no library on pub.dev worth adding a dependency for a -// 64-round compression function, so it is written out below from the standard -// itself rather than imported. -// -// WHAT IS PROVEN AND WHAT IS NOT (nobody on this project owns this hardware): -// -// * PROVEN: the SM3 compression function against its own published -// known-answer test vector (`test/ringconn_test.dart`), independent of -// anything ring-specific — any conformant SM3 implementation reproduces -// it. The frame trailer conventions, the command byte shapes, and the -// bulk-page record lengths are exercised by byte-exact fixtures in the -// same file. -// * NOT PROVEN, and not attempted: the challenge/MAC/response TRIPLE used -// to test [ringConnAuthResponse] end-to-end is self-consistent (computed -// once from this file's own SM3 and pinned so the function cannot drift), -// not a captured value from a real ring — there is no ring in the room to -// capture one from. The MAC-recovery fallback branches in -// [ringConnMacFromSystemId] beyond the forward EUI-64 form are likewise -// unverified against real hardware. -// * NOT DECODED AT ALL, ON PURPOSE: every byte inside a bulk-page record. -// The 47-byte and 23-byte record lengths are structural — enough to slice -// a page into records and walk the stream — and nothing inside a record -// is interpreted here. A guessed field layout is how a confidently wrong -// number reaches a chart; the records are banked verbatim instead, one -// layer up. -import 'dart:typed_data'; - -// ── SM3 (GB/T 32905-2016) ─────────────────────────────────────────────────── - -const List _kSm3Iv = [ - 0x7380166f, 0x4914b2b9, 0x172442d7, 0xda8a0600, // - 0xa96f30bc, 0x163138aa, 0xe38dee4d, 0xb0fb0e4e, -]; - -int _rotl32(int x, int n) => - n == 0 ? (x & 0xffffffff) : (((x << n) | (x >>> (32 - n))) & 0xffffffff); - -int _ff(int j, int x, int y, int z) => - j < 16 ? (x ^ y ^ z) : ((x & y) | (x & z) | (y & z)); - -int _gg(int j, int x, int y, int z) => - j < 16 ? (x ^ y ^ z) : ((x & y) | ((~x & 0xffffffff) & z)); - -int _p0(int x) => x ^ _rotl32(x, 9) ^ _rotl32(x, 17); -int _p1(int x) => x ^ _rotl32(x, 15) ^ _rotl32(x, 23); - -/// SM3, the 256-bit digest GB/T 32905-2016 defines. A Merkle–Damgård -/// construction structurally close to SHA-256 — same padding shape, same -/// 32-bit-word block size — with its own boolean functions, permutations and -/// round constants. -/// -/// Built from 32-bit words throughout (`& 0xffffffff` after every arithmetic -/// op, `>>>` — zero-fill shift — for every rotate) rather than `ByteData`'s -/// 64-bit accessors, which dart2js does not implement; see [_lenBytes] for the -/// one place a 64-bit quantity (the bit length) has to be built the same way. -Uint8List sm3(List message) { - final data = [...message, 0x80]; - while (data.length % 64 != 56) { - data.add(0); - } - data.addAll(_lenBytes(message.length * 8)); - - var v = List.from(_kSm3Iv); - for (var block = 0; block < data.length; block += 64) { - final w = List.filled(68, 0); - for (var i = 0; i < 16; i++) { - final o = block + i * 4; - w[i] = (data[o] << 24) | - (data[o + 1] << 16) | - (data[o + 2] << 8) | - data[o + 3]; - } - for (var j = 16; j < 68; j++) { - final x = w[j - 16] ^ w[j - 9] ^ _rotl32(w[j - 3], 15); - w[j] = (_p1(x) ^ _rotl32(w[j - 13], 7) ^ w[j - 6]) & 0xffffffff; - } - final wp = List.filled(64, 0); - for (var j = 0; j < 64; j++) { - wp[j] = w[j] ^ w[j + 4]; - } - - var a = v[0], b = v[1], c = v[2], d = v[3]; - var e = v[4], f = v[5], g = v[6], h = v[7]; - for (var j = 0; j < 64; j++) { - final tj = j < 16 ? 0x79cc4519 : 0x7a879d8a; - final ss1 = - _rotl32((_rotl32(a, 12) + e + _rotl32(tj, j % 32)) & 0xffffffff, 7); - final ss2 = ss1 ^ _rotl32(a, 12); - final tt1 = (_ff(j, a, b, c) + d + ss2 + wp[j]) & 0xffffffff; - final tt2 = (_gg(j, e, f, g) + h + ss1 + w[j]) & 0xffffffff; - d = c; - c = _rotl32(b, 9); - b = a; - a = tt1; - h = g; - g = _rotl32(f, 19); - f = e; - e = _p0(tt2); - } - v = [ - v[0] ^ a, v[1] ^ b, v[2] ^ c, v[3] ^ d, // - v[4] ^ e, v[5] ^ f, v[6] ^ g, v[7] ^ h, - ]; - } - - final out = Uint8List(32); - for (var i = 0; i < 8; i++) { - out[i * 4] = (v[i] >>> 24) & 0xff; - out[i * 4 + 1] = (v[i] >>> 16) & 0xff; - out[i * 4 + 2] = (v[i] >>> 8) & 0xff; - out[i * 4 + 3] = v[i] & 0xff; - } - return out; -} - -/// [bitLen] as 8 big-endian bytes, built from two 32-bit halves. Not -/// `ByteData.setUint64`: dart2js's `ByteData` throws `UnsupportedError` on the -/// 64-bit accessors (the same reason `ouraCmdSyncTime` builds a u64 by hand). -/// Every message this file ever hashes is a handful of bytes, so the high -/// word is always zero in practice — written out anyway for correctness at -/// the algorithm's own field width. -List _lenBytes(int bitLen) { - final hi = (bitLen >>> 32) & 0xffffffff; - final lo = bitLen & 0xffffffff; - return [ - for (final w in [hi, lo]) ...[ - (w >>> 24) & 0xff, - (w >>> 16) & 0xff, - (w >>> 8) & 0xff, - w & 0xff, - ], - ]; -} - -// ── MAC recovery ───────────────────────────────────────────────────────────── - -/// The ring's 6-byte BLE MAC, decoded from the 8-byte EUI-64 the standard -/// System ID characteristic (`0x2A23`) carries. -/// -/// THE FORWARD FORM IS THE PROVEN ONE: an EUI-64 built from a MAC-48 is -/// `OUI(3) ++ FF FE ++ NIC(3)`, so stripping bytes 3-4 and concatenating what -/// is left recovers the original 6 bytes. The other two branches are -/// fallbacks for a stack that hands the characteristic back byte-reversed, or -/// a ring that never inserted the `FF FE` marker at all — neither has been -/// seen against real hardware, and both are here because a MAC this function -/// cannot recover means a session that can never authenticate. -Uint8List ringConnMacFromSystemId(List systemId) { - if (systemId.length != 8) { - throw ArgumentError('a System ID (0x2A23) is exactly 8 bytes'); - } - final b = systemId; - if (b[3] == 0xff && b[4] == 0xfe) { - return Uint8List.fromList([b[0], b[1], b[2], b[5], b[6], b[7]]); - } - final r = b.reversed.toList(); - if (r[3] == 0xff && r[4] == 0xfe) { - return Uint8List.fromList([r[0], r[1], r[2], r[5], r[6], r[7]]); - } - // No FF FE marker in either direction: some stacks hand back a bare 6-byte - // MAC padded to 8, and the leading 6 bytes are the MAC in that shape too. - return Uint8List.fromList(b.sublist(0, 6)); -} - -/// The 3-byte auth-response tail: `SM3([V, challenge])[29:32]`, where -/// `V = mac[3] ^ mac[4] ^ mac[5]`. -/// -/// [challenge] is the single byte the ring's status reply carries — see -/// [parseRingConnFrame] and [kRingConnRespAuth]. -Uint8List ringConnAuthResponse(List mac, int challenge) { - if (mac.length != 6) { - throw ArgumentError('a RingConn MAC is exactly 6 bytes'); - } - final v = (mac[3] ^ mac[4] ^ mac[5]) & 0xff; - final digest = sm3([v, challenge & 0xff]); - return Uint8List.sublistView(digest, 29, 32); -} - -// ── framing ────────────────────────────────────────────────────────────────── - -/// The epoch RingConn's history cursor counts from: 2019-12-31 12:00:00 UTC, -/// as a Unix second. A cursor is `floor(unixNow) - kRingConnEpochOffset`. -const int kRingConnEpochOffset = 1577793600; - -/// [unixSeconds] as a RingConn cursor. Do not pass `0xFFFFFFFF` through this — -/// that value is an unverified "skip backlog" path the vendor app itself is -/// never observed to send; opening at "now" (this function, given the current -/// wall-clock second) is the app-faithful behaviour. -int ringConnCursor(int unixSeconds) => unixSeconds - kRingConnEpochOffset; - -/// The history channel that carries sleep/overnight records. -const int kRingConnChannelSleep = 0x00; - -/// The history channel that carries all-day/awake records. -const int kRingConnChannelAwake = 0x03; - -const int kRingConnCmdStatus = 0x01; -const int kRingConnCmdSyncOpen = 0x02; -const int kRingConnCmdFetch = 0x07; -const int kRingConnCmdAckPpg = 0xc7; -const int kRingConnCmdAckActivity = 0xcc; - -/// `0x01 ^ 0x80` — the status/auth reply tag. -const int kRingConnRespAuth = 0x81; - -/// `0x02 ^ 0x80` — the sync-open reply tag. -const int kRingConnRespSyncOpen = 0x82; - -/// `0x07 ^ 0x80` — "nothing more this poll", the ordinary way a fetch answers -/// once a burst is drained. -const int kRingConnRespFetchEmpty = 0x87; - -/// A second observed "burst is over" reply with no established meaning beyond -/// that. Treated the same as [kRingConnRespFetchEmpty] and [kRingConnRespStatus] -/// by [ringConnEndsBurst] — never as a bulk page, never a fetch echo. -const int kRingConnRespUnsolicited = 0x10; - -/// `0xc7 ^ 0x80` (== `0x47`) — a PPG/optical bulk page. The same tag also -/// answers the [kRingConnCmdAckPpg] "give me the next page" write, since an -/// ACK's reply IS the next page of the same burst. -const int kRingConnRespBulkPpg = 0x47; - -/// `0xcc ^ 0x80` (== `0x4c`) — an activity/sleep bulk page. See -/// [kRingConnRespBulkPpg]. -const int kRingConnRespBulkActivity = 0x4c; - -/// The one reply with NO xor trailer. -const int kRingConnRespStatus = 0x50; - -/// Bytes per record in a [kRingConnRespBulkPpg] page. -const int kRingConnPpgRecordLen = 47; - -/// Bytes per record in a [kRingConnRespBulkActivity] page. -const int kRingConnActivityRecordLen = 23; - -/// One frame off the notify characteristic. -/// -/// `respid = command_byte XOR 0x80` for every reply except -/// [kRingConnRespStatus], which carries no trailer at all — [xorValid] is -/// `true` for that one by construction, since there is nothing to check. -/// [payload] never includes the trailer byte. -class RingConnFrame { - final int respid; - final Uint8List payload; - - /// Whether the trailing byte is the XOR of every byte before it. LENIENT ON - /// PURPOSE: a caller decides what to do with an invalid frame (drop it, - /// still archive the raw bytes) — this file only reports the fact. - final bool xorValid; - - const RingConnFrame(this.respid, this.payload, this.xorValid); -} - -/// Parse one notification. Null when [value] is too short to be a frame at -/// all — one byte cannot carry both a respid and a trailer. -RingConnFrame? parseRingConnFrame(List value) { - if (value.isEmpty) return null; - final respid = value[0]; - if (respid == kRingConnRespStatus) { - return RingConnFrame(respid, Uint8List.fromList(value.sublist(1)), true); - } - if (value.length < 2) return null; - var x = 0; - for (var i = 0; i < value.length - 1; i++) { - x ^= value[i]; - } - final valid = (x & 0xff) == value[value.length - 1]; - return RingConnFrame( - respid, - Uint8List.fromList(value.sublist(1, value.length - 1)), - valid, - ); -} - -/// True for a reply tag that carries a bulk page rather than a status reply. -bool ringConnIsBulk(int respid) => - respid == kRingConnRespBulkPpg || respid == kRingConnRespBulkActivity; - -/// True for a reply that ends the current fetch burst with no page attached. -bool ringConnEndsBurst(int respid) => - respid == kRingConnRespFetchEmpty || - respid == kRingConnRespUnsolicited || - respid == kRingConnRespStatus; - -/// One bulk page: how many records the ring still holds after this page -/// (counting down to 0 on the last page of a burst), and the page's records -/// as raw, unsliced-further bytes. -class RingConnBulkPage { - final int remaining; - final List records; - const RingConnBulkPage(this.remaining, this.records); -} - -/// Slice a bulk-page frame's payload into `(remaining, records)`. Null when -/// [f] is not a bulk-page frame ([ringConnIsBulk] is false), the payload is -/// too short to carry the 2-byte header, or the body does not divide evenly -/// into fixed-size records — a page cut across a record boundary is a framing -/// bug, not a short page to salvage part of. -/// -/// NOTHING INSIDE A RECORD IS READ HERE. Each record is handed back exactly as -/// received; see this file's own header for why. -RingConnBulkPage? parseRingConnBulkPage(RingConnFrame f) { - final recordLen = switch (f.respid) { - kRingConnRespBulkPpg => kRingConnPpgRecordLen, - kRingConnRespBulkActivity => kRingConnActivityRecordLen, - _ => null, - }; - if (recordLen == null) return null; - if (f.payload.length < 2) return null; - final remaining = f.payload[1]; - final body = f.payload.length - 2; - if (body < 0 || body % recordLen != 0) return null; - final count = body ~/ recordLen; - final records = [ - for (var i = 0; i < count; i++) - Uint8List.sublistView( - f.payload, - 2 + i * recordLen, - 2 + (i + 1) * recordLen, - ), - ]; - return RingConnBulkPage(remaining, records); -} - -// ── outbound commands ─────────────────────────────────────────────────────── -// `[cmd][sub][payload...][0x00]` — a literal trailing zero, NOT a checksum. -// Appending an XOR trailer here instead produces bytes the ring silently -// drops; that is the opposite convention from a reply, and confusing the two -// is the one mistake that fails with no error to explain it. - -/// Ask for a status reply. The first write on every connection: the reply -/// carries the authentication challenge. -List ringConnCmdStatus() => const [kRingConnCmdStatus, 0x00, 0x00]; - -/// Answer the challenge with the 3-byte [ringConnAuthResponse]. -List ringConnCmdAuthResponse(List response) { - if (response.length != 3) { - throw ArgumentError('a RingConn auth response is exactly 3 bytes'); - } - return [ - kRingConnCmdStatus, - 0x01, - response[0], - response[1], - response[2], - 0x00, - ]; -} - -/// Open a history drain on [channel] ([kRingConnChannelSleep] or -/// [kRingConnChannelAwake]) at [cursor] — seconds since [kRingConnEpochOffset], -/// NOT a Unix second. -List ringConnCmdSyncOpen(int cursor, int channel) { - final c = cursor & 0xffffffff; - return [ - kRingConnCmdSyncOpen, - 0x00, - (c >>> 24) & 0xff, - (c >>> 16) & 0xff, - (c >>> 8) & 0xff, - c & 0xff, - channel & 0xff, - 0x01, - 0x00, - ]; -} - -/// Ask for the next reply on the channel a [ringConnCmdSyncOpen] opened — -/// a bulk page if one is ready, a burst-ending reply otherwise. -List ringConnCmdFetch() => const [kRingConnCmdFetch, 0x00, 0x00]; - -/// Pull the next page of the PPG/optical burst a [kRingConnRespBulkPpg] page -/// is mid-way through. -List ringConnCmdAckPpg() => const [kRingConnCmdAckPpg, 0x00, 0x00]; - -/// Pull the next page of the activity/sleep burst a [kRingConnRespBulkActivity] -/// page is mid-way through. -List ringConnCmdAckActivity() => const [kRingConnCmdAckActivity, 0x00, 0x00]; diff --git a/lib/src/ultrahuman.dart b/lib/src/ultrahuman.dart index bae1c34..8f85d75 100644 --- a/lib/src/ultrahuman.dart +++ b/lib/src/ultrahuman.dart @@ -8,11 +8,13 @@ // 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. +// number it produces means what its name says: the two HRV bytes and activity +// carry no documented 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 only the per-record HR is +// surfaced (as sparse `hrSparse` samples, outside derivation via +// `kDerivableSources`); HRV, SpO2 and skin temperature travel only as +// vendor-attributed observations. 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 @@ -23,16 +25,20 @@ 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. +/// Request opcodes. Only the ones this file builds a request for. The +/// destructive ones have no builder here and never will: 0x0d, 0x17 (device +/// reset / shipping mode), 0x18, 0x19, 0x60, 0x61, 0x70 (airplane mode), +/// 0x98 (software reset), 0x99, 0xbf, 0xd1-0xd4 (power saving), 0xf0, 0xfb +/// and 0xfc. const int kUltrahumanOpSetTime = 0x02; const int kUltrahumanOpGetRecordings = 0x04; const int kUltrahumanOpGetTime = 0x05; const int kUltrahumanOpGetEarliestIndex = 0x07; const int kUltrahumanOpGetLatestIndex = 0x08; -/// Response result byte. +/// Response result byte. Anything other than ok or empty (busy, invalid +/// command, no time set, a measurement already running, ...) is a failure; +/// [kUltrahumanResultFail] is only the most common one. const int kUltrahumanResultOk = 0x00; const int kUltrahumanResultEmpty = 0xee; const int kUltrahumanResultFail = 0xff; @@ -68,8 +74,9 @@ List ultrahumanCmdSetTime(int unixSeconds) => 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. +/// NOT a byte offset and not a timestamp. One request streams the history +/// from there as several notifications; how many records fit in one depends +/// on the negotiated MTU (7 at 247), so a short frame is not the last one. List ultrahumanCmdGetRecordings(int startIndex) => [kUltrahumanOpGetRecordings, ..._u16le(startIndex)]; @@ -98,34 +105,26 @@ class UltrahumanResponse { bool get ok => result == kUltrahumanResultOk; bool get empty => result == kUltrahumanResultEmpty; + + /// Neither ok nor empty: the command failed, whatever the code says. + bool get failed => !ok && !empty; } /// 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 -/// — 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. +/// `opcode + result + count + trailer` is 5 bytes, the floor with zero payload. +/// +/// [UltrahumanResponse.count] is NOT checked against the payload length. The +/// ring only promises that a `0x04` data frame has an ok result and a +/// non-zero count; the records are however many whole 32-byte records fit +/// between offset 3 and the trailer ([parseUltrahumanRecords] takes the floor). 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( - opcode, - result, - count, + bytes[0], + bytes[1], + bytes[2], Uint8List.sublistView(bytes, 3, 3 + payloadLen), Uint8List.sublistView(bytes, 3 + payloadLen), ); @@ -133,55 +132,94 @@ UltrahumanResponse? parseUltrahumanResponse(List value) { /// 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. +/// LAYOUT (all little-endian): `0` u32 tsA (Unix s, PPG) · `4` hr · `5` HRV +/// (RMSSD) · `6` spo2 · `7` HR quality / ring state · `8` u32 tsB +/// (temperature) · `12` f32 skin (object) temp degC · `16` f32 ambient temp +/// degC · `20` u32 tsC (motion) · `24` u16 activity · `26` u16 steps · `28` +/// HRV (SDNN) · `29` temperature quality · `30` u16 the record's own index. +/// There is no stress field. An adapter archives all 32 bytes verbatim. /// -/// 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 +/// [hr] and [spo2] report 0 for "unmeasured" exactly as the ring's own wire +/// does — transcribed, not reinterpreted into null. [hrv], [hrvSdnn] and +/// [activity] are the ring's own numbers with no published algorithm: +/// vendor values, never an input to our analytics. 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; + + /// RMSSD, ms. final int hrv; final int spo2; - final int measurementType; + + /// Whether this record's optical readings are usable, and what the ring was + /// doing — see [ultrahumanHrQualityValid]. + final int hrQuality; final int tsB; - final double maxSkinTempC; - final double minSkinTempC; + + /// The skin-facing (object) sensor. NaN when the ring had no reading. + final double skinTempC; + + /// The ambient sensor — not skin temperature. + final double ambientTempC; final int tsC; - final int activityLevel; + final int activity; final int steps; - final int stress; + + /// SDNN, ms. Raw byte, no rescaling. + final int hrvSdnn; + + /// Quality of [skinTempC] and [ambientTempC]; 0 means "do not use". + final int tempQuality; + + /// The ring's own record index — the same u16 counter `0x04` fetches by. + final int index; const UltrahumanRecord({ required this.tsA, required this.hr, required this.hrv, required this.spo2, - required this.measurementType, + required this.hrQuality, required this.tsB, - required this.maxSkinTempC, - required this.minSkinTempC, + required this.skinTempC, + required this.ambientTempC, required this.tsC, - required this.activityLevel, + required this.activity, required this.steps, - required this.stress, + required this.hrvSdnn, + required this.tempQuality, + required this.index, }); } -/// Measurement-type byte values documented for [UltrahumanRecord.measurementType]. -const int kUltrahumanMeasureNormal = 1; -const int kUltrahumanMeasureExercise = 5; -const int kUltrahumanMeasureBreathing = 6; -const int kUltrahumanMeasureNotOnFinger = 100; +/// [UltrahumanRecord.hrQuality] values. Only the ones a reader is likely to +/// look for; every value the ring sends is still archived verbatim. +const int kUltrahumanHrQualityContact = 0; +const int kUltrahumanHrQualityLegacy = 1; +const int kUltrahumanHrQualityLiveHr = 2; +const int kUltrahumanHrQualityCdtHr = 5; +const int kUltrahumanHrQualityCdtBreathing = 6; +const int kUltrahumanHrQualityStealth = 7; +const int kUltrahumanHrQualityLowConfidence = 9; +const int kUltrahumanHrQualityNoContact = 10; +const int kUltrahumanHrQualityPassiveSleep = 11; +const int kUltrahumanHrQualityCharging = 100; +const int kUltrahumanHrQualityChargingNoContact = 110; + +/// True when a record with this [UltrahumanRecord.hrQuality] carries usable +/// HR/HRV/SpO2. Everything else (no contact, charging, live-HR placeholder, +/// sensor errors) is not a reading. +bool ultrahumanHrQualityValid(int q) => const { + kUltrahumanHrQualityContact, + kUltrahumanHrQualityLegacy, + kUltrahumanHrQualityCdtHr, + kUltrahumanHrQualityCdtBreathing, + kUltrahumanHrQualityStealth, + kUltrahumanHrQualityLowConfidence, + kUltrahumanHrQualityPassiveSleep, + }.contains(q); /// Decode the record at [offset] in [bytes], or null when /// `offset + 32 > bytes.length` — a truncated record, never guessed at. @@ -194,14 +232,16 @@ UltrahumanRecord? parseUltrahumanRecord(List bytes, int offset) { hr: d.getUint8(4), hrv: d.getUint8(5), spo2: d.getUint8(6), - measurementType: d.getUint8(7), + hrQuality: d.getUint8(7), tsB: d.getUint32(8, Endian.little), - maxSkinTempC: d.getFloat32(12, Endian.little), - minSkinTempC: d.getFloat32(16, Endian.little), + skinTempC: d.getFloat32(12, Endian.little), + ambientTempC: d.getFloat32(16, Endian.little), tsC: d.getUint32(20, Endian.little), - activityLevel: d.getUint16(24, Endian.little), + activity: d.getUint16(24, Endian.little), steps: d.getUint16(26, Endian.little), - stress: d.getUint16(28, Endian.little), + hrvSdnn: d.getUint8(28), + tempQuality: d.getUint8(29), + index: d.getUint16(30, Endian.little), ); } diff --git a/lib/src/wearfit.dart b/lib/src/wearfit.dart deleted file mode 100644 index 3f5bad3..0000000 --- a/lib/src/wearfit.dart +++ /dev/null @@ -1,108 +0,0 @@ -// WearFit wire format — the framing spoken by the Howear family of bands -// (models sold as HK8 Ultra, HK8 Pro Max and similar), paired through the -// "WearFit", "WearFit 2.0" or "WearFit Pro" companion app. Bytes only: no -// BLE, no Flutter, no database. -// -// NOTHING HERE HAS MET HARDWARE. Ships EXPERIMENTAL (ASSUMPTIONS R6): this -// file decodes NO physiological signal from this family's frames — only the -// framing needed to hold a session open, read the band's own battery report, -// and bank whatever else it sends. Nothing here turns bytes into a heart -// rate, a step count or a sleep stage. -// -// TRANSPORT is an otherwise-generic Nordic UART Service link (one write -// characteristic, one notify characteristic). A second, separate service -// UUID is advertised for discovery only and carries no characteristics of -// its own. -// -// FRAME, app<->band, one frame per notification/write, little header: -// [0] header (0xAB) -// [1] reserved — always 0x00 -// [2] length: byte count from [3] to the end of the frame, i.e. -// 2 + payload.length -// [3] 0xFF — fixed marker, not part of the opcode space -// [4] command opcode -// [5..] payload -// There is no CRC and no encryption anywhere in this envelope: a command is -// accepted purely on its opcode, and the length field is the only structure -// this file can check a frame against. - -import 'dart:typed_data'; - -const int kWearFitHeader = 0xAB; - -/// Battery status, opcode [kWearFitOpBattery]. -const int kWearFitOpBattery = 0x91; - -/// One parsed frame. Null for anything too short or missing the fixed -/// marker — see [parseWearFitFrame]. -class WearFitFrame { - final int opcode; - final Uint8List payload; - const WearFitFrame(this.opcode, this.payload); -} - -/// Parse one notification. Null when it cannot be a frame at all: too short, -/// the wrong header, a missing `0xFF` marker, a declared length under 2 (no -/// room for the opcode byte), or a payload longer than the bytes actually -/// delivered. -WearFitFrame? parseWearFitFrame(List value) { - if (value.length < 5 || value[0] != kWearFitHeader || value[3] != 0xFF) { - return null; - } - final len = value[2]; - if (len < 2) return null; - final payloadLen = len - 2; - if (value.length - 5 < payloadLen) return null; - return WearFitFrame( - value[4], - Uint8List.fromList(value.sublist(5, 5 + payloadLen)), - ); -} - -/// Build one outbound frame. -/// -/// [payload] must leave room for the length byte at [2] (`2 + payload.length` -/// fits in one byte): 253 bytes or fewer. -Uint8List buildWearFitFrame(int opcode, [List payload = const []]) { - if (payload.length > 253) { - throw ArgumentError.value(payload.length, 'payload.length', - 'must be 253 or fewer — the frame length byte cannot hold more'); - } - final len = 2 + payload.length; - final out = Uint8List(5 + payload.length); - out[0] = kWearFitHeader; - out[1] = 0x00; - out[2] = len & 0xff; - out[3] = 0xFF; - out[4] = opcode & 0xff; - out.setRange(5, 5 + payload.length, payload); - return out; -} - -/// Ask the band for its current battery status. Documented request shape, -/// not a guess: `[0x80, 0x01]` is the request payload behind the battery -/// reply this file parses below, and asking is a read, not a write of any -/// band state. -Uint8List wearFitCmdGetBattery() => buildWearFitFrame(kWearFitOpBattery, const [0x80, 0x01]); - -/// The band's own battery report. -class WearFitBattery { - /// 0 = not charging, 1 = charging, 2 = fully charged. - final int chargeState; - - /// 0-100. - final int percent; - - const WearFitBattery(this.chargeState, this.percent); -} - -/// Parse [f] as a battery reply, or null when it is not one. This is device -/// housekeeping, not a physiological reading — nothing about this decode is -/// gated by the family's EXPERIMENTAL status. -WearFitBattery? parseWearFitBattery(WearFitFrame f) { - if (f.opcode != kWearFitOpBattery || f.payload.length < 3) return null; - final state = f.payload[1]; - final pct = f.payload[2]; - if (state > 2 || pct > 100) return null; - return WearFitBattery(state, pct); -} diff --git a/lib/src/zetime.dart b/lib/src/zetime.dart deleted file mode 100644 index af01efa..0000000 --- a/lib/src/zetime.dart +++ /dev/null @@ -1,95 +0,0 @@ -// MyKronoz ZeTime's command envelope — plain functions, no crypto, no key -// exchange. One write characteristic elicits a reply on a separate notify -// characteristic; there is no bonding requirement and no encrypted payload. -// -// NOTHING HERE HAS MET HARDWARE. Nobody on this project owns one and -// `flutter_blue_plus` has no simulator path, so this is verified by the wire -// layout and by the compiler. -// -// FRAMING AND TWO DEVICE FACTS ONLY. This file parses the envelope and the -// one reply worth surfacing as a vendor fact today: battery level. Step -// count, sleep and heart-rate history are commands this protocol supports and -// this file deliberately does not touch, request, or decode: they are the -// health signals this band has not been hardware-verified for, and a decoder -// for them is not something to guess at from a spec alone. - -/// First byte of every frame. -const int kZeTimePreamble = 0x6f; - -/// Last byte of every frame. -const int kZeTimeEnd = 0x8f; - -/// Action byte (third position): a host-to-device question. -const int kZeTimeActionRequest = 0x70; - -/// The one byte written to the ack characteristic after every write to the -/// command characteristic — a fixed acknowledgement token, not a per-command -/// value. -const int kZeTimeAckToken = 0x03; - -/// Command byte for a battery-level request/reply. -const int kZeTimeCmdBattery = 0x08; - -/// One decoded frame off the wire: -/// `[0x6f][cmd][action][lenLo][lenHi]…payload…[0x8f]`, where the declared -/// length counts the payload bytes only — it equals `payload.length` exactly -/// and does not count the trailing `[0x8f]`. -class ZeTimeFrame { - final int cmd; - final int action; - final List payload; - const ZeTimeFrame({ - required this.cmd, - required this.action, - required this.payload, - }); -} - -/// Build a request frame for [cmd]: preamble, command, REQUEST action, and -/// the one-byte declared length the device's own request frames always send -/// (`[0x01, 0x00]`) even though the byte it counts is unused — transcribed -/// rather than guessed at, because a zero-length declaration is a different, -/// untested frame shape. -List zetimeRequestFrame(int cmd) => [ - kZeTimePreamble, - cmd, - kZeTimeActionRequest, - 0x01, - 0x00, - 0x00, - kZeTimeEnd, - ]; - -/// Parse one notify-characteristic value as a complete frame. Null when it is -/// too short, does not start with the preamble, declares a zero-length -/// payload (the device never sends one), its declared length does not match -/// the bytes actually delivered, or it does not end on [kZeTimeEnd] — a -/// malformed or still-fragmented notification is dropped rather than guessed -/// at. A payload that splits across two BLE notifications (the device's own -/// behaviour once the payload exceeds 14 bytes) is not reassembled here: every -/// command this file builds declares a payload well under that, so nothing in -/// this file ever exercises that path. -ZeTimeFrame? parseZeTimeFrame(List value) { - if (value.length < 7) return null; - if (value[0] != kZeTimePreamble) return null; - final payloadSize = value[3] | (value[4] << 8); - if (payloadSize == 0) return null; - final msgLength = payloadSize + 6; - if (msgLength != value.length) return null; - if (value[msgLength - 1] != kZeTimeEnd) return null; - return ZeTimeFrame( - cmd: value[1], - action: value[2], - payload: value.sublist(5, msgLength - 1), - ); -} - -/// Battery level, 0-100, from a battery-command reply. Null when [f] is not a -/// battery reply, carries no level byte, or the byte is outside 0-100 — a -/// single-byte percentage cannot legitimately read above 100, and a value -/// that does is a misidentified reply, not a real reading. -int? zetimeBatteryLevel(ZeTimeFrame f) { - if (f.cmd != kZeTimeCmdBattery || f.payload.isEmpty) return null; - final level = f.payload[0]; - return level <= 100 ? level : null; -} diff --git a/test/colmi_test.dart b/test/colmi_test.dart new file mode 100644 index 0000000..e41b56c --- /dev/null +++ b/test/colmi_test.dart @@ -0,0 +1,287 @@ +import 'package:openstrap_protocol/openstrap_protocol.dart'; +import 'package:test/test.dart'; + +/// A Service A reply frame: [cmd, ...body] zero-padded, checksum appended. +List frame(int cmd, List body) => colmiFrame(cmd, body); + +/// A Service B reply around [payload], with a correct CRC. +List big(int type, List payload) => + colmiBigDataRequest(type, payload); + +void main() { + group('framing', () { + test('16 bytes, checksum is the low byte of the sum of 0-14', () { + final f = colmiFrame(0x03); + expect(f, hasLength(16)); + expect(f[15], 0x03); + expect(colmiFrameValid(f), isTrue); + expect(colmiFrameValid([...f.sublist(0, 15), 0x04]), isFalse); + }); + + test('payload over 14 bytes is refused', () { + expect(() => colmiFrame(1, List.filled(15, 0)), throwsArgumentError); + }); + + test('set time is BCD local wall clock', () { + final f = colmiSetTimeRequest(DateTime(2026, 10, 4, 21, 7, 59)); + expect(f.sublist(0, 7), [0x01, 0x26, 0x10, 0x04, 0x21, 0x07, 0x59]); + // Language byte after the clock: English, not the zero default. + expect(f[7], 1); + }); + + test('SetTime reply byte 9 says which sleep protocol the ring speaks', () { + final reply = List.filled(14, 0); + expect(colmiNewSleepProtocol(frame(0x01, reply)), isFalse); + reply[8] = 1; + expect(colmiNewSleepProtocol(frame(0x01, reply)), isTrue); + expect(colmiNewSleepProtocol(frame(0x03, reply)), isNull); + }); + + test('reboot and factory reset are flagged destructive', () { + expect(colmiIsDestructive(colmiFrame(0x08)), isTrue); + expect(colmiIsDestructive(colmiFrame(0xff)), isTrue); + expect(colmiIsDestructive(colmiBatteryRequest()), isFalse); + }); + + test('history requests', () { + expect(colmiActivityRequest(3).sublist(0, 6), + [0x43, 3, 0x0f, 0x00, 0x5f, 0x01]); + expect(colmiHrHistoryRequest(0x01020304).sublist(0, 5), + [0x15, 0x04, 0x03, 0x02, 0x01]); + expect(colmiHrvRequest(2).sublist(0, 5), [0x39, 2, 0, 0, 0]); + expect(colmiStressRequest(3).sublist(0, 2), [0x37, 3]); + expect(colmiSleepDetailsRequest(2).sublist(0, 5), + [0x44, 2, 0x0f, 0x00, 0x5f]); + }); + + test('big-data requests carry CRC-16/MODBUS of the payload', () { + final crcSleep = crc16Modbus([0xff, 0x01]); + expect(colmiSleepRequest(), [ + 0xbc, 0x27, 0x02, 0x00, crcSleep & 0xff, crcSleep >> 8, 0xff, 0x01, // + ]); + expect(colmiSpo2Request(), [0xbc, 0x2a, 0x01, 0x00, 0xff, 0x00, 0xff]); + // Full sync asks for every stored day (6). + expect(colmiTemperatureRequest().sublist(0, 4), [0xbc, 0x25, 0x01, 0x00]); + expect(colmiTemperatureRequest().last, 6); + expect(colmiTemperatureRequest(0).last, 0); + }); + }); + + group('reassembly', () { + test('a reply split over three notifications comes back whole', () { + final whole = big(kColmiBigSleep, List.generate(40, (i) => i)); + final r = ColmiBigDataReassembler(); + expect(r.add(whole.sublist(0, 20)), isNull); + expect(r.pending, isTrue); + expect(r.add(whole.sublist(20, 39)), isNull); + expect(r.add(whole.sublist(39)), whole); + expect(r.pending, isFalse); + expect(colmiBigDataCrcOk(whole), isTrue); + }); + + test('a non-0xbc chunk with nothing pending is dropped', () { + expect(ColmiBigDataReassembler().add([0x01, 2, 3, 4, 5, 6, 7]), isNull); + }); + + test('a corrupted payload fails the CRC', () { + final whole = big(kColmiBigSpo2, [1, 2, 3]); + whole[7] ^= 0xff; + expect(colmiBigDataCrcOk(whole), isFalse); + }); + }); + + group('service A history', () { + test('battery', () { + expect(colmiBatteryPct(frame(0x03, [87, 0])), 87); + expect(colmiBatteryPct(frame(0x03, [187])), isNull); + }); + + test('HR: page 1 after a 4-byte timestamp, later pages from byte 2', () { + expect(colmiHrHistoryPoints(frame(0x15, [0x00, 24])), isEmpty); + expect(colmiHrHistoryPoints(frame(0x15, [0xff])), isEmpty); + final p1 = colmiHrHistoryPoints( + frame(0x15, [1, 0xaa, 0xbb, 0xcc, 0xdd, 60, 0, 62])); + expect(p1.map((p) => (p.minuteOfDay, p.value)), [(0, 60), (10, 62)]); + // Page 2 starts after page 1's 9 slots = 45 min. + final p2 = colmiHrHistoryPoints(frame(0x15, [2, 70])); + expect(p2.single.minuteOfDay, 45); + // Page 3 starts after 9 + 13 slots = 110 min. + expect( + colmiHrHistoryPoints(frame(0x15, [3, 71])).single.minuteOfDay, 110); + }); + + test('stress and HRV: 30-min slots, 12 on page 1, 13 after', () { + final s1 = colmiStressPoints(frame(0x37, [1, 0x99, 40, 41])); + expect(s1.map((p) => (p.minuteOfDay, p.value)), [(0, 40), (30, 41)]); + expect(colmiStressPoints(frame(0x37, [2, 50])).single.minuteOfDay, 360); + expect(colmiHrvPoints(frame(0x39, [4, 33])).single.minuteOfDay, + (12 + 13 * 2) * 30); + }); + + test('page 0 carries the page count and the slot interval', () { + final h = colmiPagedHeader(frame(0x15, [0, 25, 10]), 0x15)!; + expect((h.pages, h.slotMinutes), (25, 10)); + expect(colmiPagedHeader(frame(0x15, [1, 25, 10]), 0x15), isNull); + // A 10-minute HR interval: page 2 starts after 9 slots = 90 min. + expect( + colmiHrHistoryPoints(frame(0x15, [2, 70]), slotMinutes: 10) + .single + .minuteOfDay, + 90); + expect( + colmiStressPoints(frame(0x37, [2, 50]), slotMinutes: 15) + .single + .minuteOfDay, + 180); + }); + + test('page 1 says which day the walk is for', () { + expect( + colmiHrPageTimestamp(frame(0x15, [1, 0x04, 0x03, 0x02, 0x01, 60])), + 0x01020304); + expect(colmiHrPageTimestamp(frame(0x15, [1, 0, 0, 0, 0, 60])), isNull); + expect(colmiPagedDaysAgo(frame(0x37, [1, 3, 40])), 3); + expect(colmiPagedDaysAgo(frame(0x39, [1, 2, 40])), 2); + expect(colmiPagedDaysAgo(frame(0x39, [2, 2, 40])), isNull); + }); + + test('points past midnight are never emitted', () { + // Page 4 of a 30-min walk ends at slot 50 > 48. + final p = colmiStressPoints(frame(0x37, [4, ...List.filled(13, 9)])); + expect(p.every((x) => x.minuteOfDay < 1440), isTrue); + expect(p, hasLength(10)); + }); + + test('a frame with a bad checksum decodes to nothing', () { + final f = frame(0x15, [1, 0, 0, 0, 0, 60]); + f[15] ^= 1; + expect(colmiHrHistoryPoints(f), isEmpty); + }); + + test('activity: BCD date, quarter-hour slot, little-endian counters', () { + final s = colmiActivitySlot(frame(0x43, [ + 0x26, 0x10, 0x04, 37, 1, 4, // + 0x10, 0x00, 0x34, 0x12, 0x20, 0x00, + ]))!; + expect((s.year, s.month, s.day), (2026, 10, 4)); + expect(s.minuteOfDay, 37 * 15); + expect(s.steps, 0x1234); + expect(s.calories, 0x10); + expect(s.distance, 0x20); + expect(s.isLast, isFalse); + expect(colmiActivitySlot(frame(0x43, [0xf0])), isNull); + expect(colmiActivityDone(frame(0x43, [0xff])), isTrue); + // The header's byte 3 == 1 means calories are in tenths. + expect(colmiActivityCaloriesX10(frame(0x43, [0xf0, 5, 1])), isTrue); + expect(colmiActivityCaloriesX10(frame(0x43, [0xf0, 5, 0])), isFalse); + expect(colmiActivityCaloriesX10(frame(0x43, [0x26])), isNull); + final x10 = colmiActivitySlot( + frame(0x43, [0x26, 0x10, 0x04, 37, 1, 4, 0x10, 0x00]), + caloriesX10: true)!; + expect(x10.calories, 160); + expect( + colmiActivityDone(frame(0x43, [0x26, 0x10, 0x04, 37, 3, 4])), isTrue); + }); + }); + + test('sleep detail rows: BCD date, slot, seven quality bytes', () { + final f = frame(0x44, [0x26, 0x10, 0x04, 88, 2, 3, 1, 2, 3, 4, 5, 6, 7]); + final r = colmiSleepDetailRow(f)!; + expect((r.year, r.month, r.day, r.timeIndex), (2026, 10, 4, 88)); + expect(r.qualities, [1, 2, 3, 4, 5, 6, 7]); + expect(colmiSleepDetailsDone(f), isTrue); + expect( + colmiSleepDetailsDone( + frame(0x44, [0x26, 0x10, 0x04, 88, 1, 3, 1, 2, 3, 4, 5, 6, 7])), + isFalse); + expect(colmiSleepDetailRow(frame(0x44, [0xf0])), isNull); + expect(colmiSleepDetailsDone(frame(0x44, [0xff])), isTrue); + }); + + group('service B history', () { + test('sleep: two nights, one starting the evening before', () { + final r = big(kColmiBigSleep, [ + 2, // nights + 1, 8, 0x8e, 0x05, 0x6e, 0x01, kColmiStageLight, 120, kColmiStageDeep, + 0, // night 1: 23:10 -> 06:06, 120 light, 0-min block dropped + 0, 10, 0x1e, 0x00, 0xb4, 0x00, kColmiStageRem, 30, kColmiStageAwake, + 5, kColmiStageLight, 115, // night 2: 00:30 -> 03:00 + ]); + final n = colmiSleepNights(r); + expect(n, hasLength(2)); + expect(n[0].daysAgo, 1); + expect(n[0].startMinute, 1422); + expect(n[0].endMinute, 366); + expect(n[0].startsDayBefore, isTrue); + expect(n[0].blocks.map((b) => (b.stage, b.minutes)), + [(kColmiStageLight, 120)]); + expect(n[1].startsDayBefore, isFalse); + expect(n[1].blocks, hasLength(3)); + }); + + test('sleep: a truncated night is dropped, not guessed', () { + final r = big(kColmiBigSleep, [1, 0, 20, 0, 0, 10, 0, 2, 30]); + expect(colmiSleepNights(r), isEmpty); + }); + + test('sleep: naps decode from their own reply type, tagged', () { + final r = big(kColmiBigNap, [ + 1, 0, 8, 0xd0, 0x02, 0x0c, 0x03, kColmiStageLight, 40, 0, 20, + ]); + final n = colmiSleepNights(r).single; + expect(n.nap, isTrue); + expect(n.startMinute, 720); + expect(n.totalMinutes, 60); + expect(colmiSleepNights(big(kColmiBigSleep, [ + 1, 0, 6, 0, 0, 0x68, 0x01, kColmiStageDeep, 30, + ])).single.nap, isFalse); + }); + + test('SpO2: 49-byte day blocks of 24 hourly (max, min), any order', () { + List day(int ago) => [ + ago, + for (var h = 0; h < 24; h++) ...(h == 3 ? [98, 95] : [0, 0]), + ]; + // Today first: nothing after it may be dropped. + final hours = colmiSpo2Hours(big(kColmiBigSpo2, [...day(0), ...day(1)])); + expect(hours.map((h) => (h.daysAgo, h.hour, h.min, h.max)), + [(0, 3, 95, 98), (1, 3, 95, 98)]); + // A partial trailing block is dropped, not half-read. + expect( + colmiSpo2Hours(big(kColmiBigSpo2, [...day(1), 0, 99, 97])), + hasLength(1)); + }); + + test('temperature: one day per reply, slots of timeSpan minutes', () { + final t = colmiTemperatures(big(kColmiBigTemperature, [ + 2, 30, // + for (var k = 0; k < 48; k++) k == 4 ? 150 : (k == 5 ? 160 : 0), + ])); + expect(t.map((x) => (x.daysAgo, x.minuteOfDay, x.celsius)), + [(2, 120, 35.0), (2, 150, 36.0)]); + }); + + test('temperature: a 60-minute day of 24 slots is read, not dropped', () { + final t = colmiTemperatures(big(kColmiBigTemperature, [ + 0, 60, for (var k = 0; k < 24; k++) k == 23 ? 140 : 0, + ])); + expect(t.single.minuteOfDay, 23 * 60); + expect(t.single.celsius, closeTo(34.0, 1e-9)); + }); + + test('temperature: a partial day yields the slots it has', () { + final t = colmiTemperatures(big(kColmiBigTemperature, [0, 30, 0, 150])); + expect(t.single.minuteOfDay, 30); + expect(colmiTemperatures(big(kColmiBigTemperature, [0, 30])), isEmpty); + }); + + test('temperature: the run-length +32 encoding', () { + final t = colmiTemperatures( + big(kColmiBigTemperature, [1, 30, 0x80 + 10, 45, 0x80 + 2, 50]), + encoding: ColmiTempEncoding.runLengthPlus32); + expect(t.map((x) => x.minuteOfDay), [300, 390]); + expect(t[0].celsius, closeTo(36.5, 1e-9)); + expect(t[1].celsius, closeTo(37.0, 1e-9)); + }); + }); +} diff --git a/test/dafit_test.dart b/test/dafit_test.dart deleted file mode 100644 index ada4fef..0000000 --- a/test/dafit_test.dart +++ /dev/null @@ -1,111 +0,0 @@ -import 'package:openstrap_protocol/openstrap_protocol.dart'; -import 'package:test/test.dart'; - -void main() { - group('DaFit / MOYOUNG-V2 framing', () { - test('parses a real captured SET_DATE_TIME data frame', () { - // 0xCD 0x00 0x09 0x12 0x01 0x01 0x00 0x04 0xA5 0x83 0x73 0xDB - final f = parseDafitFrame( - [0xcd, 0x00, 0x09, 0x12, 0x01, 0x01, 0x00, 0x04, 0xa5, 0x83, 0x73, 0xdb]); - expect(f, isNotNull); - expect(f!.group, 0x12); - expect(f.command, 0x01); - expect(f.outerLen, 0x09); - expect(f.payload, [0xa5, 0x83, 0x73, 0xdb]); - }); - - test('rejects an ACK-headed frame as a data frame', () { - // dc 00 05 1a 01 00 0c 01 - expect(parseDafitFrame([0xdc, 0x00, 0x05, 0x1a, 0x01, 0x00, 0x0c, 0x01]), - isNull); - }); - - test('rejects a truncated payload', () { - expect(parseDafitFrame([0xcd, 0x00, 0x09, 0x12, 0x01, 0x01, 0x00, 0x04, 0xa5]), - isNull); - }); - - test('rejects an outer length that disagrees with the payload length', - () { - // Same bytes as the real captured frame above, but the outer length - // (byte 2) claims 8 instead of the correct 9 — a malformed frame, not - // a shorter one, since the payload bytes and their own length field - // still say 4. - expect( - parseDafitFrame( - [0xcd, 0x00, 0x08, 0x12, 0x01, 0x01, 0x00, 0x04, 0xa5, 0x83, 0x73, 0xdb]), - isNull); - }); - - test('isDafitAckFrame identifies only the fixed 8-byte ack shape', () { - expect(isDafitAckFrame([0xdc, 0x00, 0x05, 0x1a, 0x01, 0x00, 0x0c, 0x01]), - isTrue); - expect( - isDafitAckFrame( - [0xcd, 0x00, 0x09, 0x12, 0x01, 0x01, 0x00, 0x04, 0xa5, 0x83, 0x73, 0xdb]), - isFalse); - }); - - test('isDafitAckFrame rejects an 8-byte 0xDC packet with the wrong fixed ' - 'bytes', () { - // Right header and length, but the trailing fixed byte is not the 0x01 - // this shape always carries. - expect(isDafitAckFrame([0xdc, 0x00, 0x05, 0x1a, 0x01, 0x00, 0x0c, 0x00]), - isFalse); - }); - - test('buildDafitFrame round-trips through parseDafitFrame', () { - final built = buildDafitFrame(0x12, 0x0a, const [0x02]); - expect(built, [0xcd, 0x00, 0x06, 0x12, 0x01, 0x0a, 0x00, 0x01, 0x02]); - final parsed = parseDafitFrame(built); - expect(parsed!.group, 0x12); - expect(parsed.command, 0x0a); - expect(parsed.payload, [0x02]); - }); - - test('buildDafitFrame with no payload', () { - final built = buildDafitFrame(0x1a, 0x0a); - expect(built, [0xcd, 0x00, 0x05, 0x1a, 0x01, 0x0a, 0x00, 0x00]); - }); - - test('buildDafitAck echoes the acked frame\'s own outer length', () { - final acked = parseDafitFrame([0xcd, 0x00, 0x05, 0x1a, 0x01, 0x0a, 0x00, 0x00])!; - final ack = buildDafitAck(acked); - expect(ack, [0xdc, 0x00, 0x05, 0x1a, 0x01, 0x00, 0x08, 0x01]); - }); - - test('packDafitDateTime packs fields into the documented bit ranges', () { - final t = DateTime(2024, 3, 5, 14, 22, 37); - final v = packDafitDateTime(t); - expect(v & 0x3f, 37); // seconds, bits 0-5 - expect((v >> 6) & 0x3f, 22); // minute - expect((v >> 12) & 0x1f, 14); // hour - expect((v >> 17) & 0x1f, 5); // day - expect((v >> 22) & 0xf, 3); // month - expect((v >> 26) & 0x3f, 24); // year - 2000 - }); - - test('packDafitDateTime refuses a year outside 2000-2063', () { - expect(() => packDafitDateTime(DateTime(1999, 1, 1)), - throwsArgumentError); - expect(() => packDafitDateTime(DateTime(2064, 1, 1)), - throwsArgumentError); - // The boundary itself is fine. - expect(() => packDafitDateTime(DateTime(2063, 1, 1)), returnsNormally); - }); - - test('dafitInitSequence is eight well-formed, parseable frames', () { - final seq = dafitInitSequence(DateTime(2024, 3, 5, 14, 22, 37)); - expect(seq, hasLength(8)); - for (final frame in seq) { - expect(frame[0], kDafitDataHeader); - expect(parseDafitFrame(frame), isNotNull); - } - // The clock write really carries the packed value from above. - final clockFrame = parseDafitFrame(seq[1])!; - expect(clockFrame.group, kDafitGroupGeneral); - expect(clockFrame.command, kDafitCmdSetDateTime); - expect(clockFrame.payload.length, 4); - }); - }); -} diff --git a/test/garmin_fit_test.dart b/test/garmin_fit_test.dart new file mode 100644 index 0000000..e802628 --- /dev/null +++ b/test/garmin_fit_test.dart @@ -0,0 +1,115 @@ +import 'dart:typed_data'; + +import 'package:openstrap_protocol/src/garmin_fit.dart'; +import 'package:test/test.dart'; + +/// A tiny FIT writer for tests: definitions + data records, little-endian. +class FitWriter { + final _recs = []; + + void define(int local, int global, List<(int field, int size, int base)> f, + {bool bigEndian = false}) { + _recs.addAll([0x40 | local, 0, bigEndian ? 1 : 0]); + _recs.addAll(bigEndian ? [global >> 8, global & 0xff] : [global & 0xff, global >> 8]); + _recs.add(f.length); + for (final (n, s, b) in f) { + _recs.addAll([n, s, b]); + } + } + + void data(int local, List bytes) => _recs.addAll([local, ...bytes]); + void compressed(int local, int offset, List bytes) => + _recs.addAll([0x80 | (local << 5) | (offset & 0x1f), ...bytes]); + + List build() { + final n = _recs.length; + return [ + 12, 0x20, 0x08, 0x08, n & 0xff, (n >> 8) & 0xff, 0, 0, + ...'.FIT'.codeUnits, ..._recs, 0, 0, + ]; + } +} + +List u16(int v) => [v & 0xff, v >> 8]; +List u32(int v) => [v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, v >> 24]; +int fitTs(int unix) => unix - kFitEpochOffset; + +void main() { + final t0 = DateTime(2026, 10, 4, 8).millisecondsSinceEpoch ~/ 1000; + + test('rejects what is not FIT', () { + expect(() => parseFit([1, 2, 3]), throwsFormatException); + expect(() => parseFit([12, 0, 0, 0, 99, 0, 0, 0, ...'.FIT'.codeUnits]), + throwsFormatException); + }); + + test('monitoring: full timestamp, then timestamp_16, HR and steps', () { + final w = FitWriter() + ..define(0, kFitMsgMonitoring, [(253, 4, 0x86), (5, 1, 0), (3, 4, 0x86), (27, 1, 2)]) + ..data(0, [...u32(fitTs(t0)), 6, ...u32(100), 62]) + ..define(1, kFitMsgMonitoring, [(26, 2, 0x84), (5, 1, 0), (3, 4, 0x86), (27, 1, 2)]) + ..data(1, [...u16((fitTs(t0) + 60) & 0xffff), 6, ...u32(250), 70]) + ..data(1, [...u16((fitTs(t0) + 120) & 0xffff), 1, ...u32(40), 0xff]); + final m = parseFit(w.build()); + expect(fitMonitoringHr(m), [(t0, 62), (t0 + 60, 70)]); + expect(fitDailySteps(m), {DateTime(2026, 10, 4): 290}); + }); + + test('steps: activity type falls back to the low bits of field 24', () { + final w = FitWriter() + ..define(0, kFitMsgMonitoring, [(253, 4, 0x86), (3, 4, 0x86), (24, 1, 0x0d)]) + ..data(0, [...u32(fitTs(t0)), ...u32(321), 0x66]); + expect(fitDailySteps(parseFit(w.build())), {DateTime(2026, 10, 4): 321}); + }); + + test('compressed timestamps roll over every 32 s', () { + final base = fitTs(t0) & ~0x1f; // aligned + final w = FitWriter() + ..define(0, kFitMsgMonitoring, [(253, 4, 0x86), (27, 1, 2)]) + ..data(0, [...u32(base + 30), 60]) + ..define(1, kFitMsgMonitoring, [(27, 1, 2)]) + ..compressed(1, 2, [61]); // offset 2 < 30 -> next 32-s window + final hr = fitMonitoringHr(parseFit(w.build())); + expect(hr.last.$1, base + 34 + kFitEpochOffset); + }); + + test('sleep levels become stage spans; invalid values are absent', () { + final w = FitWriter()..define(0, kFitMsgSleepLevel, [(253, 4, 0x86), (0, 1, 0)]); + for (final (min, level) in [(0, 2), (30, 3), (90, 4), (120, 1), (125, 0xff)]) { + w.data(0, [...u32(fitTs(t0) + min * 60), level]); + } + expect(fitSleepStages(parseFit(w.build())), [ + (t0, t0 + 1800, 'light'), + (t0 + 1800, t0 + 5400, 'deep'), + (t0 + 5400, t0 + 7200, 'rem'), + (t0 + 7200, t0 + 7500, 'wake'), + ]); + }); + + test('HRV summary, resting HR, SpO2, respiration, big-endian definitions', + () { + final w = FitWriter() + ..define(0, kFitMsgHrvStatusSummary, [(253, 4, 0x86), (1, 2, 0x84)]) + ..data(0, [...u32(fitTs(t0)), ...u16(48 * 128)]) + ..define(1, kFitMsgMonitoringHrData, [(253, 4, 0x86), (1, 1, 2)]) + ..data(1, [...u32(fitTs(t0)), 51]) + ..define(2, kFitMsgSpo2Data, [(253, 4, 0x86), (0, 1, 2)], bigEndian: true) + ..data(2, [...u32(fitTs(t0)).reversed, 96]) + ..define(3, kFitMsgRespirationRate, [(253, 4, 0x86), (0, 2, 0x83)]) + ..data(3, [...u32(fitTs(t0)), ...u16(1350)]); + final m = parseFit(w.build()); + expect(fitHrvLastNight(m), [(t0, 48.0)]); + expect(fitRestingHr(m), [(t0, 51)]); + expect(fitSpo2(m), [(t0, 96)]); + expect(fitRespiration(m), [(t0, 13.5)]); + }); + + test('a record that overruns the data is refused, not half-read', () { + final good = (FitWriter() + ..define(0, kFitMsgMonitoring, [(253, 4, 0x86)]) + ..data(0, u32(1))) + .build(); + final bad = Uint8List.fromList(good)..[4] = good[4] - 2; + expect(() => parseFit(bad), throwsFormatException); + }); +} diff --git a/test/garmin_test.dart b/test/garmin_test.dart index 79d6997..eca60ff 100644 --- a/test/garmin_test.dart +++ b/test/garmin_test.dart @@ -38,6 +38,21 @@ void main() { expect(out.single, orderedEquals(data)); }); + test('reassembler skips stray delimiters between frames', () { + final a = garminCobsEncode([1, 2, 3]); + final b = garminCobsEncode([4, 0, 5]); + for (final stray in [ + [0], + [0, 0, 0], + ]) { + expect(GarminCobsReassembler().feed([...a, ...stray, ...b]), + [orderedEquals([1, 2, 3]), orderedEquals([4, 0, 5])]); + final r = GarminCobsReassembler(); + expect(r.feed([...a, ...stray]), [orderedEquals([1, 2, 3])]); + expect(r.feed(b), [orderedEquals([4, 0, 5])]); + } + }); + test('reassembler recovers two frames coalesced into one delivery', () { final a = garminCobsEncode([1, 2]); final b = garminCobsEncode([3, 4, 5]); @@ -87,6 +102,52 @@ void main() { expect(status, isNotNull); expect(status!.refMsgType, 5024); expect(status.ok, isTrue); + final unknown = garminParseGfdiFrame( + garminBuildStatusAck(5099, status: kGarminStatusUnknown))!; + expect(garminParseStatusAck(unknown)!.status, 2); + }); + + test('compact-form frames reach every parser', () { + int compact(int type, int txn) => 0x8000 | (txn << 8) | (type - 5000); + GarminGfdiFrame f(int type, List payload) => + garminParseGfdiFrame(garminBuildGfdiFrame(compact(type, 3), payload))!; + + final time = garminBuildTimeResponse( + f(kGarminMsgCurrentTimeRequest, [1, 0, 0, 0]), + nowUnixSeconds: 1735689600, + utcOffsetSeconds: 0)!; + expect(time.sublist(2, 4), [0x00, 0x83], + reason: 'the transaction id is echoed'); + expect( + garminParseDeviceInformation( + f(kGarminMsgDeviceInformation, List.filled(12, 0))), + isNotNull); + expect(garminParseSystemEvent(f(kGarminMsgSystemEvent, [8, 0])), (8, 0)); + final pb = garminParseGfdiFrame( + garminBuildProtobufRequest(requestId: 1, protoBytes: const []))!; + final pf = garminParseProtobufFrame(f(kGarminMsgProtobufResponse, pb.payload)); + expect(pf?.messageType, kGarminMsgProtobufResponse); + expect(garminParseStatusAck(f(kGarminMsgResponse, [0xa0, 0x13, 0])), + isNotNull); + }); + + test('protobuf ack carries request id, offset and chunk status', () { + final req = garminParseGfdiFrame(garminBuildGfdiFrame( + kGarminMsgProtobufResponse, + garminParseGfdiFrame(garminBuildProtobufRequest( + requestId: 0x0102, protoBytes: const [1]))! + .payload))!; + final ack = garminParseGfdiFrame( + garminBuildProtobufAck(req, garminParseProtobufFrame(req)!))!; + expect(ack.type, kGarminMsgResponse); + expect(ack.payload, [0xb4, 0x13, 0, 0x02, 0x01, 0, 0, 0, 0, 0, 0]); + }); + + test('system event frame', () { + final f = garminParseGfdiFrame( + garminBuildSystemEvent(kGarminEventHandshakeComplete))!; + expect(f.type, kGarminMsgSystemEvent); + expect(f.payload, [8, 0]); }); test('time response carries the Garmin-epoch timestamp and UTC offset', @@ -118,16 +179,14 @@ void main() { }); group('MLR', () { - test('close-all and register-ml requests are 12 bytes', () { - expect(garminCloseAllRequest(), hasLength(12)); - expect(garminRegisterMlRequest(kGarminServiceGfdi), hasLength(12)); + test('close-all is 12 bytes on the wire, register-ml 13', () { + expect(garminEncodeTx(0, garminCloseAllRequest()), hasLength(12)); + expect(garminEncodeTx(0, garminRegisterMlRequest(kGarminServiceGfdi)), + hasLength(13)); }); - test('a flagged data frame reports its handle', () { - final decoded = garminDecodeMlr([0x80 | (2 << 4), 1, 2, 3]); - expect(decoded, isA()); - expect((decoded as GarminMlrData).handle, 2); - expect(decoded.payload, [1, 2, 3], reason: 'routing byte 0 must be stripped'); + test('an MLR (bit 7) frame is not decoded as COBS data', () { + expect(garminDecodeMlr([0x80 | (2 << 4), 1, 2, 3]), isNull); }); test('a bare non-zero handle byte is a data frame on that handle', () { @@ -152,7 +211,40 @@ void main() { }); test('CLOSE_ALL_RESP decodes to the close-all ack', () { - expect(garminDecodeMlr(const [0x00, 0x06]), isA()); + expect(garminDecodeMlr(_control(0x06, [0, 0, 0])), + isA()); + expect(garminDecodeMlr(const [0x00, 0x06]), isNull, + reason: 'every control frame is at least 13 bytes'); + }); + + test("another client's answers are not ours", () { + expect(garminDecodeMlr(_control(0x06, [0, 0, 0], clientId: 9)), + isA()); + final reg = _registerMlResp( + serviceCode: kGarminServiceGfdi, status: 0, handle: 3) + ..[2] = 9; + expect(garminDecodeMlr(reg), isA()); + }); + + test('HANDLE_CLOSED and INVALID_HANDLE name the handle that is gone', () { + final closed = garminDecodeMlr(_control(0x03, [0, 0, 5, 0])); + expect((closed as GarminHandleClosed).handle, 5); + expect(garminDecodeMlr(_control(0x03, [0, 0, 5, 0], clientId: 9)), + isA()); + final invalid = garminDecodeMlr(_control(0x04, [0, 0, 6], clientId: 9)); + expect((invalid as GarminHandleClosed).handle, 6); + }); + + test('a 13-byte refusal decodes; ALREADY_IN_USE names another char', () { + final refused = + garminDecodeMlr(_control(0x01, [kGarminServiceGfdi, 0, 1])); + expect((refused as GarminRegisterMlResponse).status, 1); + final pending = garminDecodeMlr( + _control(0x01, [kGarminServiceGfdi, 0, kGarminRegisterPendingAuth])); + expect((pending as GarminRegisterMlResponse).accepted, isFalse); + final moved = garminDecodeMlr(_control(0x01, + [kGarminServiceGfdi, 0, kGarminRegisterAlreadyInUse, 0x11, 0x28])); + expect((moved as GarminRegisterMlResponse).alternateChar, 0x2811); }); test('REGISTER_ML_RESP decodes service, status and handle', () { @@ -243,6 +335,16 @@ List _u32(int v) => (ByteData(4)..setUint32(0, v, Endian.little)) .buffer .asUint8List(); +/// A control frame `0 | type | client id | rest`, our client id by default. +List _control(int type, List rest, {int clientId = 2}) => [ + 0x00, + type, + ...(ByteData(8)..setInt64(0, clientId, Endian.little)) + .buffer + .asUint8List(), + ...rest, + ]; + List _registerMlResp({ required int serviceCode, required int status, @@ -251,6 +353,7 @@ List _registerMlResp({ final out = List.filled(14, 0); out[0] = 0x00; out[1] = 0x01; + out[2] = 2; // client id final svc = ByteData(2)..setInt16(0, serviceCode, Endian.little); out[10] = svc.getUint8(0); out[11] = svc.getUint8(1); diff --git a/test/garmin_transfer_test.dart b/test/garmin_transfer_test.dart new file mode 100644 index 0000000..0f5ddc5 --- /dev/null +++ b/test/garmin_transfer_test.dart @@ -0,0 +1,101 @@ +import 'dart:typed_data'; + +import 'package:openstrap_protocol/src/garmin.dart'; +import 'package:openstrap_protocol/src/garmin_transfer.dart'; +import 'package:test/test.dart'; + +GarminGfdiFrame frame(Uint8List built) => garminParseGfdiFrame(built)!; + +void main() { + test('compact message ids expand to 5000 + low byte', () { + expect(garminMessageType(0x8000 | 4), 5004); + expect(garminMessageType(5004), 5004); + }); + + test('download request layout', () { + final f = frame(garminBuildDownloadRequest(0x0102)); + expect(f.type, kGarminMsgDownloadRequest); + expect(f.payload, [0x02, 0x01, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0]); + }); + + test('download status and chunk parsing', () { + final status = garminBuildGfdiFrame(kGarminMsgResponse, [ + 0x8a, 0x13, 0, 0, 0x40, 0, 0, 0, // ref 5002, ok, ok, 64 bytes + ]); + expect(garminParseDownloadStatus(frame(status)), + (ok: true, downloadStatus: 0, size: 64)); + final notReady = garminBuildGfdiFrame(kGarminMsgResponse, [ + 0x8a, 0x13, 0, 3, 0, 0, 0, 0, // ref 5002, ok, not ready + ]); + expect(garminParseDownloadStatus(frame(notReady)), + (ok: false, downloadStatus: kGarminDownloadNotReady, size: 0)); + final chunk = garminBuildGfdiFrame(kGarminMsgFileTransferData, + [0, 0x34, 0x12, 16, 0, 0, 0, 9, 8, 7]); + final c = garminParseFileChunk(frame(chunk))!; + expect((c.crc, c.offset), (0x1234, 16)); + expect(c.data, [9, 8, 7]); + }); + + test('chunk ack carries the next offset', () { + final f = frame(garminBuildChunkAck(48)); + expect(f.payload, [0x8c, 0x13, 0, 0, 48, 0, 0, 0]); + expect( + frame(garminBuildChunkAck(48, + status: kGarminTransferOffsetMismatch)) + .payload[3], + 4); + }); + + test('a compact-form request is answered with its transaction id', () { + const compact = 0x8000 | (5 << 8) | 4; // FILE_TRANSFER_DATA, txn 5 + expect(garminTransactionId(compact), 5); + expect(garminTransactionId(5004), isNull); + final ack = garminBuildChunkAck(8, responseType: garminResponseType(compact)); + expect(ack.sublist(2, 4), [0x00, 0x85]); + expect(garminMessageType(frame(ack).type), kGarminMsgResponse); + expect(frame(garminBuildChunkAck(8)).type, kGarminMsgResponse); + }); + + test('directory entries: health FIT files are recognised', () { + final ts = 1000; + final e = garminParseDirectory([ + // The header record, never an entry even when it looks like one. + 0x01, 0, 128, 32, 0x10, 0, 0, 0, 0x00, 0x10, 0, 0, 0, 0, 0, 0, + 0x05, 0, 128, 32, 0x10, 0, 0, 0, 0x00, 0x10, 0, 0, ts & 0xff, ts >> 8, 0, 0, + 0x06, 0, 128, 4, 0x11, 0, 0, 0, 0x00, 0x02, 0, 0, 0, 0, 0, 0, + ]); + expect(e.map((x) => (x.index, x.subType, x.size, x.isHealthFit)), + [(5, 32, 4096, true), (6, 4, 512, false)]); + expect(e.first.timestamp, ts + kGarminEpochOffset); + }); + + test('device info reply: version 113, host strings, trailing 0x01', () { + final d = frame(garminBuildDeviceInfoReply( + name: 'OS', manufacturer: 'acme', model: 'p1', appVersion: 7)); + expect(d.type, kGarminMsgResponse); + expect(d.payload.sublist(0, 3), [0xa0, 0x13, 0]); // ref 5024, ok + final v = ByteData.sublistView(d.payload); + expect(v.getUint16(3, Endian.little), 113); + expect(v.getUint16(11, Endian.little), 7); + expect(d.payload.sublist(15), [ + 2, ...'OS'.codeUnits, 4, ...'acme'.codeUnits, 2, ...'p1'.codeUnits, 1, + ]); + final long = frame(garminBuildDeviceInfoReply(name: 'x' * 300)); + expect(long.payload[15], 255); + }); + + test('configuration: only the time-request capability is claimed', () { + final c = frame(garminBuildConfigurationReply()); + expect(c.type, kGarminMsgConfiguration); + expect(c.payload, [9, 0, 0, 0, 0, 0, 0, 0, 0, 0x80]); // bit 71 + expect(garminParseConfiguration(c), {kGarminCapCurrentTimeRequest}); + final watch = frame(garminBuildGfdiFrame( + kGarminMsgConfiguration, [2, 0x10, 0x01])); // bits 4 and 8 + expect(garminParseConfiguration(watch), {4, 8}); + }); + + test('a running CRC over two chunks equals the CRC over the whole', () { + final a = [1, 2, 3, 4], b = [5, 6, 7]; + expect(garminCrc16(b, garminCrc16(a)), garminCrc16([...a, ...b])); + }); +} diff --git a/test/hrs_test.dart b/test/hrs_test.dart index e0e1dd5..60ed0f6 100644 --- a/test/hrs_test.dart +++ b/test/hrs_test.dart @@ -61,16 +61,19 @@ void main() { expect(parseHeartRateMeasurement([0x02, 60])!.contact, isNull); // 0b01 }); - test('implausible beat intervals are dropped, not clamped', () { - // 8 ticks ≈ 8 ms and 4096 ticks = 4 s: neither is a beat. A clamped value - // would be a fabricated one. + test('out-of-range intervals pass through unclamped; zero ticks is dropped', + () { + // The plausibility bound is applied once, downstream. Here a value is + // either decoded verbatim or, at zero ticks, not an interval at all — + // never clamped into a fabricated one. final s = parseHeartRateMeasurement([ 0x10, 60, // 0x08, 0x00, // 8 ms + 0x00, 0x00, // 0 ticks — nothing measured 0x00, 0x10, // 4000 ms - 0x00, 0x04, // 1000 ms — the only real one + 0x00, 0x04, // 1000 ms ])!; - expect(s.rrMs, [1000]); + expect(s.rrMs, [8, 4000, 1000]); }); test('a searching sensor reporting 0 bpm is not a measurement', () { diff --git a/test/huami_legacy_test.dart b/test/huami_legacy_test.dart new file mode 100644 index 0000000..ee9defa --- /dev/null +++ b/test/huami_legacy_test.dart @@ -0,0 +1,64 @@ +import 'package:openstrap_protocol/openstrap_protocol.dart'; +import 'package:test/test.dart'; + +void main() { + test('clock value: Sunday is 0, ms/4, then the UTC offset in quarters', () { + final t = DateTime(2026, 10, 4, 7, 5, 9, 500); // a Sunday + final v = huamiTimeValue(t); + expect(v.sublist(0, 9), [0xea, 0x07, 10, 4, 7, 5, 9, 0, 125]); + expect(v[9], anyOf(0, 8)); + expect(v.last, (t.timeZoneOffset.inMinutes ~/ 15) & 0xff); + expect(huamiTimeValue(DateTime(2026, 10, 10))[7], 6, reason: 'Saturday'); + }); + + test('fetch start: type 1, minute-resolution since, 0, tz', () { + final v = huamiActivityFetchStart(DateTime(2026, 10, 3, 0, 0)); + expect(v.sublist(0, 9), [0x01, 0x01, 0xea, 0x07, 10, 3, 0, 0, 0]); + expect(v, hasLength(10)); + }); + + test('start response: sample count and an absolute start instant', () { + // 2026-10-03 00:00 at UTC+05:30 (22 quarters) = 2026-10-02 18:30Z. + final r = parseHuamiFetchStart( + [0x10, 0x01, 0x01, 0x40, 0x16, 0, 0, 0xea, 0x07, 10, 3, 0, 0, 0, 22])!; + expect(r.count, 0x1640); + expect(r.startSec, + DateTime.utc(2026, 10, 2, 18, 30).millisecondsSinceEpoch ~/ 1000); + expect(parseHuamiFetchStart([0x10, 0x01, 0x01, 0, 0, 0, 0])!.count, 0); + // More than 90 days of minutes is not a real answer. + expect( + parseHuamiFetchStart( + [0x10, 0x01, 0x01, 0x41, 0xfa, 0x01, 0, 0xea, 0x07, 10, 3, 0, 0, 0, 0]), + isNull); + expect(parseHuamiFetchStart([0x10, 0x01, 0x02]), isNull); + }); + + test('samples: kind inheritance, no-reading HR, minute timestamps', () { + final buf = HuamiActivityBuffer() + ..add([0, 0x09, 10, 0, 55, 0x00, 5, 0, 0xff]) + ..add([1, 0x5b, 3, 0, 52]); // high nibble masked off: kind 11 + expect(buf.ok, isTrue); + final m = buf.minutes(1000); + expect(m.map((x) => (x.tsSec, x.kind, x.hr)), + [(1000, 9, 55), (1060, 9, null), (1120, 11, 52)]); + expect(m.every((x) => x.asleep), isTrue); + }); + + test('a counter gap marks the transfer untrustworthy', () { + final buf = HuamiActivityBuffer()..add([0, 1, 0, 0, 0])..add([2, 1, 0, 0, 0]); + expect(buf.ok, isFalse); + expect(huamiFetchDone([0x10, 0x02, 0x01]), isTrue); + }); + + test('an exact repeat of the previous packet is dropped, not a gap', () { + final buf = HuamiActivityBuffer() + ..add([0, 1, 2, 3, 4]) + ..add([0, 1, 2, 3, 4]) + ..add([1, 5, 6, 7, 8]); + expect(buf.ok, isTrue); + expect(buf.bytes, [1, 2, 3, 4, 5, 6, 7, 8]); + // Same counter, different bytes: a real gap. + final bad = HuamiActivityBuffer()..add([0, 1, 2, 3, 4])..add([0, 9, 9, 9, 9]); + expect(bad.ok, isFalse); + }); +} diff --git a/test/lefun_test.dart b/test/lefun_test.dart deleted file mode 100644 index eb338b9..0000000 --- a/test/lefun_test.dart +++ /dev/null @@ -1,126 +0,0 @@ -// Envelope + battery decode for the Lefun-family OEM ring/band protocol. -// -// NOTHING HERE HAS MET HARDWARE. These fixtures are hand-built to the -// documented envelope shape, not captured off a device. - -import 'package:test/test.dart'; -import 'package:openstrap_protocol/openstrap_protocol.dart'; - -void main() { - group('buildLefunFrame', () { - test('a bare battery request', () { - expect( - buildLefunFrame(kLefunReportBattery), - [0xAB, 0x04, 0x03, 0xEE], - ); - }); - - test('a request with arguments', () { - expect( - buildLefunFrame(kLefunReportFirmwareInfo), - [0xAB, 0x04, 0x00, 0x0C], - ); - }); - - test('too long to fit one write throws', () { - expect( - () => buildLefunFrame(0x01, params: List.filled(20, 0)), - throwsArgumentError, - ); - }); - - test('an out-of-range report code throws', () { - expect(() => buildLefunFrame(256), throwsArgumentError); - expect(() => buildLefunFrame(-1), throwsArgumentError); - }); - - test('an out-of-range argument byte throws', () { - expect( - () => buildLefunFrame(0x01, params: const [256]), - throwsArgumentError, - ); - expect( - () => buildLefunFrame(0x01, params: const [-1]), - throwsArgumentError, - ); - }); - }); - - group('parseLefunFrame', () { - test('a battery reply round-trips', () { - final frame = parseLefunFrame(const [0x5A, 0x05, 0x03, 0x57, 0xFB])!; - expect(frame.report, kLefunReportBattery); - expect(frame.params, [0x57]); - }); - - test('a zero battery reply round-trips', () { - final frame = parseLefunFrame(const [0x5A, 0x05, 0x03, 0x00, 0xA3])!; - expect(frame.params, [0x00]); - }); - - test('wrong marker byte is refused', () { - expect( - parseLefunFrame(const [0xAB, 0x05, 0x03, 0x57, 0xFB]), - isNull, - ); - }); - - test('too short is refused', () { - expect(parseLefunFrame(const [0x5A, 0x05, 0x03]), isNull); - expect(parseLefunFrame(const []), isNull); - }); - - test('a declared length longer than the buffer is refused', () { - expect( - parseLefunFrame(const [0x5A, 0x06, 0x03, 0x57]), - isNull, - ); - }); - - test('a corrupted checksum is refused', () { - expect( - parseLefunFrame(const [0x5A, 0x05, 0x03, 0x57, 0x00]), - isNull, - ); - }); - - test('a declared length longer than the device ceiling is refused', () { - // Declares 21 bytes, one past kLefunMaxFrameLength, regardless of what - // the buffer itself holds. - expect(parseLefunFrame(const [0x5A, 21, 0x03]), isNull); - }); - - test('bytes past the declared length are tolerated, not refused', () { - // A valid battery reply with one undeclared trailing byte — the same - // shape a device in this family that pads past its own length would - // send (see the module doc on why this is accepted, not refused). - final frame = - parseLefunFrame(const [0x5A, 0x05, 0x03, 0x57, 0xFB, 0x00])!; - expect(frame.report, kLefunReportBattery); - expect(frame.params, [0x57]); - }); - }); - - group('decodeLefunBattery', () { - test('a mid-range level', () { - expect(decodeLefunBattery(const [87]), 87); - }); - - test('zero is valid', () { - expect(decodeLefunBattery(const [0]), 0); - }); - - test('full charge is valid', () { - expect(decodeLefunBattery(const [100]), 100); - }); - - test('out of range is refused, not clamped', () { - expect(decodeLefunBattery(const [101]), isNull); - }); - - test('wrong argument count is refused', () { - expect(decodeLefunBattery(const []), isNull); - expect(decodeLefunBattery(const [1, 2]), isNull); - }); - }); -} diff --git a/test/o2ring_test.dart b/test/o2ring_test.dart deleted file mode 100644 index 40ceb91..0000000 --- a/test/o2ring_test.dart +++ /dev/null @@ -1,112 +0,0 @@ -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('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); - 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('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); - }); - - test('refuses a JSON value that is not an object', () { - expect(parseO2RingInfo('[1,2,3]'.codeUnits), isNull); - }); - }); -} diff --git a/test/pebble_test.dart b/test/pebble_test.dart new file mode 100644 index 0000000..da87f94 --- /dev/null +++ b/test/pebble_test.dart @@ -0,0 +1,61 @@ +import 'package:openstrap_protocol/openstrap_protocol.dart'; +import 'package:test/test.dart'; + +List u16(int v) => [v & 0xff, v >> 8]; +List u32(int v) => [v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, v >> 24]; + +void main() { + test('inner frames: BE length + endpoint, reassembled across packets', () { + final f = pebbleFrame(kPebbleEndpointDatalog, List.generate(40, (i) => i)); + expect(f.sublist(0, 4), [0, 40, 0x1a, 0x7a]); + final packets = pebblePpogattPackets(f, 30); + expect(packets.first.first, 30 << 3); + expect(packets[2].first, 0 << 3, reason: 'serials are 5-bit and wrap'); + final r = PebbleFrameReassembler(); + final out = [for (final p in packets) ...r.add(p.sublist(1))]; + expect(out.single.$1, kPebbleEndpointDatalog); + expect(out.single.$2, List.generate(40, (i) => i)); + }); + + test('phone version reply: 25 bytes, Android, LE capability mask', () { + final v = pebblePhoneVersionReply(); + expect(v, hasLength(25)); + expect(v.sublist(9, 13), [0, 0, 0, 2]); + expect(v[17], 0xaf); // 0x1|0x2|0x4|0x8|0x20|0x80 + }); + + test('set time: UTC seconds BE, offset minutes BE', () { + final now = DateTime.utc(2026, 10, 4, 1, 0).toLocal(); + final v = pebbleSetTimeUtc(now); + final utc = now.millisecondsSinceEpoch ~/ 1000; + expect(v.sublist(0, 5), + [3, utc >> 24, (utc >> 16) & 0xff, (utc >> 8) & 0xff, utc & 0xff]); + expect(v[7], 0); + }); + + test('datalog open and data', () { + final open = [1, 7, ...List.filled(16, 0), ...u32(1), ...u32(81), 0, ...u16(4)]; + final s = parsePebbleDatalogOpen(open)!; + expect((s.sid, s.tag, s.itemSize), (7, 81, 4)); + final data = [2, 7, ...u32(0), ...u32(0), 1, 2, 3, 4, 5, 6, 7, 8]; + expect(parsePebbleDatalogItems(data, 4), [[1, 2, 3, 4], [5, 6, 7, 8]]); + expect(parsePebbleDatalogItems([...data, 9], 4), isNull); + expect(pebbleDatalogAck(7), [0x85, 7]); + }); + + test('steps item v7: per-minute steps and HR from the header timestamp', () { + List rec(int steps, int hr) => + [steps, 0, ...u16(300), 10, 0, ...u16(0), ...u16(0), ...u16(0), hr]; + final item = [...u16(7), ...u32(1791000000), 0, 13, 2, ...rec(12, 58), ...rec(0, 0)]; + final m = parsePebbleStepsItem(item)!; + expect(m.map((x) => (x.tsSec, x.steps, x.hr)), + [(1791000000, 12, 58), (1791000060, 0, null)]); + expect(parsePebbleStepsItem([...u16(99), ...item.sublist(2)]), isNull); + }); + + test('overlay: deep sleep period', () { + final o = parsePebbleOverlayItem( + [...u16(1), ...u16(0), ...u16(2), ...u32(0), ...u32(1791000000), ...u32(3600)])!; + expect((o.type, o.startSec, o.durationSec), (2, 1791000000, 3600)); + }); +} diff --git a/test/polar_pmd_test.dart b/test/polar_pmd_test.dart index d7489e3..dbc8d5c 100644 --- a/test/polar_pmd_test.dart +++ b/test/polar_pmd_test.dart @@ -36,6 +36,23 @@ void main() { }); }); + group('control-point indications and feature read', () { + test('the sensor stopping PPI by itself is recognised', () { + expect(polarPmdPpiStopped([0x01, 0x03]), isTrue); + expect(polarPmdPpiStopped([0x01, 0x00, 0x03]), isTrue); + expect(polarPmdPpiStopped([0x01, 0x00]), isFalse); // ECG only + expect(polarPmdPpiStopped([0xF0, 0x02, 0x03, 0x00]), isFalse); + expect(polarPmdPpiStopped([]), isFalse); + }); + + test('the feature bitmap says whether PPI is offered', () { + expect(polarPmdSupportsPpi([0x0F, 0x08]), isTrue); // PPI bit (3) + expect(polarPmdSupportsPpi([0x0F, 0x05]), isFalse); // ECG + ACC only + expect(polarPmdSupportsPpi([0xF0, 0x08]), isNull); + expect(polarPmdSupportsPpi([0x0F]), isNull); + }); + }); + group('PPI frames', () { List ppiFrame(List> records) => [ 0x03, // measurement type, low 6 bits @@ -75,6 +92,18 @@ void main() { expect(s.skinContactBits, 0x03); }); + test('contact reads the flags the way 0x2A37 reads its contact bits', () { + bool? contactOf(int flags) => parsePolarPmdPpiFrame(ppiFrame([ + [60, 0xE8, 0x03, 0x0A, 0x00, flags], + ]))! + .single + .contact; + expect(contactOf(0x04), isFalse); // supported, no contact + expect(contactOf(0x06), isTrue); // supported, contact + expect(contactOf(0x00), isNull); // not supported + expect(contactOf(0x02), isNull); // status bit without support + }); + test('a non-PPI measurement type is not this decoder\'s frame', () { final frame = ppiFrame([ [60, 0xE8, 0x03, 0x0A, 0x00, 0x00], diff --git a/test/ring11m_test.dart b/test/ring11m_test.dart deleted file mode 100644 index 1841264..0000000 --- a/test/ring11m_test.dart +++ /dev/null @@ -1,183 +0,0 @@ -// The R11M/R10M ring wire format, checked against the one real example frame -// available: a model-query request, `02 03 08 00 47 50 ef 20`. Everything -// else here follows from that frame's own header shape plus the exact -// command/ack byte sequences the source material states directly — nothing -// is checked against a second independent capture, because there is no -// second one to check against (ASSUMPTIONS R6, nobody owns this ring). - -import 'dart:typed_data'; -import 'package:test/test.dart'; -import 'package:openstrap_protocol/openstrap_protocol.dart'; - -Uint8List _hex(String s) => Uint8List.fromList([ - for (var i = 0; i + 1 < s.length; i += 2) - int.parse(s.substring(i, i + 2), radix: 16), - ]); - -void main() { - group('framing', () { - test('the model-query example decodes exactly, CRC included', () { - final f = parseRing11mFrame(_hex('020308004750ef20')); - expect(f, isNotNull); - expect(f!.group, 0x02); - expect(f.command, 0x03); - expect(f.payload, [0x47, 0x50]); - }); - - test('the builder reproduces the example frame byte-for-byte', () { - final built = buildRing11mFrame(0x02, 0x03, [0x47, 0x50]); - expect(built, _hex('020308004750ef20')); - }); - - test('a bad CRC is refused, not silently accepted', () { - final bytes = _hex('020308004750ef21'); // last byte flipped - expect(parseRing11mFrame(bytes), isNull); - }); - - test('a declared length longer than the delivered bytes is refused', () { - final bytes = _hex('02030a004750ef20'); // len says 10, only 8 delivered - expect(parseRing11mFrame(bytes), isNull); - }); - - test('round trip through every payload width, CRC verified', () { - for (final len in [0, 1, 2, 16, 240]) { - final payload = List.generate(len, (i) => i & 0xff); - final built = buildRing11mFrame(0x05, 0x11, payload); - final f = parseRing11mFrame(built); - expect(f!.payload, payload, reason: 'width $len'); - } - }); - }); - - group('device info', () { - test('model reply is the ASCII string, unmodified', () { - final f = Ring11mFrame(kRing11mGroupDeviceInfo, kRing11mCmdModelQuery, - Uint8List.fromList('R11M'.codeUnits)); - expect(parseRing11mModel(f), 'R11M'); - }); - - test('a non-ASCII model reply is refused, not mangled', () { - final f = Ring11mFrame( - kRing11mGroupDeviceInfo, kRing11mCmdModelQuery, Uint8List.fromList([0xff, 0x00])); - expect(parseRing11mModel(f), isNull); - }); - - test('battery percent at offset 0, bounded 0-100', () { - final ok = Ring11mFrame( - kRing11mGroupDeviceInfo, kRing11mCmdBatteryQuery, Uint8List.fromList([72])); - expect(parseRing11mBattery(ok), 72); - final bad = Ring11mFrame( - kRing11mGroupDeviceInfo, kRing11mCmdBatteryQuery, Uint8List.fromList([200])); - expect(parseRing11mBattery(bad), isNull); - }); - - test('capability reply is handed back raw, never bit-decoded', () { - final f = Ring11mFrame(kRing11mGroupDeviceInfo, kRing11mCmdCapabilityQuery, - Uint8List.fromList([0x01, 0x02])); - expect(parseRing11mCapabilitiesRaw(f), [0x01, 0x02]); - }); - - test('model query reproduces the verified example frame byte-for-byte', () { - expect(ring11mCmdModelQuery(), _hex('020308004750ef20')); - }); - }); - - group('setting', () { - test('set-time packs the seven local fields plus weekday, in order', () { - final t = DateTime(2026, 9, 5, 14, 30, 12); // a Saturday - final f = parseRing11mFrame(ring11mCmdSetTime(t))!; - expect(f.payload.length, 8); - expect(f.payload[0] | (f.payload[1] << 8), 2026); - expect(f.payload.sublist(2), [9, 5, 14, 30, 12, t.weekday]); - }); - - test('the automatic-toggle interval snaps to 30 or 60, nothing between', () { - final low = parseRing11mFrame( - ring11mCmdAutoToggle(kRing11mCmdAutoHrToggle, true, intervalMinutes: 10))!; - expect(low.payload, [0x01, 30]); - final high = parseRing11mFrame( - ring11mCmdAutoToggle(kRing11mCmdAutoSpo2Toggle, false, intervalMinutes: 45))!; - expect(high.payload, [0x00, 60]); - }); - - test('the automatic-toggle command selector is checked, not passed through', () { - expect(() => ring11mCmdAutoToggle(kRing11mCmdSetTime, true), - throwsArgumentError); - }); - }); - - group('app control', () { - test('manual measurement payload is [start-flag, kind]', () { - final f = parseRing11mFrame( - ring11mCmdManualMeasurement(true, kRing11mMeasureSpo2))!; - expect(f.payload, [0x01, kRing11mMeasureSpo2]); - }); - - test('an unrecognised manual-measurement kind is refused, not sent', () { - expect(() => ring11mCmdManualMeasurement(true, 0x7f), throwsArgumentError); - }); - - test('find-device and live-totals both carry an empty payload', () { - expect(parseRing11mFrame(ring11mCmdFindDevice())!.payload, isEmpty); - expect(parseRing11mFrame(ring11mCmdLiveActivityTotals())!.payload, isEmpty); - }); - }); - - group('health/history transfer', () { - test('the terminator carries the packet count and the trailing CRC', () { - final f = Ring11mFrame(kRing11mGroupHealthHistory, kRing11mCmdHistoryTerminator, - Uint8List.fromList([0x05, 0x00, 0xaa, 0xbb, 0x34, 0x12])); - final t = parseRing11mHistoryTerminator(f)!; - expect(t.packetCount, 5); - expect(t.crc16, 0x1234); - }); - - test('a terminator too short to hold both fields is refused', () { - final f = Ring11mFrame( - kRing11mGroupHealthHistory, kRing11mCmdHistoryTerminator, Uint8List.fromList([0, 0])); - expect(parseRing11mHistoryTerminator(f), isNull); - }); - - test('the block CRC is the same construction the outer frame uses', () { - final data = [1, 2, 3, 4, 5]; - // Same primitive the framing group already pins against the example - // frame — checked here by reproducing it over a whole frame's - // pre-CRC bytes and confirming it against the known-good example. - final wholeFrameCrc = ring11mHistoryCrc(_hex('02030800' '4750')); - expect(wholeFrameCrc, 0x20ef); - expect(ring11mHistoryCrc(data), isNot(0)); // sanity: it moves - }); - - test('ack is exactly 05 80 00, nack is exactly 05 80 04', () { - expect(buildRing11mHistoryAck(true), [0x05, 0x80, 0x00]); - expect(buildRing11mHistoryAck(false), [0x05, 0x80, 0x04]); - }); - - test('ring11mHistoryBlockCrcOk is the paired check for a parsed terminator', - () { - const data = [1, 2, 3, 4, 5, 6, 7, 8]; - final crc = ring11mHistoryCrc(data); - final good = parseRing11mHistoryTerminator(Ring11mFrame( - kRing11mGroupHealthHistory, - kRing11mCmdHistoryTerminator, - Uint8List.fromList([0x01, 0x00, crc & 0xff, (crc >> 8) & 0xff])))!; - expect(ring11mHistoryBlockCrcOk(data, good), isTrue); - final bad = parseRing11mHistoryTerminator(Ring11mFrame( - kRing11mGroupHealthHistory, - kRing11mCmdHistoryTerminator, - Uint8List.fromList([0x01, 0x00, 0xde, 0xad])))!; - expect(ring11mHistoryBlockCrcOk(data, bad), isFalse); - }); - }); - - group('framing limits', () { - test('a payload past the u16 length field is refused, not truncated', () { - expect( - () => buildRing11mFrame(0x05, 0x01, List.filled(kRing11mMaxPayloadLen + 1, 0)), - throwsArgumentError); - // The ceiling itself still builds. - expect(buildRing11mFrame(0x05, 0x01, List.filled(kRing11mMaxPayloadLen, 0)).length, - 0xffff); - }); - }); -} diff --git a/test/ringconn_test.dart b/test/ringconn_test.dart deleted file mode 100644 index 9b966a7..0000000 --- a/test/ringconn_test.dart +++ /dev/null @@ -1,308 +0,0 @@ -// The RingConn wire format: SM3 against its own published test vector, and -// every framing/command shape against byte-exact fixtures built from the spec -// this file's own header describes. -// -// THE AUTH TRIPLE BELOW IS SELF-CONSISTENT, NOT CAPTURED. There is no ring in -// the room. It is computed once from this package's own [sm3] and pinned so a -// later change to [ringConnAuthResponse] cannot silently drift — it proves -// determinism, not correctness against real hardware. The SM3 KAT is the -// independent half: any conformant SM3 implementation reproduces it, because -// it comes from the published standard rather than from this file. - -import 'dart:typed_data'; - -import 'package:test/test.dart'; -import 'package:openstrap_protocol/openstrap_protocol.dart'; - -Uint8List _hex(String s) => Uint8List.fromList([ - for (var i = 0; i + 1 < s.length; i += 2) - int.parse(s.substring(i, i + 2), radix: 16), - ]); - -String _toHex(List b) => - b.map((x) => x.toRadixString(16).padLeft(2, '0')).join(); - -void main() { - group('SM3', () { - test('the published known-answer vector: SM3("abc")', () { - // GB/T 32905-2016's own worked example. Independent of anything - // RingConn-specific — any conformant SM3 implementation reproduces this - // exact digest for this exact input. - final out = sm3('abc'.codeUnits); - expect( - _toHex(out), - '66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0', - ); - }); - - test('the empty message still produces a 32-byte digest', () { - final out = sm3(const []); - expect(out.length, 32); - }); - - test('one message byte changed changes the whole digest (avalanche)', () { - final a = sm3('abc'.codeUnits); - final b = sm3('abd'.codeUnits); - expect(_toHex(a), isNot(_toHex(b))); - }); - - test('deterministic: the same message always digests the same', () { - expect(sm3('abc'.codeUnits), sm3('abc'.codeUnits)); - }); - - test('a message spanning the 55/56-byte padding boundary both digest to ' - '32 bytes with no crash', () { - expect(sm3(List.filled(55, 0x61)).length, 32); - expect(sm3(List.filled(56, 0x61)).length, 32); - expect(sm3(List.filled(64, 0x61)).length, 32); - expect(sm3(List.filled(65, 0x61)).length, 32); - }); - }); - - group('MAC recovery', () { - test('the forward EUI-64 form: OUI(3) FF FE NIC(3)', () { - final mac = ringConnMacFromSystemId( - _hex('a1b2c3fffe445566'), - ); - expect(_toHex(mac), 'a1b2c3445566'); - }); - - test('reversed byte order falls back to the reversed form', () { - // The forward bytes above, byte-reversed — bytes 3/4 (from the start) - // are no longer FF FE, but they are once the whole thing is reversed. - final reversed = _hex('a1b2c3fffe445566').reversed.toList(); - final mac = ringConnMacFromSystemId(reversed); - expect(_toHex(mac), 'a1b2c3445566'); - }); - - test('no FF FE marker either way: the leading 6 bytes are taken as-is', - () { - final mac = ringConnMacFromSystemId(_hex('0011223344550000')); - expect(_toHex(mac), '001122334455'); - }); - - test('refuses anything but exactly 8 bytes', () { - expect(() => ringConnMacFromSystemId(_hex('0011223344')), - throwsArgumentError); - }); - }); - - group('auth response', () { - // SELF-CONSISTENT PIN — see this file's own header. Computed once from - // this package's own sm3()/ringConnAuthResponse, not a captured value. - test('one pinned (mac, challenge) -> response triple', () { - final mac = _hex('a1b2c3445566'); - const challenge = 0x2b; - final v = mac[3] ^ mac[4] ^ mac[5]; - final expected = sm3([v, challenge]).sublist(29, 32); - expect(_toHex(ringConnAuthResponse(mac, challenge)), _toHex(expected)); - // Pinned literal too, so a change to either function is caught even if - // both drift together. - expect( - _toHex(ringConnAuthResponse(mac, challenge)), - _toHex(sm3([mac[3] ^ mac[4] ^ mac[5], 0x2b]).sublist(29, 32)), - ); - }); - - test('is always exactly 3 bytes', () { - expect(ringConnAuthResponse(_hex('a1b2c3445566'), 0x00).length, 3); - }); - - test('a different challenge answers differently', () { - final mac = _hex('a1b2c3445566'); - expect( - _toHex(ringConnAuthResponse(mac, 0x01)), - isNot(_toHex(ringConnAuthResponse(mac, 0x02))), - ); - }); - - test('refuses anything but a 6-byte MAC', () { - expect(() => ringConnAuthResponse(_hex('a1b2c3'), 0x01), - throwsArgumentError); - }); - }); - - group('framing', () { - test('an ordinary reply strips the XOR trailer and validates it', () { - // 0x81 0x00 0x2b, xor = 0x81^0x00^0x2b = 0xaa - final f = parseRingConnFrame([0x81, 0x00, 0x2b, 0xaa]); - expect(f, isNotNull); - expect(f!.respid, kRingConnRespAuth); - expect(f.payload, [0x00, 0x2b]); - expect(f.xorValid, isTrue); - }); - - test('a wrong trailer byte is reported invalid, not dropped', () { - final f = parseRingConnFrame([0x81, 0x00, 0x2b, 0x00]); - expect(f, isNotNull); - expect(f!.xorValid, isFalse); - // The payload is still handed back — a caller may still want to - // archive the raw bytes even when the checksum does not validate. - expect(f.payload, [0x00, 0x2b]); - }); - - test('the 0x50 status frame carries no trailer at all', () { - final f = parseRingConnFrame([0x50, 0x01, 0x02, 0x03]); - expect(f, isNotNull); - expect(f!.respid, kRingConnRespStatus); - expect(f.payload, [0x01, 0x02, 0x03]); - expect(f.xorValid, isTrue); - }); - - test('too short to carry a trailer is refused', () { - expect(parseRingConnFrame([0x81]), isNull); - expect(parseRingConnFrame(const []), isNull); - }); - - test('command byte to respid, every documented pair', () { - // respid = command_byte XOR 0x80, verified on every pair the spec - // names, including the two where the ACK's own opcode maps onto the - // SAME tag as the bulk page it is acknowledging. 0x95/0x15 is not a - // command this file builds — it is here only as the general rule's - // fourth worked example, so it is checked as arithmetic, not against a - // named constant. - final pairs = <(int cmd, int respid)>[ - (0x01, kRingConnRespAuth), - (0x02, kRingConnRespSyncOpen), - (0x07, kRingConnRespFetchEmpty), - (0x95, 0x15), - (0xc7, kRingConnRespBulkPpg), - (0xcc, kRingConnRespBulkActivity), - ]; - for (final (cmd, respid) in pairs) { - expect(cmd ^ 0x80, respid, reason: '0x${cmd.toRadixString(16)}'); - } - }); - - test('ringConnIsBulk / ringConnEndsBurst partition the reply tags', () { - expect(ringConnIsBulk(kRingConnRespBulkPpg), isTrue); - expect(ringConnIsBulk(kRingConnRespBulkActivity), isTrue); - expect(ringConnIsBulk(kRingConnRespAuth), isFalse); - - expect(ringConnEndsBurst(kRingConnRespFetchEmpty), isTrue); - expect(ringConnEndsBurst(kRingConnRespUnsolicited), isTrue); - expect(ringConnEndsBurst(kRingConnRespStatus), isTrue); - expect(ringConnEndsBurst(kRingConnRespBulkPpg), isFalse); - }); - }); - - group('bulk pages', () { - test('a PPG page (47-byte records) slices cleanly', () { - final records = [ - Uint8List.fromList(List.filled(47, 0x11)), - Uint8List.fromList(List.filled(47, 0x22)), - ]; - final body = [0x00, 0x01, ...records[0], ...records[1]]; - var x = kRingConnRespBulkPpg; - for (final b in body) { - x ^= b; - } - final f = parseRingConnFrame([kRingConnRespBulkPpg, ...body, x]); - final page = parseRingConnBulkPage(f!); - expect(page, isNotNull); - expect(page!.remaining, 1); - expect(page.records.length, 2); - expect(page.records[0], records[0]); - expect(page.records[1], records[1]); - }); - - test('an activity page (23-byte records) slices cleanly', () { - final record = Uint8List.fromList(List.filled(23, 0x33)); - final body = [0x00, 0x00, ...record]; - var x = kRingConnRespBulkActivity; - for (final b in body) { - x ^= b; - } - final f = parseRingConnFrame([kRingConnRespBulkActivity, ...body, x]); - final page = parseRingConnBulkPage(f!); - expect(page, isNotNull); - expect(page!.remaining, 0); - expect(page.records.length, 1); - expect(page.records[0], record); - }); - - test('a body that does not divide evenly into records is refused', () { - final f = RingConnFrame( - kRingConnRespBulkPpg, - Uint8List.fromList([0x00, 0x00, 0x01, 0x02, 0x03]), - true, - ); - expect(parseRingConnBulkPage(f), isNull); - }); - - test('a non-bulk frame is not a bulk page', () { - final f = RingConnFrame( - kRingConnRespFetchEmpty, - Uint8List.fromList(const []), - true, - ); - expect(parseRingConnBulkPage(f), isNull); - }); - }); - - group('outbound commands', () { - test('status: 01 00 00', () { - expect(ringConnCmdStatus(), [0x01, 0x00, 0x00]); - }); - - test('auth response: 01 01 r0 r1 r2 00', () { - expect( - ringConnCmdAuthResponse([0xaa, 0xbb, 0xcc]), - [0x01, 0x01, 0xaa, 0xbb, 0xcc, 0x00], - ); - }); - - test('auth response refuses anything but 3 bytes', () { - expect(() => ringConnCmdAuthResponse([0x01, 0x02]), throwsArgumentError); - }); - - test('sync-open: 02 00 01 00', () { - expect( - ringConnCmdSyncOpen(0x01020304, kRingConnChannelSleep), - [0x02, 0x00, 0x01, 0x02, 0x03, 0x04, 0x00, 0x01, 0x00], - ); - expect( - ringConnCmdSyncOpen(0, kRingConnChannelAwake), - [0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x01, 0x00], - ); - }); - - test('fetch: 07 00 00', () { - expect(ringConnCmdFetch(), [0x07, 0x00, 0x00]); - }); - - test('page acks: c7 00 00 and cc 00 00', () { - expect(ringConnCmdAckPpg(), [0xc7, 0x00, 0x00]); - expect(ringConnCmdAckActivity(), [0xcc, 0x00, 0x00]); - }); - - test('no command builder appends an XOR trailer', () { - // Every builder above ends in a literal 0x00, never the XOR of what - // came before it — mixing the two conventions up is the one mistake - // that fails with no error to explain it (see this file's own header). - for (final cmd in [ - ringConnCmdStatus(), - ringConnCmdAuthResponse([1, 2, 3]), - ringConnCmdSyncOpen(123, kRingConnChannelSleep), - ringConnCmdFetch(), - ringConnCmdAckPpg(), - ringConnCmdAckActivity(), - ]) { - expect(cmd.last, 0x00); - } - }); - }); - - group('the epoch', () { - test('kRingConnEpochOffset is 2019-12-31 12:00:00 UTC', () { - final t = DateTime.fromMillisecondsSinceEpoch( - kRingConnEpochOffset * 1000, - isUtc: true, - ); - expect(t.year, 2019); - expect(t.month, 12); - expect(t.day, 31); - expect(t.hour, 12); - }); - }); -} diff --git a/test/thermometer_scale_test.dart b/test/thermometer_scale_test.dart new file mode 100644 index 0000000..b90ea3d --- /dev/null +++ b/test/thermometer_scale_test.dart @@ -0,0 +1,159 @@ +import 'package:openstrap_protocol/openstrap_protocol.dart'; +import 'package:test/test.dart'; + +/// IEEE-11073 FLOAT bytes for mantissa * 10^exp. +List f32(int mantissa, int exp) { + final m = mantissa & 0xffffff; + return [m & 0xff, (m >> 8) & 0xff, (m >> 16) & 0xff, exp & 0xff]; +} + +List dt(int y, int mo, int d, int h, int mi, int s) => + [y & 0xff, y >> 8, mo, d, h, mi, s]; + +void main() { + group('IEEE-11073 FLOAT', () { + test('mantissa x 10^exponent, signed both ways', () { + expect(ieee11073Float32(f32(3652, -2), 0), closeTo(36.52, 1e-9)); + expect(ieee11073Float32(f32(-5, 1), 0), -50); + }); + test('reserved values are not numbers', () { + expect(ieee11073Float32(f32(0x7fffff, 0), 0), isNull); // NaN + expect(ieee11073Float32(f32(0x800000, 0), 0), isNull); // NRes + }); + }); + + group('health thermometer', () { + test('Celsius with timestamp and site', () { + final m = parseHtpMeasurement( + [0x06, ...f32(3652, -2), ...dt(2026, 10, 4, 6, 41, 3), 0x02])!; + expect(m.celsius, closeTo(36.52, 1e-9)); + expect(m.at, (year: 2026, month: 10, day: 4, hour: 6, minute: 41, + second: 3)); + expect(m.site, 2); + }); + test('Fahrenheit is converted, no timestamp is null', () { + final m = parseHtpMeasurement([0x01, ...f32(9770, -2)])!; + expect(m.celsius, closeTo(36.5, 1e-9)); + expect(m.at, isNull); + }); + test('malformed or reserved values decode to nothing', () { + expect(parseHtpMeasurement([0x00, 1, 2]), isNull); + expect(parseHtpMeasurement([0x00, ...f32(0x7fffff, 0)]), isNull); + }); + test('a declared field the value does not carry refuses it', () { + // Timestamp flagged, only 4 of its 7 bytes present. + expect( + parseHtpMeasurement( + [0x02, ...f32(3652, -2), ...dt(2026, 10, 4, 6, 41, 3).take(4)]), + isNull); + // Site flagged, no site byte. + expect(parseHtpMeasurement([0x04, ...f32(3652, -2)]), isNull); + }); + test('a day the month does not have is not a date', () { + expect(gattDateTime(dt(2026, 2, 31, 6, 0, 0), 0), isNull); + expect(gattDateTime(dt(2025, 2, 29, 6, 0, 0), 0), isNull); + expect(gattDateTime(dt(2024, 2, 29, 6, 0, 0), 0)?.day, 29); + expect(gattDateTime(dt(10000, 1, 1, 0, 0, 0), 0), isNull); + }); + test('current time value', () { + final v = currentTimeValue(DateTime(2026, 10, 4, 7, 5, 9, 500)); + expect(v, [0xea, 0x07, 10, 4, 7, 5, 9, DateTime.sunday, 128, 0]); + }); + }); + + group('Mi scales', () { + List bc(int f0, int f1, int ohm, int raw) => [ + f0, f1, ...dt(2026, 10, 4, 7, 30, 0), ohm & 0xff, ohm >> 8, // + raw & 0xff, raw >> 8, + ]; + List rec(int flags, int raw) => + [flags, raw & 0xff, raw >> 8, ...dt(2026, 10, 3, 21, 0, 0)]; + + test('body composition: kg, impedance, timestamp when stabilised', () { + final r = parseMiBodyComposition(bc(0x02, 0x22, 500, 14368))!; + expect(r.kg, closeTo(71.84, 1e-9)); // 14368 / 200 + expect(r.impedanceOhm, 500); + expect(r.at!.hour, 7); + }); + test('body composition: settling is ignored, a finished frame is kept', () { + expect(parseMiBodyComposition(bc(0x02, 0x02, 0, 14368)), isNull); + expect(parseMiBodyComposition(bc(0x02, 0xa2, 500, 14368))!.kg, + closeTo(71.84, 1e-9)); + }); + test('body composition: pounds from byte 0, jin from byte 1 bit 6', () { + expect(parseMiBodyComposition(bc(0x01, 0x20, 0, 15800))!.kg, + closeTo(71.67, 0.01)); + expect(parseMiBodyComposition(bc(0x00, 0x60, 0, 14368))!.kg, + closeTo(71.84, 1e-9)); + // Byte 1 bit 0 is not a unit: still raw / 200 kg. + expect(parseMiBodyComposition(bc(0x00, 0x21, 0, 14368))!.kg, + closeTo(71.84, 1e-9)); + }); + test('body composition: impedance only when stable and real', () { + expect(parseMiBodyComposition(bc(0, 0x20, 500, 14368))!.impedanceOhm, + isNull); + expect(parseMiBodyComposition(bc(0, 0x22, 65534, 14368))!.impedanceOhm, + isNull); + expect(parseMiBodyComposition(bc(0, 0x22, 65533, 14368))!.impedanceOhm, + isNull); + }); + test('overload is not a weight', () { + expect(parseMiBodyComposition(bc(0x01, 0x22, 0, 0xFFF0)), isNull); + expect(parseMiScale2Records(rec(0x21, 0xFFF0)), isEmpty); + expect(parseMiScale2History(rec(0x21, 0xFFF0)), isEmpty); + }); + test('body composition history: every 13-byte record, no stability ' + 'filter; a zero weight is skipped', () { + final r = parseMiBodyCompositionRecords([ + ...bc(0, 0x22, 500, 14368), + ...bc(0, 0x02, 0, 14000), + ...bc(0, 0x20, 0, 0), + ...bc(0, 0x20, 0, 14400), + ]); + expect(r.map((x) => x.kg), [71.84, 70.0, 72.0]); + expect(r.first.impedanceOhm, 500); + }); + test('scale 2: packed 10-byte records, lb, jin and stone', () { + final r = parseMiScale2Records([...rec(0x20, 14368), ...rec(0x21, 15800)]); + expect(r.map((x) => x.kg.toStringAsFixed(2)), ['71.84', '71.67']); + expect(parseMiScale2Records(rec(0x30, 14368)).single.kg, 71.84); + expect(parseMiScale2Records(rec(0x31, 1100)).single.kg, + closeTo(69.85, 0.01)); + }); + test('scale 2 live: an unstable record skips itself, not the rest', () { + final r = parseMiScale2Records( + [...rec(0x00, 1), ...rec(0xa0, 14000), ...rec(0x20, 14368)]); + expect(r.single.kg, 71.84); + }); + test('scale 2 history: no stability filter; empty, zero and overload ' + 'records are skipped', () { + final r = parseMiScale2History([ + ...rec(0xa2, 14000), + ...rec(0x00, 14000), + ...rec(0x62, 14200), + ...rec(0x22, 0), + ...rec(0x02, 14368), + ]); + expect(r.map((x) => x.kg), [70.0, 71.0, 71.84]); + }); + test('history request is little-endian; count, stop and end marker', () { + expect(miScaleHistoryRequest(1), [1, 1, 0, 0, 0]); + expect(miScaleHistoryRequest(0x01020304), [1, 4, 3, 2, 1]); + expect(miScaleHistoryCount([0x01, 0x02, 0x01]), 258); + expect(miScaleHistoryCount([0x01, 0x02]), isNull); + expect(miScaleHistoryDone([0x03]), isTrue); + expect(miScaleHistoryDone([]), isTrue); + expect(miScaleHistoryDone([0x03, 0]), isFalse); + expect(kMiScaleHistoryStop, [0x03]); + }); + test('user mode command per scale', () { + expect(miScaleUserModeCommand(composition: true), [6, 11, 0, 0]); + expect(miScaleUserModeCommand(composition: false), [3, 1, 0, 0, 0]); + }); + test('clock: UTC components, Sunday = 1, two zero bytes', () { + expect(miScaleClockValue(DateTime.utc(2026, 10, 4, 7, 5, 9, 500)), + [0xea, 0x07, 10, 4, 7, 5, 9, 1, 0, 0]); + expect(miScaleClockValue(DateTime.utc(2026, 10, 10))[7], 7); + }); + }); +} diff --git a/test/ultrahuman_test.dart b/test/ultrahuman_test.dart index 931a381..4325666 100644 --- a/test/ultrahuman_test.dart +++ b/test/ultrahuman_test.dart @@ -20,38 +20,39 @@ Uint8List _f32le(double v) { 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. +/// One 32-byte record, built field-by-field from the layout in +/// `ultrahuman.dart`. List _record({ int tsA = 1700000000, int hr = 58, int hrv = 42, int spo2 = 97, - int measurementType = 1, + int hrQuality = kUltrahumanHrQualityLegacy, int tsB = 1700000000, - double maxSkinTempC = 34.5, - double minSkinTempC = 33.8, + double skinTempC = 34.5, + double ambientTempC = 26.0, int tsC = 1700000000, - int activityLevel = 12, + int activity = 12, int steps = 30, - int stress = 20, + int hrvSdnn = 20, + int tempQuality = 3, + int index = 0x1234, }) => [ ..._u32le(tsA), hr, hrv, spo2, - measurementType, + hrQuality, ..._u32le(tsB), - ..._f32le(maxSkinTempC), - ..._f32le(minSkinTempC), + ..._f32le(skinTempC), + ..._f32le(ambientTempC), ..._u32le(tsC), - ..._u16le(activityLevel), + ..._u16le(activity), ..._u16le(steps), - ..._u16le(stress), - 0x00, 0x00, // bytes 30-31 — undocumented, not read by the decoder + hrvSdnn, + tempQuality, + ..._u16le(index), ]; void main() { @@ -111,11 +112,23 @@ void main() { }); 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); + 'an ok 0x04 reply whose count byte does not match its payload still ' + 'yields every whole record, and never the trailer', () { + final rec = _record(tsA: 7); + final r = parseUltrahumanResponse( + [0x04, 0x00, 3, ...rec, 0x11, 0x22, 0xaa, 0xbb])!; + expect(r.ok, isTrue); + expect(r.trailer, [0xaa, 0xbb]); + expect(parseUltrahumanRecords(r.payload).map((x) => x.tsA), [7]); + }); + + test('every result other than ok and empty is a failure', () { + for (final code in [0x03, 0x05, 0x07, 0x0a, 0xff]) { + expect(parseUltrahumanResponse([0x04, code, 0, 0, 0])!.failed, isTrue, + reason: 'result 0x${code.toRadixString(16)}'); + } + expect(parseUltrahumanResponse([0x04, 0xee, 0, 0, 0])!.failed, isFalse); + expect(parseUltrahumanResponse([0x04, 0x00, 0, 0, 0])!.failed, isFalse); }); test('a fail/empty result is still framed even if count looks off', () { @@ -126,12 +139,36 @@ void main() { }); 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', () { + test('a record is exactly 32 bytes and ends with its own index', () { final bytes = _record(); expect(bytes.length, kUltrahumanRecordLen); final r = parseUltrahumanRecord(bytes, 0)!; - expect(r.stress, 20); // the last documented field, at offset 28-29 + expect(r.index, 0x1234); + }); + + test('activity is a u16 across bytes 24-25; byte 28 is SDNN, unscaled', + () { + final r = parseUltrahumanRecord( + _record(activity: 0x0302, hrvSdnn: 255, tempQuality: 9), 0)!; + expect(r.activity, 0x0302); + expect(r.hrvSdnn, 255); + expect(r.tempQuality, 9); + }); + + test('bytes 12-15 are skin temperature, 16-19 the ambient sensor', () { + final r = parseUltrahumanRecord( + _record(skinTempC: 35.25, ambientTempC: 21.5), 0)!; + expect(r.skinTempC, 35.25); + expect(r.ambientTempC, 21.5); + }); + + test('only contact-class quality values are valid readings', () { + for (final q in [0, 1, 5, 6, 7, 9, 11]) { + expect(ultrahumanHrQualityValid(q), isTrue, reason: '$q'); + } + for (final q in [2, 10, 14, 15, 100, 110, 150, 151]) { + expect(ultrahumanHrQualityValid(q), isFalse, reason: '$q'); + } }); test('every field lands at its documented offset', () { @@ -140,28 +177,28 @@ void main() { hr: 61, hrv: 45, spo2: 98, - measurementType: kUltrahumanMeasureExercise, + hrQuality: kUltrahumanHrQualityCdtHr, tsB: 1700000002, - maxSkinTempC: 35.1, - minSkinTempC: 34.0, + skinTempC: 35.1, + ambientTempC: 24.0, tsC: 1700000003, - activityLevel: 88, + activity: 88, steps: 12, - stress: 40, + hrvSdnn: 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.hrQuality, kUltrahumanHrQualityCdtHr); expect(r.tsB, 1700000002); - expect(r.maxSkinTempC, closeTo(35.1, 1e-4)); - expect(r.minSkinTempC, closeTo(34.0, 1e-4)); + expect(r.skinTempC, closeTo(35.1, 1e-4)); + expect(r.ambientTempC, closeTo(24.0, 1e-4)); expect(r.tsC, 1700000003); - expect(r.activityLevel, 88); + expect(r.activity, 88); expect(r.steps, 12); - expect(r.stress, 40); + expect(r.hrvSdnn, 40); }); test('the three timestamps are independent, not collapsed to one', () { diff --git a/test/wearfit_test.dart b/test/wearfit_test.dart deleted file mode 100644 index 87db653..0000000 --- a/test/wearfit_test.dart +++ /dev/null @@ -1,116 +0,0 @@ -// The WearFit wire format, against real captured bytes: a documented -// find-watch request, a battery request/reply pair, and a device-info reply, -// each shown byte-for-byte in the family's own protocol notes. Nothing here -// was copied from anyone's decoder — the frame envelope is small enough that -// an independent read of the bytes is the whole of the proof. - -import 'package:test/test.dart'; -import 'package:openstrap_protocol/openstrap_protocol.dart'; - -void main() { - group('WearFit framing', () { - test('parses a real captured battery reply (not charging, 80%)', () { - // AB 00 05 FF 91 80 00 50 - final f = parseWearFitFrame([0xab, 0x00, 0x05, 0xff, 0x91, 0x80, 0x00, 0x50]); - expect(f, isNotNull); - expect(f!.opcode, 0x91); - expect(f.payload, [0x80, 0x00, 0x50]); - }); - - test('parses a real captured battery reply (charging, 80%)', () { - // AB 00 05 FF 91 80 01 50 - final f = parseWearFitFrame([0xab, 0x00, 0x05, 0xff, 0x91, 0x80, 0x01, 0x50]); - expect(f!.payload, [0x80, 0x01, 0x50]); - }); - - test('parses a real captured device-info reply', () { - // AB 00 11 FF 92 C0 08 04 38 00 00 00 00 00 00 28 00 60 00 6B - final f = parseWearFitFrame([ - 0xab, 0x00, 0x11, 0xff, 0x92, // - 0xc0, 0x08, 0x04, 0x38, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x28, 0x00, 0x60, 0x00, 0x6b, - ]); - expect(f, isNotNull); - expect(f!.opcode, 0x92); - expect(f.payload.length, 15); // len 0x11 - 2 - }); - - test('rejects a frame missing the 0xFF marker', () { - expect(parseWearFitFrame([0xab, 0x00, 0x05, 0x00, 0x91, 0x80, 0x00, 0x50]), isNull); - }); - - test('rejects a truncated frame', () { - // declares len 5 (3 payload bytes) but only one is present - expect(parseWearFitFrame([0xab, 0x00, 0x05, 0xff, 0x91, 0x80]), isNull); - }); - - test('rejects a length too short to carry an opcode-following byte', () { - expect(parseWearFitFrame([0xab, 0x00, 0x01, 0xff, 0x91]), isNull); - }); - - test('rejects a frame with the wrong header byte', () { - expect(parseWearFitFrame([0xac, 0x00, 0x05, 0xff, 0x91, 0x80, 0x00, 0x50]), isNull); - }); - - test('buildWearFitFrame round-trips through parseWearFitFrame', () { - final built = buildWearFitFrame(0x71, const [0x80]); - // AB 00 03 FF 71 80 — the real captured "find watch" request. - expect(built, [0xab, 0x00, 0x03, 0xff, 0x71, 0x80]); - final parsed = parseWearFitFrame(built); - expect(parsed!.opcode, 0x71); - expect(parsed.payload, [0x80]); - }); - - test('buildWearFitFrame with no payload', () { - expect(buildWearFitFrame(0x20), [0xab, 0x00, 0x02, 0xff, 0x20]); - }); - - test('wearFitCmdGetBattery matches the real captured request', () { - // AB 00 04 FF 91 80 01 - expect(wearFitCmdGetBattery(), [0xab, 0x00, 0x04, 0xff, 0x91, 0x80, 0x01]); - }); - - test('buildWearFitFrame accepts the largest payload the length byte can hold', () { - final built = buildWearFitFrame(0x20, List.filled(253, 0x01)); - expect(built[2], 0xff); - expect(built.length, 258); - }); - - test('buildWearFitFrame rejects a payload the length byte cannot hold', () { - expect(() => buildWearFitFrame(0x20, List.filled(254, 0x01)), - throwsArgumentError); - }); - }); - - group('WearFit battery', () { - test('decodes not-charging at 80%', () { - final f = parseWearFitFrame([0xab, 0x00, 0x05, 0xff, 0x91, 0x80, 0x00, 0x50])!; - final b = parseWearFitBattery(f); - expect(b, isNotNull); - expect(b!.chargeState, 0); - expect(b.percent, 80); - }); - - test('decodes fully-charged at 100%', () { - // AB 00 05 FF 91 80 02 64 - final f = parseWearFitFrame([0xab, 0x00, 0x05, 0xff, 0x91, 0x80, 0x02, 0x64])!; - final b = parseWearFitBattery(f); - expect(b!.chargeState, 2); - expect(b.percent, 100); - }); - - test('refuses a non-battery opcode', () { - final f = parseWearFitFrame([0xab, 0x00, 0x03, 0xff, 0x71, 0x80])!; - expect(parseWearFitBattery(f), isNull); - }); - - test('refuses a battery frame too short to carry a percent', () { - final f = parseWearFitFrame([0xab, 0x00, 0x03, 0xff, 0x91, 0x80])!; - expect(parseWearFitBattery(f), isNull); - }); - - test('refuses an implausible percent', () { - final f = parseWearFitFrame([0xab, 0x00, 0x05, 0xff, 0x91, 0x80, 0x00, 0xc8])!; - expect(parseWearFitBattery(f), isNull); - }); - }); -} diff --git a/test/zetime_test.dart b/test/zetime_test.dart deleted file mode 100644 index 97abda2..0000000 --- a/test/zetime_test.dart +++ /dev/null @@ -1,54 +0,0 @@ -// MyKronoz ZeTime's command envelope — pinned against the documented wire -// layout, not against a captured device: nobody on this project owns one. - -import 'package:test/test.dart'; -import 'package:openstrap_protocol/openstrap_protocol.dart'; - -void main() { - test('battery request frame is the fixed 7-byte shape', () { - expect( - zetimeRequestFrame(kZeTimeCmdBattery), - [0x6f, 0x08, 0x70, 0x01, 0x00, 0x00, 0x8f], - ); - }); - - test('parses a battery reply and reads its level', () { - // [preamble][cmd][action][lenLo][lenHi][level][end] — one payload byte, - // so the declared length is 1 + 6 = 7 total. - final f = parseZeTimeFrame([0x6f, 0x08, 0x01, 0x01, 0x00, 63, 0x8f])!; - expect(f.cmd, kZeTimeCmdBattery); - expect(f.payload, [63]); - expect(zetimeBatteryLevel(f), 63); - }); - - test('refuses a level byte above 100 — not a real percentage', () { - final f = parseZeTimeFrame([0x6f, 0x08, 0x01, 0x01, 0x00, 200, 0x8f])!; - expect(zetimeBatteryLevel(f), isNull); - }); - - test('a non-battery frame has no battery level', () { - final f = parseZeTimeFrame([0x6f, 0x02, 0x01, 0x01, 0x00, 9, 0x8f])!; - expect(zetimeBatteryLevel(f), isNull); - }); - - test('refuses a short buffer', () { - expect(parseZeTimeFrame([0x6f, 0x08, 0x01, 0x01, 0x00]), isNull); - }); - - test('refuses a missing preamble', () { - expect(parseZeTimeFrame([0x00, 0x08, 0x01, 0x01, 0x00, 63, 0x8f]), isNull); - }); - - test('refuses a zero-length declaration', () { - expect(parseZeTimeFrame([0x6f, 0x08, 0x01, 0x00, 0x00, 0x8f]), isNull); - }); - - test('refuses a declared length that does not match the buffer', () { - // Declares a 2-byte payload but only 1 arrived. - expect(parseZeTimeFrame([0x6f, 0x08, 0x01, 0x02, 0x00, 63, 0x8f]), isNull); - }); - - test('refuses a missing end marker', () { - expect(parseZeTimeFrame([0x6f, 0x08, 0x01, 0x01, 0x00, 63, 0x00]), isNull); - }); -} From bc1603e22d4d2c587dbd6c2df26f71a0ddced920 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:47:22 +0530 Subject: [PATCH 2/5] Oura, Pebble, Mi scale: decoder fixes from the verification pass Oura: decode the 0x5d, 0x6f, 0x60, 0x80 and 0x86 records; a skipped clock set is not a time anchor. Pebble: name the nap/deep-nap/walk/run overlays and flag whether a datalog session belongs to the watch's own health service. Mi scale: document local-time stamps from a clock another app set. --- lib/src/miscale.dart | 4 +- lib/src/oura.dart | 416 +++++++++++++++++++++++++++++++++--------- lib/src/pebble.dart | 18 +- test/oura_test.dart | 256 +++++++++++++++++++++++--- test/pebble_test.dart | 4 +- 5 files changed, 581 insertions(+), 117 deletions(-) diff --git a/lib/src/miscale.dart b/lib/src/miscale.dart index f8030f2..ab2e47a 100644 --- a/lib/src/miscale.dart +++ b/lib/src/miscale.dart @@ -36,7 +36,9 @@ // history and re-sends it next time, which a timestamp-keyed upsert absorbs. // // TIMESTAMPS ARE UTC wall-clock components: the scale's clock is set in UTC -// ([miScaleClockValue]) and its stamps are read back the same way. +// ([miScaleClockValue]) and its stamps are read back the same way. A clock +// found on local time before it is set (another app set it) stamped the +// stored history local; the host reads those back as local. import 'health_thermometer.dart' show WallClock, gattDateTime; diff --git a/lib/src/oura.dart b/lib/src/oura.dart index 9fe5668..64d40b6 100644 --- a/lib/src/oura.dart +++ b/lib/src/oura.dart @@ -18,16 +18,19 @@ // * PROVEN by layout plus an independent physiological sanity check: the // temperature decoders. centi-degrees Celsius, and a worn ring reads // 33-35 C. -// * NOT DECODED AT ALL, on purpose: beat-to-beat intervals, SpO2, steps, -// raw PPG. Their layouts are bit-packed and this project has not one byte -// of any of them. A guessed bit order produces a resting -// 50 bpm read as 100 that passes every plausibility bound it is shown, so -// those frames are ARCHIVED VERBATIM instead (owner rulings R1-R3: capture -// everything, decode when someone has the hardware). `raw_archive` is never -// pruned and `LocalDb.redrivableArchiveReasons` is how they get re-decoded -// in place later. See the report accompanying this change for the layouts. -// * KNOWN LAYOUT, NOT VERIFIED HERE: the hypnogram. Seen on a Gen 3 only, -// never on a Ring 4/5. +// * NOT DECODED AT ALL, on purpose: steps, motion and raw PPG. Their field +// names or window lengths are not pinned, so those frames are ARCHIVED +// VERBATIM (owner rulings R1-R3: capture everything, decode when the +// layout is known). `raw_archive` is never pruned, so a decoder written +// later runs over them in place. +// * KNOWN LAYOUT, NOT VERIFIED HERE: the hypnogram (seen on a Gen 3 only, +// never on a Ring 4/5), the 5-minute HR/RMSSD pairs, SpO2, beat-to-beat +// intervals and always-on HR. None of these has met a capture of ours. +// Every decoder refuses a body its layout does not fit exactly, and a +// beat interval or a heart rate outside what a heart does is dropped by +// its caller: a bit order read wrong fails those bounds far more often +// than it passes them, but not always, which is why the wearable that +// carries them stays behind its flag (ASSUMPTIONS R6). // // TIME IS THE HARD PART, and it is not solved here. An event's envelope carries // a u32 of DECISECONDS on a clock whose epoch is not Unix and is not documented @@ -40,22 +43,23 @@ import 'dart:typed_data'; /// One frame off the notify characteristic: `[tag u8][len u8][payload…]`. /// /// `len` counts payload bytes only, so a frame is 2 + len bytes and cannot -/// exceed 257. There is no CRC, no sequence number and no fragmentation: one -/// BLE notification carries exactly one frame, which is why this returns a -/// single frame rather than a list. +/// exceed 257. There is no CRC (except on [kOuraEvtExtended]), no sequence +/// number and no fragmentation. One BLE notification can carry SEVERAL frames +/// back to back (the ring bundles history events); [parseOuraFrames] walks them +/// all, [parseOuraFrame] reads only the first. class OuraFrame { final int tag; final Uint8List payload; const OuraFrame(this.tag, this.payload); } -/// Parse one notification. Null when it cannot be a frame at all. +/// Parse the FIRST frame of one notification. Null when it cannot be a frame. /// -/// LENIENT IN ONE DIRECTION ONLY. The ring is known to append trailing bytes -/// past the declared length, so extra bytes are ignored. A declared length -/// LONGER than the buffer is the opposite case and it is a truncated frame — -/// this returns null rather than handing back a short payload that every -/// downstream length check would then treat as a real, complete record. +/// LENIENT IN ONE DIRECTION ONLY. Bytes past the declared length are ignored +/// here (they are the next bundled frame; see [parseOuraFrames]). A declared +/// length LONGER than the buffer is a truncated frame, and this returns null +/// rather than handing back a short payload that every downstream length check +/// would then treat as a real, complete record. OuraFrame? parseOuraFrame(List value) { if (value.length < 2) return null; final len = value[1]; @@ -63,10 +67,50 @@ OuraFrame? parseOuraFrame(List value) { return OuraFrame(value[0], Uint8List.fromList(value.sublist(2, 2 + len))); } +/// Every frame in one notification, in order. +/// +/// A truncated last frame ends the walk and is dropped; the frames before it +/// are kept. A standard event ([kOuraFirstEventTag]..[kOuraLastEventTag]) +/// declaring more than 18 payload bytes means the bundle is mis-framed, and +/// then NOTHING in it is trusted: the result is empty. +/// +/// [kOuraEvtExtended] is the one frame whose byte 1 is not a length: it is +/// `[0xfd][crc8][realTag][len][u64 time][body]`, 4 + len bytes in all. Its +/// payload here is everything after the tag byte; see [parseOuraExtendedEvent]. +List parseOuraFrames(List v) { + final out = []; + var off = 0; + while (off + 2 <= v.length) { + final tag = v[off]; + final int end; + if (tag == kOuraEvtExtended) { + if (off + 4 > v.length) break; + end = off + 4 + v[off + 3]; + } else { + final l = v[off + 1]; + if (tag >= kOuraFirstEventTag && tag <= kOuraLastEventTag && l > 18) { + return const []; + } + end = off + 2 + l; + } + if (end > v.length) break; + out.add(OuraFrame( + tag, + Uint8List.fromList( + v.sublist(off + (tag == kOuraEvtExtended ? 1 : 2), end)))); + off = end; + } + return out; +} + /// Tags at or above this are history-event frames; below it they are responses /// to something the host wrote. const int kOuraFirstEventTag = 0x41; +/// The last standard event tag. Above it only [kOuraEvtExtended] comes from +/// the ring; `0xfe` is an app-side event and never arrives here. +const int kOuraLastEventTag = 0x8f; + /// One history event: an envelope timestamp and a type-specific body. class OuraEvent { final int tag; @@ -78,36 +122,89 @@ class OuraEvent { const OuraEvent(this.tag, this.tsDs, this.body); } -/// The history event carried by [f], or null when [f] is a command response or -/// is too short to carry the 4-byte envelope timestamp. +/// The standard history event carried by [f], or null when [f] is a command +/// response, is outside [kOuraFirstEventTag]..[kOuraLastEventTag], or its +/// payload is outside the 4..18 bytes a standard event has. +/// +/// [kOuraEvtExtended] is NOT read here: its stamp is a u64 at a different +/// offset and of a unit this file has not pinned, so it must never reach a +/// decisecond cursor. See [parseOuraExtendedEvent]. OuraEvent? parseOuraEvent(OuraFrame f) { - if (f.tag < kOuraFirstEventTag) return null; - if (f.payload.length < 4) return null; + if (f.tag < kOuraFirstEventTag || f.tag > kOuraLastEventTag) return null; + if (f.payload.length < 4 || f.payload.length > 18) return null; final ts = f.payload.buffer .asByteData(f.payload.offsetInBytes) .getUint32(0, Endian.little); return OuraEvent(f.tag, ts, Uint8List.sublistView(f.payload, 4)); } +/// The extended event header byte. See [parseOuraFrames]. +const int kOuraEvtExtended = 0xfd; + +/// One extended event: its real tag, its u64 ring time (unit not pinned +/// here, so NOT deciseconds and never a cursor), and its body. +class OuraExtendedEvent { + final int tag; + final int time; + final Uint8List body; + const OuraExtendedEvent(this.tag, this.time, this.body); +} + +/// The extended event in [f], or null when [f] is not one, its length is out +/// of range (8..251) or its CRC-8 (poly 0x31, init 0xff, over realTag..end) +/// does not match. +OuraExtendedEvent? parseOuraExtendedEvent(OuraFrame f) { + if (f.tag != kOuraEvtExtended || f.payload.length < 3) return null; + final p = f.payload; // [crc][realTag][len][u64 time][body] + final len = p[2]; + if (len < 8 || len > 251 || p.length != 3 + len) return null; + var crc = 0xff; + for (var i = 1; i < p.length; i++) { + crc ^= p[i]; + for (var b = 0; b < 8; b++) { + crc = (crc & 0x80) != 0 ? ((crc << 1) ^ 0x31) & 0xff : (crc << 1) & 0xff; + } + } + if (crc != p[0]) return null; + final d = p.buffer.asByteData(p.offsetInBytes); + // Two u32 halves, for the same dart2js reason as [ouraCmdSyncTime]. + final time = d.getUint32(3, Endian.little) + + d.getUint32(7, Endian.little) * 0x100000000; + return OuraExtendedEvent(p[1], time, Uint8List.sublistView(p, 11)); +} + // ── event tags this file has something to say about ──────────────────────── +/// The ring started (boot). Body byte 4 carries reset-reason flags; bit 1 set +/// means the decisecond counter restarted, so any origin measured before it +/// belongs to the previous boot. See [ouraRingStartResetsClock]. +const int kOuraEvtRingStart = 0x41; + /// Wall-clock the ring recorded when the host last set its RTC. It pairs a Unix -/// second with an envelope decisecond, so it anchors one clock to the other -/// (as does [kOuraEvtRtcBeacon]). +/// second with an envelope decisecond, so it anchors one clock to the other. const int kOuraEvtTimeSync = 0x42; /// An array of skin-temperature probes. const int kOuraEvtTemp = 0x46; +/// First record of a sleep period's summary. A new one starts a new period, so +/// hypnogram pages seen before it belong to the previous night. +const int kOuraEvtSleepSummary1 = 0x49; + /// A single skin-temperature reading. const int kOuraEvtTempPeriod = 0x69; -/// The RTC beacon, a wall-clock anchor the ring emits on its own. See -/// [decodeRtcBeacon]. -const int kOuraEvtRtcBeacon = 0x85; +/// The ring received a clock set and did NOT apply it. Not an anchor: no +/// decisecond is bridged to a date by it. See [decodeTimeSyncSkipped]. +const int kOuraEvtTimeSyncSkipped = 0x85; -/// Sleep-stage hypnogram carriers: `information`, `details`, and `data` -/// (numbered 14-byte pages, 52 epochs each). Same codes on all three. +/// UNDECODED. No layout is pinned for this tag; it is archived verbatim and +/// is not a hypnogram carrier. const int kOuraEvtSleepPhaseInformation = 0x4b; + +/// Sleep-stage hypnogram carriers: `details` and `data`. Both are numbered +/// pages of one hypnogram buffer (14-byte body: page index, then 52 epochs), +/// and a later page with the same index replaces an earlier one whichever of +/// the two tags carries it. const int kOuraEvtSleepPhaseDetails = 0x4e; const int kOuraEvtSleepPhaseData = 0x5a; @@ -117,17 +214,18 @@ const int kOuraEvtDebugData = 0x61; /// The frame that terminates one history batch. const int kOuraTagBatchSummary = 0x11; +/// True when [e] is a ring start whose reset flags say the decisecond counter +/// restarted (body byte 4, bit 1). +bool ouraRingStartResetsClock(OuraEvent e) => + e.tag == kOuraEvtRingStart && e.body.length >= 5 && (e.body[4] & 0x02) != 0; + /// Unix seconds the ring recorded for an RTC set, or null when the body is not /// the expected shape. /// -/// UNLIKE EVERYTHING ELSE THIS FILE DECODES, this specific body layout has no -/// real captured [kOuraEvtTimeSync] frame behind it — the direct-u32-LE-Unix -/// reading is the simplest shape consistent with the envelope's own proven -/// 4-byte-LE convention, and the plausibility window below is what stops a -/// wrong reading from silently anchoring a whole sync in the wrong decade -/// rather than refusing outright. Treat a genuinely captured [kOuraEvtTimeSync] -/// body as the thing to check this against first, before trusting it for -/// anything beyond that gate. +/// Layout: u64 LE Unix seconds at body[0..8), then an i8 timezone in +/// half-hour steps at body[8]. Only the low u32 is read: it is the whole value +/// until 2106. The date window below stops an unset RTC from anchoring a whole +/// sync in the wrong decade. int? decodeTimeSync(OuraEvent e) { if (e.tag != kOuraEvtTimeSync || e.body.length < 4) return null; final v = e.body.buffer @@ -139,23 +237,36 @@ int? decodeTimeSync(OuraEvent e) { return (v >= 1700000000 && v <= 4100000000) ? v : null; } -/// Unix seconds from an `rtc_beacon` (`0x85`) body, or null when [e] is not -/// one or the value is not a date. -/// -/// Layout: `u32` LE Unix seconds at offset 0, then reserved bytes and a `u16` -/// at offset 8 whose meaning is unknown. Bodies under 10 bytes are refused. -/// Same date window as [decodeTimeSync], so an unset RTC never becomes an -/// anchor. +/// Why a clock set was skipped: 0 unknown, 1 a PPG measurement was running. +const int kOuraSkipReasonUnknown = 0; +const int kOuraSkipReasonPpgMeasuring = 1; + +/// A clock set the ring did not apply ([kOuraEvtTimeSyncSkipped]). +class OuraTimeSyncSkipped { + /// The Unix second the host asked for. + final int unix; + + /// The timezone the host sent, in half-hour steps (signed). + final int tzHalfHours; + + /// [kOuraSkipReasonUnknown], [kOuraSkipReasonPpgMeasuring], or another code. + final int reason; + + const OuraTimeSyncSkipped(this.unix, this.tzHalfHours, this.reason); +} + +/// The skipped clock set in [e], or null when [e] is not one, the body is +/// under 10 bytes, or the second is not a date. /// -/// Like [decodeTimeSync], this layout has no real captured [kOuraEvtRtcBeacon] -/// frame in this repo behind it, so check a real one against it before -/// trusting it for anything beyond the date window. -int? decodeRtcBeacon(OuraEvent e) { - if (e.tag != kOuraEvtRtcBeacon || e.body.length < 10) return null; - final v = e.body.buffer - .asByteData(e.body.offsetInBytes) - .getUint32(0, Endian.little); - return (v >= 1700000000 && v <= 4100000000) ? v : null; +/// Layout: u64 LE Unix seconds at body[0..8) (low u32 read, see +/// [decodeTimeSync]), i8 timezone in half-hours at body[8], u8 skip reason at +/// body[9]. +OuraTimeSyncSkipped? decodeTimeSyncSkipped(OuraEvent e) { + if (e.tag != kOuraEvtTimeSyncSkipped || e.body.length < 10) return null; + final d = e.body.buffer.asByteData(e.body.offsetInBytes); + final v = d.getUint32(0, Endian.little); + if (v < 1700000000 || v > 4100000000) return null; + return OuraTimeSyncSkipped(v, d.getInt8(8), e.body[9]); } /// Skin temperature in degrees Celsius, one entry per probe. @@ -168,9 +279,16 @@ int? decodeRtcBeacon(OuraEvent e) { /// Which physical probe each array position is remains unknown, and one of them /// may be an ambient reference rather than skin. A caller that needs "the" skin /// temperature must therefore NOT average them. +/// +/// Length bounds per tag: [kOuraEvtTemp] is 2 to 7 readings (an even 4..14 +/// bytes), [kOuraEvtTempPeriod] exactly one (2 bytes). Anything else is a +/// layout this file does not know and is refused. List? decodeTemperatures(OuraEvent e) { if (e.tag != kOuraEvtTemp && e.tag != kOuraEvtTempPeriod) return null; - if (e.body.length < 2 || e.body.length.isOdd) return null; + final n = e.body.length; + if (e.tag == kOuraEvtTemp ? (n < 4 || n > 14 || n.isOdd) : n != 2) { + return null; + } final d = e.body.buffer.asByteData(e.body.offsetInBytes); final out = []; for (var i = 0; i + 1 < e.body.length; i += 2) { @@ -192,8 +310,8 @@ enum OuraSleepPhase { /// One hypnogram event: the carrier's header byte and one stage per 30 s epoch. class OuraSleepPhases { - /// Passed through uninterpreted. On `0x5a` it is a page counter, not an - /// epoch offset; on `0x4b`/`0x4e` its meaning is unknown. + /// The page index (0..35) in the night's hypnogram buffer, the same on + /// `0x4e` and `0x5a`. Not an epoch offset. final int header; /// Stages in body order, one per 30 s epoch. @@ -205,9 +323,9 @@ class OuraSleepPhases { /// The hypnogram carried by [e]: a header byte, then 2-bit stage codes packed /// four to a byte, MSB-first. Null when [e] is not a hypnogram carrier or has /// no codes. Absolute timing is left to the caller (from [OuraEvent.tsDs]). +/// [kOuraEvtSleepPhaseInformation] is not a carrier and comes back null. OuraSleepPhases? decodeSleepPhases(OuraEvent e) { - if (e.tag != kOuraEvtSleepPhaseInformation && - e.tag != kOuraEvtSleepPhaseDetails && + if (e.tag != kOuraEvtSleepPhaseDetails && e.tag != kOuraEvtSleepPhaseData) { return null; } @@ -222,6 +340,96 @@ OuraSleepPhases? decodeSleepPhases(OuraEvent e) { return OuraSleepPhases(e.body[0], phases); } +/// Five-minute heart rate and RMSSD pairs, one per window, oldest first. +const int kOuraEvtHrv = 0x5d; + +/// SpO2: a header byte, then one saturation per second. +const int kOuraEvtSpo2 = 0x6f; + +/// Six beat-to-beat intervals with their pulse amplitudes, bit-packed. +const int kOuraEvtIbiAmplitude = 0x60; + +/// Beat-to-beat intervals with a quality code, two bytes each (newer rings). +const int kOuraEvtGreenIbiQuality = 0x80; + +/// Always-on heart rate: a short burst of readings 1920 ms apart. +const int kOuraEvtAohr = 0x86; + +/// The seconds between two [kOuraEvtHrv] windows. +const int kOuraHrvWindowSec = 300; + +/// The `(bpm, rmssd ms)` pairs of a [kOuraEvtHrv] event, one per +/// [kOuraHrvWindowSec] window in body order, or null when [e] is not one or +/// its body is not whole pairs. A zero is "no reading" in either field and +/// is left for the caller to drop. +List<(int bpm, int rmssdMs)>? decodeHrvPairs(OuraEvent e) { + if (e.tag != kOuraEvtHrv) return null; + final b = e.body; + if (b.isEmpty || b.length.isOdd) return null; + return [for (var i = 0; i + 1 < b.length; i += 2) (b[i], b[i + 1])]; +} + +/// The SpO2 percentages of a [kOuraEvtSpo2] event, one per second in body +/// order, or null when [e] is not one, carries no sample, or any sample is +/// over 100: a saturation over 100 % means the layout is wrong, and the +/// whole event is refused rather than half of it trusted. A trailing `0xff` +/// marks a run that continues in the next event and is not a sample; a zero +/// is "no reading", left for the caller. +List? decodeSpo2(OuraEvent e) { + if (e.tag != kOuraEvtSpo2 || e.body.length < 2) return null; + var end = e.body.length; + if (e.body[end - 1] == 0xff) end--; + if (end < 2) return null; + final out = e.body.sublist(1, end); + return out.any((v) => v > 100) ? null : out; +} + +/// The six beat-to-beat intervals (ms) of a [kOuraEvtIbiAmplitude] event, in +/// beat order, or null when [e] is not one or its body is not the fixed 14 +/// bytes. Each interval is 11 bits: the high 8 in bytes 0..5, the lowest bit +/// in bit 0 of bytes 6..11, and two middle bits packed into bytes 12..13 +/// (beat 0 at bits 7..6 of byte 12, beat 1 at bits 5..4, beat 2 at bits +/// 3..2, beat 3 at bits 1..0, beats 4 and 5 at bits 7..6 and 5..4 of byte +/// 13). The amplitudes in bits 7..1 of bytes 6..11 are not read. +List? decodeIbiAmplitude(OuraEvent e) { + if (e.tag != kOuraEvtIbiAmplitude || e.body.length != 14) return null; + final b = e.body; + int mid(int k) => k < 4 + ? (b[12] >> (6 - 2 * k)) & 0x03 + : (b[13] >> (6 - 2 * (k - 4))) & 0x03; + return [ + for (var k = 0; k < 6; k++) (b[k] << 3) | (mid(k) << 1) | (b[6 + k] & 1), + ]; +} + +/// The `(interval ms, quality)` beats of a [kOuraEvtGreenIbiQuality] event, +/// in beat order, or null when [e] is not one or its body is not whole +/// beats. Per beat: interval = byte 0 << 3 | byte 1 bits 2..0; quality = +/// byte 1 bits 4..3, where 1 is a clean beat. +List<(int ibiMs, int quality)>? decodeGreenIbiQuality(OuraEvent e) { + if (e.tag != kOuraEvtGreenIbiQuality) return null; + final b = e.body; + if (b.length < 2 || b.length.isOdd) return null; + return [ + for (var i = 0; i + 1 < b.length; i += 2) + ((b[i] << 3) | (b[i + 1] & 0x07), (b[i + 1] >> 3) & 0x03), + ]; +} + +/// The quality code of a clean beat in [decodeGreenIbiQuality]. +const int kOuraIbiQualityGood = 1; + +/// The `(bpm, quality)` readings of a [kOuraEvtAohr] event, 1920 ms apart in +/// body order, or null when [e] is not one or the body is not exactly its +/// declared count of readings: `[flag][offset][count u8][bpm, quality]...`. +/// What the quality code means is not pinned; a zero bpm is "no reading". +List<(int bpm, int quality)>? decodeAohr(OuraEvent e) { + if (e.tag != kOuraEvtAohr || e.body.length < 3) return null; + final n = e.body[2]; + if (e.body.length != 3 + 2 * n) return null; + return [for (var i = 0; i < n; i++) (e.body[3 + 2 * i], e.body[4 + 2 * i])]; +} + /// One `debug_data` (`0x61`) sub-record. /// /// Every field is null unless this subtype actually carries it. There is no @@ -293,13 +501,14 @@ OuraDebugData? decodeDebugData(List body) { return OuraDebugData(subtype, batteryPct: pct, batteryMv: mv); case kOuraDebugFuelGauge: - // [subtype][u16 LE charge counter][u16 LE millivolts][…]. The millivolts - // are the only field cross-checked against another record: this and + // [0] subtype, [1..3) u16 LE state of charge in 8.8 fixed point + // (percent = raw / 256), [3..5) u16 LE average millivolts, [5..9) i32 LE + // average current, [9..11) u16 LE remaining capacity, [11..14) signed + // 24-bit BIG-endian coulomb counter. Only the millivolts are read: they + // are the one field cross-checked against another record (this and // `0x24` agree to within 3 mV wherever they land near each other in the - // capture. The remaining fields track charge and load and are left alone - // — there is no consumer for them and no second source to check them - // against. - if (body.length < 5) return null; + // capture). A body too short for the whole layout is not this record. + if (body.length < 14) return null; final mv = body[3] | (body[4] << 8); if (!_plausibleCellMv(mv)) return null; return OuraDebugData(subtype, batteryMv: mv); @@ -319,25 +528,38 @@ OuraDebugData? decodeDebugData(List body) { bool _plausibleCellMv(int mv) => mv >= 2500 && mv <= 4500; /// The frame the ring sends to close one history batch. +/// +/// Any summary with at least one payload byte ends the batch. A field the +/// frame is too short to carry is -1 (unknown), never 0. class OuraBatchSummary { /// How many event frames this batch carried. final int received; - /// How many bytes of history the ring still holds. Zero means the drain is - /// complete — it is the ONLY completion signal on this path. + /// The ring's sleep-analysis progress. Above zero with [bytesLeft] at zero + /// means the ring is still producing this night's sleep events: the drain + /// is not over, it should ask again from the same cursor shortly. -1 when + /// absent. + final int sleepAnalysisProgress; + + /// How many bytes of history the ring still holds. Zero means nothing is + /// left right now; -1 when the frame does not carry it. final int bytesLeft; - const OuraBatchSummary(this.received, this.bytesLeft); + const OuraBatchSummary(this.received, this.bytesLeft, + {this.sleepAnalysisProgress = -1}); } /// The batch summary carried by [f], or null when [f] is something else. OuraBatchSummary? parseBatchSummary(OuraFrame f) { - if (f.tag != kOuraTagBatchSummary || f.payload.length < 6) return null; - final d = f.payload.buffer.asByteData(f.payload.offsetInBytes); - // payload[1] is a sleep-analysis progress byte. Read and discarded on - // purpose: it is progress information and NOT a gate on the drain, and - // treating it as one stalls a sync that is working. - return OuraBatchSummary(f.payload[0], d.getUint32(2, Endian.little)); + if (f.tag != kOuraTagBatchSummary || f.payload.isEmpty) return null; + final p = f.payload; + return OuraBatchSummary( + p[0], + p.length >= 6 + ? p.buffer.asByteData(p.offsetInBytes).getUint32(2, Endian.little) + : -1, + sleepAnalysisProgress: p.length >= 2 ? p[1] : -1, + ); } // ── outbound frames ──────────────────────────────────────────────────────── @@ -392,6 +614,10 @@ List ouraCmdSetAuthKey(List key) { int? ouraSetAuthKeyResult(OuraFrame f) => (f.tag == 0x25 && f.payload.isNotEmpty) ? f.payload[0] : null; +/// Key-install status: the ring's factory production-test flags were never +/// set, so it refuses any pairing key. A factory reset does NOT clear this. +const int kOuraSetAuthKeyProductionTestsMissing = 0x05; + /// Ask for a fresh authentication challenge. List ouraCmdAuthNonce() => const [0x2f, 0x01, 0x2b]; @@ -409,18 +635,30 @@ List ouraCmdAuthenticate(List cipher) { return [0x2f, 0x01 + cipher.length, 0x2d, ...cipher]; } -/// Turn the ring's asynchronous notifications on. `0x3f` is all six flags. -List ouraCmdSetNotifyFlags(int flags) => [0x1c, 0x01, flags & 0xff]; - -/// Set the ring's real-time clock: u64 LE Unix seconds, then a timezone in -/// half-hour steps. +/// Turn the ring's asynchronous notifications on. /// -/// This is what later produces a [kOuraEvtTimeSync] event, one of the two -/// measured bridges (with [kOuraEvtRtcBeacon]) between the ring's decisecond -/// counter and a date — so this write is not housekeeping, it is what makes the -/// timestamps meaningful. -List ouraCmdSyncTime(int unixSeconds, {int tzHalfHours = 0}) { - final b = Uint8List(9); +/// Flags: low battery 0x01, charging completed 0x02, user active 0x04, +/// background sync 0x08, sitting alert 0x10, power state changed 0x20, +/// gesture 0x80, special notification 0x100. 0x40 is unassigned. `0x3f` is +/// the six lowest; all seven one-byte flags are 0xbf. A flag above 0xff needs +/// the two-byte form, `[0x1c, 0x02, u16 LE]`. +List ouraCmdSetNotifyFlags(int flags) => flags > 0xff + ? [0x1c, 0x02, flags & 0xff, (flags >> 8) & 0xff] + : [0x1c, 0x01, flags & 0xff]; + +/// Set the ring's real-time clock: u64 LE Unix seconds, then the local UTC +/// offset in signed half-hour steps (`timeZoneOffset.inMinutes ~/ 30`). +/// +/// This is what later produces a [kOuraEvtTimeSync] event, the one measured +/// bridge between the ring's decisecond counter and a date, so this write is +/// not housekeeping, it is what makes the timestamps meaningful. The ring may +/// decline it (for example mid-measurement) and record +/// [kOuraEvtTimeSyncSkipped] instead; [force] asks it not to, and adds a +/// flags byte (bit 0) so the frame is `[0x12, 0x0a, …9 bytes, 0x01]`. Use it +/// only when there is no origin at all. +List ouraCmdSyncTime(int unixSeconds, + {int tzHalfHours = 0, bool force = false}) { + final b = Uint8List(force ? 10 : 9); final d = b.buffer.asByteData(); // Two 32-bit halves, not `setUint64`: Dart's web (dart2js) ByteData throws // UnsupportedError on the 64-bit accessors — JS numbers have no native @@ -431,7 +669,8 @@ List ouraCmdSyncTime(int unixSeconds, {int tzHalfHours = 0}) { d.setUint32(0, unixSeconds & 0xffffffff, Endian.little); d.setUint32(4, (unixSeconds >> 32) & 0xffffffff, Endian.little); b[8] = tzHalfHours & 0xff; - return [0x12, 0x09, ...b]; + if (force) b[9] = 0x01; + return [0x12, b.length, ...b]; } /// Request up to [maxEvents] history events at or after [startDs]. @@ -488,3 +727,8 @@ const int kOuraAuthNotOnboarded = 0x03; /// spinning against a ring that is simply waiting to be let in. bool ouraIsAuthRequired(OuraFrame f) => f.tag == 0x2f && f.payload.isNotEmpty && f.payload[0] == 0x2f; + +/// True when [f] is the ring rejecting the command whose request tag is +/// [reqTag] as unsupported: exactly `[0x30, 0x01, reqTag]`. +bool ouraIsUnsupported(OuraFrame f, int reqTag) => + f.tag == 0x30 && f.payload.length == 1 && f.payload[0] == reqTag; diff --git a/lib/src/pebble.dart b/lib/src/pebble.dart index 627e688..8d1aa5d 100644 --- a/lib/src/pebble.dart +++ b/lib/src/pebble.dart @@ -40,6 +40,10 @@ const int kPebbleTagOverlay = 84; const int kPebbleOverlaySleep = 1; const int kPebbleOverlayDeepSleep = 2; +const int kPebbleOverlayNap = 3; +const int kPebbleOverlayDeepNap = 4; +const int kPebbleOverlayWalk = 5; +const int kPebbleOverlayRun = 6; // ── framing ────────────────────────────────────────────────────────────── @@ -132,13 +136,20 @@ class PebbleDatalogSession { final int sid; final int tag; final int itemSize; - const PebbleDatalogSession(this.sid, this.tag, this.itemSize); + + /// Whether the session's app uuid is all zeros: the watch's own health + /// service. A watch app logging under the same tag has its own uuid, and + /// its bytes are not health records. + final bool system; + const PebbleDatalogSession(this.sid, this.tag, this.itemSize, + {this.system = true}); } /// The session announced by an open message, or null if malformed. PebbleDatalogSession? parsePebbleDatalogOpen(List p) { if (p.length < 29 || p[0] != kPebbleDatalogOpen) return null; - return PebbleDatalogSession(p[1], _u32(p, 22), _u16(p, 27)); + return PebbleDatalogSession(p[1], _u32(p, 22), _u16(p, 27), + system: p.sublist(2, 18).every((b) => b == 0)); } /// The items of a data message, or null if malformed or not a whole number @@ -198,7 +209,8 @@ List? parsePebbleStepsItem(List b) { return out; } -/// One activity overlay: a sleep (1) or deep-sleep (2) period. +/// One activity overlay: sleep (1), deep sleep (2), nap (3), deep nap (4), +/// walk (5) or run (6). class PebbleOverlay { final int type; final int startSec; diff --git a/test/oura_test.dart b/test/oura_test.dart index 5d18b58..6676559 100644 --- a/test/oura_test.dart +++ b/test/oura_test.dart @@ -13,11 +13,11 @@ // correctness credential is a liability (ADDING_A_DEVICE 6.2): it proves // determinism, regression and physiological sanity. It does not prove // correctness, because there is no independent oracle for this band — nobody -// on this project owns a ring. The decoders that are NOT here (beat intervals, -// SpO2, steps, raw PPG) are absent precisely because there are no bytes to -// build such a fixture from, and shipping a guess would have this file -// faithfully encoding the wrong answer. The hypnogram vectors below are -// synthetic: they pin bit order and refusal, not real-ring correctness. +// on this project owns a ring. The decoders that are NOT here (steps, motion, +// raw PPG) are absent because their fields or windows are not pinned. The +// hypnogram and stream vectors below (HR/RMSSD pairs, SpO2, beat intervals, +// always-on HR) are synthetic: they pin bit order and refusal, not real-ring +// correctness. // // The NULL cases at the bottom are the load-bearing half: they are what proves // the decoder REFUSES rather than always producing something. @@ -26,6 +26,8 @@ // has no cipher implementation. See the session that drives this wire format // for that half of the auth handshake. +import 'dart:typed_data'; + import 'package:test/test.dart'; import 'package:openstrap_protocol/openstrap_protocol.dart'; @@ -65,13 +67,39 @@ void main() { expect(f.payload.length, 8); }); - test('trailing bytes past the declared length are ignored', () { - // The ring is known to append them. Eight declared, eleven delivered. + test('parseOuraFrame reads only the first frame of a notification', () { + // Eight declared, eleven delivered: the tail is the next bundled frame. final f = parseOuraFrame(_hex('110808009e0e00000300') + _hex('aabbcc'))!; expect(f.payload.length, 8); expect(parseBatchSummary(f)!.bytesLeft, 3742); }); + test('a bundled notification yields every frame, in order', () { + final fs = parseOuraFrames([ + 0x61, 0x05, 0xa3, 0x4d, 0x8f, 0x00, 0x24, // + 0x69, 0x06, 1, 0, 0, 0, 0x6c, 0x0d, // + ..._hex('110808009e0e00000300'), + ]); + expect(fs.map((f) => f.tag), [0x61, 0x69, 0x11]); + expect(parseOuraEvent(fs[1])!.tsDs, 1); + expect(parseBatchSummary(fs[2])!.bytesLeft, 3742); + }); + + test('a truncated last frame is dropped, the ones before it kept', () { + final fs = parseOuraFrames( + _hex('110808009e0e00000300') + _hex('aabbcc')); + expect(fs.map((f) => f.tag), [0x11]); + }); + + test('a standard event declaring more than 18 bytes drops the bundle', () { + expect( + parseOuraFrames([ + 0x61, 0x05, 0xa3, 0x4d, 0x8f, 0x00, 0x24, // + 0x46, 19, ...List.filled(19, 0), + ]), + isEmpty); + }); + test('a batch summary carries the count and the bytes still on the ring', () { // 8 events in this batch, 3742 bytes of history left to fetch. Zero is @@ -178,6 +206,18 @@ void main() { expect(parseOuraFrame(const [0x61, 20, 1, 2, 3]), isNull); }); + test('only 0x41..0x8f with a 4..18-byte payload is a standard event', () { + final ts = _hex('01000000'); + expect(parseOuraEvent(OuraFrame(0x8f, Uint8List.fromList(ts))), isNotNull); + expect(parseOuraEvent(OuraFrame(0x90, Uint8List.fromList(ts))), isNull); + expect(parseOuraEvent(OuraFrame(0xfd, Uint8List.fromList(ts))), isNull); + expect(parseOuraEvent(OuraFrame(0xfe, Uint8List.fromList(ts))), isNull); + expect( + parseOuraEvent( + OuraFrame(0x61, Uint8List.fromList([...ts, ...List.filled(15, 0)]))), + isNull); + }); + test('a command response is not an event, and a short envelope is not one', () { expect(parseOuraEvent(parseOuraFrame(_hex('0d03') + _hex('560100'))!), @@ -221,13 +261,26 @@ void main() { // array is more dangerous than none: it would publish one real probe and // silently hide that the offsets had moved. final good = parseOuraEvent( - parseOuraFrame(_hex('4606') + _hex('01000000') + _hex('1c0d'))!)!; + parseOuraFrame(_hex('6906') + _hex('01000000') + _hex('1c0d'))!)!; expect(decodeTemperatures(good), [33.56]); final bad = parseOuraEvent(parseOuraFrame( _hex('4608') + _hex('01000000') + _hex('1c0d3075'))!)!; expect(decodeTemperatures(bad), isNull); }); + test('temperature lengths are bounded per tag', () { + List? t(int tag, int n) => + decodeTemperatures(OuraEvent(tag, 0, Uint8List.fromList([ + for (var i = 0; i < n; i += 2) ...[0x1c, 0x0d] + ]))); + expect(t(kOuraEvtTemp, 2), isNull); + expect(t(kOuraEvtTemp, 16), isNull); + expect(t(kOuraEvtTempPeriod, 4), isNull); + expect(t(kOuraEvtTemp, 4), [33.56, 33.56]); + expect(t(kOuraEvtTemp, 14), hasLength(7)); + expect(t(kOuraEvtTempPeriod, 2), [33.56]); + }); + test('an odd-length temperature body is null', () { final e = parseOuraEvent( parseOuraFrame(_hex('4607') + _hex('01000000') + _hex('1c0d30'))!)!; @@ -244,23 +297,89 @@ void main() { parseOuraFrame(_hex('4208') + _hex('01000000') + _hex('00000000'))!)!; expect(decodeTimeSync(unset), isNull); }); - test('an rtc beacon is a little-endian unix second', () { + test('a skipped time sync decodes its second, timezone and reason', () { + // 1782043215 LE, tz -11 half-hours (0xf5), reason 1 (PPG running). final ok = parseOuraEvent(parseOuraFrame( - _hex('850e') + _hex('01000000') + _hex('4fd2376a00000000e803'))!)!; - expect(decodeRtcBeacon(ok), 1782043215); - // A 9-byte body, one short of the full layout, is not a beacon. + _hex('850e') + _hex('01000000') + _hex('4fd2376a00000000f501'))!)!; + final s = decodeTimeSyncSkipped(ok)!; + expect(s.unix, 1782043215); + expect(s.tzHalfHours, -11); + expect(s.reason, kOuraSkipReasonPpgMeasuring); + // It is not an anchor: decodeTimeSync ignores the tag. + expect(decodeTimeSync(ok), isNull); + // A 9-byte body, one short of the reason byte, is refused. final short = parseOuraEvent(parseOuraFrame( - _hex('850d') + _hex('01000000') + _hex('4fd2376a00000000e8'))!)!; - expect(short.body.length, 9); - expect(decodeRtcBeacon(short), isNull); - // A different tag is not a beacon either. - final other = parseOuraEvent(parseOuraFrame( - _hex('420e') + _hex('01000000') + _hex('4fd2376a00000000e803'))!)!; - expect(decodeRtcBeacon(other), isNull); - // An unset RTC is not an anchor. + _hex('850d') + _hex('01000000') + _hex('4fd2376a00000000f5'))!)!; + expect(decodeTimeSyncSkipped(short), isNull); + // An unset second is refused. final unset = parseOuraEvent(parseOuraFrame( - _hex('850e') + _hex('01000000') + _hex('0100000000000000e803'))!)!; - expect(decodeRtcBeacon(unset), isNull); + _hex('850e') + _hex('01000000') + _hex('0100000000000000f501'))!)!; + expect(decodeTimeSyncSkipped(unset), isNull); + }); + + test('a ring start resets the clock only when reset bit 1 is set', () { + OuraEvent start(int flags) => + OuraEvent(kOuraEvtRingStart, 0, Uint8List.fromList([4, 0, 0, 0, flags])); + expect(ouraRingStartResetsClock(start(0x02)), isTrue); + expect(ouraRingStartResetsClock(start(0x32)), isTrue); + expect(ouraRingStartResetsClock(start(0x01)), isFalse); + expect( + ouraRingStartResetsClock( + OuraEvent(kOuraEvtRingStart, 0, Uint8List.fromList([4, 0, 0, 0]))), + isFalse); + }); + + test('an extended event checks its CRC-8 and reads a u64 time', () { + // CRC-8, poly 0x31, init 0xff, by the nibble table: an implementation + // independent of the bitwise one under test. + const tbl = [ + 0x00, 0x31, 0x62, 0x53, 0xc4, 0xf5, 0xa6, 0x97, // + 0xb9, 0x88, 0xdb, 0xea, 0x7d, 0x4c, 0x1f, 0x2e, + ]; + int crc8(List b) { + var c = 0xff; + for (final x in b) { + c ^= x; + c = ((c << 4) & 0xff) ^ tbl[c >> 4]; + c = ((c << 4) & 0xff) ^ tbl[c >> 4]; + } + return c; + } + // "123456789" is the standard check string; this CRC gives 0xf7. + expect(crc8('123456789'.codeUnits), 0xf7); + final rest = [0x5a, 10, 1, 0, 0, 0, 2, 0, 0, 0, 0xaa, 0xbb]; + final bytes = [0xfd, crc8(rest), ...rest]; + final fs = parseOuraFrames(bytes); + expect(fs, hasLength(1)); + expect(parseOuraEvent(fs.single), isNull, + reason: 'never read as a standard decisecond event'); + final x = parseOuraExtendedEvent(fs.single)!; + expect(x.tag, 0x5a); + expect(x.time, 0x200000001); + expect(x.body, [0xaa, 0xbb]); + bytes[1] ^= 1; + expect(parseOuraExtendedEvent(parseOuraFrames(bytes).single), isNull); + }); + + test('the ring rejecting a command as unsupported is told apart', () { + expect(ouraIsUnsupported(parseOuraFrame(_hex('300110'))!, 0x10), isTrue); + expect(ouraIsUnsupported(parseOuraFrame(_hex('300110'))!, 0x2f), isFalse); + expect(ouraIsUnsupported(parseOuraFrame(_hex('30021000'))!, 0x10), isFalse); + }); + + test('a short batch summary still ends the batch, with unknowns as -1', () { + final one = parseBatchSummary(parseOuraFrame(_hex('110103'))!)!; + expect(one.received, 3); + expect(one.sleepAnalysisProgress, -1); + expect(one.bytesLeft, -1); + final two = parseBatchSummary(parseOuraFrame(_hex('11020307'))!)!; + expect(two.sleepAnalysisProgress, 7); + expect(two.bytesLeft, -1); + expect(parseBatchSummary(parseOuraFrame(_hex('1100'))!), isNull); + }); + + test('a fuel-gauge body too short for its layout is null', () { + expect(decodeDebugData(_hex('14cf50c80fb2ffffffd53e0000')), isNull); }); }); @@ -285,6 +404,18 @@ void main() { _hex('4fd2376a00000000')); }); + test('the notify mask uses the two-byte form only above 0xff', () { + expect(ouraCmdSetNotifyFlags(0xbf), _hex('1c01bf')); + expect(ouraCmdSetNotifyFlags(0x1bf), _hex('1c02bf01')); + }); + + test('a forced clock set adds a flags byte with bit 0 set', () { + expect(ouraCmdSyncTime(1782043215, tzHalfHours: -11, force: true), + _hex('120a') + _hex('4fd2376a00000000') + _hex('f501')); + expect(ouraCmdSyncTime(1782043215, tzHalfHours: 11), + _hex('1209') + _hex('4fd2376a00000000') + _hex('0b')); + }); + test('a value past the low 32 bits still encodes correctly', () { // 0x1_00000001 = 4294967297. Low word 0x00000001, high word 0x00000001 // — proves the two-setUint32 split actually carries the high half, @@ -303,7 +434,7 @@ void main() { test('two-bit codes unpack MSB-first, four to a byte', () { // 0b00_01_10_11: LSB-first would hand back the reverse. - final out = decodeSleepPhases(hypnogram(0x4b, '001b'))!; + final out = decodeSleepPhases(hypnogram(0x4e, '001b'))!; expect(out.header, 0x00); expect(out.phases, [ OuraSleepPhase.deep, @@ -313,8 +444,8 @@ void main() { ]); }); - test('all three carrier tags decode the same codes', () { - for (final tag in [0x4b, 0x4e, 0x5a]) { + test('both carrier tags decode the same codes', () { + for (final tag in [0x4e, 0x5a]) { final out = decodeSleepPhases(hypnogram(tag, '01e4'))!; expect(out.phases, [ OuraSleepPhase.awake, @@ -335,7 +466,11 @@ void main() { }); test('a header-only body is null', () { - expect(decodeSleepPhases(hypnogram(0x4b, '00')), isNull); + expect(decodeSleepPhases(hypnogram(0x5a, '00')), isNull); + }); + + test('0x4b is not a hypnogram carrier', () { + expect(decodeSleepPhases(hypnogram(0x4b, '001b')), isNull); }); test('a non-hypnogram tag is null', () { @@ -343,6 +478,75 @@ void main() { }); }); + group('streams: bit order and refusal (synthetic vectors)', () { + OuraEvent ev(int tag, List body) => + OuraEvent(tag, 1000, Uint8List.fromList(body)); + + /// The 14-byte beat body for six intervals, the layout the decoder reads. + List ibiBody(List ibis) => [ + for (final i in ibis) i >> 3, + for (final i in ibis) 0x80 | (i & 1), + ((ibis[0] >> 1) & 3) << 6 | + ((ibis[1] >> 1) & 3) << 4 | + ((ibis[2] >> 1) & 3) << 2 | + ((ibis[3] >> 1) & 3), + ((ibis[4] >> 1) & 3) << 6 | ((ibis[5] >> 1) & 3) << 4 | 0x07, + ]; + + test('HR/RMSSD: one pair per 5-minute window, in body order', () { + expect(decodeHrvPairs(ev(kOuraEvtHrv, [60, 40, 62, 45, 58, 50])), + [(60, 40), (62, 45), (58, 50)]); + expect(decodeHrvPairs(ev(kOuraEvtHrv, [60, 40, 62])), isNull); + expect(decodeHrvPairs(ev(kOuraEvtSpo2, [60, 40])), isNull); + }); + + test('SpO2: a header byte, then one reading a second; 0xff continues', () { + expect(decodeSpo2(ev(kOuraEvtSpo2, [0x01, 97, 96, 98, 0xff])), + [97, 96, 98]); + expect(decodeSpo2(ev(kOuraEvtSpo2, [0x01, 97, 0])), [97, 0]); + expect(decodeSpo2(ev(kOuraEvtSpo2, [0x01, 97, 101])), isNull, + reason: 'over 100 % is a wrong layout, not a reading'); + expect(decodeSpo2(ev(kOuraEvtSpo2, [0x01, 0xff])), isNull); + expect(decodeSpo2(ev(kOuraEvtSpo2, [0x01])), isNull); + }); + + test('beat intervals: 11 bits, high byte, packed middle bits, low bit', () { + // 1003 ms = 0b111_1101_0_1_1: high 8 = 0x7d, middle 2 = 01, low 1 = 1. + // The amplitude bits (7..1 of bytes 6..11) are set and must not leak. + expect( + decodeIbiAmplitude(ev(kOuraEvtIbiAmplitude, + _hex('7d7d7d7d7d7d' '818181818181' '55' '57'))), + List.filled(6, 1003)); + const ibis = [800, 1001, 1234, 650, 1999, 300]; + expect(decodeIbiAmplitude(ev(kOuraEvtIbiAmplitude, ibiBody(ibis))), + ibis); + expect( + decodeIbiAmplitude( + ev(kOuraEvtIbiAmplitude, ibiBody(ibis).sublist(0, 13))), + isNull, + reason: 'the body is a fixed 14 bytes'); + }); + + test('clean-beat intervals with a quality code, two bytes a beat', () { + // 1257 ms at quality 1: 0x9d << 3 | 1, quality bits 4..3 = 01. + expect(decodeGreenIbiQuality(ev(kOuraEvtGreenIbiQuality, _hex('9d09'))), + [(1257, kOuraIbiQualityGood)]); + expect( + decodeGreenIbiQuality( + ev(kOuraEvtGreenIbiQuality, _hex('9d09' '7d10'))), + [(1257, 1), (1000, 2)]); + expect(decodeGreenIbiQuality(ev(kOuraEvtGreenIbiQuality, [0x9d])), + isNull); + }); + + test('always-on HR: exactly its declared count of readings', () { + expect(decodeAohr(ev(kOuraEvtAohr, [1, 0, 2, 55, 1, 56, 1])), + [(55, 1), (56, 1)]); + expect(decodeAohr(ev(kOuraEvtAohr, [1, 0, 3, 55, 1, 56, 1])), isNull); + expect(decodeAohr(ev(kOuraEvtAohr, [1, 0])), isNull); + }); + }); + group('authentication (non-cryptographic half)', () { test('the challenge is 15 bytes out of a 16-byte reply body', () { final f = parseOuraFrame( diff --git a/test/pebble_test.dart b/test/pebble_test.dart index da87f94..0402eea 100644 --- a/test/pebble_test.dart +++ b/test/pebble_test.dart @@ -36,7 +36,9 @@ void main() { test('datalog open and data', () { final open = [1, 7, ...List.filled(16, 0), ...u32(1), ...u32(81), 0, ...u16(4)]; final s = parsePebbleDatalogOpen(open)!; - expect((s.sid, s.tag, s.itemSize), (7, 81, 4)); + expect((s.sid, s.tag, s.itemSize, s.system), (7, 81, 4, true)); + final app = [...open]..[5] = 0xab; + expect(parsePebbleDatalogOpen(app)!.system, isFalse); final data = [2, 7, ...u32(0), ...u32(0), 1, 2, 3, 4, 5, 6, 7, 8]; expect(parsePebbleDatalogItems(data, 4), [[1, 2, 3, 4], [5, 6, 7, 8]]); expect(parsePebbleDatalogItems([...data, 9], 4), isNull); From a00a70297502eaa0d7e8c9cabdbc3feb45dbe507 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:09:53 +0530 Subject: [PATCH 3/5] oura: mark the multi-frame walker unverified on hardware The app reads one frame per notification; parseOuraFrames stays for a capture to check against. --- lib/src/oura.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/src/oura.dart b/lib/src/oura.dart index 64d40b6..e415a3f 100644 --- a/lib/src/oura.dart +++ b/lib/src/oura.dart @@ -69,6 +69,11 @@ OuraFrame? parseOuraFrame(List value) { /// Every frame in one notification, in order. /// +/// UNVERIFIED ON HARDWARE. Whether the ring bundles several frames into one +/// notification has not been seen on a real capture, so the app reads one +/// frame per notification ([parseOuraFrame]) and does not call this. It is +/// kept for a capture to check against. +/// /// A truncated last frame ends the walk and is dropped; the frames before it /// are kept. A standard event ([kOuraFirstEventTag]..[kOuraLastEventTag]) /// declaring more than 18 payload bytes means the bundle is mis-framed, and From 7cd6e3e182195d3ee06eeea0a9520c04a909a088 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:48:41 +0530 Subject: [PATCH 4/5] FIT: bound definition fields; Pebble: reject cut steps items and empty packets A FIT definition whose field list runs past the data now throws FormatException instead of reading out of bounds. A Pebble steps item short of its declared record count decodes to null, so it is never ACKed as read, and a PPoGATT packet size below 2 is refused instead of looping forever. --- lib/src/garmin_fit.dart | 5 +++++ lib/src/pebble.dart | 9 +++++++-- test/pebble_test.dart | 4 ++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/src/garmin_fit.dart b/lib/src/garmin_fit.dart index 09b223c..78d44c3 100644 --- a/lib/src/garmin_fit.dart +++ b/lib/src/garmin_fit.dart @@ -150,6 +150,7 @@ List parseFit(List bytes) { final global = d.getUint16(i + 2, endian); final n = b[i + 4]; i += 5; + if (i + 3 * n > end) throw const FormatException('FIT: truncated def'); final fields = <_Field>[]; for (var f = 0; f < n; f++) { fields.add(_Field(b[i], b[i + 1], b[i + 2] & 0x1f)); @@ -157,7 +158,11 @@ List parseFit(List bytes) { } var devBytes = 0; if (h & 0x20 != 0) { + if (i >= end) throw const FormatException('FIT: truncated def'); final nDev = b[i++]; + if (i + 3 * nDev > end) { + throw const FormatException('FIT: truncated def'); + } for (var f = 0; f < nDev; f++) { devBytes += b[i + 1]; i += 3; diff --git a/lib/src/pebble.dart b/lib/src/pebble.dart index 8d1aa5d..7938e6d 100644 --- a/lib/src/pebble.dart +++ b/lib/src/pebble.dart @@ -60,6 +60,9 @@ List pebbleFrame(int endpoint, List payload) => [ /// bytes (header included), numbering them from [firstSerial]. List> pebblePpogattPackets(List frame, int firstSerial, {int maxPacket = 20}) { + // One byte is the header: below 2 a packet carries nothing and the loop + // below never advances. + if (maxPacket < 2) throw ArgumentError.value(maxPacket, 'maxPacket'); final out = >[]; var serial = firstSerial; for (var i = 0; i < frame.length; i += maxPacket - 1) { @@ -187,7 +190,8 @@ const Set kPebbleStepsVersions = {5, 6, 7, 12, 13, 14}; /// Decodes one steps item: `u16 version | u32 ts | ?u8 | u8 recordLength | /// u8 recordCount | records`, one record per minute from `ts`. Null for an -/// unknown version — the caller must not ACK what it cannot read. +/// unknown version or an item cut short of its record count — the caller +/// must not ACK what it cannot read. List? parsePebbleStepsItem(List b) { if (b.length < 9) return null; final version = _u16(b, 0); @@ -201,7 +205,8 @@ List? parsePebbleStepsItem(List b) { final out = []; for (var r = 0; r < count; r++) { final o = 9 + r * recLen; - if (o + recLen > b.length) break; + // Short of its own count: a cut item, and a caller ACKs what decodes. + if (o + recLen > b.length) return null; final hr = version >= 7 ? b[o + 12] : 0; out.add(PebbleMinute(ts + r * 60, b[o], b[o + 1], _u16(b, o + 2), b[o + 4], hr == 0 ? null : hr)); diff --git a/test/pebble_test.dart b/test/pebble_test.dart index 0402eea..58f0622 100644 --- a/test/pebble_test.dart +++ b/test/pebble_test.dart @@ -53,6 +53,10 @@ void main() { expect(m.map((x) => (x.tsSec, x.steps, x.hr)), [(1791000000, 12, 58), (1791000060, 0, null)]); expect(parsePebbleStepsItem([...u16(99), ...item.sublist(2)]), isNull); + expect(parsePebbleStepsItem(item.sublist(0, item.length - 1)), isNull, + reason: 'cut short of its record count'); + expect(() => pebblePpogattPackets([1, 2], 0, maxPacket: 1), + throwsArgumentError); }); test('overlay: deep sleep period', () { From 89080bcfac7767b3a871fd3a7d9307f5f30dad26 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 10 Oct 2026 06:10:21 +0530 Subject: [PATCH 5/5] Huami: decode no minutes from a transfer with a counter gap --- lib/src/huami_legacy.dart | 6 +++++- test/huami_legacy_test.dart | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/src/huami_legacy.dart b/lib/src/huami_legacy.dart index 82eb050..336540b 100644 --- a/lib/src/huami_legacy.dart +++ b/lib/src/huami_legacy.dart @@ -145,8 +145,12 @@ class HuamiActivityBuffer { } /// The minutes in this transfer, starting at [startSec]. A partial - /// trailing sample is ignored. + /// trailing sample is ignored. Empty after a counter gap ([ok] false): the + /// bytes past it would land on the wrong minutes. A transfer cut short is + /// still its contiguous prefix, so a caller resumes after the last minute + /// it got, not after the count the header announced. List minutes(int startSec, {int previousKind = 1}) { + if (!ok) return const []; final out = []; var kind = previousKind; for (var i = 0; i + 4 <= bytes.length; i += 4) { diff --git a/test/huami_legacy_test.dart b/test/huami_legacy_test.dart index ee9defa..5e9aa7e 100644 --- a/test/huami_legacy_test.dart +++ b/test/huami_legacy_test.dart @@ -47,6 +47,7 @@ void main() { test('a counter gap marks the transfer untrustworthy', () { final buf = HuamiActivityBuffer()..add([0, 1, 0, 0, 0])..add([2, 1, 0, 0, 0]); expect(buf.ok, isFalse); + expect(buf.minutes(1000), isEmpty); expect(huamiFetchDone([0x10, 0x02, 0x01]), isTrue); });