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