diff --git a/README.md b/README.md index 40cdc7be..4f214f50 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ To get started with the OpenEarable Flutter package, follow these steps: For most devices, the sensors have to be configured before they start sending data. You can learn more about configuring sensors in the chapter [Configuring Sensors](https://github.com/OpenEarable/open_earable_flutter/blob/main/doc/SENSOR_CONFIG.md). + > [!WARNING] > Checking for capabilities using `is ` is deprecated. Please use `hasCapability()` instead. You can learn more about capabilities in the [Capabilities](https://github.com/OpenEarable/open_earable_flutter/blob/main/doc/CAPABILITIES.md) documentation. diff --git a/lib/open_earable_flutter.dart b/lib/open_earable_flutter.dart index c3262467..63cbf323 100644 --- a/lib/open_earable_flutter.dart +++ b/lib/open_earable_flutter.dart @@ -70,7 +70,7 @@ export 'src/models/wearable_factory.dart'; export 'src/models/capabilities/system_device.dart'; export 'src/managers/ble_gatt_manager.dart'; export 'src/models/capabilities/time_synchronizable.dart'; - +export 'src/models/error/sensor_error.dart'; export 'src/fota/fota.dart'; @Deprecated( diff --git a/lib/src/constants.dart b/lib/src/constants.dart index e20e21a4..f0f6fb5a 100644 --- a/lib/src/constants.dart +++ b/lib/src/constants.dart @@ -40,3 +40,9 @@ const String buttonStateCharacteristicUuid = const String ledServiceUuid = "81040a2e-4819-11ee-be56-0242ac120002"; const String ledSetStateCharacteristic = "81040e7a-4819-11ee-be56-0242ac120002"; +const String deviceErrorServiceUuid = "5f9c0001-6f4a-4c6b-9f0d-4f2f3b0a0001"; +const String deviceErrorCharacteristicUuid = + "5f9c0002-6f4a-4c6b-9f0d-4f2f3b0a0001"; + +@Deprecated('Use deviceErrorCharacteristicUuid instead.') +const String sensorErrorCharacteristicUuid = deviceErrorCharacteristicUuid; diff --git a/lib/src/managers/ble_manager.dart b/lib/src/managers/ble_manager.dart index de50c86b..ce128d76 100644 --- a/lib/src/managers/ble_manager.dart +++ b/lib/src/managers/ble_manager.dart @@ -10,8 +10,8 @@ import '../../open_earable_flutter.dart'; /// A class that establishes and manages Bluetooth Low Energy (BLE) /// communication with OpenEarable devices. class BleManager extends BleGattManager { - static const int _desiredMtu = 60; - int _mtu = _desiredMtu; // Largest Byte package sent is 42 bytes for IMU + static const int _desiredMtu = 100; + int _mtu = _desiredMtu; int get mtu => _mtu; final Map>> _streamControllers = {}; diff --git a/lib/src/models/devices/open_earable_v2.dart b/lib/src/models/devices/open_earable_v2.dart index e2b617d6..3684d2c1 100644 --- a/lib/src/models/devices/open_earable_v2.dart +++ b/lib/src/models/devices/open_earable_v2.dart @@ -4,7 +4,7 @@ import 'dart:typed_data'; import 'package:open_earable_flutter/src/constants.dart'; import 'package:open_earable_flutter/src/models/devices/bluetooth_wearable.dart'; import 'package:pub_semver/pub_semver.dart'; - +import 'package:open_earable_flutter/src/models/error/sensor_error.dart'; import '../../../open_earable_flutter.dart' hide Version; import '../../managers/v2_sensor_handler.dart'; import '../capabilities/device_firmware_version.dart'; @@ -90,6 +90,39 @@ class OpenEarableV2 extends BluetoothWearable @override bool get isConnectedViaSystem => _isConnectedViaSystem; + final _errorController = StreamController.broadcast(); + StreamSubscription>? _deviceErrorSubscription; + + @override + Stream get onError => _errorController.stream; + + void _subscribeToDeviceErrorNotifications() { + final previousSubscription = _deviceErrorSubscription; + if (previousSubscription != null) { + unawaited(previousSubscription.cancel()); + } + _deviceErrorSubscription = bleManager + .subscribe( + deviceId: deviceId, + serviceId: deviceErrorServiceUuid, + characteristicId: deviceErrorCharacteristicUuid, + ) + .listen( + (data) { + try { + final error = SensorError.fromBytes(Uint8List.fromList(data)); + logger.i('Received device error: $error'); + _errorController.add(error); + } catch (e) { + logger.e('Failed to parse device error: $e'); + } + }, + onError: (error) { + logger.e('Error in device error notification stream: $error'); + }, + ); + } + @override Stream> get sensorConfigurationStream { @@ -279,7 +312,9 @@ class OpenEarableV2 extends BluetoothWearable bool isConnectedViaSystem = false, }) : _sensors = sensors, _sensorConfigurations = sensorConfigurations, - _isConnectedViaSystem = isConnectedViaSystem; + _isConnectedViaSystem = isConnectedViaSystem { + _subscribeToDeviceErrorNotifications(); + } @override String get deviceId => discoveredDevice.id; @@ -404,8 +439,10 @@ class OpenEarableV2 extends BluetoothWearable // MARK: SensorManager / SensorConfigurationManager @override - Future disconnect() { - return bleManager.disconnect(discoveredDevice.id); + Future disconnect() async { + await _deviceErrorSubscription?.cancel(); + _deviceErrorSubscription = null; + await bleManager.disconnect(discoveredDevice.id); } @override diff --git a/lib/src/models/devices/wearable.dart b/lib/src/models/devices/wearable.dart index fdc2ac99..79d35f75 100644 --- a/lib/src/models/devices/wearable.dart +++ b/lib/src/models/devices/wearable.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:ui'; import '../../managers/wearable_disconnect_notifier.dart'; - +import 'package:open_earable_flutter/src/models/error/sensor_error.dart'; enum WearableIconVariant { single, left, @@ -44,7 +44,7 @@ abstract class Wearable { } return null; } - +Stream get onError => const Stream.empty(); /// Gets a specific capability of the wearable, throwing a StateError if not supported. T requireCapability() { final capability = getCapability(); diff --git a/lib/src/models/error/sensor_error.dart b/lib/src/models/error/sensor_error.dart new file mode 100644 index 00000000..55a21716 --- /dev/null +++ b/lib/src/models/error/sensor_error.dart @@ -0,0 +1,158 @@ +import 'dart:typed_data'; + +enum DeviceErrorLevel { + info(0, 'Info'), + warning(1, 'Warning'), + error(2, 'Error'), + fatal(3, 'Fatal'); + + final int value; + final String label; + + const DeviceErrorLevel(this.value, this.label); + + static DeviceErrorLevel fromByte(int value) { + return DeviceErrorLevel.values.firstWhere( + (level) => level.value == value, + orElse: () => DeviceErrorLevel.error, + ); + } +} + +class SensorError { + static const int payloadLength = 57; + static const int payloadVersion = 1; + + final DeviceErrorLevel level; + final int errorCode; + final int sensorId; + final int timestamp; + final String message; + + SensorError({ + this.level = DeviceErrorLevel.error, + required this.errorCode, + required this.sensorId, + required this.timestamp, + required this.message, + }); + + factory SensorError.fromBytes(Uint8List bytes) { + if (bytes.length < payloadLength) { + throw Exception('Invalid error data length: ${bytes.length}'); + } + + final byteData = ByteData.sublistView(bytes); + final version = byteData.getUint8(0); + if (version != payloadVersion) { + throw Exception('Unsupported error payload version: $version'); + } + + final messageBytes = bytes.sublist(9, payloadLength); + final nullIndex = messageBytes.indexOf(0); + final trimmedMessageBytes = nullIndex >= 0 + ? messageBytes.sublist(0, nullIndex) + : messageBytes; + + return SensorError( + level: DeviceErrorLevel.fromByte(byteData.getUint8(1)), + errorCode: byteData.getUint16(2, Endian.little), + sensorId: byteData.getUint8(4), + timestamp: byteData.getUint32(5, Endian.little), + message: String.fromCharCodes(trimmedMessageBytes).trim(), + ); + } + + String get errorDescription { + switch (errorCode) { + case 0x0001: + return 'SD card removed'; + case 0x0002: + return 'SD card not mounted'; + case 0x0003: + return 'SD card mount failed'; + case 0x0004: + return 'SD card file open failed'; + case 0x0005: + return 'SD card write failed'; + case 0x0006: + return 'SD card header write failed'; + case 0x0007: + return 'SD card flush failed'; + case 0x0008: + return 'SD card buffer full'; + case 0x0009: + return 'SD card close failed'; + case 0x0101: + return 'Sensor queue full'; + case 0x0102: + return 'Sensor initialization failed'; + case 0x0103: + return 'Sensor read failed'; + case 0x0104: + return 'Invalid sensor sample rate'; + case 0x0105: + return 'Sensor not found'; + case 0x0106: + return 'Sensor configuration queue full'; + case 0x0201: + return 'BLE notification failed'; + case 0x0301: + return 'Firmware fatal error'; + case 0x0302: + return 'Firmware log error'; + case 0x0303: + return 'Firmware log warning'; + case 0x0304: + return 'Firmware log info'; + case 0x0305: + return 'Firmware log debug'; + default: + return 'Unknown code 0x${errorCode.toRadixString(16).padLeft(4, '0')}'; + } + } + + bool get isFirmwareLog => + errorCode == 0x0302 || + errorCode == 0x0303 || + errorCode == 0x0304 || + errorCode == 0x0305; + + String get sensorName { + switch (sensorId) { + case 0x00: + return 'IMU'; + case 0x01: + return 'Temp/Barometer'; + case 0x02: + return 'Microphone'; + case 0x04: + return 'PPG'; + case 0x05: + return 'Pulse oximeter'; + case 0x06: + return 'Optical temperature'; + case 0x07: + return 'Bone conduction'; + case 0xFF: + return 'System'; + default: + return 'Source 0x${sensorId.toRadixString(16).padLeft(2, '0')}'; + } + } + + String get formattedMessage { + if (isFirmwareLog) { + final detail = message.isEmpty ? errorDescription : message; + return '${level.label}: $detail'; + } + + final detail = message.isEmpty + ? errorDescription + : '$errorDescription: $message'; + return '${level.label}: [$sensorName] $detail'; + } + + @override + String toString() => formattedMessage; +}