import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; import '../models/telemetry_sample.dart'; import '../models/field_test_event.dart'; import 'location_service.dart'; import 'sensor_fusion_service.dart'; import 'compass_service.dart'; import 'field_test/field_test_logger.dart'; import 'validation/telemetry_validator.dart'; final telemetryServiceProvider = Provider((ref) { final locationService = ref.watch(locationServiceProvider); final sensorFusionService = ref.watch(sensorFusionServiceProvider); final compassService = ref.watch(compassServiceProvider); final fieldTestLogger = ref.watch(fieldTestLoggerProvider); final validator = ref.watch(telemetryValidatorProvider); final service = TelemetryService(locationService, sensorFusionService, compassService, fieldTestLogger, validator); ref.onDispose(() => service.dispose()); return service; }); final telemetryStreamProvider = StreamProvider((ref) { return ref.watch(telemetryServiceProvider).telemetryStream; }); class TelemetryService { final LocationService _locationService; final SensorFusionService _sensorFusionService; final CompassService _compassService; final FieldTestLogger _fieldTestLogger; final TelemetryValidator _validator; final _telemetryController = StreamController.broadcast(); Stream get telemetryStream => _telemetryController.stream; StreamSubscription? _locationSub; StreamSubscription? _leanSub; StreamSubscription? _pitchSub; StreamSubscription? _gForceSub; StreamSubscription? _compassSub; Timer? _samplingTimer; // Latest states Position? _latestPosition; double _latestLean = 0.0; double _latestPitch = 0.0; GForceData? _latestGForce; double _latestCompassHeading = 0.0; DateTime? _latestImuTimestamp; // Monotonic authoritative clock final Stopwatch _monotonicStopwatch = Stopwatch(); int _sequenceCounter = 0; int _lastEmitMonotonicMs = 0; int get lastEmitMonotonicMs => _lastEmitMonotonicMs; TelemetryService( this._locationService, this._sensorFusionService, this._compassService, this._fieldTestLogger, this._validator, ) { _monotonicStopwatch.start(); _init(); } void _init() { _locationService.onGpsDiagnostic = (pos, accepted, reason) { if (_fieldTestLogger.isSessionActive && !accepted) { _fieldTestLogger.log( eventType: FieldTestEventType.gpsJumpRejected, severity: EventSeverity.error, message: 'GPS fix rejected: $reason', latitude: pos.latitude, longitude: pos.longitude, speed: pos.speed * 2.23694, gpsAccuracy: pos.accuracy, timestamp: pos.timestamp, ); } }; _locationSub = _locationService.getFilteredPositionStream().listen((pos) { _latestPosition = pos; }); _leanSub = _sensorFusionService.leanAngleStream.listen((lean) { _latestLean = lean; _latestImuTimestamp = DateTime.now(); }); _pitchSub = _sensorFusionService.pitchAngleStream.listen((pitch) { _latestPitch = pitch; _latestImuTimestamp = DateTime.now(); }); _gForceSub = _sensorFusionService.gForceStream.listen((gForce) { _latestGForce = gForce; _latestImuTimestamp = DateTime.now(); }); _compassSub = _compassService.headingStream.listen((heading) { _latestCompassHeading = heading; }); // High-rate refresh at strictly 20Hz (50ms) as the single authoritative telemetry clock _samplingTimer = Timer.periodic(const Duration(milliseconds: 50), (_) { _emitSample(); }); } DateTime? _lastSuspectLogTime; void _emitSample() { final now = DateTime.now(); final monotonicMs = _monotonicStopwatch.elapsedMilliseconds; final sequence = ++_sequenceCounter; final speedMph = _latestPosition != null ? _latestPosition!.speed * 2.23694 : 0.0; final gpsAgeMs = _latestPosition != null ? now.difference(_latestPosition!.timestamp).inMilliseconds : null; // Use GPS heading if moving, otherwise fallback to compass double finalHeading = _latestCompassHeading; if (_latestPosition != null && _latestPosition!.heading >= 0 && speedMph > 3.0) { finalHeading = _latestPosition!.heading; } final rawSample = TelemetrySample( timestamp: now, sequenceNumber: sequence, monotonicTimestampMs: monotonicMs, latitude: _latestPosition?.latitude, longitude: _latestPosition?.longitude, altitude: _latestPosition?.altitude, speed: _latestPosition != null ? _latestPosition!.speed * 2.23694 : null, // m/s to mph heading: finalHeading, gpsAccuracy: _latestPosition?.accuracy, gpsTimestamp: _latestPosition?.timestamp, gpsAgeMs: gpsAgeMs, imuTimestamp: _latestImuTimestamp, leanAngle: _latestLean, pitchAngle: _latestPitch, rollRateDegPerSec: _sensorFusionService.latestRollRateDegPerSec, accelX: _latestGForce?.accelX, accelY: _latestGForce?.accelY, accelZ: _latestGForce?.accelZ, longitudinalG: _latestGForce?.longitudinal, lateralG: _latestGForce?.lateral, rawAccelX: _latestGForce?.rawAccelX ?? _sensorFusionService.latestRawAccelX, rawAccelY: _latestGForce?.rawAccelY ?? _sensorFusionService.latestRawAccelY, rawAccelZ: _latestGForce?.rawAccelZ ?? _sensorFusionService.latestRawAccelZ, rawGyroX: _latestGForce?.rawGyroX ?? _sensorFusionService.latestRawGyroX, rawGyroY: _latestGForce?.rawGyroY ?? _sensorFusionService.latestRawGyroY, rawGyroZ: _latestGForce?.rawGyroZ ?? _sensorFusionService.latestRawGyroZ, ); _lastEmitMonotonicMs = monotonicMs; // Validate sample final quality = _validator.validate(rawSample); final validatedSample = rawSample.copyWith(quality: quality); // Log diagnostic if sample is suspect or rejected during an active field test (throttled for suspect) if (_fieldTestLogger.isSessionActive) { if (quality.isRejected) { _fieldTestLogger.log( eventType: FieldTestEventType.sampleRejected, message: 'Sample validation rejected: ${quality.reasons.join(", ")}', severity: EventSeverity.critical, telemetryTimestamp: rawSample.timestamp, speed: speedMph, overallConfidence: quality.overallConfidence, ); } else if (quality.isSuspect) { final shouldLogSuspect = _lastSuspectLogTime == null || now.difference(_lastSuspectLogTime!).inMilliseconds >= 2000; if (shouldLogSuspect) { _lastSuspectLogTime = now; _fieldTestLogger.log( eventType: FieldTestEventType.sampleSuspect, message: 'Sample validation suspect: ${quality.reasons.join(", ")}', severity: EventSeverity.warning, telemetryTimestamp: rawSample.timestamp, speed: speedMph, overallConfidence: quality.overallConfidence, ); } } } _telemetryController.add(validatedSample); } void dispose() { _locationSub?.cancel(); _leanSub?.cancel(); _pitchSub?.cancel(); _gForceSub?.cancel(); _compassSub?.cancel(); _samplingTimer?.cancel(); _telemetryController.close(); } }