diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index 7fa0a74..2f3ca4f 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -791,9 +791,9 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 52;
+ CURRENT_PROJECT_VERSION = 54;
GENERATE_INFOPLIST_FILE = YES;
- MARKETING_VERSION = 0.9.21;
+ MARKETING_VERSION = 0.9.23;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
@@ -809,9 +809,9 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 52;
+ CURRENT_PROJECT_VERSION = 54;
GENERATE_INFOPLIST_FILE = YES;
- MARKETING_VERSION = 0.9.21;
+ MARKETING_VERSION = 0.9.23;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
@@ -825,9 +825,9 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 52;
+ CURRENT_PROJECT_VERSION = 54;
GENERATE_INFOPLIST_FILE = YES;
- MARKETING_VERSION = 0.9.21;
+ MARKETING_VERSION = 0.9.23;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
@@ -849,7 +849,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = OpenStrapWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 52;
+ CURRENT_PROJECT_VERSION = 54;
DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)";
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -865,7 +865,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
- MARKETING_VERSION = 0.9.21;
+ MARKETING_VERSION = 0.9.23;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_IDENTIFIER)";
@@ -896,7 +896,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = OpenStrapWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 52;
+ CURRENT_PROJECT_VERSION = 54;
DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)";
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -912,7 +912,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
- MARKETING_VERSION = 0.9.21;
+ MARKETING_VERSION = 0.9.23;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -940,7 +940,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = OpenStrapWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 52;
+ CURRENT_PROJECT_VERSION = 54;
DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)";
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -956,7 +956,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
- MARKETING_VERSION = 0.9.21;
+ MARKETING_VERSION = 0.9.23;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -987,7 +987,7 @@
CODE_SIGN_ENTITLEMENTS = "OpenStrapWatch Watch App/OpenStrapWatch Watch App.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 52;
+ CURRENT_PROJECT_VERSION = 54;
DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1003,7 +1003,7 @@
"@executable_path/Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
- MARKETING_VERSION = 0.9.21;
+ MARKETING_VERSION = 0.9.23;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).watchkitapp";
@@ -1042,7 +1042,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = "OpenStrapWatch Watch App/OpenStrapWatch Watch App.entitlements";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 52;
+ CURRENT_PROJECT_VERSION = 54;
DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1058,7 +1058,7 @@
"@executable_path/Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
- MARKETING_VERSION = 0.9.21;
+ MARKETING_VERSION = 0.9.23;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).watchkitapp";
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -1093,7 +1093,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = "OpenStrapWatch Watch App/OpenStrapWatch Watch App.entitlements";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 52;
+ CURRENT_PROJECT_VERSION = 54;
DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1109,7 +1109,7 @@
"@executable_path/Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
- MARKETING_VERSION = 0.9.21;
+ MARKETING_VERSION = 0.9.23;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).watchkitapp";
PRODUCT_NAME = "$(TARGET_NAME)";
diff --git a/ios/Runner/AccessorySetup.swift b/ios/Runner/AccessorySetup.swift
index e057538..ee7c0a8 100644
--- a/ios/Runner/AccessorySetup.swift
+++ b/ios/Runner/AccessorySetup.swift
@@ -26,9 +26,13 @@ import AccessorySetupKit
/// - `removeAll` -> nil (deprovision all — used on unpair)
enum AccessorySetup {
private static let channelName = "openstrap/accessory_setup"
- // The WHOOP "Harvard" Gen4 GATT service (matches GattUuids.service in Dart).
- // `fileprivate` so the iOS-18 Impl below can read it.
- fileprivate static let whoopServiceUUID = "61080001-8d6d-82b8-614a-1c8cb0f8dcc6"
+ // WHOOP GATT service UUIDs, one per generation (match GattProfile in Dart).
+ // `fileprivate` so the iOS-18 Impl below can read them. BOTH must also be
+ // listed in Info.plist under NSAccessorySetupBluetoothServices.
+ // • gen4 ("Harvard", WHOOP 4) — 6108…
+ // • gen5 ("fd4b", WHOOP 5) — fd4b… (EXPERIMENTAL)
+ fileprivate static let whoopServiceUUIDGen4 = "61080001-8d6d-82b8-614a-1c8cb0f8dcc6"
+ fileprivate static let whoopServiceUUIDGen5 = "fd4b0001-cce1-4033-93ce-002d5875f58a"
static func register(messenger: FlutterBinaryMessenger) {
let channel = FlutterMethodChannel(name: channelName, binaryMessenger: messenger)
@@ -136,30 +140,35 @@ private final class Impl {
return
}
- let descriptor = ASDiscoveryDescriptor()
// Match on the WHOOP custom service UUID alone. The foreground scan finds the
- // band via startScan(withServices:[thisUUID]) and succeeds, which proves the
- // band advertises this service — so it's a reliable, sufficient filter. Every
- // descriptor criterion must be declared in Info.plist; the UUID is listed under
- // NSAccessorySetupBluetoothServices. (No bluetoothNameSubstring: a single
- // descriptor AND-combines its criteria, and a name filter would also require an
- // NSAccessorySetupBluetoothNames entry and risk excluding the band on a name
- // mismatch.)
- descriptor.bluetoothServiceUUID = CBUUID(string: AccessorySetup.whoopServiceUUID)
-
- // Show the actual strap render in the ASK pairing sheet (asset catalog →
- // StrapProduct.imageset). Fall back to an SF Symbol if the asset is missing.
+ // band via startScan(withServices:[…]) and succeeds, which proves the band
+ // advertises this service — so it's a reliable, sufficient filter. Every
+ // descriptor criterion must be declared in Info.plist; the UUIDs are listed
+ // under NSAccessorySetupBluetoothServices. (No bluetoothNameSubstring: a
+ // single descriptor AND-combines its criteria, and a name filter would also
+ // require an NSAccessorySetupBluetoothNames entry and risk excluding the band
+ // on a name mismatch.)
+ //
+ // ASK matches ANY item in the picker list, so we offer one item per WHOOP
+ // generation: gen4 (WHOOP 4) and gen5 (WHOOP 5, experimental). A band that
+ // advertises either service can be provisioned; the provisioned identifier is
+ // the same CoreBluetooth UUID regardless of generation.
let productImage = UIImage(named: "StrapProduct")
?? UIImage(systemName: "sensor.tag.radiowave.forward")
?? UIImage()
- let item = ASPickerDisplayItem(
- name: "WHOOP band",
- productImage: productImage,
- descriptor: descriptor
- )
+ func item(_ serviceUUID: String, _ name: String) -> ASPickerDisplayItem {
+ let descriptor = ASDiscoveryDescriptor()
+ descriptor.bluetoothServiceUUID = CBUUID(string: serviceUUID)
+ return ASPickerDisplayItem(
+ name: name, productImage: productImage, descriptor: descriptor)
+ }
+ let items = [
+ item(AccessorySetup.whoopServiceUUIDGen4, "WHOOP band"),
+ item(AccessorySetup.whoopServiceUUIDGen5, "WHOOP 5 band"),
+ ]
pickerResult = completion
- session.showPicker(for: [item]) { [weak self] error in
+ session.showPicker(for: items) { [weak self] error in
guard let self = self else { return }
if let error = error {
if let cb = self.pickerResult {
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index bbbd94d..d4142c7 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -54,6 +54,7 @@
NSAccessorySetupBluetoothServices
61080001-8D6D-82B8-614A-1C8CB0F8DCC6
+ FD4B0001-CCE1-4033-93CE-002D5875F58A
NSAccessorySetupKitSupports
diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart
index dbf6dfd..774cb45 100644
--- a/lib/ble/ble_engine.dart
+++ b/lib/ble/ble_engine.dart
@@ -84,6 +84,35 @@ typedef ArchiveSink = Future Function(ArchiveRecord archive);
/// trigger now that listening is continuous and there's no discrete sync end.
typedef DataStoredSink = void Function();
+/// Map a decoded gen5 historical record onto the band-agnostic `Sample` type,
+/// or null when this record kind has no `Sample` equivalent (yet).
+///
+/// Only `Gen5HistorySample` (v18, the per-second stream) maps today — the
+/// deep buffers (`Gen5OpticalBuffer`/`Gen5ImuBuffer`/`Gen5PpgWaveform`, R22
+/// opt-in only) need their own raw-buffer storage, not a 1Hz `Sample`, so
+/// they (and a null [g], e.g. an unrecognised version) correctly return null
+/// here — the caller archives those, exactly like an undecodable gen4
+/// record. Extracted as a top-level pure function (rather than inlined in
+/// `_ingestHistoricalFrame`) so the mapping is unit-testable without a live
+/// BLE session — see `gen5_sample_mapping_test.dart`.
+@visibleForTesting
+Sample? sampleFromGen5Historical(Gen5HistoricalRecord? g) {
+ if (g is! Gen5HistorySample) return null;
+ return Sample(
+ tsEpoch: g.unix,
+ counter: g.recordIndex,
+ hr: g.heartRate,
+ rrIntervalsMs: List.from(g.rrIntervalsMs),
+ // Gravity vector is float32 g-units on BOTH generations (unlike skin
+ // temp / SpO2, which use gen5-specific scales/mechanisms — see
+ // Gen5HistorySample's field docs) — safe to feed straight into the
+ // shared ax/ay/az fields analytics already reads band-agnostically.
+ ax: g.gravityG.isNotEmpty ? g.gravityG[0] : null,
+ ay: g.gravityG.length > 1 ? g.gravityG[1] : null,
+ az: g.gravityG.length > 2 ? g.gravityG[2] : null,
+ );
+}
+
@visibleForTesting
int countHistoricalBurstPackets({
required Map dataPacketCountsByRevision,
@@ -269,11 +298,27 @@ class _SessionGapSummary {
class _Session {
final BluetoothDevice device;
BluetoothCharacteristic? cmdTo;
+
+ /// Which WHOOP generation this link speaks. Defaults to gen4 (WHOOP 4) and is
+ /// pinned once during service discovery via [applyBand] — everything that
+ /// differs by generation (frame header/CRC, GATT UUIDs, command envelope,
+ /// history ACK, record decode) reads from here.
+ BandProfile band = BandProfile.gen4;
+
final Map asm = {
'cmd_from': FrameReassembler(),
'events': FrameReassembler(),
'data': FrameReassembler(),
};
+
+ /// Pin this session's generation and rebuild the reassemblers with the
+ /// matching header shape. Called once, at discovery, before any frame is fed.
+ void applyBand(BandProfile b) {
+ band = b;
+ asm['cmd_from'] = FrameReassembler(profile: b);
+ asm['events'] = FrameReassembler(profile: b);
+ asm['data'] = FrameReassembler(profile: b);
+ }
final List subs = [];
Timer? heartbeat;
// Session-owned timers; a disconnect cancels them.
@@ -354,6 +399,15 @@ class BleEngine {
final Duration Function() deriveDataStaleness;
final bool Function() isForegroundActive;
+ /// Opt-in: send the gen5 "R22" 16-flag SET_CONFIG enable sequence
+ /// (`kGen5R22EnableFlags`) before the historical offload on a gen5 link,
+ /// unlocking the v20 (optical)/v21 (IMU)/v26 (PPG) deep buffers. Defaults to
+ /// OFF — the official WHOOP app never sends this either, the sequence is
+ /// UNTESTED on physical hardware, and without it a gen5 strap still serves
+ /// its always-on v18 per-second stream perfectly well. Wire a caller-owned
+ /// settings read here to make it a real user-facing toggle.
+ final bool Function() gen5DeepBuffersEnabled;
+
BleEngine({
required this.onRecord,
required this.onState,
@@ -370,8 +424,11 @@ class BleEngine {
this.isBackgroundDrainer = false,
this.deriveDataStaleness = _defaultDeriveDataStaleness,
this.isForegroundActive = _defaultIsForegroundActive,
+ this.gen5DeepBuffersEnabled = _defaultGen5DeepBuffersDisabled,
});
+ static bool _defaultGen5DeepBuffersDisabled() => false;
+
/// True for the headless restore-drain engine (runHeadlessSync). It YIELDS the
/// band to a foreground engine rather than fighting it — see [_claimBand]. The
/// foreground app engine leaves this false and always wins.
@@ -910,7 +967,10 @@ class BleEngine {
await FlutterBluePlus.stopScan();
}
_setPhase(BleConnState.scanning);
- final svc = Guid(GattUuids.service);
+ // Advertise-filter on BOTH generations' service UUIDs (gen4 6108xxxx +
+ // gen5 fd4bxxxx); the actual generation is pinned later at discovery.
+ final gen4Svc = Guid(GattProfile.gen4.service);
+ final gen5Svc = Guid(GattProfile.gen5.service);
BluetoothDevice? found;
final sub = FlutterBluePlus.onScanResults.listen((results) {
for (final r in results) {
@@ -920,14 +980,16 @@ class BleEngine {
);
if (found == null &&
(name.contains('whoop') ||
- advNames.any((s) => s.startsWith('61080001')))) {
+ advNames.any((s) =>
+ s.startsWith('61080001') || s.startsWith('fd4b0001')))) {
found = r.device;
FlutterBluePlus.stopScan();
}
}
});
try {
- await FlutterBluePlus.startScan(withServices: [svc], timeout: timeout);
+ await FlutterBluePlus.startScan(
+ withServices: [gen4Svc, gen5Svc], timeout: timeout);
await FlutterBluePlus.isScanning.where((on) => on == false).first;
} catch (e) {
_log('scan error: $e');
@@ -1101,15 +1163,33 @@ class BleEngine {
final services = await device
.discoverServices()
.timeout(_serviceDiscoveryTimeout);
+ // Pin the generation from whichever service the peripheral exposes:
+ // gen4 "Harvard" 6108xxxx, or gen5 "fd4b" fd4bxxxx. This drives the frame
+ // header/CRC, command envelope, ACK, and record decode for the session.
BluetoothService? svc;
+ BandProfile band = BandProfile.gen4;
for (final s in services) {
- if (s.uuid.str.toLowerCase().startsWith('61080001')) svc = s;
+ final u = s.uuid.str.toLowerCase();
+ if (u.startsWith(GattProfile.gen4.servicePrefix)) {
+ svc = s;
+ band = BandProfile.gen4;
+ break;
+ }
+ if (u.startsWith(GattProfile.gen5.servicePrefix)) {
+ svc = s;
+ band = BandProfile.gen5;
+ break;
+ }
}
if (svc == null) {
- _log('Harvard service not found on device.');
+ _log('No WHOOP service (gen4 6108xxxx / gen5 fd4bxxxx) found on device.');
await _failConnect();
return false;
}
+ session.applyBand(band);
+ state.generation = band.isGen5 ? 'gen5' : 'gen4';
+ _log('Detected ${band.isGen5 ? "WHOOP 5 (gen5)" : "WHOOP 4 (gen4)"} link.');
+ final gatt = band.gatt;
BluetoothCharacteristic? find(String prefix) {
for (final c in svc!.characteristics) {
if (c.uuid.str.toLowerCase().startsWith(prefix)) return c;
@@ -1117,15 +1197,15 @@ class BleEngine {
return null;
}
- session.cmdTo = find('61080002');
- final cmdFrom = find('61080003');
- final events = find('61080004');
- final data = find('61080005');
+ session.cmdTo = find(gatt.cmdTo.substring(0, 8));
+ final cmdFrom = find(gatt.cmdFrom.substring(0, 8));
+ final events = find(gatt.events.substring(0, 8));
+ final data = find(gatt.data.substring(0, 8));
if (session.cmdTo == null ||
cmdFrom == null ||
events == null ||
data == null) {
- _log('Missing one or more Harvard characteristics.');
+ _log('Missing one or more ${band.isGen5 ? "fd4b" : "Harvard"} characteristics.');
await _failConnect();
return false;
}
@@ -1343,7 +1423,7 @@ class BleEngine {
_setOffloadActive(true);
if (refreshRange) {
_log('[SYNC] refresh($reason) — polling GET_DATA_RANGE before 0x16.');
- await _send(Cmd.getDataRange, const [0x00]);
+ await _sendGetDataRange();
// INIT spaces commands by ~120 ms; keep the same cadence here so the band
// has time to emit the range response before we request another drain.
await Future.delayed(const Duration(milliseconds: 120));
@@ -1361,7 +1441,7 @@ class BleEngine {
if (_session?.connected != true) return;
}
_log('[SYNC] refresh($reason) — sending SEND_HISTORICAL_DATA.');
- await _send(Cmd.sendHistoricalData, const [0x00]);
+ await _sendHistoricalData();
_lastHistoricalSendAt = _wallSecs();
}
@@ -1566,11 +1646,22 @@ class BleEngine {
}
Future _send(int opcode, List payload) async {
- if (dangerousCmds.contains(opcode)) {
+ // `dangerousCmds` is this codebase's own gen4-curated hard-block list
+ // (FORCE_TRIM/REBOOT/POWER_CYCLE/TOGGLE_PERSISTENT_R21/firmware-load).
+ // `OpcodeSafety.destructive` is whoop-rs's independently-curated list of
+ // opcodes with NO legitimate use anywhere in EITHER codebase (142-144
+ // have no named meaning at all) — the two don't fully overlap, so both
+ // apply. Deliberately NOT `OpcodeSafety.forbidden`: that broader list
+ // also flags opcodes this app sends ON PURPOSE via named, reviewed call
+ // sites (SET_ADVERTISING_NAME/SELECT_WRIST/SET_CONFIG for the R22
+ // sequence/SET_CLOCK_MAVERICK) — see that class's own doc for why a
+ // blanket block on `forbidden` would be wrong here.
+ if (dangerousCmds.contains(opcode) || OpcodeSafety.isDestructive(opcode)) {
_log('REFUSED dangerous opcode 0x${opcode.toRadixString(16)}');
return false;
}
- final frame = buildCommand(_seq.nextLive(), opcode, payload);
+ final frame = buildCommand(
+ _seq.nextLive(), opcode, payload, _session?.band ?? BandProfile.gen4);
final ok = await _write(frame);
if (!ok) {
_log('WRITE FAILED for opcode 0x${opcode.toRadixString(16)} — '
@@ -1579,6 +1670,18 @@ class BleEngine {
return ok;
}
+ // Offload commands whose PAYLOAD (not just the frame envelope) is
+ // generation-specific: gen4 sends a single 0x00, gen5 sends an EMPTY payload.
+ // Centralised so every offload trigger — the initial handshake, periodic
+ // backfill, manual refresh, and retry — emits the correct gen5 format on a
+ // gen5 link. (_send already frames with the session's BandProfile.)
+ List get _offloadPayload =>
+ (_session?.band.isGen5 ?? false) ? const [] : const [0x00];
+ Future _sendGetDataRange() =>
+ _send(Cmd.getDataRange, _offloadPayload);
+ Future _sendHistoricalData() =>
+ _send(Cmd.sendHistoricalData, _offloadPayload);
+
Future applyHighFreqWakeWindow({
required bool enabled,
required DateTime? targetWake,
@@ -1742,7 +1845,19 @@ class BleEngine {
} else if (pt == PacketType.consoleLogs && _offloadActive) {
_drain?.onBurstConsole();
}
- final decoded = _maybeAugmentDataRange(frame, decodeFrame(frame));
+ final band = _session?.band ?? BandProfile.gen4;
+ final decoded = _maybeAugmentGen5ClockEpoch(
+ frame,
+ _maybeAugmentDataRange(frame, decodeFrame(frame, profile: band)),
+ );
+ // gen5-only, debug-visibility ONLY (never persisted, never gated on):
+ // log the strap's own console text (now decoded by protocol's
+ // `parseConsoleLog`, wired into `decodeFrame` above). Genuinely useful
+ // for diagnosing the untested gen5 handshake/offload on real hardware.
+ if (band.isGen5 && decoded.kind == 'console_log') {
+ _log('[CONSOLE gen5] idx=${decoded.fields['record_index']} '
+ 'ts=${decoded.fields['ts_epoch']}: ${decoded.fields['text']}');
+ }
_absorbState(decoded);
}
@@ -1835,7 +1950,17 @@ class BleEngine {
// backfill (all received in one sync) splits into correct per-real-day
// buckets instead of collapsing into one "today".
Sample? sample;
- if (recType == Record.r24 || recType == Record.r12) {
+ final isGen5 = _session?.band.isGen5 ?? false;
+ if (isGen5) {
+ // gen5 (WHOOP 5): `parseGen5Historical` dispatches across all four real
+ // gen5 historical-record kinds (v18 per-second summary, v20 optical/
+ // v21 IMU/v26 PPG deep buffers — R22 opt-in only). Only v18 maps onto
+ // the band-agnostic `Sample` type today; the deep buffers need their
+ // own raw-buffer storage (a future db table), not a 1Hz Sample, so they
+ // fall through to the undecodable archive below — that is honest
+ // (correctly-identified-but-not-yet-stored), not a decode failure.
+ sample = sampleFromGen5Historical(parseGen5Historical(frame.inner));
+ } else if (recType == Record.r24 || recType == Record.r12) {
// Legacy decoder first, firmware-fallback chain second, undecodable
// archive last — see FirmwareAwareR24Decoder.
var decodeTarget = frame.inner;
@@ -2055,6 +2180,16 @@ class BleEngine {
state.wristOn = h.wristOn ?? state.wristOn;
onState(state);
}
+ // gen5's GET_HELLO (opcode 145) response shape is unrelated to gen4's
+ // HelloInfo — it carries a device_name + a gated fw_version instead
+ // (parseCommandResponse's gen5 GET_HELLO branch). No confirmed serial/
+ // battery/wrist-on offsets for it yet, so — unlike gen4's HELLO above —
+ // this is diagnostics-only for now (confirms the untested gen5 handshake
+ // actually got a byte-parseable reply) rather than wired into `state`.
+ if (d.kind == 'cmd_response' && f.containsKey('device_name')) {
+ _log('[HELLO gen5] device_name=${f['device_name']} '
+ 'fw_version=${f['fw_version']}');
+ }
if (d.kind == 'realtime_hr') {
final hr = f['hr'] as int;
if (hr > 0) {
@@ -2377,7 +2512,8 @@ class BleEngine {
);
return;
}
- final ack = buildHistoryResultOk(_seq.nextSync(), m.token!);
+ final ack = buildHistoryResultOk(_seq.nextSync(), m.token!,
+ profile: _session?.band ?? BandProfile.gen4);
_log(
'[SYNC] ACK frame='
'${ack.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}',
@@ -2456,6 +2592,11 @@ class BleEngine {
'last_ack_batches': d.batches,
'strap_history_oldest_ts': _strapHistoryOldestTs,
'strap_history_newest_ts': _strapHistoryNewestTs,
+ // Which WHOOP generation this batch came from — records/sessions
+ // vary hugely in richness by generation (and, for gen5, by whether
+ // the R22 deep-buffer opt-in was sent), so downstream diagnostics
+ // need this without reaching into the transport layer.
+ 'band_generation': state.generation,
},
));
// Same event, but a REAL per-chunk row keyed by the token — closes out
@@ -2515,6 +2656,7 @@ class BleEngine {
'history_completions': _historyCompletions,
'strap_history_oldest_ts': _strapHistoryOldestTs,
'strap_history_newest_ts': _strapHistoryNewestTs,
+ 'band_generation': state.generation,
},
));
_log(
@@ -2599,8 +2741,61 @@ class BleEngine {
String _innerHex(Uint8List inner) =>
inner.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
+ /// Send the gen5 "R22" 16-flag SET_CONFIG enable sequence
+ /// (protocol's `buildR22EnableSequence`/`kGen5R22EnableFlags`), unlocking
+ /// the v20 (optical)/v21 (IMU)/v26 (PPG) deep-buffer historical records.
+ /// Sequential, ~40ms apart (same spacing discipline as the gen4 5-packet
+ /// INIT) — the official WHOOP app never sends this, and neither does
+ /// OpenStrap unless [gen5DeepBuffersEnabled] opts in (see the constructor
+ /// doc). UNTESTED on physical hardware. No-op on a gen4 link.
+ ///
+ /// Written directly via [_write] (not [_send]) because the pre-built
+ /// frames already carry their own sequence numbers — going through `_send`
+ /// would double-allocate from [_seq] for no benefit. SET_FF_VALUE (120) is
+ /// in `OpcodeSafety.forbidden` but NOT `OpcodeSafety.destructive`; per that
+ /// class's own doc this deliberate, explicitly-opted-in sequence is exactly
+ /// the kind of call site the broader `forbidden` list is not meant to gate
+ /// (see `_send`'s doc for the full reasoning) — writing it directly here
+ /// keeps that intentional exception in ONE place rather than needing an
+ /// allowlist parameter threaded through the shared chokepoint.
+ Future enableGen5DeepBuffers() async {
+ if (!(_session?.band.isGen5 ?? false)) return;
+ final frames = buildR22EnableSequence(startSeq: _seq.nextLive());
+ _log('Sending gen5 R22 deep-buffer enable sequence (${frames.length} '
+ 'flags)…');
+ for (final frame in frames) {
+ await _write(frame);
+ await Future.delayed(const Duration(milliseconds: 40));
+ }
+ _log('gen5 R22 deep-buffer enable sequence sent.');
+ }
+
// ── high-level flows ─────────────────────────────────────────────────────────────
Future sendInit() async {
+ final band = _session?.band ?? BandProfile.gen4;
+ if (band.isGen5) {
+ // gen5 handshake: a single CLIENT_HELLO (GET_HELLO 0x91) written
+ // with-response opens the just-works bond, then the offload is driven by
+ // GET_DATA_RANGE + SEND_HISTORICAL_DATA with EMPTY payloads (gen4 sends a
+ // 0x00). The HISTORY_END ACK is byte-structured identically (handled in
+ // the metadata path). NOTE: untested on physical hardware — pending a
+ // WHOOP 5 device; the gen4 path above is unchanged.
+ _log('Sending gen5 CLIENT_HELLO + offload…');
+ await _write(gen5ClientHello());
+ await Future.delayed(const Duration(milliseconds: 120));
+ // Opt-in deep-buffer sequence, BEFORE the offload trigger (SET_CONFIG
+ // flags must land before SEND_HISTORICAL_DATA to take effect for this
+ // drain). Default OFF — see [gen5DeepBuffersEnabled].
+ if (gen5DeepBuffersEnabled()) {
+ await enableGen5DeepBuffers();
+ }
+ // Same band-aware helpers the refresh/backfill/retry paths use, so the
+ // gen5 offload command format is identical everywhere.
+ await _sendGetDataRange();
+ await Future.delayed(const Duration(milliseconds: 120));
+ await _sendHistoricalData();
+ return;
+ }
_log('Sending 5-packet INIT…');
for (final pkt in initPackets) {
await _write(pkt);
@@ -2705,16 +2900,28 @@ class BleEngine {
0,
0,
];
- await _send(Cmd.setClock, payload);
- _log('SET_CLOCK → sec=$sec subsec=$subsec (WHOOP-exact 8B).');
+ // gen5 ("Maverick") uses a DIFFERENT opcode for SET_CLOCK than gen4 — the
+ // 8-byte payload shape is unchanged, only the opcode value differs (see
+ // Cmd.setClockMaverick's doc in protocol/constants.dart). Sending gen4's
+ // opcode 0x0A to a gen5 strap here would silently fail to latch the RTC,
+ // which then refuses to serve type-47 history — the exact symptom fixed.
+ final isGen5 = _session?.band.isGen5 ?? false;
+ final opcode = isGen5 ? Cmd.setClockMaverick : Cmd.setClock;
+ await _send(opcode, payload);
+ _log('SET_CLOCK${isGen5 ? " (gen5 Maverick)" : ""} → sec=$sec '
+ 'subsec=$subsec (WHOOP-exact 8B).');
// Read the RTC back so the GET_CLOCK response handler can confirm it latched
// (and re-issue SET_CLOCK if the strap clock is still off — see _onDecoded).
await getClock();
}
/// Read the strap RTC. The response carries `clock_epoch`, handled where we
- /// verify drift and re-correlate the strap-RTC ↔ wall clock.
- Future getClock() => _send(Cmd.getClock, const []);
+ /// verify drift and re-correlate the strap-RTC ↔ wall clock. gen5 uses its
+ /// own GET_CLOCK opcode (147) — see [setClock].
+ Future getClock() => _send(
+ (_session?.band.isGen5 ?? false) ? Cmd.getClockGen5 : Cmd.getClock,
+ const [],
+ );
/// On-device wake alarm (SET_ALARM_TIME = 0x42) — the RICH 20-byte form that
/// actually FIRES on WHOOP 4.0:
@@ -2797,11 +3004,25 @@ class BleEngine {
}
Future getBattery() => _send(Cmd.getBatteryLevel, const []);
- Future getHello() => _send(Cmd.getHelloHarvard, const [0x00]);
+ Future getHello() => (_session?.band.isGen5 ?? false)
+ ? _send(Cmd.getHello, const [0x01])
+ : _send(Cmd.getHelloHarvard, const [0x00]);
Future buzz() => buzzPattern(hapticShortPulse);
- Future buzzPattern(int pattern) =>
- _send(Cmd.runHapticsPattern, [pattern, 0, 0, 0, 0]);
+ /// Play a haptic buzz. gen5 ("Maverick") has a DIFFERENT buzz opcode and
+ /// payload shape than gen4 (`Cmd.runHapticPatternMaverick`, 12-byte body —
+ /// see `cmdBuzzGen5Maverick` in protocol/commands.dart) — [pattern] is
+ /// honoured only on gen4; a gen5 link always plays the strap's fixed
+ /// `[47, 152]` waveform pair (the only Maverick buzz byte-verified so far).
+ Future buzzPattern(int pattern) {
+ if (_session?.band.isGen5 ?? false) {
+ return _send(
+ Cmd.runHapticPatternMaverick,
+ const [0x01, 47, 152, 0, 0, 0, 0, 0, 0, 0, 0, 1],
+ );
+ }
+ return _send(Cmd.runHapticsPattern, [pattern, 0, 0, 0, 0]);
+ }
/// Signal strength of the live link, in dBm (negative; closer to zero is
/// stronger). Null whenever there is nothing to measure.
@@ -3112,6 +3333,33 @@ class BleEngine {
fields['history_newest'] = ts.reduce((a, b) => a > b ? a : b);
return Decoded(decoded.kind, fields);
}
+
+ /// Patch up `COMMAND_RESPONSE` decodes for GET_CLOCK_GEN5 (147) — the one
+ /// gen5-exclusive opcode `parseCommandResponse` doesn't populate a
+ /// `clock_epoch` for yet (its GET_HELLO=145 and GET_BATTERY_LEVEL=26
+ /// battery-scale handling are already profile-aware natively, via the
+ /// `profile:` param passed into `decodeFrame` above). Without this, gen5's
+ /// SET_CLOCK/GET_CLOCK drift-correlation (`ClockRef`, read in
+ /// `_absorbState`) would never populate on a gen5 link, since
+ /// `parseCommandResponse` only recognises gen4's `Cmd.getClock` (0x0B) for
+ /// that field. Same "edge augments a decode protocol hasn't caught up to
+ /// yet" pattern as [_maybeAugmentDataRange].
+ Decoded _maybeAugmentGen5ClockEpoch(Frame frame, Decoded decoded) {
+ if (decoded.kind != 'cmd_response') return decoded;
+ if (decoded.fields['opcode'] != Cmd.getClockGen5) return decoded;
+ final inner = frame.inner;
+ final payload =
+ inner.length > 3 ? Uint8List.sublistView(inner, 3) : Uint8List(0);
+ final wallNow = DateTime.now().millisecondsSinceEpoch ~/ 1000;
+ for (var o = 0; o + 4 <= payload.length; o++) {
+ final v = u32(payload, o);
+ if (isPlausibleUnix(v, wallNow)) {
+ final fields = {...decoded.fields, 'clock_epoch': v};
+ return Decoded(decoded.kind, fields);
+ }
+ }
+ return decoded;
+ }
}
/// Per-connection historical-offload helper. Buffers records per ACK boundary and
diff --git a/lib/data/models.dart b/lib/data/models.dart
index 6f9882e..5f95c50 100644
--- a/lib/data/models.dart
+++ b/lib/data/models.dart
@@ -162,6 +162,13 @@ class DeviceState {
/// session-relative plausibility gate + the UI's "history available" readout.
int? dataRangeOldest;
int? dataRangeNewest;
+ /// Which WHOOP generation this connection is speaking — `'gen4'` or
+ /// `'gen5'`, set once at service discovery (see `BleEngine._doConnect`'s
+ /// `session.applyBand`). Null until a link has been established at least
+ /// once this process. Lets the UI show "WHOOP 5 connected" and gate any
+ /// gen5-only controls (e.g. a deep-buffer opt-in toggle) without reaching
+ /// into the transport layer.
+ String? generation;
DeviceState({this.connection = 'disconnected'});
}
diff --git a/lib/ui/profile/profile_screen.dart b/lib/ui/profile/profile_screen.dart
index f44a335..c80297c 100644
--- a/lib/ui/profile/profile_screen.dart
+++ b/lib/ui/profile/profile_screen.dart
@@ -587,6 +587,11 @@ class ProfileScreen extends StatelessWidget {
: '${d.batteryPct!.round()}%${d.charging == true ? ' ⚡' : ''}',
wrist: d.wristOn == null ? '—' : (d.wristOn! ? 'On wrist' : 'Off wrist'),
serial: d.serial ?? app.paired?.serial ?? '—',
+ generation: switch (d.generation) {
+ 'gen5' => 'WHOOP 5 (experimental)',
+ 'gen4' => 'WHOOP 4',
+ _ => null,
+ },
// Manual pull: anything the strap flashed that we don't hold yet, over
// the CURRENT connection (no reconnect). Only offered while connected.
onSyncNow: conn == 'connected' ? () => app.forceResync() : null,
@@ -847,6 +852,11 @@ class DeviceTile extends StatefulWidget {
final VoidCallback? onTap;
final Future Function()? onSyncNow;
+ /// Human label for [DeviceState.generation] ('WHOOP 4' / 'WHOOP 5
+ /// (experimental)'), or null before a link has been established this
+ /// process. Purely informational — never gates any behavior here.
+ final String? generation;
+
const DeviceTile({
super.key,
required this.name,
@@ -857,6 +867,7 @@ class DeviceTile extends StatefulWidget {
required this.serial,
this.onTap,
this.onSyncNow,
+ this.generation,
});
@override
@@ -911,7 +922,15 @@ class _DeviceTileState extends State {
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: Sp.x2),
- StatusChip(widget.statusText, tone: widget.statusTone),
+ Row(
+ children: [
+ StatusChip(widget.statusText, tone: widget.statusTone),
+ if (widget.generation != null) ...[
+ const SizedBox(width: Sp.x2),
+ StatusChip(widget.generation!, tone: ChipTone.neutral),
+ ],
+ ],
+ ),
],
),
),
diff --git a/pubspec.lock b/pubspec.lock
index bd6ebe1..956060c 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -973,8 +973,8 @@ packages:
dependency: "direct main"
description:
path: "."
- ref: "5bb8606964f2d94d9a73dc42c57416cb74282d52"
- resolved-ref: "5bb8606964f2d94d9a73dc42c57416cb74282d52"
+ ref: c525e29e69ff8b8f0a180b8464d4ccb32ec3e56d
+ resolved-ref: c525e29e69ff8b8f0a180b8464d4ccb32ec3e56d
url: "https://github.com/OpenStrap/protocol.git"
source: git
version: "1.0.0"
diff --git a/pubspec.yaml b/pubspec.yaml
index 2b5c900..02b52ad 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -6,7 +6,7 @@ publish_to: 'none'
# Watch App" targets in ios/Runner.xcodeproj/project.pbxproj — they aren't wired
# to FLUTTER_BUILD_NAME/FLUTTER_BUILD_NUMBER. See guides/IOS_INSTALLATION.md
# "Version Numbers".
-version: 0.9.21+52
+version: 0.9.23+54
environment:
sdk: ^3.11.4
@@ -35,17 +35,26 @@ dependencies:
openstrap_protocol:
git:
url: https://github.com/OpenStrap/protocol.git
- # Tip of protocol main — OpenStrap/protocol#20 merged: the reassembler now
- # checks the length-field crc8 before trusting it (a corrupted length byte
- # used to consume up to 4092 bytes of good stream before this), and
- # hexToBytes rejects odd-length hex instead of silently flooring it. This
- # PR sat unpinned for a full day after merging — see fix/issues #22 for
- # why (edge's own release pipeline shipped the pre-fix decoder).
- # NOTE: this ref predates protocol fix/issues#21 (realtimeRr RR-bound,
- # historical-family activity/steps_inc null-vs-0) — repin again once #21
- # merges to pick those up too.
- # Verified present: `git show :lib/src/framing.dart | grep 'crc8(Uint8List'`.
- ref: 5bb8606964f2d94d9a73dc42c57416cb74282d52
+ # EXPERIMENTAL: WHOOP 5 (gen5) multi-band support, this branch's whole
+ # point — stays on protocol's feat/multiband-whoop5, NOT main. Pinned to
+ # a SHA (not the floating branch ref) per this repo's own convention.
+ # 412ead9: replaces parseGen5Record's wrong version set ({9,12,24},
+ # gen4's) with real gen5 decoders (parseGen5Historical — v18/v20/v21/
+ # v26), adds the gen5 clock/haptics/SET_CONFIG opcodes + the R22
+ # deep-buffer enable-sequence builder, the band-agnostic OpcodeSafety
+ # gate, a CONSOLE_LOGS decoder, and profile-aware COMMAND_RESPONSE
+ # (gen5's direct-percent battery, GET_HELLO shape). edge's BleEngine
+ # changes in this same commit depend on all of this.
+ # c525e29 (current): an independent cross-validation pass against
+ # whoop-rs/noop's own real fixtures found and fixed real bugs on top of
+ # 412ead9 — v26 record_index was reading a u32 (wrong; whoop-rs's real
+ # consecutive-frame captures prove it's a u16), GET_DATA_RANGE's
+ # oldest/newest scan accepted a spurious far-future value and an
+ # off-grid byte offset, activity_class had no validity gate. v20's
+ # optical-buffer layout is flagged (not fixed) as a genuinely unresolved
+ # disagreement between the two references — no real v20 hardware
+ # capture exists anywhere to break the tie.
+ ref: c525e29e69ff8b8f0a180b8464d4ccb32ec3e56d
openstrap_analytics:
git:
url: https://github.com/OpenStrap/analytics.git
diff --git a/test/gen5_sample_mapping_test.dart b/test/gen5_sample_mapping_test.dart
new file mode 100644
index 0000000..37c611a
--- /dev/null
+++ b/test/gen5_sample_mapping_test.dart
@@ -0,0 +1,100 @@
+// Tests for BleEngine's gen5 -> band-agnostic Sample mapping
+// (sampleFromGen5Historical) — the seam that turns protocol's typed gen5
+// historical-record decode into the same `Sample` shape gen4 records
+// produce, so the derivation pipeline / analytics stay band-agnostic.
+//
+// The v18 fixture is the same real, independently byte-verified capture used
+// by protocol's own gen5_historical_test.dart (CRC16-modbus header + CRC32
+// payload both check out; see that file's header comment for provenance) —
+// reused here rather than re-typed, so a transcription slip can't silently
+// diverge the two test suites' expectations.
+
+import 'dart:typed_data';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:openstrap_edge/ble/ble_engine.dart';
+import 'package:openstrap_edge/data/models.dart';
+import 'package:openstrap_protocol/openstrap_protocol.dart';
+
+Uint8List hex(String s) {
+ final clean = s.replaceAll(' ', '');
+ final out = Uint8List(clean.length ~/ 2);
+ for (int i = 0; i < out.length; i++) {
+ out[i] = int.parse(clean.substring(i * 2, i * 2 + 2), radix: 16);
+ }
+ return out;
+}
+
+void main() {
+ group('sampleFromGen5Historical — v18 (real fixture)', () {
+ // "worn" capture, unix=1780916150 — CRC16+CRC32 both verified. Same
+ // bytes as protocol/test/gen5_historical_test.dart's v18 real fixture.
+ final frame = hex(
+ 'aa01740001003fb12f1280733d8401b69f266a66460066025a0265020000000'
+ '000007b0a8d656463ff0012163cf6a439bf2924fd3ed763fe3e3200aa000000'
+ '000000000000f7000901f10b0007010c020c000000000000000000000000000'
+ '00000000000000000000100656f1e1e0000009d61a7c00000003e862817',
+ );
+
+ late Sample? sample;
+
+ setUp(() {
+ final parsed = parseFrame(frame, profile: BandProfile.gen5)!;
+ expect(parsed.valid, isTrue, reason: 'both gen5 CRCs must check out');
+ sample = sampleFromGen5Historical(parseGen5Historical(parsed.inner));
+ });
+
+ test('maps ts/counter/hr straight through', () {
+ expect(sample, isNotNull);
+ expect(sample!.tsEpoch, 1780916150);
+ expect(sample!.counter, 25443699);
+ expect(sample!.hr, 102);
+ });
+
+ test('maps RR intervals straight through (band-agnostic HRV kernel)', () {
+ expect(sample!.rrIntervalsMs, [602, 613]);
+ });
+
+ test('maps the gravity vector onto ax/ay/az (shared g-units)', () {
+ expect(sample!.ax, closeTo(-0.7252, 1e-3));
+ expect(sample!.ay, closeTo(0.4944, 1e-3));
+ expect(sample!.az, closeTo(0.4969, 1e-3));
+ });
+
+ test(
+ 'does NOT populate skinTempRaw/spo2 — gen5-specific scale/absence',
+ () {
+ // See gen5_v18_decode's (now removed, folded into protocol) original
+ // caution and Gen5HistorySample's field docs: gen5's skin_temp is
+ // already °C-scaled (raw/100), a DIFFERENT transfer function from
+ // gen4's per-device affine ADC calibration that `skinTempRaw` feeds —
+ // reusing that field here would silently corrupt the skin-temp-z
+ // metric. gen5 v18 has no real dual-wavelength SpO2 at all.
+ expect(sample!.skinTempRaw, isNull);
+ expect(sample!.spo2RedRaw, isNull);
+ expect(sample!.spo2IrRaw, isNull);
+ },
+ );
+ });
+
+ group('sampleFromGen5Historical — non-Sample record kinds', () {
+ test('a null decode (unrecognised version/garbage) maps to null', () {
+ expect(sampleFromGen5Historical(null), isNull);
+ });
+
+ test('a v21 IMU deep buffer (no Sample equivalent) maps to null', () {
+ // Synthetic-but-shape-correct v21 buffer: countA/countB both 100 (the
+ // buffer's actual identity gate, per Gen5V21Decoder — hist_version is
+ // not trusted for this kind at all).
+ final inner = Uint8List(kGen5V21InnerLen);
+ inner[0] = 0x2F;
+ inner[1] = 21;
+ final view = inner.buffer.asByteData();
+ view.setUint16(16, 100, Endian.little); // countA offset
+ view.setUint16(622, 100, Endian.little); // countB offset
+ final decoded = parseGen5Historical(inner);
+ expect(decoded, isA());
+ expect(sampleFromGen5Historical(decoded), isNull);
+ });
+ });
+}