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 Function() getHost, required Future Function() getPort, required Future Function() getSsidPrefix, required Future Function() getRememberedSsid, required Future Function(String ssid) rememberSsid, MlrsNetworkController? networkController, }) : _getHost = getHost, _getPort = getPort, _getSsidPrefix = getSsidPrefix, _getRememberedSsid = getRememberedSsid, _rememberSsid = rememberSsid, _networkController = networkController ?? MethodChannelMlrsNetworkController(); final Future Function() _getHost; final Future Function() _getPort; final Future Function() _getSsidPrefix; final Future Function() _getRememberedSsid; final Future Function(String ssid) _rememberSsid; final MlrsNetworkController _networkController; final _incomingController = StreamController.broadcast(); final _stateController = StreamController.broadcast(); final _ssidController = StreamController.broadcast(); StreamSubscription? _packetSub; StreamSubscription? _networkLossSub; InternetAddress? _peerAddress; int? _peerPort; int? _boundPort; bool _socketOpen = false; bool? _staConcurrencySupported; String? _connectedSsid; LinkState _state = LinkState.disconnected; LinkErrorReason? _lastErrorReason; String? _lastErrorMessage; @override Stream get incoming => _incomingController.stream; @override Stream 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 get connectedSsidChanges => _ssidController.stream; @override Future 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(); final requestSub = _networkController.events.listen((event) { if (!firstEvent.isCompleted) firstEvent.complete(event); }); try { await _networkController.requestNetwork( ssidPrefix, preferredSsid: rememberedSsid, ); final event = await firstEvent.future; await requestSub.cancel(); 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; // Ab hier auf Netzverlust reagieren - auch waehrend des Socket-Aufbaus // unten, nicht erst danach (Doku Abschnitt 8). _networkLossSub = _networkController.events.listen(_onNetworkEvent); _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 requestSub.cancel(); _fail( LinkErrorReason.unknown, 'mLRS network channel unavailable on this platform.', ); } on PlatformException catch (e) { await requestSub.cancel(); 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(): // Wird hier nicht erwartet - diese Ereignisse werden nur waehrend // des initialen connect() ueber firstEvent ausgewertet (siehe oben). break; } } void _onPacket(MlrsSocketPacket packet) { _peerAddress = InternetAddress.tryParse(packet.address) ?? _peerAddress; _peerPort = packet.port; _incomingController.add(packet.data); } @override Future 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 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; }