Files
dmc/app/lib/transport/msp/msp_flight_controller_link.dart
Constantin Leue 5fd5d0117a Show the drone's locked home point on the fly-mode map
Adds MSP_WP (118) support to query the flightcontroller's stored home
point (WP#0 is a special case for this in iNAV's getWaypoint(), per
navigation.c) - the FC reports (0,0,0) rather than an error before one
is set, so parseMspHomePoint() treats that pair as "unset". Rendered
with a small pentagon house icon (design/homepoint-pentagon.svg,
ported to a CustomPainter like the other map icons).

Deliberately not polled continuously: a new home_point_provider.dart
refreshes it only at the three moments the FC's home point can
actually change - connect/reconnect, GPS fix acquired, and arming
(iNAV's default reset_home_type=FIRST_ARM only freezes it at the first
arm; before that it continuously follows the aircraft while disarmed).

Mock's implementation offsets the point 25m from the anchor so it
doesn't sit exactly under the drone marker during UI testing.
2026-08-09 21:10:00 +02:00

163 lines
5.8 KiB
Dart

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';
import 'msp_waypoint_codec.dart';
/// 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.
///
/// Setzt die in der Doku vorgegebene Reihenfolge um: Rahmen kodieren/
/// dekodieren, Telemetrie lesen, Missionsupload (`MSP_SET_WP`, siehe
/// msp_waypoint_codec.dart fuer das Byte-Layout und die
/// Aktions-P1/P2/P3-Belegung). Flugmodus-Wechsel und Armen sind weiterhin
/// bewusst nicht umgesetzt - eigene, sicherheitsrelevante Schritte, die
/// eigene Sorgfalt brauchen.
class MspFlightControllerLink implements FlightControllerLink {
MspFlightControllerLink({required this.transport});
final LinkTransport transport;
MspClient? _client;
MspTelemetryPoller? _poller;
@override
FcCapabilities get capabilities => const FcCapabilities(
supportsMultiMission: true,
supportsInFlightUpload: false,
// Konservativer iNAV-Default (navigation.h: NAV_MAX_WAYPOINTS = 15).
// Viele Boards/Targets erlauben mehr; uploadMission() fragt das nicht
// live per MSP_WP_GETINFO ab (dessen maxWaypoints-Feld wird nur zur
// Verifikation nach dem Upload gelesen, siehe verifyMission()) -
// ein client-seitiger Vorab-Check gegen diesen Default verhindert
// trotzdem, dass wir versuchen, mehr Wegpunkte zu senden, als der FC
// WP-Nummern dafuer reserviert (WP#1..#15).
maxWaypoints: 15,
);
@override
Future<void> connect() async {
// Idempotent (Doku flightControllerLinkProvider: "Verbindung bleibt
// ueber Moduswechsel hinweg aktiv") - jeder erneute Eintritt in den
// Fly-Modus ruft connect() erneut auf derselben, weiterhin
// bestehenden Instanz auf; ohne diese Sperre wuerde das einen zweiten
// MspClient/-Poller nebenher starten, der um MSP-Antworten race
// (MSP kennt keine Request-IDs, siehe MspClient-Doku).
if (_poller != null) return;
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) async {
final client = _client;
if (client == null) {
throw StateError('uploadMission() vor connect() aufgerufen.');
}
final waypoints = mission.waypoints;
if (waypoints.isEmpty) {
throw ArgumentError('Mission ohne Wegpunkte kann nicht hochgeladen werden.');
}
if (waypoints.length > capabilities.maxWaypoints) {
throw ArgumentError(
'Mission hat ${waypoints.length} Wegpunkte, iNAV erlaubt hier '
'maximal ${capabilities.maxWaypoints} (NAV_MAX_WAYPOINTS).',
);
}
// MSP_SET_WP kennt kein Batch-Kommando - der FC nimmt WP-fuer-WP nur
// entweder WP#1 (startet damit eine neue Mission, resetWaypointList())
// oder direkt den naechsten erwarteten WP# an (navigation.c
// setWaypoint()). Erst das NAV_WP_FLAG_LAST auf dem letzten Wegpunkt
// macht die Mission fuer den FC gueltig, siehe verifyMission().
for (var i = 0; i < waypoints.length; i++) {
final payload = encodeMspSetWaypoint(
waypoints[i],
wireIndex: i + 1,
isLast: i == waypoints.length - 1,
);
await client.request(MspCommands.setWp, payload: payload);
}
}
@override
Future<bool> verifyMission(FlatWaypointList mission) async {
final client = _client;
if (client == null) {
throw StateError('verifyMission() vor connect() aufgerufen.');
}
final info = parseMspWpGetInfo(await client.request(MspCommands.wpGetInfo));
return info.isValid && info.waypointCount == mission.waypoints.length;
}
@override
Future<void> setFlightMode(FlightMode mode) => throw UnimplementedError(
'Flugmodus-Wechsel ist noch nicht umgesetzt.',
);
@override
Future<void> arm() => throw UnimplementedError(
'Armen ueber MSP ist noch nicht umgesetzt.',
);
@override
Stream<TelemetryFrame> subscribeTelemetry() {
final poller = _poller;
if (poller == null) {
throw StateError('subscribeTelemetry() vor connect() aufgerufen.');
}
return poller.frames;
}
@override
Future<int?> readActiveWaypointIndex() async {
final client = _client;
if (client == null) {
throw StateError('readActiveWaypointIndex() vor connect() aufgerufen.');
}
return parseMspNavStatusActiveWaypoint(
await client.request(MspCommands.navStatus),
);
}
/// Fragt WP#0 (Homepoint, siehe `MspCommands.getWp`-Doku) gezielt per
/// `MSP_WP` ab - bewusst kein Teil von [MspTelemetryPoller]s Zyklus (Doku:
/// "Homepoint muss nicht kontinuierlich abgefragt werden"), Aufrufer ist
/// home_point_provider.dart.
@override
Future<HomePoint?> readHomePoint() async {
final client = _client;
if (client == null) {
throw StateError('readHomePoint() vor connect() aufgerufen.');
}
final payload = await client.request(
MspCommands.getWp,
payload: encodeMspGetWaypointRequest(mspWpNumberHome),
);
return parseMspHomePoint(payload);
}
}