From 38396738a6daaa51786d539ccc3aafc6a27126c1 Mon Sep 17 00:00:00 2001 From: Eduard Iten Date: Tue, 13 Jan 2026 08:22:38 +0100 Subject: [PATCH] Arbeiten an der BT-Kommunikation, stand vor umbau --- .../android/app/src/main/AndroidManifest.xml | 7 + software/app/lib/constants.dart | 7 +- .../app/lib/providers/device_provider.dart | 199 +++++++++++++++-- .../ui/screens/device_selection_screen.dart | 78 ++++++- .../lib/ui/screens/leader_config_screen.dart | 207 ++++++++++++++++++ .../app/lib/ui/widgets/rename_dialog.dart | 13 +- 6 files changed, 478 insertions(+), 33 deletions(-) create mode 100644 software/app/lib/ui/screens/leader_config_screen.dart diff --git a/software/app/android/app/src/main/AndroidManifest.xml b/software/app/android/app/src/main/AndroidManifest.xml index 0df0431..18b193f 100644 --- a/software/app/android/app/src/main/AndroidManifest.xml +++ b/software/app/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,11 @@ + + + + + + services = await ltDevice.btDevice.discoverServices(); - var service = services.firstWhere((s) => s.uuid == Guid(LasertagUUIDs.provService)); - var characteristic = service.characteristics.firstWhere((c) => c.uuid == Guid(LasertagUUIDs.provNameChar)); + List services = await ltDevice.btDevice + .discoverServices(); + var service = services.firstWhere( + (s) => s.uuid == Guid(LasertagUUIDs.provService), + ); + var characteristic = service.characteristics.firstWhere( + (c) => c.uuid == Guid(LasertagUUIDs.provNameChar), + ); List value = await characteristic.read(); String hardwareName = utf8.decode(value); updateDeviceName(ltDevice.id, hardwareName, verified: true); + await ltDevice.btDevice.disconnect(); return hardwareName; } catch (e) { @@ -84,7 +95,10 @@ class DeviceProvider extends ChangeNotifier { } // Öffentlicher API-Entry: Namen auf Hardware schreiben - Future updateDeviceNameOnHardware(LasertagDevice ltDevice, String newName) async { + Future updateDeviceNameOnHardware( + LasertagDevice ltDevice, + String newName, + ) async { try { await ltDevice.btDevice.connect( license: License.free, @@ -94,9 +108,14 @@ class DeviceProvider extends ChangeNotifier { await Future.delayed(const Duration(milliseconds: 500)); - List services = await ltDevice.btDevice.discoverServices(); - var service = services.firstWhere((s) => s.uuid == Guid(LasertagUUIDs.provService)); - var characteristic = service.characteristics.firstWhere((c) => c.uuid == Guid(LasertagUUIDs.provNameChar)); + List services = await ltDevice.btDevice + .discoverServices(); + var service = services.firstWhere( + (s) => s.uuid == Guid(LasertagUUIDs.provService), + ); + var characteristic = service.characteristics.firstWhere( + (c) => c.uuid == Guid(LasertagUUIDs.provNameChar), + ); await characteristic.write(utf8.encode(newName)); @@ -111,12 +130,17 @@ class DeviceProvider extends ChangeNotifier { // Öffentlicher API-Entry: Lokale Liste aktualisieren void updateDeviceName(String id, String newName, {bool verified = false}) { - _updateDeviceInLists(id, (old) => old.copyWith(name: newName, isNameVerified: verified)); + _updateDeviceInLists( + id, + (old) => old.copyWith(name: newName, isNameVerified: verified), + ); } void _addDeviceToLists(LasertagDevice device) { - List list = device.isLeader ? _discoveredLeaders : _discoveredPeripherals; - + List list = device.isLeader + ? _discoveredLeaders + : _discoveredPeripherals; + if (!list.any((d) => d.id == device.id)) { list.add(device); _sortList(list); @@ -129,24 +153,35 @@ class DeviceProvider extends ChangeNotifier { if (device.isNameVerified) return; try { - await device.btDevice.connect(license: License.free, timeout: const Duration(seconds: 5)); - - List services = await device.btDevice.discoverServices(); - var service = services.firstWhere((s) => s.uuid == Guid(LasertagUUIDs.provService)); - var characteristic = service.characteristics.firstWhere((c) => c.uuid == Guid(LasertagUUIDs.provNameChar)); + await device.btDevice.connect( + license: License.free, + timeout: const Duration(seconds: 5), + ); + + List services = await device.btDevice + .discoverServices(); + var service = services.firstWhere( + (s) => s.uuid == Guid(LasertagUUIDs.provService), + ); + var characteristic = service.characteristics.firstWhere( + (c) => c.uuid == Guid(LasertagUUIDs.provNameChar), + ); List value = await characteristic.read(); String realName = utf8.decode(value); updateDeviceName(device.id, realName, verified: true); - + await device.btDevice.disconnect(); } catch (e) { debugPrint("Background Sync fehlgeschlagen für ${device.id}: $e"); } } - void _updateDeviceInLists(String id, LasertagDevice Function(LasertagDevice) updateFn) { + void _updateDeviceInLists( + String id, + LasertagDevice Function(LasertagDevice) updateFn, + ) { for (var list in [_discoveredLeaders, _discoveredPeripherals]) { int index = list.indexWhere((d) => d.id == id); if (index != -1) { @@ -158,6 +193,130 @@ class DeviceProvider extends ChangeNotifier { } } + Future provisionLeader( + LasertagDevice device, + Map config, + ) async { + try { + if (!device.btDevice.isConnected) { + await device.btDevice.connect( + license: License.free, + timeout: const Duration(seconds: 10), + ); + } + + List services = await device.btDevice + .discoverServices(); + var s = services.firstWhere( + (s) => s.uuid == Guid(LasertagUUIDs.provService), + ); + + // Hilfsfunktion zum Finden einer Charakteristik + BluetoothCharacteristic findChar(String uuid) => + s.characteristics.firstWhere((c) => c.uuid == Guid(uuid)); + + // 1. Namen schreiben + await findChar( + LasertagUUIDs.provNameChar, + ).write(utf8.encode(config['name'])); + await findChar( + LasertagUUIDs.provNetNameChar, + ).write(utf8.encode(config['netName'])); + + // 2. Kanal (8-bit) + await findChar(LasertagUUIDs.provChanChar).write([config['chan']]); + + // 3. PAN ID (16-bit little endian für Zephyr) + final panData = ByteData(2)..setUint16(0, config['panId'], Endian.little); + await findChar( + LasertagUUIDs.provPanIdChar, + ).write(panData.buffer.asUint8List()); + + // 4. Hex-Werte (ExtPAN 8 Bytes, NetKey 16 Bytes) + await findChar( + LasertagUUIDs.provExtPanIdChar, + ).write(_hexToBytes(config['extPan'])); + await findChar( + LasertagUUIDs.provNetKeyChar, + ).write(_hexToBytes(config['netKey'])); + + // Lokalen Namen in der App-Liste aktualisieren + updateDeviceName(device.id, config['name'], verified: true); + + await device.btDevice.disconnect(); + } catch (e) { + debugPrint("Provisionierungs-Fehler: $e"); + rethrow; + } + } + + // Hilfsmethode: Wandelt "DEADBEEF" in [0xDE, 0xAD, 0xBE, 0xEF] um + List _hexToBytes(String hex) { + List bytes = []; + for (int i = 0; i < hex.length; i += 2) { + bytes.add(int.parse(hex.substring(i, i + 2), radix: 16)); + } + return bytes; + } + + Future> readFullLeaderConfig( + LasertagDevice ltDevice, + ) async { + try { + if (!ltDevice.btDevice.isConnected) { + await ltDevice.btDevice.connect( + license: License.free, + timeout: const Duration(seconds: 10), + ); + } + + List services = await ltDevice.btDevice + .discoverServices(); + var s = services.firstWhere( + (s) => s.uuid == Guid(LasertagUUIDs.provService), + ); + + BluetoothCharacteristic findChar(String uuid) => + s.characteristics.firstWhere((c) => c.uuid == Guid(uuid)); + + // Alle Daten von der Hardware abfragen + final nameBytes = await findChar(LasertagUUIDs.provNameChar).read(); + final netNameBytes = await findChar(LasertagUUIDs.provNetNameChar).read(); + final chanBytes = await findChar(LasertagUUIDs.provChanChar).read(); + final panBytes = await findChar(LasertagUUIDs.provPanIdChar).read(); + final extPanBytes = await findChar(LasertagUUIDs.provExtPanIdChar).read(); + final netKeyBytes = await findChar(LasertagUUIDs.provNetKeyChar).read(); + + // Bytes in passende Dart-Typen umwandeln + return { + 'name': utf8.decode(nameBytes).trim(), + 'netName': utf8.decode(netNameBytes).trim(), + 'chan': chanBytes.isNotEmpty ? chanBytes[0] : 15, + 'panId': panBytes.length >= 2 + ? ByteData.sublistView( + Uint8List.fromList(panBytes), + ).getUint16(0, Endian.little) + : 0xABCD, + 'extPan': _bytesToHex(extPanBytes), + 'netKey': _bytesToHex(netKeyBytes), + }; + } catch (e) { + debugPrint("Fehler beim Laden der Leader-Config: $e"); + rethrow; + } finally { + // Verbindung nach dem Einlesen wieder trennen + await ltDevice.btDevice.disconnect(); + } + } + + // Hilfsmethode: Bytes in Hex-String umwandeln + String _bytesToHex(List bytes) { + return bytes + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toUpperCase(); + } + void _sortList(List list) { list.sort((a, b) { int typeComp = a.type.compareTo(b.type); @@ -171,4 +330,4 @@ class DeviceProvider extends ChangeNotifier { _scanSubscription?.cancel(); super.dispose(); } -} \ No newline at end of file +} diff --git a/software/app/lib/ui/screens/device_selection_screen.dart b/software/app/lib/ui/screens/device_selection_screen.dart index 0b5197a..99247e2 100644 --- a/software/app/lib/ui/screens/device_selection_screen.dart +++ b/software/app/lib/ui/screens/device_selection_screen.dart @@ -6,6 +6,7 @@ import '../../constants.dart'; import '../../models/device_model.dart'; import '../../providers/device_provider.dart'; import '../widgets/rename_dialog.dart'; +import 'leader_config_screen.dart'; class DeviceSelectionScreen extends StatefulWidget { const DeviceSelectionScreen({super.key}); @@ -90,16 +91,22 @@ class _DeviceSelectionScreenState extends State { // : FontStyle.italic, // Kursiv, wenn nicht verifiziert color: device.isNameVerified ? Theme.of(context).colorScheme.onSurface - : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + : Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.7), ), ), subtitle: Text("BTLE-ID: ${device.id}"), trailing: const Icon(Icons.chevron_right), onTap: () { - showDialog( - context: context, - builder: (context) => RenameDialog(device: device), - ); + if (device.isLeader) { + _showLeaderOptions(context, device); + } else { + showDialog( + context: context, + builder: (context) => RenameDialog(device: device), + ); + } }, ); }, childCount: devices.length), @@ -120,4 +127,65 @@ class _DeviceSelectionScreenState extends State { return Icons.device_unknown; } } + + void _showLeaderOptions(BuildContext context, LasertagDevice device) { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, // Nur so groß wie der Inhalt + children: [ + // Ein kleiner Griff oben für die Optik + Container( + margin: const EdgeInsets.only(top: 12, bottom: 8), + height: 4, + width: 40, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + device.name, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + ListTile( + leading: const Icon(Icons.edit), + title: const Text('Anzeigename ändern'), + onTap: () { + Navigator.pop(context); // Bottom Sheet schließen + showDialog( + context: context, + builder: (context) => RenameDialog(device: device), + ); + }, + ), + ListTile( + leading: const Icon(Icons.hub), + title: const Text('Als Game-Leader konfigurieren'), + onTap: () { + Navigator.pop(context); // Bottom Sheet zu + Navigator.push( + // Config Screen auf + context, + MaterialPageRoute( + builder: (context) => LeaderConfigScreen(device: device), + ), + ); + }, + ), + const SizedBox(height: 16), // Abstand nach unten + ], + ), + ); + }, + ); + } } diff --git a/software/app/lib/ui/screens/leader_config_screen.dart b/software/app/lib/ui/screens/leader_config_screen.dart new file mode 100644 index 0000000..fe5cb25 --- /dev/null +++ b/software/app/lib/ui/screens/leader_config_screen.dart @@ -0,0 +1,207 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'dart:math'; +import '../../models/device_model.dart'; +import '../../providers/device_provider.dart'; + +class LeaderConfigScreen extends StatefulWidget { + final LasertagDevice device; + const LeaderConfigScreen({super.key, required this.device}); + + @override + State createState() => _LeaderConfigScreenState(); +} + +class _LeaderConfigScreenState extends State { + final _formKey = GlobalKey(); + + final TextEditingController _nameCtrl = TextEditingController(); + final TextEditingController _netNameCtrl = TextEditingController(); + final TextEditingController _panCtrl = TextEditingController(); + final TextEditingController _extPanCtrl = TextEditingController(); + final TextEditingController _netKeyCtrl = TextEditingController(); + + int _selectedChannel = 15; + bool _isLoading = true; + bool _isSaving = false; + + @override + void initState() { + super.initState(); + _loadCurrentConfig(); + } + + Future _loadCurrentConfig() async { + try { + final config = await context.read().readFullLeaderConfig(widget.device); + if (mounted) { + setState(() { + _nameCtrl.text = config['name']; + _netNameCtrl.text = config['netName']; + _selectedChannel = config['chan']; + _panCtrl.text = "0x${config['panId'].toRadixString(16).toUpperCase()}"; + _extPanCtrl.text = config['extPan']; + _netKeyCtrl.text = config['netKey']; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Fehler: $e"))); + Navigator.pop(context); + } + } + } + + // Korrektur 1: Die fehlende Hilfsmethode hinzufügen + Widget _buildSectionTitle(String title) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Text( + title, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold + ) + ), + ); + } + + String? _validateHex(String? value, int requiredLength, String fieldName, {bool with0x = false}) { + if (value == null || value.isEmpty) return "$fieldName darf nicht leer sein"; + + String cleanValue = with0x ? value.replaceFirst('0x', '') : value; + final hexRegex = RegExp(r'^[0-9A-Fa-f]+$'); + + if (!hexRegex.hasMatch(cleanValue)) return "Nur Hex-Zeichen (0-9, A-F) erlaubt"; + if (cleanValue.length != requiredLength && requiredLength > 0) { + return "Muss genau $requiredLength Zeichen lang sein"; + } + return null; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text("Leader-Konfiguration")), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : Form( + key: _formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildSectionTitle("Allgemein"), + TextFormField( + controller: _nameCtrl, + decoration: const InputDecoration(labelText: "Knoten-Name", helperText: "Max. 31 Zeichen"), + maxLength: 31, + validator: (v) => (v == null || v.isEmpty) ? "Name erforderlich" : null, + ), + + TextFormField( + controller: _netNameCtrl, + decoration: const InputDecoration(labelText: "Thread Netzwerk-Name", helperText: "1-16 Zeichen"), + maxLength: 16, + // Korrektur 2: .isEmpty statt .length < 1 nutzen + validator: (v) => (v == null || v.isEmpty || v.length > 16) ? "1-16 Zeichen erlaubt" : null, + ), + + DropdownButtonFormField( + // Korrektur 3: initialValue statt value nutzen (neue Flutter Regel) + initialValue: _selectedChannel, + decoration: const InputDecoration(labelText: "Thread-Kanal (11-26)"), + items: List.generate(16, (i) => 11 + i).map((ch) => DropdownMenuItem(value: ch, child: Text("Kanal $ch"))).toList(), + onChanged: (v) => setState(() => _selectedChannel = v!), + ), + + const SizedBox(height: 24), + _buildSectionTitle("Thread Security"), + + _buildValidatedHexField("PAN ID (16-bit)", _panCtrl, 4, true), + _buildValidatedHexField("Extended PAN ID (64-bit)", _extPanCtrl, 16, false), + _buildValidatedHexField("Network Key (128-bit)", _netKeyCtrl, 32, false), + + const SizedBox(height: 32), + _buildActionButtons(), + ], + ), + ), + ); + } + + Widget _buildValidatedHexField(String label, TextEditingController ctrl, int len, bool with0x) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: TextFormField( + controller: ctrl, + decoration: InputDecoration( + labelText: label, + suffixIcon: IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => setState(() => ctrl.text = (with0x ? "0x" : "") + _generateRandomHex(len ~/ 2)), + ), + ), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(with0x ? r'[0-9A-Fa-fXx]' : r'[0-9A-Fa-f]')), + ], + validator: (v) => _validateHex(v, len, label, with0x: with0x), + ), + ); + } + + Widget _buildActionButtons() { + return Row( + children: [ + Expanded(child: OutlinedButton(onPressed: () => Navigator.pop(context), child: const Text("Abbrechen"))), + const SizedBox(width: 16), + Expanded( + child: ElevatedButton( + onPressed: _isSaving ? null : _startProvisioning, + child: _isSaving ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Text("Lobby"), + ), + ), + ], + ); + } + + Future _startProvisioning() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isSaving = true); + try { + final config = { + 'name': _nameCtrl.text.trim(), + 'netName': _netNameCtrl.text.trim(), + 'chan': _selectedChannel, + 'panId': int.parse(_panCtrl.text.replaceFirst('0x', ''), radix: 16), + 'extPan': _extPanCtrl.text.trim(), + 'netKey': _netKeyCtrl.text.trim(), + }; + + await context.read().provisionLeader(widget.device, config); + if (mounted) Navigator.pop(context); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Fehler: $e"), backgroundColor: Colors.red)); + } finally { + if (mounted) setState(() => _isSaving = false); + } + } + + String _generateRandomHex(int bytes) { + final rand = Random(); + return List.generate(bytes, (_) => rand.nextInt(256).toRadixString(16).padLeft(2, '0')).join().toUpperCase(); + } + + @override + void dispose() { + _nameCtrl.dispose(); + _netNameCtrl.dispose(); + _panCtrl.dispose(); + _extPanCtrl.dispose(); + _netKeyCtrl.dispose(); + super.dispose(); + } +} \ No newline at end of file diff --git a/software/app/lib/ui/widgets/rename_dialog.dart b/software/app/lib/ui/widgets/rename_dialog.dart index 7986177..f68d07b 100644 --- a/software/app/lib/ui/widgets/rename_dialog.dart +++ b/software/app/lib/ui/widgets/rename_dialog.dart @@ -15,18 +15,17 @@ class RenameDialog extends StatefulWidget { class _RenameDialogState extends State { late TextEditingController _controller; bool _isSaving = false; - bool _isReading = true; // Neu: Startet im Lade-Modus + late bool _isReading; // Nicht mehr direkt auf true setzen @override void initState() { super.initState(); _controller = TextEditingController(text: widget.device.name); - if (!widget.device.isNameVerified) { - // Wenn der Name bereits verifiziert ist, nicht von der Hardware lesen - _isReading = false; - } else { - _fetchHardwareName(); // Namen beim Öffnen abfragen - } + + // Wenn der Name schon verifiziert ist, müssen wir nicht blockierend laden + _isReading = !widget.device.isNameVerified; + + _fetchHardwareName(); } Future _fetchHardwareName() async {