WIFI connection support added
This commit is contained in:
@@ -0,0 +1,305 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
**MVP: WLAN über UDP.** Bluetooth ist auf dieser Hardware **nicht** möglich —
|
||||||
|
die mLRS Wireless Bridge läuft auf dem Backpack-Chip, und laut mLRS-Dokumentation
|
||||||
|
bieten ESP8266/ESP8285 sowie die ESP32-Cx-Reihe 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 mLRS-Bridge.
|
||||||
|
|
||||||
|
**Vor Beginn prüfen:** Im mLRS Web Flasher zeigt der Punkt „Flash Wireless Bridge"
|
||||||
|
das Backpack-Target. ESP8285 → nur WLAN. ESP32-C3 → WLAN und BLE, kein Classic.
|
||||||
|
|
||||||
|
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` (MVP), `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`.
|
||||||
|
|
||||||
|
Diese Zweiteilung — rohe Bytes unten, Protokoll darüber — entspricht dem Aufbau
|
||||||
|
von Kite GC (`ByteTransport` + Protokollschicht) und hat sich dort bewährt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. UDP-Transport
|
||||||
|
|
||||||
|
Die Bridge erzeugt `mLRS-xxxx AP UDP`, ohne Passwort, Port **14550**.
|
||||||
|
|
||||||
|
### Das entscheidende 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,
|
||||||
|
auf einen flüchtigen ausweichen, aber das 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, die gesendet
|
||||||
|
haben. 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
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Berechtigungen
|
||||||
|
|
||||||
|
Als Ausgangspunkt die Liste, die Mission Planner tatsächlich verwendet:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
`WAKE_LOCK` ist im Flug nötig, sonst schläft das Gerät während der Telemetrie ein.
|
||||||
|
`CHANGE_WIFI_MULTICAST_STATE` erst anfordern, wenn sich zeigt, dass die Bridge
|
||||||
|
per Broadcast sendet — dann wird auch ein MulticastLock gebraucht.
|
||||||
|
|
||||||
|
`android:usesCleartextTraffic="true"` setzen, falls die Bridge je über HTTP
|
||||||
|
angesprochen wird. Für UDP nicht erforderlich.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Die Routing-Frage — Leiter statt Festlegung
|
||||||
|
|
||||||
|
Das mLRS-Netz hat kein Internet. Die Sorge ist, dass Android den Verkehr weiter
|
||||||
|
über Mobilfunk leitet und die UDP-Pakete verschwinden.
|
||||||
|
|
||||||
|
**Wie gravierend das ist, ist offen.** Weder Mission Planner noch Kite GC bindet
|
||||||
|
das Netz — im gesamten Mission-Planner-Quelltext gibt es kein
|
||||||
|
`bindProcessToNetwork` und kein `WifiNetworkSpecifier`, und die dafür nötige
|
||||||
|
Berechtigung `CHANGE_NETWORK_STATE` wird nicht einmal angefordert. Bei
|
||||||
|
`targetSdkVersion 35`. Entweder tritt das Problem seltener auf als beschrieben,
|
||||||
|
oder beide Projekte überlassen die Lösung dem Nutzer (mobile Daten abschalten).
|
||||||
|
|
||||||
|
**Deshalb: von unten nach oben durchprobieren, beim ersten Erfolg aufhören.**
|
||||||
|
|
||||||
|
### Stufe 0 — Messung (zuerst, vor allem anderen)
|
||||||
|
|
||||||
|
Kleiner Versuch auf echter Hardware, **mit aktiven mobilen Daten** — ohne
|
||||||
|
zweites Netz misst man nichts.
|
||||||
|
|
||||||
|
1. UDP-Socket auf 14550 binden, Paket an die Bridge senden
|
||||||
|
2. Prüfen, ob eine Antwort kommt
|
||||||
|
3. Parallel eine HTTP-Anfrage absetzen und prüfen, ob sie durchgeht
|
||||||
|
|
||||||
|
Ergebnis schriftlich festhalten, es entscheidet über alles Weitere.
|
||||||
|
|
||||||
|
### Stufe 1 — ohne Bindung
|
||||||
|
|
||||||
|
Wenn Stufe 0 erfolgreich war: fertig. Kein Sonderfall, keine Plattform-Kanäle.
|
||||||
|
|
||||||
|
### Stufe 2 — Bindungsfenster
|
||||||
|
|
||||||
|
Falls UDP nicht ankommt. Android bindet laut Dokumentation **alle künftig
|
||||||
|
erzeugten Sockets** an das gesetzte Netz, und die Bindung haftet am Socket:
|
||||||
|
|
||||||
|
1. Prozess an das WLAN binden
|
||||||
|
2. UDP-Socket in Dart erzeugen
|
||||||
|
3. Prozessbindung sofort wieder lösen
|
||||||
|
|
||||||
|
Der Socket bleibt am WLAN, spätere Verbindungen (Karten, Wetter) laufen wieder
|
||||||
|
über die Standardroute. Kostet einen Plattform-Kanal mit zwei Methoden.
|
||||||
|
|
||||||
|
**Unbestätigt:** ob die Prozessmarkierung auch für Sockets greift, die Dart auf
|
||||||
|
seinen eigenen I/O-Threads erzeugt. Der Mechanismus spricht dafür, dokumentiert
|
||||||
|
ist es nicht. Deshalb steht Stufe 0 am Anfang.
|
||||||
|
|
||||||
|
Achtung: Trennt sich das Netz, hören so gebundene Sockets nach Dokumentation
|
||||||
|
auf zu funktionieren — bewusst so gestaltet. Die Wiederverbindung muss den
|
||||||
|
Socket verwerfen und neu erzeugen, und dabei das Fenster wiederholen.
|
||||||
|
|
||||||
|
### Stufe 3 — nativer Socket
|
||||||
|
|
||||||
|
Falls Stufe 2 scheitert. UDP-Socket in Kotlin öffnen, per
|
||||||
|
`Network.bindSocket(DatagramSocket)` einzeln binden, Daten über einen
|
||||||
|
EventChannel nach Dart. Android empfiehlt Socket-genaue Bindung ausdrücklich
|
||||||
|
gegenüber der Prozessbindung; aus Dart ist sie nur nicht erreichbar, weil weder
|
||||||
|
Socket-Objekt noch Deskriptor herausgegeben werden.
|
||||||
|
|
||||||
|
### Verbindungsaufbau mit dem WLAN
|
||||||
|
|
||||||
|
Unabhängig von der Stufe. Zwei Wege:
|
||||||
|
|
||||||
|
- **Einfach:** Nutzer verbindet sich über die Systemeinstellungen. Die App prüft
|
||||||
|
nur, ob ein Netz mit Präfix `mLRS-` aktiv ist, und weist sonst darauf hin.
|
||||||
|
- **Komfortabel:** `WifiNetworkSpecifier` mit **Präfix-Muster**
|
||||||
|
(`setSsidPattern("mLRS-", PATTERN_PREFIX)`), weil die SSID eine Zufallszahl
|
||||||
|
enthält. Der Systemdialog zeigt dann nur die Bridge, der Nutzer bestätigt.
|
||||||
|
Braucht `CHANGE_NETWORK_STATE`. Verbindung ist an die App gebunden und wird
|
||||||
|
beim Beenden getrennt — für eine Bodenstation eher praktisch.
|
||||||
|
|
||||||
|
Für den MVP reicht der einfache Weg.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Bedienung
|
||||||
|
|
||||||
|
### Fly-Modus
|
||||||
|
|
||||||
|
Beim Wechsel: Verbindung aufbauen, sobald verbunden Polling starten. Beim
|
||||||
|
Verlassen sauber schließen und — falls Stufe 2 oder 3 — die Bindung lösen.
|
||||||
|
|
||||||
|
**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 Ausbleiben der Telemetrie kennzeichnen, **nicht**
|
||||||
|
den letzten Wert stehenlassen.
|
||||||
|
|
||||||
|
Wiederverbinden mit ansteigender Wartezeit (1, 2, 4 … max. 30 s).
|
||||||
|
|
||||||
|
### Einstellungen, Abschnitt „Verbindung"
|
||||||
|
|
||||||
|
- **Art:** WLAN · USB (später)
|
||||||
|
- **Host/Port:** Vorgabe automatisch (Gateway) / 14550, überschreibbar
|
||||||
|
- **Beim Wechsel in Fly automatisch verbinden:** an/aus, Standard an
|
||||||
|
- **Verbindungsprotokoll:** einfache Textansicht der letzten Ereignisse
|
||||||
|
(verbunden, getrennt, Fehler, gelernte Gegenstelle, Paketzähler). Spart im
|
||||||
|
Feld viel Ratearbeit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Abnahmekriterien
|
||||||
|
|
||||||
|
- [ ] Stufe-0-Messung durchgeführt, Ergebnis dokumentiert, Stufe begründet gewählt
|
||||||
|
- [ ] 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 → automatischer Neuaufbau, Zustand sichtbar
|
||||||
|
- [ ] Verlassen des Fly-Modus schließt Verbindung und löst eine etwaige Bindung
|
||||||
|
- [ ] Kein Netzwerk-Import außerhalb von `transport/`
|
||||||
|
- [ ] Karten und Wetter funktionieren im Fly-Modus weiterhin (bei Stufe 2/3 der
|
||||||
|
eigentliche Prüfstein)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 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`
|
||||||
|
(Kommentar erklärt das Portbindungs- und Peer-Lernen-Problem ausführlich),
|
||||||
|
Mission Planner `ExtLibs/Comms/CommsUdpSerial.cs` (dieselbe Lösung in C#).
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<!-- Fuer OSM-Kartenkacheln, Wind-/Terrain-APIs und die WiFi/TCP-Verbindung
|
<!-- Fuer OSM-Kartenkacheln, Wind-/Terrain-APIs und die WiFi/UDP-Verbindung
|
||||||
zum Flightcontroller (Doku 4.1) auch im Release-Build noetig. -->
|
zum Flightcontroller (Doku 4.1, Kommunikationsschicht Abschnitt 3)
|
||||||
|
auch im Release-Build noetig. -->
|
||||||
<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_WIFI_STATE"/>
|
||||||
|
<!-- Haelt das Geraet waehrend des Flugs wach, sonst schlaeft es ein und
|
||||||
|
die Telemetrie reisst ab (Doku Kommunikationsschicht Abschnitt 3).
|
||||||
|
CHANGE_WIFI_MULTICAST_STATE erst ergaenzen, wenn sich zeigt, dass die
|
||||||
|
Bridge per Broadcast sendet. -->
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||||
<application
|
<application
|
||||||
android:label="dmc_app"
|
android:label="dmc_app"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import 'package:drift/native.dart';
|
|||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import '../../transport/connection_type.dart';
|
||||||
|
|
||||||
part 'app_database.g.dart';
|
part 'app_database.g.dart';
|
||||||
|
|
||||||
/// Persistenz fuer Missionen (Architektur-Doku 4.15): Drift statt Hive, weil
|
/// Persistenz fuer Missionen (Architektur-Doku 4.15): Drift statt Hive, weil
|
||||||
@@ -61,6 +63,21 @@ class AppSettingsTable extends Table {
|
|||||||
BoolColumn get connectionAutoConnect =>
|
BoolColumn get connectionAutoConnect =>
|
||||||
boolean().withDefault(const Constant(true))();
|
boolean().withDefault(const Constant(true))();
|
||||||
|
|
||||||
|
/// Gewaehlte Verbindungsart (Doku 7: "Art: WLAN * USB (spaeter)"),
|
||||||
|
/// gespeichert als [ConnectionType.name]. Standard bleibt Bluetooth, damit
|
||||||
|
/// bestehende Installationen ihr bisheriges Verhalten behalten.
|
||||||
|
TextColumn get connectionType =>
|
||||||
|
text().withDefault(const Constant('bluetooth'))();
|
||||||
|
|
||||||
|
/// Vom Nutzer eingetragene Ziel-IP der mLRS-WLAN-Bruecke (Doku 3:
|
||||||
|
/// "Zieladresse niemals fest eincodieren") - null, solange keine Vorgabe
|
||||||
|
/// gesetzt ist und die Gegenstelle erst aus dem Verkehr gelernt wird.
|
||||||
|
TextColumn get connectionWifiHost => text().nullable()();
|
||||||
|
|
||||||
|
/// Lokaler UDP-Port fuer die mLRS-WLAN-Bruecke (Doku 3), Standard 14550.
|
||||||
|
IntColumn get connectionWifiPort =>
|
||||||
|
integer().withDefault(const Constant(kDefaultWifiPort))();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Set<Column> get primaryKey => {id};
|
Set<Column> get primaryKey => {id};
|
||||||
}
|
}
|
||||||
@@ -72,7 +89,7 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
AppDatabase.forTesting(super.executor);
|
AppDatabase.forTesting(super.executor);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 3;
|
int get schemaVersion => 4;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
@@ -93,6 +110,17 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
appSettingsTable.connectionAutoConnect,
|
appSettingsTable.connectionAutoConnect,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (from < 4) {
|
||||||
|
await m.addColumn(appSettingsTable, appSettingsTable.connectionType);
|
||||||
|
await m.addColumn(
|
||||||
|
appSettingsTable,
|
||||||
|
appSettingsTable.connectionWifiHost,
|
||||||
|
);
|
||||||
|
await m.addColumn(
|
||||||
|
appSettingsTable,
|
||||||
|
appSettingsTable.connectionWifiPort,
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1348,6 +1348,40 @@ class $AppSettingsTableTable extends AppSettingsTable
|
|||||||
),
|
),
|
||||||
defaultValue: const Constant(true),
|
defaultValue: const Constant(true),
|
||||||
);
|
);
|
||||||
|
static const VerificationMeta _connectionTypeMeta = const VerificationMeta(
|
||||||
|
'connectionType',
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<String> connectionType = GeneratedColumn<String>(
|
||||||
|
'connection_type',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.string,
|
||||||
|
requiredDuringInsert: false,
|
||||||
|
defaultValue: const Constant('bluetooth'),
|
||||||
|
);
|
||||||
|
static const VerificationMeta _connectionWifiHostMeta =
|
||||||
|
const VerificationMeta('connectionWifiHost');
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<String> connectionWifiHost =
|
||||||
|
GeneratedColumn<String>(
|
||||||
|
'connection_wifi_host',
|
||||||
|
aliasedName,
|
||||||
|
true,
|
||||||
|
type: DriftSqlType.string,
|
||||||
|
requiredDuringInsert: false,
|
||||||
|
);
|
||||||
|
static const VerificationMeta _connectionWifiPortMeta =
|
||||||
|
const VerificationMeta('connectionWifiPort');
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<int> connectionWifiPort = GeneratedColumn<int>(
|
||||||
|
'connection_wifi_port',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.int,
|
||||||
|
requiredDuringInsert: false,
|
||||||
|
defaultValue: const Constant(kDefaultWifiPort),
|
||||||
|
);
|
||||||
@override
|
@override
|
||||||
List<GeneratedColumn> get $columns => [
|
List<GeneratedColumn> get $columns => [
|
||||||
id,
|
id,
|
||||||
@@ -1355,6 +1389,9 @@ class $AppSettingsTableTable extends AppSettingsTable
|
|||||||
currentMissionId,
|
currentMissionId,
|
||||||
connectionDeviceAddress,
|
connectionDeviceAddress,
|
||||||
connectionAutoConnect,
|
connectionAutoConnect,
|
||||||
|
connectionType,
|
||||||
|
connectionWifiHost,
|
||||||
|
connectionWifiPort,
|
||||||
];
|
];
|
||||||
@override
|
@override
|
||||||
String get aliasedName => _alias ?? actualTableName;
|
String get aliasedName => _alias ?? actualTableName;
|
||||||
@@ -1407,6 +1444,33 @@ class $AppSettingsTableTable extends AppSettingsTable
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (data.containsKey('connection_type')) {
|
||||||
|
context.handle(
|
||||||
|
_connectionTypeMeta,
|
||||||
|
connectionType.isAcceptableOrUnknown(
|
||||||
|
data['connection_type']!,
|
||||||
|
_connectionTypeMeta,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data.containsKey('connection_wifi_host')) {
|
||||||
|
context.handle(
|
||||||
|
_connectionWifiHostMeta,
|
||||||
|
connectionWifiHost.isAcceptableOrUnknown(
|
||||||
|
data['connection_wifi_host']!,
|
||||||
|
_connectionWifiHostMeta,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data.containsKey('connection_wifi_port')) {
|
||||||
|
context.handle(
|
||||||
|
_connectionWifiPortMeta,
|
||||||
|
connectionWifiPort.isAcceptableOrUnknown(
|
||||||
|
data['connection_wifi_port']!,
|
||||||
|
_connectionWifiPortMeta,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1436,6 +1500,18 @@ class $AppSettingsTableTable extends AppSettingsTable
|
|||||||
DriftSqlType.bool,
|
DriftSqlType.bool,
|
||||||
data['${effectivePrefix}connection_auto_connect'],
|
data['${effectivePrefix}connection_auto_connect'],
|
||||||
)!,
|
)!,
|
||||||
|
connectionType: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.string,
|
||||||
|
data['${effectivePrefix}connection_type'],
|
||||||
|
)!,
|
||||||
|
connectionWifiHost: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.string,
|
||||||
|
data['${effectivePrefix}connection_wifi_host'],
|
||||||
|
),
|
||||||
|
connectionWifiPort: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.int,
|
||||||
|
data['${effectivePrefix}connection_wifi_port'],
|
||||||
|
)!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1458,12 +1534,28 @@ class AppSettingsTableData extends DataClass
|
|||||||
|
|
||||||
/// "Beim Wechsel in Fly automatisch verbinden" (Doku 7), Standard an.
|
/// "Beim Wechsel in Fly automatisch verbinden" (Doku 7), Standard an.
|
||||||
final bool connectionAutoConnect;
|
final bool connectionAutoConnect;
|
||||||
|
|
||||||
|
/// Gewaehlte Verbindungsart (Doku 7: "Art: WLAN * USB (spaeter)"),
|
||||||
|
/// gespeichert als [ConnectionType.name]. Standard bleibt Bluetooth, damit
|
||||||
|
/// bestehende Installationen ihr bisheriges Verhalten behalten.
|
||||||
|
final String connectionType;
|
||||||
|
|
||||||
|
/// Vom Nutzer eingetragene Ziel-IP der mLRS-WLAN-Bruecke (Doku 3:
|
||||||
|
/// "Zieladresse niemals fest eincodieren") - null, solange keine Vorgabe
|
||||||
|
/// gesetzt ist und die Gegenstelle erst aus dem Verkehr gelernt wird.
|
||||||
|
final String? connectionWifiHost;
|
||||||
|
|
||||||
|
/// Lokaler UDP-Port fuer die mLRS-WLAN-Bruecke (Doku 3), Standard 14550.
|
||||||
|
final int connectionWifiPort;
|
||||||
const AppSettingsTableData({
|
const AppSettingsTableData({
|
||||||
required this.id,
|
required this.id,
|
||||||
this.activeDroneProfileId,
|
this.activeDroneProfileId,
|
||||||
this.currentMissionId,
|
this.currentMissionId,
|
||||||
this.connectionDeviceAddress,
|
this.connectionDeviceAddress,
|
||||||
required this.connectionAutoConnect,
|
required this.connectionAutoConnect,
|
||||||
|
required this.connectionType,
|
||||||
|
this.connectionWifiHost,
|
||||||
|
required this.connectionWifiPort,
|
||||||
});
|
});
|
||||||
@override
|
@override
|
||||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||||
@@ -1481,6 +1573,11 @@ class AppSettingsTableData extends DataClass
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
map['connection_auto_connect'] = Variable<bool>(connectionAutoConnect);
|
map['connection_auto_connect'] = Variable<bool>(connectionAutoConnect);
|
||||||
|
map['connection_type'] = Variable<String>(connectionType);
|
||||||
|
if (!nullToAbsent || connectionWifiHost != null) {
|
||||||
|
map['connection_wifi_host'] = Variable<String>(connectionWifiHost);
|
||||||
|
}
|
||||||
|
map['connection_wifi_port'] = Variable<int>(connectionWifiPort);
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1497,6 +1594,11 @@ class AppSettingsTableData extends DataClass
|
|||||||
? const Value.absent()
|
? const Value.absent()
|
||||||
: Value(connectionDeviceAddress),
|
: Value(connectionDeviceAddress),
|
||||||
connectionAutoConnect: Value(connectionAutoConnect),
|
connectionAutoConnect: Value(connectionAutoConnect),
|
||||||
|
connectionType: Value(connectionType),
|
||||||
|
connectionWifiHost: connectionWifiHost == null && nullToAbsent
|
||||||
|
? const Value.absent()
|
||||||
|
: Value(connectionWifiHost),
|
||||||
|
connectionWifiPort: Value(connectionWifiPort),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1517,6 +1619,11 @@ class AppSettingsTableData extends DataClass
|
|||||||
connectionAutoConnect: serializer.fromJson<bool>(
|
connectionAutoConnect: serializer.fromJson<bool>(
|
||||||
json['connectionAutoConnect'],
|
json['connectionAutoConnect'],
|
||||||
),
|
),
|
||||||
|
connectionType: serializer.fromJson<String>(json['connectionType']),
|
||||||
|
connectionWifiHost: serializer.fromJson<String?>(
|
||||||
|
json['connectionWifiHost'],
|
||||||
|
),
|
||||||
|
connectionWifiPort: serializer.fromJson<int>(json['connectionWifiPort']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@override
|
@override
|
||||||
@@ -1530,6 +1637,9 @@ class AppSettingsTableData extends DataClass
|
|||||||
connectionDeviceAddress,
|
connectionDeviceAddress,
|
||||||
),
|
),
|
||||||
'connectionAutoConnect': serializer.toJson<bool>(connectionAutoConnect),
|
'connectionAutoConnect': serializer.toJson<bool>(connectionAutoConnect),
|
||||||
|
'connectionType': serializer.toJson<String>(connectionType),
|
||||||
|
'connectionWifiHost': serializer.toJson<String?>(connectionWifiHost),
|
||||||
|
'connectionWifiPort': serializer.toJson<int>(connectionWifiPort),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1539,6 +1649,9 @@ class AppSettingsTableData extends DataClass
|
|||||||
Value<int?> currentMissionId = const Value.absent(),
|
Value<int?> currentMissionId = const Value.absent(),
|
||||||
Value<String?> connectionDeviceAddress = const Value.absent(),
|
Value<String?> connectionDeviceAddress = const Value.absent(),
|
||||||
bool? connectionAutoConnect,
|
bool? connectionAutoConnect,
|
||||||
|
String? connectionType,
|
||||||
|
Value<String?> connectionWifiHost = const Value.absent(),
|
||||||
|
int? connectionWifiPort,
|
||||||
}) => AppSettingsTableData(
|
}) => AppSettingsTableData(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
activeDroneProfileId: activeDroneProfileId.present
|
activeDroneProfileId: activeDroneProfileId.present
|
||||||
@@ -1551,6 +1664,11 @@ class AppSettingsTableData extends DataClass
|
|||||||
? connectionDeviceAddress.value
|
? connectionDeviceAddress.value
|
||||||
: this.connectionDeviceAddress,
|
: this.connectionDeviceAddress,
|
||||||
connectionAutoConnect: connectionAutoConnect ?? this.connectionAutoConnect,
|
connectionAutoConnect: connectionAutoConnect ?? this.connectionAutoConnect,
|
||||||
|
connectionType: connectionType ?? this.connectionType,
|
||||||
|
connectionWifiHost: connectionWifiHost.present
|
||||||
|
? connectionWifiHost.value
|
||||||
|
: this.connectionWifiHost,
|
||||||
|
connectionWifiPort: connectionWifiPort ?? this.connectionWifiPort,
|
||||||
);
|
);
|
||||||
AppSettingsTableData copyWithCompanion(AppSettingsTableCompanion data) {
|
AppSettingsTableData copyWithCompanion(AppSettingsTableCompanion data) {
|
||||||
return AppSettingsTableData(
|
return AppSettingsTableData(
|
||||||
@@ -1567,6 +1685,15 @@ class AppSettingsTableData extends DataClass
|
|||||||
connectionAutoConnect: data.connectionAutoConnect.present
|
connectionAutoConnect: data.connectionAutoConnect.present
|
||||||
? data.connectionAutoConnect.value
|
? data.connectionAutoConnect.value
|
||||||
: this.connectionAutoConnect,
|
: this.connectionAutoConnect,
|
||||||
|
connectionType: data.connectionType.present
|
||||||
|
? data.connectionType.value
|
||||||
|
: this.connectionType,
|
||||||
|
connectionWifiHost: data.connectionWifiHost.present
|
||||||
|
? data.connectionWifiHost.value
|
||||||
|
: this.connectionWifiHost,
|
||||||
|
connectionWifiPort: data.connectionWifiPort.present
|
||||||
|
? data.connectionWifiPort.value
|
||||||
|
: this.connectionWifiPort,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1577,7 +1704,10 @@ class AppSettingsTableData extends DataClass
|
|||||||
..write('activeDroneProfileId: $activeDroneProfileId, ')
|
..write('activeDroneProfileId: $activeDroneProfileId, ')
|
||||||
..write('currentMissionId: $currentMissionId, ')
|
..write('currentMissionId: $currentMissionId, ')
|
||||||
..write('connectionDeviceAddress: $connectionDeviceAddress, ')
|
..write('connectionDeviceAddress: $connectionDeviceAddress, ')
|
||||||
..write('connectionAutoConnect: $connectionAutoConnect')
|
..write('connectionAutoConnect: $connectionAutoConnect, ')
|
||||||
|
..write('connectionType: $connectionType, ')
|
||||||
|
..write('connectionWifiHost: $connectionWifiHost, ')
|
||||||
|
..write('connectionWifiPort: $connectionWifiPort')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
@@ -1589,6 +1719,9 @@ class AppSettingsTableData extends DataClass
|
|||||||
currentMissionId,
|
currentMissionId,
|
||||||
connectionDeviceAddress,
|
connectionDeviceAddress,
|
||||||
connectionAutoConnect,
|
connectionAutoConnect,
|
||||||
|
connectionType,
|
||||||
|
connectionWifiHost,
|
||||||
|
connectionWifiPort,
|
||||||
);
|
);
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
@@ -1598,7 +1731,10 @@ class AppSettingsTableData extends DataClass
|
|||||||
other.activeDroneProfileId == this.activeDroneProfileId &&
|
other.activeDroneProfileId == this.activeDroneProfileId &&
|
||||||
other.currentMissionId == this.currentMissionId &&
|
other.currentMissionId == this.currentMissionId &&
|
||||||
other.connectionDeviceAddress == this.connectionDeviceAddress &&
|
other.connectionDeviceAddress == this.connectionDeviceAddress &&
|
||||||
other.connectionAutoConnect == this.connectionAutoConnect);
|
other.connectionAutoConnect == this.connectionAutoConnect &&
|
||||||
|
other.connectionType == this.connectionType &&
|
||||||
|
other.connectionWifiHost == this.connectionWifiHost &&
|
||||||
|
other.connectionWifiPort == this.connectionWifiPort);
|
||||||
}
|
}
|
||||||
|
|
||||||
class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
|
class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
|
||||||
@@ -1607,12 +1743,18 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
|
|||||||
final Value<int?> currentMissionId;
|
final Value<int?> currentMissionId;
|
||||||
final Value<String?> connectionDeviceAddress;
|
final Value<String?> connectionDeviceAddress;
|
||||||
final Value<bool> connectionAutoConnect;
|
final Value<bool> connectionAutoConnect;
|
||||||
|
final Value<String> connectionType;
|
||||||
|
final Value<String?> connectionWifiHost;
|
||||||
|
final Value<int> connectionWifiPort;
|
||||||
const AppSettingsTableCompanion({
|
const AppSettingsTableCompanion({
|
||||||
this.id = const Value.absent(),
|
this.id = const Value.absent(),
|
||||||
this.activeDroneProfileId = const Value.absent(),
|
this.activeDroneProfileId = const Value.absent(),
|
||||||
this.currentMissionId = const Value.absent(),
|
this.currentMissionId = const Value.absent(),
|
||||||
this.connectionDeviceAddress = const Value.absent(),
|
this.connectionDeviceAddress = const Value.absent(),
|
||||||
this.connectionAutoConnect = const Value.absent(),
|
this.connectionAutoConnect = const Value.absent(),
|
||||||
|
this.connectionType = const Value.absent(),
|
||||||
|
this.connectionWifiHost = const Value.absent(),
|
||||||
|
this.connectionWifiPort = const Value.absent(),
|
||||||
});
|
});
|
||||||
AppSettingsTableCompanion.insert({
|
AppSettingsTableCompanion.insert({
|
||||||
this.id = const Value.absent(),
|
this.id = const Value.absent(),
|
||||||
@@ -1620,6 +1762,9 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
|
|||||||
this.currentMissionId = const Value.absent(),
|
this.currentMissionId = const Value.absent(),
|
||||||
this.connectionDeviceAddress = const Value.absent(),
|
this.connectionDeviceAddress = const Value.absent(),
|
||||||
this.connectionAutoConnect = const Value.absent(),
|
this.connectionAutoConnect = const Value.absent(),
|
||||||
|
this.connectionType = const Value.absent(),
|
||||||
|
this.connectionWifiHost = const Value.absent(),
|
||||||
|
this.connectionWifiPort = const Value.absent(),
|
||||||
});
|
});
|
||||||
static Insertable<AppSettingsTableData> custom({
|
static Insertable<AppSettingsTableData> custom({
|
||||||
Expression<int>? id,
|
Expression<int>? id,
|
||||||
@@ -1627,6 +1772,9 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
|
|||||||
Expression<int>? currentMissionId,
|
Expression<int>? currentMissionId,
|
||||||
Expression<String>? connectionDeviceAddress,
|
Expression<String>? connectionDeviceAddress,
|
||||||
Expression<bool>? connectionAutoConnect,
|
Expression<bool>? connectionAutoConnect,
|
||||||
|
Expression<String>? connectionType,
|
||||||
|
Expression<String>? connectionWifiHost,
|
||||||
|
Expression<int>? connectionWifiPort,
|
||||||
}) {
|
}) {
|
||||||
return RawValuesInsertable({
|
return RawValuesInsertable({
|
||||||
if (id != null) 'id': id,
|
if (id != null) 'id': id,
|
||||||
@@ -1637,6 +1785,11 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
|
|||||||
'connection_device_address': connectionDeviceAddress,
|
'connection_device_address': connectionDeviceAddress,
|
||||||
if (connectionAutoConnect != null)
|
if (connectionAutoConnect != null)
|
||||||
'connection_auto_connect': connectionAutoConnect,
|
'connection_auto_connect': connectionAutoConnect,
|
||||||
|
if (connectionType != null) 'connection_type': connectionType,
|
||||||
|
if (connectionWifiHost != null)
|
||||||
|
'connection_wifi_host': connectionWifiHost,
|
||||||
|
if (connectionWifiPort != null)
|
||||||
|
'connection_wifi_port': connectionWifiPort,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1646,6 +1799,9 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
|
|||||||
Value<int?>? currentMissionId,
|
Value<int?>? currentMissionId,
|
||||||
Value<String?>? connectionDeviceAddress,
|
Value<String?>? connectionDeviceAddress,
|
||||||
Value<bool>? connectionAutoConnect,
|
Value<bool>? connectionAutoConnect,
|
||||||
|
Value<String>? connectionType,
|
||||||
|
Value<String?>? connectionWifiHost,
|
||||||
|
Value<int>? connectionWifiPort,
|
||||||
}) {
|
}) {
|
||||||
return AppSettingsTableCompanion(
|
return AppSettingsTableCompanion(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -1655,6 +1811,9 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
|
|||||||
connectionDeviceAddress ?? this.connectionDeviceAddress,
|
connectionDeviceAddress ?? this.connectionDeviceAddress,
|
||||||
connectionAutoConnect:
|
connectionAutoConnect:
|
||||||
connectionAutoConnect ?? this.connectionAutoConnect,
|
connectionAutoConnect ?? this.connectionAutoConnect,
|
||||||
|
connectionType: connectionType ?? this.connectionType,
|
||||||
|
connectionWifiHost: connectionWifiHost ?? this.connectionWifiHost,
|
||||||
|
connectionWifiPort: connectionWifiPort ?? this.connectionWifiPort,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1682,6 +1841,15 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
|
|||||||
connectionAutoConnect.value,
|
connectionAutoConnect.value,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (connectionType.present) {
|
||||||
|
map['connection_type'] = Variable<String>(connectionType.value);
|
||||||
|
}
|
||||||
|
if (connectionWifiHost.present) {
|
||||||
|
map['connection_wifi_host'] = Variable<String>(connectionWifiHost.value);
|
||||||
|
}
|
||||||
|
if (connectionWifiPort.present) {
|
||||||
|
map['connection_wifi_port'] = Variable<int>(connectionWifiPort.value);
|
||||||
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1692,7 +1860,10 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
|
|||||||
..write('activeDroneProfileId: $activeDroneProfileId, ')
|
..write('activeDroneProfileId: $activeDroneProfileId, ')
|
||||||
..write('currentMissionId: $currentMissionId, ')
|
..write('currentMissionId: $currentMissionId, ')
|
||||||
..write('connectionDeviceAddress: $connectionDeviceAddress, ')
|
..write('connectionDeviceAddress: $connectionDeviceAddress, ')
|
||||||
..write('connectionAutoConnect: $connectionAutoConnect')
|
..write('connectionAutoConnect: $connectionAutoConnect, ')
|
||||||
|
..write('connectionType: $connectionType, ')
|
||||||
|
..write('connectionWifiHost: $connectionWifiHost, ')
|
||||||
|
..write('connectionWifiPort: $connectionWifiPort')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
@@ -2333,6 +2504,9 @@ typedef $$AppSettingsTableTableCreateCompanionBuilder =
|
|||||||
Value<int?> currentMissionId,
|
Value<int?> currentMissionId,
|
||||||
Value<String?> connectionDeviceAddress,
|
Value<String?> connectionDeviceAddress,
|
||||||
Value<bool> connectionAutoConnect,
|
Value<bool> connectionAutoConnect,
|
||||||
|
Value<String> connectionType,
|
||||||
|
Value<String?> connectionWifiHost,
|
||||||
|
Value<int> connectionWifiPort,
|
||||||
});
|
});
|
||||||
typedef $$AppSettingsTableTableUpdateCompanionBuilder =
|
typedef $$AppSettingsTableTableUpdateCompanionBuilder =
|
||||||
AppSettingsTableCompanion Function({
|
AppSettingsTableCompanion Function({
|
||||||
@@ -2341,6 +2515,9 @@ typedef $$AppSettingsTableTableUpdateCompanionBuilder =
|
|||||||
Value<int?> currentMissionId,
|
Value<int?> currentMissionId,
|
||||||
Value<String?> connectionDeviceAddress,
|
Value<String?> connectionDeviceAddress,
|
||||||
Value<bool> connectionAutoConnect,
|
Value<bool> connectionAutoConnect,
|
||||||
|
Value<String> connectionType,
|
||||||
|
Value<String?> connectionWifiHost,
|
||||||
|
Value<int> connectionWifiPort,
|
||||||
});
|
});
|
||||||
|
|
||||||
class $$AppSettingsTableTableFilterComposer
|
class $$AppSettingsTableTableFilterComposer
|
||||||
@@ -2376,6 +2553,21 @@ class $$AppSettingsTableTableFilterComposer
|
|||||||
column: $table.connectionAutoConnect,
|
column: $table.connectionAutoConnect,
|
||||||
builder: (column) => ColumnFilters(column),
|
builder: (column) => ColumnFilters(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ColumnFilters<String> get connectionType => $composableBuilder(
|
||||||
|
column: $table.connectionType,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnFilters<String> get connectionWifiHost => $composableBuilder(
|
||||||
|
column: $table.connectionWifiHost,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnFilters<int> get connectionWifiPort => $composableBuilder(
|
||||||
|
column: $table.connectionWifiPort,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class $$AppSettingsTableTableOrderingComposer
|
class $$AppSettingsTableTableOrderingComposer
|
||||||
@@ -2411,6 +2603,21 @@ class $$AppSettingsTableTableOrderingComposer
|
|||||||
column: $table.connectionAutoConnect,
|
column: $table.connectionAutoConnect,
|
||||||
builder: (column) => ColumnOrderings(column),
|
builder: (column) => ColumnOrderings(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<String> get connectionType => $composableBuilder(
|
||||||
|
column: $table.connectionType,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<String> get connectionWifiHost => $composableBuilder(
|
||||||
|
column: $table.connectionWifiHost,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<int> get connectionWifiPort => $composableBuilder(
|
||||||
|
column: $table.connectionWifiPort,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class $$AppSettingsTableTableAnnotationComposer
|
class $$AppSettingsTableTableAnnotationComposer
|
||||||
@@ -2444,6 +2651,21 @@ class $$AppSettingsTableTableAnnotationComposer
|
|||||||
column: $table.connectionAutoConnect,
|
column: $table.connectionAutoConnect,
|
||||||
builder: (column) => column,
|
builder: (column) => column,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
GeneratedColumn<String> get connectionType => $composableBuilder(
|
||||||
|
column: $table.connectionType,
|
||||||
|
builder: (column) => column,
|
||||||
|
);
|
||||||
|
|
||||||
|
GeneratedColumn<String> get connectionWifiHost => $composableBuilder(
|
||||||
|
column: $table.connectionWifiHost,
|
||||||
|
builder: (column) => column,
|
||||||
|
);
|
||||||
|
|
||||||
|
GeneratedColumn<int> get connectionWifiPort => $composableBuilder(
|
||||||
|
column: $table.connectionWifiPort,
|
||||||
|
builder: (column) => column,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class $$AppSettingsTableTableTableManager
|
class $$AppSettingsTableTableTableManager
|
||||||
@@ -2488,12 +2710,18 @@ class $$AppSettingsTableTableTableManager
|
|||||||
Value<int?> currentMissionId = const Value.absent(),
|
Value<int?> currentMissionId = const Value.absent(),
|
||||||
Value<String?> connectionDeviceAddress = const Value.absent(),
|
Value<String?> connectionDeviceAddress = const Value.absent(),
|
||||||
Value<bool> connectionAutoConnect = const Value.absent(),
|
Value<bool> connectionAutoConnect = const Value.absent(),
|
||||||
|
Value<String> connectionType = const Value.absent(),
|
||||||
|
Value<String?> connectionWifiHost = const Value.absent(),
|
||||||
|
Value<int> connectionWifiPort = const Value.absent(),
|
||||||
}) => AppSettingsTableCompanion(
|
}) => AppSettingsTableCompanion(
|
||||||
id: id,
|
id: id,
|
||||||
activeDroneProfileId: activeDroneProfileId,
|
activeDroneProfileId: activeDroneProfileId,
|
||||||
currentMissionId: currentMissionId,
|
currentMissionId: currentMissionId,
|
||||||
connectionDeviceAddress: connectionDeviceAddress,
|
connectionDeviceAddress: connectionDeviceAddress,
|
||||||
connectionAutoConnect: connectionAutoConnect,
|
connectionAutoConnect: connectionAutoConnect,
|
||||||
|
connectionType: connectionType,
|
||||||
|
connectionWifiHost: connectionWifiHost,
|
||||||
|
connectionWifiPort: connectionWifiPort,
|
||||||
),
|
),
|
||||||
createCompanionCallback:
|
createCompanionCallback:
|
||||||
({
|
({
|
||||||
@@ -2502,12 +2730,18 @@ class $$AppSettingsTableTableTableManager
|
|||||||
Value<int?> currentMissionId = const Value.absent(),
|
Value<int?> currentMissionId = const Value.absent(),
|
||||||
Value<String?> connectionDeviceAddress = const Value.absent(),
|
Value<String?> connectionDeviceAddress = const Value.absent(),
|
||||||
Value<bool> connectionAutoConnect = const Value.absent(),
|
Value<bool> connectionAutoConnect = const Value.absent(),
|
||||||
|
Value<String> connectionType = const Value.absent(),
|
||||||
|
Value<String?> connectionWifiHost = const Value.absent(),
|
||||||
|
Value<int> connectionWifiPort = const Value.absent(),
|
||||||
}) => AppSettingsTableCompanion.insert(
|
}) => AppSettingsTableCompanion.insert(
|
||||||
id: id,
|
id: id,
|
||||||
activeDroneProfileId: activeDroneProfileId,
|
activeDroneProfileId: activeDroneProfileId,
|
||||||
currentMissionId: currentMissionId,
|
currentMissionId: currentMissionId,
|
||||||
connectionDeviceAddress: connectionDeviceAddress,
|
connectionDeviceAddress: connectionDeviceAddress,
|
||||||
connectionAutoConnect: connectionAutoConnect,
|
connectionAutoConnect: connectionAutoConnect,
|
||||||
|
connectionType: connectionType,
|
||||||
|
connectionWifiHost: connectionWifiHost,
|
||||||
|
connectionWifiPort: connectionWifiPort,
|
||||||
),
|
),
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
|
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
import '../../transport/connection_type.dart';
|
||||||
import 'app_database.dart';
|
import 'app_database.dart';
|
||||||
|
|
||||||
/// Zugriff auf die Einzeilen-Einstellungstabelle (Doku 7.5): welches
|
/// Zugriff auf die Einzeilen-Einstellungstabelle (Doku 7.5): welches
|
||||||
@@ -18,6 +19,9 @@ class AppSettingsRepository {
|
|||||||
int? currentMissionId,
|
int? currentMissionId,
|
||||||
String? connectionDeviceAddress,
|
String? connectionDeviceAddress,
|
||||||
bool connectionAutoConnect,
|
bool connectionAutoConnect,
|
||||||
|
ConnectionType connectionType,
|
||||||
|
String? connectionWifiHost,
|
||||||
|
int connectionWifiPort,
|
||||||
})> 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)))
|
||||||
@@ -27,6 +31,12 @@ class AppSettingsRepository {
|
|||||||
currentMissionId: row?.currentMissionId,
|
currentMissionId: row?.currentMissionId,
|
||||||
connectionDeviceAddress: row?.connectionDeviceAddress,
|
connectionDeviceAddress: row?.connectionDeviceAddress,
|
||||||
connectionAutoConnect: row?.connectionAutoConnect ?? true,
|
connectionAutoConnect: row?.connectionAutoConnect ?? true,
|
||||||
|
connectionType: ConnectionType.values.firstWhere(
|
||||||
|
(t) => t.name == row?.connectionType,
|
||||||
|
orElse: () => ConnectionType.bluetooth,
|
||||||
|
),
|
||||||
|
connectionWifiHost: row?.connectionWifiHost,
|
||||||
|
connectionWifiPort: row?.connectionWifiPort ?? kDefaultWifiPort,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +74,36 @@ class AppSettingsRepository {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gewaehlte Verbindungsart (Doku Kommunikationsschicht 7).
|
||||||
|
Future<void> setConnectionType(ConnectionType type) async {
|
||||||
|
await _upsert(AppSettingsTableCompanion(
|
||||||
|
id: const Value(_rowId),
|
||||||
|
connectionType: Value(type.name),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vom Nutzer eingetragene Ziel-IP der WLAN-Bruecke (Doku 3), oder null um
|
||||||
|
/// die Vorgabe zu loeschen und nur noch aus dem Verkehr zu lernen.
|
||||||
|
Future<String?> loadConnectionWifiHost() async =>
|
||||||
|
(await load()).connectionWifiHost;
|
||||||
|
|
||||||
|
Future<void> setConnectionWifiHost(String? host) async {
|
||||||
|
await _upsert(AppSettingsTableCompanion(
|
||||||
|
id: const Value(_rowId),
|
||||||
|
connectionWifiHost: Value(host),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> loadConnectionWifiPort() async =>
|
||||||
|
(await load()).connectionWifiPort;
|
||||||
|
|
||||||
|
Future<void> setConnectionWifiPort(int port) async {
|
||||||
|
await _upsert(AppSettingsTableCompanion(
|
||||||
|
id: const Value(_rowId),
|
||||||
|
connectionWifiPort: Value(port),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/// Verbindungsart, wie sie der Nutzer in den Einstellungen waehlt (Doku
|
||||||
|
/// Kommunikationsschicht Abschnitt 7: "Art: WLAN * USB (spaeter)").
|
||||||
|
/// Protokollneutraler Enum-Typ analog zu [LinkState] - deshalb bewusst auch
|
||||||
|
/// hier in transport/ statt in der UI, aber ohne jede Netzwerk-/Plattform-
|
||||||
|
/// Abhaengigkeit, damit er gefahrlos von database/ und ui/ importiert werden
|
||||||
|
/// kann.
|
||||||
|
enum ConnectionType {
|
||||||
|
wifi,
|
||||||
|
cellular5g,
|
||||||
|
bluetooth,
|
||||||
|
usb,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vorgabe-Port der mLRS-WLAN-Bruecke (Doku Abschnitt 3: "Port 14550").
|
||||||
|
const int kDefaultWifiPort = 14550;
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import '../link_transport.dart';
|
||||||
|
|
||||||
|
/// UDP-Transport zur mLRS-WLAN-Bruecke (Doku Kommunikationsschicht Abschnitt
|
||||||
|
/// 3). Erste, einfachste Ausbaustufe: lokal auf [_getPort] binden und auf
|
||||||
|
/// eingehende Pakete hoeren (Stufe 1 - ohne Netzwerk-Bindung, siehe Doku
|
||||||
|
/// Abschnitt 4; Stufe 2/3 sind bewusst noch nicht umgesetzt).
|
||||||
|
///
|
||||||
|
/// Setzt das in Abschnitt 3 beschriebene Muster um: lokal auf dem Zielport
|
||||||
|
/// binden statt auf einen fluechtigen Port (sonst kommt kein Byte an), und
|
||||||
|
/// die Gegenstelle ausschliesslich aus dem Verkehr lernen statt sie fest
|
||||||
|
/// einzucodieren. Eine vom Nutzer eingetragene Host-Vorgabe fuellt nur das
|
||||||
|
/// allererste ausgehende Paket vor.
|
||||||
|
class UdpTransport implements LinkTransport {
|
||||||
|
UdpTransport({
|
||||||
|
required Future<String?> Function() getHost,
|
||||||
|
required Future<int> Function() getPort,
|
||||||
|
}) : _getHost = getHost,
|
||||||
|
_getPort = getPort;
|
||||||
|
|
||||||
|
final Future<String?> Function() _getHost;
|
||||||
|
final Future<int> Function() _getPort;
|
||||||
|
|
||||||
|
final _incomingController = StreamController<Uint8List>.broadcast();
|
||||||
|
final _stateController = StreamController<LinkState>.broadcast();
|
||||||
|
|
||||||
|
RawDatagramSocket? _socket;
|
||||||
|
StreamSubscription<RawSocketEvent>? _socketSub;
|
||||||
|
InternetAddress? _peerAddress;
|
||||||
|
int? _peerPort;
|
||||||
|
int? _boundPort;
|
||||||
|
|
||||||
|
LinkState _state = LinkState.disconnected;
|
||||||
|
LinkErrorReason? _lastErrorReason;
|
||||||
|
String? _lastErrorMessage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<Uint8List> get incoming => _incomingController.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<LinkState> get state => _stateController.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
LinkErrorReason? get lastErrorReason => _lastErrorReason;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get lastErrorMessage => _lastErrorMessage;
|
||||||
|
|
||||||
|
/// Tatsaechlich gebundener lokaler Port, erst nach [connect] bekannt -
|
||||||
|
/// weicht von der Vorgabe ab, wenn diese belegt war (Doku 3/8).
|
||||||
|
int? get boundPort => _boundPort;
|
||||||
|
|
||||||
|
/// Gegenstelle, sobald aus dem ersten eingehenden Paket gelernt bzw. aus
|
||||||
|
/// der Host-Vorgabe vorbelegt (Doku 3).
|
||||||
|
String? get peerDescription =>
|
||||||
|
_peerAddress == null ? null : '${_peerAddress!.address}:$_peerPort';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> connect() async {
|
||||||
|
_lastErrorReason = null;
|
||||||
|
_lastErrorMessage = null;
|
||||||
|
_setState(LinkState.connecting);
|
||||||
|
|
||||||
|
final configuredPort = await _getPort();
|
||||||
|
final host = await _getHost();
|
||||||
|
if (host != null && host.isNotEmpty) {
|
||||||
|
final parsed = InternetAddress.tryParse(host);
|
||||||
|
if (parsed != null) {
|
||||||
|
_peerAddress = parsed;
|
||||||
|
_peerPort = configuredPort;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
RawDatagramSocket socket;
|
||||||
|
try {
|
||||||
|
// reuseAddress: false, damit ein tatsaechlich belegter Port hier
|
||||||
|
// erkennbar mit SocketException scheitert - Darts Standard
|
||||||
|
// (reuseAddress: true) wuerde den Konflikt sonst still zulassen.
|
||||||
|
socket = await RawDatagramSocket.bind(
|
||||||
|
InternetAddress.anyIPv4,
|
||||||
|
configuredPort,
|
||||||
|
reuseAddress: false,
|
||||||
|
);
|
||||||
|
} on SocketException {
|
||||||
|
// Port belegt (Doku 3/8: "ist 14550 belegt, auf einen fluechtigen
|
||||||
|
// ausweichen, aber das protokollieren") - Ausweichen ist kein Grund,
|
||||||
|
// die Verbindung abzubrechen.
|
||||||
|
socket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0);
|
||||||
|
_lastErrorMessage =
|
||||||
|
'Port $configuredPort belegt, ausgewichen auf ${socket.port}.';
|
||||||
|
}
|
||||||
|
_socket = socket;
|
||||||
|
_boundPort = socket.port;
|
||||||
|
_socketSub = socket.listen(
|
||||||
|
_onSocketEvent,
|
||||||
|
onError: (Object e) =>
|
||||||
|
_setError(LinkErrorReason.connectionFailed, e.toString()),
|
||||||
|
);
|
||||||
|
_setState(LinkState.connected);
|
||||||
|
} on SocketException catch (e) {
|
||||||
|
_fail(LinkErrorReason.connectionFailed, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSocketEvent(RawSocketEvent event) {
|
||||||
|
if (event != RawSocketEvent.read) return;
|
||||||
|
final datagram = _socket?.receive();
|
||||||
|
if (datagram == null) return;
|
||||||
|
_peerAddress = datagram.address;
|
||||||
|
_peerPort = datagram.port;
|
||||||
|
_incomingController.add(datagram.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> disconnect() async {
|
||||||
|
await _socketSub?.cancel();
|
||||||
|
_socketSub = null;
|
||||||
|
_socket?.close();
|
||||||
|
_socket = null;
|
||||||
|
_boundPort = null;
|
||||||
|
_setState(LinkState.disconnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> send(Uint8List data) async {
|
||||||
|
final socket = _socket;
|
||||||
|
final peer = _peerAddress;
|
||||||
|
final port = _peerPort;
|
||||||
|
if (socket == null || peer == null || port == null) {
|
||||||
|
throw const LinkConnectException(
|
||||||
|
LinkErrorReason.connectionFailed,
|
||||||
|
'send() ohne aktive Verbindung oder bekannte Gegenstelle aufgerufen.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
socket.send(data, peer, port);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_socketSub?.cancel();
|
||||||
|
_socket?.close();
|
||||||
|
_socket = null;
|
||||||
|
_incomingController.close();
|
||||||
|
_stateController.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setError(LinkErrorReason reason, String message) {
|
||||||
|
_lastErrorReason = reason;
|
||||||
|
_lastErrorMessage = message;
|
||||||
|
_setState(LinkState.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
Never _fail(LinkErrorReason reason, String message) {
|
||||||
|
_setError(reason, message);
|
||||||
|
throw LinkConnectException(reason, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setState(LinkState next) {
|
||||||
|
_state = next;
|
||||||
|
if (!_stateController.isClosed) _stateController.add(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
LinkState get currentState => _state;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../transport/link_transport.dart';
|
||||||
|
import '../../transport/udp/udp_transport.dart';
|
||||||
|
import 'database_provider.dart';
|
||||||
|
|
||||||
|
/// App-lebenslanger UDP-Transport (Doku Kommunikationsschicht Abschnitt 3),
|
||||||
|
/// verdrahtet gegen Host/Port aus den Einstellungen. Analog zu
|
||||||
|
/// [bluetoothTransportProvider] bewusst nicht autoDispose - siehe dortige
|
||||||
|
/// Begruendung.
|
||||||
|
final wifiTransportProvider = Provider<UdpTransport>((ref) {
|
||||||
|
final settingsRepo = ref.watch(appSettingsRepositoryProvider);
|
||||||
|
final transport = UdpTransport(
|
||||||
|
getHost: settingsRepo.loadConnectionWifiHost,
|
||||||
|
getPort: settingsRepo.loadConnectionWifiPort,
|
||||||
|
);
|
||||||
|
ref.onDispose(transport.dispose);
|
||||||
|
return transport;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Reaktiver [LinkState] des WLAN-Transports fuer die Oberflaeche.
|
||||||
|
final wifiLinkStateProvider = StreamProvider<LinkState>((ref) {
|
||||||
|
final transport = ref.watch(wifiTransportProvider);
|
||||||
|
return transport.state;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Grund des zuletzt gemeldeten [LinkState.error] des WLAN-Transports.
|
||||||
|
final wifiLinkErrorReasonProvider = Provider<LinkErrorReason?>((ref) {
|
||||||
|
ref.watch(wifiLinkStateProvider);
|
||||||
|
return ref.watch(wifiTransportProvider).lastErrorReason;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Ein Eintrag im WLAN-Verbindungsprotokoll (Doku 7).
|
||||||
|
class WifiConnectionLogEntry {
|
||||||
|
const WifiConnectionLogEntry(this.timestamp, this.message);
|
||||||
|
|
||||||
|
final DateTime timestamp;
|
||||||
|
final String message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verbindungsprotokoll + Paketzaehler fuer den WLAN-Transport, analog zu
|
||||||
|
/// [ConnectionLogNotifier] fuer Bluetooth.
|
||||||
|
class WifiConnectionLogNotifier
|
||||||
|
extends Notifier<List<WifiConnectionLogEntry>> {
|
||||||
|
static const _maxEntries = 50;
|
||||||
|
|
||||||
|
StreamSubscription<LinkState>? _stateSub;
|
||||||
|
StreamSubscription<void>? _dataSub;
|
||||||
|
int _packetCount = 0;
|
||||||
|
|
||||||
|
int get packetCount => _packetCount;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<WifiConnectionLogEntry> build() {
|
||||||
|
final transport = ref.watch(wifiTransportProvider);
|
||||||
|
_stateSub?.cancel();
|
||||||
|
_dataSub?.cancel();
|
||||||
|
_packetCount = 0;
|
||||||
|
_stateSub = transport.state.listen(_onStateChanged);
|
||||||
|
_dataSub = transport.incoming.listen((_) => _packetCount++);
|
||||||
|
ref.onDispose(() {
|
||||||
|
_stateSub?.cancel();
|
||||||
|
_dataSub?.cancel();
|
||||||
|
});
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onStateChanged(LinkState linkState) {
|
||||||
|
final transport = ref.read(wifiTransportProvider);
|
||||||
|
final message = switch (linkState) {
|
||||||
|
LinkState.disconnected => 'Getrennt',
|
||||||
|
LinkState.connecting => 'Verbinde ...',
|
||||||
|
LinkState.connected => 'Lausche auf Port ${transport.boundPort}',
|
||||||
|
LinkState.error => 'Fehler: '
|
||||||
|
'${transport.lastErrorMessage ?? transport.lastErrorReason?.name ?? 'unbekannt'}',
|
||||||
|
};
|
||||||
|
_append(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _append(String message) {
|
||||||
|
final entry = WifiConnectionLogEntry(DateTime.now(), message);
|
||||||
|
final next = [...state, entry];
|
||||||
|
state = next.length > _maxEntries
|
||||||
|
? next.sublist(next.length - _maxEntries)
|
||||||
|
: next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final wifiConnectionLogProvider =
|
||||||
|
NotifierProvider<WifiConnectionLogNotifier, List<WifiConnectionLogEntry>>(
|
||||||
|
WifiConnectionLogNotifier.new);
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../transport/bluetooth/bluetooth_device_info.dart';
|
import '../../../transport/bluetooth/bluetooth_device_info.dart';
|
||||||
import '../../../transport/bluetooth/bluetooth_system_settings.dart';
|
import '../../../transport/bluetooth/bluetooth_system_settings.dart';
|
||||||
|
import '../../../transport/connection_type.dart';
|
||||||
import '../../../transport/link_transport.dart';
|
import '../../../transport/link_transport.dart';
|
||||||
import '../../dmc_colors.dart';
|
import '../../dmc_colors.dart';
|
||||||
import '../../providers/bluetooth_connection_provider.dart';
|
import '../../providers/bluetooth_connection_provider.dart';
|
||||||
import '../../providers/connection_settings_provider.dart';
|
import '../../providers/connection_settings_provider.dart';
|
||||||
import '../../providers/database_provider.dart';
|
import '../../providers/database_provider.dart';
|
||||||
|
import '../../providers/wifi_connection_provider.dart';
|
||||||
|
|
||||||
/// Einstellungen: Drohnen-Profile (Doku 3.5/7.3, noch nicht hier
|
/// Einstellungen: Drohnen-Profile (Doku 3.5/7.3, noch nicht hier
|
||||||
/// angebunden - siehe DroneProfileEditor) und Verbindung (Doku
|
/// angebunden - siehe DroneProfileEditor) und Verbindung (Doku
|
||||||
@@ -41,33 +44,288 @@ class _ConnectionSection extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final linkState = ref.watch(linkStateProvider).value;
|
|
||||||
final errorReason = ref.watch(linkErrorReasonProvider);
|
|
||||||
final bondedDevices = ref.watch(bondedDevicesProvider);
|
|
||||||
final connectionSettings = ref.watch(connectionSettingsProvider).value;
|
final connectionSettings = ref.watch(connectionSettingsProvider).value;
|
||||||
final log = ref.watch(connectionLogProvider);
|
final selectedType = connectionSettings?.connectionType ?? ConnectionType.bluetooth;
|
||||||
final packetCount = ref.watch(connectionLogProvider.notifier).packetCount;
|
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Connection', style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
DropdownButtonFormField<ConnectionType>(
|
||||||
|
initialValue: selectedType,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Type',
|
||||||
|
isDense: true,
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: ConnectionType.wifi, child: Text('WiFi')),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: ConnectionType.cellular5g,
|
||||||
|
child: Text('5G (coming soon)'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: ConnectionType.bluetooth,
|
||||||
|
child: Text('Bluetooth'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: ConnectionType.usb,
|
||||||
|
child: Text('USB (coming soon)'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (type) async {
|
||||||
|
if (type == null) return;
|
||||||
|
await ref.read(appSettingsRepositoryProvider).setConnectionType(type);
|
||||||
|
ref.invalidate(connectionSettingsProvider);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
switch (selectedType) {
|
||||||
|
ConnectionType.wifi => const _WifiPanel(),
|
||||||
|
ConnectionType.bluetooth => const _BluetoothPanel(),
|
||||||
|
ConnectionType.cellular5g => const _NotAvailablePanel(
|
||||||
|
label: '5G',
|
||||||
|
),
|
||||||
|
ConnectionType.usb => const _NotAvailablePanel(label: 'USB'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NotAvailablePanel extends StatelessWidget {
|
||||||
|
const _NotAvailablePanel({required this.label});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Text(
|
||||||
|
'$label support is not available yet.',
|
||||||
|
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WifiPanel extends ConsumerStatefulWidget {
|
||||||
|
const _WifiPanel();
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_WifiPanel> createState() => _WifiPanelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WifiPanelState extends ConsumerState<_WifiPanel> {
|
||||||
|
late final TextEditingController _hostController;
|
||||||
|
late final TextEditingController _portController;
|
||||||
|
bool _controllersSeeded = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_hostController = TextEditingController();
|
||||||
|
_portController = TextEditingController();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_hostController.dispose();
|
||||||
|
_portController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final linkState = ref.watch(wifiLinkStateProvider).value;
|
||||||
|
final errorReason = ref.watch(wifiLinkErrorReasonProvider);
|
||||||
|
final connectionSettings = ref.watch(connectionSettingsProvider).value;
|
||||||
|
final log = ref.watch(wifiConnectionLogProvider);
|
||||||
|
final packetCount = ref.watch(wifiConnectionLogProvider.notifier).packetCount;
|
||||||
|
|
||||||
|
// Controller einmalig aus den gespeicherten Einstellungen befuellen -
|
||||||
|
// nicht bei jedem Rebuild, sonst wird waehrend der Nutzereingabe
|
||||||
|
// ueberschrieben.
|
||||||
|
if (!_controllersSeeded && connectionSettings != null) {
|
||||||
|
_controllersSeeded = true;
|
||||||
|
_hostController.text = connectionSettings.connectionWifiHost ?? '';
|
||||||
|
_portController.text = connectionSettings.connectionWifiPort.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text('Connection', style: Theme.of(context).textTheme.titleMedium),
|
Text('WiFi (UDP)', style: Theme.of(context).textTheme.titleSmall),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
_StatePill(state: linkState),
|
_StatePill(state: linkState),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
|
||||||
const Text('Type: Bluetooth'),
|
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
const Text(
|
const Text(
|
||||||
'Wi-Fi and USB support are coming later.',
|
'Opens a local UDP socket and listens for the mLRS bridge '
|
||||||
|
'(port 14550 by default). The peer address is learned from the '
|
||||||
|
'first incoming packet.',
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
),
|
),
|
||||||
|
if (errorReason != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_WifiErrorHint(reason: errorReason),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: TextField(
|
||||||
|
controller: _hostController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Host (optional)',
|
||||||
|
hintText: 'auto (learned from traffic)',
|
||||||
|
isDense: true,
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onSubmitted: (value) => _saveHost(value),
|
||||||
|
onEditingComplete: () => _saveHost(_hostController.text),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _portController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Port',
|
||||||
|
isDense: true,
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onSubmitted: (value) => _savePort(value),
|
||||||
|
onEditingComplete: () => _savePort(_portController.text),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => _connect(ref),
|
||||||
|
child: const Text('Connect'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () => ref.read(wifiTransportProvider).disconnect(),
|
||||||
|
child: const Text('Disconnect'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Connection log ($packetCount packets received)',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
SizedBox(
|
||||||
|
height: 120,
|
||||||
|
child: log.isEmpty
|
||||||
|
? const Text(
|
||||||
|
'No events yet.',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
reverse: true,
|
||||||
|
itemCount: log.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final entry = log[log.length - 1 - index];
|
||||||
|
return Text(
|
||||||
|
'${_formatTime(entry.timestamp)} ${entry.message}',
|
||||||
|
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveHost(String value) async {
|
||||||
|
final trimmed = value.trim();
|
||||||
|
await ref
|
||||||
|
.read(appSettingsRepositoryProvider)
|
||||||
|
.setConnectionWifiHost(trimmed.isEmpty ? null : trimmed);
|
||||||
|
ref.invalidate(connectionSettingsProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _savePort(String value) async {
|
||||||
|
final port = int.tryParse(value);
|
||||||
|
if (port == null || port <= 0 || port > 65535) return;
|
||||||
|
await ref.read(appSettingsRepositoryProvider).setConnectionWifiPort(port);
|
||||||
|
ref.invalidate(connectionSettingsProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _connect(WidgetRef ref) async {
|
||||||
|
try {
|
||||||
|
await ref.read(wifiTransportProvider).connect();
|
||||||
|
} on LinkConnectException {
|
||||||
|
// Grund/Nachricht sind bereits ueber wifiLinkErrorReasonProvider
|
||||||
|
// gemeldet, siehe _WifiErrorHint.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatTime(DateTime t) =>
|
||||||
|
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}:${t.second.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WifiErrorHint extends StatelessWidget {
|
||||||
|
const _WifiErrorHint({required this.reason});
|
||||||
|
|
||||||
|
final LinkErrorReason reason;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: DmcColors.warnRed.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Connection error: ${reason.name}.',
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BluetoothPanel extends ConsumerWidget {
|
||||||
|
const _BluetoothPanel();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final linkState = ref.watch(linkStateProvider).value;
|
||||||
|
final errorReason = ref.watch(linkErrorReasonProvider);
|
||||||
|
final bondedDevices = ref.watch(bondedDevicesProvider);
|
||||||
|
final connectionSettings = ref.watch(connectionSettingsProvider).value;
|
||||||
|
final log = ref.watch(connectionLogProvider);
|
||||||
|
final packetCount = ref.watch(connectionLogProvider.notifier).packetCount;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text('Bluetooth', style: Theme.of(context).textTheme.titleSmall),
|
||||||
|
const Spacer(),
|
||||||
|
_StatePill(state: linkState),
|
||||||
|
],
|
||||||
|
),
|
||||||
if (errorReason != null) ...[
|
if (errorReason != null) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_ErrorHint(reason: errorReason),
|
_ErrorHint(reason: errorReason),
|
||||||
@@ -115,8 +373,7 @@ class _ConnectionSection extends ConsumerWidget {
|
|||||||
)
|
)
|
||||||
: RadioGroup<String>(
|
: RadioGroup<String>(
|
||||||
groupValue: connectionSettings?.connectionDeviceAddress,
|
groupValue: connectionSettings?.connectionDeviceAddress,
|
||||||
onChanged: (address) =>
|
onChanged: (address) => _selectDevice(ref, address, devices),
|
||||||
_selectDevice(ref, address, devices),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
for (final device in devices)
|
for (final device in devices)
|
||||||
@@ -168,8 +425,6 @@ class _ConnectionSection extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
|
|
||||||
import 'package:dmc_app/services/database/app_database.dart';
|
import 'package:dmc_app/services/database/app_database.dart';
|
||||||
import 'package:dmc_app/services/database/app_settings_repository.dart';
|
import 'package:dmc_app/services/database/app_settings_repository.dart';
|
||||||
|
import 'package:dmc_app/transport/connection_type.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late AppDatabase db;
|
late AppDatabase db;
|
||||||
@@ -75,4 +76,34 @@ void main() {
|
|||||||
expect(settings.connectionDeviceAddress, 'AA:BB:CC:DD:EE:FF');
|
expect(settings.connectionDeviceAddress, 'AA:BB:CC:DD:EE:FF');
|
||||||
expect(settings.connectionAutoConnect, isFalse);
|
expect(settings.connectionAutoConnect, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Verbindungsart: Standard ist Bluetooth, Port-Vorgabe 14550', () async {
|
||||||
|
final settings = await repository.load();
|
||||||
|
expect(settings.connectionType, ConnectionType.bluetooth);
|
||||||
|
expect(settings.connectionWifiHost, isNull);
|
||||||
|
expect(settings.connectionWifiPort, 14550);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setConnectionType merkt sich die gewaehlte Verbindungsart', () async {
|
||||||
|
await repository.setConnectionType(ConnectionType.wifi);
|
||||||
|
|
||||||
|
final settings = await repository.load();
|
||||||
|
expect(settings.connectionType, ConnectionType.wifi);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setConnectionWifiHost/-Port merken sich Host und Port unabhaengig',
|
||||||
|
() async {
|
||||||
|
await repository.setConnectionWifiHost('192.168.4.1');
|
||||||
|
await repository.setConnectionWifiPort(15000);
|
||||||
|
|
||||||
|
expect(await repository.loadConnectionWifiHost(), '192.168.4.1');
|
||||||
|
expect(await repository.loadConnectionWifiPort(), 15000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setConnectionWifiHost(null) loescht die Vorgabe wieder', () async {
|
||||||
|
await repository.setConnectionWifiHost('192.168.4.1');
|
||||||
|
await repository.setConnectionWifiHost(null);
|
||||||
|
|
||||||
|
expect(await repository.loadConnectionWifiHost(), isNull);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:dmc_app/transport/link_transport.dart';
|
||||||
|
import 'package:dmc_app/transport/udp/udp_transport.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// Port 0 laesst das Betriebssystem einen freien Port waehlen, damit die
|
||||||
|
// Tests nicht mit einer echten mLRS-Bruecke oder parallelen Testlaeufen
|
||||||
|
// um Port 14550 konkurrieren.
|
||||||
|
Future<UdpTransport> openTransport({String? host}) async {
|
||||||
|
final transport = UdpTransport(
|
||||||
|
getHost: () async => host,
|
||||||
|
getPort: () async => 0,
|
||||||
|
);
|
||||||
|
await transport.connect();
|
||||||
|
return transport;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('connect() bindet einen lokalen Port und meldet connected', () async {
|
||||||
|
final transport = await openTransport();
|
||||||
|
final states = <LinkState>[];
|
||||||
|
transport.state.listen(states.add);
|
||||||
|
|
||||||
|
expect(transport.currentState, LinkState.connected);
|
||||||
|
expect(transport.boundPort, isNotNull);
|
||||||
|
expect(transport.boundPort, greaterThan(0));
|
||||||
|
|
||||||
|
transport.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eingehende Pakete kommen ueber incoming an und lernen die Gegenstelle',
|
||||||
|
() async {
|
||||||
|
final transport = await openTransport();
|
||||||
|
final sender = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
|
|
||||||
|
final received = <Uint8List>[];
|
||||||
|
transport.incoming.listen(received.add);
|
||||||
|
|
||||||
|
sender.send(
|
||||||
|
Uint8List.fromList([1, 2, 3]),
|
||||||
|
InternetAddress.loopbackIPv4,
|
||||||
|
transport.boundPort!,
|
||||||
|
);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
|
||||||
|
expect(received, [
|
||||||
|
Uint8List.fromList([1, 2, 3]),
|
||||||
|
]);
|
||||||
|
expect(transport.peerDescription, contains(sender.port.toString()));
|
||||||
|
|
||||||
|
sender.close();
|
||||||
|
transport.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('send() ohne bekannte Gegenstelle wirft LinkConnectException', () async {
|
||||||
|
final transport = await openTransport();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => transport.send(Uint8List.fromList([1])),
|
||||||
|
throwsA(isA<LinkConnectException>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
transport.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('send() schickt Bytes an die gelernte Gegenstelle', () async {
|
||||||
|
final transport = await openTransport();
|
||||||
|
final peer = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
|
|
||||||
|
// Erst "sprechen" lassen, damit der Transport die Gegenstelle lernt
|
||||||
|
// (Doku Kommunikationsschicht Abschnitt 3: "Gegenstelle aus dem Verkehr
|
||||||
|
// lernen").
|
||||||
|
peer.send(
|
||||||
|
Uint8List.fromList([9]),
|
||||||
|
InternetAddress.loopbackIPv4,
|
||||||
|
transport.boundPort!,
|
||||||
|
);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
|
||||||
|
final receivedByPeer = <Uint8List>[];
|
||||||
|
peer.listen((event) {
|
||||||
|
if (event == RawSocketEvent.read) {
|
||||||
|
final datagram = peer.receive();
|
||||||
|
if (datagram != null) receivedByPeer.add(datagram.data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await transport.send(Uint8List.fromList([7, 7, 7]));
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
|
||||||
|
expect(receivedByPeer, [
|
||||||
|
Uint8List.fromList([7, 7, 7]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
peer.close();
|
||||||
|
transport.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disconnect() setzt LinkState.disconnected', () async {
|
||||||
|
final transport = await openTransport();
|
||||||
|
|
||||||
|
await transport.disconnect();
|
||||||
|
|
||||||
|
expect(transport.currentState, LinkState.disconnected);
|
||||||
|
expect(transport.boundPort, isNull);
|
||||||
|
|
||||||
|
transport.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('weicht auf einen fluechtigen Port aus, wenn der konfigurierte belegt ist',
|
||||||
|
() async {
|
||||||
|
final occupied = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0);
|
||||||
|
final transport = UdpTransport(
|
||||||
|
getHost: () async => null,
|
||||||
|
getPort: () async => occupied.port,
|
||||||
|
);
|
||||||
|
|
||||||
|
await transport.connect();
|
||||||
|
|
||||||
|
expect(transport.currentState, LinkState.connected);
|
||||||
|
expect(transport.boundPort, isNot(occupied.port));
|
||||||
|
expect(transport.lastErrorMessage, contains('belegt'));
|
||||||
|
|
||||||
|
occupied.close();
|
||||||
|
transport.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('konfigurierte Host-Vorgabe wird als initiale Gegenstelle uebernommen',
|
||||||
|
() async {
|
||||||
|
final transport = UdpTransport(
|
||||||
|
getHost: () async => '127.0.0.1',
|
||||||
|
getPort: () async => 0,
|
||||||
|
);
|
||||||
|
await transport.connect();
|
||||||
|
|
||||||
|
expect(transport.peerDescription, isNotNull);
|
||||||
|
expect(transport.peerDescription, startsWith('127.0.0.1'));
|
||||||
|
|
||||||
|
transport.dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user