734 lines
22 KiB
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,
|
|
});
|
|
}
|