From 1a17fe5b36dff6ffe5df4366953de14ea03e6a28 Mon Sep 17 00:00:00 2001 From: Constantin Leue <77660791+Consti3D@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:47:53 +0200 Subject: [PATCH] drone status pill in footer for fly mode implemented. footer layout and sizes homogenized --- app/lib/domain/telemetry/drone_status.dart | 89 +++++++++ app/lib/transport/flight_controller_link.dart | 22 +++ .../mock/mock_flight_controller_link.dart | 6 + .../transport/msp/msp_telemetry_codec.dart | 17 ++ .../transport/msp/msp_telemetry_poller.dart | 15 +- app/lib/ui/screens/fly/fly_screen.dart | 17 ++ app/lib/ui/widgets/bottom_stats_bar.dart | 177 ++++++++++++++--- app/lib/ui/widgets/mission_footer_bar.dart | 7 + app/lib/ui/widgets/paper_plane_icon.dart | 57 ++++++ app/test/domain/drone_status_test.dart | 180 ++++++++++++++++++ .../msp/msp_flight_controller_link_test.dart | 5 + .../msp/msp_telemetry_codec_test.dart | 21 +- 12 files changed, 585 insertions(+), 28 deletions(-) create mode 100644 app/lib/domain/telemetry/drone_status.dart create mode 100644 app/lib/ui/widgets/paper_plane_icon.dart create mode 100644 app/test/domain/drone_status_test.dart diff --git a/app/lib/domain/telemetry/drone_status.dart b/app/lib/domain/telemetry/drone_status.dart new file mode 100644 index 0000000..1b1b210 --- /dev/null +++ b/app/lib/domain/telemetry/drone_status.dart @@ -0,0 +1,89 @@ +/// Ampelfarbe fuer die Verbindungs- und Positionsqualitaets-Icons in der +/// Drohnen-Status-Pille (Doku: "icon mit Farbcodierung (gruen/gelb/rot)"). +enum SignalLevel { green, yellow, red } + +/// Zustand der Drohne fuer das Papierflieger-Icon in der Drohnen-Status- +/// Pille (Doku: "drone state: papierflieger icon mit farbcodierung ... mit +/// text wie (idle, ready, armed, waypoint, error)"). +enum DroneState { idle, ready, armed, waypoint, error } + +/// Kombiniertes Ergebnis aus [computeDroneStatus] - die drei Elemente der +/// Fly-Modus-Drohnen-Pille (Doku: "connection quality", "position quality", +/// "drone state"). +class DroneStatus { + const DroneStatus({ + required this.connectionQuality, + required this.positionQuality, + required this.state, + }); + + final SignalLevel connectionQuality; + final SignalLevel positionQuality; + final DroneState state; +} + +/// Verbindungsqualitaet aus Empfaenger-Linkqualitaet (LQ%) und +/// Signal-Rausch-Verhaeltnis (SNR, dB) - LQ ist der primaere Massstab (die +/// 70%-Schwelle entspricht iNAVs eigenem OSD-Alarm-Default +/// `osd_link_quality_alarm`, `fc/settings.yaml`), SNR stuft zusaetzlich von +/// gruen auf gelb herab, wenn das Rauschen trotz noch guter LQ bereits das +/// Signal ueberdeckt (SNR < 0 dB). +SignalLevel _connectionQuality({required int linkQuality, required int snrDb}) { + if (linkQuality < 50) return SignalLevel.red; + if (linkQuality < 70 || snrDb < 0) return SignalLevel.yellow; + return SignalLevel.green; +} + +/// Positionsqualitaet aus Satellitenanzahl und HDOP - gaengige GNSS- +/// Faustregel (>=8 Sat. und HDOP <2.0 = gut, >=6 Sat. und HDOP <5.0 = +/// brauchbar, sonst schlecht/kein Fix). +SignalLevel _positionQuality({ + required bool hasFix, + required int numSat, + required double hdop, +}) { + if (!hasFix) return SignalLevel.red; + if (numSat >= 8 && hdop < 2.0) return SignalLevel.green; + if (numSat >= 6 && hdop < 5.0) return SignalLevel.yellow; + return SignalLevel.red; +} + +/// Zustand aus Armed-Status, GPS-Fix und dem rohen Navigationsmodus +/// (`MW_GPS_MODE_*`, siehe TelemetryFrame.navMode) - Emergency (15) hat +/// Vorrang vor allem anderen, "waypoint" nur solange tatsaechlich armed und +/// im Waypoint-Missionsmodus (3) navigiert wird, "ready" markiert lediglich +/// einen vorhandenen GPS-Fix im disarmed-Zustand (kein zusaetzlicher +/// Qualitaets-Gate durch Positionsqualitaet - Nutzerentscheidung). +DroneState _droneState({ + required bool armed, + required bool hasFix, + required int navMode, +}) { + const navModeEmergency = 15; + const navModeWaypointMission = 3; + + if (navMode == navModeEmergency) return DroneState.error; + if (armed && navMode == navModeWaypointMission) return DroneState.waypoint; + if (armed) return DroneState.armed; + if (hasFix) return DroneState.ready; + return DroneState.idle; +} + +/// Reine Ableitung aus einem [TelemetryFrame] (siehe transport/ +/// flight_controller_link.dart) - kein MSP-/Protokollwissen, nur die +/// bereits normalisierten Felder. +DroneStatus computeDroneStatus({ + required bool armed, + required bool hasFix, + required int numSat, + required double hdop, + required int linkQuality, + required int snrDb, + required int navMode, +}) { + return DroneStatus( + connectionQuality: _connectionQuality(linkQuality: linkQuality, snrDb: snrDb), + positionQuality: _positionQuality(hasFix: hasFix, numSat: numSat, hdop: hdop), + state: _droneState(armed: armed, hasFix: hasFix, navMode: navMode), + ); +} diff --git a/app/lib/transport/flight_controller_link.dart b/app/lib/transport/flight_controller_link.dart index 2d8d664..a3822a2 100644 --- a/app/lib/transport/flight_controller_link.dart +++ b/app/lib/transport/flight_controller_link.dart @@ -36,6 +36,9 @@ class TelemetryFrame { required this.armed, required this.batteryPercent, required this.linkQuality, + required this.snrDb, + required this.hdop, + required this.navMode, required this.flightMode, this.activeWaypointIndex, }); @@ -65,6 +68,25 @@ class TelemetryFrame { /// Empfaenger-Linkqualitaet in Prozent (0-100). final int linkQuality; + /// Signal-Rausch-Verhaeltnis des Uplinks in dB (siehe + /// parseMspLinkStatsSnr in msp_telemetry_codec.dart) - hoeher ist besser, + /// negative Werte bedeuten das Rauschen ueberdeckt das Signal bereits + /// teilweise trotz ggf. noch ordentlicher Linkqualitaet. + final int snrDb; + + /// Horizontale Streuung der GPS-Positionsschaetzung (kleiner ist besser, + /// siehe MspGpsReading.hdop in msp_telemetry_codec.dart). Ohne Fix ein + /// sehr grosser Wert (iNAV meldet dann 99.99), nicht null. + final double hdop; + + /// Roher `MW_GPS_MODE_*`-Wert aus `MSP_NAV_STATUS` (0=None, 1=Hold, + /// 2=RTH, 3=Waypoint mission, 15=Emergency) - siehe MspNavMode.mode in + /// msp_telemetry_codec.dart. Getrennt von [flightMode] (der bereits + /// formatierten Kurzbeschreibung), weil computeDroneStatus in + /// domain/telemetry/drone_status.dart den Rohwert fuer die + /// Zustandsableitung braucht. + final int navMode; + /// Menschenlesbare Kurzbeschreibung des Navigationsmodus (z.B. "Waypoint /// mission (en route)", "Return to home (climbing)", "Idle") - siehe /// MspNavMode.formatted in msp_telemetry_codec.dart. diff --git a/app/lib/transport/mock/mock_flight_controller_link.dart b/app/lib/transport/mock/mock_flight_controller_link.dart index ae48bc4..e696dcd 100644 --- a/app/lib/transport/mock/mock_flight_controller_link.dart +++ b/app/lib/transport/mock/mock_flight_controller_link.dart @@ -96,6 +96,12 @@ class MockFlightControllerLink implements FlightControllerLink { armed: _armed, batteryPercent: _batteryPercent.round(), linkQuality: 95, + snrDb: 9, + hdop: 0.9, + // MW_GPS_MODE_NAV (3), sobald armed und eine Mission aktiv ist - + // analog zu flightMode unten, damit computeDroneStatus (Doku: + // "drone state ... waypoint") auch mit Mock-Daten testbar ist. + navMode: _armed && _activeWaypointIndex != null ? 3 : 0, flightMode: _activeWaypointIndex != null ? 'Waypoint mission (en route)' : 'Idle', diff --git a/app/lib/transport/msp/msp_telemetry_codec.dart b/app/lib/transport/msp/msp_telemetry_codec.dart index 4767830..318664a 100644 --- a/app/lib/transport/msp/msp_telemetry_codec.dart +++ b/app/lib/transport/msp/msp_telemetry_codec.dart @@ -11,6 +11,7 @@ class MspGpsReading { required this.lon, required this.speedMs, required this.headingDeg, + required this.hdop, }); /// 0 = kein Fix (`fc_msp.c`: `gpsSol.fixType`). @@ -24,6 +25,13 @@ class MspGpsReading { /// `groundCourse` - nur bei [hasFix] aussagekraeftig. final double headingDeg; + /// Horizontale Streuung der Positionsschaetzung (kleiner = besser, ~1.0 + /// oder darunter ist gut) - `gpsSol.hdop / HDOP_SCALE` (`gps.h`: + /// `#define HDOP_SCALE (100)`). Ohne Fix meldet iNAV hier 9999/100 = + /// 99.99 (`gps.c`: `gpsSol.hdop = 9999`), also einen sehr grossen statt + /// eines fehlenden Werts. + final double hdop; + bool get hasFix => fixType != 0; } @@ -37,6 +45,7 @@ MspGpsReading parseMspRawGps(Uint8List payload) { lon: data.getInt32(6, Endian.little) / 1e7, speedMs: data.getUint16(12, Endian.little) / 100.0, headingDeg: data.getUint16(14, Endian.little) / 10.0, + hdop: data.getUint16(16, Endian.little) / 100.0, ); } @@ -133,3 +142,11 @@ int parseMspInavAnalogBatteryPercent(Uint8List payload) => payload[21]; /// Empfaenger-Linkqualitaet in Prozent (Offset 1, `rxLinkStatistics.uplinkLQ` /// - bereits 0-100, siehe [MspCommands.inavLinkStats]). int parseMspLinkQuality(Uint8List payload) => payload[1]; + +/// Parst die `MSP2_INAV_GET_LINK_STATS`-Antwort und liefert das +/// Signal-Rausch-Verhaeltnis des Uplinks in dB (Offset 2, +/// `rxLinkStatistics.uplinkSNR` - `int8_t`, `fc_msp.c` schreibt +/// `(uint8_t)rxLinkStatistics.uplinkSNR`, das Byte muss also vorzeichen- +/// behaftet zurueckgelesen werden, siehe [MspCommands.inavLinkStats]). +int parseMspLinkStatsSnr(Uint8List payload) => + ByteData.sublistView(payload).getInt8(2); diff --git a/app/lib/transport/msp/msp_telemetry_poller.dart b/app/lib/transport/msp/msp_telemetry_poller.dart index eddf924..9d1de42 100644 --- a/app/lib/transport/msp/msp_telemetry_poller.dart +++ b/app/lib/transport/msp/msp_telemetry_poller.dart @@ -40,6 +40,8 @@ class MspTelemetryPoller { int? _activeWaypointIndex; int _batteryPercent = 0; int _linkQuality = 0; + int _snrDb = 0; + int _navMode = 0; String _flightMode = 'Idle'; Stream get frames => _framesController.stream; @@ -81,13 +83,15 @@ class MspTelemetryPoller { ); final navStatusPayload = await _client.request(MspCommands.navStatus); _activeWaypointIndex = parseMspNavStatusActiveWaypoint(navStatusPayload); - _flightMode = parseMspNavMode(navStatusPayload).formatted; + final navMode = parseMspNavMode(navStatusPayload); + _navMode = navMode.mode; + _flightMode = navMode.formatted; _batteryPercent = parseMspInavAnalogBatteryPercent( await _client.request(MspCommands.inavAnalog), ); - _linkQuality = parseMspLinkQuality( - await _client.request(MspCommands.inavLinkStats), - ); + final linkStatsPayload = await _client.request(MspCommands.inavLinkStats); + _linkQuality = parseMspLinkQuality(linkStatsPayload); + _snrDb = parseMspLinkStatsSnr(linkStatsPayload); } if (!_framesController.isClosed) { @@ -102,6 +106,9 @@ class MspTelemetryPoller { armed: _armed, batteryPercent: _batteryPercent, linkQuality: _linkQuality, + snrDb: _snrDb, + hdop: gps.hdop, + navMode: _navMode, flightMode: _flightMode, activeWaypointIndex: _activeWaypointIndex, )); diff --git a/app/lib/ui/screens/fly/fly_screen.dart b/app/lib/ui/screens/fly/fly_screen.dart index d83bdb0..37740e7 100644 --- a/app/lib/ui/screens/fly/fly_screen.dart +++ b/app/lib/ui/screens/fly/fly_screen.dart @@ -9,6 +9,7 @@ import 'package:latlong2/latlong.dart'; import '../../../domain/mission/mission_stats.dart'; import '../../../domain/mission/mission_warnings.dart'; import '../../../domain/mission/route_geometry.dart'; +import '../../../domain/telemetry/drone_status.dart'; import '../../../domain/wind/wind_math.dart'; import '../../../transport/flight_controller_link.dart'; import '../../providers/active_drone_profile_provider.dart'; @@ -239,6 +240,21 @@ class _FlyScreenState extends ConsumerState { currentSpeedMs: telemetry?.speedMs, ); + // Drohnen-Status fuer die Drohnen-Pille (Doku: "im fly modus zeigt die + // drone pille den zustand der drohne") - null solange keine Telemetrie + // vorliegt, dann zeigt BottomStatsBar weiterhin den Drohnennamen-Chip. + final droneStatus = telemetry != null + ? computeDroneStatus( + armed: telemetry.armed, + hasFix: telemetry.hasFix, + numSat: telemetry.numSat, + hdop: telemetry.hdop, + linkQuality: telemetry.linkQuality, + snrDb: telemetry.snrDb, + navMode: telemetry.navMode, + ) + : null; + return Stack( children: [ MissionMap( @@ -287,6 +303,7 @@ class _FlyScreenState extends ConsumerState { totalDistanceM: missionStats.distanceM, totalDurationSec: missionStats.durationSec, flyMissionStatus: flyMissionStatus, + droneStatus: droneStatus, warnings: warnings, showWarningsBanner: false, batteryPercent: telemetry?.batteryPercent, diff --git a/app/lib/ui/widgets/bottom_stats_bar.dart b/app/lib/ui/widgets/bottom_stats_bar.dart index ef899d0..e7272d2 100644 --- a/app/lib/ui/widgets/bottom_stats_bar.dart +++ b/app/lib/ui/widgets/bottom_stats_bar.dart @@ -3,13 +3,29 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../domain/mission/mission_stats.dart'; import '../../domain/mission/mission_warnings.dart'; +import '../../domain/mission/mission_stats.dart'; +import '../../domain/telemetry/drone_status.dart'; +import '../dmc_colors.dart'; import '../providers/active_drone_profile_provider.dart'; import '../providers/altitude_profile_provider.dart'; import '../providers/mission_meta_provider.dart'; import 'missions_drones_panel.dart'; import 'mountain_peak_icon.dart'; +import 'paper_plane_icon.dart'; + +/// Einheitliche Hoehe fuer alle Pillen/Knoepfe der Fusszeile (Doku: "fuer +/// alle Pillen und Buttons die gleiche Hoehe wie bei der Details-Pille +/// verwenden") - vorher hatten Text-Pillen (implizit aus Padding+Zeilen- +/// hoehe) und die runden Knoepfe (fest 34px) leicht unterschiedliche +/// Hoehen, was in der Fusszeile ein unruhiges Bild ergab. +const _footerElementHeight = 36.0; + +/// Einheitlicher Abstand zwischen allen Pillen/Knoepfen der Fusszeile +/// (Doku: "vereinheitliche den Abstand zwischen Buttons und Pillen") - +/// vorher fehlte er stellenweise ganz (z.B. zwischen Details- und +/// Drohnen-Pille), an anderen Stellen war er fest auf 6px verdrahtet. +const _footerElementSpacing = 8.0; /// Statuszeile am unteren Bildschirmrand (HTML-Demonstrator: #bottomBar/ /// #statsRow). Details-Pille bleibt mittig und oeffnet die Wegpunktliste; @@ -27,6 +43,7 @@ class BottomStatsBar extends ConsumerWidget { required this.totalDistanceM, required this.totalDurationSec, this.flyMissionStatus, + this.droneStatus, required this.onTap, required this.onSettingsTap, this.warnings = const [], @@ -40,6 +57,11 @@ class BottomStatsBar extends ConsumerWidget { /// Siehe MissionFooterBar-Doku - nur im Fly-Modus gesetzt, ersetzt dann /// die statischen Felder in der Details-Pille. final FlyMissionStatus? flyMissionStatus; + + /// Nur im Fly-Modus gesetzt (Doku: "im fly modus zeigt die drone pille + /// den zustand der drohne") - ersetzt dort den Drohnennamen-Chip durch + /// die 3-Icon-Statusanzeige (Verbindung/Position/Zustand). + final DroneStatus? droneStatus; final VoidCallback onTap; /// Oeffnet das Settings-Menue (runder Zahnrad-Knopf rechts der @@ -108,14 +130,16 @@ class BottomStatsBar extends ConsumerWidget { ), ), ), + const SizedBox(width: _footerElementSpacing), _AltitudeProfileToggleButton( isExpanded: isAltitudeProfileExpanded, onTap: () => ref .read(altitudeProfileExpandedProvider.notifier) .toggle(), ), - const SizedBox(width: 6), + const SizedBox(width: _footerElementSpacing), _detailsPill(), + const SizedBox(width: _footerElementSpacing), // Drohnen-Pille + Warnungs-/Settings-Knopf rechts. Die Pille // sitzt in Flexible, damit sie bei langen Namen zu Gunsten der // beiden festgroessen Rundknoepfe schrumpft/ellipsiert, statt @@ -127,19 +151,31 @@ class BottomStatsBar extends ConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ Flexible( - child: _NameChip( - label: 'Drone: $droneName', - color: const Color(0xFF4D9DFF).withValues(alpha: 0.18), - textColor: const Color(0xFFCFE2FF), - onTap: () => Navigator.of(context).push(MaterialPageRoute( - builder: (_) => const MissionsDronesPanel.drones(), - )), - ), + child: droneStatus != null + ? _DroneStatusPill( + status: droneStatus!, + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (_) => const MissionsDronesPanel.drones(), + )), + ) + : _NameChip( + // Gleicher Hintergrund wie die Details-Pille + // (Doku: "gleichen Hintergrund wie bei der + // Detail Pille fuer die Drohne Pille + // verwenden") statt des vorherigen blauen + // Farbtons. + label: 'Drone: $droneName', + color: Colors.white.withValues(alpha: 0.12), + textColor: Colors.white, + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (_) => const MissionsDronesPanel.drones(), + )), + ), ), - const SizedBox(width: 6), + const SizedBox(width: _footerElementSpacing), if (warnings.isNotEmpty) ...[ _WarningButton(onTap: onWarningsTap), - const SizedBox(width: 6), + const SizedBox(width: _footerElementSpacing), ], _SettingsButton(onTap: onSettingsTap), ], @@ -176,10 +212,11 @@ class BottomStatsBar extends ConsumerWidget { return GestureDetector( onTap: onTap, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 9), + height: _footerElementHeight, + padding: const EdgeInsets.symmetric(horizontal: 18), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(_footerElementHeight / 2), ), child: Row( mainAxisSize: MainAxisSize.min, @@ -240,8 +277,8 @@ class _AltitudeProfileToggleButton extends StatelessWidget { return GestureDetector( onTap: onTap, child: Container( - width: 34, - height: 34, + width: _footerElementHeight, + height: _footerElementHeight, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.white.withValues(alpha: 0.12), @@ -270,8 +307,8 @@ class _WarningButton extends StatelessWidget { return GestureDetector( onTap: onTap, child: Container( - width: 34, - height: 34, + width: _footerElementHeight, + height: _footerElementHeight, decoration: BoxDecoration( shape: BoxShape.circle, color: const Color(0xFFE2574A).withValues(alpha: 0.18), @@ -295,8 +332,8 @@ class _SettingsButton extends StatelessWidget { return GestureDetector( onTap: onTap, child: Container( - width: 34, - height: 34, + width: _footerElementHeight, + height: _footerElementHeight, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.white.withValues(alpha: 0.12), @@ -307,6 +344,100 @@ class _SettingsButton extends StatelessWidget { } } +/// Ersetzt im Fly-Modus die Drohnen-Namen-Pille (Doku: "im fly modus zeigt +/// die drone pille den zustand der drohne. das beinhaltet diese 3 +/// elemente: connection quality ..., position quality ..., drone state +/// ..."). Farbcodierung siehe computeDroneStatus in +/// domain/telemetry/drone_status.dart; das Papierflieger-Icon zeigt +/// zusaetzlich einen Kurztext (idle/ready/armed/waypoint/error). +class _DroneStatusPill extends StatelessWidget { + const _DroneStatusPill({required this.status, required this.onTap}); + + final DroneStatus status; + final VoidCallback onTap; + + static const _stateLabels = { + DroneState.idle: 'idle', + DroneState.ready: 'ready', + DroneState.armed: 'armed', + DroneState.waypoint: 'waypoint', + DroneState.error: 'error', + }; + + /// Nur 3 Farben fuer 5 Zustaende (Doku: "papierflieger icon mit + /// farbcodierung (gruen/gelb/rot)") - gruen fuer die beiden Zustaende, in + /// denen alles nominal laeuft (bereit zum Start bzw. aktiv unterwegs), + /// gelb fuer die beiden "Achtung"-Uebergangszustaende (am Boden wartend + /// bzw. bereits scharf aber ohne aktive Wegpunktnavigation, z.B. manuell + /// oder im Hold), rot ausschliesslich fuer Emergency. + static const _stateColors = { + DroneState.idle: DmcColors.pafAmber, + DroneState.ready: DmcColors.flyModeColor, + DroneState.armed: DmcColors.pafAmber, + DroneState.waypoint: DmcColors.flyModeColor, + DroneState.error: DmcColors.warnRed, + }; + + static const _signalColors = { + SignalLevel.green: DmcColors.flyModeColor, + SignalLevel.yellow: DmcColors.pafAmber, + SignalLevel.red: DmcColors.warnRed, + }; + + @override + Widget build(BuildContext context) { + final stateColor = _stateColors[status.state]!; + return GestureDetector( + onTap: onTap, + child: Container( + height: _footerElementHeight, + padding: const EdgeInsets.symmetric(horizontal: 12), + alignment: Alignment.center, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(_footerElementHeight / 2), + ), + // FittedBox statt eines festen Layouts: die Pille sitzt in einem + // Flexible neben Warnungs-/Settings-Knopf (BottomStatsBar) und hat + // je nach Bildschirmbreite/Drohnennamenslaenge unterschiedlich viel + // Platz - ohne Skalierung wuerde 3 Icons + Statustext auf schmalen + // Screens einen RenderFlex-Overflow ausloesen statt einfach etwas + // kleiner zu werden. + child: FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Papierflieger-Symbol ganz links in der Pille (Doku: "fuer + // das drohnen symbol ... unsere standard paperplane form + // verwenden und nach ganz links innerhalb der pille + // anordnen") - unsere Standardform (dieselben Koordinaten + // wie DroneMarkerIcon/DroneFollowIcon), hier einfarbig nach + // Zustand statt der zweifarbigen/pinken Kartenmarker-Variante. + PaperPlaneIcon(size: 15, color: stateColor), + const SizedBox(width: 4), + Text( + _stateLabels[status.state]!, + style: TextStyle( + color: stateColor, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + const SizedBox(width: 10), + Icon(Icons.signal_cellular_alt, + size: 15, color: _signalColors[status.connectionQuality]), + const SizedBox(width: 8), + Icon(Icons.satellite_alt, + size: 14, color: _signalColors[status.positionQuality]), + ], + ), + ), + ), + ); + } +} + class _NameChip extends StatelessWidget { const _NameChip({ required this.label, @@ -328,10 +459,12 @@ class _NameChip extends StatelessWidget { return GestureDetector( onTap: onTap, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + height: _footerElementHeight, + padding: const EdgeInsets.symmetric(horizontal: 12), + alignment: Alignment.center, decoration: BoxDecoration( color: color, - borderRadius: BorderRadius.circular(14), + borderRadius: BorderRadius.circular(_footerElementHeight / 2), ), child: Text( label, diff --git a/app/lib/ui/widgets/mission_footer_bar.dart b/app/lib/ui/widgets/mission_footer_bar.dart index fc558f8..c62cdb1 100644 --- a/app/lib/ui/widgets/mission_footer_bar.dart +++ b/app/lib/ui/widgets/mission_footer_bar.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../domain/mission/mission_stats.dart'; import '../../domain/mission/mission_warnings.dart'; +import '../../domain/telemetry/drone_status.dart'; import '../../domain/waypoint/flat_waypoint_list.dart'; import '../providers/altitude_profile_provider.dart'; import 'battery_indicator_bar.dart'; @@ -23,6 +24,7 @@ class MissionFooterBar extends ConsumerWidget { required this.totalDistanceM, required this.totalDurationSec, this.flyMissionStatus, + this.droneStatus, required this.warnings, this.showWarningsBanner = true, this.batteryPercent, @@ -43,6 +45,10 @@ class MissionFooterBar extends ConsumerWidget { /// (Wegpunktzahl/Gesamtdistanz/-dauer) durch aktiven Wegpunkt sowie /// Distanz/Zeit dorthin. Bleibt im Plan-Modus null. final FlyMissionStatus? flyMissionStatus; + + /// Nur im Fly-Modus gesetzt (Doku: "im fly modus zeigt die drone pille + /// den zustand der drohne") - siehe BottomStatsBar.droneStatus. + final DroneStatus? droneStatus; final List warnings; /// Blendet das Text-Warnbanner ueber der Fusszeile aus (Doku: "Banner mit @@ -105,6 +111,7 @@ class MissionFooterBar extends ConsumerWidget { totalDistanceM: totalDistanceM, totalDurationSec: totalDurationSec, flyMissionStatus: flyMissionStatus, + droneStatus: droneStatus, onTap: onDetailsTap, warnings: warnings, onWarningsTap: onWarningsTap, diff --git a/app/lib/ui/widgets/paper_plane_icon.dart b/app/lib/ui/widgets/paper_plane_icon.dart new file mode 100644 index 0000000..919fcec --- /dev/null +++ b/app/lib/ui/widgets/paper_plane_icon.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +/// Einfarbig gefuellte Papierflieger-Silhouette (design/B_paperplane.svg, +/// dieselben Koordinaten wie DroneMarkerIcon/DroneFollowIcon) in beliebiger +/// Farbe - fuer kleine Statusanzeigen, in denen die Farbe selbst die +/// Information traegt (siehe _DroneStatusPill in bottom_stats_bar.dart), +/// statt der zweifarbigen bzw. fest pinken Varianten der beiden anderen +/// Verwendungsstellen. +class PaperPlaneIcon extends StatelessWidget { + const PaperPlaneIcon({super.key, required this.size, required this.color}); + + final double size; + final Color color; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: size, + height: size, + child: CustomPaint(painter: _PaperPlaneFillPainter(color: color)), + ); + } +} + +class _PaperPlaneFillPainter extends CustomPainter { + _PaperPlaneFillPainter({required this.color}); + + final Color color; + + // Gleiche Koordinaten wie DroneMarkerIcon._PaperPlanePainter (viewBox + // 0 0 200 200, design/B_paperplane.svg): Spitze oben, zwei Fluegel unten, + // Heckkerbe dazwischen. + static const _viewBoxSize = 200.0; + static const _nose = Offset(100, 22); + static const _rightWing = Offset(164, 172); + static const _tailNotch = Offset(100, 138); + static const _leftWing = Offset(36, 172); + + @override + void paint(Canvas canvas, Size size) { + final scale = size.width / _viewBoxSize; + Offset s(Offset p) => p * scale; + + final outline = Path() + ..moveTo(s(_nose).dx, s(_nose).dy) + ..lineTo(s(_rightWing).dx, s(_rightWing).dy) + ..lineTo(s(_tailNotch).dx, s(_tailNotch).dy) + ..lineTo(s(_leftWing).dx, s(_leftWing).dy) + ..close(); + + canvas.drawPath(outline, Paint()..color = color); + } + + @override + bool shouldRepaint(covariant _PaperPlaneFillPainter oldDelegate) => + oldDelegate.color != color; +} diff --git a/app/test/domain/drone_status_test.dart b/app/test/domain/drone_status_test.dart new file mode 100644 index 0000000..c1b6d16 --- /dev/null +++ b/app/test/domain/drone_status_test.dart @@ -0,0 +1,180 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:dmc_app/domain/telemetry/drone_status.dart'; + +void main() { + group('computeDroneStatus - connectionQuality', () { + test('gruen bei LQ >=70% und SNR >=0dB', () { + final status = computeDroneStatus( + armed: false, + hasFix: true, + numSat: 10, + hdop: 1.0, + linkQuality: 70, + snrDb: 0, + navMode: 0, + ); + expect(status.connectionQuality, SignalLevel.green); + }); + + test('gelb bei LQ >=70% aber SNR <0dB (Rauschen trotz guter LQ)', () { + final status = computeDroneStatus( + armed: false, + hasFix: true, + numSat: 10, + hdop: 1.0, + linkQuality: 90, + snrDb: -1, + navMode: 0, + ); + expect(status.connectionQuality, SignalLevel.yellow); + }); + + test('gelb bei LQ zwischen 50% und 70%', () { + final status = computeDroneStatus( + armed: false, + hasFix: true, + numSat: 10, + hdop: 1.0, + linkQuality: 60, + snrDb: 5, + navMode: 0, + ); + expect(status.connectionQuality, SignalLevel.yellow); + }); + + test('rot bei LQ <50%', () { + final status = computeDroneStatus( + armed: false, + hasFix: true, + numSat: 10, + hdop: 1.0, + linkQuality: 20, + snrDb: 10, + navMode: 0, + ); + expect(status.connectionQuality, SignalLevel.red); + }); + }); + + group('computeDroneStatus - positionQuality', () { + test('gruen bei >=8 Satelliten und HDOP <2.0', () { + final status = computeDroneStatus( + armed: false, + hasFix: true, + numSat: 9, + hdop: 1.5, + linkQuality: 80, + snrDb: 5, + navMode: 0, + ); + expect(status.positionQuality, SignalLevel.green); + }); + + test('gelb bei >=6 Satelliten und HDOP <5.0, aber nicht gruen-Schwelle', () { + final status = computeDroneStatus( + armed: false, + hasFix: true, + numSat: 6, + hdop: 3.0, + linkQuality: 80, + snrDb: 5, + navMode: 0, + ); + expect(status.positionQuality, SignalLevel.yellow); + }); + + test('rot ohne GPS-Fix, unabhaengig von Satelliten/HDOP', () { + final status = computeDroneStatus( + armed: false, + hasFix: false, + numSat: 12, + hdop: 0.5, + linkQuality: 80, + snrDb: 5, + navMode: 0, + ); + expect(status.positionQuality, SignalLevel.red); + }); + + test('rot bei wenigen Satelliten trotz Fix', () { + final status = computeDroneStatus( + armed: false, + hasFix: true, + numSat: 3, + hdop: 8.0, + linkQuality: 80, + snrDb: 5, + navMode: 0, + ); + expect(status.positionQuality, SignalLevel.red); + }); + }); + + group('computeDroneStatus - state', () { + test('error hat Vorrang, sobald navMode Emergency (15) ist', () { + final status = computeDroneStatus( + armed: true, + hasFix: true, + numSat: 10, + hdop: 1.0, + linkQuality: 80, + snrDb: 5, + navMode: 15, + ); + expect(status.state, DroneState.error); + }); + + test('waypoint, wenn armed und navMode Waypoint mission (3)', () { + final status = computeDroneStatus( + armed: true, + hasFix: true, + numSat: 10, + hdop: 1.0, + linkQuality: 80, + snrDb: 5, + navMode: 3, + ); + expect(status.state, DroneState.waypoint); + }); + + test('armed, wenn armed aber kein Waypoint-Modus (z.B. Hold)', () { + final status = computeDroneStatus( + armed: true, + hasFix: true, + numSat: 10, + hdop: 1.0, + linkQuality: 80, + snrDb: 5, + navMode: 1, + ); + expect(status.state, DroneState.armed); + }); + + test('ready, wenn nicht armed aber GPS-Fix vorhanden', () { + final status = computeDroneStatus( + armed: false, + hasFix: true, + numSat: 10, + hdop: 1.0, + linkQuality: 80, + snrDb: 5, + navMode: 0, + ); + expect(status.state, DroneState.ready); + }); + + test('idle, wenn nicht armed und kein GPS-Fix', () { + final status = computeDroneStatus( + armed: false, + hasFix: false, + numSat: 0, + hdop: 99.99, + linkQuality: 80, + snrDb: 5, + navMode: 0, + ); + expect(status.state, DroneState.idle); + }); + }); +} 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 0e6e560..0f401fa 100644 --- a/app/test/transport/msp/msp_flight_controller_link_test.dart +++ b/app/test/transport/msp/msp_flight_controller_link_test.dart @@ -33,6 +33,7 @@ Timer _startFakeFlightController( d.setInt32(6, 134050000, Endian.little); // lon 13.405 d.setUint16(12, 500, Endian.little); // 5.00 m/s d.setUint16(14, 2700, Endian.little); // 270.0 deg + d.setUint16(16, 150, Endian.little); // HDOP 1.50 payload = d.buffer.asUint8List(); case MspCommands.altitude: final d = ByteData(10); @@ -52,6 +53,7 @@ Timer _startFakeFlightController( case MspCommands.inavLinkStats: final d = ByteData(3); d.setUint8(1, 88); // Linkqualitaet % + d.setInt8(2, 6); // SNR 6 dB payload = d.buffer.asUint8List(); default: payload = Uint8List(0); @@ -88,6 +90,9 @@ void main() { expect(frame.headingDeg, closeTo(270.0, 1e-6)); expect(frame.batteryPercent, 77); expect(frame.linkQuality, 88); + expect(frame.snrDb, 6); + expect(frame.hdop, closeTo(1.5, 1e-9)); + expect(frame.navMode, 3); expect(frame.flightMode, 'Waypoint mission (en route to WP)'); // Fake-FC meldet activeWpNumber=3 (1-basiert) - als 0-basierter Index // in die App-eigene Wegpunktliste entspricht das Index 2. diff --git a/app/test/transport/msp/msp_telemetry_codec_test.dart b/app/test/transport/msp/msp_telemetry_codec_test.dart index 471f5fa..c886ced 100644 --- a/app/test/transport/msp/msp_telemetry_codec_test.dart +++ b/app/test/transport/msp/msp_telemetry_codec_test.dart @@ -6,10 +6,10 @@ 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)', () { + test('parst fixType, numSat, lat/lon (1e-7 deg), Speed (cm/s) und HDOP', () { // 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). + // ungenutzt), hdop=120 (/100 = 1.2). final data = ByteData(18); data.setUint8(0, 2); data.setUint8(1, 9); @@ -29,6 +29,7 @@ void main() { expect(reading.lon, closeTo(13.405, 1e-9)); expect(reading.speedMs, closeTo(12.34, 1e-9)); expect(reading.headingDeg, closeTo(90.0, 1e-9)); + expect(reading.hdop, closeTo(1.2, 1e-9)); }); test('fixType 0 bedeutet kein Fix', () { @@ -142,4 +143,20 @@ void main() { expect(parseMspLinkQuality(payload), 87); }); }); + + group('parseMspLinkStatsSnr', () { + test('liest positives SNR an Offset 2', () { + final payload = Uint8List(3); + payload[2] = 12; + expect(parseMspLinkStatsSnr(payload), 12); + }); + + test('liest negatives SNR (vorzeichenbehaftetes Byte) an Offset 2', () { + // -5 dB als uint8 geschrieben (fc_msp.c: (uint8_t)rxLinkStatistics. + // uplinkSNR) entspricht 251 (256-5). + final payload = Uint8List(3); + payload[2] = 251; + expect(parseMspLinkStatsSnr(payload), -5); + }); + }); }