Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions open_wearable/docs/connectors/websocket-ipc-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions open_wearable/lib/apps/widgets/apps_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -220,6 +221,7 @@ class AppsPage extends StatelessWidget {
title: PlatformText("Apps"),
trailingActions: [
const AppBarRecordingIndicator(),
const ConnectorActivityIndicator(),
PlatformIconButton(
icon: Icon(context.platformIcons.bluetooth),
onPressed: () {
Expand Down
67 changes: 60 additions & 7 deletions open_wearable/lib/models/connector_settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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.
Expand All @@ -87,6 +105,7 @@ class ConnectorSettings {
static const String _websocketPathKey = 'connector_websocket_path';

static WebSocketIpcServer _webSocketServer = WebSocketIpcServer();
static Timer? _networkStatusRefreshTimer;

static final ValueNotifier<WebSocketConnectorSettings>
_webSocketSettingsNotifier = ValueNotifier<WebSocketConnectorSettings>(
Expand Down Expand Up @@ -127,6 +146,7 @@ class ConnectorSettings {

/// Stops the running server and resets the runtime status.
static Future<void> dispose() async {
_stopNetworkStatusRefresh();
await _webSocketServer.stop();
_setRuntimeStatus(const ConnectorRuntimeStatus.disabled());
}
Expand Down Expand Up @@ -171,6 +191,7 @@ class ConnectorSettings {
_setWebSocketSettings(normalized);

if (!normalized.enabled) {
_stopNetworkStatusRefresh();
await _webSocketServer.stop();
_setRuntimeStatus(const ConnectorRuntimeStatus.disabled());
return;
Expand All @@ -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<void> _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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> start({
required int port,
Expand All @@ -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'}',
);
Expand Down
11 changes: 6 additions & 5 deletions open_wearable/lib/models/network/device_ip_address_io.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,15 @@ 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<String?> resolveCurrentDeviceIpAddressImpl() async {
final interfaces = await NetworkInterface.list(
type: InternetAddressType.IPv4,
includeLoopback: false,
);

_ResolvedAddress? bestMatch;
_ResolvedAddress? fallback;
for (final interface in interfaces) {
for (final address in interface.addresses) {
final host = address.address.trim();
Expand All @@ -23,14 +22,14 @@ Future<String?> 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.
Expand Down Expand Up @@ -86,4 +85,6 @@ class _ResolvedAddress {
required this.host,
required this.score,
});

bool get isLikelyLanAddress => score >= 100;
}
Loading
Loading