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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified adapters/dummymodbusadapter
Binary file not shown.
Binary file modified adapters/dummymodbusadapter.exe
Binary file not shown.
171 changes: 171 additions & 0 deletions adapters/json-rpc-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,177 @@ An empty `registers` array is valid and starts polling with no registers configu

---

### `adapter.dataPointSchema`

Returns the schema for data point expressions — what fields make up a data point address, how they should be rendered in the UI, and available data types. Call this after `adapter.describe` to discover how to build the data point input UI.

**Params:** `{}` (none required)

**Result:**
```json
{
"addressSchema": {
"type": "object",
"properties": {
"objectType": {
"type": "string",
"title": "Object type",
"enum": ["coil", "discrete input", "input register", "holding register"],
"x-enumLabels": ["Coil", "Discrete Input", "Input Register", "Holding Register"]
},
"address": {
"type": "integer",
"title": "Address",
"minimum": 0,
"maximum": 65535
},
"deviceId": {
"type": "integer",
"title": "Device ID",
"minimum": 1
},
"dataType": {
"type": "string",
"title": "Data type"
}
},
"required": ["objectType", "address"]
},
"dataTypes": [
{ "id": "16b", "label": "unsigned 16-bit" },
{ "id": "s16b", "label": "signed 16-bit" },
{ "id": "32b", "label": "unsigned 32-bit" },
{ "id": "s32b", "label": "signed 32-bit" },
{ "id": "f32b", "label": "32-bit float" }
],
"defaultDataType": "16b"
}
```

| Field | Description |
| --- | --- |
| `addressSchema` | JSON Schema describing the address input fields. The core renders this with `SchemaFormWidget` |
| `dataTypes` | Array of available data types. Each entry has `id` (used in expression strings) and `label` (UI display) |
| `defaultDataType` | The `id` of the type to pre-select in the UI |

The `addressSchema` follows standard JSON Schema conventions. The core application uses it to dynamically generate the address input portion of the data point dialog, so it must accurately describe all required fields and their constraints. The `dataType` property within `addressSchema` has no `enum` constraint; the available values are supplied by the top-level `dataTypes` array, and `defaultDataType` (`"16b"`) indicates which value to pre-select.

---

### `adapter.describeDataPoint`

Parses a data point expression into structured fields and returns a human-readable description. Used by the core to display data point details in tables and tooltips without understanding protocol-specific address formats.

**Params:**
```json
{
"expression": "${40001: 16b}"
}
```

**Result (valid):**
```json
{
"valid": true,
"fields": {
"objectType": "holding register",
"address": 0,
"deviceId": 1,
"dataType": "16b"
},
"description": "holding register, 0, unsigned 16-bit, device id 1"
}
```

**Result (invalid):**
```json
{
"valid": false,
"error": "Unknown type 'xyz'"
}
```

| Field | Description |
| --- | --- |
| `valid` | Whether the expression is syntactically and semantically valid |
| `fields` | Structured parsed fields — protocol-specific, but the core treats them as opaque display data |
| `description` | Human-readable description for display in tables, tooltips, and logs |
| `error` | Human-readable error message when `valid` is false |

**Errors:**
- `-32602` — Missing `expression` field

---

### `adapter.validateDataPoint`

Validates a single data point expression string without starting polling. Used for real-time validation feedback in the data point input dialog.

**Params:**
```json
{
"expression": "${40001: 16b}"
}
```

**Result (valid):**
```json
{ "valid": true }
```

**Result (invalid):**
```json
{
"valid": false,
"error": "Unknown type 'xyz'"
}
```

| Field | Description |
| --- | --- |
| `valid` | Whether the expression is valid |
| `error` | Human-readable error message when `valid` is false |

**Errors:**
- `-32602` — Missing `expression` field

---

### `adapter.buildExpression`

Constructs a register expression string from its component parts. The core calls this after the user fills in the register address form and selects a data type and device, so expression syntax stays entirely within the adapter.

**Params:**

```json
{
"fields": {
"objectType": "holding register",
"address": 0
},
"dataType": "f32b",
"deviceId": 2
}
```

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `fields` | object | yes | Address field values as returned by the data point schema form (structure matches `addressSchema` from `adapter.dataPointSchema`) |
| `dataType` | string | no | Data type identifier (e.g. `"16b"`). Omit to use the adapter default |
| `deviceId` | integer | no | Device identifier from `adapter.configure`. Omit to use the adapter default |

