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
+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);
});
});
}