feat: enhance MeshCoreConnector with improved timeout calculation and path resolution; add PathHopResolver for better contact resolution

This commit is contained in:
zjs81
2026-06-13 00:36:45 -07:00
parent 3707acb124
commit 5ea6b17b16
8 changed files with 529 additions and 234 deletions
+160 -52
View File
@@ -208,6 +208,9 @@ class MeshCoreConnector extends ChangeNotifier {
// Intentionally global (not per-contact): tracks overall network activity. // Intentionally global (not per-contact): tracks overall network activity.
// Frequent RX from any source indicates a busy network with more collisions. // Frequent RX from any source indicates a busy network with more collisions.
DateTime _lastRxTime = DateTime.now(); DateTime _lastRxTime = DateTime.now();
// Snapshot of _lastRxTime taken before the ACK frame updates it, so that
// onDeliveryObserved records the pre-ACK elapsed time (matching prediction).
DateTime _lastRxBeforeFrame = DateTime.fromMillisecondsSinceEpoch(0);
DateTime _lastRadioRxTime = DateTime.fromMillisecondsSinceEpoch(0); DateTime _lastRadioRxTime = DateTime.fromMillisecondsSinceEpoch(0);
DateTime _lastContactMsgRxTime = DateTime.fromMillisecondsSinceEpoch(0); DateTime _lastContactMsgRxTime = DateTime.fromMillisecondsSinceEpoch(0);
DateTime _lastChannelMsgRxTime = DateTime.fromMillisecondsSinceEpoch(0); DateTime _lastChannelMsgRxTime = DateTime.fromMillisecondsSinceEpoch(0);
@@ -945,11 +948,17 @@ class MeshCoreConnector extends ChangeNotifier {
updateMessage: _updateMessage, updateMessage: _updateMessage,
clearContactPath: clearContactPath, clearContactPath: clearContactPath,
setContactPath: setContactPath, setContactPath: setContactPath,
calculateTimeout: (pathLength, messageBytes, {String? contactKey}) => calculateTimeout:
calculateTimeout( (
pathLength,
messageBytes, {
String? contactKey,
int? deviceTimeoutMs,
}) => calculateTimeout(
pathLength: pathLength, pathLength: pathLength,
messageBytes: messageBytes, messageBytes: messageBytes,
contactKey: contactKey, contactKey: contactKey,
deviceTimeoutMs: deviceTimeoutMs,
), ),
getSelfPublicKey: () => _selfPublicKey, getSelfPublicKey: () => _selfPublicKey,
prepareContactOutboundText: prepareContactOutboundText, prepareContactOutboundText: prepareContactOutboundText,
@@ -965,7 +974,9 @@ class MeshCoreConnector extends ChangeNotifier {
recentSelections: recentSelections, recentSelections: recentSelections,
), ),
onDeliveryObserved: (contactKey, pathLength, messageBytes, tripTimeMs) { onDeliveryObserved: (contactKey, pathLength, messageBytes, tripTimeMs) {
final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds; final secSinceRx = DateTime.now()
.difference(_lastRxBeforeFrame)
.inSeconds;
_timeoutPredictionService?.recordObservation( _timeoutPredictionService?.recordObservation(
contactKey: contactKey, contactKey: contactKey,
pathLength: pathLength, pathLength: pathLength,
@@ -2683,41 +2694,62 @@ class MeshCoreConnector extends ChangeNotifier {
Uint8List data, { Uint8List data, {
String? channelSendQueueId, String? channelSendQueueId,
bool expectsGenericAck = false, bool expectsGenericAck = false,
bool waitForGenericAck = false,
}) async { }) async {
if (!isConnected) { if (!isConnected) {
throw Exception("Not connected to a MeshCore device"); throw Exception("Not connected to a MeshCore device");
} }
_bleDebugLogService?.logFrame(data, outgoing: true); _bleDebugLogService?.logFrame(data, outgoing: true);
if (_activeTransport == MeshCoreTransportType.usb) { final pendingAck = _trackPendingGenericAck(
await _usbManager.write(data);
// Brief pause so the device firmware can process each frame before the
// next arrives. Without this, rapid-fire frames over USB can cause the
// device to miss responses (especially on reconnect).
await Future<void>.delayed(const Duration(milliseconds: 10));
} else if (_activeTransport == MeshCoreTransportType.tcp) {
await _tcpConnector.write(data);
} else {
if (_rxCharacteristic == null) {
throw Exception("MeshCore RX characteristic not available");
}
// Prefer write without response when supported; fall back to write with response.
final properties = _rxCharacteristic!.properties;
final canWriteWithoutResponse = properties.writeWithoutResponse;
final canWriteWithResponse = properties.write;
if (!canWriteWithoutResponse && !canWriteWithResponse) {
throw Exception("MeshCore RX characteristic does not support write");
}
await _rxCharacteristic!.write(
data.toList(),
withoutResponse: canWriteWithoutResponse,
);
}
_trackPendingGenericAck(
data, data,
channelSendQueueId: channelSendQueueId, channelSendQueueId: channelSendQueueId,
expectsGenericAck: expectsGenericAck, expectsGenericAck: expectsGenericAck || waitForGenericAck,
waitForAck: waitForGenericAck,
); );
try {
if (_activeTransport == MeshCoreTransportType.usb) {
await _usbManager.write(data);
// Brief pause so the device firmware can process each frame before the
// next arrives. Without this, rapid-fire frames over USB can cause the
// device to miss responses (especially on reconnect).
await Future<void>.delayed(const Duration(milliseconds: 10));
} else if (_activeTransport == MeshCoreTransportType.tcp) {
await _tcpConnector.write(data);
} else {
if (_rxCharacteristic == null) {
throw Exception("MeshCore RX characteristic not available");
}
// Prefer write without response when supported; fall back to write with response.
final properties = _rxCharacteristic!.properties;
final canWriteWithoutResponse = properties.writeWithoutResponse;
final canWriteWithResponse = properties.write;
if (!canWriteWithoutResponse && !canWriteWithResponse) {
throw Exception("MeshCore RX characteristic does not support write");
}
await _rxCharacteristic!.write(
data.toList(),
withoutResponse: canWriteWithoutResponse,
);
}
} catch (_) {
if (pendingAck != null) {
_pendingGenericAckQueue.remove(pendingAck);
}
rethrow;
}
if (pendingAck?.completer != null) {
try {
await pendingAck!.completer!.future.timeout(const Duration(seconds: 5));
} on TimeoutException {
_pendingGenericAckQueue.remove(pendingAck);
throw TimeoutException(
'Timed out waiting for firmware acknowledgement',
);
}
}
} }
Future<void> requestBatteryStatus({bool force = false}) async { Future<void> requestBatteryStatus({bool force = false}) async {
@@ -2949,6 +2981,17 @@ class MeshCoreConnector extends ChangeNotifier {
}) async { }) async {
if (!isConnected || text.isEmpty) return; if (!isConnected || text.isEmpty) return;
final outboundBytes = utf8.encode(
prepareContactOutboundText(contact, text),
);
if (outboundBytes.length > maxTextPayloadBytes) {
debugPrint(
'sendMessage: dropping overlong message '
'(${outboundBytes.length} > $maxTextPayloadBytes bytes)',
);
return;
}
// Check if this is a reaction - apply locally with pending status and route through retry service // Check if this is a reaction - apply locally with pending status and route through retry service
final reactionInfo = ReactionHelper.parseReaction(text); final reactionInfo = ReactionHelper.parseReaction(text);
if (reactionInfo != null) { if (reactionInfo != null) {
@@ -3419,9 +3462,11 @@ class MeshCoreConnector extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> importDiscoveredContact(Contact contact) async { Future<bool> importDiscoveredContact(Contact contact) async {
if (!isConnected) return; if (!isConnected) return false;
// Manual saves must bypass the firmware's auto-add discovery policy.
// CMD_IMPORT_CONTACT replays an advert and may remain discovery-only.
await sendFrame( await sendFrame(
buildUpdateContactPathFrame( buildUpdateContactPathFrame(
contact.publicKey, contact.publicKey,
@@ -3434,6 +3479,7 @@ class MeshCoreConnector extends ChangeNotifier {
lon: contact.longitude, lon: contact.longitude,
lastModified: contact.lastSeen, lastModified: contact.lastSeen,
), ),
waitForGenericAck: true,
); );
// Update the discovered contact to mark it as active (imported) // Update the discovered contact to mark it as active (imported)
@@ -3459,6 +3505,8 @@ class MeshCoreConnector extends ChangeNotifier {
), ),
); );
notifyListeners(); notifyListeners();
unawaited(_persistDiscoveredContacts());
return true;
} }
Future<void> clearContactPath(Contact contact) async { Future<void> clearContactPath(Contact contact) async {
@@ -3864,6 +3912,7 @@ class MeshCoreConnector extends ChangeNotifier {
void _handleFrame(List<int> data) { void _handleFrame(List<int> data) {
if (data.isEmpty) return; if (data.isEmpty) return;
_lastRxBeforeFrame = _lastRxTime;
_lastRxTime = DateTime.now(); _lastRxTime = DateTime.now();
final frame = Uint8List.fromList(data); final frame = Uint8List.fromList(data);
@@ -4016,11 +4065,15 @@ class MeshCoreConnector extends ChangeNotifier {
} }
final failedAck = _pendingGenericAckQueue.removeAt(0); final failedAck = _pendingGenericAckQueue.removeAt(0);
failedAck.completer?.completeError(
Exception('Firmware rejected command with error code $errCode'),
);
if (failedAck.commandCode != cmdSendChannelTxtMsg || if (failedAck.commandCode != cmdSendChannelTxtMsg ||
failedAck.channelSendQueueId == null) { failedAck.channelSendQueueId == null) {
return; return;
} }
_pendingChannelSentQueue.remove(failedAck.channelSendQueueId); _pendingChannelSentQueue.remove(failedAck.channelSendQueueId);
_markPendingChannelMessageFailedById(failedAck.channelSendQueueId!);
} }
void _handlePathUpdated(Uint8List frame) { void _handlePathUpdated(Uint8List frame) {
@@ -4370,16 +4423,28 @@ class MeshCoreConnector extends ChangeNotifier {
// Same as max for flood — firmware uses a single formula // Same as max for flood — firmware uses a single formula
return 500 + (16 * airtime); return 500 + (16 * airtime);
} else { } else {
return airtime * (pathLength + 1); // Include firmware base (500ms) and per-hop processing (6*airtime+250)
// so ML cannot clamp below a physically plausible round-trip.
return 500 + ((airtime * 6 + 250) * pathLength);
} }
} }
/// Hard ceiling on any ML-derived or physics-fallback timeout (ms).
/// Prevents the flood formula (500 + 16·airtime at SF12 ≈ 150s) and an
/// unstable OLS model from producing multi-minute waits.
static const int _hardMaxTimeoutMs = 45000;
/// Calculate timeout for a message based on radio settings and path length. /// Calculate timeout for a message based on radio settings and path length.
/// Returns timeout in milliseconds, considering number of hops. /// Returns timeout in milliseconds, considering number of hops.
///
/// [deviceTimeoutMs] is the firmware's own est_timeout from RESP_CODE_SENT.
/// When ML is absent it is used as the fallback (clamped to physicsMin).
/// When ML is present it is used as an additional ceiling alongside physicsMax.
int calculateTimeout({ int calculateTimeout({
required int pathLength, required int pathLength,
int messageBytes = 100, int messageBytes = 100,
String? contactKey, String? contactKey,
int? deviceTimeoutMs,
}) { }) {
final airtime = _estimateAirtimeMs(messageBytes); final airtime = _estimateAirtimeMs(messageBytes);
final physicsMin = _physicsMinTimeout(pathLength, airtime); final physicsMin = _physicsMinTimeout(pathLength, airtime);
@@ -4394,17 +4459,26 @@ class MeshCoreConnector extends ChangeNotifier {
secondsSinceLastRx: secSinceRx, secondsSinceLastRx: secSinceRx,
); );
if (mlTimeout != null) { if (mlTimeout != null) {
// Use device est_timeout as an additional ceiling when available —
// the firmware computed it from real airtime, so it's better than
// a physics guess built on a 50 ms fallback.
final ceiling = deviceTimeoutMs != null && deviceTimeoutMs > physicsMin
? deviceTimeoutMs.clamp(physicsMin, _hardMaxTimeoutMs)
: physicsMax;
if (pathLength < 0) { if (pathLength < 0) {
// Flood: trust ML, only enforce firmware formula as floor // Flood: trust ML, only enforce firmware formula as floor
if (mlTimeout < physicsMin) { if (mlTimeout < physicsMin) {
return physicsMin; return physicsMin.clamp(0, _hardMaxTimeoutMs);
} }
} }
return mlTimeout.clamp(physicsMin, physicsMax); return mlTimeout.clamp(physicsMin, ceiling).clamp(0, _hardMaxTimeoutMs);
} }
// No ML data — use firmware formula // No ML data — prefer device est_timeout (it used real airtime), then physics.
return physicsMax; if (deviceTimeoutMs != null && deviceTimeoutMs > 0) {
return deviceTimeoutMs.clamp(physicsMin, _hardMaxTimeoutMs);
}
return physicsMax.clamp(0, _hardMaxTimeoutMs);
} }
void _handleContact(Uint8List frame, {bool isContact = true}) { void _handleContact(Uint8List frame, {bool isContact = true}) {
@@ -4760,14 +4834,11 @@ class MeshCoreConnector extends ChangeNotifier {
final existing = _conversations[message.senderKeyHex]; final existing = _conversations[message.senderKeyHex];
final incomingTimestamp = message.timestamp.millisecondsSinceEpoch; final incomingTimestamp = message.timestamp.millisecondsSinceEpoch;
if (existing != null && existing.isNotEmpty) { if (existing != null && existing.isNotEmpty) {
final startIndex = existing.length > 10 ? existing.length - 10 : 0; final last = existing.last;
for (int i = existing.length - 1; i >= startIndex; i--) { if (!last.isOutgoing &&
final recent = existing[i]; last.timestamp.millisecondsSinceEpoch == incomingTimestamp &&
if (!recent.isOutgoing && last.text == message.text) {
recent.timestamp.millisecondsSinceEpoch == incomingTimestamp && return;
recent.text == message.text) {
return;
}
} }
} }
} }
@@ -5351,12 +5422,37 @@ class MeshCoreConnector extends ChangeNotifier {
return false; return false;
} }
void _markPendingChannelMessageFailedById(String messageId) {
for (final entry in _channelMessages.entries) {
final channelMessages = entry.value;
for (int i = channelMessages.length - 1; i >= 0; i--) {
final message = channelMessages[i];
if (message.messageId != messageId) {
continue;
}
if (!message.isOutgoing ||
message.status != ChannelMessageStatus.pending) {
return;
}
channelMessages[i] = message.copyWith(
status: ChannelMessageStatus.failed,
);
unawaited(
_channelMessageStore.saveChannelMessages(entry.key, channelMessages),
);
notifyListeners();
return;
}
}
}
void _handleOk() { void _handleOk() {
if (_pendingGenericAckQueue.isEmpty) { if (_pendingGenericAckQueue.isEmpty) {
return; return;
} }
final pendingAck = _pendingGenericAckQueue.removeAt(0); final pendingAck = _pendingGenericAckQueue.removeAt(0);
pendingAck.completer?.complete();
if (pendingAck.commandCode != cmdSendChannelTxtMsg || if (pendingAck.commandCode != cmdSendChannelTxtMsg ||
pendingAck.channelSendQueueId == null) { pendingAck.channelSendQueueId == null) {
return; return;
@@ -6188,18 +6284,25 @@ class MeshCoreConnector extends ChangeNotifier {
_scheduleReconnect(); _scheduleReconnect();
} }
void _trackPendingGenericAck( _PendingCommandAck? _trackPendingGenericAck(
Uint8List data, { Uint8List data, {
String? channelSendQueueId, String? channelSendQueueId,
required bool expectsGenericAck, required bool expectsGenericAck,
required bool waitForAck,
}) { }) {
if (!expectsGenericAck || data.isEmpty) return; if (!expectsGenericAck || data.isEmpty) return null;
_pendingGenericAckQueue.add( final pendingAck = _PendingCommandAck(
_PendingCommandAck( commandCode: data[0],
commandCode: data[0], channelSendQueueId: channelSendQueueId,
channelSendQueueId: channelSendQueueId, completer: waitForAck ? Completer<void>() : null,
),
); );
if (pendingAck.completer != null) {
// sendFrame awaits this future after transport I/O; attach an error
// handler immediately in case USB returns an error response first.
unawaited(pendingAck.completer!.future.catchError((_) {}));
}
_pendingGenericAckQueue.add(pendingAck);
return pendingAck;
} }
String _nextReactionSendQueueId() { String _nextReactionSendQueueId() {
@@ -6733,6 +6836,11 @@ class _RepeaterAckContext {
class _PendingCommandAck { class _PendingCommandAck {
final int commandCode; final int commandCode;
final String? channelSendQueueId; final String? channelSendQueueId;
final Completer<void>? completer;
_PendingCommandAck({required this.commandCode, this.channelSendQueueId}); _PendingCommandAck({
required this.commandCode,
this.channelSendQueueId,
this.completer,
});
} }
+70
View File
@@ -0,0 +1,70 @@
import 'package:latlong2/latlong.dart';
import '../connector/meshcore_protocol.dart';
import '../models/contact.dart';
class PathHopResolver {
const PathHopResolver._();
static List<Contact?> resolve({
required List<int> pathBytes,
required List<Contact> contacts,
LatLng? endpoint,
bool resolveFromEnd = false,
}) {
final candidatesByPrefix = <int, List<Contact>>{};
for (final contact in contacts) {
if (contact.publicKey.isEmpty) continue;
if (contact.type != advTypeRepeater && contact.type != advTypeRoom) {
continue;
}
candidatesByPrefix
.putIfAbsent(contact.publicKey.first, () => <Contact>[])
.add(contact);
}
for (final candidates in candidatesByPrefix.values) {
candidates.sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
}
final resolved = List<Contact?>.filled(pathBytes.length, null);
final indexes = resolveFromEnd
? List<int>.generate(pathBytes.length, (i) => pathBytes.length - 1 - i)
: List<int>.generate(pathBytes.length, (i) => i);
final distance = Distance();
var previousPosition = endpoint;
for (final index in indexes) {
final candidates = candidatesByPrefix[pathBytes[index]];
if (candidates == null || candidates.isEmpty) continue;
var bestIndex = 0;
if (previousPosition != null && candidates.length > 1) {
double? nearestDistance;
for (var i = 0; i < candidates.length; i++) {
final position = _positionOf(candidates[i]);
if (position == null) continue;
final candidateDistance = distance(previousPosition, position);
if (nearestDistance == null || candidateDistance < nearestDistance) {
nearestDistance = candidateDistance;
bestIndex = i;
}
}
}
final contact = candidates.removeAt(bestIndex);
resolved[index] = contact;
previousPosition = _positionOf(contact) ?? previousPosition;
}
return resolved;
}
static LatLng? _positionOf(Contact contact) {
if (!contact.hasLocation ||
contact.latitude == null ||
contact.longitude == null) {
return null;
}
return LatLng(contact.latitude!, contact.longitude!);
}
}
+100 -151
View File
@@ -8,9 +8,9 @@ import 'package:meshcore_open/screens/path_trace_map.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../helpers/path_hop_resolver.dart';
import '../services/map_tile_cache_service.dart'; import '../services/map_tile_cache_service.dart';
import '../services/app_settings_service.dart'; import '../services/app_settings_service.dart';
import '../connector/meshcore_protocol.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/channel_message.dart'; import '../models/channel_message.dart';
@@ -46,7 +46,12 @@ class ChannelMessagePathScreen extends StatelessWidget {
final primaryPath = !channelMessage && !message.isOutgoing final primaryPath = !channelMessage && !message.isOutgoing
? Uint8List.fromList(primaryPathTmp.reversed.toList()) ? Uint8List.fromList(primaryPathTmp.reversed.toList())
: primaryPathTmp; : primaryPathTmp;
final hops = _buildPathHops(primaryPath, connector, l10n); final hops = _buildPathHops(
primaryPath,
connector,
l10n,
resolveFromEnd: !message.isOutgoing,
);
final hasHopDetails = primaryPath.isNotEmpty; final hasHopDetails = primaryPath.isNotEmpty;
final observedLabel = _formatObservedHops( final observedLabel = _formatObservedHops(
primaryPath.length, primaryPath.length,
@@ -808,7 +813,12 @@ class _ChannelMessagePathMapScreenState
// Match on the unoriented bytes — observedPaths stores them as // Match on the unoriented bytes — observedPaths stores them as
// recorded, while selectedPath may be reversed for display. // recorded, while selectedPath may be reversed for display.
final selectedIndex = _indexForPath(selectedPathTmp, observedPaths); final selectedIndex = _indexForPath(selectedPathTmp, observedPaths);
final hops = _buildPathHops(selectedPath, connector, context.l10n); final hops = _buildPathHops(
selectedPath,
connector,
context.l10n,
resolveFromEnd: !widget.message.isOutgoing,
);
// Renderable paths for the animation and combined view. // Renderable paths for the animation and combined view.
final entries = <_ObservedPathEntry>[]; final entries = <_ObservedPathEntry>[];
@@ -816,7 +826,12 @@ class _ChannelMessagePathMapScreenState
final oriented = _orientPath(observedPaths[i].pathBytes); final oriented = _orientPath(observedPaths[i].pathBytes);
final pathHops = i == selectedIndex final pathHops = i == selectedIndex
? hops ? hops
: _buildPathHops(oriented, connector, context.l10n); : _buildPathHops(
oriented,
connector,
context.l10n,
resolveFromEnd: !widget.message.isOutgoing,
);
final display = _buildDisplayPath( final display = _buildDisplayPath(
index: i, index: i,
isPrimary: observedPaths[i].isPrimary, isPrimary: observedPaths[i].isPrimary,
@@ -967,8 +982,7 @@ class _ChannelMessagePathMapScreenState
lines = buildMultiPathPolylines( lines = buildMultiPathPolylines(
visible: visibleDisplays, visible: visibleDisplays,
selected: selectedDisplay, selected: selectedDisplay,
combined: combined: effectiveMode == PathViewMode.combined,
effectiveMode == PathViewMode.combined,
animating: animating, animating: animating,
); );
if (animating && selectedDisplay != null) { if (animating && selectedDisplay != null) {
@@ -1498,17 +1512,14 @@ class _ChannelMessagePathMapScreenState
IconButton( IconButton(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
icon: Icon( icon: Icon(
_panelCollapsed _panelCollapsed ? Icons.expand_less : Icons.expand_more,
? Icons.expand_less
: Icons.expand_more,
size: 20, size: 20,
), ),
tooltip: _panelCollapsed tooltip: _panelCollapsed
? l10n.pathMap_expandPanel ? l10n.pathMap_expandPanel
: l10n.pathMap_collapsePanel, : l10n.pathMap_collapsePanel,
onPressed: () => setState( onPressed: () =>
() => _panelCollapsed = !_panelCollapsed, setState(() => _panelCollapsed = !_panelCollapsed),
),
), ),
], ],
), ),
@@ -1559,11 +1570,7 @@ class _ChannelMessagePathMapScreenState
), ),
const Divider(height: 1), const Divider(height: 1),
Expanded( Expanded(
child: _buildHopListView( child: _buildHopListView(hops, selectedDisplay, hopUseCount),
hops,
selectedDisplay,
hopUseCount,
),
), ),
], ],
], ],
@@ -1610,78 +1617,71 @@ class _ChannelMessagePathMapScreenState
: isFocused : isFocused
? MeshPalette.blueBg ? MeshPalette.blueBg
: Colors.transparent, : Colors.transparent,
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 12, horizontal: 12,
vertical: 8, vertical: 8,
), ),
child: Row( child: Row(
children: [ children: [
Container( Container(
width: 28, width: 28,
height: 28, height: 28,
decoration: BoxDecoration( decoration: BoxDecoration(
color: MeshPalette.blueDim.withValues( color: MeshPalette.blueDim.withValues(alpha: 0.3),
alpha: 0.3, shape: BoxShape.circle,
), border: Border.all(
shape: BoxShape.circle, color: MeshPalette.blueDim.withValues(alpha: 0.5),
border: Border.all( ),
color: MeshPalette.blueDim.withValues( ),
alpha: 0.5, alignment: Alignment.center,
), child: Text(
), hop.index.toString(),
), style: const TextStyle(
alignment: Alignment.center, color: Colors.white,
child: Text( fontSize: 11,
hop.index.toString(), fontWeight: FontWeight.w700,
style: const TextStyle( ),
color: Colors.white, ),
fontSize: 11, ),
fontWeight: FontWeight.w700, const SizedBox(width: 10),
), Expanded(
), child: Column(
), crossAxisAlignment: CrossAxisAlignment.start,
const SizedBox(width: 10), children: [
Expanded( Text(
child: Column( hop.displayLabel,
crossAxisAlignment: style: const TextStyle(
CrossAxisAlignment.start, fontWeight: FontWeight.w600,
children: [ fontSize: 13,
Text(
hop.displayLabel,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
),
overflow: TextOverflow.ellipsis,
),
Text(
[
hop.hasLocation
? '${hop.position!.latitude.toStringAsFixed(5)}, '
'${hop.position!.longitude.toStringAsFixed(5)}'
: context
.l10n
.channelPath_noLocationData,
if (sharedCount > 1)
context.l10n.pathMap_sharedNodeCount(
sharedCount,
),
].join(' · '),
style: MeshTheme.mono(
fontSize: 10,
color: MeshPalette.ink3,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
), ),
); overflow: TextOverflow.ellipsis,
}, ),
); Text(
[
hop.hasLocation
? '${hop.position!.latitude.toStringAsFixed(5)}, '
'${hop.position!.longitude.toStringAsFixed(5)}'
: context.l10n.channelPath_noLocationData,
if (sharedCount > 1)
context.l10n.pathMap_sharedNodeCount(
sharedCount,
),
].join(' · '),
style: MeshTheme.mono(
fontSize: 10,
color: MeshPalette.ink3,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
);
},
);
}, },
); );
} }
@@ -1743,76 +1743,25 @@ class _ObservedPath {
List<_PathHop> _buildPathHops( List<_PathHop> _buildPathHops(
Uint8List pathBytes, Uint8List pathBytes,
MeshCoreConnector connector, MeshCoreConnector connector,
AppLocalizations l10n, AppLocalizations l10n, {
) { bool resolveFromEnd = false,
}) {
if (pathBytes.isEmpty) return const []; if (pathBytes.isEmpty) return const [];
final candidatesByPrefix = <int, List<Contact>>{}; final endpoint =
final allContacts = connector.allContacts;
for (final contact in allContacts) {
if (contact.publicKey.isEmpty) continue;
if (contact.type != advTypeRepeater && contact.type != advTypeRoom) {
continue;
}
final prefix = contact.publicKey.first;
candidatesByPrefix.putIfAbsent(prefix, () => <Contact>[]).add(contact);
}
for (final candidates in candidatesByPrefix.values) {
candidates.sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
}
final startPoint =
(connector.selfLatitude != null && connector.selfLongitude != null) (connector.selfLatitude != null && connector.selfLongitude != null)
? LatLng(connector.selfLatitude!, connector.selfLongitude!) ? LatLng(connector.selfLatitude!, connector.selfLongitude!)
: null; : null;
var previousPosition = startPoint; final resolvedContacts = PathHopResolver.resolve(
final distance = Distance(); pathBytes: pathBytes,
var lastDistance = 0.0; contacts: connector.allContacts,
var bestDistance = 0.0; endpoint: endpoint,
resolveFromEnd: resolveFromEnd,
);
final hops = <_PathHop>[]; final hops = <_PathHop>[];
for (var i = 0; i < pathBytes.length; i++) { for (var i = 0; i < pathBytes.length; i++) {
final searchPoint = i == 0 ? startPoint : previousPosition; final contact = resolvedContacts[i];
final candidates = candidatesByPrefix[pathBytes[i]];
Contact? contact;
if (candidates != null && candidates.isNotEmpty) {
var bestIndex = 0;
if (searchPoint != null) {
bestDistance = double.infinity;
for (var j = 0; j < candidates.length; j++) {
final candidate = candidates[j];
if (!candidate.hasLocation ||
candidate.latitude == null ||
candidate.longitude == null) {
continue;
}
final currentDistance = distance(
searchPoint,
LatLng(candidate.latitude!, candidate.longitude!),
);
if (currentDistance < bestDistance) {
bestDistance = currentDistance;
bestIndex = j;
}
}
}
contact = candidates.removeAt(bestIndex);
if (candidates.isEmpty) {
candidatesByPrefix.remove(pathBytes[i]);
}
}
final resolvedPosition = _resolvePosition(contact); final resolvedPosition = _resolvePosition(contact);
if (resolvedPosition != null) {
previousPosition = resolvedPosition;
}
// If the best candidate is much farther than the previous hop, it's likely not the correct match.
if (lastDistance + bestDistance > 50000 &&
candidates != null &&
candidates.isNotEmpty) {
i--;
lastDistance = bestDistance;
continue;
}
lastDistance = bestDistance;
hops.add( hops.add(
_PathHop( _PathHop(
index: i + 1, index: i + 1,
+32 -14
View File
@@ -176,18 +176,32 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
return ListEntrance( return ListEntrance(
index: index, index: index,
child: MeshCard( child: MeshCard(
onTap: () { onTap: () async {
connector.importDiscoveredContact(contact); try {
showDismissibleSnackBar( final imported = await connector.importDiscoveredContact(contact);
context, if (!context.mounted) return;
content: Text( if (!imported) {
context.l10n.discoveredContacts_contactAdded, showDismissibleSnackBar(
), context,
action: SnackBarAction( content: Text(context.l10n.contacts_contactImportFailed),
label: context.l10n.common_undo, );
onPressed: () => connector.removeContact(contact), return;
), }
); showDismissibleSnackBar(
context,
content: Text(context.l10n.discoveredContacts_contactAdded),
action: SnackBarAction(
label: context.l10n.common_undo,
onPressed: () => connector.removeContact(contact),
),
);
} catch (_) {
if (!context.mounted) return;
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactImportFailed),
);
}
}, },
onLongPress: () => _showContactContextMenu(contact, connector), onLongPress: () => _showContactContextMenu(contact, connector),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
@@ -247,7 +261,9 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
Icon( Icon(
Icons.location_on, Icons.location_on,
size: 13, size: 13,
color: scheme.onSurfaceVariant.withValues(alpha: 0.55), color: scheme.onSurfaceVariant.withValues(
alpha: 0.55,
),
), ),
], ],
if (contact.rawPacket != null) ...[ if (contact.rawPacket != null) ...[
@@ -255,7 +271,9 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
Icon( Icon(
Icons.cell_tower, Icons.cell_tower,
size: 13, size: 13,
color: scheme.onSurfaceVariant.withValues(alpha: 0.55), color: scheme.onSurfaceVariant.withValues(
alpha: 0.55,
),
), ),
], ],
], ],
+50 -8
View File
@@ -39,8 +39,12 @@ class RetryServiceConfig {
final void Function(Message) updateMessage; final void Function(Message) updateMessage;
final Function(Contact)? clearContactPath; final Function(Contact)? clearContactPath;
final Function(Contact, Uint8List, int)? setContactPath; final Function(Contact, Uint8List, int)? setContactPath;
final int Function(int pathLength, int messageBytes, {String? contactKey})? final int Function(
calculateTimeout; int pathLength,
int messageBytes, {
String? contactKey,
int? deviceTimeoutMs,
})? calculateTimeout;
final Uint8List? Function()? getSelfPublicKey; final Uint8List? Function()? getSelfPublicKey;
final String Function(Contact, String)? prepareContactOutboundText; final String Function(Contact, String)? prepareContactOutboundText;
final AppSettingsService? appSettingsService; final AppSettingsService? appSettingsService;
@@ -74,6 +78,12 @@ class RetryServiceConfig {
class MessageRetryService extends ChangeNotifier { class MessageRetryService extends ChangeNotifier {
static const int maxAckHistorySize = 100; static const int maxAckHistorySize = 100;
/// Global cap on concurrent in-flight messages across ALL contacts.
/// The firmware's expected_ack_table is a single 8-entry circular buffer
/// shared globally; cap at 6 to leave two slots of headroom.
static const int _maxGlobalInFlight = 6;
int _maxRetries = 5; int _maxRetries = 5;
int get maxRetries => _maxRetries; int get maxRetries => _maxRetries;
@@ -170,8 +180,9 @@ class MessageRetryService extends ChangeNotifier {
_config?.addMessage(contact.publicKeyHex, message); _config?.addMessage(contact.publicKeyHex, message);
// Queue per contact only one message in-flight at a time to avoid // Queue per contact one message in-flight per contact at a time, and
// overflowing the firmware's 8-entry expected_ack_table. // bounded globally by _maxGlobalInFlight across all contacts so we never
// overflow the firmware's 8-entry global expected_ack_table.
final contactKey = contact.publicKeyHex; final contactKey = contact.publicKeyHex;
_sendQueue[contactKey] ??= []; _sendQueue[contactKey] ??= [];
_sendQueue[contactKey]!.add(messageId); _sendQueue[contactKey]!.add(messageId);
@@ -184,6 +195,11 @@ class MessageRetryService extends ChangeNotifier {
} }
void _sendNextForContact(String contactKey) { void _sendNextForContact(String contactKey) {
// Enforce the global in-flight cap before starting a new send.
// The firmware's expected_ack_table is a single 8-entry circular buffer
// shared across all contacts; exceeding it silently evicts an older slot.
if (_activeMessages.length >= _maxGlobalInFlight) return;
final queue = _sendQueue[contactKey]; final queue = _sendQueue[contactKey];
if (queue == null) return; if (queue == null) return;
@@ -211,7 +227,16 @@ class MessageRetryService extends ChangeNotifier {
if (_resolvedMessages.contains(messageId)) return; if (_resolvedMessages.contains(messageId)) return;
_resolvedMessages.add(messageId); _resolvedMessages.add(messageId);
_activeMessages.remove(messageId); _activeMessages.remove(messageId);
// Pump this contact's queue first, then any other contacts that are waiting.
_sendNextForContact(contactKey); _sendNextForContact(contactKey);
for (final key in _sendQueue.keys) {
if (key == contactKey) continue;
if (_activeMessages.length >= _maxGlobalInFlight) break;
final queue = _sendQueue[key];
if (queue != null && queue.isNotEmpty) {
_sendNextForContact(key);
}
}
} }
PathSelection? _selectPathForAttempt(Message message, Contact contact) { PathSelection? _selectPathForAttempt(Message message, Contact contact) {
@@ -352,6 +377,10 @@ class MessageRetryService extends ChangeNotifier {
} }
bool updateMessageFromSent(int ackHash, int timeoutMs) { bool updateMessageFromSent(int ackHash, int timeoutMs) {
// Firmware sets expected_ack = 0 for CLI/command sends (TXT_TYPE_CLI_DATA).
// No ACK will ever be issued for these, so arming a retry timer is wrong.
if (ackHash == 0) return false;
final config = _config; final config = _config;
if (config == null) return false; if (config == null) return false;
@@ -404,13 +433,19 @@ class MessageRetryService extends ChangeNotifier {
// Calculate timeout: prefer ML prediction, then device-provided, then physics fallback // Calculate timeout: prefer ML prediction, then device-provided, then physics fallback
final pathLengthValue = message.pathLength ?? contact.pathLength; final pathLengthValue = message.pathLength ?? contact.pathLength;
final outboundTextForTimeout =
config.prepareContactOutboundText?.call(contact, message.text) ??
message.text;
final messageBytesForTimeout =
utf8.encode(outboundTextForTimeout).length;
int actualTimeout = timeoutMs; int actualTimeout = timeoutMs;
if (config.calculateTimeout != null) { if (config.calculateTimeout != null) {
actualTimeout = config.calculateTimeout!( actualTimeout = config.calculateTimeout!(
pathLengthValue, pathLengthValue,
message.text.length, messageBytesForTimeout,
contactKey: contact.publicKeyHex, contactKey: contact.publicKeyHex,
deviceTimeoutMs: timeoutMs > 0 ? timeoutMs : null,
); );
} }
@@ -617,7 +652,6 @@ class MessageRetryService extends ChangeNotifier {
for (final expectedHash in expectedHashes) { for (final expectedHash in expectedHashes) {
if (expectedHash == ackHash) { if (expectedHash == ackHash) {
matchedMessageId = messageId; matchedMessageId = messageId;
matchedAttemptIndex = expectedHashes.indexOf(expectedHash);
break; break;
} }
} }
@@ -669,10 +703,18 @@ class MessageRetryService extends ChangeNotifier {
if (config?.onDeliveryObserved != null && if (config?.onDeliveryObserved != null &&
tripTimeMs > 0 && tripTimeMs > 0 &&
message.pathLength != null) { message.pathLength != null) {
config!.onDeliveryObserved!( final outboundTextForObserved =
config!.prepareContactOutboundText?.call(
contact,
message.text,
) ??
message.text;
final messageBytesForObserved =
utf8.encode(outboundTextForObserved).length;
config.onDeliveryObserved!(
contact.publicKeyHex, contact.publicKeyHex,
message.pathLength!, message.pathLength!,
message.text.length, messageBytesForObserved,
tripTimeMs, tripTimeMs,
); );
} }
+3 -1
View File
@@ -134,10 +134,12 @@ class PathHistoryService extends ChangeNotifier {
newWeight = (currentWeight + successIncrement).clamp(0.0, maxWeight); newWeight = (currentWeight + successIncrement).clamp(0.0, maxWeight);
} else { } else {
newWeight = currentWeight - failureDecrement; newWeight = currentWeight - failureDecrement;
if (newWeight <= 0) { if (newWeight <= 0 && failureCount >= 3) {
removePathRecord(contactPubKeyHex, selection.pathBytes); removePathRecord(contactPubKeyHex, selection.pathBytes);
return; return;
} }
// Keep the record with a small floor weight until we have enough evidence
newWeight = newWeight.clamp(0.1, maxWeight);
} }
_addPathRecord( _addPathRecord(
+20 -8
View File
@@ -63,12 +63,15 @@ class TimeoutPredictionService extends ChangeNotifier {
required int tripTimeMs, required int tripTimeMs,
int secondsSinceLastRx = 0, int secondsSinceLastRx = 0,
}) { }) {
final isFlood = pathLength < 0;
final observation = DeliveryObservation( final observation = DeliveryObservation(
contactKey: contactKey, contactKey: contactKey,
pathLength: pathLength, // Clamp to 0 for flood so the hop-count slope is learned from direct paths
// only; isFlood carries the flood signal as a separate feature.
pathLength: isFlood ? 0 : pathLength,
messageBytes: messageBytes, messageBytes: messageBytes,
secondsSinceLastRx: secondsSinceLastRx, secondsSinceLastRx: secondsSinceLastRx,
isFlood: pathLength < 0, isFlood: isFlood,
deliveryMs: tripTimeMs, deliveryMs: tripTimeMs,
timestamp: DateTime.now(), timestamp: DateTime.now(),
); );
@@ -76,11 +79,12 @@ class TimeoutPredictionService extends ChangeNotifier {
_observations.add(observation); _observations.add(observation);
if (_observations.length > maxObservations) { if (_observations.length > maxObservations) {
_observations.removeAt(0); _observations.removeAt(0);
_rebuildContactStats();
} else {
_contactStats.putIfAbsent(contactKey, () => _ContactStats());
_contactStats[contactKey]!.add(tripTimeMs.toDouble());
} }
_contactStats.putIfAbsent(contactKey, () => _ContactStats());
_contactStats[contactKey]!.add(tripTimeMs.toDouble());
_observationsSinceLastTrain++; _observationsSinceLastTrain++;
if (_observationsSinceLastTrain >= _retrainInterval && if (_observationsSinceLastTrain >= _retrainInterval &&
_observations.length >= minObservations) { _observations.length >= minObservations) {
@@ -108,11 +112,14 @@ class TimeoutPredictionService extends ChangeNotifier {
try { try {
if (_activeFeatures.isEmpty) return null; if (_activeFeatures.isEmpty) return null;
final flood = pathLength < 0;
final allFeatures = { final allFeatures = {
'pathLength': pathLength.toDouble(), // Clamp to 0 for flood mirrors recordObservation so training and
// prediction see the same pathLength values; isFlood carries the signal.
'pathLength': flood ? 0.0 : pathLength.toDouble(),
'messageBytes': messageBytes.toDouble(), 'messageBytes': messageBytes.toDouble(),
'secSinceRx': secondsSinceLastRx.toDouble(), 'secSinceRx': secondsSinceLastRx.toDouble(),
'isFlood': pathLength < 0 ? 1.0 : 0.0, 'isFlood': flood ? 1.0 : 0.0,
}; };
final row = _activeFeatures.map((f) => allFeatures[f]!).toList(); final row = _activeFeatures.map((f) => allFeatures[f]!).toList();
@@ -164,7 +171,9 @@ class TimeoutPredictionService extends ChangeNotifier {
// (ml_algo's OLS produces all-zero coefficients for singular matrices) // (ml_algo's OLS produces all-zero coefficients for singular matrices)
final allNames = ['pathLength', 'messageBytes', 'secSinceRx', 'isFlood']; final allNames = ['pathLength', 'messageBytes', 'secSinceRx', 'isFlood'];
final allExtractors = <double Function(DeliveryObservation)>[ final allExtractors = <double Function(DeliveryObservation)>[
(o) => o.pathLength.toDouble(), // pathLength is already clamped to >=0 in recordObservation, but guard
// here as well for any observations loaded from older persisted data.
(o) => o.pathLength < 0 ? 0.0 : o.pathLength.toDouble(),
(o) => o.messageBytes.toDouble(), (o) => o.messageBytes.toDouble(),
(o) => o.secondsSinceLastRx.toDouble(), (o) => o.secondsSinceLastRx.toDouble(),
(o) => o.isFlood ? 1.0 : 0.0, (o) => o.isFlood ? 1.0 : 0.0,
@@ -215,6 +224,9 @@ class TimeoutPredictionService extends ChangeNotifier {
@override @override
void dispose() { void dispose() {
if (_persistTimer?.isActive == true) {
_storage?.saveDeliveryObservations(_observations);
}
_persistTimer?.cancel(); _persistTimer?.cancel();
super.dispose(); super.dispose();
} }
+94
View File
@@ -0,0 +1,94 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
import 'package:meshcore_open/helpers/path_hop_resolver.dart';
import 'package:meshcore_open/models/contact.dart';
Contact _contact({
required int prefix,
required String name,
required double latitude,
required double longitude,
DateTime? lastSeen,
}) {
return Contact(
publicKey: Uint8List(32)..first = prefix,
name: name,
type: advTypeRepeater,
pathLength: 0,
path: Uint8List(0),
latitude: latitude,
longitude: longitude,
lastSeen: lastSeen ?? DateTime.utc(2026),
);
}
void main() {
test('received paths resolve hash conflicts from the receiver backward', () {
final nearReceiver = _contact(
prefix: 0xAA,
name: 'Near receiver',
latitude: 0,
longitude: 0.1,
);
final nearPreviousHop = _contact(
prefix: 0xBB,
name: 'Near previous hop',
latitude: 0,
longitude: 1.1,
);
final wrongConflict = _contact(
prefix: 0xBB,
name: 'Near receiver but wrong',
latitude: 0,
longitude: 0.2,
);
final previousHop = _contact(
prefix: 0xCC,
name: 'Previous hop',
latitude: 0,
longitude: 1,
);
final resolved = PathHopResolver.resolve(
pathBytes: const [0xBB, 0xCC, 0xAA],
contacts: [nearReceiver, nearPreviousHop, wrongConflict, previousHop],
endpoint: const LatLng(0, 0),
resolveFromEnd: true,
);
expect(resolved.map((contact) => contact?.name), [
'Near previous hop',
'Previous hop',
'Near receiver',
]);
});
test('falls back to the most recently seen conflict without locations', () {
final older = Contact(
publicKey: Uint8List(32)..first = 0xAA,
name: 'Older',
type: advTypeRepeater,
pathLength: 0,
path: Uint8List(0),
lastSeen: DateTime.utc(2025),
);
final newer = Contact(
publicKey: Uint8List(32)..first = 0xAA,
name: 'Newer',
type: advTypeRepeater,
pathLength: 0,
path: Uint8List(0),
lastSeen: DateTime.utc(2026),
);
final resolved = PathHopResolver.resolve(
pathBytes: const [0xAA],
contacts: [older, newer],
);
expect(resolved.single?.name, 'Newer');
});
}