MSP protocol implementation and settings menu

This commit is contained in:
Constantin Leue
2026-07-30 21:18:48 +02:00
parent 5994aee7ad
commit 078d58dbc0
16 changed files with 1132 additions and 29 deletions
+111
View File
@@ -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<Uint8List>? _incomingSub;
// Einfache Mutex-Kette (siehe unten in request()): serialisiert
// ueberlappende Aufrufe, ohne eine explizite Warteschlangen-Datenstruktur
// zu brauchen.
Future<void> _lock = Future.value();
Completer<MspFrame>? _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<Uint8List> request(int function, {Uint8List? payload}) async {
final previous = _lock;
final release = Completer<void>();
_lock = release.future;
await previous;
try {
return await _requestOnce(function, payload: payload);
} finally {
release.complete();
}
}
Future<Uint8List> _requestOnce(int function, {Uint8List? payload}) async {
Object? lastError;
for (var attempt = 0; attempt <= maxRetries; attempt++) {
final completer = Completer<MspFrame>();
_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();
}
}
+37
View File
@@ -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;
}
@@ -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<void> 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<void> disconnect() => throw UnimplementedError();
Future<void> connect() async {
await transport.connect();
final client = MspClient(transport);
_client = client;
final poller = MspTelemetryPoller(client);
_poller = poller;
poller.start();
}
@override
Future<void> disconnect() async {
await _poller?.dispose();
_poller = null;
_client?.dispose();
_client = null;
await transport.disconnect();
}
@override
Future<void> 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<void> setFlightMode(FlightMode mode) => throw UnimplementedError();
Future<void> setFlightMode(FlightMode mode) => throw UnimplementedError(
'Flugmodus-Wechsel ist noch nicht umgesetzt.',
);
@override
Future<void> arm() => throw UnimplementedError();
Future<void> arm() => throw UnimplementedError(
'Armen ueber MSP ist noch nicht umgesetzt.',
);
@override
Stream<TelemetryFrame> subscribeTelemetry() => throw UnimplementedError();
Stream<TelemetryFrame> subscribeTelemetry() {
final poller = _poller;
if (poller == null) {
throw StateError('subscribeTelemetry() vor connect() aufgerufen.');
}
return poller.frames;
}
@override
Future<int?> readActiveWaypointIndex() => throw UnimplementedError();
Future<int?> readActiveWaypointIndex() async {
final client = _client;
if (client == null) {
throw StateError('readActiveWaypointIndex() vor connect() aufgerufen.');
}
return parseMspNavStatusActiveWaypoint(
await client.request(MspCommands.navStatus),
);
}
}
+112
View File
@@ -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:1> <flags:1> <function:2 LE> <payloadSize:2 LE> <payload> <crc:1>
/// ```
///
/// `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<int> 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);
@@ -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<int> _headerBytes = [];
int _function = 0;
int _payloadSize = 0;
final List<int> _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<MspFrame> addBytes(Uint8List chunk) {
final frames = <MspFrame>[];
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),
);
}
}
}
@@ -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;
}
@@ -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<TelemetryFrame>.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<TelemetryFrame> get frames => _framesController.stream;
void start() {
if (_running) return;
_running = true;
unawaited(_loop());
}
void stop() {
_running = false;
}
Future<void> _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<void>.delayed(_cycleInterval);
}
}
Future<void> _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<void> dispose() async {
stop();
await _framesController.close();
}
}
+4
View File
@@ -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(),
)),
),
),
],
+4
View File
@@ -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<PlanScreen> {
warnings: warnings,
onDetailsTap: _openWaypointList,
onWarningsTap: () => _openWarningsPanel(warnings),
onSettingsTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const SettingsScreen(),
)),
),
),
],
+59 -16
View File
@@ -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(
@@ -21,6 +21,7 @@ class MissionFooterBar extends StatelessWidget {
required this.warnings,
required this.onDetailsTap,
required this.onWarningsTap,
required this.onSettingsTap,
});
final List<Waypoint> waypoints;
@@ -32,6 +33,7 @@ class MissionFooterBar extends StatelessWidget {
final List<MissionWarning> 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,
),
],
),
+118
View File
@@ -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<void>.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<MspRequestException>()),
);
// 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<void>.delayed(Duration.zero);
transport.feed(
encodeMspV2Frame(MspDirection.error, MspCommands.altitude, Uint8List(0)),
);
await expectLater(future, throwsA(isA<MspRequestException>()));
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<void>.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<void>.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();
});
}
@@ -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);
});
}
@@ -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<MspFrame> _decodeInChunks(Uint8List bytes, int chunkSize) {
final decoder = MspFrameDecoder();
final frames = <MspFrame>[];
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);
});
}
@@ -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,
]),
);
});
});
}
@@ -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);
});
});
}