drone status pill in footer for fly mode implemented. footer layout and sizes homogenized

This commit is contained in:
Constantin Leue
2026-08-04 15:47:53 +02:00
parent f2f2953642
commit 1a17fe5b36
12 changed files with 585 additions and 28 deletions
@@ -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),
);
}
@@ -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.
@@ -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',
@@ -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);
@@ -40,6 +40,8 @@ class MspTelemetryPoller {
int? _activeWaypointIndex;
int _batteryPercent = 0;
int _linkQuality = 0;
int _snrDb = 0;
int _navMode = 0;
String _flightMode = 'Idle';
Stream<TelemetryFrame> 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,
));
+17
View File
@@ -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<FlyScreen> {
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<FlyScreen> {
totalDistanceM: missionStats.distanceM,
totalDurationSec: missionStats.durationSec,
flyMissionStatus: flyMissionStatus,
droneStatus: droneStatus,
warnings: warnings,
showWarningsBanner: false,
batteryPercent: telemetry?.batteryPercent,
+155 -22
View File
@@ -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,
@@ -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<MissionWarning> 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,
+57
View File
@@ -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;
}
+180
View File
@@ -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);
});
});
}
@@ -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.
@@ -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);
});
});
}