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_ring/openring.png b/assets/wearable_icons/open_ring/openring.png new file mode 100644 index 0000000..48cccac Binary files /dev/null and b/assets/wearable_icons/open_ring/openring.png differ diff --git a/example/lib/widgets/sensor_configuration_view.dart b/example/lib/widgets/sensor_configuration_view.dart index d8e7afe..e510bf3 100644 --- a/example/lib/widgets/sensor_configuration_view.dart +++ b/example/lib/widgets/sensor_configuration_view.dart @@ -33,7 +33,8 @@ class _SensorConfigurationViewState extends State { @override void initState() { super.initState(); - _selectedValue = widget.configuration.values.first; + _selectedValue = + widget.configuration.offValue ?? widget.configuration.values.first; } @override diff --git a/lib/open_earable_flutter.dart b/lib/open_earable_flutter.dart index b357438..f0ca29f 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(), ]; @@ -262,16 +262,53 @@ class WearableManager { } String deviceErrorMessage(dynamic e, String deviceName) { + final normalizedDeviceName = _formatDisplayDeviceName(deviceName); return switch (e) { - UnsupportedDeviceException _ => 'Device "$deviceName" is not supported.', + UnsupportedDeviceException _ => + 'Device "$normalizedDeviceName" is not supported.', AlreadyConnectedException _ => - 'Device "$deviceName" is already connected.', + 'Device "$normalizedDeviceName" is already connected.', ConnectionFailedException _ => - 'Failed to connect to device "$deviceName". Please try again.', - _ => e.toString(), + 'Failed to connect to device "$normalizedDeviceName". Please try again.', + _ => _normalizeDeviceNameInMessage( + message: e.toString(), + rawDeviceName: deviceName, + normalizedDeviceName: normalizedDeviceName, + ), }; } + String _formatDisplayDeviceName(String rawName) { + final trimmed = rawName.trim(); + if (trimmed.isEmpty) { + return trimmed; + } + + final replaced = trimmed.replaceFirst( + RegExp(r'^bcl[-_\s]*', caseSensitive: false), + 'OpenRing-', + ); + + if (replaced == 'OpenRing-') { + return 'OpenRing'; + } + + return replaced; + } + + String _normalizeDeviceNameInMessage({ + required String message, + required String rawDeviceName, + required String normalizedDeviceName, + }) { + if (rawDeviceName.isEmpty || + rawDeviceName == normalizedDeviceName || + message.isEmpty) { + return message; + } + return message.replaceAll(rawDeviceName, normalizedDeviceName); + } + void addPairingRule(PairingRule rule) { _pairingManager.addRule(rule); } 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..6081ec9 --- /dev/null +++ b/lib/src/managers/open_ring_sensor_handler.dart @@ -0,0 +1,770 @@ +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 = 20; + static const int _minSampleDelayMs = 12; + static const int _maxSampleDelayMs = 22; + static const int _maxScheduleLagMs = 80; + static const double _delayAlpha = 0.12; + static const double _backlogCompressionPerPacket = 0.06; + static const int _commandSettleDelayMs = 45; + static const List _ppgRealtimeStartPayload = [ + 0x00, + 0x00, + 0x19, + 0x01, + 0x01, + ]; + static const List _ppgRealtimeStopPayload = [0x06]; + static const Set _pacedStreamingCommands = { + OpenRingGatt.cmdPPGQ2, + }; + + Stream>? _sensorDataStream; + Future _commandQueue = Future.value(); + final _OpenRingDesiredState _desiredState = _OpenRingDesiredState(); + int _applyVersion = 0; + int _transportTimingResetCounter = 0; + bool _isApplying = false; + bool _hasRealtimeConfigurationWrite = false; + bool _hasAdoptedInitialStreamingState = false; + void Function()? _onInitialStreamingDetected; + _OpenRingTransportCommand _lastAppliedTransport = + _OpenRingTransportCommand.none; + + 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"); + } + + if (!_isRealtimeStreamingCommand(sensorConfig.cmd)) { + await _writeCommand(sensorConfig); + return; + } + + _hasRealtimeConfigurationWrite = true; + _updateDesiredStateFromSensorConfig(sensorConfig); + await _enqueueApplyDesiredTransport( + reason: 'config-write-cmd-${sensorConfig.cmd}', + ); + } + + Future>> _parseData(List data) async { + final byteData = ByteData.sublistView(Uint8List.fromList(data)); + return _sensorValueParser.parse(byteData, []); + } + + void setTemperatureStreamEnabled(bool enabled) { + _hasRealtimeConfigurationWrite = true; + _desiredState.temperatureEnabled = enabled; + logger.d('OpenRing software toggle: temperatureStream=$enabled'); + + unawaited( + _enqueueApplyDesiredTransport( + reason: 'temperature-set-$enabled', + ), + ); + } + + void setInitialStreamingDetectedCallback(void Function()? callback) { + _onInitialStreamingDetected = callback; + } + + bool get hasActiveRealtimeStreaming => + _desiredState.hasAnyEnabled || + _isApplying || + _lastAppliedTransport != _OpenRingTransportCommand.none; + + bool _isRealtimeStreamingCommand(int cmd) => + cmd == OpenRingGatt.cmdIMU || cmd == OpenRingGatt.cmdPPGQ2; + + void _updateDesiredStateFromSensorConfig(OpenRingSensorConfig sensorConfig) { + final bool isStart = _isRealtimeStreamingStart(sensorConfig); + final bool isStop = _isRealtimeStreamingStop(sensorConfig); + if (!isStart && !isStop) { + logger.d( + 'Ignoring OpenRing realtime config with unknown payload ' + '(cmd=${sensorConfig.cmd}, payload=${sensorConfig.payload})', + ); + return; + } + + if (sensorConfig.cmd == OpenRingGatt.cmdIMU) { + if (isStart) { + _desiredState.imuEnabled = true; + } else { + _desiredState.imuEnabled = false; + } + return; + } + + if (sensorConfig.cmd == OpenRingGatt.cmdPPGQ2) { + _desiredState.ppgEnabled = isStart; + } + } + + Future _enqueueApplyDesiredTransport({ + required String reason, + }) { + _applyVersion += 1; + final int requestVersion = _applyVersion; + + _commandQueue = + _commandQueue.catchError((Object error, StackTrace stackTrace) { + logger.e('OpenRing previous command failed: $error'); + logger.t(stackTrace); + }).then((_) async { + if (requestVersion != _applyVersion) { + return; + } + await _applyDesiredTransport( + requestVersion: requestVersion, + reason: reason, + ); + }); + + return _commandQueue; + } + + Future _applyDesiredTransport({ + required int requestVersion, + required String reason, + }) async { + if (!_bleManager.isConnected(_discoveredDevice.id)) { + return; + } + + final _OpenRingTransportCommand desiredTransport = + _desiredState.resolveDesiredTransport(); + if (desiredTransport == _lastAppliedTransport) { + return; + } + + _isApplying = true; + try { + logger.d( + 'OpenRing apply transport ($reason): ' + '${_desiredState.debugSummary(desiredTransport)}', + ); + + if (_lastAppliedTransport == _OpenRingTransportCommand.ppg && + desiredTransport == _OpenRingTransportCommand.none) { + await _writeCommand( + OpenRingSensorConfig( + cmd: OpenRingGatt.cmdPPGQ2, + payload: List.from(_ppgRealtimeStopPayload), + ), + ); + _transportTimingResetCounter += 1; + _lastAppliedTransport = _OpenRingTransportCommand.none; + await Future.delayed( + const Duration(milliseconds: _commandSettleDelayMs), + ); + return; + } + + if (desiredTransport == _OpenRingTransportCommand.none) { + _lastAppliedTransport = _OpenRingTransportCommand.none; + return; + } + + await _writeCommand( + OpenRingSensorConfig( + cmd: OpenRingGatt.cmdPPGQ2, + payload: List.from(_ppgRealtimeStopPayload), + ), + ); + await Future.delayed( + const Duration(milliseconds: _commandSettleDelayMs), + ); + if (!_shouldContinueApply(requestVersion)) { + return; + } + + await _writeCommand( + OpenRingSensorConfig( + cmd: OpenRingGatt.cmdPPGQ2, + payload: List.from(_ppgRealtimeStartPayload), + ), + ); + _transportTimingResetCounter += 1; + _lastAppliedTransport = _OpenRingTransportCommand.ppg; + await Future.delayed( + const Duration(milliseconds: _commandSettleDelayMs), + ); + } finally { + _isApplying = false; + } + } + + bool _shouldContinueApply(int requestVersion) { + return requestVersion == _applyVersion && + _bleManager.isConnected(_discoveredDevice.id); + } + + void _emitSample( + StreamController> streamController, + Map sample, + ) { + if (streamController.isClosed) { + return; + } + if (_isApplying) { + return; + } + + final filtered = Map.from(sample); + + final dynamic cmd = filtered['cmd']; + final bool isPpgSample = cmd is int && cmd == OpenRingGatt.cmdPPGQ2; + final bool shouldConsumeTransport = _desiredState.hasAnyEnabled; + + if (isPpgSample) { + if (!shouldConsumeTransport) { + return; + } + if (!_desiredState.temperatureEnabled) { + filtered.remove('Temperature'); + } + if (!_desiredState.ppgEnabled) { + filtered.remove('PPG'); + } + + final bool hasImuPayload = _hasImuPayload(filtered); + if (_desiredState.imuEnabled && hasImuPayload) { + final imuAlias = _createImuAliasFromPpg(filtered); + _removeImuPayload(filtered); + _emitIfSampleHasSensorPayload(streamController, filtered); + streamController.add(imuAlias); + return; + } + + if (!_desiredState.imuEnabled && hasImuPayload) { + _removeImuPayload(filtered); + } + + _emitIfSampleHasSensorPayload(streamController, filtered); + return; + } + + // 0x40 transport is intentionally ignored. IMU is emitted via 0x32 aliasing. + if (cmd is int && cmd == OpenRingGatt.cmdIMU) { + return; + } + + _emitIfSampleHasSensorPayload(streamController, filtered); + } + + void _emitIfSampleHasSensorPayload( + StreamController> streamController, + Map sample, + ) { + if (!_hasAnySensorPayload(sample)) { + return; + } + streamController.add(sample); + } + + bool _hasImuPayload(Map sample) { + return sample.containsKey('Accelerometer') || + sample.containsKey('Gyroscope'); + } + + bool _hasAnySensorPayload(Map sample) { + return _hasImuPayload(sample) || + sample.containsKey('PPG') || + sample.containsKey('Temperature'); + } + + void _removeImuPayload(Map sample) { + sample.remove('Accelerometer'); + sample.remove('Gyroscope'); + } + + Map _createImuAliasFromPpg(Map sample) { + final imuAlias = Map.from(sample); + imuAlias['cmd'] = OpenRingGatt.cmdIMU; + imuAlias.remove('PPG'); + imuAlias.remove('Temperature'); + return imuAlias; + } + + List> _filterSamplesForScheduling( + List> parsedSamples, + ) { + return parsedSamples.where(_shouldScheduleSample).toList(growable: false); + } + + bool _shouldScheduleSample(Map sample) { + if (_isApplying) { + return false; + } + + final dynamic cmd = sample['cmd']; + if (cmd is! int) { + return _hasAnySensorPayload(sample); + } + + _adoptInitialStreamingStateIfNeeded(sample, cmd); + + final bool shouldConsumeTransport = _desiredState.hasAnyEnabled; + final bool hasImuPayload = _hasImuPayload(sample); + final bool hasPpgPayload = sample.containsKey('PPG'); + final bool hasTemperaturePayload = sample.containsKey('Temperature'); + + if (cmd == OpenRingGatt.cmdPPGQ2) { + if (!shouldConsumeTransport) { + return false; + } + final bool shouldEmitImu = _desiredState.imuEnabled && hasImuPayload; + final bool shouldEmitPpg = _desiredState.ppgEnabled && hasPpgPayload; + final bool shouldEmitTemperature = + _desiredState.temperatureEnabled && hasTemperaturePayload; + return shouldEmitImu || shouldEmitPpg || shouldEmitTemperature; + } + + if (cmd == OpenRingGatt.cmdIMU) { + return false; + } + + return _hasAnySensorPayload(sample); + } + + void _adoptInitialStreamingStateIfNeeded( + Map sample, + int cmd, + ) { + if (_hasAdoptedInitialStreamingState || + _hasRealtimeConfigurationWrite || + _desiredState.hasAnyEnabled) { + return; + } + if (cmd != OpenRingGatt.cmdPPGQ2) { + return; + } + if (!_hasAnySensorPayload(sample)) { + return; + } + + _hasAdoptedInitialStreamingState = true; + _desiredState.imuEnabled = true; + _desiredState.ppgEnabled = true; + _desiredState.temperatureEnabled = true; + _lastAppliedTransport = _OpenRingTransportCommand.ppg; + _transportTimingResetCounter += 1; + + logger.i( + 'OpenRing detected active realtime stream on initial start; ' + 'assuming IMU/PPG/Temperature enabled', + ); + _onInitialStreamingDetected?.call(); + } + + Stream> _createSensorDataStream() { + late final StreamController> streamController; + // ignore: cancel_subscriptions + StreamSubscription>? bleSubscription; + + final scheduler = _OpenRingPacedScheduler( + pacedCommands: _pacedStreamingCommands, + defaultSampleDelayMs: _defaultSampleDelayMs, + minSampleDelayMs: _minSampleDelayMs, + maxSampleDelayMs: _maxSampleDelayMs, + maxScheduleLagMs: _maxScheduleLagMs, + delayAlpha: _delayAlpha, + backlogCompressionPerPacket: _backlogCompressionPerPacket, + ); + + // Keep command families independent. + final Map> processingQueueByCmd = {}; + + 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; + } + + final filteredForScheduling = _filterSamplesForScheduling(parsedData); + if (filteredForScheduling.isEmpty) { + return; + } + + if (cmdKey == null) { + for (final sample in filteredForScheduling) { + _emitSample(streamController, sample); + } + return; + } + + if (!scheduler.isPacedCommand(cmdKey)) { + for (final sample in filteredForScheduling) { + _emitSample(streamController, sample); + } + return; + } + + await scheduler.emitPacedSamples( + cmd: cmdKey, + samples: filteredForScheduling, + arrivalMs: arrivalMs, + onEmitSample: (sample) => _emitSample(streamController, sample), + ); + } finally { + scheduler.finishPacket(rawCmd: rawCmd, parsedCmd: cmdKey); + } + } + + streamController = StreamController>.broadcast( + onListen: () { + bleSubscription ??= _bleManager + .subscribe( + deviceId: _discoveredDevice.id, + serviceId: OpenRingGatt.service, + characteristicId: OpenRingGatt.rxChar, + ) + .listen( + (data) { + scheduler.resetIfRequested( + _transportTimingResetCounter, + const [OpenRingGatt.cmdPPGQ2], + ); + + final int? rawCmd = data.length > 2 ? data[2] : null; + if (rawCmd != null) { + scheduler.notePacketQueued(rawCmd); + } + + final int arrivalMs = scheduler.nowMonotonicMs; + 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(); + scheduler.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; + } +} + +class _OpenRingDesiredState { + bool imuEnabled = false; + bool ppgEnabled = false; + bool temperatureEnabled = false; + + bool get hasAnyEnabled => imuEnabled || ppgEnabled || temperatureEnabled; + + _OpenRingTransportCommand resolveDesiredTransport() { + if (hasAnyEnabled) { + return _OpenRingTransportCommand.ppg; + } + return _OpenRingTransportCommand.none; + } + + String debugSummary(_OpenRingTransportCommand transport) { + return 'imu=$imuEnabled ppg=$ppgEnabled temp=$temperatureEnabled ' + 'transport=$transport'; + } +} + +enum _OpenRingTransportCommand { + none, + ppg, +} + +class _OpenRingPacedScheduler { + _OpenRingPacedScheduler({ + required this.pacedCommands, + required this.defaultSampleDelayMs, + required this.minSampleDelayMs, + required this.maxSampleDelayMs, + required this.maxScheduleLagMs, + required this.delayAlpha, + required this.backlogCompressionPerPacket, + }) : _clock = Stopwatch()..start(), + _wallClockAnchorMs = DateTime.now().millisecondsSinceEpoch; + + final Set pacedCommands; + final int defaultSampleDelayMs; + final int minSampleDelayMs; + final int maxSampleDelayMs; + final int maxScheduleLagMs; + final double delayAlpha; + final double backlogCompressionPerPacket; + + final Stopwatch _clock; + final int _wallClockAnchorMs; + + final Map _lastArrivalByCmd = {}; + final Map _delayEstimateByCmd = {}; + final Map _nextDueByCmd = {}; + final Map _emittedTimestampByCmd = {}; + final Map _pendingPacketsByCmd = {}; + int _seenTimingResetCounter = 0; + + int get nowMonotonicMs => _clock.elapsedMilliseconds; + + bool isPacedCommand(int cmd) => pacedCommands.contains(cmd); + + void notePacketQueued(int rawCmd) { + _pendingPacketsByCmd[rawCmd] = (_pendingPacketsByCmd[rawCmd] ?? 0) + 1; + } + + void clear() { + _lastArrivalByCmd.clear(); + _delayEstimateByCmd.clear(); + _nextDueByCmd.clear(); + _emittedTimestampByCmd.clear(); + _pendingPacketsByCmd.clear(); + } + + void resetIfRequested(int timingResetCounter, Iterable commandKeys) { + if (_seenTimingResetCounter == timingResetCounter) { + return; + } + _seenTimingResetCounter = timingResetCounter; + for (final key in commandKeys) { + _lastArrivalByCmd.remove(key); + _delayEstimateByCmd.remove(key); + _nextDueByCmd.remove(key); + _emittedTimestampByCmd.remove(key); + } + } + + Future emitPacedSamples({ + required int cmd, + required List> samples, + required int arrivalMs, + required void Function(Map sample) onEmitSample, + }) async { + final int stepMs = _resolveStepMs( + cmd: cmd, + sampleCount: samples.length, + arrivalMs: arrivalMs, + ); + + int nextDueMs = _nextDueByCmd[cmd] ?? arrivalMs; + final int nowMs = _clock.elapsedMilliseconds; + if (nextDueMs < nowMs - maxScheduleLagMs) { + nextDueMs = nowMs - maxScheduleLagMs; + } + + for (final sample in samples) { + final int now = _clock.elapsedMilliseconds; + if (nextDueMs > now) { + await Future.delayed(Duration(milliseconds: nextDueMs - now)); + } + + final int epochNowMs = _toEpochMs(_clock.elapsedMilliseconds); + final int previousTsRaw = + _emittedTimestampByCmd[cmd] ?? (epochNowMs - stepMs); + final int previousTs = + previousTsRaw > epochNowMs ? (epochNowMs - stepMs) : previousTsRaw; + int nextTs = previousTs + stepMs; + final int minTs = epochNowMs - maxScheduleLagMs; + if (nextTs < minTs) { + // After packet stalls, do not keep emitting stale timestamps. + // Keep stream time close to "now" so charts do not rewind on resume. + nextTs = minTs; + } + if (nextTs > epochNowMs) { + nextTs = epochNowMs; + } + if (nextTs <= previousTs) { + nextTs = math.min(epochNowMs, previousTs + 1); + } + _emittedTimestampByCmd[cmd] = nextTs; + sample['timestamp'] = nextTs; + + onEmitSample(sample); + + final int emitNow = _clock.elapsedMilliseconds; + nextDueMs = math.max(nextDueMs, emitNow) + stepMs; + } + + _nextDueByCmd[cmd] = nextDueMs; + } + + void finishPacket({int? rawCmd, int? parsedCmd}) { + if (parsedCmd != null) { + _decrementPending(parsedCmd); + } + if (rawCmd != null && rawCmd != parsedCmd) { + _decrementPending(rawCmd); + } + } + + int _toEpochMs(int monotonicMs) => _wallClockAnchorMs + monotonicMs; + + 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; + } +} + +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..072e58a --- /dev/null +++ b/lib/src/models/capabilities/sensor_configuration_specializations/open_ring_sensor_configuration.dart @@ -0,0 +1,142 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:open_earable_flutter/src/managers/open_ring_sensor_handler.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart' show logger; + +import 'configurable_sensor_configuration.dart'; +import 'sensor_frequency_configuration.dart'; +import 'streamable_sensor_configuration.dart'; + +typedef OpenRingConfigurationAppliedCallback = void Function( + OpenRingSensorConfiguration configuration, + OpenRingSensorConfigurationValue value, +); + +class OpenRingSensorConfiguration + extends SensorFrequencyConfiguration + implements + ConfigurableSensorConfiguration { + final OpenRingSensorHandler _sensorHandler; + final Set _availableOptions; + OpenRingConfigurationAppliedCallback? onConfigurationApplied; + + @override + Set get availableOptions => _availableOptions; + + OpenRingSensorConfiguration({ + required super.name, + required super.values, + super.offValue, + required OpenRingSensorHandler sensorHandler, + Set? availableOptions, + this.onConfigurationApplied, + }) : _sensorHandler = sensorHandler, + _availableOptions = availableOptions ?? {StreamSensorConfigOption()}; + + @override + void setConfiguration(OpenRingSensorConfigurationValue value) { + onConfigurationApplied?.call(this, value); + + if (value.softwareToggleOnly) { + _sensorHandler.setTemperatureStreamEnabled(value.streamData); + return; + } + + final payload = value.streamData ? value.startPayload : value.stopPayload; + final config = OpenRingSensorConfig(cmd: value.cmd, payload: payload); + unawaited( + _sensorHandler.writeSensorConfig(config).catchError(( + Object error, + StackTrace stackTrace, + ) { + logger.e( + 'Failed to apply OpenRing sensor config ' + '(cmd=${value.cmd}, stream=${value.streamData}): $error', + ); + logger.t(stackTrace); + }), + ); + } +} + +class OpenRingSensorConfigurationValue extends SensorFrequencyConfigurationValue + implements ConfigurableSensorConfigurationValue { + final int cmd; + final List startPayload; + final List stopPayload; + final bool softwareToggleOnly; + + @override + final Set options; + + bool get streamData => + options.any((option) => option is StreamSensorConfigOption); + + OpenRingSensorConfigurationValue({ + required super.frequencyHz, + required this.cmd, + required List startPayload, + required List stopPayload, + this.softwareToggleOnly = false, + this.options = const {}, + }) : startPayload = List.unmodifiable(startPayload), + stopPayload = List.unmodifiable(stopPayload), + super(key: '${frequencyHz}Hz ${_optionsToString(options)}'); + + @override + OpenRingSensorConfigurationValue withoutOptions() { + return OpenRingSensorConfigurationValue( + frequencyHz: frequencyHz, + cmd: cmd, + startPayload: startPayload, + stopPayload: stopPayload, + softwareToggleOnly: softwareToggleOnly, + options: const {}, + ); + } + + OpenRingSensorConfigurationValue copyWith({ + double? frequencyHz, + Set? options, + }) { + return OpenRingSensorConfigurationValue( + frequencyHz: frequencyHz ?? this.frequencyHz, + cmd: cmd, + startPayload: startPayload, + stopPayload: stopPayload, + softwareToggleOnly: softwareToggleOnly, + options: options ?? this.options, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is OpenRingSensorConfigurationValue && + other.frequencyHz == frequencyHz && + other.cmd == cmd && + listEquals(other.startPayload, startPayload) && + listEquals(other.stopPayload, stopPayload) && + other.softwareToggleOnly == softwareToggleOnly && + setEquals(other.options, options); + } + + @override + int get hashCode => + frequencyHz.hashCode ^ + cmd.hashCode ^ + Object.hashAll(startPayload) ^ + Object.hashAll(stopPayload) ^ + softwareToggleOnly.hashCode ^ + options.hashCode; + + static String _optionsToString(Set options) { + String trailer = 'off'; + if (options.any((option) => option is StreamSensorConfigOption)) { + trailer = 'stream'; + } + return trailer; + } +} 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..a363db8 --- /dev/null +++ b/lib/src/models/capabilities/sensor_specializations/open_ring/open_ring_sensor.dart @@ -0,0 +1,133 @@ +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; + int? _lastEmittedTimestamp; + + // 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 SensorDoubleValue? 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; + _lastEmittedTimestamp = null; + if (subscription != null) { + await subscription.cancel(); + } + } + + int _normalizeTimestamp(int rawTimestamp) { + int normalized = rawTimestamp; + final int nowMs = DateTime.now().millisecondsSinceEpoch; + if (normalized > nowMs) { + normalized = nowMs; + } + + final int? last = _lastEmittedTimestamp; + if (last != null && normalized <= last) { + normalized = last + 1; + } + _lastEmittedTimestamp = normalized; + return normalized; + } + + SensorDoubleValue? _toSensorValue(Map data) { + if (!data.containsKey(sensorName)) { + return null; + } + + final sensorData = data[sensorName]; + final rawTimestamp = data['timestamp']; + if (sensorData is! Map || rawTimestamp is! int) { + return null; + } + final timestamp = _normalizeTimestamp(rawTimestamp); + + final Map sensorDataMap = sensorData; + final List values = []; + for (final axisName in _axisNames) { + final dynamic axisValue = sensorDataMap[axisName]; + if (axisValue is int) { + values.add(axisValue.toDouble()); + } else if (axisValue is double) { + 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).toDouble()); + } else if (entry.value is double) { + values.add(entry.value as double); + } + } + } + + if (values.isEmpty) { + return null; + } + + return SensorDoubleValue(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/open_ring.dart b/lib/src/models/devices/open_ring.dart new file mode 100644 index 0000000..be97c3a --- /dev/null +++ b/lib/src/models/devices/open_ring.dart @@ -0,0 +1,536 @@ +import 'dart:async'; + +import '../../../open_earable_flutter.dart'; +import '../capabilities/sensor_configuration_specializations/open_ring_sensor_configuration.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 { + _initializeAssumedSensorStates(); + } + + final DiscoveredDevice _discoveredDevice; + + final List _sensors; + final List _sensorConfigs; + final BleGattManager _bleManager; + final bool Function()? _isSensorStreamingActive; + + bool _batteryPollingWasSkippedForStreaming = false; + int? _lastKnownBatteryPercentage; + final Map, + OpenRingSensorConfigurationValue> _offValueByConfiguration = {}; + final Map, + OpenRingSensorConfigurationValue> _onValueByConfiguration = {}; + Map, SensorConfigurationValue> + _currentSensorConfigMap = {}; + + StreamController< + Map, + SensorConfigurationValue>>? _sensorConfigController; + + static const int _batteryReadType = 0x00; + static const int _batteryPushType = 0x02; + static const Duration _batteryResponseTimeout = Duration(milliseconds: 1800); + + @override + final String deviceId; + + @override + String? getWearableIconPath({ + bool darkmode = false, + }) { + return 'packages/open_earable_flutter/assets/wearable_icons/open_ring/openring.png'; + } + + @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 { + _sensorConfigController ??= StreamController< + Map, + SensorConfigurationValue>>.broadcast(); + final controller = _sensorConfigController!; + + return Stream< + Map, + SensorConfigurationValue>>.multi((emitter) { + emitter.add(Map.unmodifiable(Map.of(_currentSensorConfigMap))); + final sub = controller.stream.listen( + emitter.add, + onError: emitter.addError, + ); + emitter.onCancel = sub.cancel; + }); + } + + void _initializeAssumedSensorStates() { + _offValueByConfiguration.clear(); + _onValueByConfiguration.clear(); + final initialMap = , + SensorConfigurationValue>{}; + + for (final rawConfig in _sensorConfigs) { + if (rawConfig is! OpenRingSensorConfiguration) { + continue; + } + + final values = rawConfig.values + .whereType() + .toList(growable: false); + if (values.isEmpty) { + continue; + } + + final offValue = rawConfig.offValue is OpenRingSensorConfigurationValue + ? rawConfig.offValue as OpenRingSensorConfigurationValue + : values.firstWhere( + (value) => !value.streamData, + orElse: () => values.first, + ); + final onValue = values.firstWhere( + (value) => value.streamData, + orElse: () => offValue, + ); + + final configuration = + rawConfig as SensorConfiguration; + _offValueByConfiguration[configuration] = offValue; + _onValueByConfiguration[configuration] = onValue; + initialMap[configuration] = offValue; + } + + _currentSensorConfigMap = initialMap; + } + + void assumeConfigurationApplied({ + required OpenRingSensorConfiguration configuration, + required OpenRingSensorConfigurationValue value, + }) { + final configurationKey = + configuration as SensorConfiguration; + final offValue = _offValueByConfiguration[configurationKey]; + if (offValue == null) { + return; + } + + final SensorConfigurationValue nextValue = + value.streamData ? value : offValue; + final previousValue = _currentSensorConfigMap[configurationKey]; + if (previousValue == nextValue) { + return; + } + + _currentSensorConfigMap[configurationKey] = nextValue; + _emitSensorConfigurationState(); + } + + void assumeAllConfigurationsEnabledFromDetectedStreaming() { + bool changed = false; + for (final entry in _onValueByConfiguration.entries) { + if (_currentSensorConfigMap[entry.key] == entry.value) { + continue; + } + _currentSensorConfigMap[entry.key] = entry.value; + changed = true; + } + if (changed) { + _emitSensorConfigurationState(); + } + } + + void _emitSensorConfigurationState() { + final controller = _sensorConfigController; + if (controller == null || controller.isClosed) { + return; + } + + controller.add(Map.unmodifiable(Map.of(_currentSensorConfigMap))); + } + + @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) { + final response = _parseBatteryResponse(data); + if (response == null || !response.isRead) { + return; + } + if (response.frameId != frameId) { + return; + } + _lastKnownBatteryPercentage = response.batteryPercentage; + if (!completer.isCompleted) { + completer.complete(response.batteryPercentage); + } + }, + 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(); + } + } + + /// One-time battery pull triggered on connect/device creation. + Future prefetchBatteryOnConnect() async { + if (!_bleManager.isConnected(deviceId)) { + return false; + } + + try { + await readBatteryPercentage(); + return true; + } catch (error) { + logger.w('OpenRing initial battery read failed for $deviceId: $error'); + return false; + } + } + + _OpenRingBatteryResponse? _parseBatteryResponse(List data) { + if (data.length < 5) { + return null; + } + + final int frameType = data[0] & 0xFF; + if (frameType != 0x00) { + return null; + } + + final int frameId = data[1] & 0xFF; + final int cmd = data[2] & 0xFF; + if (cmd != OpenRingGatt.cmdBatt) { + return null; + } + + final int type = data[3] & 0xFF; + if (type != _batteryReadType && type != _batteryPushType) { + return null; + } + + final int batteryPercentage = data[4] & 0xFF; + return _OpenRingBatteryResponse( + frameId: frameId, + type: type, + batteryPercentage: batteryPercentage, + ); + } + + @override + Stream get batteryPercentageStream { + StreamController controller = StreamController(); + Timer? batteryPollingTimer; + StreamSubscription>? batteryPushSubscription; + bool batteryPollingInFlight = false; + int? lastEmittedBatteryPercentage; + + void emitIfChanged(int batteryPercentage) { + if (controller.isClosed || + batteryPercentage == lastEmittedBatteryPercentage) { + return; + } + _lastKnownBatteryPercentage = batteryPercentage; + lastEmittedBatteryPercentage = batteryPercentage; + controller.add(batteryPercentage); + } + + 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(); + emitIfChanged(batteryPercentage); + } catch (e) { + logger.e('Error reading OpenRing battery percentage: $e'); + } finally { + batteryPollingInFlight = false; + } + } + + controller.onCancel = () { + batteryPollingTimer?.cancel(); + unawaited(batteryPushSubscription?.cancel()); + }; + + controller.onListen = () { + final initialBatteryPercentage = _lastKnownBatteryPercentage; + if (initialBatteryPercentage != null) { + emitIfChanged(initialBatteryPercentage); + } + + batteryPushSubscription = _bleManager + .subscribe( + deviceId: deviceId, + serviceId: OpenRingGatt.service, + characteristicId: OpenRingGatt.rxChar, + ) + .listen( + (data) { + final response = _parseBatteryResponse(data); + if (response == null || !response.isPush) { + return; + } + emitIfChanged(response.batteryPercentage); + }, + onError: (error) { + logger.w('OpenRing battery push subscription error: $error'); + }, + ); + + batteryPollingTimer = Timer.periodic(const Duration(seconds: 5), (timer) { + unawaited(pollBattery()); + }); + unawaited(pollBattery()); + }; + + return controller.stream; + } +} + +class _OpenRingBatteryResponse { + const _OpenRingBatteryResponse({ + required this.frameId, + required this.type, + required this.batteryPercentage, + }); + + final int frameId; + final int type; + final int batteryPercentage; + + bool get isRead => type == OpenRing._batteryReadType; + + bool get isPush => type == OpenRing._batteryPushType; +} + +// 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..9fd1bb5 --- /dev/null +++ b/lib/src/models/devices/open_ring_factory.dart @@ -0,0 +1,254 @@ +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_configuration_specializations/streamable_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/sensor_configuration_specializations/configurable_sensor_configuration.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(), + ); + + // OpenRing exposes one realtime rate per stream; represent it as fixed Hz. + const double imuFrequencyHz = 50.0; + const double ppgFrequencyHz = 50.0; + const double temperatureFrequencyHz = 50.0; + final streamOnly = Set.unmodifiable({ + StreamSensorConfigOption(), + }); + + List singleRateValues({ + required double frequencyHz, + required int cmd, + required List startPayload, + required List stopPayload, + bool softwareToggleOnly = false, + }) { + final base = OpenRingSensorConfigurationValue( + frequencyHz: frequencyHz, + cmd: cmd, + startPayload: startPayload, + stopPayload: stopPayload, + softwareToggleOnly: softwareToggleOnly, + ); + return [base, base.copyWith(options: streamOnly)]; + } + + final imuConfigValues = singleRateValues( + frequencyHz: imuFrequencyHz, + cmd: OpenRingGatt.cmdIMU, + // 6-axis standalone mode (accel + gyro). + // When PPG is active, motion channels are sourced from cmdPPGQ2 packets. + startPayload: [0x06], + stopPayload: [0x00], + ); + final imuSensorConfig = OpenRingSensorConfiguration( + name: "6-Axis IMU", + values: imuConfigValues, + offValue: imuConfigValues.firstWhere((value) => value.options.isEmpty), + sensorHandler: sensorHandler, + availableOptions: streamOnly, + ); + + final ppgConfigValues = singleRateValues( + frequencyHz: ppgFrequencyHz, + cmd: OpenRingGatt.cmdPPGQ2, + startPayload: [ + 0x00, // start Q2 collection (LmAPI GET_HEART_Q2) + 0x00, // collectionTime = 0s (continuous streaming mode) + 0x19, // acquisition parameter (firmware-fixed) + 0x01, // enable waveform streaming + 0x01, // enable progress packets + ], + stopPayload: [ + 0x06, // stop Q2 collection (LmAPI STOP_Q2) + ], + ); + final ppgSensorConfig = OpenRingSensorConfiguration( + name: "PPG", + values: ppgConfigValues, + offValue: ppgConfigValues.firstWhere((value) => value.options.isEmpty), + sensorHandler: sensorHandler, + availableOptions: streamOnly, + ); + + final temperatureConfigValues = singleRateValues( + frequencyHz: temperatureFrequencyHz, + cmd: OpenRingGatt.cmdPPGQ2, + startPayload: const [], + stopPayload: const [], + softwareToggleOnly: true, + ); + final temperatureSensorConfig = OpenRingSensorConfiguration( + name: "Temperature", + values: temperatureConfigValues, + offValue: temperatureConfigValues.firstWhere( + (value) => value.options.isEmpty, + ), + sensorHandler: sensorHandler, + availableOptions: streamOnly, + ); + + 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, + relatedConfigurations: [imuSensorConfig], + ), + OpenRingSensor( + sensorId: OpenRingGatt.cmdIMU, + sensorName: "Gyroscope", + chartTitle: "Gyroscope", + shortChartTitle: "Gyr.", + axisNames: ["X", "Y", "Z"], + axisUnits: ["rad/s * 10^2", "rad/s * 10^2", "rad/s * 10^2"], + sensorHandler: sensorHandler, + relatedConfigurations: [imuSensorConfig], + ), + OpenRingSensor( + sensorId: OpenRingGatt.cmdPPGQ2, + sensorName: "PPG", + chartTitle: "PPG", + shortChartTitle: "PPG", + axisNames: ["Infrared", "Red", "Green"], + axisUnits: ["raw", "raw", "raw"], + sensorHandler: sensorHandler, + relatedConfigurations: [ppgSensorConfig], + ), + OpenRingSensor( + sensorId: OpenRingGatt.cmdPPGQ2, + sensorName: "Temperature", + chartTitle: "Temperature", + shortChartTitle: "Temp", + axisNames: ["Temp0", "Temp1", "Temp2"], + axisUnits: ["raw", "raw", "raw"], + sensorHandler: sensorHandler, + // Temperature uses software on/off and enables PPG transport automatically. + relatedConfigurations: [temperatureSensorConfig], + ), + ]; + + final w = OpenRing( + discoveredDevice: device, + deviceId: device.id, + name: device.name, + sensors: sensors, + sensorConfigs: sensorConfigs, + disconnectNotifier: disconnectNotifier!, + bleManager: bleManager!, + isSensorStreamingActive: () => sensorHandler.hasActiveRealtimeStreaming, + ); + sensorHandler.setInitialStreamingDetectedCallback( + w.assumeAllConfigurationsEnabledFromDetectedStreaming, + ); + for (final config + in sensorConfigs.whereType()) { + config.onConfigurationApplied = (configuration, value) { + w.assumeConfigurationApplied( + configuration: configuration, + value: value, + ); + }; + } + + final timeSync = OpenRingTimeSyncImp( + bleManager: bleManager!, + deviceId: device.id, + ); + w.registerCapability(timeSync); + + unawaited( + _synchronizeTimeOnConnect( + timeSync: timeSync, + deviceId: device.id, + ), + ); + unawaited( + _prefetchBatteryOnConnect( + openRing: w, + 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); + } + } + + Future _prefetchBatteryOnConnect({ + required OpenRing openRing, + required String deviceId, + }) async { + try { + final bool prefetched = await openRing.prefetchBatteryOnConnect(); + if (prefetched) { + logger.i('OpenRing battery prefetched on connect for $deviceId'); + } + } catch (error, stack) { + logger.w( + 'OpenRing battery prefetch 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/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/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..f743370 --- /dev/null +++ b/lib/src/utils/sensor_value_parser/open_ring_value_parser.dart @@ -0,0 +1,477 @@ +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 { + // 50 Hz -> 20 ms per sample + static const int _samplePeriodMs = 20; + // IMU (cmd=0x40) accelerometer channels are reported in milli-g. + static const double _imuAccRawToGScale = 1000.0; + // PPG realtime (cmd=0x32) carries accelerometer with half-scale raw counts. + static const double _ppgAccRawToGScale = 500.0; + // OpenRing realtime temperature channels are provided in milli-degrees C. + static const double _tempRawToCelsiusScale = 1000.0; + + final Map _lastSeqByCmd = {}; + final Map _lastTsByCmd = {}; + + @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 (subOpcode == 0x00) { + return const []; + } + if (subOpcode != 0x01 && subOpcode != 0x04 && subOpcode != 0x06) { + return const []; + } + + // Firmware variants differ in IMU stream framing: + // - Variant A: [00,seq,40,sub,status,payload...] + // - Variant B: [00,seq,40,sub,payload...] + // Parse both layouts and keep whichever yields more full samples. + int? statusWithLayout; + List> withStatusLayout = const []; + if (frame.lengthInBytes >= 5) { + statusWithLayout = frame.getUint8(4); + final ByteData payloadWithStatus = frame.lengthInBytes > 5 + ? ByteData.sublistView(frame, 5) + : ByteData.sublistView(frame, 5, 5); + withStatusLayout = _parseImuSamples( + subOpcode: subOpcode, + payload: payloadWithStatus, + receiveTs: receiveTs, + baseHeader: { + 'sequenceNum': sequenceNum, + 'cmd': cmd, + 'subOpcode': subOpcode, + 'status': statusWithLayout, + }, + ); + } + + List> withoutStatusLayout = const []; + if (frame.lengthInBytes > 4) { + final ByteData payloadWithoutStatus = ByteData.sublistView(frame, 4); + withoutStatusLayout = _parseImuSamples( + subOpcode: subOpcode, + payload: payloadWithoutStatus, + receiveTs: receiveTs, + baseHeader: { + 'sequenceNum': sequenceNum, + 'cmd': cmd, + 'subOpcode': subOpcode, + // Keep a neutral status marker for inferred no-status layout. + 'status': 0x00, + }, + ); + } + + if (withoutStatusLayout.length > withStatusLayout.length) { + return withoutStatusLayout; + } + if (withStatusLayout.isNotEmpty) { + return withStatusLayout; + } + if (withoutStatusLayout.isNotEmpty) { + return withoutStatusLayout; + } + + // Common busy ACK: [00, seq, 40, subOpcode, 0x01] + if (statusWithLayout == 0x01 && frame.lengthInBytes == 5) { + return const []; + } + + return const []; + } + + List> _parseImuSamples({ + required int subOpcode, + required ByteData payload, + required int receiveTs, + required Map baseHeader, + }) { + switch (subOpcode) { + case 0x01: + case 0x04: + return _parseAccelOnly( + data: payload, + receiveTs: receiveTs, + baseHeader: baseHeader, + samplePeriodMs: _samplePeriodMs, + ); + case 0x06: + return _parseAccelGyro( + data: payload, + receiveTs: receiveTs, + baseHeader: baseHeader, + samplePeriodMs: _samplePeriodMs, + ); + 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 == 1) { + // Legacy Q2 state packet: ignore. + return const []; + } + 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.getUint16(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}'); + } + + final int nSamples = frame.getUint8(5); + final ByteData waveformPayload = ByteData.sublistView(frame, 6); + + final List> waveform14 = _parsePpgWaveform( + data: waveformPayload, + nSamples: nSamples, + receiveTs: receiveTs, + baseHeader: baseHeader, + ); + if (waveform14.isEmpty && nSamples > 0) { + logger.w( + 'OpenRing PPG waveform length mismatch ' + '(type=0x01, nSamples=$nSamples, payloadLen=${waveformPayload.lengthInBytes})', + ); + } + return waveform14; + } + + 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 const []; + } + + 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 = _parseAccelerometerComp(accBytes); + final Map gyroData = _parseGyroscopeComp(gyroBytes); + + parsedData.add({ + ...baseHeader, + 'timestamp': ts, + 'Accelerometer': accelData, + 'Gyroscope': gyroData, + }); + } + return parsedData; + } + + List> _parseAccelOnly({ + required ByteData data, + required int receiveTs, + required Map baseHeader, + required int samplePeriodMs, + }) { + final int usableBytes = data.lengthInBytes - (data.lengthInBytes % 6); + if (usableBytes == 0) { + return const []; + } + + final List> parsedData = []; + for (int i = 0; i < usableBytes; i += 6) { + final int sampleIndex = i ~/ 6; + final int ts = receiveTs + (sampleIndex + 1) * samplePeriodMs; + + final ByteData sample = ByteData.sublistView(data, i, i + 6); + final Map accelData = _parseAccelerometerComp(sample); + + parsedData.add({ + ...baseHeader, + 'timestamp': ts, + 'Accelerometer': accelData, + }); + } + return parsedData; + } + + Map _parseAccelerometerComp( + ByteData data, { + double rawToGScale = _imuAccRawToGScale, + }) { + return { + 'X': data.getInt16(0, Endian.little) / rawToGScale, + 'Y': data.getInt16(2, Endian.little) / rawToGScale, + 'Z': data.getInt16(4, Endian.little) / rawToGScale, + }; + } + + Map _parseGyroscopeComp(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, + }) { + if (nSamples == 0) { + return const []; + } + + const int sampleSize = 14; + final int expectedBytes = nSamples * sampleSize; + if (data.lengthInBytes < expectedBytes) { + return const []; + } + final ByteData exactSamples = ByteData.sublistView(data, 0, expectedBytes); + + final List> parsedData = []; + for (int i = 0; i < nSamples; i++) { + final int offset = i * sampleSize; + final int ts = receiveTs + (i + 1) * _samplePeriodMs; + + parsedData.add({ + ...baseHeader, + 'timestamp': ts, + 'PPG': { + 'Green': 0, + 'Red': exactSamples.getUint32(offset, Endian.little), + 'Infrared': exactSamples.getUint32(offset + 4, Endian.little), + }, + // Legacy Q2 waveform packets also carry accelerometer payload + // (bytes 8..13 in each 14-byte sample). + 'Accelerometer': _parseAccelerometerComp( + ByteData.sublistView(exactSamples, offset + 8, offset + 14), + rawToGScale: _ppgAccRawToGScale, + ), + }); + } + + 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 expectedPayloadBytes = nSamples * sampleSize; + if (sampleData.lengthInBytes != expectedPayloadBytes) { + // Guard against mis-parsing legacy type-0x02 payload layouts as the + // realtime30 format. Fallback parser handles those variants. + return const []; + } + + final int usableSamples = nSamples; + + 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), + }, + 'Accelerometer': _parseAccelerometerComp( + ByteData.sublistView(sampleData, offset + 12, offset + 18), + rawToGScale: _ppgAccRawToGScale, + ), + 'Gyroscope': { + 'X': sampleData.getInt16(offset + 18, Endian.little), + 'Y': sampleData.getInt16(offset + 20, Endian.little), + 'Z': sampleData.getInt16(offset + 22, 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), - }; - } -} diff --git a/pubspec.yaml b/pubspec.yaml index a75f472..52cf739 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,6 +53,7 @@ flutter: assets: - assets/wearable_icons/open_earable_v1/ - assets/wearable_icons/open_earable_v2/ + - assets/wearable_icons/open_ring/ - assets/wearable_icons/cosinuss_one/ - assets/wearable_icons/polar/default/ - assets/wearable_icons/polar/watch/