From 7bbbe8c2ce1b38c9a4f63a11e3ddd26b49c4f191 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 19 Jul 2026 12:09:52 +0530 Subject: [PATCH 01/11] feat(edge): detect + speak WHOOP 5 (gen5) alongside WHOOP 4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scan filters both service UUIDs (gen4 6108xxxx / gen5 fd4bxxxx); at discovery the session pins its generation and rebuilds the frame reassemblers with the matching header shape. The BandProfile is threaded through the frame path, the command builder, and the history-result ACK (the safe-trim token echo). Adds the gen5 handshake branch (client-hello + empty-payload offload) and routes gen5 records through parseGen5Record; unknown/motion kinds fall through to raw_archive as before. The WHOOP 4 path is unchanged. The gen5 connect/handshake path is not yet validated on physical hardware (marked in-code) — pending a WHOOP 5 band. --- lib/ble/ble_engine.dart | 91 +++++++++++++++++++++++++++++++++++------ 1 file changed, 78 insertions(+), 13 deletions(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index c828a3b2..5e27da6e 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -280,11 +280,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. @@ -868,7 +884,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) { @@ -878,14 +897,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'); @@ -1028,16 +1049,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 _teardownSession(intentional: true); _setPhase(BleConnState.idle); return false; } + session.applyBand(band); + _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; @@ -1045,15 +1083,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 _teardownSession(intentional: true); _setPhase(BleConnState.idle); return false; @@ -1455,7 +1493,8 @@ class BleEngine { _log('REFUSED dangerous opcode 0x${opcode.toRadixString(16)}'); return; } - final frame = buildCommand(_seq.nextLive(), opcode, payload); + final frame = buildCommand( + _seq.nextLive(), opcode, payload, _session?.band ?? BandProfile.gen4); await _write(frame); } @@ -1685,7 +1724,16 @@ 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) thin 1 Hz record: HR + timing only. Motion (K10/K21) and + // any unknown kind return null → archived to raw_archive below, exactly + // like an undecodable gen4 record. No accel/spo2/temp is fabricated. + final r = parseGen5Record(frame.inner); + if (r != null) { + sample = Sample(tsEpoch: r.tsEpoch, counter: r.counter, hr: r.hr); + } + } else if (recType == Record.r24 || recType == Record.r12) { // Legacy decoder first, firmware-fallback chain second, undecodable // archive last — see FirmwareAwareR24Decoder. var decodeTarget = frame.inner; @@ -2093,7 +2141,8 @@ class BleEngine { // re-delivers the chunk. Echo the 8-byte slice the band acks verbatim — // a mangled echo is the "Groundhog Day" re-flood bug. await d.commit(m.token); // raw + samples + strap_trim cursor, atomic - 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()}', @@ -2328,6 +2377,22 @@ class BleEngine { // ── 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)); + await _write(cmdGetDataRangeGen5(_seq.nextSync())); + await Future.delayed(const Duration(milliseconds: 120)); + await _write(cmdSendHistoricalGen5(_seq.nextSync())); + return; + } _log('Sending 5-packet INIT…'); for (final pkt in initPackets) { await _write(pkt); From 67fa1cbcb0f752bab3c2b92d9f03124c6ea8533e Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 19 Jul 2026 12:17:17 +0530 Subject: [PATCH 02/11] fix(edge): centralize gen5 offload command format across all trigger paths CodeRabbit review: the initial handshake used gen5 empty-payload GET_DATA_RANGE/SEND_HISTORICAL_DATA, but the periodic backfill, manual refresh, and retry paths still sent the gen4 [0x00] payload (only the frame envelope was band-correct). Extract _sendGetDataRange / _sendHistoricalData helpers that pick the payload by generation (gen4 [0x00], gen5 empty) and route the init, refresh, backfill, and retry paths through them, so the gen5 offload format is identical everywhere. --- lib/ble/ble_engine.dart | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 5e27da6e..2efc771e 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -1306,7 +1306,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)); @@ -1324,7 +1324,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(); } @@ -1498,6 +1498,18 @@ class BleEngine { await _write(frame); } + // 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, @@ -2388,9 +2400,11 @@ class BleEngine { _log('Sending gen5 CLIENT_HELLO + offload…'); await _write(gen5ClientHello()); await Future.delayed(const Duration(milliseconds: 120)); - await _write(cmdGetDataRangeGen5(_seq.nextSync())); + // 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 _write(cmdSendHistoricalGen5(_seq.nextSync())); + await _sendHistoricalData(); return; } _log('Sending 5-packet INIT…'); From 360ea72c694e4ad6b8b790e931aacf623640b559 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 19 Jul 2026 12:54:04 +0530 Subject: [PATCH 03/11] =?UTF-8?q?feat(edge):=20make=20WHOOP=205=20(gen5)?= =?UTF-8?q?=20pairable=20=E2=80=94=20iOS=20ASK=20+=20point=20protocol=20at?= =?UTF-8?q?=20gen5=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The BLE engine could speak gen5, but a WHOOP 5 band could not be paired on iOS: AccessorySetupKit only advertised the gen4 6108 service, so a fd4b band never appeared in the picker (and with no ASK provisioning the restore central is never created → no connection at all). - AccessorySetup.swift: offer one ASPickerDisplayItem per generation (gen4 6108 + gen5 fd4b) so either band can be provisioned; the provisioned CoreBluetooth identifier is generation-agnostic. - Info.plist: add the gen5 service to NSAccessorySetupBluetoothServices (required for the descriptor criterion). - pubspec.yaml: point openstrap_protocol at feat/multiband-whoop5 for the experimental build (revert to main once protocol#16 merges). Android needs no change: CDM associates by MAC (generation-agnostic) and the Flutter scan is already fd4b-aware. iOS restore reconnects by peripheral identifier, also generation-agnostic. Still hardware-unvalidated end-to-end — pending a physical WHOOP 5 band. --- ios/Runner/AccessorySetup.swift | 51 +++++++++++++++++++-------------- ios/Runner/Info.plist | 1 + pubspec.yaml | 4 ++- 3 files changed, 34 insertions(+), 22 deletions(-) diff --git a/ios/Runner/AccessorySetup.swift b/ios/Runner/AccessorySetup.swift index e0575385..ee7c0a86 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 2fcc9d28..5f4b34b3 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -29,6 +29,7 @@ NSAccessorySetupBluetoothServices 61080001-8D6D-82B8-614A-1C8CB0F8DCC6 + FD4B0001-CCE1-4033-93CE-002D5875F58A NSAccessorySetupKitSupports diff --git a/pubspec.yaml b/pubspec.yaml index 5a5ab5b9..99a37e4d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,7 +24,9 @@ dependencies: openstrap_protocol: git: url: https://github.com/OpenStrap/protocol.git - ref: main + # EXPERIMENTAL: WHOOP 5 (gen5) multi-band support. Point back to `main` + # once OpenStrap/protocol#16 merges. + ref: feat/multiband-whoop5 openstrap_analytics: git: url: https://github.com/OpenStrap/analytics.git From 25de96b0c48a2378f0150876477a3b8c5db20e2e Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 19 Jul 2026 13:20:01 +0530 Subject: [PATCH 04/11] chore(edge): pin pubspec.lock to protocol feat/multiband-whoop5 Pin the committed lock's openstrap_protocol dependency to the gen5 branch commit (687aa46) so CI/release resolves the exact experimental protocol revision. Only the protocol ref/resolved-ref changed; analytics stays on main. (Locally the gitignored pubspec_overrides.yaml still redirects to ../protocol for dev; the committed lock is what release resolution uses.) --- pubspec.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index d4aac69d..87ec50dd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -974,8 +974,8 @@ packages: dependency: "direct main" description: path: "." - ref: main - resolved-ref: "02fc8e5f2310ded690a522cd9884bf51b4cdc7e1" + ref: "feat/multiband-whoop5" + resolved-ref: "687aa4631809b5e2dcbddb9df66c5aaf32b814f9" url: "https://github.com/OpenStrap/protocol.git" source: git version: "1.0.0" From 701b4b491d0f02831f95b6b81ef539b47b2827df Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 1 Aug 2026 11:08:02 +0530 Subject: [PATCH 05/11] repin protocol to the merged sha --- pubspec.lock | 4 ++-- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index c207cb2c..8f8b1197 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -973,8 +973,8 @@ packages: dependency: "direct main" description: path: "." - ref: e543e47918d151f9acd2770eb0a84dddadc0387c - resolved-ref: e543e47918d151f9acd2770eb0a84dddadc0387c + ref: "7f1a2dbe27f9c9e1023dce2ca1a2c2baf96a883e" + resolved-ref: "7f1a2dbe27f9c9e1023dce2ca1a2c2baf96a883e" url: "https://github.com/OpenStrap/protocol.git" source: git version: "1.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 9c52cfa4..2f2b97f0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,7 +41,7 @@ dependencies: # This SHA is that branch merged with protocol main (crc8 length-field # check + hexToBytes odd-length rejection + the framing.dart profile- # aware header-CRC fix so gen5 frames actually pass the same guard). - ref: e543e47918d151f9acd2770eb0a84dddadc0387c + ref: 7f1a2dbe27f9c9e1023dce2ca1a2c2baf96a883e openstrap_analytics: git: url: https://github.com/OpenStrap/analytics.git From 9d13f1b0dfa527d4dfc5948a47888ec1f9062f12 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 1 Aug 2026 12:10:16 +0530 Subject: [PATCH 06/11] feat: wire the gen5 session lifecycle through the same BandProfile-gated engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends BleEngine (not a parallel copy) so a gen5 link gets a real, working lifecycle end to end instead of stopping at "connected": - SET_CLOCK/GET_CLOCK now use gen5's own opcodes (SET_CLOCK_MAVERICK/ GET_CLOCK_GEN5) instead of silently sending gen4's — the bug that would have left a gen5 strap's RTC forever unlatched and refusing history. - Historical-record ingestion now calls protocol's real parseGen5Historical (v18/v20/v21/v26) via a new sampleFromGen5Historical mapper, replacing the old parseGen5Record call that targeted gen4's version numbers and would have silently archived every real gen5 record. - Added the opt-in R22 deep-buffer enable sequence (default OFF, gated by a constructor toggle) and a gen5 Maverick haptic buzz path. - _send now also blocks OpcodeSafety.destructive band-agnostically, alongside the existing gen4 dangerousCmds list. - decodeFrame is now called with the session's BandProfile so gen5's direct-percent battery / GET_HELLO shape decode correctly; added a small edge-side augment for GET_CLOCK_GEN5's clock_epoch (protocol doesn't populate it yet) and debug-only logging for gen5 console/hello frames. - DeviceState.generation + a band_generation ledger field surface which WHOOP generation a session/batch came from, with no schema migration (rides the existing sync_ledger meta_json blob). - New gen5_sample_mapping_test.dart covers the v18->Sample mapping against a real byte-verified capture, plus the deep-buffer/null fall-through. gen4 behavior is unchanged (every branch above is band-gated); full suite green (1059 tests, 2 pre-existing skips). Real-hardware validation of the handshake and R22 sequence is still outstanding — see inline doc comments. --- lib/ble/ble_engine.dart | 201 ++++++++++++++++++++++++++--- lib/data/models.dart | 7 + test/gen5_sample_mapping_test.dart | 100 ++++++++++++++ 3 files changed, 292 insertions(+), 16 deletions(-) create mode 100644 test/gen5_sample_mapping_test.dart diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index acce7d81..774cb451 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, @@ -370,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, @@ -386,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. @@ -1146,6 +1187,7 @@ class BleEngine { 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) { @@ -1604,7 +1646,17 @@ 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; } @@ -1793,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); } @@ -1888,13 +1952,14 @@ class BleEngine { Sample? sample; final isGen5 = _session?.band.isGen5 ?? false; if (isGen5) { - // gen5 (WHOOP 5) thin 1 Hz record: HR + timing only. Motion (K10/K21) and - // any unknown kind return null → archived to raw_archive below, exactly - // like an undecodable gen4 record. No accel/spo2/temp is fabricated. - final r = parseGen5Record(frame.inner); - if (r != null) { - sample = Sample(tsEpoch: r.tsEpoch, counter: r.counter, hr: r.hr); - } + // 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. @@ -2115,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) { @@ -2517,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 @@ -2576,6 +2656,7 @@ class BleEngine { 'history_completions': _historyCompletions, 'strap_history_oldest_ts': _strapHistoryOldestTs, 'strap_history_newest_ts': _strapHistoryNewestTs, + 'band_generation': state.generation, }, )); _log( @@ -2660,6 +2741,35 @@ 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; @@ -2673,6 +2783,12 @@ class BleEngine { _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(); @@ -2784,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: @@ -2876,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. @@ -3191,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 6f9882e9..5f95c50c 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/test/gen5_sample_mapping_test.dart b/test/gen5_sample_mapping_test.dart new file mode 100644 index 00000000..37c611a7 --- /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); + }); + }); +} From aa187824b4ec9e82058ed6d142991427e2638a62 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 1 Aug 2026 12:14:02 +0530 Subject: [PATCH 07/11] chore: repin protocol to the real gen5 decoders (412ead9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous pin (7f1a2db) predates protocol's real v18/v20/v21/v26 decoders, gen5 clock/haptics/SET_CONFIG opcodes, R22 sequence builder, OpcodeSafety gate, and CONSOLE_LOGS decoder — all of which the previous edge commit's BleEngine changes call directly. Verified with a clean `flutter pub get` (no local path override) against this SHA: analyze and the full test suite (1059 tests) both green. --- pubspec.lock | 4 ++-- pubspec.yaml | 14 ++++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 8f8b1197..61917496 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -973,8 +973,8 @@ packages: dependency: "direct main" description: path: "." - ref: "7f1a2dbe27f9c9e1023dce2ca1a2c2baf96a883e" - resolved-ref: "7f1a2dbe27f9c9e1023dce2ca1a2c2baf96a883e" + ref: "412ead9415240591b89164110ad37dcf204f9dcb" + resolved-ref: "412ead9415240591b89164110ad37dcf204f9dcb" url: "https://github.com/OpenStrap/protocol.git" source: git version: "1.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 2f2b97f0..f7ccf1eb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,10 +38,16 @@ dependencies: # 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. - # This SHA is that branch merged with protocol main (crc8 length-field - # check + hexToBytes odd-length rejection + the framing.dart profile- - # aware header-CRC fix so gen5 frames actually pass the same guard). - ref: 7f1a2dbe27f9c9e1023dce2ca1a2c2baf96a883e + # Moved from 7f1a2db to this SHA for the gen5 session-lifecycle work in + # this same edge branch: 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 — + # do NOT roll this pin back without reverting those changes too. + ref: 412ead9415240591b89164110ad37dcf204f9dcb openstrap_analytics: git: url: https://github.com/OpenStrap/analytics.git From e21bbaf34eeaada435a4fb020aecbdfcb320ba2e Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 1 Aug 2026 17:24:32 +0530 Subject: [PATCH 08/11] repin protocol to c525e29 (cross-validation fixes) --- pubspec.lock | 16 ++++++---------- pubspec.yaml | 27 +++++++++++++++++---------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 61917496..7f8f06cd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -963,20 +963,16 @@ packages: openstrap_analytics: dependency: "direct main" description: - path: "." - ref: cbbe06addec1cb78b4ea2c75f64e8a281ac09294 - resolved-ref: cbbe06addec1cb78b4ea2c75f64e8a281ac09294 - url: "https://github.com/OpenStrap/analytics.git" - source: git + path: "../analytics" + relative: true + source: path version: "1.0.0" openstrap_protocol: dependency: "direct main" description: - path: "." - ref: "412ead9415240591b89164110ad37dcf204f9dcb" - resolved-ref: "412ead9415240591b89164110ad37dcf204f9dcb" - url: "https://github.com/OpenStrap/protocol.git" - source: git + path: "../protocol" + relative: true + source: path version: "1.0.0" ota_update: dependency: "direct main" diff --git a/pubspec.yaml b/pubspec.yaml index f7ccf1eb..655029e4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,16 +38,23 @@ dependencies: # 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. - # Moved from 7f1a2db to this SHA for the gen5 session-lifecycle work in - # this same edge branch: 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 — - # do NOT roll this pin back without reverting those changes too. - ref: 412ead9415240591b89164110ad37dcf204f9dcb + # 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 From 260cd0c8bce0c905e7b8bbaed4bd003d7ca4ecf0 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 1 Aug 2026 17:54:41 +0530 Subject: [PATCH 09/11] bump to 0.9.22+53 for the whoop5 experimental release --- ios/Runner.xcodeproj/project.pbxproj | 36 ++++++++++++++-------------- pubspec.lock | 16 ++++++++----- pubspec.yaml | 2 +- 3 files changed, 29 insertions(+), 25 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 7fa0a740..33d1be80 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 = 53; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 0.9.21; + MARKETING_VERSION = 0.9.22; 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 = 53; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 0.9.21; + MARKETING_VERSION = 0.9.22; 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 = 53; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 0.9.21; + MARKETING_VERSION = 0.9.22; 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 = 53; 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.22; 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 = 53; 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.22; 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 = 53; 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.22; 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 = 53; 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.22; 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 = 53; 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.22; 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 = 53; 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.22; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).watchkitapp"; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/pubspec.lock b/pubspec.lock index 7f8f06cd..956060cd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -963,16 +963,20 @@ packages: openstrap_analytics: dependency: "direct main" description: - path: "../analytics" - relative: true - source: path + path: "." + ref: cbbe06addec1cb78b4ea2c75f64e8a281ac09294 + resolved-ref: cbbe06addec1cb78b4ea2c75f64e8a281ac09294 + url: "https://github.com/OpenStrap/analytics.git" + source: git version: "1.0.0" openstrap_protocol: dependency: "direct main" description: - path: "../protocol" - relative: true - source: path + path: "." + ref: c525e29e69ff8b8f0a180b8464d4ccb32ec3e56d + resolved-ref: c525e29e69ff8b8f0a180b8464d4ccb32ec3e56d + url: "https://github.com/OpenStrap/protocol.git" + source: git version: "1.0.0" ota_update: dependency: "direct main" diff --git a/pubspec.yaml b/pubspec.yaml index 655029e4..7a1ea4ec 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.22+53 environment: sdk: ^3.11.4 From 63a87ed8f53aa09d337c4dfe3f64833907f75233 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 1 Aug 2026 20:21:32 +0530 Subject: [PATCH 10/11] show which WHOOP generation is connected on the device tile --- lib/ui/profile/profile_screen.dart | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/lib/ui/profile/profile_screen.dart b/lib/ui/profile/profile_screen.dart index f44a3350..c80297c9 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), + ], + ], + ), ], ), ), From 82b094c3c35fc6d159857b379e03b304a412b58d Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 1 Aug 2026 20:29:38 +0530 Subject: [PATCH 11/11] bump to 0.9.23+54 for the next whoop5 experimental release --- ios/Runner.xcodeproj/project.pbxproj | 36 ++++++++++++++-------------- pubspec.yaml | 2 +- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 33d1be80..2f3ca4f9 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 = 53; + CURRENT_PROJECT_VERSION = 54; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 0.9.22; + 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 = 53; + CURRENT_PROJECT_VERSION = 54; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 0.9.22; + 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 = 53; + CURRENT_PROJECT_VERSION = 54; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 0.9.22; + 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 = 53; + 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.22; + 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 = 53; + 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.22; + 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 = 53; + 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.22; + 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 = 53; + 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.22; + 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 = 53; + 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.22; + 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 = 53; + 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.22; + MARKETING_VERSION = 0.9.23; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).watchkitapp"; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/pubspec.yaml b/pubspec.yaml index 7a1ea4ec..02b52ad6 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.22+53 +version: 0.9.23+54 environment: sdk: ^3.11.4