From 8e4df2d0d33ad3e7d46ae434bc8b24c49f829ffe Mon Sep 17 00:00:00 2001 From: Constantin Leue <77660791+Consti3D@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:15:56 +0200 Subject: [PATCH] drone status menue with status and critical event log implemented --- ....Design Journal Drone Mission Control.odp# | 2 +- Design Journal Drone Mission Control.odp | Bin 2549722 -> 2549811 bytes app/lib/domain/telemetry/drone_event.dart | 72 ++++++ app/lib/transport/flight_controller_link.dart | 10 + .../mock/mock_flight_controller_link.dart | 5 + .../transport/msp/msp_telemetry_codec.dart | 14 ++ .../transport/msp/msp_telemetry_poller.dart | 11 +- .../providers/drone_event_log_provider.dart | 64 ++++++ app/lib/ui/screens/fly/fly_screen.dart | 29 +-- app/lib/ui/widgets/bottom_stats_bar.dart | 21 +- .../widgets/drone_status_warnings_panel.dart | 215 ++++++++++++++++++ app/lib/ui/widgets/mission_footer_bar.dart | 5 + app/test/domain/drone_event_test.dart | 112 +++++++++ .../msp/msp_flight_controller_link_test.dart | 4 + .../msp/msp_telemetry_codec_test.dart | 31 +++ 15 files changed, 571 insertions(+), 24 deletions(-) create mode 100644 app/lib/domain/telemetry/drone_event.dart create mode 100644 app/lib/ui/providers/drone_event_log_provider.dart create mode 100644 app/lib/ui/widgets/drone_status_warnings_panel.dart create mode 100644 app/test/domain/drone_event_test.dart diff --git a/.~lock.Design Journal Drone Mission Control.odp# b/.~lock.Design Journal Drone Mission Control.odp# index e23a271..d088ecb 100644 --- a/.~lock.Design Journal Drone Mission Control.odp# +++ b/.~lock.Design Journal Drone Mission Control.odp# @@ -1 +1 @@ -,AI/Consti,AI,04.08.2026 12:11,file:///C:/Users/Consti/AppData/Roaming/LibreOffice/4; \ No newline at end of file +,AI/Consti,AI,04.08.2026 16:22,file:///C:/Users/Consti/AppData/Roaming/LibreOffice/4; \ No newline at end of file diff --git a/Design Journal Drone Mission Control.odp b/Design Journal Drone Mission Control.odp index 131e66fc1727bee3c85cb9f3eb5ef1e55f91486a..cef65f4848a45b3ad70296d290f10ecd774d8b21 100644 GIT binary patch delta 14766 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 delta 14635 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 diff --git a/app/lib/domain/telemetry/drone_event.dart b/app/lib/domain/telemetry/drone_event.dart new file mode 100644 index 0000000..3a1f7e1 --- /dev/null +++ b/app/lib/domain/telemetry/drone_event.dart @@ -0,0 +1,72 @@ +import '../../transport/flight_controller_link.dart'; + +/// Schweregrad eines Eintrags im Ereignis-Log (Doku: "log von kritischen +/// ereignissen mit time stamp ... connection lost, battery low/critical, +/// fail safe mode activated"). +enum DroneEventSeverity { warning, critical } + +/// Ein einzelner Log-Eintrag mit Zeitstempel (Doku: "Warnings Tab ... log +/// von kritischen Ereignissen mit time stamp"). +class DroneEvent { + const DroneEvent({ + required this.timestamp, + required this.message, + required this.severity, + }); + + final DateTime timestamp; + final String message; + final DroneEventSeverity severity; +} + +/// Erkennt Uebergaenge zwischen zwei aufeinanderfolgenden Telemetrie-Frames, +/// die einen Log-Eintrag wert sind - flankengetriggert (nur beim +/// Ueberschreiten einer Schwelle, nicht bei jedem Frame, solange der +/// Zustand anhaelt). "Connection lost" gehoert NICHT hierher, da es keinen +/// [current]-Frame gibt, wenn die Verbindung abreisst - das wird auf +/// Provider-Ebene aus dem Stream-Fehler abgeleitet (siehe +/// drone_event_log_provider.dart). +/// +/// Batterie-Schwellen identisch zu den Ampelfarben der BatteryIndicatorBar +/// (20%/30%) - dieselbe Semantik, nur zusaetzlich protokolliert statt nur +/// angezeigt. "kritisch" hat Vorrang: wird beim selben Uebergang sowohl die +/// 30%- als auch die 20%-Schwelle unterschritten (z.B. Ladestand sackt in +/// einem Zyklus von 35% auf 15%), wird nur der kritische Eintrag erzeugt. +List detectDroneEvents({ + required TelemetryFrame? previous, + required TelemetryFrame current, + required DateTime now, +}) { + final events = []; + + if (previous != null && + previous.batteryPercent > 20 && + current.batteryPercent <= 20) { + events.add(DroneEvent( + timestamp: now, + message: 'Battery critical (${current.batteryPercent}%)', + severity: DroneEventSeverity.critical, + )); + } else if (previous != null && + previous.batteryPercent > 30 && + current.batteryPercent <= 30) { + events.add(DroneEvent( + timestamp: now, + message: 'Battery low (${current.batteryPercent}%)', + severity: DroneEventSeverity.warning, + )); + } + + const navModeEmergency = 15; + if (previous != null && + previous.navMode != navModeEmergency && + current.navMode == navModeEmergency) { + events.add(DroneEvent( + timestamp: now, + message: 'Failsafe mode activated', + severity: DroneEventSeverity.critical, + )); + } + + return events; +} diff --git a/app/lib/transport/flight_controller_link.dart b/app/lib/transport/flight_controller_link.dart index a3822a2..0332709 100644 --- a/app/lib/transport/flight_controller_link.dart +++ b/app/lib/transport/flight_controller_link.dart @@ -35,6 +35,8 @@ class TelemetryFrame { required this.headingDeg, required this.armed, required this.batteryPercent, + required this.batteryVoltage, + required this.currentA, required this.linkQuality, required this.snrDb, required this.hdop, @@ -65,6 +67,14 @@ class TelemetryFrame { /// Akku-Ladestand in Prozent (0-100). final int batteryPercent; + /// Akkuspannung in Volt (siehe parseMspInavAnalogVoltage in + /// msp_telemetry_codec.dart). + final double batteryVoltage; + + /// Aktuelle Stromaufnahme in Ampere (siehe parseMspInavAnalogAmperage in + /// msp_telemetry_codec.dart). + final double currentA; + /// Empfaenger-Linkqualitaet in Prozent (0-100). final int linkQuality; diff --git a/app/lib/transport/mock/mock_flight_controller_link.dart b/app/lib/transport/mock/mock_flight_controller_link.dart index e696dcd..59a83d8 100644 --- a/app/lib/transport/mock/mock_flight_controller_link.dart +++ b/app/lib/transport/mock/mock_flight_controller_link.dart @@ -95,6 +95,11 @@ class MockFlightControllerLink implements FlightControllerLink { headingDeg: _heading, armed: _armed, batteryPercent: _batteryPercent.round(), + // 6S-Akku (22.2V nominal), Spannung sinkt mit dem Mock-Ladestand + // mit, damit auch die neue Statusanzeige (Doku: "Drone battery + // Voltage") auf Werteaenderungen reagiert. + batteryVoltage: 18.0 + (_batteryPercent / 100) * 6.0, + currentA: 12.5, linkQuality: 95, snrDb: 9, hdop: 0.9, diff --git a/app/lib/transport/msp/msp_telemetry_codec.dart b/app/lib/transport/msp/msp_telemetry_codec.dart index 318664a..d5f76b2 100644 --- a/app/lib/transport/msp/msp_telemetry_codec.dart +++ b/app/lib/transport/msp/msp_telemetry_codec.dart @@ -138,6 +138,20 @@ MspNavMode parseMspNavMode(Uint8List payload) { /// 0-100 berechnet, siehe [MspCommands.inavAnalog]). int parseMspInavAnalogBatteryPercent(Uint8List payload) => payload[21]; +/// Parst die `MSP2_INAV_ANALOG`-Antwort und liefert die Akkuspannung in Volt +/// (Offset 1, u16 in 0.01V-Schritten - `battery.c`: `static uint16_t vbat`, +/// Kommentar "battery voltage in 0.01V steps", siehe [MspCommands.inavAnalog]). +double parseMspInavAnalogVoltage(Uint8List payload) => + ByteData.sublistView(payload).getUint16(1, Endian.little) / 100.0; + +/// Parst die `MSP2_INAV_ANALOG`-Antwort und liefert die aktuelle +/// Stromaufnahme in Ampere (Offset 3, `fc_msp.c`: `sbufWriteU16(dst, +/// getAmperage())` - "send amperage in 0.01 A steps", `getAmperage()` selbst +/// liefert `int16_t`, deshalb vorzeichenbehaftet gelesen, siehe +/// [MspCommands.inavAnalog]). +double parseMspInavAnalogAmperage(Uint8List payload) => + ByteData.sublistView(payload).getInt16(3, Endian.little) / 100.0; + /// Parst die `MSP2_INAV_GET_LINK_STATS`-Antwort und liefert die /// Empfaenger-Linkqualitaet in Prozent (Offset 1, `rxLinkStatistics.uplinkLQ` /// - bereits 0-100, siehe [MspCommands.inavLinkStats]). diff --git a/app/lib/transport/msp/msp_telemetry_poller.dart b/app/lib/transport/msp/msp_telemetry_poller.dart index 9d1de42..f8f31d7 100644 --- a/app/lib/transport/msp/msp_telemetry_poller.dart +++ b/app/lib/transport/msp/msp_telemetry_poller.dart @@ -39,6 +39,8 @@ class MspTelemetryPoller { bool _armed = false; int? _activeWaypointIndex; int _batteryPercent = 0; + double _batteryVoltage = 0; + double _currentA = 0; int _linkQuality = 0; int _snrDb = 0; int _navMode = 0; @@ -86,9 +88,10 @@ class MspTelemetryPoller { final navMode = parseMspNavMode(navStatusPayload); _navMode = navMode.mode; _flightMode = navMode.formatted; - _batteryPercent = parseMspInavAnalogBatteryPercent( - await _client.request(MspCommands.inavAnalog), - ); + final analogPayload = await _client.request(MspCommands.inavAnalog); + _batteryPercent = parseMspInavAnalogBatteryPercent(analogPayload); + _batteryVoltage = parseMspInavAnalogVoltage(analogPayload); + _currentA = parseMspInavAnalogAmperage(analogPayload); final linkStatsPayload = await _client.request(MspCommands.inavLinkStats); _linkQuality = parseMspLinkQuality(linkStatsPayload); _snrDb = parseMspLinkStatsSnr(linkStatsPayload); @@ -105,6 +108,8 @@ class MspTelemetryPoller { headingDeg: gps.headingDeg, armed: _armed, batteryPercent: _batteryPercent, + batteryVoltage: _batteryVoltage, + currentA: _currentA, linkQuality: _linkQuality, snrDb: _snrDb, hdop: gps.hdop, diff --git a/app/lib/ui/providers/drone_event_log_provider.dart b/app/lib/ui/providers/drone_event_log_provider.dart new file mode 100644 index 0000000..41ec73f --- /dev/null +++ b/app/lib/ui/providers/drone_event_log_provider.dart @@ -0,0 +1,64 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../domain/telemetry/drone_event.dart'; +import '../../transport/flight_controller_link.dart'; +import 'telemetry_provider.dart'; + +/// Fuehrt das Ereignis-Log fuer den neuen Warnings-Tab (Doku: +/// "log von kritischen ereignissen mit time stamp") - beobachtet +/// [telemetryProvider] dauerhaft (bewusst KEIN autoDispose, siehe +/// telemetry_provider.dart-Doku zur analogen Begruendung bei +/// flightControllerLinkProvider), damit ein Ereignis auch dann protokolliert +/// wird, wenn die Warnings-Ansicht selbst gerade nicht geoeffnet ist. +/// Beginnt zu beobachten, sobald irgendein Widget diesen Provider erstmals +/// liest (siehe FlyScreen) - vor diesem Zeitpunkt liegende Ereignisse +/// erscheinen nicht im Log, das ist fuer ein Live-Log erwartbar. +class DroneEventLogNotifier extends Notifier> { + TelemetryFrame? _previousFrame; + bool _hadData = false; + + @override + List build() { + ref.listen>(telemetryProvider, (previous, next) { + next.when( + data: (frame) { + final events = detectDroneEvents( + previous: _previousFrame, + current: frame, + now: DateTime.now(), + ); + _previousFrame = frame; + _hadData = true; + if (events.isNotEmpty) { + state = [...events.reversed, ...state]; + } + }, + error: (error, stackTrace) { + // Nur melden, wenn zuvor bereits Frames flossen - sonst wuerde + // schon das (normale) Ausbleiben der allerersten Verbindung als + // "Connection lost" auftauchen. + if (_hadData) { + state = [ + DroneEvent( + timestamp: DateTime.now(), + message: 'Connection lost', + severity: DroneEventSeverity.critical, + ), + ...state, + ]; + } + _previousFrame = null; + _hadData = false; + }, + loading: () {}, + ); + }); + return []; + } + + void clear() => state = []; +} + +final droneEventLogProvider = + NotifierProvider>( + DroneEventLogNotifier.new); diff --git a/app/lib/ui/screens/fly/fly_screen.dart b/app/lib/ui/screens/fly/fly_screen.dart index 37740e7..0ad03f3 100644 --- a/app/lib/ui/screens/fly/fly_screen.dart +++ b/app/lib/ui/screens/fly/fly_screen.dart @@ -14,16 +14,17 @@ import '../../../domain/wind/wind_math.dart'; import '../../../transport/flight_controller_link.dart'; import '../../providers/active_drone_profile_provider.dart'; import '../../providers/current_mission_provider.dart'; +import '../../providers/drone_event_log_provider.dart'; import '../../providers/follow_drone_provider.dart'; import '../../providers/map_controller_provider.dart'; import '../../providers/telemetry_provider.dart'; import '../../providers/terrain_provider.dart'; import '../settings/settings_screen.dart'; import '../../widgets/drone_marker_icon.dart'; +import '../../widgets/drone_status_warnings_panel.dart'; import '../../widgets/mission_footer_bar.dart'; import '../../widgets/mission_map.dart'; import '../../widgets/waypoint_list_panel.dart'; -import '../../widgets/warnings_panel.dart'; /// Fly-Modus (Architektur-Doku 3.10/3.11): zeigt dieselbe Karte + Fusszeile /// wie der Plan-Modus, aber ohne Reticle/Draeder - im Flug wird die Route @@ -116,23 +117,9 @@ class _FlyScreenState extends ConsumerState { ); } - void _openWarningsPanel(List warnings) { + void _openDroneStatusWarningsPanel() { Navigator.of(context).push(MaterialPageRoute( - builder: (routeContext) => WarningsPanel( - warnings: warnings, - onClose: () => Navigator.of(routeContext).pop(), - onWarningTap: (warning) { - Navigator.of(routeContext).pop(); - switch (warning.action) { - case WarningAction.altitude: - _openWaypointList(initialTab: PanelTab.altitude); - case WarningAction.speed: - _openWaypointList(initialTab: PanelTab.speed); - case WarningAction.map: - fitMapToWaypoints(ref, ref.read(currentMissionProvider)); - } - }, - ), + builder: (_) => const DroneStatusWarningsPanel.warnings(), )); } @@ -142,6 +129,11 @@ class _FlyScreenState extends ConsumerState { final droneProfile = ref.watch(activeDroneProfileProvider); final telemetry = ref.watch(telemetryProvider).value; final followDrone = ref.watch(followDroneProvider); + // Beobachtet das Ereignis-Log dauerhaft ab dem ersten Eintritt in den + // Fly-Modus (Doku: "log von kritischen ereignissen mit time stamp") - + // haelt drone_event_log_provider.dart's Notifier am Leben, solange + // dieser Screen existiert, siehe dessen Klassendoku. + final droneEventLog = ref.watch(droneEventLogProvider); // Solange der ueber den GPS-Knopf in der Kopfleiste (FlyMapControls) // aktivierte Folgemodus aktiv ist, jede neue Telemetrie-Position @@ -307,8 +299,9 @@ class _FlyScreenState extends ConsumerState { warnings: warnings, showWarningsBanner: false, batteryPercent: telemetry?.batteryPercent, + forceShowWarningsButton: droneEventLog.isNotEmpty, onDetailsTap: _openWaypointList, - onWarningsTap: () => _openWarningsPanel(warnings), + onWarningsTap: _openDroneStatusWarningsPanel, onSettingsTap: () => Navigator.of(context).push(MaterialPageRoute( builder: (_) => const SettingsScreen(), )), diff --git a/app/lib/ui/widgets/bottom_stats_bar.dart b/app/lib/ui/widgets/bottom_stats_bar.dart index 7bf32e8..d7512ba 100644 --- a/app/lib/ui/widgets/bottom_stats_bar.dart +++ b/app/lib/ui/widgets/bottom_stats_bar.dart @@ -10,6 +10,7 @@ import '../dmc_colors.dart'; import '../providers/active_drone_profile_provider.dart'; import '../providers/altitude_profile_provider.dart'; import '../providers/mission_meta_provider.dart'; +import 'drone_status_warnings_panel.dart'; import 'missions_drones_panel.dart'; import 'mountain_peak_icon.dart'; import 'paper_plane_icon.dart'; @@ -59,6 +60,7 @@ class BottomStatsBar extends ConsumerWidget { required this.onSettingsTap, this.warnings = const [], this.onWarningsTap, + this.forceShowWarningsButton = false, }); final int waypointCount; @@ -85,6 +87,14 @@ class BottomStatsBar extends ConsumerWidget { final List warnings; final VoidCallback? onWarningsTap; + /// Zeigt den Warnungs-Knopf auch ohne Mission-Warnungen (Doku: "der + /// warnings tab laesst sich direkt erreichen ueber den existierenden + /// warnings button") - im Fly-Modus sonst unerreichbar, sobald die + /// Mission selbst keine Plan-Warnungen (Speed/Altitude/Range) hat, aber + /// bereits kritische Live-Ereignisse (Doku: "log von kritischen + /// ereignissen") vorliegen. Im Plan-Modus unveraendert false. + final bool forceShowWarningsButton; + @override Widget build(BuildContext context, WidgetRef ref) { final missionName = ref.watch(currentMissionMetaProvider).name; @@ -165,8 +175,15 @@ class BottomStatsBar extends ConsumerWidget { child: droneStatus != null ? _DroneStatusPill( status: droneStatus!, + // Oeffnet im Fly-Modus das neue Status-Menue + // (Doku: "ein neues menue anlegen ... nach dem + // vorbild des menues fuer drones and + // missions") direkt auf dem Status-Tab, statt + // wie zuvor die Drohnenprofil-Verwaltung - + // Profile werden waehrend des Fluges ohnehin + // nicht gewechselt. onTap: () => Navigator.of(context).push(MaterialPageRoute( - builder: (_) => const MissionsDronesPanel.drones(), + builder: (_) => const DroneStatusWarningsPanel.status(), )), ) : _NameChip( @@ -184,7 +201,7 @@ class BottomStatsBar extends ConsumerWidget { ), ), const SizedBox(width: _footerElementSpacing), - if (warnings.isNotEmpty) ...[ + if (warnings.isNotEmpty || forceShowWarningsButton) ...[ _WarningButton(onTap: onWarningsTap), const SizedBox(width: _footerElementSpacing), ], diff --git a/app/lib/ui/widgets/drone_status_warnings_panel.dart b/app/lib/ui/widgets/drone_status_warnings_panel.dart new file mode 100644 index 0000000..b0d7c97 --- /dev/null +++ b/app/lib/ui/widgets/drone_status_warnings_panel.dart @@ -0,0 +1,215 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../domain/telemetry/drone_event.dart'; +import '../dmc_colors.dart'; +import '../providers/drone_event_log_provider.dart'; +import '../providers/telemetry_provider.dart'; + +enum _PanelTab { status, warnings } + +/// Vollbild-Statusmenue fuer den Fly-Modus (Doku: "ein neues menue anlegen +/// fuer die neue drone status pille, nach dem vorbild des menues fuer +/// drones and missions, aber jetzt fuer drone status and warnings") - +/// dieselbe Tab-Struktur wie [MissionsDronesPanel], nur mit den beiden +/// telemetriebezogenen Tabs Status/Warnings statt Missions/Drones. +/// Erreichbar ueber die Drohnen-Status-Pille (Status-Tab) bzw. den +/// Warnungs-Knopf (Warnings-Tab) in der Fusszeile im Fly-Modus (siehe +/// bottom_stats_bar.dart/fly_screen.dart). +class DroneStatusWarningsPanel extends ConsumerStatefulWidget { + const DroneStatusWarningsPanel.status({super.key}) : _initialTab = _PanelTab.status; + const DroneStatusWarningsPanel.warnings({super.key}) : _initialTab = _PanelTab.warnings; + + final _PanelTab _initialTab; + + @override + ConsumerState createState() => _DroneStatusWarningsPanelState(); +} + +class _DroneStatusWarningsPanelState extends ConsumerState { + late _PanelTab _tab = widget._initialTab; + + @override + Widget build(BuildContext context) { + return Material( + color: const Color(0xEB080808), + child: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 12), + child: Row( + children: [ + const Expanded( + child: Text( + 'Drone Status & Warnings', + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, color: Colors.white), + ), + ], + ), + ), + Row( + children: [ + _tabButton('Status', _PanelTab.status), + _tabButton('Warnings', _PanelTab.warnings), + ], + ), + const Divider(color: Colors.white24, height: 1), + Expanded( + child: switch (_tab) { + _PanelTab.status => _statusTab(), + _PanelTab.warnings => _warningsTab(), + }, + ), + ], + ), + ), + ); + } + + Widget _tabButton(String label, _PanelTab tab) { + final active = _tab == tab; + return Expanded( + child: InkWell( + onTap: () => setState(() => _tab = tab), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: active ? const Color(0xFF4D9DFF) : Colors.transparent, + width: 2, + ), + ), + ), + child: Text( + label, + textAlign: TextAlign.center, + style: TextStyle( + color: active ? Colors.white : Colors.white60, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ), + ), + ); + } + + // --- Status --- + + Widget _statusTab() { + final telemetry = ref.watch(telemetryProvider); + return telemetry.when( + data: (frame) => ListView( + padding: const EdgeInsets.all(16), + children: [ + _statusRow('GPS satellites', '${frame.numSat}'), + _statusRow( + 'GPS precision (HDOP)', + frame.hasFix ? frame.hdop.toStringAsFixed(2) : '—', + ), + _statusRow( + 'GPS coordinates', + frame.hasFix + ? '${frame.lat.toStringAsFixed(6)}, ${frame.lon.toStringAsFixed(6)}' + : '—', + ), + _statusRow('Current draw', '${frame.currentA.toStringAsFixed(1)} A'), + _statusRow('Battery voltage', '${frame.batteryVoltage.toStringAsFixed(2)} V'), + _statusRow('Heading', '${frame.headingDeg.round()}°'), + _statusRow('RX quality / SNR', '${frame.linkQuality}% / ${frame.snrDb} dB'), + // Downlink-Qualitaet/-SNR und die Akku-SoC des Sendemoduls selbst + // (nicht der Drohne) sind ueber keinen MSP-GET-Befehl abrufbar - + // rxLinkStatistics_t (iNAV 9.1, rx.h) hat zwar ein downlinkLQ-Feld, + // das wird aber nur per MSP2_COMMON_SET_MSP_RC_LINK_STATS von + // aussen HINEINgeschrieben, nie per GET zurueckgelesen; eine + // Downlink-SNR oder eine Sender-Akkugroesse existiert im Struct + // ueberhaupt nicht (verifiziert im lokalen iNAV-Checkout, + // release/9.1). + _statusRow('TX battery SoC / quality / SNR', 'Not available (not exposed via MSP)'), + ], + ), + loading: () => const Center( + child: CircularProgressIndicator(color: Color(0xFF4D9DFF)), + ), + error: (err, st) => const Center( + child: Text('No telemetry', style: TextStyle(color: Colors.white60, fontSize: 15)), + ), + ); + } + + Widget _statusRow(String label, String value) { + return Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Expanded(child: Text(label, style: const TextStyle(color: Colors.white70))), + Text(value, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600)), + ], + ), + ); + } + + // --- Warnings --- + + Widget _warningsTab() { + final events = ref.watch(droneEventLogProvider); + if (events.isEmpty) { + return const Center( + child: Text('No events yet', style: TextStyle(color: Colors.white60, fontSize: 15)), + ); + } + return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + itemCount: events.length, + itemBuilder: (context, index) => _eventRow(events[index]), + ); + } + + Widget _eventRow(DroneEvent event) { + final color = event.severity == DroneEventSeverity.critical + ? DmcColors.warnRed + : DmcColors.pafAmber; + return Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.04), + border: Border.all(color: color.withValues(alpha: 0.4)), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Text(_formatTime(event.timestamp), style: const TextStyle(color: Colors.white60)), + const SizedBox(width: 12), + Expanded( + child: Text( + event.message, + style: TextStyle(color: color, fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } + + String _formatTime(DateTime dt) => + '${dt.hour.toString().padLeft(2, '0')}:' + '${dt.minute.toString().padLeft(2, '0')}:' + '${dt.second.toString().padLeft(2, '0')}'; +} diff --git a/app/lib/ui/widgets/mission_footer_bar.dart b/app/lib/ui/widgets/mission_footer_bar.dart index c62cdb1..ff67a8b 100644 --- a/app/lib/ui/widgets/mission_footer_bar.dart +++ b/app/lib/ui/widgets/mission_footer_bar.dart @@ -28,6 +28,7 @@ class MissionFooterBar extends ConsumerWidget { required this.warnings, this.showWarningsBanner = true, this.batteryPercent, + this.forceShowWarningsButton = false, required this.onDetailsTap, required this.onWarningsTap, required this.onSettingsTap, @@ -63,6 +64,9 @@ class MissionFooterBar extends ConsumerWidget { /// unter einem ggf. ausgeklappten Hoehenprofil. Bleibt im Plan-Modus null /// (keine Telemetrie), die Anzeige entfaellt dann vollstaendig. final int? batteryPercent; + + /// Siehe BottomStatsBar.forceShowWarningsButton. + final bool forceShowWarningsButton; final VoidCallback onDetailsTap; final VoidCallback onWarningsTap; final VoidCallback onSettingsTap; @@ -114,6 +118,7 @@ class MissionFooterBar extends ConsumerWidget { droneStatus: droneStatus, onTap: onDetailsTap, warnings: warnings, + forceShowWarningsButton: forceShowWarningsButton, onWarningsTap: onWarningsTap, onSettingsTap: onSettingsTap, ), diff --git a/app/test/domain/drone_event_test.dart b/app/test/domain/drone_event_test.dart new file mode 100644 index 0000000..37f3ee0 --- /dev/null +++ b/app/test/domain/drone_event_test.dart @@ -0,0 +1,112 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:dmc_app/domain/telemetry/drone_event.dart'; +import 'package:dmc_app/transport/flight_controller_link.dart'; + +TelemetryFrame _frame({int batteryPercent = 80, int navMode = 0}) { + return TelemetryFrame( + lat: 52.5, + lon: 13.4, + hasFix: true, + numSat: 10, + altitudeM: 100, + speedMs: 15, + headingDeg: 0, + armed: true, + batteryPercent: batteryPercent, + batteryVoltage: 22.0, + currentA: 10.0, + linkQuality: 90, + snrDb: 5, + hdop: 1.0, + navMode: navMode, + flightMode: 'Idle', + ); +} + +void main() { + final now = DateTime(2026, 1, 1, 12, 0, 0); + + group('detectDroneEvents - battery', () { + test('kein Ereignis ohne vorherigen Frame', () { + final events = detectDroneEvents( + previous: null, + current: _frame(batteryPercent: 15), + now: now, + ); + expect(events, isEmpty); + }); + + test('kein Ereignis, solange keine Schwelle unterschritten wird', () { + final events = detectDroneEvents( + previous: _frame(batteryPercent: 80), + current: _frame(batteryPercent: 75), + now: now, + ); + expect(events, isEmpty); + }); + + test('"Battery low" beim Unterschreiten von 30%', () { + final events = detectDroneEvents( + previous: _frame(batteryPercent: 32), + current: _frame(batteryPercent: 28), + now: now, + ); + expect(events, hasLength(1)); + expect(events.single.severity, DroneEventSeverity.warning); + expect(events.single.message, contains('Battery low')); + }); + + test('"Battery critical" beim Unterschreiten von 20%', () { + final events = detectDroneEvents( + previous: _frame(batteryPercent: 22), + current: _frame(batteryPercent: 18), + now: now, + ); + expect(events, hasLength(1)); + expect(events.single.severity, DroneEventSeverity.critical); + expect(events.single.message, contains('Battery critical')); + }); + + test('nur "Battery critical", wenn beide Schwellen im selben Schritt fallen', () { + final events = detectDroneEvents( + previous: _frame(batteryPercent: 35), + current: _frame(batteryPercent: 15), + now: now, + ); + expect(events, hasLength(1)); + expect(events.single.message, contains('Battery critical')); + }); + + test('kein erneutes Ereignis, solange der Ladestand unter der Schwelle bleibt', () { + final events = detectDroneEvents( + previous: _frame(batteryPercent: 25), + current: _frame(batteryPercent: 22), + now: now, + ); + expect(events, isEmpty); + }); + }); + + group('detectDroneEvents - failsafe', () { + test('"Failsafe mode activated" beim Wechsel in Emergency (navMode 15)', () { + final events = detectDroneEvents( + previous: _frame(navMode: 3), + current: _frame(navMode: 15), + now: now, + ); + expect(events, hasLength(1)); + expect(events.single.severity, DroneEventSeverity.critical); + expect(events.single.message, 'Failsafe mode activated'); + }); + + test('kein erneutes Ereignis, solange Emergency anhaelt', () { + final events = detectDroneEvents( + previous: _frame(navMode: 15), + current: _frame(navMode: 15), + now: now, + ); + expect(events, isEmpty); + }); + }); +} 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 0f401fa..695e3f2 100644 --- a/app/test/transport/msp/msp_flight_controller_link_test.dart +++ b/app/test/transport/msp/msp_flight_controller_link_test.dart @@ -48,6 +48,8 @@ Timer _startFakeFlightController( payload = Uint8List.fromList([3, 5, 0, 3, 0, 0, 0]); case MspCommands.inavAnalog: final d = ByteData(24); + d.setUint16(1, 2160, Endian.little); // 21.60 V + d.setInt16(3, 1180, Endian.little); // 11.80 A d.setUint8(21, 77); // Batterie-Prozent payload = d.buffer.asUint8List(); case MspCommands.inavLinkStats: @@ -89,6 +91,8 @@ void main() { expect(frame.speedMs, closeTo(5.0, 1e-6)); expect(frame.headingDeg, closeTo(270.0, 1e-6)); expect(frame.batteryPercent, 77); + expect(frame.batteryVoltage, closeTo(21.6, 1e-9)); + expect(frame.currentA, closeTo(11.8, 1e-9)); expect(frame.linkQuality, 88); expect(frame.snrDb, 6); expect(frame.hdop, closeTo(1.5, 1e-9)); diff --git a/app/test/transport/msp/msp_telemetry_codec_test.dart b/app/test/transport/msp/msp_telemetry_codec_test.dart index c886ced..c7421af 100644 --- a/app/test/transport/msp/msp_telemetry_codec_test.dart +++ b/app/test/transport/msp/msp_telemetry_codec_test.dart @@ -136,6 +136,37 @@ void main() { }); }); + group('parseMspInavAnalogVoltage', () { + test('liest Akkuspannung an Offset 1 (u16, 0.01V-Schritte)', () { + final data = ByteData(24); + data.setUint16(1, 2220, Endian.little); // 22.20 V + expect( + parseMspInavAnalogVoltage(data.buffer.asUint8List()), + closeTo(22.2, 1e-9), + ); + }); + }); + + group('parseMspInavAnalogAmperage', () { + test('liest positive Stromaufnahme an Offset 3 (i16, 0.01A-Schritte)', () { + final data = ByteData(24); + data.setInt16(3, 1250, Endian.little); // 12.50 A + expect( + parseMspInavAnalogAmperage(data.buffer.asUint8List()), + closeTo(12.5, 1e-9), + ); + }); + + test('liest negative Stromaufnahme (vorzeichenbehaftet)', () { + final data = ByteData(24); + data.setInt16(3, -50, Endian.little); // -0.50 A + expect( + parseMspInavAnalogAmperage(data.buffer.asUint8List()), + closeTo(-0.5, 1e-9), + ); + }); + }); + group('parseMspLinkQuality', () { test('liest Linkqualitaet an Offset 1', () { final payload = Uint8List(3);