From c57e6ff6d86befebfaf3152f8c774f8b2b129443 Mon Sep 17 00:00:00 2001 From: Brackyt <60280126+Brackyt@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:16:06 +0200 Subject: [PATCH 1/2] fix(gen5): arm and decode Maverick live IMU for step calibration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHOOP 5 rejects bare toggleImuMode (Invalid rev 0); arm with [revision1, on] and skip unhandled R10/R11 realtime. Live accel arrives as 0x2B rec 0x15 (100 Hz planar), not gen4 0x33 — decode that layout so calibration counts. Gen4 0x33/R10 still use frameAccel unchanged. --- lib/ble/ble_engine.dart | 59 ++++++++++++++++------ lib/ble/gen5_live_imu.dart | 73 ++++++++++++++++++++++++++++ lib/state/app_state.dart | 17 ++++--- test/gen5_imu_mode_payload_test.dart | 14 ++++++ test/gen5_live_imu_test.dart | 64 ++++++++++++++++++++++++ 5 files changed, 205 insertions(+), 22 deletions(-) create mode 100644 lib/ble/gen5_live_imu.dart create mode 100644 test/gen5_imu_mode_payload_test.dart create mode 100644 test/gen5_live_imu_test.dart diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 774cb45..d306b63 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -184,6 +184,17 @@ bool burstPacketCountMatches({ }) => expectedPacketCount == actualBurstPacketCount + droppedThisBurst; +/// IMU_SET_DATA_STREAM (0x6A) body — gen4 is a bare on/off byte; gen5 requires +/// a leading [revision1] (fw 50.40.1.0 console: `Invalid rev (0) for +/// WSBLE_CMD_IMU_SET_DATA_STREAM` when body is `[0x01]` / `[0x00]` alone). +/// Same revision role as optical. Without this the live IMU stream never arms +/// (gen4: 0x33; gen5 Maverick: 0x2B rec 0x15), so step calibration / live +/// workout steps stay 0. +@visibleForTesting +List imuModePayload(bool on, {required bool isGen5}) => isGen5 + ? [revision1, on ? 0x01 : 0x00] + : [on ? 0x01 : 0x00]; + /// Fired for every LIVE high-rate frame (0x28/0x2B/0x33). These are EPHEMERAL — /// they are NOT persisted to raw_records (that bloated storage ~50x and stalled /// derivation). The caller routes them to an in-memory sink for the live UI / @@ -1347,8 +1358,14 @@ class BleEngine { // Re-arm ONLY what the current live mode wants: re-sending the high-rate // R10/R11 toggle while in HR-only mode (background downgrade) or under the // marginal-radio fallback would silently undo the downgrade every 30 s. + // gen5: 0x3F is Unknown/Unhandled — re-arm IMU instead when full live. + final isGen5 = _session?.band.isGen5 ?? false; if (!_liveHrOnly && !state.standardHrFallback) { - _send(Cmd.sendR10R11Realtime, const [0x01]); + if (isGen5) { + _send(Cmd.toggleImuMode, imuModePayload(true, isGen5: true)); + } else { + _send(Cmd.sendR10R11Realtime, const [0x01]); + } } _send(Cmd.toggleRealtimeHr, const [0x01]); } @@ -3057,6 +3074,7 @@ class BleEngine { _liveHrOnly = false; _armTime = DateTime.now(); // marginal-radio detector measures arm→drop latency + final isGen5 = _session?.band.isGen5 ?? false; await _send(Cmd.toggleRealtimeHr, const [0x01]); // MARGINAL-RADIO FALLBACK: a weak radio can't sustain the high-rate R10/R11 + // IMU + optical flood, so once the detector trips we arm HR only. @@ -3065,12 +3083,19 @@ class BleEngine { return; } await Future.delayed(const Duration(milliseconds: 100)); - await _send(Cmd.sendR10R11Realtime, const [0x01]); - await Future.delayed(const Duration(milliseconds: 100)); - await _send(Cmd.toggleImuMode, const [0x01]); + // gen5 console: 0x3F (R10/R11 realtime) is Unknown/Unhandled — skip it. + // Live steps ride toggleImuMode (gen5: 0x2B rec 0x15; gen4: 0x33). + if (!isGen5) { + await _send(Cmd.sendR10R11Realtime, const [0x01]); + await Future.delayed(const Duration(milliseconds: 100)); + } + await _send(Cmd.toggleImuMode, imuModePayload(true, isGen5: isGen5)); await Future.delayed(const Duration(milliseconds: 100)); await _send(Cmd.enableOpticalData, const [revision1, 0x01]); - _log('Live streams enabled (optical: wrist-gated).'); + _log( + 'Live streams enabled (optical: wrist-gated' + '${isGen5 ? "; gen5 IMU rev1" : ""}).', + ); } /// Clear the sticky standard-HR fallback and give the full live set another @@ -3102,6 +3127,7 @@ class BleEngine { if (_session?.connected != true) return; _liveEnabled = true; _liveHrOnly = true; + final isGen5 = _session?.band.isGen5 ?? false; await _send(Cmd.toggleRealtimeHr, const [0x01]); final offOps = >[ [ @@ -3112,13 +3138,14 @@ class BleEngine { Cmd.enableOpticalData, [revision1, 0x00], ], - [ - Cmd.sendR10R11Realtime, - [0x00], - ], + if (!isGen5) + [ + Cmd.sendR10R11Realtime, + [0x00], + ], [ Cmd.toggleImuMode, - [0x00], + imuModePayload(false, isGen5: isGen5), ], ]; for (final op in offOps) { @@ -3130,6 +3157,7 @@ class BleEngine { /// Turn everything off. Safe + idempotent. Clears flags back to wrist-gated. Future disableLiveStreams() async { + final isGen5 = _session?.band.isGen5 ?? false; final ops = >[ [ Cmd.toggleOpticalMode, @@ -3139,13 +3167,14 @@ class BleEngine { Cmd.enableOpticalData, [revision1, 0x00], ], - [ - Cmd.sendR10R11Realtime, - [0x00], - ], + if (!isGen5) + [ + Cmd.sendR10R11Realtime, + [0x00], + ], [ Cmd.toggleImuMode, - [0x00], + imuModePayload(false, isGen5: isGen5), ], [ Cmd.toggleRealtimeHr, diff --git a/lib/ble/gen5_live_imu.dart b/lib/ble/gen5_live_imu.dart new file mode 100644 index 0000000..8c4606c --- /dev/null +++ b/lib/ble/gen5_live_imu.dart @@ -0,0 +1,73 @@ +// Gen5 (WHOOP 5 / Maverick) live IMU decode. +// +// Hardware evidence (fw 50.40.1.0 HCI snoop, 2026-08-05): after toggleImuMode +// with [revision1, 0x01] the strap emits gen5-framed **0x2B** inners of +// **1232 bytes**, not top-level 0x33: +// [0]=0x2B [1]=0x15 … [13]=0x04 [14..15]=100 LE (sample count) +// [16..17]=100 LE (rate) [18..19]=3 LE (axes) +// [20 .. 20+600)=100 planar XYZ int16 LE @ 100 Hz, scale 1/4096 g +// +// Gen4 `frameAccel` only accepts 0x33 (≥84 B / 10 samples) or R10 (rec 0x0A +// @685 B). Every gen5 live IMU frame abstained → step calibration stayed 0 +// despite console `IMU data stream enabled`. + +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +/// Gen5 live 0x2B subtype byte[1] seen on every Maverick IMU frame. +const int kGen5LiveImuRec = 0x15; + +/// Minimum inner length: header through 100×3×int16 accel planes. +const int kGen5LiveImuMinLen = 620; + +/// Samples per gen5 live IMU accel block (matches u16 @ offset 14). +const int kGen5LiveImuSamples = 100; + +/// Accel planar XYZ starts here (after 0x04 / count / rate / axes sub-header). +const int kGen5LiveImuAccelOffset = 20; + +Uint8List? _bytes(String hex) { + try { + return hexToBytes(hex); + } catch (_) { + return null; + } +} + +/// Decode a gen5 Maverick live 0x2B IMU inner, or null if not that layout. +ImuFrame? frameAccelGen5Live(String hex) { + final b = _bytes(hex); + if (b == null || b.length < kGen5LiveImuMinLen) return null; + if (b[0] != PacketType.realtimeRawData || b[1] != kGen5LiveImuRec) { + return null; + } + // Sample-count u16 LE @14 must be 100 — rejects other 0x2B shapes. + final count = b[14] | (b[15] << 8); + if (count != kGen5LiveImuSamples) return null; + + final view = b.buffer.asByteData(b.offsetInBytes, b.lengthInBytes); + const n = kGen5LiveImuSamples; + const start = kGen5LiveImuAccelOffset; + final xs = []; + final ys = []; + final zs = []; + final mags = []; + for (var i = 0; i < n; i++) { + final x = view.getInt16(start + 2 * i, Endian.little).toDouble(); + final y = view.getInt16(start + 2 * (n + i), Endian.little).toDouble(); + final z = view.getInt16(start + 2 * (2 * n + i), Endian.little).toDouble(); + xs.add(x); + ys.add(y); + zs.add(z); + mags.add(math.sqrt(x * x + y * y + z * z) / 4096.0); + } + // Already 100 Hz — no upsample. ts=1: gen5 header has no reliable unix@4; + // live ingest uses wall time for coverage; callers reject ts<=0. + return ImuFrame(1, 0, mags, xs, ys, zs); +} + +/// Gen5 Maverick live 0x2B first; else gen4 `frameAccel` (0x33 / R10). +ImuFrame? frameAccelForBand(String hex) => + frameAccelGen5Live(hex) ?? frameAccel(hex); diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index d4465cd..5000787 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -32,6 +32,7 @@ import '../ble/accessory_setup.dart'; import '../ble/android_background.dart'; import '../ble/ble_engine.dart'; import '../ble/ble_state.dart' show AlarmConfirmation, AlarmEffect; +import '../ble/gen5_live_imu.dart'; import '../ble/ios_ble_restore.dart'; import '../cloud/companion_client.dart'; import '../compute/derivation_engine.dart'; @@ -1722,12 +1723,11 @@ class AppState extends ChangeNotifier { if (breathingActive && (pt == 0x28 || pt == 0x2B)) { if (_breathingFrames.length < 8000) _breathingFrames.add(hex); } - // LIVE STEP COUNTER. The dedicated 0x33 IMU stream is the high-rate live - // accel — it arrives ~10 frames/s (10 samples each), so it drives a smooth, - // responsive count. Full R10 (0x2B) is only a fallback when the IMU stream - // isn't flowing (and live 0x2B is often R10-LITE, which carries no accel). - // `frameAccel` returns |a|(g) samples for both; once 0x33 is seen we ignore - // 0x2B to avoid double-counting the same motion from two stream formats. + // LIVE STEP COUNTER. Gen4: dedicated 0x33 IMU (~10 frames/s × 10 samples) + // is preferred; full R10 (0x2B) is only a fallback when 0x33 isn't flowing. + // Gen5 Maverick: live IMU is 0x2B (rec 0x15, 100 Hz planar) — see + // gen5_live_imu.dart. Once gen4 0x33 is seen we ignore 0x2B to avoid + // double-counting the same motion from two stream formats. if (pt == 0x33) { _imuStreamSeen = true; final f = _safeFrameAccel(hex); @@ -1736,6 +1736,7 @@ class AppState extends ChangeNotifier { _trackCoverage(recTs); } } else if (pt == 0x2B && !_imuStreamSeen) { + // Gen5 Maverick live IMU is 0x2B (100 Hz planar), not top-level 0x33. final f = _safeFrameAccel(hex); if (f != null) { _ingestLiveMags(f); @@ -1746,7 +1747,9 @@ class AppState extends ChangeNotifier { proto.ImuFrame? _safeFrameAccel(String hex) { try { - return proto.frameAccel(hex); + // Gen5 Maverick live IMU is 0x2B; gen4 stays on frameAccel (0x33 / R10). + // See gen5_live_imu.dart — gen5 path abstains unless rec=0x15. + return frameAccelForBand(hex); } catch (_) { return null; } diff --git a/test/gen5_imu_mode_payload_test.dart b/test/gen5_imu_mode_payload_test.dart new file mode 100644 index 0000000..f9fc49d --- /dev/null +++ b/test/gen5_imu_mode_payload_test.dart @@ -0,0 +1,14 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/ble_engine.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +void main() { + group('imuModePayload', () { + test('gen4 stays bare on/off; gen5 prepends revision1', () { + expect(imuModePayload(true, isGen5: false), [0x01]); + expect(imuModePayload(false, isGen5: false), [0x00]); + expect(imuModePayload(true, isGen5: true), [revision1, 0x01]); + expect(imuModePayload(false, isGen5: true), [revision1, 0x00]); + }); + }); +} diff --git a/test/gen5_live_imu_test.dart b/test/gen5_live_imu_test.dart new file mode 100644 index 0000000..5b1ca1a --- /dev/null +++ b/test/gen5_live_imu_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/gen5_live_imu.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +void main() { + // Real Maverick 0x2B live IMU inner from HCI snoop (fw 50.40.1.0, 2026-08-05). + const captured = '2b15800ccef901a011736a140e046400640003004f00470046004a004600400041004e005500510066005400550053005200450041004200480050004d0044004200550054004f0053005000410034002c003e0047004f004b00470051004a005c004f0044005700530048004300420042004f00520063005200530060005c00510049003a00470052005c003d003b0044004a004b00610062005c0051005a00500048003800460037005300440051004f004d004f00590051005b005200590049004c0041003e004e005d005f005f00500043003a004d0041005900090c0e0c0f0c0f0c170c060cef0bf90bf50bf50bf00bea0bec0bff0b170c150c1e0c130c0d0c0f0c030c000cfc0b0a0c030cef0bf20bf50bf80bf70b010c0b0c120c160c160c1e0c180c070cf20bec0be80be00bf90bf80b070c080c090c070c040c0b0c0e0c080cfe0b070cff0bf80bf70beb0be70b080c180c150c030c140c120c020cef0be80be80bf10bf60b0e0c190c140c0d0c100cf80bf00bf00bf90b090c0e0c080cf90b060cfe0b0d0cff0bfc0bff0b0a0c080c000cf30bef0bf50b030c080c020cf70b350a390a370a400a380a4e0a3c0a340a370a350a330a3c0a440a470a490a3e0a310a430a390a370a2c0a360a3a0a370a370a3b0a280a2b0a290a2a0a3d0a530a3b0a320a3a0a310a3c0a380a330a380a3a0a3c0a410a430a4a0a370a390a2e0a310a2d0a430a3a0a380a420a350a430a500a380a320a300a420a3e0a470a490a3c0a330a220a2a0a3f0a480a420a3c0a4d0a3e0a300a450a350a2e0a450a450a3a0a4f0a430a2e0a390a3e0a320a430a4f0a4d0a340a420a2d0a3a0a3d0a3e0a510a430a420a3f0a6400640005020000000000000600070008000400fafff5fff5fff5fff6fff9ff000008000d001500140012000900060003000100ffff010000000200fdfffdffffff02000200040009000a000d000c000700fcfff2fff0fff3fff6fff9ff00000600090008000b000900030002000000fafffafffbfffcfffeff030003000a000c0013000d000100fbfffafffafffbfffcfffeff00000100040009000b0009000200fefff9fff9fffdff0100020000000000fffffffffbfffdfffdff000004000600060004000000feffffffffff020002000600fefffeff000003000400fefffcff0100fefffefffeff000006000a000c000f000a0008000400040004000300fdfff8fffafffaffffff080009000b000300fdfffbfffdfffcfffcfffbff0000030006000400fefffdff030001000000fefffefff9fffcfffefffcff01000400080007000000f7fffdff04000800fdfff6fffafffffffeffffff040001000200070007000500ffffffff0000ffff0100fdfff9fffcfffdfffcfffdff0000ffff010005000400fdfffbfffcff00000400030007000200fdfffcff040007000500070009000300fdfffaff0000ffff0100030005000700070002000100fcfffafffcfffdff0000020005000500030002000000020000000300090009000700060006000200fefffbffffff03000300060009000800080005000700090006000600070004000100feffffffffff0000ffff05000a000a0005000300060003000000ffff0200050005000700040003000400ffffffff000002000600070005000200040005000400050004000500050005000300040000000200feff0100020003000000ffff'; + + // Gen4 R10 from protocol decode_parity_cases.json (rec=0x0A). + const gen4R10 = '2b0a29a7f6f001568de201706880545401570000000000000000000031fe00ee0000000000000000000050a1ba3d71a548bfb8bed4bd4811303f0000e44571a548bfb8bed4bd4811303f350266022d03670203016272f3a5f3ccf3b8f3bef311f4ebf3c4f372f34df344f344f339f318f3f1f2f0f217f334f33af363f35cf327f315f32ef361f3a3f3cbf3cdf3baf3baf3d1f3cef3d5f3baf393f39ff395f3ebf281f2f8f2b7f2e9f2d1f36bf3e0f346f45af48af462f450f443f432f4fcf3b7f3a8f3a3f384f343f327f329f307f3fbf203f3daf2b8f2d3f2baf280f288f2aef2d6f2eff205f324f348f392f3f6f328f43af44ff42cf4e5f3bef3a8f38af34ef33df34af357f35df34af336f350f34ef353f349f344f322f366f384f3e2fad5fb48fdfdfd55fed6fe85ff74ff05ffccfe78fe82fed3fe0bff54ffb0ffc3ff2dff83fe46fe85fe2bffc6ffb1ff99fe53fdf5fcadfddafeb1ffd7ff6bffe7fe79fe66fef9fed3ffc8006701170107ffd9fe88fe3dfdd3fc9ffc4bfd1bfe62fe72fe26feeefde5fd9ffd99fd9bfdabfdcdfd4cfee5fe3dff65ffa0ffa3ff78ffc6ffcaff66ff26ff41ff81ffa8ffa0ff4cff7efe5ffd4bfcf2fb36fd6bffc5009b006eff47fe7efd67fdebfd8afed5fec4fe8ffed5fe46ff60ffe9fe72fe84fe82fe44fe6ffdc00a0f0b020b290b180bb90a6e0a6d0a7e0a490a0b0acc095e0951094509660988099b09ac09df09e009a7096e0955098d09190ace0a740bd70be10bae0b570be80a9f0a4a0ae7097a09c40880072607f1078d08d4094c0b810c500d900d700d200d580c8a0bf40a470a260ad909ce09c509b609b509b60987095709110906090d09e908a308670841082f0835087208d0084d09f909e40af10bbf0cfd0cb90c330c770bb40a100aa3097d09700971098109d009210a380a090a080aef09030af209080af709fe090501624c004401f1011602d30172010901a80075007700a200e90047019201b2019e0161011a01e700dd00eb00c8005000abff29ff15ff83ff2100830075000f009bff59ff55ff74ff87ff63fffdfe71fe2efe53fe77fefafeb6ff8c005a01e5010602a101f5006400efffc0ffb8ffc5ffd1ffe9ff2100570070006700480029000600ceff8dff62ff59ff78ffadffdcffeaffd4ffaaff8effa7ff06009e0026014101bd00b8ffb4fe2cfe45fecbfe5fffbcffdaffd9fff1ff1f002c000b00ddffb8ff8fff7cff5fff63ff16000e0012001e001400fefff8ff04000d000300faffedffdcffd3ffd0ffcdffccffcfffceffcfffd4ffd0ffc9ffcaffcdffd2ffdefff2ff08001b002f00400048004c00450032001b00ebff77ff04ffd9fe9bfeb7fe05ff4aff9efff7ff4b009000bf00d700df00e800d700b9009f0087006e0058004d00420035002e003c003f00350028000e00eaffbdff92ff6cff50ff41ff41ff55ff83ffb9ffe2ff0500300051005c005400410028000600edffe1ffe1ffe8ffe4ffe7fff8ff070012001a0016000b0006000400cfffaaffcbff02001e001900260043004f004b003e001c00ffffedffd2ffc1ffc7ffceffcbffc7ffc5ffc7ffd1ffe7fffeff0800f9ffdeffd1ffdbffedfff5fffcff0600080004000100f3ffbaffefff0a00c0ffc0ffb4ff9fff79ff69ff88ffaeffdaff070030004e0063007e009e00b300b1009b008c00840079006d006a0047001200f5ffe9ffcdffacff97ff88ff7dff7bff82ff95ffabffa7ff8fff9dffd9ff1e004c00540047003300230020001f0016000300e2ffd1ffd6ffd9ffcbffb8ffc4ffeaff000100000003040000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000320400000500000100000320000000000002200000000000f9fffffff1fffffffefffffffcfffffffdfffffffffffffffefffffff4fffffff1fffffff0fffffffdfffffff6fffffff9fffffff5fffffff9fffffff8fffffff2fffffff4fffffff7fffffffafffffff0fffffff6fffffffdfffffff2fffffffdfffffffbfffffff3fffffffcfffffffdfffffff6fffffff9fffffffdfffffffafffffffcfffffffdfffffffafffffffcfffffff9fffffffdfffffff1fffffff6fffffff5fffffff9fffffff4fffffff1fffffff4fffffff6fffffff9fffffffefffffff6ffffff03000000f9fffffff8fffffffefffffffaffffff020000000a000000ffffffff010000000400000002000000ffffffff0500000007000000080000000200000004000000fffffffffefffffffffffffffbfffffffdffffff07000000fcfffffffefffffffffffffffbffffff00000000040000000c000000f5ffffff000000000500000009000000faffffff03000000ffffffff0100000003000000ffffffff04000000fcffffff0700000007000000f7ffffff0200000008000000030000000a00000001000000315e0100'; + + // Minimal valid gen4 0x33 IMU stream (10 samples, ts>0, |a|≈1 g on Z). + const gen4Imu33 = '3300000000f1536500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010001000100010001000100010001000100010'; + + group('frameAccelGen5Live', () { + test('decodes 0x2B Maverick IMU at 100 Hz near 1 g', () { + expect(captured.length ~/ 2, 1232); + final f = frameAccelGen5Live(captured); + expect(f, isNotNull); + expect(f!.mags, hasLength(kGen5LiveImuSamples)); + expect(f.xs, hasLength(kGen5LiveImuSamples)); + final avg = f.mags.reduce((a, b) => a + b) / f.mags.length; + expect(avg, greaterThan(0.7)); + expect(avg, lessThan(1.4)); + }); + + test('gen4 frameAccel abstains — gen5 path must win', () { + expect(frameAccel(captured), isNull); + final band = frameAccelForBand(captured)!; + final gen5 = frameAccelGen5Live(captured)!; + expect(band.mags, gen5.mags); + }); + + test('abstains on gen4-shaped short 0x33', () { + expect(frameAccelGen5Live('33' + ('00' * 80)), isNull); + }); + }); + + group('WHOOP 4 unchanged via frameAccelForBand', () { + test('gen5 decoder abstains on gen4 0x33; band path == frameAccel', () { + expect(frameAccelGen5Live(gen4Imu33), isNull); + final legacy = frameAccel(gen4Imu33); + final band = frameAccelForBand(gen4Imu33); + expect(legacy, isNotNull); + expect(band, isNotNull); + expect(band!.mags, legacy!.mags); + expect(band.ts, legacy.ts); + expect(band.mags, hasLength(10)); + }); + + test('gen5 decoder abstains on gen4 R10 (rec 0x0A); band path == frameAccel', () { + expect(gen4R10.substring(0, 4).toLowerCase(), '2b0a'); + expect(frameAccelGen5Live(gen4R10), isNull, + reason: 'gen4 R10 is rec=0x0A; gen5 gate requires rec=0x15'); + final legacy = frameAccel(gen4R10); + final band = frameAccelForBand(gen4R10); + expect(legacy, isNotNull); + expect(band, isNotNull); + expect(band!.mags, legacy!.mags); + expect(band.ts, legacy.ts); + expect(band.mags, hasLength(100)); + }); + }); +} From da95af76e42378d54acb9c470778e7580aa9c547 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 7 Aug 2026 23:26:46 +0530 Subject: [PATCH 2/2] test(gen5): use interpolation so `flutter analyze` stays clean `'33' + ('00' * 80)` trips prefer_interpolation_to_compose_strings. That is only an INFO, but CI runs a bare `flutter analyze`, which exits 1 on any issue including infos -- so this alone would have turned the job red. Note for the record: Copilot flagged this same line claiming `'00' * 80` "relies on a non-standard String operator * and will fail to compile". That part is wrong -- `String.operator*` is dart:core, and `'00' * 4` evaluates to '00000000'. The line compiles fine; it is the lint, not the language. --- test/gen5_live_imu_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/gen5_live_imu_test.dart b/test/gen5_live_imu_test.dart index 5b1ca1a..4a9b62b 100644 --- a/test/gen5_live_imu_test.dart +++ b/test/gen5_live_imu_test.dart @@ -32,7 +32,7 @@ void main() { }); test('abstains on gen4-shaped short 0x33', () { - expect(frameAccelGen5Live('33' + ('00' * 80)), isNull); + expect(frameAccelGen5Live('33${'00' * 80}'), isNull); }); });