Fills in MspFlightControllerLink.uploadMission(), which previously just threw UnimplementedError, plus a new verifyMission() (both now on the generic FlightControllerLink interface, protocol-neutral by signature - MAVLink/ArduPilot get their own implementation later without touching callers). All iNAV-specific encoding lives in the new msp_waypoint_codec.dart: - encodeMspSetWaypoint(): the 21-byte MSP_SET_WP payload. Action/P1/P2/P3 byte layout was checked against the actual iNAV 9.1.0 source (navigation.c/navigation.h), not guessed - notably our generic `loiter` action has no configurable duration, so it maps to NAV_WP_ACTION_HOLD_TIME with the max representable p1 (int16 max, not 0xFFFF - that would read as -1 and end the hold immediately instead of never). - parseMspWpGetInfo(): decodes MSP_WP_GETINFO's validity/count fields, used by verifyMission() to confirm the FC actually accepted the full mission (Doku 2.2/4.5 Ready-to-Fly-Gate: "upload + verified"). uploadMission() sends one MSP_SET_WP per waypoint in order (iNAV has no batch command - WP#1 resets the FC's mission list, every next number must follow immediately, only the last carries NAV_WP_FLAG_LAST) and rejects missions above NAV_MAX_WAYPOINTS upfront instead of silently truncating. MissionSyncService now calls the real verifyMission() instead of always confirming, throwing MissionVerificationException when the FC doesn't confirm the mission. FlyScreen's footer swaps the warnings button for a send button (Doku: "ersetze den warnings button mit einem wp send button", pink horizontal PaperPlaneIcon, matching the existing paper-plane drone iconography) - warnings/event log stay reachable via the drone status pill's Warnings tab. BottomStatsBar/MissionFooterBar gained onSendTap/sending in place of the old forceShowWarningsButton. Verified end to end on the Pixel_10a emulator: tapping send with WLAN as the active connection type triggers the real WifiNetworkSpecifier flow through MspFlightControllerLink (correctly reports "no devices found" - expected, no real mLRS bridge on the emulator); the actual MSP_SET_WP/ MSP_WP_GETINFO wire behavior is covered by tests against a fake FC responder over LoopbackTransport instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
181 lines
7.1 KiB
Dart
181 lines
7.1 KiB
Dart
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<Waypoint> 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<TelemetryFrame>? _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<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);
|
|
|
|
// 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(),
|
|
// 6S-Akku (22.2V nominal), Spannung sinkt mit dem Mock-Ladestand
|
|
// mit, damit auch die neue Statusanzeige (Doku: "Drone battery
|
|
// Voltage") auf Werteaenderungen reagiert.
|
|
batteryVoltage: 18.0 + (_batteryPercent / 100) * 6.0,
|
|
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',
|
|
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;
|
|
}
|