diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b33c9f..d9bdf7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## Unreleased + +* added wireless audio policy configuration and runtime-state support for OpenEarable V2 devices exposing the new protocol service. + ## 2.4.1 * fixed microphone gain capability detection so it is only registered when the device exposes the required characteristic. diff --git a/doc/CAPABILITIES.md b/doc/CAPABILITIES.md index 908cafe..2573ae9 100644 --- a/doc/CAPABILITIES.md +++ b/doc/CAPABILITIES.md @@ -143,6 +143,38 @@ if (audioModeManager != null) { --- +### WirelessAudioConfigurationManager + +Configures device-owned Bluetooth audio policies on firmware that exposes the +wireless audio configuration protocol. The capability also provides the +effective LE Audio runtime state negotiated by the standard Bluetooth services. + +```dart +final audioConfiguration = + wearable.getCapability(); +if (audioConfiguration != null) { + final capabilities = await audioConfiguration.getCapabilities(); + final runtimeState = await audioConfiguration.getRuntimeState(); + + await audioConfiguration.setAclConnectionPolicy( + WirelessAudioConfigurationAclConnectionPolicy.fixedAclPolicy( + WirelessAudioConfigurationFixedAclPolicy( + interval_us: 15000, + peripheral_latency: 0, + supervision_timeout_ms: 4000, + ), + ), + ); +} +``` + +Check the device-reported capabilities before applying a policy. Policy +requests remain subject to negotiation by the Bluetooth controller and peer; +use `getRuntimeState()` or `subscribeToRuntimeState()` to observe the effective +values. + +--- + ### PowerSavingModeManager Reads the power saving modes that the firmware currently supports, including diff --git a/example/pubspec.lock b/example/pubspec.lock index 4539e62..eb4c739 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -418,10 +418,11 @@ packages: open_earable_protocols: dependency: transitive description: - name: open_earable_protocols - sha256: c11cae4914827c1d7617d44647a5c49304f0c2a2c02c907c595682cfade6ddb2 - url: "https://pub.dev" - source: hosted + path: "generated/dart" + ref: main + resolved-ref: a4b6c08f5a2b7b59f53b7f02a209299c1f2c1864 + url: "https://github.com/OpenEarable/protocol.git" + source: git version: "0.0.2" package_config: dependency: transitive diff --git a/lib/open_earable_flutter.dart b/lib/open_earable_flutter.dart index 459df4e..e1e9a89 100644 --- a/lib/open_earable_flutter.dart +++ b/lib/open_earable_flutter.dart @@ -76,6 +76,7 @@ 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/models/capabilities/wireless_audio_configuration_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 7a4245c..bf4eeb3 100644 --- a/lib/src/managers/ble_gatt_manager.dart +++ b/lib/src/managers/ble_gatt_manager.dart @@ -30,11 +30,13 @@ abstract class BleGattManager { /// Subscribes to a specific characteristic of the connected device. /// /// The returned future completes only after the underlying GATT - /// notification subscription has been enabled. + /// subscription has been enabled. Set [indications] for an indication-only + /// characteristic; notification mode is used by default. Future>> subscribe({ required String deviceId, required String serviceId, required String characteristicId, + bool indications = false, }); /// Reads data from a specific characteristic of the connected device. diff --git a/lib/src/managers/ble_manager.dart b/lib/src/managers/ble_manager.dart index ca81d66..122b94a 100644 --- a/lib/src/managers/ble_manager.dart +++ b/lib/src/managers/ble_manager.dart @@ -348,6 +348,7 @@ class BleManager extends BleGattManager { required String deviceId, required String serviceId, required String characteristicId, + bool indications = false, }) async { logger.d( "Subscribing to $deviceId, service $serviceId, characteristic $characteristicId", @@ -379,12 +380,17 @@ class BleManager extends BleGattManager { streamController ??= StreamController>.broadcast(); _streamControllers[streamIdentifier] = streamController; - _subscriptionSetups[streamIdentifier] ??= - UniversalBle.subscribeNotifications( - deviceId, - serviceId, - characteristicId, - ); + _subscriptionSetups[streamIdentifier] ??= indications + ? UniversalBle.subscribeIndications( + deviceId, + serviceId, + characteristicId, + ) + : UniversalBle.subscribeNotifications( + deviceId, + serviceId, + characteristicId, + ); try { await _subscriptionSetups[streamIdentifier]; diff --git a/lib/src/models/capabilities/wireless_audio_configuration_manager.dart b/lib/src/models/capabilities/wireless_audio_configuration_manager.dart new file mode 100644 index 0000000..e1f4fab --- /dev/null +++ b/lib/src/models/capabilities/wireless_audio_configuration_manager.dart @@ -0,0 +1,118 @@ +import 'package:open_earable_protocols/open_earable_protocols.dart'; + +export 'package:open_earable_protocols/open_earable_protocols.dart' + show + WirelessAudioConfigurationAclConnectionPolicy, + WirelessAudioConfigurationAclRadioPolicy, + WirelessAudioConfigurationAdaptiveLinearAclPolicy, + WirelessAudioConfigurationAutomaticAclRadioPolicy, + WirelessAudioConfigurationCapabilities, + WirelessAudioConfigurationCommandResult, + WirelessAudioConfigurationConfiguredAclConnectionPolicy, + WirelessAudioConfigurationConfiguredAclRadioPolicy, + WirelessAudioConfigurationConfiguredUnicastServerQosPreferences, + WirelessAudioConfigurationControllerDefaultAclPolicy, + WirelessAudioConfigurationFixedAclPolicy, + WirelessAudioConfigurationPreferredAclRadioPolicy, + WirelessAudioConfigurationPreferredRangeAclPolicy, + WirelessAudioConfigurationRuntimeState, + WirelessAudioConfigurationUnicastServerQosPreferences; + +/// A configurable section of the wireless audio policy protocol. +enum WirelessAudioConfigurationSection { + /// ACL connection interval, latency, and supervision timeout policy. + aclConnectionPolicy(0), + + /// ACL PHY and data-length policy. + aclRadioPolicy(1), + + /// Preferences advertised by the Unicast Server during codec setup. + unicastServerQosPreferences(2); + + /// Creates a section with its stable protocol identifier. + const WirelessAudioConfigurationSection(this.id); + + /// Stable identifier used by the protocol. + final int id; + + /// Bit used for this section in protocol masks. + int get mask => 1 << id; +} + +/// Controls OpenEarable's device-owned Bluetooth audio policies. +/// +/// Standard LE Audio codec and stream negotiation continues to use PACS and +/// ASCS. This capability configures only the device's local policy and exposes +/// the resulting runtime state. +abstract class WirelessAudioConfigurationManager { + /// Reads the policy features and ranges supported by the device. + Future getCapabilities(); + + /// Reads the latest effective Bluetooth audio runtime state. + Future getRuntimeState(); + + /// Subscribes to effective Bluetooth audio runtime-state changes. + /// + /// Awaiting this method guarantees that the BLE notification subscription is + /// active before the returned stream is consumed. + Future> + subscribeToRuntimeState(); + + /// Sets the ACL connection [policy]. + /// + /// When [persist] is true, the device retains the setting across restarts. + Future setAclConnectionPolicy( + WirelessAudioConfigurationAclConnectionPolicy policy, { + bool persist = false, + }); + + /// Sets the ACL radio [policy]. + /// + /// When [persist] is true, the device retains the setting across restarts. + Future setAclRadioPolicy( + WirelessAudioConfigurationAclRadioPolicy policy, { + bool persist = false, + }); + + /// Sets the Unicast Server QoS [preferences]. + /// + /// When [persist] is true, the device retains the setting across restarts. + Future + setUnicastServerQosPreferences( + WirelessAudioConfigurationUnicastServerQosPreferences preferences, { + bool persist = false, + }); + + /// Reads the configured ACL connection policy. + Future + getAclConnectionPolicy(); + + /// Reads the configured ACL radio policy. + Future + getAclRadioPolicy(); + + /// Reads the configured Unicast Server QoS preferences. + Future + getUnicastServerQosPreferences(); + + /// Restores compiled defaults for [sections]. + /// + /// An empty set restores every section supported by the device. + Future restoreDefaults({ + Set sections = const {}, + }); +} + +/// Indicates that a wireless audio configuration command was rejected. +class WirelessAudioConfigurationException implements Exception { + /// Creates an exception for the rejected protocol [result]. + const WirelessAudioConfigurationException(this.result); + + /// Rejection details reported by the device. + final WirelessAudioConfigurationCommandResult result; + + @override + String toString() => + 'WirelessAudioConfigurationException(status: ${result.status}, ' + 'errorDomain: ${result.error_domain}, errorCode: ${result.error_code})'; +} diff --git a/lib/src/models/devices/open_earable_factory.dart b/lib/src/models/devices/open_earable_factory.dart index a1d82b0..001c251 100644 --- a/lib/src/models/devices/open_earable_factory.dart +++ b/lib/src/models/devices/open_earable_factory.dart @@ -25,11 +25,13 @@ import '../capabilities/sensor_configuration_specializations/sensor_configuratio import '../capabilities/sensor_configuration_specializations/streamable_sensor_configuration.dart'; import '../capabilities/system_device.dart'; import '../capabilities/time_synchronizable.dart'; +import '../capabilities/wireless_audio_configuration_manager.dart'; import 'discovered_device.dart'; 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_wireless_audio_configuration_manager.dart'; import 'wearable.dart'; import '../../fota/firmware_slot_manager_impl.dart'; @@ -45,6 +47,7 @@ class OpenEarableFactory extends WearableFactory { Set get usedServiceUuids => { ...OpenEarableV1.serviceUuids, ...OpenEarableV2.serviceUuids, + WirelessAudioConfigurationBleUuids.serviceUuid, mcuMgrSmpServiceUuid, timeSynchronizationServiceUuid, }; @@ -132,6 +135,17 @@ class OpenEarableFactory extends WearableFactory { ), ); } + if (await bleManager!.hasService( + deviceId: device.id, + serviceId: WirelessAudioConfigurationBleUuids.serviceUuid, + )) { + wearable.registerCapability( + OpenEarableV2WirelessAudioConfigurationManager( + bleManager: bleManager!, + deviceId: device.id, + ), + ); + } if (await bleManager!.hasCharacteristic( deviceId: device.id, serviceId: OpenEarableV2MicrophoneGainManager.serviceUuid, diff --git a/lib/src/models/devices/open_earable_v2_wireless_audio_configuration_manager.dart b/lib/src/models/devices/open_earable_v2_wireless_audio_configuration_manager.dart new file mode 100644 index 0000000..b07e335 --- /dev/null +++ b/lib/src/models/devices/open_earable_v2_wireless_audio_configuration_manager.dart @@ -0,0 +1,243 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:open_earable_protocols/open_earable_protocols.dart'; + +import '../../managers/ble_gatt_manager.dart'; +import '../capabilities/wireless_audio_configuration_manager.dart'; + +/// Wireless audio configuration implementation for OpenEarable V2 devices. +class OpenEarableV2WirelessAudioConfigurationManager + implements WirelessAudioConfigurationManager { + /// Creates a manager backed by [bleManager]. + OpenEarableV2WirelessAudioConfigurationManager({ + required this.bleManager, + required this.deviceId, + this.responseTimeout = 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 a response indication. + final Duration responseTimeout; + + Future _operationQueue = Future.value(); + int _nextRequestId = 0; + + @override + Future getCapabilities() async { + final bytes = await _read( + WirelessAudioConfigurationBleUuids.capabilitiesCharacteristicUuid, + ); + return WirelessAudioConfigurationCapabilities.fromBytes( + Uint8List.fromList(bytes), + ); + } + + @override + Future getRuntimeState() async { + final bytes = await _read( + WirelessAudioConfigurationBleUuids.runtimeStateCharacteristicUuid, + ); + return WirelessAudioConfigurationRuntimeState.fromBytes( + Uint8List.fromList(bytes), + ); + } + + @override + Future> + subscribeToRuntimeState() async { + final stream = await bleManager.subscribe( + deviceId: deviceId, + serviceId: WirelessAudioConfigurationBleUuids.serviceUuid, + characteristicId: + WirelessAudioConfigurationBleUuids.runtimeStateCharacteristicUuid, + ); + return stream.map( + (bytes) => WirelessAudioConfigurationRuntimeState.fromBytes( + Uint8List.fromList(bytes), + ), + ); + } + + @override + Future setAclConnectionPolicy( + WirelessAudioConfigurationAclConnectionPolicy policy, { + bool persist = false, + }) { + return _execute( + WirelessAudioConfigurationSetAclConnectionPolicy( + persist: persist ? 1 : 0, + policy: policy, + ), + ); + } + + @override + Future setAclRadioPolicy( + WirelessAudioConfigurationAclRadioPolicy policy, { + bool persist = false, + }) { + return _execute( + WirelessAudioConfigurationSetAclRadioPolicy( + persist: persist ? 1 : 0, + policy: policy, + ), + ); + } + + @override + Future + setUnicastServerQosPreferences( + WirelessAudioConfigurationUnicastServerQosPreferences preferences, { + bool persist = false, + }) { + return _execute( + WirelessAudioConfigurationSetUnicastServerQosPreferences( + persist: persist ? 1 : 0, + preferences: preferences, + ), + ); + } + + @override + Future + getAclConnectionPolicy() { + return _getConfiguration< + WirelessAudioConfigurationConfiguredAclConnectionPolicy>( + WirelessAudioConfigurationSection.aclConnectionPolicy, + ); + } + + @override + Future + getAclRadioPolicy() { + return _getConfiguration< + WirelessAudioConfigurationConfiguredAclRadioPolicy>( + WirelessAudioConfigurationSection.aclRadioPolicy, + ); + } + + @override + Future + getUnicastServerQosPreferences() { + return _getConfiguration< + WirelessAudioConfigurationConfiguredUnicastServerQosPreferences>( + WirelessAudioConfigurationSection.unicastServerQosPreferences, + ); + } + + @override + Future restoreDefaults({ + Set sections = const {}, + }) { + final sectionMask = sections.fold( + 0, + (mask, section) => mask | section.mask, + ); + return _execute( + WirelessAudioConfigurationRestoreDefaults(section_mask: sectionMask), + ); + } + + Future _getConfiguration( + WirelessAudioConfigurationSection section, + ) { + return _execute( + WirelessAudioConfigurationGetConfiguration(section: section.id), + ); + } + + Future _execute( + WirelessAudioConfigurationConfigurationCommandOperation operation, + ) { + return _runExclusive(() async { + final requestId = _allocateRequestId(); + final responseIterator = StreamIterator>( + await bleManager.subscribe( + deviceId: deviceId, + serviceId: WirelessAudioConfigurationBleUuids.serviceUuid, + characteristicId: + WirelessAudioConfigurationBleUuids.responseCharacteristicUuid, + indications: true, + ), + ); + + try { + final responseFuture = _nextResponse(responseIterator, requestId); + responseFuture.ignore(); + await bleManager.write( + deviceId: deviceId, + serviceId: WirelessAudioConfigurationBleUuids.serviceUuid, + characteristicId: + WirelessAudioConfigurationBleUuids.commandCharacteristicUuid, + byteData: WirelessAudioConfigurationConfigurationCommand( + request_id: requestId, + operation: operation, + ).toBytes(), + ); + + final payload = (await responseFuture).payload; + if (payload is WirelessAudioConfigurationCommandResult && + payload.status >= 4) { + throw WirelessAudioConfigurationException(payload); + } + if (payload is! T) { + throw StateError( + 'Expected wireless audio response $T, but received ' + '${payload.runtimeType}', + ); + } + return payload as T; + } finally { + await responseIterator.cancel(); + } + }); + } + + Future _runExclusive(Future Function() operation) { + final result = Completer(); + _operationQueue = _operationQueue.then((_) async { + try { + result.complete(await operation()); + } on Object catch (error, stackTrace) { + result.completeError(error, stackTrace); + } + }); + return result.future; + } + + int _allocateRequestId() { + final requestId = _nextRequestId; + _nextRequestId = (_nextRequestId + 1) & 0xffff; + return requestId; + } + + Future _nextResponse( + StreamIterator> iterator, + int requestId, + ) async { + while (await iterator.moveNext().timeout(responseTimeout)) { + final response = + WirelessAudioConfigurationConfigurationResponse.fromBytes( + Uint8List.fromList(iterator.current), + ); + if (response.request_id == requestId) { + return response; + } + } + throw StateError('Wireless audio configuration response stream closed'); + } + + Future> _read(String characteristicId) { + return bleManager.read( + deviceId: deviceId, + serviceId: WirelessAudioConfigurationBleUuids.serviceUuid, + characteristicId: characteristicId, + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index b81f405..17efabc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,7 +39,7 @@ dependencies: bloc: ^9.1.0 meta: ^1.16.0 pub_semver: ^2.2.0 - open_earable_protocols: ^0.0.2 + open_earable_protocols: ^0.0.3 dev_dependencies: flutter_test: diff --git a/test/ble_manager_subscription_test.dart b/test/ble_manager_subscription_test.dart index faf7839..bc951fd 100644 --- a/test/ble_manager_subscription_test.dart +++ b/test/ble_manager_subscription_test.dart @@ -62,6 +62,27 @@ void main() { ); await replacementSubscription.cancel(); }); + + test('uses indications when requested by a characteristic', () async { + const deviceId = 'device'; + const serviceId = '7467b395-9043-4453-bc5c-2d8e8b10680a'; + const characteristicId = '1ae8ed46-b23c-48ba-8e67-5713a4a4dc69'; + final platform = _FakeUniversalBlePlatform(); + UniversalBle.setInstance(platform); + final manager = BleManager(); + + platform.updateConnection(deviceId, true); + final stream = await manager.subscribe( + deviceId: deviceId, + serviceId: serviceId, + characteristicId: characteristicId, + indications: true, + ); + final subscription = stream.listen((_) {}); + + expect(platform.notificationChanges, [BleInputProperty.indication]); + await subscription.cancel(); + }); } class _FakeUniversalBlePlatform extends UniversalBlePlatform { diff --git a/test/open_earable_v2_audio_response_manager_test.dart b/test/open_earable_v2_audio_response_manager_test.dart index 959d59a..2f35af9 100644 --- a/test/open_earable_v2_audio_response_manager_test.dart +++ b/test/open_earable_v2_audio_response_manager_test.dart @@ -563,6 +563,7 @@ class _FakeBleGattManager implements BleGattManager { required String deviceId, required String serviceId, required String characteristicId, + bool indications = false, }) async { return _controller(characteristicId).stream; } diff --git a/test/open_earable_v2_wireless_audio_configuration_manager_test.dart b/test/open_earable_v2_wireless_audio_configuration_manager_test.dart new file mode 100644 index 0000000..dcdc316 --- /dev/null +++ b/test/open_earable_v2_wireless_audio_configuration_manager_test.dart @@ -0,0 +1,449 @@ +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/wireless_audio_configuration_manager.dart'; +import 'package:open_earable_flutter/src/models/devices/open_earable_v2_wireless_audio_configuration_manager.dart'; +import 'package:open_earable_protocols/open_earable_protocols.dart'; + +void main() { + group('OpenEarableV2WirelessAudioConfigurationManager', () { + test('reads capabilities and runtime state', () async { + final bleManager = _FakeBleGattManager(); + final capabilities = _capabilities(); + final runtimeState = _runtimeState(sequence: 7); + bleManager.readValues[WirelessAudioConfigurationBleUuids + .capabilitiesCharacteristicUuid] = capabilities.toBytes(); + bleManager.readValues[WirelessAudioConfigurationBleUuids + .runtimeStateCharacteristicUuid] = runtimeState.toBytes(); + final manager = _manager(bleManager); + + final decodedCapabilities = await manager.getCapabilities(); + final decodedRuntimeState = await manager.getRuntimeState(); + + expect(decodedCapabilities.protocol_version, 1); + expect(decodedCapabilities.supported_section_mask, 0x7); + expect(decodedRuntimeState.sequence, 7); + expect(decodedRuntimeState.acl_interval_us, 15000); + }); + + test('decodes runtime state notifications', () async { + final bleManager = _FakeBleGattManager(); + final manager = _manager(bleManager); + final stream = await manager.subscribeToRuntimeState(); + final nextState = stream.first; + + bleManager.emit( + WirelessAudioConfigurationBleUuids.runtimeStateCharacteristicUuid, + _runtimeState(sequence: 8).toBytes(), + ); + + expect((await nextState).sequence, 8); + expect( + bleManager.subscriptions, + [WirelessAudioConfigurationBleUuids.runtimeStateCharacteristicUuid], + ); + }); + + test('encodes all mutating commands and returns command results', () async { + final bleManager = _FakeBleGattManager(); + final manager = _manager(bleManager); + final operations = + []; + + bleManager.onWrite = (write) { + final command = + WirelessAudioConfigurationConfigurationCommand.fromBytes( + write.bytes, + ); + operations.add(command.operation); + bleManager.emitResponse( + WirelessAudioConfigurationConfigurationResponse( + request_id: command.request_id, + payload: _successfulResult(), + ), + ); + }; + + await manager.setAclConnectionPolicy( + WirelessAudioConfigurationAclConnectionPolicy.fixedAclPolicy( + WirelessAudioConfigurationFixedAclPolicy( + interval_us: 15000, + peripheral_latency: 0, + supervision_timeout_ms: 4000, + ), + ), + persist: true, + ); + await manager.setAclRadioPolicy( + WirelessAudioConfigurationAclRadioPolicy.preferredAclRadioPolicy( + WirelessAudioConfigurationPreferredAclRadioPolicy( + transmit_phy_mask: 2, + receive_phy_mask: 2, + transmit_max_data_octets: 251, + transmit_max_time_us: 2120, + ), + ), + ); + await manager.setUnicastServerQosPreferences( + _qosPreferences(), + persist: true, + ); + await manager.restoreDefaults( + sections: { + WirelessAudioConfigurationSection.aclConnectionPolicy, + WirelessAudioConfigurationSection.unicastServerQosPreferences, + }, + ); + + final connection = + operations[0] as WirelessAudioConfigurationSetAclConnectionPolicy; + final radio = + operations[1] as WirelessAudioConfigurationSetAclRadioPolicy; + final qos = operations[2] + as WirelessAudioConfigurationSetUnicastServerQosPreferences; + final restore = + operations[3] as WirelessAudioConfigurationRestoreDefaults; + expect(connection.persist, 1); + expect( + connection.policy.policy, + isA(), + ); + expect(radio.persist, 0); + expect( + radio.policy.policy, + isA(), + ); + expect(qos.persist, 1); + expect(qos.preferences.maximum_transport_latency_ms, 20); + expect(restore.section_mask, 0x5); + expect( + bleManager.writes.map((write) => write.requestId), + [0, 1, 2, 3], + ); + expect( + bleManager.indicationSubscriptions, + List.filled( + 4, + WirelessAudioConfigurationBleUuids.responseCharacteristicUuid, + ), + ); + }); + + test('gets each configured policy section with its stable id', () async { + final bleManager = _FakeBleGattManager(); + final manager = _manager(bleManager); + final requestedSections = []; + + bleManager.onWrite = (write) { + final command = + WirelessAudioConfigurationConfigurationCommand.fromBytes( + write.bytes, + ); + final operation = + command.operation as WirelessAudioConfigurationGetConfiguration; + requestedSections.add(operation.section); + final WirelessAudioConfigurationConfigurationResponsePayload payload; + switch (operation.section) { + case 0: + payload = WirelessAudioConfigurationConfiguredAclConnectionPolicy( + persisted: 1, + policy: WirelessAudioConfigurationAclConnectionPolicy + .controllerDefaultAclPolicy( + WirelessAudioConfigurationControllerDefaultAclPolicy( + reserved: 0, + ), + ), + ); + case 1: + payload = WirelessAudioConfigurationConfiguredAclRadioPolicy( + persisted: 0, + policy: WirelessAudioConfigurationAclRadioPolicy + .automaticAclRadioPolicy( + WirelessAudioConfigurationAutomaticAclRadioPolicy(reserved: 0), + ), + ); + case 2: + payload = + WirelessAudioConfigurationConfiguredUnicastServerQosPreferences( + persisted: 1, + preferences: _qosPreferences(), + ); + default: + throw StateError('Unexpected section ${operation.section}'); + } + bleManager.emitResponse( + WirelessAudioConfigurationConfigurationResponse( + request_id: command.request_id, + payload: payload, + ), + ); + }; + + final connection = await manager.getAclConnectionPolicy(); + final radio = await manager.getAclRadioPolicy(); + final qos = await manager.getUnicastServerQosPreferences(); + + expect(requestedSections, [0, 1, 2]); + expect(connection.persisted, 1); + expect(radio.persisted, 0); + expect(qos.preferences.preferred_retransmission_number, 2); + }); + + test('throws a typed exception when the device rejects a command', + () async { + final bleManager = _FakeBleGattManager(); + final manager = _manager(bleManager); + bleManager.onWrite = (write) { + bleManager.emitResponse( + WirelessAudioConfigurationConfigurationResponse( + request_id: write.requestId, + payload: WirelessAudioConfigurationCommandResult( + status: 5, + error_domain: 1, + error_code: -2, + restart_required_mask: 0, + ), + ), + ); + }; + + await expectLater( + manager.restoreDefaults(), + throwsA( + isA() + .having((error) => error.result.status, 'status', 5) + .having((error) => error.result.error_code, 'error code', -2), + ), + ); + }); + + test('serializes commands so response subscriptions cannot race', () async { + final bleManager = _FakeBleGattManager(); + final manager = _manager(bleManager); + final firstWrite = Completer(); + final requestIds = []; + bleManager.onWrite = (write) { + requestIds.add(write.requestId); + if (requestIds.length == 1) { + firstWrite.complete(); + return; + } + bleManager.emitResponse( + WirelessAudioConfigurationConfigurationResponse( + request_id: write.requestId, + payload: _successfulResult(), + ), + ); + }; + + final first = manager.restoreDefaults(); + await firstWrite.future; + final second = manager.restoreDefaults(); + await Future.delayed(Duration.zero); + expect(requestIds, [0]); + + bleManager.emitResponse( + WirelessAudioConfigurationConfigurationResponse( + request_id: 0, + payload: _successfulResult(), + ), + ); + await first; + await second; + + expect(requestIds, [0, 1]); + }); + }); +} + +OpenEarableV2WirelessAudioConfigurationManager _manager( + BleGattManager bleManager, +) { + return OpenEarableV2WirelessAudioConfigurationManager( + bleManager: bleManager, + deviceId: 'device', + ); +} + +WirelessAudioConfigurationCommandResult _successfulResult() { + return WirelessAudioConfigurationCommandResult( + status: 0, + error_domain: 0, + error_code: 0, + restart_required_mask: 0, + ); +} + +WirelessAudioConfigurationUnicastServerQosPreferences _qosPreferences() { + return WirelessAudioConfigurationUnicastServerQosPreferences( + direction_mask: 3, + unframed_supported: 1, + preferred_phy_mask: 2, + preferred_retransmission_number: 2, + maximum_transport_latency_ms: 20, + minimum_presentation_delay_us: 10000, + maximum_presentation_delay_us: 40000, + preferred_minimum_presentation_delay_us: 20000, + preferred_maximum_presentation_delay_us: 30000, + ); +} + +WirelessAudioConfigurationCapabilities _capabilities() { + return WirelessAudioConfigurationCapabilities( + protocol_version: 1, + supported_section_mask: 0x7, + supported_command_mask: 0x1f, + supported_acl_policy_mask: 0xf, + supported_phy_mask: 0x7, + minimum_acl_interval_us: 7500, + maximum_acl_interval_us: 40000, + acl_interval_resolution_us: 1250, + maximum_acl_peripheral_latency: 30, + minimum_acl_supervision_timeout_ms: 100, + maximum_acl_supervision_timeout_ms: 32000, + minimum_acl_data_octets: 27, + maximum_acl_data_octets: 251, + minimum_acl_data_time_us: 328, + maximum_acl_data_time_us: 2120, + supported_audio_direction_mask: 3, + maximum_preferred_retransmission_number: 15, + maximum_transport_latency_ms: 4000, + minimum_presentation_delay_us: 0, + maximum_presentation_delay_us: 4000000, + feature_flags: 0x7, + ); +} + +WirelessAudioConfigurationRuntimeState _runtimeState({required int sequence}) { + return WirelessAudioConfigurationRuntimeState( + sequence: sequence, + validity_flags: 0x7f, + connection_id: 3, + stream_id: 1, + direction: 0, + lifecycle_state: 5, + acl_interval_us: 15000, + acl_peripheral_latency: 0, + acl_supervision_timeout_ms: 4000, + transmit_phy: 2, + receive_phy: 2, + transmit_data_octets: 251, + receive_data_octets: 251, + lc3_sampling_frequency_hz: 48000, + lc3_frame_duration_us: 10000, + lc3_octets_per_frame: 100, + lc3_frame_blocks_per_sdu: 1, + lc3_channel_allocation: 1, + iso_sdu_interval_us: 10000, + iso_framing: 0, + iso_phy: 2, + iso_retransmission_number: 2, + iso_maximum_sdu_octets: 100, + iso_maximum_transport_latency_ms: 20, + presentation_delay_us: 30000, + audio_underrun_count: 0, + acl_adjustment_count: 0, + ); +} + +class _FakeBleGattManager implements BleGattManager { + final _streams = >>{}; + final readValues = >{}; + final subscriptions = []; + final indicationSubscriptions = []; + final writes = <_Write>[]; + + void Function(_Write write)? onWrite; + + void emit(String characteristicId, List bytes) { + _controller(characteristicId).add(bytes); + } + + void emitResponse( + WirelessAudioConfigurationConfigurationResponse response, + ) { + emit( + WirelessAudioConfigurationBleUuids.responseCharacteristicUuid, + response.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)); + writes.add(write); + onWrite?.call(write); + } + + @override + Future>> subscribe({ + required String deviceId, + required String serviceId, + required String characteristicId, + bool indications = false, + }) async { + subscriptions.add(characteristicId); + if (indications) { + indicationSubscriptions.add(characteristicId); + } + return _controller(characteristicId).stream; + } + + StreamController> _controller(String characteristicId) { + return _streams.putIfAbsent( + characteristicId, + () => StreamController>.broadcast(sync: true), + ); + } + + @override + Future> read({ + required String deviceId, + required String serviceId, + required String characteristicId, + }) async { + return readValues[characteristicId] ?? []; + } + + @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; +} + +class _Write { + const _Write(this.characteristicId, this.bytes); + + final String characteristicId; + final Uint8List bytes; + + int get requestId => + WirelessAudioConfigurationConfigurationCommand.fromBytes(bytes) + .request_id; +}