From da8cee44fc233dba79584f260d155748cb11dc4d Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:22:08 +0100 Subject: [PATCH 1/7] identify sensor streams by wearable and sensor to avoid collisions --- open_wearable/lib/models/sensor_streams.dart | 33 ++++++++++++++++--- .../lib/view_models/sensor_data_provider.dart | 8 +++-- .../view_models/sensor_recorder_provider.dart | 5 ++- .../lib/widgets/sensors/sensor_page.dart | 5 ++- .../sensors/values/sensor_values_page.dart | 5 ++- 5 files changed, 46 insertions(+), 10 deletions(-) diff --git a/open_wearable/lib/models/sensor_streams.dart b/open_wearable/lib/models/sensor_streams.dart index 4d1a0338..a8688649 100644 --- a/open_wearable/lib/models/sensor_streams.dart +++ b/open_wearable/lib/models/sensor_streams.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:collection'; import 'package:open_earable_flutter/open_earable_flutter.dart'; @@ -10,16 +11,38 @@ import 'package:open_earable_flutter/open_earable_flutter.dart'; class SensorStreams { SensorStreams._(); - static final Map> _sharedStreams = {}; + static final Map>> + _sharedStreamsByDevice = {}; + static Map> _createIdentitySensorStreamMap() => + LinkedHashMap>.identity(); - static Stream shared(Sensor sensor) { - return _sharedStreams.putIfAbsent( + static Stream shared({ + required Wearable wearable, + required Sensor sensor, + }) { + final deviceStreams = _sharedStreamsByDevice.putIfAbsent( + wearable.deviceId, + // Identity map avoids collisions when Sensor overrides ==/hashCode + // non-uniquely across different devices. + _createIdentitySensorStreamMap, + ); + return deviceStreams.putIfAbsent( sensor, () => sensor.sensorStream.asBroadcastStream(), ); } - static void clearForSensor(Sensor sensor) { - _sharedStreams.remove(sensor); + static void clearForSensor({ + required Wearable wearable, + required Sensor sensor, + }) { + final deviceStreams = _sharedStreamsByDevice[wearable.deviceId]; + if (deviceStreams == null) { + return; + } + deviceStreams.remove(sensor); + if (deviceStreams.isEmpty) { + _sharedStreamsByDevice.remove(wearable.deviceId); + } } } diff --git a/open_wearable/lib/view_models/sensor_data_provider.dart b/open_wearable/lib/view_models/sensor_data_provider.dart index 516cf640..f870c2b0 100644 --- a/open_wearable/lib/view_models/sensor_data_provider.dart +++ b/open_wearable/lib/view_models/sensor_data_provider.dart @@ -19,6 +19,7 @@ import 'package:open_wearable/models/sensor_streams.dart'; /// Provides: /// - `sensorValues` and `displayTimestamp` for chart/value widgets. class SensorDataProvider with ChangeNotifier { + final Wearable wearable; final Sensor sensor; final int timeWindow; // seconds @@ -38,6 +39,7 @@ class SensorDataProvider with ChangeNotifier { DateTime? _lastSensorArrivalTime; SensorDataProvider({ + required this.wearable, required this.sensor, this.timeWindow = 5, }) { @@ -64,8 +66,10 @@ class SensorDataProvider with ChangeNotifier { } void _listenToStream() { - _sensorStreamSubscription = - SensorStreams.shared(sensor).listen((sensorValue) { + _sensorStreamSubscription = SensorStreams.shared( + wearable: wearable, + sensor: sensor, + ).listen((sensorValue) { sensorValues.add(sensorValue); _lastSensorTimestamp = sensorValue.timestamp; _lastSensorArrivalTime = DateTime.now(); diff --git a/open_wearable/lib/view_models/sensor_recorder_provider.dart b/open_wearable/lib/view_models/sensor_recorder_provider.dart index 2ffa0127..6813699b 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider.dart @@ -162,7 +162,10 @@ class SensorRecorderProvider with ChangeNotifier { File file = await recorder.start( filepath: filepath, - inputStream: SensorStreams.shared(sensor), + inputStream: SensorStreams.shared( + wearable: wearable, + sensor: sensor, + ), ); logger.i( diff --git a/open_wearable/lib/widgets/sensors/sensor_page.dart b/open_wearable/lib/widgets/sensors/sensor_page.dart index cc8e9f7b..6b5fdf86 100644 --- a/open_wearable/lib/widgets/sensors/sensor_page.dart +++ b/open_wearable/lib/widgets/sensors/sensor_page.dart @@ -189,7 +189,10 @@ class _SensorPageState extends State in wearable.requireCapability().sensors) { _sensorDataProviders.putIfAbsent( (wearable, sensor), - () => SensorDataProvider(sensor: sensor), + () => SensorDataProvider( + wearable: wearable, + sensor: sensor, + ), ); } } diff --git a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart index b3483772..f847afa7 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart @@ -144,7 +144,10 @@ class _SensorValuesPageState extends State in wearable.requireCapability().sensors) { _sensorDataProvider.putIfAbsent( (wearable, sensor), - () => SensorDataProvider(sensor: sensor), + () => SensorDataProvider( + wearable: wearable, + sensor: sensor, + ), ); } } From c66d11777317718ed98e95f6c924be4a393dcdb2 Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:14:53 +0100 Subject: [PATCH 2/7] add websocket connector backend and ipc protocol server --- .../lib/models/connector_settings.dart | 218 +++ .../connectors/websocket_ipc_server.dart | 1596 +++++++++++++++++ 2 files changed, 1814 insertions(+) create mode 100644 open_wearable/lib/models/connector_settings.dart create mode 100644 open_wearable/lib/models/connectors/websocket_ipc_server.dart diff --git a/open_wearable/lib/models/connector_settings.dart b/open_wearable/lib/models/connector_settings.dart new file mode 100644 index 00000000..e7e74010 --- /dev/null +++ b/open_wearable/lib/models/connector_settings.dart @@ -0,0 +1,218 @@ +// ignore_for_file: cancel_subscriptions + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'connectors/websocket_ipc_server.dart'; + +class WebSocketConnectorSettings { + final bool enabled; + final String host; + final int port; + final String path; + + const WebSocketConnectorSettings({ + required this.enabled, + required this.host, + required this.port, + required this.path, + }); + + const WebSocketConnectorSettings.defaults() + : enabled = false, + host = WebSocketIpcServer.defaultHost, + port = WebSocketIpcServer.defaultPort, + path = WebSocketIpcServer.defaultPath; + + bool get isConfigured => host.trim().isNotEmpty; + + Uri get endpoint => Uri( + scheme: 'ws', + host: host, + port: port, + path: path, + ); + + WebSocketConnectorSettings copyWith({ + bool? enabled, + String? host, + int? port, + String? path, + }) { + return WebSocketConnectorSettings( + enabled: enabled ?? this.enabled, + host: host ?? this.host, + port: port ?? this.port, + path: path ?? this.path, + ); + } +} + +enum ConnectorRuntimeState { + disabled, + starting, + running, + error, +} + +class ConnectorRuntimeStatus { + final ConnectorRuntimeState state; + final String? message; + + const ConnectorRuntimeStatus({ + required this.state, + this.message, + }); + + const ConnectorRuntimeStatus.disabled() + : state = ConnectorRuntimeState.disabled, + message = null; + + const ConnectorRuntimeStatus.starting() + : state = ConnectorRuntimeState.starting, + message = null; + + const ConnectorRuntimeStatus.running() + : state = ConnectorRuntimeState.running, + message = null; + + const ConnectorRuntimeStatus.error(this.message) + : state = ConnectorRuntimeState.error; +} + +class ConnectorSettings { + static const String _websocketEnabledKey = 'connector_websocket_enabled'; + static const String _websocketHostKey = 'connector_websocket_host'; + static const String _websocketPortKey = 'connector_websocket_port'; + static const String _websocketPathKey = 'connector_websocket_path'; + + static final WebSocketIpcServer _webSocketServer = WebSocketIpcServer(); + + static final ValueNotifier + _webSocketSettingsNotifier = ValueNotifier( + const WebSocketConnectorSettings.defaults(), + ); + + static final ValueNotifier + _webSocketRuntimeStatusNotifier = ValueNotifier( + const ConnectorRuntimeStatus.disabled(), + ); + + static ValueListenable + get webSocketSettingsListenable => _webSocketSettingsNotifier; + + static ValueListenable + get webSocketRuntimeStatusListenable => _webSocketRuntimeStatusNotifier; + + static WebSocketConnectorSettings get currentWebSocketSettings => + _webSocketSettingsNotifier.value; + + static ConnectorRuntimeStatus get currentWebSocketRuntimeStatus => + _webSocketRuntimeStatusNotifier.value; + + static Future initialize() async { + final settings = await loadWebSocketSettings(); + await applyWebSocketSettings(settings); + } + + static Future dispose() async { + await _webSocketServer.stop(); + _setRuntimeStatus(const ConnectorRuntimeStatus.disabled()); + } + + static Future loadWebSocketSettings() async { + final prefs = await SharedPreferences.getInstance(); + final raw = WebSocketConnectorSettings( + enabled: prefs.getBool(_websocketEnabledKey) ?? false, + host: + prefs.getString(_websocketHostKey) ?? WebSocketIpcServer.defaultHost, + port: prefs.getInt(_websocketPortKey) ?? WebSocketIpcServer.defaultPort, + path: + prefs.getString(_websocketPathKey) ?? WebSocketIpcServer.defaultPath, + ); + + final normalized = _normalizeWebSocketSettings(raw); + _setWebSocketSettings(normalized); + return normalized; + } + + static Future saveWebSocketSettings( + WebSocketConnectorSettings settings, + ) async { + final normalized = _normalizeWebSocketSettings(settings); + final prefs = await SharedPreferences.getInstance(); + + await prefs.setBool(_websocketEnabledKey, normalized.enabled); + await prefs.setString(_websocketHostKey, normalized.host); + await prefs.setInt(_websocketPortKey, normalized.port); + await prefs.setString(_websocketPathKey, normalized.path); + + _setWebSocketSettings(normalized); + await applyWebSocketSettings(normalized); + return normalized; + } + + static Future applyWebSocketSettings( + WebSocketConnectorSettings settings, + ) async { + final normalized = _normalizeWebSocketSettings(settings); + _setWebSocketSettings(normalized); + + if (!normalized.enabled || !normalized.isConfigured) { + await _webSocketServer.stop(); + _setRuntimeStatus(const ConnectorRuntimeStatus.disabled()); + return; + } + + _setRuntimeStatus(const ConnectorRuntimeStatus.starting()); + + try { + await _webSocketServer.start( + host: normalized.host, + port: normalized.port, + path: normalized.path, + ); + _setRuntimeStatus(const ConnectorRuntimeStatus.running()); + } catch (error) { + _setRuntimeStatus(ConnectorRuntimeStatus.error(error.toString())); + rethrow; + } + } + + static WebSocketConnectorSettings _normalizeWebSocketSettings( + WebSocketConnectorSettings settings, + ) { + final host = settings.host.trim().isEmpty + ? WebSocketIpcServer.defaultHost + : settings.host.trim(); + final port = (settings.port > 0 && settings.port <= 65535) + ? settings.port + : WebSocketIpcServer.defaultPort; + final path = _normalizePath(settings.path); + + return settings.copyWith( + host: host, + port: port, + path: path, + enabled: settings.enabled, + ); + } + + static String _normalizePath(String path) { + final trimmed = path.trim(); + if (trimmed.isEmpty) { + return WebSocketIpcServer.defaultPath; + } + return trimmed.startsWith('/') ? trimmed : '/$trimmed'; + } + + static void _setWebSocketSettings(WebSocketConnectorSettings settings) { + _webSocketSettingsNotifier.value = settings; + } + + static void _setRuntimeStatus(ConnectorRuntimeStatus status) { + _webSocketRuntimeStatusNotifier.value = status; + } +} diff --git a/open_wearable/lib/models/connectors/websocket_ipc_server.dart b/open_wearable/lib/models/connectors/websocket_ipc_server.dart new file mode 100644 index 00000000..d5af4821 --- /dev/null +++ b/open_wearable/lib/models/connectors/websocket_ipc_server.dart @@ -0,0 +1,1596 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:open_earable_flutter/open_earable_flutter.dart'; + +class WebSocketIpcServer { + static const String defaultHost = '127.0.0.1'; + static const int defaultPort = 8765; + static const String defaultPath = '/ws'; + + final WearableManager _wearableManager; + + HttpServer? _httpServer; + String _host = defaultHost; + int _port = defaultPort; + String _path = defaultPath; + + final Map _discoveredDevicesById = + {}; + final Map _connectedWearablesById = {}; + final Set<_ClientSession> _clients = <_ClientSession>{}; + + StreamSubscription? _scanSubscription; + StreamSubscription? _connectingSubscription; + StreamSubscription? _connectSubscription; + + int _nextSubscriptionId = 1; + + WebSocketIpcServer({WearableManager? wearableManager}) + : _wearableManager = wearableManager ?? WearableManager(); + + bool get isRunning => _httpServer != null; + + Uri get endpoint => Uri( + scheme: 'ws', + host: _host, + port: _port, + path: _path, + ); + + Future start({ + required String host, + required int port, + required String path, + }) async { + await stop(); + + _host = host.trim(); + _port = port; + _path = _normalizePath(path); + + _httpServer = await HttpServer.bind(_host, _port, shared: true); + _attachManagerSubscriptions(); + + unawaited( + _httpServer!.forEach((request) async { + if (request.uri.path != _path || + !WebSocketTransformer.isUpgradeRequest(request)) { + request.response + ..statusCode = HttpStatus.notFound + ..headers.contentType = ContentType.text + ..write('OpenWearables WebSocket IPC endpoint: $_path') + ..close(); + return; + } + + final socket = await WebSocketTransformer.upgrade(request); + final session = _ClientSession( + socket: socket, + server: this, + ); + _clients.add(session); + session.start(); + }), + ); + } + + Future stop() async { + final server = _httpServer; + _httpServer = null; + + if (server != null) { + await server.close(force: true); + } + + final sessions = _clients.toList(growable: false); + _clients.clear(); + for (final session in sessions) { + await session.close(); + } + + await _scanSubscription?.cancel(); + await _connectingSubscription?.cancel(); + await _connectSubscription?.cancel(); + _scanSubscription = null; + _connectingSubscription = null; + _connectSubscription = null; + + _discoveredDevicesById.clear(); + _connectedWearablesById.clear(); + } + + void _onClientClosed(_ClientSession client) { + _clients.remove(client); + } + + List get methods => const [ + 'ping', + 'methods', + 'has_permissions', + 'check_and_request_permissions', + 'start_scan', + 'get_discovered_devices', + 'connect', + 'connect_system_devices', + 'list_connected', + 'disconnect', + 'set_auto_connect', + 'get_wearable', + 'get_actions', + 'invoke_action', + 'subscribe', + 'unsubscribe', + ]; + + Future _handleRequest({ + required _ClientSession client, + required String method, + required Map params, + }) async { + switch (method) { + case 'ping': + return {'ok': true}; + case 'methods': + return methods; + case 'has_permissions': + return _wearableManager.hasPermissions(); + case 'check_and_request_permissions': + return WearableManager.checkAndRequestPermissions(); + case 'start_scan': + final checkAndRequestPermissions = + _asOptionalBool(params['check_and_request_permissions']) ?? true; + await _wearableManager.startScan( + checkAndRequestPermissions: checkAndRequestPermissions, + ); + return {'started': true}; + case 'get_discovered_devices': + return _discoveredDevicesById.values.map(_serializeDiscovered).toList(); + case 'connect': + return _connect(params); + case 'connect_system_devices': + return _connectSystemDevices(params); + case 'list_connected': + return _connectedWearablesById.values + .map(_serializeWearableSummary) + .toList(); + case 'disconnect': + return _disconnect(params); + case 'set_auto_connect': + return _setAutoConnect(params); + case 'get_wearable': + return _getWearable(params); + case 'get_actions': + return _getActions(params); + case 'invoke_action': + return _invokeAction(params); + case 'subscribe': + return _subscribe(client, params); + case 'unsubscribe': + return client.unsubscribe( + _asInt(params['subscription_id'], name: 'subscription_id'), + ); + default: + throw UnsupportedError('Unknown method: $method'); + } + } + + Future> _connect(Map params) async { + final deviceId = _asString(params['device_id'], name: 'device_id'); + final discovered = _discoveredDevicesById[deviceId]; + if (discovered == null) { + throw StateError('Device not found in discovered devices: $deviceId'); + } + + final connectedViaSystem = + _asOptionalBool(params['connected_via_system']) ?? false; + final options = connectedViaSystem + ? {const ConnectedViaSystem()} + : const {}; + + final wearable = await _wearableManager.connectToDevice( + discovered, + options: options, + ); + _registerConnectedWearable(wearable); + return _serializeWearableSummary(wearable); + } + + Future>> _connectSystemDevices( + Map params, + ) async { + final ignoredIds = _asStringList(params['ignored_device_ids']); + final wearables = await _wearableManager.connectToSystemDevices( + ignoredDeviceIds: ignoredIds, + ); + for (final wearable in wearables) { + _registerConnectedWearable(wearable); + } + return wearables.map(_serializeWearableSummary).toList(); + } + + Future> _disconnect(Map params) async { + final deviceId = _asString(params['device_id'], name: 'device_id'); + final wearable = _requireConnectedWearable(deviceId); + await wearable.disconnect(); + _connectedWearablesById.remove(deviceId); + return {'disconnected': true}; + } + + Map _setAutoConnect(Map params) { + final deviceIds = _asStringList(params['device_ids']); + _wearableManager.setAutoConnect(deviceIds); + return {'device_ids': deviceIds}; + } + + Map _getWearable(Map params) { + final wearable = _requireConnectedWearable( + _asString(params['device_id'], name: 'device_id'), + ); + + final details = _serializeWearableSummary(wearable); + details['sensors'] = _serializeSensors(wearable); + details['sensor_configurations'] = _serializeSensorConfigurations(wearable); + details['actions'] = _actionsForWearable(wearable); + details['streams'] = _streamsForWearable(wearable); + return details; + } + + List _getActions(Map params) { + final wearable = _requireConnectedWearable( + _asString(params['device_id'], name: 'device_id'), + ); + return _actionsForWearable(wearable); + } + + Future _invokeAction(Map params) async { + final wearable = _requireConnectedWearable( + _asString(params['device_id'], name: 'device_id'), + ); + final action = _asString(params['action'], name: 'action'); + final args = _asMap(params['args']); + + switch (action) { + case 'disconnect': + await wearable.disconnect(); + _connectedWearablesById.remove(wearable.deviceId); + return {'disconnected': true}; + case 'get_wearable_icon_path': + return wearable.getWearableIconPath( + darkmode: _asOptionalBool(args['darkmode']) ?? false, + ); + case 'list_sensors': + return _serializeSensors(wearable); + case 'list_sensor_configurations': + return _serializeSensorConfigurations(wearable); + case 'set_sensor_configuration': + return _setSensorConfiguration(wearable, args); + case 'set_sensor_frequency_best_effort': + return _setSensorFrequencyBestEffort(wearable, args); + case 'set_sensor_maximum_frequency': + return _setSensorMaximumFrequency(wearable, args); + case 'read_device_identifier': + return _requireCapability( + wearable, + action: action, + ).readDeviceIdentifier(); + case 'read_device_firmware_version': + return _requireCapability( + wearable, + action: action, + ).readDeviceFirmwareVersion(); + case 'read_firmware_version_number': + return (await _requireCapability( + wearable, + action: action, + ).readFirmwareVersionNumber()) + ?.toString(); + case 'check_firmware_support': + return (await _requireCapability( + wearable, + action: action, + ).checkFirmwareSupport()) + .name; + case 'read_device_hardware_version': + return _requireCapability( + wearable, + action: action, + ).readDeviceHardwareVersion(); + case 'write_led_color': + await _requireCapability( + wearable, + action: action, + ).writeLedColor( + r: _asInt(args['r'], name: 'r'), + g: _asInt(args['g'], name: 'g'), + b: _asInt(args['b'], name: 'b'), + ); + return {'ok': true}; + case 'show_status': + await _requireCapability( + wearable, + action: action, + ).showStatus(_asRequiredBool(args['status'], name: 'status')); + return {'ok': true}; + case 'read_battery_percentage': + return _requireCapability( + wearable, + action: action, + ).readBatteryPercentage(); + case 'read_power_status': + return _serializeBatteryPowerStatus( + await _requireCapability( + wearable, + action: action, + ).readPowerStatus(), + ); + case 'read_health_status': + return _serializeBatteryHealthStatus( + await _requireCapability( + wearable, + action: action, + ).readHealthStatus(), + ); + case 'read_energy_status': + return _serializeBatteryEnergyStatus( + await _requireCapability( + wearable, + action: action, + ).readEnergyStatus(), + ); + case 'play_frequency': + await _playFrequency(wearable, args); + return {'ok': true}; + case 'list_wave_types': + return _requireCapability( + wearable, + action: action, + ).supportedFrequencyPlayerWaveTypes.map((w) => w.key).toList(); + case 'play_jingle': + await _playJingle(wearable, args); + return {'ok': true}; + case 'list_jingles': + return _requireCapability( + wearable, + action: action, + ).supportedJingles.map((j) => j.key).toList(); + case 'start_audio': + await _requireCapability( + wearable, + action: action, + ).startAudio(); + return {'ok': true}; + case 'pause_audio': + await _requireCapability( + wearable, + action: action, + ).pauseAudio(); + return {'ok': true}; + case 'stop_audio': + await _requireCapability( + wearable, + action: action, + ).stopAudio(); + return {'ok': true}; + case 'play_audio_from_storage_path': + await _requireCapability( + wearable, + action: action, + ).playAudioFromStoragePath( + _asString(args['filepath'], name: 'filepath')); + return {'ok': true}; + case 'list_audio_modes': + return _requireCapability( + wearable, + action: action, + ).availableAudioModes.map((mode) => mode.key).toList(); + case 'set_audio_mode': + _setAudioMode(wearable, args); + return {'ok': true}; + case 'get_audio_mode': + return (await _requireCapability( + wearable, + action: action, + ).getAudioMode()) + .key; + case 'list_microphones': + return _listMicrophones(wearable); + case 'set_microphone': + await _setMicrophone(wearable, args); + return {'ok': true}; + case 'get_microphone': + return _getMicrophone(wearable); + case 'get_file_prefix': + return _requireCapability( + wearable, + action: action, + ).filePrefix; + case 'set_file_prefix': + await _requireCapability( + wearable, + action: action, + ).setFilePrefix(_asString(args['prefix'], name: 'prefix')); + return {'ok': true}; + case 'get_position': + final position = await _requireCapability( + wearable, + action: action, + ).position; + return position?.name; + case 'pair': + await _pairWearable(wearable, args); + return {'ok': true}; + case 'unpair': + await _requireCapability( + wearable, + action: action, + ).unpair(); + return {'ok': true}; + case 'is_connected_via_system': + return _requireCapability( + wearable, + action: action, + ).isConnectedViaSystem; + case 'is_time_synchronized': + return _requireCapability( + wearable, + action: action, + ).isTimeSynchronized; + case 'synchronize_time': + await _requireCapability( + wearable, + action: action, + ).synchronizeTime(); + return {'ok': true}; + case 'measure_audio_response': + case 'measure_freq_response': + return _measureAudioResponse(wearable, args); + default: + throw UnsupportedError('Unsupported action: $action'); + } + } + + Future> _subscribe( + _ClientSession client, + Map params, + ) async { + final wearable = _requireConnectedWearable( + _asString(params['device_id'], name: 'device_id'), + ); + final streamName = _asString(params['stream'], name: 'stream'); + final args = _asMap(params['args']); + + final Stream stream; + switch (streamName) { + case 'sensor_values': + stream = _resolveSensor(wearable, args).sensorStream; + break; + case 'sensor_configuration': + stream = _requireCapability( + wearable, + action: 'subscribe:$streamName', + ).sensorConfigurationStream; + break; + case 'button_events': + stream = _requireCapability( + wearable, + action: 'subscribe:$streamName', + ).buttonEvents; + break; + case 'battery_percentage': + stream = _requireCapability( + wearable, + action: 'subscribe:$streamName', + ).batteryPercentageStream; + break; + case 'battery_power_status': + stream = _requireCapability( + wearable, + action: 'subscribe:$streamName', + ).powerStatusStream; + break; + case 'battery_health_status': + stream = _requireCapability( + wearable, + action: 'subscribe:$streamName', + ).healthStatusStream; + break; + case 'battery_energy_status': + stream = _requireCapability( + wearable, + action: 'subscribe:$streamName', + ).energyStatusStream; + break; + default: + throw UnsupportedError('Unknown stream: $streamName'); + } + + final subscriptionId = _nextSubscriptionId++; + await client.subscribe( + subscriptionId: subscriptionId, + streamName: streamName, + deviceId: wearable.deviceId, + stream: stream, + serializer: _serializeStreamData, + ); + + return { + 'subscription_id': subscriptionId, + 'stream': streamName, + 'device_id': wearable.deviceId, + }; + } + + void _attachManagerSubscriptions() { + _scanSubscription ??= _wearableManager.scanStream.listen((device) { + _discoveredDevicesById[device.id] = device; + _broadcastEvent( + { + 'event': 'scan', + 'device': _serializeDiscovered(device), + }, + ); + }); + + _connectingSubscription ??= + _wearableManager.connectingStream.listen((device) { + _broadcastEvent( + { + 'event': 'connecting', + 'device': _serializeDiscovered(device), + }, + ); + }); + + _connectSubscription ??= _wearableManager.connectStream.listen((wearable) { + _registerConnectedWearable(wearable); + _broadcastEvent( + { + 'event': 'connected', + 'wearable': _serializeWearableSummary(wearable), + }, + ); + }); + } + + void _registerConnectedWearable(Wearable wearable) { + _connectedWearablesById[wearable.deviceId] = wearable; + wearable.addDisconnectListener(() { + _connectedWearablesById.remove(wearable.deviceId); + }); + } + + void _broadcastEvent(Map event) { + final payload = _jsonEncode(event); + for (final client in _clients.toList(growable: false)) { + client.sendRaw(payload); + } + } + + void _sendReady(_ClientSession client) { + client.send( + { + 'event': 'ready', + 'methods': methods, + }, + ); + } + + Sensor _resolveSensor(Wearable wearable, Map args) { + final sensorManager = wearable.getCapability(); + if (sensorManager == null) { + throw StateError('Wearable has no SensorManager capability.'); + } + + final sensors = sensorManager.sensors; + if (sensors.isEmpty) { + throw StateError('Wearable has no sensors.'); + } + + final sensorId = args['sensor_id']; + if (sensorId != null) { + final id = _asString(sensorId, name: 'sensor_id'); + for (var i = 0; i < sensors.length; i++) { + if (_sensorId(sensors[i], i) == id) { + return sensors[i]; + } + } + throw StateError('Unknown sensor_id: $id'); + } + + final sensorIndex = args['sensor_index']; + if (sensorIndex != null) { + final index = _asInt(sensorIndex, name: 'sensor_index'); + if (index < 0 || index >= sensors.length) { + throw RangeError.index(index, sensors, 'sensor_index'); + } + return sensors[index]; + } + + final sensorName = args['sensor_name']; + if (sensorName != null) { + final name = _asString(sensorName, name: 'sensor_name'); + final matched = + sensors.where((sensor) => sensor.sensorName == name).toList(); + if (matched.length != 1) { + throw StateError( + 'sensor_name must resolve to exactly one sensor. Matches: ${matched.length}', + ); + } + return matched.first; + } + + throw ArgumentError( + 'sensor_values subscription requires one of sensor_id, sensor_index, or sensor_name.', + ); + } + + Map _setSensorConfiguration( + Wearable wearable, + Map args, + ) { + final config = _requireSensorConfiguration( + wearable, + _asString(args['configuration_name'], name: 'configuration_name'), + ); + final valueKey = _asString(args['value_key'], name: 'value_key'); + final selected = config.values.where((v) => v.key == valueKey).firstOrNull; + if (selected == null) { + throw StateError( + 'Value "$valueKey" not found for configuration ${config.name}.', + ); + } + + _applyConfiguration(config, selected); + return { + 'configuration_name': config.name, + 'value_key': selected.key, + }; + } + + Map _setSensorFrequencyBestEffort( + Wearable wearable, + Map args, + ) { + final config = _requireSensorConfiguration( + wearable, + _asString(args['configuration_name'], name: 'configuration_name'), + ); + if (config is! SensorFrequencyConfiguration) { + throw UnsupportedError( + 'Configuration ${config.name} is not frequency-based.', + ); + } + + final targetHz = _asInt(args['target_hz'], name: 'target_hz'); + final streamData = _asOptionalBool(args['stream_data']); + final recordData = _asOptionalBool(args['record_data']); + + final selected = _selectBestEffortFrequencyValue( + config: config, + targetHz: targetHz, + streamData: streamData, + recordData: recordData, + ); + + if (selected == null) { + throw StateError('No frequency value available for ${config.name}.'); + } + + _applyConfiguration(config, selected); + return { + 'configuration_name': config.name, + 'value_key': selected.key, + 'target_hz': targetHz, + 'selected_hz': _frequencyHzForValue(selected), + }; + } + + Map _setSensorMaximumFrequency( + Wearable wearable, + Map args, + ) { + final config = _requireSensorConfiguration( + wearable, + _asString(args['configuration_name'], name: 'configuration_name'), + ); + if (config is! SensorFrequencyConfiguration) { + throw UnsupportedError( + 'Configuration ${config.name} is not frequency-based.', + ); + } + + final streamData = _asOptionalBool(args['stream_data']); + final recordData = _asOptionalBool(args['record_data']); + + final selected = _selectMaximumFrequencyValue( + config: config, + streamData: streamData, + recordData: recordData, + ); + + if (selected == null) { + throw StateError('No frequency value available for ${config.name}.'); + } + + _applyConfiguration(config, selected); + return { + 'configuration_name': config.name, + 'value_key': selected.key, + 'selected_hz': _frequencyHzForValue(selected), + }; + } + + SensorConfiguration _requireSensorConfiguration( + Wearable wearable, String name) { + final manager = wearable.getCapability(); + if (manager == null) { + throw StateError( + 'Wearable has no SensorConfigurationManager capability.'); + } + + final config = manager.sensorConfigurations + .where((configuration) => configuration.name == name) + .firstOrNull; + if (config == null) { + throw StateError('Unknown configuration: $name'); + } + return config; + } + + void _applyConfiguration( + SensorConfiguration configuration, + SensorConfigurationValue value, + ) { + final dynamic dynamicConfiguration = configuration; + dynamicConfiguration.setConfiguration(value); + } + + SensorConfigurationValue? _selectBestEffortFrequencyValue({ + required SensorFrequencyConfiguration config, + required int targetHz, + required bool? streamData, + required bool? recordData, + }) { + final values = _filterConfigValuesByOptions( + config.values, + streamData: streamData, + recordData: recordData, + ); + + if (values.isEmpty) { + return null; + } + + SensorConfigurationValue? lower; + SensorConfigurationValue? higher; + + for (final value in values) { + final hz = _frequencyHzForValue(value); + if (hz == null) { + continue; + } + + if (hz < targetHz) { + if (lower == null || hz > (_frequencyHzForValue(lower) ?? hz)) { + lower = value; + } + } else { + if (higher == null || hz < (_frequencyHzForValue(higher) ?? hz)) { + higher = value; + } + } + } + + return higher ?? lower; + } + + SensorConfigurationValue? _selectMaximumFrequencyValue({ + required SensorFrequencyConfiguration config, + required bool? streamData, + required bool? recordData, + }) { + final values = _filterConfigValuesByOptions( + config.values, + streamData: streamData, + recordData: recordData, + ); + if (values.isEmpty) { + return null; + } + + SensorConfigurationValue? currentMax; + for (final value in values) { + final hz = _frequencyHzForValue(value); + if (hz == null) { + continue; + } + if (currentMax == null || hz > (_frequencyHzForValue(currentMax) ?? hz)) { + currentMax = value; + } + } + return currentMax; + } + + List _filterConfigValuesByOptions( + List values, { + bool? streamData, + bool? recordData, + }) { + return values.where((value) { + if (value is! ConfigurableSensorConfigurationValue) { + return true; + } + + bool hasOption() { + return value.options.any((option) => option is T); + } + + if (streamData != null && + streamData != hasOption()) { + return false; + } + if (recordData != null && + recordData != hasOption()) { + return false; + } + return true; + }).toList(growable: false); + } + + double? _frequencyHzForValue(SensorConfigurationValue value) { + if (value is SensorFrequencyConfigurationValue) { + return value.frequencyHz; + } + return null; + } + + Future _playFrequency( + Wearable wearable, Map args) async { + final player = _requireCapability( + wearable, + action: 'play_frequency', + ); + final waveTypeKey = _asString(args['wave_type'], name: 'wave_type'); + final waveType = player.supportedFrequencyPlayerWaveTypes + .where((wave) => wave.key == waveTypeKey) + .firstOrNull; + if (waveType == null) { + throw StateError('Unsupported wave type: $waveTypeKey'); + } + + final frequency = _asDouble(args['frequency']) ?? 440.0; + final loudness = _asDouble(args['loudness']) ?? 1.0; + await player.playFrequency( + waveType, + frequency: frequency, + loudness: loudness, + ); + } + + Future _playJingle(Wearable wearable, Map args) async { + final player = _requireCapability( + wearable, + action: 'play_jingle', + ); + final key = _asString(args['jingle'], name: 'jingle'); + final jingle = + player.supportedJingles.where((j) => j.key == key).firstOrNull; + if (jingle == null) { + throw StateError('Unsupported jingle: $key'); + } + await player.playJingle(jingle); + } + + void _setAudioMode(Wearable wearable, Map args) { + final manager = _requireCapability( + wearable, + action: 'set_audio_mode', + ); + final key = _asString(args['audio_mode'], name: 'audio_mode'); + final mode = + manager.availableAudioModes.where((m) => m.key == key).firstOrNull; + if (mode == null) { + throw StateError('Unsupported audio_mode: $key'); + } + manager.setAudioMode(mode); + } + + List _listMicrophones(Wearable wearable) { + final manager = _requireCapability( + wearable, + action: 'list_microphones', + ); + final microphones = manager.availableMicrophones.cast(); + return microphones.map((microphone) => microphone.key.toString()).toList(); + } + + Future _setMicrophone( + Wearable wearable, Map args) async { + final manager = _requireCapability( + wearable, + action: 'set_microphone', + ); + final key = _asString(args['microphone'], name: 'microphone'); + final microphones = manager.availableMicrophones.cast(); + final dynamic selected = microphones.where((microphone) { + return microphone.key.toString() == key; + }).firstOrNull; + + if (selected == null) { + throw StateError('Unsupported microphone: $key'); + } + + manager.setMicrophone(selected); + } + + Future _getMicrophone(Wearable wearable) async { + final manager = _requireCapability( + wearable, + action: 'get_microphone', + ); + final dynamic microphone = await manager.getMicrophone(); + return microphone?.key?.toString(); + } + + Future _pairWearable( + Wearable wearable, Map args) async { + final stereo = _requireCapability( + wearable, + action: 'pair', + ); + final otherDeviceId = + _asString(args['other_device_id'], name: 'other_device_id'); + final partner = _requireConnectedWearable(otherDeviceId); + final partnerStereo = _requireCapability( + partner, + action: 'pair', + ); + await stereo.pair(partnerStereo); + } + + Future _measureAudioResponse( + Wearable wearable, + Map args, + ) async { + final dynamic dynamicWearable = wearable; + + try { + if (args.isEmpty) { + return await dynamicWearable.measureAudioResponse(); + } + return await Function.apply( + dynamicWearable.measureAudioResponse, + const [], + args.map((key, value) => MapEntry(Symbol(key), value)), + ); + } on NoSuchMethodError { + if (args.isEmpty) { + return await dynamicWearable.measureFreqResponse(); + } + return await Function.apply( + dynamicWearable.measureFreqResponse, + const [], + args.map((key, value) => MapEntry(Symbol(key), value)), + ); + } + } + + Map _serializeDiscovered(DiscoveredDevice device) { + return { + 'id': device.id, + 'name': device.name, + 'service_uuids': device.serviceUuids, + 'manufacturer_data': device.manufacturerData.toList(), + 'rssi': device.rssi, + }; + } + + Map _serializeWearableSummary(Wearable wearable) { + return { + 'device_id': wearable.deviceId, + 'name': wearable.name, + 'type': wearable.runtimeType.toString(), + 'capabilities': _capabilitiesForWearable(wearable), + }; + } + + List> _serializeSensors(Wearable wearable) { + final manager = wearable.getCapability(); + if (manager == null) { + return const >[]; + } + + final sensors = manager.sensors; + return [ + for (var index = 0; index < sensors.length; index++) + { + 'sensor_id': _sensorId(sensors[index], index), + 'sensor_index': index, + 'sensor_name': sensors[index].sensorName, + 'chart_title': sensors[index].chartTitle, + 'short_chart_title': sensors[index].shortChartTitle, + 'axis_names': sensors[index].axisNames, + 'axis_units': sensors[index].axisUnits, + 'timestamp_exponent': sensors[index].timestampExponent, + }, + ]; + } + + List> _serializeSensorConfigurations(Wearable wearable) { + final manager = wearable.getCapability(); + if (manager == null) { + return const >[]; + } + + return manager.sensorConfigurations.map((configuration) { + return { + 'name': configuration.name, + 'unit': configuration.unit, + 'values': configuration.values + .map((value) => _serializeSensorConfigurationValue(value)) + .toList(), + 'off_value': configuration.offValue?.key, + }; + }).toList(); + } + + Map _serializeSensorConfigurationValue( + SensorConfigurationValue value, + ) { + final payload = {'key': value.key}; + + if (value is SensorFrequencyConfigurationValue) { + payload['frequency_hz'] = value.frequencyHz; + } + if (value is ConfigurableSensorConfigurationValue) { + payload['options'] = value.options.map((option) => option.name).toList(); + } + + return payload; + } + + Object? _serializeStreamData(dynamic data) { + if (data is SensorValue) { + final payload = { + 'timestamp': data.timestamp, + 'value_strings': data.valueStrings, + }; + if (data is SensorDoubleValue) { + payload['values'] = data.values; + } else if (data is SensorIntValue) { + payload['values'] = data.values; + } + return payload; + } + if (data is ButtonEvent) { + return data.name; + } + if (data is BatteryPowerStatus) { + return _serializeBatteryPowerStatus(data); + } + if (data is BatteryHealthStatus) { + return _serializeBatteryHealthStatus(data); + } + if (data is BatteryEnergyStatus) { + return _serializeBatteryEnergyStatus(data); + } + if (data is Map) { + return data.entries + .map( + (entry) => { + 'name': entry.key.name, + 'value_key': entry.value.key, + }, + ) + .toList(); + } + + return _jsonSafe(data); + } + + Map _serializeBatteryPowerStatus(BatteryPowerStatus status) { + return { + 'battery_present': status.batteryPresent, + 'wired_external_power_source_connected': + status.wiredExternalPowerSourceConnected.name, + 'wireless_external_power_source_connected': + status.wirelessExternalPowerSourceConnected.name, + 'charge_state': status.chargeState.name, + 'charge_level': status.chargeLevel.name, + 'charging_type': status.chargingType.name, + 'charging_fault_reason': + status.chargingFaultReason.map((item) => item.name).toList(), + }; + } + + Map _serializeBatteryHealthStatus( + BatteryHealthStatus status) { + return { + 'health_summary': status.healthSummary, + 'cycle_count': status.cycleCount, + 'current_temperature': status.currentTemperature, + }; + } + + Map _serializeBatteryEnergyStatus( + BatteryEnergyStatus status) { + return { + 'voltage': status.voltage, + 'available_capacity': status.availableCapacity, + 'charge_rate': status.chargeRate, + }; + } + + List _capabilitiesForWearable(Wearable wearable) { + final capabilities = []; + void addIf(String name) { + if (wearable.hasCapability()) { + capabilities.add(name); + } + } + + addIf('SensorManager'); + addIf('SensorConfigurationManager'); + addIf('DeviceIdentifier'); + addIf('DeviceFirmwareVersion'); + addIf('DeviceHardwareVersion'); + addIf('RgbLed'); + addIf('StatusLed'); + addIf('BatteryLevelStatus'); + addIf('BatteryLevelStatusService'); + addIf('BatteryHealthStatusService'); + addIf('BatteryEnergyStatusService'); + addIf('FrequencyPlayer'); + addIf('JinglePlayer'); + addIf('AudioPlayerControls'); + addIf('StoragePathAudioPlayer'); + addIf('AudioModeManager'); + addIf('MicrophoneManager'); + addIf('EdgeRecorderManager'); + addIf('ButtonManager'); + addIf('StereoDevice'); + addIf('SystemDevice'); + addIf('TimeSynchronizable'); + return capabilities; + } + + List _actionsForWearable(Wearable wearable) { + final actions = [ + 'disconnect', + 'get_wearable_icon_path', + 'list_sensors', + 'list_sensor_configurations', + 'set_sensor_configuration', + 'set_sensor_frequency_best_effort', + 'set_sensor_maximum_frequency', + ]; + + void addIf(List names) { + if (wearable.hasCapability()) { + actions.addAll(names); + } + } + + addIf(['read_device_identifier']); + addIf([ + 'read_device_firmware_version', + 'read_firmware_version_number', + 'check_firmware_support', + ]); + addIf(['read_device_hardware_version']); + addIf(['write_led_color']); + addIf(['show_status']); + addIf(['read_battery_percentage']); + addIf(['read_power_status']); + addIf(['read_health_status']); + addIf(['read_energy_status']); + addIf(['play_frequency', 'list_wave_types']); + addIf(['play_jingle', 'list_jingles']); + addIf( + ['start_audio', 'pause_audio', 'stop_audio']); + addIf(['play_audio_from_storage_path']); + addIf( + ['list_audio_modes', 'set_audio_mode', 'get_audio_mode']); + addIf( + ['list_microphones', 'set_microphone', 'get_microphone']); + addIf(['get_file_prefix', 'set_file_prefix']); + addIf(['get_position', 'pair', 'unpair']); + addIf(['is_connected_via_system']); + addIf( + ['is_time_synchronized', 'synchronize_time']); + + final dynamic dynamicWearable = wearable; + final hasMeasureAudioResponse = _hasDynamicMethod( + dynamicWearable, + 'measureAudioResponse', + ); + final hasMeasureFreqResponse = _hasDynamicMethod( + dynamicWearable, + 'measureFreqResponse', + ); + if (hasMeasureAudioResponse || hasMeasureFreqResponse) { + actions + .addAll(['measure_audio_response', 'measure_freq_response']); + } + + return actions; + } + + List _streamsForWearable(Wearable wearable) { + final streams = []; + if (wearable.hasCapability()) { + streams.add('sensor_values'); + } + if (wearable.hasCapability()) { + streams.add('sensor_configuration'); + } + if (wearable.hasCapability()) { + streams.add('button_events'); + } + if (wearable.hasCapability()) { + streams.add('battery_percentage'); + } + if (wearable.hasCapability()) { + streams.add('battery_power_status'); + } + if (wearable.hasCapability()) { + streams.add('battery_health_status'); + } + if (wearable.hasCapability()) { + streams.add('battery_energy_status'); + } + return streams; + } + + bool _hasDynamicMethod(dynamic target, String methodName) { + try { + // ignore: unnecessary_statements + target.noSuchMethod; + switch (methodName) { + case 'measureAudioResponse': + // ignore: unnecessary_statements + target.measureAudioResponse; + return true; + case 'measureFreqResponse': + // ignore: unnecessary_statements + target.measureFreqResponse; + return true; + default: + return false; + } + } on NoSuchMethodError { + return false; + } + } + + Wearable _requireConnectedWearable(String deviceId) { + final wearable = _connectedWearablesById[deviceId]; + if (wearable == null) { + throw StateError('No connected wearable for device_id: $deviceId'); + } + return wearable; + } + + T _requireCapability( + Wearable wearable, { + required String action, + }) { + final capability = wearable.getCapability(); + if (capability != null) { + return capability; + } + throw UnsupportedError( + 'Action "$action" requires capability $T on ${wearable.deviceId}.', + ); + } + + String _sensorId(Sensor sensor, int index) { + final normalized = sensor.sensorName + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9]+'), '_') + .replaceAll(RegExp(r'^_+|_+$'), ''); + return '${normalized}_$index'; + } + + String _normalizePath(String path) { + final trimmed = path.trim(); + if (trimmed.isEmpty) { + return defaultPath; + } + return trimmed.startsWith('/') ? trimmed : '/$trimmed'; + } + + String _jsonEncode(Map payload) { + return jsonEncode(_jsonSafe(payload)); + } + + Object? _jsonSafe(Object? value) { + if (value == null || value is num || value is bool || value is String) { + return value; + } + if (value is Enum) { + return value.name; + } + if (value is List) { + return value.map(_jsonSafe).toList(growable: false); + } + if (value is Set) { + return value.map(_jsonSafe).toList(growable: false); + } + if (value is Map) { + final map = {}; + value.forEach((key, nestedValue) { + map[key.toString()] = _jsonSafe(nestedValue); + }); + return map; + } + return value.toString(); + } + + Map _asMap(Object? value) { + if (value == null) { + return {}; + } + if (value is Map) { + return value; + } + if (value is Map) { + return value.map((key, val) => MapEntry(key.toString(), val)); + } + throw FormatException('Expected params/args to be an object.'); + } + + String _asString(Object? value, {required String name}) { + if (value is String) { + return value; + } + throw FormatException('Expected "$name" to be a string.'); + } + + bool? _asOptionalBool(Object? value) { + if (value == null) { + return null; + } + if (value is bool) { + return value; + } + throw const FormatException('Expected a boolean.'); + } + + bool _asRequiredBool(Object? value, {required String name}) { + if (value is bool) { + return value; + } + throw FormatException('Expected "$name" to be a boolean.'); + } + + int _asInt(Object? value, {required String name}) { + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + if (value is String) { + final parsed = int.tryParse(value); + if (parsed != null) { + return parsed; + } + } + throw FormatException('Expected "$name" to be an integer.'); + } + + double? _asDouble(Object? value) { + if (value == null) { + return null; + } + if (value is double) { + return value; + } + if (value is num) { + return value.toDouble(); + } + if (value is String) { + return double.tryParse(value); + } + return null; + } + + List _asStringList(Object? value) { + if (value == null) { + return []; + } + if (value is List) { + return value.map((entry) => entry.toString()).toList(growable: false); + } + throw FormatException('Expected a list of strings.'); + } +} + +class _ClientSession { + final WebSocket socket; + final WebSocketIpcServer server; + + final Map> _subscriptions = + >{}; + + bool _closed = false; + + _ClientSession({ + required this.socket, + required this.server, + }); + + void start() { + server._sendReady(this); + + socket.listen( + (message) async { + await _handleMessage(message); + }, + onDone: () async { + await close(); + }, + onError: (_) async { + await close(); + }, + cancelOnError: true, + ); + } + + void send(Map payload) { + if (_closed) { + return; + } + sendRaw(jsonEncode(payload)); + } + + void sendRaw(String payload) { + if (_closed) { + return; + } + socket.add(payload); + } + + Future _handleMessage(dynamic rawMessage) async { + dynamic id; + try { + if (rawMessage is! String) { + throw const FormatException('Expected text websocket frame.'); + } + + final decoded = jsonDecode(rawMessage); + if (decoded is! Map) { + throw const FormatException('Request must be a JSON object.'); + } + + final request = + decoded.map((key, value) => MapEntry(key.toString(), value)); + id = request['id']; + + final method = request['method']; + if (method is! String || method.trim().isEmpty) { + throw const FormatException( + 'Request method must be a non-empty string.'); + } + + final params = server._asMap(request['params']); + final result = await server._handleRequest( + client: this, + method: method, + params: params, + ); + + send( + { + 'id': id, + 'result': result, + }, + ); + } catch (error, stackTrace) { + send( + { + 'id': id, + 'error': { + 'message': error.toString(), + 'type': error.runtimeType.toString(), + 'stack': stackTrace.toString(), + }, + }, + ); + } + } + + Future subscribe({ + required int subscriptionId, + required String streamName, + required String deviceId, + required Stream stream, + required Object? Function(dynamic value) serializer, + }) async { + await _subscriptions[subscriptionId]?.cancel(); + _subscriptions[subscriptionId] = stream.listen( + (data) { + send( + { + 'event': 'stream', + 'subscription_id': subscriptionId, + 'stream': streamName, + 'device_id': deviceId, + 'data': serializer(data), + }, + ); + }, + onError: (error, stackTrace) { + send( + { + 'event': 'stream_error', + 'subscription_id': subscriptionId, + 'stream': streamName, + 'device_id': deviceId, + 'error': { + 'message': error.toString(), + 'type': error.runtimeType.toString(), + 'stack': stackTrace.toString(), + }, + }, + ); + }, + onDone: () { + _subscriptions.remove(subscriptionId); + send( + { + 'event': 'stream_done', + 'subscription_id': subscriptionId, + 'stream': streamName, + 'device_id': deviceId, + }, + ); + }, + cancelOnError: false, + ); + } + + Future> unsubscribe(int subscriptionId) async { + final existing = _subscriptions.remove(subscriptionId); + if (existing == null) { + return { + 'subscription_id': subscriptionId, + 'cancelled': false, + }; + } + await existing.cancel(); + return { + 'subscription_id': subscriptionId, + 'cancelled': true, + }; + } + + Future close() async { + if (_closed) { + return; + } + _closed = true; + + final subscriptions = _subscriptions.values.toList(growable: false); + _subscriptions.clear(); + + for (final subscription in subscriptions) { + await subscription.cancel(); + } + + await socket.close(); + server._onClientClosed(this); + } +} + +extension on Iterable { + T? get firstOrNull { + if (isEmpty) { + return null; + } + return first; + } +} From 60b5cd317f79f8ef6471e912170b17c9c129c08e Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:15:07 +0100 Subject: [PATCH 3/7] wire connectors settings page into app navigation and startup --- open_wearable/lib/main.dart | 3 + open_wearable/lib/router.dart | 10 + open_wearable/lib/widgets/home_page.dart | 6 + .../lib/widgets/settings/connectors_page.dart | 461 ++++++++++++++++++ .../lib/widgets/settings/settings_page.dart | 8 + open_wearable/macos/Podfile.lock | 7 + 6 files changed, 495 insertions(+) create mode 100644 open_wearable/lib/widgets/settings/connectors_page.dart diff --git a/open_wearable/lib/main.dart b/open_wearable/lib/main.dart index 9bea2f14..b029787c 100644 --- a/open_wearable/lib/main.dart +++ b/open_wearable/lib/main.dart @@ -8,6 +8,7 @@ import 'package:open_wearable/models/app_background_execution_bridge.dart'; import 'package:open_wearable/models/app_launch_session.dart'; import 'package:open_wearable/models/app_shutdown_settings.dart'; import 'package:open_wearable/models/auto_connect_preferences.dart'; +import 'package:open_wearable/models/connector_settings.dart'; import 'package:open_wearable/models/log_file_manager.dart'; import 'package:open_wearable/models/fota_post_update_verification.dart'; import 'package:open_wearable/models/wearable_connector.dart' @@ -34,6 +35,7 @@ void main() async { initLogger(logFileManager.logger); await AutoConnectPreferences.initialize(); await AppShutdownSettings.initialize(); + await ConnectorSettings.initialize(); runApp( MultiProvider( @@ -618,6 +620,7 @@ class _MyAppState extends State with WidgetsBindingObserver { @override void dispose() { + unawaited(ConnectorSettings.dispose()); _unsupportedFirmwareSub.cancel(); _wearableEventSub.cancel(); _wearableProvEventSub.cancel(); diff --git a/open_wearable/lib/router.dart b/open_wearable/lib/router.dart index 6e69c56c..533972bb 100644 --- a/open_wearable/lib/router.dart +++ b/open_wearable/lib/router.dart @@ -8,6 +8,7 @@ import 'package:open_wearable/widgets/fota/fota_warning_page.dart'; import 'package:open_wearable/widgets/home_page.dart'; import 'package:open_wearable/widgets/logging/log_files_screen.dart'; import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_all_recordings_page.dart'; +import 'package:open_wearable/widgets/settings/connectors_page.dart'; import 'package:open_wearable/widgets/settings/general_settings_page.dart'; import 'dart:io' show Platform; import 'package:flutter/cupertino.dart'; @@ -134,10 +135,19 @@ final GoRouter router = GoRouter( name: 'settings/general', builder: (context, state) => const GeneralSettingsPage(), ), + GoRoute( + path: '/settings/connectors', + name: 'settings/connectors', + builder: (context, state) => const ConnectorsPage(), + ), GoRoute( path: '/settings/app-close', redirect: (_, __) => '/settings/general', ), + GoRoute( + path: '/connectors', + redirect: (_, __) => '/settings/connectors', + ), GoRoute( path: '/fota', name: 'fota', diff --git a/open_wearable/lib/widgets/home_page.dart b/open_wearable/lib/widgets/home_page.dart index 2bb74d7e..01820324 100644 --- a/open_wearable/lib/widgets/home_page.dart +++ b/open_wearable/lib/widgets/home_page.dart @@ -89,6 +89,7 @@ class _HomePageState extends State { onLogsRequested: _openLogFiles, onConnectRequested: _openConnectDevices, onGeneralSettingsRequested: _openGeneralSettings, + onConnectorsRequested: _openConnectors, ), ]; } @@ -238,6 +239,11 @@ class _HomePageState extends State { if (!mounted) return; context.push('/settings/general'); } + + void _openConnectors() { + if (!mounted) return; + context.push('/settings/connectors'); + } } class _HomeDestination { diff --git a/open_wearable/lib/widgets/settings/connectors_page.dart b/open_wearable/lib/widgets/settings/connectors_page.dart new file mode 100644 index 00000000..be16cb9d --- /dev/null +++ b/open_wearable/lib/widgets/settings/connectors_page.dart @@ -0,0 +1,461 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:open_wearable/models/connector_settings.dart'; +import 'package:open_wearable/widgets/app_toast.dart'; +import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; + +class ConnectorsPage extends StatefulWidget { + const ConnectorsPage({super.key}); + + @override + State createState() => _ConnectorsPageState(); +} + +class _ConnectorsPageState extends State { + late final TextEditingController _hostController; + late final TextEditingController _portController; + late final TextEditingController _pathController; + + bool _enabled = false; + bool _isLoading = true; + bool _isSaving = false; + String? _validationMessage; + + @override + void initState() { + super.initState(); + _hostController = TextEditingController(); + _portController = TextEditingController(); + _pathController = TextEditingController(); + _loadSettings(); + } + + @override + void dispose() { + _hostController.dispose(); + _portController.dispose(); + _pathController.dispose(); + super.dispose(); + } + + Future _loadSettings() async { + try { + final settings = await ConnectorSettings.loadWebSocketSettings(); + if (!mounted) { + return; + } + + setState(() { + _enabled = settings.enabled; + _hostController.text = settings.host; + _portController.text = settings.port.toString(); + _pathController.text = settings.path; + _validationMessage = null; + _isLoading = false; + }); + } catch (_) { + if (!mounted) { + return; + } + setState(() { + _validationMessage = 'Could not load connector settings.'; + _isLoading = false; + }); + AppToast.show( + context, + message: 'Failed to load connector settings.', + type: AppToastType.error, + icon: Icons.error_outline_rounded, + ); + } + } + + Future _saveSettings() async { + if (_isSaving) { + return; + } + + final validated = _buildValidatedSettings(); + if (validated == null) { + return; + } + + setState(() { + _isSaving = true; + _validationMessage = null; + }); + + try { + final saved = await ConnectorSettings.saveWebSocketSettings(validated); + if (!mounted) { + return; + } + + setState(() { + _enabled = saved.enabled; + _hostController.text = saved.host; + _portController.text = saved.port.toString(); + _pathController.text = saved.path; + }); + + AppToast.show( + context, + message: 'WebSocket IPC settings saved.', + type: AppToastType.success, + icon: Icons.check_circle_outline_rounded, + ); + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _validationMessage = + 'Could not start WebSocket IPC server: ${error.toString()}'; + }); + AppToast.show( + context, + message: 'Failed to apply WebSocket IPC settings.', + type: AppToastType.error, + icon: Icons.error_outline_rounded, + ); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + WebSocketConnectorSettings? _buildValidatedSettings() { + final host = _hostController.text.trim(); + final parsedPort = int.tryParse(_portController.text.trim()); + final rawPath = _pathController.text.trim(); + final path = rawPath.isEmpty ? '/ws' : rawPath; + + if (host.isEmpty) { + setState(() { + _validationMessage = 'Host is required.'; + }); + return null; + } + + if (parsedPort == null || parsedPort <= 0 || parsedPort > 65535) { + setState(() { + _validationMessage = 'Port must be between 1 and 65535.'; + }); + return null; + } + + if (!path.startsWith('/')) { + setState(() { + _validationMessage = 'Path must start with /. Example: /ws'; + }); + return null; + } + + return WebSocketConnectorSettings( + enabled: _enabled, + host: host, + port: parsedPort, + path: path, + ); + } + + void _clearValidation([String? _]) { + if (_validationMessage == null) { + return; + } + setState(() { + _validationMessage = null; + }); + } + + bool _hasPendingChanges(WebSocketConnectorSettings applied) { + final parsedPort = int.tryParse(_portController.text.trim()); + final path = _pathController.text.trim().isEmpty + ? '/ws' + : _pathController.text.trim(); + + return _enabled != applied.enabled || + _hostController.text.trim() != applied.host || + parsedPort != applied.port || + path != applied.path; + } + + @override + Widget build(BuildContext context) { + return PlatformScaffold( + appBar: PlatformAppBar( + title: const Text('Connectors'), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : ValueListenableBuilder( + valueListenable: ConnectorSettings.webSocketSettingsListenable, + builder: (context, appliedSettings, _) { + return ValueListenableBuilder( + valueListenable: + ConnectorSettings.webSocketRuntimeStatusListenable, + builder: (context, runtimeStatus, __) { + final pending = _hasPendingChanges(appliedSettings); + return ListView( + padding: + SensorPageSpacing.pagePaddingWithBottomInset(context), + children: [ + Text( + 'Connectors', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + 'Expose OpenEarable features for external tools.', + style: + Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + const SizedBox(height: 10), + _buildWebSocketConnectorCard( + context, + appliedSettings: appliedSettings, + runtimeStatus: runtimeStatus, + hasPendingChanges: pending, + ), + ], + ); + }, + ); + }, + ), + ); + } + + Widget _buildWebSocketConnectorCard( + BuildContext context, { + required WebSocketConnectorSettings appliedSettings, + required ConnectorRuntimeStatus runtimeStatus, + required bool hasPendingChanges, + }) { + final colorScheme = Theme.of(context).colorScheme; + final statusColor = switch (runtimeStatus.state) { + ConnectorRuntimeState.running => const Color(0xFF1E6A3A), + ConnectorRuntimeState.starting => colorScheme.primary, + ConnectorRuntimeState.error => colorScheme.error, + ConnectorRuntimeState.disabled => colorScheme.onSurfaceVariant, + }; + + final endpoint = Uri( + scheme: 'ws', + host: _hostController.text.trim().isEmpty + ? appliedSettings.host + : _hostController.text.trim(), + port: int.tryParse(_portController.text.trim()) ?? appliedSettings.port, + path: _pathController.text.trim().isEmpty + ? appliedSettings.path + : _pathController.text.trim(), + ); + + return Card( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: Icon( + Icons.cable_rounded, + size: 18, + color: statusColor, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'WebSocket IPC', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + 'Expose the OpenEarable Flutter API over JSON messages.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Switch.adaptive( + value: _enabled, + onChanged: _isSaving + ? null + : (value) { + setState(() { + _enabled = value; + _validationMessage = null; + }); + }, + ), + ], + ), + const SizedBox(height: 10), + TextField( + controller: _hostController, + enabled: !_isSaving, + onChanged: _clearValidation, + decoration: const InputDecoration( + labelText: 'Host', + hintText: '127.0.0.1', + ), + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: TextField( + controller: _portController, + enabled: !_isSaving, + onChanged: _clearValidation, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Port', + hintText: '8765', + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: TextField( + controller: _pathController, + enabled: !_isSaving, + onChanged: _clearValidation, + decoration: const InputDecoration( + labelText: 'Path', + hintText: '/ws', + ), + ), + ), + ], + ), + const SizedBox(height: 10), + _StatusChip( + status: runtimeStatus, + endpoint: endpoint.toString(), + ), + if (_validationMessage != null) ...[ + const SizedBox(height: 8), + Text( + _validationMessage!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.error, + ), + ), + ], + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: PlatformElevatedButton( + onPressed: + _isSaving || !hasPendingChanges ? null : _saveSettings, + child: Text(_isSaving ? 'Saving...' : 'Save & Apply'), + ), + ), + ], + ), + ), + ); + } +} + +class _StatusChip extends StatelessWidget { + final ConnectorRuntimeStatus status; + final String endpoint; + + const _StatusChip({ + required this.status, + required this.endpoint, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + final (title, detail, foreground) = switch (status.state) { + ConnectorRuntimeState.running => ( + 'Running', + endpoint, + const Color(0xFF1E6A3A), + ), + ConnectorRuntimeState.starting => ( + 'Starting', + endpoint, + colorScheme.primary, + ), + ConnectorRuntimeState.error => ( + 'Error', + status.message ?? 'Unknown startup error', + colorScheme.error, + ), + ConnectorRuntimeState.disabled => ( + 'Disabled', + 'Connector is off', + colorScheme.onSurfaceVariant, + ), + }; + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: foreground.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: foreground.withValues(alpha: 0.35)), + ), + child: Row( + children: [ + Icon(Icons.circle, size: 10, color: foreground), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: foreground, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 1), + Text( + detail, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: foreground, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/open_wearable/lib/widgets/settings/settings_page.dart b/open_wearable/lib/widgets/settings/settings_page.dart index cd21e39f..c1244c74 100644 --- a/open_wearable/lib/widgets/settings/settings_page.dart +++ b/open_wearable/lib/widgets/settings/settings_page.dart @@ -13,12 +13,14 @@ class SettingsPage extends StatelessWidget { final VoidCallback onLogsRequested; final VoidCallback onConnectRequested; final VoidCallback onGeneralSettingsRequested; + final VoidCallback onConnectorsRequested; const SettingsPage({ super.key, required this.onLogsRequested, required this.onConnectRequested, required this.onGeneralSettingsRequested, + required this.onConnectorsRequested, }); @override @@ -49,6 +51,12 @@ class SettingsPage extends StatelessWidget { subtitle: 'View, share, and remove diagnostic logs', onTap: onLogsRequested, ), + _QuickActionTile( + icon: Icons.cable_rounded, + title: 'Connectors', + subtitle: 'Configure external API connectors', + onTap: onConnectorsRequested, + ), _QuickActionTile( icon: Icons.info_outline_rounded, title: 'About', diff --git a/open_wearable/macos/Podfile.lock b/open_wearable/macos/Podfile.lock index 0fb17023..95737490 100644 --- a/open_wearable/macos/Podfile.lock +++ b/open_wearable/macos/Podfile.lock @@ -9,6 +9,9 @@ PODS: - FlutterMacOS (1.0.0) - open_file_mac (1.0.3): - FlutterMacOS + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS - share_plus (0.0.1): - FlutterMacOS - shared_preferences_foundation (0.0.1): @@ -27,6 +30,7 @@ DEPENDENCIES: - flutter_archive (from `Flutter/ephemeral/.symlinks/plugins/flutter_archive/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - open_file_mac (from `Flutter/ephemeral/.symlinks/plugins/open_file_mac/macos`) + - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - universal_ble (from `Flutter/ephemeral/.symlinks/plugins/universal_ble/darwin`) @@ -47,6 +51,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral open_file_mac: :path: Flutter/ephemeral/.symlinks/plugins/open_file_mac/macos + path_provider_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin share_plus: :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos shared_preferences_foundation: @@ -62,6 +68,7 @@ SPEC CHECKSUMS: flutter_archive: 07888d9aeb79da005e0ad8b9d347d17cdea07f68 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 open_file_mac: 76f06c8597551249bdb5e8fd8827a98eae0f4585 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb universal_ble: ff19787898040d721109c6324472e5dd4bc86adc From 478c9e98c77465bd2ef2866eb01cabeb39663cf3 Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:43:24 +0100 Subject: [PATCH 4/7] use wearableConnector for connections in websocket to share connected devices --- open_wearable/lib/main.dart | 7 +++++-- open_wearable/lib/models/connector_settings.dart | 12 ++++++++++-- .../models/connectors/websocket_ipc_server.dart | 14 ++++++++++---- open_wearable/lib/models/wearable_connector.dart | 16 ++++++++++++---- 4 files changed, 37 insertions(+), 12 deletions(-) diff --git a/open_wearable/lib/main.dart b/open_wearable/lib/main.dart index b029787c..a31d8756 100644 --- a/open_wearable/lib/main.dart +++ b/open_wearable/lib/main.dart @@ -31,11 +31,14 @@ import 'view_models/wearables_provider.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); LogFileManager logFileManager = await LogFileManager.create(); + final wearableConnector = WearableConnector(); initOpenWearableLogger(logFileManager.libLogger); initLogger(logFileManager.logger); await AutoConnectPreferences.initialize(); await AppShutdownSettings.initialize(); - await ConnectorSettings.initialize(); + await ConnectorSettings.initialize( + wearableConnector: wearableConnector, + ); runApp( MultiProvider( @@ -47,7 +50,7 @@ void main() async { ChangeNotifierProvider( create: (context) => SensorRecorderProvider(), ), - Provider.value(value: WearableConnector()), + Provider.value(value: wearableConnector), ChangeNotifierProvider( create: (context) => AppBannerController(), ), diff --git a/open_wearable/lib/models/connector_settings.dart b/open_wearable/lib/models/connector_settings.dart index e7e74010..755ad319 100644 --- a/open_wearable/lib/models/connector_settings.dart +++ b/open_wearable/lib/models/connector_settings.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import 'package:open_wearable/models/wearable_connector.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'connectors/websocket_ipc_server.dart'; @@ -88,7 +89,7 @@ class ConnectorSettings { static const String _websocketPortKey = 'connector_websocket_port'; static const String _websocketPathKey = 'connector_websocket_path'; - static final WebSocketIpcServer _webSocketServer = WebSocketIpcServer(); + static WebSocketIpcServer _webSocketServer = WebSocketIpcServer(); static final ValueNotifier _webSocketSettingsNotifier = ValueNotifier( @@ -112,7 +113,14 @@ class ConnectorSettings { static ConnectorRuntimeStatus get currentWebSocketRuntimeStatus => _webSocketRuntimeStatusNotifier.value; - static Future initialize() async { + static Future initialize({ + WearableConnector? wearableConnector, + }) async { + if (wearableConnector != null) { + _webSocketServer = WebSocketIpcServer( + wearableConnector: wearableConnector, + ); + } final settings = await loadWebSocketSettings(); await applyWebSocketSettings(settings); } diff --git a/open_wearable/lib/models/connectors/websocket_ipc_server.dart b/open_wearable/lib/models/connectors/websocket_ipc_server.dart index d5af4821..36a8c34f 100644 --- a/open_wearable/lib/models/connectors/websocket_ipc_server.dart +++ b/open_wearable/lib/models/connectors/websocket_ipc_server.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/models/wearable_connector.dart'; class WebSocketIpcServer { static const String defaultHost = '127.0.0.1'; @@ -10,6 +11,7 @@ class WebSocketIpcServer { static const String defaultPath = '/ws'; final WearableManager _wearableManager; + final WearableConnector _wearableConnector; HttpServer? _httpServer; String _host = defaultHost; @@ -27,8 +29,11 @@ class WebSocketIpcServer { int _nextSubscriptionId = 1; - WebSocketIpcServer({WearableManager? wearableManager}) - : _wearableManager = wearableManager ?? WearableManager(); + WebSocketIpcServer({ + WearableManager? wearableManager, + WearableConnector? wearableConnector, + }) : _wearableManager = wearableManager ?? WearableManager(), + _wearableConnector = wearableConnector ?? WearableConnector(); bool get isRunning => _httpServer != null; @@ -141,6 +146,7 @@ class WebSocketIpcServer { case 'start_scan': final checkAndRequestPermissions = _asOptionalBool(params['check_and_request_permissions']) ?? true; + _discoveredDevicesById.clear(); await _wearableManager.startScan( checkAndRequestPermissions: checkAndRequestPermissions, ); @@ -189,7 +195,7 @@ class WebSocketIpcServer { ? {const ConnectedViaSystem()} : const {}; - final wearable = await _wearableManager.connectToDevice( + final wearable = await _wearableConnector.connect( discovered, options: options, ); @@ -201,7 +207,7 @@ class WebSocketIpcServer { Map params, ) async { final ignoredIds = _asStringList(params['ignored_device_ids']); - final wearables = await _wearableManager.connectToSystemDevices( + final wearables = await _wearableConnector.connectToSystemDevices( ignoredDeviceIds: ignoredIds, ); for (final wearable in wearables) { diff --git a/open_wearable/lib/models/wearable_connector.dart b/open_wearable/lib/models/wearable_connector.dart index 557722fb..22619b40 100644 --- a/open_wearable/lib/models/wearable_connector.dart +++ b/open_wearable/lib/models/wearable_connector.dart @@ -58,15 +58,23 @@ class WearableConnector { WearableConnector([WearableManager? wm]) : _wm = wm ?? WearableManager(); - Future connect(DiscoveredDevice device) async { - final wearable = await _wm.connectToDevice(device); + Future connect( + DiscoveredDevice device, { + Set options = const {}, + }) async { + final wearable = await _wm.connectToDevice(device, options: options); _handleConnection(wearable); return wearable; } - Future connectToSystemDevices() async { - List connectedWearables = await _wm.connectToSystemDevices(); + Future> connectToSystemDevices({ + List ignoredDeviceIds = const [], + }) async { + final connectedWearables = await _wm.connectToSystemDevices( + ignoredDeviceIds: ignoredDeviceIds, + ); connectedWearables.forEach(_handleConnection); + return connectedWearables; } void _handleConnection(Wearable wearable) { From 4aae55b570573b0f1ad0aab036f4166017035ab5 Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Thu, 26 Feb 2026 00:16:22 +0100 Subject: [PATCH 5/7] feat: Implement command structure for wearable connector - Added Command and RuntimeCommand classes to define command structure. - Introduced various command implementations including CheckAndRequestPermissionsCommand, ConnectCommand, DisconnectCommand, and others for managing wearable connections and actions. - Implemented parameter handling with CommandParam and utility functions for parameter validation. - Added logging for command execution and error handling. - Created device-specific commands inheriting from DeviceCommand for sensor management. - Established IPC command list for default command registration. refactor: Remove unused commands and clean up IPC command structure feat: Refactor command structure and add invoke action command feat: Enhance subscription management with createSubscriptionId and attachStreamSubscription methods --- ...check_and_request_permissions_command.dart | 12 + .../models/connectors/commands/command.dart | 95 ++ .../connectors/commands/connect_command.dart | 23 + .../connect_system_devices_command.dart | 21 + .../commands/default_action_commands.dart | 17 + .../commands/default_ipc_commands.dart | 33 + .../connectors/commands/device_command.dart | 33 + .../commands/disconnect_command.dart | 20 + .../get_discovered_devices_command.dart | 12 + .../commands/has_permissions_command.dart | 12 + .../commands/invoke_action_command.dart | 24 + .../commands/ipc_internal_param_names.dart | 1 + .../commands/list_connected_command.dart | 12 + .../commands/list_sensor_configs_command.dart | 49 + .../commands/list_sensors_command.dart | 42 + .../connectors/commands/methods_command.dart | 10 + .../connectors/commands/param_readers.dart | 84 ++ .../connectors/commands/ping_command.dart | 9 + .../models/connectors/commands/runtime.dart | 53 + .../connectors/commands/runtime_command.dart | 12 + .../commands/set_sensor_config_command.dart | 47 + .../commands/start_scan_command.dart | 22 + .../commands/subscribe_command.dart | 179 +++ .../commands/sync_time_command.dart | 18 + .../commands/unsubscribe_command.dart | 23 + .../connectors/websocket_ipc_server.dart | 1169 ++--------------- 26 files changed, 1003 insertions(+), 1029 deletions(-) create mode 100644 open_wearable/lib/models/connectors/commands/check_and_request_permissions_command.dart create mode 100644 open_wearable/lib/models/connectors/commands/command.dart create mode 100644 open_wearable/lib/models/connectors/commands/connect_command.dart create mode 100644 open_wearable/lib/models/connectors/commands/connect_system_devices_command.dart create mode 100644 open_wearable/lib/models/connectors/commands/default_action_commands.dart create mode 100644 open_wearable/lib/models/connectors/commands/default_ipc_commands.dart create mode 100644 open_wearable/lib/models/connectors/commands/device_command.dart create mode 100644 open_wearable/lib/models/connectors/commands/disconnect_command.dart create mode 100644 open_wearable/lib/models/connectors/commands/get_discovered_devices_command.dart create mode 100644 open_wearable/lib/models/connectors/commands/has_permissions_command.dart create mode 100644 open_wearable/lib/models/connectors/commands/invoke_action_command.dart create mode 100644 open_wearable/lib/models/connectors/commands/ipc_internal_param_names.dart create mode 100644 open_wearable/lib/models/connectors/commands/list_connected_command.dart create mode 100644 open_wearable/lib/models/connectors/commands/list_sensor_configs_command.dart create mode 100644 open_wearable/lib/models/connectors/commands/list_sensors_command.dart create mode 100644 open_wearable/lib/models/connectors/commands/methods_command.dart create mode 100644 open_wearable/lib/models/connectors/commands/param_readers.dart create mode 100644 open_wearable/lib/models/connectors/commands/ping_command.dart create mode 100644 open_wearable/lib/models/connectors/commands/runtime.dart create mode 100644 open_wearable/lib/models/connectors/commands/runtime_command.dart create mode 100644 open_wearable/lib/models/connectors/commands/set_sensor_config_command.dart create mode 100644 open_wearable/lib/models/connectors/commands/start_scan_command.dart create mode 100644 open_wearable/lib/models/connectors/commands/subscribe_command.dart create mode 100644 open_wearable/lib/models/connectors/commands/sync_time_command.dart create mode 100644 open_wearable/lib/models/connectors/commands/unsubscribe_command.dart diff --git a/open_wearable/lib/models/connectors/commands/check_and_request_permissions_command.dart b/open_wearable/lib/models/connectors/commands/check_and_request_permissions_command.dart new file mode 100644 index 00000000..a5fde7e8 --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/check_and_request_permissions_command.dart @@ -0,0 +1,12 @@ +import 'command.dart'; +import 'runtime_command.dart'; + +class CheckAndRequestPermissionsCommand extends RuntimeCommand { + CheckAndRequestPermissionsCommand({required super.runtime}) + : super(name: 'check_and_request_permissions'); + + @override + Future execute(List params) { + return runtime.checkAndRequestPermissions(); + } +} diff --git a/open_wearable/lib/models/connectors/commands/command.dart b/open_wearable/lib/models/connectors/commands/command.dart new file mode 100644 index 00000000..1506805a --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/command.dart @@ -0,0 +1,95 @@ +import '../../logger.dart'; + +class CommandParam { + final String name; + final T? value; + final bool required; + + CommandParam({ + required this.name, + this.value, + this.required = false, + }); +} + +abstract class Command { + final String name; + final List params; + + Command({required this.name, this.params = const []}); + + T requireParam(List params, String paramName) { + final param = params.firstWhere( + (p) => p.name == paramName, + orElse: () => + throw ArgumentError('Missing required parameter: $paramName'), + ); + if (param.value == null) { + throw ArgumentError('Parameter $paramName cannot be null'); + } + return param.value as T; + } + + Future run(List params) async { + final startedAt = DateTime.now(); + logger.d( + '[connector.command] start name=$name params=${_formatParams(params)}', + ); + for (final param in this.params) { + if (param.required) { + final providedParam = params.firstWhere( + (p) => p.name == param.name, + orElse: () => throw ArgumentError( + 'Missing required parameter: ${param.name}', + ), + ); + if (providedParam.value == null) { + throw ArgumentError('Parameter ${param.name} cannot be null'); + } + } + } + try { + final result = await execute(params); + final durationMs = DateTime.now().difference(startedAt).inMilliseconds; + logger.d( + '[connector.command] done name=$name duration_ms=$durationMs', + ); + return result; + } catch (error, stackTrace) { + final durationMs = DateTime.now().difference(startedAt).inMilliseconds; + logger.w( + '[connector.command] failed name=$name duration_ms=$durationMs error=$error\n$stackTrace', + ); + rethrow; + } + } + + Future execute(List params); + + String _formatParams(List params) { + final map = {}; + for (final param in params) { + if (param.name.startsWith('__')) { + continue; + } + map[param.name] = _loggableValue(param.value); + } + return map.toString(); + } + + Object? _loggableValue(Object? value) { + if (value == null || value is num || value is bool || value is String) { + return value; + } + if (value is List) { + return value.map(_loggableValue).toList(growable: false); + } + if (value is Map) { + return value.map( + (key, nestedValue) => + MapEntry(key.toString(), _loggableValue(nestedValue)), + ); + } + return value.runtimeType.toString(); + } +} diff --git a/open_wearable/lib/models/connectors/commands/connect_command.dart b/open_wearable/lib/models/connectors/commands/connect_command.dart new file mode 100644 index 00000000..e43df7e9 --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/connect_command.dart @@ -0,0 +1,23 @@ +import 'command.dart'; +import 'param_readers.dart'; +import 'runtime_command.dart'; + +class ConnectCommand extends RuntimeCommand { + ConnectCommand({required super.runtime}) + : super( + name: 'connect', + params: [ + CommandParam(name: 'device_id', required: true), + CommandParam(name: 'connected_via_system'), + ], + ); + + @override + Future> execute(List params) { + return runtime.connect( + deviceId: requireStringParam(params, 'device_id'), + connectedViaSystem: + readOptionalBoolParam(params, 'connected_via_system') ?? false, + ); + } +} diff --git a/open_wearable/lib/models/connectors/commands/connect_system_devices_command.dart b/open_wearable/lib/models/connectors/commands/connect_system_devices_command.dart new file mode 100644 index 00000000..60abf1e9 --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/connect_system_devices_command.dart @@ -0,0 +1,21 @@ +import 'command.dart'; +import 'param_readers.dart'; +import 'runtime_command.dart'; + +class ConnectSystemDevicesCommand extends RuntimeCommand { + ConnectSystemDevicesCommand({required super.runtime}) + : super( + name: 'connect_system_devices', + params: [ + CommandParam>(name: 'ignored_device_ids'), + ], + ); + + @override + Future>> execute(List params) { + return runtime.connectSystemDevices( + ignoredDeviceIds: + readOptionalStringListParam(params, 'ignored_device_ids'), + ); + } +} diff --git a/open_wearable/lib/models/connectors/commands/default_action_commands.dart b/open_wearable/lib/models/connectors/commands/default_action_commands.dart new file mode 100644 index 00000000..511e0c7a --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/default_action_commands.dart @@ -0,0 +1,17 @@ +import 'command.dart'; +import 'disconnect_command.dart'; +import 'list_sensor_configs_command.dart'; +import 'list_sensors_command.dart'; +import 'runtime.dart'; +import 'set_sensor_config_command.dart'; +import 'sync_time_command.dart'; + +List createDefaultActionCommands(CommandRuntime runtime) { + return [ + DisconnectCommand(runtime: runtime), + SyncTimeCommand(runtime: runtime), + ListSensorsCommand(runtime: runtime), + ListSensorConfigsCommand(runtime: runtime), + SetSensorConfigCommand(runtime: runtime), + ]; +} diff --git a/open_wearable/lib/models/connectors/commands/default_ipc_commands.dart b/open_wearable/lib/models/connectors/commands/default_ipc_commands.dart new file mode 100644 index 00000000..1eecc087 --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/default_ipc_commands.dart @@ -0,0 +1,33 @@ +import 'check_and_request_permissions_command.dart'; +import 'command.dart'; +import 'connect_command.dart'; +import 'connect_system_devices_command.dart'; +import 'disconnect_command.dart'; +import 'get_discovered_devices_command.dart'; +import 'has_permissions_command.dart'; +import 'invoke_action_command.dart'; +import 'list_connected_command.dart'; +import 'methods_command.dart'; +import 'ping_command.dart'; +import 'runtime.dart'; +import 'start_scan_command.dart'; +import 'subscribe_command.dart'; +import 'unsubscribe_command.dart'; + +List createDefaultIpcCommands(CommandRuntime runtime) { + return [ + PingCommand(), + MethodsCommand(runtime: runtime), + HasPermissionsCommand(runtime: runtime), + CheckAndRequestPermissionsCommand(runtime: runtime), + StartScanCommand(runtime: runtime), + GetDiscoveredDevicesCommand(runtime: runtime), + ConnectCommand(runtime: runtime), + ConnectSystemDevicesCommand(runtime: runtime), + ListConnectedCommand(runtime: runtime), + DisconnectCommand(runtime: runtime), + SubscribeCommand(runtime: runtime), + UnsubscribeCommand(runtime: runtime), + InvokeActionCommand(runtime: runtime), + ]; +} diff --git a/open_wearable/lib/models/connectors/commands/device_command.dart b/open_wearable/lib/models/connectors/commands/device_command.dart new file mode 100644 index 00000000..66afb42b --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/device_command.dart @@ -0,0 +1,33 @@ +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/models/connectors/commands/command.dart'; +import 'package:open_wearable/models/connectors/commands/runtime_command.dart'; + +abstract class DeviceCommand extends RuntimeCommand { + DeviceCommand({ + required super.name, + required super.runtime, + List params = const [], + }) : super( + params: [ + CommandParam(name: 'device_id', required: true), + ...params, + ], + ); + + Future getWearable(List params) async { + final deviceId = requireParam(params, 'device_id'); + return runtime.getWearable(deviceId: deviceId); + } + + T requireWearableCapability( + Wearable wearable, { + required String action, + }) { + if (!wearable.hasCapability()) { + throw UnsupportedError( + 'Action "$action" requires capability $T on ${wearable.deviceId}.', + ); + } + return wearable.requireCapability(); + } +} diff --git a/open_wearable/lib/models/connectors/commands/disconnect_command.dart b/open_wearable/lib/models/connectors/commands/disconnect_command.dart new file mode 100644 index 00000000..e3977729 --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/disconnect_command.dart @@ -0,0 +1,20 @@ +import 'command.dart'; +import 'param_readers.dart'; +import 'runtime_command.dart'; + +class DisconnectCommand extends RuntimeCommand { + DisconnectCommand({required super.runtime}) + : super( + name: 'disconnect', + params: [ + CommandParam(name: 'device_id', required: true), + ], + ); + + @override + Future> execute(List params) { + return runtime.disconnect( + deviceId: requireStringParam(params, 'device_id'), + ); + } +} diff --git a/open_wearable/lib/models/connectors/commands/get_discovered_devices_command.dart b/open_wearable/lib/models/connectors/commands/get_discovered_devices_command.dart new file mode 100644 index 00000000..19f824ca --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/get_discovered_devices_command.dart @@ -0,0 +1,12 @@ +import 'command.dart'; +import 'runtime_command.dart'; + +class GetDiscoveredDevicesCommand extends RuntimeCommand { + GetDiscoveredDevicesCommand({required super.runtime}) + : super(name: 'get_discovered_devices'); + + @override + Future>> execute(List params) { + return runtime.getDiscoveredDevices(); + } +} diff --git a/open_wearable/lib/models/connectors/commands/has_permissions_command.dart b/open_wearable/lib/models/connectors/commands/has_permissions_command.dart new file mode 100644 index 00000000..f991fdba --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/has_permissions_command.dart @@ -0,0 +1,12 @@ +import 'command.dart'; +import 'runtime_command.dart'; + +class HasPermissionsCommand extends RuntimeCommand { + HasPermissionsCommand({required super.runtime}) + : super(name: 'has_permissions'); + + @override + Future execute(List params) { + return runtime.hasPermissions(); + } +} diff --git a/open_wearable/lib/models/connectors/commands/invoke_action_command.dart b/open_wearable/lib/models/connectors/commands/invoke_action_command.dart new file mode 100644 index 00000000..5b5694e1 --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/invoke_action_command.dart @@ -0,0 +1,24 @@ +import 'command.dart'; +import 'param_readers.dart'; +import 'runtime_command.dart'; + +class InvokeActionCommand extends RuntimeCommand { + InvokeActionCommand({required super.runtime}) + : super( + name: 'invoke_action', + params: [ + CommandParam(name: 'device_id', required: true), + CommandParam(name: 'action', required: true), + CommandParam>(name: 'args'), + ], + ); + + @override + Future execute(List params) { + return runtime.invokeAction( + deviceId: requireStringParam(params, 'device_id'), + action: requireStringParam(params, 'action'), + args: readOptionalMapParam(params, 'args'), + ); + } +} diff --git a/open_wearable/lib/models/connectors/commands/ipc_internal_param_names.dart b/open_wearable/lib/models/connectors/commands/ipc_internal_param_names.dart new file mode 100644 index 00000000..ea776941 --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/ipc_internal_param_names.dart @@ -0,0 +1 @@ +const String sessionParamName = '__session'; diff --git a/open_wearable/lib/models/connectors/commands/list_connected_command.dart b/open_wearable/lib/models/connectors/commands/list_connected_command.dart new file mode 100644 index 00000000..ddf0ff72 --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/list_connected_command.dart @@ -0,0 +1,12 @@ +import 'command.dart'; +import 'runtime_command.dart'; + +class ListConnectedCommand extends RuntimeCommand { + ListConnectedCommand({required super.runtime}) + : super(name: 'list_connected'); + + @override + Future>> execute(List params) { + return runtime.listConnected(); + } +} diff --git a/open_wearable/lib/models/connectors/commands/list_sensor_configs_command.dart b/open_wearable/lib/models/connectors/commands/list_sensor_configs_command.dart new file mode 100644 index 00000000..498c5d79 --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/list_sensor_configs_command.dart @@ -0,0 +1,49 @@ +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'command.dart'; +import 'device_command.dart'; + +class ListSensorConfigsCommand extends DeviceCommand { + ListSensorConfigsCommand({required super.runtime}) + : super(name: 'list_sensor_configurations'); + + @override + Future>> execute(List params) async { + final wearable = await getWearable(params); + final manager = requireWearableCapability( + wearable, + action: name, + ); + + return _serializeSensorConfigurations(manager); + } + + List> _serializeSensorConfigurations( + SensorConfigurationManager manager, + ) { + return manager.sensorConfigurations.map((configuration) { + return { + 'name': configuration.name, + 'unit': configuration.unit, + 'values': configuration.values + .map(_serializeSensorConfigurationValue) + .toList(), + 'off_value': configuration.offValue?.key, + }; + }).toList(); + } + + Map _serializeSensorConfigurationValue( + SensorConfigurationValue value, + ) { + final payload = {'key': value.key}; + + if (value is SensorFrequencyConfigurationValue) { + payload['frequency_hz'] = value.frequencyHz; + } + if (value is ConfigurableSensorConfigurationValue) { + payload['options'] = value.options.map((option) => option.name).toList(); + } + + return payload; + } +} diff --git a/open_wearable/lib/models/connectors/commands/list_sensors_command.dart b/open_wearable/lib/models/connectors/commands/list_sensors_command.dart new file mode 100644 index 00000000..d072d161 --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/list_sensors_command.dart @@ -0,0 +1,42 @@ +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'command.dart'; +import 'device_command.dart'; + +class ListSensorsCommand extends DeviceCommand { + ListSensorsCommand({required super.runtime}) : super(name: 'list_sensors'); + + @override + Future>> execute(List params) async { + final wearable = await getWearable(params); + final manager = requireWearableCapability( + wearable, + action: name, + ); + return _serializeSensors(manager); + } + + List> _serializeSensors(SensorManager manager) { + final sensors = manager.sensors; + return [ + for (var index = 0; index < sensors.length; index++) + { + 'sensor_id': _sensorId(sensors[index], index), + 'sensor_index': index, + 'name': sensors[index].sensorName, + 'chart_title': sensors[index].chartTitle, + 'short_chart_title': sensors[index].shortChartTitle, + 'axis_names': sensors[index].axisNames, + 'axis_units': sensors[index].axisUnits, + 'timestamp_exponent': sensors[index].timestampExponent, + }, + ]; + } + + String _sensorId(Sensor sensor, int index) { + final normalized = sensor.sensorName + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9]+'), '_') + .replaceAll(RegExp(r'^_+|_+$'), ''); + return '${normalized}_$index'; + } +} diff --git a/open_wearable/lib/models/connectors/commands/methods_command.dart b/open_wearable/lib/models/connectors/commands/methods_command.dart new file mode 100644 index 00000000..92a1d851 --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/methods_command.dart @@ -0,0 +1,10 @@ +import 'command.dart'; +import 'runtime_command.dart'; + +class MethodsCommand extends RuntimeCommand { + MethodsCommand({required super.runtime}) : super(name: 'methods'); + + @override + Future> execute(List params) async => + runtime.methods; +} diff --git a/open_wearable/lib/models/connectors/commands/param_readers.dart b/open_wearable/lib/models/connectors/commands/param_readers.dart new file mode 100644 index 00000000..0dee31fa --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/param_readers.dart @@ -0,0 +1,84 @@ +import 'command.dart'; + +String requireStringParam(List params, String name) { + final Object? value = params.firstWhere((p) => p.name == name).value; + if (value is String) { + return value; + } + throw FormatException('Expected "$name" to be a string.'); +} + +int requireIntParam(List params, String name) { + final Object? value = params.firstWhere((p) => p.name == name).value; + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + if (value is String) { + final int? parsed = int.tryParse(value); + if (parsed != null) { + return parsed; + } + } + throw FormatException('Expected "$name" to be an integer.'); +} + +bool? readOptionalBoolParam(List params, String name) { + final CommandParam? param = params.where((p) => p.name == name).firstOrNull; + if (param == null || param.value == null) { + return null; + } + if (param.value is bool) { + return param.value as bool; + } + throw FormatException('Expected "$name" to be a boolean.'); +} + +Map readOptionalMapParam( + List params, + String name, +) { + final CommandParam? param = params.where((p) => p.name == name).firstOrNull; + final Object? value = param?.value; + if (value == null) { + return {}; + } + if (value is Map) { + return value; + } + if (value is Map) { + return value + .map((key, dynamic mapValue) => MapEntry(key.toString(), mapValue)); + } + throw FormatException('Expected "$name" to be an object.'); +} + +List readOptionalStringListParam( + List params, + String name, +) { + final CommandParam? param = params.where((p) => p.name == name).firstOrNull; + final Object? value = param?.value; + if (value == null) { + return []; + } + if (value is List) { + return value.map((item) => item.toString()).toList(growable: false); + } + throw FormatException('Expected "$name" to be a list.'); +} + +Object? requireParam(List params, String name) { + return params.firstWhere((p) => p.name == name).value; +} + +extension on Iterable { + T? get firstOrNull { + if (isEmpty) { + return null; + } + return first; + } +} diff --git a/open_wearable/lib/models/connectors/commands/ping_command.dart b/open_wearable/lib/models/connectors/commands/ping_command.dart new file mode 100644 index 00000000..79433952 --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/ping_command.dart @@ -0,0 +1,9 @@ +import 'package:open_wearable/models/connectors/commands/command.dart'; + +class PingCommand extends Command { + PingCommand() : super(name: 'ping'); + + @override + Future> execute(List params) async => + {'ok': true}; +} diff --git a/open_wearable/lib/models/connectors/commands/runtime.dart b/open_wearable/lib/models/connectors/commands/runtime.dart new file mode 100644 index 00000000..07dbbc59 --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/runtime.dart @@ -0,0 +1,53 @@ +import 'package:open_earable_flutter/open_earable_flutter.dart'; + +abstract class CommandRuntime { + List get methods; + + Future hasPermissions(); + + Future checkAndRequestPermissions(); + + Future> startScan({ + bool checkAndRequestPermissions = true, + }); + + Future>> getDiscoveredDevices(); + + Future> connect({ + required String deviceId, + bool connectedViaSystem = false, + }); + + Future>> connectSystemDevices({ + List ignoredDeviceIds = const [], + }); + + Future>> listConnected(); + + Future> disconnect({ + required String deviceId, + }); + + Future createSubscriptionId(); + + Future attachStreamSubscription({ + required dynamic session, + required int subscriptionId, + required String streamName, + required String deviceId, + required Stream stream, + }); + + Future> unsubscribe({ + required dynamic session, + required int subscriptionId, + }); + + Future invokeAction({ + required String deviceId, + required String action, + Map args = const {}, + }); + + Future getWearable({required String deviceId}); +} diff --git a/open_wearable/lib/models/connectors/commands/runtime_command.dart b/open_wearable/lib/models/connectors/commands/runtime_command.dart new file mode 100644 index 00000000..71616450 --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/runtime_command.dart @@ -0,0 +1,12 @@ +import 'command.dart'; +import 'runtime.dart'; + +abstract class RuntimeCommand extends Command { + final CommandRuntime runtime; + + RuntimeCommand({ + required super.name, + required this.runtime, + super.params, + }); +} diff --git a/open_wearable/lib/models/connectors/commands/set_sensor_config_command.dart b/open_wearable/lib/models/connectors/commands/set_sensor_config_command.dart new file mode 100644 index 00000000..469e9ed4 --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/set_sensor_config_command.dart @@ -0,0 +1,47 @@ +import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; +import 'command.dart'; +import 'device_command.dart'; + +class SetSensorConfigCommand extends DeviceCommand { + SetSensorConfigCommand({required super.runtime}) + : super( + name: 'set_sensor_configuration', + params: [ + CommandParam(name: 'configuration_name', required: true), + CommandParam(name: 'value_key', required: true), + ], + ); + + @override + Future> execute(List params) async { + final wearable = await getWearable(params); + final manager = requireWearableCapability( + wearable, + action: name, + ); + + final configurationName = + requireParam(params, 'configuration_name'); + final valueKey = requireParam(params, 'value_key'); + + final configuration = manager.sensorConfigurations.firstWhere( + (config) => config.name == configurationName, + orElse: () => throw ArgumentError( + 'Unknown sensor configuration: $configurationName', + ), + ); + + final value = configuration.values.firstWhere( + (value) => value.key == valueKey, + orElse: () => throw ArgumentError( + "Unknown value key '$valueKey' for configuration '$configurationName'", + ), + ); + + configuration.setConfiguration(value); + return { + 'configuration_name': configurationName, + 'value_key': valueKey, + }; + } +} diff --git a/open_wearable/lib/models/connectors/commands/start_scan_command.dart b/open_wearable/lib/models/connectors/commands/start_scan_command.dart new file mode 100644 index 00000000..56ba1dbe --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/start_scan_command.dart @@ -0,0 +1,22 @@ +import 'command.dart'; +import 'param_readers.dart'; +import 'runtime_command.dart'; + +class StartScanCommand extends RuntimeCommand { + StartScanCommand({required super.runtime}) + : super( + name: 'start_scan', + params: [ + CommandParam(name: 'check_and_request_permissions'), + ], + ); + + @override + Future> execute(List params) { + return runtime.startScan( + checkAndRequestPermissions: + readOptionalBoolParam(params, 'check_and_request_permissions') ?? + true, + ); + } +} diff --git a/open_wearable/lib/models/connectors/commands/subscribe_command.dart b/open_wearable/lib/models/connectors/commands/subscribe_command.dart new file mode 100644 index 00000000..79bc32a2 --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/subscribe_command.dart @@ -0,0 +1,179 @@ +import 'package:open_earable_flutter/open_earable_flutter.dart'; + +import 'command.dart'; +import 'ipc_internal_param_names.dart'; +import 'param_readers.dart'; +import 'runtime_command.dart'; + +class SubscribeCommand extends RuntimeCommand { + SubscribeCommand({required super.runtime}) + : super( + name: 'subscribe', + params: [ + CommandParam(name: 'device_id', required: true), + CommandParam(name: 'stream', required: true), + CommandParam>(name: 'args'), + CommandParam(name: sessionParamName, required: true), + ], + ); + + @override + Future> execute(List params) async { + final session = requireParam(params, sessionParamName); + final deviceId = requireStringParam(params, 'device_id'); + final streamName = requireStringParam(params, 'stream'); + final args = readOptionalMapParam(params, 'args'); + final wearable = await runtime.getWearable(deviceId: deviceId); + + final Stream stream = _resolveStream( + wearable: wearable, + streamName: streamName, + args: args, + ); + + final subscriptionId = await runtime.createSubscriptionId(); + await runtime.attachStreamSubscription( + session: session, + subscriptionId: subscriptionId, + streamName: streamName, + deviceId: wearable.deviceId, + stream: stream, + ); + + return { + 'subscription_id': subscriptionId, + 'stream': streamName, + 'device_id': wearable.deviceId, + }; + } + + Stream _resolveStream({ + required Wearable wearable, + required String streamName, + required Map args, + }) { + switch (streamName) { + case 'sensor_values': + return _resolveSensor( + wearable: wearable, + args: args, + ).sensorStream; + case 'sensor_configuration': + return _requireCapability( + wearable: wearable, + streamName: streamName, + ).sensorConfigurationStream; + case 'button_events': + return _requireCapability( + wearable: wearable, + streamName: streamName, + ).buttonEvents; + case 'battery_percentage': + return _requireCapability( + wearable: wearable, + streamName: streamName, + ).batteryPercentageStream; + case 'battery_power_status': + return _requireCapability( + wearable: wearable, + streamName: streamName, + ).powerStatusStream; + case 'battery_health_status': + return _requireCapability( + wearable: wearable, + streamName: streamName, + ).healthStatusStream; + case 'battery_energy_status': + return _requireCapability( + wearable: wearable, + streamName: streamName, + ).energyStatusStream; + default: + throw UnsupportedError('Unknown stream: $streamName'); + } + } + + Sensor _resolveSensor({ + required Wearable wearable, + required Map args, + }) { + final manager = _requireCapability( + wearable: wearable, + streamName: 'sensor_values', + ); + final sensors = manager.sensors; + if (sensors.isEmpty) { + throw StateError('Wearable has no sensors.'); + } + + if (args['sensor_id'] != null) { + final sensorId = args['sensor_id'].toString(); + for (var i = 0; i < sensors.length; i++) { + if (_sensorId(sensors[i], i) == sensorId) { + return sensors[i]; + } + } + throw StateError('Unknown sensor_id: $sensorId'); + } + + if (args['sensor_index'] != null) { + final index = _asInt(args['sensor_index'], name: 'sensor_index'); + if (index < 0 || index >= sensors.length) { + throw RangeError.index(index, sensors, 'sensor_index'); + } + return sensors[index]; + } + + if (args['sensor_name'] != null) { + final name = args['sensor_name'].toString(); + final matched = + sensors.where((sensor) => sensor.sensorName == name).toList(); + if (matched.length != 1) { + throw StateError( + 'sensor_name must resolve to exactly one sensor. Matches: ${matched.length}', + ); + } + return matched.first; + } + + throw ArgumentError( + 'sensor_values subscription requires one of sensor_id, sensor_index, or sensor_name.', + ); + } + + T _requireCapability({ + required Wearable wearable, + required String streamName, + }) { + if (!wearable.hasCapability()) { + throw UnsupportedError( + 'Stream "$streamName" requires capability $T on ${wearable.deviceId}.', + ); + } + return wearable.requireCapability(); + } + + String _sensorId(Sensor sensor, int index) { + final normalized = sensor.sensorName + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9]+'), '_') + .replaceAll(RegExp(r'^_+|_+$'), ''); + return '${normalized}_$index'; + } + + int _asInt(Object? value, {required String name}) { + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + if (value is String) { + final parsed = int.tryParse(value); + if (parsed != null) { + return parsed; + } + } + throw FormatException('Expected "$name" to be an integer.'); + } +} diff --git a/open_wearable/lib/models/connectors/commands/sync_time_command.dart b/open_wearable/lib/models/connectors/commands/sync_time_command.dart new file mode 100644 index 00000000..eeb7f6b8 --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/sync_time_command.dart @@ -0,0 +1,18 @@ +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'device_command.dart'; + +import 'command.dart'; + +class SyncTimeCommand extends DeviceCommand { + SyncTimeCommand({required super.runtime}) : super(name: 'synchronize_time'); + + @override + Future> execute(List params) async { + final wearable = await getWearable(params); + await requireWearableCapability( + wearable, + action: name, + ).synchronizeTime(); + return {'synchronized': true}; + } +} diff --git a/open_wearable/lib/models/connectors/commands/unsubscribe_command.dart b/open_wearable/lib/models/connectors/commands/unsubscribe_command.dart new file mode 100644 index 00000000..efcf3cff --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/unsubscribe_command.dart @@ -0,0 +1,23 @@ +import 'command.dart'; +import 'ipc_internal_param_names.dart'; +import 'param_readers.dart'; +import 'runtime_command.dart'; + +class UnsubscribeCommand extends RuntimeCommand { + UnsubscribeCommand({required super.runtime}) + : super( + name: 'unsubscribe', + params: [ + CommandParam(name: 'subscription_id', required: true), + CommandParam(name: sessionParamName, required: true), + ], + ); + + @override + Future> execute(List params) { + return runtime.unsubscribe( + session: requireParam(params, sessionParamName), + subscriptionId: requireIntParam(params, 'subscription_id'), + ); + } +} diff --git a/open_wearable/lib/models/connectors/websocket_ipc_server.dart b/open_wearable/lib/models/connectors/websocket_ipc_server.dart index 36a8c34f..3d969bf8 100644 --- a/open_wearable/lib/models/connectors/websocket_ipc_server.dart +++ b/open_wearable/lib/models/connectors/websocket_ipc_server.dart @@ -2,10 +2,16 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; +import 'package:open_wearable/models/connectors/commands/command.dart'; +import 'package:open_wearable/models/connectors/commands/default_action_commands.dart'; +import 'package:open_wearable/models/connectors/commands/default_ipc_commands.dart'; +import 'package:open_wearable/models/connectors/commands/ipc_internal_param_names.dart'; +import 'package:open_wearable/models/connectors/commands/runtime.dart'; +import 'package:open_wearable/models/logger.dart'; import 'package:open_wearable/models/wearable_connector.dart'; -class WebSocketIpcServer { +class WebSocketIpcServer implements CommandRuntime { static const String defaultHost = '127.0.0.1'; static const int defaultPort = 8765; static const String defaultPath = '/ws'; @@ -28,12 +34,21 @@ class WebSocketIpcServer { StreamSubscription? _connectSubscription; int _nextSubscriptionId = 1; + final Map _topLevelCommands = {}; + final Map _actionCommands = {}; WebSocketIpcServer({ WearableManager? wearableManager, WearableConnector? wearableConnector, }) : _wearableManager = wearableManager ?? WearableManager(), - _wearableConnector = wearableConnector ?? WearableConnector(); + _wearableConnector = wearableConnector ?? WearableConnector() { + for (final command in createDefaultIpcCommands(this)) { + addCommand(command); + } + for (final command in createDefaultActionCommands(this)) { + addActionCommand(command); + } + } bool get isRunning => _httpServer != null; @@ -110,87 +125,69 @@ class WebSocketIpcServer { _clients.remove(client); } - List get methods => const [ - 'ping', - 'methods', - 'has_permissions', - 'check_and_request_permissions', - 'start_scan', - 'get_discovered_devices', - 'connect', - 'connect_system_devices', - 'list_connected', - 'disconnect', - 'set_auto_connect', - 'get_wearable', - 'get_actions', - 'invoke_action', - 'subscribe', - 'unsubscribe', - ]; + @override + List get methods => _topLevelCommands.keys.toList(growable: false); + + void addCommand(Command command) { + _topLevelCommands[command.name] = command; + } + + void addActionCommand(Command command) { + _actionCommands[command.name] = command; + } Future _handleRequest({ required _ClientSession client, required String method, required Map params, }) async { - switch (method) { - case 'ping': - return {'ok': true}; - case 'methods': - return methods; - case 'has_permissions': - return _wearableManager.hasPermissions(); - case 'check_and_request_permissions': - return WearableManager.checkAndRequestPermissions(); - case 'start_scan': - final checkAndRequestPermissions = - _asOptionalBool(params['check_and_request_permissions']) ?? true; - _discoveredDevicesById.clear(); - await _wearableManager.startScan( - checkAndRequestPermissions: checkAndRequestPermissions, - ); - return {'started': true}; - case 'get_discovered_devices': - return _discoveredDevicesById.values.map(_serializeDiscovered).toList(); - case 'connect': - return _connect(params); - case 'connect_system_devices': - return _connectSystemDevices(params); - case 'list_connected': - return _connectedWearablesById.values - .map(_serializeWearableSummary) - .toList(); - case 'disconnect': - return _disconnect(params); - case 'set_auto_connect': - return _setAutoConnect(params); - case 'get_wearable': - return _getWearable(params); - case 'get_actions': - return _getActions(params); - case 'invoke_action': - return _invokeAction(params); - case 'subscribe': - return _subscribe(client, params); - case 'unsubscribe': - return client.unsubscribe( - _asInt(params['subscription_id'], name: 'subscription_id'), - ); - default: - throw UnsupportedError('Unknown method: $method'); + logger.d("Received request: method=$method, params=$params"); + + final command = _topLevelCommands[method]; + if (command == null) { + throw UnsupportedError('Unknown method: $method'); } + return command.run(_paramsToCommandParams(params, session: client)); + } + + @override + Future getWearable({required String deviceId}) async { + return _requireConnectedWearable(deviceId); + } + + @override + Future hasPermissions() => _wearableManager.hasPermissions(); + + @override + Future checkAndRequestPermissions() => + WearableManager.checkAndRequestPermissions(); + + @override + Future> startScan({ + bool checkAndRequestPermissions = true, + }) async { + _discoveredDevicesById.clear(); + await _wearableManager.startScan( + checkAndRequestPermissions: checkAndRequestPermissions, + ); + return {'started': true}; } - Future> _connect(Map params) async { - final deviceId = _asString(params['device_id'], name: 'device_id'); + @override + Future>> getDiscoveredDevices() async { + return _discoveredDevicesById.values.map(_serializeDiscovered).toList(); + } + + @override + Future> connect({ + required String deviceId, + bool connectedViaSystem = false, + }) async { final discovered = _discoveredDevicesById[deviceId]; if (discovered == null) { throw StateError('Device not found in discovered devices: $deviceId'); } - final connectedViaSystem = - _asOptionalBool(params['connected_via_system']) ?? false; final options = connectedViaSystem ? {const ConnectedViaSystem()} : const {}; @@ -203,12 +200,12 @@ class WebSocketIpcServer { return _serializeWearableSummary(wearable); } - Future>> _connectSystemDevices( - Map params, - ) async { - final ignoredIds = _asStringList(params['ignored_device_ids']); + @override + Future>> connectSystemDevices({ + List ignoredDeviceIds = const [], + }) async { final wearables = await _wearableConnector.connectToSystemDevices( - ignoredDeviceIds: ignoredIds, + ignoredDeviceIds: ignoredDeviceIds, ); for (final wearable in wearables) { _registerConnectedWearable(wearable); @@ -216,316 +213,85 @@ class WebSocketIpcServer { return wearables.map(_serializeWearableSummary).toList(); } - Future> _disconnect(Map params) async { - final deviceId = _asString(params['device_id'], name: 'device_id'); + @override + Future>> listConnected() async { + return _connectedWearablesById.values + .map(_serializeWearableSummary) + .toList(); + } + + @override + Future> disconnect({ + required String deviceId, + }) async { final wearable = _requireConnectedWearable(deviceId); await wearable.disconnect(); _connectedWearablesById.remove(deviceId); return {'disconnected': true}; } - Map _setAutoConnect(Map params) { - final deviceIds = _asStringList(params['device_ids']); - _wearableManager.setAutoConnect(deviceIds); - return {'device_ids': deviceIds}; + @override + Future createSubscriptionId() async { + return _nextSubscriptionId++; } - Map _getWearable(Map params) { - final wearable = _requireConnectedWearable( - _asString(params['device_id'], name: 'device_id'), + @override + Future attachStreamSubscription({ + required dynamic session, + required int subscriptionId, + required String streamName, + required String deviceId, + required Stream stream, + }) async { + final _ClientSession client = session as _ClientSession; + await client.subscribe( + subscriptionId: subscriptionId, + streamName: streamName, + deviceId: deviceId, + stream: stream, + serializer: _serializeStreamData, ); - - final details = _serializeWearableSummary(wearable); - details['sensors'] = _serializeSensors(wearable); - details['sensor_configurations'] = _serializeSensorConfigurations(wearable); - details['actions'] = _actionsForWearable(wearable); - details['streams'] = _streamsForWearable(wearable); - return details; } - List _getActions(Map params) { - final wearable = _requireConnectedWearable( - _asString(params['device_id'], name: 'device_id'), - ); - return _actionsForWearable(wearable); + @override + Future> unsubscribe({ + required dynamic session, + required int subscriptionId, + }) async { + final _ClientSession client = session as _ClientSession; + return client.unsubscribe(subscriptionId); } - Future _invokeAction(Map params) async { - final wearable = _requireConnectedWearable( - _asString(params['device_id'], name: 'device_id'), - ); - final action = _asString(params['action'], name: 'action'); - final args = _asMap(params['args']); - - switch (action) { - case 'disconnect': - await wearable.disconnect(); - _connectedWearablesById.remove(wearable.deviceId); - return {'disconnected': true}; - case 'get_wearable_icon_path': - return wearable.getWearableIconPath( - darkmode: _asOptionalBool(args['darkmode']) ?? false, - ); - case 'list_sensors': - return _serializeSensors(wearable); - case 'list_sensor_configurations': - return _serializeSensorConfigurations(wearable); - case 'set_sensor_configuration': - return _setSensorConfiguration(wearable, args); - case 'set_sensor_frequency_best_effort': - return _setSensorFrequencyBestEffort(wearable, args); - case 'set_sensor_maximum_frequency': - return _setSensorMaximumFrequency(wearable, args); - case 'read_device_identifier': - return _requireCapability( - wearable, - action: action, - ).readDeviceIdentifier(); - case 'read_device_firmware_version': - return _requireCapability( - wearable, - action: action, - ).readDeviceFirmwareVersion(); - case 'read_firmware_version_number': - return (await _requireCapability( - wearable, - action: action, - ).readFirmwareVersionNumber()) - ?.toString(); - case 'check_firmware_support': - return (await _requireCapability( - wearable, - action: action, - ).checkFirmwareSupport()) - .name; - case 'read_device_hardware_version': - return _requireCapability( - wearable, - action: action, - ).readDeviceHardwareVersion(); - case 'write_led_color': - await _requireCapability( - wearable, - action: action, - ).writeLedColor( - r: _asInt(args['r'], name: 'r'), - g: _asInt(args['g'], name: 'g'), - b: _asInt(args['b'], name: 'b'), - ); - return {'ok': true}; - case 'show_status': - await _requireCapability( - wearable, - action: action, - ).showStatus(_asRequiredBool(args['status'], name: 'status')); - return {'ok': true}; - case 'read_battery_percentage': - return _requireCapability( - wearable, - action: action, - ).readBatteryPercentage(); - case 'read_power_status': - return _serializeBatteryPowerStatus( - await _requireCapability( - wearable, - action: action, - ).readPowerStatus(), - ); - case 'read_health_status': - return _serializeBatteryHealthStatus( - await _requireCapability( - wearable, - action: action, - ).readHealthStatus(), - ); - case 'read_energy_status': - return _serializeBatteryEnergyStatus( - await _requireCapability( - wearable, - action: action, - ).readEnergyStatus(), - ); - case 'play_frequency': - await _playFrequency(wearable, args); - return {'ok': true}; - case 'list_wave_types': - return _requireCapability( - wearable, - action: action, - ).supportedFrequencyPlayerWaveTypes.map((w) => w.key).toList(); - case 'play_jingle': - await _playJingle(wearable, args); - return {'ok': true}; - case 'list_jingles': - return _requireCapability( - wearable, - action: action, - ).supportedJingles.map((j) => j.key).toList(); - case 'start_audio': - await _requireCapability( - wearable, - action: action, - ).startAudio(); - return {'ok': true}; - case 'pause_audio': - await _requireCapability( - wearable, - action: action, - ).pauseAudio(); - return {'ok': true}; - case 'stop_audio': - await _requireCapability( - wearable, - action: action, - ).stopAudio(); - return {'ok': true}; - case 'play_audio_from_storage_path': - await _requireCapability( - wearable, - action: action, - ).playAudioFromStoragePath( - _asString(args['filepath'], name: 'filepath')); - return {'ok': true}; - case 'list_audio_modes': - return _requireCapability( - wearable, - action: action, - ).availableAudioModes.map((mode) => mode.key).toList(); - case 'set_audio_mode': - _setAudioMode(wearable, args); - return {'ok': true}; - case 'get_audio_mode': - return (await _requireCapability( - wearable, - action: action, - ).getAudioMode()) - .key; - case 'list_microphones': - return _listMicrophones(wearable); - case 'set_microphone': - await _setMicrophone(wearable, args); - return {'ok': true}; - case 'get_microphone': - return _getMicrophone(wearable); - case 'get_file_prefix': - return _requireCapability( - wearable, - action: action, - ).filePrefix; - case 'set_file_prefix': - await _requireCapability( - wearable, - action: action, - ).setFilePrefix(_asString(args['prefix'], name: 'prefix')); - return {'ok': true}; - case 'get_position': - final position = await _requireCapability( - wearable, - action: action, - ).position; - return position?.name; - case 'pair': - await _pairWearable(wearable, args); - return {'ok': true}; - case 'unpair': - await _requireCapability( - wearable, - action: action, - ).unpair(); - return {'ok': true}; - case 'is_connected_via_system': - return _requireCapability( - wearable, - action: action, - ).isConnectedViaSystem; - case 'is_time_synchronized': - return _requireCapability( - wearable, - action: action, - ).isTimeSynchronized; - case 'synchronize_time': - await _requireCapability( - wearable, - action: action, - ).synchronizeTime(); - return {'ok': true}; - case 'measure_audio_response': - case 'measure_freq_response': - return _measureAudioResponse(wearable, args); - default: - throw UnsupportedError('Unsupported action: $action'); + @override + Future invokeAction({ + required String deviceId, + required String action, + Map args = const {}, + }) async { + final command = _actionCommands[action]; + if (command == null) { + throw UnsupportedError('Unsupported action: $action'); } + final actionParams = >[ + CommandParam(name: 'device_id', value: deviceId), + ..._paramsToCommandParams(args, session: null), + ]; + return command.run(actionParams); } - Future> _subscribe( - _ClientSession client, - Map params, - ) async { - final wearable = _requireConnectedWearable( - _asString(params['device_id'], name: 'device_id'), - ); - final streamName = _asString(params['stream'], name: 'stream'); - final args = _asMap(params['args']); - - final Stream stream; - switch (streamName) { - case 'sensor_values': - stream = _resolveSensor(wearable, args).sensorStream; - break; - case 'sensor_configuration': - stream = _requireCapability( - wearable, - action: 'subscribe:$streamName', - ).sensorConfigurationStream; - break; - case 'button_events': - stream = _requireCapability( - wearable, - action: 'subscribe:$streamName', - ).buttonEvents; - break; - case 'battery_percentage': - stream = _requireCapability( - wearable, - action: 'subscribe:$streamName', - ).batteryPercentageStream; - break; - case 'battery_power_status': - stream = _requireCapability( - wearable, - action: 'subscribe:$streamName', - ).powerStatusStream; - break; - case 'battery_health_status': - stream = _requireCapability( - wearable, - action: 'subscribe:$streamName', - ).healthStatusStream; - break; - case 'battery_energy_status': - stream = _requireCapability( - wearable, - action: 'subscribe:$streamName', - ).energyStatusStream; - break; - default: - throw UnsupportedError('Unknown stream: $streamName'); + List> _paramsToCommandParams( + Map params, { + required _ClientSession? session, + }) { + final commandParams = >[]; + if (session != null) { + commandParams + .add(CommandParam(name: sessionParamName, value: session)); } - - final subscriptionId = _nextSubscriptionId++; - await client.subscribe( - subscriptionId: subscriptionId, - streamName: streamName, - deviceId: wearable.deviceId, - stream: stream, - serializer: _serializeStreamData, - ); - - return { - 'subscription_id': subscriptionId, - 'stream': streamName, - 'device_id': wearable.deviceId, - }; + params.forEach((key, value) { + commandParams.add(CommandParam(name: key, value: value)); + }); + return commandParams; } void _attachManagerSubscriptions() { @@ -583,405 +349,6 @@ class WebSocketIpcServer { ); } - Sensor _resolveSensor(Wearable wearable, Map args) { - final sensorManager = wearable.getCapability(); - if (sensorManager == null) { - throw StateError('Wearable has no SensorManager capability.'); - } - - final sensors = sensorManager.sensors; - if (sensors.isEmpty) { - throw StateError('Wearable has no sensors.'); - } - - final sensorId = args['sensor_id']; - if (sensorId != null) { - final id = _asString(sensorId, name: 'sensor_id'); - for (var i = 0; i < sensors.length; i++) { - if (_sensorId(sensors[i], i) == id) { - return sensors[i]; - } - } - throw StateError('Unknown sensor_id: $id'); - } - - final sensorIndex = args['sensor_index']; - if (sensorIndex != null) { - final index = _asInt(sensorIndex, name: 'sensor_index'); - if (index < 0 || index >= sensors.length) { - throw RangeError.index(index, sensors, 'sensor_index'); - } - return sensors[index]; - } - - final sensorName = args['sensor_name']; - if (sensorName != null) { - final name = _asString(sensorName, name: 'sensor_name'); - final matched = - sensors.where((sensor) => sensor.sensorName == name).toList(); - if (matched.length != 1) { - throw StateError( - 'sensor_name must resolve to exactly one sensor. Matches: ${matched.length}', - ); - } - return matched.first; - } - - throw ArgumentError( - 'sensor_values subscription requires one of sensor_id, sensor_index, or sensor_name.', - ); - } - - Map _setSensorConfiguration( - Wearable wearable, - Map args, - ) { - final config = _requireSensorConfiguration( - wearable, - _asString(args['configuration_name'], name: 'configuration_name'), - ); - final valueKey = _asString(args['value_key'], name: 'value_key'); - final selected = config.values.where((v) => v.key == valueKey).firstOrNull; - if (selected == null) { - throw StateError( - 'Value "$valueKey" not found for configuration ${config.name}.', - ); - } - - _applyConfiguration(config, selected); - return { - 'configuration_name': config.name, - 'value_key': selected.key, - }; - } - - Map _setSensorFrequencyBestEffort( - Wearable wearable, - Map args, - ) { - final config = _requireSensorConfiguration( - wearable, - _asString(args['configuration_name'], name: 'configuration_name'), - ); - if (config is! SensorFrequencyConfiguration) { - throw UnsupportedError( - 'Configuration ${config.name} is not frequency-based.', - ); - } - - final targetHz = _asInt(args['target_hz'], name: 'target_hz'); - final streamData = _asOptionalBool(args['stream_data']); - final recordData = _asOptionalBool(args['record_data']); - - final selected = _selectBestEffortFrequencyValue( - config: config, - targetHz: targetHz, - streamData: streamData, - recordData: recordData, - ); - - if (selected == null) { - throw StateError('No frequency value available for ${config.name}.'); - } - - _applyConfiguration(config, selected); - return { - 'configuration_name': config.name, - 'value_key': selected.key, - 'target_hz': targetHz, - 'selected_hz': _frequencyHzForValue(selected), - }; - } - - Map _setSensorMaximumFrequency( - Wearable wearable, - Map args, - ) { - final config = _requireSensorConfiguration( - wearable, - _asString(args['configuration_name'], name: 'configuration_name'), - ); - if (config is! SensorFrequencyConfiguration) { - throw UnsupportedError( - 'Configuration ${config.name} is not frequency-based.', - ); - } - - final streamData = _asOptionalBool(args['stream_data']); - final recordData = _asOptionalBool(args['record_data']); - - final selected = _selectMaximumFrequencyValue( - config: config, - streamData: streamData, - recordData: recordData, - ); - - if (selected == null) { - throw StateError('No frequency value available for ${config.name}.'); - } - - _applyConfiguration(config, selected); - return { - 'configuration_name': config.name, - 'value_key': selected.key, - 'selected_hz': _frequencyHzForValue(selected), - }; - } - - SensorConfiguration _requireSensorConfiguration( - Wearable wearable, String name) { - final manager = wearable.getCapability(); - if (manager == null) { - throw StateError( - 'Wearable has no SensorConfigurationManager capability.'); - } - - final config = manager.sensorConfigurations - .where((configuration) => configuration.name == name) - .firstOrNull; - if (config == null) { - throw StateError('Unknown configuration: $name'); - } - return config; - } - - void _applyConfiguration( - SensorConfiguration configuration, - SensorConfigurationValue value, - ) { - final dynamic dynamicConfiguration = configuration; - dynamicConfiguration.setConfiguration(value); - } - - SensorConfigurationValue? _selectBestEffortFrequencyValue({ - required SensorFrequencyConfiguration config, - required int targetHz, - required bool? streamData, - required bool? recordData, - }) { - final values = _filterConfigValuesByOptions( - config.values, - streamData: streamData, - recordData: recordData, - ); - - if (values.isEmpty) { - return null; - } - - SensorConfigurationValue? lower; - SensorConfigurationValue? higher; - - for (final value in values) { - final hz = _frequencyHzForValue(value); - if (hz == null) { - continue; - } - - if (hz < targetHz) { - if (lower == null || hz > (_frequencyHzForValue(lower) ?? hz)) { - lower = value; - } - } else { - if (higher == null || hz < (_frequencyHzForValue(higher) ?? hz)) { - higher = value; - } - } - } - - return higher ?? lower; - } - - SensorConfigurationValue? _selectMaximumFrequencyValue({ - required SensorFrequencyConfiguration config, - required bool? streamData, - required bool? recordData, - }) { - final values = _filterConfigValuesByOptions( - config.values, - streamData: streamData, - recordData: recordData, - ); - if (values.isEmpty) { - return null; - } - - SensorConfigurationValue? currentMax; - for (final value in values) { - final hz = _frequencyHzForValue(value); - if (hz == null) { - continue; - } - if (currentMax == null || hz > (_frequencyHzForValue(currentMax) ?? hz)) { - currentMax = value; - } - } - return currentMax; - } - - List _filterConfigValuesByOptions( - List values, { - bool? streamData, - bool? recordData, - }) { - return values.where((value) { - if (value is! ConfigurableSensorConfigurationValue) { - return true; - } - - bool hasOption() { - return value.options.any((option) => option is T); - } - - if (streamData != null && - streamData != hasOption()) { - return false; - } - if (recordData != null && - recordData != hasOption()) { - return false; - } - return true; - }).toList(growable: false); - } - - double? _frequencyHzForValue(SensorConfigurationValue value) { - if (value is SensorFrequencyConfigurationValue) { - return value.frequencyHz; - } - return null; - } - - Future _playFrequency( - Wearable wearable, Map args) async { - final player = _requireCapability( - wearable, - action: 'play_frequency', - ); - final waveTypeKey = _asString(args['wave_type'], name: 'wave_type'); - final waveType = player.supportedFrequencyPlayerWaveTypes - .where((wave) => wave.key == waveTypeKey) - .firstOrNull; - if (waveType == null) { - throw StateError('Unsupported wave type: $waveTypeKey'); - } - - final frequency = _asDouble(args['frequency']) ?? 440.0; - final loudness = _asDouble(args['loudness']) ?? 1.0; - await player.playFrequency( - waveType, - frequency: frequency, - loudness: loudness, - ); - } - - Future _playJingle(Wearable wearable, Map args) async { - final player = _requireCapability( - wearable, - action: 'play_jingle', - ); - final key = _asString(args['jingle'], name: 'jingle'); - final jingle = - player.supportedJingles.where((j) => j.key == key).firstOrNull; - if (jingle == null) { - throw StateError('Unsupported jingle: $key'); - } - await player.playJingle(jingle); - } - - void _setAudioMode(Wearable wearable, Map args) { - final manager = _requireCapability( - wearable, - action: 'set_audio_mode', - ); - final key = _asString(args['audio_mode'], name: 'audio_mode'); - final mode = - manager.availableAudioModes.where((m) => m.key == key).firstOrNull; - if (mode == null) { - throw StateError('Unsupported audio_mode: $key'); - } - manager.setAudioMode(mode); - } - - List _listMicrophones(Wearable wearable) { - final manager = _requireCapability( - wearable, - action: 'list_microphones', - ); - final microphones = manager.availableMicrophones.cast(); - return microphones.map((microphone) => microphone.key.toString()).toList(); - } - - Future _setMicrophone( - Wearable wearable, Map args) async { - final manager = _requireCapability( - wearable, - action: 'set_microphone', - ); - final key = _asString(args['microphone'], name: 'microphone'); - final microphones = manager.availableMicrophones.cast(); - final dynamic selected = microphones.where((microphone) { - return microphone.key.toString() == key; - }).firstOrNull; - - if (selected == null) { - throw StateError('Unsupported microphone: $key'); - } - - manager.setMicrophone(selected); - } - - Future _getMicrophone(Wearable wearable) async { - final manager = _requireCapability( - wearable, - action: 'get_microphone', - ); - final dynamic microphone = await manager.getMicrophone(); - return microphone?.key?.toString(); - } - - Future _pairWearable( - Wearable wearable, Map args) async { - final stereo = _requireCapability( - wearable, - action: 'pair', - ); - final otherDeviceId = - _asString(args['other_device_id'], name: 'other_device_id'); - final partner = _requireConnectedWearable(otherDeviceId); - final partnerStereo = _requireCapability( - partner, - action: 'pair', - ); - await stereo.pair(partnerStereo); - } - - Future _measureAudioResponse( - Wearable wearable, - Map args, - ) async { - final dynamic dynamicWearable = wearable; - - try { - if (args.isEmpty) { - return await dynamicWearable.measureAudioResponse(); - } - return await Function.apply( - dynamicWearable.measureAudioResponse, - const [], - args.map((key, value) => MapEntry(Symbol(key), value)), - ); - } on NoSuchMethodError { - if (args.isEmpty) { - return await dynamicWearable.measureFreqResponse(); - } - return await Function.apply( - dynamicWearable.measureFreqResponse, - const [], - args.map((key, value) => MapEntry(Symbol(key), value)), - ); - } - } Map _serializeDiscovered(DiscoveredDevice device) { return { @@ -1002,61 +369,6 @@ class WebSocketIpcServer { }; } - List> _serializeSensors(Wearable wearable) { - final manager = wearable.getCapability(); - if (manager == null) { - return const >[]; - } - - final sensors = manager.sensors; - return [ - for (var index = 0; index < sensors.length; index++) - { - 'sensor_id': _sensorId(sensors[index], index), - 'sensor_index': index, - 'sensor_name': sensors[index].sensorName, - 'chart_title': sensors[index].chartTitle, - 'short_chart_title': sensors[index].shortChartTitle, - 'axis_names': sensors[index].axisNames, - 'axis_units': sensors[index].axisUnits, - 'timestamp_exponent': sensors[index].timestampExponent, - }, - ]; - } - - List> _serializeSensorConfigurations(Wearable wearable) { - final manager = wearable.getCapability(); - if (manager == null) { - return const >[]; - } - - return manager.sensorConfigurations.map((configuration) { - return { - 'name': configuration.name, - 'unit': configuration.unit, - 'values': configuration.values - .map((value) => _serializeSensorConfigurationValue(value)) - .toList(), - 'off_value': configuration.offValue?.key, - }; - }).toList(); - } - - Map _serializeSensorConfigurationValue( - SensorConfigurationValue value, - ) { - final payload = {'key': value.key}; - - if (value is SensorFrequencyConfigurationValue) { - payload['frequency_hz'] = value.frequencyHz; - } - if (value is ConfigurableSensorConfigurationValue) { - payload['options'] = value.options.map((option) => option.name).toList(); - } - - return payload; - } - Object? _serializeStreamData(dynamic data) { if (data is SensorValue) { final payload = { @@ -1112,7 +424,8 @@ class WebSocketIpcServer { } Map _serializeBatteryHealthStatus( - BatteryHealthStatus status) { + BatteryHealthStatus status, + ) { return { 'health_summary': status.healthSummary, 'cycle_count': status.cycleCount, @@ -1121,7 +434,8 @@ class WebSocketIpcServer { } Map _serializeBatteryEnergyStatus( - BatteryEnergyStatus status) { + BatteryEnergyStatus status, + ) { return { 'voltage': status.voltage, 'available_capacity': status.availableCapacity, @@ -1162,115 +476,6 @@ class WebSocketIpcServer { return capabilities; } - List _actionsForWearable(Wearable wearable) { - final actions = [ - 'disconnect', - 'get_wearable_icon_path', - 'list_sensors', - 'list_sensor_configurations', - 'set_sensor_configuration', - 'set_sensor_frequency_best_effort', - 'set_sensor_maximum_frequency', - ]; - - void addIf(List names) { - if (wearable.hasCapability()) { - actions.addAll(names); - } - } - - addIf(['read_device_identifier']); - addIf([ - 'read_device_firmware_version', - 'read_firmware_version_number', - 'check_firmware_support', - ]); - addIf(['read_device_hardware_version']); - addIf(['write_led_color']); - addIf(['show_status']); - addIf(['read_battery_percentage']); - addIf(['read_power_status']); - addIf(['read_health_status']); - addIf(['read_energy_status']); - addIf(['play_frequency', 'list_wave_types']); - addIf(['play_jingle', 'list_jingles']); - addIf( - ['start_audio', 'pause_audio', 'stop_audio']); - addIf(['play_audio_from_storage_path']); - addIf( - ['list_audio_modes', 'set_audio_mode', 'get_audio_mode']); - addIf( - ['list_microphones', 'set_microphone', 'get_microphone']); - addIf(['get_file_prefix', 'set_file_prefix']); - addIf(['get_position', 'pair', 'unpair']); - addIf(['is_connected_via_system']); - addIf( - ['is_time_synchronized', 'synchronize_time']); - - final dynamic dynamicWearable = wearable; - final hasMeasureAudioResponse = _hasDynamicMethod( - dynamicWearable, - 'measureAudioResponse', - ); - final hasMeasureFreqResponse = _hasDynamicMethod( - dynamicWearable, - 'measureFreqResponse', - ); - if (hasMeasureAudioResponse || hasMeasureFreqResponse) { - actions - .addAll(['measure_audio_response', 'measure_freq_response']); - } - - return actions; - } - - List _streamsForWearable(Wearable wearable) { - final streams = []; - if (wearable.hasCapability()) { - streams.add('sensor_values'); - } - if (wearable.hasCapability()) { - streams.add('sensor_configuration'); - } - if (wearable.hasCapability()) { - streams.add('button_events'); - } - if (wearable.hasCapability()) { - streams.add('battery_percentage'); - } - if (wearable.hasCapability()) { - streams.add('battery_power_status'); - } - if (wearable.hasCapability()) { - streams.add('battery_health_status'); - } - if (wearable.hasCapability()) { - streams.add('battery_energy_status'); - } - return streams; - } - - bool _hasDynamicMethod(dynamic target, String methodName) { - try { - // ignore: unnecessary_statements - target.noSuchMethod; - switch (methodName) { - case 'measureAudioResponse': - // ignore: unnecessary_statements - target.measureAudioResponse; - return true; - case 'measureFreqResponse': - // ignore: unnecessary_statements - target.measureFreqResponse; - return true; - default: - return false; - } - } on NoSuchMethodError { - return false; - } - } - Wearable _requireConnectedWearable(String deviceId) { final wearable = _connectedWearablesById[deviceId]; if (wearable == null) { @@ -1279,27 +484,6 @@ class WebSocketIpcServer { return wearable; } - T _requireCapability( - Wearable wearable, { - required String action, - }) { - final capability = wearable.getCapability(); - if (capability != null) { - return capability; - } - throw UnsupportedError( - 'Action "$action" requires capability $T on ${wearable.deviceId}.', - ); - } - - String _sensorId(Sensor sensor, int index) { - final normalized = sensor.sensorName - .toLowerCase() - .replaceAll(RegExp(r'[^a-z0-9]+'), '_') - .replaceAll(RegExp(r'^_+|_+$'), ''); - return '${normalized}_$index'; - } - String _normalizePath(String path) { final trimmed = path.trim(); if (trimmed.isEmpty) { @@ -1348,71 +532,6 @@ class WebSocketIpcServer { throw FormatException('Expected params/args to be an object.'); } - String _asString(Object? value, {required String name}) { - if (value is String) { - return value; - } - throw FormatException('Expected "$name" to be a string.'); - } - - bool? _asOptionalBool(Object? value) { - if (value == null) { - return null; - } - if (value is bool) { - return value; - } - throw const FormatException('Expected a boolean.'); - } - - bool _asRequiredBool(Object? value, {required String name}) { - if (value is bool) { - return value; - } - throw FormatException('Expected "$name" to be a boolean.'); - } - - int _asInt(Object? value, {required String name}) { - if (value is int) { - return value; - } - if (value is num) { - return value.toInt(); - } - if (value is String) { - final parsed = int.tryParse(value); - if (parsed != null) { - return parsed; - } - } - throw FormatException('Expected "$name" to be an integer.'); - } - - double? _asDouble(Object? value) { - if (value == null) { - return null; - } - if (value is double) { - return value; - } - if (value is num) { - return value.toDouble(); - } - if (value is String) { - return double.tryParse(value); - } - return null; - } - - List _asStringList(Object? value) { - if (value == null) { - return []; - } - if (value is List) { - return value.map((entry) => entry.toString()).toList(growable: false); - } - throw FormatException('Expected a list of strings.'); - } } class _ClientSession { @@ -1479,7 +598,8 @@ class _ClientSession { final method = request['method']; if (method is! String || method.trim().isEmpty) { throw const FormatException( - 'Request method must be a non-empty string.'); + 'Request method must be a non-empty string.', + ); } final params = server._asMap(request['params']); @@ -1591,12 +711,3 @@ class _ClientSession { server._onClientClosed(this); } } - -extension on Iterable { - T? get firstOrNull { - if (isEmpty) { - return null; - } - return first; - } -} From 3594ced216d0b330d5d808661e49bf7ccc2cba5c Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:32:41 +0100 Subject: [PATCH 6/7] feat: Add AsyncScanCommand for asynchronous scanning and event streaming --- .../commands/async_scan_command.dart | 42 +++++++++++++++++++ .../commands/default_ipc_commands.dart | 2 + .../models/connectors/commands/runtime.dart | 1 + .../connectors/websocket_ipc_server.dart | 11 ++++- 4 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 open_wearable/lib/models/connectors/commands/async_scan_command.dart diff --git a/open_wearable/lib/models/connectors/commands/async_scan_command.dart b/open_wearable/lib/models/connectors/commands/async_scan_command.dart new file mode 100644 index 00000000..80125325 --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/async_scan_command.dart @@ -0,0 +1,42 @@ +import 'command.dart'; +import 'ipc_internal_param_names.dart'; +import 'param_readers.dart'; +import 'runtime_command.dart'; + +class AsyncScanCommand extends RuntimeCommand { + AsyncScanCommand({required super.runtime}) + : super( + name: 'start_scan_async', + params: [ + CommandParam(name: 'check_and_request_permissions'), + CommandParam(name: sessionParamName, required: true), + ], + ); + + @override + Future> execute(List params) async { + final session = requireParam(params, sessionParamName); + final checkAndRequestPermissions = + readOptionalBoolParam(params, 'check_and_request_permissions') ?? true; + + await runtime.startScan( + checkAndRequestPermissions: checkAndRequestPermissions, + ); + + final subscriptionId = await runtime.createSubscriptionId(); + await runtime.attachStreamSubscription( + session: session, + subscriptionId: subscriptionId, + streamName: 'scan', + deviceId: 'scanner', + stream: runtime.scanEvents, + ); + + return { + 'started': true, + 'subscription_id': subscriptionId, + 'stream': 'scan', + 'device_id': 'scanner', + }; + } +} diff --git a/open_wearable/lib/models/connectors/commands/default_ipc_commands.dart b/open_wearable/lib/models/connectors/commands/default_ipc_commands.dart index 1eecc087..65c3fbef 100644 --- a/open_wearable/lib/models/connectors/commands/default_ipc_commands.dart +++ b/open_wearable/lib/models/connectors/commands/default_ipc_commands.dart @@ -3,6 +3,7 @@ import 'command.dart'; import 'connect_command.dart'; import 'connect_system_devices_command.dart'; import 'disconnect_command.dart'; +import 'async_scan_command.dart'; import 'get_discovered_devices_command.dart'; import 'has_permissions_command.dart'; import 'invoke_action_command.dart'; @@ -21,6 +22,7 @@ List createDefaultIpcCommands(CommandRuntime runtime) { HasPermissionsCommand(runtime: runtime), CheckAndRequestPermissionsCommand(runtime: runtime), StartScanCommand(runtime: runtime), + AsyncScanCommand(runtime: runtime), GetDiscoveredDevicesCommand(runtime: runtime), ConnectCommand(runtime: runtime), ConnectSystemDevicesCommand(runtime: runtime), diff --git a/open_wearable/lib/models/connectors/commands/runtime.dart b/open_wearable/lib/models/connectors/commands/runtime.dart index 07dbbc59..9c6507a6 100644 --- a/open_wearable/lib/models/connectors/commands/runtime.dart +++ b/open_wearable/lib/models/connectors/commands/runtime.dart @@ -12,6 +12,7 @@ abstract class CommandRuntime { }); Future>> getDiscoveredDevices(); + Stream get scanEvents; Future> connect({ required String deviceId, diff --git a/open_wearable/lib/models/connectors/websocket_ipc_server.dart b/open_wearable/lib/models/connectors/websocket_ipc_server.dart index 3d969bf8..1b44fda3 100644 --- a/open_wearable/lib/models/connectors/websocket_ipc_server.dart +++ b/open_wearable/lib/models/connectors/websocket_ipc_server.dart @@ -32,6 +32,8 @@ class WebSocketIpcServer implements CommandRuntime { StreamSubscription? _scanSubscription; StreamSubscription? _connectingSubscription; StreamSubscription? _connectSubscription; + final StreamController _scanEventsController = + StreamController.broadcast(); int _nextSubscriptionId = 1; final Map _topLevelCommands = {}; @@ -178,6 +180,9 @@ class WebSocketIpcServer implements CommandRuntime { return _discoveredDevicesById.values.map(_serializeDiscovered).toList(); } + @override + Stream get scanEvents => _scanEventsController.stream; + @override Future> connect({ required String deviceId, @@ -297,6 +302,7 @@ class WebSocketIpcServer implements CommandRuntime { void _attachManagerSubscriptions() { _scanSubscription ??= _wearableManager.scanStream.listen((device) { _discoveredDevicesById[device.id] = device; + _scanEventsController.add(device); _broadcastEvent( { 'event': 'scan', @@ -349,7 +355,6 @@ class WebSocketIpcServer implements CommandRuntime { ); } - Map _serializeDiscovered(DiscoveredDevice device) { return { 'id': device.id, @@ -370,6 +375,9 @@ class WebSocketIpcServer implements CommandRuntime { } Object? _serializeStreamData(dynamic data) { + if (data is DiscoveredDevice) { + return _serializeDiscovered(data); + } if (data is SensorValue) { final payload = { 'timestamp': data.timestamp, @@ -531,7 +539,6 @@ class WebSocketIpcServer implements CommandRuntime { } throw FormatException('Expected params/args to be an object.'); } - } class _ClientSession { From a4b0c5df5338a3134d4b303d97cf3e4a2df3aacf Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:07:15 +0100 Subject: [PATCH 7/7] feat: Add WebSocket IPC API documentation for OpenWearable connector --- .../docs/connectors/websocket-ipc-api.md | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 open_wearable/docs/connectors/websocket-ipc-api.md diff --git a/open_wearable/docs/connectors/websocket-ipc-api.md b/open_wearable/docs/connectors/websocket-ipc-api.md new file mode 100644 index 00000000..bbf0ea4b --- /dev/null +++ b/open_wearable/docs/connectors/websocket-ipc-api.md @@ -0,0 +1,205 @@ +# WebSocket IPC API + +This document describes how to communicate with the OpenWearable WebSocket connector. + +## Endpoint + +Default endpoint: + +- `ws://127.0.0.1:8765/ws` + +Notes: + +- Host, port, and path are configurable in app settings. +- The API is JSON over WebSocket text frames. + +## Message Envelopes + +Request: + +```json +{"id":1,"method":"ping","params":{}} +``` + +Success response: + +```json +{"id":1,"result":{"ok":true}} +``` + +Error response: + +```json +{ + "id": 1, + "error": { + "message": "Unknown method: foo", + "type": "UnsupportedError", + "stack": "..." + } +} +``` + +## Server Events + +On connect, the server sends: + +```json +{ + "event": "ready", + "methods": ["ping", "methods", "..."] +} +``` + +Other event messages: + +- `scan`: broadcast when a device is discovered. +- `connecting`: broadcast when a connect attempt starts. +- `connected`: broadcast when a wearable is connected. +- `stream`: stream subscription data. +- `stream_error`: error for a stream subscription. +- `stream_done`: stream finished. + +`stream` event format: + +```json +{ + "event": "stream", + "subscription_id": 1, + "stream": "sensor_values", + "device_id": "string", + "data": {} +} +``` + +## Top-Level Methods + +| Method | Params | Result | +|---|---|---| +| `ping` | `{}` | `{"ok":true}` | +| `methods` | `{}` | `string[]` | +| `has_permissions` | `{}` | `bool` | +| `check_and_request_permissions` | `{}` | `bool` | +| `start_scan` | `{"check_and_request_permissions"?:bool}` | `{"started":true}` | +| `start_scan_async` | `{"check_and_request_permissions"?:bool}` | `{"started":true,"subscription_id":int,"stream":"scan","device_id":"scanner"}` | +| `get_discovered_devices` | `{}` | `DiscoveredDevice[]` | +| `connect` | `{"device_id":string,"connected_via_system"?:bool}` | `WearableSummary` | +| `connect_system_devices` | `{"ignored_device_ids"?:string[]}` | `WearableSummary[]` | +| `list_connected` | `{}` | `WearableSummary[]` | +| `disconnect` | `{"device_id":string}` | `{"disconnected":true}` | +| `subscribe` | `{"device_id":string,"stream":string,"args"?:object}` | `{"subscription_id":int,"stream":string,"device_id":string}` | +| `unsubscribe` | `{"subscription_id":int}` | `{"subscription_id":int,"cancelled":bool}` | +| `invoke_action` | `{"device_id":string,"action":string,"args"?:object}` | depends on action | + +## Action Commands (`invoke_action`) + +Current actions: + +- `disconnect` (no `args`) +- `synchronize_time` +- `list_sensors` +- `list_sensor_configurations` +- `set_sensor_configuration` with args: + - `{"configuration_name":string,"value_key":string}` + +Examples: + +```json +{"id":10,"method":"invoke_action","params":{"device_id":"abc","action":"synchronize_time"}} +``` + +```json +{"id":11,"method":"invoke_action","params":{"device_id":"abc","action":"set_sensor_configuration","args":{"configuration_name":"Accelerometer","value_key":"100Hz"}}} +``` + +## Subscribe Streams + +Supported values for `subscribe.params.stream`: + +- `sensor_values` (requires one of below in `args`) + - `{"sensor_id":string}` (recommended) + - `{"sensor_index":int}` + - `{"sensor_name":string}` +- `sensor_configuration` +- `button_events` +- `battery_percentage` +- `battery_power_status` +- `battery_health_status` +- `battery_energy_status` + +Note: + +- `scan` is not a direct `subscribe` stream. +- Use `start_scan_async` to receive scan data via `stream` events. + +## Data Shapes + +### DiscoveredDevice + +```json +{ + "id": "string", + "name": "string", + "service_uuids": ["string"], + "manufacturer_data": [1, 2, 3], + "rssi": -56 +} +``` + +### WearableSummary + +```json +{ + "device_id": "string", + "name": "string", + "type": "OpenEarableV2", + "capabilities": ["SensorManager", "SensorConfigurationManager"] +} +``` + +### `list_sensors` item + +```json +{ + "sensor_id": "accelerometer_0", + "sensor_index": 0, + "name": "Accelerometer", + "chart_title": "Accelerometer", + "short_chart_title": "ACC", + "axis_names": ["x", "y", "z"], + "axis_units": ["m/s²", "m/s²", "m/s²"], + "timestamp_exponent": -9 +} +``` + +### `list_sensor_configurations` item + +```json +{ + "name": "Accelerometer", + "unit": "Hz", + "values": [ + { + "key": "100Hz", + "frequency_hz": 100, + "options": ["streamSensorConfigOption"] + } + ], + "off_value": "off" +} +``` + +## Suggested Workflows + +### Scan and connect + +1. Call `start_scan` or `start_scan_async`. +2. Use `get_discovered_devices` (or consume stream events from `start_scan_async`). +3. Call `connect` with selected `device_id`. + +### Sensor streaming + +1. `invoke_action` with `action="list_sensors"`. +2. Pick `sensor_id`. +3. `subscribe` with `stream="sensor_values"` and `args={"sensor_id":"..."}`. +4. `unsubscribe` when done.