From e5c87fba5fb0b47ea7278dc91c15e42470462bd3 Mon Sep 17 00:00:00 2001 From: Constantin Leue <77660791+Consti3D@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:17:13 +0200 Subject: [PATCH] Drone/Netzwerk-Connect-Disconnect wirklich repariert (Riverpods Auto-Retry verschluckte den Fehlerzustand) Der vorherige Fix (msp_telemetry_poller.dart, addError nach 3 fehl- geschlagenen Zyklen) hat die Erkennung allein nicht repariert - aus zwei Gruenden, beide jetzt behoben: 1. telemetryProvider brach bei WLAN-Verbindungsverlust (wifiLinkStateProvider != connected) den Strom bisher mit einem stillen `return;` ab, statt ueberhaupt subscribeTelemetry() zu abonnieren. Damit blieb der Provider nach einem zuvor erfolgreichen Verbindungsaufbau unbegrenzt auf dem letzten AsyncData(...) haengen, sobald das WLAN-Netz verloren ging - komplett unabhaengig vom MSP-Poller-Fix, der in diesem Fall nie erreicht wird. Ersetzt durch eine neue WifiLinkNotConnectedException. 2. Der eigentliche Grund, warum ich das beim ersten Fix nicht bemerkt habe: Riverpod 3.x wiederholt einen fehlgeschlagenen Provider standardmaessig automatisch (ProviderContainer.defaultRetry) und haelt ihn dabei in AsyncLoading(error: ..., retrying: true) statt sofort auf AsyncError zu wechseln - AsyncValue.when()s error:-Zweig (systemMessageAutoLogProvider) feuert dafuer nicht, nur der loading:-Zweig (No-Op). Das betraf sowohl die neue WifiLinkNotConnectedException als auch das per yield* durchgereichte addError aus dem MSP-Poller - beide blieben dadurch unbegrenzt "am Wiederholen haengen", nie als AsyncError sichtbar. Mit retry: (retryCount, error) => null gezielt fuer telemetryProvider deaktiviert - die eigentliche Wiederherstellung passiert ohnehin reaktiv (ref.watch(wifiLinkStateProvider) bzw. der Poller-Takt selbst), nicht ueber Riverpods Backoff. Neuer Test in telemetry_provider_test.dart deckt jetzt die komplette Kette end-to-end ab (echter MspFlightControllerLink + LoopbackTransport + ueberschriebener wifiLinkStateProvider, keine der bisherigen Tests in system_message_log_provider_test.dart haette diesen Fehler auffangen koennen, da sie telemetryProvider selbst immer ueberschreiben statt seine eigene Generatorfunktion zu durchlaufen). Co-Authored-By: Claude Sonnet 5 --- app/lib/ui/providers/telemetry_provider.dart | 60 ++++++- .../ui/providers/telemetry_provider_test.dart | 164 ++++++++++++++++++ 2 files changed, 215 insertions(+), 9 deletions(-) create mode 100644 app/test/ui/providers/telemetry_provider_test.dart diff --git a/app/lib/ui/providers/telemetry_provider.dart b/app/lib/ui/providers/telemetry_provider.dart index cbe4988..ca428a0 100644 --- a/app/lib/ui/providers/telemetry_provider.dart +++ b/app/lib/ui/providers/telemetry_provider.dart @@ -10,6 +10,25 @@ import 'connection_settings_provider.dart'; import 'current_mission_provider.dart'; import 'wifi_connection_provider.dart'; +/// Wird von [telemetryProvider] geworfen, wenn `wifiLinkStateProvider` +/// (weder) verbunden ist - siehe dortige Doku. Frueher liess die +/// Stream-Generatorfunktion den Strom in diesem Fall einfach per `return;` +/// enden, ohne je [FlightControllerLink.subscribeTelemetry] zu abonnieren - +/// dabei blieb `telemetryProvider` fuer jeden Beobachter (u.a. +/// systemMessageAutoLogProvider) unbegrenzt in AsyncLoading haengen, auch +/// nachdem zuvor bereits Frames flossen (WLAN-Netz verloren/mLRS-Bruecke +/// aus): AsyncValue.when()s loading-Zweig feuert dort nicht, "Drone +/// disconnected" wurde also nie geloggt. Ein echter Fehler statt eines +/// stillen Stream-Endes sorgt dafuer, dass genau dieser Uebergang als +/// AsyncError sichtbar wird. +class WifiLinkNotConnectedException implements Exception { + const WifiLinkNotConnectedException(this.linkState); + final LinkState linkState; + + @override + String toString() => 'WifiLinkNotConnectedException($linkState)'; +} + /// FlightControllerLink fuer den Fly-Modus (Doku 3.1): bei WLAN als /// gewaehlter Verbindungsart ein echter MSP-Link ueber den UDP-Transport, /// sonst (ConnectionType.mock, oder solange die Einstellungen noch nicht @@ -74,12 +93,35 @@ final flightControllerLinkProvider = Provider((ref) { /// FlyScreen zeigt den Fallback-Marker auf dem ersten Wegpunkt). Fuer /// Mock/Bluetooth/5G/USB gilt diese Einschraenkung nicht, dort ist /// "verbinden" ohnehin folgenlos bzw. noch nicht umgesetzt. -final telemetryProvider = StreamProvider.autoDispose((ref) async* { - final link = ref.watch(flightControllerLinkProvider); - if (link is MspFlightControllerLink && - ref.watch(wifiLinkStateProvider).value != LinkState.connected) { - return; - } - await link.connect(); - yield* link.subscribeTelemetry(); -}); +final telemetryProvider = StreamProvider.autoDispose( + (ref) async* { + final link = ref.watch(flightControllerLinkProvider); + if (link is MspFlightControllerLink) { + final wifiState = ref.watch(wifiLinkStateProvider).value; + if (wifiState != LinkState.connected) { + // Bewusst ein Fehler statt eines stillen `return;` (siehe + // [WifiLinkNotConnectedException]-Doku) - fuer den allerersten + // Aufbau (noch nie verbunden) harmlos, da systemMessageAutoLogProvider + // den Fehler ignoriert, solange zuvor noch keine Frames flossen. + throw WifiLinkNotConnectedException(wifiState ?? LinkState.disconnected); + } + } + await link.connect(); + yield* link.subscribeTelemetry(); + }, + // Riverpods eingebautes automatisches Wiederholen (Standard seit Riverpod + // 3.x) haelt einen fehlgeschlagenen Provider absichtlich in + // AsyncLoading(..., retrying: true) statt sofort in AsyncError zu + // wechseln - AsyncValue.when()s error:-Zweig (u.a. in + // systemMessageAutoLogProvider, siehe dortige Doku zu "Drone + // disconnected") feuert dafuer aber gar nicht, nur der loading:-Zweig + // (No-Op). Betrifft nicht nur [WifiLinkNotConnectedException] oben, + // sondern genauso ein error-Event aus dem per yield* durchgereichten + // [MspTelemetryPoller.frames]-Strom (siehe dortige _disconnectAfterFailures- + // Doku) - beide Faelle blieben dadurch unbegrenzt "am Wiederholen", nie + // als AsyncError sichtbar. retry: null deaktiviert das gezielt fuer + // diesen Provider: die eigentliche Wiederherstellung passiert ohnehin + // reaktiv ueber ref.watch(wifiLinkStateProvider)/den Poller-Takt selbst, + // nicht ueber Riverpods Backoff. + retry: (retryCount, error) => null, +); diff --git a/app/test/ui/providers/telemetry_provider_test.dart b/app/test/ui/providers/telemetry_provider_test.dart new file mode 100644 index 0000000..bcdae7b --- /dev/null +++ b/app/test/ui/providers/telemetry_provider_test.dart @@ -0,0 +1,164 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:dmc_app/domain/telemetry/system_message.dart'; +import 'package:dmc_app/transport/connection_type.dart'; +import 'package:dmc_app/transport/link_transport.dart'; +import 'package:dmc_app/transport/loopback/loopback_transport.dart'; +import 'package:dmc_app/transport/msp/msp_commands.dart'; +import 'package:dmc_app/transport/msp/msp_flight_controller_link.dart'; +import 'package:dmc_app/transport/msp/msp_frame.dart'; +import 'package:dmc_app/transport/msp/msp_frame_decoder.dart'; +import 'package:dmc_app/ui/providers/connection_settings_provider.dart'; +import 'package:dmc_app/ui/providers/system_message_log_provider.dart'; +import 'package:dmc_app/ui/providers/telemetry_provider.dart'; +import 'package:dmc_app/ui/providers/wifi_connection_provider.dart'; + +/// Minimaler Fake-FC (analog msp_flight_controller_link_test.dart/ +/// msp_telemetry_poller_test.dart) - beantwortet jede Anfrage sofort mit +/// einer gueltigen Antwort. +class _FakeResponder { + _FakeResponder(this.transport) { + _timer = Timer.periodic(const Duration(milliseconds: 2), _tick); + } + + final LoopbackTransport transport; + late final Timer _timer; + var _answered = 0; + + void _tick(Timer _) { + while (_answered < transport.sentData.length) { + final request = + MspFrameDecoder().addBytes(transport.sentData[_answered]).single; + _answered++; + + final Uint8List payload; + switch (request.function) { + case MspCommands.rawGps: + final d = ByteData(18); + d.setUint8(0, 3); + d.setUint8(1, 10); + d.setInt32(2, 525200000, Endian.little); + d.setInt32(6, 134050000, Endian.little); + d.setUint16(10, 100, Endian.little); + d.setUint16(12, 500, Endian.little); + d.setUint16(16, 150, Endian.little); + payload = d.buffer.asUint8List(); + case MspCommands.altitude: + final d = ByteData(10); + d.setInt32(0, 5000, Endian.little); + payload = d.buffer.asUint8List(); + case MspCommands.inavStatus: + payload = Uint8List(22); + case MspCommands.navStatus: + payload = Uint8List.fromList([0, 0, 0, 0, 0, 0, 0]); + case MspCommands.inavAnalog: + payload = Uint8List(24); + case MspCommands.inavLinkStats: + payload = Uint8List(3); + case MspCommands.inavTemperatures: + payload = Uint8List(16); + default: + payload = Uint8List(0); + } + + transport.feed( + encodeMspV2Frame(MspDirection.response, request.function, payload), + ); + } + } + + void cancel() => _timer.cancel(); +} + +typedef _ConnectionSettings = ({ + int? activeDroneProfileId, + int? currentMissionId, + String? connectionDeviceAddress, + bool connectionAutoConnect, + ConnectionType connectionType, + String? connectionWifiHost, + int connectionWifiPort, + String connectionWifiSsidPrefix, + String? connectionWifiLastSsid, +}); + +_ConnectionSettings _wifiSettings() => ( + activeDroneProfileId: null, + currentMissionId: null, + connectionDeviceAddress: null, + connectionAutoConnect: true, + connectionType: ConnectionType.wifi, + connectionWifiHost: null, + connectionWifiPort: 14550, + connectionWifiSsidPrefix: 'mLRS-', + connectionWifiLastSsid: null, + ); + +void main() { + // Reproduziert den Nutzerbericht "Netzwerk Connect und Disconnect wurde + // noch nie erkannt": alle Tests in system_message_log_provider_test.dart + // ueberschreiben telemetryProvider direkt und pruefen deshalb nie dessen + // eigene Generatorfunktion - genau dort sass der Fehler (stilles `return;` + // statt eines Fehlers, siehe telemetry_provider.dart). + test( + 'telemetryProvider haengt bei Verlust der WLAN-Verbindung nicht mehr ' + 'unbegrenzt in AsyncLoading, sondern wird zu AsyncError - end-to-end ' + 'bis "Drone connected"/"Drone disconnected" im System-Messages-Log', + () async { + final transport = LoopbackTransport(); + final responder = _FakeResponder(transport); + addTearDown(responder.cancel); + final link = MspFlightControllerLink(transport: transport); + addTearDown(link.disconnect); + + final wifiState = StreamController.broadcast(); + addTearDown(wifiState.close); + + final container = ProviderContainer(overrides: [ + connectionSettingsProvider + .overrideWith((ref) async => _wifiSettings()), + flightControllerLinkProvider.overrideWithValue(link), + wifiLinkStateProvider.overrideWith((ref) => wifiState.stream), + ]); + addTearDown(container.dispose); + container.listen(systemMessageAutoLogProvider, (previous, next) {}); + container.listen(telemetryProvider, (previous, next) {}); + + // Noch nie verbunden: telemetryProvider darf nicht auf AsyncData + // stehen, und es darf noch nichts geloggt werden (kein hadData). + wifiState.add(LinkState.disconnected); + await Future.delayed(const Duration(milliseconds: 100)); + expect(container.read(telemetryProvider).hasValue, isFalse); + expect(container.read(systemMessageLogProvider), isEmpty); + + // "WLAN verbindet sich". + wifiState.add(LinkState.connected); + await Future.delayed(const Duration(milliseconds: 400)); + expect(container.read(telemetryProvider).hasValue, isTrue); + final afterConnect = container.read(systemMessageLogProvider); + expect(afterConnect, hasLength(1)); + expect(afterConnect.single.message, 'Drone connected'); + + // "WLAN-Netz verloren" (UdpTransport meldet LinkState.error bei + // MlrsNetworkLost, siehe udp_transport.dart). + wifiState.add(LinkState.error); + await Future.delayed(const Duration(milliseconds: 100)); + expect(container.read(telemetryProvider).hasError, isTrue); + final afterLost = container.read(systemMessageLogProvider); + expect(afterLost.first.message, 'Drone disconnected'); + expect(afterLost.first.severity, SystemMessageSeverity.critical); + + // "WLAN wiederhergestellt". + wifiState.add(LinkState.connected); + await Future.delayed(const Duration(milliseconds: 400)); + expect(container.read(telemetryProvider).hasValue, isTrue); + final afterReconnect = container.read(systemMessageLogProvider); + expect(afterReconnect.first.message, 'Drone connected'); + }, + timeout: const Timeout(Duration(seconds: 10)), + ); +}