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
16 changes: 15 additions & 1 deletion doc/AI_BLE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,24 @@ cancellation.
## Gone-Device Error Handling

`UniversalBleTransport._handleGattError()` catches `UniversalBleException` with gone-device codes:
`characteristicNotFound`, `deviceNotFound`, `serviceNotFound`, `connectionTerminated`, `deviceDisconnected`, `unknownError`.
`deviceNotFound`, `connectionTerminated`, `deviceDisconnected`, `unknownError`.

On hit: emits `disconnected`, drains the queue with typed `deviceDisconnected`, and throws `DeviceNotConnectedException`.

`characteristicNotFound` and `serviceNotFound` are ambiguous and are handled separately. A live peripheral
returns them when the attribute simply is not in its GATT database, and a dead link returns them from a stale
cache. Treating them as gone-device broke the Solo Barista (LSJ-001), which the matcher routes to `EurekaScale`
but which has no 0x180F battery service: the optional battery read at the end of `onConnect()` failed with
`characteristicNotFound`, the transport emitted `disconnected`, and the scale dropped one tick after connecting
(log signature: `GATT read(...2a19...) failed - device gone`, then `scale connection update: disconnected`).
These two codes now log, throw the domain `GattAttributeUnavailableException`, and hand off to
`_probeAndDeclareIfDead()`, which asks the OS for the real link state and only then declares the link dead.
`GattAttributeUnavailableException` extends `DeviceNotConnectedException`, so the lowest-level scale write
helpers that already catch `DeviceNotConnectedException` keep swallowing it: for a write, a stale-GATT
`characteristicNotFound` may still mean a dead link, and the asynchronous probe cannot retroactively change
the exception the caller already received.
Device implementations should still gate optional reads on `discoverServices()` rather than relying on the probe.

The `isBenignFrameworkError()` filter in `crashlytics_error_filter.dart` suppresses these from `FlutterError.onError` — but scale-level catches at the write helper are defense-in-depth.

## Faulted Queue Recovery
Expand Down
4 changes: 3 additions & 1 deletion lib/src/models/device/impl/eureka/eureka_scale.dart
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ class EurekaScale implements Scale {
);
}
await _registerNotifications();
_readBattery();
if (batteryService.matchesAny(services)) {
_readBattery();
}
_connectionStateController.add(ConnectionState.connected);
} catch (e) {
_log.warning('Connect failed: $e');
Expand Down
15 changes: 15 additions & 0 deletions lib/src/models/errors.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,21 @@ class DeviceNotConnectedException implements Exception {
'DeviceNotConnectedException: ${kind.name} not connected';
}

class GattAttributeUnavailableException extends DeviceNotConnectedException {
final String operation;
final String path;

const GattAttributeUnavailableException({
required this.operation,
required this.path,
}) : super(DeviceKind.unknown);

@override
String toString() =>
'GattAttributeUnavailableException: $operation($path) not in the '
'GATT database';
}

class DeviceIdentityMismatchException implements Exception {
final String expected;
final int actualModelValue;
Expand Down
20 changes: 18 additions & 2 deletions lib/src/services/ble/universal_ble_transport.dart
Original file line number Diff line number Diff line change
Expand Up @@ -256,18 +256,34 @@ class UniversalBleTransport extends BLETransport {
}

static const _goneDeviceCodes = {
UniversalBleErrorCode.characteristicNotFound,
UniversalBleErrorCode.deviceNotFound,
UniversalBleErrorCode.serviceNotFound,
UniversalBleErrorCode.connectionTerminated,
UniversalBleErrorCode.deviceDisconnected,
};

static const _attributeMissingCodes = {
UniversalBleErrorCode.characteristicNotFound,
UniversalBleErrorCode.serviceNotFound,
};

Never _handleGattError(
UniversalBleException e,
String operation,
String path,
) {
if (_attributeMissingCodes.contains(e.code)) {
_log.warning(
'GATT $operation($path) failed — attribute not in GATT database: '
'${e.code}',
);
unawaited(
_probeAndDeclareIfDead(
'GATT $operation($path) attribute missing',
_connectionGeneration,
),
);
throw GattAttributeUnavailableException(operation: operation, path: path);
}
if (_goneDeviceCodes.contains(e.code)) {
_log.warning('GATT $operation($path) failed — device gone: ${e.code}');
_connectionStateSubject.add(device.ConnectionState.disconnected);
Expand Down
77 changes: 76 additions & 1 deletion test/services/ble/universal_ble_transport_mtu_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import 'dart:typed_data';

import 'package:flutter_test/flutter_test.dart';
import 'package:reaprime/src/models/device/device.dart' as device;
import 'package:reaprime/src/models/errors.dart';
import 'package:reaprime/src/services/ble/universal_ble_transport.dart';
import 'package:universal_ble/universal_ble.dart';

class _MtuRecordingBlePlatform extends UniversalBlePlatform {
final List<(String, int)> mtuRequests = [];

bool throwOnRequestMtu = false;
UniversalBleErrorCode? readErrorCode;
bool systemConnected = true;
bool attached = false;
int connectCalls = 0;
Expand Down Expand Up @@ -92,7 +94,16 @@ class _MtuRecordingBlePlatform extends UniversalBlePlatform {
String service,
String characteristic, {
Duration? timeout,
}) async => Uint8List(0);
}) async {
final code = readErrorCode;
if (code != null) {
throw UniversalBleException(
code: code,
message: 'simulated read failure',
);
}
return Uint8List(0);
}

@override
Future<void> writeValue(
Expand Down Expand Up @@ -162,6 +173,70 @@ void main() {
requestLargeMtuNonAndroid: flag,
);

for (final code in [
UniversalBleErrorCode.characteristicNotFound,
UniversalBleErrorCode.serviceNotFound,
]) {
test('$code on a live link does not disconnect', () async {
final value = transport(android: false, linux: false);
final states = <device.ConnectionState>[];
final subscription = value.connectionState.listen(states.add);
await value.connect();
platform.readErrorCode = code;

await expectLater(
value.read(
'0000180f-0000-1000-8000-00805f9b34fb',
'00002a19-0000-1000-8000-00805f9b34fb',
),
throwsA(isA<GattAttributeUnavailableException>()),
);
await Future<void>.delayed(const Duration(milliseconds: 50));

expect(states, isNot(contains(device.ConnectionState.disconnected)));
await subscription.cancel();
await value.dispose();
});

test('$code after the link died reports disconnected', () async {
final value = transport(android: false, linux: false);
final states = <device.ConnectionState>[];
final subscription = value.connectionState.listen(states.add);
await value.connect();
platform.readErrorCode = code;
platform.systemConnected = false;

await expectLater(
value.read(
'0000180f-0000-1000-8000-00805f9b34fb',
'00002a19-0000-1000-8000-00805f9b34fb',
),
throwsA(isA<GattAttributeUnavailableException>()),
);
await Future<void>.delayed(const Duration(milliseconds: 50));

expect(states, contains(device.ConnectionState.disconnected));
await subscription.cancel();
await value.dispose();
});
}

test('attribute-missing errors are catchable as not-connected', () async {
final value = transport(android: false, linux: false);
await value.connect();
platform.readErrorCode = UniversalBleErrorCode.characteristicNotFound;

await expectLater(
value.read(
'0000180f-0000-1000-8000-00805f9b34fb',
'00002a19-0000-1000-8000-00805f9b34fb',
),
throwsA(isA<DeviceNotConnectedException>()),
);
await Future<void>.delayed(const Duration(milliseconds: 50));
await value.dispose();
});

test('Android requests 517 once with the flag off or on', () async {
for (final flag in [false, true]) {
final value = transport(android: true, linux: false, flag: flag);
Expand Down
132 changes: 132 additions & 0 deletions test/unit/models/eureka_scale_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import 'dart:async';
import 'dart:typed_data';

import 'package:flutter_test/flutter_test.dart';
import 'package:reaprime/src/models/device/device.dart';
import 'package:reaprime/src/models/device/impl/eureka/eureka_scale.dart';
import 'package:reaprime/src/models/device/transport/ble_transport.dart';
import 'package:rxdart/rxdart.dart';

class _MockEurekaBleTransport extends BLETransport {
_MockEurekaBleTransport({required this.serviceUUIDs});

final List<String> serviceUUIDs;
final BehaviorSubject<ConnectionState> _connectionState =
BehaviorSubject.seeded(ConnectionState.discovered);
final List<(String, String)> reads = [];
final List<(String, String)> subscriptions = [];

@override
String get id => 'AA:BB:CC:DD:EE:FF';

@override
String get name => 'Solo Barista';

@override
Stream<ConnectionState> get connectionState => _connectionState.stream;

@override
Future<ConnectionState> getConnectionState() async => _connectionState.value;

@override
Future<void> connect() async {
_connectionState.add(ConnectionState.connected);
}

@override
Future<void> disconnect() async {
_connectionState.add(ConnectionState.disconnected);
}

@override
Future<List<String>> discoverServices() async => serviceUUIDs;

@override
Future<Uint8List> read(
String serviceUUID,
String characteristicUUID, {
Duration? timeout,
}) async {
reads.add((serviceUUID, characteristicUUID));
return Uint8List.fromList([50]);
}

@override
Future<void> subscribe(
String serviceUUID,
String characteristicUUID,
void Function(Uint8List) callback,
) async {
subscriptions.add((serviceUUID, characteristicUUID));
}

@override
Future<void> write(
String serviceUUID,
String characteristicUUID,
Uint8List data, {
bool withResponse = true,
Duration? timeout,
}) async {}

@override
Future<void> setTransportPriority(bool prioritized) async {}

@override
Future<void> dispose() async {
await _connectionState.close();
}
}

void main() {
test(
'scale without a battery service connects and skips the battery read',
() async {
final transport = _MockEurekaBleTransport(
serviceUUIDs: [EurekaScale.serviceIdentifier.long],
);
final scale = EurekaScale(transport: transport);
final states = <ConnectionState>[];
final subscription = scale.connectionState.listen(states.add);

await scale.onConnect();
await Future<void>.delayed(const Duration(milliseconds: 20));

expect(
transport.subscriptions,
contains((
EurekaScale.serviceIdentifier.long,
EurekaScale.dataCharacteristic.long,
)),
);
expect(transport.reads, isEmpty);
expect(states.last, ConnectionState.connected);

await subscription.cancel();
await transport.dispose();
},
);

test('scale with a battery service still reads the battery level', () async {
final transport = _MockEurekaBleTransport(
serviceUUIDs: [
EurekaScale.serviceIdentifier.long,
EurekaScale.batteryService.long,
],
);
final scale = EurekaScale(transport: transport);

await scale.onConnect();
await Future<void>.delayed(const Duration(milliseconds: 20));

expect(
transport.reads,
contains((
EurekaScale.batteryService.long,
EurekaScale.batteryCharacteristic.long,
)),
);

await transport.dispose();
});
}
Loading