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. diff --git a/open_wearable/lib/main.dart b/open_wearable/lib/main.dart index 9bea2f14..a31d8756 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' @@ -30,10 +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( + wearableConnector: wearableConnector, + ); runApp( MultiProvider( @@ -45,7 +50,7 @@ void main() async { ChangeNotifierProvider( create: (context) => SensorRecorderProvider(), ), - Provider.value(value: WearableConnector()), + Provider.value(value: wearableConnector), ChangeNotifierProvider( create: (context) => AppBannerController(), ), @@ -618,6 +623,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/models/connector_settings.dart b/open_wearable/lib/models/connector_settings.dart new file mode 100644 index 00000000..755ad319 --- /dev/null +++ b/open_wearable/lib/models/connector_settings.dart @@ -0,0 +1,226 @@ +// ignore_for_file: cancel_subscriptions + +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'; + +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 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({ + WearableConnector? wearableConnector, + }) async { + if (wearableConnector != null) { + _webSocketServer = WebSocketIpcServer( + wearableConnector: wearableConnector, + ); + } + 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/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/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..65c3fbef --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/default_ipc_commands.dart @@ -0,0 +1,35 @@ +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 'async_scan_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), + AsyncScanCommand(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..9c6507a6 --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/runtime.dart @@ -0,0 +1,54 @@ +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(); + Stream get scanEvents; + + 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 new file mode 100644 index 00000000..1b44fda3 --- /dev/null +++ b/open_wearable/lib/models/connectors/websocket_ipc_server.dart @@ -0,0 +1,720 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +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 implements CommandRuntime { + static const String defaultHost = '127.0.0.1'; + static const int defaultPort = 8765; + static const String defaultPath = '/ws'; + + final WearableManager _wearableManager; + final WearableConnector _wearableConnector; + + 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; + final StreamController _scanEventsController = + StreamController.broadcast(); + + int _nextSubscriptionId = 1; + final Map _topLevelCommands = {}; + final Map _actionCommands = {}; + + WebSocketIpcServer({ + WearableManager? wearableManager, + WearableConnector? wearableConnector, + }) : _wearableManager = wearableManager ?? WearableManager(), + _wearableConnector = wearableConnector ?? WearableConnector() { + for (final command in createDefaultIpcCommands(this)) { + addCommand(command); + } + for (final command in createDefaultActionCommands(this)) { + addActionCommand(command); + } + } + + 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); + } + + @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 { + 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}; + } + + @override + Future>> getDiscoveredDevices() async { + return _discoveredDevicesById.values.map(_serializeDiscovered).toList(); + } + + @override + Stream get scanEvents => _scanEventsController.stream; + + @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 options = connectedViaSystem + ? {const ConnectedViaSystem()} + : const {}; + + final wearable = await _wearableConnector.connect( + discovered, + options: options, + ); + _registerConnectedWearable(wearable); + return _serializeWearableSummary(wearable); + } + + @override + Future>> connectSystemDevices({ + List ignoredDeviceIds = const [], + }) async { + final wearables = await _wearableConnector.connectToSystemDevices( + ignoredDeviceIds: ignoredDeviceIds, + ); + for (final wearable in wearables) { + _registerConnectedWearable(wearable); + } + return wearables.map(_serializeWearableSummary).toList(); + } + + @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}; + } + + @override + Future createSubscriptionId() async { + return _nextSubscriptionId++; + } + + @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, + ); + } + + @override + Future> unsubscribe({ + required dynamic session, + required int subscriptionId, + }) async { + final _ClientSession client = session as _ClientSession; + return client.unsubscribe(subscriptionId); + } + + @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); + } + + List> _paramsToCommandParams( + Map params, { + required _ClientSession? session, + }) { + final commandParams = >[]; + if (session != null) { + commandParams + .add(CommandParam(name: sessionParamName, value: session)); + } + params.forEach((key, value) { + commandParams.add(CommandParam(name: key, value: value)); + }); + return commandParams; + } + + void _attachManagerSubscriptions() { + _scanSubscription ??= _wearableManager.scanStream.listen((device) { + _discoveredDevicesById[device.id] = device; + _scanEventsController.add(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, + }, + ); + } + + 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), + }; + } + + Object? _serializeStreamData(dynamic data) { + if (data is DiscoveredDevice) { + return _serializeDiscovered(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; + } + + Wearable _requireConnectedWearable(String deviceId) { + final wearable = _connectedWearablesById[deviceId]; + if (wearable == null) { + throw StateError('No connected wearable for device_id: $deviceId'); + } + return wearable; + } + + 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.'); + } +} + +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); + } +} 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/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) { 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/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/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/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, + ), ); } } 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