From 6cfb82bd7769355d8d944943e1e2e876045d8482 Mon Sep 17 00:00:00 2001 From: Constantin Leue <77660791+Consti3D@users.noreply.github.com> Date: Sun, 2 Aug 2026 09:20:05 +0200 Subject: [PATCH] msp over wifi implemented --- app/lib/transport/flight_controller_link.dart | 5 ++ .../mock/mock_flight_controller_link.dart | 7 ++ .../transport/msp/msp_telemetry_codec.dart | 6 ++ .../transport/msp/msp_telemetry_poller.dart | 1 + app/lib/transport/udp/udp_transport.dart | 9 +++ app/lib/ui/providers/telemetry_provider.dart | 31 ++++++-- app/lib/ui/screens/fly/fly_screen.dart | 2 +- app/lib/ui/widgets/drone_marker_icon.dart | 79 +++++++++++++++++-- .../msp/msp_flight_controller_link_test.dart | 2 + .../msp/msp_telemetry_codec_test.dart | 1 + app/test/widget_test.dart | 25 +++++- 11 files changed, 155 insertions(+), 13 deletions(-) diff --git a/app/lib/transport/flight_controller_link.dart b/app/lib/transport/flight_controller_link.dart index d121bdc..ea34caf 100644 --- a/app/lib/transport/flight_controller_link.dart +++ b/app/lib/transport/flight_controller_link.dart @@ -23,6 +23,7 @@ class TelemetryFrame { required this.lon, required this.altitudeM, required this.speedMs, + required this.headingDeg, required this.armed, this.activeWaypointIndex, }); @@ -31,6 +32,10 @@ class TelemetryFrame { final double lon; final double altitudeM; final double speedMs; + + /// Kurs ueber Grund in Grad (0 = Norden, im Uhrzeigersinn) - dreht den + /// Drohnen-Marker (Doku 3.10, [DroneMarkerIcon]). + final double headingDeg; final bool armed; final int? activeWaypointIndex; } diff --git a/app/lib/transport/mock/mock_flight_controller_link.dart b/app/lib/transport/mock/mock_flight_controller_link.dart index 4c66281..a225e14 100644 --- a/app/lib/transport/mock/mock_flight_controller_link.dart +++ b/app/lib/transport/mock/mock_flight_controller_link.dart @@ -10,6 +10,7 @@ class MockFlightControllerLink implements FlightControllerLink { int? _activeWaypointIndex; StreamController? _telemetryController; Timer? _ticker; + double _heading = 0; @override FcCapabilities get capabilities => const FcCapabilities( @@ -22,11 +23,17 @@ class MockFlightControllerLink implements FlightControllerLink { Future connect() async { _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; _telemetryController?.add(TelemetryFrame( lat: 52.5, lon: 13.4, altitudeM: 120, speedMs: 18, + headingDeg: _heading, armed: _armed, activeWaypointIndex: _activeWaypointIndex, )); diff --git a/app/lib/transport/msp/msp_telemetry_codec.dart b/app/lib/transport/msp/msp_telemetry_codec.dart index 3632a1e..326caac 100644 --- a/app/lib/transport/msp/msp_telemetry_codec.dart +++ b/app/lib/transport/msp/msp_telemetry_codec.dart @@ -10,6 +10,7 @@ class MspGpsReading { required this.lat, required this.lon, required this.speedMs, + required this.headingDeg, }); /// 0 = kein Fix (`fc_msp.c`: `gpsSol.fixType`). @@ -19,6 +20,10 @@ class MspGpsReading { final double lon; final double speedMs; + /// Kurs ueber Grund in Grad (0 = Norden, im Uhrzeigersinn), aus + /// `groundCourse` - nur bei [hasFix] aussagekraeftig. + final double headingDeg; + bool get hasFix => fixType != 0; } @@ -31,6 +36,7 @@ MspGpsReading parseMspRawGps(Uint8List payload) { lat: data.getInt32(2, Endian.little) / 1e7, lon: data.getInt32(6, Endian.little) / 1e7, speedMs: data.getUint16(12, Endian.little) / 100.0, + headingDeg: data.getUint16(14, Endian.little) / 10.0, ); } diff --git a/app/lib/transport/msp/msp_telemetry_poller.dart b/app/lib/transport/msp/msp_telemetry_poller.dart index 6c26e64..a1a1174 100644 --- a/app/lib/transport/msp/msp_telemetry_poller.dart +++ b/app/lib/transport/msp/msp_telemetry_poller.dart @@ -90,6 +90,7 @@ class MspTelemetryPoller { lon: gps.lon, altitudeM: altitudeM, speedMs: gps.speedMs, + headingDeg: gps.headingDeg, armed: _armed, activeWaypointIndex: _activeWaypointIndex, )); diff --git a/app/lib/transport/udp/udp_transport.dart b/app/lib/transport/udp/udp_transport.dart index 18824be..7fc4e11 100644 --- a/app/lib/transport/udp/udp_transport.dart +++ b/app/lib/transport/udp/udp_transport.dart @@ -101,6 +101,15 @@ class UdpTransport implements LinkTransport { @override Future connect() async { + // Idempotent: mehrere Aufrufer koennen unabhaengig voneinander "sorg + // dafuer, dass wir verbunden sind" anfordern (z.B. der Auto-Connect- + // Trigger beim Wechsel in den Fly-Modus UND der MSP-Telemetrie-Consumer, + // der denselben Transport braucht) - ohne diese Sperre wuerde der + // zweite Aufruf eine weitere WifiNetworkSpecifier-Anfrage samt + // Systemdialog auslösen, waehrend die erste noch laeuft. + if (_state == LinkState.connecting || _state == LinkState.connected) { + return; + } _lastErrorReason = null; _lastErrorMessage = null; _setState(LinkState.connecting); diff --git a/app/lib/ui/providers/telemetry_provider.dart b/app/lib/ui/providers/telemetry_provider.dart index 08c4664..3f05128 100644 --- a/app/lib/ui/providers/telemetry_provider.dart +++ b/app/lib/ui/providers/telemetry_provider.dart @@ -1,16 +1,37 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../transport/connection_type.dart'; import '../../transport/flight_controller_link.dart'; import '../../transport/mock/mock_flight_controller_link.dart'; +import '../../transport/msp/msp_flight_controller_link.dart'; +import 'connection_settings_provider.dart'; +import 'wifi_connection_provider.dart'; -/// FlightControllerLink fuer den Fly-Modus (Doku 3.1) - aktuell fest auf den -/// Mock verdrahtet, bis ein echter Transport (MSP, Doku 4.19) existiert. +/// FlightControllerLink fuer den Fly-Modus (Doku 3.1): bei WLAN als +/// gewaehlter Verbindungsart ein echter MSP-Link ueber den UDP-Transport, +/// sonst (Bluetooth/5G/USB, oder solange die Einstellungen noch nicht +/// geladen sind) weiterhin der Mock fuer UI-Entwicklung ohne Hardware - +/// Bluetooth ist auf der eingesetzten mLRS-Bruecke ohnehin nicht moeglich +/// (Doku Kommunikationsschicht v2, Abschnitt 1), 5G/USB sind noch nicht +/// umgesetzt. +/// +/// connectionType wird bewusst per ref.read() einmalig beim Eintritt in den +/// Fly-Modus gelesen, nicht reaktiv beobachtet - sonst wuerde jede +/// Einstellungsaenderung waehrend des Flugs (z.B. Tippen im +/// SSID-Praefix-Feld, das ueberall im Settings-Screen ein +/// `ref.invalidate(connectionSettingsProvider)` ausloest) einen Neuaufbau +/// dieses Providers samt Trennen/Neuverbinden des Transports ausloesen. +/// /// autoDispose: verbindet erst, wenn der Fly-Screen tatsaechlich beobachtet -/// wird, und trennt/stoppt den Mock-Ticker wieder, sobald niemand mehr -/// zuhoert (z.B. Rueckkehr in den Plan-Modus) statt endlos im Hintergrund +/// wird, und trennt sich wieder (Doku Abschnitt 8: "Verlassen des +/// Fly-Modus gibt den Netz-Request frei"), sobald niemand mehr zuhoert +/// (z.B. Rueckkehr in den Plan-Modus) statt endlos im Hintergrund /// weiterzulaufen. final flightControllerLinkProvider = Provider.autoDispose((ref) { - final link = MockFlightControllerLink(); + final connectionType = ref.read(connectionSettingsProvider).value?.connectionType; + final FlightControllerLink link = connectionType == ConnectionType.wifi + ? MspFlightControllerLink(transport: ref.watch(wifiTransportProvider)) + : MockFlightControllerLink(); ref.onDispose(link.disconnect); return link; }); diff --git a/app/lib/ui/screens/fly/fly_screen.dart b/app/lib/ui/screens/fly/fly_screen.dart index 5d0644a..5d7b715 100644 --- a/app/lib/ui/screens/fly/fly_screen.dart +++ b/app/lib/ui/screens/fly/fly_screen.dart @@ -149,7 +149,7 @@ class FlyScreen extends ConsumerWidget { width: 28, height: 28, alignment: Alignment.center, - child: const DroneMarkerIcon(), + child: DroneMarkerIcon(headingDeg: telemetry.headingDeg), ), ), Positioned( diff --git a/app/lib/ui/widgets/drone_marker_icon.dart b/app/lib/ui/widgets/drone_marker_icon.dart index caf8392..ea7465d 100644 --- a/app/lib/ui/widgets/drone_marker_icon.dart +++ b/app/lib/ui/widgets/drone_marker_icon.dart @@ -1,15 +1,84 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; -/// Live-Positionsmarker der Drohne im Fly-Modus (Doku 3.10) - noch ohne -/// Peilungsanzeige, da TelemetryFrame aktuell keinen Kurs (heading) -/// mitliefert (nur MockFlightControllerLink, siehe Doku 4.19). +/// Live-Positionsmarker der Drohne im Fly-Modus (Doku 3.10), Papierflieger- +/// Form aus design/B_paperplane.svg - nativ als [CustomPainter] statt als +/// SVG-Asset nachgebaut (Format bewusst geaendert: die drei Pfade sind +/// einfache Dreiecke, eine zusaetzliche SVG-Renderer-Abhaengigkeit lohnt sich +/// dafuer nicht, siehe auch mountain_peak_icon.dart/wind_arrow_icon.dart fuer +/// dasselbe Muster). Zeigt unrotiert (Spitze nach oben) bei fehlendem +/// [headingDeg], sonst im Uhrzeigersinn um den Kurs ueber Grund gedreht. class DroneMarkerIcon extends StatelessWidget { - const DroneMarkerIcon({super.key, this.size = 22}); + const DroneMarkerIcon({super.key, this.size = 28, this.headingDeg}); final double size; + /// Kurs ueber Grund in Grad (0 = Norden, im Uhrzeigersinn) aus + /// TelemetryFrame.headingDeg. + final double? headingDeg; + @override Widget build(BuildContext context) { - return Icon(Icons.airplanemode_active, color: const Color(0xFF4D9DFF), size: size); + return Transform.rotate( + angle: (headingDeg ?? 0) * math.pi / 180, + child: CustomPaint( + size: Size.square(size), + painter: const _PaperPlanePainter(), + ), + ); } } + +class _PaperPlanePainter extends CustomPainter { + const _PaperPlanePainter(); + + // 1:1 aus design/B_paperplane.svg uebernommene Koordinaten (viewBox + // 0 0 200 200): Spitze oben, zwei Fluegel unten, Heckkerbe dazwischen. + static const _viewBoxSize = 200.0; + static const _nose = Offset(100, 22); + static const _rightWing = Offset(164, 172); + static const _tailNotch = Offset(100, 138); + static const _leftWing = Offset(36, 172); + static const _rightFill = Color(0xFFFF2E93); + static const _leftFill = Color(0xFFB01464); + static const _strokeWidth = 7.0; + + @override + void paint(Canvas canvas, Size size) { + final scale = size.width / _viewBoxSize; + Offset s(Offset p) => p * scale; + + final rightHalf = Path() + ..moveTo(s(_nose).dx, s(_nose).dy) + ..lineTo(s(_rightWing).dx, s(_rightWing).dy) + ..lineTo(s(_tailNotch).dx, s(_tailNotch).dy) + ..close(); + final leftHalf = Path() + ..moveTo(s(_nose).dx, s(_nose).dy) + ..lineTo(s(_leftWing).dx, s(_leftWing).dy) + ..lineTo(s(_tailNotch).dx, s(_tailNotch).dy) + ..close(); + + canvas.drawPath(rightHalf, Paint()..color = _rightFill); + canvas.drawPath(leftHalf, Paint()..color = _leftFill); + + final outline = Path() + ..moveTo(s(_nose).dx, s(_nose).dy) + ..lineTo(s(_rightWing).dx, s(_rightWing).dy) + ..lineTo(s(_tailNotch).dx, s(_tailNotch).dy) + ..lineTo(s(_leftWing).dx, s(_leftWing).dy) + ..close(); + canvas.drawPath( + outline, + Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = _strokeWidth * scale + ..strokeJoin = StrokeJoin.round, + ); + } + + @override + bool shouldRepaint(covariant _PaperPlanePainter oldDelegate) => false; +} 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 7c0ffc9..28fbc4b 100644 --- a/app/test/transport/msp/msp_flight_controller_link_test.dart +++ b/app/test/transport/msp/msp_flight_controller_link_test.dart @@ -29,6 +29,7 @@ Timer _startFakeFlightController(LoopbackTransport transport) { d.setInt32(2, 525200000, Endian.little); // lat 52.52 d.setInt32(6, 134050000, Endian.little); // lon 13.405 d.setUint16(12, 500, Endian.little); // 5.00 m/s + d.setUint16(14, 2700, Endian.little); // 270.0 deg payload = d.buffer.asUint8List(); case MspCommands.altitude: final d = ByteData(10); @@ -67,6 +68,7 @@ void main() { expect(frame.lon, closeTo(13.405, 1e-6)); expect(frame.altitudeM, closeTo(50.0, 1e-6)); expect(frame.speedMs, closeTo(5.0, 1e-6)); + expect(frame.headingDeg, closeTo(270.0, 1e-6)); await link.disconnect(); responder.cancel(); diff --git a/app/test/transport/msp/msp_telemetry_codec_test.dart b/app/test/transport/msp/msp_telemetry_codec_test.dart index 605bc36..0814082 100644 --- a/app/test/transport/msp/msp_telemetry_codec_test.dart +++ b/app/test/transport/msp/msp_telemetry_codec_test.dart @@ -28,6 +28,7 @@ void main() { expect(reading.lat, closeTo(52.52, 1e-9)); expect(reading.lon, closeTo(13.405, 1e-9)); expect(reading.speedMs, closeTo(12.34, 1e-9)); + expect(reading.headingDeg, closeTo(90.0, 1e-9)); }); test('fixType 0 bedeutet kein Fix', () { diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index cf9b55b..769ecaf 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -25,6 +25,7 @@ import 'package:dmc_app/ui/providers/geocoding_provider.dart'; import 'package:dmc_app/ui/providers/mission_meta_provider.dart'; import 'package:dmc_app/ui/providers/sharing_provider.dart'; import 'package:dmc_app/ui/providers/telemetry_provider.dart'; +import 'package:dmc_app/transport/mock/mock_flight_controller_link.dart'; import 'package:dmc_app/ui/widgets/full_value_chart.dart'; import 'package:dmc_app/ui/widgets/halo_menu.dart'; import 'package:dmc_app/ui/widgets/header_wind_pill.dart'; @@ -68,8 +69,21 @@ class _FakeSharingService extends SharingService { // path_provider-Plugin (echtes Dateisystem) benoetigt wird. AppDatabase _testDatabase() => AppDatabase.forTesting(NativeDatabase.memory()); +// flightControllerLinkProvider waehlt inzwischen je nach Verbindungsart +// (Settings-Default: WLAN) zwischen echtem MSP-ueber-UDP-Link und dem Mock - +// in Tests immer explizit den Mock erzwingen, sonst wuerde der Fly-Modus +// versuchen, einen echten Platform-Channel (WifiNetworkSpecifier) zu +// verwenden, der in der Testumgebung nie eine Antwort liefert und den Test +// haengen liesse. ProviderContainer _testContainer() => ProviderContainer( - overrides: [appDatabaseProvider.overrideWithValue(_testDatabase())], + overrides: [ + appDatabaseProvider.overrideWithValue(_testDatabase()), + flightControllerLinkProvider.overrideWith((ref) { + final link = MockFlightControllerLink(); + ref.onDispose(link.disconnect); + return link; + }), + ], ); /// Der Fly-Modus startet ueber telemetryProvider einen Mock-Telemetrie- @@ -81,7 +95,14 @@ Future _stopTelemetry(ProviderContainer container) => container.read(flightControllerLinkProvider).disconnect(); Widget _wrap(Widget child) => ProviderScope( - overrides: [appDatabaseProvider.overrideWithValue(_testDatabase())], + overrides: [ + appDatabaseProvider.overrideWithValue(_testDatabase()), + flightControllerLinkProvider.overrideWith((ref) { + final link = MockFlightControllerLink(); + ref.onDispose(link.disconnect); + return link; + }), + ], child: BlocProvider( create: (_) => AppModeCubit(), child: MaterialApp(home: child),