diff --git a/DMC_Kommunikationsschicht.md b/DMC_Kommunikationsschicht.md new file mode 100644 index 0000000..13a8d10 --- /dev/null +++ b/DMC_Kommunikationsschicht.md @@ -0,0 +1,272 @@ +# DMC — Kommunikationsschicht + +Erweiterung der bestehenden Flutter-App um die Verbindung zum Fluggerät. +Diese Datei beschreibt **nur** Transport, MSP und die zugehörige Bedienung. +Planung, Karte, Wind und Gelände sind bereits vorhanden und werden nicht berührt. + +--- + +## 1. Aufgabe + +Die App soll im Fly-Modus per Funk mit dem Flight Controller sprechen: +Telemetrie lesen, später Missionen hochladen. Der Weg führt über die +Fernsteuerung, nicht über eine eigene Funkstrecke. + +``` +Handy ──Bluetooth SPP──► RadioMaster Pocket (mLRS Tx + Backpack) + │ 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 funktioniert +parallel unverändert weiter. + +**MVP:** Bluetooth Classic (SPP), nur Android. +**Später:** WLAN (UDP) als in den Einstellungen wählbare Alternative. + +--- + +## 2. Transportschnittstelle + +Zuerst bauen. Alles Weitere wird ausschließlich gegen diese Schnittstelle +programmiert, damit WLAN später eine zweite Implementierung ist und kein Umbau. + +```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: +- `BluetoothClassicTransport` — MVP +- `UdpTransport` — später +- `LoopbackTransport` — für Tests und Entwicklung ohne Hardware + +Der MSP-Code darüber kennt ausschließlich `LinkTransport`. Kein +`import` von Bluetooth- oder Netzwerkpaketen außerhalb von `transport/`. + +--- + +## 3. Bluetooth Classic (MVP) + +### Warum Classic und nicht BLE + +MSP ist ein Byte-Strom, und SPP liefert genau das. BLE kennt nur GATT mit kurzen +Attributen; ein serieller Kanal müsste dort über einen UART-Dienst nachgebaut +werden, inklusive eigener Zerlegung und Wiederzusammensetzung der MSP-Nachrichten. + +Zweiter Grund: Bluetooth berührt den Netzwerk-Stack nicht. WLAN und Mobilfunk +bleiben im Flug frei für Kartenkacheln und Wetterabruf. Bei WLAN ist genau das +das Problem (siehe 6). + +BLE bleibt relevant, falls iOS dazukommt — dort gibt es kein SPP. + +### Paket und Konstanten + +```yaml +dependencies: + flutter_classic_bluetooth: ^ # RFCOMM/SPP, gepflegt + permission_handler: ^ +``` + +SPP-UUID: `00001101-0000-1000-8000-00805F9B34FB` + +### Verbindungsablauf beim Tippen auf „Fly" + +1. **Bluetooth an?** + Wenn nicht: Hinweis mit Knopf in die Systemeinstellungen. + `BluetoothAdapter.enable()` ist ab API 31 nicht mehr nutzbar — die App kann + Bluetooth nicht selbst einschalten. + +2. **Gerät bestimmen — ohne zu suchen.** + `getBondedDevices()` liefert die gekoppelten Geräte sofort. Auswahl in dieser + Reihenfolge: + a) gemerkte MAC-Adresse aus den Einstellungen + b) erstes gekoppeltes Gerät, dessen Name mit `mLRS` beginnt + c) sonst: Auswahlliste anzeigen + + **Nicht scannen**, wenn ein gekoppeltes Gerät gefunden wurde. Ein Scan kostet + Sekunden, braucht eine zusätzliche Berechtigung und ist unnötig. + +3. **Laufende Suche abbrechen**, falls doch eine läuft. Andernfalls schlägt der + Verbindungsaufbau fehl oder wird sehr langsam — klassischer Android-Fallstrick. + +4. **Socket öffnen**, `LinkState.connected` melden, MSP-Polling starten. + +Zielzeit: unter 3 Sekunden, ohne einen einzigen Dialog. + +### Kopplung + +Bleibt einmalig manuell über den Systemdialog, das ist nicht umgehbar. Danach +bleiben die Geräte gekoppelt und verbinden sich bei künftigen Sitzungen +automatisch, solange die Kopplung nicht gelöscht wird. + +In den Einstellungen ein Knopf „Gerät koppeln", der die Bluetooth-Systemeinstellungen +öffnet, plus ein kurzer Hinweistext. + +### Berechtigungen + +| Ab Android 12 (API 31) | Darunter | +|---|---| +| `BLUETOOTH_CONNECT` (Laufzeit) | `BLUETOOTH`, `BLUETOOTH_ADMIN` | +| `BLUETOOTH_SCAN` nur falls gescannt wird | `ACCESS_FINE_LOCATION` | + +`BLUETOOTH_CONNECT` erscheint dem Nutzer als **„Geräte in der Nähe"**. + +Wichtig: Wird sie beim ersten Mal abgelehnt, fragt Android **kein zweites Mal**. +Diesen Fall abfangen und einen erklärenden Hinweis mit Knopf in die +App-Einstellungen zeigen — nicht bloß „Verbindung fehlgeschlagen". + +### Robustheit + +- Wiederverbinden mit ansteigender Wartezeit (1 s, 2 s, 4 s, … max. 30 s) +- Bei `LinkState.error` den Grund mitführen und in der Oberfläche anzeigen +- **Alter des letzten empfangenen MSP-Pakets** dauerhaft sichtbar machen. + Im Feld ist das der einzige verlässliche Hinweis, ob die Werte noch aktuell + sind. Vorschlag: grün < 2 s, bernstein < 5 s, rot darüber. +- Verbindung beim Verlassen des Fly-Modus sauber schließen + +--- + +## 4. MSP + +### Rahmenformat + +iNAV erwartet **MSPv2**. MSPv1 reicht nicht — der iNAV-Configurator sendet +ebenfalls v2, und die MSP-über-CRSF-Umsetzung scheitert genau daran. + +``` +'$' 'X' +dir: '<' Anfrage, '>' Antwort, '!' Fehler +crc: CRC8 DVB-S2 über flag..payload +``` + +Zu implementieren: Kodierer, Streaming-Dekodierer (der Byte-Strom kommt +fragmentiert an, also Zustandsautomat statt „ein Paket pro Lesevorgang"), +CRC-Prüfung, Verwerfen fehlerhafter Rahmen. + +### Polling + +MSP ist ein Frage-Antwort-Protokoll: die App fragt, der FC antwortet. Es kommt +nichts von selbst. Also ein eigener Takt: + +| Zweck | Rate | +|---|---| +| Position, Lage, Höhe | 5–10 Hz | +| Spannung, Strom | 1 Hz | +| Status, Flugmodus | 2 Hz | + +Anfragen serialisieren: immer nur eine offene Anfrage, mit Zeitgrenze +(z. B. 500 ms) und Wiederholung. Sonst laufen bei schwacher Strecke die +Antworten durcheinander. + +**Befehlsnummern nicht aus dem Gedächtnis übernehmen.** Gegen +`msp_protocol.h` beziehungsweise `msp_protocol_v2_inav.h` im iNAV-Repository +prüfen und als Konstanten an einer Stelle sammeln. + +### Reihenfolge der Umsetzung + +1. Rahmen kodieren/dekodieren + Tests gegen aufgezeichnete Bytefolgen +2. Telemetrie lesen und in der bestehenden Fly-Anzeige darstellen + (`LIVE`-Zeilen bei Höhe und Geschwindigkeit sind bereits vorhanden) +3. Erst danach Missionsupload + +--- + +## 5. Gegenstelle (keine App-Arbeit, aber Voraussetzung) + +Zur Einordnung, damit beim Testen klar ist, woran es liegt, wenn nichts kommt. + +**mLRS-Empfänger:** `Rx Ser Link Mode` = `mspX`, `Rx Ser Baudrate` = 115200, +`Rx Snd RcChannel` = `rc override` oder `rc channels`. + +**mLRS-Sender:** `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 autonom fliegende Fixed-Wing unkritisch. + +--- + +## 6. WLAN (später) + +Vorbereiten, aber nicht im MVP umsetzen. Die Fallstricke hier festhalten, damit +sie nicht neu entdeckt werden müssen. + +Die Bridge erzeugt `mLRS-xxxx AP UDP`, ohne Passwort, UDP-Port **14550**. + +**Präfix-Verbindung.** Die SSID enthält eine Zufallszahl, der genaue Name ist +also unbekannt. `WifiNetworkSpecifier` mit `setSsidPattern(PatternMatcher("mLRS-", +PATTERN_PREFIX))` — der Systemdialog zeigt dann nur die Bridge, der Nutzer +bestätigt bloß. + +**Kein Internet auf diesem Netz.** Ohne Bindung routet Android weiter über +Mobilfunk, und die UDP-Pakete verschwinden, obwohl das WLAN verbunden aussieht. +Zwei Wege: + +- *Einfach:* `forceWifiUsage(true)` aus `wifi_iot`. Bindet aber **den gesamten + Prozess** — solange es aktiv ist, sind Kartenkacheln und Wetter nicht mehr + erreichbar. Nur im Fly-Modus setzen und beim Verlassen sofort lösen. Es gibt + Berichte über gerätespezifische Aussetzer. +- *Sauber:* UDP-Socket nativ in Kotlin öffnen, per `Network.bindSocket()` + **einzeln** binden, Daten über einen EventChannel nach Dart streamen. Dann + läuft MSP über die Bridge und alles andere weiter über Mobilfunk. + +**Zieladresse nicht fest eincodieren.** Gateway-Adresse des Netzes verwenden +(das ist der ESP) oder die Absenderadresse des ersten eingehenden Pakets merken. + +--- + +## 7. Einstellungen + +Neuer Abschnitt „Verbindung": + +- **Art:** Bluetooth · WLAN (ausgegraut bis umgesetzt) · USB (später) +- **Gerät:** Auswahlliste der gekoppelten Geräte, Standard + „automatisch (Name beginnt mit mLRS)" +- **Beim Wechsel in Fly automatisch verbinden:** an/aus, Standard an +- **Gerät koppeln** → öffnet die Bluetooth-Systemeinstellungen +- **Verbindungsprotokoll** → einfache Textansicht der letzten Ereignisse + (verbunden, getrennt, Fehler, Paketzähler). Spart im Feld viel Ratearbeit. + +--- + +## 8. Abnahmekriterien + +- [ ] Nach einmaliger Kopplung verbindet ein Tipp auf „Fly" in unter 3 Sekunden + ohne Dialog +- [ ] Bluetooth aus → verständlicher Hinweis mit Knopf in die Einstellungen, + kein stiller Fehlschlag +- [ ] Berechtigung abgelehnt → erklärender Hinweis mit Weg in die App-Einstellungen +- [ ] Verbindung wird während des Betriebs getrennt → automatischer Neuaufbau, + Zustand jederzeit in der Oberfläche sichtbar +- [ ] Telemetriealter ist immer sichtbar und färbt sich bei Veralten um +- [ ] 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 +- [ ] Verlassen des Fly-Modus schließt die Verbindung +- [ ] Kein Bluetooth-Import außerhalb von `transport/` + +--- + +## 9. Hinweise + +- Ohne Hardware entwickelbar: `LoopbackTransport` plus aufgezeichnete + MSP-Antworten. Erst danach am echten Gerät gegenprüfen. +- Sicherheitsrelevante Anzeigen nie mit Platzhaltern füllen. Kommt keine + Telemetrie, muss das sichtbar sein — nicht ein eingefrorener alter Wert. +- Die App steuert das Fluggerät nicht. RC bleibt vollständig bei der + Fernsteuerung; MSP dient dem Lesen und dem Übertragen von Missionen. diff --git a/app/lib/services/database/app_database.dart b/app/lib/services/database/app_database.dart index 471671e..871e947 100644 --- a/app/lib/services/database/app_database.dart +++ b/app/lib/services/database/app_database.dart @@ -43,14 +43,24 @@ class DroneProfiles extends Table { } /// Einzeilige Einstellungstabelle (id fest 0) fuer app-weite Auswahl, die -/// einen Neustart ueberleben soll: zuletzt aktives Drohnenprofil und die +/// einen Neustart ueberleben soll: zuletzt aktives Drohnenprofil, die /// zuletzt bearbeitete (autosaved) Mission, um sie beim naechsten Start -/// fortzusetzen (Doku 7.5: Autosave/Mission-Drafts). +/// fortzusetzen (Doku 7.5: Autosave/Mission-Drafts), sowie die +/// Verbindungseinstellungen (Doku Kommunikationsschicht Abschnitt 7). class AppSettingsTable extends Table { IntColumn get id => integer()(); IntColumn get activeDroneProfileId => integer().nullable()(); IntColumn get currentMissionId => integer().nullable()(); + /// Gemerkte MAC-Adresse des Bluetooth-Zielgeraets (Doku Kommunikations- + /// schicht 3.2a) - entweder manuell gewaehlt oder automatisch anhand des + /// "mLRS"-Namenspraefix aufgeloest. + TextColumn get connectionDeviceAddress => text().nullable()(); + + /// "Beim Wechsel in Fly automatisch verbinden" (Doku 7), Standard an. + BoolColumn get connectionAutoConnect => + boolean().withDefault(const Constant(true))(); + @override Set get primaryKey => {id}; } @@ -62,7 +72,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase.forTesting(super.executor); @override - int get schemaVersion => 2; + int get schemaVersion => 3; @override MigrationStrategy get migration => MigrationStrategy( @@ -73,6 +83,16 @@ class AppDatabase extends _$AppDatabase { await m.createTable(droneProfiles); await m.createTable(appSettingsTable); } + if (from < 3) { + await m.addColumn( + appSettingsTable, + appSettingsTable.connectionDeviceAddress, + ); + await m.addColumn( + appSettingsTable, + appSettingsTable.connectionAutoConnect, + ); + } }, ); diff --git a/app/lib/services/database/app_database.g.dart b/app/lib/services/database/app_database.g.dart index f32706d..e6f346a 100644 --- a/app/lib/services/database/app_database.g.dart +++ b/app/lib/services/database/app_database.g.dart @@ -1322,11 +1322,39 @@ class $AppSettingsTableTable extends AppSettingsTable type: DriftSqlType.int, requiredDuringInsert: false, ); + static const VerificationMeta _connectionDeviceAddressMeta = + const VerificationMeta('connectionDeviceAddress'); + @override + late final GeneratedColumn connectionDeviceAddress = + GeneratedColumn( + 'connection_device_address', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _connectionAutoConnectMeta = + const VerificationMeta('connectionAutoConnect'); + @override + late final GeneratedColumn connectionAutoConnect = + GeneratedColumn( + 'connection_auto_connect', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("connection_auto_connect" IN (0, 1))', + ), + defaultValue: const Constant(true), + ); @override List get $columns => [ id, activeDroneProfileId, currentMissionId, + connectionDeviceAddress, + connectionAutoConnect, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -1361,6 +1389,24 @@ class $AppSettingsTableTable extends AppSettingsTable ), ); } + if (data.containsKey('connection_device_address')) { + context.handle( + _connectionDeviceAddressMeta, + connectionDeviceAddress.isAcceptableOrUnknown( + data['connection_device_address']!, + _connectionDeviceAddressMeta, + ), + ); + } + if (data.containsKey('connection_auto_connect')) { + context.handle( + _connectionAutoConnectMeta, + connectionAutoConnect.isAcceptableOrUnknown( + data['connection_auto_connect']!, + _connectionAutoConnectMeta, + ), + ); + } return context; } @@ -1382,6 +1428,14 @@ class $AppSettingsTableTable extends AppSettingsTable DriftSqlType.int, data['${effectivePrefix}current_mission_id'], ), + connectionDeviceAddress: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}connection_device_address'], + ), + connectionAutoConnect: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}connection_auto_connect'], + )!, ); } @@ -1396,10 +1450,20 @@ class AppSettingsTableData extends DataClass final int id; final int? activeDroneProfileId; final int? currentMissionId; + + /// Gemerkte MAC-Adresse des Bluetooth-Zielgeraets (Doku Kommunikations- + /// schicht 3.2a) - entweder manuell gewaehlt oder automatisch anhand des + /// "mLRS"-Namenspraefix aufgeloest. + final String? connectionDeviceAddress; + + /// "Beim Wechsel in Fly automatisch verbinden" (Doku 7), Standard an. + final bool connectionAutoConnect; const AppSettingsTableData({ required this.id, this.activeDroneProfileId, this.currentMissionId, + this.connectionDeviceAddress, + required this.connectionAutoConnect, }); @override Map toColumns(bool nullToAbsent) { @@ -1411,6 +1475,12 @@ class AppSettingsTableData extends DataClass if (!nullToAbsent || currentMissionId != null) { map['current_mission_id'] = Variable(currentMissionId); } + if (!nullToAbsent || connectionDeviceAddress != null) { + map['connection_device_address'] = Variable( + connectionDeviceAddress, + ); + } + map['connection_auto_connect'] = Variable(connectionAutoConnect); return map; } @@ -1423,6 +1493,10 @@ class AppSettingsTableData extends DataClass currentMissionId: currentMissionId == null && nullToAbsent ? const Value.absent() : Value(currentMissionId), + connectionDeviceAddress: connectionDeviceAddress == null && nullToAbsent + ? const Value.absent() + : Value(connectionDeviceAddress), + connectionAutoConnect: Value(connectionAutoConnect), ); } @@ -1437,6 +1511,12 @@ class AppSettingsTableData extends DataClass json['activeDroneProfileId'], ), currentMissionId: serializer.fromJson(json['currentMissionId']), + connectionDeviceAddress: serializer.fromJson( + json['connectionDeviceAddress'], + ), + connectionAutoConnect: serializer.fromJson( + json['connectionAutoConnect'], + ), ); } @override @@ -1446,6 +1526,10 @@ class AppSettingsTableData extends DataClass 'id': serializer.toJson(id), 'activeDroneProfileId': serializer.toJson(activeDroneProfileId), 'currentMissionId': serializer.toJson(currentMissionId), + 'connectionDeviceAddress': serializer.toJson( + connectionDeviceAddress, + ), + 'connectionAutoConnect': serializer.toJson(connectionAutoConnect), }; } @@ -1453,6 +1537,8 @@ class AppSettingsTableData extends DataClass int? id, Value activeDroneProfileId = const Value.absent(), Value currentMissionId = const Value.absent(), + Value connectionDeviceAddress = const Value.absent(), + bool? connectionAutoConnect, }) => AppSettingsTableData( id: id ?? this.id, activeDroneProfileId: activeDroneProfileId.present @@ -1461,6 +1547,10 @@ class AppSettingsTableData extends DataClass currentMissionId: currentMissionId.present ? currentMissionId.value : this.currentMissionId, + connectionDeviceAddress: connectionDeviceAddress.present + ? connectionDeviceAddress.value + : this.connectionDeviceAddress, + connectionAutoConnect: connectionAutoConnect ?? this.connectionAutoConnect, ); AppSettingsTableData copyWithCompanion(AppSettingsTableCompanion data) { return AppSettingsTableData( @@ -1471,6 +1561,12 @@ class AppSettingsTableData extends DataClass currentMissionId: data.currentMissionId.present ? data.currentMissionId.value : this.currentMissionId, + connectionDeviceAddress: data.connectionDeviceAddress.present + ? data.connectionDeviceAddress.value + : this.connectionDeviceAddress, + connectionAutoConnect: data.connectionAutoConnect.present + ? data.connectionAutoConnect.value + : this.connectionAutoConnect, ); } @@ -1479,46 +1575,68 @@ class AppSettingsTableData extends DataClass return (StringBuffer('AppSettingsTableData(') ..write('id: $id, ') ..write('activeDroneProfileId: $activeDroneProfileId, ') - ..write('currentMissionId: $currentMissionId') + ..write('currentMissionId: $currentMissionId, ') + ..write('connectionDeviceAddress: $connectionDeviceAddress, ') + ..write('connectionAutoConnect: $connectionAutoConnect') ..write(')')) .toString(); } @override - int get hashCode => Object.hash(id, activeDroneProfileId, currentMissionId); + int get hashCode => Object.hash( + id, + activeDroneProfileId, + currentMissionId, + connectionDeviceAddress, + connectionAutoConnect, + ); @override bool operator ==(Object other) => identical(this, other) || (other is AppSettingsTableData && other.id == this.id && other.activeDroneProfileId == this.activeDroneProfileId && - other.currentMissionId == this.currentMissionId); + other.currentMissionId == this.currentMissionId && + other.connectionDeviceAddress == this.connectionDeviceAddress && + other.connectionAutoConnect == this.connectionAutoConnect); } class AppSettingsTableCompanion extends UpdateCompanion { final Value id; final Value activeDroneProfileId; final Value currentMissionId; + final Value connectionDeviceAddress; + final Value connectionAutoConnect; 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(), }); AppSettingsTableCompanion.insert({ 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(), }); static Insertable custom({ Expression? id, Expression? activeDroneProfileId, Expression? currentMissionId, + Expression? connectionDeviceAddress, + Expression? connectionAutoConnect, }) { return RawValuesInsertable({ if (id != null) 'id': id, if (activeDroneProfileId != null) 'active_drone_profile_id': activeDroneProfileId, if (currentMissionId != null) 'current_mission_id': currentMissionId, + if (connectionDeviceAddress != null) + 'connection_device_address': connectionDeviceAddress, + if (connectionAutoConnect != null) + 'connection_auto_connect': connectionAutoConnect, }); } @@ -1526,11 +1644,17 @@ class AppSettingsTableCompanion extends UpdateCompanion { Value? id, Value? activeDroneProfileId, Value? currentMissionId, + Value? connectionDeviceAddress, + Value? connectionAutoConnect, }) { return AppSettingsTableCompanion( id: id ?? this.id, activeDroneProfileId: activeDroneProfileId ?? this.activeDroneProfileId, currentMissionId: currentMissionId ?? this.currentMissionId, + connectionDeviceAddress: + connectionDeviceAddress ?? this.connectionDeviceAddress, + connectionAutoConnect: + connectionAutoConnect ?? this.connectionAutoConnect, ); } @@ -1548,6 +1672,16 @@ class AppSettingsTableCompanion extends UpdateCompanion { if (currentMissionId.present) { map['current_mission_id'] = Variable(currentMissionId.value); } + if (connectionDeviceAddress.present) { + map['connection_device_address'] = Variable( + connectionDeviceAddress.value, + ); + } + if (connectionAutoConnect.present) { + map['connection_auto_connect'] = Variable( + connectionAutoConnect.value, + ); + } return map; } @@ -1556,7 +1690,9 @@ class AppSettingsTableCompanion extends UpdateCompanion { return (StringBuffer('AppSettingsTableCompanion(') ..write('id: $id, ') ..write('activeDroneProfileId: $activeDroneProfileId, ') - ..write('currentMissionId: $currentMissionId') + ..write('currentMissionId: $currentMissionId, ') + ..write('connectionDeviceAddress: $connectionDeviceAddress, ') + ..write('connectionAutoConnect: $connectionAutoConnect') ..write(')')) .toString(); } @@ -2195,12 +2331,16 @@ typedef $$AppSettingsTableTableCreateCompanionBuilder = Value id, Value activeDroneProfileId, Value currentMissionId, + Value connectionDeviceAddress, + Value connectionAutoConnect, }); typedef $$AppSettingsTableTableUpdateCompanionBuilder = AppSettingsTableCompanion Function({ Value id, Value activeDroneProfileId, Value currentMissionId, + Value connectionDeviceAddress, + Value connectionAutoConnect, }); class $$AppSettingsTableTableFilterComposer @@ -2226,6 +2366,16 @@ class $$AppSettingsTableTableFilterComposer column: $table.currentMissionId, builder: (column) => ColumnFilters(column), ); + + ColumnFilters get connectionDeviceAddress => $composableBuilder( + column: $table.connectionDeviceAddress, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get connectionAutoConnect => $composableBuilder( + column: $table.connectionAutoConnect, + builder: (column) => ColumnFilters(column), + ); } class $$AppSettingsTableTableOrderingComposer @@ -2251,6 +2401,16 @@ class $$AppSettingsTableTableOrderingComposer column: $table.currentMissionId, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get connectionDeviceAddress => $composableBuilder( + column: $table.connectionDeviceAddress, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get connectionAutoConnect => $composableBuilder( + column: $table.connectionAutoConnect, + builder: (column) => ColumnOrderings(column), + ); } class $$AppSettingsTableTableAnnotationComposer @@ -2274,6 +2434,16 @@ class $$AppSettingsTableTableAnnotationComposer column: $table.currentMissionId, builder: (column) => column, ); + + GeneratedColumn get connectionDeviceAddress => $composableBuilder( + column: $table.connectionDeviceAddress, + builder: (column) => column, + ); + + GeneratedColumn get connectionAutoConnect => $composableBuilder( + column: $table.connectionAutoConnect, + builder: (column) => column, + ); } class $$AppSettingsTableTableTableManager @@ -2316,20 +2486,28 @@ class $$AppSettingsTableTableTableManager Value id = const Value.absent(), Value activeDroneProfileId = const Value.absent(), Value currentMissionId = const Value.absent(), + Value connectionDeviceAddress = const Value.absent(), + Value connectionAutoConnect = const Value.absent(), }) => AppSettingsTableCompanion( id: id, activeDroneProfileId: activeDroneProfileId, currentMissionId: currentMissionId, + connectionDeviceAddress: connectionDeviceAddress, + connectionAutoConnect: connectionAutoConnect, ), createCompanionCallback: ({ Value id = const Value.absent(), Value activeDroneProfileId = const Value.absent(), Value currentMissionId = const Value.absent(), + Value connectionDeviceAddress = const Value.absent(), + Value connectionAutoConnect = const Value.absent(), }) => AppSettingsTableCompanion.insert( id: id, activeDroneProfileId: activeDroneProfileId, currentMissionId: currentMissionId, + connectionDeviceAddress: connectionDeviceAddress, + connectionAutoConnect: connectionAutoConnect, ), 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 76414fb..ed31bbf 100644 --- a/app/lib/services/database/app_settings_repository.dart +++ b/app/lib/services/database/app_settings_repository.dart @@ -12,13 +12,21 @@ class AppSettingsRepository { static const _rowId = 0; - Future<({int? activeDroneProfileId, int? currentMissionId})> load() async { + Future< + ({ + int? activeDroneProfileId, + int? currentMissionId, + String? connectionDeviceAddress, + bool connectionAutoConnect, + })> load() async { final row = await (_db.select(_db.appSettingsTable) ..where((s) => s.id.equals(_rowId))) .getSingleOrNull(); return ( activeDroneProfileId: row?.activeDroneProfileId, currentMissionId: row?.currentMissionId, + connectionDeviceAddress: row?.connectionDeviceAddress, + connectionAutoConnect: row?.connectionAutoConnect ?? true, ); } @@ -36,6 +44,26 @@ class AppSettingsRepository { )); } + /// Gemerkte Bluetooth-MAC-Adresse (Doku Kommunikationsschicht 3.2a), oder + /// null um die Erinnerung zu loeschen (z.B. nach "Geraet entkoppeln"). + Future loadConnectionDeviceAddress() async => + (await load()).connectionDeviceAddress; + + Future setConnectionDeviceAddress(String? address) async { + await _upsert(AppSettingsTableCompanion( + id: const Value(_rowId), + connectionDeviceAddress: Value(address), + )); + } + + /// "Beim Wechsel in Fly automatisch verbinden" (Doku 7), Standard an. + Future setConnectionAutoConnect(bool value) async { + await _upsert(AppSettingsTableCompanion( + id: const Value(_rowId), + connectionAutoConnect: Value(value), + )); + } + Future _upsert(AppSettingsTableCompanion companion) async { await _db.into(_db.appSettingsTable).insertOnConflictUpdate(companion); } diff --git a/app/lib/transport/bluetooth/bluetooth_classic_transport.dart b/app/lib/transport/bluetooth/bluetooth_classic_transport.dart new file mode 100644 index 0000000..1814908 --- /dev/null +++ b/app/lib/transport/bluetooth/bluetooth_classic_transport.dart @@ -0,0 +1,257 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_classic_bluetooth/flutter_classic_bluetooth.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import '../link_transport.dart'; +import 'bluetooth_device_info.dart'; +import 'bluetooth_device_selector.dart'; + +/// Bluetooth-Classic-(SPP)-Transport (Doku Kommunikationsschicht Abschnitt 3): +/// verbindet sich mit dem RadioMaster-Pocket-Backpack (mLRS Tx) ueber RFCOMM. +/// Setzt den in Abschnitt 3 beschriebenen Verbindungsablauf um: +/// +/// 1. Bluetooth-Adapter an? (kann ab Android 12 nicht mehr programmatisch +/// eingeschaltet werden - meldet stattdessen [LinkErrorReason.bluetoothOff]). +/// 2. Geraet bestimmen, ohne zu scannen: gemerkte MAC-Adresse, sonst erstes +/// gekoppeltes Geraet mit "mLRS"-Praefix, sonst +/// [LinkErrorReason.deviceSelectionRequired] (Oberflaeche zeigt +/// Auswahlliste, siehe [listBondedDevices]). +/// 3. Laufende Discovery abbrechen, falls eine laeuft. +/// 4. Socket oeffnen; danach uebernimmt der eingebaute Reconnect-Mechanismus +/// des Plugins (exponentielles Backoff 1s -> 30s) das Wiederverbinden bei +/// Verbindungsabbruch. +class BluetoothClassicTransport implements LinkTransport { + BluetoothClassicTransport({ + required Future Function() getRememberedAddress, + required Future Function(String address) rememberAddress, + FlutterClassicBluetooth? bluetooth, + }) : _getRememberedAddress = getRememberedAddress, + _rememberAddress = rememberAddress, + _bluetooth = bluetooth ?? FlutterClassicBluetooth(); + + final Future Function() _getRememberedAddress; + final Future Function(String address) _rememberAddress; + final FlutterClassicBluetooth _bluetooth; + + final _incomingController = StreamController.broadcast(); + final _stateController = StreamController.broadcast(); + + BtcReconnectingConnection? _link; + StreamSubscription? _linkDataSub; + StreamSubscription? _linkStateSub; + + BluetoothDeviceInfo? _connectedDevice; + 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; + + /// Das aktuell verbundene (oder zuletzt verbundene) Geraet, falls bekannt. + BluetoothDeviceInfo? get connectedDevice => _connectedDevice; + + /// Gekoppelte Geraete fuer die manuelle Auswahlliste (Doku 3.2c). Scannt + /// nicht - liefert nur bereits gekoppelte Geraete, das ist sofort verfuegbar. + Future> listBondedDevices() async { + final devices = await _bluetooth.getPairedDevices(); + return [ + for (final d in devices) + BluetoothDeviceInfo(address: d.address, name: d.name ?? d.alias), + ]; + } + + @override + Future connect() async { + _lastErrorReason = null; + _lastErrorMessage = null; + _setState(LinkState.connecting); + + try { + if (!await _bluetooth.isEnabled()) { + _fail( + LinkErrorReason.bluetoothOff, + 'Bluetooth-Adapter ist aus. Kann ab Android 12 nicht mehr aus der ' + 'App heraus eingeschaltet werden.', + ); + } + + final permissionOutcome = await _ensureConnectPermission(); + if (permissionOutcome == _PermissionOutcome.deniedPermanently) { + _fail( + LinkErrorReason.permissionDeniedPermanently, + '"Geraete in der Naehe"-Berechtigung dauerhaft abgelehnt.', + ); + } + if (permissionOutcome == _PermissionOutcome.denied) { + _fail( + LinkErrorReason.permissionDenied, + '"Geraete in der Naehe"-Berechtigung abgelehnt.', + ); + } + + final bonded = await listBondedDevices(); + final remembered = await _getRememberedAddress(); + final selection = selectBluetoothDevice( + bondedDevices: bonded, + rememberedAddress: remembered, + ); + + if (selection is BluetoothDeviceSelectionRequired) { + _fail( + LinkErrorReason.deviceSelectionRequired, + 'Kein gekoppeltes mLRS-Geraet automatisch bestimmbar ' + '(${bonded.length} gekoppelte Geraete zur Auswahl).', + ); + } + final device = (selection as BluetoothDeviceResolved).device; + + if (device.address != remembered) { + await _rememberAddress(device.address); + } + + try { + if (await _bluetooth.isDiscovering()) { + await _bluetooth.stopDiscovery(); + } + } catch (_) { + // Discovery laeuft ohnehin nicht (mehr) - unkritisch (Doku 3.3). + } + + _connectedDevice = device; + final link = _bluetooth.connectWithReconnect( + address: device.address, + uuid: BtcUuid.spp, + ); + _link = link; + _linkStateSub = link.state.listen(_onReconnectStateChanged); + _linkDataSub = link.input.listen(_incomingController.add); + } on BtcException catch (e) { + _fail(_reasonForException(e), e.message); + } + } + + @override + Future disconnect() async { + await _linkDataSub?.cancel(); + _linkDataSub = null; + await _linkStateSub?.cancel(); + _linkStateSub = null; + await _link?.close(); + _link = null; + _setState(LinkState.disconnected); + } + + @override + Future send(Uint8List data) async { + final link = _link; + if (link == null) { + throw const LinkConnectException( + LinkErrorReason.connectionFailed, + 'send() ohne aktive Verbindung aufgerufen.', + ); + } + await link.send(data); + } + + @override + void dispose() { + _linkDataSub?.cancel(); + _linkStateSub?.cancel(); + unawaited(_link?.close()); + _link = null; + _incomingController.close(); + _stateController.close(); + } + + void _onReconnectStateChanged(BtcReconnectState s) { + switch (s) { + case BtcReconnectState.connecting: + case BtcReconnectState.reconnecting: + _setState(LinkState.connecting); + case BtcReconnectState.connected: + _lastErrorReason = null; + _lastErrorMessage = null; + _setState(LinkState.connected); + case BtcReconnectState.closed: + _setState(LinkState.disconnected); + case BtcReconnectState.failed: + // Nur erreichbar, wenn BtcReconnectPolicy.maxAttempts gesetzt waere; + // die Standardpolicy (Doku 3, Robustheit: 1s->30s, unbegrenzt) haelt + // stattdessen einfach LinkState.connecting, bis es klappt oder + // disconnect() gerufen wird. Trotzdem hier behandelt, falls die + // Policy kuenftig eine Obergrenze bekommt. + _setError( + LinkErrorReason.connectionFailed, + _link?.lastError?.toString() ?? 'Verbindung fehlgeschlagen.', + ); + } + } + + /// Setzt Fehlergrund/-nachricht und meldet [LinkState.error] ueber den + /// Zustands-Stream, ohne selbst zu werfen - fuer asynchrone Beobachter + /// (z.B. den Reconnect-Zustands-Listener). + void _setError(LinkErrorReason reason, String message) { + _lastErrorReason = reason; + _lastErrorMessage = message; + _setState(LinkState.error); + } + + /// Wie [_setError], wirft danach zusaetzlich eine [LinkConnectException] + /// mit demselben Grund - siehe dortige Doku. Nur fuer Fehler, die + /// synchron innerhalb von [connect] erkannt werden. + 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; + + Future<_PermissionOutcome> _ensureConnectPermission() async { + // MVP ist auf Android beschraenkt (Doku CLAUDE.md); auf anderen + // Plattformen (z.B. Windows-Desktop waehrend der UI-Entwicklung) gibt es + // keine "Geraete in der Naehe"-Laufzeitberechtigung zu pruefen. + if (!Platform.isAndroid) return _PermissionOutcome.granted; + + var status = await Permission.bluetoothConnect.status; + if (status.isGranted) return _PermissionOutcome.granted; + if (status.isPermanentlyDenied) { + return _PermissionOutcome.deniedPermanently; + } + + status = await Permission.bluetoothConnect.request(); + if (status.isGranted) return _PermissionOutcome.granted; + if (status.isPermanentlyDenied) { + return _PermissionOutcome.deniedPermanently; + } + return _PermissionOutcome.denied; + } + + LinkErrorReason _reasonForException(BtcException e) { + if (e is BtcPermissionException) return LinkErrorReason.permissionDenied; + if (e is BtcDisabledException) return LinkErrorReason.bluetoothOff; + if (e is BtcConnectionException || e is BtcTimeoutException) { + return LinkErrorReason.connectionFailed; + } + return LinkErrorReason.unknown; + } +} + +enum _PermissionOutcome { granted, denied, deniedPermanently } diff --git a/app/lib/transport/bluetooth/bluetooth_device_info.dart b/app/lib/transport/bluetooth/bluetooth_device_info.dart new file mode 100644 index 0000000..245966f --- /dev/null +++ b/app/lib/transport/bluetooth/bluetooth_device_info.dart @@ -0,0 +1,23 @@ +/// DMC-eigenes, plugin-unabhaengiges Geraetemodell fuer gekoppelte +/// Bluetooth-Classic-Geraete (Doku Kommunikationsschicht Abschnitt 3.2). +/// Haelt `flutter_classic_bluetooth`-Typen aus der Oberflaeche heraus, damit +/// nur transport/bluetooth/ das Paket kennen muss. +class BluetoothDeviceInfo { + const BluetoothDeviceInfo({required this.address, this.name}); + + final String address; + final String? name; + + String get displayName => name ?? address; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BluetoothDeviceInfo && other.address == address; + + @override + int get hashCode => address.hashCode; + + @override + String toString() => 'BluetoothDeviceInfo($address, $name)'; +} diff --git a/app/lib/transport/bluetooth/bluetooth_device_selector.dart b/app/lib/transport/bluetooth/bluetooth_device_selector.dart new file mode 100644 index 0000000..76a1f2b --- /dev/null +++ b/app/lib/transport/bluetooth/bluetooth_device_selector.dart @@ -0,0 +1,63 @@ +import 'bluetooth_device_info.dart'; + +/// Praefix, an dem der mLRS-Backpack in seinem Bluetooth-Namen erkannt wird +/// (Doku Kommunikationsschicht Abschnitt 3.2). +const mLrsDeviceNamePrefix = 'mLRS'; + +/// Ergebnis der automatischen Geraeteauswahl (Doku Abschnitt 3.2): entweder +/// ein eindeutig bestimmtes Geraet, oder die Angabe, dass die Oberflaeche +/// eine Auswahlliste zeigen muss. +sealed class BluetoothDeviceSelection { + const BluetoothDeviceSelection(); +} + +class BluetoothDeviceResolved extends BluetoothDeviceSelection { + const BluetoothDeviceResolved(this.device); + final BluetoothDeviceInfo device; +} + +class BluetoothDeviceSelectionRequired extends BluetoothDeviceSelection { + const BluetoothDeviceSelectionRequired(this.candidates); + + /// Gekoppelte Geraete, aus denen die Oberflaeche eine Auswahl anbieten + /// muss (Doku 3.2c). + final List candidates; +} + +/// Reine Auswahllogik, unabhaengig vom Bluetooth-Plugin und ohne +/// Plattform-Channel - daher ohne Hardware testbar. +/// +/// Reihenfolge (Doku Abschnitt 3.2): +/// a) gemerkte MAC-Adresse aus den Einstellungen (falls noch gekoppelt) +/// b) erstes gekoppeltes Geraet, dessen Name mit "mLRS" beginnt +/// c) sonst: Auswahlliste anzeigen +BluetoothDeviceSelection selectBluetoothDevice({ + required List bondedDevices, + String? rememberedAddress, +}) { + if (rememberedAddress != null) { + final remembered = _firstWhereOrNull( + bondedDevices, + (d) => d.address == rememberedAddress, + ); + if (remembered != null) return BluetoothDeviceResolved(remembered); + } + + final mLrsDevice = _firstWhereOrNull( + bondedDevices, + (d) => (d.name ?? '').startsWith(mLrsDeviceNamePrefix), + ); + if (mLrsDevice != null) return BluetoothDeviceResolved(mLrsDevice); + + return BluetoothDeviceSelectionRequired(bondedDevices); +} + +BluetoothDeviceInfo? _firstWhereOrNull( + List devices, + bool Function(BluetoothDeviceInfo) test, +) { + for (final device in devices) { + if (test(device)) return device; + } + return null; +} diff --git a/app/lib/transport/bluetooth/bluetooth_system_settings.dart b/app/lib/transport/bluetooth/bluetooth_system_settings.dart new file mode 100644 index 0000000..6d84c00 --- /dev/null +++ b/app/lib/transport/bluetooth/bluetooth_system_settings.dart @@ -0,0 +1,20 @@ +import 'package:app_settings/app_settings.dart'; +import 'package:permission_handler/permission_handler.dart' as ph; + +/// Verlinkt zu den System-/App-Einstellungen fuer die drei Bluetooth- +/// Hinweisfaelle aus Doku Kommunikationsschicht Abschnitt 3/7 - haelt die +/// Wahl des Settings-Pakets aus ui/ heraus (Doku: kein Bluetooth-/ +/// Settings-Import ausserhalb von transport/). +abstract final class BluetoothSystemSettings { + /// "Bluetooth an?" negativ (Doku 3.1) bzw. der "Gerät koppeln"-Knopf + /// (Doku 7): oeffnet die System-Bluetooth-Einstellungen, in denen der + /// Nutzer sowohl den Adapter einschalten als auch ein neues Geraet koppeln + /// kann. + static Future openBluetoothSettings() => + AppSettings.openAppSettings(type: AppSettingsType.bluetooth); + + /// Berechtigung dauerhaft abgelehnt (Doku 3, Berechtigungen): oeffnet die + /// App-Einstellungen, in denen der Nutzer die Berechtigung manuell erteilen + /// kann, da Android den Dialog kein zweites Mal zeigt. + static Future openAppSettings() => ph.openAppSettings(); +} diff --git a/app/lib/transport/link_transport.dart b/app/lib/transport/link_transport.dart new file mode 100644 index 0000000..fcf5054 --- /dev/null +++ b/app/lib/transport/link_transport.dart @@ -0,0 +1,79 @@ +import 'dart:typed_data'; + +/// Transportzustand, protokollneutral (Doku Kommunikationsschicht Abschnitt 2). +enum LinkState { disconnected, connecting, connected, error } + +/// Grund eines [LinkState.error] (Doku Abschnitt 3, Robustheit/Abnahme): +/// Bluetooth aus und Berechtigung abgelehnt brauchen jeweils einen eigenen, +/// erklaerenden Hinweis in der Oberflaeche statt eines generischen +/// "Verbindung fehlgeschlagen". +enum LinkErrorReason { + /// Bluetooth-Adapter ist aus. Kann ab Android 12 nicht mehr programmatisch + /// eingeschaltet werden - die Oberflaeche muss zu den Systemeinstellungen + /// verlinken. + bluetoothOff, + + /// Berechtigung wurde abgelehnt, darf aber erneut angefragt werden. + permissionDenied, + + /// Berechtigung wurde dauerhaft abgelehnt ("nicht mehr fragen"). Android + /// zeigt den Dialog kein zweites Mal - die Oberflaeche muss zu den + /// App-Einstellungen verlinken. + permissionDeniedPermanently, + + /// Kein gekoppeltes Zielgeraet konnte automatisch bestimmt werden (weder + /// gemerkte MAC-Adresse noch ein Geraet mit "mLRS"-Praefix). Die + /// Oberflaeche muss eine Auswahlliste der gekoppelten Geraete anbieten. + deviceSelectionRequired, + + /// Verbindungsaufbau zum bestimmten Geraet ist fehlgeschlagen oder wurde + /// getrennt. + connectionFailed, + + /// Sonstiger, nicht naeher klassifizierter Fehler. + unknown, +} + +/// Protokollneutrale Transportschnittstelle (Doku Kommunikationsschicht +/// Abschnitt 2). Alles oberhalb (MSP-Encoder/-Decoder) programmiert +/// ausschliesslich gegen diese Schnittstelle - kein Import von Bluetooth- +/// oder Netzwerkpaketen ausserhalb von transport/. +/// +/// Implementierungen: BluetoothClassicTransport (MVP), UdpTransport +/// (spaeter, Doku Abschnitt 6), LoopbackTransport (Tests/Entwicklung ohne +/// Hardware). +abstract class LinkTransport { + Stream get incoming; + Stream get state; + + /// Grund des zuletzt gemeldeten [LinkState.error], oder null solange kein + /// Fehler aufgetreten ist bzw. nach erfolgreicher Verbindung. + LinkErrorReason? get lastErrorReason; + + /// Freitext zum zuletzt gemeldeten Fehler, fuer Diagnose/Verbindungsprotokoll + /// (Doku Abschnitt 7). Nicht fuer die direkte Nutzeranzeige gedacht - dafuer + /// dient [lastErrorReason]. + String? get lastErrorMessage; + + Future send(Uint8List data); + Future connect(); + Future disconnect(); + void dispose(); +} + +/// Wird von [LinkTransport.connect] geworfen, wenn der Verbindungsaufbau +/// vorab (also bevor ueberhaupt eine Socket-Verbindung versucht wird) +/// erkennbar scheitert - z.B. Bluetooth aus, Berechtigung fehlt, oder kein +/// Geraet automatisch bestimmbar. Der gleiche [reason] wird zeitgleich ueber +/// [LinkTransport.state]/[LinkTransport.lastErrorReason] gemeldet, damit +/// sowohl der aufrufende Code (durch Warten auf [LinkTransport.connect]) +/// als auch reine Beobachter der Zustands-Streams reagieren koennen. +class LinkConnectException implements Exception { + const LinkConnectException(this.reason, this.message); + + final LinkErrorReason reason; + final String message; + + @override + String toString() => 'LinkConnectException($reason): $message'; +} diff --git a/app/lib/transport/loopback/loopback_transport.dart b/app/lib/transport/loopback/loopback_transport.dart new file mode 100644 index 0000000..c61e284 --- /dev/null +++ b/app/lib/transport/loopback/loopback_transport.dart @@ -0,0 +1,76 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import '../link_transport.dart'; + +/// Transport ohne echte Gegenstelle (Doku Kommunikationsschicht Abschnitt 2/9): +/// erlaubt UI- und MSP-Entwicklung ohne Bluetooth-Hardware oder SITL. Bytes, +/// die die Gegenstelle "gesendet" haette, werden ueber [feed] eingespeist; +/// [sentData] sammelt, was die App darueber gesendet hat, damit Tests das +/// pruefen koennen. +class LoopbackTransport implements LinkTransport { + final _incomingController = StreamController.broadcast(); + final _stateController = StreamController.broadcast(); + + LinkState _state = LinkState.disconnected; + LinkErrorReason? _lastErrorReason; + String? _lastErrorMessage; + + /// Alle bisher per [send] geschickten Bytes, in Reihenfolge. + final List sentData = []; + + @override + Stream get incoming => _incomingController.stream; + + @override + Stream get state => _stateController.stream; + + @override + LinkErrorReason? get lastErrorReason => _lastErrorReason; + + @override + String? get lastErrorMessage => _lastErrorMessage; + + /// Simuliert eingehende Bytes von der Gegenstelle (z.B. aufgezeichnete + /// MSP-Antworten, Doku Abschnitt 9). + void feed(Uint8List data) => _incomingController.add(data); + + /// Simuliert einen Verbindungsfehler, z.B. um Fehlerbehandlung in der + /// Oberflaeche ohne Hardware zu testen. + void simulateError(LinkErrorReason reason, [String? message]) { + _lastErrorReason = reason; + _lastErrorMessage = message; + _setState(LinkState.error); + } + + @override + Future connect() async { + _lastErrorReason = null; + _lastErrorMessage = null; + _setState(LinkState.connecting); + _setState(LinkState.connected); + } + + @override + Future disconnect() async { + _setState(LinkState.disconnected); + } + + @override + Future send(Uint8List data) async { + sentData.add(data); + } + + @override + void dispose() { + _incomingController.close(); + _stateController.close(); + } + + void _setState(LinkState next) { + _state = next; + if (!_stateController.isClosed) _stateController.add(next); + } + + LinkState get currentState => _state; +} diff --git a/app/lib/transport/msp/msp_flight_controller_link.dart b/app/lib/transport/msp/msp_flight_controller_link.dart index 0b3e2c7..9385907 100644 --- a/app/lib/transport/msp/msp_flight_controller_link.dart +++ b/app/lib/transport/msp/msp_flight_controller_link.dart @@ -1,16 +1,20 @@ import '../../domain/waypoint/flat_waypoint_list.dart'; import '../flight_controller_link.dart'; +import '../link_transport.dart'; /// iNAV-Anbindung via MSP (Architektur-Doku 3.1). Eigenstaendiger Adapter, /// da kein fertiges Dart-Paket fuer MSP verfuegbar ist (Doku 4.19). /// +/// Kennt nur die protokollneutrale [LinkTransport]-Abstraktion (Doku +/// Kommunikationsschicht Abschnitt 2) - ob darunter Bluetooth Classic (MVP) +/// oder spaeter WLAN/UDP steckt, ist fuer diese Klasse unsichtbar. +/// /// Protokoll-Implementierung (Framing, MSP_SET_WP, MSP_NAV_STATUS, ...) ist /// noch offen - dies ist bewusst nur das Geruest fuer die Abstraktion. class MspFlightControllerLink implements FlightControllerLink { - MspFlightControllerLink({required this.host, required this.port}); + MspFlightControllerLink({required this.transport}); - final String host; - final int port; + final LinkTransport transport; @override FcCapabilities get capabilities => throw UnimplementedError(); diff --git a/app/lib/ui/providers/bluetooth_connection_provider.dart b/app/lib/ui/providers/bluetooth_connection_provider.dart new file mode 100644 index 0000000..1e35c0a --- /dev/null +++ b/app/lib/ui/providers/bluetooth_connection_provider.dart @@ -0,0 +1,110 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../transport/bluetooth/bluetooth_classic_transport.dart'; +import '../../transport/bluetooth/bluetooth_device_info.dart'; +import '../../transport/link_transport.dart'; +import 'database_provider.dart'; + +/// App-lebenslanger Bluetooth-Classic-Transport (Doku Kommunikationsschicht +/// Abschnitt 3), verdrahtet gegen die gemerkte Geraete-MAC in den +/// Einstellungen. Bewusst nicht autoDispose: die Verbindung soll bestehen +/// bleiben, waehrend zwischen Fly- und Plan-Modus gewechselt wird, und erst +/// beim Verlassen des Fly-Modus explizit getrennt werden (Doku 3, +/// Robustheit: "Verbindung beim Verlassen des Fly-Modus sauber schliessen"). +final bluetoothTransportProvider = Provider((ref) { + final settingsRepo = ref.watch(appSettingsRepositoryProvider); + final transport = BluetoothClassicTransport( + getRememberedAddress: settingsRepo.loadConnectionDeviceAddress, + rememberAddress: settingsRepo.setConnectionDeviceAddress, + ); + ref.onDispose(transport.dispose); + return transport; +}); + +/// Reaktiver [LinkState] des Bluetooth-Transports fuer die Oberflaeche +/// (Verbindungsindikator, Abnahmekriterium: "Zustand jederzeit sichtbar"). +final linkStateProvider = StreamProvider((ref) { + final transport = ref.watch(bluetoothTransportProvider); + return transport.state; +}); + +/// Grund des zuletzt gemeldeten [LinkState.error] (Doku 3, Robustheit: "Grund +/// mitfuehren und in der Oberflaeche anzeigen"). Wird bei jedem neuen +/// LinkState neu ausgelesen, da der Transport ihn synchron mitfuehrt. +final linkErrorReasonProvider = Provider((ref) { + ref.watch(linkStateProvider); + return ref.watch(bluetoothTransportProvider).lastErrorReason; +}); + +/// Gekoppelte Geraete fuer die manuelle Auswahlliste (Doku 3.2c). Wird bei +/// jedem [LinkState]-Wechsel neu geladen, damit eine frisch angeschlossene +/// Kopplung (ueber den "Geraet koppeln"-Knopf) ohne expliziten Reload +/// auftaucht. +final bondedDevicesProvider = FutureProvider>((ref) { + ref.watch(linkStateProvider); + return ref.watch(bluetoothTransportProvider).listBondedDevices(); +}); + +/// Ein Eintrag im Verbindungsprotokoll (Doku 7: "einfache Textansicht der +/// letzten Ereignisse ... spart im Feld viel Ratearbeit"). +class ConnectionLogEntry { + const ConnectionLogEntry(this.timestamp, this.message); + + final DateTime timestamp; + final String message; +} + +/// Verbindungsprotokoll + Paketzaehler (Doku 7). Haengt sich an den +/// LinkState- und den Rohdaten-Stream des Transports, damit auch ohne +/// MSP-Decoder schon sichtbar ist, dass ueberhaupt Bytes ankommen. +class ConnectionLogNotifier extends Notifier> { + static const _maxEntries = 50; + + StreamSubscription? _stateSub; + StreamSubscription? _dataSub; + int _packetCount = 0; + + int get packetCount => _packetCount; + + @override + List build() { + final transport = ref.watch(bluetoothTransportProvider); + _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(bluetoothTransportProvider); + final message = switch (linkState) { + LinkState.disconnected => 'Getrennt', + LinkState.connecting => 'Verbinde ...', + LinkState.connected => + 'Verbunden${transport.connectedDevice != null ? ' (${transport.connectedDevice!.displayName})' : ''}', + LinkState.error => + 'Fehler: ${transport.lastErrorMessage ?? transport.lastErrorReason?.name ?? 'unbekannt'}', + }; + _append(message); + } + + void _append(String message) { + final entry = ConnectionLogEntry(DateTime.now(), message); + final next = [...state, entry]; + state = next.length > _maxEntries + ? next.sublist(next.length - _maxEntries) + : next; + } +} + +final connectionLogProvider = + NotifierProvider>( + ConnectionLogNotifier.new); diff --git a/app/lib/ui/providers/connection_settings_provider.dart b/app/lib/ui/providers/connection_settings_provider.dart new file mode 100644 index 0000000..3e58c57 --- /dev/null +++ b/app/lib/ui/providers/connection_settings_provider.dart @@ -0,0 +1,12 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'database_provider.dart'; + +/// Verbindungseinstellungen (Doku Kommunikationsschicht Abschnitt 7): +/// gemerkte Bluetooth-Geraete-MAC und "beim Wechsel in Fly automatisch +/// verbinden". Eigener Provider statt Wiederverwendung von +/// [appSettingsRepositoryProvider] direkt, damit die Einstellungs-UI nach +/// einem Schreibzugriff gezielt neu laden kann (`ref.invalidate`). +final connectionSettingsProvider = FutureProvider((ref) { + return ref.watch(appSettingsRepositoryProvider).load(); +}); diff --git a/app/lib/ui/screens/settings/settings_screen.dart b/app/lib/ui/screens/settings/settings_screen.dart index e7716ff..c40e209 100644 --- a/app/lib/ui/screens/settings/settings_screen.dart +++ b/app/lib/ui/screens/settings/settings_screen.dart @@ -1,15 +1,277 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; -/// Drohnen-Profile: FC-Typ, Flugleistungsdaten, Standardprofil T1 Ranger -/// (Architektur-Doku 3.5). +import '../../../transport/bluetooth/bluetooth_device_info.dart'; +import '../../../transport/bluetooth/bluetooth_system_settings.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'; + +/// Einstellungen: Drohnen-Profile (Doku 3.5/7.3, noch nicht hier +/// angebunden - siehe DroneProfileEditor) und Verbindung (Doku +/// Kommunikationsschicht Abschnitt 7). class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Drone Profiles')), - body: const Center(child: Text('Settings')), + appBar: AppBar(title: const Text('Settings')), + body: ListView( + padding: const EdgeInsets.all(16), + children: const [_ConnectionSection()], + ), ); } } + +class _ConnectionSection extends ConsumerWidget { + const _ConnectionSection(); + + @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 Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('Verbindung', style: Theme.of(context).textTheme.titleMedium), + const Spacer(), + _StatePill(state: linkState), + ], + ), + const SizedBox(height: 12), + const Text('Art: Bluetooth'), + const SizedBox(height: 4), + const Text( + 'WLAN und USB folgen spaeter (Doku Kommunikationsschicht 6/7).', + 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('Verbinden'), + ), + const SizedBox(width: 8), + OutlinedButton( + onPressed: () => ref.read(bluetoothTransportProvider).disconnect(), + child: const Text('Trennen'), + ), + const SizedBox(width: 8), + TextButton( + onPressed: BluetoothSystemSettings.openBluetoothSettings, + child: const Text('Geraet koppeln'), + ), + ], + ), + const SizedBox(height: 16), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Beim Wechsel in Fly automatisch verbinden'), + value: connectionSettings?.connectionAutoConnect ?? true, + onChanged: (value) async { + await ref + .read(appSettingsRepositoryProvider) + .setConnectionAutoConnect(value); + ref.invalidate(connectionSettingsProvider); + }, + ), + const SizedBox(height: 8), + Text('Gekoppelte Geraete', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 4), + bondedDevices.when( + data: (devices) => devices.isEmpty + ? const Text( + 'Keine gekoppelten Geraete. Ueber "Geraet koppeln" ' + 'zunaechst einmalig ueber den Systemdialog koppeln.', + 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( + 'Gekoppelte Geraete konnten nicht gelesen werden: $e', + style: const TextStyle(fontSize: 12, color: Colors.grey), + ), + ), + const SizedBox(height: 16), + Text( + 'Verbindungsprotokoll ($packetCount Datenpakete empfangen)', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 4), + SizedBox( + height: 120, + child: log.isEmpty + ? const Text( + 'Noch keine Ereignisse.', + 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 { + await ref.read(bluetoothTransportProvider).connect(); + } on LinkConnectException { + // Grund/Nachricht sind bereits ueber lastErrorReason/state gemeldet + // (siehe LinkConnectException-Doku) - die Oberflaeche reagiert darauf + // ueber linkErrorReasonProvider/_ErrorHint, hier ist nichts weiter zu tun. + } + } + + String _formatTime(DateTime t) => + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}:${t.second.toString().padLeft(2, '0')}'; + + Future _selectDevice( + WidgetRef ref, + String? address, + List devices, + ) async { + if (address == null) return; + await ref + .read(appSettingsRepositoryProvider) + .setConnectionDeviceAddress(address); + ref.invalidate(connectionSettingsProvider); + try { + await ref.read(bluetoothTransportProvider).connect(); + } on LinkConnectException { + // siehe _connect() oben + } + } +} + +class _StatePill extends StatelessWidget { + const _StatePill({required this.state}); + + final LinkState? state; + + @override + Widget build(BuildContext context) { + final (label, color) = switch (state) { + LinkState.connected => ('Verbunden', DmcColors.liveGreen), + LinkState.connecting => ('Verbinde ...', DmcColors.pafAmber), + LinkState.error => ('Fehler', DmcColors.warnRed), + LinkState.disconnected || null => ('Getrennt', Colors.grey), + }; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color), + ), + child: Text(label, style: TextStyle(color: color, fontSize: 12)), + ); + } +} + +class _ErrorHint extends StatelessWidget { + const _ErrorHint({required this.reason}); + + final LinkErrorReason reason; + + @override + Widget build(BuildContext context) { + final (text, action) = switch (reason) { + LinkErrorReason.bluetoothOff => ( + 'Bluetooth ist aus. Bitte in den Systemeinstellungen einschalten.', + _HintAction('Einstellungen', BluetoothSystemSettings.openBluetoothSettings), + ), + LinkErrorReason.permissionDenied => ( + '"Geraete in der Naehe"-Berechtigung wird benoetigt. Bitte erneut ' + 'auf "Verbinden" tippen.', + null, + ), + LinkErrorReason.permissionDeniedPermanently => ( + '"Geraete in der Naehe"-Berechtigung wurde dauerhaft abgelehnt. ' + 'Bitte in den App-Einstellungen manuell erteilen.', + _HintAction('App-Einstellungen', BluetoothSystemSettings.openAppSettings), + ), + LinkErrorReason.deviceSelectionRequired => ( + 'Kein gekoppeltes mLRS-Geraet gefunden. Bitte unten ein Geraet ' + 'auswaehlen.', + null, + ), + LinkErrorReason.connectionFailed => ( + 'Verbindung zum Geraet ist fehlgeschlagen.', + null, + ), + LinkErrorReason.unknown => ('Unbekannter Verbindungsfehler.', null), + }; + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: DmcColors.warnRed.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Expanded(child: Text(text, style: const TextStyle(fontSize: 12))), + if (action != null) + TextButton(onPressed: action.onTap, child: Text(action.label)), + ], + ), + ); + } +} + +class _HintAction { + const _HintAction(this.label, this.onTap); + final String label; + final Future Function() onTap; +} diff --git a/app/pubspec.lock b/app/pubspec.lock index 8a07021..e7fb8f0 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "12.1.0" + app_settings: + dependency: "direct main" + description: + name: app_settings + sha256: "5e7d6e5b60e90c40b3409d6fb33d1844497be3a2e959935da8b77bc437221db8" + url: "https://pub.dev" + source: hosted + version: "8.0.3" archive: dependency: transitive description: @@ -318,6 +326,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.1.1" + flutter_classic_bluetooth: + dependency: "direct main" + description: + name: flutter_classic_bluetooth + sha256: "6343b5d0e0bd07fffc72ed7758170c74a1356a04177ca45856d332b58783f909" + url: "https://pub.dev" + source: hosted + version: "0.1.6" flutter_launcher_icons: dependency: "direct dev" description: @@ -672,6 +688,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: fe54465bcc62a4564c6e4db337bbaded6c0c0fa6e10487414436d163114784f6 + url: "https://pub.dev" + source: hosted + version: "12.0.3" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: "79dfa1df734798aa3cfdad166d3a3698c206d8813de13516ea1071b5d7e2f420" + url: "https://pub.dev" + source: hosted + version: "9.4.10" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" petitparser: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 45d042e..cb62789 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -55,6 +55,9 @@ dependencies: url_launcher: ^6.3.2 share_plus: ^13.3.0 file_picker: ">=12.0.0-beta.1 <13.0.0" + flutter_classic_bluetooth: ^0.1.6 + permission_handler: ^12.0.3 + app_settings: ^8.0.3 dev_dependencies: flutter_test: diff --git a/app/test/services/app_settings_repository_test.dart b/app/test/services/app_settings_repository_test.dart index 3d389da..250036d 100644 --- a/app/test/services/app_settings_repository_test.dart +++ b/app/test/services/app_settings_repository_test.dart @@ -49,4 +49,30 @@ void main() { final settings = await repository.load(); expect(settings.currentMissionId, isNull); }); + + test('Verbindungseinstellungen: Standard ist kein Geraet, Autoconnect an', + () async { + final settings = await repository.load(); + expect(settings.connectionDeviceAddress, isNull); + expect(settings.connectionAutoConnect, isTrue); + }); + + test('setConnectionDeviceAddress merkt sich die MAC-Adresse', () async { + await repository.setConnectionDeviceAddress('AA:BB:CC:DD:EE:FF'); + + expect( + await repository.loadConnectionDeviceAddress(), + 'AA:BB:CC:DD:EE:FF', + ); + }); + + test('setConnectionAutoConnect(false) bleibt unabhaengig von der MAC-Adresse', + () async { + await repository.setConnectionDeviceAddress('AA:BB:CC:DD:EE:FF'); + await repository.setConnectionAutoConnect(false); + + final settings = await repository.load(); + expect(settings.connectionDeviceAddress, 'AA:BB:CC:DD:EE:FF'); + expect(settings.connectionAutoConnect, isFalse); + }); } diff --git a/app/test/transport/bluetooth_device_selector_test.dart b/app/test/transport/bluetooth_device_selector_test.dart new file mode 100644 index 0000000..6bcd2e2 --- /dev/null +++ b/app/test/transport/bluetooth_device_selector_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:dmc_app/transport/bluetooth/bluetooth_device_info.dart'; +import 'package:dmc_app/transport/bluetooth/bluetooth_device_selector.dart'; + +void main() { + const remembered = BluetoothDeviceInfo(address: '11:11:11:11:11:11', name: 'Alt-Geraet'); + const mLrs = BluetoothDeviceInfo(address: '22:22:22:22:22:22', name: 'mLRS-a1b2'); + const other = BluetoothDeviceInfo(address: '33:33:33:33:33:33', name: 'Kopfhoerer'); + + test('gemerkte MAC-Adresse hat Vorrang vor mLRS-Praefix (Doku 3.2a)', () { + final result = selectBluetoothDevice( + bondedDevices: [other, mLrs, remembered], + rememberedAddress: remembered.address, + ); + + expect(result, isA()); + expect((result as BluetoothDeviceResolved).device, remembered); + }); + + test( + 'ohne (noch gueltige) gemerkte Adresse gewinnt das erste Geraet mit ' + 'mLRS-Praefix (Doku 3.2b)', () { + final result = selectBluetoothDevice( + bondedDevices: [other, mLrs], + rememberedAddress: null, + ); + + expect(result, isA()); + expect((result as BluetoothDeviceResolved).device, mLrs); + }); + + test( + 'gemerkte Adresse ist nicht mehr gekoppelt -> faellt auf mLRS-Praefix ' + 'zurueck statt zu scheitern', () { + final result = selectBluetoothDevice( + bondedDevices: [other, mLrs], + rememberedAddress: '99:99:99:99:99:99', + ); + + expect(result, isA()); + expect((result as BluetoothDeviceResolved).device, mLrs); + }); + + test( + 'weder gemerkte Adresse noch mLRS-Praefix -> Auswahlliste noetig ' + '(Doku 3.2c)', () { + final result = selectBluetoothDevice( + bondedDevices: [other], + rememberedAddress: null, + ); + + expect(result, isA()); + expect( + (result as BluetoothDeviceSelectionRequired).candidates, + [other], + ); + }); + + test('keine gekoppelten Geraete -> Auswahlliste ist leer, kein Fehler', () { + final result = selectBluetoothDevice( + bondedDevices: const [], + rememberedAddress: null, + ); + + expect(result, isA()); + expect((result as BluetoothDeviceSelectionRequired).candidates, isEmpty); + }); + + test('Praefix-Pruefung ist case-sensitiv wie in der Doku spezifiziert', () { + const lowerCase = BluetoothDeviceInfo( + address: '44:44:44:44:44:44', + name: 'mlrs-nicht-erkannt', + ); + + final result = selectBluetoothDevice( + bondedDevices: [lowerCase], + rememberedAddress: null, + ); + + expect(result, isA()); + }); +} diff --git a/app/test/transport/loopback_transport_test.dart b/app/test/transport/loopback_transport_test.dart new file mode 100644 index 0000000..c60375c --- /dev/null +++ b/app/test/transport/loopback_transport_test.dart @@ -0,0 +1,70 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:dmc_app/transport/link_transport.dart'; +import 'package:dmc_app/transport/loopback/loopback_transport.dart'; + +void main() { + test('connect() durchlaeuft connecting -> connected', () async { + final transport = LoopbackTransport(); + final states = []; + transport.state.listen(states.add); + + await transport.connect(); + await Future.delayed(Duration.zero); + + expect(states, [LinkState.connecting, LinkState.connected]); + transport.dispose(); + }); + + test('feed() eingespeiste Bytes kommen ueber incoming an', () async { + final transport = LoopbackTransport(); + await transport.connect(); + + final received = []; + transport.incoming.listen(received.add); + + transport.feed(Uint8List.fromList([1, 2, 3])); + await Future.delayed(Duration.zero); + + expect(received, [ + Uint8List.fromList([1, 2, 3]), + ]); + transport.dispose(); + }); + + test('send() sammelt gesendete Bytes in sentData', () async { + final transport = LoopbackTransport(); + await transport.connect(); + + await transport.send(Uint8List.fromList([9, 9])); + + expect(transport.sentData, [ + Uint8List.fromList([9, 9]), + ]); + transport.dispose(); + }); + + test('simulateError() meldet Grund/Nachricht und LinkState.error', () async { + final transport = LoopbackTransport(); + await transport.connect(); + + transport.simulateError(LinkErrorReason.connectionFailed, 'Testfehler'); + + expect(transport.lastErrorReason, LinkErrorReason.connectionFailed); + expect(transport.lastErrorMessage, 'Testfehler'); + expect(transport.currentState, LinkState.error); + transport.dispose(); + }); + + test('disconnect() setzt LinkState.disconnected', () async { + final transport = LoopbackTransport(); + await transport.connect(); + + await transport.disconnect(); + + expect(transport.currentState, LinkState.disconnected); + transport.dispose(); + }); +}