| @@ -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<std::int16_t, 2> &values) | |||
| const std::vector<std::int16_t> &values) | |||
| { | |||
| return repository_->writeWordPair(address, values); | |||
| return repository_->writeWords(address, values); | |||
| } | |||
| @@ -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<std::int16_t, 2> &values) override; | |||
| const std::vector<std::int16_t> &values) override; | |||
| private: | |||
| RegisterRepository *repository_ = nullptr; | |||
| @@ -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) | |||
| @@ -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; | |||
| } | |||
| @@ -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) | |||
| { | |||
| @@ -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 | |||
| @@ -4,6 +4,8 @@ | |||
| #include <cstdint> | |||
| #include <array> | |||
| #include <utility> | |||
| #include <vector> | |||
| // 寄存器仓库操作失败原因;服务层会把它转换成用户可读消息 | |||
| enum class RegisterError | |||
| @@ -45,6 +47,13 @@ struct WordPairReadResult | |||
| RegisterError error = RegisterError::Unavailable; | |||
| }; | |||
| struct WordsReadResult | |||
| { | |||
| bool succeeded = false; | |||
| std::vector<std::int16_t> 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<std::int16_t> values; | |||
| values.reserve(static_cast<std::size_t>(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<std::int16_t, 2> &values) | |||
| const std::vector<std::int16_t> &values) | |||
| { | |||
| const int count = static_cast<int>(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<std::size_t>(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<std::int16_t, 2> &values) | |||
| { | |||
| return writeWords(address, {values[0], values[1]}); | |||
| } | |||
| }; | |||
| @@ -2,10 +2,54 @@ | |||
| #include <cmath> | |||
| #include <cstring> | |||
| #include <limits> | |||
| 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::size_t Size> | |||
| std::vector<std::int16_t> toVector(const std::array<std::int16_t, Size> &words) | |||
| { | |||
| return std::vector<std::int16_t>(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<std::int16_t, 2> Int32Codec::encode(std::int32_t value) | |||
| { | |||
| std::uint32_t bits = 0; | |||
| std::memcpy(&bits, &value, sizeof(bits)); | |||
| return {static_cast<std::int16_t>(bits & 0xffffU), | |||
| static_cast<std::int16_t>(bits >> 16U)}; | |||
| } | |||
| std::int32_t Int32Codec::decode( | |||
| std::int16_t low_word, std::int16_t high_word) | |||
| { | |||
| const std::uint32_t bits = static_cast<std::uint16_t>(low_word) | |||
| | (static_cast<std::uint32_t>(static_cast<std::uint16_t>(high_word)) << 16U); | |||
| std::int32_t value = 0; | |||
| std::memcpy(&value, &bits, sizeof(value)); | |||
| return value; | |||
| } | |||
| std::array<std::int16_t, 2> Float32Codec::encode(float value) | |||
| @@ -49,3 +132,104 @@ std::optional<float> Float32Codec::decode( | |||
| std::memcpy(&value, &bits, sizeof(value)); | |||
| return std::isfinite(value) ? std::optional<float>(value) : std::nullopt; | |||
| } | |||
| std::array<std::int16_t, 4> Float64Codec::encode(double value) | |||
| { | |||
| std::uint64_t bits = 0; | |||
| std::memcpy(&bits, &value, sizeof(bits)); | |||
| return { | |||
| static_cast<std::int16_t>(bits & 0xffffU), | |||
| static_cast<std::int16_t>((bits >> 16U) & 0xffffU), | |||
| static_cast<std::int16_t>((bits >> 32U) & 0xffffU), | |||
| static_cast<std::int16_t>(bits >> 48U)}; | |||
| } | |||
| std::optional<double> Float64Codec::decode( | |||
| const std::array<std::int16_t, 4> &words) | |||
| { | |||
| const std::uint64_t bits = static_cast<std::uint16_t>(words[0]) | |||
| | (static_cast<std::uint64_t>(static_cast<std::uint16_t>(words[1])) << 16U) | |||
| | (static_cast<std::uint64_t>(static_cast<std::uint16_t>(words[2])) << 32U) | |||
| | (static_cast<std::uint64_t>(static_cast<std::uint16_t>(words[3])) << 48U); | |||
| double value = 0.0; | |||
| std::memcpy(&value, &bits, sizeof(value)); | |||
| return std::isfinite(value) ? std::optional<double>(value) : std::nullopt; | |||
| } | |||
| std::optional<RegisterNumericValue> decodeRegisterNumericValue( | |||
| RegisterDataType type, const std::vector<std::int16_t> &words) | |||
| { | |||
| if (static_cast<int>(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<float> value = Float32Codec::decode(words[0], words[1]); | |||
| return value.has_value() | |||
| ? std::optional<RegisterNumericValue>{RegisterNumericValue{*value}} | |||
| : std::nullopt; | |||
| } | |||
| case RegisterDataType::Float64: | |||
| { | |||
| const std::optional<double> value = Float64Codec::decode( | |||
| {words[0], words[1], words[2], words[3]}); | |||
| return value.has_value() | |||
| ? std::optional<RegisterNumericValue>{RegisterNumericValue{*value}} | |||
| : std::nullopt; | |||
| } | |||
| default: | |||
| return std::nullopt; | |||
| } | |||
| } | |||
| std::optional<std::vector<std::int16_t>> 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<std::int16_t>::min() | |||
| || value > std::numeric_limits<std::int16_t>::max()) | |||
| { | |||
| return std::nullopt; | |||
| } | |||
| return std::vector<std::int16_t>{static_cast<std::int16_t>(value)}; | |||
| case RegisterDataType::Int32: | |||
| if (std::trunc(value) != value | |||
| || value < std::numeric_limits<std::int32_t>::min() | |||
| || value > std::numeric_limits<std::int32_t>::max()) | |||
| { | |||
| return std::nullopt; | |||
| } | |||
| return toVector(Int32Codec::encode(static_cast<std::int32_t>(value))); | |||
| case RegisterDataType::Float32: | |||
| { | |||
| if (value < -static_cast<double>(std::numeric_limits<float>::max()) | |||
| || value > static_cast<double>(std::numeric_limits<float>::max())) | |||
| { | |||
| return std::nullopt; | |||
| } | |||
| const float converted = static_cast<float>(value); | |||
| return std::isfinite(converted) | |||
| ? std::optional<std::vector<std::int16_t>>{ | |||
| toVector(Float32Codec::encode(converted))} | |||
| : std::nullopt; | |||
| } | |||
| case RegisterDataType::Float64: | |||
| return toVector(Float64Codec::encode(value)); | |||
| default: | |||
| return std::nullopt; | |||
| } | |||
| } | |||
| @@ -1,22 +1,72 @@ | |||
| #pragma once | |||
| #include "register_address.h" | |||
| #include <array> | |||
| #include <cstdint> | |||
| #include <optional> | |||
| #include <string> | |||
| #include <variant> | |||
| #include <vector> | |||
| 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<std::int16_t, std::int32_t, float, double>; | |||
| 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<std::int16_t, 2> 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<std::int16_t, 2> encode(float value); | |||
| static std::optional<float> decode(std::int16_t low_word, std::int16_t high_word); | |||
| }; | |||
| struct Float64Codec | |||
| { | |||
| static std::array<std::int16_t, 4> encode(double value); | |||
| static std::optional<double> decode( | |||
| const std::array<std::int16_t, 4> &words); | |||
| }; | |||
| std::optional<RegisterNumericValue> decodeRegisterNumericValue( | |||
| RegisterDataType type, const std::vector<std::int16_t> &words); | |||
| std::optional<std::vector<std::int16_t>> encodeRegisterNumericValue( | |||
| RegisterDataType type, double value); | |||
| @@ -1,5 +1,7 @@ | |||
| #include "virtual_register_repository.h" | |||
| #include <algorithm> | |||
| 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<std::int16_t>(begin, begin + count), RegisterError::None}; | |||
| } | |||
| RegisterWriteResult VirtualRegisterRepository::writeWordPair( | |||
| RegisterWriteResult VirtualRegisterRepository::writeWords( | |||
| const RegisterAddress &address, | |||
| const std::array<std::int16_t, 2> &values) | |||
| const std::vector<std::int16_t> &values) | |||
| { | |||
| return RegisterRepository::writeWordPair(address, values); | |||
| const int count = static_cast<int>(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() | |||
| @@ -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<std::int16_t, 2> &values) override; | |||
| const std::vector<std::int16_t> &values) override; | |||
| // 将所有虚拟寄存器恢复为默认值,开始新的离线会话 | |||
| void clear(); | |||
| @@ -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) | |||
| @@ -78,67 +78,68 @@ bool normalizePollAddresses( | |||
| return true; | |||
| } | |||
| bool isFloat32Start( | |||
| const std::vector<RegisterAddress> &float32_starts, | |||
| bool boundaryBelongsToMultiWordRange( | |||
| RegisterArea area, | |||
| int index) | |||
| int boundary, | |||
| const std::vector<RegisterWordRange> &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<RegisterAddress> &addresses, | |||
| const std::vector<RegisterAddress> &float32_starts) | |||
| const std::vector<RegisterWordRange> &multi_word_ranges, | |||
| std::vector<PlcPollBlock> *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<std::int16_t, 2> &values) | |||
| const std::vector<std::int16_t> &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<RegisterAddress> &addresses, | |||
| const std::vector<RegisterAddress> &float32_starts) | |||
| const std::vector<RegisterWordRange> &multi_word_ranges) | |||
| { | |||
| std::vector<RegisterAddress> normalized; | |||
| std::string error; | |||
| @@ -310,43 +311,38 @@ PlcCommunicationResult PlcCommunicationService::setPollAddresses( | |||
| { | |||
| return {false, error}; | |||
| } | |||
| std::vector<RegisterAddress> normalized_float32_starts = float32_starts; | |||
| std::vector<RegisterWordRange> 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<PlcPollBlock> 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<PlcPollBlock> &PlcCommunicationService::pollBlocks() const | |||
| { | |||
| return poll_blocks_; | |||
| } | |||
| void PlcCommunicationService::setCallbacks( | |||
| std::function<void()> state_changed, | |||
| std::function<void(bool)> 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<PlcPollBlock> 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<quint16>(block.count)); | |||
| @@ -584,7 +548,7 @@ void PlcCommunicationService::pollNextBlock() | |||
| if (poll_update_pending_) | |||
| { | |||
| const std::vector<RegisterAddress> 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<RegisterAddress> 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<std::int16_t, 2> &values) | |||
| const std::vector<std::int16_t> &values) | |||
| { | |||
| const int count = static_cast<int>(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<quint16>(values[0])); | |||
| unit.setValue(1, static_cast<quint16>(values[1])); | |||
| QModbusDataUnit unit(QModbusDataUnit::HoldingRegisters, address.index(), count); | |||
| for (int offset = 0; offset < count; ++offset) | |||
| { | |||
| unit.setValue( | |||
| offset, static_cast<quint16>(values[static_cast<std::size_t>(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); | |||
| } | |||
| } | |||
| // 按区域和地址排序,方便后面合并连续读块 | |||
| // 相同区域和编号的地址只保留一个 | |||
| @@ -8,7 +8,6 @@ | |||
| #include <QTimer> | |||
| #include <cstdint> | |||
| #include <array> | |||
| #include <memory> | |||
| #include <string> | |||
| #include <vector> | |||
| @@ -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<RegisterAddress> &addresses) override; | |||
| PlcCommunicationResult setPollAddresses( | |||
| const std::vector<RegisterAddress> &addresses, | |||
| const std::vector<RegisterAddress> &float32_starts) override; | |||
| const std::vector<RegisterWordRange> &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<PlcPollBlock> &pollBlocks() const; | |||
| // 注册状态、首读、缓存更新和错误通知回调 | |||
| void setCallbacks( | |||
| std::function<void()> 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<std::int16_t, 2> &values); | |||
| const std::vector<std::int16_t> &values); | |||
| // 更新“所有轮询块均成功读取”的真机进入资格 | |||
| void updateInitialReadCompleted(bool completed, bool force_notification = false); | |||
| // 处理没有主动断开时发生的串口断线 | |||
| @@ -124,9 +124,9 @@ private: | |||
| PlcSerialConfiguration configuration_; // 当前串口和站号配置 | |||
| std::vector<RegisterAddress> poll_addresses_; // 当前生效的轮询地址 | |||
| std::vector<RegisterAddress> pending_poll_addresses_; // 请求在途时暂存的新地址 | |||
| std::vector<RegisterAddress> poll_float32_starts_; // 当前轮询集合中的 Float32 起始地址 | |||
| std::vector<RegisterAddress> pending_poll_float32_starts_; // 请求在途时暂存的 Float32 起始地址 | |||
| std::vector<PollBlock> poll_blocks_; // 根据地址合并出的连续读块 | |||
| std::vector<RegisterWordRange> poll_multi_word_ranges_; // 当前轮询集合中的多字范围 | |||
| std::vector<RegisterWordRange> pending_poll_multi_word_ranges_; // 请求在途时暂存的多字范围 | |||
| std::vector<PlcPollBlock> poll_blocks_; // 根据地址合并出的连续读块 | |||
| std::size_t next_poll_block_ = 0; // 下一次要读取的读块下标 | |||
| QModbusReply *pending_reply_ = nullptr; // 当前未完成的读请求或恢复探测 | |||
| QModbusReply *pending_write_reply_ = nullptr; // 当前未完成的单点写请求 | |||
| @@ -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<std::int16_t, 2> &values) | |||
| const std::vector<std::int16_t> &values) | |||
| { | |||
| const int count = static_cast<int>(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<RegisterWriteResult(const RegisterAddress &, bool)> bit_handler, | |||
| std::function<RegisterWriteResult(const RegisterAddress &, std::int16_t)> word_handler, | |||
| std::function<RegisterWriteResult( | |||
| const RegisterAddress &, const std::array<std::int16_t, 2> &)> word_pair_handler) | |||
| const RegisterAddress &, | |||
| const std::vector<std::int16_t> &)> 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) | |||
| @@ -2,8 +2,8 @@ | |||
| #include "domain/register_repository.h" | |||
| #include <array> | |||
| #include <functional> | |||
| #include <vector> | |||
| // 保存 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<std::int16_t, 2> &values) override; | |||
| const std::vector<std::int16_t> &values) override; | |||
| // 注入异步写入回调;回调成功不代表缓存已经更新 | |||
| void setWriteHandlers( | |||
| std::function<RegisterWriteResult(const RegisterAddress &, bool)> bit_handler, | |||
| std::function<RegisterWriteResult(const RegisterAddress &, std::int16_t)> word_handler, | |||
| std::function<RegisterWriteResult( | |||
| const RegisterAddress &, const std::array<std::int16_t, 2> &)> word_pair_handler = {}); | |||
| const RegisterAddress &, | |||
| const std::vector<std::int16_t> &)> words_handler = {}); | |||
| // 由通信服务在读回成功后更新缓存并标记地址有效 | |||
| void updateBit(int address, bool value); | |||
| void updateWord(int address, std::int16_t value); | |||
| @@ -47,5 +49,5 @@ private: | |||
| std::function<RegisterWriteResult(const RegisterAddress &, bool)> bit_handler_; // M 区异步写入回调 | |||
| std::function<RegisterWriteResult(const RegisterAddress &, std::int16_t)> word_handler_; // D 区异步写入回调 | |||
| std::function<RegisterWriteResult( | |||
| const RegisterAddress &, const std::array<std::int16_t, 2> &)> word_pair_handler_; | |||
| const RegisterAddress &, const std::vector<std::int16_t> &)> words_handler_; | |||
| }; | |||
| @@ -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<float> 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<RegisterNumericValue> 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<double>(std::numeric_limits<float>::max()) | |||
| || value < -static_cast<double>(std::numeric_limits<float>::max())) | |||
| { | |||
| return writeFailure(HmiRuntimeError::InvalidBinding); | |||
| } | |||
| const float float_value = static_cast<float>(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<std::vector<std::int16_t>> encoded = | |||
| encodeRegisterNumericValue(control.dataType, value); | |||
| if (!encoded.has_value()) | |||
| { | |||
| if (!std::isfinite(value) | |||
| || value < std::numeric_limits<std::int16_t>::min() | |||
| || value > std::numeric_limits<std::int16_t>::max() | |||
| || std::trunc(value) != value) | |||
| { | |||
| return writeFailure(HmiRuntimeError::InvalidBinding); | |||
| } | |||
| result = repository_.writeWord( | |||
| *control.binding, static_cast<std::int16_t>(value)); | |||
| return writeFailure(HmiRuntimeError::InvalidBinding); | |||
| } | |||
| const RegisterWriteResult result = repository_.writeWords( | |||
| *control.binding, *encoded); | |||
| return result.succeeded | |||
| ? HmiRuntimeWriteResult{true, HmiRuntimeError::None} | |||
| : writeFailure(repositoryError(result.error)); | |||
| @@ -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}; // 数值控件的类型化读回值 | |||
| }; | |||
| /** | |||
| @@ -1,6 +1,7 @@ | |||
| #pragma once | |||
| #include "domain/register_address.h" | |||
| #include "domain/register_value_type.h" | |||
| #include "domain/project_limits.h" | |||
| #include <algorithm> | |||
| @@ -153,18 +154,18 @@ public: | |||
| const std::vector<RegisterAddress> &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<RegisterAddress> &addresses, | |||
| const std::vector<RegisterAddress> &float32_starts) | |||
| const std::vector<RegisterWordRange> &multi_word_ranges) | |||
| { | |||
| (void)float32_starts; | |||
| (void)multi_word_ranges; | |||
| return setPollAddresses(addresses); | |||
| } | |||
| @@ -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<RegisterAddress> pending; | |||
| std::vector<MonitorPoint> pending; | |||
| pending.reserve(static_cast<std::size_t>(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<MonitorPoint> 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<std::vector<std::int16_t>> 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<std::int16_t> &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<MonitorValue> RegisterMonitorService::values(bool communication_faul | |||
| } | |||
| else | |||
| { | |||
| if (point.dataType == RegisterDataType::Float32) | |||
| const WordsReadResult read = repository_.readWords( | |||
| address, registerDataTypeWordCount(point.dataType)); | |||
| const std::optional<RegisterNumericValue> decoded = read.succeeded | |||
| ? decodeRegisterNumericValue(point.dataType, read.values) | |||
| : std::nullopt; | |||
| if (decoded.has_value()) | |||
| { | |||
| const WordPairReadResult read = repository_.readWordPair(address); | |||
| const std::optional<float> 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<MonitorPoint> &RegisterMonitorService::points() const | |||
| } | |||
| std::vector<RegisterAddress> RegisterMonitorService::pollAddresses() const | |||
| { | |||
| return pollAddressesForPoints(model_.points()); | |||
| } | |||
| std::vector<RegisterAddress> RegisterMonitorService::pollAddressesForPoints( | |||
| const std::vector<MonitorPoint> &points) | |||
| { | |||
| std::vector<RegisterAddress> 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<RegisterAddress> RegisterMonitorService::pollAddresses() const | |||
| return result; | |||
| } | |||
| std::vector<RegisterAddress> RegisterMonitorService::float32Starts() const | |||
| std::vector<RegisterWordRange> RegisterMonitorService::multiWordRanges() const | |||
| { | |||
| std::vector<RegisterAddress> result; | |||
| for (const MonitorPoint &point : model_.points()) | |||
| return multiWordRangesForPoints(model_.points()); | |||
| } | |||
| std::vector<RegisterWordRange> RegisterMonitorService::multiWordRangesForPoints( | |||
| const std::vector<MonitorPoint> &points) | |||
| { | |||
| std::vector<RegisterWordRange> 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<void()> c | |||
| { | |||
| addresses_changed_callback_ = std::move(callback); | |||
| } | |||
| void RegisterMonitorService::setPollConfigurationValidator( | |||
| std::function<std::string( | |||
| const std::vector<RegisterAddress> &, | |||
| const std::vector<RegisterWordRange> &)> validator) | |||
| { | |||
| poll_configuration_validator_ = std::move(validator); | |||
| } | |||
| @@ -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<RegisterAddress> &addresses() const; | |||
| const std::vector<MonitorPoint> &points() const; | |||
| std::vector<RegisterAddress> pollAddresses() const; | |||
| std::vector<RegisterAddress> float32Starts() const; | |||
| std::vector<RegisterWordRange> multiWordRanges() const; | |||
| /** | |||
| * @brief 读取监控列表的当前值并生成 UI 展示模型 | |||
| * @param communication_fault 通信处于故障态时,将成功读回值标记为通信故障 | |||
| @@ -113,21 +116,30 @@ public: | |||
| * @param callback 地址列表新增、删除或清空后调用;可传空函数取消通知 | |||
| */ | |||
| void setAddressesChangedCallback(std::function<void()> callback); | |||
| void setPollConfigurationValidator( | |||
| std::function<std::string( | |||
| const std::vector<RegisterAddress> &, | |||
| const std::vector<RegisterWordRange> &)> 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<std::int16_t> &values, | |||
| const RegisterMonitorWriteResult &active_result); | |||
| static std::vector<RegisterAddress> pollAddressesForPoints( | |||
| const std::vector<MonitorPoint> &points); | |||
| static std::vector<RegisterWordRange> multiWordRangesForPoints( | |||
| const std::vector<MonitorPoint> &points); | |||
| RegisterRepository &repository_; // 当前运行模式的寄存器仓库,不由服务拥有 | |||
| VirtualRegisterRepository *offline_initial_repository_ = nullptr; | |||
| bool capture_offline_initial_values_ = false; | |||
| RegisterMonitorModel model_; // 当前会话的去重监控地址列表 | |||
| std::function<void()> addresses_changed_callback_; // 地址变化后的轮询刷新通知 | |||
| std::function<std::string( | |||
| const std::vector<RegisterAddress> &, | |||
| const std::vector<RegisterWordRange> &)> poll_configuration_validator_; | |||
| }; | |||
| @@ -282,19 +282,27 @@ PlcCommunicationResult RuntimeModeService::connectPlc( | |||
| return plc_gateway_->connectDevice(configuration); | |||
| } | |||
| void RuntimeModeService::setMonitorAddresses( | |||
| PlcCommunicationResult RuntimeModeService::setMonitorAddresses( | |||
| const std::vector<RegisterAddress> &addresses) | |||
| { | |||
| setMonitorAddresses(addresses, {}); | |||
| return setMonitorAddresses(addresses, {}); | |||
| } | |||
| void RuntimeModeService::setMonitorAddresses( | |||
| PlcCommunicationResult RuntimeModeService::setMonitorAddresses( | |||
| const std::vector<RegisterAddress> &addresses, | |||
| const std::vector<RegisterAddress> &float32_starts) | |||
| const std::vector<RegisterWordRange> &multi_word_ranges) | |||
| { | |||
| const std::vector<RegisterAddress> previous_addresses = monitor_addresses_; | |||
| const std::vector<RegisterWordRange> 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<RegisterAddress> addresses = monitor_addresses_; | |||
| std::vector<RegisterAddress> float32_starts = monitor_float32_starts_; | |||
| std::vector<RegisterWordRange> 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() | |||
| @@ -112,10 +112,11 @@ public: | |||
| * @brief 设置自由监控引用的地址并刷新 PLC 轮询集合 | |||
| * @param addresses 自由监控当前需要读取的 M/D 地址 | |||
| */ | |||
| void setMonitorAddresses(const std::vector<RegisterAddress> &addresses); | |||
| void setMonitorAddresses( | |||
| PlcCommunicationResult setMonitorAddresses( | |||
| const std::vector<RegisterAddress> &addresses); | |||
| PlcCommunicationResult setMonitorAddresses( | |||
| const std::vector<RegisterAddress> &addresses, | |||
| const std::vector<RegisterAddress> &float32_starts); | |||
| const std::vector<RegisterWordRange> &multi_word_ranges); | |||
| /** | |||
| * @brief 汇总工程和自由监控引用并刷新 PLC 轮询地址 | |||
| @@ -156,6 +157,6 @@ private: | |||
| RegisterRepository *plc_repository_ = nullptr; // 真机模式的 PLC 缓存仓库 | |||
| std::function<void()> plc_status_changed_callback_; // PLC 状态变化通知 | |||
| std::vector<RegisterAddress> monitor_addresses_; // 自由监控额外引用的地址 | |||
| std::vector<RegisterAddress> monitor_float32_starts_; // 自由监控中的 Float32 起始地址 | |||
| std::vector<RegisterWordRange> monitor_multi_word_ranges_; // 自由监控中的多字范围 | |||
| LogicSyntaxCheckResult last_syntax_check_; // 最近一次运行前梯形图检查结果 | |||
| }; | |||
| @@ -5,16 +5,20 @@ | |||
| #include "ui_free_monitor_widget.h" | |||
| #include <QComboBox> | |||
| #include <QDoubleValidator> | |||
| #include <QHeaderView> | |||
| #include <QIntValidator> | |||
| #include <QLineEdit> | |||
| #include <QLocale> | |||
| #include <QMessageBox> | |||
| #include <QPushButton> | |||
| #include <QTableWidgetItem> | |||
| #include <cstdint> | |||
| #include <cmath> | |||
| #include <limits> | |||
| #include <set> | |||
| #include <type_traits> | |||
| 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<decltype(typed_value)>; | |||
| if constexpr (std::is_same_v<Value, float>) | |||
| { | |||
| return QString::number( | |||
| static_cast<double>(typed_value), 'g', | |||
| std::numeric_limits<float>::max_digits10); | |||
| } | |||
| else if constexpr (std::is_same_v<Value, double>) | |||
| { | |||
| return QString::number( | |||
| typed_value, 'g', std::numeric_limits<double>::max_digits10); | |||
| } | |||
| else | |||
| { | |||
| return QString::number(static_cast<qlonglong>(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<int>(RegisterDataType::Int16)); | |||
| ui_->dataTypeComboBox->setItemData(1, static_cast<int>(RegisterDataType::Float32)); | |||
| ui_->dataTypeComboBox->setItemData(1, static_cast<int>(RegisterDataType::Int32)); | |||
| ui_->dataTypeComboBox->setItemData(2, static_cast<int>(RegisterDataType::Float32)); | |||
| ui_->dataTypeComboBox->setItemData(3, static_cast<int>(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<double>(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<int>(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<int>::min(), | |||
| std::numeric_limits<int>::max(), target)); | |||
| } | |||
| else | |||
| { | |||
| target->setValidator(new QIntValidator(-32768, 32767, target)); | |||
| const double maximum = point.dataType == RegisterDataType::Float32 | |||
| ? static_cast<double>(std::numeric_limits<float>::max()) | |||
| : std::numeric_limits<double>::max(); | |||
| const int digits = point.dataType == RegisterDataType::Float32 | |||
| ? std::numeric_limits<float>::max_digits10 | |||
| : std::numeric_limits<double>::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<float>(value)) | |||
| : service_.writeWord(address, static_cast<std::int16_t>(value)); | |||
| result = service_.writeNumeric(address, point.dataType, value); | |||
| } | |||
| QTableWidgetItem *state_item = ui_->monitorTable->item(row, 5); | |||
| @@ -16,7 +16,7 @@ | |||
| <item row="0" column="0"><widget class="QLabel" name="addressLabel"><property name="text"><string>起始地址</string></property></widget></item> | |||
| <item row="0" column="1"><widget class="QLineEdit" name="addressEdit"><property name="minimumSize"><size><width>90</width><height>0</height></size></property><property name="maximumSize"><size><width>160</width><height>16777215</height></size></property><property name="placeholderText"><string>M0 或 D0</string></property><property name="clearButtonEnabled"><bool>true</bool></property></widget></item> | |||
| <item row="0" column="2"><widget class="QLabel" name="dataTypeLabel"><property name="text"><string>类型</string></property></widget></item> | |||
| <item row="0" column="3"><widget class="QComboBox" name="dataTypeComboBox"><item><property name="text"><string>Int16</string></property></item><item><property name="text"><string>Float32</string></property></item></widget></item> | |||
| <item row="0" column="3"><widget class="QComboBox" name="dataTypeComboBox"><item><property name="text"><string>Int16</string></property></item><item><property name="text"><string>Int32</string></property></item><item><property name="text"><string>Float32</string></property></item><item><property name="text"><string>Double (Float64)</string></property></item></widget></item> | |||
| <item row="0" column="4"><widget class="QLabel" name="countLabel"><property name="text"><string>连续个数</string></property></widget></item> | |||
| <item row="0" column="5"><widget class="QSpinBox" name="countSpinBox"><property name="minimum"><number>1</number></property><property name="maximum"><number>64</number></property><property name="value"><number>1</number></property></widget></item> | |||
| <item row="0" column="6"><widget class="QPushButton" name="addButton"><property name="text"><string>添加</string></property></widget></item> | |||
| @@ -7,6 +7,7 @@ | |||
| #include <QApplication> | |||
| #include <QDateTime> | |||
| #include <QDoubleValidator> | |||
| #include <QFont> | |||
| #include <QGraphicsItem> | |||
| #include <QGraphicsRectItem> | |||
| @@ -14,6 +15,8 @@ | |||
| #include <QGraphicsSceneHoverEvent> | |||
| #include <QGraphicsSceneMouseEvent> | |||
| #include <QInputDialog> | |||
| #include <QLineEdit> | |||
| #include <QLocale> | |||
| #include <QPainter> | |||
| #include <QResizeEvent> | |||
| #include <QStyle> | |||
| @@ -25,6 +28,8 @@ | |||
| #include <cstdint> | |||
| #include <functional> | |||
| #include <limits> | |||
| #include <optional> | |||
| #include <type_traits> | |||
| 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<decltype(typed_value)>; | |||
| if constexpr (std::is_same_v<Value, float>) | |||
| { | |||
| return QString::number( | |||
| static_cast<double>(typed_value), 'g', | |||
| std::numeric_limits<float>::max_digits10); | |||
| } | |||
| else if constexpr (std::is_same_v<Value, double>) | |||
| { | |||
| return QString::number( | |||
| typed_value, 'g', std::numeric_limits<double>::max_digits10); | |||
| } | |||
| else | |||
| { | |||
| return QString::number(static_cast<qlonglong>(typed_value)); | |||
| } | |||
| }, | |||
| value); | |||
| } | |||
| double numericValueAsDouble(const RegisterNumericValue &value) | |||
| { | |||
| return std::visit( | |||
| [](const auto &typed_value) | |||
| { | |||
| return static_cast<double>(typed_value); | |||
| }, | |||
| value); | |||
| } | |||
| std::optional<double> 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<double>(std::numeric_limits<float>::max()) | |||
| : std::numeric_limits<double>::max(); | |||
| const int digits = float32 | |||
| ? std::numeric_limits<float>::max_digits10 | |||
| : std::numeric_limits<double>::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<int>(control.text.size()))); | |||
| dialog.setTextValue(QString::number( | |||
| has_current_value ? numericValueAsDouble(current_value) : 0.0, | |||
| 'g', digits)); | |||
| QLineEdit *editor = dialog.findChild<QLineEdit *>(); | |||
| 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<double>{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<double> value; | |||
| if (registerDataTypeDescriptor(control->dataType).floatingPoint) | |||
| { | |||
| value = QInputDialog::getDouble( | |||
| this, | |||
| tr("输入 Float32"), | |||
| QString::fromUtf8( | |||
| control->text.data(), static_cast<int>(control->text.size())), | |||
| current.succeeded ? static_cast<double>(current.float_value) : 0.0, | |||
| -static_cast<double>(std::numeric_limits<float>::max()), | |||
| static_cast<double>(std::numeric_limits<float>::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<std::int16_t>::min() | |||
| : std::numeric_limits<std::int32_t>::min(); | |||
| const int maximum = control->dataType == RegisterDataType::Int16 | |||
| ? std::numeric_limits<std::int16_t>::max() | |||
| : std::numeric_limits<std::int32_t>::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<int>(control->text.size())), | |||
| current.succeeded ? current.word_value : 0, | |||
| std::numeric_limits<std::int16_t>::min(), | |||
| std::numeric_limits<std::int16_t>::max(), | |||
| current.succeeded | |||
| ? static_cast<int>(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("寄存器操作失败")); | |||
| @@ -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<RegisterAddress> &addresses, | |||
| const std::vector<RegisterWordRange> &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); | |||
| @@ -778,10 +778,12 @@ | |||
| </widget> | |||
| </item> | |||
| <item row="13" column="1"> | |||
| <widget class="QComboBox" name="dataTypeComboBox"> | |||
| <item><property name="text"><string>Int16</string></property></item> | |||
| <item><property name="text"><string>Float32</string></property></item> | |||
| </widget> | |||
| <widget class="QComboBox" name="dataTypeComboBox"> | |||
| <item><property name="text"><string>Int16</string></property></item> | |||
| <item><property name="text"><string>Int32</string></property></item> | |||
| <item><property name="text"><string>Float32</string></property></item> | |||
| <item><property name="text"><string>Double (Float64)</string></property></item> | |||
| </widget> | |||
| </item> | |||
| <item row="14" column="0" colspan="2"> | |||
| <widget class="QPushButton" name="applyPropertiesButton"> | |||
| @@ -111,7 +111,11 @@ void PropertyPanelController::configure() | |||
| ui_.dataTypeComboBox->setItemData( | |||
| 0, static_cast<int>(RegisterDataType::Int16)); | |||
| ui_.dataTypeComboBox->setItemData( | |||
| 1, static_cast<int>(RegisterDataType::Float32)); | |||
| 1, static_cast<int>(RegisterDataType::Int32)); | |||
| ui_.dataTypeComboBox->setItemData( | |||
| 2, static_cast<int>(RegisterDataType::Float32)); | |||
| ui_.dataTypeComboBox->setItemData( | |||
| 3, static_cast<int>(RegisterDataType::Float64)); | |||
| for (int index = 0; index < ui_.logicComparisonComboBox->count(); ++index) | |||
| { | |||
| ui_.logicComparisonComboBox->setItemData(index, index); | |||
| @@ -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()); | |||
| @@ -78,8 +78,23 @@ void testRegisterRepositorySeparatesAreas() | |||
| "M address must not be read as a word"); | |||
| } | |||
| void testFloat32CodecAndPairAccess() | |||
| void testMultiWordCodecsAndBlockAccess() | |||
| { | |||
| const std::array<std::int32_t, 4> int32_values = { | |||
| std::numeric_limits<std::int32_t>::min(), -1, 0, | |||
| std::numeric_limits<std::int32_t>::max()}; | |||
| for (const std::int32_t value : int32_values) | |||
| { | |||
| const std::array<std::int16_t, 2> words = Int32Codec::encode(value); | |||
| require(Int32Codec::decode(words[0], words[1]) == value, | |||
| "Int32 codec must preserve signed boundary values"); | |||
| } | |||
| const std::array<std::int16_t, 2> int32_words = | |||
| Int32Codec::encode(0x12345678); | |||
| require(static_cast<std::uint16_t>(int32_words[0]) == 0x5678U | |||
| && static_cast<std::uint16_t>(int32_words[1]) == 0x1234U, | |||
| "Int32 codec must store the low 16-bit word at the lower D address"); | |||
| const std::array<float, 4> values = {1.0f, -2.5f, 0.1f, 0.0f}; | |||
| for (const float value : values) | |||
| { | |||
| @@ -96,6 +111,51 @@ void testFloat32CodecAndPairAccess() | |||
| static_cast<std::int16_t>(0), | |||
| static_cast<std::int16_t>(0x7fc0)).has_value(), | |||
| "Float32 codec must reject NaN"); | |||
| const std::array<std::int16_t, 2> float20 = Float32Codec::encode(20.0f); | |||
| require(static_cast<std::uint16_t>(float20[0]) == 0x0000U | |||
| && static_cast<std::uint16_t>(float20[1]) == 0x41a0U, | |||
| "Float32 20.0 must match the Xinje low-word-first example"); | |||
| const std::array<double, 4> double_values = {1.0, -2.5, 0.1, 0.0}; | |||
| for (const double value : double_values) | |||
| { | |||
| const std::array<std::int16_t, 4> words = Float64Codec::encode(value); | |||
| const std::optional<double> decoded = Float64Codec::decode(words); | |||
| require(decoded.has_value() && *decoded == value, | |||
| "Double codec must preserve representative finite values"); | |||
| } | |||
| const std::array<std::int16_t, 4> double_one = Float64Codec::encode(1.0); | |||
| require(static_cast<std::uint16_t>(double_one[0]) == 0x0000U | |||
| && static_cast<std::uint16_t>(double_one[1]) == 0x0000U | |||
| && static_cast<std::uint16_t>(double_one[2]) == 0x0000U | |||
| && static_cast<std::uint16_t>(double_one[3]) == 0x3ff0U, | |||
| "Double 1.0 must occupy four low-address-first D words"); | |||
| require(!Float64Codec::decode({ | |||
| static_cast<std::int16_t>(0), static_cast<std::int16_t>(0), | |||
| static_cast<std::int16_t>(0), static_cast<std::int16_t>(0x7ff0)}) | |||
| .has_value() | |||
| && !Float64Codec::decode({ | |||
| static_cast<std::int16_t>(0), static_cast<std::int16_t>(0), | |||
| static_cast<std::int16_t>(0), static_cast<std::int16_t>(0x7ff8)}) | |||
| .has_value(), | |||
| "Double codec must reject infinity and NaN bit patterns"); | |||
| require(encodeRegisterNumericValue( | |||
| RegisterDataType::Int32, | |||
| static_cast<double>(std::numeric_limits<std::int32_t>::min())) | |||
| .has_value() | |||
| && encodeRegisterNumericValue( | |||
| RegisterDataType::Int32, | |||
| static_cast<double>(std::numeric_limits<std::int32_t>::max())) | |||
| .has_value() | |||
| && !encodeRegisterNumericValue( | |||
| RegisterDataType::Int32, 1.5).has_value() | |||
| && !encodeRegisterNumericValue( | |||
| RegisterDataType::Float64, | |||
| std::numeric_limits<double>::infinity()).has_value() | |||
| && !encodeRegisterNumericValue( | |||
| RegisterDataType::Float64, | |||
| std::numeric_limits<double>::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<std::int16_t>( | |||
| 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<RegisterDataType>(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<RegisterDataType>(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(); | |||
| @@ -13,6 +13,7 @@ | |||
| #include <limits> | |||
| #include <stdexcept> | |||
| #include <string> | |||
| #include <variant> | |||
| 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<std::int16_t>(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>(float_value.numeric_value) == -2.5f, | |||
| "Float32 numeric control must decode two consecutive D words"); | |||
| require(!runtime_service.writeNumericInput( | |||
| float_input, std::numeric_limits<double>::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<std::uint16_t>(int32_words.values[0]) == 0x5678U | |||
| && static_cast<std::uint16_t>(int32_words.values[1]) == 0x1234U | |||
| && int32_value.succeeded | |||
| && std::get<std::int32_t>(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<std::uint16_t>(double_words.values[0]) == 0x0000U | |||
| && static_cast<std::uint16_t>(double_words.values[1]) == 0x0000U | |||
| && static_cast<std::uint16_t>(double_words.values[2]) == 0x0000U | |||
| && static_cast<std::uint16_t>(double_words.values[3]) == 0x3ff0U | |||
| && double_value.succeeded | |||
| && std::get<double>(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<double>::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"); | |||
| @@ -770,18 +770,44 @@ void testSimulationUsesInitialValuesAndDiscardsRuntimeOutputs() | |||
| writeBit(initial_repository, 0, true); | |||
| writeWord(initial_repository, 10, 321); | |||
| const std::array<std::int16_t, 2> initial_int32 = | |||
| Int32Codec::encode(0x12345678); | |||
| const std::array<std::int16_t, 4> 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<std::int16_t>{ | |||
| initial_int32[0], initial_int32[1]} | |||
| && repository.readWords({RegisterArea::D, 30}, 4).values | |||
| == std::vector<std::int16_t>{ | |||
| 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<std::int16_t>{ | |||
| initial_int32[0], initial_int32[1]} | |||
| && repository.readWords({RegisterArea::D, 30}, 4).values | |||
| == std::vector<std::int16_t>{ | |||
| 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, | |||
| @@ -150,6 +150,9 @@ void testPlcCacheAndWriteForwarding() | |||
| RegisterAddress written_address{RegisterArea::M, 1}; | |||
| bool written_bit = false; | |||
| std::int16_t written_word = 0; | |||
| std::vector<std::int16_t> 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<std::int16_t> &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<std::int16_t>(0x5678), | |||
| static_cast<std::int16_t>(0x1234)}).succeeded | |||
| && written_address == d10 | |||
| && written_words.size() == 2U | |||
| && static_cast<std::uint16_t>(written_words[0]) == 0x5678U | |||
| && static_cast<std::uint16_t>(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<std::int16_t>(0x0000), | |||
| static_cast<std::int16_t>(0x0000), | |||
| static_cast<std::int16_t>(0x0000), | |||
| static_cast<std::int16_t>(0x3ff0)}).succeeded | |||
| && written_address == d20 | |||
| && written_words.size() == 4U | |||
| && static_cast<std::uint16_t>(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<RegisterAddress> 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<RegisterWordRange> 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 | |||
| @@ -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(); | |||
| @@ -10,6 +10,7 @@ | |||
| #include <limits> | |||
| #include <stdexcept> | |||
| #include <string> | |||
| #include <variant> | |||
| namespace { | |||
| @@ -59,14 +60,16 @@ void testSharedActiveRepositoryValues() | |||
| virtual_repository.writeBit({RegisterArea::M, 0}, true); | |||
| virtual_repository.writeWord({RegisterArea::D, 0}, -123); | |||
| std::vector<MonitorValue> values = service.values(false); | |||
| require(values.size() == 2U && values[0].bitValue && values[1].wordValue == -123, | |||
| require(values.size() == 2U && values[0].bitValue | |||
| && std::get<std::int16_t>(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<std::int16_t>(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<std::uint16_t>(captured_int32.values[0]) == 0x5678U | |||
| && static_cast<std::uint16_t>(captured_int32.values[1]) == 0x1234U | |||
| && captured_double.succeeded | |||
| && static_cast<std::uint16_t>(captured_double.values[0]) == 0x0000U | |||
| && static_cast<std::uint16_t>(captured_double.values[1]) == 0x0000U | |||
| && static_cast<std::uint16_t>(captured_double.values[2]) == 0x0000U | |||
| && static_cast<std::uint16_t>(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<MonitorValue> values = service.values(false); | |||
| std::vector<MonitorValue> values = service.values(false); | |||
| require(values.front().state == MonitorValueState::Valid | |||
| && std::fabs(values.front().floatValue - (-2.5f)) < 0.000001f, | |||
| && std::fabs( | |||
| std::get<float>(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<std::int32_t>(values[3].numericValue) == 0x12345678 | |||
| && std::get<double>(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<RegisterAddress> &, | |||
| const std::vector<RegisterWordRange> &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) | |||
| { | |||
| @@ -40,7 +40,19 @@ public: | |||
| PlcCommunicationResult setPollAddresses( | |||
| const std::vector<RegisterAddress> &addresses) override | |||
| { | |||
| return setPollAddresses(addresses, {}); | |||
| } | |||
| PlcCommunicationResult setPollAddresses( | |||
| const std::vector<RegisterAddress> &addresses, | |||
| const std::vector<RegisterWordRange> &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<RegisterWordRange> &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<void()> poll_cycle_completed; | |||
| std::function<void(const std::string &)> error_reported; | |||
| std::vector<RegisterAddress> poll_addresses; | |||
| std::vector<RegisterWordRange> 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<RegisterWordRange>({ | |||
| {{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) | |||
| @@ -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<QComboBox *>( | |||
| QStringLiteral("dataTypeComboBox")); | |||
| require(type_combo != nullptr | |||
| && type_combo->count() == 4 | |||
| && type_combo->itemData(0).toInt() | |||
| == static_cast<int>(RegisterDataType::Int16) | |||
| && type_combo->itemData(1).toInt() | |||
| == static_cast<int>(RegisterDataType::Int32) | |||
| && type_combo->itemData(2).toInt() | |||
| == static_cast<int>(RegisterDataType::Float32) | |||
| && type_combo->itemData(3).toInt() | |||
| == static_cast<int>(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(); | |||