Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
9d5481a
gate the r10 decoders on the packet type
abdulsaheel Oct 17, 2026
f948597
parseR10Lite now refuses short historical records with layout byte 10
abdulsaheel Oct 17, 2026
747e6ea
reassembler no longer holds a history_end behind a truncated frame
abdulsaheel Oct 17, 2026
e69502f
hello battery reads past the field when the band is below 1%
abdulsaheel Oct 17, 2026
6568ddf
huami clock: send sunday as 7, not 0
abdulsaheel Oct 17, 2026
f4ae06a
frame oura extended events correctly in parseOuraFrame
abdulsaheel Oct 17, 2026
b8e8cc6
stop the garmin protobuf walker throwing on a bad length varint
abdulsaheel Oct 17, 2026
b7639f0
colmi big-data reassembler restarts on a new reply instead of splicin…
abdulsaheel Oct 17, 2026
3223b21
garmin fit: a timestamp that isn't a plain integer now fails as a For…
abdulsaheel Oct 17, 2026
fbe95e7
drop the deprecated gen5 compat getters nothing calls anymore
abdulsaheel Oct 17, 2026
e2fbda3
pebble reassembler can drop a partial frame on a transport reset
abdulsaheel Oct 17, 2026
8993693
pebble reassembler only takes the next serial instead of clearing on …
abdulsaheel Oct 17, 2026
779ec2a
garmin fit views skip a NaN or Inf sample instead of throwing
abdulsaheel Oct 17, 2026
04fa1d8
drop the deprecated gen5 clock builders from the public api
abdulsaheel Oct 17, 2026
cfff47c
drop the rest of the deprecated gen5 v26 compat surface
abdulsaheel Oct 17, 2026
30d7ace
flag frames the reassembler gives up as truncated
abdulsaheel Oct 17, 2026
efc524e
keep a history_end held behind a truncated frame unless the caller op…
abdulsaheel Oct 17, 2026
b15a6cc
stop trusting battery_level millivolts and charging from a garbage body
abdulsaheel Oct 17, 2026
f8ee613
drop the deprecated channel0/channel1AdcOffset getters on the gen5 op…
abdulsaheel Oct 17, 2026
a44ac47
pebble reassembler tells the caller to re-ack a repeated packet
abdulsaheel Oct 17, 2026
e0eb92d
gen5 hello battery dropped the tenths and let raw 1001..1009 through …
abdulsaheel Oct 17, 2026
d734064
drop the deprecated ecg opcode aliases and builders
abdulsaheel Oct 17, 2026
0d56c90
drop the unused decodeBatch helper
abdulsaheel Oct 17, 2026
bf203cf
truncated-frame give-up could resume inside the frame's own header
abdulsaheel Oct 18, 2026
d7a6ca3
colmi: don't drop a pending reply on a continuation that looks like a…
abdulsaheel Oct 28, 2026
b49a288
garmin: reject a 10-byte varint with bits past 63
abdulsaheel Oct 28, 2026
c9f78a0
battery level: read soc and millivolts as the u32s they are
abdulsaheel Oct 28, 2026
a1a7ce8
reject non-finite values before the int check in the fit reader
abdulsaheel Oct 28, 2026
58eaf87
ack every re-sent pebble packet that was already taken
abdulsaheel Oct 28, 2026
887771f
refuse FIT timestamps declared as floats
abdulsaheel Oct 29, 2026
9155f99
garmin stress: drop a sample whose own time is unreadable
abdulsaheel Oct 29, 2026
17854ab
garmin stress: a sent-but-invalid or float time field also drops the …
abdulsaheel Oct 29, 2026
0d3cb73
colmi test: a header-shaped continuation that completes with a bad crc
abdulsaheel Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 2 additions & 12 deletions lib/openstrap_protocol.dart
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,10 @@ export 'src/gen5_records.dart'
kGen5HistoricalDecoders,
kGen5V22KnownTags,
kGen5V22InnerLen,
// The exact lengths are the live names; the *MinInnerLen* aliases below
// are deprecated and exported only so existing callers still resolve.
kGen5V18InnerLen,
kGen5V26InnerLen,
kGen5V18MinInnerLen,
kGen5V20InnerLen,
kGen5V21InnerLen,
kGen5V26MinInnerLen,
kGen5V26MinInnerLenWithMeta,
kGen5AccelScaleG,
kGen5GyroScaleDps,
isGen5ImuBuffer,
Expand All @@ -96,8 +91,7 @@ export 'src/live.dart'
realtimeRr,
decodeR10Imu,
decodeR11Raw,
decodeRecord,
decodeBatch;
decodeRecord;

