mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-12 11:52:07 +10:00
Merge branch 'dev' into update-pacox-multibyte
This commit is contained in:
@@ -243,6 +243,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);
|
||||
@@ -363,6 +366,20 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
String? get deviceId => _deviceId;
|
||||
String get deviceIdLabel => _deviceId ?? 'Unknown';
|
||||
|
||||
/// Stable per-radio key for transport-agnostic per-device settings such as
|
||||
/// battery chemistry. On BLE this is the existing remoteId (so previously
|
||||
/// saved settings are preserved); on USB/TCP — where there is no BLE
|
||||
/// remoteId — it falls back to the node's public key, which identifies the
|
||||
/// same physical radio across transports. Null until a device identity is
|
||||
/// known.
|
||||
String? get batteryDeviceKey {
|
||||
if (_deviceId != null) return _deviceId;
|
||||
if (_selfPublicKey != null && _selfPublicKey!.isNotEmpty) {
|
||||
return selfPublicKeyHex;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
MeshCoreTransportType get activeTransport => _activeTransport;
|
||||
String? get activeUsbPort => _usbManager.activePortKey;
|
||||
String? get activeUsbPortDisplayLabel => _usbManager.activePortDisplayLabel;
|
||||
@@ -528,7 +545,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
}
|
||||
|
||||
String _batteryChemistryForDevice() {
|
||||
final deviceId = _device?.remoteId.toString();
|
||||
final deviceId = batteryDeviceKey;
|
||||
if (deviceId == null || _appSettingsService == null) return 'nmc';
|
||||
return _appSettingsService!.batteryChemistryForDevice(deviceId);
|
||||
}
|
||||
@@ -966,11 +983,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,
|
||||
@@ -986,7 +1009,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,
|
||||
@@ -1642,6 +1667,20 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
await stopScan();
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
|
||||
// The read pump can fail the instant the port opens (e.g. a device that
|
||||
// re-enumerates on open). That error is emitted on a broadcast stream
|
||||
// before the listener below attaches, so it would otherwise be lost and
|
||||
// the connect would stall until the SELF_INFO timeout. Check transport
|
||||
// liveness directly and abort fast with the real cause.
|
||||
if (!_usbManager.isConnected) {
|
||||
final cause = _usbManager.lastError;
|
||||
throw StateError(
|
||||
'USB device disconnected during connect'
|
||||
'${cause == null ? '' : ': $cause'}',
|
||||
);
|
||||
}
|
||||
|
||||
_usbFrameSubscription = _usbManager.frameStream.listen(
|
||||
_handleFrame,
|
||||
onError: (error, stackTrace) {
|
||||
@@ -2690,41 +2729,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 {
|
||||
@@ -2981,6 +3041,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) {
|
||||
@@ -3472,14 +3543,16 @@ 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.
|
||||
final encodedPathLen = _encodePathLenForCurrentMode(
|
||||
contact.pathLength,
|
||||
contact.path,
|
||||
);
|
||||
if (encodedPathLen == null) return;
|
||||
if (encodedPathLen == null) return false;
|
||||
await sendFrame(
|
||||
buildUpdateContactPathFrame(
|
||||
contact.publicKey,
|
||||
@@ -3492,6 +3565,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
lon: contact.longitude,
|
||||
lastModified: contact.lastSeen,
|
||||
),
|
||||
waitForGenericAck: true,
|
||||
);
|
||||
|
||||
// Update the discovered contact to mark it as active (imported)
|
||||
@@ -3517,6 +3591,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
),
|
||||
);
|
||||
notifyListeners();
|
||||
unawaited(_persistDiscoveredContacts());
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> clearContactPath(Contact contact) async {
|
||||
@@ -3922,6 +3998,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
|
||||
void _handleFrame(List<int> data) {
|
||||
if (data.isEmpty) return;
|
||||
_lastRxBeforeFrame = _lastRxTime;
|
||||
_lastRxTime = DateTime.now();
|
||||
|
||||
final frame = Uint8List.fromList(data);
|
||||
@@ -4074,11 +4151,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) {
|
||||
@@ -4432,16 +4513,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);
|
||||
@@ -4456,17 +4549,29 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
secondsSinceLastRx: secSinceRx,
|
||||
);
|
||||
if (mlTimeout != null) {
|
||||
// Use device est_timeout as a baseline floor when available —
|
||||
// the firmware computed it from real airtime. Let the learned ML
|
||||
// estimate widen above it up to the hard cap, but never below it.
|
||||
final floor = deviceTimeoutMs != null && deviceTimeoutMs > physicsMin
|
||||
? deviceTimeoutMs.clamp(physicsMin, _hardMaxTimeoutMs)
|
||||
: physicsMin.clamp(0, _hardMaxTimeoutMs);
|
||||
if (pathLength < 0) {
|
||||
// Flood: trust ML, only enforce firmware formula as floor
|
||||
if (mlTimeout < physicsMin) {
|
||||
return physicsMin;
|
||||
// Flood: trust ML, only enforce firmware estimate as floor
|
||||
if (mlTimeout < floor) {
|
||||
return floor.clamp(0, _hardMaxTimeoutMs);
|
||||
}
|
||||
}
|
||||
return mlTimeout.clamp(physicsMin, physicsMax);
|
||||
return mlTimeout.clamp(floor, _hardMaxTimeoutMs);
|
||||
}
|
||||
|
||||
// No ML data — use firmware formula
|
||||
return physicsMax;
|
||||
// No ML data — prefer device est_timeout (it used real airtime), then physics.
|
||||
// Cap the floor to the hard maximum so slow-flood physicsMin cannot exceed
|
||||
// the upper bound and make clamp() throw.
|
||||
if (deviceTimeoutMs != null && deviceTimeoutMs > 0) {
|
||||
final floor = physicsMin.clamp(0, _hardMaxTimeoutMs);
|
||||
return deviceTimeoutMs.clamp(floor, _hardMaxTimeoutMs);
|
||||
}
|
||||
return physicsMax.clamp(0, _hardMaxTimeoutMs);
|
||||
}
|
||||
|
||||
void _handleContact(Uint8List frame, {bool isContact = true}) {
|
||||
@@ -4831,14 +4936,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5425,12 +5527,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;
|
||||
@@ -5839,7 +5966,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
) {
|
||||
if (!isRoomServer) return null;
|
||||
if (!msg.isOutgoing) {
|
||||
final senderContact = _contacts.cast<Contact?>().firstWhere(
|
||||
// Saved contacts first, then discovery-only nodes, so reaction matching
|
||||
// resolves the author's name even when they haven't been saved.
|
||||
final senderContact = allContactsUnfiltered.cast<Contact?>().firstWhere(
|
||||
(c) =>
|
||||
c != null &&
|
||||
_matchesPrefix(c.publicKey, msg.fourByteRoomContactKey),
|
||||
@@ -6262,18 +6391,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() {
|
||||
@@ -7033,6 +7169,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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ class MeshCoreUsbManager {
|
||||
String? get activePortKey => _activePortKey;
|
||||
String? get activePortDisplayLabel => _activePortLabel ?? _activePortKey;
|
||||
bool get isConnected => _service.isConnected;
|
||||
Object? get lastError => _service.lastError;
|
||||
Stream<Uint8List> get frameStream => _service.frameStream;
|
||||
|
||||
// --- Configuration ---
|
||||
|
||||
Reference in New Issue
Block a user