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..ccc6abea --- /dev/null +++ b/open_wearable/docs/connectors/websocket-ipc-api.md @@ -0,0 +1,254 @@ +# WebSocket IPC API + +This document describes how to communicate with the OpenWearable WebSocket connector. + +Python clients can use the [`open-wearables`](https://pypi.org/project/open-wearables/) +package instead of implementing the JSON WebSocket protocol directly. + +## Endpoint + +Default endpoint: + +- `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/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..fad4db75 100644 --- a/open_wearable/ios/Podfile +++ b/open_wearable/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '13.0' +platform :ios, '16.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/open_wearable/ios/Podfile.lock b/open_wearable/ios/Podfile.lock index 110052ae..1e2cdfa2 100644 --- a/open_wearable/ios/Podfile.lock +++ b/open_wearable/ios/Podfile.lock @@ -2,6 +2,8 @@ PODS: - audioplayers_darwin (0.0.1): - Flutter - FlutterMacOS + - device_info_plus (0.0.1): + - Flutter - DKImagePickerController/Core (4.3.9): - DKImagePickerController/ImageDataManager - DKImagePickerController/Resource @@ -55,16 +57,18 @@ PODS: - Flutter - permission_handler_apple (9.3.0): - Flutter - - SDWebImage (5.21.5): - - SDWebImage/Core (= 5.21.5) - - SDWebImage/Core (5.21.5) + - SDWebImage (5.21.7): + - SDWebImage/Core (= 5.21.7) + - SDWebImage/Core (5.21.7) + - sensors_plus (0.0.1): + - Flutter - share_plus (0.0.1): - Flutter - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS - SwiftCBOR (0.4.7) - - SwiftProtobuf (1.33.3) + - SwiftProtobuf (1.37.0) - SwiftyGif (5.4.5) - universal_ble (0.0.1): - Flutter @@ -77,6 +81,7 @@ PODS: DEPENDENCIES: - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`) + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) - file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`) - Flutter (from `Flutter`) @@ -85,6 +90,7 @@ DEPENDENCIES: - open_file_ios (from `.symlinks/plugins/open_file_ios/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - sensors_plus (from `.symlinks/plugins/sensors_plus/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - universal_ble (from `.symlinks/plugins/universal_ble/darwin`) @@ -105,6 +111,8 @@ SPEC REPOS: EXTERNAL SOURCES: audioplayers_darwin: :path: ".symlinks/plugins/audioplayers_darwin/darwin" + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" file_picker: :path: ".symlinks/plugins/file_picker/ios" file_selector_ios: @@ -121,6 +129,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/package_info_plus/ios" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" + sensors_plus: + :path: ".symlinks/plugins/sensors_plus/ios" share_plus: :path: ".symlinks/plugins/share_plus/ios" shared_preferences_foundation: @@ -134,6 +144,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5 + device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be @@ -145,17 +156,18 @@ SPEC CHECKSUMS: open_file_ios: 46184d802ee7959203f6392abcfa0dd49fdb5be0 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d - SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838 + SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf + sensors_plus: 6a11ed0c2e1d0bd0b20b4029d3bad27d96e0c65b share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb SwiftCBOR: 465775bed0e8bac7bfb8160bcf7b95d7f75971e4 - SwiftProtobuf: e1b437c8e31a4c5577b643249a0bb62ed4f02153 + SwiftProtobuf: 3fafd1b2fb97e6d95ad9c8adb2215da9afec7c83 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 universal_ble: ff19787898040d721109c6324472e5dd4bc86adc url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c -PODFILE CHECKSUM: 251cb053df7158f337c0712f2ab29f4e0fa474ce +PODFILE CHECKSUM: 1857a7cdb7dfafe45f2b0e9a9af44644190f7506 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..b9ef095a 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,31 @@ ???? 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. + NSMotionUsageDescription + This app requires access to device motion in order to provide sensor data. + NSPhotoLibraryUsageDescription + Needed for optional file selection functionality. + UIApplicationSupportsIndirectInputEvents + + UIFileSharingEnabled + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -37,28 +62,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/apps/widgets/apps_page.dart b/open_wearable/lib/apps/widgets/apps_page.dart index 2528a001..024e656c 100644 --- a/open_wearable/lib/apps/widgets/apps_page.dart +++ b/open_wearable/lib/apps/widgets/apps_page.dart @@ -9,6 +9,7 @@ import 'package:open_wearable/apps/widgets/app_compatibility.dart'; import 'package:open_wearable/apps/widgets/select_earable_view.dart'; import 'package:open_wearable/apps/widgets/app_tile.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; +import 'package:open_wearable/widgets/connector_activity_indicator.dart'; import 'package:open_wearable/widgets/recording_activity_indicator.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; import 'package:provider/provider.dart'; @@ -220,6 +221,7 @@ class AppsPage extends StatelessWidget { title: PlatformText("Apps"), trailingActions: [ const AppBarRecordingIndicator(), + const ConnectorActivityIndicator(), PlatformIconButton( icon: Icon(context.platformIcons.bluetooth), onPressed: () { diff --git a/open_wearable/lib/apps/widgets/select_earable_view.dart b/open_wearable/lib/apps/widgets/select_earable_view.dart index cf7649c5..cc367ccd 100644 --- a/open_wearable/lib/apps/widgets/select_earable_view.dart +++ b/open_wearable/lib/apps/widgets/select_earable_view.dart @@ -432,12 +432,7 @@ class _SelectableWearableCard extends StatelessWidget { } bool _hasWearableIcon(WearableIconVariant initialVariant) { - final variantPath = wearable.getWearableIconPath(variant: initialVariant); - if (variantPath != null && variantPath.isNotEmpty) { - return true; - } - final fallbackPath = wearable.getWearableIconPath(); - return fallbackPath != null && fallbackPath.isNotEmpty; + return WearableIcon.hasIcon(wearable, variant: initialVariant); } List _buildDeviceStatusPills() { diff --git a/open_wearable/lib/assets/devices/phone-app.png b/open_wearable/lib/assets/devices/phone-app.png new file mode 100644 index 00000000..5a2d2037 Binary files /dev/null and b/open_wearable/lib/assets/devices/phone-app.png differ 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/app_upgrade_registry.dart b/open_wearable/lib/models/app_upgrade_registry.dart index 8808a591..b3e7cc04 100644 --- a/open_wearable/lib/models/app_upgrade_registry.dart +++ b/open_wearable/lib/models/app_upgrade_registry.dart @@ -58,6 +58,51 @@ class AppUpgradeRegistry { ), ], ), + AppUpgradeHighlight( + version: '1.2.0', + eyebrow: 'OpenWearables 1.2.0', + title: 'Automate OpenWearables\nwith the new connector', + summary: + 'Control the app over WebSocket and use your phone as a sensor source.', + heroDescription: + 'OpenWearables now includes a connector that exposes app control through a WebSocket API. ' + 'The Python API builds on that connection, making scripted workflows, external tools, ' + 'and repeatable automation possible.', + accentColor: Color(0xFF2F7D6D), + useHeroGradient: false, + features: [ + AppUpgradeFeatureHighlight( + icon: Icons.hub_rounded, + title: 'Brand-new connector', + description: + 'Control app behavior remotely through the WebSocket connector for automation and integration workflows.', + ), + AppUpgradeFeatureHighlight( + icon: Icons.code_rounded, + title: 'Python API support', + description: + 'Use the Python API on top of the connector to script app interactions from external tools.', + ), + AppUpgradeFeatureHighlight( + icon: Icons.wifi_tethering_rounded, + title: 'Automation-ready access', + description: + 'Build repeatable experiments, demos, and integrations without driving every action through the UI.', + ), + AppUpgradeFeatureHighlight( + icon: Icons.phone_iphone_rounded, + title: 'Phone as a sensor device', + description: + 'Use the phone running the app as a local sensor source for supported motion data workflows.', + ), + AppUpgradeFeatureHighlight( + icon: Icons.tune_rounded, + title: 'Configurable local sensors', + description: + 'Phone sensor streams support configurable sampling options and start with sampling disabled until selected.', + ), + ], + ), ]; /// Returns the configured highlight for [version], if any. diff --git a/open_wearable/lib/models/connector_settings.dart b/open_wearable/lib/models/connector_settings.dart new file mode 100644 index 00000000..6ceb31fb --- /dev/null +++ b/open_wearable/lib/models/connector_settings.dart @@ -0,0 +1,280 @@ +// ignore_for_file: cancel_subscriptions + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:open_wearable/models/network/device_ip_address.dart'; +import 'package:open_wearable/models/wearable_connector.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +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; + final bool hasReachableNetworkAddress; + final String? reachableNetworkAddress; + + const ConnectorRuntimeStatus({ + required this.state, + this.message, + this.hasReachableNetworkAddress = true, + this.reachableNetworkAddress, + }); + + const ConnectorRuntimeStatus.disabled() + : state = ConnectorRuntimeState.disabled, + message = null, + hasReachableNetworkAddress = true, + reachableNetworkAddress = null; + + const ConnectorRuntimeStatus.starting() + : state = ConnectorRuntimeState.starting, + message = null, + hasReachableNetworkAddress = true, + reachableNetworkAddress = null; + + const ConnectorRuntimeStatus.running({ + this.hasReachableNetworkAddress = true, + this.reachableNetworkAddress, + }) : state = ConnectorRuntimeState.running, + message = null; + + const ConnectorRuntimeStatus.error(this.message) + : state = ConnectorRuntimeState.error, + hasReachableNetworkAddress = true, + reachableNetworkAddress = null; + + /// Whether the connector is currently enabled and participating in runtime + /// work. + bool get isActive => + state == ConnectorRuntimeState.starting || + state == ConnectorRuntimeState.running; + + /// Whether the active connector has enough runtime state to accept clients. + bool get isHealthy => + state == ConnectorRuntimeState.starting || + (state == ConnectorRuntimeState.running && hasReachableNetworkAddress); +} + +/// Loads, normalizes, persists, and applies connector settings. +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 Timer? _networkStatusRefreshTimer; + + 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 { + _stopNetworkStatusRefresh(); + 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) { + _stopNetworkStatusRefresh(); + await _webSocketServer.stop(); + _setRuntimeStatus(const ConnectorRuntimeStatus.disabled()); + return; + } + + _setRuntimeStatus(const ConnectorRuntimeStatus.starting()); + + try { + await _webSocketServer.start( + port: normalized.port, + path: normalized.path, + ); + await _refreshRunningNetworkStatus(); + _startNetworkStatusRefresh(); + } catch (error) { + _stopNetworkStatusRefresh(); + _setRuntimeStatus(ConnectorRuntimeStatus.error(error.toString())); + rethrow; + } + } + + /// Refreshes the running connector's local-network reachability state. + static Future _refreshRunningNetworkStatus() async { + if (!_webSocketServer.isRunning) { + return; + } + final address = await resolveCurrentDeviceIpAddress(); + _webSocketServer.updateAdvertisedHost(address); + _setRuntimeStatus( + ConnectorRuntimeStatus.running( + hasReachableNetworkAddress: address != null, + reachableNetworkAddress: address, + ), + ); + } + + /// Keeps connector health current when Wi-Fi or network interfaces change. + static void _startNetworkStatusRefresh() { + _stopNetworkStatusRefresh(); + _networkStatusRefreshTimer = Timer.periodic( + const Duration(seconds: 5), + (_) => unawaited(_refreshRunningNetworkStatus()), + ); + } + + /// Stops periodic connector network-health checks. + static void _stopNetworkStatusRefresh() { + _networkStatusRefreshTimer?.cancel(); + _networkStatusRefreshTimer = null; + } + + /// Normalizes persisted settings into a valid runtime configuration. + static WebSocketConnectorSettings _normalizeWebSocketSettings( + WebSocketConnectorSettings settings, + ) { + 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..15358c5a --- /dev/null +++ b/open_wearable/lib/models/connectors/websocket_ipc_server.dart @@ -0,0 +1,895 @@ +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, + ); + } + + /// Updates the client-facing host advertised by command responses. + void updateAdvertisedHost(String? host) { + _advertisedHost = host?.trim().isEmpty ?? true ? null : host!.trim(); + } + + /// Starts the server with the provided port and path. + Future start({ + required int port, + 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); + updateAdvertisedHost(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/log_file_manager.dart b/open_wearable/lib/models/log_file_manager.dart index 23d7b5f2..56a14c63 100644 --- a/open_wearable/lib/models/log_file_manager.dart +++ b/open_wearable/lib/models/log_file_manager.dart @@ -5,8 +5,8 @@ import 'package:flutter/foundation.dart'; import 'package:logger/logger.dart'; import 'package:path_provider/path_provider.dart'; -class _CustomLogFilter extends LogFilter { - _CustomLogFilter(this._minLevel); +class _CustomLibLogFilter extends LogFilter { + _CustomLibLogFilter(this._minLevel); final Level _minLevel; @@ -26,6 +26,17 @@ class _CustomLogFilter extends LogFilter { } } +class _CustomAppLogFilter extends LogFilter { + _CustomAppLogFilter(this._minLevel); + + final Level _minLevel; + + @override + bool shouldLog(LogEvent event) { + return event.level.index >= _minLevel.index; + } +} + /// Central logging service for app/runtime logs and persisted log files. /// /// Needs: @@ -75,7 +86,8 @@ class LogFileManager with ChangeNotifier { printer = LogfmtPrinter(); } - final libFilter = _CustomLogFilter(level); + final appFilter = _CustomAppLogFilter(level); + final libFilter = _CustomLibLogFilter(level); LogOutput? fileOutput; String logDirPath = ''; @@ -112,6 +124,7 @@ class LogFileManager with ChangeNotifier { // ------------------------ final logger = Logger( level: level, + filter: appFilter, printer: PrefixPrinter( printer, trace: '[APP] TRACE', 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..341a60ae --- /dev/null +++ b/open_wearable/lib/models/network/device_ip_address_io.dart @@ -0,0 +1,90 @@ +import 'dart:io'; + +/// Resolves the best client-reachable IPv4 address for the current device. +/// +/// The resolver returns private LAN addresses on likely Wi-Fi or Ethernet +/// interfaces and rejects cellular, VPN, hotspot, or peer-to-peer interfaces. +Future resolveCurrentDeviceIpAddressImpl() async { + final interfaces = await NetworkInterface.list( + type: InternetAddressType.IPv4, + includeLoopback: false, + ); + + _ResolvedAddress? bestMatch; + 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) && + resolved.isLikelyLanAddress && + (bestMatch == null || resolved.score > bestMatch.score)) { + bestMatch = resolved; + } + } + } + + return bestMatch?.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, + }); + + bool get isLikelyLanAddress => score >= 100; +} 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/this_device_wearable.dart b/open_wearable/lib/models/this_device_wearable.dart new file mode 100644 index 00000000..22a28262 --- /dev/null +++ b/open_wearable/lib/models/this_device_wearable.dart @@ -0,0 +1,645 @@ +import 'dart:async'; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/foundation.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart' + hide Version, logger; +import 'package:pub_semver/pub_semver.dart'; +import 'package:sensors_plus/sensors_plus.dart'; + +import 'logger.dart'; + +/// Represents the phone, tablet, desktop, or browser running the app as a +/// wearable-like device with locally available sensors. +class ThisDeviceWearable extends Wearable + implements + SensorManager, + SensorConfigurationManager, + DeviceFirmwareVersion { + @override + final List sensors = []; + + @override + final List sensorConfigurations = []; + + final StreamController> + _sensorConfigurationController = StreamController.broadcast(); + + @override + Stream> + get sensorConfigurationStream => _sensorConfigurationController.stream; + + final DeviceProfile deviceProfile; + + final WearableDisconnectNotifier _disconnectNotifier; + + /// Creates a host-device wearable from an already resolved device profile. + ThisDeviceWearable._({ + required super.disconnectNotifier, + required this.deviceProfile, + }) : _disconnectNotifier = disconnectNotifier, + super(name: deviceProfile.displayName); + + /// Builds the host-device wearable and registers every sensor that produces + /// at least one sample on the current platform. + static Future create({ + required WearableDisconnectNotifier disconnectNotifier, + }) async { + final profile = await DeviceProfile.fetch(); + logger.d('Fetched device profile: $profile'); + final wearable = ThisDeviceWearable._( + disconnectNotifier: disconnectNotifier, + deviceProfile: profile, + ); + await wearable._initSensors(); + return wearable; + } + + @override + String get deviceId => deviceProfile.deviceId; + + @override + Future disconnect() async { + _disconnectNotifier.notifyListeners(); + } + + @override + String? getWearableIconPath({ + bool darkmode = false, + WearableIconVariant variant = WearableIconVariant.single, + }) { + return 'lib/assets/devices/phone-app.png'; + } + + void _emitSensorConfigurationChange( + SensorConfiguration configuration, + SensorConfigurationValue value, + ) { + _sensorConfigurationController.add({configuration: value}); + } + + Future _initSensors() async { + await _registerSensorIfAvailable( + sensorName: 'Accelerometer', + chartTitle: 'Accelerometer', + shortChartTitle: 'Accel', + axisNames: ['X', 'Y', 'Z'], + axisUnits: ['m/s²', 'm/s²', 'm/s²'], + valueExtractor: (event) => SensorDoubleValue( + values: [event.x, event.y, event.z], + timestamp: event.timestamp.millisecondsSinceEpoch, + ), + sensorStreamProvider: accelerometerEventStream, + ); + await _registerSensorIfAvailable( + sensorName: 'User Accelerometer', + chartTitle: 'User Accelerometer', + shortChartTitle: 'User Accel', + axisNames: ['X', 'Y', 'Z'], + axisUnits: ['m/s²', 'm/s²', 'm/s²'], + valueExtractor: (event) => SensorDoubleValue( + values: [event.x, event.y, event.z], + timestamp: event.timestamp.millisecondsSinceEpoch, + ), + sensorStreamProvider: userAccelerometerEventStream, + ); + await _registerSensorIfAvailable( + sensorName: 'Gyroscope', + chartTitle: 'Gyroscope', + shortChartTitle: 'Gyro', + axisNames: ['X', 'Y', 'Z'], + axisUnits: ['rad/s', 'rad/s', 'rad/s'], + valueExtractor: (event) => SensorDoubleValue( + values: [event.x, event.y, event.z], + timestamp: event.timestamp.millisecondsSinceEpoch, + ), + sensorStreamProvider: gyroscopeEventStream, + ); + await _registerSensorIfAvailable( + sensorName: 'Magnetometer', + chartTitle: 'Magnetometer', + shortChartTitle: 'Mag', + axisNames: ['X', 'Y', 'Z'], + axisUnits: ['µT', 'µT', 'µT'], + valueExtractor: (event) => SensorDoubleValue( + values: [event.x, event.y, event.z], + timestamp: event.timestamp.millisecondsSinceEpoch, + ), + sensorStreamProvider: magnetometerEventStream, + ); + await _registerSensorIfAvailable( + sensorName: 'Barometer', + chartTitle: 'Barometer', + shortChartTitle: 'Baro', + axisNames: ['Pressure'], + axisUnits: ['hPa'], + valueExtractor: (event) => SensorDoubleValue( + values: [event.pressure], + timestamp: event.timestamp.millisecondsSinceEpoch, + ), + sensorStreamProvider: barometerEventStream, + ); + } + + Future _registerSensorIfAvailable({ + required String sensorName, + required String chartTitle, + required String shortChartTitle, + required List axisNames, + required List axisUnits, + required SensorDoubleValue Function(SensorEvent event) valueExtractor, + required Stream Function({required Duration samplingPeriod}) + sensorStreamProvider, + }) async { + final availabilityProbe = sensorStreamProvider( + samplingPeriod: SensorInterval.normalInterval, + ); + if (!await _isSensorAvailable(availabilityProbe)) { + logger.w("Sensor '$sensorName' is not available on this device."); + return; + } + + final config = DeviceSensorConfiguration( + name: sensorName, + onChange: _emitSensorConfigurationChange, + ); + sensorConfigurations.add(config); + _emitSensorConfigurationChange(config, config.currentValue); + sensors.add( + ThisDeviceSensor( + config: config, + sensorName: sensorName, + chartTitle: chartTitle, + shortChartTitle: shortChartTitle, + axisNames: axisNames, + axisUnits: axisUnits, + valueExtractor: valueExtractor, + sensorStreamProvider: sensorStreamProvider, + ), + ); + } + + /// Returns whether the platform emitted a sample before the availability + /// timeout. Missing hardware, unsupported platforms, and permission failures + /// are treated as unavailable so the app does not show dead sensors. + static Future _isSensorAvailable(Stream stream) async { + try { + await stream.first.timeout(const Duration(milliseconds: 800)); + return true; + } catch (_) { + return false; + } + } + + @override + Future checkFirmwareSupport() { + return Future.value(FirmwareSupportStatus.supported); + } + + @override + Future readDeviceFirmwareVersion() { + return deviceProfile.osVersion != null + ? Future.value(deviceProfile.osVersion) + : Future.error('OS version not available'); + } + + @override + Future readFirmwareVersionNumber() { + if (deviceProfile.osVersion == null) { + return Future.error('OS version not available'); + } + try { + final version = Version.parse(deviceProfile.osVersion!); + return Future.value(version); + } catch (e) { + return Future.error('Failed to parse OS version: $e'); + } + } + + @override + VersionConstraint get supportedFirmwareRange => VersionConstraint.any; +} + +/// Static metadata for the device running the app. +class DeviceProfile { + final String displayName; + final String deviceId; + final String? model; + final String? manufacturer; + final String? osVersion; + final String? platform; + + /// Creates a host device metadata snapshot. + const DeviceProfile({ + required this.displayName, + required this.deviceId, + this.model, + this.manufacturer, + this.osVersion, + this.platform, + }); + + /// Reads platform-specific device information and falls back to a generic + /// profile when a platform does not expose one. + static Future fetch() async { + final deviceInfo = DeviceInfoPlugin(); + try { + if (kIsWeb) { + final info = await deviceInfo.webBrowserInfo; + logger.d("Fetched web browser info: $info"); + final browserName = info.browserName.name; + final displayName = _firstNonEmpty( + [info.platform, browserName, info.appName], + 'Web Browser', + ); + final deviceId = _firstNonEmpty( + [info.userAgent, info.appVersion], + 'WEB-DEVICE', + ); + return DeviceProfile( + displayName: displayName, + deviceId: deviceId, + model: browserName, + manufacturer: info.vendor, + osVersion: info.appVersion, + platform: 'web', + ); + } + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + final info = await deviceInfo.androidInfo; + logger.d("Fetched Android device info: $info"); + final displayName = _firstNonEmpty( + [ + _formatAndroidDeviceDisplayName( + _joinNonEmpty([info.brand, info.model]), + ), + _formatAndroidDeviceDisplayName(info.model), + _formatAndroidDeviceDisplayName(info.device), + _formatAndroidDeviceDisplayName(info.product), + ], + 'Android Device', + ); + final deviceId = _firstNonEmpty( + [info.id, info.device, info.product, info.model], + 'ANDROID-DEVICE', + ); + final osVersion = _joinNonEmpty( + [ + 'Android', + info.version.release, + 'SDK ${info.version.sdkInt}', + ], + ); + return DeviceProfile( + displayName: displayName, + deviceId: deviceId, + model: info.model, + manufacturer: info.manufacturer, + osVersion: osVersion, + platform: 'android', + ); + case TargetPlatform.iOS: + final info = await deviceInfo.iosInfo; + logger.d("Fetched iOS device info: $info"); + final displayName = _firstNonEmpty( + [info.name, info.localizedModel, info.model], + 'iOS Device', + ); + final deviceId = _firstNonEmpty( + [info.identifierForVendor, info.name, info.model], + 'IOS-DEVICE', + ); + final osVersion = _joinNonEmpty( + [info.systemName, info.systemVersion], + ); + return DeviceProfile( + displayName: displayName, + deviceId: deviceId, + model: info.model, + manufacturer: 'Apple', + osVersion: osVersion, + platform: 'ios', + ); + case TargetPlatform.macOS: + final info = await deviceInfo.macOsInfo; + logger.d("Fetched macOS device info: $info"); + final displayName = _firstNonEmpty( + [info.computerName, info.model], + 'macOS Device', + ); + final deviceId = _firstNonEmpty( + [info.computerName, info.model], + 'MAC-DEVICE', + ); + final osVersion = _joinNonEmpty( + ['macOS', info.osRelease], + ); + return DeviceProfile( + displayName: displayName, + deviceId: deviceId, + model: info.model, + manufacturer: 'Apple', + osVersion: osVersion, + platform: 'macos', + ); + case TargetPlatform.windows: + final info = await deviceInfo.windowsInfo; + logger.d("Fetched Windows device info: $info"); + final displayName = _firstNonEmpty( + [info.computerName, info.productName], + 'Windows Device', + ); + final deviceId = _firstNonEmpty( + [info.deviceId, info.computerName, info.productName], + 'WINDOWS-DEVICE', + ); + final osVersion = _joinNonEmpty( + ['Windows', info.displayVersion, info.buildNumber.toString()], + ); + return DeviceProfile( + displayName: displayName, + deviceId: deviceId, + model: info.productName, + manufacturer: 'Microsoft', + osVersion: osVersion, + platform: 'windows', + ); + case TargetPlatform.linux: + final info = await deviceInfo.linuxInfo; + logger.d("Fetched Linux device info: $info"); + final displayName = _firstNonEmpty( + [info.prettyName, info.name], + 'Linux Device', + ); + final deviceId = _firstNonEmpty( + [info.machineId, info.prettyName, info.name], + 'LINUX-DEVICE', + ); + final osVersion = _joinNonEmpty( + [info.name, info.version], + ); + return DeviceProfile( + displayName: displayName, + deviceId: deviceId, + model: info.prettyName, + manufacturer: null, + osVersion: osVersion, + platform: 'linux', + ); + case TargetPlatform.fuchsia: + break; + } + } catch (_) { + // Fall back to default profile below. + } + + return const DeviceProfile( + displayName: 'This Device', + deviceId: 'THIS-DEVICE-001', + platform: 'unknown', + ); + } + + @override + String toString() { + return 'DeviceProfile(displayName: $displayName, deviceId: $deviceId, model: $model, manufacturer: $manufacturer, osVersion: $osVersion, platform: $platform)'; + } +} + +String _firstNonEmpty(List candidates, String fallback) { + for (final candidate in candidates) { + if (candidate == null) continue; + final trimmed = candidate.trim(); + if (trimmed.isNotEmpty) return trimmed; + } + return fallback; +} + +String? _joinNonEmpty(List parts) { + final cleaned = []; + for (final part in parts) { + if (part == null) continue; + final trimmed = part.trim(); + if (trimmed.isNotEmpty) cleaned.add(trimmed); + } + if (cleaned.isEmpty) return null; + return cleaned.join(' '); +} + +// Android's Build fields often expose manufacturer names in lowercase. Keep +// model tokens intact and only normalize known brand/manufacturer tokens. +String? _formatAndroidDeviceDisplayName(String? value) { + final trimmed = value?.trim(); + if (trimmed == null || trimmed.isEmpty) { + return null; + } + + return trimmed + .split(RegExp(r'\s+')) + .map(_formatAndroidDeviceDisplayNameWord) + .join(' '); +} + +// Returns canonical UI casing for common Android manufacturer tokens. +String _formatAndroidDeviceDisplayNameWord(String word) { + if (word.isEmpty || word != word.toLowerCase()) { + return word; + } + + return switch (word) { + 'google' => 'Google', + 'samsung' => 'Samsung', + 'oneplus' => 'OnePlus', + 'xiaomi' => 'Xiaomi', + 'huawei' => 'Huawei', + 'honor' => 'Honor', + 'motorola' => 'Motorola', + 'sony' => 'Sony', + 'oppo' => 'OPPO', + 'vivo' => 'vivo', + 'realme' => 'realme', + 'nokia' => 'Nokia', + 'fairphone' => 'Fairphone', + 'nothing' => 'Nothing', + 'asus' => 'ASUS', + 'lenovo' => 'Lenovo', + 'lg' => 'LG', + 'htc' => 'HTC', + _ => word, + }; +} + +/// Adapts a `sensors_plus` event stream to the OpenEarable sensor interface. +class ThisDeviceSensor extends Sensor { + final DeviceSensorConfiguration config; + late final StreamController _controller; + StreamSubscription? _subscription; + final Stream Function({required Duration samplingPeriod}) + _sensorStreamProvider; + final SensorDoubleValue Function(SensorEvent event) _valueExtractor; + + /// Creates a sensor adapter for a single host-device sensor stream. + ThisDeviceSensor({ + required super.sensorName, + required super.chartTitle, + required super.shortChartTitle, + required this.config, + required List axisNames, + required List axisUnits, + required SensorDoubleValue Function(SensorEvent event) valueExtractor, + required Stream Function({required Duration samplingPeriod}) + sensorStreamProvider, + }) : _axisNames = axisNames, + _axisUnits = axisUnits, + _valueExtractor = valueExtractor, + _sensorStreamProvider = sensorStreamProvider { + _controller = StreamController.broadcast( + onListen: _updateSubscription, + onCancel: _updateSubscription, + ); + config.changes.listen((value) { + _updateSubscription(); + }); + } + + final List _axisNames; + @override + List get axisNames => _axisNames; + + final List _axisUnits; + @override + List get axisUnits => _axisUnits; + + @override + Stream get sensorStream => _controller.stream; + + void _updateSubscription() { + if (!_controller.hasListener) { + _cancelSubscription(); + return; + } + + final value = config.currentValue; + if (value.isOff) { + _cancelSubscription(); + return; + } + + final samplingPeriod = value.frequencyHz > 0 + ? Duration(milliseconds: (1000 / value.frequencyHz).round()) + : SensorInterval.normalInterval; + + _cancelSubscription(); + _subscription = + _sensorStreamProvider(samplingPeriod: samplingPeriod).listen( + (event) { + _controller.add( + _valueExtractor(event), + ); + }, + onError: _controller.addError, + ); + } + + void _cancelSubscription() { + _subscription?.cancel(); + _subscription = null; + } +} + +/// Frequency configuration shared by host-device sensor streams. +class DeviceSensorConfiguration + extends SensorFrequencyConfiguration { + final void Function( + SensorConfiguration configuration, + SensorConfigurationValue value, + ) onChange; + + DeviceSensorFrequencyValue _currentValue; + + /// Creates a frequency configuration with the standard host-device values. + DeviceSensorConfiguration({ + required super.name, + required this.onChange, + }) : _currentValue = DeviceSensorFrequencyValue.off(), + super( + values: DeviceSensorFrequencyValue.defaults(), + offValue: DeviceSensorFrequencyValue.off(), + ); + + DeviceSensorFrequencyValue get currentValue => _currentValue; + + /// Emits every frequency value applied to this host-device sensor. + Stream get changes => _changesController.stream; + + final StreamController _changesController = + StreamController.broadcast(); + + @override + void setConfiguration(DeviceSensorFrequencyValue configuration) { + _currentValue = configuration; + onChange(this, configuration); + _changesController.add(configuration); + } +} + +/// Sampling frequency option for host-device sensors. +class DeviceSensorFrequencyValue extends SensorFrequencyConfigurationValue { + DeviceSensorFrequencyValue({ + required super.frequencyHz, + String? key, + }) : super( + key: key ?? _formatKey(frequencyHz), + ); + + /// Whether this value disables sampling for the sensor. + bool get isOff => frequencyHz <= 0; + + /// Creates the disabled sampling option. + static DeviceSensorFrequencyValue off() { + return DeviceSensorFrequencyValue( + frequencyHz: 0, + key: 'Off', + ); + } + + /// Creates the default interactive sampling option. + static DeviceSensorFrequencyValue normal() { + return DeviceSensorFrequencyValue( + frequencyHz: 5, + ); + } + + /// Returns the standard frequency choices shown for host-device sensors. + static List defaults() { + return [ + off(), + fromHz(1), + normal(), + fromHz(10), + fromHz(15), + fromHz(20), + fromHz(30), + fromHz(50), + fromHz(60), + fromHz(100), + fromHz(200), + ]; + } + + /// Creates a sampling option for the provided frequency. + static DeviceSensorFrequencyValue fromHz(double frequencyHz) { + return DeviceSensorFrequencyValue( + frequencyHz: frequencyHz, + ); + } + + static String _formatKey(double frequencyHz) { + if (frequencyHz == frequencyHz.roundToDouble()) { + return '${frequencyHz.toInt()} Hz'; + } + return '${frequencyHz.toStringAsFixed(2)} Hz'; + } +} 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/view_models/wearables_provider.dart b/open_wearable/lib/view_models/wearables_provider.dart index f81bba4e..c59a0d67 100644 --- a/open_wearable/lib/view_models/wearables_provider.dart +++ b/open_wearable/lib/view_models/wearables_provider.dart @@ -344,13 +344,33 @@ class WearablesProvider with ChangeNotifier { config.values.isNotEmpty) { notifier.addSensorConfiguration( config, - config.values.first, + _initialSensorConfigurationValue(config), markPending: false, ); } } } + /// Returns the best initial value for a sensor configuration. + /// + /// Some local configuration implementations expose a `currentValue` before + /// the provider subscribes to configuration reports. Prefer that value so the + /// UI and pending-apply state start from the device's actual configuration. + SensorConfigurationValue _initialSensorConfigurationValue( + SensorConfiguration config, + ) { + final dynamic configDynamic = config; + try { + final currentValue = configDynamic.currentValue; + if (currentValue is SensorConfigurationValue) { + return currentValue; + } + } catch (_) { + // Fall back to the first advertised value below. + } + return config.values.first; + } + /// Attempts to pair a stereo device with a matching partner among the /// already-known wearables. Runs asynchronously and logs results. /// Non-blocking for the caller. 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..67bd2b32 --- /dev/null +++ b/open_wearable/lib/widgets/connector_activity_indicator.dart @@ -0,0 +1,90 @@ +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 status pill shown while an external connector is active. +class ConnectorActivityIndicator extends StatelessWidget { + const ConnectorActivityIndicator({ + super.key, + this.statusListenable, + this.onOpenSettings, + }); + + /// 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; + + ValueListenable _resolveStatusListenable() { + return statusListenable ?? + ConnectorSettings.webSocketRuntimeStatusListenable; + } + + void _openSettings() { + if (onOpenSettings != null) { + onOpenSettings!(); + return; + } + + rootNavigatorKey.currentContext?.push('/settings/connectors'); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: _resolveStatusListenable(), + builder: (context, status, _) { + if (!status.isActive) { + return const SizedBox.shrink(); + } + + final colorScheme = Theme.of(context).colorScheme; + final foregroundColor = + status.isHealthy ? const Color(0xFF1E6A3A) : colorScheme.error; + final label = status.isHealthy + ? 'Connector active' + : 'Connector active, network unavailable'; + + return Padding( + padding: const EdgeInsetsDirectional.only(end: 2), + child: Tooltip( + message: label, + child: Semantics( + button: true, + label: label, + liveRegion: true, + child: InkWell( + customBorder: const StadiumBorder(), + onTap: _openSettings, + child: Container( + height: 32, + constraints: const BoxConstraints(minWidth: 40), + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: ShapeDecoration( + color: foregroundColor.withValues(alpha: 0.12), + shape: StadiumBorder( + side: BorderSide( + color: foregroundColor.withValues(alpha: 0.32), + ), + ), + ), + alignment: Alignment.center, + child: Icon( + ConnectorBranding.icon, + size: 16, + color: foregroundColor, + ), + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/open_wearable/lib/widgets/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/devices/connect_devices_page.dart b/open_wearable/lib/widgets/devices/connect_devices_page.dart index f9ffd08d..bb453e12 100644 --- a/open_wearable/lib/widgets/devices/connect_devices_page.dart +++ b/open_wearable/lib/widgets/devices/connect_devices_page.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; @@ -6,7 +7,9 @@ import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; import 'package:universal_ble/universal_ble.dart'; import 'package:open_wearable/models/connect_devices_scan_session.dart'; import 'package:open_wearable/models/device_name_formatter.dart'; +import 'package:open_wearable/models/this_device_wearable.dart'; import 'package:open_wearable/models/wearable_display_group.dart'; +import 'package:open_wearable/view_models/sensor_recorder_provider.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:open_wearable/widgets/devices/devices_page.dart'; import 'package:open_wearable/widgets/recording_activity_indicator.dart'; @@ -32,6 +35,7 @@ class _ConnectDevicesPageState extends State { late ConnectDevicesScanSnapshot _scanSnapshot; late final VoidCallback _scanSnapshotListener; + DiscoveredDevice? _thisDeviceEntry; @override void initState() { @@ -50,6 +54,7 @@ class _ConnectDevicesPageState extends State { if (!_scanSnapshot.isScanning) { unawaited(ConnectDevicesScanSession.startScanning(clearPrevious: true)); } + unawaited(_addThisDeviceToDiscovered()); } @override @@ -68,9 +73,16 @@ class _ConnectDevicesPageState extends State { .toList(), ); - final availableDevices = _scanSnapshot.discoveredDevices + final scannedDevices = _scanSnapshot.discoveredDevices .where((device) => !connectedDeviceIds.contains(device.id)) .toList(); + final thisDeviceEntry = _thisDeviceEntry; + final availableDevices = [ + if (thisDeviceEntry != null && + !connectedDeviceIds.contains(thisDeviceEntry.id)) + thisDeviceEntry, + ...scannedDevices.where((device) => device.id != thisDeviceEntry?.id), + ]; return PlatformScaffold( appBar: PlatformAppBar( @@ -157,18 +169,32 @@ class _ConnectDevicesPageState extends State { ) else ...availableDevices.map( - (device) => Card( - margin: const EdgeInsets.only(bottom: 8), - child: PlatformListTile( - leading: const Icon(Icons.bluetooth), - title: PlatformText(_deviceName(device)), - subtitle: PlatformText(device.id), - trailing: _buildTrailingWidget(device), - onTap: _connectingDevices[device.id] == true - ? null - : () => _connectToDevice(device, context), - ), - ), + (device) { + final isThisDevice = device.id == _thisDeviceEntry?.id; + final connect = isThisDevice + ? () => _connectToThisDevice(context) + : () => _connectToDevice(device, context); + + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: PlatformListTile( + leading: Icon( + isThisDevice ? Icons.smartphone : Icons.bluetooth, + ), + title: PlatformText( + _deviceName(device, isThisDevice: isThisDevice), + ), + subtitle: PlatformText(device.id), + trailing: _buildTrailingWidget( + device, + onConnect: connect, + ), + onTap: _connectingDevices[device.id] == true + ? null + : connect, + ), + ); + }, ), const SizedBox(height: 10), PlatformElevatedButton( @@ -293,7 +319,10 @@ class _ConnectDevicesPageState extends State { ); } - Widget _buildTrailingWidget(DiscoveredDevice device) { + Widget _buildTrailingWidget( + DiscoveredDevice device, { + required VoidCallback onConnect, + }) { return SizedBox( width: 90, child: Align( @@ -308,17 +337,23 @@ class _ConnectDevicesPageState extends State { ), ) : PlatformTextButton( - onPressed: () => _connectToDevice(device, context), + onPressed: onConnect, child: const Text('Connect'), ), ), ); } - String _deviceName(DiscoveredDevice device) { + String _deviceName(DiscoveredDevice device, {required bool isThisDevice}) { final name = device.name.trim(); - if (name.isEmpty) return 'Unnamed device'; - return formatWearableDisplayName(name); + final displayName = + name.isEmpty ? 'Unnamed device' : formatWearableDisplayName(name); + + if (isThisDevice) { + return '$displayName (this device)'; + } + + return displayName; } String _formatScanTime(DateTime startedAt) { @@ -329,6 +364,49 @@ class _ConnectDevicesPageState extends State { return '${elapsed.inHours}h ago'; } + Future _addThisDeviceToDiscovered() async { + if (_thisDeviceEntry != null) return; + final profile = await DeviceProfile.fetch(); + if (!mounted) return; + + final thisDevice = DiscoveredDevice( + id: profile.deviceId, + name: profile.displayName, + manufacturerData: Uint8List(0), + rssi: 0, + serviceUuids: const [], + ); + + setState(() { + _thisDeviceEntry = thisDevice; + }); + } + + Future _connectToThisDevice(BuildContext context) async { + final device = _thisDeviceEntry; + if (device == null) return; + if (_connectingDevices[device.id] == true) return; + + setState(() { + _connectingDevices[device.id] = true; + }); + + try { + final wearable = await ThisDeviceWearable.create( + disconnectNotifier: WearableDisconnectNotifier(), + ); + if (!context.mounted) return; + context.read().addWearable(wearable); + context.read().addWearable(wearable); + } finally { + if (context.mounted) { + setState(() { + _connectingDevices.remove(device.id); + }); + } + } + } + Future _connectToDevice( DiscoveredDevice device, BuildContext context, diff --git a/open_wearable/lib/widgets/devices/device_detail/device_detail_page.dart b/open_wearable/lib/widgets/devices/device_detail/device_detail_page.dart index d3306d12..8197a289 100644 --- a/open_wearable/lib/widgets/devices/device_detail/device_detail_page.dart +++ b/open_wearable/lib/widgets/devices/device_detail/device_detail_page.dart @@ -308,7 +308,7 @@ class _DeviceDetailPageState extends State { Widget _buildHeaderCard(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final hasWearableIcon = widget.device.getWearableIconPath() != null; + final hasWearableIcon = WearableIcon.hasIcon(widget.device); final statusPills = buildDeviceStatusPills( wearable: widget.device, diff --git a/open_wearable/lib/widgets/devices/devices_page.dart b/open_wearable/lib/widgets/devices/devices_page.dart index 38e29096..99b46db7 100644 --- a/open_wearable/lib/widgets/devices/devices_page.dart +++ b/open_wearable/lib/widgets/devices/devices_page.dart @@ -7,6 +7,7 @@ import 'package:open_wearable/models/wearable_connector.dart'; import 'package:open_wearable/models/wearable_display_group.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:open_wearable/widgets/common/no_devices_prompt.dart'; +import 'package:open_wearable/widgets/connector_activity_indicator.dart'; import 'package:open_wearable/widgets/devices/connect_devices_page.dart'; import 'package:open_wearable/widgets/devices/device_detail/audio_mode_widget.dart'; import 'package:open_wearable/widgets/devices/device_detail/device_detail_page.dart'; @@ -61,6 +62,7 @@ class _DevicesPageState extends State { title: PlatformText("Devices"), trailingActions: [ const AppBarRecordingIndicator(), + const ConnectorActivityIndicator(), PlatformIconButton( icon: Icon(context.platformIcons.bluetooth), onPressed: () { @@ -267,8 +269,7 @@ class DeviceRow extends StatelessWidget { final pairKey = group.stereoPairKey; final knownIconVariant = _resolveWearableIconVariant(); final hasWearableIcon = showWearableIcon && - (primary.getWearableIconPath(variant: knownIconVariant)?.isNotEmpty ?? - false); + WearableIcon.hasIcon(primary, variant: knownIconVariant); final topRightIdentifierLabel = _buildTopRightIdentifierLabel(); final statusPills = _buildDeviceStatusPills( primary, diff --git a/open_wearable/lib/widgets/devices/wearable_icon.dart b/open_wearable/lib/widgets/devices/wearable_icon.dart index 6a11d974..d350a382 100644 --- a/open_wearable/lib/widgets/devices/wearable_icon.dart +++ b/open_wearable/lib/widgets/devices/wearable_icon.dart @@ -23,6 +23,24 @@ class WearableIcon extends StatefulWidget { this.fallback, }); + /// Returns whether [wearable] can render a custom asset through this widget. + static bool hasIcon( + Wearable wearable, { + WearableIconVariant variant = WearableIconVariant.single, + }) { + final variantPath = wearable.getWearableIconPath(variant: variant); + if (variantPath != null && variantPath.isNotEmpty) { + return true; + } + + if (variant != WearableIconVariant.single) { + final fallbackPath = wearable.getWearableIconPath(); + return fallbackPath != null && fallbackPath.isNotEmpty; + } + + return false; + } + @override State createState() => _WearableIconState(); } diff --git a/open_wearable/lib/widgets/global_app_banner_overlay.dart b/open_wearable/lib/widgets/global_app_banner_overlay.dart index 8282a1ea..f53c1117 100644 --- a/open_wearable/lib/widgets/global_app_banner_overlay.dart +++ b/open_wearable/lib/widgets/global_app_banner_overlay.dart @@ -39,7 +39,6 @@ class GlobalAppBannerOverlay extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const SizedBox(height: 6), ...banners.map( (banner) => Padding( padding: const EdgeInsets.fromLTRB(10, 0, 10, 8), 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/home_page_overview.dart b/open_wearable/lib/widgets/home_page_overview.dart index 3e8c1f40..19edbdb5 100644 --- a/open_wearable/lib/widgets/home_page_overview.dart +++ b/open_wearable/lib/widgets/home_page_overview.dart @@ -5,6 +5,7 @@ import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_wearable/models/device_name_formatter.dart'; import 'package:open_wearable/view_models/sensor_recorder_provider.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; +import 'package:open_wearable/widgets/connector_activity_indicator.dart'; import 'package:open_wearable/widgets/devices/device_detail/device_detail_page.dart'; import 'package:open_wearable/widgets/recording_activity_indicator.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; @@ -31,6 +32,7 @@ class OverviewPage extends StatelessWidget { title: const Text('Overview'), trailingActions: [ const AppBarRecordingIndicator(), + const ConnectorActivityIndicator(), PlatformIconButton( icon: Icon(context.platformIcons.bluetooth), onPressed: onConnectRequested, diff --git a/open_wearable/lib/widgets/sensors/sensor_page.dart b/open_wearable/lib/widgets/sensors/sensor_page.dart index 6b5fdf86..9fd9455e 100644 --- a/open_wearable/lib/widgets/sensors/sensor_page.dart +++ b/open_wearable/lib/widgets/sensors/sensor_page.dart @@ -5,6 +5,7 @@ import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_wearable/view_models/sensor_data_provider.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:open_wearable/widgets/common/no_devices_prompt.dart'; +import 'package:open_wearable/widgets/connector_activity_indicator.dart'; import 'package:open_wearable/widgets/recording_activity_indicator.dart'; import 'package:open_wearable/widgets/sensors/configuration/sensor_configuration_view.dart'; import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_view.dart'; @@ -124,6 +125,7 @@ class _SensorPageState extends State title: PlatformText("Sensors"), actions: [ const AppBarRecordingIndicator(), + const ConnectorActivityIndicator(), PlatformIconButton( icon: Icon(context.platformIcons.bluetooth), onPressed: () { diff --git a/open_wearable/lib/widgets/settings/connectors_page.dart b/open_wearable/lib/widgets/settings/connectors_page.dart new file mode 100644 index 00000000..c65dfc09 --- /dev/null +++ b/open_wearable/lib/widgets/settings/connectors_page.dart @@ -0,0 +1,618 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:open_wearable/models/connector_settings.dart'; +import 'package:open_wearable/models/network/device_ip_address.dart'; +import 'package:open_wearable/widgets/app_toast.dart'; +import 'package:open_wearable/widgets/connector_branding.dart'; +import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class ConnectorsPage extends StatefulWidget { + const ConnectorsPage({super.key}); + + @override + State createState() => _ConnectorsPageState(); +} + +class _ConnectorsPageState extends State { + static final Uri _webSocketDocumentationUri = Uri.parse( + 'https://github.com/OpenEarable/app/blob/main/open_wearable/docs/connectors/websocket-ipc-api.md', + ); + + late final TextEditingController _portController; + late final TextEditingController _pathController; + late final ValueListenable _runtimeStatusListenable; + + 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(); + _runtimeStatusListenable = + ConnectorSettings.webSocketRuntimeStatusListenable; + _runtimeStatusListenable.addListener(_syncCurrentIpAddress); + _loadSettings(); + } + + @override + void dispose() { + _runtimeStatusListenable.removeListener(_syncCurrentIpAddress); + _portController.dispose(); + _pathController.dispose(); + super.dispose(); + } + + void _syncCurrentIpAddress() { + final status = _runtimeStatusListenable.value; + if (status.state != ConnectorRuntimeState.running) { + return; + } + if (_currentIpAddress == status.reachableNetworkAddress) { + return; + } + setState(() { + _currentIpAddress = status.reachableNetworkAddress; + }); + } + + Future _loadSettings() async { + try { + final settingsFuture = ConnectorSettings.loadWebSocketSettings(); + 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 _openWebSocketDocumentation() async { + final opened = await launchUrl( + _webSocketDocumentationUri, + mode: LaunchMode.externalApplication, + ); + if (opened || !mounted) { + return; + } + + AppToast.show( + context, + message: 'Could not open WebSocket connector documentation.', + type: AppToastType.error, + icon: Icons.link_off_rounded, + ); + } + + Future _saveSettings() async { + if (_isSaving) { + return; + } + + 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: _runtimeStatusListenable, + 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 => + runtimeStatus.isHealthy ? const Color(0xFF1E6A3A) : colorScheme.error, + 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; + }); + }, + ), + ], + ), + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton.icon( + onPressed: _openWebSocketDocumentation, + icon: const Icon(Icons.open_in_new_rounded, size: 16), + label: const Text('View documentation'), + ), + ), + const SizedBox(height: 10), + InputDecorator( + decoration: InputDecoration( + 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 when status.hasReachableNetworkAddress => ( + 'Running', + endpoint, + const Color(0xFF1E6A3A), + ), + ConnectorRuntimeState.running => ( + 'Network unavailable', + 'Connector is on, but no local network address is available.', + colorScheme.error, + ), + 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..dccdf7c3 100644 --- a/open_wearable/lib/widgets/settings/settings_page.dart +++ b/open_wearable/lib/widgets/settings/settings_page.dart @@ -8,6 +8,8 @@ import 'package:open_wearable/models/app_upgrade_registry.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:open_wearable/widgets/app_toast.dart'; +import 'package:open_wearable/widgets/connector_activity_indicator.dart'; +import 'package:open_wearable/widgets/connector_branding.dart'; import 'package:open_wearable/widgets/recording_activity_indicator.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; @@ -15,12 +17,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 @@ -30,6 +34,7 @@ class SettingsPage extends StatelessWidget { title: const Text('Settings'), trailingActions: [ const AppBarRecordingIndicator(), + const ConnectorActivityIndicator(), PlatformIconButton( icon: Icon(context.platformIcons.bluetooth), onPressed: onConnectRequested, @@ -58,6 +63,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/linux/flutter/generated_plugins.cmake b/open_wearable/linux/flutter/generated_plugins.cmake index 18a2dcb9..6462693b 100644 --- a/open_wearable/linux/flutter/generated_plugins.cmake +++ b/open_wearable/linux/flutter/generated_plugins.cmake @@ -10,6 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift index 696f95be..39bfb0ef 100644 --- a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,6 +6,7 @@ import FlutterMacOS import Foundation import audioplayers_darwin +import device_info_plus import file_picker import file_selector_macos import flutter_archive @@ -19,6 +20,7 @@ import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterArchivePlugin.register(with: registry.registrar(forPlugin: "FlutterArchivePlugin")) diff --git a/open_wearable/macos/Podfile.lock b/open_wearable/macos/Podfile.lock index e2338e0f..1d89c73e 100644 --- a/open_wearable/macos/Podfile.lock +++ b/open_wearable/macos/Podfile.lock @@ -1,6 +1,7 @@ PODS: - audioplayers_darwin (0.0.1): - Flutter + - device_info_plus (0.0.1): - FlutterMacOS - file_picker (0.0.1): - FlutterMacOS @@ -14,6 +15,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): @@ -30,11 +36,13 @@ PODS: DEPENDENCIES: - audioplayers_darwin (from `Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/darwin`) + - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) - flutter_archive (from `Flutter/ephemeral/.symlinks/plugins/flutter_archive/macos`) - 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`) @@ -49,6 +57,8 @@ SPEC REPOS: EXTERNAL SOURCES: audioplayers_darwin: :path: Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/darwin + device_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos file_picker: :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos file_selector_macos: @@ -59,6 +69,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: @@ -74,12 +86,15 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5 + device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76 file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7 flutter_archive: 07888d9aeb79da005e0ad8b9d347d17cdea07f68 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 open_file_mac: 76f06c8597551249bdb5e8fd8827a98eae0f4585 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 package_info_plus: f0052d280d17aa382b932f399edf32507174e870 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb universal_ble: ff19787898040d721109c6324472e5dd4bc86adc diff --git a/open_wearable/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/pubspec.lock b/open_wearable/pubspec.lock index 7031a8b8..39926ab2 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -185,6 +185,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.12" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd + url: "https://pub.dev" + source: hosted + version: "12.4.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" equatable: dependency: transitive description: @@ -346,10 +362,10 @@ packages: dependency: "direct main" description: name: flutter_platform_widgets - sha256: "22a86564cb6cc0b93637c813ca91b0b1f61c2681a31e0f9d77590c1fa9f12020" + sha256: aa110ef638076831d060047911a62810d02b4695db58e7682b716c4c4eee65bc url: "https://pub.dev" source: hosted - version: "9.0.0" + version: "10.0.1" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -404,18 +420,18 @@ packages: dependency: "direct main" description: name: go_router - sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + sha256: "08b742eef4f71c9df5af543751cd0b7f1c679c4088488f4223ecaddc1a813b79" url: "https://pub.dev" source: hosted - version: "14.8.1" + version: "17.2.2" hooks: dependency: transitive description: name: hooks - sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "1.0.3" http: dependency: "direct main" description: @@ -440,6 +456,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.2" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" json_annotation: dependency: transitive description: @@ -564,10 +596,10 @@ packages: dependency: "direct main" description: name: open_earable_flutter - sha256: "078c8a64ad05265b5b7afae991830549e08729fecacfd255dc4a8e038f8ad12b" + sha256: b55a2e70ab5ee7ce7d46cebd65f1463a0a83684aa5849e9e3e2526471c0a4b02 url: "https://pub.dev" source: hosted - version: "2.3.5" + version: "2.3.6" open_file: dependency: "direct main" description: @@ -632,6 +664,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.3" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" package_info_plus: dependency: "direct main" description: @@ -676,10 +716,10 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba" + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" url: "https://pub.dev" source: hosted - version: "2.2.23" + version: "2.3.1" path_provider_foundation: dependency: transitive description: @@ -801,13 +841,21 @@ packages: source: hosted version: "6.1.5+1" pub_semver: - dependency: transitive + dependency: "direct main" description: name: pub_semver sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" url: "https://pub.dev" source: hosted version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" rxdart: dependency: transitive description: @@ -816,6 +864,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + sensors_plus: + dependency: "direct main" + description: + name: sensors_plus + sha256: "56e8cd4260d9ed8e00ecd8da5d9fdc8a1b2ec12345a750dfa51ff83fcf12e3fa" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + sensors_plus_platform_interface: + dependency: transitive + description: + name: sensors_plus_platform_interface + sha256: "58815d2f5e46c0c41c40fb39375d3f127306f7742efe3b891c0b1c87e2b5cd5d" + url: "https://pub.dev" + source: hosted + version: "2.0.1" share_plus: dependency: "direct main" description: @@ -929,10 +993,10 @@ packages: dependency: transitive description: name: synchronized - sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" url: "https://pub.dev" source: hosted - version: "3.4.0" + version: "3.4.0+1" term_glyph: dependency: transitive description: @@ -1081,26 +1145,26 @@ packages: dependency: transitive description: name: vm_service - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + sha256: "046d3928e16fa4dc46e8350415661755ab759d9fc97fc21b5ab295f71e4f0499" url: "https://pub.dev" source: hosted - version: "15.0.2" + version: "15.1.0" wakelock_plus: dependency: "direct main" description: name: wakelock_plus - sha256: "8b12256f616346910c519a35606fb69b1fe0737c06b6a447c6df43888b097f39" + sha256: ddf3db70eaa10c37558ff817519b85d527dbd21034fd5d8e1c2e85f31588f1c1 url: "https://pub.dev" source: hosted - version: "1.5.1" + version: "1.5.2" wakelock_plus_platform_interface: dependency: transitive description: name: wakelock_plus_platform_interface - sha256: "24b84143787220a403491c2e5de0877fbbb87baf3f0b18a2a988973863db4b03" + sha256: "14b2e5b9e35c2631e656913c47adecdd71633ae92896a27a64c8f1fcfabc21cc" url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.5.0" web: dependency: transitive description: @@ -1117,6 +1181,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.dev" + source: hosted + version: "2.1.0" xdg_directories: dependency: transitive description: @@ -1142,5 +1214,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.3 <4.0.0" - flutter: ">=3.38.4" + dart: ">=3.11.0 <4.0.0" + flutter: ">=3.41.0" diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index 6147e6aa..510d1c8b 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.1.1+1 +version: 1.2.0+1 environment: sdk: ^3.6.0 @@ -37,7 +37,7 @@ dependencies: open_file: ^3.3.2 open_earable_flutter: ^2.3.5 universal_ble: ^0.21.1 - flutter_platform_widgets: ^9.0.0 + flutter_platform_widgets: ^10.0.1 provider: ^6.1.2 logger: ^2.5.0 community_charts_flutter: ^1.0.4 @@ -54,11 +54,14 @@ dependencies: flutter_archive: ^6.0.3 shared_preferences: ^2.5.3 url_launcher: ^6.3.2 - go_router: ^14.6.2 + go_router: ^17.2.2 http: ^1.6.0 audioplayers: ^6.6.0 wakelock_plus: ^1.4.0 package_info_plus: ^9.0.0 + sensors_plus: ^7.0.0 + device_info_plus: ^12.3.0 + pub_semver: ^2.2.0 dev_dependencies: flutter_test: @@ -86,6 +89,7 @@ flutter: assets: - lib/apps/posture_tracker/assets/ - lib/apps/heart_tracker/assets/ + - lib/assets/devices/phone-app.png - android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png # An image asset can refer to one or more resolution-specific "variants", seeq diff --git a/open_wearable/test/models/app_upgrade_registry_test.dart b/open_wearable/test/models/app_upgrade_registry_test.dart new file mode 100644 index 00000000..e6970637 --- /dev/null +++ b/open_wearable/test/models/app_upgrade_registry_test.dart @@ -0,0 +1,15 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_wearable/models/app_upgrade_registry.dart'; + +void main() { + group('AppUpgradeRegistry', () { + test('registers version 1.2.0 as the latest upgrade highlight', () { + final highlight = AppUpgradeRegistry.forVersion('1.2.0'); + + expect(highlight, isNotNull); + expect(highlight?.version, '1.2.0'); + expect(AppUpgradeRegistry.latest?.version, '1.2.0'); + expect(AppUpgradeRegistry.all.first.version, '1.2.0'); + }); + }); +} 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..e081a2f5 --- /dev/null +++ b/open_wearable/test/widgets/connector_activity_indicator_test.dart @@ -0,0 +1,108 @@ +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); + expect(find.byIcon(ConnectorBranding.icon), findsNothing); + + statusNotifier.value = const ConnectorRuntimeStatus.starting(); + await tester.pump(); + expect(find.text('Connector'), findsNothing); + expect(find.byIcon(ConnectorBranding.icon), findsOneWidget); + + statusNotifier.value = const ConnectorRuntimeStatus.running(); + await tester.pump(); + expect(find.text('Connector'), findsNothing); + expect(find.byIcon(ConnectorBranding.icon), findsOneWidget); + + statusNotifier.value = const ConnectorRuntimeStatus.error('failed'); + await tester.pump(); + expect(find.text('Connector'), findsNothing); + expect(find.byIcon(ConnectorBranding.icon), findsNothing); + }); + + testWidgets('uses red styling when connector lacks Wi-Fi', (tester) async { + final statusNotifier = ValueNotifier( + const ConnectorRuntimeStatus.running(hasReachableNetworkAddress: false), + ); + addTearDown(statusNotifier.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ConnectorActivityIndicator( + statusListenable: statusNotifier, + ), + ), + ), + ); + + final icon = tester.widget(find.byIcon(ConnectorBranding.icon)); + expect(icon.color, ThemeData().colorScheme.error); + }); + + testWidgets('describes missing Wi-Fi in tooltip semantics', (tester) async { + final statusNotifier = ValueNotifier( + const ConnectorRuntimeStatus.running(hasReachableNetworkAddress: false), + ); + addTearDown(statusNotifier.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ConnectorActivityIndicator( + statusListenable: statusNotifier, + ), + ), + ), + ); + + expect( + find.byTooltip('Connector active, Wi-Fi unavailable'), + findsOneWidget, + ); + }); + + testWidgets('opens connector settings on tap', (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.tap(find.byIcon(ConnectorBranding.icon)); + await tester.pump(); + + expect(settingsOpenCount, 1); + }); +} diff --git a/open_wearable/windows/flutter/generated_plugins.cmake b/open_wearable/windows/flutter/generated_plugins.cmake index 154f7830..ed5b9c00 100644 --- a/open_wearable/windows/flutter/generated_plugins.cmake +++ b/open_wearable/windows/flutter/generated_plugins.cmake @@ -12,6 +12,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES)