diff --git a/CHANGELOG.md b/CHANGELOG.md index 048d3f6..6ea985a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## Unreleased + +* added global LSL forwarding support for sensor streams + * introduced `UdpBridgeForwarder` implementation for UDP -> LSL bridge forwarding + * forwards active sensor samples to a configurable UDP bridge endpoint (`host:port`) + * introduced generic sensor forwarding abstractions (`SensorForwarder`) with global dependency injection and per-forwarder enable/disable support + * added LSL forwarding documentation (`doc/LSL.md`) + * bundled Python UDP->LSL bridge script (`tools/lsl_bridge.py`) that prints local IPs for app setup + +## 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 +87,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/README.md b/README.md index 19740cf..6d3482b 100644 --- a/README.md +++ b/README.md @@ -114,5 +114,65 @@ To get started with the OpenEarable Flutter package, follow these steps: > [!WARNING] > Checking for capabilities using `is ` is deprecated. Please use `hasCapability()` instead. You can learn more about capabilities in the [Capabilities](doc/CAPABILITIES.md) documentation. +### 8. Forward Sensor Streams to LSL (Optional) +Forwarding is global and protocol-agnostic. Configure LSL as one forwarder: + +```dart +final udpBridgeForwarder = UdpBridgeForwarder.instance; +udpBridgeForwarder.configure( + host: "192.168.1.42", // IP/hostname of the bridge machine + port: 16571, // UDP port of the bridge + enabled: true, +); + +final manager = WearableManager( + sensorForwarders: [udpBridgeForwarder], +); +``` + +You can enable/disable/add/remove forwarders at runtime: + +```dart +manager.setSensorForwarderEnabled(udpBridgeForwarder, false); // disable +manager.setSensorForwarderEnabled(udpBridgeForwarder, true); // enable +manager.removeSensorForwarder(udpBridgeForwarder); // remove +manager.addSensorForwarder(udpBridgeForwarder); // add again +``` + +You can also check forwarding connectivity state: + +```dart +final state = manager.getSensorForwarderConnectionState(udpBridgeForwarder); +// SensorForwarderConnectionState.active (default) +// A probe runs after configure/enable. +// SensorForwarderConnectionState.unreachable (after detected send fault) +// While enabled + configured, health probes run every 1 second. + +manager.getSensorForwarderConnectionStateStream(udpBridgeForwarder)?.listen((state) { + // react to state changes +}); + +final error = manager.getSensorForwarderConnectionErrorMessage(udpBridgeForwarder); +// null while active, otherwise a human-readable unreachable error +``` + +This package forwards sensor samples as UDP JSON packets. A middleware process must run on the target machine and publish those samples to Lab Streaming Layer. +By default, the stream prefix is the local phone/device name. You can still override it via `streamPrefix` if needed. + +This repository includes the **OpenWearables LSL Dashboard Example** at `tools/lsl_receive_and_ploty.py`: + +```bash +pip install pylsl +python tools/lsl_receive_and_ploty.py --port 16571 --dashboard-port 8765 +``` + +At startup it prints the local IP addresses you can use as `host` in your app config and the dashboard URL(s). Open the dashboard URL in a browser to see live stream cards and latest values. The bridge publishes LSL outlets and forwards the same samples to the web dashboard. See [LSL Forwarding](doc/LSL.md) for details. + +Minimal network relay consumer example (placeholder hooks for your own handling): + +```bash +python tools/lsl_receive_minimal.py +``` + ## Add custom Wearable Support -Learn more about how to add support for your own wearable devices in the [Adding Custom Wearable Support](doc/ADD_CUSTOM_WEARABLE.md) documentation. \ No newline at end of file +Learn more about how to add support for your own wearable devices in the [Adding Custom Wearable Support](doc/ADD_CUSTOM_WEARABLE.md) documentation. 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/doc/LSL.md b/doc/LSL.md new file mode 100644 index 0000000..7f3098a --- /dev/null +++ b/doc/LSL.md @@ -0,0 +1,105 @@ +# OpenWearables LSL Dashboard Example (Python Setup) + +This guide is focused on one thing: starting the Python bridge on your computer so it can: + +- receive forwarded UDP sensor packets, +- publish them as LSL streams, +- and show live data in a web dashboard. + +## Quick Start + +From the repository root (`/Users/tobi/open_earable_flutter`): + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install pylsl +python tools/lsl_receive_and_ploty.py --port 16571 --dashboard-port 8765 +``` + +When it starts, it prints: + +- the UDP listener (`host:port`) for incoming forwarded data, +- one or more local IP addresses, +- dashboard URLs to open in a browser. + +Open the dashboard in your browser, for example: + +```text +http://:8765 +``` + +## What You Need to Configure on Sender Side + +The sender app/device must forward UDP to your computer: + +- `host`: your computer IP printed by the script +- `port`: `16571` (or your custom `--port`) + +That is all that is required for the bridge to ingest data. + +## Common Commands + +Default setup: + +```bash +python tools/lsl_receive_and_ploty.py --port 16571 --dashboard-port 8765 +``` + +Verbose packet logging: + +```bash +python tools/lsl_receive_and_ploty.py --host 0.0.0.0 --port 16571 --dashboard-port 8765 --verbose +``` + +Use a different dashboard port: + +```bash +python tools/lsl_receive_and_ploty.py --port 16571 --dashboard-port 9000 +``` + +## Verify It Is Running + +- Dashboard opens and shows `LIVE` once packets arrive. +- `http://:/health` returns JSON status. +- LSL streams become discoverable with type `OpenWearables`. + +## Minimal Network Relay Consumer Example + +If you only want to receive UDP relay data and process it yourself: + +```bash +python tools/lsl_receive_minimal.py +``` + +This script runs the reusable `NetworkRelayServer` from: + +- `tools/network_relay_server.py` + +The placeholder hooks are in: + +- `tools/lsl_receive_minimal.py` (`handle_sensor_sample(...)`) +- `tools/lsl_receive_minimal.py` (`handle_channel_sample(...)`) + +The minimal script also includes lightweight abstractions: + +- `SensorSample`: one concrete sample from one sensor stream on one device +- `ChannelSample`: one concrete channel value split out from `SensorSample` + +## Troubleshooting + +No packets in dashboard: + +- Confirm sender `host` matches your computer IP (not `localhost` unless sender is same machine). +- Confirm sender `port` matches bridge `--port`. +- Ensure firewall allows inbound UDP on the chosen bridge port. + +Dashboard unreachable: + +- Confirm dashboard port is open and not in use. +- Try `http://127.0.0.1:` locally first. + +No LSL streams found by consumers: + +- Ensure packets are reaching the bridge (dashboard should show samples). +- Ensure consumer is filtering for stream type `OpenWearables`. 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..2ef36a4 100644 --- a/lib/open_earable_flutter.dart +++ b/lib/open_earable_flutter.dart @@ -15,13 +15,17 @@ import 'package:open_earable_flutter/src/models/devices/stereo_pairing/pairing_r import 'package:open_earable_flutter/src/models/wearable_factory.dart'; import 'package:universal_ble/universal_ble.dart'; +import 'src/forwarding/sensor_forwarder.dart'; +import 'src/forwarding/sensor_forwarding.dart'; import 'src/managers/ble_manager.dart'; import 'src/managers/pairing_manager.dart'; import 'src/managers/wearable_disconnect_notifier.dart'; +import 'src/models/capabilities/sensor.dart'; +import 'src/models/capabilities/sensor_manager.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'; @@ -68,6 +72,9 @@ export 'src/models/wearable_factory.dart'; export 'src/models/capabilities/system_device.dart'; export 'src/managers/ble_gatt_manager.dart'; export 'src/models/capabilities/time_synchronizable.dart'; +export 'src/forwarding/sensor_forwarder.dart'; +export 'src/forwarding/sensor_forwarding.dart'; +export 'src/forwarding/forwarders/udp_bridge_forwarder.dart'; export 'src/fota/fota.dart'; @@ -102,6 +109,8 @@ class WearableManager { late final StreamController _connectingStreamController; final List _connectedIds = []; + final Map>> + _sensorForwardingSubscriptions = {}; List _autoConnectDeviceIds = []; StreamSubscription? _autoconnectScanSubscription; @@ -111,11 +120,16 @@ class WearableManager { CosinussOneFactory(), PolarFactory(), DevKitFactory(), - TauRingFactory(), + OpenRingFactory(), EsenseFactory(), ]; - factory WearableManager() { + factory WearableManager({ + List? sensorForwarders, + }) { + if (sensorForwarders != null) { + SensorForwardingPipeline.instance.setForwarders(sensorForwarders); + } return _instance; } @@ -213,10 +227,12 @@ class WearableManager { if (await wearableFactory.matches(device, connectionResult.$2)) { Wearable wearable = await wearableFactory.createFromDevice(device, options: options); + await _startSensorForwardingSubscriptions(wearable); _connectedIds.add(device.id); wearable.addDisconnectListener(() { _connectedIds.remove(device.id); + _stopSensorForwardingSubscriptions(wearable.deviceId); }); _connectStreamController.add(wearable); @@ -319,6 +335,8 @@ class WearableManager { void dispose() { _autoconnectScanSubscription?.cancel(); + _stopAllSensorForwardingSubscriptions(); + unawaited(SensorForwardingPipeline.instance.close()); _bleManager.dispose(); } @@ -328,4 +346,199 @@ class WearableManager { static Future checkAndRequestPermissions() { return BleManager.checkAndRequestPermissions(); } + + /// Globally configured list of sensor forwarders. + List get sensorForwarders => + SensorForwardingPipeline.instance.forwarders; + + /// Replaces all global sensor forwarders. + /// + /// This enables dependency injection of custom forwarding pipelines. + void setSensorForwarders(List forwarders) { + SensorForwardingPipeline.instance.setForwarders(forwarders); + } + + /// Adds one forwarder to the global forwarding pipeline. + void addSensorForwarder(SensorForwarder forwarder) { + SensorForwardingPipeline.instance.addForwarder(forwarder); + } + + /// Removes one forwarder from the global forwarding pipeline. + bool removeSensorForwarder(SensorForwarder forwarder) { + return SensorForwardingPipeline.instance.removeForwarder(forwarder); + } + + /// Enables or disables one specific forwarder. + bool setSensorForwarderEnabled(SensorForwarder forwarder, bool enabled) { + return SensorForwardingPipeline.instance.setForwarderEnabled( + forwarder, + enabled, + ); + } + + /// Returns whether a specific forwarder is enabled. Null if not registered. + bool? isSensorForwarderEnabled(SensorForwarder forwarder) { + return SensorForwardingPipeline.instance.isForwarderEnabled(forwarder); + } + + /// Returns current connection state for a specific forwarder. + /// + /// Returns null if the forwarder is not registered. + SensorForwarderConnectionState? getSensorForwarderConnectionState( + SensorForwarder forwarder, + ) { + return SensorForwardingPipeline.instance + .forwarderConnectionState(forwarder); + } + + /// Returns connection-state changes for a specific forwarder. + /// + /// Returns null if the forwarder is not registered. + Stream? + getSensorForwarderConnectionStateStream( + SensorForwarder forwarder, + ) { + return SensorForwardingPipeline.instance.forwarderConnectionStateStream( + forwarder, + ); + } + + /// Returns the last connection error message for a specific forwarder. + /// + /// This is typically only non-null when state is `unreachable`. + /// Returns null if the forwarder is not registered. + String? getSensorForwarderConnectionErrorMessage( + SensorForwarder forwarder, + ) { + return SensorForwardingPipeline.instance.forwarderConnectionErrorMessage( + forwarder, + ); + } + + /// Returns updates for the forwarder's connection error message. + /// + /// Returns null if the forwarder is not registered. + Stream? getSensorForwarderConnectionErrorMessageStream( + SensorForwarder forwarder, + ) { + return SensorForwardingPipeline.instance + .forwarderConnectionErrorMessageStream(forwarder); + } + + /// Removes all registered forwarders. + void clearSensorForwarders() { + SensorForwardingPipeline.instance.clearForwarders(); + } + + Future _startSensorForwardingSubscriptions(Wearable wearable) async { + if (_sensorForwardingSubscriptions.containsKey(wearable.deviceId)) { + return; + } + + final sensorManager = wearable.getCapability(); + if (sensorManager == null) { + return; + } + + final sideLabel = await _resolveWearableSideLabel(wearable); + final subscriptions = >[]; + for (final sensor in sensorManager.sensors) { + try { + final subscription = sensor.sensorStream.cast().listen( + (value) { + unawaited( + SensorForwardingPipeline.instance.forward( + SensorForwardingSample( + sensor: sensor, + value: value, + deviceId: wearable.deviceId, + deviceName: wearable.name, + deviceSide: sideLabel, + ), + ), + ); + }, + onError: (Object error, StackTrace stackTrace) { + logger.w( + 'Forwarding subscription failed for ${wearable.name}/${sensor.sensorName}: $error', + error: error, + stackTrace: stackTrace, + ); + }, + ); + subscriptions.add(subscription); + } catch (error, stackTrace) { + logger.w( + 'Failed to attach forwarding subscription for ${wearable.name}/${sensor.sensorName}: $error', + error: error, + stackTrace: stackTrace, + ); + } + } + + _sensorForwardingSubscriptions[wearable.deviceId] = subscriptions; + if (subscriptions.isEmpty) { + logger.w( + 'No sensor forwarding subscriptions attached for ' + '${wearable.name} (${wearable.deviceId})', + ); + } else { + logger.i( + 'Attached ${subscriptions.length} sensor forwarding subscriptions for ' + '${wearable.name} (${wearable.deviceId}), side=${sideLabel ?? "unknown"}', + ); + } + } + + Future _resolveWearableSideLabel(Wearable wearable) async { + final fallbackSide = _resolveSideLabelFromName(wearable.name); + final stereoDevice = wearable.getCapability(); + if (stereoDevice == null) { + return fallbackSide; + } + try { + final position = await stereoDevice.position.timeout( + const Duration(seconds: 2), + ); + final resolvedFromCapability = switch (position) { + DevicePosition.left => 'L', + DevicePosition.right => 'R', + _ => null, + }; + return resolvedFromCapability ?? fallbackSide; + } catch (_) { + return fallbackSide; + } + } + + String? _resolveSideLabelFromName(String deviceName) { + final trimmed = deviceName.trim(); + final lower = trimmed.toLowerCase(); + if (lower.endsWith('-l') || lower.endsWith('_l')) { + return 'L'; + } + if (lower.endsWith('-r') || lower.endsWith('_r')) { + return 'R'; + } + return null; + } + + void _stopSensorForwardingSubscriptions(String deviceId) { + final subscriptions = _sensorForwardingSubscriptions.remove(deviceId); + if (subscriptions == null) { + return; + } + for (final subscription in subscriptions) { + unawaited(subscription.cancel()); + } + } + + void _stopAllSensorForwardingSubscriptions() { + for (final subscriptions in _sensorForwardingSubscriptions.values) { + for (final subscription in subscriptions) { + unawaited(subscription.cancel()); + } + } + _sensorForwardingSubscriptions.clear(); + } } diff --git a/lib/src/forwarding/forwarders/udp/device_name/device_name.dart b/lib/src/forwarding/forwarders/udp/device_name/device_name.dart new file mode 100644 index 0000000..63def32 --- /dev/null +++ b/lib/src/forwarding/forwarders/udp/device_name/device_name.dart @@ -0,0 +1,3 @@ +abstract interface class UdpDeviceNameProvider { + String get deviceName; +} diff --git a/lib/src/forwarding/forwarders/udp/device_name/device_name_io.dart b/lib/src/forwarding/forwarders/udp/device_name/device_name_io.dart new file mode 100644 index 0000000..12c5ae2 --- /dev/null +++ b/lib/src/forwarding/forwarders/udp/device_name/device_name_io.dart @@ -0,0 +1,34 @@ +import 'dart:io'; + +import 'device_name.dart'; + +UdpDeviceNameProvider createUdpDeviceNameProvider() => + _IoUdpDeviceNameProvider(); + +class _IoUdpDeviceNameProvider implements UdpDeviceNameProvider { + @override + String get deviceName { + final hostname = Platform.localHostname.trim(); + if (_isUsableName(hostname)) { + return hostname; + } + + const envKeys = ['DEVICE_NAME', 'COMPUTERNAME', 'HOSTNAME']; + for (final key in envKeys) { + final value = Platform.environment[key]?.trim() ?? ''; + if (_isUsableName(value)) { + return value; + } + } + + return 'Phone'; + } + + bool _isUsableName(String name) { + if (name.isEmpty) { + return false; + } + final lower = name.toLowerCase(); + return lower != 'localhost' && lower != 'unknown'; + } +} diff --git a/lib/src/forwarding/forwarders/udp/device_name/device_name_stub.dart b/lib/src/forwarding/forwarders/udp/device_name/device_name_stub.dart new file mode 100644 index 0000000..c0b9d86 --- /dev/null +++ b/lib/src/forwarding/forwarders/udp/device_name/device_name_stub.dart @@ -0,0 +1,9 @@ +import 'device_name.dart'; + +UdpDeviceNameProvider createUdpDeviceNameProvider() => + _StubUdpDeviceNameProvider(); + +class _StubUdpDeviceNameProvider implements UdpDeviceNameProvider { + @override + String get deviceName => 'Phone'; +} diff --git a/lib/src/forwarding/forwarders/udp/transport/udp_transport.dart b/lib/src/forwarding/forwarders/udp/transport/udp_transport.dart new file mode 100644 index 0000000..86b54f5 --- /dev/null +++ b/lib/src/forwarding/forwarders/udp/transport/udp_transport.dart @@ -0,0 +1,14 @@ +import 'dart:typed_data'; + +abstract class UdpTransport { + bool get isSupported; + + /// Performs a lightweight reachability probe for the configured endpoint. + /// + /// Implementations should throw on probe failures. + Future probe(String host, int port); + + Future send(Uint8List payload, String host, int port); + + Future close(); +} diff --git a/lib/src/forwarding/forwarders/udp/transport/udp_transport_io.dart b/lib/src/forwarding/forwarders/udp/transport/udp_transport_io.dart new file mode 100644 index 0000000..8b53e62 --- /dev/null +++ b/lib/src/forwarding/forwarders/udp/transport/udp_transport_io.dart @@ -0,0 +1,164 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'udp_transport.dart'; + +UdpTransport createUdpTransport() => _IoUdpTransport(); + +class _IoUdpTransport implements UdpTransport { + RawDatagramSocket? _socket; + Future? _pendingSocket; + + String? _cachedHost; + InternetAddress? _cachedAddress; + + @override + bool get isSupported => true; + + static const String _probeRequestType = 'open_earable_udp_probe'; + static const String _probeAckType = 'open_earable_udp_probe_ack'; + static const Duration _probeTimeout = Duration(milliseconds: 800); + + Future _getSocket() { + final socket = _socket; + if (socket != null) { + return Future.value(socket); + } + final pending = _pendingSocket; + if (pending != null) { + return pending; + } + + _pendingSocket = RawDatagramSocket.bind(InternetAddress.anyIPv4, 0).then(( + value, + ) { + _socket = value; + _pendingSocket = null; + return value; + }).catchError((Object error) { + _pendingSocket = null; + throw error; + }); + + return _pendingSocket!; + } + + Future _resolveAddress(String host) async { + if (_cachedHost == host && _cachedAddress != null) { + return _cachedAddress!; + } + + final parsed = InternetAddress.tryParse(host); + if (parsed != null) { + _cachedHost = host; + _cachedAddress = parsed; + return parsed; + } + + final resolved = await InternetAddress.lookup(host); + if (resolved.isEmpty) { + throw const SocketException('Unable to resolve UDP bridge host'); + } + + _cachedHost = host; + _cachedAddress = resolved.first; + return _cachedAddress!; + } + + @override + Future send(Uint8List payload, String host, int port) async { + final socket = await _getSocket(); + final address = await _resolveAddress(host); + final sentBytes = socket.send(payload, address, port); + if (sentBytes <= 0 || sentBytes != payload.length) { + throw SocketException( + 'Failed to send UDP payload to UDP bridge ($host:$port)', + ); + } + } + + @override + Future probe(String host, int port) async { + final address = await _resolveAddress(host); + final nonce = '${DateTime.now().microsecondsSinceEpoch}:$host:$port'; + final probePayload = Uint8List.fromList( + utf8.encode( + jsonEncode({ + 'type': _probeRequestType, + 'nonce': nonce, + }), + ), + ); + + final socket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0); + socket.writeEventsEnabled = false; + socket.readEventsEnabled = true; + + final ackCompleter = Completer(); + late final StreamSubscription subscription; + subscription = socket.listen((event) { + if (event != RawSocketEvent.read) { + return; + } + + while (true) { + final datagram = socket.receive(); + if (datagram == null) { + break; + } + if (datagram.port != port || + datagram.address.address != address.address) { + continue; + } + + try { + final decoded = jsonDecode(utf8.decode(datagram.data)); + if (decoded is! Map) { + continue; + } + if (decoded['type'] != _probeAckType || decoded['nonce'] != nonce) { + continue; + } + if (!ackCompleter.isCompleted) { + ackCompleter.complete(); + } + return; + } catch (_) { + continue; + } + } + }); + + try { + final sentBytes = socket.send(probePayload, address, port); + if (sentBytes <= 0 || sentBytes != probePayload.length) { + throw SocketException( + 'Failed to send UDP probe to UDP bridge ($host:$port)', + ); + } + + await ackCompleter.future.timeout( + _probeTimeout, + onTimeout: () { + throw SocketException( + 'Timed out waiting for UDP probe acknowledgment ($host:$port)', + ); + }, + ); + } finally { + await subscription.cancel(); + socket.close(); + } + } + + @override + Future close() async { + _socket?.close(); + _socket = null; + _pendingSocket = null; + _cachedHost = null; + _cachedAddress = null; + } +} diff --git a/lib/src/forwarding/forwarders/udp/transport/udp_transport_stub.dart b/lib/src/forwarding/forwarders/udp/transport/udp_transport_stub.dart new file mode 100644 index 0000000..a0774b5 --- /dev/null +++ b/lib/src/forwarding/forwarders/udp/transport/udp_transport_stub.dart @@ -0,0 +1,19 @@ +import 'dart:typed_data'; + +import 'udp_transport.dart'; + +UdpTransport createUdpTransport() => _UnsupportedUdpTransport(); + +class _UnsupportedUdpTransport implements UdpTransport { + @override + bool get isSupported => false; + + @override + Future close() async {} + + @override + Future probe(String host, int port) async {} + + @override + Future send(Uint8List payload, String host, int port) async {} +} diff --git a/lib/src/forwarding/forwarders/udp_bridge_forwarder.dart b/lib/src/forwarding/forwarders/udp_bridge_forwarder.dart new file mode 100644 index 0000000..10e327c --- /dev/null +++ b/lib/src/forwarding/forwarders/udp_bridge_forwarder.dart @@ -0,0 +1,503 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer' as developer; +import 'dart:typed_data'; + +import '../sensor_forwarder.dart'; +import '../../models/capabilities/sensor.dart'; +import 'udp/device_name/device_name_stub.dart' + if (dart.library.io) 'udp/device_name/device_name_io.dart'; +import 'udp/transport/udp_transport.dart'; +import 'udp/transport/udp_transport_stub.dart' + if (dart.library.io) 'udp/transport/udp_transport_io.dart'; + +const int defaultUdpBridgePort = 16571; +const String defaultUdpBridgeStreamPrefix = 'Phone'; +const String _sourceIdPrefix = 'oe-v1'; +const String _sourceIdSeparator = ':'; +const String _emptySourceIdComponent = '-'; + +final String _autoStreamPrefix = _resolveAutoStreamPrefix(); + +String _resolveAutoStreamPrefix() { + final name = createUdpDeviceNameProvider().deviceName.trim(); + if (name.isNotEmpty) { + return name; + } + return defaultUdpBridgeStreamPrefix; +} + +/// Configuration for UDP bridge forwarding. +/// +/// The Flutter package forwards sensor samples as UDP JSON packets to a +/// middleware process which then publishes the data to LSL outlets. +class UdpBridgeForwardingConfig { + final bool enabled; + final String host; + final int port; + final String streamPrefix; + + const UdpBridgeForwardingConfig({ + this.enabled = false, + this.host = '', + this.port = defaultUdpBridgePort, + this.streamPrefix = defaultUdpBridgeStreamPrefix, + }); + + bool get isConfigured => host.trim().isNotEmpty; + + UdpBridgeForwardingConfig copyWith({ + bool? enabled, + String? host, + int? port, + String? streamPrefix, + }) { + return UdpBridgeForwardingConfig( + enabled: enabled ?? this.enabled, + host: host ?? this.host, + port: port ?? this.port, + streamPrefix: streamPrefix ?? this.streamPrefix, + ); + } +} + +/// Forwarder implementation that sends samples to a UDP bridge endpoint. +class UdpBridgeForwarder + implements + SensorForwarder, + SensorForwarderConnectionStateProvider, + SensorForwarderConnectionErrorProvider { + UdpBridgeForwarder._internal() : _transport = createUdpTransport(); + + static final UdpBridgeForwarder instance = UdpBridgeForwarder._internal(); + + final UdpTransport _transport; + UdpBridgeForwardingConfig _config = + UdpBridgeForwardingConfig(streamPrefix: _autoStreamPrefix); + final Map _deviceTokenById = {}; + int _nextDeviceToken = 1; + + DateTime? _lastForwardingError; + bool _didLogUnsupportedPlatform = false; + final StreamController + _connectionStateController = + StreamController.broadcast(); + final StreamController _connectionErrorMessageController = + StreamController.broadcast(); + SensorForwarderConnectionState _connectionState = + SensorForwarderConnectionState.active; + String? _connectionErrorMessage; + int _probeGeneration = 0; + bool _probeInFlight = false; + Timer? _healthProbeTimer; + + static const Duration _healthProbeInterval = Duration(seconds: 1); + + UdpBridgeForwardingConfig get config => _config; + String get defaultStreamPrefix => _autoStreamPrefix; + bool get isSupported => _transport.isSupported; + @override + bool get isEnabled => _config.enabled; + bool get isConfigured => _config.isConfigured; + @override + SensorForwarderConnectionState get connectionState => _connectionState; + @override + Stream get connectionStateStream => + _connectionStateController.stream; + @override + String? get connectionErrorMessage => _connectionErrorMessage; + @override + Stream get connectionErrorMessageStream => + _connectionErrorMessageController.stream; + + void configure({ + required String host, + int port = defaultUdpBridgePort, + bool enabled = true, + String? streamPrefix, + }) { + final trimmedHost = host.trim(); + if (trimmedHost.isEmpty) { + throw ArgumentError.value(host, 'host', 'must not be empty'); + } + if (port <= 0 || port > 65535) { + throw ArgumentError.value(port, 'port', 'must be between 1 and 65535'); + } + + final trimmedPrefix = (streamPrefix ?? _autoStreamPrefix).trim(); + if (trimmedPrefix.isEmpty) { + throw ArgumentError.value( + streamPrefix, + 'streamPrefix', + 'must not be empty', + ); + } + + _config = _config.copyWith( + host: trimmedHost, + port: port, + enabled: enabled, + streamPrefix: trimmedPrefix, + ); + _syncHealthProbeLoop(); + _scheduleConnectionProbe(); + } + + @override + void setEnabled(bool enabled) { + _config = _config.copyWith(enabled: enabled); + if (!enabled) { + _probeGeneration += 1; + _setConnectionState(SensorForwarderConnectionState.active); + _setConnectionErrorMessage(null); + _syncHealthProbeLoop(); + return; + } + _syncHealthProbeLoop(); + _scheduleConnectionProbe(); + } + + void reset() { + _probeGeneration += 1; + _config = UdpBridgeForwardingConfig(streamPrefix: _autoStreamPrefix); + _setConnectionState(SensorForwarderConnectionState.active); + _setConnectionErrorMessage(null); + _syncHealthProbeLoop(); + } + + @override + Future forward(SensorForwardingSample sample) async { + final localConfig = _config; + + if (!localConfig.enabled || !localConfig.isConfigured) { + return; + } + + if (!_transport.isSupported) { + if (!_didLogUnsupportedPlatform) { + _didLogUnsupportedPlatform = true; + developer.log( + 'UDP bridge forwarding is not available on this platform. Sensor data stays local.', + name: 'open_earable_flutter', + ); + } + return; + } + + final payload = _buildPayload(sample, localConfig.streamPrefix); + + try { + await _transport.send(payload, localConfig.host, localConfig.port); + if (!_matchesCurrentConfig(localConfig)) { + return; + } + _setConnectionState(SensorForwarderConnectionState.active); + _setConnectionErrorMessage(null); + } catch (error, stackTrace) { + if (!_matchesCurrentConfig(localConfig)) { + return; + } + _setConnectionState(SensorForwarderConnectionState.unreachable); + _setConnectionErrorMessage(error.toString()); + _logForwardingError( + 'Failed to forward sensor data to UDP bridge (${localConfig.host}:${localConfig.port}): $error', + stackTrace, + ); + } + } + + void _setConnectionState(SensorForwarderConnectionState nextState) { + if (_connectionState == nextState) { + _syncHealthProbeLoop(); + return; + } + _connectionState = nextState; + if (!_connectionStateController.isClosed) { + _connectionStateController.add(nextState); + } + _syncHealthProbeLoop(); + } + + void _setConnectionErrorMessage(String? nextMessage) { + if (_connectionErrorMessage == nextMessage) { + return; + } + _connectionErrorMessage = nextMessage; + if (!_connectionErrorMessageController.isClosed) { + _connectionErrorMessageController.add(nextMessage); + } + } + + void _scheduleConnectionProbe() { + final localConfig = _config; + + if (!localConfig.enabled || !localConfig.isConfigured) { + _probeGeneration += 1; + _setConnectionState(SensorForwarderConnectionState.active); + _setConnectionErrorMessage(null); + return; + } + + if (!_transport.isSupported) { + _setConnectionState(SensorForwarderConnectionState.active); + _setConnectionErrorMessage(null); + return; + } + + if (_probeInFlight) { + return; + } + + final probeGeneration = ++_probeGeneration; + _probeInFlight = true; + unawaited(_probeConnection(localConfig, probeGeneration)); + } + + Future _probeConnection( + UdpBridgeForwardingConfig config, + int probeGeneration, + ) async { + try { + await _transport.probe(config.host, config.port); + if (!_isCurrentProbe(config, probeGeneration)) { + return; + } + _setConnectionState(SensorForwarderConnectionState.active); + _setConnectionErrorMessage(null); + } catch (error, stackTrace) { + if (!_isCurrentProbe(config, probeGeneration)) { + return; + } + _setConnectionState(SensorForwarderConnectionState.unreachable); + _setConnectionErrorMessage(error.toString()); + _logForwardingError( + 'Failed to reach UDP bridge during probe (${config.host}:${config.port}): $error', + stackTrace, + ); + } finally { + _probeInFlight = false; + _syncHealthProbeLoop(); + } + } + + bool _shouldRunHealthProbeLoop() { + return _config.enabled && _config.isConfigured && _transport.isSupported; + } + + void _syncHealthProbeLoop() { + if (!_shouldRunHealthProbeLoop()) { + _healthProbeTimer?.cancel(); + _healthProbeTimer = null; + return; + } + + _healthProbeTimer ??= Timer.periodic(_healthProbeInterval, (_) { + if (!_shouldRunHealthProbeLoop()) { + _healthProbeTimer?.cancel(); + _healthProbeTimer = null; + return; + } + _scheduleConnectionProbe(); + }); + } + + bool _isCurrentProbe(UdpBridgeForwardingConfig config, int probeGeneration) { + return probeGeneration == _probeGeneration && + config.host == _config.host && + config.port == _config.port && + config.enabled == _config.enabled; + } + + bool _matchesCurrentConfig(UdpBridgeForwardingConfig config) { + return config.host == _config.host && + config.port == _config.port && + config.enabled == _config.enabled; + } + + void _logForwardingError(String message, StackTrace stackTrace) { + final now = DateTime.now(); + final shouldLog = _lastForwardingError == null || + now.difference(_lastForwardingError!) >= const Duration(seconds: 5); + if (!shouldLog) { + return; + } + _lastForwardingError = now; + developer.log( + message, + name: 'open_earable_flutter', + stackTrace: stackTrace, + ); + } + + Uint8List _buildPayload( + SensorForwardingSample sample, + String streamPrefix, + ) { + final sensor = sample.sensor; + final value = sample.value; + final side = _normalizeSide(sample.deviceSide); + final source = _buildSource(deviceId: sample.deviceId); + final deviceToken = _resolveDeviceToken(sample.deviceId); + + final streamName = _buildStreamName( + deviceName: sample.deviceName, + deviceSide: side, + sensorName: sensor.sensorName, + source: source, + ); + final sourceId = _buildSourceId( + deviceName: sample.deviceName, + deviceSide: side, + sensorName: sensor.sensorName, + source: source, + ); + + final map = { + 'type': 'open_earable_udp_sample', + 'stream_name': streamName, + 'source_id': sourceId, + 'device_token': deviceToken, + 'device_id': sample.deviceId, + 'device_name': sample.deviceName, + 'device_source': source, + 'device_channel': side ?? '', + 'sensor_name': sensor.sensorName, + 'chart_title': sensor.chartTitle, + 'short_chart_title': sensor.shortChartTitle, + 'stream_prefix': streamPrefix, + 'axis_names': sensor.axisNames, + 'axis_units': sensor.axisUnits, + 'timestamp': value.timestamp, + 'timestamp_exponent': sensor.timestampExponent, + 'dimensions': value.dimensions, + 'values': _numericValues(value), + 'value_strings': value.valueStrings, + 'sent_at_unix_ms': DateTime.now().millisecondsSinceEpoch, + }; + if (side != null) { + map['device_side'] = side; + } + + return Uint8List.fromList(utf8.encode(jsonEncode(map))); + } + + String _buildSource({required String deviceId}) { + final normalizedDeviceId = _normalizeDisplay( + deviceId, + fallback: 'unknown_device_source', + ); + return normalizedDeviceId; + } + + String _buildSourceId({ + required String deviceName, + String? deviceSide, + required String sensorName, + required String source, + }) { + final side = _normalizeSide(deviceSide) ?? ''; + return [ + _sourceIdPrefix, + _encodeSourceIdComponent( + _normalizeDisplay(deviceName, fallback: 'unknown_device'), + ), + _encodeSourceIdComponent(side), + _encodeSourceIdComponent( + _normalizeDisplay(sensorName, fallback: 'unknown_sensor'), + ), + _encodeSourceIdComponent(source), + ].join(_sourceIdSeparator); + } + + String _encodeSourceIdComponent(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + return _emptySourceIdComponent; + } + return Uri.encodeComponent(trimmed); + } + + String _buildStreamName({ + required String deviceName, + String? deviceSide, + required String sensorName, + required String source, + }) { + final normalizedDevice = _normalizeDisplay( + deviceName, + fallback: 'unknown_device', + ); + final normalizedSensor = _normalizeDisplay( + sensorName, + fallback: 'unknown_sensor', + ); + final normalizedSource = _normalizeDisplay( + source, + fallback: 'unknown_source', + ); + final side = _normalizeSide(deviceSide); + final sideSuffix = side == null ? '' : ' [$side]'; + return '$normalizedDevice$sideSuffix ($normalizedSource) - $normalizedSensor'; + } + + String _resolveDeviceToken(String deviceId) { + final existing = _deviceTokenById[deviceId]; + if (existing != null) { + return existing; + } + + final nextToken = 'device_${_nextDeviceToken.toString().padLeft(2, '0')}'; + _nextDeviceToken += 1; + _deviceTokenById[deviceId] = nextToken; + return nextToken; + } + + String? _normalizeSide(String? side) { + if (side == null) { + return null; + } + final trimmed = side.trim(); + if (trimmed.isEmpty) { + return null; + } + final lower = trimmed.toLowerCase(); + if (lower.startsWith('l')) { + return 'L'; + } + if (lower.startsWith('r')) { + return 'R'; + } + return trimmed; + } + + String _normalizeDisplay(String value, {required String fallback}) { + final compact = value.replaceAll(RegExp(r'\s+'), ' ').trim(); + if (compact.isEmpty) { + return fallback; + } + return compact; + } + + List _numericValues(SensorValue value) { + if (value is SensorDoubleValue) { + return value.values; + } + if (value is SensorIntValue) { + return value.values; + } + return value.valueStrings + .map(num.tryParse) + .whereType() + .toList(growable: false); + } + + @override + Future close() { + _probeGeneration += 1; + _probeInFlight = false; + _healthProbeTimer?.cancel(); + _healthProbeTimer = null; + _setConnectionErrorMessage(null); + return _transport.close(); + } +} diff --git a/lib/src/forwarding/sensor_forwarder.dart b/lib/src/forwarding/sensor_forwarder.dart new file mode 100644 index 0000000..d94a443 --- /dev/null +++ b/lib/src/forwarding/sensor_forwarder.dart @@ -0,0 +1,60 @@ +import 'dart:async'; + +import '../models/capabilities/sensor.dart'; + +/// Single sample emitted by a sensor stream and dispatched to forwarders. +class SensorForwardingSample { + final Sensor sensor; + final SensorValue value; + final String deviceId; + final String deviceName; + final String? deviceSide; + + const SensorForwardingSample({ + required this.sensor, + required this.value, + required this.deviceId, + required this.deviceName, + this.deviceSide, + }); +} + +/// Runtime connectivity state of a forwarder target. +enum SensorForwarderConnectionState { + /// Forwarder is operating normally (or no fault has been observed yet). + active, + + /// A forwarding attempt failed and the target is currently considered unreachable. + unreachable, +} + +/// Optional capability for forwarders that can report connectivity health. +/// +/// Forwarders that do not implement this are treated as always [SensorForwarderConnectionState.active]. +abstract class SensorForwarderConnectionStateProvider { + SensorForwarderConnectionState get connectionState; + + Stream get connectionStateStream; +} + +/// Optional capability for forwarders that can expose a human-readable +/// connection error while unreachable. +abstract class SensorForwarderConnectionErrorProvider { + String? get connectionErrorMessage; + + Stream get connectionErrorMessageStream; +} + +/// Pluggable forwarding target. +/// +/// Implementations can publish samples to different destinations +/// (e.g. LSL bridge, file sink, websocket sink). +abstract class SensorForwarder { + bool get isEnabled; + + void setEnabled(bool enabled); + + FutureOr forward(SensorForwardingSample sample); + + Future close(); +} diff --git a/lib/src/forwarding/sensor_forwarding.dart b/lib/src/forwarding/sensor_forwarding.dart new file mode 100644 index 0000000..516183d --- /dev/null +++ b/lib/src/forwarding/sensor_forwarding.dart @@ -0,0 +1,187 @@ +import 'dart:async'; +import 'dart:developer' as developer; + +import 'sensor_forwarder.dart'; + +/// Global forwarding pipeline used by all sensors. +class SensorForwardingPipeline { + SensorForwardingPipeline._internal(); + + static final SensorForwardingPipeline instance = + SensorForwardingPipeline._internal(); + + final List _forwarders = []; + DateTime? _lastForwardingError; + Future _forwardingQueue = Future.value(); + DateTime? _lastDroppedSamplesLog; + int _pendingForwardSamples = 0; + int _droppedForwardSamples = 0; + + static const int _maxPendingForwardSamples = 256; + + List get forwarders => List.unmodifiable(_forwarders); + + void setForwarders( + List forwarders, + ) { + _forwarders + ..clear() + ..addAll(forwarders); + } + + void addForwarder(SensorForwarder forwarder) { + _forwarders.add(forwarder); + } + + bool removeForwarder(SensorForwarder forwarder) { + return _forwarders.remove(forwarder); + } + + bool setForwarderEnabled(SensorForwarder forwarder, bool enabled) { + if (!_forwarders.contains(forwarder)) { + return false; + } + forwarder.setEnabled(enabled); + return true; + } + + bool? isForwarderEnabled(SensorForwarder forwarder) { + if (!_forwarders.contains(forwarder)) { + return null; + } + return forwarder.isEnabled; + } + + SensorForwarderConnectionState? forwarderConnectionState( + SensorForwarder forwarder, + ) { + if (!_forwarders.contains(forwarder)) { + return null; + } + if (forwarder is SensorForwarderConnectionStateProvider) { + final provider = forwarder as SensorForwarderConnectionStateProvider; + return provider.connectionState; + } + return SensorForwarderConnectionState.active; + } + + Stream? forwarderConnectionStateStream( + SensorForwarder forwarder, + ) { + if (!_forwarders.contains(forwarder)) { + return null; + } + if (forwarder is SensorForwarderConnectionStateProvider) { + final provider = forwarder as SensorForwarderConnectionStateProvider; + return provider.connectionStateStream; + } + return const Stream.empty(); + } + + String? forwarderConnectionErrorMessage(SensorForwarder forwarder) { + if (!_forwarders.contains(forwarder)) { + return null; + } + if (forwarder is SensorForwarderConnectionErrorProvider) { + final provider = forwarder as SensorForwarderConnectionErrorProvider; + return provider.connectionErrorMessage; + } + return null; + } + + Stream? forwarderConnectionErrorMessageStream( + SensorForwarder forwarder, + ) { + if (!_forwarders.contains(forwarder)) { + return null; + } + if (forwarder is SensorForwarderConnectionErrorProvider) { + final provider = forwarder as SensorForwarderConnectionErrorProvider; + return provider.connectionErrorMessageStream; + } + return const Stream.empty(); + } + + void clearForwarders() { + _forwarders.clear(); + } + + Future forward(SensorForwardingSample sample) { + if (_forwarders.isEmpty) { + return Future.value(); + } + + if (_pendingForwardSamples >= _maxPendingForwardSamples) { + _recordDroppedSample(); + return Future.value(); + } + + _pendingForwardSamples += 1; + _forwardingQueue = _forwardingQueue.catchError((_) {}).then((_) async { + try { + await _dispatchToForwarders(sample); + } finally { + _pendingForwardSamples -= 1; + } + }); + return _forwardingQueue; + } + + Future _dispatchToForwarders(SensorForwardingSample sample) async { + final forwarders = List.from(_forwarders); + for (final forwarder in forwarders) { + if (!forwarder.isEnabled) { + continue; + } + try { + await forwarder.forward(sample); + } catch (error, stackTrace) { + final now = DateTime.now(); + final shouldLog = _lastForwardingError == null || + now.difference(_lastForwardingError!) >= const Duration(seconds: 5); + if (shouldLog) { + _lastForwardingError = now; + developer.log( + 'Sensor forwarding failed in ${forwarder.runtimeType}: $error', + name: 'open_earable_flutter', + stackTrace: stackTrace, + ); + } + } + } + } + + void _recordDroppedSample() { + _droppedForwardSamples += 1; + final now = DateTime.now(); + final shouldLog = _lastDroppedSamplesLog == null || + now.difference(_lastDroppedSamplesLog!) >= const Duration(seconds: 5); + if (!shouldLog) { + return; + } + final droppedSamples = _droppedForwardSamples; + _droppedForwardSamples = 0; + _lastDroppedSamplesLog = now; + developer.log( + 'Dropping $droppedSamples sensor samples because forwarding queue is saturated.', + name: 'open_earable_flutter', + ); + } + + Future close() async { + try { + await _forwardingQueue; + } catch (_) {} + for (final forwarder in _forwarders) { + try { + await forwarder.close(); + } catch (error, stackTrace) { + developer.log( + 'Failed to close forwarder ${forwarder.runtimeType}: $error', + name: 'open_earable_flutter', + stackTrace: stackTrace, + ); + } + } + } +} diff --git a/lib/src/managers/ble_manager.dart b/lib/src/managers/ble_manager.dart index cac6ec8..3609b7c 100644 --- a/lib/src/managers/ble_manager.dart +++ b/lib/src/managers/ble_manager.dart @@ -24,6 +24,25 @@ class BleManager extends BleGattManager { String _getCharacteristicKey(String deviceId, String characteristicId) => "$deviceId||$characteristicId"; + void _closeStreamControllersForDevice(String deviceId) { + final prefix = "$deviceId||"; + final keysToRemove = _streamControllers.keys + .where((key) => key.startsWith(prefix)) + .toList(growable: false); + + for (final key in keysToRemove) { + final controllers = _streamControllers.remove(key); + if (controllers == null) { + continue; + } + for (final controller in controllers) { + if (!controller.isClosed) { + controller.close(); + } + } + } + } + final Map _connectionCompleters = {}; final Map _connectCallbacks = {}; final Map _disconnectCallbacks = {}; @@ -71,8 +90,11 @@ class BleManager extends BleGattManager { if (!_streamControllers.containsKey(streamIdentifier)) { return; } - for (var e in _streamControllers[streamIdentifier]!) { - e.add(value); + final controllers = _streamControllers[streamIdentifier]!; + for (var e in controllers) { + if (!e.isClosed) { + e.add(value); + } } }; } @@ -186,11 +208,7 @@ class BleManager extends BleGattManager { DiscoveredDevice device, VoidCallback onDisconnect, ) { - for (var list in _streamControllers.values) { - for (var e in list) { - e.close(); - } - } + _closeStreamControllersForDevice(device.id); Completer<(bool, List)> completer = Completer<(bool, List)>(); @@ -211,6 +229,7 @@ class BleManager extends BleGattManager { }; _disconnectCallbacks[device.id] = () { + _closeStreamControllersForDevice(device.id); _connectionCompleters[device.id]?.complete((false, [])); _connectionCompleters.remove(device.id); @@ -359,8 +378,11 @@ class BleManager extends BleGattManager { for (var list in _streamControllers.values) { for (var e in list) { - e.close(); + if (!e.isClosed) { + e.close(); + } } } + _streamControllers.clear(); } } diff --git a/lib/src/managers/open_earable_sensor_manager.dart b/lib/src/managers/open_earable_sensor_manager.dart index 81944bf..2c62386 100644 --- a/lib/src/managers/open_earable_sensor_manager.dart +++ b/lib/src/managers/open_earable_sensor_manager.dart @@ -7,6 +7,7 @@ import 'package:open_earable_flutter/src/utils/sensor_scheme_parser/edge_ml_sens import 'package:open_earable_flutter/src/utils/sensor_value_parser/sensor_value_parser.dart'; import '../constants.dart'; +import '../../open_earable_flutter.dart' show logger; import '../utils/mahony_ahrs.dart'; import '../utils/sensor_scheme_parser/sensor_scheme_reader.dart'; import '../utils/sensor_value_parser/edge_ml_sensor_value_parser.dart'; @@ -23,6 +24,7 @@ class OpenEarableSensorHandler extends SensorHandler { final SensorSchemeReader _sensorSchemeParser; final SensorValueParser _sensorValueParser; List? _sensorSchemes; + Future? _sensorSchemesReadFuture; /// Creates a [OpenEarableSensorHandler] instance with the specified [bleManager]. OpenEarableSensorHandler({ @@ -31,9 +33,10 @@ class OpenEarableSensorHandler extends SensorHandler { SensorSchemeReader? sensorSchemeParser, SensorValueParser? sensorValueParser, }) : _bleManager = bleManager, - _sensorSchemeParser = sensorSchemeParser ?? EdgeMlSensorSchemeReader(bleManager, deviceId), + _sensorSchemeParser = sensorSchemeParser ?? + EdgeMlSensorSchemeReader(bleManager, deviceId), _sensorValueParser = sensorValueParser ?? EdgeMlSensorValueParser() { - _readSensorScheme(); + unawaited(_ensureSensorSchemesLoaded()); } /// Writes the sensor configuration to the OpenEarable device. @@ -43,7 +46,7 @@ class OpenEarableSensorHandler extends SensorHandler { @override Future writeSensorConfig(OpenEarableSensorConfig sensorConfig) async { if (!_bleManager.isConnected(deviceId)) { - Exception("Can't write sensor config. Earable not connected"); + throw Exception("Can't write sensor config. Earable not connected"); } await _bleManager.write( deviceId: deviceId, @@ -51,9 +54,7 @@ class OpenEarableSensorHandler extends SensorHandler { characteristicId: sensorConfigurationCharacteristicUuid, byteData: sensorConfig.byteList, ); - if (_sensorSchemes == null || _sensorSchemes!.isEmpty) { - await _readSensorScheme(); - } + await _ensureSensorSchemesLoaded(); } /// Subscribes to sensor data for a specific sensor. @@ -65,73 +66,122 @@ class OpenEarableSensorHandler extends SensorHandler { @override Stream> subscribeToSensorData(int sensorId) { if (!_bleManager.isConnected(deviceId)) { - Exception("Can't subscribe to sensor data. Earable not connected"); + throw Exception("Can't subscribe to sensor data. Earable not connected"); } - StreamController> streamController = - StreamController(); - int lastTimestamp = 0; - _bleManager - .subscribe( - deviceId: deviceId, - serviceId: sensorServiceUuid, - characteristicId: sensorDataCharacteristicUuid, - ) - .listen( - (data) async { - if (data.isNotEmpty && data[0] == sensorId) { - List> parsedDataList = await _parseData(data); - for (var parsedData in parsedDataList) { - if (sensorId == imuID) { - int timestamp = parsedData["timestamp"]; - double ax = parsedData["ACC"]["X"]; - double ay = parsedData["ACC"]["Y"]; - double az = parsedData["ACC"]["Z"]; - - double gx = parsedData["GYRO"]["X"]; - double gy = parsedData["GYRO"]["Y"]; - double gz = parsedData["GYRO"]["Z"]; - - double dt = (timestamp - lastTimestamp) / 1000.0; - - // x, y, z was changed in firmware to -x, z, y - _mahonyAHRS.update( - ax, - ay, - az, - gx, - gy, - gz, - dt, - ); - lastTimestamp = timestamp; - List q = _mahonyAHRS.quaternion; - double yaw = -atan2( - 2 * (q[0] * q[3] + q[1] * q[2]), - 1 - 2 * (q[2] * q[2] + q[3] * q[3]), - ); + late final StreamController> streamController; + // ignore: cancel_subscriptions + StreamSubscription>? subscription; + int lastTimestamp = 0; - // Pitch (around Y-axis) - double pitch = -asin(2 * (q[0] * q[2] - q[3] * q[1])); + streamController = StreamController>( + onListen: () { + // ignore: cancel_subscriptions + subscription = _bleManager + .subscribe( + deviceId: deviceId, + serviceId: sensorServiceUuid, + characteristicId: sensorDataCharacteristicUuid, + ) + .listen( + (data) async { + if (data.isEmpty || data[0] != sensorId) { + return; + } - // Roll (around X-axis) - double roll = -atan2( - 2 * (q[0] * q[1] + q[2] * q[3]), - 1 - 2 * (q[1] * q[1] + q[2] * q[2]), + try { + List> parsedDataList = + await _parseData(data); + for (var parsedData in parsedDataList) { + if (sensorId == imuID) { + int timestamp = parsedData["timestamp"]; + double ax = parsedData["ACC"]["X"]; + double ay = parsedData["ACC"]["Y"]; + double az = parsedData["ACC"]["Z"]; + + double gx = parsedData["GYRO"]["X"]; + double gy = parsedData["GYRO"]["Y"]; + double gz = parsedData["GYRO"]["Z"]; + + double dt = (timestamp - lastTimestamp) / 1000.0; + + // x, y, z was changed in firmware to -x, z, y + _mahonyAHRS.update( + ax, + ay, + az, + gx, + gy, + gz, + dt, + ); + + lastTimestamp = timestamp; + List q = _mahonyAHRS.quaternion; + double yaw = -atan2( + 2 * (q[0] * q[3] + q[1] * q[2]), + 1 - 2 * (q[2] * q[2] + q[3] * q[3]), + ); + + // Pitch (around Y-axis) + double pitch = -asin(2 * (q[0] * q[2] - q[3] * q[1])); + + // Roll (around X-axis) + double roll = -atan2( + 2 * (q[0] * q[1] + q[2] * q[3]), + 1 - 2 * (q[1] * q[1] + q[2] * q[2]), + ); + + parsedData["EULER"] = {}; + parsedData["EULER"]["YAW"] = yaw; + parsedData["EULER"]["PITCH"] = pitch; + parsedData["EULER"]["ROLL"] = roll; + parsedData["EULER"]["units"] = { + "YAW": "rad", + "PITCH": "rad", + "ROLL": "rad", + }; + } + + if (!streamController.isClosed) { + streamController.add(parsedData); + } + } + } catch (error, stackTrace) { + logger.e( + "Error while processing OpenEarable sensor packet: $error", + error: error, + stackTrace: stackTrace, ); - - parsedData["EULER"] = {}; - parsedData["EULER"]["YAW"] = yaw; - parsedData["EULER"]["PITCH"] = pitch; - parsedData["EULER"]["ROLL"] = roll; - parsedData["EULER"] - ["units"] = {"YAW": "rad", "PITCH": "rad", "ROLL": "rad"}; + if (!streamController.isClosed) { + streamController.addError(error, stackTrace); + } + } + }, + onError: (error, stackTrace) { + logger.e( + "Error while subscribing to OpenEarable sensor data: $error", + error: error, + stackTrace: stackTrace, + ); + if (!streamController.isClosed) { + streamController.addError(error, stackTrace); } - streamController.add(parsedData); - } + }, + onDone: () { + if (!streamController.isClosed) { + streamController.close(); + } + }, + ); + }, + onCancel: () async { + final activeSubscription = subscription; + subscription = null; + if (activeSubscription != null) { + await activeSubscription.cancel(); } }, - onError: (error) {}, ); return streamController.stream; @@ -139,11 +189,33 @@ class OpenEarableSensorHandler extends SensorHandler { /// Parses raw sensor data bytes into a [Map] of sensor values. Future>> _parseData(List data) async { + await _ensureSensorSchemesLoaded(); ByteData byteData = ByteData.sublistView(Uint8List.fromList(data)); - + return _sensorValueParser.parse(byteData, _sensorSchemes!); } + Future _ensureSensorSchemesLoaded() async { + final schemes = _sensorSchemes; + if (schemes != null && schemes.isNotEmpty) { + return; + } + + final pendingRead = _sensorSchemesReadFuture ??= _readSensorScheme(); + try { + await pendingRead; + } finally { + if (identical(_sensorSchemesReadFuture, pendingRead)) { + _sensorSchemesReadFuture = null; + } + } + + final loadedSchemes = _sensorSchemes; + if (loadedSchemes == null || loadedSchemes.isEmpty) { + throw StateError('OpenEarable sensor scheme is not available yet'); + } + } + /// Reads the sensor scheme that is needed to parse the raw sensor /// data bytes Future _readSensorScheme() async { 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..bd14b95 --- /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 = false; + 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/managers/v2_sensor_handler.dart b/lib/src/managers/v2_sensor_handler.dart index e8ffa4f..8637daf 100644 --- a/lib/src/managers/v2_sensor_handler.dart +++ b/lib/src/managers/v2_sensor_handler.dart @@ -14,6 +14,7 @@ class V2SensorHandler extends SensorHandler { final SensorSchemeReader _sensorSchemeParser; final SensorValueParser _sensorValueParser; List? _sensorSchemes; + Future? _sensorSchemesReadFuture; V2SensorHandler({ required DiscoveredDevice discoveredDevice, @@ -31,29 +32,65 @@ class V2SensorHandler extends SensorHandler { throw Exception("Can't subscribe to sensor data. Earable not connected"); } - if (_sensorSchemes == null) { - _readSensorScheme(); - } - - StreamController> streamController = - StreamController(); - _bleManager - .subscribe( - deviceId: _discoveredDevice.id, - serviceId: sensorServiceUuid, - characteristicId: sensorDataCharacteristicUuid, - ) - .listen( - (data) async { - if (data.isNotEmpty && data[0] == sensorId) { - List> parsedData = await _parseData(data); - for (var d in parsedData) { - streamController.add(d); - } - } + late final StreamController> streamController; + // ignore: cancel_subscriptions + StreamSubscription>? subscription; + streamController = StreamController>( + onListen: () { + // ignore: cancel_subscriptions + subscription = _bleManager + .subscribe( + deviceId: _discoveredDevice.id, + serviceId: sensorServiceUuid, + characteristicId: sensorDataCharacteristicUuid, + ) + .listen( + (data) async { + if (data.isEmpty || data[0] != sensorId) { + return; + } + + try { + List> parsedData = await _parseData(data); + for (var d in parsedData) { + if (!streamController.isClosed) { + streamController.add(d); + } + } + } catch (error, stackTrace) { + logger.e( + "Error while processing V2 sensor packet: $error", + error: error, + stackTrace: stackTrace, + ); + if (!streamController.isClosed) { + streamController.addError(error, stackTrace); + } + } + }, + onError: (error, stackTrace) { + logger.e( + "Error while subscribing to sensor data: $error", + error: error, + stackTrace: stackTrace, + ); + if (!streamController.isClosed) { + streamController.addError(error, stackTrace); + } + }, + onDone: () { + if (!streamController.isClosed) { + streamController.close(); + } + }, + ); }, - onError: (error) { - logger.e("Error while subscribing to sensor data: $error"); + onCancel: () async { + final activeSubscription = subscription; + subscription = null; + if (activeSubscription != null) { + await activeSubscription.cancel(); + } }, ); @@ -63,11 +100,9 @@ class V2SensorHandler extends SensorHandler { @override Future writeSensorConfig(V2SensorConfig sensorConfig) async { if (!_bleManager.isConnected(_discoveredDevice.id)) { - Exception("Can't write sensor config. Earable not connected"); - } - if (_sensorSchemes == null) { - await _readSensorScheme(); + throw Exception("Can't write sensor config. Earable not connected"); } + await _ensureSensorSchemesLoaded(); Uint8List sensorConfigBytes = sensorConfig.toBytes(); @@ -79,17 +114,35 @@ class V2SensorHandler extends SensorHandler { ); } - /// Parses raw sensor data bytes into a [Map] of sensor values. + /// Parses raw sensor data bytes into a [Map] of sensor values. Future>> _parseData(List data) async { + await _ensureSensorSchemesLoaded(); ByteData byteData = ByteData.sublistView(Uint8List.fromList(data)); - if (_sensorSchemes == null) { - await _readSensorScheme(); - } - return _sensorValueParser.parse(byteData, _sensorSchemes!); } + Future _ensureSensorSchemesLoaded() async { + final schemes = _sensorSchemes; + if (schemes != null && schemes.isNotEmpty) { + return; + } + + final pendingRead = _sensorSchemesReadFuture ??= _readSensorScheme(); + try { + await pendingRead; + } finally { + if (identical(_sensorSchemesReadFuture, pendingRead)) { + _sensorSchemesReadFuture = null; + } + } + + final loadedSchemes = _sensorSchemes; + if (loadedSchemes == null || loadedSchemes.isEmpty) { + throw StateError('V2 sensor scheme is not available yet'); + } + } + /// Reads the sensor scheme that is needed to parse the raw sensor /// data bytes Future _readSensorScheme() async { 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..ea25bfd --- /dev/null +++ b/lib/src/models/capabilities/sensor_configuration_specializations/open_ring_sensor_configuration.dart @@ -0,0 +1,45 @@ +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, + super.offValue, + 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..8bab7b0 --- /dev/null +++ b/lib/src/models/devices/open_ring_factory.dart @@ -0,0 +1,198 @@ +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 imuOnConfig = OpenRingSensorConfigurationValue( + key: "On", + cmd: OpenRingGatt.cmdIMU, + payload: [0x06], + ); + final imuOffConfig = OpenRingSensorConfigurationValue( + key: "Off", + cmd: OpenRingGatt.cmdIMU, + payload: [0x00], + ); + final imuSensorConfig = OpenRingSensorConfiguration( + name: "6-Axis IMU", + values: [imuOffConfig, imuOnConfig], + offValue: imuOffConfig, + sensorHandler: sensorHandler, + ); + + final ppgOnConfig = 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 + ], + ); + final ppgOffConfig = OpenRingSensorConfigurationValue( + key: "Off", + cmd: OpenRingGatt.cmdPPGQ2, + payload: [ + 0x06, // stop Q2 collection (LmAPI STOP_Q2) + ], + ); + final ppgSensorConfig = OpenRingSensorConfiguration( + name: "PPG", + values: [ppgOffConfig, ppgOnConfig], + offValue: ppgOffConfig, + sensorHandler: sensorHandler, + ); + + final temperatureOnConfig = OpenRingSensorConfigurationValue( + key: "On", + cmd: OpenRingGatt.cmdPPGQ2, + payload: const [], + temperatureStreamEnabled: true, + ); + final temperatureOffConfig = OpenRingSensorConfigurationValue( + key: "Off", + cmd: OpenRingGatt.cmdPPGQ2, + payload: const [], + temperatureStreamEnabled: false, + ); + final temperatureSensorConfig = OpenRingSensorConfiguration( + name: "Temperature", + values: [temperatureOffConfig, temperatureOnConfig], + offValue: temperatureOffConfig, + 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, + relatedConfigurations: [imuSensorConfig], + ), + OpenRingSensor( + sensorId: OpenRingGatt.cmdIMU, + sensorName: "Gyroscope", + chartTitle: "Gyroscope", + shortChartTitle: "Gyr.", + axisNames: ["X", "Y", "Z"], + axisUnits: ["dps", "dps", "dps"], + 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: ["°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), - }; - } -} diff --git a/tools/examples/lsl_bridge/lsl_bridge.py b/tools/examples/lsl_bridge/lsl_bridge.py new file mode 100644 index 0000000..d706405 --- /dev/null +++ b/tools/examples/lsl_bridge/lsl_bridge.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +""" +OpenWearables LSL bridge example. + +Receives UDP JSON sensor packets through the shared NetworkRelayServer and +publishes one LSL outlet per OpenWearables stream. +""" + +from __future__ import annotations + +import argparse +import socket +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Optional + +TOOLS_DIR = Path(__file__).resolve().parents[2] +if str(TOOLS_DIR) not in sys.path: + sys.path.insert(0, str(TOOLS_DIR)) + +from network_relay_server import ( # noqa: E402 + NetworkRelayServer, + UdpSensorSample, + clean_text, +) + +try: + from pylsl import StreamInfo, StreamOutlet, local_clock +except ImportError as exc: # pragma: no cover - import guard + print( + "Missing dependency: pylsl.\n" + "Install with:\n" + " pip install pylsl", + file=sys.stderr, + ) + raise SystemExit(2) from exc + + +@dataclass(frozen=True) +class StreamSpec: + name: str + stream_type: str + channel_count: int + source_id: str + device_name: str + device_channel: str + sensor_name: str + source: str + + +@dataclass +class SensorClockAlignment: + sensor_zero_seconds: float + lsl_zero_seconds: float + last_lsl_seconds: float + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Receive OpenWearables UDP sensor packets and publish them as " + "LSL outlets." + ) + ) + parser.add_argument( + "--host", + default="0.0.0.0", + help="UDP bind host. Use 0.0.0.0 to listen on all interfaces (default).", + ) + parser.add_argument( + "--port", + type=int, + default=16571, + help="UDP bind port (default: 16571).", + ) + parser.add_argument( + "--poll-interval", + type=float, + default=0.25, + help="Polling interval for UDP relay loop in seconds (default: 0.25).", + ) + parser.add_argument( + "--verbose", + action="store_true", + help="Print every bridged sample.", + ) + return parser.parse_args() + + +def _candidate_ips(bind_host: str) -> List[str]: + addresses = set() + + if bind_host not in ("0.0.0.0", "", "::"): + addresses.add(bind_host) + + try: + host_name = socket.gethostname() + for ip in socket.gethostbyname_ex(host_name)[2]: + if "." in ip and not ip.startswith("127."): + addresses.add(ip) + except OSError: + pass + + for probe_host in ("8.8.8.8", "1.1.1.1"): + try: + probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + probe.connect((probe_host, 80)) + local_ip = probe.getsockname()[0] + probe.close() + if local_ip and not local_ip.startswith("127."): + addresses.add(local_ip) + except OSError: + continue + + if not addresses: + addresses.add("127.0.0.1") + + return sorted(addresses) + + +def _stream_spec(sample: UdpSensorSample) -> StreamSpec: + return StreamSpec( + name=sample.stream.name, + stream_type="OpenWearables", + channel_count=len(sample.values), + source_id=sample.stream.source_id, + device_name=sample.stream.device.name, + device_channel=sample.stream.device.channel, + sensor_name=sample.stream.sensor_name, + source=sample.stream.device.source, + ) + + +def _create_outlet(sample: UdpSensorSample, values: List[float]) -> StreamOutlet: + spec = _stream_spec(sample) + device_token = clean_text( + sample.raw.get("device_token") or sample.raw.get("device_id"), + "unknown_device", + ) + axis_names = list(sample.axis_names) + axis_units = list(sample.axis_units) + + info = StreamInfo( + name=spec.name, + type=spec.stream_type, + channel_count=len(values), + nominal_srate=0.0, + channel_format="float32", + source_id=spec.source_id, + ) + + desc = info.desc() + desc.append_child_value("manufacturer", "OpenWearables") + desc.append_child_value("device_token", device_token) + desc.append_child_value("device_name", spec.device_name) + desc.append_child_value("device_channel", spec.device_channel) + if spec.device_channel: + desc.append_child_value("device_side", spec.device_channel) + desc.append_child_value("device_source", spec.source) + desc.append_child_value("sensor_name", spec.sensor_name) + desc.append_child_value("source_id", spec.source_id) + desc.append_child_value("timestamp_exponent", str(sample.timestamp_exponent or -3)) + + channels = desc.append_child("channels") + for idx in range(len(values)): + ch = channels.append_child("channel") + label = axis_names[idx] if idx < len(axis_names) else f"ch_{idx}" + if spec.device_channel: + label = f"{spec.device_channel}-{label}" + unit = axis_units[idx] if idx < len(axis_units) else "" + ch.append_child_value("label", str(label)) + ch.append_child_value("unit", str(unit)) + ch.append_child_value("type", spec.sensor_name) + + return StreamOutlet(info) + + +class LslBridgeApp: + def __init__(self, args: argparse.Namespace) -> None: + self._args = args + self._relay_server = NetworkRelayServer( + host=args.host, + port=args.port, + on_warning=print, + ) + self._relay_server.add_sample_listener(self._on_sample) + + self._outlets: Dict[StreamSpec, StreamOutlet] = {} + self._clock_alignment: Dict[StreamSpec, SensorClockAlignment] = {} + + self._print_startup() + + def _print_startup(self) -> None: + udp_ips = _candidate_ips(self._args.host) + selected_udp_ip = udp_ips[0] + + print("") + print("OpenWearables LSL Bridge started.") + print(f"Listening for UDP packets on {self._args.host}:{self._relay_server.port}") + print("Use one of these IPs in your Flutter app:") + for ip in udp_ips: + marker = " (recommended)" if ip == selected_udp_ip else "" + print(f" - {ip}{marker}") + + print("") + print("Example app setup:") + print(" final udpBridgeForwarder = UdpBridgeForwarder.instance;") + print( + " udpBridgeForwarder.configure(" + f"host: '{selected_udp_ip}', port: {self._relay_server.port}, enabled: true);" + ) + print(" WearableManager().addSensorForwarder(udpBridgeForwarder);") + print("") + print("Press Ctrl+C to stop.") + + def _lsl_timestamp_for_sample( + self, spec: StreamSpec, sensor_time_seconds: Optional[float] + ) -> float: + if sensor_time_seconds is None: + return local_clock() + + alignment = self._clock_alignment.get(spec) + if alignment is None: + now = local_clock() + self._clock_alignment[spec] = SensorClockAlignment( + sensor_zero_seconds=sensor_time_seconds, + lsl_zero_seconds=now, + last_lsl_seconds=now, + ) + return now + + lsl_time = alignment.lsl_zero_seconds + ( + sensor_time_seconds - alignment.sensor_zero_seconds + ) + if lsl_time <= alignment.last_lsl_seconds: + lsl_time = alignment.last_lsl_seconds + 1e-6 + alignment.last_lsl_seconds = lsl_time + return lsl_time + + def _on_sample(self, sample: UdpSensorSample, remote: tuple[str, int]) -> None: + _ = remote + values = list(sample.values) + spec = _stream_spec(sample) + lsl_timestamp = self._lsl_timestamp_for_sample(spec, sample.timestamp_seconds) + + outlet = self._outlets.get(spec) + if outlet is None: + outlet = _create_outlet(sample, values) + self._outlets[spec] = outlet + print( + "Created LSL outlet: " + f"name='{spec.name}', channels={spec.channel_count}, source_id='{spec.source_id}'" + ) + + outlet.push_sample(values, lsl_timestamp) + + if self._args.verbose: + print( + f"Sample {spec.name}: {values} " + f"(device_ts={sample.timestamp}, lsl_ts={lsl_timestamp:.6f})" + ) + + def run(self) -> None: + self._relay_server.run(poll_interval=self._args.poll_interval) + + def close(self) -> None: + self._relay_server.close() + + +def main() -> int: + args = parse_args() + + if args.port < 1 or args.port > 65535: + print(f"Invalid UDP port: {args.port}", file=sys.stderr) + return 2 + if args.poll_interval <= 0: + print("--poll-interval must be > 0", file=sys.stderr) + return 2 + + try: + app = LslBridgeApp(args) + except OSError as exc: + print( + f"Failed to bind UDP socket ({args.host}:{args.port}): {exc}", + file=sys.stderr, + ) + return 2 + + try: + app.run() + except KeyboardInterrupt: + print("\nStopping LSL bridge...") + finally: + app.close() + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/examples/web_plotter/dashboard.html b/tools/examples/web_plotter/dashboard.html new file mode 100644 index 0000000..632b397 --- /dev/null +++ b/tools/examples/web_plotter/dashboard.html @@ -0,0 +1,1126 @@ + + + + + + OpenWearables Web Plotter + + + +
+
+

