diff --git a/assets/api/rest_v1.yml b/assets/api/rest_v1.yml index 87d29139d..ccafd9e78 100644 --- a/assets/api/rest_v1.yml +++ b/assets/api/rest_v1.yml @@ -7241,6 +7241,10 @@ components: type: type: string enum: [sensor, scale] + settingsEndpoint: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$' + description: Optional declared HTTP endpoint used by native per-device settings UI. Requires the api permission and a matching HTTP entry in the manifest api list. capabilities: type: array uniqueItems: true diff --git a/doc/Api.md b/doc/Api.md index a6652d14b..749bd9d6c 100644 --- a/doc/Api.md +++ b/doc/Api.md @@ -374,6 +374,11 @@ Settings fields include: `gatewayMode`, `themeMode`, `logLevel`, `weightFlowMult | ANY | `/api/v1/plugins/:id/:endpoint` | Plugin HTTP endpoint; requires `api` and returns 403 without it | | | WS | `/ws/v1/plugins/:id/:endpoint` | Plugin WebSocket endpoint | | +Plugin driver manifests may declare a `settingsEndpoint` that names a declared +HTTP endpoint for the native per-device settings action. The host validates +the endpoint and `api` permission; the plugin page remains responsible for +per-device validation and persistence. + Plugin setting updates use patch semantics for every field: an omitted field preserves the existing value, a field sent as `null` clears it, and a secure field sent as its returned `{ "isSet": true|false }` object preserves the diff --git a/doc/DeviceManagement.md b/doc/DeviceManagement.md index e32fafc3d..f455a021c 100644 --- a/doc/DeviceManagement.md +++ b/doc/DeviceManagement.md @@ -1725,3 +1725,17 @@ current ownership. See `doc/Plugins.md` for the session-bound GATT contract. - **State Manager:** Orchestrator for machine state changes and related behaviors - **UUID:** Universally Unique Identifier, used to identify BLE services/devices - **Service Mapping:** Dictionary mapping UUIDs to device factory functions + +## Plugin device settings + +The native device-management page lists machines, scales, and sensors. A +plugin device may expose an optional driver-declared HTTP settings endpoint. +When the host validates that endpoint against the loaded plugin manifest and +`api` permission, the page offers a settings action for that device. It opens +the plugin-owned page with the exact public device ID and name, preserving +per-physical-instance ownership and the plugin's existing persistence +authority. Devices are removed from eligibility when their binding retires. + +The action uses the platform's in-app browser where supported and its normal +fallback otherwise. A failed launch is shown in the native page. No native +settings schema or duplicate persistence store is created. diff --git a/doc/Plugins.md b/doc/Plugins.md index 080a22a7f..c7bc4697b 100644 --- a/doc/Plugins.md +++ b/doc/Plugins.md @@ -361,11 +361,8 @@ const upload = await fetch("https://api.example.com/upload", { Manifest parsing accepts the separate `transport.ble` permission, `scale` driver type, Scale capabilities, and one `ble.match` declaration per plugin. -This branch does not yet implement runtime BLE binding. Public non-BLE Scale -registration is available as described below; end-to-end API and timing -acceptance remain in progress. -Accepting a declaration does not grant GATT access. See -`doc/plans/issue-809-design.md` for the remaining implementation and tests. +Runtime BLE binding and public non-BLE Scale registration are available as +described below. Accepting a declaration does not grant GATT access. The matcher supports one case-insensitive `name` predicate (`exact`, `prefix`, or `contains`, 1-248 characters), and/or `serviceUuids` (1-64 UUIDs). It does not @@ -411,8 +408,10 @@ function createPlugin(host) { } ``` -Each connect invocation receives a fresh context with `transport`, -`publish(snapshot)`, and `reportDisconnected()`. Network `transport` uses the +Each connect invocation receives a fresh context with `connectionId`, +`transport`, `publish(snapshot)`, `publishInfo(info)`, and +`reportDisconnected()`. `connectionId` is an opaque, read-only identity for +that connection session; it is not GATT authority. Network `transport` uses the existing invocation-owned transport API and requires the corresponding network permission. Capture this context in protocol callbacks; do not look up a mutable current context when a delayed callback runs. The host rejects stale-session @@ -431,6 +430,14 @@ Optional finite `flow` and nonnegative integer `timerMs` require `flow` and `timerTelemetry` capabilities respectively. Battery requires `battery`. Arbitrary timestamps and unknown publication fields are rejected. +`publishInfo({firmwareVersion, batteryLevel})` publishes connected-session +metadata for Scale drivers only. `firmwareVersion` is an opaque string or +`null`; `batteryLevel` is an integer from 0 through 100 or `null`. Unknown +fields, invalid types, and Sensor metadata are rejected. Metadata is cleared on +connect, disconnect, replacement, failure, unload, and stale-session cleanup; +it is not included in device inventory. A non-null `batteryLevel` requires the +Scale driver's `battery` capability. + Declare optional commands in manifest `capabilities`: `tare` requires a `tare` handler; `timerControl` requires `startTimer`, `stopTimer`, and `resetTimer`; `displayControl` requires `sleepDisplay` and `wakeDisplay`. Host registration @@ -781,6 +788,9 @@ existing REST/WebSocket paths. Each connection receives a fresh context: - `context.publish(snapshot)` and `context.reportDisconnected()` belong only to that connection. Retaining a context cannot authorize a replacement session. +- Scale contexts also expose `connectionId` and `publishInfo(info)`; the ID is + session identity only, and metadata is connected-session state rather than + inventory data. - `context.gatt.discoverServices()` returns normalized 128-bit service UUIDs. - `read(service, characteristic)` returns base64 bytes. - `writeWithResponse(service, characteristic, base64)` and @@ -847,6 +857,26 @@ __httpRequestHandler: function (request) { } ``` +A driver may optionally declare `settingsEndpoint` with the ID of one declared +HTTP endpoint. The manifest must also request `api`; Decaid validates that the +endpoint exists and is HTTP before exposing a native device-settings action. +The native action opens the plugin-owned page with `ui=1`, the exact public +`deviceId`, and the device name as query parameters. The plugin remains the +authority for validation and persistence, so this is separate from the +plugin-global `/settings` API and must not add another host settings store. + +```json +{ + "permissions": ["api"], + "drivers": [{ + "id": "scale", + "type": "scale", + "settingsEndpoint": "device-settings" + }], + "api": [{"id": "device-settings", "type": "http", "data": {}}] +} +``` + A `handleHttpRequest` method on the object `createPlugin` returns works the same way — the loader aliases it to `__httpRequestHandler` at load. diff --git a/doc/plans/archive/generic-native-device-settings/README.md b/doc/plans/archive/generic-native-device-settings/README.md new file mode 100644 index 000000000..200a5a9ec --- /dev/null +++ b/doc/plans/archive/generic-native-device-settings/README.md @@ -0,0 +1,21 @@ +# Generic native device settings + +This archive records the design for the generic native device-settings entry. + +Plugin drivers may opt in by naming one declared HTTP API endpoint as +`settingsEndpoint`. The host validates the plugin's `api` permission and the +endpoint type before exposing the action. The device descriptor is native-only +and carries the plugin ID and endpoint ID; public inventory does not gain a +second settings field. + +The native device page constructs a localhost URL from validated path +components and passes the stable public device ID and name. It opens the +plugin-owned HTML page with the existing `url_launcher` in-app browser mode, +whose platform fallback avoids a custom WebView lifecycle. The plugin endpoint +continues to own per-device validation and persistence through its existing +store. Plugin-global settings remain a separate authority. + +Eligibility follows the live device object. A retired or replaced binding +cannot be opened through a stale settings action. Same-model instances retain +independent descriptors and public IDs; plugin-generation teardown removes all +of that generation's eligible devices while sibling plugins remain unaffected. diff --git a/lib/src/plugins/plugin_ble_binding.dart b/lib/src/plugins/plugin_ble_binding.dart index 2451479a0..95dea47af 100644 --- a/lib/src/plugins/plugin_ble_binding.dart +++ b/lib/src/plugins/plugin_ble_binding.dart @@ -53,6 +53,7 @@ class PluginBleBinding { prepareConnection: prepareConnection, onReady: () => _session!.markReady(), invocationTimeout: invocationTimeout, + deviceSettings: _deviceSettings, definition: definition, ) : PluginScale( @@ -64,9 +65,18 @@ class PluginBleBinding { onReady: () => _session!.markReady(), invocationTimeout: invocationTimeout, capabilities: driver.declaration.capabilities, + deviceSettings: _deviceSettings, ); } + PluginDeviceSettings? get _deviceSettings => + driver.declaration.settingsEndpoint == null + ? null + : PluginDeviceSettings( + pluginId: driver.pluginId, + endpointId: driver.declaration.settingsEndpoint!, + ); + bool get occupied => _session != null && _session!.state != PluginBleSessionState.closed; @@ -202,6 +212,18 @@ class PluginBleBinding { } } + void publishInfo(Map info, String? domainSession) { + _checkPublication(domainSession); + final target = device; + if (target is! PluginScale) { + throw const PluginBleException( + 'invalid_argument', + 'Device metadata is only supported by plugin scales', + ); + } + target.publishInfo(info, session: domainSession); + } + void reportDisconnected(String? domainSession) { _checkPublication(domainSession); device.reportDisconnected(session: domainSession); diff --git a/lib/src/plugins/plugin_ble_bridge.dart b/lib/src/plugins/plugin_ble_bridge.dart index 7167550b7..f51745549 100644 --- a/lib/src/plugins/plugin_ble_bridge.dart +++ b/lib/src/plugins/plugin_ble_bridge.dart @@ -75,6 +75,7 @@ const __bindBleDriver = (driverId, factory) => { if (cleanup) return Object.freeze({gatt}); return Object.freeze({ gatt, + connectionId: payload.session, publish: (snapshot, sample) => record.disconnected ? Promise.reject(stale()) : __deviceCall('blePublish', { registrationHandle: handle, session: payload.session, snapshot, sample }).catch(error => { @@ -88,6 +89,9 @@ const __bindBleDriver = (driverId, factory) => { } throw error; }), + publishInfo: info => record.disconnected ? Promise.reject(stale()) : __deviceCall('blePublishInfo', { + registrationHandle: handle, session: payload.session, info + }), reportDisconnected: () => record.disconnected ? Promise.reject(stale()) : __deviceCall('bleDisconnected', { registrationHandle: handle, session: payload.session }) diff --git a/lib/src/plugins/plugin_ble_service.dart b/lib/src/plugins/plugin_ble_service.dart index 9911475d2..101d0e3a8 100644 --- a/lib/src/plugins/plugin_ble_service.dart +++ b/lib/src/plugins/plugin_ble_service.dart @@ -215,6 +215,14 @@ class PluginBleService { handle, ).publish(snapshot, session, sample: sample); + void publishInfo( + String pluginId, + int generation, + String handle, + Map info, + String? session, + ) => _binding(pluginId, generation, handle).publishInfo(info, session); + void reportDisconnected( String pluginId, int generation, diff --git a/lib/src/plugins/plugin_bound_sensor.dart b/lib/src/plugins/plugin_bound_sensor.dart index e3ac4b6dc..68a9cd91c 100644 --- a/lib/src/plugins/plugin_bound_sensor.dart +++ b/lib/src/plugins/plugin_bound_sensor.dart @@ -15,6 +15,7 @@ class PluginBoundSensor extends PluginProtocolDevice implements Sensor { super.prepareConnection, required super.onReady, required super.invocationTimeout, + super.deviceSettings, required Map definition, }) : info = SensorInfo( name: name, diff --git a/lib/src/plugins/plugin_device_contract.dart b/lib/src/plugins/plugin_device_contract.dart index e828b65e3..35fc9c2be 100644 --- a/lib/src/plugins/plugin_device_contract.dart +++ b/lib/src/plugins/plugin_device_contract.dart @@ -31,6 +31,20 @@ class PluginDeviceException implements Exception { String toString() => message; } +class PluginDeviceSettings { + final String pluginId; + final String endpointId; + + const PluginDeviceSettings({ + required this.pluginId, + required this.endpointId, + }); +} + +abstract interface class DeviceSettingsCapable implements Device { + PluginDeviceSettings? get deviceSettings; +} + abstract class PluginDeviceAdapter implements Device { void publish(Map snapshot, {String? session}); void reportDisconnected({String? session}); diff --git a/lib/src/plugins/plugin_device_service.dart b/lib/src/plugins/plugin_device_service.dart index edeb59468..372ba01fb 100644 --- a/lib/src/plugins/plugin_device_service.dart +++ b/lib/src/plugins/plugin_device_service.dart @@ -8,6 +8,7 @@ import 'package:reaprime/src/models/device/scan_filter.dart'; import 'package:reaprime/src/models/device/sensor.dart'; import 'package:reaprime/src/models/device/transport/data_transport.dart'; import 'package:rxdart/rxdart.dart'; + import 'plugin_device_contract.dart'; import 'plugin_manifest.dart'; import 'plugin_scale.dart'; @@ -130,6 +131,12 @@ class PluginDeviceService implements DeviceDiscoveryService { } final deviceId = 'plugin:$pluginId:$driverId:$instanceId'; + final deviceSettings = driver?.settingsEndpoint == null + ? null + : PluginDeviceSettings( + pluginId: pluginId, + endpointId: driver!.settingsEndpoint!, + ); if (_registrations.values.any((sensor) => sensor.deviceId == deviceId)) { throw PluginDeviceException('Device already registered: $deviceId'); } @@ -140,6 +147,7 @@ class PluginDeviceService implements DeviceDiscoveryService { capabilities: driver!.capabilities, invoke: invoke, invocationTimeout: scaleInvocationTimeout, + deviceSettings: deviceSettings, ) : _PluginSensor( deviceId: deviceId, @@ -148,6 +156,7 @@ class PluginDeviceService implements DeviceDiscoveryService { dataChannels: parsePluginDataChannels(definition['dataChannels']), commands: parsePluginCommands(definition['commands']), invoke: invoke, + deviceSettings: deviceSettings, ); _registrations[key] = sensor; _publishDevices(); @@ -170,6 +179,24 @@ class PluginDeviceService implements DeviceDiscoveryService { ).publish(snapshot, session: session); } + void publishInfo({ + required String pluginId, + required int generation, + required String registrationHandle, + required Map info, + String? session, + }) { + _ensureActive(); + final device = _registration(pluginId, generation, registrationHandle); + if (device is! PluginScale) { + throw const PluginDeviceException( + 'Device metadata is only supported by plugin scales', + code: 'invalid_argument', + ); + } + device.publishInfo(info, session: session); + } + void reportDisconnected({ required String pluginId, required int generation, @@ -283,7 +310,8 @@ class PluginDeviceService implements DeviceDiscoveryService { } } -class _PluginSensor implements Sensor, PluginDeviceAdapter { +class _PluginSensor + implements Sensor, PluginDeviceAdapter, DeviceSettingsCapable { _PluginSensor({ required this.deviceId, required this.name, @@ -291,6 +319,7 @@ class _PluginSensor implements Sensor, PluginDeviceAdapter { required List dataChannels, required List commands, required PluginDeviceInvoker invoke, + this.deviceSettings, }) : _invoke = invoke, info = SensorInfo( name: name, @@ -303,6 +332,8 @@ class _PluginSensor implements Sensor, PluginDeviceAdapter { }; final PluginDeviceInvoker _invoke; + @override + final PluginDeviceSettings? deviceSettings; final Map _dataChannels; final BehaviorSubject _connectionState = BehaviorSubject.seeded(ConnectionState.discovered); diff --git a/lib/src/plugins/plugin_manager.dart b/lib/src/plugins/plugin_manager.dart index 76ccc8509..eaaea67f8 100644 --- a/lib/src/plugins/plugin_manager.dart +++ b/lib/src/plugins/plugin_manager.dart @@ -1310,6 +1310,23 @@ class PluginManager { sample: sample as String?, ); _replyDevice(requestId, bridgeToken, result: const {}); + case 'blePublishInfo': + final info = data['info']; + final session = data['session']; + if (info is! Map || (session != null && session is! String)) { + throw const PluginBleException( + 'invalid_argument', + 'Invalid BLE device metadata', + ); + } + bleService.publishInfo( + pluginId, + generation, + registrationHandle, + Map.from(info), + session as String?, + ); + _replyDevice(requestId, bridgeToken, result: const {}); case 'bleDisconnected': bleService.reportDisconnected( pluginId, @@ -1407,6 +1424,20 @@ class PluginManager { : null, ); _replyDevice(requestId, bridgeToken, result: const {}); + case 'publishInfo': + final info = data['info']; + final session = data['session']; + if (info is! Map || (session != null && session is! String)) { + throw const PluginDeviceException('Invalid plugin device metadata'); + } + deviceService.publishInfo( + pluginId: pluginId, + generation: generation, + registrationHandle: registrationHandle, + info: Map.from(info), + session: session as String?, + ); + _replyDevice(requestId, bridgeToken, result: const {}); case 'reportDisconnected': deviceService.reportDisconnected( pluginId: pluginId, @@ -2134,12 +2165,18 @@ class PluginManager { if (driver.type === "sensor") return transport; const session = payload.session; return Object.freeze({ + connectionId: session, transport: transport, publish(snapshot) { return __deviceCall("publish", { registrationHandle: registrationHandle, session: session, snapshot: snapshot }); }, + publishInfo(info) { + return __deviceCall("publishInfo", { + registrationHandle: registrationHandle, session: session, info: info + }); + }, reportDisconnected() { return __deviceCall("reportDisconnected", { registrationHandle: registrationHandle, session: session diff --git a/lib/src/plugins/plugin_manifest.dart b/lib/src/plugins/plugin_manifest.dart index c706bf9b9..6fef442c4 100644 --- a/lib/src/plugins/plugin_manifest.dart +++ b/lib/src/plugins/plugin_manifest.dart @@ -1,4 +1,5 @@ import 'package:collection/collection.dart'; + import 'plugin_ble_matcher.dart'; List parsePluginEnumValues(String key, dynamic schema) { @@ -55,12 +56,14 @@ class PluginDriverDeclaration { final PluginDriverType type; final PluginBleMatcher? ble; final Set capabilities; + final String? settingsEndpoint; const PluginDriverDeclaration({ required this.id, required this.type, this.ble, this.capabilities = const {}, + this.settingsEndpoint, }); factory PluginDriverDeclaration.fromJson(dynamic json) { @@ -105,11 +108,21 @@ class PluginDriverDeclaration { } ble = PluginBleMatcher.fromJson(declaration['match']); } + final settingsEndpoint = json['settingsEndpoint']; + if (settingsEndpoint != null || json.containsKey('settingsEndpoint')) { + if (settingsEndpoint is! String || + !RegExp( + r'^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$', + ).hasMatch(settingsEndpoint)) { + throw const FormatException('Invalid driver settings endpoint'); + } + } return PluginDriverDeclaration( id: id, type: type, ble: ble, capabilities: Set.unmodifiable(capabilities), + settingsEndpoint: settingsEndpoint as String?, ); } @@ -119,6 +132,7 @@ class PluginDriverDeclaration { if (capabilities.isNotEmpty) 'capabilities': capabilities.map((value) => value.name).toList(), if (ble != null) 'ble': {'match': ble!.toJson()}, + if (settingsEndpoint != null) 'settingsEndpoint': settingsEndpoint, }; } @@ -145,7 +159,22 @@ class PluginManifest { this.drivers = const [], required this.settings, required this.api, - }); + }) { + for (final driver in drivers) { + final endpoint = driver.settingsEndpoint; + if (endpoint == null) continue; + if (!permissions.contains(PluginPermissions.api) || + api?.endpoints.firstWhereOrNull( + (value) => + value.id == endpoint && value.type == ApiEndpointType.http, + ) == + null) { + throw FormatException( + 'Driver ${driver.id} settings endpoint is not a declared HTTP API endpoint', + ); + } + } + } factory PluginManifest.fromJson(Map json) { final settings = Map.from(json['settings'] ?? {}); diff --git a/lib/src/plugins/plugin_protocol_device.dart b/lib/src/plugins/plugin_protocol_device.dart index 3e404891d..bbfc48661 100644 --- a/lib/src/plugins/plugin_protocol_device.dart +++ b/lib/src/plugins/plugin_protocol_device.dart @@ -9,7 +9,8 @@ import 'package:uuid/uuid.dart'; import 'plugin_device_contract.dart'; -abstract class PluginProtocolDevice extends PluginDeviceAdapter { +abstract class PluginProtocolDevice extends PluginDeviceAdapter + implements DeviceSettingsCapable { @override final String deviceId; @override @@ -20,6 +21,8 @@ abstract class PluginProtocolDevice extends PluginDeviceAdapter { final Future Function(String session)? prepareConnection; final void Function()? onReady; final Duration invocationTimeout; + @override + final PluginDeviceSettings? deviceSettings; final BehaviorSubject _state = BehaviorSubject.seeded( ConnectionState.discovered, ); @@ -38,6 +41,7 @@ abstract class PluginProtocolDevice extends PluginDeviceAdapter { this.prepareConnection, this.onReady, this.invocationTimeout = const Duration(seconds: 5), + this.deviceSettings, }); @override @@ -45,6 +49,8 @@ abstract class PluginProtocolDevice extends PluginDeviceAdapter { @override Stream get connectionState => _state.stream; + String? get connectionId => _session; + void checkSession(String? session) { if (_disposed || session == null || diff --git a/lib/src/plugins/plugin_scale.dart b/lib/src/plugins/plugin_scale.dart index b2c1343b9..f431073c5 100644 --- a/lib/src/plugins/plugin_scale.dart +++ b/lib/src/plugins/plugin_scale.dart @@ -1,15 +1,21 @@ import 'dart:async'; +import 'dart:convert'; import 'package:clock/clock.dart'; import 'package:reaprime/src/models/device/device.dart'; import 'package:reaprime/src/models/device/scale.dart'; +import 'package:rxdart/rxdart.dart'; import 'plugin_device_contract.dart'; import 'plugin_manifest.dart'; import 'plugin_protocol_device.dart'; class PluginScale extends PluginProtocolDevice - implements Scale, ScaleSnapshotHandoff, DisconnectToSleepScale { + implements + Scale, + DeviceInformationCapable, + ScaleSnapshotHandoff, + DisconnectToSleepScale { final Set capabilities; final StreamController _snapshots = StreamController.broadcast(); @@ -17,6 +23,9 @@ class PluginScale extends PluginProtocolDevice Completer _firstWeight = Completer(); bool _active = false; DateTime? _lastTimestamp; + final BehaviorSubject _information = + BehaviorSubject.seeded(null); + static const _maxMetadataPayloadBytes = 64 * 1024; PluginScale({ required super.deviceId, @@ -27,17 +36,23 @@ class PluginScale extends PluginProtocolDevice super.prepareConnection, super.onReady, super.invocationTimeout, + super.deviceSettings, }) : capabilities = Set.unmodifiable(capabilities); @override DeviceType get type => DeviceType.scale; @override + DeviceInformation? get currentDeviceInformation => _information.value; + @override + Stream get deviceInformation => _information.stream; + @override bool get disconnectsToSleep => capabilities.contains(PluginScaleCapability.disconnectToSleep); @override Stream get currentSnapshot => _snapshots.stream; @override void beginSamples() { + _clearInformation(); _firstWeight = Completer(); _handoff.clear(); _active = false; @@ -46,6 +61,55 @@ class PluginScale extends PluginProtocolDevice @override Future waitForReadiness() => _firstWeight.future; + + void publishInfo(Map info, {String? session}) { + checkSession(session); + final firmwareVersion = info['firmwareVersion']; + final batteryLevel = info['batteryLevel']; + if (info.keys.any( + (key) => !const {'firmwareVersion', 'batteryLevel'}.contains(key), + ) || + (firmwareVersion != null && firmwareVersion is! String) || + (batteryLevel != null && + (batteryLevel is! int || + batteryLevel < 0 || + batteryLevel > 100 || + !capabilities.contains(PluginScaleCapability.battery)))) { + throw const PluginDeviceException( + 'Invalid Scale metadata', + code: 'invalid_argument', + ); + } + if (utf8.encode(jsonEncode(info)).length > _maxMetadataPayloadBytes) { + throw const PluginDeviceException( + 'Plugin device metadata exceeds 64 KiB', + code: 'resource_limit', + ); + } + final current = _information.value; + final information = DeviceInformation( + firmwareVersion: info.containsKey('firmwareVersion') + ? firmwareVersion as String? + : current?.firmwareVersion, + batteryLevel: info.containsKey('batteryLevel') + ? batteryLevel as int? + : current?.batteryLevel, + ); + _information.add(information.isEmpty ? null : information); + } + + void _clearInformation() { + if (!_information.isClosed && _information.value != null) { + _information.add(null); + } + } + + @override + Future disconnect() { + _clearInformation(); + return super.disconnect(); + } + @override void activateSnapshots() { if (_active) return; @@ -174,7 +238,9 @@ class PluginScale extends PluginProtocolDevice @override Future dispose() async { _handoff.clear(); + _clearInformation(); await super.dispose(); await _snapshots.close(); + await _information.close(); } } diff --git a/lib/src/settings/device_management_page.dart b/lib/src/settings/device_management_page.dart index bee85bf3c..3de8f118f 100644 --- a/lib/src/settings/device_management_page.dart +++ b/lib/src/settings/device_management_page.dart @@ -3,20 +3,27 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:reaprime/src/controllers/device_controller.dart'; import 'package:reaprime/src/models/device/device.dart'; +import 'package:reaprime/src/plugins/plugin_device_contract.dart'; +import 'package:reaprime/src/settings/plugin_device_settings.dart'; import 'package:reaprime/src/settings/settings_controller.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:url_launcher/url_launcher.dart'; + +typedef DeviceSettingsLauncher = Future Function(Uri uri); class DeviceManagementPage extends StatefulWidget { const DeviceManagementPage({ super.key, required this.settingsController, required this.deviceController, + this.settingsLauncher, }); static const routeName = '/devices'; final SettingsController settingsController; final DeviceController deviceController; + final DeviceSettingsLauncher? settingsLauncher; @override State createState() => _DeviceManagementPageState(); @@ -58,6 +65,15 @@ class _DeviceManagementPageState extends State { List get _scales => _devices.where((d) => d.type == DeviceType.scale).toList(); + List get _sensors => _devices + .where( + (d) => + d.type == DeviceType.sensor && + d is DeviceSettingsCapable && + d.deviceSettings != null, + ) + .toList(); + @override Widget build(BuildContext context) { return Scaffold( @@ -85,6 +101,16 @@ class _DeviceManagementPageState extends State { if (mounted) _showSavedSnackbar(); }, ), + if (_sensors.isNotEmpty) + _buildSection( + title: 'Sensors', + icon: Icons.sensors_outlined, + devices: _sensors, + selectedId: null, + emptyLabel: 'sensors', + selectable: false, + onSelected: (_) async {}, + ), _buildSection( title: 'Auto-connect Scale', icon: Icons.scale_outlined, @@ -112,6 +138,7 @@ class _DeviceManagementPageState extends State { required String? selectedId, required String emptyLabel, required Future Function(String?) onSelected, + bool selectable = true, }) { return ShadCard( padding: const EdgeInsets.all(16), @@ -133,12 +160,13 @@ class _DeviceManagementPageState extends State { ], ), const SizedBox(height: 12), - _buildDeviceRadio( - name: 'None', - subtitle: 'No auto-connect', - isSelected: selectedId == null, - onTap: () => onSelected(null), - ), + if (selectable) + _buildDeviceRadio( + name: 'None', + subtitle: 'No auto-connect', + isSelected: selectedId == null, + onTap: () => onSelected(null), + ), if (devices.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 8), @@ -154,8 +182,10 @@ class _DeviceManagementPageState extends State { (device) => _buildDeviceRadio( name: device.name, subtitle: _deviceSubtitle(device), - isSelected: selectedId == device.deviceId, - onTap: () => onSelected(device.deviceId), + isSelected: selectable && selectedId == device.deviceId, + onTap: selectable ? () => onSelected(device.deviceId) : null, + showSelection: selectable, + trailing: _settingsButton(device), ), ), ], @@ -196,7 +226,9 @@ class _DeviceManagementPageState extends State { required String name, required String subtitle, required bool isSelected, - required VoidCallback onTap, + required VoidCallback? onTap, + bool showSelection = true, + Widget? trailing, }) { return InkWell( onTap: onTap, @@ -205,17 +237,20 @@ class _DeviceManagementPageState extends State { padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4), child: Row( children: [ - Icon( - isSelected - ? Icons.radio_button_checked - : Icons.radio_button_unchecked, - size: 20, - color: isSelected - ? Theme.of(context).colorScheme.primary - : Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), - ), + if (showSelection) + Icon( + isSelected + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + size: 20, + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ) + else + const Icon(Icons.sensors_outlined, size: 20), const SizedBox(width: 8), Expanded( child: Column( @@ -233,12 +268,46 @@ class _DeviceManagementPageState extends State { ], ), ), + ?trailing, ], ), ), ); } + Widget? _settingsButton(Device device) { + if (device is! DeviceSettingsCapable || device.deviceSettings == null) { + return null; + } + return IconButton( + tooltip: 'Device settings', + icon: const Icon(Icons.settings_outlined), + onPressed: () => _openDeviceSettings(device), + ); + } + + Future _openDeviceSettings(Device device) async { + if (!widget.deviceController.devices.any( + (current) => identical(current, device), + )) { + _showSettingsError(); + return; + } + bool launched = false; + try { + final uri = pluginDeviceSettingsUriForDevice( + device as DeviceSettingsCapable, + ); + launched = + await (widget.settingsLauncher?.call(uri) ?? + launchUrl(uri, mode: LaunchMode.inAppBrowserView)); + } catch (_) { + launched = false; + } + if (!mounted) return; + if (!launched) _showSettingsError(); + } + String _truncatedId(String id) { if (id.length > 8) { return 'ID: ...${id.substring(id.length - 8)}'; @@ -256,4 +325,12 @@ class _DeviceManagementPageState extends State { ), ); } + + void _showSettingsError() { + ScaffoldMessenger.of(context) + ..clearSnackBars() + ..showSnackBar( + const SnackBar(content: Text('Unable to open device settings.')), + ); + } } diff --git a/lib/src/settings/plugin_device_settings.dart b/lib/src/settings/plugin_device_settings.dart new file mode 100644 index 000000000..24b11bb48 --- /dev/null +++ b/lib/src/settings/plugin_device_settings.dart @@ -0,0 +1,35 @@ +import 'package:reaprime/src/plugins/plugin_device_contract.dart'; +import 'package:reaprime/src/util/safe_path.dart'; + +Uri pluginDeviceSettingsUri({ + required String pluginId, + required String endpointId, + required String deviceId, + String? deviceName, +}) { + if (!isSafePathComponent(pluginId) || !isSafePathComponent(endpointId)) { + throw ArgumentError('Invalid plugin settings endpoint'); + } + final queryParameters = {'ui': '1', 'deviceId': deviceId}; + if (deviceName != null) queryParameters['deviceName'] = deviceName; + return Uri( + scheme: 'http', + host: 'localhost', + port: 8080, + pathSegments: ['api', 'v1', 'plugins', pluginId, endpointId], + queryParameters: queryParameters, + ); +} + +Uri pluginDeviceSettingsUriForDevice(DeviceSettingsCapable device) { + final settings = device.deviceSettings; + if (settings == null) { + throw ArgumentError('Device has no settings endpoint'); + } + return pluginDeviceSettingsUri( + pluginId: settings.pluginId, + endpointId: settings.endpointId, + deviceId: device.deviceId, + deviceName: device.name, + ); +} diff --git a/test/plugins/plugin_device_settings_test.dart b/test/plugins/plugin_device_settings_test.dart new file mode 100644 index 000000000..bcb79ac45 --- /dev/null +++ b/test/plugins/plugin_device_settings_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:reaprime/src/plugins/plugin_device_service.dart'; +import 'package:reaprime/src/plugins/plugin_manifest.dart'; + +void main() { + test('registers a device settings descriptor per plugin instance', () async { + final service = PluginDeviceService(); + addTearDown(service.dispose); + + await service.register( + pluginId: 'test.plugin', + generation: 1, + registrationHandle: 'device_1', + definition: { + 'driverId': 'scale', + 'instanceId': 'one', + 'name': 'Scale one', + }, + driver: const PluginDriverDeclaration( + id: 'scale', + type: PluginDriverType.scale, + settingsEndpoint: 'device-settings', + ), + invoke: (_, _) async => const {}, + ); + + final device = (await service.devices.first).single; + expect(device.deviceId, 'plugin:test.plugin:scale:one'); + final settings = device as DeviceSettingsCapable; + expect(settings.deviceSettings?.pluginId, 'test.plugin'); + expect(settings.deviceSettings?.endpointId, 'device-settings'); + }); +} diff --git a/test/plugins/plugin_manifest_permissions_test.dart b/test/plugins/plugin_manifest_permissions_test.dart index 00f97e094..55dc90c94 100644 --- a/test/plugins/plugin_manifest_permissions_test.dart +++ b/test/plugins/plugin_manifest_permissions_test.dart @@ -26,6 +26,81 @@ void main() { expect(manifest.permissions, {PluginPermissions.networkWebsocket}); }); + test('parses a driver device settings endpoint', () { + final manifest = PluginManifest.fromJson({ + 'id': 'test.plugin', + 'name': 'Test Plugin', + 'author': 'Test', + 'description': 'Test', + 'version': '1.0.0', + 'apiVersion': 1, + 'permissions': ['api'], + 'drivers': [ + {'id': 'scale', 'type': 'scale', 'settingsEndpoint': 'device-settings'}, + ], + 'settings': {}, + 'api': [ + { + 'id': 'device-settings', + 'type': 'http', + 'data': {}, + }, + ], + }); + + expect(manifest.drivers.single.settingsEndpoint, 'device-settings'); + expect(manifest.toJson()['drivers'], [ + {'id': 'scale', 'type': 'scale', 'settingsEndpoint': 'device-settings'}, + ]); + }); + + test('rejects an invalid driver device settings endpoint', () { + Map manifestWith({ + required List permissions, + required dynamic api, + String endpoint = 'missing', + }) => { + 'id': 'test.plugin', + 'name': 'Test Plugin', + 'author': 'Test', + 'description': 'Test', + 'version': '1.0.0', + 'apiVersion': 1, + 'permissions': permissions, + 'drivers': [ + {'id': 'scale', 'type': 'scale', 'settingsEndpoint': endpoint}, + ], + 'settings': {}, + 'api': api, + }; + + for (final value in [ + manifestWith(permissions: const [], api: const []), + manifestWith( + permissions: const ['api'], + api: const [ + { + 'id': 'missing', + 'type': 'websocket', + 'data': {}, + }, + ], + ), + manifestWith( + permissions: const ['api'], + api: const [ + { + 'id': 'other', + 'type': 'http', + 'data': {}, + }, + ], + ), + ]) { + expect(() => PluginManifest.fromJson(value), throwsFormatException); + } + }); + test('rejects invalid or duplicate driver contributions', () { Map manifestWith(dynamic drivers) => { 'id': 'test.plugin', diff --git a/test/plugins/plugin_scale_api_test.dart b/test/plugins/plugin_scale_api_test.dart index 0fb2e9ad5..8dfa9c7a6 100644 --- a/test/plugins/plugin_scale_api_test.dart +++ b/test/plugins/plugin_scale_api_test.dart @@ -91,6 +91,7 @@ void main() { id: 'scale', type: PluginDriverType.scale, capabilities: { + PluginScaleCapability.battery, if (timers) PluginScaleCapability.tare, if (timers) PluginScaleCapability.timerControl, }, @@ -104,7 +105,11 @@ void main() { let context; return {id: 'api.scale', async onLoad() { await host.devices.register({driverId:'scale', instanceId:'one', name:'API Scale'}, { - async connect(session) { context = session; await session.publish({weight:12.5}); }, + async connect(session) { + context = session; + await session.publish({weight:12.5}); + await session.publishInfo({firmwareVersion:'R029', batteryLevel:${timers ? 0 : 100}}); + }, disconnect() {}, ${timers ? 'async tare() { await context.publish({weight:0}); },' : ''} ${timers ? "startTimer() {host.emit('timer','start');}, stopTimer() {host.emit('timer','stop');}, resetTimer() {host.emit('timer','reset');}," : ''} @@ -128,6 +133,10 @@ void main() { expect((await first).weight, 12.5); expect(scales.lastConnectedDeviceId, publicId); expect(settings.preferredScaleId, publicId); + expect(await request('GET', '/api/v1/scale/info'), { + 'firmwareVersion': 'R029', + 'batteryLevel': timers ? 0 : 100, + }); final listed = await request('GET', '/api/v1/devices') as List; expect(listed.single['id'], publicId); expect(listed.single['type'], 'scale'); diff --git a/test/plugins/plugin_scale_ble_metadata_test.dart b/test/plugins/plugin_scale_ble_metadata_test.dart new file mode 100644 index 000000000..c601a8e17 --- /dev/null +++ b/test/plugins/plugin_scale_ble_metadata_test.dart @@ -0,0 +1,119 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:reaprime/src/models/device/device.dart'; +import 'package:reaprime/src/plugins/plugin_ble_matcher.dart'; +import 'package:reaprime/src/plugins/plugin_ble_registry.dart'; +import 'package:reaprime/src/plugins/plugin_manager.dart'; +import 'package:reaprime/src/plugins/plugin_manifest.dart'; +import 'package:reaprime/src/plugins/plugin_scale.dart'; + +import '../helpers/plugin_ble_fixture.dart'; +import 'plugin_test_helpers.dart'; + +void main() { + test( + 'BLE Scale context exposes domain identity and metadata publication', + () async { + final manager = PluginManager(kvStore: FakeKeyValueStoreService()); + addTearDown(manager.dispose); + final manifest = testManifest( + 'ble.metadata.scale', + permissions: {PluginPermissions.emit, PluginPermissions.transportBle}, + drivers: [ + PluginDriverDeclaration( + id: 'scale', + type: PluginDriverType.scale, + capabilities: {PluginScaleCapability.battery}, + ble: PluginBleMatcher.fromJson({ + 'serviceUuids': ['180f'], + }), + ), + ], + ); + final bound = manager.emitStream.firstWhere( + (event) => event['event'] == 'bound', + ); + await manager.loadPlugin( + id: 'ble.metadata.scale', + manifest: manifest, + settings: {}, + jsCode: ''' + function createPlugin(host) { + return {id: 'ble.metadata.scale', async onLoad() { + await host.devices.bindDriver('scale', {create() { + return { + async connect(context) { + globalThis.oldContext = globalThis.currentContext; + globalThis.currentContext = context; + globalThis.publishOld = () => oldContext.publishInfo({batteryLevel: 0}).then( + () => host.emit('stale', false), error => host.emit('stale', error.code)); + globalThis.publishOversize = () => context.publishInfo({firmwareVersion: 'x'.repeat(65536)}) + .then(() => host.emit('oversize', false), error => host.emit('oversize', error.code)); + host.emit('connection', context.connectionId); + await context.publish({weight: 0}); + await context.publishInfo({firmwareVersion: 'R029', batteryLevel: 100}); + }, + disconnect() {} + }; + }}); + host.emit('bound', true); + }}; + } + ''', + ); + await bound.timeout(const Duration(seconds: 2)); + final evidence = BleAdvertisementEvidence(serviceUuids: ['180f']); + final transports = []; + final scale = + await manager.bleService.createCandidate( + driver: manager.bleService.registry + .decide(evidence) + .drivers + .single, + physicalId: 'AA:BB', + evidence: evidence, + createTransport: () { + final transport = PluginBleFixtureTransport('AA:BB'); + transports.add(transport); + return transport; + }, + admit: () => true, + ) + as PluginScale; + final connection = manager.emitStream.firstWhere( + (event) => event['event'] == 'connection', + ); + await scale.onConnect(); + expect((await connection)['payload'], isA()); + expect(scale.currentDeviceInformation?.toJson(), { + 'firmwareVersion': 'R029', + 'batteryLevel': 100, + }); + final oversize = manager.emitStream.firstWhere( + (event) => event['event'] == 'oversize', + ); + manager.js.evaluate('publishOversize();'); + while (manager.js.executePendingJob() > 0) {} + expect((await oversize)['payload'], 'resource_limit'); + expect(scale.connectionId, (await connection)['payload']); + manager.bleService.revokeSessions(); + await transports.first.disposed.future.timeout( + const Duration(seconds: 2), + ); + expect(scale.currentDeviceInformation, isNull); + await scale.disconnect(); + expect(scale.currentDeviceInformation, isNull); + await scale.onConnect(); + expect(scale.currentDeviceInformation?.firmwareVersion, 'R029'); + transports.last.states.add(ConnectionState.disconnected); + await transports.last.disposed.future.timeout(const Duration(seconds: 2)); + expect(scale.currentDeviceInformation, isNull); + expect(scale.connectionId, isNull); + final stale = manager.emitStream.firstWhere( + (event) => event['event'] == 'stale', + ); + manager.js.evaluate('publishOld();'); + while (manager.js.executePendingJob() > 0) {} + expect((await stale)['payload'], 'stale_session'); + }, + ); +} diff --git a/test/plugins/plugin_scale_metadata_test.dart b/test/plugins/plugin_scale_metadata_test.dart new file mode 100644 index 000000000..17193a182 --- /dev/null +++ b/test/plugins/plugin_scale_metadata_test.dart @@ -0,0 +1,228 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:reaprime/src/plugins/plugin_device_contract.dart'; +import 'package:reaprime/src/plugins/plugin_manager.dart'; +import 'package:reaprime/src/plugins/plugin_manifest.dart'; +import 'package:reaprime/src/plugins/plugin_scale.dart'; + +import 'plugin_test_helpers.dart'; + +PluginScale _scale( + String id, + void Function(String) onSession, { + Set capabilities = const { + PluginScaleCapability.battery, + }, +}) { + late PluginScale scale; + scale = PluginScale( + deviceId: id, + name: id, + capabilities: capabilities, + invoke: (operation, payload) async { + if (operation == PluginDeviceOperation.connect) { + onSession(payload['session'] as String); + scale.publish({'weight': 0}, session: payload['session'] as String); + } + return {}; + }, + ); + return scale; +} + +void main() { + test('scale metadata is validated, session scoped, and cleared', () async { + String? firstSession; + String? secondSession; + final first = _scale('first', (session) => firstSession = session); + final second = _scale('second', (session) => secondSession = session); + addTearDown(() async { + await first.dispose(); + await second.dispose(); + }); + + await first.onConnect(); + await second.onConnect(); + expect(first.currentDeviceInformation, isNull); + first.publishInfo({ + 'firmwareVersion': 'opaque', + 'batteryLevel': 0, + }, session: firstSession); + second.publishInfo({ + 'firmwareVersion': 'other', + 'batteryLevel': 100, + }, session: secondSession); + expect(first.currentDeviceInformation?.toJson(), { + 'firmwareVersion': 'opaque', + 'batteryLevel': 0, + }); + String? unsupportedSession; + final unsupported = _scale( + 'unsupported', + (session) => unsupportedSession = session, + capabilities: const {}, + ); + addTearDown(unsupported.dispose); + await unsupported.onConnect(); + expect( + () => unsupported.publishInfo({ + 'batteryLevel': 1, + }, session: unsupportedSession), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'invalid_argument', + ), + ), + ); + expect(second.currentDeviceInformation?.toJson(), { + 'firmwareVersion': 'other', + 'batteryLevel': 100, + }); + for (final invalid in [ + {'unknown': true}, + {'firmwareVersion': 1}, + {'batteryLevel': -1}, + {'batteryLevel': 101}, + {'batteryLevel': 1.5}, + ]) { + expect( + () => first.publishInfo(invalid, session: firstSession), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'invalid_argument', + ), + ), + ); + } + first.publishInfo({'batteryLevel': null}, session: firstSession); + expect(first.currentDeviceInformation?.toJson(), { + 'firmwareVersion': 'opaque', + }); + await first.disconnect(); + expect(first.currentDeviceInformation, isNull); + final staleSession = firstSession; + await first.onConnect(); + expect(first.currentDeviceInformation, isNull); + expect( + () => first.publishInfo({'batteryLevel': 50}, session: staleSession), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'stale_session', + ), + ), + ); + }); + + test( + 'metadata alone does not satisfy Scale readiness and failed connect clears it', + () async { + late PluginScale scale; + scale = PluginScale( + deviceId: 'metadata-only', + name: 'Metadata only', + capabilities: const {PluginScaleCapability.battery}, + invocationTimeout: const Duration(milliseconds: 30), + invoke: (operation, payload) async { + if (operation == PluginDeviceOperation.connect) { + scale.publishInfo({ + 'firmwareVersion': 'R029', + 'batteryLevel': 0, + }, session: payload['session'] as String); + } + return {}; + }, + ); + addTearDown(scale.dispose); + await expectLater(scale.onConnect(), throwsA(isA())); + expect(scale.currentDeviceInformation, isNull); + }, + ); + + test( + 'real JS Scale context publishes metadata and fences stale contexts', + () async { + final manager = PluginManager(kvStore: FakeKeyValueStoreService()); + addTearDown(manager.dispose); + final registered = manager.emitStream.firstWhere( + (event) => event['event'] == 'registered', + ); + await manager.loadPlugin( + id: 'metadata.scale', + manifest: testManifest( + 'metadata.scale', + permissions: {PluginPermissions.emit}, + drivers: const [ + PluginDriverDeclaration( + id: 'scale', + type: PluginDriverType.scale, + capabilities: {PluginScaleCapability.battery}, + ), + ], + ), + settings: {}, + jsCode: ''' + function createPlugin(host) { + const contexts = []; + return {id: 'metadata.scale', onLoad() { + return host.devices.register( + {driverId: 'scale', instanceId: 'one', name: 'Metadata Scale'}, + { + async connect(context) { + contexts.push(context); + globalThis.publishInvalid = () => context.publishInfo({batteryLevel: 1.5}) + .then(() => host.emit('invalid', false), error => host.emit('invalid', error.code)); + globalThis.publishOld = () => contexts[0].publishInfo({batteryLevel: 42}) + .then(() => host.emit('stale', false), error => host.emit('stale', error.code)); + host.emit('connection', context.connectionId); + await context.publish({weight: 0}); + await context.publishInfo({firmwareVersion: 'R029', batteryLevel: 0}); + }, + disconnect() {} + } + ).then(() => host.emit('registered', true)); + }}; + } + ''', + ); + await registered.timeout(const Duration(seconds: 2)); + final scale = + (await manager.deviceService.devices.first).single as PluginScale; + final connection = manager.emitStream.firstWhere( + (event) => event['event'] == 'connection', + ); + await scale.onConnect(); + final connectionId = (await connection)['payload']; + expect(connectionId, isA()); + expect(scale.connectionId, connectionId); + expect(scale.currentDeviceInformation?.toJson(), { + 'firmwareVersion': 'R029', + 'batteryLevel': 0, + }); + + final invalid = manager.emitStream.firstWhere( + (event) => event['event'] == 'invalid', + ); + manager.js.evaluate('publishInvalid();'); + while (manager.js.executePendingJob() > 0) {} + expect((await invalid)['payload'], 'invalid_argument'); + + await scale.disconnect(); + expect(scale.currentDeviceInformation, isNull); + expect(scale.connectionId, isNull); + await scale.onConnect(); + final stale = manager.emitStream.firstWhere( + (event) => event['event'] == 'stale', + ); + manager.js.evaluate('publishOld();'); + while (manager.js.executePendingJob() > 0) {} + expect((await stale)['payload'], 'stale_session'); + }, + ); +} diff --git a/test/services/webserver/scale_info_handler_test.dart b/test/services/webserver/scale_info_handler_test.dart index 9afc7a28a..cb9b8771a 100644 --- a/test/services/webserver/scale_info_handler_test.dart +++ b/test/services/webserver/scale_info_handler_test.dart @@ -7,6 +7,9 @@ import 'package:reaprime/src/controllers/scale_controller.dart'; import 'package:reaprime/src/models/device/device.dart'; import 'package:reaprime/src/models/errors.dart'; import 'package:reaprime/src/models/device/scale.dart'; +import 'package:reaprime/src/plugins/plugin_device_contract.dart'; +import 'package:reaprime/src/plugins/plugin_manifest.dart'; +import 'package:reaprime/src/plugins/plugin_scale.dart'; import 'package:reaprime/src/services/webserver_service.dart'; import 'package:reaprime/src/settings/settings_controller.dart'; import 'package:shelf_plus/shelf_plus.dart'; @@ -88,6 +91,59 @@ void main() { expect(response.statusCode, 200); expect(jsonDecode(await response.readAsString()), isEmpty); }); + + test( + 'selects metadata from the active scale without leaking prior info', + () async { + final native = _InfoScale( + const DeviceInformation(firmwareVersion: 'native-r1', batteryLevel: 91), + ); + final metadataFree = TestScale(deviceId: 'metadata-free'); + late PluginScale plugin; + plugin = PluginScale( + deviceId: 'plugin-scale', + name: 'Plugin Scale', + capabilities: {PluginScaleCapability.battery}, + invoke: (operation, payload) async { + if (operation == PluginDeviceOperation.connect) { + plugin.publish({ + 'weight': 1, + }, session: payload['session'] as String); + } + return {}; + }, + ); + await plugin.onConnect(); + plugin.publishInfo({ + 'firmwareVersion': 'plugin-r2', + 'batteryLevel': 42, + }, session: plugin.connectionId); + final controller = _SwitchingScaleController(native); + addTearDown(controller.dispose); + addTearDown(native.dispose); + addTearDown(metadataFree.dispose); + addTearDown(plugin.dispose); + + Future> readInfo() async { + final response = await requestInfo(controller); + expect(response.statusCode, 200); + return jsonDecode(await response.readAsString()) + as Map; + } + + expect(await readInfo(), { + 'firmwareVersion': 'native-r1', + 'batteryLevel': 91, + }); + controller.select(plugin); + expect(await readInfo(), { + 'firmwareVersion': 'plugin-r2', + 'batteryLevel': 42, + }); + controller.select(metadataFree); + expect(await readInfo(), isEmpty); + }, + ); } class _InfoScale extends TestScale implements DeviceInformationCapable { @@ -111,6 +167,19 @@ class _FixedScaleController extends ScaleController { Scale connectedScale() => _scale; } +class _SwitchingScaleController extends ScaleController { + _SwitchingScaleController(this._scale); + + Scale _scale; + + void select(Scale scale) { + _scale = scale; + } + + @override + Scale connectedScale() => _scale; +} + class _DisconnectedScaleController extends ScaleController { @override Scale connectedScale() => throw const DeviceNotConnectedException.scale(); diff --git a/test/settings/device_management_page_test.dart b/test/settings/device_management_page_test.dart index 4eb9b40f0..08dfe64e6 100644 --- a/test/settings/device_management_page_test.dart +++ b/test/settings/device_management_page_test.dart @@ -1,7 +1,8 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:reaprime/src/controllers/device_controller.dart'; import 'package:reaprime/src/models/device/device.dart'; +import 'package:reaprime/src/plugins/plugin_device_contract.dart'; import 'package:reaprime/src/settings/device_management_page.dart'; import 'package:reaprime/src/settings/settings_controller.dart'; import 'package:rxdart/rxdart.dart'; @@ -43,6 +44,27 @@ class _InformationScale extends TestScale implements DeviceInformationCapable { } } +class _SettingsScale extends _InformationScale + implements DeviceSettingsCapable { + _SettingsScale({required super.deviceId}) : super(firmwareVersion: 'R029'); + + @override + PluginDeviceSettings get deviceSettings => const PluginDeviceSettings( + pluginId: 'test.plugin', + endpointId: 'device-settings', + ); +} + +class _InvalidSettingsScale extends _SettingsScale { + _InvalidSettingsScale({required super.deviceId}); + + @override + PluginDeviceSettings get deviceSettings => const PluginDeviceSettings( + pluginId: 'invalid/plugin', + endpointId: 'device-settings', + ); +} + void main() { testWidgets('shows firmware and follows a same-ID replacement scale', ( tester, @@ -102,4 +124,101 @@ void main() { deviceController.dispose(); discovery.dispose(); }); + + testWidgets('opens settings for an eligible plugin device', (tester) async { + final discovery = MockDeviceDiscoveryService(); + final deviceController = DeviceController([discovery]); + await deviceController.initialize(); + final settingsController = SettingsController(MockSettingsService()); + await settingsController.loadSettings(); + final device = _SettingsScale(deviceId: 'plugin:test.plugin:scale:one'); + discovery.addDevice(device); + Uri? launched; + + await tester.pumpWidget( + ShadApp( + home: DeviceManagementPage( + settingsController: settingsController, + deviceController: deviceController, + settingsLauncher: (uri) async { + launched = uri; + return true; + }, + ), + ), + ); + await tester.pump(); + + await tester.tap(find.byTooltip('Device settings')); + expect(launched?.queryParameters['deviceId'], device.deviceId); + expect(launched?.queryParameters['ui'], '1'); + + await tester.pumpWidget(const SizedBox.shrink()); + deviceController.dispose(); + discovery.dispose(); + }); + + testWidgets('shows a launch failure', (tester) async { + final discovery = MockDeviceDiscoveryService(); + final deviceController = DeviceController([discovery]); + await deviceController.initialize(); + final settingsController = SettingsController(MockSettingsService()); + await settingsController.loadSettings(); + discovery.addDevice( + _SettingsScale(deviceId: 'plugin:test.plugin:scale:one'), + ); + + await tester.pumpWidget( + ShadApp( + home: ScaffoldMessenger( + child: DeviceManagementPage( + settingsController: settingsController, + deviceController: deviceController, + settingsLauncher: (_) async => false, + ), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.byTooltip('Device settings')); + await tester.pump(); + expect(find.text('Unable to open device settings.'), findsOneWidget); + + await tester.pumpWidget(const SizedBox.shrink()); + deviceController.dispose(); + discovery.dispose(); + }); + + testWidgets('shows a URI construction failure', (tester) async { + final discovery = MockDeviceDiscoveryService(); + final deviceController = DeviceController([discovery]); + await deviceController.initialize(); + final settingsController = SettingsController(MockSettingsService()); + await settingsController.loadSettings(); + discovery.addDevice( + _InvalidSettingsScale(deviceId: 'plugin:test.plugin:scale:one'), + ); + + await tester.pumpWidget( + ShadApp( + home: ScaffoldMessenger( + child: DeviceManagementPage( + settingsController: settingsController, + deviceController: deviceController, + settingsLauncher: (_) async => true, + ), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.byTooltip('Device settings')); + await tester.pump(); + expect(find.text('Unable to open device settings.'), findsOneWidget); + + await tester.pumpWidget(const SizedBox.shrink()); + deviceController.dispose(); + discovery.dispose(); + }); } diff --git a/test/settings/plugin_device_settings_test.dart b/test/settings/plugin_device_settings_test.dart new file mode 100644 index 000000000..0ff6652e2 --- /dev/null +++ b/test/settings/plugin_device_settings_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:reaprime/src/settings/plugin_device_settings.dart'; + +void main() { + test('builds a device settings URI with encoded identity', () { + final uri = pluginDeviceSettingsUri( + pluginId: 'skale.reaplugin', + endpointId: 'device-settings', + deviceId: 'plugin:skale.reaplugin:skale:one two', + deviceName: 'Skale #1', + ); + + expect( + uri.toString(), + contains('/api/v1/plugins/skale.reaplugin/device-settings?'), + ); + expect( + uri.queryParameters['deviceId'], + 'plugin:skale.reaplugin:skale:one two', + ); + expect(uri.queryParameters['deviceName'], 'Skale #1'); + expect(uri.queryParameters['ui'], '1'); + }); + + test('encodes legal path component identifiers', () { + final uri = pluginDeviceSettingsUri( + pluginId: '_plugin name', + endpointId: 'device-settings', + deviceId: 'device', + ); + + expect(uri.pathSegments, [ + 'api', + 'v1', + 'plugins', + '_plugin name', + 'device-settings', + ]); + expect(uri.toString(), contains('_plugin%20name')); + }); +}