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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions assets/api/rest_v1.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions doc/Api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions doc/DeviceManagement.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
44 changes: 37 additions & 7 deletions doc/Plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
21 changes: 21 additions & 0 deletions doc/plans/archive/generic-native-device-settings/README.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions lib/src/plugins/plugin_ble_binding.dart
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ class PluginBleBinding {
prepareConnection: prepareConnection,
onReady: () => _session!.markReady(),
invocationTimeout: invocationTimeout,
deviceSettings: _deviceSettings,
definition: definition,
)
: PluginScale(
Expand All @@ -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;

Expand Down Expand Up @@ -202,6 +212,18 @@ class PluginBleBinding {
}
}

void publishInfo(Map<String, dynamic> 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);
Expand Down
4 changes: 4 additions & 0 deletions lib/src/plugins/plugin_ble_bridge.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -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
})
Expand Down
8 changes: 8 additions & 0 deletions lib/src/plugins/plugin_ble_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,14 @@ class PluginBleService {
handle,
).publish(snapshot, session, sample: sample);

void publishInfo(
String pluginId,
int generation,
String handle,
Map<String, dynamic> info,
String? session,
) => _binding(pluginId, generation, handle).publishInfo(info, session);

void reportDisconnected(
String pluginId,
int generation,
Expand Down
1 change: 1 addition & 0 deletions lib/src/plugins/plugin_bound_sensor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class PluginBoundSensor extends PluginProtocolDevice implements Sensor {
super.prepareConnection,
required super.onReady,
required super.invocationTimeout,
super.deviceSettings,
required Map<String, dynamic> definition,
}) : info = SensorInfo(
name: name,
Expand Down
14 changes: 14 additions & 0 deletions lib/src/plugins/plugin_device_contract.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, dynamic> snapshot, {String? session});
void reportDisconnected({String? session});
Expand Down
33 changes: 32 additions & 1 deletion lib/src/plugins/plugin_device_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
}
Expand All @@ -140,6 +147,7 @@ class PluginDeviceService implements DeviceDiscoveryService {
capabilities: driver!.capabilities,
invoke: invoke,
invocationTimeout: scaleInvocationTimeout,
deviceSettings: deviceSettings,
)
: _PluginSensor(
deviceId: deviceId,
Expand All @@ -148,6 +156,7 @@ class PluginDeviceService implements DeviceDiscoveryService {
dataChannels: parsePluginDataChannels(definition['dataChannels']),
commands: parsePluginCommands(definition['commands']),
invoke: invoke,
deviceSettings: deviceSettings,
);
_registrations[key] = sensor;
_publishDevices();
Expand All @@ -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<String, dynamic> 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,
Expand Down Expand Up @@ -283,14 +310,16 @@ class PluginDeviceService implements DeviceDiscoveryService {
}
}

class _PluginSensor implements Sensor, PluginDeviceAdapter {
class _PluginSensor
implements Sensor, PluginDeviceAdapter, DeviceSettingsCapable {
_PluginSensor({
required this.deviceId,
required this.name,
required String vendor,
required List<DataChannel> dataChannels,
required List<CommandDescriptor> commands,
required PluginDeviceInvoker invoke,
this.deviceSettings,
}) : _invoke = invoke,
info = SensorInfo(
name: name,
Expand All @@ -303,6 +332,8 @@ class _PluginSensor implements Sensor, PluginDeviceAdapter {
};

final PluginDeviceInvoker _invoke;
@override
final PluginDeviceSettings? deviceSettings;
final Map<String, DataChannel> _dataChannels;
final BehaviorSubject<ConnectionState> _connectionState =
BehaviorSubject.seeded(ConnectionState.discovered);
Expand Down
Loading
Loading