From 078d58dbc01e4ba5fd0b3db8112db51dd3309939 Mon Sep 17 00:00:00 2001 From: Constantin Leue <77660791+Consti3D@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:18:48 +0200 Subject: [PATCH] MSP protocol implementation and settings menu --- app/lib/transport/msp/msp_client.dart | 111 ++++++++++++++++ app/lib/transport/msp/msp_commands.dart | 37 ++++++ .../msp/msp_flight_controller_link.dart | 82 ++++++++++-- app/lib/transport/msp/msp_frame.dart | 112 +++++++++++++++++ app/lib/transport/msp/msp_frame_decoder.dart | 101 +++++++++++++++ .../transport/msp/msp_telemetry_codec.dart | 60 +++++++++ .../transport/msp/msp_telemetry_poller.dart | 103 +++++++++++++++ app/lib/ui/screens/fly/fly_screen.dart | 4 + app/lib/ui/screens/plan/plan_screen.dart | 4 + app/lib/ui/widgets/bottom_stats_bar.dart | 75 ++++++++--- app/lib/ui/widgets/mission_footer_bar.dart | 3 + app/test/transport/msp/msp_client_test.dart | 118 ++++++++++++++++++ .../msp/msp_flight_controller_link_test.dart | 96 ++++++++++++++ .../transport/msp/msp_frame_decoder_test.dart | 91 ++++++++++++++ app/test/transport/msp/msp_frame_test.dart | 68 ++++++++++ .../msp/msp_telemetry_codec_test.dart | 96 ++++++++++++++ 16 files changed, 1132 insertions(+), 29 deletions(-) create mode 100644 app/lib/transport/msp/msp_client.dart create mode 100644 app/lib/transport/msp/msp_commands.dart create mode 100644 app/lib/transport/msp/msp_frame.dart create mode 100644 app/lib/transport/msp/msp_frame_decoder.dart create mode 100644 app/lib/transport/msp/msp_telemetry_codec.dart create mode 100644 app/lib/transport/msp/msp_telemetry_poller.dart create mode 100644 app/test/transport/msp/msp_client_test.dart create mode 100644 app/test/transport/msp/msp_flight_controller_link_test.dart create mode 100644 app/test/transport/msp/msp_frame_decoder_test.dart create mode 100644 app/test/transport/msp/msp_frame_test.dart create mode 100644 app/test/transport/msp/msp_telemetry_codec_test.dart diff --git a/app/lib/transport/msp/msp_client.dart b/app/lib/transport/msp/msp_client.dart new file mode 100644 index 0000000..701200e --- /dev/null +++ b/app/lib/transport/msp/msp_client.dart @@ -0,0 +1,111 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import '../link_transport.dart'; +import 'msp_frame.dart'; +import 'msp_frame_decoder.dart'; + +/// Wird geworfen, wenn eine MSP-Anfrage nach allen Wiederholungen keine +/// Antwort bekommen hat, oder wenn die Gegenstelle eine Fehlerantwort +/// (`dir` = '!') gemeldet hat. +class MspRequestException implements Exception { + const MspRequestException(this.message); + final String message; + + @override + String toString() => 'MspRequestException: $message'; +} + +/// MSP ist ein reines Frage-Antwort-Protokoll (Doku Kommunikationsschicht +/// Abschnitt 4): die App fragt, der FC antwortet, von selbst kommt nichts. +/// [MspClient] setzt darauf den in der Doku geforderten eigenen Takt auf: +/// Anfragen werden serialisiert (immer nur eine offene Anfrage - sonst +/// laufen bei schwacher Strecke die Antworten durcheinander), mit +/// Zeitgrenze und Wiederholung. +class MspClient { + MspClient( + this._transport, { + this.requestTimeout = const Duration(milliseconds: 500), + this.maxRetries = 2, + }) { + _incomingSub = _transport.incoming.listen(_onBytes); + } + + final LinkTransport _transport; + final Duration requestTimeout; + final int maxRetries; + + final _decoder = MspFrameDecoder(); + StreamSubscription? _incomingSub; + + // Einfache Mutex-Kette (siehe unten in request()): serialisiert + // ueberlappende Aufrufe, ohne eine explizite Warteschlangen-Datenstruktur + // zu brauchen. + Future _lock = Future.value(); + + Completer? _pending; + int? _pendingFunction; + + void _onBytes(Uint8List chunk) { + for (final frame in _decoder.addBytes(chunk)) { + final pending = _pending; + if (pending != null && + !pending.isCompleted && + frame.function == _pendingFunction) { + pending.complete(frame); + } + // Rahmen, die zu keiner offenen Anfrage passen (z.B. eine verspaetete + // Antwort nach bereits abgelaufenem Timeout), werden verworfen - MSP + // ist strikt Frage/Antwort, es gibt keine unaufgeforderten Pakete. + } + } + + /// Fragt [function] ab und liefert den rohen Antwort-Payload. Wartet ggf. + /// auf eine bereits laufende Anfrage (Serialisierung), bevor die eigene + /// gesendet wird. + Future request(int function, {Uint8List? payload}) async { + final previous = _lock; + final release = Completer(); + _lock = release.future; + await previous; + try { + return await _requestOnce(function, payload: payload); + } finally { + release.complete(); + } + } + + Future _requestOnce(int function, {Uint8List? payload}) async { + Object? lastError; + for (var attempt = 0; attempt <= maxRetries; attempt++) { + final completer = Completer(); + _pending = completer; + _pendingFunction = function; + try { + await _transport.send(encodeMspV2Request(function, payload)); + final frame = await completer.future.timeout(requestTimeout); + if (frame.direction == MspDirection.error) { + throw MspRequestException( + 'Flightcontroller meldet Fehler fuer MSP-Funktion $function', + ); + } + return frame.payload; + } on TimeoutException catch (e) { + lastError = e; + } finally { + if (identical(_pending, completer)) { + _pending = null; + _pendingFunction = null; + } + } + } + throw MspRequestException( + 'Keine Antwort auf MSP-Funktion $function nach ${maxRetries + 1} ' + 'Versuchen${lastError != null ? ': $lastError' : ''}', + ); + } + + void dispose() { + _incomingSub?.cancel(); + } +} diff --git a/app/lib/transport/msp/msp_commands.dart b/app/lib/transport/msp/msp_commands.dart new file mode 100644 index 0000000..26c5bbc --- /dev/null +++ b/app/lib/transport/msp/msp_commands.dart @@ -0,0 +1,37 @@ +/// MSP-Befehlsnummern, geprueft gegen den tatsaechlichen iNAV-9.1.0-Quellcode +/// statt aus dem Gedaechtnis uebernommen (Doku Kommunikationsschicht +/// Abschnitt 4): +/// https://github.com/iNavFlight/inav/blob/9.1.0/src/main/msp/msp_protocol.h +/// https://github.com/iNavFlight/inav/blob/9.1.0/src/main/msp/msp_protocol_v2_inav.h +/// +/// `MSP_STATUS` und `MSP_ANALOG` sind laut `msp_protocol.h` seit iNAV 9.1 +/// als "DEPRECATED ... use MSP2_INAV_STATUS/MSP2_INAV_ANALOG instead. Will +/// be removed in INAV 10.0" markiert - deshalb wird hier bewusst die +/// MSP2_INAV_-Variante verwendet, nicht die MSPv1-Klassiker. +abstract final class MspCommands { + /// Response: u8 fixType, u8 numSat, i32 lat(1e-7 deg), i32 lon(1e-7 deg), + /// u16 alt(m), u16 groundSpeed(cm/s), u16 groundCourse(0.1 deg), u16 hdop. + /// (`fc_msp.c`, `case MSP_RAW_GPS`) + static const int rawGps = 106; + + /// Response: i32 estAlt(cm), i16 vario(cm/s), i32 baroAlt(cm). + /// (`fc_msp.c`, `case MSP_ALTITUDE`) + static const int altitude = 109; + + /// Response: u8 mode, u8 state, u8 activeWpAction, u8 activeWpNumber, + /// u8 error, u16 headingHoldTarget. (`fc_msp.c`, `case MSP_NAV_STATUS`) + static const int navStatus = 121; + + /// Response: u16 cycleTime, u16 i2cErrors, u16 sensorStatus, + /// u16 avgSystemLoad%, u8 (batteryProfile<<4|configProfile), + /// u32 armingFlags, 8 byte boxModeFlags, u8 mixerProfile. + /// (`fc_msp.c`, `case MSP2_INAV_STATUS`; ID aus + /// `msp_protocol_v2_inav.h`: `#define MSP2_INAV_STATUS 0x2000`) + static const int inavStatus = 0x2000; +} + +/// Bits von `armingFlags` (Doku: `fc/runtime_config.h`, `armingFlags_e`). +/// Nur das fuer die Telemetrie benoetigte ARMED-Bit ist hier abgebildet. +abstract final class MspArmingFlags { + static const int armed = 1 << 2; +} diff --git a/app/lib/transport/msp/msp_flight_controller_link.dart b/app/lib/transport/msp/msp_flight_controller_link.dart index 9385907..b12b46b 100644 --- a/app/lib/transport/msp/msp_flight_controller_link.dart +++ b/app/lib/transport/msp/msp_flight_controller_link.dart @@ -1,43 +1,99 @@ import '../../domain/waypoint/flat_waypoint_list.dart'; import '../flight_controller_link.dart'; import '../link_transport.dart'; +import 'msp_client.dart'; +import 'msp_commands.dart'; +import 'msp_telemetry_codec.dart'; +import 'msp_telemetry_poller.dart'; -/// iNAV-Anbindung via MSP (Architektur-Doku 3.1). Eigenstaendiger Adapter, -/// da kein fertiges Dart-Paket fuer MSP verfuegbar ist (Doku 4.19). +/// iNAV-Anbindung via MSP (Architektur-Doku 3.1), umgesetzt gegen den +/// tatsaechlichen iNAV-9.1.0-Quellcode (Doku Kommunikationsschicht Abschnitt +/// 4 - Befehlsnummern/Byte-Layouts gegen `msp_protocol.h`, +/// `msp_protocol_v2_inav.h` und `fc_msp.c` geprueft, nicht aus dem +/// Gedaechtnis uebernommen). /// /// Kennt nur die protokollneutrale [LinkTransport]-Abstraktion (Doku /// Kommunikationsschicht Abschnitt 2) - ob darunter Bluetooth Classic (MVP) /// oder spaeter WLAN/UDP steckt, ist fuer diese Klasse unsichtbar. /// -/// Protokoll-Implementierung (Framing, MSP_SET_WP, MSP_NAV_STATUS, ...) ist -/// noch offen - dies ist bewusst nur das Geruest fuer die Abstraktion. +/// 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. class MspFlightControllerLink implements FlightControllerLink { MspFlightControllerLink({required this.transport}); final LinkTransport transport; - @override - FcCapabilities get capabilities => throw UnimplementedError(); + MspClient? _client; + MspTelemetryPoller? _poller; @override - Future connect() => throw UnimplementedError(); + FcCapabilities get capabilities => const FcCapabilities( + 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. + maxWaypoints: 15, + ); @override - Future disconnect() => throw UnimplementedError(); + Future connect() async { + await transport.connect(); + final client = MspClient(transport); + _client = client; + final poller = MspTelemetryPoller(client); + _poller = poller; + poller.start(); + } + + @override + Future disconnect() async { + await _poller?.dispose(); + _poller = null; + _client?.dispose(); + _client = null; + await transport.disconnect(); + } @override Future uploadMission(FlatWaypointList mission) => - throw UnimplementedError(); + throw UnimplementedError( + 'Missions-Upload (MSP_SET_WP) ist noch nicht umgesetzt - Doku ' + 'Kommunikationsschicht 4 sieht das erst nach dem Telemetrie-Schritt vor.', + ); @override - Future setFlightMode(FlightMode mode) => throw UnimplementedError(); + Future setFlightMode(FlightMode mode) => throw UnimplementedError( + 'Flugmodus-Wechsel ist noch nicht umgesetzt.', + ); @override - Future arm() => throw UnimplementedError(); + Future arm() => throw UnimplementedError( + 'Armen ueber MSP ist noch nicht umgesetzt.', + ); @override - Stream subscribeTelemetry() => throw UnimplementedError(); + Stream subscribeTelemetry() { + final poller = _poller; + if (poller == null) { + throw StateError('subscribeTelemetry() vor connect() aufgerufen.'); + } + return poller.frames; + } @override - Future readActiveWaypointIndex() => throw UnimplementedError(); + Future readActiveWaypointIndex() async { + final client = _client; + if (client == null) { + throw StateError('readActiveWaypointIndex() vor connect() aufgerufen.'); + } + return parseMspNavStatusActiveWaypoint( + await client.request(MspCommands.navStatus), + ); + } } diff --git a/app/lib/transport/msp/msp_frame.dart b/app/lib/transport/msp/msp_frame.dart new file mode 100644 index 0000000..226e3ec --- /dev/null +++ b/app/lib/transport/msp/msp_frame.dart @@ -0,0 +1,112 @@ +import 'dart:typed_data'; + +/// MSPv2-Rahmenformat (Doku Kommunikationsschicht Abschnitt 4), geprueft +/// gegen den tatsaechlichen iNAV-9.1.0-Quellcode statt aus dem Gedaechtnis +/// (`src/main/msp/msp_serial.c`, Zustaende `MSP_HEADER_X`/`MSP_HEADER_V2_NATIVE` +/// sowie `mspSerialEncode()`): +/// +/// ``` +/// '$' 'X' +/// ``` +/// +/// `dir` ist '<' (0x3C) fuer Anfragen, '>' (0x3E) fuer Antworten, '!' (0x21) +/// fuer Fehlerantworten. Die CRC ist CRC8 DVB-S2 (Poly 0xD5, Startwert 0), +/// berechnet ueber `flags`..`payload` - NICHT ueber '$'/'X'/dir. +enum MspDirection { request, response, error } + +const int mspDollarByte = 0x24; // '$' +const int mspXByte = 0x58; // 'X' + +int? mspDirectionByte(MspDirection direction) => switch (direction) { + MspDirection.request => 0x3C, // '<' + MspDirection.response => 0x3E, // '>' + MspDirection.error => 0x21, // '!' + }; + +MspDirection? mspDirectionForByte(int byte) => switch (byte) { + 0x3C => MspDirection.request, + 0x3E => MspDirection.response, + 0x21 => MspDirection.error, + _ => null, + }; + +/// Ein vollstaendig dekodierter (und CRC-gepruefter) MSPv2-Rahmen. +class MspFrame { + const MspFrame({ + required this.direction, + required this.function, + required this.payload, + }); + + final MspDirection direction; + final int function; + final Uint8List payload; + + @override + String toString() => + 'MspFrame($direction, fn=$function, ${payload.length} bytes)'; +} + +/// CRC8 DVB-S2 (Poly 0xD5, kein Reflect, kein Final-XOR) - 1:1 aus +/// `src/main/common/crc.c` (`crc8_dvb_s2`) uebernommen. +int crc8DvbS2(int crc, int byte) { + var c = (crc ^ byte) & 0xFF; + for (var i = 0; i < 8; i++) { + c = (c & 0x80) != 0 ? ((c << 1) ^ 0xD5) & 0xFF : (c << 1) & 0xFF; + } + return c; +} + +int crc8DvbS2Update(int crc, Iterable bytes) { + var c = crc; + for (final b in bytes) { + c = crc8DvbS2(c, b); + } + return c; +} + +/// Kodiert einen vollstaendigen MSPv2-Rahmen fuer [direction]/[function] mit +/// optionalem [payload]. `flags` ist im MVP immer 0. +/// +/// Produktionscode braucht nur [encodeMspV2Request] (die App sendet +/// ausschliesslich Anfragen); der allgemeine Encoder ist zusaetzlich +/// oeffentlich, damit Tests damit Antwort-/Fehlerrahmen der Gegenstelle +/// nachbilden koennen, ohne den Rahmenaufbau ein zweites Mal zu duplizieren. +Uint8List encodeMspV2Frame( + MspDirection direction, + int function, [ + Uint8List? payload, +]) { + final body = payload ?? Uint8List(0); + if (body.length > 0xFFFF) { + throw ArgumentError.value( + body.length, + 'payload.length', + 'MSPv2-Payload darf 65535 Byte nicht ueberschreiten', + ); + } + + final header = Uint8List(5); + final headerView = ByteData.sublistView(header); + headerView.setUint8(0, 0); // flags + headerView.setUint16(1, function, Endian.little); + headerView.setUint16(3, body.length, Endian.little); + + var crc = crc8DvbS2Update(0, header); + crc = crc8DvbS2Update(crc, body); + + final frame = BytesBuilder(); + frame.addByte(mspDollarByte); + frame.addByte(mspXByte); + frame.addByte(mspDirectionByte(direction)!); + frame.add(header); + frame.add(body); + frame.addByte(crc); + return frame.toBytes(); +} + +/// Kodiert eine MSPv2-Anfrage (`dir` = '<') fuer [function] mit optionalem +/// [payload]. `flags` ist im MVP immer 0 - iNAV nutzt das Feld aktuell nicht +/// fuer Client-Anfragen. +Uint8List encodeMspV2Request(int function, [Uint8List? payload]) => + encodeMspV2Frame(MspDirection.request, function, payload); diff --git a/app/lib/transport/msp/msp_frame_decoder.dart b/app/lib/transport/msp/msp_frame_decoder.dart new file mode 100644 index 0000000..b9b2de2 --- /dev/null +++ b/app/lib/transport/msp/msp_frame_decoder.dart @@ -0,0 +1,101 @@ +import 'dart:typed_data'; + +import 'msp_frame.dart'; + +enum _State { idle, waitingForX, waitingForDirection, header, payload, checksum } + +/// Streaming-Dekodierer fuer MSPv2-Rahmen (Doku Kommunikationsschicht +/// Abschnitt 4): der Byte-Strom von der Gegenstelle kommt fragmentiert an +/// (RFCOMM liefert kein "ein Paket pro Lesevorgang"), also ein +/// Zustandsautomat statt eines naiven Parsers - ein Byte pro Aufruf, egal +/// wie [addBytes] geschnitten wird. Zustaende gespiegelt aus iNAVs +/// `msp_serial.c` (`mspState_e`), reduziert auf den MSPv2-Native-Pfad, da +/// die Gegenstelle (mLRS-Backpack) ausschliesslich MSPv2 spricht - iNAV +/// benoetigt MSPv2, MSPv1 reicht laut Doku nicht. +/// +/// Fehlerhafte Rahmen (CRC-Mismatch, unbekanntes Richtungszeichen, zu grosse +/// gemeldete Payload-Groesse) werden stillschweigend verworfen und der +/// Automat kehrt in den Idle-Zustand zurueck, ohne den weiteren Strom zu +/// verlieren (Abnahmekriterium: "Fehlerhafte Rahmen werden verworfen, ohne +/// den Strom zu verlieren"). +class MspFrameDecoder { + /// Schutz vor unbegrenztem Speicherwachstum, falls eine als Muell + /// interpretierte Payload-Groesse absurd gross waere - kein reales + /// MSP-Kommando braucht mehr. + static const int maxPayloadSize = 4096; + + _State _state = _State.idle; + MspDirection _direction = MspDirection.response; + final List _headerBytes = []; + int _function = 0; + int _payloadSize = 0; + final List _payloadBytes = []; + int _crc = 0; + + /// Verarbeitet [chunk] und liefert alle Rahmen, die dadurch vollstaendig + /// wurden (0, 1 oder mehrere, je nachdem wie der Strom geschnitten ist). + List addBytes(Uint8List chunk) { + final frames = []; + for (final byte in chunk) { + final frame = _consume(byte); + if (frame != null) frames.add(frame); + } + return frames; + } + + MspFrame? _consume(int byte) { + switch (_state) { + case _State.idle: + if (byte == mspDollarByte) _state = _State.waitingForX; + return null; + + case _State.waitingForX: + _state = byte == mspXByte ? _State.waitingForDirection : _State.idle; + return null; + + case _State.waitingForDirection: + final direction = mspDirectionForByte(byte); + if (direction == null) { + _state = _State.idle; + return null; + } + _direction = direction; + _headerBytes.clear(); + _crc = 0; + _state = _State.header; + return null; + + case _State.header: + _headerBytes.add(byte); + _crc = crc8DvbS2(_crc, byte); + if (_headerBytes.length == 5) { + _function = _headerBytes[1] | (_headerBytes[2] << 8); + _payloadSize = _headerBytes[3] | (_headerBytes[4] << 8); + if (_payloadSize > maxPayloadSize) { + _state = _State.idle; + return null; + } + _payloadBytes.clear(); + _state = _payloadSize == 0 ? _State.checksum : _State.payload; + } + return null; + + case _State.payload: + _payloadBytes.add(byte); + _crc = crc8DvbS2(_crc, byte); + if (_payloadBytes.length == _payloadSize) { + _state = _State.checksum; + } + return null; + + case _State.checksum: + _state = _State.idle; + if (byte != _crc) return null; + return MspFrame( + direction: _direction, + function: _function, + payload: Uint8List.fromList(_payloadBytes), + ); + } + } +} diff --git a/app/lib/transport/msp/msp_telemetry_codec.dart b/app/lib/transport/msp/msp_telemetry_codec.dart new file mode 100644 index 0000000..3632a1e --- /dev/null +++ b/app/lib/transport/msp/msp_telemetry_codec.dart @@ -0,0 +1,60 @@ +import 'dart:typed_data'; + +import 'msp_commands.dart'; + +/// GPS-Rohwerte aus `MSP_RAW_GPS` (Doku Kommunikationsschicht Abschnitt 4). +class MspGpsReading { + const MspGpsReading({ + required this.fixType, + required this.numSat, + required this.lat, + required this.lon, + required this.speedMs, + }); + + /// 0 = kein Fix (`fc_msp.c`: `gpsSol.fixType`). + final int fixType; + final int numSat; + final double lat; + final double lon; + final double speedMs; + + bool get hasFix => fixType != 0; +} + +/// Parst die `MSP_RAW_GPS`-Antwort (Byte-Layout siehe [MspCommands.rawGps]). +MspGpsReading parseMspRawGps(Uint8List payload) { + final data = ByteData.sublistView(payload); + return MspGpsReading( + fixType: data.getUint8(0), + numSat: data.getUint8(1), + lat: data.getInt32(2, Endian.little) / 1e7, + lon: data.getInt32(6, Endian.little) / 1e7, + speedMs: data.getUint16(12, Endian.little) / 100.0, + ); +} + +/// Parst die `MSP_ALTITUDE`-Antwort und liefert die geschaetzte Hoehe in +/// Metern (siehe [MspCommands.altitude]). +double parseMspAltitudeMeters(Uint8List payload) { + final data = ByteData.sublistView(payload); + return data.getInt32(0, Endian.little) / 100.0; +} + +/// Parst die `MSP2_INAV_STATUS`-Antwort und liefert, ob der ARMED-Bit in +/// `armingFlags` gesetzt ist (siehe [MspCommands.inavStatus]). +bool parseMspInavStatusArmed(Uint8List payload) { + final data = ByteData.sublistView(payload); + // Offset 9 = cycleTime(2) + i2cErrors(2) + sensorStatus(2) + avgLoad(2) + // + Profil-Byte(1), siehe fc_msp.c/MSP2_INAV_STATUS. + final armingFlags = data.getUint32(9, Endian.little); + return (armingFlags & MspArmingFlags.armed) != 0; +} + +/// Parst die `MSP_NAV_STATUS`-Antwort und liefert den aktiven +/// Wegpunktindex, oder null, wenn keine Mission aktiv ist (`activeWpNumber +/// == 0`, siehe [MspCommands.navStatus]). +int? parseMspNavStatusActiveWaypoint(Uint8List payload) { + final activeWpNumber = payload[3]; + return activeWpNumber == 0 ? null : activeWpNumber; +} diff --git a/app/lib/transport/msp/msp_telemetry_poller.dart b/app/lib/transport/msp/msp_telemetry_poller.dart new file mode 100644 index 0000000..6c26e64 --- /dev/null +++ b/app/lib/transport/msp/msp_telemetry_poller.dart @@ -0,0 +1,103 @@ +import 'dart:async'; + +import '../flight_controller_link.dart'; +import 'msp_client.dart'; +import 'msp_commands.dart'; +import 'msp_telemetry_codec.dart'; + +/// Eigener Poll-Takt ueber [MspClient] (Doku Kommunikationsschicht Abschnitt +/// 4, "Polling"): MSP liefert nichts von selbst. Rundenbasiert statt +/// paralleler Timer pro Nachrichtengruppe, weil [MspClient] Anfragen +/// ohnehin serialisiert (immer nur eine offene Anfrage) - ein +/// Round-Robin-Zyklus bildet die in der Doku genannten Raten ab, ohne dass +/// sich Anfragen ueberlappen koennten: +/// +/// - Position/Lage/Hoehe (5-10 Hz): jeden Zyklus (`MSP_RAW_GPS` + +/// `MSP_ALTITUDE`). +/// - Status/Flugmodus (2 Hz): jeden [_statusEveryNCycles]-ten Zyklus +/// (`MSP2_INAV_STATUS` + `MSP_NAV_STATUS`). +/// +/// Spannung/Strom (1 Hz laut Doku) fehlt hier bewusst noch - [TelemetryFrame] +/// hat aktuell keine Felder dafuer; sobald die UI das braucht, ist ein +/// dritter, noch selteners abgefragter Zweig (`MSP2_INAV_ANALOG`) trivial +/// ergaenzt. +class MspTelemetryPoller { + MspTelemetryPoller(this._client); + + final MspClient _client; + final _framesController = StreamController.broadcast(); + + static const _cycleInterval = Duration(milliseconds: 150); // ~6-7 Hz + static const _statusEveryNCycles = 3; // ~2 Hz bei 150-ms-Takt + + bool _running = false; + int _cycle = 0; + bool _armed = false; + int? _activeWaypointIndex; + + Stream get frames => _framesController.stream; + + void start() { + if (_running) return; + _running = true; + unawaited(_loop()); + } + + void stop() { + _running = false; + } + + Future _loop() async { + while (_running) { + try { + await _runCycle(); + } catch (_) { + // Eine einzelne fehlgeschlagene Anfrage (Timeout/CRC/Transport + // getrennt) darf den Takt nicht stoppen - der naechste Zyklus + // startet nach der Wartezeit regulaer weiter (Doku 4: Zeitgrenze + + // Wiederholung; Wiederverbinden passiert bereits auf Transport- + // Ebene, siehe BluetoothClassicTransport). + } + await Future.delayed(_cycleInterval); + } + } + + Future _runCycle() async { + final gps = parseMspRawGps(await _client.request(MspCommands.rawGps)); + final altitudeM = + parseMspAltitudeMeters(await _client.request(MspCommands.altitude)); + + _cycle++; + if (_cycle % _statusEveryNCycles == 0) { + _armed = parseMspInavStatusArmed( + await _client.request(MspCommands.inavStatus), + ); + _activeWaypointIndex = parseMspNavStatusActiveWaypoint( + await _client.request(MspCommands.navStatus), + ); + } + + // Sicherheitsrelevante Anzeigen nie mit Platzhaltern fuellen (Doku 9): + // ohne GPS-Fix gibt es keine sinnvolle Position - dann lieber gar kein + // Telemetrie-Frame senden, statt (0,0) oder einen eingefrorenen alten + // Wert zu zeigen. Die bestehende Fly-Anzeige zeigt ohne Frame ohnehin + // keinen Drohnen-Marker (siehe telemetryProvider/FlyScreen). + if (!gps.hasFix) return; + + if (!_framesController.isClosed) { + _framesController.add(TelemetryFrame( + lat: gps.lat, + lon: gps.lon, + altitudeM: altitudeM, + speedMs: gps.speedMs, + armed: _armed, + activeWaypointIndex: _activeWaypointIndex, + )); + } + } + + Future dispose() async { + stop(); + await _framesController.close(); + } +} diff --git a/app/lib/ui/screens/fly/fly_screen.dart b/app/lib/ui/screens/fly/fly_screen.dart index 6c9d753..5d0644a 100644 --- a/app/lib/ui/screens/fly/fly_screen.dart +++ b/app/lib/ui/screens/fly/fly_screen.dart @@ -12,6 +12,7 @@ import '../../providers/current_mission_provider.dart'; import '../../providers/map_controller_provider.dart'; import '../../providers/telemetry_provider.dart'; import '../../providers/terrain_provider.dart'; +import '../settings/settings_screen.dart'; import '../../widgets/drone_marker_icon.dart'; import '../../widgets/mission_footer_bar.dart'; import '../../widgets/mission_map.dart'; @@ -165,6 +166,9 @@ class FlyScreen extends ConsumerWidget { warnings: warnings, onDetailsTap: () => _openWaypointList(context, ref), onWarningsTap: () => _openWarningsPanel(context, ref, warnings), + onSettingsTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (_) => const SettingsScreen(), + )), ), ), ], diff --git a/app/lib/ui/screens/plan/plan_screen.dart b/app/lib/ui/screens/plan/plan_screen.dart index 8aa456c..c75ff5f 100644 --- a/app/lib/ui/screens/plan/plan_screen.dart +++ b/app/lib/ui/screens/plan/plan_screen.dart @@ -18,6 +18,7 @@ import '../../providers/map_controller_provider.dart'; import '../../providers/mission_meta_provider.dart'; import '../../providers/terrain_provider.dart'; import '../../providers/wind_provider.dart'; +import '../settings/settings_screen.dart'; import '../../widgets/halo_menu.dart'; import '../../widgets/mission_footer_bar.dart'; import '../../widgets/mission_map.dart'; @@ -508,6 +509,9 @@ class _PlanScreenState extends ConsumerState { warnings: warnings, onDetailsTap: _openWaypointList, onWarningsTap: () => _openWarningsPanel(warnings), + onSettingsTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (_) => const SettingsScreen(), + )), ), ), ], diff --git a/app/lib/ui/widgets/bottom_stats_bar.dart b/app/lib/ui/widgets/bottom_stats_bar.dart index 15cb474..a1ad393 100644 --- a/app/lib/ui/widgets/bottom_stats_bar.dart +++ b/app/lib/ui/widgets/bottom_stats_bar.dart @@ -19,6 +19,7 @@ class BottomStatsBar extends ConsumerWidget { required this.totalDistanceM, required this.totalDurationSec, required this.onTap, + required this.onSettingsTap, this.warnings = const [], this.onWarningsTap, }); @@ -28,6 +29,10 @@ class BottomStatsBar extends ConsumerWidget { final double totalDurationSec; final VoidCallback onTap; + /// Oeffnet das Settings-Menue (runder Zahnrad-Knopf rechts der + /// Details-Pille). + final VoidCallback onSettingsTap; + /// Aktive Warnungen zur Mission (Doku 3.10) - der Button erscheint nur, /// solange diese Liste nicht leer ist (HTML-Demonstrator: #warningsBtn, /// per default versteckt). @@ -62,6 +67,7 @@ class BottomStatsBar extends ConsumerWidget { label: 'Mission: $missionName', color: Colors.white.withValues(alpha: 0.12), textColor: Colors.white, + maxWidth: 160, onTap: () => Navigator.of(context).push(MaterialPageRoute( builder: (_) => const MissionsDronesPanel.missions(), )), @@ -73,21 +79,32 @@ class BottomStatsBar extends ConsumerWidget { label: 'Drone: $droneName', color: const Color(0xFF4D9DFF).withValues(alpha: 0.18), textColor: const Color(0xFFCFE2FF), + maxWidth: 140, onTap: () => Navigator.of(context).push(MaterialPageRoute( builder: (_) => const MissionsDronesPanel.drones(), )), ), ), - if (warnings.isNotEmpty) ...[ - const SizedBox(width: 6), - _WarningButton(onTap: onWarningsTap), - ], ], ), ), ), _detailsPill(), - const Expanded(child: SizedBox.shrink()), + Expanded( + child: Align( + alignment: Alignment.centerRight, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (warnings.isNotEmpty) ...[ + _WarningButton(onTap: onWarningsTap), + const SizedBox(width: 6), + ], + _SettingsButton(onTap: onSettingsTap), + ], + ), + ), + ), ], ), ), @@ -168,25 +185,51 @@ class _WarningButton extends StatelessWidget { } } -class _NameChip extends StatelessWidget { - const _NameChip({ - required this.label, - required this.color, - required this.textColor, - required this.onTap, - }); +/// Runder Zahnrad-Knopf rechts der Details-Pille - oeffnet das +/// Settings-Menue (SettingsScreen). +class _SettingsButton extends StatelessWidget { + const _SettingsButton({required this.onTap}); - final String label; - final Color color; - final Color textColor; final VoidCallback onTap; + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.12), + ), + child: const Icon(Icons.settings, color: Colors.white, size: 18), + ), + ); + } +} + +class _NameChip extends StatelessWidget { + const _NameChip({ + required this.label, + required this.color, + required this.textColor, + required this.onTap, + required this.maxWidth, + }); + + final String label; + final Color color; + final Color textColor; + final VoidCallback onTap; + final double maxWidth; + @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 130), + constraints: BoxConstraints(maxWidth: maxWidth), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), decoration: BoxDecoration( diff --git a/app/lib/ui/widgets/mission_footer_bar.dart b/app/lib/ui/widgets/mission_footer_bar.dart index 85c6d44..6fab836 100644 --- a/app/lib/ui/widgets/mission_footer_bar.dart +++ b/app/lib/ui/widgets/mission_footer_bar.dart @@ -21,6 +21,7 @@ class MissionFooterBar extends StatelessWidget { required this.warnings, required this.onDetailsTap, required this.onWarningsTap, + required this.onSettingsTap, }); final List waypoints; @@ -32,6 +33,7 @@ class MissionFooterBar extends StatelessWidget { final List warnings; final VoidCallback onDetailsTap; final VoidCallback onWarningsTap; + final VoidCallback onSettingsTap; @override Widget build(BuildContext context) { @@ -71,6 +73,7 @@ class MissionFooterBar extends StatelessWidget { onTap: onDetailsTap, warnings: warnings, onWarningsTap: onWarningsTap, + onSettingsTap: onSettingsTap, ), ], ), diff --git a/app/test/transport/msp/msp_client_test.dart b/app/test/transport/msp/msp_client_test.dart new file mode 100644 index 0000000..81c8bef --- /dev/null +++ b/app/test/transport/msp/msp_client_test.dart @@ -0,0 +1,118 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:dmc_app/transport/loopback/loopback_transport.dart'; +import 'package:dmc_app/transport/msp/msp_client.dart'; +import 'package:dmc_app/transport/msp/msp_commands.dart'; +import 'package:dmc_app/transport/msp/msp_frame.dart'; +import 'package:dmc_app/transport/msp/msp_frame_decoder.dart'; + +MspFrame _decodeSingle(Uint8List bytes) => + MspFrameDecoder().addBytes(bytes).single; + +void main() { + late LoopbackTransport transport; + + setUp(() async { + transport = LoopbackTransport(); + await transport.connect(); + }); + + tearDown(() { + transport.dispose(); + }); + + test('request() sendet einen MSPv2-Rahmen und liefert den Antwort-Payload', + () async { + final client = MspClient(transport); + final future = client.request(MspCommands.altitude); + + await Future.delayed(Duration.zero); + expect(transport.sentData, hasLength(1)); + final sentFrame = _decodeSingle(transport.sentData.single); + expect(sentFrame.direction, MspDirection.request); + expect(sentFrame.function, MspCommands.altitude); + + final payload = Uint8List.fromList([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + transport.feed( + encodeMspV2Frame(MspDirection.response, MspCommands.altitude, payload), + ); + + expect(await future, payload); + client.dispose(); + }); + + test('wirft nach Timeout + Wiederholungen MspRequestException und sendet ' + 'jedes Mal erneut', () async { + final client = MspClient( + transport, + requestTimeout: const Duration(milliseconds: 30), + maxRetries: 2, + ); + + await expectLater( + client.request(MspCommands.altitude), + throwsA(isA()), + ); + + // Erstversuch + 2 Wiederholungen = 3 gesendete Anfragen. + expect(transport.sentData, hasLength(3)); + for (final sent in transport.sentData) { + expect(_decodeSingle(sent).function, MspCommands.altitude); + } + client.dispose(); + }); + + test('Fehlerantwort (dir = \'!\') wirft sofort ohne weiteren Versuch', + () async { + final client = MspClient( + transport, + requestTimeout: const Duration(seconds: 5), + maxRetries: 2, + ); + + final future = client.request(MspCommands.altitude); + await Future.delayed(Duration.zero); + transport.feed( + encodeMspV2Frame(MspDirection.error, MspCommands.altitude, Uint8List(0)), + ); + + await expectLater(future, throwsA(isA())); + expect(transport.sentData, hasLength(1)); + client.dispose(); + }); + + test('serialisiert ueberlappende Anfragen - immer nur eine offen', () async { + final client = MspClient(transport); + + final future1 = client.request(MspCommands.rawGps); + final future2 = client.request(MspCommands.altitude); + + await Future.delayed(Duration.zero); + expect(transport.sentData, hasLength(1)); + expect(_decodeSingle(transport.sentData.single).function, + MspCommands.rawGps); + + transport.feed(encodeMspV2Frame( + MspDirection.response, + MspCommands.rawGps, + Uint8List(18), + )); + await future1; + await Future.delayed(Duration.zero); + + expect(transport.sentData, hasLength(2)); + expect(_decodeSingle(transport.sentData[1]).function, + MspCommands.altitude); + + transport.feed(encodeMspV2Frame( + MspDirection.response, + MspCommands.altitude, + Uint8List(10), + )); + await future2; + + client.dispose(); + }); +} diff --git a/app/test/transport/msp/msp_flight_controller_link_test.dart b/app/test/transport/msp/msp_flight_controller_link_test.dart new file mode 100644 index 0000000..7c0ffc9 --- /dev/null +++ b/app/test/transport/msp/msp_flight_controller_link_test.dart @@ -0,0 +1,96 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.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) { + var answered = 0; + return Timer.periodic(const Duration(milliseconds: 5), (_) { + while (answered < transport.sentData.length) { + final request = + MspFrameDecoder().addBytes(transport.sentData[answered]).single; + answered++; + + final Uint8List payload; + switch (request.function) { + case MspCommands.rawGps: + final d = ByteData(18); + d.setUint8(0, 3); // fixType: 3D-Fix + d.setUint8(1, 11); + 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 + payload = d.buffer.asUint8List(); + case MspCommands.altitude: + final d = ByteData(10); + d.setInt32(0, 5000, Endian.little); // 50.00 m + payload = d.buffer.asUint8List(); + case MspCommands.inavStatus: + final d = ByteData(22); + d.setUint32(9, 1 << 2, Endian.little); // ARMED + payload = d.buffer.asUint8List(); + case MspCommands.navStatus: + payload = Uint8List.fromList([0, 0, 0, 3, 0, 0, 0]); // WP #3 aktiv + default: + payload = Uint8List(0); + } + + transport.feed( + encodeMspV2Frame(MspDirection.response, request.function, payload), + ); + } + }); +} + +void main() { + test( + 'connect() + subscribeTelemetry() liefern echte, ueber MSP dekodierte ' + 'Telemetrie', + () async { + final transport = LoopbackTransport(); + final responder = _startFakeFlightController(transport); + final link = MspFlightControllerLink(transport: transport); + + await link.connect(); + final frame = await link.subscribeTelemetry().first; + + expect(frame.lat, closeTo(52.52, 1e-6)); + expect(frame.lon, closeTo(13.405, 1e-6)); + expect(frame.altitudeM, closeTo(50.0, 1e-6)); + expect(frame.speedMs, closeTo(5.0, 1e-6)); + + await link.disconnect(); + responder.cancel(); + }, + timeout: const Timeout(Duration(seconds: 5)), + ); + + test('readActiveWaypointIndex() fragt MSP_NAV_STATUS direkt ab', () async { + final transport = LoopbackTransport(); + final responder = _startFakeFlightController(transport); + final link = MspFlightControllerLink(transport: transport); + + await link.connect(); + // Der Poller laeuft bereits mit, aber readActiveWaypointIndex() fragt + // unabhaengig davon direkt ab. + final index = await link.readActiveWaypointIndex(); + expect(index, 3); + + await link.disconnect(); + responder.cancel(); + }, timeout: const Timeout(Duration(seconds: 5))); + + test('subscribeTelemetry() vor connect() wirft StateError', () { + final link = MspFlightControllerLink(transport: LoopbackTransport()); + expect(() => link.subscribeTelemetry(), throwsStateError); + }); +} diff --git a/app/test/transport/msp/msp_frame_decoder_test.dart b/app/test/transport/msp/msp_frame_decoder_test.dart new file mode 100644 index 0000000..c47d985 --- /dev/null +++ b/app/test/transport/msp/msp_frame_decoder_test.dart @@ -0,0 +1,91 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:dmc_app/transport/msp/msp_frame.dart'; +import 'package:dmc_app/transport/msp/msp_frame_decoder.dart'; + +/// Fuettert [bytes] in Stuecken der Groesse [chunkSize] und sammelt alle +/// dekodierten Rahmen ueber den gesamten Strom. +List _decodeInChunks(Uint8List bytes, int chunkSize) { + final decoder = MspFrameDecoder(); + final frames = []; + for (var offset = 0; offset < bytes.length; offset += chunkSize) { + final end = (offset + chunkSize).clamp(0, bytes.length); + frames.addAll(decoder.addBytes(bytes.sublist(offset, end))); + } + return frames; +} + +void main() { + final altitudePayload = Uint8List.fromList( + [0xD2, 0x04, 0x00, 0x00, 0xCE, 0xFF, 0xB0, 0x04, 0x00, 0x00], + ); + final altitudeFrameBytes = + encodeMspV2Frame(MspDirection.response, 109, altitudePayload); + + // Abnahmekriterium (Doku Kommunikationsschicht 8): dieselbe Bytefolge in + // Stuecken von 1, 7 und 512 Byte muss dasselbe Ergebnis liefern. + for (final chunkSize in [1, 7, 512]) { + test('dekodiert einen MSP_ALTITUDE-Rahmen bei Chunk-Groesse $chunkSize', () { + final frames = _decodeInChunks(altitudeFrameBytes, chunkSize); + + expect(frames, hasLength(1)); + expect(frames.single.direction, MspDirection.response); + expect(frames.single.function, 109); + expect(frames.single.payload, altitudePayload); + }); + } + + test('mehrere Rahmen hintereinander in einem Chunk werden alle erkannt', () { + final gpsFrameBytes = encodeMspV2Frame(MspDirection.response, 106, + Uint8List.fromList([1, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])); + + final combined = Uint8List.fromList([ + ...altitudeFrameBytes, + ...gpsFrameBytes, + ]); + + final frames = _decodeInChunks(combined, 512); + + expect(frames, hasLength(2)); + expect(frames[0].function, 109); + expect(frames[1].function, 106); + }); + + test('fehlerhafte CRC wird verworfen, ohne den Strom zu verlieren', () { + final corrupted = Uint8List.fromList(altitudeFrameBytes); + corrupted[corrupted.length - 1] ^= 0xFF; // letztes Byte (CRC) kippen + + final decoder = MspFrameDecoder(); + final framesFromCorrupted = decoder.addBytes(corrupted); + expect(framesFromCorrupted, isEmpty); + + // Direkt danach ein gueltiger Rahmen auf demselben Decoder - der + // Automat muss wieder im Idle-Zustand sein und normal weiterlesen. + final framesFromValid = decoder.addBytes(altitudeFrameBytes); + expect(framesFromValid, hasLength(1)); + expect(framesFromValid.single.function, 109); + }); + + test('Fehlerantwort (dir = \'!\') wird als solche erkannt', () { + final errorFrameBytes = + encodeMspV2Frame(MspDirection.error, 109, Uint8List(0)); + + final frames = MspFrameDecoder().addBytes(errorFrameBytes); + + expect(frames, hasLength(1)); + expect(frames.single.direction, MspDirection.error); + expect(frames.single.function, 109); + }); + + test('Muell vor dem eigentlichen Rahmen wird ignoriert', () { + final garbage = Uint8List.fromList([0x00, 0x24, 0x11, 0x58, 0xFF]); + final combined = Uint8List.fromList([...garbage, ...altitudeFrameBytes]); + + final frames = _decodeInChunks(combined, 7); + + expect(frames, hasLength(1)); + expect(frames.single.function, 109); + }); +} diff --git a/app/test/transport/msp/msp_frame_test.dart b/app/test/transport/msp/msp_frame_test.dart new file mode 100644 index 0000000..17e13a7 --- /dev/null +++ b/app/test/transport/msp/msp_frame_test.dart @@ -0,0 +1,68 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:dmc_app/transport/msp/msp_frame.dart'; + +/// Erwartete Bytefolgen unabhaengig (in Python, CRC8-DVB-S2 mit Poly 0xD5, +/// Startwert 0) nachgerechnet - siehe Kommentare. +void main() { + group('crc8DvbS2', () { + test('leerer Header (flags=0, function=106, size=0) ergibt 0x93', () { + final header = Uint8List.fromList([0x00, 0x6A, 0x00, 0x00, 0x00]); + expect(crc8DvbS2Update(0, header), 0x93); + }); + }); + + group('encodeMspV2Request', () { + test('MSP_RAW_GPS (106) ohne Payload', () { + final bytes = encodeMspV2Request(106); + expect(bytes, Uint8List.fromList([ + 0x24, 0x58, 0x3C, // '$', 'X', '<' + 0x00, 0x6A, 0x00, 0x00, 0x00, // flags, fn LE, size LE + 0x93, // crc + ])); + }); + + test('MSP2_INAV_STATUS (0x2000) ohne Payload', () { + final bytes = encodeMspV2Request(0x2000); + expect(bytes, Uint8List.fromList([ + 0x24, 0x58, 0x3C, + 0x00, 0x00, 0x20, 0x00, 0x00, + 0x32, + ])); + }); + + test('Anfrage mit Payload', () { + final bytes = encodeMspV2Request( + 1, + Uint8List.fromList([0xAA, 0xBB, 0xCC]), + ); + expect(bytes, Uint8List.fromList([ + 0x24, 0x58, 0x3C, + 0x00, 0x01, 0x00, 0x03, 0x00, + 0xAA, 0xBB, 0xCC, + 0x1A, + ])); + }); + }); + + group('encodeMspV2Frame', () { + test('Antwortrahmen (dir = \'>\') fuer MSP_ALTITUDE', () { + // estAlt=1234cm (i32 LE), vario=-50cm/s (i16 LE), baroAlt=1200cm (i32 LE) + final payload = Uint8List.fromList( + [0xD2, 0x04, 0x00, 0x00, 0xCE, 0xFF, 0xB0, 0x04, 0x00, 0x00], + ); + final bytes = encodeMspV2Frame(MspDirection.response, 109, payload); + expect( + bytes, + Uint8List.fromList([ + 0x24, 0x58, 0x3E, + 0x00, 0x6D, 0x00, 0x0A, 0x00, + ...payload, + 0x73, + ]), + ); + }); + }); +} diff --git a/app/test/transport/msp/msp_telemetry_codec_test.dart b/app/test/transport/msp/msp_telemetry_codec_test.dart new file mode 100644 index 0000000..605bc36 --- /dev/null +++ b/app/test/transport/msp/msp_telemetry_codec_test.dart @@ -0,0 +1,96 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:dmc_app/transport/msp/msp_telemetry_codec.dart'; + +void main() { + group('parseMspRawGps', () { + test('parst fixType, numSat, lat/lon (1e-7 deg) und Speed (cm/s)', () { + // fixType=2, numSat=9, lat=52.5200000 deg, lon=13.4050000 deg, + // alt=100m (ungenutzt), groundSpeed=1234 cm/s, groundCourse=900 (0.1deg, + // ungenutzt), hdop=120 (ungenutzt). + final data = ByteData(18); + data.setUint8(0, 2); + data.setUint8(1, 9); + data.setInt32(2, 525200000, Endian.little); + data.setInt32(6, 134050000, Endian.little); + data.setUint16(10, 100, Endian.little); + data.setUint16(12, 1234, Endian.little); + data.setUint16(14, 900, Endian.little); + data.setUint16(16, 120, Endian.little); + + final reading = parseMspRawGps(data.buffer.asUint8List()); + + expect(reading.fixType, 2); + expect(reading.hasFix, isTrue); + expect(reading.numSat, 9); + expect(reading.lat, closeTo(52.52, 1e-9)); + expect(reading.lon, closeTo(13.405, 1e-9)); + expect(reading.speedMs, closeTo(12.34, 1e-9)); + }); + + test('fixType 0 bedeutet kein Fix', () { + final data = ByteData(18); + final reading = parseMspRawGps(data.buffer.asUint8List()); + expect(reading.hasFix, isFalse); + }); + }); + + group('parseMspAltitudeMeters', () { + test('estAlt in cm wird zu Metern (auch negativ moeglich)', () { + final data = ByteData(10); + data.setInt32(0, 1234, Endian.little); // 12.34 m + data.setInt16(4, -50, Endian.little); + data.setInt32(6, 1200, Endian.little); + + expect( + parseMspAltitudeMeters(data.buffer.asUint8List()), + closeTo(12.34, 1e-9), + ); + }); + + test('negative Hoehe (unter Startpunkt)', () { + final data = ByteData(10); + data.setInt32(0, -250, Endian.little); // -2.5 m + expect( + parseMspAltitudeMeters(data.buffer.asUint8List()), + closeTo(-2.5, 1e-9), + ); + }); + }); + + group('parseMspInavStatusArmed', () { + Uint8List statusPayload(int armingFlags) { + final data = ByteData(22); + data.setUint32(9, armingFlags, Endian.little); + return data.buffer.asUint8List(); + } + + test('ARMED-Bit (1<<2) gesetzt', () { + expect(parseMspInavStatusArmed(statusPayload(1 << 2)), isTrue); + }); + + test('nur andere Bits gesetzt, ARMED-Bit fehlt', () { + expect(parseMspInavStatusArmed(statusPayload(1 << 3)), isFalse); + }); + + test('keine Flags gesetzt', () { + expect(parseMspInavStatusArmed(statusPayload(0)), isFalse); + }); + }); + + group('parseMspNavStatusActiveWaypoint', () { + Uint8List navStatusPayload(int activeWpNumber) { + return Uint8List.fromList([0, 0, 0, activeWpNumber, 0, 0, 0]); + } + + test('activeWpNumber 0 bedeutet keine aktive Mission', () { + expect(parseMspNavStatusActiveWaypoint(navStatusPayload(0)), isNull); + }); + + test('activeWpNumber > 0 wird durchgereicht', () { + expect(parseMspNavStatusActiveWaypoint(navStatusPayload(5)), 5); + }); + }); +}