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