mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Add conservative ACK timeout
This commit is contained in:
@@ -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:
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 '';
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user