diff --git a/CHANGELOG.md b/CHANGELOG.md index 048d3f6..4cf5d12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 2.3.3 + +* renamed TauRing to OpenRing +* added support for OpenRing temperature sensors (`temp0`, `temp1`, `temp2`) as one 3-channel `Temperature` sensor (`°C`) with software-only on/off control + ## 2.3.2 * fixed some bugs with Esense devices @@ -73,4 +78,4 @@ Connecting to earable now retries after first failure. ## 0.0.1 -* TODO: Describe initial release. \ No newline at end of file +* TODO: Describe initial release. diff --git a/assets/wearable_icons/open_earable_v2/left.png b/assets/wearable_icons/open_earable_v2/left.png new file mode 100644 index 0000000..865d882 Binary files /dev/null and b/assets/wearable_icons/open_earable_v2/left.png differ diff --git a/assets/wearable_icons/open_earable_v2/pair.png b/assets/wearable_icons/open_earable_v2/pair.png new file mode 100644 index 0000000..cd34dc9 Binary files /dev/null and b/assets/wearable_icons/open_earable_v2/pair.png differ diff --git a/assets/wearable_icons/open_earable_v2/right.png b/assets/wearable_icons/open_earable_v2/right.png new file mode 100644 index 0000000..5f8382f Binary files /dev/null and b/assets/wearable_icons/open_earable_v2/right.png differ diff --git a/lib/open_earable_flutter.dart b/lib/open_earable_flutter.dart index b357438..42b0da0 100644 --- a/lib/open_earable_flutter.dart +++ b/lib/open_earable_flutter.dart @@ -21,7 +21,7 @@ import 'src/managers/wearable_disconnect_notifier.dart'; 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/tau_ring_factory.dart'; +import 'src/models/devices/open_ring_factory.dart'; import 'src/models/devices/wearable.dart'; export 'src/models/devices/discovered_device.dart'; @@ -111,7 +111,7 @@ class WearableManager { CosinussOneFactory(), PolarFactory(), DevKitFactory(), - TauRingFactory(), + OpenRingFactory(), EsenseFactory(), ]; diff --git a/lib/src/managers/open_ring_sensor_handler.dart b/lib/src/managers/open_ring_sensor_handler.dart new file mode 100644 index 0000000..4c8df9f --- /dev/null +++ b/lib/src/managers/open_ring_sensor_handler.dart @@ -0,0 +1,507 @@ +import 'dart:async'; +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:open_earable_flutter/src/models/devices/open_ring.dart'; + +import '../../open_earable_flutter.dart'; +import '../utils/sensor_value_parser/sensor_value_parser.dart'; +import 'sensor_handler.dart'; + +class OpenRingSensorHandler extends SensorHandler { + final DiscoveredDevice _discoveredDevice; + final BleGattManager _bleManager; + final SensorValueParser _sensorValueParser; + + static const int _defaultSampleDelayMs = 10; + static const int _minSampleDelayMs = 2; + static const int _maxSampleDelayMs = 20; + static const int _maxScheduleLagMs = 80; + static const double _delayAlpha = 0.22; + static const double _backlogCompressionPerPacket = 0.18; + static const int _ppgRestartDelayMs = 140; + static const int _ppgBusyRetryDelayMs = 320; + static const int _maxPpgBusyRetries = 2; + static const Set _pacedStreamingCommands = { + OpenRingGatt.cmdIMU, + OpenRingGatt.cmdPPGQ2, + }; + + Stream>? _sensorDataStream; + List? _lastPpgStartPayload; + int _ppgBusyRetryCount = 0; + Timer? _ppgBusyRetryTimer; + bool _temperatureStreamEnabled = true; + final Set _activeRealtimeStreamingCommands = {}; + + OpenRingSensorHandler({ + required DiscoveredDevice discoveredDevice, + required BleGattManager bleManager, + required SensorValueParser sensorValueParser, + }) : _discoveredDevice = discoveredDevice, + _bleManager = bleManager, + _sensorValueParser = sensorValueParser; + + @override + Stream> subscribeToSensorData(int sensorId) { + if (!_bleManager.isConnected(_discoveredDevice.id)) { + throw Exception("Can't subscribe to sensor data. Earable not connected"); + } + + _sensorDataStream ??= _createSensorDataStream(); + + return _sensorDataStream!.where((data) { + final dynamic cmd = data['cmd']; + return cmd is int && cmd == sensorId; + }); + } + + @override + Future writeSensorConfig(OpenRingSensorConfig sensorConfig) async { + if (!_bleManager.isConnected(_discoveredDevice.id)) { + throw Exception("Can't write sensor config. Earable not connected"); + } + + final bool isRealtimeStreamingStart = + _isRealtimeStreamingStart(sensorConfig); + final bool isRealtimeStreamingStop = _isRealtimeStreamingStop(sensorConfig); + + final bool isPpgCmd = sensorConfig.cmd == OpenRingGatt.cmdPPGQ2; + final bool isPpgStart = isPpgCmd && + sensorConfig.payload.isNotEmpty && + sensorConfig.payload[0] == 0x00; + final bool isPpgStop = isPpgCmd && + sensorConfig.payload.isNotEmpty && + sensorConfig.payload[0] == 0x06; + + if (isPpgStart) { + _lastPpgStartPayload = List.from(sensorConfig.payload); + _ppgBusyRetryCount = 0; + _cancelPpgBusyRetry(); + await _writeCommand(sensorConfig); + if (isRealtimeStreamingStart) { + _activeRealtimeStreamingCommands.add(sensorConfig.cmd); + } + return; + } + + if (isPpgStop) { + _lastPpgStartPayload = null; + _ppgBusyRetryCount = 0; + _cancelPpgBusyRetry(); + } + + await _writeCommand(sensorConfig); + if (isRealtimeStreamingStart) { + _activeRealtimeStreamingCommands.add(sensorConfig.cmd); + } else if (isRealtimeStreamingStop) { + _activeRealtimeStreamingCommands.remove(sensorConfig.cmd); + } + } + + Future>> _parseData(List data) async { + final byteData = ByteData.sublistView(Uint8List.fromList(data)); + return _sensorValueParser.parse(byteData, []); + } + + void setTemperatureStreamEnabled(bool enabled) { + _temperatureStreamEnabled = enabled; + logger.d('OpenRing software toggle: temperatureStream=$enabled'); + } + + bool get hasActiveRealtimeStreaming => + _activeRealtimeStreamingCommands.isNotEmpty; + + Map _filterTemperature(Map sample) { + if (!_temperatureStreamEnabled) { + sample.remove('Temperature'); + } + return sample; + } + + Stream> _createSensorDataStream() { + late final StreamController> streamController; + // ignore: cancel_subscriptions + StreamSubscription>? bleSubscription; + + // Monotonic clock for all timing decisions. + final clock = Stopwatch()..start(); + final int wallClockAnchorMs = DateTime.now().millisecondsSinceEpoch; + + int monotonicToEpochMs(int monotonicMs) { + return wallClockAnchorMs + monotonicMs; + } + + // Keep command families independent (PPG should not stall IMU). + final Map> processingQueueByCmd = {}; + + final Map lastArrivalByCmd = {}; + final Map delayEstimateByCmd = {}; + final Map nextDueByCmd = {}; + final Map emittedTimestampByCmd = {}; + final Map pendingPacketsByCmd = {}; + + int resolveStepMs({ + required int cmd, + required int sampleCount, + required int arrivalMs, + }) { + double delayMs = + delayEstimateByCmd[cmd] ?? _defaultSampleDelayMs.toDouble(); + + final int? lastArrival = lastArrivalByCmd[cmd]; + if (lastArrival != null) { + final int interArrivalMs = arrivalMs - lastArrival; + if (interArrivalMs > 0 && sampleCount > 0) { + final double observedDelayMs = (interArrivalMs / sampleCount).clamp( + _minSampleDelayMs.toDouble(), + _maxSampleDelayMs.toDouble(), + ); + delayMs = delayMs + _delayAlpha * (observedDelayMs - delayMs); + } + } + lastArrivalByCmd[cmd] = arrivalMs; + + final int backlog = math.max(0, (pendingPacketsByCmd[cmd] ?? 1) - 1); + if (backlog > 0) { + final double compression = + 1.0 + math.min(backlog, 6) * _backlogCompressionPerPacket; + delayMs = delayMs / compression; + } + + delayMs = delayMs.clamp( + _minSampleDelayMs.toDouble(), + _maxSampleDelayMs.toDouble(), + ); + + delayEstimateByCmd[cmd] = delayMs; + return delayMs.round(); + } + + void decrementPending(int key) { + final int? pending = pendingPacketsByCmd[key]; + if (pending == null || pending <= 1) { + pendingPacketsByCmd.remove(key); + return; + } + pendingPacketsByCmd[key] = pending - 1; + } + + Future processPacket( + List data, + int arrivalMs, + int? rawCmd, + ) async { + int? cmdKey = rawCmd; + try { + final parsedData = await _parseData(data); + if (parsedData.isEmpty) { + return; + } + + final dynamic parsedCmd = parsedData.first['cmd']; + if (parsedCmd is int) { + cmdKey = parsedCmd; + } + + if (cmdKey == null) { + for (final sample in parsedData) { + if (!streamController.isClosed) { + streamController.add(_filterTemperature(sample)); + } + } + return; + } + + if (!_pacedStreamingCommands.contains(cmdKey)) { + for (final sample in parsedData) { + if (!streamController.isClosed) { + streamController.add(_filterTemperature(sample)); + } + } + return; + } + + final int stepMs = resolveStepMs( + cmd: cmdKey, + sampleCount: parsedData.length, + arrivalMs: arrivalMs, + ); + + int nextDueMs = nextDueByCmd[cmdKey] ?? arrivalMs; + final int nowMs = clock.elapsedMilliseconds; + + // Keep bounded catch-up to avoid both lag and hard jumps. + if (nextDueMs < nowMs - _maxScheduleLagMs) { + nextDueMs = nowMs - _maxScheduleLagMs; + } + + for (final sample in parsedData) { + final int now = clock.elapsedMilliseconds; + if (nextDueMs > now) { + await Future.delayed(Duration(milliseconds: nextDueMs - now)); + } + + final int epochNowMs = monotonicToEpochMs(clock.elapsedMilliseconds); + final int previousTs = + emittedTimestampByCmd[cmdKey] ?? (epochNowMs - stepMs); + final int nextTs = previousTs + stepMs; + emittedTimestampByCmd[cmdKey] = nextTs; + sample['timestamp'] = nextTs; + + if (!streamController.isClosed) { + streamController.add(_filterTemperature(sample)); + } + + final int emitNow = clock.elapsedMilliseconds; + nextDueMs = math.max(nextDueMs, emitNow) + stepMs; + } + + nextDueByCmd[cmdKey] = nextDueMs; + } finally { + if (cmdKey != null) { + decrementPending(cmdKey); + } + if (rawCmd != null && rawCmd != cmdKey) { + decrementPending(rawCmd); + } + } + } + + streamController = StreamController>.broadcast( + onListen: () { + bleSubscription ??= _bleManager + .subscribe( + deviceId: _discoveredDevice.id, + serviceId: OpenRingGatt.service, + characteristicId: OpenRingGatt.rxChar, + ) + .listen( + (data) { + if (_isPpgBusyResponse(data)) { + _schedulePpgBusyRetry(); + } + _updateRealtimeStreamingStateFromPacket(data); + + final int? rawCmd = data.length > 2 ? data[2] : null; + if (rawCmd != null) { + pendingPacketsByCmd[rawCmd] = + (pendingPacketsByCmd[rawCmd] ?? 0) + 1; + } + + final int arrivalMs = clock.elapsedMilliseconds; + final int queueKey = rawCmd ?? -1; + final Future previousQueue = + processingQueueByCmd[queueKey] ?? Future.value(); + + processingQueueByCmd[queueKey] = previousQueue + .then((_) => processPacket(data, arrivalMs, rawCmd)) + .catchError((error) { + logger.e( + 'Error while parsing OpenRing sensor packet: $error', + ); + }); + }, + onError: (error) { + logger.e('Error while subscribing to sensor data: $error'); + if (!streamController.isClosed) { + streamController.addError(error); + } + }, + ); + }, + onCancel: () { + if (!streamController.hasListener) { + final subscription = bleSubscription; + bleSubscription = null; + processingQueueByCmd.clear(); + lastArrivalByCmd.clear(); + delayEstimateByCmd.clear(); + nextDueByCmd.clear(); + emittedTimestampByCmd.clear(); + pendingPacketsByCmd.clear(); + _cancelPpgBusyRetry(); + _lastPpgStartPayload = null; + _ppgBusyRetryCount = 0; + _activeRealtimeStreamingCommands.clear(); + + if (subscription != null) { + unawaited(subscription.cancel()); + } + } + }, + ); + + return streamController.stream; + } + + Future _writeCommand(OpenRingSensorConfig sensorConfig) async { + final sensorConfigBytes = sensorConfig.toBytes(); + await _bleManager.write( + deviceId: _discoveredDevice.id, + serviceId: OpenRingGatt.service, + characteristicId: OpenRingGatt.txChar, + byteData: sensorConfigBytes, + ); + } + + bool _isRealtimeStreamingStart(OpenRingSensorConfig sensorConfig) { + if (sensorConfig.payload.isEmpty) { + return false; + } + + if (sensorConfig.cmd == OpenRingGatt.cmdPPGQ2) { + return sensorConfig.payload[0] == 0x00; + } + + if (sensorConfig.cmd == OpenRingGatt.cmdIMU) { + return sensorConfig.payload[0] != 0x00; + } + + return false; + } + + bool _isRealtimeStreamingStop(OpenRingSensorConfig sensorConfig) { + if (sensorConfig.payload.isEmpty) { + return false; + } + + if (sensorConfig.cmd == OpenRingGatt.cmdPPGQ2) { + return sensorConfig.payload[0] == 0x06; + } + + if (sensorConfig.cmd == OpenRingGatt.cmdIMU) { + return sensorConfig.payload[0] == 0x00; + } + + return false; + } + + bool _isPpgBusyResponse(List data) { + return data.length >= 5 && + data[0] == 0x00 && + data[2] == OpenRingGatt.cmdPPGQ2 && + data[3] == 0x00 && + data[4] == 0x04; + } + + void _updateRealtimeStreamingStateFromPacket(List data) { + if (data.length < 4 || data[0] != 0x00) { + return; + } + + final int cmd = data[2] & 0xFF; + + if (cmd == OpenRingGatt.cmdPPGQ2) { + final int packetType = data[3] & 0xFF; + + // Stop ack can be a 4-byte control frame. + if (packetType == 0x06) { + _activeRealtimeStreamingCommands.remove(cmd); + return; + } + + if (data.length < 5) { + return; + } + + final int packetValue = data[4] & 0xFF; + + // Realtime waveform packets imply active streaming. + if (packetType == 0x01 || packetType == 0x02) { + _activeRealtimeStreamingCommands.add(cmd); + return; + } + + // Final/result and terminal error packets indicate no active realtime stream. + if (packetType == 0x00 && + (packetValue == 0 || // not worn + packetValue == 2 || // charging + packetValue == 3 || // final result + packetValue == 4)) { + _activeRealtimeStreamingCommands.remove(cmd); + } + return; + } + + if (cmd == OpenRingGatt.cmdIMU) { + if (data.length < 5) { + return; + } + + final int subOpcode = data[3] & 0xFF; + final int status = data[4] & 0xFF; + + if (subOpcode == 0x00) { + _activeRealtimeStreamingCommands.remove(cmd); + return; + } + + if (subOpcode == 0x06 && status != 0x01) { + _activeRealtimeStreamingCommands.add(cmd); + } + } + } + + void _schedulePpgBusyRetry() { + if (_ppgBusyRetryCount >= _maxPpgBusyRetries) { + return; + } + final List? startPayload = _lastPpgStartPayload; + if (startPayload == null) { + return; + } + if (_ppgBusyRetryTimer?.isActive ?? false) { + return; + } + + _ppgBusyRetryCount += 1; + logger.w( + 'OpenRing PPG busy; retrying start in ${_ppgBusyRetryDelayMs}ms ' + '($_ppgBusyRetryCount/$_maxPpgBusyRetries)', + ); + + _ppgBusyRetryTimer = Timer( + const Duration(milliseconds: _ppgBusyRetryDelayMs), + () { + unawaited(_retryPpgStart(startPayload)); + }, + ); + } + + Future _retryPpgStart(List startPayload) async { + try { + await _writeCommand( + OpenRingSensorConfig(cmd: OpenRingGatt.cmdPPGQ2, payload: const [0x06]), + ); + _activeRealtimeStreamingCommands.remove(OpenRingGatt.cmdPPGQ2); + await Future.delayed(const Duration(milliseconds: _ppgRestartDelayMs)); + await _writeCommand( + OpenRingSensorConfig( + cmd: OpenRingGatt.cmdPPGQ2, + payload: List.from(startPayload), + ), + ); + _activeRealtimeStreamingCommands.add(OpenRingGatt.cmdPPGQ2); + } catch (error) { + logger.e('OpenRing PPG busy retry failed: $error'); + } + } + + void _cancelPpgBusyRetry() { + _ppgBusyRetryTimer?.cancel(); + _ppgBusyRetryTimer = null; + } +} + +class OpenRingSensorConfig extends SensorConfig { + int cmd; + List payload; + + OpenRingSensorConfig({required this.cmd, required this.payload}); + + Uint8List toBytes() { + final int randomByte = DateTime.now().microsecondsSinceEpoch & 0xFF; + return Uint8List.fromList([0x00, randomByte, cmd, ...payload]); + } +} diff --git a/lib/src/managers/tau_sensor_handler.dart b/lib/src/managers/tau_sensor_handler.dart deleted file mode 100644 index 93d0c01..0000000 --- a/lib/src/managers/tau_sensor_handler.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; - -import 'package:open_earable_flutter/src/models/devices/tau_ring.dart'; - -import '../../open_earable_flutter.dart'; -import 'sensor_handler.dart'; -import '../utils/sensor_value_parser/sensor_value_parser.dart'; - -class TauSensorHandler extends SensorHandler { - final DiscoveredDevice _discoveredDevice; - final BleGattManager _bleManager; - - final SensorValueParser _sensorValueParser; - - TauSensorHandler({ - required DiscoveredDevice discoveredDevice, - required BleGattManager bleManager, - required SensorValueParser sensorValueParser, - }) : _discoveredDevice = discoveredDevice, - _bleManager = bleManager, - _sensorValueParser = sensorValueParser; - - @override - Stream> subscribeToSensorData(int sensorId) { - if (!_bleManager.isConnected(_discoveredDevice.id)) { - throw Exception("Can't subscribe to sensor data. Earable not connected"); - } - - StreamController> streamController = - StreamController(); - _bleManager - .subscribe( - deviceId: _discoveredDevice.id, - serviceId: TauRingGatt.service, - characteristicId: TauRingGatt.rxChar, - ).listen( - (data) async { - List> parsedData = await _parseData(data); - for (var d in parsedData) { - streamController.add(d); - } - }, - onError: (error) { - logger.e("Error while subscribing to sensor data: $error"); - }, - ); - - return streamController.stream; - } - - @override - Future writeSensorConfig(TauSensorConfig sensorConfig) async { - if (!_bleManager.isConnected(_discoveredDevice.id)) { - Exception("Can't write sensor config. Earable not connected"); - } - - Uint8List sensorConfigBytes = sensorConfig.toBytes(); - - await _bleManager.write( - deviceId: _discoveredDevice.id, - serviceId: TauRingGatt.service, - characteristicId: TauRingGatt.txChar, - byteData: sensorConfigBytes, - ); - } - - /// Parses raw sensor data bytes into a [Map] of sensor values. - Future>> _parseData(List data) async { - ByteData byteData = ByteData.sublistView(Uint8List.fromList(data)); - - return _sensorValueParser.parse(byteData, []); - } -} - -class TauSensorConfig extends SensorConfig { - int cmd; - int subOpcode; - - TauSensorConfig({ - required this.cmd, - required this.subOpcode, - }); - - Uint8List toBytes() { - int randomByte = DateTime.now().microsecondsSinceEpoch & 0xFF; - - return Uint8List.fromList([ - 0x00, - randomByte, - cmd, - subOpcode, - ]); - } -} diff --git a/lib/src/models/capabilities/sensor_configuration_specializations/open_ring_sensor_configuration.dart b/lib/src/models/capabilities/sensor_configuration_specializations/open_ring_sensor_configuration.dart new file mode 100644 index 0000000..404784e --- /dev/null +++ b/lib/src/models/capabilities/sensor_configuration_specializations/open_ring_sensor_configuration.dart @@ -0,0 +1,44 @@ +import 'package:open_earable_flutter/src/managers/open_ring_sensor_handler.dart'; + +import '../sensor_configuration.dart'; + +class OpenRingSensorConfiguration + extends SensorConfiguration { + final OpenRingSensorHandler _sensorHandler; + + OpenRingSensorConfiguration({ + required super.name, + required super.values, + required OpenRingSensorHandler sensorHandler, + }) : _sensorHandler = sensorHandler; + + @override + void setConfiguration(OpenRingSensorConfigurationValue value) { + if (value.temperatureStreamEnabled != null) { + _sensorHandler.setTemperatureStreamEnabled( + value.temperatureStreamEnabled!, + ); + return; + } + + final config = OpenRingSensorConfig(cmd: value.cmd, payload: value.payload); + + _sensorHandler.writeSensorConfig(config); + } +} + +class OpenRingSensorConfigurationValue extends SensorConfigurationValue { + final int cmd; + final List payload; + final bool? temperatureStreamEnabled; + + OpenRingSensorConfigurationValue({ + required super.key, + required this.cmd, + required this.payload, + this.temperatureStreamEnabled, + }); + + @override + String toString() => key; +} diff --git a/lib/src/models/capabilities/sensor_configuration_specializations/tau_ring_sensor_configuration.dart b/lib/src/models/capabilities/sensor_configuration_specializations/tau_ring_sensor_configuration.dart deleted file mode 100644 index 4867e56..0000000 --- a/lib/src/models/capabilities/sensor_configuration_specializations/tau_ring_sensor_configuration.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:open_earable_flutter/src/managers/tau_sensor_handler.dart'; - -import '../sensor_configuration.dart'; - -class TauRingSensorConfiguration extends SensorConfiguration { - - final TauSensorHandler _sensorHandler; - - TauRingSensorConfiguration({required super.name, required super.values, required TauSensorHandler sensorHandler}) - : _sensorHandler = sensorHandler; - - @override - void setConfiguration(TauRingSensorConfigurationValue value) { - TauSensorConfig config = TauSensorConfig( - cmd: value.cmd, - subOpcode: value.subOpcode, - ); - - _sensorHandler.writeSensorConfig(config); - } -} - -class TauRingSensorConfigurationValue extends SensorConfigurationValue { - final int cmd; - final int subOpcode; - - TauRingSensorConfigurationValue({ - required super.key, - required this.cmd, - required this.subOpcode, - }); - - @override - String toString() { - return key; - } -} diff --git a/lib/src/models/capabilities/sensor_specializations/open_ring/open_ring_sensor.dart b/lib/src/models/capabilities/sensor_specializations/open_ring/open_ring_sensor.dart new file mode 100644 index 0000000..3d83da8 --- /dev/null +++ b/lib/src/models/capabilities/sensor_specializations/open_ring/open_ring_sensor.dart @@ -0,0 +1,111 @@ +import 'dart:async'; + +import '../../../../managers/sensor_handler.dart'; +import '../../sensor.dart'; + +class OpenRingSensor extends Sensor { + OpenRingSensor({ + required this.sensorId, + required super.sensorName, + required super.chartTitle, + required super.shortChartTitle, + required List axisNames, + required List axisUnits, + required this.sensorHandler, + super.relatedConfigurations = const [], + }) : _axisNames = axisNames, + _axisUnits = axisUnits; + + final int sensorId; + final List _axisNames; + final List _axisUnits; + + final SensorHandler sensorHandler; + + // ignore: cancel_subscriptions + StreamSubscription>? _sensorSubscription; + late final StreamController _sensorStreamController = + StreamController.broadcast( + onListen: _handleListen, + onCancel: _handleCancel, + ); + + @override + List get axisNames => _axisNames; + + @override + List get axisUnits => _axisUnits; + + @override + int get axisCount => _axisNames.length; + + @override + Stream get sensorStream => _sensorStreamController.stream; + + void _handleListen() { + _sensorSubscription ??= + sensorHandler.subscribeToSensorData(sensorId).listen( + (data) { + final SensorIntValue? sensorValue = _toSensorValue(data); + if (sensorValue != null && !_sensorStreamController.isClosed) { + _sensorStreamController.add(sensorValue); + } + }, + onError: (error, stack) { + if (!_sensorStreamController.isClosed) { + _sensorStreamController.addError(error, stack); + } + }, + ); + } + + Future _handleCancel() async { + if (_sensorStreamController.hasListener) { + return; + } + + final subscription = _sensorSubscription; + _sensorSubscription = null; + if (subscription != null) { + await subscription.cancel(); + } + } + + SensorIntValue? _toSensorValue(Map data) { + if (!data.containsKey(sensorName)) { + return null; + } + + final sensorData = data[sensorName]; + final timestamp = data['timestamp']; + if (sensorData is! Map || timestamp is! int) { + return null; + } + + final Map sensorDataMap = sensorData; + final List values = []; + for (final axisName in _axisNames) { + final dynamic axisValue = sensorDataMap[axisName]; + if (axisValue is int) { + values.add(axisValue); + } + } + + if (values.isEmpty) { + for (final entry in sensorDataMap.entries) { + if (entry.key == 'units') { + continue; + } + if (entry.value is int) { + values.add(entry.value as int); + } + } + } + + if (values.isEmpty) { + return null; + } + + return SensorIntValue(values: values, timestamp: timestamp); + } +} diff --git a/lib/src/models/capabilities/sensor_specializations/tau_ring/tau_ring_sensor.dart b/lib/src/models/capabilities/sensor_specializations/tau_ring/tau_ring_sensor.dart deleted file mode 100644 index 7f918a4..0000000 --- a/lib/src/models/capabilities/sensor_specializations/tau_ring/tau_ring_sensor.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'dart:async'; - -import '../../../../managers/sensor_handler.dart'; -import '../../sensor.dart'; - -class TauRingSensor extends Sensor { - const TauRingSensor({ - required this.sensorId, - required super.sensorName, - required super.chartTitle, - required super.shortChartTitle, - required List axisNames, - required List axisUnits, - required this.sensorHandler, - super.relatedConfigurations = const [], - }) : _axisNames = axisNames, _axisUnits = axisUnits; - - final int sensorId; - final List _axisNames; - final List _axisUnits; - - final SensorHandler sensorHandler; - - @override - List get axisNames => _axisNames; - - @override - List get axisUnits => _axisUnits; - - @override - int get axisCount => _axisNames.length; - - @override - Stream get sensorStream { - StreamController streamController = StreamController(); - sensorHandler.subscribeToSensorData(sensorId).listen( - (data) { - int timestamp = data["timestamp"]; - - List values = []; - for (var entry in (data[sensorName] as Map).entries) { - if (entry.key == 'units') { - continue; - } - - values.add(entry.value); - } - - SensorIntValue sensorValue = SensorIntValue( - values: values, - timestamp: timestamp, - ); - - streamController.add(sensorValue); - }, - ); - return streamController.stream; - } -} diff --git a/lib/src/models/devices/cosinuss_one.dart b/lib/src/models/devices/cosinuss_one.dart index 310d4d5..7c51158 100644 --- a/lib/src/models/devices/cosinuss_one.dart +++ b/lib/src/models/devices/cosinuss_one.dart @@ -90,7 +90,10 @@ class CosinussOne extends Wearable } @override - String? getWearableIconPath({bool darkmode = false}) { + String? getWearableIconPath({ + bool darkmode = false, + WearableIconVariant variant = WearableIconVariant.single, + }) { String basePath = 'packages/open_earable_flutter/assets/wearable_icons/cosinuss_one'; diff --git a/lib/src/models/devices/open_earable_v1.dart b/lib/src/models/devices/open_earable_v1.dart index 29c27b6..26cf406 100644 --- a/lib/src/models/devices/open_earable_v1.dart +++ b/lib/src/models/devices/open_earable_v1.dart @@ -170,7 +170,10 @@ class OpenEarableV1 extends Wearable } @override - String? getWearableIconPath({bool darkmode = false}) { + String? getWearableIconPath({ + bool darkmode = false, + WearableIconVariant variant = WearableIconVariant.single, + }) { String basePath = 'packages/open_earable_flutter/assets/wearable_icons/open_earable_v1'; diff --git a/lib/src/models/devices/open_earable_v2.dart b/lib/src/models/devices/open_earable_v2.dart index a7b58a7..e2b617d 100644 --- a/lib/src/models/devices/open_earable_v2.dart +++ b/lib/src/models/devices/open_earable_v2.dart @@ -285,10 +285,26 @@ class OpenEarableV2 extends BluetoothWearable String get deviceId => discoveredDevice.id; @override - String? getWearableIconPath({bool darkmode = false}) { + String? getWearableIconPath({ + bool darkmode = false, + WearableIconVariant variant = WearableIconVariant.single, + }) { String basePath = 'packages/open_earable_flutter/assets/wearable_icons/open_earable_v2'; + if (!darkmode) { + switch (variant) { + case WearableIconVariant.left: + return '$basePath/left.png'; + case WearableIconVariant.right: + return '$basePath/right.png'; + case WearableIconVariant.pair: + return '$basePath/pair.png'; + case WearableIconVariant.single: + break; + } + } + if (darkmode) { return '$basePath/icon_no_text_white.svg'; } diff --git a/lib/src/models/devices/open_ring.dart b/lib/src/models/devices/open_ring.dart new file mode 100644 index 0000000..6532fbd --- /dev/null +++ b/lib/src/models/devices/open_ring.dart @@ -0,0 +1,329 @@ +import 'dart:async'; + +import '../../../open_earable_flutter.dart'; + +/// OpenRing integration for OpenEarable. +/// Implements Wearable + sensor configuration + battery level capability. +class OpenRing extends Wearable + implements SensorManager, SensorConfigurationManager, BatteryLevelStatus { + OpenRing({ + required DiscoveredDevice discoveredDevice, + required this.deviceId, + required super.name, + List sensors = const [], + List sensorConfigs = const [], + required BleGattManager bleManager, + required super.disconnectNotifier, + bool Function()? isSensorStreamingActive, + }) : _sensors = sensors, + _sensorConfigs = sensorConfigs, + _bleManager = bleManager, + _discoveredDevice = discoveredDevice, + _isSensorStreamingActive = isSensorStreamingActive; + + final DiscoveredDevice _discoveredDevice; + + final List _sensors; + final List _sensorConfigs; + final BleGattManager _bleManager; + final bool Function()? _isSensorStreamingActive; + + bool _batteryPollingWasSkippedForStreaming = false; + + static const int _batteryReadType = 0x00; + static const int _batteryPushType = 0x02; + static const Duration _batteryResponseTimeout = Duration(milliseconds: 1800); + + @override + final String deviceId; + + @override + List> + get sensorConfigurations => List.unmodifiable(_sensorConfigs); + @override + List> get sensors => List.unmodifiable(_sensors); + + @override + Future disconnect() { + return _bleManager.disconnect(_discoveredDevice.id); + } + + @override + Stream< + Map, + SensorConfigurationValue>> get sensorConfigurationStream => + const Stream.empty(); + + @override + Future readBatteryPercentage() async { + if (!_bleManager.isConnected(deviceId)) { + throw StateError( + 'Cannot read OpenRing battery level while disconnected ($deviceId)', + ); + } + + final int frameId = DateTime.now().microsecondsSinceEpoch & 0xFF; + final List command = OpenRingGatt.frame( + OpenRingGatt.cmdBatt, + rnd: frameId, + payload: const [_batteryReadType], + ); + + final completer = Completer(); + late final StreamSubscription> sub; + sub = _bleManager + .subscribe( + deviceId: deviceId, + serviceId: OpenRingGatt.service, + characteristicId: OpenRingGatt.rxChar, + ) + .listen( + (data) { + if (data.length < 5) { + return; + } + + final int responseFrameId = data[1] & 0xFF; + final int responseCmd = data[2] & 0xFF; + final int responseType = data[3] & 0xFF; + if (responseFrameId != frameId || responseCmd != OpenRingGatt.cmdBatt) { + return; + } + if (responseType != _batteryReadType && + responseType != _batteryPushType) { + return; + } + + final int battery = data[4] & 0xFF; + if (!completer.isCompleted) { + completer.complete(battery); + } + }, + onError: (error, stack) { + if (!completer.isCompleted) { + completer.completeError(error, stack); + } + }, + ); + + try { + await _bleManager.write( + deviceId: deviceId, + serviceId: OpenRingGatt.service, + characteristicId: OpenRingGatt.txChar, + byteData: command, + ); + + return await completer.future.timeout(_batteryResponseTimeout); + } finally { + await sub.cancel(); + } + } + + @override + Stream get batteryPercentageStream { + StreamController controller = StreamController(); + Timer? batteryPollingTimer; + bool batteryPollingInFlight = false; + + Future pollBattery() async { + if (batteryPollingInFlight) { + return; + } + final bool streamingActive = _isSensorStreamingActive?.call() ?? false; + if (streamingActive) { + if (!_batteryPollingWasSkippedForStreaming) { + logger.d( + 'Skipping OpenRing battery poll while realtime sensor streaming is active', + ); + _batteryPollingWasSkippedForStreaming = true; + } + return; + } + if (_batteryPollingWasSkippedForStreaming) { + logger.d('Resuming OpenRing battery polling after sensor streaming'); + _batteryPollingWasSkippedForStreaming = false; + } + + batteryPollingInFlight = true; + try { + final int batteryPercentage = await readBatteryPercentage(); + if (!controller.isClosed) { + controller.add(batteryPercentage); + } + } catch (e) { + logger.e('Error reading OpenRing battery percentage: $e'); + } finally { + batteryPollingInFlight = false; + } + } + + controller.onCancel = () { + batteryPollingTimer?.cancel(); + }; + + controller.onListen = () { + batteryPollingTimer = Timer.periodic(const Duration(seconds: 5), (timer) { + unawaited(pollBattery()); + }); + unawaited(pollBattery()); + }; + + return controller.stream; + } +} + +// OpenRing GATT constants (from the vendor AAR) +class OpenRingGatt { + static const String service = 'bae80001-4f05-4503-8e65-3af1f7329d1f'; + static const String txChar = 'bae80010-4f05-4503-8e65-3af1f7329d1f'; // write + static const String rxChar = 'bae80011-4f05-4503-8e65-3af1f7329d1f'; // notify + + // opcodes (subset) + static const int cmdApp = 0xA0; // APP_* handshake + static const int cmdTime = 0x10; // wall clock sync + static const int cmdVers = 0x11; // version + static const int cmdBatt = 0x12; // battery + static const int cmdSys = 0x37; // system (reset etc.) + static const int cmdIMU = 0x40; // start/stop IMU + static const int cmdPPGQ2 = 0x32; // start/stop PPG Q2 + + // build a framed command: [0x00, rnd, cmdId, payload...] + static List frame(int cmd, {List payload = const [], int? rnd}) { + final r = rnd ?? DateTime.now().microsecondsSinceEpoch & 0xFF; + return [0x00, r & 0xFF, cmd, ...payload]; + } + + static List le64(int ms) { + final b = List.filled(8, 0); + var v = ms; + for (var i = 0; i < 8; i++) { + b[i] = v & 0xFF; + v >>= 8; + } + return b; + } +} + +class OpenRingTimeSyncImp implements TimeSynchronizable { + OpenRingTimeSyncImp({required this.bleManager, required this.deviceId}); + + final BleGattManager bleManager; + final String deviceId; + + static const int _timeUpdateSubCommand = 0x00; + static const int _maxAttempts = 3; + static const Duration _responseTimeout = Duration(milliseconds: 1800); + static const Duration _retryDelay = Duration(milliseconds: 220); + + bool _isTimeSynchronized = false; + + @override + bool get isTimeSynchronized => _isTimeSynchronized; + + @override + Future synchronizeTime() async { + if (!bleManager.isConnected(deviceId)) { + throw StateError('Cannot synchronize OpenRing time while disconnected'); + } + + for (var attempt = 1; attempt <= _maxAttempts; attempt++) { + bool synced = false; + try { + synced = await _sendTimeUpdateOnce(attempt); + } catch (error, stack) { + logger.w( + 'OpenRing time sync attempt $attempt/$_maxAttempts failed for $deviceId: $error', + ); + logger.t(stack); + } + + if (synced) { + _isTimeSynchronized = true; + return; + } + + logger.w( + 'OpenRing time sync attempt $attempt/$_maxAttempts timed out for $deviceId', + ); + + if (attempt < _maxAttempts) { + await Future.delayed(_retryDelay); + } + } + + _isTimeSynchronized = false; + throw TimeoutException( + 'OpenRing time sync failed after $_maxAttempts attempts', + ); + } + + Future _sendTimeUpdateOnce(int attempt) async { + final int frameId = + (DateTime.now().microsecondsSinceEpoch + attempt) & 0xFF; + final int timestampMs = DateTime.now().millisecondsSinceEpoch; + final int timezoneHours = DateTime.now().timeZoneOffset.inHours; + final int timezoneByte = timezoneHours & 0xFF; + + final List command = OpenRingGatt.frame( + OpenRingGatt.cmdTime, + rnd: frameId, + payload: [ + _timeUpdateSubCommand, + ...OpenRingGatt.le64(timestampMs), + timezoneByte, + ], + ); + + final completer = Completer(); + late final StreamSubscription> sub; + sub = bleManager + .subscribe( + deviceId: deviceId, + serviceId: OpenRingGatt.service, + characteristicId: OpenRingGatt.rxChar, + ) + .listen( + (data) { + if (data.length < 4) { + return; + } + final int responseFrameId = data[1] & 0xFF; + final int responseCmd = data[2] & 0xFF; + final int responseSubCommand = data[3] & 0xFF; + + if (responseFrameId == frameId && + responseCmd == OpenRingGatt.cmdTime && + responseSubCommand == _timeUpdateSubCommand && + !completer.isCompleted) { + completer.complete(true); + } + }, + onError: (error, stack) { + if (!completer.isCompleted) { + completer.completeError(error, stack); + } + }, + ); + + try { + logger.d( + 'OpenRing time sync attempt $attempt: ' + 'frameId=$frameId ts=$timestampMs timezoneHours=$timezoneHours', + ); + + await bleManager.write( + deviceId: deviceId, + serviceId: OpenRingGatt.service, + characteristicId: OpenRingGatt.txChar, + byteData: command, + ); + + return await completer.future.timeout(_responseTimeout); + } on TimeoutException { + return false; + } finally { + await sub.cancel(); + } + } +} diff --git a/lib/src/models/devices/open_ring_factory.dart b/lib/src/models/devices/open_ring_factory.dart new file mode 100644 index 0000000..2149512 --- /dev/null +++ b/lib/src/models/devices/open_ring_factory.dart @@ -0,0 +1,191 @@ +import 'dart:async'; + +import 'package:open_earable_flutter/src/models/capabilities/sensor_configuration_specializations/open_ring_sensor_configuration.dart'; +import 'package:open_earable_flutter/src/models/capabilities/sensor_specializations/open_ring/open_ring_sensor.dart'; +import 'package:universal_ble/universal_ble.dart'; +import '../../../open_earable_flutter.dart' show logger; + +import '../../managers/open_ring_sensor_handler.dart'; +import '../../utils/sensor_value_parser/open_ring_value_parser.dart'; +import '../capabilities/time_synchronizable.dart'; +import '../capabilities/sensor.dart'; +import '../capabilities/sensor_configuration.dart'; +import '../wearable_factory.dart'; +import 'discovered_device.dart'; +import 'open_ring.dart'; +import 'wearable.dart'; + +class OpenRingFactory extends WearableFactory { + @override + Future createFromDevice( + DiscoveredDevice device, { + Set options = const {}, + }) async { + if (bleManager == null) { + throw Exception( + "Can't create OpenRing instance: bleManager not set in factory", + ); + } + if (disconnectNotifier == null) { + throw Exception( + "Can't create OpenRing instance: disconnectNotifier not set in factory", + ); + } + + final sensorHandler = OpenRingSensorHandler( + discoveredDevice: device, + bleManager: bleManager!, + sensorValueParser: OpenRingValueParser(), + ); + + final imuSensorConfig = OpenRingSensorConfiguration( + name: "6-Axis IMU", + values: [ + OpenRingSensorConfigurationValue(key: "On", cmd: 0x40, payload: [0x06]), + OpenRingSensorConfigurationValue( + key: "Off", + cmd: 0x40, + payload: [0x00], + ), + ], + sensorHandler: sensorHandler, + ); + + final ppgSensorConfig = OpenRingSensorConfiguration( + name: "PPG", + values: [ + OpenRingSensorConfigurationValue( + key: "On", + cmd: OpenRingGatt.cmdPPGQ2, + payload: [ + 0x00, // start Q2 collection (LmAPI GET_HEART_Q2) + 0x1E, // collectionTime = 30s (LmAPI default) + 0x19, // acquisition parameter (firmware-fixed) + 0x01, // enable waveform streaming + 0x01, // enable progress packets + ], + ), + OpenRingSensorConfigurationValue( + key: "Off", + cmd: OpenRingGatt.cmdPPGQ2, + payload: [ + 0x06, // stop Q2 collection (LmAPI STOP_Q2) + ], + ), + ], + sensorHandler: sensorHandler, + ); + + final temperatureSensorConfig = OpenRingSensorConfiguration( + name: "Temperature", + values: [ + OpenRingSensorConfigurationValue( + key: "On", + cmd: OpenRingGatt.cmdPPGQ2, + payload: const [], + temperatureStreamEnabled: true, + ), + OpenRingSensorConfigurationValue( + key: "Off", + cmd: OpenRingGatt.cmdPPGQ2, + payload: const [], + temperatureStreamEnabled: false, + ), + ], + sensorHandler: sensorHandler, + ); + + List sensorConfigs = [ + imuSensorConfig, + ppgSensorConfig, + temperatureSensorConfig, + ]; + List sensors = [ + OpenRingSensor( + sensorId: OpenRingGatt.cmdIMU, + sensorName: "Accelerometer", + chartTitle: "Accelerometer", + shortChartTitle: "Acc.", + axisNames: ["X", "Y", "Z"], + axisUnits: ["g", "g", "g"], + sensorHandler: sensorHandler, + ), + OpenRingSensor( + sensorId: OpenRingGatt.cmdIMU, + sensorName: "Gyroscope", + chartTitle: "Gyroscope", + shortChartTitle: "Gyr.", + axisNames: ["X", "Y", "Z"], + axisUnits: ["dps", "dps", "dps"], + sensorHandler: sensorHandler, + ), + OpenRingSensor( + sensorId: OpenRingGatt.cmdPPGQ2, + sensorName: "PPG", + chartTitle: "PPG", + shortChartTitle: "PPG", + axisNames: ["Infrared", "Red", "Green"], + axisUnits: ["raw", "raw", "raw"], + sensorHandler: sensorHandler, + ), + OpenRingSensor( + sensorId: OpenRingGatt.cmdPPGQ2, + sensorName: "Temperature", + chartTitle: "Temperature", + shortChartTitle: "Temp", + axisNames: ["Temp0", "Temp1", "Temp2"], + axisUnits: ["°C", "°C", "°C"], + sensorHandler: sensorHandler, + // Temperature uses software on/off. PPG must be enabled separately. + relatedConfigurations: [temperatureSensorConfig], + ), + ]; + + final w = OpenRing( + discoveredDevice: device, + deviceId: device.id, + name: device.name, + sensors: sensors, + sensorConfigs: sensorConfigs, + disconnectNotifier: disconnectNotifier!, + bleManager: bleManager!, + isSensorStreamingActive: () => sensorHandler.hasActiveRealtimeStreaming, + ); + + final timeSync = OpenRingTimeSyncImp( + bleManager: bleManager!, + deviceId: device.id, + ); + w.registerCapability(timeSync); + + unawaited( + _synchronizeTimeOnConnect( + timeSync: timeSync, + deviceId: device.id, + ), + ); + + return w; + } + + Future _synchronizeTimeOnConnect({ + required TimeSynchronizable timeSync, + required String deviceId, + }) async { + try { + await timeSync.synchronizeTime(); + logger.i('OpenRing time synchronized on connect for $deviceId'); + } catch (error, stack) { + logger.w('OpenRing time sync on connect failed for $deviceId: $error'); + logger.t(stack); + } + } + + @override + Future matches( + DiscoveredDevice device, + List services, + ) async { + return services.any((s) => s.uuid.toLowerCase() == OpenRingGatt.service); + } +} diff --git a/lib/src/models/devices/polar.dart b/lib/src/models/devices/polar.dart index 16a2e3d..e9321bd 100644 --- a/lib/src/models/devices/polar.dart +++ b/lib/src/models/devices/polar.dart @@ -29,7 +29,10 @@ class Polar extends Wearable _discoveredDevice = discoveredDevice; @override - String? getWearableIconPath({bool darkmode = false}) { + String? getWearableIconPath({ + bool darkmode = false, + WearableIconVariant variant = WearableIconVariant.single, + }) { String basePath = 'packages/open_earable_flutter/assets/wearable_icons/polar'; diff --git a/lib/src/models/devices/tau_ring.dart b/lib/src/models/devices/tau_ring.dart deleted file mode 100644 index 0823ac7..0000000 --- a/lib/src/models/devices/tau_ring.dart +++ /dev/null @@ -1,68 +0,0 @@ -import '../../../open_earable_flutter.dart'; - - -/// τ-Ring integration for OpenEarable. -/// Implements Wearable (mandatory) + SensorManager (exposes sensors). -class TauRing extends Wearable implements SensorManager, SensorConfigurationManager { - TauRing({ - required DiscoveredDevice discoveredDevice, - required this.deviceId, - required super.name, - List sensors = const [], - List sensorConfigs = const [], - required BleGattManager bleManager, - required super.disconnectNotifier, - }) : _sensors = sensors, - _sensorConfigs = sensorConfigs, - _bleManager = bleManager, - _discoveredDevice = discoveredDevice; - - final DiscoveredDevice _discoveredDevice; - - final List _sensors; - final List _sensorConfigs; - final BleGattManager _bleManager; - - @override - final String deviceId; - - @override - List> get sensorConfigurations => _sensorConfigs; - @override - List> get sensors => _sensors; - - @override - Future disconnect() { - return _bleManager.disconnect(_discoveredDevice.id); - } - - @override - Stream, SensorConfigurationValue>> get sensorConfigurationStream => const Stream.empty(); -} - -// τ-Ring GATT constants (from the vendor AAR) -class TauRingGatt { - static const String service = 'bae80001-4f05-4503-8e65-3af1f7329d1f'; - static const String txChar = 'bae80010-4f05-4503-8e65-3af1f7329d1f'; // write - static const String rxChar = 'bae80011-4f05-4503-8e65-3af1f7329d1f'; // notify - - // opcodes (subset) - static const int cmdApp = 0xA0; // APP_* handshake - static const int cmdVers = 0x11; // version - static const int cmdBatt = 0x12; // battery - static const int cmdSys = 0x37; // system (reset etc.) - static const int cmdPPGQ2 = 0x32; // start/stop PPG Q2 - - // build a framed command: [0x00, rnd, cmdId, payload...] - static List frame(int cmd, {List payload = const [], int? rnd}) { - final r = rnd ?? DateTime.now().microsecondsSinceEpoch & 0xFF; - return [0x00, r & 0xFF, cmd, ...payload]; - } - - static List le64(int ms) { - final b = List.filled(8, 0); - var v = ms; - for (var i = 0; i < 8; i++) { b[i] = v & 0xFF; v >>= 8; } - return b; - } -} diff --git a/lib/src/models/devices/tau_ring_factory.dart b/lib/src/models/devices/tau_ring_factory.dart deleted file mode 100644 index 9b12719..0000000 --- a/lib/src/models/devices/tau_ring_factory.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:open_earable_flutter/src/models/capabilities/sensor_configuration_specializations/tau_ring_sensor_configuration.dart'; -import 'package:open_earable_flutter/src/models/capabilities/sensor_specializations/tau_ring/tau_ring_sensor.dart'; -import 'package:universal_ble/universal_ble.dart'; - -import '../../managers/tau_sensor_handler.dart'; -import '../../utils/sensor_value_parser/tau_ring_value_parser.dart'; -import '../capabilities/sensor.dart'; -import '../capabilities/sensor_configuration.dart'; -import '../wearable_factory.dart'; -import 'discovered_device.dart'; -import 'tau_ring.dart'; -import 'wearable.dart'; - -class TauRingFactory extends WearableFactory { - @override - Future createFromDevice(DiscoveredDevice device, {Set options = const {}}) { - if (bleManager == null) { - throw Exception("Can't create τ-Ring instance: bleManager not set in factory"); - } - if (disconnectNotifier == null) { - throw Exception("Can't create τ-Ring instance: disconnectNotifier not set in factory"); - } - - final sensorHandler = TauSensorHandler( - discoveredDevice: device, - bleManager: bleManager!, - sensorValueParser: TauRingValueParser(), - ); - - List sensorConfigs = [ - TauRingSensorConfiguration( - name: "6-Axis IMU", - values: [ - TauRingSensorConfigurationValue(key: "On", cmd: 0x40, subOpcode: 0x06), - TauRingSensorConfigurationValue(key: "Off", cmd: 0x40, subOpcode: 0x00), - ], - sensorHandler: sensorHandler, - ), - ]; - List sensors = [ - TauRingSensor( - sensorId: 0x40, - sensorName: "Accelerometer", - chartTitle: "Accelerometer", - shortChartTitle: "Accel", - axisNames: ["X", "Y", "Z"], - axisUnits: ["g", "g", "g"], - sensorHandler: sensorHandler, - ), - TauRingSensor( - sensorId: 0x40, - sensorName: "Gyroscope", - chartTitle: "Gyroscope", - shortChartTitle: "Gyro", - axisNames: ["X", "Y", "Z"], - axisUnits: ["dps", "dps", "dps"], - sensorHandler: sensorHandler, - ), - ]; - - final w = TauRing( - discoveredDevice: device, - deviceId: device.id, - name: device.name, - sensors: sensors, - sensorConfigs: sensorConfigs, - disconnectNotifier: disconnectNotifier!, - bleManager: bleManager!, - ); - return Future.value(w); - } - - @override - Future matches(DiscoveredDevice device, List services) async { - return services.any((s) => s.uuid.toLowerCase() == TauRingGatt.service); - } -} diff --git a/lib/src/models/devices/wearable.dart b/lib/src/models/devices/wearable.dart index bb87f9f..fdc2ac9 100644 --- a/lib/src/models/devices/wearable.dart +++ b/lib/src/models/devices/wearable.dart @@ -3,6 +3,13 @@ import 'dart:ui'; import '../../managers/wearable_disconnect_notifier.dart'; +enum WearableIconVariant { + single, + left, + right, + pair, +} + abstract class Wearable { final String name; @@ -85,7 +92,11 @@ abstract class Wearable { /// The parameters are best-effort /// /// @param darkmode: Whether the icon should be for dark mode (if available). - String? getWearableIconPath({bool darkmode = false}) { + /// @param variant: Which icon variant should be used. + String? getWearableIconPath({ + bool darkmode = false, + WearableIconVariant variant = WearableIconVariant.single, + }) { return null; } diff --git a/lib/src/utils/sensor_value_parser/open_ring_value_parser.dart b/lib/src/utils/sensor_value_parser/open_ring_value_parser.dart new file mode 100644 index 0000000..f443e3c --- /dev/null +++ b/lib/src/utils/sensor_value_parser/open_ring_value_parser.dart @@ -0,0 +1,568 @@ +import 'dart:typed_data'; + +import '../../../open_earable_flutter.dart' show logger; +import '../sensor_scheme_parser/sensor_scheme_reader.dart'; +import 'sensor_value_parser.dart'; + +class OpenRingValueParser extends SensorValueParser { + // 100 Hz -> 10 ms per sample + static const int _samplePeriodMs = 10; + // OpenRing realtime temperature channels are provided in milli-degrees C. + static const double _tempRawToCelsiusScale = 1000.0; + + final Map _lastSeqByCmd = {}; + final Map _lastTsByCmd = {}; + final Set _seenType2MismatchWarnings = {}; + final Set _seenType2RealtimeMismatchWarnings = {}; + + @override + List> parse( + ByteData data, + List sensorSchemes, + ) { + if (data.lengthInBytes < 4) { + throw Exception('Data too short to parse'); + } + + final int framePrefix = data.getUint8(0); + if (framePrefix != 0x00) { + throw Exception('Invalid frame prefix: $framePrefix'); + } + + final int sequenceNum = data.getUint8(1); + final int cmd = data.getUint8(2); + + final int receiveTs = + _lastTsByCmd[cmd] ?? DateTime.now().millisecondsSinceEpoch; + _lastSeqByCmd[cmd] = sequenceNum; + + List> result; + switch (cmd) { + case 0x40: // IMU + result = _parseImuFrame(data, sequenceNum, cmd, receiveTs); + break; + case 0x32: // PPG Q2 + result = _parsePpgFrame(data, sequenceNum, cmd, receiveTs); + break; + default: + return const []; + } + + if (result.isNotEmpty) { + final int updatedTs = result.last['timestamp'] as int; + _lastTsByCmd[cmd] = updatedTs; + } + + return result; + } + + List> _parseImuFrame( + ByteData frame, + int sequenceNum, + int cmd, + int receiveTs, + ) { + if (frame.lengthInBytes < 4) { + throw Exception('IMU frame too short: ${frame.lengthInBytes}'); + } + + final int subOpcode = frame.getUint8(3); + if (frame.lengthInBytes < 5) { + if (subOpcode == 0x00) { + return const []; + } + throw Exception('IMU frame missing status byte: ${frame.lengthInBytes}'); + } + + final int status = frame.getUint8(4); + final ByteData payload = frame.lengthInBytes > 5 + ? ByteData.sublistView(frame, 5) + : ByteData.sublistView(frame, 5, 5); + + final Map baseHeader = { + 'sequenceNum': sequenceNum, + 'cmd': cmd, + 'subOpcode': subOpcode, + 'status': status, + }; + + switch (subOpcode) { + case 0x01: // Accel-only stream (ignored by design) + case 0x04: // Accel-only stream (ignored by design) + if (status == 0x01) { + return const []; + } + return const []; + case 0x06: // Accel + Gyro (12 bytes per sample) + if (status == 0x01) { + return const []; + } + return _parseAccelGyro( + data: payload, + receiveTs: receiveTs, + baseHeader: baseHeader, + samplePeriodMs: _samplePeriodMs, + ); + case 0x00: + // Common non-streaming/control response. + return const []; + default: + return const []; + } + } + + List> _parsePpgFrame( + ByteData frame, + int sequenceNum, + int cmd, + int receiveTs, + ) { + if (frame.lengthInBytes < 5) { + // Q2 control acks can be 4-byte frames (e.g. stop ack type=0x06). + if (frame.lengthInBytes == 4) { + return const []; + } + throw Exception('PPG frame too short: ${frame.lengthInBytes}'); + } + + final int type = frame.getUint8(3); + final int value = frame.getUint8(4); + + final Map baseHeader = { + 'sequenceNum': sequenceNum, + 'cmd': cmd, + 'type': type, + 'value': value, + }; + + if (type == 0xFF) { + logger.d('OpenRing PPG progress: $value%'); + if (value >= 100) { + logger.d('OpenRing PPG progress complete'); + } + return const []; + } + + if (type == 0x00) { + if (value == 0 || value == 2 || value == 4) { + final String reason = switch (value) { + 0 => 'not worn', + 2 => 'charging', + 4 => 'busy', + _ => 'unknown', + }; + logger.w('OpenRing PPG error packet received: code=$value ($reason)'); + return const []; + } + + if (value == 3) { + if (frame.lengthInBytes < 9) { + throw Exception( + 'Invalid final PPG result length: ${frame.lengthInBytes}', + ); + } + + final int heart = frame.getUint8(5); + final int q2 = frame.getUint8(6); + final int temp = frame.getInt16(7, Endian.little); + + logger.d( + 'OpenRing PPG result received: heart=$heart q2=$q2 temp=$temp', + ); + return const []; + } + + logger.w('OpenRing PPG result packet with unknown value=$value'); + return const []; + } + + if (type == 0x01) { + if (frame.lengthInBytes < 6) { + throw Exception('PPG waveform frame too short: ${frame.lengthInBytes}'); + } + + int nSamples = frame.getUint8(5); + int payloadOffset = 6; + + // Some firmware variants include an extra byte after sample count. + if (nSamples == 0 && frame.lengthInBytes >= 7) { + final int altSamples = frame.getUint8(6); + if (altSamples > 0) { + nSamples = altSamples; + payloadOffset = 7; + } + } + + final ByteData waveformPayload = ByteData.sublistView( + frame, + payloadOffset, + ); + + final List> waveform14 = _parsePpgWaveform( + data: waveformPayload, + nSamples: nSamples, + receiveTs: receiveTs, + baseHeader: baseHeader, + ); + if (waveform14.isNotEmpty) { + return waveform14; + } + + // Fallback observed on some OpenRing firmware revisions. + final List> waveform34 = _parsePpgWaveformType2( + data: waveformPayload, + nSamples: nSamples, + receiveTs: receiveTs, + baseHeader: baseHeader, + ); + if (waveform34.isNotEmpty) { + return waveform34; + } + + // Last-resort fallback (red + infrared only). + final List> waveform8 = _parsePpgWaveformType8( + data: waveformPayload, + nSamples: nSamples, + receiveTs: receiveTs, + baseHeader: baseHeader, + ); + if (waveform8.isNotEmpty) { + return waveform8; + } + + logger.w( + 'OpenRing PPG waveform packet could not be parsed ' + '(type=0x01, nSamples=$nSamples, payloadLen=${waveformPayload.lengthInBytes})', + ); + return const []; + } + + if (type == 0x02) { + if (frame.lengthInBytes < 6) { + throw Exception( + 'PPG extended waveform frame too short: ${frame.lengthInBytes}', + ); + } + + final int nSamples = frame.getUint8(5); + final ByteData waveformPayload = ByteData.sublistView(frame, 6); + + final List> realtimeType2 = + _parsePpgWaveformType2Realtime30( + data: waveformPayload, + nSamples: nSamples, + receiveTs: receiveTs, + baseHeader: baseHeader, + ); + if (realtimeType2.isNotEmpty) { + return realtimeType2; + } + + return _parsePpgWaveformType2( + data: waveformPayload, + nSamples: nSamples, + receiveTs: receiveTs, + baseHeader: baseHeader, + ); + } + + return const []; + } + + List> _parseAccelGyro({ + required ByteData data, + required int receiveTs, + required Map baseHeader, + required int samplePeriodMs, + }) { + final int usableBytes = data.lengthInBytes - (data.lengthInBytes % 12); + if (usableBytes == 0) { + return const []; + } + + final List> parsedData = []; + for (int i = 0; i < usableBytes; i += 12) { + final int sampleIndex = i ~/ 12; + final int ts = receiveTs + (sampleIndex + 1) * samplePeriodMs; + + final ByteData sample = ByteData.sublistView(data, i, i + 12); + final ByteData accBytes = ByteData.sublistView(sample, 0, 6); + final ByteData gyroBytes = ByteData.sublistView(sample, 6); + + final Map accelData = _parseImuComp(accBytes); + final Map gyroData = _parseImuComp(gyroBytes); + + parsedData.add({ + ...baseHeader, + 'timestamp': ts, + 'Accelerometer': accelData, + 'Gyroscope': gyroData, + }); + } + return parsedData; + } + + Map _parseImuComp(ByteData data) { + return { + 'X': data.getInt16(0, Endian.little), + 'Y': data.getInt16(2, Endian.little), + 'Z': data.getInt16(4, Endian.little), + }; + } + + List> _parsePpgWaveform({ + required ByteData data, + required int nSamples, + required int receiveTs, + required Map baseHeader, + }) { + final int expectedBytes = nSamples * 14; + final int usableBytes = data.lengthInBytes - (data.lengthInBytes % 14); + if (usableBytes == 0 || nSamples == 0) { + return const []; + } + + int usableSamples = usableBytes ~/ 14; + if (usableSamples > nSamples) { + usableSamples = nSamples; + } + + if (data.lengthInBytes != expectedBytes && nSamples > usableSamples) { + logger.w( + 'PPG waveform length mismatch len=${data.lengthInBytes} expected=$expectedBytes; parsing $usableSamples sample(s)', + ); + } + + final List> parsedData = []; + for (int i = 0; i < usableSamples; i++) { + final int offset = i * 14; + final int ts = receiveTs + (i + 1) * _samplePeriodMs; + + parsedData.add({ + ...baseHeader, + 'timestamp': ts, + 'PPG': { + 'Green': 0, + 'Red': data.getUint32(offset, Endian.little), + 'Infrared': data.getUint32(offset + 4, Endian.little), + }, + }); + } + + return parsedData; + } + + List> _parsePpgWaveformType2({ + required ByteData data, + required int nSamples, + required int receiveTs, + required Map baseHeader, + }) { + const int sampleSize = 34; + const int legacyTailSampleSize = 22; + + final int expectedBytes = nSamples * sampleSize; + if (nSamples == 0) { + return const []; + } + + // Observed firmware variant: + // n samples announced, but payload is (n-1)*34 + 22 bytes. + if (nSamples > 1 && + data.lengthInBytes == + ((nSamples - 1) * sampleSize + legacyTailSampleSize)) { + final List> parsedData = []; + + for (int i = 0; i < nSamples - 1; i++) { + final int offset = i * sampleSize; + final int ts = receiveTs + (i + 1) * _samplePeriodMs; + parsedData.add({ + ...baseHeader, + 'timestamp': ts, + 'PPG': { + 'Green': 0, + 'Red': data.getUint32(offset + 4, Endian.little), + 'Infrared': data.getUint32(offset + 8, Endian.little), + }, + }); + } + + final int tailOffset = (nSamples - 1) * sampleSize; + final int tailTs = receiveTs + nSamples * _samplePeriodMs; + parsedData.add({ + ...baseHeader, + 'timestamp': tailTs, + 'PPG': { + 'Green': 0, + 'Red': data.getUint32(tailOffset + 4, Endian.little), + 'Infrared': data.getUint32(tailOffset + 8, Endian.little), + }, + }); + + return parsedData; + } + + final int usableBytes = + data.lengthInBytes - (data.lengthInBytes % sampleSize); + if (usableBytes == 0) { + return const []; + } + + int usableSamples = usableBytes ~/ sampleSize; + if (usableSamples > nSamples) { + usableSamples = nSamples; + } + + if (data.lengthInBytes != expectedBytes) { + final String warningKey = + '${data.lengthInBytes}:$expectedBytes:$usableSamples:$nSamples'; + if (_seenType2MismatchWarnings.add(warningKey)) { + logger.w( + 'PPG type2 length mismatch len=${data.lengthInBytes} expected=$expectedBytes; parsing $usableSamples sample(s)', + ); + } + } + + final List> parsedData = []; + for (int i = 0; i < usableSamples; i++) { + final int offset = i * sampleSize; + final int ts = receiveTs + (i + 1) * _samplePeriodMs; + + parsedData.add({ + ...baseHeader, + 'timestamp': ts, + 'PPG': { + 'Green': 0, + 'Red': data.getUint32(offset + 4, Endian.little), + 'Infrared': data.getUint32(offset + 8, Endian.little), + }, + }); + } + + return parsedData; + } + + List> _parsePpgWaveformType8({ + required ByteData data, + required int nSamples, + required int receiveTs, + required Map baseHeader, + }) { + const int sampleSize = 8; + + final int expectedBytes = nSamples * sampleSize; + final int usableBytes = + data.lengthInBytes - (data.lengthInBytes % sampleSize); + if (usableBytes == 0 || nSamples == 0) { + return const []; + } + + int usableSamples = usableBytes ~/ sampleSize; + if (usableSamples > nSamples) { + usableSamples = nSamples; + } + + if (data.lengthInBytes != expectedBytes && nSamples > usableSamples) { + logger.w( + 'PPG type8 length mismatch len=${data.lengthInBytes} expected=$expectedBytes; parsing $usableSamples sample(s)', + ); + } + + final List> parsedData = []; + for (int i = 0; i < usableSamples; i++) { + final int offset = i * sampleSize; + final int ts = receiveTs + (i + 1) * _samplePeriodMs; + + parsedData.add({ + ...baseHeader, + 'timestamp': ts, + 'PPG': { + 'Green': 0, + 'Red': data.getUint32(offset, Endian.little), + 'Infrared': data.getUint32(offset + 4, Endian.little), + }, + }); + } + + return parsedData; + } + + List> _parsePpgWaveformType2Realtime30({ + required ByteData data, + required int nSamples, + required int receiveTs, + required Map baseHeader, + }) { + // Observed OpenRing type-0x02 packet: + // [8-byte timestamp][n * 30-byte samples] + // sample bytes (LE): + // 0..3 green uint32 + // 4..7 red uint32 + // 8..11 infrared uint32 + // 12..17 accX/accY/accZ int16 + // 18..23 gyroX/gyroY/gyroZ int16 + // 24..29 temp0/temp1/temp2 uint16 (milli-degC) + const int headerSize = 8; + const int sampleSize = 30; + + if (nSamples == 0 || data.lengthInBytes <= headerSize) { + return const []; + } + + final ByteData sampleData = ByteData.sublistView(data, headerSize); + final int expectedBytes = nSamples * sampleSize; + final int usableBytes = + sampleData.lengthInBytes - (sampleData.lengthInBytes % sampleSize); + if (usableBytes == 0) { + return const []; + } + + int usableSamples = usableBytes ~/ sampleSize; + if (usableSamples > nSamples) { + usableSamples = nSamples; + } + + if (sampleData.lengthInBytes != expectedBytes) { + final String warningKey = + '${sampleData.lengthInBytes}:$expectedBytes:$usableSamples:$nSamples'; + if (_seenType2RealtimeMismatchWarnings.add(warningKey)) { + logger.w( + 'PPG type2 realtime30 length mismatch len=${sampleData.lengthInBytes} expected=$expectedBytes; parsing $usableSamples sample(s)', + ); + } + } + + final List> parsedData = []; + for (int i = 0; i < usableSamples; i++) { + final int offset = i * sampleSize; + final int ts = receiveTs + (i + 1) * _samplePeriodMs; + + parsedData.add({ + ...baseHeader, + 'timestamp': ts, + 'PPG': { + 'Green': sampleData.getUint32(offset, Endian.little), + 'Red': sampleData.getUint32(offset + 4, Endian.little), + 'Infrared': sampleData.getUint32(offset + 8, Endian.little), + }, + 'Temperature': { + 'Temp0': + (sampleData.getUint16(offset + 24, Endian.little) / + _tempRawToCelsiusScale) + .round(), + 'Temp1': + (sampleData.getUint16(offset + 26, Endian.little) / + _tempRawToCelsiusScale) + .round(), + 'Temp2': + (sampleData.getUint16(offset + 28, Endian.little) / + _tempRawToCelsiusScale) + .round(), + 'units': '°C', + }, + }); + } + + return parsedData; + } +} diff --git a/lib/src/utils/sensor_value_parser/tau_ring_value_parser.dart b/lib/src/utils/sensor_value_parser/tau_ring_value_parser.dart deleted file mode 100644 index f5588c8..0000000 --- a/lib/src/utils/sensor_value_parser/tau_ring_value_parser.dart +++ /dev/null @@ -1,154 +0,0 @@ -import 'dart:typed_data'; - -import '../../../open_earable_flutter.dart' show logger; -import '../sensor_scheme_parser/sensor_scheme_reader.dart'; -import 'sensor_value_parser.dart'; - -class TauRingValueParser extends SensorValueParser { - // 100 Hz → 10 ms per sample - static const int _samplePeriodMs = 10; - - int _lastSeq = -1; - int _lastTs = 0; - - @override - List> parse( - ByteData data, - List sensorSchemes, - ) { - - - logger.t("Received Tau Ring sensor data: size: ${data.lengthInBytes} ${data.buffer.asUint8List()}"); - - - final int framePrefix = data.getUint8(0); - if (framePrefix != 0x00) { - throw Exception("Invalid frame prefix: $framePrefix"); // TODO: specific exception - } - - if (data.lengthInBytes < 5) { - throw Exception("Data too short to parse"); // TODO: specific exception - } - - final int sequenceNum = data.getUint8(1); - final int cmd = data.getUint8(2); - final int subOpcode = data.getUint8(3); - final int status = data.getUint8(4); - final ByteData payload = ByteData.sublistView(data, 5); - - logger.t("last sequenceNum: $_lastSeq, current sequenceNum: $sequenceNum"); - if (sequenceNum != _lastSeq) { - _lastSeq = sequenceNum; - _lastTs = 0; - logger.d("Sequence number changed. Resetting last timestamp."); - } - - // These header fields should go into every sample map - final Map baseHeader = { - "sequenceNum": sequenceNum, - "cmd": cmd, - "subOpcode": subOpcode, - "status": status, - }; - - List> result; - switch (cmd) { - case 0x40: // IMU - switch (subOpcode) { - case 0x01: // Accel only (6 bytes per sample) - result = _parseAccel( - data: payload, - receiveTs: _lastTs, - baseHeader: baseHeader, - ); - case 0x06: // Accel + Gyro (12 bytes per sample) - result = _parseAccelGyro( - data: payload, - receiveTs: _lastTs, - baseHeader: baseHeader, - ); - default: - throw Exception("Unknown sub-opcode for sensor data: $subOpcode"); - } - - default: - throw Exception("Unknown command: $cmd"); - } - if (result.isNotEmpty) { - _lastTs = result.last["timestamp"] as int; - logger.t("Updated last timestamp to $_lastTs"); - } - return result; - } - - List> _parseAccel({ - required ByteData data, - required int receiveTs, - required Map baseHeader, - }) { - if (data.lengthInBytes % 6 != 0) { - throw Exception("Invalid data length for Accel: ${data.lengthInBytes}"); - } - - final int nSamples = data.lengthInBytes ~/ 6; - if (nSamples == 0) return const []; - - final List> parsedData = []; - for (int i = 0; i < data.lengthInBytes; i += 6) { - final int sampleIndex = i ~/ 6; - final int ts = receiveTs + sampleIndex * _samplePeriodMs; - - final ByteData sample = ByteData.sublistView(data, i, i + 6); - final Map accelData = _parseImuComp(sample); - - parsedData.add({ - ...baseHeader, - "timestamp": ts, - "Accelerometer": accelData, - }); - } - return parsedData; - } - - List> _parseAccelGyro({ - required ByteData data, - required int receiveTs, - required Map baseHeader, - }) { - if (data.lengthInBytes % 12 != 0) { - throw Exception("Invalid data length for Accel+Gyro: ${data.lengthInBytes}"); - } - - final int nSamples = data.lengthInBytes ~/ 12; - if (nSamples == 0) return const []; - - final List> parsedData = []; - for (int i = 0; i < data.lengthInBytes; i += 12) { - final int sampleIndex = i ~/ 12; - final int ts = receiveTs + sampleIndex * _samplePeriodMs; - - final ByteData sample = ByteData.sublistView(data, i, i + 12); - final ByteData accBytes = ByteData.sublistView(sample, 0, 6); - final ByteData gyroBytes = ByteData.sublistView(sample, 6); - - final Map accelData = _parseImuComp(accBytes); - final Map gyroData = _parseImuComp(gyroBytes); - - parsedData.add({ - ...baseHeader, - "timestamp": ts, - "Accelerometer": accelData, - "Gyroscope": gyroData, - }); - } - return parsedData; - } - - Map _parseImuComp(ByteData data) { - return { - 'X': data.getInt16(0, Endian.little), - 'Y': data.getInt16(2, Endian.little), - 'Z': data.getInt16(4, Endian.little), - }; - } -}