diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 1bff354a..ef9a9e24 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -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.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.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 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 importDiscoveredContact(Contact contact) async { - if (!isConnected) return; + Future 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 clearContactPath(Contact contact) async { @@ -3864,6 +3912,7 @@ class MeshCoreConnector extends ChangeNotifier { void _handleFrame(List 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() : 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? completer; - _PendingCommandAck({required this.commandCode, this.channelSendQueueId}); + _PendingCommandAck({ + required this.commandCode, + this.channelSendQueueId, + this.completer, + }); } diff --git a/lib/helpers/path_hop_resolver.dart b/lib/helpers/path_hop_resolver.dart new file mode 100644 index 00000000..19f26001 --- /dev/null +++ b/lib/helpers/path_hop_resolver.dart @@ -0,0 +1,70 @@ +import 'package:latlong2/latlong.dart'; + +import '../connector/meshcore_protocol.dart'; +import '../models/contact.dart'; + +class PathHopResolver { + const PathHopResolver._(); + + static List resolve({ + required List pathBytes, + required List contacts, + LatLng? endpoint, + bool resolveFromEnd = false, + }) { + final candidatesByPrefix = >{}; + for (final contact in contacts) { + if (contact.publicKey.isEmpty) continue; + if (contact.type != advTypeRepeater && contact.type != advTypeRoom) { + continue; + } + candidatesByPrefix + .putIfAbsent(contact.publicKey.first, () => []) + .add(contact); + } + for (final candidates in candidatesByPrefix.values) { + candidates.sort((a, b) => b.lastSeen.compareTo(a.lastSeen)); + } + + final resolved = List.filled(pathBytes.length, null); + final indexes = resolveFromEnd + ? List.generate(pathBytes.length, (i) => pathBytes.length - 1 - i) + : List.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!); + } +} diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index 13bc0c3c..c46c413a 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -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 = >{}; - 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, () => []).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, diff --git a/lib/screens/discovery_screen.dart b/lib/screens/discovery_screen.dart index 8fb4cf95..415562c3 100644 --- a/lib/screens/discovery_screen.dart +++ b/lib/screens/discovery_screen.dart @@ -176,18 +176,32 @@ class _DiscoveryScreenState extends State { 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 { 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 { Icon( Icons.cell_tower, size: 13, - color: scheme.onSurfaceVariant.withValues(alpha: 0.55), + color: scheme.onSurfaceVariant.withValues( + alpha: 0.55, + ), ), ], ], diff --git a/lib/services/message_retry_service.dart b/lib/services/message_retry_service.dart index a15b9c7d..56341598 100644 --- a/lib/services/message_retry_service.dart +++ b/lib/services/message_retry_service.dart @@ -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, ); } diff --git a/lib/services/path_history_service.dart b/lib/services/path_history_service.dart index fc81c565..7a61e3f6 100644 --- a/lib/services/path_history_service.dart +++ b/lib/services/path_history_service.dart @@ -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( diff --git a/lib/services/timeout_prediction_service.dart b/lib/services/timeout_prediction_service.dart index d92ca643..f007e51c 100644 --- a/lib/services/timeout_prediction_service.dart +++ b/lib/services/timeout_prediction_service.dart @@ -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 = [ - (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(); } diff --git a/test/helpers/path_hop_resolver_test.dart b/test/helpers/path_hop_resolver_test.dart new file mode 100644 index 00000000..8e903bc6 --- /dev/null +++ b/test/helpers/path_hop_resolver_test.dart @@ -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'); + }); +}