**Result:**

```json
{ "expression": "${h0@2:f32b}" }
```

**Errors:**

- `-32602` — Missing or invalid `fields`; unknown `dataType`

---

### `adapter.getStatus`

Returns the current poll activity state.
Expand Down
Binary file modified adapters/modbusadapter
Binary file not shown.
Binary file modified adapters/modbusadapter.exe
Binary file not shown.
131 changes: 128 additions & 3 deletions src/ProtocolAdapter/adapterclient.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ void AdapterClient::provideConfig(QJsonObject config, QStringList registerExpres

_pendingExpressions = registerExpressions;
_pendingConfig = config;
_pendingAuxRequests.clear();
_state = State::CONFIGURING;
_handshakeTimer.start(_handshakeTimeoutMs);
QJsonObject params;
Expand Down Expand Up @@ -80,6 +81,85 @@ void AdapterClient::requestStatus()
_pProcess->sendRequest("adapter.getStatus", QJsonObject());
}

/*!
* \brief Request the adapter's data point schema while awaiting configuration.
*/
void AdapterClient::requestDataPointSchema()
{
if (_state != State::AWAITING_CONFIG)
{
qCWarning(scopeComm) << "AdapterClient: requestDataPointSchema called in unexpected state"
<< static_cast<int>(_state);
return;
}

_pendingAuxRequests["adapter.dataPointSchema"] = _pProcess->sendRequest("adapter.dataPointSchema", QJsonObject());
}

/*!
* \brief Request a human-readable description of a data point expression.
* \param expression The data point expression string to describe.
*/
void AdapterClient::describeDataPoint(const QString& expression)
{
if (_state != State::AWAITING_CONFIG && _state != State::ACTIVE)
{
qCWarning(scopeComm) << "AdapterClient: describeDataPoint called in unexpected state"
<< static_cast<int>(_state);
return;
}

QJsonObject params;
params["expression"] = expression;
_pendingAuxRequests["adapter.describeDataPoint"] = _pProcess->sendRequest("adapter.describeDataPoint", params);
}

/*!
* \brief Validate a data point expression string via the adapter.
* \param expression The data point expression string to validate.
*/
void AdapterClient::validateDataPoint(const QString& expression)
{
if (_state != State::AWAITING_CONFIG && _state != State::ACTIVE)
{
qCWarning(scopeComm) << "AdapterClient: validateDataPoint called in unexpected state"
<< static_cast<int>(_state);
return;
}

QJsonObject params;
params["expression"] = expression;
_pendingAuxRequests["adapter.validateDataPoint"] = _pProcess->sendRequest("adapter.validateDataPoint", params);
}

/*!
* \brief Send an adapter.buildExpression request to construct a data point expression string.
* \param addressFields Address field values as returned by the data point schema form.
* \param dataType Data type identifier; omitted from params when empty.
* \param deviceId Device identifier; omitted from params when zero.
*/
void AdapterClient::buildExpression(const QJsonObject& addressFields, const QString& dataType, deviceId_t deviceId)
{
if (_state != State::AWAITING_CONFIG && _state != State::ACTIVE)
{
qCWarning(scopeComm) << "AdapterClient: buildExpression called in unexpected state" << static_cast<int>(_state);
return;
}

QJsonObject params;
params["fields"] = addressFields;
const QString trimmedDataType = dataType.trimmed();
if (!trimmedDataType.isEmpty())
{
params["dataType"] = trimmedDataType;
}
if (deviceId != 0)
{
params["deviceId"] = static_cast<qint64>(deviceId);
}
_pendingAuxRequests["adapter.buildExpression"] = _pProcess->sendRequest("adapter.buildExpression", params);
}

void AdapterClient::stopSession()
{
if (_state == State::IDLE || _state == State::STOPPING)
Expand All @@ -88,6 +168,7 @@ void AdapterClient::stopSession()
}

_handshakeTimer.stop();
_pendingAuxRequests.clear();

