Adds MSP_WP (118) support to query the flightcontroller's stored home point (WP#0 is a special case for this in iNAV's getWaypoint(), per navigation.c) - the FC reports (0,0,0) rather than an error before one is set, so parseMspHomePoint() treats that pair as "unset". Rendered with a small pentagon house icon (design/homepoint-pentagon.svg, ported to a CustomPainter like the other map icons). Deliberately not polled continuously: a new home_point_provider.dart refreshes it only at the three moments the FC's home point can actually change - connect/reconnect, GPS fix acquired, and arming (iNAV's default reset_home_type=FIRST_ARM only freezes it at the first arm; before that it continuously follows the aircraft while disarmed). Mock's implementation offsets the point 25m from the anchor so it doesn't sit exactly under the drone marker during UI testing.
246 lines
10 KiB
Dart
246 lines
10 KiB
Dart
import 'dart:async';
|
|
import 'dart:math' as math;
|
|
|
|
import '../../domain/waypoint/flat_waypoint_list.dart';
|
|
import '../flight_controller_link.dart';
|
|
import '../msp/msp_commands.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<Waypoint> initialWaypoints = const [],
|
|
this._batteryCellCount = 4,
|
|
}) : _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;
|
|
|
|
/// Zellenzahl des simulierten Akkus (Doku DroneProfile.batteryCellCount) -
|
|
/// bestimmt den Spannungsbereich der Mock-Telemetrie (siehe
|
|
/// batteryVoltage unten), damit die Statusanzeige mit den Ampel-
|
|
/// Schwellwerten des aktiven Profils zusammenpasst statt fest auf 6S
|
|
/// verdrahtet zu sein.
|
|
final int _batteryCellCount;
|
|
final _random = math.Random();
|
|
|
|
bool _armed = false;
|
|
int? _activeWaypointIndex;
|
|
StreamController<TelemetryFrame>? _telemetryController;
|
|
Timer? _ticker;
|
|
double _heading = 0;
|
|
double _batteryPercent = 100;
|
|
int _tickCount = 0;
|
|
|
|
/// 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<void> 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<TelemetryFrame>.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);
|
|
_tickCount++;
|
|
|
|
// 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,
|
|
fixType: 2,
|
|
numSat: 12,
|
|
altitudeM: 120,
|
|
// Leicht abweichend von altitudeM statt identisch (Doku: rohe
|
|
// GPS-Hoehe ist typischerweise etwas verrauschter als die
|
|
// barometrisch/GPS-fusionierte Schaetzung), damit im UI sichtbar
|
|
// wird, dass es sich um zwei unterschiedliche Werte handelt.
|
|
gpsAltitudeM: 120 + math.sin(_tickCount * 0.11) * 4,
|
|
speedMs: 18,
|
|
headingDeg: _heading,
|
|
armed: _armed,
|
|
batteryPercent: _batteryPercent.round(),
|
|
// Spannung sinkt mit dem Mock-Ladestand von voll (4.2 V/Zelle,
|
|
// LiPo-Ladeschluss) auf nahezu leer (3.0 V/Zelle) mit, skaliert mit
|
|
// der Zellenzahl des aktiven Drohnenprofils statt fest auf 6S
|
|
// verdrahtet zu sein (Doku: "bei 4s ist 16.8 Volt aber der max
|
|
// Wert") - damit reagiert auch die neue Statusanzeige (Doku: "Drone
|
|
// battery Voltage") auf Werteaenderungen und durchlaeuft dabei
|
|
// realistisch die Ampel-Schwellwerte des Profils.
|
|
batteryVoltage:
|
|
_batteryCellCount * (3.0 + (_batteryPercent / 100) * 1.2),
|
|
currentA: 12.5,
|
|
linkQuality: 95,
|
|
snrDb: 9,
|
|
hdop: 0.9,
|
|
// MW_GPS_MODE_NAV (3), sobald armed und eine Mission aktiv ist -
|
|
// analog zu flightMode unten, damit computeDroneStatus (Doku:
|
|
// "drone state ... waypoint") auch mit Mock-Daten testbar ist.
|
|
navMode: _armed && _activeWaypointIndex != null ? 3 : 0,
|
|
flightMode: _activeWaypointIndex != null
|
|
? 'Waypoint mission (en route)'
|
|
: 'Idle',
|
|
// Kein Pitot/Rangefinder/Optical-Flow am T1 Ranger vorgesehen (Doku
|
|
// Architektur 6: "MSPV2_INAV_AIR_SPEED ... fuer den T1 Ranger
|
|
// aktuell nicht relevant") - die uebrigen Sensoren gelten als
|
|
// vorhanden/gesund, damit die Sensor-Status-Zeile im Drone-Status-
|
|
// Menue sowohl gruene als auch graue Badges zeigt.
|
|
sensorStatusBits: MspSensorStatusBits.acc |
|
|
MspSensorStatusBits.baro |
|
|
MspSensorStatusBits.mag |
|
|
MspSensorStatusBits.gps |
|
|
MspSensorStatusBits.temp,
|
|
// Leichtes Schwanken um drei Basiswerte statt fester Zahlen, damit
|
|
// die Temperaturfelder ebenfalls sichtbar auf Werteaenderungen
|
|
// reagieren (Doku 9).
|
|
temperaturesC: [
|
|
28.0 + math.sin(_tickCount * 0.05) * 1.5,
|
|
34.0 + math.sin(_tickCount * 0.07) * 2.0,
|
|
46.0 + math.sin(_tickCount * 0.06) * 3.0,
|
|
],
|
|
// Sinuskurve statt fester 0 - die Mock-Hoehe selbst bleibt
|
|
// konstant (siehe altitudeM oben), die Steig-/Sinkrate soll aber
|
|
// trotzdem sichtbar zwischen Steigen/Sinken wechseln koennen.
|
|
verticalSpeedMs: math.sin(_tickCount * 0.3) * 2.5,
|
|
activeWaypointIndex: _activeWaypointIndex,
|
|
));
|
|
});
|
|
}
|
|
|
|
@override
|
|
Future<void> 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<Waypoint> 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<void> 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.');
|
|
}
|
|
_uploadedWaypointCount = mission.waypoints.length;
|
|
_activeWaypointIndex = mission.waypoints.isEmpty ? null : 0;
|
|
}
|
|
|
|
/// Kein separates FC-seitiges "gueltig"-Flag zu simulieren (anders als
|
|
/// MspFlightControllerLink, das echte MSP_WP_GETINFO-Antworten abfragt) -
|
|
/// der Mock haelt die zuletzt hochgeladene Wegpunktzahl und vergleicht sie
|
|
/// hier direkt, das reicht fuer UI-Entwicklung ohne Hardware.
|
|
int? _uploadedWaypointCount;
|
|
|
|
@override
|
|
Future<bool> verifyMission(FlatWaypointList mission) async =>
|
|
_uploadedWaypointCount == mission.waypoints.length;
|
|
|
|
@override
|
|
Future<void> setFlightMode(FlightMode mode) async {}
|
|
|
|
@override
|
|
Future<void> arm() async {
|
|
_armed = true;
|
|
}
|
|
|
|
@override
|
|
Stream<TelemetryFrame> subscribeTelemetry() {
|
|
final controller = _telemetryController;
|
|
if (controller == null) {
|
|
throw StateError('subscribeTelemetry() vor connect() aufgerufen.');
|
|
}
|
|
return controller.stream;
|
|
}
|
|
|
|
@override
|
|
Future<int?> readActiveWaypointIndex() async => _activeWaypointIndex;
|
|
|
|
/// Liefert immer einen Wert (nahe dem Ankerpunkt, siehe Klassendoku) statt
|
|
/// wie der echte FC vor dem ersten Fix `null` - fuer UI-Entwicklung ohne
|
|
/// Hardware (Doku 9) ist ein sofort sichtbarer Homepoint-Marker
|
|
/// hilfreicher als ein realistisch leerer Zustand.
|
|
///
|
|
/// Bewusst um [_homeOffsetM] Meter suedlich des Ankerpunkts versetzt statt
|
|
/// exakt darauf: der Drohnen-Marker (siehe connect()) startet ebenfalls am
|
|
/// Ankerpunkt und drueckt sich nur innerhalb von [_driftRadiusM] (10 m)
|
|
/// davon weg - ohne Versatz lag der Homepoint-Marker im Mock-Test also
|
|
/// praktisch immer direkt unter dem Drohnen-Marker und war nie als
|
|
/// eigenes Icon zu sehen.
|
|
static const _homeOffsetM = 25.0;
|
|
|
|
@override
|
|
Future<HomePoint?> readHomePoint() async => HomePoint(
|
|
lat: _anchorLat - _homeOffsetM / _metersPerDegreeLat,
|
|
lon: _anchorLon,
|
|
altitudeM: 0,
|
|
);
|
|
}
|