From 261921b652afa0a990af908bc70097adfe8ca531 Mon Sep 17 00:00:00 2001 From: Eduard Iten Date: Tue, 13 Jan 2026 14:56:56 +0100 Subject: [PATCH] Lobby Screen --- firmware/libs/game_mgmt/src/game_mgmt.c | 3 +- software/app/lib/constants.dart | 18 +++-- .../app/lib/models/device_config_model.dart | 75 ++++++++++++++++--- software/app/lib/models/device_model.dart | 2 +- .../app/lib/providers/device_provider.dart | 60 ++++++++++----- .../ui/screens/device_selection_screen.dart | 8 +- .../lib/ui/screens/leader_config_screen.dart | 34 +++++++-- software/app/lib/ui/screens/lobby_screen.dart | 42 +++++++++++ .../lib/ui/widgets/leader_info_dialog.dart | 37 +++++++++ 9 files changed, 232 insertions(+), 47 deletions(-) create mode 100644 software/app/lib/ui/screens/lobby_screen.dart create mode 100644 software/app/lib/ui/widgets/leader_info_dialog.dart diff --git a/firmware/libs/game_mgmt/src/game_mgmt.c b/firmware/libs/game_mgmt/src/game_mgmt.c index 364ba63..68328e1 100644 --- a/firmware/libs/game_mgmt/src/game_mgmt.c +++ b/firmware/libs/game_mgmt/src/game_mgmt.c @@ -24,8 +24,9 @@ void game_mgmt_set_state(sys_state_t state) sys_state_t game_mgmt_get_state(void) { return current_state; } void game_mgmt_set_game_id(uint64_t id) { + if (current_game_id == id) return; current_game_id = id; - LOG_INF("Game ID updated: %llu", id); + LOG_INF("Game ID updated: 0x%llx", id); } uint64_t game_mgmt_get_game_id(void) { return current_game_id; } \ No newline at end of file diff --git a/software/app/lib/constants.dart b/software/app/lib/constants.dart index 2f08930..f0efee6 100644 --- a/software/app/lib/constants.dart +++ b/software/app/lib/constants.dart @@ -1,18 +1,22 @@ -class LasertagUUIDs { +class LasertagConfig { + // BLE UUID for services and characteristics static const String base = "03afe2cf-6c64-4a22-9289-c3ae820c"; static const String provService = "${base}1000"; static const String provNameChar = "${base}1001"; - // static const String provPanIdChar = "${base}1002"; - // static const String provChanChar = "${base}1003"; - // static const String provExtPanIdChar = "${base}1004"; - // static const String provNetKeyChar = "${base}1005"; - // static const String provNetNameChar = "${base}1006"; static const String provTypeChar = "${base}1008"; static const String provConfigChar = "${base}100c"; - // Gerätetypen aus deiner ble_mgmt.h + // Device types from ble_mgmt.h static const int typeLeader = 0x01; static const int typeWeapon = 0x02; static const int typeVest = 0x03; static const int typeBeacon = 0x04; + + // System states from game_mgmt.h + static const int sysStateNoChange = 0x00; + static const int sysStateIdle = 0x01; + static const int sysStateLobby = 0x02; + static const int sysStateStarting = 0x03; + static const int sysStateRunning = 0x04; + static const int sysStatePostGame = 0x05; } diff --git a/software/app/lib/models/device_config_model.dart b/software/app/lib/models/device_config_model.dart index 023fead..4ede2c2 100644 --- a/software/app/lib/models/device_config_model.dart +++ b/software/app/lib/models/device_config_model.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'dart:math'; class DeviceConfig { final int systemState; @@ -22,9 +23,30 @@ class DeviceConfig { required this.nodeName, }); + static BigInt generateRandom64BitId() { + final rand = Random.secure(); + final low = rand.nextInt(0xFFFFFFFF); + final high = rand.nextInt(0xFFFFFFFF); + return (BigInt.from(high) << 32) | BigInt.from(low); + } + + // Hilfsmethode für eine leere/Standard-Config + factory DeviceConfig.defaultConfig() { + return DeviceConfig( + systemState: 1, // IDLE + gameId: BigInt.from(0), + panId: 0xABCD, + channel: 15, + extPanId: "0011223344556677", + networkKey: "00112233445566778899AABBCCDDEEFF", + networkName: "LasertagMesh", + nodeName: "New Device", + ); + } + factory DeviceConfig.fromBytes(List bytes) { final data = ByteData.sublistView(Uint8List.fromList(bytes)); - + // Manuelles Decoding von 64-Bit (2x 32-Bit), da getUint64 nicht Standard ist final low = data.getUint32(1, Endian.little); final high = data.getUint32(5, Endian.little); @@ -47,14 +69,18 @@ class DeviceConfig { final data = ByteData.view(bytes.buffer); data.setUint8(0, systemState); - + // Encoding von 64-Bit BigInt in zwei 32-Bit Segmente - data.setUint32(1, (gameId & BigInt.from(0xFFFFFFFF)).toInt(), Endian.little); + data.setUint32( + 1, + (gameId & BigInt.from(0xFFFFFFFF)).toInt(), + Endian.little, + ); data.setUint32(5, (gameId >> 32).toInt(), Endian.little); data.setUint16(9, panId, Endian.little); data.setUint8(11, channel); - + _writeHexToBuffer(bytes, 12, extPanId); _writeHexToBuffer(bytes, 20, networkKey); _writeStringToBuffer(bytes, 36, 17, networkName); @@ -65,10 +91,17 @@ class DeviceConfig { static String _decodeString(List bytes) { int nullIdx = bytes.indexOf(0); - return utf8.decode(nullIdx == -1 ? bytes : bytes.sublist(0, nullIdx)).trim(); + return utf8 + .decode(nullIdx == -1 ? bytes : bytes.sublist(0, nullIdx)) + .trim(); } - static void _writeStringToBuffer(Uint8List buffer, int offset, int maxLen, String val) { + static void _writeStringToBuffer( + Uint8List buffer, + int offset, + int maxLen, + String val, + ) { final encoded = utf8.encode(val); for (int i = 0; i < maxLen - 1 && i < encoded.length; i++) { buffer[offset + i] = encoded[i]; @@ -76,12 +109,36 @@ class DeviceConfig { // Buffer ist bereits mit 0 initialisiert, Terminator steht also am Ende } - static String _bytesToHex(List bytes) => - bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join().toUpperCase(); + static String _bytesToHex(List bytes) => bytes + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toUpperCase(); static void _writeHexToBuffer(Uint8List buffer, int offset, String hex) { for (int i = 0; i < hex.length; i += 2) { buffer[offset + (i ~/ 2)] = int.parse(hex.substring(i, i + 2), radix: 16); } } -} \ No newline at end of file + + DeviceConfig copyWith({ + int? systemState, + BigInt? gameId, + int? panId, + int? channel, + String? extPanId, + String? networkKey, + String? networkName, + String? nodeName, + }) { + return DeviceConfig( + systemState: systemState ?? this.systemState, + gameId: gameId ?? this.gameId, + panId: panId ?? this.panId, + channel: channel ?? this.channel, + extPanId: extPanId ?? this.extPanId, + networkKey: networkKey ?? this.networkKey, + networkName: networkName ?? this.networkName, + nodeName: nodeName ?? this.nodeName, + ); + } +} diff --git a/software/app/lib/models/device_model.dart b/software/app/lib/models/device_model.dart index c3022db..730bafe 100644 --- a/software/app/lib/models/device_model.dart +++ b/software/app/lib/models/device_model.dart @@ -19,7 +19,7 @@ class LasertagDevice { this.isNameVerified = false, }); - bool get isLeader => type == LasertagUUIDs.typeLeader; + bool get isLeader => type == LasertagConfig.typeLeader; LasertagDevice copyWith({String? name, bool? isNameVerified}) { return LasertagDevice( diff --git a/software/app/lib/providers/device_provider.dart b/software/app/lib/providers/device_provider.dart index 1439c29..80c354e 100644 --- a/software/app/lib/providers/device_provider.dart +++ b/software/app/lib/providers/device_provider.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; @@ -14,11 +13,29 @@ class DeviceProvider extends ChangeNotifier { final List _discoveredLeaders = []; final List _discoveredPeripherals = []; StreamSubscription? _scanSubscription; + DeviceConfig? _activeConfig; + DeviceConfig? get activeConfig => _activeConfig; List get leaders => _discoveredLeaders; List get peripherals => _discoveredPeripherals; - // ... startScan bleibt gleich ... + void setActiveConfig(DeviceConfig config) { + _activeConfig = config; + notifyListeners(); + } + + Future provisionWithActiveConfig(LasertagDevice device, {bool keepName = true}) async { + if (_activeConfig == null) throw Exception("Keine aktive Konfiguration vorhanden"); + DeviceConfig configToUse = _activeConfig!; + if (keepName) { + configToUse = configToUse.copyWith( + nodeName: device.name, + systemState: LasertagConfig.sysStateLobby, + ); + } + await provisionDevice(device, configToUse); + } + void startScan() async { _discoveredLeaders.clear(); _discoveredPeripherals.clear(); @@ -28,7 +45,7 @@ class DeviceProvider extends ChangeNotifier { _scanSubscription?.cancel(); _scanSubscription = FlutterBluePlus.scanResults.listen((results) { for (ScanResult r in results) { - bool hasService = r.advertisementData.serviceUuids.contains(Guid(LasertagUUIDs.provService)); + bool hasService = r.advertisementData.serviceUuids.contains(Guid(LasertagConfig.provService)); if (!hasService) continue; final mfgData = r.advertisementData.manufacturerData[65535]; if (mfgData != null && mfgData.isNotEmpty) { @@ -53,8 +70,8 @@ class DeviceProvider extends ChangeNotifier { await ltDevice.btDevice.connect(license: License.free, timeout: const Duration(seconds: 5)); } List srv = await ltDevice.btDevice.discoverServices(); - var c = srv.firstWhere((s) => s.uuid == Guid(LasertagUUIDs.provService)) - .characteristics.firstWhere((c) => c.uuid == Guid(LasertagUUIDs.provNameChar)); + var c = srv.firstWhere((s) => s.uuid == Guid(LasertagConfig.provService)) + .characteristics.firstWhere((c) => c.uuid == Guid(LasertagConfig.provNameChar)); List value = await c.read(); String hardwareName = utf8.decode(value); updateDeviceName(ltDevice.id, hardwareName, verified: true); @@ -66,8 +83,8 @@ class DeviceProvider extends ChangeNotifier { try { await ltDevice.btDevice.connect(license: License.free, timeout: const Duration(seconds: 10)); List srv = await ltDevice.btDevice.discoverServices(); - var c = srv.firstWhere((s) => s.uuid == Guid(LasertagUUIDs.provService)) - .characteristics.firstWhere((c) => c.uuid == Guid(LasertagUUIDs.provNameChar)); + var c = srv.firstWhere((s) => s.uuid == Guid(LasertagConfig.provService)) + .characteristics.firstWhere((c) => c.uuid == Guid(LasertagConfig.provNameChar)); await c.write(utf8.encode(newName)); updateDeviceName(ltDevice.id, newName, verified: true); } finally { await ltDevice.btDevice.disconnect(); } @@ -80,20 +97,19 @@ class DeviceProvider extends ChangeNotifier { await device.btDevice.connect(license: License.free, timeout: const Duration(seconds: 10)); } List srv = await device.btDevice.discoverServices(); - var c = srv.firstWhere((s) => s.uuid == Guid(LasertagUUIDs.provService)) - .characteristics.firstWhere((c) => c.uuid == Guid(LasertagUUIDs.provConfigChar)); + var c = srv.firstWhere((s) => s.uuid == Guid(LasertagConfig.provService)) + .characteristics.firstWhere((c) => c.uuid == Guid(LasertagConfig.provConfigChar)); final bytes = await c.read(); return DeviceConfig.fromBytes(bytes); } finally { await device.btDevice.disconnect(); } } - Future provisionDevice(LasertagDevice device, DeviceConfig config) async { + Future provisionDevice(LasertagDevice device, DeviceConfig config, {bool keepConnected = false}) async { try { if (!device.btDevice.isConnected) { await device.btDevice.connect(license: License.free, timeout: const Duration(seconds: 10)); } - // MTU nur auf Android anfordern, auf macOS/iOS macht das das OS automatisch if (Platform.isAndroid) { try { await device.btDevice.requestMtu(250); @@ -102,13 +118,23 @@ class DeviceProvider extends ChangeNotifier { debugPrint("MTU Request failed (Android only): $e"); } } - + List srv = await device.btDevice.discoverServices(); - var c = srv.firstWhere((s) => s.uuid == Guid(LasertagUUIDs.provService)) - .characteristics.firstWhere((c) => c.uuid == Guid(LasertagUUIDs.provConfigChar)); + var c = srv + .firstWhere((s) => s.uuid == Guid(LasertagConfig.provService)) + .characteristics + .firstWhere((c) => c.uuid == Guid(LasertagConfig.provConfigChar)); + await c.write(config.toBytes(), allowLongWrite: true); updateDeviceName(device.id, config.nodeName, verified: true); - } finally { await device.btDevice.disconnect(); } + } catch (e) { + await device.btDevice.disconnect(); + rethrow; + } finally { + if (!keepConnected) { + await device.btDevice.disconnect(); + } + } } // ... Hilfsmethoden updateDeviceName, _addDeviceToLists, _verifyNameInBackground (mit license!), _sortList etc. bleiben ... @@ -131,8 +157,8 @@ class DeviceProvider extends ChangeNotifier { try { await device.btDevice.connect(license: License.free, timeout: const Duration(seconds: 5)); List services = await device.btDevice.discoverServices(); - var characteristic = services.firstWhere((s) => s.uuid == Guid(LasertagUUIDs.provService)) - .characteristics.firstWhere((c) => c.uuid == Guid(LasertagUUIDs.provNameChar)); + var characteristic = services.firstWhere((s) => s.uuid == Guid(LasertagConfig.provService)) + .characteristics.firstWhere((c) => c.uuid == Guid(LasertagConfig.provNameChar)); List value = await characteristic.read(); updateDeviceName(device.id, utf8.decode(value), verified: true); } catch (e) { diff --git a/software/app/lib/ui/screens/device_selection_screen.dart b/software/app/lib/ui/screens/device_selection_screen.dart index 9828395..63d1251 100644 --- a/software/app/lib/ui/screens/device_selection_screen.dart +++ b/software/app/lib/ui/screens/device_selection_screen.dart @@ -115,13 +115,13 @@ class _DeviceSelectionScreenState extends State { IconData _getIconForType(int type) { switch (type) { - case LasertagUUIDs.typeLeader: + case LasertagConfig.typeLeader: return Icons.hub; - case LasertagUUIDs.typeWeapon: + case LasertagConfig.typeWeapon: return Icons.flash_on; // Blitz für Waffe - case LasertagUUIDs.typeVest: + case LasertagConfig.typeVest: return Icons.accessibility_new; // Person für Weste - case LasertagUUIDs.typeBeacon: + case LasertagConfig.typeBeacon: return Icons.flag; // Flagge für Basis default: return Icons.device_unknown; diff --git a/software/app/lib/ui/screens/leader_config_screen.dart b/software/app/lib/ui/screens/leader_config_screen.dart index 635c35b..0e43040 100644 --- a/software/app/lib/ui/screens/leader_config_screen.dart +++ b/software/app/lib/ui/screens/leader_config_screen.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:lasertag_app/constants.dart'; import 'package:provider/provider.dart'; import 'dart:math'; import '../../models/device_model.dart'; import '../../models/device_config_model.dart'; import '../../providers/device_provider.dart'; +import "../screens/lobby_screen.dart"; class LeaderConfigScreen extends StatefulWidget { final LasertagDevice device; @@ -204,8 +206,10 @@ class _LeaderConfigScreenState extends State { try { // Neues Model-Objekt erstellen final newConfig = DeviceConfig( - systemState: _currentConfig.systemState, // Unverändert übernehmen - gameId: _currentConfig.gameId, // Unverändert übernehmen + systemState: LasertagConfig.sysStateLobby, // Immer Lobby setzen + gameId: _currentConfig.gameId == BigInt.from(0) + ? _generateRandom64BitId() + : _currentConfig.gameId, nodeName: _nameCtrl.text.trim(), networkName: _netNameCtrl.text.trim(), channel: _selectedChannel, @@ -214,14 +218,20 @@ class _LeaderConfigScreenState extends State { networkKey: _netKeyCtrl.text.trim(), ); - // Optimierte Methode im Provider aufrufen - await context.read().provisionDevice(widget.device, newConfig); - + await context.read().provisionDevice( + widget.device, + newConfig, + keepConnected: true, + ); + if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text("Konfiguration erfolgreich übertragen!")), + // Navigation zum Lobby Screen statt pop + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => LobbyScreen(leaderDevice: widget.device), + ), ); - Navigator.pop(context); } } catch (e) { if (mounted) { @@ -241,6 +251,14 @@ class _LeaderConfigScreenState extends State { .toUpperCase(); } + BigInt _generateRandom64BitId() { + final random = Random.secure(); // Sicherer Zufall + // Generiere zwei 32-Bit Werte und kombiniere sie + final low = random.nextInt(0xFFFFFFFF); + final high = random.nextInt(0xFFFFFFFF); + return (BigInt.from(high) << 32) | BigInt.from(low); +} + @override void dispose() { _nameCtrl.dispose(); diff --git a/software/app/lib/ui/screens/lobby_screen.dart b/software/app/lib/ui/screens/lobby_screen.dart new file mode 100644 index 0000000..6543dde --- /dev/null +++ b/software/app/lib/ui/screens/lobby_screen.dart @@ -0,0 +1,42 @@ +// lib/ui/screens/lobby_screen.dart +import 'package:flutter/material.dart'; +import '../../models/device_model.dart'; +import '../widgets/leader_info_dialog.dart'; + +class LobbyScreen extends StatelessWidget { + final LasertagDevice leaderDevice; + const LobbyScreen({super.key, required this.leaderDevice}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text("Spiel-Lobby")), + body: Column( + children: [ + // Leader-Karte mit interaktiver Info + Card( + margin: const EdgeInsets.all(16), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => showDialog( + context: context, + builder: (context) => LeaderInfoDialog(device: leaderDevice), + ), + child: ListTile( + leading: Icon(Icons.hub, color: Theme.of(context).colorScheme.primary), + title: Text(leaderDevice.name), + subtitle: const Text("Status: LOBBY (Tippen für Details)"), + trailing: const Icon(Icons.info_outline), + ), + ), + ), + const Divider(), + const Expanded( + child: Center(child: Text("Warten auf Teilnehmer...")), + ), + // Buttons zum Abbrechen oder Starten + ], + ), + ); + } +} \ No newline at end of file diff --git a/software/app/lib/ui/widgets/leader_info_dialog.dart b/software/app/lib/ui/widgets/leader_info_dialog.dart new file mode 100644 index 0000000..7dc11e7 --- /dev/null +++ b/software/app/lib/ui/widgets/leader_info_dialog.dart @@ -0,0 +1,37 @@ +// lib/ui/widgets/leader_info_dialog.dart +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../models/device_model.dart'; +import '../../models/device_config_model.dart'; +import '../../providers/device_provider.dart'; + +class LeaderInfoDialog extends StatelessWidget { + final LasertagDevice device; + const LeaderInfoDialog({super.key, required this.device}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text("Leader Details"), + content: FutureBuilder( + future: context.read().readDeviceConfig(device), + builder: (context, snapshot) { + if (!snapshot.hasData) return const CircularProgressIndicator(); + final cfg = snapshot.data!; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Game ID: 0x${cfg.gameId.toRadixString(16).toUpperCase()}"), + const Divider(), + Text("Netzwerk: ${cfg.networkName}"), + Text("Kanal: ${cfg.channel}"), + Text("PAN ID: 0x${cfg.panId.toRadixString(16).toUpperCase()}"), + ], + ); + }, + ), + actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text("Schließen"))], + ); + } +} \ No newline at end of file