import 'dart:async'; import 'dart:math' as math; import '../../domain/waypoint/flat_waypoint_list.dart'; import '../flight_controller_link.dart'; /// Synthetische Telemetrie fuer UI-Entwicklung/Tests ohne Hardware oder SITL /// (Architektur-Doku 3.1). /// /// Verankert am ersten Wegpunkt der uebergebenen Mission (Doku: "solange die /// Drohne nicht verbunden ist, Mock-Daten fuer die neue Live-Details- /// Ansicht nutzbar machen") statt an einem fest verdrahteten Ort (frueher /// Berlin) - dadurch liegt die simulierte Position tatsaechlich auf der /// gerade bearbeiteten Route, und activeWaypointIndex ist von Anfang an /// gesetzt (0, "erster Wegpunkt aktiv"), auch ohne den noch nicht /// umgesetzten Missions-Upload-Flow. Fehlt eine Mission (keine Wegpunkte), /// bleibt der alte Berlin-Fallback bestehen, damit die Klasse auch ganz /// ohne Mission nutzbar ist. class MockFlightControllerLink implements FlightControllerLink { MockFlightControllerLink({List initialWaypoints = const []}) : _anchorLat = initialWaypoints.isEmpty ? 52.5 : initialWaypoints.first.lat, _anchorLon = initialWaypoints.isEmpty ? 13.4 : initialWaypoints.first.lon, _activeWaypointIndex = initialWaypoints.isEmpty ? null : 0; double _anchorLat; double _anchorLon; final _random = math.Random(); bool _armed = false; int? _activeWaypointIndex; StreamController? _telemetryController; Timer? _ticker; double _heading = 0; double _batteryPercent = 100; /// Aktuelle Drift vom Ankerpunkt in Metern (Nord/Ost) - Zufalls-Trudeln /// innerhalb eines 10-m-Radius statt eines fest stehenden Punkts, damit /// auch die Distanz/ETA-Anzeige der neuen Details-Pille sichtbar auf /// Werteaenderungen reagiert (Doku 9: UI-Entwicklung ohne Hardware). double _driftNorthM = 0; double _driftEastM = 0; static const _driftRadiusM = 10.0; static const _driftStepM = 1.5; static const _metersPerDegreeLat = 111320.0; @override FcCapabilities get capabilities => const FcCapabilities( supportsMultiMission: true, supportsInFlightUpload: false, maxWaypoints: 60, ); @override Future connect() async { // Idempotent (Doku flightControllerLinkProvider: "Verbindung bleibt // ueber Moduswechsel hinweg aktiv") - sonst wuerde jeder erneute // Eintritt in den Fly-Modus einen zweiten Timer/Controller anlegen und // den vorherigen verwaist weiterlaufen lassen. if (_telemetryController != null) return; _telemetryController = StreamController.broadcast(); _ticker = Timer.periodic(const Duration(seconds: 1), (_) { // Kurs dreht sich langsam weiter statt fest zu stehen, damit die // Rotation des Drohnen-Markers (DroneMarkerIcon) auch ohne Hardware // sichtbar ueberprueft werden kann (Doku 9: UI-Entwicklung ohne // Hardware). _heading = (_heading + 5) % 360; // Langsam sinkender Akkustand, damit die Anzeige auch ohne Hardware // sichtbar auf Werteaenderungen reagiert (Doku 9). _batteryPercent = (_batteryPercent - 0.1).clamp(0, 100); // Zufallsschritt, danach auf den 10-m-Radius um den Ankerpunkt // zurueckgeklemmt statt frei wegzulaufen. _driftNorthM += (_random.nextDouble() - 0.5) * 2 * _driftStepM; _driftEastM += (_random.nextDouble() - 0.5) * 2 * _driftStepM; final driftMagnitude = math.sqrt( _driftNorthM * _driftNorthM + _driftEastM * _driftEastM, ); if (driftMagnitude > _driftRadiusM) { final scale = _driftRadiusM / driftMagnitude; _driftNorthM *= scale; _driftEastM *= scale; } final metersPerDegreeLon = _metersPerDegreeLat * math.cos(_anchorLat * math.pi / 180); final lat = _anchorLat + _driftNorthM / _metersPerDegreeLat; final lon = _anchorLon + _driftEastM / metersPerDegreeLon; _telemetryController?.add(TelemetryFrame( lat: lat, lon: lon, hasFix: true, numSat: 12, altitudeM: 120, speedMs: 18, headingDeg: _heading, armed: _armed, batteryPercent: _batteryPercent.round(), linkQuality: 95, flightMode: _activeWaypointIndex != null ? 'Waypoint mission (en route)' : 'Idle', activeWaypointIndex: _activeWaypointIndex, )); }); } @override Future disconnect() async { _ticker?.cancel(); await _telemetryController?.close(); _telemetryController = null; } /// Setzt die simulierte Position auf den ersten Wegpunkt von [waypoints] /// zurueck und loescht die bisherige Drift (Doku: "beim Laden einer /// Mission Mock-Drohnenposition auf ersten Wegpunkt setzen, solange die /// Drohne nicht verbunden ist") - der Aufrufer (CurrentMissionMetaNotifier) /// ruft dies nur auf, wenn [FlightControllerLink] tatsaechlich dieser Mock /// ist, also noch keine echte Verbindung (WLAN/MSP) aktiv ist. Ohne /// Wegpunkte faellt die Position auf den alten Berlin-Fallback zurueck, /// analog zum Konstruktor. void resetPosition(List waypoints) { _anchorLat = waypoints.isEmpty ? 52.5 : waypoints.first.lat; _anchorLon = waypoints.isEmpty ? 13.4 : waypoints.first.lon; _driftNorthM = 0; _driftEastM = 0; _activeWaypointIndex = waypoints.isEmpty ? null : 0; } @override Future uploadMission(FlatWaypointList mission) async { // iNAV/MAVLink erlauben Mission-Upload nur im unarmed-Zustand (Doku 4.5). if (_armed) { throw StateError('Mission-Upload verweigert: Drohne ist armed.'); } _activeWaypointIndex = mission.waypoints.isEmpty ? null : 0; } @override Future setFlightMode(FlightMode mode) async {} @override Future arm() async { _armed = true; } @override Stream subscribeTelemetry() { final controller = _telemetryController; if (controller == null) { throw StateError('subscribeTelemetry() vor connect() aufgerufen.'); } return controller.stream; } @override Future readActiveWaypointIndex() async => _activeWaypointIndex; }