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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
1b8c67a02b
commit
e5c87fba5f
@@ -10,6 +10,25 @@ import 'connection_settings_provider.dart';
|
|||||||
import 'current_mission_provider.dart';
|
import 'current_mission_provider.dart';
|
||||||
import 'wifi_connection_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
|
/// FlightControllerLink fuer den Fly-Modus (Doku 3.1): bei WLAN als
|
||||||
/// gewaehlter Verbindungsart ein echter MSP-Link ueber den UDP-Transport,
|
/// gewaehlter Verbindungsart ein echter MSP-Link ueber den UDP-Transport,
|
||||||
/// sonst (ConnectionType.mock, oder solange die Einstellungen noch nicht
|
/// sonst (ConnectionType.mock, oder solange die Einstellungen noch nicht
|
||||||
@@ -74,12 +93,35 @@ final flightControllerLinkProvider = Provider<FlightControllerLink>((ref) {
|
|||||||
/// FlyScreen zeigt den Fallback-Marker auf dem ersten Wegpunkt). Fuer
|
/// FlyScreen zeigt den Fallback-Marker auf dem ersten Wegpunkt). Fuer
|
||||||
/// Mock/Bluetooth/5G/USB gilt diese Einschraenkung nicht, dort ist
|
/// Mock/Bluetooth/5G/USB gilt diese Einschraenkung nicht, dort ist
|
||||||
/// "verbinden" ohnehin folgenlos bzw. noch nicht umgesetzt.
|
/// "verbinden" ohnehin folgenlos bzw. noch nicht umgesetzt.
|
||||||
final telemetryProvider = StreamProvider.autoDispose<TelemetryFrame>((ref) async* {
|
final telemetryProvider = StreamProvider.autoDispose<TelemetryFrame>(
|
||||||
|
(ref) async* {
|
||||||
final link = ref.watch(flightControllerLinkProvider);
|
final link = ref.watch(flightControllerLinkProvider);
|
||||||
if (link is MspFlightControllerLink &&
|
if (link is MspFlightControllerLink) {
|
||||||
ref.watch(wifiLinkStateProvider).value != LinkState.connected) {
|
final wifiState = ref.watch(wifiLinkStateProvider).value;
|
||||||
return;
|
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();
|
await link.connect();
|
||||||
yield* link.subscribeTelemetry();
|
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,
|
||||||
|
);
|
||||||
|
|||||||
@@ -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<LinkState>.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<void>.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<void>.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<void>.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<void>.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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user