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/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<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)
/// 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();
}
}

View File

@@ -31,6 +31,10 @@ class MessagesProvider with ChangeNotifier {
// Recently completed ACKs are kept briefly to ignore duplicate confirms.
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
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, () => <int>{})
.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);
}
}
}
}