diff --git a/lib/open_earable_flutter.dart b/lib/open_earable_flutter.dart index 459df4e..aef0914 100644 --- a/lib/open_earable_flutter.dart +++ b/lib/open_earable_flutter.dart @@ -48,6 +48,7 @@ export 'src/models/capabilities/battery_health_status.dart'; export 'src/models/capabilities/battery_energy_status.dart'; export 'src/models/capabilities/rgb_led.dart'; export 'src/models/capabilities/status_led.dart'; +export 'src/models/capabilities/led_state_reader.dart'; export 'src/models/capabilities/sensor.dart'; export 'src/models/capabilities/sensor_specializations/heart_rate_sensor.dart'; export 'src/models/capabilities/sensor_specializations/heart_rate_variability_sensor.dart'; diff --git a/lib/src/models/capabilities/led_state_reader.dart b/lib/src/models/capabilities/led_state_reader.dart new file mode 100644 index 0000000..6ddc77f --- /dev/null +++ b/lib/src/models/capabilities/led_state_reader.dart @@ -0,0 +1,20 @@ +/// Optional readback of the LED override selected on a device. +abstract class LedStateReader { + Future readLedState(); +} + +class LedState { + final bool showStatus; + final int red; + final int green; + final int blue; + + const LedState({ + required this.showStatus, + required this.red, + required this.green, + required this.blue, + }); + + bool get isBlack => red == 0 && green == 0 && blue == 0; +} diff --git a/lib/src/models/devices/open_earable_factory.dart b/lib/src/models/devices/open_earable_factory.dart index bc31ec6..cd2614c 100644 --- a/lib/src/models/devices/open_earable_factory.dart +++ b/lib/src/models/devices/open_earable_factory.dart @@ -7,6 +7,7 @@ import 'package:open_earable_flutter/src/utils/sensor_scheme_parser/sensor_schem import 'package:open_earable_flutter/src/utils/sensor_scheme_parser/v2_sensor_scheme_reader.dart'; import 'package:open_earable_protocols/open_earable_protocols.dart'; import 'package:universal_ble/universal_ble.dart'; +import 'package:pub_semver/pub_semver.dart' as semver; import '../../../open_earable_flutter.dart' show logger; import '../../constants.dart'; @@ -17,6 +18,7 @@ import '../capabilities/audio_response_manager.dart'; import '../capabilities/fota_capability.dart'; import '../capabilities/fota_slot_info_capability.dart'; import '../capabilities/microphone_gain_manager.dart'; +import '../capabilities/led_state_reader.dart'; import '../capabilities/power_saving_mode_manager.dart'; import '../capabilities/sensor.dart'; import '../capabilities/sensor_configuration.dart'; @@ -30,6 +32,7 @@ import 'open_earable_v1.dart'; import 'open_earable_v2.dart'; import 'open_earable_v2_audio_response_manager.dart'; import 'open_earable_v2_microphone_gain_manager.dart'; +import 'open_earable_v2_led_state_reader.dart'; import 'wearable.dart'; import '../../fota/firmware_slot_manager_impl.dart'; @@ -98,9 +101,11 @@ class OpenEarableFactory extends WearableFactory { discoveredDevice: device, ); } else if (_v2Regex.hasMatch(hardwareVersion)) { + final firmwareVersion = + await _readVersion(device, _deviceFirmwareVersionCharacteristicUuid); (List, List) sensorInfo = await _initSensors( device, - await _readVersion(device, _deviceFirmwareVersionCharacteristicUuid), + firmwareVersion, ); final wearable = OpenEarableV2( name: device.name, @@ -120,6 +125,14 @@ class OpenEarableFactory extends WearableFactory { }, isConnectedViaSystem: options.contains(const ConnectedViaSystem()), ); + if (_supportsLedReadback(firmwareVersion)) { + wearable.registerCapability( + OpenEarableV2LedStateReader( + bleManager: bleManager!, + deviceId: device.id, + ), + ); + } if (await bleManager!.hasService( deviceId: device.id, serviceId: timeSynchronizationServiceUuid, @@ -199,6 +212,15 @@ class OpenEarableFactory extends WearableFactory { return String.fromCharCodes(softwareGenerationBytes); } + bool _supportsLedReadback(String firmwareVersion) { + try { + final version = semver.Version.parse(firmwareVersion.trim()); + return version.major == 2 && version.minor >= 3; + } on FormatException { + return false; + } + } + Future _hasPowerSavingService(DiscoveredDevice device) async { return await bleManager!.hasCharacteristic( deviceId: device.id, diff --git a/lib/src/models/devices/open_earable_v2_led_state_reader.dart b/lib/src/models/devices/open_earable_v2_led_state_reader.dart new file mode 100644 index 0000000..f6da837 --- /dev/null +++ b/lib/src/models/devices/open_earable_v2_led_state_reader.dart @@ -0,0 +1,40 @@ +import '../../managers/ble_gatt_manager.dart'; +import '../capabilities/led_state_reader.dart'; + +/// LED readback added in OpenEarable firmware 2.3.0. +class OpenEarableV2LedStateReader implements LedStateReader { + static const serviceUuid = '81040a2e-4819-11ee-be56-0242ac120002'; + static const colorUuid = '81040e7a-4819-11ee-be56-0242ac120002'; + static const modeUuid = '81040e7b-4819-11ee-be56-0242ac120002'; + + final BleGattManager bleManager; + final String deviceId; + + const OpenEarableV2LedStateReader({ + required this.bleManager, + required this.deviceId, + }); + + @override + Future readLedState() async { + final mode = await bleManager.read( + deviceId: deviceId, + serviceId: serviceUuid, + characteristicId: modeUuid, + ); + final color = await bleManager.read( + deviceId: deviceId, + serviceId: serviceUuid, + characteristicId: colorUuid, + ); + if (mode.length != 1 || mode[0] > 1 || color.length != 3) { + throw const FormatException('Invalid LED state'); + } + return LedState( + showStatus: mode[0] == 0, + red: color[0], + green: color[1], + blue: color[2], + ); + } +} diff --git a/test/factory_transport_version_test.dart b/test/factory_transport_version_test.dart index 74c8664..5a41439 100644 --- a/test/factory_transport_version_test.dart +++ b/test/factory_transport_version_test.dart @@ -116,6 +116,10 @@ void main() { ); final wearable = await factory.createFromDevice(device); expect(ble.reads, contains(firmwareUuid)); + expect( + wearable.hasCapability(), + version.startsWith('2.3.'), + ); final sensor = wearable.requireCapability().sensors.single; expect(sensor.axisUnits, ['ADC', 'ADC', 'ADC', 'ADC']); final value = sensor.sensorStream.first; diff --git a/test/led_state_reader_test.dart b/test/led_state_reader_test.dart new file mode 100644 index 0000000..dbd020a --- /dev/null +++ b/test/led_state_reader_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_earable_flutter/src/models/devices/open_earable_v2_led_state_reader.dart'; + +class LedGatt extends Fake implements BleGattManager { + List mode = [1]; + List color = [12, 34, 56]; + + @override + Future> read({ + required String deviceId, + required String serviceId, + required String characteristicId, + }) async { + expect(deviceId, 'ear'); + expect(serviceId, OpenEarableV2LedStateReader.serviceUuid); + if (characteristicId == OpenEarableV2LedStateReader.modeUuid) return mode; + expect(characteristicId, OpenEarableV2LedStateReader.colorUuid); + return color; + } +} + +void main() { + test('reads manual color, disabled output and automatic status', () async { + final ble = LedGatt(); + final reader = + OpenEarableV2LedStateReader(bleManager: ble, deviceId: 'ear'); + var state = await reader.readLedState(); + expect(state.showStatus, isFalse); + expect([state.red, state.green, state.blue], [12, 34, 56]); + expect(state.isBlack, isFalse); + ble.color = [0, 0, 0]; + state = await reader.readLedState(); + expect(state.isBlack, isTrue); + ble.mode = [0]; + expect((await reader.readLedState()).showStatus, isTrue); + }); + + test('rejects malformed state instead of displaying a guessed mode', + () async { + final ble = LedGatt(); + final reader = + OpenEarableV2LedStateReader(bleManager: ble, deviceId: 'ear'); + for (final mode in >[ + [], + [0, 1], + [2], + ]) { + ble.mode = mode; + await expectLater(reader.readLedState(), throwsFormatException); + } + ble.mode = [1]; + ble.color = [1, 2]; + await expectLater(reader.readLedState(), throwsFormatException); + }); +}