OpenWearables Web Plotter

+
+ UDP: -- + WAITING +
+
+ 0 packets + 0 streams + No data yet +
+
+ + +
Waiting for packets. Keep this page open.
+
+ + + + diff --git a/tools/examples/web_plotter/web_plotter.py b/tools/examples/web_plotter/web_plotter.py new file mode 100644 index 0000000..16ad4b2 --- /dev/null +++ b/tools/examples/web_plotter/web_plotter.py @@ -0,0 +1,577 @@ +#!/usr/bin/env python3 +""" +OpenWearables web plotter example. + +Receives UDP JSON sensor packets from open_earable_flutter through the shared +NetworkRelayServer and serves a live web dashboard (SSE + static HTML). +""" + +from __future__ import annotations + +import argparse +import json +import os +import queue +import socket +import sys +import threading +import time +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Dict, List, Optional, Set + +TOOLS_DIR = Path(__file__).resolve().parents[2] +if str(TOOLS_DIR) not in sys.path: + sys.path.insert(0, str(TOOLS_DIR)) + +from network_relay_server import NetworkRelayServer, UdpSensorSample # noqa: E402 + + +DEFAULT_DASHBOARD_PORT = 8765 +DEFAULT_MAX_EVENTS = 300 + +DASHBOARD_TITLE = "OpenWearables Web Plotter" + +ANSI_RESET = "\033[0m" +ANSI_BOLD = "\033[1m" +ANSI_DIM = "\033[2m" +ANSI_GREEN = "\033[32m" +ANSI_YELLOW = "\033[33m" +ANSI_CYAN = "\033[36m" + + +def _supports_color() -> bool: + if os.getenv("NO_COLOR") is not None: + return False + return hasattr(sys.stdout, "isatty") and sys.stdout.isatty() + + +def _styled(text: str, *codes: str) -> str: + if not _supports_color() or not codes: + return text + return f"{''.join(codes)}{text}{ANSI_RESET}" + + +@dataclass(frozen=True) +class StreamSpec: + name: str + channel_count: int + source_id: str + device_name: str + device_channel: str + sensor_name: str + source: str + + +@dataclass +class SensorClockAlignment: + sensor_zero_seconds: float + wall_zero_seconds: float + last_wall_seconds: float + + +class DashboardState: + def __init__(self, max_events: int, udp_host: str, udp_port: int) -> None: + self._max_events = max(1, max_events) + self._udp_host = udp_host + self._udp_port = udp_port + self._lock = threading.Lock() + self._listeners: Set[queue.Queue] = set() + self._streams_by_id: Dict[str, dict] = {} + self._recent_events: List[dict] = [] + self._packets_received = 0 + self._last_packet_wall_time: Optional[float] = None + + def add_listener(self) -> queue.Queue: + listener: queue.Queue = queue.Queue(maxsize=256) + with self._lock: + self._listeners.add(listener) + return listener + + def remove_listener(self, listener: queue.Queue) -> None: + with self._lock: + self._listeners.discard(listener) + + def _publish_event(self, event_name: str, payload: dict) -> None: + with self._lock: + listeners = list(self._listeners) + + for listener in listeners: + try: + listener.put_nowait((event_name, payload)) + except queue.Full: + try: + listener.get_nowait() + except queue.Empty: + pass + try: + listener.put_nowait((event_name, payload)) + except queue.Full: + self.remove_listener(listener) + + def snapshot(self) -> dict: + with self._lock: + streams = sorted( + self._streams_by_id.values(), + key=lambda item: ( + str(item.get("device_name", "")).casefold(), + str(item.get("device_channel", "")).casefold(), + str(item.get("sensor_name", "")).casefold(), + ), + ) + return { + "title": DASHBOARD_TITLE, + "packets_received": self._packets_received, + "last_packet_wall_time": self._last_packet_wall_time, + "udp_host": self._udp_host, + "udp_port": self._udp_port, + "streams": streams, + "recent_events": list(self._recent_events), + } + + def record_sample( + self, + spec: StreamSpec, + values: List[float], + sample: dict, + plot_timestamp: float, + ) -> None: + now = time.time() + payload = { + "stream_name": spec.name, + "source_id": spec.source_id, + "device_name": spec.device_name, + "device_channel": spec.device_channel, + "sensor_name": spec.sensor_name, + "source": spec.source, + "values": values, + "axis_names": sample.get("axis_names") or [], + "axis_units": sample.get("axis_units") or [], + "timestamp": sample.get("timestamp"), + "timestamp_exponent": sample.get("timestamp_exponent"), + # Kept for compatibility with the existing dashboard UI payload shape. + "lsl_timestamp": plot_timestamp, + "received_at": now, + } + + with self._lock: + self._packets_received += 1 + self._last_packet_wall_time = now + + stream_item = self._streams_by_id.get(spec.source_id) + if stream_item is None: + stream_item = { + "stream_name": spec.name, + "source_id": spec.source_id, + "device_name": spec.device_name, + "device_channel": spec.device_channel, + "sensor_name": spec.sensor_name, + "source": spec.source, + "channel_count": spec.channel_count, + "samples_received": 0, + "last_values": [], + "axis_names": payload["axis_names"], + "axis_units": payload["axis_units"], + "last_lsl_timestamp": None, + "last_received_at": None, + } + self._streams_by_id[spec.source_id] = stream_item + + stream_item["samples_received"] = int(stream_item["samples_received"]) + 1 + stream_item["channel_count"] = spec.channel_count + stream_item["last_values"] = values + stream_item["axis_names"] = payload["axis_names"] + stream_item["axis_units"] = payload["axis_units"] + stream_item["last_lsl_timestamp"] = plot_timestamp + stream_item["last_received_at"] = now + + self._recent_events.append(payload) + if len(self._recent_events) > self._max_events: + self._recent_events.pop(0) + + self._publish_event( + "sample", + { + "packets_received": self.packet_count, + "last_packet_wall_time": self.last_packet_wall_time, + "sample": payload, + }, + ) + + @property + def packet_count(self) -> int: + with self._lock: + return self._packets_received + + @property + def last_packet_wall_time(self) -> Optional[float]: + with self._lock: + return self._last_packet_wall_time + + +class DashboardRequestHandler(BaseHTTPRequestHandler): + dashboard_state: DashboardState + stop_event: threading.Event + dashboard_html_path: Path + + def do_GET(self) -> None: # noqa: N802 - stdlib API + if self.path in ("/", "/index.html"): + try: + body = self.dashboard_html_path.read_bytes() + except OSError as exc: + self.send_error(500, f"Failed to load dashboard HTML: {exc}") + return + + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") + self.send_header("Pragma", "no-cache") + self.send_header("Expires", "0") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + + if self.path == "/events": + self._serve_events() + return + + if self.path == "/health": + body = json.dumps( + { + "status": "ok", + "packets_received": self.dashboard_state.packet_count, + "last_packet_wall_time": self.dashboard_state.last_packet_wall_time, + } + ).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Cache-Control", "no-cache") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + + self.send_error(404, "Not Found") + + def _serve_events(self) -> None: + listener = self.dashboard_state.add_listener() + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "keep-alive") + self.send_header("X-Accel-Buffering", "no") + self.end_headers() + + try: + self._send_sse("snapshot", self.dashboard_state.snapshot()) + while not self.stop_event.is_set(): + try: + event_name, payload = listener.get(timeout=10.0) + self._send_sse(event_name, payload) + except queue.Empty: + self.wfile.write(b": ping\n\n") + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError, TimeoutError): + return + finally: + self.dashboard_state.remove_listener(listener) + + def _send_sse(self, event_name: str, payload: dict) -> None: + data = json.dumps(payload, separators=(",", ":")) + self.wfile.write(f"event: {event_name}\n".encode("utf-8")) + self.wfile.write(f"data: {data}\n\n".encode("utf-8")) + self.wfile.flush() + + def log_message(self, format: str, *args: object) -> None: + return + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Receive OpenWearables UDP sensor packets and serve a live web " + "plotting dashboard." + ) + ) + parser.add_argument( + "--host", + default="0.0.0.0", + help="UDP bind host. Use 0.0.0.0 to listen on all interfaces (default).", + ) + parser.add_argument( + "--port", + type=int, + default=16571, + help="UDP bind port (default: 16571).", + ) + parser.add_argument( + "--dashboard-host", + default="0.0.0.0", + help="Web dashboard bind host (default: 0.0.0.0).", + ) + parser.add_argument( + "--dashboard-port", + type=int, + default=DEFAULT_DASHBOARD_PORT, + help=f"Web dashboard bind port (default: {DEFAULT_DASHBOARD_PORT}).", + ) + parser.add_argument( + "--max-events", + type=int, + default=DEFAULT_MAX_EVENTS, + help=( + "How many recent dashboard events to keep in memory for reconnecting " + f"clients (default: {DEFAULT_MAX_EVENTS})." + ), + ) + parser.add_argument( + "--poll-interval", + type=float, + default=0.25, + help="Polling interval for UDP relay loop in seconds (default: 0.25).", + ) + parser.add_argument( + "--verbose", + action="store_true", + help="Print every received sample.", + ) + + # Backward-compatible no-op flags from the old plot-based script. + parser.add_argument("--history-seconds", type=float, default=20.0, help=argparse.SUPPRESS) + parser.add_argument("--refresh-ms", type=int, default=100, help=argparse.SUPPRESS) + parser.add_argument("--max-samples", type=int, default=2000, help=argparse.SUPPRESS) + + return parser.parse_args() + + +def _candidate_ips(bind_host: str) -> List[str]: + addresses = set() + + if bind_host not in ("0.0.0.0", "", "::"): + addresses.add(bind_host) + + try: + host_name = socket.gethostname() + for ip in socket.gethostbyname_ex(host_name)[2]: + if "." in ip and not ip.startswith("127."): + addresses.add(ip) + except OSError: + pass + + for probe_host in ("8.8.8.8", "1.1.1.1"): + try: + probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + probe.connect((probe_host, 80)) + local_ip = probe.getsockname()[0] + probe.close() + if local_ip and not local_ip.startswith("127."): + addresses.add(local_ip) + except OSError: + continue + + if not addresses: + addresses.add("127.0.0.1") + + return sorted(addresses) + + +def _stream_spec(sample: UdpSensorSample) -> StreamSpec: + return StreamSpec( + name=sample.stream.name, + channel_count=len(sample.values), + source_id=sample.stream.source_id, + device_name=sample.stream.device.name, + device_channel=sample.stream.device.channel, + sensor_name=sample.stream.sensor_name, + source=sample.stream.device.source, + ) + + +class WebPlotterApp: + def __init__(self, args: argparse.Namespace) -> None: + self._args = args + self._relay_server = NetworkRelayServer( + host=args.host, + port=args.port, + on_warning=print, + ) + self._relay_server.add_sample_listener(self._on_sample) + self._clock_alignment: Dict[StreamSpec, SensorClockAlignment] = {} + + self._dashboard_state = DashboardState( + max_events=args.max_events, + udp_host=args.host, + udp_port=self._relay_server.port, + ) + self._stop_event = threading.Event() + + handler_cls = self._handler_class() + self._dashboard_server = ThreadingHTTPServer( + (args.dashboard_host, args.dashboard_port), handler_cls + ) + self._dashboard_server.daemon_threads = True + self._dashboard_thread = threading.Thread( + target=self._dashboard_server.serve_forever, + kwargs={"poll_interval": 0.25}, + daemon=True, + name="openwearables-web-plotter", + ) + + self._print_startup() + + def _handler_class(self) -> type[DashboardRequestHandler]: + dashboard_state = self._dashboard_state + stop_event = self._stop_event + dashboard_html_path = Path(__file__).with_name("dashboard.html") + + class Handler(DashboardRequestHandler): + pass + + Handler.dashboard_state = dashboard_state + Handler.stop_event = stop_event + Handler.dashboard_html_path = dashboard_html_path + return Handler + + def _print_startup(self) -> None: + udp_ips = _candidate_ips(self._args.host) + selected_udp_ip = udp_ips[0] + + if self._args.dashboard_host in ("0.0.0.0", "", "::"): + dashboard_ips = _candidate_ips(self._args.dashboard_host) + else: + dashboard_ips = [self._args.dashboard_host] + + dashboard_url = f"http://{dashboard_ips[0]}:{self._args.dashboard_port}" + + print("") + print(_styled("OpenWearables Web Plotter started.", ANSI_BOLD, ANSI_CYAN)) + print( + _styled( + f"Listening for UDP packets on {self._args.host}:{self._relay_server.port}", + ANSI_GREEN, + ) + ) + print(_styled("Use one of these IPs in your Flutter app:", ANSI_BOLD)) + for ip in udp_ips: + marker = " (recommended)" if ip == selected_udp_ip else "" + print(f" - {_styled(ip, ANSI_YELLOW)}{marker}") + + print("") + print(_styled("Example app setup:", ANSI_BOLD)) + print(_styled(" final udpBridgeForwarder = UdpBridgeForwarder.instance;", ANSI_DIM)) + print( + _styled( + f" udpBridgeForwarder.configure(host: '{selected_udp_ip}', port: {self._relay_server.port}, enabled: true);", + ANSI_DIM, + ) + ) + print(_styled(" WearableManager().addSensorForwarder(udpBridgeForwarder);", ANSI_DIM)) + + print("") + print(_styled("Web dashboard URLs:", ANSI_BOLD)) + for ip in dashboard_ips: + marker = " (recommended)" if ip == dashboard_ips[0] else "" + print(f" - http://{ip}:{self._args.dashboard_port}{marker}") + + print("") + print(f"Open your browser to {dashboard_url}") + print("Press Ctrl+C to stop.") + + def _plot_timestamp_for_sample( + self, spec: StreamSpec, sensor_time_seconds: Optional[float] + ) -> float: + if sensor_time_seconds is None: + return time.time() + + alignment = self._clock_alignment.get(spec) + if alignment is None: + now = time.time() + self._clock_alignment[spec] = SensorClockAlignment( + sensor_zero_seconds=sensor_time_seconds, + wall_zero_seconds=now, + last_wall_seconds=now, + ) + return now + + plot_time = alignment.wall_zero_seconds + ( + sensor_time_seconds - alignment.sensor_zero_seconds + ) + if plot_time <= alignment.last_wall_seconds: + plot_time = alignment.last_wall_seconds + 1e-6 + alignment.last_wall_seconds = plot_time + return plot_time + + def _on_sample(self, sample: UdpSensorSample, remote: tuple[str, int]) -> None: + _ = remote + values = list(sample.values) + spec = _stream_spec(sample) + plot_timestamp = self._plot_timestamp_for_sample(spec, sample.timestamp_seconds) + + self._dashboard_state.record_sample(spec, values, sample.raw, plot_timestamp) + + if self._args.verbose: + print( + f"Sample {spec.name}: {values} " + f"(device_ts={sample.timestamp}, plot_ts={plot_timestamp:.6f})" + ) + + def run(self) -> None: + self._dashboard_thread.start() + self._relay_server.run( + stop_event=self._stop_event, + poll_interval=self._args.poll_interval, + ) + + def close(self) -> None: + self._stop_event.set() + try: + self._dashboard_server.shutdown() + self._dashboard_server.server_close() + except Exception: + pass + self._relay_server.close() + + +def main() -> int: + args = parse_args() + + if args.port < 1 or args.port > 65535: + print(f"Invalid UDP port: {args.port}", file=sys.stderr) + return 2 + if args.dashboard_port < 1 or args.dashboard_port > 65535: + print(f"Invalid dashboard port: {args.dashboard_port}", file=sys.stderr) + return 2 + if args.max_events < 1: + print("--max-events must be > 0", file=sys.stderr) + return 2 + if args.poll_interval <= 0: + print("--poll-interval must be > 0", file=sys.stderr) + return 2 + if not Path(__file__).with_name("dashboard.html").is_file(): + print("Missing dashboard.html next to web_plotter.py", file=sys.stderr) + return 2 + + try: + app = WebPlotterApp(args) + except OSError as exc: + print( + "Failed to bind plotter sockets " + f"(udp={args.host}:{args.port}, dashboard={args.dashboard_host}:{args.dashboard_port}): {exc}", + file=sys.stderr, + ) + return 2 + + try: + app.run() + except KeyboardInterrupt: + print("\nStopping web plotter...") + finally: + app.close() + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/lsl_receive_and_ploty.py b/tools/lsl_receive_and_ploty.py new file mode 100755 index 0000000..c018bc7 --- /dev/null +++ b/tools/lsl_receive_and_ploty.py @@ -0,0 +1,1753 @@ +#!/usr/bin/env python3 +""" +OpenWearables LSL Dashboard. + +Receives UDP JSON packets from open_earable_flutter, publishes them as +Lab Streaming Layer (LSL) outlets, and forwards live updates to a simple +built-in web dashboard. +""" + +from __future__ import annotations + +import argparse +import json +import os +import queue +import socket +import sys +import threading +import time +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Dict, List, Optional, Set + +from network_relay_server import ( + NetworkRelayServer, + UdpSensorSample, + clean_text, +) + +try: + from pylsl import StreamInfo, StreamOutlet, local_clock +except ImportError as exc: # pragma: no cover - import guard + print( + "Missing dependency: pylsl.\n" + "Install with:\n" + " pip install pylsl", + file=sys.stderr, + ) + raise SystemExit(2) from exc + + +DEFAULT_DASHBOARD_PORT = 8765 +DEFAULT_MAX_EVENTS = 300 + +DASHBOARD_TITLE = "OpenWearables LSL Dashboard" + +ANSI_RESET = "\033[0m" +ANSI_BOLD = "\033[1m" +ANSI_DIM = "\033[2m" +ANSI_GREEN = "\033[32m" +ANSI_YELLOW = "\033[33m" +ANSI_CYAN = "\033[36m" + + +def _supports_color() -> bool: + if os.getenv("NO_COLOR") is not None: + return False + return hasattr(sys.stdout, "isatty") and sys.stdout.isatty() + + +def _styled(text: str, *codes: str) -> str: + if not _supports_color() or not codes: + return text + return f"{''.join(codes)}{text}{ANSI_RESET}" + + +@dataclass(frozen=True) +class StreamSpec: + name: str + stream_type: str + channel_count: int + source_id: str + device_name: str + device_channel: str + sensor_name: str + source: str + + +@dataclass +class SensorClockAlignment: + sensor_zero_seconds: float + lsl_zero_seconds: float + last_lsl_seconds: float + + +class DashboardState: + def __init__(self, max_events: int, udp_host: str, udp_port: int) -> None: + self._max_events = max(1, max_events) + self._udp_host = udp_host + self._udp_port = udp_port + self._lock = threading.Lock() + self._listeners: Set[queue.Queue] = set() + self._streams_by_id: Dict[str, dict] = {} + self._recent_events: List[dict] = [] + self._packets_received = 0 + self._last_packet_wall_time: Optional[float] = None + + def add_listener(self) -> queue.Queue: + listener: queue.Queue = queue.Queue(maxsize=256) + with self._lock: + self._listeners.add(listener) + return listener + + def remove_listener(self, listener: queue.Queue) -> None: + with self._lock: + self._listeners.discard(listener) + + def _publish_event(self, event_name: str, payload: dict) -> None: + with self._lock: + listeners = list(self._listeners) + + for listener in listeners: + try: + listener.put_nowait((event_name, payload)) + except queue.Full: + try: + listener.get_nowait() + except queue.Empty: + pass + try: + listener.put_nowait((event_name, payload)) + except queue.Full: + self.remove_listener(listener) + + def snapshot(self) -> dict: + with self._lock: + streams = sorted( + self._streams_by_id.values(), + key=lambda item: ( + str(item.get("device_name", "")).casefold(), + str(item.get("device_channel", "")).casefold(), + str(item.get("sensor_name", "")).casefold(), + ), + ) + return { + "title": DASHBOARD_TITLE, + "packets_received": self._packets_received, + "last_packet_wall_time": self._last_packet_wall_time, + "udp_host": self._udp_host, + "udp_port": self._udp_port, + "streams": streams, + "recent_events": list(self._recent_events), + } + + def record_sample( + self, + spec: StreamSpec, + values: List[float], + sample: dict, + lsl_timestamp: float, + ) -> None: + now = time.time() + payload = { + "stream_name": spec.name, + "source_id": spec.source_id, + "device_name": spec.device_name, + "device_channel": spec.device_channel, + "sensor_name": spec.sensor_name, + "source": spec.source, + "values": values, + "axis_names": sample.get("axis_names") or [], + "axis_units": sample.get("axis_units") or [], + "timestamp": sample.get("timestamp"), + "timestamp_exponent": sample.get("timestamp_exponent"), + "lsl_timestamp": lsl_timestamp, + "received_at": now, + } + + with self._lock: + self._packets_received += 1 + self._last_packet_wall_time = now + + stream_item = self._streams_by_id.get(spec.source_id) + if stream_item is None: + stream_item = { + "stream_name": spec.name, + "source_id": spec.source_id, + "device_name": spec.device_name, + "device_channel": spec.device_channel, + "sensor_name": spec.sensor_name, + "source": spec.source, + "channel_count": spec.channel_count, + "samples_received": 0, + "last_values": [], + "axis_names": payload["axis_names"], + "axis_units": payload["axis_units"], + "last_lsl_timestamp": None, + "last_received_at": None, + } + self._streams_by_id[spec.source_id] = stream_item + + stream_item["samples_received"] = int(stream_item["samples_received"]) + 1 + stream_item["channel_count"] = spec.channel_count + stream_item["last_values"] = values + stream_item["axis_names"] = payload["axis_names"] + stream_item["axis_units"] = payload["axis_units"] + stream_item["last_lsl_timestamp"] = lsl_timestamp + stream_item["last_received_at"] = now + + self._recent_events.append(payload) + if len(self._recent_events) > self._max_events: + self._recent_events.pop(0) + + self._publish_event( + "sample", + { + "packets_received": self.packet_count, + "last_packet_wall_time": self.last_packet_wall_time, + "sample": payload, + }, + ) + + @property + def packet_count(self) -> int: + with self._lock: + return self._packets_received + + @property + def last_packet_wall_time(self) -> Optional[float]: + with self._lock: + return self._last_packet_wall_time + + +class DashboardRequestHandler(BaseHTTPRequestHandler): + dashboard_state: DashboardState + stop_event: threading.Event + + def do_GET(self) -> None: # noqa: N802 - stdlib API + if self.path in ("/", "/index.html"): + body = DASHBOARD_HTML.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") + self.send_header("Pragma", "no-cache") + self.send_header("Expires", "0") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + + if self.path == "/events": + self._serve_events() + return + + if self.path == "/health": + body = json.dumps( + { + "status": "ok", + "packets_received": self.dashboard_state.packet_count, + "last_packet_wall_time": self.dashboard_state.last_packet_wall_time, + } + ).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Cache-Control", "no-cache") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + + self.send_error(404, "Not Found") + + def _serve_events(self) -> None: + listener = self.dashboard_state.add_listener() + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "keep-alive") + self.send_header("X-Accel-Buffering", "no") + self.end_headers() + + try: + self._send_sse("snapshot", self.dashboard_state.snapshot()) + while not self.stop_event.is_set(): + try: + event_name, payload = listener.get(timeout=10.0) + self._send_sse(event_name, payload) + except queue.Empty: + self.wfile.write(b": ping\n\n") + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError, TimeoutError): + return + finally: + self.dashboard_state.remove_listener(listener) + + def _send_sse(self, event_name: str, payload: dict) -> None: + data = json.dumps(payload, separators=(",", ":")) + self.wfile.write(f"event: {event_name}\n".encode("utf-8")) + self.wfile.write(f"data: {data}\n\n".encode("utf-8")) + self.wfile.flush() + + def log_message(self, format: str, *args: object) -> None: + return + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Receive OpenWearables UDP sensor packets, publish LSL streams, and " + "serve a simple web dashboard." + ) + ) + parser.add_argument( + "--host", + default="0.0.0.0", + help="UDP bind host. Use 0.0.0.0 to listen on all interfaces (default).", + ) + parser.add_argument( + "--port", + type=int, + default=16571, + help="UDP bind port (default: 16571).", + ) + parser.add_argument( + "--dashboard-host", + default="0.0.0.0", + help="Web dashboard bind host (default: 0.0.0.0).", + ) + parser.add_argument( + "--dashboard-port", + type=int, + default=DEFAULT_DASHBOARD_PORT, + help=f"Web dashboard bind port (default: {DEFAULT_DASHBOARD_PORT}).", + ) + parser.add_argument( + "--max-events", + type=int, + default=DEFAULT_MAX_EVENTS, + help=( + "How many recent dashboard events to keep in memory for reconnecting " + f"clients (default: {DEFAULT_MAX_EVENTS})." + ), + ) + parser.add_argument( + "--verbose", + action="store_true", + help="Print every received sample.", + ) + + # Backward-compatible no-op flags from the old plot-based script. + parser.add_argument("--history-seconds", type=float, default=20.0, help=argparse.SUPPRESS) + parser.add_argument("--refresh-ms", type=int, default=100, help=argparse.SUPPRESS) + parser.add_argument("--max-samples", type=int, default=2000, help=argparse.SUPPRESS) + + return parser.parse_args() + + +def _candidate_ips(bind_host: str) -> List[str]: + addresses = set() + + if bind_host not in ("0.0.0.0", "", "::"): + addresses.add(bind_host) + + try: + host_name = socket.gethostname() + for ip in socket.gethostbyname_ex(host_name)[2]: + if "." in ip and not ip.startswith("127."): + addresses.add(ip) + except OSError: + pass + + for probe_host in ("8.8.8.8", "1.1.1.1"): + try: + probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + probe.connect((probe_host, 80)) + local_ip = probe.getsockname()[0] + probe.close() + if local_ip and not local_ip.startswith("127."): + addresses.add(local_ip) + except OSError: + continue + + if not addresses: + addresses.add("127.0.0.1") + + return sorted(addresses) + + +def _stream_spec(sample: UdpSensorSample) -> StreamSpec: + return StreamSpec( + name=sample.stream.name, + stream_type="OpenWearables", + channel_count=len(sample.values), + source_id=sample.stream.source_id, + device_name=sample.stream.device.name, + device_channel=sample.stream.device.channel, + sensor_name=sample.stream.sensor_name, + source=sample.stream.device.source, + ) + + +def _create_outlet(sample: UdpSensorSample, values: List[float]) -> StreamOutlet: + spec = _stream_spec(sample) + device_token = clean_text( + sample.raw.get("device_token") or sample.raw.get("device_id"), + "unknown_device", + ) + axis_names = list(sample.axis_names) + axis_units = list(sample.axis_units) + + info = StreamInfo( + name=spec.name, + type="OpenWearables", + channel_count=len(values), + nominal_srate=0.0, + channel_format="float32", + source_id=spec.source_id, + ) + + desc = info.desc() + desc.append_child_value("manufacturer", "OpenWearables") + desc.append_child_value("device_token", device_token) + desc.append_child_value("device_name", spec.device_name) + desc.append_child_value("device_channel", spec.device_channel) + if spec.device_channel: + desc.append_child_value("device_side", spec.device_channel) + desc.append_child_value("device_source", spec.source) + desc.append_child_value("sensor_name", spec.sensor_name) + desc.append_child_value("source_id", spec.source_id) + desc.append_child_value("timestamp_exponent", str(sample.timestamp_exponent or -3)) + + channels = desc.append_child("channels") + for idx in range(len(values)): + ch = channels.append_child("channel") + label = axis_names[idx] if idx < len(axis_names) else f"ch_{idx}" + if spec.device_channel: + label = f"{spec.device_channel}-{label}" + unit = axis_units[idx] if idx < len(axis_units) else "" + ch.append_child_value("label", str(label)) + ch.append_child_value("unit", str(unit)) + ch.append_child_value("type", spec.sensor_name) + + return StreamOutlet(info) + + +class LslBridgeApp: + def __init__(self, args: argparse.Namespace) -> None: + self._args = args + self._relay_server = NetworkRelayServer( + host=args.host, + port=args.port, + on_warning=print, + ) + self._relay_server.add_sample_listener(self._on_sample) + + self._outlets: Dict[StreamSpec, StreamOutlet] = {} + self._clock_alignment: Dict[StreamSpec, SensorClockAlignment] = {} + + self._dashboard_state = DashboardState( + max_events=args.max_events, + udp_host=args.host, + udp_port=self._relay_server.port, + ) + self._stop_event = threading.Event() + + handler_cls = self._handler_class() + self._dashboard_server = ThreadingHTTPServer( + (args.dashboard_host, args.dashboard_port), handler_cls + ) + self._dashboard_server.daemon_threads = True + self._dashboard_thread = threading.Thread( + target=self._dashboard_server.serve_forever, + kwargs={"poll_interval": 0.25}, + daemon=True, + name="openwearables-dashboard", + ) + + self._print_startup() + + def _handler_class(self) -> type[DashboardRequestHandler]: + dashboard_state = self._dashboard_state + stop_event = self._stop_event + + class Handler(DashboardRequestHandler): + pass + + Handler.dashboard_state = dashboard_state + Handler.stop_event = stop_event + return Handler + + def _print_startup(self) -> None: + udp_ips = _candidate_ips(self._args.host) + selected_udp_ip = udp_ips[0] + + if self._args.dashboard_host in ("0.0.0.0", "", "::"): + dashboard_ips = _candidate_ips(self._args.dashboard_host) + else: + dashboard_ips = [self._args.dashboard_host] + + dashboard_url = f"http://{dashboard_ips[0]}:{self._args.dashboard_port}" + + print("") + print(_styled("OpenWearables LSL Dashboard started.", ANSI_BOLD, ANSI_CYAN)) + print( + _styled( + f"Listening for UDP packets on {self._args.host}:{self._relay_server.port}", + ANSI_GREEN, + ) + ) + print(_styled("Use one of these IPs in your Flutter app:", ANSI_BOLD)) + for ip in udp_ips: + marker = " (recommended)" if ip == selected_udp_ip else "" + print(f" - {_styled(ip, ANSI_YELLOW)}{marker}") + + print("") + print(_styled("Example app setup:", ANSI_BOLD)) + print(_styled(" final udpBridgeForwarder = UdpBridgeForwarder.instance;", ANSI_DIM)) + print( + _styled( + f" udpBridgeForwarder.configure(host: '{selected_udp_ip}', port: {self._relay_server.port}, enabled: true);", + ANSI_DIM, + ) + ) + print(_styled(" WearableManager().addSensorForwarder(udpBridgeForwarder);", ANSI_DIM)) + + print("") + print(_styled("Web dashboard URLs:", ANSI_BOLD)) + for ip in dashboard_ips: + marker = " (recommended)" if ip == dashboard_ips[0] else "" + print(f" - http://{ip}:{self._args.dashboard_port}{marker}") + + print("") + print(f"Open your browser to {dashboard_url}") + print("Press Ctrl+C to stop.") + + def _lsl_timestamp_for_sample( + self, spec: StreamSpec, sensor_time_seconds: Optional[float] + ) -> float: + if sensor_time_seconds is None: + return local_clock() + + alignment = self._clock_alignment.get(spec) + if alignment is None: + now = local_clock() + self._clock_alignment[spec] = SensorClockAlignment( + sensor_zero_seconds=sensor_time_seconds, + lsl_zero_seconds=now, + last_lsl_seconds=now, + ) + return now + + lsl_time = alignment.lsl_zero_seconds + ( + sensor_time_seconds - alignment.sensor_zero_seconds + ) + + if lsl_time <= alignment.last_lsl_seconds: + lsl_time = alignment.last_lsl_seconds + 1e-6 + alignment.last_lsl_seconds = lsl_time + return lsl_time + + def _on_sample(self, sample: UdpSensorSample, remote: tuple[str, int]) -> None: + _ = remote + values = list(sample.values) + spec = _stream_spec(sample) + sensor_time_seconds = sample.timestamp_seconds + lsl_timestamp = self._lsl_timestamp_for_sample(spec, sensor_time_seconds) + + outlet = self._outlets.get(spec) + if outlet is None: + outlet = _create_outlet(sample, values) + self._outlets[spec] = outlet + print( + "Created LSL outlet: " + f"name='{spec.name}', channels={spec.channel_count}, source_id='{spec.source_id}'" + ) + + outlet.push_sample(values, lsl_timestamp) + self._dashboard_state.record_sample(spec, values, sample.raw, lsl_timestamp) + + if self._args.verbose: + print( + f"Sample {spec.name}: {values} " + f"(device_ts={sample.timestamp}, lsl_ts={lsl_timestamp:.6f})" + ) + + def run(self) -> None: + self._dashboard_thread.start() + self._relay_server.run(stop_event=self._stop_event, poll_interval=0.25) + + def close(self) -> None: + self._stop_event.set() + try: + self._dashboard_server.shutdown() + self._dashboard_server.server_close() + except Exception: + pass + self._relay_server.close() + + +DASHBOARD_HTML = """ + + + + + OpenWearables LSL Dashboard + + + +
+
+

