diff --git a/app/lib/transport/flight_controller_link.dart b/app/lib/transport/flight_controller_link.dart index 6199f42..be6eb0e 100644 --- a/app/lib/transport/flight_controller_link.dart +++ b/app/lib/transport/flight_controller_link.dart @@ -34,6 +34,9 @@ class TelemetryFrame { required this.speedMs, required this.headingDeg, required this.armed, + required this.batteryPercent, + required this.linkQuality, + required this.flightMode, this.activeWaypointIndex, }); @@ -43,6 +46,11 @@ class TelemetryFrame { final double lon; final bool hasFix; final int numSat; + + /// Barometrisch/GPS-fusionierte Hoehe relativ zum Referenzpunkt der + /// Positionsschaetzung (in der Praxis: Einschaltort, siehe + /// MspNavMode-/estAlt-Doku in msp_telemetry_codec.dart) - keine absolute + /// Hoehe ueber Meeresspiegel. final double altitudeM; final double speedMs; @@ -50,6 +58,17 @@ class TelemetryFrame { /// Drohnen-Marker (Doku 3.10, [DroneMarkerIcon]). final double headingDeg; final bool armed; + + /// Akku-Ladestand in Prozent (0-100). + final int batteryPercent; + + /// Empfaenger-Linkqualitaet in Prozent (0-100). + final int linkQuality; + + /// Menschenlesbare Kurzbeschreibung des Navigationsmodus (z.B. "Waypoint + /// mission (en route)", "Return to home (climbing)", "Idle") - siehe + /// MspNavMode.formatted in msp_telemetry_codec.dart. + final String flightMode; final int? activeWaypointIndex; } diff --git a/app/lib/transport/mock/mock_flight_controller_link.dart b/app/lib/transport/mock/mock_flight_controller_link.dart index 0e80da5..b63ec0b 100644 --- a/app/lib/transport/mock/mock_flight_controller_link.dart +++ b/app/lib/transport/mock/mock_flight_controller_link.dart @@ -11,6 +11,7 @@ class MockFlightControllerLink implements FlightControllerLink { StreamController? _telemetryController; Timer? _ticker; double _heading = 0; + double _batteryPercent = 100; @override FcCapabilities get capabilities => const FcCapabilities( @@ -28,6 +29,9 @@ class MockFlightControllerLink implements FlightControllerLink { // sichtbar ueberprueft werden kann (Doku 9: UI-Entwicklung ohne // Hardware). _heading = (_heading + 5) % 360; + // Langsam sinkender Akkustand, damit die Anzeige auch ohne Hardware + // sichtbar auf Werteaenderungen reagiert (Doku 9). + _batteryPercent = (_batteryPercent - 0.1).clamp(0, 100); _telemetryController?.add(TelemetryFrame( lat: 52.5, lon: 13.4, @@ -37,6 +41,11 @@ class MockFlightControllerLink implements FlightControllerLink { speedMs: 18, headingDeg: _heading, armed: _armed, + batteryPercent: _batteryPercent.round(), + linkQuality: 95, + flightMode: _activeWaypointIndex != null + ? 'Waypoint mission (en route)' + : 'Idle', activeWaypointIndex: _activeWaypointIndex, )); }); diff --git a/app/lib/transport/msp/msp_commands.dart b/app/lib/transport/msp/msp_commands.dart index 26c5bbc..318a024 100644 --- a/app/lib/transport/msp/msp_commands.dart +++ b/app/lib/transport/msp/msp_commands.dart @@ -28,6 +28,22 @@ abstract final class MspCommands { /// (`fc_msp.c`, `case MSP2_INAV_STATUS`; ID aus /// `msp_protocol_v2_inav.h`: `#define MSP2_INAV_STATUS 0x2000`) static const int inavStatus = 0x2000; + + /// Response (24 Byte): u8 flags, u16 batteryVoltage(0.01V), + /// u16 amperage(0.01A), u32 power, u32 mAhDrawn, u32 mWhDrawn, + /// u32 remainingCapacity, u8 batteryPercentage(0-100, bereits fertig + /// berechnet ueber `calculateBatteryPercentage()`), u16 legacyRssi + /// (0-1023, veraltete Skala - siehe stattdessen [inavLinkStats]). + /// (`fc_msp.c`, `case MSP2_INAV_ANALOG`; ID aus + /// `msp_protocol_v2_inav.h`: `#define MSP2_INAV_ANALOG 0x2002`) + static const int inavAnalog = 0x2002; + + /// Response (3 Byte): u8 uplinkRssiDbm (negiert gespeichert als + /// `-rxLinkStatistics.uplinkRSSI`), u8 uplinkLinkQuality (0-100%, + /// `rxLinkStatistics.uplinkLQ` - das ist die "Receiver Link Quality"), + /// i8 uplinkSnr. (`fc_msp.c`, `case MSP2_INAV_GET_LINK_STATS`; ID aus + /// `msp_protocol_v2_inav.h`: `#define MSP2_INAV_GET_LINK_STATS 0x2103`) + static const int inavLinkStats = 0x2103; } /// Bits von `armingFlags` (Doku: `fc/runtime_config.h`, `armingFlags_e`). diff --git a/app/lib/transport/msp/msp_telemetry_codec.dart b/app/lib/transport/msp/msp_telemetry_codec.dart index 326caac..1f67999 100644 --- a/app/lib/transport/msp/msp_telemetry_codec.dart +++ b/app/lib/transport/msp/msp_telemetry_codec.dart @@ -64,3 +64,61 @@ int? parseMspNavStatusActiveWaypoint(Uint8List payload) { final activeWpNumber = payload[3]; return activeWpNumber == 0 ? null : activeWpNumber; } + +/// Parst `mode`/`state` aus der `MSP_NAV_STATUS`-Antwort (Byte 0/1, siehe +/// [MspCommands.navStatus]) - Rohwerte aus `navSystemStatus_Mode_e`/ +/// `navSystemStatus_State_e` (`navigation.h`, iNAV 9.1.0). [formatted] +/// bildet daraus eine fuer die UI lesbare Kurzbeschreibung (Doku: "drone +/// flight mode" im Settings-Telemetriepanel). +class MspNavMode { + const MspNavMode({required this.mode, required this.state}); + + final int mode; + final int state; + + /// `MW_GPS_MODE_*`-Namen (0=None, 1=Hold, 2=RTH, 3=Nav/Waypoint, + /// 15=Emergency) - mode 0 heisst nur "keine GPS-Nav-Uebersteuerung aktiv", + /// nicht "Manual/Acro": die reine Fluglage (Angle/Horizon/Acro) ist ueber + /// MSP_NAV_STATUS nicht sichtbar (dafuer waere MSP_BOXNAMES+boxModeFlags + /// noetig, hier bewusst nicht umgesetzt). + static const _modeNames = {0: 'Idle', 1: 'Position hold', 2: 'Return to home', 3: 'Waypoint mission', 15: 'Emergency'}; + + /// `MW_NAV_STATE_*`-Namen, nur die fuer die Kurzanzeige relevanten. + static const _stateNames = { + 1: 'starting', + 2: 'en route', + 3: 'holding', + 4: 'holding (timed)', + 5: 'en route to WP', + 6: 'next waypoint', + 7: 'jump', + 8: 'landing', + 9: 'landing', + 10: 'landed', + 11: 'landing', + 12: 'descending', + 13: 'hover above home', + 14: 'emergency landing', + 15: 'climbing', + }; + + String get formatted { + final modeLabel = _modeNames[mode] ?? 'Unknown ($mode)'; + final stateLabel = _stateNames[state]; + return stateLabel == null ? modeLabel : '$modeLabel ($stateLabel)'; + } +} + +MspNavMode parseMspNavMode(Uint8List payload) { + return MspNavMode(mode: payload[0], state: payload[1]); +} + +/// Parst die `MSP2_INAV_ANALOG`-Antwort und liefert den Akku-Ladestand in +/// Prozent (Offset 21, `calculateBatteryPercentage()` - bereits fertig auf +/// 0-100 berechnet, siehe [MspCommands.inavAnalog]). +int parseMspInavAnalogBatteryPercent(Uint8List payload) => payload[21]; + +/// Parst die `MSP2_INAV_GET_LINK_STATS`-Antwort und liefert die +/// Empfaenger-Linkqualitaet in Prozent (Offset 1, `rxLinkStatistics.uplinkLQ` +/// - bereits 0-100, siehe [MspCommands.inavLinkStats]). +int parseMspLinkQuality(Uint8List payload) => payload[1]; diff --git a/app/lib/transport/msp/msp_telemetry_poller.dart b/app/lib/transport/msp/msp_telemetry_poller.dart index 03de413..eddf924 100644 --- a/app/lib/transport/msp/msp_telemetry_poller.dart +++ b/app/lib/transport/msp/msp_telemetry_poller.dart @@ -14,13 +14,11 @@ import 'msp_telemetry_codec.dart'; /// /// - 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. +/// - Status/Flugmodus/Akku/Link (2 Hz): jeden [_statusEveryNCycles]-ten +/// Zyklus (`MSP2_INAV_STATUS`, `MSP_NAV_STATUS`, `MSP2_INAV_ANALOG`, +/// `MSP2_INAV_GET_LINK_STATS`) - Flugmodus wird aus derselben +/// `MSP_NAV_STATUS`-Antwort gelesen wie der aktive Wegpunkt, keine +/// zusaetzliche Anfrage noetig. /// /// Der Strom wird NICHT auf einen gueltigen GPS-Fix gegated - jeder Zyklus /// emittiert ein [TelemetryFrame], auch ohne Fix (dann mit `hasFix: false` @@ -40,6 +38,9 @@ class MspTelemetryPoller { int _cycle = 0; bool _armed = false; int? _activeWaypointIndex; + int _batteryPercent = 0; + int _linkQuality = 0; + String _flightMode = 'Idle'; Stream get frames => _framesController.stream; @@ -78,8 +79,14 @@ class MspTelemetryPoller { _armed = parseMspInavStatusArmed( await _client.request(MspCommands.inavStatus), ); - _activeWaypointIndex = parseMspNavStatusActiveWaypoint( - await _client.request(MspCommands.navStatus), + final navStatusPayload = await _client.request(MspCommands.navStatus); + _activeWaypointIndex = parseMspNavStatusActiveWaypoint(navStatusPayload); + _flightMode = parseMspNavMode(navStatusPayload).formatted; + _batteryPercent = parseMspInavAnalogBatteryPercent( + await _client.request(MspCommands.inavAnalog), + ); + _linkQuality = parseMspLinkQuality( + await _client.request(MspCommands.inavLinkStats), ); } @@ -93,6 +100,9 @@ class MspTelemetryPoller { speedMs: gps.speedMs, headingDeg: gps.headingDeg, armed: _armed, + batteryPercent: _batteryPercent, + linkQuality: _linkQuality, + flightMode: _flightMode, activeWaypointIndex: _activeWaypointIndex, )); } diff --git a/app/lib/ui/screens/settings/settings_screen.dart b/app/lib/ui/screens/settings/settings_screen.dart index 4679108..c7e67a6 100644 --- a/app/lib/ui/screens/settings/settings_screen.dart +++ b/app/lib/ui/screens/settings/settings_screen.dart @@ -273,6 +273,9 @@ class _TelemetryFields extends StatelessWidget { _field('Speed', '${t.speedMs.toStringAsFixed(1)} m/s'), _field('Heading', '${t.headingDeg.toStringAsFixed(0)}°'), _field('Armed', t.armed ? 'Yes' : 'No'), + _field('Battery', '${t.batteryPercent}%'), + _field('Link quality', '${t.linkQuality}%'), + _field('Flight mode', t.flightMode), if (t.activeWaypointIndex != null) _field('Active WP', '${t.activeWaypointIndex}'), ], diff --git a/app/test/transport/msp/msp_flight_controller_link_test.dart b/app/test/transport/msp/msp_flight_controller_link_test.dart index 51c6146..9dc7429 100644 --- a/app/test/transport/msp/msp_flight_controller_link_test.dart +++ b/app/test/transport/msp/msp_flight_controller_link_test.dart @@ -43,7 +43,16 @@ Timer _startFakeFlightController( 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 + // mode=3 (Nav/Waypoint), state=5 (WP_ENROUTE), WP #3 aktiv. + payload = Uint8List.fromList([3, 5, 0, 3, 0, 0, 0]); + case MspCommands.inavAnalog: + final d = ByteData(24); + d.setUint8(21, 77); // Batterie-Prozent + payload = d.buffer.asUint8List(); + case MspCommands.inavLinkStats: + final d = ByteData(3); + d.setUint8(1, 88); // Linkqualitaet % + payload = d.buffer.asUint8List(); default: payload = Uint8List(0); } @@ -65,7 +74,10 @@ void main() { final link = MspFlightControllerLink(transport: transport); await link.connect(); - final frame = await link.subscribeTelemetry().first; + // Armed/Akku/Link/Flugmodus/aktiver Wegpunkt werden nur jeden dritten + // Zyklus aufgefrischt (siehe MspTelemetryPoller._statusEveryNCycles) - + // erst ab dem dritten emittierten Frame garantiert befuellt. + final frame = await link.subscribeTelemetry().skip(2).first; expect(frame.lat, closeTo(52.52, 1e-6)); expect(frame.lon, closeTo(13.405, 1e-6)); @@ -74,6 +86,9 @@ void main() { expect(frame.altitudeM, closeTo(50.0, 1e-6)); expect(frame.speedMs, closeTo(5.0, 1e-6)); expect(frame.headingDeg, closeTo(270.0, 1e-6)); + expect(frame.batteryPercent, 77); + expect(frame.linkQuality, 88); + expect(frame.flightMode, 'Waypoint mission (en route to WP)'); await link.disconnect(); responder.cancel(); diff --git a/app/test/transport/msp/msp_telemetry_codec_test.dart b/app/test/transport/msp/msp_telemetry_codec_test.dart index 0814082..a69db8a 100644 --- a/app/test/transport/msp/msp_telemetry_codec_test.dart +++ b/app/test/transport/msp/msp_telemetry_codec_test.dart @@ -94,4 +94,48 @@ void main() { expect(parseMspNavStatusActiveWaypoint(navStatusPayload(5)), 5); }); }); + + group('parseMspNavMode/MspNavMode.formatted', () { + Uint8List navStatusPayload(int mode, int state) { + return Uint8List.fromList([mode, state, 0, 0, 0, 0, 0]); + } + + test('mode 0 (None) ohne state', () { + final nav = parseMspNavMode(navStatusPayload(0, 0)); + expect(nav.mode, 0); + expect(nav.state, 0); + expect(nav.formatted, 'Idle'); + }); + + test('mode 3 (Nav/Waypoint) + state 5 (WP_ENROUTE)', () { + final nav = parseMspNavMode(navStatusPayload(3, 5)); + expect(nav.formatted, 'Waypoint mission (en route to WP)'); + }); + + test('mode 2 (RTH) + state 15 (RTH_CLIMB)', () { + final nav = parseMspNavMode(navStatusPayload(2, 15)); + expect(nav.formatted, 'Return to home (climbing)'); + }); + + test('unbekannter mode faellt auf Nummer zurueck', () { + final nav = parseMspNavMode(navStatusPayload(7, 0)); + expect(nav.formatted, 'Unknown (7)'); + }); + }); + + group('parseMspInavAnalogBatteryPercent', () { + test('liest Batterie-Prozent an Offset 21', () { + final payload = Uint8List(24); + payload[21] = 42; + expect(parseMspInavAnalogBatteryPercent(payload), 42); + }); + }); + + group('parseMspLinkQuality', () { + test('liest Linkqualitaet an Offset 1', () { + final payload = Uint8List(3); + payload[1] = 87; + expect(parseMspLinkQuality(payload), 87); + }); + }); } diff --git a/inav b/inav new file mode 160000 index 0000000..c5c593d --- /dev/null +++ b/inav @@ -0,0 +1 @@ +Subproject commit c5c593d71d33c8e284bf9cd34381588fda7a98c8