Between cancelling the initial "available" subscription and attaching the network-loss listener, no listener was attached to the broadcast event stream for a brief window. Events landing in that gap (notably a late SSID update, which real hardware without STA concurrency often fires very close after "available") were silently lost since broadcast streams don't buffer for late subscribers. Now uses one continuous subscription for the whole connection lifetime instead.
363 lines
15 KiB
Dart
363 lines
15 KiB
Dart
import 'dart:async';
|
|
import 'dart:io' show InternetAddress;
|
|
|
|
import 'package:flutter/services.dart';
|
|
|
|
import '../link_transport.dart';
|
|
import 'mlrs_network_controller.dart';
|
|
|
|
/// UDP-Transport zur mLRS-WLAN-Bruecke (Doku Kommunikationsschicht v2,
|
|
/// Abschnitt 3/4). Die Verbindung zum WLAN wird ausschliesslich von der App
|
|
/// selbst aufgebaut (`WifiNetworkSpecifier` ueber [MlrsNetworkController]),
|
|
/// nicht ueber die Systemeinstellungen - sonst wuerde das mLRS-Netz zur
|
|
/// Standardroute des Geraets und Kartenkacheln/Wetter fielen aus.
|
|
///
|
|
/// Ablauf von [connect]:
|
|
/// 1. Netz mit passendem SSID-Praefix anfordern, auf `available`/
|
|
/// `unavailable` warten (Doku Abschnitt 4).
|
|
/// 2. Zieladresse vorbelegen: konfigurierter Host, sonst die vom Netz
|
|
/// gemeldete Gateway-Adresse, sonst ungelernt (Doku Abschnitt 3).
|
|
/// 3. Nativen UDP-Socket oeffnen, der **socket-genau** (nicht prozessweit)
|
|
/// per `Network.bindSocket()` an das mLRS-Netz gebunden ist (Doku
|
|
/// Abschnitt 4, Stufe 3) - siehe [MlrsNetworkController]-Doku fuer die
|
|
/// Begruendung, warum Prozessbindung hier in der Praxis Kartenkacheln/
|
|
/// Wetter-Requests beschaedigt hat.
|
|
/// 4. Weiterhin auf das Netz hoeren: geht es verloren (`lost`), ist der
|
|
/// Socket ungueltig (Doku Abschnitt 8) und die Verbindung wird mit
|
|
/// [LinkErrorReason.networkLost] beendet. Automatisches Wiederverbinden
|
|
/// mit ansteigender Wartezeit (Doku Abschnitt 8) ist noch nicht
|
|
/// umgesetzt - der Nutzer stoesst es aktuell ueber "Connect" erneut an.
|
|
class UdpTransport implements LinkTransport {
|
|
/// Bekannte Standard-IP der mLRS-WLAN-Bruecke im AP-Modus - letzter
|
|
/// Rueckfall, wenn weder ein Host konfiguriert noch eine Gateway-Adresse
|
|
/// vom Netz gemeldet wurde (siehe [connect]).
|
|
static const _defaultMlrsHost = '192.168.4.55';
|
|
|
|
UdpTransport({
|
|
required Future<String?> Function() getHost,
|
|
required Future<int> Function() getPort,
|
|
required Future<String> Function() getSsidPrefix,
|
|
required Future<String?> Function() getRememberedSsid,
|
|
required Future<void> Function(String ssid) rememberSsid,
|
|
MlrsNetworkController? networkController,
|
|
}) : _getHost = getHost,
|
|
_getPort = getPort,
|
|
_getSsidPrefix = getSsidPrefix,
|
|
_getRememberedSsid = getRememberedSsid,
|
|
_rememberSsid = rememberSsid,
|
|
_networkController =
|
|
networkController ?? MethodChannelMlrsNetworkController();
|
|
|
|
final Future<String?> Function() _getHost;
|
|
final Future<int> Function() _getPort;
|
|
final Future<String> Function() _getSsidPrefix;
|
|
final Future<String?> Function() _getRememberedSsid;
|
|
final Future<void> Function(String ssid) _rememberSsid;
|
|
final MlrsNetworkController _networkController;
|
|
|
|
final _incomingController = StreamController<Uint8List>.broadcast();
|
|
final _stateController = StreamController<LinkState>.broadcast();
|
|
final _ssidController = StreamController<String?>.broadcast();
|
|
|
|
StreamSubscription<MlrsSocketPacket>? _packetSub;
|
|
StreamSubscription<MlrsNetworkEvent>? _networkLossSub;
|
|
InternetAddress? _peerAddress;
|
|
int? _peerPort;
|
|
int? _boundPort;
|
|
bool _socketOpen = false;
|
|
bool? _staConcurrencySupported;
|
|
String? _connectedSsid;
|
|
|
|
LinkState _state = LinkState.disconnected;
|
|
LinkErrorReason? _lastErrorReason;
|
|
String? _lastErrorMessage;
|
|
|
|
@override
|
|
Stream<Uint8List> get incoming => _incomingController.stream;
|
|
|
|
@override
|
|
Stream<LinkState> get state => _stateController.stream;
|
|
|
|
@override
|
|
LinkErrorReason? get lastErrorReason => _lastErrorReason;
|
|
|
|
@override
|
|
String? get lastErrorMessage => _lastErrorMessage;
|
|
|
|
/// Tatsaechlich gebundener lokaler Port, erst nach [connect] bekannt -
|
|
/// weicht von der Vorgabe ab, wenn diese belegt war (Doku 3/9).
|
|
int? get boundPort => _boundPort;
|
|
|
|
/// Gegenstelle, sobald aus dem ersten eingehenden Paket gelernt bzw. aus
|
|
/// Host-Vorgabe/Gateway-Adresse vorbelegt (Doku Abschnitt 3).
|
|
String? get peerDescription =>
|
|
_peerAddress == null ? null : '${_peerAddress!.address}:$_peerPort';
|
|
|
|
/// Ergebnis von `isStaConcurrencyForLocalOnlyConnectionsSupported()`, ab
|
|
/// dem ersten erfolgreichen [connect] verfuegbar - null vorher oder falls
|
|
/// nicht ermittelbar (API < 30, Doku Abschnitt 4/8).
|
|
bool? get staConcurrencySupported => _staConcurrencySupported;
|
|
|
|
/// Tatsaechliche SSID des verbundenen Netzes, sobald von Android
|
|
/// ermittelbar (Doku Abschnitt 8: "verbundenes WLAN anzeigen"). Wird bei
|
|
/// jedem erfolgreichen [connect] auch dauerhaft gemerkt (siehe
|
|
/// [_rememberSsid]), damit kuenftige Verbindungen exakt statt per Praefix
|
|
/// angefragt werden koennen.
|
|
String? get connectedSsid => _connectedSsid;
|
|
|
|
/// Reaktive Aenderungen von [connectedSsid] - liefert erneut ein Ereignis,
|
|
/// wenn Android die SSID nachtraeglich meldet (siehe [_onNetworkEvent]),
|
|
/// da die Zustandsmaschine dafuer kein neues [LinkState] durchlaeuft und
|
|
/// die UI sonst nicht mitbekommt, dass sich der Wert geaendert hat.
|
|
Stream<String?> get connectedSsidChanges => _ssidController.stream;
|
|
|
|
@override
|
|
Future<void> connect() async {
|
|
// Idempotent: mehrere Aufrufer koennen unabhaengig voneinander "sorg
|
|
// dafuer, dass wir verbunden sind" anfordern (z.B. der Auto-Connect-
|
|
// Trigger beim Wechsel in den Fly-Modus UND der MSP-Telemetrie-Consumer,
|
|
// der denselben Transport braucht) - ohne diese Sperre wuerde der
|
|
// zweite Aufruf eine weitere WifiNetworkSpecifier-Anfrage samt
|
|
// Systemdialog auslösen, waehrend die erste noch laeuft.
|
|
if (_state == LinkState.connecting || _state == LinkState.connected) {
|
|
return;
|
|
}
|
|
_lastErrorReason = null;
|
|
_lastErrorMessage = null;
|
|
_setState(LinkState.connecting);
|
|
|
|
final ssidPrefix = await _getSsidPrefix();
|
|
final rememberedSsid = await _getRememberedSsid();
|
|
final firstEvent = Completer<MlrsNetworkEvent>();
|
|
// EIN durchgehendes Abonnement ab hier, statt vorher ein temporaeres
|
|
// request-Abo zu registrieren, es nach dem ersten Ereignis wieder
|
|
// abzumelden und danach durch ein zweites (fuer Netzverlust) zu
|
|
// ersetzen: zwischen `requestSub.cancel()` und dem Aufsetzen des
|
|
// zweiten Listeners lag eine (kurze, aber reale) Luecke ganz ohne
|
|
// Listener auf dem Broadcast-EventChannel-Stream - Ereignisse, die
|
|
// genau in diesem Fenster ankamen, gingen unwiderruflich verloren
|
|
// (Broadcast-Streams puffern nicht fuer spaeter hinzukommende
|
|
// Listener). Betroffen war insbesondere das nachtraeglich eintreffende
|
|
// SSID-Ereignis (`onCapabilitiesChanged`, siehe
|
|
// MlrsNetworkSsidUpdated-Doku): auf echter Hardware (Pixel-Geraet ohne
|
|
// STA-Concurrency) trifft es haeufig so kurz nach dem "available"-
|
|
// Ereignis ein, dass es fast immer in genau diese Luecke fiel - die
|
|
// Verbindungsprotokoll-Pille zeigte dauerhaft "Connected to unknown",
|
|
// obwohl `dumpsys wifi` auf dem Geraet die korrekte SSID zeigte.
|
|
_networkLossSub = _networkController.events.listen((event) {
|
|
if (!firstEvent.isCompleted &&
|
|
(event is MlrsNetworkAvailable || event is MlrsNetworkUnavailable)) {
|
|
firstEvent.complete(event);
|
|
return;
|
|
}
|
|
_onNetworkEvent(event);
|
|
});
|
|
|
|
try {
|
|
await _networkController.requestNetwork(
|
|
ssidPrefix,
|
|
preferredSsid: rememberedSsid,
|
|
);
|
|
final event = await firstEvent.future;
|
|
|
|
if (event is! MlrsNetworkAvailable) {
|
|
_fail(
|
|
LinkErrorReason.networkUnavailable,
|
|
'No WiFi network matching SSID prefix "$ssidPrefix" found, or the '
|
|
'system connection dialog was dismissed.',
|
|
);
|
|
}
|
|
final available = event;
|
|
|
|
_connectedSsid = available.ssid;
|
|
_ssidController.add(_connectedSsid);
|
|
if (available.ssid != null) {
|
|
await _rememberSsid(available.ssid!);
|
|
}
|
|
|
|
final configuredHost = await _getHost();
|
|
final configuredPort = await _getPort();
|
|
// Reihenfolge: explizit konfigurierter Host > vom Netz gemeldete
|
|
// Gateway-Adresse (bei der hier verwendeten lokalen, internetlosen
|
|
// WifiNetworkSpecifier-Anfrage in der Praxis meist gar nicht gesetzt,
|
|
// da solche Netze i.d.R. keine Default-Route haben) > Subnetz-
|
|
// Broadcast-Adresse (Bootstrap-Fallback, siehe unten) > fest
|
|
// hinterlegte Standard-IP der mLRS-Bruecke als allerletzter Rueckfall.
|
|
// Diese Vorbelegung wird durch das erste tatsaechlich empfangene Paket
|
|
// in [_onPacket] ueberschrieben - sie greift also nur, solange (noch)
|
|
// kein Paket gelernt wurde.
|
|
//
|
|
// Die Broadcast-Adresse ist der eigentlich robuste Fallback: sendet
|
|
// die App ihre erste MSP-Anfrage dorthin statt an eine geratene feste
|
|
// Unicast-IP, erreicht sie die Bruecke unabhaengig davon, welche IP
|
|
// diese tatsaechlich hat - deren (unicast) Antwort lernt dann ueber
|
|
// [_onPacket] die echte Adresse. Das schliesst genau die Luecke, die
|
|
// entsteht, wenn die Bruecke selbst nie unaufgefordert sendet UND die
|
|
// geratene Standard-IP falsch liegt (dann kaeme ohne Broadcast
|
|
// ueberhaupt kein Paket an, aus dem gelernt werden koennte).
|
|
final prefillHost = (configuredHost != null && configuredHost.isNotEmpty)
|
|
? configuredHost
|
|
: (available.gatewayAddress ?? available.broadcastAddress ?? _defaultMlrsHost);
|
|
final parsed = InternetAddress.tryParse(prefillHost);
|
|
if (parsed != null) {
|
|
_peerAddress = parsed;
|
|
_peerPort = configuredPort;
|
|
}
|
|
|
|
// WICHTIG: Auf eingehende Pakete lauschen, BEVOR der native Socket
|
|
// geoeffnet wird, nicht danach. MlrsNetworkPlugin.kt startet seine
|
|
// Empfangsschleife (startReceiveLoop) direkt nach dem Binden des
|
|
// Sockets - noch bevor das openSocket()-Ergebnis ueberhaupt an Dart
|
|
// zurueckgemeldet wird. Wuerden wir wie zuvor erst NACH openSocket()
|
|
// abonnieren, koennte die mLRS-Bruecke ihr erstes (fuer das
|
|
// IP-Lernen entscheidendes) Paket in genau diesem Fenster senden,
|
|
// waehrend der native socketEventSink noch null ist - das Paket waere
|
|
// dann unwiderruflich verloren (kein Buffering auf der Event-Channel-
|
|
// Seite). Durch das vorgezogene Abonnement ist der Sink so gut wie
|
|
// immer gesetzt, bevor die Empfangsschleife ueberhaupt starten kann.
|
|
_packetSub = _networkController.incomingPackets.listen(_onPacket);
|
|
|
|
// Nativer, socket-genau gebundener UDP-Socket statt Prozessbindung
|
|
// (siehe Klassendoku) - der eigentliche RawDatagramSocket lebt in
|
|
// MlrsNetworkPlugin.kt, hier kommen nur noch Bytes ueber den
|
|
// EventChannel an.
|
|
final bindResult = await _networkController.openSocket(configuredPort);
|
|
_socketOpen = true;
|
|
_boundPort = bindResult.boundPort;
|
|
if (bindResult.fallbackFromPort != null) {
|
|
_lastErrorMessage = 'Port ${bindResult.fallbackFromPort} in use, '
|
|
'fell back to ${bindResult.boundPort}.';
|
|
}
|
|
|
|
_staConcurrencySupported =
|
|
await _networkController.isStaConcurrencySupported();
|
|
|
|
_setState(LinkState.connected);
|
|
} on MissingPluginException {
|
|
await _networkLossSub?.cancel();
|
|
_networkLossSub = null;
|
|
_fail(
|
|
LinkErrorReason.unknown,
|
|
'mLRS network channel unavailable on this platform.',
|
|
);
|
|
} on PlatformException catch (e) {
|
|
await _networkLossSub?.cancel();
|
|
_networkLossSub = null;
|
|
await _networkController.releaseNetwork();
|
|
_fail(
|
|
LinkErrorReason.connectionFailed,
|
|
e.message ?? 'Platform error while opening the mLRS socket.',
|
|
);
|
|
}
|
|
}
|
|
|
|
void _onNetworkEvent(MlrsNetworkEvent event) {
|
|
switch (event) {
|
|
case MlrsNetworkSsidUpdated():
|
|
// Android liefert WifiInfo/SSID fuer WifiNetworkSpecifier-Netze oft
|
|
// erst nach onAvailable() nach (siehe MlrsNetworkPlugin.kt) - hier
|
|
// nachtragen und merken, statt auf das schon vergangene erste
|
|
// Ereignis in connect() angewiesen zu sein.
|
|
_connectedSsid = event.ssid;
|
|
_ssidController.add(_connectedSsid);
|
|
unawaited(_rememberSsid(event.ssid));
|
|
case MlrsNetworkLost():
|
|
_networkLossSub?.cancel();
|
|
_networkLossSub = null;
|
|
_packetSub?.cancel();
|
|
_packetSub = null;
|
|
_boundPort = null;
|
|
_connectedSsid = null;
|
|
_ssidController.add(null);
|
|
if (_socketOpen) {
|
|
_socketOpen = false;
|
|
unawaited(_networkController.closeSocket());
|
|
}
|
|
_setError(
|
|
LinkErrorReason.networkLost,
|
|
'The mLRS WiFi network was lost.',
|
|
);
|
|
case MlrsNetworkAvailable() || MlrsNetworkUnavailable():
|
|
// Werden bereits im Listener in connect() ueber firstEvent
|
|
// abgefangen, bevor sie hierher durchgereicht werden (siehe dort) -
|
|
// hier nur zur Vollstaendigkeit des switch, kein weiteres Handling
|
|
// noetig.
|
|
break;
|
|
}
|
|
}
|
|
|
|
void _onPacket(MlrsSocketPacket packet) {
|
|
_peerAddress = InternetAddress.tryParse(packet.address) ?? _peerAddress;
|
|
_peerPort = packet.port;
|
|
_incomingController.add(packet.data);
|
|
}
|
|
|
|
@override
|
|
Future<void> disconnect() async {
|
|
await _networkLossSub?.cancel();
|
|
_networkLossSub = null;
|
|
await _packetSub?.cancel();
|
|
_packetSub = null;
|
|
_boundPort = null;
|
|
_connectedSsid = null;
|
|
_ssidController.add(null);
|
|
if (_socketOpen) {
|
|
_socketOpen = false;
|
|
await _networkController.closeSocket();
|
|
}
|
|
await _networkController.releaseNetwork();
|
|
_setState(LinkState.disconnected);
|
|
}
|
|
|
|
@override
|
|
Future<void> send(Uint8List data) async {
|
|
final peer = _peerAddress;
|
|
final port = _peerPort;
|
|
if (!_socketOpen || peer == null || port == null) {
|
|
throw const LinkConnectException(
|
|
LinkErrorReason.connectionFailed,
|
|
'send() ohne aktive Verbindung oder bekannte Gegenstelle aufgerufen.',
|
|
);
|
|
}
|
|
await _networkController.send(data, peer.address, port);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_networkLossSub?.cancel();
|
|
_packetSub?.cancel();
|
|
if (_socketOpen) {
|
|
_socketOpen = false;
|
|
// dispose() darf nie eine unbehandelte Future-Exception hinterlassen -
|
|
// z.B. wirft der Platform-Channel MissingPluginException, wenn
|
|
// ueberhaupt kein natives Gegenstueck existiert (Tests, Web/Desktop-
|
|
// UI-Vorschau). Der Aufrufer kann an dieser Stelle ohnehin nicht mehr
|
|
// reagieren.
|
|
unawaited(_networkController.closeSocket().catchError((_) {}));
|
|
}
|
|
unawaited(_networkController.releaseNetwork().catchError((_) {}));
|
|
_networkController.dispose();
|
|
_incomingController.close();
|
|
_stateController.close();
|
|
_ssidController.close();
|
|
}
|
|
|
|
void _setError(LinkErrorReason reason, String message) {
|
|
_lastErrorReason = reason;
|
|
_lastErrorMessage = message;
|
|
_setState(LinkState.error);
|
|
}
|
|
|
|
Never _fail(LinkErrorReason reason, String message) {
|
|
_setError(reason, message);
|
|
throw LinkConnectException(reason, message);
|
|
}
|
|
|
|
void _setState(LinkState next) {
|
|
_state = next;
|
|
if (!_stateController.isClosed) _stateController.add(next);
|
|
}
|
|
|
|
LinkState get currentState => _state;
|
|
}
|