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 131e66f..cef65f4 100644 Binary files a/Design Journal Drone Mission Control.odp and b/Design Journal Drone Mission Control.odp differ 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);