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/docs/connectors/websocket-ipc-api.md b/open_wearable/docs/connectors/websocket-ipc-api.md new file mode 100644 index 00000000..4e78b951 --- /dev/null +++ b/open_wearable/docs/connectors/websocket-ipc-api.md @@ -0,0 +1,251 @@ +# WebSocket IPC API + +This document describes how to communicate with the OpenWearable WebSocket connector. + +## Endpoint + +Default endpoint: + +- `ws://:8765/ws` + +Notes: + +- 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 + +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", "..."], + "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. +- `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}` | +| `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,"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 | + +## 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. + +## Audio Playback Over WebSocket + +The connector supports distinct preloaded sounds (store once, play many times). + +### 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_sound` requires `sound_id`. + +## 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. + +### Distinct sound playback + +1. `store_sound` with `sound_id` and `audio_base64`. +2. `play_sound` with the same `sound_id`. 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/main.dart b/open_wearable/lib/main.dart index d0a34cce..0c9e6108 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' @@ -34,10 +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( + wearableConnector: wearableConnector, + ); runApp( MultiProvider( @@ -56,7 +61,7 @@ void main() async { return provider; }, ), - Provider.value(value: WearableConnector()), + Provider.value(value: wearableConnector), ChangeNotifierProvider( create: (context) => AppBannerController(), ), @@ -722,6 +727,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/models/connector_settings.dart b/open_wearable/lib/models/connector_settings.dart new file mode 100644 index 00000000..72fbf843 --- /dev/null +++ b/open_wearable/lib/models/connector_settings.dart @@ -0,0 +1,227 @@ +// ignore_for_file: cancel_subscriptions + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:open_wearable/models/wearable_connector.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'connectors/websocket_ipc_server.dart'; + +/// Persisted configuration for the network connector. +class WebSocketConnectorSettings { + final bool enabled; + final int port; + final String path; + + const WebSocketConnectorSettings({ + required this.enabled, + required this.port, + required this.path, + }); + + const WebSocketConnectorSettings.defaults() + : enabled = false, + port = WebSocketIpcServer.defaultPort, + path = WebSocketIpcServer.defaultPath; + + /// Returns a copy with selectively replaced fields. + WebSocketConnectorSettings copyWith({ + bool? enabled, + int? port, + String? path, + }) { + return WebSocketConnectorSettings( + enabled: enabled ?? this.enabled, + port: port ?? this.port, + path: path ?? this.path, + ); + } +} + +/// High-level runtime state of the connector server. +enum ConnectorRuntimeState { + disabled, + starting, + running, + error, +} + +/// Snapshot of the current connector runtime state and message. +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; + + /// 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. +class ConnectorSettings { + static const String _websocketEnabledKey = 'connector_websocket_enabled'; + static const String _websocketHostKey = 'connector_websocket_host'; + static const String _websocketPortKey = 'connector_websocket_port'; + static const String _websocketPathKey = 'connector_websocket_path'; + + static WebSocketIpcServer _webSocketServer = WebSocketIpcServer(); + + static final ValueNotifier + _webSocketSettingsNotifier = ValueNotifier( + const WebSocketConnectorSettings.defaults(), + ); + + static final ValueNotifier + _webSocketRuntimeStatusNotifier = ValueNotifier( + const ConnectorRuntimeStatus.disabled(), + ); + + static ValueListenable + get webSocketSettingsListenable => _webSocketSettingsNotifier; + + static ValueListenable + get webSocketRuntimeStatusListenable => _webSocketRuntimeStatusNotifier; + + /// 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 { + if (wearableConnector != null) { + _webSocketServer = WebSocketIpcServer( + wearableConnector: wearableConnector, + ); + } + final settings = await loadWebSocketSettings(); + 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, + port: prefs.getInt(_websocketPortKey) ?? WebSocketIpcServer.defaultPort, + path: + prefs.getString(_websocketPathKey) ?? WebSocketIpcServer.defaultPath, + ); + + final normalized = _normalizeWebSocketSettings(raw); + _setWebSocketSettings(normalized); + return normalized; + } + + /// Saves websocket settings, removes deprecated host state, and applies them. + static Future saveWebSocketSettings( + WebSocketConnectorSettings settings, + ) async { + final normalized = _normalizeWebSocketSettings(settings); + final prefs = await SharedPreferences.getInstance(); + + await prefs.setBool(_websocketEnabledKey, normalized.enabled); + 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) { + await _webSocketServer.stop(); + _setRuntimeStatus(const ConnectorRuntimeStatus.disabled()); + return; + } + + _setRuntimeStatus(const ConnectorRuntimeStatus.starting()); + + try { + await _webSocketServer.start( + port: normalized.port, + path: normalized.path, + ); + _setRuntimeStatus(const ConnectorRuntimeStatus.running()); + } catch (error) { + _setRuntimeStatus(ConnectorRuntimeStatus.error(error.toString())); + rethrow; + } + } + + /// Normalizes persisted settings into a valid runtime configuration. + static WebSocketConnectorSettings _normalizeWebSocketSettings( + WebSocketConnectorSettings settings, + ) { + final port = (settings.port > 0 && settings.port <= 65535) + ? settings.port + : WebSocketIpcServer.defaultPort; + final path = _normalizePath(settings.path); + + return settings.copyWith( + 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) { + return WebSocketIpcServer.defaultPath; + } + 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/audio_playback_config.dart b/open_wearable/lib/models/connectors/audio_playback_config.dart new file mode 100644 index 00000000..11f97496 --- /dev/null +++ b/open_wearable/lib/models/connectors/audio_playback_config.dart @@ -0,0 +1,152 @@ +class AudioPlaybackConfig { + final String codec; + final int sampleRate; + final int numChannels; + final bool interleaved; + final int bufferSize; + + const AudioPlaybackConfig({ + this.codec = 'default', + this.sampleRate = 16000, + this.numChannels = 1, + this.interleaved = true, + this.bufferSize = 8192, + }); + + AudioPlaybackConfig copyWith({ + String? 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, + 'sample_rate': sampleRate, + 'num_channels': numChannels, + 'interleaved': interleaved, + 'buffer_size': bufferSize, + }; + } + + String get normalizedCodec => _normalizeCodec(codec); + + static AudioPlaybackConfig? fromOptional({ + String? codecKey, + int? sampleRate, + int? numChannels, + bool? interleaved, + int? bufferSize, + }) { + if (codecKey == null && + sampleRate == null && + numChannels == null && + interleaved == null && + bufferSize == null) { + return null; + } + + final resolvedCodec = codecKey == null ? 'default' : _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: resolvedCodec, + sampleRate: resolvedSampleRate, + numChannels: resolvedNumChannels, + interleaved: interleaved ?? true, + bufferSize: resolvedBufferSize, + ); + } + + static String _parseCodec(String input) { + final normalized = _normalizeCodec(input); + switch (normalized) { + case 'default': + case 'aacadts': + case 'opusogg': + case 'opuscaf': + case 'mp3': + case 'vorbisogg': + case 'pcm16': + case 'pcm16wav': + case 'pcm16aiff': + case 'pcm16caf': + case 'flac': + case 'aacmp4': + case 'amrnb': + case 'amrwb': + case 'pcm8': + case 'pcmfloat32': + case 'pcmwebm': + case 'opuswebm': + case 'vorbiswebm': + case '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, sampleRate: $sampleRate, numChannels: $numChannels, interleaved: $interleaved, bufferSize: $bufferSize)'; + } +} diff --git a/open_wearable/lib/models/connectors/commands/async_scan_command.dart b/open_wearable/lib/models/connectors/commands/async_scan_command.dart new file mode 100644 index 00000000..80125325 --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/async_scan_command.dart @@ -0,0 +1,42 @@ +import 'command.dart'; +import 'ipc_internal_param_names.dart'; +import 'param_readers.dart'; +import 'runtime_command.dart'; + +class AsyncScanCommand extends RuntimeCommand { + AsyncScanCommand({required super.runtime}) + : super( + name: 'start_scan_async', + params: [ + CommandParam(name: 'check_and_request_permissions'), + CommandParam(name: sessionParamName, required: true), + ], + ); + + @override + Future> execute(List params) async { + final session = requireParam(params, sessionParamName); + final checkAndRequestPermissions = + readOptionalBoolParam(params, 'check_and_request_permissions') ?? true; + + await runtime.startScan( + checkAndRequestPermissions: checkAndRequestPermissions, + ); + + final subscriptionId = await runtime.createSubscriptionId(); + await runtime.attachStreamSubscription( + session: session, + subscriptionId: subscriptionId, + streamName: 'scan', + deviceId: 'scanner', + stream: runtime.scanEvents, + ); + + return { + 'started': true, + 'subscription_id': subscriptionId, + 'stream': 'scan', + 'device_id': 'scanner', + }; + } +} diff --git a/open_wearable/lib/models/connectors/commands/check_and_request_permissions_command.dart b/open_wearable/lib/models/connectors/commands/check_and_request_permissions_command.dart new file mode 100644 index 00000000..a5fde7e8 --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/check_and_request_permissions_command.dart @@ -0,0 +1,12 @@ +import 'command.dart'; +import 'runtime_command.dart'; + +class CheckAndRequestPermissionsCommand extends RuntimeCommand { + CheckAndRequestPermissionsCommand({required super.runtime}) + : super(name: 'check_and_request_permissions'); + + @override + Future execute(List params) { + return runtime.checkAndRequestPermissions(); + } +} diff --git a/open_wearable/lib/models/connectors/commands/command.dart b/open_wearable/lib/models/connectors/commands/command.dart new file mode 100644 index 00000000..1506805a --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/command.dart @@ -0,0 +1,95 @@ +import '../../logger.dart'; + +class CommandParam { + final String name; + final T? value; + final bool required; + + CommandParam({ + required this.name, + this.value, + this.required = false, + }); +} + +abstract class Command { + final String name; + final List params; + + Command({required this.name, this.params = const []}); + + T requireParam(List params, String paramName) { + final param = params.firstWhere( + (p) => p.name == paramName, + orElse: () => + throw ArgumentError('Missing required parameter: $paramName'), + ); + if (param.value == null) { + throw ArgumentError('Parameter $paramName cannot be null'); + } + return param.value as T; + } + + Future run(List params) async { + final startedAt = DateTime.now(); + logger.d( + '[connector.command] start name=$name params=${_formatParams(params)}', + ); + for (final param in this.params) { + if (param.required) { + final providedParam = params.firstWhere( + (p) => p.name == param.name, + orElse: () => throw ArgumentError( + 'Missing required parameter: ${param.name}', + ), + ); + if (providedParam.value == null) { + throw ArgumentError('Parameter ${param.name} cannot be null'); + } + } + } + try { + final result = await execute(params); + final durationMs = DateTime.now().difference(startedAt).inMilliseconds; + logger.d( + '[connector.command] done name=$name duration_ms=$durationMs', + ); + return result; + } catch (error, stackTrace) { + final durationMs = DateTime.now().difference(startedAt).inMilliseconds; + logger.w( + '[connector.command] failed name=$name duration_ms=$durationMs error=$error\n$stackTrace', + ); + rethrow; + } + } + + Future execute(List params); + + String _formatParams(List params) { + final map = {}; + for (final param in params) { + if (param.name.startsWith('__')) { + continue; + } + map[param.name] = _loggableValue(param.value); + } + return map.toString(); + } + + Object? _loggableValue(Object? value) { + if (value == null || value is num || value is bool || value is String) { + return value; + } + if (value is List) { + return value.map(_loggableValue).toList(growable: false); + } + if (value is Map) { + return value.map( + (key, nestedValue) => + MapEntry(key.toString(), _loggableValue(nestedValue)), + ); + } + return value.runtimeType.toString(); + } +} diff --git a/open_wearable/lib/models/connectors/commands/connect_command.dart b/open_wearable/lib/models/connectors/commands/connect_command.dart new file mode 100644 index 00000000..e43df7e9 --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/connect_command.dart @@ -0,0 +1,23 @@ +import 'command.dart'; +import 'param_readers.dart'; +import 'runtime_command.dart'; + +class ConnectCommand extends RuntimeCommand { + ConnectCommand({required super.runtime}) + : super( + name: 'connect', + params: [ + CommandParam(name: 'device_id', required: true), + CommandParam(name: 'connected_via_system'), + ], + ); + + @override + Future> execute(List params) { + return runtime.connect( + deviceId: requireStringParam(params, 'device_id'), + connectedViaSystem: + readOptionalBoolParam(params, 'connected_via_system') ?? false, + ); + } +} diff --git a/open_wearable/lib/models/connectors/commands/connect_system_devices_command.dart b/open_wearable/lib/models/connectors/commands/connect_system_devices_command.dart new file mode 100644 index 00000000..60abf1e9 --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/connect_system_devices_command.dart @@ -0,0 +1,21 @@ +import 'command.dart'; +import 'param_readers.dart'; +import 'runtime_command.dart'; + +class ConnectSystemDevicesCommand extends RuntimeCommand { + ConnectSystemDevicesCommand({required super.runtime}) + : super( + name: 'connect_system_devices', + params: [ + CommandParam>(name: 'ignored_device_ids'), + ], + ); + + @override + Future>> execute(List params) { + return runtime.connectSystemDevices( + ignoredDeviceIds: + readOptionalStringListParam(params, 'ignored_device_ids'), + ); + } +} diff --git a/open_wearable/lib/models/connectors/commands/default_action_commands.dart b/open_wearable/lib/models/connectors/commands/default_action_commands.dart new file mode 100644 index 00000000..511e0c7a --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/default_action_commands.dart @@ -0,0 +1,17 @@ +import 'command.dart'; +import 'disconnect_command.dart'; +import 'list_sensor_configs_command.dart'; +import 'list_sensors_command.dart'; +import 'runtime.dart'; +import 'set_sensor_config_command.dart'; +import 'sync_time_command.dart'; + +List createDefaultActionCommands(CommandRuntime runtime) { + return [ + DisconnectCommand(runtime: runtime), + SyncTimeCommand(runtime: runtime), + ListSensorsCommand(runtime: runtime), + ListSensorConfigsCommand(runtime: runtime), + SetSensorConfigCommand(runtime: runtime), + ]; +} diff --git a/open_wearable/lib/models/connectors/commands/default_ipc_commands.dart b/open_wearable/lib/models/connectors/commands/default_ipc_commands.dart new file mode 100644 index 00000000..87ecd6a4 --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/default_ipc_commands.dart @@ -0,0 +1,39 @@ +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 '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 'runtime.dart'; +import 'start_scan_command.dart'; +import 'store_sound_command.dart'; +import 'subscribe_command.dart'; +import 'unsubscribe_command.dart'; + +List createDefaultIpcCommands(CommandRuntime runtime) { + return [ + PingCommand(), + MethodsCommand(runtime: runtime), + HasPermissionsCommand(runtime: runtime), + CheckAndRequestPermissionsCommand(runtime: runtime), + StartScanCommand(runtime: runtime), + AsyncScanCommand(runtime: runtime), + GetDiscoveredDevicesCommand(runtime: runtime), + ConnectCommand(runtime: runtime), + ConnectSystemDevicesCommand(runtime: runtime), + ListConnectedCommand(runtime: runtime), + DisconnectCommand(runtime: runtime), + StoreSoundCommand(runtime: runtime), + PlaySoundCommand(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..3b49df01 --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/param_readers.dart @@ -0,0 +1,132 @@ +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.'); +} + +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) { + 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; + } +} + +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/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/play_sound_command.dart b/open_wearable/lib/models/connectors/commands/play_sound_command.dart new file mode 100644 index 00000000..726d4235 --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/play_sound_command.dart @@ -0,0 +1,38 @@ +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: 'volume'), + CommandParam(name: 'codec'), + CommandParam(name: 'sample_rate'), + CommandParam(name: 'num_channels'), + ], + ); + + @override + Future> execute(List params) { + final soundId = readOptionalStringParam(params, 'sound_id'); + if (soundId == null || soundId.isEmpty) { + throw ArgumentError('play_sound requires "sound_id".'); + } + + final config = AudioPlaybackConfig.fromOptional( + codecKey: readOptionalStringParam(params, 'codec'), + sampleRate: readOptionalIntParam(params, 'sample_rate'), + numChannels: readOptionalIntParam(params, 'num_channels'), + ); + + return runtime.playSound( + soundId: soundId, + 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 new file mode 100644 index 00000000..02de4a26 --- /dev/null +++ b/open_wearable/lib/models/connectors/commands/runtime.dart @@ -0,0 +1,70 @@ +import 'dart:typed_data'; + +import 'package:open_earable_flutter/open_earable_flutter.dart'; + +import '../audio_playback_config.dart'; + +abstract class CommandRuntime { + List get methods; + + Future hasPermissions(); + + Future checkAndRequestPermissions(); + + Future> startScan({ + bool checkAndRequestPermissions = true, + }); + + Future>> getDiscoveredDevices(); + Stream get scanEvents; + + Future> connect({ + required String deviceId, + bool connectedViaSystem = false, + }); + + Future>> connectSystemDevices({ + List ignoredDeviceIds = const [], + }); + + Future>> listConnected(); + + Future> disconnect({ + required String deviceId, + }); + + Future> storeSound({ + required String soundId, + required Uint8List bytes, + required AudioPlaybackConfig config, + }); + + Future> playSound({ + String? soundId, + double? volume, + AudioPlaybackConfig? config, + }); + + 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/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/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_audio_playback_service.dart b/open_wearable/lib/models/connectors/websocket_audio_playback_service.dart new file mode 100644 index 00000000..976d9e07 --- /dev/null +++ b/open_wearable/lib/models/connectors/websocket_audio_playback_service.dart @@ -0,0 +1,87 @@ +import 'dart:typed_data'; + +import 'dart:io'; +import 'package:audioplayers/audioplayers.dart'; +import 'package:path_provider/path_provider.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 AudioPlayer _preloadedPlayer = AudioPlayer(); + + final Map _preloadedSounds = {}; + + 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 { + 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); + } + + 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} sample_rate=${config.sampleRate} num_channels=${config.numChannels}', + ); + return config; + } + + Future dispose() async { + await _preloadedPlayer.dispose(); + } + + 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 new file mode 100644 index 00000000..2e9a1c6b --- /dev/null +++ b/open_wearable/lib/models/connectors/websocket_ipc_server.dart @@ -0,0 +1,890 @@ +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'; +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/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'; + +/// Websocket-based IPC server that exposes wearable operations to external clients. +class WebSocketIpcServer implements CommandRuntime { + static const int defaultPort = 8765; + static const String defaultPath = '/ws'; + + final WearableManager _wearableManager; + final WearableConnector _wearableConnector; + final WebsocketAudioPlaybackService _audioPlaybackService; + + HttpServer? _httpServer; + final InternetAddress _host = InternetAddress.anyIPv4; + int _port = defaultPort; + String _path = defaultPath; + String? _advertisedHost; + + final Map _discoveredDevicesById = + {}; + final Map _connectedWearablesById = {}; + final Set<_ClientSession> _clients = <_ClientSession>{}; + + StreamSubscription? _scanSubscription; + StreamSubscription? _connectingSubscription; + StreamSubscription? _connectSubscription; + final StreamController _scanEventsController = + StreamController.broadcast(); + + int _nextSubscriptionId = 1; + final Map _topLevelCommands = {}; + final Map _actionCommands = {}; + + WebSocketIpcServer({ + WearableManager? wearableManager, + WearableConnector? wearableConnector, + WebsocketAudioPlaybackService? audioPlaybackService, + }) : _wearableManager = wearableManager ?? WearableManager(), + _wearableConnector = wearableConnector ?? WearableConnector(), + _audioPlaybackService = + audioPlaybackService ?? WebsocketAudioPlaybackService() { + for (final command in createDefaultIpcCommands(this)) { + addCommand(command); + } + for (final command in createDefaultActionCommands(this)) { + addActionCommand(command); + } + } + + /// Returns whether the websocket server is currently bound and accepting requests. + bool get isRunning => _httpServer != null; + + /// Returns the internal bind endpoint used by the server. + Uri get bindEndpoint => Uri( + scheme: 'ws', + 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 int port, + required String path, + }) async { + await stop(); + + _port = port; + _path = _normalizePath(path); + logger.i( + '[connector.websocket] starting bind_address=${_host.address} port=$_port path=$_path', + ); + + _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(); + + _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 + ..write('OpenWearables WebSocket IPC endpoint: $_path') + ..close(); + 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); + } + + 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(); + _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 { + 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, + }) async { + _discoveredDevicesById.clear(); + await _wearableManager.startScan( + checkAndRequestPermissions: checkAndRequestPermissions, + ); + 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, + bool connectedViaSystem = false, + }) async { + final discovered = _discoveredDevicesById[deviceId]; + if (discovered == null) { + throw StateError('Device not found in discovered devices: $deviceId'); + } + + final options = connectedViaSystem + ? {const ConnectedViaSystem()} + : const {}; + + final wearable = await _wearableConnector.connect( + discovered, + options: options, + ); + _registerConnectedWearable(wearable); + return _serializeWearableSummary(wearable); + } + + /// Connects to system-managed wearables and registers them with the server. + @override + Future>> connectSystemDevices({ + List ignoredDeviceIds = const [], + }) async { + final wearables = await _wearableConnector.connectToSystemDevices( + ignoredDeviceIds: ignoredDeviceIds, + ); + for (final wearable in wearables) { + _registerConnectedWearable(wearable); + } + return wearables.map(_serializeWearableSummary).toList(); + } + + /// Lists currently connected wearables. + @override + Future>> listConnected() async { + return _connectedWearablesById.values + .map(_serializeWearableSummary) + .toList(); + } + + /// Disconnects a connected wearable by id. + @override + Future> disconnect({ + required String deviceId, + }) async { + final wearable = _requireConnectedWearable(deviceId); + await wearable.disconnect(); + _connectedWearablesById.remove(deviceId); + 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, + double? volume, + AudioPlaybackConfig? config, + }) async { + final hasSoundId = soundId != null && soundId.trim().isNotEmpty; + if (!hasSoundId) { + throw ArgumentError('play_sound requires "sound_id".'); + } + + final usedConfig = await _audioPlaybackService.playStoredSound( + soundId: soundId, + volume: volume, + overrideConfig: config, + ); + return { + 'source': 'sound_id', + 'sound_id': soundId, + 'playing': true, + 'config': usedConfig.toJson(), + }; + } + + /// 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, + 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, + ); + } + + /// Cancels a previously registered client stream subscription. + @override + Future> unsubscribe({ + required dynamic session, + required int subscriptionId, + }) async { + final _ClientSession client = session as _ClientSession; + return client.unsubscribe(subscriptionId); + } + + /// Invokes an action command against a connected wearable. + @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); + } + + /// Converts raw request params into command params for command execution. + List> _paramsToCommandParams( + Map params, { + required _ClientSession? session, + }) { + final commandParams = >[]; + if (session != null) { + commandParams + .add(CommandParam(name: sessionParamName, value: session)); + } + params.forEach((key, value) { + commandParams.add(CommandParam(name: key, value: value)); + }); + return commandParams; + } + + /// Hooks wearable manager streams into websocket broadcast events. + void _attachManagerSubscriptions() { + _scanSubscription ??= _wearableManager.scanStream.listen((device) { + _discoveredDevicesById[device.id] = device; + _scanEventsController.add(device); + _broadcastEvent( + { + 'event': 'scan', + 'device': _serializeDiscovered(device), + }, + ); + }); + + _connectingSubscription ??= + _wearableManager.connectingStream.listen((device) { + _broadcastEvent( + { + 'event': 'connecting', + 'device': _serializeDiscovered(device), + }, + ); + }); + + _connectSubscription ??= _wearableManager.connectStream.listen((wearable) { + _registerConnectedWearable(wearable); + _broadcastEvent( + { + 'event': 'connected', + 'wearable': _serializeWearableSummary(wearable), + }, + ); + }); + } + + /// Tracks a connected wearable and removes it when it disconnects. + void _registerConnectedWearable(Wearable wearable) { + _connectedWearablesById[wearable.deviceId] = wearable; + wearable.addDisconnectListener(() { + _connectedWearablesById.remove(wearable.deviceId); + }); + } + + /// 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)) { + client.sendRaw(payload); + } + } + + /// 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, + 'name': device.name, + 'service_uuids': device.serviceUuids, + 'manufacturer_data': device.manufacturerData.toList(), + 'rssi': device.rssi, + }; + } + + /// Serializes a connected wearable summary into the external IPC format. + Map _serializeWearableSummary(Wearable wearable) { + return { + 'device_id': wearable.deviceId, + 'name': wearable.name, + 'type': wearable.runtimeType.toString(), + 'capabilities': _capabilitiesForWearable(wearable), + }; + } + + /// Serializes streamed capability data into JSON-safe payloads. + Object? _serializeStreamData(dynamic data) { + if (data is DiscoveredDevice) { + return _serializeDiscovered(data); + } + if (data is SensorValue) { + final payload = { + 'timestamp': data.timestamp, + 'value_strings': data.valueStrings, + }; + if (data is SensorDoubleValue) { + payload['values'] = data.values; + } else if (data is SensorIntValue) { + payload['values'] = data.values; + } + return payload; + } + if (data is ButtonEvent) { + return data.name; + } + if (data is BatteryPowerStatus) { + return _serializeBatteryPowerStatus(data); + } + if (data is BatteryHealthStatus) { + return _serializeBatteryHealthStatus(data); + } + if (data is BatteryEnergyStatus) { + return _serializeBatteryEnergyStatus(data); + } + if (data is Map) { + return data.entries + .map( + (entry) => { + 'name': entry.key.name, + 'value_key': entry.value.key, + }, + ) + .toList(); + } + + return _jsonSafe(data); + } + + /// Serializes battery power status into a JSON-safe payload. + 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(), + }; + } + + /// Serializes battery health status into a JSON-safe payload. + Map _serializeBatteryHealthStatus( + BatteryHealthStatus status, + ) { + return { + 'health_summary': status.healthSummary, + 'cycle_count': status.cycleCount, + 'current_temperature': status.currentTemperature, + }; + } + + /// Serializes battery energy status into a JSON-safe payload. + Map _serializeBatteryEnergyStatus( + BatteryEnergyStatus status, + ) { + return { + 'voltage': status.voltage, + 'available_capacity': status.availableCapacity, + 'charge_rate': status.chargeRate, + }; + } + + /// Lists known capabilities for a connected wearable. + 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; + } + + /// Looks up a connected wearable and throws if it is unavailable. + Wearable _requireConnectedWearable(String deviceId) { + final wearable = _connectedWearablesById[deviceId]; + if (wearable == null) { + throw StateError('No connected wearable for device_id: $deviceId'); + } + return wearable; + } + + /// Ensures the configured websocket path is non-empty and absolute. + String _normalizePath(String path) { + final trimmed = path.trim(); + if (trimmed.isEmpty) { + return defaultPath; + } + 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; + } + 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(); + } + + /// Normalizes arbitrary request payloads into string-keyed maps. + 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.'); + } +} + +/// Represents one connected websocket client and its active subscriptions. +class _ClientSession { + final WebSocket socket; + final WebSocketIpcServer server; + + final Map> _subscriptions = + >{}; + + 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); + + socket.listen( + (message) async { + await _handleMessage(message); + }, + onDone: () async { + await close(); + }, + 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; + } + sendRaw(jsonEncode(payload)); + } + + /// Sends a pre-serialized websocket text frame to the client. + void sendRaw(String payload) { + if (_closed) { + return; + } + socket.add(payload); + } + + /// Parses and executes a single inbound websocket message. + 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) { + logger.w( + '[connector.websocket] request_failed client=$label id=$id error=$error\n$stackTrace', + ); + send( + { + 'id': id, + 'error': { + 'message': error.toString(), + 'type': error.runtimeType.toString(), + 'stack': stackTrace.toString(), + }, + }, + ); + } + } + + /// Registers or replaces a stream subscription owned by this client. + 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) { + logger.w( + '[connector.websocket] stream_error client=$label subscription_id=$subscriptionId stream=$streamName device_id=$deviceId error=$error\n$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, + ); + } + + /// Cancels a single client-owned stream subscription. + 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, + }; + } + + /// Closes the client socket and cancels all active subscriptions. + Future close() async { + if (_closed) { + return; + } + _closed = true; + + final subscriptions = _subscriptions.values.toList(growable: false); + _subscriptions.clear(); + + for (final subscription in subscriptions) { + await subscription.cancel(); + } + + await socket.close(); + server._onClientClosed(this); + } +} diff --git a/open_wearable/lib/models/network/device_ip_address.dart b/open_wearable/lib/models/network/device_ip_address.dart new file mode 100644 index 00000000..2592159f --- /dev/null +++ b/open_wearable/lib/models/network/device_ip_address.dart @@ -0,0 +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 new file mode 100644 index 00000000..665cde90 --- /dev/null +++ b/open_wearable/lib/models/network/device_ip_address_io.dart @@ -0,0 +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, + ); + + _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; + } + final resolved = _ResolvedAddress( + host: host, + score: _scoreInterfaceAddress(interface.name, host), + ); + if (_isPrivateIpv4(host) && + (bestMatch == null || resolved.score > bestMatch.score)) { + bestMatch = resolved; + } + fallback ??= resolved; + } + } + + 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 new file mode 100644 index 00000000..b454d68d --- /dev/null +++ b/open_wearable/lib/models/network/device_ip_address_stub.dart @@ -0,0 +1,2 @@ +/// Returns `null` on targets that cannot inspect local network interfaces. +Future resolveCurrentDeviceIpAddressImpl() async => null; 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. 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/connector_activity_indicator.dart b/open_wearable/lib/widgets/connector_activity_indicator.dart new file mode 100644 index 00000000..9cfbffeb --- /dev/null +++ b/open_wearable/lib/widgets/connector_activity_indicator.dart @@ -0,0 +1,237 @@ +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'; +import 'package:open_wearable/widgets/connector_branding.dart'; + +/// Compact global status chip shown while an external connector is active. +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 + 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: _statusListenable, + 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.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, + ), + ), + ], + ], + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + }, + ); + } +} 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/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/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..cad0633b --- /dev/null +++ b/open_wearable/lib/widgets/settings/connectors_page.dart @@ -0,0 +1,564 @@ +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/connector_branding.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 _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(); + _portController = TextEditingController(); + _pathController = TextEditingController(); + _loadSettings(); + } + + @override + void dispose() { + _portController.dispose(); + _pathController.dispose(); + super.dispose(); + } + + Future _loadSettings() async { + try { + final settingsFuture = ConnectorSettings.loadWebSocketSettings(); + final ipAddressFuture = resolveCurrentDeviceIpAddress(); + final settings = await settingsFuture; + final ipAddress = await ipAddressFuture; + if (!mounted) { + return; + } + + setState(() { + _enabled = settings.enabled; + _portController.text = settings.port.toString(); + _pathController.text = settings.path; + _currentIpAddress = ipAddress; + _validationMessage = null; + _isResolvingIpAddress = false; + _isLoading = false; + }); + } catch (_) { + if (!mounted) { + return; + } + setState(() { + _validationMessage = 'Could not load connector settings.'; + _isResolvingIpAddress = false; + _isLoading = false; + }); + AppToast.show( + context, + message: 'Failed to load connector settings.', + type: AppToastType.error, + icon: Icons.error_outline_rounded, + ); + } + } + + 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; + } + + 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; + _portController.text = saved.port.toString(); + _pathController.text = saved.path; + }); + + AppToast.show( + context, + message: 'Network connector settings saved.', + type: AppToastType.success, + icon: Icons.check_circle_outline_rounded, + ); + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _validationMessage = + 'Could not start network connector server: ${error.toString()}'; + }); + AppToast.show( + context, + 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; + _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, + ); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + WebSocketConnectorSettings? _buildValidatedSettings() { + final parsedPort = int.tryParse(_portController.text.trim()); + final rawPath = _pathController.text.trim(); + final path = rawPath.isEmpty ? '/ws' : rawPath; + + 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, + 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 || + parsedPort != applied.port || + path != applied.path; + } + + @override + Widget build(BuildContext context) { + return PlatformScaffold( + appBar: PlatformAppBar( + title: const Text(ConnectorBranding.pluralLabel), + ), + 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( + ConnectorBranding.pluralLabel, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + 'Expose OpenWearable 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: (_currentIpAddress?.trim().isNotEmpty ?? false) + ? _currentIpAddress!.trim() + : 'device-ip-unavailable', + 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( + ConnectorBranding.icon, + size: 18, + color: statusColor, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Network Connector', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + 'Expose the OpenWearable 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), + 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), + 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), + 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'), + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +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(ConnectorBranding.icon, size: 14, 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..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'; @@ -15,12 +16,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 +61,12 @@ class SettingsPage extends StatelessWidget { subtitle: 'Browse app releases', onTap: () => context.push('/whats-new'), ), + _QuickActionTile( + icon: ConnectorBranding.icon, + title: ConnectorBranding.pluralLabel, + 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 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 + 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..0c902c54 --- /dev/null +++ b/open_wearable/test/widgets/connector_activity_indicator_test.dart @@ -0,0 +1,100 @@ +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 { + 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); + 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(); + 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(ConnectorBranding.icon), findsOneWidget); + + await tester.tap(find.byIcon(ConnectorBranding.icon)); + await tester.pump(); + + expect(find.text('Connector'), findsOneWidget); + + await tester.pump(ConnectorActivityIndicator.expandedDuration); + + expect(find.text('Connector'), findsNothing); + expect(find.byIcon(ConnectorBranding.icon), 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(ConnectorBranding.icon)); + await tester.pump(); + + expect(settingsOpenCount, 1); + }); +}