Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/open_earable_flutter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
6 changes: 6 additions & 0 deletions lib/src/models/capabilities/time_synchronizable.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/// Defines an interface for objects that can be synchronized with a time source.
abstract class TimeSynchronizable {
bool get isTimeSynchronized;

Future<void> synchronizeTime();
}
223 changes: 222 additions & 1 deletion lib/src/models/devices/open_earable_v2.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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<void> 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<void>();

// Collected offset estimates (µs).
final offsets = <int>[];

// Subscribe to RTT responses
late final StreamSubscription<List<int>> 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<int> values) {
final sorted = List<int>.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
Expand Down Expand Up @@ -838,3 +975,87 @@ class OpenEarableV2PairingRule extends PairingRule<OpenEarableV2> {
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)';
}
}