Skip to content
10 changes: 10 additions & 0 deletions lib/state/app_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,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';
Expand Down Expand Up @@ -393,6 +394,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(
Expand Down Expand Up @@ -2998,6 +3005,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].
Expand Down
125 changes: 125 additions & 0 deletions lib/state/hyrox_live_taps.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// 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_plan.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<int> _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<Database> Function() _db;
final void Function(String line)? log;

final LiveBurstDetector _detector = LiveBurstDetector();

/// Feed every live band event here. Cheap for non-tap events.
Future<void> 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<void> _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})');
}
}
}
158 changes: 158 additions & 0 deletions test/hyrox_live_taps_test.dart
Original file line number Diff line number Diff line change
@@ -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<Database> freshDb(String name) async {
LocalDb.dbName = name;
final dir = await databaseFactory.getDatabasesPath();
await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName));
return LocalDb.instance;
}

Future<HyroxLiveTapHandler> handlerFor(Database db, {void Function(String)? log}) async {
return HyroxLiveTapHandler(() async => db, log: log);
}

Future<void> 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();
});
});
}
Loading