From 9238be1a59a20a039d8f358691e98743bcb715e4 Mon Sep 17 00:00:00 2001 From: Janez T Date: Tue, 14 Oct 2025 21:11:54 +0200 Subject: [PATCH] feat: Enhance HomeScreen with RX/TX indicators and long press functionality - Added GestureDetector to RX/TX indicators for long press to open PacketLogScreen. - Refactored RX/TX indicator code for better readability. - Updated disconnect button to maintain functionality. feat: Extend MeshCoreBleService with new login and message handling features - Introduced new callbacks for device info, message waiting, login success, and login fail. - Implemented handling for new message waiting and login success/fail pushes. - Enhanced device info parsing to include additional fields such as firmware version, max contacts, and more. fix: Correct MeshCoreConstants response codes - Added new response codes for custom variables, advertisement path, and tuning parameters. --- lib/providers/connection_provider.dart | 86 ++ lib/screens/device_config_screen.dart | 1151 ++++++------------------ lib/screens/home_screen.dart | 167 ++-- lib/services/meshcore_ble_service.dart | 386 ++++++-- lib/services/meshcore_constants.dart | 3 + 5 files changed, 807 insertions(+), 986 deletions(-) diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 0c60d07..7998ef8 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -49,6 +49,8 @@ class ConnectionProvider with ChangeNotifier { Function(List)? onContactsComplete; Function(Message)? onMessageReceived; Function(Uint8List publicKey, Uint8List lppData)? onTelemetryReceived; + Function(Uint8List publicKeyPrefix, int permissions, bool isAdmin, int tag)? onLoginSuccess; + Function(Uint8List publicKeyPrefix)? onLoginFail; ConnectionProvider() { _initializeBleService(); @@ -112,6 +114,25 @@ class ConnectionProvider with ChangeNotifier { _noMoreMessages = true; }; + _bleService.onMessageWaiting = () { + print('๐Ÿ“ฅ [Provider] Received MsgWaiting push - auto-fetching messages'); + // Automatically fetch messages when push notification received + syncAllMessages(); + }; + + _bleService.onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) { + print('๐Ÿ“ฅ [Provider] Login successful to room'); + print(' Public key prefix: ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); + print(' Permissions: $permissions, Admin: $isAdmin, Tag: $tag'); + onLoginSuccess?.call(publicKeyPrefix, permissions, isAdmin, tag); + }; + + _bleService.onLoginFail = (publicKeyPrefix) { + print('๐Ÿ“ฅ [Provider] Login failed to room'); + print(' Public key prefix: ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); + onLoginFail?.call(publicKeyPrefix); + }; + _bleService.onSelfInfoReceived = (selfInfo) { print('๐Ÿ“ฅ [Provider] Received SelfInfo:'); print(' TX Power: ${selfInfo['txPower']} / ${selfInfo['maxTxPower']} dBm'); @@ -413,6 +434,32 @@ class ConnectionProvider with ChangeNotifier { } } + /// Set other parameters (telemetry modes, advert location policy) + Future setOtherParams({ + required int manualAddContacts, + required int telemetryModes, + required int advertLocationPolicy, + int multiAcks = 0, + }) async { + if (!_bleService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + await _bleService.setOtherParams( + manualAddContacts: manualAddContacts, + telemetryModes: telemetryModes, + advertLocationPolicy: advertLocationPolicy, + multiAcks: multiAcks, + ); + } catch (e) { + _error = 'Failed to set other params: $e'; + notifyListeners(); + } + } + /// Request fresh device info (triggers SelfInfo response) Future refreshDeviceInfo() async { if (!_bleService.isConnected) { @@ -490,6 +537,45 @@ class ConnectionProvider with ChangeNotifier { } } + /// Login to a room or repeater + /// + /// Sends login request with password. Results will be delivered via + /// onLoginSuccess or onLoginFail callbacks. + /// + /// Example usage: + /// ```dart + /// connectionProvider.onLoginSuccess = (pkPrefix, perms, isAdmin, tag) { + /// print('Successfully logged in to room!'); + /// }; + /// connectionProvider.onLoginFail = (pkPrefix) { + /// print('Login failed - incorrect password'); + /// }; + /// await connectionProvider.loginToRoom( + /// roomPublicKey: contact.publicKey, + /// password: 'secret123', + /// ); + /// ``` + Future loginToRoom({ + required Uint8List roomPublicKey, + required String password, + }) async { + if (!_bleService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + await _bleService.loginToRoom( + roomPublicKey: roomPublicKey, + password: password, + ); + } catch (e) { + _error = 'Failed to send login request: $e'; + notifyListeners(); + } + } + /// Clear error message void clearError() { _error = null; diff --git a/lib/screens/device_config_screen.dart b/lib/screens/device_config_screen.dart index facaf6d..355b979 100644 --- a/lib/screens/device_config_screen.dart +++ b/lib/screens/device_config_screen.dart @@ -18,7 +18,7 @@ class _DeviceConfigScreenState extends State { late TextEditingController _freqController; late TextEditingController _txPowerController; - bool _sharePosition = false; + bool _telemetryEnabled = false; String _selectedBandwidth = '62.5 kHz'; int _selectedSpreadingFactor = 8; int _selectedCodingRate = 8; @@ -42,7 +42,7 @@ class _DeviceConfigScreenState extends State { final deviceInfo = context.read().deviceInfo; _nameController = TextEditingController( - text: deviceInfo.selfName ?? deviceInfo.displayName ?? '', + text: deviceInfo.selfName ?? deviceInfo.deviceName ?? '', ); _latController = TextEditingController( text: deviceInfo.advLat != null ? (deviceInfo.advLat! / 1000000).toStringAsFixed(6) : '0.0', @@ -60,26 +60,16 @@ class _DeviceConfigScreenState extends State { if (deviceInfo.radioBw != null && deviceInfo.radioBw! >= 0 && deviceInfo.radioBw! <= 9) { _selectedBandwidth = _bandwidthFromValue(deviceInfo.radioBw!); } - if (deviceInfo.radioSf != null) { - // Validate spreading factor is in valid range (7-12) - if (deviceInfo.radioSf! >= 7 && deviceInfo.radioSf! <= 12) { - _selectedSpreadingFactor = deviceInfo.radioSf!; - } else { - debugPrint('โš ๏ธ Invalid spreading factor from device: ${deviceInfo.radioSf}. Using default: 8'); - _selectedSpreadingFactor = 8; - } + if (deviceInfo.radioSf != null && deviceInfo.radioSf! >= 7 && deviceInfo.radioSf! <= 12) { + _selectedSpreadingFactor = deviceInfo.radioSf!; } - if (deviceInfo.radioCr != null) { - // Validate coding rate is in valid range (5-8) - if (deviceInfo.radioCr! >= 5 && deviceInfo.radioCr! <= 8) { - _selectedCodingRate = deviceInfo.radioCr!; - } else { - debugPrint('โš ๏ธ Invalid coding rate from device: ${deviceInfo.radioCr}. Using default: 8'); - _selectedCodingRate = 8; - } + if (deviceInfo.radioCr != null && deviceInfo.radioCr! >= 5 && deviceInfo.radioCr! <= 8) { + _selectedCodingRate = deviceInfo.radioCr!; } - _sharePosition = (deviceInfo.advLat != null && deviceInfo.advLat! != 0) || - (deviceInfo.advLon != null && deviceInfo.advLon! != 0); + + // Check if telemetry is enabled (check if lat/lon are set and not zero) + _telemetryEnabled = (deviceInfo.advLat != null && deviceInfo.advLat! != 0) || + (deviceInfo.advLon != null && deviceInfo.advLon! != 0); } @override @@ -93,7 +83,6 @@ class _DeviceConfigScreenState extends State { } String _bandwidthFromValue(int bw) { - // Convert bandwidth value to display string switch (bw) { case 0: return '7.8 kHz'; case 1: return '10.4 kHz'; @@ -115,6 +104,7 @@ class _DeviceConfigScreenState extends State { Future _savePublicInfo() async { final connectionProvider = context.read(); + final deviceInfo = connectionProvider.deviceInfo; try { // Save name @@ -122,20 +112,36 @@ class _DeviceConfigScreenState extends State { await connectionProvider.setAdvertName(_nameController.text); } - // Save position if share position is enabled - if (_sharePosition) { + // Save position and telemetry settings + if (_telemetryEnabled) { final lat = double.tryParse(_latController.text) ?? 0.0; final lon = double.tryParse(_lonController.text) ?? 0.0; await connectionProvider.setAdvertLatLon( latitude: lat, longitude: lon, ); + + // Set telemetry modes to "Allow All" (mode 2 for both base and location) + final telemetryModes = 0x0A; // binary: 00001010 (base=2, location=2) + await connectionProvider.setOtherParams( + manualAddContacts: deviceInfo.manualAddContacts == true ? 1 : 0, + telemetryModes: telemetryModes, + advertLocationPolicy: 1, + ); } else { // Clear position await connectionProvider.setAdvertLatLon( latitude: 0.0, longitude: 0.0, ); + + // Set telemetry modes to "Deny" (mode 0) + final telemetryModes = 0x00; + await connectionProvider.setOtherParams( + manualAddContacts: deviceInfo.manualAddContacts == true ? 1 : 0, + telemetryModes: telemetryModes, + advertLocationPolicy: 0, + ); } if (context.mounted) { @@ -150,7 +156,7 @@ class _DeviceConfigScreenState extends State { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to save public info: $e'), + content: Text('Failed to save: $e'), backgroundColor: Colors.red, ), ); @@ -162,7 +168,7 @@ class _DeviceConfigScreenState extends State { final connectionProvider = context.read(); try { - // Parse and save frequency (convert from MHz to Hz) + // Parse and save frequency (convert from MHz to kHz) final freq = (double.tryParse(_freqController.text) ?? 869.618) * 1000; await connectionProvider.setRadioParams( @@ -188,7 +194,7 @@ class _DeviceConfigScreenState extends State { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to save radio settings: $e'), + content: Text('Failed to save: $e'), backgroundColor: Colors.red, ), ); @@ -196,144 +202,25 @@ class _DeviceConfigScreenState extends State { } } - Future _refreshDeviceInfo() async { - final connectionProvider = context.read(); - - try { - await connectionProvider.refreshDeviceInfo(); - - if (context.mounted) { - // Update controllers with fresh data - final deviceInfo = connectionProvider.deviceInfo; - setState(() { - if (deviceInfo.selfName != null) { - _nameController.text = deviceInfo.selfName!; - } - if (deviceInfo.advLat != null) { - _latController.text = (deviceInfo.advLat! / 1000000).toStringAsFixed(6); - } - if (deviceInfo.advLon != null) { - _lonController.text = (deviceInfo.advLon! / 1000000).toStringAsFixed(6); - } - if (deviceInfo.radioFreq != null) { - _freqController.text = (deviceInfo.radioFreq! / 1000).toStringAsFixed(3); - } - if (deviceInfo.txPower != null) { - _txPowerController.text = deviceInfo.txPower.toString(); - } - if (deviceInfo.radioBw != null && deviceInfo.radioBw! >= 0 && deviceInfo.radioBw! <= 9) { - _selectedBandwidth = _bandwidthFromValue(deviceInfo.radioBw!); - } - if (deviceInfo.radioSf != null) { - // Validate spreading factor is in valid range (7-12) - if (deviceInfo.radioSf! >= 7 && deviceInfo.radioSf! <= 12) { - _selectedSpreadingFactor = deviceInfo.radioSf!; - } else { - debugPrint('โš ๏ธ Invalid spreading factor from device: ${deviceInfo.radioSf}. Using default: 8'); - _selectedSpreadingFactor = 8; - } - } - if (deviceInfo.radioCr != null) { - // Validate coding rate is in valid range (5-8) - if (deviceInfo.radioCr! >= 5 && deviceInfo.radioCr! <= 8) { - _selectedCodingRate = deviceInfo.radioCr!; - } else { - debugPrint('โš ๏ธ Invalid coding rate from device: ${deviceInfo.radioCr}. Using default: 8'); - _selectedCodingRate = 8; - } - } - _sharePosition = (deviceInfo.advLat != null && deviceInfo.advLat! != 0) || - (deviceInfo.advLon != null && deviceInfo.advLon! != 0); - }); - - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Device info refreshed'), - backgroundColor: Colors.green, - ), - ); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to refresh: $e'), - backgroundColor: Colors.red, - ), - ); - } - } - } - - String _getDeviceTypeString(int? deviceType) { - if (deviceType == null) return 'Unknown'; - switch (deviceType) { - case 0: - return 'None/Unknown'; - case 1: - return 'Chat Node'; - case 2: - return 'Repeater'; - case 3: - return 'Room/Channel Server'; - default: - return 'Type $deviceType'; - } - } - - String _getTelemetryModesString(deviceInfo) { - if (deviceInfo.telemetryModes == null) return 'Unknown'; - - final telemetryModes = deviceInfo.telemetryModes!; - final baseMode = telemetryModes & 0x03; // bits 0-1 - final locationMode = (telemetryModes >> 2) & 0x03; // bits 2-3 - - String baseModeStr = _getTelemetryModeString(baseMode); - String locationModeStr = _getTelemetryModeString(locationMode); - - return 'Base: $baseModeStr, Loc: $locationModeStr'; - } - - String _getTelemetryModeString(int mode) { - switch (mode) { - case 0: - return 'Deny'; - case 1: - return 'By Contact'; - case 2: - return 'Allow All'; - default: - return 'Unknown'; - } - } - Future _useCurrentLocation() async { try { - // Check if location services are enabled bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Location services are disabled'), - backgroundColor: Colors.orange, - ), + const SnackBar(content: Text('Location services disabled')), ); } return; } - // Check location permission LocationPermission permission = await Geolocator.checkPermission(); if (permission == LocationPermission.denied) { permission = await Geolocator.requestPermission(); if (permission == LocationPermission.denied) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Location permission denied'), - backgroundColor: Colors.red, - ), + const SnackBar(content: Text('Location permission denied')), ); } return; @@ -343,16 +230,12 @@ class _DeviceConfigScreenState extends State { if (permission == LocationPermission.deniedForever) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Location permission permanently denied'), - backgroundColor: Colors.red, - ), + const SnackBar(content: Text('Location permission permanently denied')), ); } return; } - // Get current position Position position = await Geolocator.getCurrentPosition( locationSettings: const LocationSettings( accuracy: LocationAccuracy.best, @@ -362,7 +245,7 @@ class _DeviceConfigScreenState extends State { setState(() { _latController.text = position.latitude.toStringAsFixed(6); _lonController.text = position.longitude.toStringAsFixed(6); - _sharePosition = true; + _telemetryEnabled = true; }); if (context.mounted) { @@ -376,10 +259,7 @@ class _DeviceConfigScreenState extends State { } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to get location: $e'), - backgroundColor: Colors.red, - ), + SnackBar(content: Text('Failed to get location: $e')), ); } } @@ -388,223 +268,37 @@ class _DeviceConfigScreenState extends State { @override Widget build(BuildContext context) { final deviceInfo = context.watch().deviceInfo; - final publicKeyHex = deviceInfo.publicKey != null - ? deviceInfo.publicKey! - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join('') - : ''; - final publicKeyShort = publicKeyHex.isNotEmpty && publicKeyHex.length >= 16 - ? '${publicKeyHex.substring(0, 8)}...${publicKeyHex.substring(publicKeyHex.length - 8)}' - : 'unknown'; - final theme = Theme.of(context); - final isDark = theme.brightness == Brightness.dark; return Scaffold( - backgroundColor: isDark ? const Color(0xFF121212) : const Color(0xFFF5F5F5), appBar: AppBar( - elevation: 0, - backgroundColor: Colors.transparent, - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.pop(context), - ), title: const Text('Device Settings'), - centerTitle: false, ), body: ListView( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.all(16), children: [ - const SizedBox(height: 8), - - // Device Card - Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: theme.cardColor, - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - ), - child: Column( - children: [ - // Device Avatar - Container( - width: 100, - height: 100, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - Colors.purple.shade400, - Colors.purple.shade700, - ], - ), - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: Colors.purple.withOpacity(0.3), - blurRadius: 20, - offset: const Offset(0, 8), - ), - ], - ), - child: Center( - child: Text( - deviceInfo.displayName?.substring(0, 1).toUpperCase() ?? 'M', - style: const TextStyle( - fontSize: 42, - fontWeight: FontWeight.bold, - color: Colors.white, - ), + // Device Info Card + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Device Information', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, ), ), - ), - const SizedBox(height: 16), - - // Device Name - Text( - deviceInfo.displayName ?? 'MeshCore Device', - style: theme.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - - // Public Key Chip - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: isDark - ? Colors.grey.shade800 - : Colors.grey.shade200, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.fingerprint, - size: 16, - color: theme.colorScheme.primary, - ), - const SizedBox(width: 6), - Text( - publicKeyShort, - style: TextStyle( - fontSize: 12, - fontFamily: 'monospace', - color: theme.textTheme.bodyMedium?.color, - ), - ), - ], - ), - ), - ], - ), - ), - - const SizedBox(height: 24), - - // Device Information Section (Read-only) - _SectionHeader( - title: 'Device Information', - trailing: IconButton( - icon: const Icon(Icons.info_outline), - onPressed: () { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Device Information'), - content: const Text( - 'This information is provided by the MeshCore device ' - 'and cannot be edited. Tap refresh to update.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('GOT IT'), - ), - ], - ), - ); - }, - iconSize: 20, - ), - ), - - _SettingTile( - icon: Icons.numbers, - label: 'Device Type', - isFirst: true, - trailing: Text( - _getDeviceTypeString(deviceInfo.deviceType), - style: const TextStyle( - fontWeight: FontWeight.w500, - fontSize: 14, - ), - ), - ), - - _SettingTile( - icon: Icons.groups, - label: 'Max Contacts', - trailing: Text( - deviceInfo.maxContacts != null - ? deviceInfo.maxContacts.toString() - : 'Unknown', - style: const TextStyle( - fontWeight: FontWeight.w500, - fontSize: 14, - ), - ), - ), - - _SettingTile( - icon: Icons.tag, - label: 'Max Channels', - trailing: Text( - deviceInfo.maxChannels != null - ? deviceInfo.maxChannels.toString() - : 'Unknown', - style: const TextStyle( - fontWeight: FontWeight.w500, - fontSize: 14, - ), - ), - ), - - _SettingTile( - icon: Icons.settings_suggest, - label: 'Telemetry Modes', - trailing: Text( - _getTelemetryModesString(deviceInfo), - style: const TextStyle( - fontWeight: FontWeight.w500, - fontSize: 13, - ), - ), - ), - - _SettingTile( - icon: Icons.group_add, - label: 'Manual Add Contacts', - isLast: true, - trailing: Text( - deviceInfo.manualAddContacts == true ? 'Enabled' : 'Disabled', - style: const TextStyle( - fontWeight: FontWeight.w500, - fontSize: 14, + const SizedBox(height: 16), + _InfoRow('BLE Name', deviceInfo.deviceName ?? 'Unknown'), + _InfoRow('Mesh Name', deviceInfo.selfName ?? 'Not set'), + _InfoRow('Type', _getDeviceTypeString(deviceInfo.deviceType)), + _InfoRow('Firmware', deviceInfo.firmwareVersion?.toString() ?? 'Unknown'), + _InfoRow('Max Contacts', deviceInfo.maxContacts?.toString() ?? 'Unknown'), + _InfoRow('Max Channels', deviceInfo.maxChannels?.toString() ?? 'Unknown'), + _InfoRow('Public Key', _getPublicKeyShort(deviceInfo.publicKey)), + ], ), ), ), @@ -612,542 +306,283 @@ class _DeviceConfigScreenState extends State { const SizedBox(height: 24), // Public Info Section - _SectionHeader( - title: 'Public Info', - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.refresh), - onPressed: _refreshDeviceInfo, - tooltip: 'Refresh', - iconSize: 20, - ), - IconButton( - icon: const Icon(Icons.check), - onPressed: _savePublicInfo, - tooltip: 'Save', - iconSize: 20, - ), - ], - ), - ), - - _SettingTile( - icon: Icons.person, - label: 'Name', - isFirst: true, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.3), - borderRadius: BorderRadius.circular(8), - ), - child: TextField( - controller: _nameController, - decoration: const InputDecoration( - border: InputBorder.none, - hintText: 'Device name', - isDense: true, - contentPadding: EdgeInsets.zero, - ), - textAlign: TextAlign.end, - style: const TextStyle( - fontWeight: FontWeight.w500, - fontSize: 14, - ), - ), - ), - ), - - _SettingTile( - icon: Icons.key, - label: 'Public Key', - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - publicKeyHex.substring(0, 32.clamp(0, publicKeyHex.length)), - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontFamily: 'monospace', - fontSize: 12, - ), - ), - ), - const SizedBox(width: 4), - Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(20), - onTap: () { - Clipboard.setData(ClipboardData(text: publicKeyHex)); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Public key copied'), - duration: Duration(seconds: 1), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Public Info', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, ), - ); + ), + ElevatedButton.icon( + onPressed: _savePublicInfo, + icon: const Icon(Icons.save, size: 18), + label: const Text('Save'), + ), + ], + ), + const SizedBox(height: 16), + + // Mesh Network Name + TextField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Mesh Network Name', + border: OutlineInputBorder(), + helperText: 'Name broadcast in mesh advertisements', + ), + ), + const SizedBox(height: 16), + + // Telemetry Toggle + SwitchListTile( + title: const Text('Enable Telemetry & Location Sharing'), + subtitle: const Text('Allow others to query your location and telemetry'), + value: _telemetryEnabled, + onChanged: (value) { + setState(() { + _telemetryEnabled = value; + }); }, - child: Padding( - padding: const EdgeInsets.all(8), - child: Icon( - Icons.copy, - size: 18, - color: Theme.of(context).colorScheme.primary, - ), - ), ), - ), - ], - ), - ), + const SizedBox(height: 16), - _SettingTile( - icon: Icons.location_on, - label: 'Latitude', - trailing: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.3), - borderRadius: BorderRadius.circular(8), - ), - child: IntrinsicWidth( - child: TextField( - controller: _latController, - decoration: const InputDecoration( - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.zero, - ), - textAlign: TextAlign.center, - style: const TextStyle( - fontWeight: FontWeight.w500, - fontSize: 13, - ), - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - signed: true, - ), - ), - ), - ), - ), - - _SettingTile( - icon: null, - label: 'Longitude', - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.3), - borderRadius: BorderRadius.circular(8), - ), - child: IntrinsicWidth( - child: TextField( - controller: _lonController, - decoration: const InputDecoration( - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.zero, - ), - textAlign: TextAlign.center, - style: const TextStyle( - fontWeight: FontWeight.w500, - fontSize: 13, - ), - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - signed: true, - ), - ), - ), - ), - const SizedBox(width: 8), - Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(20), - onTap: _useCurrentLocation, - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, - shape: BoxShape.circle, - ), - child: Icon( - Icons.my_location, - size: 16, - color: Theme.of(context).colorScheme.onPrimaryContainer, - ), - ), - ), - ), - ], - ), - ), - - _SettingTile( - icon: Icons.wifi_tethering, - label: 'Share Position', - isLast: true, - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Switch( - value: _sharePosition, - onChanged: (value) { - setState(() { - _sharePosition = value; - }); - }, - ), - Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(20), - onTap: () { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Share Position'), - content: const Text( - 'When enabled, your device will broadcast its GPS coordinates ' - 'to other devices in the mesh network.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('GOT IT'), + // GPS Coordinates (only show if telemetry enabled) + if (_telemetryEnabled) ...[ + Row( + children: [ + Expanded( + child: TextField( + controller: _latController, + decoration: const InputDecoration( + labelText: 'Latitude', + border: OutlineInputBorder(), ), - ], + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + signed: true, + ), + ), ), - ); - }, - child: Padding( - padding: const EdgeInsets.all(8), - child: Icon( - Icons.info_outline, - size: 18, - color: Theme.of(context).colorScheme.primary.withOpacity(0.7), + const SizedBox(width: 16), + Expanded( + child: TextField( + controller: _lonController, + decoration: const InputDecoration( + labelText: 'Longitude', + border: OutlineInputBorder(), + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + signed: true, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + ElevatedButton.icon( + onPressed: _useCurrentLocation, + icon: const Icon(Icons.my_location), + label: const Text('Use Current Location'), + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 48), ), ), - ), - ), - ], + ], + ], + ), ), ), const SizedBox(height: 24), // Radio Settings Section - _SectionHeader( - title: 'Radio Settings', - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - OutlinedButton( - onPressed: () { - // TODO: Show preset selection dialog - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Preset selection coming soon')), - ); - }, - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - minimumSize: Size.zero, + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Radio Settings', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ElevatedButton.icon( + onPressed: _saveRadioSettings, + icon: const Icon(Icons.save, size: 18), + label: const Text('Save'), + ), + ], ), - child: const Text('Choose Preset', style: TextStyle(fontSize: 12)), - ), - IconButton( - icon: const Icon(Icons.check), - onPressed: _saveRadioSettings, - tooltip: 'Save', - iconSize: 20, - ), - ], - ), - ), + const SizedBox(height: 16), - _SettingTile( - icon: Icons.radio, - label: 'Frequency (MHz)', - isFirst: true, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.3), - borderRadius: BorderRadius.circular(8), - ), - child: TextField( - controller: _freqController, - decoration: const InputDecoration( - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.zero, - ), - textAlign: TextAlign.end, - style: const TextStyle( - fontWeight: FontWeight.w500, - fontSize: 14, - ), - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), + // LoRa Frequency + TextField( + controller: _freqController, + decoration: const InputDecoration( + labelText: 'Frequency (MHz)', + border: OutlineInputBorder(), + helperText: 'e.g., 869.618', + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + ), + const SizedBox(height: 16), + + // Bandwidth + DropdownButtonFormField( + value: _selectedBandwidth, + decoration: const InputDecoration( + labelText: 'Bandwidth', + border: OutlineInputBorder(), + ), + items: _bandwidthOptions.map((String value) { + return DropdownMenuItem( + value: value, + child: Text(value), + ); + }).toList(), + onChanged: (String? newValue) { + if (newValue != null) { + setState(() { + _selectedBandwidth = newValue; + }); + } + }, + ), + const SizedBox(height: 16), + + // Spreading Factor + DropdownButtonFormField( + value: _selectedSpreadingFactor, + decoration: const InputDecoration( + labelText: 'Spreading Factor', + border: OutlineInputBorder(), + ), + items: List.generate(6, (index) => index + 7).map((int value) { + return DropdownMenuItem( + value: value, + child: Text(value.toString()), + ); + }).toList(), + onChanged: (int? newValue) { + if (newValue != null) { + setState(() { + _selectedSpreadingFactor = newValue; + }); + } + }, + ), + const SizedBox(height: 16), + + // Coding Rate + DropdownButtonFormField( + value: _selectedCodingRate, + decoration: const InputDecoration( + labelText: 'Coding Rate', + border: OutlineInputBorder(), + ), + items: List.generate(4, (index) => index + 5).map((int value) { + return DropdownMenuItem( + value: value, + child: Text(value.toString()), + ); + }).toList(), + onChanged: (int? newValue) { + if (newValue != null) { + setState(() { + _selectedCodingRate = newValue; + }); + } + }, + ), + const SizedBox(height: 16), + + // TX Power + TextField( + controller: _txPowerController, + decoration: InputDecoration( + labelText: 'TX Power (dBm)', + border: const OutlineInputBorder(), + helperText: 'Max: ${deviceInfo.maxTxPower ?? 22} dBm', + ), + keyboardType: TextInputType.number, + ), + ], ), ), ), - _SettingTile( - icon: Icons.graphic_eq, - label: 'Bandwidth', - trailing: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.3), - borderRadius: BorderRadius.circular(8), - ), - child: DropdownButton( - value: _selectedBandwidth, - underline: const SizedBox(), - isDense: true, - style: TextStyle( - fontWeight: FontWeight.w500, - fontSize: 13, - color: Theme.of(context).textTheme.bodyLarge?.color, - ), - items: _bandwidthOptions.map((String value) { - return DropdownMenuItem( - value: value, - child: Text(value), - ); - }).toList(), - onChanged: (String? newValue) { - if (newValue != null) { - setState(() { - _selectedBandwidth = newValue; - }); - } - }, - ), - ), - ), - - _SettingTile( - icon: Icons.layers, - label: 'Spreading Factor', - trailing: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.3), - borderRadius: BorderRadius.circular(8), - ), - child: DropdownButton( - value: _selectedSpreadingFactor, - underline: const SizedBox(), - isDense: true, - style: TextStyle( - fontWeight: FontWeight.w500, - fontSize: 13, - color: Theme.of(context).textTheme.bodyLarge?.color, - ), - items: List.generate(6, (index) => index + 7).map((int value) { - return DropdownMenuItem( - value: value, - child: Text(value.toString()), - ); - }).toList(), - onChanged: (int? newValue) { - if (newValue != null) { - setState(() { - _selectedSpreadingFactor = newValue; - }); - } - }, - ), - ), - ), - - _SettingTile( - icon: Icons.data_usage, - label: 'Coding Rate', - trailing: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.3), - borderRadius: BorderRadius.circular(8), - ), - child: DropdownButton( - value: _selectedCodingRate, - underline: const SizedBox(), - isDense: true, - style: TextStyle( - fontWeight: FontWeight.w500, - fontSize: 13, - color: Theme.of(context).textTheme.bodyLarge?.color, - ), - items: List.generate(4, (index) => index + 5).map((int value) { - return DropdownMenuItem( - value: value, - child: Text(value.toString()), - ); - }).toList(), - onChanged: (int? newValue) { - if (newValue != null) { - setState(() { - _selectedCodingRate = newValue; - }); - } - }, - ), - ), - ), - - _SettingTile( - icon: Icons.power, - label: 'Transmit Power (dBm)', - isLast: true, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.3), - borderRadius: BorderRadius.circular(8), - ), - child: TextField( - controller: _txPowerController, - decoration: const InputDecoration( - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.zero, - ), - textAlign: TextAlign.end, - style: const TextStyle( - fontWeight: FontWeight.w500, - fontSize: 14, - ), - keyboardType: TextInputType.number, - ), - ), - ), - - const SizedBox(height: 32), + const SizedBox(height: 24), ], ), ); } -} -class _SectionHeader extends StatelessWidget { - final String title; - final VoidCallback? onMorePressed; - final Widget? trailing; + String _getDeviceTypeString(int? deviceType) { + if (deviceType == null) return 'Unknown'; + switch (deviceType) { + case 0: return 'None/Unknown'; + case 1: return 'Chat Node'; + case 2: return 'Repeater'; + case 3: return 'Room/Channel'; + default: return 'Type $deviceType'; + } + } - const _SectionHeader({ - required this.title, - this.onMorePressed, - this.trailing, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Container( - padding: const EdgeInsets.fromLTRB(4, 16, 4, 8), - child: Row( - children: [ - Text( - title, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - letterSpacing: 0.5, - ), - ), - const Spacer(), - if (trailing != null) - trailing! - else if (onMorePressed != null) - IconButton( - icon: const Icon(Icons.more_vert), - onPressed: onMorePressed, - iconSize: 20, - ), - ], - ), - ); + String _getPublicKeyShort(List? publicKey) { + if (publicKey == null || publicKey.isEmpty) return 'N/A'; + final hex = publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); + if (hex.length >= 16) { + return '${hex.substring(0, 8)}...${hex.substring(hex.length - 8)}'; + } + return hex; } } -class _SettingTile extends StatelessWidget { - final IconData? icon; +class _InfoRow extends StatelessWidget { final String label; - final Widget? child; - final Widget? trailing; - final bool isFirst; - final bool isLast; + final String value; - const _SettingTile({ - this.icon, - required this.label, - this.child, - this.trailing, - this.isFirst = false, - this.isLast = false, - }); + const _InfoRow(this.label, this.value); @override Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Container( - margin: EdgeInsets.only( - top: isFirst ? 8 : 0, - bottom: isLast ? 0 : 1, - ), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - decoration: BoxDecoration( - color: theme.cardColor, - borderRadius: BorderRadius.vertical( - top: isFirst ? const Radius.circular(12) : Radius.zero, - bottom: isLast ? const Radius.circular(12) : Radius.zero, - ), - ), + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (icon != null) ...[ - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: theme.colorScheme.primary.withOpacity(0.1), - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - icon, - size: 18, - color: theme.colorScheme.primary, - ), - ), - const SizedBox(width: 12), - ] else - const SizedBox(width: 12), - Expanded( + SizedBox( + width: 120, child: Text( label, - style: theme.textTheme.bodyMedium?.copyWith( + style: const TextStyle( fontWeight: FontWeight.w500, + color: Colors.grey, + ), + ), + ), + Expanded( + child: Text( + value, + style: const TextStyle( + fontWeight: FontWeight.w600, ), ), ), - if (child != null) - Flexible( - child: child!, - ) - else if (trailing != null) - trailing!, ], ), ); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 0aca629..e567139 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -362,68 +362,7 @@ class _HomeScreenState extends State with SingleTickerProviderStateM Row( mainAxisSize: MainAxisSize.min, children: [ - // RX indicator - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: provider.rxActivity - ? Colors.green - : Colors.grey.withOpacity(0.3), - ), - ), - const SizedBox(width: 4), - Text( - 'RX:${provider.rxPacketCount}', - style: const TextStyle( - fontSize: 11, - color: Colors.grey, - ), - ), - const SizedBox(width: 12), - // TX indicator - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: provider.txActivity - ? Colors.blue - : Colors.grey.withOpacity(0.3), - ), - ), - const SizedBox(width: 4), - Text( - 'TX:${provider.txPacketCount}', - style: const TextStyle( - fontSize: 11, - color: Colors.grey, - ), - ), - const SizedBox(width: 12), - // Settings button - IconButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const DeviceConfigScreen(), - ), - ); - }, - icon: const Icon(Icons.settings), - iconSize: 20, - tooltip: 'Device Settings', - constraints: const BoxConstraints( - minWidth: 36, - minHeight: 36, - ), - padding: EdgeInsets.zero, - ), - const SizedBox(width: 8), - // Disconnect button (prominent, icon only) - // Long press to open packet log viewer + // RX/TX indicators with long press to open packet log GestureDetector( onLongPress: () { Navigator.push( @@ -435,23 +374,97 @@ class _HomeScreenState extends State with SingleTickerProviderStateM ), ); }, - child: FilledButton( - onPressed: () async { - await provider.disconnect(); - if (context.mounted) { - context.read().clearAllData(); - } - }, - style: FilledButton.styleFrom( - backgroundColor: Colors.red.shade700, - foregroundColor: Colors.white, - padding: const EdgeInsets.all(10), - minimumSize: const Size(40, 40), - shape: const CircleBorder(), - ), - child: const Icon(Icons.power_settings_new, size: 20), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // RX indicator + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: provider.rxActivity + ? Colors.green + : Colors.grey.withOpacity(0.3), + ), + ), + const SizedBox(width: 4), + Text( + 'RX:${provider.rxPacketCount}', + style: const TextStyle( + fontSize: 11, + color: Colors.grey, + ), + ), + const SizedBox(width: 12), + // TX indicator + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: provider.txActivity + ? Colors.blue + : Colors.grey.withOpacity(0.3), + ), + ), + const SizedBox(width: 4), + Text( + 'TX:${provider.txPacketCount}', + style: const TextStyle( + fontSize: 11, + color: Colors.grey, + ), + ), + ], ), ), + const SizedBox(width: 12), + // Settings button (tap for device settings, long press for packet logs) + GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const DeviceConfigScreen(), + ), + ); + }, + onLongPress: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PacketLogScreen( + bleService: provider.bleService, + ), + ), + ); + }, + child: Container( + width: 36, + height: 36, + alignment: Alignment.center, + child: const Icon(Icons.settings, size: 20), + ), + ), + const SizedBox(width: 8), + // Disconnect button (prominent, icon only) + FilledButton( + onPressed: () async { + await provider.disconnect(); + if (context.mounted) { + context.read().clearAllData(); + } + }, + style: FilledButton.styleFrom( + backgroundColor: Colors.red.shade700, + foregroundColor: Colors.white, + padding: const EdgeInsets.all(10), + minimumSize: const Size(40, 40), + shape: const CircleBorder(), + ), + child: const Icon(Icons.power_settings_new, size: 20), + ), ], ), ], diff --git a/lib/services/meshcore_ble_service.dart b/lib/services/meshcore_ble_service.dart index 6d9141a..1e45bf0 100644 --- a/lib/services/meshcore_ble_service.dart +++ b/lib/services/meshcore_ble_service.dart @@ -17,7 +17,11 @@ typedef OnContactsCompleteCallback = void Function(List contacts); typedef OnMessageCallback = void Function(Message message); typedef OnTelemetryCallback = void Function(Uint8List publicKey, Uint8List lppData); typedef OnSelfInfoCallback = void Function(Map selfInfo); +typedef OnDeviceInfoCallback = void Function(Map deviceInfo); typedef OnNoMoreMessagesCallback = void Function(); +typedef OnMessageWaitingCallback = void Function(); +typedef OnLoginSuccessCallback = void Function(Uint8List publicKeyPrefix, int permissions, bool isAdmin, int tag); +typedef OnLoginFailCallback = void Function(Uint8List publicKeyPrefix); typedef OnErrorCallback = void Function(String error); typedef OnConnectionStateCallback = void Function(bool isConnected); @@ -35,7 +39,11 @@ class MeshCoreBleService { OnMessageCallback? onMessageReceived; OnTelemetryCallback? onTelemetryReceived; OnSelfInfoCallback? onSelfInfoReceived; + OnDeviceInfoCallback? onDeviceInfoReceived; OnNoMoreMessagesCallback? onNoMoreMessages; + OnMessageWaitingCallback? onMessageWaiting; + OnLoginSuccessCallback? onLoginSuccess; + OnLoginFailCallback? onLoginFail; OnErrorCallback? onError; // Internal state @@ -350,6 +358,18 @@ class MeshCoreBleService { print(' โ†’ Handling SendConfirmed push'); _handleSendConfirmed(reader); break; + case MeshCoreConstants.pushMsgWaiting: + print(' โ†’ Handling MsgWaiting push'); + _handleMsgWaiting(reader); + break; + case MeshCoreConstants.pushLoginSuccess: + print(' โ†’ Handling LoginSuccess push'); + _handleLoginSuccess(reader); + break; + case MeshCoreConstants.pushLoginFail: + print(' โ†’ Handling LoginFail push'); + _handleLoginFail(reader); + break; case MeshCoreConstants.respNoMoreMessages: print(' โ†’ Response: No More Messages'); onNoMoreMessages?.call(); @@ -359,6 +379,7 @@ class MeshCoreBleService { break; case MeshCoreConstants.respErr: print(' โ†’ Response: ERROR'); + _handleError(reader); break; default: print(' โš ๏ธ Unknown response code: $responseCode'); @@ -587,38 +608,76 @@ class MeshCoreBleService { } /// Handle DeviceInfo response + /// Handle DeviceInfo response (RESP_CODE_DEVICE_INFO) + /// + /// Protocol format: + /// - 1 byte: firmware version + /// - 1 byte: max contacts รท 2 (ver 3+) + /// - 1 byte: max channels (ver 3+) + /// - 4 bytes: BLE PIN (uint32, ver 3+) + /// - 12 bytes: firmware build date (ASCII null-terminated) + /// - 40 bytes: manufacturer model (ASCII null-terminated) + /// - 20 bytes: semantic version (ASCII null-terminated) void _handleDeviceInfo(BufferReader reader) { try { print(' [DeviceInfo] Parsing device info...'); print(' Remaining bytes: ${reader.remainingBytesCount}'); - // DeviceInfo format (based on MeshCore protocol): - // - 1 byte: protocol version - // - 32 bytes: public key - // - 1 byte: device name length - // - N bytes: device name (UTF-8) - // - remaining: additional info (firmware version, etc.) - if (reader.remainingBytesCount < 1) { print(' [DeviceInfo] No data to parse'); return; } - final protocolVersion = reader.readByte(); - print(' Protocol version: $protocolVersion'); + final firmwareVersion = reader.readByte(); + print(' Firmware version: $firmwareVersion'); - if (reader.remainingBytesCount >= 32) { - final publicKey = reader.readBytes(32); - print(' Public key prefix: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); + int? maxContacts; + int? maxChannels; + int? blePin; + if (reader.remainingBytesCount >= 6) { + final maxContactsDiv2 = reader.readByte(); + maxContacts = maxContactsDiv2 * 2; + print(' Max contacts: $maxContacts'); + + maxChannels = reader.readByte(); + print(' Max channels: $maxChannels'); + + blePin = reader.readUInt32LE(); + print(' BLE PIN: $blePin'); } - // Read remaining data as device info details - if (reader.hasRemaining) { - final remainingData = reader.readRemainingBytes(); - print(' Additional info: ${remainingData.length} bytes'); - // Could parse device name, firmware version, etc. here if needed + String? firmwareBuildDate; + if (reader.remainingBytesCount >= 12) { + final buildDateBytes = reader.readBytes(12); + firmwareBuildDate = String.fromCharCodes(buildDateBytes.takeWhile((b) => b != 0)); + print(' Firmware build date: "$firmwareBuildDate"'); } + String? manufacturerModel; + if (reader.remainingBytesCount >= 40) { + final modelBytes = reader.readBytes(40); + manufacturerModel = String.fromCharCodes(modelBytes.takeWhile((b) => b != 0)); + print(' Manufacturer model: "$manufacturerModel"'); + } + + String? semanticVersion; + if (reader.remainingBytesCount >= 20) { + final versionBytes = reader.readBytes(20); + semanticVersion = String.fromCharCodes(versionBytes.takeWhile((b) => b != 0)); + print(' Semantic version: "$semanticVersion"'); + } + + // Call callback with parsed data + onDeviceInfoReceived?.call({ + 'firmwareVersion': firmwareVersion, + 'maxContacts': maxContacts, + 'maxChannels': maxChannels, + 'blePin': blePin, + 'firmwareBuildDate': firmwareBuildDate, + 'manufacturerModel': manufacturerModel, + 'semanticVersion': semanticVersion, + }); + print(' โœ… [DeviceInfo] Parsed successfully'); } catch (e) { print(' โŒ [DeviceInfo] Parsing error: $e'); @@ -632,20 +691,22 @@ class MeshCoreBleService { print(' [SelfInfo] Parsing self info...'); print(' Remaining bytes: ${reader.remainingBytesCount}'); - // SelfInfo format (from MeshCore protocol): - // - 1 byte: protocol version - // - 1 byte: device type - // - 1 byte: tx power - // - 1 byte: max tx power + // SelfInfo format (RESP_CODE_SELF_INFO): + // - 1 byte: type (ADV_TYPE_*) + // - 1 byte: tx power (dBm, current) + // - 1 byte: max tx power (dBm, max radio supports) // - 32 bytes: public key - // - 4 bytes: adv lat (int32) - // - 4 bytes: adv lon (int32) - // - 1 byte: manual add contacts flag - // - 4 bytes: radio freq (uint32) - // - 2 bytes: radio bw (uint16) - // - 1 byte: radio sf - // - 1 byte: radio cr - // - remaining: self name (null-terminated string) + // - 4 bytes: adv lat * 1E6 (int32) + // - 4 bytes: adv lon * 1E6 (int32) + // - 1 byte: multi ACKs (0=no extra, 1=send extra ACK) + // - 1 byte: advert location policy (0=don't share, 1=share) + // - 1 byte: telemetry modes (bits 0-1: Base, bits 2-3: Location) + // - 1 byte: manual add contacts (0 or 1) + // - 4 bytes: radio freq * 1000 (uint32) + // - 4 bytes: radio bw (kHz) * 1000 (uint32) + // - 1 byte: spreading factor + // - 1 byte: coding rate + // - remaining: self name (null-terminated varchar) if (reader.remainingBytesCount < 54) { print(' [SelfInfo] Insufficient data: ${reader.remainingBytesCount} bytes'); @@ -654,35 +715,85 @@ class MeshCoreBleService { return; } - final protocolVersion = reader.readByte(); - final deviceType = reader.readByte(); - final txPower = reader.readByte(); - final maxTxPower = reader.readByte(); - final publicKey = reader.readBytes(32); - final advLat = reader.readInt32LE(); - final advLon = reader.readInt32LE(); - final manualAddContacts = reader.readByte(); - final radioFreq = reader.readUInt32LE(); - final radioBw = reader.readUInt16LE(); - final radioSf = reader.readByte(); - final radioCr = reader.readByte(); + print(' ๐Ÿ“ BYTE-BY-BYTE PARSING DEBUG:'); + print(' Position before reads: offset=0, remaining=${reader.remainingBytesCount}'); - print(' Protocol version: $protocolVersion'); - print(' Device type: $deviceType'); - print(' TX power: $txPower / $maxTxPower dBm'); - print(' Public key prefix: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); - print(' Position: ${advLat / 1000000.0}, ${advLon / 1000000.0}'); - print(' Radio: freq=$radioFreq, bw=$radioBw, sf=$radioSf, cr=$radioCr'); + // NO protocol version byte - it starts with device type! + final deviceType = reader.readByte(); + print(' [Byte 0] Device type: $deviceType (0x${deviceType.toRadixString(16).padLeft(2, '0')})'); + + final txPower = reader.readByte(); + print(' [Byte 1] TX power: $txPower dBm (0x${txPower.toRadixString(16).padLeft(2, '0')})'); + + final maxTxPower = reader.readByte(); + print(' [Byte 2] Max TX power: $maxTxPower dBm (0x${maxTxPower.toRadixString(16).padLeft(2, '0')})'); + + final publicKey = reader.readBytes(32); + print(' [Bytes 3-34] Public key (32 bytes): ${publicKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}...'); + + final advLatBytes = reader.readBytes(4); + final advLat = ByteData.sublistView(Uint8List.fromList(advLatBytes)).getInt32(0, Endian.little); + print(' [Bytes 35-38] Adv Lat (raw bytes): ${advLatBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); + print(' [Bytes 35-38] Adv Lat (int32 LE): $advLat'); + print(' [Bytes 35-38] Adv Lat (decimal): ${advLat / 1000000.0}ยฐ'); + + final advLonBytes = reader.readBytes(4); + final advLon = ByteData.sublistView(Uint8List.fromList(advLonBytes)).getInt32(0, Endian.little); + print(' [Bytes 39-42] Adv Lon (raw bytes): ${advLonBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); + print(' [Bytes 39-42] Adv Lon (int32 LE): $advLon'); + print(' [Bytes 39-42] Adv Lon (decimal): ${advLon / 1000000.0}ยฐ'); + + final multiAcks = reader.readByte(); + print(' [Byte 43] Multi ACKs: $multiAcks (0x${multiAcks.toRadixString(16).padLeft(2, '0')})'); + + final advertLocPolicy = reader.readByte(); + print(' [Byte 44] Advert Loc Policy: $advertLocPolicy (0x${advertLocPolicy.toRadixString(16).padLeft(2, '0')})'); + + final telemetryModes = reader.readByte(); + print(' [Byte 45] Telemetry Modes: $telemetryModes (0x${telemetryModes.toRadixString(16).padLeft(2, '0')})'); + + final manualAddContacts = reader.readByte(); + print(' [Byte 46] Manual Add Contacts: $manualAddContacts (0x${manualAddContacts.toRadixString(16).padLeft(2, '0')})'); + + final radioFreqBytes = reader.readBytes(4); + final radioFreq = ByteData.sublistView(Uint8List.fromList(radioFreqBytes)).getUint32(0, Endian.little); + print(' [Bytes 47-50] Radio Freq (raw bytes): ${radioFreqBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); + print(' [Bytes 47-50] Radio Freq (uint32 LE): $radioFreq'); + print(' [Bytes 47-50] Radio Freq (MHz): ${radioFreq / 1000.0}'); + + final radioBwBytes = reader.readBytes(4); + final radioBw = ByteData.sublistView(Uint8List.fromList(radioBwBytes)).getUint32(0, Endian.little); + print(' [Bytes 51-54] Radio BW (raw bytes): ${radioBwBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); + print(' [Bytes 51-54] Radio BW (uint32 LE): $radioBw'); + print(' [Bytes 51-54] Radio BW (kHz): ${radioBw / 1000.0}'); + + final radioSf = reader.readByte(); + print(' [Byte 55] Radio SF: $radioSf (0x${radioSf.toRadixString(16).padLeft(2, '0')})'); + + final radioCr = reader.readByte(); + print(' [Byte 56] Radio CR: $radioCr (0x${radioCr.toRadixString(16).padLeft(2, '0')})'); + + print(' Remaining bytes after radio params: ${reader.remainingBytesCount}'); String? selfName; if (reader.hasRemaining) { - selfName = String.fromCharCodes(reader.readRemainingBytes().takeWhile((b) => b != 0)); - print(' Self name: $selfName'); + final nameBytes = reader.readRemainingBytes(); + print(' Self name bytes (hex): ${nameBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); + print(' Self name bytes (ASCII): ${nameBytes.map((b) => b >= 32 && b <= 126 ? String.fromCharCode(b) : '.')}'); + selfName = String.fromCharCodes(nameBytes.takeWhile((b) => b != 0)); + print(' Self name (parsed): "$selfName"'); } + print(' โœ… PARSED SUMMARY:'); + print(' Type: $deviceType'); + print(' TX Power: $txPower / $maxTxPower dBm'); + print(' Position: ${advLat / 1000000.0}ยฐ, ${advLon / 1000000.0}ยฐ'); + print(' Flags: multiAcks=$multiAcks, locPolicy=$advertLocPolicy, telemetry=$telemetryModes, manual=$manualAddContacts'); + print(' Radio: freq=${radioFreq / 1000.0} MHz, bw=${radioBw / 1000.0} kHz, sf=$radioSf, cr=$radioCr'); + print(' Name: "$selfName"'); + // Call callback with parsed data onSelfInfoReceived?.call({ - 'protocolVersion': protocolVersion, 'deviceType': deviceType, 'txPower': txPower, 'maxTxPower': maxTxPower, @@ -846,6 +957,132 @@ class MeshCoreBleService { } } + /// Handle MsgWaiting push (PUSH_CODE_MSG_WAITING) + /// + /// This push notification indicates that new messages are waiting + /// in the device queue and should be fetched using syncNextMessage() + void _handleMsgWaiting(BufferReader reader) { + try { + print(' [MsgWaiting] New message(s) waiting in queue'); + print(' โœ… [MsgWaiting] Notifying callback to fetch messages'); + onMessageWaiting?.call(); + } catch (e) { + print(' โŒ [MsgWaiting] Parsing error: $e'); + // Don't call onError - this is informational + } + } + + /// Handle LoginSuccess push (PUSH_CODE_LOGIN_SUCCESS) + /// + /// Protocol format: + /// - 1 byte: permissions (lowest bit = is_admin) + /// - 6 bytes: public key prefix (first 6 bytes) + /// - 4 bytes: tag (int32) + /// - 1 byte: (V7+) new permissions + void _handleLoginSuccess(BufferReader reader) { + try { + print(' [LoginSuccess] Parsing login success...'); + print(' Remaining bytes: ${reader.remainingBytesCount}'); + + if (reader.remainingBytesCount >= 11) { + final permissions = reader.readByte(); + final isAdmin = (permissions & 0x01) != 0; + print(' Permissions: $permissions (admin: $isAdmin)'); + + final publicKeyPrefix = reader.readBytes(6); + print(' Room public key prefix: ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); + + final tag = reader.readInt32LE(); + print(' Tag: $tag'); + + // V7+ new permissions byte + int? newPermissions; + if (reader.hasRemaining) { + newPermissions = reader.readByte(); + print(' New permissions (V7+): $newPermissions'); + } + + print(' โœ… [LoginSuccess] Successfully logged into room'); + onLoginSuccess?.call(publicKeyPrefix, permissions, isAdmin, tag); + } else { + print(' โš ๏ธ [LoginSuccess] Insufficient data for full parsing'); + } + } catch (e) { + print(' โŒ [LoginSuccess] Parsing error: $e'); + onError?.call('Login success parsing error: $e'); + } + } + + /// Handle LoginFail push (PUSH_CODE_LOGIN_FAIL) + /// + /// Protocol format: + /// - 1 byte: reserved (zero) + /// - 6 bytes: public key prefix (first 6 bytes) + void _handleLoginFail(BufferReader reader) { + try { + print(' [LoginFail] Parsing login fail...'); + print(' Remaining bytes: ${reader.remainingBytesCount}'); + + if (reader.remainingBytesCount >= 7) { + final reserved = reader.readByte(); + print(' Reserved: $reserved'); + + final publicKeyPrefix = reader.readBytes(6); + print(' Room public key prefix: ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); + + print(' โŒ [LoginFail] Failed to login to room (incorrect password or access denied)'); + onLoginFail?.call(publicKeyPrefix); + } else { + print(' โš ๏ธ [LoginFail] Insufficient data for full parsing'); + } + } catch (e) { + print(' โŒ [LoginFail] Parsing error: $e'); + onError?.call('Login fail parsing error: $e'); + } + } + + /// Handle Error response (RESP_CODE_ERR) + /// + /// Protocol format: + /// - 1 byte: error code (ERR_CODE_*) + void _handleError(BufferReader reader) { + try { + print(' [Error] Parsing error response...'); + print(' Remaining bytes: ${reader.remainingBytesCount}'); + + if (reader.hasRemaining) { + final errorCode = reader.readByte(); + String errorMsg = 'Error code: $errorCode'; + + switch (errorCode) { + case MeshCoreConstants.errUnsupportedCmd: + errorMsg = 'Unsupported command'; + break; + case MeshCoreConstants.errNotFound: + errorMsg = 'Not found'; + break; + case MeshCoreConstants.errTableFull: + errorMsg = 'Table full'; + break; + case MeshCoreConstants.errBadState: + errorMsg = 'Bad state'; + break; + case MeshCoreConstants.errFileIoError: + errorMsg = 'File I/O error'; + break; + case MeshCoreConstants.errIllegalArg: + errorMsg = 'Illegal argument'; + break; + } + + print(' โŒ [Error] $errorMsg'); + onError?.call(errorMsg); + } + } catch (e) { + print(' โŒ [Error] Parsing error: $e'); + } + } + /// Send AppStart command Future _sendAppStart() async { final writer = BufferWriter(); @@ -1027,6 +1264,53 @@ class MeshCoreBleService { await _writeData(writer.toBytes()); } + /// Set other parameters (telemetry modes, advert location policy, manual add contacts) + /// + /// Protocol format (CMD_SET_OTHER_PARAMS): + /// - 1 byte: command code (38) + /// - 1 byte: manual add contacts (0 or 1) + /// - 1 byte: telemetry modes (bits 0-1: Base mode, bits 2-3: Location mode) + /// Modes: 0=DENY, 1=apply contact.flags, 2=ALLOW ALL + /// - 1 byte: advert location policy (0=don't share, 1=share) + /// - 1 byte: multi ACKs (0=no extra, 1=send extra ACK) + Future setOtherParams({ + required int manualAddContacts, // 0 or 1 + required int telemetryModes, // bits 0-1: Base, bits 2-3: Location + required int advertLocationPolicy, // 0=don't share, 1=share + int multiAcks = 0, // 0=no extra, 1=send extra + }) async { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdSetOtherParams); + writer.writeByte(manualAddContacts); + writer.writeByte(telemetryModes); + writer.writeByte(advertLocationPolicy); + writer.writeByte(multiAcks); + await _writeData(writer.toBytes()); + } + + /// Send login request to room or repeater + /// + /// Protocol format (CMD_SEND_LOGIN): + /// - 1 byte: command code (26) + /// - 32 bytes: public key (room or repeater) + /// - N bytes: password (remainder of frame, varchar, max 15 bytes) + /// + /// Response: PUSH_CODE_LOGIN_SUCCESS (0x85) or PUSH_CODE_LOGIN_FAIL (0x86) + Future loginToRoom({ + required Uint8List roomPublicKey, + required String password, + }) async { + if (password.length > 15) { + throw ArgumentError('Password exceeds 15 character limit'); + } + + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdSendLogin); + writer.writeBytes(roomPublicKey); // 32 bytes + writer.writeString(password); // Max 15 bytes + await _writeData(writer.toBytes()); + } + /// Log a packet void _logPacket(Uint8List data, PacketDirection direction, {int? responseCode}) { // Add new packet diff --git a/lib/services/meshcore_constants.dart b/lib/services/meshcore_constants.dart index edc24a7..552452d 100644 --- a/lib/services/meshcore_constants.dart +++ b/lib/services/meshcore_constants.dart @@ -69,6 +69,9 @@ class MeshCoreConstants { static const int respChannelInfo = 18; static const int respSignStart = 19; static const int respSignature = 20; + static const int respCustomVars = 21; + static const int respAdvertPath = 22; + static const int respTuningParams = 21; // Same as respCustomVars per protocol // Push Codes (Device -> App, unsolicited) static const int pushAdvert = 0x80;