diff --git a/CHANGELOG.md b/CHANGELOG.md index 92f3b019..f1e25999 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## Unreleased + +- Add experimental, APK-derived OmniBuds sensor support with per-ear streams, + acknowledged configuration, raw packet access, and single/pair illustrations. + Hardware throughput and compatibility remain unverified; see `doc/OMNIBUDS.md`. + ## 2.5.0 - Decode compact firmware 2.3.x IMU samples back to the same float values and units; retain legacy firmware and SD formats. diff --git a/README.md b/README.md index 40cdc7be..936960e5 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,14 @@ This Dart package provides functionality for interacting with OpenEarable device See [CONTRIBUTING.md](https://github.com/OpenEarable/open_earable_flutter/CONTRIBUTING.md) for the contribution workflow, code quality expectations, rebasing policy, and required verification steps. +## OmniBuds + +Experimental [OmniBuds support](doc/OMNIBUDS.md) includes side-labelled motion, +PPG, temperature and vital-sign streams, configuration controls, and single-ear +and pair illustrations. The Bluetooth protocol was derived from the official +Android APK; hardware rates and compatibility are not yet verified. See the +integration guide for supported settings, usage, and limitations. + ## Permissions For your app to be able to use [UniversalBLE](https://pub.dev/packages/universal_ble) in this package, you need to grant the following permissions: ### Android diff --git a/assets/wearable_icons/omnibuds/left.svg b/assets/wearable_icons/omnibuds/left.svg new file mode 100644 index 00000000..30ba7df1 --- /dev/null +++ b/assets/wearable_icons/omnibuds/left.svg @@ -0,0 +1,6 @@ +OmniBuds left illustration + + + + + diff --git a/assets/wearable_icons/omnibuds/pair.svg b/assets/wearable_icons/omnibuds/pair.svg new file mode 100644 index 00000000..35f86e88 --- /dev/null +++ b/assets/wearable_icons/omnibuds/pair.svg @@ -0,0 +1,11 @@ +OmniBuds pair illustration + + + + + + + + + + diff --git a/assets/wearable_icons/omnibuds/right.svg b/assets/wearable_icons/omnibuds/right.svg new file mode 100644 index 00000000..91f03216 --- /dev/null +++ b/assets/wearable_icons/omnibuds/right.svg @@ -0,0 +1,6 @@ +OmniBuds right illustration + + + + + diff --git a/doc/OMNIBUDS.md b/doc/OMNIBUDS.md new file mode 100644 index 00000000..f90f5e98 --- /dev/null +++ b/doc/OMNIBUDS.md @@ -0,0 +1,183 @@ +# OmniBuds (experimental) + +The library recognizes OmniBuds by its vendor GATT service. One BLE peripheral +carries separate left and right channels; it is represented by **one `OmniBuds` +wearable** with side-labelled sensors. Do not pair two connections using the +OpenEarable `StereoDevice` API. + +This integration was derived from the public Android app, not hardware captures. +Packet decoding and connection/configuration handling have automated tests; +actual sampling rates, simultaneous streaming, sensor availability across +firmware versions, and peer-status behavior still require OmniBuds hardware. +There is no firmware update, microphone recording, or audio playback API here. + +## Sensors and rates + +These are the selectable settings in the official app, **per ear**, not measured +BLE throughput or a guarantee that all streams can run together at maximum rate. + +| Sensor | Peripheral ID | Selectable Hz | Output | +| --- | --- | --- | --- | +| Accelerometer | 0 | 10*, 25, 50, 100 | X/Y/Z in g; original LSB values retained | +| Gyroscope | 1 | 10*, 25, 50, 100 | X/Y/Z in degrees/second; original LSB values retained | +| Magnetometer | 2 | 10, 20, 50, 100 | X/Y/Z in gauss; original LSB values retained | +| PPG | 5 | 25, 50, 100 | Green, red, infrared counts | +| Object temperature | 7 | 0.2 | Degrees Celsius | +| Heart rate | 9 | Not established | Beats/minute | +| Heart-rate variability | 10 | Not established | Milliseconds | +| Blood oxygen | 11 | Not established | Percent | +| Respiration | 12 | Not established | Breaths/minute | + +*The APK's accelerometer/gyro controls label rate code 0 as **10 Hz**, but its +decoder uses **12 Hz** for within-packet timing. We preserve those respective +behaviors and flag the discrepancy rather than claim either is the measured +hardware rate. Packet timestamps and original values remain accessible so this +can be checked on hardware. There is no verified output-frequency setting for +the four calculated vital signs in this integration; they have on/off controls. + +Additional settings: accelerometer range 2/4/8/16 g, gyroscope range +125/250/500/1000/2000 degrees/second, and PPG LED current 31/62/93/124 mA. +`setSensorRange` selects these without changing the streaming state. + +The APK names other peripherals (including ambient temperature, steps, gestures, +and battery), but names alone do not establish their configuration and payload +contracts. They are not advertised as supported typed sensors. Unmodelled events +and data remain accessible through `messages`. The microphone advertised on the +product website has no raw-stream decoder or command identified in this APK. +Characteristics A03/A04 are firmware-transfer channels, not microphone streams. + +## Use + +`OmniBudsFactory` is registered by default in `WearableManager`, including its +service in the browser's permitted-service list. Normal scanning/connecting is +unchanged. Connect to the earbuds, not a charging-case advertisement. + +```dart +final wearable = await manager.connectToDevice(discoveredDevice); +if (wearable is OmniBuds) { + final sensor = wearable.sensors.firstWhere( + (s) => s.ear == DevicePosition.left && s.kind == OmniBudsSensorKind.ppg, + ); + final samples = sensor.sensorStream.listen((sample) { + print('${sample.timestamp}: ${sample.values}'); + // rawValues: values before conversion; message.bytes: complete notification. + }, onError: (Object error) { + print('Invalid sensor packet: $error'); + }); + final diagnostics = wearable.diagnostics.listen((error) { + print('OmniBuds: $error'); + }); + + await wearable.configureSensor( + DevicePosition.left, + OmniBudsSensorKind.ppg, + enabled: true, + frequencyHz: 100, + ); + // Select DevicePosition.right to configure the other ear independently. + // Leave subscriptions active while collecting data, then clean up: + await wearable.configureSensor( + DevicePosition.left, + OmniBudsSensorKind.ppg, + enabled: false, + ); + await samples.cancel(); + await diagnostics.cancel(); + await wearable.disconnect(); +} +``` + +The common `SensorManager`/`SensorConfigurationManager` APIs expose the same +sensors and controls. Raw sensors use `OmniBudsRateConfiguration`; vital signs +use `OmniBudsToggleConfiguration`. Their `apply` methods are awaitable. The +inherited void `setConfiguration` API reports asynchronous failures on +`diagnostics`. No sensors are enabled or disabled just by connecting. + +Configuration state starts unknown. Successful responses populate +`sensorConfigurationStream`; `readConfiguration(ear, kind, endpoint)` explicitly +reads it back. Enabling a raw sensor waits for acknowledgements of stop, rate, +and start in that order. A rejected change is not reported as applied. If a +response times out, reconnect before retrying that endpoint: this protocol has +no transaction ID with which to distinguish a late acknowledgement from a retry. + +`connectedEars` is the last optional peer-status response; `null` means unknown. +Call `refreshConnectionStatus()` to refresh it. Sensor channels existing in GATT +do not prove both ears are presently connected. `getWearableIconPath()` uses this +snapshot for the single-ear or pair illustration; if status is unavailable it +falls back to the exposed channels. Explicit `left`, `right`, and `pair` icon +variants are also available. The SVGs are original stylized illustrations, not +photographs. Consumers should refresh their image on `connectionStatusStream`. + +## Wire format + +Service: `00000a00-ae4a-11ed-afa1-0242ac120002`. +Left/right read-write notification channels: same UUID suffix with `00000a01` +and `00000a02`. Optional control: `00000a05`. + +Configuration request bytes: + +```text +peripheral, flags, length, 0, 0, endpoint, UTF-8 value +flags: 0x01 read; 0x09 write +length: 1 + UTF-8 value byte count +endpoint: 0 enable ("0"/"1"); 1 rate; 2 range/current +``` + +Motion rate/range values are zero-based indices into the lists above. PPG and +temperature rates, and PPG current, are literal decimal strings. Responses have +the peripheral at byte 0, message kind in the low three bits of byte 1, endpoint +at byte 5, error code at byte 6, and optional text from byte 7. Error 0 indicates +success. The implementation serializes commands and matches ear, peripheral, +and endpoint before accepting a response. + +Data messages (kind 2) have a five-byte header followed by UTF-8 CSV: +`timestamp_ms,value,...`. Motion and PPG contain consecutive groups of three +values. For motion, byte 3's high nibble encodes the rate and its low nibble the +range. For PPG it contains the frequency in Hz. It is **not** a sequence number. +Scalar messages decode one value; extra scalar fields are left to the raw +message API instead of assigning them invented timing or meaning. + +Motion scales are 0.061/0.122/0.244/0.488 mg/LSB for acceleration, +4.375/8.75/17.5/35/70 mdps/LSB for angular velocity, and 1.5 mG/LSB for magnetic +field. Public values convert to g, dps, and G, respectively. + +`Sensor.timestampExponent` is -6. The device's millisecond timestamp is preserved +as each packet's anchor, then batch samples are interpolated at the advertised +rate using rounded microsecond offsets. This does **not** imply a microsecond +device clock or synchronized left/right sampling. Time requests (peripheral 35) +receive the phone's current Unix milliseconds, matching the vendor app. Firmware +versions can be read separately with `readEarFirmwareVersion(ear)` (peripheral +41, endpoint 0). + +Optional control status uses request `[2]` and reply `[3, flags_hi, flags_lo]`; +bits 0/1 indicate left/right connection. This integration never sends firmware +transfer, reset, or firmware-mode commands. + +## Evidence and remaining validation + +Source: [official research page](https://www.omnibuds.tech/solutions/research), +which links the [Android APK](https://firmware.omnibuds.tech/app/ob_app.aab.apk). +Inspected package `tech.omnibuds`, app version **1.6.41**, version code **1**. +SHA-256: `40bd47eeae93f02553150d3abb28baa1b7406b48eb11fbf2712d0c03278f1251`. + +Protocol observations came from its Hermes bundle's `decodeEarbudMessage`, +`convertCommandToBase64`, `ConfigCommandBuilder`, sensor configuration/conversion +tables, `readData`, `sendTimeToHeadphone`, and `getStatus` implementations. The +APK and its decompiled sources are not distributed with this package. Tests use +synthetic fixtures derived from these wire contracts, not recorded device data. + +Before declaring hardware support verified: + +- Confirm discovery, notification setup, configuration/readback, error replies, + and reconnect with the installed firmware version. +- Measure each sensor at every listed rate, on each ear independently and as a + pair. Resolve the 10/12 Hz discrepancy against device timestamps and elapsed + time, not notification count (notifications may contain multiple readings). +- Run all streams together, with and without music. Measure achieved samples/s, + gaps, malformed packets, and BLE MTU behavior on Android and iOS. +- Verify single-ear/pair status transitions and update the icon accordingly. +- Check coordinate axes, range scaling, PPG order, timestamp anchoring and + restart behavior against the official app; determine vital-sign prerequisites + and actual reporting intervals. +- Obtain a documented or captured microphone/other-peripheral contract before + exposing additional typed sensors. diff --git a/lib/open_earable_flutter.dart b/lib/open_earable_flutter.dart index aef0914f..53dfab66 100644 --- a/lib/open_earable_flutter.dart +++ b/lib/open_earable_flutter.dart @@ -22,6 +22,7 @@ import 'src/models/capabilities/stereo_device.dart'; import 'src/models/capabilities/system_device.dart'; import 'src/models/devices/discovered_device.dart'; import 'src/models/devices/open_ring_factory.dart'; +import 'src/models/devices/omnibuds_factory.dart'; import 'src/models/devices/wearable.dart'; export 'package:open_earable_protocols/open_earable_protocols.dart' @@ -33,6 +34,12 @@ export 'src/models/devices/cosinuss_one.dart'; export 'src/models/devices/open_earable_v1.dart'; export 'src/models/devices/open_earable_v2.dart'; export 'src/models/devices/polar.dart'; +export 'src/models/devices/omnibuds.dart'; +export 'src/models/devices/omnibuds_factory.dart'; +export 'src/omnibuds/omnibuds_protocol.dart'; +export 'src/omnibuds/omnibuds_configuration.dart'; +export 'src/omnibuds/omnibuds_transport.dart' + show OmniBudsConfigurationException; export 'src/managers/wearable_disconnect_notifier.dart'; @@ -123,6 +130,7 @@ class WearableManager { DevKitFactory(), OpenRingFactory(), EsenseFactory(), + OmniBudsFactory(), ]; factory WearableManager() { diff --git a/lib/src/models/devices/omnibuds.dart b/lib/src/models/devices/omnibuds.dart new file mode 100644 index 00000000..3a33fbe6 --- /dev/null +++ b/lib/src/models/devices/omnibuds.dart @@ -0,0 +1,325 @@ +import 'dart:async'; + +import '../../../open_earable_flutter.dart'; +import '../../omnibuds/omnibuds_transport.dart'; + +/// OmniBuds exposes both ears through one physical BLE connection. +/// Sensors and configurations are explicitly labelled Left and Right; this +/// device must not be paired again using OpenEarable's two-connection pairing. +class OmniBuds extends Wearable + implements SensorManager, SensorConfigurationManager { + /// Creates a wearable around an initialized shared transport. + OmniBuds({ + required super.name, + required this.transport, + required super.disconnectNotifier, + }) { + for (final ear in transport.channels) { + for (final kind in OmniBudsSensorKind.values) { + final title = + '${ear == DevicePosition.left ? "Left" : "Right"} ${kind.label}'; + final SensorConfiguration configuration; + if (kind.rates.isNotEmpty) { + configuration = OmniBudsRateConfiguration( + name: title, + rates: kind.rates, + apply: (v) => configureSensor( + ear, + kind, + enabled: v.enabled, + frequencyHz: v.frequencyHz, + ), + reportError: transport.report, + ); + } else { + configuration = OmniBudsToggleConfiguration( + name: title, + apply: (v) => configureSensor(ear, kind, enabled: v.enabled), + reportError: transport.report, + ); + } + _configurations[(ear, kind.peripheralId)] = configuration; + _sensors.add( + OmniBudsSensor( + ear: ear, + kind: kind, + transport: transport, + configuration: configuration, + name: title, + ), + ); + } + } + _stateSubscription = transport.messages.listen(_updateConfigurationState); + addDisconnectListener(() => unawaited(_dispose())); + } + + final OmniBudsTransport transport; + final List _sensors = []; + final Map<(DevicePosition, int), SensorConfiguration> _configurations = {}; + final Map<(DevicePosition, int, int), String> _endpointValues = {}; + final Map _state = {}; + final _stateChanges = StreamController< + Map>.broadcast(); + late final StreamSubscription _stateSubscription; + Future _configurationQueue = Future.value(); + bool _disposed = false; + + @override + String get deviceId => transport.deviceId; + @override + List get sensors => List.unmodifiable(_sensors); + @override + List get sensorConfigurations => + List.unmodifiable(_configurations.values); + + /// Every protocol message, including unmodelled peripherals and event payloads. + Stream get messages => transport.messages; + + /// Parse errors, unavailable peer status, and failures of fire-and-forget UI writes. + Stream get diagnostics => transport.diagnostics; + + /// Last confirmed peer-status snapshot. Null means unknown, not disconnected. + Set? get connectedEars => transport.connectedEars; + Stream> get connectionStatusStream => + transport.connectionStatusStream; + + /// Refreshes peer presence without changing sensing or firmware mode. + Future> refreshConnectionStatus() => + transport.refreshConnectionStatus(); + + @override + Stream> + get sensorConfigurationStream => Stream.multi((controller) { + final subscription = _stateChanges.stream.listen( + controller.add, + onError: controller.addError, + onDone: controller.close, + ); + controller.add(Map.unmodifiable(_state)); + controller.onCancel = subscription.cancel; + }); + + void _updateConfigurationState(OmniBudsMessage message) { + if (_disposed || + message.type != 1 || + message.errorCode != 0 || + message.text.isEmpty) { + return; + } + _rememberConfiguration( + message.ear, + message.peripheralId, + message.endpoint!, + message.text, + ); + } + + void _rememberConfiguration( + DevicePosition ear, + int peripheralId, + int endpoint, + String text, + ) { + final key = (ear, peripheralId); + final config = _configurations[key]; + if (config == null) return; + _endpointValues[(key.$1, key.$2, endpoint)] = text; + final enabled = _endpointValues[(key.$1, key.$2, 0)]; + if (enabled != '0' && enabled != '1') return; + SensorConfigurationValue? value; + if (config is OmniBudsToggleConfiguration) { + value = OmniBudsToggleValue(enabled == '1'); + } else if (config is OmniBudsRateConfiguration) { + if (enabled == '0') { + value = config.offValue; + } else { + final kind = OmniBudsSensorKind.values + .firstWhere((k) => k.peripheralId == key.$2); + final encoded = _endpointValues[(key.$1, key.$2, 1)]; + for (final rate in kind.rates) { + if (kind.encodeRate(rate) == encoded) value = OmniBudsRateValue(rate); + } + } + } + if (value != null) { + _state[config] = value; + _stateChanges.add(Map.unmodifiable(_state)); + } else if (_state.remove(config) != null) { + // A newly observed, unrecognised setting invalidates the previous value. + _stateChanges.add(Map.unmodifiable(_state)); + } + } + + Future _writeConfiguration( + DevicePosition ear, + int peripheralId, + int endpoint, + String value, + ) async { + final response = + await transport.configure(ear, peripheralId, endpoint, value: value); + if (!_disposed) { + _rememberConfiguration( + ear, + peripheralId, + endpoint, + response.text.isEmpty ? value : response.text, + ); + } + } + + Future _serialize(Future Function() operation) { + final result = _configurationQueue.then((_) { + if (_disposed) throw StateError('OmniBuds disconnected'); + return operation(); + }); + _configurationQueue = + result.then((_) {}, onError: (Object _, StackTrace __) {}); + return result; + } + + /// Stops, sets the rate, then starts the requested sensor. Every write must + /// receive a successful configuration response before the next is sent. + Future configureSensor( + DevicePosition ear, + OmniBudsSensorKind kind, { + required bool enabled, + double? frequencyHz, + }) { + if (!transport.channels.contains(ear)) { + throw ArgumentError.value(ear, 'ear'); + } + if (enabled && kind.rates.isNotEmpty && !kind.rates.contains(frequencyHz)) { + throw ArgumentError.value( + frequencyHz, + 'frequencyHz', + 'Choose a supported rate', + ); + } + if (kind.rates.isEmpty && frequencyHz != null) { + throw ArgumentError('Reporting rate is managed by the device'); + } + return _serialize(() async { + await _writeConfiguration(ear, kind.peripheralId, 0, '0'); + if (!enabled) return; + if (kind.rates.isNotEmpty) { + await _writeConfiguration( + ear, + kind.peripheralId, + 1, + kind.encodeRate(frequencyHz!), + ); + } + await _writeConfiguration(ear, kind.peripheralId, 0, '1'); + }); + } + + /// Reads an endpoint; confirmed values update the common configuration stream. + Future readConfiguration( + DevicePosition ear, + OmniBudsSensorKind kind, + int endpoint, + ) => + _serialize(() => transport.configure(ear, kind.peripheralId, endpoint)); + + /// Sets accelerometer range in g, gyroscope range in dps, or PPG LED current + /// in mA using the options exposed by the vendor app. + Future setSensorRange( + DevicePosition ear, + OmniBudsSensorKind kind, + int value, + ) { + final choices = switch (kind) { + OmniBudsSensorKind.accelerometer => [2, 4, 8, 16], + OmniBudsSensorKind.gyroscope => [125, 250, 500, 1000, 2000], + OmniBudsSensorKind.ppg => [31, 62, 93, 124], + _ => [], + }; + final index = choices.indexOf(value); + if (index < 0) { + throw ArgumentError.value(value, 'value', 'Unsupported range/current'); + } + return _serialize(() async { + await transport.configure( + ear, + kind.peripheralId, + 2, + value: kind == OmniBudsSensorKind.ppg ? '$value' : '$index', + ); + }); + } + + /// Reads the firmware version reported by the selected ear. + Future readEarFirmwareVersion(DevicePosition ear) async => + (await transport.configure(ear, 41, 0)).text; + + @override + String getWearableIconPath({ + bool darkmode = false, + WearableIconVariant variant = WearableIconVariant.single, + }) { + final status = connectedEars ?? transport.channels; + final icon = switch (variant) { + WearableIconVariant.pair => 'pair', + WearableIconVariant.left => 'left', + WearableIconVariant.right => 'right', + WearableIconVariant.single => status.length == 2 + ? 'pair' + : status.contains(DevicePosition.left) + ? 'left' + : 'right', + }; + return 'packages/open_earable_flutter/assets/wearable_icons/omnibuds/$icon.svg'; + } + + @override + Future disconnect() async { + try { + await transport.ble.disconnect(deviceId); + } finally { + await _dispose(); + } + } + + Future _dispose() async { + if (_disposed) return; + _disposed = true; + await _stateSubscription.cancel(); + await transport.dispose(); + unawaited(_stateChanges.close()); + } +} + +/// One side-labelled sensor with raw values retained alongside physical units. +class OmniBudsSensor extends Sensor { + /// Uses the shared ear notification channel, avoiding duplicate subscriptions. + OmniBudsSensor({ + required this.ear, + required this.kind, + required OmniBudsTransport transport, + required SensorConfiguration configuration, + required String name, + }) : _transport = transport, + super( + sensorName: name, + chartTitle: name, + shortChartTitle: name, + timestampExponent: -6, + relatedConfigurations: [configuration], + ); + final DevicePosition ear; + final OmniBudsSensorKind kind; + final OmniBudsTransport _transport; + @override + List get axisNames => kind.axes; + @override + List get axisUnits => List.filled(kind.axes.length, kind.unit); + @override + Stream get sensorStream => _transport.messages + .where( + (m) => + m.ear == ear && m.type == 2 && m.peripheralId == kind.peripheralId, + ) + .expand((m) => decodeOmniBudsSamples(m, kind)); +} diff --git a/lib/src/models/devices/omnibuds_factory.dart b/lib/src/models/devices/omnibuds_factory.dart new file mode 100644 index 00000000..f0d8888d --- /dev/null +++ b/lib/src/models/devices/omnibuds_factory.dart @@ -0,0 +1,43 @@ +import 'package:universal_ble/universal_ble.dart'; + +import '../../omnibuds/omnibuds_protocol.dart'; +import '../../omnibuds/omnibuds_transport.dart'; +import '../wearable_factory.dart'; +import 'discovered_device.dart'; +import 'omnibuds.dart'; +import 'wearable.dart'; + +/// Recognizes OmniBuds by its vendor service rather than accepting a name alone. +class OmniBudsFactory extends WearableFactory { + @override + Set get usedServiceUuids => const {OmniBudsGatt.service}; + + @override + Future matches( + DiscoveredDevice device, + List services, + ) async => + !RegExp(r'(Case|Charger)', caseSensitive: false).hasMatch(device.name) && + services.any((s) => s.uuid.toLowerCase() == OmniBudsGatt.service); + + @override + Future createFromDevice( + DiscoveredDevice device, { + Set options = const {}, + }) async { + final ble = bleManager; + final notifier = disconnectNotifier; + if (ble == null || notifier == null) { + throw StateError('OmniBuds factory is not attached to a WearableManager'); + } + final transport = OmniBudsTransport(ble: ble, deviceId: device.id); + // Cover a disconnect during initialization as well as after construction. + notifier.addListener(transport.dispose); + await transport.initialize(); + return OmniBuds( + name: device.name, + transport: transport, + disconnectNotifier: notifier, + ); + } +} diff --git a/lib/src/omnibuds/omnibuds_configuration.dart b/lib/src/omnibuds/omnibuds_configuration.dart new file mode 100644 index 00000000..1555bc4a --- /dev/null +++ b/lib/src/omnibuds/omnibuds_configuration.dart @@ -0,0 +1,120 @@ +import '../models/capabilities/sensor_configuration.dart'; +import '../models/capabilities/sensor_configuration_specializations/configurable_sensor_configuration.dart'; +import '../models/capabilities/sensor_configuration_specializations/sensor_frequency_configuration.dart'; +import '../models/capabilities/sensor_configuration_specializations/streamable_sensor_configuration.dart'; + +/// A selectable raw-sensor rate, with streaming independently enabled or disabled. +class OmniBudsRateValue extends SensorFrequencyConfigurationValue + implements ConfigurableSensorConfigurationValue { + /// The rates are those advertised by the official app, not throughput promises. + OmniBudsRateValue(double frequencyHz, {this.enabled = true}) + : super( + frequencyHz: frequencyHz, + key: '$frequencyHz Hz ${enabled ? "stream" : "off"}', + ); + final bool enabled; + @override + Set get options => + {if (enabled) const StreamSensorConfigOption()}; + @override + OmniBudsRateValue withoutOptions() => + OmniBudsRateValue(frequencyHz, enabled: false); + @override + bool operator ==(Object other) => + other is OmniBudsRateValue && + other.frequencyHz == frequencyHz && + other.enabled == enabled; + @override + int get hashCode => Object.hash(frequencyHz, enabled); +} + +/// Frequency configuration using the common library UI, plus an awaitable API. +class OmniBudsRateConfiguration + extends SensorFrequencyConfiguration + implements ConfigurableSensorConfiguration { + /// Creates the supported on/off combinations for one ear and sensor. + OmniBudsRateConfiguration({ + required super.name, + required List rates, + required Future Function(OmniBudsRateValue) apply, + required void Function(Object) reportError, + }) : _apply = apply, + _reportError = reportError, + super( + values: [ + for (final hz in rates) ...[ + OmniBudsRateValue(hz), + OmniBudsRateValue(hz, enabled: false), + ], + ], + offValue: OmniBudsRateValue(rates.first, enabled: false), + ); + + final Future Function(OmniBudsRateValue) _apply; + final void Function(Object) _reportError; + @override + Set get availableOptions => + {const StreamSensorConfigOption()}; + + /// Completes after the device acknowledges the change; rejects invalid values. + Future apply(OmniBudsRateValue value) { + if (!values.contains(value)) throw ArgumentError.value(value, 'value'); + return _apply(value); + } + + @override + void setConfiguration(OmniBudsRateValue configuration) { + apply(configuration).catchError(_reportError); + } +} + +/// On/off value for an on-device algorithm with no verified fixed sample rate. +class OmniBudsToggleValue extends SensorConfigurationValue + implements ConfigurableSensorConfigurationValue { + /// Creates a streaming switch; no artificial Hz value is assigned. + OmniBudsToggleValue(this.enabled) : super(key: enabled ? 'Stream' : 'Off'); + final bool enabled; + @override + Set get options => + {if (enabled) const StreamSensorConfigOption()}; + @override + OmniBudsToggleValue withoutOptions() => OmniBudsToggleValue(false); + @override + bool operator ==(Object other) => + other is OmniBudsToggleValue && other.enabled == enabled; + @override + int get hashCode => enabled.hashCode; +} + +/// Enables a vendor algorithm without claiming a raw sampling frequency. +class OmniBudsToggleConfiguration + extends SensorConfiguration + implements ConfigurableSensorConfiguration { + /// Creates an awaitable configuration for one vital-sign stream. + OmniBudsToggleConfiguration({ + required super.name, + required Future Function(OmniBudsToggleValue) apply, + required void Function(Object) reportError, + }) : _apply = apply, + _reportError = reportError, + super( + values: [OmniBudsToggleValue(false), OmniBudsToggleValue(true)], + offValue: OmniBudsToggleValue(false), + ); + final Future Function(OmniBudsToggleValue) _apply; + final void Function(Object) _reportError; + @override + Set get availableOptions => + {const StreamSensorConfigOption()}; + + /// Completes only after the configuration response confirms success. + Future apply(OmniBudsToggleValue value) { + if (!values.contains(value)) throw ArgumentError.value(value, 'value'); + return _apply(value); + } + + @override + void setConfiguration(OmniBudsToggleValue configuration) { + apply(configuration).catchError(_reportError); + } +} diff --git a/lib/src/omnibuds/omnibuds_protocol.dart b/lib/src/omnibuds/omnibuds_protocol.dart new file mode 100644 index 00000000..fc5bc4fd --- /dev/null +++ b/lib/src/omnibuds/omnibuds_protocol.dart @@ -0,0 +1,227 @@ +import 'dart:convert'; + +import '../models/capabilities/sensor.dart'; +import '../models/capabilities/stereo_device.dart'; + +/// UUIDs observed in the official OmniBuds Android app 1.6.41. +abstract final class OmniBudsGatt { + static const service = '00000a00-ae4a-11ed-afa1-0242ac120002'; + static const left = '00000a01-ae4a-11ed-afa1-0242ac120002'; + static const right = '00000a02-ae4a-11ed-afa1-0242ac120002'; + static const control = '00000a05-ae4a-11ed-afa1-0242ac120002'; + + /// Both ears are addressed through the same BLE peripheral. + static String characteristic(DevicePosition ear) => + ear == DevicePosition.left ? left : right; +} + +/// Peripheral IDs with a raw sensor or vital-sign control in the vendor app. +enum OmniBudsSensorKind { + accelerometer(0, 'Accelerometer', ['X', 'Y', 'Z'], 'g', [10, 25, 50, 100]), + gyroscope(1, 'Gyroscope', ['X', 'Y', 'Z'], 'dps', [10, 25, 50, 100]), + magnetometer(2, 'Magnetometer', ['X', 'Y', 'Z'], 'G', [10, 20, 50, 100]), + ppg(5, 'PPG', ['Green', 'Red', 'Infrared'], 'raw', [25, 50, 100]), + temperature(7, 'Temperature', ['Object'], '°C', [0.2]), + heartRate(9, 'Heart Rate', ['Heart Rate'], 'bpm', []), + heartRateVariability(10, 'Heart Rate Variability', ['HRV'], 'ms', []), + oxygenSaturation(11, 'Blood Oxygen', ['SpO2'], '%', []), + respirationRate(12, 'Respiration Rate', ['Respiration'], 'breaths/min', []); + + const OmniBudsSensorKind( + this.peripheralId, + this.label, + this.axes, + this.unit, + this.rates, + ); + + final int peripheralId; + final String label; + final List axes; + final String unit; + + /// Selectable values in the vendor UI, not measured delivery guarantees. + /// An empty list means the device manages the reporting interval. + final List rates; + + /// Encodes the rate value expected at configuration endpoint 1. + String encodeRate(double hz) { + final index = rates.indexOf(hz); + if (index < 0) throw ArgumentError.value(hz, 'hz', 'Unsupported rate'); + if (peripheralId <= 2) return '$index'; + return hz == hz.roundToDouble() ? '${hz.toInt()}' : '$hz'; + } +} + +/// A decoded notification, including messages not mapped to a typed sensor. +class OmniBudsMessage { + /// Parses one complete GATT notification. No bytes are silently discarded. + factory OmniBudsMessage.decode(DevicePosition ear, List bytes) { + if (bytes.length < 5 || bytes.any((b) => b < 0 || b > 255)) { + throw const FormatException('Invalid OmniBuds header'); + } + final type = bytes[1] & 7; + if (type > 2) throw FormatException('Unknown OmniBuds message type $type'); + if (type == 1 && bytes.length < 6) { + throw const FormatException('Missing configuration endpoint'); + } + final offset = type == 1 ? (bytes.length >= 7 ? 7 : 6) : 5; + final text = utf8.decode(bytes.sublist(offset)).replaceAll('\u0000', ''); + return OmniBudsMessage._( + ear, + bytes[0], + type, + bytes[3], + type == 1 ? bytes[5] : null, + type == 1 && bytes.length >= 7 ? bytes[6] : null, + text, + List.unmodifiable(bytes), + ); + } + + const OmniBudsMessage._( + this.ear, + this.peripheralId, + this.type, + this.metadata, + this.endpoint, + this.errorCode, + this.text, + this.bytes, + ); + + final DevicePosition ear; + final int peripheralId; + + /// 0: event, 1: configuration, 2: sensor data. + final int type; + + /// For motion data: rate index in high nibble, range index in low nibble. + /// For PPG: sample rate in Hz. This is not a packet sequence counter. + final int metadata; + final int? endpoint; + final int? errorCode; + final String text; + final List bytes; +} + +/// Encodes the configuration header followed by its UTF-8 textual value. +List encodeOmniBudsConfiguration( + int peripheralId, + int endpoint, { + String? value, + int? errorCode, +}) { + if (peripheralId < 0 || + peripheralId > 255 || + endpoint < 0 || + endpoint > 255) { + throw ArgumentError('Peripheral and endpoint must be bytes'); + } + if (errorCode != null && (errorCode < 0 || errorCode > 255)) { + throw ArgumentError.value(errorCode, 'errorCode'); + } + final data = utf8.encode(value ?? ''); + if (data.length > 254 || data.contains(0)) { + throw ArgumentError('OmniBuds values must fit one message without NULs'); + } + // The vendor time response includes an error byte but still uses 1 + text + // length in this field. Match that wire format instead of inferring a size. + return [ + peripheralId, + value == null ? 1 : 9, + 1 + data.length, + 0, + 0, + endpoint, + if (errorCode != null) errorCode, + ...data, + ]; +} + +/// Converted sample plus the untouched values and notification that produced it. +class OmniBudsSensorValue extends SensorDoubleValue { + /// Creates a timestamped sample. Timestamps are expressed in microseconds. + OmniBudsSensorValue({ + required super.values, + required super.timestamp, + required this.rawValues, + required this.message, + required this.sampleIndex, + }); + + final List rawValues; + final OmniBudsMessage message; + final int sampleIndex; +} + +/// Converts the APK's CSV samples and expands batches without integer-ms drift. +List decodeOmniBudsSamples( + OmniBudsMessage message, + OmniBudsSensorKind kind, +) { + if (message.type != 2 || message.peripheralId != kind.peripheralId) { + throw const FormatException('Wrong sensor message'); + } + final fields = message.text.split(','); + final timeMs = int.tryParse(fields.first); + if (timeMs == null || timeMs < 0 || timeMs > 9007199254740) { + throw const FormatException('Invalid OmniBuds millisecond timestamp'); + } + final numbers = fields.skip(1).map((field) { + final value = double.tryParse(field); + if (value == null || !value.isFinite) { + throw const FormatException('Invalid OmniBuds numeric sample'); + } + return value; + }).toList(); + final width = kind.axes.length; + if (numbers.isEmpty || numbers.length % width != 0) { + throw const FormatException('Incomplete OmniBuds sensor sample'); + } + double? hz; + double scale = 1; + if (kind.peripheralId <= 2) { + final rateIndex = message.metadata >> 4; + final rangeIndex = message.metadata & 15; + if (rateIndex > 3) throw const FormatException('Unknown motion rate'); + // The APK decoder says 12 Hz for code 0 although its UI says 10 Hz. + // Preserve its decoder behaviour and document the unresolved discrepancy. + hz = (kind == OmniBudsSensorKind.magnetometer + ? [10.0, 20.0, 50.0, 100.0] + : [12.0, 25.0, 50.0, 100.0])[rateIndex]; + if (kind == OmniBudsSensorKind.accelerometer) { + if (rangeIndex > 3) throw const FormatException('Unknown accel range'); + scale = [0.061, 0.122, 0.244, 0.488][rangeIndex] / 1000; + } else if (kind == OmniBudsSensorKind.gyroscope) { + if (rangeIndex > 4) throw const FormatException('Unknown gyro range'); + scale = [4.375, 8.75, 17.5, 35.0, 70.0][rangeIndex] / 1000; + } else { + scale = 0.0015; + } + } else if (kind == OmniBudsSensorKind.ppg) { + hz = message.metadata.toDouble(); + if (!kind.rates.contains(hz)) { + throw const FormatException('Unknown PPG rate'); + } + } + if (hz == null && numbers.length != width) { + // The app only defines batch timing for motion and PPG. Do not fabricate + // timestamps for multiple scalar values or vendor-specific auxiliary data. + throw const FormatException('Unsupported scalar payload; inspect messages'); + } + return [ + for (var i = 0; i < numbers.length ~/ width; i++) + OmniBudsSensorValue( + values: List.unmodifiable( + numbers.sublist(i * width, (i + 1) * width).map((v) => v * scale), + ), + timestamp: + timeMs * 1000 + (hz == null ? 0 : (i * 1000000 / hz).round()), + rawValues: + List.unmodifiable(numbers.sublist(i * width, (i + 1) * width)), + message: message, + sampleIndex: i, + ), + ]; +} diff --git a/lib/src/omnibuds/omnibuds_transport.dart b/lib/src/omnibuds/omnibuds_transport.dart new file mode 100644 index 00000000..5aa4578f --- /dev/null +++ b/lib/src/omnibuds/omnibuds_transport.dart @@ -0,0 +1,300 @@ +import 'dart:async'; + +import '../managers/ble_gatt_manager.dart'; +import '../models/capabilities/stereo_device.dart'; +import 'omnibuds_protocol.dart'; + +/// A rejected configuration response from an OmniBuds ear. +class OmniBudsConfigurationException implements Exception { + /// Retains the complete response for diagnostics. + const OmniBudsConfigurationException(this.response); + final OmniBudsMessage response; + + @override + String toString() => 'OmniBuds ${response.ear.name} peripheral ' + '${response.peripheralId}, endpoint ${response.endpoint}: ' + 'error ${response.errorCode}'; +} + +/// Owns the notification subscriptions shared by every sensor on one pair. +class OmniBudsTransport { + /// Creates a transport; call [initialize] before issuing commands. + OmniBudsTransport({ + required this.ble, + required this.deviceId, + this.responseTimeout = const Duration(seconds: 2), + }); + + final BleGattManager ble; + final String deviceId; + final Duration responseTimeout; + final Set _channels = {}; + final List>> _subscriptions = []; + final _messages = StreamController.broadcast(); + final _diagnostics = StreamController.broadcast(); + final _status = StreamController>.broadcast(); + final Map<(DevicePosition, int, int), Completer> _pending = + {}; + final Set<(DevicePosition, int, int)> _timedOut = {}; + Future _queue = Future.value(); + Completer>? _pendingStatus; + bool _controlAvailable = false; + bool _statusTimedOut = false; + bool _closed = false; + bool _initializationStarted = false; + Set? _connectedEars; + + /// Ears whose data characteristic exists, independent of peer connectivity. + Set get channels => Set.unmodifiable(_channels); + Stream get messages => _messages.stream; + Stream get diagnostics => _diagnostics.stream; + + /// Last status response, or null if the optional control service is absent. + Set? get connectedEars => _connectedEars; + Stream> get connectionStatusStream => _status.stream; + + /// Subscribe once, before sending anything that can produce a response. + Future initialize() async { + _ensureOpen(); + if (_initializationStarted) throw StateError('Already initialized'); + _initializationStarted = true; + try { + for (final ear in DevicePosition.values) { + final characteristic = OmniBudsGatt.characteristic(ear); + final available = await ble.hasCharacteristic( + deviceId: deviceId, + serviceId: OmniBudsGatt.service, + characteristicId: characteristic, + ); + _ensureOpen(); + if (!available) { + continue; + } + final stream = await ble.subscribe( + deviceId: deviceId, + serviceId: OmniBudsGatt.service, + characteristicId: characteristic, + ); + _ensureOpen(); + _channels.add(ear); + _subscriptions.add( + stream.listen((bytes) => _receive(ear, bytes), onError: report), + ); + } + if (_channels.isEmpty) throw StateError('No OmniBuds sensor channels'); + _controlAvailable = await ble.hasCharacteristic( + deviceId: deviceId, + serviceId: OmniBudsGatt.service, + characteristicId: OmniBudsGatt.control, + ); + _ensureOpen(); + if (_controlAvailable) { + try { + final stream = await ble.subscribe( + deviceId: deviceId, + serviceId: OmniBudsGatt.service, + characteristicId: OmniBudsGatt.control, + ); + _ensureOpen(); + _subscriptions.add(stream.listen(_receiveStatus, onError: report)); + await refreshConnectionStatus(); + } catch (error) { + // Older firmware may lack this optional, read-only status command. + report(error); + } + } + _ensureOpen(); + } catch (_) { + await dispose(); + rethrow; + } + } + + void _ensureOpen() { + if (_closed) throw StateError('OmniBuds disconnected'); + } + + /// Reports malformed frames or background operation errors without throwing + /// from BLE callbacks. Callers can observe [diagnostics]. + void report(Object error) { + if (!_closed) _diagnostics.add(error); + } + + void _receive(DevicePosition ear, List bytes) { + if (_closed) return; + try { + final message = OmniBudsMessage.decode(ear, bytes); + _messages.add(message); + if (message.type != 1) return; + final pending = _pending[(ear, message.peripheralId, message.endpoint!)]; + if (pending != null && + message.errorCode != null && + !pending.isCompleted) { + pending.complete(message); + } + if (message.peripheralId == 35 && + message.endpoint == 0 && + message.text.isEmpty && + (message.errorCode ?? 0) == 0) { + unawaited(sendTime(ear).catchError(report)); + } + } catch (error) { + report(error); + } + } + + void _receiveStatus(List bytes) { + if (_closed || bytes.isEmpty || bytes[0] != 3) return; + if (bytes.length != 3) { + report(const FormatException('Invalid OmniBuds connection status')); + return; + } + final flags = (bytes[1] << 8) | bytes[2]; + final ears = Set.unmodifiable({ + if (flags & 1 != 0) DevicePosition.left, + if (flags & 2 != 0) DevicePosition.right, + }); + _connectedEars = ears; + _status.add(ears); + final pending = _pendingStatus; + if (pending != null && !pending.isCompleted) pending.complete(ears); + } + + Future _enqueue(Future Function() action) { + final result = _queue.then((_) { + if (_closed) throw StateError('OmniBuds disconnected'); + return action(); + }); + _queue = result.then((_) {}, onError: (Object _, StackTrace __) {}); + return result; + } + + /// Requests the read-only peer status (opcode 2, response 3). + Future> refreshConnectionStatus() => _enqueue(() async { + if (!_controlAvailable) { + throw UnsupportedError('Peer status unavailable'); + } + if (_statusTimedOut) throw StateError('Reconnect after status timeout'); + final pending = Completer>(); + _pendingStatus = pending; + // Attach error handling before write: a synchronous notification or + // disconnect can complete the waiter while the GATT write is still pending. + final response = pending.future.timeout(responseTimeout); + unawaited( + response.then((_) {}, onError: (Object _, StackTrace __) {}), + ); + try { + await ble.write( + deviceId: deviceId, + serviceId: OmniBudsGatt.service, + characteristicId: OmniBudsGatt.control, + byteData: [2], + ).timeout(responseTimeout); + return await response; + } on TimeoutException { + _statusTimedOut = true; + rethrow; + } finally { + _pendingStatus = null; + if (!pending.isCompleted) { + pending.completeError(StateError('Status ended')); + } + } + }); + + /// Reads or writes an endpoint and waits for its application-level response. + /// Timed-out endpoint keys cannot be reused until reconnect because the wire + /// protocol has no transaction ID to distinguish a delayed old response. + Future configure( + DevicePosition ear, + int peripheralId, + int endpoint, { + String? value, + }) => + _enqueue(() async { + if (!_channels.contains(ear)) { + throw StateError('No ${ear.name} channel'); + } + final key = (ear, peripheralId, endpoint); + if (_timedOut.contains(key)) { + throw StateError('Reconnect before retrying timed-out endpoint $key'); + } + final bytes = + encodeOmniBudsConfiguration(peripheralId, endpoint, value: value); + final pending = Completer(); + _pending[key] = pending; + final response = pending.future.timeout(responseTimeout); + unawaited( + response.then((_) {}, onError: (Object _, StackTrace __) {}), + ); + try { + await ble + .write( + deviceId: deviceId, + serviceId: OmniBudsGatt.service, + characteristicId: OmniBudsGatt.characteristic(ear), + byteData: bytes, + ) + .timeout(responseTimeout); + final message = await response; + if (message.errorCode != 0) { + throw OmniBudsConfigurationException(message); + } + return message; + } on TimeoutException { + _timedOut.add(key); + rethrow; + } catch (_) { + // A failed write may have reached the peripheral; do not let a delayed + // reply be mistaken for a new transaction on the same endpoint. + if (!pending.isCompleted) _timedOut.add(key); + rethrow; + } finally { + _pending.remove(key); + if (!pending.isCompleted) { + pending.completeError(StateError('Request ended')); + } + } + }); + + /// Answers the device's time request using Unix milliseconds, as its app does. + /// This sets wall time; it does not establish sample-accurate stereo sync. + Future sendTime(DevicePosition ear) => _enqueue(() async { + await ble + .write( + deviceId: deviceId, + serviceId: OmniBudsGatt.service, + characteristicId: OmniBudsGatt.characteristic(ear), + byteData: encodeOmniBudsConfiguration( + 35, + 0, + value: '${DateTime.now().millisecondsSinceEpoch}', + errorCode: 0, + ), + ) + .timeout(responseTimeout); + }); + + /// Cancels notifications and rejects pending commands on physical disconnect. + Future dispose() async { + if (_closed) return; + _closed = true; + for (final pending in _pending.values) { + if (!pending.isCompleted) { + pending.completeError(StateError('OmniBuds disconnected')); + } + } + final status = _pendingStatus; + if (status != null && !status.isCompleted) { + status.completeError(StateError('OmniBuds disconnected')); + } + for (final subscription in _subscriptions) { + await subscription.cancel(); + } + _subscriptions.clear(); + _connectedEars = null; + unawaited(_messages.close()); + unawaited(_diagnostics.close()); + unawaited(_status.close()); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 92185456..e4e353ca 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -55,6 +55,7 @@ flutter: - assets/wearable_icons/open_earable_v1/ - assets/wearable_icons/open_earable_v2/ - assets/wearable_icons/open_ring/ + - assets/wearable_icons/omnibuds/ - assets/wearable_icons/cosinuss_one/ - assets/wearable_icons/polar/default/ - assets/wearable_icons/polar/watch/ diff --git a/test/omnibuds_test.dart b/test/omnibuds_test.dart new file mode 100644 index 00000000..d69ab333 --- /dev/null +++ b/test/omnibuds_test.dart @@ -0,0 +1,552 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_earable_flutter/src/omnibuds/omnibuds_transport.dart'; +import 'package:universal_ble/universal_ble.dart'; + +const left = DevicePosition.left; +const right = DevicePosition.right; + +// Synthetic wire fixtures derived from the APK's decoder, NOT hardware captures. +List data(int peripheral, int metadata, String csv) => + [peripheral, 2, csv.length, metadata, 0, ...utf8.encode(csv)]; +List ack( + int peripheral, + int endpoint, { + int error = 0, + String text = '', +}) => + [ + peripheral, + 0x11, + text.length + 2, + 0, + 0, + endpoint, + error, + ...utf8.encode(text), + ]; + +class OmniGatt extends Fake implements BleGattManager { + final characteristics = { + OmniBudsGatt.left, + OmniBudsGatt.right, + OmniBudsGatt.control, + }; + final streams = >>{}; + final writes = <(String, List)>[]; + final subscriptions = {}; + final endpointValues = <(String, int, int), String>{}; + final errors = <(int, int), int>{}; + int statusFlags = 3; + bool respond = true; + bool connected = true; + bool echo = true; + Future? discoveryGate; + Future? writeGate; + + @override + Future hasCharacteristic({ + required String deviceId, + required String serviceId, + required String characteristicId, + }) async { + await discoveryGate; + return characteristics.contains(characteristicId); + } + + @override + Future>> subscribe({ + required String deviceId, + required String serviceId, + required String characteristicId, + }) async { + subscriptions.update(characteristicId, (v) => v + 1, ifAbsent: () => 1); + return (streams[characteristicId] ??= + StreamController>.broadcast(sync: true)) + .stream; + } + + void emit(DevicePosition ear, List bytes) => + streams[OmniBudsGatt.characteristic(ear)]!.add(bytes); + @override + Future write({ + required String deviceId, + required String serviceId, + required String characteristicId, + required List byteData, + bool withoutResponse = false, + }) async { + expect(serviceId, OmniBudsGatt.service); + expect(withoutResponse, false); + writes.add((characteristicId, List.of(byteData))); + await writeGate; + if (!respond) return; + if (characteristicId == OmniBudsGatt.control) { + expect(byteData, [2]); + streams[characteristicId]!.add([3, statusFlags >> 8, statusFlags & 255]); + } else if (byteData[0] != 35) { + final key = (characteristicId, byteData[0], byteData[5]); + final error = errors[(key.$2, key.$3)] ?? 0; + if (byteData[1] == 9 && error == 0) { + endpointValues[key] = utf8.decode(byteData.sublist(6)); + } + streams[characteristicId]!.add( + ack( + key.$2, + key.$3, + error: error, + text: echo ? endpointValues[key] ?? '0' : '', + ), + ); + } + } + + @override + Future disconnect(String deviceId) async { + connected = false; + } + + @override + bool isConnected(String deviceId) => connected; + Future dispose() async { + for (final stream in streams.values) { + await stream.close(); + } + } +} + +Future<(OmniBuds, OmniGatt, WearableDisconnectNotifier)> makeWearable({ + OmniGatt? ble, +}) async { + final gatt = ble ?? OmniGatt(); + final transport = OmniBudsTransport( + ble: gatt, + deviceId: 'pair', + responseTimeout: const Duration(milliseconds: 25), + ); + await transport.initialize(); + final notifier = WearableDisconnectNotifier(); + final wearable = OmniBuds( + name: 'OmniBuds-FF03', + transport: transport, + disconnectNotifier: notifier, + ); + addTearDown(() async { + await wearable.disconnect(); + await gatt.dispose(); + }); + return (wearable, gatt, notifier); +} + +void main() { + test('frequency helpers enable the requested or maximum stream', () { + final applied = []; + final config = OmniBudsRateConfiguration( + name: 'PPG', + rates: [25, 50, 100], + apply: (v) async { + applied.add(v); + }, + reportError: (e) => fail('$e'), + ); + expect(config.setMaximumFrequency(), OmniBudsRateValue(100)); + expect(config.setFrequencyBestEffort(30), OmniBudsRateValue(50)); + expect(config.setFrequencyBestEffort(200), OmniBudsRateValue(100)); + expect(applied.every((v) => v.enabled), true); + expect(() => config.apply(OmniBudsRateValue(75)), throwsArgumentError); + }); + + test('disconnect during discovery never leaves a notification listener', + () async { + final gate = Completer(); + final gatt = OmniGatt()..discoveryGate = gate.future; + final transport = OmniBudsTransport(ble: gatt, deviceId: 'pair'); + final init = expectLater(transport.initialize(), throwsStateError); + await transport.dispose(); + gate.complete(); + await init; + expect(gatt.subscriptions, isEmpty); + await gatt.dispose(); + }); + + test('a hung GATT write times out without wedging the command queue', + () async { + final (w, g, _) = await makeWearable(); + final gate = Completer(); + g.writeGate = gate.future; + g.respond = false; + await expectLater( + w.readConfiguration(left, OmniBudsSensorKind.ppg, 1), + throwsA(isA()), + ); + gate.complete(); + await Future.delayed(Duration.zero); + g.writeGate = null; + g.respond = true; + await w.readConfiguration(right, OmniBudsSensorKind.ppg, 1); + await expectLater( + w.readConfiguration(left, OmniBudsSensorKind.ppg, 1), + throwsStateError, + ); + }); + + test('range controls encode supported physical values and reject others', + () async { + final (w, g, _) = await makeWearable(); + g.writes.clear(); + await w.setSensorRange(left, OmniBudsSensorKind.accelerometer, 16); + await w.setSensorRange(right, OmniBudsSensorKind.gyroscope, 2000); + await w.setSensorRange(left, OmniBudsSensorKind.ppg, 93); + expect(g.writes.map((w) => utf8.decode(w.$2.sublist(6))), ['3', '4', '93']); + expect( + () => w.setSensorRange(left, OmniBudsSensorKind.ppg, 100), + throwsArgumentError, + ); + expect(g.writes.length, 3); + }); + + test( + 'invalid settings produce no BLE writes and observed readback updates state', + () async { + final (w, g, _) = await makeWearable(); + g.writes.clear(); + expect( + () => w.configureSensor( + left, + OmniBudsSensorKind.ppg, + enabled: true, + frequencyHz: 999, + ), + throwsArgumentError, + ); + expect( + () => w.configureSensor( + left, + OmniBudsSensorKind.heartRate, + enabled: true, + frequencyHz: 1, + ), + throwsArgumentError, + ); + expect(g.writes, isEmpty); + g.endpointValues[(OmniBudsGatt.left, 5, 0)] = '1'; + g.endpointValues[(OmniBudsGatt.left, 5, 1)] = '50'; + await w.readConfiguration(left, OmniBudsSensorKind.ppg, 0); + await w.readConfiguration(left, OmniBudsSensorKind.ppg, 1); + expect( + (await w.sensorConfigurationStream.first).values.single, + OmniBudsRateValue(50), + ); + g.endpointValues[(OmniBudsGatt.left, 5, 1)] = '999'; + await w.readConfiguration(left, OmniBudsSensorKind.ppg, 1); + expect(await w.sensorConfigurationStream.first, isEmpty); + }); + + test('configuration bytes match the APK header and text wire format', () { + expect( + encodeOmniBudsConfiguration(0, 1, value: '3'), + [0, 9, 2, 0, 0, 1, 51], + ); + expect( + encodeOmniBudsConfiguration(5, 1, value: '100'), + [5, 9, 4, 0, 0, 1, 49, 48, 48], + ); + expect( + encodeOmniBudsConfiguration(7, 1, value: '0.2'), + [7, 9, 4, 0, 0, 1, 48, 46, 50], + ); + expect(encodeOmniBudsConfiguration(41, 0), [41, 1, 1, 0, 0, 0]); + expect( + encodeOmniBudsConfiguration(35, 0, value: '123', errorCode: 0), + [35, 9, 4, 0, 0, 0, 0, 49, 50, 51], + ); + expect(() => encodeOmniBudsConfiguration(256, 0), throwsArgumentError); + expect( + () => encodeOmniBudsConfiguration(1, 0, value: 'a' * 255), + throwsArgumentError, + ); + }); + + test('all raw rate options encode to the vendor settings', () { + expect( + OmniBudsSensorKind.accelerometer.rates + .map(OmniBudsSensorKind.accelerometer.encodeRate), + ['0', '1', '2', '3'], + ); + expect(OmniBudsSensorKind.gyroscope.rates, [10, 25, 50, 100]); + expect(OmniBudsSensorKind.magnetometer.rates, [10, 20, 50, 100]); + expect( + OmniBudsSensorKind.ppg.rates.map(OmniBudsSensorKind.ppg.encodeRate), + ['25', '50', '100'], + ); + expect(OmniBudsSensorKind.temperature.encodeRate(0.2), '0.2'); + }); + + test( + 'negative motion values, units, raw readings and ear identity survive decoding', + () { + final msg = + OmniBudsMessage.decode(right, data(0, 0x30, '1000,-16384,0,16384')); + final value = + decodeOmniBudsSamples(msg, OmniBudsSensorKind.accelerometer).single; + expect(value.values[0], closeTo(-0.999424, 1e-9)); + expect(value.rawValues, [-16384, 0, 16384]); + expect(value.message.ear, right); + expect(value.timestamp, 1000000); + final gyro = decodeOmniBudsSamples( + OmniBudsMessage.decode(left, data(1, 0x34, '1000,-100,100,0')), + OmniBudsSensorKind.gyroscope, + ).single; + expect(gyro.values, [closeTo(-7, 1e-9), closeTo(7, 1e-9), 0]); + final mag = decodeOmniBudsSamples( + OmniBudsMessage.decode(left, data(2, 0x30, '1000,-1000,1000,0')), + OmniBudsSensorKind.magnetometer, + ).single; + expect(mag.values, [-1.5, 1.5, 0]); + }); + + test('batches retain every reading at each known motion and PPG rate', () { + for (final kind in [ + OmniBudsSensorKind.accelerometer, + OmniBudsSensorKind.gyroscope, + OmniBudsSensorKind.magnetometer, + OmniBudsSensorKind.ppg, + ]) { + for (var code = 0; code < kind.rates.length; code++) { + final metadata = kind == OmniBudsSensorKind.ppg + ? kind.rates[code].toInt() + : code << 4; + final packet = OmniBudsMessage.decode( + left, + data(kind.peripheralId, metadata, '1000,1,2,3,4,5,6,7,8,9'), + ); + final samples = decodeOmniBudsSamples(packet, kind); + expect(samples.length, 3); + expect(samples.map((v) => v.rawValues), [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ]); + final rate = kind.peripheralId < 2 && code == 0 ? 12 : kind.rates[code]; + expect(samples[2].timestamp, 1000000 + (2000000 / rate).round()); + expect(samples[2].sampleIndex, 2); + } + } + }); + + test( + 'temperature and all four supported vital outputs retain device timestamp', + () { + for (final kind + in OmniBudsSensorKind.values.where((k) => k.peripheralId >= 7)) { + final samples = decodeOmniBudsSamples( + OmniBudsMessage.decode( + left, + data(kind.peripheralId, 0, '1728000000000,36.75'), + ), + kind, + ); + expect(samples.single.timestamp, 1728000000000000); + expect(samples.single.values, [36.75]); + } + }); + + test('malformed packets and unknown metadata do not invent sensor samples', + () { + for (final bytes in [ + [], + [0, 1, 2, 3, 4], + data(0, 0x40, '1000,1,2,3'), + data(0, 0x3f, '1000,1,2,3'), + data(0, 0x30, '1000,1,2'), + data(0, 0x30, '1000,NaN,1,2'), + data(0, 0x30, 'oops,1,2,3'), + ]) { + expect( + () => decodeOmniBudsSamples( + OmniBudsMessage.decode(left, bytes), + OmniBudsSensorKind.accelerometer, + ), + throwsFormatException, + ); + } + expect( + () => decodeOmniBudsSamples( + OmniBudsMessage.decode(left, data(5, 0, '1,1,2,3')), + OmniBudsSensorKind.ppg, + ), + throwsFormatException, + ); + }); + + test( + 'one connection exposes both sides and shares one subscription per channel', + () async { + final (w, g, _) = await makeWearable(); + expect(w.sensors.length, 18); + expect(w.connectedEars, {left, right}); + expect(w.getWearableIconPath(), endsWith('/pair.svg')); + final ls = w.sensors + .firstWhere((s) => s.ear == left && s.kind == OmniBudsSensorKind.ppg); + final rs = w.sensors + .firstWhere((s) => s.ear == right && s.kind == OmniBudsSensorKind.ppg); + final l = ls.sensorStream.first; + final r = rs.sensorStream.first; + g.emit(right, data(5, 100, '1000,4,5,6')); + g.emit(left, data(5, 100, '1000,1,2,3')); + expect((await l).values, [1, 2, 3]); + expect((await r).values, [4, 5, 6]); + expect(g.subscriptions[OmniBudsGatt.left], 1); + expect(g.subscriptions[OmniBudsGatt.right], 1); + g.statusFlags = 1; + await w.refreshConnectionStatus(); + expect(w.getWearableIconPath(), endsWith('/left.svg')); + expect( + w.getWearableIconPath(variant: WearableIconVariant.right), + endsWith('/right.svg'), + ); + }); + + test('one-ear firmware works without the optional status characteristic', + () async { + final g = OmniGatt() + ..characteristics.remove(OmniBudsGatt.right) + ..characteristics.remove(OmniBudsGatt.control); + final (w, _, _) = await makeWearable(ble: g); + expect(w.sensors.length, 9); + expect(w.connectedEars, isNull); + expect(w.getWearableIconPath(), endsWith('/left.svg')); + expect(g.writes, isEmpty); // Connecting never starts or stops sensing. + }); + + test( + 'configuration transactions serialize and ACK-only replies update UI state', + () async { + final (w, g, _) = await makeWearable(); + g.echo = false; + g.writes.clear(); + expect(await w.sensorConfigurationStream.first, isEmpty); + await Future.wait([ + w.configureSensor( + left, + OmniBudsSensorKind.ppg, + enabled: true, + frequencyHz: 100, + ), + w.configureSensor( + right, + OmniBudsSensorKind.accelerometer, + enabled: true, + frequencyHz: 50, + ), + ]); + expect(g.writes.map((v) => v.$2), [ + [5, 9, 2, 0, 0, 0, 48], + [5, 9, 4, 0, 0, 1, 49, 48, 48], + [5, 9, 2, 0, 0, 0, 49], + [0, 9, 2, 0, 0, 0, 48], + [0, 9, 2, 0, 0, 1, 50], + [0, 9, 2, 0, 0, 0, 49], + ]); + final state = await w.sensorConfigurationStream.first; + expect( + state.values.whereType().every((v) => v.enabled), + true, + ); + expect(state.length, 2); + }); + + test( + 'failed rate change leaves sensor off and never reports successful start', + () async { + final (w, g, _) = await makeWearable(); + g.echo = false; + g.writes.clear(); + g.errors[(5, 1)] = 4; + await expectLater( + w.configureSensor( + left, + OmniBudsSensorKind.ppg, + enabled: true, + frequencyHz: 50, + ), + throwsA(isA()), + ); + expect(g.writes.length, 2); + final state = await w.sensorConfigurationStream.first; + expect((state.values.single as OmniBudsRateValue).enabled, false); + }); + + test( + 'timed-out endpoint is quarantined and late responses cannot confirm a retry', + () async { + final (w, g, _) = await makeWearable(); + g.respond = false; + await expectLater( + w.readConfiguration(left, OmniBudsSensorKind.ppg, 1), + throwsA(isA()), + ); + g.respond = true; + g.emit(left, ack(5, 1, text: '100')); + await expectLater( + w.readConfiguration(left, OmniBudsSensorKind.ppg, 1), + throwsStateError, + ); + // An independent ear/endpoint remains usable. + await w.readConfiguration(right, OmniBudsSensorKind.ppg, 1); + }); + + test( + 'disconnect rejects pending commands, closes streams and cancels BLE listeners', + () async { + final (w, g, n) = await makeWearable(); + g.respond = false; + final pending = w.readConfiguration(left, OmniBudsSensorKind.ppg, 1); + final checked = expectLater(pending, throwsStateError); + await Future.delayed(Duration.zero); + n.notifyListeners(); + await checked; + await Future.delayed(Duration.zero); + expect(g.streams.values.any((s) => s.hasListener), false); + }); + + test('time requests are answered in Unix ms without switching device modes', + () async { + final (w, g, _) = await makeWearable(); + g.writes.clear(); + g.emit(left, [35, 1, 1, 0, 0, 0]); + await Future.delayed(Duration.zero); + final bytes = g.writes.single.$2; + expect(bytes.take(2), [35, 9]); + expect(bytes[6], 0); + final time = int.parse(utf8.decode(bytes.sublist(7))); + expect( + (DateTime.now().millisecondsSinceEpoch - time).abs(), + lessThan(1000), + ); + expect(w.connectedEars, {left, right}); + }); + + test('factory requires the vendor service and excludes charging cases', + () async { + final factory = OmniBudsFactory(); + DiscoveredDevice device(String name) => DiscoveredDevice( + id: 'id', + name: name, + manufacturerData: Uint8List(0), + rssi: -50, + serviceUuids: [], + ); + final services = [BleService(OmniBudsGatt.service, [])]; + expect(await factory.matches(device('OmniBuds-FF03'), services), true); + expect( + await factory.matches(device('OmniBuds-Case-FF03'), services), + false, + ); + expect(await factory.matches(device('OmniBuds-FF03'), []), false); + }); +}