bluetooth connectivity layer added
This commit is contained in:
@@ -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<Uint8List> get incoming;
|
||||||
|
Stream<LinkState> get state;
|
||||||
|
Future<void> send(Uint8List data);
|
||||||
|
Future<void> connect();
|
||||||
|
Future<void> 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: ^<aktuell> # RFCOMM/SPP, gepflegt
|
||||||
|
permission_handler: ^<aktuell>
|
||||||
|
```
|
||||||
|
|
||||||
|
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> <flag:1> <function:2 LE> <payloadSize:2 LE> <payload> <crc:1>
|
||||||
|
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.
|
||||||
@@ -43,14 +43,24 @@ class DroneProfiles extends Table {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Einzeilige Einstellungstabelle (id fest 0) fuer app-weite Auswahl, die
|
/// 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
|
/// 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 {
|
class AppSettingsTable extends Table {
|
||||||
IntColumn get id => integer()();
|
IntColumn get id => integer()();
|
||||||
IntColumn get activeDroneProfileId => integer().nullable()();
|
IntColumn get activeDroneProfileId => integer().nullable()();
|
||||||
IntColumn get currentMissionId => 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
|
@override
|
||||||
Set<Column> get primaryKey => {id};
|
Set<Column> get primaryKey => {id};
|
||||||
}
|
}
|
||||||
@@ -62,7 +72,7 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
AppDatabase.forTesting(super.executor);
|
AppDatabase.forTesting(super.executor);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 2;
|
int get schemaVersion => 3;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
@@ -73,6 +83,16 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
await m.createTable(droneProfiles);
|
await m.createTable(droneProfiles);
|
||||||
await m.createTable(appSettingsTable);
|
await m.createTable(appSettingsTable);
|
||||||
}
|
}
|
||||||
|
if (from < 3) {
|
||||||
|
await m.addColumn(
|
||||||
|
appSettingsTable,
|
||||||
|
appSettingsTable.connectionDeviceAddress,
|
||||||
|
);
|
||||||
|
await m.addColumn(
|
||||||
|
appSettingsTable,
|
||||||
|
appSettingsTable.connectionAutoConnect,
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1322,11 +1322,39 @@ class $AppSettingsTableTable extends AppSettingsTable
|
|||||||
type: DriftSqlType.int,
|
type: DriftSqlType.int,
|
||||||
requiredDuringInsert: false,
|
requiredDuringInsert: false,
|
||||||
);
|
);
|
||||||
|
static const VerificationMeta _connectionDeviceAddressMeta =
|
||||||
|
const VerificationMeta('connectionDeviceAddress');
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<String> connectionDeviceAddress =
|
||||||
|
GeneratedColumn<String>(
|
||||||
|
'connection_device_address',
|
||||||
|
aliasedName,
|
||||||
|
true,
|
||||||
|
type: DriftSqlType.string,
|
||||||
|
requiredDuringInsert: false,
|
||||||
|
);
|
||||||
|
static const VerificationMeta _connectionAutoConnectMeta =
|
||||||
|
const VerificationMeta('connectionAutoConnect');
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<bool> connectionAutoConnect =
|
||||||
|
GeneratedColumn<bool>(
|
||||||
|
'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
|
@override
|
||||||
List<GeneratedColumn> get $columns => [
|
List<GeneratedColumn> get $columns => [
|
||||||
id,
|
id,
|
||||||
activeDroneProfileId,
|
activeDroneProfileId,
|
||||||
currentMissionId,
|
currentMissionId,
|
||||||
|
connectionDeviceAddress,
|
||||||
|
connectionAutoConnect,
|
||||||
];
|
];
|
||||||
@override
|
@override
|
||||||
String get aliasedName => _alias ?? actualTableName;
|
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;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1382,6 +1428,14 @@ class $AppSettingsTableTable extends AppSettingsTable
|
|||||||
DriftSqlType.int,
|
DriftSqlType.int,
|
||||||
data['${effectivePrefix}current_mission_id'],
|
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 id;
|
||||||
final int? activeDroneProfileId;
|
final int? activeDroneProfileId;
|
||||||
final int? currentMissionId;
|
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({
|
const AppSettingsTableData({
|
||||||
required this.id,
|
required this.id,
|
||||||
this.activeDroneProfileId,
|
this.activeDroneProfileId,
|
||||||
this.currentMissionId,
|
this.currentMissionId,
|
||||||
|
this.connectionDeviceAddress,
|
||||||
|
required this.connectionAutoConnect,
|
||||||
});
|
});
|
||||||
@override
|
@override
|
||||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||||
@@ -1411,6 +1475,12 @@ class AppSettingsTableData extends DataClass
|
|||||||
if (!nullToAbsent || currentMissionId != null) {
|
if (!nullToAbsent || currentMissionId != null) {
|
||||||
map['current_mission_id'] = Variable<int>(currentMissionId);
|
map['current_mission_id'] = Variable<int>(currentMissionId);
|
||||||
}
|
}
|
||||||
|
if (!nullToAbsent || connectionDeviceAddress != null) {
|
||||||
|
map['connection_device_address'] = Variable<String>(
|
||||||
|
connectionDeviceAddress,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
map['connection_auto_connect'] = Variable<bool>(connectionAutoConnect);
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1423,6 +1493,10 @@ class AppSettingsTableData extends DataClass
|
|||||||
currentMissionId: currentMissionId == null && nullToAbsent
|
currentMissionId: currentMissionId == null && nullToAbsent
|
||||||
? const Value.absent()
|
? const Value.absent()
|
||||||
: Value(currentMissionId),
|
: Value(currentMissionId),
|
||||||
|
connectionDeviceAddress: connectionDeviceAddress == null && nullToAbsent
|
||||||
|
? const Value.absent()
|
||||||
|
: Value(connectionDeviceAddress),
|
||||||
|
connectionAutoConnect: Value(connectionAutoConnect),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1437,6 +1511,12 @@ class AppSettingsTableData extends DataClass
|
|||||||
json['activeDroneProfileId'],
|
json['activeDroneProfileId'],
|
||||||
),
|
),
|
||||||
currentMissionId: serializer.fromJson<int?>(json['currentMissionId']),
|
currentMissionId: serializer.fromJson<int?>(json['currentMissionId']),
|
||||||
|
connectionDeviceAddress: serializer.fromJson<String?>(
|
||||||
|
json['connectionDeviceAddress'],
|
||||||
|
),
|
||||||
|
connectionAutoConnect: serializer.fromJson<bool>(
|
||||||
|
json['connectionAutoConnect'],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@override
|
@override
|
||||||
@@ -1446,6 +1526,10 @@ class AppSettingsTableData extends DataClass
|
|||||||
'id': serializer.toJson<int>(id),
|
'id': serializer.toJson<int>(id),
|
||||||
'activeDroneProfileId': serializer.toJson<int?>(activeDroneProfileId),
|
'activeDroneProfileId': serializer.toJson<int?>(activeDroneProfileId),
|
||||||
'currentMissionId': serializer.toJson<int?>(currentMissionId),
|
'currentMissionId': serializer.toJson<int?>(currentMissionId),
|
||||||
|
'connectionDeviceAddress': serializer.toJson<String?>(
|
||||||
|
connectionDeviceAddress,
|
||||||
|
),
|
||||||
|
'connectionAutoConnect': serializer.toJson<bool>(connectionAutoConnect),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1453,6 +1537,8 @@ class AppSettingsTableData extends DataClass
|
|||||||
int? id,
|
int? id,
|
||||||
Value<int?> activeDroneProfileId = const Value.absent(),
|
Value<int?> activeDroneProfileId = const Value.absent(),
|
||||||
Value<int?> currentMissionId = const Value.absent(),
|
Value<int?> currentMissionId = const Value.absent(),
|
||||||
|
Value<String?> connectionDeviceAddress = const Value.absent(),
|
||||||
|
bool? connectionAutoConnect,
|
||||||
}) => AppSettingsTableData(
|
}) => AppSettingsTableData(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
activeDroneProfileId: activeDroneProfileId.present
|
activeDroneProfileId: activeDroneProfileId.present
|
||||||
@@ -1461,6 +1547,10 @@ class AppSettingsTableData extends DataClass
|
|||||||
currentMissionId: currentMissionId.present
|
currentMissionId: currentMissionId.present
|
||||||
? currentMissionId.value
|
? currentMissionId.value
|
||||||
: this.currentMissionId,
|
: this.currentMissionId,
|
||||||
|
connectionDeviceAddress: connectionDeviceAddress.present
|
||||||
|
? connectionDeviceAddress.value
|
||||||
|
: this.connectionDeviceAddress,
|
||||||
|
connectionAutoConnect: connectionAutoConnect ?? this.connectionAutoConnect,
|
||||||
);
|
);
|
||||||
AppSettingsTableData copyWithCompanion(AppSettingsTableCompanion data) {
|
AppSettingsTableData copyWithCompanion(AppSettingsTableCompanion data) {
|
||||||
return AppSettingsTableData(
|
return AppSettingsTableData(
|
||||||
@@ -1471,6 +1561,12 @@ class AppSettingsTableData extends DataClass
|
|||||||
currentMissionId: data.currentMissionId.present
|
currentMissionId: data.currentMissionId.present
|
||||||
? data.currentMissionId.value
|
? data.currentMissionId.value
|
||||||
: this.currentMissionId,
|
: 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(')
|
return (StringBuffer('AppSettingsTableData(')
|
||||||
..write('id: $id, ')
|
..write('id: $id, ')
|
||||||
..write('activeDroneProfileId: $activeDroneProfileId, ')
|
..write('activeDroneProfileId: $activeDroneProfileId, ')
|
||||||
..write('currentMissionId: $currentMissionId')
|
..write('currentMissionId: $currentMissionId, ')
|
||||||
|
..write('connectionDeviceAddress: $connectionDeviceAddress, ')
|
||||||
|
..write('connectionAutoConnect: $connectionAutoConnect')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(id, activeDroneProfileId, currentMissionId);
|
int get hashCode => Object.hash(
|
||||||
|
id,
|
||||||
|
activeDroneProfileId,
|
||||||
|
currentMissionId,
|
||||||
|
connectionDeviceAddress,
|
||||||
|
connectionAutoConnect,
|
||||||
|
);
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
identical(this, other) ||
|
identical(this, other) ||
|
||||||
(other is AppSettingsTableData &&
|
(other is AppSettingsTableData &&
|
||||||
other.id == this.id &&
|
other.id == this.id &&
|
||||||
other.activeDroneProfileId == this.activeDroneProfileId &&
|
other.activeDroneProfileId == this.activeDroneProfileId &&
|
||||||
other.currentMissionId == this.currentMissionId);
|
other.currentMissionId == this.currentMissionId &&
|
||||||
|
other.connectionDeviceAddress == this.connectionDeviceAddress &&
|
||||||
|
other.connectionAutoConnect == this.connectionAutoConnect);
|
||||||
}
|
}
|
||||||
|
|
||||||
class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
|
class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
|
||||||
final Value<int> id;
|
final Value<int> id;
|
||||||
final Value<int?> activeDroneProfileId;
|
final Value<int?> activeDroneProfileId;
|
||||||
final Value<int?> currentMissionId;
|
final Value<int?> currentMissionId;
|
||||||
|
final Value<String?> connectionDeviceAddress;
|
||||||
|
final Value<bool> connectionAutoConnect;
|
||||||
const AppSettingsTableCompanion({
|
const AppSettingsTableCompanion({
|
||||||
this.id = const Value.absent(),
|
this.id = const Value.absent(),
|
||||||
this.activeDroneProfileId = const Value.absent(),
|
this.activeDroneProfileId = const Value.absent(),
|
||||||
this.currentMissionId = const Value.absent(),
|
this.currentMissionId = const Value.absent(),
|
||||||
|
this.connectionDeviceAddress = const Value.absent(),
|
||||||
|
this.connectionAutoConnect = const Value.absent(),
|
||||||
});
|
});
|
||||||
AppSettingsTableCompanion.insert({
|
AppSettingsTableCompanion.insert({
|
||||||
this.id = const Value.absent(),
|
this.id = const Value.absent(),
|
||||||
this.activeDroneProfileId = const Value.absent(),
|
this.activeDroneProfileId = const Value.absent(),
|
||||||
this.currentMissionId = const Value.absent(),
|
this.currentMissionId = const Value.absent(),
|
||||||
|
this.connectionDeviceAddress = const Value.absent(),
|
||||||
|
this.connectionAutoConnect = const Value.absent(),
|
||||||
});
|
});
|
||||||
static Insertable<AppSettingsTableData> custom({
|
static Insertable<AppSettingsTableData> custom({
|
||||||
Expression<int>? id,
|
Expression<int>? id,
|
||||||
Expression<int>? activeDroneProfileId,
|
Expression<int>? activeDroneProfileId,
|
||||||
Expression<int>? currentMissionId,
|
Expression<int>? currentMissionId,
|
||||||
|
Expression<String>? connectionDeviceAddress,
|
||||||
|
Expression<bool>? connectionAutoConnect,
|
||||||
}) {
|
}) {
|
||||||
return RawValuesInsertable({
|
return RawValuesInsertable({
|
||||||
if (id != null) 'id': id,
|
if (id != null) 'id': id,
|
||||||
if (activeDroneProfileId != null)
|
if (activeDroneProfileId != null)
|
||||||
'active_drone_profile_id': activeDroneProfileId,
|
'active_drone_profile_id': activeDroneProfileId,
|
||||||
if (currentMissionId != null) 'current_mission_id': currentMissionId,
|
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<AppSettingsTableData> {
|
|||||||
Value<int>? id,
|
Value<int>? id,
|
||||||
Value<int?>? activeDroneProfileId,
|
Value<int?>? activeDroneProfileId,
|
||||||
Value<int?>? currentMissionId,
|
Value<int?>? currentMissionId,
|
||||||
|
Value<String?>? connectionDeviceAddress,
|
||||||
|
Value<bool>? connectionAutoConnect,
|
||||||
}) {
|
}) {
|
||||||
return AppSettingsTableCompanion(
|
return AppSettingsTableCompanion(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
activeDroneProfileId: activeDroneProfileId ?? this.activeDroneProfileId,
|
activeDroneProfileId: activeDroneProfileId ?? this.activeDroneProfileId,
|
||||||
currentMissionId: currentMissionId ?? this.currentMissionId,
|
currentMissionId: currentMissionId ?? this.currentMissionId,
|
||||||
|
connectionDeviceAddress:
|
||||||
|
connectionDeviceAddress ?? this.connectionDeviceAddress,
|
||||||
|
connectionAutoConnect:
|
||||||
|
connectionAutoConnect ?? this.connectionAutoConnect,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1548,6 +1672,16 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
|
|||||||
if (currentMissionId.present) {
|
if (currentMissionId.present) {
|
||||||
map['current_mission_id'] = Variable<int>(currentMissionId.value);
|
map['current_mission_id'] = Variable<int>(currentMissionId.value);
|
||||||
}
|
}
|
||||||
|
if (connectionDeviceAddress.present) {
|
||||||
|
map['connection_device_address'] = Variable<String>(
|
||||||
|
connectionDeviceAddress.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (connectionAutoConnect.present) {
|
||||||
|
map['connection_auto_connect'] = Variable<bool>(
|
||||||
|
connectionAutoConnect.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1556,7 +1690,9 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
|
|||||||
return (StringBuffer('AppSettingsTableCompanion(')
|
return (StringBuffer('AppSettingsTableCompanion(')
|
||||||
..write('id: $id, ')
|
..write('id: $id, ')
|
||||||
..write('activeDroneProfileId: $activeDroneProfileId, ')
|
..write('activeDroneProfileId: $activeDroneProfileId, ')
|
||||||
..write('currentMissionId: $currentMissionId')
|
..write('currentMissionId: $currentMissionId, ')
|
||||||
|
..write('connectionDeviceAddress: $connectionDeviceAddress, ')
|
||||||
|
..write('connectionAutoConnect: $connectionAutoConnect')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
@@ -2195,12 +2331,16 @@ typedef $$AppSettingsTableTableCreateCompanionBuilder =
|
|||||||
Value<int> id,
|
Value<int> id,
|
||||||
Value<int?> activeDroneProfileId,
|
Value<int?> activeDroneProfileId,
|
||||||
Value<int?> currentMissionId,
|
Value<int?> currentMissionId,
|
||||||
|
Value<String?> connectionDeviceAddress,
|
||||||
|
Value<bool> connectionAutoConnect,
|
||||||
});
|
});
|
||||||
typedef $$AppSettingsTableTableUpdateCompanionBuilder =
|
typedef $$AppSettingsTableTableUpdateCompanionBuilder =
|
||||||
AppSettingsTableCompanion Function({
|
AppSettingsTableCompanion Function({
|
||||||
Value<int> id,
|
Value<int> id,
|
||||||
Value<int?> activeDroneProfileId,
|
Value<int?> activeDroneProfileId,
|
||||||
Value<int?> currentMissionId,
|
Value<int?> currentMissionId,
|
||||||
|
Value<String?> connectionDeviceAddress,
|
||||||
|
Value<bool> connectionAutoConnect,
|
||||||
});
|
});
|
||||||
|
|
||||||
class $$AppSettingsTableTableFilterComposer
|
class $$AppSettingsTableTableFilterComposer
|
||||||
@@ -2226,6 +2366,16 @@ class $$AppSettingsTableTableFilterComposer
|
|||||||
column: $table.currentMissionId,
|
column: $table.currentMissionId,
|
||||||
builder: (column) => ColumnFilters(column),
|
builder: (column) => ColumnFilters(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ColumnFilters<String> get connectionDeviceAddress => $composableBuilder(
|
||||||
|
column: $table.connectionDeviceAddress,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnFilters<bool> get connectionAutoConnect => $composableBuilder(
|
||||||
|
column: $table.connectionAutoConnect,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class $$AppSettingsTableTableOrderingComposer
|
class $$AppSettingsTableTableOrderingComposer
|
||||||
@@ -2251,6 +2401,16 @@ class $$AppSettingsTableTableOrderingComposer
|
|||||||
column: $table.currentMissionId,
|
column: $table.currentMissionId,
|
||||||
builder: (column) => ColumnOrderings(column),
|
builder: (column) => ColumnOrderings(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<String> get connectionDeviceAddress => $composableBuilder(
|
||||||
|
column: $table.connectionDeviceAddress,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<bool> get connectionAutoConnect => $composableBuilder(
|
||||||
|
column: $table.connectionAutoConnect,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class $$AppSettingsTableTableAnnotationComposer
|
class $$AppSettingsTableTableAnnotationComposer
|
||||||
@@ -2274,6 +2434,16 @@ class $$AppSettingsTableTableAnnotationComposer
|
|||||||
column: $table.currentMissionId,
|
column: $table.currentMissionId,
|
||||||
builder: (column) => column,
|
builder: (column) => column,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
GeneratedColumn<String> get connectionDeviceAddress => $composableBuilder(
|
||||||
|
column: $table.connectionDeviceAddress,
|
||||||
|
builder: (column) => column,
|
||||||
|
);
|
||||||
|
|
||||||
|
GeneratedColumn<bool> get connectionAutoConnect => $composableBuilder(
|
||||||
|
column: $table.connectionAutoConnect,
|
||||||
|
builder: (column) => column,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class $$AppSettingsTableTableTableManager
|
class $$AppSettingsTableTableTableManager
|
||||||
@@ -2316,20 +2486,28 @@ class $$AppSettingsTableTableTableManager
|
|||||||
Value<int> id = const Value.absent(),
|
Value<int> id = const Value.absent(),
|
||||||
Value<int?> activeDroneProfileId = const Value.absent(),
|
Value<int?> activeDroneProfileId = const Value.absent(),
|
||||||
Value<int?> currentMissionId = const Value.absent(),
|
Value<int?> currentMissionId = const Value.absent(),
|
||||||
|
Value<String?> connectionDeviceAddress = const Value.absent(),
|
||||||
|
Value<bool> connectionAutoConnect = const Value.absent(),
|
||||||
}) => AppSettingsTableCompanion(
|
}) => AppSettingsTableCompanion(
|
||||||
id: id,
|
id: id,
|
||||||
activeDroneProfileId: activeDroneProfileId,
|
activeDroneProfileId: activeDroneProfileId,
|
||||||
currentMissionId: currentMissionId,
|
currentMissionId: currentMissionId,
|
||||||
|
connectionDeviceAddress: connectionDeviceAddress,
|
||||||
|
connectionAutoConnect: connectionAutoConnect,
|
||||||
),
|
),
|
||||||
createCompanionCallback:
|
createCompanionCallback:
|
||||||
({
|
({
|
||||||
Value<int> id = const Value.absent(),
|
Value<int> id = const Value.absent(),
|
||||||
Value<int?> activeDroneProfileId = const Value.absent(),
|
Value<int?> activeDroneProfileId = const Value.absent(),
|
||||||
Value<int?> currentMissionId = const Value.absent(),
|
Value<int?> currentMissionId = const Value.absent(),
|
||||||
|
Value<String?> connectionDeviceAddress = const Value.absent(),
|
||||||
|
Value<bool> connectionAutoConnect = const Value.absent(),
|
||||||
}) => AppSettingsTableCompanion.insert(
|
}) => AppSettingsTableCompanion.insert(
|
||||||
id: id,
|
id: id,
|
||||||
activeDroneProfileId: activeDroneProfileId,
|
activeDroneProfileId: activeDroneProfileId,
|
||||||
currentMissionId: currentMissionId,
|
currentMissionId: currentMissionId,
|
||||||
|
connectionDeviceAddress: connectionDeviceAddress,
|
||||||
|
connectionAutoConnect: connectionAutoConnect,
|
||||||
),
|
),
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
|
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
|
||||||
|
|||||||
@@ -12,13 +12,21 @@ class AppSettingsRepository {
|
|||||||
|
|
||||||
static const _rowId = 0;
|
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)
|
final row = await (_db.select(_db.appSettingsTable)
|
||||||
..where((s) => s.id.equals(_rowId)))
|
..where((s) => s.id.equals(_rowId)))
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
return (
|
return (
|
||||||
activeDroneProfileId: row?.activeDroneProfileId,
|
activeDroneProfileId: row?.activeDroneProfileId,
|
||||||
currentMissionId: row?.currentMissionId,
|
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<String?> loadConnectionDeviceAddress() async =>
|
||||||
|
(await load()).connectionDeviceAddress;
|
||||||
|
|
||||||
|
Future<void> 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<void> setConnectionAutoConnect(bool value) async {
|
||||||
|
await _upsert(AppSettingsTableCompanion(
|
||||||
|
id: const Value(_rowId),
|
||||||
|
connectionAutoConnect: Value(value),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _upsert(AppSettingsTableCompanion companion) async {
|
Future<void> _upsert(AppSettingsTableCompanion companion) async {
|
||||||
await _db.into(_db.appSettingsTable).insertOnConflictUpdate(companion);
|
await _db.into(_db.appSettingsTable).insertOnConflictUpdate(companion);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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<String?> Function() getRememberedAddress,
|
||||||
|
required Future<void> Function(String address) rememberAddress,
|
||||||
|
FlutterClassicBluetooth? bluetooth,
|
||||||
|
}) : _getRememberedAddress = getRememberedAddress,
|
||||||
|
_rememberAddress = rememberAddress,
|
||||||
|
_bluetooth = bluetooth ?? FlutterClassicBluetooth();
|
||||||
|
|
||||||
|
final Future<String?> Function() _getRememberedAddress;
|
||||||
|
final Future<void> Function(String address) _rememberAddress;
|
||||||
|
final FlutterClassicBluetooth _bluetooth;
|
||||||
|
|
||||||
|
final _incomingController = StreamController<Uint8List>.broadcast();
|
||||||
|
final _stateController = StreamController<LinkState>.broadcast();
|
||||||
|
|
||||||
|
BtcReconnectingConnection? _link;
|
||||||
|
StreamSubscription<Uint8List>? _linkDataSub;
|
||||||
|
StreamSubscription<BtcReconnectState>? _linkStateSub;
|
||||||
|
|
||||||
|
BluetoothDeviceInfo? _connectedDevice;
|
||||||
|
LinkState _state = LinkState.disconnected;
|
||||||
|
LinkErrorReason? _lastErrorReason;
|
||||||
|
String? _lastErrorMessage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<Uint8List> get incoming => _incomingController.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<LinkState> get state => _stateController.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
LinkErrorReason? get lastErrorReason => _lastErrorReason;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get lastErrorMessage => _lastErrorMessage;
|
||||||
|
|
||||||
|
/// 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<List<BluetoothDeviceInfo>> listBondedDevices() async {
|
||||||
|
final devices = await _bluetooth.getPairedDevices();
|
||||||
|
return [
|
||||||
|
for (final d in devices)
|
||||||
|
BluetoothDeviceInfo(address: d.address, name: d.name ?? d.alias),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> 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<void> disconnect() async {
|
||||||
|
await _linkDataSub?.cancel();
|
||||||
|
_linkDataSub = null;
|
||||||
|
await _linkStateSub?.cancel();
|
||||||
|
_linkStateSub = null;
|
||||||
|
await _link?.close();
|
||||||
|
_link = null;
|
||||||
|
_setState(LinkState.disconnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> 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 }
|
||||||
@@ -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)';
|
||||||
|
}
|
||||||
@@ -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<BluetoothDeviceInfo> 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<BluetoothDeviceInfo> 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<BluetoothDeviceInfo> devices,
|
||||||
|
bool Function(BluetoothDeviceInfo) test,
|
||||||
|
) {
|
||||||
|
for (final device in devices) {
|
||||||
|
if (test(device)) return device;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -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<void> 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<void> openAppSettings() => ph.openAppSettings();
|
||||||
|
}
|
||||||
@@ -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<Uint8List> get incoming;
|
||||||
|
Stream<LinkState> 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<void> send(Uint8List data);
|
||||||
|
Future<void> connect();
|
||||||
|
Future<void> 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';
|
||||||
|
}
|
||||||
@@ -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<Uint8List>.broadcast();
|
||||||
|
final _stateController = StreamController<LinkState>.broadcast();
|
||||||
|
|
||||||
|
LinkState _state = LinkState.disconnected;
|
||||||
|
LinkErrorReason? _lastErrorReason;
|
||||||
|
String? _lastErrorMessage;
|
||||||
|
|
||||||
|
/// Alle bisher per [send] geschickten Bytes, in Reihenfolge.
|
||||||
|
final List<Uint8List> sentData = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<Uint8List> get incoming => _incomingController.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<LinkState> 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<void> connect() async {
|
||||||
|
_lastErrorReason = null;
|
||||||
|
_lastErrorMessage = null;
|
||||||
|
_setState(LinkState.connecting);
|
||||||
|
_setState(LinkState.connected);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> disconnect() async {
|
||||||
|
_setState(LinkState.disconnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> 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;
|
||||||
|
}
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
import '../../domain/waypoint/flat_waypoint_list.dart';
|
import '../../domain/waypoint/flat_waypoint_list.dart';
|
||||||
import '../flight_controller_link.dart';
|
import '../flight_controller_link.dart';
|
||||||
|
import '../link_transport.dart';
|
||||||
|
|
||||||
/// iNAV-Anbindung via MSP (Architektur-Doku 3.1). Eigenstaendiger Adapter,
|
/// iNAV-Anbindung via MSP (Architektur-Doku 3.1). Eigenstaendiger Adapter,
|
||||||
/// da kein fertiges Dart-Paket fuer MSP verfuegbar ist (Doku 4.19).
|
/// 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
|
/// Protokoll-Implementierung (Framing, MSP_SET_WP, MSP_NAV_STATUS, ...) ist
|
||||||
/// noch offen - dies ist bewusst nur das Geruest fuer die Abstraktion.
|
/// noch offen - dies ist bewusst nur das Geruest fuer die Abstraktion.
|
||||||
class MspFlightControllerLink implements FlightControllerLink {
|
class MspFlightControllerLink implements FlightControllerLink {
|
||||||
MspFlightControllerLink({required this.host, required this.port});
|
MspFlightControllerLink({required this.transport});
|
||||||
|
|
||||||
final String host;
|
final LinkTransport transport;
|
||||||
final int port;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
FcCapabilities get capabilities => throw UnimplementedError();
|
FcCapabilities get capabilities => throw UnimplementedError();
|
||||||
|
|||||||
@@ -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<BluetoothClassicTransport>((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<LinkState>((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<LinkErrorReason?>((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<List<BluetoothDeviceInfo>>((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<List<ConnectionLogEntry>> {
|
||||||
|
static const _maxEntries = 50;
|
||||||
|
|
||||||
|
StreamSubscription<LinkState>? _stateSub;
|
||||||
|
StreamSubscription<void>? _dataSub;
|
||||||
|
int _packetCount = 0;
|
||||||
|
|
||||||
|
int get packetCount => _packetCount;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<ConnectionLogEntry> 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, List<ConnectionLogEntry>>(
|
||||||
|
ConnectionLogNotifier.new);
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -1,15 +1,277 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
/// Drohnen-Profile: FC-Typ, Flugleistungsdaten, Standardprofil T1 Ranger
|
import '../../../transport/bluetooth/bluetooth_device_info.dart';
|
||||||
/// (Architektur-Doku 3.5).
|
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 {
|
class SettingsScreen extends StatelessWidget {
|
||||||
const SettingsScreen({super.key});
|
const SettingsScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Drone Profiles')),
|
appBar: AppBar(title: const Text('Settings')),
|
||||||
body: const Center(child: 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<String>(
|
||||||
|
groupValue: connectionSettings?.connectionDeviceAddress,
|
||||||
|
onChanged: (address) =>
|
||||||
|
_selectDevice(ref, address, devices),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
for (final device in devices)
|
||||||
|
RadioListTile<String>(
|
||||||
|
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<void> _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<void> _selectDevice(
|
||||||
|
WidgetRef ref,
|
||||||
|
String? address,
|
||||||
|
List<BluetoothDeviceInfo> 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<void> Function() onTap;
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "12.1.0"
|
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:
|
archive:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -318,6 +326,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.1.1"
|
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:
|
flutter_launcher_icons:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -672,6 +688,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
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:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ dependencies:
|
|||||||
url_launcher: ^6.3.2
|
url_launcher: ^6.3.2
|
||||||
share_plus: ^13.3.0
|
share_plus: ^13.3.0
|
||||||
file_picker: ">=12.0.0-beta.1 <13.0.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:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -49,4 +49,30 @@ void main() {
|
|||||||
final settings = await repository.load();
|
final settings = await repository.load();
|
||||||
expect(settings.currentMissionId, isNull);
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<BluetoothDeviceResolved>());
|
||||||
|
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<BluetoothDeviceResolved>());
|
||||||
|
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<BluetoothDeviceResolved>());
|
||||||
|
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<BluetoothDeviceSelectionRequired>());
|
||||||
|
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<BluetoothDeviceSelectionRequired>());
|
||||||
|
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<BluetoothDeviceSelectionRequired>());
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 = <LinkState>[];
|
||||||
|
transport.state.listen(states.add);
|
||||||
|
|
||||||
|
await transport.connect();
|
||||||
|
await Future<void>.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 = <Uint8List>[];
|
||||||
|
transport.incoming.listen(received.add);
|
||||||
|
|
||||||
|
transport.feed(Uint8List.fromList([1, 2, 3]));
|
||||||
|
await Future<void>.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();
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user