WIFI connection support added

This commit is contained in:
Constantin Leue
2026-07-31 08:17:12 +02:00
parent b28b1a89bb
commit 8cc9a51a34
11 changed files with 1436 additions and 115 deletions
+29 -1
View File
@@ -5,6 +5,8 @@ import 'package:drift/native.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../../transport/connection_type.dart';
part 'app_database.g.dart';
/// Persistenz fuer Missionen (Architektur-Doku 4.15): Drift statt Hive, weil
@@ -61,6 +63,21 @@ class AppSettingsTable extends Table {
BoolColumn get connectionAutoConnect =>
boolean().withDefault(const Constant(true))();
/// Gewaehlte Verbindungsart (Doku 7: "Art: WLAN * USB (spaeter)"),
/// gespeichert als [ConnectionType.name]. Standard bleibt Bluetooth, damit
/// bestehende Installationen ihr bisheriges Verhalten behalten.
TextColumn get connectionType =>
text().withDefault(const Constant('bluetooth'))();
/// Vom Nutzer eingetragene Ziel-IP der mLRS-WLAN-Bruecke (Doku 3:
/// "Zieladresse niemals fest eincodieren") - null, solange keine Vorgabe
/// gesetzt ist und die Gegenstelle erst aus dem Verkehr gelernt wird.
TextColumn get connectionWifiHost => text().nullable()();
/// Lokaler UDP-Port fuer die mLRS-WLAN-Bruecke (Doku 3), Standard 14550.
IntColumn get connectionWifiPort =>
integer().withDefault(const Constant(kDefaultWifiPort))();
@override
Set<Column> get primaryKey => {id};
}
@@ -72,7 +89,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase.forTesting(super.executor);
@override
int get schemaVersion => 3;
int get schemaVersion => 4;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -93,6 +110,17 @@ class AppDatabase extends _$AppDatabase {
appSettingsTable.connectionAutoConnect,
);
}
if (from < 4) {
await m.addColumn(appSettingsTable, appSettingsTable.connectionType);
await m.addColumn(
appSettingsTable,
appSettingsTable.connectionWifiHost,
);
await m.addColumn(
appSettingsTable,
appSettingsTable.connectionWifiPort,
);
}
},
);
+237 -3
View File
@@ -1348,6 +1348,40 @@ class $AppSettingsTableTable extends AppSettingsTable
),
defaultValue: const Constant(true),
);
static const VerificationMeta _connectionTypeMeta = const VerificationMeta(
'connectionType',
);
@override
late final GeneratedColumn<String> connectionType = GeneratedColumn<String>(
'connection_type',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: false,
defaultValue: const Constant('bluetooth'),
);
static const VerificationMeta _connectionWifiHostMeta =
const VerificationMeta('connectionWifiHost');
@override
late final GeneratedColumn<String> connectionWifiHost =
GeneratedColumn<String>(
'connection_wifi_host',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _connectionWifiPortMeta =
const VerificationMeta('connectionWifiPort');
@override
late final GeneratedColumn<int> connectionWifiPort = GeneratedColumn<int>(
'connection_wifi_port',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: false,
defaultValue: const Constant(kDefaultWifiPort),
);
@override
List<GeneratedColumn> get $columns => [
id,
@@ -1355,6 +1389,9 @@ class $AppSettingsTableTable extends AppSettingsTable
currentMissionId,
connectionDeviceAddress,
connectionAutoConnect,
connectionType,
connectionWifiHost,
connectionWifiPort,
];
@override
String get aliasedName => _alias ?? actualTableName;
@@ -1407,6 +1444,33 @@ class $AppSettingsTableTable extends AppSettingsTable
),
);
}
if (data.containsKey('connection_type')) {
context.handle(
_connectionTypeMeta,
connectionType.isAcceptableOrUnknown(
data['connection_type']!,
_connectionTypeMeta,
),
);
}
if (data.containsKey('connection_wifi_host')) {
context.handle(
_connectionWifiHostMeta,
connectionWifiHost.isAcceptableOrUnknown(
data['connection_wifi_host']!,
_connectionWifiHostMeta,
),
);
}
if (data.containsKey('connection_wifi_port')) {
context.handle(
_connectionWifiPortMeta,
connectionWifiPort.isAcceptableOrUnknown(
data['connection_wifi_port']!,
_connectionWifiPortMeta,
),
);
}
return context;
}
@@ -1436,6 +1500,18 @@ class $AppSettingsTableTable extends AppSettingsTable
DriftSqlType.bool,
data['${effectivePrefix}connection_auto_connect'],
)!,
connectionType: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}connection_type'],
)!,
connectionWifiHost: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}connection_wifi_host'],
),
connectionWifiPort: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}connection_wifi_port'],
)!,
);
}
@@ -1458,12 +1534,28 @@ class AppSettingsTableData extends DataClass
/// "Beim Wechsel in Fly automatisch verbinden" (Doku 7), Standard an.
final bool connectionAutoConnect;
/// Gewaehlte Verbindungsart (Doku 7: "Art: WLAN * USB (spaeter)"),
/// gespeichert als [ConnectionType.name]. Standard bleibt Bluetooth, damit
/// bestehende Installationen ihr bisheriges Verhalten behalten.
final String connectionType;
/// Vom Nutzer eingetragene Ziel-IP der mLRS-WLAN-Bruecke (Doku 3:
/// "Zieladresse niemals fest eincodieren") - null, solange keine Vorgabe
/// gesetzt ist und die Gegenstelle erst aus dem Verkehr gelernt wird.
final String? connectionWifiHost;
/// Lokaler UDP-Port fuer die mLRS-WLAN-Bruecke (Doku 3), Standard 14550.
final int connectionWifiPort;
const AppSettingsTableData({
required this.id,
this.activeDroneProfileId,
this.currentMissionId,
this.connectionDeviceAddress,
required this.connectionAutoConnect,
required this.connectionType,
this.connectionWifiHost,
required this.connectionWifiPort,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
@@ -1481,6 +1573,11 @@ class AppSettingsTableData extends DataClass
);
}
map['connection_auto_connect'] = Variable<bool>(connectionAutoConnect);
map['connection_type'] = Variable<String>(connectionType);
if (!nullToAbsent || connectionWifiHost != null) {
map['connection_wifi_host'] = Variable<String>(connectionWifiHost);
}
map['connection_wifi_port'] = Variable<int>(connectionWifiPort);
return map;
}
@@ -1497,6 +1594,11 @@ class AppSettingsTableData extends DataClass
? const Value.absent()
: Value(connectionDeviceAddress),
connectionAutoConnect: Value(connectionAutoConnect),
connectionType: Value(connectionType),
connectionWifiHost: connectionWifiHost == null && nullToAbsent
? const Value.absent()
: Value(connectionWifiHost),
connectionWifiPort: Value(connectionWifiPort),
);
}
@@ -1517,6 +1619,11 @@ class AppSettingsTableData extends DataClass
connectionAutoConnect: serializer.fromJson<bool>(
json['connectionAutoConnect'],
),
connectionType: serializer.fromJson<String>(json['connectionType']),
connectionWifiHost: serializer.fromJson<String?>(
json['connectionWifiHost'],
),
connectionWifiPort: serializer.fromJson<int>(json['connectionWifiPort']),
);
}
@override
@@ -1530,6 +1637,9 @@ class AppSettingsTableData extends DataClass
connectionDeviceAddress,
),
'connectionAutoConnect': serializer.toJson<bool>(connectionAutoConnect),
'connectionType': serializer.toJson<String>(connectionType),
'connectionWifiHost': serializer.toJson<String?>(connectionWifiHost),
'connectionWifiPort': serializer.toJson<int>(connectionWifiPort),
};
}
@@ -1539,6 +1649,9 @@ class AppSettingsTableData extends DataClass
Value<int?> currentMissionId = const Value.absent(),
Value<String?> connectionDeviceAddress = const Value.absent(),
bool? connectionAutoConnect,
String? connectionType,
Value<String?> connectionWifiHost = const Value.absent(),
int? connectionWifiPort,
}) => AppSettingsTableData(
id: id ?? this.id,
activeDroneProfileId: activeDroneProfileId.present
@@ -1551,6 +1664,11 @@ class AppSettingsTableData extends DataClass
? connectionDeviceAddress.value
: this.connectionDeviceAddress,
connectionAutoConnect: connectionAutoConnect ?? this.connectionAutoConnect,
connectionType: connectionType ?? this.connectionType,
connectionWifiHost: connectionWifiHost.present
? connectionWifiHost.value
: this.connectionWifiHost,
connectionWifiPort: connectionWifiPort ?? this.connectionWifiPort,
);
AppSettingsTableData copyWithCompanion(AppSettingsTableCompanion data) {
return AppSettingsTableData(
@@ -1567,6 +1685,15 @@ class AppSettingsTableData extends DataClass
connectionAutoConnect: data.connectionAutoConnect.present
? data.connectionAutoConnect.value
: this.connectionAutoConnect,
connectionType: data.connectionType.present
? data.connectionType.value
: this.connectionType,
connectionWifiHost: data.connectionWifiHost.present
? data.connectionWifiHost.value
: this.connectionWifiHost,
connectionWifiPort: data.connectionWifiPort.present
? data.connectionWifiPort.value
: this.connectionWifiPort,
);
}
@@ -1577,7 +1704,10 @@ class AppSettingsTableData extends DataClass
..write('activeDroneProfileId: $activeDroneProfileId, ')
..write('currentMissionId: $currentMissionId, ')
..write('connectionDeviceAddress: $connectionDeviceAddress, ')
..write('connectionAutoConnect: $connectionAutoConnect')
..write('connectionAutoConnect: $connectionAutoConnect, ')
..write('connectionType: $connectionType, ')
..write('connectionWifiHost: $connectionWifiHost, ')
..write('connectionWifiPort: $connectionWifiPort')
..write(')'))
.toString();
}
@@ -1589,6 +1719,9 @@ class AppSettingsTableData extends DataClass
currentMissionId,
connectionDeviceAddress,
connectionAutoConnect,
connectionType,
connectionWifiHost,
connectionWifiPort,
);
@override
bool operator ==(Object other) =>
@@ -1598,7 +1731,10 @@ class AppSettingsTableData extends DataClass
other.activeDroneProfileId == this.activeDroneProfileId &&
other.currentMissionId == this.currentMissionId &&
other.connectionDeviceAddress == this.connectionDeviceAddress &&
other.connectionAutoConnect == this.connectionAutoConnect);
other.connectionAutoConnect == this.connectionAutoConnect &&
other.connectionType == this.connectionType &&
other.connectionWifiHost == this.connectionWifiHost &&
other.connectionWifiPort == this.connectionWifiPort);
}
class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
@@ -1607,12 +1743,18 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
final Value<int?> currentMissionId;
final Value<String?> connectionDeviceAddress;
final Value<bool> connectionAutoConnect;
final Value<String> connectionType;
final Value<String?> connectionWifiHost;
final Value<int> connectionWifiPort;
const AppSettingsTableCompanion({
this.id = const Value.absent(),
this.activeDroneProfileId = const Value.absent(),
this.currentMissionId = const Value.absent(),
this.connectionDeviceAddress = const Value.absent(),
this.connectionAutoConnect = const Value.absent(),
this.connectionType = const Value.absent(),
this.connectionWifiHost = const Value.absent(),
this.connectionWifiPort = const Value.absent(),
});
AppSettingsTableCompanion.insert({
this.id = const Value.absent(),
@@ -1620,6 +1762,9 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
this.currentMissionId = const Value.absent(),
this.connectionDeviceAddress = const Value.absent(),
this.connectionAutoConnect = const Value.absent(),
this.connectionType = const Value.absent(),
this.connectionWifiHost = const Value.absent(),
this.connectionWifiPort = const Value.absent(),
});
static Insertable<AppSettingsTableData> custom({
Expression<int>? id,
@@ -1627,6 +1772,9 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
Expression<int>? currentMissionId,
Expression<String>? connectionDeviceAddress,
Expression<bool>? connectionAutoConnect,
Expression<String>? connectionType,
Expression<String>? connectionWifiHost,
Expression<int>? connectionWifiPort,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
@@ -1637,6 +1785,11 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
'connection_device_address': connectionDeviceAddress,
if (connectionAutoConnect != null)
'connection_auto_connect': connectionAutoConnect,
if (connectionType != null) 'connection_type': connectionType,
if (connectionWifiHost != null)
'connection_wifi_host': connectionWifiHost,
if (connectionWifiPort != null)
'connection_wifi_port': connectionWifiPort,
});
}
@@ -1646,6 +1799,9 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
Value<int?>? currentMissionId,
Value<String?>? connectionDeviceAddress,
Value<bool>? connectionAutoConnect,
Value<String>? connectionType,
Value<String?>? connectionWifiHost,
Value<int>? connectionWifiPort,
}) {
return AppSettingsTableCompanion(
id: id ?? this.id,
@@ -1655,6 +1811,9 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
connectionDeviceAddress ?? this.connectionDeviceAddress,
connectionAutoConnect:
connectionAutoConnect ?? this.connectionAutoConnect,
connectionType: connectionType ?? this.connectionType,
connectionWifiHost: connectionWifiHost ?? this.connectionWifiHost,
connectionWifiPort: connectionWifiPort ?? this.connectionWifiPort,
);
}
@@ -1682,6 +1841,15 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
connectionAutoConnect.value,
);
}
if (connectionType.present) {
map['connection_type'] = Variable<String>(connectionType.value);
}
if (connectionWifiHost.present) {
map['connection_wifi_host'] = Variable<String>(connectionWifiHost.value);
}
if (connectionWifiPort.present) {
map['connection_wifi_port'] = Variable<int>(connectionWifiPort.value);
}
return map;
}
@@ -1692,7 +1860,10 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
..write('activeDroneProfileId: $activeDroneProfileId, ')
..write('currentMissionId: $currentMissionId, ')
..write('connectionDeviceAddress: $connectionDeviceAddress, ')
..write('connectionAutoConnect: $connectionAutoConnect')
..write('connectionAutoConnect: $connectionAutoConnect, ')
..write('connectionType: $connectionType, ')
..write('connectionWifiHost: $connectionWifiHost, ')
..write('connectionWifiPort: $connectionWifiPort')
..write(')'))
.toString();
}
@@ -2333,6 +2504,9 @@ typedef $$AppSettingsTableTableCreateCompanionBuilder =
Value<int?> currentMissionId,
Value<String?> connectionDeviceAddress,
Value<bool> connectionAutoConnect,
Value<String> connectionType,
Value<String?> connectionWifiHost,
Value<int> connectionWifiPort,
});
typedef $$AppSettingsTableTableUpdateCompanionBuilder =
AppSettingsTableCompanion Function({
@@ -2341,6 +2515,9 @@ typedef $$AppSettingsTableTableUpdateCompanionBuilder =
Value<int?> currentMissionId,
Value<String?> connectionDeviceAddress,
Value<bool> connectionAutoConnect,
Value<String> connectionType,
Value<String?> connectionWifiHost,
Value<int> connectionWifiPort,
});
class $$AppSettingsTableTableFilterComposer
@@ -2376,6 +2553,21 @@ class $$AppSettingsTableTableFilterComposer
column: $table.connectionAutoConnect,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get connectionType => $composableBuilder(
column: $table.connectionType,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get connectionWifiHost => $composableBuilder(
column: $table.connectionWifiHost,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get connectionWifiPort => $composableBuilder(
column: $table.connectionWifiPort,
builder: (column) => ColumnFilters(column),
);
}
class $$AppSettingsTableTableOrderingComposer
@@ -2411,6 +2603,21 @@ class $$AppSettingsTableTableOrderingComposer
column: $table.connectionAutoConnect,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get connectionType => $composableBuilder(
column: $table.connectionType,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get connectionWifiHost => $composableBuilder(
column: $table.connectionWifiHost,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get connectionWifiPort => $composableBuilder(
column: $table.connectionWifiPort,
builder: (column) => ColumnOrderings(column),
);
}
class $$AppSettingsTableTableAnnotationComposer
@@ -2444,6 +2651,21 @@ class $$AppSettingsTableTableAnnotationComposer
column: $table.connectionAutoConnect,
builder: (column) => column,
);
GeneratedColumn<String> get connectionType => $composableBuilder(
column: $table.connectionType,
builder: (column) => column,
);
GeneratedColumn<String> get connectionWifiHost => $composableBuilder(
column: $table.connectionWifiHost,
builder: (column) => column,
);
GeneratedColumn<int> get connectionWifiPort => $composableBuilder(
column: $table.connectionWifiPort,
builder: (column) => column,
);
}
class $$AppSettingsTableTableTableManager
@@ -2488,12 +2710,18 @@ class $$AppSettingsTableTableTableManager
Value<int?> currentMissionId = const Value.absent(),
Value<String?> connectionDeviceAddress = const Value.absent(),
Value<bool> connectionAutoConnect = const Value.absent(),
Value<String> connectionType = const Value.absent(),
Value<String?> connectionWifiHost = const Value.absent(),
Value<int> connectionWifiPort = const Value.absent(),
}) => AppSettingsTableCompanion(
id: id,
activeDroneProfileId: activeDroneProfileId,
currentMissionId: currentMissionId,
connectionDeviceAddress: connectionDeviceAddress,
connectionAutoConnect: connectionAutoConnect,
connectionType: connectionType,
connectionWifiHost: connectionWifiHost,
connectionWifiPort: connectionWifiPort,
),
createCompanionCallback:
({
@@ -2502,12 +2730,18 @@ class $$AppSettingsTableTableTableManager
Value<int?> currentMissionId = const Value.absent(),
Value<String?> connectionDeviceAddress = const Value.absent(),
Value<bool> connectionAutoConnect = const Value.absent(),
Value<String> connectionType = const Value.absent(),
Value<String?> connectionWifiHost = const Value.absent(),
Value<int> connectionWifiPort = const Value.absent(),
}) => AppSettingsTableCompanion.insert(
id: id,
activeDroneProfileId: activeDroneProfileId,
currentMissionId: currentMissionId,
connectionDeviceAddress: connectionDeviceAddress,
connectionAutoConnect: connectionAutoConnect,
connectionType: connectionType,
connectionWifiHost: connectionWifiHost,
connectionWifiPort: connectionWifiPort,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
@@ -1,5 +1,6 @@
import 'package:drift/drift.dart';
import '../../transport/connection_type.dart';
import 'app_database.dart';
/// Zugriff auf die Einzeilen-Einstellungstabelle (Doku 7.5): welches
@@ -18,6 +19,9 @@ class AppSettingsRepository {
int? currentMissionId,
String? connectionDeviceAddress,
bool connectionAutoConnect,
ConnectionType connectionType,
String? connectionWifiHost,
int connectionWifiPort,
})> load() async {
final row = await (_db.select(_db.appSettingsTable)
..where((s) => s.id.equals(_rowId)))
@@ -27,6 +31,12 @@ class AppSettingsRepository {
currentMissionId: row?.currentMissionId,
connectionDeviceAddress: row?.connectionDeviceAddress,
connectionAutoConnect: row?.connectionAutoConnect ?? true,
connectionType: ConnectionType.values.firstWhere(
(t) => t.name == row?.connectionType,
orElse: () => ConnectionType.bluetooth,
),
connectionWifiHost: row?.connectionWifiHost,
connectionWifiPort: row?.connectionWifiPort ?? kDefaultWifiPort,
);
}
@@ -64,6 +74,36 @@ class AppSettingsRepository {
));
}
/// Gewaehlte Verbindungsart (Doku Kommunikationsschicht 7).
Future<void> setConnectionType(ConnectionType type) async {
await _upsert(AppSettingsTableCompanion(
id: const Value(_rowId),
connectionType: Value(type.name),
));
}
/// Vom Nutzer eingetragene Ziel-IP der WLAN-Bruecke (Doku 3), oder null um
/// die Vorgabe zu loeschen und nur noch aus dem Verkehr zu lernen.
Future<String?> loadConnectionWifiHost() async =>
(await load()).connectionWifiHost;
Future<void> setConnectionWifiHost(String? host) async {
await _upsert(AppSettingsTableCompanion(
id: const Value(_rowId),
connectionWifiHost: Value(host),
));
}
Future<int> loadConnectionWifiPort() async =>
(await load()).connectionWifiPort;
Future<void> setConnectionWifiPort(int port) async {
await _upsert(AppSettingsTableCompanion(
id: const Value(_rowId),
connectionWifiPort: Value(port),
));
}
Future<void> _upsert(AppSettingsTableCompanion companion) async {
await _db.into(_db.appSettingsTable).insertOnConflictUpdate(companion);
}
+15
View File
@@ -0,0 +1,15 @@
/// Verbindungsart, wie sie der Nutzer in den Einstellungen waehlt (Doku
/// Kommunikationsschicht Abschnitt 7: "Art: WLAN * USB (spaeter)").
/// Protokollneutraler Enum-Typ analog zu [LinkState] - deshalb bewusst auch
/// hier in transport/ statt in der UI, aber ohne jede Netzwerk-/Plattform-
/// Abhaengigkeit, damit er gefahrlos von database/ und ui/ importiert werden
/// kann.
enum ConnectionType {
wifi,
cellular5g,
bluetooth,
usb,
}
/// Vorgabe-Port der mLRS-WLAN-Bruecke (Doku Abschnitt 3: "Port 14550").
const int kDefaultWifiPort = 14550;
+168
View File
@@ -0,0 +1,168 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import '../link_transport.dart';
/// UDP-Transport zur mLRS-WLAN-Bruecke (Doku Kommunikationsschicht Abschnitt
/// 3). Erste, einfachste Ausbaustufe: lokal auf [_getPort] binden und auf
/// eingehende Pakete hoeren (Stufe 1 - ohne Netzwerk-Bindung, siehe Doku
/// Abschnitt 4; Stufe 2/3 sind bewusst noch nicht umgesetzt).
///
/// Setzt das in Abschnitt 3 beschriebene Muster um: lokal auf dem Zielport
/// binden statt auf einen fluechtigen Port (sonst kommt kein Byte an), und
/// die Gegenstelle ausschliesslich aus dem Verkehr lernen statt sie fest
/// einzucodieren. Eine vom Nutzer eingetragene Host-Vorgabe fuellt nur das
/// allererste ausgehende Paket vor.
class UdpTransport implements LinkTransport {
UdpTransport({
required Future<String?> Function() getHost,
required Future<int> Function() getPort,
}) : _getHost = getHost,
_getPort = getPort;
final Future<String?> Function() _getHost;
final Future<int> Function() _getPort;
final _incomingController = StreamController<Uint8List>.broadcast();
final _stateController = StreamController<LinkState>.broadcast();
RawDatagramSocket? _socket;
StreamSubscription<RawSocketEvent>? _socketSub;
InternetAddress? _peerAddress;
int? _peerPort;
int? _boundPort;
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;
/// Tatsaechlich gebundener lokaler Port, erst nach [connect] bekannt -
/// weicht von der Vorgabe ab, wenn diese belegt war (Doku 3/8).
int? get boundPort => _boundPort;
/// Gegenstelle, sobald aus dem ersten eingehenden Paket gelernt bzw. aus
/// der Host-Vorgabe vorbelegt (Doku 3).
String? get peerDescription =>
_peerAddress == null ? null : '${_peerAddress!.address}:$_peerPort';
@override
Future<void> connect() async {
_lastErrorReason = null;
_lastErrorMessage = null;
_setState(LinkState.connecting);
final configuredPort = await _getPort();
final host = await _getHost();
if (host != null && host.isNotEmpty) {
final parsed = InternetAddress.tryParse(host);
if (parsed != null) {
_peerAddress = parsed;
_peerPort = configuredPort;
}
}
try {
RawDatagramSocket socket;
try {
// reuseAddress: false, damit ein tatsaechlich belegter Port hier
// erkennbar mit SocketException scheitert - Darts Standard
// (reuseAddress: true) wuerde den Konflikt sonst still zulassen.
socket = await RawDatagramSocket.bind(
InternetAddress.anyIPv4,
configuredPort,
reuseAddress: false,
);
} on SocketException {
// Port belegt (Doku 3/8: "ist 14550 belegt, auf einen fluechtigen
// ausweichen, aber das protokollieren") - Ausweichen ist kein Grund,
// die Verbindung abzubrechen.
socket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0);
_lastErrorMessage =
'Port $configuredPort belegt, ausgewichen auf ${socket.port}.';
}
_socket = socket;
_boundPort = socket.port;
_socketSub = socket.listen(
_onSocketEvent,
onError: (Object e) =>
_setError(LinkErrorReason.connectionFailed, e.toString()),
);
_setState(LinkState.connected);
} on SocketException catch (e) {
_fail(LinkErrorReason.connectionFailed, e.message);
}
}
void _onSocketEvent(RawSocketEvent event) {
if (event != RawSocketEvent.read) return;
final datagram = _socket?.receive();
if (datagram == null) return;
_peerAddress = datagram.address;
_peerPort = datagram.port;
_incomingController.add(datagram.data);
}
@override
Future<void> disconnect() async {
await _socketSub?.cancel();
_socketSub = null;
_socket?.close();
_socket = null;
_boundPort = null;
_setState(LinkState.disconnected);
}
@override
Future<void> send(Uint8List data) async {
final socket = _socket;
final peer = _peerAddress;
final port = _peerPort;
if (socket == null || peer == null || port == null) {
throw const LinkConnectException(
LinkErrorReason.connectionFailed,
'send() ohne aktive Verbindung oder bekannte Gegenstelle aufgerufen.',
);
}
socket.send(data, peer, port);
}
@override
void dispose() {
_socketSub?.cancel();
_socket?.close();
_socket = null;
_incomingController.close();
_stateController.close();
}
void _setError(LinkErrorReason reason, String message) {
_lastErrorReason = reason;
_lastErrorMessage = message;
_setState(LinkState.error);
}
Never _fail(LinkErrorReason reason, String message) {
_setError(reason, message);
throw LinkConnectException(reason, message);
}
void _setState(LinkState next) {
_state = next;
if (!_stateController.isClosed) _stateController.add(next);
}
LinkState get currentState => _state;
}
@@ -0,0 +1,93 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../transport/link_transport.dart';
import '../../transport/udp/udp_transport.dart';
import 'database_provider.dart';
/// App-lebenslanger UDP-Transport (Doku Kommunikationsschicht Abschnitt 3),
/// verdrahtet gegen Host/Port aus den Einstellungen. Analog zu
/// [bluetoothTransportProvider] bewusst nicht autoDispose - siehe dortige
/// Begruendung.
final wifiTransportProvider = Provider<UdpTransport>((ref) {
final settingsRepo = ref.watch(appSettingsRepositoryProvider);
final transport = UdpTransport(
getHost: settingsRepo.loadConnectionWifiHost,
getPort: settingsRepo.loadConnectionWifiPort,
);
ref.onDispose(transport.dispose);
return transport;
});
/// Reaktiver [LinkState] des WLAN-Transports fuer die Oberflaeche.
final wifiLinkStateProvider = StreamProvider<LinkState>((ref) {
final transport = ref.watch(wifiTransportProvider);
return transport.state;
});
/// Grund des zuletzt gemeldeten [LinkState.error] des WLAN-Transports.
final wifiLinkErrorReasonProvider = Provider<LinkErrorReason?>((ref) {
ref.watch(wifiLinkStateProvider);
return ref.watch(wifiTransportProvider).lastErrorReason;
});
/// Ein Eintrag im WLAN-Verbindungsprotokoll (Doku 7).
class WifiConnectionLogEntry {
const WifiConnectionLogEntry(this.timestamp, this.message);
final DateTime timestamp;
final String message;
}
/// Verbindungsprotokoll + Paketzaehler fuer den WLAN-Transport, analog zu
/// [ConnectionLogNotifier] fuer Bluetooth.
class WifiConnectionLogNotifier
extends Notifier<List<WifiConnectionLogEntry>> {
static const _maxEntries = 50;
StreamSubscription<LinkState>? _stateSub;
StreamSubscription<void>? _dataSub;
int _packetCount = 0;
int get packetCount => _packetCount;
@override
List<WifiConnectionLogEntry> build() {
final transport = ref.watch(wifiTransportProvider);
_stateSub?.cancel();
_dataSub?.cancel();
_packetCount = 0;
_stateSub = transport.state.listen(_onStateChanged);
_dataSub = transport.incoming.listen((_) => _packetCount++);
ref.onDispose(() {
_stateSub?.cancel();
_dataSub?.cancel();
});
return const [];
}
void _onStateChanged(LinkState linkState) {
final transport = ref.read(wifiTransportProvider);
final message = switch (linkState) {
LinkState.disconnected => 'Getrennt',
LinkState.connecting => 'Verbinde ...',
LinkState.connected => 'Lausche auf Port ${transport.boundPort}',
LinkState.error => 'Fehler: '
'${transport.lastErrorMessage ?? transport.lastErrorReason?.name ?? 'unbekannt'}',
};
_append(message);
}
void _append(String message) {
final entry = WifiConnectionLogEntry(DateTime.now(), message);
final next = [...state, entry];
state = next.length > _maxEntries
? next.sublist(next.length - _maxEntries)
: next;
}
}
final wifiConnectionLogProvider =
NotifierProvider<WifiConnectionLogNotifier, List<WifiConnectionLogEntry>>(
WifiConnectionLogNotifier.new);
+364 -109
View File
@@ -1,13 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../transport/bluetooth/bluetooth_device_info.dart';
import '../../../transport/bluetooth/bluetooth_system_settings.dart';
import '../../../transport/connection_type.dart';
import '../../../transport/link_transport.dart';
import '../../dmc_colors.dart';
import '../../providers/bluetooth_connection_provider.dart';
import '../../providers/connection_settings_provider.dart';
import '../../providers/database_provider.dart';
import '../../providers/wifi_connection_provider.dart';
/// Einstellungen: Drohnen-Profile (Doku 3.5/7.3, noch nicht hier
/// angebunden - siehe DroneProfileEditor) und Verbindung (Doku
@@ -41,12 +44,8 @@ class _ConnectionSection extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final linkState = ref.watch(linkStateProvider).value;
final errorReason = ref.watch(linkErrorReasonProvider);
final bondedDevices = ref.watch(bondedDevicesProvider);
final connectionSettings = ref.watch(connectionSettingsProvider).value;
final log = ref.watch(connectionLogProvider);
final packetCount = ref.watch(connectionLogProvider.notifier).packetCount;
final selectedType = connectionSettings?.connectionType ?? ConnectionType.bluetooth;
return Card(
child: Padding(
@@ -54,124 +53,380 @@ class _ConnectionSection extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('Connection', style: Theme.of(context).textTheme.titleMedium),
const Spacer(),
_StatePill(state: linkState),
],
),
Text('Connection', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
const Text('Type: Bluetooth'),
const SizedBox(height: 4),
const Text(
'Wi-Fi and USB support are coming later.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
if (errorReason != null) ...[
const SizedBox(height: 12),
_ErrorHint(reason: errorReason),
],
const SizedBox(height: 16),
Row(
children: [
ElevatedButton(
onPressed: () => _connect(ref),
child: const Text('Connect'),
DropdownButtonFormField<ConnectionType>(
initialValue: selectedType,
decoration: const InputDecoration(
labelText: 'Type',
isDense: true,
border: OutlineInputBorder(),
),
items: const [
DropdownMenuItem(value: ConnectionType.wifi, child: Text('WiFi')),
DropdownMenuItem(
value: ConnectionType.cellular5g,
child: Text('5G (coming soon)'),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: () => ref.read(bluetoothTransportProvider).disconnect(),
child: const Text('Disconnect'),
DropdownMenuItem(
value: ConnectionType.bluetooth,
child: Text('Bluetooth'),
),
const SizedBox(width: 8),
TextButton(
onPressed: BluetoothSystemSettings.openBluetoothSettings,
child: const Text('Pair device'),
DropdownMenuItem(
value: ConnectionType.usb,
child: Text('USB (coming soon)'),
),
],
),
const SizedBox(height: 16),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Auto-connect when entering Fly mode'),
value: connectionSettings?.connectionAutoConnect ?? true,
onChanged: (value) async {
await ref
.read(appSettingsRepositoryProvider)
.setConnectionAutoConnect(value);
onChanged: (type) async {
if (type == null) return;
await ref.read(appSettingsRepositoryProvider).setConnectionType(type);
ref.invalidate(connectionSettingsProvider);
},
),
const SizedBox(height: 8),
Text('Paired devices', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 4),
bondedDevices.when(
data: (devices) => devices.isEmpty
? const Text(
'No paired devices yet. Pair one once via the system '
'dialog using "Pair device".',
style: TextStyle(fontSize: 12, color: Colors.grey),
)
: RadioGroup<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(
'Could not read paired devices: $e',
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
),
const SizedBox(height: 16),
Text(
'Connection log ($packetCount packets received)',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 4),
SizedBox(
height: 120,
child: log.isEmpty
? const Text(
'No events yet.',
style: TextStyle(fontSize: 12, color: Colors.grey),
)
: ListView.builder(
reverse: true,
itemCount: log.length,
itemBuilder: (context, index) {
final entry = log[log.length - 1 - index];
return Text(
'${_formatTime(entry.timestamp)} ${entry.message}',
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
);
},
),
),
switch (selectedType) {
ConnectionType.wifi => const _WifiPanel(),
ConnectionType.bluetooth => const _BluetoothPanel(),
ConnectionType.cellular5g => const _NotAvailablePanel(
label: '5G',
),
ConnectionType.usb => const _NotAvailablePanel(label: 'USB'),
},
],
),
),
);
}
}
class _NotAvailablePanel extends StatelessWidget {
const _NotAvailablePanel({required this.label});
final String label;
@override
Widget build(BuildContext context) {
return Text(
'$label support is not available yet.',
style: const TextStyle(fontSize: 12, color: Colors.grey),
);
}
}
class _WifiPanel extends ConsumerStatefulWidget {
const _WifiPanel();
@override
ConsumerState<_WifiPanel> createState() => _WifiPanelState();
}
class _WifiPanelState extends ConsumerState<_WifiPanel> {
late final TextEditingController _hostController;
late final TextEditingController _portController;
bool _controllersSeeded = false;
@override
void initState() {
super.initState();
_hostController = TextEditingController();
_portController = TextEditingController();
}
@override
void dispose() {
_hostController.dispose();
_portController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final linkState = ref.watch(wifiLinkStateProvider).value;
final errorReason = ref.watch(wifiLinkErrorReasonProvider);
final connectionSettings = ref.watch(connectionSettingsProvider).value;
final log = ref.watch(wifiConnectionLogProvider);
final packetCount = ref.watch(wifiConnectionLogProvider.notifier).packetCount;
// Controller einmalig aus den gespeicherten Einstellungen befuellen -
// nicht bei jedem Rebuild, sonst wird waehrend der Nutzereingabe
// ueberschrieben.
if (!_controllersSeeded && connectionSettings != null) {
_controllersSeeded = true;
_hostController.text = connectionSettings.connectionWifiHost ?? '';
_portController.text = connectionSettings.connectionWifiPort.toString();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('WiFi (UDP)', style: Theme.of(context).textTheme.titleSmall),
const Spacer(),
_StatePill(state: linkState),
],
),
const SizedBox(height: 4),
const Text(
'Opens a local UDP socket and listens for the mLRS bridge '
'(port 14550 by default). The peer address is learned from the '
'first incoming packet.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
if (errorReason != null) ...[
const SizedBox(height: 12),
_WifiErrorHint(reason: errorReason),
],
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 2,
child: TextField(
controller: _hostController,
decoration: const InputDecoration(
labelText: 'Host (optional)',
hintText: 'auto (learned from traffic)',
isDense: true,
border: OutlineInputBorder(),
),
onSubmitted: (value) => _saveHost(value),
onEditingComplete: () => _saveHost(_hostController.text),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _portController,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(
labelText: 'Port',
isDense: true,
border: OutlineInputBorder(),
),
onSubmitted: (value) => _savePort(value),
onEditingComplete: () => _savePort(_portController.text),
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
ElevatedButton(
onPressed: () => _connect(ref),
child: const Text('Connect'),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: () => ref.read(wifiTransportProvider).disconnect(),
child: const Text('Disconnect'),
),
],
),
const SizedBox(height: 16),
Text(
'Connection log ($packetCount packets received)',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 4),
SizedBox(
height: 120,
child: log.isEmpty
? const Text(
'No events yet.',
style: TextStyle(fontSize: 12, color: Colors.grey),
)
: ListView.builder(
reverse: true,
itemCount: log.length,
itemBuilder: (context, index) {
final entry = log[log.length - 1 - index];
return Text(
'${_formatTime(entry.timestamp)} ${entry.message}',
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
);
},
),
),
],
);
}
Future<void> _saveHost(String value) async {
final trimmed = value.trim();
await ref
.read(appSettingsRepositoryProvider)
.setConnectionWifiHost(trimmed.isEmpty ? null : trimmed);
ref.invalidate(connectionSettingsProvider);
}
Future<void> _savePort(String value) async {
final port = int.tryParse(value);
if (port == null || port <= 0 || port > 65535) return;
await ref.read(appSettingsRepositoryProvider).setConnectionWifiPort(port);
ref.invalidate(connectionSettingsProvider);
}
Future<void> _connect(WidgetRef ref) async {
try {
await ref.read(wifiTransportProvider).connect();
} on LinkConnectException {
// Grund/Nachricht sind bereits ueber wifiLinkErrorReasonProvider
// gemeldet, siehe _WifiErrorHint.
}
}
String _formatTime(DateTime t) =>
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}:${t.second.toString().padLeft(2, '0')}';
}
class _WifiErrorHint extends StatelessWidget {
const _WifiErrorHint({required this.reason});
final LinkErrorReason reason;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: DmcColors.warnRed.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Connection error: ${reason.name}.',
style: const TextStyle(fontSize: 12),
),
);
}
}
class _BluetoothPanel extends ConsumerWidget {
const _BluetoothPanel();
@override
Widget build(BuildContext context, WidgetRef ref) {
final linkState = ref.watch(linkStateProvider).value;
final errorReason = ref.watch(linkErrorReasonProvider);
final bondedDevices = ref.watch(bondedDevicesProvider);
final connectionSettings = ref.watch(connectionSettingsProvider).value;
final log = ref.watch(connectionLogProvider);
final packetCount = ref.watch(connectionLogProvider.notifier).packetCount;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('Bluetooth', style: Theme.of(context).textTheme.titleSmall),
const Spacer(),
_StatePill(state: linkState),
],
),
if (errorReason != null) ...[
const SizedBox(height: 12),
_ErrorHint(reason: errorReason),
],
const SizedBox(height: 16),
Row(
children: [
ElevatedButton(
onPressed: () => _connect(ref),
child: const Text('Connect'),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: () => ref.read(bluetoothTransportProvider).disconnect(),
child: const Text('Disconnect'),
),
const SizedBox(width: 8),
TextButton(
onPressed: BluetoothSystemSettings.openBluetoothSettings,
child: const Text('Pair device'),
),
],
),
const SizedBox(height: 16),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Auto-connect when entering Fly mode'),
value: connectionSettings?.connectionAutoConnect ?? true,
onChanged: (value) async {
await ref
.read(appSettingsRepositoryProvider)
.setConnectionAutoConnect(value);
ref.invalidate(connectionSettingsProvider);
},
),
const SizedBox(height: 8),
Text('Paired devices', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 4),
bondedDevices.when(
data: (devices) => devices.isEmpty
? const Text(
'No paired devices yet. Pair one once via the system '
'dialog using "Pair device".',
style: TextStyle(fontSize: 12, color: Colors.grey),
)
: RadioGroup<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(
'Could not read paired devices: $e',
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
),
const SizedBox(height: 16),
Text(
'Connection log ($packetCount packets received)',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 4),
SizedBox(
height: 120,
child: log.isEmpty
? const Text(
'No events yet.',
style: TextStyle(fontSize: 12, color: Colors.grey),
)
: ListView.builder(
reverse: true,
itemCount: log.length,
itemBuilder: (context, index) {
final entry = log[log.length - 1 - index];
return Text(
'${_formatTime(entry.timestamp)} ${entry.message}',
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
);
},
),
),
],
);
}
Future<void> _connect(WidgetRef ref) async {
try {