msp over wifi implemented

This commit is contained in:
Constantin Leue
2026-08-02 09:20:05 +02:00
parent 24db38dc33
commit 6cfb82bd77
11 changed files with 155 additions and 13 deletions
@@ -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;
}
@@ -10,6 +10,7 @@ class MockFlightControllerLink implements FlightControllerLink {
int? _activeWaypointIndex;
StreamController<TelemetryFrame>? _telemetryController;
Timer? _ticker;
double _heading = 0;
@override
FcCapabilities get capabilities => const FcCapabilities(
@@ -22,11 +23,17 @@ class MockFlightControllerLink implements FlightControllerLink {
Future<void> connect() async {
_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;
_telemetryController?.add(TelemetryFrame(
lat: 52.5,
lon: 13.4,
altitudeM: 120,
speedMs: 18,
headingDeg: _heading,
armed: _armed,
activeWaypointIndex: _activeWaypointIndex,
));
@@ -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,
);
}
@@ -90,6 +90,7 @@ class MspTelemetryPoller {
lon: gps.lon,
altitudeM: altitudeM,
speedMs: gps.speedMs,
headingDeg: gps.headingDeg,
armed: _armed,
activeWaypointIndex: _activeWaypointIndex,
));
+9
View File
@@ -101,6 +101,15 @@ class UdpTransport implements LinkTransport {
@override
Future<void> 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);
+26 -5
View File
@@ -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<FlightControllerLink>((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;
});
+1 -1
View File
@@ -149,7 +149,7 @@ class FlyScreen extends ConsumerWidget {
width: 28,
height: 28,
alignment: Alignment.center,
child: const DroneMarkerIcon(),
child: DroneMarkerIcon(headingDeg: telemetry.headingDeg),
),
),
Positioned(
+74 -5
View File
@@ -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;
}
@@ -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();
@@ -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', () {
+23 -2
View File
@@ -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<void> _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),