mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-20 01:15:35 +10:00
Merge remote-tracking branch 'origin/dev' into test-regions Also added fixes
This commit is contained in:
@@ -39,7 +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})?
|
||||
final int Function(
|
||||
int pathLength,
|
||||
int messageBytes, {
|
||||
String? contactKey,
|
||||
int? deviceTimeoutMs,
|
||||
})?
|
||||
calculateTimeout;
|
||||
final Uint8List? Function()? getSelfPublicKey;
|
||||
final String Function(Contact, String)? prepareContactOutboundText;
|
||||
@@ -74,6 +79,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 +181,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 +196,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;
|
||||
|
||||
@@ -210,8 +227,23 @@ class MessageRetryService extends ChangeNotifier {
|
||||
void _onMessageResolved(String messageId, String contactKey) {
|
||||
if (_resolvedMessages.contains(messageId)) return;
|
||||
_resolvedMessages.add(messageId);
|
||||
_activeMessages.remove(messageId);
|
||||
// If cleanup already removed this message from the active set, it has
|
||||
// already pumped the queues; avoid double-pumping.
|
||||
if (!_activeMessages.remove(messageId)) return;
|
||||
_pumpQueues(contactKey);
|
||||
}
|
||||
|
||||
void _pumpQueues(String contactKey) {
|
||||
// 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 +384,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 +440,18 @@ 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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -449,17 +490,28 @@ class MessageRetryService extends ChangeNotifier {
|
||||
});
|
||||
}
|
||||
|
||||
void untrack(String messageId) {
|
||||
_timeoutTimers[messageId]?.cancel();
|
||||
_cleanupMessage(messageId);
|
||||
}
|
||||
|
||||
void _cleanupMessage(String messageId) {
|
||||
_moveAckHashesToHistory(messageId);
|
||||
_ackHashToMessageId.removeWhere(
|
||||
(_, mapping) => mapping.messageId == messageId,
|
||||
);
|
||||
_expectedHashToMessageId.removeWhere((_, msgId) => msgId == messageId);
|
||||
final contactKey = _pendingContacts[messageId]?.publicKeyHex;
|
||||
_pendingMessages.remove(messageId);
|
||||
_pendingContacts.remove(messageId);
|
||||
_attemptPathHistory.remove(messageId);
|
||||
_timeoutTimers.remove(messageId);
|
||||
_resolvedMessages.remove(messageId);
|
||||
// Cancellation (and other cleanup paths) must release the active in-flight
|
||||
// slot and pump waiting queues so the global cap does not stall forever.
|
||||
if (_activeMessages.remove(messageId) && contactKey != null) {
|
||||
_pumpQueues(contactKey);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTimeout(String messageId) {
|
||||
@@ -612,7 +664,6 @@ class MessageRetryService extends ChangeNotifier {
|
||||
for (final expectedHash in expectedHashes) {
|
||||
if (expectedHash == ackHash) {
|
||||
matchedMessageId = messageId;
|
||||
matchedAttemptIndex = expectedHashes.indexOf(expectedHash);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -664,10 +715,16 @@ 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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,6 +114,36 @@ class NotificationService {
|
||||
return _isInitialized;
|
||||
}
|
||||
|
||||
// Cached "are we allowed to post notifications" result. Null = not yet
|
||||
// determined. Avoids calling _notifications.show() when it would only throw
|
||||
// "You must request notifications permissions first" (every web build, and
|
||||
// Android 13+ before the user grants the permission).
|
||||
bool? _canNotify;
|
||||
|
||||
Future<bool> _ensureCanNotify() async {
|
||||
if (!await _ensureInitialized()) return false;
|
||||
final cached = _canNotify;
|
||||
if (cached != null) return cached;
|
||||
|
||||
// flutter_local_notifications has no web backend, so show() always throws.
|
||||
// Skip silently instead of logging an error per incoming message.
|
||||
if (kIsWeb) return _canNotify = false;
|
||||
|
||||
// On Android 13+ notifications require an explicit grant; reflect the real
|
||||
// OS state so we don't spam failed show() calls when denied.
|
||||
final androidPlugin = _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
if (androidPlugin != null) {
|
||||
final enabled = await androidPlugin.areNotificationsEnabled();
|
||||
return _canNotify = enabled ?? false;
|
||||
}
|
||||
|
||||
// iOS/macOS request permission during initialize(); desktop has no gate.
|
||||
return _canNotify = true;
|
||||
}
|
||||
|
||||
Future<bool> requestPermissions() async {
|
||||
if (!_isInitialized) {
|
||||
await initialize();
|
||||
@@ -126,7 +156,8 @@ class NotificationService {
|
||||
>();
|
||||
if (androidPlugin != null) {
|
||||
final granted = await androidPlugin.requestNotificationsPermission();
|
||||
return granted ?? false;
|
||||
_canNotify = granted ?? false;
|
||||
return _canNotify!;
|
||||
}
|
||||
|
||||
// iOS permissions are requested during initialization
|
||||
@@ -140,7 +171,8 @@ class NotificationService {
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
return granted ?? false;
|
||||
_canNotify = granted ?? false;
|
||||
return _canNotify!;
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -165,7 +197,7 @@ class NotificationService {
|
||||
String? contactId,
|
||||
int? badgeCount,
|
||||
}) async {
|
||||
if (!await _ensureInitialized()) return;
|
||||
if (!await _ensureCanNotify()) return;
|
||||
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
'messages',
|
||||
@@ -215,7 +247,7 @@ class NotificationService {
|
||||
required String contactType,
|
||||
String? contactId,
|
||||
}) async {
|
||||
if (!await _ensureInitialized()) return;
|
||||
if (!await _ensureCanNotify()) return;
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'adverts',
|
||||
@@ -248,7 +280,7 @@ class NotificationService {
|
||||
await _notifications.show(
|
||||
id: contactId != null
|
||||
? 'advert:$contactId'.hashCode
|
||||
: DateTime.now().millisecondsSinceEpoch,
|
||||
: DateTime.now().millisecondsSinceEpoch & 0x7FFFFFFF,
|
||||
title: _l10n.notification_newTypeDiscovered(contactType),
|
||||
body: contactName,
|
||||
notificationDetails: notificationDetails,
|
||||
@@ -265,7 +297,7 @@ class NotificationService {
|
||||
int? channelIndex,
|
||||
int? badgeCount,
|
||||
}) async {
|
||||
if (!await _ensureInitialized()) return;
|
||||
if (!await _ensureCanNotify()) return;
|
||||
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
'channel_messages',
|
||||
@@ -304,7 +336,9 @@ class NotificationService {
|
||||
|
||||
try {
|
||||
await _notifications.show(
|
||||
id: channelIndex?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
|
||||
id:
|
||||
channelIndex?.hashCode ??
|
||||
DateTime.now().millisecondsSinceEpoch & 0x7FFFFFFF,
|
||||
title: channelName,
|
||||
body: body,
|
||||
notificationDetails: notificationDetails,
|
||||
@@ -543,7 +577,7 @@ class NotificationService {
|
||||
}
|
||||
|
||||
Future<void> _showBatchSummary(List<_PendingNotification> batch) async {
|
||||
if (!await _ensureInitialized()) return;
|
||||
if (!await _ensureCanNotify()) return;
|
||||
|
||||
// Group by type
|
||||
final messages = batch
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ class TranslationService extends ChangeNotifier {
|
||||
_langDetectInit = initLangDetect();
|
||||
}
|
||||
|
||||
bool _disposed = false;
|
||||
bool _isBusy = false;
|
||||
bool _isDownloading = false;
|
||||
bool _cancelDownloadRequested = false;
|
||||
@@ -215,7 +216,7 @@ class TranslationService extends ChangeNotifier {
|
||||
}
|
||||
|
||||
_downloadTotalBytes = totalSize;
|
||||
notifyListeners();
|
||||
_notify();
|
||||
|
||||
DownloadedModelFile downloaded;
|
||||
if (supportsRange &&
|
||||
@@ -268,7 +269,7 @@ class TranslationService extends ChangeNotifier {
|
||||
throw StateError('Model download failed: HTTP ${response.statusCode}');
|
||||
}
|
||||
_downloadTotalBytes ??= response.contentLength;
|
||||
notifyListeners();
|
||||
_notify();
|
||||
final trackedStream = _trackDownloadProgress(response.stream);
|
||||
return await _fileStore.writeModelBytes(
|
||||
fileName: fileName,
|
||||
@@ -313,7 +314,7 @@ class TranslationService extends ChangeNotifier {
|
||||
throw const TranslationDownloadCancelled();
|
||||
}
|
||||
_downloadFileName = 'Merging chunks...';
|
||||
notifyListeners();
|
||||
_notify();
|
||||
combineReached = true;
|
||||
return await _fileStore.combineChunks(
|
||||
fileName: fileName,
|
||||
@@ -361,7 +362,7 @@ class TranslationService extends ChangeNotifier {
|
||||
}
|
||||
_cancelDownloadRequested = true;
|
||||
_lastError = 'Download stopped.';
|
||||
notifyListeners();
|
||||
_notify();
|
||||
}
|
||||
|
||||
Future<void> removeModel(TranslationModelRecord model) async {
|
||||
@@ -469,7 +470,7 @@ class TranslationService extends ChangeNotifier {
|
||||
} catch (error) {
|
||||
_lastError = error.toString();
|
||||
appLogger.warn('Language detection failed: $error');
|
||||
notifyListeners();
|
||||
_notify();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -538,7 +539,7 @@ class TranslationService extends ChangeNotifier {
|
||||
} catch (error) {
|
||||
_lastError = error.toString();
|
||||
appLogger.warn('Translation request failed: $error');
|
||||
notifyListeners();
|
||||
_notify();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -631,6 +632,10 @@ class TranslationService extends ChangeNotifier {
|
||||
final completer = Completer<T>();
|
||||
_setBusy(true);
|
||||
_queue = _queue.then((_) async {
|
||||
if (_disposed) {
|
||||
completer.completeError(StateError('TranslationService disposed.'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
completer.complete(await action());
|
||||
} catch (error, stackTrace) {
|
||||
@@ -648,17 +653,24 @@ class TranslationService extends ChangeNotifier {
|
||||
throw const TranslationDownloadCancelled();
|
||||
}
|
||||
_downloadedBytes += chunk.length;
|
||||
notifyListeners();
|
||||
_notify();
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
|
||||
void _notify() {
|
||||
if (_disposed) {
|
||||
return;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _setBusy(bool value) {
|
||||
if (_isBusy == value) {
|
||||
return;
|
||||
}
|
||||
_isBusy = value;
|
||||
notifyListeners();
|
||||
_notify();
|
||||
}
|
||||
|
||||
void _setDownloading(bool value) {
|
||||
@@ -669,11 +681,12 @@ class TranslationService extends ChangeNotifier {
|
||||
_downloadTotalBytes = null;
|
||||
_downloadFileName = null;
|
||||
}
|
||||
notifyListeners();
|
||||
_notify();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
final engine = _engine;
|
||||
_engine = null;
|
||||
_loadedModelPath = null;
|
||||
|
||||
@@ -33,12 +33,14 @@ class UsbSerialService {
|
||||
String? _connectedPortLabel;
|
||||
FlSerial? _serial;
|
||||
AppDebugLogService? _debugLogService;
|
||||
Object? _lastError;
|
||||
|
||||
UsbSerialStatus get status => _status;
|
||||
String? get activePortKey => _connectedPortKey;
|
||||
String? get activePortDisplayLabel =>
|
||||
_connectedPortLabel ?? _connectedPortKey;
|
||||
Stream<Uint8List> get frameStream => _frameController.stream;
|
||||
Object? get lastError => _lastError;
|
||||
bool get _useAndroidUsbHost =>
|
||||
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
|
||||
bool get _useDesktopFlSerial =>
|
||||
@@ -434,6 +436,7 @@ class UsbSerialService {
|
||||
}
|
||||
|
||||
void _addFrameError(Object error, [StackTrace? stackTrace]) {
|
||||
_lastError = error;
|
||||
if (_frameController.isClosed) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,18 @@ class UsbSerialService {
|
||||
static const Map<String, String> _knownUsbNames = <String, String>{
|
||||
'2886:1667': 'Seeed Wio Tracker L1',
|
||||
};
|
||||
|
||||
/// USB-to-UART bridge chips whose hardware auto-reset circuit requires DTR
|
||||
/// to be held asserted after open (otherwise the MCU resets). Native-USB-CDC
|
||||
/// boards (nRF52840/Adafruit 0x239A, Espressif native 0x303A, Seeed 0x2886)
|
||||
/// tie DTR to the bootloader/reset line, so asserting it re-enumerates and
|
||||
/// drops the device ("The device has been lost"); they must be left alone.
|
||||
static const Set<int> _uartBridgeVendorIds = <int>{
|
||||
0x10C4, // Silicon Labs CP210x
|
||||
0x1A86, // QinHeng CH340 / CH9102
|
||||
0x0403, // FTDI
|
||||
0x067B, // Prolific PL2303
|
||||
};
|
||||
static final Map<String, String> _deviceNamesByPortKey = <String, String>{};
|
||||
static final Map<String, String> _baseLabelsByPortKey = <String, String>{};
|
||||
static final Map<String, JSObject> _authorizedPortsByKey =
|
||||
@@ -34,12 +46,14 @@ class UsbSerialService {
|
||||
String _requestPortLabel = 'Choose USB Device';
|
||||
String _fallbackDeviceName = 'Web Serial Device';
|
||||
AppDebugLogService? _debugLogService;
|
||||
Object? _lastError;
|
||||
|
||||
UsbSerialStatus get status => _status;
|
||||
String? get activePortKey => _connectedPortKey;
|
||||
String? get activePortDisplayLabel => _connectedPortName ?? _connectedPortKey;
|
||||
Stream<Uint8List> get frameStream => _frameController.stream;
|
||||
bool get isConnected => _status == UsbSerialStatus.connected;
|
||||
Object? get lastError => _lastError;
|
||||
|
||||
JSObject get _navigator => JSObject.fromInteropObject(web.window.navigator);
|
||||
bool get _isSupported => _navigator.has('serial');
|
||||
@@ -74,6 +88,7 @@ class UsbSerialService {
|
||||
}
|
||||
|
||||
_status = UsbSerialStatus.connecting;
|
||||
_lastError = null;
|
||||
_frameDecoder.reset();
|
||||
|
||||
try {
|
||||
@@ -282,16 +297,30 @@ class UsbSerialService {
|
||||
..['flowControl'] = 'none'.toJS;
|
||||
await port.callMethod<JSPromise<JSAny?>>('open'.toJS, options).toDart;
|
||||
|
||||
// Prevent ESP32 USB-CDC reset: hold DTR=true, RTS=false after open.
|
||||
try {
|
||||
final signals = JSObject()
|
||||
..['dataTerminalReady'] = true.toJS
|
||||
..['requestToSend'] = false.toJS;
|
||||
await port
|
||||
.callMethod<JSPromise<JSAny?>>('setSignals'.toJS, signals)
|
||||
.toDart;
|
||||
} catch (_) {
|
||||
// setSignals may not be supported on all browsers/devices.
|
||||
// Only UART-bridge chips (CP210x/CH340/FTDI/PL2303) need DTR held high to
|
||||
// avoid the auto-reset circuit firing on open. Native-USB-CDC boards
|
||||
// (e.g. nRF52840/Adafruit) tie DTR to the reset line — toggling it there
|
||||
// re-enumerates the device and Web Serial reports "The device has been
|
||||
// lost". Leave their signals untouched.
|
||||
final vendorId = _portInfo(port)?.usbVendorId;
|
||||
final isUartBridge =
|
||||
vendorId != null && _uartBridgeVendorIds.contains(vendorId);
|
||||
_debugLogService?.info(
|
||||
'Open: vendorId=${vendorId == null ? 'unknown' : '0x${vendorId.toRadixString(16)}'} '
|
||||
'uartBridge=$isUartBridge (DTR ${isUartBridge ? 'asserted' : 'left default'})',
|
||||
tag: 'USB Serial',
|
||||
);
|
||||
if (isUartBridge) {
|
||||
try {
|
||||
final signals = JSObject()
|
||||
..['dataTerminalReady'] = true.toJS
|
||||
..['requestToSend'] = false.toJS;
|
||||
await port
|
||||
.callMethod<JSPromise<JSAny?>>('setSignals'.toJS, signals)
|
||||
.toDart;
|
||||
} catch (_) {
|
||||
// setSignals may not be supported on all browsers/devices.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,13 +413,21 @@ class UsbSerialService {
|
||||
} catch (error, stackTrace) {
|
||||
_debugLogService?.error('_pumpReads error: $error', tag: 'USB Serial');
|
||||
if (_status == UsbSerialStatus.connected) {
|
||||
// The transport is dead — reflect that in status immediately so a
|
||||
// concurrent connect handshake fails fast instead of waiting for a
|
||||
// SELF_INFO that can never arrive.
|
||||
_status = UsbSerialStatus.disconnected;
|
||||
_lastError = error;
|
||||
_addFrameError(error, stackTrace);
|
||||
}
|
||||
} finally {
|
||||
_debugLogService?.info('_pumpReads: ended', tag: 'USB Serial');
|
||||
_releaseLock(reader);
|
||||
if (_status == UsbSerialStatus.connected && identical(reader, _reader)) {
|
||||
_addFrameError(StateError('USB serial connection closed'));
|
||||
_status = UsbSerialStatus.disconnected;
|
||||
final closedError = StateError('USB serial connection closed');
|
||||
_lastError = closedError;
|
||||
_addFrameError(closedError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user