206 lines
7.2 KiB
Dart
206 lines
7.2 KiB
Dart
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<TelemetryService>((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<TelemetrySample>((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<TelemetrySample>.broadcast();
|
|
Stream<TelemetrySample> 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();
|
|
}
|
|
}
|