346 lines
14 KiB
Dart
346 lines
14 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>();
|
|
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<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;
|
|
}
|