From acc131b7915bd6414ce7dbe1c365b334cd5ce4cc 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 01/51] 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 76832950d04d2ee257318dc9cc900e00e4731ff8 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 02/51] 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 | 9 + 6 files changed, 497 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 d0a34cce..618426fe 100644 --- a/open_wearable/lib/main.dart +++ b/open_wearable/lib/main.dart @@ -11,6 +11,7 @@ import 'package:open_wearable/models/app_shutdown_settings.dart'; import 'package:open_wearable/models/app_upgrade_coordinator.dart'; import 'package:open_wearable/models/app_upgrade_highlight.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' @@ -38,6 +39,7 @@ void main() async { initLogger(logFileManager.logger); await AutoConnectPreferences.initialize(); await AppShutdownSettings.initialize(); + await ConnectorSettings.initialize(); runApp( MultiProvider( @@ -722,6 +724,7 @@ class _MyAppState extends State with WidgetsBindingObserver { @override void dispose() { + unawaited(ConnectorSettings.dispose()); _unsupportedFirmwareSub.cancel(); _wearableEventSub.cancel(); _bleAvailabilitySub.cancel(); diff --git a/open_wearable/lib/router.dart b/open_wearable/lib/router.dart index 331c2a1b..3ddea0e2 100644 --- a/open_wearable/lib/router.dart +++ b/open_wearable/lib/router.dart @@ -10,6 +10,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 'package:open_wearable/widgets/updates/app_upgrade_history_page.dart'; import 'dart:io' show Platform; @@ -138,6 +139,11 @@ 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: '/whats-new', name: 'whats-new', @@ -157,6 +163,10 @@ final GoRouter router = GoRouter( 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 59b4390c..84ebf57d 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 22156776..8e54fb5f 100644 --- a/open_wearable/lib/widgets/settings/settings_page.dart +++ b/open_wearable/lib/widgets/settings/settings_page.dart @@ -15,12 +15,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 @@ -58,6 +60,12 @@ class SettingsPage extends StatelessWidget { subtitle: 'Browse app releases', onTap: () => context.push('/whats-new'), ), + _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 e2338e0f..6cb76654 100644 --- a/open_wearable/macos/Podfile.lock +++ b/open_wearable/macos/Podfile.lock @@ -14,6 +14,11 @@ PODS: - FlutterMacOS - package_info_plus (0.0.1): - FlutterMacOS + - package_info_plus (0.0.1): + - FlutterMacOS + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS - share_plus (0.0.1): - FlutterMacOS - shared_preferences_foundation (0.0.1): @@ -35,6 +40,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`) - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) @@ -59,6 +65,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 package_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos share_plus: @@ -79,6 +87,7 @@ SPEC CHECKSUMS: flutter_archive: 07888d9aeb79da005e0ad8b9d347d17cdea07f68 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 open_file_mac: 76f06c8597551249bdb5e8fd8827a98eae0f4585 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 package_info_plus: f0052d280d17aa382b932f399edf32507174e870 share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb From 04903bcbb04b965fe7722e660f9e1bba57b0c186 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 03/51] 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 618426fe..0c9e6108 100644 --- a/open_wearable/lib/main.dart +++ b/open_wearable/lib/main.dart @@ -35,11 +35,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( @@ -58,7 +61,7 @@ void main() async { return provider; }, ), - 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 ca75985d..1907b240 100644 --- a/open_wearable/lib/models/wearable_connector.dart +++ b/open_wearable/lib/models/wearable_connector.dart @@ -59,15 +59,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; } /// Clears local connection bookkeeping. From e16d90f457f07217bc9c355b982e762e2e125677 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 04/51] 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 e5eb9eeac94abf164049cb418485c80a8706ec23 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 05/51] 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 b20a62366c33463eaaa79d5cdb108de98c48f6c3 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 06/51] 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. From 48f37ea7a7dba2be5652a0f663c5b058c2d48ba7 Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:13:55 +0100 Subject: [PATCH 07/51] feat(connectors_page.dart): Update WebSocket IPC terminology and add reset settings functionality --- .../lib/widgets/settings/connectors_page.dart | 90 ++++++++++++++++--- 1 file changed, 77 insertions(+), 13 deletions(-) diff --git a/open_wearable/lib/widgets/settings/connectors_page.dart b/open_wearable/lib/widgets/settings/connectors_page.dart index be16cb9d..db20d12f 100644 --- a/open_wearable/lib/widgets/settings/connectors_page.dart +++ b/open_wearable/lib/widgets/settings/connectors_page.dart @@ -100,7 +100,7 @@ class _ConnectorsPageState extends State { AppToast.show( context, - message: 'WebSocket IPC settings saved.', + message: 'Network connector settings saved.', type: AppToastType.success, icon: Icons.check_circle_outline_rounded, ); @@ -110,11 +110,65 @@ class _ConnectorsPageState extends State { } setState(() { _validationMessage = - 'Could not start WebSocket IPC server: ${error.toString()}'; + 'Could not start network connector server: ${error.toString()}'; }); AppToast.show( context, - message: 'Failed to apply WebSocket IPC settings.', + message: 'Failed to apply network connector settings.', + type: AppToastType.error, + icon: Icons.error_outline_rounded, + ); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Future _resetSettingsToDefaults() async { + if (_isSaving) { + return; + } + + setState(() { + _isSaving = true; + _validationMessage = null; + }); + + try { + final saved = await ConnectorSettings.saveWebSocketSettings( + const WebSocketConnectorSettings.defaults(), + ); + if (!mounted) { + return; + } + + setState(() { + _enabled = saved.enabled; + _hostController.text = saved.host; + _portController.text = saved.port.toString(); + _pathController.text = saved.path; + }); + + AppToast.show( + context, + message: 'Network connector settings reset to defaults.', + type: AppToastType.success, + icon: Icons.restart_alt_rounded, + ); + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _validationMessage = + 'Could not restore default network connector settings: ${error.toString()}'; + }); + AppToast.show( + context, + message: 'Failed to reset network connector settings.', type: AppToastType.error, icon: Icons.error_outline_rounded, ); @@ -209,7 +263,7 @@ class _ConnectorsPageState extends State { ), const SizedBox(height: 4), Text( - 'Expose OpenEarable features for external tools.', + 'Expose OpenWearable features for external tools.', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context) @@ -287,14 +341,14 @@ class _ConnectorsPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'WebSocket IPC', + 'Network Connector', style: Theme.of(context).textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 2), Text( - 'Expose the OpenEarable Flutter API over JSON messages.', + 'Expose the OpenWearable Flutter API over JSON messages.', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -370,13 +424,23 @@ class _ConnectorsPageState extends State { ), ], const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: PlatformElevatedButton( - onPressed: - _isSaving || !hasPendingChanges ? null : _saveSettings, - child: Text(_isSaving ? 'Saving...' : 'Save & Apply'), - ), + Row( + children: [ + Expanded( + child: PlatformTextButton( + onPressed: _isSaving ? null : _resetSettingsToDefaults, + child: const Text('Reset to Defaults'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: PlatformElevatedButton( + onPressed: + _isSaving || !hasPendingChanges ? null : _saveSettings, + child: Text(_isSaving ? 'Saving...' : 'Save & Apply'), + ), + ), + ], ), ], ), From 33ed2e4308df897c5f207575621c75cd330e4b03 Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:21:43 +0100 Subject: [PATCH 08/51] feat: Implement device IP address resolution and integrate into connectors page --- .../lib/models/network/device_ip_address.dart | 5 + .../models/network/device_ip_address_io.dart | 30 ++++++ .../network/device_ip_address_stub.dart | 1 + .../lib/widgets/settings/connectors_page.dart | 99 +++++++++++++++---- 4 files changed, 114 insertions(+), 21 deletions(-) create mode 100644 open_wearable/lib/models/network/device_ip_address.dart create mode 100644 open_wearable/lib/models/network/device_ip_address_io.dart create mode 100644 open_wearable/lib/models/network/device_ip_address_stub.dart diff --git a/open_wearable/lib/models/network/device_ip_address.dart b/open_wearable/lib/models/network/device_ip_address.dart new file mode 100644 index 00000000..8b1aee17 --- /dev/null +++ b/open_wearable/lib/models/network/device_ip_address.dart @@ -0,0 +1,5 @@ +import 'device_ip_address_stub.dart' + if (dart.library.io) 'device_ip_address_io.dart'; + +Future resolveCurrentDeviceIpAddress() => + resolveCurrentDeviceIpAddressImpl(); diff --git a/open_wearable/lib/models/network/device_ip_address_io.dart b/open_wearable/lib/models/network/device_ip_address_io.dart new file mode 100644 index 00000000..6be51a58 --- /dev/null +++ b/open_wearable/lib/models/network/device_ip_address_io.dart @@ -0,0 +1,30 @@ +import 'dart:io'; + +Future resolveCurrentDeviceIpAddressImpl() async { + final interfaces = await NetworkInterface.list( + type: InternetAddressType.IPv4, + includeLoopback: false, + ); + + String? fallback; + for (final interface in interfaces) { + for (final address in interface.addresses) { + final host = address.address.trim(); + if (host.isEmpty || host.startsWith('169.254.')) { + continue; + } + if (_isPrivateIpv4(host)) { + return host; + } + fallback ??= host; + } + } + + return fallback; +} + +bool _isPrivateIpv4(String host) { + return host.startsWith('10.') || + host.startsWith('192.168.') || + RegExp(r'^172\.(1[6-9]|2\d|3[0-1])\.').hasMatch(host); +} diff --git a/open_wearable/lib/models/network/device_ip_address_stub.dart b/open_wearable/lib/models/network/device_ip_address_stub.dart new file mode 100644 index 00000000..1f021899 --- /dev/null +++ b/open_wearable/lib/models/network/device_ip_address_stub.dart @@ -0,0 +1 @@ +Future resolveCurrentDeviceIpAddressImpl() async => null; diff --git a/open_wearable/lib/widgets/settings/connectors_page.dart b/open_wearable/lib/widgets/settings/connectors_page.dart index db20d12f..94f3e56f 100644 --- a/open_wearable/lib/widgets/settings/connectors_page.dart +++ b/open_wearable/lib/widgets/settings/connectors_page.dart @@ -1,6 +1,7 @@ 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/models/network/device_ip_address.dart'; import 'package:open_wearable/widgets/app_toast.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; @@ -12,19 +13,19 @@ class ConnectorsPage extends StatefulWidget { } 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; + bool _isResolvingIpAddress = true; + String? _currentIpAddress; String? _validationMessage; @override void initState() { super.initState(); - _hostController = TextEditingController(); _portController = TextEditingController(); _pathController = TextEditingController(); _loadSettings(); @@ -32,7 +33,6 @@ class _ConnectorsPageState extends State { @override void dispose() { - _hostController.dispose(); _portController.dispose(); _pathController.dispose(); super.dispose(); @@ -40,17 +40,21 @@ class _ConnectorsPageState extends State { Future _loadSettings() async { try { - final settings = await ConnectorSettings.loadWebSocketSettings(); + final settingsFuture = ConnectorSettings.loadWebSocketSettings(); + final ipAddressFuture = resolveCurrentDeviceIpAddress(); + final settings = await settingsFuture; + final ipAddress = await ipAddressFuture; if (!mounted) { return; } setState(() { _enabled = settings.enabled; - _hostController.text = settings.host; _portController.text = settings.port.toString(); _pathController.text = settings.path; + _currentIpAddress = ipAddress; _validationMessage = null; + _isResolvingIpAddress = false; _isLoading = false; }); } catch (_) { @@ -59,6 +63,7 @@ class _ConnectorsPageState extends State { } setState(() { _validationMessage = 'Could not load connector settings.'; + _isResolvingIpAddress = false; _isLoading = false; }); AppToast.show( @@ -70,6 +75,38 @@ class _ConnectorsPageState extends State { } } + Future _refreshCurrentIpAddress() async { + setState(() { + _isResolvingIpAddress = true; + _validationMessage = null; + }); + + try { + final ipAddress = await resolveCurrentDeviceIpAddress(); + if (!mounted) { + return; + } + setState(() { + _currentIpAddress = ipAddress; + }); + } catch (_) { + if (!mounted) { + return; + } + setState(() { + _currentIpAddress = null; + _validationMessage = + 'Could not determine the current device IP address.'; + }); + } finally { + if (mounted) { + setState(() { + _isResolvingIpAddress = false; + }); + } + } + } + Future _saveSettings() async { if (_isSaving) { return; @@ -93,7 +130,6 @@ class _ConnectorsPageState extends State { setState(() { _enabled = saved.enabled; - _hostController.text = saved.host; _portController.text = saved.port.toString(); _pathController.text = saved.path; }); @@ -132,6 +168,14 @@ class _ConnectorsPageState extends State { return; } + final host = _currentIpAddress?.trim() ?? ''; + if (host.isEmpty) { + setState(() { + _validationMessage = 'Current device IP address is unavailable.'; + }); + return; + } + setState(() { _isSaving = true; _validationMessage = null; @@ -139,7 +183,7 @@ class _ConnectorsPageState extends State { try { final saved = await ConnectorSettings.saveWebSocketSettings( - const WebSocketConnectorSettings.defaults(), + const WebSocketConnectorSettings.defaults().copyWith(host: host), ); if (!mounted) { return; @@ -147,7 +191,6 @@ class _ConnectorsPageState extends State { setState(() { _enabled = saved.enabled; - _hostController.text = saved.host; _portController.text = saved.port.toString(); _pathController.text = saved.path; }); @@ -182,14 +225,14 @@ class _ConnectorsPageState extends State { } 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; + final host = _currentIpAddress?.trim() ?? ''; if (host.isEmpty) { setState(() { - _validationMessage = 'Host is required.'; + _validationMessage = 'Current device IP address is unavailable.'; }); return null; } @@ -230,9 +273,10 @@ class _ConnectorsPageState extends State { final path = _pathController.text.trim().isEmpty ? '/ws' : _pathController.text.trim(); + final host = _currentIpAddress?.trim(); return _enabled != applied.enabled || - _hostController.text.trim() != applied.host || + (host != null && host.isNotEmpty && host != applied.host) || parsedPort != applied.port || path != applied.path; } @@ -303,9 +347,9 @@ class _ConnectorsPageState extends State { final endpoint = Uri( scheme: 'ws', - host: _hostController.text.trim().isEmpty - ? appliedSettings.host - : _hostController.text.trim(), + host: (_currentIpAddress?.trim().isNotEmpty ?? false) + ? _currentIpAddress!.trim() + : appliedSettings.host, port: int.tryParse(_portController.text.trim()) ?? appliedSettings.port, path: _pathController.text.trim().isEmpty ? appliedSettings.path @@ -371,13 +415,26 @@ class _ConnectorsPageState extends State { ], ), const SizedBox(height: 10), - TextField( - controller: _hostController, - enabled: !_isSaving, - onChanged: _clearValidation, - decoration: const InputDecoration( - labelText: 'Host', - hintText: '127.0.0.1', + InputDecorator( + decoration: InputDecoration( + labelText: 'Current IP Address', + suffixIcon: IconButton( + onPressed: _isSaving || _isResolvingIpAddress + ? null + : _refreshCurrentIpAddress, + icon: _isResolvingIpAddress + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh_rounded), + tooltip: 'Refresh IP address', + ), + ), + child: Text( + _currentIpAddress ?? 'Unavailable on this device', + style: Theme.of(context).textTheme.bodyLarge, ), ), const SizedBox(height: 10), From b539be68653718e22d6c9af3569df69f7c7026c6 Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Tue, 3 Mar 2026 13:43:10 +0100 Subject: [PATCH 09/51] feat: Update iOS project settings and dependencies - Added MinimumOSVersion key to AppFrameworkInfo.plist with value 13.0. - Removed Profile.xcconfig file as it is no longer needed. - Updated Podfile to comment out the platform version specification. - Updated Podfile.lock to reflect changes in dependencies, including updates to SDWebImage and SwiftProtobuf. - Modified project.pbxproj to reflect changes in build settings and file references. - Updated Info.plist to include new keys for Bluetooth and network usage descriptions. - Refactored ConnectorSettings to handle legacy loopback host. - Changed default WebSocket IPC server host to 0.0.0.0 for better accessibility. - Updated ConnectorsPage to use default WebSocket host and removed unnecessary IP address checks. - Added Bluetooth and network permissions to macOS entitlements. --- open_wearable/.metadata | 12 +- .../ios/Flutter/AppFrameworkInfo.plist | 2 + open_wearable/ios/Flutter/Profile.xcconfig | 2 - open_wearable/ios/Podfile | 2 +- open_wearable/ios/Podfile.lock | 23 ++- .../ios/Runner.xcodeproj/project.pbxproj | 171 +++++++++--------- open_wearable/ios/Runner/Info.plist | 46 ++--- .../lib/models/connector_settings.dart | 6 +- .../connectors/websocket_ipc_server.dart | 4 +- .../lib/widgets/settings/connectors_page.dart | 23 +-- .../macos/Runner/Release.entitlements | 4 + 11 files changed, 144 insertions(+), 151 deletions(-) delete mode 100644 open_wearable/ios/Flutter/Profile.xcconfig diff --git a/open_wearable/.metadata b/open_wearable/.metadata index 16653984..878a1448 100644 --- a/open_wearable/.metadata +++ b/open_wearable/.metadata @@ -4,8 +4,8 @@ # This file should be version controlled and should not be manually edited. version: - revision: "3297454732841b1a5a25d9f35f1fd5d7a4479e12" - channel: "main" + revision: "f5a8537f90d143abd5bb2f658fa69c388da9677b" + channel: "stable" project_type: app @@ -13,11 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: 3297454732841b1a5a25d9f35f1fd5d7a4479e12 - base_revision: 3297454732841b1a5a25d9f35f1fd5d7a4479e12 + create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b - platform: ios - create_revision: 3297454732841b1a5a25d9f35f1fd5d7a4479e12 - base_revision: 3297454732841b1a5a25d9f35f1fd5d7a4479e12 + create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b # User provided section diff --git a/open_wearable/ios/Flutter/AppFrameworkInfo.plist b/open_wearable/ios/Flutter/AppFrameworkInfo.plist index 391a902b..1dc6cf76 100644 --- a/open_wearable/ios/Flutter/AppFrameworkInfo.plist +++ b/open_wearable/ios/Flutter/AppFrameworkInfo.plist @@ -20,5 +20,7 @@ ???? CFBundleVersion 1.0 + MinimumOSVersion + 13.0 diff --git a/open_wearable/ios/Flutter/Profile.xcconfig b/open_wearable/ios/Flutter/Profile.xcconfig deleted file mode 100644 index 73272fc1..00000000 --- a/open_wearable/ios/Flutter/Profile.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig" -#include "Generated.xcconfig" diff --git a/open_wearable/ios/Podfile b/open_wearable/ios/Podfile index 2dbf7d72..620e46eb 100644 --- a/open_wearable/ios/Podfile +++ b/open_wearable/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '13.0' +# platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/open_wearable/ios/Podfile.lock b/open_wearable/ios/Podfile.lock index 110052ae..21367c47 100644 --- a/open_wearable/ios/Podfile.lock +++ b/open_wearable/ios/Podfile.lock @@ -53,18 +53,23 @@ PODS: - Flutter - package_info_plus (0.4.5): - Flutter + - package_info_plus (0.4.5): + - Flutter + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS - permission_handler_apple (9.3.0): - Flutter - - SDWebImage (5.21.5): - - SDWebImage/Core (= 5.21.5) - - SDWebImage/Core (5.21.5) + - SDWebImage (5.21.6): + - SDWebImage/Core (= 5.21.6) + - SDWebImage/Core (5.21.6) - share_plus (0.0.1): - Flutter - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS - SwiftCBOR (0.4.7) - - SwiftProtobuf (1.33.3) + - SwiftProtobuf (1.34.1) - SwiftyGif (5.4.5) - universal_ble (0.0.1): - Flutter @@ -83,6 +88,7 @@ DEPENDENCIES: - flutter_archive (from `.symlinks/plugins/flutter_archive/ios`) - mcumgr_flutter (from `.symlinks/plugins/mcumgr_flutter/ios`) - open_file_ios (from `.symlinks/plugins/open_file_ios/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) @@ -117,6 +123,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/mcumgr_flutter/ios" open_file_ios: :path: ".symlinks/plugins/open_file_ios/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" permission_handler_apple: @@ -143,19 +151,20 @@ SPEC CHECKSUMS: iOSMcuManagerLibrary: e9555825af11a61744fe369c12e1e66621061b58 mcumgr_flutter: 969e99cc15e9fe658242669ce1075bf4612aef8a open_file_ios: 46184d802ee7959203f6392abcfa0dd49fdb5be0 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d - SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838 + SDWebImage: 1bb6a1b84b6fe87b972a102bdc77dd589df33477 share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb SwiftCBOR: 465775bed0e8bac7bfb8160bcf7b95d7f75971e4 - SwiftProtobuf: e1b437c8e31a4c5577b643249a0bb62ed4f02153 + SwiftProtobuf: c901f00a3e125dc33cac9b16824da85682ee47da SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 universal_ble: ff19787898040d721109c6324472e5dd4bc86adc url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c -PODFILE CHECKSUM: 251cb053df7158f337c0712f2ab29f4e0fa474ce +PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e COCOAPODS: 1.16.2 diff --git a/open_wearable/ios/Runner.xcodeproj/project.pbxproj b/open_wearable/ios/Runner.xcodeproj/project.pbxproj index a107054d..c65acc9f 100644 --- a/open_wearable/ios/Runner.xcodeproj/project.pbxproj +++ b/open_wearable/ios/Runner.xcodeproj/project.pbxproj @@ -7,8 +7,7 @@ objects = { /* Begin PBXBuildFile section */ - 0845ED28949C135158A6A44C /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3535700A26547C4E20FE3FC6 /* Pods_RunnerTests.framework */; }; - 0ED777B22A8643DAFC696BCE /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3297620631257A015E392A44 /* Pods_Runner.framework */; }; + 102E9C01F61260F9E7065730 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 419E467D63E8688048F5C75E /* Pods_RunnerTests.framework */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; @@ -16,6 +15,7 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + F46DF65FA99CF361E7CEAA40 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EF0861E71FF5A330973499A0 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -42,38 +42,37 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 09A3426BA68715704AC42C1C /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 3297620631257A015E392A44 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 24396D1F3AC1166BC140B428 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 34CEAC226289D7F06F23AA4A /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - 3535700A26547C4E20FE3FC6 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 41658CB11BC48C7DC2B08A3E /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; - 696DE6D08EAE76AF18B59278 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 419E467D63E8688048F5C75E /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 456A71BA77F549CC4BFFAC1E /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 52F9CE79C4C8FF8BCB3E77E2 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 8E7C81D1F19D5FDC2058E571 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - A5E547E7250B8EDE002C7480 /* Profile.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Profile.xcconfig; path = Flutter/Profile.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - D41DC304068D7FFAC6E2A91A /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; - E459735E7D60C06940F97360 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + A9582C5AF71A83F5C1675FE0 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + DA11AB685288DE423745C68D /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + EF0861E71FF5A330973499A0 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ - 2F13991BB6EE57E0B0CBDD2A /* Frameworks */ = { + 15A78D528879B5F30DFDD247 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 0845ED28949C135158A6A44C /* Pods_RunnerTests.framework in Frameworks */, + 102E9C01F61260F9E7065730 /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -81,28 +80,41 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 0ED777B22A8643DAFC696BCE /* Pods_Runner.framework in Frameworks */, + F46DF65FA99CF361E7CEAA40 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 07F33C7FA7E36FBB7D1AFB90 /* Frameworks */ = { + 331C8082294A63A400263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( - 3297620631257A015E392A44 /* Pods_Runner.framework */, - 3535700A26547C4E20FE3FC6 /* Pods_RunnerTests.framework */, + 331C807B294A618700263BE5 /* RunnerTests.swift */, ); - name = Frameworks; + path = RunnerTests; sourceTree = ""; }; - 331C8082294A63A400263BE5 /* RunnerTests */ = { + 444D919222E527AFB756E5FC /* Pods */ = { isa = PBXGroup; children = ( - 331C807B294A618700263BE5 /* RunnerTests.swift */, + 8E7C81D1F19D5FDC2058E571 /* Pods-Runner.debug.xcconfig */, + 456A71BA77F549CC4BFFAC1E /* Pods-Runner.release.xcconfig */, + DA11AB685288DE423745C68D /* Pods-Runner.profile.xcconfig */, + 52F9CE79C4C8FF8BCB3E77E2 /* Pods-RunnerTests.debug.xcconfig */, + A9582C5AF71A83F5C1675FE0 /* Pods-RunnerTests.release.xcconfig */, + 24396D1F3AC1166BC140B428 /* Pods-RunnerTests.profile.xcconfig */, ); - path = RunnerTests; + path = Pods; + sourceTree = ""; + }; + 6DD1A86F1F88D6B3F4C07A0E /* Frameworks */ = { + isa = PBXGroup; + children = ( + EF0861E71FF5A330973499A0 /* Pods_Runner.framework */, + 419E467D63E8688048F5C75E /* Pods_RunnerTests.framework */, + ); + name = Frameworks; sourceTree = ""; }; 9740EEB11CF90186004384FC /* Flutter */ = { @@ -111,7 +123,6 @@ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - A5E547E7250B8EDE002C7480 /* Profile.xcconfig */, 9740EEB31CF90195004384FC /* Generated.xcconfig */, ); name = Flutter; @@ -124,8 +135,8 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, - ABD622D2A2D0BCB7CE87A7A2 /* Pods */, - 07F33C7FA7E36FBB7D1AFB90 /* Frameworks */, + 444D919222E527AFB756E5FC /* Pods */, + 6DD1A86F1F88D6B3F4C07A0E /* Frameworks */, ); sourceTree = ""; }; @@ -153,19 +164,6 @@ path = Runner; sourceTree = ""; }; - ABD622D2A2D0BCB7CE87A7A2 /* Pods */ = { - isa = PBXGroup; - children = ( - D41DC304068D7FFAC6E2A91A /* Pods-Runner.debug.xcconfig */, - 34CEAC226289D7F06F23AA4A /* Pods-Runner.release.xcconfig */, - E459735E7D60C06940F97360 /* Pods-Runner.profile.xcconfig */, - 41658CB11BC48C7DC2B08A3E /* Pods-RunnerTests.debug.xcconfig */, - 696DE6D08EAE76AF18B59278 /* Pods-RunnerTests.release.xcconfig */, - 09A3426BA68715704AC42C1C /* Pods-RunnerTests.profile.xcconfig */, - ); - path = Pods; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -173,10 +171,10 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( - 5718563614C8F7894157DC10 /* [CP] Check Pods Manifest.lock */, + 029B72797FDE532F817B1806 /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, - 2F13991BB6EE57E0B0CBDD2A /* Frameworks */, + 15A78D528879B5F30DFDD247 /* Frameworks */, ); buildRules = ( ); @@ -192,15 +190,15 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - FFFE2A85F70743792722C29B /* [CP] Check Pods Manifest.lock */, + 04183D9F071BDEFA9C1F1A0C /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - 215AE7F5CAC4502A9765F2E9 /* [CP] Embed Pods Frameworks */, - FE97B2599EF19B047DD993AE /* [CP] Copy Pods Resources */, + 4BE2C1EC56EB7B056F761ABF /* [CP] Embed Pods Frameworks */, + EA0E9000CF1E32719EBA3126 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -272,21 +270,48 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 215AE7F5CAC4502A9765F2E9 /* [CP] Embed Pods Frameworks */ = { + 029B72797FDE532F817B1806 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - name = "[CP] Embed Pods Frameworks"; + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 04183D9F071BDEFA9C1F1A0C /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { @@ -303,28 +328,23 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin\n\n# Fix App.framework MinimumOSVersion\nAPP_FRAMEWORK=\"${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/App.framework\"\nAPP_PLIST=\"${APP_FRAMEWORK}/Info.plist\"\n\nif [ -f \"$APP_PLIST\" ]; then\n echo \"Fixing MinimumOSVersion in App.framework\"\n /usr/libexec/PlistBuddy -c \"Delete :MinimumOSVersion\" \"$APP_PLIST\" 2>/dev/null || true\n /usr/libexec/PlistBuddy -c \"Add :MinimumOSVersion string 13.0\" \"$APP_PLIST\"\n echo \"Successfully set MinimumOSVersion to 13.0\"\nfi\n"; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; - 5718563614C8F7894157DC10 /* [CP] Check Pods Manifest.lock */ = { + 4BE2C1EC56EB7B056F761ABF /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; + name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; 9740EEB61CF901F6004384FC /* Run Script */ = { @@ -342,7 +362,7 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; - FE97B2599EF19B047DD993AE /* [CP] Copy Pods Resources */ = { + EA0E9000CF1E32719EBA3126 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -359,28 +379,6 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; showEnvVarsInLog = 0; }; - FFFE2A85F70743792722C29B /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -485,7 +483,7 @@ }; 249021D4217E4FDB00AE95B9 /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = A5E547E7250B8EDE002C7480 /* Profile.xcconfig */; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; @@ -493,7 +491,6 @@ DEVELOPMENT_TEAM = 6DCQ69GP5G; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -508,7 +505,7 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 41658CB11BC48C7DC2B08A3E /* Pods-RunnerTests.debug.xcconfig */; + baseConfigurationReference = 52F9CE79C4C8FF8BCB3E77E2 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -526,7 +523,7 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 696DE6D08EAE76AF18B59278 /* Pods-RunnerTests.release.xcconfig */; + baseConfigurationReference = A9582C5AF71A83F5C1675FE0 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -542,7 +539,7 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 09A3426BA68715704AC42C1C /* Pods-RunnerTests.profile.xcconfig */; + baseConfigurationReference = 24396D1F3AC1166BC140B428 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -677,7 +674,6 @@ DEVELOPMENT_TEAM = 6DCQ69GP5G; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -701,7 +697,6 @@ DEVELOPMENT_TEAM = 6DCQ69GP5G; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/open_wearable/ios/Runner/Info.plist b/open_wearable/ios/Runner/Info.plist index 7bbea3c9..72b4953b 100644 --- a/open_wearable/ios/Runner/Info.plist +++ b/open_wearable/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -22,8 +24,29 @@ ???? CFBundleVersion $(FLUTTER_BUILD_NUMBER) + ITSAppUsesNonExemptEncryption + + LSApplicationQueriesSchemes + + http + https + LSRequiresIPhoneOS + LSSupportsOpeningDocumentsInPlace + + NSBluetoothAlwaysUsageDescription + This app uses Bluetooth to connect to wearable devices. + NSBluetoothPeripheralUsageDescription + This app requires Bluetooth access to communicate with wearable devices. + NSLocalNetworkUsageDescription + This app uses the local network to host a webserver for tools integration. + NSPhotoLibraryUsageDescription + Needed for optional file selection functionality. + UIApplicationSupportsIndirectInputEvents + + UIFileSharingEnabled + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -37,28 +60,5 @@ UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - - NSBluetoothAlwaysUsageDescription - This app uses Bluetooth to connect to wearable devices. - NSBluetoothPeripheralUsageDescription - This app requires Bluetooth access to communicate with wearable devices. - NSPhotoLibraryUsageDescription - Needed for optional file selection functionality. - ITSAppUsesNonExemptEncryption - - UIFileSharingEnabled - - LSSupportsOpeningDocumentsInPlace - - LSApplicationQueriesSchemes - - http - https - - MinimumOSVersion - 13.0 diff --git a/open_wearable/lib/models/connector_settings.dart b/open_wearable/lib/models/connector_settings.dart index 755ad319..00b9f95b 100644 --- a/open_wearable/lib/models/connector_settings.dart +++ b/open_wearable/lib/models/connector_settings.dart @@ -84,6 +84,7 @@ class ConnectorRuntimeStatus { } class ConnectorSettings { + static const String _legacyLoopbackHost = '127.0.0.1'; static const String _websocketEnabledKey = 'connector_websocket_enabled'; static const String _websocketHostKey = 'connector_websocket_host'; static const String _websocketPortKey = 'connector_websocket_port'; @@ -192,9 +193,10 @@ class ConnectorSettings { static WebSocketConnectorSettings _normalizeWebSocketSettings( WebSocketConnectorSettings settings, ) { - final host = settings.host.trim().isEmpty + final trimmedHost = settings.host.trim(); + final host = trimmedHost.isEmpty || trimmedHost == _legacyLoopbackHost ? WebSocketIpcServer.defaultHost - : settings.host.trim(); + : trimmedHost; final port = (settings.port > 0 && settings.port <= 65535) ? settings.port : WebSocketIpcServer.defaultPort; diff --git a/open_wearable/lib/models/connectors/websocket_ipc_server.dart b/open_wearable/lib/models/connectors/websocket_ipc_server.dart index 1b44fda3..750982db 100644 --- a/open_wearable/lib/models/connectors/websocket_ipc_server.dart +++ b/open_wearable/lib/models/connectors/websocket_ipc_server.dart @@ -12,7 +12,7 @@ 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 String defaultHost = '0.0.0.0'; static const int defaultPort = 8765; static const String defaultPath = '/ws'; @@ -72,7 +72,7 @@ class WebSocketIpcServer implements CommandRuntime { _port = port; _path = _normalizePath(path); - _httpServer = await HttpServer.bind(_host, _port, shared: true); + _httpServer = await HttpServer.bind(InternetAddress.anyIPv4, _port, shared: true); _attachManagerSubscriptions(); unawaited( diff --git a/open_wearable/lib/widgets/settings/connectors_page.dart b/open_wearable/lib/widgets/settings/connectors_page.dart index 94f3e56f..7b7c0883 100644 --- a/open_wearable/lib/widgets/settings/connectors_page.dart +++ b/open_wearable/lib/widgets/settings/connectors_page.dart @@ -1,6 +1,7 @@ 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/models/connectors/websocket_ipc_server.dart'; import 'package:open_wearable/models/network/device_ip_address.dart'; import 'package:open_wearable/widgets/app_toast.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; @@ -168,14 +169,6 @@ class _ConnectorsPageState extends State { return; } - final host = _currentIpAddress?.trim() ?? ''; - if (host.isEmpty) { - setState(() { - _validationMessage = 'Current device IP address is unavailable.'; - }); - return; - } - setState(() { _isSaving = true; _validationMessage = null; @@ -183,7 +176,7 @@ class _ConnectorsPageState extends State { try { final saved = await ConnectorSettings.saveWebSocketSettings( - const WebSocketConnectorSettings.defaults().copyWith(host: host), + const WebSocketConnectorSettings.defaults(), ); if (!mounted) { return; @@ -229,14 +222,6 @@ class _ConnectorsPageState extends State { final rawPath = _pathController.text.trim(); final path = rawPath.isEmpty ? '/ws' : rawPath; - final host = _currentIpAddress?.trim() ?? ''; - if (host.isEmpty) { - setState(() { - _validationMessage = 'Current device IP address is unavailable.'; - }); - return null; - } - if (parsedPort == null || parsedPort <= 0 || parsedPort > 65535) { setState(() { _validationMessage = 'Port must be between 1 and 65535.'; @@ -253,7 +238,7 @@ class _ConnectorsPageState extends State { return WebSocketConnectorSettings( enabled: _enabled, - host: host, + host: WebSocketIpcServer.defaultHost, port: parsedPort, path: path, ); @@ -273,10 +258,8 @@ class _ConnectorsPageState extends State { final path = _pathController.text.trim().isEmpty ? '/ws' : _pathController.text.trim(); - final host = _currentIpAddress?.trim(); return _enabled != applied.enabled || - (host != null && host.isNotEmpty && host != applied.host) || parsedPort != applied.port || path != applied.path; } diff --git a/open_wearable/macos/Runner/Release.entitlements b/open_wearable/macos/Runner/Release.entitlements index 852fa1a4..c63c9510 100644 --- a/open_wearable/macos/Runner/Release.entitlements +++ b/open_wearable/macos/Runner/Release.entitlements @@ -4,5 +4,9 @@ com.apple.security.app-sandbox + com.apple.security.device.bluetooth + + com.apple.security.network.server + From 6374e473adcd27e2a76fef31d2ec84fdff8b0e7c Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:47:58 +0100 Subject: [PATCH 10/51] feat: Update WebSocket IPC server to resolve device IP address and improve endpoint handling --- .../docs/connectors/websocket-ipc-api.md | 11 +- .../lib/models/connector_settings.dart | 43 +++--- .../connectors/websocket_ipc_server.dart | 144 ++++++++++++++++-- .../lib/models/network/device_ip_address.dart | 4 + .../models/network/device_ip_address_io.dart | 69 ++++++++- .../network/device_ip_address_stub.dart | 1 + .../lib/widgets/settings/connectors_page.dart | 4 +- 7 files changed, 227 insertions(+), 49 deletions(-) diff --git a/open_wearable/docs/connectors/websocket-ipc-api.md b/open_wearable/docs/connectors/websocket-ipc-api.md index bbf0ea4b..a9084490 100644 --- a/open_wearable/docs/connectors/websocket-ipc-api.md +++ b/open_wearable/docs/connectors/websocket-ipc-api.md @@ -6,11 +6,12 @@ This document describes how to communicate with the OpenWearable WebSocket conne Default endpoint: -- `ws://127.0.0.1:8765/ws` +- `ws://:8765/ws` Notes: -- Host, port, and path are configurable in app settings. +- The app binds the websocket server on all IPv4 interfaces and advertises the current device IP for clients on the same network. +- Port and path are configurable in app settings. - The API is JSON over WebSocket text frames. ## Message Envelopes @@ -47,10 +48,14 @@ On connect, the server sends: ```json { "event": "ready", - "methods": ["ping", "methods", "..."] + "methods": ["ping", "methods", "..."], + "endpoint": "ws://192.168.1.23:8765/ws" } ``` +`ready.endpoint` may be `null` when the app cannot determine a client-reachable +LAN IP address. The connector still runs in that case. + Other event messages: - `scan`: broadcast when a device is discovered. diff --git a/open_wearable/lib/models/connector_settings.dart b/open_wearable/lib/models/connector_settings.dart index 00b9f95b..31681259 100644 --- a/open_wearable/lib/models/connector_settings.dart +++ b/open_wearable/lib/models/connector_settings.dart @@ -8,49 +8,38 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'connectors/websocket_ipc_server.dart'; +/// Persisted configuration for the network connector. 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, - ); - + /// Returns a copy with selectively replaced fields. 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, ); } } +/// High-level runtime state of the connector server. enum ConnectorRuntimeState { disabled, starting, @@ -58,6 +47,7 @@ enum ConnectorRuntimeState { error, } +/// Snapshot of the current connector runtime state and message. class ConnectorRuntimeStatus { final ConnectorRuntimeState state; final String? message; @@ -83,8 +73,8 @@ class ConnectorRuntimeStatus { : state = ConnectorRuntimeState.error; } +/// Loads, normalizes, persists, and applies connector settings. class ConnectorSettings { - static const String _legacyLoopbackHost = '127.0.0.1'; static const String _websocketEnabledKey = 'connector_websocket_enabled'; static const String _websocketHostKey = 'connector_websocket_host'; static const String _websocketPortKey = 'connector_websocket_port'; @@ -108,12 +98,15 @@ class ConnectorSettings { static ValueListenable get webSocketRuntimeStatusListenable => _webSocketRuntimeStatusNotifier; + /// Returns the current persisted settings snapshot. static WebSocketConnectorSettings get currentWebSocketSettings => _webSocketSettingsNotifier.value; + /// Returns the current runtime status snapshot. static ConnectorRuntimeStatus get currentWebSocketRuntimeStatus => _webSocketRuntimeStatusNotifier.value; + /// Initializes the server runtime and applies persisted settings. static Future initialize({ WearableConnector? wearableConnector, }) async { @@ -126,17 +119,17 @@ class ConnectorSettings { await applyWebSocketSettings(settings); } + /// Stops the running server and resets the runtime status. static Future dispose() async { await _webSocketServer.stop(); _setRuntimeStatus(const ConnectorRuntimeStatus.disabled()); } + /// Loads persisted websocket settings and normalizes any legacy values. 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, @@ -147,6 +140,7 @@ class ConnectorSettings { return normalized; } + /// Saves websocket settings, removes deprecated host state, and applies them. static Future saveWebSocketSettings( WebSocketConnectorSettings settings, ) async { @@ -154,22 +148,23 @@ class ConnectorSettings { 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); + await prefs.remove(_websocketHostKey); _setWebSocketSettings(normalized); await applyWebSocketSettings(normalized); return normalized; } + /// Applies the given settings to the websocket server. static Future applyWebSocketSettings( WebSocketConnectorSettings settings, ) async { final normalized = _normalizeWebSocketSettings(settings); _setWebSocketSettings(normalized); - if (!normalized.enabled || !normalized.isConfigured) { + if (!normalized.enabled) { await _webSocketServer.stop(); _setRuntimeStatus(const ConnectorRuntimeStatus.disabled()); return; @@ -179,7 +174,6 @@ class ConnectorSettings { try { await _webSocketServer.start( - host: normalized.host, port: normalized.port, path: normalized.path, ); @@ -190,26 +184,23 @@ class ConnectorSettings { } } + /// Normalizes persisted settings into a valid runtime configuration. static WebSocketConnectorSettings _normalizeWebSocketSettings( WebSocketConnectorSettings settings, ) { - final trimmedHost = settings.host.trim(); - final host = trimmedHost.isEmpty || trimmedHost == _legacyLoopbackHost - ? WebSocketIpcServer.defaultHost - : trimmedHost; 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, ); } + /// Ensures the websocket path is non-empty and starts with `/`. static String _normalizePath(String path) { final trimmed = path.trim(); if (trimmed.isEmpty) { @@ -218,10 +209,12 @@ class ConnectorSettings { return trimmed.startsWith('/') ? trimmed : '/$trimmed'; } + /// Publishes the current settings snapshot to listeners. static void _setWebSocketSettings(WebSocketConnectorSettings settings) { _webSocketSettingsNotifier.value = settings; } + /// Publishes the current runtime status to listeners. 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 index 750982db..e8e8b60c 100644 --- a/open_wearable/lib/models/connectors/websocket_ipc_server.dart +++ b/open_wearable/lib/models/connectors/websocket_ipc_server.dart @@ -9,10 +9,11 @@ import 'package:open_wearable/models/connectors/commands/default_ipc_commands.da 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/network/device_ip_address.dart'; import 'package:open_wearable/models/wearable_connector.dart'; +/// Websocket-based IPC server that exposes wearable operations to external clients. class WebSocketIpcServer implements CommandRuntime { - static const String defaultHost = '0.0.0.0'; static const int defaultPort = 8765; static const String defaultPath = '/ws'; @@ -20,9 +21,10 @@ class WebSocketIpcServer implements CommandRuntime { final WearableConnector _wearableConnector; HttpServer? _httpServer; - String _host = defaultHost; + final InternetAddress _host = InternetAddress.anyIPv4; int _port = defaultPort; String _path = defaultPath; + String? _advertisedHost; final Map _discoveredDevicesById = {}; @@ -52,33 +54,58 @@ class WebSocketIpcServer implements CommandRuntime { } } + /// Returns whether the websocket server is currently bound and accepting requests. bool get isRunning => _httpServer != null; - Uri get endpoint => Uri( + /// Returns the internal bind endpoint used by the server. + Uri get bindEndpoint => Uri( scheme: 'ws', - host: _host, + host: _host.address, port: _port, path: _path, ); + /// Returns the client-facing endpoint derived from the current advertised IP. + Uri? get advertisedEndpoint { + final host = _advertisedHost; + if (host == null || host.trim().isEmpty) { + return null; + } + return Uri( + scheme: 'ws', + host: host, + port: _port, + path: _path, + ); + } + + /// Starts the server with the provided port and path. Future start({ - required String host, required int port, required String path, }) async { await stop(); - _host = host.trim(); _port = port; _path = _normalizePath(path); + logger.i( + '[connector.websocket] starting bind_address=${_host.address} port=$_port path=$_path', + ); - _httpServer = await HttpServer.bind(InternetAddress.anyIPv4, _port, shared: true); + _httpServer = await HttpServer.bind(_host, _port, shared: true); + _advertisedHost = await resolveCurrentDeviceIpAddress(); + logger.i( + '[connector.websocket] listening address=${_httpServer!.address.address} port=${_httpServer!.port} path=$_path advertised_endpoint=${advertisedEndpoint?.toString() ?? 'unavailable'}', + ); _attachManagerSubscriptions(); - unawaited( - _httpServer!.forEach((request) async { + _httpServer!.listen( + (request) async { if (request.uri.path != _path || !WebSocketTransformer.isUpgradeRequest(request)) { + logger.d( + '[connector.websocket] rejected_http_request method=${request.method} path=${request.uri.path} remote=${request.connectionInfo?.remoteAddress.address}:${request.connectionInfo?.remotePort}', + ); request.response ..statusCode = HttpStatus.notFound ..headers.contentType = ContentType.text @@ -87,22 +114,39 @@ class WebSocketIpcServer implements CommandRuntime { return; } + logger.i( + '[connector.websocket] upgrade_request accepted remote=${request.connectionInfo?.remoteAddress.address}:${request.connectionInfo?.remotePort}', + ); final socket = await WebSocketTransformer.upgrade(request); final session = _ClientSession( socket: socket, server: this, ); _clients.add(session); + logger.i( + '[connector.websocket] client_connected client=${session.label} active_clients=${_clients.length}', + ); session.start(); - }), + }, + onError: (error, stackTrace) { + logger.e( + '[connector.websocket] http_server_loop_failed error=$error', + error: error, + stackTrace: stackTrace, + ); + }, ); } + /// Stops the server, closes active clients, and clears runtime state. Future stop() async { final server = _httpServer; _httpServer = null; if (server != null) { + logger.i( + '[connector.websocket] stopping address=${server.address.address} port=${server.port} active_clients=${_clients.length}', + ); await server.close(force: true); } @@ -121,49 +165,68 @@ class WebSocketIpcServer implements CommandRuntime { _discoveredDevicesById.clear(); _connectedWearablesById.clear(); + _advertisedHost = null; + logger.i('[connector.websocket] stopped'); } + /// Removes a disconnected client session from the active set. void _onClientClosed(_ClientSession client) { _clients.remove(client); + logger.i( + '[connector.websocket] client_disconnected client=${client.label} active_clients=${_clients.length}', + ); } @override + + /// Returns the list of registered top-level IPC method names. List get methods => _topLevelCommands.keys.toList(growable: false); + /// Registers a top-level IPC command. void addCommand(Command command) { _topLevelCommands[command.name] = command; } + /// Registers an action command callable through `invoke_action`. void addActionCommand(Command command) { _actionCommands[command.name] = command; } + /// Dispatches an inbound request to the matching 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) { + logger.w( + '[connector.websocket] unknown_method client=${client.label} method=$method', + ); throw UnsupportedError('Unknown method: $method'); } return command.run(_paramsToCommandParams(params, session: client)); } @override + + /// Returns a connected wearable by device id. Future getWearable({required String deviceId}) async { return _requireConnectedWearable(deviceId); } @override + + /// Returns whether the underlying wearable runtime already has required permissions. Future hasPermissions() => _wearableManager.hasPermissions(); @override + + /// Checks for and requests missing runtime permissions from the platform. Future checkAndRequestPermissions() => WearableManager.checkAndRequestPermissions(); + /// Starts device scanning through the wearable manager. @override Future> startScan({ bool checkAndRequestPermissions = true, @@ -175,14 +238,18 @@ class WebSocketIpcServer implements CommandRuntime { return {'started': true}; } + /// Returns the currently discovered devices as JSON-safe maps. @override Future>> getDiscoveredDevices() async { return _discoveredDevicesById.values.map(_serializeDiscovered).toList(); } @override + + /// Exposes the scan event stream for async scan subscriptions. Stream get scanEvents => _scanEventsController.stream; + /// Connects to a discovered device by id. @override Future> connect({ required String deviceId, @@ -205,6 +272,7 @@ class WebSocketIpcServer implements CommandRuntime { return _serializeWearableSummary(wearable); } + /// Connects to system-managed wearables and registers them with the server. @override Future>> connectSystemDevices({ List ignoredDeviceIds = const [], @@ -218,6 +286,7 @@ class WebSocketIpcServer implements CommandRuntime { return wearables.map(_serializeWearableSummary).toList(); } + /// Lists currently connected wearables. @override Future>> listConnected() async { return _connectedWearablesById.values @@ -225,6 +294,7 @@ class WebSocketIpcServer implements CommandRuntime { .toList(); } + /// Disconnects a connected wearable by id. @override Future> disconnect({ required String deviceId, @@ -235,11 +305,13 @@ class WebSocketIpcServer implements CommandRuntime { return {'disconnected': true}; } + /// Allocates the next unique subscription id for a client. @override Future createSubscriptionId() async { return _nextSubscriptionId++; } + /// Attaches a stream subscription to the given client session. @override Future attachStreamSubscription({ required dynamic session, @@ -258,6 +330,7 @@ class WebSocketIpcServer implements CommandRuntime { ); } + /// Cancels a previously registered client stream subscription. @override Future> unsubscribe({ required dynamic session, @@ -267,6 +340,7 @@ class WebSocketIpcServer implements CommandRuntime { return client.unsubscribe(subscriptionId); } + /// Invokes an action command against a connected wearable. @override Future invokeAction({ required String deviceId, @@ -284,6 +358,7 @@ class WebSocketIpcServer implements CommandRuntime { return command.run(actionParams); } + /// Converts raw request params into command params for command execution. List> _paramsToCommandParams( Map params, { required _ClientSession? session, @@ -299,6 +374,7 @@ class WebSocketIpcServer implements CommandRuntime { return commandParams; } + /// Hooks wearable manager streams into websocket broadcast events. void _attachManagerSubscriptions() { _scanSubscription ??= _wearableManager.scanStream.listen((device) { _discoveredDevicesById[device.id] = device; @@ -332,6 +408,7 @@ class WebSocketIpcServer implements CommandRuntime { }); } + /// Tracks a connected wearable and removes it when it disconnects. void _registerConnectedWearable(Wearable wearable) { _connectedWearablesById[wearable.deviceId] = wearable; wearable.addDisconnectListener(() { @@ -339,6 +416,7 @@ class WebSocketIpcServer implements CommandRuntime { }); } + /// Broadcasts a JSON event to all currently connected clients. void _broadcastEvent(Map event) { final payload = _jsonEncode(event); for (final client in _clients.toList(growable: false)) { @@ -346,15 +424,18 @@ class WebSocketIpcServer implements CommandRuntime { } } + /// Sends the initial ready event to a newly connected client. void _sendReady(_ClientSession client) { client.send( { 'event': 'ready', 'methods': methods, + 'endpoint': advertisedEndpoint?.toString(), }, ); } + /// Serializes a discovered device into the external IPC format. Map _serializeDiscovered(DiscoveredDevice device) { return { 'id': device.id, @@ -365,6 +446,7 @@ class WebSocketIpcServer implements CommandRuntime { }; } + /// Serializes a connected wearable summary into the external IPC format. Map _serializeWearableSummary(Wearable wearable) { return { 'device_id': wearable.deviceId, @@ -374,6 +456,7 @@ class WebSocketIpcServer implements CommandRuntime { }; } + /// Serializes streamed capability data into JSON-safe payloads. Object? _serializeStreamData(dynamic data) { if (data is DiscoveredDevice) { return _serializeDiscovered(data); @@ -416,6 +499,7 @@ class WebSocketIpcServer implements CommandRuntime { return _jsonSafe(data); } + /// Serializes battery power status into a JSON-safe payload. Map _serializeBatteryPowerStatus(BatteryPowerStatus status) { return { 'battery_present': status.batteryPresent, @@ -431,6 +515,7 @@ class WebSocketIpcServer implements CommandRuntime { }; } + /// Serializes battery health status into a JSON-safe payload. Map _serializeBatteryHealthStatus( BatteryHealthStatus status, ) { @@ -441,6 +526,7 @@ class WebSocketIpcServer implements CommandRuntime { }; } + /// Serializes battery energy status into a JSON-safe payload. Map _serializeBatteryEnergyStatus( BatteryEnergyStatus status, ) { @@ -451,6 +537,7 @@ class WebSocketIpcServer implements CommandRuntime { }; } + /// Lists known capabilities for a connected wearable. List _capabilitiesForWearable(Wearable wearable) { final capabilities = []; void addIf(String name) { @@ -484,6 +571,7 @@ class WebSocketIpcServer implements CommandRuntime { return capabilities; } + /// Looks up a connected wearable and throws if it is unavailable. Wearable _requireConnectedWearable(String deviceId) { final wearable = _connectedWearablesById[deviceId]; if (wearable == null) { @@ -492,6 +580,7 @@ class WebSocketIpcServer implements CommandRuntime { return wearable; } + /// Ensures the configured websocket path is non-empty and absolute. String _normalizePath(String path) { final trimmed = path.trim(); if (trimmed.isEmpty) { @@ -500,10 +589,12 @@ class WebSocketIpcServer implements CommandRuntime { return trimmed.startsWith('/') ? trimmed : '/$trimmed'; } + /// Encodes an event payload after coercing unsupported values to JSON-safe forms. String _jsonEncode(Map payload) { return jsonEncode(_jsonSafe(payload)); } + /// Recursively converts arbitrary values into JSON-safe representations. Object? _jsonSafe(Object? value) { if (value == null || value is num || value is bool || value is String) { return value; @@ -527,6 +618,7 @@ class WebSocketIpcServer implements CommandRuntime { return value.toString(); } + /// Normalizes arbitrary request payloads into string-keyed maps. Map _asMap(Object? value) { if (value == null) { return {}; @@ -541,6 +633,7 @@ class WebSocketIpcServer implements CommandRuntime { } } +/// Represents one connected websocket client and its active subscriptions. class _ClientSession { final WebSocket socket; final WebSocketIpcServer server; @@ -550,11 +643,21 @@ class _ClientSession { bool _closed = false; + /// Returns a log-friendly label for this client session. + String get label { + final remote = socket.closeCode == null + ? '${socket.hashCode}' + : '${socket.hashCode}:${socket.closeCode}'; + final address = socket.hashCode; + return 'ws#$address/$remote'; + } + _ClientSession({ required this.socket, required this.server, }); + /// Starts listening for websocket messages and lifecycle events. void start() { server._sendReady(this); @@ -565,13 +668,17 @@ class _ClientSession { onDone: () async { await close(); }, - onError: (_) async { + onError: (error, stackTrace) async { + logger.w( + '[connector.websocket] socket_error client=$label error=$error\n$stackTrace', + ); await close(); }, cancelOnError: true, ); } + /// Sends a JSON payload to the client. void send(Map payload) { if (_closed) { return; @@ -579,6 +686,7 @@ class _ClientSession { sendRaw(jsonEncode(payload)); } + /// Sends a pre-serialized websocket text frame to the client. void sendRaw(String payload) { if (_closed) { return; @@ -586,6 +694,7 @@ class _ClientSession { socket.add(payload); } + /// Parses and executes a single inbound websocket message. Future _handleMessage(dynamic rawMessage) async { dynamic id; try { @@ -623,6 +732,9 @@ class _ClientSession { }, ); } catch (error, stackTrace) { + logger.w( + '[connector.websocket] request_failed client=$label id=$id error=$error\n$stackTrace', + ); send( { 'id': id, @@ -636,6 +748,7 @@ class _ClientSession { } } + /// Registers or replaces a stream subscription owned by this client. Future subscribe({ required int subscriptionId, required String streamName, @@ -657,6 +770,9 @@ class _ClientSession { ); }, onError: (error, stackTrace) { + logger.w( + '[connector.websocket] stream_error client=$label subscription_id=$subscriptionId stream=$streamName device_id=$deviceId error=$error\n$stackTrace', + ); send( { 'event': 'stream_error', @@ -686,6 +802,7 @@ class _ClientSession { ); } + /// Cancels a single client-owned stream subscription. Future> unsubscribe(int subscriptionId) async { final existing = _subscriptions.remove(subscriptionId); if (existing == null) { @@ -701,6 +818,7 @@ class _ClientSession { }; } + /// Closes the client socket and cancels all active subscriptions. Future close() async { if (_closed) { return; diff --git a/open_wearable/lib/models/network/device_ip_address.dart b/open_wearable/lib/models/network/device_ip_address.dart index 8b1aee17..2592159f 100644 --- a/open_wearable/lib/models/network/device_ip_address.dart +++ b/open_wearable/lib/models/network/device_ip_address.dart @@ -1,5 +1,9 @@ import 'device_ip_address_stub.dart' if (dart.library.io) 'device_ip_address_io.dart'; +/// Resolves the best client-reachable IPv4 address for the current device. +/// +/// Native targets attempt to return the preferred LAN address. Targets without +/// `dart:io` support return `null`. Future resolveCurrentDeviceIpAddress() => resolveCurrentDeviceIpAddressImpl(); diff --git a/open_wearable/lib/models/network/device_ip_address_io.dart b/open_wearable/lib/models/network/device_ip_address_io.dart index 6be51a58..665cde90 100644 --- a/open_wearable/lib/models/network/device_ip_address_io.dart +++ b/open_wearable/lib/models/network/device_ip_address_io.dart @@ -1,30 +1,89 @@ import 'dart:io'; +/// Resolves the best client-reachable IPv4 address for the current device. +/// +/// The resolver prefers private LAN addresses on likely Wi-Fi or Ethernet +/// interfaces and de-prioritizes VPN, hotspot, or peer-to-peer interfaces. Future resolveCurrentDeviceIpAddressImpl() async { final interfaces = await NetworkInterface.list( type: InternetAddressType.IPv4, includeLoopback: false, ); - String? fallback; + _ResolvedAddress? bestMatch; + _ResolvedAddress? fallback; for (final interface in interfaces) { for (final address in interface.addresses) { final host = address.address.trim(); if (host.isEmpty || host.startsWith('169.254.')) { continue; } - if (_isPrivateIpv4(host)) { - return host; + final resolved = _ResolvedAddress( + host: host, + score: _scoreInterfaceAddress(interface.name, host), + ); + if (_isPrivateIpv4(host) && + (bestMatch == null || resolved.score > bestMatch.score)) { + bestMatch = resolved; } - fallback ??= host; + fallback ??= resolved; } } - return fallback; + return (bestMatch ?? fallback)?.host; } +/// Returns whether [host] is within one of the standard private IPv4 ranges. bool _isPrivateIpv4(String host) { return host.startsWith('10.') || host.startsWith('192.168.') || RegExp(r'^172\.(1[6-9]|2\d|3[0-1])\.').hasMatch(host); } + +/// Scores an interface/address pair for LAN reachability preference. +int _scoreInterfaceAddress(String interfaceName, String host) { + final name = interfaceName.toLowerCase(); + var score = 0; + + if (_isPrivateIpv4(host)) { + score += 100; + } + + if (name == 'en0') { + score += 80; + } + if (name.startsWith('wlan') || name.startsWith('wifi')) { + score += 80; + } + if (name.startsWith('eth') || name.startsWith('en')) { + score += 50; + } + if (name.startsWith('rmnet') || + name.startsWith('pdp_ip') || + name.startsWith('ccmni')) { + score -= 40; + } + if (name.startsWith('utun') || + name.startsWith('tun') || + name.startsWith('tap') || + name.startsWith('bridge') || + name.startsWith('awdl') || + name.startsWith('llw') || + name.startsWith('p2p') || + name.startsWith('ap')) { + score -= 100; + } + + return score; +} + +/// Holds a candidate advertised host with its selection score. +class _ResolvedAddress { + final String host; + final int score; + + const _ResolvedAddress({ + required this.host, + required this.score, + }); +} diff --git a/open_wearable/lib/models/network/device_ip_address_stub.dart b/open_wearable/lib/models/network/device_ip_address_stub.dart index 1f021899..b454d68d 100644 --- a/open_wearable/lib/models/network/device_ip_address_stub.dart +++ b/open_wearable/lib/models/network/device_ip_address_stub.dart @@ -1 +1,2 @@ +/// Returns `null` on targets that cannot inspect local network interfaces. Future resolveCurrentDeviceIpAddressImpl() async => null; diff --git a/open_wearable/lib/widgets/settings/connectors_page.dart b/open_wearable/lib/widgets/settings/connectors_page.dart index 7b7c0883..2277146e 100644 --- a/open_wearable/lib/widgets/settings/connectors_page.dart +++ b/open_wearable/lib/widgets/settings/connectors_page.dart @@ -1,7 +1,6 @@ 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/models/connectors/websocket_ipc_server.dart'; import 'package:open_wearable/models/network/device_ip_address.dart'; import 'package:open_wearable/widgets/app_toast.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; @@ -238,7 +237,6 @@ class _ConnectorsPageState extends State { return WebSocketConnectorSettings( enabled: _enabled, - host: WebSocketIpcServer.defaultHost, port: parsedPort, path: path, ); @@ -332,7 +330,7 @@ class _ConnectorsPageState extends State { scheme: 'ws', host: (_currentIpAddress?.trim().isNotEmpty ?? false) ? _currentIpAddress!.trim() - : appliedSettings.host, + : 'device-ip-unavailable', port: int.tryParse(_portController.text.trim()) ?? appliedSettings.port, path: _pathController.text.trim().isEmpty ? appliedSettings.path From 93394fc9dc4dafc856163a06078cab47e1acc2d2 Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:45:30 +0100 Subject: [PATCH 11/51] feat(connectors): migrate websocket audio playback to flutter_sound --- open_wearable/ios/Podfile.lock | 10 + .../connectors/audio_playback_config.dart | 140 ++++++++++ .../websocket_audio_playback_service.dart | 254 ++++++++++++++++++ .../flutter/generated_plugin_registrant.cc | 4 + .../linux/flutter/generated_plugins.cmake | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 2 + open_wearable/pubspec.lock | 24 ++ open_wearable/pubspec.yaml | 1 + .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 1 + 10 files changed, 440 insertions(+) create mode 100644 open_wearable/lib/models/connectors/audio_playback_config.dart create mode 100644 open_wearable/lib/models/connectors/websocket_audio_playback_service.dart diff --git a/open_wearable/ios/Podfile.lock b/open_wearable/ios/Podfile.lock index 21367c47..b8222671 100644 --- a/open_wearable/ios/Podfile.lock +++ b/open_wearable/ios/Podfile.lock @@ -42,6 +42,10 @@ PODS: - flutter_archive (0.0.1): - Flutter - ZIPFoundation (= 0.9.19) + - flutter_sound (9.30.0): + - Flutter + - flutter_sound_core (= 9.30.0) + - flutter_sound_core (9.30.0) - iOSMcuManagerLibrary (1.10.1): - SwiftCBOR (= 0.4.7) - ZIPFoundation (= 0.9.19) @@ -86,6 +90,7 @@ DEPENDENCIES: - file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`) - Flutter (from `Flutter`) - flutter_archive (from `.symlinks/plugins/flutter_archive/ios`) + - flutter_sound (from `.symlinks/plugins/flutter_sound/ios`) - mcumgr_flutter (from `.symlinks/plugins/mcumgr_flutter/ios`) - open_file_ios (from `.symlinks/plugins/open_file_ios/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) @@ -101,6 +106,7 @@ SPEC REPOS: trunk: - DKImagePickerController - DKPhotoGallery + - flutter_sound_core - iOSMcuManagerLibrary - SDWebImage - SwiftCBOR @@ -119,6 +125,8 @@ EXTERNAL SOURCES: :path: Flutter flutter_archive: :path: ".symlinks/plugins/flutter_archive/ios" + flutter_sound: + :path: ".symlinks/plugins/flutter_sound/ios" mcumgr_flutter: :path: ".symlinks/plugins/mcumgr_flutter/ios" open_file_ios: @@ -148,6 +156,8 @@ SPEC CHECKSUMS: file_selector_ios: ec57ec07954363dd730b642e765e58f199bb621a Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_archive: ad8edfd7f7d1bb12058d05424ba93e27d9930efe + flutter_sound: d95194f6476c9ad211d22b3a414d852c12c7ca44 + flutter_sound_core: 7f2626d249d3a57bfa6da892ef7e22d234482c1a iOSMcuManagerLibrary: e9555825af11a61744fe369c12e1e66621061b58 mcumgr_flutter: 969e99cc15e9fe658242669ce1075bf4612aef8a open_file_ios: 46184d802ee7959203f6392abcfa0dd49fdb5be0 diff --git a/open_wearable/lib/models/connectors/audio_playback_config.dart b/open_wearable/lib/models/connectors/audio_playback_config.dart new file mode 100644 index 00000000..588b650b --- /dev/null +++ b/open_wearable/lib/models/connectors/audio_playback_config.dart @@ -0,0 +1,140 @@ +import 'package:flutter_sound/flutter_sound.dart'; + +class AudioPlaybackConfig { + final Codec codec; + final int sampleRate; + final int numChannels; + final bool interleaved; + final int bufferSize; + + const AudioPlaybackConfig({ + this.codec = Codec.defaultCodec, + this.sampleRate = 16000, + this.numChannels = 1, + this.interleaved = true, + this.bufferSize = 8192, + }); + + AudioPlaybackConfig copyWith({ + Codec? codec, + int? sampleRate, + int? numChannels, + bool? interleaved, + int? bufferSize, + }) { + return AudioPlaybackConfig( + codec: codec ?? this.codec, + sampleRate: sampleRate ?? this.sampleRate, + numChannels: numChannels ?? this.numChannels, + interleaved: interleaved ?? this.interleaved, + bufferSize: bufferSize ?? this.bufferSize, + ); + } + + Map toJson() { + return { + 'codec': codec.name, + 'sample_rate': sampleRate, + 'num_channels': numChannels, + 'interleaved': interleaved, + 'buffer_size': bufferSize, + }; + } + + static AudioPlaybackConfig? fromOptional({ + String? codecKey, + int? sampleRate, + int? numChannels, + bool? interleaved, + int? bufferSize, + }) { + // return null if no config parameters are provided, to allow using defaults from stored sound when playing + if (codecKey == null && + sampleRate == null && + numChannels == null && + interleaved == null && + bufferSize == null) { + return null; + } + + final parsedCodec = + codecKey == null ? Codec.defaultCodec : _parseCodec(codecKey); + + final resolvedSampleRate = sampleRate ?? 16000; + final resolvedNumChannels = numChannels ?? 1; + final resolvedBufferSize = bufferSize ?? 8192; + + if (resolvedSampleRate <= 0) { + throw ArgumentError('sample_rate must be > 0'); + } + if (resolvedNumChannels <= 0) { + throw ArgumentError('num_channels must be > 0'); + } + if (resolvedBufferSize <= 0) { + throw ArgumentError('buffer_size must be > 0'); + } + + return AudioPlaybackConfig( + codec: parsedCodec, + sampleRate: resolvedSampleRate, + numChannels: resolvedNumChannels, + interleaved: interleaved ?? true, + bufferSize: resolvedBufferSize, + ); + } + + static Codec _parseCodec(String input) { + final normalized = + input.trim().toLowerCase().replaceAll('_', '').replaceAll('-', ''); + switch (normalized) { + case 'default': + case 'defaultcodec': + return Codec.defaultCodec; + case 'aacadts': + return Codec.aacADTS; + case 'opusogg': + return Codec.opusOGG; + case 'opuscaf': + return Codec.opusCAF; + case 'mp3': + return Codec.mp3; + case 'vorbisogg': + return Codec.vorbisOGG; + case 'pcm16': + return Codec.pcm16; + case 'pcm16wav': + return Codec.pcm16WAV; + case 'pcm16aiff': + return Codec.pcm16AIFF; + case 'pcm16caf': + return Codec.pcm16CAF; + case 'flac': + return Codec.flac; + case 'aacmp4': + return Codec.aacMP4; + case 'amrnb': + return Codec.amrNB; + case 'amrwb': + return Codec.amrWB; + case 'pcm8': + return Codec.pcm8; + case 'pcmfloat32': + return Codec.pcmFloat32; + case 'pcmwebm': + return Codec.pcmWebM; + case 'opuswebm': + return Codec.opusWebM; + case 'vorbiswebm': + return Codec.vorbisWebM; + case 'pcmfloat32wav': + return Codec.pcmFloat32WAV; + default: + throw ArgumentError('Unsupported codec: $input'); + } + } + + @override + String toString() { + return 'AudioPlaybackConfig(codec: ${codec.name}, sampleRate: $sampleRate, numChannels: $numChannels, interleaved: $interleaved, bufferSize: $bufferSize)'; + } +} diff --git a/open_wearable/lib/models/connectors/websocket_audio_playback_service.dart b/open_wearable/lib/models/connectors/websocket_audio_playback_service.dart new file mode 100644 index 00000000..2d426fa9 --- /dev/null +++ b/open_wearable/lib/models/connectors/websocket_audio_playback_service.dart @@ -0,0 +1,254 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:typed_data'; + +import 'package:flutter_sound/flutter_sound.dart'; + +import '../logger.dart'; +import 'audio_playback_config.dart'; + +class _StoredSound { + final Uint8List bytes; + final AudioPlaybackConfig config; + + const _StoredSound({ + required this.bytes, + required this.config, + }); + + @override + String toString() { + return '_StoredSound(bytes=${bytes.length}, config=$config)'; + } +} + +/// Handles app-side playback for websocket-delivered audio. +class WebsocketAudioPlaybackService { + final FlutterSoundPlayer _preloadedPlayer = FlutterSoundPlayer(); + final FlutterSoundPlayer _streamPlayer = FlutterSoundPlayer(); + + final Map _preloadedSounds = {}; + final Queue _streamQueue = Queue(); + + bool _playersOpen = false; + bool _streamActive = false; + bool _streamUsesFeedApi = false; + bool _drainingStreamQueue = false; + double? _streamVolume; + AudioPlaybackConfig _streamConfig = const AudioPlaybackConfig(); + + Future storeSound({ + required String soundId, + required Uint8List bytes, + required AudioPlaybackConfig config, + }) async { + final sound = _StoredSound(bytes: bytes, config: config); + _preloadedSounds[soundId] = sound; + logger.i( + '[connector.audio] stored sound_id=$soundId sound=$sound', + ); + } + + Future playStoredSound({ + required String soundId, + double? volume, + AudioPlaybackConfig? overrideConfig, + }) async { + await _ensurePlayersOpen(); + + final stored = _preloadedSounds[soundId]; + if (stored == null) { + throw StateError('Unknown sound_id: $soundId'); + } + + final config = overrideConfig ?? stored.config; + + if (volume != null) { + await _preloadedPlayer.setVolume(volume); + } + + logger.d("[connector.audio] playing stored sound_id=$soundId with override config: codec=${config.codec.name} sample_rate=${config.sampleRate} num_channels=${config.numChannels}"); + + await _preloadedPlayer.stopPlayer(); + await _preloadedPlayer.startPlayer( + fromDataBuffer: stored.bytes, + codec: config.codec, + sampleRate: config.sampleRate, + numChannels: config.numChannels, + ); + + logger.i( + '[connector.audio] playing stored sound_id=$soundId codec=${config.codec.name} sample_rate=${config.sampleRate} num_channels=${config.numChannels}', + ); + return config; + } + + Future playFromUrl({ + required String url, + double? volume, + AudioPlaybackConfig? config, + }) async { + await _ensurePlayersOpen(); + + final uri = Uri.tryParse(url); + if (uri == null || !uri.hasScheme) { + throw ArgumentError('Invalid URL: $url'); + } + if (uri.scheme != 'http' && uri.scheme != 'https') { + throw ArgumentError( + 'Only http/https URLs are supported. Got: ${uri.scheme}', + ); + } + if (uri.host == 'commons.wikimedia.org' && + uri.path.startsWith('/wiki/File:')) { + throw ArgumentError( + 'URL points to a Wikimedia page, not a direct audio file. Use the raw media URL from upload.wikimedia.org.', + ); + } + + if (volume != null) { + await _preloadedPlayer.setVolume(volume); + } + + final playbackConfig = config ?? const AudioPlaybackConfig(); + + await _preloadedPlayer.stopPlayer(); + + try { + await _preloadedPlayer.startPlayer( + fromURI: url, + codec: playbackConfig.codec, + ); + } catch (error) { + throw StateError( + 'Failed to play URL source. Ensure it is a direct audio file URL (not an HTML page). Original error: $error', + ); + } + + logger.i( + '[connector.audio] playing url source=$url codec=${playbackConfig.codec.name}', + ); + return playbackConfig; + } + + Future startStream({ + double? volume, + required AudioPlaybackConfig config, + }) async { + await _ensurePlayersOpen(); + + _streamActive = true; + _streamVolume = volume; + _streamConfig = config; + _streamQueue.clear(); + + await _streamPlayer.stopPlayer(); + if (volume != null) { + await _streamPlayer.setVolume(volume); + } + + _streamUsesFeedApi = + config.codec == Codec.pcm16 || config.codec == Codec.pcmFloat32; + + if (_streamUsesFeedApi) { + await _streamPlayer.startPlayerFromStream( + codec: config.codec, + interleaved: config.interleaved, + numChannels: config.numChannels, + sampleRate: config.sampleRate, + bufferSize: config.bufferSize, + ); + } + + logger.i( + '[connector.audio] stream started codec=${config.codec.name} sample_rate=${config.sampleRate} num_channels=${config.numChannels} interleaved=${config.interleaved} buffer_size=${config.bufferSize} mode=${_streamUsesFeedApi ? 'feed' : 'chunk'}', + ); + } + + Future pushStreamChunk(Uint8List bytes) async { + if (!_streamActive) { + throw StateError( + 'Audio stream is not active. Call start_audio_stream first.', + ); + } + + if (_streamUsesFeedApi) { + await _streamPlayer.feedUint8FromStream(bytes); + return; + } + + _streamQueue.add(bytes); + unawaited(_drainStreamQueue()); + } + + Future stopStream() async { + _streamActive = false; + _streamQueue.clear(); + _streamUsesFeedApi = false; + + if (_playersOpen) { + await _streamPlayer.stopPlayer(); + } + logger.i('[connector.audio] stream stopped'); + } + + Future dispose() async { + await stopStream(); + if (_playersOpen) { + await _preloadedPlayer.closePlayer(); + await _streamPlayer.closePlayer(); + _playersOpen = false; + } + } + + Future _ensurePlayersOpen() async { + if (_playersOpen) { + return; + } + await _preloadedPlayer.openPlayer(); + await _streamPlayer.openPlayer(); + _playersOpen = true; + } + + Future _drainStreamQueue() async { + if (_drainingStreamQueue) { + return; + } + _drainingStreamQueue = true; + try { + while (_streamActive && _streamQueue.isNotEmpty) { + final chunk = _streamQueue.removeFirst(); + if (_streamVolume != null) { + await _streamPlayer.setVolume(_streamVolume!); + } + await _playChunkAndWait(chunk); + } + } finally { + _drainingStreamQueue = false; + } + } + + Future _playChunkAndWait(Uint8List bytes) async { + final completer = Completer(); + + await _streamPlayer.stopPlayer(); + await _streamPlayer.startPlayer( + fromDataBuffer: bytes, + codec: _streamConfig.codec, + sampleRate: _streamConfig.sampleRate, + numChannels: _streamConfig.numChannels, + whenFinished: () { + if (!completer.isCompleted) { + completer.complete(); + } + }, + ); + + await completer.future.timeout( + const Duration(seconds: 30), + onTimeout: () { + logger.w('[connector.audio] stream chunk playback timeout'); + }, + ); + } +} diff --git a/open_wearable/linux/flutter/generated_plugin_registrant.cc b/open_wearable/linux/flutter/generated_plugin_registrant.cc index f547c379..73720ad6 100644 --- a/open_wearable/linux/flutter/generated_plugin_registrant.cc +++ b/open_wearable/linux/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -18,6 +19,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_sound_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSoundPlugin"); + flutter_sound_plugin_register_with_registrar(flutter_sound_registrar); g_autoptr(FlPluginRegistrar) open_file_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "OpenFileLinuxPlugin"); open_file_linux_plugin_register_with_registrar(open_file_linux_registrar); diff --git a/open_wearable/linux/flutter/generated_plugins.cmake b/open_wearable/linux/flutter/generated_plugins.cmake index 18a2dcb9..a656d703 100644 --- a/open_wearable/linux/flutter/generated_plugins.cmake +++ b/open_wearable/linux/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_linux file_selector_linux + flutter_sound open_file_linux url_launcher_linux ) diff --git a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift index 696f95be..1bd238d6 100644 --- a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,6 +9,7 @@ import audioplayers_darwin import file_picker import file_selector_macos import flutter_archive +import flutter_sound import open_file_mac import package_info_plus import share_plus @@ -22,6 +23,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterArchivePlugin.register(with: registry.registrar(forPlugin: "FlutterArchivePlugin")) + FlutterSoundPlugin.register(with: registry.registrar(forPlugin: "FlutterSoundPlugin")) OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index 7031a8b8..e4383b5c 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -358,6 +358,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.34" + flutter_sound: + dependency: "direct main" + description: + name: flutter_sound + sha256: "77f0252a2f08449d621f68b8fd617c3b391e7f862eaa94bb32f53cc2dc3bbe85" + url: "https://pub.dev" + source: hosted + version: "9.30.0" + flutter_sound_platform_interface: + dependency: transitive + description: + name: flutter_sound_platform_interface + sha256: "5ffc858fd96c6fa277e3bb25eecc100849d75a8792b1f9674d1ba817aea9f861" + url: "https://pub.dev" + source: hosted + version: "9.30.0" + flutter_sound_web: + dependency: transitive + description: + name: flutter_sound_web + sha256: "3af46f45f44768c01c83ba260855c956d0963673664947926d942aa6fbf6f6fb" + url: "https://pub.dev" + source: hosted + version: "9.30.0" flutter_staggered_grid_view: dependency: "direct main" description: diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index 6147e6aa..6e4f0a9f 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -56,6 +56,7 @@ dependencies: url_launcher: ^6.3.2 go_router: ^14.6.2 http: ^1.6.0 + flutter_sound: ^9.30.0 audioplayers: ^6.6.0 wakelock_plus: ^1.4.0 package_info_plus: ^9.0.0 diff --git a/open_wearable/windows/flutter/generated_plugin_registrant.cc b/open_wearable/windows/flutter/generated_plugin_registrant.cc index 37245d29..e5fdf9cd 100644 --- a/open_wearable/windows/flutter/generated_plugin_registrant.cc +++ b/open_wearable/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -18,6 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + FlutterSoundPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSoundPluginCApi")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); SharePlusWindowsPluginCApiRegisterWithRegistrar( diff --git a/open_wearable/windows/flutter/generated_plugins.cmake b/open_wearable/windows/flutter/generated_plugins.cmake index 154f7830..a75f32e8 100644 --- a/open_wearable/windows/flutter/generated_plugins.cmake +++ b/open_wearable/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_windows file_selector_windows + flutter_sound permission_handler_windows share_plus universal_ble From b2efa885b284f7b166e7848ad010f6bdfd6c9986 Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:45:56 +0100 Subject: [PATCH 12/51] feat(connectors): add websocket audio commands with codec configuration --- .../commands/default_ipc_commands.dart | 12 +- .../connectors/commands/param_readers.dart | 48 ++++++++ .../commands/play_sound_command.dart | 48 ++++++++ .../push_audio_stream_chunk_command.dart | 23 ++++ .../models/connectors/commands/runtime.dart | 28 +++++ .../commands/start_audio_stream_command.dart | 35 ++++++ .../commands/stop_audio_stream_command.dart | 12 ++ .../commands/store_sound_command.dart | 44 ++++++++ .../connectors/websocket_ipc_server.dart | 106 +++++++++++++++++- 9 files changed, 354 insertions(+), 2 deletions(-) create mode 100644 open_wearable/lib/models/connectors/commands/play_sound_command.dart create mode 100644 open_wearable/lib/models/connectors/commands/push_audio_stream_chunk_command.dart create mode 100644 open_wearable/lib/models/connectors/commands/start_audio_stream_command.dart create mode 100644 open_wearable/lib/models/connectors/commands/stop_audio_stream_command.dart create mode 100644 open_wearable/lib/models/connectors/commands/store_sound_command.dart 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 65c3fbef..b38c2d23 100644 --- a/open_wearable/lib/models/connectors/commands/default_ipc_commands.dart +++ b/open_wearable/lib/models/connectors/commands/default_ipc_commands.dart @@ -1,17 +1,22 @@ +import 'async_scan_command.dart'; 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 'play_sound_command.dart'; +import 'push_audio_stream_chunk_command.dart'; import 'runtime.dart'; +import 'start_audio_stream_command.dart'; import 'start_scan_command.dart'; +import 'stop_audio_stream_command.dart'; +import 'store_sound_command.dart'; import 'subscribe_command.dart'; import 'unsubscribe_command.dart'; @@ -28,6 +33,11 @@ List createDefaultIpcCommands(CommandRuntime runtime) { ConnectSystemDevicesCommand(runtime: runtime), ListConnectedCommand(runtime: runtime), DisconnectCommand(runtime: runtime), + StoreSoundCommand(runtime: runtime), + PlaySoundCommand(runtime: runtime), + StartAudioStreamCommand(runtime: runtime), + PushAudioStreamChunkCommand(runtime: runtime), + StopAudioStreamCommand(runtime: runtime), SubscribeCommand(runtime: runtime), UnsubscribeCommand(runtime: runtime), InvokeActionCommand(runtime: runtime), diff --git a/open_wearable/lib/models/connectors/commands/param_readers.dart b/open_wearable/lib/models/connectors/commands/param_readers.dart index 0dee31fa..3b49df01 100644 --- a/open_wearable/lib/models/connectors/commands/param_readers.dart +++ b/open_wearable/lib/models/connectors/commands/param_readers.dart @@ -25,6 +25,36 @@ int requireIntParam(List params, String name) { throw FormatException('Expected "$name" to be an integer.'); } +String? readOptionalStringParam(List params, String name) { + final CommandParam? param = params.where((p) => p.name == name).firstOrNull; + final Object? value = param?.value; + if (value == null) { + return null; + } + if (value is String) { + return value; + } + throw FormatException('Expected "\$name" to be a string.'); +} + +double? readOptionalDoubleParam(List params, String name) { + final CommandParam? param = params.where((p) => p.name == name).firstOrNull; + final Object? value = param?.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); + } + throw FormatException('Expected "$name" to be a number.'); +} + bool? readOptionalBoolParam(List params, String name) { final CommandParam? param = params.where((p) => p.name == name).firstOrNull; if (param == null || param.value == null) { @@ -82,3 +112,21 @@ extension on Iterable { return first; } } + +int? readOptionalIntParam(List params, String name) { + final CommandParam? param = params.where((p) => p.name == name).firstOrNull; + final Object? value = param?.value; + if (value == null) { + return null; + } + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + if (value is String) { + return int.tryParse(value); + } + throw FormatException('Expected "\$name" to be an integer.'); +} diff --git a/open_wearable/lib/models/connectors/commands/play_sound_command.dart b/open_wearable/lib/models/connectors/commands/play_sound_command.dart new file mode 100644 index 00000000..77933144 --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/play_sound_command.dart @@ -0,0 +1,48 @@ +import '../audio_playback_config.dart'; +import 'command.dart'; +import 'param_readers.dart'; +import 'runtime_command.dart'; + +class PlaySoundCommand extends RuntimeCommand { + PlaySoundCommand({required super.runtime}) + : super( + name: 'play_sound', + params: [ + CommandParam(name: 'sound_id'), + CommandParam(name: 'url'), + CommandParam(name: 'volume'), + CommandParam(name: 'codec'), + CommandParam(name: 'sample_rate'), + CommandParam(name: 'num_channels'), + ], + ); + + @override + Future> execute(List params) { + final soundId = readOptionalStringParam(params, 'sound_id'); + final url = readOptionalStringParam(params, 'url'); + + if ((soundId == null || soundId.isEmpty) && (url == null || url.isEmpty)) { + throw ArgumentError('play_sound requires either "sound_id" or "url".'); + } + if (soundId != null && + soundId.isNotEmpty && + url != null && + url.isNotEmpty) { + throw ArgumentError('Provide either "sound_id" or "url", not both.'); + } + + final config = AudioPlaybackConfig.fromOptional( + codecKey: readOptionalStringParam(params, 'codec'), + sampleRate: readOptionalIntParam(params, 'sample_rate'), + numChannels: readOptionalIntParam(params, 'num_channels'), + ); + + return runtime.playSound( + soundId: soundId, + url: url, + volume: readOptionalDoubleParam(params, 'volume'), + config: config, + ); + } +} diff --git a/open_wearable/lib/models/connectors/commands/push_audio_stream_chunk_command.dart b/open_wearable/lib/models/connectors/commands/push_audio_stream_chunk_command.dart new file mode 100644 index 00000000..210dbeaf --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/push_audio_stream_chunk_command.dart @@ -0,0 +1,23 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'command.dart'; +import 'param_readers.dart'; +import 'runtime_command.dart'; + +class PushAudioStreamChunkCommand extends RuntimeCommand { + PushAudioStreamChunkCommand({required super.runtime}) + : super( + name: 'push_audio_stream_chunk', + params: [ + CommandParam(name: 'audio_base64', required: true), + ], + ); + + @override + Future> execute(List params) { + final audioBase64 = requireStringParam(params, 'audio_base64'); + final Uint8List bytes = base64Decode(audioBase64); + return runtime.pushAudioStreamChunk(bytes: bytes); + } +} diff --git a/open_wearable/lib/models/connectors/commands/runtime.dart b/open_wearable/lib/models/connectors/commands/runtime.dart index 9c6507a6..f70619b2 100644 --- a/open_wearable/lib/models/connectors/commands/runtime.dart +++ b/open_wearable/lib/models/connectors/commands/runtime.dart @@ -1,5 +1,9 @@ +import 'dart:typed_data'; + import 'package:open_earable_flutter/open_earable_flutter.dart'; +import '../audio_playback_config.dart'; + abstract class CommandRuntime { List get methods; @@ -29,6 +33,30 @@ abstract class CommandRuntime { required String deviceId, }); + Future> storeSound({ + required String soundId, + required Uint8List bytes, + required AudioPlaybackConfig config, + }); + + Future> playSound({ + String? soundId, + String? url, + double? volume, + AudioPlaybackConfig? config, + }); + + Future> startAudioStream({ + double? volume, + required AudioPlaybackConfig config, + }); + + Future> pushAudioStreamChunk({ + required Uint8List bytes, + }); + + Future> stopAudioStream(); + Future createSubscriptionId(); Future attachStreamSubscription({ diff --git a/open_wearable/lib/models/connectors/commands/start_audio_stream_command.dart b/open_wearable/lib/models/connectors/commands/start_audio_stream_command.dart new file mode 100644 index 00000000..694d7a0a --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/start_audio_stream_command.dart @@ -0,0 +1,35 @@ +import '../audio_playback_config.dart'; +import 'command.dart'; +import 'param_readers.dart'; +import 'runtime_command.dart'; + +class StartAudioStreamCommand extends RuntimeCommand { + StartAudioStreamCommand({required super.runtime}) + : super( + name: 'start_audio_stream', + params: [ + CommandParam(name: 'volume'), + CommandParam(name: 'codec'), + CommandParam(name: 'sample_rate'), + CommandParam(name: 'num_channels'), + CommandParam(name: 'interleaved'), + CommandParam(name: 'buffer_size'), + ], + ); + + @override + Future> execute(List params) { + final config = AudioPlaybackConfig.fromOptional( + codecKey: readOptionalStringParam(params, 'codec'), + sampleRate: readOptionalIntParam(params, 'sample_rate'), + numChannels: readOptionalIntParam(params, 'num_channels'), + interleaved: readOptionalBoolParam(params, 'interleaved'), + bufferSize: readOptionalIntParam(params, 'buffer_size'), + ); + + return runtime.startAudioStream( + volume: readOptionalDoubleParam(params, 'volume'), + config: config ?? AudioPlaybackConfig(), + ); + } +} diff --git a/open_wearable/lib/models/connectors/commands/stop_audio_stream_command.dart b/open_wearable/lib/models/connectors/commands/stop_audio_stream_command.dart new file mode 100644 index 00000000..e7782980 --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/stop_audio_stream_command.dart @@ -0,0 +1,12 @@ +import 'command.dart'; +import 'runtime_command.dart'; + +class StopAudioStreamCommand extends RuntimeCommand { + StopAudioStreamCommand({required super.runtime}) + : super(name: 'stop_audio_stream'); + + @override + Future> execute(List params) { + return runtime.stopAudioStream(); + } +} diff --git a/open_wearable/lib/models/connectors/commands/store_sound_command.dart b/open_wearable/lib/models/connectors/commands/store_sound_command.dart new file mode 100644 index 00000000..242bb696 --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/store_sound_command.dart @@ -0,0 +1,44 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import '../audio_playback_config.dart'; +import 'command.dart'; +import 'param_readers.dart'; +import 'runtime_command.dart'; + +class StoreSoundCommand extends RuntimeCommand { + StoreSoundCommand({required super.runtime}) + : super( + name: 'store_sound', + params: [ + CommandParam(name: 'sound_id', required: true), + CommandParam(name: 'audio_base64', required: true), + CommandParam(name: 'codec'), + CommandParam(name: 'sample_rate'), + CommandParam(name: 'num_channels'), + CommandParam(name: 'interleaved'), + CommandParam(name: 'buffer_size'), + ], + ); + + @override + Future> execute(List params) { + final soundId = requireStringParam(params, 'sound_id'); + final audioBase64 = requireStringParam(params, 'audio_base64'); + final Uint8List bytes = base64Decode(audioBase64); + + final config = AudioPlaybackConfig.fromOptional( + codecKey: readOptionalStringParam(params, 'codec'), + sampleRate: readOptionalIntParam(params, 'sample_rate'), + numChannels: readOptionalIntParam(params, 'num_channels'), + interleaved: readOptionalBoolParam(params, 'interleaved'), + bufferSize: readOptionalIntParam(params, 'buffer_size'), + ); + + return runtime.storeSound( + soundId: soundId, + bytes: bytes, + config: config ?? AudioPlaybackConfig(), + ); + } +} diff --git a/open_wearable/lib/models/connectors/websocket_ipc_server.dart b/open_wearable/lib/models/connectors/websocket_ipc_server.dart index e8e8b60c..d91a1c3c 100644 --- a/open_wearable/lib/models/connectors/websocket_ipc_server.dart +++ b/open_wearable/lib/models/connectors/websocket_ipc_server.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; import 'package:open_wearable/models/connectors/commands/command.dart'; @@ -8,6 +9,8 @@ import 'package:open_wearable/models/connectors/commands/default_action_commands 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/connectors/audio_playback_config.dart'; +import 'package:open_wearable/models/connectors/websocket_audio_playback_service.dart'; import 'package:open_wearable/models/logger.dart'; import 'package:open_wearable/models/network/device_ip_address.dart'; import 'package:open_wearable/models/wearable_connector.dart'; @@ -19,6 +22,7 @@ class WebSocketIpcServer implements CommandRuntime { final WearableManager _wearableManager; final WearableConnector _wearableConnector; + final WebsocketAudioPlaybackService _audioPlaybackService; HttpServer? _httpServer; final InternetAddress _host = InternetAddress.anyIPv4; @@ -44,8 +48,11 @@ class WebSocketIpcServer implements CommandRuntime { WebSocketIpcServer({ WearableManager? wearableManager, WearableConnector? wearableConnector, + WebsocketAudioPlaybackService? audioPlaybackService, }) : _wearableManager = wearableManager ?? WearableManager(), - _wearableConnector = wearableConnector ?? WearableConnector() { + _wearableConnector = wearableConnector ?? WearableConnector(), + _audioPlaybackService = + audioPlaybackService ?? WebsocketAudioPlaybackService() { for (final command in createDefaultIpcCommands(this)) { addCommand(command); } @@ -166,6 +173,7 @@ class WebSocketIpcServer implements CommandRuntime { _discoveredDevicesById.clear(); _connectedWearablesById.clear(); _advertisedHost = null; + await _audioPlaybackService.stopStream(); logger.i('[connector.websocket] stopped'); } @@ -305,6 +313,102 @@ class WebSocketIpcServer implements CommandRuntime { return {'disconnected': true}; } + /// Stores a sound in app memory for later playback. + @override + Future> storeSound({ + required String soundId, + required Uint8List bytes, + required AudioPlaybackConfig config, + }) async { + await _audioPlaybackService.storeSound( + soundId: soundId, + bytes: bytes, + config: config, + ); + return { + 'sound_id': soundId, + 'stored': true, + 'bytes': bytes.length, + 'config': config.toJson(), + }; + } + + /// Plays a previously stored sound. + @override + Future> playSound({ + String? soundId, + String? url, + double? volume, + AudioPlaybackConfig? config, + }) async { + final hasSoundId = soundId != null && soundId.trim().isNotEmpty; + final hasUrl = url != null && url.trim().isNotEmpty; + if (!hasSoundId && !hasUrl) { + throw ArgumentError('play_sound requires either "sound_id" or "url".'); + } + if (hasSoundId && hasUrl) { + throw ArgumentError('Provide either "sound_id" or "url", not both.'); + } + + if (hasSoundId) { + final usedConfig = await _audioPlaybackService.playStoredSound( + soundId: soundId, + volume: volume, + overrideConfig: config, + ); + return { + 'source': 'sound_id', + 'sound_id': soundId, + 'playing': true, + 'config': usedConfig.toJson(), + }; + } + + final usedConfig = await _audioPlaybackService.playFromUrl( + url: url!, + volume: volume, + config: config, + ); + return { + 'source': 'url', + 'url': url, + 'playing': true, + 'config': usedConfig.toJson(), + }; + } + + /// Starts chunked audio stream playback. + @override + Future> startAudioStream({ + double? volume, + required AudioPlaybackConfig config, + }) async { + await _audioPlaybackService.startStream( + volume: volume, + config: config, + ); + return { + 'started': true, + 'config': config.toJson(), + }; + } + + /// Pushes one audio chunk into the active playback stream queue. + @override + Future> pushAudioStreamChunk({ + required Uint8List bytes, + }) async { + await _audioPlaybackService.pushStreamChunk(bytes); + return {'queued_bytes': bytes.length}; + } + + /// Stops chunked audio stream playback and clears the queue. + @override + Future> stopAudioStream() async { + await _audioPlaybackService.stopStream(); + return {'stopped': true}; + } + /// Allocates the next unique subscription id for a client. @override Future createSubscriptionId() async { From 081c22e20892b5607bdf05b268fcfdebd826e40a Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:46:09 +0100 Subject: [PATCH 13/51] docs(connectors): document websocket audio sources and codec options --- .../docs/connectors/websocket-ipc-api.md | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/open_wearable/docs/connectors/websocket-ipc-api.md b/open_wearable/docs/connectors/websocket-ipc-api.md index a9084490..c25ee629 100644 --- a/open_wearable/docs/connectors/websocket-ipc-api.md +++ b/open_wearable/docs/connectors/websocket-ipc-api.md @@ -92,6 +92,11 @@ Other event messages: | `connect_system_devices` | `{"ignored_device_ids"?:string[]}` | `WearableSummary[]` | | `list_connected` | `{}` | `WearableSummary[]` | | `disconnect` | `{"device_id":string}` | `{"disconnected":true}` | +| `store_sound` | `{"sound_id":string,"audio_base64":string,"codec"?:string,"sample_rate"?:int,"num_channels"?:int,"interleaved"?:bool,"buffer_size"?:int}` | `{"sound_id":string,"stored":true,"bytes":int,"config":object}` | +| `play_sound` | `{"sound_id"?:string,"url"?:string,"volume"?:number,"codec"?:string,"sample_rate"?:int,"num_channels"?:int}` | `{"source":"sound_id"\\|"url","playing":true,"config":object,...}` | +| `start_audio_stream` | `{"volume"?:number,"codec"?:string,"sample_rate"?:int,"num_channels"?:int,"interleaved"?:bool,"buffer_size"?:int}` | `{"started":true,"config":object}` | +| `push_audio_stream_chunk` | `{"audio_base64":string}` | `{"queued_bytes":int}` | +| `stop_audio_stream` | `{}` | `{"stopped":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 | @@ -137,6 +142,110 @@ Note: - `scan` is not a direct `subscribe` stream. - Use `start_scan_async` to receive scan data via `stream` events. +## Audio Playback Over WebSocket + +The connector supports two audio modes: + +1. Distinct preloaded sounds (store once, play many times) +2. Chunked audio stream playback (headphone-like continuous feed) + +### 1) Distinct Preloaded Sounds + +Store sound bytes in memory: + +```json +{ + "id": 20, + "method": "store_sound", + "params": { + "sound_id": "beep_ok", + "audio_base64": "" + } +} +``` + +Play a stored sound: + +```json +{ + "id": 21, + "method": "play_sound", + "params": { + "sound_id": "beep_ok", + "volume": 1.0 + } +} +``` + +Play directly from a URL: + +```json +{ + "id": 22, + "method": "play_sound", + "params": { + "url": "https://example.com/notification.wav", + "volume": 1.0 + } +} +``` + +`play_sound` rules: + +- Provide exactly one source: `sound_id` or `url`. +- If both are set, the server returns an error. + +### 2) Chunked Audio Stream + +Start stream playback mode: + +```json +{ + "id": 30, + "method": "start_audio_stream", + "params": { + "volume": 1.0 + } +} +``` + +Push chunks continuously: + +```json +{ + "id": 31, + "method": "push_audio_stream_chunk", + "params": { + "audio_base64": "" + } +} +``` + +Stop stream playback: + +```json +{ + "id": 32, + "method": "stop_audio_stream", + "params": {} +} +``` + +Notes: + +- `audio_base64` must be raw audio file/chunk bytes encoded as Base64. +- Default config when omitted: + - `codec=defaultCodec` + - `sample_rate=16000` + - `num_channels=1` + - `interleaved=true` + - `buffer_size=8192` +- PCM stream mode: + - `codec=pcm16` or `codec=pcmFloat32` enables low-latency feed mode (`startPlayerFromStream`). + - Other codecs are handled as queued chunk playback. +- Keep chunk sizes moderate to reduce latency and memory pressure. +- Call `start_audio_stream` before first chunk; otherwise the server returns an error. + ## Data Shapes ### DiscoveredDevice @@ -208,3 +317,14 @@ Note: 2. Pick `sensor_id`. 3. `subscribe` with `stream="sensor_values"` and `args={"sensor_id":"..."}`. 4. `unsubscribe` when done. + +### Distinct sound playback + +1. `store_sound` with `sound_id` and `audio_base64`. +2. `play_sound` with the same `sound_id`. + +### Live audio streaming + +1. `start_audio_stream`. +2. Repeatedly call `push_audio_stream_chunk`. +3. `stop_audio_stream` when done. From 7c255c51598dad4c9f0591819fd872f88e671c76 Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:07:47 +0100 Subject: [PATCH 14/51] feat(connectors): switch websocket audio playback to audioplayers --- open_wearable/ios/Podfile.lock | 10 - .../connectors/audio_playback_config.dart | 84 ++++---- .../commands/default_ipc_commands.dart | 6 - .../push_audio_stream_chunk_command.dart | 23 --- .../models/connectors/commands/runtime.dart | 11 -- .../commands/start_audio_stream_command.dart | 35 ---- .../commands/stop_audio_stream_command.dart | 12 -- .../websocket_audio_playback_service.dart | 182 +++--------------- .../connectors/websocket_ipc_server.dart | 33 ---- .../flutter/generated_plugin_registrant.cc | 4 - .../linux/flutter/generated_plugins.cmake | 1 - .../Flutter/GeneratedPluginRegistrant.swift | 2 - .../flutter/generated_plugin_registrant.cc | 3 - .../windows/flutter/generated_plugins.cmake | 1 - 14 files changed, 77 insertions(+), 330 deletions(-) delete mode 100644 open_wearable/lib/models/connectors/commands/push_audio_stream_chunk_command.dart delete mode 100644 open_wearable/lib/models/connectors/commands/start_audio_stream_command.dart delete mode 100644 open_wearable/lib/models/connectors/commands/stop_audio_stream_command.dart diff --git a/open_wearable/ios/Podfile.lock b/open_wearable/ios/Podfile.lock index b8222671..21367c47 100644 --- a/open_wearable/ios/Podfile.lock +++ b/open_wearable/ios/Podfile.lock @@ -42,10 +42,6 @@ PODS: - flutter_archive (0.0.1): - Flutter - ZIPFoundation (= 0.9.19) - - flutter_sound (9.30.0): - - Flutter - - flutter_sound_core (= 9.30.0) - - flutter_sound_core (9.30.0) - iOSMcuManagerLibrary (1.10.1): - SwiftCBOR (= 0.4.7) - ZIPFoundation (= 0.9.19) @@ -90,7 +86,6 @@ DEPENDENCIES: - file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`) - Flutter (from `Flutter`) - flutter_archive (from `.symlinks/plugins/flutter_archive/ios`) - - flutter_sound (from `.symlinks/plugins/flutter_sound/ios`) - mcumgr_flutter (from `.symlinks/plugins/mcumgr_flutter/ios`) - open_file_ios (from `.symlinks/plugins/open_file_ios/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) @@ -106,7 +101,6 @@ SPEC REPOS: trunk: - DKImagePickerController - DKPhotoGallery - - flutter_sound_core - iOSMcuManagerLibrary - SDWebImage - SwiftCBOR @@ -125,8 +119,6 @@ EXTERNAL SOURCES: :path: Flutter flutter_archive: :path: ".symlinks/plugins/flutter_archive/ios" - flutter_sound: - :path: ".symlinks/plugins/flutter_sound/ios" mcumgr_flutter: :path: ".symlinks/plugins/mcumgr_flutter/ios" open_file_ios: @@ -156,8 +148,6 @@ SPEC CHECKSUMS: file_selector_ios: ec57ec07954363dd730b642e765e58f199bb621a Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_archive: ad8edfd7f7d1bb12058d05424ba93e27d9930efe - flutter_sound: d95194f6476c9ad211d22b3a414d852c12c7ca44 - flutter_sound_core: 7f2626d249d3a57bfa6da892ef7e22d234482c1a iOSMcuManagerLibrary: e9555825af11a61744fe369c12e1e66621061b58 mcumgr_flutter: 969e99cc15e9fe658242669ce1075bf4612aef8a open_file_ios: 46184d802ee7959203f6392abcfa0dd49fdb5be0 diff --git a/open_wearable/lib/models/connectors/audio_playback_config.dart b/open_wearable/lib/models/connectors/audio_playback_config.dart index 588b650b..11f97496 100644 --- a/open_wearable/lib/models/connectors/audio_playback_config.dart +++ b/open_wearable/lib/models/connectors/audio_playback_config.dart @@ -1,14 +1,12 @@ -import 'package:flutter_sound/flutter_sound.dart'; - class AudioPlaybackConfig { - final Codec codec; + final String codec; final int sampleRate; final int numChannels; final bool interleaved; final int bufferSize; const AudioPlaybackConfig({ - this.codec = Codec.defaultCodec, + this.codec = 'default', this.sampleRate = 16000, this.numChannels = 1, this.interleaved = true, @@ -16,7 +14,7 @@ class AudioPlaybackConfig { }); AudioPlaybackConfig copyWith({ - Codec? codec, + String? codec, int? sampleRate, int? numChannels, bool? interleaved, @@ -33,7 +31,7 @@ class AudioPlaybackConfig { Map toJson() { return { - 'codec': codec.name, + 'codec': codec, 'sample_rate': sampleRate, 'num_channels': numChannels, 'interleaved': interleaved, @@ -41,6 +39,8 @@ class AudioPlaybackConfig { }; } + String get normalizedCodec => _normalizeCodec(codec); + static AudioPlaybackConfig? fromOptional({ String? codecKey, int? sampleRate, @@ -48,7 +48,6 @@ class AudioPlaybackConfig { bool? interleaved, int? bufferSize, }) { - // return null if no config parameters are provided, to allow using defaults from stored sound when playing if (codecKey == null && sampleRate == null && numChannels == null && @@ -57,9 +56,7 @@ class AudioPlaybackConfig { return null; } - final parsedCodec = - codecKey == null ? Codec.defaultCodec : _parseCodec(codecKey); - + final resolvedCodec = codecKey == null ? 'default' : _parseCodec(codecKey); final resolvedSampleRate = sampleRate ?? 16000; final resolvedNumChannels = numChannels ?? 1; final resolvedBufferSize = bufferSize ?? 8192; @@ -75,7 +72,7 @@ class AudioPlaybackConfig { } return AudioPlaybackConfig( - codec: parsedCodec, + codec: resolvedCodec, sampleRate: resolvedSampleRate, numChannels: resolvedNumChannels, interleaved: interleaved ?? true, @@ -83,58 +80,73 @@ class AudioPlaybackConfig { ); } - static Codec _parseCodec(String input) { - final normalized = - input.trim().toLowerCase().replaceAll('_', '').replaceAll('-', ''); + static String _parseCodec(String input) { + final normalized = _normalizeCodec(input); switch (normalized) { case 'default': - case 'defaultcodec': - return Codec.defaultCodec; case 'aacadts': - return Codec.aacADTS; case 'opusogg': - return Codec.opusOGG; case 'opuscaf': - return Codec.opusCAF; case 'mp3': - return Codec.mp3; case 'vorbisogg': - return Codec.vorbisOGG; case 'pcm16': - return Codec.pcm16; case 'pcm16wav': - return Codec.pcm16WAV; case 'pcm16aiff': - return Codec.pcm16AIFF; case 'pcm16caf': - return Codec.pcm16CAF; case 'flac': - return Codec.flac; case 'aacmp4': - return Codec.aacMP4; case 'amrnb': - return Codec.amrNB; case 'amrwb': - return Codec.amrWB; case 'pcm8': - return Codec.pcm8; case 'pcmfloat32': - return Codec.pcmFloat32; case 'pcmwebm': - return Codec.pcmWebM; case 'opuswebm': - return Codec.opusWebM; case 'vorbiswebm': - return Codec.vorbisWebM; case 'pcmfloat32wav': - return Codec.pcmFloat32WAV; + return normalized; default: throw ArgumentError('Unsupported codec: $input'); } } + static String _normalizeCodec(String input) { + final normalized = + input.trim().toLowerCase().replaceAll('_', '').replaceAll('-', ''); + if (normalized == 'defaultcodec') { + return 'default'; + } + return normalized; + } + + String fileExtension() { + switch (normalizedCodec) { + case 'mp3': + return 'mp3'; + case 'flac': + return 'flac'; + case 'aacadts': + case 'aacmp4': + return 'm4a'; + case 'pcm16wav': + case 'pcmfloat32wav': + case 'pcm16': + case 'pcmfloat32': + case 'pcm8': + return 'wav'; + case 'opusogg': + case 'vorbisogg': + return 'ogg'; + case 'opuswebm': + case 'vorbiswebm': + case 'pcmwebm': + return 'webm'; + default: + return 'bin'; + } + } + @override String toString() { - return 'AudioPlaybackConfig(codec: ${codec.name}, sampleRate: $sampleRate, numChannels: $numChannels, interleaved: $interleaved, bufferSize: $bufferSize)'; + return 'AudioPlaybackConfig(codec: $codec, sampleRate: $sampleRate, numChannels: $numChannels, interleaved: $interleaved, bufferSize: $bufferSize)'; } } 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 b38c2d23..87ecd6a4 100644 --- a/open_wearable/lib/models/connectors/commands/default_ipc_commands.dart +++ b/open_wearable/lib/models/connectors/commands/default_ipc_commands.dart @@ -11,11 +11,8 @@ import 'list_connected_command.dart'; import 'methods_command.dart'; import 'ping_command.dart'; import 'play_sound_command.dart'; -import 'push_audio_stream_chunk_command.dart'; import 'runtime.dart'; -import 'start_audio_stream_command.dart'; import 'start_scan_command.dart'; -import 'stop_audio_stream_command.dart'; import 'store_sound_command.dart'; import 'subscribe_command.dart'; import 'unsubscribe_command.dart'; @@ -35,9 +32,6 @@ List createDefaultIpcCommands(CommandRuntime runtime) { DisconnectCommand(runtime: runtime), StoreSoundCommand(runtime: runtime), PlaySoundCommand(runtime: runtime), - StartAudioStreamCommand(runtime: runtime), - PushAudioStreamChunkCommand(runtime: runtime), - StopAudioStreamCommand(runtime: runtime), SubscribeCommand(runtime: runtime), UnsubscribeCommand(runtime: runtime), InvokeActionCommand(runtime: runtime), diff --git a/open_wearable/lib/models/connectors/commands/push_audio_stream_chunk_command.dart b/open_wearable/lib/models/connectors/commands/push_audio_stream_chunk_command.dart deleted file mode 100644 index 210dbeaf..00000000 --- a/open_wearable/lib/models/connectors/commands/push_audio_stream_chunk_command.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'dart:convert'; -import 'dart:typed_data'; - -import 'command.dart'; -import 'param_readers.dart'; -import 'runtime_command.dart'; - -class PushAudioStreamChunkCommand extends RuntimeCommand { - PushAudioStreamChunkCommand({required super.runtime}) - : super( - name: 'push_audio_stream_chunk', - params: [ - CommandParam(name: 'audio_base64', required: true), - ], - ); - - @override - Future> execute(List params) { - final audioBase64 = requireStringParam(params, 'audio_base64'); - final Uint8List bytes = base64Decode(audioBase64); - return runtime.pushAudioStreamChunk(bytes: bytes); - } -} diff --git a/open_wearable/lib/models/connectors/commands/runtime.dart b/open_wearable/lib/models/connectors/commands/runtime.dart index f70619b2..4bc0ec86 100644 --- a/open_wearable/lib/models/connectors/commands/runtime.dart +++ b/open_wearable/lib/models/connectors/commands/runtime.dart @@ -46,17 +46,6 @@ abstract class CommandRuntime { AudioPlaybackConfig? config, }); - Future> startAudioStream({ - double? volume, - required AudioPlaybackConfig config, - }); - - Future> pushAudioStreamChunk({ - required Uint8List bytes, - }); - - Future> stopAudioStream(); - Future createSubscriptionId(); Future attachStreamSubscription({ diff --git a/open_wearable/lib/models/connectors/commands/start_audio_stream_command.dart b/open_wearable/lib/models/connectors/commands/start_audio_stream_command.dart deleted file mode 100644 index 694d7a0a..00000000 --- a/open_wearable/lib/models/connectors/commands/start_audio_stream_command.dart +++ /dev/null @@ -1,35 +0,0 @@ -import '../audio_playback_config.dart'; -import 'command.dart'; -import 'param_readers.dart'; -import 'runtime_command.dart'; - -class StartAudioStreamCommand extends RuntimeCommand { - StartAudioStreamCommand({required super.runtime}) - : super( - name: 'start_audio_stream', - params: [ - CommandParam(name: 'volume'), - CommandParam(name: 'codec'), - CommandParam(name: 'sample_rate'), - CommandParam(name: 'num_channels'), - CommandParam(name: 'interleaved'), - CommandParam(name: 'buffer_size'), - ], - ); - - @override - Future> execute(List params) { - final config = AudioPlaybackConfig.fromOptional( - codecKey: readOptionalStringParam(params, 'codec'), - sampleRate: readOptionalIntParam(params, 'sample_rate'), - numChannels: readOptionalIntParam(params, 'num_channels'), - interleaved: readOptionalBoolParam(params, 'interleaved'), - bufferSize: readOptionalIntParam(params, 'buffer_size'), - ); - - return runtime.startAudioStream( - volume: readOptionalDoubleParam(params, 'volume'), - config: config ?? AudioPlaybackConfig(), - ); - } -} diff --git a/open_wearable/lib/models/connectors/commands/stop_audio_stream_command.dart b/open_wearable/lib/models/connectors/commands/stop_audio_stream_command.dart deleted file mode 100644 index e7782980..00000000 --- a/open_wearable/lib/models/connectors/commands/stop_audio_stream_command.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'command.dart'; -import 'runtime_command.dart'; - -class StopAudioStreamCommand extends RuntimeCommand { - StopAudioStreamCommand({required super.runtime}) - : super(name: 'stop_audio_stream'); - - @override - Future> execute(List params) { - return runtime.stopAudioStream(); - } -} diff --git a/open_wearable/lib/models/connectors/websocket_audio_playback_service.dart b/open_wearable/lib/models/connectors/websocket_audio_playback_service.dart index 2d426fa9..95803d1d 100644 --- a/open_wearable/lib/models/connectors/websocket_audio_playback_service.dart +++ b/open_wearable/lib/models/connectors/websocket_audio_playback_service.dart @@ -1,8 +1,8 @@ -import 'dart:async'; -import 'dart:collection'; import 'dart:typed_data'; -import 'package:flutter_sound/flutter_sound.dart'; +import 'package:audioplayers/audioplayers.dart'; +import 'package:path_provider/path_provider.dart'; +import 'dart:io'; import '../logger.dart'; import 'audio_playback_config.dart'; @@ -24,18 +24,9 @@ class _StoredSound { /// Handles app-side playback for websocket-delivered audio. class WebsocketAudioPlaybackService { - final FlutterSoundPlayer _preloadedPlayer = FlutterSoundPlayer(); - final FlutterSoundPlayer _streamPlayer = FlutterSoundPlayer(); + final AudioPlayer _preloadedPlayer = AudioPlayer(); final Map _preloadedSounds = {}; - final Queue _streamQueue = Queue(); - - bool _playersOpen = false; - bool _streamActive = false; - bool _streamUsesFeedApi = false; - bool _drainingStreamQueue = false; - double? _streamVolume; - AudioPlaybackConfig _streamConfig = const AudioPlaybackConfig(); Future storeSound({ required String soundId, @@ -44,9 +35,7 @@ class WebsocketAudioPlaybackService { }) async { final sound = _StoredSound(bytes: bytes, config: config); _preloadedSounds[soundId] = sound; - logger.i( - '[connector.audio] stored sound_id=$soundId sound=$sound', - ); + logger.i('[connector.audio] stored sound_id=$soundId sound=$sound'); } Future playStoredSound({ @@ -54,31 +43,27 @@ class WebsocketAudioPlaybackService { double? volume, AudioPlaybackConfig? overrideConfig, }) async { - await _ensurePlayersOpen(); - final stored = _preloadedSounds[soundId]; if (stored == null) { throw StateError('Unknown sound_id: $soundId'); } final config = overrideConfig ?? stored.config; - if (volume != null) { await _preloadedPlayer.setVolume(volume); } - logger.d("[connector.audio] playing stored sound_id=$soundId with override config: codec=${config.codec.name} sample_rate=${config.sampleRate} num_channels=${config.numChannels}"); - - await _preloadedPlayer.stopPlayer(); - await _preloadedPlayer.startPlayer( - fromDataBuffer: stored.bytes, - codec: config.codec, - sampleRate: config.sampleRate, - numChannels: config.numChannels, + final filePath = await _writeTempAudioFile( + stored.bytes, + prefix: 'stored_$soundId', + extension: config.fileExtension(), ); + await _preloadedPlayer.stop(); + await _preloadedPlayer.play(DeviceFileSource(filePath)); + logger.i( - '[connector.audio] playing stored sound_id=$soundId codec=${config.codec.name} sample_rate=${config.sampleRate} num_channels=${config.numChannels}', + '[connector.audio] playing stored sound_id=$soundId codec=${config.codec} sample_rate=${config.sampleRate} num_channels=${config.numChannels}', ); return config; } @@ -88,8 +73,6 @@ class WebsocketAudioPlaybackService { double? volume, AudioPlaybackConfig? config, }) async { - await _ensurePlayersOpen(); - final uri = Uri.tryParse(url); if (uri == null || !uri.hasScheme) { throw ArgumentError('Invalid URL: $url'); @@ -106,19 +89,16 @@ class WebsocketAudioPlaybackService { ); } + final playbackConfig = config ?? const AudioPlaybackConfig(); + if (volume != null) { await _preloadedPlayer.setVolume(volume); } - final playbackConfig = config ?? const AudioPlaybackConfig(); - - await _preloadedPlayer.stopPlayer(); + await _preloadedPlayer.stop(); try { - await _preloadedPlayer.startPlayer( - fromURI: url, - codec: playbackConfig.codec, - ); + await _preloadedPlayer.play(UrlSource(url)); } catch (error) { throw StateError( 'Failed to play URL source. Ensure it is a direct audio file URL (not an HTML page). Original error: $error', @@ -126,129 +106,25 @@ class WebsocketAudioPlaybackService { } logger.i( - '[connector.audio] playing url source=$url codec=${playbackConfig.codec.name}', + '[connector.audio] playing url source=$url codec_hint=${playbackConfig.codec}', ); return playbackConfig; } - Future startStream({ - double? volume, - required AudioPlaybackConfig config, - }) async { - await _ensurePlayersOpen(); - - _streamActive = true; - _streamVolume = volume; - _streamConfig = config; - _streamQueue.clear(); - - await _streamPlayer.stopPlayer(); - if (volume != null) { - await _streamPlayer.setVolume(volume); - } - - _streamUsesFeedApi = - config.codec == Codec.pcm16 || config.codec == Codec.pcmFloat32; - - if (_streamUsesFeedApi) { - await _streamPlayer.startPlayerFromStream( - codec: config.codec, - interleaved: config.interleaved, - numChannels: config.numChannels, - sampleRate: config.sampleRate, - bufferSize: config.bufferSize, - ); - } - - logger.i( - '[connector.audio] stream started codec=${config.codec.name} sample_rate=${config.sampleRate} num_channels=${config.numChannels} interleaved=${config.interleaved} buffer_size=${config.bufferSize} mode=${_streamUsesFeedApi ? 'feed' : 'chunk'}', - ); - } - - Future pushStreamChunk(Uint8List bytes) async { - if (!_streamActive) { - throw StateError( - 'Audio stream is not active. Call start_audio_stream first.', - ); - } - - if (_streamUsesFeedApi) { - await _streamPlayer.feedUint8FromStream(bytes); - return; - } - - _streamQueue.add(bytes); - unawaited(_drainStreamQueue()); - } - - Future stopStream() async { - _streamActive = false; - _streamQueue.clear(); - _streamUsesFeedApi = false; - - if (_playersOpen) { - await _streamPlayer.stopPlayer(); - } - logger.i('[connector.audio] stream stopped'); - } - Future dispose() async { - await stopStream(); - if (_playersOpen) { - await _preloadedPlayer.closePlayer(); - await _streamPlayer.closePlayer(); - _playersOpen = false; - } + await _preloadedPlayer.dispose(); } - Future _ensurePlayersOpen() async { - if (_playersOpen) { - return; - } - await _preloadedPlayer.openPlayer(); - await _streamPlayer.openPlayer(); - _playersOpen = true; - } - - Future _drainStreamQueue() async { - if (_drainingStreamQueue) { - return; - } - _drainingStreamQueue = true; - try { - while (_streamActive && _streamQueue.isNotEmpty) { - final chunk = _streamQueue.removeFirst(); - if (_streamVolume != null) { - await _streamPlayer.setVolume(_streamVolume!); - } - await _playChunkAndWait(chunk); - } - } finally { - _drainingStreamQueue = false; - } - } - - Future _playChunkAndWait(Uint8List bytes) async { - final completer = Completer(); - - await _streamPlayer.stopPlayer(); - await _streamPlayer.startPlayer( - fromDataBuffer: bytes, - codec: _streamConfig.codec, - sampleRate: _streamConfig.sampleRate, - numChannels: _streamConfig.numChannels, - whenFinished: () { - if (!completer.isCompleted) { - completer.complete(); - } - }, - ); - - await completer.future.timeout( - const Duration(seconds: 30), - onTimeout: () { - logger.w('[connector.audio] stream chunk playback timeout'); - }, + Future _writeTempAudioFile( + Uint8List bytes, { + required String prefix, + required String extension, + }) async { + final dir = await getTemporaryDirectory(); + final file = File( + '${dir.path}/${prefix}_${DateTime.now().microsecondsSinceEpoch}.$extension', ); + await file.writeAsBytes(bytes, flush: true); + return file.path; } } diff --git a/open_wearable/lib/models/connectors/websocket_ipc_server.dart b/open_wearable/lib/models/connectors/websocket_ipc_server.dart index d91a1c3c..39b82b5e 100644 --- a/open_wearable/lib/models/connectors/websocket_ipc_server.dart +++ b/open_wearable/lib/models/connectors/websocket_ipc_server.dart @@ -173,7 +173,6 @@ class WebSocketIpcServer implements CommandRuntime { _discoveredDevicesById.clear(); _connectedWearablesById.clear(); _advertisedHost = null; - await _audioPlaybackService.stopStream(); logger.i('[connector.websocket] stopped'); } @@ -377,38 +376,6 @@ class WebSocketIpcServer implements CommandRuntime { }; } - /// Starts chunked audio stream playback. - @override - Future> startAudioStream({ - double? volume, - required AudioPlaybackConfig config, - }) async { - await _audioPlaybackService.startStream( - volume: volume, - config: config, - ); - return { - 'started': true, - 'config': config.toJson(), - }; - } - - /// Pushes one audio chunk into the active playback stream queue. - @override - Future> pushAudioStreamChunk({ - required Uint8List bytes, - }) async { - await _audioPlaybackService.pushStreamChunk(bytes); - return {'queued_bytes': bytes.length}; - } - - /// Stops chunked audio stream playback and clears the queue. - @override - Future> stopAudioStream() async { - await _audioPlaybackService.stopStream(); - return {'stopped': true}; - } - /// Allocates the next unique subscription id for a client. @override Future createSubscriptionId() async { diff --git a/open_wearable/linux/flutter/generated_plugin_registrant.cc b/open_wearable/linux/flutter/generated_plugin_registrant.cc index 73720ad6..f547c379 100644 --- a/open_wearable/linux/flutter/generated_plugin_registrant.cc +++ b/open_wearable/linux/flutter/generated_plugin_registrant.cc @@ -8,7 +8,6 @@ #include #include -#include #include #include @@ -19,9 +18,6 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); - g_autoptr(FlPluginRegistrar) flutter_sound_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSoundPlugin"); - flutter_sound_plugin_register_with_registrar(flutter_sound_registrar); g_autoptr(FlPluginRegistrar) open_file_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "OpenFileLinuxPlugin"); open_file_linux_plugin_register_with_registrar(open_file_linux_registrar); diff --git a/open_wearable/linux/flutter/generated_plugins.cmake b/open_wearable/linux/flutter/generated_plugins.cmake index a656d703..18a2dcb9 100644 --- a/open_wearable/linux/flutter/generated_plugins.cmake +++ b/open_wearable/linux/flutter/generated_plugins.cmake @@ -5,7 +5,6 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_linux file_selector_linux - flutter_sound open_file_linux url_launcher_linux ) diff --git a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift index 1bd238d6..696f95be 100644 --- a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,7 +9,6 @@ import audioplayers_darwin import file_picker import file_selector_macos import flutter_archive -import flutter_sound import open_file_mac import package_info_plus import share_plus @@ -23,7 +22,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterArchivePlugin.register(with: registry.registrar(forPlugin: "FlutterArchivePlugin")) - FlutterSoundPlugin.register(with: registry.registrar(forPlugin: "FlutterSoundPlugin")) OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) diff --git a/open_wearable/windows/flutter/generated_plugin_registrant.cc b/open_wearable/windows/flutter/generated_plugin_registrant.cc index e5fdf9cd..37245d29 100644 --- a/open_wearable/windows/flutter/generated_plugin_registrant.cc +++ b/open_wearable/windows/flutter/generated_plugin_registrant.cc @@ -8,7 +8,6 @@ #include #include -#include #include #include #include @@ -19,8 +18,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); - FlutterSoundPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("FlutterSoundPluginCApi")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); SharePlusWindowsPluginCApiRegisterWithRegistrar( diff --git a/open_wearable/windows/flutter/generated_plugins.cmake b/open_wearable/windows/flutter/generated_plugins.cmake index a75f32e8..154f7830 100644 --- a/open_wearable/windows/flutter/generated_plugins.cmake +++ b/open_wearable/windows/flutter/generated_plugins.cmake @@ -5,7 +5,6 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_windows file_selector_windows - flutter_sound permission_handler_windows share_plus universal_ble From 64968123bb149d0e596ef0144cade0773586af7a Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:08:07 +0100 Subject: [PATCH 15/51] docs(connectors): remove websocket audio streaming API docs --- .../docs/connectors/websocket-ipc-api.md | 59 ------------------- 1 file changed, 59 deletions(-) diff --git a/open_wearable/docs/connectors/websocket-ipc-api.md b/open_wearable/docs/connectors/websocket-ipc-api.md index c25ee629..493d437c 100644 --- a/open_wearable/docs/connectors/websocket-ipc-api.md +++ b/open_wearable/docs/connectors/websocket-ipc-api.md @@ -94,9 +94,6 @@ Other event messages: | `disconnect` | `{"device_id":string}` | `{"disconnected":true}` | | `store_sound` | `{"sound_id":string,"audio_base64":string,"codec"?:string,"sample_rate"?:int,"num_channels"?:int,"interleaved"?:bool,"buffer_size"?:int}` | `{"sound_id":string,"stored":true,"bytes":int,"config":object}` | | `play_sound` | `{"sound_id"?:string,"url"?:string,"volume"?:number,"codec"?:string,"sample_rate"?:int,"num_channels"?:int}` | `{"source":"sound_id"\\|"url","playing":true,"config":object,...}` | -| `start_audio_stream` | `{"volume"?:number,"codec"?:string,"sample_rate"?:int,"num_channels"?:int,"interleaved"?:bool,"buffer_size"?:int}` | `{"started":true,"config":object}` | -| `push_audio_stream_chunk` | `{"audio_base64":string}` | `{"queued_bytes":int}` | -| `stop_audio_stream` | `{}` | `{"stopped":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 | @@ -195,57 +192,6 @@ Play directly from a URL: - Provide exactly one source: `sound_id` or `url`. - If both are set, the server returns an error. -### 2) Chunked Audio Stream - -Start stream playback mode: - -```json -{ - "id": 30, - "method": "start_audio_stream", - "params": { - "volume": 1.0 - } -} -``` - -Push chunks continuously: - -```json -{ - "id": 31, - "method": "push_audio_stream_chunk", - "params": { - "audio_base64": "" - } -} -``` - -Stop stream playback: - -```json -{ - "id": 32, - "method": "stop_audio_stream", - "params": {} -} -``` - -Notes: - -- `audio_base64` must be raw audio file/chunk bytes encoded as Base64. -- Default config when omitted: - - `codec=defaultCodec` - - `sample_rate=16000` - - `num_channels=1` - - `interleaved=true` - - `buffer_size=8192` -- PCM stream mode: - - `codec=pcm16` or `codec=pcmFloat32` enables low-latency feed mode (`startPlayerFromStream`). - - Other codecs are handled as queued chunk playback. -- Keep chunk sizes moderate to reduce latency and memory pressure. -- Call `start_audio_stream` before first chunk; otherwise the server returns an error. - ## Data Shapes ### DiscoveredDevice @@ -323,8 +269,3 @@ Notes: 1. `store_sound` with `sound_id` and `audio_base64`. 2. `play_sound` with the same `sound_id`. -### Live audio streaming - -1. `start_audio_stream`. -2. Repeatedly call `push_audio_stream_chunk`. -3. `stop_audio_stream` when done. From 60aeaf8c170253bddde5a1a905c92b68b34c22cc Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:40:37 +0100 Subject: [PATCH 16/51] refactor(connectors): remove websocket URL audio playback --- .../docs/connectors/websocket-ipc-api.md | 26 ++--------- .../commands/play_sound_command.dart | 14 +----- .../models/connectors/commands/runtime.dart | 1 - .../websocket_audio_playback_service.dart | 45 +------------------ .../connectors/websocket_ipc_server.dart | 33 +++----------- 5 files changed, 13 insertions(+), 106 deletions(-) diff --git a/open_wearable/docs/connectors/websocket-ipc-api.md b/open_wearable/docs/connectors/websocket-ipc-api.md index 493d437c..4e78b951 100644 --- a/open_wearable/docs/connectors/websocket-ipc-api.md +++ b/open_wearable/docs/connectors/websocket-ipc-api.md @@ -93,7 +93,7 @@ Other event messages: | `list_connected` | `{}` | `WearableSummary[]` | | `disconnect` | `{"device_id":string}` | `{"disconnected":true}` | | `store_sound` | `{"sound_id":string,"audio_base64":string,"codec"?:string,"sample_rate"?:int,"num_channels"?:int,"interleaved"?:bool,"buffer_size"?:int}` | `{"sound_id":string,"stored":true,"bytes":int,"config":object}` | -| `play_sound` | `{"sound_id"?:string,"url"?:string,"volume"?:number,"codec"?:string,"sample_rate"?:int,"num_channels"?:int}` | `{"source":"sound_id"\\|"url","playing":true,"config":object,...}` | +| `play_sound` | `{"sound_id":string,"volume"?:number,"codec"?:string,"sample_rate"?:int,"num_channels"?:int}` | `{"source":"sound_id","sound_id":string,"playing":true,"config":object}` | | `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 | @@ -141,10 +141,7 @@ Note: ## Audio Playback Over WebSocket -The connector supports two audio modes: - -1. Distinct preloaded sounds (store once, play many times) -2. Chunked audio stream playback (headphone-like continuous feed) +The connector supports distinct preloaded sounds (store once, play many times). ### 1) Distinct Preloaded Sounds @@ -174,23 +171,7 @@ Play a stored sound: } ``` -Play directly from a URL: - -```json -{ - "id": 22, - "method": "play_sound", - "params": { - "url": "https://example.com/notification.wav", - "volume": 1.0 - } -} -``` - -`play_sound` rules: - -- Provide exactly one source: `sound_id` or `url`. -- If both are set, the server returns an error. +`play_sound` requires `sound_id`. ## Data Shapes @@ -268,4 +249,3 @@ Play directly from a URL: 1. `store_sound` with `sound_id` and `audio_base64`. 2. `play_sound` with the same `sound_id`. - diff --git a/open_wearable/lib/models/connectors/commands/play_sound_command.dart b/open_wearable/lib/models/connectors/commands/play_sound_command.dart index 77933144..726d4235 100644 --- a/open_wearable/lib/models/connectors/commands/play_sound_command.dart +++ b/open_wearable/lib/models/connectors/commands/play_sound_command.dart @@ -9,7 +9,6 @@ class PlaySoundCommand extends RuntimeCommand { name: 'play_sound', params: [ CommandParam(name: 'sound_id'), - CommandParam(name: 'url'), CommandParam(name: 'volume'), CommandParam(name: 'codec'), CommandParam(name: 'sample_rate'), @@ -20,16 +19,8 @@ class PlaySoundCommand extends RuntimeCommand { @override Future> execute(List params) { final soundId = readOptionalStringParam(params, 'sound_id'); - final url = readOptionalStringParam(params, 'url'); - - if ((soundId == null || soundId.isEmpty) && (url == null || url.isEmpty)) { - throw ArgumentError('play_sound requires either "sound_id" or "url".'); - } - if (soundId != null && - soundId.isNotEmpty && - url != null && - url.isNotEmpty) { - throw ArgumentError('Provide either "sound_id" or "url", not both.'); + if (soundId == null || soundId.isEmpty) { + throw ArgumentError('play_sound requires "sound_id".'); } final config = AudioPlaybackConfig.fromOptional( @@ -40,7 +31,6 @@ class PlaySoundCommand extends RuntimeCommand { return runtime.playSound( soundId: soundId, - url: url, volume: readOptionalDoubleParam(params, 'volume'), config: config, ); diff --git a/open_wearable/lib/models/connectors/commands/runtime.dart b/open_wearable/lib/models/connectors/commands/runtime.dart index 4bc0ec86..02de4a26 100644 --- a/open_wearable/lib/models/connectors/commands/runtime.dart +++ b/open_wearable/lib/models/connectors/commands/runtime.dart @@ -41,7 +41,6 @@ abstract class CommandRuntime { Future> playSound({ String? soundId, - String? url, double? volume, AudioPlaybackConfig? config, }); diff --git a/open_wearable/lib/models/connectors/websocket_audio_playback_service.dart b/open_wearable/lib/models/connectors/websocket_audio_playback_service.dart index 95803d1d..976d9e07 100644 --- a/open_wearable/lib/models/connectors/websocket_audio_playback_service.dart +++ b/open_wearable/lib/models/connectors/websocket_audio_playback_service.dart @@ -1,8 +1,8 @@ import 'dart:typed_data'; +import 'dart:io'; import 'package:audioplayers/audioplayers.dart'; import 'package:path_provider/path_provider.dart'; -import 'dart:io'; import '../logger.dart'; import 'audio_playback_config.dart'; @@ -68,49 +68,6 @@ class WebsocketAudioPlaybackService { return config; } - Future playFromUrl({ - required String url, - double? volume, - AudioPlaybackConfig? config, - }) async { - final uri = Uri.tryParse(url); - if (uri == null || !uri.hasScheme) { - throw ArgumentError('Invalid URL: $url'); - } - if (uri.scheme != 'http' && uri.scheme != 'https') { - throw ArgumentError( - 'Only http/https URLs are supported. Got: ${uri.scheme}', - ); - } - if (uri.host == 'commons.wikimedia.org' && - uri.path.startsWith('/wiki/File:')) { - throw ArgumentError( - 'URL points to a Wikimedia page, not a direct audio file. Use the raw media URL from upload.wikimedia.org.', - ); - } - - final playbackConfig = config ?? const AudioPlaybackConfig(); - - if (volume != null) { - await _preloadedPlayer.setVolume(volume); - } - - await _preloadedPlayer.stop(); - - try { - await _preloadedPlayer.play(UrlSource(url)); - } catch (error) { - throw StateError( - 'Failed to play URL source. Ensure it is a direct audio file URL (not an HTML page). Original error: $error', - ); - } - - logger.i( - '[connector.audio] playing url source=$url codec_hint=${playbackConfig.codec}', - ); - return playbackConfig; - } - Future dispose() async { await _preloadedPlayer.dispose(); } diff --git a/open_wearable/lib/models/connectors/websocket_ipc_server.dart b/open_wearable/lib/models/connectors/websocket_ipc_server.dart index 39b82b5e..2e9a1c6b 100644 --- a/open_wearable/lib/models/connectors/websocket_ipc_server.dart +++ b/open_wearable/lib/models/connectors/websocket_ipc_server.dart @@ -336,41 +336,22 @@ class WebSocketIpcServer implements CommandRuntime { @override Future> playSound({ String? soundId, - String? url, double? volume, AudioPlaybackConfig? config, }) async { final hasSoundId = soundId != null && soundId.trim().isNotEmpty; - final hasUrl = url != null && url.trim().isNotEmpty; - if (!hasSoundId && !hasUrl) { - throw ArgumentError('play_sound requires either "sound_id" or "url".'); - } - if (hasSoundId && hasUrl) { - throw ArgumentError('Provide either "sound_id" or "url", not both.'); + if (!hasSoundId) { + throw ArgumentError('play_sound requires "sound_id".'); } - if (hasSoundId) { - final usedConfig = await _audioPlaybackService.playStoredSound( - soundId: soundId, - volume: volume, - overrideConfig: config, - ); - return { - 'source': 'sound_id', - 'sound_id': soundId, - 'playing': true, - 'config': usedConfig.toJson(), - }; - } - - final usedConfig = await _audioPlaybackService.playFromUrl( - url: url!, + final usedConfig = await _audioPlaybackService.playStoredSound( + soundId: soundId, volume: volume, - config: config, + overrideConfig: config, ); return { - 'source': 'url', - 'url': url, + 'source': 'sound_id', + 'sound_id': soundId, 'playing': true, 'config': usedConfig.toJson(), }; From 0e76599d995f890a4fcd74d791779a95aaf8e2f6 Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:07:22 +0100 Subject: [PATCH 17/51] feat(settings): add keep app in foreground setting to disable automatic sleep --- open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift index 696f95be..fe9a90a0 100644 --- a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift @@ -24,6 +24,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterArchivePlugin.register(with: registry.registrar(forPlugin: "FlutterArchivePlugin")) OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UniversalBlePlugin.register(with: registry.registrar(forPlugin: "UniversalBlePlugin")) From bb96f1f5ab9653a118911e85193681c81b686053 Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:36:51 +0200 Subject: [PATCH 18/51] refactor: regenerate generated files after rebase --- open_wearable/linux/flutter/generated_plugin_registrant.cc | 4 ++++ open_wearable/linux/flutter/generated_plugins.cmake | 1 + open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift | 3 ++- open_wearable/windows/flutter/generated_plugin_registrant.cc | 3 +++ open_wearable/windows/flutter/generated_plugins.cmake | 1 + 5 files changed, 11 insertions(+), 1 deletion(-) diff --git a/open_wearable/linux/flutter/generated_plugin_registrant.cc b/open_wearable/linux/flutter/generated_plugin_registrant.cc index f547c379..73720ad6 100644 --- a/open_wearable/linux/flutter/generated_plugin_registrant.cc +++ b/open_wearable/linux/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -18,6 +19,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_sound_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSoundPlugin"); + flutter_sound_plugin_register_with_registrar(flutter_sound_registrar); g_autoptr(FlPluginRegistrar) open_file_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "OpenFileLinuxPlugin"); open_file_linux_plugin_register_with_registrar(open_file_linux_registrar); diff --git a/open_wearable/linux/flutter/generated_plugins.cmake b/open_wearable/linux/flutter/generated_plugins.cmake index 18a2dcb9..a656d703 100644 --- a/open_wearable/linux/flutter/generated_plugins.cmake +++ b/open_wearable/linux/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_linux file_selector_linux + flutter_sound open_file_linux url_launcher_linux ) diff --git a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift index fe9a90a0..1bd238d6 100644 --- a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,6 +9,7 @@ import audioplayers_darwin import file_picker import file_selector_macos import flutter_archive +import flutter_sound import open_file_mac import package_info_plus import share_plus @@ -22,9 +23,9 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterArchivePlugin.register(with: registry.registrar(forPlugin: "FlutterArchivePlugin")) + FlutterSoundPlugin.register(with: registry.registrar(forPlugin: "FlutterSoundPlugin")) OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UniversalBlePlugin.register(with: registry.registrar(forPlugin: "UniversalBlePlugin")) diff --git a/open_wearable/windows/flutter/generated_plugin_registrant.cc b/open_wearable/windows/flutter/generated_plugin_registrant.cc index 37245d29..e5fdf9cd 100644 --- a/open_wearable/windows/flutter/generated_plugin_registrant.cc +++ b/open_wearable/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -18,6 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + FlutterSoundPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSoundPluginCApi")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); SharePlusWindowsPluginCApiRegisterWithRegistrar( diff --git a/open_wearable/windows/flutter/generated_plugins.cmake b/open_wearable/windows/flutter/generated_plugins.cmake index 154f7830..a75f32e8 100644 --- a/open_wearable/windows/flutter/generated_plugins.cmake +++ b/open_wearable/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_windows file_selector_windows + flutter_sound permission_handler_windows share_plus universal_ble From 319fb256826d361ee875c17aca05913f4dd59cc2 Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:33:06 +0200 Subject: [PATCH 19/51] fix(dependencies): remove flutter_sound plugin references from generated files --- .../flutter/generated_plugin_registrant.cc | 4 ---- .../linux/flutter/generated_plugins.cmake | 1 - .../Flutter/GeneratedPluginRegistrant.swift | 2 -- open_wearable/pubspec.lock | 24 ------------------- open_wearable/pubspec.yaml | 1 - .../flutter/generated_plugin_registrant.cc | 3 --- .../windows/flutter/generated_plugins.cmake | 1 - 7 files changed, 36 deletions(-) diff --git a/open_wearable/linux/flutter/generated_plugin_registrant.cc b/open_wearable/linux/flutter/generated_plugin_registrant.cc index 73720ad6..f547c379 100644 --- a/open_wearable/linux/flutter/generated_plugin_registrant.cc +++ b/open_wearable/linux/flutter/generated_plugin_registrant.cc @@ -8,7 +8,6 @@ #include #include -#include #include #include @@ -19,9 +18,6 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); - g_autoptr(FlPluginRegistrar) flutter_sound_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSoundPlugin"); - flutter_sound_plugin_register_with_registrar(flutter_sound_registrar); g_autoptr(FlPluginRegistrar) open_file_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "OpenFileLinuxPlugin"); open_file_linux_plugin_register_with_registrar(open_file_linux_registrar); diff --git a/open_wearable/linux/flutter/generated_plugins.cmake b/open_wearable/linux/flutter/generated_plugins.cmake index a656d703..18a2dcb9 100644 --- a/open_wearable/linux/flutter/generated_plugins.cmake +++ b/open_wearable/linux/flutter/generated_plugins.cmake @@ -5,7 +5,6 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_linux file_selector_linux - flutter_sound open_file_linux url_launcher_linux ) diff --git a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift index 1bd238d6..696f95be 100644 --- a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,7 +9,6 @@ import audioplayers_darwin import file_picker import file_selector_macos import flutter_archive -import flutter_sound import open_file_mac import package_info_plus import share_plus @@ -23,7 +22,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterArchivePlugin.register(with: registry.registrar(forPlugin: "FlutterArchivePlugin")) - FlutterSoundPlugin.register(with: registry.registrar(forPlugin: "FlutterSoundPlugin")) OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index e4383b5c..7031a8b8 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -358,30 +358,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.34" - flutter_sound: - dependency: "direct main" - description: - name: flutter_sound - sha256: "77f0252a2f08449d621f68b8fd617c3b391e7f862eaa94bb32f53cc2dc3bbe85" - url: "https://pub.dev" - source: hosted - version: "9.30.0" - flutter_sound_platform_interface: - dependency: transitive - description: - name: flutter_sound_platform_interface - sha256: "5ffc858fd96c6fa277e3bb25eecc100849d75a8792b1f9674d1ba817aea9f861" - url: "https://pub.dev" - source: hosted - version: "9.30.0" - flutter_sound_web: - dependency: transitive - description: - name: flutter_sound_web - sha256: "3af46f45f44768c01c83ba260855c956d0963673664947926d942aa6fbf6f6fb" - url: "https://pub.dev" - source: hosted - version: "9.30.0" flutter_staggered_grid_view: dependency: "direct main" description: diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index 6e4f0a9f..6147e6aa 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -56,7 +56,6 @@ dependencies: url_launcher: ^6.3.2 go_router: ^14.6.2 http: ^1.6.0 - flutter_sound: ^9.30.0 audioplayers: ^6.6.0 wakelock_plus: ^1.4.0 package_info_plus: ^9.0.0 diff --git a/open_wearable/windows/flutter/generated_plugin_registrant.cc b/open_wearable/windows/flutter/generated_plugin_registrant.cc index e5fdf9cd..37245d29 100644 --- a/open_wearable/windows/flutter/generated_plugin_registrant.cc +++ b/open_wearable/windows/flutter/generated_plugin_registrant.cc @@ -8,7 +8,6 @@ #include #include -#include #include #include #include @@ -19,8 +18,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); - FlutterSoundPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("FlutterSoundPluginCApi")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); SharePlusWindowsPluginCApiRegisterWithRegistrar( diff --git a/open_wearable/windows/flutter/generated_plugins.cmake b/open_wearable/windows/flutter/generated_plugins.cmake index a75f32e8..154f7830 100644 --- a/open_wearable/windows/flutter/generated_plugins.cmake +++ b/open_wearable/windows/flutter/generated_plugins.cmake @@ -5,7 +5,6 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_windows file_selector_windows - flutter_sound permission_handler_windows share_plus universal_ble From 45219bddd864729580d8e6e4816971e615914b18 Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:03:59 +0200 Subject: [PATCH 20/51] feat(connectors): show active connector indicator --- .../lib/models/connector_settings.dart | 6 + .../widgets/connector_activity_indicator.dart | 107 ++++++++++++++++++ .../widgets/global_app_banner_overlay.dart | 12 ++ .../connector_activity_indicator_test.dart | 37 ++++++ 4 files changed, 162 insertions(+) create mode 100644 open_wearable/lib/widgets/connector_activity_indicator.dart create mode 100644 open_wearable/test/widgets/connector_activity_indicator_test.dart diff --git a/open_wearable/lib/models/connector_settings.dart b/open_wearable/lib/models/connector_settings.dart index 31681259..72fbf843 100644 --- a/open_wearable/lib/models/connector_settings.dart +++ b/open_wearable/lib/models/connector_settings.dart @@ -71,6 +71,12 @@ class ConnectorRuntimeStatus { const ConnectorRuntimeStatus.error(this.message) : state = ConnectorRuntimeState.error; + + /// Whether the connector is currently enabled and participating in runtime + /// work. + bool get isActive => + state == ConnectorRuntimeState.starting || + state == ConnectorRuntimeState.running; } /// Loads, normalizes, persists, and applies connector settings. diff --git a/open_wearable/lib/widgets/connector_activity_indicator.dart b/open_wearable/lib/widgets/connector_activity_indicator.dart new file mode 100644 index 00000000..46836d22 --- /dev/null +++ b/open_wearable/lib/widgets/connector_activity_indicator.dart @@ -0,0 +1,107 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:open_wearable/models/connector_settings.dart'; + +/// Compact global status chip shown while an external connector is active. +class ConnectorActivityIndicator extends StatelessWidget { + const ConnectorActivityIndicator({ + super.key, + this.statusListenable, + }); + + /// Runtime status source. Tests may inject a notifier without touching the + /// process-wide connector service. + final ValueListenable? statusListenable; + + @override + Widget build(BuildContext context) { + final listenable = + statusListenable ?? ConnectorSettings.webSocketRuntimeStatusListenable; + + return ValueListenableBuilder( + valueListenable: listenable, + builder: (context, status, _) { + if (!status.isActive) { + return const SizedBox.shrink(); + } + + final colorScheme = Theme.of(context).colorScheme; + final foregroundColor = status.state == ConnectorRuntimeState.starting + ? colorScheme.onPrimaryContainer + : colorScheme.onTertiaryContainer; + final backgroundColor = status.state == ConnectorRuntimeState.starting + ? colorScheme.primaryContainer + : colorScheme.tertiaryContainer; + final label = status.state == ConnectorRuntimeState.starting + ? 'Connector starting' + : 'Connector active'; + + return Padding( + padding: const EdgeInsets.fromLTRB(10, 6, 10, 0), + child: Align( + alignment: AlignmentDirectional.topEnd, + child: Semantics( + label: label, + liveRegion: true, + child: Semantics( + excludeSemantics: true, + child: Material( + color: Colors.transparent, + child: DecoratedBox( + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: foregroundColor.withValues(alpha: 0.22), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.hub_rounded, + size: 14, + color: foregroundColor, + ), + const SizedBox(width: 6), + Text( + 'Connector', + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith( + color: foregroundColor, + fontWeight: FontWeight.w800, + height: 1, + ) ?? + TextStyle( + color: foregroundColor, + fontWeight: FontWeight.w800, + height: 1, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/open_wearable/lib/widgets/global_app_banner_overlay.dart b/open_wearable/lib/widgets/global_app_banner_overlay.dart index 8282a1ea..a5322ae2 100644 --- a/open_wearable/lib/widgets/global_app_banner_overlay.dart +++ b/open_wearable/lib/widgets/global_app_banner_overlay.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../view_models/app_banner_controller.dart'; +import 'connector_activity_indicator.dart'; class GlobalAppBannerOverlay extends StatelessWidget { final Widget child; @@ -39,6 +40,7 @@ class GlobalAppBannerOverlay extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + const ConnectorActivityIndicator(), const SizedBox(height: 6), ...banners.map( (banner) => Padding( @@ -55,6 +57,16 @@ class GlobalAppBannerOverlay extends StatelessWidget { ), ), ), + if (!hasBanners) + const Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + bottom: false, + child: ConnectorActivityIndicator(), + ), + ), ], ), ); diff --git a/open_wearable/test/widgets/connector_activity_indicator_test.dart b/open_wearable/test/widgets/connector_activity_indicator_test.dart new file mode 100644 index 00000000..ec441f13 --- /dev/null +++ b/open_wearable/test/widgets/connector_activity_indicator_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_wearable/models/connector_settings.dart'; +import 'package:open_wearable/widgets/connector_activity_indicator.dart'; + +void main() { + testWidgets('shows only while connector runtime is active', (tester) async { + final statusNotifier = ValueNotifier( + const ConnectorRuntimeStatus.disabled(), + ); + addTearDown(statusNotifier.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ConnectorActivityIndicator( + statusListenable: statusNotifier, + ), + ), + ), + ); + + expect(find.text('Connector'), findsNothing); + + statusNotifier.value = const ConnectorRuntimeStatus.starting(); + await tester.pump(); + expect(find.text('Connector'), findsOneWidget); + + statusNotifier.value = const ConnectorRuntimeStatus.running(); + await tester.pump(); + expect(find.text('Connector'), findsOneWidget); + + statusNotifier.value = const ConnectorRuntimeStatus.error('failed'); + await tester.pump(); + expect(find.text('Connector'), findsNothing); + }); +} From 76686f4dbaef55b34ec089088374546b1676de44 Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:12:08 +0200 Subject: [PATCH 21/51] fix(connectors): center active connector indicator --- open_wearable/lib/widgets/connector_activity_indicator.dart | 2 +- .../test/widgets/connector_activity_indicator_test.dart | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/open_wearable/lib/widgets/connector_activity_indicator.dart b/open_wearable/lib/widgets/connector_activity_indicator.dart index 46836d22..bb48ca3b 100644 --- a/open_wearable/lib/widgets/connector_activity_indicator.dart +++ b/open_wearable/lib/widgets/connector_activity_indicator.dart @@ -39,7 +39,7 @@ class ConnectorActivityIndicator extends StatelessWidget { return Padding( padding: const EdgeInsets.fromLTRB(10, 6, 10, 0), child: Align( - alignment: AlignmentDirectional.topEnd, + alignment: AlignmentDirectional.topCenter, child: Semantics( label: label, liveRegion: true, diff --git a/open_wearable/test/widgets/connector_activity_indicator_test.dart b/open_wearable/test/widgets/connector_activity_indicator_test.dart index ec441f13..311880d3 100644 --- a/open_wearable/test/widgets/connector_activity_indicator_test.dart +++ b/open_wearable/test/widgets/connector_activity_indicator_test.dart @@ -29,6 +29,10 @@ void main() { statusNotifier.value = const ConnectorRuntimeStatus.running(); await tester.pump(); expect(find.text('Connector'), findsOneWidget); + expect( + tester.getCenter(find.text('Connector')).dx, + closeTo(tester.getSize(find.byType(MaterialApp)).width / 2, 60), + ); statusNotifier.value = const ConnectorRuntimeStatus.error('failed'); await tester.pump(); From f8181a850e1bffd731675615148defef7ec81d9f Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:13:57 +0200 Subject: [PATCH 22/51] feat(connectors): compact active connector indicator --- .../widgets/connector_activity_indicator.dart | 235 ++++++++++++++---- .../connector_activity_indicator_test.dart | 58 +++++ 2 files changed, 240 insertions(+), 53 deletions(-) diff --git a/open_wearable/lib/widgets/connector_activity_indicator.dart b/open_wearable/lib/widgets/connector_activity_indicator.dart index bb48ca3b..4620a1e7 100644 --- a/open_wearable/lib/widgets/connector_activity_indicator.dart +++ b/open_wearable/lib/widgets/connector_activity_indicator.dart @@ -1,25 +1,142 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:open_wearable/models/connector_settings.dart'; +import 'package:open_wearable/router.dart'; /// Compact global status chip shown while an external connector is active. -class ConnectorActivityIndicator extends StatelessWidget { +class ConnectorActivityIndicator extends StatefulWidget { const ConnectorActivityIndicator({ super.key, this.statusListenable, + this.onOpenSettings, }); + /// How long the indicator shows its expanded label before compacting. + static const Duration expandedDuration = Duration(seconds: 5); + /// Runtime status source. Tests may inject a notifier without touching the /// process-wide connector service. final ValueListenable? statusListenable; + /// Opens connector settings. Defaults to navigating through the app router. + final VoidCallback? onOpenSettings; + @override - Widget build(BuildContext context) { - final listenable = - statusListenable ?? ConnectorSettings.webSocketRuntimeStatusListenable; + State createState() => + _ConnectorActivityIndicatorState(); +} + +class _ConnectorActivityIndicatorState + extends State { + late ValueListenable _statusListenable; + Timer? _collapseTimer; + bool _isExpanded = false; + bool _wasActive = false; + + @override + void initState() { + super.initState(); + _statusListenable = _resolveStatusListenable(); + _wasActive = _statusListenable.value.isActive; + _isExpanded = _wasActive; + _statusListenable.addListener(_handleStatusChanged); + if (_isExpanded) { + _scheduleCollapse(); + } + } + + @override + void didUpdateWidget(covariant ConnectorActivityIndicator oldWidget) { + super.didUpdateWidget(oldWidget); + final nextStatusListenable = _resolveStatusListenable(); + if (nextStatusListenable == _statusListenable) { + return; + } + + _statusListenable.removeListener(_handleStatusChanged); + _statusListenable = nextStatusListenable; + _statusListenable.addListener(_handleStatusChanged); + _syncStateWithStatus(_statusListenable.value); + } + + @override + void dispose() { + _collapseTimer?.cancel(); + _statusListenable.removeListener(_handleStatusChanged); + super.dispose(); + } + + ValueListenable _resolveStatusListenable() { + return widget.statusListenable ?? + ConnectorSettings.webSocketRuntimeStatusListenable; + } + + void _handleStatusChanged() { + _syncStateWithStatus(_statusListenable.value); + } + void _syncStateWithStatus(ConnectorRuntimeStatus status) { + final isActive = status.isActive; + if (isActive == _wasActive) { + return; + } + + _wasActive = isActive; + if (!mounted) { + return; + } + + setState(() { + _isExpanded = isActive; + }); + + if (isActive) { + _scheduleCollapse(); + } else { + _collapseTimer?.cancel(); + _collapseTimer = null; + } + } + + void _scheduleCollapse() { + _collapseTimer?.cancel(); + _collapseTimer = Timer(ConnectorActivityIndicator.expandedDuration, () { + if (!mounted) { + return; + } + setState(() { + _isExpanded = false; + }); + }); + } + + void _expandTemporarily() { + if (!_statusListenable.value.isActive) { + return; + } + setState(() { + _isExpanded = true; + }); + _scheduleCollapse(); + } + + void _openSettings() { + final onOpenSettings = widget.onOpenSettings; + if (onOpenSettings != null) { + onOpenSettings(); + return; + } + + rootNavigatorKey.currentContext?.push('/settings/connectors'); + } + + @override + Widget build(BuildContext context) { return ValueListenableBuilder( - valueListenable: listenable, + valueListenable: _statusListenable, builder: (context, status, _) { if (!status.isActive) { return const SizedBox.shrink(); @@ -40,59 +157,71 @@ class ConnectorActivityIndicator extends StatelessWidget { padding: const EdgeInsets.fromLTRB(10, 6, 10, 0), child: Align( alignment: AlignmentDirectional.topCenter, - child: Semantics( - label: label, - liveRegion: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _expandTemporarily, + onLongPress: _openSettings, child: Semantics( - excludeSemantics: true, - child: Material( - color: Colors.transparent, - child: DecoratedBox( - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: foregroundColor.withValues(alpha: 0.22), - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.08), - blurRadius: 12, - offset: const Offset(0, 4), + button: true, + label: label, + liveRegion: true, + child: Semantics( + excludeSemantics: true, + child: Material( + color: Colors.transparent, + child: AnimatedSize( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + child: DecoratedBox( + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: foregroundColor.withValues(alpha: 0.22), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], ), - ], - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.hub_rounded, - size: 14, - color: foregroundColor, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, ), - const SizedBox(width: 6), - Text( - 'Connector', - style: Theme.of(context) - .textTheme - .labelSmall - ?.copyWith( - color: foregroundColor, - fontWeight: FontWeight.w800, - height: 1, - ) ?? - TextStyle( - color: foregroundColor, - fontWeight: FontWeight.w800, - height: 1, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.hub_rounded, + size: 14, + color: foregroundColor, + ), + if (_isExpanded) ...[ + const SizedBox(width: 6), + Text( + 'Connector', + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith( + color: foregroundColor, + fontWeight: FontWeight.w800, + height: 1, + ) ?? + TextStyle( + color: foregroundColor, + fontWeight: FontWeight.w800, + height: 1, + ), ), + ], + ], ), - ], + ), ), ), ), diff --git a/open_wearable/test/widgets/connector_activity_indicator_test.dart b/open_wearable/test/widgets/connector_activity_indicator_test.dart index 311880d3..d82ba0e4 100644 --- a/open_wearable/test/widgets/connector_activity_indicator_test.dart +++ b/open_wearable/test/widgets/connector_activity_indicator_test.dart @@ -38,4 +38,62 @@ void main() { await tester.pump(); expect(find.text('Connector'), findsNothing); }); + + testWidgets('compacts after delay and expands again on tap', (tester) async { + final statusNotifier = ValueNotifier( + const ConnectorRuntimeStatus.running(), + ); + addTearDown(statusNotifier.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ConnectorActivityIndicator( + statusListenable: statusNotifier, + ), + ), + ), + ); + + expect(find.text('Connector'), findsOneWidget); + + await tester.pump(ConnectorActivityIndicator.expandedDuration); + + expect(find.text('Connector'), findsNothing); + expect(find.byIcon(Icons.hub_rounded), findsOneWidget); + + await tester.tap(find.byIcon(Icons.hub_rounded)); + await tester.pump(); + + expect(find.text('Connector'), findsOneWidget); + + await tester.pump(ConnectorActivityIndicator.expandedDuration); + + expect(find.text('Connector'), findsNothing); + expect(find.byIcon(Icons.hub_rounded), findsOneWidget); + }); + + testWidgets('opens connector settings on long press', (tester) async { + var settingsOpenCount = 0; + final statusNotifier = ValueNotifier( + const ConnectorRuntimeStatus.running(), + ); + addTearDown(statusNotifier.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ConnectorActivityIndicator( + statusListenable: statusNotifier, + onOpenSettings: () => settingsOpenCount += 1, + ), + ), + ), + ); + + await tester.longPress(find.byIcon(Icons.hub_rounded)); + await tester.pump(); + + expect(settingsOpenCount, 1); + }); } From 300a4f48f09e734279c49d1c4cc34d92defb5c77 Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:29:03 +0200 Subject: [PATCH 23/51] refactor(connectors): unify connector branding --- .../lib/widgets/connector_activity_indicator.dart | 5 +++-- open_wearable/lib/widgets/connector_branding.dart | 15 +++++++++++++++ .../lib/widgets/settings/connectors_page.dart | 9 +++++---- .../lib/widgets/settings/settings_page.dart | 5 +++-- .../connector_activity_indicator_test.dart | 9 +++++---- 5 files changed, 31 insertions(+), 12 deletions(-) create mode 100644 open_wearable/lib/widgets/connector_branding.dart diff --git a/open_wearable/lib/widgets/connector_activity_indicator.dart b/open_wearable/lib/widgets/connector_activity_indicator.dart index 4620a1e7..9cfbffeb 100644 --- a/open_wearable/lib/widgets/connector_activity_indicator.dart +++ b/open_wearable/lib/widgets/connector_activity_indicator.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:open_wearable/models/connector_settings.dart'; import 'package:open_wearable/router.dart'; +import 'package:open_wearable/widgets/connector_branding.dart'; /// Compact global status chip shown while an external connector is active. class ConnectorActivityIndicator extends StatefulWidget { @@ -196,14 +197,14 @@ class _ConnectorActivityIndicatorState mainAxisSize: MainAxisSize.min, children: [ Icon( - Icons.hub_rounded, + ConnectorBranding.icon, size: 14, color: foregroundColor, ), if (_isExpanded) ...[ const SizedBox(width: 6), Text( - 'Connector', + ConnectorBranding.label, style: Theme.of(context) .textTheme .labelSmall diff --git a/open_wearable/lib/widgets/connector_branding.dart b/open_wearable/lib/widgets/connector_branding.dart new file mode 100644 index 00000000..54fc7e3d --- /dev/null +++ b/open_wearable/lib/widgets/connector_branding.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +/// Shared visual identity for connector entry points and status surfaces. +class ConnectorBranding { + const ConnectorBranding._(); + + /// Primary connector icon used wherever connector features are represented. + static const IconData icon = Icons.hub_rounded; + + /// User-facing connector family label. + static const String label = 'Connector'; + + /// User-facing plural connector family label. + static const String pluralLabel = 'Connectors'; +} diff --git a/open_wearable/lib/widgets/settings/connectors_page.dart b/open_wearable/lib/widgets/settings/connectors_page.dart index 2277146e..cad0633b 100644 --- a/open_wearable/lib/widgets/settings/connectors_page.dart +++ b/open_wearable/lib/widgets/settings/connectors_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:open_wearable/models/connector_settings.dart'; import 'package:open_wearable/models/network/device_ip_address.dart'; import 'package:open_wearable/widgets/app_toast.dart'; +import 'package:open_wearable/widgets/connector_branding.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; class ConnectorsPage extends StatefulWidget { @@ -266,7 +267,7 @@ class _ConnectorsPageState extends State { Widget build(BuildContext context) { return PlatformScaffold( appBar: PlatformAppBar( - title: const Text('Connectors'), + title: const Text(ConnectorBranding.pluralLabel), ), body: _isLoading ? const Center(child: CircularProgressIndicator()) @@ -283,7 +284,7 @@ class _ConnectorsPageState extends State { SensorPageSpacing.pagePaddingWithBottomInset(context), children: [ Text( - 'Connectors', + ConnectorBranding.pluralLabel, style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 4), @@ -355,7 +356,7 @@ class _ConnectorsPageState extends State { ), alignment: Alignment.center, child: Icon( - Icons.cable_rounded, + ConnectorBranding.icon, size: 18, color: statusColor, ), @@ -533,7 +534,7 @@ class _StatusChip extends StatelessWidget { ), child: Row( children: [ - Icon(Icons.circle, size: 10, color: foreground), + Icon(ConnectorBranding.icon, size: 14, color: foreground), const SizedBox(width: 8), Expanded( child: Column( diff --git a/open_wearable/lib/widgets/settings/settings_page.dart b/open_wearable/lib/widgets/settings/settings_page.dart index 8e54fb5f..327ec62f 100644 --- a/open_wearable/lib/widgets/settings/settings_page.dart +++ b/open_wearable/lib/widgets/settings/settings_page.dart @@ -8,6 +8,7 @@ import 'package:open_wearable/models/app_upgrade_registry.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:open_wearable/widgets/app_toast.dart'; +import 'package:open_wearable/widgets/connector_branding.dart'; import 'package:open_wearable/widgets/recording_activity_indicator.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; @@ -61,8 +62,8 @@ class SettingsPage extends StatelessWidget { onTap: () => context.push('/whats-new'), ), _QuickActionTile( - icon: Icons.cable_rounded, - title: 'Connectors', + icon: ConnectorBranding.icon, + title: ConnectorBranding.pluralLabel, subtitle: 'Configure external API connectors', onTap: onConnectorsRequested, ), diff --git a/open_wearable/test/widgets/connector_activity_indicator_test.dart b/open_wearable/test/widgets/connector_activity_indicator_test.dart index d82ba0e4..0c902c54 100644 --- a/open_wearable/test/widgets/connector_activity_indicator_test.dart +++ b/open_wearable/test/widgets/connector_activity_indicator_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:open_wearable/models/connector_settings.dart'; import 'package:open_wearable/widgets/connector_activity_indicator.dart'; +import 'package:open_wearable/widgets/connector_branding.dart'; void main() { testWidgets('shows only while connector runtime is active', (tester) async { @@ -60,9 +61,9 @@ void main() { await tester.pump(ConnectorActivityIndicator.expandedDuration); expect(find.text('Connector'), findsNothing); - expect(find.byIcon(Icons.hub_rounded), findsOneWidget); + expect(find.byIcon(ConnectorBranding.icon), findsOneWidget); - await tester.tap(find.byIcon(Icons.hub_rounded)); + await tester.tap(find.byIcon(ConnectorBranding.icon)); await tester.pump(); expect(find.text('Connector'), findsOneWidget); @@ -70,7 +71,7 @@ void main() { await tester.pump(ConnectorActivityIndicator.expandedDuration); expect(find.text('Connector'), findsNothing); - expect(find.byIcon(Icons.hub_rounded), findsOneWidget); + expect(find.byIcon(ConnectorBranding.icon), findsOneWidget); }); testWidgets('opens connector settings on long press', (tester) async { @@ -91,7 +92,7 @@ void main() { ), ); - await tester.longPress(find.byIcon(Icons.hub_rounded)); + await tester.longPress(find.byIcon(ConnectorBranding.icon)); await tester.pump(); expect(settingsOpenCount, 1); From c7ee97ac75f49d83e0db3e5762075aa3b30f9d6c Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:13:49 +0200 Subject: [PATCH 24/51] chore(deps): update Flutter dependencies --- .../ios/Flutter/AppFrameworkInfo.plist | 2 - open_wearable/ios/Podfile | 2 +- open_wearable/ios/Podfile.lock | 23 ++---- .../linux/flutter/generated_plugins.cmake | 1 + open_wearable/pubspec.lock | 72 +++++++++++++------ open_wearable/pubspec.yaml | 4 +- .../windows/flutter/generated_plugins.cmake | 1 + 7 files changed, 64 insertions(+), 41 deletions(-) diff --git a/open_wearable/ios/Flutter/AppFrameworkInfo.plist b/open_wearable/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf76..391a902b 100644 --- a/open_wearable/ios/Flutter/AppFrameworkInfo.plist +++ b/open_wearable/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 13.0 diff --git a/open_wearable/ios/Podfile b/open_wearable/ios/Podfile index 620e46eb..fad4db75 100644 --- a/open_wearable/ios/Podfile +++ b/open_wearable/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '13.0' +platform :ios, '16.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/open_wearable/ios/Podfile.lock b/open_wearable/ios/Podfile.lock index 21367c47..fb2003e6 100644 --- a/open_wearable/ios/Podfile.lock +++ b/open_wearable/ios/Podfile.lock @@ -53,23 +53,18 @@ PODS: - Flutter - package_info_plus (0.4.5): - Flutter - - package_info_plus (0.4.5): - - Flutter - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - permission_handler_apple (9.3.0): - Flutter - - SDWebImage (5.21.6): - - SDWebImage/Core (= 5.21.6) - - SDWebImage/Core (5.21.6) + - SDWebImage (5.21.7): + - SDWebImage/Core (= 5.21.7) + - SDWebImage/Core (5.21.7) - share_plus (0.0.1): - Flutter - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS - SwiftCBOR (0.4.7) - - SwiftProtobuf (1.34.1) + - SwiftProtobuf (1.37.0) - SwiftyGif (5.4.5) - universal_ble (0.0.1): - Flutter @@ -88,7 +83,6 @@ DEPENDENCIES: - flutter_archive (from `.symlinks/plugins/flutter_archive/ios`) - mcumgr_flutter (from `.symlinks/plugins/mcumgr_flutter/ios`) - open_file_ios (from `.symlinks/plugins/open_file_ios/ios`) - - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) @@ -123,8 +117,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/mcumgr_flutter/ios" open_file_ios: :path: ".symlinks/plugins/open_file_ios/ios" - path_provider_foundation: - :path: ".symlinks/plugins/path_provider_foundation/darwin" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" permission_handler_apple: @@ -151,20 +143,19 @@ SPEC CHECKSUMS: iOSMcuManagerLibrary: e9555825af11a61744fe369c12e1e66621061b58 mcumgr_flutter: 969e99cc15e9fe658242669ce1075bf4612aef8a open_file_ios: 46184d802ee7959203f6392abcfa0dd49fdb5be0 - path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d - SDWebImage: 1bb6a1b84b6fe87b972a102bdc77dd589df33477 + SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb SwiftCBOR: 465775bed0e8bac7bfb8160bcf7b95d7f75971e4 - SwiftProtobuf: c901f00a3e125dc33cac9b16824da85682ee47da + SwiftProtobuf: 3fafd1b2fb97e6d95ad9c8adb2215da9afec7c83 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 universal_ble: ff19787898040d721109c6324472e5dd4bc86adc url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c -PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e +PODFILE CHECKSUM: 1857a7cdb7dfafe45f2b0e9a9af44644190f7506 COCOAPODS: 1.16.2 diff --git a/open_wearable/linux/flutter/generated_plugins.cmake b/open_wearable/linux/flutter/generated_plugins.cmake index 18a2dcb9..6462693b 100644 --- a/open_wearable/linux/flutter/generated_plugins.cmake +++ b/open_wearable/linux/flutter/generated_plugins.cmake @@ -10,6 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index 7031a8b8..304bce54 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -346,10 +346,10 @@ packages: dependency: "direct main" description: name: flutter_platform_widgets - sha256: "22a86564cb6cc0b93637c813ca91b0b1f61c2681a31e0f9d77590c1fa9f12020" + sha256: aa110ef638076831d060047911a62810d02b4695db58e7682b716c4c4eee65bc url: "https://pub.dev" source: hosted - version: "9.0.0" + version: "10.0.1" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -404,18 +404,18 @@ packages: dependency: "direct main" description: name: go_router - sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + sha256: "08b742eef4f71c9df5af543751cd0b7f1c679c4088488f4223ecaddc1a813b79" url: "https://pub.dev" source: hosted - version: "14.8.1" + version: "17.2.2" hooks: dependency: transitive description: name: hooks - sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "1.0.3" http: dependency: "direct main" description: @@ -440,6 +440,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.2" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" json_annotation: dependency: transitive description: @@ -564,10 +580,10 @@ packages: dependency: "direct main" description: name: open_earable_flutter - sha256: "078c8a64ad05265b5b7afae991830549e08729fecacfd255dc4a8e038f8ad12b" + sha256: b55a2e70ab5ee7ce7d46cebd65f1463a0a83684aa5849e9e3e2526471c0a4b02 url: "https://pub.dev" source: hosted - version: "2.3.5" + version: "2.3.6" open_file: dependency: "direct main" description: @@ -632,6 +648,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.3" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" package_info_plus: dependency: "direct main" description: @@ -676,10 +700,10 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba" + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" url: "https://pub.dev" source: hosted - version: "2.2.23" + version: "2.3.1" path_provider_foundation: dependency: transitive description: @@ -808,6 +832,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" rxdart: dependency: transitive description: @@ -929,10 +961,10 @@ packages: dependency: transitive description: name: synchronized - sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" url: "https://pub.dev" source: hosted - version: "3.4.0" + version: "3.4.0+1" term_glyph: dependency: transitive description: @@ -1081,26 +1113,26 @@ packages: dependency: transitive description: name: vm_service - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + sha256: "046d3928e16fa4dc46e8350415661755ab759d9fc97fc21b5ab295f71e4f0499" url: "https://pub.dev" source: hosted - version: "15.0.2" + version: "15.1.0" wakelock_plus: dependency: "direct main" description: name: wakelock_plus - sha256: "8b12256f616346910c519a35606fb69b1fe0737c06b6a447c6df43888b097f39" + sha256: ddf3db70eaa10c37558ff817519b85d527dbd21034fd5d8e1c2e85f31588f1c1 url: "https://pub.dev" source: hosted - version: "1.5.1" + version: "1.5.2" wakelock_plus_platform_interface: dependency: transitive description: name: wakelock_plus_platform_interface - sha256: "24b84143787220a403491c2e5de0877fbbb87baf3f0b18a2a988973863db4b03" + sha256: "14b2e5b9e35c2631e656913c47adecdd71633ae92896a27a64c8f1fcfabc21cc" url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.5.0" web: dependency: transitive description: @@ -1142,5 +1174,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.3 <4.0.0" - flutter: ">=3.38.4" + dart: ">=3.11.0 <4.0.0" + flutter: ">=3.41.0" diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index 6147e6aa..c62ade23 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -37,7 +37,7 @@ dependencies: open_file: ^3.3.2 open_earable_flutter: ^2.3.5 universal_ble: ^0.21.1 - flutter_platform_widgets: ^9.0.0 + flutter_platform_widgets: ^10.0.1 provider: ^6.1.2 logger: ^2.5.0 community_charts_flutter: ^1.0.4 @@ -54,7 +54,7 @@ dependencies: flutter_archive: ^6.0.3 shared_preferences: ^2.5.3 url_launcher: ^6.3.2 - go_router: ^14.6.2 + go_router: ^17.2.2 http: ^1.6.0 audioplayers: ^6.6.0 wakelock_plus: ^1.4.0 diff --git a/open_wearable/windows/flutter/generated_plugins.cmake b/open_wearable/windows/flutter/generated_plugins.cmake index 154f7830..ed5b9c00 100644 --- a/open_wearable/windows/flutter/generated_plugins.cmake +++ b/open_wearable/windows/flutter/generated_plugins.cmake @@ -12,6 +12,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) From d34e96cec7b7914b0dea2fcee2f0d1d41aabc838 Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:14:02 +0200 Subject: [PATCH 25/51] fix(logging): enable app file logs in release --- .../lib/models/log_file_manager.dart | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/open_wearable/lib/models/log_file_manager.dart b/open_wearable/lib/models/log_file_manager.dart index 23d7b5f2..56a14c63 100644 --- a/open_wearable/lib/models/log_file_manager.dart +++ b/open_wearable/lib/models/log_file_manager.dart @@ -5,8 +5,8 @@ import 'package:flutter/foundation.dart'; import 'package:logger/logger.dart'; import 'package:path_provider/path_provider.dart'; -class _CustomLogFilter extends LogFilter { - _CustomLogFilter(this._minLevel); +class _CustomLibLogFilter extends LogFilter { + _CustomLibLogFilter(this._minLevel); final Level _minLevel; @@ -26,6 +26,17 @@ class _CustomLogFilter extends LogFilter { } } +class _CustomAppLogFilter extends LogFilter { + _CustomAppLogFilter(this._minLevel); + + final Level _minLevel; + + @override + bool shouldLog(LogEvent event) { + return event.level.index >= _minLevel.index; + } +} + /// Central logging service for app/runtime logs and persisted log files. /// /// Needs: @@ -75,7 +86,8 @@ class LogFileManager with ChangeNotifier { printer = LogfmtPrinter(); } - final libFilter = _CustomLogFilter(level); + final appFilter = _CustomAppLogFilter(level); + final libFilter = _CustomLibLogFilter(level); LogOutput? fileOutput; String logDirPath = ''; @@ -112,6 +124,7 @@ class LogFileManager with ChangeNotifier { // ------------------------ final logger = Logger( level: level, + filter: appFilter, printer: PrefixPrinter( printer, trace: '[APP] TRACE', From 649beebb78654eb64b0eb30e5d36a5c8bb31a9e9 Mon Sep 17 00:00:00 2001 From: Josua Hechel Date: Fri, 6 Feb 2026 16:40:07 +0100 Subject: [PATCH 26/51] Add mock wearable and sensor simulation functionality that reads form the sensors of the device the app is running on --- open_wearable/lib/models/mock_wearable.dart | 131 ++++++++++++++++++ .../widgets/devices/connect_devices_page.dart | 17 +++ open_wearable/macos/Podfile.lock | 1 + open_wearable/pubspec.lock | 16 +++ open_wearable/pubspec.yaml | 1 + 5 files changed, 166 insertions(+) create mode 100644 open_wearable/lib/models/mock_wearable.dart diff --git a/open_wearable/lib/models/mock_wearable.dart b/open_wearable/lib/models/mock_wearable.dart new file mode 100644 index 00000000..87811a02 --- /dev/null +++ b/open_wearable/lib/models/mock_wearable.dart @@ -0,0 +1,131 @@ +import 'dart:async'; + +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:sensors_plus/sensors_plus.dart'; + +class MockWearable extends Wearable + implements SensorManager, SensorConfigurationManager { + @override + final List sensors = []; + + @override + final List sensorConfigurations = []; + + @override + Stream> + get sensorConfigurationStream => const Stream.empty(); + + MockWearable({required super.disconnectNotifier}) + : super(name: "Mock Device") { + sensors.add(MockGyroSensor()); + sensors.add(MockAccelerometer()); + } + + @override + String get deviceId => "MOCK-001"; + + @override + Future disconnect() async { + // nothing to do + return Future.value(); + } + + @override + String? getWearableIconPath({bool darkmode = false}) { + return null; + } +} + +class MockGyroSensor extends Sensor { + MockGyroSensor() + : super( + sensorName: "Gyroscope", + chartTitle: "Gyroscope", + shortChartTitle: "Gyro", + relatedConfigurations: [], + ); + + @override + List get axisNames => ['X', 'Y', 'Z']; + + @override + List get axisUnits => ['rad/s', 'rad/s', 'rad/s']; + + @override + Stream get sensorStream { + return gyroscopeEventStream().map((event) { + return SensorDoubleValue( + values: [event.x, event.y, event.z], + timestamp: DateTime.now().millisecondsSinceEpoch, + ); + }); + } +} + +class MockAccelerometer extends Sensor { + MockAccelerometer() + : super( + sensorName: "Accelerometer", + chartTitle: "Accelerometer", + shortChartTitle: "Accel", + relatedConfigurations: [ + MockConfigurableSensorConfiguration( + name: "Sensor Rate", + availableOptions: { + StreamSensorConfigOption() + }, + values: [ + MockConfigurableSensorConfigurationValue( + key: "30Hz", + options: { + StreamSensorConfigOption(), + }) + ]) + ], + ); + + @override + List get axisNames => ['X', 'Y', 'Z']; + + @override + List get axisUnits => ['m/s²', 'm/s²', 'm/s²']; + + @override + Stream get sensorStream { + return accelerometerEventStream().map((event) { + return SensorDoubleValue( + values: [event.x, event.y, event.z], + timestamp: DateTime.now().millisecondsSinceEpoch, + ); + }); + } +} + +class MockConfigurableSensorConfiguration + extends ConfigurableSensorConfiguration< + MockConfigurableSensorConfigurationValue> { + MockConfigurableSensorConfiguration({ + required super.name, + required super.values, + super.availableOptions, + }); + + @override + void setConfiguration( + MockConfigurableSensorConfigurationValue configuration) { + // no-op + } +} + +class MockConfigurableSensorConfigurationValue + extends ConfigurableSensorConfigurationValue { + MockConfigurableSensorConfigurationValue({ + required super.key, + super.options, + }); + + @override + MockConfigurableSensorConfigurationValue withoutOptions() { + return MockConfigurableSensorConfigurationValue(key: key); + } +} diff --git a/open_wearable/lib/widgets/devices/connect_devices_page.dart b/open_wearable/lib/widgets/devices/connect_devices_page.dart index f9ffd08d..9daa6416 100644 --- a/open_wearable/lib/widgets/devices/connect_devices_page.dart +++ b/open_wearable/lib/widgets/devices/connect_devices_page.dart @@ -6,7 +6,9 @@ import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; import 'package:universal_ble/universal_ble.dart'; import 'package:open_wearable/models/connect_devices_scan_session.dart'; import 'package:open_wearable/models/device_name_formatter.dart'; +import 'package:open_wearable/models/mock_wearable.dart'; import 'package:open_wearable/models/wearable_display_group.dart'; +import 'package:open_wearable/view_models/sensor_recorder_provider.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:open_wearable/widgets/devices/devices_page.dart'; import 'package:open_wearable/widgets/recording_activity_indicator.dart'; @@ -193,6 +195,21 @@ class _ConnectDevicesPageState extends State { ], ), ), + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: PlatformElevatedButton( + onPressed: () { + final mockWearable = MockWearable( + disconnectNotifier: WearableDisconnectNotifier(), + ); + context.read().addWearable(mockWearable); + context + .read() + .addWearable(mockWearable); + }, + child: PlatformText('Simulate Device'), + ), + ), ], ), ), diff --git a/open_wearable/macos/Podfile.lock b/open_wearable/macos/Podfile.lock index 6cb76654..aedcf168 100644 --- a/open_wearable/macos/Podfile.lock +++ b/open_wearable/macos/Podfile.lock @@ -89,6 +89,7 @@ SPEC CHECKSUMS: open_file_mac: 76f06c8597551249bdb5e8fd8827a98eae0f4585 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 package_info_plus: f0052d280d17aa382b932f399edf32507174e870 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb universal_ble: ff19787898040d721109c6324472e5dd4bc86adc diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index 304bce54..df54b5a2 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -848,6 +848,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + sensors_plus: + dependency: "direct main" + description: + name: sensors_plus + sha256: "56e8cd4260d9ed8e00ecd8da5d9fdc8a1b2ec12345a750dfa51ff83fcf12e3fa" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + sensors_plus_platform_interface: + dependency: transitive + description: + name: sensors_plus_platform_interface + sha256: "58815d2f5e46c0c41c40fb39375d3f127306f7742efe3b891c0b1c87e2b5cd5d" + url: "https://pub.dev" + source: hosted + version: "2.0.1" share_plus: dependency: "direct main" description: diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index c62ade23..2cc26310 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -59,6 +59,7 @@ dependencies: audioplayers: ^6.6.0 wakelock_plus: ^1.4.0 package_info_plus: ^9.0.0 + sensors_plus: ^7.0.0 dev_dependencies: flutter_test: From 6e406e6097eb1324a2478f1486bbfdf5fc420120 Mon Sep 17 00:00:00 2001 From: Josua Hechel Date: Fri, 6 Feb 2026 16:48:40 +0100 Subject: [PATCH 27/51] Fix formatting issues in mock wearable sensor configuration --- open_wearable/lib/models/mock_wearable.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/open_wearable/lib/models/mock_wearable.dart b/open_wearable/lib/models/mock_wearable.dart index 87811a02..560ab833 100644 --- a/open_wearable/lib/models/mock_wearable.dart +++ b/open_wearable/lib/models/mock_wearable.dart @@ -72,15 +72,15 @@ class MockAccelerometer extends Sensor { MockConfigurableSensorConfiguration( name: "Sensor Rate", availableOptions: { - StreamSensorConfigOption() + StreamSensorConfigOption(), }, values: [ MockConfigurableSensorConfigurationValue( key: "30Hz", options: { StreamSensorConfigOption(), - }) - ]) + },), + ],), ], ); @@ -112,7 +112,7 @@ class MockConfigurableSensorConfiguration @override void setConfiguration( - MockConfigurableSensorConfigurationValue configuration) { + MockConfigurableSensorConfigurationValue configuration,) { // no-op } } From b5a2724bc1c94a0adc094be3b3f6457ea98ec912 Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Mon, 9 Feb 2026 16:24:11 +0100 Subject: [PATCH 28/51] Renamed MockWearable with ThisDeviceWearable --- .../{mock_wearable.dart => this_device_wearable.dart} | 8 ++++---- .../lib/widgets/devices/connect_devices_page.dart | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) rename open_wearable/lib/models/{mock_wearable.dart => this_device_wearable.dart} (94%) diff --git a/open_wearable/lib/models/mock_wearable.dart b/open_wearable/lib/models/this_device_wearable.dart similarity index 94% rename from open_wearable/lib/models/mock_wearable.dart rename to open_wearable/lib/models/this_device_wearable.dart index 560ab833..98d4b5f5 100644 --- a/open_wearable/lib/models/mock_wearable.dart +++ b/open_wearable/lib/models/this_device_wearable.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:sensors_plus/sensors_plus.dart'; -class MockWearable extends Wearable +class ThisDeviceWearable extends Wearable implements SensorManager, SensorConfigurationManager { @override final List sensors = []; @@ -15,14 +15,14 @@ class MockWearable extends Wearable Stream> get sensorConfigurationStream => const Stream.empty(); - MockWearable({required super.disconnectNotifier}) - : super(name: "Mock Device") { + ThisDeviceWearable({required super.disconnectNotifier}) + : super(name: "This Device") { sensors.add(MockGyroSensor()); sensors.add(MockAccelerometer()); } @override - String get deviceId => "MOCK-001"; + String get deviceId => "THIS-DEVICE-001"; @override Future disconnect() async { diff --git a/open_wearable/lib/widgets/devices/connect_devices_page.dart b/open_wearable/lib/widgets/devices/connect_devices_page.dart index 9daa6416..3668a6e1 100644 --- a/open_wearable/lib/widgets/devices/connect_devices_page.dart +++ b/open_wearable/lib/widgets/devices/connect_devices_page.dart @@ -6,7 +6,7 @@ import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; import 'package:universal_ble/universal_ble.dart'; import 'package:open_wearable/models/connect_devices_scan_session.dart'; import 'package:open_wearable/models/device_name_formatter.dart'; -import 'package:open_wearable/models/mock_wearable.dart'; +import 'package:open_wearable/models/this_device_wearable.dart'; import 'package:open_wearable/models/wearable_display_group.dart'; import 'package:open_wearable/view_models/sensor_recorder_provider.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; @@ -199,7 +199,7 @@ class _ConnectDevicesPageState extends State { padding: const EdgeInsets.only(top: 8.0), child: PlatformElevatedButton( onPressed: () { - final mockWearable = MockWearable( + final mockWearable = ThisDeviceWearable( disconnectNotifier: WearableDisconnectNotifier(), ); context.read().addWearable(mockWearable); From 90f96ece978dbc098167ce7150991f8925d440a5 Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:47:44 +0100 Subject: [PATCH 29/51] Integrate device_info_plus for enhanced device profile management and update ThisDeviceWearable to utilize device information --- .../lib/models/this_device_wearable.dart | 267 +++++++++++++++++- .../widgets/devices/connect_devices_page.dart | 5 +- .../Flutter/GeneratedPluginRegistrant.swift | 2 + open_wearable/macos/Podfile.lock | 5 + open_wearable/pubspec.lock | 26 +- open_wearable/pubspec.yaml | 2 + 6 files changed, 292 insertions(+), 15 deletions(-) diff --git a/open_wearable/lib/models/this_device_wearable.dart b/open_wearable/lib/models/this_device_wearable.dart index 98d4b5f5..6a79324d 100644 --- a/open_wearable/lib/models/this_device_wearable.dart +++ b/open_wearable/lib/models/this_device_wearable.dart @@ -1,32 +1,45 @@ import 'dart:async'; -import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/foundation.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart' hide Version, logger; +import 'package:pub_semver/pub_semver.dart'; import 'package:sensors_plus/sensors_plus.dart'; +import 'logger.dart'; + class ThisDeviceWearable extends Wearable - implements SensorManager, SensorConfigurationManager { + implements SensorManager, DeviceFirmwareVersion { @override final List sensors = []; - @override - final List sensorConfigurations = []; + final DeviceProfile deviceProfile; - @override - Stream> - get sensorConfigurationStream => const Stream.empty(); - - ThisDeviceWearable({required super.disconnectNotifier}) - : super(name: "This Device") { + ThisDeviceWearable._({ + required super.disconnectNotifier, + required this.deviceProfile, + }) : super(name: deviceProfile.displayName) { sensors.add(MockGyroSensor()); sensors.add(MockAccelerometer()); } + static Future create({ + required WearableDisconnectNotifier disconnectNotifier, + }) async { + final profile = await DeviceProfile.fetch(); + logger.d('Fetched device profile: $profile'); + return ThisDeviceWearable._( + disconnectNotifier: disconnectNotifier, + deviceProfile: profile, + ); + } + @override - String get deviceId => "THIS-DEVICE-001"; + String get deviceId => deviceProfile.deviceId; @override Future disconnect() async { - // nothing to do + // TODO: Call disconnect listeners return Future.value(); } @@ -34,6 +47,236 @@ class ThisDeviceWearable extends Wearable String? getWearableIconPath({bool darkmode = false}) { return null; } + + @override + Future checkFirmwareSupport() { + return Future.value(FirmwareSupportStatus.supported); + } + + @override + Future readDeviceFirmwareVersion() { + return deviceProfile.osVersion != null + ? Future.value(deviceProfile.osVersion) + : Future.error('OS version not available'); + } + + @override + Future readFirmwareVersionNumber() { + if (deviceProfile.osVersion == null) { + return Future.error('OS version not available'); + } + try { + final version = Version.parse(deviceProfile.osVersion!); + return Future.value(version); + } catch (e) { + return Future.error('Failed to parse OS version: $e'); + } + } + + @override + VersionConstraint get supportedFirmwareRange => VersionConstraint.any; +} + +class DeviceProfile { + final String displayName; + final String deviceId; + final String? model; + final String? manufacturer; + final String? osVersion; + final String? platform; + + const DeviceProfile({ + required this.displayName, + required this.deviceId, + this.model, + this.manufacturer, + this.osVersion, + this.platform, + }); + + static Future fetch() async { + final deviceInfo = DeviceInfoPlugin(); + try { + if (kIsWeb) { + final info = await deviceInfo.webBrowserInfo; + logger.d("Fetched web browser info: $info"); + final browserName = info.browserName.name; + final displayName = _firstNonEmpty( + [info.platform, browserName, info.appName], + 'Web Browser', + ); + final deviceId = _firstNonEmpty( + [info.userAgent, info.appVersion], + 'WEB-DEVICE', + ); + return DeviceProfile( + displayName: displayName, + deviceId: deviceId, + model: browserName, + manufacturer: info.vendor, + osVersion: info.appVersion, + platform: 'web', + ); + } + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + final info = await deviceInfo.androidInfo; + logger.d("Fetched Android device info: $info"); + final displayName = _firstNonEmpty( + [ + _joinNonEmpty([info.brand, info.model]), + info.model, + info.device, + info.product, + ], + 'Android Device', + ); + final deviceId = _firstNonEmpty( + [info.id, info.device, info.product, info.model], + 'ANDROID-DEVICE', + ); + final osVersion = _joinNonEmpty( + [ + 'Android', + info.version.release, + 'SDK ${info.version.sdkInt}', + ], + ); + return DeviceProfile( + displayName: displayName, + deviceId: deviceId, + model: info.model, + manufacturer: info.manufacturer, + osVersion: osVersion, + platform: 'android', + ); + case TargetPlatform.iOS: + final info = await deviceInfo.iosInfo; + logger.d("Fetched iOS device info: $info"); + final displayName = _firstNonEmpty( + [info.name, info.localizedModel, info.model], + 'iOS Device', + ); + final deviceId = _firstNonEmpty( + [info.identifierForVendor, info.name, info.model], + 'IOS-DEVICE', + ); + final osVersion = _joinNonEmpty( + [info.systemName, info.systemVersion], + ); + return DeviceProfile( + displayName: displayName, + deviceId: deviceId, + model: info.model, + manufacturer: 'Apple', + osVersion: osVersion, + platform: 'ios', + ); + case TargetPlatform.macOS: + final info = await deviceInfo.macOsInfo; + logger.d("Fetched macOS device info: $info"); + final displayName = _firstNonEmpty( + [info.computerName, info.model], + 'macOS Device', + ); + final deviceId = _firstNonEmpty( + [info.computerName, info.model], + 'MAC-DEVICE', + ); + final osVersion = _joinNonEmpty( + ['macOS', info.osRelease], + ); + return DeviceProfile( + displayName: displayName, + deviceId: deviceId, + model: info.model, + manufacturer: 'Apple', + osVersion: osVersion, + platform: 'macos', + ); + case TargetPlatform.windows: + final info = await deviceInfo.windowsInfo; + logger.d("Fetched Windows device info: $info"); + final displayName = _firstNonEmpty( + [info.computerName, info.productName], + 'Windows Device', + ); + final deviceId = _firstNonEmpty( + [info.deviceId, info.computerName, info.productName], + 'WINDOWS-DEVICE', + ); + final osVersion = _joinNonEmpty( + ['Windows', info.displayVersion, info.buildNumber.toString()], + ); + return DeviceProfile( + displayName: displayName, + deviceId: deviceId, + model: info.productName, + manufacturer: 'Microsoft', + osVersion: osVersion, + platform: 'windows', + ); + case TargetPlatform.linux: + final info = await deviceInfo.linuxInfo; + logger.d("Fetched Linux device info: $info"); + final displayName = _firstNonEmpty( + [info.prettyName, info.name], + 'Linux Device', + ); + final deviceId = _firstNonEmpty( + [info.machineId, info.prettyName, info.name], + 'LINUX-DEVICE', + ); + final osVersion = _joinNonEmpty( + [info.name, info.version], + ); + return DeviceProfile( + displayName: displayName, + deviceId: deviceId, + model: info.prettyName, + manufacturer: null, + osVersion: osVersion, + platform: 'linux', + ); + case TargetPlatform.fuchsia: + break; + } + } catch (_) { + // Fall back to default profile below. + } + + return const DeviceProfile( + displayName: 'This Device', + deviceId: 'THIS-DEVICE-001', + platform: 'unknown', + ); + } + + @override + String toString() { + return 'DeviceProfile(displayName: $displayName, deviceId: $deviceId, model: $model, manufacturer: $manufacturer, osVersion: $osVersion, platform: $platform)'; + } +} + +String _firstNonEmpty(List candidates, String fallback) { + for (final candidate in candidates) { + if (candidate == null) continue; + final trimmed = candidate.trim(); + if (trimmed.isNotEmpty) return trimmed; + } + return fallback; +} + +String? _joinNonEmpty(List parts) { + final cleaned = []; + for (final part in parts) { + if (part == null) continue; + final trimmed = part.trim(); + if (trimmed.isNotEmpty) cleaned.add(trimmed); + } + if (cleaned.isEmpty) return null; + return cleaned.join(' '); } class MockGyroSensor extends Sensor { diff --git a/open_wearable/lib/widgets/devices/connect_devices_page.dart b/open_wearable/lib/widgets/devices/connect_devices_page.dart index 3668a6e1..8ade47f3 100644 --- a/open_wearable/lib/widgets/devices/connect_devices_page.dart +++ b/open_wearable/lib/widgets/devices/connect_devices_page.dart @@ -198,10 +198,11 @@ class _ConnectDevicesPageState extends State { Padding( padding: const EdgeInsets.only(top: 8.0), child: PlatformElevatedButton( - onPressed: () { - final mockWearable = ThisDeviceWearable( + onPressed: () async { + final mockWearable = await ThisDeviceWearable.create( disconnectNotifier: WearableDisconnectNotifier(), ); + if (!context.mounted) return; context.read().addWearable(mockWearable); context .read() diff --git a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift index 696f95be..39bfb0ef 100644 --- a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,6 +6,7 @@ import FlutterMacOS import Foundation import audioplayers_darwin +import device_info_plus import file_picker import file_selector_macos import flutter_archive @@ -19,6 +20,7 @@ import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterArchivePlugin.register(with: registry.registrar(forPlugin: "FlutterArchivePlugin")) diff --git a/open_wearable/macos/Podfile.lock b/open_wearable/macos/Podfile.lock index aedcf168..1d89c73e 100644 --- a/open_wearable/macos/Podfile.lock +++ b/open_wearable/macos/Podfile.lock @@ -1,6 +1,7 @@ PODS: - audioplayers_darwin (0.0.1): - Flutter + - device_info_plus (0.0.1): - FlutterMacOS - file_picker (0.0.1): - FlutterMacOS @@ -35,6 +36,7 @@ PODS: DEPENDENCIES: - audioplayers_darwin (from `Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/darwin`) + - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) - flutter_archive (from `Flutter/ephemeral/.symlinks/plugins/flutter_archive/macos`) @@ -55,6 +57,8 @@ SPEC REPOS: EXTERNAL SOURCES: audioplayers_darwin: :path: Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/darwin + device_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos file_picker: :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos file_selector_macos: @@ -82,6 +86,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5 + device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76 file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7 flutter_archive: 07888d9aeb79da005e0ad8b9d347d17cdea07f68 diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index df54b5a2..39926ab2 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -185,6 +185,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.12" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd + url: "https://pub.dev" + source: hosted + version: "12.4.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" equatable: dependency: transitive description: @@ -825,7 +841,7 @@ packages: source: hosted version: "6.1.5+1" pub_semver: - dependency: transitive + dependency: "direct main" description: name: pub_semver sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" @@ -1165,6 +1181,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.dev" + source: hosted + version: "2.1.0" xdg_directories: dependency: transitive description: diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index 2cc26310..d0678f7a 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -60,6 +60,8 @@ dependencies: wakelock_plus: ^1.4.0 package_info_plus: ^9.0.0 sensors_plus: ^7.0.0 + device_info_plus: ^12.3.0 + pub_semver: ^2.2.0 dev_dependencies: flutter_test: From 25b7bb16aaf6e6a95ce4870d3893ac5d8d961a37 Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:53:41 +0100 Subject: [PATCH 30/51] open_wearable/lib/models/this_device_wearable.dart: Refactor ThisDeviceWearable to initialize sensors asynchronously and improve sensor availability checks --- .../lib/models/this_device_wearable.dart | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/open_wearable/lib/models/this_device_wearable.dart b/open_wearable/lib/models/this_device_wearable.dart index 6a79324d..31ab2af8 100644 --- a/open_wearable/lib/models/this_device_wearable.dart +++ b/open_wearable/lib/models/this_device_wearable.dart @@ -18,20 +18,19 @@ class ThisDeviceWearable extends Wearable ThisDeviceWearable._({ required super.disconnectNotifier, required this.deviceProfile, - }) : super(name: deviceProfile.displayName) { - sensors.add(MockGyroSensor()); - sensors.add(MockAccelerometer()); - } + }) : super(name: deviceProfile.displayName); static Future create({ required WearableDisconnectNotifier disconnectNotifier, }) async { final profile = await DeviceProfile.fetch(); logger.d('Fetched device profile: $profile'); - return ThisDeviceWearable._( + final wearable = ThisDeviceWearable._( disconnectNotifier: disconnectNotifier, deviceProfile: profile, ); + await wearable._initSensors(); + return wearable; } @override @@ -48,6 +47,26 @@ class ThisDeviceWearable extends Wearable return null; } + Future _initSensors() async { + if (await _isSensorAvailable(gyroscopeEventStream())) { + sensors.add(MockGyroSensor()); + } + if (await _isSensorAvailable( + accelerometerEventStream(), + )) { + sensors.add(MockAccelerometer()); + } + } + + static Future _isSensorAvailable(Stream stream) async { + try { + await stream.first.timeout(const Duration(milliseconds: 800)); + return true; + } catch (_) { + return false; + } + } + @override Future checkFirmwareSupport() { return Future.value(FirmwareSupportStatus.supported); From 6410e141e807ff98b182982d4e08440df03ff100 Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:22:21 +0100 Subject: [PATCH 31/51] open_wearable/ios/Runner/Info.plist: added key for motion data --- open_wearable/ios/Runner/Info.plist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/open_wearable/ios/Runner/Info.plist b/open_wearable/ios/Runner/Info.plist index 72b4953b..b9ef095a 100644 --- a/open_wearable/ios/Runner/Info.plist +++ b/open_wearable/ios/Runner/Info.plist @@ -41,6 +41,8 @@ This app requires Bluetooth access to communicate with wearable devices. NSLocalNetworkUsageDescription This app uses the local network to host a webserver for tools integration. + NSMotionUsageDescription + This app requires access to device motion in order to provide sensor data. NSPhotoLibraryUsageDescription Needed for optional file selection functionality. UIApplicationSupportsIndirectInputEvents From b8578476b8b62208d96ba96244216ef59c60bcde Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:22:55 +0100 Subject: [PATCH 32/51] open_wearable/lib/models/this_device_wearable.dart: added configurable sensors --- .../lib/models/this_device_wearable.dart | 310 +++++++++++++----- 1 file changed, 235 insertions(+), 75 deletions(-) diff --git a/open_wearable/lib/models/this_device_wearable.dart b/open_wearable/lib/models/this_device_wearable.dart index 31ab2af8..49fd5dbd 100644 --- a/open_wearable/lib/models/this_device_wearable.dart +++ b/open_wearable/lib/models/this_device_wearable.dart @@ -9,10 +9,20 @@ import 'package:sensors_plus/sensors_plus.dart'; import 'logger.dart'; class ThisDeviceWearable extends Wearable - implements SensorManager, DeviceFirmwareVersion { + implements SensorManager, SensorConfigurationManager, DeviceFirmwareVersion { @override final List sensors = []; + @override + final List sensorConfigurations = []; + + final StreamController> + _sensorConfigurationController = StreamController.broadcast(); + + @override + Stream> + get sensorConfigurationStream => _sensorConfigurationController.stream; + final DeviceProfile deviceProfile; ThisDeviceWearable._({ @@ -47,14 +57,107 @@ class ThisDeviceWearable extends Wearable return null; } + void _emitSensorConfigurationChange( + SensorConfiguration configuration, + SensorConfigurationValue value, + ) { + _sensorConfigurationController.add({configuration: value}); + } + Future _initSensors() async { if (await _isSensorAvailable(gyroscopeEventStream())) { - sensors.add(MockGyroSensor()); + final gyroConfig = DeviceSensorConfiguration( + name: 'Gyroscope', + onChange: _emitSensorConfigurationChange, + ); + sensorConfigurations.add(gyroConfig); + _emitSensorConfigurationChange(gyroConfig, gyroConfig.currentValue); + sensors.add( + ThisDeviceSensor( + config: gyroConfig, + sensorName: 'Gyroscope', + chartTitle: 'Gyroscope', + shortChartTitle: 'Gyro', + axisNames: ['X', 'Y', 'Z'], + axisUnits: ['rad/s', 'rad/s', 'rad/s'], + valueExtractor: (event) => SensorDoubleValue( + values: [event.x, event.y, event.z], + timestamp: event.timestamp.millisecondsSinceEpoch, + ), + sensorStreamProvider: gyroscopeEventStream, + ), + ); } if (await _isSensorAvailable( accelerometerEventStream(), )) { - sensors.add(MockAccelerometer()); + final accelConfig = DeviceSensorConfiguration( + name: 'Accelerometer', + onChange: _emitSensorConfigurationChange, + ); + sensorConfigurations.add(accelConfig); + _emitSensorConfigurationChange(accelConfig, accelConfig.currentValue); + sensors.add( + ThisDeviceSensor( + config: accelConfig, + sensorName: 'Accelerometer', + chartTitle: 'Accelerometer', + shortChartTitle: 'Accel', + axisNames: ['X', 'Y', 'Z'], + axisUnits: ['m/s²', 'm/s²', 'm/s²'], + valueExtractor: (event) => SensorDoubleValue( + values: [event.x, event.y, event.z], + timestamp: event.timestamp.millisecondsSinceEpoch, + ), + sensorStreamProvider: accelerometerEventStream, + ), + ); + } + if (await _isSensorAvailable(magnetometerEventStream())) { + final magConfig = DeviceSensorConfiguration( + name: 'Magnetometer', + onChange: _emitSensorConfigurationChange, + ); + sensorConfigurations.add(magConfig); + _emitSensorConfigurationChange(magConfig, magConfig.currentValue); + sensors.add( + ThisDeviceSensor( + config: magConfig, + sensorName: 'Magnetometer', + chartTitle: 'Magnetometer', + shortChartTitle: 'Mag', + axisNames: ['X', 'Y', 'Z'], + axisUnits: ['µT', 'µT', 'µT'], + valueExtractor: (event) => SensorDoubleValue( + values: [event.x, event.y, event.z], + timestamp: event.timestamp.millisecondsSinceEpoch, + ), + sensorStreamProvider: magnetometerEventStream, + ), + ); + } + if (await _isSensorAvailable(barometerEventStream())) { + final baroConfig = DeviceSensorConfiguration( + name: 'Barometer', + onChange: _emitSensorConfigurationChange, + ); + sensorConfigurations.add(baroConfig); + _emitSensorConfigurationChange(baroConfig, baroConfig.currentValue); + sensors.add( + ThisDeviceSensor( + config: baroConfig, + sensorName: 'Barometer', + chartTitle: 'Barometer', + shortChartTitle: 'Baro', + axisNames: ['Pressure'], + axisUnits: ['hPa'], + valueExtractor: (event) => SensorDoubleValue( + values: [event.pressure], + timestamp: event.timestamp.millisecondsSinceEpoch, + ), + sensorStreamProvider: barometerEventStream, + ), + ); } } @@ -298,96 +401,153 @@ String? _joinNonEmpty(List parts) { return cleaned.join(' '); } -class MockGyroSensor extends Sensor { - MockGyroSensor() - : super( - sensorName: "Gyroscope", - chartTitle: "Gyroscope", - shortChartTitle: "Gyro", - relatedConfigurations: [], - ); +class ThisDeviceSensor extends Sensor { + final DeviceSensorConfiguration config; + late final StreamController _controller; + StreamSubscription? _subscription; + final Stream Function({required Duration samplingPeriod}) _sensorStreamProvider; + final SensorDoubleValue Function(SensorEvent event) _valueExtractor; + + ThisDeviceSensor({ + required super.sensorName, + required super.chartTitle, + required super.shortChartTitle, + required this.config, + required List axisNames, + required List axisUnits, + required SensorDoubleValue Function(SensorEvent event) valueExtractor, + required Stream Function({required Duration samplingPeriod}) sensorStreamProvider, + }) : _axisNames = axisNames, + _axisUnits = axisUnits, + _valueExtractor = valueExtractor, + _sensorStreamProvider = sensorStreamProvider { + _controller = StreamController.broadcast( + onListen: _updateSubscription, + onCancel: _updateSubscription, + ); + } + + final List _axisNames; @override - List get axisNames => ['X', 'Y', 'Z']; - + List get axisNames => _axisNames; + + final List _axisUnits; @override - List get axisUnits => ['rad/s', 'rad/s', 'rad/s']; - + List get axisUnits => _axisUnits; + @override - Stream get sensorStream { - return gyroscopeEventStream().map((event) { - return SensorDoubleValue( - values: [event.x, event.y, event.z], - timestamp: DateTime.now().millisecondsSinceEpoch, - ); - }); + Stream get sensorStream => _controller.stream; + + void _updateSubscription() { + if (!_controller.hasListener) { + _cancelSubscription(); + return; + } + + final value = config.currentValue; + if (value.isOff) { + _cancelSubscription(); + return; + } + + final samplingPeriod = value.frequencyHz > 0 ? Duration(milliseconds: (1000 / value.frequencyHz).round()) : SensorInterval.normalInterval; + + _cancelSubscription(); + _subscription = + _sensorStreamProvider(samplingPeriod: samplingPeriod).listen( + (event) { + _controller.add( + _valueExtractor(event), + ); + }, + onError: _controller.addError, + ); + } + + void _cancelSubscription() { + _subscription?.cancel(); + _subscription = null; } } -class MockAccelerometer extends Sensor { - MockAccelerometer() - : super( - sensorName: "Accelerometer", - chartTitle: "Accelerometer", - shortChartTitle: "Accel", - relatedConfigurations: [ - MockConfigurableSensorConfiguration( - name: "Sensor Rate", - availableOptions: { - StreamSensorConfigOption(), - }, - values: [ - MockConfigurableSensorConfigurationValue( - key: "30Hz", - options: { - StreamSensorConfigOption(), - },), - ],), - ], +class DeviceSensorConfiguration + extends SensorFrequencyConfiguration { + final void Function( + SensorConfiguration configuration, + SensorConfigurationValue value, + ) onChange; + + DeviceSensorFrequencyValue _currentValue; + + DeviceSensorConfiguration({ + required super.name, + required this.onChange, + }) : _currentValue = DeviceSensorFrequencyValue.normal(), + super( + values: DeviceSensorFrequencyValue.defaults(), + offValue: DeviceSensorFrequencyValue.off(), ); - @override - List get axisNames => ['X', 'Y', 'Z']; + DeviceSensorFrequencyValue get currentValue => _currentValue; - @override - List get axisUnits => ['m/s²', 'm/s²', 'm/s²']; + Stream get changes => + _changesController.stream; + + final StreamController _changesController = + StreamController.broadcast(); @override - Stream get sensorStream { - return accelerometerEventStream().map((event) { - return SensorDoubleValue( - values: [event.x, event.y, event.z], - timestamp: DateTime.now().millisecondsSinceEpoch, - ); - }); + void setConfiguration(DeviceSensorFrequencyValue configuration) { + _currentValue = configuration; + onChange(this, configuration); + _changesController.add(configuration); } } -class MockConfigurableSensorConfiguration - extends ConfigurableSensorConfiguration< - MockConfigurableSensorConfigurationValue> { - MockConfigurableSensorConfiguration({ - required super.name, - required super.values, - super.availableOptions, - }); +class DeviceSensorFrequencyValue extends SensorFrequencyConfigurationValue { + DeviceSensorFrequencyValue({ + required super.frequencyHz, + String? key, + }) : super( + key: key ?? _formatKey(frequencyHz), + ); - @override - void setConfiguration( - MockConfigurableSensorConfigurationValue configuration,) { - // no-op + bool get isOff => frequencyHz <= 0; + + static DeviceSensorFrequencyValue off() { + return DeviceSensorFrequencyValue( + frequencyHz: 0, + key: 'Off', + ); } -} -class MockConfigurableSensorConfigurationValue - extends ConfigurableSensorConfigurationValue { - MockConfigurableSensorConfigurationValue({ - required super.key, - super.options, - }); + static DeviceSensorFrequencyValue normal() { + return DeviceSensorFrequencyValue( + frequencyHz: 5, + ); + } - @override - MockConfigurableSensorConfigurationValue withoutOptions() { - return MockConfigurableSensorConfigurationValue(key: key); + static List defaults() { + return [ + off(), + normal(), + DeviceSensorFrequencyValue( + frequencyHz: 15, + ), + DeviceSensorFrequencyValue( + frequencyHz: 30, + ), + DeviceSensorFrequencyValue( + frequencyHz: 50, + ), + ]; + } + + static String _formatKey(double frequencyHz) { + if (frequencyHz == frequencyHz.roundToDouble()) { + return '${frequencyHz.toInt()} Hz'; + } + return '${frequencyHz.toStringAsFixed(2)} Hz'; } } From 9b64a8b4c46ffef191eb73bbbe7a04bc938bfe9b Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:45:21 +0100 Subject: [PATCH 33/51] open_wearable/lib/modles/this_device_wearable.dart: fixed bug where configurations could not be changed --- open_wearable/lib/models/this_device_wearable.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/open_wearable/lib/models/this_device_wearable.dart b/open_wearable/lib/models/this_device_wearable.dart index 49fd5dbd..f7315c7b 100644 --- a/open_wearable/lib/models/this_device_wearable.dart +++ b/open_wearable/lib/models/this_device_wearable.dart @@ -425,9 +425,11 @@ class ThisDeviceSensor extends Sensor { onListen: _updateSubscription, onCancel: _updateSubscription, ); + config.changes.listen((value) { + _updateSubscription(); + }); } - final List _axisNames; @override List get axisNames => _axisNames; From 046ef8e58396c620d4c090f02142c5f295a169dd Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Tue, 10 Feb 2026 18:19:58 +0100 Subject: [PATCH 34/51] lib/widgets/devices/connect_devices_page.dart: display this device in the list of discovered devices --- .../widgets/devices/connect_devices_page.dart | 114 +++++++++++++----- 1 file changed, 83 insertions(+), 31 deletions(-) diff --git a/open_wearable/lib/widgets/devices/connect_devices_page.dart b/open_wearable/lib/widgets/devices/connect_devices_page.dart index 8ade47f3..30e2b2f9 100644 --- a/open_wearable/lib/widgets/devices/connect_devices_page.dart +++ b/open_wearable/lib/widgets/devices/connect_devices_page.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; @@ -34,6 +35,7 @@ class _ConnectDevicesPageState extends State { late ConnectDevicesScanSnapshot _scanSnapshot; late final VoidCallback _scanSnapshotListener; + DiscoveredDevice? _thisDeviceEntry; @override void initState() { @@ -52,6 +54,7 @@ class _ConnectDevicesPageState extends State { if (!_scanSnapshot.isScanning) { unawaited(ConnectDevicesScanSession.startScanning(clearPrevious: true)); } + unawaited(_addThisDeviceToDiscovered()); } @override @@ -70,9 +73,16 @@ class _ConnectDevicesPageState extends State { .toList(), ); - final availableDevices = _scanSnapshot.discoveredDevices + final scannedDevices = _scanSnapshot.discoveredDevices .where((device) => !connectedDeviceIds.contains(device.id)) .toList(); + final thisDeviceEntry = _thisDeviceEntry; + final availableDevices = [ + if (thisDeviceEntry != null && + !connectedDeviceIds.contains(thisDeviceEntry.id)) + thisDeviceEntry, + ...scannedDevices.where((device) => device.id != thisDeviceEntry?.id), + ]; return PlatformScaffold( appBar: PlatformAppBar( @@ -159,18 +169,30 @@ class _ConnectDevicesPageState extends State { ) else ...availableDevices.map( - (device) => Card( - margin: const EdgeInsets.only(bottom: 8), - child: PlatformListTile( - leading: const Icon(Icons.bluetooth), - title: PlatformText(_deviceName(device)), - subtitle: PlatformText(device.id), - trailing: _buildTrailingWidget(device), - onTap: _connectingDevices[device.id] == true - ? null - : () => _connectToDevice(device, context), - ), - ), + (device) { + final isThisDevice = device.id == _thisDeviceEntry?.id; + final connect = isThisDevice + ? () => _connectToThisDevice(context) + : () => _connectToDevice(device, context); + + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: PlatformListTile( + leading: Icon( + isThisDevice ? Icons.smartphone : Icons.bluetooth, + ), + title: PlatformText(_deviceName(device)), + subtitle: PlatformText(device.id), + trailing: _buildTrailingWidget( + device, + onConnect: connect, + ), + onTap: _connectingDevices[device.id] == true + ? null + : connect, + ), + ); + }, ), const SizedBox(height: 10), PlatformElevatedButton( @@ -195,22 +217,6 @@ class _ConnectDevicesPageState extends State { ], ), ), - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: PlatformElevatedButton( - onPressed: () async { - final mockWearable = await ThisDeviceWearable.create( - disconnectNotifier: WearableDisconnectNotifier(), - ); - if (!context.mounted) return; - context.read().addWearable(mockWearable); - context - .read() - .addWearable(mockWearable); - }, - child: PlatformText('Simulate Device'), - ), - ), ], ), ), @@ -311,7 +317,10 @@ class _ConnectDevicesPageState extends State { ); } - Widget _buildTrailingWidget(DiscoveredDevice device) { + Widget _buildTrailingWidget( + DiscoveredDevice device, { + required VoidCallback onConnect, + }) { return SizedBox( width: 90, child: Align( @@ -326,7 +335,7 @@ class _ConnectDevicesPageState extends State { ), ) : PlatformTextButton( - onPressed: () => _connectToDevice(device, context), + onPressed: onConnect, child: const Text('Connect'), ), ), @@ -347,6 +356,49 @@ class _ConnectDevicesPageState extends State { return '${elapsed.inHours}h ago'; } + Future _addThisDeviceToDiscovered() async { + if (_thisDeviceEntry != null) return; + final profile = await DeviceProfile.fetch(); + if (!mounted) return; + + final thisDevice = DiscoveredDevice( + id: profile.deviceId, + name: profile.displayName, + manufacturerData: Uint8List(0), + rssi: 0, + serviceUuids: const [], + ); + + setState(() { + _thisDeviceEntry = thisDevice; + }); + } + + Future _connectToThisDevice(BuildContext context) async { + final device = _thisDeviceEntry; + if (device == null) return; + if (_connectingDevices[device.id] == true) return; + + setState(() { + _connectingDevices[device.id] = true; + }); + + try { + final wearable = await ThisDeviceWearable.create( + disconnectNotifier: WearableDisconnectNotifier(), + ); + if (!context.mounted) return; + context.read().addWearable(wearable); + context.read().addWearable(wearable); + } finally { + if (context.mounted) { + setState(() { + _connectingDevices.remove(device.id); + }); + } + } + } + Future _connectToDevice( DiscoveredDevice device, BuildContext context, From 5950e0821008cc2ed790115a2b3407d03b260ff1 Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Tue, 10 Feb 2026 18:34:26 +0100 Subject: [PATCH 35/51] open_wearable/lib/models/this_device_wearable.dart: implemented disconnect --- open_wearable/lib/models/this_device_wearable.dart | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/open_wearable/lib/models/this_device_wearable.dart b/open_wearable/lib/models/this_device_wearable.dart index f7315c7b..5586d881 100644 --- a/open_wearable/lib/models/this_device_wearable.dart +++ b/open_wearable/lib/models/this_device_wearable.dart @@ -25,10 +25,13 @@ class ThisDeviceWearable extends Wearable final DeviceProfile deviceProfile; + final WearableDisconnectNotifier _disconnectNotifier; + ThisDeviceWearable._({ - required super.disconnectNotifier, + required WearableDisconnectNotifier disconnectNotifier, required this.deviceProfile, - }) : super(name: deviceProfile.displayName); + }) : _disconnectNotifier = disconnectNotifier, + super(name: deviceProfile.displayName, disconnectNotifier: disconnectNotifier); static Future create({ required WearableDisconnectNotifier disconnectNotifier, @@ -48,8 +51,7 @@ class ThisDeviceWearable extends Wearable @override Future disconnect() async { - // TODO: Call disconnect listeners - return Future.value(); + _disconnectNotifier.notifyListeners(); } @override From d1005d5a361113d26a88d4e43f42092ed05bd9a1 Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:34:10 +0100 Subject: [PATCH 36/51] open_wearable/lib/models/this_device_wearable.dart: fixed issues with flutter analyze --- open_wearable/ios/Podfile.lock | 19 ++++++++ .../lib/models/this_device_wearable.dart | 44 ++++++++++++------- 2 files changed, 46 insertions(+), 17 deletions(-) diff --git a/open_wearable/ios/Podfile.lock b/open_wearable/ios/Podfile.lock index fb2003e6..4d00798f 100644 --- a/open_wearable/ios/Podfile.lock +++ b/open_wearable/ios/Podfile.lock @@ -2,6 +2,8 @@ PODS: - audioplayers_darwin (0.0.1): - Flutter - FlutterMacOS + - device_info_plus (0.0.1): + - Flutter - DKImagePickerController/Core (4.3.9): - DKImagePickerController/ImageDataManager - DKImagePickerController/Resource @@ -53,11 +55,16 @@ PODS: - Flutter - package_info_plus (0.4.5): - Flutter + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS - permission_handler_apple (9.3.0): - Flutter - SDWebImage (5.21.7): - SDWebImage/Core (= 5.21.7) - SDWebImage/Core (5.21.7) + - sensors_plus (0.0.1): + - Flutter - share_plus (0.0.1): - Flutter - shared_preferences_foundation (0.0.1): @@ -77,6 +84,7 @@ PODS: DEPENDENCIES: - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`) + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) - file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`) - Flutter (from `Flutter`) @@ -84,7 +92,9 @@ DEPENDENCIES: - mcumgr_flutter (from `.symlinks/plugins/mcumgr_flutter/ios`) - open_file_ios (from `.symlinks/plugins/open_file_ios/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - sensors_plus (from `.symlinks/plugins/sensors_plus/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - universal_ble (from `.symlinks/plugins/universal_ble/darwin`) @@ -105,6 +115,8 @@ SPEC REPOS: EXTERNAL SOURCES: audioplayers_darwin: :path: ".symlinks/plugins/audioplayers_darwin/darwin" + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" file_picker: :path: ".symlinks/plugins/file_picker/ios" file_selector_ios: @@ -119,8 +131,12 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/open_file_ios/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" + sensors_plus: + :path: ".symlinks/plugins/sensors_plus/ios" share_plus: :path: ".symlinks/plugins/share_plus/ios" shared_preferences_foundation: @@ -134,6 +150,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5 + device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be @@ -144,8 +161,10 @@ SPEC CHECKSUMS: mcumgr_flutter: 969e99cc15e9fe658242669ce1075bf4612aef8a open_file_ios: 46184d802ee7959203f6392abcfa0dd49fdb5be0 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf + sensors_plus: 6a11ed0c2e1d0bd0b20b4029d3bad27d96e0c65b share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb SwiftCBOR: 465775bed0e8bac7bfb8160bcf7b95d7f75971e4 diff --git a/open_wearable/lib/models/this_device_wearable.dart b/open_wearable/lib/models/this_device_wearable.dart index 5586d881..7830aa00 100644 --- a/open_wearable/lib/models/this_device_wearable.dart +++ b/open_wearable/lib/models/this_device_wearable.dart @@ -2,14 +2,18 @@ import 'dart:async'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart'; -import 'package:open_earable_flutter/open_earable_flutter.dart' hide Version, logger; +import 'package:open_earable_flutter/open_earable_flutter.dart' + hide Version, logger; import 'package:pub_semver/pub_semver.dart'; import 'package:sensors_plus/sensors_plus.dart'; import 'logger.dart'; class ThisDeviceWearable extends Wearable - implements SensorManager, SensorConfigurationManager, DeviceFirmwareVersion { + implements + SensorManager, + SensorConfigurationManager, + DeviceFirmwareVersion { @override final List sensors = []; @@ -28,10 +32,10 @@ class ThisDeviceWearable extends Wearable final WearableDisconnectNotifier _disconnectNotifier; ThisDeviceWearable._({ - required WearableDisconnectNotifier disconnectNotifier, + required super.disconnectNotifier, required this.deviceProfile, - }) : _disconnectNotifier = disconnectNotifier, - super(name: deviceProfile.displayName, disconnectNotifier: disconnectNotifier); + }) : _disconnectNotifier = disconnectNotifier, + super(name: deviceProfile.displayName); static Future create({ required WearableDisconnectNotifier disconnectNotifier, @@ -55,7 +59,10 @@ class ThisDeviceWearable extends Wearable } @override - String? getWearableIconPath({bool darkmode = false}) { + String? getWearableIconPath({ + bool darkmode = false, + WearableIconVariant variant = WearableIconVariant.single, + }) { return null; } @@ -407,7 +414,8 @@ class ThisDeviceSensor extends Sensor { final DeviceSensorConfiguration config; late final StreamController _controller; StreamSubscription? _subscription; - final Stream Function({required Duration samplingPeriod}) _sensorStreamProvider; + final Stream Function({required Duration samplingPeriod}) + _sensorStreamProvider; final SensorDoubleValue Function(SensorEvent event) _valueExtractor; ThisDeviceSensor({ @@ -418,11 +426,12 @@ class ThisDeviceSensor extends Sensor { required List axisNames, required List axisUnits, required SensorDoubleValue Function(SensorEvent event) valueExtractor, - required Stream Function({required Duration samplingPeriod}) sensorStreamProvider, - }) : _axisNames = axisNames, - _axisUnits = axisUnits, - _valueExtractor = valueExtractor, - _sensorStreamProvider = sensorStreamProvider { + required Stream Function({required Duration samplingPeriod}) + sensorStreamProvider, + }) : _axisNames = axisNames, + _axisUnits = axisUnits, + _valueExtractor = valueExtractor, + _sensorStreamProvider = sensorStreamProvider { _controller = StreamController.broadcast( onListen: _updateSubscription, onCancel: _updateSubscription, @@ -435,11 +444,11 @@ class ThisDeviceSensor extends Sensor { final List _axisNames; @override List get axisNames => _axisNames; - + final List _axisUnits; @override List get axisUnits => _axisUnits; - + @override Stream get sensorStream => _controller.stream; @@ -455,7 +464,9 @@ class ThisDeviceSensor extends Sensor { return; } - final samplingPeriod = value.frequencyHz > 0 ? Duration(milliseconds: (1000 / value.frequencyHz).round()) : SensorInterval.normalInterval; + final samplingPeriod = value.frequencyHz > 0 + ? Duration(milliseconds: (1000 / value.frequencyHz).round()) + : SensorInterval.normalInterval; _cancelSubscription(); _subscription = @@ -495,8 +506,7 @@ class DeviceSensorConfiguration DeviceSensorFrequencyValue get currentValue => _currentValue; - Stream get changes => - _changesController.stream; + Stream get changes => _changesController.stream; final StreamController _changesController = StreamController.broadcast(); From f91ed3ec0beb6cb42d89c86431bb0a565032f076 Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:51:19 +0200 Subject: [PATCH 37/51] feat(this_device_wearable): added more frequency options --- .../lib/models/this_device_wearable.dart | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/open_wearable/lib/models/this_device_wearable.dart b/open_wearable/lib/models/this_device_wearable.dart index 7830aa00..57709d8d 100644 --- a/open_wearable/lib/models/this_device_wearable.dart +++ b/open_wearable/lib/models/this_device_wearable.dart @@ -545,19 +545,25 @@ class DeviceSensorFrequencyValue extends SensorFrequencyConfigurationValue { static List defaults() { return [ off(), + fromHz(1), normal(), - DeviceSensorFrequencyValue( - frequencyHz: 15, - ), - DeviceSensorFrequencyValue( - frequencyHz: 30, - ), - DeviceSensorFrequencyValue( - frequencyHz: 50, - ), + fromHz(10), + fromHz(15), + fromHz(20), + fromHz(30), + fromHz(50), + fromHz(60), + fromHz(100), + fromHz(200), ]; } + static DeviceSensorFrequencyValue fromHz(double frequencyHz) { + return DeviceSensorFrequencyValue( + frequencyHz: frequencyHz, + ); + } + static String _formatKey(double frequencyHz) { if (frequencyHz == frequencyHz.roundToDouble()) { return '${frequencyHz.toInt()} Hz'; From 0ff058cbcc08b3ef76a26734a5bc0ea8d52c76ad Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:39:54 +0200 Subject: [PATCH 38/51] fix(sensors): default phone sensor sampling to off --- .../lib/models/this_device_wearable.dart | 2 +- .../lib/view_models/wearables_provider.dart | 22 ++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/open_wearable/lib/models/this_device_wearable.dart b/open_wearable/lib/models/this_device_wearable.dart index 57709d8d..49abb953 100644 --- a/open_wearable/lib/models/this_device_wearable.dart +++ b/open_wearable/lib/models/this_device_wearable.dart @@ -498,7 +498,7 @@ class DeviceSensorConfiguration DeviceSensorConfiguration({ required super.name, required this.onChange, - }) : _currentValue = DeviceSensorFrequencyValue.normal(), + }) : _currentValue = DeviceSensorFrequencyValue.off(), super( values: DeviceSensorFrequencyValue.defaults(), offValue: DeviceSensorFrequencyValue.off(), diff --git a/open_wearable/lib/view_models/wearables_provider.dart b/open_wearable/lib/view_models/wearables_provider.dart index f81bba4e..c59a0d67 100644 --- a/open_wearable/lib/view_models/wearables_provider.dart +++ b/open_wearable/lib/view_models/wearables_provider.dart @@ -344,13 +344,33 @@ class WearablesProvider with ChangeNotifier { config.values.isNotEmpty) { notifier.addSensorConfiguration( config, - config.values.first, + _initialSensorConfigurationValue(config), markPending: false, ); } } } + /// Returns the best initial value for a sensor configuration. + /// + /// Some local configuration implementations expose a `currentValue` before + /// the provider subscribes to configuration reports. Prefer that value so the + /// UI and pending-apply state start from the device's actual configuration. + SensorConfigurationValue _initialSensorConfigurationValue( + SensorConfiguration config, + ) { + final dynamic configDynamic = config; + try { + final currentValue = configDynamic.currentValue; + if (currentValue is SensorConfigurationValue) { + return currentValue; + } + } catch (_) { + // Fall back to the first advertised value below. + } + return config.values.first; + } + /// Attempts to pair a stereo device with a matching partner among the /// already-known wearables. Runs asynchronously and logs results. /// Non-blocking for the caller. From 0823d618e79b6c10a70940d54b979182679528d4 Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:40:04 +0200 Subject: [PATCH 39/51] chore(ios): refresh pod lock dependencies --- open_wearable/ios/Podfile.lock | 7 ------- 1 file changed, 7 deletions(-) diff --git a/open_wearable/ios/Podfile.lock b/open_wearable/ios/Podfile.lock index 4d00798f..1e2cdfa2 100644 --- a/open_wearable/ios/Podfile.lock +++ b/open_wearable/ios/Podfile.lock @@ -55,9 +55,6 @@ PODS: - Flutter - package_info_plus (0.4.5): - Flutter - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - permission_handler_apple (9.3.0): - Flutter - SDWebImage (5.21.7): @@ -92,7 +89,6 @@ DEPENDENCIES: - mcumgr_flutter (from `.symlinks/plugins/mcumgr_flutter/ios`) - open_file_ios (from `.symlinks/plugins/open_file_ios/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - sensors_plus (from `.symlinks/plugins/sensors_plus/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) @@ -131,8 +127,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/open_file_ios/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" - path_provider_foundation: - :path: ".symlinks/plugins/path_provider_foundation/darwin" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" sensors_plus: @@ -161,7 +155,6 @@ SPEC CHECKSUMS: mcumgr_flutter: 969e99cc15e9fe658242669ce1075bf4612aef8a open_file_ios: 46184d802ee7959203f6392abcfa0dd49fdb5be0 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 - path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf sensors_plus: 6a11ed0c2e1d0bd0b20b4029d3bad27d96e0c65b From 09296a5db4d5b591eb9f917509958c37dd2ee5f6 Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:46:31 +0200 Subject: [PATCH 40/51] refactor(this_device_wearable): refactor sensor registration and enhance documentation --- .../lib/models/this_device_wearable.dart | 213 ++++++++++-------- 1 file changed, 120 insertions(+), 93 deletions(-) diff --git a/open_wearable/lib/models/this_device_wearable.dart b/open_wearable/lib/models/this_device_wearable.dart index 49abb953..769d8e70 100644 --- a/open_wearable/lib/models/this_device_wearable.dart +++ b/open_wearable/lib/models/this_device_wearable.dart @@ -9,6 +9,8 @@ import 'package:sensors_plus/sensors_plus.dart'; import 'logger.dart'; +/// Represents the phone, tablet, desktop, or browser running the app as a +/// wearable-like device with locally available sensors. class ThisDeviceWearable extends Wearable implements SensorManager, @@ -31,12 +33,15 @@ class ThisDeviceWearable extends Wearable final WearableDisconnectNotifier _disconnectNotifier; + /// Creates a host-device wearable from an already resolved device profile. ThisDeviceWearable._({ required super.disconnectNotifier, required this.deviceProfile, }) : _disconnectNotifier = disconnectNotifier, super(name: deviceProfile.displayName); + /// Builds the host-device wearable and registers every sensor that produces + /// at least one sample on the current platform. static Future create({ required WearableDisconnectNotifier disconnectNotifier, }) async { @@ -74,102 +79,109 @@ class ThisDeviceWearable extends Wearable } Future _initSensors() async { - if (await _isSensorAvailable(gyroscopeEventStream())) { - final gyroConfig = DeviceSensorConfiguration( - name: 'Gyroscope', - onChange: _emitSensorConfigurationChange, - ); - sensorConfigurations.add(gyroConfig); - _emitSensorConfigurationChange(gyroConfig, gyroConfig.currentValue); - sensors.add( - ThisDeviceSensor( - config: gyroConfig, - sensorName: 'Gyroscope', - chartTitle: 'Gyroscope', - shortChartTitle: 'Gyro', - axisNames: ['X', 'Y', 'Z'], - axisUnits: ['rad/s', 'rad/s', 'rad/s'], - valueExtractor: (event) => SensorDoubleValue( - values: [event.x, event.y, event.z], - timestamp: event.timestamp.millisecondsSinceEpoch, - ), - sensorStreamProvider: gyroscopeEventStream, - ), - ); - } - if (await _isSensorAvailable( - accelerometerEventStream(), - )) { - final accelConfig = DeviceSensorConfiguration( - name: 'Accelerometer', - onChange: _emitSensorConfigurationChange, - ); - sensorConfigurations.add(accelConfig); - _emitSensorConfigurationChange(accelConfig, accelConfig.currentValue); - sensors.add( - ThisDeviceSensor( - config: accelConfig, - sensorName: 'Accelerometer', - chartTitle: 'Accelerometer', - shortChartTitle: 'Accel', - axisNames: ['X', 'Y', 'Z'], - axisUnits: ['m/s²', 'm/s²', 'm/s²'], - valueExtractor: (event) => SensorDoubleValue( - values: [event.x, event.y, event.z], - timestamp: event.timestamp.millisecondsSinceEpoch, - ), - sensorStreamProvider: accelerometerEventStream, - ), - ); - } - if (await _isSensorAvailable(magnetometerEventStream())) { - final magConfig = DeviceSensorConfiguration( - name: 'Magnetometer', - onChange: _emitSensorConfigurationChange, - ); - sensorConfigurations.add(magConfig); - _emitSensorConfigurationChange(magConfig, magConfig.currentValue); - sensors.add( - ThisDeviceSensor( - config: magConfig, - sensorName: 'Magnetometer', - chartTitle: 'Magnetometer', - shortChartTitle: 'Mag', - axisNames: ['X', 'Y', 'Z'], - axisUnits: ['µT', 'µT', 'µT'], - valueExtractor: (event) => SensorDoubleValue( - values: [event.x, event.y, event.z], - timestamp: event.timestamp.millisecondsSinceEpoch, - ), - sensorStreamProvider: magnetometerEventStream, - ), - ); - } - if (await _isSensorAvailable(barometerEventStream())) { - final baroConfig = DeviceSensorConfiguration( - name: 'Barometer', - onChange: _emitSensorConfigurationChange, - ); - sensorConfigurations.add(baroConfig); - _emitSensorConfigurationChange(baroConfig, baroConfig.currentValue); - sensors.add( - ThisDeviceSensor( - config: baroConfig, - sensorName: 'Barometer', - chartTitle: 'Barometer', - shortChartTitle: 'Baro', - axisNames: ['Pressure'], - axisUnits: ['hPa'], - valueExtractor: (event) => SensorDoubleValue( - values: [event.pressure], - timestamp: event.timestamp.millisecondsSinceEpoch, - ), - sensorStreamProvider: barometerEventStream, - ), - ); + await _registerSensorIfAvailable( + sensorName: 'Gyroscope', + chartTitle: 'Gyroscope', + shortChartTitle: 'Gyro', + axisNames: ['X', 'Y', 'Z'], + axisUnits: ['rad/s', 'rad/s', 'rad/s'], + valueExtractor: (event) => SensorDoubleValue( + values: [event.x, event.y, event.z], + timestamp: event.timestamp.millisecondsSinceEpoch, + ), + sensorStreamProvider: gyroscopeEventStream, + ); + await _registerSensorIfAvailable( + sensorName: 'Accelerometer', + chartTitle: 'Accelerometer', + shortChartTitle: 'Accel', + axisNames: ['X', 'Y', 'Z'], + axisUnits: ['m/s²', 'm/s²', 'm/s²'], + valueExtractor: (event) => SensorDoubleValue( + values: [event.x, event.y, event.z], + timestamp: event.timestamp.millisecondsSinceEpoch, + ), + sensorStreamProvider: accelerometerEventStream, + ); + await _registerSensorIfAvailable( + sensorName: 'User Accelerometer', + chartTitle: 'User Accelerometer', + shortChartTitle: 'User Accel', + axisNames: ['X', 'Y', 'Z'], + axisUnits: ['m/s²', 'm/s²', 'm/s²'], + valueExtractor: (event) => SensorDoubleValue( + values: [event.x, event.y, event.z], + timestamp: event.timestamp.millisecondsSinceEpoch, + ), + sensorStreamProvider: userAccelerometerEventStream, + ); + await _registerSensorIfAvailable( + sensorName: 'Magnetometer', + chartTitle: 'Magnetometer', + shortChartTitle: 'Mag', + axisNames: ['X', 'Y', 'Z'], + axisUnits: ['µT', 'µT', 'µT'], + valueExtractor: (event) => SensorDoubleValue( + values: [event.x, event.y, event.z], + timestamp: event.timestamp.millisecondsSinceEpoch, + ), + sensorStreamProvider: magnetometerEventStream, + ); + await _registerSensorIfAvailable( + sensorName: 'Barometer', + chartTitle: 'Barometer', + shortChartTitle: 'Baro', + axisNames: ['Pressure'], + axisUnits: ['hPa'], + valueExtractor: (event) => SensorDoubleValue( + values: [event.pressure], + timestamp: event.timestamp.millisecondsSinceEpoch, + ), + sensorStreamProvider: barometerEventStream, + ); + } + + Future _registerSensorIfAvailable({ + required String sensorName, + required String chartTitle, + required String shortChartTitle, + required List axisNames, + required List axisUnits, + required SensorDoubleValue Function(SensorEvent event) valueExtractor, + required Stream Function({required Duration samplingPeriod}) + sensorStreamProvider, + }) async { + final availabilityProbe = sensorStreamProvider( + samplingPeriod: SensorInterval.normalInterval, + ); + if (!await _isSensorAvailable(availabilityProbe)) { + logger.w("Sensor '$sensorName' is not available on this device."); + return; } + + final config = DeviceSensorConfiguration( + name: sensorName, + onChange: _emitSensorConfigurationChange, + ); + sensorConfigurations.add(config); + _emitSensorConfigurationChange(config, config.currentValue); + sensors.add( + ThisDeviceSensor( + config: config, + sensorName: sensorName, + chartTitle: chartTitle, + shortChartTitle: shortChartTitle, + axisNames: axisNames, + axisUnits: axisUnits, + valueExtractor: valueExtractor, + sensorStreamProvider: sensorStreamProvider, + ), + ); } + /// Returns whether the platform emitted a sample before the availability + /// timeout. Missing hardware, unsupported platforms, and permission failures + /// are treated as unavailable so the app does not show dead sensors. static Future _isSensorAvailable(Stream stream) async { try { await stream.first.timeout(const Duration(milliseconds: 800)); @@ -208,6 +220,7 @@ class ThisDeviceWearable extends Wearable VersionConstraint get supportedFirmwareRange => VersionConstraint.any; } +/// Static metadata for the device running the app. class DeviceProfile { final String displayName; final String deviceId; @@ -216,6 +229,7 @@ class DeviceProfile { final String? osVersion; final String? platform; + /// Creates a host device metadata snapshot. const DeviceProfile({ required this.displayName, required this.deviceId, @@ -225,6 +239,8 @@ class DeviceProfile { this.platform, }); + /// Reads platform-specific device information and falls back to a generic + /// profile when a platform does not expose one. static Future fetch() async { final deviceInfo = DeviceInfoPlugin(); try { @@ -410,6 +426,7 @@ String? _joinNonEmpty(List parts) { return cleaned.join(' '); } +/// Adapts a `sensors_plus` event stream to the OpenEarable sensor interface. class ThisDeviceSensor extends Sensor { final DeviceSensorConfiguration config; late final StreamController _controller; @@ -418,6 +435,7 @@ class ThisDeviceSensor extends Sensor { _sensorStreamProvider; final SensorDoubleValue Function(SensorEvent event) _valueExtractor; + /// Creates a sensor adapter for a single host-device sensor stream. ThisDeviceSensor({ required super.sensorName, required super.chartTitle, @@ -486,6 +504,7 @@ class ThisDeviceSensor extends Sensor { } } +/// Frequency configuration shared by host-device sensor streams. class DeviceSensorConfiguration extends SensorFrequencyConfiguration { final void Function( @@ -495,6 +514,7 @@ class DeviceSensorConfiguration DeviceSensorFrequencyValue _currentValue; + /// Creates a frequency configuration with the standard host-device values. DeviceSensorConfiguration({ required super.name, required this.onChange, @@ -506,6 +526,7 @@ class DeviceSensorConfiguration DeviceSensorFrequencyValue get currentValue => _currentValue; + /// Emits every frequency value applied to this host-device sensor. Stream get changes => _changesController.stream; final StreamController _changesController = @@ -519,6 +540,7 @@ class DeviceSensorConfiguration } } +/// Sampling frequency option for host-device sensors. class DeviceSensorFrequencyValue extends SensorFrequencyConfigurationValue { DeviceSensorFrequencyValue({ required super.frequencyHz, @@ -527,8 +549,10 @@ class DeviceSensorFrequencyValue extends SensorFrequencyConfigurationValue { key: key ?? _formatKey(frequencyHz), ); + /// Whether this value disables sampling for the sensor. bool get isOff => frequencyHz <= 0; + /// Creates the disabled sampling option. static DeviceSensorFrequencyValue off() { return DeviceSensorFrequencyValue( frequencyHz: 0, @@ -536,12 +560,14 @@ class DeviceSensorFrequencyValue extends SensorFrequencyConfigurationValue { ); } + /// Creates the default interactive sampling option. static DeviceSensorFrequencyValue normal() { return DeviceSensorFrequencyValue( frequencyHz: 5, ); } + /// Returns the standard frequency choices shown for host-device sensors. static List defaults() { return [ off(), @@ -558,6 +584,7 @@ class DeviceSensorFrequencyValue extends SensorFrequencyConfigurationValue { ]; } + /// Creates a sampling option for the provided frequency. static DeviceSensorFrequencyValue fromHz(double frequencyHz) { return DeviceSensorFrequencyValue( frequencyHz: frequencyHz, From 978b0fa691302aeb5bc07967521ae48c60d7dd37 Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:38:53 +0200 Subject: [PATCH 41/51] feat(devices): show phone icon for host device --- .../lib/apps/widgets/select_earable_view.dart | 7 +--- .../device_detail/device_detail_page.dart | 2 +- .../lib/widgets/devices/devices_page.dart | 3 +- .../lib/widgets/devices/wearable_icon.dart | 38 +++++++++++++++++++ 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/open_wearable/lib/apps/widgets/select_earable_view.dart b/open_wearable/lib/apps/widgets/select_earable_view.dart index cf7649c5..cc367ccd 100644 --- a/open_wearable/lib/apps/widgets/select_earable_view.dart +++ b/open_wearable/lib/apps/widgets/select_earable_view.dart @@ -432,12 +432,7 @@ class _SelectableWearableCard extends StatelessWidget { } bool _hasWearableIcon(WearableIconVariant initialVariant) { - final variantPath = wearable.getWearableIconPath(variant: initialVariant); - if (variantPath != null && variantPath.isNotEmpty) { - return true; - } - final fallbackPath = wearable.getWearableIconPath(); - return fallbackPath != null && fallbackPath.isNotEmpty; + return WearableIcon.hasIcon(wearable, variant: initialVariant); } List _buildDeviceStatusPills() { diff --git a/open_wearable/lib/widgets/devices/device_detail/device_detail_page.dart b/open_wearable/lib/widgets/devices/device_detail/device_detail_page.dart index d3306d12..8197a289 100644 --- a/open_wearable/lib/widgets/devices/device_detail/device_detail_page.dart +++ b/open_wearable/lib/widgets/devices/device_detail/device_detail_page.dart @@ -308,7 +308,7 @@ class _DeviceDetailPageState extends State { Widget _buildHeaderCard(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final hasWearableIcon = widget.device.getWearableIconPath() != null; + final hasWearableIcon = WearableIcon.hasIcon(widget.device); final statusPills = buildDeviceStatusPills( wearable: widget.device, diff --git a/open_wearable/lib/widgets/devices/devices_page.dart b/open_wearable/lib/widgets/devices/devices_page.dart index 38e29096..2f7c6986 100644 --- a/open_wearable/lib/widgets/devices/devices_page.dart +++ b/open_wearable/lib/widgets/devices/devices_page.dart @@ -267,8 +267,7 @@ class DeviceRow extends StatelessWidget { final pairKey = group.stereoPairKey; final knownIconVariant = _resolveWearableIconVariant(); final hasWearableIcon = showWearableIcon && - (primary.getWearableIconPath(variant: knownIconVariant)?.isNotEmpty ?? - false); + WearableIcon.hasIcon(primary, variant: knownIconVariant); final topRightIdentifierLabel = _buildTopRightIdentifierLabel(); final statusPills = _buildDeviceStatusPills( primary, diff --git a/open_wearable/lib/widgets/devices/wearable_icon.dart b/open_wearable/lib/widgets/devices/wearable_icon.dart index 6a11d974..b657d330 100644 --- a/open_wearable/lib/widgets/devices/wearable_icon.dart +++ b/open_wearable/lib/widgets/devices/wearable_icon.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/models/this_device_wearable.dart'; /// Reusable wearable icon renderer with optional stereo-side resolution. class WearableIcon extends StatefulWidget { @@ -23,6 +24,29 @@ class WearableIcon extends StatefulWidget { this.fallback, }); + /// Returns whether [wearable] can render either a custom asset or a built-in + /// Flutter icon through this widget. + static bool hasIcon( + Wearable wearable, { + WearableIconVariant variant = WearableIconVariant.single, + }) { + if (wearable is ThisDeviceWearable) { + return true; + } + + final variantPath = wearable.getWearableIconPath(variant: variant); + if (variantPath != null && variantPath.isNotEmpty) { + return true; + } + + if (variant != WearableIconVariant.single) { + final fallbackPath = wearable.getWearableIconPath(); + return fallbackPath != null && fallbackPath.isNotEmpty; + } + + return false; + } + @override State createState() => _WearableIconState(); } @@ -91,7 +115,21 @@ class _WearableIconState extends State { return widget.fallback ?? const SizedBox.shrink(); } + Widget _buildStandardIcon() { + return FittedBox( + fit: widget.fit, + child: Icon( + Icons.smartphone, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); + } + Widget _buildIcon(WearableIconVariant variant) { + if (widget.wearable is ThisDeviceWearable) { + return _buildStandardIcon(); + } + final path = _resolveIconPath(variant); if (path == null) { return _buildFallback(); From 8a95f9b634ac17194d4bf656cfab1907988c1f29 Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:56:12 +0200 Subject: [PATCH 42/51] refactor(this_device_wearable): reorder gyroscope sensor registration for consistency --- .../lib/models/this_device_wearable.dart | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/open_wearable/lib/models/this_device_wearable.dart b/open_wearable/lib/models/this_device_wearable.dart index 769d8e70..566c4308 100644 --- a/open_wearable/lib/models/this_device_wearable.dart +++ b/open_wearable/lib/models/this_device_wearable.dart @@ -79,18 +79,6 @@ class ThisDeviceWearable extends Wearable } Future _initSensors() async { - await _registerSensorIfAvailable( - sensorName: 'Gyroscope', - chartTitle: 'Gyroscope', - shortChartTitle: 'Gyro', - axisNames: ['X', 'Y', 'Z'], - axisUnits: ['rad/s', 'rad/s', 'rad/s'], - valueExtractor: (event) => SensorDoubleValue( - values: [event.x, event.y, event.z], - timestamp: event.timestamp.millisecondsSinceEpoch, - ), - sensorStreamProvider: gyroscopeEventStream, - ); await _registerSensorIfAvailable( sensorName: 'Accelerometer', chartTitle: 'Accelerometer', @@ -115,6 +103,18 @@ class ThisDeviceWearable extends Wearable ), sensorStreamProvider: userAccelerometerEventStream, ); + await _registerSensorIfAvailable( + sensorName: 'Gyroscope', + chartTitle: 'Gyroscope', + shortChartTitle: 'Gyro', + axisNames: ['X', 'Y', 'Z'], + axisUnits: ['rad/s', 'rad/s', 'rad/s'], + valueExtractor: (event) => SensorDoubleValue( + values: [event.x, event.y, event.z], + timestamp: event.timestamp.millisecondsSinceEpoch, + ), + sensorStreamProvider: gyroscopeEventStream, + ); await _registerSensorIfAvailable( sensorName: 'Magnetometer', chartTitle: 'Magnetometer', From 442fec1b8f7bafad721efa0933af33605b7f258c Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:44:45 +0200 Subject: [PATCH 43/51] feat(devices): use phone image for host device --- .../lib/assets/devices/phone-app.png | Bin 0 -> 284315 bytes .../lib/models/this_device_wearable.dart | 2 +- .../lib/widgets/devices/wearable_icon.dart | 22 +----------------- open_wearable/pubspec.yaml | 1 + 4 files changed, 3 insertions(+), 22 deletions(-) create mode 100644 open_wearable/lib/assets/devices/phone-app.png diff --git a/open_wearable/lib/assets/devices/phone-app.png b/open_wearable/lib/assets/devices/phone-app.png new file mode 100644 index 0000000000000000000000000000000000000000..5a2d203726f536331c283b856e88018887696b82 GIT binary patch literal 284315 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 `KyymFMVi7P^IOK7%#5KoFyO1ba}+aTKQbT%9Y% z_X+*BU)n5(E*T%2>H^>e<(1uv;%+e@~>N+gz9OTGU*wZ$!x4JL5p6k9# zgSJIj_q4aOzxF(x0a#sZQl6f_vg)v-{)2)3aCwrxFX_asI*8dVPCN(=UT1C8v zK@KLImS9cE?6pFvJFiAR`7}pWa^l&zF?SDKP6sgEgeY0T-`DN8o@v7*R7Ahy4~%j~ zV`rm4eVqD51Pjc}E7wdz|Dd7&`MW7c#1F|{2DP^xPvbOo^z~u?HlsfVd`uX+t$_!6 zn*Py)->u%#E(V0r#IkLFezqkGceEWC%lcYsXJi}E5z7U!^;eR6oS$*f+v5wnxL6xc z_I)M)G~EBDNtf;ayjj!35IsSn9f`ss&^jAk_ zgPG(iTwi%x2?eS=&O51w9~FVBxw-627{oKGO<$Za65dfgEIkrO{xqI}Cvi1BZ{-0nkP?Fq z8u|N{zLw zSvXFl5G5{yZHw0hk5vi~EAU^r1hp}KB3sIoMYs~8tZ$^29KEc?y!0|0x2qz@qn_@f zvb13RZ|zJcSGRXcAmmxEmzhW~__$w&zCCTCQ@Hg-NcQV{;vL1`aSX?SvFERIK?_ZF zmYNW86sT3)*ixB7KT3&a!^SarWOKqW0xKe|x}+|~P{GGhKY#>jCB8ubz8E}e@>e)R zlv3^DpQ>tI^G*f_*N*o~`hD|?!)Tj_(7J4n3PJ|n&Z**bAY}sT)L)%mg(geWnKc8*pw4XBSZa>B(-TI{u=!gx9 z%;QkCtofF*HNw3ihirVa%!b5vGUWV=@JoouINE2&AAB$W&pH;N0Z(`m>Cq zpR3ZjO#|%~fTxmESq7eq@inDJfwb6(=#3|}lCQ@0byJd>&$eR-o9utuuuU8^5BJ}G zEojQ)#m@yetUE4y8{IB@jkb)NwD90MzfE|AM>-jyM6%}Rz$Jng6GNGpYFxu~ZYG%e z!~7za7J55kESpF@*_*T>YZ**RQU!D1c(IFKSyA?51~5?V?!gB9WZ3Z2<$EA#SS1=g zt&P-os^^>TTg&8uyx+`(fHEd-Sl0d|wPJ%>MUqu2SO&>R@b5xUSQhsJY8=+2@wpwvy`lDiA)@7W>SbM$QQ?n`$Xe2 z2N~du78zXEYlKSe2XLG4RUi`=bBr4;O%`*!?u)&UEkLdxsa$C*j-ZUiz%sis-0SB? z89pJr3Uwam%gQ7Lc-71wCM%QqIkvA?pht>neKaud_tDUCFN!dLLA-tGG3Zp|)M5+e z>Ci0s;*8~X0)(?idb~&Ysa`|$6qz@9IUZIhU_yQ05ojHCFOw6Ocx&R(r2zSwlzrn6 zsOKZ_1+QP+uR8`IoCrZjo6Bk8`7esZ=@Iw-W$tysmcxKQ6nfI}>Zl-jF;1-=IRTyb zCFP4|_v_|>Bnj_Sp7TLkVlCSl4uqQTfNr|HMp;^_4?!+*#q^uxnJEkSnX@x>?trES za4yl<$y$6O$ z{56vc`Pq+{ESg!go-;N%SnSg8I@blN)OO^jfGLhgw{-h;{!M}vfW>omvTHa|_-Kyk zg`%!3{a!F!!?qfQ8-V;*y{VT8i5qFqYsymmGP!SAcI)vYb!(gJ))eR@dTT1L*d1Q| zgG4D_*mPH2FB&jhxUxA-AzsgOwipeh)Qhz@DL{66&63~}*cf$)NKSGoaxH=3=?;Ba zo2je1Q_bc?BvKFvOY!giHvizq4@|81B!RrBN>p_eS6zdwoewIZ;qVS)@e+(()Pg@n zy^VpYHm`;T5D>LCo}2|><&N;Z-Sm?6buCelSci2i=tDcX8xB>hv^jg3-+`eQXpjs4 zGf7XyZU;K3%njQ;wE|m5Ryy>VW{p zrA(MF)fBMNdqnE}2i(Q-twu@ftihu>AzUzcz2*`S{nOEl=z(xxf2G|;^TQ&I&JD&U zJhm*Yp;o$>Go5`pi`v5zR>#M790Q~`KWTxeo{^ItrAEYt+u}d2X_p8NEwLbMT(thV zW1N9OvKjfmRvmhEoGDRMygyS}UJkg4-kPE4OBMT{7oZ)RTROwtQfAq`QnOc$Z4$qq z3kX%hx}4boAMj@8Y!XicqHl)df{kr!V7`$F0TBDkuhb4#W_DccaVTF1b|AtU_ z70c-sC>m_nO8U1|!PXvKmsffo!yeQ%*z4np?=08FmiBGdIA5SfuENWvW=G9i*{?hc z$bI(eN%r{Gip%N$TiXpKpf7$5ge-Fz=xpCNRIB2T*(Qy?x}gX!2DxHvbl4Zd-|a3) zMgeWIET%wdn4prP5@2Qv@X(}WgX6Pq(w3rj!@mB4^g#VTy51VMZKpT1k$9@8kJ%A- zSc9`XUD%YPt;7YMVL-)bAD1bgba+vW#Mye!A|xB~7FoO8zk(&H{b;6KmXZVQGxCA0 zE7}WKeA>IHPNdYNm!Tb<@-ts(o)fH#j5spVPUrp*5;k*Ix}5RCWWH~Ol7}ape4Ra> zEqeL_99fHTn_ICWA=SM3EC|o_`K4&4s%$m?7Ah2!Cfam!vHmkPT@^T~c3|?UbbbXW zRSqxcb!=Ns7|@vSw)&+aZ*p=1YspzT8j8oIfnl(YW{CZ#c zrlPX-*FxzrMDbrlgHGm#xfqal!7K%!Cahmp>uMFrlu9&R_`OU8i7%-m183|35Q-uN zMRio)<#YPd6pZQ;Kw(N!yS2K|CYU6js>xRzf50mS!w4lbE~=%DK%oNd!$22F9XSBW zK0%-CX`sP>v21N^l_qB`J;Cw)|Al2KMgkZR%5mQ}tTz~ZcABwPZ3f8oU__zL@?m9a zx#J|ixi%KJ02$CWEEcQEocCl>bWg1dCo|4tgTIg!wAa%EN2Rb|CYIYg7=<>l zlA=?lXt^jYLG2)9+k_bB$Y@Yxic`dL`C|Lp1`K9ibQvH80)^J#<5YpDB)t9E-L5a0 zpRlqti(qxW8bD^2UOo0h)SaQDe>++-?tO=(OS@TGY5VG+!rE$5kb8)c)cldvwBNCz z*kjusldrrGAQV61!tRbrD++f0eO1*jWvU2L>7!t*4M3yZc@<~nrW2jr0<_M{@o0lI z90zK9!r(037ria`5RRHOoL4k{<|;s>~ryj{55GN`H23Dnlzr%bb_tG_(@lw48f<{iI%T(ZLjRx`$r~ z@YzpjXayww?G&9lWjIL|aQolw#<;=~?dzE&fUQa7e=;YChMb+(M1Pi|uWZvVsY@QFn&^3g(|Sn+!+TB zwpzVry{He#Pz^ALCTNVd^({mocjk=L!DZ`J?uO{`jwKnR27LK*SF6hN7wR7Z$olY* zUWgFYfF{VEg1(E7g|1!{*n;`LtOYrkZCezXC5rfImRfE9rnJ9&oY^u~Z{rKd{p;vq zQ(;T)SrFcYSZBh&g5*yXSa;`WkmS!tRY6B=L2|6qth15|L2iH}AiP8e4YPIS6~}k| zrn%EkjC1813RDhJlz${df9#jD!iQH@Y3Wxdycn4B69I>qD z7txSbl52FOkF;o$SR#a1+%RexPwgo>ix zfPVj-?o?mHTB=}tPSq+!wUdJd!^Nb;3lqOX>yCx1dNSw%3ir9BCD9uFzLhN7r)d<9 z!hc%+Q^uS|*0)A@WF$(pEl<}>WXI+uqEQ z!RcDk${=jK;9TgoGaJ%dVYtcG7605JVO232V{UTGB1#${>6~-}1IuRNx8satY;zP0 zW@mIBwl3DtlU-to9`t6m)&Z_zZEGBHc;OFH9-+l5watuMm^BWT(4hqdAW63oMDtak z(q;4in6w{cDOO-I&t~bS%P%{KSeHmi

zJrO94aZqg>thue?dmw8(NG^$-m6vkX~ z86u>KMqzjGK~P!>Re$X(lX+!4`7$q%%R-1`dSFT#szLVUAN+ii_Z z(ge*yd?jU_U+C7IpCoM|w(dg2*f_oS@_uOqG}w40;n$))mn4w#Rh11VJ|k8hYr-n+ z2S;+r+&==5a4z^loRiGJ14cM+OA6gT6_EL`3b-;45*{Ac2_*$S&&;Us94&8!EgG@s zssVZ7GA3>t z;150@mcbHfO$a3HE+^l7Nuj+#dnBD*G*&U!})38YF1# zoeU&B2k+PYr)XR&=eY$E$A&~)(?SML8P*d1A}o33(nSL(={sU2#JPiDK=^ONM|qn# zDx0p8EZapnz?TlK@zERe@%WH2VY#&^nG$x$?JLEX^|hmM{%**@eWmgVurCrmv3fTl z(4`_n-~=n$(3jJ7h62=`cwhPKXL191j~(ozJ9XK52^m(9hI*CrG1tGZBI*(DS6(}V z0iG=Xa{fiGUt`8~R;}%EXJ(qJGnzGK6NhiPA$6vp(G*vbM+1^zCf%0 z(MxqH^B=3STQ$Fo{{V;<%3igT%sZv6`Th}IBK${FC}c>UjAsRIxXtv}qVovNcCLo> z;7*7{X1#{SiXMaJNzAyfPZ_88YM}i?VC*tdFI-2HQf5hgGgZ+0_@cxwNw_P0A+RKy zORwQ03uGia3Vc{eXfw?Hb6W~%LU&PpG>+VO@WfisI36&mai)3EdoefpMu{f7Dm!A%xk>C=80YA4wV zJ_Y%bM4YM^+s5|x_T3*&N}!DU>6KTP!fhrhiBlJ=@uDTvHw&4`7E#z>Nlble(3^cb zkr6kF&yUe){Ji(9WpdpLp0C(#OZ~G(bG1+)gb_MCb79T{W|-D&y`Pm)b41RC#?Hs( zpzZ-jv+^-i%YRvoHWG$5)W5Q|+-YjB&1!)s=Ap2gF8;E=X4TT@AyK^b*aH*FSUX;~ z>Y#X%zX$XC6vN^D)-h4=o6+KqY|lDINj1q*H-`3}jkIx=Tx<^`vZIYApO?%(hB5E}2VxO)UYo7TefjEVG)#0no z6eR|dK^MFIWZ+znAGSK3%!zKmw3q**YQ(KxGegN51utd#S4x&4STj(rfz3QxhNB*zU#r5*k zbQI`iZnAtxD3x3VUq3Zl`y05mT1N9w)RRA!dD-K%abxb}3J;_Nd80mbDsooMlld}y zg^s;c=%gIIub7n(|S!Rf9u^ zUb%BTBczS0YPQw^vMcNor@exoPKHS!wLAHzF&xy&_cdHnY zXBQdUjG>8tev4Vxl_uc+!d%60ve8(2n0=M~jj}6-B^-i2TWmM-gOkuyh-$Wf)L7s2 zU?jd`+e=|5c>fz;J(WBX`!(bp4Xo;1i7h9gCB;CkE~LbdKke3ozbA$v#B{sHshdgMV!b*S9j z$5GuidS5lakAUA>|LS~Tmn@4ahkW) zhG_Cr*9@@(9Ye*M_BhkObYrO|{O=M}q!E$Te_ek5S1}-1 z&mv2o;8H;;HxAq$g;I3%Rjhf?@*rtZitleb9AzZ`7pTg7)q<`jP*lq%%Y=({x&DvEW16wZ+&>>sB{KLp;`cEIww;Wf7gi_x}ScpZuS zEeZBN$<$>-AXQt6@@kCARvAfMiH7-V1X>LIm4`fkvC#K@g>pGvzfYv;x96p>_S(mN zjJLT#d$(+52D{fi24C?jg&s4ztGhOWZlx%BxADw8$@M`D zJyzKI`Eed91Z->n5h?vyKgKD9V-6#>WH>%1#r56JNN58zj6XmBGq>HAR1d*pN%{-M z#pw!aj<`WX)qHK=PG->k>MLns^pATX$!x9lh?vdv-{LAA!;z&B>q)or zzj@YwnXbgPK7Z6(_&~}`7~pH^RK%khoSX>zP;HJn*QTaCH=~PUI;U7)x=#<~VS(=8t@$w!? zaBsGPxzbDyxTJw?mW%GxGh(F@{SrRUv&Y?v}H zkI#q^ZNXx(S$DB7Y6={bCF2;hNW7Cb5N87?-NAY+YS$i)%YSDPV$%d)QK^35jnSFA zkH;MBp8JteHQr`;>U`%XpM|!@ppA82ji9MR*vPq5B0lc>By8@?Lv?MOj#L*mecpfC z1o<5s-vwMpeW$JH6zvO?T%ku$jH4&w3_WCXsVQq2*>C`6|)F%%C9oow~P*0WCi#fRCSwaqQ z{121-?2p2Gbq$cV_lo#mIRGe;-Pu+!NZ^;TW-Mu_w&2fXa?X}8{f({6ffy2g?`GTP zXsgYTs^yjUzFH*F$oWVwv?t^JaR=!mRd?f#hQwDWCjyb?o<`f<-a=Wsc8Au*rmmFx z>X=>(pE`u1RwtX94?W9{LYRyRWwIW@;6sY)Z0u)p1=v_|Scfq;g;6PAduLXLz}Seb zGYMs2r-cYB4*uRS;>S`l+|Zde8?NT4Y;~}FhY>Plqhl*WB9u4>UyZ@Q!`*0G-$>wB zd~Y;1%QWL?f4UBPa%B64=<&u<$vPx@oRdjI6k$DzOkS?T$8o7rl?4o4mngnd_W@{8dybEQc;bn26ZRIOtD z!gW5ia>!3cVpGH0GJ>L^ZAKGbZ_gtPqbt7ME_E7QTun!4-*TF?I5MzK0nUjRabS7& z#jDR^fF;@pKR^nN(pid47V8xGi;#-AtYLnI4of}Gu$ykkZZ6eP7uboEl;7-J)Bu_& z6XJSc!wea#E1t0TXo|2W^SQaKT6?|&>-P`iFk*YSs_Wc$cq$tH%`fim$lZ-$R>WdC#Vb)a1v?EZ^boc+2r^!kDW&9>bvr0tS8oGt%4k z?UrDKC3d5T47O72@rt9@^M`m}cQ%2Y9?Gk%f!xcK`iNHf5hSQM9eCudTB87p$TY#C zM(DDd&^Me;-}@}Zfi1$Zp0?k!_s`wH&O{li(*ZwsJZrfvS4{P^!RPAT%_ZX#4N!2zq4jwVPoQ$}tf|NlWL?5x0)W1(FZ6N2=NE7xr zg?8}2^vt$A9CHTU>_fb9yY9Lkt6og?W@G>TzUuFaN@3I^^g1-MM^0h|uxf1O>G<<8 z6OKIZN7VI7Rlkh$0=?^Yf}ia)42N3B*bRe3)|E$Dr}}MS1YIJS3T^KS9{w&w-;ckW zy&*RMfvi0sdewI_9jk|^q`ExYaQcnr*It<`XPq);Gn7r9QVydUhI#D1@D-@r>N<+b+b&pCZD;R*79;=KW!!sXzQjVz<-yh~#IjEo@glpqjvATcY^$O&20)9ZxN>x8K^ zx^L4#8SBn%yx;OU_qokJm46OuhnJW8+dN$ zdlEpr4;Fu1+%OY%Tz<3#Us97J_4Vgd#H7?Qx9-6CT<(VZPwrpg-9leA=BO=?1TDe=Oc`Wa z4%hFq)FgGzqU(q|ol4OmG%A?PoUXZ@EGSjAh zBG1dRn@j!ME$nsFL~!;Us}J1s!jAXHuCso|^%U<@98+r@ef{x?IG7)|>#$i~CvUO@Jc4n?vl*#s_6g8QU!yoM~^+GNmB#UM)_Hcf8sJGs)UD0__I;Ub>kfY+7h;Gt zpu)cRIajGiL#|jt5rjfYUAiw0C|H0(f%l?ZIit(Q8OAlC)|nO3fY^|-#bAua#0p^R z4B@mgn5Msy9|_iVcQhU@vLx}@e^ci+ZxtG$^sWs$VoQ=AKv~!4^@;YmIOvM5dYDOx zb>wgE*p967+>`O8dAgB*Qfc^<`CD&3L*iSTuAQSm6KBlVKD#E{6WXfv`_;QJ#(%7< z*IrP*K4)8u8yw!3>beimfOkCL`GWD0-1FH~0I_8K`QGOl_4!EuX?P;z+LJ+RYEXvwsyR#^RAm}dXYSm^304n=>e!C2rfH51VnugtJ& zf!QPj;=lig`_x_a!1A=G?tM*VZ?l6SBePZ(2%Zc!Yp1lf2V()XP>jy8EO++z!?5P ztrt8V%(ja6;HG`UA3A~Bk?$X&!^VX^Foxk_(ze3K#)n3_Ek)*|Xl?6@sooBv|G(ck zib@#koP{Eurn(~7n!W}O4=H6*tv2%5IRD#d`6%?C_f=BMPF`tL_&+1?i&KO{102XO ziSCy@=sH$`V`i(pJVZ}S!0n_0AywX6!x1$*_cv!LPqqaHzhbXw~YxiwXHDsp`X;*@uP}xk&GQTJ-?^{o|7JUH`eMx5$W2*}*9twRQ@=je}3i zEStY0+{h(gmJBCP)Z*0+L4TIDDXRwa?b3yV`Dnt#RmJ1Ew%W$F#QC+)(d-GW&wa$r z_|w5NocxNn2c)r^rA3=x9;5rnz~nU8jo@(6Cq`a0HQ{YC#`sb1rq&L!-z|YOL%_VK zajGdRB;_nqCN`1DWI2-S!x65}z6v%;;K+X#VHQI59C7%&Z+c|g0U~JMOZyAb9Hi*k zTd2=~yCh&5>SzsAUE8^NCd6Rh869xl+)xUHK^`_KPgj7uJ$#<-yIkW1pU31q z(FN{q$3;{D2QfJzpSNd`UharPyHd=!E(ZBR#nr=`Mk$3Migo^b5rM$$TSp%h#w@;p zbJOvYhg(MgY4FFh`35BTuWFv)b>V8~qFfI59@p)61)OZMKklc=bh4El%i z71CU{;9G)n&%aR z^TYSTn9HK`zPv9Yj-o_p!Q5S+PQR>ceg562TZi%mXN??fIx;8WQi`}2%IcnTdl5dY z6dr>kogKLM*uSjIvLv~6b)5euM~2uHSP%S{9jhMkTMuS(c1>M8G8^k95BnZRkO0z* zH!$5uPy%Q90^vlrlRls8*_J6A*f-R=l~x=QzzEsfLu;-XNd^a2Ht3c)?}qI;Gfm91 zoof!_8J0B{=pO$T0aBySO_c3F9G^9u6TGe~%a6ftAfCG-HwgmgtdD^KG@YjnRclob z*Utn`rzxF*O{?B`&rjpez{T}v-@PATlVKqBkGgX^?{?{6gq+v%G9;a(R+nRLzbA&k z%}r9Cb0#{Z3^M2PgM7D@TpgF9djVHv^E|+EU&}Ew67?sAq_!YMoe?h`i<44ub(1#^ z>=9h_uRh+G9=GIE2Cn{}U)^%&6~o-`W11ZEz|Beps)wwesJHrA`_ zfJ^uCaZ+~~>~)vnk&qs!Qz9XC=cKN#sy@qAy5qHSU4aBj>;!AAPoktY4@`@Yr1D>D z6pdy^sM9FYxGi0$FU~g@d01$7T<-X)XOHFTRjH|OiUdtDwI(L9C!Q_ZJm3as2glN4 z?pi@|Hr+_@7VEwF78|V)u7s*`ixf^`0-o>o`#;IU6!&P@olC`w@{h%1)Cv7`oOilM z$>LCB_7lqM`9U5mCO>~dn~PbdR{rldk!N;AItQ!8g_#RvWt7p|ozMBwIiuQ6QNw2b zbIi$swv7q!x-RhkXJ%PtM{;A0(ft?kM~QX6Vbn|+<*s{BXfZb%%r!&H0mBt!UWOst zw68=F_&bR467rrZCHm&fQh2s}2J$MJgMJoCRCq$w*@Tykh4mBIpf4bcL7i9m5t`xcV0 zN&6nO^su8jOy(|3vhn-2GW8Kq(x{NSX@CC@eYUQP`{DatP82x?3HDhI=aTDB_n{&` zolr_vF#akFsZt$f5AK{#PtTkU$uN?t%M|R86h@Zj%hP-K>n}SgTgaT_(66`3`viae zU2zf*>f$0;tthTQwLd$?3ngXzpq!*0+#$JI{+B>Ji`ac1VdCUt168r-VyuDFqI*%KJna&5mMPM?PIedzU zeb#eM3z*vl5L_c~njoGjS641i8??`o>JpSKp3&D{Cjt1b>(Ke&CLAXv8NPx0+VRIV z@xoQfzse$kF8V@)=NU?+n~(rP%&ntq8hSc96z125Yd^*+_(kiUwIlgX$Q=4+q#h4H zxdj!mlAd6pB(L{jpNXrL#G(d?L#_7X;x4HVvbHY>vu1}pqLl12{8PQSO*N4mI#qmg zt?+z46_HHTvHhol*K_vHyBm0Hug^b0KDW3y-B3O*E6c{+JsfzQnfd!%bq<41)rEPT ziK4Qyh`Kt?(f#%Lon=-3a)t*v<`%azILTwq^^v#dM^^pW(8p-s>U0keYDa9>)q6ecY!3$9*}~G6pC5eA5y*WRE9|;{eaC{PUM#BuN?WDt!duAAd%lQ4X>TJmVd|lrBW-PDEd~ z{%=&@{x_P8xsj1D1HIBh8Nbguwj(>^O5f7bskmx_8E48`QS z;>pmp{mJ`jgQ&)9k^LnUJyu**n~NX(Nbf4e2;YY9wiSUNr7L@fQa|;SE9l2Wd2K-? zw5}e`t`+xiFCdcNeks3?BXc)!e=PA)f^Z65ntIZ%Z-L9)&L$-Wao$svQYD3tXg8n0 z2a6Zv*ZK`UPu*0h2RY_x(6#m(GJ9;gn;96Ky7-ds0`IrI#fDap=2G~iq`p>PWz=!&MuQ+a9D!<4cIq!HcZIY+IO+6|F24=3%pIscqN@%Pt z-jd@#HhLJ77{R}@0>jJ3VPbX@xSq?UlTjk&2k(a?#eN^dwc32n)>SF?m&KboAIW~| z;~C>lxO4a6Z>byxp3cT6yvEyhEz8UIh3EO?^WoG2X00rG9J>%*S;^xo`~|ximNs_Y zLo4wJC6m_-T`J$=bsu~?GKE~`P+zI?=L3J#=~?jk_4AdjJ;e7P@u=CES~u`k?`^iv zvukk7r~W=+v>wf342z}qVcTJN&=qSM}n@k!2v>}C1#A$iZY{(;LAk!@UL0? zkD*tpZOxRfy9&AFgCX$S2^Wbc4@_lE?V0^OJ?nq|dob1y9*svOtx~kO7uKzsK3T~< ztjZ3*c;wB(1M!HFfdamW@T7AJnxdi$Dl^lgA{27e#NW455jYX6xCcfJ2e-mmT$EX< z4&*&y#q>p+Sc+MF=e(=^_X?CI=+G7)7^F0kq{}j7MAIm*fRE}fbh(T=&rWh1k`)@p zMIGqxY#&s`SfH#_Q22EQ1jA!qoQXBV+wRj&N@Jp{nstN^Nk9am0x_6>tE94)v{Me2 zNlRyWUMSk!-^w1&HAczevD*RPiyOXY?gZ8=NKFwC{JK}UnHJlw+u!LBroA!L z!mmFc#lPjxJl)lDsn%#cFU*i_w7EN(;oQwL56~OhY`0(?68TDnZ%;j9@FYJT8)ofv zQ#^A8&vS%;^bI2(-$Wy#&U-hZr03yGUsa*on-4sE1>6L&?J$zcmrf!Qvet?#9{s7m zR)WxAd7%MV&Fq~|+{m)Bo_l5b`K2wlqlcX+*a*6dy{oYJg^8F-(yUo)6?ddSHjF0z z54R^!p$idIBHQ2R$qy-XBe|&zv;iXOSBAd)kz-N^H#&&lambsBhqpD=e|j`Q+&ixKsOSf##a}*8A=g#?#rpYPntuy04USvHkCsof`W`cbmug zs5SgdE)NtXsm}YTsyf$kLYR{*fwnZ(aRJ8TQIQ^E=3h0==h%cAwSm8jC>T7)jd`oT zBPhAIKj#w}FZD@%XJHW0*1o;K>HPCnCfV*f({-KGf9pH|1oveK~;6{+ZRN-8>K6JFe?aa$C-}Ik?zKKp7s6e5;nNVSw;HsZ~=>pO8<)T zAk4*uGPclN61Y}xC!pNXjD9(*!IqgqpN}1;NBCh3E#JzVGI2LH&G=UaW1)c zgY^4&QJyLTn^P}3Q|;xFYywvdiN`@p?aOgPexcQB#sG5!M$T|Js)y%8sk8IdceXl0 zAiv}QUtP=nd&H_Lv$v!4QBSlj3HmIo45&G0WGDe_w28d9&$tm|glr>7FhGbsE@65T zEV#6m*@^R1gF2vDwa)wVXtpixY=c*COjKT{F-~6rE#>ldC3U4U0S}y!Q}PE3mti&e z#Dm*_S~e}cEb%&0S~2Z`>l>j^$b1nEnqyj59%LoMN4$b*y&3a-v5J7(i#HswD79(#tiIsb z8F4`DVesQ?hPDXfj?2Bila!O&X>xZgRhJMGLb+a#E=>PeV;76+Ai}&X6zcKwr^>69 z8*3vUQs8~m)uCZd^ai$)Kk^4jS66=5n?r<+ks;o+zNx3r$NnFK5JSzM^PA-AXABBz z+uUSWnS7r~=n^QDP%zRX*#3t6{e|uyP&s;)=$~bKDG8Mbi?SY_`qEMf+qwIezp$D)NYcEA?692@$t!2*}%U0;Or_c=C1Kj?OwQ-O&2dOoJQb9ZbPvmSA9%trt5 z>6!XDjqtDh+Jm!_#Q;OWvxBY9=A7#Ag!yk-kmiFHWutToWxB=C4?onfZfh{f6!fRd zdRQN7H#E*JZ9Q&_U)DQP@3H5!{+zfy39H{7R!mM$r9> zK)D&4a%?$WtnJzfUtee7_31ZqJgfHNTZ+=(;~|!6&J^+AvmmBNqf zXL=4Qo5tkw61>65Ce+&=zivrk-o-V47PKfA>l2=fN5}G0qFg1qD@D3LG=D%&UF2Q* zk^bG1Z;S`yJ05^;Fr~z@zMgMye?KF)Fr=h}NwPXDJXFAa*WA{%Qq@)`r}AM?Ijx;% z-@2Qinhd8jr>2Au9Vo@~xTBD(QAvg!QlB|i?q@vLuECGq^YeV&vT>~=9=C6GG%Ngr zRC28QaMTo4Gy~M=$g>eK&xn{Vy?RwFN8z7z@7r>sD4&AS+;pKI^l7-b8vwIHR|mA#u3aoLn-@ln{V?X5l_%A@ugWkLI`9>*&-?*Of=u5k}8o zhaWTR##%aVdN>sk{NeOMw<3J}e6?$?D+YZ;Eqc!x67~*`XBQ+-@cG!WS~lq9j(rS~&i$ZH zdXq4%q&d9aMK~yVZ=Cqv4ZXK#O6N)?R7fK13p?<}Id_RFvfh-TT85_OuNdqPOc=e@3Ea)=An;T?M{- zMPZ%1Y3qS(z=#R#%Q@dU`g7x(M}t zeqF!(`}82sSySh$6{IPKYP1rzCQNdPF`s&HVEZ;yYcbV!yAtFT|J3q+Cnx)kI6=|O zfZ1J%ThGnBxi#3O4OHXz-CFbu-tA769^I8kx|m>r5U3R2b&%EVup)J< zrRAE_#l^*Gr(vao_rzs}Ol`nmm_;16q*CjqOx(Kr!tZ$hY=V|C@|)EPA3{s*T5gg1 zosn&wfQ3Pi^%k5^Z}-(f-N{W0MeK-*O5R%IXC|`LkqWmtwl_c+qL`n@pPzSGJwj!{ zc6FI;uws6rot!)gYi9&pP_IY89SucBbA9={&)9}2z)i@p^MTH{)qx#eiDMW;vKpW1 z7?e)qtZY@Wy+Lzp_IgY7?Y|_dOMWV(29ng$Bsi-jgUR{qEMAl0URYIb3L@q6ajk9o zlNPG_a72BNqrxg@ie&NUuDoMlIA;yCEaBlI7g}ypc-!E3YzP#3Z|of%*PMxg z-!|9ah~cdS??Tzr@`J$7hmzG+ONgxv%XS2@I?h{OCPt)Wu)nSDn1qU= zshTba!n=-hYP7f>?Gt-&HCvsjM(m9ic-T_$g_lt`(xt+^0rj89Q$GB`wk0*_I^SA+ zXjmDWT^xK6zGlrCjPp{Cx?pbhU9h|-06zq@Nb#W3bjjqV4rk?0>>lpw0zq2f?6l)? zW~RBn=jGf^ibE#ppgsl(MmPX;f}R0U1i`ZBlYC_cuIfg5`>#XAr^Ks;_JWKf)YK@6 zhf0dy&}=d1k&+(t2VvYQq975}pBns>)6?ju!DZEz853Q!w~_x%jB{Qit%Q>xDp~mC zbk5s!;IOi;S4ct55Rg{RO(I$A^)-==&+Ep`AM#mQ#tLdrgoGdis&4DA0j%(4g?8Q8UI++7+5u2$6>qyN{} zd_tF2KrHrt2+qXh`>I9ljU__PQ|t>Ji5~h^{?mNerto`IGAT6G>tE+E^vpP)TX8V% zO*L@Ask9rAufk&IxQLZ9-%0fw>pk;iO<{>EZ8|7hyK#-~WkaRm3^^U($@>H-r_$4@ zTu9C`u_czA+|x1Ayw5(}FizfMu{96#5Wn{dO_BUwuZPaI6GSR)Z8~d+`7{Gt$VLcN zK6^sd4yV!wM=y2gvUrQGGu|D1s~}E7@Kt<*sp%ZVRJ(~8EFo-Jr>N4=*%y8HDC`!m zoG)FbuCMqR>CP=BWX1KJ>(Aq?!iEq7DuulTJ2YJmE>4^0>{#222+vOL=eyL|7EfX6 zzR!hUZPtup&w^U+4@P+|Z^b^>J>j*GMh~&=%x=s!nqe`D1+X5>b{rIA1OR6$wVhV7 zAe(_?$g*E>E*qnOH6u_IrM+)#@p*mKIsZx4x~sL}(7>OlsLwFa-vh@7Wn+_BO=o@c z73U{4qLZnCzFOqLs;{Px-d0sq`7%j?fhiN6Lx7S~{_Cyw@|9$>1jzsjOkn5Aee~CI z=#s>!zl_0GrCJriQzZEYWUQz`TBlr`Ww=3V-_aj>Exr+aRE8HCa|_0>!->3Ok*bs~ z?B1;?`Ld8VC{%zr5#GPE!l0^pDThmxxIFJl=F&^knf=%{gnvETh3H>qs5Z!UC+g*v zuB(Lw)T69={hO_@8{9cC418CUIw_CE0ayxrL2$>-iGr>_?G_vF>?==p#)O4>k?2!4 zkid7;h@6~mI|naq`#Y&ULxy?z`D@4gCvR5Eg&XQu8r~A3!x$>t(RJ>`MvZ$GDl4Bi z7A;#|m_85#*OVj6{wMHb97$Mq<|GIeiLSyRdDwyX<3h?rnzPbN3e)6qK6@6=C5V~? z&SZhvyLyx7e=T3~#6S}w^VXcM)LEgA!Ny(|?Yk^a^fd&=_?U_iCuoJbVh5>^m4y%( zNPlLFK{c{hF#T=?lZZsLlX5R>oS3iunN<=+EA9@Z3N9Fxn|91S(}Z zSq#0Gtgf<|sv;*HRYVgB_e2baDwj7PTHm)+Z!63EM6pj1@9{4bK3 z2hM_FP=V7bu~g7AC91#9_=#*{>un$YDvJ5Bw+i#~&4t?>lQyeNYG5tFwRbI|x|wz^ z-W5VT*Y&E^1db;(Ss&^(8T|*k2~NIalD2ehwI6ur;^jOlX>t}!PO(%GNMWC0KcIvP z&8LfH2}LV`*HukhSw0g`!ybmRRcv<&XsfyzMxl}5DkKOZW2Um1=9E!T9#6qC^&KCL zng1UPusiet?l6r6zdJM)op{EOjga4p7>%JWF*nsINgIoLd~U4m5sBHbKa}8&mhD!I zy`w|)z)pmz@H?A#g3m{o^TfXBt32VNWRLEnudNFz(f?wP6a6PL-}9TMAXU|Nb$c)r zUfJLX^cqj3a!Cy>79FJ<4=JH=S+uoyTE;_Q`6p_O-C|>JHXmg1M?pmsl?S7{)9!f}q#< z5-b>@VMRxhzLzjrkK)j(PW&L3{NA@@ylKK*@KkB;h>kiea7 zMb0HWxN++>ynY-5nyV3qmIwOgOxS-JOzZZ0Ew<7&<451qzMpXn1Jtu1kQnF9(yW6r zZ$37t?{{lrr%q`Kk(k;H(phEW$d_2NvUQvD8qX0+;<_v2pH|DVT;Mm@D!56Vm(dD) zA6=b<)3872XoXo5N)q896rvvT#;{47vCZ;rtLUZhy}=PQRNp%41_vP)?k#odels8U z1&i^bl~)C%5a7fofgX=m(w3eFn7-ug=TQfD3++CeJjHtct{?j1R*R(rR{)v}@)UIb zHbd5~M(o?FT0-tYr&q+g-}A_lZZylXe9>Gu+iXFvt*s5zsra6QtX4m3&_#U_T^=2| z&$SRN=s^gy9|7I|gKXg}HcR!ps=t7wL;COSPgD9+rdgU|-=3lyN%%2fLTj0NJFl0K zw|hilm5RjLw#lkK;`e>*74@Rz0JSNG;j5nWNQQ^sB4<1qofJ8aar(_pq z`b0)kkqC*kLan_~wU0LMqFm!)r&Ea;D&scXWXX?H_9~O~4LRQ&r4*x9Y_2ED)w<`| zdxs&%A$T*WEW~=VQ^;nb!Imp>r%O4Hq4&~1Y3pN1TqWH&9mijv9+xY(FnS^WQIf)K zHLFHEGWA^l)C6CXj~R|(cg&njNOoFddJ(&TYICHt z@?rD&455C*cS{VKx-vt@IiCnmo#K2G>xL(YJ|@^)^X5HcW)GYGI;9qOM`g6aS{F63 zK?f2&0k1RN&GRN)<3@0yX(Cs0cFm%1R1~|gJ<$iS*3jnx;@rulp;H4Ywx>{)7d3NhhKm(9}X8=G4#zGy=(!a6n|>Aa)@8h%gpoD2@S&@=9mkEyws5o{W)9*NI-`$OlxaU1&C9 z(bB|OGiscVZ^+dg^rAd)n$e;k6K(cf-qYEQs}okCi)J1B5PNjHjX;y3>y$xo+LaK| z$P~j3g6e=A{_=k%ddq_TN(y;9L-kH1H8R=*|Q=j`mi%C|M?Zw2-UG~$5#&N_~ zjWC!TO(@uH2b6621;ma|jJ3_pxQ8AOqdwcu5ShEW3;NZ6%SpCJ+%O(l%Wv@fY+-;^ zA+I^uAMh-lUi~MCdeG#ET|s64*!VmM3H7n-i^QF4@B?$6dcG8~(=UEchYvs*fRQQ5 zYSkucOyVgpr&S^l?UC`EHPd>m-QaW-qZa4>c*Ya%;Xd{-&X`+RJyxdhSMCDsOL+9v zI}1P9UwQ7Sl^<@Xju1gzF5jm!Nhlm1$|1{5`HKDog{7!CZR#=fYg0q0?sKVvEMs?e z3Y5n(PUMs-1e7ZQU1MSU8jCThvB0T}6u%LJv-RcSKLO5W;cHEWGdmdlKYI$4o`sq4 zJ5$iN1IPX^2iM|~-&qTbh}Q1)S*ZgA0 z#PDqf#hGp=bIpi^Q`WG;^-ii8?W<}k3^rh4!|+AYo5}X$l^ah-mAOit{WNluhgX#{ z)t?_4b{AVxWvK?brwVs1t#+nzV=IjBRGhLgnHk)FFKKf1fcu~@FjSNTemy{l9ba0G zQ!D|;qg`VM-1=9qc=2)Kpzg-8h5h@{Lx%HfD|PB~QekzHa;oL_quEE(yT@QK`sK?f z@1y{67S??I<0(;JaZWDJvzSFMF%r7Mp|yqxci|`cy_=8NKV2N($g~tK*<)H>7~kn2 zb3dCX8RM7pxcqKLtM^ZFU30}wfs~v^Lke^UIDy_O#G{SwpPSm;kdE2yt%jt{x=xt$ z6OK?y{H!BE{70<5+;#zFJ)jer1gG;Nk_`~C|dV5oG5GHpx{|Gywz>C72i1I+6Z8!ipIN!gY1q)7c za!xGGbxk{zvOdPiG*+)`R2PG#${Iq#-CgG@Ea+XUtqiu!Oej-cbZ2tiTbT2?4sN)t z#>jzJ%jes-+Kt#6+eL3I0wH}$&1EMaqX;2yH9FQCU+2(d7O$xh;gM(e2OhmO(cmuf z(o*fu2fdMm{GLMpgmVORY1+Nx{dOC|^E{^ThbImM$H#L0GiNNdfetfs2v!^R z_yP=-73zD|B+hoHhf%)l`z%}%2kC1K*KE@UKbxi+{FCNWeWK|1U`Sy#eXa{tEUp5FpJo)OjP;6S@VVFIn%BA~EjR|1vo_!+vvUXGX|} z{j0uP(m#|?rYB|T`Hv}xHP3-3dWU%xNHkkvvEYn`OrO3BJeD(GXW(>9W46#l!9+Y_ z9ngWVfvGvc`-pJ|LjO?Wp&UFlcAMp(Np8*x$A_Q(VIKmNgX?pQr%Sc^7AuC+8=8{w zQk_CY-QPOM$+^30ch?3KE6bbEVW>t!zFx578J26<))5jN9Tju&_Ne$;rWzQ5-gu$mQ1^M@{Szn0>P{vNfaCG0n|& z5%R&K|27C~u2%2ujOR(ipy0F9SK$`{`V^gElb3BPjhdf~!jC5}`j@k1hqKMeLDAF~ z%|C=?xf10_f^?bUhDo-*YlA7l7wXh`NW%Wm4`RS)Gjcyd0Gy5X`f_w>sw+U~OeQ>D zpzekX6|*UFYO~tl$x4A>f+7J-R?HS=5|oajBG=ixd&Z_0R`6JE#os$={9h$8yk4TM;PB{NiKDFgmD)`u=u0*Hwen{> zv1!Y2(R@;~00aCt$VBWGW;rpo>y;Jbbe6Zb2*?&6nw!>jCcf!~_CChxhF^_^dYi(#M?hIYYWnNc3h%{iGk`-NFIN z$`^f*?+E#)#bT!z_lkJ6A}Ls9U~k6lmloinpROG)l_l$K^h`|5b@d0lNDsuZ8Y1~2 zDyX2iV7tWhV^QR|R|nl$Nl}h4&n(T!n3zyII5+_QK1!+TdTPtT?1Bwp0UwU{PK?@(M8UCM&045t&e^o=;0l!WMZdTn~@JE29vvEjXMRx znT3TRo=@W|=lvnfQohkvC}r+(>7&0nve9`0V;ANbOo z_p{@ZFG#GY!=TA;(AiSy3tWVNwQ!GYg0E^YfZUBYdkO<`*_ePK3v}DE zU_*X>v6u@ni1yr1bkIOVCdfLepLME9@lR#5T7X^_E8UH7_FZ&<&~wvu=FA_gXWl+y zKZh%wDX1C?o6XhkYmo@og5AGaqiY~C2!zX`0dOYB{QfaWu*!66Jmk%Ec#%DIpGYH> z%=7l3i$9T#xhe$Q6a-$<5;!JL-ynXfwbX@z=N>yh!N@wz`_<~(GxpU=+z|_x4cm=7 zu?+=5oA7FAXp)<|2e@sBcRoix(_+7J_701=KI`x~JyXhVT1p7SwPTe^MHs+qk`x9w zV}t?o-IBPTt77IWr+i%{maq|o63x}HzgXE#qDl)hJw{0v{~%LP%Nuug%19nHMIPPG zW?`PpuE^M0PFieYkDh2lEiEkp&<*ip2T5LjvC*I}0#8XuV&AD6_?{mYg+NwH3a-u^ zl{*?R!=jz;`;4$dB!=rw$pOJp8dgP`wr{H(o~+l9Ey-nC1Nay~s2{1X`f-=b?BNBt zm;xi2nN7(WJ;Pi1)Wkc&FIV6=p&z)fGNuCx%9zj&YZZ^Xj8(zjSzo%S0{TnlbuMmX zgTve(%gB+cHub@hDXTTu)7yD>TJ)vhg*9k8o2!6|75C}Cy@A^{t|?(z zM0bmAq!}*F)2%N^gYmn;v`?b;bS9{WaLq&`qC?fzCqd<_$-D#S;MzMVv%r+J#(7Gw zqmgoEj}~jf?;qY9e0=YZ9OOPcHOXQ-4k>q$luPN)>r?4q&((f4rUEBYK&1{eSaXg z+8s7JKW@j?j885OcG5;?7R03D=^8H3{8{!CZg4yju1R(4{Cc z^Z2VgNv>;tK>gv^a)$EFzsmN0&ctqfePVBRW%$W)WoXg)!f7V^WPAF9)n-b=@w%Aj zvrQJnzOu&d%3;Q)r?}=kpy62mzDR?i?m}Ki{fy8g#g+JGrNTFv-_fQEPN?1qUizGIBiqzx-wyeubA`{sUITR(p~uAS~CPk?<78ns+ho5K9r@J{rx@X`9%h zEiDkaGiAIB-!o=_VyuTT2fyn#mgW4C4zI8oOEMZ_edNf&bUywtSyP;$jD1>uflvTZ z^zSIAJwhOhCyRY4+6{9xR?OPk$vmR3 zOevLoO8a1)o%3cJ9FUZKk0OYLQ_wuuqp?4KgzUUp9yaTJ)OKe$YNtRUczTd%wOv^; zEXfmY)q1-M7tdzF=jrljvpoCPOTqp5Lf~%JVA1ntX;A3-R`p7`>qR6WwdYAeufb3A_>LtpgzGX_ftX~iXl|%#S<*%x7PkBBq{w)G2ZFL#gB6V z!uqud4@Tv63?z5Lo=~d+I5ctYi)Y9roWb?=E^KqiskZ|ouLdMYC#Y{CM`>mB6R_+g zPlF~-=6fV_Rd&=SNv^~I=%8+WPHVo<7|-wNOfSllWVa@POvF_rTI#nJwTz~K&>=>7 ziw^^D>WKksVE@T)PSTo>&>%9r!S~_IWf*|-r5P3 zepLoVm^@!n_9Q!zGZ%mB+LlYi7qZ0$GS=c_Gzq)=w0X7p@?ana1$1creapsH0a78H4yFN z;mQ9ZcEEqxyzKVk%^F`nq-coJM%ORWcac% zQ$V{vr8*}qr^ZTL;5~|zO&VK?JSG+|&EQQrpGH zJ044Foj!`htBsQ@MZR`~`+TSUvmV~(PAq4DSr{7eJU6gx*rO7<$!1nsne7E?B&b)P z@mDJ4hz*d`^}xA8mWy_wKg_j=Jl!+zduvyd|MaQ{#o9^(&f45+rm2OSmYIvVf$2L|KVZ=?l9zwm?` z3_^8W(F(EpUN0+yctJ9cR;gijfDFAWTvRvG-mXtdafuD52tqJJ-&c>~snm2C*XFy| zBtWRmiV}INq)!KCo^jpu*B4CTkZwFy0@vo}6Neyf%66twB|Rt|6@TQrrs);Rycoq(!9jg<|FUALY0FLbJzkGFzO8I2`o`wmi5vAv!}T{l zzIhE`IHA$tj6mxy{YT%nZR3u++8B_DM?5aYJlroBD^C{S;K7{S%?f%sk0tUR_fIFU z_?qEBjvNOXvF}Fxpk^h|FD>;nwLCDk+-((y;!ky-DpA-FzPM(cyljvhuk}e0Z>;7@EaIKDZGvst;GsAjJ;S~30U5* zp{ZZ@X1)=4m{f5)NnAlj(X^tf^ghmvno&^Xx^3BUE`cD9#U4QeHWI`Ao5EM_3RxXS zT%IP|R|kRwL#IwnOF%<19*Be^j~_uy`^V<$1AMP9a&LWpnv`o&h;C4htoQLs==45< zs%#Tl11)Zk7F8k7teTy7M6BFik22`>>H1MyzjO3CesRtJ|NE}AYti@=!2gx(I zy%zhHIdHdF;v%zrE%V;t#cc45?{}Qt2Gigf>Wxf3gVJ4_MxEY_dk~{eMTa0tffn#Bqxv0cDjv74j=Va8;iHVK^oRKou8_U-h zdbY}d`hyO*2N-N2m>l=sxaDVsUt=jK4vPhfy+JcpTl)_VS672V$2@$?v3f%m;9YGa>6mNK9$X>0Tl}ux9{D{01o7E@sEG&E4FI zfFUsbHrf;2Bl^<6`*%1r*-p>#Cf2^X05_%_6$Om`u-*7#;q$t1_$9mtWVfyqV!&L! zyuAE9vemBmX7u9aG&~|6P6R)}3ELu5{P&Xzk5@=T6x$)>6|6ZL{sBaAIJEy$R7kJd zX-=)Bm>cCBzL`0K0nP@K8tejPVQTg3lT4~xCN@Za_o_{&R&;HZ^sRRZjwQ~t;W8FR zo9%U0eq2E<4>K5&Q}>x9h;yxtA-pisr2{^)TQ`hx%H>GPkJ7fD$RY$mMt7G8gW6>5 z0c6F^mZ$0?(_h$78rzWsN5wmniW8}x%Lqa^Pr$F<9TR`d@-kS>U8<5)+i*;U!KqC&DCpA z$zT9%>|V3+icJ!3>$!xW{y8|$R!p|B@kJ_uRd*+xsImGdqg}YLSHk>z!XCYWZEp4F z&FCbXWoLM@-MxKD+OFoNhrHtAt1u~1#lt;>`7vT%>v;{6*EJp>@k~1Lyhwa4&68ZN z=cF7m>gIZgdjMSfG#TitLq>RGY1xgf7lWvwPkUUC*P1^%vIBDJaqW?s`9gh6yxlgb zpp5H-$8^gz9T0wBDOMZ>!LG@@iQXL~01*K1@C!Et?fen$IzHfl`+I3I*lKr8zJSu1*T_ zqFIbmg$Nl3n}3p_z2qc=3E!akJPyce_iz900aECw&}qS-z?jn|(+yP~asfT_`cc$i za`jJeA@oU#W?|%WPa~`%nmrjC%(b7hm5UswZWDDYmCLuteBcO)_BMY0kE7Bzl z`C8!jxGaaX@??`oE>+sZ1)bC7cLqS$X)#@6S2;KUutit@D{q!+og4htC-9lA zrD6L6{7%&N5zhA4`MOsX48Uzm9#`Q^P0dAVyNbCNTkQr?*Ec5=q~P1$-Z1Dr{9zP0 z>+N9R+ah{a=SNi#Jc9uetkE0=8oQG;(h%?xws5~BZHxHS?a!mxU0eyTjnzPZnc_^FDuq!4d!gy2+uT+d^sibvC49UJK6*|( z`-&y74b1aefzsR zsR9fEWvVlf`*yHKQvpGkX`4~26PQg3MfZ3P0fg?BhNl^O$Dg))4<%VSCE*&tJ9xEn z$92P8m#AOXdxr-e|IGEkO;VWRit4+X9IMk$o6EfYCsk)8O!yWm^uqV9jh5ETmakvP?(zOQT1!(%lk4?gk0rkH zIffaxO+G;NY40h0W*68ib6nAR?WiI=?%4tEI05dsE+G8++5^LWg-8F9iDqR*6t+() z8}V5i-s3r&+Aj(-1BIh(|J6tR4|{BtOf!TlNFO~u;!BUwVeozL^)9u1pe$$S#H0_3V;iQqgeBKi+LAAR{a?Mj>*sg6^~zb{@Xz%9Vz z5~K%LY)qY+Tkj=eLWcLdcaivPjAoI%sk$ROg#)uWhmJTyc2s{}Oq;Ydo-MK*dqbRQ3NxZKU zMFst}SfmRFv_N8HY=@HtG;#r!M(uJ7gAXve;qtxl)9%XU|s zZ=ABpfq^8R@DC5}_F8z%w}(8IJR;gN*}#y7X#=|m0D%I7fPh*nXTc~6?_XI-Di|?6 zz4j`(ExK%=yjFYpCdqwiWMcPMZbq!T^Ape=raw9*AdOKQ&dXy@<3POxi~W!JbAn%g zfridnKNKT?>9Xg!HUq`YGLrIyZ`@8UV!-#9a44J2*liUGk!dK<*+tr-tvV|s9h9-* zqb~M6WXKgo4Q_tY099S-32P{Pe{v@~{8AEt%%#J$H@8Z^3s-wRRhHq8Cu+6tDyxKg zpM6uS;KC)2hMt1*9E#Pk>@U#Q(XM{A@Az?^<00|T;KUuDW%mO6SkM`auNt-e&3d&A zn+$N9CU;q}zyC1U$+U)!e^0VBIw>xuqB3`viIdA912HF>MaB^IX!JX)bpHFfA3Olxjd*6A=tfmchR?XZvL$$h0&@J;}I54 zvPzfP28WUp`&SgPPeAow&l91amMijr&v&>|gSICFpz^Qw-?B}2JOj8o6d>3a+_po& zSr&t{J|05Cc;4{IiTW$f!nh!WmjD?$s1)iL0mM*kpa!ub`j2cO&Bmr9Z5LUr4OUMH z9@7R+4nedRPqhzGEW7;TfKd;{|I0cMVgY7g)q3xfXD?>XO^^hMnw?)+h2OJp)BTFS}Z4%4x%t%Rn#be+7Sd`R65 z`{_Xaoxf?PKwsPVd;4Ec93>4hH3S0Qq<8+2ZK0XdBl#^@W<2w|K64|t^d+sGXs1x^ zR?%lZq>$$lDJ+SgJm&t>Ngn*@uYi39IB&Ld&fL*!6X4r*rP9zF9H0<%d$jZH0&2y% zIs^VNxuHMnLFb}J`p zAFja_k7qscEJn6*siatWGs+-OP1#rfplHqxf1WS&pyh$+n?DMm8Nc0gK3uAaIPuu{ z@HzWdj6C5egS2Lh+WM&3?XJy%>0@A+SIzV0 z#?X$pTOpw{^<@iuKX>9aP}=Ozy3wtam%k=P@&Ypu%b2OC*#XG!Hd+A~6jHVTkO-vq zKciDqT^e|e(KbDV6DK?L7mKW(8Q7rw)?5IoppH>K|2;wg001@2{{jSyH6e91ta4Ch zpy7`ge#K#fD)9lPp0rURv(Ho}h{q^P3!Gk|T3gYZt&5g&Go;GdLzEkbVWULk4cB}tm3>(>ru)KR8-0V?+Layc+h|f@pnrb5eP2PTSp}>dmX5^xFqZ-U+bZ^f$zcANef~I18EBR}#ho~o% z*X|Vbk!{YrCHl}W1vR}!;?$L*eFf)Z*b{*utwi4i4CqQv5+GC6FH!$%BSaI5f^2`2 z%SY=Kdz*S3MoUxIa@QWi6!2kY8(M=$KjrP-UET5@=J6{IHgh$7DK+qQ-HKnu*UvWmDgb zZ%!*_b$o`~{7!gDO*ub>P@Vrb(cizofJlC>HbqlzPxw7%BN2nLGOu{Q!@Pvplsv^c zd3KS8d_xH}7Sy$$S+08`L%4t&_g#h4Wds^PX7;(3*7+0IZTrq>&j=c7PDm`{j9C>J zZ_shs_`;kDD9lpYyuLk{|BZfk-h7JSkWgWK+tEd&*xNcK7;N$wJjNZvA@Y<3e=XdJ}IuvDS{D$zUc-MvQ?I z4g#8rWxM4XJy)=()~?bYVX{R5H}*{6WOw<)tHkqqrTqV97yVKdxgQCS9B{^pSPC60 zEn-jSxG?=PlhgtBGMvJi2u^A@%XW&;93?!)w8Alim^GkQ^h4hz`8F>>Z1P4Yqa}Xe z4R0|?!TQsdBy8j@|Ndv}nq{Q|PgDGrrJ@IV1RQ;GH`uwVy z5@A)En5jKHQa7^XG*Ru-ow}xU+&3lZLp6Q=$jPX8{V0)s|L)Q`@ir6H?1VkumRWRM zJ(7#cY-Rd8F@pO+OxFTbc(m%eyD)>7)!>wlfQN<`g!rpS4S<6{{^AP1J{r3rW7 z>DXVGrFvFs7K`c!anyn!U2gr;<~XTY zc<>S~IhB-pC&f%Lx0*0F@f2!vQ_YPjzq)-zI_Z+t;}2=F&)?e2aS1|;f6|wUD*atV z-2^4>)LdR#X({Cv{`1f`SFEL; zq_OCn6@Ep(2(7QY%rFwyioUia@%%i@ITOCA1{#g7CprZX;aGnS83Nl<3a!Z7QcsUH zEnTsFmHdqtx5Wq)zjv;WU)cdodO+S4k7Z(xnj`mJ)lq}a8P~z zeUA&~970BRc?#+Lue2Sgvs`2%{0!ZjNg+QAf~;9tL@B4^Pc!@dPD4*7fp6O+H2u&B zJ(rj7mlp(rnl{)Rx6aSKy;yZ{14!a!E2lnmdT#k*N`I31sMwDI-x#&iABA1k{+g_w z{yVnhu*3ZpDnQS&CKVR=CdxM_wX!Y^ zqW#dzB-BG6)ijc>h+|6#{HR6~IHWok91Y|R@aWdL>t+3_eOVAb281GaFGzDEiX$x0 zL zLodB<=`XszH5RYc$G4tRb!cIL8g}v1aiFbzTcgJrf&VEd0 z$W&4N5X5*j?9Uc|R3lxevXP_PW%ft~gDxq%^w9*nh|38)J#~P|eB%nIkIY4CCcY8a zdM(2o@>>^RoU3f^kJ|V>rWw@a^_IT_f%Cx>;V96q1N~wz946Y+M(|<1o7tXGY)R0p9-zC$~4{()95L9(`QF^{cxt1`E! zNJf+!+?DR>-T z5-J?_cEcY(29HjOMdQnSG|Y*J=@()A!Wyq&VxF4IY{UJzIE_4#%3cot%O;1A8gi|f zk#(k+;BCL)(DGGkJb-!)xBd;{1IeUAY+=u@1OC4^js+vc;H$`PolGx9RLLox=J>^< zy4}af@er+rrz7)7(AeCRjENeapJ-TV20xQ!cQ){-z#uT;Oq)&8Xq6zQ@CxW-6zO5MT5kGBVKy+%L z?02QhISH?vPU&C7{|%Xn*Os8!)zIS5ZqGYtuhXI_xH#$bIU2+ggFpfb%IBap&u_~| zrYBkMFCGx(OnUwk zltE~&=%VMrHF9N@1WSiK_2u%ms@BS1uN)DH*!=pw`8Ha;@oJ4nNaRL9=Jsudr_|ex zL~CD6D4AGgzLXt=j;`KU8yfuIqj??F8x@iihr@xA&LII&Fb`9~)*m?KW@8{u%KJ?} z&ICqErMxHzfu?5`sF;d7{G*4AZG6+JtNpbbex^eJgTq=IZ?CU5;6bDen%%fs%Zavh zV$vddc)_gP@jV@aK+JKmvI!_!hABoCGy^`@P|8!doee8A%HtXC+`K{$#;4}|4(e;eKg&Sc zv+NS2)l}j;nm>3_sT&o+P->MJNDG%%hB&15)B6bI(qF}-&;9g!{z%NsDB=o6P;m<> zx~2J2oLO*4d{fA^RFvrwR>l!n5g5Y_Dx{R*PH7k%#zeQpsK&mo`BqwdVveZ1?GNZ9 zzx!$-!8-9!G>>8MP1-Qw))3|XNaf(sG!v5yU{?z3{TqGuQ3k=g&9TOgIM8R)IHy?q z${R~c_g}4i$_u8+ew`WI1CEY!O7Uch9vE9%9&T=O-85ZzNlGCJRmG>rToVZDb4>xix7L z^VmN>i`w38;wrTfcEvK~w#BipvEI=2iZeWj>$F2BGVa3=8Wm8Vt=CZWq24uT56(1yhIctvH=?kE z1M`{uUU)+|g1)+}OkA{}T#qgJyVb$*@uf)7W#E2;#YprarK&$x2uT!C@Jm6jKu{Zf z=k!otioTAQ59})bARmdB8Zd2!(uj*Fz}6IsVUUa&T`0|h;aK+3;ICxZ3+TBp@y`am z0p1B}64vUVbC`l+YA8Q@D5xV7AVdHGAR{CXn2|MK_4Vv>q-8;GBc@WaJT{Ke0SrB zx+AQBh5j;|Sw75_w(s=|DF~e9V@yG- zX+2xF7J85Ce6ff5?tZ`68|Jt15K>1cLz{6FO+Fa`?5IZga_|}Md>Fxj(xEHuOA@pb z$?yXsfqp21T+hZobo5ad$JfwB>ZgN?9?Um!5{INNzBn5aUYe}D@2bt9Afq0z7QG;a z@BKBcS;>gI@x`L(G6PQ)iD5P`{dTNUi2U5{TVK!5qWk>QfmhKI@vyiT zqj|yckrkVXKt~LHlmxYT3#Y2!FK$Hw|H>!M8Ci(v%BSMBR;yU8bP z=5qI!A#Bq;F3ROklES#zMaI9irq%uT)$J!Z31B_^ti+s9VeJ; zZ*BO`5Pd73@dPo9u{jx);;xH{vq{C@d|O{qy%L?bvDIyf52}~wm0#06NhP`VJ*ygf z?eekxtF6Zd@&g5E4e5L%N<9GSi9Z4i&v~e;9cDT~J$Rl3-}>_qHa!RW?P0SXDNyT2 zI}}JOCwmPT5ehd5eo~xuifhxDQByMNS87}uFa`oOQPH+o>gv;FEZi)`@9X0L0dPm@ zexrg&PrG+}j^BN=7PL`d+~a*nuu#xP&{p5B@>YKO$g+B@T08$K{)oIuoD5bC@$ zikV2`Y}T<}WJ69N7KlO2VI(e)1z`hs|KcG2pegH@LaT}4Mm`QTr@{zFBK`M>ewZ}<&s<6>)U=FYo2UIR!86a} zRATpx8%qvEWjKN#Z5EqsHMZ+&ish$%zXs~qsvufoth7mi$p=W-u&z^%1EL3jj=RqH zm!$75rg+b$N2FZ{RxNRH@MD3`<6=)t-Ww0=5h$;{Hy3(|C$f5YCEtZy>o^+>pOL#x z_5`u6GIT9+(Kf}G*2LGX6mRYMSN*D_OV7VblCc#f2t%zH1l=R3Fia$?bbu-==GSJo{bBIaizTiYUaQK@Ml7}A2@FCu57 zA~8tODC}W*G+jDtD0(5fORu z6wP}n7jRtn4{eo|MAcSW1^2p2vvi@7cAr361a@7s`^N*EIW;FEolc#(Y zN_cug$bIieWIdfbx(gpC;&X_XLeV^68|rYqLJ!=t4kGHmjp>iX4!RwZyADc`Ve}0g zU`88Xq;>(2DD3X$$o)*B&ilzNmj}gpBsi(GTo94+VsZF*U3qNM8nSBoCnj>eW*8wY zbTdt0+!exubr2&m*C+hu@LOockG6?!=*1o7!Nx4&kq0)^w`LJO%s(Qu*-Ise;Mg9t zal^^FkPo3N4#Fm+k#BcuH3-o9g;X4HlufaP(V~L?QV*G?Oev6~4WU864aN=z&{)ZC zCh~+2M`Y-K%S_Vv=Q$?X{}-LuM_!Pu+7|0gH +_olKs}XI7{nXYU@Z6>;GE~_R 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 Dk^R#N literal 0 HcmV?d00001 diff --git a/open_wearable/lib/models/this_device_wearable.dart b/open_wearable/lib/models/this_device_wearable.dart index 566c4308..865c8622 100644 --- a/open_wearable/lib/models/this_device_wearable.dart +++ b/open_wearable/lib/models/this_device_wearable.dart @@ -68,7 +68,7 @@ class ThisDeviceWearable extends Wearable bool darkmode = false, WearableIconVariant variant = WearableIconVariant.single, }) { - return null; + return 'lib/assets/devices/phone-app.png'; } void _emitSensorConfigurationChange( diff --git a/open_wearable/lib/widgets/devices/wearable_icon.dart b/open_wearable/lib/widgets/devices/wearable_icon.dart index b657d330..d350a382 100644 --- a/open_wearable/lib/widgets/devices/wearable_icon.dart +++ b/open_wearable/lib/widgets/devices/wearable_icon.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; -import 'package:open_wearable/models/this_device_wearable.dart'; /// Reusable wearable icon renderer with optional stereo-side resolution. class WearableIcon extends StatefulWidget { @@ -24,16 +23,11 @@ class WearableIcon extends StatefulWidget { this.fallback, }); - /// Returns whether [wearable] can render either a custom asset or a built-in - /// Flutter icon through this widget. + /// Returns whether [wearable] can render a custom asset through this widget. static bool hasIcon( Wearable wearable, { WearableIconVariant variant = WearableIconVariant.single, }) { - if (wearable is ThisDeviceWearable) { - return true; - } - final variantPath = wearable.getWearableIconPath(variant: variant); if (variantPath != null && variantPath.isNotEmpty) { return true; @@ -115,21 +109,7 @@ class _WearableIconState extends State { return widget.fallback ?? const SizedBox.shrink(); } - Widget _buildStandardIcon() { - return FittedBox( - fit: widget.fit, - child: Icon( - Icons.smartphone, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ); - } - Widget _buildIcon(WearableIconVariant variant) { - if (widget.wearable is ThisDeviceWearable) { - return _buildStandardIcon(); - } - final path = _resolveIconPath(variant); if (path == null) { return _buildFallback(); diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index d0678f7a..10a1b1ad 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -89,6 +89,7 @@ flutter: assets: - lib/apps/posture_tracker/assets/ - lib/apps/heart_tracker/assets/ + - lib/assets/devices/phone-app.png - android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png # An image asset can refer to one or more resolution-specific "variants", seeq From 6e0f41e394f9c7307777246e1f7c06a46c7c0d70 Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:36:53 +0200 Subject: [PATCH 44/51] fix(devices): normalize Android host device brand casing --- .../lib/models/this_device_wearable.dart | 53 +++++++++++++++++-- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/open_wearable/lib/models/this_device_wearable.dart b/open_wearable/lib/models/this_device_wearable.dart index 865c8622..22a28262 100644 --- a/open_wearable/lib/models/this_device_wearable.dart +++ b/open_wearable/lib/models/this_device_wearable.dart @@ -272,10 +272,12 @@ class DeviceProfile { logger.d("Fetched Android device info: $info"); final displayName = _firstNonEmpty( [ - _joinNonEmpty([info.brand, info.model]), - info.model, - info.device, - info.product, + _formatAndroidDeviceDisplayName( + _joinNonEmpty([info.brand, info.model]), + ), + _formatAndroidDeviceDisplayName(info.model), + _formatAndroidDeviceDisplayName(info.device), + _formatAndroidDeviceDisplayName(info.product), ], 'Android Device', ); @@ -426,6 +428,49 @@ String? _joinNonEmpty(List parts) { return cleaned.join(' '); } +// Android's Build fields often expose manufacturer names in lowercase. Keep +// model tokens intact and only normalize known brand/manufacturer tokens. +String? _formatAndroidDeviceDisplayName(String? value) { + final trimmed = value?.trim(); + if (trimmed == null || trimmed.isEmpty) { + return null; + } + + return trimmed + .split(RegExp(r'\s+')) + .map(_formatAndroidDeviceDisplayNameWord) + .join(' '); +} + +// Returns canonical UI casing for common Android manufacturer tokens. +String _formatAndroidDeviceDisplayNameWord(String word) { + if (word.isEmpty || word != word.toLowerCase()) { + return word; + } + + return switch (word) { + 'google' => 'Google', + 'samsung' => 'Samsung', + 'oneplus' => 'OnePlus', + 'xiaomi' => 'Xiaomi', + 'huawei' => 'Huawei', + 'honor' => 'Honor', + 'motorola' => 'Motorola', + 'sony' => 'Sony', + 'oppo' => 'OPPO', + 'vivo' => 'vivo', + 'realme' => 'realme', + 'nokia' => 'Nokia', + 'fairphone' => 'Fairphone', + 'nothing' => 'Nothing', + 'asus' => 'ASUS', + 'lenovo' => 'Lenovo', + 'lg' => 'LG', + 'htc' => 'HTC', + _ => word, + }; +} + /// Adapts a `sensors_plus` event stream to the OpenEarable sensor interface. class ThisDeviceSensor extends Sensor { final DeviceSensorConfiguration config; From b4a126e2657ac2ee3b21b3104e5008bcc7ce5c60 Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:36:40 +0200 Subject: [PATCH 45/51] feat(devices): enhance device name display for current device --- .../widgets/devices/connect_devices_page.dart | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/open_wearable/lib/widgets/devices/connect_devices_page.dart b/open_wearable/lib/widgets/devices/connect_devices_page.dart index 30e2b2f9..bb453e12 100644 --- a/open_wearable/lib/widgets/devices/connect_devices_page.dart +++ b/open_wearable/lib/widgets/devices/connect_devices_page.dart @@ -181,7 +181,9 @@ class _ConnectDevicesPageState extends State { leading: Icon( isThisDevice ? Icons.smartphone : Icons.bluetooth, ), - title: PlatformText(_deviceName(device)), + title: PlatformText( + _deviceName(device, isThisDevice: isThisDevice), + ), subtitle: PlatformText(device.id), trailing: _buildTrailingWidget( device, @@ -342,10 +344,16 @@ class _ConnectDevicesPageState extends State { ); } - String _deviceName(DiscoveredDevice device) { + String _deviceName(DiscoveredDevice device, {required bool isThisDevice}) { final name = device.name.trim(); - if (name.isEmpty) return 'Unnamed device'; - return formatWearableDisplayName(name); + final displayName = + name.isEmpty ? 'Unnamed device' : formatWearableDisplayName(name); + + if (isThisDevice) { + return '$displayName (this device)'; + } + + return displayName; } String _formatScanTime(DateTime startedAt) { From 519b17df419c59ffed994b6edceec6532821dda6 Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:08:00 +0200 Subject: [PATCH 46/51] feat(connectors): track websocket network reachability --- .../lib/models/connector_settings.dart | 67 +++++++++++++++++-- .../connectors/websocket_ipc_server.dart | 7 +- .../models/network/device_ip_address_io.dart | 11 +-- 3 files changed, 72 insertions(+), 13 deletions(-) diff --git a/open_wearable/lib/models/connector_settings.dart b/open_wearable/lib/models/connector_settings.dart index 72fbf843..6ceb31fb 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/network/device_ip_address.dart'; import 'package:open_wearable/models/wearable_connector.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -51,32 +52,49 @@ enum ConnectorRuntimeState { class ConnectorRuntimeStatus { final ConnectorRuntimeState state; final String? message; + final bool hasReachableNetworkAddress; + final String? reachableNetworkAddress; const ConnectorRuntimeStatus({ required this.state, this.message, + this.hasReachableNetworkAddress = true, + this.reachableNetworkAddress, }); const ConnectorRuntimeStatus.disabled() : state = ConnectorRuntimeState.disabled, - message = null; + message = null, + hasReachableNetworkAddress = true, + reachableNetworkAddress = null; const ConnectorRuntimeStatus.starting() : state = ConnectorRuntimeState.starting, - message = null; - - const ConnectorRuntimeStatus.running() - : state = ConnectorRuntimeState.running, + message = null, + hasReachableNetworkAddress = true, + reachableNetworkAddress = null; + + const ConnectorRuntimeStatus.running({ + this.hasReachableNetworkAddress = true, + this.reachableNetworkAddress, + }) : state = ConnectorRuntimeState.running, message = null; const ConnectorRuntimeStatus.error(this.message) - : state = ConnectorRuntimeState.error; + : state = ConnectorRuntimeState.error, + hasReachableNetworkAddress = true, + reachableNetworkAddress = null; /// Whether the connector is currently enabled and participating in runtime /// work. bool get isActive => state == ConnectorRuntimeState.starting || state == ConnectorRuntimeState.running; + + /// Whether the active connector has enough runtime state to accept clients. + bool get isHealthy => + state == ConnectorRuntimeState.starting || + (state == ConnectorRuntimeState.running && hasReachableNetworkAddress); } /// Loads, normalizes, persists, and applies connector settings. @@ -87,6 +105,7 @@ class ConnectorSettings { static const String _websocketPathKey = 'connector_websocket_path'; static WebSocketIpcServer _webSocketServer = WebSocketIpcServer(); + static Timer? _networkStatusRefreshTimer; static final ValueNotifier _webSocketSettingsNotifier = ValueNotifier( @@ -127,6 +146,7 @@ class ConnectorSettings { /// Stops the running server and resets the runtime status. static Future dispose() async { + _stopNetworkStatusRefresh(); await _webSocketServer.stop(); _setRuntimeStatus(const ConnectorRuntimeStatus.disabled()); } @@ -171,6 +191,7 @@ class ConnectorSettings { _setWebSocketSettings(normalized); if (!normalized.enabled) { + _stopNetworkStatusRefresh(); await _webSocketServer.stop(); _setRuntimeStatus(const ConnectorRuntimeStatus.disabled()); return; @@ -183,13 +204,45 @@ class ConnectorSettings { port: normalized.port, path: normalized.path, ); - _setRuntimeStatus(const ConnectorRuntimeStatus.running()); + await _refreshRunningNetworkStatus(); + _startNetworkStatusRefresh(); } catch (error) { + _stopNetworkStatusRefresh(); _setRuntimeStatus(ConnectorRuntimeStatus.error(error.toString())); rethrow; } } + /// Refreshes the running connector's local-network reachability state. + static Future _refreshRunningNetworkStatus() async { + if (!_webSocketServer.isRunning) { + return; + } + final address = await resolveCurrentDeviceIpAddress(); + _webSocketServer.updateAdvertisedHost(address); + _setRuntimeStatus( + ConnectorRuntimeStatus.running( + hasReachableNetworkAddress: address != null, + reachableNetworkAddress: address, + ), + ); + } + + /// Keeps connector health current when Wi-Fi or network interfaces change. + static void _startNetworkStatusRefresh() { + _stopNetworkStatusRefresh(); + _networkStatusRefreshTimer = Timer.periodic( + const Duration(seconds: 5), + (_) => unawaited(_refreshRunningNetworkStatus()), + ); + } + + /// Stops periodic connector network-health checks. + static void _stopNetworkStatusRefresh() { + _networkStatusRefreshTimer?.cancel(); + _networkStatusRefreshTimer = null; + } + /// Normalizes persisted settings into a valid runtime configuration. static WebSocketConnectorSettings _normalizeWebSocketSettings( WebSocketConnectorSettings settings, diff --git a/open_wearable/lib/models/connectors/websocket_ipc_server.dart b/open_wearable/lib/models/connectors/websocket_ipc_server.dart index 2e9a1c6b..15358c5a 100644 --- a/open_wearable/lib/models/connectors/websocket_ipc_server.dart +++ b/open_wearable/lib/models/connectors/websocket_ipc_server.dart @@ -86,6 +86,11 @@ class WebSocketIpcServer implements CommandRuntime { ); } + /// Updates the client-facing host advertised by command responses. + void updateAdvertisedHost(String? host) { + _advertisedHost = host?.trim().isEmpty ?? true ? null : host!.trim(); + } + /// Starts the server with the provided port and path. Future start({ required int port, @@ -100,7 +105,7 @@ class WebSocketIpcServer implements CommandRuntime { ); _httpServer = await HttpServer.bind(_host, _port, shared: true); - _advertisedHost = await resolveCurrentDeviceIpAddress(); + updateAdvertisedHost(await resolveCurrentDeviceIpAddress()); logger.i( '[connector.websocket] listening address=${_httpServer!.address.address} port=${_httpServer!.port} path=$_path advertised_endpoint=${advertisedEndpoint?.toString() ?? 'unavailable'}', ); diff --git a/open_wearable/lib/models/network/device_ip_address_io.dart b/open_wearable/lib/models/network/device_ip_address_io.dart index 665cde90..341a60ae 100644 --- a/open_wearable/lib/models/network/device_ip_address_io.dart +++ b/open_wearable/lib/models/network/device_ip_address_io.dart @@ -2,8 +2,8 @@ import 'dart:io'; /// Resolves the best client-reachable IPv4 address for the current device. /// -/// The resolver prefers private LAN addresses on likely Wi-Fi or Ethernet -/// interfaces and de-prioritizes VPN, hotspot, or peer-to-peer interfaces. +/// The resolver returns private LAN addresses on likely Wi-Fi or Ethernet +/// interfaces and rejects cellular, VPN, hotspot, or peer-to-peer interfaces. Future resolveCurrentDeviceIpAddressImpl() async { final interfaces = await NetworkInterface.list( type: InternetAddressType.IPv4, @@ -11,7 +11,6 @@ Future resolveCurrentDeviceIpAddressImpl() async { ); _ResolvedAddress? bestMatch; - _ResolvedAddress? fallback; for (final interface in interfaces) { for (final address in interface.addresses) { final host = address.address.trim(); @@ -23,14 +22,14 @@ Future resolveCurrentDeviceIpAddressImpl() async { score: _scoreInterfaceAddress(interface.name, host), ); if (_isPrivateIpv4(host) && + resolved.isLikelyLanAddress && (bestMatch == null || resolved.score > bestMatch.score)) { bestMatch = resolved; } - fallback ??= resolved; } } - return (bestMatch ?? fallback)?.host; + return bestMatch?.host; } /// Returns whether [host] is within one of the standard private IPv4 ranges. @@ -86,4 +85,6 @@ class _ResolvedAddress { required this.host, required this.score, }); + + bool get isLikelyLanAddress => score >= 100; } From f28027e187575978957721c9817074192a473b17 Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:08:12 +0200 Subject: [PATCH 47/51] feat(connectors): move status pill into app bars --- open_wearable/lib/apps/widgets/apps_page.dart | 2 + .../widgets/connector_activity_indicator.dart | 217 +++--------------- .../lib/widgets/devices/devices_page.dart | 2 + .../widgets/global_app_banner_overlay.dart | 13 -- .../lib/widgets/home_page_overview.dart | 2 + .../lib/widgets/sensors/sensor_page.dart | 2 + .../lib/widgets/settings/connectors_page.dart | 32 ++- .../lib/widgets/settings/settings_page.dart | 2 + .../connector_activity_indicator_test.dart | 54 +++-- 9 files changed, 104 insertions(+), 222 deletions(-) diff --git a/open_wearable/lib/apps/widgets/apps_page.dart b/open_wearable/lib/apps/widgets/apps_page.dart index 2528a001..024e656c 100644 --- a/open_wearable/lib/apps/widgets/apps_page.dart +++ b/open_wearable/lib/apps/widgets/apps_page.dart @@ -9,6 +9,7 @@ import 'package:open_wearable/apps/widgets/app_compatibility.dart'; import 'package:open_wearable/apps/widgets/select_earable_view.dart'; import 'package:open_wearable/apps/widgets/app_tile.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; +import 'package:open_wearable/widgets/connector_activity_indicator.dart'; import 'package:open_wearable/widgets/recording_activity_indicator.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; import 'package:provider/provider.dart'; @@ -220,6 +221,7 @@ class AppsPage extends StatelessWidget { title: PlatformText("Apps"), trailingActions: [ const AppBarRecordingIndicator(), + const ConnectorActivityIndicator(), PlatformIconButton( icon: Icon(context.platformIcons.bluetooth), onPressed: () { diff --git a/open_wearable/lib/widgets/connector_activity_indicator.dart b/open_wearable/lib/widgets/connector_activity_indicator.dart index 9cfbffeb..5e5ab5d0 100644 --- a/open_wearable/lib/widgets/connector_activity_indicator.dart +++ b/open_wearable/lib/widgets/connector_activity_indicator.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -7,17 +5,14 @@ import 'package:open_wearable/models/connector_settings.dart'; import 'package:open_wearable/router.dart'; import 'package:open_wearable/widgets/connector_branding.dart'; -/// Compact global status chip shown while an external connector is active. -class ConnectorActivityIndicator extends StatefulWidget { +/// Compact status pill shown while an external connector is active. +class ConnectorActivityIndicator extends StatelessWidget { const ConnectorActivityIndicator({ super.key, this.statusListenable, this.onOpenSettings, }); - /// How long the indicator shows its expanded label before compacting. - static const Duration expandedDuration = Duration(seconds: 5); - /// Runtime status source. Tests may inject a notifier without touching the /// process-wide connector service. final ValueListenable? statusListenable; @@ -25,109 +20,14 @@ class ConnectorActivityIndicator extends StatefulWidget { /// Opens connector settings. Defaults to navigating through the app router. final VoidCallback? onOpenSettings; - @override - State createState() => - _ConnectorActivityIndicatorState(); -} - -class _ConnectorActivityIndicatorState - extends State { - late ValueListenable _statusListenable; - Timer? _collapseTimer; - bool _isExpanded = false; - bool _wasActive = false; - - @override - void initState() { - super.initState(); - _statusListenable = _resolveStatusListenable(); - _wasActive = _statusListenable.value.isActive; - _isExpanded = _wasActive; - _statusListenable.addListener(_handleStatusChanged); - if (_isExpanded) { - _scheduleCollapse(); - } - } - - @override - void didUpdateWidget(covariant ConnectorActivityIndicator oldWidget) { - super.didUpdateWidget(oldWidget); - final nextStatusListenable = _resolveStatusListenable(); - if (nextStatusListenable == _statusListenable) { - return; - } - - _statusListenable.removeListener(_handleStatusChanged); - _statusListenable = nextStatusListenable; - _statusListenable.addListener(_handleStatusChanged); - _syncStateWithStatus(_statusListenable.value); - } - - @override - void dispose() { - _collapseTimer?.cancel(); - _statusListenable.removeListener(_handleStatusChanged); - super.dispose(); - } - ValueListenable _resolveStatusListenable() { - return widget.statusListenable ?? + return statusListenable ?? ConnectorSettings.webSocketRuntimeStatusListenable; } - void _handleStatusChanged() { - _syncStateWithStatus(_statusListenable.value); - } - - void _syncStateWithStatus(ConnectorRuntimeStatus status) { - final isActive = status.isActive; - if (isActive == _wasActive) { - return; - } - - _wasActive = isActive; - if (!mounted) { - return; - } - - setState(() { - _isExpanded = isActive; - }); - - if (isActive) { - _scheduleCollapse(); - } else { - _collapseTimer?.cancel(); - _collapseTimer = null; - } - } - - void _scheduleCollapse() { - _collapseTimer?.cancel(); - _collapseTimer = Timer(ConnectorActivityIndicator.expandedDuration, () { - if (!mounted) { - return; - } - setState(() { - _isExpanded = false; - }); - }); - } - - void _expandTemporarily() { - if (!_statusListenable.value.isActive) { - return; - } - setState(() { - _isExpanded = true; - }); - _scheduleCollapse(); - } - void _openSettings() { - final onOpenSettings = widget.onOpenSettings; if (onOpenSettings != null) { - onOpenSettings(); + onOpenSettings!(); return; } @@ -137,95 +37,48 @@ class _ConnectorActivityIndicatorState @override Widget build(BuildContext context) { return ValueListenableBuilder( - valueListenable: _statusListenable, + valueListenable: _resolveStatusListenable(), builder: (context, status, _) { if (!status.isActive) { return const SizedBox.shrink(); } final colorScheme = Theme.of(context).colorScheme; - final foregroundColor = status.state == ConnectorRuntimeState.starting - ? colorScheme.onPrimaryContainer - : colorScheme.onTertiaryContainer; - final backgroundColor = status.state == ConnectorRuntimeState.starting - ? colorScheme.primaryContainer - : colorScheme.tertiaryContainer; - final label = status.state == ConnectorRuntimeState.starting - ? 'Connector starting' - : 'Connector active'; + final foregroundColor = + status.isHealthy ? const Color(0xFF1E6A3A) : colorScheme.error; + final label = status.isHealthy + ? 'Connector active' + : 'Connector active, Wi-Fi unavailable'; return Padding( - padding: const EdgeInsets.fromLTRB(10, 6, 10, 0), - child: Align( - alignment: AlignmentDirectional.topCenter, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: _expandTemporarily, - onLongPress: _openSettings, - child: Semantics( - button: true, - label: label, - liveRegion: true, - child: Semantics( - excludeSemantics: true, - child: Material( - color: Colors.transparent, - child: AnimatedSize( - duration: const Duration(milliseconds: 180), - curve: Curves.easeOutCubic, - child: DecoratedBox( - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: foregroundColor.withValues(alpha: 0.22), - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.08), - blurRadius: 12, - offset: const Offset(0, 4), - ), - ], - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - ConnectorBranding.icon, - size: 14, - color: foregroundColor, - ), - if (_isExpanded) ...[ - const SizedBox(width: 6), - Text( - ConnectorBranding.label, - style: Theme.of(context) - .textTheme - .labelSmall - ?.copyWith( - color: foregroundColor, - fontWeight: FontWeight.w800, - height: 1, - ) ?? - TextStyle( - color: foregroundColor, - fontWeight: FontWeight.w800, - height: 1, - ), - ), - ], - ], - ), - ), + padding: const EdgeInsetsDirectional.only(end: 2), + child: Tooltip( + message: label, + child: Semantics( + button: true, + label: label, + liveRegion: true, + child: InkWell( + customBorder: const StadiumBorder(), + onTap: _openSettings, + child: Container( + height: 32, + constraints: const BoxConstraints(minWidth: 40), + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: ShapeDecoration( + color: foregroundColor.withValues(alpha: 0.12), + shape: StadiumBorder( + side: BorderSide( + color: foregroundColor.withValues(alpha: 0.32), ), ), ), + alignment: Alignment.center, + child: Icon( + ConnectorBranding.icon, + size: 16, + color: foregroundColor, + ), ), ), ), diff --git a/open_wearable/lib/widgets/devices/devices_page.dart b/open_wearable/lib/widgets/devices/devices_page.dart index 2f7c6986..99b46db7 100644 --- a/open_wearable/lib/widgets/devices/devices_page.dart +++ b/open_wearable/lib/widgets/devices/devices_page.dart @@ -7,6 +7,7 @@ import 'package:open_wearable/models/wearable_connector.dart'; import 'package:open_wearable/models/wearable_display_group.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:open_wearable/widgets/common/no_devices_prompt.dart'; +import 'package:open_wearable/widgets/connector_activity_indicator.dart'; import 'package:open_wearable/widgets/devices/connect_devices_page.dart'; import 'package:open_wearable/widgets/devices/device_detail/audio_mode_widget.dart'; import 'package:open_wearable/widgets/devices/device_detail/device_detail_page.dart'; @@ -61,6 +62,7 @@ class _DevicesPageState extends State { title: PlatformText("Devices"), trailingActions: [ const AppBarRecordingIndicator(), + const ConnectorActivityIndicator(), PlatformIconButton( icon: Icon(context.platformIcons.bluetooth), onPressed: () { diff --git a/open_wearable/lib/widgets/global_app_banner_overlay.dart b/open_wearable/lib/widgets/global_app_banner_overlay.dart index a5322ae2..f53c1117 100644 --- a/open_wearable/lib/widgets/global_app_banner_overlay.dart +++ b/open_wearable/lib/widgets/global_app_banner_overlay.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../view_models/app_banner_controller.dart'; -import 'connector_activity_indicator.dart'; class GlobalAppBannerOverlay extends StatelessWidget { final Widget child; @@ -40,8 +39,6 @@ class GlobalAppBannerOverlay extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const ConnectorActivityIndicator(), - const SizedBox(height: 6), ...banners.map( (banner) => Padding( padding: const EdgeInsets.fromLTRB(10, 0, 10, 8), @@ -57,16 +54,6 @@ class GlobalAppBannerOverlay extends StatelessWidget { ), ), ), - if (!hasBanners) - const Positioned( - top: 0, - left: 0, - right: 0, - child: SafeArea( - bottom: false, - child: ConnectorActivityIndicator(), - ), - ), ], ), ); diff --git a/open_wearable/lib/widgets/home_page_overview.dart b/open_wearable/lib/widgets/home_page_overview.dart index 3e8c1f40..19edbdb5 100644 --- a/open_wearable/lib/widgets/home_page_overview.dart +++ b/open_wearable/lib/widgets/home_page_overview.dart @@ -5,6 +5,7 @@ import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_wearable/models/device_name_formatter.dart'; import 'package:open_wearable/view_models/sensor_recorder_provider.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; +import 'package:open_wearable/widgets/connector_activity_indicator.dart'; import 'package:open_wearable/widgets/devices/device_detail/device_detail_page.dart'; import 'package:open_wearable/widgets/recording_activity_indicator.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; @@ -31,6 +32,7 @@ class OverviewPage extends StatelessWidget { title: const Text('Overview'), trailingActions: [ const AppBarRecordingIndicator(), + const ConnectorActivityIndicator(), PlatformIconButton( icon: Icon(context.platformIcons.bluetooth), onPressed: onConnectRequested, diff --git a/open_wearable/lib/widgets/sensors/sensor_page.dart b/open_wearable/lib/widgets/sensors/sensor_page.dart index 6b5fdf86..9fd9455e 100644 --- a/open_wearable/lib/widgets/sensors/sensor_page.dart +++ b/open_wearable/lib/widgets/sensors/sensor_page.dart @@ -5,6 +5,7 @@ import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_wearable/view_models/sensor_data_provider.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:open_wearable/widgets/common/no_devices_prompt.dart'; +import 'package:open_wearable/widgets/connector_activity_indicator.dart'; import 'package:open_wearable/widgets/recording_activity_indicator.dart'; import 'package:open_wearable/widgets/sensors/configuration/sensor_configuration_view.dart'; import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_view.dart'; @@ -124,6 +125,7 @@ class _SensorPageState extends State title: PlatformText("Sensors"), actions: [ const AppBarRecordingIndicator(), + const ConnectorActivityIndicator(), PlatformIconButton( icon: Icon(context.platformIcons.bluetooth), onPressed: () { diff --git a/open_wearable/lib/widgets/settings/connectors_page.dart b/open_wearable/lib/widgets/settings/connectors_page.dart index cad0633b..efc6c7d7 100644 --- a/open_wearable/lib/widgets/settings/connectors_page.dart +++ b/open_wearable/lib/widgets/settings/connectors_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:open_wearable/models/connector_settings.dart'; import 'package:open_wearable/models/network/device_ip_address.dart'; @@ -16,6 +17,7 @@ class ConnectorsPage extends StatefulWidget { class _ConnectorsPageState extends State { late final TextEditingController _portController; late final TextEditingController _pathController; + late final ValueListenable _runtimeStatusListenable; bool _enabled = false; bool _isLoading = true; @@ -29,16 +31,33 @@ class _ConnectorsPageState extends State { super.initState(); _portController = TextEditingController(); _pathController = TextEditingController(); + _runtimeStatusListenable = + ConnectorSettings.webSocketRuntimeStatusListenable; + _runtimeStatusListenable.addListener(_syncCurrentIpAddress); _loadSettings(); } @override void dispose() { + _runtimeStatusListenable.removeListener(_syncCurrentIpAddress); _portController.dispose(); _pathController.dispose(); super.dispose(); } + void _syncCurrentIpAddress() { + final status = _runtimeStatusListenable.value; + if (status.state != ConnectorRuntimeState.running) { + return; + } + if (_currentIpAddress == status.reachableNetworkAddress) { + return; + } + setState(() { + _currentIpAddress = status.reachableNetworkAddress; + }); + } + Future _loadSettings() async { try { final settingsFuture = ConnectorSettings.loadWebSocketSettings(); @@ -275,8 +294,7 @@ class _ConnectorsPageState extends State { valueListenable: ConnectorSettings.webSocketSettingsListenable, builder: (context, appliedSettings, _) { return ValueListenableBuilder( - valueListenable: - ConnectorSettings.webSocketRuntimeStatusListenable, + valueListenable: _runtimeStatusListenable, builder: (context, runtimeStatus, __) { final pending = _hasPendingChanges(appliedSettings); return ListView( @@ -321,7 +339,8 @@ class _ConnectorsPageState extends State { }) { final colorScheme = Theme.of(context).colorScheme; final statusColor = switch (runtimeStatus.state) { - ConnectorRuntimeState.running => const Color(0xFF1E6A3A), + ConnectorRuntimeState.running => + runtimeStatus.isHealthy ? const Color(0xFF1E6A3A) : colorScheme.error, ConnectorRuntimeState.starting => colorScheme.primary, ConnectorRuntimeState.error => colorScheme.error, ConnectorRuntimeState.disabled => colorScheme.onSurfaceVariant, @@ -502,11 +521,16 @@ class _StatusChip extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; final (title, detail, foreground) = switch (status.state) { - ConnectorRuntimeState.running => ( + ConnectorRuntimeState.running when status.hasReachableNetworkAddress => ( 'Running', endpoint, const Color(0xFF1E6A3A), ), + ConnectorRuntimeState.running => ( + 'Wi-Fi unavailable', + 'Connector is on, but no local network address is available.', + colorScheme.error, + ), ConnectorRuntimeState.starting => ( 'Starting', endpoint, diff --git a/open_wearable/lib/widgets/settings/settings_page.dart b/open_wearable/lib/widgets/settings/settings_page.dart index 327ec62f..dccdf7c3 100644 --- a/open_wearable/lib/widgets/settings/settings_page.dart +++ b/open_wearable/lib/widgets/settings/settings_page.dart @@ -8,6 +8,7 @@ import 'package:open_wearable/models/app_upgrade_registry.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:open_wearable/widgets/app_toast.dart'; +import 'package:open_wearable/widgets/connector_activity_indicator.dart'; import 'package:open_wearable/widgets/connector_branding.dart'; import 'package:open_wearable/widgets/recording_activity_indicator.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; @@ -33,6 +34,7 @@ class SettingsPage extends StatelessWidget { title: const Text('Settings'), trailingActions: [ const AppBarRecordingIndicator(), + const ConnectorActivityIndicator(), PlatformIconButton( icon: Icon(context.platformIcons.bluetooth), onPressed: onConnectRequested, diff --git a/open_wearable/test/widgets/connector_activity_indicator_test.dart b/open_wearable/test/widgets/connector_activity_indicator_test.dart index 0c902c54..e081a2f5 100644 --- a/open_wearable/test/widgets/connector_activity_indicator_test.dart +++ b/open_wearable/test/widgets/connector_activity_indicator_test.dart @@ -22,27 +22,27 @@ void main() { ); expect(find.text('Connector'), findsNothing); + expect(find.byIcon(ConnectorBranding.icon), findsNothing); statusNotifier.value = const ConnectorRuntimeStatus.starting(); await tester.pump(); - expect(find.text('Connector'), findsOneWidget); + expect(find.text('Connector'), findsNothing); + expect(find.byIcon(ConnectorBranding.icon), findsOneWidget); statusNotifier.value = const ConnectorRuntimeStatus.running(); await tester.pump(); - expect(find.text('Connector'), findsOneWidget); - expect( - tester.getCenter(find.text('Connector')).dx, - closeTo(tester.getSize(find.byType(MaterialApp)).width / 2, 60), - ); + expect(find.text('Connector'), findsNothing); + expect(find.byIcon(ConnectorBranding.icon), findsOneWidget); statusNotifier.value = const ConnectorRuntimeStatus.error('failed'); await tester.pump(); expect(find.text('Connector'), findsNothing); + expect(find.byIcon(ConnectorBranding.icon), findsNothing); }); - testWidgets('compacts after delay and expands again on tap', (tester) async { + testWidgets('uses red styling when connector lacks Wi-Fi', (tester) async { final statusNotifier = ValueNotifier( - const ConnectorRuntimeStatus.running(), + const ConnectorRuntimeStatus.running(hasReachableNetworkAddress: false), ); addTearDown(statusNotifier.dispose); @@ -56,25 +56,33 @@ void main() { ), ); - expect(find.text('Connector'), findsOneWidget); - - await tester.pump(ConnectorActivityIndicator.expandedDuration); - - expect(find.text('Connector'), findsNothing); - expect(find.byIcon(ConnectorBranding.icon), findsOneWidget); - - await tester.tap(find.byIcon(ConnectorBranding.icon)); - await tester.pump(); + final icon = tester.widget(find.byIcon(ConnectorBranding.icon)); + expect(icon.color, ThemeData().colorScheme.error); + }); - expect(find.text('Connector'), findsOneWidget); + testWidgets('describes missing Wi-Fi in tooltip semantics', (tester) async { + final statusNotifier = ValueNotifier( + const ConnectorRuntimeStatus.running(hasReachableNetworkAddress: false), + ); + addTearDown(statusNotifier.dispose); - await tester.pump(ConnectorActivityIndicator.expandedDuration); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ConnectorActivityIndicator( + statusListenable: statusNotifier, + ), + ), + ), + ); - expect(find.text('Connector'), findsNothing); - expect(find.byIcon(ConnectorBranding.icon), findsOneWidget); + expect( + find.byTooltip('Connector active, Wi-Fi unavailable'), + findsOneWidget, + ); }); - testWidgets('opens connector settings on long press', (tester) async { + testWidgets('opens connector settings on tap', (tester) async { var settingsOpenCount = 0; final statusNotifier = ValueNotifier( const ConnectorRuntimeStatus.running(), @@ -92,7 +100,7 @@ void main() { ), ); - await tester.longPress(find.byIcon(ConnectorBranding.icon)); + await tester.tap(find.byIcon(ConnectorBranding.icon)); await tester.pump(); expect(settingsOpenCount, 1); From 80bbcb0d442ebb9979a7944d9ea37a3f7962b93a Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:21:32 +0200 Subject: [PATCH 48/51] feat(connectors): link websocket documentation --- .../lib/widgets/settings/connectors_page.dart | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/open_wearable/lib/widgets/settings/connectors_page.dart b/open_wearable/lib/widgets/settings/connectors_page.dart index efc6c7d7..9e8c9437 100644 --- a/open_wearable/lib/widgets/settings/connectors_page.dart +++ b/open_wearable/lib/widgets/settings/connectors_page.dart @@ -6,6 +6,7 @@ import 'package:open_wearable/models/network/device_ip_address.dart'; import 'package:open_wearable/widgets/app_toast.dart'; import 'package:open_wearable/widgets/connector_branding.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; +import 'package:url_launcher/url_launcher.dart'; class ConnectorsPage extends StatefulWidget { const ConnectorsPage({super.key}); @@ -15,6 +16,10 @@ class ConnectorsPage extends StatefulWidget { } class _ConnectorsPageState extends State { + static final Uri _webSocketDocumentationUri = Uri.parse( + 'https://github.com/OpenEarable/app/blob/main/open_wearable/docs/connectors/websocket-ipc-api.md', + ); + late final TextEditingController _portController; late final TextEditingController _pathController; late final ValueListenable _runtimeStatusListenable; @@ -127,6 +132,23 @@ class _ConnectorsPageState extends State { } } + Future _openWebSocketDocumentation() async { + final opened = await launchUrl( + _webSocketDocumentationUri, + mode: LaunchMode.externalApplication, + ); + if (opened || !mounted) { + return; + } + + AppToast.show( + context, + message: 'Could not open WebSocket connector documentation.', + type: AppToastType.error, + icon: Icons.link_off_rounded, + ); + } + Future _saveSettings() async { if (_isSaving) { return; @@ -415,6 +437,14 @@ class _ConnectorsPageState extends State { ), ], ), + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton.icon( + onPressed: _openWebSocketDocumentation, + icon: const Icon(Icons.open_in_new_rounded, size: 16), + label: const Text('View documentation'), + ), + ), const SizedBox(height: 10), InputDecorator( decoration: InputDecoration( From b5b0a853292ef94ae97fef49bf72e4b52159e889 Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:11:08 +0200 Subject: [PATCH 49/51] fix(connectors): update status messages for network availability --- open_wearable/lib/widgets/connector_activity_indicator.dart | 2 +- open_wearable/lib/widgets/settings/connectors_page.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/open_wearable/lib/widgets/connector_activity_indicator.dart b/open_wearable/lib/widgets/connector_activity_indicator.dart index 5e5ab5d0..67bd2b32 100644 --- a/open_wearable/lib/widgets/connector_activity_indicator.dart +++ b/open_wearable/lib/widgets/connector_activity_indicator.dart @@ -48,7 +48,7 @@ class ConnectorActivityIndicator extends StatelessWidget { status.isHealthy ? const Color(0xFF1E6A3A) : colorScheme.error; final label = status.isHealthy ? 'Connector active' - : 'Connector active, Wi-Fi unavailable'; + : 'Connector active, network unavailable'; return Padding( padding: const EdgeInsetsDirectional.only(end: 2), diff --git a/open_wearable/lib/widgets/settings/connectors_page.dart b/open_wearable/lib/widgets/settings/connectors_page.dart index 9e8c9437..c65dfc09 100644 --- a/open_wearable/lib/widgets/settings/connectors_page.dart +++ b/open_wearable/lib/widgets/settings/connectors_page.dart @@ -557,7 +557,7 @@ class _StatusChip extends StatelessWidget { const Color(0xFF1E6A3A), ), ConnectorRuntimeState.running => ( - 'Wi-Fi unavailable', + 'Network unavailable', 'Connector is on, but no local network address is available.', colorScheme.error, ), From 9f52ea4bb348d490b3ed296a1f2a9680ed4de757 Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:15:58 +0200 Subject: [PATCH 50/51] docs(connectors): clarify usage of open-wearables package for Python clients --- open_wearable/docs/connectors/websocket-ipc-api.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/open_wearable/docs/connectors/websocket-ipc-api.md b/open_wearable/docs/connectors/websocket-ipc-api.md index 4e78b951..ccc6abea 100644 --- a/open_wearable/docs/connectors/websocket-ipc-api.md +++ b/open_wearable/docs/connectors/websocket-ipc-api.md @@ -2,6 +2,9 @@ This document describes how to communicate with the OpenWearable WebSocket connector. +Python clients can use the [`open-wearables`](https://pypi.org/project/open-wearables/) +package instead of implementing the JSON WebSocket protocol directly. + ## Endpoint Default endpoint: From ad0dcd46486e3bde19f8019c84027d9147178383 Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:31:51 +0200 Subject: [PATCH 51/51] feat(release): add 1.2.0 whats new page --- .../lib/models/app_upgrade_registry.dart | 45 +++++++++++++++++++ open_wearable/pubspec.yaml | 2 +- .../models/app_upgrade_registry_test.dart | 15 +++++++ 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 open_wearable/test/models/app_upgrade_registry_test.dart diff --git a/open_wearable/lib/models/app_upgrade_registry.dart b/open_wearable/lib/models/app_upgrade_registry.dart index 8808a591..b3e7cc04 100644 --- a/open_wearable/lib/models/app_upgrade_registry.dart +++ b/open_wearable/lib/models/app_upgrade_registry.dart @@ -58,6 +58,51 @@ class AppUpgradeRegistry { ), ], ), + AppUpgradeHighlight( + version: '1.2.0', + eyebrow: 'OpenWearables 1.2.0', + title: 'Automate OpenWearables\nwith the new connector', + summary: + 'Control the app over WebSocket and use your phone as a sensor source.', + heroDescription: + 'OpenWearables now includes a connector that exposes app control through a WebSocket API. ' + 'The Python API builds on that connection, making scripted workflows, external tools, ' + 'and repeatable automation possible.', + accentColor: Color(0xFF2F7D6D), + useHeroGradient: false, + features: [ + AppUpgradeFeatureHighlight( + icon: Icons.hub_rounded, + title: 'Brand-new connector', + description: + 'Control app behavior remotely through the WebSocket connector for automation and integration workflows.', + ), + AppUpgradeFeatureHighlight( + icon: Icons.code_rounded, + title: 'Python API support', + description: + 'Use the Python API on top of the connector to script app interactions from external tools.', + ), + AppUpgradeFeatureHighlight( + icon: Icons.wifi_tethering_rounded, + title: 'Automation-ready access', + description: + 'Build repeatable experiments, demos, and integrations without driving every action through the UI.', + ), + AppUpgradeFeatureHighlight( + icon: Icons.phone_iphone_rounded, + title: 'Phone as a sensor device', + description: + 'Use the phone running the app as a local sensor source for supported motion data workflows.', + ), + AppUpgradeFeatureHighlight( + icon: Icons.tune_rounded, + title: 'Configurable local sensors', + description: + 'Phone sensor streams support configurable sampling options and start with sampling disabled until selected.', + ), + ], + ), ]; /// Returns the configured highlight for [version], if any. diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index 10a1b1ad..510d1c8b 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.1.1+1 +version: 1.2.0+1 environment: sdk: ^3.6.0 diff --git a/open_wearable/test/models/app_upgrade_registry_test.dart b/open_wearable/test/models/app_upgrade_registry_test.dart new file mode 100644 index 00000000..e6970637 --- /dev/null +++ b/open_wearable/test/models/app_upgrade_registry_test.dart @@ -0,0 +1,15 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_wearable/models/app_upgrade_registry.dart'; + +void main() { + group('AppUpgradeRegistry', () { + test('registers version 1.2.0 as the latest upgrade highlight', () { + final highlight = AppUpgradeRegistry.forVersion('1.2.0'); + + expect(highlight, isNotNull); + expect(highlight?.version, '1.2.0'); + expect(AppUpgradeRegistry.latest?.version, '1.2.0'); + expect(AppUpgradeRegistry.all.first.version, '1.2.0'); + }); + }); +}