feat: MeshCore SAR - Flutter BLE mesh radio companion app

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janez T
2026-02-28 10:11:33 +01:00
commit baf49d27d8
313 changed files with 101770 additions and 0 deletions

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

View 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];
}
}

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

View 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(':');
}
}