drone status pill in footer for fly mode implemented. footer layout and sizes homogenized
This commit is contained in:
@@ -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.armed,
|
||||||
required this.batteryPercent,
|
required this.batteryPercent,
|
||||||
required this.linkQuality,
|
required this.linkQuality,
|
||||||
|
required this.snrDb,
|
||||||
|
required this.hdop,
|
||||||
|
required this.navMode,
|
||||||
required this.flightMode,
|
required this.flightMode,
|
||||||
this.activeWaypointIndex,
|
this.activeWaypointIndex,
|
||||||
});
|
});
|
||||||
@@ -65,6 +68,25 @@ class TelemetryFrame {
|
|||||||
/// Empfaenger-Linkqualitaet in Prozent (0-100).
|
/// Empfaenger-Linkqualitaet in Prozent (0-100).
|
||||||
final int linkQuality;
|
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
|
/// Menschenlesbare Kurzbeschreibung des Navigationsmodus (z.B. "Waypoint
|
||||||
/// mission (en route)", "Return to home (climbing)", "Idle") - siehe
|
/// mission (en route)", "Return to home (climbing)", "Idle") - siehe
|
||||||
/// MspNavMode.formatted in msp_telemetry_codec.dart.
|
/// MspNavMode.formatted in msp_telemetry_codec.dart.
|
||||||
|
|||||||
@@ -96,6 +96,12 @@ class MockFlightControllerLink implements FlightControllerLink {
|
|||||||
armed: _armed,
|
armed: _armed,
|
||||||
batteryPercent: _batteryPercent.round(),
|
batteryPercent: _batteryPercent.round(),
|
||||||
linkQuality: 95,
|
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
|
flightMode: _activeWaypointIndex != null
|
||||||
? 'Waypoint mission (en route)'
|
? 'Waypoint mission (en route)'
|
||||||
: 'Idle',
|
: 'Idle',
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class MspGpsReading {
|
|||||||
required this.lon,
|
required this.lon,
|
||||||
required this.speedMs,
|
required this.speedMs,
|
||||||
required this.headingDeg,
|
required this.headingDeg,
|
||||||
|
required this.hdop,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 0 = kein Fix (`fc_msp.c`: `gpsSol.fixType`).
|
/// 0 = kein Fix (`fc_msp.c`: `gpsSol.fixType`).
|
||||||
@@ -24,6 +25,13 @@ class MspGpsReading {
|
|||||||
/// `groundCourse` - nur bei [hasFix] aussagekraeftig.
|
/// `groundCourse` - nur bei [hasFix] aussagekraeftig.
|
||||||
final double headingDeg;
|
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;
|
bool get hasFix => fixType != 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +45,7 @@ MspGpsReading parseMspRawGps(Uint8List payload) {
|
|||||||
lon: data.getInt32(6, Endian.little) / 1e7,
|
lon: data.getInt32(6, Endian.little) / 1e7,
|
||||||
speedMs: data.getUint16(12, Endian.little) / 100.0,
|
speedMs: data.getUint16(12, Endian.little) / 100.0,
|
||||||
headingDeg: data.getUint16(14, Endian.little) / 10.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`
|
/// Empfaenger-Linkqualitaet in Prozent (Offset 1, `rxLinkStatistics.uplinkLQ`
|
||||||
/// - bereits 0-100, siehe [MspCommands.inavLinkStats]).
|
/// - bereits 0-100, siehe [MspCommands.inavLinkStats]).
|
||||||
int parseMspLinkQuality(Uint8List payload) => payload[1];
|
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? _activeWaypointIndex;
|
||||||
int _batteryPercent = 0;
|
int _batteryPercent = 0;
|
||||||
int _linkQuality = 0;
|
int _linkQuality = 0;
|
||||||
|
int _snrDb = 0;
|
||||||
|
int _navMode = 0;
|
||||||
String _flightMode = 'Idle';
|
String _flightMode = 'Idle';
|
||||||
|
|
||||||
Stream<TelemetryFrame> get frames => _framesController.stream;
|
Stream<TelemetryFrame> get frames => _framesController.stream;
|
||||||
@@ -81,13 +83,15 @@ class MspTelemetryPoller {
|
|||||||
);
|
);
|
||||||
final navStatusPayload = await _client.request(MspCommands.navStatus);
|
final navStatusPayload = await _client.request(MspCommands.navStatus);
|
||||||
_activeWaypointIndex = parseMspNavStatusActiveWaypoint(navStatusPayload);
|
_activeWaypointIndex = parseMspNavStatusActiveWaypoint(navStatusPayload);
|
||||||
_flightMode = parseMspNavMode(navStatusPayload).formatted;
|
final navMode = parseMspNavMode(navStatusPayload);
|
||||||
|
_navMode = navMode.mode;
|
||||||
|
_flightMode = navMode.formatted;
|
||||||
_batteryPercent = parseMspInavAnalogBatteryPercent(
|
_batteryPercent = parseMspInavAnalogBatteryPercent(
|
||||||
await _client.request(MspCommands.inavAnalog),
|
await _client.request(MspCommands.inavAnalog),
|
||||||
);
|
);
|
||||||
_linkQuality = parseMspLinkQuality(
|
final linkStatsPayload = await _client.request(MspCommands.inavLinkStats);
|
||||||
await _client.request(MspCommands.inavLinkStats),
|
_linkQuality = parseMspLinkQuality(linkStatsPayload);
|
||||||
);
|
_snrDb = parseMspLinkStatsSnr(linkStatsPayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_framesController.isClosed) {
|
if (!_framesController.isClosed) {
|
||||||
@@ -102,6 +106,9 @@ class MspTelemetryPoller {
|
|||||||
armed: _armed,
|
armed: _armed,
|
||||||
batteryPercent: _batteryPercent,
|
batteryPercent: _batteryPercent,
|
||||||
linkQuality: _linkQuality,
|
linkQuality: _linkQuality,
|
||||||
|
snrDb: _snrDb,
|
||||||
|
hdop: gps.hdop,
|
||||||
|
navMode: _navMode,
|
||||||
flightMode: _flightMode,
|
flightMode: _flightMode,
|
||||||
activeWaypointIndex: _activeWaypointIndex,
|
activeWaypointIndex: _activeWaypointIndex,
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'package:latlong2/latlong.dart';
|
|||||||
import '../../../domain/mission/mission_stats.dart';
|
import '../../../domain/mission/mission_stats.dart';
|
||||||
import '../../../domain/mission/mission_warnings.dart';
|
import '../../../domain/mission/mission_warnings.dart';
|
||||||
import '../../../domain/mission/route_geometry.dart';
|
import '../../../domain/mission/route_geometry.dart';
|
||||||
|
import '../../../domain/telemetry/drone_status.dart';
|
||||||
import '../../../domain/wind/wind_math.dart';
|
import '../../../domain/wind/wind_math.dart';
|
||||||
import '../../../transport/flight_controller_link.dart';
|
import '../../../transport/flight_controller_link.dart';
|
||||||
import '../../providers/active_drone_profile_provider.dart';
|
import '../../providers/active_drone_profile_provider.dart';
|
||||||
@@ -239,6 +240,21 @@ class _FlyScreenState extends ConsumerState<FlyScreen> {
|
|||||||
currentSpeedMs: telemetry?.speedMs,
|
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(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
MissionMap(
|
MissionMap(
|
||||||
@@ -287,6 +303,7 @@ class _FlyScreenState extends ConsumerState<FlyScreen> {
|
|||||||
totalDistanceM: missionStats.distanceM,
|
totalDistanceM: missionStats.distanceM,
|
||||||
totalDurationSec: missionStats.durationSec,
|
totalDurationSec: missionStats.durationSec,
|
||||||
flyMissionStatus: flyMissionStatus,
|
flyMissionStatus: flyMissionStatus,
|
||||||
|
droneStatus: droneStatus,
|
||||||
warnings: warnings,
|
warnings: warnings,
|
||||||
showWarningsBanner: false,
|
showWarningsBanner: false,
|
||||||
batteryPercent: telemetry?.batteryPercent,
|
batteryPercent: telemetry?.batteryPercent,
|
||||||
|
|||||||
@@ -3,13 +3,29 @@ import 'dart:math' as math;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../domain/mission/mission_stats.dart';
|
|
||||||
import '../../domain/mission/mission_warnings.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/active_drone_profile_provider.dart';
|
||||||
import '../providers/altitude_profile_provider.dart';
|
import '../providers/altitude_profile_provider.dart';
|
||||||
import '../providers/mission_meta_provider.dart';
|
import '../providers/mission_meta_provider.dart';
|
||||||
import 'missions_drones_panel.dart';
|
import 'missions_drones_panel.dart';
|
||||||
import 'mountain_peak_icon.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/
|
/// Statuszeile am unteren Bildschirmrand (HTML-Demonstrator: #bottomBar/
|
||||||
/// #statsRow). Details-Pille bleibt mittig und oeffnet die Wegpunktliste;
|
/// #statsRow). Details-Pille bleibt mittig und oeffnet die Wegpunktliste;
|
||||||
@@ -27,6 +43,7 @@ class BottomStatsBar extends ConsumerWidget {
|
|||||||
required this.totalDistanceM,
|
required this.totalDistanceM,
|
||||||
required this.totalDurationSec,
|
required this.totalDurationSec,
|
||||||
this.flyMissionStatus,
|
this.flyMissionStatus,
|
||||||
|
this.droneStatus,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
required this.onSettingsTap,
|
required this.onSettingsTap,
|
||||||
this.warnings = const [],
|
this.warnings = const [],
|
||||||
@@ -40,6 +57,11 @@ class BottomStatsBar extends ConsumerWidget {
|
|||||||
/// Siehe MissionFooterBar-Doku - nur im Fly-Modus gesetzt, ersetzt dann
|
/// Siehe MissionFooterBar-Doku - nur im Fly-Modus gesetzt, ersetzt dann
|
||||||
/// die statischen Felder in der Details-Pille.
|
/// die statischen Felder in der Details-Pille.
|
||||||
final FlyMissionStatus? flyMissionStatus;
|
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;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
/// Oeffnet das Settings-Menue (runder Zahnrad-Knopf rechts der
|
/// Oeffnet das Settings-Menue (runder Zahnrad-Knopf rechts der
|
||||||
@@ -108,14 +130,16 @@ class BottomStatsBar extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: _footerElementSpacing),
|
||||||
_AltitudeProfileToggleButton(
|
_AltitudeProfileToggleButton(
|
||||||
isExpanded: isAltitudeProfileExpanded,
|
isExpanded: isAltitudeProfileExpanded,
|
||||||
onTap: () => ref
|
onTap: () => ref
|
||||||
.read(altitudeProfileExpandedProvider.notifier)
|
.read(altitudeProfileExpandedProvider.notifier)
|
||||||
.toggle(),
|
.toggle(),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: _footerElementSpacing),
|
||||||
_detailsPill(),
|
_detailsPill(),
|
||||||
|
const SizedBox(width: _footerElementSpacing),
|
||||||
// Drohnen-Pille + Warnungs-/Settings-Knopf rechts. Die Pille
|
// Drohnen-Pille + Warnungs-/Settings-Knopf rechts. Die Pille
|
||||||
// sitzt in Flexible, damit sie bei langen Namen zu Gunsten der
|
// sitzt in Flexible, damit sie bei langen Namen zu Gunsten der
|
||||||
// beiden festgroessen Rundknoepfe schrumpft/ellipsiert, statt
|
// beiden festgroessen Rundknoepfe schrumpft/ellipsiert, statt
|
||||||
@@ -127,19 +151,31 @@ class BottomStatsBar extends ConsumerWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Flexible(
|
Flexible(
|
||||||
child: _NameChip(
|
child: droneStatus != null
|
||||||
label: 'Drone: $droneName',
|
? _DroneStatusPill(
|
||||||
color: const Color(0xFF4D9DFF).withValues(alpha: 0.18),
|
status: droneStatus!,
|
||||||
textColor: const Color(0xFFCFE2FF),
|
onTap: () => Navigator.of(context).push(MaterialPageRoute(
|
||||||
onTap: () => Navigator.of(context).push(MaterialPageRoute(
|
builder: (_) => const MissionsDronesPanel.drones(),
|
||||||
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) ...[
|
if (warnings.isNotEmpty) ...[
|
||||||
_WarningButton(onTap: onWarningsTap),
|
_WarningButton(onTap: onWarningsTap),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: _footerElementSpacing),
|
||||||
],
|
],
|
||||||
_SettingsButton(onTap: onSettingsTap),
|
_SettingsButton(onTap: onSettingsTap),
|
||||||
],
|
],
|
||||||
@@ -176,10 +212,11 @@ class BottomStatsBar extends ConsumerWidget {
|
|||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 9),
|
height: _footerElementHeight,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withValues(alpha: 0.12),
|
color: Colors.white.withValues(alpha: 0.12),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(_footerElementHeight / 2),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -240,8 +277,8 @@ class _AltitudeProfileToggleButton extends StatelessWidget {
|
|||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 34,
|
width: _footerElementHeight,
|
||||||
height: 34,
|
height: _footerElementHeight,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: Colors.white.withValues(alpha: 0.12),
|
color: Colors.white.withValues(alpha: 0.12),
|
||||||
@@ -270,8 +307,8 @@ class _WarningButton extends StatelessWidget {
|
|||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 34,
|
width: _footerElementHeight,
|
||||||
height: 34,
|
height: _footerElementHeight,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: const Color(0xFFE2574A).withValues(alpha: 0.18),
|
color: const Color(0xFFE2574A).withValues(alpha: 0.18),
|
||||||
@@ -295,8 +332,8 @@ class _SettingsButton extends StatelessWidget {
|
|||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 34,
|
width: _footerElementHeight,
|
||||||
height: 34,
|
height: _footerElementHeight,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: Colors.white.withValues(alpha: 0.12),
|
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 {
|
class _NameChip extends StatelessWidget {
|
||||||
const _NameChip({
|
const _NameChip({
|
||||||
required this.label,
|
required this.label,
|
||||||
@@ -328,10 +459,12 @@ class _NameChip extends StatelessWidget {
|
|||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
|
height: _footerElementHeight,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color,
|
color: color,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(_footerElementHeight / 2),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
label,
|
label,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../../domain/mission/mission_stats.dart';
|
import '../../domain/mission/mission_stats.dart';
|
||||||
import '../../domain/mission/mission_warnings.dart';
|
import '../../domain/mission/mission_warnings.dart';
|
||||||
|
import '../../domain/telemetry/drone_status.dart';
|
||||||
import '../../domain/waypoint/flat_waypoint_list.dart';
|
import '../../domain/waypoint/flat_waypoint_list.dart';
|
||||||
import '../providers/altitude_profile_provider.dart';
|
import '../providers/altitude_profile_provider.dart';
|
||||||
import 'battery_indicator_bar.dart';
|
import 'battery_indicator_bar.dart';
|
||||||
@@ -23,6 +24,7 @@ class MissionFooterBar extends ConsumerWidget {
|
|||||||
required this.totalDistanceM,
|
required this.totalDistanceM,
|
||||||
required this.totalDurationSec,
|
required this.totalDurationSec,
|
||||||
this.flyMissionStatus,
|
this.flyMissionStatus,
|
||||||
|
this.droneStatus,
|
||||||
required this.warnings,
|
required this.warnings,
|
||||||
this.showWarningsBanner = true,
|
this.showWarningsBanner = true,
|
||||||
this.batteryPercent,
|
this.batteryPercent,
|
||||||
@@ -43,6 +45,10 @@ class MissionFooterBar extends ConsumerWidget {
|
|||||||
/// (Wegpunktzahl/Gesamtdistanz/-dauer) durch aktiven Wegpunkt sowie
|
/// (Wegpunktzahl/Gesamtdistanz/-dauer) durch aktiven Wegpunkt sowie
|
||||||
/// Distanz/Zeit dorthin. Bleibt im Plan-Modus null.
|
/// Distanz/Zeit dorthin. Bleibt im Plan-Modus null.
|
||||||
final FlyMissionStatus? flyMissionStatus;
|
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;
|
final List<MissionWarning> warnings;
|
||||||
|
|
||||||
/// Blendet das Text-Warnbanner ueber der Fusszeile aus (Doku: "Banner mit
|
/// Blendet das Text-Warnbanner ueber der Fusszeile aus (Doku: "Banner mit
|
||||||
@@ -105,6 +111,7 @@ class MissionFooterBar extends ConsumerWidget {
|
|||||||
totalDistanceM: totalDistanceM,
|
totalDistanceM: totalDistanceM,
|
||||||
totalDurationSec: totalDurationSec,
|
totalDurationSec: totalDurationSec,
|
||||||
flyMissionStatus: flyMissionStatus,
|
flyMissionStatus: flyMissionStatus,
|
||||||
|
droneStatus: droneStatus,
|
||||||
onTap: onDetailsTap,
|
onTap: onDetailsTap,
|
||||||
warnings: warnings,
|
warnings: warnings,
|
||||||
onWarningsTap: onWarningsTap,
|
onWarningsTap: onWarningsTap,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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.setInt32(6, 134050000, Endian.little); // lon 13.405
|
||||||
d.setUint16(12, 500, Endian.little); // 5.00 m/s
|
d.setUint16(12, 500, Endian.little); // 5.00 m/s
|
||||||
d.setUint16(14, 2700, Endian.little); // 270.0 deg
|
d.setUint16(14, 2700, Endian.little); // 270.0 deg
|
||||||
|
d.setUint16(16, 150, Endian.little); // HDOP 1.50
|
||||||
payload = d.buffer.asUint8List();
|
payload = d.buffer.asUint8List();
|
||||||
case MspCommands.altitude:
|
case MspCommands.altitude:
|
||||||
final d = ByteData(10);
|
final d = ByteData(10);
|
||||||
@@ -52,6 +53,7 @@ Timer _startFakeFlightController(
|
|||||||
case MspCommands.inavLinkStats:
|
case MspCommands.inavLinkStats:
|
||||||
final d = ByteData(3);
|
final d = ByteData(3);
|
||||||
d.setUint8(1, 88); // Linkqualitaet %
|
d.setUint8(1, 88); // Linkqualitaet %
|
||||||
|
d.setInt8(2, 6); // SNR 6 dB
|
||||||
payload = d.buffer.asUint8List();
|
payload = d.buffer.asUint8List();
|
||||||
default:
|
default:
|
||||||
payload = Uint8List(0);
|
payload = Uint8List(0);
|
||||||
@@ -88,6 +90,9 @@ void main() {
|
|||||||
expect(frame.headingDeg, closeTo(270.0, 1e-6));
|
expect(frame.headingDeg, closeTo(270.0, 1e-6));
|
||||||
expect(frame.batteryPercent, 77);
|
expect(frame.batteryPercent, 77);
|
||||||
expect(frame.linkQuality, 88);
|
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)');
|
expect(frame.flightMode, 'Waypoint mission (en route to WP)');
|
||||||
// Fake-FC meldet activeWpNumber=3 (1-basiert) - als 0-basierter Index
|
// Fake-FC meldet activeWpNumber=3 (1-basiert) - als 0-basierter Index
|
||||||
// in die App-eigene Wegpunktliste entspricht das Index 2.
|
// 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() {
|
void main() {
|
||||||
group('parseMspRawGps', () {
|
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,
|
// fixType=2, numSat=9, lat=52.5200000 deg, lon=13.4050000 deg,
|
||||||
// alt=100m (ungenutzt), groundSpeed=1234 cm/s, groundCourse=900 (0.1deg,
|
// 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);
|
final data = ByteData(18);
|
||||||
data.setUint8(0, 2);
|
data.setUint8(0, 2);
|
||||||
data.setUint8(1, 9);
|
data.setUint8(1, 9);
|
||||||
@@ -29,6 +29,7 @@ void main() {
|
|||||||
expect(reading.lon, closeTo(13.405, 1e-9));
|
expect(reading.lon, closeTo(13.405, 1e-9));
|
||||||
expect(reading.speedMs, closeTo(12.34, 1e-9));
|
expect(reading.speedMs, closeTo(12.34, 1e-9));
|
||||||
expect(reading.headingDeg, closeTo(90.0, 1e-9));
|
expect(reading.headingDeg, closeTo(90.0, 1e-9));
|
||||||
|
expect(reading.hdop, closeTo(1.2, 1e-9));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fixType 0 bedeutet kein Fix', () {
|
test('fixType 0 bedeutet kein Fix', () {
|
||||||
@@ -142,4 +143,20 @@ void main() {
|
|||||||
expect(parseMspLinkQuality(payload), 87);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user