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
@@ -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 {