diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index e7b96ba..634a1d1 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -117,6 +117,7 @@ class MeshCoreConnector extends ChangeNotifier { final ContactStore _contactStore = ContactStore(); final UnreadStore _unreadStore = UnreadStore(); final Map _channelSmazEnabled = {}; + bool _lastSentWasCliCommand = false; // Track if last sent message was a CLI command final Map _contactSmazEnabled = {}; final Set _knownContactKeys = {}; final Map _contactLastReadMs = {}; @@ -459,6 +460,7 @@ class MeshCoreConnector extends ChangeNotifier { addMessageCallback: _addMessage, updateMessageCallback: _updateMessage, clearContactPathCallback: clearContactPath, + setContactPathCallback: setContactPath, calculateTimeoutCallback: (pathLength, messageBytes) => calculateTimeout(pathLength: pathLength, messageBytes: messageBytes), appSettingsService: appSettingsService, @@ -1040,6 +1042,24 @@ class MeshCoreConnector extends ChangeNotifier { // The device will send updated contact info with path_len = -1 } + void updateContactInMemory( + String publicKeyHex, { + Uint8List? pathBytes, + int? pathLength, + }) { + final existingIndex = + _contacts.indexWhere((c) => c.publicKeyHex == publicKeyHex); + if (existingIndex >= 0) { + final existing = _contacts[existingIndex]; + _contacts[existingIndex] = existing.copyWith( + pathLength: pathLength, + path: pathBytes, + ); + notifyListeners(); + unawaited(_persistContacts()); + } + } + Future syncTime() async { if (!isConnected) return; @@ -1080,6 +1100,7 @@ class MeshCoreConnector extends ChangeNotifier { // CLI commands are sent as UTF-8 text with a special prefix final commandBytes = utf8.encode(command); final bytes = Uint8List.fromList([0x01, ...commandBytes, 0x00]); + _lastSentWasCliCommand = true; await sendFrame(bytes); } @@ -1120,7 +1141,10 @@ class MeshCoreConnector extends ChangeNotifier { await sendFrame(buildGetChannelFrame(i)); } + // Wait a bit for responses to arrive, then apply final sort + await Future.delayed(const Duration(seconds: 2)); _isLoadingChannels = false; + _applyChannelOrder(); notifyListeners(); } @@ -1416,6 +1440,12 @@ class MeshCoreConnector extends ChangeNotifier { } _knownContactKeys.add(contact.publicKeyHex); _loadMessagesForContact(contact.publicKeyHex); + + // Add path to history if we have a valid path + if (_pathHistoryService != null && contact.pathLength >= 0) { + _pathHistoryService!.handlePathUpdated(contact); + } + notifyListeners(); // Show notification for new contact (advertisement) @@ -1797,6 +1827,14 @@ class MeshCoreConnector extends ChangeNotifier { final ackHash = Uint8List.fromList(frame.sublist(2, 6)); final timeoutMs = readUint32LE(frame, 6); + // Check if this is a CLI command ACK - if so, ignore it + if (_lastSentWasCliCommand) { + final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + debugPrint('Ignoring CLI command ACK (sent): $ackHashHex'); + _lastSentWasCliCommand = false; + return; + } + if (_retryService != null) { _retryService!.updateMessageFromSent(ackHash, timeoutMs); } @@ -1824,6 +1862,8 @@ class MeshCoreConnector extends ChangeNotifier { final ackHash = Uint8List.fromList(frame.sublist(1, 5)); final tripTimeMs = readUint32LE(frame, 5); + // CLI command ACKs are already filtered in _handleMessageSent, so this should only see real messages + // Handle ACK in retry service if (_retryService != null) { _retryService!.handleAckReceived(ackHash, tripTimeMs); @@ -1846,7 +1886,11 @@ class MeshCoreConnector extends ChangeNotifier { final channel = Channel.fromFrame(frame); if (channel != null && !channel.isEmpty) { _channels.add(channel); - _applyChannelOrder(); + // Only sort and notify if we're not currently loading channels + // This prevents the list from jumping around as channels arrive during refresh + if (!_isLoadingChannels) { + _applyChannelOrder(); + } notifyListeners(); } } diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index b80d3c0..1b734e5 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -413,10 +413,20 @@ class _ChatScreenState extends State { return; } + final pathBytes = Uint8List.fromList(path.pathBytes); + final pathLength = path.pathBytes.length; + await connector.setContactPath( widget.contact, - Uint8List.fromList(path.pathBytes), - path.pathBytes.length, + pathBytes, + pathLength, + ); + + // Update contact in memory directly for immediate UI feedback + connector.updateContactInMemory( + widget.contact.publicKeyHex, + pathBytes: pathBytes, + pathLength: pathLength, ); if (!context.mounted) return; diff --git a/lib/screens/repeater_settings_screen.dart b/lib/screens/repeater_settings_screen.dart index a0cb266..cc02955 100644 --- a/lib/screens/repeater_settings_screen.dart +++ b/lib/screens/repeater_settings_screen.dart @@ -184,7 +184,24 @@ class _RepeaterSettingsScreenState extends State { } if (_fetchedSettings.containsKey('tx')) { - _txPowerController.text = _fetchedSettings['tx']!; + final txValue = _fetchedSettings['tx']!; + // Extract just the power value if it's part of a larger response + // Handle formats like "10", "10 dBm", or "908.205017,62.5,10,7" + final parts = txValue.split(','); + if (parts.length >= 3) { + // If comma-separated (likely radio format), TX power is typically the 3rd or 4th value + // Format: freq,bandwidth,sf,cr OR freq,bandwidth,power,sf,cr + final powerCandidate = parts.length > 3 ? parts[2].trim() : parts.last.trim(); + final powerInt = int.tryParse(powerCandidate.replaceAll(RegExp(r'[^0-9-]'), '')); + if (powerInt != null && powerInt >= 1 && powerInt <= 30) { + _txPowerController.text = powerInt.toString(); + } else { + _txPowerController.text = txValue.replaceAll(RegExp(r'[^0-9-]'), ''); + } + } else { + // Simple format, just extract the number + _txPowerController.text = txValue.replaceAll(RegExp(r'[^0-9-]'), ''); + } } if (_fetchedSettings.containsKey('lat')) { diff --git a/lib/services/message_retry_service.dart b/lib/services/message_retry_service.dart index 5e96bd6..696a332 100644 --- a/lib/services/message_retry_service.dart +++ b/lib/services/message_retry_service.dart @@ -7,19 +7,35 @@ import '../models/path_selection.dart'; import 'storage_service.dart'; import 'app_settings_service.dart'; +class _AckHistoryEntry { + final String messageId; + final List ackHashes; + final DateTime timestamp; + + _AckHistoryEntry({ + required this.messageId, + required this.ackHashes, + required this.timestamp, + }); +} + class MessageRetryService extends ChangeNotifier { static const int maxRetries = 5; + static const int maxAckHistorySize = 100; final StorageService _storage; final Map _timeoutTimers = {}; final Map _pendingMessages = {}; final Map _pendingContacts = {}; final Map _pendingPathSelections = {}; + final Map> _expectedAckHashes = {}; // Track all expected ACKs for retries + final List<_AckHistoryEntry> _ackHistory = []; // Rolling buffer of recent ACK hashes Function(Contact, String, int, int)? _sendMessageCallback; Function(String, Message)? _addMessageCallback; Function(Message)? _updateMessageCallback; Function(Contact)? _clearContactPathCallback; + Function(Contact, Uint8List, int)? _setContactPathCallback; Function(int, int)? _calculateTimeoutCallback; AppSettingsService? _appSettingsService; Function(String, PathSelection, bool, int?)? _recordPathResultCallback; @@ -31,6 +47,7 @@ class MessageRetryService extends ChangeNotifier { required Function(String, Message) addMessageCallback, required Function(Message) updateMessageCallback, Function(Contact)? clearContactPathCallback, + Function(Contact, Uint8List, int)? setContactPathCallback, Function(int pathLength, int messageBytes)? calculateTimeoutCallback, AppSettingsService? appSettingsService, Function(String, PathSelection, bool, int?)? recordPathResultCallback, @@ -39,6 +56,7 @@ class MessageRetryService extends ChangeNotifier { _addMessageCallback = addMessageCallback; _updateMessageCallback = updateMessageCallback; _clearContactPathCallback = clearContactPathCallback; + _setContactPathCallback = setContactPathCallback; _calculateTimeoutCallback = calculateTimeoutCallback; _appSettingsService = appSettingsService; _recordPathResultCallback = recordPathResultCallback; @@ -89,6 +107,23 @@ class MessageRetryService extends ChangeNotifier { if (message == null || contact == null) return; + // Sync path settings with device before sending + // Use the path that was captured when the message was first sent + if (_setContactPathCallback != null && _clearContactPathCallback != null) { + if (message.pathLength != null && message.pathLength! < 0) { + // Flood mode - clear the path + debugPrint('Setting flood mode for retry attempt ${message.retryCount}'); + _clearContactPathCallback!(contact); + } else if (message.pathLength != null && message.pathLength! >= 0) { + // Specific path (including direct neighbor with pathLength=0) + final pathStr = message.pathBytes.isEmpty + ? 'direct' + : message.pathBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(','); + debugPrint('Setting path [$pathStr] (${message.pathLength} hops) for retry attempt ${message.retryCount}'); + await _setContactPathCallback!(contact, message.pathBytes, message.pathLength!); + } + } + final attempt = message.retryCount.clamp(0, 3); if (_sendMessageCallback != null) { @@ -105,10 +140,21 @@ class MessageRetryService extends ChangeNotifier { void updateMessageFromSent(Uint8List ackHash, int timeoutMs) { for (var entry in _pendingMessages.entries) { final message = entry.value; - if (message.status == MessageStatus.pending) { + // Only update if pending (waiting to send) or already sent with matching ACK + if (message.status == MessageStatus.pending || + (message.status == MessageStatus.sent && + message.expectedAckHash != null && + listEquals(message.expectedAckHash, ackHash))) { final contact = _pendingContacts[entry.key]; final selection = _pendingPathSelections[entry.key]; + // Add this ACK hash to the list of expected ACKs for this message + _expectedAckHashes[entry.key] ??= []; + if (!_expectedAckHashes[entry.key]!.any((hash) => listEquals(hash, ackHash))) { + _expectedAckHashes[entry.key]!.add(Uint8List.fromList(ackHash)); + debugPrint('Added ACK hash ${ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join()} to message ${entry.key} (total: ${_expectedAckHashes[entry.key]!.length})'); + } + // Use device-provided timeout, or calculate from radio settings if timeout is 0 or invalid int actualTimeout = timeoutMs; if (timeoutMs <= 0 && _calculateTimeoutCallback != null && contact != null) { @@ -127,7 +173,7 @@ class MessageRetryService extends ChangeNotifier { final updatedMessage = message.copyWith( status: MessageStatus.sent, - expectedAckHash: ackHash, + expectedAckHash: ackHash, // Keep the most recent one for display estimatedTimeoutMs: actualTimeout, sentAt: DateTime.now(), ); @@ -139,9 +185,11 @@ class MessageRetryService extends ChangeNotifier { } _startTimeoutTimer(entry.key, actualTimeout); + debugPrint('Updated message ${entry.key} with ACK hash: ${ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}'); return; } } + debugPrint('No pending message found for ACK hash: ${ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}'); } void _startTimeoutTimer(String messageId, int timeoutMs) { @@ -158,12 +206,15 @@ class MessageRetryService extends ChangeNotifier { if (message == null || contact == null) return; + debugPrint('Timeout for message $messageId (retry ${message.retryCount}/${maxRetries - 1})'); + if (message.retryCount < maxRetries - 1) { final backoffMs = 1000 * (1 << message.retryCount); final updatedMessage = message.copyWith( retryCount: message.retryCount + 1, status: MessageStatus.pending, + // Keep expectedAckHash - it will be updated when the new attempt is sent ); _pendingMessages[messageId] = updatedMessage; @@ -172,6 +223,7 @@ class MessageRetryService extends ChangeNotifier { _updateMessageCallback!(updatedMessage); } + debugPrint('Scheduling retry after ${backoffMs}ms'); Timer(Duration(milliseconds: backoffMs), () { _attemptSend(messageId); }); @@ -179,6 +231,9 @@ class MessageRetryService extends ChangeNotifier { // Max retries reached - mark as failed final failedMessage = message.copyWith(status: MessageStatus.failed); + // Move ACK hashes to history before removing + _moveAckHashesToHistory(messageId); + _pendingMessages.remove(messageId); _pendingContacts.remove(messageId); _pendingPathSelections.remove(messageId); @@ -201,22 +256,71 @@ class MessageRetryService extends ChangeNotifier { } } + void _moveAckHashesToHistory(String messageId) { + final ackHashes = _expectedAckHashes.remove(messageId); + if (ackHashes != null && ackHashes.isNotEmpty) { + _ackHistory.add(_AckHistoryEntry( + messageId: messageId, + ackHashes: ackHashes, + timestamp: DateTime.now(), + )); + + // Trim history to max size (rolling buffer) + while (_ackHistory.length > maxAckHistorySize) { + _ackHistory.removeAt(0); + } + + debugPrint('Moved ${ackHashes.length} ACK hashes to history for message $messageId (history size: ${_ackHistory.length})'); + } + } + + bool _checkAckHistory(Uint8List ackHash) { + for (final entry in _ackHistory) { + for (final expectedHash in entry.ackHashes) { + if (listEquals(expectedHash, ackHash)) { + debugPrint('Found ACK match in history: messageId=${entry.messageId}, age=${DateTime.now().difference(entry.timestamp).inSeconds}s'); + return true; + } + } + } + return false; + } + void handleAckReceived(Uint8List ackHash, int tripTimeMs) { String? matchedMessageId; + final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + debugPrint('ACK received: $ackHashHex, trip time: ${tripTimeMs}ms'); + debugPrint('Pending messages:'); for (var entry in _pendingMessages.entries) { final message = entry.value; - if (message.expectedAckHash != null && - listEquals(message.expectedAckHash, ackHash)) { - matchedMessageId = entry.key; - break; + final expectedHex = message.expectedAckHash?.map((b) => b.toRadixString(16).padLeft(2, '0')).join() ?? 'none'; + final allExpectedHashes = _expectedAckHashes[entry.key]?.map((h) => h.map((b) => b.toRadixString(16).padLeft(2, '0')).join()).join(', ') ?? 'none'; + debugPrint(' ${entry.key}: status=${message.status}, latestAck=$expectedHex, allAcks=[$allExpectedHashes], retry=${message.retryCount}'); + } + + // Check against ALL expected ACK hashes (from all retry attempts) + for (var entry in _expectedAckHashes.entries) { + final messageId = entry.key; + final expectedHashes = entry.value; + + for (final expectedHash in expectedHashes) { + if (listEquals(expectedHash, ackHash)) { + matchedMessageId = messageId; + debugPrint('Matched ACK to message: $matchedMessageId (matched hash from attempt ${expectedHashes.indexOf(expectedHash)})'); + break; + } } + + if (matchedMessageId != null) break; } if (matchedMessageId != null) { final message = _pendingMessages[matchedMessageId]!; final contact = _pendingContacts[matchedMessageId]; final selection = _pendingPathSelections[matchedMessageId]; + + // Cancel any pending timeout or retry _timeoutTimers[matchedMessageId]?.cancel(); _timeoutTimers.remove(matchedMessageId); @@ -226,6 +330,9 @@ class MessageRetryService extends ChangeNotifier { tripTimeMs: tripTimeMs, ); + // Move ACK hashes to history before removing + _moveAckHashesToHistory(matchedMessageId); + _pendingMessages.remove(matchedMessageId); _pendingContacts.remove(matchedMessageId); _pendingPathSelections.remove(matchedMessageId); @@ -239,6 +346,13 @@ class MessageRetryService extends ChangeNotifier { } notifyListeners(); + } else { + // Check ACK history for recently completed messages + if (_checkAckHistory(ackHash)) { + debugPrint('ACK matched a recently completed message from history'); + } else { + debugPrint('No matching message found for ACK: $ackHashHex'); + } } } @@ -326,6 +440,8 @@ class MessageRetryService extends ChangeNotifier { _pendingMessages.clear(); _pendingContacts.clear(); _pendingPathSelections.clear(); + _expectedAckHashes.clear(); + _ackHistory.clear(); super.dispose(); } }