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 <noreply@anthropic.com>
This commit is contained in:
Constantin Leue
2026-08-06 16:49:12 +02:00
co-authored by Claude Sonnet 5
parent a8d2731c43
commit 1b8c67a02b
2 changed files with 206 additions and 6 deletions
@@ -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 = <Object>[];
final sub = poller.frames.listen(events.add, onError: events.add);
addTearDown(sub.cancel);
await Future<void>.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<void>.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<void>.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 = <Object>[];
final sub = poller.frames.listen(events.add, onError: events.add);
addTearDown(sub.cancel);
await Future<void>.delayed(const Duration(milliseconds: 100));
events.clear();
// Nur kurz aussetzen - deutlich weniger als die 3 Zyklen bis zum
// Fehler.
responder.respond = false;
await Future<void>.delayed(const Duration(milliseconds: 80));
responder.respond = true;
await Future<void>.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)),
);
}