mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-14 22:55:12 +10:00
feat: enhance MeshCoreConnector with improved timeout calculation and path resolution; add PathHopResolver for better contact resolution
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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!);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user