MSP protocol implementation and settings menu
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user