From 43f5702666aae65edc10499166a06ac6702dfc16 Mon Sep 17 00:00:00 2001 From: Janez T Date: Fri, 6 Mar 2026 20:27:05 +0100 Subject: [PATCH] Add conservative ACK timeout --- .../helpers/message_retry_manager.dart | 62 +++++++++++++- lib/providers/messages_provider.dart | 85 ++++++++++++++++++- lib/utils/message_extensions.dart | 12 +++ lib/widgets/messages/message_bubble.dart | 20 ++++- ...messages_provider_retransmission_test.dart | 34 ++++++++ 5 files changed, 206 insertions(+), 7 deletions(-) diff --git a/lib/providers/helpers/message_retry_manager.dart b/lib/providers/helpers/message_retry_manager.dart index fa1b8c0..ebfc8d2 100644 --- a/lib/providers/helpers/message_retry_manager.dart +++ b/lib/providers/helpers/message_retry_manager.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; +import 'dart:math' as math; + import '../../models/message.dart'; import '../../models/contact.dart'; @@ -22,6 +25,12 @@ class MessageRetryManager { // These are app-level timeouts, separate from firmware's suggested timeout // Firmware timeout is for ACK arrival, these are for retry attempts static const List _timeouts = [4000, 8000, 12000]; + static const int _defaultLoRaSf = 10; + static const int _defaultLoRaCr = 5; + static const int _defaultLoRaBwHz = 250000; + static const int _defaultLoRaPreambleSymbols = 8; + static const int _defaultLoRaCrcEnabled = 1; + static const int _defaultLoRaExplicitHeader = 1; /// Get timeout for a specific retry attempt (0-2) /// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2 @@ -32,6 +41,30 @@ class MessageRetryManager { return _timeouts[attempt]; } + /// Calculate a conservative delivery-ACK timeout when firmware doesn't + /// provide one or returns an invalid value. + int calculateAckTimeoutMs({ + required String text, + required Contact? contact, + int? suggestedTimeoutMs, + }) { + if (suggestedTimeoutMs != null && suggestedTimeoutMs > 0) { + return suggestedTimeoutMs; + } + + final payloadBytes = utf8.encode(text).length; + final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes); + final hopCount = contact?.hasPath == true + ? math.max(contact!.outPathLen, 0) + : -1; + + if (hopCount < 0) { + return ((airtimeMs * 10) + 4000).clamp(10000, 30000); + } + + return ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000); + } + /// Check if a message is eligible for retry /// /// Returns true if: @@ -67,8 +100,8 @@ class MessageRetryManager { /// Contacts without paths already use flood mode automatically. bool shouldUseFloodFallback(Message message, Contact contact) { return message.retryAttempt >= 3 && - contact.hasPath && // ✅ FIXED: Flood fallback for failed direct paths - !message.usedFloodFallback; + contact.hasPath && // ✅ FIXED: Flood fallback for failed direct paths + !message.usedFloodFallback; } /// Track a retry attempt for a message @@ -98,4 +131,29 @@ class MessageRetryManager { DateTime? getLastRetryTime(String messageId) { return _lastRetryTimes[messageId]; } + + int _estimateLoRaAirtimeMs(int payloadLenBytes) { + final sf = _defaultLoRaSf; + final bw = _defaultLoRaBwHz.toDouble(); + final cr = (_defaultLoRaCr - 4).clamp(1, 4); + final ih = _defaultLoRaExplicitHeader == 1 ? 0 : 1; + final de = (sf >= 11 && _defaultLoRaBwHz <= 125000) ? 1 : 0; + + final symbolMs = ((1 << sf) / bw) * 1000.0; + final preambleMs = (_defaultLoRaPreambleSymbols + 4.25) * symbolMs; + + final num = + (8 * payloadLenBytes) - + (4 * sf) + + 28 + + (16 * _defaultLoRaCrcEnabled) - + (20 * ih); + final den = 4 * (sf - (2 * de)); + final payloadSymCoeff = den <= 0 ? 0 : (num / den).ceil(); + final payloadSymbols = + 8 + (payloadSymCoeff < 0 ? 0 : payloadSymCoeff) * (cr + 4); + final payloadMs = payloadSymbols * symbolMs; + + return (preambleMs + payloadMs).ceil(); + } } diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index fc898c5..7992a0b 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -31,6 +31,10 @@ class MessagesProvider with ChangeNotifier { // Recently completed ACKs are kept briefly to ignore duplicate confirms. final Map _completedAckHistory = {}; + // Preserve ACK tags assigned to a message across retries. + final Map> _messageAckHistory = {}; + final Map _ackHistoryLookup = {}; + // Retry management final MessageRetryManager _retryManager = MessageRetryManager(); @@ -1020,6 +1024,12 @@ class MessagesProvider with ChangeNotifier { if (index != -1) { final message = _messages[index]; + final contact = _messageContactMap[messageId]; + final effectiveTimeout = _retryManager.calculateAckTimeoutMs( + text: message.text, + contact: contact, + suggestedTimeoutMs: suggestedTimeoutMs > 0 ? suggestedTimeoutMs : null, + ); debugPrint(' Current status: ${message.deliveryStatus}'); debugPrint(' Message type: ${message.messageType}'); debugPrint( @@ -1031,14 +1041,18 @@ class MessagesProvider with ChangeNotifier { ? MessageDeliveryStatus.sending : MessageDeliveryStatus.sent, expectedAckTag: expectedAckTag > 0 ? expectedAckTag : null, - suggestedTimeoutMs: suggestedTimeoutMs > 0 ? suggestedTimeoutMs : null, + suggestedTimeoutMs: expectedAckTag > 0 ? effectiveTimeout : null, ); _messages[index] = updatedMessage; // Only track and set timeout for direct messages (channel messages have expectedAckTag=0) - if (expectedAckTag > 0 && suggestedTimeoutMs > 0) { + if (expectedAckTag > 0) { // Track by ACK tag for matching with delivery confirmation _pendingSentMessages[expectedAckTag] = updatedMessage; + _messageAckHistory + .putIfAbsent(messageId, () => {}) + .add(expectedAckTag); + _ackHistoryLookup[expectedAckTag] = (messageId, DateTime.now()); debugPrint( ' ✅ Added to pending messages map with ACK: $expectedAckTag', ); @@ -1049,7 +1063,7 @@ class MessagesProvider with ChangeNotifier { // Start timeout timer using message ID as key _timeoutTimers[messageId] = Timer( - Duration(milliseconds: suggestedTimeoutMs), + Duration(milliseconds: effectiveTimeout), () { debugPrint( '⏱️ [MessagesProvider] Timeout for message $messageId (ACK $expectedAckTag)', @@ -1061,7 +1075,7 @@ class MessagesProvider with ChangeNotifier { ); debugPrint( - '⏱️ [MessagesProvider] Started ${suggestedTimeoutMs}ms timeout timer for message $messageId (ACK $expectedAckTag)', + '⏱️ [MessagesProvider] Started ${effectiveTimeout}ms timeout timer for message $messageId (ACK $expectedAckTag)', ); } else { debugPrint( @@ -1253,6 +1267,7 @@ class MessagesProvider with ChangeNotifier { /// Update message status to delivered with RTT void markMessageDelivered(int ackCode, int roundTripTimeMs) { _cleanupCompletedAckHistory(); + _cleanupAckHistoryLookup(); debugPrint( '🔍 [MessagesProvider] markMessageDelivered called with ACK: $ackCode, RTT: ${roundTripTimeMs}ms', ); @@ -1349,6 +1364,7 @@ class MessagesProvider with ChangeNotifier { // Remove from pending _pendingSentMessages.remove(ackCode); _rememberCompletedAck(ackCode); + _clearAckHistoryForMessage(message.id); // Clear retry tracking on successful delivery _retryManager.clearRetry(message.id); @@ -1372,6 +1388,33 @@ class MessagesProvider with ChangeNotifier { ); } } else { + final historicalMatch = _ackHistoryLookup[ackCode]; + if (historicalMatch != null) { + final historicalMessageId = historicalMatch.$1; + final historicalIndex = _messages.indexWhere( + (m) => m.id == historicalMessageId, + ); + if (historicalIndex != -1 && + _messages[historicalIndex].deliveryStatus != + MessageDeliveryStatus.delivered) { + _messages[historicalIndex] = _messages[historicalIndex].copyWith( + deliveryStatus: MessageDeliveryStatus.delivered, + roundTripTimeMs: roundTripTimeMs, + deliveredAt: DateTime.now(), + ); + _timeoutTimers[historicalMessageId]?.cancel(); + _timeoutTimers.remove(historicalMessageId); + _rememberCompletedAck(ackCode); + _clearAckHistoryForMessage(historicalMessageId); + _retryManager.clearRetry(historicalMessageId); + _persistMessages(); + notifyListeners(); + debugPrint( + '✅ [MessagesProvider] Historical ACK $ackCode matched message $historicalMessageId', + ); + return; + } + } if (_completedAckHistory.containsKey(ackCode)) { debugPrint( 'ℹ️ [MessagesProvider] Duplicate/late ACK $ackCode ignored (already completed)', @@ -1595,6 +1638,7 @@ class MessagesProvider with ChangeNotifier { if (message.expectedAckTag != null) { _pendingSentMessages.remove(message.expectedAckTag); } + _clearAckHistoryForMessage(messageId); // Clear retry tracking _retryManager.clearRetry(messageId); @@ -1669,6 +1713,8 @@ class MessagesProvider with ChangeNotifier { } _timeoutTimers.clear(); _completedAckHistory.clear(); + _messageAckHistory.clear(); + _ackHistoryLookup.clear(); // Clear retry manager _retryManager.clearAll(); @@ -1693,4 +1739,35 @@ class MessagesProvider with ChangeNotifier { _completedAckHistory.remove(ack); } } + + void _clearAckHistoryForMessage(String messageId) { + final ackTags = _messageAckHistory.remove(messageId); + if (ackTags == null) { + return; + } + for (final ack in ackTags) { + _ackHistoryLookup.remove(ack); + } + } + + void _cleanupAckHistoryLookup({ + Duration maxAge = const Duration(minutes: 15), + }) { + final cutoff = DateTime.now().subtract(maxAge); + final staleAcks = _ackHistoryLookup.entries + .where((entry) => entry.value.$2.isBefore(cutoff)) + .map((entry) => entry.key) + .toList(); + for (final ack in staleAcks) { + final messageId = _ackHistoryLookup.remove(ack)?.$1; + if (messageId == null) { + continue; + } + final history = _messageAckHistory[messageId]; + history?.remove(ack); + if (history != null && history.isEmpty) { + _messageAckHistory.remove(messageId); + } + } + } } diff --git a/lib/utils/message_extensions.dart b/lib/utils/message_extensions.dart index 42d7b43..e1a9d9f 100644 --- a/lib/utils/message_extensions.dart +++ b/lib/utils/message_extensions.dart @@ -25,17 +25,29 @@ extension MessageLocalization on Message { switch (deliveryStatus) { case MessageDeliveryStatus.sending: if (isContactMessage) { + if (retryAttempt > 0) { + return '${l10n.pending} • ${l10n.retryAttempt} $retryAttempt/3'; + } return l10n.pending; } return l10n.sending; case MessageDeliveryStatus.sent: return l10n.sent; case MessageDeliveryStatus.delivered: + if (retryAttempt > 0 && roundTripTimeMs != null) { + return '${l10n.deliveredWithTime(roundTripTimeMs!)} • ${l10n.retryAttempt} $retryAttempt/3'; + } + if (retryAttempt > 0) { + return '${l10n.delivered} • ${l10n.retryAttempt} $retryAttempt/3'; + } if (roundTripTimeMs != null) { return l10n.deliveredWithTime(roundTripTimeMs!); } return l10n.delivered; case MessageDeliveryStatus.failed: + if (retryAttempt > 0) { + return '${l10n.failed} • ${l10n.retryAttempt} $retryAttempt/3'; + } return l10n.failed; case MessageDeliveryStatus.received: return ''; diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index b6794e9..494429d 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -734,6 +734,13 @@ class _MessageBubbleState extends State { value: widget.message.expectedAckTag! .toString(), ), + if (widget.message.suggestedTimeoutMs != null) + _detailRow( + context, + label: 'ACK timeout', + value: + '${widget.message.suggestedTimeoutMs} ms', + ), if (widget.message.roundTripTimeMs != null) _detailRow( context, @@ -744,7 +751,18 @@ class _MessageBubbleState extends State { _detailRow( context, label: l10n.retryAttempt, - value: widget.message.retryAttempt.toString(), + value: '${widget.message.retryAttempt}/3', + ), + if (widget.message.lastRetryAt != null) + _detailRow( + context, + label: 'Last retry', + value: _formatRfc3339( + widget.message.lastRetryAt!, + ), + onCopy: () => copyField( + _formatRfc3339(widget.message.lastRetryAt!), + ), ), if (widget.message.usedFloodFallback) _detailRow( diff --git a/test/providers/messages_provider_retransmission_test.dart b/test/providers/messages_provider_retransmission_test.dart index 721a2a1..a30e590 100644 --- a/test/providers/messages_provider_retransmission_test.dart +++ b/test/providers/messages_provider_retransmission_test.dart @@ -127,5 +127,39 @@ void main() { expect(retryCalls, 1); }); }); + + test('uses calculated timeout when radio timeout is missing', () { + final provider = MessagesProvider(); + provider.addSentMessage( + _buildDirectMessage('m3'), + contact: _buildContact(), + ); + + provider.markMessageSent('m3', 99, 0); + + expect(provider.messages.single.suggestedTimeoutMs, isNotNull); + expect( + provider.messages.single.suggestedTimeoutMs!, + greaterThanOrEqualTo(4000), + ); + }); + + test('older retry ack still marks message delivered', () { + final provider = MessagesProvider(); + provider.addSentMessage( + _buildDirectMessage('m4'), + contact: _buildContact(), + ); + + provider.markMessageSent('m4', 111, 10); + provider.markMessageSent('m4', 112, 10); + provider.markMessageDelivered(111, 220); + + expect( + provider.messages.single.deliveryStatus, + MessageDeliveryStatus.delivered, + ); + expect(provider.messages.single.roundTripTimeMs, 220); + }); }); }