From 5fd5d0117aee0140f5a342599a54e08af9d5bbfb Mon Sep 17 00:00:00 2001 From: Constantin Leue <77660791+Consti3D@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:10:00 +0200 Subject: [PATCH] Show the drone's locked home point on the fly-mode map 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. --- app/lib/transport/flight_controller_link.dart | 27 +++ .../mock/mock_flight_controller_link.dart | 20 ++ app/lib/transport/msp/msp_commands.dart | 16 ++ .../msp/msp_flight_controller_link.dart | 17 ++ app/lib/transport/msp/msp_waypoint_codec.dart | 28 +++ app/lib/ui/providers/home_point_provider.dart | 72 +++++++ app/lib/ui/screens/fly/fly_screen.dart | 24 +++ app/lib/ui/widgets/home_point_icon.dart | 63 ++++++ app/lib/ui/widgets/mission_map.dart | 7 + .../msp/msp_flight_controller_link_test.dart | 56 +++++ .../msp/msp_waypoint_codec_test.dart | 64 ++++++ .../providers/home_point_provider_test.dart | 200 ++++++++++++++++++ 12 files changed, 594 insertions(+) create mode 100644 app/lib/ui/providers/home_point_provider.dart create mode 100644 app/lib/ui/widgets/home_point_icon.dart create mode 100644 app/test/ui/providers/home_point_provider_test.dart diff --git a/app/lib/transport/flight_controller_link.dart b/app/lib/transport/flight_controller_link.dart index 65d68f3..91d0cb7 100644 --- a/app/lib/transport/flight_controller_link.dart +++ b/app/lib/transport/flight_controller_link.dart @@ -144,6 +144,24 @@ class TelemetryFrame { final int? activeWaypointIndex; } +/// Vom Flightcontroller gemeldeter Homepoint (Doku: "den auf der Drohne +/// gelockten Homepoint abfragen") - anders als [TelemetryFrame] NICHT Teil +/// des kontinuierlichen Telemetrie-Stroms, sondern gezielt per +/// [FlightControllerLink.readHomePoint] abgefragt (siehe dortige Doku und +/// ui/providers/home_point_provider.dart fuer die drei Momente, zu denen +/// das tatsaechlich noetig ist). +class HomePoint { + const HomePoint({required this.lat, required this.lon, required this.altitudeM}); + + final double lat; + final double lon; + + /// Referenzhoehe des Homepoints in Metern (bei MSP: `GPS_home.alt` in cm, + /// bereits umgerechnet) - relativ zum selben Nullpunkt wie + /// TelemetryFrame.altitudeM, keine absolute Hoehe ueber Meeresspiegel. + final double altitudeM; +} + /// Zentrale Abstraktion fuer alle Flightcontroller-Interaktionen (Doku 3.1). /// Implementierungen: MspFlightControllerLink (iNAV), MavlinkFlightControllerLink /// (ArduPilot, Post-MVP), MockFlightControllerLink (Tests ohne Hardware). @@ -171,4 +189,13 @@ abstract class FlightControllerLink { Stream subscribeTelemetry(); Future readActiveWaypointIndex(); + + /// Fragt den aktuell auf dem Flightcontroller gespeicherten Homepoint ab, + /// oder `null`, wenn dort noch keiner gesetzt ist (bei MSP: FC meldet vor + /// dem ersten Fix (0,0,0) statt eines Fehlers, siehe + /// `MspFlightControllerLink.readHomePoint`-Doku). Bewusst ein einzelner + /// gezielter Abruf statt eines Streams/Poll-Feldes - der Homepoint + /// aendert sich ausserhalb weniger Trigger-Momente ohnehin nicht (siehe + /// ui/providers/home_point_provider.dart). + Future readHomePoint(); } diff --git a/app/lib/transport/mock/mock_flight_controller_link.dart b/app/lib/transport/mock/mock_flight_controller_link.dart index 978ba8e..d2d15d9 100644 --- a/app/lib/transport/mock/mock_flight_controller_link.dart +++ b/app/lib/transport/mock/mock_flight_controller_link.dart @@ -222,4 +222,24 @@ class MockFlightControllerLink implements FlightControllerLink { @override Future 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 readHomePoint() async => HomePoint( + lat: _anchorLat - _homeOffsetM / _metersPerDegreeLat, + lon: _anchorLon, + altitudeM: 0, + ); } diff --git a/app/lib/transport/msp/msp_commands.dart b/app/lib/transport/msp/msp_commands.dart index af2c781..a94131f 100644 --- a/app/lib/transport/msp/msp_commands.dart +++ b/app/lib/transport/msp/msp_commands.dart @@ -21,6 +21,22 @@ abstract final class MspCommands { /// msp_waypoint_codec.dart. (`fc_msp.c`, `case MSP_WP_GETINFO`) static const int wpGetInfo = 20; + /// Request-Payload (1 Byte): u8 wp_no. Response (21 Byte): 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 (`fc_msp.c`, `mspFcWaypointOutCommand()`, `case + /// MSP_WP`). Kein Batch-Kommando - ein Request pro Wegpunktnummer, analog + /// zu [setWp]. `wp_no` kennt laut `navigation.c` `getWaypoint()` zwei + /// Sonderwerte neben echten Missions-Wegpunktnummern: 0 = Homepoint + /// (`GPS_home.lat/lon/alt`, nur gueltig wenn `STATE(GPS_FIX_HOME)` - + /// sonst liefert der FC (0,0,0) statt eines Fehlers, siehe + /// msp_waypoint_codec.dart `parseMspHomePoint`), 255 = aktuelle + /// Ist-Position. Der Homepoint selbst wird vom FC NICHT beim ersten + /// GPS-Fix eingefroren, sondern folgt der Drohne kontinuierlich, solange + /// sie disarmed ist und noch nie armed war (`updateHomePosition()`) - + /// gesperrt wird er erst beim ersten Armen (iNAV-Default + /// `reset_home_type = FIRST_ARM`, `settings.yaml`). + static const int getWp = 118; + /// 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 diff --git a/app/lib/transport/msp/msp_flight_controller_link.dart b/app/lib/transport/msp/msp_flight_controller_link.dart index c56ef9c..f4f562d 100644 --- a/app/lib/transport/msp/msp_flight_controller_link.dart +++ b/app/lib/transport/msp/msp_flight_controller_link.dart @@ -142,4 +142,21 @@ class MspFlightControllerLink implements FlightControllerLink { await client.request(MspCommands.navStatus), ); } + + /// Fragt WP#0 (Homepoint, siehe `MspCommands.getWp`-Doku) gezielt per + /// `MSP_WP` ab - bewusst kein Teil von [MspTelemetryPoller]s Zyklus (Doku: + /// "Homepoint muss nicht kontinuierlich abgefragt werden"), Aufrufer ist + /// home_point_provider.dart. + @override + Future readHomePoint() async { + final client = _client; + if (client == null) { + throw StateError('readHomePoint() vor connect() aufgerufen.'); + } + final payload = await client.request( + MspCommands.getWp, + payload: encodeMspGetWaypointRequest(mspWpNumberHome), + ); + return parseMspHomePoint(payload); + } } diff --git a/app/lib/transport/msp/msp_waypoint_codec.dart b/app/lib/transport/msp/msp_waypoint_codec.dart index 37c4b17..75e58a6 100644 --- a/app/lib/transport/msp/msp_waypoint_codec.dart +++ b/app/lib/transport/msp/msp_waypoint_codec.dart @@ -1,6 +1,7 @@ import 'dart:typed_data'; import '../../domain/waypoint/flat_waypoint_list.dart'; +import '../flight_controller_link.dart'; /// `navWaypointActions_e` (Doku Kommunikationsschicht v2 Abschnitt 6, /// geprueft gegen den iNAV-9.1.0-Quellcode: `navigation.h`). Nur die vier @@ -136,3 +137,30 @@ MspWpGetInfo parseMspWpGetInfo(Uint8List payload) { waypointCount: payload[3], ); } + +/// Sonderwert fuer [encodeMspGetWaypointRequest]/`MspCommands.getWp`: WP#0 +/// ist laut `navigation.c` `getWaypoint()` der Homepoint, kein echter +/// Missions-Wegpunkt. +const int mspWpNumberHome = 0; + +/// Kodiert den 1-Byte-Request-Payload fuer `MspCommands.getWp` (`MSP_WP`). +Uint8List encodeMspGetWaypointRequest(int wpNumber) => + Uint8List.fromList([wpNumber]); + +/// Parst die 21-Byte-Antwort auf `MspCommands.getWp` fuer `wp_no == +/// [mspWpNumberHome]` (siehe dortige Doku zu `getWaypoint()`/`GPS_home`). +/// +/// Liefert `null`, wenn der FC noch keinen Homepoint gesetzt hat: dafuer +/// gibt es kein eigenes Flag in der Antwort selbst, `getWaypoint()` +/// initialisiert `lat`/`lon`/`alt` aber generell auf 0 und ueberschreibt +/// das fuer WP#0 nur, wenn `STATE(GPS_FIX_HOME)` gesetzt ist - (0,0) ist +/// also der FC-eigene "kein Homepoint"-Wert, nicht eine echte Koordinate +/// am Nullmeridian/Aequator. +HomePoint? parseMspHomePoint(Uint8List payload) { + final d = ByteData.sublistView(payload); + final lat = d.getInt32(2, Endian.little) / 1e7; + final lon = d.getInt32(6, Endian.little) / 1e7; + if (lat == 0 && lon == 0) return null; + final altCm = d.getInt32(10, Endian.little); + return HomePoint(lat: lat, lon: lon, altitudeM: altCm / 100); +} diff --git a/app/lib/ui/providers/home_point_provider.dart b/app/lib/ui/providers/home_point_provider.dart new file mode 100644 index 0000000..4b5fd94 --- /dev/null +++ b/app/lib/ui/providers/home_point_provider.dart @@ -0,0 +1,72 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../transport/flight_controller_link.dart'; +import 'telemetry_provider.dart'; + +/// Zuletzt vom Flightcontroller abgefragter Homepoint (Doku: "den auf der +/// Drohne gelockten Homepoint abfragen") - reine Ablage, analog +/// systemMessageLogProvider. Die eigentliche Abfrage-Logik (wann neu +/// nachgefragt wird) sitzt getrennt in [homePointAutoRefreshProvider], aus +/// demselben Grund wie dort: nur der Fly-Modus soll dafuer den Telemetrie-/ +/// Transport-Stack beobachten. +class HomePointNotifier extends Notifier { + @override + HomePoint? build() => null; + + void set(HomePoint? point) => state = point; +} + +final homePointProvider = + NotifierProvider(HomePointNotifier.new); + +/// Frischt [homePointProvider] gezielt bei drei Momenten auf, statt +/// kontinuierlich (Doku: "der Homepoint muss nicht kontinuierlich abgefragt +/// werden, aber an diesen drei Stellen macht es Sinn"): +/// - Verbindungsaufbau/-wiederaufbau: der FC koennte seit dem letzten Mal +/// einen anderen Homepoint gesetzt haben (z.B. nach einem Akkuwechsel). +/// - GPS-Fix-Erwerb: vorher konnte der FC ohnehin noch keinen gueltigen +/// Homepoint gesetzt haben (siehe `MspCommands.getWp`-Doku: +/// `STATE(GPS_FIX_HOME)`). +/// - Armen: laut iNAV-Default (`reset_home_type = FIRST_ARM`, +/// `navigation.c`/`settings.yaml`) friert genau dieser Moment den bis +/// dahin der Drohne folgenden Homepoint ein - vorherige Abfragen koennten +/// also noch veraltet/in Bewegung gewesen sein. +/// +/// autoDispose wie systemMessageAutoLogProvider (dortige Doku): nur aktiv, +/// waehrend irgendwer (FlyScreen) diesen Provider beobachtet, damit ein +/// simpler Missionswechsel im Plan-Modus nicht ungewollt den kompletten +/// Telemetrie-/Transport-Stack mit hochreisst. +final homePointAutoRefreshProvider = Provider.autoDispose((ref) { + TelemetryFrame? previousFrame; + var hadData = false; + + Future refresh() async { + final link = ref.read(flightControllerLinkProvider); + try { + ref.read(homePointProvider.notifier).set(await link.readHomePoint()); + } catch (_) { + // Einzelne fehlgeschlagene Abfrage ist kein Beinbruch - der naechste + // Trigger (naechster Fix/Armen/Reconnect) versucht es erneut. + } + } + + ref.listen>(telemetryProvider, (previous, next) { + next.whenData((frame) { + final justConnected = !hadData; + final fixJustAcquired = + previousFrame != null && !previousFrame!.hasFix && frame.hasFix; + final justArmed = previousFrame != null && !previousFrame!.armed && frame.armed; + previousFrame = frame; + hadData = true; + if (justConnected || fixJustAcquired || justArmed) { + unawaited(refresh()); + } + }); + if (next is AsyncError) { + previousFrame = null; + hadData = false; + } + }); +}); diff --git a/app/lib/ui/screens/fly/fly_screen.dart b/app/lib/ui/screens/fly/fly_screen.dart index 806ac59..a71bac4 100644 --- a/app/lib/ui/screens/fly/fly_screen.dart +++ b/app/lib/ui/screens/fly/fly_screen.dart @@ -21,6 +21,7 @@ import '../../../transport/flight_controller_link.dart'; import '../../providers/active_drone_profile_provider.dart'; import '../../providers/current_mission_provider.dart'; import '../../providers/follow_drone_provider.dart'; +import '../../providers/home_point_provider.dart'; import '../../providers/map_controller_provider.dart'; import '../../providers/mission_meta_provider.dart'; import '../../providers/system_message_log_provider.dart'; @@ -29,6 +30,7 @@ import '../../providers/terrain_provider.dart'; import '../settings/settings_screen.dart'; import '../../widgets/drone_marker_icon.dart'; import '../../widgets/drone_status_messages_panel.dart'; +import '../../widgets/home_point_icon.dart'; import '../../widgets/mission_footer_bar.dart'; import '../../widgets/mission_map.dart'; import '../../widgets/waypoint_list_panel.dart'; @@ -189,6 +191,13 @@ class _FlyScreenState extends ConsumerState { // gekoppelt). Der Rueckgabewert wird hier nicht gebraucht, nur der // Beobachtungs-Seiteneffekt. ref.watch(systemMessageAutoLogProvider); + // Homepoint-Abfrage bei Verbindungsaufbau/GPS-Fix/Armen (Doku: "den auf + // der Drohne gelockten Homepoint abfragen ... an diesen drei Stellen + // macht es Sinn") - analog systemMessageAutoLogProvider nur der + // Beobachtungs-Seiteneffekt, der eigentliche Wert kommt gleich aus + // homePointProvider. + ref.watch(homePointAutoRefreshProvider); + final homePoint = ref.watch(homePointProvider); // Solange der ueber den GPS-Knopf in der Kopfleiste (FlyMapControls) // aktivierte Folgemodus aktiv ist, jede neue Telemetrie-Position @@ -343,6 +352,20 @@ class _FlyScreenState extends ConsumerState { navMode: telemetry?.navMode ?? 0, ); + // Homepoint-Marker (Doku: "benutze dieses Icon, um den Homepoint + // anzuzeigen") - null solange homePointAutoRefreshProvider noch keinen + // der drei Trigger-Momente gesehen hat bzw. der FC (noch) keinen + // Homepoint gesetzt hat (siehe MspCommands.getWp/parseMspHomePoint-Doku). + final homePointMarker = homePoint == null + ? null + : Marker( + point: LatLng(homePoint.lat, homePoint.lon), + width: 32, + height: 32, + alignment: Alignment.center, + child: const HomePointIcon(), + ); + return Stack( children: [ MissionMap( @@ -362,6 +385,7 @@ class _FlyScreenState extends ConsumerState { ), ], droneMarker: droneMarker, + homePointMarker: homePointMarker, ), // Live-Hoehe/-Geschwindigkeit aus der Telemetrie (HTML-Demonstrator: // #altReadout/#spdReadout im Fly-Modus) - anders als dort keine diff --git a/app/lib/ui/widgets/home_point_icon.dart b/app/lib/ui/widgets/home_point_icon.dart new file mode 100644 index 0000000..ac53ab2 --- /dev/null +++ b/app/lib/ui/widgets/home_point_icon.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; + +/// Homepoint-Marker auf der Karte im Fly-Modus (Doku: "den auf der Drohne +/// gelockten Homepoint abfragen ... benutze dieses Icon"), Pentagon-Haus- +/// Form aus design/homepoint-pentagon.svg - nativ als [CustomPainter] statt +/// als SVG-Asset nachgebaut (Format bewusst geaendert, dasselbe Muster wie +/// DroneMarkerIcon/PaperPlaneIcon: ein einzelnes Polygon lohnt keine +/// zusaetzliche SVG-Renderer-Abhaengigkeit). +class HomePointIcon extends StatelessWidget { + const HomePointIcon({super.key, this.size = 32}); + + final double size; + + @override + Widget build(BuildContext context) { + return CustomPaint( + size: Size.square(size), + painter: const _HomePointPainter(), + ); + } +} + +class _HomePointPainter extends CustomPainter { + const _HomePointPainter(); + + // 1:1 aus design/homepoint-pentagon.svg uebernommene Koordinaten (viewBox + // 0 0 72 72): Haus-Silhouette (Fuenfeck) mit spitzem Dach. + static const _viewBoxSize = 72.0; + static const _points = [ + Offset(20, 52), + Offset(20, 34), + Offset(36, 16), + Offset(52, 34), + Offset(52, 52), + ]; + static const _fill = Color(0xFF161616); + static const _strokeWidth = 3.0; + + @override + void paint(Canvas canvas, Size size) { + final scale = size.width / _viewBoxSize; + Offset s(Offset p) => p * scale; + + final path = Path()..moveTo(s(_points.first).dx, s(_points.first).dy); + for (final point in _points.skip(1)) { + path.lineTo(s(point).dx, s(point).dy); + } + path.close(); + + canvas.drawPath(path, Paint()..color = _fill); + canvas.drawPath( + path, + Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = _strokeWidth * scale + ..strokeJoin = StrokeJoin.round, + ); + } + + @override + bool shouldRepaint(covariant _HomePointPainter oldDelegate) => false; +} diff --git a/app/lib/ui/widgets/mission_map.dart b/app/lib/ui/widgets/mission_map.dart index 678d411..e7bced8 100644 --- a/app/lib/ui/widgets/mission_map.dart +++ b/app/lib/ui/widgets/mission_map.dart @@ -16,6 +16,7 @@ class MissionMap extends StatelessWidget { this.routeSegments = const [], this.rubberBandLine, this.droneMarker, + this.homePointMarker, this.onMapReady, }); @@ -33,6 +34,11 @@ class MissionMap extends StatelessWidget { /// keine Telemetrie eingetroffen ist. final Marker? droneMarker; + /// Homepoint-Marker im Fly-Modus (Doku: "den auf der Drohne gelockten + /// Homepoint abfragen"), null solange noch keiner abgefragt wurde/der FC + /// noch keinen gesetzt hat - siehe home_point_provider.dart. + final Marker? homePointMarker; + /// Geraden + Kreisbogen-Segmente des Flugpfads (Doku 3.7/4.6); rot /// markierte Segmente ueberschreiten Steig-/Sinkrate oder Kurvenradius. final List routeSegments; @@ -95,6 +101,7 @@ class MissionMap extends StatelessWidget { CircleLayer(circles: waypointMarkers), MarkerLayer(markers: [ ...windMarkers, + ?homePointMarker, ?droneMarker, ]), const _CompactAttribution(), 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 5eb7512..6d81314 100644 --- a/app/test/transport/msp/msp_flight_controller_link_test.dart +++ b/app/test/transport/msp/msp_flight_controller_link_test.dart @@ -30,6 +30,14 @@ class _FakeFlightController { bool wpGetInfoValid = false; int wpGetInfoCount = 0; + /// null simuliert "FC hat noch keinen Homepoint gesetzt" (`!STATE( + /// GPS_FIX_HOME)`, meldet dann (0,0,0) statt eines Fehlers). + ({int lat, int lon, int altCm})? homePoint = ( + lat: 525000000, + lon: 134000000, + altCm: 6000, + ); + void _tick(Timer _) { while (_answered < transport.sentData.length) { final request = @@ -76,6 +84,17 @@ class _FakeFlightController { case MspCommands.wpGetInfo: payload = Uint8List.fromList( [0, 15, wpGetInfoValid ? 1 : 0, wpGetInfoCount]); + case MspCommands.getWp: + final wpNo = request.payload[0]; + final point = homePoint; + final d = ByteData(21); + d.setUint8(0, wpNo); + if (wpNo == 0 && point != null) { + d.setInt32(2, point.lat, Endian.little); + d.setInt32(6, point.lon, Endian.little); + d.setInt32(10, point.altCm, Endian.little); + } + payload = d.buffer.asUint8List(); default: payload = Uint8List(0); } @@ -170,6 +189,43 @@ void main() { responder.cancel(); }, timeout: const Timeout(Duration(seconds: 5))); + group('readHomePoint', () { + test('fragt MSP_WP mit wp_no=0 ab und dekodiert den Homepoint', () async { + final transport = LoopbackTransport(); + final responder = _startFakeFlightController(transport); + final link = MspFlightControllerLink(transport: transport); + await link.connect(); + + final home = await link.readHomePoint(); + + expect(home, isNotNull); + expect(home!.lat, closeTo(52.5, 1e-6)); + expect(home.lon, closeTo(13.4, 1e-6)); + expect(home.altitudeM, closeTo(60.0, 1e-9)); + + await link.disconnect(); + responder.cancel(); + }, timeout: const Timeout(Duration(seconds: 5))); + + test('liefert null, wenn der FC noch keinen Homepoint gesetzt hat ' + '(meldet (0,0,0) statt eines Fehlers)', () async { + final transport = LoopbackTransport(); + final responder = _startFakeFlightController(transport)..homePoint = null; + final link = MspFlightControllerLink(transport: transport); + await link.connect(); + + expect(await link.readHomePoint(), isNull); + + await link.disconnect(); + responder.cancel(); + }, timeout: const Timeout(Duration(seconds: 5))); + + test('readHomePoint() vor connect() wirft StateError', () { + final link = MspFlightControllerLink(transport: LoopbackTransport()); + expect(() => link.readHomePoint(), throwsStateError); + }); + }); + test('subscribeTelemetry() vor connect() wirft StateError', () { final link = MspFlightControllerLink(transport: LoopbackTransport()); expect(() => link.subscribeTelemetry(), throwsStateError); diff --git a/app/test/transport/msp/msp_waypoint_codec_test.dart b/app/test/transport/msp/msp_waypoint_codec_test.dart index d580eac..7936409 100644 --- a/app/test/transport/msp/msp_waypoint_codec_test.dart +++ b/app/test/transport/msp/msp_waypoint_codec_test.dart @@ -152,4 +152,68 @@ void main() { expect(info.isValid, isFalse); }); }); + + group('encodeMspGetWaypointRequest', () { + test('kodiert wp_no als einzelnes Byte', () { + expect(encodeMspGetWaypointRequest(0), Uint8List.fromList([0])); + expect(encodeMspGetWaypointRequest(255), Uint8List.fromList([255])); + }); + + test('mspWpNumberHome ist 0 (WP#0 = Homepoint laut getWaypoint())', () { + expect(mspWpNumberHome, 0); + }); + }); + + group('parseMspHomePoint', () { + /// Baut die 21-Byte MSP_WP-Antwort wie der echte FC (`fc_msp.c`, + /// `mspFcWaypointOutCommand()`) - dient nur der Testverifikation. + Uint8List encodeFakeWpResponse({ + int wpNo = 0, + int action = 0, + required int lat, + required int lon, + required int altCm, + }) { + final d = ByteData(21); + d.setUint8(0, wpNo); + d.setUint8(1, action); + d.setInt32(2, lat, Endian.little); + d.setInt32(6, lon, Endian.little); + d.setInt32(10, altCm, Endian.little); + return d.buffer.asUint8List(); + } + + test('dekodiert lat/lon (1e-7 deg) und Hoehe (cm -> m)', () { + final home = parseMspHomePoint(encodeFakeWpResponse( + lat: 525000000, + lon: 134000000, + altCm: 6050, + )); + expect(home, isNotNull); + expect(home!.lat, closeTo(52.5, 1e-9)); + expect(home.lon, closeTo(13.4, 1e-9)); + expect(home.altitudeM, closeTo(60.5, 1e-9)); + }); + + test('liefert null bei (0,0) - FCs eigener "kein Homepoint gesetzt"-Wert ' + '(STATE(GPS_FIX_HOME) noch nicht erreicht)', () { + final home = parseMspHomePoint(encodeFakeWpResponse( + lat: 0, + lon: 0, + altCm: 0, + )); + expect(home, isNull); + }); + + test('lat == 0 alleine (echter Punkt auf dem Aequator) zaehlt NICHT als ' + 'unset - nur (0,0) gemeinsam ist der Sonderfall', () { + final home = parseMspHomePoint(encodeFakeWpResponse( + lat: 0, + lon: 134000000, + altCm: 0, + )); + expect(home, isNotNull); + expect(home!.lon, closeTo(13.4, 1e-9)); + }); + }); } diff --git a/app/test/ui/providers/home_point_provider_test.dart b/app/test/ui/providers/home_point_provider_test.dart new file mode 100644 index 0000000..47c2210 --- /dev/null +++ b/app/test/ui/providers/home_point_provider_test.dart @@ -0,0 +1,200 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart'; +import 'package:dmc_app/transport/flight_controller_link.dart'; +import 'package:dmc_app/ui/providers/home_point_provider.dart'; +import 'package:dmc_app/ui/providers/telemetry_provider.dart'; + +/// Zaehlt readHomePoint()-Aufrufe statt sie tatsaechlich per MSP zu +/// beantworten - alle anderen Mitglieder sind fuer diesen Test irrelevant +/// und werfen bei Gebrauch bewusst. +class _FakeLink implements FlightControllerLink { + int readHomePointCalls = 0; + HomePoint? nextHomePoint; + + @override + Future readHomePoint() async { + readHomePointCalls++; + return nextHomePoint; + } + + @override + FcCapabilities get capabilities => throw UnimplementedError(); + @override + Future connect() => throw UnimplementedError(); + @override + Future disconnect() => throw UnimplementedError(); + @override + Future uploadMission(FlatWaypointList mission) => throw UnimplementedError(); + @override + Future verifyMission(FlatWaypointList mission) => throw UnimplementedError(); + @override + Future setFlightMode(FlightMode mode) => throw UnimplementedError(); + @override + Future arm() => throw UnimplementedError(); + @override + Stream subscribeTelemetry() => throw UnimplementedError(); + @override + Future readActiveWaypointIndex() => throw UnimplementedError(); +} + +TelemetryFrame _frame({bool hasFix = true, bool armed = false}) => TelemetryFrame( + lat: 52.5, + lon: 13.4, + hasFix: hasFix, + fixType: hasFix ? 2 : 0, + numSat: 10, + altitudeM: 100, + gpsAltitudeM: 100, + speedMs: 15, + headingDeg: 0, + armed: armed, + batteryPercent: 80, + batteryVoltage: 22.0, + currentA: 10.0, + linkQuality: 90, + snrDb: 5, + hdop: 1.0, + navMode: 0, + flightMode: 'Idle', + sensorStatusBits: 0, + temperaturesC: const [null, null, null], + verticalSpeedMs: 0, + ); + +void main() { + test('fragt den Homepoint beim ersten Telemetrie-Frame ab (Verbindungsaufbau)', + () async { + final controller = StreamController(); + addTearDown(controller.close); + final link = _FakeLink() + ..nextHomePoint = const HomePoint(lat: 52.5, lon: 13.4, altitudeM: 60); + final container = ProviderContainer(overrides: [ + telemetryProvider.overrideWith((ref) => controller.stream), + flightControllerLinkProvider.overrideWithValue(link), + ]); + addTearDown(container.dispose); + container.listen(homePointAutoRefreshProvider, (previous, next) {}); + + controller.add(_frame(hasFix: false)); + await Future.delayed(Duration.zero); + + expect(link.readHomePointCalls, 1); + expect(container.read(homePointProvider)?.lat, closeTo(52.5, 1e-9)); + }); + + test('fragt NICHT bei jedem Frame erneut ab, solange sich Fix-/Arm-Status ' + 'nicht aendern (keine kontinuierliche Abfrage)', () async { + final controller = StreamController(); + addTearDown(controller.close); + final link = _FakeLink(); + final container = ProviderContainer(overrides: [ + telemetryProvider.overrideWith((ref) => controller.stream), + flightControllerLinkProvider.overrideWithValue(link), + ]); + addTearDown(container.dispose); + container.listen(homePointAutoRefreshProvider, (previous, next) {}); + + controller.add(_frame()); + await Future.delayed(Duration.zero); + controller.add(_frame()); + controller.add(_frame()); + controller.add(_frame()); + await Future.delayed(Duration.zero); + + expect(link.readHomePointCalls, 1, reason: 'nur der erste Frame (Connect) loest aus'); + }); + + test('fragt beim Uebergang auf einen GPS-Fix erneut ab', () async { + final controller = StreamController(); + addTearDown(controller.close); + final link = _FakeLink(); + final container = ProviderContainer(overrides: [ + telemetryProvider.overrideWith((ref) => controller.stream), + flightControllerLinkProvider.overrideWithValue(link), + ]); + addTearDown(container.dispose); + container.listen(homePointAutoRefreshProvider, (previous, next) {}); + + controller.add(_frame(hasFix: false)); + await Future.delayed(Duration.zero); + expect(link.readHomePointCalls, 1, reason: 'erster Frame = Connect-Trigger'); + + controller.add(_frame(hasFix: true)); + await Future.delayed(Duration.zero); + expect(link.readHomePointCalls, 2, reason: 'Fix-Erwerb ist ein eigener Trigger'); + + controller.add(_frame(hasFix: true)); + await Future.delayed(Duration.zero); + expect(link.readHomePointCalls, 2, reason: 'weiterhin Fix loest nicht erneut aus'); + }); + + test('fragt beim Uebergang auf armed erneut ab', () async { + final controller = StreamController(); + addTearDown(controller.close); + final link = _FakeLink(); + final container = ProviderContainer(overrides: [ + telemetryProvider.overrideWith((ref) => controller.stream), + flightControllerLinkProvider.overrideWithValue(link), + ]); + addTearDown(container.dispose); + container.listen(homePointAutoRefreshProvider, (previous, next) {}); + + controller.add(_frame(armed: false)); + await Future.delayed(Duration.zero); + expect(link.readHomePointCalls, 1, reason: 'erster Frame = Connect-Trigger'); + + controller.add(_frame(armed: true)); + await Future.delayed(Duration.zero); + expect(link.readHomePointCalls, 2, reason: 'Armen ist ein eigener Trigger'); + + controller.add(_frame(armed: true)); + await Future.delayed(Duration.zero); + expect(link.readHomePointCalls, 2, reason: 'weiterhin armed loest nicht erneut aus'); + }); + + test('fragt nach einem Verbindungsverlust bei Wiederverbindung erneut ab', + () async { + final controller = StreamController(); + addTearDown(controller.close); + final link = _FakeLink(); + final container = ProviderContainer(overrides: [ + telemetryProvider.overrideWith((ref) => controller.stream), + flightControllerLinkProvider.overrideWithValue(link), + ]); + addTearDown(container.dispose); + container.listen(homePointAutoRefreshProvider, (previous, next) {}); + + controller.add(_frame()); + await Future.delayed(Duration.zero); + expect(link.readHomePointCalls, 1); + + controller.addError(StateError('link down')); + await Future.delayed(Duration.zero); + expect(link.readHomePointCalls, 1, reason: 'ein Fehler alleine fragt nichts ab'); + + controller.add(_frame()); + await Future.delayed(Duration.zero); + expect(link.readHomePointCalls, 2, reason: 'erster Frame nach Reconnect ist wieder ein Trigger'); + }); + + test('speichert null, wenn der FC (noch) keinen Homepoint gesetzt hat', () async { + final controller = StreamController(); + addTearDown(controller.close); + final link = _FakeLink()..nextHomePoint = null; + final container = ProviderContainer(overrides: [ + telemetryProvider.overrideWith((ref) => controller.stream), + flightControllerLinkProvider.overrideWithValue(link), + ]); + addTearDown(container.dispose); + container.listen(homePointAutoRefreshProvider, (previous, next) {}); + + controller.add(_frame()); + await Future.delayed(Duration.zero); + + expect(container.read(homePointProvider), isNull); + }); +}