// Source 2 — CRC, constants, framing, commands.
export 'src/crc.dart' show crc8, crc32, crc16Modbus;
Expand Down Expand Up @@ -141,8 +135,6 @@ export 'src/commands.dart'
cmdDisableAlarm,
kDefaultAlarmHaptics,
gen5ClientHello,
cmdSetClockGen5,
cmdGetClockGen5,
cmdBuzzGen5Maverick,
cmdSetConfigGen5,
cmdSetDeviceConfigValueGen5,
Expand Down Expand Up @@ -170,9 +162,7 @@ export 'src/commands.dart'
cmdRawDataStart, // 81
cmdRawDataStop, // 82
cmdStopHaptics, // 122
// Filtered reading ("Labrador", R17). The wrong-bodied cmdEcgControl
// (124 as a bool) and the opcode-126 cmdEcgSendRaw stay deprecated
// and unexported.
// Filtered reading ("Labrador", R17).
cmdLabradorDataGeneration, // 124 — R17 lifecycle
cmdLabradorRawSave, // 125 — R17 lifecycle
cmdLabradorFiltered, // 139 — R17 lifecycle
Expand Down
75 changes: 68 additions & 7 deletions lib/src/colmi.dart
Original file line number Diff line number Diff line change
Expand Up @@ -143,24 +143,85 @@ List<int> colmiTemperatureRequest([int mode = 6]) =>
/// 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.
///
/// A notification that looks like the start of a new reply (0xbc plus a known
/// type) while one is still pending is ambiguous: either the pending reply
/// lost a notification and this is the next reply, or the pending reply's own
/// payload happens to start with those bytes. It is appended either way and
/// its offset remembered. The pending reply is given up for the one starting
/// there only when the evidence says so: the pending reply completes and fails
/// its CRC, or the later reply completes on its own with a valid CRC before
/// the pending one does. Restarting on the header shape alone discarded valid
/// replies whose payload resembled a header; appending blindly filled a lost
/// notification's gap with the next reply and lost that reply too.
class ColmiBigDataReassembler {
List<int>? _pending;
int _want = 0;
// Offsets inside [_pending] where a header-shaped notification began.
List<int> _restarts = <int>[];

static bool _startsReply(List<int> chunk) =>
chunk.length >= kColmiBigHeaderLength &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Short replies mix with pending data

When a new reply begins with the 0xbc/type prefix in a notification shorter than kColmiBigHeaderLength while another reply is pending, _startsReply returns false, so add appends the new reply's prefix to the pending reply; later chunks can complete a mixed response while the new reply is lost.

Handle a partial new-reply prefix without appending it to the pending reply.

Prompt for AI agents
In `lib/src/colmi.dart` at line 157:

**Short replies mix with pending data**

When a new reply begins with the 0xbc/type prefix in a notification shorter than `kColmiBigHeaderLength` while another reply is pending, `_startsReply` returns false, so `add` appends the new reply's prefix to the pending reply; later chunks can complete a mixed response while the new reply is lost.

Handle a partial new-reply prefix without appending it to the pending reply.

chunk[0] == kColmiCmdBigData &&
(chunk[1] == kColmiBigSleep ||
chunk[1] == kColmiBigNap ||
chunk[1] == kColmiBigSpo2 ||
chunk[1] == kColmiBigTemperature);

static int _wantOf(List<int> header) =>
kColmiBigHeaderLength + (header[2] | (header[3] << 8));

void _start(List<int> bytes, List<int> restarts) {
_want = _wantOf(bytes);
_pending = <int>[...bytes];
_restarts = restarts;
}

/// Restart from the header at [_restarts]`[idx]`, keeping later offsets.
void _restartAt(int idx) {
final p = _pending!;
final from = _restarts[idx];
_start(p.sublist(from),
[for (final o in _restarts.skip(idx + 1)) o - from]);
}

List<int>? add(List<int> chunk) {
if (_pending == null) {
if (chunk.length < kColmiBigHeaderLength ||
chunk[0] != kColmiCmdBigData) {
return null;
}
_want = kColmiBigHeaderLength + (chunk[2] | (chunk[3] << 8));
_pending = <int>[];
_start(chunk, <int>[]);
} else {
if (_startsReply(chunk)) _restarts.add(_pending!.length);
_pending!.addAll(chunk);
}
while (true) {
final p = _pending!;
if (p.length >= _want) {
final whole = p.sublist(0, _want);
if (_restarts.isEmpty || colmiBigDataCrcOk(whole)) {
_pending = null;
_restarts = <int>[];
return whole;
}
_restartAt(0);
continue;
}
// Still short: a later reply that is already whole and checks out means
// the pending one lost a notification and will never complete on its
// own bytes.
var done = -1;
for (var k = 0; k < _restarts.length && done < 0; k++) {
final o = _restarts[k];
final w = _wantOf(p.sublist(o, o + kColmiBigHeaderLength));
if (p.length - o >= w && colmiBigDataCrcOk(p.sublist(o, o + w))) {
done = k;
}
}
if (done < 0) return null;
_restartAt(done);
}
_pending!.addAll(chunk);
if (_pending!.length < _want) return null;
final whole = _pending!.sublist(0, _want);
_pending = null;
return whole;
}

bool get pending => _pending != null;
Expand Down
105 changes: 5 additions & 100 deletions lib/src/commands.dart
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,9 @@ Uint8List cmdGetClock(int seq, {BandProfile profile = BandProfile.gen4}) =>
/// SHARED ACROSS GENERATIONS — this is the correct builder for WHOOP 5 too:
/// gen5 takes `SET_CLOCK(10)` carrying
/// `<whole_seconds:u32le><subseconds:u32le>`, and a real gen5 strap answers
/// `SUCCESS` for exactly this 8-byte form. Prefer this over
/// [cmdSetClockGen5], whose opcode 146 is not an established WHOOP opcode.
/// `SUCCESS` for exactly this 8-byte form. There is no separate gen5 clock
/// builder: opcode 146 ("SET_CLOCK_MAVERICK") is not an established WHOOP
/// opcode, and a set the strap ignores fails silently with the RTC unlatched.
///
/// [now] defaults to `DateTime.now()`; pass a fixed instant for tests.
Uint8List cmdSetClock(int seq,
Expand Down Expand Up @@ -616,56 +617,6 @@ Uint8List cmdDisableAlarm(int seq,
Uint8List gen5ClientHello({int seq = 1}) =>
buildCommand(seq, Cmd.getHello, const [0x01], BandProfile.gen5);

// ── gen5 clock (SET_CLOCK_MAVERICK=146 / GET_CLOCK_GEN5=147) ───────────────
//
// gen5 replaces gen4's SET_CLOCK(0x0A)/GET_CLOCK(0x0B) with distinct opcode
// VALUES (§1.4). The bodies are NOT gen4's 8-byte two-u32 form — see each
// builder's own doc for the shape it sends. Still unverified against a real
// strap end to end: the bytes are right, but nothing here has watched an RTC
// actually latch.

/// gen5 SET_CLOCK_MAVERICK (146) — **UNVERIFIED; prefer [cmdSetClock] (10).**
///
/// Body is `[0x01][u32 epoch][u16 subsec]`.
///
/// Opcode **146 is not an established WHOOP opcode**, and no "Maverick clock"
/// command is known to exist. What IS established for gen5: `SET_CLOCK(10)`
/// with `<u32 sec><u32 subsec>`, for which a real gen5 strap answers
/// `SUCCESS` while `GET_CLOCK(11)` reads the time back.
///
/// So this builder sends a guess where a confirmed command exists. That is
/// especially dangerous for the clock: a rejected or no-op'd set is silent —
/// the RTC simply never latches, and every absolute timestamp afterwards
/// (alarms above all) is armed against a clock that was never set.
///
/// Kept only for source compatibility. Do NOT send it to "find out what it
/// does": a clock write is a mutation, not a read.
@Deprecated('opcode 146 is not an established opcode; gen5 '
'uses SET_CLOCK(10) — use cmdSetClock(seq, profile: BandProfile.gen5)')
Uint8List cmdSetClockGen5(int seq, {DateTime? now}) {
final ms = (now ?? DateTime.now()).millisecondsSinceEpoch;
final sec = ms ~/ 1000;
final subsec = ((ms % 1000) * 32768) ~/ 1000;
final payload = <int>[
revision1,
..._leTimestamp(sec, subsec),
0,
0,
];
return buildCommand(seq, Cmd.setClockMaverick, payload, BandProfile.gen5);
}

/// gen5 GET_CLOCK_GEN5 (147) — **UNVERIFIED; prefer [cmdGetClock] (11).**
///
/// Takes a `[0x01]` revision body. Same problem as [cmdSetClockGen5]: opcode
/// 147 is not an established WHOOP opcode, while `GET_CLOCK(11)` with an
/// EMPTY body is hardware-confirmed to answer on a real gen5 strap. Kept for
/// source compatibility only.
@Deprecated('opcode 147 is not an established opcode; gen5 '
'uses GET_CLOCK(11) — use cmdGetClock(seq, profile: BandProfile.gen5)')
Uint8List cmdGetClockGen5(int seq) =>
buildCommand(seq, Cmd.getClockGen5, const [revision1], BandProfile.gen5);

// ── gen5 Maverick haptics (RUN_HAPTIC_PATTERN_MAVERICK = 0x13/19) ──────────

/// gen5's Maverick haptic-buzz command — a DIFFERENT opcode from gen4's
Expand Down Expand Up @@ -990,10 +941,8 @@ Uint8List cmdStopHaptics(int seq, {BandProfile profile = BandProfile.gen4}) =>
// 125 TOGGLE_LABRADOR_RAW_SAVE 00=disable 01=enable
// 139 TOGGLE_LABRADOR_FILTERED 00=disable 01=enable
//
// The old `cmdEcg*` builders below modelled 124 as a boolean arm/disarm. That
// is wrong at the byte level, not just in naming: `01 01` — what the old
// builder sent to "arm" — is the STOP operation, and `01 00` is not an
// operation the strap defines at all.
// The bytes matter: sending `01 01` to 124 as an "arm" flag is the STOP
// operation, and `01 00` is not an operation the strap defines at all.

/// The operation byte of TOGGLE_LABRADOR_DATA_GENERATION (124) — the
/// filtered-reading (R17) lifecycle. It is an operation selector, not a
Expand Down Expand Up @@ -1064,47 +1013,3 @@ Uint8List cmdLabradorFiltered(int seq, bool on,
buildCommand(seq, Cmd.toggleLabradorFiltered, [revision1, on ? 0x01 : 0x00],
profile);

/// Arm / disarm an ECG reading (ECG main control, 0x7C) — `[0x01][0|1]`.
///
/// **Wrong bytes, kept only for source compatibility.** Opcode 124 is
/// TOGGLE_LABRADOR_DATA_GENERATION and its second byte is an operation, not a
/// flag: `cmdEcgControl(seq,
/// true)` emits `01 01`, which is the **STOP** operation, and
/// `cmdEcgControl(seq, false)` emits `01 00`, which is not a defined
/// operation at all. Use [cmdLabradorDataGeneration].
@Deprecated('opcode 124 takes an OPERATION byte, not a bool: this builder\'s '
'`true` sends 01 01 = the STOP operation, and `false` sends the undefined '
'01 00 — use cmdLabradorDataGeneration(seq, LabradorOperation.start)')
Uint8List cmdEcgControl(int seq, bool on,
{BandProfile profile = BandProfile.gen4}) =>
buildCommand(seq, Cmd.toggleLabradorDataGeneration,
[revision1, on ? 0x01 : 0x00], profile);

/// Start/stop the raw ECG trace (0x7E) — `[0x01][on]`.
///
/// **Wrong opcode, kept only for source compatibility.** 0x7E (126) is not
/// an established WHOOP 5 opcode — origin unknown, most likely gen4 or
/// third-party lore. The raw save of a filtered
/// reading is TOGGLE_LABRADOR_RAW_SAVE (125): use [cmdLabradorRawSave], which
/// emits the identical `01 00` / `01 01` body on the opcode the strap
/// actually implements.
@Deprecated('opcode 126 (0x7E) is not an established WHOOP 5 opcode '
'— the raw save is TOGGLE_LABRADOR_RAW_SAVE (125): use '
'cmdLabradorRawSave(seq, on)')
Uint8List cmdEcgSendRaw(int seq, bool on,
{BandProfile profile = BandProfile.gen4}) =>
// ignore: deprecated_member_use_from_same_package
buildCommand(
seq, Cmd.ecgSendRawData, [revision1, on ? 0x01 : 0x00], profile);

/// Start/stop the filtered ECG trace (0x8B) — `[0x01][on]`.
///
/// Body-correct: this is TOGGLE_LABRADOR_FILTERED (139) under its old name,
/// and the bytes are unchanged. Renamed only, so [cmdLabradorFiltered] is a
/// drop-in replacement.
@Deprecated('renamed: opcode 139 is TOGGLE_LABRADOR_FILTERED and the trace is '
'a filtered reading, not an ECG — use cmdLabradorFiltered(seq, on) '
'(identical bytes)')
Uint8List cmdEcgSendFiltered(int seq, bool on,
{BandProfile profile = BandProfile.gen4}) =>
cmdLabradorFiltered(seq, on, profile: profile);
24 changes: 0 additions & 24 deletions lib/src/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -163,30 +163,6 @@ class Cmd {
static const int toggleLabradorDataGeneration = 0x7C; // 124
static const int toggleLabradorRawSave = 0x7D; // 125
static const int toggleLabradorFiltered = 0x8B; // 139
// ── Deprecated "ECG" spellings of the three above ─────────────────────────
// The feature was previously modelled as an ECG arm/save/send matrix. The
// opcode NUMBERS 124/125/139 are right; the names and the arm/disarm mental
// model are not (see the operation table above).
@Deprecated('this is TOGGLE_LABRADOR_DATA_GENERATION (124): its body is an '
'operation byte (01=stop, 02=start, 03=restart), not an on/off flag — '
'use Cmd.toggleLabradorDataGeneration')
static const int ecgMainControl = 0x7C;
@Deprecated('this is TOGGLE_LABRADOR_RAW_SAVE (125), 01 00 / 01 01 — '
'use Cmd.toggleLabradorRawSave')
static const int ecgSaveRawData = 0x7D;
@Deprecated('opcode 126 (0x7E) is NOT an established WHOOP 5 opcode '
'— origin unknown, most likely gen4 '
'or third-party lore. The raw trace is TOGGLE_LABRADOR_RAW_SAVE (125): '
'use Cmd.toggleLabradorRawSave')
static const int ecgSendRawData = 0x7E;
@Deprecated('opcode 127 (0x7F) is NOT an established WHOOP 5 opcode '
'— origin unknown, most likely gen4 '
'or third-party lore. The filtered trace is TOGGLE_LABRADOR_FILTERED '
'(139): use Cmd.toggleLabradorFiltered')
static const int ecgSaveFilteredData = 0x7F;
@Deprecated('this is TOGGLE_LABRADOR_FILTERED (139), 01 00 / 01 01 — '
'use Cmd.toggleLabradorFiltered')
static const int ecgSendFilteredData = 0x8B;
// Signal-processing configuration. Advanced/diagnostic: it retunes the
// on-strap algorithms, so it can change what the strap itself reports.
static const int setSignalConfig = 0x8A;
Expand Down
Loading
Loading