diff --git a/adapters/dummymodbusadapter b/adapters/dummymodbusadapter index c261c847..849dca3d 100755 Binary files a/adapters/dummymodbusadapter and b/adapters/dummymodbusadapter differ diff --git a/adapters/dummymodbusadapter.exe b/adapters/dummymodbusadapter.exe index 50b39d6f..9dd50a69 100644 Binary files a/adapters/dummymodbusadapter.exe and b/adapters/dummymodbusadapter.exe differ diff --git a/adapters/json-rpc-spec.md b/adapters/json-rpc-spec.md index fd7543a6..81ee495a 100644 --- a/adapters/json-rpc-spec.md +++ b/adapters/json-rpc-spec.md @@ -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. diff --git a/adapters/modbusadapter b/adapters/modbusadapter index 3fe38a83..2c8f78dd 100755 Binary files a/adapters/modbusadapter and b/adapters/modbusadapter differ diff --git a/adapters/modbusadapter.exe b/adapters/modbusadapter.exe index fbd630ce..4a9e3569 100644 Binary files a/adapters/modbusadapter.exe and b/adapters/modbusadapter.exe differ diff --git a/src/ProtocolAdapter/adapterclient.cpp b/src/ProtocolAdapter/adapterclient.cpp index b4a20ba2..acd2cbc9 100644 --- a/src/ProtocolAdapter/adapterclient.cpp +++ b/src/ProtocolAdapter/adapterclient.cpp @@ -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; @@ -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(_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(_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(_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(_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(deviceId); + } + _pendingAuxRequests["adapter.buildExpression"] = _pProcess->sendRequest("adapter.buildExpression", params); +} + void AdapterClient::stopSession() { if (_state == State::IDLE || _state == State::STOPPING) @@ -88,6 +168,7 @@ void AdapterClient::stopSession() } _handshakeTimer.stop(); + _pendingAuxRequests.clear(); if (_state == State::ACTIVE || _state == State::STARTING) { @@ -105,10 +186,9 @@ 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 { @@ -116,6 +196,7 @@ void AdapterClient::onResponseReceived(int id, const QString& method, const QJso _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)); @@ -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(); @@ -146,6 +228,7 @@ void AdapterClient::onProcessError(const QString& message) _handshakeTimer.stop(); if (_state != State::STOPPING) { + _pendingAuxRequests.clear(); _state = State::IDLE; emit sessionError(message); } @@ -154,6 +237,7 @@ void AdapterClient::onProcessError(const QString& message) void AdapterClient::onProcessFinished() { _handshakeTimer.stop(); + _pendingAuxRequests.clear(); if (_state == State::STOPPING) { _state = State::IDLE; @@ -170,6 +254,7 @@ void AdapterClient::onHandshakeTimeout() { qCWarning(scopeComm) << "AdapterClient: handshake timed out in state" << static_cast(_state); bool wasStopping = (_state == State::STOPPING); + _pendingAuxRequests.clear(); _state = State::IDLE; _pProcess->stop(); if (wasStopping) @@ -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) { @@ -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" diff --git a/src/ProtocolAdapter/adapterclient.h b/src/ProtocolAdapter/adapterclient.h index 88a50bb5..95735dd0 100644 --- a/src/ProtocolAdapter/adapterclient.h +++ b/src/ProtocolAdapter/adapterclient.h @@ -2,9 +2,11 @@ #define ADAPTERCLIENT_H #include "ProtocolAdapter/adapterprocess.h" +#include "models/device.h" #include "util/result.h" #include +#include #include #include #include @@ -78,6 +80,46 @@ class AdapterClient : public QObject */ void stopSession(); + /*! + * \brief Send an adapter.dataPointSchema request to discover the data point UI schema. + * + * Must only be called after describeResult() has been emitted (i.e., in the + * AWAITING_CONFIG state). Emits dataPointSchemaResult() when the adapter responds. + */ + void requestDataPointSchema(); + + /*! + * \brief Send an adapter.describeDataPoint request to parse a data point expression. + * + * Can be called in AWAITING_CONFIG or ACTIVE state. + * Emits describeDataPointResult() when the adapter responds. + * + * \param expression The data point expression string to describe. + */ + void describeDataPoint(const QString& expression); + + /*! + * \brief Send an adapter.validateDataPoint request to validate a data point expression. + * + * Can be called in AWAITING_CONFIG or ACTIVE state. + * Emits validateDataPointResult() when the adapter responds. + * + * \param expression The data point expression string to validate. + */ + void validateDataPoint(const QString& expression); + + /*! + * \brief Send an adapter.buildExpression request to construct a data point expression string. + * + * Can be called in AWAITING_CONFIG or ACTIVE state. + * Emits buildExpressionResult() when the adapter responds. + * + * \param addressFields Address field values as returned by the data point schema form (e.g. objectType, address). + * \param dataType Data type identifier (e.g. "16b"). Omitted from params when empty; adapter uses its default. + * \param deviceId Device identifier. Omitted from params when zero; adapter uses its default. + */ + void buildExpression(const QJsonObject& addressFields, const QString& dataType, deviceId_t deviceId); + signals: /*! * \brief Emitted when the adapter has been initialized, described, configured, and started. @@ -124,6 +166,31 @@ class AdapterClient : public QObject */ void diagnosticReceived(QString level, QString message); + /*! + * \brief Emitted when an adapter.dataPointSchema response has been received. + * \param schema The full data point schema object (addressSchema, dataTypes, defaultDataType). + */ + void dataPointSchemaResult(QJsonObject schema); + + /*! + * \brief Emitted when an adapter.describeDataPoint response has been received. + * \param result The full result object (valid, fields, description or error). + */ + void describeDataPointResult(QJsonObject result); + + /*! + * \brief Emitted when an adapter.validateDataPoint response has been received. + * \param valid Whether the expression is valid. + * \param error Human-readable error message when valid is false; empty otherwise. + */ + void validateDataPointResult(bool valid, QString error); + + /*! + * \brief Emitted when an adapter.buildExpression response has been received. + * \param expression The constructed data point expression string (e.g. \c ${h0:f32b}). + */ + void buildExpressionResult(QString expression); + protected: enum class State { @@ -148,7 +215,7 @@ private slots: void onNotificationReceived(QString method, QJsonValue params); private: - void handleLifecycleResponse(const QString& method, const QJsonObject& result); + void handleLifecycleResponse(int id, const QString& method, const QJsonObject& result); static constexpr int cHandshakeTimeoutMs = 10000; @@ -157,6 +224,7 @@ private slots: int _handshakeTimeoutMs; QJsonObject _pendingConfig; QStringList _pendingExpressions; + QMap _pendingAuxRequests; }; #endif // ADAPTERCLIENT_H diff --git a/src/ProtocolAdapter/adaptermanager.cpp b/src/ProtocolAdapter/adaptermanager.cpp new file mode 100644 index 00000000..f493fd16 --- /dev/null +++ b/src/ProtocolAdapter/adaptermanager.cpp @@ -0,0 +1,123 @@ + +#include "ProtocolAdapter/adaptermanager.h" + +#include "ProtocolAdapter/adapterclient.h" +#include "ProtocolAdapter/adapterprocess.h" +#include "models/settingsmodel.h" +#include "util/scopelogging.h" + +#include +#include +#include + +AdapterManager::AdapterManager(SettingsModel* pSettingsModel, QObject* parent) + : QObject(parent), _pSettingsModel(pSettingsModel) +{ + _pAdapterProcess = new AdapterProcess(this); + _pAdapterClient = new AdapterClient(_pAdapterProcess, this); + + connect(_pAdapterClient, &AdapterClient::sessionStarted, this, &AdapterManager::sessionStarted); + connect(_pAdapterClient, &AdapterClient::readDataResult, this, &AdapterManager::readDataResult); + connect(_pAdapterClient, &AdapterClient::buildExpressionResult, this, &AdapterManager::buildExpressionResult); + connect(_pAdapterClient, &AdapterClient::sessionStopped, this, &AdapterManager::sessionStopped); + connect(_pAdapterClient, &AdapterClient::sessionError, this, &AdapterManager::sessionError); + connect(_pAdapterClient, &AdapterClient::describeResult, this, &AdapterManager::onDescribeResult); + connect(_pAdapterClient, &AdapterClient::dataPointSchemaResult, this, &AdapterManager::onDataPointSchemaResult); + connect(_pAdapterClient, &AdapterClient::diagnosticReceived, this, &AdapterManager::onAdapterDiagnostic); +} + +/*! \brief Launch the adapter subprocess and start the initialization handshake. + * + * Resolves the adapter binary relative to the running executable so the path + * is correct in the build tree, AppImage, and installed layouts alike. + */ +void AdapterManager::initAdapter() +{ + const QString suffix = QFileInfo(QCoreApplication::applicationFilePath()).suffix(); + const QString adapterName = "modbusadapter" + (suffix.isEmpty() ? QString() : "." + suffix); + const QString adapterPath = QDir(QCoreApplication::applicationDirPath()).filePath(adapterName); + _pAdapterClient->prepareAdapter(adapterPath); +} + +/*! \brief Fetch the effective adapter config from the settings model and start the session. + * \param registerExpressions Register expression strings to pass to the adapter. + */ +void AdapterManager::startSession(const QStringList& registerExpressions) +{ + const AdapterData* data = _pSettingsModel->adapterData("modbus"); + QJsonObject config = data->effectiveConfig(); + _pAdapterClient->provideConfig(config, registerExpressions); +} + +/*! \brief Send adapter.shutdown and signal the adapter process to stop. */ +void AdapterManager::stopSession() +{ + _pAdapterClient->stopSession(); +} + +/*! \brief Send an adapter.readData request to the active adapter. */ +void AdapterManager::requestReadData() +{ + _pAdapterClient->requestReadData(); +} + +/*! + * \brief Request the adapter to construct a register expression from its component parts. + * \param addressFields Address field values from the register schema form. + * \param dataType Data type identifier; empty string uses the adapter default. + * \param deviceId Device identifier; 0 uses the adapter default. + */ +void AdapterManager::buildExpression(const QJsonObject& addressFields, const QString& dataType, deviceId_t deviceId) +{ + _pAdapterClient->buildExpression(addressFields, dataType, deviceId); +} + +/*! \brief Receive the adapter.describe response, update the settings model, and request the data point schema. + * \param description The full describe result object from the adapter. + */ +void AdapterManager::onDescribeResult(const QJsonObject& description) +{ + _pSettingsModel->updateAdapterFromDescribe("modbus", description); + _pAdapterClient->requestDataPointSchema(); +} + +/*! \brief Receive the adapter data point schema and forward it to the settings model. + * \param schema The data point schema JSON object returned by adapter.dataPointSchema. + */ +void AdapterManager::onDataPointSchemaResult(const QJsonObject& schema) +{ + _pSettingsModel->setAdapterDataPointSchema("modbus", schema); +} + +/*! + * \brief Route an adapter.diagnostic notification to the diagnostics log. + * + * Maps the adapter's level string to the appropriate Qt logging severity so + * the message flows through ScopeLogging into DiagnosticModel. + * + * \param level Severity string from the adapter: "debug", "info", "warning", or "error". + * \param message The diagnostic message text. + */ +void AdapterManager::onAdapterDiagnostic(const QString& level, const QString& message) +{ + if (level == QStringLiteral("debug")) + { + qCDebug(scopeAdapter) << message; + } + else if (level == QStringLiteral("info")) + { + qCInfo(scopeAdapter) << message; + } + else if (level == QStringLiteral("warning")) + { + qCWarning(scopeAdapter) << message; + } + else if (level == QStringLiteral("error")) + { + qCCritical(scopeAdapter) << message; + } + else + { + qCWarning(scopeAdapter) << "AdapterClient: unknown diagnostic level:" << level << "-" << message; + } +} diff --git a/src/ProtocolAdapter/adaptermanager.h b/src/ProtocolAdapter/adaptermanager.h new file mode 100644 index 00000000..141ba1f1 --- /dev/null +++ b/src/ProtocolAdapter/adaptermanager.h @@ -0,0 +1,111 @@ +#ifndef ADAPTERMANAGER_H +#define ADAPTERMANAGER_H + +#include "models/device.h" +#include "util/result.h" + +#include +#include +#include + +class AdapterClient; +class AdapterProcess; +class SettingsModel; + +/*! + * \brief Owns the adapter subprocess and drives the full adapter session lifecycle. + * + * Creates and holds AdapterProcess and AdapterClient. Handles initialization, + * describe, schema retrieval, configuration, and session management. Exposes + * only the signals needed to coordinate data polling. + */ +class AdapterManager : public QObject +{ + Q_OBJECT +public: + explicit AdapterManager(SettingsModel* pSettingsModel, QObject* parent = nullptr); + + /*! + * \brief Launch the adapter subprocess and begin the initialization handshake. + * + * Resolves the adapter binary path relative to the running executable and + * calls AdapterClient::prepareAdapter(). + */ + void initAdapter(); + + /*! + * \brief Provide register expressions to the adapter and start the session. + * + * Fetches the effective adapter configuration from the settings model, then + * calls AdapterClient::provideConfig() to configure and start the adapter. + * + * \param registerExpressions Register expression strings to pass to the adapter. + */ + void startSession(const QStringList& registerExpressions); + + /*! + * \brief Send adapter.shutdown and signal the adapter process to stop. + */ + void stopSession(); + + /*! + * \brief Send an adapter.readData request to the active adapter. + */ + void requestReadData(); + + /*! + * \brief Request the adapter to construct a register expression from its component parts. + * + * \param addressFields Address field values as returned by the register schema form. + * \param dataType Data type identifier (e.g. "16b"). Pass empty string for the adapter default. + * \param deviceId Device identifier. Pass 0 for the adapter default. + */ + virtual void buildExpression(const QJsonObject& addressFields, const QString& dataType, deviceId_t deviceId); + + /*! + * \brief Route an adapter.diagnostic notification to the diagnostics log. + * + * Public for testability. Maps the adapter's level string to the appropriate + * Qt logging severity. + * + * \param level Severity string from the adapter: "debug", "info", "warning", or "error". + * \param message The diagnostic message text. + */ + void onAdapterDiagnostic(const QString& level, const QString& message); + +signals: + //! Emitted when the adapter has been initialized, described, configured, and started. + void sessionStarted(); + + //! Emitted when the adapter process has been intentionally stopped and the client is IDLE. + void sessionStopped(); + + /*! + * \brief Emitted when an unrecoverable error occurs (process failure, RPC error). + * \param message Human-readable error description. + */ + void sessionError(QString message); + + /*! + * \brief Emitted when an adapter.readData response has been received. + * \param results One entry per register, in the same order as the expressions passed to startSession(). + */ + void readDataResult(ResultDoubleList results); + + /*! + * \brief Emitted when an adapter.buildExpression response has been received. + * \param expression The constructed register expression string (e.g. \c ${h0:f32b}). + */ + void buildExpressionResult(QString expression); + +private slots: + void onDescribeResult(const QJsonObject& description); + void onDataPointSchemaResult(const QJsonObject& schema); + +private: + SettingsModel* _pSettingsModel; + AdapterProcess* _pAdapterProcess; + AdapterClient* _pAdapterClient; +}; + +#endif // ADAPTERMANAGER_H diff --git a/src/communication/modbuspoll.cpp b/src/communication/modbuspoll.cpp index ca7df6c4..9121d1f6 100644 --- a/src/communication/modbuspoll.cpp +++ b/src/communication/modbuspoll.cpp @@ -1,14 +1,10 @@ #include "communication/modbuspoll.h" -#include "models/device.h" #include "models/settingsmodel.h" #include "util/formatdatetime.h" -#include "util/modbusdatatype.h" #include "util/scopelogging.h" -#include -#include ModbusPoll::ModbusPoll(SettingsModel* pSettingsModel, QObject* parent) : QObject(parent), _bPollActive(false) { @@ -19,19 +15,16 @@ ModbusPoll::ModbusPoll(SettingsModel* pSettingsModel, QObject* parent) : QObject _pSettingsModel = pSettingsModel; _lastPollStart = QDateTime::currentMSecsSinceEpoch(); - _pAdapterProcess = new AdapterProcess(this); - _pAdapterClient = new AdapterClient(_pAdapterProcess, this); + _pAdapterManager = new AdapterManager(_pSettingsModel, this); - connect(_pAdapterClient, &AdapterClient::sessionStarted, this, &ModbusPoll::triggerRegisterRead); - connect(_pAdapterClient, &AdapterClient::readDataResult, this, &ModbusPoll::onReadDataResult); - connect(_pAdapterClient, &AdapterClient::describeResult, this, &ModbusPoll::onDescribeResult); - connect(_pAdapterClient, &AdapterClient::sessionError, this, [this](QString message) { - qCWarning(scopeComm) << "AdapterClient error:" << message; + connect(_pAdapterManager, &AdapterManager::sessionStarted, this, &ModbusPoll::triggerRegisterRead); + connect(_pAdapterManager, &AdapterManager::readDataResult, this, &ModbusPoll::onReadDataResult); + connect(_pAdapterManager, &AdapterManager::sessionStopped, this, &ModbusPoll::initAdapter); + connect(_pAdapterManager, &AdapterManager::sessionError, this, [this](QString message) { + qCWarning(scopeComm) << "AdapterManager error:" << message; _bPollActive = false; - disconnect(_pAdapterClient, &AdapterClient::sessionStopped, this, &ModbusPoll::initAdapter); + disconnect(_pAdapterManager, &AdapterManager::sessionStopped, this, &ModbusPoll::initAdapter); }); - connect(_pAdapterClient, &AdapterClient::sessionStopped, this, &ModbusPoll::initAdapter); - connect(_pAdapterClient, &AdapterClient::diagnosticReceived, this, &ModbusPoll::onAdapterDiagnostic); } ModbusPoll::~ModbusPoll() = default; @@ -40,13 +33,11 @@ ModbusPoll::~ModbusPoll() = default; * * Resolves the adapter binary relative to the running executable so the path * is correct in the build tree, AppImage, and installed layouts alike. - * Calls prepareAdapter() on the client, which triggers the adapter.describe - * handshake. + * Delegates to AdapterManager::initAdapter(). */ void ModbusPoll::initAdapter() { - const QString adapterPath = QCoreApplication::applicationDirPath() + "/modbusadapter"; - _pAdapterClient->prepareAdapter(adapterPath); + _pAdapterManager->initAdapter(); } void ModbusPoll::startCommunication(QList& registerList) @@ -55,19 +46,15 @@ void ModbusPoll::startCommunication(QList& registerList) _bPollActive = true; /* Re-establish auto-restart in case it was disconnected by a prior session error */ - disconnect(_pAdapterClient, &AdapterClient::sessionStopped, this, &ModbusPoll::initAdapter); - connect(_pAdapterClient, &AdapterClient::sessionStopped, this, &ModbusPoll::initAdapter); + disconnect(_pAdapterManager, &AdapterManager::sessionStopped, this, &ModbusPoll::initAdapter); + connect(_pAdapterManager, &AdapterManager::sessionStopped, this, &ModbusPoll::initAdapter); qCInfo(scopeComm) << QString("Start logging: %1").arg(FormatDateTime::currentDateTime()); resetCommunicationStats(); QStringList expressions = buildRegisterExpressions(_registerList); - - const AdapterData* data = _pSettingsModel->adapterData("modbus"); - QJsonObject config = data->effectiveConfig(); - - _pAdapterClient->provideConfig(config, expressions); + _pAdapterManager->startSession(expressions); } void ModbusPoll::resetCommunicationStats() @@ -79,7 +66,7 @@ void ModbusPoll::stopCommunication() { _bPollActive = false; _pPollTimer->stop(); - _pAdapterClient->stopSession(); + _pAdapterManager->stopSession(); qCInfo(scopeComm) << QString("Stop logging: %1").arg(FormatDateTime::currentDateTime()); } @@ -94,7 +81,7 @@ void ModbusPoll::triggerRegisterRead() if (_bPollActive) { _lastPollStart = QDateTime::currentMSecsSinceEpoch(); - _pAdapterClient->requestReadData(); + _pAdapterManager->requestReadData(); } } @@ -120,41 +107,11 @@ void ModbusPoll::onReadDataResult(ResultDoubleList results) } } -void ModbusPoll::onDescribeResult(const QJsonObject& description) -{ - _pSettingsModel->updateAdapterFromDescribe("modbus", description); -} -/*! \brief Route an adapter.diagnostic notification to the diagnostics log. - * - * Maps the adapter's level string to the appropriate Qt logging severity so - * the message flows through ScopeLogging into DiagnosticModel. - * - * \param level Severity string from the adapter: "debug", "info", "warning", or "error". - * \param message The diagnostic message text. - */ -void ModbusPoll::onAdapterDiagnostic(const QString& level, const QString& message) +/*! \brief Returns the AdapterManager owned by this instance. */ +AdapterManager* ModbusPoll::adapterManager() const { - if (level == QStringLiteral("debug")) - { - qCDebug(scopeAdapter) << message; - } - else if (level == QStringLiteral("info")) - { - qCInfo(scopeAdapter) << message; - } - else if (level == QStringLiteral("warning")) - { - qCWarning(scopeAdapter) << message; - } - else if (level == QStringLiteral("error")) - { - qCCritical(scopeAdapter) << message; - } - else - { - qCWarning(scopeAdapter) << "AdapterClient: unknown diagnostic level:" << level << "-" << message; - } + return _pAdapterManager; } QStringList ModbusPoll::buildRegisterExpressions(const QList& registerList) diff --git a/src/communication/modbuspoll.h b/src/communication/modbuspoll.h index 23126e41..b42623df 100644 --- a/src/communication/modbuspoll.h +++ b/src/communication/modbuspoll.h @@ -1,12 +1,10 @@ #ifndef MODBUSPOLL_H #define MODBUSPOLL_H -#include "ProtocolAdapter/adapterclient.h" -#include "ProtocolAdapter/adapterprocess.h" +#include "ProtocolAdapter/adaptermanager.h" #include "communication/datapoint.h" #include "util/result.h" -#include #include #include @@ -27,7 +25,13 @@ class ModbusPoll : public QObject bool isActive(); void resetCommunicationStats(); - void onAdapterDiagnostic(const QString& level, const QString& message); + /*! + * \brief Returns the AdapterManager owned by this poll instance. + * + * Callers that need to interact with the adapter directly (e.g. to call + * buildExpression()) should use this accessor rather than going through ModbusPoll. + */ + AdapterManager* adapterManager() const; signals: void registerDataReady(ResultDoubleList registers); @@ -35,7 +39,6 @@ class ModbusPoll : public QObject private slots: void triggerRegisterRead(); void onReadDataResult(ResultDoubleList results); - void onDescribeResult(const QJsonObject& description); private: QStringList buildRegisterExpressions(const QList& registerList); @@ -47,8 +50,7 @@ private slots: qint64 _lastPollStart; SettingsModel* _pSettingsModel; - AdapterProcess* _pAdapterProcess; - AdapterClient* _pAdapterClient; + AdapterManager* _pAdapterManager; }; #endif // MODBUSPOLL_H diff --git a/src/dialogs/addregisterwidget.cpp b/src/dialogs/addregisterwidget.cpp index 6dc2fc5f..c3b14e7c 100644 --- a/src/dialogs/addregisterwidget.cpp +++ b/src/dialogs/addregisterwidget.cpp @@ -1,20 +1,31 @@ #include "addregisterwidget.h" #include "ui_addregisterwidget.h" +#include "ProtocolAdapter/adaptermanager.h" +#include "customwidgets/schemaformwidget.h" +#include "models/adapterdata.h" +#include "models/device.h" #include "models/settingsmodel.h" -#include "util/expressiongenerator.h" -#include "util/modbusaddress.h" -#include "util/modbusdatatype.h" -using Type = ModbusDataType::Type; -using ObjectType = ModbusAddress::ObjectType; - -Q_DECLARE_METATYPE(ModbusDataType::Type) - -AddRegisterWidget::AddRegisterWidget(SettingsModel* pSettingsModel, QWidget *parent) : - QWidget(parent), - _pUi(new Ui::AddRegisterWidget), - _pSettingsModel(pSettingsModel) +#include +#include + +/*! + * \brief Constructs the widget and populates it from the adapter's register schema. + * \param pSettingsModel Pointer to the application settings model. + * \param adapterId Identifier of the adapter whose register schema to use. + * \param pAdapterManager Pointer to the adapter manager used to build expression strings. + * \param parent Optional parent widget. + */ +AddRegisterWidget::AddRegisterWidget(SettingsModel* pSettingsModel, + const QString& adapterId, + AdapterManager* pAdapterManager, + QWidget* parent) + : QWidget(parent), + _pUi(new Ui::AddRegisterWidget), + _pAddressForm(new SchemaFormWidget(this)), + _pSettingsModel(pSettingsModel), + _pAdapterManager(pAdapterManager) { _pUi->setupUi(this); @@ -24,25 +35,49 @@ AddRegisterWidget::AddRegisterWidget(SettingsModel* pSettingsModel, QWidget *par /* Disable question mark button */ setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint); + /* Build the address form from the adapter's data point schema */ + const AdapterData* adapterData = _pSettingsModel->adapterData(adapterId); + const QJsonObject dataPointSchema = adapterData->dataPointSchema(); + _addressSchema = dataPointSchema["addressSchema"].toObject(); + _pAddressForm->setSchema(_addressSchema, QJsonObject()); + + auto* addressLayout = new QVBoxLayout(_pUi->addressContainer); + addressLayout->setContentsMargins(0, 0, 0, 0); + addressLayout->addWidget(_pAddressForm); + + /* Populate data type combo from the adapter's dataTypes array */ + const QJsonArray dataTypes = dataPointSchema["dataTypes"].toArray(); + const QString defaultTypeId = dataPointSchema["defaultDataType"].toString(); + for (const QJsonValue& entry : dataTypes) + { + const QJsonObject typeObj = entry.toObject(); + _pUi->cmbType->addItem(typeObj["label"].toString(), typeObj["id"].toString()); + } + + /* Pre-select the default data type and remember the index for resetFields() */ + _defaultTypeIndex = _pUi->cmbType->findData(defaultTypeId); + if (_defaultTypeIndex < 0) + { + _defaultTypeIndex = 0; + } + _pUi->cmbType->setCurrentIndex(_defaultTypeIndex); + + /* Populate device combo */ _pUi->cmbDevice->clear(); - const auto deviceList = _pSettingsModel->deviceList(); + const auto deviceList = _pSettingsModel->deviceListForAdapter(adapterId); for (deviceId_t devId : std::as_const(deviceList)) { _pUi->cmbDevice->addItem(QString(tr("Device %1").arg(devId)), devId); } - _pUi->cmbObjectType->addItem("Coil", QVariant::fromValue(ObjectType::COIL)); - _pUi->cmbObjectType->addItem("Discrete input", QVariant::fromValue(ObjectType::DISCRETE_INPUT)); - _pUi->cmbObjectType->addItem("Input register", QVariant::fromValue(ObjectType::INPUT_REGISTER)); - _pUi->cmbObjectType->addItem("Holding register", QVariant::fromValue(ObjectType::HOLDING_REGISTER)); - - _pUi->cmbType->addItem(ModbusDataType::description(Type::UNSIGNED_16), QVariant::fromValue(Type::UNSIGNED_16)); - _pUi->cmbType->addItem(ModbusDataType::description(Type::UNSIGNED_32), QVariant::fromValue(Type::UNSIGNED_32)); - _pUi->cmbType->addItem(ModbusDataType::description(Type::SIGNED_16), QVariant::fromValue(Type::SIGNED_16)); - _pUi->cmbType->addItem(ModbusDataType::description(Type::SIGNED_32), QVariant::fromValue(Type::SIGNED_32)); - _pUi->cmbType->addItem(ModbusDataType::description(Type::FLOAT_32), QVariant::fromValue(Type::FLOAT_32)); + if (deviceList.isEmpty()) + { + _pUi->btnAdd->setEnabled(false); + _pUi->cmbDevice->setEnabled(false); + } connect(_pUi->btnAdd, &QPushButton::clicked, this, &AddRegisterWidget::handleResultAccept); + connect(_pAdapterManager, &AdapterManager::buildExpressionResult, this, &AddRegisterWidget::onBuildExpressionResult); _axisGroup.setExclusive(true); _axisGroup.addButton(_pUi->radioPrimary); @@ -58,23 +93,38 @@ AddRegisterWidget::~AddRegisterWidget() void AddRegisterWidget::handleResultAccept() { - QString expression = generateExpression(); - GraphData graphData; + if (_pUi->cmbDevice->count() == 0) + { + return; + } - graphData.setLabel(_pUi->lineName->text()); + collectPendingGraphData(); - if (_pUi->radioSecondary->isChecked()) + const QJsonObject addressValues = _pAddressForm->values(); + const QString typeId = _pUi->cmbType->currentData().toString(); + + deviceId_t deviceId = Device::cFirstDeviceId; + const QVariant devData = _pUi->cmbDevice->currentData(); + if (devData.canConvert()) { - graphData.setValueAxis(GraphData::VALUE_AXIS_SECONDARY); + deviceId = devData.value(); } - else + + _pUi->btnAdd->setEnabled(false); + _pAdapterManager->buildExpression(addressValues, typeId, deviceId); +} + +void AddRegisterWidget::onBuildExpressionResult(const QString& expression) +{ + _pUi->btnAdd->setEnabled(true); + + if (expression.isEmpty()) { - graphData.setValueAxis(GraphData::VALUE_AXIS_PRIMARY); + return; } - graphData.setExpression(expression); - - emit graphDataConfigured(graphData); + _pendingGraphData.setExpression(expression); + emit graphDataConfigured(_pendingGraphData); resetFields(); } @@ -82,50 +132,29 @@ void AddRegisterWidget::handleResultAccept() void AddRegisterWidget::resetFields() { _pUi->lineName->setText("Name of curve"); - _pUi->spinAddress->setValue(0); - _pUi->cmbType->setCurrentIndex(0); - _pUi->cmbObjectType->setCurrentIndex(3); + _pUi->cmbType->setCurrentIndex(_defaultTypeIndex); _pUi->cmbDevice->setCurrentIndex(0); _pUi->radioPrimary->setChecked(true); + _pAddressForm->setSchema(_addressSchema, QJsonObject()); } -QString AddRegisterWidget::generateExpression() +/*! + * \brief Captures the current non-expression fields into \a _pendingGraphData. + * + * Called just before the async adapter.buildExpression request is sent, so that + * the label and value axis are snapshotted at click time. + */ +void AddRegisterWidget::collectPendingGraphData() { - deviceId_t deviceId; - Type type; - ObjectType objectType; + _pendingGraphData = GraphData(); + _pendingGraphData.setLabel(_pUi->lineName->text()); - QVariant typeData = _pUi->cmbType->currentData(); - if (typeData.canConvert()) - { - type = typeData.value(); - } - else - { - type = Type::UNSIGNED_16; - } - - QVariant objectTypeData = _pUi->cmbObjectType->currentData(); - if (objectTypeData.canConvert()) - { - objectType = objectTypeData.value(); - } - else - { - objectType = ObjectType::UNKNOWN; - } - - auto registerAddr = ModbusAddress(static_cast(_pUi->spinAddress->value()), objectType); - - QVariant devData = _pUi->cmbDevice->currentData(); - if (devData.canConvert()) + if (_pUi->radioSecondary->isChecked()) { - deviceId = devData.value(); + _pendingGraphData.setValueAxis(GraphData::VALUE_AXIS_SECONDARY); } else { - deviceId = 0; + _pendingGraphData.setValueAxis(GraphData::VALUE_AXIS_PRIMARY); } - - return ExpressionGenerator::constructRegisterString(registerAddr.fullAddress(), type, deviceId); } diff --git a/src/dialogs/addregisterwidget.h b/src/dialogs/addregisterwidget.h index 436c4d16..76b4e2dd 100644 --- a/src/dialogs/addregisterwidget.h +++ b/src/dialogs/addregisterwidget.h @@ -1,11 +1,15 @@ #ifndef ADDREGISTERWIDGET_H #define ADDREGISTERWIDGET_H +#include "models/device.h" #include "models/graphdata.h" -#include #include +#include +#include +class AdapterManager; +class SchemaFormWidget; class SettingsModel; namespace Ui { @@ -19,7 +23,10 @@ class AddRegisterWidget : public QWidget friend class TestAddRegisterWidget; public: - explicit AddRegisterWidget(SettingsModel* pSettingsModel, QWidget *parent = nullptr); + explicit AddRegisterWidget(SettingsModel* pSettingsModel, + const QString& adapterId, + AdapterManager* pAdapterManager, + QWidget* parent = nullptr); ~AddRegisterWidget(); signals: @@ -27,16 +34,24 @@ class AddRegisterWidget : public QWidget private slots: void handleResultAccept(); + void onBuildExpressionResult(const QString& expression); private: void resetFields(); - QString generateExpression(); + void collectPendingGraphData(); - Ui::AddRegisterWidget * _pUi; + Ui::AddRegisterWidget* _pUi; + SchemaFormWidget* _pAddressForm; + QJsonObject _addressSchema; + int _defaultTypeIndex{ 0 }; SettingsModel* _pSettingsModel; + AdapterManager* _pAdapterManager; QButtonGroup _axisGroup; + + /* Temporary storage while waiting for buildExpression response */ + GraphData _pendingGraphData; }; #endif // ADDREGISTERWIDGET_H diff --git a/src/dialogs/addregisterwidget.ui b/src/dialogs/addregisterwidget.ui index 2a98a304..6ac5c19a 100644 --- a/src/dialogs/addregisterwidget.ui +++ b/src/dialogs/addregisterwidget.ui @@ -29,16 +29,6 @@ 6 - - - - 65535 - - - 0 - - - @@ -46,10 +36,16 @@ - + + + + - + + + + Y1 axis @@ -59,19 +55,13 @@ - + Y2 axis - - - - - - diff --git a/src/dialogs/mainwindow.cpp b/src/dialogs/mainwindow.cpp index 2211758d..a59b9f09 100644 --- a/src/dialogs/mainwindow.cpp +++ b/src/dialogs/mainwindow.cpp @@ -397,7 +397,7 @@ void MainWindow::showRegisterDialog() _pGuiModel->setGuiState(GuiState::INIT); } - RegisterDialog registerDialog(_pGraphDataModel, _pSettingsModel, this); + RegisterDialog registerDialog(_pGraphDataModel, _pSettingsModel, _pModbusPoll->adapterManager(), this); registerDialog.exec(); } diff --git a/src/dialogs/registerdialog.cpp b/src/dialogs/registerdialog.cpp index 4dab905a..430b6123 100644 --- a/src/dialogs/registerdialog.cpp +++ b/src/dialogs/registerdialog.cpp @@ -1,6 +1,7 @@ #include "registerdialog.h" +#include "ProtocolAdapter/adaptermanager.h" #include "customwidgets/actionbuttondelegate.h" #include "dialogs/addregisterwidget.h" #include "dialogs/expressionsdialog.h" @@ -11,7 +12,10 @@ #include -RegisterDialog::RegisterDialog(GraphDataModel* pGraphDataModel, SettingsModel* pSettingsModel, QWidget* parent) +RegisterDialog::RegisterDialog(GraphDataModel* pGraphDataModel, + SettingsModel* pSettingsModel, + AdapterManager* pAdapterManager, + QWidget* parent) : QDialog(parent), _pUi(new Ui::RegisterDialog) { _pUi->setupUi(this); @@ -21,6 +25,7 @@ RegisterDialog::RegisterDialog(GraphDataModel* pGraphDataModel, SettingsModel* p _pGraphDataModel = pGraphDataModel; _pSettingsModel = pSettingsModel; + _pAdapterManager = pAdapterManager; // Setup registerView _pUi->registerView->setModel(_pGraphDataModel); @@ -39,7 +44,8 @@ RegisterDialog::RegisterDialog(GraphDataModel* pGraphDataModel, SettingsModel* p /* Except following columns */ _pUi->registerView->horizontalHeader()->setSectionResizeMode(GraphDataModel::column::TEXT, QHeaderView::Stretch); - _pUi->registerView->horizontalHeader()->setSectionResizeMode(GraphDataModel::column::EXPRESSION, QHeaderView::Stretch); + _pUi->registerView->horizontalHeader()->setSectionResizeMode(GraphDataModel::column::EXPRESSION, + QHeaderView::Stretch); auto triggers = QAbstractItemView::DoubleClicked | QAbstractItemView::EditKeyPressed; _pUi->registerView->setEditTriggers(triggers); @@ -66,12 +72,17 @@ RegisterDialog::RegisterDialog(GraphDataModel* pGraphDataModel, SettingsModel* p connect(_pUi->btnRemove, &QPushButton::released, this, &RegisterDialog::removeRegisterRow); connect(_pGraphDataModel, &GraphDataModel::rowsInserted, this, &RegisterDialog::onRegisterInserted); - auto registerPopupMenu = new AddRegisterWidget(_pSettingsModel, this); - connect(registerPopupMenu, &AddRegisterWidget::graphDataConfigured, this, &RegisterDialog::addRegister); + const QStringList ids = _pSettingsModel->adapterIds(); + const QString adapterId = ids.isEmpty() ? QString() : ids.first(); + if (!adapterId.isEmpty()) + { + auto registerPopupMenu = new AddRegisterWidget(_pSettingsModel, adapterId, _pAdapterManager, this); + connect(registerPopupMenu, &AddRegisterWidget::graphDataConfigured, this, &RegisterDialog::addRegister); - _registerPopupAction = std::make_unique(this); - _registerPopupAction->setDefaultWidget(registerPopupMenu); - _pUi->btnAdd->addAction(_registerPopupAction.get()); + _registerPopupAction = std::make_unique(this); + _registerPopupAction->setDefaultWidget(registerPopupMenu); + _pUi->btnAdd->addAction(_registerPopupAction.get()); + } } RegisterDialog::~RegisterDialog() @@ -79,7 +90,7 @@ RegisterDialog::~RegisterDialog() delete _pUi; } -void RegisterDialog::addRegister(const GraphData &graphData) +void RegisterDialog::addRegister(const GraphData& graphData) { _pGraphDataModel->add(graphData); } @@ -91,10 +102,7 @@ void RegisterDialog::addDefaultRegister() void RegisterDialog::activatedCell(QModelIndex modelIndex) { - if ( - (modelIndex.column() == GraphDataModel::column::COLOR) - && (modelIndex.row() < _pGraphDataModel->size()) - ) + if ((modelIndex.column() == GraphDataModel::column::COLOR) && (modelIndex.row() < _pGraphDataModel->size())) { QColor color = QColorDialog::getColor(_pGraphDataModel->color(modelIndex.row())); @@ -105,7 +113,7 @@ void RegisterDialog::activatedCell(QModelIndex modelIndex) } } -void RegisterDialog::onRegisterInserted(const QModelIndex &parent, int first, int last) +void RegisterDialog::onRegisterInserted(const QModelIndex& parent, int first, int last) { Q_UNUSED(parent); Q_UNUSED(last); @@ -117,14 +125,14 @@ void RegisterDialog::onRegisterInserted(const QModelIndex &parent, int first, in void RegisterDialog::removeRegisterRow() { // get list of selected rows - QItemSelectionModel *selected = _pUi->registerView->selectionModel(); + QItemSelectionModel* selected = _pUi->registerView->selectionModel(); QModelIndexList rowList = selected->selectedRows(); // sort QModelIndexList // We need to remove the highest rows first std::sort(rowList.begin(), rowList.end(), &RegisterDialog::sortRegistersLastFirst); - foreach(QModelIndex rowIndex, rowList) + foreach (QModelIndex rowIndex, rowList) { _pGraphDataModel->removeRow(rowIndex.row()); } @@ -167,7 +175,7 @@ int RegisterDialog::selectedRowAfterDelete(int deletedStartIndex, int rowCnt) return nextSelectedRow; } -bool RegisterDialog::sortRegistersLastFirst(const QModelIndex &s1, const QModelIndex &s2) +bool RegisterDialog::sortRegistersLastFirst(const QModelIndex& s1, const QModelIndex& s2) { return s1.row() > s2.row(); } diff --git a/src/dialogs/registerdialog.h b/src/dialogs/registerdialog.h index 8fca27e5..f089f025 100644 --- a/src/dialogs/registerdialog.h +++ b/src/dialogs/registerdialog.h @@ -8,6 +8,7 @@ /* Forward declaration */ class GraphDataModel; +class AdapterManager; class SettingsModel; class RegisterValueAxisDelegate; class ActionButtonDelegate; @@ -22,32 +23,35 @@ class RegisterDialog : public QDialog Q_OBJECT public: - explicit RegisterDialog(GraphDataModel* pGraphDataModel, SettingsModel* pSettingsModel, QWidget* parent = nullptr); + explicit RegisterDialog(GraphDataModel* pGraphDataModel, + SettingsModel* pSettingsModel, + AdapterManager* pAdapterManager, + QWidget* parent = nullptr); ~RegisterDialog(); private slots: - void addRegister(const GraphData &graphData); + void addRegister(const GraphData& graphData); void addDefaultRegister(); void removeRegisterRow(); void activatedCell(QModelIndex modelIndex); - void onRegisterInserted(const QModelIndex &parent, int first, int last); + void onRegisterInserted(const QModelIndex& parent, int first, int last); void handleExpressionEdit(const QModelIndex& index); private: int selectedRowAfterDelete(int deletedStartIndex, int rowCnt); - static bool sortRegistersLastFirst(const QModelIndex &s1, const QModelIndex &s2); + static bool sortRegistersLastFirst(const QModelIndex& s1, const QModelIndex& s2); Ui::RegisterDialog* _pUi; GraphDataModel* _pGraphDataModel; SettingsModel* _pSettingsModel; + AdapterManager* _pAdapterManager; CenteredBoxProxyStyle _centeredBoxStyle; std::unique_ptr _valueAxisDelegate; std::unique_ptr _expressionDelegate; std::unique_ptr _registerPopupAction; - }; #endif // REGISTERDIALOG_H diff --git a/src/importexport/mbcregisterdata.cpp b/src/importexport/mbcregisterdata.cpp index 8eaa3ac7..465ab598 100644 --- a/src/importexport/mbcregisterdata.cpp +++ b/src/importexport/mbcregisterdata.cpp @@ -1,7 +1,5 @@ #include "mbcregisterdata.h" -#include "util/expressiongenerator.h" - #include /*! @@ -182,17 +180,14 @@ void MbcRegisterData::setDecimals(const quint8& decimals) */ QString MbcRegisterData::toExpression() const { - QString expression; - QString registerStr = - ExpressionGenerator::constructRegisterString(QString("%1").arg(_registerAddress), _type, Device::cFirstDeviceId); + const QString typeStr = ModbusDataType::typeString(_type); + const QString suffix = (typeStr == QStringLiteral("16b")) ? QString() : QString(":%1").arg(typeStr); + const QString registerStr = QString("${%1%2}").arg(_registerAddress).arg(suffix); + if (_decimals != 0) { - expression = QString("%1/%2").arg(registerStr).arg(static_cast(qPow(10, _decimals))); - } - else - { - expression = registerStr; + return QString("%1/%2").arg(registerStr).arg(static_cast(qPow(10, _decimals))); } - return expression; + return registerStr; } diff --git a/src/models/adapterdata.cpp b/src/models/adapterdata.cpp index c1d9bc56..e801473e 100644 --- a/src/models/adapterdata.cpp +++ b/src/models/adapterdata.cpp @@ -43,6 +43,13 @@ void AdapterData::setHasStoredConfig(bool hasStoredConfig) _hasStoredConfig = hasStoredConfig; } +//! \brief Sets the data point schema received from adapter.dataPointSchema. +//! \param schema The full data point schema JSON object. +void AdapterData::setDataPointSchema(const QJsonObject& schema) +{ + _dataPointSchema = schema; +} + QString AdapterData::name() const { return _name; @@ -83,6 +90,13 @@ bool AdapterData::hasStoredConfig() const return _hasStoredConfig; } +//! \brief Returns the data point schema set via setDataPointSchema. +//! \return The data point schema JSON object, or an empty object if not yet set. +QJsonObject AdapterData::dataPointSchema() const +{ + return _dataPointSchema; +} + void AdapterData::updateFromDescribe(const QJsonObject& describeResult) { _name = describeResult.value("name").toString(); diff --git a/src/models/adapterdata.h b/src/models/adapterdata.h index 072ccfd7..71f0f581 100644 --- a/src/models/adapterdata.h +++ b/src/models/adapterdata.h @@ -5,11 +5,12 @@ #include /*! - * \brief Holds adapter describe metadata and opaque configuration. + * \brief Holds adapter describe metadata, register schema, and opaque configuration. * * Stores the result of an adapter.describe response (name, version, schema, - * defaults, capabilities) along with the current adapter configuration. - * The core application treats the configuration as opaque JSON — it never + * defaults, capabilities), the data point schema from adapter.dataPointSchema, + * and the current adapter configuration. + * The core application treats all adapter-specific JSON as opaque — it never * interprets adapter-specific fields. */ class AdapterData @@ -27,6 +28,7 @@ class AdapterData void setCapabilities(const QJsonObject& capabilities); void setCurrentConfig(const QJsonObject& config); void setHasStoredConfig(bool hasStoredConfig); + void setDataPointSchema(const QJsonObject& schema); QString name() const; QString version() const; @@ -36,6 +38,7 @@ class AdapterData QJsonObject capabilities() const; QJsonObject currentConfig() const; bool hasStoredConfig() const; + QJsonObject dataPointSchema() const; /*! * \brief Populate describe metadata from an adapter.describe response. @@ -58,6 +61,7 @@ class AdapterData QJsonObject _capabilities; QJsonObject _currentConfig; bool _hasStoredConfig{ false }; + QJsonObject _dataPointSchema; }; #endif // ADAPTERDATA_H diff --git a/src/models/settingsmodel.cpp b/src/models/settingsmodel.cpp index 45155954..f22c0255 100644 --- a/src/models/settingsmodel.cpp +++ b/src/models/settingsmodel.cpp @@ -202,13 +202,20 @@ void SettingsModel::setAdapterCurrentConfig(const QString& adapterId, const QJso emit adapterDataChanged(adapterId); } -/*! \brief Update adapter metadata from an adapter.describe response and notify observers. - * - * Parses name, version, schema, defaults and capabilities from \a describeResult - * and stores them in the adapter entry, then emits adapterDataChanged(). - * \param adapterId The adapter identifier string. - * \param describeResult The full JSON object returned by adapter.describe. +/*! \brief Store the data point schema from an adapter.dataPointSchema response and notify observers. + * \param adapterId The adapter identifier string. + * \param schema The full data point schema object (addressSchema, dataTypes, defaultDataType). */ +void SettingsModel::setAdapterDataPointSchema(const QString& adapterId, const QJsonObject& schema) +{ + if (!_adapters.contains(adapterId)) + { + _adapters[adapterId] = AdapterData(); + } + _adapters[adapterId].setDataPointSchema(schema); + emit adapterDataChanged(adapterId); +} + void SettingsModel::updateAdapterFromDescribe(const QString& adapterId, const QJsonObject& describeResult) { if (!_adapters.contains(adapterId)) diff --git a/src/models/settingsmodel.h b/src/models/settingsmodel.h index 5fc19a53..3e6923fe 100644 --- a/src/models/settingsmodel.h +++ b/src/models/settingsmodel.h @@ -42,6 +42,7 @@ class SettingsModel : public QObject void setAdapterCurrentConfig(const QString& adapterId, const QJsonObject& config); void updateAdapterFromDescribe(const QString& adapterId, const QJsonObject& describeResult); + void setAdapterDataPointSchema(const QString& adapterId, const QJsonObject& schema); static const QString defaultLogPath() { diff --git a/src/util/expressiongenerator.cpp b/src/util/expressiongenerator.cpp deleted file mode 100644 index 62c5f12b..00000000 --- a/src/util/expressiongenerator.cpp +++ /dev/null @@ -1,31 +0,0 @@ - -#include "expressiongenerator.h" -#include "models/device.h" - -#include - -namespace ExpressionGenerator -{ - QString typeSuffix(ModbusDataType::Type type) - { - QString suffix; - if (type == ModbusDataType::Type::UNSIGNED_16) - { - suffix = QString(); - } - else - { - suffix = QString(":%1").arg(ModbusDataType::typeString(type)); - } - - return suffix; - } - - QString constructRegisterString(QString registerAddress, ModbusDataType::Type type, deviceId_t devId) - { - QString suffix = ExpressionGenerator::typeSuffix(type); - QString connStr = devId != Device::cFirstDeviceId ? QString("@%1").arg(devId) : QString(); - - return QString("${%1%2%3}").arg(registerAddress, connStr, suffix); - } -} diff --git a/src/util/expressiongenerator.h b/src/util/expressiongenerator.h deleted file mode 100644 index f3e93784..00000000 --- a/src/util/expressiongenerator.h +++ /dev/null @@ -1,16 +0,0 @@ -#ifndef EXPRESSION_GENERATOR_H__ -#define EXPRESSION_GENERATOR_H__ - -#include - -#include "modbusdatatype.h" -#include "models/device.h" - -namespace ExpressionGenerator -{ - QString typeSuffix(ModbusDataType::Type type); - QString constructRegisterString(QString registerAddress, ModbusDataType::Type type, deviceId_t devId); -} - -#endif // EXPRESSION_GENERATOR_H__ - diff --git a/src/util/expressionregex.cpp b/src/util/expressionregex.cpp index 76f0e01d..5b52a57a 100644 --- a/src/util/expressionregex.cpp +++ b/src/util/expressionregex.cpp @@ -6,5 +6,5 @@ const QString ExpressionRegex::cNumberDec = R"(\d+)"; const QString ExpressionRegex::cNumberHex = R"(0[x]\d+)"; const QString ExpressionRegex::cNumberBin = R"(0[b]\d+)"; -const QString ExpressionRegex::cMatchRegister = R"(\$\{([ichd]?\d?.*?)\})"; -const QString ExpressionRegex::cParseReg = R"(\$\{\s*([ichd]?\d+)(?:\s*@\s*(\d+))?(?:\s*\:\s*(\w+))?\s*\})"; +const QString ExpressionRegex::cMatchRegister = R"(\$\{([^}]+)\})"; +const QString ExpressionRegex::cParseReg = R"(\$\{\s*([^@:}\s][^@:}]*?)(?:\s*@\s*(\d+))?(?:\s*\:\s*(\w+))?\s*\})"; diff --git a/tests/ProtocolAdapter/CMakeLists.txt b/tests/ProtocolAdapter/CMakeLists.txt index 74526d3e..25fa95a0 100644 --- a/tests/ProtocolAdapter/CMakeLists.txt +++ b/tests/ProtocolAdapter/CMakeLists.txt @@ -1,6 +1,7 @@ add_xtest(tst_framingreader) add_xtest(tst_adapterclient) add_xtest(tst_adapterprocess) +add_xtest(tst_adaptermanager) target_compile_definitions(tst_adapterprocess PRIVATE DUMMY_ADAPTER_EXECUTABLE="$" diff --git a/tests/ProtocolAdapter/tst_adapterclient.cpp b/tests/ProtocolAdapter/tst_adapterclient.cpp index 8219ec8c..531b4a26 100644 --- a/tests/ProtocolAdapter/tst_adapterclient.cpp +++ b/tests/ProtocolAdapter/tst_adapterclient.cpp @@ -610,4 +610,279 @@ void TestAdapterClient::processErrorDuringStoppingThenProcessFinished() QCOMPARE(spyStopped.count(), 1); } +/* ---- Helper: drive client to AWAITING_CONFIG state ---- */ +static void driveToAwaitingConfig(AdapterClient& client, MockAdapterProcess* mock) +{ + client.prepareAdapter(QStringLiteral("./dummy")); + mock->injectResponse(1, "adapter.initialize", QJsonObject{ { "status", "ok" } }); + mock->injectResponse(2, "adapter.describe", describeResult()); +} + +/* ---- Helper: drive client to ACTIVE state ---- */ +static void driveToActive(AdapterClient& client, MockAdapterProcess* mock) +{ + driveToAwaitingConfig(client, mock); + client.provideConfig(QJsonObject(), QStringList()); + mock->injectResponse(3, "adapter.configure", QJsonObject{ { "status", "ok" } }); + mock->injectResponse(4, "adapter.start", QJsonObject{ { "status", "ok" } }); +} + +void TestAdapterClient::requestDataPointSchemaEmitsSignal() +{ + auto* mock = new MockAdapterProcess(); + AdapterClient client(mock); + + QSignalSpy spySchema(&client, &AdapterClient::dataPointSchemaResult); + + driveToAwaitingConfig(client, mock); + + client.requestDataPointSchema(); + + QCOMPARE(mock->sentRequests.last().method, QStringLiteral("adapter.dataPointSchema")); + + QJsonObject schema; + schema["defaultDataType"] = QStringLiteral("16b"); + mock->injectResponse(3, "adapter.dataPointSchema", schema); + + QCOMPARE(spySchema.count(), 1); + QJsonObject received = spySchema.at(0).at(0).value(); + QCOMPARE(received["defaultDataType"].toString(), QStringLiteral("16b")); +} + +void TestAdapterClient::requestDataPointSchemaInWrongStateIgnored() +{ + auto* mock = new MockAdapterProcess(); + AdapterClient client(mock); + + QSignalSpy spySchema(&client, &AdapterClient::dataPointSchemaResult); + + /* Call in IDLE state — should be silently ignored */ + client.requestDataPointSchema(); + + QCOMPARE(spySchema.count(), 0); + QCOMPARE(mock->sentRequests.size(), 0); +} + +void TestAdapterClient::describeDataPointInAwaitingConfig() +{ + auto* mock = new MockAdapterProcess(); + AdapterClient client(mock); + + QSignalSpy spyDescReg(&client, &AdapterClient::describeDataPointResult); + + driveToAwaitingConfig(client, mock); + + client.describeDataPoint(QStringLiteral("${h0}")); + + QCOMPARE(mock->sentRequests.last().method, QStringLiteral("adapter.describeDataPoint")); + QCOMPARE(mock->sentRequests.last().params["expression"].toString(), QStringLiteral("${h0}")); + + QJsonObject result; + result["valid"] = true; + result["description"] = QStringLiteral("Holding register 0, device 1, unsigned 16-bit"); + mock->injectResponse(3, "adapter.describeDataPoint", result); + + QCOMPARE(spyDescReg.count(), 1); + QJsonObject received = spyDescReg.at(0).at(0).value(); + QCOMPARE(received["valid"].toBool(), true); + QCOMPARE(received["description"].toString(), QStringLiteral("Holding register 0, device 1, unsigned 16-bit")); +} + +void TestAdapterClient::describeDataPointInActiveState() +{ + auto* mock = new MockAdapterProcess(); + AdapterClient client(mock); + + QSignalSpy spyDescReg(&client, &AdapterClient::describeDataPointResult); + + driveToActive(client, mock); + + client.describeDataPoint(QStringLiteral("${h0}")); + + QCOMPARE(mock->sentRequests.last().method, QStringLiteral("adapter.describeDataPoint")); + + mock->injectResponse(5, "adapter.describeDataPoint", + QJsonObject{ { "valid", true }, { "description", QStringLiteral("Holding register 0") } }); + + QCOMPARE(spyDescReg.count(), 1); +} + +void TestAdapterClient::describeDataPointInWrongStateIgnored() +{ + auto* mock = new MockAdapterProcess(); + AdapterClient client(mock); + + QSignalSpy spyDescReg(&client, &AdapterClient::describeDataPointResult); + + /* Call in IDLE state — should be silently ignored */ + client.describeDataPoint(QStringLiteral("${h0}")); + + QCOMPARE(spyDescReg.count(), 0); + QCOMPARE(mock->sentRequests.size(), 0); +} + +void TestAdapterClient::validateDataPointValid() +{ + auto* mock = new MockAdapterProcess(); + AdapterClient client(mock); + + QSignalSpy spyValidate(&client, &AdapterClient::validateDataPointResult); + + driveToAwaitingConfig(client, mock); + + client.validateDataPoint(QStringLiteral("${40001: 16b}")); + + QCOMPARE(mock->sentRequests.last().method, QStringLiteral("adapter.validateDataPoint")); + QCOMPARE(mock->sentRequests.last().params["expression"].toString(), QStringLiteral("${40001: 16b}")); + + mock->injectResponse(3, "adapter.validateDataPoint", QJsonObject{ { "valid", true } }); + + QCOMPARE(spyValidate.count(), 1); + QCOMPARE(spyValidate.at(0).at(0).toBool(), true); + QCOMPARE(spyValidate.at(0).at(1).toString(), QString()); +} + +void TestAdapterClient::validateDataPointInvalid() +{ + auto* mock = new MockAdapterProcess(); + AdapterClient client(mock); + + QSignalSpy spyValidate(&client, &AdapterClient::validateDataPointResult); + + driveToAwaitingConfig(client, mock); + + client.validateDataPoint(QStringLiteral("${bad}")); + + mock->injectResponse(3, "adapter.validateDataPoint", + QJsonObject{ { "valid", false }, { "error", QStringLiteral("Unknown type 'bad'") } }); + + QCOMPARE(spyValidate.count(), 1); + QCOMPARE(spyValidate.at(0).at(0).toBool(), false); + QCOMPARE(spyValidate.at(0).at(1).toString(), QStringLiteral("Unknown type 'bad'")); +} + +void TestAdapterClient::validateDataPointInActiveState() +{ + auto* mock = new MockAdapterProcess(); + AdapterClient client(mock); + + QSignalSpy spyValidate(&client, &AdapterClient::validateDataPointResult); + + driveToActive(client, mock); + + client.validateDataPoint(QStringLiteral("${h0}")); + + QCOMPARE(mock->sentRequests.last().method, QStringLiteral("adapter.validateDataPoint")); + QCOMPARE(mock->sentRequests.last().params["expression"].toString(), QStringLiteral("${h0}")); + + mock->injectResponse(5, "adapter.validateDataPoint", QJsonObject{ { "valid", true } }); + + QCOMPARE(spyValidate.count(), 1); + QCOMPARE(spyValidate.at(0).at(0).toBool(), true); + QCOMPARE(spyValidate.at(0).at(1).toString(), QString()); +} + +void TestAdapterClient::validateDataPointInWrongStateIgnored() +{ + auto* mock = new MockAdapterProcess(); + AdapterClient client(mock); + + QSignalSpy spyValidate(&client, &AdapterClient::validateDataPointResult); + + /* Call in IDLE state — should be silently ignored */ + client.validateDataPoint(QStringLiteral("${h0}")); + + QCOMPARE(spyValidate.count(), 0); + QCOMPARE(mock->sentRequests.size(), 0); +} + +void TestAdapterClient::buildExpressionRequestAndResponse() +{ + auto* mock = new MockAdapterProcess(); + AdapterClient client(mock); + + QSignalSpy spyBuild(&client, &AdapterClient::buildExpressionResult); + + driveToAwaitingConfig(client, mock); + + QJsonObject fields; + fields["objectType"] = QStringLiteral("holding-register"); + fields["address"] = 0; + + client.buildExpression(fields, QStringLiteral("f32b"), 2); + + QCOMPARE(mock->sentRequests.last().method, QStringLiteral("adapter.buildExpression")); + QCOMPARE(mock->sentRequests.last().params["fields"].toObject(), fields); + QCOMPARE(mock->sentRequests.last().params["dataType"].toString(), QStringLiteral("f32b")); + QCOMPARE(mock->sentRequests.last().params["deviceId"].toInt(), 2); + + mock->injectResponse(3, "adapter.buildExpression", QJsonObject{ { "expression", QStringLiteral("${h0@2:f32b}") } }); + + QCOMPARE(spyBuild.count(), 1); + QCOMPARE(spyBuild.at(0).at(0).toString(), QStringLiteral("${h0@2:f32b}")); +} + +void TestAdapterClient::buildExpressionInActiveState() +{ + auto* mock = new MockAdapterProcess(); + AdapterClient client(mock); + + QSignalSpy spyBuild(&client, &AdapterClient::buildExpressionResult); + + driveToActive(client, mock); + + QJsonObject fields; + fields["objectType"] = QStringLiteral("holding-register"); + fields["address"] = 5; + + client.buildExpression(fields, QStringLiteral("16b"), 1); + + QCOMPARE(mock->sentRequests.last().method, QStringLiteral("adapter.buildExpression")); + + mock->injectResponse(5, "adapter.buildExpression", QJsonObject{ { "expression", QStringLiteral("${h5}") } }); + + QCOMPARE(spyBuild.count(), 1); + QCOMPARE(spyBuild.at(0).at(0).toString(), QStringLiteral("${h5}")); +} + +void TestAdapterClient::buildExpressionInWrongStateIgnored() +{ + auto* mock = new MockAdapterProcess(); + AdapterClient client(mock); + + QSignalSpy spyBuild(&client, &AdapterClient::buildExpressionResult); + + /* Call in IDLE state — should be silently ignored */ + client.buildExpression(QJsonObject(), QStringLiteral("16b"), 1); + + QCOMPARE(spyBuild.count(), 0); + QCOMPARE(mock->sentRequests.size(), 0); +} + +void TestAdapterClient::buildExpressionOmitsDefaultDataType() +{ + auto* mock = new MockAdapterProcess(); + AdapterClient client(mock); + + driveToAwaitingConfig(client, mock); + + /* Empty dataType should not be sent in params */ + client.buildExpression(QJsonObject(), QString(), 1); + + QVERIFY(!mock->sentRequests.last().params.contains(QStringLiteral("dataType"))); +} + +void TestAdapterClient::buildExpressionOmitsDefaultDeviceId() +{ + auto* mock = new MockAdapterProcess(); + AdapterClient client(mock); + + driveToAwaitingConfig(client, mock); + + /* deviceId == 0 should not be sent in params */ + client.buildExpression(QJsonObject(), QStringLiteral("16b"), 0); + + QVERIFY(!mock->sentRequests.last().params.contains(QStringLiteral("deviceId"))); +} + QTEST_GUILESS_MAIN(TestAdapterClient) diff --git a/tests/ProtocolAdapter/tst_adapterclient.h b/tests/ProtocolAdapter/tst_adapterclient.h index a757a9da..a5bf173d 100644 --- a/tests/ProtocolAdapter/tst_adapterclient.h +++ b/tests/ProtocolAdapter/tst_adapterclient.h @@ -33,6 +33,22 @@ private slots: void shutdownAckEmitsSessionStoppedAfterProcessExit(); void processErrorDuringStoppingNoSessionError(); void processErrorDuringStoppingThenProcessFinished(); + + void requestDataPointSchemaEmitsSignal(); + void requestDataPointSchemaInWrongStateIgnored(); + void describeDataPointInAwaitingConfig(); + void describeDataPointInActiveState(); + void describeDataPointInWrongStateIgnored(); + void validateDataPointValid(); + void validateDataPointInvalid(); + void validateDataPointInActiveState(); + void validateDataPointInWrongStateIgnored(); + + void buildExpressionRequestAndResponse(); + void buildExpressionInActiveState(); + void buildExpressionInWrongStateIgnored(); + void buildExpressionOmitsDefaultDataType(); + void buildExpressionOmitsDefaultDeviceId(); }; #endif // TST_ADAPTERCLIENT_H diff --git a/tests/communication/tst_modbuspoll.cpp b/tests/ProtocolAdapter/tst_adaptermanager.cpp similarity index 68% rename from tests/communication/tst_modbuspoll.cpp rename to tests/ProtocolAdapter/tst_adaptermanager.cpp index 87f83f8a..000f6e9b 100644 --- a/tests/communication/tst_modbuspoll.cpp +++ b/tests/ProtocolAdapter/tst_adaptermanager.cpp @@ -1,6 +1,6 @@ -#include "tst_modbuspoll.h" +#include "tst_adaptermanager.h" -#include "communication/modbuspoll.h" +#include "ProtocolAdapter/adaptermanager.h" #include "models/settingsmodel.h" #include @@ -25,79 +25,79 @@ void captureHandler(QtMsgType type, const QMessageLogContext&, const QString& ms } } // namespace -void TestModbusPoll::init() +void TestAdapterManager::init() { _pSettingsModel = new SettingsModel; - _pModbusPoll = new ModbusPoll(_pSettingsModel); + _pAdapterManager = new AdapterManager(_pSettingsModel); /* Enable debug output for scope.comm.adapter so qCDebug calls reach the handler */ QLoggingCategory::setFilterRules(QStringLiteral("scope.comm.adapter.debug=true")); } -void TestModbusPoll::cleanup() +void TestAdapterManager::cleanup() { QLoggingCategory::setFilterRules(QString()); - delete _pModbusPoll; + delete _pAdapterManager; delete _pSettingsModel; } -void TestModbusPoll::diagnosticDebugLevel() +void TestAdapterManager::diagnosticDebugLevel() { capturedType() = QtMsgType{}; capturedMessage() = QString{}; QtMessageHandler previous = qInstallMessageHandler(captureHandler); - _pModbusPoll->onAdapterDiagnostic(QStringLiteral("debug"), QStringLiteral("polling started")); + _pAdapterManager->onAdapterDiagnostic(QStringLiteral("debug"), QStringLiteral("polling started")); qInstallMessageHandler(previous); QCOMPARE(capturedType(), QtDebugMsg); QVERIFY(capturedMessage().contains(QStringLiteral("polling started"))); } -void TestModbusPoll::diagnosticInfoLevel() +void TestAdapterManager::diagnosticInfoLevel() { capturedType() = QtMsgType{}; capturedMessage() = QString{}; QtMessageHandler previous = qInstallMessageHandler(captureHandler); - _pModbusPoll->onAdapterDiagnostic(QStringLiteral("info"), QStringLiteral("session active")); + _pAdapterManager->onAdapterDiagnostic(QStringLiteral("info"), QStringLiteral("session active")); qInstallMessageHandler(previous); QCOMPARE(capturedType(), QtInfoMsg); QVERIFY(capturedMessage().contains(QStringLiteral("session active"))); } -void TestModbusPoll::diagnosticWarningLevel() +void TestAdapterManager::diagnosticWarningLevel() { capturedType() = QtMsgType{}; capturedMessage() = QString{}; QtMessageHandler previous = qInstallMessageHandler(captureHandler); - _pModbusPoll->onAdapterDiagnostic(QStringLiteral("warning"), QStringLiteral("register read failed")); + _pAdapterManager->onAdapterDiagnostic(QStringLiteral("warning"), QStringLiteral("register read failed")); qInstallMessageHandler(previous); QCOMPARE(capturedType(), QtWarningMsg); QVERIFY(capturedMessage().contains(QStringLiteral("register read failed"))); } -void TestModbusPoll::diagnosticErrorLevel() +void TestAdapterManager::diagnosticErrorLevel() { capturedType() = QtMsgType{}; capturedMessage() = QString{}; QtMessageHandler previous = qInstallMessageHandler(captureHandler); - _pModbusPoll->onAdapterDiagnostic(QStringLiteral("error"), QStringLiteral("fatal adapter fault")); + _pAdapterManager->onAdapterDiagnostic(QStringLiteral("error"), QStringLiteral("fatal adapter fault")); qInstallMessageHandler(previous); QCOMPARE(capturedType(), QtCriticalMsg); QVERIFY(capturedMessage().contains(QStringLiteral("fatal adapter fault"))); } -void TestModbusPoll::diagnosticUnknownLevel() +void TestAdapterManager::diagnosticUnknownLevel() { capturedType() = QtMsgType{}; capturedMessage() = QString{}; QtMessageHandler previous = qInstallMessageHandler(captureHandler); - _pModbusPoll->onAdapterDiagnostic(QStringLiteral("critical"), QStringLiteral("unexpected error")); + _pAdapterManager->onAdapterDiagnostic(QStringLiteral("critical"), QStringLiteral("unexpected error")); qInstallMessageHandler(previous); QCOMPARE(capturedType(), QtWarningMsg); QVERIFY(capturedMessage().contains(QStringLiteral("unknown diagnostic level"))); } -QTEST_GUILESS_MAIN(TestModbusPoll) +QTEST_GUILESS_MAIN(TestAdapterManager) diff --git a/tests/communication/tst_modbuspoll.h b/tests/ProtocolAdapter/tst_adaptermanager.h similarity index 62% rename from tests/communication/tst_modbuspoll.h rename to tests/ProtocolAdapter/tst_adaptermanager.h index 9564a73b..87cbfc6c 100644 --- a/tests/communication/tst_modbuspoll.h +++ b/tests/ProtocolAdapter/tst_adaptermanager.h @@ -1,12 +1,12 @@ -#ifndef TST_MODBUSPOLL_H -#define TST_MODBUSPOLL_H +#ifndef TST_ADAPTERMANAGER_H +#define TST_ADAPTERMANAGER_H #include -class ModbusPoll; +class AdapterManager; class SettingsModel; -class TestModbusPoll : public QObject +class TestAdapterManager : public QObject { Q_OBJECT private slots: @@ -21,7 +21,7 @@ private slots: private: SettingsModel* _pSettingsModel{ nullptr }; - ModbusPoll* _pModbusPoll{ nullptr }; + AdapterManager* _pAdapterManager{ nullptr }; }; -#endif // TST_MODBUSPOLL_H +#endif // TST_ADAPTERMANAGER_H diff --git a/tests/communication/CMakeLists.txt b/tests/communication/CMakeLists.txt index c50cdbb4..e69de29b 100644 --- a/tests/communication/CMakeLists.txt +++ b/tests/communication/CMakeLists.txt @@ -1 +0,0 @@ -add_xtest(tst_modbuspoll) diff --git a/tests/dialogs/tst_addregisterwidget.cpp b/tests/dialogs/tst_addregisterwidget.cpp index 71baba76..675bb3f5 100644 --- a/tests/dialogs/tst_addregisterwidget.cpp +++ b/tests/dialogs/tst_addregisterwidget.cpp @@ -1,20 +1,81 @@ #include "tst_addregisterwidget.h" +#include "customwidgets/schemaformwidget.h" #include "dialogs/addregisterwidget.h" +#include "models/device.h" #include "ui_addregisterwidget.h" +#include +#include #include #include +QJsonObject TestAddRegisterWidget::buildAddressSchema() +{ + QJsonObject objectTypeSchema; + objectTypeSchema["type"] = QStringLiteral("string"); + objectTypeSchema["title"] = QStringLiteral("Object type"); + objectTypeSchema["enum"] = QJsonArray{ QStringLiteral("coil"), QStringLiteral("discrete-input"), + QStringLiteral("input-register"), QStringLiteral("holding-register") }; + objectTypeSchema["x-enumLabels"] = + QJsonArray{ QStringLiteral("Coil"), QStringLiteral("Discrete Input"), QStringLiteral("Input Register"), + QStringLiteral("Holding Register") }; + + QJsonObject addressField; + addressField["type"] = QStringLiteral("integer"); + addressField["title"] = QStringLiteral("Address"); + addressField["minimum"] = 0; + addressField["maximum"] = 65535; + + QJsonObject properties; + properties["objectType"] = objectTypeSchema; + properties["address"] = addressField; + + QJsonObject schema; + schema["type"] = QStringLiteral("object"); + schema["properties"] = properties; + schema["required"] = QJsonArray{ QStringLiteral("objectType"), QStringLiteral("address") }; + return schema; +} + +QJsonObject TestAddRegisterWidget::buildTestRegisterSchema() +{ + QJsonArray dataTypes; + dataTypes.append(QJsonObject{ { QStringLiteral("id"), QStringLiteral("16b") }, + { QStringLiteral("label"), QStringLiteral("Unsigned 16-bit") } }); + dataTypes.append(QJsonObject{ { QStringLiteral("id"), QStringLiteral("s16b") }, + { QStringLiteral("label"), QStringLiteral("Signed 16-bit") } }); + dataTypes.append(QJsonObject{ { QStringLiteral("id"), QStringLiteral("32b") }, + { QStringLiteral("label"), QStringLiteral("Unsigned 32-bit") } }); + dataTypes.append(QJsonObject{ { QStringLiteral("id"), QStringLiteral("s32b") }, + { QStringLiteral("label"), QStringLiteral("Signed 32-bit") } }); + dataTypes.append(QJsonObject{ { QStringLiteral("id"), QStringLiteral("f32b") }, + { QStringLiteral("label"), QStringLiteral("32-bit float") } }); + + QJsonObject schema; + schema["addressSchema"] = buildAddressSchema(); + schema["dataTypes"] = dataTypes; + schema["defaultDataType"] = QStringLiteral("16b"); + return schema; +} + void TestAddRegisterWidget::init() { - _pRegWidget = new AddRegisterWidget(&_settingsModel); + _settingsModel.removeAllDevice(); + _settingsModel.setAdapterDataPointSchema("modbus", buildTestRegisterSchema()); + _settingsModel.deviceSettings(Device::cFirstDeviceId)->setAdapterId("modbus"); + + _pMockAdapterManager = new MockAdapterManager(&_settingsModel); + _pRegWidget = new AddRegisterWidget(&_settingsModel, QStringLiteral("modbus"), _pMockAdapterManager); } void TestAddRegisterWidget::cleanup() { delete _pRegWidget; + _pRegWidget = nullptr; + delete _pMockAdapterManager; + _pMockAdapterManager = nullptr; } void TestAddRegisterWidget::registerDefault() @@ -22,51 +83,75 @@ void TestAddRegisterWidget::registerDefault() _pRegWidget->_pUi->lineName->selectAll(); QTest::keyClicks(_pRegWidget->_pUi->lineName, "Register 1"); - _pRegWidget->_pUi->spinAddress->selectAll(); - QTest::keyClicks(_pRegWidget->_pUi->spinAddress, "100"); + _pRegWidget->_pAddressForm->setSchema( + buildAddressSchema(), QJsonObject{ { QStringLiteral("objectType"), QStringLiteral("holding-register") }, + { QStringLiteral("address"), 100 } }); GraphData graphData; - addRegister(graphData); + addRegister(graphData, QStringLiteral("${h100}")); - QCOMPARE(graphData.label(), "Register 1"); - QCOMPARE(graphData.expression(), "${40101}"); + QCOMPARE(graphData.label(), QStringLiteral("Register 1")); + QCOMPARE(graphData.expression(), QStringLiteral("${h100}")); QVERIFY(graphData.isActive()); + + QCOMPARE(_pMockAdapterManager->buildCalls.size(), 1); + QCOMPARE(_pMockAdapterManager->buildCalls[0].fields["objectType"].toString(), QStringLiteral("holding-register")); + QCOMPARE(_pMockAdapterManager->buildCalls[0].fields["address"].toInt(), 100); + QCOMPARE(_pMockAdapterManager->buildCalls[0].dataType, QStringLiteral("16b")); + QCOMPARE(_pMockAdapterManager->buildCalls[0].deviceId, Device::cFirstDeviceId); } void TestAddRegisterWidget::registerType() { - QTest::keyClick(_pRegWidget->_pUi->cmbType, Qt::Key_Down); + _pRegWidget->_pAddressForm->setSchema( + buildAddressSchema(), QJsonObject{ { QStringLiteral("objectType"), QStringLiteral("holding-register") }, + { QStringLiteral("address"), 0 } }); + + /* Select "32b" (index 2 in the combo: 16b, s16b, 32b, ...) */ + _pRegWidget->_pUi->cmbType->setCurrentIndex(2); GraphData graphData; - addRegister(graphData); + addRegister(graphData, QStringLiteral("${h0:32b}")); - QCOMPARE(graphData.expression(), "${40001:32b}"); + QCOMPARE(graphData.expression(), QStringLiteral("${h0:32b}")); + QCOMPARE(_pMockAdapterManager->buildCalls[0].dataType, QStringLiteral("32b")); } void TestAddRegisterWidget::registerObjectType() { - QTest::keyClick(_pRegWidget->_pUi->cmbObjectType, Qt::Key_Up); + _pRegWidget->_pAddressForm->setSchema( + buildAddressSchema(), QJsonObject{ { QStringLiteral("objectType"), QStringLiteral("input-register") }, + { QStringLiteral("address"), 0 } }); GraphData graphData; - addRegister(graphData); + addRegister(graphData, QStringLiteral("${i0}")); - QCOMPARE(graphData.expression(), "${30001}"); + QCOMPARE(graphData.expression(), QStringLiteral("${i0}")); + QCOMPARE(_pMockAdapterManager->buildCalls[0].fields["objectType"].toString(), QStringLiteral("input-register")); } void TestAddRegisterWidget::registerDevice() { delete _pRegWidget; + _pRegWidget = nullptr; - _settingsModel.addDevice(2); + const deviceId_t devId2 = _settingsModel.addNewDevice(); + _settingsModel.deviceSettings(devId2)->setAdapterId("modbus"); - _pRegWidget = new AddRegisterWidget(&_settingsModel); + _pRegWidget = new AddRegisterWidget(&_settingsModel, QStringLiteral("modbus"), _pMockAdapterManager); - QTest::keyClick(_pRegWidget->_pUi->cmbDevice, Qt::Key_Down); + _pRegWidget->_pAddressForm->setSchema( + buildAddressSchema(), QJsonObject{ { QStringLiteral("objectType"), QStringLiteral("holding-register") }, + { QStringLiteral("address"), 0 } }); + + /* Select device 2 (index 1) */ + _pRegWidget->_pUi->cmbDevice->setCurrentIndex(1); GraphData graphData; - addRegister(graphData); + addRegister(graphData, QStringLiteral("${h0@2}")); - QCOMPARE(graphData.expression(), "${40001@2}"); + QCOMPARE(graphData.expression(), QStringLiteral("${h0@2}")); + QCOMPARE(_pMockAdapterManager->buildCalls[0].deviceId, devId2); } void TestAddRegisterWidget::registerValueAxis() @@ -74,21 +159,38 @@ void TestAddRegisterWidget::registerValueAxis() QTest::mouseClick(_pRegWidget->_pUi->radioSecondary, Qt::LeftButton); GraphData graphData; - addRegister(graphData); + addRegister(graphData, QStringLiteral("${h0}")); QCOMPARE(graphData.valueAxis(), GraphData::VALUE_AXIS_SECONDARY); } -void TestAddRegisterWidget::pushOk() +void TestAddRegisterWidget::buildExpressionEmptyResponseIgnored() +{ + QSignalSpy spy(_pRegWidget, &AddRegisterWidget::graphDataConfigured); + + clickAdd(); + + /* Adapter returns empty expression — graphDataConfigured must not be emitted */ + _pMockAdapterManager->injectBuildExpressionResult(QString()); + + QCOMPARE(spy.count(), 0); + /* Button should be re-enabled even on empty response */ + QVERIFY(_pRegWidget->_pUi->btnAdd->isEnabled()); +} + +void TestAddRegisterWidget::clickAdd() { QTest::mouseClick(_pRegWidget->_pUi->btnAdd, Qt::LeftButton); } -void TestAddRegisterWidget::addRegister(GraphData &graphData) +void TestAddRegisterWidget::addRegister(GraphData& graphData, const QString& expression) { QSignalSpy spyGraphDataConfigured(_pRegWidget, &AddRegisterWidget::graphDataConfigured); - pushOk(); + clickAdd(); + + /* Simulate the adapter returning the expression string */ + _pMockAdapterManager->injectBuildExpressionResult(expression); QCOMPARE(spyGraphDataConfigured.count(), 1); diff --git a/tests/dialogs/tst_addregisterwidget.h b/tests/dialogs/tst_addregisterwidget.h index 877be3d0..876e7bb0 100644 --- a/tests/dialogs/tst_addregisterwidget.h +++ b/tests/dialogs/tst_addregisterwidget.h @@ -1,12 +1,54 @@ +#ifndef TST_ADDREGISTERWIDGET_H +#define TST_ADDREGISTERWIDGET_H + +#include "ProtocolAdapter/adaptermanager.h" #include "models/graphdata.h" #include "models/settingsmodel.h" +#include #include class AddRegisterWidget; -class TestAddRegisterWidget: public QObject +/*! + * \brief Test double for AdapterManager. + * + * Captures buildExpression() calls and provides an inject helper to simulate + * the adapter.buildExpression response without a real adapter process. + */ +class MockAdapterManager : public AdapterManager +{ + Q_OBJECT + +public: + struct BuildCall + { + QJsonObject fields; + QString dataType; + deviceId_t deviceId; + }; + + explicit MockAdapterManager(SettingsModel* pSettingsModel, QObject* parent = nullptr) + : AdapterManager(pSettingsModel, parent) + { + } + + void buildExpression(const QJsonObject& fields, const QString& dataType, deviceId_t deviceId) override + { + buildCalls.append({ fields, dataType, deviceId }); + } + + //! Simulate the adapter returning an expression string. + void injectBuildExpressionResult(const QString& expression) + { + emit buildExpressionResult(expression); + } + + QList buildCalls; +}; + +class TestAddRegisterWidget : public QObject { Q_OBJECT @@ -19,13 +61,17 @@ private slots: void registerObjectType(); void registerDevice(); void registerValueAxis(); + void buildExpressionEmptyResponseIgnored(); private: - - void pushOk(); - void addRegister(GraphData &graphData); + void clickAdd(); + void addRegister(GraphData& graphData, const QString& expression); + static QJsonObject buildAddressSchema(); + static QJsonObject buildTestRegisterSchema(); SettingsModel _settingsModel; - AddRegisterWidget* _pRegWidget; - + MockAdapterManager* _pMockAdapterManager{ nullptr }; + AddRegisterWidget* _pRegWidget{ nullptr }; }; + +#endif // TST_ADDREGISTERWIDGET_H diff --git a/tests/models/tst_adapterdata.cpp b/tests/models/tst_adapterdata.cpp index 4dbced84..9bf13abb 100644 --- a/tests/models/tst_adapterdata.cpp +++ b/tests/models/tst_adapterdata.cpp @@ -131,6 +131,7 @@ void TestAdapterData::settingsModelAdapterDataCreatesEntry() /* First access creates a default entry */ const AdapterData* data = model.adapterData("modbus"); QVERIFY(data != nullptr); + // NOLINTNEXTLINE(clang-analyzer-core.CallAndMessage) -- QVERIFY aborts if null QVERIFY(data->name().isEmpty()); /* updateAdapterFromDescribe updates the same entry in place */ @@ -173,6 +174,51 @@ void TestAdapterData::settingsModelRemoveAdapter() QVERIFY(model.adapterIds().isEmpty()); } +void TestAdapterData::dataPointSchemaDefaultEmpty() +{ + AdapterData data; + QVERIFY(data.dataPointSchema().isEmpty()); +} + +void TestAdapterData::setAndGetDataPointSchema() +{ + AdapterData data; + + QJsonObject addressSchema; + addressSchema["type"] = QStringLiteral("object"); + + QJsonArray dataTypes; + QJsonObject type16b; + type16b["id"] = QStringLiteral("16b"); + type16b["label"] = QStringLiteral("Unsigned 16-bit"); + dataTypes.append(type16b); + + QJsonObject schema; + schema["addressSchema"] = addressSchema; + schema["dataTypes"] = dataTypes; + schema["defaultDataType"] = QStringLiteral("16b"); + + data.setDataPointSchema(schema); + + const QJsonObject stored = data.dataPointSchema(); + QCOMPARE(stored["defaultDataType"].toString(), QStringLiteral("16b")); + QCOMPARE(stored["dataTypes"].toArray().size(), 1); +} + +void TestAdapterData::settingsModelSetAdapterDataPointSchema() +{ + SettingsModel model; + + QJsonObject schema; + schema["defaultDataType"] = QStringLiteral("16b"); + + model.setAdapterDataPointSchema("modbus", schema); + + const AdapterData* data = model.adapterData("modbus"); + const QJsonObject stored = data->dataPointSchema(); + QCOMPARE(stored["defaultDataType"].toString(), QStringLiteral("16b")); +} + void TestAdapterData::deviceAdapterIdDefaultsToModbus() { SettingsModel model; diff --git a/tests/models/tst_adapterdata.h b/tests/models/tst_adapterdata.h index 9ae17de4..c12c7005 100644 --- a/tests/models/tst_adapterdata.h +++ b/tests/models/tst_adapterdata.h @@ -21,6 +21,10 @@ private slots: void settingsModelAdapterIds(); void settingsModelRemoveAdapter(); + void dataPointSchemaDefaultEmpty(); + void setAndGetDataPointSchema(); + void settingsModelSetAdapterDataPointSchema(); + void deviceAdapterIdDefaultsToModbus(); void deviceSetAndGetAdapterId(); void deviceListForAdapterFiltersCorrectly();