MotorSignal-App/lib/services/field_test/field_test_logger.dart

734 lines
22 KiB
Dart

import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:drift/drift.dart' as drift;
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../database/database.dart';
import 'field_test_models.dart';
final fieldTestModeEnabledProvider = StateNotifierProvider<FieldTestModeNotifier, bool>((ref) {
return FieldTestModeNotifier();
});
class FieldTestModeNotifier extends StateNotifier<bool> {
static const _prefKey = 'field_test_mode_enabled';
FieldTestModeNotifier() : super(false) {
_load();
}
Future<void> _load() async {
try {
final prefs = await SharedPreferences.getInstance();
state = prefs.getBool(_prefKey) ?? false;
} catch (_) {}
}
Future<void> setEnabled(bool enabled) async {
state = enabled;
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefKey, enabled);
} catch (_) {}
}
}
final fieldTestLoggerProvider = Provider<FieldTestLogger>((ref) {
final db = ref.watch(databaseProvider);
final logger = FieldTestLogger(db, ref);
ref.onDispose(() => logger.dispose());
return logger;
});
final activeFieldTestSessionProvider = StateProvider<FieldTestSession?>((ref) => null);
class FieldTestLogger {
final AppDatabase _db;
final Ref _ref;
int? _activeSessionId;
int? get activeSessionId => _activeSessionId;
bool get isSessionActive => _activeSessionId != null;
// In-memory circular buffer for Live Log UI (latest 500 entries)
final ListQueue<FieldTestEventItem> _liveLogBuffer = ListQueue(500);
List<FieldTestEventItem> get liveLogs => _liveLogBuffer.toList();
final _liveLogStreamController = StreamController<List<FieldTestEventItem>>.broadcast();
Stream<List<FieldTestEventItem>> get liveLogStream => _liveLogStreamController.stream;
// Batches
final List<FieldTestEventsCompanion> _eventBatch = [];
final List<FieldTestGpsRecordsCompanion> _gpsBatch = [];
final List<FieldTestImuRecordsCompanion> _imuBatch = [];
Timer? _flushTimer;
// Aggregated Diagnostic Metrics for Active Session
int _totalEvents = 0;
int get totalEvents => _totalEvents;
int _anomalyCount = 0;
int get anomalyCount => _anomalyCount;
int _gpsFixCount = 0;
int get gpsFixCount => _gpsFixCount;
int _gpsRejectedCount = 0;
int get gpsRejectedCount => _gpsRejectedCount;
int _gpsOutageCount = 0;
int get gpsOutageCount => _gpsOutageCount;
DateTime? _lastGpsLossTime;
double _totalGpsOutageDurationSeconds = 0.0;
double get totalGpsOutageDurationSeconds => _totalGpsOutageDurationSeconds;
double _longestGpsOutageSeconds = 0.0;
double get longestGpsOutageSeconds => _longestGpsOutageSeconds;
int _gpsBaselineResetCount = 0;
int get gpsBaselineResetCount => _gpsBaselineResetCount;
int _imuSampleCount = 0;
int get imuSampleCount => _imuSampleCount;
int _imuSpikeFilteredCount = 0;
int get imuSpikeFilteredCount => _imuSpikeFilteredCount;
int _dbBatchCount = 0;
int get dbBatchCount => _dbBatchCount;
int _dbBatchFailures = 0;
int get dbBatchFailures => _dbBatchFailures;
double _maxFlushDelayMs = 0.0;
double get maxFlushDelayMs => _maxFlushDelayMs;
int _roadAlertsReceived = 0;
int get roadAlertsReceived => _roadAlertsReceived;
int _policeAlerts = 0;
int get policeAlerts => _policeAlerts;
int _speedCameraAlerts = 0;
int get speedCameraAlerts => _speedCameraAlerts;
int _accidentHazardAlerts = 0;
int get accidentHazardAlerts => _accidentHazardAlerts;
int _providerFailures = 0;
int get providerFailures => _providerFailures;
int _ttsRequests = 0;
int get ttsRequests => _ttsRequests;
int _ttsSuccesses = 0;
int get ttsSuccesses => _ttsSuccesses;
int _ttsFailures = 0;
int get ttsFailures => _ttsFailures;
int _lifecycleEvents = 0;
int get lifecycleEvents => _lifecycleEvents;
int _interruptedRideEvents = 0;
int get interruptedRideEvents => _interruptedRideEvents;
int _recoveryEvents = 0;
int get recoveryEvents => _recoveryEvents;
FieldTestLogger(this._db, this._ref) {
_flushTimer = Timer.periodic(const Duration(seconds: 5), (_) => flush());
}
Future<int?> startSession({
int? rideId,
GarageMotorcycle? bike,
double rollOffset = 0.0,
double pitchOffset = 0.0,
}) async {
try {
// Ensure any previous session is finalized
if (_activeSessionId != null) {
await stopSession();
}
_resetMetrics();
String appVer = '1.0.0';
String buildNum = '1';
try {
final info = await PackageInfo.fromPlatform();
appVer = info.version;
buildNum = info.buildNumber;
} catch (_) {}
final now = DateTime.now();
final deviceModel = Platform.isAndroid ? 'Android Device' : Platform.operatingSystem;
final androidVer = Platform.operatingSystemVersion;
final sessionId = await _db.into(_db.fieldTestSessions).insert(
FieldTestSessionsCompanion.insert(
rideId: drift.Value(rideId),
startedAt: now,
appVersion: drift.Value(appVer),
buildNumber: drift.Value(buildNum),
androidVersion: drift.Value(androidVer),
deviceModel: drift.Value(deviceModel),
motorcycleId: drift.Value(bike?.id),
motorcycleYear: drift.Value(bike?.year),
motorcycleMake: drift.Value(bike?.makeName),
motorcycleModel: drift.Value(bike?.modelName),
calibrationRollOffset: drift.Value(rollOffset),
calibrationPitchOffset: drift.Value(pitchOffset),
createdAt: now,
),
);
_activeSessionId = sessionId;
final sessionObj = await (_db.select(_db.fieldTestSessions)..where((s) => s.id.equals(sessionId))).getSingleOrNull();
_ref.read(activeFieldTestSessionProvider.notifier).state = sessionObj;
logEvent(
eventType: FieldTestEventType.loggerStarted,
severity: FieldTestSeverity.info,
message: 'Black-Box Logger Session #$sessionId started for Ride #${rideId ?? "N/A"}',
data: {
'appVersion': appVer,
'buildNumber': buildNum,
'device': deviceModel,
'os': androidVer,
'bike': bike != null ? '${bike.year} ${bike.makeName} ${bike.modelName}' : 'None',
},
);
return sessionId;
} catch (e) {
if (kDebugMode) debugPrint('[FieldTestLogger] Start session error: $e');
return null;
}
}
Future<void> stopSession() async {
if (_activeSessionId == null) return;
final sessionId = _activeSessionId!;
try {
logEvent(
eventType: FieldTestEventType.loggerStopped,
severity: FieldTestSeverity.info,
message: 'Black-Box Logger Session #$sessionId stopped. Finalizing...',
);
// Flush all remaining batches immediately
await flush();
final now = DateTime.now();
final health = computeHealthSummary();
await (_db.update(_db.fieldTestSessions)..where((s) => s.id.equals(sessionId))).write(
FieldTestSessionsCompanion(
endedAt: drift.Value(now),
totalEvents: drift.Value(_totalEvents),
anomalyCount: drift.Value(_anomalyCount),
completed: const drift.Value(true),
healthSummaryJson: drift.Value(jsonEncode(health.toJson())),
),
);
// Prune old sessions beyond retention limit (keep newest 10)
await enforceRetention(maxSessions: 10);
} catch (e) {
if (kDebugMode) debugPrint('[FieldTestLogger] Stop session error: $e');
} finally {
_activeSessionId = null;
_ref.read(activeFieldTestSessionProvider.notifier).state = null;
}
}
void logEvent({
required FieldTestEventType eventType,
required FieldTestSeverity severity,
required String message,
Map<String, dynamic>? data,
DateTime? timestamp,
}) {
final now = timestamp ?? DateTime.now();
_totalEvents++;
if (severity == FieldTestSeverity.warning || severity == FieldTestSeverity.error || severity == FieldTestSeverity.critical) {
_anomalyCount++;
}
final item = FieldTestEventItem(
timestamp: now,
eventType: eventType,
severity: severity,
message: message,
dataJson: data != null ? jsonEncode(data) : null,
);
// Add to circular memory buffer
if (_liveLogBuffer.length >= 500) {
_liveLogBuffer.removeFirst();
}
_liveLogBuffer.add(item);
_liveLogStreamController.add(_liveLogBuffer.toList());
if (_activeSessionId != null) {
_eventBatch.add(
FieldTestEventsCompanion.insert(
sessionId: _activeSessionId!,
timestamp: now,
eventType: eventType.name,
severity: severity.name,
message: message,
dataJson: drift.Value(data != null ? jsonEncode(data) : null),
),
);
// Immediate flush on critical event
if (severity == FieldTestSeverity.critical) {
flush();
}
}
}
void logGpsEvent({
required double latitude,
required double longitude,
double? altitude,
double? rawSpeed,
double? filteredSpeed,
double? heading,
double? accuracy,
required String gpsHealth,
required bool accepted,
String? rejectionReason,
DateTime? timestamp,
}) {
final now = timestamp ?? DateTime.now();
_gpsFixCount++;
if (!accepted) {
_gpsRejectedCount++;
logEvent(
eventType: FieldTestEventType.gpsOutlierRejected,
severity: FieldTestSeverity.warning,
message: 'GPS Fix Rejected: ${rejectionReason ?? "Outlier"} (Raw Spd: ${rawSpeed?.toStringAsFixed(1) ?? "?"} MPH, Acc: ${accuracy?.toStringAsFixed(1) ?? "?"}m)',
data: {
'lat': latitude,
'lng': longitude,
'rawSpeed': rawSpeed,
'accuracy': accuracy,
'reason': rejectionReason,
},
timestamp: now,
);
}
if (_activeSessionId != null) {
_gpsBatch.add(
FieldTestGpsRecordsCompanion.insert(
sessionId: _activeSessionId!,
timestamp: now,
latitude: latitude,
longitude: longitude,
altitude: drift.Value(altitude),
rawSpeed: drift.Value(rawSpeed),
filteredSpeed: drift.Value(filteredSpeed),
heading: drift.Value(heading),
accuracy: drift.Value(accuracy),
gpsHealth: gpsHealth,
accepted: accepted,
rejectionReason: drift.Value(rejectionReason),
),
);
}
}
void logGpsSignalLoss() {
_gpsOutageCount++;
_lastGpsLossTime = DateTime.now();
logEvent(
eventType: FieldTestEventType.gpsLost,
severity: FieldTestSeverity.warning,
message: 'GPS Signal Lost (No Fix)',
);
}
void logGpsSignalRecovery() {
double durationSec = 0.0;
if (_lastGpsLossTime != null) {
durationSec = DateTime.now().difference(_lastGpsLossTime!).inMilliseconds / 1000.0;
_totalGpsOutageDurationSeconds += durationSec;
if (durationSec > _longestGpsOutageSeconds) {
_longestGpsOutageSeconds = durationSec;
}
_lastGpsLossTime = null;
}
logEvent(
eventType: FieldTestEventType.gpsRecovered,
severity: FieldTestSeverity.info,
message: 'GPS Signal Recovered after ${durationSec.toStringAsFixed(1)}s outage',
data: {'outageDurationSeconds': durationSec},
);
}
void logGpsBaselineReset() {
_gpsBaselineResetCount++;
logEvent(
eventType: FieldTestEventType.gpsBaselineReset,
severity: FieldTestSeverity.info,
message: 'GPS continuity baseline reset after signal gap (>10s)',
);
}
void logImuDiagnostic({
double? accelX,
double? accelY,
double? accelZ,
double? gyroX,
double? gyroY,
double? gyroZ,
double? roll,
double? pitch,
double? longitudinalG,
double? lateralG,
double? filteredLongitudinalG,
double? filteredLateralG,
DateTime? timestamp,
}) {
final now = timestamp ?? DateTime.now();
_imuSampleCount++;
// Detect high vibration spike
if (longitudinalG != null && filteredLongitudinalG != null) {
final delta = (longitudinalG - filteredLongitudinalG).abs();
if (delta > 0.8) {
_imuSpikeFilteredCount++;
logEvent(
eventType: FieldTestEventType.imuSpikeFiltered,
severity: FieldTestSeverity.warning,
message: 'IMU Vibration Spike Filtered: Raw ${longitudinalG.toStringAsFixed(2)}G -> Filtered ${filteredLongitudinalG.toStringAsFixed(2)}G (Δ ${delta.toStringAsFixed(2)}G)',
data: {
'rawLongitudinalG': longitudinalG,
'filteredLongitudinalG': filteredLongitudinalG,
'delta': delta,
},
timestamp: now,
);
}
}
if (_activeSessionId != null) {
_imuBatch.add(
FieldTestImuRecordsCompanion.insert(
sessionId: _activeSessionId!,
timestamp: now,
accelerometerX: drift.Value(accelX),
accelerometerY: drift.Value(accelY),
accelerometerZ: drift.Value(accelZ),
gyroX: drift.Value(gyroX),
gyroY: drift.Value(gyroY),
gyroZ: drift.Value(gyroZ),
roll: drift.Value(roll),
pitch: drift.Value(pitch),
longitudinalG: drift.Value(longitudinalG),
lateralG: drift.Value(lateralG),
filteredLongitudinalG: drift.Value(filteredLongitudinalG),
filteredLateralG: drift.Value(filteredLateralG),
),
);
}
}
void logRoadAlertEvent({
required FieldTestEventType type,
required String providerId,
required String category,
required String title,
double? distanceMeters,
}) {
_roadAlertsReceived++;
if (category.toLowerCase().contains('police')) _policeAlerts++;
if (category.toLowerCase().contains('camera')) _speedCameraAlerts++;
if (category.toLowerCase().contains('accident') || category.toLowerCase().contains('hazard')) _accidentHazardAlerts++;
logEvent(
eventType: type,
severity: FieldTestSeverity.info,
message: 'Road Alert [$providerId]: $category - $title (${distanceMeters?.toStringAsFixed(0) ?? "?"}m)',
data: {
'provider': providerId,
'category': category,
'title': title,
'distanceMeters': distanceMeters,
},
);
}
void logProviderFailure(String providerId, String error) {
_providerFailures++;
logEvent(
eventType: FieldTestEventType.roadAlertReceived,
severity: FieldTestSeverity.warning,
message: 'Road Alert Provider [$providerId] Error: $error',
data: {'provider': providerId, 'error': error},
);
}
void logTts({required bool success, required String phrase, String? error}) {
_ttsRequests++;
if (success) {
_ttsSuccesses++;
logEvent(
eventType: FieldTestEventType.ttsSuccess,
severity: FieldTestSeverity.debug,
message: 'TTS Spoke: "$phrase"',
);
} else {
_ttsFailures++;
logEvent(
eventType: FieldTestEventType.ttsFailure,
severity: FieldTestSeverity.warning,
message: 'TTS Speech Failed for "$phrase": ${error ?? "Unknown error"}',
data: {'phrase': phrase, 'error': error},
);
}
}
void logLifecycle(String stateName) {
_lifecycleEvents++;
logEvent(
eventType: stateName.contains('Background') ? FieldTestEventType.appBackgrounded : FieldTestEventType.appForegrounded,
severity: FieldTestSeverity.info,
message: 'App Lifecycle Transition: $stateName',
);
}
void logInterruptedRideFound(int count) {
_interruptedRideEvents++;
logEvent(
eventType: FieldTestEventType.rideInterrupted,
severity: FieldTestSeverity.warning,
message: 'Detected $count unfinished/interrupted ride(s) from previous session',
);
}
void logRideRecovered(int rideId) {
_recoveryEvents++;
logEvent(
eventType: FieldTestEventType.rideRecovery,
severity: FieldTestSeverity.info,
message: 'Successfully recovered and closed interrupted Ride #$rideId',
);
}
Future<void> flush() async {
if (_isDisposed) return;
if (_eventBatch.isEmpty && _gpsBatch.isEmpty && _imuBatch.isEmpty) return;
final start = DateTime.now();
_dbBatchCount++;
final eventsToInsert = List<FieldTestEventsCompanion>.from(_eventBatch);
final gpsToInsert = List<FieldTestGpsRecordsCompanion>.from(_gpsBatch);
final imuToInsert = List<FieldTestImuRecordsCompanion>.from(_imuBatch);
_eventBatch.clear();
_gpsBatch.clear();
_imuBatch.clear();
try {
await _db.batch((batch) {
if (eventsToInsert.isNotEmpty) batch.insertAll(_db.fieldTestEvents, eventsToInsert);
if (gpsToInsert.isNotEmpty) batch.insertAll(_db.fieldTestGpsRecords, gpsToInsert);
if (imuToInsert.isNotEmpty) batch.insertAll(_db.fieldTestImuRecords, imuToInsert);
});
final delayMs = DateTime.now().difference(start).inMilliseconds.toDouble();
if (delayMs > _maxFlushDelayMs) {
_maxFlushDelayMs = delayMs;
}
} catch (e) {
_dbBatchFailures++;
if (kDebugMode) debugPrint('[FieldTestLogger] Batch flush error: $e');
}
}
SystemHealthSummary computeHealthSummary() {
// 1. GPS Health
HealthScoreCategory gpsH = HealthScoreCategory.good;
final rejectRate = _gpsFixCount > 0 ? (_gpsRejectedCount / _gpsFixCount) : 0.0;
if (_gpsOutageCount > 3 || rejectRate > 0.15) {
gpsH = HealthScoreCategory.warning;
} else if (_gpsOutageCount > 0 || rejectRate > 0.05) {
gpsH = HealthScoreCategory.degraded;
}
// 2. Sensors Health
HealthScoreCategory sensorsH = HealthScoreCategory.good;
if (_imuSpikeFilteredCount > 25) {
sensorsH = HealthScoreCategory.warning;
} else if (_imuSpikeFilteredCount > 5) {
sensorsH = HealthScoreCategory.degraded;
}
// 3. Persistence Health
HealthScoreCategory persistenceH = HealthScoreCategory.good;
if (_dbBatchFailures > 0) {
persistenceH = HealthScoreCategory.critical;
} else if (_maxFlushDelayMs > 1000) {
persistenceH = HealthScoreCategory.warning;
}
// 4. Lifecycle Health
HealthScoreCategory lifecycleH = HealthScoreCategory.good;
if (_interruptedRideEvents > 0) {
lifecycleH = HealthScoreCategory.warning;
}
// 5. Road Alerts Health
HealthScoreCategory alertsH = HealthScoreCategory.good;
if (_providerFailures > 2) {
alertsH = HealthScoreCategory.warning;
} else if (_providerFailures > 0) {
alertsH = HealthScoreCategory.degraded;
}
// 6. TTS Health
HealthScoreCategory ttsH = HealthScoreCategory.good;
if (_ttsFailures > 2) {
ttsH = HealthScoreCategory.warning;
} else if (_ttsFailures > 0) {
ttsH = HealthScoreCategory.degraded;
}
return SystemHealthSummary(
gpsHealth: gpsH,
sensorsHealth: sensorsH,
persistenceHealth: persistenceH,
lifecycleHealth: lifecycleH,
roadAlertsHealth: alertsH,
ttsHealth: ttsH,
totalAnomalies: _anomalyCount,
criticalCount: _dbBatchFailures,
warningCount: _anomalyCount - _dbBatchFailures,
);
}
Future<void> enforceRetention({int maxSessions = 10}) async {
try {
final sessions = await (_db.select(_db.fieldTestSessions)
..orderBy([(s) => drift.OrderingTerm(expression: s.startedAt, mode: drift.OrderingMode.desc)]))
.get();
if (sessions.length > maxSessions) {
final toDelete = sessions.sublist(maxSessions);
for (final s in toDelete) {
// Never delete the currently active session
if (s.id != _activeSessionId) {
await deleteSession(s.id);
}
}
}
} catch (_) {}
}
Future<bool> deleteSession(int sessionId) async {
if (sessionId == _activeSessionId) return false; // Block active deletion
try {
await _db.transaction(() async {
await (_db.delete(_db.fieldTestEvents)..where((e) => e.sessionId.equals(sessionId))).go();
await (_db.delete(_db.fieldTestGpsRecords)..where((g) => g.sessionId.equals(sessionId))).go();
await (_db.delete(_db.fieldTestImuRecords)..where((i) => i.sessionId.equals(sessionId))).go();
await (_db.delete(_db.fieldTestSessions)..where((s) => s.id.equals(sessionId))).go();
});
return true;
} catch (_) {
return false;
}
}
Future<bool> clearAllSessions() async {
try {
await _db.transaction(() async {
if (_activeSessionId != null) {
// Keep active session data only
await (_db.delete(_db.fieldTestEvents)..where((e) => e.sessionId.isNotValue(_activeSessionId!))).go();
await (_db.delete(_db.fieldTestGpsRecords)..where((g) => g.sessionId.isNotValue(_activeSessionId!))).go();
await (_db.delete(_db.fieldTestImuRecords)..where((i) => i.sessionId.isNotValue(_activeSessionId!))).go();
await (_db.delete(_db.fieldTestSessions)..where((s) => s.id.isNotValue(_activeSessionId!))).go();
} else {
await _db.delete(_db.fieldTestEvents).go();
await _db.delete(_db.fieldTestGpsRecords).go();
await _db.delete(_db.fieldTestImuRecords).go();
await _db.delete(_db.fieldTestSessions).go();
}
});
return true;
} catch (_) {
return false;
}
}
void _resetMetrics() {
_totalEvents = 0;
_anomalyCount = 0;
_gpsFixCount = 0;
_gpsRejectedCount = 0;
_gpsOutageCount = 0;
_lastGpsLossTime = null;
_totalGpsOutageDurationSeconds = 0.0;
_longestGpsOutageSeconds = 0.0;
_gpsBaselineResetCount = 0;
_imuSampleCount = 0;
_imuSpikeFilteredCount = 0;
_dbBatchCount = 0;
_dbBatchFailures = 0;
_maxFlushDelayMs = 0.0;
_roadAlertsReceived = 0;
_policeAlerts = 0;
_speedCameraAlerts = 0;
_accidentHazardAlerts = 0;
_providerFailures = 0;
_ttsRequests = 0;
_ttsSuccesses = 0;
_ttsFailures = 0;
_lifecycleEvents = 0;
_interruptedRideEvents = 0;
_recoveryEvents = 0;
}
bool _isDisposed = false;
void dispose() {
_isDisposed = true;
_flushTimer?.cancel();
_liveLogStreamController.close();
}
}
class FieldTestEventItem {
final DateTime timestamp;
final FieldTestEventType eventType;
final FieldTestSeverity severity;
final String message;
final String? dataJson;
FieldTestEventItem({
required this.timestamp,
required this.eventType,
required this.severity,
required this.message,
this.dataJson,
});
}