diff --git a/app/lib/services/mission_sync_service.dart b/app/lib/services/mission_sync_service.dart index b4b0cc0..d9fa6bc 100644 --- a/app/lib/services/mission_sync_service.dart +++ b/app/lib/services/mission_sync_service.dart @@ -2,6 +2,16 @@ import '../app_mode/app_mode_cubit.dart'; import '../domain/waypoint/flat_waypoint_list.dart'; import '../transport/flight_controller_link.dart'; +/// Geworfen von [MissionSyncService.syncAndUpload], wenn +/// [FlightControllerLink.verifyMission] nach einem Upload false liefert. +class MissionVerificationException implements Exception { + const MissionVerificationException(this.message); + final String message; + + @override + String toString() => 'MissionVerificationException: $message'; +} + /// Orchestriert Connect -> Verify -> Upload -> Ready-to-Fly und erzwingt das /// Ready-to-Fly-Gate (Architektur-Doku 3.3 / 4.5). class MissionSyncService { @@ -10,6 +20,11 @@ class MissionSyncService { final FlightControllerLink _link; final AppModeCubit _appMode; + /// Wirft [MissionVerificationException], wenn der Flightcontroller die + /// Mission nach dem Upload nicht als vollstaendig/gueltig bestaetigt + /// (Doku 2.2/4.5) - der Aufrufer zeigt das dem Nutzer an, das + /// Ready-to-Fly-Gate bleibt in diesem Fall zurecht geschlossen + /// (missionVerified bleibt false). Future syncAndUpload(FlatWaypointList mission) async { await _link.connect(); // uploadMission() der jeweiligen FlightControllerLink-Implementierung @@ -19,8 +34,11 @@ class MissionSyncService { await _link.uploadMission(mission); _appMode.missionUploadConfirmed(); - // TODO: echte Verifikation gegen FC-Rueckmeldung, sobald ein - // Protokoll-Adapter (MSP/MAVLink) eine Upload-Bestaetigung liefert. + if (!await _link.verifyMission(mission)) { + throw const MissionVerificationException( + 'Flightcontroller did not confirm the uploaded mission', + ); + } _appMode.missionVerifiedConfirmed(); } } diff --git a/app/lib/transport/flight_controller_link.dart b/app/lib/transport/flight_controller_link.dart index 0332709..e0f9c03 100644 --- a/app/lib/transport/flight_controller_link.dart +++ b/app/lib/transport/flight_controller_link.dart @@ -122,6 +122,14 @@ abstract class FlightControllerLink { Future uploadMission(FlatWaypointList mission); + /// Prueft nach [uploadMission], ob der Flightcontroller die Mission + /// tatsaechlich vollstaendig und gueltig uebernommen hat (Doku 2.2/4.5: + /// Ready-to-Fly-Gate "Upload + verifiziert + disarmed") - protokollneutrale + /// Ja/Nein-Antwort, jede Implementierung entscheidet selbst, wie sie das + /// beim jeweiligen Flightcontroller abfragt (MSP: `MSP_WP_GETINFO`, + /// MAVLink: `MISSION_REQUEST_LIST`/`MISSION_COUNT`-Runde). + Future verifyMission(FlatWaypointList mission); + Future setFlightMode(FlightMode mode); Future arm(); diff --git a/app/lib/transport/mock/mock_flight_controller_link.dart b/app/lib/transport/mock/mock_flight_controller_link.dart index 59a83d8..29489ce 100644 --- a/app/lib/transport/mock/mock_flight_controller_link.dart +++ b/app/lib/transport/mock/mock_flight_controller_link.dart @@ -144,9 +144,20 @@ class MockFlightControllerLink implements FlightControllerLink { 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 verifyMission(FlatWaypointList mission) async => + _uploadedWaypointCount == mission.waypoints.length; + @override Future setFlightMode(FlightMode mode) async {} diff --git a/app/lib/transport/msp/msp_commands.dart b/app/lib/transport/msp/msp_commands.dart index 318a024..83e4812 100644 --- a/app/lib/transport/msp/msp_commands.dart +++ b/app/lib/transport/msp/msp_commands.dart @@ -14,6 +14,22 @@ abstract final class MspCommands { /// (`fc_msp.c`, `case MSP_RAW_GPS`) static const int rawGps = 106; + /// Response (4 Byte): u8 reserved, u8 maxWaypoints (NAV_MAX_WAYPOINTS), + /// u8 isWaypointListValid (`posControl.waypointListValid`), u8 + /// waypointCount (`getWaypointCount()`) - Grundlage der Upload- + /// Verifikation (Doku 2.2/4.5: "Upload + verifiziert"), siehe + /// msp_waypoint_codec.dart. (`fc_msp.c`, `case MSP_WP_GETINFO`) + static const int wpGetInfo = 20; + + /// Request-Payload (21 Byte, siehe msp_waypoint_codec.dart + /// `encodeMspSetWaypoint`): u8 wp_no, u8 action, i32 lat(1e-7 deg), + /// i32 lon(1e-7 deg), i32 alt(cm), i16 p1, i16 p2, i16 p3, u8 flag. Nur + /// WP#1..NAV_MAX_WAYPOINTS gueltig; WP#1 setzt die Missionsliste des FC + /// zurueck (neue Mission), jede weitere WP# muss exakt die naechste sein + /// (`fc_msp.c` `case MSP_SET_WP`, `navigation.c` `setWaypoint()` - kein + /// Batch-Kommando, ein Aufruf pro Wegpunkt). + static const int setWp = 209; + /// Response: i32 estAlt(cm), i16 vario(cm/s), i32 baroAlt(cm). /// (`fc_msp.c`, `case MSP_ALTITUDE`) static const int altitude = 109; diff --git a/app/lib/transport/msp/msp_flight_controller_link.dart b/app/lib/transport/msp/msp_flight_controller_link.dart index 8ca034d..c56ef9c 100644 --- a/app/lib/transport/msp/msp_flight_controller_link.dart +++ b/app/lib/transport/msp/msp_flight_controller_link.dart @@ -5,6 +5,7 @@ import 'msp_client.dart'; import 'msp_commands.dart'; import 'msp_telemetry_codec.dart'; import 'msp_telemetry_poller.dart'; +import 'msp_waypoint_codec.dart'; /// iNAV-Anbindung via MSP (Architektur-Doku 3.1), umgesetzt gegen den /// tatsaechlichen iNAV-9.1.0-Quellcode (Doku Kommunikationsschicht Abschnitt @@ -17,11 +18,11 @@ import 'msp_telemetry_poller.dart'; /// oder spaeter WLAN/UDP steckt, ist fuer diese Klasse unsichtbar. /// /// Setzt die in der Doku vorgegebene Reihenfolge um: Rahmen kodieren/ -/// dekodieren + Telemetrie lesen (dieser Schritt). Missionsupload, -/// Flugmodus-Wechsel und Armen sind bewusst noch nicht umgesetzt (Doku 4, -/// "Reihenfolge der Umsetzung": "Erst danach Missionsupload") - das sind -/// eigene, sicherheitsrelevante Schritte (u.a. `MSP_SET_WP`), die eigene -/// Sorgfalt brauchen. +/// dekodieren, Telemetrie lesen, Missionsupload (`MSP_SET_WP`, siehe +/// msp_waypoint_codec.dart fuer das Byte-Layout und die +/// Aktions-P1/P2/P3-Belegung). Flugmodus-Wechsel und Armen sind weiterhin +/// bewusst nicht umgesetzt - eigene, sicherheitsrelevante Schritte, die +/// eigene Sorgfalt brauchen. class MspFlightControllerLink implements FlightControllerLink { MspFlightControllerLink({required this.transport}); @@ -35,9 +36,12 @@ class MspFlightControllerLink implements FlightControllerLink { supportsMultiMission: true, supportsInFlightUpload: false, // Konservativer iNAV-Default (navigation.h: NAV_MAX_WAYPOINTS = 15). - // Viele Boards/Targets erlauben mehr; ohne eine MSP_WP_GETINFO- - // Abfrage (Teil des noch nicht umgesetzten Missions-Uploads) nicht - // praeziser bestimmbar. + // Viele Boards/Targets erlauben mehr; uploadMission() fragt das nicht + // live per MSP_WP_GETINFO ab (dessen maxWaypoints-Feld wird nur zur + // Verifikation nach dem Upload gelesen, siehe verifyMission()) - + // ein client-seitiger Vorab-Check gegen diesen Default verhindert + // trotzdem, dass wir versuchen, mehr Wegpunkte zu senden, als der FC + // WP-Nummern dafuer reserviert (WP#1..#15). maxWaypoints: 15, ); @@ -68,11 +72,46 @@ class MspFlightControllerLink implements FlightControllerLink { } @override - Future uploadMission(FlatWaypointList mission) => - throw UnimplementedError( - 'Missions-Upload (MSP_SET_WP) ist noch nicht umgesetzt - Doku ' - 'Kommunikationsschicht 4 sieht das erst nach dem Telemetrie-Schritt vor.', + Future uploadMission(FlatWaypointList mission) async { + final client = _client; + if (client == null) { + throw StateError('uploadMission() vor connect() aufgerufen.'); + } + final waypoints = mission.waypoints; + if (waypoints.isEmpty) { + throw ArgumentError('Mission ohne Wegpunkte kann nicht hochgeladen werden.'); + } + if (waypoints.length > capabilities.maxWaypoints) { + throw ArgumentError( + 'Mission hat ${waypoints.length} Wegpunkte, iNAV erlaubt hier ' + 'maximal ${capabilities.maxWaypoints} (NAV_MAX_WAYPOINTS).', ); + } + + // MSP_SET_WP kennt kein Batch-Kommando - der FC nimmt WP-fuer-WP nur + // entweder WP#1 (startet damit eine neue Mission, resetWaypointList()) + // oder direkt den naechsten erwarteten WP# an (navigation.c + // setWaypoint()). Erst das NAV_WP_FLAG_LAST auf dem letzten Wegpunkt + // macht die Mission fuer den FC gueltig, siehe verifyMission(). + for (var i = 0; i < waypoints.length; i++) { + final payload = encodeMspSetWaypoint( + waypoints[i], + wireIndex: i + 1, + isLast: i == waypoints.length - 1, + ); + await client.request(MspCommands.setWp, payload: payload); + } + } + + @override + Future verifyMission(FlatWaypointList mission) async { + final client = _client; + if (client == null) { + throw StateError('verifyMission() vor connect() aufgerufen.'); + } + final info = parseMspWpGetInfo(await client.request(MspCommands.wpGetInfo)); + return info.isValid && info.waypointCount == mission.waypoints.length; + } @override Future setFlightMode(FlightMode mode) => throw UnimplementedError( diff --git a/app/lib/transport/msp/msp_waypoint_codec.dart b/app/lib/transport/msp/msp_waypoint_codec.dart new file mode 100644 index 0000000..37c4b17 --- /dev/null +++ b/app/lib/transport/msp/msp_waypoint_codec.dart @@ -0,0 +1,138 @@ +import 'dart:typed_data'; + +import '../../domain/waypoint/flat_waypoint_list.dart'; + +/// `navWaypointActions_e` (Doku Kommunikationsschicht v2 Abschnitt 6, +/// geprueft gegen den iNAV-9.1.0-Quellcode: `navigation.h`). Nur die vier +/// Werte, auf die unser protokollneutrales [WaypointAction] tatsaechlich +/// abbildet, sind hier benannt - RTH/SET_POI/SET_HEAD kennt unser +/// Domainmodell (noch) nicht. +abstract final class MspNavWpAction { + static const int waypoint = 0x01; + static const int holdTime = 0x03; + static const int jump = 0x06; + static const int land = 0x08; +} + +/// `navWaypointFlags_e` (`navigation.h`). [last] markiert den letzten +/// Wegpunkt einer Mission - erst dieses Flag setzt +/// `posControl.waypointListValid` auf dem FC, siehe encodeMspSetWaypoint-Doku. +abstract final class MspNavWpFlag { + static const int none = 0; + static const int last = 0xA5; +} + +/// p1 ist `int16_t` auf der Leitung (Doku: `navWaypoint_t`) - groesster +/// darstellbarer positiver Wert, nicht 0xFFFF (das waere als int16 -1 und +/// wuerde NAV_WP_ACTION_HOLD_TIME sofort - statt dauerhaft - abschliessen +/// lassen, siehe navigation.c `navOnEnteringState_NAV_STATE_WAYPOINT_...`: +/// "p1 <= 0" beendet das Halten sofort). +const int _mspInt16Max = 0x7FFF; + +/// Kodiert einen einzelnen Wegpunkt als 21-Byte MSP_SET_WP-Payload +/// (`MspCommands.setWp`, Doku Kommunikationsschicht v2 Abschnitt 6). +/// +/// [wireIndex] ist die 1-basierte FC-interne Wegpunktnummer (WP#1..#15), +/// NICHT der 0-basierte Index in der App-eigenen Liste - iNAV reserviert +/// WP#0 fuer "Home" und WP#255 fuer einen Direkt-Sprung im Poshold-Modus +/// (`navigation.c` `setWaypoint()`). Der Aufrufer (MspFlightControllerLink) +/// ist dafuer verantwortlich, WP#1 zuerst und danach jede weitere Nummer +/// lueckenlos aufsteigend zu senden - der FC nimmt sonst gar nichts an. +/// +/// [isLast] setzt [MspNavWpFlag.last] auf dem letzten Wegpunkt - ohne dieses +/// Flag bleibt `posControl.waypointListValid` auf dem FC false, selbst wenn +/// alle Wegpunkte einzeln angekommen sind (siehe `MspCommands.wpGetInfo`, +/// zur Verifikation nach dem Upload). +/// +/// Aktions-P1/P2/P3-Belegung (`navigation.c` `getActiveSpeed()`/ +/// `setupJumpCounters()`, Aktion fuer Aktion geprueft statt geraten): +/// - WAYPOINT/LAND: p1 = Sollgeschwindigkeit (cm/s), p2 unbenutzt (0). +/// - Unser generisches `loiter` hat im Domainmodell keine eigene Dauer +/// (HTML-Referenz zeigt es als endloses Kreisen um den Punkt mit dem +/// `loiterRadius` des Drohnenprofils, kein Zeitfeld) - kodiert als +/// NAV_WP_ACTION_HOLD_TIME mit dem groesstmoeglichen p1 (`_mspInt16Max` +/// Sekunden, ueber 9 Stunden - laenger als jeder realistische Flug), p2 = +/// Sollgeschwindigkeit (cm/s, dort das P1-Aequivalent fuer HOLD_TIME). +/// - JUMP: p1 = 1-basierte Ziel-WP# (der FC zieht beim Empfang selbst 1 ab, +/// `setWaypoint()`: "make index (vice WP #)"), p2 = statische +/// Wiederholzahl (-1 = endlos, `jumpRepeatCount == null`). p3 haelt bei +/// JUMP den volatilen Wiederholzaehler - der FC initialisiert ihn beim +/// Missionsstart selbst aus p2 (`setupJumpCounters()`), wir spiegeln das +/// hier nur defensiv. +/// - Bei allen anderen Aktionen ist p3 das Hoehenmodus-Bitfeld +/// (`NAV_WP_ALTMODE`): 0 = relativ zum Startpunkt - passend zu unserem +/// `altitudeM` (siehe TelemetryFrame.altitudeM-Doku: "keine absolute Hoehe +/// ueber Meeresspiegel"), 1 waere absolut (AMSL) und wird hier nie gesetzt. +Uint8List encodeMspSetWaypoint( + Waypoint waypoint, { + required int wireIndex, + required bool isLast, +}) { + final speedP1 = (waypoint.speedMs * 100).round().clamp(0, _mspInt16Max); + + final int action; + final int p1; + final int p2; + final int p3; + switch (waypoint.action) { + case WaypointAction.none: + action = MspNavWpAction.waypoint; + p1 = speedP1; + p2 = 0; + p3 = 0; + case WaypointAction.loiter: + action = MspNavWpAction.holdTime; + p1 = _mspInt16Max; + p2 = speedP1; + p3 = 0; + case WaypointAction.landing: + action = MspNavWpAction.land; + p1 = speedP1; + p2 = 0; + p3 = 0; + case WaypointAction.jump: + action = MspNavWpAction.jump; + final target = ((waypoint.jumpTargetIndex ?? 0) + 1) + .clamp(1, _mspInt16Max); + final repeat = (waypoint.jumpRepeatCount ?? -1).clamp(-1, _mspInt16Max); + p1 = target; + p2 = repeat; + p3 = repeat; + } + + final payload = ByteData(21); + payload.setUint8(0, wireIndex); + payload.setUint8(1, action); + payload.setInt32(2, (waypoint.lat * 1e7).round(), Endian.little); + payload.setInt32(6, (waypoint.lon * 1e7).round(), Endian.little); + payload.setInt32(10, (waypoint.altitudeM * 100).round(), Endian.little); + payload.setInt16(14, p1, Endian.little); + payload.setInt16(16, p2, Endian.little); + payload.setInt16(18, p3, Endian.little); + payload.setUint8(20, isLast ? MspNavWpFlag.last : MspNavWpFlag.none); + return payload.buffer.asUint8List(); +} + +/// Antwort auf `MspCommands.wpGetInfo` (4 Byte): ob der FC die aktuell +/// hochgeladene Mission als vollstaendig/gueltig fuehrt, und wie viele +/// Wegpunkte er dafuer zaehlt - Grundlage der Upload-Verifikation (Doku +/// 2.2/4.5). +class MspWpGetInfo { + const MspWpGetInfo({ + required this.maxWaypoints, + required this.isValid, + required this.waypointCount, + }); + + final int maxWaypoints; + final bool isValid; + final int waypointCount; +} + +MspWpGetInfo parseMspWpGetInfo(Uint8List payload) { + return MspWpGetInfo( + maxWaypoints: payload[1], + isValid: payload[2] != 0, + waypointCount: payload[3], + ); +} diff --git a/app/lib/ui/screens/fly/fly_screen.dart b/app/lib/ui/screens/fly/fly_screen.dart index bf23bc3..7084d34 100644 --- a/app/lib/ui/screens/fly/fly_screen.dart +++ b/app/lib/ui/screens/fly/fly_screen.dart @@ -2,15 +2,19 @@ import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:latlong2/latlong.dart'; +import '../../../app_mode/app_mode_cubit.dart'; import '../../../domain/mission/mission_stats.dart'; import '../../../domain/mission/mission_warnings.dart'; import '../../../domain/mission/route_geometry.dart'; import '../../../domain/telemetry/drone_status.dart'; +import '../../../domain/waypoint/flat_waypoint_list.dart'; import '../../../domain/wind/wind_math.dart'; +import '../../../services/mission_sync_service.dart'; import '../../../transport/flight_controller_link.dart'; import '../../providers/active_drone_profile_provider.dart'; import '../../providers/current_mission_provider.dart'; @@ -43,6 +47,7 @@ class FlyScreen extends ConsumerStatefulWidget { class _FlyScreenState extends ConsumerState { late final MapController _mapController; StreamSubscription? _mapEventSub; + bool _sending = false; @override void initState() { @@ -123,6 +128,36 @@ class _FlyScreenState extends ConsumerState { )); } + /// Laedt die aktuelle Mission auf den Flightcontroller (Doku 2.2/4.5: + /// Connect -> Upload -> Verify) - MissionSyncService kennt selbst kein + /// UI, deshalb hier Ladezustand/Fehleranzeige. uploadMission() der + /// jeweiligen FlightControllerLink-Implementierung verweigert den Versand + /// bereits selbst im armed-Zustand (Doku 4.5); dieser Handler prueft nur + /// den trivialen Fall (keine Wegpunkte), um nicht unnoetig einen + /// Verbindungsversuch auszuloesen. + Future _onSendTap() async { + final waypoints = ref.read(currentMissionProvider); + final messenger = ScaffoldMessenger.of(context); + if (waypoints.isEmpty) { + messenger.showSnackBar(const SnackBar(content: Text('No waypoints to send'))); + return; + } + + setState(() => _sending = true); + try { + final service = MissionSyncService( + ref.read(flightControllerLinkProvider), + context.read(), + ); + await service.syncAndUpload(FlatWaypointList(waypoints)); + messenger.showSnackBar(const SnackBar(content: Text('Mission sent'))); + } catch (e) { + messenger.showSnackBar(SnackBar(content: Text('Send failed: $e'))); + } finally { + if (mounted) setState(() => _sending = false); + } + } + @override Widget build(BuildContext context) { final waypoints = ref.watch(currentMissionProvider); @@ -132,8 +167,12 @@ class _FlyScreenState extends ConsumerState { // Beobachtet das Ereignis-Log dauerhaft ab dem ersten Eintritt in den // Fly-Modus (Doku: "log von kritischen ereignissen mit time stamp") - // haelt drone_event_log_provider.dart's Notifier am Leben, solange - // dieser Screen existiert, siehe dessen Klassendoku. - final droneEventLog = ref.watch(droneEventLogProvider); + // dieser Screen existiert, siehe dessen Klassendoku. Der Rueckgabewert + // selbst wird hier nicht mehr gebraucht (der Warnungs-Knopf, der ihn + // frueher fuer forceShowWarningsButton auswertete, ist im Fly-Modus + // durch den Sende-Knopf ersetzt, siehe onSendTap unten) - Warnungen/ + // Ereignis-Log bleiben ueber die Drohnen-Status-Pille erreichbar. + ref.watch(droneEventLogProvider); // Solange der ueber den GPS-Knopf in der Kopfleiste (FlyMapControls) // aktivierte Folgemodus aktiv ist, jede neue Telemetrie-Position @@ -301,7 +340,8 @@ class _FlyScreenState extends ConsumerState { warnings: warnings, showWarningsBanner: false, batteryPercent: telemetry?.batteryPercent, - forceShowWarningsButton: droneEventLog.isNotEmpty, + onSendTap: _onSendTap, + sending: _sending, onDetailsTap: _openWaypointList, onWarningsTap: _openDroneStatusWarningsPanel, onSettingsTap: () => Navigator.of(context).push(MaterialPageRoute( diff --git a/app/lib/ui/widgets/bottom_stats_bar.dart b/app/lib/ui/widgets/bottom_stats_bar.dart index d7512ba..9e45332 100644 --- a/app/lib/ui/widgets/bottom_stats_bar.dart +++ b/app/lib/ui/widgets/bottom_stats_bar.dart @@ -43,11 +43,10 @@ const _footerFontWeight = FontWeight.w600; /// #statsRow). Details-Pille bleibt mittig und oeffnet die Wegpunktliste; /// Mission-Chip links und Drohnen-Chip rechts oeffnen das Verwaltungsmenue /// mit dem passenden Starttab (HTML-Demonstrator: #missionNameBtn/ -/// #droneNameBtn). Warnungs- und Settings-Knopf sitzen neben dem -/// Drohnen-Chip rechts aussen. Der Hoehenprofil-Toggle (Bergkuppen-Icon) -/// sitzt links der Details-Pille. -/// Der Send-Button ist bewusst nicht Teil dieses Schritts - die dafuer -/// noetige Upload-Anbindung existiert noch nicht. +/// #droneNameBtn). Warnungs- (Plan-Modus) bzw. Sende-Knopf (Fly-Modus, +/// siehe onSendTap) und Settings-Knopf sitzen neben dem Drohnen-Chip +/// rechts aussen. Der Hoehenprofil-Toggle (Bergkuppen-Icon) sitzt links +/// der Details-Pille. class BottomStatsBar extends ConsumerWidget { const BottomStatsBar({ super.key, @@ -60,7 +59,8 @@ class BottomStatsBar extends ConsumerWidget { required this.onSettingsTap, this.warnings = const [], this.onWarningsTap, - this.forceShowWarningsButton = false, + this.onSendTap, + this.sending = false, }); final int waypointCount; @@ -87,13 +87,17 @@ class BottomStatsBar extends ConsumerWidget { final List warnings; final VoidCallback? onWarningsTap; - /// Zeigt den Warnungs-Knopf auch ohne Mission-Warnungen (Doku: "der - /// warnings tab laesst sich direkt erreichen ueber den existierenden - /// warnings button") - im Fly-Modus sonst unerreichbar, sobald die - /// Mission selbst keine Plan-Warnungen (Speed/Altitude/Range) hat, aber - /// bereits kritische Live-Ereignisse (Doku: "log von kritischen - /// ereignissen") vorliegen. Im Plan-Modus unveraendert false. - final bool forceShowWarningsButton; + /// Nur im Fly-Modus gesetzt (Doku: "im fly screen den warnings button + /// durch einen wp send button ersetzen") - wenn gesetzt, ersetzt der + /// Sende-Knopf (Papierflieger, horizontal, pink) den runden Warnungs- + /// Knopf komplett, unabhaengig von [warnings]. Warnungen/Ereignis-Log + /// bleiben im Fly-Modus weiterhin ueber die Drohnen-Status-Pille + /// erreichbar (dort als eigener Tab), siehe DroneStatusWarningsPanel. + final VoidCallback? onSendTap; + + /// Zeigt einen Ladeindikator statt des Papierflieger-Icons und ignoriert + /// Taps, waehrend ein Upload laeuft (verhindert doppeltes Abschicken). + final bool sending; @override Widget build(BuildContext context, WidgetRef ref) { @@ -201,7 +205,10 @@ class BottomStatsBar extends ConsumerWidget { ), ), const SizedBox(width: _footerElementSpacing), - if (warnings.isNotEmpty || forceShowWarningsButton) ...[ + if (onSendTap != null) ...[ + _SendButton(sending: sending, onTap: onSendTap), + const SizedBox(width: _footerElementSpacing), + ] else if (warnings.isNotEmpty) ...[ _WarningButton(onTap: onWarningsTap), const SizedBox(width: _footerElementSpacing), ], @@ -360,6 +367,50 @@ class _WarningButton extends StatelessWidget { } } +/// Runder Sende-Knopf im Fly-Modus (Doku: "im fly screen den warnings +/// button durch einen wp send button ersetzen. verwende unser paperplane +/// shape. ausrichtung horizontal und pink gefuellt") - ersetzt dort den +/// Warnungs-Knopf komplett, siehe BottomStatsBar.onSendTap. +class _SendButton extends StatelessWidget { + const _SendButton({required this.sending, required this.onTap}); + + final bool sending; + final VoidCallback? onTap; + + static const _pink = Color(0xFFFF2E93); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: sending ? null : onTap, + child: Container( + width: _footerElementHeight, + height: _footerElementHeight, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.12), + ), + child: Center( + child: sending + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: _pink), + ) + // Papierflieger liegend statt der Spitze-nach-oben- + // Kartenmarker-Orientierung (Doku: "ausrichtung horizontal") - + // um 90 Grad im Uhrzeigersinn gedreht zeigt die Nase nach + // rechts, wie ein abgeschickter/wegfliegender Flieger. + : Transform.rotate( + angle: math.pi / 2, + child: const PaperPlaneIcon(size: _footerIconSize, color: _pink), + ), + ), + ), + ); + } +} + /// Runder Zahnrad-Knopf rechts der Details-Pille - oeffnet das /// Settings-Menue (SettingsScreen). class _SettingsButton extends StatelessWidget { diff --git a/app/lib/ui/widgets/mission_footer_bar.dart b/app/lib/ui/widgets/mission_footer_bar.dart index aae9be6..4e44fa2 100644 --- a/app/lib/ui/widgets/mission_footer_bar.dart +++ b/app/lib/ui/widgets/mission_footer_bar.dart @@ -28,7 +28,8 @@ class MissionFooterBar extends ConsumerWidget { required this.warnings, this.showWarningsBanner = true, this.batteryPercent, - this.forceShowWarningsButton = false, + this.onSendTap, + this.sending = false, required this.onDetailsTap, required this.onWarningsTap, required this.onSettingsTap, @@ -73,8 +74,11 @@ class MissionFooterBar extends ConsumerWidget { /// kritischer Ladestand gelesen werden koennte). final int? batteryPercent; - /// Siehe BottomStatsBar.forceShowWarningsButton. - final bool forceShowWarningsButton; + /// Siehe BottomStatsBar.onSendTap. + final VoidCallback? onSendTap; + + /// Siehe BottomStatsBar.sending. + final bool sending; final VoidCallback onDetailsTap; final VoidCallback onWarningsTap; final VoidCallback onSettingsTap; @@ -126,7 +130,8 @@ class MissionFooterBar extends ConsumerWidget { droneStatus: droneStatus, onTap: onDetailsTap, warnings: warnings, - forceShowWarningsButton: forceShowWarningsButton, + onSendTap: onSendTap, + sending: sending, onWarningsTap: onWarningsTap, onSettingsTap: onSettingsTap, ), diff --git a/app/test/services/mission_sync_service_test.dart b/app/test/services/mission_sync_service_test.dart new file mode 100644 index 0000000..bc6d8b1 --- /dev/null +++ b/app/test/services/mission_sync_service_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:dmc_app/app_mode/app_mode_cubit.dart'; +import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart'; +import 'package:dmc_app/services/mission_sync_service.dart'; +import 'package:dmc_app/transport/mock/mock_flight_controller_link.dart'; + +/// FlightControllerLink-Doppelgaenger, der einen erfolgreichen Upload aber +/// eine fehlschlagende Verifikation simuliert - MockFlightControllerLink +/// selbst kann das nicht (dessen verifyMission() vergleicht immer korrekt +/// gegen die zuletzt hochgeladene Wegpunktzahl). +class _UnverifiableLink extends MockFlightControllerLink { + @override + Future verifyMission(FlatWaypointList mission) async => false; +} + +void main() { + const mission = FlatWaypointList([ + Waypoint(lat: 52.5, lon: 13.4, altitudeM: 60, speedMs: 15, catchRadiusM: 60), + ]); + + test('erfolgreicher Sync setzt missionUploaded und missionVerified', () async { + final appMode = AppModeCubit(); + addTearDown(appMode.close); + final service = MissionSyncService(MockFlightControllerLink(), appMode); + + await service.syncAndUpload(mission); + + expect(appMode.state.missionUploaded, isTrue); + expect(appMode.state.missionVerified, isTrue); + }); + + test('Upload im armed-Zustand wirft und setzt keinen der beiden Flags', () async { + final appMode = AppModeCubit(); + addTearDown(appMode.close); + final link = MockFlightControllerLink(); + await link.arm(); + final service = MissionSyncService(link, appMode); + + await expectLater(() => service.syncAndUpload(mission), throwsStateError); + + expect(appMode.state.missionUploaded, isFalse); + expect(appMode.state.missionVerified, isFalse); + }); + + test('fehlschlagende Verifikation wirft MissionVerificationException, ' + 'missionUploaded bleibt trotzdem gesetzt, missionVerified nicht', () async { + final appMode = AppModeCubit(); + addTearDown(appMode.close); + final service = MissionSyncService(_UnverifiableLink(), appMode); + + await expectLater( + () => service.syncAndUpload(mission), + throwsA(isA()), + ); + + expect(appMode.state.missionUploaded, isTrue); + expect(appMode.state.missionVerified, isFalse); + }); +} diff --git a/app/test/transport/msp/msp_flight_controller_link_test.dart b/app/test/transport/msp/msp_flight_controller_link_test.dart index 695e3f2..5eb7512 100644 --- a/app/test/transport/msp/msp_flight_controller_link_test.dart +++ b/app/test/transport/msp/msp_flight_controller_link_test.dart @@ -3,25 +3,38 @@ import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; +import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart'; import 'package:dmc_app/transport/loopback/loopback_transport.dart'; import 'package:dmc_app/transport/msp/msp_commands.dart'; import 'package:dmc_app/transport/msp/msp_flight_controller_link.dart'; import 'package:dmc_app/transport/msp/msp_frame.dart'; import 'package:dmc_app/transport/msp/msp_frame_decoder.dart'; -/// Beantwortet jede ueber [transport] gesendete MSP-Anfrage sofort mit einer -/// synthetischen Antwort - simuliert den Flightcontroller, ohne echte -/// Hardware (Doku Kommunikationsschicht Abschnitt 9). -Timer _startFakeFlightController( - LoopbackTransport transport, { - int fixType = 3, -}) { - var answered = 0; - return Timer.periodic(const Duration(milliseconds: 5), (_) { - while (answered < transport.sentData.length) { +/// Simuliert den Flightcontroller ohne echte Hardware (Doku Kommunikations- +/// schicht Abschnitt 9): beantwortet jede ueber [transport] gesendete +/// MSP-Anfrage sofort mit einer synthetischen Antwort. Zeichnet ausserdem +/// jede empfangene MSP_SET_WP-Payload auf und haelt einen einstellbaren +/// MSP_WP_GETINFO-Zustand (fuer Upload-/Verifikations-Tests), analog +/// `posControl.waypointListValid`/`getWaypointCount()` auf dem echten FC. +class _FakeFlightController { + _FakeFlightController(this.transport, {this.fixType = 3}) { + _timer = Timer.periodic(const Duration(milliseconds: 5), _tick); + } + + final LoopbackTransport transport; + final int fixType; + late final Timer _timer; + var _answered = 0; + + final receivedSetWpPayloads = []; + bool wpGetInfoValid = false; + int wpGetInfoCount = 0; + + void _tick(Timer _) { + while (_answered < transport.sentData.length) { final request = - MspFrameDecoder().addBytes(transport.sentData[answered]).single; - answered++; + MspFrameDecoder().addBytes(transport.sentData[_answered]).single; + _answered++; final Uint8List payload; switch (request.function) { @@ -57,6 +70,12 @@ Timer _startFakeFlightController( d.setUint8(1, 88); // Linkqualitaet % d.setInt8(2, 6); // SNR 6 dB payload = d.buffer.asUint8List(); + case MspCommands.setWp: + receivedSetWpPayloads.add(request.payload); + payload = Uint8List(0); + case MspCommands.wpGetInfo: + payload = Uint8List.fromList( + [0, 15, wpGetInfoValid ? 1 : 0, wpGetInfoCount]); default: payload = Uint8List(0); } @@ -65,9 +84,17 @@ Timer _startFakeFlightController( encodeMspV2Frame(MspDirection.response, request.function, payload), ); } - }); + } + + void cancel() => _timer.cancel(); } +_FakeFlightController _startFakeFlightController( + LoopbackTransport transport, { + int fixType = 3, +}) => + _FakeFlightController(transport, fixType: fixType); + void main() { test( 'connect() + subscribeTelemetry() liefern echte, ueber MSP dekodierte ' @@ -147,4 +174,125 @@ void main() { final link = MspFlightControllerLink(transport: LoopbackTransport()); expect(() => link.subscribeTelemetry(), throwsStateError); }); + + group('uploadMission', () { + const waypoints = [ + Waypoint(lat: 52.5, lon: 13.4, altitudeM: 100, speedMs: 15, catchRadiusM: 60), + Waypoint(lat: 52.51, lon: 13.41, altitudeM: 110, speedMs: 15, catchRadiusM: 60), + Waypoint(lat: 52.52, lon: 13.42, altitudeM: 120, speedMs: 15, catchRadiusM: 60), + ]; + + test('sendet einen MSP_SET_WP pro Wegpunkt, WP#1-basiert, nur der ' + 'letzte traegt NAV_WP_FLAG_LAST', () async { + final transport = LoopbackTransport(); + final responder = _startFakeFlightController(transport); + final link = MspFlightControllerLink(transport: transport); + await link.connect(); + + await link.uploadMission(const FlatWaypointList(waypoints)); + + expect(responder.receivedSetWpPayloads, hasLength(3)); + for (var i = 0; i < 3; i++) { + final d = ByteData.sublistView(responder.receivedSetWpPayloads[i]); + expect(d.getUint8(0), i + 1, reason: 'WP# ist 1-basiert'); + expect(d.getUint8(20), i == 2 ? 0xA5 : 0, reason: 'nur der letzte WP traegt LAST'); + } + + await link.disconnect(); + responder.cancel(); + }, timeout: const Timeout(Duration(seconds: 5))); + + test('wirft ArgumentError bei leerer Mission', () async { + final transport = LoopbackTransport(); + final responder = _startFakeFlightController(transport); + final link = MspFlightControllerLink(transport: transport); + await link.connect(); + + expect( + () => link.uploadMission(const FlatWaypointList([])), + throwsArgumentError, + ); + + await link.disconnect(); + responder.cancel(); + }, timeout: const Timeout(Duration(seconds: 5))); + + test('wirft ArgumentError, wenn mehr Wegpunkte als NAV_MAX_WAYPOINTS ' + '(15) vorliegen - ohne dabei etwas zu senden', () async { + final transport = LoopbackTransport(); + final responder = _startFakeFlightController(transport); + final link = MspFlightControllerLink(transport: transport); + await link.connect(); + final tooMany = List.generate( + 16, + (_) => const Waypoint(lat: 0, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60), + ); + + await expectLater( + () => link.uploadMission(FlatWaypointList(tooMany)), + throwsArgumentError, + ); + expect(responder.receivedSetWpPayloads, isEmpty); + + await link.disconnect(); + responder.cancel(); + }, timeout: const Timeout(Duration(seconds: 5))); + }); + + group('verifyMission', () { + test('liefert true, wenn der FC die Mission als gueltig mit passender ' + 'Wegpunktzahl meldet', () async { + final transport = LoopbackTransport(); + final responder = _startFakeFlightController(transport) + ..wpGetInfoValid = true + ..wpGetInfoCount = 2; + final link = MspFlightControllerLink(transport: transport); + await link.connect(); + + const mission = FlatWaypointList([ + Waypoint(lat: 0, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60), + Waypoint(lat: 0.01, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60), + ]); + expect(await link.verifyMission(mission), isTrue); + + await link.disconnect(); + responder.cancel(); + }, timeout: const Timeout(Duration(seconds: 5))); + + test('liefert false, wenn der FC die Mission als ungueltig meldet', () async { + final transport = LoopbackTransport(); + final responder = _startFakeFlightController(transport) + ..wpGetInfoValid = false + ..wpGetInfoCount = 0; + final link = MspFlightControllerLink(transport: transport); + await link.connect(); + + const mission = FlatWaypointList([ + Waypoint(lat: 0, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60), + ]); + expect(await link.verifyMission(mission), isFalse); + + await link.disconnect(); + responder.cancel(); + }, timeout: const Timeout(Duration(seconds: 5))); + + test('liefert false, wenn die vom FC gezaehlte Wegpunktzahl nicht zur ' + 'erwarteten Mission passt (z.B. veraltete/vorherige Mission)', () async { + final transport = LoopbackTransport(); + final responder = _startFakeFlightController(transport) + ..wpGetInfoValid = true + ..wpGetInfoCount = 1; + final link = MspFlightControllerLink(transport: transport); + await link.connect(); + + const mission = FlatWaypointList([ + Waypoint(lat: 0, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60), + Waypoint(lat: 0.01, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60), + ]); + expect(await link.verifyMission(mission), isFalse); + + await link.disconnect(); + responder.cancel(); + }, timeout: const Timeout(Duration(seconds: 5))); + }); } diff --git a/app/test/transport/msp/msp_waypoint_codec_test.dart b/app/test/transport/msp/msp_waypoint_codec_test.dart new file mode 100644 index 0000000..d580eac --- /dev/null +++ b/app/test/transport/msp/msp_waypoint_codec_test.dart @@ -0,0 +1,155 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart'; +import 'package:dmc_app/transport/msp/msp_waypoint_codec.dart'; + +Waypoint _wp({ + double lat = 52.5, + double lon = 13.4, + double alt = 120, + double speed = 15, + WaypointAction action = WaypointAction.none, + int? jumpTargetIndex, + int? jumpRepeatCount, +}) => + Waypoint( + lat: lat, + lon: lon, + altitudeM: alt, + speedMs: speed, + catchRadiusM: 60, + action: action, + jumpTargetIndex: jumpTargetIndex, + jumpRepeatCount: jumpRepeatCount, + ); + +/// Liest die 21-Byte MSP_SET_WP-Payload wie der echte FC (`fc_msp.c`, +/// `case MSP_SET_WP`) - dient nur der Testverifikation, dupliziert bewusst +/// nicht encodeMspSetWaypoint(). +({ + int wpNo, + int action, + int lat, + int lon, + int alt, + int p1, + int p2, + int p3, + int flag, +}) _decode(Uint8List payload) { + final d = ByteData.sublistView(payload); + return ( + wpNo: d.getUint8(0), + action: d.getUint8(1), + lat: d.getInt32(2, Endian.little), + lon: d.getInt32(6, Endian.little), + alt: d.getInt32(10, Endian.little), + p1: d.getInt16(14, Endian.little), + p2: d.getInt16(16, Endian.little), + p3: d.getInt16(18, Endian.little), + flag: d.getUint8(20), + ); +} + +void main() { + group('encodeMspSetWaypoint', () { + test('Payload ist immer genau 21 Byte lang (dataSize-Check des FC)', () { + final payload = encodeMspSetWaypoint(_wp(), wireIndex: 1, isLast: true); + expect(payload.length, 21); + }); + + test('wp_no, lat/lon (1e7), Hoehe (cm) und flag werden korrekt kodiert', () { + final payload = encodeMspSetWaypoint( + _wp(lat: 52.5, lon: 13.4, alt: 120), + wireIndex: 3, + isLast: false, + ); + final d = _decode(payload); + + expect(d.wpNo, 3); + expect(d.lat, 525000000); + expect(d.lon, 134000000); + expect(d.alt, 12000); + expect(d.flag, 0); + }); + + test('isLast setzt NAV_WP_FLAG_LAST (0xA5)', () { + final payload = encodeMspSetWaypoint(_wp(), wireIndex: 1, isLast: true); + expect(_decode(payload).flag, 0xA5); + }); + + test('WaypointAction.none -> NAV_WP_ACTION_WAYPOINT, p1 = Speed in cm/s', () { + final d = _decode(encodeMspSetWaypoint(_wp(speed: 15), wireIndex: 1, isLast: false)); + expect(d.action, 0x01); + expect(d.p1, 1500); + expect(d.p2, 0); + expect(d.p3, 0); + }); + + test('WaypointAction.landing -> NAV_WP_ACTION_LAND, p1 = Speed in cm/s', () { + final d = _decode(encodeMspSetWaypoint( + _wp(speed: 12, action: WaypointAction.landing), + wireIndex: 1, + isLast: false, + )); + expect(d.action, 0x08); + expect(d.p1, 1200); + expect(d.p2, 0); + }); + + test( + 'WaypointAction.loiter -> NAV_WP_ACTION_HOLD_TIME mit maximalem p1 ' + '(int16-Max statt 0xFFFF, sonst wuerde der FC das Halten sofort statt ' + 'dauerhaft beenden), p2 = Speed in cm/s', () { + final d = _decode(encodeMspSetWaypoint( + _wp(speed: 10, action: WaypointAction.loiter), + wireIndex: 1, + isLast: false, + )); + expect(d.action, 0x03); + expect(d.p1, 0x7FFF); + expect(d.p2, 1000); + }); + + test( + 'WaypointAction.jump -> NAV_WP_ACTION_JUMP, p1 = 1-basierte Ziel-WP#, ' + 'p2/p3 = Wiederholzahl', () { + final d = _decode(encodeMspSetWaypoint( + _wp(action: WaypointAction.jump, jumpTargetIndex: 2, jumpRepeatCount: 5), + wireIndex: 4, + isLast: false, + )); + expect(d.action, 0x06); + // 0-basierter App-Index 2 -> 1-basierte FC-WP# 3. + expect(d.p1, 3); + expect(d.p2, 5); + expect(d.p3, 5); + }); + + test('WaypointAction.jump ohne jumpRepeatCount kodiert -1 (endlos)', () { + final d = _decode(encodeMspSetWaypoint( + _wp(action: WaypointAction.jump, jumpTargetIndex: 0), + wireIndex: 2, + isLast: false, + )); + expect(d.p2, -1); + expect(d.p3, -1); + }); + }); + + group('parseMspWpGetInfo', () { + test('dekodiert maxWaypoints/isValid/waypointCount', () { + final info = parseMspWpGetInfo(Uint8List.fromList([0, 15, 1, 5])); + expect(info.maxWaypoints, 15); + expect(info.isValid, isTrue); + expect(info.waypointCount, 5); + }); + + test('isValid == false bei 0', () { + final info = parseMspWpGetInfo(Uint8List.fromList([0, 15, 0, 0])); + expect(info.isValid, isFalse); + }); + }); +}