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 @@
+
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 @@
+
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 @@
+
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