Add transmission timing details

This commit is contained in:
Janez T
2026-03-07 09:05:55 +01:00
parent 810897d348
commit f6c5a3a4ca
21 changed files with 2010 additions and 602 deletions

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'connection_provider.dart';
@@ -16,9 +17,12 @@ import '../services/packet_capture_storage_service.dart';
import '../models/contact.dart';
import '../models/message.dart';
import '../models/ble_packet_log.dart';
import '../models/message_reception_details.dart';
import '../utils/drawing_message_parser.dart';
import '../utils/raw_route_probe.dart';
import '../utils/voice_message_parser.dart';
import '../utils/image_message_parser.dart';
import '../utils/message_airtime_estimator.dart';
/// Main App Provider - coordinates all other providers
class AppProvider with ChangeNotifier {
@@ -70,6 +74,8 @@ class AppProvider with ChangeNotifier {
FragmentAckWaitRegistry();
final FragmentAckWaitRegistry _imageFragmentAckWaiters =
FragmentAckWaitRegistry();
final FragmentAckWaitRegistry _rawProbeWaiters = FragmentAckWaitRegistry();
final Map<String, Future<bool>> _pendingRawRouteProbes = {};
Timer? _packetCaptureFlushTimer;
String? _lastPersistedPacketSignature;
bool _isPersistingPacketCapture = false;
@@ -633,6 +639,9 @@ class AppProvider with ChangeNotifier {
capturedAt: enrichedMessage.receivedAt,
)
: null;
final receptionDetailsSnapshot = _buildReceptionDetailsSnapshot(
enrichedMessage,
);
// Check if message is a drawing broadcast
if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) {
@@ -664,6 +673,7 @@ class AppProvider with ChangeNotifier {
updatedMessage,
contactLookup: (name) => '',
contactLocationSnapshot: contactLocationSnapshot,
receptionDetailsSnapshot: receptionDetailsSnapshot,
);
// Broadcast drawing message to SSE clients if server is running
@@ -700,6 +710,7 @@ class AppProvider with ChangeNotifier {
}
},
contactLocationSnapshot: contactLocationSnapshot,
receptionDetailsSnapshot: receptionDetailsSnapshot,
);
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
return;
@@ -728,6 +739,7 @@ class AppProvider with ChangeNotifier {
}
},
contactLocationSnapshot: contactLocationSnapshot,
receptionDetailsSnapshot: receptionDetailsSnapshot,
);
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
return;
@@ -765,12 +777,16 @@ class AppProvider with ChangeNotifier {
}
},
contactLocationSnapshot: contactLocationSnapshot,
receptionDetailsSnapshot: receptionDetailsSnapshot,
);
// Broadcast message to SSE clients if server is running
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
};
// Keep a compact receive-time snapshot because packet logs roll over.
// This lets the UI still show timing/link details after app restarts.
// When telemetry is received via PUSH_CODE_TELEMETRY_RESPONSE (0x8B)
// Used by older firmware versions for telemetry responses
connectionProvider.onTelemetryReceived = (publicKey, lppData) {
@@ -796,6 +812,18 @@ class AppProvider with ChangeNotifier {
// Magic 0x72 'r' = voice fetch request; 0x69 'i' = image fetch request.
// Magic 0x56 'V' = voice packet; magic 0x49 'I' = image packet.
connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) {
final rawProbeRequest = RawRouteProbeRequest.tryParseBinary(payload);
if (rawProbeRequest != null) {
_handleRawRouteProbeRequest(rawProbeRequest);
return;
}
final rawProbeAck = RawRouteProbeAck.tryParseBinary(payload);
if (rawProbeAck != null) {
_completeRawRouteProbeAck(rawProbeAck.nonce);
return;
}
final voiceFetchRequest = VoiceFetchRequest.tryParseBinary(payload);
if (voiceFetchRequest != null) {
final requester = _resolveVoiceFetchRequester(voiceFetchRequest);
@@ -1559,6 +1587,78 @@ class AppProvider with ChangeNotifier {
}
String _fragmentAckKey(String sessionId, int index) => '$sessionId:$index';
String _rawProbeKey(int nonce) =>
nonce.toRadixString(16).padLeft(8, '0').toLowerCase();
Future<bool> verifyRawTransportRoute(
Contact target, {
Duration timeout = const Duration(seconds: 8),
}) async {
if (!connectionProvider.deviceInfo.isConnected) {
return false;
}
if (target.outPathLen < 0 || target.outPathLen > _maxDirectPayloadHops) {
return false;
}
if (target.outPath.isEmpty) {
return false;
}
final probeKey = _routeProbeTargetKey(target);
final pendingProbe = _pendingRawRouteProbes[probeKey];
if (pendingProbe != null) {
return pendingProbe;
}
final deviceKey = connectionProvider.deviceInfo.publicKey;
if (deviceKey == null || deviceKey.length < 6) {
return false;
}
final requesterKey6 = deviceKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final future = () async {
final nonce = math.Random.secure().nextInt(0x100000000);
final ackFuture = _rawProbeWaiters.waitFor(
_rawProbeKey(nonce),
timeout: timeout,
);
try {
await connectionProvider.sendRawVoicePacket(
contactPath: target.outPath,
contactPathLen: target.outPathLen,
payload: RawRouteProbeRequest(
nonce: nonce,
requesterKey6: requesterKey6,
).encodeBinary(),
);
return await ackFuture;
} catch (e) {
debugPrint(
'⚠️ [AppProvider] Raw route probe failed for ${target.advName}: $e',
);
_rawProbeWaiters.complete(_rawProbeKey(nonce));
return false;
}
}();
_pendingRawRouteProbes[probeKey] = future;
try {
return await future;
} finally {
_pendingRawRouteProbes.remove(probeKey);
}
}
String _routeProbeTargetKey(Contact target) {
if (target.publicKeyHex.isNotEmpty) {
return 'pk:${target.publicKeyHex}';
}
return 'name:${target.advName}:${target.outPathLen}:${target.outPath.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}';
}
Future<bool> _waitForVoiceFragmentAck({
required String sessionId,
@@ -1608,6 +1708,37 @@ class AppProvider with ChangeNotifier {
);
}
void _handleRawRouteProbeRequest(RawRouteProbeRequest request) {
final requester = _resolveContactByPrefixHex(request.requesterKey6);
if (requester == null) {
debugPrint(
'⚠️ [AppProvider] Raw route probe requester not found: ${request.requesterKey6}',
);
return;
}
if (requester.outPathLen < 0 ||
requester.outPathLen > _maxDirectPayloadHops) {
debugPrint(
'⚠️ [AppProvider] Raw route probe requester out of range: ${requester.outPathLen}',
);
return;
}
if (requester.outPath.isEmpty) {
return;
}
unawaited(
connectionProvider.sendRawVoicePacket(
contactPath: requester.outPath,
contactPathLen: requester.outPathLen,
payload: RawRouteProbeAck(nonce: request.nonce).encodeBinary(),
),
);
}
void _completeRawRouteProbeAck(int nonce) {
_rawProbeWaiters.complete(_rawProbeKey(nonce));
}
void _sendVoiceFragmentAck(VoicePacket packet) {
final senderKey6 = _voiceSessionSenderKey6[packet.sessionId];
if (senderKey6 == null) return;
@@ -1648,6 +1779,95 @@ class AppProvider with ChangeNotifier {
);
}
MessageReceptionDetails? _buildReceptionDetailsSnapshot(Message message) {
final matchedRxLog = _findBestMatchingRxLog(message);
final estimatedTx = estimateMessageTransmitDuration(
message,
radioBw: connectionProvider.deviceInfo.radioBw,
radioSf: connectionProvider.deviceInfo.radioSf,
radioCr: connectionProvider.deviceInfo.radioCr,
);
final senderToReceiptMs = _senderToReceiptMs(message);
final estimatedTransmitMs = estimatedTx > Duration.zero
? estimatedTx.inMilliseconds
: null;
final postTransmitDelayMs =
senderToReceiptMs != null && estimatedTransmitMs != null
? (senderToReceiptMs - estimatedTransmitMs).clamp(0, 86400000).toInt()
: null;
if (matchedRxLog == null &&
senderToReceiptMs == null &&
estimatedTransmitMs == null) {
return null;
}
return MessageReceptionDetails(
capturedAt: DateTime.now(),
packetLoggedAt: matchedRxLog?.timestamp,
rssiDbm: matchedRxLog?.logRxDataInfo?.rssiDbm,
snrDb: matchedRxLog?.logRxDataInfo?.snrDb,
pathBytes: _extractPathBytesFromLog(matchedRxLog),
senderToReceiptMs: senderToReceiptMs,
estimatedTransmitMs: estimatedTransmitMs,
postTransmitDelayMs: postTransmitDelayMs,
);
}
int? _senderToReceiptMs(Message message) {
if (message.senderTimestamp <= 0) return null;
final senderAt = DateTime.fromMillisecondsSinceEpoch(
message.senderTimestamp * 1000,
isUtc: true,
);
final deltaMs = message.receivedAt
.toUtc()
.difference(senderAt)
.inMilliseconds;
if (deltaMs < 0 || deltaMs > 86400000) return null;
return deltaMs;
}
BlePacketLog? _findBestMatchingRxLog(Message message) {
if (message.pathLen < 0 || message.pathLen >= 255) return null;
final expectedPayloadType = message.messageType == MessageType.channel
? 0x05
: 0x02;
BlePacketLog? bestLog;
var bestDeltaMs = 999999999;
for (final log in connectionProvider.bleService.packetLogs) {
if (log.responseCode != 0x88) continue;
if (log.rawData.length < 6) continue;
final raw = log.rawData;
final payloadType = (raw[3] >> 2) & 0x0F;
final pathLen = raw[4];
if (payloadType != expectedPayloadType) continue;
if (pathLen != message.pathLen) continue;
if (raw.length < 5 + pathLen) continue;
final deltaMs =
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
if (deltaMs < bestDeltaMs) {
bestDeltaMs = deltaMs;
bestLog = log;
}
}
if (bestDeltaMs > 30000) return null;
return bestLog;
}
List<int>? _extractPathBytesFromLog(BlePacketLog? log) {
if (log == null) return null;
final raw = log.rawData;
if (raw.length < 6) return null;
final pathLen = raw[4];
if (pathLen <= 0 || raw.length < 5 + pathLen) return null;
return raw.sublist(5, 5 + pathLen);
}
// Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events
// The ConnectionProvider's onMessageWaiting callback handles automatic message fetching

View File

@@ -146,11 +146,15 @@ class ConnectionProvider with ChangeNotifier {
final MessageDeliveryTracker _messageDeliveryTracker =
MessageDeliveryTracker();
final PingTracker _pingTracker = PingTracker();
final Map<String, Future<PingResult>> _pendingSmartPings = {};
// Expose room login states
Map<String, RoomLoginState> get roomLoginStates =>
_roomLoginManager.roomLoginStates;
bool isPingInProgress(Uint8List publicKey) =>
_pendingSmartPings.containsKey(_publicKeyToHex(publicKey));
// Callbacks for other providers
Function(Contact)? onContactReceived;
Function(List<Contact>)? onContactsComplete;
@@ -1316,6 +1320,34 @@ class ConnectionProvider with ChangeNotifier {
required Uint8List contactPublicKey,
required bool hasPath,
Function()? onRetryWithFlooding,
}) async {
final pingKey = _publicKeyToHex(contactPublicKey);
final pendingPing = _pendingSmartPings[pingKey];
if (pendingPing != null) {
debugPrint(' [Provider] Joining in-flight ping for $pingKey');
return pendingPing;
}
final future = _runSmartPing(
contactPublicKey: contactPublicKey,
hasPath: hasPath,
onRetryWithFlooding: onRetryWithFlooding,
);
_pendingSmartPings[pingKey] = future;
notifyListeners();
try {
return await future;
} finally {
_pendingSmartPings.remove(pingKey);
notifyListeners();
}
}
Future<PingResult> _runSmartPing({
required Uint8List contactPublicKey,
required bool hasPath,
Function()? onRetryWithFlooding,
}) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
@@ -1334,7 +1366,10 @@ class ConnectionProvider with ChangeNotifier {
);
// Send the ping
await _activeService.requestTelemetry(contactPublicKey, zeroHop: true);
await _activeService.requestTelemetry(
contactPublicKey,
zeroHop: firstAttemptDirect,
);
// Wait for response or timeout
final bool gotResponse = await pingFuture;
@@ -1361,8 +1396,8 @@ class ConnectionProvider with ChangeNotifier {
wasDirectAttempt: false,
);
// Retry with flooding (zeroHop=true acts as broadcast to neighbors)
await _activeService.requestTelemetry(contactPublicKey, zeroHop: true);
// Retry with flooding.
await _activeService.requestTelemetry(contactPublicKey, zeroHop: false);
// Wait for response or timeout
final bool gotRetryResponse = await retryFuture;
@@ -1384,6 +1419,10 @@ class ConnectionProvider with ChangeNotifier {
}
}
String _publicKeyToHex(Uint8List publicKey) {
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
}
/// Send binary request to contact (modern replacement for requestTelemetry)
///
/// Supports multiple request types:

View File

@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import '../models/message.dart';
import '../models/contact.dart';
import '../models/message_contact_location.dart';
import '../models/message_reception_details.dart';
import '../models/sar_marker.dart';
import '../models/map_drawing.dart';
import '../services/message_storage_service.dart';
@@ -22,6 +23,7 @@ class MessagesProvider with ChangeNotifier {
bool _isInitialized = false;
AppLocalizations? _localizations;
final Map<String, MessageContactLocation> _messageContactLocations = {};
final Map<String, MessageReceptionDetails> _messageReceptionDetails = {};
// Track pending sent messages by expected ACK/TAG
final Map<int, Message> _pendingSentMessages = {};
@@ -74,10 +76,7 @@ class MessagesProvider with ChangeNotifier {
})?
sendMessageCallback;
Future<void> Function({
required Contact contact,
required int failureStreak,
})?
Future<void> Function({required Contact contact, required int failureStreak})?
onDirectPathFailedCallback;
List<Message> get messages => List.unmodifiable(_messages);
@@ -115,6 +114,9 @@ class MessagesProvider with ChangeNotifier {
MessageContactLocation? getMessageContactLocation(String messageId) =>
_messageContactLocations[messageId];
MessageReceptionDetails? getMessageReceptionDetails(String messageId) =>
_messageReceptionDetails[messageId];
/// Set localizations for notifications
void setLocalizations(AppLocalizations localizations) {
_localizations = localizations;
@@ -145,9 +147,14 @@ class MessagesProvider with ChangeNotifier {
final storedMessages = await _storageService.loadMessages();
final storedContactLocations = await _storageService
.loadMessageContactLocations();
final storedReceptionDetails = await _storageService
.loadMessageReceptionDetails();
_messageContactLocations
..clear()
..addAll(storedContactLocations);
_messageReceptionDetails
..clear()
..addAll(storedReceptionDetails);
// Add stored messages with enhancement to ensure SAR detection
for (final message in storedMessages) {
@@ -311,6 +318,7 @@ class MessagesProvider with ChangeNotifier {
Message message, {
String Function(String name)? contactLookup,
MessageContactLocation? contactLocationSnapshot,
MessageReceptionDetails? receptionDetailsSnapshot,
}) {
// Always enhance message with SAR parser to detect SAR markers
var enhancedMessage = SarMessageParser.enhanceMessage(message);
@@ -397,6 +405,22 @@ class MessagesProvider with ChangeNotifier {
debugPrint(
' Text: ${finalMessage.text.substring(0, finalMessage.text.length > 50 ? 50 : finalMessage.text.length)}...',
);
final existingIndex = _messages.indexWhere(
(existing) =>
existing.messageType == finalMessage.messageType &&
existing.senderTimestamp == finalMessage.senderTimestamp &&
existing.text == finalMessage.text,
);
if (existingIndex != -1) {
final existingId = _messages[existingIndex].id;
if (contactLocationSnapshot != null) {
_messageContactLocations[existingId] = contactLocationSnapshot;
}
if (receptionDetailsSnapshot != null) {
_messageReceptionDetails[existingId] = receptionDetailsSnapshot;
}
_persistMessages();
}
return; // Skip duplicate
}
@@ -404,6 +428,9 @@ class MessagesProvider with ChangeNotifier {
if (contactLocationSnapshot != null) {
_messageContactLocations[finalMessage.id] = contactLocationSnapshot;
}
if (receptionDetailsSnapshot != null) {
_messageReceptionDetails[finalMessage.id] = receptionDetailsSnapshot;
}
// If it's a SAR marker message, extract and store the marker
if (finalMessage.isSarMarker) {
@@ -593,6 +620,7 @@ class MessagesProvider with ChangeNotifier {
await _storageService.saveMessages(
_messages,
messageContactLocations: _messageContactLocations,
messageReceptionDetails: _messageReceptionDetails,
);
} catch (e) {
debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
@@ -709,6 +737,7 @@ class MessagesProvider with ChangeNotifier {
_messageContactMap.remove(messageId);
_groupedMessageMapping.remove(messageId);
_messageContactLocations.remove(messageId);
_messageReceptionDetails.remove(messageId);
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
@@ -738,6 +767,7 @@ class MessagesProvider with ChangeNotifier {
_messages.clear();
_sarMarkers.clear();
_messageContactLocations.clear();
_messageReceptionDetails.clear();
_persistMessages();
notifyListeners();
}
@@ -753,6 +783,7 @@ class MessagesProvider with ChangeNotifier {
_messages.clear();
_sarMarkers.clear();
_messageContactLocations.clear();
_messageReceptionDetails.clear();
_persistMessages();
notifyListeners();
}
@@ -1062,10 +1093,11 @@ class MessagesProvider with ChangeNotifier {
' Message text preview: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...',
);
// Once the device accepts a direct message and returns an ACK tag, the
// send itself succeeded locally even if end-to-end delivery confirmation
// may still arrive later. Keep ACK tracking, but stop showing "waiting".
final updatedMessage = message.copyWith(
deliveryStatus: expectedAckTag > 0
? MessageDeliveryStatus.sending
: MessageDeliveryStatus.sent,
deliveryStatus: MessageDeliveryStatus.sent,
expectedAckTag: expectedAckTag > 0 ? expectedAckTag : null,
suggestedTimeoutMs: expectedAckTag > 0 ? effectiveTimeout : null,
);