From 1b8c67a02b2e5e2ca39c6b6b22485f344e899370 Mon Sep 17 00:00:00 2001 From: Constantin Leue <77660791+Consti3D@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:49:12 +0200 Subject: [PATCH] Erkennung von Drone connected/disconnected repariert (MSP-Poller verschluckte Verbindungsabbrueche komplett) Wenn die Drohne ausgeschaltet wurde, liefen die MSP-Anfragen in MspTelemetryPoller._runCycle() zwar korrekt in den Timeout (MspClient hat bereits eigene Zeitgrenze + Wiederholung), aber die aeussere Schleife in _loop() hat jede Exception stillschweigend verschluckt (catch (_) {}) und einfach den naechsten Zyklus gestartet - ohne jemals ein error: auf _framesController zu emittieren. telemetryProvider blieb dadurch bei einem stillen Ausbleiben der Drohne einfach auf dem letzten AsyncData(...) Frame stehen. systemMessageAutoLogProvider (system_message_log_provider.dart) wartet aber genau auf einen error:-Uebergang, um "Drone disconnected" zu loggen und sein internes hadData zurueckzusetzen - ohne diesen Uebergang blieb nicht nur "Drone disconnected" aus, sondern beim Wiederverbinden auch "Drone connected" (hadData war ja nie zurueckgesetzt worden). Der Nutzer sah das Problem korrekt schon eingegrenzt: der WLAN-Connection-Log in den Settings (wifi_connection_provider.dart) erkennt "Telemetry stream stopped"/"Receiving telemetry" bereits richtig, weil er unabhaengig davon direkt auf rohe eingehende UDP-Pakete schaut, nicht auf MSP-Antworten. Fix ausschliesslich in msp_telemetry_poller.dart: nach 3 aufeinander- folgenden fehlgeschlagenen Zyklen (vermeidet Falschmeldungen bei kurzen Signalluecken, ein einzelner Zyklus scheitert bereits erst nach MspClients eigenen internen Retries) wird einmalig ein addError auf den Frames-Stream gegeben - die bereits vorhandene Logik in systemMessageAutoLogProvider greift danach unveraendert. Keine Aenderung an system_message_log_provider.dart noetig. Co-Authored-By: Claude Sonnet 5 --- .../transport/msp/msp_telemetry_poller.dart | 39 +++- .../msp/msp_telemetry_poller_test.dart | 173 ++++++++++++++++++ 2 files changed, 206 insertions(+), 6 deletions(-) create mode 100644 app/test/transport/msp/msp_telemetry_poller_test.dart diff --git a/app/lib/transport/msp/msp_telemetry_poller.dart b/app/lib/transport/msp/msp_telemetry_poller.dart index 5078f10..79ef0db 100644 --- a/app/lib/transport/msp/msp_telemetry_poller.dart +++ b/app/lib/transport/msp/msp_telemetry_poller.dart @@ -36,8 +36,20 @@ class MspTelemetryPoller { static const _cycleInterval = Duration(milliseconds: 150); // ~6-7 Hz static const _statusEveryNCycles = 3; // ~2 Hz bei 150-ms-Takt + /// Anzahl aufeinanderfolgender fehlgeschlagener Zyklen, bevor der Strom + /// einmalig einen Fehler emittiert (Doku: "Drone connected/disconnected" + /// wird nicht erkannt, wenn die Drohne ausgeschaltet wird) - jede + /// [MspClient.request]-Anfrage hat bereits ihre eigene Zeitgrenze + + /// Wiederholung (siehe dort), ein einzelner fehlgeschlagener Zyklus ist + /// also schon ein "MSP_RAW_GPS blieb trotz interner Retries 1.5s lang + /// unbeantwortet" und kein einzelner verlorener Funkframe mehr. 3 Zyklen + /// vermeiden trotzdem, dass eine kurze Signalluecke sofort als + /// "disconnected" gilt. + static const _disconnectAfterFailures = 3; + bool _running = false; int _cycle = 0; + int _consecutiveFailures = 0; bool _armed = false; int? _activeWaypointIndex; int _batteryPercent = 0; @@ -66,12 +78,27 @@ class MspTelemetryPoller { while (_running) { try { await _runCycle(); - } catch (_) { - // Eine einzelne fehlgeschlagene Anfrage (Timeout/CRC/Transport - // getrennt) darf den Takt nicht stoppen - der naechste Zyklus - // startet nach der Wartezeit regulaer weiter (Doku 4: Zeitgrenze + - // Wiederholung; Wiederverbinden passiert bereits auf Transport- - // Ebene, siehe BluetoothClassicTransport). + _consecutiveFailures = 0; + } catch (error, stackTrace) { + // Ein einzelner fehlgeschlagener Zyklus darf den Takt nicht stoppen + // - der naechste Zyklus startet nach der Wartezeit regulaer weiter + // (Doku 4: Zeitgrenze + Wiederholung; Wiederverbinden passiert + // bereits auf Transport-Ebene, siehe BluetoothClassicTransport). + // ABER: ohne irgendeine Fehler-Emission auf [_framesController] + // bleibt telemetryProvider bei einem stillen Ausbleiben der Drohne + // (z.B. ausgeschaltet) einfach auf dem letzten AsyncData(...) Frame + // stehen - systemMessageAutoLogProvider (das genau auf einen + // error:-Uebergang wartet, siehe dort) bekommt dann nie mit, dass + // die Verbindung weg ist, und loggt weder "Drone disconnected" noch + // (weil hadData nie zurueckgesetzt wird) spaeter erneut "Drone + // connected". Deshalb hier einmalig ein addError, sobald genug + // Zyklen in Folge fehlgeschlagen sind - danach greift die bereits + // vorhandene Logik in systemMessageAutoLogProvider unveraendert. + _consecutiveFailures++; + if (_consecutiveFailures == _disconnectAfterFailures && + !_framesController.isClosed) { + _framesController.addError(error, stackTrace); + } } await Future.delayed(_cycleInterval); } diff --git a/app/test/transport/msp/msp_telemetry_poller_test.dart b/app/test/transport/msp/msp_telemetry_poller_test.dart new file mode 100644 index 0000000..15a17c0 --- /dev/null +++ b/app/test/transport/msp/msp_telemetry_poller_test.dart @@ -0,0 +1,173 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:dmc_app/transport/flight_controller_link.dart'; +import 'package:dmc_app/transport/loopback/loopback_transport.dart'; +import 'package:dmc_app/transport/msp/msp_client.dart'; +import 'package:dmc_app/transport/msp/msp_commands.dart'; +import 'package:dmc_app/transport/msp/msp_frame.dart'; +import 'package:dmc_app/transport/msp/msp_frame_decoder.dart'; +import 'package:dmc_app/transport/msp/msp_telemetry_poller.dart'; + +/// Minimaler Fake-FC (analog msp_flight_controller_link_test.dart, hier +/// bewusst eigenstaendig statt geteilt, da jene Klasse privat ist): +/// beantwortet jede Anfrage sofort mit einer gueltigen Antwort, solange +/// [respond] true ist. Auf false gesetzt simuliert das eine ausgeschaltete +/// Drohne - Anfragen laufen dann in den MspClient-eigenen Timeout, ohne +/// dass ueberhaupt geantwortet wird. +class _FakeResponder { + _FakeResponder(this.transport) { + _timer = Timer.periodic(const Duration(milliseconds: 2), _tick); + } + + final LoopbackTransport transport; + bool respond = true; + late final Timer _timer; + var _answered = 0; + + void _tick(Timer _) { + if (!respond) return; + 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); // 3D-Fix + d.setUint8(1, 10); + d.setInt32(2, 525200000, Endian.little); // lat 52.52 + d.setInt32(6, 134050000, Endian.little); // lon 13.405 + d.setUint16(10, 100, Endian.little); // Hoehe 100m + d.setUint16(12, 500, Endian.little); // 5.00 m/s + d.setUint16(16, 150, Endian.little); // HDOP 1.50 + payload = d.buffer.asUint8List(); + case MspCommands.altitude: + final d = ByteData(10); + d.setInt32(0, 5000, Endian.little); // 50.00 m + 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(); +} + +void main() { + test( + 'frames-Strom emittiert einen Fehler nach mehreren aufeinanderfolgenden ' + 'fehlgeschlagenen Zyklen (Doku: "Drone connected/disconnected wird ' + 'nicht erkannt, wenn die Drohne ausgeschaltet wird") und liefert nach ' + 'Wiederaufnahme der Antworten wieder normale Frames', + () async { + final transport = LoopbackTransport(); + final responder = _FakeResponder(transport); + addTearDown(responder.cancel); + // Sehr kurze Zeitgrenze/keine Wiederholung, damit der Test nicht auf + // die produktiven 500ms x 3 Versuche warten muss. + final client = MspClient( + transport, + requestTimeout: const Duration(milliseconds: 20), + maxRetries: 0, + ); + addTearDown(client.dispose); + final poller = MspTelemetryPoller(client); + addTearDown(poller.dispose); + poller.start(); + + final events = []; + final sub = poller.frames.listen(events.add, onError: events.add); + addTearDown(sub.cancel); + + await Future.delayed(const Duration(milliseconds: 100)); + expect(events, isNotEmpty); + expect(events.every((e) => e is TelemetryFrame), isTrue); + + // "Drohne ausschalten": Fake-FC antwortet nicht mehr. + responder.respond = false; + events.clear(); + + // 3 fehlgeschlagene Zyklen a (20ms Timeout + 150ms Zykluspause). + await Future.delayed(const Duration(milliseconds: 700)); + expect( + events.any((e) => e is! TelemetryFrame), + isTrue, + reason: 'Nach anhaltendem Ausbleiben von Antworten sollte der Strom ' + 'mindestens einen Fehler emittiert haben.', + ); + + // "Drohne wieder einschalten". + events.clear(); + responder.respond = true; + await Future.delayed(const Duration(milliseconds: 300)); + expect( + events.any((e) => e is TelemetryFrame), + isTrue, + reason: 'Nach Wiederaufnahme der Antworten sollten wieder normale ' + 'Frames ankommen.', + ); + }, + timeout: const Timeout(Duration(seconds: 10)), + ); + + test( + 'ein einzelner fehlgeschlagener Zyklus emittiert noch keinen Fehler ' + '(vermeidet Falschmeldungen bei kurzen Signalluecken)', + () async { + final transport = LoopbackTransport(); + final responder = _FakeResponder(transport); + addTearDown(responder.cancel); + final client = MspClient( + transport, + requestTimeout: const Duration(milliseconds: 20), + maxRetries: 0, + ); + addTearDown(client.dispose); + final poller = MspTelemetryPoller(client); + addTearDown(poller.dispose); + poller.start(); + + final events = []; + final sub = poller.frames.listen(events.add, onError: events.add); + addTearDown(sub.cancel); + + await Future.delayed(const Duration(milliseconds: 100)); + events.clear(); + + // Nur kurz aussetzen - deutlich weniger als die 3 Zyklen bis zum + // Fehler. + responder.respond = false; + await Future.delayed(const Duration(milliseconds: 80)); + responder.respond = true; + + await Future.delayed(const Duration(milliseconds: 200)); + expect( + events.any((e) => e is! TelemetryFrame), + isFalse, + reason: 'Eine kurze Luecke allein sollte noch keinen Fehler ' + 'ausloesen.', + ); + }, + timeout: const Timeout(Duration(seconds: 10)), + ); +}