diff --git a/adapters/json-rpc-spec.md b/adapters/json-rpc-spec.md index fd7543a6..83ae8267 100644 --- a/adapters/json-rpc-spec.md +++ b/adapters/json-rpc-spec.md @@ -277,6 +277,133 @@ An empty `registers` array is valid and starts polling with no registers configu --- +### `adapter.registerSchema` + +Returns the schema for register expressions — what fields make up a register address, how they should be rendered in the UI, and available data types. Call this after `adapter.describe` to discover how to build the register 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 + } + }, + "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 register dialog, so it must accurately describe all required fields and their constraints. + +--- + +### `adapter.describeRegister` + +Parses a register expression into structured fields and returns a human-readable description. Used by the core to display register 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, device 1, unsigned 16-bit" +} +``` + +**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.validateRegister` + +Validates a single register expression string without starting polling. Used for real-time validation feedback in the register 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.getStatus` Returns the current poll activity state. diff --git a/src/ProtocolAdapter/adapterclient.cpp b/src/ProtocolAdapter/adapterclient.cpp index b4a20ba2..9d21bdf5 100644 --- a/src/ProtocolAdapter/adapterclient.cpp +++ b/src/ProtocolAdapter/adapterclient.cpp @@ -80,6 +80,46 @@ void AdapterClient::requestStatus() _pProcess->sendRequest("adapter.getStatus", QJsonObject()); } +void AdapterClient::requestRegisterSchema() +{ + if (_state != State::AWAITING_CONFIG) + { + qCWarning(scopeComm) << "AdapterClient: requestRegisterSchema called in unexpected state" + << static_cast(_state); + return; + } + + _pProcess->sendRequest("adapter.registerSchema", QJsonObject()); +} + +void AdapterClient::describeRegister(const QString& expression) +{ + if (_state != State::AWAITING_CONFIG && _state != State::ACTIVE) + { + qCWarning(scopeComm) << "AdapterClient: describeRegister called in unexpected state" + << static_cast(_state); + return; + } + + QJsonObject params; + params["expression"] = expression; + _pProcess->sendRequest("adapter.describeRegister", params); +} + +void AdapterClient::validateRegister(const QString& expression) +{ + if (_state != State::AWAITING_CONFIG && _state != State::ACTIVE) + { + qCWarning(scopeComm) << "AdapterClient: validateRegister called in unexpected state" + << static_cast(_state); + return; + } + + QJsonObject params; + params["expression"] = expression; + _pProcess->sendRequest("adapter.validateRegister", params); +} + void AdapterClient::stopSession() { if (_state == State::IDLE || _state == State::STOPPING) @@ -258,6 +298,18 @@ void AdapterClient::handleLifecycleResponse(const QString& method, const QJsonOb _pProcess->stop(); /* sessionStopped is emitted from onProcessFinished once the process exits */ } + else if (method == "adapter.registerSchema" && _state == State::AWAITING_CONFIG) + { + emit registerSchemaResult(result); + } + else if (method == "adapter.describeRegister" && (_state == State::AWAITING_CONFIG || _state == State::ACTIVE)) + { + emit describeRegisterResult(result); + } + else if (method == "adapter.validateRegister" && (_state == State::AWAITING_CONFIG || _state == State::ACTIVE)) + { + emit validateRegisterResult(result["valid"].toBool(), result["error"].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..1e75e57c 100644 --- a/src/ProtocolAdapter/adapterclient.h +++ b/src/ProtocolAdapter/adapterclient.h @@ -78,6 +78,34 @@ class AdapterClient : public QObject */ void stopSession(); + /*! + * \brief Send an adapter.registerSchema request to discover the register UI schema. + * + * Must only be called after describeResult() has been emitted (i.e., in the + * AWAITING_CONFIG state). Emits registerSchemaResult() when the adapter responds. + */ + void requestRegisterSchema(); + + /*! + * \brief Send an adapter.describeRegister request to parse a register expression. + * + * Can be called in AWAITING_CONFIG or ACTIVE state. + * Emits describeRegisterResult() when the adapter responds. + * + * \param expression The register expression string to describe. + */ + void describeRegister(const QString& expression); + + /*! + * \brief Send an adapter.validateRegister request to validate a register expression. + * + * Can be called in AWAITING_CONFIG or ACTIVE state. + * Emits validateRegisterResult() when the adapter responds. + * + * \param expression The register expression string to validate. + */ + void validateRegister(const QString& expression); + signals: /*! * \brief Emitted when the adapter has been initialized, described, configured, and started. @@ -124,6 +152,25 @@ class AdapterClient : public QObject */ void diagnosticReceived(QString level, QString message); + /*! + * \brief Emitted when an adapter.registerSchema response has been received. + * \param schema The full register schema object (addressSchema, dataTypes, defaultDataType). + */ + void registerSchemaResult(QJsonObject schema); + + /*! + * \brief Emitted when an adapter.describeRegister response has been received. + * \param result The full result object (valid, fields, description or error). + */ + void describeRegisterResult(QJsonObject result); + + /*! + * \brief Emitted when an adapter.validateRegister response has been received. + * \param valid Whether the expression is valid. + * \param error Human-readable error message when valid is false; empty otherwise. + */ + void validateRegisterResult(bool valid, QString error); + protected: enum class State { diff --git a/src/communication/modbuspoll.cpp b/src/communication/modbuspoll.cpp index ca7df6c4..d848da5e 100644 --- a/src/communication/modbuspoll.cpp +++ b/src/communication/modbuspoll.cpp @@ -1,10 +1,8 @@ #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 @@ -25,6 +23,7 @@ ModbusPoll::ModbusPoll(SettingsModel* pSettingsModel, QObject* parent) : QObject connect(_pAdapterClient, &AdapterClient::sessionStarted, this, &ModbusPoll::triggerRegisterRead); connect(_pAdapterClient, &AdapterClient::readDataResult, this, &ModbusPoll::onReadDataResult); connect(_pAdapterClient, &AdapterClient::describeResult, this, &ModbusPoll::onDescribeResult); + connect(_pAdapterClient, &AdapterClient::registerSchemaResult, this, &ModbusPoll::onRegisterSchemaResult); connect(_pAdapterClient, &AdapterClient::sessionError, this, [this](QString message) { qCWarning(scopeComm) << "AdapterClient error:" << message; _bPollActive = false; @@ -123,6 +122,12 @@ void ModbusPoll::onReadDataResult(ResultDoubleList results) void ModbusPoll::onDescribeResult(const QJsonObject& description) { _pSettingsModel->updateAdapterFromDescribe("modbus", description); + _pAdapterClient->requestRegisterSchema(); +} + +void ModbusPoll::onRegisterSchemaResult(const QJsonObject& schema) +{ + _pSettingsModel->setAdapterRegisterSchema("modbus", schema); } /*! \brief Route an adapter.diagnostic notification to the diagnostics log. diff --git a/src/communication/modbuspoll.h b/src/communication/modbuspoll.h index 23126e41..8e674ac8 100644 --- a/src/communication/modbuspoll.h +++ b/src/communication/modbuspoll.h @@ -36,6 +36,7 @@ private slots: void triggerRegisterRead(); void onReadDataResult(ResultDoubleList results); void onDescribeResult(const QJsonObject& description); + void onRegisterSchemaResult(const QJsonObject& schema); private: QStringList buildRegisterExpressions(const QList& registerList); diff --git a/src/dialogs/addregisterwidget.cpp b/src/dialogs/addregisterwidget.cpp index 6dc2fc5f..7ae9576e 100644 --- a/src/dialogs/addregisterwidget.cpp +++ b/src/dialogs/addregisterwidget.cpp @@ -1,20 +1,20 @@ #include "addregisterwidget.h" #include "ui_addregisterwidget.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; +#include +#include -Q_DECLARE_METATYPE(ModbusDataType::Type) - -AddRegisterWidget::AddRegisterWidget(SettingsModel* pSettingsModel, QWidget *parent) : - QWidget(parent), - _pUi(new Ui::AddRegisterWidget), - _pSettingsModel(pSettingsModel) +AddRegisterWidget::AddRegisterWidget(SettingsModel* pSettingsModel, const QString& adapterId, QWidget* parent) + : QWidget(parent), + _pUi(new Ui::AddRegisterWidget), + _pAddressForm(new SchemaFormWidget(this)), + _pSettingsModel(pSettingsModel) { _pUi->setupUi(this); @@ -24,24 +24,41 @@ AddRegisterWidget::AddRegisterWidget(SettingsModel* pSettingsModel, QWidget *par /* Disable question mark button */ setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint); + /* Build the address form from the adapter's register schema */ + const AdapterData* adapterData = _pSettingsModel->adapterData(adapterId); + const QJsonObject registerSchema = adapterData->registerSchema(); + _addressSchema = registerSchema["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 = registerSchema["dataTypes"].toArray(); + const QString defaultTypeId = registerSchema["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)); - connect(_pUi->btnAdd, &QPushButton::clicked, this, &AddRegisterWidget::handleResultAccept); _axisGroup.setExclusive(true); @@ -82,50 +99,26 @@ 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() { - deviceId_t deviceId; - Type type; - ObjectType objectType; - - QVariant typeData = _pUi->cmbType->currentData(); - if (typeData.canConvert()) - { - type = typeData.value(); - } - else - { - type = Type::UNSIGNED_16; - } + const QJsonObject addressValues = _pAddressForm->values(); + const QString objectType = addressValues["objectType"].toString(); + const int address = addressValues["address"].toInt(); - QVariant objectTypeData = _pUi->cmbObjectType->currentData(); - if (objectTypeData.canConvert()) - { - objectType = objectTypeData.value(); - } - else - { - objectType = ObjectType::UNKNOWN; - } - - auto registerAddr = ModbusAddress(static_cast(_pUi->spinAddress->value()), objectType); + const QString typeId = _pUi->cmbType->currentData().toString(); - QVariant devData = _pUi->cmbDevice->currentData(); + deviceId_t deviceId = Device::cFirstDeviceId; + const QVariant devData = _pUi->cmbDevice->currentData(); if (devData.canConvert()) { deviceId = devData.value(); } - else - { - deviceId = 0; - } - return ExpressionGenerator::constructRegisterString(registerAddr.fullAddress(), type, deviceId); + return ExpressionGenerator::constructRegisterString(objectType, address, typeId, deviceId); } diff --git a/src/dialogs/addregisterwidget.h b/src/dialogs/addregisterwidget.h index 436c4d16..0d19631d 100644 --- a/src/dialogs/addregisterwidget.h +++ b/src/dialogs/addregisterwidget.h @@ -3,9 +3,11 @@ #include "models/graphdata.h" -#include #include +#include +#include +class SchemaFormWidget; class SettingsModel; namespace Ui { @@ -19,7 +21,7 @@ class AddRegisterWidget : public QWidget friend class TestAddRegisterWidget; public: - explicit AddRegisterWidget(SettingsModel* pSettingsModel, QWidget *parent = nullptr); + explicit AddRegisterWidget(SettingsModel* pSettingsModel, const QString& adapterId, QWidget* parent = nullptr); ~AddRegisterWidget(); signals: @@ -32,7 +34,10 @@ private slots: void resetFields(); QString generateExpression(); - Ui::AddRegisterWidget * _pUi; + Ui::AddRegisterWidget* _pUi; + SchemaFormWidget* _pAddressForm; + QJsonObject _addressSchema; + int _defaultTypeIndex{ 0 }; SettingsModel* _pSettingsModel; 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/registerdialog.cpp b/src/dialogs/registerdialog.cpp index 4dab905a..c3ad6b9e 100644 --- a/src/dialogs/registerdialog.cpp +++ b/src/dialogs/registerdialog.cpp @@ -66,7 +66,9 @@ 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); + const QStringList ids = _pSettingsModel->adapterIds(); + const QString adapterId = ids.isEmpty() ? QString() : ids.first(); + auto registerPopupMenu = new AddRegisterWidget(_pSettingsModel, adapterId, this); connect(registerPopupMenu, &AddRegisterWidget::graphDataConfigured, this, &RegisterDialog::addRegister); _registerPopupAction = std::make_unique(this); diff --git a/src/importexport/mbcregisterdata.cpp b/src/importexport/mbcregisterdata.cpp index 8eaa3ac7..e4d3b30f 100644 --- a/src/importexport/mbcregisterdata.cpp +++ b/src/importexport/mbcregisterdata.cpp @@ -183,8 +183,8 @@ void MbcRegisterData::setDecimals(const quint8& decimals) QString MbcRegisterData::toExpression() const { QString expression; - QString registerStr = - ExpressionGenerator::constructRegisterString(QString("%1").arg(_registerAddress), _type, Device::cFirstDeviceId); + QString registerStr = ExpressionGenerator::constructRegisterString( + QString("%1").arg(_registerAddress), ModbusDataType::typeString(_type), Device::cFirstDeviceId); if (_decimals != 0) { expression = QString("%1/%2").arg(registerStr).arg(static_cast(qPow(10, _decimals))); diff --git a/src/models/adapterdata.cpp b/src/models/adapterdata.cpp index c1d9bc56..ba44b3f8 100644 --- a/src/models/adapterdata.cpp +++ b/src/models/adapterdata.cpp @@ -43,6 +43,11 @@ void AdapterData::setHasStoredConfig(bool hasStoredConfig) _hasStoredConfig = hasStoredConfig; } +void AdapterData::setRegisterSchema(const QJsonObject& schema) +{ + _registerSchema = schema; +} + QString AdapterData::name() const { return _name; @@ -83,6 +88,11 @@ bool AdapterData::hasStoredConfig() const return _hasStoredConfig; } +QJsonObject AdapterData::registerSchema() const +{ + return _registerSchema; +} + 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..239246d5 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 register schema from adapter.registerSchema, + * 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 setRegisterSchema(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 registerSchema() const; /*! * \brief Populate describe metadata from an adapter.describe response. @@ -58,6 +61,7 @@ class AdapterData QJsonObject _capabilities; QJsonObject _currentConfig; bool _hasStoredConfig{ false }; + QJsonObject _registerSchema; }; #endif // ADAPTERDATA_H diff --git a/src/models/settingsmodel.cpp b/src/models/settingsmodel.cpp index 45155954..3fcbbbea 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 register schema from an adapter.registerSchema response and notify observers. + * \param adapterId The adapter identifier string. + * \param schema The full register schema object (addressSchema, dataTypes, defaultDataType). */ +void SettingsModel::setAdapterRegisterSchema(const QString& adapterId, const QJsonObject& schema) +{ + if (!_adapters.contains(adapterId)) + { + _adapters[adapterId] = AdapterData(); + } + _adapters[adapterId].setRegisterSchema(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..95f7bdae 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 setAdapterRegisterSchema(const QString& adapterId, const QJsonObject& schema); static const QString defaultLogPath() { diff --git a/src/util/expressiongenerator.cpp b/src/util/expressiongenerator.cpp index 62c5f12b..757522ad 100644 --- a/src/util/expressiongenerator.cpp +++ b/src/util/expressiongenerator.cpp @@ -4,28 +4,77 @@ #include -namespace ExpressionGenerator +namespace ExpressionGenerator { +/*! + * \brief Returns the type suffix for an expression string. + * \param typeId The data type identifier (e.g. "16b", "s16b", "f32b"). + * \return Empty string for the default type "16b", otherwise ":\a typeId". + */ +QString typeSuffix(const QString& typeId) { - QString typeSuffix(ModbusDataType::Type type) + if (typeId == QStringLiteral("16b") || typeId.isEmpty()) { - QString suffix; - if (type == ModbusDataType::Type::UNSIGNED_16) - { - suffix = QString(); - } - else - { - suffix = QString(":%1").arg(ModbusDataType::typeString(type)); - } - - return suffix; + return QString(); } + return QString(":%1").arg(typeId); +} - QString constructRegisterString(QString registerAddress, ModbusDataType::Type type, deviceId_t devId) +/*! + * \brief Returns the single-character address prefix for an object type string. + * \param objectType One of "coil", "discrete-input", "input-register", "holding-register". + * \return The corresponding prefix character ("c", "d", "i", or "h"). + * Returns "h" for unknown values as a safe default. + */ +QString objectTypeToAddressPrefix(const QString& objectType) +{ + if (objectType == QStringLiteral("coil")) { - QString suffix = ExpressionGenerator::typeSuffix(type); - QString connStr = devId != Device::cFirstDeviceId ? QString("@%1").arg(devId) : QString(); - - return QString("${%1%2%3}").arg(registerAddress, connStr, suffix); + return QStringLiteral("c"); + } + if (objectType == QStringLiteral("discrete-input")) + { + return QStringLiteral("d"); + } + if (objectType == QStringLiteral("input-register")) + { + return QStringLiteral("i"); } + return QStringLiteral("h"); +} + +/*! + * \brief Constructs a register expression string from its component parts. + * \param objectType Object type string (e.g. "holding-register"). + * \param address Zero-based register address within the object type space. + * \param typeId Data type identifier (e.g. "16b", "f32b"). + * \param devId Device identifier; omitted from the string when it equals the first device ID. + * \return A register expression string such as \c ${h0}, \c ${h5@2:f32b}. + */ +QString constructRegisterString(const QString& objectType, int address, const QString& typeId, deviceId_t devId) +{ + const QString prefix = objectTypeToAddressPrefix(objectType); + const QString suffix = typeSuffix(typeId); + const QString connStr = devId != Device::cFirstDeviceId ? QString("@%1").arg(devId) : QString(); + + return QString("${%1%2%3%4}").arg(prefix).arg(address).arg(connStr, suffix); +} + +/*! + * \brief Constructs a register expression string from a pre-formatted address string. + * + * Use this overload when the address is already in the protocol format (e.g. "40001" + * from an MBC file), rather than as a separate objectType and zero-based index. + * + * \param rawAddress Pre-formatted address string (e.g. "40001", "h0"). + * \param typeId Data type identifier (e.g. "16b", "f32b"). + * \param devId Device identifier; omitted from the string when it equals the first device ID. + * \return A register expression string such as \c ${40001}, \c ${40001@2:f32b}. + */ +QString constructRegisterString(const QString& rawAddress, const QString& typeId, deviceId_t devId) +{ + const QString suffix = typeSuffix(typeId); + const QString connStr = devId != Device::cFirstDeviceId ? QString("@%1").arg(devId) : QString(); + + return QString("${%1%2%3}").arg(rawAddress, connStr, suffix); } +} // namespace ExpressionGenerator diff --git a/src/util/expressiongenerator.h b/src/util/expressiongenerator.h index f3e93784..3d50657f 100644 --- a/src/util/expressiongenerator.h +++ b/src/util/expressiongenerator.h @@ -1,16 +1,15 @@ #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); -} +#include -#endif // EXPRESSION_GENERATOR_H__ +namespace ExpressionGenerator { +QString typeSuffix(const QString& typeId); +QString objectTypeToAddressPrefix(const QString& objectType); +QString constructRegisterString(const QString& objectType, int address, const QString& typeId, deviceId_t devId); +QString constructRegisterString(const QString& rawAddress, const QString& typeId, deviceId_t devId); +} // namespace ExpressionGenerator +#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/tst_adapterclient.cpp b/tests/ProtocolAdapter/tst_adapterclient.cpp index 8219ec8c..617df663 100644 --- a/tests/ProtocolAdapter/tst_adapterclient.cpp +++ b/tests/ProtocolAdapter/tst_adapterclient.cpp @@ -610,4 +610,169 @@ 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::requestRegisterSchemaEmitsSignal() +{ + auto* mock = new MockAdapterProcess(); + AdapterClient client(mock); + + QSignalSpy spySchema(&client, &AdapterClient::registerSchemaResult); + + driveToAwaitingConfig(client, mock); + + client.requestRegisterSchema(); + + QCOMPARE(mock->sentRequests.last().method, QStringLiteral("adapter.registerSchema")); + + QJsonObject schema; + schema["defaultDataType"] = QStringLiteral("16b"); + mock->injectResponse(3, "adapter.registerSchema", schema); + + QCOMPARE(spySchema.count(), 1); + QJsonObject received = spySchema.at(0).at(0).value(); + QCOMPARE(received["defaultDataType"].toString(), QStringLiteral("16b")); +} + +void TestAdapterClient::requestRegisterSchemaInWrongStateIgnored() +{ + auto* mock = new MockAdapterProcess(); + AdapterClient client(mock); + + QSignalSpy spySchema(&client, &AdapterClient::registerSchemaResult); + + /* Call in IDLE state — should be silently ignored */ + client.requestRegisterSchema(); + + QCOMPARE(spySchema.count(), 0); + QCOMPARE(mock->sentRequests.size(), 0); +} + +void TestAdapterClient::describeRegisterInAwaitingConfig() +{ + auto* mock = new MockAdapterProcess(); + AdapterClient client(mock); + + QSignalSpy spyDescReg(&client, &AdapterClient::describeRegisterResult); + + driveToAwaitingConfig(client, mock); + + client.describeRegister(QStringLiteral("${h0}")); + + QCOMPARE(mock->sentRequests.last().method, QStringLiteral("adapter.describeRegister")); + 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.describeRegister", 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::describeRegisterInActiveState() +{ + auto* mock = new MockAdapterProcess(); + AdapterClient client(mock); + + QSignalSpy spyDescReg(&client, &AdapterClient::describeRegisterResult); + + driveToActive(client, mock); + + client.describeRegister(QStringLiteral("${h0}")); + + QCOMPARE(mock->sentRequests.last().method, QStringLiteral("adapter.describeRegister")); + + mock->injectResponse(5, "adapter.describeRegister", + QJsonObject{ { "valid", true }, { "description", QStringLiteral("Holding register 0") } }); + + QCOMPARE(spyDescReg.count(), 1); +} + +void TestAdapterClient::describeRegisterInWrongStateIgnored() +{ + auto* mock = new MockAdapterProcess(); + AdapterClient client(mock); + + QSignalSpy spyDescReg(&client, &AdapterClient::describeRegisterResult); + + /* Call in IDLE state — should be silently ignored */ + client.describeRegister(QStringLiteral("${h0}")); + + QCOMPARE(spyDescReg.count(), 0); + QCOMPARE(mock->sentRequests.size(), 0); +} + +void TestAdapterClient::validateRegisterValid() +{ + auto* mock = new MockAdapterProcess(); + AdapterClient client(mock); + + QSignalSpy spyValidate(&client, &AdapterClient::validateRegisterResult); + + driveToAwaitingConfig(client, mock); + + client.validateRegister(QStringLiteral("${40001: 16b}")); + + QCOMPARE(mock->sentRequests.last().method, QStringLiteral("adapter.validateRegister")); + QCOMPARE(mock->sentRequests.last().params["expression"].toString(), QStringLiteral("${40001: 16b}")); + + mock->injectResponse(3, "adapter.validateRegister", 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::validateRegisterInvalid() +{ + auto* mock = new MockAdapterProcess(); + AdapterClient client(mock); + + QSignalSpy spyValidate(&client, &AdapterClient::validateRegisterResult); + + driveToAwaitingConfig(client, mock); + + client.validateRegister(QStringLiteral("${bad}")); + + mock->injectResponse(3, "adapter.validateRegister", + 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::validateRegisterInWrongStateIgnored() +{ + auto* mock = new MockAdapterProcess(); + AdapterClient client(mock); + + QSignalSpy spyValidate(&client, &AdapterClient::validateRegisterResult); + + /* Call in IDLE state — should be silently ignored */ + client.validateRegister(QStringLiteral("${h0}")); + + QCOMPARE(spyValidate.count(), 0); + QCOMPARE(mock->sentRequests.size(), 0); +} + QTEST_GUILESS_MAIN(TestAdapterClient) diff --git a/tests/ProtocolAdapter/tst_adapterclient.h b/tests/ProtocolAdapter/tst_adapterclient.h index a757a9da..66f8311c 100644 --- a/tests/ProtocolAdapter/tst_adapterclient.h +++ b/tests/ProtocolAdapter/tst_adapterclient.h @@ -33,6 +33,15 @@ private slots: void shutdownAckEmitsSessionStoppedAfterProcessExit(); void processErrorDuringStoppingNoSessionError(); void processErrorDuringStoppingThenProcessFinished(); + + void requestRegisterSchemaEmitsSignal(); + void requestRegisterSchemaInWrongStateIgnored(); + void describeRegisterInAwaitingConfig(); + void describeRegisterInActiveState(); + void describeRegisterInWrongStateIgnored(); + void validateRegisterValid(); + void validateRegisterInvalid(); + void validateRegisterInWrongStateIgnored(); }; #endif // TST_ADAPTERCLIENT_H diff --git a/tests/dialogs/tst_addregisterwidget.cpp b/tests/dialogs/tst_addregisterwidget.cpp index 71baba76..0b8eabdd 100644 --- a/tests/dialogs/tst_addregisterwidget.cpp +++ b/tests/dialogs/tst_addregisterwidget.cpp @@ -1,20 +1,76 @@ #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.setAdapterRegisterSchema("modbus", buildTestRegisterSchema()); + _settingsModel.deviceSettings(Device::cFirstDeviceId)->setAdapterId("modbus"); + _pRegWidget = new AddRegisterWidget(&_settingsModel, QStringLiteral("modbus")); } void TestAddRegisterWidget::cleanup() { delete _pRegWidget; + _pRegWidget = nullptr; } void TestAddRegisterWidget::registerDefault() @@ -22,51 +78,66 @@ 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); - QCOMPARE(graphData.label(), "Register 1"); - QCOMPARE(graphData.expression(), "${40101}"); + QCOMPARE(graphData.label(), QStringLiteral("Register 1")); + QCOMPARE(graphData.expression(), QStringLiteral("${h100}")); QVERIFY(graphData.isActive()); } 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); - QCOMPARE(graphData.expression(), "${40001:32b}"); + QCOMPARE(graphData.expression(), QStringLiteral("${h0: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); - QCOMPARE(graphData.expression(), "${30001}"); + QCOMPARE(graphData.expression(), QStringLiteral("${i0}")); } void TestAddRegisterWidget::registerDevice() { delete _pRegWidget; + _pRegWidget = nullptr; + + const deviceId_t devId2 = _settingsModel.addNewDevice(); + _settingsModel.deviceSettings(devId2)->setAdapterId("modbus"); - _settingsModel.addDevice(2); + _pRegWidget = new AddRegisterWidget(&_settingsModel, QStringLiteral("modbus")); - _pRegWidget = new AddRegisterWidget(&_settingsModel); + _pRegWidget->_pAddressForm->setSchema( + buildAddressSchema(), QJsonObject{ { QStringLiteral("objectType"), QStringLiteral("holding-register") }, + { QStringLiteral("address"), 0 } }); - QTest::keyClick(_pRegWidget->_pUi->cmbDevice, Qt::Key_Down); + /* Select device 2 (index 1) */ + _pRegWidget->_pUi->cmbDevice->setCurrentIndex(1); GraphData graphData; addRegister(graphData); - QCOMPARE(graphData.expression(), "${40001@2}"); + QCOMPARE(graphData.expression(), QStringLiteral("${h0@2}")); } void TestAddRegisterWidget::registerValueAxis() @@ -84,7 +155,7 @@ void TestAddRegisterWidget::pushOk() QTest::mouseClick(_pRegWidget->_pUi->btnAdd, Qt::LeftButton); } -void TestAddRegisterWidget::addRegister(GraphData &graphData) +void TestAddRegisterWidget::addRegister(GraphData& graphData) { QSignalSpy spyGraphDataConfigured(_pRegWidget, &AddRegisterWidget::graphDataConfigured); diff --git a/tests/dialogs/tst_addregisterwidget.h b/tests/dialogs/tst_addregisterwidget.h index 877be3d0..00f71258 100644 --- a/tests/dialogs/tst_addregisterwidget.h +++ b/tests/dialogs/tst_addregisterwidget.h @@ -1,12 +1,16 @@ +#ifndef TST_ADDREGISTERWIDGET_H +#define TST_ADDREGISTERWIDGET_H + #include "models/graphdata.h" #include "models/settingsmodel.h" +#include #include class AddRegisterWidget; -class TestAddRegisterWidget: public QObject +class TestAddRegisterWidget : public QObject { Q_OBJECT @@ -21,11 +25,13 @@ private slots: void registerValueAxis(); private: - void pushOk(); - void addRegister(GraphData &graphData); + void addRegister(GraphData& graphData); + static QJsonObject buildAddressSchema(); + static QJsonObject buildTestRegisterSchema(); SettingsModel _settingsModel; - AddRegisterWidget* _pRegWidget; - + AddRegisterWidget* _pRegWidget{ nullptr }; }; + +#endif // TST_ADDREGISTERWIDGET_H diff --git a/tests/models/tst_adapterdata.cpp b/tests/models/tst_adapterdata.cpp index 4dbced84..96d24d63 100644 --- a/tests/models/tst_adapterdata.cpp +++ b/tests/models/tst_adapterdata.cpp @@ -131,6 +131,10 @@ void TestAdapterData::settingsModelAdapterDataCreatesEntry() /* First access creates a default entry */ const AdapterData* data = model.adapterData("modbus"); QVERIFY(data != nullptr); + if (data == nullptr) + { + return; + } QVERIFY(data->name().isEmpty()); /* updateAdapterFromDescribe updates the same entry in place */ @@ -173,6 +177,51 @@ void TestAdapterData::settingsModelRemoveAdapter() QVERIFY(model.adapterIds().isEmpty()); } +void TestAdapterData::registerSchemaDefaultEmpty() +{ + AdapterData data; + QVERIFY(data.registerSchema().isEmpty()); +} + +void TestAdapterData::setAndGetRegisterSchema() +{ + 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.setRegisterSchema(schema); + + const QJsonObject stored = data.registerSchema(); + QCOMPARE(stored["defaultDataType"].toString(), QStringLiteral("16b")); + QCOMPARE(stored["dataTypes"].toArray().size(), 1); +} + +void TestAdapterData::settingsModelSetAdapterRegisterSchema() +{ + SettingsModel model; + + QJsonObject schema; + schema["defaultDataType"] = QStringLiteral("16b"); + + model.setAdapterRegisterSchema("modbus", schema); + + const AdapterData* data = model.adapterData("modbus"); + const QJsonObject stored = data->registerSchema(); + 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..8042a65e 100644 --- a/tests/models/tst_adapterdata.h +++ b/tests/models/tst_adapterdata.h @@ -21,6 +21,10 @@ private slots: void settingsModelAdapterIds(); void settingsModelRemoveAdapter(); + void registerSchemaDefaultEmpty(); + void setAndGetRegisterSchema(); + void settingsModelSetAdapterRegisterSchema(); + void deviceAdapterIdDefaultsToModbus(); void deviceSetAndGetAdapterId(); void deviceListForAdapterFiltersCorrectly();