diff --git a/CHANGELOG.md b/CHANGELOG.md index f86b5e6..c353652 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Unreleased +* added audio response capability support for OpenEarable V2 devices. +* added audio response upload progress reporting. * BREAKING CHANGE: `BleGattManager.subscribe` now returns `Future>>`, so callers must `await` subscription setup before listening to BLE notifications. * BREAKING CHANGE: `SensorHandler.subscribeToSensorData` now returns `Future>>`, so callers must `await` sensor notification readiness before listening to sensor data. * fixed BLE notification setup races by ensuring subscription futures complete only after the underlying GATT notification subscription is enabled. diff --git a/example/pubspec.lock b/example/pubspec.lock index eb7392d..2ff45dc 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -414,7 +414,15 @@ packages: path: ".." relative: true source: path - version: "2.3.9" + version: "2.3.10" + open_earable_protocols: + dependency: transitive + description: + name: open_earable_protocols + sha256: c11cae4914827c1d7617d44647a5c49304f0c2a2c02c907c595682cfade6ddb2 + url: "https://pub.dev" + source: hosted + version: "0.0.2" package_config: dependency: transitive description: diff --git a/lib/open_earable_flutter.dart b/lib/open_earable_flutter.dart index 80e0e7f..4f76631 100644 --- a/lib/open_earable_flutter.dart +++ b/lib/open_earable_flutter.dart @@ -24,6 +24,9 @@ import 'src/models/devices/discovered_device.dart'; import 'src/models/devices/open_ring_factory.dart'; import 'src/models/devices/wearable.dart'; +export 'package:open_earable_protocols/open_earable_protocols.dart' + show AudioResponseConfig, AudioResponseResult; + export 'src/models/devices/discovered_device.dart'; export 'src/models/devices/wearable.dart'; export 'src/models/devices/cosinuss_one.dart'; @@ -71,6 +74,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/capabilities/audio_response_manager.dart'; export 'src/fota/fota.dart'; diff --git a/lib/src/managers/ble_gatt_manager.dart b/lib/src/managers/ble_gatt_manager.dart index e93ef47..7a4245c 100644 --- a/lib/src/managers/ble_gatt_manager.dart +++ b/lib/src/managers/ble_gatt_manager.dart @@ -16,11 +16,15 @@ abstract class BleGattManager { }); /// Writes byte data to a specific characteristic of a device. + /// + /// Set [withoutResponse] when the characteristic only supports writes without + /// response or when an acknowledged write is not required. Future write({ required String deviceId, required String serviceId, required String characteristicId, required List byteData, + bool withoutResponse = false, }); /// Subscribes to a specific characteristic of the connected device. diff --git a/lib/src/managers/ble_manager.dart b/lib/src/managers/ble_manager.dart index e5cd772..709d01c 100644 --- a/lib/src/managers/ble_manager.dart +++ b/lib/src/managers/ble_manager.dart @@ -328,6 +328,7 @@ class BleManager extends BleGattManager { required String serviceId, required String characteristicId, required List byteData, + bool withoutResponse = false, }) async { if (!isConnected(deviceId)) { throw Exception("Write failed because no Earable is connected"); @@ -337,6 +338,7 @@ class BleManager extends BleGattManager { serviceId, characteristicId, Uint8List.fromList(byteData), + withoutResponse: withoutResponse, ); } diff --git a/lib/src/models/capabilities/audio_response_manager.dart b/lib/src/models/capabilities/audio_response_manager.dart new file mode 100644 index 0000000..a4e45a6 --- /dev/null +++ b/lib/src/models/capabilities/audio_response_manager.dart @@ -0,0 +1,85 @@ +import 'package:open_earable_protocols/open_earable_protocols.dart'; + +/// Receives progress updates for an audio buffer upload. +typedef AudioResponseUploadProgressCallback = void Function( + AudioResponseUploadProgress progress, +); + +/// Current phase of an audio buffer upload. +enum AudioResponseUploadPhase { + /// The transfer is being initialized on the device. + starting, + + /// Audio samples are being transferred and acknowledged by the device. + uploading, + + /// All samples are acknowledged and the transfer is being committed. + committing, + + /// The device successfully committed the transfer. + completed, +} + +/// Immutable progress reported while uploading an audio buffer. +class AudioResponseUploadProgress { + /// Creates an audio buffer upload progress update. + const AudioResponseUploadProgress({ + required this.phase, + required this.acknowledgedSamples, + required this.totalSamples, + }); + + /// Current transfer phase. + final AudioResponseUploadPhase phase; + + /// Number of samples acknowledged by the device. + final int acknowledgedSamples; + + /// Total number of samples in the transfer. + final int totalSamples; + + /// Fraction of samples acknowledged by the device, between zero and one. + double get fraction => + totalSamples == 0 ? 0 : acknowledgedSamples / totalSamples; +} + +/// An interface for managing audio response measurements. +abstract class AudioResponseManager { + /// Uploads and commits signed 16-bit PCM [samples] for later measurements. + /// + /// [maximumSamplesPerChunk] must fit the effective GATT write payload. + /// [onProgress] receives synchronous updates based on sample offsets + /// acknowledged by the device. Exceptions thrown by the callback terminate + /// the upload. + Future uploadAudioBuffer({ + required int transferId, + required List samples, + required int samplingRate, + int maximumSamplesPerChunk = 118, + AudioResponseUploadProgressCallback? onProgress, + }); + + /// Starts a measurement and returns its protocol result. + /// + /// The [config] must reference a buffer previously committed by + /// [uploadAudioBuffer]. + Future measureAudioResponse(AudioResponseConfig config); +} + +/// Error reported by the device while transferring an audio response buffer. +class AudioResponseTransferException implements Exception { + /// Creates an audio response transfer error from a protocol status value. + const AudioResponseTransferException({ + required this.status, + required this.message, + }); + + /// Numeric status reported by the audio response protocol. + final int status; + + /// Human-readable description of [status]. + final String message; + + @override + String toString() => 'AudioResponseTransferException($status): $message'; +} diff --git a/lib/src/models/devices/open_earable_factory.dart b/lib/src/models/devices/open_earable_factory.dart index fd8db73..a5ca10f 100644 --- a/lib/src/models/devices/open_earable_factory.dart +++ b/lib/src/models/devices/open_earable_factory.dart @@ -5,6 +5,7 @@ import 'package:open_earable_flutter/src/managers/sensor_handler.dart'; import 'package:open_earable_flutter/src/models/wearable_factory.dart'; import 'package:open_earable_flutter/src/utils/sensor_scheme_parser/sensor_scheme_reader.dart'; 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 '../../../open_earable_flutter.dart' show logger; @@ -12,6 +13,7 @@ import '../../constants.dart'; import '../../managers/v2_sensor_handler.dart'; import '../../utils/sensor_value_parser/v2_sensor_value_parser.dart'; import '../capabilities/audio_mode_manager.dart'; +import '../capabilities/audio_response_manager.dart'; import '../capabilities/fota_capability.dart'; import '../capabilities/fota_slot_info_capability.dart'; import '../capabilities/power_saving_mode_manager.dart'; @@ -25,6 +27,7 @@ import '../capabilities/time_synchronizable.dart'; import 'discovered_device.dart'; import 'open_earable_v1.dart'; import 'open_earable_v2.dart'; +import 'open_earable_v2_audio_response_manager.dart'; import 'wearable.dart'; import '../../fota/firmware_slot_manager_impl.dart'; @@ -116,6 +119,17 @@ class OpenEarableFactory extends WearableFactory { ), ); } + if (await bleManager!.hasService( + deviceId: device.id, + serviceId: AudioResponseBleUuids.serviceUuid, + )) { + wearable.registerCapability( + OpenEarableV2AudioResponseManager( + bleManager: bleManager!, + deviceId: device.id, + ), + ); + } if (await bleManager!.hasService( deviceId: device.id, serviceId: mcuMgrSmpServiceUuid, diff --git a/lib/src/models/devices/open_earable_v2_audio_response_manager.dart b/lib/src/models/devices/open_earable_v2_audio_response_manager.dart new file mode 100644 index 0000000..29f61cd --- /dev/null +++ b/lib/src/models/devices/open_earable_v2_audio_response_manager.dart @@ -0,0 +1,419 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:open_earable_protocols/open_earable_protocols.dart'; + +import '../../managers/ble_gatt_manager.dart'; +import '../capabilities/audio_response_manager.dart'; + +/// Audio response protocol implementation for OpenEarable V2 devices. +class OpenEarableV2AudioResponseManager implements AudioResponseManager { + /// Creates an audio response manager backed by [bleManager]. + OpenEarableV2AudioResponseManager({ + required this.bleManager, + required this.deviceId, + this.notificationTimeout = const Duration(seconds: 10), + }); + + /// BLE manager used to communicate with the device. + final BleGattManager bleManager; + + /// Identifier of the OpenEarable device. + final String deviceId; + + /// Maximum time to wait for each protocol notification. + final Duration notificationTimeout; + + bool _operationInProgress = false; + + @override + Future uploadAudioBuffer({ + required int transferId, + required List samples, + required int samplingRate, + int maximumSamplesPerChunk = 118, + AudioResponseUploadProgressCallback? onProgress, + }) { + return _runExclusive(() async { + _validateTransferParameters( + transferId: transferId, + samples: samples, + samplingRate: samplingRate, + maximumSamplesPerChunk: maximumSamplesPerChunk, + ); + + final statusIterator = StreamIterator>( + await bleManager.subscribe( + deviceId: deviceId, + serviceId: AudioResponseBleUuids.serviceUuid, + characteristicId: + AudioResponseBleUuids.transferStatusCharacteristicUuid, + ), + ); + var committed = false; + var lastAcknowledgedSamples = 0; + + void reportProgress( + AudioResponseUploadPhase phase, + int acknowledgedSamples, + ) { + onProgress?.call( + AudioResponseUploadProgress( + phase: phase, + acknowledgedSamples: acknowledgedSamples, + totalSamples: samples.length, + ), + ); + } + + void reportAcknowledgedProgress(AudioResponseTransferStatus status) { + if (status.next_sample_offset <= lastAcknowledgedSamples) { + return; + } + lastAcknowledgedSamples = status.next_sample_offset; + reportProgress( + AudioResponseUploadPhase.uploading, + lastAcknowledgedSamples, + ); + } + + try { + reportProgress(AudioResponseUploadPhase.starting, 0); + var statusFuture = _nextTransferStatus(statusIterator, transferId); + await _write( + AudioResponseBleUuids.transferControlCharacteristicUuid, + AudioResponseTransferControl.start( + AudioResponseTransferStart( + transfer_id: transferId, + total_samples: samples.length, + sampling_rate: samplingRate, + checksum: _crc32IsoHdlc(samples), + ), + ).toBytes(), + ); + + var status = await statusFuture; + _requireStatus(status, expectedStatus: 0); + if (status.next_sample_offset != 0) { + throw StateError( + 'New audio response transfer started at unexpected sample offset ' + '${status.next_sample_offset}', + ); + } + reportProgress(AudioResponseUploadPhase.uploading, 0); + + var sentOffset = status.next_sample_offset; + while (sentOffset < samples.length) { + if (status.credits == 0) { + status = await _nextTransferStatus(statusIterator, transferId); + _requireStatus(status, expectedStatus: 0); + if (status.next_sample_offset > sentOffset) { + throw StateError( + 'Device acknowledged unsent sample offset ' + '${status.next_sample_offset}', + ); + } + reportAcknowledgedProgress(status); + continue; + } + + statusFuture = _nextTransferStatus(statusIterator, transferId); + for (var credit = 0; + credit < status.credits && sentOffset < samples.length; + credit++) { + final end = (sentOffset + maximumSamplesPerChunk).clamp( + 0, + samples.length, + ); + final chunkSamples = samples.sublist(sentOffset, end); + await _write( + AudioResponseBleUuids.transferDataCharacteristicUuid, + AudioResponseTransferChunk( + transfer_id: transferId, + sample_offset: sentOffset, + sample_count: chunkSamples.length, + samples: chunkSamples, + ).toBytes(), + withoutResponse: true, + ); + sentOffset = end; + } + + status = await statusFuture; + while (true) { + _requireStatus(status, expectedStatus: 0); + if (status.next_sample_offset > sentOffset) { + throw StateError( + 'Device acknowledged unsent sample offset ' + '${status.next_sample_offset}', + ); + } + reportAcknowledgedProgress(status); + if (status.next_sample_offset == sentOffset) { + break; + } + status = await _nextTransferStatus(statusIterator, transferId); + } + } + + reportProgress( + AudioResponseUploadPhase.committing, + lastAcknowledgedSamples, + ); + statusFuture = _nextTransferStatus(statusIterator, transferId); + await _write( + AudioResponseBleUuids.transferControlCharacteristicUuid, + AudioResponseTransferControl.commit( + AudioResponseTransferCommit(transfer_id: transferId), + ).toBytes(), + ); + _requireStatus(await statusFuture, expectedStatus: 1); + committed = true; + reportProgress( + AudioResponseUploadPhase.completed, + lastAcknowledgedSamples, + ); + } finally { + if (!committed) { + await _abortTransfer(transferId); + } + await statusIterator.cancel(); + } + }); + } + + @override + Future measureAudioResponse(AudioResponseConfig config) { + return _runExclusive(() async { + _validateConfig(config); + + final resultIterator = StreamIterator>( + await bleManager.subscribe( + deviceId: deviceId, + serviceId: AudioResponseBleUuids.serviceUuid, + characteristicId: AudioResponseBleUuids.resultCharacteristicUuid, + ), + ); + + try { + final resultFuture = _nextResult(resultIterator, config.id); + await _write( + AudioResponseBleUuids.configCharacteristicUuid, + config.toBytes(), + ); + final result = await resultFuture; + _validateResult(config, result); + return result; + } finally { + await resultIterator.cancel(); + } + }); + } + + Future _runExclusive(Future Function() operation) async { + if (_operationInProgress) { + throw StateError('An audio response operation is already in progress'); + } + + _operationInProgress = true; + try { + return await operation(); + } finally { + _operationInProgress = false; + } + } + + Future _write( + String characteristicId, + Uint8List bytes, { + bool withoutResponse = false, + }) { + return bleManager.write( + deviceId: deviceId, + serviceId: AudioResponseBleUuids.serviceUuid, + characteristicId: characteristicId, + byteData: bytes, + withoutResponse: withoutResponse, + ); + } + + Future _nextTransferStatus( + StreamIterator> iterator, + int transferId, + ) async { + while (await iterator.moveNext().timeout(notificationTimeout)) { + final status = AudioResponseTransferStatus.fromBytes( + Uint8List.fromList(iterator.current), + ); + if (status.transfer_id == transferId) { + return status; + } + } + throw StateError('Audio response transfer status stream closed'); + } + + Future _nextResult( + StreamIterator> iterator, + int measurementId, + ) async { + while (await iterator.moveNext().timeout(notificationTimeout)) { + final result = AudioResponseResult.fromBytes( + Uint8List.fromList(iterator.current), + ); + if (result.id == measurementId) { + return result; + } + } + throw StateError('Audio response result stream closed'); + } + + void _requireStatus( + AudioResponseTransferStatus status, { + required int expectedStatus, + }) { + if (status.status == expectedStatus) { + return; + } + throw AudioResponseTransferException( + status: status.status, + message: _transferStatusMessages[status.status] ?? 'Unknown status', + ); + } + + Future _abortTransfer(int transferId) async { + try { + await _write( + AudioResponseBleUuids.transferControlCharacteristicUuid, + AudioResponseTransferControl.abort( + AudioResponseTransferAbort(transfer_id: transferId), + ).toBytes(), + ); + } on Object { + // Preserve the original transfer error if best-effort cleanup fails. + } + } + + void _validateTransferParameters({ + required int transferId, + required List samples, + required int samplingRate, + required int maximumSamplesPerChunk, + }) { + if (transferId < 0 || transferId > 0xffff) { + throw RangeError.range(transferId, 0, 0xffff, 'transferId'); + } + if (samples.isEmpty) { + throw ArgumentError.value(samples, 'samples', 'must not be empty'); + } + if (samples.length > 0xffffffff) { + throw RangeError('samples length must fit an unsigned 32-bit integer'); + } + if (samples.any((sample) => sample < -0x8000 || sample > 0x7fff)) { + throw RangeError('Every sample must fit a signed 16-bit integer'); + } + if (samplingRate <= 0 || samplingRate > 0xffffffff) { + throw RangeError.range(samplingRate, 1, 0xffffffff, 'samplingRate'); + } + if (maximumSamplesPerChunk <= 0 || maximumSamplesPerChunk > 0xffff) { + throw RangeError.range( + maximumSamplesPerChunk, + 1, + 0xffff, + 'maximumSamplesPerChunk', + ); + } + } + + void _validateConfig(AudioResponseConfig config) { + if (config.id < 0 || config.id > 0xff) { + throw RangeError.range(config.id, 0, 0xff, 'config.id'); + } + if (config.transfer_id < 0 || config.transfer_id > 0xffff) { + throw RangeError.range( + config.transfer_id, + 0, + 0xffff, + 'config.transfer_id', + ); + } + if (!config.volume.isFinite) { + throw ArgumentError.value( + config.volume, + 'config.volume', + 'must be finite', + ); + } + if (config.points < 0 || config.points > 0xff) { + throw RangeError.range(config.points, 0, 0xff, 'config.points'); + } + if (config.frequencies.length != config.points) { + throw ArgumentError.value( + config.frequencies, + 'config.frequencies', + 'length must match config.points', + ); + } + if (config.frequencies + .any((frequency) => frequency < 0 || frequency > 0xffff)) { + throw RangeError( + 'Every config frequency must fit an unsigned 16-bit integer', + ); + } + } + + void _validateResult( + AudioResponseConfig config, + AudioResponseResult result, + ) { + if (config.points == 0) { + return; + } + if (result.points != config.points || + !_listEquals(result.frequencies, config.frequencies)) { + throw StateError( + 'Audio response result points do not match the requested frequencies', + ); + } + } +} + +bool _listEquals(List left, List right) { + if (left.length != right.length) { + return false; + } + for (var i = 0; i < left.length; i++) { + if (left[i] != right[i]) { + return false; + } + } + return true; +} + +const Map _transferStatusMessages = { + 0: 'Ready to receive chunks', + 1: 'Transfer committed', + 2: 'Transfer aborted', + 3: 'Invalid command or transfer state', + 4: 'Invalid chunk offset or length', + 5: 'Insufficient storage', + 6: 'Checksum mismatch', + 7: 'Transfer timed out', +}; + +int _crc32IsoHdlc(List samples) { + var crc = 0xffffffff; + for (final sample in samples) { + final value = sample & 0xffff; + crc = _updateCrc32(crc, value & 0xff); + crc = _updateCrc32(crc, value >> 8); + } + return (crc ^ 0xffffffff) & 0xffffffff; +} + +int _updateCrc32(int crc, int byte) { + var updated = crc ^ byte; + for (var bit = 0; bit < 8; bit++) { + updated = (updated & 1) != 0 ? (updated >> 1) ^ 0xedb88320 : updated >> 1; + } + return updated; +} diff --git a/pubspec.yaml b/pubspec.yaml index 8993d03..841004c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,7 @@ dependencies: bloc: ^9.1.0 meta: ^1.16.0 pub_semver: ^2.2.0 + open_earable_protocols: ^0.0.2 dev_dependencies: flutter_test: diff --git a/test/open_earable_v2_audio_response_manager_test.dart b/test/open_earable_v2_audio_response_manager_test.dart new file mode 100644 index 0000000..3adefe1 --- /dev/null +++ b/test/open_earable_v2_audio_response_manager_test.dart @@ -0,0 +1,503 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_earable_flutter/src/managers/ble_gatt_manager.dart'; +import 'package:open_earable_flutter/src/models/capabilities/audio_response_manager.dart'; +import 'package:open_earable_flutter/src/models/devices/open_earable_v2_audio_response_manager.dart'; +import 'package:open_earable_protocols/open_earable_protocols.dart'; + +void main() { + group('OpenEarableV2AudioResponseManager', () { + test('uploads chunks according to credits and commits the transfer', + () async { + final bleManager = _FakeBleGattManager(); + final manager = OpenEarableV2AudioResponseManager( + bleManager: bleManager, + deviceId: 'device', + ); + const samples = [0, 1000, -1000, 32767, -32768]; + var uploadedSamples = 0; + final progress = []; + + bleManager.onWrite = (write) { + if (write.characteristicId == + AudioResponseBleUuids.transferControlCharacteristicUuid) { + final control = AudioResponseTransferControl.fromBytes(write.bytes); + if (_controlType(control) == 0) { + final start = control.command as AudioResponseTransferStart; + expect(start.transfer_id, 42); + expect(start.total_samples, samples.length); + expect(start.sampling_rate, 16000); + expect(start.checksum, 0x8c06db29); + bleManager.emitStatus( + AudioResponseTransferStatus( + transfer_id: 42, + status: 0, + next_sample_offset: 0, + credits: 2, + ), + ); + } else if (_controlType(control) == 1) { + bleManager.emitStatus( + AudioResponseTransferStatus( + transfer_id: 42, + status: 1, + next_sample_offset: samples.length, + credits: 0, + ), + ); + } + } else if (write.characteristicId == + AudioResponseBleUuids.transferDataCharacteristicUuid) { + final chunk = AudioResponseTransferChunk.fromBytes(write.bytes); + expect(chunk.sample_offset, uploadedSamples); + uploadedSamples += chunk.sample_count; + bleManager.emitStatus( + AudioResponseTransferStatus( + transfer_id: 42, + status: 0, + next_sample_offset: uploadedSamples, + credits: 2, + ), + ); + } + }; + + await manager.uploadAudioBuffer( + transferId: 42, + samples: samples, + samplingRate: 16000, + maximumSamplesPerChunk: 2, + onProgress: progress.add, + ); + + expect( + progress.map((update) => update.phase), + [ + AudioResponseUploadPhase.starting, + AudioResponseUploadPhase.uploading, + AudioResponseUploadPhase.uploading, + AudioResponseUploadPhase.uploading, + AudioResponseUploadPhase.uploading, + AudioResponseUploadPhase.committing, + AudioResponseUploadPhase.completed, + ], + ); + expect( + progress.map((update) => update.acknowledgedSamples), + [0, 0, 2, 4, 5, 5, 5], + ); + expect(progress.last.fraction, 1); + + final controls = bleManager.writes + .where( + (write) => + write.characteristicId == + AudioResponseBleUuids.transferControlCharacteristicUuid, + ) + .map((write) => AudioResponseTransferControl.fromBytes(write.bytes)) + .toList(); + final chunks = bleManager.writes + .where( + (write) => + write.characteristicId == + AudioResponseBleUuids.transferDataCharacteristicUuid, + ) + .map((write) => AudioResponseTransferChunk.fromBytes(write.bytes)) + .toList(); + + expect(controls.map(_controlType), [0, 1]); + expect(chunks.map((chunk) => chunk.samples), [ + [0, 1000], + [-1000, 32767], + [-32768], + ]); + expect( + bleManager.writes + .where( + (write) => + write.characteristicId == + AudioResponseBleUuids.transferDataCharacteristicUuid, + ) + .every((write) => write.withoutResponse), + isTrue, + ); + expect( + bleManager.writes + .where( + (write) => + write.characteristicId != + AudioResponseBleUuids.transferDataCharacteristicUuid, + ) + .every((write) => !write.withoutResponse), + isTrue, + ); + }); + + test('writes config and returns the matching typed result', () async { + final bleManager = _FakeBleGattManager(); + final manager = OpenEarableV2AudioResponseManager( + bleManager: bleManager, + deviceId: 'device', + ); + final config = AudioResponseConfig( + id: 7, + transfer_id: 42, + volume: 0.5, + points: 2, + frequencies: [100, 200], + ); + + bleManager.onWrite = (write) { + if (write.characteristicId == + AudioResponseBleUuids.configCharacteristicUuid) { + final decodedConfig = AudioResponseConfig.fromBytes(write.bytes); + expect(decodedConfig.id, 7); + expect(decodedConfig.transfer_id, 42); + expect(decodedConfig.volume, closeTo(0.5, 0.0001)); + expect(decodedConfig.points, 2); + expect(decodedConfig.frequencies, [100, 200]); + bleManager.emitResult( + AudioResponseResult( + id: 7, + points: 2, + frequencies: [100, 200], + response: [10, 20], + ), + ); + } + }; + + final result = await manager.measureAudioResponse(config); + + expect(result.id, 7); + expect(result.frequencies, [100, 200]); + expect(result.response, [10, 20]); + }); + + test('allows device default measurement points', () async { + final bleManager = _FakeBleGattManager(); + final manager = OpenEarableV2AudioResponseManager( + bleManager: bleManager, + deviceId: 'device', + ); + final config = AudioResponseConfig( + id: 7, + transfer_id: 42, + volume: 0.5, + points: 0, + frequencies: [], + ); + + bleManager.onWrite = (write) { + if (write.characteristicId == + AudioResponseBleUuids.configCharacteristicUuid) { + final decodedConfig = AudioResponseConfig.fromBytes(write.bytes); + expect(decodedConfig.points, 0); + expect(decodedConfig.frequencies, isEmpty); + bleManager.emitResult( + AudioResponseResult( + id: 7, + points: 2, + frequencies: [100, 200], + response: [10, 20], + ), + ); + } + }; + + final result = await manager.measureAudioResponse(config); + + expect(result.points, 2); + expect(result.frequencies, [100, 200]); + }); + + test('rejects invalid point configuration before writing config', () async { + final bleManager = _FakeBleGattManager(); + final manager = OpenEarableV2AudioResponseManager( + bleManager: bleManager, + deviceId: 'device', + ); + + await expectLater( + manager.measureAudioResponse( + AudioResponseConfig( + id: 7, + transfer_id: 42, + volume: 0.5, + points: 2, + frequencies: [100], + ), + ), + throwsArgumentError, + ); + expect(bleManager.writes, isEmpty); + }); + + test('rejects result points that do not match requested points', () async { + final bleManager = _FakeBleGattManager(); + final manager = OpenEarableV2AudioResponseManager( + bleManager: bleManager, + deviceId: 'device', + ); + final config = AudioResponseConfig( + id: 7, + transfer_id: 42, + volume: 0.5, + points: 2, + frequencies: [100, 200], + ); + + bleManager.onWrite = (write) { + if (write.characteristicId == + AudioResponseBleUuids.configCharacteristicUuid) { + bleManager.emitResult( + AudioResponseResult( + id: 7, + points: 2, + frequencies: [100, 300], + response: [10, 20], + ), + ); + } + }; + + await expectLater( + manager.measureAudioResponse(config), + throwsStateError, + ); + }); + + test('uses the default protocol chunk size when not overridden', () async { + final bleManager = _FakeBleGattManager(); + final manager = OpenEarableV2AudioResponseManager( + bleManager: bleManager, + deviceId: 'device', + ); + final samples = List.generate(119, (index) => index); + var uploadedSamples = 0; + + bleManager.onWrite = (write) { + if (write.characteristicId == + AudioResponseBleUuids.transferControlCharacteristicUuid) { + final control = AudioResponseTransferControl.fromBytes(write.bytes); + bleManager.emitStatus( + AudioResponseTransferStatus( + transfer_id: 42, + status: _controlType(control) == 1 ? 1 : 0, + next_sample_offset: uploadedSamples, + credits: 10, + ), + ); + } else if (write.characteristicId == + AudioResponseBleUuids.transferDataCharacteristicUuid) { + final chunk = AudioResponseTransferChunk.fromBytes(write.bytes); + uploadedSamples += chunk.sample_count; + bleManager.emitStatus( + AudioResponseTransferStatus( + transfer_id: 42, + status: 0, + next_sample_offset: uploadedSamples, + credits: 10, + ), + ); + } + }; + + await manager.uploadAudioBuffer( + transferId: 42, + samples: samples, + samplingRate: 16000, + ); + + final chunkSizes = bleManager.writes + .where( + (write) => + write.characteristicId == + AudioResponseBleUuids.transferDataCharacteristicUuid, + ) + .map( + (write) => + AudioResponseTransferChunk.fromBytes(write.bytes).sample_count, + ); + expect(chunkSizes, [118, 1]); + }); + + test('rejects invalid chunk size before writing transfer start', () async { + final bleManager = _FakeBleGattManager(); + final manager = OpenEarableV2AudioResponseManager( + bleManager: bleManager, + deviceId: 'device', + ); + + await expectLater( + manager.uploadAudioBuffer( + transferId: 42, + samples: const [0], + samplingRate: 16000, + maximumSamplesPerChunk: 0, + ), + throwsRangeError, + ); + expect(bleManager.writes, isEmpty); + }); + + test('aborts a transfer after a protocol error', () async { + final bleManager = _FakeBleGattManager(); + final manager = OpenEarableV2AudioResponseManager( + bleManager: bleManager, + deviceId: 'device', + ); + + bleManager.onWrite = (write) { + if (write.characteristicId != + AudioResponseBleUuids.transferControlCharacteristicUuid) { + return; + } + final control = AudioResponseTransferControl.fromBytes(write.bytes); + if (_controlType(control) == 0) { + bleManager.emitStatus( + AudioResponseTransferStatus( + transfer_id: 42, + status: 5, + next_sample_offset: 0, + credits: 0, + ), + ); + } + }; + + await expectLater( + manager.uploadAudioBuffer( + transferId: 42, + samples: const [0], + samplingRate: 16000, + ), + throwsA( + isA().having( + (error) => error.status, + 'status', + 5, + ), + ), + ); + + final controls = bleManager.writes + .where( + (write) => + write.characteristicId == + AudioResponseBleUuids.transferControlCharacteristicUuid, + ) + .map((write) => AudioResponseTransferControl.fromBytes(write.bytes)); + expect(controls.map(_controlType), [0, 2]); + }); + }); +} + +int _controlType(AudioResponseTransferControl control) { + final command = control.command; + if (command is AudioResponseTransferStart) { + return 0; + } + if (command is AudioResponseTransferCommit) { + return 1; + } + if (command is AudioResponseTransferAbort) { + return 2; + } + throw StateError( + 'Unsupported transfer control command: ${command.runtimeType}', + ); +} + +class _FakeBleGattManager implements BleGattManager { + final _streams = >>{}; + final writes = <_Write>[]; + + void Function(_Write write)? onWrite; + + void emitStatus(AudioResponseTransferStatus status) { + _controller(AudioResponseBleUuids.transferStatusCharacteristicUuid) + .add(status.toBytes()); + } + + void emitResult(AudioResponseResult result) { + _controller( + AudioResponseBleUuids.resultCharacteristicUuid, + ).add(result.toBytes()); + } + + @override + Future write({ + required String deviceId, + required String serviceId, + required String characteristicId, + required List byteData, + bool withoutResponse = false, + }) async { + final write = _Write( + characteristicId, + Uint8List.fromList(byteData), + withoutResponse, + ); + writes.add(write); + onWrite?.call(write); + } + + @override + Future>> subscribe({ + required String deviceId, + required String serviceId, + required String characteristicId, + }) async { + return _controller(characteristicId).stream; + } + + StreamController> _controller(String characteristicId) { + return _streams.putIfAbsent( + characteristicId, + () => StreamController>.broadcast(sync: true), + ); + } + + @override + Future disconnect(String deviceId) async {} + + @override + Future hasCharacteristic({ + required String deviceId, + required String serviceId, + required String characteristicId, + }) async { + return true; + } + + @override + Future hasService({ + required String deviceId, + required String serviceId, + }) async { + return true; + } + + @override + bool isConnected(String deviceId) => true; + + @override + Future> read({ + required String deviceId, + required String serviceId, + required String characteristicId, + }) async { + return []; + } +} + +class _Write { + const _Write(this.characteristicId, this.bytes, this.withoutResponse); + + final String characteristicId; + final Uint8List bytes; + final bool withoutResponse; +}