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