From 320c9f452cf6eca577871ec498e523d57f02d489 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Tue, 3 Nov 2026 09:39:39 +0000 Subject: [PATCH 1/2] feat(hyrox): live double-tap bursts close HYROX rounds (PR 3) New HyroxLiveTapHandler (lib/state/hyrox_live_taps.dart), fed from AppState._onLiveEvent on the foreground/live path only (never the headless drain). Same burst rule as the offline reconciler: >=3 double-taps within 6 s anchored on the FIRST tap (chained 0/6/12 s does not count), per-station minimum floors, boundaries stamped 'gesture'. A burst starts an armed race, closes the open round, or finishes the final one; activeSession is re-read per accepted burst so relaunch/reconciler state is picked up. Finalized and discarded sessions are never touched; rejections are logged, never thrown. Co-authored-by: BucciMobile --- lib/state/app_state.dart | 10 +++ lib/state/hyrox_live_taps.dart | 124 ++++++++++++++++++++++++++ test/hyrox_live_taps_test.dart | 158 +++++++++++++++++++++++++++++++++ 3 files changed, 292 insertions(+) create mode 100644 lib/state/hyrox_live_taps.dart create mode 100644 test/hyrox_live_taps_test.dart diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 4c7c3fe3d..8d0f45c68 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -86,6 +86,7 @@ import 'alarm_schedule.dart'; import 'derive_coordinator.dart'; import 'gesture_controller.dart'; import 'hyrox_reconciler.dart'; +import 'hyrox_live_taps.dart'; import 'live_stream_controller.dart'; import 'sync_controller.dart'; import 'workout_controller.dart'; @@ -392,6 +393,12 @@ class AppState extends ChangeNotifier { final GestureSettings gestureSettings = GestureSettings(); late final GestureController _gestureController; + /// Live double-tap -> HYROX round boundary, fed from [_onLiveEvent]. + late final HyroxLiveTapHandler _hyroxLiveTaps = HyroxLiveTapHandler( + () => LocalDb.instance, + log: _log, + ); + /// Relay selected phone-app notifications to the strap as a buzz (Android only). /// Exposed for the settings UI; buzzes via the live BLE engine when connected. late final NotificationRelay notificationRelay = NotificationRelay( @@ -2987,6 +2994,9 @@ class AppState extends ChangeNotifier { _handleAlarmEvent(id, ts, hex: hex, bodyVerified: profile == proto.BandProfile.gen4); _gestureController.onEvent(id, ts, hex); + // HYROX live taps: a double-tap burst closes the current round of the + // active race. Live path only — the headless drain never reaches here. + unawaited(_hyroxLiveTaps.onEvent(id, ts, deviceId)); } /// Why start-up failed, or null if it did not. Drives [AppRoute.failed]. diff --git a/lib/state/hyrox_live_taps.dart b/lib/state/hyrox_live_taps.dart new file mode 100644 index 000000000..7fa25e8c1 --- /dev/null +++ b/lib/state/hyrox_live_taps.dart @@ -0,0 +1,124 @@ +// hyrox_live_taps.dart — turns LIVE band double-taps into HYROX round +// boundaries while the phone is connected. +// +// The offline reconciler (hyrox_reconciler.dart) applies the same burst rule +// AFTER a drain; this handler applies it DURING the race, so the UI can show +// the current round immediately. Both paths converge on the same rule: +// a boundary is the FIRST tap of a cluster of >= [kMinTaps] double-taps where +// every tap is within [kTapWindowSec] of the cluster's first tap AND follows +// the previous tap without a longer pause (a chained 0/6/12 s sequence does +// NOT count). Per-station minimum-duration floors reject a tap during a +// station struggle. Boundaries are stamped 'gesture'; the reconciler later +// ignores taps at or before the last recorded boundary, so the two paths are +// idempotent together. +// +// Wired ONLY into the foreground/live event path (AppState._onLiveEvent), +// never the headless drain — a drained historical tap must not fire a live +// transition (same guard philosophy as GestureDispatcher's recency window; +// the band ts of a live tap is seconds old by construction). + +import 'package:sqflite/sqflite.dart'; + +import '../data/hyrox_store.dart'; +import 'hyrox_reconciler.dart' + show kMinTaps, kTapWindowSec, kMinSegmentSec; + +/// Double-tap event id, the only gesture the firmware emits today. +const int kHyroxLiveDoubleTapEventId = 14; + +/// Pure, testable: incremental burst detection over a live tap stream. +class LiveBurstDetector { + final List _cluster = []; + + /// Feed one live tap (band timestamp, seconds). Returns the accepted + /// boundary time — the FIRST tap of the burst — or null while the cluster + /// has not cleared the gate yet. + int? addTap(int ts) { + if (_cluster.isNotEmpty) { + final first = _cluster.first; + final last = _cluster.last; + // Anchored on the first tap AND no long pause inside the burst: a + // chained 0 s / 6 s / 12 s sequence starts a new cluster at 12 s. + if (ts > first + kTapWindowSec || ts - last > kTapWindowSec) { + _cluster.clear(); + } + } + _cluster.add(ts); + if (_cluster.length >= kMinTaps) { + final at = _cluster.first; + _cluster.clear(); + return at; + } + return null; + } +} + +/// Applies accepted live bursts to the ONE active HYROX session. +/// +/// [db] is a getter because the database handle is async; the handler is +/// created before the DB is open. Every accepted burst re-reads the session +/// from the DB, so a relaunch or a reconciler-applied boundary is picked up. +class HyroxLiveTapHandler { + HyroxLiveTapHandler(this._db, {this.log}); + + final Future Function() _db; + final void Function(String line)? log; + + final LiveBurstDetector _detector = LiveBurstDetector(); + + /// Feed every live band event here. Cheap for non-tap events. + Future onEvent(int eventId, int ts, String deviceId) async { + if (eventId != kHyroxLiveDoubleTapEventId) return; + if (ts <= 0) return; // unset RTC: the offline path owns those rows + final at = _detector.addTap(ts); + if (at == null) return; + try { + await _applyBoundary(at); + } on HyroxWriteError catch (e) { + // A rejected boundary (floor, already-started, finalized) is a normal + // race condition between live and offline paths — log, never throw. + log?.call('[hyrox] live tap at $at rejected: $e'); + } + } + + Future _applyBoundary(int at) async { + final db = await _db(); + final session = await HyroxDb.activeSession(db); + if (session == null) return; + if (session.status != 'armed' && session.status != 'active') return; + final segs = session.segments; + if (segs.isEmpty) return; + final open = segs.where((s) => s.isOpen).toList(); + if (open.isEmpty) { + // Nothing started yet: the burst STARTS the race (armed only — a + // started session re-arming would be a caller bug). + if (segs.any((s) => s.startTs != null)) return; + await HyroxDb.startSegment( + db: db, sessionId: session.id, ts: at, boundarySource: 'gesture', + ); + log?.call('[hyrox] race started at $at (${session.id})'); + return; + } + if (open.length != 1) return; + final cur = open.single; + final minSec = kMinSegmentSec[cur.station] ?? 0; + if (cur.startTs != null && at - cur.startTs! < minSec) { + log?.call('[hyrox] tap at $at under the ${cur.station.key} floor ' + '($minSec s) — ignored'); + return; + } + if (cur.idx == segs.length - 1) { + await HyroxDb.finishSegment( + db: db, sessionId: session.id, ts: at, boundarySource: 'gesture', + ); + log?.call('[hyrox] race finished at $at (${session.id})'); + } else { + await HyroxDb.transitionSegment( + db: db, sessionId: session.id, currentIdx: cur.idx, + transitionTs: at, boundarySource: 'gesture', + ); + log?.call('[hyrox] round ${cur.roundIndex ?? cur.idx + 1} closed at ' + '$at (${session.id})'); + } + } +} diff --git a/test/hyrox_live_taps_test.dart b/test/hyrox_live_taps_test.dart new file mode 100644 index 000000000..4f4bc732d --- /dev/null +++ b/test/hyrox_live_taps_test.dart @@ -0,0 +1,158 @@ +// PR 3 tests: live double-tap bursts -> HYROX boundaries (gesture), with the +// same burst rule and per-station floors as the offline reconciler. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:openstrap_edge/state/hyrox_live_taps.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/data/hyrox_plan.dart'; +import 'package:openstrap_edge/data/hyrox_store.dart'; + +void main() { + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + tearDownAll(() async { + await LocalDb.close(); + }); + + group('LiveBurstDetector (pure)', () { + test('three taps inside the window anchored on the first are one burst', + () { + final d = LiveBurstDetector(); + expect(d.addTap(1000), isNull); + expect(d.addTap(1002), isNull); + expect(d.addTap(1004), 1000); // boundary = FIRST tap + }); + + test('a chained 0/6/12 s sequence is NOT one burst', () { + final d = LiveBurstDetector(); + expect(d.addTap(1000), isNull); + expect(d.addTap(1006), isNull); // within 6 s of first + expect(d.addTap(1012), isNull); // 12 s after first -> new cluster + expect(d.addTap(1013), isNull); + expect(d.addTap(1014), 1012); // second burst anchored at 1012 + }); + + test('two taps never clear the gate', () { + final d = LiveBurstDetector(); + expect(d.addTap(1000), isNull); + expect(d.addTap(1001), isNull); + expect(d.addTap(5000), isNull); // stale cluster reset, single tap + }); + }); + + group('HyroxLiveTapHandler against real DB', () { + Future freshDb(String name) async { + LocalDb.dbName = name; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + return LocalDb.instance; + } + + Future handlerFor(Database db, {void Function(String)? log}) async { + return HyroxLiveTapHandler(() async => db, log: log); + } + + Future burst(HyroxLiveTapHandler h, int first) async { + for (final o in [0, 1, 2]) { + await h.onEvent(14, first + o, ''); + } + } + + test('no session: taps are ignored without throwing', () async { + final db = await freshDb('hx_live_none.db'); + final h = await handlerFor(db); + await burst(h, 1000); + expect(await HyroxDb.activeSession(db), isNull); + await LocalDb.close(); + }); + + test('a burst starts an armed race, later bursts close rounds, the last ' + 'finishes it', () async { + final db = await freshDb('hx_live_race.db'); + final t0 = 1_000_000; + await HyroxDb.create(db, HyroxSession( + id: 'hx_live_1', kind: 'race', preset: 'hyrox', status: 'armed', + plan: HyroxPlan.race('hyrox'), + createdAt: t0 * 1000, updatedAt: t0 * 1000, + )); + final h = await handlerFor(db); + await burst(h, t0 + 100); // start + var segs = await HyroxDb.segments(db, 'hx_live_1'); + expect(segs[0].startTs, t0 + 100); + expect(segs[0].boundarySource, 'gesture'); + expect((await HyroxDb.byId(db, 'hx_live_1'))!.status, 'active'); + + await burst(h, t0 + 500); // close round 1 (skierg floor 300 s: 400 ok) + segs = await HyroxDb.segments(db, 'hx_live_1'); + expect(segs[0].endTs, t0 + 500); + expect(segs[1].startTs, t0 + 500); + + // A burst 60 s into round 2 (sled_push floor 240 s) is ignored. + await burst(h, t0 + 560); + segs = await HyroxDb.segments(db, 'hx_live_1'); + expect(segs[1].endTs, isNull); + + // Advance through to the final round and finish. + var t = t0 + 500; + for (var i = 1; i < 7; i++) { + t += 600; + await burst(h, t); + } + t += 600; + await burst(h, t); // finish + final read = await HyroxDb.byId(db, 'hx_live_1'); + expect(read!.status, 'finished'); + expect(read.segments.last.endTs, t); + expect(read.segments.every((s) => s.endTs != null), isTrue); + await LocalDb.close(); + }); + + test('non-tap events and zero timestamps are ignored', () async { + final db = await freshDb('hx_live_ignored.db'); + final t0 = 1_000_000; + await HyroxDb.create(db, HyroxSession( + id: 'hx_live_2', kind: 'race', preset: 'hyrox', status: 'armed', + plan: HyroxPlan.race('hyrox'), + createdAt: t0 * 1000, updatedAt: t0 * 1000, + )); + final h = await handlerFor(db); + await h.onEvent(7, t0 + 100, ''); // not a double-tap + await h.onEvent(14, 0, ''); // unset RTC + final segs = await HyroxDb.segments(db, 'hx_live_2'); + expect(segs[0].startTs, isNull); + await LocalDb.close(); + }); + + test('a finalized session is not modified by live taps', () async { + final db = await freshDb('hx_live_fin.db'); + final t0 = 1_000_000; + await HyroxDb.create(db, HyroxSession( + id: 'hx_live_3', kind: 'race', preset: 'hyrox', status: 'armed', + plan: HyroxPlan.race('hyrox'), + createdAt: t0 * 1000, updatedAt: t0 * 1000, + )); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_live_3', ts: t0 + 100, + boundarySource: 'gesture'); + for (var i = 0; i < 7; i++) { + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_live_3', currentIdx: i, + transitionTs: t0 + 1000 + i * 600, boundarySource: 'gesture'); + } + await HyroxDb.finishSegment( + db: db, sessionId: 'hx_live_3', ts: t0 + 1000 + 7 * 600 + 600, + boundarySource: 'gesture'); + await HyroxDb.finalize(db, 'hx_live_3'); + final h = await handlerFor(db); + await burst(h, t0 + 9999); + final segs = await HyroxDb.segments(db, 'hx_live_3'); + expect(segs[7].endTs, t0 + 1000 + 7 * 600 + 600); + await LocalDb.close(); + }); + }); +} From 11aae09bb9022c5fadabe9784c553c2d6fd2dfb4 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Tue, 3 Nov 2026 09:50:26 +0000 Subject: [PATCH 2/2] fix(hyrox): import hyrox_plan for the HyroxStationX extension (.key) Co-authored-by: BucciMobile --- lib/state/hyrox_live_taps.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/state/hyrox_live_taps.dart b/lib/state/hyrox_live_taps.dart index 7fa25e8c1..0e423108a 100644 --- a/lib/state/hyrox_live_taps.dart +++ b/lib/state/hyrox_live_taps.dart @@ -19,6 +19,7 @@ import 'package:sqflite/sqflite.dart'; +import '../data/hyrox_plan.dart'; import '../data/hyrox_store.dart'; import 'hyrox_reconciler.dart' show kMinTaps, kTapWindowSec, kMinSegmentSec;