if (_state == State::ACTIVE || _state == State::STARTING)
{
Expand All @@ -105,17 +186,17 @@ void AdapterClient::stopSession()

void AdapterClient::onResponseReceived(int id, const QString& method, const QJsonValue& result)
{
Q_UNUSED(id)
if (result.isObject())
{
handleLifecycleResponse(method, result.toObject());
handleLifecycleResponse(id, method, result.toObject());
}
else
{
qCWarning(scopeComm) << "AdapterClient: unexpected non-object result for" << method;
_handshakeTimer.stop();
/* Set IDLE before stop() so onProcessFinished's IDLE guard suppresses any
duplicate sessionError emission when the process exits asynchronously. */
_pendingAuxRequests.clear();
_state = State::IDLE;
_pProcess->stop();
emit sessionError(QString("Unexpected non-object result for %1").arg(method));
Expand All @@ -132,6 +213,7 @@ void AdapterClient::onErrorReceived(int id, const QString& method, const QJsonOb
State previousState = _state;
/* Set IDLE before stop() so onProcessFinished's IDLE guard suppresses any
duplicate sessionError emission when the process exits asynchronously. */
_pendingAuxRequests.clear();
_state = State::IDLE;
_pProcess->stop();

Expand All @@ -146,6 +228,7 @@ void AdapterClient::onProcessError(const QString& message)
_handshakeTimer.stop();
if (_state != State::STOPPING)
{
_pendingAuxRequests.clear();
_state = State::IDLE;
emit sessionError(message);
}
Expand All @@ -154,6 +237,7 @@ void AdapterClient::onProcessError(const QString& message)
void AdapterClient::onProcessFinished()
{
_handshakeTimer.stop();
_pendingAuxRequests.clear();
if (_state == State::STOPPING)
{
_state = State::IDLE;
Expand All @@ -170,6 +254,7 @@ void AdapterClient::onHandshakeTimeout()
{
qCWarning(scopeComm) << "AdapterClient: handshake timed out in state" << static_cast<int>(_state);
bool wasStopping = (_state == State::STOPPING);
_pendingAuxRequests.clear();
_state = State::IDLE;
_pProcess->stop();
if (wasStopping)
Expand Down Expand Up @@ -200,7 +285,7 @@ void AdapterClient::onNotificationReceived(QString method, QJsonValue params)
obj.value(QStringLiteral("message")).toString());
}

void AdapterClient::handleLifecycleResponse(const QString& method, const QJsonObject& result)
void AdapterClient::handleLifecycleResponse(int id, const QString& method, const QJsonObject& result)
{
if (method == "adapter.initialize" && _state == State::INITIALIZING)
{
Expand Down Expand Up @@ -258,6 +343,46 @@ void AdapterClient::handleLifecycleResponse(const QString& method, const QJsonOb
_pProcess->stop();
/* sessionStopped is emitted from onProcessFinished once the process exits */
}
else if (method == "adapter.dataPointSchema" && _state == State::AWAITING_CONFIG)
{
if (_pendingAuxRequests.value(method, -1) != id)
{
qCWarning(scopeComm) << "AdapterClient: ignoring stale response for" << method;
return;
}
_pendingAuxRequests.remove(method);
emit dataPointSchemaResult(result);
}
else if (method == "adapter.describeDataPoint" && (_state == State::AWAITING_CONFIG || _state == State::ACTIVE))
{
if (_pendingAuxRequests.value(method, -1) != id)
{
qCWarning(scopeComm) << "AdapterClient: ignoring stale response for" << method;
return;
}
_pendingAuxRequests.remove(method);
emit describeDataPointResult(result);
}
else if (method == "adapter.validateDataPoint" && (_state == State::AWAITING_CONFIG || _state == State::ACTIVE))
{
if (_pendingAuxRequests.value(method, -1) != id)
{
qCWarning(scopeComm) << "AdapterClient: ignoring stale response for" << method;
return;
}
_pendingAuxRequests.remove(method);
emit validateDataPointResult(result["valid"].toBool(), result["error"].toString());
}
else if (method == "adapter.buildExpression" && (_state == State::AWAITING_CONFIG || _state == State::ACTIVE))
{
if (_pendingAuxRequests.value(method, -1) != id)
{
qCWarning(scopeComm) << "AdapterClient: ignoring stale response for" << method;
return;
}
_pendingAuxRequests.remove(method);
emit buildExpressionResult(result["expression"].toString());
}
else
{
qCWarning(scopeComm) << "AdapterClient: unexpected response for" << method << "in state"
Expand Down
Loading
Loading