Add conservative ACK timeout

This commit is contained in:
Janez T
2026-03-06 20:27:05 +01:00
parent 85d0b26c67
commit 43f5702666
5 changed files with 206 additions and 7 deletions

View File

@@ -1,3 +1,6 @@
import 'dart:convert';
import 'dart:math' as math;
import '../../models/message.dart'; import '../../models/message.dart';
import '../../models/contact.dart'; import '../../models/contact.dart';
@@ -22,6 +25,12 @@ class MessageRetryManager {
// These are app-level timeouts, separate from firmware's suggested timeout // These are app-level timeouts, separate from firmware's suggested timeout
// Firmware timeout is for ACK arrival, these are for retry attempts // Firmware timeout is for ACK arrival, these are for retry attempts
static const List<int> _timeouts = [4000, 8000, 12000]; static const List<int> _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) /// Get timeout for a specific retry attempt (0-2)
/// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2 /// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2
@@ -32,6 +41,30 @@ class MessageRetryManager {
return _timeouts[attempt]; 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 /// Check if a message is eligible for retry
/// ///
/// Returns true if: /// Returns true if:
@@ -67,8 +100,8 @@ class MessageRetryManager {
/// Contacts without paths already use flood mode automatically. /// Contacts without paths already use flood mode automatically.
bool shouldUseFloodFallback(Message message, Contact contact) { bool shouldUseFloodFallback(Message message, Contact contact) {
return message.retryAttempt >= 3 && return message.retryAttempt >= 3 &&
contact.hasPath && // ✅ FIXED: Flood fallback for failed direct paths contact.hasPath && // ✅ FIXED: Flood fallback for failed direct paths
!message.usedFloodFallback; !message.usedFloodFallback;
} }
/// Track a retry attempt for a message /// Track a retry attempt for a message
@@ -98,4 +131,29 @@ class MessageRetryManager {
DateTime? getLastRetryTime(String messageId) { DateTime? getLastRetryTime(String messageId) {
return _lastRetryTimes[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();
}
} }

View File

@@ -31,6 +31,10 @@ class MessagesProvider with ChangeNotifier {
// Recently completed ACKs are kept briefly to ignore duplicate confirms. // Recently completed ACKs are kept briefly to ignore duplicate confirms.
final Map<int, DateTime> _completedAckHistory = {}; final Map<int, DateTime> _completedAckHistory = {};
// Preserve ACK tags assigned to a message across retries.
final Map<String, Set<int>> _messageAckHistory = {};
final Map<int, (String, DateTime)> _ackHistoryLookup = {};
// Retry management // Retry management
final MessageRetryManager _retryManager = MessageRetryManager(); final MessageRetryManager _retryManager = MessageRetryManager();
@@ -1020,6 +1024,12 @@ class MessagesProvider with ChangeNotifier {
if (index != -1) { if (index != -1) {
final message = _messages[index]; 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(' Current status: ${message.deliveryStatus}');
debugPrint(' Message type: ${message.messageType}'); debugPrint(' Message type: ${message.messageType}');
debugPrint( debugPrint(
@@ -1031,14 +1041,18 @@ class MessagesProvider with ChangeNotifier {
? MessageDeliveryStatus.sending ? MessageDeliveryStatus.sending
: MessageDeliveryStatus.sent, : MessageDeliveryStatus.sent,
expectedAckTag: expectedAckTag > 0 ? expectedAckTag : null, expectedAckTag: expectedAckTag > 0 ? expectedAckTag : null,
suggestedTimeoutMs: suggestedTimeoutMs > 0 ? suggestedTimeoutMs : null, suggestedTimeoutMs: expectedAckTag > 0 ? effectiveTimeout : null,
); );
_messages[index] = updatedMessage; _messages[index] = updatedMessage;
// Only track and set timeout for direct messages (channel messages have expectedAckTag=0) // 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 // Track by ACK tag for matching with delivery confirmation
_pendingSentMessages[expectedAckTag] = updatedMessage; _pendingSentMessages[expectedAckTag] = updatedMessage;
_messageAckHistory
.putIfAbsent(messageId, () => <int>{})
.add(expectedAckTag);
_ackHistoryLookup[expectedAckTag] = (messageId, DateTime.now());
debugPrint( debugPrint(
' ✅ Added to pending messages map with ACK: $expectedAckTag', ' ✅ Added to pending messages map with ACK: $expectedAckTag',
); );
@@ -1049,7 +1063,7 @@ class MessagesProvider with ChangeNotifier {
// Start timeout timer using message ID as key // Start timeout timer using message ID as key
_timeoutTimers[messageId] = Timer( _timeoutTimers[messageId] = Timer(
Duration(milliseconds: suggestedTimeoutMs), Duration(milliseconds: effectiveTimeout),
() { () {
debugPrint( debugPrint(
'⏱️ [MessagesProvider] Timeout for message $messageId (ACK $expectedAckTag)', '⏱️ [MessagesProvider] Timeout for message $messageId (ACK $expectedAckTag)',
@@ -1061,7 +1075,7 @@ class MessagesProvider with ChangeNotifier {
); );
debugPrint( debugPrint(
'⏱️ [MessagesProvider] Started ${suggestedTimeoutMs}ms timeout timer for message $messageId (ACK $expectedAckTag)', '⏱️ [MessagesProvider] Started ${effectiveTimeout}ms timeout timer for message $messageId (ACK $expectedAckTag)',
); );
} else { } else {
debugPrint( debugPrint(
@@ -1253,6 +1267,7 @@ class MessagesProvider with ChangeNotifier {
/// Update message status to delivered with RTT /// Update message status to delivered with RTT
void markMessageDelivered(int ackCode, int roundTripTimeMs) { void markMessageDelivered(int ackCode, int roundTripTimeMs) {
_cleanupCompletedAckHistory(); _cleanupCompletedAckHistory();
_cleanupAckHistoryLookup();
debugPrint( debugPrint(
'🔍 [MessagesProvider] markMessageDelivered called with ACK: $ackCode, RTT: ${roundTripTimeMs}ms', '🔍 [MessagesProvider] markMessageDelivered called with ACK: $ackCode, RTT: ${roundTripTimeMs}ms',
); );
@@ -1349,6 +1364,7 @@ class MessagesProvider with ChangeNotifier {
// Remove from pending // Remove from pending
_pendingSentMessages.remove(ackCode); _pendingSentMessages.remove(ackCode);
_rememberCompletedAck(ackCode); _rememberCompletedAck(ackCode);
_clearAckHistoryForMessage(message.id);
// Clear retry tracking on successful delivery // Clear retry tracking on successful delivery
_retryManager.clearRetry(message.id); _retryManager.clearRetry(message.id);
@@ -1372,6 +1388,33 @@ class MessagesProvider with ChangeNotifier {
); );
} }
} else { } 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)) { if (_completedAckHistory.containsKey(ackCode)) {
debugPrint( debugPrint(
' [MessagesProvider] Duplicate/late ACK $ackCode ignored (already completed)', ' [MessagesProvider] Duplicate/late ACK $ackCode ignored (already completed)',
@@ -1595,6 +1638,7 @@ class MessagesProvider with ChangeNotifier {
if (message.expectedAckTag != null) { if (message.expectedAckTag != null) {
_pendingSentMessages.remove(message.expectedAckTag); _pendingSentMessages.remove(message.expectedAckTag);
} }
_clearAckHistoryForMessage(messageId);
// Clear retry tracking // Clear retry tracking
_retryManager.clearRetry(messageId); _retryManager.clearRetry(messageId);
@@ -1669,6 +1713,8 @@ class MessagesProvider with ChangeNotifier {
} }
_timeoutTimers.clear(); _timeoutTimers.clear();
_completedAckHistory.clear(); _completedAckHistory.clear();
_messageAckHistory.clear();
_ackHistoryLookup.clear();
// Clear retry manager // Clear retry manager
_retryManager.clearAll(); _retryManager.clearAll();
@@ -1693,4 +1739,35 @@ class MessagesProvider with ChangeNotifier {
_completedAckHistory.remove(ack); _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);
}
}
}
} }

View File

@@ -25,17 +25,29 @@ extension MessageLocalization on Message {
switch (deliveryStatus) { switch (deliveryStatus) {
case MessageDeliveryStatus.sending: case MessageDeliveryStatus.sending:
if (isContactMessage) { if (isContactMessage) {
if (retryAttempt > 0) {
return '${l10n.pending}${l10n.retryAttempt} $retryAttempt/3';
}
return l10n.pending; return l10n.pending;
} }
return l10n.sending; return l10n.sending;
case MessageDeliveryStatus.sent: case MessageDeliveryStatus.sent:
return l10n.sent; return l10n.sent;
case MessageDeliveryStatus.delivered: 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) { if (roundTripTimeMs != null) {
return l10n.deliveredWithTime(roundTripTimeMs!); return l10n.deliveredWithTime(roundTripTimeMs!);
} }
return l10n.delivered; return l10n.delivered;
case MessageDeliveryStatus.failed: case MessageDeliveryStatus.failed:
if (retryAttempt > 0) {
return '${l10n.failed}${l10n.retryAttempt} $retryAttempt/3';
}
return l10n.failed; return l10n.failed;
case MessageDeliveryStatus.received: case MessageDeliveryStatus.received:
return ''; return '';

View File

@@ -734,6 +734,13 @@ class _MessageBubbleState extends State<MessageBubble> {
value: widget.message.expectedAckTag! value: widget.message.expectedAckTag!
.toString(), .toString(),
), ),
if (widget.message.suggestedTimeoutMs != null)
_detailRow(
context,
label: 'ACK timeout',
value:
'${widget.message.suggestedTimeoutMs} ms',
),
if (widget.message.roundTripTimeMs != null) if (widget.message.roundTripTimeMs != null)
_detailRow( _detailRow(
context, context,
@@ -744,7 +751,18 @@ class _MessageBubbleState extends State<MessageBubble> {
_detailRow( _detailRow(
context, context,
label: l10n.retryAttempt, 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) if (widget.message.usedFloodFallback)
_detailRow( _detailRow(

View File

@@ -127,5 +127,39 @@ void main() {
expect(retryCalls, 1); 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);
});
}); });
} }