wifi connection established by app, process socket binding, primary os connection untouched
This commit is contained in:
@@ -0,0 +1,333 @@
|
|||||||
|
# DMC — Kommunikationsschicht
|
||||||
|
|
||||||
|
Erweiterung der bestehenden Flutter-App um die Verbindung zum Fluggerät.
|
||||||
|
Betrifft **nur** Transport, MSP und die zugehörige Bedienung. Planung, Karte,
|
||||||
|
Wind und Gelände sind vorhanden und werden nicht berührt.
|
||||||
|
|
||||||
|
Stand: nach dem ersten Gerätetest auf Pixel 10a. Die Erkenntnisse daraus sind
|
||||||
|
in Abschnitt 4 eingearbeitet und haben die ursprüngliche Herangehensweise
|
||||||
|
korrigiert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Aufgabe und Aufbau
|
||||||
|
|
||||||
|
```
|
||||||
|
Handy ──WLAN (UDP)──► RadioMaster Pocket (mLRS Tx + Backpack ESP)
|
||||||
|
│ LoRa 2,4 GHz
|
||||||
|
Funke ──CRSF (intern)─────────┤
|
||||||
|
▼
|
||||||
|
XR1 (mLRS Rx) ──UART/MspX──► FC (iNAV, MSP)
|
||||||
|
```
|
||||||
|
|
||||||
|
RC und MSP teilen sich dieselbe LoRa-Strecke. Die Fernsteuerung läuft parallel
|
||||||
|
unverändert weiter.
|
||||||
|
|
||||||
|
**WLAN über UDP ist der einzige Weg auf dieser Hardware.** Bluetooth scheidet
|
||||||
|
aus: Die mLRS Wireless Bridge läuft auf dem Backpack-Chip, und ESP8266/ESP8285
|
||||||
|
sowie die ESP32-Cx-Reihe bieten kein klassisches Bluetooth. Bei
|
||||||
|
RadioMaster-Modulen ist der Backpack ein ESP8285 — WLAN, sonst nichts. Das mit
|
||||||
|
„Bluetooth" beworbene Merkmal der Funke betrifft den Haupt-Chip des Moduls
|
||||||
|
(Simulator-Joystick), nicht die Bridge.
|
||||||
|
|
||||||
|
Später möglich: USB-Seriell als kabelgebundene Rückfallebene.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Transportschnittstelle
|
||||||
|
|
||||||
|
Zuerst bauen. Alles Weitere wird ausschließlich dagegen programmiert.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
enum LinkState { disconnected, connecting, connected, error }
|
||||||
|
|
||||||
|
abstract class LinkTransport {
|
||||||
|
Stream<Uint8List> get incoming;
|
||||||
|
Stream<LinkState> get state;
|
||||||
|
Future<void> send(Uint8List data);
|
||||||
|
Future<void> connect();
|
||||||
|
Future<void> disconnect();
|
||||||
|
void dispose();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementierungen: `UdpTransport` (aktuell), `LoopbackTransport` (Tests ohne
|
||||||
|
Hardware), später `UsbSerialTransport`.
|
||||||
|
|
||||||
|
**Regel, als Abnahmekriterium prüfbar:** kein Netzwerk- oder Plattform-Import
|
||||||
|
außerhalb von `transport/`. Der MSP-Code kennt nur `LinkTransport`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. UDP-Transport
|
||||||
|
|
||||||
|
Die Bridge erzeugt `mLRS-xxxx AP UDP`, ohne Passwort, Port **14550**.
|
||||||
|
|
||||||
|
### Das Umsetzungsmuster
|
||||||
|
|
||||||
|
Kite GC und Mission Planner kommen unabhängig voneinander zum selben Ergebnis.
|
||||||
|
Beide Punkte sind nicht optional — ohne sie empfängt die App **kein einziges Byte**:
|
||||||
|
|
||||||
|
**1. Lokal auf denselben Port binden, den man ansprechen will (14550).**
|
||||||
|
Nicht auf einen flüchtigen Port. WLAN-Telemetriebrücken senden an einen festen
|
||||||
|
Port; ein zufälliger lokaler Port verwirft das stillschweigend. Ist 14550 belegt,
|
||||||
|
ausweichen, aber protokollieren.
|
||||||
|
|
||||||
|
**2. Kein `connect()`. Gegenstelle aus dem Verkehr lernen.**
|
||||||
|
Ziel mit der konfigurierten Adresse vorbelegen (damit das erste Paket rausgeht),
|
||||||
|
danach auf die Quelladresse umstellen, von der tatsächlich Daten kommen. Deckt
|
||||||
|
Lausch-, Client- und Broadcast-Betrieb gleichermaßen ab. Mission Planner führt
|
||||||
|
zusätzlich eine Liste aller Gegenstellen — für uns nicht nötig, aber der Grund
|
||||||
|
ist bedenkenswert: Es kann mehr als eine geben.
|
||||||
|
|
||||||
|
**Zieladresse niemals fest eincodieren.** Vorbelegung aus der Gateway-Adresse
|
||||||
|
des angeforderten Netzes, danach gilt die gelernte Adresse.
|
||||||
|
|
||||||
|
**Lesezeitgrenze kurz halten** (etwa 50 ms). Sie begrenzt, wie lange ein
|
||||||
|
ausgehender Befehl wartet, bis die laufende Leseoperation zurückkehrt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Verbindungsaufbau — der kritische Teil
|
||||||
|
|
||||||
|
### Was der Gerätetest ergeben hat
|
||||||
|
|
||||||
|
Getestet auf Pixel 10a: Verbindung mit dem mLRS-Netz über die **Systemeinstellungen**,
|
||||||
|
danach in der App verbunden.
|
||||||
|
|
||||||
|
- UDP funktionierte, empfangene Pakete zählten hoch ✓
|
||||||
|
- **Kartenkacheln und Wetterabruf fielen aus** ✗
|
||||||
|
|
||||||
|
Ursache: Bei Verbindung über die Systemeinstellungen wird das mLRS-Netz zur
|
||||||
|
**Standardroute des Geräts** — für alles. Dass es kein Internet hat, hindert
|
||||||
|
Android nicht daran; bestätigt der Nutzer den „kein Internet"-Hinweis, bleibt
|
||||||
|
das Netz aktiv und bevorzugt.
|
||||||
|
|
||||||
|
### Die Lösung: Verbindung ausschließlich aus der App
|
||||||
|
|
||||||
|
`WifiNetworkSpecifier` ist genau dafür gebaut. Aus der Android-Referenz:
|
||||||
|
Diese Spezifizierer können ausschließlich für ein lokales WLAN ohne
|
||||||
|
Internet-Fähigkeit verwendet werden; das Gerät wechselt seine Standardroute
|
||||||
|
daher **nicht** auf WLAN, wenn andere Übertragungswege wie Mobilfunk verfügbar
|
||||||
|
sind.
|
||||||
|
|
||||||
|
Das ist keine Nebenwirkung, sondern der Zweck der Schnittstelle.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val specifier = WifiNetworkSpecifier.Builder()
|
||||||
|
.setSsidPattern(PatternMatcher("mLRS-", PatternMatcher.PATTERN_PREFIX))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val request = NetworkRequest.Builder()
|
||||||
|
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
|
||||||
|
.removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
.setNetworkSpecifier(specifier)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
connectivityManager.requestNetwork(request, callback)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Präfix-Muster** statt fester SSID, weil die SSID eine Zufallszahl aus der
|
||||||
|
MAC-Adresse enthält. Der Systemdialog zeigt dann nur die Bridge, der Nutzer
|
||||||
|
bestätigt einmal.
|
||||||
|
- **`removeCapability(NET_CAPABILITY_INTERNET)` ist zwingend** — ohne sie kommt
|
||||||
|
die Anfrage nie zustande, weil das Netz diese Fähigkeit nicht besitzt.
|
||||||
|
- Braucht `CHANGE_NETWORK_STATE` im Manifest.
|
||||||
|
- Die Verbindung ist an die App gebunden und endet, wenn der Request
|
||||||
|
freigegeben wird. Für eine Bodenstation eher praktisch.
|
||||||
|
|
||||||
|
### Bindung ist damit verpflichtend
|
||||||
|
|
||||||
|
Weil das angeforderte Netz **nicht** die Standardroute ist, erreicht ein
|
||||||
|
ungebundener Socket die Bridge nicht. Ablauf im `onAvailable`-Rückruf:
|
||||||
|
|
||||||
|
1. Prozess an das erhaltene `Network` binden
|
||||||
|
2. UDP-Socket in Dart erzeugen (`RawDatagramSocket.bind`)
|
||||||
|
3. Prozessbindung **sofort wieder lösen**
|
||||||
|
|
||||||
|
Android bindet laut Dokumentation alle **künftig erzeugten** Sockets an das
|
||||||
|
gesetzte Netz, und die Bindung haftet am Socket. Der UDP-Socket bleibt daher am
|
||||||
|
WLAN, während spätere Verbindungen — Karten, Wetter — wieder über Mobilfunk
|
||||||
|
laufen. Plattform-Kanal mit zwei Methoden (`bindToNetwork`, `unbind`).
|
||||||
|
|
||||||
|
**Noch unbestätigt:** ob die Prozessmarkierung auch für Sockets greift, die Dart
|
||||||
|
auf eigenen I/O-Threads erzeugt. Der Mechanismus spricht dafür, dokumentiert ist
|
||||||
|
es nicht. Deshalb der Abnahmetest in Abschnitt 8, der genau das prüft.
|
||||||
|
|
||||||
|
**Falls es nicht greift:** UDP-Socket nativ in Kotlin öffnen, per
|
||||||
|
`Network.bindSocket(DatagramSocket)` einzeln binden, Daten über einen
|
||||||
|
EventChannel nach Dart. Android empfiehlt die Socket-genaue Bindung ohnehin
|
||||||
|
gegenüber der Prozessbindung; aus Dart ist sie nur nicht erreichbar, weil weder
|
||||||
|
Socket-Objekt noch Deskriptor herausgegeben werden.
|
||||||
|
|
||||||
|
### Zwei Fallstricke beim Testen
|
||||||
|
|
||||||
|
**Das mLRS-Netz muss in den Systemeinstellungen entfernt werden.** Bleibt es
|
||||||
|
gespeichert, verbindet sich das Telefon automatisch wieder darauf, es wird
|
||||||
|
erneut zur Standardroute, und der Fehler tritt auf, obwohl der Code stimmt.
|
||||||
|
|
||||||
|
**Der Test braucht aktive mobile Daten.** Ohne zweites Netz funktioniert alles
|
||||||
|
auch ohne Bindung, und man misst nichts.
|
||||||
|
|
||||||
|
### Gleichzeitige Nutzung prüfen
|
||||||
|
|
||||||
|
Ab Android 12 können Geräte parallel mit einem lokalen Gerät und dem primären
|
||||||
|
Netz verbunden bleiben. Abfragbar über
|
||||||
|
`WifiManager.isStaConcurrencyForLocalOnlyConnectionsSupported()`. Wird das
|
||||||
|
unterstützt (auf aktuellen Pixel-Geräten der Fall), bleibt sogar das heimische
|
||||||
|
WLAN nutzbar. Nicht voraussetzen, aber abfragen und im Verbindungsprotokoll
|
||||||
|
festhalten — es erklärt Verhaltensunterschiede zwischen Geräten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Berechtigungen
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
`CHANGE_NETWORK_STATE` für `requestNetwork` — die fehlt in Mission Planner,
|
||||||
|
weshalb dieser Weg dort gar nicht möglich ist.
|
||||||
|
`WAKE_LOCK` ist im Flug nötig, sonst schläft das Gerät während der Telemetrie ein.
|
||||||
|
|
||||||
|
`CHANGE_WIFI_MULTICAST_STATE` erst ergänzen, wenn sich zeigt, dass die Bridge
|
||||||
|
per Broadcast sendet — dann wird zusätzlich ein MulticastLock gebraucht.
|
||||||
|
|
||||||
|
`android:usesCleartextTraffic="true"` nur nötig, falls die Bridge je über HTTP
|
||||||
|
angesprochen wird. Für UDP nicht erforderlich.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. MSP
|
||||||
|
|
||||||
|
### Rahmenformat
|
||||||
|
|
||||||
|
iNAV erwartet **MSPv2**. MSPv1 reicht nicht.
|
||||||
|
|
||||||
|
```
|
||||||
|
'$' 'X' <dir> <flag:1> <function:2 LE> <payloadSize:2 LE> <payload> <crc:1>
|
||||||
|
dir: '<' Anfrage, '>' Antwort, '!' Fehler
|
||||||
|
crc: CRC8 DVB-S2 über flag..payload
|
||||||
|
```
|
||||||
|
|
||||||
|
Zu bauen: Kodierer, **Streaming-Dekodierer als Zustandsautomat** (der Byte-Strom
|
||||||
|
kommt fragmentiert an — mehrere Pakete in einem Lesevorgang, ein Paket über
|
||||||
|
mehrere Lesevorgänge), CRC-Prüfung, Verwerfen fehlerhafter Rahmen ohne den
|
||||||
|
Strom zu verlieren.
|
||||||
|
|
||||||
|
### Polling
|
||||||
|
|
||||||
|
MSP ist Frage und Antwort — von selbst kommt nichts. Eigener Takt:
|
||||||
|
|
||||||
|
| Zweck | Rate |
|
||||||
|
|---|---|
|
||||||
|
| Position, Lage, Höhe | 5–10 Hz |
|
||||||
|
| Status, Flugmodus | 2 Hz |
|
||||||
|
| Spannung, Strom | 1 Hz |
|
||||||
|
|
||||||
|
Immer nur **eine offene Anfrage**, mit Zeitgrenze (etwa 500 ms) und Wiederholung.
|
||||||
|
Sonst laufen bei schwacher Strecke die Antworten durcheinander.
|
||||||
|
|
||||||
|
**Befehlsnummern nicht aus dem Gedächtnis übernehmen.** Gegen `msp_protocol.h`
|
||||||
|
und `msp_protocol_v2_inav.h` im iNAV-Repository prüfen und als Konstanten an
|
||||||
|
einer Stelle sammeln.
|
||||||
|
|
||||||
|
### Reihenfolge
|
||||||
|
|
||||||
|
1. Rahmen kodieren/dekodieren, Tests gegen aufgezeichnete Bytefolgen
|
||||||
|
2. Telemetrie lesen, in die vorhandenen `LIVE`-Zeilen einspeisen
|
||||||
|
3. Erst danach Missionsupload
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Gegenstelle (keine App-Arbeit, aber Voraussetzung)
|
||||||
|
|
||||||
|
Damit beim Testen klar ist, woran es liegt, wenn nichts ankommt.
|
||||||
|
|
||||||
|
**mLRS Rx:** `Rx Ser Link Mode` = `mspX`, `Rx Ser Baudrate` = 115200,
|
||||||
|
`Rx Snd RcChannel` = `rc override` oder `rc channels`.
|
||||||
|
|
||||||
|
**mLRS Tx:** `Tx Ch Source` = `crsf`. In EdgeTX internes Modul auf CRSF mit
|
||||||
|
**400 k** Baud (nicht 5,25 M wie bei ELRS).
|
||||||
|
|
||||||
|
**iNAV:** MSP auf dem UART aktivieren, an dem der Empfänger hängt (UART2
|
||||||
|
empfohlen), **„Serial RX" auf diesem Port aus**. Baudrate ≥ 115200 — darunter
|
||||||
|
gehen Nachrichten verloren. Receiver-Tab: Receiver Mode `MSP`, RSSI Source `MSP`.
|
||||||
|
|
||||||
|
Bekannte Grenze: Im 2,4-GHz-FLRC-Modus ist die RC-Rate über MSP auf 37 Hz
|
||||||
|
begrenzt. Für eine autonome Fixed-Wing unkritisch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Bedienung
|
||||||
|
|
||||||
|
### Fly-Modus
|
||||||
|
|
||||||
|
Beim Wechsel: Netz anfordern, nach `onAvailable` Socket erzeugen und binden,
|
||||||
|
Polling starten. Beim Verlassen Socket schließen und den Netz-Request freigeben.
|
||||||
|
|
||||||
|
**Telemetriealter dauerhaft sichtbar**, nicht nur ein Verbindungssymbol. Im Feld
|
||||||
|
ist das der einzige verlässliche Hinweis, ob die Werte noch stimmen. Vorschlag:
|
||||||
|
grün < 2 s, bernstein < 5 s, rot darüber.
|
||||||
|
|
||||||
|
Sicherheitsrelevante Anzeigen bei ausbleibender Telemetrie kennzeichnen,
|
||||||
|
**nicht** den letzten Wert stehenlassen.
|
||||||
|
|
||||||
|
Wiederverbinden mit ansteigender Wartezeit (1, 2, 4 … max. 30 s). Nach
|
||||||
|
Netzabriss den Socket verwerfen und samt Bindungsfenster neu erzeugen — laut
|
||||||
|
Dokumentation hören so gebundene Sockets bei Netztrennung bewusst auf zu
|
||||||
|
funktionieren.
|
||||||
|
|
||||||
|
### Einstellungen, Abschnitt „Verbindung"
|
||||||
|
|
||||||
|
- **Art:** WLAN · USB (später)
|
||||||
|
- **SSID-Präfix:** Vorgabe `mLRS-`, überschreibbar
|
||||||
|
- **Port:** Vorgabe 14550, überschreibbar
|
||||||
|
- **Beim Wechsel in Fly automatisch verbinden:** an/aus, Standard an
|
||||||
|
- **Verbindungsprotokoll:** Textansicht der letzten Ereignisse — Netz angefordert,
|
||||||
|
verfügbar, Socket gebunden, gelernte Gegenstelle, Paketzähler, Fehler. Dazu
|
||||||
|
einmalig das Ergebnis von `isStaConcurrencyForLocalOnlyConnectionsSupported()`.
|
||||||
|
Spart im Feld viel Ratearbeit.
|
||||||
|
- **Hinweistext:** dass das mLRS-Netz nicht in den Systemeinstellungen
|
||||||
|
gespeichert sein soll.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Abnahmekriterien
|
||||||
|
|
||||||
|
- [ ] Verbindung erfolgt ausschließlich über `WifiNetworkSpecifier` aus der App,
|
||||||
|
nicht über die Systemeinstellungen
|
||||||
|
- [ ] **Während aktiver Telemetrie funktionieren Kartenkacheln und Wetterabruf
|
||||||
|
weiter** — das ist der eigentliche Prüfstein, mit aktiven mobilen Daten testen
|
||||||
|
- [ ] Socket bindet auf 14550; ist der Port belegt, wird ausgewichen und geloggt
|
||||||
|
- [ ] Gegenstelle wird aus dem ersten eingehenden Paket gelernt und im Protokoll
|
||||||
|
angezeigt
|
||||||
|
- [ ] Keine feste IP-Adresse im Quelltext
|
||||||
|
- [ ] MSP-Dekodierer verarbeitet beliebig fragmentierte Eingaben korrekt
|
||||||
|
(Test: dieselbe Bytefolge in Stücken von 1, 7 und 512 Byte)
|
||||||
|
- [ ] Fehlerhafte Rahmen werden verworfen, ohne den Strom zu verlieren
|
||||||
|
- [ ] Nur eine offene Anfrage gleichzeitig; Zeitüberschreitung führt zu
|
||||||
|
Wiederholung, nicht zum Hängen
|
||||||
|
- [ ] Telemetriealter immer sichtbar und farblich abgestuft
|
||||||
|
- [ ] Verbindungsabriss → Socket wird verworfen und mit Bindungsfenster neu
|
||||||
|
erzeugt, Zustand sichtbar
|
||||||
|
- [ ] Verlassen des Fly-Modus gibt den Netz-Request frei
|
||||||
|
- [ ] Kein Netzwerk-Import außerhalb von `transport/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Hinweise
|
||||||
|
|
||||||
|
- **Ohne Hardware entwickelbar:** `LoopbackTransport` plus aufgezeichnete
|
||||||
|
MSP-Antworten. Erst danach am echten Gerät gegenprüfen.
|
||||||
|
- Die App steuert das Fluggerät nicht. RC bleibt vollständig bei der
|
||||||
|
Fernsteuerung; MSP dient dem Lesen und dem Übertragen von Missionen.
|
||||||
|
- Referenzen zum Nachschlagen: Kite GC `src-tauri/src/transport/udp.rs`
|
||||||
|
(der Kommentar erklärt das Portbindungs- und Peer-Lernen-Problem ausführlich),
|
||||||
|
Mission Planner `ExtLibs/Comms/CommsUdpSerial.cs` (dieselbe Lösung in C#).
|
||||||
|
Beide lösen das Routing-Problem **nicht** — dort verbindet sich der Nutzer
|
||||||
|
über die Systemeinstellungen, mit demselben Nebeneffekt, den unser Test gezeigt hat.
|
||||||
@@ -19,7 +19,11 @@ android {
|
|||||||
applicationId = "com.dmc.dmc_app"
|
applicationId = "com.dmc.dmc_app"
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = flutter.minSdkVersion
|
// minSdk 29 (statt Flutter-Default 24): WifiNetworkSpecifier/
|
||||||
|
// ConnectivityManager.requestNetwork() fuer den app-initiierten
|
||||||
|
// mLRS-Verbindungsaufbau (Doku Kommunikationsschicht v2, Abschnitt 4)
|
||||||
|
// gibt es erst ab Android 10.
|
||||||
|
minSdk = 29
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
|
|||||||
@@ -5,6 +5,14 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
|
||||||
|
<!-- Fuer WifiNetworkSpecifier/ConnectivityManager.requestNetwork() (Doku
|
||||||
|
Kommunikationsschicht v2, Abschnitt 4/5) - die App baut die
|
||||||
|
WLAN-Verbindung zur mLRS-Bruecke selbst auf, statt sich auf eine
|
||||||
|
manuelle Verbindung ueber die Systemeinstellungen zu verlassen (das
|
||||||
|
wuerde das mLRS-Netz zur Standardroute machen und Kartenkacheln/
|
||||||
|
Wetter kappen). Fehlt Mission Planner, weshalb dieser Weg dort nicht
|
||||||
|
moeglich ist. -->
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>
|
||||||
<!-- Haelt das Geraet waehrend des Flugs wach, sonst schlaeft es ein und
|
<!-- Haelt das Geraet waehrend des Flugs wach, sonst schlaeft es ein und
|
||||||
die Telemetrie reisst ab (Doku Kommunikationsschicht Abschnitt 3).
|
die Telemetrie reisst ab (Doku Kommunikationsschicht Abschnitt 3).
|
||||||
CHANGE_WIFI_MULTICAST_STATE erst ergaenzen, wenn sich zeigt, dass die
|
CHANGE_WIFI_MULTICAST_STATE erst ergaenzen, wenn sich zeigt, dass die
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
package com.dmc.dmc_app
|
package com.dmc.dmc_app
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.EventChannel
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
class MainActivity : FlutterActivity()
|
class MainActivity : FlutterActivity() {
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
|
val plugin = MlrsNetworkPlugin(applicationContext)
|
||||||
|
MethodChannel(
|
||||||
|
flutterEngine.dartExecutor.binaryMessenger,
|
||||||
|
MlrsNetworkPlugin.METHOD_CHANNEL,
|
||||||
|
).setMethodCallHandler(plugin)
|
||||||
|
EventChannel(
|
||||||
|
flutterEngine.dartExecutor.binaryMessenger,
|
||||||
|
MlrsNetworkPlugin.EVENT_CHANNEL,
|
||||||
|
).setStreamHandler(plugin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package com.dmc.dmc_app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.LinkProperties
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.NetworkRequest
|
||||||
|
import android.net.wifi.WifiManager
|
||||||
|
import android.net.wifi.WifiNetworkSpecifier
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.os.PatternMatcher
|
||||||
|
import io.flutter.plugin.common.EventChannel
|
||||||
|
import io.flutter.plugin.common.MethodCall
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verbindungsaufbau zur mLRS-WLAN-Bruecke ausschliesslich aus der App heraus
|
||||||
|
* (Doku Kommunikationsschicht v2, Abschnitt 4): das WLAN darf nicht die
|
||||||
|
* Standardroute des Geraets werden (sonst fallen Kartenkacheln/Wetter aus),
|
||||||
|
* daher `WifiNetworkSpecifier` statt Verbindung ueber die Systemeinstellungen.
|
||||||
|
*
|
||||||
|
* Ablauf: [requestNetwork] loest `ConnectivityManager.requestNetwork()` aus,
|
||||||
|
* das System zeigt dabei automatisch einen Bestaetigungsdialog fuer die
|
||||||
|
* passende SSID. `onAvailable`/`onUnavailable`/`onLost` werden ueber den
|
||||||
|
* EventChannel nach Dart gemeldet, das den restlichen Ablauf (Socket binden,
|
||||||
|
* lauschen, Fehleranzeige) uebernimmt.
|
||||||
|
*
|
||||||
|
* [bindProcessToNetwork]/[unbindProcessFromNetwork] setzen/loeschen die
|
||||||
|
* Prozessbindung fuer das kurze Fenster, in dem Dart den UDP-Socket erzeugt -
|
||||||
|
* siehe UdpTransport.connect() fuer die Gegenseite dieses Musters.
|
||||||
|
*/
|
||||||
|
class MlrsNetworkPlugin(
|
||||||
|
private val context: Context,
|
||||||
|
) : MethodChannel.MethodCallHandler, EventChannel.StreamHandler {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val METHOD_CHANNEL = "com.dmc.dmc_app/mlrs_network"
|
||||||
|
const val EVENT_CHANNEL = "com.dmc.dmc_app/mlrs_network_events"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val connectivityManager =
|
||||||
|
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
|
||||||
|
// NetworkCallback-Methoden laufen ohne expliziten Handler auf einem
|
||||||
|
// internen ConnectivityThread, nicht auf dem Main-Thread - Flutters
|
||||||
|
// EventChannel.EventSink.success() verlangt aber @UiThread (schlaegt
|
||||||
|
// sonst mit "Methods marked with @UiThread must be executed on the main
|
||||||
|
// thread" fehl). Deshalb Callback-Dispatch explizit auf den Main-Looper
|
||||||
|
// legen.
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
private var eventSink: EventChannel.EventSink? = null
|
||||||
|
private var networkCallback: ConnectivityManager.NetworkCallback? = null
|
||||||
|
private var currentNetwork: Network? = null
|
||||||
|
|
||||||
|
override fun onListen(arguments: Any?, sink: EventChannel.EventSink?) {
|
||||||
|
eventSink = sink
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCancel(arguments: Any?) {
|
||||||
|
eventSink = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
when (call.method) {
|
||||||
|
"requestNetwork" -> {
|
||||||
|
val ssidPrefix = call.argument<String>("ssidPrefix")
|
||||||
|
if (ssidPrefix.isNullOrEmpty()) {
|
||||||
|
result.error("invalid_argument", "ssidPrefix darf nicht leer sein", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requestNetwork(ssidPrefix)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"releaseNetwork" -> {
|
||||||
|
releaseNetwork()
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"bindProcessToNetwork" -> {
|
||||||
|
val network = currentNetwork
|
||||||
|
if (network == null) {
|
||||||
|
result.success(false)
|
||||||
|
} else {
|
||||||
|
result.success(connectivityManager.bindProcessToNetwork(network))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"unbindProcessFromNetwork" -> {
|
||||||
|
connectivityManager.bindProcessToNetwork(null)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"isStaConcurrencySupported" -> {
|
||||||
|
result.success(isStaConcurrencySupported())
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestNetwork(ssidPrefix: String) {
|
||||||
|
// Vorherigen Callback zuerst abmelden - sonst haeuften sich bei
|
||||||
|
// mehrfachem Connect-Versuch mehrere registrierte Callbacks an.
|
||||||
|
releaseNetwork()
|
||||||
|
|
||||||
|
val specifier = WifiNetworkSpecifier.Builder()
|
||||||
|
.setSsidPattern(PatternMatcher(ssidPrefix, PatternMatcher.PATTERN_PREFIX))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val request = NetworkRequest.Builder()
|
||||||
|
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
|
||||||
|
.removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
.setNetworkSpecifier(specifier)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||||
|
override fun onAvailable(network: Network) {
|
||||||
|
currentNetwork = network
|
||||||
|
val gateway = gatewayAddressOf(connectivityManager.getLinkProperties(network))
|
||||||
|
eventSink?.success(
|
||||||
|
mapOf("type" to "available", "gatewayAddress" to gateway),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUnavailable() {
|
||||||
|
currentNetwork = null
|
||||||
|
eventSink?.success(mapOf("type" to "unavailable"))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLost(network: Network) {
|
||||||
|
currentNetwork = null
|
||||||
|
eventSink?.success(mapOf("type" to "lost"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
networkCallback = callback
|
||||||
|
connectivityManager.requestNetwork(request, callback, mainHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseNetwork() {
|
||||||
|
networkCallback?.let {
|
||||||
|
try {
|
||||||
|
connectivityManager.unregisterNetworkCallback(it)
|
||||||
|
} catch (_: IllegalArgumentException) {
|
||||||
|
// Callback war bereits abgemeldet (z.B. durch onUnavailable
|
||||||
|
// gefolgt von einem expliziten releaseNetwork()) - unkritisch.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
networkCallback = null
|
||||||
|
currentNetwork = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun gatewayAddressOf(linkProperties: LinkProperties?): String? {
|
||||||
|
val route = linkProperties?.routes?.firstOrNull { it.isDefaultRoute }
|
||||||
|
return route?.gateway?.hostAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isStaConcurrencySupported(): Boolean? {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
|
||||||
|
val wifiManager =
|
||||||
|
context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
|
||||||
|
?: return null
|
||||||
|
return wifiManager.isStaConcurrencyForLocalOnlyConnectionsSupported
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,12 @@ class AppSettingsTable extends Table {
|
|||||||
IntColumn get connectionWifiPort =>
|
IntColumn get connectionWifiPort =>
|
||||||
integer().withDefault(const Constant(kDefaultWifiPort))();
|
integer().withDefault(const Constant(kDefaultWifiPort))();
|
||||||
|
|
||||||
|
/// SSID-Praefix fuer den app-initiierten WLAN-Verbindungsaufbau per
|
||||||
|
/// `WifiNetworkSpecifier` (Doku Kommunikationsschicht v2 Abschnitt 4/8),
|
||||||
|
/// Standard "mLRS-", ueberschreibbar.
|
||||||
|
TextColumn get connectionWifiSsidPrefix =>
|
||||||
|
text().withDefault(const Constant(kDefaultWifiSsidPrefix))();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Set<Column> get primaryKey => {id};
|
Set<Column> get primaryKey => {id};
|
||||||
}
|
}
|
||||||
@@ -89,7 +95,7 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
AppDatabase.forTesting(super.executor);
|
AppDatabase.forTesting(super.executor);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 4;
|
int get schemaVersion => 5;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
@@ -121,6 +127,12 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
appSettingsTable.connectionWifiPort,
|
appSettingsTable.connectionWifiPort,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (from < 5) {
|
||||||
|
await m.addColumn(
|
||||||
|
appSettingsTable,
|
||||||
|
appSettingsTable.connectionWifiSsidPrefix,
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1358,7 +1358,7 @@ class $AppSettingsTableTable extends AppSettingsTable
|
|||||||
false,
|
false,
|
||||||
type: DriftSqlType.string,
|
type: DriftSqlType.string,
|
||||||
requiredDuringInsert: false,
|
requiredDuringInsert: false,
|
||||||
defaultValue: const Constant('bluetooth'),
|
defaultValue: const Constant('wifi'),
|
||||||
);
|
);
|
||||||
static const VerificationMeta _connectionWifiHostMeta =
|
static const VerificationMeta _connectionWifiHostMeta =
|
||||||
const VerificationMeta('connectionWifiHost');
|
const VerificationMeta('connectionWifiHost');
|
||||||
@@ -1382,6 +1382,18 @@ class $AppSettingsTableTable extends AppSettingsTable
|
|||||||
requiredDuringInsert: false,
|
requiredDuringInsert: false,
|
||||||
defaultValue: const Constant(kDefaultWifiPort),
|
defaultValue: const Constant(kDefaultWifiPort),
|
||||||
);
|
);
|
||||||
|
static const VerificationMeta _connectionWifiSsidPrefixMeta =
|
||||||
|
const VerificationMeta('connectionWifiSsidPrefix');
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<String> connectionWifiSsidPrefix =
|
||||||
|
GeneratedColumn<String>(
|
||||||
|
'connection_wifi_ssid_prefix',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.string,
|
||||||
|
requiredDuringInsert: false,
|
||||||
|
defaultValue: const Constant(kDefaultWifiSsidPrefix),
|
||||||
|
);
|
||||||
@override
|
@override
|
||||||
List<GeneratedColumn> get $columns => [
|
List<GeneratedColumn> get $columns => [
|
||||||
id,
|
id,
|
||||||
@@ -1392,6 +1404,7 @@ class $AppSettingsTableTable extends AppSettingsTable
|
|||||||
connectionType,
|
connectionType,
|
||||||
connectionWifiHost,
|
connectionWifiHost,
|
||||||
connectionWifiPort,
|
connectionWifiPort,
|
||||||
|
connectionWifiSsidPrefix,
|
||||||
];
|
];
|
||||||
@override
|
@override
|
||||||
String get aliasedName => _alias ?? actualTableName;
|
String get aliasedName => _alias ?? actualTableName;
|
||||||
@@ -1471,6 +1484,15 @@ class $AppSettingsTableTable extends AppSettingsTable
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (data.containsKey('connection_wifi_ssid_prefix')) {
|
||||||
|
context.handle(
|
||||||
|
_connectionWifiSsidPrefixMeta,
|
||||||
|
connectionWifiSsidPrefix.isAcceptableOrUnknown(
|
||||||
|
data['connection_wifi_ssid_prefix']!,
|
||||||
|
_connectionWifiSsidPrefixMeta,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1512,6 +1534,10 @@ class $AppSettingsTableTable extends AppSettingsTable
|
|||||||
DriftSqlType.int,
|
DriftSqlType.int,
|
||||||
data['${effectivePrefix}connection_wifi_port'],
|
data['${effectivePrefix}connection_wifi_port'],
|
||||||
)!,
|
)!,
|
||||||
|
connectionWifiSsidPrefix: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.string,
|
||||||
|
data['${effectivePrefix}connection_wifi_ssid_prefix'],
|
||||||
|
)!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1536,8 +1562,8 @@ class AppSettingsTableData extends DataClass
|
|||||||
final bool connectionAutoConnect;
|
final bool connectionAutoConnect;
|
||||||
|
|
||||||
/// Gewaehlte Verbindungsart (Doku 7: "Art: WLAN * USB (spaeter)"),
|
/// Gewaehlte Verbindungsart (Doku 7: "Art: WLAN * USB (spaeter)"),
|
||||||
/// gespeichert als [ConnectionType.name]. Standard bleibt Bluetooth, damit
|
/// gespeichert als [ConnectionType.name]. App-Default ist WLAN; die
|
||||||
/// bestehende Installationen ihr bisheriges Verhalten behalten.
|
/// Nutzerauswahl wird danach dauerhaft gemerkt.
|
||||||
final String connectionType;
|
final String connectionType;
|
||||||
|
|
||||||
/// Vom Nutzer eingetragene Ziel-IP der mLRS-WLAN-Bruecke (Doku 3:
|
/// Vom Nutzer eingetragene Ziel-IP der mLRS-WLAN-Bruecke (Doku 3:
|
||||||
@@ -1547,6 +1573,11 @@ class AppSettingsTableData extends DataClass
|
|||||||
|
|
||||||
/// Lokaler UDP-Port fuer die mLRS-WLAN-Bruecke (Doku 3), Standard 14550.
|
/// Lokaler UDP-Port fuer die mLRS-WLAN-Bruecke (Doku 3), Standard 14550.
|
||||||
final int connectionWifiPort;
|
final int connectionWifiPort;
|
||||||
|
|
||||||
|
/// SSID-Praefix fuer den app-initiierten WLAN-Verbindungsaufbau per
|
||||||
|
/// `WifiNetworkSpecifier` (Doku Kommunikationsschicht v2 Abschnitt 4/8),
|
||||||
|
/// Standard "mLRS-", ueberschreibbar.
|
||||||
|
final String connectionWifiSsidPrefix;
|
||||||
const AppSettingsTableData({
|
const AppSettingsTableData({
|
||||||
required this.id,
|
required this.id,
|
||||||
this.activeDroneProfileId,
|
this.activeDroneProfileId,
|
||||||
@@ -1556,6 +1587,7 @@ class AppSettingsTableData extends DataClass
|
|||||||
required this.connectionType,
|
required this.connectionType,
|
||||||
this.connectionWifiHost,
|
this.connectionWifiHost,
|
||||||
required this.connectionWifiPort,
|
required this.connectionWifiPort,
|
||||||
|
required this.connectionWifiSsidPrefix,
|
||||||
});
|
});
|
||||||
@override
|
@override
|
||||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||||
@@ -1578,6 +1610,9 @@ class AppSettingsTableData extends DataClass
|
|||||||
map['connection_wifi_host'] = Variable<String>(connectionWifiHost);
|
map['connection_wifi_host'] = Variable<String>(connectionWifiHost);
|
||||||
}
|
}
|
||||||
map['connection_wifi_port'] = Variable<int>(connectionWifiPort);
|
map['connection_wifi_port'] = Variable<int>(connectionWifiPort);
|
||||||
|
map['connection_wifi_ssid_prefix'] = Variable<String>(
|
||||||
|
connectionWifiSsidPrefix,
|
||||||
|
);
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1599,6 +1634,7 @@ class AppSettingsTableData extends DataClass
|
|||||||
? const Value.absent()
|
? const Value.absent()
|
||||||
: Value(connectionWifiHost),
|
: Value(connectionWifiHost),
|
||||||
connectionWifiPort: Value(connectionWifiPort),
|
connectionWifiPort: Value(connectionWifiPort),
|
||||||
|
connectionWifiSsidPrefix: Value(connectionWifiSsidPrefix),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1624,6 +1660,9 @@ class AppSettingsTableData extends DataClass
|
|||||||
json['connectionWifiHost'],
|
json['connectionWifiHost'],
|
||||||
),
|
),
|
||||||
connectionWifiPort: serializer.fromJson<int>(json['connectionWifiPort']),
|
connectionWifiPort: serializer.fromJson<int>(json['connectionWifiPort']),
|
||||||
|
connectionWifiSsidPrefix: serializer.fromJson<String>(
|
||||||
|
json['connectionWifiSsidPrefix'],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@override
|
@override
|
||||||
@@ -1640,6 +1679,9 @@ class AppSettingsTableData extends DataClass
|
|||||||
'connectionType': serializer.toJson<String>(connectionType),
|
'connectionType': serializer.toJson<String>(connectionType),
|
||||||
'connectionWifiHost': serializer.toJson<String?>(connectionWifiHost),
|
'connectionWifiHost': serializer.toJson<String?>(connectionWifiHost),
|
||||||
'connectionWifiPort': serializer.toJson<int>(connectionWifiPort),
|
'connectionWifiPort': serializer.toJson<int>(connectionWifiPort),
|
||||||
|
'connectionWifiSsidPrefix': serializer.toJson<String>(
|
||||||
|
connectionWifiSsidPrefix,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1652,6 +1694,7 @@ class AppSettingsTableData extends DataClass
|
|||||||
String? connectionType,
|
String? connectionType,
|
||||||
Value<String?> connectionWifiHost = const Value.absent(),
|
Value<String?> connectionWifiHost = const Value.absent(),
|
||||||
int? connectionWifiPort,
|
int? connectionWifiPort,
|
||||||
|
String? connectionWifiSsidPrefix,
|
||||||
}) => AppSettingsTableData(
|
}) => AppSettingsTableData(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
activeDroneProfileId: activeDroneProfileId.present
|
activeDroneProfileId: activeDroneProfileId.present
|
||||||
@@ -1669,6 +1712,8 @@ class AppSettingsTableData extends DataClass
|
|||||||
? connectionWifiHost.value
|
? connectionWifiHost.value
|
||||||
: this.connectionWifiHost,
|
: this.connectionWifiHost,
|
||||||
connectionWifiPort: connectionWifiPort ?? this.connectionWifiPort,
|
connectionWifiPort: connectionWifiPort ?? this.connectionWifiPort,
|
||||||
|
connectionWifiSsidPrefix:
|
||||||
|
connectionWifiSsidPrefix ?? this.connectionWifiSsidPrefix,
|
||||||
);
|
);
|
||||||
AppSettingsTableData copyWithCompanion(AppSettingsTableCompanion data) {
|
AppSettingsTableData copyWithCompanion(AppSettingsTableCompanion data) {
|
||||||
return AppSettingsTableData(
|
return AppSettingsTableData(
|
||||||
@@ -1694,6 +1739,9 @@ class AppSettingsTableData extends DataClass
|
|||||||
connectionWifiPort: data.connectionWifiPort.present
|
connectionWifiPort: data.connectionWifiPort.present
|
||||||
? data.connectionWifiPort.value
|
? data.connectionWifiPort.value
|
||||||
: this.connectionWifiPort,
|
: this.connectionWifiPort,
|
||||||
|
connectionWifiSsidPrefix: data.connectionWifiSsidPrefix.present
|
||||||
|
? data.connectionWifiSsidPrefix.value
|
||||||
|
: this.connectionWifiSsidPrefix,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1707,7 +1755,8 @@ class AppSettingsTableData extends DataClass
|
|||||||
..write('connectionAutoConnect: $connectionAutoConnect, ')
|
..write('connectionAutoConnect: $connectionAutoConnect, ')
|
||||||
..write('connectionType: $connectionType, ')
|
..write('connectionType: $connectionType, ')
|
||||||
..write('connectionWifiHost: $connectionWifiHost, ')
|
..write('connectionWifiHost: $connectionWifiHost, ')
|
||||||
..write('connectionWifiPort: $connectionWifiPort')
|
..write('connectionWifiPort: $connectionWifiPort, ')
|
||||||
|
..write('connectionWifiSsidPrefix: $connectionWifiSsidPrefix')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
@@ -1722,6 +1771,7 @@ class AppSettingsTableData extends DataClass
|
|||||||
connectionType,
|
connectionType,
|
||||||
connectionWifiHost,
|
connectionWifiHost,
|
||||||
connectionWifiPort,
|
connectionWifiPort,
|
||||||
|
connectionWifiSsidPrefix,
|
||||||
);
|
);
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
@@ -1734,7 +1784,8 @@ class AppSettingsTableData extends DataClass
|
|||||||
other.connectionAutoConnect == this.connectionAutoConnect &&
|
other.connectionAutoConnect == this.connectionAutoConnect &&
|
||||||
other.connectionType == this.connectionType &&
|
other.connectionType == this.connectionType &&
|
||||||
other.connectionWifiHost == this.connectionWifiHost &&
|
other.connectionWifiHost == this.connectionWifiHost &&
|
||||||
other.connectionWifiPort == this.connectionWifiPort);
|
other.connectionWifiPort == this.connectionWifiPort &&
|
||||||
|
other.connectionWifiSsidPrefix == this.connectionWifiSsidPrefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
|
class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
|
||||||
@@ -1746,6 +1797,7 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
|
|||||||
final Value<String> connectionType;
|
final Value<String> connectionType;
|
||||||
final Value<String?> connectionWifiHost;
|
final Value<String?> connectionWifiHost;
|
||||||
final Value<int> connectionWifiPort;
|
final Value<int> connectionWifiPort;
|
||||||
|
final Value<String> connectionWifiSsidPrefix;
|
||||||
const AppSettingsTableCompanion({
|
const AppSettingsTableCompanion({
|
||||||
this.id = const Value.absent(),
|
this.id = const Value.absent(),
|
||||||
this.activeDroneProfileId = const Value.absent(),
|
this.activeDroneProfileId = const Value.absent(),
|
||||||
@@ -1755,6 +1807,7 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
|
|||||||
this.connectionType = const Value.absent(),
|
this.connectionType = const Value.absent(),
|
||||||
this.connectionWifiHost = const Value.absent(),
|
this.connectionWifiHost = const Value.absent(),
|
||||||
this.connectionWifiPort = const Value.absent(),
|
this.connectionWifiPort = const Value.absent(),
|
||||||
|
this.connectionWifiSsidPrefix = const Value.absent(),
|
||||||
});
|
});
|
||||||
AppSettingsTableCompanion.insert({
|
AppSettingsTableCompanion.insert({
|
||||||
this.id = const Value.absent(),
|
this.id = const Value.absent(),
|
||||||
@@ -1765,6 +1818,7 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
|
|||||||
this.connectionType = const Value.absent(),
|
this.connectionType = const Value.absent(),
|
||||||
this.connectionWifiHost = const Value.absent(),
|
this.connectionWifiHost = const Value.absent(),
|
||||||
this.connectionWifiPort = const Value.absent(),
|
this.connectionWifiPort = const Value.absent(),
|
||||||
|
this.connectionWifiSsidPrefix = const Value.absent(),
|
||||||
});
|
});
|
||||||
static Insertable<AppSettingsTableData> custom({
|
static Insertable<AppSettingsTableData> custom({
|
||||||
Expression<int>? id,
|
Expression<int>? id,
|
||||||
@@ -1775,6 +1829,7 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
|
|||||||
Expression<String>? connectionType,
|
Expression<String>? connectionType,
|
||||||
Expression<String>? connectionWifiHost,
|
Expression<String>? connectionWifiHost,
|
||||||
Expression<int>? connectionWifiPort,
|
Expression<int>? connectionWifiPort,
|
||||||
|
Expression<String>? connectionWifiSsidPrefix,
|
||||||
}) {
|
}) {
|
||||||
return RawValuesInsertable({
|
return RawValuesInsertable({
|
||||||
if (id != null) 'id': id,
|
if (id != null) 'id': id,
|
||||||
@@ -1790,6 +1845,8 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
|
|||||||
'connection_wifi_host': connectionWifiHost,
|
'connection_wifi_host': connectionWifiHost,
|
||||||
if (connectionWifiPort != null)
|
if (connectionWifiPort != null)
|
||||||
'connection_wifi_port': connectionWifiPort,
|
'connection_wifi_port': connectionWifiPort,
|
||||||
|
if (connectionWifiSsidPrefix != null)
|
||||||
|
'connection_wifi_ssid_prefix': connectionWifiSsidPrefix,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1802,6 +1859,7 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
|
|||||||
Value<String>? connectionType,
|
Value<String>? connectionType,
|
||||||
Value<String?>? connectionWifiHost,
|
Value<String?>? connectionWifiHost,
|
||||||
Value<int>? connectionWifiPort,
|
Value<int>? connectionWifiPort,
|
||||||
|
Value<String>? connectionWifiSsidPrefix,
|
||||||
}) {
|
}) {
|
||||||
return AppSettingsTableCompanion(
|
return AppSettingsTableCompanion(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -1814,6 +1872,8 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
|
|||||||
connectionType: connectionType ?? this.connectionType,
|
connectionType: connectionType ?? this.connectionType,
|
||||||
connectionWifiHost: connectionWifiHost ?? this.connectionWifiHost,
|
connectionWifiHost: connectionWifiHost ?? this.connectionWifiHost,
|
||||||
connectionWifiPort: connectionWifiPort ?? this.connectionWifiPort,
|
connectionWifiPort: connectionWifiPort ?? this.connectionWifiPort,
|
||||||
|
connectionWifiSsidPrefix:
|
||||||
|
connectionWifiSsidPrefix ?? this.connectionWifiSsidPrefix,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1850,6 +1910,11 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
|
|||||||
if (connectionWifiPort.present) {
|
if (connectionWifiPort.present) {
|
||||||
map['connection_wifi_port'] = Variable<int>(connectionWifiPort.value);
|
map['connection_wifi_port'] = Variable<int>(connectionWifiPort.value);
|
||||||
}
|
}
|
||||||
|
if (connectionWifiSsidPrefix.present) {
|
||||||
|
map['connection_wifi_ssid_prefix'] = Variable<String>(
|
||||||
|
connectionWifiSsidPrefix.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1863,7 +1928,8 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
|
|||||||
..write('connectionAutoConnect: $connectionAutoConnect, ')
|
..write('connectionAutoConnect: $connectionAutoConnect, ')
|
||||||
..write('connectionType: $connectionType, ')
|
..write('connectionType: $connectionType, ')
|
||||||
..write('connectionWifiHost: $connectionWifiHost, ')
|
..write('connectionWifiHost: $connectionWifiHost, ')
|
||||||
..write('connectionWifiPort: $connectionWifiPort')
|
..write('connectionWifiPort: $connectionWifiPort, ')
|
||||||
|
..write('connectionWifiSsidPrefix: $connectionWifiSsidPrefix')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
@@ -2507,6 +2573,7 @@ typedef $$AppSettingsTableTableCreateCompanionBuilder =
|
|||||||
Value<String> connectionType,
|
Value<String> connectionType,
|
||||||
Value<String?> connectionWifiHost,
|
Value<String?> connectionWifiHost,
|
||||||
Value<int> connectionWifiPort,
|
Value<int> connectionWifiPort,
|
||||||
|
Value<String> connectionWifiSsidPrefix,
|
||||||
});
|
});
|
||||||
typedef $$AppSettingsTableTableUpdateCompanionBuilder =
|
typedef $$AppSettingsTableTableUpdateCompanionBuilder =
|
||||||
AppSettingsTableCompanion Function({
|
AppSettingsTableCompanion Function({
|
||||||
@@ -2518,6 +2585,7 @@ typedef $$AppSettingsTableTableUpdateCompanionBuilder =
|
|||||||
Value<String> connectionType,
|
Value<String> connectionType,
|
||||||
Value<String?> connectionWifiHost,
|
Value<String?> connectionWifiHost,
|
||||||
Value<int> connectionWifiPort,
|
Value<int> connectionWifiPort,
|
||||||
|
Value<String> connectionWifiSsidPrefix,
|
||||||
});
|
});
|
||||||
|
|
||||||
class $$AppSettingsTableTableFilterComposer
|
class $$AppSettingsTableTableFilterComposer
|
||||||
@@ -2568,6 +2636,11 @@ class $$AppSettingsTableTableFilterComposer
|
|||||||
column: $table.connectionWifiPort,
|
column: $table.connectionWifiPort,
|
||||||
builder: (column) => ColumnFilters(column),
|
builder: (column) => ColumnFilters(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ColumnFilters<String> get connectionWifiSsidPrefix => $composableBuilder(
|
||||||
|
column: $table.connectionWifiSsidPrefix,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class $$AppSettingsTableTableOrderingComposer
|
class $$AppSettingsTableTableOrderingComposer
|
||||||
@@ -2618,6 +2691,11 @@ class $$AppSettingsTableTableOrderingComposer
|
|||||||
column: $table.connectionWifiPort,
|
column: $table.connectionWifiPort,
|
||||||
builder: (column) => ColumnOrderings(column),
|
builder: (column) => ColumnOrderings(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<String> get connectionWifiSsidPrefix => $composableBuilder(
|
||||||
|
column: $table.connectionWifiSsidPrefix,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class $$AppSettingsTableTableAnnotationComposer
|
class $$AppSettingsTableTableAnnotationComposer
|
||||||
@@ -2666,6 +2744,11 @@ class $$AppSettingsTableTableAnnotationComposer
|
|||||||
column: $table.connectionWifiPort,
|
column: $table.connectionWifiPort,
|
||||||
builder: (column) => column,
|
builder: (column) => column,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
GeneratedColumn<String> get connectionWifiSsidPrefix => $composableBuilder(
|
||||||
|
column: $table.connectionWifiSsidPrefix,
|
||||||
|
builder: (column) => column,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class $$AppSettingsTableTableTableManager
|
class $$AppSettingsTableTableTableManager
|
||||||
@@ -2713,6 +2796,7 @@ class $$AppSettingsTableTableTableManager
|
|||||||
Value<String> connectionType = const Value.absent(),
|
Value<String> connectionType = const Value.absent(),
|
||||||
Value<String?> connectionWifiHost = const Value.absent(),
|
Value<String?> connectionWifiHost = const Value.absent(),
|
||||||
Value<int> connectionWifiPort = const Value.absent(),
|
Value<int> connectionWifiPort = const Value.absent(),
|
||||||
|
Value<String> connectionWifiSsidPrefix = const Value.absent(),
|
||||||
}) => AppSettingsTableCompanion(
|
}) => AppSettingsTableCompanion(
|
||||||
id: id,
|
id: id,
|
||||||
activeDroneProfileId: activeDroneProfileId,
|
activeDroneProfileId: activeDroneProfileId,
|
||||||
@@ -2722,6 +2806,7 @@ class $$AppSettingsTableTableTableManager
|
|||||||
connectionType: connectionType,
|
connectionType: connectionType,
|
||||||
connectionWifiHost: connectionWifiHost,
|
connectionWifiHost: connectionWifiHost,
|
||||||
connectionWifiPort: connectionWifiPort,
|
connectionWifiPort: connectionWifiPort,
|
||||||
|
connectionWifiSsidPrefix: connectionWifiSsidPrefix,
|
||||||
),
|
),
|
||||||
createCompanionCallback:
|
createCompanionCallback:
|
||||||
({
|
({
|
||||||
@@ -2733,6 +2818,7 @@ class $$AppSettingsTableTableTableManager
|
|||||||
Value<String> connectionType = const Value.absent(),
|
Value<String> connectionType = const Value.absent(),
|
||||||
Value<String?> connectionWifiHost = const Value.absent(),
|
Value<String?> connectionWifiHost = const Value.absent(),
|
||||||
Value<int> connectionWifiPort = const Value.absent(),
|
Value<int> connectionWifiPort = const Value.absent(),
|
||||||
|
Value<String> connectionWifiSsidPrefix = const Value.absent(),
|
||||||
}) => AppSettingsTableCompanion.insert(
|
}) => AppSettingsTableCompanion.insert(
|
||||||
id: id,
|
id: id,
|
||||||
activeDroneProfileId: activeDroneProfileId,
|
activeDroneProfileId: activeDroneProfileId,
|
||||||
@@ -2742,6 +2828,7 @@ class $$AppSettingsTableTableTableManager
|
|||||||
connectionType: connectionType,
|
connectionType: connectionType,
|
||||||
connectionWifiHost: connectionWifiHost,
|
connectionWifiHost: connectionWifiHost,
|
||||||
connectionWifiPort: connectionWifiPort,
|
connectionWifiPort: connectionWifiPort,
|
||||||
|
connectionWifiSsidPrefix: connectionWifiSsidPrefix,
|
||||||
),
|
),
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
|
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class AppSettingsRepository {
|
|||||||
ConnectionType connectionType,
|
ConnectionType connectionType,
|
||||||
String? connectionWifiHost,
|
String? connectionWifiHost,
|
||||||
int connectionWifiPort,
|
int connectionWifiPort,
|
||||||
|
String connectionWifiSsidPrefix,
|
||||||
})> load() async {
|
})> load() async {
|
||||||
final row = await (_db.select(_db.appSettingsTable)
|
final row = await (_db.select(_db.appSettingsTable)
|
||||||
..where((s) => s.id.equals(_rowId)))
|
..where((s) => s.id.equals(_rowId)))
|
||||||
@@ -37,6 +38,8 @@ class AppSettingsRepository {
|
|||||||
),
|
),
|
||||||
connectionWifiHost: row?.connectionWifiHost,
|
connectionWifiHost: row?.connectionWifiHost,
|
||||||
connectionWifiPort: row?.connectionWifiPort ?? kDefaultWifiPort,
|
connectionWifiPort: row?.connectionWifiPort ?? kDefaultWifiPort,
|
||||||
|
connectionWifiSsidPrefix:
|
||||||
|
row?.connectionWifiSsidPrefix ?? kDefaultWifiSsidPrefix,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +107,18 @@ class AppSettingsRepository {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// SSID-Praefix fuer den app-initiierten WLAN-Verbindungsaufbau (Doku
|
||||||
|
/// Kommunikationsschicht v2 Abschnitt 4/8), Standard "mLRS-".
|
||||||
|
Future<String> loadConnectionWifiSsidPrefix() async =>
|
||||||
|
(await load()).connectionWifiSsidPrefix;
|
||||||
|
|
||||||
|
Future<void> setConnectionWifiSsidPrefix(String prefix) async {
|
||||||
|
await _upsert(AppSettingsTableCompanion(
|
||||||
|
id: const Value(_rowId),
|
||||||
|
connectionWifiSsidPrefix: Value(prefix),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _upsert(AppSettingsTableCompanion companion) async {
|
Future<void> _upsert(AppSettingsTableCompanion companion) async {
|
||||||
await _db.into(_db.appSettingsTable).insertOnConflictUpdate(companion);
|
await _db.into(_db.appSettingsTable).insertOnConflictUpdate(companion);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,3 +13,8 @@ enum ConnectionType {
|
|||||||
|
|
||||||
/// Vorgabe-Port der mLRS-WLAN-Bruecke (Doku Abschnitt 3: "Port 14550").
|
/// Vorgabe-Port der mLRS-WLAN-Bruecke (Doku Abschnitt 3: "Port 14550").
|
||||||
const int kDefaultWifiPort = 14550;
|
const int kDefaultWifiPort = 14550;
|
||||||
|
|
||||||
|
/// Vorgabe-SSID-Praefix der mLRS-WLAN-Bruecke (Doku Kommunikationsschicht v2
|
||||||
|
/// Abschnitt 3/8: "mLRS-xxxx AP UDP" - die SSID enthaelt eine Zufallszahl,
|
||||||
|
/// daher Praefixvergleich statt exakter SSID).
|
||||||
|
const String kDefaultWifiSsidPrefix = 'mLRS-';
|
||||||
|
|||||||
@@ -30,6 +30,18 @@ enum LinkErrorReason {
|
|||||||
/// getrennt.
|
/// getrennt.
|
||||||
connectionFailed,
|
connectionFailed,
|
||||||
|
|
||||||
|
/// Die per WifiNetworkSpecifier angeforderte mLRS-Netz konnte nicht
|
||||||
|
/// zustande kommen (Doku Kommunikationsschicht v2, Abschnitt 4) - z.B.
|
||||||
|
/// Nutzer hat den Systemdialog abgelehnt/keine passende SSID in Reichweite.
|
||||||
|
/// Android meldet das ueber `onUnavailable()` statt einer Exception.
|
||||||
|
networkUnavailable,
|
||||||
|
|
||||||
|
/// Das zuvor verfuegbare mLRS-Netz wurde waehrend einer bestehenden
|
||||||
|
/// Verbindung getrennt (`onLost()`) - z.B. ausser Reichweite oder Bridge
|
||||||
|
/// ausgeschaltet. Der Socket ist damit ungueltig und muss samt
|
||||||
|
/// Bindungsfenster neu erzeugt werden (Doku Abschnitt 8).
|
||||||
|
networkLost,
|
||||||
|
|
||||||
/// Sonstiger, nicht naeher klassifizierter Fehler.
|
/// Sonstiger, nicht naeher klassifizierter Fehler.
|
||||||
unknown,
|
unknown,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// Ereignis der per [MlrsNetworkController.requestNetwork] angeforderten
|
||||||
|
/// mLRS-WLAN-Verbindung (Doku Kommunikationsschicht v2, Abschnitt 4).
|
||||||
|
sealed class MlrsNetworkEvent {
|
||||||
|
const MlrsNetworkEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Das Netz ist verfuegbar (Nutzer hat den Systemdialog bestaetigt).
|
||||||
|
/// [gatewayAddress] ist die Gateway-Adresse des Netzes, falls ermittelbar -
|
||||||
|
/// dient als Vorbelegung der Zieladresse, bevor eine Gegenstelle aus dem
|
||||||
|
/// Verkehr gelernt wurde (Doku Abschnitt 3).
|
||||||
|
class MlrsNetworkAvailable extends MlrsNetworkEvent {
|
||||||
|
const MlrsNetworkAvailable({this.gatewayAddress});
|
||||||
|
|
||||||
|
final String? gatewayAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Anfrage konnte nicht erfuellt werden (Nutzer hat den Systemdialog
|
||||||
|
/// abgelehnt, keine passende SSID in Reichweite, oder Systemtimeout).
|
||||||
|
class MlrsNetworkUnavailable extends MlrsNetworkEvent {
|
||||||
|
const MlrsNetworkUnavailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein zuvor verfuegbares Netz wurde waehrend einer bestehenden Verbindung
|
||||||
|
/// getrennt (Doku Abschnitt 8: "Bindung haftet am Socket ... hoeren bewusst
|
||||||
|
/// auf zu funktionieren").
|
||||||
|
class MlrsNetworkLost extends MlrsNetworkEvent {
|
||||||
|
const MlrsNetworkLost();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plattform-Bruecke zum app-initiierten WLAN-Verbindungsaufbau
|
||||||
|
/// (`WifiNetworkSpecifier`/`ConnectivityManager`, Doku Kommunikationsschicht
|
||||||
|
/// v2 Abschnitt 4) - kapselt den einzigen Platform-Channel-Zugriff dieses
|
||||||
|
/// Features. [UdpTransport] programmiert ausschliesslich gegen dieses
|
||||||
|
/// Interface, damit es in Tests durch eine Fake-Implementierung ersetzt
|
||||||
|
/// werden kann, ohne einen echten Android-Kanal zu brauchen.
|
||||||
|
abstract class MlrsNetworkController {
|
||||||
|
/// Ergebnisse zur zuletzt per [requestNetwork] gestellten Anfrage -
|
||||||
|
/// broadcast, damit sowohl [UdpTransport.connect] (wartet auf das erste
|
||||||
|
/// Ereignis) als auch ein laufender Verbindungsueberwacher (reagiert auf
|
||||||
|
/// ein spaeteres [MlrsNetworkLost]) gleichzeitig zuhoeren koennen.
|
||||||
|
Stream<MlrsNetworkEvent> get events;
|
||||||
|
|
||||||
|
/// Loest `ConnectivityManager.requestNetwork()` mit einem
|
||||||
|
/// `WifiNetworkSpecifier` aus, dessen SSID-Muster auf [ssidPrefix] passt.
|
||||||
|
/// Das Ergebnis kommt asynchron ueber [events] (available/unavailable).
|
||||||
|
Future<void> requestNetwork(String ssidPrefix);
|
||||||
|
|
||||||
|
/// Meldet den Callback ab und geht damit von der aktuellen Anfrage
|
||||||
|
/// zurueck - keine weiteren [events] danach, bis erneut angefragt wird.
|
||||||
|
Future<void> releaseNetwork();
|
||||||
|
|
||||||
|
/// Bindet den App-Prozess kurzzeitig an das zuletzt verfuegbare Netz
|
||||||
|
/// (Doku Abschnitt 4: "Bindungsfenster"). Liefert false, wenn aktuell kein
|
||||||
|
/// Netz verfuegbar ist.
|
||||||
|
Future<bool> bindProcessToNetwork();
|
||||||
|
|
||||||
|
/// Loest die Prozessbindung wieder - muss unmittelbar nach dem Erzeugen
|
||||||
|
/// des UDP-Sockets aufgerufen werden, sonst laufen auch Karten/Wetter
|
||||||
|
/// ueber das mLRS-Netz statt der Standardroute.
|
||||||
|
Future<void> unbindProcessFromNetwork();
|
||||||
|
|
||||||
|
/// Ergebnis von
|
||||||
|
/// `WifiManager.isStaConcurrencyForLocalOnlyConnectionsSupported()` (Doku
|
||||||
|
/// Abschnitt 4/8) - null falls nicht ermittelbar (API < 30). Nur zur
|
||||||
|
/// Anzeige/zum Verbindungsprotokoll, nicht handlungsrelevant.
|
||||||
|
Future<bool?> isStaConcurrencySupported();
|
||||||
|
|
||||||
|
void dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Echte Implementierung ueber MethodChannel/EventChannel gegen
|
||||||
|
/// `MlrsNetworkPlugin.kt` (android/app/.../MlrsNetworkPlugin.kt).
|
||||||
|
class MethodChannelMlrsNetworkController implements MlrsNetworkController {
|
||||||
|
MethodChannelMlrsNetworkController()
|
||||||
|
: _methodChannel = const MethodChannel('com.dmc.dmc_app/mlrs_network'),
|
||||||
|
_eventChannel =
|
||||||
|
const EventChannel('com.dmc.dmc_app/mlrs_network_events');
|
||||||
|
|
||||||
|
final MethodChannel _methodChannel;
|
||||||
|
final EventChannel _eventChannel;
|
||||||
|
|
||||||
|
@override
|
||||||
|
late final Stream<MlrsNetworkEvent> events =
|
||||||
|
_eventChannel.receiveBroadcastStream().map(_toEvent);
|
||||||
|
|
||||||
|
MlrsNetworkEvent _toEvent(dynamic raw) {
|
||||||
|
final map = Map<Object?, Object?>.from(raw as Map);
|
||||||
|
return switch (map['type']) {
|
||||||
|
'available' => MlrsNetworkAvailable(
|
||||||
|
gatewayAddress: map['gatewayAddress'] as String?,
|
||||||
|
),
|
||||||
|
'lost' => const MlrsNetworkLost(),
|
||||||
|
_ => const MlrsNetworkUnavailable(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> requestNetwork(String ssidPrefix) => _methodChannel
|
||||||
|
.invokeMethod('requestNetwork', {'ssidPrefix': ssidPrefix});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> releaseNetwork() => _methodChannel.invokeMethod('releaseNetwork');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> bindProcessToNetwork() async =>
|
||||||
|
(await _methodChannel.invokeMethod<bool>('bindProcessToNetwork')) ??
|
||||||
|
false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> unbindProcessFromNetwork() =>
|
||||||
|
_methodChannel.invokeMethod('unbindProcessFromNetwork');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool?> isStaConcurrencySupported() =>
|
||||||
|
_methodChannel.invokeMethod<bool>('isStaConcurrencySupported');
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
// Keine eigenen Ressourcen - Abonnenten von [events] verwalten ihre
|
||||||
|
// StreamSubscription selbst (siehe UdpTransport.dispose).
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,37 +1,57 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import '../link_transport.dart';
|
import '../link_transport.dart';
|
||||||
|
import 'mlrs_network_controller.dart';
|
||||||
|
|
||||||
/// UDP-Transport zur mLRS-WLAN-Bruecke (Doku Kommunikationsschicht Abschnitt
|
/// UDP-Transport zur mLRS-WLAN-Bruecke (Doku Kommunikationsschicht v2,
|
||||||
/// 3). Erste, einfachste Ausbaustufe: lokal auf [_getPort] binden und auf
|
/// Abschnitt 3/4). Die Verbindung zum WLAN wird ausschliesslich von der App
|
||||||
/// eingehende Pakete hoeren (Stufe 1 - ohne Netzwerk-Bindung, siehe Doku
|
/// selbst aufgebaut (`WifiNetworkSpecifier` ueber [MlrsNetworkController]),
|
||||||
/// Abschnitt 4; Stufe 2/3 sind bewusst noch nicht umgesetzt).
|
/// nicht ueber die Systemeinstellungen - sonst wuerde das mLRS-Netz zur
|
||||||
|
/// Standardroute des Geraets und Kartenkacheln/Wetter fielen aus.
|
||||||
///
|
///
|
||||||
/// Setzt das in Abschnitt 3 beschriebene Muster um: lokal auf dem Zielport
|
/// Ablauf von [connect]:
|
||||||
/// binden statt auf einen fluechtigen Port (sonst kommt kein Byte an), und
|
/// 1. Netz mit passendem SSID-Praefix anfordern, auf `available`/
|
||||||
/// die Gegenstelle ausschliesslich aus dem Verkehr lernen statt sie fest
|
/// `unavailable` warten (Doku Abschnitt 4).
|
||||||
/// einzucodieren. Eine vom Nutzer eingetragene Host-Vorgabe fuellt nur das
|
/// 2. Zieladresse vorbelegen: konfigurierter Host, sonst die vom Netz
|
||||||
/// allererste ausgehende Paket vor.
|
/// gemeldete Gateway-Adresse, sonst ungelernt (Doku Abschnitt 3).
|
||||||
|
/// 3. Kurzes Bindungsfenster: Prozess an das Netz binden, UDP-Socket
|
||||||
|
/// erzeugen, Prozessbindung sofort wieder loesen (Doku Abschnitt 4) - der
|
||||||
|
/// Socket selbst bleibt danach am WLAN gebunden.
|
||||||
|
/// 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 {
|
class UdpTransport implements LinkTransport {
|
||||||
UdpTransport({
|
UdpTransport({
|
||||||
required Future<String?> Function() getHost,
|
required Future<String?> Function() getHost,
|
||||||
required Future<int> Function() getPort,
|
required Future<int> Function() getPort,
|
||||||
|
required Future<String> Function() getSsidPrefix,
|
||||||
|
MlrsNetworkController? networkController,
|
||||||
}) : _getHost = getHost,
|
}) : _getHost = getHost,
|
||||||
_getPort = getPort;
|
_getPort = getPort,
|
||||||
|
_getSsidPrefix = getSsidPrefix,
|
||||||
|
_networkController =
|
||||||
|
networkController ?? MethodChannelMlrsNetworkController();
|
||||||
|
|
||||||
final Future<String?> Function() _getHost;
|
final Future<String?> Function() _getHost;
|
||||||
final Future<int> Function() _getPort;
|
final Future<int> Function() _getPort;
|
||||||
|
final Future<String> Function() _getSsidPrefix;
|
||||||
|
final MlrsNetworkController _networkController;
|
||||||
|
|
||||||
final _incomingController = StreamController<Uint8List>.broadcast();
|
final _incomingController = StreamController<Uint8List>.broadcast();
|
||||||
final _stateController = StreamController<LinkState>.broadcast();
|
final _stateController = StreamController<LinkState>.broadcast();
|
||||||
|
|
||||||
RawDatagramSocket? _socket;
|
RawDatagramSocket? _socket;
|
||||||
StreamSubscription<RawSocketEvent>? _socketSub;
|
StreamSubscription<RawSocketEvent>? _socketSub;
|
||||||
|
StreamSubscription<MlrsNetworkEvent>? _networkLossSub;
|
||||||
InternetAddress? _peerAddress;
|
InternetAddress? _peerAddress;
|
||||||
int? _peerPort;
|
int? _peerPort;
|
||||||
int? _boundPort;
|
int? _boundPort;
|
||||||
|
bool? _staConcurrencySupported;
|
||||||
|
|
||||||
LinkState _state = LinkState.disconnected;
|
LinkState _state = LinkState.disconnected;
|
||||||
LinkErrorReason? _lastErrorReason;
|
LinkErrorReason? _lastErrorReason;
|
||||||
@@ -50,31 +70,63 @@ class UdpTransport implements LinkTransport {
|
|||||||
String? get lastErrorMessage => _lastErrorMessage;
|
String? get lastErrorMessage => _lastErrorMessage;
|
||||||
|
|
||||||
/// Tatsaechlich gebundener lokaler Port, erst nach [connect] bekannt -
|
/// Tatsaechlich gebundener lokaler Port, erst nach [connect] bekannt -
|
||||||
/// weicht von der Vorgabe ab, wenn diese belegt war (Doku 3/8).
|
/// weicht von der Vorgabe ab, wenn diese belegt war (Doku 3/9).
|
||||||
int? get boundPort => _boundPort;
|
int? get boundPort => _boundPort;
|
||||||
|
|
||||||
/// Gegenstelle, sobald aus dem ersten eingehenden Paket gelernt bzw. aus
|
/// Gegenstelle, sobald aus dem ersten eingehenden Paket gelernt bzw. aus
|
||||||
/// der Host-Vorgabe vorbelegt (Doku 3).
|
/// Host-Vorgabe/Gateway-Adresse vorbelegt (Doku Abschnitt 3).
|
||||||
String? get peerDescription =>
|
String? get peerDescription =>
|
||||||
_peerAddress == null ? null : '${_peerAddress!.address}:$_peerPort';
|
_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;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> connect() async {
|
Future<void> connect() async {
|
||||||
_lastErrorReason = null;
|
_lastErrorReason = null;
|
||||||
_lastErrorMessage = null;
|
_lastErrorMessage = null;
|
||||||
_setState(LinkState.connecting);
|
_setState(LinkState.connecting);
|
||||||
|
|
||||||
final configuredPort = await _getPort();
|
final ssidPrefix = await _getSsidPrefix();
|
||||||
final host = await _getHost();
|
final firstEvent = Completer<MlrsNetworkEvent>();
|
||||||
if (host != null && host.isNotEmpty) {
|
final requestSub = _networkController.events.listen((event) {
|
||||||
final parsed = InternetAddress.tryParse(host);
|
if (!firstEvent.isCompleted) firstEvent.complete(event);
|
||||||
if (parsed != null) {
|
});
|
||||||
_peerAddress = parsed;
|
|
||||||
_peerPort = configuredPort;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await _networkController.requestNetwork(ssidPrefix);
|
||||||
|
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 kurzen
|
||||||
|
// Bindungsfensters unten, nicht erst danach (Doku Abschnitt 8).
|
||||||
|
_networkLossSub = _networkController.events.listen(_onNetworkEvent);
|
||||||
|
|
||||||
|
final configuredHost = await _getHost();
|
||||||
|
final configuredPort = await _getPort();
|
||||||
|
final prefillHost = (configuredHost != null && configuredHost.isNotEmpty)
|
||||||
|
? configuredHost
|
||||||
|
: available.gatewayAddress;
|
||||||
|
if (prefillHost != null) {
|
||||||
|
final parsed = InternetAddress.tryParse(prefillHost);
|
||||||
|
if (parsed != null) {
|
||||||
|
_peerAddress = parsed;
|
||||||
|
_peerPort = configuredPort;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _networkController.bindProcessToNetwork();
|
||||||
RawDatagramSocket socket;
|
RawDatagramSocket socket;
|
||||||
try {
|
try {
|
||||||
// reuseAddress: false, damit ein tatsaechlich belegter Port hier
|
// reuseAddress: false, damit ein tatsaechlich belegter Port hier
|
||||||
@@ -86,13 +138,15 @@ class UdpTransport implements LinkTransport {
|
|||||||
reuseAddress: false,
|
reuseAddress: false,
|
||||||
);
|
);
|
||||||
} on SocketException {
|
} on SocketException {
|
||||||
// Port belegt (Doku 3/8: "ist 14550 belegt, auf einen fluechtigen
|
// Port belegt (Doku 3/9: "ist 14550 belegt, ausweichen, aber
|
||||||
// ausweichen, aber das protokollieren") - Ausweichen ist kein Grund,
|
// protokollieren") - Ausweichen ist kein Grund, abzubrechen.
|
||||||
// die Verbindung abzubrechen.
|
|
||||||
socket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0);
|
socket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0);
|
||||||
_lastErrorMessage =
|
_lastErrorMessage =
|
||||||
'Port $configuredPort in use, fell back to ${socket.port}.';
|
'Port $configuredPort in use, fell back to ${socket.port}.';
|
||||||
|
} finally {
|
||||||
|
await _networkController.unbindProcessFromNetwork();
|
||||||
}
|
}
|
||||||
|
|
||||||
_socket = socket;
|
_socket = socket;
|
||||||
_boundPort = socket.port;
|
_boundPort = socket.port;
|
||||||
_socketSub = socket.listen(
|
_socketSub = socket.listen(
|
||||||
@@ -100,12 +154,47 @@ class UdpTransport implements LinkTransport {
|
|||||||
onError: (Object e) =>
|
onError: (Object e) =>
|
||||||
_setError(LinkErrorReason.connectionFailed, e.toString()),
|
_setError(LinkErrorReason.connectionFailed, e.toString()),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
_staConcurrencySupported =
|
||||||
|
await _networkController.isStaConcurrencySupported();
|
||||||
|
|
||||||
_setState(LinkState.connected);
|
_setState(LinkState.connected);
|
||||||
} on SocketException catch (e) {
|
} on SocketException catch (e) {
|
||||||
|
await requestSub.cancel();
|
||||||
|
await _networkLossSub?.cancel();
|
||||||
|
_networkLossSub = null;
|
||||||
|
await _networkController.releaseNetwork();
|
||||||
_fail(LinkErrorReason.connectionFailed, e.message);
|
_fail(LinkErrorReason.connectionFailed, e.message);
|
||||||
|
} on MissingPluginException {
|
||||||
|
await requestSub.cancel();
|
||||||
|
_fail(
|
||||||
|
LinkErrorReason.unknown,
|
||||||
|
'mLRS network channel unavailable on this platform.',
|
||||||
|
);
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
await requestSub.cancel();
|
||||||
|
_fail(
|
||||||
|
LinkErrorReason.unknown,
|
||||||
|
e.message ?? 'Platform error requesting the mLRS network.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onNetworkEvent(MlrsNetworkEvent event) {
|
||||||
|
if (event is! MlrsNetworkLost) return;
|
||||||
|
_networkLossSub?.cancel();
|
||||||
|
_networkLossSub = null;
|
||||||
|
_socketSub?.cancel();
|
||||||
|
_socketSub = null;
|
||||||
|
_socket?.close();
|
||||||
|
_socket = null;
|
||||||
|
_boundPort = null;
|
||||||
|
_setError(
|
||||||
|
LinkErrorReason.networkLost,
|
||||||
|
'The mLRS WiFi network was lost.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _onSocketEvent(RawSocketEvent event) {
|
void _onSocketEvent(RawSocketEvent event) {
|
||||||
if (event != RawSocketEvent.read) return;
|
if (event != RawSocketEvent.read) return;
|
||||||
final datagram = _socket?.receive();
|
final datagram = _socket?.receive();
|
||||||
@@ -117,11 +206,14 @@ class UdpTransport implements LinkTransport {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> disconnect() async {
|
Future<void> disconnect() async {
|
||||||
|
await _networkLossSub?.cancel();
|
||||||
|
_networkLossSub = null;
|
||||||
await _socketSub?.cancel();
|
await _socketSub?.cancel();
|
||||||
_socketSub = null;
|
_socketSub = null;
|
||||||
_socket?.close();
|
_socket?.close();
|
||||||
_socket = null;
|
_socket = null;
|
||||||
_boundPort = null;
|
_boundPort = null;
|
||||||
|
await _networkController.releaseNetwork();
|
||||||
_setState(LinkState.disconnected);
|
_setState(LinkState.disconnected);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,9 +233,12 @@ class UdpTransport implements LinkTransport {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_networkLossSub?.cancel();
|
||||||
_socketSub?.cancel();
|
_socketSub?.cancel();
|
||||||
_socket?.close();
|
_socket?.close();
|
||||||
_socket = null;
|
_socket = null;
|
||||||
|
unawaited(_networkController.releaseNetwork());
|
||||||
|
_networkController.dispose();
|
||||||
_incomingController.close();
|
_incomingController.close();
|
||||||
_stateController.close();
|
_stateController.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ final wifiTransportProvider = Provider<UdpTransport>((ref) {
|
|||||||
final transport = UdpTransport(
|
final transport = UdpTransport(
|
||||||
getHost: settingsRepo.loadConnectionWifiHost,
|
getHost: settingsRepo.loadConnectionWifiHost,
|
||||||
getPort: settingsRepo.loadConnectionWifiPort,
|
getPort: settingsRepo.loadConnectionWifiPort,
|
||||||
|
getSsidPrefix: settingsRepo.loadConnectionWifiSsidPrefix,
|
||||||
);
|
);
|
||||||
ref.onDispose(transport.dispose);
|
ref.onDispose(transport.dispose);
|
||||||
return transport;
|
return transport;
|
||||||
@@ -96,6 +97,7 @@ class WifiConnectionLogNotifier
|
|||||||
StreamSubscription<LinkState>? _stateSub;
|
StreamSubscription<LinkState>? _stateSub;
|
||||||
StreamSubscription<void>? _dataSub;
|
StreamSubscription<void>? _dataSub;
|
||||||
int _packetCount = 0;
|
int _packetCount = 0;
|
||||||
|
String? _lastLoggedPeer;
|
||||||
|
|
||||||
int get packetCount => _packetCount;
|
int get packetCount => _packetCount;
|
||||||
|
|
||||||
@@ -105,8 +107,9 @@ class WifiConnectionLogNotifier
|
|||||||
_stateSub?.cancel();
|
_stateSub?.cancel();
|
||||||
_dataSub?.cancel();
|
_dataSub?.cancel();
|
||||||
_packetCount = 0;
|
_packetCount = 0;
|
||||||
|
_lastLoggedPeer = null;
|
||||||
_stateSub = transport.state.listen(_onStateChanged);
|
_stateSub = transport.state.listen(_onStateChanged);
|
||||||
_dataSub = transport.incoming.listen((_) => _packetCount++);
|
_dataSub = transport.incoming.listen((_) => _onPacket(transport));
|
||||||
ref.onDispose(() {
|
ref.onDispose(() {
|
||||||
_stateSub?.cancel();
|
_stateSub?.cancel();
|
||||||
_dataSub?.cancel();
|
_dataSub?.cancel();
|
||||||
@@ -114,18 +117,38 @@ class WifiConnectionLogNotifier
|
|||||||
return const [];
|
return const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onPacket(UdpTransport transport) {
|
||||||
|
_packetCount++;
|
||||||
|
// Gegenstelle wird aus dem ersten eingehenden Paket gelernt (Doku
|
||||||
|
// Kommunikationsschicht v2, Abschnitt 3/9) - nur beim ersten Mal bzw.
|
||||||
|
// bei Aenderung protokollieren, nicht pro Paket.
|
||||||
|
final peer = transport.peerDescription;
|
||||||
|
if (peer != null && peer != _lastLoggedPeer) {
|
||||||
|
_lastLoggedPeer = peer;
|
||||||
|
_append('Peer learned: $peer');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _onStateChanged(LinkState linkState) {
|
void _onStateChanged(LinkState linkState) {
|
||||||
final transport = ref.read(wifiTransportProvider);
|
final transport = ref.read(wifiTransportProvider);
|
||||||
final message = switch (linkState) {
|
final message = switch (linkState) {
|
||||||
LinkState.disconnected => 'Disconnected',
|
LinkState.disconnected => 'Disconnected',
|
||||||
LinkState.connecting => 'Connecting...',
|
LinkState.connecting => 'Requesting mLRS network...',
|
||||||
LinkState.connected => 'Listening on port ${transport.boundPort}',
|
LinkState.connected =>
|
||||||
|
'Network available, listening on port ${transport.boundPort}'
|
||||||
|
' (STA concurrency: ${_formatStaConcurrency(transport.staConcurrencySupported)})',
|
||||||
LinkState.error => 'Error: '
|
LinkState.error => 'Error: '
|
||||||
'${transport.lastErrorMessage ?? transport.lastErrorReason?.name ?? 'unknown'}',
|
'${transport.lastErrorMessage ?? transport.lastErrorReason?.name ?? 'unknown'}',
|
||||||
};
|
};
|
||||||
_append(message);
|
_append(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _formatStaConcurrency(bool? supported) => switch (supported) {
|
||||||
|
true => 'supported',
|
||||||
|
false => 'not supported',
|
||||||
|
null => 'unknown',
|
||||||
|
};
|
||||||
|
|
||||||
void _append(String message) {
|
void _append(String message) {
|
||||||
final entry = WifiConnectionLogEntry(DateTime.now(), message);
|
final entry = WifiConnectionLogEntry(DateTime.now(), message);
|
||||||
final next = [...state, entry];
|
final next = [...state, entry];
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ class _WifiPanel extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _WifiPanelState extends ConsumerState<_WifiPanel> {
|
class _WifiPanelState extends ConsumerState<_WifiPanel> {
|
||||||
|
late final TextEditingController _ssidPrefixController;
|
||||||
late final TextEditingController _hostController;
|
late final TextEditingController _hostController;
|
||||||
late final TextEditingController _portController;
|
late final TextEditingController _portController;
|
||||||
bool _controllersSeeded = false;
|
bool _controllersSeeded = false;
|
||||||
@@ -128,12 +129,14 @@ class _WifiPanelState extends ConsumerState<_WifiPanel> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_ssidPrefixController = TextEditingController();
|
||||||
_hostController = TextEditingController();
|
_hostController = TextEditingController();
|
||||||
_portController = TextEditingController();
|
_portController = TextEditingController();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_ssidPrefixController.dispose();
|
||||||
_hostController.dispose();
|
_hostController.dispose();
|
||||||
_portController.dispose();
|
_portController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -153,6 +156,7 @@ class _WifiPanelState extends ConsumerState<_WifiPanel> {
|
|||||||
// ueberschrieben.
|
// ueberschrieben.
|
||||||
if (!_controllersSeeded && connectionSettings != null) {
|
if (!_controllersSeeded && connectionSettings != null) {
|
||||||
_controllersSeeded = true;
|
_controllersSeeded = true;
|
||||||
|
_ssidPrefixController.text = connectionSettings.connectionWifiSsidPrefix;
|
||||||
_hostController.text = connectionSettings.connectionWifiHost ?? '';
|
_hostController.text = connectionSettings.connectionWifiHost ?? '';
|
||||||
_portController.text = connectionSettings.connectionWifiPort.toString();
|
_portController.text = connectionSettings.connectionWifiPort.toString();
|
||||||
}
|
}
|
||||||
@@ -171,9 +175,17 @@ class _WifiPanelState extends ConsumerState<_WifiPanel> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
const Text(
|
const Text(
|
||||||
'Opens a local UDP socket and listens for the mLRS bridge '
|
'The app connects to the mLRS bridge WiFi itself (a system dialog '
|
||||||
'(port 14550 by default). The peer address is learned from the '
|
'will ask you to confirm) so your normal mobile data connection '
|
||||||
'first incoming packet.',
|
'keeps working for maps and weather. The peer address is learned '
|
||||||
|
'from the first incoming packet.',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
const Text(
|
||||||
|
'Do not manually connect to or save the mLRS network in your '
|
||||||
|
'system WiFi settings - that would make it the default route and '
|
||||||
|
'break maps/weather.',
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
),
|
),
|
||||||
if (errorReason != null) ...[
|
if (errorReason != null) ...[
|
||||||
@@ -187,15 +199,15 @@ class _WifiPanelState extends ConsumerState<_WifiPanel> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
flex: 2,
|
flex: 2,
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _hostController,
|
controller: _ssidPrefixController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Host (optional)',
|
labelText: 'SSID prefix',
|
||||||
hintText: 'auto (learned from traffic)',
|
|
||||||
isDense: true,
|
isDense: true,
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
onSubmitted: (value) => _saveHost(value),
|
onSubmitted: (value) => _saveSsidPrefix(value),
|
||||||
onEditingComplete: () => _saveHost(_hostController.text),
|
onEditingComplete: () =>
|
||||||
|
_saveSsidPrefix(_ssidPrefixController.text),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -215,6 +227,18 @@ class _WifiPanelState extends ConsumerState<_WifiPanel> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: _hostController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Host (optional)',
|
||||||
|
hintText: 'auto (network gateway, then learned from traffic)',
|
||||||
|
isDense: true,
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onSubmitted: (value) => _saveHost(value),
|
||||||
|
onEditingComplete: () => _saveHost(_hostController.text),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -258,6 +282,15 @@ class _WifiPanelState extends ConsumerState<_WifiPanel> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _saveSsidPrefix(String value) async {
|
||||||
|
final trimmed = value.trim();
|
||||||
|
if (trimmed.isEmpty) return;
|
||||||
|
await ref
|
||||||
|
.read(appSettingsRepositoryProvider)
|
||||||
|
.setConnectionWifiSsidPrefix(trimmed);
|
||||||
|
ref.invalidate(connectionSettingsProvider);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _saveHost(String value) async {
|
Future<void> _saveHost(String value) async {
|
||||||
final trimmed = value.trim();
|
final trimmed = value.trim();
|
||||||
await ref
|
await ref
|
||||||
@@ -293,16 +326,25 @@ class _WifiErrorHint extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final text = switch (reason) {
|
||||||
|
LinkErrorReason.networkUnavailable =>
|
||||||
|
'No matching WiFi network found, or the system connection dialog '
|
||||||
|
'was dismissed. Make sure the mLRS bridge is powered on and not '
|
||||||
|
'saved/connected in system WiFi settings, then tap "Connect" '
|
||||||
|
'again.',
|
||||||
|
LinkErrorReason.networkLost =>
|
||||||
|
'The mLRS WiFi network was lost (out of range or bridge powered '
|
||||||
|
'off). Tap "Connect" to reconnect.',
|
||||||
|
LinkErrorReason.connectionFailed => 'Connection to the bridge failed.',
|
||||||
|
_ => 'Connection error: ${reason.name}.',
|
||||||
|
};
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: DmcColors.warnRed.withValues(alpha: 0.1),
|
color: DmcColors.warnRed.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(text, style: const TextStyle(fontSize: 12)),
|
||||||
'Connection error: ${reason.name}.',
|
|
||||||
style: const TextStyle(fontSize: 12),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -543,7 +585,13 @@ class _ErrorHint extends StatelessWidget {
|
|||||||
'Connection to the device failed.',
|
'Connection to the device failed.',
|
||||||
null,
|
null,
|
||||||
),
|
),
|
||||||
LinkErrorReason.unknown => ('Unknown connection error.', null),
|
// networkUnavailable/networkLost betreffen nur den WLAN-Transport
|
||||||
|
// (siehe _WifiErrorHint) - hier nur der Vollstaendigkeit halber, damit
|
||||||
|
// der gemeinsame LinkErrorReason-Enum exhaustiv bleibt.
|
||||||
|
LinkErrorReason.networkUnavailable ||
|
||||||
|
LinkErrorReason.networkLost ||
|
||||||
|
LinkErrorReason.unknown =>
|
||||||
|
('Unknown connection error.', null),
|
||||||
};
|
};
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
|
|||||||
@@ -106,4 +106,15 @@ void main() {
|
|||||||
|
|
||||||
expect(await repository.loadConnectionWifiHost(), isNull);
|
expect(await repository.loadConnectionWifiHost(), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('SSID-Praefix: Standard ist "mLRS-"', () async {
|
||||||
|
final settings = await repository.load();
|
||||||
|
expect(settings.connectionWifiSsidPrefix, 'mLRS-');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setConnectionWifiSsidPrefix merkt sich den Praefix', () async {
|
||||||
|
await repository.setConnectionWifiSsidPrefix('mLRS-1234');
|
||||||
|
|
||||||
|
expect(await repository.loadConnectionWifiSsidPrefix(), 'mLRS-1234');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,183 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'package:dmc_app/transport/link_transport.dart';
|
import 'package:dmc_app/transport/link_transport.dart';
|
||||||
|
import 'package:dmc_app/transport/udp/mlrs_network_controller.dart';
|
||||||
import 'package:dmc_app/transport/udp/udp_transport.dart';
|
import 'package:dmc_app/transport/udp/udp_transport.dart';
|
||||||
|
|
||||||
|
/// Test-Doppel fuer [MlrsNetworkController] (Doku Kommunikationsschicht v2,
|
||||||
|
/// Abschnitt 4): simuliert Netzanfrage-Ergebnisse ueber [emit], ohne einen
|
||||||
|
/// echten Android-Platform-Channel zu brauchen. Zeichnet Aufrufe auf, damit
|
||||||
|
/// Tests den Bindungsfenster-Ablauf (bind -> Socket -> unbind) pruefen
|
||||||
|
/// koennen.
|
||||||
|
class FakeMlrsNetworkController implements MlrsNetworkController {
|
||||||
|
final _eventsController = StreamController<MlrsNetworkEvent>.broadcast();
|
||||||
|
|
||||||
|
final List<String> requestedSsidPrefixes = [];
|
||||||
|
int releaseCount = 0;
|
||||||
|
int bindCount = 0;
|
||||||
|
int unbindCount = 0;
|
||||||
|
bool bindResult = true;
|
||||||
|
bool? staConcurrencyResult;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<MlrsNetworkEvent> get events => _eventsController.stream;
|
||||||
|
|
||||||
|
void emit(MlrsNetworkEvent event) => _eventsController.add(event);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> requestNetwork(String ssidPrefix) async {
|
||||||
|
requestedSsidPrefixes.add(ssidPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> releaseNetwork() async {
|
||||||
|
releaseCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> bindProcessToNetwork() async {
|
||||||
|
bindCount++;
|
||||||
|
return bindResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> unbindProcessFromNetwork() async {
|
||||||
|
unbindCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool?> isStaConcurrencySupported() async => staConcurrencyResult;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_eventsController.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
// Port 0 laesst das Betriebssystem einen freien Port waehlen, damit die
|
// Echter Timer-Tick statt nur eines Microtasks: laesst die
|
||||||
// Tests nicht mit einer echten mLRS-Bruecke oder parallelen Testlaeufen
|
// async/await-Kette in UdpTransport.connect() (getSsidPrefix ->
|
||||||
// um Port 14550 konkurrieren.
|
// requestNetwork -> Listener-Aufbau) vollstaendig ablaufen, bevor der Test
|
||||||
Future<UdpTransport> openTransport({String? host}) async {
|
// ein Netzwerk-Ereignis einspeist.
|
||||||
|
Future<void> pump() => Future<void>.delayed(const Duration(milliseconds: 10));
|
||||||
|
|
||||||
|
Future<UdpTransport> connectWithFake(
|
||||||
|
FakeMlrsNetworkController fake, {
|
||||||
|
String? host,
|
||||||
|
int port = 0,
|
||||||
|
String ssidPrefix = 'mLRS-',
|
||||||
|
MlrsNetworkEvent event = const MlrsNetworkAvailable(),
|
||||||
|
}) async {
|
||||||
final transport = UdpTransport(
|
final transport = UdpTransport(
|
||||||
getHost: () async => host,
|
getHost: () async => host,
|
||||||
getPort: () async => 0,
|
getPort: () async => port,
|
||||||
|
getSsidPrefix: () async => ssidPrefix,
|
||||||
|
networkController: fake,
|
||||||
);
|
);
|
||||||
await transport.connect();
|
final connectFuture = transport.connect();
|
||||||
|
await pump();
|
||||||
|
fake.emit(event);
|
||||||
|
await connectFuture;
|
||||||
return transport;
|
return transport;
|
||||||
}
|
}
|
||||||
|
|
||||||
test('connect() bindet einen lokalen Port und meldet connected', () async {
|
test('connect() requests the network with the configured SSID prefix',
|
||||||
final transport = await openTransport();
|
() async {
|
||||||
final states = <LinkState>[];
|
final fake = FakeMlrsNetworkController();
|
||||||
transport.state.listen(states.add);
|
final transport =
|
||||||
|
await connectWithFake(fake, ssidPrefix: 'mLRS-1234');
|
||||||
|
|
||||||
expect(transport.currentState, LinkState.connected);
|
expect(fake.requestedSsidPrefixes, ['mLRS-1234']);
|
||||||
expect(transport.boundPort, isNotNull);
|
|
||||||
expect(transport.boundPort, greaterThan(0));
|
|
||||||
|
|
||||||
transport.dispose();
|
transport.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('eingehende Pakete kommen ueber incoming an und lernen die Gegenstelle',
|
test(
|
||||||
|
'connect() binds process to network, creates the socket, then unbinds',
|
||||||
() async {
|
() async {
|
||||||
final transport = await openTransport();
|
final fake = FakeMlrsNetworkController();
|
||||||
|
final transport = await connectWithFake(fake);
|
||||||
|
|
||||||
|
expect(transport.currentState, LinkState.connected);
|
||||||
|
expect(transport.boundPort, isNotNull);
|
||||||
|
expect(fake.bindCount, 1);
|
||||||
|
expect(fake.unbindCount, 1);
|
||||||
|
|
||||||
|
transport.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('connect() fails with networkUnavailable when the request is denied',
|
||||||
|
() async {
|
||||||
|
final fake = FakeMlrsNetworkController();
|
||||||
|
final transport = UdpTransport(
|
||||||
|
getHost: () async => null,
|
||||||
|
getPort: () async => 0,
|
||||||
|
getSsidPrefix: () async => 'mLRS-',
|
||||||
|
networkController: fake,
|
||||||
|
);
|
||||||
|
|
||||||
|
final connectFuture = transport.connect();
|
||||||
|
await pump();
|
||||||
|
fake.emit(const MlrsNetworkUnavailable());
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
connectFuture,
|
||||||
|
throwsA(isA<LinkConnectException>()),
|
||||||
|
);
|
||||||
|
expect(transport.lastErrorReason, LinkErrorReason.networkUnavailable);
|
||||||
|
expect(transport.currentState, LinkState.error);
|
||||||
|
|
||||||
|
transport.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('losing the network while connected closes the socket and reports '
|
||||||
|
'networkLost', () async {
|
||||||
|
final fake = FakeMlrsNetworkController();
|
||||||
|
final transport = await connectWithFake(fake);
|
||||||
|
|
||||||
|
fake.emit(const MlrsNetworkLost());
|
||||||
|
await pump();
|
||||||
|
|
||||||
|
expect(transport.currentState, LinkState.error);
|
||||||
|
expect(transport.lastErrorReason, LinkErrorReason.networkLost);
|
||||||
|
expect(transport.boundPort, isNull);
|
||||||
|
|
||||||
|
transport.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses the reported gateway address as initial peer when no host is '
|
||||||
|
'configured', () async {
|
||||||
|
final fake = FakeMlrsNetworkController();
|
||||||
|
final transport = await connectWithFake(
|
||||||
|
fake,
|
||||||
|
event: const MlrsNetworkAvailable(gatewayAddress: '192.168.4.1'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(transport.peerDescription, startsWith('192.168.4.1'));
|
||||||
|
|
||||||
|
transport.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a configured host overrides the reported gateway address', () async {
|
||||||
|
final fake = FakeMlrsNetworkController();
|
||||||
|
final transport = await connectWithFake(
|
||||||
|
fake,
|
||||||
|
host: '10.0.0.5',
|
||||||
|
event: const MlrsNetworkAvailable(gatewayAddress: '192.168.4.1'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(transport.peerDescription, startsWith('10.0.0.5'));
|
||||||
|
|
||||||
|
transport.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('incoming packets arrive via incoming and learn the peer', () async {
|
||||||
|
final fake = FakeMlrsNetworkController();
|
||||||
|
final transport = await connectWithFake(fake);
|
||||||
final sender = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0);
|
final sender = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
|
|
||||||
final received = <Uint8List>[];
|
final received = <Uint8List>[];
|
||||||
@@ -55,8 +199,9 @@ void main() {
|
|||||||
transport.dispose();
|
transport.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('send() ohne bekannte Gegenstelle wirft LinkConnectException', () async {
|
test('send() without a known peer throws LinkConnectException', () async {
|
||||||
final transport = await openTransport();
|
final fake = FakeMlrsNetworkController();
|
||||||
|
final transport = await connectWithFake(fake);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
() => transport.send(Uint8List.fromList([1])),
|
() => transport.send(Uint8List.fromList([1])),
|
||||||
@@ -66,13 +211,14 @@ void main() {
|
|||||||
transport.dispose();
|
transport.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('send() schickt Bytes an die gelernte Gegenstelle', () async {
|
test('send() sends bytes to the learned peer', () async {
|
||||||
final transport = await openTransport();
|
final fake = FakeMlrsNetworkController();
|
||||||
|
final transport = await connectWithFake(fake);
|
||||||
final peer = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0);
|
final peer = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
|
|
||||||
// Erst "sprechen" lassen, damit der Transport die Gegenstelle lernt
|
// Erst "sprechen" lassen, damit der Transport die Gegenstelle lernt
|
||||||
// (Doku Kommunikationsschicht Abschnitt 3: "Gegenstelle aus dem Verkehr
|
// (Doku Kommunikationsschicht v2 Abschnitt 3: "Gegenstelle aus dem
|
||||||
// lernen").
|
// Verkehr lernen").
|
||||||
peer.send(
|
peer.send(
|
||||||
Uint8List.fromList([9]),
|
Uint8List.fromList([9]),
|
||||||
InternetAddress.loopbackIPv4,
|
InternetAddress.loopbackIPv4,
|
||||||
@@ -99,26 +245,26 @@ void main() {
|
|||||||
transport.dispose();
|
transport.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('disconnect() setzt LinkState.disconnected', () async {
|
test('disconnect() closes the socket and releases the network request',
|
||||||
final transport = await openTransport();
|
() async {
|
||||||
|
final fake = FakeMlrsNetworkController();
|
||||||
|
final transport = await connectWithFake(fake);
|
||||||
|
|
||||||
await transport.disconnect();
|
await transport.disconnect();
|
||||||
|
|
||||||
expect(transport.currentState, LinkState.disconnected);
|
expect(transport.currentState, LinkState.disconnected);
|
||||||
expect(transport.boundPort, isNull);
|
expect(transport.boundPort, isNull);
|
||||||
|
expect(fake.releaseCount, greaterThanOrEqualTo(1));
|
||||||
|
|
||||||
transport.dispose();
|
transport.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('weicht auf einen fluechtigen Port aus, wenn der konfigurierte belegt ist',
|
test(
|
||||||
|
'falls back to an ephemeral port when the configured one is occupied',
|
||||||
() async {
|
() async {
|
||||||
final occupied = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0);
|
final occupied = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0);
|
||||||
final transport = UdpTransport(
|
final fake = FakeMlrsNetworkController();
|
||||||
getHost: () async => null,
|
final transport = await connectWithFake(fake, port: occupied.port);
|
||||||
getPort: () async => occupied.port,
|
|
||||||
);
|
|
||||||
|
|
||||||
await transport.connect();
|
|
||||||
|
|
||||||
expect(transport.currentState, LinkState.connected);
|
expect(transport.currentState, LinkState.connected);
|
||||||
expect(transport.boundPort, isNot(occupied.port));
|
expect(transport.boundPort, isNot(occupied.port));
|
||||||
@@ -128,16 +274,11 @@ void main() {
|
|||||||
transport.dispose();
|
transport.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('konfigurierte Host-Vorgabe wird als initiale Gegenstelle uebernommen',
|
test('reports isStaConcurrencySupported() once connected', () async {
|
||||||
() async {
|
final fake = FakeMlrsNetworkController()..staConcurrencyResult = true;
|
||||||
final transport = UdpTransport(
|
final transport = await connectWithFake(fake);
|
||||||
getHost: () async => '127.0.0.1',
|
|
||||||
getPort: () async => 0,
|
|
||||||
);
|
|
||||||
await transport.connect();
|
|
||||||
|
|
||||||
expect(transport.peerDescription, isNotNull);
|
expect(transport.staConcurrencySupported, isTrue);
|
||||||
expect(transport.peerDescription, startsWith('127.0.0.1'));
|
|
||||||
|
|
||||||
transport.dispose();
|
transport.dispose();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user