989 lines
39 KiB
Dart
989 lines
39 KiB
Dart
import 'dart:async';
|
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter/foundation.dart' show debugPrint;
|
|
import '../../database/database.dart' hide TelemetrySample;
|
|
import 'package:drift/drift.dart' as drift;
|
|
import 'package:geolocator/geolocator.dart';
|
|
import '../../models/telemetry_sample.dart';
|
|
import '../../models/field_test_event.dart';
|
|
import '../../models/recording_health_status.dart';
|
|
import '../../services/telemetry_service.dart';
|
|
import '../../services/audio_alert_service.dart';
|
|
import '../records/records_service.dart';
|
|
import '../garage/garage_providers.dart';
|
|
import '../../services/field_test/field_test_logger.dart';
|
|
|
|
enum RideState { idle, ready, riding, paused, finished }
|
|
|
|
class RideSession {
|
|
final int? dbId;
|
|
final RideState state;
|
|
final RecordingHealthStatus healthStatus;
|
|
final double tripDistanceMiles;
|
|
final double topSpeedMph;
|
|
final double maxLeanLeft;
|
|
final double maxLeanRight;
|
|
final double maxAccelG;
|
|
final double maxBrakingG;
|
|
final DateTime? startTime;
|
|
|
|
// Phase 4 Stats
|
|
final int movingTimeSeconds;
|
|
final int stoppedTimeSeconds;
|
|
final double elevationGain;
|
|
final double elevationLoss;
|
|
final double averageSpeedMph;
|
|
final int totalSamplesReceived;
|
|
final int totalSamplesPersisted;
|
|
|
|
RideSession({
|
|
this.dbId,
|
|
this.state = RideState.idle,
|
|
this.healthStatus = RecordingHealthStatus.recordingArmed,
|
|
this.tripDistanceMiles = 0.0,
|
|
this.topSpeedMph = 0.0,
|
|
this.maxLeanLeft = 0.0,
|
|
this.maxLeanRight = 0.0,
|
|
this.maxAccelG = 0.0,
|
|
this.maxBrakingG = 0.0,
|
|
this.startTime,
|
|
this.movingTimeSeconds = 0,
|
|
this.stoppedTimeSeconds = 0,
|
|
this.elevationGain = 0.0,
|
|
this.elevationLoss = 0.0,
|
|
this.averageSpeedMph = 0.0,
|
|
this.totalSamplesReceived = 0,
|
|
this.totalSamplesPersisted = 0,
|
|
});
|
|
|
|
RideSession copyWith({
|
|
int? dbId,
|
|
RideState? state,
|
|
RecordingHealthStatus? healthStatus,
|
|
double? tripDistanceMiles,
|
|
double? topSpeedMph,
|
|
double? maxLeanLeft,
|
|
double? maxLeanRight,
|
|
double? maxAccelG,
|
|
double? maxBrakingG,
|
|
DateTime? startTime,
|
|
int? movingTimeSeconds,
|
|
int? stoppedTimeSeconds,
|
|
double? elevationGain,
|
|
double? elevationLoss,
|
|
double? averageSpeedMph,
|
|
int? totalSamplesReceived,
|
|
int? totalSamplesPersisted,
|
|
}) {
|
|
return RideSession(
|
|
dbId: dbId ?? this.dbId,
|
|
state: state ?? this.state,
|
|
healthStatus: healthStatus ?? this.healthStatus,
|
|
tripDistanceMiles: tripDistanceMiles ?? this.tripDistanceMiles,
|
|
topSpeedMph: topSpeedMph ?? this.topSpeedMph,
|
|
maxLeanLeft: maxLeanLeft ?? this.maxLeanLeft,
|
|
maxLeanRight: maxLeanRight ?? this.maxLeanRight,
|
|
maxAccelG: maxAccelG ?? this.maxAccelG,
|
|
maxBrakingG: maxBrakingG ?? this.maxBrakingG,
|
|
startTime: startTime ?? this.startTime,
|
|
movingTimeSeconds: movingTimeSeconds ?? this.movingTimeSeconds,
|
|
stoppedTimeSeconds: stoppedTimeSeconds ?? this.stoppedTimeSeconds,
|
|
elevationGain: elevationGain ?? this.elevationGain,
|
|
elevationLoss: elevationLoss ?? this.elevationLoss,
|
|
averageSpeedMph: averageSpeedMph ?? this.averageSpeedMph,
|
|
totalSamplesReceived: totalSamplesReceived ?? this.totalSamplesReceived,
|
|
totalSamplesPersisted: totalSamplesPersisted ?? this.totalSamplesPersisted,
|
|
);
|
|
}
|
|
}
|
|
|
|
class RideStateMachine extends StateNotifier<RideSession> {
|
|
final AppDatabase db;
|
|
final TelemetryService telemetryService;
|
|
final RecordsService recordsService;
|
|
final AudioAlertService audioAlertService;
|
|
final Ref ref;
|
|
|
|
StreamSubscription? _telemetrySub;
|
|
|
|
TelemetrySample? _lastSample;
|
|
double _accumulatedDistance = 0; // meters internally
|
|
double _movingTimeAccumulator = 0.0; // seconds internally
|
|
double _stoppedTimeAccumulator = 0.0; // seconds internally
|
|
|
|
// Batching
|
|
final List<TelemetrySamplesCompanion> _telemetryBatch = [];
|
|
Timer? _batchWriteTimer;
|
|
Timer? _watchdogTimer;
|
|
DateTime? _lastTelemetryReceivedTime;
|
|
|
|
// Event Debouncing (Store last trigger time per event type)
|
|
final Map<String, DateTime> _lastEventTimes = {};
|
|
static const Duration _eventCooldown = Duration(seconds: 5);
|
|
|
|
// RID-1: re-entrancy guard for ride start. Raised synchronously before the
|
|
// first await inside startRide() so concurrent callers cannot each create
|
|
// their own ride row and cloudRideId. Its lifetime is owned exclusively by
|
|
// the in-flight start operation (try/finally); it is intentionally NOT
|
|
// cleared anywhere else, because clearing it while a start is still awaiting
|
|
// would re-open the duplicate-start race.
|
|
bool _isStarting = false;
|
|
|
|
// RID-2: re-entrancy guard for ride finish. finishRide() previously left
|
|
// state.state == riding across every await (only healthStatus changed), so
|
|
// two rapid Stop taps both entered finalization and duplicated db.endRide,
|
|
// personal-record evaluation, logger.stopSession, the field-test summary row
|
|
// and the "Session ended" announcement. Raised synchronously before the
|
|
// first await; lifetime is owned exclusively by the in-flight finish
|
|
// operation (try/finally) and it is intentionally NOT cleared anywhere else.
|
|
bool _isFinishing = false;
|
|
|
|
// RID-3A: re-entrancy guard for telemetry batch persistence. Raised
|
|
// synchronously before the first await inside _flushBatch(); its lifetime is
|
|
// owned solely by the in-flight persistence operation (try/finally) and it is
|
|
// intentionally NOT cleared anywhere else, because clearing it while a
|
|
// persistence await is outstanding would re-open the shared-buffer race.
|
|
bool _isFlushingTelemetryBatch = false;
|
|
|
|
// RID-3B-1: coordination primitive exposing the in-flight telemetry
|
|
// persistence operation, so an explicit Stop can await the current owner
|
|
// instead of observing a no-op flush. This is NOT a second buffer and NOT a
|
|
// serialization framework: it publishes exactly one Future for exactly one
|
|
// owned snapshot and is cleared when that snapshot's attempt settles.
|
|
Future<void>? _activeTelemetryFlush;
|
|
|
|
// RID-3B-1: late private telemetry-acceptance cutoff (invariant I3). Owned
|
|
// solely by the in-flight finish operation and reopened in its finally path,
|
|
// so a failed-but-retryable ride keeps recording and the next ride is
|
|
// unaffected. Strictly private: no public RideState or RideSession change.
|
|
bool _telemetryAcceptanceClosed = false;
|
|
|
|
// RID-3B-1: bound on the convergent final drain. Telemetry arrives at 20 Hz
|
|
// (one sample per 50 ms) while each pass awaits a single db.batch round-trip,
|
|
// so the samples that can accumulate during a pass are normally 0 or 1 and
|
|
// convergence is reached in 1-2 passes. Four passes tolerate a persistence
|
|
// latency of roughly 150 ms per pass while still bounding finalization to at
|
|
// most four batch writes. The bound never fabricates durability: if telemetry
|
|
// is still outstanding when it is exhausted, finalization FAILS and remains
|
|
// retryable rather than transitioning to finished.
|
|
static const int _maxFinalDrainPasses = 4;
|
|
|
|
RideStateMachine(this.db, this.telemetryService, this.recordsService, this.audioAlertService, this.ref) : super(RideSession()) {
|
|
_telemetrySub = telemetryService.telemetryStream.listen(_onTelemetryUpdate);
|
|
|
|
// Write to DB every 3 seconds for tight crash-durability
|
|
_batchWriteTimer = Timer.periodic(const Duration(seconds: 3), (_) => _runScheduledFlush());
|
|
|
|
// Watchdog timer (every 2 seconds) checks if telemetry pipeline is alive
|
|
_watchdogTimer = Timer.periodic(const Duration(seconds: 2), (_) => _checkWatchdog());
|
|
|
|
// Recover any unfinished rides from previous sessions
|
|
_recoverInterruptedRides();
|
|
}
|
|
|
|
void _checkWatchdog() {
|
|
if (state.state != RideState.riding) return;
|
|
|
|
final now = DateTime.now();
|
|
if (_lastTelemetryReceivedTime != null) {
|
|
final elapsedSec = now.difference(_lastTelemetryReceivedTime!).inSeconds;
|
|
if (elapsedSec > 4) {
|
|
// Telemetry pipeline has stalled
|
|
if (state.healthStatus != RecordingHealthStatus.recordingDegraded) {
|
|
state = state.copyWith(healthStatus: RecordingHealthStatus.recordingDegraded);
|
|
try {
|
|
ref.read(fieldTestLoggerProvider).log(
|
|
eventType: FieldTestEventType.telemetryGap,
|
|
severity: EventSeverity.warning,
|
|
message: 'Watchdog: Telemetry pipeline stalled ($elapsedSec seconds without samples)',
|
|
);
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _recoverInterruptedRides() async {
|
|
try {
|
|
final unclosedRides = await (db.select(db.rides)
|
|
..where((r) => r.endTime.isNull())
|
|
).get();
|
|
|
|
for (final r in unclosedRides) {
|
|
// Find last recorded telemetry sample for this ride to determine actual end time
|
|
final lastSample = await (db.select(db.telemetrySamples)
|
|
..where((t) => t.rideId.equals(r.id))
|
|
..orderBy([(t) => drift.OrderingTerm(expression: t.timestamp, mode: drift.OrderingMode.desc)])
|
|
..limit(1)
|
|
).getSingleOrNull();
|
|
|
|
final calculatedEndTime = lastSample?.timestamp ?? r.startTime.add(Duration(seconds: r.movingTime + r.stoppedTime));
|
|
|
|
await (db.update(db.rides)..where((t) => t.id.equals(r.id))).write(
|
|
RidesCompanion(
|
|
endTime: drift.Value(calculatedEndTime),
|
|
),
|
|
);
|
|
|
|
try {
|
|
final logger = ref.read(fieldTestLoggerProvider);
|
|
logger.log(
|
|
eventType: FieldTestEventType.databaseRecovery,
|
|
severity: EventSeverity.critical,
|
|
message: 'Interrupted ride #${r.id} recovered after unexpected exit',
|
|
rideId: r.id,
|
|
);
|
|
await logger.generateAndSaveSummary(r.id, wasRecovered: true);
|
|
} catch (_) {}
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
void setReady() {
|
|
state = state.copyWith(
|
|
state: RideState.ready,
|
|
healthStatus: RecordingHealthStatus.recordingArmed,
|
|
);
|
|
}
|
|
|
|
/// RID-1: idempotent ride start.
|
|
///
|
|
/// The guard is raised synchronously, before the first `await`, so that
|
|
/// concurrent callers — the 20 Hz motion auto-start in [_onTelemetryUpdate]
|
|
/// and rapid taps on the dashboard Start buttons — collapse into a single
|
|
/// start operation instead of each minting its own ride row, `cloudRideId`
|
|
/// and orphaned open ride.
|
|
///
|
|
/// The guard is released in `finally` so a genuinely failed start never
|
|
/// leaves the machine permanently unable to record, and a later legitimate
|
|
/// retry still succeeds. Frozen semantics (auto-start threshold, PR gating,
|
|
/// kickstand isolation, recovery, `cloudRideId` generation) are untouched.
|
|
Future<void> startRide() async {
|
|
if (_isStarting) return;
|
|
if (state.state == RideState.riding) return;
|
|
_isStarting = true;
|
|
try {
|
|
await _performStartRide();
|
|
} finally {
|
|
_isStarting = false;
|
|
}
|
|
}
|
|
|
|
/// Performs the actual ride start. Guarded exclusively by [startRide] and
|
|
/// must never be invoked directly.
|
|
Future<void> _performStartRide() async {
|
|
try {
|
|
ref.read(fieldTestLoggerProvider).log(
|
|
eventType: FieldTestEventType.rideStartRequested,
|
|
severity: EventSeverity.info,
|
|
message: 'Ride start requested by user or motion trigger',
|
|
);
|
|
} catch (_) {}
|
|
|
|
final defaultBike = ref.read(defaultMotorcycleProvider);
|
|
final startTime = DateTime.now();
|
|
final id = await db.startRide(
|
|
startTime: startTime,
|
|
motorcycleId: defaultBike?.id,
|
|
motorcycleYear: defaultBike?.year,
|
|
motorcycleMake: defaultBike?.makeName,
|
|
motorcycleModel: defaultBike?.modelName,
|
|
motorcycleVariant: defaultBike?.variantName,
|
|
);
|
|
|
|
state = state.copyWith(
|
|
dbId: id,
|
|
state: RideState.riding,
|
|
healthStatus: RecordingHealthStatus.recordingActive,
|
|
startTime: startTime,
|
|
tripDistanceMiles: 0,
|
|
topSpeedMph: 0,
|
|
maxLeanLeft: 0,
|
|
maxLeanRight: 0,
|
|
maxAccelG: 0,
|
|
maxBrakingG: 0,
|
|
averageSpeedMph: 0,
|
|
movingTimeSeconds: 0,
|
|
stoppedTimeSeconds: 0,
|
|
elevationGain: 0,
|
|
elevationLoss: 0,
|
|
totalSamplesReceived: 0,
|
|
totalSamplesPersisted: 0,
|
|
);
|
|
_lastSample = null;
|
|
_accumulatedDistance = 0;
|
|
_movingTimeAccumulator = 0.0;
|
|
_stoppedTimeAccumulator = 0.0;
|
|
_lastTelemetryReceivedTime = DateTime.now();
|
|
|
|
try {
|
|
WakelockPlus.enable().catchError((_) {});
|
|
} catch (_) {}
|
|
audioAlertService.speak('Session started');
|
|
|
|
// Auto-start Field Test Session with full forensic logging
|
|
try {
|
|
final logger = ref.read(fieldTestLoggerProvider);
|
|
logger.startSession(id);
|
|
logger.log(
|
|
eventType: FieldTestEventType.foregroundServiceStarted,
|
|
severity: EventSeverity.info,
|
|
message: 'Foreground service confirmed active for ride #$id',
|
|
rideId: id,
|
|
);
|
|
logger.log(
|
|
eventType: FieldTestEventType.wakelockAcquired,
|
|
severity: EventSeverity.info,
|
|
message: 'Screen wakelock acquired for ride #$id',
|
|
rideId: id,
|
|
);
|
|
logger.log(
|
|
eventType: FieldTestEventType.telemetryPipelineStarted,
|
|
severity: EventSeverity.info,
|
|
message: '20Hz telemetry aggregation pipeline started for ride #$id',
|
|
rideId: id,
|
|
);
|
|
} catch (_) {}
|
|
}
|
|
|
|
void pauseRide() {
|
|
state = state.copyWith(state: RideState.paused);
|
|
try {
|
|
WakelockPlus.disable().catchError((_) {});
|
|
} catch (_) {}
|
|
try {
|
|
ref.read(fieldTestLoggerProvider).log(
|
|
eventType: FieldTestEventType.appPaused,
|
|
severity: EventSeverity.info,
|
|
message: 'Ride session paused',
|
|
);
|
|
} catch (_) {}
|
|
}
|
|
|
|
void resumeRide() {
|
|
state = state.copyWith(state: RideState.riding, healthStatus: RecordingHealthStatus.recordingActive);
|
|
_lastSample = null; // reset to avoid jumping
|
|
_lastTelemetryReceivedTime = DateTime.now();
|
|
try {
|
|
WakelockPlus.enable().catchError((_) {});
|
|
} catch (_) {}
|
|
try {
|
|
ref.read(fieldTestLoggerProvider).log(
|
|
eventType: FieldTestEventType.appResumed,
|
|
severity: EventSeverity.info,
|
|
message: 'Ride session resumed',
|
|
);
|
|
} catch (_) {}
|
|
}
|
|
|
|
/// RID-2: idempotent ride finish.
|
|
///
|
|
/// The guard is raised synchronously, before the first `await`, so concurrent
|
|
/// Stop taps collapse into a single finalization instead of each persisting
|
|
/// their own end-of-ride write and forensic summary. A finish after a
|
|
/// successful completion is a no-op.
|
|
///
|
|
/// The guard is released in `finally`, so a persistence failure never leaves
|
|
/// the machine permanently unable to finalize and a later explicit retry
|
|
/// still succeeds. Failure semantics are deliberately unchanged: a failed
|
|
/// `db.endRide` still propagates to callers that await this method, still
|
|
/// leaves the session in its existing recoverable riding/finalizing state,
|
|
/// and still does not fabricate an `endTime`, emit a finished transition, or
|
|
/// enqueue an unpersisted ride for cloud sync.
|
|
Future<void> finishRide() async {
|
|
if (_isFinishing) return;
|
|
if (state.state == RideState.finished) return;
|
|
_isFinishing = true;
|
|
try {
|
|
await _performFinishRide();
|
|
} finally {
|
|
_isFinishing = false;
|
|
// RID-3B-1: the telemetry-acceptance cutoff is owned solely by this
|
|
// in-flight finish operation, so it is reopened here. A failed but
|
|
// retryable ride therefore keeps recording, and the next ride is
|
|
// unaffected. Deliberately NOT cleared from reset(): source analysis shows
|
|
// reset() has no callers, and clearing finish-owned state elsewhere would
|
|
// weaken the barrier.
|
|
_telemetryAcceptanceClosed = false;
|
|
}
|
|
}
|
|
|
|
/// Performs the actual ride finalization. Guarded exclusively by
|
|
/// [finishRide] and must never be invoked directly.
|
|
Future<void> _performFinishRide() async {
|
|
if (state.dbId != null) {
|
|
state = state.copyWith(healthStatus: RecordingHealthStatus.recordingFinalizing);
|
|
|
|
try {
|
|
ref.read(fieldTestLoggerProvider).log(
|
|
eventType: FieldTestEventType.rideFinishRequested,
|
|
severity: EventSeverity.info,
|
|
message: 'Ride finish requested for ride #${state.dbId}',
|
|
rideId: state.dbId,
|
|
);
|
|
} catch (_) {}
|
|
|
|
// RID-3B-1: the explicit-Stop durability barrier replaces the single
|
|
// tolerant flush. It awaits any in-flight periodic flush owner, drains to
|
|
// convergence, closes telemetry acceptance, and performs a final strict
|
|
// drain - throwing if durability cannot be established (invariant I2).
|
|
await _establishTelemetryDurabilityForFinish();
|
|
|
|
await db.endRide(
|
|
state.dbId!,
|
|
RidesCompanion(
|
|
distanceMiles: drift.Value(state.tripDistanceMiles),
|
|
topSpeedMph: drift.Value(state.topSpeedMph),
|
|
maxLeanLeft: drift.Value(state.maxLeanLeft),
|
|
maxLeanRight: drift.Value(state.maxLeanRight),
|
|
maxAccelG: drift.Value(state.maxAccelG),
|
|
maxBrakingG: drift.Value(state.maxBrakingG),
|
|
movingTime: drift.Value(state.movingTimeSeconds),
|
|
stoppedTime: drift.Value(state.stoppedTimeSeconds),
|
|
averageSpeed: drift.Value(state.averageSpeedMph),
|
|
elevationGain: drift.Value(state.elevationGain),
|
|
elevationLoss: drift.Value(state.elevationLoss),
|
|
),
|
|
);
|
|
|
|
try {
|
|
ref.read(fieldTestLoggerProvider).log(
|
|
eventType: FieldTestEventType.rideFinalized,
|
|
severity: EventSeverity.info,
|
|
message: 'Ride record #${state.dbId} finalized in database',
|
|
rideId: state.dbId,
|
|
);
|
|
} catch (_) {}
|
|
|
|
// Check for personal records
|
|
final runStats = PersonalRecords(
|
|
topSpeedMph: state.topSpeedMph,
|
|
maxLeanLeft: state.maxLeanLeft,
|
|
maxLeanRight: state.maxLeanRight,
|
|
maxAccelG: state.maxAccelG,
|
|
maxBrakingG: state.maxBrakingG,
|
|
longestRideMiles: state.tripDistanceMiles,
|
|
greatestElevationGain: state.elevationGain,
|
|
);
|
|
|
|
await recordsService.checkAndSaveRecords(runStats, (recordName, value) {
|
|
audioAlertService.speak('New record: $recordName');
|
|
});
|
|
}
|
|
|
|
// Auto-stop Field Test Session if active
|
|
try {
|
|
final logger = ref.read(fieldTestLoggerProvider);
|
|
if (logger.isSessionActive) {
|
|
await logger.stopSession();
|
|
logger.log(
|
|
eventType: FieldTestEventType.rideSummaryCreated,
|
|
severity: EventSeverity.info,
|
|
message: 'Field test summary generated and saved for ride #${state.dbId}',
|
|
rideId: state.dbId,
|
|
);
|
|
}
|
|
} catch (_) {}
|
|
|
|
try {
|
|
WakelockPlus.disable().catchError((_) {});
|
|
} catch (_) {}
|
|
audioAlertService.speak('Session ended');
|
|
|
|
// RID-3B-1: the sync-visible finished edge is the LAST side effect of
|
|
// finalization. It is emitted only after telemetry durability, endTime,
|
|
// personal records and the forensic summary are complete, so SyncObserver
|
|
// can keep observing only this edge (invariant I1).
|
|
state = state.copyWith(
|
|
state: RideState.finished,
|
|
healthStatus: RecordingHealthStatus.recordingComplete,
|
|
);
|
|
}
|
|
|
|
void reset() {
|
|
state = RideSession();
|
|
}
|
|
|
|
void _onTelemetryUpdate(TelemetrySample sample) {
|
|
_lastTelemetryReceivedTime = DateTime.now();
|
|
|
|
// Motion-triggered auto-start safeguard:
|
|
// If in `ready` or `idle` and vehicle speed is detected >= 2.0 MPH,
|
|
// automatically promote to active recording so physical rides are NEVER lost!
|
|
if (state.state == RideState.ready) {
|
|
if (sample.speed != null && sample.speed! >= 1.5) {
|
|
startRide();
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (state.state != RideState.riding) return;
|
|
|
|
// RID-3B-1 (invariant I3): telemetry accepted after the finalization
|
|
// boundary is never attributed to the finishing ride. The gate is private
|
|
// and is reopened by finishRide()'s finally path, so a retryable ride and
|
|
// every subsequent ride continue to record normally.
|
|
if (_telemetryAcceptanceClosed) return;
|
|
|
|
// RID-3B-1: incoming telemetry must not clobber recordingFinalizing back to
|
|
// recordingActive while finalization is in progress. Minimum private
|
|
// condition: the existing RID-2 in-flight finish flag.
|
|
if (!_isFinishing &&
|
|
state.healthStatus != RecordingHealthStatus.recordingActive) {
|
|
state = state.copyWith(healthStatus: RecordingHealthStatus.recordingActive);
|
|
}
|
|
|
|
_queueForBatch(sample);
|
|
_processStats(sample);
|
|
|
|
try {
|
|
if (sample.quality != null) {
|
|
ref.read(fieldTestLoggerProvider).processTelemetrySample(sample, sample.quality!);
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
static const int _maxBatchCapacity = 1000;
|
|
|
|
void _queueForBatch(TelemetrySample sample) {
|
|
if (state.dbId == null) return;
|
|
|
|
// Bound queue to prevent memory leaks during extended DB locks
|
|
if (_telemetryBatch.length >= _maxBatchCapacity) {
|
|
_telemetryBatch.removeAt(0);
|
|
try {
|
|
ref.read(fieldTestLoggerProvider).log(
|
|
eventType: FieldTestEventType.databaseQueueOverflow,
|
|
message: 'Telemetry batch buffer reached capacity (1000); dropping oldest sample',
|
|
severity: EventSeverity.warning,
|
|
);
|
|
} catch (_) {}
|
|
}
|
|
|
|
_telemetryBatch.add(
|
|
TelemetrySamplesCompanion(
|
|
rideId: drift.Value(state.dbId!),
|
|
timestamp: drift.Value(sample.timestamp),
|
|
latitude: drift.Value(sample.latitude),
|
|
longitude: drift.Value(sample.longitude),
|
|
altitude: drift.Value(sample.altitude),
|
|
speed: drift.Value(sample.speed),
|
|
heading: drift.Value(sample.heading),
|
|
gpsAccuracy: drift.Value(sample.gpsAccuracy),
|
|
leanAngle: drift.Value(sample.leanAngle),
|
|
pitchAngle: drift.Value(sample.pitchAngle),
|
|
accelX: drift.Value(sample.accelX),
|
|
accelY: drift.Value(sample.accelY),
|
|
accelZ: drift.Value(sample.accelZ),
|
|
longitudinalG: drift.Value(sample.longitudinalG),
|
|
lateralG: drift.Value(sample.lateralG),
|
|
)
|
|
);
|
|
|
|
state = state.copyWith(totalSamplesReceived: state.totalSamplesReceived + 1);
|
|
}
|
|
|
|
/// RID-3A ownership semantics, plus RID-3B-1 active-flush publication.
|
|
///
|
|
/// Takes exclusive ownership of the current buffer contents, removes them from
|
|
/// the live buffer BEFORE awaiting persistence, and restores them to the FRONT
|
|
/// on failure so chronological identity order survives. Returns true only when
|
|
/// the owned snapshot is durable (or there was nothing to own); it never
|
|
/// reports success for a failed persistence.
|
|
///
|
|
/// Tolerant by design for the periodic path: a persistence failure is
|
|
/// retained, restored and logged with the existing bounded warning, never
|
|
/// thrown into the timer callback.
|
|
/// RID-3B-1: finish-only strict drain.
|
|
///
|
|
/// Awaits any active telemetry flush owner first - looping, because a periodic
|
|
/// tick may take ownership again while waiting - then takes ownership under
|
|
/// RID-3A's guard and persists. Returns false whenever durability could not be
|
|
/// established, so finishRide can distinguish success from failure. It never
|
|
/// reports success for a failed persistence and never silently swallows a
|
|
/// durability failure. No automatic retry worker is added.
|
|
Future<bool> _drainTelemetryBatchStrictly() async {
|
|
while (_isFlushingTelemetryBatch) {
|
|
final active = _activeTelemetryFlush;
|
|
if (active == null) break;
|
|
try {
|
|
await active;
|
|
} catch (_) {
|
|
// The owner retains, restores and logs its own failure; ownership is
|
|
// re-checked by the loop condition.
|
|
}
|
|
}
|
|
if (_telemetryBatch.isEmpty) return true;
|
|
// Another owner is still active and could not be awaited, so durability is
|
|
// not established and must not be assumed.
|
|
if (_isFlushingTelemetryBatch) return false;
|
|
return _persistOwnedTelemetrySnapshot();
|
|
}
|
|
|
|
/// RID-3B-1: the explicit-Stop durability barrier (invariants I1-I3).
|
|
///
|
|
/// Awaits any in-flight periodic flush, drains the buffer to convergence within
|
|
/// a bounded policy while telemetry capture continues, closes acceptance, then
|
|
/// performs one final strict drain for anything queued between the last
|
|
/// convergence check and the cutoff assignment.
|
|
///
|
|
/// Throws if durability cannot be established, so the caller aborts
|
|
/// finalization without writing endTime, evaluating personal records,
|
|
/// generating a forensic summary, announcing completion, or emitting the
|
|
/// sync-visible finished edge.
|
|
Future<void> _establishTelemetryDurabilityForFinish() async {
|
|
var durable = true;
|
|
var passes = 0;
|
|
|
|
// Bounded convergent drain. Telemetry keeps being accepted during this phase
|
|
// so existing capture semantics are preserved; the final few samples are
|
|
// never intentionally dropped to simplify finalization.
|
|
while (passes < _maxFinalDrainPasses &&
|
|
(_telemetryBatch.isNotEmpty || _activeTelemetryFlush != null)) {
|
|
passes++;
|
|
if (!await _drainTelemetryBatchStrictly()) {
|
|
durable = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Convergence must actually have been reached. The cap never fabricates
|
|
// durability: telemetry still outstanding means finalization fails.
|
|
if (durable &&
|
|
(_telemetryBatch.isNotEmpty || _activeTelemetryFlush != null)) {
|
|
durable = false;
|
|
}
|
|
|
|
if (durable) {
|
|
// I3: close acceptance, then one final strict drain.
|
|
_telemetryAcceptanceClosed = true;
|
|
durable = await _drainTelemetryBatchStrictly();
|
|
}
|
|
|
|
if (!durable) {
|
|
// I2: restore a truthful recoverable recording status and propagate, so
|
|
// RID-2's existing UI boundary containment handles the rejection and the
|
|
// ride remains retryable. RID-3A has already restored any failed owned
|
|
// snapshot to the front of the buffer in correct order.
|
|
state = state.copyWith(healthStatus: RecordingHealthStatus.recordingActive);
|
|
throw StateError(
|
|
'Ride finalization aborted: telemetry durability could not be established',
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<bool> _persistOwnedTelemetrySnapshot() async {
|
|
if (_isFlushingTelemetryBatch || _telemetryBatch.isEmpty) return true;
|
|
|
|
_isFlushingTelemetryBatch = true;
|
|
final batchToInsert = List<TelemetrySamplesCompanion>.from(_telemetryBatch);
|
|
_telemetryBatch.clear();
|
|
// Publish this exact in-flight operation so an explicit Stop can await the
|
|
// current owner instead of observing a no-op flush. One Future for one owned
|
|
// snapshot - a coordination primitive, not a second buffering system.
|
|
final completion = Completer<void>();
|
|
_activeTelemetryFlush = completion.future;
|
|
try {
|
|
// ---- PRE-COMMIT PHASE ---------------------------------------------------
|
|
// This is the ONLY block that may fail persistence. RID-3A ownership,
|
|
// ordering and front-restoration semantics are unchanged.
|
|
try {
|
|
await db.batch((batch) {
|
|
batch.insertAll(db.telemetrySamples, batchToInsert);
|
|
});
|
|
} catch (e) {
|
|
// Persistence failed BEFORE the commit point, so the owned snapshot is
|
|
// not durable. Restore it to the FRONT of the live buffer, preserving
|
|
// both the snapshot's internal order and the order of samples that
|
|
// arrived during the await, so a later legitimate flush can retry them.
|
|
// No automatic retry worker is added.
|
|
_telemetryBatch.insertAll(0, batchToInsert);
|
|
// Keep in queue for retry on next flush interval. A notifier that has
|
|
// begun disposal must not touch ref, so the diagnostic is skipped in that
|
|
// case; it remains bounded either way.
|
|
if (mounted) {
|
|
try {
|
|
ref.read(fieldTestLoggerProvider).log(
|
|
eventType: FieldTestEventType.databaseQueueWarning,
|
|
message: 'Database batch write retry: $e',
|
|
severity: EventSeverity.warning,
|
|
);
|
|
} catch (_) {}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ---- COMMIT POINT REACHED -----------------------------------------------
|
|
// The successful return of db.batch(...) above is the telemetry durability
|
|
// commit point. The owned snapshot is now durable: nothing below may restore
|
|
// it to the live buffer or change the durability verdict.
|
|
|
|
// Post-commit bookkeeping is notifier-owned, so it is conditional on the
|
|
// notifier still being alive and isolated in its own guard. `mounted` is
|
|
// StateNotifier's real property, so this boundary is identical in debug and
|
|
// release - correctness never depends on a debug assertion firing. After
|
|
// disposal there is no state read, no state assignment and no ref access.
|
|
//
|
|
// Accepted RID-3B-2 counter contract: when disposal races a committed flush,
|
|
// the durable row count may exceed the dead notifier's final in-memory
|
|
// totalSamplesPersisted value. That is a terminal counter snapshot on a
|
|
// disposed notifier, NOT a persistence failure, and no live consumer may
|
|
// rely on it. Committed samples are never restored to make the counter look
|
|
// consistent, and no database counter or schema field is added.
|
|
if (mounted) {
|
|
try {
|
|
state = state.copyWith(totalSamplesPersisted: state.totalSamplesPersisted + batchToInsert.length);
|
|
} catch (_) {
|
|
// Post-commit bookkeeping failure must never be classified as a
|
|
// persistence failure and must never restore the committed snapshot.
|
|
}
|
|
}
|
|
return true;
|
|
} finally {
|
|
_isFlushingTelemetryBatch = false;
|
|
if (!completion.isCompleted) completion.complete();
|
|
_activeTelemetryFlush = null;
|
|
}
|
|
}
|
|
|
|
/// RID-3B-2 timer Future ownership.
|
|
///
|
|
/// A Timer.periodic callback is synchronous, so the Future returned by
|
|
/// _flushBatch() would otherwise be discarded and any rejection would surface
|
|
/// as an uncaught zone error attributed to the timer callback. The Future is
|
|
/// explicitly owned here instead.
|
|
///
|
|
/// This is a defensive ownership boundary only: no second scheduler, no new
|
|
/// timer, and no change to the 3-second crash-durability cadence. The timer
|
|
/// callback itself stays synchronous, so cancellation semantics are unchanged.
|
|
///
|
|
/// Diagnostics are bounded to runtimeType only. The exception object is never
|
|
/// logged because its message may carry implementation or database detail, and
|
|
/// no stack trace or telemetry payload is recorded.
|
|
void _runScheduledFlush() {
|
|
unawaited(
|
|
_flushBatch().catchError((Object error, StackTrace stackTrace) {
|
|
debugPrint('Scheduled telemetry flush failed: ${error.runtimeType}');
|
|
}),
|
|
);
|
|
}
|
|
|
|
Future<void> _flushBatch() async {
|
|
// RID-3A telemetry batch ownership (exclusive snapshot, clear-before-await,
|
|
// front-restoration on failure, success-only counter) now lives in
|
|
// _persistOwnedTelemetrySnapshot(), unchanged in behaviour. RID-3B-1 also
|
|
// publishes the in-flight operation there so an explicit Stop can await the
|
|
// current owner instead of observing a no-op flush. The periodic path stays
|
|
// tolerant and never throws into the timer callback; timer Future ownership
|
|
// and teardown safety are RID-3B-2 scope and are deliberately unchanged.
|
|
//
|
|
// The guard is raised synchronously, before the first `await`, so a
|
|
// competing legitimate flush (the 3 s periodic timer against the flush
|
|
// awaited by finishRide) returns without taking ownership instead of
|
|
// snapshotting the very same samples.
|
|
//
|
|
// The snapshot is removed from the live buffer BEFORE the persistence await,
|
|
// so the in-flight operation owns those samples exclusively and telemetry
|
|
// arriving during the await accumulates in a fresh live buffer. Nothing is
|
|
// removed from the live buffer after the await - that post-await
|
|
// `removeRange` is what previously allowed an overlapping flush to discard
|
|
// newer samples it had never written, while re-inserting samples another
|
|
// flush had already persisted.
|
|
//
|
|
// The incremental ride-stats persistence below is deliberately left outside
|
|
// this guard so its existing every-tick cadence is unchanged.
|
|
await _persistOwnedTelemetrySnapshot();
|
|
|
|
// RID-3B-2: the notifier may have been disposed while this telemetry flush
|
|
// was in flight, because Timer.cancel() cannot abort an already-started async
|
|
// callback. The database commit above is already authoritative and is not
|
|
// revisited; everything below is notifier-owned, so return without reading
|
|
// state or ref. Incremental stats are never fabricated after disposal, and
|
|
// stats persistence is never moved ahead of the telemetry commit.
|
|
if (!mounted) return;
|
|
|
|
// Incrementally persist ongoing ride summary stats to SQLite so crashes/terminations never lose stats
|
|
if (state.dbId != null && state.state == RideState.riding) {
|
|
try {
|
|
await (db.update(db.rides)..where((t) => t.id.equals(state.dbId!))).write(
|
|
RidesCompanion(
|
|
distanceMiles: drift.Value(state.tripDistanceMiles),
|
|
topSpeedMph: drift.Value(state.topSpeedMph),
|
|
maxLeanLeft: drift.Value(state.maxLeanLeft),
|
|
maxLeanRight: drift.Value(state.maxLeanRight),
|
|
maxAccelG: drift.Value(state.maxAccelG),
|
|
maxBrakingG: drift.Value(state.maxBrakingG),
|
|
movingTime: drift.Value(state.movingTimeSeconds),
|
|
stoppedTime: drift.Value(state.stoppedTimeSeconds),
|
|
averageSpeed: drift.Value(state.averageSpeedMph),
|
|
elevationGain: drift.Value(state.elevationGain),
|
|
elevationLoss: drift.Value(state.elevationLoss),
|
|
),
|
|
);
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
void _processStats(TelemetrySample sample) {
|
|
// Completely ignore rejected samples (NaN, teleportation jumps, extreme spikes)
|
|
if (sample.quality?.isRejected == true) return;
|
|
|
|
double newTopSpeed = state.topSpeedMph;
|
|
double newMaxLeanLeft = state.maxLeanLeft;
|
|
double newMaxLeanRight = state.maxLeanRight;
|
|
double newMaxAccelG = state.maxAccelG;
|
|
double newMaxBrakingG = state.maxBrakingG;
|
|
int newMovingTime = state.movingTimeSeconds;
|
|
int newStoppedTime = state.stoppedTimeSeconds;
|
|
double newElevationGain = state.elevationGain;
|
|
double newElevationLoss = state.elevationLoss;
|
|
|
|
if (sample.speed != null && sample.speed! <= 220.0) {
|
|
if (sample.speed! > state.topSpeedMph) {
|
|
newTopSpeed = sample.speed!;
|
|
}
|
|
}
|
|
|
|
// Continuous sub-second time accumulation across 20Hz frames
|
|
if (_lastSample != null && sample.speed != null) {
|
|
final dt = sample.timestamp.difference(_lastSample!.timestamp).inMilliseconds / 1000.0;
|
|
if (dt > 0 && dt < 2.0) {
|
|
if (sample.speed! > 1.8) {
|
|
_movingTimeAccumulator += dt;
|
|
} else {
|
|
_stoppedTimeAccumulator += dt;
|
|
}
|
|
newMovingTime = _movingTimeAccumulator.floor();
|
|
newStoppedTime = _stoppedTimeAccumulator.floor();
|
|
}
|
|
}
|
|
|
|
// Speed-gate dynamic motorcycle metrics (require speed >= 3.0 MPH to prevent stationary kickstand / dismount artifacts from registering as personal riding records)
|
|
final isDynamicallyMoving = sample.speed != null && sample.speed! >= 3.0;
|
|
|
|
if (isDynamicallyMoving && sample.leanAngle != null && sample.leanAngle!.abs() <= 65.0) {
|
|
if (sample.leanAngle! < 0 && sample.leanAngle!.abs() > state.maxLeanLeft) {
|
|
newMaxLeanLeft = sample.leanAngle!.abs();
|
|
} else if (sample.leanAngle! > 0 && sample.leanAngle! > state.maxLeanRight) {
|
|
newMaxLeanRight = sample.leanAngle!;
|
|
}
|
|
}
|
|
|
|
if (isDynamicallyMoving && sample.longitudinalG != null && sample.longitudinalG!.abs() <= 2.5) {
|
|
if (sample.longitudinalG! > 0 && sample.longitudinalG! > state.maxAccelG) {
|
|
newMaxAccelG = sample.longitudinalG!;
|
|
} else if (sample.longitudinalG! < 0 && sample.longitudinalG!.abs() > state.maxBrakingG) {
|
|
newMaxBrakingG = sample.longitudinalG!.abs();
|
|
}
|
|
}
|
|
|
|
if (sample.latitude != null && sample.longitude != null && _lastSample?.latitude != null && _lastSample?.longitude != null) {
|
|
final distMeters = Geolocator.distanceBetween(
|
|
_lastSample!.latitude!,
|
|
_lastSample!.longitude!,
|
|
sample.latitude!,
|
|
sample.longitude!,
|
|
);
|
|
|
|
// Accumulate distance ONLY if moving and GPS accuracy is reasonable (suppresses stationary jitter at stop lights)
|
|
final isMoving = (sample.speed != null && sample.speed! >= 1.0) || (_lastSample!.speed != null && _lastSample!.speed! >= 1.0);
|
|
final hasGoodGps = sample.gpsAccuracy != null && sample.gpsAccuracy! <= 35.0;
|
|
if (isMoving && hasGoodGps && distMeters > 0.3 && distMeters < 150.0) {
|
|
_accumulatedDistance += distMeters;
|
|
}
|
|
|
|
// Elevation tracking
|
|
if (sample.altitude != null && _lastSample!.altitude != null) {
|
|
final elevationDelta = sample.altitude! - _lastSample!.altitude!;
|
|
// Threshold filter to prevent phantom gain (e.g. > 1 meter change)
|
|
if (elevationDelta.abs() > 1.0 && elevationDelta.abs() < 50.0) {
|
|
if (elevationDelta > 0) {
|
|
newElevationGain += elevationDelta;
|
|
} else {
|
|
newElevationLoss += elevationDelta.abs();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
double tripDistanceMiles = _accumulatedDistance / 1609.34;
|
|
|
|
// Calculate avg speed (only moving time)
|
|
double avgSpeed = 0;
|
|
if (newMovingTime > 0) {
|
|
// miles per hour
|
|
avgSpeed = (tripDistanceMiles / newMovingTime) * 3600;
|
|
}
|
|
|
|
_detectEvents(sample);
|
|
|
|
_lastSample = sample;
|
|
|
|
state = state.copyWith(
|
|
topSpeedMph: newTopSpeed,
|
|
maxLeanLeft: newMaxLeanLeft,
|
|
maxLeanRight: newMaxLeanRight,
|
|
maxAccelG: newMaxAccelG,
|
|
maxBrakingG: newMaxBrakingG,
|
|
tripDistanceMiles: tripDistanceMiles,
|
|
movingTimeSeconds: newMovingTime,
|
|
stoppedTimeSeconds: newStoppedTime,
|
|
elevationGain: newElevationGain,
|
|
elevationLoss: newElevationLoss,
|
|
averageSpeedMph: avgSpeed,
|
|
);
|
|
}
|
|
|
|
void _detectEvents(TelemetrySample sample) {
|
|
if (state.dbId == null) return;
|
|
final now = DateTime.now();
|
|
|
|
void checkAndLogEvent(String type, double value, bool condition) {
|
|
if (condition) {
|
|
final lastTime = _lastEventTimes[type];
|
|
if (lastTime == null || now.difference(lastTime) > _eventCooldown) {
|
|
_lastEventTimes[type] = now;
|
|
db.into(db.rideEvents).insert(RideEventsCompanion.insert(
|
|
rideId: state.dbId!,
|
|
timestamp: now,
|
|
eventType: type,
|
|
value: value,
|
|
latitude: drift.Value(sample.latitude),
|
|
longitude: drift.Value(sample.longitude),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (sample.longitudinalG != null) {
|
|
checkAndLogEvent('Hard Braking', sample.longitudinalG!, sample.longitudinalG! < -0.8);
|
|
checkAndLogEvent('Hard Acceleration', sample.longitudinalG!, sample.longitudinalG! > 0.6);
|
|
}
|
|
|
|
if (sample.leanAngle != null) {
|
|
checkAndLogEvent('Extreme Lean', sample.leanAngle!, sample.leanAngle!.abs() > 45.0);
|
|
}
|
|
|
|
if (sample.speed != null) {
|
|
checkAndLogEvent('High Speed', sample.speed!, sample.speed! > 100.0);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_telemetrySub?.cancel();
|
|
_batchWriteTimer?.cancel();
|
|
_watchdogTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
final rideStateMachineProvider = StateNotifierProvider<RideStateMachine, RideSession>((ref) {
|
|
final db = ref.watch(databaseProvider);
|
|
final telemetryService = ref.watch(telemetryServiceProvider);
|
|
final recordsService = ref.watch(recordsServiceProvider.notifier);
|
|
final audioAlertService = ref.watch(audioAlertServiceProvider);
|
|
return RideStateMachine(db, telemetryService, recordsService, audioAlertService, ref);
|
|
});
|