bluetooth connectivity layer added

This commit is contained in:
Constantin Leue
2026-07-30 19:46:52 +02:00
parent 310d92f187
commit 5994aee7ad
19 changed files with 1665 additions and 15 deletions
+272
View File
@@ -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.
+23 -3
View File
@@ -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,
);
}
}, },
); );
+182 -4
View File
@@ -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();
}
+79
View File
@@ -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;
}
+64
View File
@@ -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:
+3
View File
@@ -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();
});
}