OpenWearables LSL Dashboard

+
+ UDP: -- + WAITING +
+
+ 0 packets + 0 streams + No data yet +
+
+ + +
Waiting for packets. Keep this page open.
+
+ + + + +""" + + +def main() -> int: + args = parse_args() + + if args.port < 1 or args.port > 65535: + print(f"Invalid UDP port: {args.port}", file=sys.stderr) + return 2 + if args.dashboard_port < 1 or args.dashboard_port > 65535: + print(f"Invalid dashboard port: {args.dashboard_port}", file=sys.stderr) + return 2 + if args.max_events < 1: + print("--max-events must be > 0", file=sys.stderr) + return 2 + + try: + app = LslBridgeApp(args) + except OSError as exc: + print( + "Failed to bind bridge sockets " + f"(udp={args.host}:{args.port}, dashboard={args.dashboard_host}:{args.dashboard_port}): {exc}", + file=sys.stderr, + ) + return 2 + + try: + app.run() + except KeyboardInterrupt: + print("\nStopping bridge...") + finally: + app.close() + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/lsl_receive_minimal.py b/tools/lsl_receive_minimal.py new file mode 100644 index 0000000..9bc5ec4 --- /dev/null +++ b/tools/lsl_receive_minimal.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +""" +OpenWearables network relay minimal example. + +This example focuses on the core flow: +1) run the reusable UDP network relay server +2) map each packet to a concrete device + sensor sample +3) split each sample into concrete sensor channels + +Replace the two placeholder hooks at the bottom with your own logic. +""" + +from __future__ import annotations + +import argparse +import sys +import time +from dataclasses import dataclass +from typing import List, Optional, Tuple + +from network_relay_server import DeviceInfo, NetworkRelayServer, UdpSensorSample + + +@dataclass(frozen=True) +class SensorChannel: + index: int + name: str + unit: str + + +@dataclass(frozen=True) +class SensorSample: + device: DeviceInfo + sensor_name: str + timestamp: float + values: Tuple[float, ...] + channels: Tuple[SensorChannel, ...] + + +@dataclass(frozen=True) +class ChannelSample: + device: DeviceInfo + sensor_name: str + channel: SensorChannel + timestamp: float + value: float + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Minimal network relay receiver for OpenWearables UDP packets." + ) + parser.add_argument( + "--host", + default="0.0.0.0", + help="UDP bind host (default: 0.0.0.0).", + ) + parser.add_argument( + "--port", + type=int, + default=16571, + help="UDP bind port (default: 16571).", + ) + parser.add_argument( + "--poll-interval", + type=float, + default=0.25, + help="Polling interval in seconds (default: 0.25).", + ) + return parser.parse_args() + + +def _channels_from_sample(sample: UdpSensorSample) -> Tuple[SensorChannel, ...]: + channels: List[SensorChannel] = [] + for index in range(len(sample.values)): + name = sample.axis_names[index] if index < len(sample.axis_names) else f"ch_{index}" + unit = sample.axis_units[index] if index < len(sample.axis_units) else "" + channels.append(SensorChannel(index=index, name=str(name), unit=str(unit))) + return tuple(channels) + + +def to_sensor_sample(sample: UdpSensorSample) -> SensorSample: + timestamp_seconds: Optional[float] = sample.timestamp_seconds + if timestamp_seconds is None: + timestamp_seconds = time.time() + return SensorSample( + device=sample.stream.device, + sensor_name=sample.stream.sensor_name, + timestamp=float(timestamp_seconds), + values=sample.values, + channels=_channels_from_sample(sample), + ) + + +def split_channels(sample: SensorSample) -> List[ChannelSample]: + items: List[ChannelSample] = [] + for index, value in enumerate(sample.values): + if index < len(sample.channels): + channel = sample.channels[index] + else: + channel = SensorChannel(index=index, name=f"ch_{index}", unit="") + items.append( + ChannelSample( + device=sample.device, + sensor_name=sample.sensor_name, + channel=channel, + timestamp=sample.timestamp, + value=value, + ) + ) + return items + + +def handle_sensor_sample(sample: SensorSample) -> None: + """ + Placeholder #1: handle the full multi-channel sensor sample. + """ + device = sample.device + side = f"[{device.channel}] " if device.channel else "" + print( + f"{device.name} {side}{sample.sensor_name} " + f"ts={sample.timestamp:.6f} values={list(sample.values)}" + ) + + +def handle_channel_sample(channel_sample: ChannelSample) -> None: + """ + Placeholder #2: handle each concrete channel value. + + Example: + - route channel_sample.device.name to per-device pipelines + - map channel_sample.sensor_name + channel_sample.channel.name + to custom processing + """ + # Intentionally empty. Add your channel-specific logic here. + return + + +def main() -> int: + args = parse_args() + + if args.poll_interval <= 0: + print("--poll-interval must be > 0", file=sys.stderr) + return 2 + + server = NetworkRelayServer(host=args.host, port=args.port, on_warning=print) + + def _on_udp_sample(raw_sample: UdpSensorSample, remote: tuple[str, int]) -> None: + _ = remote + sample = to_sensor_sample(raw_sample) + handle_sensor_sample(sample) + for channel_sample in split_channels(sample): + handle_channel_sample(channel_sample) + + server.add_sample_listener(_on_udp_sample) + + print(f"Listening for OpenWearables UDP packets on {args.host}:{server.port}") + print("Press Ctrl+C to stop.") + + try: + server.run(poll_interval=args.poll_interval) + except KeyboardInterrupt: + print("\nStopping minimal receiver...") + finally: + server.close() + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/network_relay_server.py b/tools/network_relay_server.py new file mode 100644 index 0000000..a1e3745 --- /dev/null +++ b/tools/network_relay_server.py @@ -0,0 +1,413 @@ +#!/usr/bin/env python3 +""" +Reusable OpenWearables network relay server. + +This module provides: +- parsing helpers for OpenWearables UDP sample packets +- lightweight device/stream/sample abstractions +- a reusable UDP relay server (`NetworkRelayServer`) that receives packets, + answers probe pings, and emits parsed samples to listeners +""" + +from __future__ import annotations + +import json +import select +import socket +import threading +import time +from dataclasses import dataclass +from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple +from urllib.parse import quote, unquote + + +UDP_PACKET_TYPE_SAMPLE = "open_earable_udp_sample" +UDP_PACKET_TYPE_PROBE = "open_earable_udp_probe" +UDP_PACKET_TYPE_PROBE_ACK = "open_earable_udp_probe_ack" + +SOURCE_ID_PREFIX = "oe-v1" +SOURCE_ID_EMPTY_COMPONENT = "-" + +MAX_UDP_PACKET_SIZE = 65535 + +SampleListener = Callable[["UdpSensorSample", Tuple[str, int]], None] +WarningListener = Callable[[str], None] + + +@dataclass(frozen=True) +class DeviceInfo: + name: str + channel: str + source: str + + +@dataclass(frozen=True) +class StreamInfo: + name: str + source_id: str + sensor_name: str + device: DeviceInfo + + +@dataclass(frozen=True) +class UdpSensorSample: + stream: StreamInfo + values: Tuple[float, ...] + axis_names: Tuple[str, ...] + axis_units: Tuple[str, ...] + timestamp: Any + timestamp_exponent: Any + timestamp_seconds: Optional[float] + raw: Dict[str, Any] + + +def clean_text(value: object, fallback: str = "") -> str: + if value is None: + return fallback + text = str(value).strip() + if not text: + return fallback + return " ".join(text.split()) + + +def normalize_channel(value: object) -> str: + channel = clean_text(value, "") + if not channel: + return "" + lower = channel.lower() + if lower.startswith("l"): + return "L" + if lower.startswith("r"): + return "R" + return channel + + +def _encode_source_component(value: str) -> str: + cleaned = clean_text(value, "") + if not cleaned: + return SOURCE_ID_EMPTY_COMPONENT + return quote(cleaned, safe="") + + +def _decode_source_component(value: str) -> str: + if value == SOURCE_ID_EMPTY_COMPONENT: + return "" + return clean_text(unquote(value), "") + + +def encode_source_id( + *, + device_name: str, + device_channel: str, + sensor_name: str, + source: str, +) -> str: + return ":".join( + [ + SOURCE_ID_PREFIX, + _encode_source_component(device_name), + _encode_source_component(device_channel), + _encode_source_component(sensor_name), + _encode_source_component(source), + ] + ) + + +def decode_source_id(source_id: str) -> Optional[Dict[str, str]]: + parts = source_id.split(":") + if len(parts) != 5 or parts[0] != SOURCE_ID_PREFIX: + return None + return { + "device_name": _decode_source_component(parts[1]), + "device_channel": _decode_source_component(parts[2]), + "sensor_name": _decode_source_component(parts[3]), + "source": _decode_source_component(parts[4]), + } + + +def build_stream_name( + *, + device_name: str, + device_channel: str, + sensor_name: str, + source: str, +) -> str: + channel_suffix = f" [{device_channel}]" if device_channel else "" + return f"{device_name}{channel_suffix} ({source}) - {sensor_name}" + + +def parse_values(payload: Mapping[str, Any]) -> Tuple[float, ...]: + raw_values = payload.get("values") + if not isinstance(raw_values, list): + return tuple() + parsed: List[float] = [] + for value in raw_values: + try: + parsed.append(float(value)) + except (TypeError, ValueError): + continue + return tuple(parsed) + + +def _string_list(value: Any) -> Tuple[str, ...]: + if not isinstance(value, list): + return tuple() + return tuple(str(item if item is not None else "") for item in value) + + +def sensor_timestamp_seconds(payload: Mapping[str, Any]) -> Optional[float]: + raw_timestamp = payload.get("timestamp") + raw_exponent = payload.get("timestamp_exponent") + if raw_timestamp is None or raw_exponent is None: + return None + + try: + timestamp = float(raw_timestamp) + exponent = int(raw_exponent) + except (TypeError, ValueError): + return None + + try: + return timestamp * (10.0 ** exponent) + except OverflowError: + return None + + +def parse_sensor_sample(payload: Mapping[str, Any]) -> Optional[UdpSensorSample]: + if payload.get("type") != UDP_PACKET_TYPE_SAMPLE: + return None + + values = parse_values(payload) + if not values: + return None + + raw_source_id = clean_text(payload.get("source_id"), "") + decoded_source = decode_source_id(raw_source_id) if raw_source_id else None + + fallback_device = clean_text( + payload.get("device_name") + or payload.get("device_token") + or payload.get("device_id"), + "unknown_device", + ) + fallback_channel = normalize_channel( + payload.get("device_channel") or payload.get("device_side") + ) + fallback_sensor = clean_text(payload.get("sensor_name"), "unknown_sensor") + fallback_source = clean_text( + payload.get("device_source") + or payload.get("device_id") + or payload.get("device_token"), + "unknown_source", + ) + + device_name = clean_text( + decoded_source.get("device_name") if decoded_source else None, + fallback_device, + ) + device_channel = normalize_channel( + decoded_source.get("device_channel") if decoded_source else fallback_channel + ) + sensor_name = clean_text( + decoded_source.get("sensor_name") if decoded_source else None, + fallback_sensor, + ) + source = clean_text( + decoded_source.get("source") if decoded_source else None, + fallback_source, + ) + + source_id = raw_source_id or encode_source_id( + device_name=device_name, + device_channel=device_channel, + sensor_name=sensor_name, + source=source, + ) + stream_name = clean_text(payload.get("stream_name"), "") or build_stream_name( + device_name=device_name, + device_channel=device_channel, + sensor_name=sensor_name, + source=source, + ) + + device = DeviceInfo( + name=device_name, + channel=device_channel, + source=source, + ) + stream = StreamInfo( + name=stream_name, + source_id=source_id, + sensor_name=sensor_name, + device=device, + ) + + return UdpSensorSample( + stream=stream, + values=values, + axis_names=_string_list(payload.get("axis_names")), + axis_units=_string_list(payload.get("axis_units")), + timestamp=payload.get("timestamp"), + timestamp_exponent=payload.get("timestamp_exponent"), + timestamp_seconds=sensor_timestamp_seconds(payload), + raw=dict(payload), + ) + + +def build_probe_ack_payload(payload: Mapping[str, Any]) -> Dict[str, Any]: + ack: Dict[str, Any] = {"type": UDP_PACKET_TYPE_PROBE_ACK} + nonce = payload.get("nonce") + if nonce is not None: + ack["nonce"] = nonce + return ack + + +class NetworkRelayServer: + def __init__( + self, + host: str = "0.0.0.0", + port: int = 16571, + *, + on_warning: Optional[WarningListener] = None, + ) -> None: + self._on_warning = on_warning + self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self._sock.bind((host, port)) + self._sock.setblocking(False) + + bound_host, bound_port = self._sock.getsockname() + self._host = str(bound_host) + self._port = int(bound_port) + + self._closed = False + self._lock = threading.Lock() + self._sample_listeners: List[SampleListener] = [] + self._samples_received = 0 + self._last_packet_wall_time: Optional[float] = None + + @property + def host(self) -> str: + return self._host + + @property + def port(self) -> int: + return self._port + + @property + def samples_received(self) -> int: + with self._lock: + return self._samples_received + + @property + def last_packet_wall_time(self) -> Optional[float]: + with self._lock: + return self._last_packet_wall_time + + @property + def is_closed(self) -> bool: + return self._closed + + def add_sample_listener(self, listener: SampleListener) -> None: + with self._lock: + if listener in self._sample_listeners: + return + self._sample_listeners.append(listener) + + def remove_sample_listener(self, listener: SampleListener) -> None: + with self._lock: + self._sample_listeners = [cb for cb in self._sample_listeners if cb != listener] + + def poll(self, timeout: float = 0.0) -> int: + if self._closed: + return 0 + if timeout < 0: + raise ValueError("timeout must be >= 0") + + if timeout > 0: + try: + ready, _, _ = select.select([self._sock], [], [], timeout) + except (OSError, ValueError): + return 0 + if not ready: + return 0 + + samples = 0 + while True: + try: + packet, remote = self._sock.recvfrom(MAX_UDP_PACKET_SIZE) + except BlockingIOError: + return samples + except OSError: + return samples + + if self._process_packet(packet, remote): + samples += 1 + + def run( + self, + *, + stop_event: Optional[threading.Event] = None, + poll_interval: float = 0.25, + ) -> None: + if poll_interval <= 0: + raise ValueError("poll_interval must be > 0") + while True: + if self._closed: + return + if stop_event is not None and stop_event.is_set(): + return + self.poll(timeout=poll_interval) + + def close(self) -> None: + if self._closed: + return + self._closed = True + try: + self._sock.close() + except OSError: + pass + + def _warn(self, message: str) -> None: + if self._on_warning is not None: + self._on_warning(message) + + def _process_packet(self, packet: bytes, remote: Tuple[str, int]) -> bool: + try: + payload = json.loads(packet.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + self._warn(f"Ignoring non-JSON packet from {remote[0]}:{remote[1]}") + return False + + if not isinstance(payload, dict): + self._warn(f"Ignoring unexpected payload type from {remote[0]}:{remote[1]}") + return False + + if payload.get("type") == UDP_PACKET_TYPE_PROBE: + ack = build_probe_ack_payload(payload) + try: + self._sock.sendto( + json.dumps(ack, separators=(",", ":")).encode("utf-8"), + remote, + ) + except OSError: + pass + return False + + sample = parse_sensor_sample(payload) + if sample is None: + return False + + now = time.time() + with self._lock: + self._samples_received += 1 + self._last_packet_wall_time = now + listeners = tuple(self._sample_listeners) + + for listener in listeners: + try: + listener(sample, remote) + except Exception as exc: + self._warn( + f"Sample listener failed for {remote[0]}:{remote[1]}: {exc}" + ) + return True