mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 00:40:28 +00:00
feat: MeshCore SAR - Flutter BLE mesh radio companion app
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
150
lib/providers/helpers/message_delivery_tracker.dart
Normal file
150
lib/providers/helpers/message_delivery_tracker.dart
Normal file
@@ -0,0 +1,150 @@
|
||||
/// Message delivery tracking helper
|
||||
///
|
||||
/// Manages message delivery tracking for sent messages, including:
|
||||
/// - FIFO queue for matching RESP_CODE_SENT with message IDs
|
||||
/// - ACK tag to message ID mapping
|
||||
/// - Timeout tracking for stale ACK mappings
|
||||
/// - Message sent/delivered coordination
|
||||
///
|
||||
/// IMPORTANT: Based on MeshCore firmware analysis:
|
||||
/// - Firmware tracks max 8 pending ACKs in circular buffer
|
||||
/// - ACK entries overwritten after 8 messages → need rate limiting
|
||||
/// - Duplicate ACKs suppressed after first match
|
||||
/// - No automatic retry → app must implement
|
||||
class MessageDeliveryTracker {
|
||||
/// FIFO queue of pending message IDs
|
||||
/// Messages tracked here before sending, popped when RESP_CODE_SENT arrives
|
||||
final List<String> _pendingMessageIds = [];
|
||||
|
||||
/// Map of ACK tag to message ID for delivery confirmation
|
||||
final Map<int, String> _ackTagToMessageId = {};
|
||||
|
||||
/// Map of message ID to ACK tag (reverse mapping for cleanup)
|
||||
final Map<String, int> _messageIdToAckTag = {};
|
||||
|
||||
/// Map of ACK tag to timestamp for timeout cleanup
|
||||
final Map<int, DateTime> _ackTagTimestamps = {};
|
||||
|
||||
/// Track a pending message ID before sending
|
||||
///
|
||||
/// This is called BEFORE sending the message. When RESP_CODE_SENT
|
||||
/// arrives, we pop from this FIFO queue to match with the ACK tag.
|
||||
void trackPendingMessage(String messageId) {
|
||||
_pendingMessageIds.add(messageId);
|
||||
}
|
||||
|
||||
/// Pop the next pending message ID from FIFO queue
|
||||
///
|
||||
/// Called when RESP_CODE_SENT arrives. Returns null if queue empty.
|
||||
String? popPendingMessageId() {
|
||||
if (_pendingMessageIds.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return _pendingMessageIds.removeAt(0);
|
||||
}
|
||||
|
||||
/// Map ACK tag to message ID after RESP_CODE_SENT received
|
||||
///
|
||||
/// Creates bidirectional mapping for efficient cleanup and tracking.
|
||||
///
|
||||
/// WARNING: Firmware only tracks 8 pending ACKs! Caller should
|
||||
/// enforce rate limiting before calling this.
|
||||
void mapAckTagToMessageId(int ackTag, String messageId) {
|
||||
// Store bidirectional mapping
|
||||
_ackTagToMessageId[ackTag] = messageId;
|
||||
_messageIdToAckTag[messageId] = ackTag;
|
||||
_ackTagTimestamps[ackTag] = DateTime.now();
|
||||
}
|
||||
|
||||
/// Get message ID for ACK code
|
||||
///
|
||||
/// Called when SEND_CONFIRMED arrives. Returns the message ID
|
||||
/// that corresponds to this ACK code.
|
||||
///
|
||||
/// Returns null if ACK tag not found.
|
||||
String? getMessageIdForAck(int ackCode) {
|
||||
return _ackTagToMessageId[ackCode];
|
||||
}
|
||||
|
||||
/// Remove ACK tag mapping after delivery confirmed or timeout
|
||||
///
|
||||
/// Cleans up both forward and reverse mappings.
|
||||
void removeAckTag(int ackCode) {
|
||||
final messageId = _ackTagToMessageId.remove(ackCode);
|
||||
if (messageId != null) {
|
||||
_messageIdToAckTag.remove(messageId);
|
||||
}
|
||||
_ackTagTimestamps.remove(ackCode);
|
||||
}
|
||||
|
||||
/// Remove ACK tag mapping by message ID
|
||||
///
|
||||
/// Used when message times out or is cancelled.
|
||||
void removeByMessageId(String messageId) {
|
||||
final ackTag = _messageIdToAckTag.remove(messageId);
|
||||
if (ackTag != null) {
|
||||
_ackTagToMessageId.remove(ackTag);
|
||||
_ackTagTimestamps.remove(ackTag);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean up stale ACK mappings
|
||||
///
|
||||
/// Removes ACK tags that haven't received delivery confirmation
|
||||
/// within the specified timeout (default: 5 minutes).
|
||||
///
|
||||
/// Returns count of cleaned up entries.
|
||||
int cleanupStaleAcks({Duration timeout = const Duration(minutes: 5)}) {
|
||||
final now = DateTime.now();
|
||||
final staleAcks = <int>[];
|
||||
|
||||
for (final entry in _ackTagTimestamps.entries) {
|
||||
if (now.difference(entry.value) > timeout) {
|
||||
staleAcks.add(entry.key);
|
||||
}
|
||||
}
|
||||
|
||||
for (final ackTag in staleAcks) {
|
||||
removeAckTag(ackTag);
|
||||
}
|
||||
|
||||
return staleAcks.length;
|
||||
}
|
||||
|
||||
/// Clear all tracking state
|
||||
void clearTracking() {
|
||||
_pendingMessageIds.clear();
|
||||
_ackTagToMessageId.clear();
|
||||
_messageIdToAckTag.clear();
|
||||
_ackTagTimestamps.clear();
|
||||
}
|
||||
|
||||
/// Get count of pending ACK tags
|
||||
///
|
||||
/// WARNING: Firmware only tracks 8 pending ACKs in circular buffer.
|
||||
/// If this exceeds 7, message sending should be rate limited.
|
||||
int get pendingCount => _ackTagToMessageId.length;
|
||||
|
||||
/// Check if should rate limit message sending
|
||||
///
|
||||
/// Returns true if >= 7 pending ACKs (stay under firmware limit of 8)
|
||||
bool get shouldRateLimit => pendingCount >= 7;
|
||||
|
||||
/// Get oldest pending ACK timestamp (for debugging)
|
||||
DateTime? get oldestPendingTimestamp {
|
||||
if (_ackTagTimestamps.isEmpty) return null;
|
||||
return _ackTagTimestamps.values.reduce(
|
||||
(a, b) => a.isBefore(b) ? a : b,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get diagnostic info for debugging
|
||||
Map<String, dynamic> getDiagnostics() {
|
||||
return {
|
||||
'pendingCount': pendingCount,
|
||||
'shouldRateLimit': shouldRateLimit,
|
||||
'oldestPending': oldestPendingTimestamp?.toIso8601String(),
|
||||
'ackTags': _ackTagToMessageId.keys.toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
101
lib/providers/helpers/message_retry_manager.dart
Normal file
101
lib/providers/helpers/message_retry_manager.dart
Normal file
@@ -0,0 +1,101 @@
|
||||
import '../../models/message.dart';
|
||||
import '../../models/contact.dart';
|
||||
|
||||
/// Manages message retry state and logic
|
||||
///
|
||||
/// This helper class centralizes retry logic for direct messages, implementing
|
||||
/// a progressive timeout strategy (4s, 8s, 12s) for messages sent to contacts
|
||||
/// with learned routing paths.
|
||||
///
|
||||
/// IMPORTANT: Based on MeshCore firmware analysis:
|
||||
/// - Firmware calculates timeout based on path length and airtime
|
||||
/// - Direct mode: ~(path_len * airtime * 2) + margin
|
||||
/// - Flood mode: ~10-30 seconds for multi-hop
|
||||
/// - Our timeouts (4s, 8s, 12s) are conservative for direct paths
|
||||
/// - Firmware does NOT automatically retry - app must implement
|
||||
class MessageRetryManager {
|
||||
// Track retry state for each message ID
|
||||
final Map<String, int> _retryAttempts = {};
|
||||
final Map<String, DateTime> _lastRetryTimes = {};
|
||||
|
||||
// Progressive timeout values in milliseconds
|
||||
// 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];
|
||||
|
||||
/// Get timeout for a specific retry attempt (0-2)
|
||||
/// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2
|
||||
int getTimeoutForAttempt(int attempt) {
|
||||
if (attempt < 0 || attempt >= _timeouts.length) {
|
||||
return _timeouts.last; // Default to last timeout if out of range
|
||||
}
|
||||
return _timeouts[attempt];
|
||||
}
|
||||
|
||||
/// Check if a message is eligible for retry
|
||||
///
|
||||
/// Returns true if:
|
||||
/// - The message has retryAttempt < 3
|
||||
/// - The contact has a learned path (contact.hasPath == true)
|
||||
/// - The message hasn't used flood fallback yet
|
||||
///
|
||||
/// Messages to contacts without paths should NOT retry (flood mode already broadcasts)
|
||||
bool canRetry(Message message, Contact contact) {
|
||||
// Never retry if already tried flood mode
|
||||
if (message.usedFloodFallback) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Never retry beyond 3 attempts
|
||||
if (message.retryAttempt >= 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only retry if contact has a learned path
|
||||
// If no path, the device uses flood mode automatically - retrying won't help
|
||||
return contact.hasPath;
|
||||
}
|
||||
|
||||
/// Check if should fall back to flood mode
|
||||
///
|
||||
/// Returns true if:
|
||||
/// - Message has exhausted all 3 retry attempts with direct mode
|
||||
/// - Contact HAS a learned path (so direct mode was used)
|
||||
/// - Hasn't already used flood fallback
|
||||
///
|
||||
/// IMPORTANT: Only contacts WITH paths need flood fallback.
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// Track a retry attempt for a message
|
||||
void trackRetry(String messageId, int attempt) {
|
||||
_retryAttempts[messageId] = attempt;
|
||||
_lastRetryTimes[messageId] = DateTime.now();
|
||||
}
|
||||
|
||||
/// Clear retry tracking for a message (on success or permanent failure)
|
||||
void clearRetry(String messageId) {
|
||||
_retryAttempts.remove(messageId);
|
||||
_lastRetryTimes.remove(messageId);
|
||||
}
|
||||
|
||||
/// Clear all retry tracking (on disconnect)
|
||||
void clearAll() {
|
||||
_retryAttempts.clear();
|
||||
_lastRetryTimes.clear();
|
||||
}
|
||||
|
||||
/// Get current retry attempt for a message (for debugging)
|
||||
int? getRetryAttempt(String messageId) {
|
||||
return _retryAttempts[messageId];
|
||||
}
|
||||
|
||||
/// Get last retry time for a message (for debugging)
|
||||
DateTime? getLastRetryTime(String messageId) {
|
||||
return _lastRetryTimes[messageId];
|
||||
}
|
||||
}
|
||||
103
lib/providers/helpers/ping_tracker.dart
Normal file
103
lib/providers/helpers/ping_tracker.dart
Normal file
@@ -0,0 +1,103 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Helper class to track pending ping (telemetry) requests
|
||||
/// and implement automatic fallback to flooding if no response received
|
||||
class PingTracker {
|
||||
// Map of public key hex string to ping request state
|
||||
final Map<String, _PingRequest> _pendingPings = {};
|
||||
|
||||
// Timeout duration for ping responses (seconds)
|
||||
static const int _pingTimeoutSeconds = 5;
|
||||
|
||||
/// Track a new ping request
|
||||
/// Returns a Future that completes when either:
|
||||
/// - A response is received (completes with true)
|
||||
/// - Timeout occurs (completes with false)
|
||||
Future<bool> trackPing({
|
||||
required Uint8List publicKey,
|
||||
required bool wasDirectAttempt,
|
||||
}) {
|
||||
final String keyHex = _publicKeyToHex(publicKey);
|
||||
|
||||
// Cancel any existing pending ping for this contact
|
||||
_pendingPings[keyHex]?.cancel();
|
||||
|
||||
// Create new ping request tracker
|
||||
final completer = Completer<bool>();
|
||||
final timer = Timer(const Duration(seconds: _pingTimeoutSeconds), () {
|
||||
// Timeout occurred - mark as failed
|
||||
_pendingPings.remove(keyHex);
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(false);
|
||||
}
|
||||
});
|
||||
|
||||
_pendingPings[keyHex] = _PingRequest(
|
||||
publicKey: publicKey,
|
||||
wasDirectAttempt: wasDirectAttempt,
|
||||
timer: timer,
|
||||
completer: completer,
|
||||
);
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
/// Mark a ping as successful (response received)
|
||||
/// Should be called when telemetry response arrives
|
||||
void markPingSuccessful(Uint8List publicKey) {
|
||||
final String keyHex = _publicKeyToHex(publicKey);
|
||||
final request = _pendingPings.remove(keyHex);
|
||||
|
||||
if (request != null) {
|
||||
request.cancel();
|
||||
if (!request.completer.isCompleted) {
|
||||
request.completer.complete(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if there's a pending ping for this contact
|
||||
bool hasPendingPing(Uint8List publicKey) {
|
||||
final String keyHex = _publicKeyToHex(publicKey);
|
||||
return _pendingPings.containsKey(keyHex);
|
||||
}
|
||||
|
||||
/// Get pending ping info (was it a direct attempt?)
|
||||
bool? wasPingDirect(Uint8List publicKey) {
|
||||
final String keyHex = _publicKeyToHex(publicKey);
|
||||
return _pendingPings[keyHex]?.wasDirectAttempt;
|
||||
}
|
||||
|
||||
/// Clear all pending pings (useful on disconnect)
|
||||
void clearAll() {
|
||||
for (final request in _pendingPings.values) {
|
||||
request.cancel();
|
||||
}
|
||||
_pendingPings.clear();
|
||||
}
|
||||
|
||||
/// Convert public key to hex string for map key
|
||||
String _publicKeyToHex(Uint8List publicKey) {
|
||||
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal class to track a single ping request
|
||||
class _PingRequest {
|
||||
final Uint8List publicKey;
|
||||
final bool wasDirectAttempt;
|
||||
final Timer timer;
|
||||
final Completer<bool> completer;
|
||||
|
||||
_PingRequest({
|
||||
required this.publicKey,
|
||||
required this.wasDirectAttempt,
|
||||
required this.timer,
|
||||
required this.completer,
|
||||
});
|
||||
|
||||
void cancel() {
|
||||
timer.cancel();
|
||||
}
|
||||
}
|
||||
84
lib/providers/helpers/room_login_manager.dart
Normal file
84
lib/providers/helpers/room_login_manager.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../models/room_login_state.dart';
|
||||
|
||||
/// Room login state management helper
|
||||
///
|
||||
/// Manages login state tracking for room contacts, including:
|
||||
/// - Room login state per contact (Map of String to RoomLoginState)
|
||||
/// - Password checking logic
|
||||
/// - Login success/fail state updates
|
||||
class RoomLoginManager {
|
||||
/// Map of room public key prefix (hex string) to login state
|
||||
final Map<String, RoomLoginState> _roomLoginStates = {};
|
||||
|
||||
/// Get all room login states (unmodifiable view)
|
||||
Map<String, RoomLoginState> get roomLoginStates => Map.unmodifiable(_roomLoginStates);
|
||||
|
||||
/// Get login state for a room by public key prefix
|
||||
RoomLoginState? getRoomLoginState(Uint8List publicKeyPrefix) {
|
||||
final prefixHex = _publicKeyPrefixToHex(publicKeyPrefix);
|
||||
return _roomLoginStates[prefixHex];
|
||||
}
|
||||
|
||||
/// Check if logged into a specific room
|
||||
bool isLoggedIntoRoom(Uint8List publicKeyPrefix) {
|
||||
final state = getRoomLoginState(publicKeyPrefix);
|
||||
return state?.isLoggedIn ?? false;
|
||||
}
|
||||
|
||||
/// Update room login state after successful login
|
||||
Future<void> handleLoginSuccess({
|
||||
required Uint8List publicKeyPrefix,
|
||||
required int permissions,
|
||||
required bool isAdmin,
|
||||
required int tag,
|
||||
}) async {
|
||||
final prefixHex = _publicKeyPrefixToHex(publicKeyPrefix);
|
||||
final hasPassword = await _hasPasswordForRoom(publicKeyPrefix);
|
||||
|
||||
_roomLoginStates[prefixHex] = RoomLoginState.loggedIn(
|
||||
publicKeyPrefix: publicKeyPrefix,
|
||||
permissions: permissions,
|
||||
isAdmin: isAdmin,
|
||||
tag: tag,
|
||||
hasPassword: hasPassword,
|
||||
);
|
||||
}
|
||||
|
||||
/// Update room login state after failed login
|
||||
void handleLoginFail({
|
||||
required Uint8List publicKeyPrefix,
|
||||
}) {
|
||||
final prefixHex = _publicKeyPrefixToHex(publicKeyPrefix);
|
||||
|
||||
_roomLoginStates[prefixHex] = RoomLoginState.loggedOut(
|
||||
publicKeyPrefix: publicKeyPrefix,
|
||||
hasPassword: false, // Password was incorrect
|
||||
);
|
||||
}
|
||||
|
||||
/// Clear all room login states (call on disconnect)
|
||||
void clearRoomLoginStates() {
|
||||
_roomLoginStates.clear();
|
||||
}
|
||||
|
||||
/// Check if a password exists for a room (by public key prefix)
|
||||
Future<bool> _hasPasswordForRoom(Uint8List publicKeyPrefix) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
// Convert prefix to hex string for storage key
|
||||
final prefixHex = _publicKeyPrefixToHex(publicKeyPrefix);
|
||||
final roomKey = 'room_password_$prefixHex';
|
||||
return prefs.getString(roomKey) != null;
|
||||
} catch (e) {
|
||||
debugPrint('Error checking password for room: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert public key prefix to hex string (colon-separated)
|
||||
String _publicKeyPrefixToHex(Uint8List publicKeyPrefix) {
|
||||
return publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user