diff --git a/app/src/domain/active_register_repository.cpp b/app/src/domain/active_register_repository.cpp index b43b132..0694033 100644 --- a/app/src/domain/active_register_repository.cpp +++ b/app/src/domain/active_register_repository.cpp @@ -33,15 +33,15 @@ RegisterWriteResult ActiveRegisterRepository::writeWord( return repository_->writeWord(address, value); } -WordPairReadResult ActiveRegisterRepository::readWordPair( - const RegisterAddress &address) const +WordsReadResult ActiveRegisterRepository::readWords( + const RegisterAddress &address, int count) const { - return repository_->readWordPair(address); + return repository_->readWords(address, count); } -RegisterWriteResult ActiveRegisterRepository::writeWordPair( +RegisterWriteResult ActiveRegisterRepository::writeWords( const RegisterAddress &address, - const std::array &values) + const std::vector &values) { - return repository_->writeWordPair(address, values); + return repository_->writeWords(address, values); } diff --git a/app/src/domain/active_register_repository.h b/app/src/domain/active_register_repository.h index 0e0b4ff..5c600c7 100644 --- a/app/src/domain/active_register_repository.h +++ b/app/src/domain/active_register_repository.h @@ -15,10 +15,11 @@ public: WordReadResult readWord(const RegisterAddress &address) const override; RegisterWriteResult writeWord( const RegisterAddress &address, std::int16_t value) override; - WordPairReadResult readWordPair(const RegisterAddress &address) const override; - RegisterWriteResult writeWordPair( + WordsReadResult readWords( + const RegisterAddress &address, int count) const override; + RegisterWriteResult writeWords( const RegisterAddress &address, - const std::array &values) override; + const std::vector &values) override; private: RegisterRepository *repository_ = nullptr; diff --git a/app/src/domain/hmi_model.cpp b/app/src/domain/hmi_model.cpp index 0907a79..8f9a958 100644 --- a/app/src/domain/hmi_model.cpp +++ b/app/src/domain/hmi_model.cpp @@ -173,16 +173,30 @@ bool HmiControl::validate(std::string *error) const } const bool numeric = type == HmiControlType::NumericDisplay || type == HmiControlType::NumericInput; + if (!registerDataTypeIsSupported(dataType)) + { + setError(error, "HMI 数值类型无效"); + return false; + } if (!numeric && dataType != RegisterDataType::Int16) { - setError(error, "只有 HMI 数值控件可以选择 Float32"); + setError(error, "只有 HMI 数值控件可以选择多字数值类型"); return false; } - if (numeric && dataType == RegisterDataType::Float32 - && binding.has_value() - && binding->index() >= RegisterAddress::kMaximumIndex) + if (numeric && binding.has_value() + && !registerDataTypeAddressIsValid(dataType, *binding)) { - setError(error, "Float32 起始地址必须位于 D0~D3999"); + if (dataType == RegisterDataType::Float64) + { + setError(error, "Double 起始地址必须是 D0~D3996 范围内的偶数地址"); + } + else + { + setError( + error, + std::string(registerDataTypeDescriptor(dataType).displayName) + + " 起始地址超出可用 D 区范围"); + } return false; } if (type == HmiControlType::PageJump) diff --git a/app/src/domain/project_model.cpp b/app/src/domain/project_model.cpp index 1f86fad..ffdd36d 100644 --- a/app/src/domain/project_model.cpp +++ b/app/src/domain/project_model.cpp @@ -96,7 +96,7 @@ bool rangesIntersect(const HmiDataBinding &left, const HmiDataBinding &right) && right.address.index() <= leftEnd; } -bool isFloatWriteDestination(const LogicNodeConfig &config, RegisterAddress *address) +bool isWordWriteDestination(const LogicNodeConfig &config, RegisterAddress *address) { if (address == nullptr) { @@ -383,11 +383,15 @@ bool Project::validate( { poll_addresses.push_back(*control.binding); if (isNumericControl(control) - && control.dataType == RegisterDataType::Float32 && control.binding->area() == RegisterArea::D) { - poll_addresses.push_back(RegisterAddress{ - RegisterArea::D, control.binding->index() + 1}); + for (int offset = 1; + offset < registerDataTypeWordCount(control.dataType); + ++offset) + { + poll_addresses.push_back(RegisterAddress{ + RegisterArea::D, control.binding->index() + offset}); + } } } } @@ -411,19 +415,20 @@ bool Project::validate( collectRegisterAddressesForLogicNode(node->config, &poll_addresses); RegisterAddress destination{RegisterArea::D, 0}; if (node->configured - && isFloatWriteDestination(node->config, &destination)) + && isWordWriteDestination(node->config, &destination)) { for (const HmiDataBinding &binding : hmi_bindings) { - if (binding.type == RegisterDataType::Float32 + const int word_count = registerDataTypeWordCount(binding.type); + if (word_count > 1 && binding.address.area() == RegisterArea::D && destination.index() >= binding.address.index() && destination.index() - < binding.address.index() + 2) + < binding.address.index() + word_count) { setError( error, - "普通整数指令不能写入 Float32 占用的 D" + "16 位整数指令不能写入多字 HMI 数值占用的 D" + std::to_string(destination.index())); return false; } diff --git a/app/src/domain/register_monitor_model.cpp b/app/src/domain/register_monitor_model.cpp index f8afd34..732b732 100644 --- a/app/src/domain/register_monitor_model.cpp +++ b/app/src/domain/register_monitor_model.cpp @@ -30,12 +30,11 @@ bool RegisterMonitorModel::contains(const MonitorPoint &point) const bool RegisterMonitorModel::add(const MonitorPoint &point) { - const int end = point.address.index() - + registerDataTypeWordCount(point.dataType) - 1; if (!point.address.isValid() - || (point.dataType == RegisterDataType::Float32 - && point.address.area() != RegisterArea::D) - || end > RegisterAddress::kMaximumIndex + || (point.address.area() == RegisterArea::M + && point.dataType != RegisterDataType::Int16) + || (point.address.area() == RegisterArea::D + && !registerDataTypeAddressIsValid(point.dataType, point.address)) || contains(point) || points_.size() >= kMaximumItemCount) { diff --git a/app/src/domain/register_monitor_model.h b/app/src/domain/register_monitor_model.h index 5a7cad9..696bbdb 100644 --- a/app/src/domain/register_monitor_model.h +++ b/app/src/domain/register_monitor_model.h @@ -23,8 +23,7 @@ struct MonitorValue int endAddress = 0; MonitorValueState state = MonitorValueState::Unavailable; bool bitValue = false; - std::int16_t wordValue = 0; - float floatValue = 0.0f; + RegisterNumericValue numericValue = std::int16_t{0}; }; struct MonitorPoint diff --git a/app/src/domain/register_repository.h b/app/src/domain/register_repository.h index c7a818c..ef2361a 100644 --- a/app/src/domain/register_repository.h +++ b/app/src/domain/register_repository.h @@ -4,6 +4,8 @@ #include #include +#include +#include // 寄存器仓库操作失败原因;服务层会把它转换成用户可读消息 enum class RegisterError @@ -45,6 +47,13 @@ struct WordPairReadResult RegisterError error = RegisterError::Unavailable; }; +struct WordsReadResult +{ + bool succeeded = false; + std::vector values; + RegisterError error = RegisterError::Unavailable; +}; + // M/D 寄存器访问边界 // 所有运行态读写都必须经过此接口,UI 不直接接触串口或缓存实现 class RegisterRepository @@ -61,33 +70,66 @@ public: // 写入 D 区 16 位字寄存器 virtual RegisterWriteResult writeWord( const RegisterAddress &address, std::int16_t value) = 0; - virtual WordPairReadResult readWordPair(const RegisterAddress &address) const + virtual WordsReadResult readWords( + const RegisterAddress &address, int count) const { if (!address.isValid() || address.area() != RegisterArea::D - || address.index() >= RegisterAddress::kMaximumIndex) + || count < 1 + || address.index() > RegisterAddress::kMaximumIndex - count + 1) { return {false, {}, address.isValid() && address.area() != RegisterArea::D ? RegisterError::AreaMismatch : RegisterError::InvalidAddress}; } - const WordReadResult low = readWord(address); - const WordReadResult high = readWord( - RegisterAddress{RegisterArea::D, address.index() + 1}); - if (!low.succeeded) return {false, {}, low.error}; - if (!high.succeeded) return {false, {}, high.error}; - return {true, {low.value, high.value}, RegisterError::None}; + std::vector values; + values.reserve(static_cast(count)); + for (int offset = 0; offset < count; ++offset) + { + const WordReadResult result = readWord( + RegisterAddress{RegisterArea::D, address.index() + offset}); + if (!result.succeeded) + { + return {false, {}, result.error}; + } + values.push_back(result.value); + } + return {true, std::move(values), RegisterError::None}; } - virtual RegisterWriteResult writeWordPair( + virtual RegisterWriteResult writeWords( const RegisterAddress &address, - const std::array &values) + const std::vector &values) { + const int count = static_cast(values.size()); if (!address.isValid() || address.area() != RegisterArea::D - || address.index() >= RegisterAddress::kMaximumIndex) + || count < 1 + || address.index() > RegisterAddress::kMaximumIndex - count + 1) { return {false, address.isValid() && address.area() != RegisterArea::D ? RegisterError::AreaMismatch : RegisterError::InvalidAddress}; } - const RegisterWriteResult low = writeWord(address, values[0]); - if (!low.succeeded) return low; - return writeWord(RegisterAddress{RegisterArea::D, address.index() + 1}, values[1]); + for (int offset = 0; offset < count; ++offset) + { + const RegisterWriteResult result = writeWord( + RegisterAddress{RegisterArea::D, address.index() + offset}, + values[static_cast(offset)]); + if (!result.succeeded) + { + return result; + } + } + return {true, RegisterError::None}; + } + virtual WordPairReadResult readWordPair(const RegisterAddress &address) const + { + const WordsReadResult result = readWords(address, 2); + return result.succeeded + ? WordPairReadResult{ + true, {result.values[0], result.values[1]}, RegisterError::None} + : WordPairReadResult{false, {}, result.error}; + } + virtual RegisterWriteResult writeWordPair( + const RegisterAddress &address, + const std::array &values) + { + return writeWords(address, {values[0], values[1]}); } }; diff --git a/app/src/domain/register_value_type.cpp b/app/src/domain/register_value_type.cpp index 35f6794..5b44811 100644 --- a/app/src/domain/register_value_type.cpp +++ b/app/src/domain/register_value_type.cpp @@ -2,10 +2,54 @@ #include #include +#include + +namespace { + +constexpr RegisterDataTypeDescriptor kInt16Descriptor{ + "int16", "Int16", 1, 1, false}; +constexpr RegisterDataTypeDescriptor kInt32Descriptor{ + "int32", "Int32", 2, 1, false}; +constexpr RegisterDataTypeDescriptor kFloat32Descriptor{ + "float32", "Float32", 2, 1, true}; +constexpr RegisterDataTypeDescriptor kFloat64Descriptor{ + "float64", "Double (Float64)", 4, 2, true}; +constexpr RegisterDataTypeDescriptor kInvalidDescriptor{ + "", "Unknown", 0, 1, false}; + +template +std::vector toVector(const std::array &words) +{ + return std::vector(words.cbegin(), words.cend()); +} + +} // namespace + +const RegisterDataTypeDescriptor ®isterDataTypeDescriptor(RegisterDataType type) +{ + switch (type) + { + case RegisterDataType::Int16: + return kInt16Descriptor; + case RegisterDataType::Int32: + return kInt32Descriptor; + case RegisterDataType::Float32: + return kFloat32Descriptor; + case RegisterDataType::Float64: + return kFloat64Descriptor; + default: + return kInvalidDescriptor; + } +} + +bool registerDataTypeIsSupported(RegisterDataType type) +{ + return registerDataTypeDescriptor(type).wordCount > 0; +} const char *registerDataTypeName(RegisterDataType type) { - return type == RegisterDataType::Float32 ? "float32" : "int16"; + return registerDataTypeDescriptor(type).jsonName; } bool parseRegisterDataType(const std::string &text, RegisterDataType *type) @@ -24,12 +68,51 @@ bool parseRegisterDataType(const std::string &text, RegisterDataType *type) *type = RegisterDataType::Float32; return true; } + if (text == "int32") + { + *type = RegisterDataType::Int32; + return true; + } + if (text == "float64") + { + *type = RegisterDataType::Float64; + return true; + } return false; } int registerDataTypeWordCount(RegisterDataType type) { - return type == RegisterDataType::Float32 ? 2 : 1; + return registerDataTypeDescriptor(type).wordCount; +} + +bool registerDataTypeAddressIsValid( + RegisterDataType type, const RegisterAddress &address) +{ + const RegisterDataTypeDescriptor &descriptor = registerDataTypeDescriptor(type); + return descriptor.wordCount > 0 + && address.isValid() + && address.area() == RegisterArea::D + && address.index() <= RegisterAddress::kMaximumIndex - descriptor.wordCount + 1 + && address.index() % descriptor.addressAlignment == 0; +} + +std::array Int32Codec::encode(std::int32_t value) +{ + std::uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + return {static_cast(bits & 0xffffU), + static_cast(bits >> 16U)}; +} + +std::int32_t Int32Codec::decode( + std::int16_t low_word, std::int16_t high_word) +{ + const std::uint32_t bits = static_cast(low_word) + | (static_cast(static_cast(high_word)) << 16U); + std::int32_t value = 0; + std::memcpy(&value, &bits, sizeof(value)); + return value; } std::array Float32Codec::encode(float value) @@ -49,3 +132,104 @@ std::optional Float32Codec::decode( std::memcpy(&value, &bits, sizeof(value)); return std::isfinite(value) ? std::optional(value) : std::nullopt; } + +std::array Float64Codec::encode(double value) +{ + std::uint64_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + return { + static_cast(bits & 0xffffU), + static_cast((bits >> 16U) & 0xffffU), + static_cast((bits >> 32U) & 0xffffU), + static_cast(bits >> 48U)}; +} + +std::optional Float64Codec::decode( + const std::array &words) +{ + const std::uint64_t bits = static_cast(words[0]) + | (static_cast(static_cast(words[1])) << 16U) + | (static_cast(static_cast(words[2])) << 32U) + | (static_cast(static_cast(words[3])) << 48U); + double value = 0.0; + std::memcpy(&value, &bits, sizeof(value)); + return std::isfinite(value) ? std::optional(value) : std::nullopt; +} + +std::optional decodeRegisterNumericValue( + RegisterDataType type, const std::vector &words) +{ + if (static_cast(words.size()) != registerDataTypeWordCount(type)) + { + return std::nullopt; + } + switch (type) + { + case RegisterDataType::Int16: + return RegisterNumericValue{words[0]}; + case RegisterDataType::Int32: + return RegisterNumericValue{Int32Codec::decode(words[0], words[1])}; + case RegisterDataType::Float32: + { + const std::optional value = Float32Codec::decode(words[0], words[1]); + return value.has_value() + ? std::optional{RegisterNumericValue{*value}} + : std::nullopt; + } + case RegisterDataType::Float64: + { + const std::optional value = Float64Codec::decode( + {words[0], words[1], words[2], words[3]}); + return value.has_value() + ? std::optional{RegisterNumericValue{*value}} + : std::nullopt; + } + default: + return std::nullopt; + } +} + +std::optional> encodeRegisterNumericValue( + RegisterDataType type, double value) +{ + if (!std::isfinite(value)) + { + return std::nullopt; + } + switch (type) + { + case RegisterDataType::Int16: + if (std::trunc(value) != value + || value < std::numeric_limits::min() + || value > std::numeric_limits::max()) + { + return std::nullopt; + } + return std::vector{static_cast(value)}; + case RegisterDataType::Int32: + if (std::trunc(value) != value + || value < std::numeric_limits::min() + || value > std::numeric_limits::max()) + { + return std::nullopt; + } + return toVector(Int32Codec::encode(static_cast(value))); + case RegisterDataType::Float32: + { + if (value < -static_cast(std::numeric_limits::max()) + || value > static_cast(std::numeric_limits::max())) + { + return std::nullopt; + } + const float converted = static_cast(value); + return std::isfinite(converted) + ? std::optional>{ + toVector(Float32Codec::encode(converted))} + : std::nullopt; + } + case RegisterDataType::Float64: + return toVector(Float64Codec::encode(value)); + default: + return std::nullopt; + } +} diff --git a/app/src/domain/register_value_type.h b/app/src/domain/register_value_type.h index 2605f64..386e25c 100644 --- a/app/src/domain/register_value_type.h +++ b/app/src/domain/register_value_type.h @@ -1,22 +1,72 @@ #pragma once +#include "register_address.h" + #include #include #include #include +#include +#include enum class RegisterDataType { Int16, - Float32 + Int32, + Float32, + Float64 +}; + +struct RegisterDataTypeDescriptor +{ + const char *jsonName = "int16"; + const char *displayName = "Int16"; + int wordCount = 1; + int addressAlignment = 1; + bool floatingPoint = false; +}; + +using RegisterNumericValue = std::variant; + +struct RegisterWordRange +{ + RegisterAddress start{RegisterArea::D, 0}; + int wordCount = 1; + + bool operator==(const RegisterWordRange &other) const + { + return start == other.start && wordCount == other.wordCount; + } }; +const RegisterDataTypeDescriptor ®isterDataTypeDescriptor(RegisterDataType type); +bool registerDataTypeIsSupported(RegisterDataType type); const char *registerDataTypeName(RegisterDataType type); bool parseRegisterDataType(const std::string &text, RegisterDataType *type); int registerDataTypeWordCount(RegisterDataType type); +bool registerDataTypeAddressIsValid( + RegisterDataType type, const RegisterAddress &address); + +struct Int32Codec +{ + static std::array encode(std::int32_t value); + static std::int32_t decode(std::int16_t low_word, std::int16_t high_word); +}; struct Float32Codec { static std::array encode(float value); static std::optional decode(std::int16_t low_word, std::int16_t high_word); }; + +struct Float64Codec +{ + static std::array encode(double value); + static std::optional decode( + const std::array &words); +}; + +std::optional decodeRegisterNumericValue( + RegisterDataType type, const std::vector &words); +std::optional> encodeRegisterNumericValue( + RegisterDataType type, double value); diff --git a/app/src/domain/virtual_register_repository.cpp b/app/src/domain/virtual_register_repository.cpp index 091932c..ecdddb6 100644 --- a/app/src/domain/virtual_register_repository.cpp +++ b/app/src/domain/virtual_register_repository.cpp @@ -1,5 +1,7 @@ #include "virtual_register_repository.h" +#include + VirtualRegisterRepository::VirtualRegisterRepository() { // 构造时清空离线寄存器,确保初始值确定 @@ -66,17 +68,35 @@ RegisterWriteResult VirtualRegisterRepository::writeWord( return {true, RegisterError::None}; } -WordPairReadResult VirtualRegisterRepository::readWordPair( - const RegisterAddress &address) const +WordsReadResult VirtualRegisterRepository::readWords( + const RegisterAddress &address, int count) const { - return RegisterRepository::readWordPair(address); + if (!address.isValid() || address.area() != RegisterArea::D + || count < 1 + || address.index() > RegisterAddress::kMaximumIndex - count + 1) + { + return {false, {}, address.isValid() && address.area() != RegisterArea::D + ? RegisterError::AreaMismatch : RegisterError::InvalidAddress}; + } + const auto begin = words_.cbegin() + address.index(); + return {true, std::vector(begin, begin + count), RegisterError::None}; } -RegisterWriteResult VirtualRegisterRepository::writeWordPair( +RegisterWriteResult VirtualRegisterRepository::writeWords( const RegisterAddress &address, - const std::array &values) + const std::vector &values) { - return RegisterRepository::writeWordPair(address, values); + const int count = static_cast(values.size()); + if (!address.isValid() || address.area() != RegisterArea::D + || count < 1 + || address.index() > RegisterAddress::kMaximumIndex - count + 1) + { + return {false, address.isValid() && address.area() != RegisterArea::D + ? RegisterError::AreaMismatch : RegisterError::InvalidAddress}; + } + std::copy( + values.cbegin(), values.cend(), words_.begin() + address.index()); + return {true, RegisterError::None}; } void VirtualRegisterRepository::clear() diff --git a/app/src/domain/virtual_register_repository.h b/app/src/domain/virtual_register_repository.h index cd7a587..fc35719 100644 --- a/app/src/domain/virtual_register_repository.h +++ b/app/src/domain/virtual_register_repository.h @@ -20,10 +20,11 @@ public: // 更新内存中的 D 区字值 RegisterWriteResult writeWord( const RegisterAddress &address, std::int16_t value) override; - WordPairReadResult readWordPair(const RegisterAddress &address) const override; - RegisterWriteResult writeWordPair( + WordsReadResult readWords( + const RegisterAddress &address, int count) const override; + RegisterWriteResult writeWords( const RegisterAddress &address, - const std::array &values) override; + const std::vector &values) override; // 将所有虚拟寄存器恢复为默认值,开始新的离线会话 void clear(); diff --git a/app/src/infrastructure/json_project_storage.cpp b/app/src/infrastructure/json_project_storage.cpp index 260bb12..7fb8a51 100644 --- a/app/src/infrastructure/json_project_storage.cpp +++ b/app/src/infrastructure/json_project_storage.cpp @@ -727,7 +727,7 @@ bool parseHmiControl( { return state->fail( ProjectStorageError::InvalidField, - context + ".dataType 必须是 int16 或 float32"); + context + ".dataType 必须是 int16、int32、float32 或 float64"); } } if (control->type == HmiControlType::Button) diff --git a/app/src/infrastructure/plc_communication_service.cpp b/app/src/infrastructure/plc_communication_service.cpp index eedbce3..07d7984 100644 --- a/app/src/infrastructure/plc_communication_service.cpp +++ b/app/src/infrastructure/plc_communication_service.cpp @@ -78,67 +78,68 @@ bool normalizePollAddresses( return true; } -bool isFloat32Start( - const std::vector &float32_starts, +bool boundaryBelongsToMultiWordRange( RegisterArea area, - int index) + int boundary, + const std::vector &ranges) { - return std::binary_search( - float32_starts.cbegin(), float32_starts.cend(), - RegisterAddress{area, index}, - [](const RegisterAddress &left, const RegisterAddress &right) + return std::any_of( + ranges.cbegin(), ranges.cend(), + [area, boundary](const RegisterWordRange &range) { - return left.area() == right.area() - ? left.index() < right.index() - : left.area() == RegisterArea::M; + return range.start.area() == area + && range.start.index() <= boundary + && boundary < range.start.index() + range.wordCount - 1; }); } -std::size_t pollBlockCount( +bool calculatePollBlocks( const std::vector &addresses, - const std::vector &float32_starts) + const std::vector &multi_word_ranges, + std::vector *blocks) { + blocks->clear(); if (addresses.empty()) { - // 空集合也会保留 M0、D0 两个默认读块 - return 2U; - } - std::size_t blocks = 0U; - RegisterArea current_area = RegisterArea::M; - int start_address = 0; - int count = 0; - for (const RegisterAddress &address : addresses) - { - const bool must_keep_pair = count >= ProjectLimits::kMaximumModbusReadCount - && address.area() == current_area - && address.index() == start_address + count - && isFloat32Start(float32_starts, address.area(), address.index() - 1) - && start_address + count - 1 == address.index() - 1; - if (must_keep_pair) - { - ++blocks; - count = 2; - start_address = address.index() - 1; - current_area = address.area(); - continue; - } - // 跨区域、出现地址间隔或达到 Modbus 单次上限时,必须开始新读块 - if (count == 0 - || address.area() != current_area - || address.index() > start_address + count - || count >= ProjectLimits::kMaximumModbusReadCount) + blocks->push_back({RegisterArea::M, 0, 1}); + blocks->push_back({RegisterArea::D, 0, 1}); + return true; + } + + std::size_t run_start = 0U; + while (run_start < addresses.size()) + { + std::size_t run_end = run_start; + while (run_end + 1U < addresses.size() + && addresses[run_end + 1U].area() == addresses[run_start].area() + && addresses[run_end + 1U].index() == addresses[run_end].index() + 1) { - ++blocks; - current_area = address.area(); - start_address = address.index(); - count = 1; + ++run_end; } - else + const RegisterArea area = addresses[run_start].area(); + int cursor = addresses[run_start].index(); + const int last = addresses[run_end].index(); + while (cursor <= last) { - count = address.index() - start_address + 1; + int block_end = std::min( + cursor + ProjectLimits::kMaximumModbusReadCount - 1, last); + while (block_end < last + && boundaryBelongsToMultiWordRange( + area, block_end, multi_word_ranges)) + { + --block_end; + } + if (block_end < cursor) + { + blocks->clear(); + return false; + } + blocks->push_back({area, cursor, block_end - cursor + 1}); + cursor = block_end + 1; } + run_start = run_end + 1U; } - return blocks; + return true; } } // namespace @@ -161,9 +162,9 @@ PlcCommunicationService::PlcCommunicationService( return sendWordWrite(address, value); }, [this](const RegisterAddress &address, - const std::array &values) + const std::vector &values) { - return sendWordPairWrite(address, values); + return sendWordsWrite(address, values); }); // 轮询定时器每次只推动一个读块,避免一次压入大量异步请求 connect(&poll_timer_, &QTimer::timeout, this, &PlcCommunicationService::pollNextBlock); @@ -302,7 +303,7 @@ PlcCommunicationResult PlcCommunicationService::setPollAddresses( PlcCommunicationResult PlcCommunicationService::setPollAddresses( const std::vector &addresses, - const std::vector &float32_starts) + const std::vector &multi_word_ranges) { std::vector normalized; std::string error; @@ -310,43 +311,38 @@ PlcCommunicationResult PlcCommunicationService::setPollAddresses( { return {false, error}; } - std::vector normalized_float32_starts = float32_starts; + std::vector normalized_ranges = multi_word_ranges; std::sort( - normalized_float32_starts.begin(), normalized_float32_starts.end(), - [](const RegisterAddress &left, const RegisterAddress &right) + normalized_ranges.begin(), normalized_ranges.end(), + [](const RegisterWordRange &left, const RegisterWordRange &right) { - return left.area() == right.area() - ? left.index() < right.index() - : left.area() == RegisterArea::M; + return left.start.index() == right.start.index() + ? left.wordCount < right.wordCount + : left.start.index() < right.start.index(); }); - normalized_float32_starts.erase( + normalized_ranges.erase( std::unique( - normalized_float32_starts.begin(), normalized_float32_starts.end()), - normalized_float32_starts.end()); + normalized_ranges.begin(), normalized_ranges.end()), + normalized_ranges.end()); if (std::any_of( - normalized_float32_starts.cbegin(), normalized_float32_starts.cend(), - [](const RegisterAddress &address) + normalized_ranges.cbegin(), normalized_ranges.cend(), + [](const RegisterWordRange &range) { - return !address.isValid() - || address.area() != RegisterArea::D - || address.index() >= RegisterAddress::kMaximumIndex; + return !range.start.isValid() + || range.start.area() != RegisterArea::D + || range.wordCount < 2 || range.wordCount > 4 + || range.start.index() + > RegisterAddress::kMaximumIndex - range.wordCount + 1; })) { - return {false, "Float32 轮询起始地址必须位于 D0~D3999"}; + return {false, "多字轮询范围必须是 D 区内连续的 2~4 个字"}; } - for (const RegisterAddress &start : normalized_float32_starts) + for (const RegisterWordRange &range : normalized_ranges) { - const RegisterAddress high{ - RegisterArea::D, start.index() + 1}; - if (std::find(normalized.cbegin(), normalized.cend(), start) - == normalized.cend()) + for (int offset = 0; offset < range.wordCount; ++offset) { - normalized.push_back(start); - } - if (std::find(normalized.cbegin(), normalized.cend(), high) - == normalized.cend()) - { - normalized.push_back(high); + normalized.push_back(RegisterAddress{ + RegisterArea::D, range.start.index() + offset}); } } std::sort( @@ -362,8 +358,12 @@ PlcCommunicationResult PlcCommunicationService::setPollAddresses( { return {false, "PLC 轮询的去重 M/D 地址最多为 256 个"}; } - if (pollBlockCount(normalized, normalized_float32_starts) - > ProjectLimits::kMaximumPollBlocks) + std::vector calculated_blocks; + if (!calculatePollBlocks(normalized, normalized_ranges, &calculated_blocks)) + { + return {false, "重叠的多字范围无法在单次 120 字读取边界内完整轮询"}; + } + if (calculated_blocks.size() > ProjectLimits::kMaximumPollBlocks) { return {false, "PLC 轮询地址拆分后最多允许 8 个读块"}; } @@ -372,11 +372,11 @@ PlcCommunicationResult PlcCommunicationService::setPollAddresses( { // 当前回复仍在解析旧集合,等它结束后再切换到新集合 pending_poll_addresses_ = std::move(normalized); - pending_poll_float32_starts_ = std::move(normalized_float32_starts); + pending_poll_multi_word_ranges_ = std::move(normalized_ranges); poll_update_pending_ = true; return {true, {}}; } - poll_float32_starts_ = std::move(normalized_float32_starts); + poll_multi_word_ranges_ = std::move(normalized_ranges); applyPollAddresses(normalized); return {true, {}}; } @@ -406,6 +406,11 @@ const PlcSerialConfiguration &PlcCommunicationService::configuration() const return configuration_; } +const std::vector &PlcCommunicationService::pollBlocks() const +{ + return poll_blocks_; +} + void PlcCommunicationService::setCallbacks( std::function state_changed, std::function initial_read_changed, @@ -442,54 +447,13 @@ void PlcCommunicationService::rebuildPollBlocks() return left.index() < right.index(); }); addresses.erase(std::unique(addresses.begin(), addresses.end()), addresses.end()); - std::sort( - poll_float32_starts_.begin(), poll_float32_starts_.end(), - [](const RegisterAddress &left, const RegisterAddress &right) - { - return left.area() == right.area() - ? left.index() < right.index() - : left.area() == RegisterArea::M; - }); - poll_float32_starts_.erase( - std::unique(poll_float32_starts_.begin(), poll_float32_starts_.end()), - poll_float32_starts_.end()); - // 将相邻地址合并为读块,同时遵守 Modbus 单次读取上限 + std::vector calculated_blocks; + calculatePollBlocks(addresses, poll_multi_word_ranges_, &calculated_blocks); poll_blocks_.clear(); - for (const RegisterAddress &address : addresses) + poll_blocks_.reserve(calculated_blocks.size()); + for (const PlcPollBlock &block : calculated_blocks) { - if (!address.isValid()) - { - continue; - } - if (!poll_blocks_.empty() - && poll_blocks_.back().area == address.area() - && address.index() == poll_blocks_.back().startAddress - + poll_blocks_.back().count - && poll_blocks_.back().count >= ProjectLimits::kMaximumModbusReadCount - && isFloat32Start( - poll_float32_starts_, address.area(), address.index() - 1) - && poll_blocks_.back().startAddress - + poll_blocks_.back().count - 1 == address.index() - 1) - { - // 把 Float32 低字从已满读块移到新块,保证高字不会单独读取 - --poll_blocks_.back().count; - poll_blocks_.push_back({address.area(), address.index() - 1, 2}); - continue; - } - if (poll_blocks_.empty() - || poll_blocks_.back().area != address.area() - || address.index() > poll_blocks_.back().startAddress - + poll_blocks_.back().count - || poll_blocks_.back().count - >= ProjectLimits::kMaximumModbusReadCount) - { - poll_blocks_.push_back({address.area(), address.index(), 1}); - } - else - { - poll_blocks_.back().count = address.index() - - poll_blocks_.back().startAddress + 1; - } + poll_blocks_.push_back(block); } next_poll_block_ = 0; if (!initial_read_completed_) @@ -511,7 +475,7 @@ void PlcCommunicationService::applyPollAddresses( rebuildPollBlocks(); poll_update_pending_ = false; pending_poll_addresses_.clear(); - pending_poll_float32_starts_.clear(); + pending_poll_multi_word_ranges_.clear(); if (isReadingState(state_) && pending_reply_ == nullptr) { pollNextBlock(); @@ -527,7 +491,7 @@ void PlcCommunicationService::pollNextBlock() } // 每次只发一个异步请求,完成回调中再推进到下一个块 const std::size_t block_index = next_poll_block_; - const PollBlock block = poll_blocks_.at(block_index); + const PlcPollBlock block = poll_blocks_.at(block_index); next_poll_block_ = (next_poll_block_ + 1U) % poll_blocks_.size(); QModbusDataUnit request( registerType(block.area), block.startAddress, static_cast(block.count)); @@ -584,7 +548,7 @@ void PlcCommunicationService::pollNextBlock() if (poll_update_pending_) { const std::vector addresses = pending_poll_addresses_; - poll_float32_starts_ = pending_poll_float32_starts_; + poll_multi_word_ranges_ = pending_poll_multi_word_ranges_; applyPollAddresses(addresses); } if (poll_cycle_completed) @@ -621,7 +585,7 @@ void PlcCommunicationService::probeRecovery() return; } // 恢复探测只读取一个地址,确认链路恢复后再进行完整首读 - const PollBlock block = poll_blocks_.front(); + const PlcPollBlock block = poll_blocks_.front(); const QModbusDataUnit request( registerType(block.area), block.startAddress, 1); QModbusReply *reply = master_->sendReadRequest(request, configuration_.serverAddress); @@ -650,7 +614,7 @@ void PlcCommunicationService::probeRecovery() if (poll_update_pending_) { const std::vector addresses = pending_poll_addresses_; - poll_float32_starts_ = pending_poll_float32_starts_; + poll_multi_word_ranges_ = pending_poll_multi_word_ranges_; applyPollAddresses(addresses); } if (state_ != PlcConnectionState::Faulted @@ -698,7 +662,8 @@ void PlcCommunicationService::restoreCommunication() pollNextBlock(); } -void PlcCommunicationService::handleReadFinished(QModbusReply *reply, PollBlock block) +void PlcCommunicationService::handleReadFinished( + QModbusReply *reply, PlcPollBlock block) { if (!isReadingState(state_)) { @@ -822,24 +787,29 @@ RegisterWriteResult PlcCommunicationService::sendWordWrite( return {true, RegisterError::None}; } -RegisterWriteResult PlcCommunicationService::sendWordPairWrite( +RegisterWriteResult PlcCommunicationService::sendWordsWrite( const RegisterAddress &address, - const std::array &values) + const std::vector &values) { + const int count = static_cast(values.size()); if (state_ != PlcConnectionState::Connected) { return {false, RegisterError::Unavailable}; } if (pending_write_reply_ != nullptr || !address.isValid() || address.area() != RegisterArea::D - || address.index() >= RegisterAddress::kMaximumIndex) + || count < 2 || count > 4 + || address.index() > RegisterAddress::kMaximumIndex - count + 1) { return {false, pending_write_reply_ != nullptr ? RegisterError::WriteRejected : RegisterError::InvalidAddress}; } - QModbusDataUnit unit(QModbusDataUnit::HoldingRegisters, address.index(), 2); - unit.setValue(0, static_cast(values[0])); - unit.setValue(1, static_cast(values[1])); + QModbusDataUnit unit(QModbusDataUnit::HoldingRegisters, address.index(), count); + for (int offset = 0; offset < count; ++offset) + { + unit.setValue( + offset, static_cast(values[static_cast(offset)])); + } QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); if (reply == nullptr) { @@ -927,6 +897,7 @@ void PlcCommunicationService::closeSerialSession() pending_write_reply_ = nullptr; poll_update_pending_ = false; pending_poll_addresses_.clear(); + pending_poll_multi_word_ranges_.clear(); if (master_->state() != QModbusDevice::UnconnectedState) { master_->disconnectDevice(); @@ -980,5 +951,3 @@ void PlcCommunicationService::setError(const PlcCommunicationFailure &failure) recovery_timer_.start(kRecoveryProbeIntervalMs); } } - // 按区域和地址排序,方便后面合并连续读块 - // 相同区域和编号的地址只保留一个 diff --git a/app/src/infrastructure/plc_communication_service.h b/app/src/infrastructure/plc_communication_service.h index d26381d..d1acd2b 100644 --- a/app/src/infrastructure/plc_communication_service.h +++ b/app/src/infrastructure/plc_communication_service.h @@ -8,7 +8,6 @@ #include #include -#include #include #include #include @@ -18,6 +17,13 @@ class QModbusReply; class QModbusRtuSerialMaster; struct PlcCommunicationFailure; +struct PlcPollBlock +{ + RegisterArea area = RegisterArea::M; + int startAddress = 0; + int count = 1; +}; + // 基于 Qt Modbus RTU 的异步 PLC 通信实现 // 负责连接、轮询、单点写入、故障恢复和首读资格,不阻塞 UI 线程 class PlcCommunicationService final : public QObject, public PlcCommunicationGateway @@ -42,7 +48,7 @@ public: const std::vector &addresses) override; PlcCommunicationResult setPollAddresses( const std::vector &addresses, - const std::vector &float32_starts) override; + const std::vector &multi_word_ranges) override; // 返回当前连接状态 PlcConnectionState state() const override; @@ -54,6 +60,8 @@ public: const std::string &lastError() const override; // 返回当前使用的串口和 Modbus 参数 const PlcSerialConfiguration &configuration() const; + // 返回当前拆分后的只读轮询计划,供诊断和边界测试使用 + const std::vector &pollBlocks() const; // 注册状态、首读、缓存更新和错误通知回调 void setCallbacks( std::function state_changed, @@ -75,14 +83,6 @@ signals: void communicationError(const QString &message); private: - struct PollBlock - { - // 同一区域的一段连续 Modbus 原始地址 - RegisterArea area = RegisterArea::M; // M 区或 D 区 - int startAddress = 0; // 读块的第一个原始地址 - int count = 1; // 从起始地址连续读取的数量 - }; - // 把去重后的地址集合压缩为有限数量的连续读块 void rebuildPollBlocks(); // 应用新的轮询集合;有请求在途时由 pending_* 延后应用 @@ -96,14 +96,14 @@ private: // 探测成功后恢复轮询,但仍需重新完成首读 void restoreCommunication(); // 处理读回复并把值写入 PLC 缓存 - void handleReadFinished(QModbusReply *reply, PollBlock block); + void handleReadFinished(QModbusReply *reply, PlcPollBlock block); // 发送单点 M/D 写请求;缓存等待后续轮询确认 RegisterWriteResult sendBitWrite(const RegisterAddress &address, bool value); RegisterWriteResult sendWordWrite( const RegisterAddress &address, std::int16_t value); - RegisterWriteResult sendWordPairWrite( + RegisterWriteResult sendWordsWrite( const RegisterAddress &address, - const std::array &values); + const std::vector &values); // 更新“所有轮询块均成功读取”的真机进入资格 void updateInitialReadCompleted(bool completed, bool force_notification = false); // 处理没有主动断开时发生的串口断线 @@ -124,9 +124,9 @@ private: PlcSerialConfiguration configuration_; // 当前串口和站号配置 std::vector poll_addresses_; // 当前生效的轮询地址 std::vector pending_poll_addresses_; // 请求在途时暂存的新地址 - std::vector poll_float32_starts_; // 当前轮询集合中的 Float32 起始地址 - std::vector pending_poll_float32_starts_; // 请求在途时暂存的 Float32 起始地址 - std::vector poll_blocks_; // 根据地址合并出的连续读块 + std::vector poll_multi_word_ranges_; // 当前轮询集合中的多字范围 + std::vector pending_poll_multi_word_ranges_; // 请求在途时暂存的多字范围 + std::vector poll_blocks_; // 根据地址合并出的连续读块 std::size_t next_poll_block_ = 0; // 下一次要读取的读块下标 QModbusReply *pending_reply_ = nullptr; // 当前未完成的读请求或恢复探测 QModbusReply *pending_write_reply_ = nullptr; // 当前未完成的单点写请求 diff --git a/app/src/infrastructure/plc_register_repository.cpp b/app/src/infrastructure/plc_register_repository.cpp index c90c8b3..1ebf26e 100644 --- a/app/src/infrastructure/plc_register_repository.cpp +++ b/app/src/infrastructure/plc_register_repository.cpp @@ -69,35 +69,44 @@ RegisterWriteResult PlcRegisterRepository::writeWord( : RegisterWriteResult{false, RegisterError::Unavailable}; } -WordPairReadResult PlcRegisterRepository::readWordPair( - const RegisterAddress &address) const +WordsReadResult PlcRegisterRepository::readWords( + const RegisterAddress &address, int count) const { - return RegisterRepository::readWordPair(address); + return RegisterRepository::readWords(address, count); } -RegisterWriteResult PlcRegisterRepository::writeWordPair( +RegisterWriteResult PlcRegisterRepository::writeWords( const RegisterAddress &address, - const std::array &values) + const std::vector &values) { + const int count = static_cast(values.size()); if (!address.isValid() || address.area() != RegisterArea::D - || address.index() >= RegisterAddress::kMaximumIndex) + || count < 1 + || address.index() > RegisterAddress::kMaximumIndex - count + 1) { return {false, address.isValid() && address.area() != RegisterArea::D ? RegisterError::AreaMismatch : RegisterError::InvalidAddress}; } - return word_pair_handler_ ? word_pair_handler_(address, values) - : RegisterWriteResult{false, RegisterError::Unavailable}; + if (count == 1) + { + return word_handler_ ? word_handler_(address, values.front()) + : RegisterWriteResult{ + false, RegisterError::Unavailable}; + } + return words_handler_ ? words_handler_(address, values) + : RegisterWriteResult{false, RegisterError::Unavailable}; } void PlcRegisterRepository::setWriteHandlers( std::function bit_handler, std::function word_handler, std::function &)> word_pair_handler) + const RegisterAddress &, + const std::vector &)> words_handler) { bit_handler_ = std::move(bit_handler); word_handler_ = std::move(word_handler); - word_pair_handler_ = std::move(word_pair_handler); + words_handler_ = std::move(words_handler); } void PlcRegisterRepository::updateBit(int address, bool value) diff --git a/app/src/infrastructure/plc_register_repository.h b/app/src/infrastructure/plc_register_repository.h index 01e051d..ec62f8a 100644 --- a/app/src/infrastructure/plc_register_repository.h +++ b/app/src/infrastructure/plc_register_repository.h @@ -2,8 +2,8 @@ #include "domain/register_repository.h" -#include #include +#include // 保存 PLC 最近一次成功读回的 M/D 缓存,并把写请求转交给通信服务 // 未读到过的地址保持 Unavailable,避免界面显示伪造的初始值 @@ -17,17 +17,19 @@ public: WordReadResult readWord(const RegisterAddress &address) const override; RegisterWriteResult writeWord( const RegisterAddress &address, std::int16_t value) override; - WordPairReadResult readWordPair(const RegisterAddress &address) const override; - RegisterWriteResult writeWordPair( + WordsReadResult readWords( + const RegisterAddress &address, int count) const override; + RegisterWriteResult writeWords( const RegisterAddress &address, - const std::array &values) override; + const std::vector &values) override; // 注入异步写入回调;回调成功不代表缓存已经更新 void setWriteHandlers( std::function bit_handler, std::function word_handler, std::function &)> word_pair_handler = {}); + const RegisterAddress &, + const std::vector &)> words_handler = {}); // 由通信服务在读回成功后更新缓存并标记地址有效 void updateBit(int address, bool value); void updateWord(int address, std::int16_t value); @@ -47,5 +49,5 @@ private: std::function bit_handler_; // M 区异步写入回调 std::function word_handler_; // D 区异步写入回调 std::function &)> word_pair_handler_; + const RegisterAddress &, const std::vector &)> words_handler_; }; diff --git a/app/src/services/hmi_runtime_service.cpp b/app/src/services/hmi_runtime_service.cpp index 30d879a..0060b22 100644 --- a/app/src/services/hmi_runtime_service.cpp +++ b/app/src/services/hmi_runtime_service.cpp @@ -9,7 +9,7 @@ namespace { HmiRuntimeReadResult readFailure(HmiRuntimeError error) { - return {false, error, false, 0, 0.0f}; + return {false, error, false, std::int16_t{0}}; } HmiRuntimeWriteResult writeFailure(HmiRuntimeError error) @@ -58,30 +58,26 @@ HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) c { return readFailure(repositoryError(result.error)); } - return {true, HmiRuntimeError::None, result.value, 0, 0.0f}; + return {true, HmiRuntimeError::None, result.value, std::int16_t{0}}; } case HmiRuntimeValueKind::Word: { - if (control.dataType == RegisterDataType::Float32) + if (!registerDataTypeAddressIsValid(control.dataType, *control.binding)) { - const WordPairReadResult result = repository_.readWordPair(*control.binding); - if (!result.succeeded) - { - return readFailure(repositoryError(result.error)); - } - const std::optional value = Float32Codec::decode( - result.values[0], result.values[1]); - return value.has_value() - ? HmiRuntimeReadResult{ - true, HmiRuntimeError::None, false, 0, *value} - : readFailure(HmiRuntimeError::RepositoryFailure); + return readFailure(HmiRuntimeError::InvalidBinding); } - const WordReadResult result = repository_.readWord(*control.binding); + const WordsReadResult result = repository_.readWords( + *control.binding, registerDataTypeWordCount(control.dataType)); if (!result.succeeded) { return readFailure(repositoryError(result.error)); } - return {true, HmiRuntimeError::None, false, result.value, 0.0f}; + const std::optional value = + decodeRegisterNumericValue(control.dataType, result.values); + return value.has_value() + ? HmiRuntimeReadResult{ + true, HmiRuntimeError::None, false, *value} + : readFailure(HmiRuntimeError::RepositoryFailure); } case HmiRuntimeValueKind::None: default: @@ -171,36 +167,18 @@ HmiRuntimeWriteResult HmiRuntimeService::writeNumericInput( { return writeFailure(HmiRuntimeError::InvalidBinding); } - RegisterWriteResult result; - if (control.dataType == RegisterDataType::Float32) + if (!registerDataTypeAddressIsValid(control.dataType, *control.binding)) { - if (control.binding->index() >= RegisterAddress::kMaximumIndex - || !std::isfinite(value) - || value > static_cast(std::numeric_limits::max()) - || value < -static_cast(std::numeric_limits::max())) - { - return writeFailure(HmiRuntimeError::InvalidBinding); - } - const float float_value = static_cast(value); - if (!std::isfinite(float_value)) - { - return writeFailure(HmiRuntimeError::InvalidBinding); - } - result = repository_.writeWordPair( - *control.binding, Float32Codec::encode(float_value)); + return writeFailure(HmiRuntimeError::InvalidBinding); } - else + const std::optional> encoded = + encodeRegisterNumericValue(control.dataType, value); + if (!encoded.has_value()) { - if (!std::isfinite(value) - || value < std::numeric_limits::min() - || value > std::numeric_limits::max() - || std::trunc(value) != value) - { - return writeFailure(HmiRuntimeError::InvalidBinding); - } - result = repository_.writeWord( - *control.binding, static_cast(value)); + return writeFailure(HmiRuntimeError::InvalidBinding); } + const RegisterWriteResult result = repository_.writeWords( + *control.binding, *encoded); return result.succeeded ? HmiRuntimeWriteResult{true, HmiRuntimeError::None} : writeFailure(repositoryError(result.error)); diff --git a/app/src/services/hmi_runtime_service.h b/app/src/services/hmi_runtime_service.h index 827112a..8c6fc76 100644 --- a/app/src/services/hmi_runtime_service.h +++ b/app/src/services/hmi_runtime_service.h @@ -27,15 +27,14 @@ enum class HmiRuntimeError /** * @brief HMI 控件寄存器读取结果 * - * 控件类型决定 `bit_value` 或 `word_value` 哪一个有效 + * 控件类型决定 `bit_value` 或 `numeric_value` 哪一个有效 */ struct HmiRuntimeReadResult { bool succeeded = false; // 是否成功读到控件绑定值 HmiRuntimeError error = HmiRuntimeError::None; // 失败时的 HMI 错误分类 bool bit_value = false; // 位控件的读回值;字控件读取成功时无效 - std::int16_t word_value = 0; // 字控件的读回值;位控件读取成功时无效 - float float_value = 0.0f; // Float32 数值控件的读回值 + RegisterNumericValue numeric_value = std::int16_t{0}; // 数值控件的类型化读回值 }; /** diff --git a/app/src/services/plc_communication_gateway.h b/app/src/services/plc_communication_gateway.h index 1ea483f..6b37fe2 100644 --- a/app/src/services/plc_communication_gateway.h +++ b/app/src/services/plc_communication_gateway.h @@ -1,6 +1,7 @@ #pragma once #include "domain/register_address.h" +#include "domain/register_value_type.h" #include "domain/project_limits.h" #include @@ -153,18 +154,18 @@ public: const std::vector &addresses) = 0; /** - * @brief 设置轮询地址并标记必须成对读取的 Float32 起始地址 + * @brief 设置轮询地址并标记不能跨读块拆分的多字范围 * @param addresses 需要周期性读回的 M/D 地址集合 - * @param float32_starts D 区 Float32 起始地址;每个地址必须和下一个 D 一起读取 + * @param multi_word_ranges Int32、Float32 或 Double 占用的连续 D 范围 * @return true 表示集合已接受 * - * 默认实现兼容只关心地址集合的测试网关和旧调用方;真实 Modbus 实现会使用成对信息避免拆分 Float32 + * 默认实现兼容只关心地址集合的测试网关和旧调用方;真实 Modbus 实现会避免拆分任何多字值 */ virtual PlcCommunicationResult setPollAddresses( const std::vector &addresses, - const std::vector &float32_starts) + const std::vector &multi_word_ranges) { - (void)float32_starts; + (void)multi_word_ranges; return setPollAddresses(addresses); } diff --git a/app/src/services/register_monitor_service.cpp b/app/src/services/register_monitor_service.cpp index 978b0c8..6eaeb22 100644 --- a/app/src/services/register_monitor_service.cpp +++ b/app/src/services/register_monitor_service.cpp @@ -60,10 +60,20 @@ RegisterMonitorResult RegisterMonitorService::addRange( parseErrorMessage(parsed.error), 0}; } if (parsed.address.area() == RegisterArea::M - && data_type == RegisterDataType::Float32) + && data_type != RegisterDataType::Int16) { return {false, RegisterMonitorError::InvalidAddress, - "Float32 只允许使用 D 区地址", 0}; + "M 区只支持位监控,Int32、Float32 和 Double 只允许使用 D 区", 0}; + } + if (parsed.address.area() == RegisterArea::D + && !registerDataTypeAddressIsValid(data_type, parsed.address)) + { + return {false, RegisterMonitorError::InvalidAddress, + data_type == RegisterDataType::Float64 + ? "Double 起始地址必须是 D0~D3996 范围内的偶数地址" + : std::string(registerDataTypeDescriptor(data_type).displayName) + + " 起始地址超出可用 D 区范围", + 0}; } const int step = registerDataTypeWordCount(data_type); if (count < 1 @@ -73,7 +83,7 @@ RegisterMonitorResult RegisterMonitorService::addRange( "连续监控范围不能超过地址 4000", 0}; } - std::vector pending; + std::vector pending; pending.reserve(static_cast(count)); for (int offset = 0; offset < count; ++offset) { @@ -83,7 +93,7 @@ RegisterMonitorResult RegisterMonitorService::addRange( data_type}; if (!model_.contains(point)) { - pending.push_back(point.address); + pending.push_back(point); } } if (pending.empty()) @@ -95,9 +105,21 @@ RegisterMonitorResult RegisterMonitorService::addRange( return {false, RegisterMonitorError::LimitExceeded, "自由监控最多允许 64 个地址", 0}; } - for (const RegisterAddress &address : pending) + std::vector candidate = model_.points(); + candidate.insert(candidate.end(), pending.cbegin(), pending.cend()); + if (poll_configuration_validator_) + { + const std::string validation_error = poll_configuration_validator_( + pollAddressesForPoints(candidate), multiWordRangesForPoints(candidate)); + if (!validation_error.empty()) + { + return {false, RegisterMonitorError::PollConfigurationRejected, + validation_error, 0}; + } + } + for (const MonitorPoint &point : pending) { - model_.add(MonitorPoint{address, data_type}); + model_.add(point); } if (addresses_changed_callback_) { @@ -108,13 +130,16 @@ RegisterMonitorResult RegisterMonitorService::addRange( { const int existing_end = existing.address.index() + registerDataTypeWordCount(existing.dataType) - 1; - for (const RegisterAddress &address : pending) + for (const MonitorPoint &pending_point : pending) { - const int pending_end = address.index() + step - 1; + const RegisterAddress &address = pending_point.address; + const int pending_end = address.index() + + registerDataTypeWordCount(pending_point.dataType) - 1; if (existing.address.area() == address.area() && existing.address.index() <= pending_end && address.index() <= existing_end - && !(existing.address == address && existing.dataType == data_type)) + && !(existing.address == address + && existing.dataType == pending_point.dataType)) { overlaps = true; } @@ -203,35 +228,49 @@ RegisterMonitorWriteResult RegisterMonitorService::writeBit( RegisterMonitorWriteResult RegisterMonitorService::writeWord( const RegisterAddress &address, std::int16_t value) { - const RegisterWriteResult result = repository_.writeWord(address, value); - const RegisterMonitorWriteResult active_result = result.succeeded - ? RegisterMonitorWriteResult{true, RegisterError::None, {}} - : RegisterMonitorWriteResult{false, result.error, result.error == RegisterError::AreaMismatch - ? "字写入只允许访问 D 区" - : result.error == RegisterError::InvalidAddress - ? "写入地址无效" - : result.error == RegisterError::Unavailable - ? "当前寄存器源不可用" - : "寄存器写入请求被拒绝"}; - return captureInitialWord(address, value, active_result); + return writeNumeric(address, RegisterDataType::Int16, value); } RegisterMonitorWriteResult RegisterMonitorService::writeFloat( const RegisterAddress &address, float value) { - if (!std::isfinite(value) || !address.isValid() - || address.area() != RegisterArea::D - || address.index() >= RegisterAddress::kMaximumIndex) + return writeNumeric(address, RegisterDataType::Float32, value); +} + +RegisterMonitorWriteResult RegisterMonitorService::writeNumeric( + const RegisterAddress &address, RegisterDataType data_type, double value) +{ + if (address.isValid() && address.area() != RegisterArea::D) + { + return {false, RegisterError::AreaMismatch, + "数值写入只允许访问 D 区"}; + } + if (!registerDataTypeAddressIsValid(data_type, address)) + { + return {false, RegisterError::InvalidAddress, + std::string(registerDataTypeDescriptor(data_type).displayName) + + " 写入地址无效"}; + } + const std::optional> encoded = + encodeRegisterNumericValue(data_type, value); + if (!encoded.has_value()) { - return {false, RegisterError::InvalidAddress, "Float32 写入值或地址无效"}; + return {false, RegisterError::InvalidAddress, + std::string(registerDataTypeDescriptor(data_type).displayName) + + " 写入值无效"}; } - const RegisterWriteResult result = repository_.writeWordPair( - address, Float32Codec::encode(value)); + const RegisterWriteResult result = repository_.writeWords(address, *encoded); const RegisterMonitorWriteResult active_result = result.succeeded ? RegisterMonitorWriteResult{true, RegisterError::None, {}} - : RegisterMonitorWriteResult{false, result.error, result.error == RegisterError::Unavailable - ? "当前寄存器源不可用" : "寄存器写入请求被拒绝"}; - return captureInitialFloat(address, value, active_result); + : RegisterMonitorWriteResult{false, result.error, + result.error == RegisterError::AreaMismatch + ? "数值写入只允许访问 D 区" + : result.error == RegisterError::InvalidAddress + ? "写入地址无效" + : result.error == RegisterError::Unavailable + ? "当前寄存器源不可用" + : "寄存器写入请求被拒绝"}; + return captureInitialWords(address, *encoded, active_result); } RegisterMonitorWriteResult RegisterMonitorService::captureInitialBit( @@ -247,21 +286,9 @@ RegisterMonitorWriteResult RegisterMonitorService::captureInitialBit( return active_result; } -RegisterMonitorWriteResult RegisterMonitorService::captureInitialWord( - const RegisterAddress &address, std::int16_t value, - const RegisterMonitorWriteResult &active_result) -{ - if (!active_result.succeeded || !capture_offline_initial_values_ - || offline_initial_repository_ == nullptr) - { - return active_result; - } - offline_initial_repository_->writeWord(address, value); - return active_result; -} - -RegisterMonitorWriteResult RegisterMonitorService::captureInitialFloat( - const RegisterAddress &address, float value, +RegisterMonitorWriteResult RegisterMonitorService::captureInitialWords( + const RegisterAddress &address, + const std::vector &values, const RegisterMonitorWriteResult &active_result) { if (!active_result.succeeded || !capture_offline_initial_values_ @@ -269,8 +296,7 @@ RegisterMonitorWriteResult RegisterMonitorService::captureInitialFloat( { return active_result; } - offline_initial_repository_->writeWordPair( - address, Float32Codec::encode(value)); + offline_initial_repository_->writeWords(address, values); return active_result; } @@ -302,27 +328,19 @@ std::vector RegisterMonitorService::values(bool communication_faul } else { - if (point.dataType == RegisterDataType::Float32) + const WordsReadResult read = repository_.readWords( + address, registerDataTypeWordCount(point.dataType)); + const std::optional decoded = read.succeeded + ? decodeRegisterNumericValue(point.dataType, read.values) + : std::nullopt; + if (decoded.has_value()) { - const WordPairReadResult read = repository_.readWordPair(address); - const std::optional decoded = read.succeeded - ? Float32Codec::decode(read.values[0], read.values[1]) - : std::nullopt; - value.floatValue = decoded.value_or(0.0f); - value.state = decoded.has_value() - ? (communication_fault ? MonitorValueState::CommunicationFault - : MonitorValueState::Valid) - : MonitorValueState::Unavailable; + value.numericValue = *decoded; } - else - { - const WordReadResult read = repository_.readWord(address); - value.wordValue = read.value; - value.state = read.succeeded + value.state = decoded.has_value() ? (communication_fault ? MonitorValueState::CommunicationFault : MonitorValueState::Valid) : MonitorValueState::Unavailable; - } } result.push_back(value); } @@ -335,9 +353,15 @@ const std::vector &RegisterMonitorService::points() const } std::vector RegisterMonitorService::pollAddresses() const +{ + return pollAddressesForPoints(model_.points()); +} + +std::vector RegisterMonitorService::pollAddressesForPoints( + const std::vector &points) { std::vector result; - for (const MonitorPoint &point : model_.points()) + for (const MonitorPoint &point : points) { for (int offset = 0; offset < registerDataTypeWordCount(point.dataType); ++offset) { @@ -348,14 +372,21 @@ std::vector RegisterMonitorService::pollAddresses() const return result; } -std::vector RegisterMonitorService::float32Starts() const +std::vector RegisterMonitorService::multiWordRanges() const { - std::vector result; - for (const MonitorPoint &point : model_.points()) + return multiWordRangesForPoints(model_.points()); +} + +std::vector RegisterMonitorService::multiWordRangesForPoints( + const std::vector &points) +{ + std::vector result; + for (const MonitorPoint &point : points) { - if (point.dataType == RegisterDataType::Float32) + const int word_count = registerDataTypeWordCount(point.dataType); + if (point.address.area() == RegisterArea::D && word_count > 1) { - result.push_back(point.address); + result.push_back({point.address, word_count}); } } return result; @@ -365,3 +396,11 @@ void RegisterMonitorService::setAddressesChangedCallback(std::function c { addresses_changed_callback_ = std::move(callback); } + +void RegisterMonitorService::setPollConfigurationValidator( + std::function &, + const std::vector &)> validator) +{ + poll_configuration_validator_ = std::move(validator); +} diff --git a/app/src/services/register_monitor_service.h b/app/src/services/register_monitor_service.h index fbe54b0..91e5151 100644 --- a/app/src/services/register_monitor_service.h +++ b/app/src/services/register_monitor_service.h @@ -17,6 +17,7 @@ enum class RegisterMonitorError InvalidAddress, // 起始地址格式或区域不支持 RangeOverflow, // 连续范围超出 M/D 地址上限 LimitExceeded, // 监控地址数量超过会话上限 + PollConfigurationRejected, // 新增后超出 PLC 轮询资源边界 NoChange // 操作没有产生任何列表变化 }; @@ -94,6 +95,8 @@ public: const RegisterAddress &address, std::int16_t value); RegisterMonitorWriteResult writeFloat( const RegisterAddress &address, float value); + RegisterMonitorWriteResult writeNumeric( + const RegisterAddress &address, RegisterDataType data_type, double value); /** * @brief 返回当前会话的监控地址列表 * @return 按服务内部顺序保存的只读地址列表 @@ -101,7 +104,7 @@ public: const std::vector &addresses() const; const std::vector &points() const; std::vector pollAddresses() const; - std::vector float32Starts() const; + std::vector multiWordRanges() const; /** * @brief 读取监控列表的当前值并生成 UI 展示模型 * @param communication_fault 通信处于故障态时,将成功读回值标记为通信故障 @@ -113,21 +116,30 @@ public: * @param callback 地址列表新增、删除或清空后调用;可传空函数取消通知 */ void setAddressesChangedCallback(std::function callback); + void setPollConfigurationValidator( + std::function &, + const std::vector &)> validator); private: RegisterMonitorWriteResult captureInitialBit( const RegisterAddress &address, bool value, const RegisterMonitorWriteResult &active_result); - RegisterMonitorWriteResult captureInitialWord( - const RegisterAddress &address, std::int16_t value, - const RegisterMonitorWriteResult &active_result); - RegisterMonitorWriteResult captureInitialFloat( - const RegisterAddress &address, float value, + RegisterMonitorWriteResult captureInitialWords( + const RegisterAddress &address, + const std::vector &values, const RegisterMonitorWriteResult &active_result); + static std::vector pollAddressesForPoints( + const std::vector &points); + static std::vector multiWordRangesForPoints( + const std::vector &points); RegisterRepository &repository_; // 当前运行模式的寄存器仓库,不由服务拥有 VirtualRegisterRepository *offline_initial_repository_ = nullptr; bool capture_offline_initial_values_ = false; RegisterMonitorModel model_; // 当前会话的去重监控地址列表 std::function addresses_changed_callback_; // 地址变化后的轮询刷新通知 + std::function &, + const std::vector &)> poll_configuration_validator_; }; diff --git a/app/src/services/runtime_mode_service.cpp b/app/src/services/runtime_mode_service.cpp index 9ed8594..a84521e 100644 --- a/app/src/services/runtime_mode_service.cpp +++ b/app/src/services/runtime_mode_service.cpp @@ -282,19 +282,27 @@ PlcCommunicationResult RuntimeModeService::connectPlc( return plc_gateway_->connectDevice(configuration); } -void RuntimeModeService::setMonitorAddresses( +PlcCommunicationResult RuntimeModeService::setMonitorAddresses( const std::vector &addresses) { - setMonitorAddresses(addresses, {}); + return setMonitorAddresses(addresses, {}); } -void RuntimeModeService::setMonitorAddresses( +PlcCommunicationResult RuntimeModeService::setMonitorAddresses( const std::vector &addresses, - const std::vector &float32_starts) + const std::vector &multi_word_ranges) { + const std::vector previous_addresses = monitor_addresses_; + const std::vector previous_ranges = monitor_multi_word_ranges_; monitor_addresses_ = addresses; - monitor_float32_starts_ = float32_starts; - refreshPlcPollAddresses(); + monitor_multi_word_ranges_ = multi_word_ranges; + const PlcCommunicationResult result = refreshPlcPollAddresses(); + if (!result.succeeded) + { + monitor_addresses_ = previous_addresses; + monitor_multi_word_ranges_ = previous_ranges; + } + return result; } PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses() @@ -305,7 +313,7 @@ PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses() } // 轮询集合来自自由监控、HMI、报警和梯形图引用的并集 std::vector addresses = monitor_addresses_; - std::vector float32_starts = monitor_float32_starts_; + std::vector multi_word_ranges = monitor_multi_word_ranges_; const Project &project = project_service_.project(); for (const HmiPage &page : project.hmiPages) { @@ -316,12 +324,18 @@ PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses() addresses.push_back(*control.binding); if ((control.type == HmiControlType::NumericDisplay || control.type == HmiControlType::NumericInput) - && control.dataType == RegisterDataType::Float32 && control.binding->area() == RegisterArea::D) { - addresses.push_back(RegisterAddress{ - RegisterArea::D, control.binding->index() + 1}); - float32_starts.push_back(*control.binding); + const int word_count = registerDataTypeWordCount(control.dataType); + for (int offset = 1; offset < word_count; ++offset) + { + addresses.push_back(RegisterAddress{ + RegisterArea::D, control.binding->index() + offset}); + } + if (word_count > 1) + { + multi_word_ranges.push_back({*control.binding, word_count}); + } } } } @@ -367,28 +381,7 @@ PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses() { return {false, "PLC 轮询的去重 M/D 地址最多为 256 个"}; } - std::sort( - float32_starts.begin(), float32_starts.end(), - [](const RegisterAddress &left, const RegisterAddress &right) - { - return left.area() == right.area() - ? left.index() < right.index() - : left.area() == RegisterArea::M; - }); - float32_starts.erase( - std::unique(float32_starts.begin(), float32_starts.end()), - float32_starts.end()); - float32_starts.erase( - std::remove_if( - float32_starts.begin(), float32_starts.end(), - [](const RegisterAddress &address) - { - return !address.isValid() - || address.area() != RegisterArea::D - || address.index() >= RegisterAddress::kMaximumIndex; - }), - float32_starts.end()); - return plc_gateway_->setPollAddresses(addresses, float32_starts); + return plc_gateway_->setPollAddresses(addresses, multi_word_ranges); } void RuntimeModeService::disconnectPlc() diff --git a/app/src/services/runtime_mode_service.h b/app/src/services/runtime_mode_service.h index aa9eb43..5ffe42e 100644 --- a/app/src/services/runtime_mode_service.h +++ b/app/src/services/runtime_mode_service.h @@ -112,10 +112,11 @@ public: * @brief 设置自由监控引用的地址并刷新 PLC 轮询集合 * @param addresses 自由监控当前需要读取的 M/D 地址 */ - void setMonitorAddresses(const std::vector &addresses); - void setMonitorAddresses( + PlcCommunicationResult setMonitorAddresses( + const std::vector &addresses); + PlcCommunicationResult setMonitorAddresses( const std::vector &addresses, - const std::vector &float32_starts); + const std::vector &multi_word_ranges); /** * @brief 汇总工程和自由监控引用并刷新 PLC 轮询地址 @@ -156,6 +157,6 @@ private: RegisterRepository *plc_repository_ = nullptr; // 真机模式的 PLC 缓存仓库 std::function plc_status_changed_callback_; // PLC 状态变化通知 std::vector monitor_addresses_; // 自由监控额外引用的地址 - std::vector monitor_float32_starts_; // 自由监控中的 Float32 起始地址 + std::vector monitor_multi_word_ranges_; // 自由监控中的多字范围 LogicSyntaxCheckResult last_syntax_check_; // 最近一次运行前梯形图检查结果 }; diff --git a/app/src/ui/free_monitor_widget.cpp b/app/src/ui/free_monitor_widget.cpp index 895eb30..03bb091 100644 --- a/app/src/ui/free_monitor_widget.cpp +++ b/app/src/ui/free_monitor_widget.cpp @@ -5,16 +5,20 @@ #include "ui_free_monitor_widget.h" #include +#include #include #include #include +#include #include #include #include #include #include +#include #include +#include namespace { @@ -28,6 +32,31 @@ QString addressText(const RegisterAddress &address) return QString::fromStdString(address.toString()); } +QString numericValueText(const RegisterNumericValue &value) +{ + return std::visit( + [](const auto &typed_value) + { + using Value = std::decay_t; + if constexpr (std::is_same_v) + { + return QString::number( + static_cast(typed_value), 'g', + std::numeric_limits::max_digits10); + } + else if constexpr (std::is_same_v) + { + return QString::number( + typed_value, 'g', std::numeric_limits::max_digits10); + } + else + { + return QString::number(static_cast(typed_value)); + } + }, + value); +} + } // namespace FreeMonitorWidget::FreeMonitorWidget( @@ -47,7 +76,9 @@ FreeMonitorWidget::FreeMonitorWidget( ui_->monitorTable->horizontalHeader()->setSectionResizeMode(4, QHeaderView::ResizeToContents); ui_->monitorTable->horizontalHeader()->setSectionResizeMode(5, QHeaderView::Stretch); ui_->dataTypeComboBox->setItemData(0, static_cast(RegisterDataType::Int16)); - ui_->dataTypeComboBox->setItemData(1, static_cast(RegisterDataType::Float32)); + ui_->dataTypeComboBox->setItemData(1, static_cast(RegisterDataType::Int32)); + ui_->dataTypeComboBox->setItemData(2, static_cast(RegisterDataType::Float32)); + ui_->dataTypeComboBox->setItemData(3, static_cast(RegisterDataType::Float64)); ui_->monitorTable->verticalHeader()->setVisible(false); connect(ui_->addButton, &QPushButton::clicked, this, &FreeMonitorWidget::addAddresses); connect(ui_->addressEdit, &QLineEdit::returnPressed, @@ -116,14 +147,9 @@ void FreeMonitorWidget::refreshValues( value_item->setText(value.bitValue ? QStringLiteral("ON") : QStringLiteral("OFF")); } - else if (value.dataType == RegisterDataType::Float32) - { - value_item->setText(QString::number( - static_cast(value.floatValue), 'g', 7)); - } else { - value_item->setText(QString::number(value.wordValue)); + value_item->setText(numericValueText(value.numericValue)); } state_item->setText(value.state == MonitorValueState::CommunicationFault ? tr("通信故障,最后有效值") : tr("有效")); @@ -141,16 +167,22 @@ void FreeMonitorWidget::reloadAddresses() const int row = static_cast(index); ui_->monitorTable->setItem(row, 0, new QTableWidgetItem(addressText(address))); const MonitorPoint &point = service_.points().at(index); - ui_->monitorTable->setItem( - row, 1, new QTableWidgetItem(address.area() == RegisterArea::M - ? tr("位") - : point.dataType == RegisterDataType::Float32 - ? tr("Float32 %1 (%1~%2)") - .arg(addressText(address), - addressText(RegisterAddress{ - RegisterArea::D, - point.address.index() + 1})) - : tr("Int16"))); + QString type_text = tr("位"); + if (address.area() == RegisterArea::D) + { + const RegisterDataTypeDescriptor &descriptor = + registerDataTypeDescriptor(point.dataType); + type_text = QString::fromLatin1(descriptor.displayName); + if (descriptor.wordCount > 1) + { + type_text += tr(" (%1~%2)").arg( + addressText(address), + addressText(RegisterAddress{ + RegisterArea::D, + point.address.index() + descriptor.wordCount - 1})); + } + } + ui_->monitorTable->setItem(row, 1, new QTableWidgetItem(type_text)); ui_->monitorTable->setItem(row, 2, new QTableWidgetItem(QStringLiteral("--"))); if (address.area() == RegisterArea::M) { @@ -162,13 +194,29 @@ void FreeMonitorWidget::reloadAddresses() else { auto *target = new QLineEdit(ui_->monitorTable); - if (point.dataType == RegisterDataType::Float32) + if (point.dataType == RegisterDataType::Int16) { - target->setPlaceholderText(tr("小数或科学计数法")); + target->setValidator(new QIntValidator(-32768, 32767, target)); + } + else if (point.dataType == RegisterDataType::Int32) + { + target->setValidator(new QIntValidator( + std::numeric_limits::min(), + std::numeric_limits::max(), target)); } else { - target->setValidator(new QIntValidator(-32768, 32767, target)); + const double maximum = point.dataType == RegisterDataType::Float32 + ? static_cast(std::numeric_limits::max()) + : std::numeric_limits::max(); + const int digits = point.dataType == RegisterDataType::Float32 + ? std::numeric_limits::max_digits10 + : std::numeric_limits::max_digits10; + auto *validator = new QDoubleValidator(-maximum, maximum, digits, target); + validator->setNotation(QDoubleValidator::ScientificNotation); + validator->setLocale(QLocale::c()); + target->setValidator(validator); + target->setPlaceholderText(tr("小数或科学计数法")); } target->setText(QStringLiteral("0")); target->setAlignment(Qt::AlignRight | Qt::AlignVCenter); @@ -215,20 +263,18 @@ void FreeMonitorWidget::writeRow(int row) return; } bool converted = false; - const double value = target->text().toDouble(&converted); - if (!converted || !std::isfinite(value) - || (point.dataType == RegisterDataType::Int16 - && (value < -32768 || value > 32767 || std::trunc(value) != value))) + const double value = QLocale::c().toDouble(target->text(), &converted); + if (!converted + || !encodeRegisterNumericValue(point.dataType, value).has_value()) { ui_->monitorTable->item(row, 5)->setText(tr("目标值无效")); - emit operationMessage(point.dataType == RegisterDataType::Float32 - ? tr("写入 D 区地址失败:请输入有限 Float32 数值") - : tr("写入 D 区地址失败:请输入 -32768~32767 的整数")); + emit operationMessage( + tr("写入 D 区地址失败:请输入有效的 %1 数值") + .arg(QString::fromLatin1( + registerDataTypeDescriptor(point.dataType).displayName))); return; } - result = point.dataType == RegisterDataType::Float32 - ? service_.writeFloat(address, static_cast(value)) - : service_.writeWord(address, static_cast(value)); + result = service_.writeNumeric(address, point.dataType, value); } QTableWidgetItem *state_item = ui_->monitorTable->item(row, 5); diff --git a/app/src/ui/free_monitor_widget.ui b/app/src/ui/free_monitor_widget.ui index ff57067..b4349a0 100644 --- a/app/src/ui/free_monitor_widget.ui +++ b/app/src/ui/free_monitor_widget.ui @@ -16,7 +16,7 @@ 起始地址 90016016777215M0 或 D0true 类型 - Int16Float32 + Int16Int32Float32Double (Float64) 连续个数 1641 添加 diff --git a/app/src/ui/hmi_editor_widget.cpp b/app/src/ui/hmi_editor_widget.cpp index 0ed1172..c980a2b 100644 --- a/app/src/ui/hmi_editor_widget.cpp +++ b/app/src/ui/hmi_editor_widget.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -14,6 +15,8 @@ #include #include #include +#include +#include #include #include #include @@ -25,6 +28,8 @@ #include #include #include +#include +#include namespace { @@ -45,6 +50,83 @@ QString alarmTimeText(const std::chrono::system_clock::time_point &time) return QDateTime::fromSecsSinceEpoch(seconds).toString(QStringLiteral("HH:mm:ss")); } +QString numericValueText(const RegisterNumericValue &value) +{ + return std::visit( + [](const auto &typed_value) + { + using Value = std::decay_t; + if constexpr (std::is_same_v) + { + return QString::number( + static_cast(typed_value), 'g', + std::numeric_limits::max_digits10); + } + else if constexpr (std::is_same_v) + { + return QString::number( + typed_value, 'g', std::numeric_limits::max_digits10); + } + else + { + return QString::number(static_cast(typed_value)); + } + }, + value); +} + +double numericValueAsDouble(const RegisterNumericValue &value) +{ + return std::visit( + [](const auto &typed_value) + { + return static_cast(typed_value); + }, + value); +} + +std::optional requestFloatingPointInput( + QWidget *parent, + const HmiControl &control, + const RegisterNumericValue ¤t_value, + bool has_current_value) +{ + const bool float32 = control.dataType == RegisterDataType::Float32; + const double maximum = float32 + ? static_cast(std::numeric_limits::max()) + : std::numeric_limits::max(); + const int digits = float32 + ? std::numeric_limits::max_digits10 + : std::numeric_limits::max_digits10; + + QInputDialog dialog(parent); + dialog.setInputMode(QInputDialog::TextInput); + dialog.setWindowTitle(QObject::tr("输入 %1").arg( + QString::fromLatin1(registerDataTypeDescriptor(control.dataType).displayName))); + dialog.setLabelText(QString::fromUtf8( + control.text.data(), static_cast(control.text.size()))); + dialog.setTextValue(QString::number( + has_current_value ? numericValueAsDouble(current_value) : 0.0, + 'g', digits)); + QLineEdit *editor = dialog.findChild(); + if (editor != nullptr) + { + auto *validator = new QDoubleValidator(-maximum, maximum, digits, editor); + validator->setNotation(QDoubleValidator::ScientificNotation); + validator->setLocale(QLocale::c()); + editor->setValidator(validator); + editor->selectAll(); + } + if (dialog.exec() != QDialog::Accepted) + { + return std::nullopt; + } + bool converted = false; + const double value = QLocale::c().toDouble(dialog.textValue(), &converted); + return converted && encodeRegisterNumericValue(control.dataType, value).has_value() + ? std::optional{value} : std::nullopt; +} + // 将领域层 HMI 控件投影为可绘制、可选择和可交互的场景图元 class HmiGraphicsItem final : public QGraphicsItem { @@ -413,11 +495,10 @@ public: // 缓存运行服务读出的值并触发 Qt 重绘 void setRuntimeValue( - bool bit_value, std::int16_t word_value, float float_value, bool available) + bool bit_value, const RegisterNumericValue &numeric_value, bool available) { bit_value_ = bit_value; - word_value_ = word_value; - float_value_ = float_value; + numeric_value_ = numeric_value; has_runtime_value_ = available; update(); } @@ -760,9 +841,7 @@ private: if (control_.type == HmiControlType::NumericDisplay || control_.type == HmiControlType::NumericInput) { - const QString value = control_.dataType == RegisterDataType::Float32 - ? QString::number(float_value_, 'g', 7) - : QString::number(word_value_); + const QString value = numericValueText(numeric_value_); return text + QStringLiteral(": ") + value; } return text; @@ -791,9 +870,8 @@ private: bool page_pressed_ = false; // 指示灯读取到的 M 位值 bool bit_value_ = false; - // 数值控件读取到的 D 字值 - std::int16_t word_value_ = 0; - float float_value_ = 0.0f; + // 数值控件读取到的类型化 D 值 + RegisterNumericValue numeric_value_ = std::int16_t{0}; // 标记当前缓存值是否来自一次成功的运行时读取 bool has_runtime_value_ = false; }; @@ -856,7 +934,7 @@ void HmiEditorWidget::setRuntimeActive(bool active) HmiGraphicsItem *control_item = asHmiItem(item); if (control_item != nullptr) { - control_item->setRuntimeValue(false, 0, 0.0f, false); + control_item->setRuntimeValue(false, std::int16_t{0}, false); control_item->setAlarmRecords({}); } } @@ -1007,7 +1085,7 @@ void HmiEditorWidget::refreshRuntimeValues() // 运行值通过服务读取,图元不直接接触寄存器仓库 const HmiRuntimeReadResult value = runtime_service_.readControl(*control); control_item->setRuntimeValue( - value.bit_value, value.word_value, value.float_value, value.succeeded); + value.bit_value, value.numeric_value, value.succeeded); } } @@ -1108,41 +1186,45 @@ void HmiEditorWidget::handleNumericInputActivated(const std::string &control_id) { return; } - bool accepted = false; const HmiRuntimeReadResult current = runtime_service_.readControl(*control); - double value = 0.0; - if (control->dataType == RegisterDataType::Float32) + std::optional value; + if (registerDataTypeDescriptor(control->dataType).floatingPoint) { - value = QInputDialog::getDouble( - this, - tr("输入 Float32"), - QString::fromUtf8( - control->text.data(), static_cast(control->text.size())), - current.succeeded ? static_cast(current.float_value) : 0.0, - -static_cast(std::numeric_limits::max()), - static_cast(std::numeric_limits::max()), - 7, - &accepted); + value = requestFloatingPointInput( + this, *control, current.numeric_value, current.succeeded); } else { - value = QInputDialog::getInt( + bool accepted = false; + const int minimum = control->dataType == RegisterDataType::Int16 + ? std::numeric_limits::min() + : std::numeric_limits::min(); + const int maximum = control->dataType == RegisterDataType::Int16 + ? std::numeric_limits::max() + : std::numeric_limits::max(); + const int input = QInputDialog::getInt( this, - tr("输入 Int16"), + tr("输入 %1").arg(QString::fromLatin1( + registerDataTypeDescriptor(control->dataType).displayName)), QString::fromUtf8( control->text.data(), static_cast(control->text.size())), - current.succeeded ? current.word_value : 0, - std::numeric_limits::min(), - std::numeric_limits::max(), + current.succeeded + ? static_cast(numericValueAsDouble(current.numeric_value)) : 0, + minimum, + maximum, 1, &accepted); + if (accepted) + { + value = input; + } } - if (!accepted) + if (!value.has_value()) { return; } const HmiRuntimeWriteResult result = runtime_service_.writeNumericInput( - *control, value); + *control, *value); if (!result.succeeded) { emit editorError(tr("寄存器操作失败")); diff --git a/app/src/ui/main_window.cpp b/app/src/ui/main_window.cpp index 816b060..94ac4b6 100644 --- a/app/src/ui/main_window.cpp +++ b/app/src/ui/main_window.cpp @@ -1136,7 +1136,7 @@ void MainWindow::configureDataMonitor() { runtime_mode_service_.setMonitorAddresses( register_monitor_service_.pollAddresses(), - register_monitor_service_.float32Starts()); + register_monitor_service_.multiWordRanges()); refreshDataMonitorUi(); }); connect(data_monitor_widget_, &FreeMonitorWidget::operationMessage, @@ -1160,7 +1160,15 @@ void MainWindow::configureDataMonitor() } runtime_mode_service_.setMonitorAddresses( register_monitor_service_.pollAddresses(), - register_monitor_service_.float32Starts()); + register_monitor_service_.multiWordRanges()); + }); + register_monitor_service_.setPollConfigurationValidator( + [this](const std::vector &addresses, + const std::vector &ranges) + { + const PlcCommunicationResult result = + runtime_mode_service_.setMonitorAddresses(addresses, ranges); + return result.succeeded ? std::string{} : result.message; }); data_monitor_refresh_timer_ = new QTimer(this); data_monitor_refresh_timer_->setInterval(150); diff --git a/app/src/ui/main_window.ui b/app/src/ui/main_window.ui index 460a29d..9a7f751 100644 --- a/app/src/ui/main_window.ui +++ b/app/src/ui/main_window.ui @@ -778,10 +778,12 @@ - - Int16 - Float32 - + + Int16 + Int32 + Float32 + Double (Float64) + diff --git a/app/src/ui/property_panel_controller.cpp b/app/src/ui/property_panel_controller.cpp index 4531a2e..1bcf7a3 100644 --- a/app/src/ui/property_panel_controller.cpp +++ b/app/src/ui/property_panel_controller.cpp @@ -111,7 +111,11 @@ void PropertyPanelController::configure() ui_.dataTypeComboBox->setItemData( 0, static_cast(RegisterDataType::Int16)); ui_.dataTypeComboBox->setItemData( - 1, static_cast(RegisterDataType::Float32)); + 1, static_cast(RegisterDataType::Int32)); + ui_.dataTypeComboBox->setItemData( + 2, static_cast(RegisterDataType::Float32)); + ui_.dataTypeComboBox->setItemData( + 3, static_cast(RegisterDataType::Float64)); for (int index = 0; index < ui_.logicComparisonComboBox->count(); ++index) { ui_.logicComparisonComboBox->setItemData(index, index); diff --git a/app/src/ui/runtime_panel_controller.cpp b/app/src/ui/runtime_panel_controller.cpp index 47d524b..42f7b43 100644 --- a/app/src/ui/runtime_panel_controller.cpp +++ b/app/src/ui/runtime_panel_controller.cpp @@ -104,7 +104,7 @@ void RuntimePanelController::configure() { runtime_mode_service_.setMonitorAddresses( register_monitor_service_.pollAddresses(), - register_monitor_service_.float32Starts()); + register_monitor_service_.multiWordRanges()); runtime_monitor_widget_->refreshValues( runtime_mode_service_.mode(), runtime_mode_service_.plcConnectionState()); diff --git a/app/tests/domain_tests.cpp b/app/tests/domain_tests.cpp index db1e253..61b8ea4 100644 --- a/app/tests/domain_tests.cpp +++ b/app/tests/domain_tests.cpp @@ -78,8 +78,23 @@ void testRegisterRepositorySeparatesAreas() "M address must not be read as a word"); } -void testFloat32CodecAndPairAccess() +void testMultiWordCodecsAndBlockAccess() { + const std::array int32_values = { + std::numeric_limits::min(), -1, 0, + std::numeric_limits::max()}; + for (const std::int32_t value : int32_values) + { + const std::array words = Int32Codec::encode(value); + require(Int32Codec::decode(words[0], words[1]) == value, + "Int32 codec must preserve signed boundary values"); + } + const std::array int32_words = + Int32Codec::encode(0x12345678); + require(static_cast(int32_words[0]) == 0x5678U + && static_cast(int32_words[1]) == 0x1234U, + "Int32 codec must store the low 16-bit word at the lower D address"); + const std::array values = {1.0f, -2.5f, 0.1f, 0.0f}; for (const float value : values) { @@ -96,6 +111,51 @@ void testFloat32CodecAndPairAccess() static_cast(0), static_cast(0x7fc0)).has_value(), "Float32 codec must reject NaN"); + const std::array float20 = Float32Codec::encode(20.0f); + require(static_cast(float20[0]) == 0x0000U + && static_cast(float20[1]) == 0x41a0U, + "Float32 20.0 must match the Xinje low-word-first example"); + + const std::array double_values = {1.0, -2.5, 0.1, 0.0}; + for (const double value : double_values) + { + const std::array words = Float64Codec::encode(value); + const std::optional decoded = Float64Codec::decode(words); + require(decoded.has_value() && *decoded == value, + "Double codec must preserve representative finite values"); + } + const std::array double_one = Float64Codec::encode(1.0); + require(static_cast(double_one[0]) == 0x0000U + && static_cast(double_one[1]) == 0x0000U + && static_cast(double_one[2]) == 0x0000U + && static_cast(double_one[3]) == 0x3ff0U, + "Double 1.0 must occupy four low-address-first D words"); + require(!Float64Codec::decode({ + static_cast(0), static_cast(0), + static_cast(0), static_cast(0x7ff0)}) + .has_value() + && !Float64Codec::decode({ + static_cast(0), static_cast(0), + static_cast(0), static_cast(0x7ff8)}) + .has_value(), + "Double codec must reject infinity and NaN bit patterns"); + require(encodeRegisterNumericValue( + RegisterDataType::Int32, + static_cast(std::numeric_limits::min())) + .has_value() + && encodeRegisterNumericValue( + RegisterDataType::Int32, + static_cast(std::numeric_limits::max())) + .has_value() + && !encodeRegisterNumericValue( + RegisterDataType::Int32, 1.5).has_value() + && !encodeRegisterNumericValue( + RegisterDataType::Float64, + std::numeric_limits::infinity()).has_value() + && !encodeRegisterNumericValue( + RegisterDataType::Float64, + std::numeric_limits::quiet_NaN()).has_value(), + "typed encoding must enforce Int32 integrality and finite Double values"); VirtualRegisterRepository repository; const RegisterAddress d10{RegisterArea::D, 10}; @@ -107,6 +167,33 @@ void testFloat32CodecAndPairAccess() "virtual repository must read Float32 from two consecutive words"); require(!repository.writeWordPair({RegisterArea::D, 4000}, {}).succeeded, "Float32 write at D4000 must be rejected"); + require(repository.writeWords( + {RegisterArea::D, 30}, + {double_one[0], double_one[1], double_one[2], double_one[3]}) + .succeeded + && repository.readWords({RegisterArea::D, 30}, 4).values + == std::vector( + double_one.cbegin(), double_one.cend()), + "virtual repository must read and write one complete four-word block"); + repository.writeWord({RegisterArea::D, 4000}, 77); + require(!repository.writeWords( + {RegisterArea::D, 4000}, {1, 2}).succeeded + && repository.readWord({RegisterArea::D, 4000}).value == 77, + "an overflowing block write must fail before changing any D word"); + + RegisterDataType parsed = RegisterDataType::Int16; + require(parseRegisterDataType("int32", &parsed) + && parsed == RegisterDataType::Int32 + && parseRegisterDataType("float64", &parsed) + && parsed == RegisterDataType::Float64 + && registerDataTypeWordCount(RegisterDataType::Float64) == 4, + "register data type descriptors must expose Int32 and Float64 names"); + const RegisterDataType invalid_type = static_cast(99); + require(!registerDataTypeIsSupported(invalid_type) + && registerDataTypeWordCount(invalid_type) == 0 + && !registerDataTypeAddressIsValid( + invalid_type, {RegisterArea::D, 0}), + "unknown register data types must not borrow Int16 rules"); } void testHmiControlRegistryCompleteness() @@ -197,7 +284,7 @@ void testHmiControlRegistryCompleteness() Project makeValidProject(); -void testFloat32HmiBoundaries() +void testMultiWordHmiBoundaries() { Project project = makeValidProject(); HmiControl display; @@ -214,6 +301,40 @@ void testFloat32HmiBoundaries() RegisterAddress{RegisterArea::D, 4000}; require(!project.validate(), "Float32 D4000 must be rejected"); + project = makeValidProject(); + HmiControl int32_display = display; + int32_display.id = "int32-display"; + int32_display.dataType = RegisterDataType::Int32; + int32_display.binding = RegisterAddress{RegisterArea::D, 3999}; + project.hmiPages.front().controls.push_back(int32_display); + require(project.validate(), "Int32 D3999 must be valid"); + project.hmiPages.front().controls.back().binding = + RegisterAddress{RegisterArea::D, 4000}; + require(!project.validate(), "Int32 D4000 must be rejected"); + + project = makeValidProject(); + HmiControl double_display = display; + double_display.id = "double-display"; + double_display.dataType = RegisterDataType::Float64; + double_display.binding = RegisterAddress{RegisterArea::D, 3996}; + project.hmiPages.front().controls.push_back(double_display); + require(project.validate(), "Double D3996 must be a valid even start address"); + project.hmiPages.front().controls.back().binding = + RegisterAddress{RegisterArea::D, 3997}; + require(!project.validate(), "Double D3997 must be rejected"); + project.hmiPages.front().controls.back().binding = + RegisterAddress{RegisterArea::D, 3995}; + require(!project.validate(), "Double odd start addresses must be rejected"); + + project = makeValidProject(); + HmiControl invalid_display = display; + invalid_display.id = "invalid-type-display"; + invalid_display.binding.reset(); + invalid_display.dataType = static_cast(99); + project.hmiPages.front().controls.push_back(invalid_display); + require(!project.validate(), + "an unbound HMI draft must still reject an unknown numeric type"); + project = makeValidProject(); HmiControl first = display; first.binding = RegisterAddress{RegisterArea::D, 10}; @@ -229,21 +350,31 @@ void testFloat32HmiBoundaries() require(!project.validate(), "different HMI data types may not partially overlap"); - project = makeValidProject(); - HmiControl configured_float = display; - configured_float.binding = RegisterAddress{RegisterArea::D, 10}; - project.hmiPages.front().controls.push_back(configured_float); - project.controlLogics.front().rungs.front().output = LogicNode{ - "move-output", - MoveNodeConfig{ - WordOperand{ - WordOperandKind::Constant, - RegisterAddress{RegisterArea::D, 0}, - 1}, - RegisterAddress{RegisterArea::D, 11}}, - true}; - require(!project.validate(), - "ordinary integer instructions must not write inside Float32 range"); + for (const RegisterDataType type : { + RegisterDataType::Int32, + RegisterDataType::Float32, + RegisterDataType::Float64}) + { + project = makeValidProject(); + HmiControl configured_multi_word = display; + configured_multi_word.id = "protected-multi-word"; + configured_multi_word.dataType = type; + configured_multi_word.binding = RegisterAddress{RegisterArea::D, 10}; + project.hmiPages.front().controls.push_back(configured_multi_word); + project.controlLogics.front().rungs.front().output = LogicNode{ + "move-output", + MoveNodeConfig{ + WordOperand{ + WordOperandKind::Constant, + RegisterAddress{RegisterArea::D, 0}, + 1}, + RegisterAddress{ + RegisterArea::D, + 10 + registerDataTypeWordCount(type) - 1}}, + true}; + require(!project.validate(), + "16-bit instructions must not write inside any multi-word HMI range"); + } } void testHmiAppearancePropertyBoundaries() @@ -826,9 +957,9 @@ int main() testRegisterAddressBoundaries(); testRegisterAddressParsing(); testRegisterRepositorySeparatesAreas(); - testFloat32CodecAndPairAccess(); + testMultiWordCodecsAndBlockAccess(); testHmiControlRegistryCompleteness(); - testFloat32HmiBoundaries(); + testMultiWordHmiBoundaries(); testHmiAppearancePropertyBoundaries(); testLogicNodeConfigurationBoundaries(); testEdgeAndCommentBoundaries(); diff --git a/app/tests/hmi_editor_service_tests.cpp b/app/tests/hmi_editor_service_tests.cpp index f1348b2..f5a8165 100644 --- a/app/tests/hmi_editor_service_tests.cpp +++ b/app/tests/hmi_editor_service_tests.cpp @@ -13,6 +13,7 @@ #include #include #include +#include namespace { @@ -130,7 +131,8 @@ void testRuntimeUsesRegisterRepository() numeric_display.type = HmiControlType::NumericDisplay; numeric_display.binding = RegisterAddress{RegisterArea::D, 9}; const HmiRuntimeReadResult numeric_value = runtime_service.readControl(numeric_display); - require(numeric_value.succeeded && numeric_value.word_value == -18, + require(numeric_value.succeeded + && std::get(numeric_value.numeric_value) == -18, "numeric display must read D values through the repository"); HmiControl float_input = numeric_input; @@ -139,12 +141,50 @@ void testRuntimeUsesRegisterRepository() require(runtime_service.writeNumericInput(float_input, -2.5).succeeded, "Float32 numeric input must write two consecutive D words"); const HmiRuntimeReadResult float_value = runtime_service.readControl(float_input); - require(float_value.succeeded && float_value.float_value == -2.5f, + require(float_value.succeeded + && std::get(float_value.numeric_value) == -2.5f, "Float32 numeric control must decode two consecutive D words"); require(!runtime_service.writeNumericInput( float_input, std::numeric_limits::infinity()).succeeded, "Float32 numeric input must reject infinity"); + HmiControl int32_input = numeric_input; + int32_input.dataType = RegisterDataType::Int32; + int32_input.binding = RegisterAddress{RegisterArea::D, 30}; + require(runtime_service.writeNumericInput(int32_input, 305419896).succeeded, + "Int32 numeric input must write two consecutive D words"); + const WordsReadResult int32_words = repository.readWords(*int32_input.binding, 2); + const HmiRuntimeReadResult int32_value = runtime_service.readControl(int32_input); + require(int32_words.succeeded + && static_cast(int32_words.values[0]) == 0x5678U + && static_cast(int32_words.values[1]) == 0x1234U + && int32_value.succeeded + && std::get(int32_value.numeric_value) == 0x12345678, + "Int32 HMI read/write must use low-word-first Xinje ordering"); + + HmiControl double_input = numeric_input; + double_input.dataType = RegisterDataType::Float64; + double_input.binding = RegisterAddress{RegisterArea::D, 40}; + require(runtime_service.writeNumericInput(double_input, 1.0).succeeded, + "Double numeric input must write four consecutive D words"); + const WordsReadResult double_words = repository.readWords(*double_input.binding, 4); + const HmiRuntimeReadResult double_value = runtime_service.readControl(double_input); + require(double_words.succeeded + && static_cast(double_words.values[0]) == 0x0000U + && static_cast(double_words.values[1]) == 0x0000U + && static_cast(double_words.values[2]) == 0x0000U + && static_cast(double_words.values[3]) == 0x3ff0U + && double_value.succeeded + && std::get(double_value.numeric_value) == 1.0, + "Double HMI read/write must use four low-address-first words"); + require(!runtime_service.writeNumericInput( + double_input, std::numeric_limits::quiet_NaN()).succeeded, + "Double numeric input must reject NaN"); + double_input.binding = RegisterAddress{RegisterArea::D, 3997}; + require(runtime_service.writeNumericInput(double_input, 1.0).error + == HmiRuntimeError::InvalidBinding, + "Double numeric input must reject odd or overflowing start addresses"); + } void testHistoryAndAtomicBatchDelete() @@ -207,7 +247,8 @@ void testBatchPasteControls() ProjectService project_service(storage); HmiEditorService service(project_service); const std::string page_id = service.ensureDefaultPage().id; - const HmiEditorResult first = service.addControl(page_id, HmiControlType::Label); + const HmiEditorResult first = service.addControl( + page_id, HmiControlType::NumericDisplay); const HmiEditorResult second = service.addControl(page_id, HmiControlType::Label); require(first.succeeded && second.succeeded, "controls for paste testing must be created"); @@ -215,7 +256,10 @@ void testBatchPasteControls() HmiControl first_copy = *service.findControl(page_id, first.id); HmiControl second_copy = *service.findControl(page_id, second.id); first_copy.bounds = {100, 80, 80, 32}; - first_copy.text = "复制标签"; + first_copy.type = HmiControlType::NumericDisplay; + first_copy.text = "复制 Double"; + first_copy.binding = RegisterAddress{RegisterArea::D, 100}; + first_copy.dataType = RegisterDataType::Float64; first_copy.properties[HmiAppearanceProperty::kTextColor] = "#E53935"; second_copy.bounds = {220, 80, 80, 32}; require(service.updateControl(page_id, first.id, first_copy).succeeded @@ -234,8 +278,10 @@ void testBatchPasteControls() && pasted_control->bounds.x == first_copy.bounds.x + 20 && pasted_control->bounds.y == first_copy.bounds.y + 20 && pasted_control->text == first_copy.text + && pasted_control->binding == first_copy.binding + && pasted_control->dataType == RegisterDataType::Float64 && pasted_control->properties == first_copy.properties, - "pasted controls must preserve content and appearance with an offset"); + "pasted controls must preserve data type, binding and appearance with an offset"); require(service.undo().succeeded && service.findPage(page_id)->controls.size() == 2U, "batch control paste must be one undoable operation"); diff --git a/app/tests/offline_simulation_service_tests.cpp b/app/tests/offline_simulation_service_tests.cpp index 1fc7b18..669b4a9 100644 --- a/app/tests/offline_simulation_service_tests.cpp +++ b/app/tests/offline_simulation_service_tests.cpp @@ -770,18 +770,44 @@ void testSimulationUsesInitialValuesAndDiscardsRuntimeOutputs() writeBit(initial_repository, 0, true); writeWord(initial_repository, 10, 321); + const std::array initial_int32 = + Int32Codec::encode(0x12345678); + const std::array initial_double = + Float64Codec::encode(1.25); + initial_repository.writeWords( + {RegisterArea::D, 20}, {initial_int32[0], initial_int32[1]}); + initial_repository.writeWords( + {RegisterArea::D, 30}, + {initial_double[0], initial_double[1], + initial_double[2], initial_double[3]}); require(simulation.start({program}).succeeded, "simulation must start from the explicit initial repository"); require(readBit(repository, 0) - && readWord(repository, 10) == 321, - "simulation startup must copy initial M/D values into its runtime repository"); + && readWord(repository, 10) == 321 + && repository.readWords({RegisterArea::D, 20}, 2).values + == std::vector{ + initial_int32[0], initial_int32[1]} + && repository.readWords({RegisterArea::D, 30}, 4).values + == std::vector{ + initial_double[0], initial_double[1], + initial_double[2], initial_double[3]}, + "simulation startup must copy single-word and multi-word initial values"); require(simulation.executeOnce().succeeded && readBit(repository, 1), "simulation must execute against the copied initial values"); + repository.writeWords({RegisterArea::D, 20}, {0, 0}); + repository.writeWords({RegisterArea::D, 30}, {0, 0, 0, 0}); simulation.stop(); require(!readBit(repository, 1) && readBit(repository, 0) - && readWord(repository, 10) == 321, - "stopping simulation must restore initial values instead of runtime outputs"); + && readWord(repository, 10) == 321 + && repository.readWords({RegisterArea::D, 20}, 2).values + == std::vector{ + initial_int32[0], initial_int32[1]} + && repository.readWords({RegisterArea::D, 30}, 4).values + == std::vector{ + initial_double[0], initial_double[1], + initial_double[2], initial_double[3]}, + "stopping simulation must restore complete multi-word initial values"); writeBit(initial_repository, 0, false); require(simulation.start({program}).succeeded, diff --git a/app/tests/plc_runtime_tests.cpp b/app/tests/plc_runtime_tests.cpp index dadd74f..5f5cb68 100644 --- a/app/tests/plc_runtime_tests.cpp +++ b/app/tests/plc_runtime_tests.cpp @@ -150,6 +150,9 @@ void testPlcCacheAndWriteForwarding() RegisterAddress written_address{RegisterArea::M, 1}; bool written_bit = false; std::int16_t written_word = 0; + std::vector written_words; + int single_word_write_count = 0; + int multi_word_write_count = 0; repository.setWriteHandlers( [&](const RegisterAddress &address, bool value) { @@ -161,14 +164,52 @@ void testPlcCacheAndWriteForwarding() { written_address = address; written_word = value; + ++single_word_write_count; + return RegisterWriteResult{true, RegisterError::None}; + }, + [&](const RegisterAddress &address, + const std::vector &values) + { + written_address = address; + written_words = values; + ++multi_word_write_count; return RegisterWriteResult{true, RegisterError::None}; }); require(repository.writeBit(m0, false).succeeded && written_address == m0 && !written_bit, "PLC bit writes must be forwarded without changing the cache"); require(repository.writeWord(d0, 456).succeeded - && written_address == d0 && written_word == 456, + && written_address == d0 && written_word == 456 + && single_word_write_count == 1, "PLC word writes must be forwarded without changing the cache"); + require(repository.writeWords(d0, {789}).succeeded + && written_word == 789 + && single_word_write_count == 2 + && multi_word_write_count == 0, + "one-word generic writes must keep using the PLC single-word request"); + const RegisterAddress d10{RegisterArea::D, 10}; + require(repository.writeWords( + d10, + {static_cast(0x5678), + static_cast(0x1234)}).succeeded + && written_address == d10 + && written_words.size() == 2U + && static_cast(written_words[0]) == 0x5678U + && static_cast(written_words[1]) == 0x1234U + && multi_word_write_count == 1, + "Int32 words must be forwarded in one low-word-first request"); + const RegisterAddress d20{RegisterArea::D, 20}; + require(repository.writeWords( + d20, + {static_cast(0x0000), + static_cast(0x0000), + static_cast(0x0000), + static_cast(0x3ff0)}).succeeded + && written_address == d20 + && written_words.size() == 4U + && static_cast(written_words[3]) == 0x3ff0U + && multi_word_write_count == 2, + "Double words must be forwarded in one four-register request"); repository.invalidate(); require(!repository.hasAnyValidValue(), "disconnecting must invalidate all PLC cache validity flags"); @@ -453,6 +494,31 @@ void testPlcPollQuantityBoundaries() } require(service.setPollAddresses(maximum_contiguous_block).succeeded, "a contiguous Modbus read block of 120 values must be accepted"); + + std::vector double_boundary_addresses; + for (int index = 0; index <= 123; ++index) + { + double_boundary_addresses.push_back({RegisterArea::D, index}); + } + require(service.setPollAddresses( + double_boundary_addresses, + {{RegisterAddress{RegisterArea::D, 118}, 4}}).succeeded + && service.pollBlocks().size() == 2U + && service.pollBlocks()[0].area == RegisterArea::D + && service.pollBlocks()[0].startAddress == 0 + && service.pollBlocks()[0].count == 118 + && service.pollBlocks()[1].startAddress == 118 + && service.pollBlocks()[1].count == 6, + "a Double at the 120-word boundary must stay in one Modbus read block"); + + std::vector unsplittable_ranges; + for (int start = 0; start <= 120; start += 3) + { + unsplittable_ranges.push_back({ + RegisterAddress{RegisterArea::D, start}, 4}); + } + require(!service.setPollAddresses({}, unsplittable_ranges).succeeded, + "an overlapping multi-word chain without a 120-word split point must fail"); } } // namespace diff --git a/app/tests/project_management_tests.cpp b/app/tests/project_management_tests.cpp index 49c9ba2..842c95f 100644 --- a/app/tests/project_management_tests.cpp +++ b/app/tests/project_management_tests.cpp @@ -250,6 +250,69 @@ void testMOffAlarmRoundTrip() "the M OFF condition and address must survive JSON round trip"); } +void testMultiWordHmiDataTypeRoundTrip() +{ + QTemporaryDir directory; + require(directory.isValid(), "temporary directory must be valid"); + JsonProjectStorage storage; + const QString path = directory.filePath("multi-word-hmi.json"); + Project project = makeExampleProject(); + + HmiControl int32_display; + int32_display.id = "int32-display"; + int32_display.type = HmiControlType::NumericDisplay; + int32_display.bounds = {20, 80, 120, 40}; + int32_display.text = "Int32"; + int32_display.binding = RegisterAddress{RegisterArea::D, 100}; + int32_display.dataType = RegisterDataType::Int32; + project.hmiPages.front().controls.push_back(int32_display); + + HmiControl double_input = int32_display; + double_input.id = "double-input"; + double_input.type = HmiControlType::NumericInput; + double_input.bounds = {160, 80, 120, 40}; + double_input.text = "Double"; + double_input.binding = RegisterAddress{RegisterArea::D, 200}; + double_input.dataType = RegisterDataType::Float64; + project.hmiPages.front().controls.push_back(double_input); + + require(storage.save(project, path.toStdString()).succeeded, + "Int32 and Double HMI controls must save in a 2.0 project"); + const QByteArray json = readBytes(path); + require(json.contains("\"dataType\": \"int32\"") + && json.contains("\"dataType\": \"float64\"") + && json.contains("\"formatVersion\": \"2.0\""), + "multi-word HMI types must use stable JSON names without a version bump"); + + const ProjectLoadResult loaded = storage.load(path.toStdString()); + require(loaded.succeeded + && loaded.project.hmiPages.front().controls[1].dataType + == RegisterDataType::Int32 + && loaded.project.hmiPages.front().controls[1].binding + == RegisterAddress{RegisterArea::D, 100} + && loaded.project.hmiPages.front().controls[2].dataType + == RegisterDataType::Float64 + && loaded.project.hmiPages.front().controls[2].binding + == RegisterAddress{RegisterArea::D, 200}, + "Int32 and Double HMI types and bindings must survive JSON round trip"); + + QJsonObject root = QJsonDocument::fromJson(json).object(); + QJsonArray pages = root.value(QStringLiteral("hmiPages")).toArray(); + QJsonObject page = pages.at(0).toObject(); + QJsonArray controls = page.value(QStringLiteral("controls")).toArray(); + QJsonObject invalid_control = controls.at(1).toObject(); + invalid_control.insert(QStringLiteral("dataType"), QStringLiteral("int64")); + controls.replace(1, invalid_control); + page.insert(QStringLiteral("controls"), controls); + pages.replace(0, page); + root.insert(QStringLiteral("hmiPages"), pages); + const QString invalid_path = directory.filePath("invalid-data-type.json"); + writeBytes(invalid_path, QJsonDocument(root).toJson()); + require(storage.load(invalid_path.toStdString()).error + == ProjectStorageError::InvalidField, + "unknown HMI data types must be rejected by strict JSON loading"); +} + void testStrictVersionAndRequiredFields() { QTemporaryDir directory; @@ -420,6 +483,7 @@ int main() testEmptyProjectRoundTrip(); testGridProjectRoundTrip(); testMOffAlarmRoundTrip(); + testMultiWordHmiDataTypeRoundTrip(); testStrictVersionAndRequiredFields(); testInvalidGridAndConnectionsAreRejected(); testProjectServiceStateAndConfiguredLimits(); diff --git a/app/tests/register_monitor_service_tests.cpp b/app/tests/register_monitor_service_tests.cpp index e721e7c..6ac0d7f 100644 --- a/app/tests/register_monitor_service_tests.cpp +++ b/app/tests/register_monitor_service_tests.cpp @@ -10,6 +10,7 @@ #include #include #include +#include namespace { @@ -59,14 +60,16 @@ void testSharedActiveRepositoryValues() virtual_repository.writeBit({RegisterArea::M, 0}, true); virtual_repository.writeWord({RegisterArea::D, 0}, -123); std::vector values = service.values(false); - require(values.size() == 2U && values[0].bitValue && values[1].wordValue == -123, + require(values.size() == 2U && values[0].bitValue + && std::get(values[1].numericValue) == -123, "monitor values must reflect the active virtual repository"); plc_repository.writeBit({RegisterArea::M, 0}, false); plc_repository.writeWord({RegisterArea::D, 0}, 456); active_repository.use(plc_repository); values = service.values(false); - require(!values[0].bitValue && values[1].wordValue == 456, + require(!values[0].bitValue + && std::get(values[1].numericValue) == 456, "monitor values must follow the active repository switch"); values = service.values(true); require(values[0].state == MonitorValueState::CommunicationFault @@ -114,8 +117,18 @@ void testOfflineInitialValueCapture() require(service.writeBit({RegisterArea::M, 3}, true).succeeded && service.writeWord({RegisterArea::D, 10}, -456).succeeded - && service.writeFloat({RegisterArea::D, 20}, 1.5f).succeeded, + && service.writeFloat({RegisterArea::D, 20}, 1.5f).succeeded + && service.writeNumeric( + {RegisterArea::D, 30}, RegisterDataType::Int32, 305419896) + .succeeded + && service.writeNumeric( + {RegisterArea::D, 40}, RegisterDataType::Float64, 1.0) + .succeeded, "editing monitor writes must succeed against the virtual repository"); + const WordsReadResult captured_int32 = initial_repository.readWords( + {RegisterArea::D, 30}, 2); + const WordsReadResult captured_double = initial_repository.readWords( + {RegisterArea::D, 40}, 4); require(initial_repository.readBit({RegisterArea::M, 3}).value && initial_repository.readWord({RegisterArea::D, 10}).value == -456 && std::fabs(Float32Codec::decode( @@ -123,6 +136,15 @@ void testOfflineInitialValueCapture() initial_repository.readWordPair({RegisterArea::D, 20}).values[1]) .value_or(0.0f) - 1.5f) < 0.000001f, "editing monitor writes must be captured as offline initial values"); + require(captured_int32.succeeded + && static_cast(captured_int32.values[0]) == 0x5678U + && static_cast(captured_int32.values[1]) == 0x1234U + && captured_double.succeeded + && static_cast(captured_double.values[0]) == 0x0000U + && static_cast(captured_double.values[1]) == 0x0000U + && static_cast(captured_double.values[2]) == 0x0000U + && static_cast(captured_double.values[3]) == 0x3ff0U, + "offline initial capture must keep all Int32 and Double words"); service.setOfflineInitialCaptureEnabled(false); require(service.writeBit({RegisterArea::M, 3}, false).succeeded @@ -130,7 +152,7 @@ void testOfflineInitialValueCapture() "runtime writes must not overwrite the captured initial values"); } -void testFloat32Monitoring() +void testMultiWordMonitoring() { VirtualRegisterRepository repository; RegisterMonitorService service(repository); @@ -142,7 +164,8 @@ void testFloat32Monitoring() && service.points()[1].address == RegisterAddress{RegisterArea::D, 3} && service.points()[2].address == RegisterAddress{RegisterArea::D, 5}, "Float32 monitor batches must advance by two D words"); - require(service.float32Starts().size() == 3U + require(service.multiWordRanges().size() == 3U + && service.multiWordRanges().front().wordCount == 2 && service.pollAddresses().size() == 6U, "Float32 monitor points must expose both words for polling"); @@ -161,12 +184,68 @@ void testFloat32Monitoring() require(service.writeFloat({RegisterArea::D, 1}, -2.5f).succeeded, "Float32 monitor writes must use the pair write path"); - const std::vector values = service.values(false); + std::vector values = service.values(false); require(values.front().state == MonitorValueState::Valid - && std::fabs(values.front().floatValue - (-2.5f)) < 0.000001f, + && std::fabs( + std::get(values.front().numericValue) - (-2.5f)) + < 0.000001f, "Float32 monitor values must decode the pair correctly"); require(!service.writeFloat({RegisterArea::D, 4000}, 1.0f).succeeded, "Float32 monitor writes at D4000 must be rejected"); + + const RegisterMonitorResult int32_added = service.addRange( + "D10", 2, RegisterDataType::Int32); + const RegisterMonitorResult double_added = service.addRange( + "D20", 2, RegisterDataType::Float64); + require(int32_added.succeeded && double_added.succeeded + && service.points()[3].address == RegisterAddress{RegisterArea::D, 10} + && service.points()[4].address == RegisterAddress{RegisterArea::D, 12} + && service.points()[5].address == RegisterAddress{RegisterArea::D, 20} + && service.points()[6].address == RegisterAddress{RegisterArea::D, 24} + && service.pollAddresses().size() == 18U + && service.multiWordRanges().size() == 7U + && service.multiWordRanges()[5].wordCount == 4, + "Int32 and Double monitor batches must advance by their word counts"); + require(service.writeNumeric( + {RegisterArea::D, 10}, RegisterDataType::Int32, 305419896) + .succeeded + && service.writeNumeric( + {RegisterArea::D, 20}, RegisterDataType::Float64, 1.25) + .succeeded, + "Int32 and Double monitor writes must use continuous word blocks"); + values = service.values(false); + require(std::get(values[3].numericValue) == 0x12345678 + && std::get(values[5].numericValue) == 1.25, + "Int32 and Double monitor values must decode to their exact types"); + + RegisterMonitorService boundary_service(repository); + require(boundary_service.addRange( + "D3999", 1, RegisterDataType::Int32).succeeded + && !boundary_service.addRange( + "D4000", 1, RegisterDataType::Int32).succeeded + && boundary_service.addRange( + "D3996", 1, RegisterDataType::Float64).succeeded + && !boundary_service.addRange( + "D3997", 1, RegisterDataType::Float64).succeeded + && !boundary_service.addRange( + "D21", 1, RegisterDataType::Float64).succeeded + && !boundary_service.addRange( + "M10", 1, RegisterDataType::Int32).succeeded, + "multi-word monitor points must enforce D limits and Double alignment"); + + RegisterMonitorService validated_service(repository); + validated_service.setPollConfigurationValidator( + [](const std::vector &, + const std::vector &ranges) + { + return ranges.empty() ? std::string{} : std::string{"轮询范围被拒绝"}; + }); + const RegisterMonitorResult rejected = validated_service.addRange( + "D100", 1, RegisterDataType::Float64); + require(!rejected.succeeded + && rejected.error == RegisterMonitorError::PollConfigurationRejected + && validated_service.points().empty(), + "a rejected candidate poll configuration must not change monitor points"); } } // namespace @@ -179,7 +258,7 @@ int main() testSharedActiveRepositoryValues(); testRegisterWrites(); testOfflineInitialValueCapture(); - testFloat32Monitoring(); + testMultiWordMonitoring(); } catch (const std::exception &error) { diff --git a/app/tests/runtime_mode_service_tests.cpp b/app/tests/runtime_mode_service_tests.cpp index 80038f6..8be11dd 100644 --- a/app/tests/runtime_mode_service_tests.cpp +++ b/app/tests/runtime_mode_service_tests.cpp @@ -40,7 +40,19 @@ public: PlcCommunicationResult setPollAddresses( const std::vector &addresses) override { + return setPollAddresses(addresses, {}); + } + + PlcCommunicationResult setPollAddresses( + const std::vector &addresses, + const std::vector &ranges) override + { + if (reject_poll_configuration) + { + return {false, "poll configuration rejected"}; + } poll_addresses = addresses; + poll_ranges = ranges; return {true, {}}; } PlcConnectionState state() const override { return connection_state; } @@ -87,6 +99,13 @@ public: return poll_addresses; } + const std::vector &pollRanges() const + { + return poll_ranges; + } + + bool reject_poll_configuration = false; + private: PlcConnectionState connection_state = PlcConnectionState::Disconnected; bool initial_read = false; @@ -97,6 +116,7 @@ private: std::function poll_cycle_completed; std::function error_reported; std::vector poll_addresses; + std::vector poll_ranges; }; using TestSupport::require; @@ -135,6 +155,24 @@ void testModeTransitions() gateway, active_repository, virtual_repository, plc_repository); Project &project = project_service.editProject(); + HmiPage page; + page.id = "runtime-page"; + page.name = "Runtime"; + HmiControl int32_display; + int32_display.id = "runtime-int32"; + int32_display.type = HmiControlType::NumericDisplay; + int32_display.bounds = {0, 0, 100, 40}; + int32_display.binding = RegisterAddress{RegisterArea::D, 50}; + int32_display.dataType = RegisterDataType::Int32; + page.controls.push_back(int32_display); + HmiControl double_display = int32_display; + double_display.id = "runtime-double"; + double_display.bounds = {120, 0, 100, 40}; + double_display.binding = RegisterAddress{RegisterArea::D, 60}; + double_display.dataType = RegisterDataType::Float64; + page.controls.push_back(double_display); + project.initialHmiPageId = page.id; + project.hmiPages.push_back(page); project.alarmDefinitions.push_back( {"alarm-m", {RegisterArea::M, 12}, AlarmCondition::MOn, 0, "M alarm"}); project.alarmDefinitions.push_back( @@ -250,7 +288,14 @@ void testModeTransitions() {RegisterArea::M, 27}, {RegisterArea::M, 28}, {RegisterArea::D, 34}, {RegisterArea::D, 35}, {RegisterArea::D, 38}, {RegisterArea::D, 39}, - {RegisterArea::D, 40}, {RegisterArea::D, 41}}), + {RegisterArea::D, 40}, {RegisterArea::D, 41}, + {RegisterArea::D, 50}, {RegisterArea::D, 51}, + {RegisterArea::D, 60}, {RegisterArea::D, 61}, + {RegisterArea::D, 62}, {RegisterArea::D, 63}}) + && gateway.pollRanges() + == std::vector({ + {{RegisterArea::D, 50}, 2}, + {{RegisterArea::D, 60}, 4}}), "all instruction M/D references must be polled while comments stay metadata-only"); gateway.completeInitialRead(); require(service.initialPlcReadCompleted(), @@ -277,6 +322,40 @@ void testModeTransitions() "a completed PLC poll cycle must trigger one new local trace scan"); } +void testMonitorPollRangeRollback() +{ + TestProjectStorage storage; + ProjectService project_service(storage); + VirtualRegisterRepository virtual_repository; + VirtualRegisterRepository plc_repository; + ActiveRegisterRepository active_repository(virtual_repository); + OfflineSimulationService simulation_service(virtual_repository); + OnlineLogicMonitorService online_monitor_service(plc_repository); + LogicEditorService logic_editor_service(project_service); + ReadyPlcGateway gateway; + RuntimeModeService service( + project_service, + logic_editor_service, + simulation_service, + online_monitor_service); + service.configurePlc( + gateway, active_repository, virtual_repository, plc_repository); + + gateway.reject_poll_configuration = true; + const PlcCommunicationResult rejected = service.setMonitorAddresses( + {{RegisterArea::D, 100}, {RegisterArea::D, 101}, + {RegisterArea::D, 102}, {RegisterArea::D, 103}}, + {{{RegisterArea::D, 100}, 4}}); + require(!rejected.succeeded, + "a gateway-rejected Double monitor range must fail atomically"); + + gateway.reject_poll_configuration = false; + require(service.refreshPlcPollAddresses().succeeded + && gateway.pollAddresses().empty() + && gateway.pollRanges().empty(), + "a rejected monitor candidate must not remain in runtime poll state"); +} + void testDisconnectedOutputBlocksOfflineAndOnlineRuntime() { TestProjectStorage storage; @@ -379,6 +458,7 @@ int main() { // 运行模式只有这一组状态机边界测试 testModeTransitions(); + testMonitorPollRangeRollback(); testDisconnectedOutputBlocksOfflineAndOnlineRuntime(); } catch (const std::exception &error) diff --git a/app/tests/runtime_panel_controller_tests.cpp b/app/tests/runtime_panel_controller_tests.cpp index 30e97ba..054e286 100644 --- a/app/tests/runtime_panel_controller_tests.cpp +++ b/app/tests/runtime_panel_controller_tests.cpp @@ -11,6 +11,7 @@ #include "services/runtime_mode_service.h" #include "support/test_support.h" #include "ui/alarm_configuration_dialog.h" +#include "ui/free_monitor_widget.h" #include "ui/hmi_editor_widget.h" #include "ui/logic_editor_widget.h" #include "ui/runtime_monitor_widget.h" @@ -70,6 +71,28 @@ ControlLogic makeAlwaysOnLogic() return logic; } +void testMonitorOffersAllNumericTypes() +{ + VirtualRegisterRepository repository; + RegisterMonitorService service(repository); + FreeMonitorWidget widget(service); + QComboBox *type_combo = widget.findChild( + QStringLiteral("dataTypeComboBox")); + require(type_combo != nullptr + && type_combo->count() == 4 + && type_combo->itemData(0).toInt() + == static_cast(RegisterDataType::Int16) + && type_combo->itemData(1).toInt() + == static_cast(RegisterDataType::Int32) + && type_combo->itemData(2).toInt() + == static_cast(RegisterDataType::Float32) + && type_combo->itemData(3).toInt() + == static_cast(RegisterDataType::Float64) + && type_combo->itemText(3) + == QStringLiteral("Double (Float64)"), + "shared data/free monitor UI must expose all four numeric types"); +} + void testAlarmConfigurationOffersMOnAndMOff() { TestProjectStorage storage; @@ -958,6 +981,7 @@ int main(int argc, char *argv[]) try { testAlarmConfigurationOffersMOnAndMOff(); + testMonitorOffersAllNumericTypes(); testAlarmListKeepsFixedGeometryWhileRecordsChange(); testQueuedOfflineTraceIsIgnoredAfterReturningToEditing(); testLogicEditorGridSelectionAndDeletion();