bluetooth connectivity layer added
This commit is contained in:
@@ -43,14 +43,24 @@ class DroneProfiles extends Table {
|
||||
}
|
||||
|
||||
/// Einzeilige Einstellungstabelle (id fest 0) fuer app-weite Auswahl, die
|
||||
/// einen Neustart ueberleben soll: zuletzt aktives Drohnenprofil und die
|
||||
/// einen Neustart ueberleben soll: zuletzt aktives Drohnenprofil, die
|
||||
/// zuletzt bearbeitete (autosaved) Mission, um sie beim naechsten Start
|
||||
/// fortzusetzen (Doku 7.5: Autosave/Mission-Drafts).
|
||||
/// fortzusetzen (Doku 7.5: Autosave/Mission-Drafts), sowie die
|
||||
/// Verbindungseinstellungen (Doku Kommunikationsschicht Abschnitt 7).
|
||||
class AppSettingsTable extends Table {
|
||||
IntColumn get id => integer()();
|
||||
IntColumn get activeDroneProfileId => integer().nullable()();
|
||||
IntColumn get currentMissionId => integer().nullable()();
|
||||
|
||||
/// Gemerkte MAC-Adresse des Bluetooth-Zielgeraets (Doku Kommunikations-
|
||||
/// schicht 3.2a) - entweder manuell gewaehlt oder automatisch anhand des
|
||||
/// "mLRS"-Namenspraefix aufgeloest.
|
||||
TextColumn get connectionDeviceAddress => text().nullable()();
|
||||
|
||||
/// "Beim Wechsel in Fly automatisch verbinden" (Doku 7), Standard an.
|
||||
BoolColumn get connectionAutoConnect =>
|
||||
boolean().withDefault(const Constant(true))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
@@ -62,7 +72,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase.forTesting(super.executor);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 2;
|
||||
int get schemaVersion => 3;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -73,6 +83,16 @@ class AppDatabase extends _$AppDatabase {
|
||||
await m.createTable(droneProfiles);
|
||||
await m.createTable(appSettingsTable);
|
||||
}
|
||||
if (from < 3) {
|
||||
await m.addColumn(
|
||||
appSettingsTable,
|
||||
appSettingsTable.connectionDeviceAddress,
|
||||
);
|
||||
await m.addColumn(
|
||||
appSettingsTable,
|
||||
appSettingsTable.connectionAutoConnect,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1322,11 +1322,39 @@ class $AppSettingsTableTable extends AppSettingsTable
|
||||
type: DriftSqlType.int,
|
||||
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
|
||||
List<GeneratedColumn> get $columns => [
|
||||
id,
|
||||
activeDroneProfileId,
|
||||
currentMissionId,
|
||||
connectionDeviceAddress,
|
||||
connectionAutoConnect,
|
||||
];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@@ -1361,6 +1389,24 @@ class $AppSettingsTableTable extends AppSettingsTable
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('connection_device_address')) {
|
||||
context.handle(
|
||||
_connectionDeviceAddressMeta,
|
||||
connectionDeviceAddress.isAcceptableOrUnknown(
|
||||
data['connection_device_address']!,
|
||||
_connectionDeviceAddressMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('connection_auto_connect')) {
|
||||
context.handle(
|
||||
_connectionAutoConnectMeta,
|
||||
connectionAutoConnect.isAcceptableOrUnknown(
|
||||
data['connection_auto_connect']!,
|
||||
_connectionAutoConnectMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -1382,6 +1428,14 @@ class $AppSettingsTableTable extends AppSettingsTable
|
||||
DriftSqlType.int,
|
||||
data['${effectivePrefix}current_mission_id'],
|
||||
),
|
||||
connectionDeviceAddress: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}connection_device_address'],
|
||||
),
|
||||
connectionAutoConnect: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.bool,
|
||||
data['${effectivePrefix}connection_auto_connect'],
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1396,10 +1450,20 @@ class AppSettingsTableData extends DataClass
|
||||
final int id;
|
||||
final int? activeDroneProfileId;
|
||||
final int? currentMissionId;
|
||||
|
||||
/// Gemerkte MAC-Adresse des Bluetooth-Zielgeraets (Doku Kommunikations-
|
||||
/// schicht 3.2a) - entweder manuell gewaehlt oder automatisch anhand des
|
||||
/// "mLRS"-Namenspraefix aufgeloest.
|
||||
final String? connectionDeviceAddress;
|
||||
|
||||
/// "Beim Wechsel in Fly automatisch verbinden" (Doku 7), Standard an.
|
||||
final bool connectionAutoConnect;
|
||||
const AppSettingsTableData({
|
||||
required this.id,
|
||||
this.activeDroneProfileId,
|
||||
this.currentMissionId,
|
||||
this.connectionDeviceAddress,
|
||||
required this.connectionAutoConnect,
|
||||
});
|
||||
@override
|
||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||
@@ -1411,6 +1475,12 @@ class AppSettingsTableData extends DataClass
|
||||
if (!nullToAbsent || currentMissionId != null) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1423,6 +1493,10 @@ class AppSettingsTableData extends DataClass
|
||||
currentMissionId: currentMissionId == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
: Value(currentMissionId),
|
||||
connectionDeviceAddress: connectionDeviceAddress == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
: Value(connectionDeviceAddress),
|
||||
connectionAutoConnect: Value(connectionAutoConnect),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1437,6 +1511,12 @@ class AppSettingsTableData extends DataClass
|
||||
json['activeDroneProfileId'],
|
||||
),
|
||||
currentMissionId: serializer.fromJson<int?>(json['currentMissionId']),
|
||||
connectionDeviceAddress: serializer.fromJson<String?>(
|
||||
json['connectionDeviceAddress'],
|
||||
),
|
||||
connectionAutoConnect: serializer.fromJson<bool>(
|
||||
json['connectionAutoConnect'],
|
||||
),
|
||||
);
|
||||
}
|
||||
@override
|
||||
@@ -1446,6 +1526,10 @@ class AppSettingsTableData extends DataClass
|
||||
'id': serializer.toJson<int>(id),
|
||||
'activeDroneProfileId': serializer.toJson<int?>(activeDroneProfileId),
|
||||
'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,
|
||||
Value<int?> activeDroneProfileId = const Value.absent(),
|
||||
Value<int?> currentMissionId = const Value.absent(),
|
||||
Value<String?> connectionDeviceAddress = const Value.absent(),
|
||||
bool? connectionAutoConnect,
|
||||
}) => AppSettingsTableData(
|
||||
id: id ?? this.id,
|
||||
activeDroneProfileId: activeDroneProfileId.present
|
||||
@@ -1461,6 +1547,10 @@ class AppSettingsTableData extends DataClass
|
||||
currentMissionId: currentMissionId.present
|
||||
? currentMissionId.value
|
||||
: this.currentMissionId,
|
||||
connectionDeviceAddress: connectionDeviceAddress.present
|
||||
? connectionDeviceAddress.value
|
||||
: this.connectionDeviceAddress,
|
||||
connectionAutoConnect: connectionAutoConnect ?? this.connectionAutoConnect,
|
||||
);
|
||||
AppSettingsTableData copyWithCompanion(AppSettingsTableCompanion data) {
|
||||
return AppSettingsTableData(
|
||||
@@ -1471,6 +1561,12 @@ class AppSettingsTableData extends DataClass
|
||||
currentMissionId: data.currentMissionId.present
|
||||
? data.currentMissionId.value
|
||||
: this.currentMissionId,
|
||||
connectionDeviceAddress: data.connectionDeviceAddress.present
|
||||
? data.connectionDeviceAddress.value
|
||||
: this.connectionDeviceAddress,
|
||||
connectionAutoConnect: data.connectionAutoConnect.present
|
||||
? data.connectionAutoConnect.value
|
||||
: this.connectionAutoConnect,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1479,46 +1575,68 @@ class AppSettingsTableData extends DataClass
|
||||
return (StringBuffer('AppSettingsTableData(')
|
||||
..write('id: $id, ')
|
||||
..write('activeDroneProfileId: $activeDroneProfileId, ')
|
||||
..write('currentMissionId: $currentMissionId')
|
||||
..write('currentMissionId: $currentMissionId, ')
|
||||
..write('connectionDeviceAddress: $connectionDeviceAddress, ')
|
||||
..write('connectionAutoConnect: $connectionAutoConnect')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, activeDroneProfileId, currentMissionId);
|
||||
int get hashCode => Object.hash(
|
||||
id,
|
||||
activeDroneProfileId,
|
||||
currentMissionId,
|
||||
connectionDeviceAddress,
|
||||
connectionAutoConnect,
|
||||
);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is AppSettingsTableData &&
|
||||
other.id == this.id &&
|
||||
other.activeDroneProfileId == this.activeDroneProfileId &&
|
||||
other.currentMissionId == this.currentMissionId);
|
||||
other.currentMissionId == this.currentMissionId &&
|
||||
other.connectionDeviceAddress == this.connectionDeviceAddress &&
|
||||
other.connectionAutoConnect == this.connectionAutoConnect);
|
||||
}
|
||||
|
||||
class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
|
||||
final Value<int> id;
|
||||
final Value<int?> activeDroneProfileId;
|
||||
final Value<int?> currentMissionId;
|
||||
final Value<String?> connectionDeviceAddress;
|
||||
final Value<bool> connectionAutoConnect;
|
||||
const AppSettingsTableCompanion({
|
||||
this.id = const Value.absent(),
|
||||
this.activeDroneProfileId = const Value.absent(),
|
||||
this.currentMissionId = const Value.absent(),
|
||||
this.connectionDeviceAddress = const Value.absent(),
|
||||
this.connectionAutoConnect = const Value.absent(),
|
||||
});
|
||||
AppSettingsTableCompanion.insert({
|
||||
this.id = const Value.absent(),
|
||||
this.activeDroneProfileId = const Value.absent(),
|
||||
this.currentMissionId = const Value.absent(),
|
||||
this.connectionDeviceAddress = const Value.absent(),
|
||||
this.connectionAutoConnect = const Value.absent(),
|
||||
});
|
||||
static Insertable<AppSettingsTableData> custom({
|
||||
Expression<int>? id,
|
||||
Expression<int>? activeDroneProfileId,
|
||||
Expression<int>? currentMissionId,
|
||||
Expression<String>? connectionDeviceAddress,
|
||||
Expression<bool>? connectionAutoConnect,
|
||||
}) {
|
||||
return RawValuesInsertable({
|
||||
if (id != null) 'id': id,
|
||||
if (activeDroneProfileId != null)
|
||||
'active_drone_profile_id': activeDroneProfileId,
|
||||
if (currentMissionId != null) 'current_mission_id': currentMissionId,
|
||||
if (connectionDeviceAddress != null)
|
||||
'connection_device_address': connectionDeviceAddress,
|
||||
if (connectionAutoConnect != null)
|
||||
'connection_auto_connect': connectionAutoConnect,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1526,11 +1644,17 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
|
||||
Value<int>? id,
|
||||
Value<int?>? activeDroneProfileId,
|
||||
Value<int?>? currentMissionId,
|
||||
Value<String?>? connectionDeviceAddress,
|
||||
Value<bool>? connectionAutoConnect,
|
||||
}) {
|
||||
return AppSettingsTableCompanion(
|
||||
id: id ?? this.id,
|
||||
activeDroneProfileId: activeDroneProfileId ?? this.activeDroneProfileId,
|
||||
currentMissionId: currentMissionId ?? this.currentMissionId,
|
||||
connectionDeviceAddress:
|
||||
connectionDeviceAddress ?? this.connectionDeviceAddress,
|
||||
connectionAutoConnect:
|
||||
connectionAutoConnect ?? this.connectionAutoConnect,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1548,6 +1672,16 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
|
||||
if (currentMissionId.present) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1556,7 +1690,9 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
|
||||
return (StringBuffer('AppSettingsTableCompanion(')
|
||||
..write('id: $id, ')
|
||||
..write('activeDroneProfileId: $activeDroneProfileId, ')
|
||||
..write('currentMissionId: $currentMissionId')
|
||||
..write('currentMissionId: $currentMissionId, ')
|
||||
..write('connectionDeviceAddress: $connectionDeviceAddress, ')
|
||||
..write('connectionAutoConnect: $connectionAutoConnect')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
@@ -2195,12 +2331,16 @@ typedef $$AppSettingsTableTableCreateCompanionBuilder =
|
||||
Value<int> id,
|
||||
Value<int?> activeDroneProfileId,
|
||||
Value<int?> currentMissionId,
|
||||
Value<String?> connectionDeviceAddress,
|
||||
Value<bool> connectionAutoConnect,
|
||||
});
|
||||
typedef $$AppSettingsTableTableUpdateCompanionBuilder =
|
||||
AppSettingsTableCompanion Function({
|
||||
Value<int> id,
|
||||
Value<int?> activeDroneProfileId,
|
||||
Value<int?> currentMissionId,
|
||||
Value<String?> connectionDeviceAddress,
|
||||
Value<bool> connectionAutoConnect,
|
||||
});
|
||||
|
||||
class $$AppSettingsTableTableFilterComposer
|
||||
@@ -2226,6 +2366,16 @@ class $$AppSettingsTableTableFilterComposer
|
||||
column: $table.currentMissionId,
|
||||
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
|
||||
@@ -2251,6 +2401,16 @@ class $$AppSettingsTableTableOrderingComposer
|
||||
column: $table.currentMissionId,
|
||||
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
|
||||
@@ -2274,6 +2434,16 @@ class $$AppSettingsTableTableAnnotationComposer
|
||||
column: $table.currentMissionId,
|
||||
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
|
||||
@@ -2316,20 +2486,28 @@ class $$AppSettingsTableTableTableManager
|
||||
Value<int> id = const Value.absent(),
|
||||
Value<int?> activeDroneProfileId = const Value.absent(),
|
||||
Value<int?> currentMissionId = const Value.absent(),
|
||||
Value<String?> connectionDeviceAddress = const Value.absent(),
|
||||
Value<bool> connectionAutoConnect = const Value.absent(),
|
||||
}) => AppSettingsTableCompanion(
|
||||
id: id,
|
||||
activeDroneProfileId: activeDroneProfileId,
|
||||
currentMissionId: currentMissionId,
|
||||
connectionDeviceAddress: connectionDeviceAddress,
|
||||
connectionAutoConnect: connectionAutoConnect,
|
||||
),
|
||||
createCompanionCallback:
|
||||
({
|
||||
Value<int> id = const Value.absent(),
|
||||
Value<int?> activeDroneProfileId = const Value.absent(),
|
||||
Value<int?> currentMissionId = const Value.absent(),
|
||||
Value<String?> connectionDeviceAddress = const Value.absent(),
|
||||
Value<bool> connectionAutoConnect = const Value.absent(),
|
||||
}) => AppSettingsTableCompanion.insert(
|
||||
id: id,
|
||||
activeDroneProfileId: activeDroneProfileId,
|
||||
currentMissionId: currentMissionId,
|
||||
connectionDeviceAddress: connectionDeviceAddress,
|
||||
connectionAutoConnect: connectionAutoConnect,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
|
||||
|
||||
@@ -12,13 +12,21 @@ class AppSettingsRepository {
|
||||
|
||||
static const _rowId = 0;
|
||||
|
||||
Future<({int? activeDroneProfileId, int? currentMissionId})> load() async {
|
||||
Future<
|
||||
({
|
||||
int? activeDroneProfileId,
|
||||
int? currentMissionId,
|
||||
String? connectionDeviceAddress,
|
||||
bool connectionAutoConnect,
|
||||
})> load() async {
|
||||
final row = await (_db.select(_db.appSettingsTable)
|
||||
..where((s) => s.id.equals(_rowId)))
|
||||
.getSingleOrNull();
|
||||
return (
|
||||
activeDroneProfileId: row?.activeDroneProfileId,
|
||||
currentMissionId: row?.currentMissionId,
|
||||
connectionDeviceAddress: row?.connectionDeviceAddress,
|
||||
connectionAutoConnect: row?.connectionAutoConnect ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,6 +44,26 @@ class AppSettingsRepository {
|
||||
));
|
||||
}
|
||||
|
||||
/// Gemerkte Bluetooth-MAC-Adresse (Doku Kommunikationsschicht 3.2a), oder
|
||||
/// null um die Erinnerung zu loeschen (z.B. nach "Geraet entkoppeln").
|
||||
Future<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 {
|
||||
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 '../flight_controller_link.dart';
|
||||
import '../link_transport.dart';
|
||||
|
||||
/// iNAV-Anbindung via MSP (Architektur-Doku 3.1). Eigenstaendiger Adapter,
|
||||
/// da kein fertiges Dart-Paket fuer MSP verfuegbar ist (Doku 4.19).
|
||||
///
|
||||
/// Kennt nur die protokollneutrale [LinkTransport]-Abstraktion (Doku
|
||||
/// Kommunikationsschicht Abschnitt 2) - ob darunter Bluetooth Classic (MVP)
|
||||
/// oder spaeter WLAN/UDP steckt, ist fuer diese Klasse unsichtbar.
|
||||
///
|
||||
/// Protokoll-Implementierung (Framing, MSP_SET_WP, MSP_NAV_STATUS, ...) ist
|
||||
/// noch offen - dies ist bewusst nur das Geruest fuer die Abstraktion.
|
||||
class MspFlightControllerLink implements FlightControllerLink {
|
||||
MspFlightControllerLink({required this.host, required this.port});
|
||||
MspFlightControllerLink({required this.transport});
|
||||
|
||||
final String host;
|
||||
final int port;
|
||||
final LinkTransport transport;
|
||||
|
||||
@override
|
||||
FcCapabilities get capabilities => throw UnimplementedError();
|
||||
|
||||
@@ -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_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// Drohnen-Profile: FC-Typ, Flugleistungsdaten, Standardprofil T1 Ranger
|
||||
/// (Architektur-Doku 3.5).
|
||||
import '../../../transport/bluetooth/bluetooth_device_info.dart';
|
||||
import '../../../transport/bluetooth/bluetooth_system_settings.dart';
|
||||
import '../../../transport/link_transport.dart';
|
||||
import '../../dmc_colors.dart';
|
||||
import '../../providers/bluetooth_connection_provider.dart';
|
||||
import '../../providers/connection_settings_provider.dart';
|
||||
import '../../providers/database_provider.dart';
|
||||
|
||||
/// Einstellungen: Drohnen-Profile (Doku 3.5/7.3, noch nicht hier
|
||||
/// angebunden - siehe DroneProfileEditor) und Verbindung (Doku
|
||||
/// Kommunikationsschicht Abschnitt 7).
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Drone Profiles')),
|
||||
body: const Center(child: Text('Settings')),
|
||||
appBar: AppBar(title: const Text('Settings')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: const [_ConnectionSection()],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConnectionSection extends ConsumerWidget {
|
||||
const _ConnectionSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final linkState = ref.watch(linkStateProvider).value;
|
||||
final errorReason = ref.watch(linkErrorReasonProvider);
|
||||
final bondedDevices = ref.watch(bondedDevicesProvider);
|
||||
final connectionSettings = ref.watch(connectionSettingsProvider).value;
|
||||
final log = ref.watch(connectionLogProvider);
|
||||
final packetCount = ref.watch(connectionLogProvider.notifier).packetCount;
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text('Verbindung', style: Theme.of(context).textTheme.titleMedium),
|
||||
const Spacer(),
|
||||
_StatePill(state: linkState),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Art: Bluetooth'),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'WLAN und USB folgen spaeter (Doku Kommunikationsschicht 6/7).',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
if (errorReason != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
_ErrorHint(reason: errorReason),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () => _connect(ref),
|
||||
child: const Text('Verbinden'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton(
|
||||
onPressed: () => ref.read(bluetoothTransportProvider).disconnect(),
|
||||
child: const Text('Trennen'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: BluetoothSystemSettings.openBluetoothSettings,
|
||||
child: const Text('Geraet koppeln'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Beim Wechsel in Fly automatisch verbinden'),
|
||||
value: connectionSettings?.connectionAutoConnect ?? true,
|
||||
onChanged: (value) async {
|
||||
await ref
|
||||
.read(appSettingsRepositoryProvider)
|
||||
.setConnectionAutoConnect(value);
|
||||
ref.invalidate(connectionSettingsProvider);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('Gekoppelte Geraete', style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
bondedDevices.when(
|
||||
data: (devices) => devices.isEmpty
|
||||
? const Text(
|
||||
'Keine gekoppelten Geraete. Ueber "Geraet koppeln" '
|
||||
'zunaechst einmalig ueber den Systemdialog koppeln.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
)
|
||||
: RadioGroup<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"
|
||||
source: hosted
|
||||
version: "12.1.0"
|
||||
app_settings:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: app_settings
|
||||
sha256: "5e7d6e5b60e90c40b3409d6fb33d1844497be3a2e959935da8b77bc437221db8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.0.3"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -318,6 +326,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.1.1"
|
||||
flutter_classic_bluetooth:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_classic_bluetooth
|
||||
sha256: "6343b5d0e0bd07fffc72ed7758170c74a1356a04177ca45856d332b58783f909"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.6"
|
||||
flutter_launcher_icons:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -672,6 +688,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
permission_handler:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: permission_handler
|
||||
sha256: fe54465bcc62a4564c6e4db337bbaded6c0c0fa6e10487414436d163114784f6
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.0.3"
|
||||
permission_handler_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_android
|
||||
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "13.0.1"
|
||||
permission_handler_apple:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_apple
|
||||
sha256: "79dfa1df734798aa3cfdad166d3a3698c206d8813de13516ea1071b5d7e2f420"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.4.10"
|
||||
permission_handler_html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_html
|
||||
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.3+5"
|
||||
permission_handler_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_platform_interface
|
||||
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.3.0"
|
||||
permission_handler_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_windows
|
||||
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -55,6 +55,9 @@ dependencies:
|
||||
url_launcher: ^6.3.2
|
||||
share_plus: ^13.3.0
|
||||
file_picker: ">=12.0.0-beta.1 <13.0.0"
|
||||
flutter_classic_bluetooth: ^0.1.6
|
||||
permission_handler: ^12.0.3
|
||||
app_settings: ^8.0.3
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -49,4 +49,30 @@ void main() {
|
||||
final settings = await repository.load();
|
||||
expect(settings.currentMissionId, isNull);
|
||||
});
|
||||
|
||||
test('Verbindungseinstellungen: Standard ist kein Geraet, Autoconnect an',
|
||||
() async {
|
||||
final settings = await repository.load();
|
||||
expect(settings.connectionDeviceAddress, isNull);
|
||||
expect(settings.connectionAutoConnect, isTrue);
|
||||
});
|
||||
|
||||
test('setConnectionDeviceAddress merkt sich die MAC-Adresse', () async {
|
||||
await repository.setConnectionDeviceAddress('AA:BB:CC:DD:EE:FF');
|
||||
|
||||
expect(
|
||||
await repository.loadConnectionDeviceAddress(),
|
||||
'AA:BB:CC:DD:EE:FF',
|
||||
);
|
||||
});
|
||||
|
||||
test('setConnectionAutoConnect(false) bleibt unabhaengig von der MAC-Adresse',
|
||||
() async {
|
||||
await repository.setConnectionDeviceAddress('AA:BB:CC:DD:EE:FF');
|
||||
await repository.setConnectionAutoConnect(false);
|
||||
|
||||
final settings = await repository.load();
|
||||
expect(settings.connectionDeviceAddress, 'AA:BB:CC:DD:EE:FF');
|
||||
expect(settings.connectionAutoConnect, isFalse);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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