Files
dmc/app/lib/ui/providers/telemetry_provider.dart
T
Constantin LeueandClaude Sonnet 5 e5c87fba5f 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 <noreply@anthropic.com>
2026-08-06 17:17:13 +02:00

128 lines
7.1 KiB
Dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../transport/connection_type.dart';
import '../../transport/flight_controller_link.dart';
import '../../transport/link_transport.dart';
import '../../transport/mock/mock_flight_controller_link.dart';
import '../../transport/msp/msp_flight_controller_link.dart';
import 'active_drone_profile_provider.dart';
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
/// geladen sind) der Mock fuer UI-Entwicklung/Testphase ohne Hardware.
///
/// connectionType wird reaktiv beobachtet (ref.watch), nicht nur einmalig
/// gelesen - beim ersten App-Start ist connectionSettingsProvider oft noch
/// nicht aus der DB geladen (AsyncLoading, value == null), was ohne Watch
/// dauerhaft auf den Mock-Fallback festgenagelt haette (siehe unten, "nicht
/// autoDispose"): sobald die Einstellungen nachladen oder der Nutzer die
/// Verbindungsart in den Settings aendert, baut dieser Provider sich korrekt
/// neu auf (alter Link wird dabei via ref.onDispose sauber getrennt). Das
/// ist inzwischen unkritisch haeufig, da die fruehere Sorge (Tippen im
/// SSID-Praefix-Feld loest bei jedem Zeichen einen Neuaufbau aus) entfaellt,
/// seit dieses Feld aus den Settings entfernt wurde (Preset "WiFi mLRS UDP").
///
/// Bewusst NICHT autoDispose (Doku: "beim Wechsel in Plan kein disconnect
/// triggern, Verbindung aktiv halten") - anders als frueher trennt ein
/// Wechsel zurueck in den Plan-Modus (der telemetryProvider nicht mehr
/// beobachtet) die Verbindung nicht mehr automatisch. Sie bleibt bis zum
/// expliziten "Disconnect" in den Settings, einer Aenderung der
/// Verbindungsart oder Schliessen der App bestehen.
/// [MspFlightControllerLink.connect]/[MockFlightControllerLink.connect]
/// sind deshalb idempotent: jeder erneute Watch von [telemetryProvider]
/// (z.B. erneuter Eintritt in den Fly-Modus) ruft `connect()` erneut auf,
/// darf aber keinen zweiten MSP-Client/-Poller nebenher starten.
final flightControllerLinkProvider = Provider<FlightControllerLink>((ref) {
final connectionType = ref.watch(connectionSettingsProvider).value?.connectionType;
final FlightControllerLink link = connectionType == ConnectionType.wifi
? MspFlightControllerLink(transport: ref.watch(wifiTransportProvider))
// ref.read() statt watch() - der Mock soll nur einmal bei seiner
// Erzeugung am ersten Wegpunkt der GERADE aktuellen Mission
// verankert werden (Doku: "Mock-Daten fuer die Live-Details-Ansicht
// nutzbar machen"), nicht bei jeder spaeteren Missionsaenderung
// neu erzeugt werden (das wuerde Timer/Drift/Akkustand zuruecksetzen).
// Gleiches gilt fuer die Zellenzahl: sie bestimmt nur den simulierten
// Spannungsbereich bei der Erzeugung, ein spaeterer Profilwechsel
// waehrend einer laufenden Mock-Verbindung setzt den Akkustand nicht
// zurueck.
: MockFlightControllerLink(
initialWaypoints: ref.read(currentMissionProvider),
batteryCellCount: ref.read(activeDroneProfileProvider).batteryCellCount,
);
ref.onDispose(link.disconnect);
return link;
});
/// Live-Telemetrie des verbundenen Flightcontrollers (Doku 3.4). Bleibt
/// selbst autoDispose: nur die Frame-Weiterleitung an gerade aktive Watcher
/// endet beim Verlassen, nicht die zugrundeliegende Verbindung (siehe
/// [flightControllerLinkProvider]).
///
/// Ruft bei WLAN/MSP bewusst NICHT einfach `link.connect()` auf, sobald
/// irgendwer (z.B. FlyScreen beim Eintritt in den Fly-Modus) diesen Provider
/// beobachtet (Doku: "kein connect beim Wechsel in den Fly-Modus, um das
/// Debuggen auf echter Hardware zu vereinfachen") - `MspFlightControllerLink.
/// connect()` wuerde sonst ueber `transport.connect()` sofort einen
/// WifiNetworkSpecifier-Systemdialog ausloesen, unabhaengig vom entfernten
/// Auto-Connect-Trigger in TopModeBar. Stattdessen wird erst aktiv, sobald
/// die rohe WLAN-Verbindung bereits ueber den expliziten "Connect"-Knopf in
/// den Settings steht - bis dahin bleibt der Stream leer (kein Frame,
/// 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<TelemetryFrame>(
(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,
);