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'; 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(); +} diff --git a/lib/src/models/devices/open_earable_v2.dart b/lib/src/models/devices/open_earable_v2.dart index da16492..d426e97 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,136 @@ class OpenEarableV2 extends Wearable _pairedDevice?.unpair(); _pairedDevice = null; } + + // MARK: TimeSynchronizable + + @override + bool get isTimeSynchronized { + // Placeholder implementation + 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..."); + + // 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 + } + + logger.d("Received time sync response packet: $pkt"); + + final t1 = pkt.timePhoneSend; // phone send timestamp (µs) + final t3 = pkt.timeDeviceSend; // device send timestamp (µs, device clock) + + // 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("Time sync sample #${offsets.length}: offset=$offset µs"); + + if (offsets.length >= _timeSyncSampleCount && !completer.isCompleted) { + await rttSub.cancel(); + + final medianOffset = _computeMedian(offsets); + logger.i( + "Collected ${offsets.length} samples. Median offset: $medianOffset µs", + ); + + // 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: i, // optional: use i to correlate if you want + timePhoneSend: t1, + timeDeviceReceive: 0, + timeDeviceSend: 0, + ); + + 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); + } + } } // MARK: OpenEarableV2Mic @@ -838,3 +975,87 @@ 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); + 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(); + } + + @override + String toString() { + return '_SyncTimePacket(version: $version, op: $op, seq: $seq, timePhoneSend: $timePhoneSend, timeDeviceReceive: $timeDeviceReceive, timeDeviceSend: $timeDeviceSend)'; + } +}