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.
// Frequent RX from any source indicates a busy network with more collisions.
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 _lastContactMsgRxTime = DateTime.fromMillisecondsSinceEpoch(0);
DateTime _lastChannelMsgRxTime = DateTime.fromMillisecondsSinceEpoch(0);
@@ -945,11 +948,17 @@ class MeshCoreConnector extends ChangeNotifier {
updateMessage: _updateMessage,
clearContactPath: clearContactPath,
setContactPath: setContactPath,
calculateTimeout: (pathLength, messageBytes, {String? contactKey}) =>
calculateTimeout(
calculateTimeout:
(
pathLength,
messageBytes, {
String? contactKey,
int? deviceTimeoutMs,
}) => calculateTimeout(
pathLength: pathLength,
messageBytes: messageBytes,
contactKey: contactKey,
deviceTimeoutMs: deviceTimeoutMs,
),
getSelfPublicKey: () => _selfPublicKey,
prepareContactOutboundText: prepareContactOutboundText,
@@ -965,7 +974,9 @@ class MeshCoreConnector extends ChangeNotifier {
recentSelections: recentSelections,
),
onDeliveryObserved: (contactKey, pathLength, messageBytes, tripTimeMs) {
final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds;
final secSinceRx = DateTime.now()
.difference(_lastRxBeforeFrame)
.inSeconds;
_timeoutPredictionService?.recordObservation(
contactKey: contactKey,
pathLength: pathLength,
@@ -2683,41 +2694,62 @@ class MeshCoreConnector extends ChangeNotifier {
Uint8List data, {
String? channelSendQueueId,
bool expectsGenericAck = false,
bool waitForGenericAck = false,
}) async {
if (!isConnected) {
throw Exception("Not connected to a MeshCore device");
}
_bleDebugLogService?.logFrame(data, outgoing: true);
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,
);
}
_trackPendingGenericAck(
final pendingAck = _trackPendingGenericAck(
data,
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 {
@@ -2949,6 +2981,17 @@ class MeshCoreConnector extends ChangeNotifier {
}) async {
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
final reactionInfo = ReactionHelper.parseReaction(text);
if (reactionInfo != null) {
@@ -3419,9 +3462,11 @@ class MeshCoreConnector extends ChangeNotifier {
notifyListeners();
}
Future<void> importDiscoveredContact(Contact contact) async {
if (!isConnected) return;
Future<bool> importDiscoveredContact(Contact contact) async {
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(
buildUpdateContactPathFrame(
contact.publicKey,
@@ -3434,6 +3479,7 @@ class MeshCoreConnector extends ChangeNotifier {
lon: contact.longitude,
lastModified: contact.lastSeen,
),
waitForGenericAck: true,
);
// Update the discovered contact to mark it as active (imported)
@@ -3459,6 +3505,8 @@ class MeshCoreConnector extends ChangeNotifier {
),
);
notifyListeners();
unawaited(_persistDiscoveredContacts());
return true;
}
Future<void> clearContactPath(Contact contact) async {
@@ -3864,6 +3912,7 @@ class MeshCoreConnector extends ChangeNotifier {
void _handleFrame(List<int> data) {
if (data.isEmpty) return;
_lastRxBeforeFrame = _lastRxTime;
_lastRxTime = DateTime.now();
final frame = Uint8List.fromList(data);
@@ -4016,11 +4065,15 @@ class MeshCoreConnector extends ChangeNotifier {
}
final failedAck = _pendingGenericAckQueue.removeAt(0);
failedAck.completer?.completeError(
Exception('Firmware rejected command with error code $errCode'),
);
if (failedAck.commandCode != cmdSendChannelTxtMsg ||
failedAck.channelSendQueueId == null) {
return;
}
_pendingChannelSentQueue.remove(failedAck.channelSendQueueId);
_markPendingChannelMessageFailedById(failedAck.channelSendQueueId!);
}
void _handlePathUpdated(Uint8List frame) {
@@ -4370,16 +4423,28 @@ class MeshCoreConnector extends ChangeNotifier {
// Same as max for flood — firmware uses a single formula
return 500 + (16 * airtime);
} 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.
/// 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({
required int pathLength,
int messageBytes = 100,
String? contactKey,
int? deviceTimeoutMs,
}) {
final airtime = _estimateAirtimeMs(messageBytes);
final physicsMin = _physicsMinTimeout(pathLength, airtime);
@@ -4394,17 +4459,26 @@ class MeshCoreConnector extends ChangeNotifier {
secondsSinceLastRx: secSinceRx,
);
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) {
// Flood: trust ML, only enforce firmware formula as floor
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
return physicsMax;
// No ML data — prefer device est_timeout (it used real airtime), then physics.
if (deviceTimeoutMs != null && deviceTimeoutMs > 0) {
return deviceTimeoutMs.clamp(physicsMin, _hardMaxTimeoutMs);
}
return physicsMax.clamp(0, _hardMaxTimeoutMs);
}
void _handleContact(Uint8List frame, {bool isContact = true}) {
@@ -4760,14 +4834,11 @@ class MeshCoreConnector extends ChangeNotifier {
final existing = _conversations[message.senderKeyHex];
final incomingTimestamp = message.timestamp.millisecondsSinceEpoch;
if (existing != null && existing.isNotEmpty) {
final startIndex = existing.length > 10 ? existing.length - 10 : 0;
for (int i = existing.length - 1; i >= startIndex; i--) {
final recent = existing[i];
if (!recent.isOutgoing &&
recent.timestamp.millisecondsSinceEpoch == incomingTimestamp &&
recent.text == message.text) {
return;
}
final last = existing.last;
if (!last.isOutgoing &&
last.timestamp.millisecondsSinceEpoch == incomingTimestamp &&
last.text == message.text) {
return;
}
}
}
@@ -5351,12 +5422,37 @@ class MeshCoreConnector extends ChangeNotifier {
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() {
if (_pendingGenericAckQueue.isEmpty) {
return;
}
final pendingAck = _pendingGenericAckQueue.removeAt(0);
pendingAck.completer?.complete();
if (pendingAck.commandCode != cmdSendChannelTxtMsg ||
pendingAck.channelSendQueueId == null) {
return;
@@ -6188,18 +6284,25 @@ class MeshCoreConnector extends ChangeNotifier {
_scheduleReconnect();
}
void _trackPendingGenericAck(
_PendingCommandAck? _trackPendingGenericAck(
Uint8List data, {
String? channelSendQueueId,
required bool expectsGenericAck,
required bool waitForAck,
}) {
if (!expectsGenericAck || data.isEmpty) return;
_pendingGenericAckQueue.add(
_PendingCommandAck(
commandCode: data[0],
channelSendQueueId: channelSendQueueId,
),
if (!expectsGenericAck || data.isEmpty) return null;
final pendingAck = _PendingCommandAck(
commandCode: data[0],
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() {
@@ -6733,6 +6836,11 @@ class _RepeaterAckContext {
class _PendingCommandAck {
final int commandCode;
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 '../connector/meshcore_connector.dart';
import '../helpers/path_hop_resolver.dart';
import '../services/map_tile_cache_service.dart';
import '../services/app_settings_service.dart';
import '../connector/meshcore_protocol.dart';
import '../l10n/app_localizations.dart';
import '../l10n/l10n.dart';
import '../models/channel_message.dart';
@@ -46,7 +46,12 @@ class ChannelMessagePathScreen extends StatelessWidget {
final primaryPath = !channelMessage && !message.isOutgoing
? Uint8List.fromList(primaryPathTmp.reversed.toList())
: primaryPathTmp;
final hops = _buildPathHops(primaryPath, connector, l10n);
final hops = _buildPathHops(
primaryPath,
connector,
l10n,
resolveFromEnd: !message.isOutgoing,
);
final hasHopDetails = primaryPath.isNotEmpty;
final observedLabel = _formatObservedHops(
primaryPath.length,
@@ -808,7 +813,12 @@ class _ChannelMessagePathMapScreenState
// Match on the unoriented bytes observedPaths stores them as
// recorded, while selectedPath may be reversed for display.
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.
final entries = <_ObservedPathEntry>[];
@@ -816,7 +826,12 @@ class _ChannelMessagePathMapScreenState
final oriented = _orientPath(observedPaths[i].pathBytes);
final pathHops = i == selectedIndex
? hops
: _buildPathHops(oriented, connector, context.l10n);
: _buildPathHops(
oriented,
connector,
context.l10n,
resolveFromEnd: !widget.message.isOutgoing,
);
final display = _buildDisplayPath(
index: i,
isPrimary: observedPaths[i].isPrimary,
@@ -967,8 +982,7 @@ class _ChannelMessagePathMapScreenState
lines = buildMultiPathPolylines(
visible: visibleDisplays,
selected: selectedDisplay,
combined:
effectiveMode == PathViewMode.combined,
combined: effectiveMode == PathViewMode.combined,
animating: animating,
);
if (animating && selectedDisplay != null) {
@@ -1498,17 +1512,14 @@ class _ChannelMessagePathMapScreenState
IconButton(
visualDensity: VisualDensity.compact,
icon: Icon(
_panelCollapsed
? Icons.expand_less
: Icons.expand_more,
_panelCollapsed ? Icons.expand_less : Icons.expand_more,
size: 20,
),
tooltip: _panelCollapsed
? l10n.pathMap_expandPanel
: l10n.pathMap_collapsePanel,
onPressed: () => setState(
() => _panelCollapsed = !_panelCollapsed,
),
onPressed: () =>
setState(() => _panelCollapsed = !_panelCollapsed),
),
],
),
@@ -1559,11 +1570,7 @@ class _ChannelMessagePathMapScreenState
),
const Divider(height: 1),
Expanded(
child: _buildHopListView(
hops,
selectedDisplay,
hopUseCount,
),
child: _buildHopListView(hops, selectedDisplay, hopUseCount),
),
],
],
@@ -1610,78 +1617,71 @@ class _ChannelMessagePathMapScreenState
: isFocused
? MeshPalette.blueBg
: Colors.transparent,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: MeshPalette.blueDim.withValues(
alpha: 0.3,
),
shape: BoxShape.circle,
border: Border.all(
color: MeshPalette.blueDim.withValues(
alpha: 0.5,
),
),
),
alignment: Alignment.center,
child: Text(
hop.index.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
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,
),
],
),
),
],
),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: MeshPalette.blueDim.withValues(alpha: 0.3),
shape: BoxShape.circle,
border: Border.all(
color: MeshPalette.blueDim.withValues(alpha: 0.5),
),
),
alignment: Alignment.center,
child: Text(
hop.index.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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,
),
],
),
),
],
),
),
);
},
);
},
);
}
@@ -1743,76 +1743,25 @@ class _ObservedPath {
List<_PathHop> _buildPathHops(
Uint8List pathBytes,
MeshCoreConnector connector,
AppLocalizations l10n,
) {
AppLocalizations l10n, {
bool resolveFromEnd = false,
}) {
if (pathBytes.isEmpty) return const [];
final candidatesByPrefix = <int, List<Contact>>{};
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 =
final endpoint =
(connector.selfLatitude != null && connector.selfLongitude != null)
? LatLng(connector.selfLatitude!, connector.selfLongitude!)
: null;
var previousPosition = startPoint;
final distance = Distance();
var lastDistance = 0.0;
var bestDistance = 0.0;
final resolvedContacts = PathHopResolver.resolve(
pathBytes: pathBytes,
contacts: connector.allContacts,
endpoint: endpoint,
resolveFromEnd: resolveFromEnd,
);
final hops = <_PathHop>[];
for (var i = 0; i < pathBytes.length; i++) {
final searchPoint = i == 0 ? startPoint : previousPosition;
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 contact = resolvedContacts[i];
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(
_PathHop(
index: i + 1,
+32 -14
View File
@@ -176,18 +176,32 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
return ListEntrance(
index: index,
child: MeshCard(
onTap: () {
connector.importDiscoveredContact(contact);
showDismissibleSnackBar(
context,
content: Text(
context.l10n.discoveredContacts_contactAdded,
),
action: SnackBarAction(
label: context.l10n.common_undo,
onPressed: () => connector.removeContact(contact),
),
);
onTap: () async {
try {
final imported = await connector.importDiscoveredContact(contact);
if (!context.mounted) return;
if (!imported) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactImportFailed),
);
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),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
@@ -247,7 +261,9 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
Icon(
Icons.location_on,
size: 13,
color: scheme.onSurfaceVariant.withValues(alpha: 0.55),
color: scheme.onSurfaceVariant.withValues(
alpha: 0.55,
),
),
],
if (contact.rawPacket != null) ...[
@@ -255,7 +271,9 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
Icon(
Icons.cell_tower,
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 Function(Contact)? clearContactPath;
final Function(Contact, Uint8List, int)? setContactPath;
final int Function(int pathLength, int messageBytes, {String? contactKey})?
calculateTimeout;
final int Function(
int pathLength,
int messageBytes, {
String? contactKey,
int? deviceTimeoutMs,
})? calculateTimeout;
final Uint8List? Function()? getSelfPublicKey;
final String Function(Contact, String)? prepareContactOutboundText;
final AppSettingsService? appSettingsService;
@@ -74,6 +78,12 @@ class RetryServiceConfig {
class MessageRetryService extends ChangeNotifier {
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 get maxRetries => _maxRetries;
@@ -170,8 +180,9 @@ class MessageRetryService extends ChangeNotifier {
_config?.addMessage(contact.publicKeyHex, message);
// Queue per contact only one message in-flight at a time to avoid
// overflowing the firmware's 8-entry expected_ack_table.
// Queue per contact one message in-flight per contact at a time, and
// bounded globally by _maxGlobalInFlight across all contacts so we never
// overflow the firmware's 8-entry global expected_ack_table.
final contactKey = contact.publicKeyHex;
_sendQueue[contactKey] ??= [];
_sendQueue[contactKey]!.add(messageId);
@@ -184,6 +195,11 @@ class MessageRetryService extends ChangeNotifier {
}
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];
if (queue == null) return;
@@ -211,7 +227,16 @@ class MessageRetryService extends ChangeNotifier {
if (_resolvedMessages.contains(messageId)) return;
_resolvedMessages.add(messageId);
_activeMessages.remove(messageId);
// Pump this contact's queue first, then any other contacts that are waiting.
_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) {
@@ -352,6 +377,10 @@ class MessageRetryService extends ChangeNotifier {
}
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;
if (config == null) return false;
@@ -404,13 +433,19 @@ class MessageRetryService extends ChangeNotifier {
// Calculate timeout: prefer ML prediction, then device-provided, then physics fallback
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;
if (config.calculateTimeout != null) {
actualTimeout = config.calculateTimeout!(
pathLengthValue,
message.text.length,
messageBytesForTimeout,
contactKey: contact.publicKeyHex,
deviceTimeoutMs: timeoutMs > 0 ? timeoutMs : null,
);
}
@@ -617,7 +652,6 @@ class MessageRetryService extends ChangeNotifier {
for (final expectedHash in expectedHashes) {
if (expectedHash == ackHash) {
matchedMessageId = messageId;
matchedAttemptIndex = expectedHashes.indexOf(expectedHash);
break;
}
}
@@ -669,10 +703,18 @@ class MessageRetryService extends ChangeNotifier {
if (config?.onDeliveryObserved != null &&
tripTimeMs > 0 &&
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,
message.pathLength!,
message.text.length,
messageBytesForObserved,
tripTimeMs,
);
}
+3 -1
View File
@@ -134,10 +134,12 @@ class PathHistoryService extends ChangeNotifier {
newWeight = (currentWeight + successIncrement).clamp(0.0, maxWeight);
} else {
newWeight = currentWeight - failureDecrement;
if (newWeight <= 0) {
if (newWeight <= 0 && failureCount >= 3) {
removePathRecord(contactPubKeyHex, selection.pathBytes);
return;
}
// Keep the record with a small floor weight until we have enough evidence
newWeight = newWeight.clamp(0.1, maxWeight);
}
_addPathRecord(
+20 -8
View File
@@ -63,12 +63,15 @@ class TimeoutPredictionService extends ChangeNotifier {
required int tripTimeMs,
int secondsSinceLastRx = 0,
}) {
final isFlood = pathLength < 0;
final observation = DeliveryObservation(
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,
secondsSinceLastRx: secondsSinceLastRx,
isFlood: pathLength < 0,
isFlood: isFlood,
deliveryMs: tripTimeMs,
timestamp: DateTime.now(),
);
@@ -76,11 +79,12 @@ class TimeoutPredictionService extends ChangeNotifier {
_observations.add(observation);
if (_observations.length > maxObservations) {
_observations.removeAt(0);
_rebuildContactStats();
} else {
_contactStats.putIfAbsent(contactKey, () => _ContactStats());
_contactStats[contactKey]!.add(tripTimeMs.toDouble());
}
_contactStats.putIfAbsent(contactKey, () => _ContactStats());
_contactStats[contactKey]!.add(tripTimeMs.toDouble());
_observationsSinceLastTrain++;
if (_observationsSinceLastTrain >= _retrainInterval &&
_observations.length >= minObservations) {
@@ -108,11 +112,14 @@ class TimeoutPredictionService extends ChangeNotifier {
try {
if (_activeFeatures.isEmpty) return null;
final flood = pathLength < 0;
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(),
'secSinceRx': secondsSinceLastRx.toDouble(),
'isFlood': pathLength < 0 ? 1.0 : 0.0,
'isFlood': flood ? 1.0 : 0.0,
};
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)
final allNames = ['pathLength', 'messageBytes', 'secSinceRx', 'isFlood'];
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.secondsSinceLastRx.toDouble(),
(o) => o.isFlood ? 1.0 : 0.0,
@@ -215,6 +224,9 @@ class TimeoutPredictionService extends ChangeNotifier {
@override
void dispose() {
if (_persistTimer?.isActive == true) {
_storage?.saveDeliveryObservations(_observations);
}
_persistTimer?.cancel();
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');
});
}