diff --git a/DMC_Kommunikationsschicht_neu.md b/DMC_Kommunikationsschicht_neu.md new file mode 100644 index 0000000..ced1d66 --- /dev/null +++ b/DMC_Kommunikationsschicht_neu.md @@ -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 get incoming; + Stream get state; + Future send(Uint8List data); + Future connect(); + Future 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 + + + + + +``` + +`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: '<' 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#). diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index 74be2b9..e56bac6 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -1,7 +1,15 @@ - + + + + + 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 Set get primaryKey => {id}; } @@ -72,7 +89,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase.forTesting(super.executor); @override - int get schemaVersion => 3; + int get schemaVersion => 4; @override MigrationStrategy get migration => MigrationStrategy( @@ -93,6 +110,17 @@ class AppDatabase extends _$AppDatabase { appSettingsTable.connectionAutoConnect, ); } + if (from < 4) { + await m.addColumn(appSettingsTable, appSettingsTable.connectionType); + await m.addColumn( + appSettingsTable, + appSettingsTable.connectionWifiHost, + ); + await m.addColumn( + appSettingsTable, + appSettingsTable.connectionWifiPort, + ); + } }, ); diff --git a/app/lib/services/database/app_database.g.dart b/app/lib/services/database/app_database.g.dart index e6f346a..e26b830 100644 --- a/app/lib/services/database/app_database.g.dart +++ b/app/lib/services/database/app_database.g.dart @@ -1348,6 +1348,40 @@ class $AppSettingsTableTable extends AppSettingsTable ), defaultValue: const Constant(true), ); + static const VerificationMeta _connectionTypeMeta = const VerificationMeta( + 'connectionType', + ); + @override + late final GeneratedColumn connectionType = GeneratedColumn( + 'connection_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('bluetooth'), + ); + static const VerificationMeta _connectionWifiHostMeta = + const VerificationMeta('connectionWifiHost'); + @override + late final GeneratedColumn connectionWifiHost = + GeneratedColumn( + 'connection_wifi_host', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _connectionWifiPortMeta = + const VerificationMeta('connectionWifiPort'); + @override + late final GeneratedColumn connectionWifiPort = GeneratedColumn( + 'connection_wifi_port', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(kDefaultWifiPort), + ); @override List get $columns => [ id, @@ -1355,6 +1389,9 @@ class $AppSettingsTableTable extends AppSettingsTable currentMissionId, connectionDeviceAddress, connectionAutoConnect, + connectionType, + connectionWifiHost, + connectionWifiPort, ]; @override 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; } @@ -1436,6 +1500,18 @@ class $AppSettingsTableTable extends AppSettingsTable DriftSqlType.bool, 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. 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({ required this.id, this.activeDroneProfileId, this.currentMissionId, this.connectionDeviceAddress, required this.connectionAutoConnect, + required this.connectionType, + this.connectionWifiHost, + required this.connectionWifiPort, }); @override Map toColumns(bool nullToAbsent) { @@ -1481,6 +1573,11 @@ class AppSettingsTableData extends DataClass ); } map['connection_auto_connect'] = Variable(connectionAutoConnect); + map['connection_type'] = Variable(connectionType); + if (!nullToAbsent || connectionWifiHost != null) { + map['connection_wifi_host'] = Variable(connectionWifiHost); + } + map['connection_wifi_port'] = Variable(connectionWifiPort); return map; } @@ -1497,6 +1594,11 @@ class AppSettingsTableData extends DataClass ? const Value.absent() : Value(connectionDeviceAddress), 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( json['connectionAutoConnect'], ), + connectionType: serializer.fromJson(json['connectionType']), + connectionWifiHost: serializer.fromJson( + json['connectionWifiHost'], + ), + connectionWifiPort: serializer.fromJson(json['connectionWifiPort']), ); } @override @@ -1530,6 +1637,9 @@ class AppSettingsTableData extends DataClass connectionDeviceAddress, ), 'connectionAutoConnect': serializer.toJson(connectionAutoConnect), + 'connectionType': serializer.toJson(connectionType), + 'connectionWifiHost': serializer.toJson(connectionWifiHost), + 'connectionWifiPort': serializer.toJson(connectionWifiPort), }; } @@ -1539,6 +1649,9 @@ class AppSettingsTableData extends DataClass Value currentMissionId = const Value.absent(), Value connectionDeviceAddress = const Value.absent(), bool? connectionAutoConnect, + String? connectionType, + Value connectionWifiHost = const Value.absent(), + int? connectionWifiPort, }) => AppSettingsTableData( id: id ?? this.id, activeDroneProfileId: activeDroneProfileId.present @@ -1551,6 +1664,11 @@ class AppSettingsTableData extends DataClass ? connectionDeviceAddress.value : this.connectionDeviceAddress, connectionAutoConnect: connectionAutoConnect ?? this.connectionAutoConnect, + connectionType: connectionType ?? this.connectionType, + connectionWifiHost: connectionWifiHost.present + ? connectionWifiHost.value + : this.connectionWifiHost, + connectionWifiPort: connectionWifiPort ?? this.connectionWifiPort, ); AppSettingsTableData copyWithCompanion(AppSettingsTableCompanion data) { return AppSettingsTableData( @@ -1567,6 +1685,15 @@ class AppSettingsTableData extends DataClass connectionAutoConnect: data.connectionAutoConnect.present ? data.connectionAutoConnect.value : 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('currentMissionId: $currentMissionId, ') ..write('connectionDeviceAddress: $connectionDeviceAddress, ') - ..write('connectionAutoConnect: $connectionAutoConnect') + ..write('connectionAutoConnect: $connectionAutoConnect, ') + ..write('connectionType: $connectionType, ') + ..write('connectionWifiHost: $connectionWifiHost, ') + ..write('connectionWifiPort: $connectionWifiPort') ..write(')')) .toString(); } @@ -1589,6 +1719,9 @@ class AppSettingsTableData extends DataClass currentMissionId, connectionDeviceAddress, connectionAutoConnect, + connectionType, + connectionWifiHost, + connectionWifiPort, ); @override bool operator ==(Object other) => @@ -1598,7 +1731,10 @@ class AppSettingsTableData extends DataClass other.activeDroneProfileId == this.activeDroneProfileId && other.currentMissionId == this.currentMissionId && 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 { @@ -1607,12 +1743,18 @@ class AppSettingsTableCompanion extends UpdateCompanion { final Value currentMissionId; final Value connectionDeviceAddress; final Value connectionAutoConnect; + final Value connectionType; + final Value connectionWifiHost; + final Value connectionWifiPort; const AppSettingsTableCompanion({ this.id = const Value.absent(), this.activeDroneProfileId = const Value.absent(), this.currentMissionId = const Value.absent(), this.connectionDeviceAddress = 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({ this.id = const Value.absent(), @@ -1620,6 +1762,9 @@ class AppSettingsTableCompanion extends UpdateCompanion { this.currentMissionId = const Value.absent(), this.connectionDeviceAddress = 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 custom({ Expression? id, @@ -1627,6 +1772,9 @@ class AppSettingsTableCompanion extends UpdateCompanion { Expression? currentMissionId, Expression? connectionDeviceAddress, Expression? connectionAutoConnect, + Expression? connectionType, + Expression? connectionWifiHost, + Expression? connectionWifiPort, }) { return RawValuesInsertable({ if (id != null) 'id': id, @@ -1637,6 +1785,11 @@ class AppSettingsTableCompanion extends UpdateCompanion { 'connection_device_address': connectionDeviceAddress, if (connectionAutoConnect != null) '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 { Value? currentMissionId, Value? connectionDeviceAddress, Value? connectionAutoConnect, + Value? connectionType, + Value? connectionWifiHost, + Value? connectionWifiPort, }) { return AppSettingsTableCompanion( id: id ?? this.id, @@ -1655,6 +1811,9 @@ class AppSettingsTableCompanion extends UpdateCompanion { connectionDeviceAddress ?? this.connectionDeviceAddress, connectionAutoConnect: connectionAutoConnect ?? this.connectionAutoConnect, + connectionType: connectionType ?? this.connectionType, + connectionWifiHost: connectionWifiHost ?? this.connectionWifiHost, + connectionWifiPort: connectionWifiPort ?? this.connectionWifiPort, ); } @@ -1682,6 +1841,15 @@ class AppSettingsTableCompanion extends UpdateCompanion { connectionAutoConnect.value, ); } + if (connectionType.present) { + map['connection_type'] = Variable(connectionType.value); + } + if (connectionWifiHost.present) { + map['connection_wifi_host'] = Variable(connectionWifiHost.value); + } + if (connectionWifiPort.present) { + map['connection_wifi_port'] = Variable(connectionWifiPort.value); + } return map; } @@ -1692,7 +1860,10 @@ class AppSettingsTableCompanion extends UpdateCompanion { ..write('activeDroneProfileId: $activeDroneProfileId, ') ..write('currentMissionId: $currentMissionId, ') ..write('connectionDeviceAddress: $connectionDeviceAddress, ') - ..write('connectionAutoConnect: $connectionAutoConnect') + ..write('connectionAutoConnect: $connectionAutoConnect, ') + ..write('connectionType: $connectionType, ') + ..write('connectionWifiHost: $connectionWifiHost, ') + ..write('connectionWifiPort: $connectionWifiPort') ..write(')')) .toString(); } @@ -2333,6 +2504,9 @@ typedef $$AppSettingsTableTableCreateCompanionBuilder = Value currentMissionId, Value connectionDeviceAddress, Value connectionAutoConnect, + Value connectionType, + Value connectionWifiHost, + Value connectionWifiPort, }); typedef $$AppSettingsTableTableUpdateCompanionBuilder = AppSettingsTableCompanion Function({ @@ -2341,6 +2515,9 @@ typedef $$AppSettingsTableTableUpdateCompanionBuilder = Value currentMissionId, Value connectionDeviceAddress, Value connectionAutoConnect, + Value connectionType, + Value connectionWifiHost, + Value connectionWifiPort, }); class $$AppSettingsTableTableFilterComposer @@ -2376,6 +2553,21 @@ class $$AppSettingsTableTableFilterComposer column: $table.connectionAutoConnect, builder: (column) => ColumnFilters(column), ); + + ColumnFilters get connectionType => $composableBuilder( + column: $table.connectionType, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get connectionWifiHost => $composableBuilder( + column: $table.connectionWifiHost, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get connectionWifiPort => $composableBuilder( + column: $table.connectionWifiPort, + builder: (column) => ColumnFilters(column), + ); } class $$AppSettingsTableTableOrderingComposer @@ -2411,6 +2603,21 @@ class $$AppSettingsTableTableOrderingComposer column: $table.connectionAutoConnect, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get connectionType => $composableBuilder( + column: $table.connectionType, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get connectionWifiHost => $composableBuilder( + column: $table.connectionWifiHost, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get connectionWifiPort => $composableBuilder( + column: $table.connectionWifiPort, + builder: (column) => ColumnOrderings(column), + ); } class $$AppSettingsTableTableAnnotationComposer @@ -2444,6 +2651,21 @@ class $$AppSettingsTableTableAnnotationComposer column: $table.connectionAutoConnect, builder: (column) => column, ); + + GeneratedColumn get connectionType => $composableBuilder( + column: $table.connectionType, + builder: (column) => column, + ); + + GeneratedColumn get connectionWifiHost => $composableBuilder( + column: $table.connectionWifiHost, + builder: (column) => column, + ); + + GeneratedColumn get connectionWifiPort => $composableBuilder( + column: $table.connectionWifiPort, + builder: (column) => column, + ); } class $$AppSettingsTableTableTableManager @@ -2488,12 +2710,18 @@ class $$AppSettingsTableTableTableManager Value currentMissionId = const Value.absent(), Value connectionDeviceAddress = const Value.absent(), Value connectionAutoConnect = const Value.absent(), + Value connectionType = const Value.absent(), + Value connectionWifiHost = const Value.absent(), + Value connectionWifiPort = const Value.absent(), }) => AppSettingsTableCompanion( id: id, activeDroneProfileId: activeDroneProfileId, currentMissionId: currentMissionId, connectionDeviceAddress: connectionDeviceAddress, connectionAutoConnect: connectionAutoConnect, + connectionType: connectionType, + connectionWifiHost: connectionWifiHost, + connectionWifiPort: connectionWifiPort, ), createCompanionCallback: ({ @@ -2502,12 +2730,18 @@ class $$AppSettingsTableTableTableManager Value currentMissionId = const Value.absent(), Value connectionDeviceAddress = const Value.absent(), Value connectionAutoConnect = const Value.absent(), + Value connectionType = const Value.absent(), + Value connectionWifiHost = const Value.absent(), + Value connectionWifiPort = const Value.absent(), }) => AppSettingsTableCompanion.insert( id: id, activeDroneProfileId: activeDroneProfileId, currentMissionId: currentMissionId, connectionDeviceAddress: connectionDeviceAddress, connectionAutoConnect: connectionAutoConnect, + connectionType: connectionType, + connectionWifiHost: connectionWifiHost, + connectionWifiPort: connectionWifiPort, ), withReferenceMapper: (p0) => p0 .map((e) => (e.readTable(table), BaseReferences(db, table, e))) diff --git a/app/lib/services/database/app_settings_repository.dart b/app/lib/services/database/app_settings_repository.dart index ed31bbf..1be8427 100644 --- a/app/lib/services/database/app_settings_repository.dart +++ b/app/lib/services/database/app_settings_repository.dart @@ -1,5 +1,6 @@ import 'package:drift/drift.dart'; +import '../../transport/connection_type.dart'; import 'app_database.dart'; /// Zugriff auf die Einzeilen-Einstellungstabelle (Doku 7.5): welches @@ -18,6 +19,9 @@ class AppSettingsRepository { int? currentMissionId, String? connectionDeviceAddress, bool connectionAutoConnect, + ConnectionType connectionType, + String? connectionWifiHost, + int connectionWifiPort, })> load() async { final row = await (_db.select(_db.appSettingsTable) ..where((s) => s.id.equals(_rowId))) @@ -27,6 +31,12 @@ class AppSettingsRepository { currentMissionId: row?.currentMissionId, connectionDeviceAddress: row?.connectionDeviceAddress, 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 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 loadConnectionWifiHost() async => + (await load()).connectionWifiHost; + + Future setConnectionWifiHost(String? host) async { + await _upsert(AppSettingsTableCompanion( + id: const Value(_rowId), + connectionWifiHost: Value(host), + )); + } + + Future loadConnectionWifiPort() async => + (await load()).connectionWifiPort; + + Future setConnectionWifiPort(int port) async { + await _upsert(AppSettingsTableCompanion( + id: const Value(_rowId), + connectionWifiPort: Value(port), + )); + } + Future _upsert(AppSettingsTableCompanion companion) async { await _db.into(_db.appSettingsTable).insertOnConflictUpdate(companion); } diff --git a/app/lib/transport/connection_type.dart b/app/lib/transport/connection_type.dart new file mode 100644 index 0000000..c28a6d3 --- /dev/null +++ b/app/lib/transport/connection_type.dart @@ -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; diff --git a/app/lib/transport/udp/udp_transport.dart b/app/lib/transport/udp/udp_transport.dart new file mode 100644 index 0000000..d014134 --- /dev/null +++ b/app/lib/transport/udp/udp_transport.dart @@ -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 Function() getHost, + required Future Function() getPort, + }) : _getHost = getHost, + _getPort = getPort; + + final Future Function() _getHost; + final Future Function() _getPort; + + final _incomingController = StreamController.broadcast(); + final _stateController = StreamController.broadcast(); + + RawDatagramSocket? _socket; + StreamSubscription? _socketSub; + InternetAddress? _peerAddress; + int? _peerPort; + int? _boundPort; + + LinkState _state = LinkState.disconnected; + LinkErrorReason? _lastErrorReason; + String? _lastErrorMessage; + + @override + Stream get incoming => _incomingController.stream; + + @override + Stream get state => _stateController.stream; + + @override + LinkErrorReason? get lastErrorReason => _lastErrorReason; + + @override + String? get lastErrorMessage => _lastErrorMessage; + + /// Tatsaechlich gebundener lokaler Port, erst nach [connect] bekannt - + /// weicht von der Vorgabe ab, wenn diese belegt war (Doku 3/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 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 disconnect() async { + await _socketSub?.cancel(); + _socketSub = null; + _socket?.close(); + _socket = null; + _boundPort = null; + _setState(LinkState.disconnected); + } + + @override + Future 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; +} diff --git a/app/lib/ui/providers/wifi_connection_provider.dart b/app/lib/ui/providers/wifi_connection_provider.dart new file mode 100644 index 0000000..867ca17 --- /dev/null +++ b/app/lib/ui/providers/wifi_connection_provider.dart @@ -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((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((ref) { + final transport = ref.watch(wifiTransportProvider); + return transport.state; +}); + +/// Grund des zuletzt gemeldeten [LinkState.error] des WLAN-Transports. +final wifiLinkErrorReasonProvider = Provider((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> { + static const _maxEntries = 50; + + StreamSubscription? _stateSub; + StreamSubscription? _dataSub; + int _packetCount = 0; + + int get packetCount => _packetCount; + + @override + List 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.new); diff --git a/app/lib/ui/screens/settings/settings_screen.dart b/app/lib/ui/screens/settings/settings_screen.dart index f2aa39a..fa2b472 100644 --- a/app/lib/ui/screens/settings/settings_screen.dart +++ b/app/lib/ui/screens/settings/settings_screen.dart @@ -1,13 +1,16 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../transport/bluetooth/bluetooth_device_info.dart'; import '../../../transport/bluetooth/bluetooth_system_settings.dart'; +import '../../../transport/connection_type.dart'; import '../../../transport/link_transport.dart'; import '../../dmc_colors.dart'; import '../../providers/bluetooth_connection_provider.dart'; import '../../providers/connection_settings_provider.dart'; import '../../providers/database_provider.dart'; +import '../../providers/wifi_connection_provider.dart'; /// Einstellungen: Drohnen-Profile (Doku 3.5/7.3, noch nicht hier /// angebunden - siehe DroneProfileEditor) und Verbindung (Doku @@ -41,12 +44,8 @@ class _ConnectionSection extends ConsumerWidget { @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; + final selectedType = connectionSettings?.connectionType ?? ConnectionType.bluetooth; return Card( child: Padding( @@ -54,124 +53,380 @@ class _ConnectionSection extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Text('Connection', style: Theme.of(context).textTheme.titleMedium), - const Spacer(), - _StatePill(state: linkState), - ], - ), + Text('Connection', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 12), - const Text('Type: Bluetooth'), - const SizedBox(height: 4), - const Text( - 'Wi-Fi and USB support are coming later.', - style: TextStyle(fontSize: 12, color: Colors.grey), - ), - if (errorReason != null) ...[ - const SizedBox(height: 12), - _ErrorHint(reason: errorReason), - ], - const SizedBox(height: 16), - Row( - children: [ - ElevatedButton( - onPressed: () => _connect(ref), - child: const Text('Connect'), + DropdownButtonFormField( + 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)'), ), - const SizedBox(width: 8), - OutlinedButton( - onPressed: () => ref.read(bluetoothTransportProvider).disconnect(), - child: const Text('Disconnect'), + DropdownMenuItem( + value: ConnectionType.bluetooth, + child: Text('Bluetooth'), ), - const SizedBox(width: 8), - TextButton( - onPressed: BluetoothSystemSettings.openBluetoothSettings, - child: const Text('Pair device'), + DropdownMenuItem( + value: ConnectionType.usb, + child: Text('USB (coming soon)'), ), ], - ), - const SizedBox(height: 16), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Auto-connect when entering Fly mode'), - value: connectionSettings?.connectionAutoConnect ?? true, - onChanged: (value) async { - await ref - .read(appSettingsRepositoryProvider) - .setConnectionAutoConnect(value); + onChanged: (type) async { + if (type == null) return; + await ref.read(appSettingsRepositoryProvider).setConnectionType(type); ref.invalidate(connectionSettingsProvider); }, ), - const SizedBox(height: 8), - Text('Paired devices', style: Theme.of(context).textTheme.titleSmall), - const SizedBox(height: 4), - bondedDevices.when( - data: (devices) => devices.isEmpty - ? const Text( - 'No paired devices yet. Pair one once via the system ' - 'dialog using "Pair device".', - style: TextStyle(fontSize: 12, color: Colors.grey), - ) - : RadioGroup( - groupValue: connectionSettings?.connectionDeviceAddress, - onChanged: (address) => - _selectDevice(ref, address, devices), - child: Column( - children: [ - for (final device in devices) - RadioListTile( - contentPadding: EdgeInsets.zero, - dense: true, - value: device.address, - title: Text(device.displayName), - subtitle: Text( - device.address, - style: const TextStyle(fontSize: 11), - ), - ), - ], - ), - ), - loading: () => const Padding( - padding: EdgeInsets.symmetric(vertical: 8), - child: LinearProgressIndicator(), - ), - error: (e, _) => Text( - 'Could not read paired devices: $e', - style: const TextStyle(fontSize: 12, color: Colors.grey), - ), - ), 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'), - ); - }, - ), - ), + 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, + children: [ + Row( + children: [ + Text('WiFi (UDP)', style: Theme.of(context).textTheme.titleSmall), + const Spacer(), + _StatePill(state: linkState), + ], + ), + const SizedBox(height: 4), + const Text( + '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), + ), + 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 _saveHost(String value) async { + final trimmed = value.trim(); + await ref + .read(appSettingsRepositoryProvider) + .setConnectionWifiHost(trimmed.isEmpty ? null : trimmed); + ref.invalidate(connectionSettingsProvider); + } + + Future _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 _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) ...[ + const SizedBox(height: 12), + _ErrorHint(reason: errorReason), + ], + const SizedBox(height: 16), + Row( + children: [ + ElevatedButton( + onPressed: () => _connect(ref), + child: const Text('Connect'), + ), + const SizedBox(width: 8), + OutlinedButton( + onPressed: () => ref.read(bluetoothTransportProvider).disconnect(), + child: const Text('Disconnect'), + ), + const SizedBox(width: 8), + TextButton( + onPressed: BluetoothSystemSettings.openBluetoothSettings, + child: const Text('Pair device'), + ), + ], + ), + const SizedBox(height: 16), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Auto-connect when entering Fly mode'), + value: connectionSettings?.connectionAutoConnect ?? true, + onChanged: (value) async { + await ref + .read(appSettingsRepositoryProvider) + .setConnectionAutoConnect(value); + ref.invalidate(connectionSettingsProvider); + }, + ), + const SizedBox(height: 8), + Text('Paired devices', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 4), + bondedDevices.when( + data: (devices) => devices.isEmpty + ? const Text( + 'No paired devices yet. Pair one once via the system ' + 'dialog using "Pair device".', + style: TextStyle(fontSize: 12, color: Colors.grey), + ) + : RadioGroup( + groupValue: connectionSettings?.connectionDeviceAddress, + onChanged: (address) => _selectDevice(ref, address, devices), + child: Column( + children: [ + for (final device in devices) + RadioListTile( + contentPadding: EdgeInsets.zero, + dense: true, + value: device.address, + title: Text(device.displayName), + subtitle: Text( + device.address, + style: const TextStyle(fontSize: 11), + ), + ), + ], + ), + ), + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: LinearProgressIndicator(), + ), + error: (e, _) => Text( + 'Could not read paired devices: $e', + style: const TextStyle(fontSize: 12, color: Colors.grey), + ), + ), + 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 _connect(WidgetRef ref) async { try { diff --git a/app/test/services/app_settings_repository_test.dart b/app/test/services/app_settings_repository_test.dart index 250036d..70de074 100644 --- a/app/test/services/app_settings_repository_test.dart +++ b/app/test/services/app_settings_repository_test.dart @@ -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_settings_repository.dart'; +import 'package:dmc_app/transport/connection_type.dart'; void main() { late AppDatabase db; @@ -75,4 +76,34 @@ void main() { expect(settings.connectionDeviceAddress, 'AA:BB:CC:DD:EE:FF'); 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); + }); } diff --git a/app/test/transport/udp_transport_test.dart b/app/test/transport/udp_transport_test.dart new file mode 100644 index 0000000..8c2b3bc --- /dev/null +++ b/app/test/transport/udp_transport_test.dart @@ -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 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 = []; + 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 = []; + transport.incoming.listen(received.add); + + sender.send( + Uint8List.fromList([1, 2, 3]), + InternetAddress.loopbackIPv4, + transport.boundPort!, + ); + await Future.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()), + ); + + 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.delayed(const Duration(milliseconds: 50)); + + final receivedByPeer = []; + 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.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(); + }); +}