From 5b30cf9819533dc1e9401cb771a7f5de9ef58732 Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:31:54 +0100 Subject: [PATCH 1/6] lib/src/models/capabilities/time_synchronizable.dart: added time sync capability --- lib/src/models/capabilities/time_synchronizable.dart | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 lib/src/models/capabilities/time_synchronizable.dart diff --git a/lib/src/models/capabilities/time_synchronizable.dart b/lib/src/models/capabilities/time_synchronizable.dart new file mode 100644 index 0000000..e727839 --- /dev/null +++ b/lib/src/models/capabilities/time_synchronizable.dart @@ -0,0 +1,6 @@ +/// Defines an interface for objects that can be synchronized with a time source. +abstract class TimeSynchronizable { + bool get isTimeSynchronized; + + Future synchronizeTime(); +} From d01f94fc564c511373f06325b4d18ff8a87668b1 Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:35:11 +0100 Subject: [PATCH 2/6] lib/open_earable_flutter.dart: export time sync capability --- lib/open_earable_flutter.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/open_earable_flutter.dart b/lib/open_earable_flutter.dart index b215bf2..48bb10f 100644 --- a/lib/open_earable_flutter.dart +++ b/lib/open_earable_flutter.dart @@ -66,6 +66,7 @@ export 'src/models/capabilities/button_manager.dart'; export 'src/models/wearable_factory.dart'; export 'src/models/capabilities/system_device.dart'; export 'src/managers/ble_gatt_manager.dart'; +export 'src/models/capabilities/time_synchronizable.dart'; export 'src/fota/fota.dart'; From bddc2963511f72cb60327e10d81a704a18d06ffd Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:35:46 +0100 Subject: [PATCH 3/6] lib/src/models/devices/open_earable_v2.dart: implemented time sync for open earable 2 --- lib/src/models/devices/open_earable_v2.dart | 178 +++++++++++++++++++- 1 file changed, 177 insertions(+), 1 deletion(-) diff --git a/lib/src/models/devices/open_earable_v2.dart b/lib/src/models/devices/open_earable_v2.dart index da16492..6ea6396 100644 --- a/lib/src/models/devices/open_earable_v2.dart +++ b/lib/src/models/devices/open_earable_v2.dart @@ -36,6 +36,12 @@ const String _audioModeCharacteristicUuid = const String _buttonServiceUuid = "29c10bdc-4773-11ee-be56-0242ac120002"; const String _buttonCharacteristicUuid = "29c10f38-4773-11ee-be56-0242ac120002"; +const String _timeSynchronizationServiceUuid = "2e04cbf7-939d-4be5-823e-271838b75259"; +const String _timeSyncTimeMappingCharacteristicUuid = + "2e04cbf8-939d-4be5-823e-271838b75259"; +const String _timeSyncRttCharacteristicUuid = + "2e04cbf9-939d-4be5-823e-271838b75259"; + final VersionConstraint _versionConstraint = VersionConstraint.parse(">=2.1.0 <2.3.0"); @@ -70,7 +76,8 @@ class OpenEarableV2 extends Wearable EdgeRecorderManager, ButtonManager, StereoDevice, - SystemDevice { + SystemDevice, + TimeSynchronizable { static const String deviceInfoServiceUuid = "45622510-6468-465a-b141-0b9b0f96b468"; static const String ledServiceUuid = "81040a2e-4819-11ee-be56-0242ac120002"; @@ -809,6 +816,74 @@ class OpenEarableV2 extends Wearable _pairedDevice?.unpair(); _pairedDevice = null; } + + // MARK: TimeSynchronizable + + @override + bool get isTimeSynchronized { + // Placeholder implementation + return true; + } + + @override + Future synchronizeTime() async { + logger.i("Synchronizing time with OpenEarable V2 device..."); + + _bleManager.subscribe( + deviceId: deviceId, + serviceId: _timeSynchronizationServiceUuid, + characteristicId: _timeSyncRttCharacteristicUuid, + ).listen((data) { + final t4 = DateTime.now().microsecondsSinceEpoch; + final pkt = _SyncTimePacket.fromBytes(Uint8List.fromList(data)); + + if (pkt.op == _TimeSyncOperation.response) { + logger.d("Received time sync response packet: $pkt"); + + final t1 = pkt.timePhoneSend; // request send time on phone + final t3 = pkt.timeDeviceSend; // device send + + // Approximate the phone time that corresponds to device send (T3) + // Use midpoint between T1 and T4 as estimate for when the device was "in the middle": + final unixAtMid = t1 + ((t4 - t1) ~/ 2); + + // Use device send time as devTimeUs + final devTimeUs = t3; + + final mapping = _SyncedTimeMapping( + deviceTime: devTimeUs, + unixTime: unixAtMid, + ); + + logger.i( + "Writing time mapping: devTime=$devTimeUs, unixTime=$unixAtMid", + ); + + _bleManager.write( + deviceId: deviceId, + serviceId: _timeSynchronizationServiceUuid, + characteristicId: _timeSyncTimeMappingCharacteristicUuid, + byteData: mapping.toBytes(), + ); + } + }); + + _bleManager.write( + deviceId: deviceId, + serviceId: _timeSynchronizationServiceUuid, + characteristicId: _timeSyncRttCharacteristicUuid, + byteData: _SyncTimePacket( + version: 1, + op: _TimeSyncOperation.request, + seq: 0, + timePhoneSend: DateTime.now().microsecondsSinceEpoch, + timeDeviceReceive: 0, + timeDeviceSend: 0, + ).toBytes(), + ); + await Future.delayed(const Duration(seconds: 1)); + logger.i("Time synchronized."); + } } // MARK: OpenEarableV2Mic @@ -838,3 +913,104 @@ class OpenEarableV2PairingRule extends PairingRule { return left.name == right.name; } } + +// MARK: OpenEarable Sync Time packet + +enum _TimeSyncOperation { + request(0x00), + response(0x01); + + final int value; + const _TimeSyncOperation(this.value); +} + +class _SyncTimePacket { + final int version; + final _TimeSyncOperation op; + final int seq; + final int timePhoneSend; + final int timeDeviceReceive; + final int timeDeviceSend; + + factory _SyncTimePacket.fromBytes(Uint8List bytes) { + if (bytes.length < 15) { + throw ArgumentError.value( + bytes, + 'bytes', + 'Byte array too short to be a valid SyncTimePacket', + ); + } + + ByteData bd = ByteData.sublistView(bytes); + int version = bd.getUint8(0); + _TimeSyncOperation op = + _TimeSyncOperation.values.firstWhere((e) => e.value == bd.getUint8(1)); + int seq = bd.getUint16(2, Endian.little); + int timePhoneSend = bd.getUint64(4, Endian.little); + int timeDeviceReceive = bd.getUint64(12, Endian.little); + int timeDeviceSend = bd.getUint64(20, Endian.little); + + return _SyncTimePacket( + version: version, + op: op, + seq: seq, + timePhoneSend: timePhoneSend, + timeDeviceReceive: timeDeviceReceive, + timeDeviceSend: timeDeviceSend, + ); + } + + const _SyncTimePacket({ + required this.version, + required this.op, + required this.seq, + required this.timePhoneSend, + required this.timeDeviceReceive, + required this.timeDeviceSend, + }); + + /// Serialize packet to bytes. + /// Layout (little-endian): + /// [0] : version (1 byte) + /// [1] : operation (1 byte) + /// [2] : sequence (2 byte) + /// [3..6] : timePhoneSend (uint64) + /// [7..10]: timeDeviceReceive (uint64) + /// [11..14]: timeDeviceSend (uint64) + Uint8List toBytes() { + if (seq < 0 || seq > 0xFFFF) { + throw ArgumentError.value(seq, 'seq', 'Must fit in two bytes (0..65535)'); + } + + final ByteData bd = ByteData(28); + bd.setUint8(0, version & 0xFF); + bd.setUint8(1, op.value & 0xFF); + bd.setUint16(2, seq & 0xFFFF, Endian.little); + bd.setUint64(4, timePhoneSend & 0xFFFFFFFFFFFFFFFF, Endian.little); + bd.setUint64(12, timeDeviceReceive & 0xFFFFFFFFFFFFFFFF, Endian.little); + bd.setUint64(20, timeDeviceSend & 0xFFFFFFFFFFFFFFFF, Endian.little); + return bd.buffer.asUint8List(); + } + + @override + String toString() { + return '_SyncTimePacket(version: $version, op: $op, seq: $seq, timePhoneSend: $timePhoneSend, timeDeviceReceive: $timeDeviceReceive, timeDeviceSend: $timeDeviceSend)'; + } +} + +class _SyncedTimeMapping { + final int deviceTime; + final int unixTime; + + const _SyncedTimeMapping({ + required this.deviceTime, + required this.unixTime, + }); + + Uint8List toBytes() { + final ByteData bd = ByteData(16); + bd.setUint64(0, deviceTime & 0xFFFFFFFFFFFFFFFF, Endian.little); + bd.setUint64(8, unixTime & 0xFFFFFFFFFFFFFFFF, Endian.little); + return bd.buffer.asUint8List(); + } +} From a193c1e93cb82e0c4274ff43f63de267eb6e30e4 Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Sun, 7 Dec 2025 00:08:35 +0100 Subject: [PATCH 4/6] lib/src/models/devices/open_earable_v2.dart: send offset instead of time mapping --- lib/src/models/devices/open_earable_v2.dart | 43 ++++++--------------- 1 file changed, 11 insertions(+), 32 deletions(-) diff --git a/lib/src/models/devices/open_earable_v2.dart b/lib/src/models/devices/open_earable_v2.dart index 6ea6396..3c59581 100644 --- a/lib/src/models/devices/open_earable_v2.dart +++ b/lib/src/models/devices/open_earable_v2.dart @@ -840,30 +840,26 @@ class OpenEarableV2 extends Wearable if (pkt.op == _TimeSyncOperation.response) { logger.d("Received time sync response packet: $pkt"); - final t1 = pkt.timePhoneSend; // request send time on phone - final t3 = pkt.timeDeviceSend; // device send + final t1 = pkt.timePhoneSend; // phone send timestamp + final t3 = pkt.timeDeviceSend; // device send timestamp - // Approximate the phone time that corresponds to device send (T3) - // Use midpoint between T1 and T4 as estimate for when the device was "in the middle": - final unixAtMid = t1 + ((t4 - t1) ~/ 2); + // Estimate Unix time at moment device sent response + final unixAtT3 = t1 + ((t4 - t1) ~/ 2); - // Use device send time as devTimeUs - final devTimeUs = t3; + // offset = unix_time - device_time + final offset = unixAtT3 - t3; - final mapping = _SyncedTimeMapping( - deviceTime: devTimeUs, - unixTime: unixAtMid, - ); + logger.i("Calculated offset to send: $offset µs"); - logger.i( - "Writing time mapping: devTime=$devTimeUs, unixTime=$unixAtMid", - ); + // Convert to bytes (signed int64) + final offsetBytes = ByteData(8)..setInt64(0, offset, Endian.little); + // Write the offset to the device _bleManager.write( deviceId: deviceId, serviceId: _timeSynchronizationServiceUuid, characteristicId: _timeSyncTimeMappingCharacteristicUuid, - byteData: mapping.toBytes(), + byteData: offsetBytes.buffer.asUint8List(), ); } }); @@ -997,20 +993,3 @@ class _SyncTimePacket { return '_SyncTimePacket(version: $version, op: $op, seq: $seq, timePhoneSend: $timePhoneSend, timeDeviceReceive: $timeDeviceReceive, timeDeviceSend: $timeDeviceSend)'; } } - -class _SyncedTimeMapping { - final int deviceTime; - final int unixTime; - - const _SyncedTimeMapping({ - required this.deviceTime, - required this.unixTime, - }); - - Uint8List toBytes() { - final ByteData bd = ByteData(16); - bd.setUint64(0, deviceTime & 0xFFFFFFFFFFFFFFFF, Endian.little); - bd.setUint64(8, unixTime & 0xFFFFFFFFFFFFFFFF, Endian.little); - return bd.buffer.asUint8List(); - } -} From 02025c7718567cd11ab44245258fe5b0397d084b Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:45:54 +0100 Subject: [PATCH 5/6] lib/src/models/devices/open_earable_v2.dart: calculate offset based on multiple rtts --- lib/src/models/devices/open_earable_v2.dart | 134 +++++++++++++++----- 1 file changed, 100 insertions(+), 34 deletions(-) diff --git a/lib/src/models/devices/open_earable_v2.dart b/lib/src/models/devices/open_earable_v2.dart index 3c59581..867b99c 100644 --- a/lib/src/models/devices/open_earable_v2.dart +++ b/lib/src/models/devices/open_earable_v2.dart @@ -825,60 +825,126 @@ class OpenEarableV2 extends Wearable return true; } + /// How many RTT samples to collect before computing the median offset. + static const int _timeSyncSampleCount = 7; + @override Future synchronizeTime() async { logger.i("Synchronizing time with OpenEarable V2 device..."); - _bleManager.subscribe( - deviceId: deviceId, - serviceId: _timeSynchronizationServiceUuid, - characteristicId: _timeSyncRttCharacteristicUuid, - ).listen((data) { - final t4 = DateTime.now().microsecondsSinceEpoch; - final pkt = _SyncTimePacket.fromBytes(Uint8List.fromList(data)); + // Will complete when we have enough samples and wrote the final offset. + final completer = Completer(); + + // Collected offset estimates (µs). + final offsets = []; + + // Subscribe to RTT responses + late final StreamSubscription> rttSub; + rttSub = _bleManager + .subscribe( + deviceId: deviceId, + serviceId: _timeSynchronizationServiceUuid, + characteristicId: _timeSyncRttCharacteristicUuid, + ) + .listen( + (data) async { + final t4 = DateTime.now().microsecondsSinceEpoch; + final pkt = _SyncTimePacket.fromBytes(Uint8List.fromList(data)); + + if (pkt.op != _TimeSyncOperation.response) { + return; // ignore anything that's not a response + } - if (pkt.op == _TimeSyncOperation.response) { logger.d("Received time sync response packet: $pkt"); - final t1 = pkt.timePhoneSend; // phone send timestamp - final t3 = pkt.timeDeviceSend; // device send timestamp + final t1 = pkt.timePhoneSend; // phone send timestamp (µs) + final t3 = pkt.timeDeviceSend; // device send timestamp (µs, device clock) - // Estimate Unix time at moment device sent response + // Estimate Unix time at the moment the device sent the response. + // Use midpoint between T1 and T4 as an estimate of when the device was "in the middle". final unixAtT3 = t1 + ((t4 - t1) ~/ 2); // offset = unix_time - device_time final offset = unixAtT3 - t3; + offsets.add(offset); - logger.i("Calculated offset to send: $offset µs"); + logger.i("Time sync sample #${offsets.length}: offset=$offset µs"); - // Convert to bytes (signed int64) - final offsetBytes = ByteData(8)..setInt64(0, offset, Endian.little); + if (offsets.length >= _timeSyncSampleCount && !completer.isCompleted) { + await rttSub.cancel(); - // Write the offset to the device - _bleManager.write( - deviceId: deviceId, - serviceId: _timeSynchronizationServiceUuid, - characteristicId: _timeSyncTimeMappingCharacteristicUuid, - byteData: offsetBytes.buffer.asUint8List(), - ); - } - }); + final medianOffset = _computeMedian(offsets); + logger.i( + "Collected ${offsets.length} samples. Median offset: $medianOffset µs", + ); - _bleManager.write( - deviceId: deviceId, - serviceId: _timeSynchronizationServiceUuid, - characteristicId: _timeSyncRttCharacteristicUuid, - byteData: _SyncTimePacket( + // Convert to bytes (signed int64, little endian) + final offsetBytes = ByteData(8) + ..setInt64(0, medianOffset, Endian.little); + + // Write the final median offset to the device + await _bleManager.write( + deviceId: deviceId, + serviceId: _timeSynchronizationServiceUuid, + characteristicId: _timeSyncTimeMappingCharacteristicUuid, + byteData: offsetBytes.buffer.asUint8List(), + ); + + logger.i("Median offset written to device. Time sync complete."); + + completer.complete(); + } + }, + onError: (error, stack) async { + logger.e("Error during time sync subscription $error, $stack",); + if (!completer.isCompleted) { + completer.completeError(error, stack); + } + }, + ); + + // Send multiple RTT requests. + // Each request carries its own send timestamp (T1) inside the packet. + for (var i = 0; i < _timeSyncSampleCount; i++) { + final t1 = DateTime.now().microsecondsSinceEpoch; + + final request = _SyncTimePacket( version: 1, op: _TimeSyncOperation.request, - seq: 0, - timePhoneSend: DateTime.now().microsecondsSinceEpoch, + seq: i, // optional: use i to correlate if you want + timePhoneSend: t1, timeDeviceReceive: 0, timeDeviceSend: 0, - ).toBytes(), - ); - await Future.delayed(const Duration(seconds: 1)); - logger.i("Time synchronized."); + ); + + logger.d("Sending time sync request seq=$i, t1=$t1"); + + await _bleManager.write( + deviceId: deviceId, + serviceId: _timeSynchronizationServiceUuid, + characteristicId: _timeSyncRttCharacteristicUuid, + byteData: request.toBytes(), + ); + + // Short delay between requests to avoid overloading BLE + await Future.delayed(const Duration(milliseconds: 50)); + } + + // Wait until enough responses arrive and median is written + await completer.future; + } + + /// Compute the median of a non-empty list of integers. + int _computeMedian(List values) { + final sorted = List.from(values)..sort(); + final mid = sorted.length ~/ 2; + + if (sorted.length.isOdd) { + return sorted[mid]; + } else { + // average of the two middle values (integer division) + return ((sorted[mid - 1] + sorted[mid]) ~/ 2); + } } } From 535ae6cca5b829a266a9b8e140bf687983028ca5 Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:56:12 +0100 Subject: [PATCH 6/6] lib/src/models/devices/open_earable_v2.dart: removed unused masks --- lib/src/models/devices/open_earable_v2.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/src/models/devices/open_earable_v2.dart b/lib/src/models/devices/open_earable_v2.dart index 867b99c..d426e97 100644 --- a/lib/src/models/devices/open_earable_v2.dart +++ b/lib/src/models/devices/open_earable_v2.dart @@ -1045,12 +1045,12 @@ class _SyncTimePacket { } final ByteData bd = ByteData(28); - bd.setUint8(0, version & 0xFF); - bd.setUint8(1, op.value & 0xFF); - bd.setUint16(2, seq & 0xFFFF, Endian.little); - bd.setUint64(4, timePhoneSend & 0xFFFFFFFFFFFFFFFF, Endian.little); - bd.setUint64(12, timeDeviceReceive & 0xFFFFFFFFFFFFFFFF, Endian.little); - bd.setUint64(20, timeDeviceSend & 0xFFFFFFFFFFFFFFFF, Endian.little); + bd.setUint8(0, version); + bd.setUint8(1, op.value); + bd.setUint16(2, seq, Endian.little); + bd.setUint64(4, timePhoneSend, Endian.little); + bd.setUint64(12, timeDeviceReceive, Endian.little); + bd.setUint64(20, timeDeviceSend, Endian.little); return bd.buffer.asUint8List(); }