Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* **Breaking:** Add `QueueType.auto` which auto-selects the best queueing strategy per platform: Android uses a per-device queue, all other platforms run commands in parallel. It is now the default for both `UniversalBle` and `UniversalBlePeripheral`, replacing the previous `QueueType.global` default.
* iOS/macOS: Handle write-without-response transmit buffer backpressure
* iOS/macOS: complete concurrent reads, descriptor operations, notification changes, and RSSI reads one callback at a time.
* Windows: retry transiently unreachable GATT service discovery during connection.

## 2.3.0
* Windows: support connectionless manufacturer-data advertising without a GATT service, including state/error reporting and cleanup on stop/disposal.
Expand Down
88 changes: 88 additions & 0 deletions hil/integration_test/fault_injection_hil_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,67 @@ void main() {
_pending(
'[FIT-CONN-014] does not emit a stale connection failure after a newer connection succeeds',
);
testWidgets(
'[FIT-CONN-015] survives repeated immediate connections after discovery',
(_) async {
const cycles = int.fromEnvironment(
'HIL_COLD_CONNECT_CYCLES',
defaultValue: 20,
);

for (var cycle = 0; cycle < cycles; cycle++) {
try {
await UniversalBle.disconnect(
peripheral.deviceId,
timeout: HilPeripheral.operationTimeout,
);
final discovered = await _discoverHilPeripheral();

// Connect immediately after discovery. Do not use
// HilPeripheral.reconnect(), because its retry loop would hide a
// failed first connection attempt.
await UniversalBle.connect(
discovered.deviceId,
timeout: HilPeripheral.operationTimeout,
);

final services = await UniversalBle.discoverServices(
discovered.deviceId,
withDescriptors: false,
timeout: HilPeripheral.operationTimeout,
);
expect(
services.map((service) => service.uuid),
anyElement(_isHilService),
reason: 'HIL service missing after cold-connect cycle '
'${cycle + 1}/$cycles',
);
expect(
utf8.decode(
await UniversalBle.read(
discovered.deviceId,
HilUuid.service,
HilUuid.read,
timeout: HilPeripheral.operationTimeout,
),
),
'HIL-READ-V1',
reason: 'GATT read failed after cold-connect cycle '
'${cycle + 1}/$cycles',
);
} catch (error, stackTrace) {
Error.throwWithStackTrace(
StateError(
'Cold-connect cycle ${cycle + 1}/$cycles failed: $error',
),
stackTrace,
);
}
}
},
timeout: const Timeout(Duration(minutes: 5)),
skip: defaultTargetPlatform != TargetPlatform.windows,
);
});

group('service discovery and GATT database changes', () {
Expand Down Expand Up @@ -1187,6 +1248,33 @@ Future<void> _waitForDisconnect(HilPeripheral peripheral) async {
.timeout(HilPeripheral.operationTimeout);
}

Future<BleDevice> _discoverHilPeripheral() async {
final result = Completer<BleDevice>();
final subscription = UniversalBle.scanStream.listen((device) {
if (!result.isCompleted &&
(device.name == HilPeripheral.deviceName ||
device.services.any(_isHilService))) {
result.complete(device);
}
});

try {
await UniversalBle.startScan(
scanFilter: ScanFilter(
withServices: const [HilUuid.service],
withNamePrefix: const [HilPeripheral.deviceName],
),
);
return await result.future.timeout(HilPeripheral.scanTimeout);
} finally {
await UniversalBle.stopScan();
await subscription.cancel();
}
}

bool _isHilService(String uuid) =>
BleUuidParser.compareStrings(uuid, HilUuid.service);

Future<bool> _hasService(HilPeripheral peripheral, String serviceUuid) async {
final services = await peripheral.discover(withDescriptors: false);
return services.any(
Expand Down
7 changes: 7 additions & 0 deletions lib/src/utils/universal_ble_error_parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ class UniversalBleErrorParser {

static UniversalBleErrorCode? _parseStringErrorCode(String code) {
switch (code.toLowerCase()) {
case 'failed to get device':
case 'failed to get services: unreachable':
return UniversalBleErrorCode.connectionFailed;
case 'failed to get services: accessdenied':
return UniversalBleErrorCode.bluetoothUnauthorized;
case 'failed to get services: protocolerror':
return UniversalBleErrorCode.failed;
case 'notsupported':
case 'not_supported':
return UniversalBleErrorCode.notSupported;
Expand Down
53 changes: 53 additions & 0 deletions test/universal_ble_error_parser_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:universal_ble/src/universal_ble.g.dart';
import 'package:universal_ble/src/utils/universal_ble_error_parser.dart';

void main() {
group(UniversalBleErrorParser, () {
group('getCode', () {
test('classifies a Windows unreachable callback as connection failed',
() {
expect(
UniversalBleErrorParser.getCode(
'Failed to get services: Unreachable',
),
UniversalBleErrorCode.connectionFailed,
);
});

test('classifies a Windows access callback as unauthorized', () {
expect(
UniversalBleErrorParser.getCode(
'Failed to get services: AccessDenied',
),
UniversalBleErrorCode.bluetoothUnauthorized,
);
});

test('classifies a missing Windows device as connection failed', () {
expect(
UniversalBleErrorParser.getCode('Failed to get device'),
UniversalBleErrorCode.connectionFailed,
);
});

test('classifies a Windows protocol callback as failed', () {
expect(
UniversalBleErrorParser.getCode(
'Failed to get services: ProtocolError',
),
UniversalBleErrorCode.failed,
);
});

test('does not classify unrelated messages by substring', () {
expect(
UniversalBleErrorParser.getCode(
'Request failed after the service became unreachable',
),
UniversalBleErrorCode.unknownError,
);
});
});
});
}
20 changes: 19 additions & 1 deletion windows/src/universal_ble_plugin.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1847,7 +1847,25 @@ fire_and_forget UniversalBlePlugin::ConnectAsync(
}
UniversalBleLogger::LogInfo("ConnectionLog: Device found");
auto services_result =
co_await device.GetGattServicesAsync((BluetoothCacheMode::Uncached));
co_await device.GetGattServicesAsync(BluetoothCacheMode::Uncached);
// Creating a BluetoothLEDevice does not itself establish a connection.
// The first uncached service query initiates it, but an immediate
// post-scan query can transiently return Unreachable. Retry only that
// status before reporting the connection attempt as failed.
constexpr int kGattReadinessRetries = 2;
for (int retry = 0;
services_result.Status() == GattCommunicationStatus::Unreachable &&
retry < kGattReadinessRetries;
++retry) {
UniversalBleLogger::LogInfo(
"ConnectionLog: GATT services temporarily unreachable; retrying " +
std::to_string(retry + 1) + "/" +
std::to_string(kGattReadinessRetries));
co_await winrt::resume_after(
std::chrono::milliseconds(250 * (retry + 1)));
services_result =
co_await device.GetGattServicesAsync(BluetoothCacheMode::Uncached);
}
auto services_result_error =
gatt_communication_status_to_error(services_result.Status());
if (services_result_error.has_value()) {
Expand Down
Loading