mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
Merge pull request #270 from just-stuff-tm/fix/linux-ble-pairing-flow
Some checks failed
Build / android (push) Has been cancelled
Build / ios (push) Has been cancelled
Build / linux (push) Has been cancelled
Build / macos (push) Has been cancelled
Build / web (push) Has been cancelled
Flutter and Dart / analyze (push) Has been cancelled
Some checks failed
Build / android (push) Has been cancelled
Build / ios (push) Has been cancelled
Build / linux (push) Has been cancelled
Build / macos (push) Has been cancelled
Build / web (push) Has been cancelled
Flutter and Dart / analyze (push) Has been cancelled
Fix/linux ble pairing flow
This commit is contained in:
commit
411cd3f8d2
46 changed files with 2129 additions and 52 deletions
|
|
@ -6,6 +6,7 @@ import 'package:crypto/crypto.dart' as crypto;
|
|||
import 'package:pointycastle/export.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import 'package:flutter_blue_plus_platform_interface/flutter_blue_plus_platform_interface.dart';
|
||||
|
||||
import '../models/channel.dart';
|
||||
import '../models/channel_message.dart';
|
||||
|
|
@ -17,6 +18,9 @@ import '../helpers/reaction_helper.dart';
|
|||
import '../helpers/smaz.dart';
|
||||
import '../services/app_debug_log_service.dart';
|
||||
import '../services/ble_debug_log_service.dart';
|
||||
import '../services/linux_ble_error_classifier.dart';
|
||||
import '../services/linux_ble_pairing_service_stub.dart'
|
||||
if (dart.library.io) '../services/linux_ble_pairing_service.dart';
|
||||
import '../services/message_retry_service.dart';
|
||||
import '../services/path_history_service.dart';
|
||||
import '../services/app_settings_service.dart';
|
||||
|
|
@ -37,14 +41,9 @@ import '../storage/unread_store.dart';
|
|||
import '../utils/app_logger.dart';
|
||||
import '../utils/battery_utils.dart';
|
||||
import '../utils/platform_info.dart';
|
||||
import 'meshcore_uuids.dart';
|
||||
import 'meshcore_protocol.dart';
|
||||
|
||||
class MeshCoreUuids {
|
||||
static const String service = "6e400001-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
static const String rxCharacteristic = "6e400002-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
static const String txCharacteristic = "6e400003-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
}
|
||||
|
||||
class DirectRepeater {
|
||||
static const int maxAgeMinutes = 30; // Max age for direct repeater info
|
||||
final int pubkeyFirstByte;
|
||||
|
|
@ -118,11 +117,14 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
String? _lastDeviceDisplayName;
|
||||
bool _manualDisconnect = false;
|
||||
final MeshCoreUsbManager _usbManager = MeshCoreUsbManager();
|
||||
final LinuxBlePairingService _linuxBlePairingService =
|
||||
LinuxBlePairingService();
|
||||
StreamSubscription<Uint8List>? _usbFrameSubscription;
|
||||
final MeshCoreTcpConnector _tcpConnector = MeshCoreTcpConnector();
|
||||
MeshCoreTransportType _activeTransport = MeshCoreTransportType.bluetooth;
|
||||
|
||||
final List<ScanResult> _scanResults = [];
|
||||
final List<ScanResult> _linuxSystemScanResults = [];
|
||||
final List<Contact> _contacts = [];
|
||||
final List<Contact> _discoveredContacts = [];
|
||||
final List<Channel> _channels = [];
|
||||
|
|
@ -287,6 +289,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
bool get isUsbTransportConnected =>
|
||||
_state == MeshCoreConnectionState.connected &&
|
||||
_activeTransport == MeshCoreTransportType.usb;
|
||||
bool get isAutoReconnectScheduled =>
|
||||
_shouldAutoReconnect && (_reconnectTimer?.isActive ?? false);
|
||||
String? get activeTcpEndpoint => _tcpConnector.activeEndpoint;
|
||||
bool get isTcpTransportConnected =>
|
||||
_state == MeshCoreConnectionState.connected &&
|
||||
|
|
@ -1026,6 +1030,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
if (_state == MeshCoreConnectionState.scanning) return;
|
||||
|
||||
_scanResults.clear();
|
||||
_linuxSystemScanResults.clear();
|
||||
_setState(MeshCoreConnectionState.scanning);
|
||||
|
||||
// Ensure any previous scan is fully stopped. Guard with isScanningNow to
|
||||
|
|
@ -1064,15 +1069,21 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
}
|
||||
|
||||
if (PlatformInfo.isLinux) {
|
||||
await _loadLinuxSystemDevicesForScan();
|
||||
}
|
||||
|
||||
_scanSubscription = FlutterBluePlus.scanResults.listen((results) {
|
||||
_scanResults.clear();
|
||||
_scanResults.addAll(results);
|
||||
_scanResults
|
||||
..clear()
|
||||
..addAll(results);
|
||||
_mergeLinuxSystemScanResults();
|
||||
notifyListeners();
|
||||
});
|
||||
|
||||
try {
|
||||
await FlutterBluePlus.startScan(
|
||||
withKeywords: ["MeshCore-", "Whisper-"],
|
||||
withKeywords: MeshCoreUuids.deviceNamePrefixes,
|
||||
webOptionalServices: [Guid(MeshCoreUuids.service)],
|
||||
timeout: timeout,
|
||||
androidScanMode: AndroidScanMode.lowLatency,
|
||||
|
|
@ -1087,6 +1098,62 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
await stopScan();
|
||||
}
|
||||
|
||||
Future<void> _loadLinuxSystemDevicesForScan() async {
|
||||
try {
|
||||
final systemDevices = await FlutterBluePlus.systemDevices([
|
||||
Guid(MeshCoreUuids.service),
|
||||
]);
|
||||
_linuxSystemScanResults
|
||||
..clear()
|
||||
..addAll(
|
||||
systemDevices
|
||||
.where(
|
||||
(device) => MeshCoreUuids.deviceNamePrefixes.any(
|
||||
device.platformName.startsWith,
|
||||
),
|
||||
)
|
||||
.map(
|
||||
(device) => ScanResult(
|
||||
device: device,
|
||||
advertisementData: AdvertisementData(
|
||||
advName: device.platformName,
|
||||
txPowerLevel: null,
|
||||
appearance: null,
|
||||
connectable: true,
|
||||
manufacturerData: const <int, List<int>>{},
|
||||
serviceData: const <Guid, List<int>>{},
|
||||
serviceUuids: <Guid>[Guid(MeshCoreUuids.service)],
|
||||
),
|
||||
rssi: 0,
|
||||
timeStamp: DateTime.now(),
|
||||
),
|
||||
),
|
||||
);
|
||||
_mergeLinuxSystemScanResults();
|
||||
notifyListeners();
|
||||
} catch (error) {
|
||||
_appDebugLogService?.warn(
|
||||
'Failed loading Linux paired/system BLE devices: $error',
|
||||
tag: 'BLE Scan',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _mergeLinuxSystemScanResults() {
|
||||
if (!PlatformInfo.isLinux || _linuxSystemScanResults.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final existingIds = _scanResults
|
||||
.map((result) => result.device.remoteId.str)
|
||||
.toSet();
|
||||
for (final result in _linuxSystemScanResults) {
|
||||
if (existingIds.contains(result.device.remoteId.str)) {
|
||||
continue;
|
||||
}
|
||||
_scanResults.add(result);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stopScan() async {
|
||||
// Only call FlutterBluePlus.stopScan() when a scan is actually running.
|
||||
// Calling it when idle triggers a native BLE completion callback even
|
||||
|
|
@ -1345,7 +1412,11 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
activeTransport == MeshCoreTransportType.tcp;
|
||||
}
|
||||
|
||||
Future<void> connect(BluetoothDevice device, {String? displayName}) async {
|
||||
Future<void> connect(
|
||||
BluetoothDevice device, {
|
||||
String? displayName,
|
||||
Future<String?> Function()? linuxPairingPinProvider,
|
||||
}) async {
|
||||
if (_state == MeshCoreConnectionState.connecting ||
|
||||
_state == MeshCoreConnectionState.connected) {
|
||||
return;
|
||||
|
|
@ -1390,22 +1461,149 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await device.connect(
|
||||
timeout: const Duration(seconds: 15),
|
||||
mtu: null,
|
||||
license: License.free,
|
||||
);
|
||||
} catch (error) {
|
||||
_appDebugLogService?.error(
|
||||
'device.connect() failure: $error',
|
||||
if (PlatformInfo.isLinux) {
|
||||
final remoteId = device.remoteId.str;
|
||||
_appDebugLogService?.info(
|
||||
'Linux pre-connect BlueZ disconnect for $remoteId',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
rethrow;
|
||||
await _linuxBlePairingService.disconnectDevice(
|
||||
remoteId,
|
||||
onLog: (message) {
|
||||
_appDebugLogService?.info(message, tag: 'BLE Pair');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Request larger MTU only on native platforms; web does not support it.
|
||||
if (!PlatformInfo.isWeb) {
|
||||
final connectTimeout = PlatformInfo.isLinux
|
||||
? const Duration(seconds: 6)
|
||||
: const Duration(seconds: 15);
|
||||
_appDebugLogService?.info(
|
||||
'device.connect timeout set to ${connectTimeout.inSeconds}s',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
if (PlatformInfo.isLinux) {
|
||||
Future<void> attemptConnect() {
|
||||
return device
|
||||
.connect(
|
||||
timeout: connectTimeout,
|
||||
mtu: null,
|
||||
license: License.free,
|
||||
)
|
||||
.timeout(
|
||||
connectTimeout + const Duration(seconds: 2),
|
||||
onTimeout: () {
|
||||
throw TimeoutException(
|
||||
'Linux connect hard-timeout after ${connectTimeout.inSeconds + 2}s',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await attemptConnect();
|
||||
} catch (error) {
|
||||
_appDebugLogService?.error(
|
||||
'device.connect() failure: $error',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
final remoteId = device.remoteId.str;
|
||||
_appDebugLogService?.warn(
|
||||
'Linux immediate retry: forcing BlueZ disconnect before second connect attempt',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
await _linuxBlePairingService.disconnectDevice(
|
||||
remoteId,
|
||||
onLog: (message) {
|
||||
_appDebugLogService?.info(message, tag: 'BLE Pair');
|
||||
},
|
||||
);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 700));
|
||||
try {
|
||||
await attemptConnect();
|
||||
_appDebugLogService?.info(
|
||||
'Linux immediate retry connect succeeded',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
} catch (retryError, retryStackTrace) {
|
||||
Object finalConnectError = retryError;
|
||||
StackTrace finalConnectStackTrace = retryStackTrace;
|
||||
final retryErrorText = retryError.toString().toLowerCase();
|
||||
final isAbortByLocal = retryErrorText.contains(
|
||||
'le-connection-abort-by-local',
|
||||
);
|
||||
var recoveredOnThirdAttempt = false;
|
||||
if (isAbortByLocal) {
|
||||
_appDebugLogService?.warn(
|
||||
'Linux immediate retry aborted by local stack; waiting and retrying once more',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 1200));
|
||||
try {
|
||||
await attemptConnect();
|
||||
_appDebugLogService?.info(
|
||||
'Linux third-attempt connect succeeded after local abort',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
recoveredOnThirdAttempt = true;
|
||||
} catch (thirdError, thirdStackTrace) {
|
||||
finalConnectError = thirdError;
|
||||
finalConnectStackTrace = thirdStackTrace;
|
||||
_appDebugLogService?.error(
|
||||
'device.connect() third-attempt failure: $thirdError',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!recoveredOnThirdAttempt) {
|
||||
final recoveredByPairing = await _recoverLinuxConnectFailure(
|
||||
device,
|
||||
attemptConnect: attemptConnect,
|
||||
onRequestPin: linuxPairingPinProvider,
|
||||
);
|
||||
if (recoveredByPairing) {
|
||||
_appDebugLogService?.info(
|
||||
'Linux connect succeeded after pairing/trust recovery',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
} else {
|
||||
_appDebugLogService?.error(
|
||||
'device.connect() retry failure: $finalConnectError',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
Error.throwWithStackTrace(
|
||||
_wrapLinuxConnectStageError(finalConnectError),
|
||||
finalConnectStackTrace,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await device.connect(
|
||||
timeout: connectTimeout,
|
||||
mtu: null,
|
||||
license: License.free,
|
||||
);
|
||||
} catch (error) {
|
||||
_appDebugLogService?.error(
|
||||
'device.connect() failure: $error',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
if (PlatformInfo.isLinux) {
|
||||
await _ensureLinuxBleBond(
|
||||
device,
|
||||
onRequestPin: linuxPairingPinProvider,
|
||||
);
|
||||
}
|
||||
|
||||
// Request larger MTU only where the platform path supports it.
|
||||
if (!PlatformInfo.isWeb && !PlatformInfo.isLinux) {
|
||||
try {
|
||||
final mtu = await device.requestMtu(185);
|
||||
_appDebugLogService?.info('MTU set to: $mtu', tag: 'BLE Connect');
|
||||
|
|
@ -1415,6 +1613,11 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
tag: 'BLE Connect',
|
||||
);
|
||||
}
|
||||
} else if (PlatformInfo.isLinux) {
|
||||
_appDebugLogService?.info(
|
||||
'Skipping MTU request on Linux; flutter_blue_plus only supports requestMtu on Android',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
}
|
||||
|
||||
late final List<BluetoothService> services;
|
||||
|
|
@ -1528,11 +1731,222 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
await _startBleInitialSync();
|
||||
} catch (e) {
|
||||
_appDebugLogService?.error('Connection error: $e', tag: 'BLE Connect');
|
||||
await disconnect(manual: false);
|
||||
final errorText = e.toString();
|
||||
final lowerErrorText = errorText.toLowerCase();
|
||||
final isLinuxPairingFailure =
|
||||
PlatformInfo.isLinux && isLinuxBlePairingFailureText(errorText);
|
||||
final isLikelyPairingTimeout = isLikelyLinuxBlePairingTimeoutText(
|
||||
errorText,
|
||||
);
|
||||
final isConnectFailure = isLinuxBleConnectFailureText(errorText);
|
||||
final isConnectTimeoutFailure =
|
||||
isConnectFailure && lowerErrorText.contains('timed out');
|
||||
final isLinuxConnectFailure = PlatformInfo.isLinux && isConnectFailure;
|
||||
// Linux pairing failures should not enter auto-reconnect loops; user
|
||||
// needs to retry manually so they can re-enter PIN / resolve pairing.
|
||||
if (isLinuxPairingFailure) {
|
||||
_appDebugLogService?.warn(
|
||||
isLikelyPairingTimeout
|
||||
? 'Linux pairing timed out: stopping reconnect until user retries manually'
|
||||
: 'Linux pairing failure: stopping reconnect until user retries manually',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
await disconnect(manual: true);
|
||||
} else if (isLinuxConnectFailure) {
|
||||
_appDebugLogService?.warn(
|
||||
isConnectTimeoutFailure
|
||||
? 'Linux connect timeout: issuing BlueZ disconnect before reconnect'
|
||||
: 'Linux connect failure: issuing BlueZ disconnect before reconnect',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
final remoteId = _device?.remoteId.str;
|
||||
if (remoteId != null) {
|
||||
await _linuxBlePairingService.disconnectDevice(
|
||||
remoteId,
|
||||
onLog: (message) {
|
||||
_appDebugLogService?.info(message, tag: 'BLE Pair');
|
||||
},
|
||||
);
|
||||
}
|
||||
await disconnect(manual: false, skipBleDeviceDisconnect: true);
|
||||
} else {
|
||||
await disconnect(manual: false);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _recoverLinuxConnectFailure(
|
||||
BluetoothDevice device, {
|
||||
required Future<void> Function() attemptConnect,
|
||||
Future<String?> Function()? onRequestPin,
|
||||
}) async {
|
||||
if (!PlatformInfo.isLinux ||
|
||||
!await _linuxBlePairingService.isBluetoothctlAvailable()) {
|
||||
return false;
|
||||
}
|
||||
final remoteId = device.remoteId.str;
|
||||
final pluginBondState = await _getLinuxPluginBondState(device);
|
||||
final trustedByBluez = await _linuxBlePairingService.isPairedAndTrusted(
|
||||
remoteId,
|
||||
);
|
||||
final needsBondRecovery =
|
||||
(pluginBondState != null &&
|
||||
pluginBondState != BmBondStateEnum.bonded) ||
|
||||
!trustedByBluez;
|
||||
if (!needsBondRecovery) {
|
||||
return false;
|
||||
}
|
||||
_appDebugLogService?.warn(
|
||||
pluginBondState == BmBondStateEnum.bonded
|
||||
? 'Linux connect failed with an untrusted bond; attempting trust/pair recovery'
|
||||
: 'Linux connect failed before bond completed; attempting pairing fallback',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
await _ensureLinuxBleBond(device, onRequestPin: onRequestPin);
|
||||
_appDebugLogService?.info(
|
||||
'Resetting BlueZ connection after Linux pairing/trust recovery',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
await _linuxBlePairingService.disconnectDevice(
|
||||
remoteId,
|
||||
onLog: (message) {
|
||||
_appDebugLogService?.info(message, tag: 'BLE Pair');
|
||||
},
|
||||
);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 700));
|
||||
try {
|
||||
await attemptConnect();
|
||||
} catch (error, stackTrace) {
|
||||
Error.throwWithStackTrace(_wrapLinuxConnectStageError(error), stackTrace);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Object _wrapLinuxConnectStageError(Object error) {
|
||||
final errorText = error.toString();
|
||||
if (errorText.toLowerCase().contains(linuxConnectStageFailureMarker)) {
|
||||
return error;
|
||||
}
|
||||
return StateError('Linux connect stage failure: $error');
|
||||
}
|
||||
|
||||
Future<BmBondStateEnum?> _getLinuxPluginBondState(
|
||||
BluetoothDevice device,
|
||||
) async {
|
||||
try {
|
||||
final response = await FlutterBluePlusPlatform.instance.getBondState(
|
||||
BmBondStateRequest(remoteId: device.remoteId),
|
||||
);
|
||||
return response.bondState;
|
||||
} catch (error) {
|
||||
_appDebugLogService?.warn(
|
||||
'Linux getBondState unavailable for ${device.remoteId.str}: $error',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _ensureLinuxBleBond(
|
||||
BluetoothDevice device, {
|
||||
Future<String?> Function()? onRequestPin,
|
||||
}) async {
|
||||
final remoteId = device.remoteId.str;
|
||||
final bluetoothctlAvailable = await _linuxBlePairingService
|
||||
.isBluetoothctlAvailable();
|
||||
final beforeBondState = await _getLinuxPluginBondState(device);
|
||||
if (!bluetoothctlAvailable) {
|
||||
if (beforeBondState == BmBondStateEnum.bonded) {
|
||||
_appDebugLogService?.warn(
|
||||
'bluetoothctl unavailable; continuing with plugin bonded state',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
} else if (beforeBondState == null) {
|
||||
_appDebugLogService?.warn(
|
||||
'bluetoothctl unavailable and plugin bond state is unknown; skipping Linux pairing fallback',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
} else {
|
||||
_appDebugLogService?.warn(
|
||||
'bluetoothctl unavailable and device is not bonded; skipping Linux pairing fallback',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final trustedByBluez = await _linuxBlePairingService.isPairedAndTrusted(
|
||||
remoteId,
|
||||
);
|
||||
if (trustedByBluez) {
|
||||
_appDebugLogService?.info(
|
||||
'Linux BLE device already paired/trusted, skipping pairing flow',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (beforeBondState == BmBondStateEnum.bonded && !trustedByBluez) {
|
||||
_appDebugLogService?.warn(
|
||||
'Linux BLE device is bonded but not trusted in BlueZ; repairing trust',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
final trustRepaired = await _linuxBlePairingService.trustDevice(
|
||||
remoteId,
|
||||
onLog: (message) {
|
||||
_appDebugLogService?.info(message, tag: 'BLE Pair');
|
||||
},
|
||||
);
|
||||
if (trustRepaired) {
|
||||
_appDebugLogService?.info(
|
||||
'Linux BLE trust repair succeeded without re-pairing',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
return;
|
||||
}
|
||||
_appDebugLogService?.warn(
|
||||
'Linux BLE trust repair did not stick; retrying pairing flow',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
}
|
||||
|
||||
_appDebugLogService?.info(
|
||||
beforeBondState == BmBondStateEnum.bonded
|
||||
? 'Linux BLE device still untrusted after repair; requesting pair'
|
||||
: beforeBondState == null
|
||||
? 'Linux BLE device bond state unknown; requesting pair'
|
||||
: 'Linux BLE device not bonded, requesting pair',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
final paired = await _linuxBlePairingService.pairAndTrust(
|
||||
remoteId: remoteId,
|
||||
onLog: (message) {
|
||||
_appDebugLogService?.info(message, tag: 'BLE Pair');
|
||||
},
|
||||
onRequestPin: onRequestPin,
|
||||
);
|
||||
if (!paired) {
|
||||
throw StateError('Linux pairing fallback failed');
|
||||
}
|
||||
|
||||
final afterBondState = await _getLinuxPluginBondState(device);
|
||||
if (afterBondState != null && afterBondState != BmBondStateEnum.bonded) {
|
||||
throw StateError('Linux BLE pairing did not complete');
|
||||
} else if (afterBondState == null) {
|
||||
_appDebugLogService?.warn(
|
||||
'Linux plugin bond state unavailable after pairing; relying on BlueZ trust verification',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
}
|
||||
final trustedAfter = await _linuxBlePairingService.isPairedAndTrusted(
|
||||
remoteId,
|
||||
);
|
||||
if (!trustedAfter) {
|
||||
throw StateError('Linux BLE trust repair did not complete');
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _waitForSelfInfo({required Duration timeout}) async {
|
||||
if (_selfPublicKey != null) return true;
|
||||
if (!isConnected) return false;
|
||||
|
|
@ -1656,7 +2070,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
});
|
||||
}
|
||||
|
||||
Future<void> disconnect({bool manual = true}) async {
|
||||
Future<void> disconnect({
|
||||
bool manual = true,
|
||||
bool skipBleDeviceDisconnect = false,
|
||||
}) async {
|
||||
if (_state == MeshCoreConnectionState.disconnecting) return;
|
||||
final transportAtDisconnect = _activeTransport;
|
||||
final transportLabel = switch (transportAtDisconnect) {
|
||||
|
|
@ -1700,11 +2117,18 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
_channelSyncTimeout = null;
|
||||
_channelSyncRetries = 0;
|
||||
|
||||
try {
|
||||
// Skip queued BLE operations so disconnect doesn't get stuck behind them.
|
||||
await _device?.disconnect(queue: false);
|
||||
} catch (e) {
|
||||
_appDebugLogService?.warn('Disconnect error: $e', tag: 'BLE Connect');
|
||||
if (!skipBleDeviceDisconnect) {
|
||||
try {
|
||||
// Skip queued BLE operations so disconnect doesn't get stuck behind them.
|
||||
await _device?.disconnect(queue: false);
|
||||
} catch (e) {
|
||||
_appDebugLogService?.warn('Disconnect error: $e', tag: 'BLE Connect');
|
||||
}
|
||||
} else {
|
||||
_appDebugLogService?.info(
|
||||
'Skipping plugin BLE disconnect and continuing cleanup',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
}
|
||||
|
||||
_device = null;
|
||||
|
|
|
|||
12
lib/connector/meshcore_uuids.dart
Normal file
12
lib/connector/meshcore_uuids.dart
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
class MeshCoreUuids {
|
||||
static const String service = "6e400001-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
static const String rxCharacteristic = "6e400002-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
static const String txCharacteristic = "6e400003-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
|
||||
static const List<String> deviceNamePrefixes = [
|
||||
"MeshCore-",
|
||||
"Whisper-",
|
||||
"WisCore-",
|
||||
"HT-",
|
||||
];
|
||||
}
|
||||
|
|
@ -2006,5 +2006,16 @@
|
|||
"radioStats_stripNoise": "Ниво на шума: {noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "Извличане на данни за радиото…",
|
||||
"radioStats_settingsTile": "Статистически данни за радиостанции",
|
||||
"radioStats_settingsSubtitle": "Ниво на шума, RSSI, SNR и време на пренос"
|
||||
"radioStats_settingsSubtitle": "Ниво на шума, RSSI, SNR и време на пренос",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingHidePin": "Скрий ПИН",
|
||||
"scanner_linuxPairingShowPin": "Покажи PIN",
|
||||
"scanner_linuxPairingPinTitle": "PIN код за сдвояване на Bluetooth",
|
||||
"scanner_linuxPairingPinPrompt": "Въведете ПИН за {deviceName} (оставете празно, ако няма)."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2034,5 +2034,16 @@
|
|||
"radioStats_stripNoise": "Rauschpegel: {noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "Abrufen von Radiostatus…",
|
||||
"radioStats_settingsTile": "Senderinformationen",
|
||||
"radioStats_settingsSubtitle": "Rauschpegel, RSSI, Signal-Rausch-Verhältnis (SNR) und Nutzzeit"
|
||||
"radioStats_settingsSubtitle": "Rauschpegel, RSSI, Signal-Rausch-Verhältnis (SNR) und Nutzzeit",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingShowPin": "PIN anzeigen",
|
||||
"scanner_linuxPairingHidePin": "PIN ausblenden",
|
||||
"scanner_linuxPairingPinTitle": "Bluetooth-Paarungs-PIN",
|
||||
"scanner_linuxPairingPinPrompt": "Geben Sie die PIN für {deviceName} ein (leer lassen, falls keine)."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
|
||||
"scanner_stop": "Stop",
|
||||
"scanner_scan": "Scan",
|
||||
"scanner_bluetoothOff": "Bluetooth is off",
|
||||
|
|
@ -2040,5 +2041,16 @@
|
|||
},
|
||||
"radioStats_stripWaiting": "Fetching radio stats…",
|
||||
"radioStats_settingsTile": "Radio stats",
|
||||
"radioStats_settingsSubtitle": "Noise floor, RSSI, SNR, and airtime"
|
||||
}
|
||||
"radioStats_settingsSubtitle": "Noise floor, RSSI, SNR, and airtime",
|
||||
"scanner_linuxPairingShowPin": "Show PIN",
|
||||
"scanner_linuxPairingHidePin": "Hide PIN",
|
||||
"scanner_linuxPairingPinTitle": "Bluetooth Pairing PIN",
|
||||
"scanner_linuxPairingPinPrompt": "Enter PIN for {deviceName} (leave blank if none).",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2034,5 +2034,16 @@
|
|||
"radioStats_stripNoise": "Nivel de ruido: {noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "Obteniendo estadísticas de la radio…",
|
||||
"radioStats_settingsTile": "Estadísticas de radio",
|
||||
"radioStats_settingsSubtitle": "Nivel de ruido, RSSI, SNR y tiempo de transmisión"
|
||||
"radioStats_settingsSubtitle": "Nivel de ruido, RSSI, SNR y tiempo de transmisión",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingShowPin": "Mostrar PIN",
|
||||
"scanner_linuxPairingPinTitle": "PIN de emparejamiento Bluetooth",
|
||||
"scanner_linuxPairingHidePin": "Ocultar PIN",
|
||||
"scanner_linuxPairingPinPrompt": "Introduzca el PIN para {deviceName} (déjelo en blanco si no hay ninguno)."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2006,5 +2006,16 @@
|
|||
"radioStats_stripNoise": "Niveau de bruit : {noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "Récupération des statistiques de la radio…",
|
||||
"radioStats_settingsTile": "Statistiques de radio",
|
||||
"radioStats_settingsSubtitle": "Niveau de bruit, RSSI, rapport signal/bruit (SNR) et temps d'antenne"
|
||||
"radioStats_settingsSubtitle": "Niveau de bruit, RSSI, rapport signal/bruit (SNR) et temps d'antenne",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingShowPin": "Afficher le code PIN",
|
||||
"scanner_linuxPairingHidePin": "Masquer le code PIN",
|
||||
"scanner_linuxPairingPinTitle": "Code PIN d’appairage Bluetooth",
|
||||
"scanner_linuxPairingPinPrompt": "Entrez le code PIN pour {deviceName} (laissez vide si aucun)."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2044,5 +2044,16 @@
|
|||
"contact_teleEnv": "Adatkapcsolati környezet",
|
||||
"contact_teleEnvSubtitle": "Engedje meg az érzékelő adatok megosztását",
|
||||
"map_showOverlaps": "Az ismétlő kulcsok ütköznek",
|
||||
"map_runTraceWithReturnPath": "Visszaforduljon az eredeti úton."
|
||||
"map_runTraceWithReturnPath": "Visszaforduljon az eredeti úton.",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingHidePin": "PIN elrejtése",
|
||||
"scanner_linuxPairingShowPin": "PIN megjelenítése",
|
||||
"scanner_linuxPairingPinTitle": "Bluetooth párosítási PIN",
|
||||
"scanner_linuxPairingPinPrompt": "Adja meg a(z) {deviceName} PIN-kódját (hagyja üresen, ha nincs)."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2006,5 +2006,16 @@
|
|||
"radioStats_stripNoise": "Livello di rumore: {noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "Recupero delle statistiche radio…",
|
||||
"radioStats_settingsTile": "Statistiche radio",
|
||||
"radioStats_settingsSubtitle": "Livello di rumore, RSSI, rapporto segnale/rumore (SNR) e tempo di trasmissione"
|
||||
"radioStats_settingsSubtitle": "Livello di rumore, RSSI, rapporto segnale/rumore (SNR) e tempo di trasmissione",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingShowPin": "Mostra PIN",
|
||||
"scanner_linuxPairingHidePin": "Nascondi PIN",
|
||||
"scanner_linuxPairingPinTitle": "PIN di associazione Bluetooth",
|
||||
"scanner_linuxPairingPinPrompt": "Inserisci il PIN per {deviceName} (lascia vuoto se non ce n'è)."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2044,5 +2044,16 @@
|
|||
"contact_teleEnv": "テレメトリ環境",
|
||||
"contact_teleEnvSubtitle": "環境センサーのデータを共有することを許可する",
|
||||
"map_showOverlaps": "リピーターキーの重複",
|
||||
"map_runTraceWithReturnPath": "元の経路に戻る。"
|
||||
"map_runTraceWithReturnPath": "元の経路に戻る。",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingShowPin": "PINを表示",
|
||||
"scanner_linuxPairingHidePin": "PINを非表示",
|
||||
"scanner_linuxPairingPinTitle": "Bluetooth ペアリング PIN",
|
||||
"scanner_linuxPairingPinPrompt": "{deviceName}のPINを入力してください(なしの場合は空欄のまま)。"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2044,5 +2044,16 @@
|
|||
"contact_teleEnv": "텔레메트리 환경",
|
||||
"contact_teleEnvSubtitle": "환경 센서 데이터를 공유하도록 허용",
|
||||
"map_showOverlaps": "반복 키 중복",
|
||||
"map_runTraceWithReturnPath": "원래 경로로 돌아가세요."
|
||||
"map_runTraceWithReturnPath": "원래 경로로 돌아가세요.",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingShowPin": "PIN 표시",
|
||||
"scanner_linuxPairingPinTitle": "블루투스 페어링 PIN",
|
||||
"scanner_linuxPairingHidePin": "PIN 숨기기",
|
||||
"scanner_linuxPairingPinPrompt": "{deviceName}에 대한 PIN을 입력하세요 (없으면 비워두세요)."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6148,6 +6148,30 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'Noise floor, RSSI, SNR, and airtime'**
|
||||
String get radioStats_settingsSubtitle;
|
||||
|
||||
/// No description provided for @scanner_linuxPairingShowPin.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show PIN'**
|
||||
String get scanner_linuxPairingShowPin;
|
||||
|
||||
/// No description provided for @scanner_linuxPairingHidePin.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hide PIN'**
|
||||
String get scanner_linuxPairingHidePin;
|
||||
|
||||
/// No description provided for @scanner_linuxPairingPinTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Bluetooth Pairing PIN'**
|
||||
String get scanner_linuxPairingPinTitle;
|
||||
|
||||
/// No description provided for @scanner_linuxPairingPinPrompt.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enter PIN for {deviceName} (leave blank if none).'**
|
||||
String scanner_linuxPairingPinPrompt(String deviceName);
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -3567,4 +3567,19 @@ class AppLocalizationsBg extends AppLocalizations {
|
|||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Ниво на шума, RSSI, SNR и време на пренос';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Покажи PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Скрий ПИН';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle =>
|
||||
'PIN код за сдвояване на Bluetooth';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Въведете ПИН за $deviceName (оставете празно, ако няма).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3576,4 +3576,18 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Rauschpegel, RSSI, Signal-Rausch-Verhältnis (SNR) und Nutzzeit';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'PIN anzeigen';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'PIN ausblenden';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'Bluetooth-Paarungs-PIN';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Geben Sie die PIN für $deviceName ein (leer lassen, falls keine).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3501,4 +3501,18 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Noise floor, RSSI, SNR, and airtime';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Show PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Hide PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'Bluetooth Pairing PIN';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Enter PIN for $deviceName (leave blank if none).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3569,4 +3569,18 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Nivel de ruido, RSSI, SNR y tiempo de transmisión';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Mostrar PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Ocultar PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'PIN de emparejamiento Bluetooth';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Introduzca el PIN para $deviceName (déjelo en blanco si no hay ninguno).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3595,4 +3595,18 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Niveau de bruit, RSSI, rapport signal/bruit (SNR) et temps d\'antenne';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Afficher le code PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Masquer le code PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'Code PIN d’appairage Bluetooth';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Entrez le code PIN pour $deviceName (laissez vide si aucun).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3586,4 +3586,18 @@ class AppLocalizationsHu extends AppLocalizations {
|
|||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Háttérzaj, RSSI, zaj-sűrűség, és a használat időtartama';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'PIN megjelenítése';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'PIN elrejtése';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'Bluetooth párosítási PIN';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Adja meg a(z) $deviceName PIN-kódját (hagyja üresen, ha nincs).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3573,4 +3573,18 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Livello di rumore, RSSI, rapporto segnale/rumore (SNR) e tempo di trasmissione';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Mostra PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Nascondi PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'PIN di associazione Bluetooth';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Inserisci il PIN per $deviceName (lascia vuoto se non ce n\'è).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3401,4 +3401,18 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get radioStats_settingsSubtitle => 'ノイズレベル、RSSI、SNR、および通信時間';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'PINを表示';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'PINを非表示';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'Bluetooth ペアリング PIN';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return '$deviceNameのPINを入力してください(なしの場合は空欄のまま)。';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3400,4 +3400,18 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get radioStats_settingsSubtitle => '잡음 수준, RSSI, 신호 대 잡음비, 통신 시간';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'PIN 표시';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'PIN 숨기기';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => '블루투스 페어링 PIN';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return '$deviceName에 대한 PIN을 입력하세요 (없으면 비워두세요).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3551,4 +3551,18 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Ruimtelijke ruis, RSSI, SNR en beschikbare tijd';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Toon PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'PIN verbergen';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'Bluetooth‑koppelings‑PIN';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Voer PIN in voor $deviceName (laat leeg als er geen is).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3585,4 +3585,18 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Szum tła, RSSI, SNR oraz czas dostępny';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Pokaż PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Ukryj PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'Kod PIN parowania Bluetooth';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Wprowadź kod PIN dla $deviceName (pozostaw puste, jeśli brak).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3566,4 +3566,18 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Nível de ruído, RSSI, SNR e tempo de transmissão';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Mostrar PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Ocultar PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'PIN de emparelhamento Bluetooth';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Insira o PIN para $deviceName (deixe em branco se não houver).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3580,4 +3580,18 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Уровень шума, RSSI, SNR и время передачи';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Показать PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Скрыть PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'PIN‑код сопряжения Bluetooth';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Введите PIN‑код для $deviceName (оставьте пустым, если нет).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3544,4 +3544,18 @@ class AppLocalizationsSk extends AppLocalizations {
|
|||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Úroveň hluku, RSSI, SNR a časové rozloženie';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Zobraziť PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Skryť PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'Bluetooth párovací PIN';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Zadajte PIN pre $deviceName (ak nie je, nechajte prázdne).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3549,4 +3549,18 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Število šumov, RSSI, SNR in čas, ki ga je napolnila oprema';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Prikaži PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Skrij PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'Bluetooth PIN za seznanjanje';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Vnesite PIN za $deviceName (pustite prazno, če ga ni).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3526,4 +3526,18 @@ class AppLocalizationsSv extends AppLocalizations {
|
|||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Bakgrundsnivå, RSSI, SNR och tillgänglig tid';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Visa PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Dölj PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'Bluetooth‑parnings‑PIN';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Ange PIN för $deviceName (lämna tomt om ingen).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3583,4 +3583,18 @@ class AppLocalizationsUk extends AppLocalizations {
|
|||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Рівень шуму, RSSI, SNR та час, протягом якого пристрій використовує радіоканал.';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Показати PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Приховати PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'PIN‑код спарювання Bluetooth';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Введіть PIN для $deviceName (залиште порожнім, якщо його немає).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3298,4 +3298,18 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get radioStats_settingsSubtitle => '噪声水平、RSSI、信噪比和空中时间';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => '显示 PIN码';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => '隐藏 PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => '蓝牙配对 PIN';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return '输入 $deviceName 的 PIN(如果没有,请留空)。';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2006,5 +2006,16 @@
|
|||
"radioStats_stripNoise": "Ruisfrequentie: {noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "Radio-statistieken ophalen…",
|
||||
"radioStats_settingsTile": "Statistieken over radio",
|
||||
"radioStats_settingsSubtitle": "Ruimtelijke ruis, RSSI, SNR en beschikbare tijd"
|
||||
"radioStats_settingsSubtitle": "Ruimtelijke ruis, RSSI, SNR en beschikbare tijd",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingShowPin": "Toon PIN",
|
||||
"scanner_linuxPairingHidePin": "PIN verbergen",
|
||||
"scanner_linuxPairingPinPrompt": "Voer PIN in voor {deviceName} (laat leeg als er geen is).",
|
||||
"scanner_linuxPairingPinTitle": "Bluetooth‑koppelings‑PIN"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2044,5 +2044,16 @@
|
|||
"radioStats_stripNoise": "Poziom szumów: {noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "Pobieranie danych dotyczących radia…",
|
||||
"radioStats_settingsTile": "Statystyki radiowe",
|
||||
"radioStats_settingsSubtitle": "Szum tła, RSSI, SNR oraz czas dostępny"
|
||||
"radioStats_settingsSubtitle": "Szum tła, RSSI, SNR oraz czas dostępny",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingShowPin": "Pokaż PIN",
|
||||
"scanner_linuxPairingHidePin": "Ukryj PIN",
|
||||
"scanner_linuxPairingPinPrompt": "Wprowadź kod PIN dla {deviceName} (pozostaw puste, jeśli brak).",
|
||||
"scanner_linuxPairingPinTitle": "Kod PIN parowania Bluetooth"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2006,5 +2006,16 @@
|
|||
"radioStats_stripNoise": "Nível de ruído: {noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "Obtendo estatísticas de rádio…",
|
||||
"radioStats_settingsTile": "Estatísticas de rádio",
|
||||
"radioStats_settingsSubtitle": "Nível de ruído, RSSI, SNR e tempo de transmissão"
|
||||
"radioStats_settingsSubtitle": "Nível de ruído, RSSI, SNR e tempo de transmissão",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingShowPin": "Mostrar PIN",
|
||||
"scanner_linuxPairingHidePin": "Ocultar PIN",
|
||||
"scanner_linuxPairingPinPrompt": "Insira o PIN para {deviceName} (deixe em branco se não houver).",
|
||||
"scanner_linuxPairingPinTitle": "PIN de emparelhamento Bluetooth"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1246,5 +1246,16 @@
|
|||
"radioStats_stripNoise": "Уровень шума: {noiseDbm} дБм",
|
||||
"radioStats_stripWaiting": "Получение данных о радио…",
|
||||
"radioStats_settingsTile": "Статистика радиовещания",
|
||||
"radioStats_settingsSubtitle": "Уровень шума, RSSI, SNR и время передачи"
|
||||
"radioStats_settingsSubtitle": "Уровень шума, RSSI, SNR и время передачи",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingShowPin": "Показать PIN",
|
||||
"scanner_linuxPairingPinPrompt": "Введите PIN‑код для {deviceName} (оставьте пустым, если нет).",
|
||||
"scanner_linuxPairingHidePin": "Скрыть PIN",
|
||||
"scanner_linuxPairingPinTitle": "PIN‑код сопряжения Bluetooth"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2006,5 +2006,16 @@
|
|||
"radioStats_stripNoise": "Úroveň hluku: {noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "Získavanie údajov o rádiu…",
|
||||
"radioStats_settingsTile": "Štatistiky rádiových vysielaní",
|
||||
"radioStats_settingsSubtitle": "Úroveň hluku, RSSI, SNR a časové rozloženie"
|
||||
"radioStats_settingsSubtitle": "Úroveň hluku, RSSI, SNR a časové rozloženie",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingPinPrompt": "Zadajte PIN pre {deviceName} (ak nie je, nechajte prázdne).",
|
||||
"scanner_linuxPairingShowPin": "Zobraziť PIN",
|
||||
"scanner_linuxPairingHidePin": "Skryť PIN",
|
||||
"scanner_linuxPairingPinTitle": "Bluetooth párovací PIN"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2006,5 +2006,16 @@
|
|||
"radioStats_stripNoise": "Število šuma: {noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "Prejemanje statistike o radiju…",
|
||||
"radioStats_settingsTile": "Radijske statistike",
|
||||
"radioStats_settingsSubtitle": "Število šumov, RSSI, SNR in čas, ki ga je napolnila oprema"
|
||||
"radioStats_settingsSubtitle": "Število šumov, RSSI, SNR in čas, ki ga je napolnila oprema",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingShowPin": "Prikaži PIN",
|
||||
"scanner_linuxPairingHidePin": "Skrij PIN",
|
||||
"scanner_linuxPairingPinPrompt": "Vnesite PIN za {deviceName} (pustite prazno, če ga ni).",
|
||||
"scanner_linuxPairingPinTitle": "Bluetooth PIN za seznanjanje"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2006,5 +2006,16 @@
|
|||
"radioStats_stripNoise": "Bakgrundsnivå: {noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "Hämtar radiostatistik…",
|
||||
"radioStats_settingsTile": "Radiostation",
|
||||
"radioStats_settingsSubtitle": "Bakgrundsnivå, RSSI, SNR och tillgänglig tid"
|
||||
"radioStats_settingsSubtitle": "Bakgrundsnivå, RSSI, SNR och tillgänglig tid",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingShowPin": "Visa PIN",
|
||||
"scanner_linuxPairingPinTitle": "Bluetooth‑parnings‑PIN",
|
||||
"scanner_linuxPairingPinPrompt": "Ange PIN för {deviceName} (lämna tomt om ingen).",
|
||||
"scanner_linuxPairingHidePin": "Dölj PIN"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2006,5 +2006,16 @@
|
|||
"radioStats_stripNoise": "Рівень шуму: {noiseDbm} дБм",
|
||||
"radioStats_stripWaiting": "Отримано статистику радіо…",
|
||||
"radioStats_settingsTile": "Дані про радіостанції",
|
||||
"radioStats_settingsSubtitle": "Рівень шуму, RSSI, SNR та час, протягом якого пристрій використовує радіоканал."
|
||||
"radioStats_settingsSubtitle": "Рівень шуму, RSSI, SNR та час, протягом якого пристрій використовує радіоканал.",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingPinTitle": "PIN‑код спарювання Bluetooth",
|
||||
"scanner_linuxPairingShowPin": "Показати PIN",
|
||||
"scanner_linuxPairingPinPrompt": "Введіть PIN для {deviceName} (залиште порожнім, якщо його немає).",
|
||||
"scanner_linuxPairingHidePin": "Приховати PIN"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2011,5 +2011,16 @@
|
|||
"radioStats_stripNoise": "噪声水平:{noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "正在获取收音机数据…",
|
||||
"radioStats_settingsTile": "广播统计数据",
|
||||
"radioStats_settingsSubtitle": "噪声水平、RSSI、信噪比和空中时间"
|
||||
"radioStats_settingsSubtitle": "噪声水平、RSSI、信噪比和空中时间",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingShowPin": "显示 PIN码",
|
||||
"scanner_linuxPairingPinPrompt": "输入 {deviceName} 的 PIN(如果没有,请留空)。",
|
||||
"scanner_linuxPairingPinTitle": "蓝牙配对 PIN",
|
||||
"scanner_linuxPairingHidePin": "隐藏 PIN"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
|
|||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../services/linux_ble_error_classifier.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../widgets/adaptive_app_bar_title.dart';
|
||||
import '../widgets/device_tile.dart';
|
||||
|
|
@ -288,12 +289,33 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||
MeshCoreConnector connector,
|
||||
ScanResult result,
|
||||
) async {
|
||||
final name = result.device.platformName.isNotEmpty
|
||||
? result.device.platformName
|
||||
: result.advertisementData.advName;
|
||||
try {
|
||||
final name = result.device.platformName.isNotEmpty
|
||||
? result.device.platformName
|
||||
: result.advertisementData.advName;
|
||||
await connector.connect(result.device, displayName: name);
|
||||
await connector.connect(
|
||||
result.device,
|
||||
displayName: name,
|
||||
linuxPairingPinProvider: PlatformInfo.isLinux
|
||||
? () async {
|
||||
if (!context.mounted) return null;
|
||||
return _promptLinuxPairingPin(context, name);
|
||||
}
|
||||
: null,
|
||||
);
|
||||
} catch (e) {
|
||||
final errorText = e.toString();
|
||||
final suppressTransientLinuxConnectError =
|
||||
PlatformInfo.isLinux &&
|
||||
connector.isAutoReconnectScheduled &&
|
||||
isLinuxBleConnectFailureText(errorText);
|
||||
if (suppressTransientLinuxConnectError) {
|
||||
appLogger.info(
|
||||
'Suppressing transient Linux connect error while auto-reconnect is active: $e',
|
||||
tag: 'ScannerScreen',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
|
|
@ -305,6 +327,92 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
Future<String?> _promptLinuxPairingPin(
|
||||
BuildContext context,
|
||||
String deviceName,
|
||||
) async {
|
||||
final l10n = context.l10n;
|
||||
var pinValue = '';
|
||||
var obscure = true;
|
||||
appLogger.info(
|
||||
'Showing Linux BLE pairing PIN prompt for $deviceName',
|
||||
tag: 'ScannerScreen',
|
||||
);
|
||||
final pin = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (dialogContext, setDialogState) {
|
||||
return AlertDialog(
|
||||
title: Text(l10n.scanner_linuxPairingPinTitle),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.scanner_linuxPairingPinPrompt(deviceName)),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.done,
|
||||
obscureText: obscure,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
onChanged: (value) {
|
||||
pinValue = value.trim();
|
||||
},
|
||||
onSubmitted: (value) {
|
||||
Navigator.of(dialogContext).pop(value.trim());
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
suffixIcon: IconButton(
|
||||
onPressed: () {
|
||||
setDialogState(() {
|
||||
obscure = !obscure;
|
||||
});
|
||||
},
|
||||
icon: Icon(
|
||||
obscure ? Icons.visibility : Icons.visibility_off,
|
||||
),
|
||||
tooltip: obscure
|
||||
? l10n.scanner_linuxPairingShowPin
|
||||
: l10n.scanner_linuxPairingHidePin,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(null),
|
||||
child: Text(l10n.common_cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(pinValue),
|
||||
child: Text(l10n.common_connect),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
if (pin == null) {
|
||||
appLogger.info(
|
||||
'Linux BLE pairing PIN prompt cancelled for $deviceName',
|
||||
tag: 'ScannerScreen',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
appLogger.info(
|
||||
'Linux BLE pairing PIN prompt completed for $deviceName',
|
||||
tag: 'ScannerScreen',
|
||||
);
|
||||
return pin;
|
||||
}
|
||||
|
||||
Widget _bluetoothOffWarning(BuildContext context) {
|
||||
final errorColor = Theme.of(context).colorScheme.error;
|
||||
return Container(
|
||||
|
|
|
|||
37
lib/services/linux_ble_error_classifier.dart
Normal file
37
lib/services/linux_ble_error_classifier.dart
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
const String linuxConnectStageFailureMarker = 'linux connect stage failure';
|
||||
|
||||
bool isLinuxBleConnectFailureText(String errorText) {
|
||||
final lowerErrorText = errorText.toLowerCase();
|
||||
if (isLinuxBlePairingFailureText(errorText)) {
|
||||
return false;
|
||||
}
|
||||
return lowerErrorText.contains(linuxConnectStageFailureMarker) ||
|
||||
lowerErrorText.contains('| connect |') ||
|
||||
lowerErrorText.contains('linux connect hard-timeout') ||
|
||||
lowerErrorText.contains('org.bluez.error.failed') ||
|
||||
lowerErrorText.contains('org.bluez.error.inprogress') ||
|
||||
lowerErrorText.contains('le-connection-abort-by-local');
|
||||
}
|
||||
|
||||
bool isLinuxBlePairingFailureText(String errorText) {
|
||||
final lowerErrorText = errorText.toLowerCase();
|
||||
final isPairingSpecificStateError =
|
||||
lowerErrorText.contains('bad state: no element') &&
|
||||
(lowerErrorText.contains('pair') ||
|
||||
lowerErrorText.contains('bond') ||
|
||||
lowerErrorText.contains('trust'));
|
||||
return lowerErrorText.contains('authenticationfailed') ||
|
||||
lowerErrorText.contains('authentication failed') ||
|
||||
lowerErrorText.contains('notpermitted: not paired') ||
|
||||
lowerErrorText.contains('pairing fallback failed') ||
|
||||
lowerErrorText.contains('linux ble pairing did not complete') ||
|
||||
lowerErrorText.contains('linux ble trust repair did not complete') ||
|
||||
isPairingSpecificStateError ||
|
||||
isLikelyLinuxBlePairingTimeoutText(errorText);
|
||||
}
|
||||
|
||||
bool isLikelyLinuxBlePairingTimeoutText(String errorText) {
|
||||
final lowerErrorText = errorText.toLowerCase();
|
||||
return lowerErrorText.contains('timed out') &&
|
||||
(lowerErrorText.contains('pair') || lowerErrorText.contains('bond'));
|
||||
}
|
||||
423
lib/services/linux_ble_pairing_service.dart
Normal file
423
lib/services/linux_ble_pairing_service.dart
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
typedef ProcessStartFn =
|
||||
Future<Process> Function(String executable, List<String> arguments);
|
||||
typedef ProcessRunFn =
|
||||
Future<ProcessResult> Function(String executable, List<String> arguments);
|
||||
|
||||
/// Best-effort Linux BLE pairing helper using bluetoothctl.
|
||||
///
|
||||
/// This is used only as a fallback when BlueZ pairing via flutter_blue_plus
|
||||
/// fails to surface agent prompts in-app.
|
||||
class LinuxBlePairingService {
|
||||
/// Maximum number of pairing attempts (initial + retries).
|
||||
/// Covers one remove-and-retry plus one proactive-PIN retry.
|
||||
static const int _maxAttempts = 3;
|
||||
|
||||
static const Duration _processExitTimeout = Duration(seconds: 6);
|
||||
static const Duration _pairingCleanupTimeout = Duration(seconds: 5);
|
||||
static const Duration _defaultPairingTimeout = Duration(seconds: 45);
|
||||
LinuxBlePairingService({
|
||||
ProcessStartFn? processStart,
|
||||
ProcessRunFn? processRun,
|
||||
}) : _processStart = processStart ?? Process.start,
|
||||
_processRun = processRun ?? Process.run;
|
||||
|
||||
final ProcessStartFn _processStart;
|
||||
final ProcessRunFn _processRun;
|
||||
|
||||
Future<bool> isBluetoothctlAvailable() async {
|
||||
try {
|
||||
final result = await _processRun('bluetoothctl', <String>['--version']);
|
||||
return result.exitCode == 0;
|
||||
} on ProcessException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disconnectDevice(
|
||||
String remoteId, {
|
||||
void Function(String message)? onLog,
|
||||
}) async {
|
||||
onLog?.call('Requesting BlueZ disconnect for $remoteId');
|
||||
Process process;
|
||||
try {
|
||||
process = await _processStart('bluetoothctl', <String>[]);
|
||||
} on ProcessException catch (error) {
|
||||
onLog?.call(
|
||||
'bluetoothctl unavailable, skipping BlueZ disconnect: $error',
|
||||
);
|
||||
return;
|
||||
}
|
||||
process.stdin.writeln('disconnect $remoteId');
|
||||
process.stdin.writeln('quit');
|
||||
try {
|
||||
await process.exitCode.timeout(_processExitTimeout);
|
||||
} catch (_) {
|
||||
process.kill();
|
||||
}
|
||||
onLog?.call('Issued bluetoothctl disconnect for $remoteId');
|
||||
}
|
||||
|
||||
Future<bool> isPairedAndTrusted(String remoteId) async {
|
||||
ProcessResult result;
|
||||
try {
|
||||
result = await _processRun('bluetoothctl', <String>['info', remoteId]);
|
||||
} on ProcessException {
|
||||
return false;
|
||||
}
|
||||
if (result.exitCode != 0) {
|
||||
return false;
|
||||
}
|
||||
final output = (result.stdout as String).toLowerCase();
|
||||
return output.contains('paired: yes') && output.contains('trusted: yes');
|
||||
}
|
||||
|
||||
Future<bool> trustDevice(
|
||||
String remoteId, {
|
||||
void Function(String message)? onLog,
|
||||
}) async {
|
||||
onLog?.call('Requesting BlueZ trust for $remoteId');
|
||||
ProcessResult result;
|
||||
try {
|
||||
result = await _processRun('bluetoothctl', <String>['trust', remoteId]);
|
||||
} on ProcessException catch (error) {
|
||||
onLog?.call('bluetoothctl unavailable, cannot trust $remoteId: $error');
|
||||
return false;
|
||||
}
|
||||
if (result.exitCode != 0) {
|
||||
onLog?.call('bluetoothctl trust failed for $remoteId: ${result.stderr}');
|
||||
return false;
|
||||
}
|
||||
final trusted = await isPairedAndTrusted(remoteId);
|
||||
onLog?.call(
|
||||
trusted
|
||||
? 'Verified BlueZ trust for $remoteId'
|
||||
: 'BlueZ trust verification failed for $remoteId',
|
||||
);
|
||||
return trusted;
|
||||
}
|
||||
|
||||
Future<bool> pairAndTrust({
|
||||
required String remoteId,
|
||||
Duration timeout = _defaultPairingTimeout,
|
||||
void Function(String message)? onLog,
|
||||
Future<String?> Function()? onRequestPin,
|
||||
}) async {
|
||||
var removeRetryUsed = false;
|
||||
var proactivePinRetryUsed = false;
|
||||
Future<String?> Function()? currentPinProvider = onRequestPin;
|
||||
|
||||
for (var attempt = 0; attempt < _maxAttempts; attempt++) {
|
||||
final result = await _runPairingAttempt(
|
||||
remoteId: remoteId,
|
||||
timeout: timeout,
|
||||
onLog: onLog,
|
||||
onRequestPin: currentPinProvider,
|
||||
);
|
||||
|
||||
if (result.success) return true;
|
||||
if (result.userCancelled) {
|
||||
onLog?.call('Pairing cancelled by user; skipping retry/remove flow');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (result.pairFailed) {
|
||||
if (!removeRetryUsed) {
|
||||
removeRetryUsed = true;
|
||||
onLog?.call(
|
||||
'Pairing failed; removing cached bond and retrying '
|
||||
'(attempt ${attempt + 1}/$_maxAttempts)',
|
||||
);
|
||||
await _removeDevice(remoteId, onLog: onLog);
|
||||
continue;
|
||||
}
|
||||
if (!result.pinSent &&
|
||||
!proactivePinRetryUsed &&
|
||||
currentPinProvider != null) {
|
||||
proactivePinRetryUsed = true;
|
||||
onLog?.call(
|
||||
'Pairing failed before PIN challenge; requesting PIN for '
|
||||
'proactive retry (attempt ${attempt + 1}/$_maxAttempts)',
|
||||
);
|
||||
final pin = await currentPinProvider();
|
||||
if (pin == null) {
|
||||
onLog?.call('PIN entry cancelled for proactive retry');
|
||||
return false;
|
||||
}
|
||||
final capturedPin = pin.trim();
|
||||
currentPinProvider = () async => capturedPin;
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Timeout path — pairing neither succeeded nor failed.
|
||||
onLog?.call('Pairing did not complete before timeout');
|
||||
if (!result.pinSent &&
|
||||
!proactivePinRetryUsed &&
|
||||
currentPinProvider != null) {
|
||||
proactivePinRetryUsed = true;
|
||||
onLog?.call(
|
||||
'No PIN challenge observed before timeout; requesting PIN for '
|
||||
'proactive retry (attempt ${attempt + 1}/$_maxAttempts)',
|
||||
);
|
||||
final pin = await currentPinProvider();
|
||||
if (pin == null) {
|
||||
onLog?.call('PIN entry cancelled for proactive retry after timeout');
|
||||
return false;
|
||||
}
|
||||
final capturedPin = pin.trim();
|
||||
currentPinProvider = () async => capturedPin;
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Runs a single bluetoothctl pairing attempt.
|
||||
///
|
||||
/// Uses a [Completer] to wake as soon as pairing succeeds or fails,
|
||||
/// instead of polling.
|
||||
Future<_PairingResult> _runPairingAttempt({
|
||||
required String remoteId,
|
||||
required Duration timeout,
|
||||
void Function(String message)? onLog,
|
||||
Future<String?> Function()? onRequestPin,
|
||||
}) async {
|
||||
onLog?.call('Starting bluetoothctl pairing flow for $remoteId');
|
||||
Process process;
|
||||
try {
|
||||
process = await _processStart('bluetoothctl', <String>[]);
|
||||
} on ProcessException catch (error) {
|
||||
onLog?.call('bluetoothctl unavailable, cannot run pairing flow: $error');
|
||||
return const _PairingResult();
|
||||
}
|
||||
final output = StringBuffer();
|
||||
var pinSent = false;
|
||||
var sessionClosed = false;
|
||||
var userCancelledPinEntry = false;
|
||||
var confirmationHandled = false;
|
||||
var successHandled = false;
|
||||
var failureHandled = false;
|
||||
var detectorBuffer = '';
|
||||
final pairingDone = Completer<void>();
|
||||
var pairSucceeded = false;
|
||||
var pairFailed = false;
|
||||
|
||||
void writeCmd(String cmd) {
|
||||
if (sessionClosed) return;
|
||||
try {
|
||||
process.stdin.writeln(cmd);
|
||||
} on StateError {
|
||||
sessionClosed = true;
|
||||
onLog?.call('bluetoothctl stdin already closed; ignoring "$cmd"');
|
||||
}
|
||||
}
|
||||
|
||||
unawaited(
|
||||
process.exitCode.then((_) {
|
||||
sessionClosed = true;
|
||||
if (!pairingDone.isCompleted) pairingDone.complete();
|
||||
}),
|
||||
);
|
||||
|
||||
void handleChunk(String chunk) {
|
||||
output.write(chunk);
|
||||
detectorBuffer += chunk.toLowerCase();
|
||||
if (detectorBuffer.length > 4096) {
|
||||
detectorBuffer = detectorBuffer.substring(detectorBuffer.length - 4096);
|
||||
}
|
||||
final lower = detectorBuffer;
|
||||
|
||||
if (!pinSent &&
|
||||
!sessionClosed &&
|
||||
(lower.contains('enter pin code') ||
|
||||
lower.contains('requestpin') ||
|
||||
lower.contains('input pin code') ||
|
||||
lower.contains('request passkey') ||
|
||||
lower.contains('requestpasskey') ||
|
||||
lower.contains('enter passkey'))) {
|
||||
pinSent = true;
|
||||
if (onRequestPin == null) {
|
||||
onLog?.call(
|
||||
'PIN/passkey requested but no onRequestPin callback; '
|
||||
'sending empty line to accept default pairing',
|
||||
);
|
||||
writeCmd('');
|
||||
} else {
|
||||
onLog?.call('Pairing agent is ready for PIN/passkey input');
|
||||
unawaited(
|
||||
Future<void>(() async {
|
||||
String? pin;
|
||||
try {
|
||||
pin = await onRequestPin();
|
||||
} catch (e) {
|
||||
onLog?.call('onRequestPin callback threw: $e');
|
||||
pairFailed = true;
|
||||
writeCmd('cancel');
|
||||
if (!pairingDone.isCompleted) pairingDone.complete();
|
||||
return;
|
||||
}
|
||||
if (pin == null) {
|
||||
if (sessionClosed) {
|
||||
onLog?.call(
|
||||
'PIN prompt resolved after pairing session closed',
|
||||
);
|
||||
return;
|
||||
}
|
||||
onLog?.call('PIN entry cancelled by user; cancelling pairing');
|
||||
userCancelledPinEntry = true;
|
||||
pairFailed = true;
|
||||
writeCmd('cancel');
|
||||
if (!pairingDone.isCompleted) pairingDone.complete();
|
||||
return;
|
||||
}
|
||||
if (sessionClosed) {
|
||||
onLog?.call(
|
||||
'PIN provided after pairing session closed; ignoring',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (pin.trim().isEmpty) {
|
||||
onLog?.call(
|
||||
'Blank PIN submitted; sending empty line to accept default pairing',
|
||||
);
|
||||
writeCmd('');
|
||||
} else {
|
||||
onLog?.call('Submitting PIN/passkey to pairing agent');
|
||||
writeCmd(pin.trim());
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!confirmationHandled &&
|
||||
(lower.contains('confirm passkey') ||
|
||||
lower.contains('requestconfirmation') ||
|
||||
lower.contains('[agent] confirm'))) {
|
||||
confirmationHandled = true;
|
||||
onLog?.call(
|
||||
'Pairing agent requested passkey confirmation; answering yes',
|
||||
);
|
||||
writeCmd('yes');
|
||||
}
|
||||
|
||||
if (!successHandled &&
|
||||
(lower.contains('pairing successful') ||
|
||||
lower.contains('already paired'))) {
|
||||
successHandled = true;
|
||||
onLog?.call('Pairing reported success');
|
||||
pairSucceeded = true;
|
||||
if (!pairingDone.isCompleted) pairingDone.complete();
|
||||
}
|
||||
|
||||
if (!failureHandled &&
|
||||
(lower.contains('failed to pair') ||
|
||||
lower.contains('authenticationfailed') ||
|
||||
lower.contains('authentication failed'))) {
|
||||
failureHandled = true;
|
||||
onLog?.call('Pairing reported authentication failure');
|
||||
pairFailed = true;
|
||||
if (!pairingDone.isCompleted) pairingDone.complete();
|
||||
}
|
||||
}
|
||||
|
||||
final stdoutSub = process.stdout
|
||||
.transform(utf8.decoder)
|
||||
.listen(handleChunk);
|
||||
final stderrSub = process.stderr
|
||||
.transform(utf8.decoder)
|
||||
.listen(handleChunk);
|
||||
|
||||
writeCmd('power on');
|
||||
writeCmd('agent KeyboardDisplay');
|
||||
writeCmd('default-agent');
|
||||
onLog?.call('Waiting for pairing challenge from bluetoothctl agent');
|
||||
writeCmd('pair $remoteId');
|
||||
|
||||
// Wait for the Completer to fire (success/failure/process exit) or timeout.
|
||||
await pairingDone.future.timeout(timeout, onTimeout: () {});
|
||||
|
||||
if (!pairFailed && pairSucceeded) {
|
||||
onLog?.call('Pair succeeded; trusting and connecting device');
|
||||
writeCmd('trust $remoteId');
|
||||
writeCmd('connect $remoteId');
|
||||
}
|
||||
writeCmd('quit');
|
||||
sessionClosed = true;
|
||||
|
||||
try {
|
||||
await process.exitCode.timeout(_pairingCleanupTimeout);
|
||||
} catch (_) {
|
||||
process.kill();
|
||||
}
|
||||
await stdoutSub.cancel();
|
||||
await stderrSub.cancel();
|
||||
|
||||
if (pairFailed) {
|
||||
return _PairingResult(
|
||||
pairFailed: true,
|
||||
pinSent: pinSent,
|
||||
userCancelled: userCancelledPinEntry,
|
||||
);
|
||||
}
|
||||
|
||||
final allOutput = output.toString().toLowerCase();
|
||||
final reportedSuccess =
|
||||
pairSucceeded ||
|
||||
allOutput.contains('pairing successful') ||
|
||||
allOutput.contains('already paired');
|
||||
if (reportedSuccess) {
|
||||
final trusted = await trustDevice(remoteId, onLog: onLog);
|
||||
if (!trusted) {
|
||||
onLog?.call('Pairing completed but BlueZ trust was not restored');
|
||||
}
|
||||
return _PairingResult(success: trusted, pinSent: pinSent);
|
||||
}
|
||||
|
||||
return _PairingResult(pinSent: pinSent);
|
||||
}
|
||||
|
||||
Future<void> _removeDevice(
|
||||
String remoteId, {
|
||||
void Function(String message)? onLog,
|
||||
}) async {
|
||||
Process process;
|
||||
try {
|
||||
process = await _processStart('bluetoothctl', <String>[]);
|
||||
} on ProcessException catch (error) {
|
||||
onLog?.call(
|
||||
'bluetoothctl unavailable, skipping remove for $remoteId: $error',
|
||||
);
|
||||
return;
|
||||
}
|
||||
process.stdin.writeln('remove $remoteId');
|
||||
process.stdin.writeln('quit');
|
||||
try {
|
||||
await process.exitCode.timeout(_processExitTimeout);
|
||||
} catch (_) {
|
||||
process.kill();
|
||||
}
|
||||
onLog?.call('Issued bluetoothctl remove for $remoteId');
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of a single bluetoothctl pairing attempt.
|
||||
class _PairingResult {
|
||||
final bool success;
|
||||
final bool pairFailed;
|
||||
final bool pinSent;
|
||||
final bool userCancelled;
|
||||
|
||||
const _PairingResult({
|
||||
this.success = false,
|
||||
this.pairFailed = false,
|
||||
this.pinSent = false,
|
||||
this.userCancelled = false,
|
||||
});
|
||||
}
|
||||
28
lib/services/linux_ble_pairing_service_stub.dart
Normal file
28
lib/services/linux_ble_pairing_service_stub.dart
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/// No-op stub for web builds where dart:io is unavailable.
|
||||
///
|
||||
/// The real implementation lives in linux_ble_pairing_service.dart and is
|
||||
/// selected via conditional import in meshcore_connector.dart.
|
||||
class LinuxBlePairingService {
|
||||
LinuxBlePairingService();
|
||||
|
||||
Future<bool> isBluetoothctlAvailable() async => false;
|
||||
|
||||
Future<void> disconnectDevice(
|
||||
String remoteId, {
|
||||
void Function(String message)? onLog,
|
||||
}) async {}
|
||||
|
||||
Future<bool> isPairedAndTrusted(String remoteId) async => false;
|
||||
|
||||
Future<bool> trustDevice(
|
||||
String remoteId, {
|
||||
void Function(String message)? onLog,
|
||||
}) async => false;
|
||||
|
||||
Future<bool> pairAndTrust({
|
||||
required String remoteId,
|
||||
Duration timeout = const Duration(seconds: 45),
|
||||
void Function(String message)? onLog,
|
||||
Future<String?> Function()? onRequestPin,
|
||||
}) async => false;
|
||||
}
|
||||
|
|
@ -68,6 +68,7 @@ dependencies:
|
|||
material_symbols_icons: ^4.2906.0
|
||||
web: ^1.1.1
|
||||
flutter_svg: ^2.0.10+1
|
||||
flutter_blue_plus_platform_interface: ^8.2.1
|
||||
ml_algo: ^16.0.0
|
||||
ml_dataframe: ^1.0.0
|
||||
|
||||
|
|
|
|||
150
test/services/linux_ble_error_classifier_test.dart
Normal file
150
test/services/linux_ble_error_classifier_test.dart
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_open/services/linux_ble_error_classifier.dart';
|
||||
|
||||
void main() {
|
||||
group('isLinuxBleConnectFailureText', () {
|
||||
test('matches flutter_blue_plus connect timeout error', () {
|
||||
expect(
|
||||
isLinuxBleConnectFailureText(
|
||||
'FlutterBluePlusException | connect | fbp-code: 1 | Timed out after 15s',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('matches hard-timeout marker', () {
|
||||
expect(
|
||||
isLinuxBleConnectFailureText(
|
||||
'TimeoutException: Linux connect hard-timeout after 8s',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('matches BlueZ local abort failure', () {
|
||||
expect(
|
||||
isLinuxBleConnectFailureText(
|
||||
'org.bluez.Error.Failed: le-connection-abort-by-local',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('matches BlueZ in-progress failure', () {
|
||||
expect(
|
||||
isLinuxBleConnectFailureText(
|
||||
'org.bluez.Error.InProgress: Operation already in progress',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('matches flutter_blue_plus null-detail connect failure', () {
|
||||
expect(
|
||||
isLinuxBleConnectFailureText(
|
||||
'FlutterBluePlusException | connect | linux-code: null | null',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('matches tagged connect-stage failure marker', () {
|
||||
expect(
|
||||
isLinuxBleConnectFailureText(
|
||||
'StateError: Linux connect stage failure: Bad state: No element',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not match connect-shaped pairing auth failure', () {
|
||||
expect(
|
||||
isLinuxBleConnectFailureText(
|
||||
'FlutterBluePlusException | connect | AuthenticationFailed',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not match explicit pair auth failure', () {
|
||||
expect(
|
||||
isLinuxBleConnectFailureText(
|
||||
'FlutterBluePlusException | pair | AuthenticationFailed',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('isLikelyLinuxBlePairingTimeoutText', () {
|
||||
test('matches pair timeout text', () {
|
||||
expect(
|
||||
isLikelyLinuxBlePairingTimeoutText('Timed out waiting for pair'),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('matches bond timeout text', () {
|
||||
expect(
|
||||
isLikelyLinuxBlePairingTimeoutText('Operation timed out during bond'),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not match generic timeout text', () {
|
||||
expect(
|
||||
isLikelyLinuxBlePairingTimeoutText('Timed out after 15s'),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('isLinuxBlePairingFailureText', () {
|
||||
test('matches connect-shaped authentication failure', () {
|
||||
expect(
|
||||
isLinuxBlePairingFailureText(
|
||||
'FlutterBluePlusException | connect | AuthenticationFailed',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('matches app pairing incomplete failure', () {
|
||||
expect(
|
||||
isLinuxBlePairingFailureText(
|
||||
'StateError: Linux BLE pairing did not complete',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not match generic bad state error', () {
|
||||
expect(isLinuxBlePairingFailureText('Bad state: No element'), isFalse);
|
||||
});
|
||||
|
||||
test('matches pair-context bad state error', () {
|
||||
expect(
|
||||
isLinuxBlePairingFailureText(
|
||||
'Pair request failed: Bad state: No element',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('matches app trust repair incomplete failure', () {
|
||||
expect(
|
||||
isLinuxBlePairingFailureText(
|
||||
'StateError: Linux BLE trust repair did not complete',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('matches pairing timeout text', () {
|
||||
expect(
|
||||
isLinuxBlePairingFailureText('Timed out waiting for pair'),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
418
test/services/linux_ble_pairing_service_test.dart
Normal file
418
test/services/linux_ble_pairing_service_test.dart
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_open/services/linux_ble_pairing_service.dart';
|
||||
|
||||
class _FakeProcess implements Process {
|
||||
_FakeProcess({this.stdoutText = '', this.autoFinish = true}) {
|
||||
_stdin = IOSink(_stdinController.sink);
|
||||
_stdinController.stream.listen((chunk) {
|
||||
_stdinBuffer.write(utf8.decode(chunk));
|
||||
});
|
||||
|
||||
// Use Timer.run (event-loop tick) instead of microtask so that broadcast
|
||||
// listeners in _runPairingAttempt are attached before the event fires.
|
||||
Timer.run(() {
|
||||
if (_closed) {
|
||||
return;
|
||||
}
|
||||
if (stdoutText.isNotEmpty) {
|
||||
_stdoutController.add(utf8.encode(stdoutText));
|
||||
}
|
||||
});
|
||||
|
||||
if (autoFinish) {
|
||||
// Scheduled after the Timer.run above (FIFO order), so stdout is
|
||||
// emitted before the process exits.
|
||||
Timer(Duration.zero, () async {
|
||||
await _finish(exitStatus);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
final String stdoutText;
|
||||
final bool autoFinish;
|
||||
final int exitStatus = 0;
|
||||
final StreamController<List<int>> _stdinController =
|
||||
StreamController<List<int>>();
|
||||
final StreamController<List<int>> _stdoutController =
|
||||
StreamController<List<int>>.broadcast();
|
||||
final StreamController<List<int>> _stderrController =
|
||||
StreamController<List<int>>.broadcast();
|
||||
final Completer<int> _exitCodeCompleter = Completer<int>();
|
||||
final StringBuffer _stdinBuffer = StringBuffer();
|
||||
late final IOSink _stdin;
|
||||
bool _closed = false;
|
||||
|
||||
String get stdinText => _stdinBuffer.toString();
|
||||
|
||||
void emitStdout(String text) {
|
||||
if (!_closed) {
|
||||
_stdoutController.add(utf8.encode(text));
|
||||
}
|
||||
}
|
||||
|
||||
void finishProcess([int code = 0]) {
|
||||
unawaited(_finish(code));
|
||||
}
|
||||
|
||||
Future<void> _finish(int code) async {
|
||||
if (_closed) {
|
||||
return;
|
||||
}
|
||||
_closed = true;
|
||||
await _stdin.close();
|
||||
await _stdoutController.close();
|
||||
await _stderrController.close();
|
||||
if (!_exitCodeCompleter.isCompleted) {
|
||||
_exitCodeCompleter.complete(code);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> get exitCode => _exitCodeCompleter.future;
|
||||
|
||||
@override
|
||||
bool kill([ProcessSignal signal = ProcessSignal.sigterm]) {
|
||||
unawaited(_finish(exitStatus));
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
int get pid => 1;
|
||||
|
||||
@override
|
||||
IOSink get stdin => _stdin;
|
||||
|
||||
@override
|
||||
Stream<List<int>> get stderr => _stderrController.stream;
|
||||
|
||||
@override
|
||||
Stream<List<int>> get stdout => _stdoutController.stream;
|
||||
}
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'disconnectDevice skips gracefully when bluetoothctl is unavailable',
|
||||
() async {
|
||||
final logs = <String>[];
|
||||
final service = LinuxBlePairingService(
|
||||
processStart: (executable, arguments) async {
|
||||
throw const ProcessException(
|
||||
'bluetoothctl',
|
||||
<String>[],
|
||||
'not found',
|
||||
2,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await service.disconnectDevice('AA:BB:CC:DD:EE:FF', onLog: logs.add);
|
||||
|
||||
expect(
|
||||
logs.any((line) => line.contains('bluetoothctl unavailable')),
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'isPairedAndTrusted returns false when bluetoothctl is unavailable',
|
||||
() async {
|
||||
final service = LinuxBlePairingService(
|
||||
processRun: (executable, arguments) async {
|
||||
throw const ProcessException(
|
||||
'bluetoothctl',
|
||||
<String>[],
|
||||
'not found',
|
||||
2,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final trusted = await service.isPairedAndTrusted('AA:BB:CC:DD:EE:FF');
|
||||
expect(trusted, isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('isBluetoothctlAvailable returns false when unavailable', () async {
|
||||
final service = LinuxBlePairingService(
|
||||
processRun: (executable, arguments) async {
|
||||
throw const ProcessException(
|
||||
'bluetoothctl',
|
||||
<String>[],
|
||||
'not found',
|
||||
2,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final available = await service.isBluetoothctlAvailable();
|
||||
expect(available, isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'isBluetoothctlAvailable returns true when version command succeeds',
|
||||
() async {
|
||||
final service = LinuxBlePairingService(
|
||||
processRun: (executable, arguments) async {
|
||||
return ProcessResult(1234, 0, '5.72', '');
|
||||
},
|
||||
);
|
||||
|
||||
final available = await service.isBluetoothctlAvailable();
|
||||
expect(available, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'isPairedAndTrusted returns true when paired and trusted are yes',
|
||||
() async {
|
||||
final service = LinuxBlePairingService(
|
||||
processRun: (executable, arguments) async {
|
||||
return ProcessResult(1234, 0, '''
|
||||
Device AA:BB:CC:DD:EE:FF
|
||||
Paired: yes
|
||||
Trusted: yes
|
||||
''', '');
|
||||
},
|
||||
);
|
||||
|
||||
final trusted = await service.isPairedAndTrusted('AA:BB:CC:DD:EE:FF');
|
||||
expect(trusted, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test('pairAndTrust returns false when bluetoothctl is unavailable', () async {
|
||||
final service = LinuxBlePairingService(
|
||||
processStart: (executable, arguments) async {
|
||||
throw const ProcessException(
|
||||
'bluetoothctl',
|
||||
<String>[],
|
||||
'not found',
|
||||
2,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final paired = await service.pairAndTrust(remoteId: 'AA:BB:CC:DD:EE:FF');
|
||||
expect(paired, isFalse);
|
||||
});
|
||||
|
||||
test('trustDevice verifies trust after trust command succeeds', () async {
|
||||
final logs = <String>[];
|
||||
final service = LinuxBlePairingService(
|
||||
processRun: (executable, arguments) async {
|
||||
switch (arguments.first) {
|
||||
case 'trust':
|
||||
return ProcessResult(1234, 0, 'trust succeeded', '');
|
||||
case 'info':
|
||||
return ProcessResult(1234, 0, '''
|
||||
Device AA:BB:CC:DD:EE:FF
|
||||
Paired: yes
|
||||
Trusted: yes
|
||||
''', '');
|
||||
}
|
||||
fail('Unexpected bluetoothctl arguments: $arguments');
|
||||
},
|
||||
);
|
||||
|
||||
final trusted = await service.trustDevice(
|
||||
'AA:BB:CC:DD:EE:FF',
|
||||
onLog: logs.add,
|
||||
);
|
||||
|
||||
expect(trusted, isTrue);
|
||||
expect(logs.any((line) => line.contains('Verified BlueZ trust')), isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'trustDevice returns false when trust verification stays untrusted',
|
||||
() async {
|
||||
final logs = <String>[];
|
||||
final service = LinuxBlePairingService(
|
||||
processRun: (executable, arguments) async {
|
||||
switch (arguments.first) {
|
||||
case 'trust':
|
||||
return ProcessResult(1234, 0, 'trust succeeded', '');
|
||||
case 'info':
|
||||
return ProcessResult(1234, 0, '''
|
||||
Device AA:BB:CC:DD:EE:FF
|
||||
Paired: yes
|
||||
Trusted: no
|
||||
''', '');
|
||||
}
|
||||
fail('Unexpected bluetoothctl arguments: $arguments');
|
||||
},
|
||||
);
|
||||
|
||||
final trusted = await service.trustDevice(
|
||||
'AA:BB:CC:DD:EE:FF',
|
||||
onLog: logs.add,
|
||||
);
|
||||
|
||||
expect(trusted, isFalse);
|
||||
expect(
|
||||
logs.any((line) => line.contains('trust verification failed')),
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'pairAndTrust fails when pairing reports success but trust is not restored',
|
||||
() async {
|
||||
final logs = <String>[];
|
||||
final service = LinuxBlePairingService(
|
||||
processStart: (executable, arguments) async =>
|
||||
_FakeProcess(stdoutText: 'Pairing successful\n'),
|
||||
processRun: (executable, arguments) async {
|
||||
switch (arguments.first) {
|
||||
case 'trust':
|
||||
return ProcessResult(1234, 0, 'trust succeeded', '');
|
||||
case 'info':
|
||||
return ProcessResult(1234, 0, '''
|
||||
Device AA:BB:CC:DD:EE:FF
|
||||
Paired: yes
|
||||
Trusted: no
|
||||
''', '');
|
||||
}
|
||||
fail('Unexpected bluetoothctl arguments: $arguments');
|
||||
},
|
||||
);
|
||||
|
||||
final paired = await service.pairAndTrust(
|
||||
remoteId: 'AA:BB:CC:DD:EE:FF',
|
||||
onLog: logs.add,
|
||||
);
|
||||
|
||||
expect(paired, isFalse);
|
||||
expect(
|
||||
logs.any((line) => line.contains('trust was not restored')),
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'pairAndTrust succeeds without requesting proactive PIN after success',
|
||||
() async {
|
||||
final logs = <String>[];
|
||||
var pinRequests = 0;
|
||||
final service = LinuxBlePairingService(
|
||||
processStart: (executable, arguments) async =>
|
||||
_FakeProcess(stdoutText: 'Pairing successful\n'),
|
||||
processRun: (executable, arguments) async {
|
||||
switch (arguments.first) {
|
||||
case 'trust':
|
||||
return ProcessResult(1234, 0, 'trust succeeded', '');
|
||||
case 'info':
|
||||
return ProcessResult(1234, 0, '''
|
||||
Device AA:BB:CC:DD:EE:FF
|
||||
Paired: yes
|
||||
Trusted: yes
|
||||
''', '');
|
||||
}
|
||||
fail('Unexpected bluetoothctl arguments: $arguments');
|
||||
},
|
||||
);
|
||||
|
||||
final paired = await service.pairAndTrust(
|
||||
remoteId: 'AA:BB:CC:DD:EE:FF',
|
||||
onLog: logs.add,
|
||||
onRequestPin: () async {
|
||||
pinRequests++;
|
||||
return '123456';
|
||||
},
|
||||
);
|
||||
|
||||
expect(paired, isTrue);
|
||||
expect(pinRequests, 0);
|
||||
expect(
|
||||
logs.any((line) => line.contains('did not complete before timeout')),
|
||||
isFalse,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'pairAndTrust sends empty line when blank PIN is submitted (not cancel)',
|
||||
() async {
|
||||
final logs = <String>[];
|
||||
late final _FakeProcess fakeProc;
|
||||
final service = LinuxBlePairingService(
|
||||
processStart: (executable, arguments) async {
|
||||
fakeProc = _FakeProcess(stdoutText: '', autoFinish: false);
|
||||
// Emit PIN prompt after an event-loop tick (not microtask) so
|
||||
// broadcast listeners are attached first.
|
||||
Timer.run(() {
|
||||
fakeProc.emitStdout('Enter PIN code:\n');
|
||||
Future<void>.delayed(const Duration(milliseconds: 100), () {
|
||||
fakeProc.emitStdout('Pairing successful\n');
|
||||
Future<void>.delayed(const Duration(milliseconds: 50), () {
|
||||
fakeProc.finishProcess();
|
||||
});
|
||||
});
|
||||
});
|
||||
return fakeProc;
|
||||
},
|
||||
processRun: (executable, arguments) async {
|
||||
switch (arguments.first) {
|
||||
case 'trust':
|
||||
return ProcessResult(1234, 0, 'trust succeeded', '');
|
||||
case 'info':
|
||||
return ProcessResult(1234, 0, '''
|
||||
Device AA:BB:CC:DD:EE:FF
|
||||
Paired: yes
|
||||
Trusted: yes
|
||||
''', '');
|
||||
}
|
||||
fail('Unexpected bluetoothctl arguments: $arguments');
|
||||
},
|
||||
);
|
||||
|
||||
final paired = await service.pairAndTrust(
|
||||
remoteId: 'AA:BB:CC:DD:EE:FF',
|
||||
timeout: const Duration(seconds: 5),
|
||||
onLog: logs.add,
|
||||
onRequestPin: () async => '',
|
||||
);
|
||||
|
||||
expect(paired, isTrue);
|
||||
expect(logs.any((line) => line.contains('Blank PIN submitted')), isTrue);
|
||||
expect(logs.any((line) => line.contains('cancelling pairing')), isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('pairAndTrust cancels pairing when PIN dialog returns null', () async {
|
||||
final logs = <String>[];
|
||||
final service = LinuxBlePairingService(
|
||||
processStart: (executable, arguments) async {
|
||||
final proc = _FakeProcess(stdoutText: '', autoFinish: false);
|
||||
Timer.run(() {
|
||||
proc.emitStdout('Enter PIN code:\n');
|
||||
// Process will be killed/quit by the pairing service after cancel
|
||||
Future<void>.delayed(const Duration(milliseconds: 200), () {
|
||||
proc.finishProcess();
|
||||
});
|
||||
});
|
||||
return proc;
|
||||
},
|
||||
processRun: (executable, arguments) async {
|
||||
return ProcessResult(1234, 0, '', '');
|
||||
},
|
||||
);
|
||||
|
||||
final paired = await service.pairAndTrust(
|
||||
remoteId: 'AA:BB:CC:DD:EE:FF',
|
||||
timeout: const Duration(seconds: 3),
|
||||
onLog: logs.add,
|
||||
onRequestPin: () async => null,
|
||||
);
|
||||
|
||||
expect(paired, isFalse);
|
||||
expect(logs.any((line) => line.contains('cancelled by user')), isTrue);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue