| @@ -29,6 +29,7 @@ SOURCES += \ | |||
| src/ui/property_panel_controller.cpp \ | |||
| src/ui/runtime_panel_controller.cpp \ | |||
| src/domain/register_address.cpp \ | |||
| src/domain/register_value_type.cpp \ | |||
| src/domain/virtual_register_repository.cpp \ | |||
| src/domain/active_register_repository.cpp \ | |||
| src/domain/register_monitor_model.cpp \ | |||
| @@ -75,6 +76,7 @@ HEADERS += \ | |||
| src/ui/property_panel_controller.h \ | |||
| src/ui/runtime_panel_controller.h \ | |||
| src/domain/register_address.h \ | |||
| src/domain/register_value_type.h \ | |||
| src/domain/register_repository.h \ | |||
| src/domain/virtual_register_repository.h \ | |||
| src/domain/active_register_repository.h \ | |||
| @@ -32,3 +32,16 @@ RegisterWriteResult ActiveRegisterRepository::writeWord( | |||
| { | |||
| return repository_->writeWord(address, value); | |||
| } | |||
| WordPairReadResult ActiveRegisterRepository::readWordPair( | |||
| const RegisterAddress &address) const | |||
| { | |||
| return repository_->readWordPair(address); | |||
| } | |||
| RegisterWriteResult ActiveRegisterRepository::writeWordPair( | |||
| const RegisterAddress &address, | |||
| const std::array<std::int16_t, 2> &values) | |||
| { | |||
| return repository_->writeWordPair(address, values); | |||
| } | |||
| @@ -15,6 +15,10 @@ 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( | |||
| const RegisterAddress &address, | |||
| const std::array<std::int16_t, 2> &values) override; | |||
| private: | |||
| RegisterRepository *repository_ = nullptr; | |||
| @@ -171,6 +171,20 @@ bool HmiControl::validate(std::string *error) const | |||
| setError(error, "HMI 控件绑定了无效地址"); | |||
| return false; | |||
| } | |||
| const bool numeric = type == HmiControlType::NumericDisplay | |||
| || type == HmiControlType::NumericInput; | |||
| if (!numeric && dataType != RegisterDataType::Int16) | |||
| { | |||
| setError(error, "只有 HMI 数值控件可以选择 Float32"); | |||
| return false; | |||
| } | |||
| if (numeric && dataType == RegisterDataType::Float32 | |||
| && binding.has_value() | |||
| && binding->index() >= RegisterAddress::kMaximumIndex) | |||
| { | |||
| setError(error, "Float32 起始地址必须位于 D0~D3999"); | |||
| return false; | |||
| } | |||
| if (type == HmiControlType::PageJump) | |||
| { | |||
| if (!pageJump.has_value()) | |||
| @@ -8,6 +8,7 @@ | |||
| #pragma once | |||
| #include "register_address.h" | |||
| #include "register_value_type.h" | |||
| #include "project_limits.h" | |||
| #include <map> | |||
| @@ -86,6 +87,7 @@ struct HmiControl | |||
| HmiRect bounds; | |||
| std::string text; | |||
| std::optional<RegisterAddress> binding; | |||
| RegisterDataType dataType = RegisterDataType::Int16; | |||
| std::map<std::string, std::string> properties; | |||
| HmiButtonOperation buttonOperation = HmiButtonOperation::MomentaryOn; | |||
| std::optional<HmiPageJumpConfig> pageJump; | |||
| @@ -4,6 +4,8 @@ | |||
| #include <algorithm> | |||
| #include <cctype> | |||
| #include <variant> | |||
| #include <type_traits> | |||
| namespace { | |||
| @@ -70,6 +72,51 @@ bool containsLineBreak(const std::string &value) | |||
| || value.find('\n') != std::string::npos; | |||
| } | |||
| struct HmiDataBinding | |||
| { | |||
| RegisterAddress address{RegisterArea::D, 0}; | |||
| RegisterDataType type = RegisterDataType::Int16; | |||
| std::string controlId; | |||
| }; | |||
| bool isNumericControl(const HmiControl &control) | |||
| { | |||
| return control.type == HmiControlType::NumericDisplay | |||
| || control.type == HmiControlType::NumericInput; | |||
| } | |||
| bool rangesIntersect(const HmiDataBinding &left, const HmiDataBinding &right) | |||
| { | |||
| const int leftEnd = left.address.index() | |||
| + registerDataTypeWordCount(left.type) - 1; | |||
| const int rightEnd = right.address.index() | |||
| + registerDataTypeWordCount(right.type) - 1; | |||
| return left.address.area() == right.address.area() | |||
| && left.address.index() <= rightEnd | |||
| && right.address.index() <= leftEnd; | |||
| } | |||
| bool isFloatWriteDestination(const LogicNodeConfig &config, RegisterAddress *address) | |||
| { | |||
| if (address == nullptr) | |||
| { | |||
| return false; | |||
| } | |||
| return std::visit( | |||
| [address](const auto &value) | |||
| { | |||
| using Config = std::decay_t<decltype(value)>; | |||
| if constexpr (std::is_same_v<Config, MoveNodeConfig> | |||
| || std::is_same_v<Config, ArithmeticNodeConfig>) | |||
| { | |||
| *address = value.destination; | |||
| return true; | |||
| } | |||
| return false; | |||
| }, | |||
| config); | |||
| } | |||
| } // namespace | |||
| bool RegisterComment::validate(std::string *error) const | |||
| @@ -290,6 +337,34 @@ bool Project::validate( | |||
| } | |||
| } | |||
| } | |||
| std::vector<HmiDataBinding> hmi_bindings; | |||
| for (const HmiPage &page : hmiPages) | |||
| { | |||
| for (const HmiControl &control : page.controls) | |||
| { | |||
| if (isNumericControl(control) && control.binding.has_value() | |||
| && control.binding->area() == RegisterArea::D) | |||
| { | |||
| hmi_bindings.push_back({*control.binding, control.dataType, control.id}); | |||
| } | |||
| } | |||
| } | |||
| for (auto current = hmi_bindings.cbegin(); current != hmi_bindings.cend(); ++current) | |||
| { | |||
| for (auto other = current + 1; other != hmi_bindings.cend(); ++other) | |||
| { | |||
| const bool samePoint = current->address == other->address | |||
| && current->type == other->type; | |||
| if (rangesIntersect(*current, *other) && !samePoint) | |||
| { | |||
| setError( | |||
| error, | |||
| "HMI 数值控件 " + current->controlId + " 与 " | |||
| + other->controlId + " 的 D 占用范围重叠或类型冲突"); | |||
| return false; | |||
| } | |||
| } | |||
| } | |||
| for (const ControlLogic &logic : controlLogics) | |||
| { | |||
| if (!logic.validate(limits, error)) | |||
| @@ -307,6 +382,13 @@ bool Project::validate( | |||
| if (control.binding.has_value()) | |||
| { | |||
| 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}); | |||
| } | |||
| } | |||
| } | |||
| } | |||
| @@ -330,6 +412,26 @@ bool Project::validate( | |||
| for (const LogicNode *node : nodes) | |||
| { | |||
| collectRegisterAddressesForLogicNode(node->config, &poll_addresses); | |||
| RegisterAddress destination{RegisterArea::D, 0}; | |||
| if (node->configured | |||
| && isFloatWriteDestination(node->config, &destination)) | |||
| { | |||
| for (const HmiDataBinding &binding : hmi_bindings) | |||
| { | |||
| if (binding.type == RegisterDataType::Float32 | |||
| && binding.address.area() == RegisterArea::D | |||
| && destination.index() >= binding.address.index() | |||
| && destination.index() | |||
| < binding.address.index() + 2) | |||
| { | |||
| setError( | |||
| error, | |||
| "普通整数指令不能写入 Float32 占用的 D" | |||
| + std::to_string(destination.index())); | |||
| return false; | |||
| } | |||
| } | |||
| } | |||
| } | |||
| } | |||
| } | |||
| @@ -4,37 +4,80 @@ | |||
| const std::vector<RegisterAddress> &RegisterMonitorModel::addresses() const | |||
| { | |||
| addresses_.clear(); | |||
| addresses_.reserve(points_.size()); | |||
| for (const MonitorPoint &point : points_) | |||
| { | |||
| addresses_.push_back(point.address); | |||
| } | |||
| return addresses_; | |||
| } | |||
| bool RegisterMonitorModel::contains(const RegisterAddress &address) const | |||
| const std::vector<MonitorPoint> &RegisterMonitorModel::points() const | |||
| { | |||
| return std::find(addresses_.begin(), addresses_.end(), address) != addresses_.end(); | |||
| return points_; | |||
| } | |||
| bool RegisterMonitorModel::add(const RegisterAddress &address) | |||
| bool RegisterMonitorModel::contains(const MonitorPoint &point) const | |||
| { | |||
| if (!address.isValid() || contains(address) | |||
| || addresses_.size() >= kMaximumItemCount) | |||
| return std::find_if( | |||
| points_.cbegin(), points_.cend(), [&point](const MonitorPoint &candidate) | |||
| { | |||
| return candidate.address == point.address | |||
| && candidate.dataType == point.dataType; | |||
| }) != points_.cend(); | |||
| } | |||
| 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 | |||
| || contains(point) | |||
| || points_.size() >= kMaximumItemCount) | |||
| { | |||
| return false; | |||
| } | |||
| addresses_.push_back(address); | |||
| points_.push_back(point); | |||
| return true; | |||
| } | |||
| bool RegisterMonitorModel::remove(const RegisterAddress &address) | |||
| { | |||
| const auto position = std::find(addresses_.begin(), addresses_.end(), address); | |||
| if (position == addresses_.end()) | |||
| const auto position = std::find_if( | |||
| points_.begin(), points_.end(), [&address](const MonitorPoint &point) | |||
| { | |||
| return point.address == address; | |||
| }); | |||
| if (position == points_.end()) | |||
| { | |||
| return false; | |||
| } | |||
| points_.erase(position); | |||
| return true; | |||
| } | |||
| bool RegisterMonitorModel::remove(const MonitorPoint &point) | |||
| { | |||
| const auto position = std::find_if( | |||
| points_.begin(), points_.end(), [&point](const MonitorPoint &candidate) | |||
| { | |||
| return candidate.address == point.address | |||
| && candidate.dataType == point.dataType; | |||
| }); | |||
| if (position == points_.end()) | |||
| { | |||
| return false; | |||
| } | |||
| addresses_.erase(position); | |||
| points_.erase(position); | |||
| return true; | |||
| } | |||
| void RegisterMonitorModel::clear() | |||
| { | |||
| addresses_.clear(); | |||
| points_.clear(); | |||
| } | |||
| @@ -1,6 +1,7 @@ | |||
| #pragma once | |||
| #include "register_address.h" | |||
| #include "register_value_type.h" | |||
| #include <cstddef> | |||
| #include <cstdint> | |||
| @@ -18,9 +19,18 @@ enum class MonitorValueState | |||
| struct MonitorValue | |||
| { | |||
| RegisterAddress address{RegisterArea::M, 0}; | |||
| RegisterDataType dataType = RegisterDataType::Int16; | |||
| int endAddress = 0; | |||
| MonitorValueState state = MonitorValueState::Unavailable; | |||
| bool bitValue = false; | |||
| std::int16_t wordValue = 0; | |||
| float floatValue = 0.0f; | |||
| }; | |||
| struct MonitorPoint | |||
| { | |||
| RegisterAddress address{RegisterArea::M, 0}; | |||
| RegisterDataType dataType = RegisterDataType::Int16; | |||
| }; | |||
| // 运行期间由用户临时维护的监控地址列表,不写入工程文件 | |||
| @@ -31,15 +41,19 @@ public: | |||
| // 返回当前按添加顺序保存的地址 | |||
| const std::vector<RegisterAddress> &addresses() const; | |||
| const std::vector<MonitorPoint> &points() const; | |||
| // 判断地址是否已经在监控列表中 | |||
| bool contains(const RegisterAddress &address) const; | |||
| bool contains(const MonitorPoint &point) const; | |||
| // 添加一个地址;重复或超过上限时返回 false | |||
| bool add(const RegisterAddress &address); | |||
| bool add(const MonitorPoint &point); | |||
| // 删除一个地址;地址不存在时返回 false | |||
| bool remove(const RegisterAddress &address); | |||
| // 删除一个精确的监控点;地址相同但类型不同的点互不影响 | |||
| bool remove(const MonitorPoint &point); | |||
| // 清空当前会话的监控列表 | |||
| void clear(); | |||
| private: | |||
| std::vector<RegisterAddress> addresses_; | |||
| mutable std::vector<RegisterAddress> addresses_; | |||
| std::vector<MonitorPoint> points_; | |||
| }; | |||
| @@ -3,6 +3,7 @@ | |||
| #include "register_address.h" | |||
| #include <cstdint> | |||
| #include <array> | |||
| // 寄存器仓库操作失败原因;服务层会把它转换成用户可读消息 | |||
| enum class RegisterError | |||
| @@ -37,6 +38,13 @@ struct RegisterWriteResult | |||
| RegisterError error = RegisterError::Unavailable; // 写入失败原因 | |||
| }; | |||
| struct WordPairReadResult | |||
| { | |||
| bool succeeded = false; | |||
| std::array<std::int16_t, 2> values{}; | |||
| RegisterError error = RegisterError::Unavailable; | |||
| }; | |||
| // M/D 寄存器访问边界 | |||
| // 所有运行态读写都必须经过此接口,UI 不直接接触串口或缓存实现 | |||
| class RegisterRepository | |||
| @@ -53,4 +61,33 @@ public: | |||
| // 写入 D 区 16 位字寄存器 | |||
| virtual RegisterWriteResult writeWord( | |||
| const RegisterAddress &address, std::int16_t value) = 0; | |||
| virtual WordPairReadResult readWordPair(const RegisterAddress &address) const | |||
| { | |||
| if (!address.isValid() || address.area() != RegisterArea::D | |||
| || address.index() >= RegisterAddress::kMaximumIndex) | |||
| { | |||
| 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}; | |||
| } | |||
| virtual RegisterWriteResult writeWordPair( | |||
| const RegisterAddress &address, | |||
| const std::array<std::int16_t, 2> &values) | |||
| { | |||
| if (!address.isValid() || address.area() != RegisterArea::D | |||
| || address.index() >= RegisterAddress::kMaximumIndex) | |||
| { | |||
| 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]); | |||
| } | |||
| }; | |||
| @@ -0,0 +1,51 @@ | |||
| #include "register_value_type.h" | |||
| #include <cmath> | |||
| #include <cstring> | |||
| const char *registerDataTypeName(RegisterDataType type) | |||
| { | |||
| return type == RegisterDataType::Float32 ? "float32" : "int16"; | |||
| } | |||
| bool parseRegisterDataType(const std::string &text, RegisterDataType *type) | |||
| { | |||
| if (type == nullptr) | |||
| { | |||
| return false; | |||
| } | |||
| if (text == "int16") | |||
| { | |||
| *type = RegisterDataType::Int16; | |||
| return true; | |||
| } | |||
| if (text == "float32") | |||
| { | |||
| *type = RegisterDataType::Float32; | |||
| return true; | |||
| } | |||
| return false; | |||
| } | |||
| int registerDataTypeWordCount(RegisterDataType type) | |||
| { | |||
| return type == RegisterDataType::Float32 ? 2 : 1; | |||
| } | |||
| std::array<std::int16_t, 2> Float32Codec::encode(float 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::optional<float> Float32Codec::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); | |||
| float value = 0.0f; | |||
| std::memcpy(&value, &bits, sizeof(value)); | |||
| return std::isfinite(value) ? std::optional<float>(value) : std::nullopt; | |||
| } | |||
| @@ -0,0 +1,22 @@ | |||
| #pragma once | |||
| #include <array> | |||
| #include <cstdint> | |||
| #include <optional> | |||
| #include <string> | |||
| enum class RegisterDataType | |||
| { | |||
| Int16, | |||
| Float32 | |||
| }; | |||
| const char *registerDataTypeName(RegisterDataType type); | |||
| bool parseRegisterDataType(const std::string &text, RegisterDataType *type); | |||
| int registerDataTypeWordCount(RegisterDataType type); | |||
| 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); | |||
| }; | |||
| @@ -66,6 +66,19 @@ RegisterWriteResult VirtualRegisterRepository::writeWord( | |||
| return {true, RegisterError::None}; | |||
| } | |||
| WordPairReadResult VirtualRegisterRepository::readWordPair( | |||
| const RegisterAddress &address) const | |||
| { | |||
| return RegisterRepository::readWordPair(address); | |||
| } | |||
| RegisterWriteResult VirtualRegisterRepository::writeWordPair( | |||
| const RegisterAddress &address, | |||
| const std::array<std::int16_t, 2> &values) | |||
| { | |||
| return RegisterRepository::writeWordPair(address, values); | |||
| } | |||
| void VirtualRegisterRepository::clear() | |||
| { | |||
| // 离线运行复位时同时清空 M 区和 D 区 | |||
| @@ -20,6 +20,10 @@ public: | |||
| // 更新内存中的 D 区字值 | |||
| RegisterWriteResult writeWord( | |||
| const RegisterAddress &address, std::int16_t value) override; | |||
| WordPairReadResult readWordPair(const RegisterAddress &address) const override; | |||
| RegisterWriteResult writeWordPair( | |||
| const RegisterAddress &address, | |||
| const std::array<std::int16_t, 2> &values) override; | |||
| // 将所有虚拟寄存器恢复为默认值,开始新的离线会话 | |||
| void clear(); | |||
| @@ -658,6 +658,13 @@ QJsonObject serializeHmiControl(const HmiControl &control) | |||
| object.insert(QStringLiteral("binding"), QJsonValue::Null); | |||
| } | |||
| object.insert(QStringLiteral("properties"), serializeProperties(control.properties)); | |||
| if (control.type == HmiControlType::NumericDisplay | |||
| || control.type == HmiControlType::NumericInput) | |||
| { | |||
| object.insert( | |||
| QStringLiteral("dataType"), | |||
| QString::fromLatin1(registerDataTypeName(control.dataType))); | |||
| } | |||
| if (control.type == HmiControlType::Button) | |||
| { | |||
| object.insert( | |||
| @@ -703,6 +710,18 @@ bool parseHmiControl( | |||
| { | |||
| return false; | |||
| } | |||
| if (control->type == HmiControlType::NumericDisplay | |||
| || control->type == HmiControlType::NumericInput) | |||
| { | |||
| std::string data_type_text; | |||
| if (!readString(object, "dataType", context, &data_type_text, state) | |||
| || !parseRegisterDataType(data_type_text, &control->dataType)) | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| context + ".dataType 必须是 int16 或 float32"); | |||
| } | |||
| } | |||
| if (control->type == HmiControlType::Button) | |||
| { | |||
| std::string operation_text; | |||
| @@ -78,7 +78,25 @@ bool normalizePollAddresses( | |||
| return true; | |||
| } | |||
| std::size_t pollBlockCount(const std::vector<RegisterAddress> &addresses) | |||
| bool isFloat32Start( | |||
| const std::vector<RegisterAddress> &float32_starts, | |||
| RegisterArea area, | |||
| int index) | |||
| { | |||
| return std::binary_search( | |||
| float32_starts.cbegin(), float32_starts.cend(), | |||
| RegisterAddress{area, index}, | |||
| [](const RegisterAddress &left, const RegisterAddress &right) | |||
| { | |||
| return left.area() == right.area() | |||
| ? left.index() < right.index() | |||
| : left.area() == RegisterArea::M; | |||
| }); | |||
| } | |||
| std::size_t pollBlockCount( | |||
| const std::vector<RegisterAddress> &addresses, | |||
| const std::vector<RegisterAddress> &float32_starts) | |||
| { | |||
| if (addresses.empty()) | |||
| { | |||
| @@ -91,6 +109,19 @@ std::size_t pollBlockCount(const std::vector<RegisterAddress> &addresses) | |||
| 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 | |||
| @@ -128,6 +159,11 @@ PlcCommunicationService::PlcCommunicationService( | |||
| [this](const RegisterAddress &address, std::int16_t value) | |||
| { | |||
| return sendWordWrite(address, value); | |||
| }, | |||
| [this](const RegisterAddress &address, | |||
| const std::array<std::int16_t, 2> &values) | |||
| { | |||
| return sendWordPairWrite(address, values); | |||
| }); | |||
| // 轮询定时器每次只推动一个读块,避免一次压入大量异步请求 | |||
| connect(&poll_timer_, &QTimer::timeout, this, &PlcCommunicationService::pollNextBlock); | |||
| @@ -260,6 +296,13 @@ void PlcCommunicationService::disconnectDevice() | |||
| PlcCommunicationResult PlcCommunicationService::setPollAddresses( | |||
| const std::vector<RegisterAddress> &addresses) | |||
| { | |||
| return setPollAddresses(addresses, {}); | |||
| } | |||
| PlcCommunicationResult PlcCommunicationService::setPollAddresses( | |||
| const std::vector<RegisterAddress> &addresses, | |||
| const std::vector<RegisterAddress> &float32_starts) | |||
| { | |||
| std::vector<RegisterAddress> normalized; | |||
| std::string error; | |||
| @@ -267,7 +310,60 @@ PlcCommunicationResult PlcCommunicationService::setPollAddresses( | |||
| { | |||
| return {false, error}; | |||
| } | |||
| if (pollBlockCount(normalized) > ProjectLimits::kMaximumPollBlocks) | |||
| std::vector<RegisterAddress> normalized_float32_starts = float32_starts; | |||
| std::sort( | |||
| normalized_float32_starts.begin(), normalized_float32_starts.end(), | |||
| [](const RegisterAddress &left, const RegisterAddress &right) | |||
| { | |||
| return left.area() == right.area() | |||
| ? left.index() < right.index() | |||
| : left.area() == RegisterArea::M; | |||
| }); | |||
| normalized_float32_starts.erase( | |||
| std::unique( | |||
| normalized_float32_starts.begin(), normalized_float32_starts.end()), | |||
| normalized_float32_starts.end()); | |||
| if (std::any_of( | |||
| normalized_float32_starts.cbegin(), normalized_float32_starts.cend(), | |||
| [](const RegisterAddress &address) | |||
| { | |||
| return !address.isValid() | |||
| || address.area() != RegisterArea::D | |||
| || address.index() >= RegisterAddress::kMaximumIndex; | |||
| })) | |||
| { | |||
| return {false, "Float32 轮询起始地址必须位于 D0~D3999"}; | |||
| } | |||
| for (const RegisterAddress &start : normalized_float32_starts) | |||
| { | |||
| const RegisterAddress high{ | |||
| RegisterArea::D, start.index() + 1}; | |||
| if (std::find(normalized.cbegin(), normalized.cend(), start) | |||
| == normalized.cend()) | |||
| { | |||
| normalized.push_back(start); | |||
| } | |||
| if (std::find(normalized.cbegin(), normalized.cend(), high) | |||
| == normalized.cend()) | |||
| { | |||
| normalized.push_back(high); | |||
| } | |||
| } | |||
| std::sort( | |||
| normalized.begin(), normalized.end(), | |||
| [](const RegisterAddress &left, const RegisterAddress &right) | |||
| { | |||
| return left.area() == right.area() | |||
| ? left.index() < right.index() | |||
| : left.area() == RegisterArea::M; | |||
| }); | |||
| normalized.erase(std::unique(normalized.begin(), normalized.end()), normalized.end()); | |||
| if (normalized.size() > ProjectLimits::kMaximumPollAddresses) | |||
| { | |||
| return {false, "PLC 轮询的去重 M/D 地址最多为 256 个"}; | |||
| } | |||
| if (pollBlockCount(normalized, normalized_float32_starts) | |||
| > ProjectLimits::kMaximumPollBlocks) | |||
| { | |||
| return {false, "PLC 轮询地址拆分后最多允许 8 个读块"}; | |||
| } | |||
| @@ -276,9 +372,11 @@ PlcCommunicationResult PlcCommunicationService::setPollAddresses( | |||
| { | |||
| // 当前回复仍在解析旧集合,等它结束后再切换到新集合 | |||
| pending_poll_addresses_ = std::move(normalized); | |||
| pending_poll_float32_starts_ = std::move(normalized_float32_starts); | |||
| poll_update_pending_ = true; | |||
| return {true, {}}; | |||
| } | |||
| poll_float32_starts_ = std::move(normalized_float32_starts); | |||
| applyPollAddresses(normalized); | |||
| return {true, {}}; | |||
| } | |||
| @@ -344,6 +442,17 @@ 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 单次读取上限 | |||
| poll_blocks_.clear(); | |||
| for (const RegisterAddress &address : addresses) | |||
| @@ -352,6 +461,21 @@ void PlcCommunicationService::rebuildPollBlocks() | |||
| { | |||
| 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 | |||
| @@ -387,6 +511,7 @@ void PlcCommunicationService::applyPollAddresses( | |||
| rebuildPollBlocks(); | |||
| poll_update_pending_ = false; | |||
| pending_poll_addresses_.clear(); | |||
| pending_poll_float32_starts_.clear(); | |||
| if (isReadingState(state_) && pending_reply_ == nullptr) | |||
| { | |||
| pollNextBlock(); | |||
| @@ -459,6 +584,7 @@ void PlcCommunicationService::pollNextBlock() | |||
| if (poll_update_pending_) | |||
| { | |||
| const std::vector<RegisterAddress> addresses = pending_poll_addresses_; | |||
| poll_float32_starts_ = pending_poll_float32_starts_; | |||
| applyPollAddresses(addresses); | |||
| } | |||
| if (poll_cycle_completed) | |||
| @@ -524,6 +650,7 @@ void PlcCommunicationService::probeRecovery() | |||
| if (poll_update_pending_) | |||
| { | |||
| const std::vector<RegisterAddress> addresses = pending_poll_addresses_; | |||
| poll_float32_starts_ = pending_poll_float32_starts_; | |||
| applyPollAddresses(addresses); | |||
| } | |||
| if (state_ != PlcConnectionState::Faulted | |||
| @@ -695,6 +822,53 @@ RegisterWriteResult PlcCommunicationService::sendWordWrite( | |||
| return {true, RegisterError::None}; | |||
| } | |||
| RegisterWriteResult PlcCommunicationService::sendWordPairWrite( | |||
| const RegisterAddress &address, | |||
| const std::array<std::int16_t, 2> &values) | |||
| { | |||
| if (state_ != PlcConnectionState::Connected) | |||
| { | |||
| return {false, RegisterError::Unavailable}; | |||
| } | |||
| if (pending_write_reply_ != nullptr | |||
| || !address.isValid() || address.area() != RegisterArea::D | |||
| || address.index() >= RegisterAddress::kMaximumIndex) | |||
| { | |||
| 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])); | |||
| QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); | |||
| if (reply == nullptr) | |||
| { | |||
| handleModbusError(master_->error()); | |||
| return {false, RegisterError::WriteRejected}; | |||
| } | |||
| pending_write_reply_ = reply; | |||
| connect(reply, &QModbusReply::finished, | |||
| this, | |||
| [this, reply, generation = connection_generation_] | |||
| { | |||
| if (pending_write_reply_ == reply) | |||
| { | |||
| pending_write_reply_ = nullptr; | |||
| } | |||
| if (generation != connection_generation_) | |||
| { | |||
| reply->deleteLater(); | |||
| return; | |||
| } | |||
| if (reply->error() != QModbusDevice::NoError) | |||
| { | |||
| handleModbusError(reply->error()); | |||
| } | |||
| reply->deleteLater(); | |||
| }); | |||
| return {true, RegisterError::None}; | |||
| } | |||
| void PlcCommunicationService::updateInitialReadCompleted( | |||
| bool completed, bool force_notification) | |||
| { | |||
| @@ -8,6 +8,7 @@ | |||
| #include <QTimer> | |||
| #include <cstdint> | |||
| #include <array> | |||
| #include <memory> | |||
| #include <string> | |||
| #include <vector> | |||
| @@ -39,6 +40,9 @@ public: | |||
| // 设置需要周期性读取的 M/D 地址集合 | |||
| PlcCommunicationResult setPollAddresses( | |||
| const std::vector<RegisterAddress> &addresses) override; | |||
| PlcCommunicationResult setPollAddresses( | |||
| const std::vector<RegisterAddress> &addresses, | |||
| const std::vector<RegisterAddress> &float32_starts) override; | |||
| // 返回当前连接状态 | |||
| PlcConnectionState state() const override; | |||
| @@ -97,6 +101,9 @@ private: | |||
| RegisterWriteResult sendBitWrite(const RegisterAddress &address, bool value); | |||
| RegisterWriteResult sendWordWrite( | |||
| const RegisterAddress &address, std::int16_t value); | |||
| RegisterWriteResult sendWordPairWrite( | |||
| const RegisterAddress &address, | |||
| const std::array<std::int16_t, 2> &values); | |||
| // 更新“所有轮询块均成功读取”的真机进入资格 | |||
| void updateInitialReadCompleted(bool completed, bool force_notification = false); | |||
| // 处理没有主动断开时发生的串口断线 | |||
| @@ -117,6 +124,8 @@ 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::size_t next_poll_block_ = 0; // 下一次要读取的读块下标 | |||
| QModbusReply *pending_reply_ = nullptr; // 当前未完成的读请求或恢复探测 | |||
| @@ -69,12 +69,35 @@ RegisterWriteResult PlcRegisterRepository::writeWord( | |||
| : RegisterWriteResult{false, RegisterError::Unavailable}; | |||
| } | |||
| WordPairReadResult PlcRegisterRepository::readWordPair( | |||
| const RegisterAddress &address) const | |||
| { | |||
| return RegisterRepository::readWordPair(address); | |||
| } | |||
| RegisterWriteResult PlcRegisterRepository::writeWordPair( | |||
| const RegisterAddress &address, | |||
| const std::array<std::int16_t, 2> &values) | |||
| { | |||
| if (!address.isValid() || address.area() != RegisterArea::D | |||
| || address.index() >= RegisterAddress::kMaximumIndex) | |||
| { | |||
| 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}; | |||
| } | |||
| 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 &, std::int16_t)> word_handler, | |||
| std::function<RegisterWriteResult( | |||
| const RegisterAddress &, const std::array<std::int16_t, 2> &)> word_pair_handler) | |||
| { | |||
| bit_handler_ = std::move(bit_handler); | |||
| word_handler_ = std::move(word_handler); | |||
| word_pair_handler_ = std::move(word_pair_handler); | |||
| } | |||
| void PlcRegisterRepository::updateBit(int address, bool value) | |||
| @@ -17,11 +17,17 @@ 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( | |||
| const RegisterAddress &address, | |||
| const std::array<std::int16_t, 2> &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 &, std::int16_t)> word_handler, | |||
| std::function<RegisterWriteResult( | |||
| const RegisterAddress &, const std::array<std::int16_t, 2> &)> word_pair_handler = {}); | |||
| // 由通信服务在读回成功后更新缓存并标记地址有效 | |||
| void updateBit(int address, bool value); | |||
| void updateWord(int address, std::int16_t value); | |||
| @@ -40,4 +46,6 @@ private: | |||
| std::array<bool, kRegisterCount> valid_words_{}; // 对应 D 地址是否读到过有效值 | |||
| 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_; | |||
| }; | |||
| @@ -134,6 +134,7 @@ bool HmiEditorService::controlsEqual( | |||
| && left.bounds.height == right.bounds.height | |||
| && left.text == right.text | |||
| && left.binding == right.binding | |||
| && left.dataType == right.dataType | |||
| && left.properties == right.properties | |||
| && left.buttonOperation == right.buttonOperation | |||
| && page_jump_equal; | |||
| @@ -2,11 +2,14 @@ | |||
| #include "domain/hmi_control_registry.h" | |||
| #include <cmath> | |||
| #include <limits> | |||
| namespace { | |||
| HmiRuntimeReadResult readFailure(HmiRuntimeError error) | |||
| { | |||
| return {false, error, false, 0}; | |||
| return {false, error, false, 0, 0.0f}; | |||
| } | |||
| HmiRuntimeWriteResult writeFailure(HmiRuntimeError error) | |||
| @@ -55,16 +58,30 @@ HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) c | |||
| { | |||
| return readFailure(repositoryError(result.error)); | |||
| } | |||
| return {true, HmiRuntimeError::None, result.value, 0}; | |||
| return {true, HmiRuntimeError::None, result.value, 0, 0.0f}; | |||
| } | |||
| case HmiRuntimeValueKind::Word: | |||
| { | |||
| if (control.dataType == RegisterDataType::Float32) | |||
| { | |||
| 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); | |||
| } | |||
| const WordReadResult result = repository_.readWord(*control.binding); | |||
| if (!result.succeeded) | |||
| { | |||
| return readFailure(repositoryError(result.error)); | |||
| } | |||
| return {true, HmiRuntimeError::None, false, result.value}; | |||
| return {true, HmiRuntimeError::None, false, result.value, 0.0f}; | |||
| } | |||
| case HmiRuntimeValueKind::None: | |||
| default: | |||
| @@ -140,7 +157,7 @@ HmiRuntimeWriteResult HmiRuntimeService::operateButton( | |||
| } | |||
| HmiRuntimeWriteResult HmiRuntimeService::writeNumericInput( | |||
| const HmiControl &control, std::int16_t value) | |||
| const HmiControl &control, double value) | |||
| { | |||
| if (control.type != HmiControlType::NumericInput) | |||
| { | |||
| @@ -150,11 +167,40 @@ HmiRuntimeWriteResult HmiRuntimeService::writeNumericInput( | |||
| { | |||
| return writeFailure(HmiRuntimeError::MissingBinding); | |||
| } | |||
| if (!control.binding->isValid()) | |||
| if (!control.binding->isValid() || control.binding->area() != RegisterArea::D) | |||
| { | |||
| return writeFailure(HmiRuntimeError::InvalidBinding); | |||
| } | |||
| const RegisterWriteResult result = repository_.writeWord(*control.binding, value); | |||
| RegisterWriteResult result; | |||
| if (control.dataType == RegisterDataType::Float32) | |||
| { | |||
| 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)); | |||
| } | |||
| else | |||
| { | |||
| 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 result.succeeded | |||
| ? HmiRuntimeWriteResult{true, HmiRuntimeError::None} | |||
| : writeFailure(repositoryError(result.error)); | |||
| @@ -35,6 +35,7 @@ struct HmiRuntimeReadResult | |||
| HmiRuntimeError error = HmiRuntimeError::None; // 失败时的 HMI 错误分类 | |||
| bool bit_value = false; // 位控件的读回值;字控件读取成功时无效 | |||
| std::int16_t word_value = 0; // 字控件的读回值;位控件读取成功时无效 | |||
| float float_value = 0.0f; // Float32 数值控件的读回值 | |||
| }; | |||
| /** | |||
| @@ -98,7 +99,7 @@ public: | |||
| * 该服务只负责提交写入请求,实际读回值由后续仓库轮询确认 | |||
| */ | |||
| HmiRuntimeWriteResult writeNumericInput( | |||
| const HmiControl &control, std::int16_t value); | |||
| const HmiControl &control, double value); | |||
| /** | |||
| * @brief 读取指定 M 位 | |||
| @@ -152,6 +152,22 @@ public: | |||
| virtual PlcCommunicationResult setPollAddresses( | |||
| const std::vector<RegisterAddress> &addresses) = 0; | |||
| /** | |||
| * @brief 设置轮询地址并标记必须成对读取的 Float32 起始地址 | |||
| * @param addresses 需要周期性读回的 M/D 地址集合 | |||
| * @param float32_starts D 区 Float32 起始地址;每个地址必须和下一个 D 一起读取 | |||
| * @return true 表示集合已接受 | |||
| * | |||
| * 默认实现兼容只关心地址集合的测试网关和旧调用方;真实 Modbus 实现会使用成对信息避免拆分 Float32 | |||
| */ | |||
| virtual PlcCommunicationResult setPollAddresses( | |||
| const std::vector<RegisterAddress> &addresses, | |||
| const std::vector<RegisterAddress> &float32_starts) | |||
| { | |||
| (void)float32_starts; | |||
| return setPollAddresses(addresses); | |||
| } | |||
| // 返回当前连接生命周期状态;Connected 不代表首读资格已经完成 | |||
| virtual PlcConnectionState state() const = 0; | |||
| @@ -1,6 +1,8 @@ | |||
| #include "register_monitor_service.h" | |||
| #include <algorithm> | |||
| #include <cmath> | |||
| #include <optional> | |||
| #include <utility> | |||
| namespace { | |||
| @@ -31,6 +33,12 @@ RegisterMonitorService::RegisterMonitorService(RegisterRepository &repository) | |||
| RegisterMonitorResult RegisterMonitorService::addRange( | |||
| const std::string &start_address, int count) | |||
| { | |||
| return addRange(start_address, count, RegisterDataType::Int16); | |||
| } | |||
| RegisterMonitorResult RegisterMonitorService::addRange( | |||
| const std::string &start_address, int count, RegisterDataType data_type) | |||
| { | |||
| const RegisterAddressParseResult parsed = parseRegisterAddress(start_address); | |||
| if (!parsed.succeeded) | |||
| @@ -38,8 +46,15 @@ RegisterMonitorResult RegisterMonitorService::addRange( | |||
| return {false, RegisterMonitorError::InvalidAddress, | |||
| parseErrorMessage(parsed.error), 0}; | |||
| } | |||
| if (parsed.address.area() == RegisterArea::M | |||
| && data_type == RegisterDataType::Float32) | |||
| { | |||
| return {false, RegisterMonitorError::InvalidAddress, | |||
| "Float32 只允许使用 D 区地址", 0}; | |||
| } | |||
| const int step = registerDataTypeWordCount(data_type); | |||
| if (count < 1 | |||
| || parsed.address.index() > RegisterAddress::kMaximumIndex - (count - 1)) | |||
| || parsed.address.index() > RegisterAddress::kMaximumIndex - step * count + 1) | |||
| { | |||
| return {false, RegisterMonitorError::RangeOverflow, | |||
| "连续监控范围不能超过地址 4000", 0}; | |||
| @@ -49,10 +64,13 @@ RegisterMonitorResult RegisterMonitorService::addRange( | |||
| pending.reserve(static_cast<std::size_t>(count)); | |||
| for (int offset = 0; offset < count; ++offset) | |||
| { | |||
| RegisterAddress address{parsed.address.area(), parsed.address.index() + offset}; | |||
| if (!model_.contains(address)) | |||
| MonitorPoint point{ | |||
| RegisterAddress{ | |||
| parsed.address.area(), parsed.address.index() + offset * step}, | |||
| data_type}; | |||
| if (!model_.contains(point)) | |||
| { | |||
| pending.push_back(address); | |||
| pending.push_back(point.address); | |||
| } | |||
| } | |||
| if (pending.empty()) | |||
| @@ -66,13 +84,32 @@ RegisterMonitorResult RegisterMonitorService::addRange( | |||
| } | |||
| for (const RegisterAddress &address : pending) | |||
| { | |||
| model_.add(address); | |||
| model_.add(MonitorPoint{address, data_type}); | |||
| } | |||
| if (addresses_changed_callback_) | |||
| { | |||
| addresses_changed_callback_(); | |||
| } | |||
| return {true, RegisterMonitorError::None, {}, static_cast<int>(pending.size())}; | |||
| bool overlaps = false; | |||
| for (const MonitorPoint &existing : model_.points()) | |||
| { | |||
| const int existing_end = existing.address.index() | |||
| + registerDataTypeWordCount(existing.dataType) - 1; | |||
| for (const RegisterAddress &address : pending) | |||
| { | |||
| const int pending_end = address.index() + step - 1; | |||
| if (existing.address.area() == address.area() | |||
| && existing.address.index() <= pending_end | |||
| && address.index() <= existing_end | |||
| && !(existing.address == address && existing.dataType == data_type)) | |||
| { | |||
| overlaps = true; | |||
| } | |||
| } | |||
| } | |||
| return {true, RegisterMonitorError::None, | |||
| overlaps ? "已添加,但与已有监控点的 D 地址范围重叠" : std::string{}, | |||
| static_cast<int>(pending.size())}; | |||
| } | |||
| RegisterMonitorResult RegisterMonitorService::remove( | |||
| @@ -97,6 +134,28 @@ RegisterMonitorResult RegisterMonitorService::remove( | |||
| return {true, RegisterMonitorError::None, {}, removed_count}; | |||
| } | |||
| RegisterMonitorResult RegisterMonitorService::remove( | |||
| const std::vector<MonitorPoint> &points) | |||
| { | |||
| int removed_count = 0; | |||
| for (const MonitorPoint &point : points) | |||
| { | |||
| if (model_.remove(point)) | |||
| { | |||
| ++removed_count; | |||
| } | |||
| } | |||
| if (removed_count == 0) | |||
| { | |||
| return {false, RegisterMonitorError::NoChange, "请先选择要删除的监控点", 0}; | |||
| } | |||
| if (addresses_changed_callback_) | |||
| { | |||
| addresses_changed_callback_(); | |||
| } | |||
| return {true, RegisterMonitorError::None, {}, removed_count}; | |||
| } | |||
| RegisterMonitorResult RegisterMonitorService::clear() | |||
| { | |||
| if (model_.addresses().empty()) | |||
| @@ -146,6 +205,25 @@ RegisterMonitorWriteResult RegisterMonitorService::writeWord( | |||
| : "寄存器写入请求被拒绝"}; | |||
| } | |||
| RegisterMonitorWriteResult RegisterMonitorService::writeFloat( | |||
| const RegisterAddress &address, float value) | |||
| { | |||
| if (!std::isfinite(value) || !address.isValid() | |||
| || address.area() != RegisterArea::D | |||
| || address.index() >= RegisterAddress::kMaximumIndex) | |||
| { | |||
| return {false, RegisterError::InvalidAddress, "Float32 写入值或地址无效"}; | |||
| } | |||
| const RegisterWriteResult result = repository_.writeWordPair( | |||
| address, Float32Codec::encode(value)); | |||
| if (result.succeeded) | |||
| { | |||
| return {true, RegisterError::None, {}}; | |||
| } | |||
| return {false, result.error, result.error == RegisterError::Unavailable | |||
| ? "当前寄存器源不可用" : "寄存器写入请求被拒绝"}; | |||
| } | |||
| const std::vector<RegisterAddress> &RegisterMonitorService::addresses() const | |||
| { | |||
| return model_.addresses(); | |||
| @@ -154,11 +232,15 @@ const std::vector<RegisterAddress> &RegisterMonitorService::addresses() const | |||
| std::vector<MonitorValue> RegisterMonitorService::values(bool communication_fault) const | |||
| { | |||
| std::vector<MonitorValue> result; | |||
| result.reserve(model_.addresses().size()); | |||
| for (const RegisterAddress &address : model_.addresses()) | |||
| result.reserve(model_.points().size()); | |||
| for (const MonitorPoint &point : model_.points()) | |||
| { | |||
| const RegisterAddress &address = point.address; | |||
| MonitorValue value; | |||
| value.address = address; | |||
| value.dataType = point.dataType; | |||
| value.endAddress = address.index() | |||
| + registerDataTypeWordCount(point.dataType) - 1; | |||
| if (address.area() == RegisterArea::M) | |||
| { | |||
| const BitReadResult read = repository_.readBit(address); | |||
| @@ -170,18 +252,65 @@ std::vector<MonitorValue> RegisterMonitorService::values(bool communication_faul | |||
| } | |||
| else | |||
| { | |||
| const WordReadResult read = repository_.readWord(address); | |||
| value.wordValue = read.value; | |||
| value.state = read.succeeded | |||
| if (point.dataType == RegisterDataType::Float32) | |||
| { | |||
| 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; | |||
| } | |||
| else | |||
| { | |||
| const WordReadResult read = repository_.readWord(address); | |||
| value.wordValue = read.value; | |||
| value.state = read.succeeded | |||
| ? (communication_fault ? MonitorValueState::CommunicationFault | |||
| : MonitorValueState::Valid) | |||
| : MonitorValueState::Unavailable; | |||
| } | |||
| } | |||
| result.push_back(value); | |||
| } | |||
| return result; | |||
| } | |||
| const std::vector<MonitorPoint> &RegisterMonitorService::points() const | |||
| { | |||
| return model_.points(); | |||
| } | |||
| std::vector<RegisterAddress> RegisterMonitorService::pollAddresses() const | |||
| { | |||
| std::vector<RegisterAddress> result; | |||
| for (const MonitorPoint &point : model_.points()) | |||
| { | |||
| for (int offset = 0; offset < registerDataTypeWordCount(point.dataType); ++offset) | |||
| { | |||
| result.push_back(RegisterAddress{ | |||
| point.address.area(), point.address.index() + offset}); | |||
| } | |||
| } | |||
| return result; | |||
| } | |||
| std::vector<RegisterAddress> RegisterMonitorService::float32Starts() const | |||
| { | |||
| std::vector<RegisterAddress> result; | |||
| for (const MonitorPoint &point : model_.points()) | |||
| { | |||
| if (point.dataType == RegisterDataType::Float32) | |||
| { | |||
| result.push_back(point.address); | |||
| } | |||
| } | |||
| return result; | |||
| } | |||
| void RegisterMonitorService::setAddressesChangedCallback(std::function<void()> callback) | |||
| { | |||
| addresses_changed_callback_ = std::move(callback); | |||
| @@ -56,12 +56,15 @@ public: | |||
| * @return 去重后实际新增数量;格式、范围、容量或无变化时返回失败 | |||
| */ | |||
| RegisterMonitorResult addRange(const std::string &start_address, int count); | |||
| RegisterMonitorResult addRange( | |||
| const std::string &start_address, int count, RegisterDataType data_type); | |||
| /** | |||
| * @brief 删除一批监控地址 | |||
| * @param addresses 待删除地址;不存在的地址会被忽略 | |||
| * @return 实际删除数量,并在发生变化时通知轮询集合更新 | |||
| */ | |||
| RegisterMonitorResult remove(const std::vector<RegisterAddress> &addresses); | |||
| RegisterMonitorResult remove(const std::vector<MonitorPoint> &points); | |||
| /** | |||
| * @brief 清空当前会话的监控地址列表 | |||
| * @return 实际清除数量;列表本来为空时返回 NoChange | |||
| @@ -82,11 +85,16 @@ public: | |||
| */ | |||
| RegisterMonitorWriteResult writeWord( | |||
| const RegisterAddress &address, std::int16_t value); | |||
| RegisterMonitorWriteResult writeFloat( | |||
| const RegisterAddress &address, float value); | |||
| /** | |||
| * @brief 返回当前会话的监控地址列表 | |||
| * @return 按服务内部顺序保存的只读地址列表 | |||
| */ | |||
| const std::vector<RegisterAddress> &addresses() const; | |||
| const std::vector<MonitorPoint> &points() const; | |||
| std::vector<RegisterAddress> pollAddresses() const; | |||
| std::vector<RegisterAddress> float32Starts() const; | |||
| /** | |||
| * @brief 读取监控列表的当前值并生成 UI 展示模型 | |||
| * @param communication_fault 通信处于故障态时,将成功读回值标记为通信故障 | |||
| @@ -253,8 +253,16 @@ PlcCommunicationResult RuntimeModeService::connectPlc( | |||
| void RuntimeModeService::setMonitorAddresses( | |||
| const std::vector<RegisterAddress> &addresses) | |||
| { | |||
| setMonitorAddresses(addresses, {}); | |||
| } | |||
| void RuntimeModeService::setMonitorAddresses( | |||
| const std::vector<RegisterAddress> &addresses, | |||
| const std::vector<RegisterAddress> &float32_starts) | |||
| { | |||
| monitor_addresses_ = addresses; | |||
| monitor_float32_starts_ = float32_starts; | |||
| refreshPlcPollAddresses(); | |||
| } | |||
| @@ -266,6 +274,7 @@ PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses() | |||
| } | |||
| // 轮询集合来自自由监控、HMI、报警和梯形图引用的并集 | |||
| std::vector<RegisterAddress> addresses = monitor_addresses_; | |||
| std::vector<RegisterAddress> float32_starts = monitor_float32_starts_; | |||
| const Project &project = project_service_.project(); | |||
| for (const HmiPage &page : project.hmiPages) | |||
| { | |||
| @@ -274,6 +283,15 @@ PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses() | |||
| if (control.binding.has_value()) | |||
| { | |||
| 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); | |||
| } | |||
| } | |||
| } | |||
| } | |||
| @@ -321,7 +339,28 @@ PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses() | |||
| { | |||
| return {false, "PLC 轮询的去重 M/D 地址最多为 256 个"}; | |||
| } | |||
| return plc_gateway_->setPollAddresses(addresses); | |||
| 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); | |||
| } | |||
| void RuntimeModeService::disconnectPlc() | |||
| @@ -109,6 +109,9 @@ public: | |||
| * @param addresses 自由监控当前需要读取的 M/D 地址 | |||
| */ | |||
| void setMonitorAddresses(const std::vector<RegisterAddress> &addresses); | |||
| void setMonitorAddresses( | |||
| const std::vector<RegisterAddress> &addresses, | |||
| const std::vector<RegisterAddress> &float32_starts); | |||
| /** | |||
| * @brief 汇总工程和自由监控引用并刷新 PLC 轮询地址 | |||
| @@ -146,4 +149,5 @@ 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 起始地址 | |||
| }; | |||
| @@ -13,6 +13,7 @@ | |||
| #include <QTableWidgetItem> | |||
| #include <cstdint> | |||
| #include <cmath> | |||
| #include <set> | |||
| namespace { | |||
| @@ -45,6 +46,8 @@ FreeMonitorWidget::FreeMonitorWidget( | |||
| ui_->monitorTable->horizontalHeader()->setSectionResizeMode(3, QHeaderView::Stretch); | |||
| 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_->monitorTable->verticalHeader()->setVisible(false); | |||
| connect(ui_->addButton, &QPushButton::clicked, this, &FreeMonitorWidget::addAddresses); | |||
| connect(ui_->addressEdit, &QLineEdit::returnPressed, | |||
| @@ -103,10 +106,20 @@ void FreeMonitorWidget::refreshValues( | |||
| ? tr("等待读取") : tr("不可用")); | |||
| continue; | |||
| } | |||
| value_item->setText(value.address.area() == RegisterArea::M | |||
| ? (value.bitValue ? QStringLiteral("ON") | |||
| : QStringLiteral("OFF")) | |||
| : QString::number(value.wordValue)); | |||
| if (value.address.area() == RegisterArea::M) | |||
| { | |||
| 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)); | |||
| } | |||
| state_item->setText(value.state == MonitorValueState::CommunicationFault | |||
| ? tr("通信故障,最后有效值") : tr("有效")); | |||
| } | |||
| @@ -122,9 +135,17 @@ void FreeMonitorWidget::reloadAddresses() | |||
| const RegisterAddress &address = addresses[index]; | |||
| 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("位") : tr("16 位整数"))); | |||
| ? tr("位") | |||
| : point.dataType == RegisterDataType::Float32 | |||
| ? tr("Float32 %1 (%1~%2)") | |||
| .arg(addressText(address), | |||
| addressText(RegisterAddress{ | |||
| RegisterArea::D, | |||
| point.address.index() + 1})) | |||
| : tr("Int16"))); | |||
| ui_->monitorTable->setItem(row, 2, new QTableWidgetItem(QStringLiteral("--"))); | |||
| if (address.area() == RegisterArea::M) | |||
| { | |||
| @@ -136,7 +157,14 @@ void FreeMonitorWidget::reloadAddresses() | |||
| else | |||
| { | |||
| auto *target = new QLineEdit(ui_->monitorTable); | |||
| target->setValidator(new QIntValidator(-32768, 32767, target)); | |||
| if (point.dataType == RegisterDataType::Float32) | |||
| { | |||
| target->setPlaceholderText(tr("小数或科学计数法")); | |||
| } | |||
| else | |||
| { | |||
| target->setValidator(new QIntValidator(-32768, 32767, target)); | |||
| } | |||
| target->setText(QStringLiteral("0")); | |||
| target->setAlignment(Qt::AlignRight | Qt::AlignVCenter); | |||
| ui_->monitorTable->setCellWidget(row, 3, target); | |||
| @@ -162,7 +190,8 @@ void FreeMonitorWidget::writeRow(int row) | |||
| return; | |||
| } | |||
| const RegisterAddress address = service_.addresses().at(static_cast<std::size_t>(row)); | |||
| const MonitorPoint point = service_.points().at(static_cast<std::size_t>(row)); | |||
| const RegisterAddress address = point.address; | |||
| RegisterMonitorWriteResult result; | |||
| if (address.area() == RegisterArea::M) | |||
| { | |||
| @@ -181,14 +210,20 @@ void FreeMonitorWidget::writeRow(int row) | |||
| return; | |||
| } | |||
| bool converted = false; | |||
| const int value = target->text().toInt(&converted); | |||
| if (!converted || value < -32768 || value > 32767) | |||
| const double value = target->text().toDouble(&converted); | |||
| if (!converted || !std::isfinite(value) | |||
| || (point.dataType == RegisterDataType::Int16 | |||
| && (value < -32768 || value > 32767 || std::trunc(value) != value))) | |||
| { | |||
| ui_->monitorTable->item(row, 5)->setText(tr("目标值无效")); | |||
| emit operationMessage(tr("写入 D 区地址失败:请输入 -32768~32767 的整数")); | |||
| emit operationMessage(point.dataType == RegisterDataType::Float32 | |||
| ? tr("写入 D 区地址失败:请输入有限 Float32 数值") | |||
| : tr("写入 D 区地址失败:请输入 -32768~32767 的整数")); | |||
| return; | |||
| } | |||
| result = service_.writeWord(address, static_cast<std::int16_t>(value)); | |||
| result = point.dataType == RegisterDataType::Float32 | |||
| ? service_.writeFloat(address, static_cast<float>(value)) | |||
| : service_.writeWord(address, static_cast<std::int16_t>(value)); | |||
| } | |||
| QTableWidgetItem *state_item = ui_->monitorTable->item(row, 5); | |||
| @@ -225,13 +260,20 @@ void FreeMonitorWidget::addAddresses() | |||
| const QByteArray address = ui_->addressEdit->text().toUtf8(); | |||
| const RegisterMonitorResult result = service_.addRange( | |||
| std::string(address.constData(), static_cast<std::size_t>(address.size())), | |||
| ui_->countSpinBox->value()); | |||
| ui_->countSpinBox->value(), | |||
| static_cast<RegisterDataType>(ui_->dataTypeComboBox->currentData().toInt())); | |||
| if (result.succeeded) | |||
| { | |||
| reloadAddresses(); | |||
| ui_->addressEdit->selectAll(); | |||
| emit monitorAddressesChanged(); | |||
| emit operationMessage(tr("已添加 %1 个监控地址").arg(result.affectedCount)); | |||
| const QString message = fromUtf8(result.message); | |||
| emit operationMessage( | |||
| message.isEmpty() | |||
| ? tr("已添加 %1 个监控点").arg(result.affectedCount) | |||
| : tr("已添加 %1 个监控点;%2") | |||
| .arg(result.affectedCount) | |||
| .arg(message)); | |||
| return; | |||
| } | |||
| handleResult(tr("添加监控地址"), false, result.message); | |||
| @@ -244,16 +286,16 @@ void FreeMonitorWidget::removeSelectedAddresses() | |||
| { | |||
| rows.insert(index.row()); | |||
| } | |||
| std::vector<RegisterAddress> addresses; | |||
| const std::vector<RegisterAddress> &all_addresses = service_.addresses(); | |||
| std::vector<MonitorPoint> points; | |||
| const std::vector<MonitorPoint> &all_points = service_.points(); | |||
| for (int row : rows) | |||
| { | |||
| if (row >= 0 && static_cast<std::size_t>(row) < all_addresses.size()) | |||
| if (row >= 0 && static_cast<std::size_t>(row) < all_points.size()) | |||
| { | |||
| addresses.push_back(all_addresses.at(static_cast<std::size_t>(row))); | |||
| points.push_back(all_points.at(static_cast<std::size_t>(row))); | |||
| } | |||
| } | |||
| const RegisterMonitorResult result = service_.remove(addresses); | |||
| const RegisterMonitorResult result = service_.remove(points); | |||
| if (result.succeeded) | |||
| { | |||
| reloadAddresses(); | |||
| @@ -15,13 +15,15 @@ | |||
| <layout class="QGridLayout" name="commandLayout"> | |||
| <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="countLabel"><property name="text"><string>连续个数</string></property></widget></item> | |||
| <item row="0" column="3"><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="4"><widget class="QPushButton" name="addButton"><property name="text"><string>添加</string></property></widget></item> | |||
| <item row="0" column="5"><spacer name="firstRowSpacer"><property name="orientation"><enum>Qt::Horizontal</enum></property><property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property></spacer></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="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> | |||
| <item row="0" column="7"><spacer name="firstRowSpacer"><property name="orientation"><enum>Qt::Horizontal</enum></property><property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property></spacer></item> | |||
| <item row="1" column="0"><widget class="QToolButton" name="removeButton"><property name="toolTip"><string>删除选中的监控地址</string></property><property name="text"><string>删除</string></property></widget></item> | |||
| <item row="1" column="1"><widget class="QToolButton" name="clearButton"><property name="toolTip"><string>清空监控表</string></property><property name="text"><string>清空</string></property></widget></item> | |||
| <item row="1" column="2" colspan="4"><widget class="QLabel" name="sourceLabel"><property name="text"><string>数据源:未启用</string></property><property name="alignment"><set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set></property></widget></item> | |||
| <item row="1" column="2" colspan="6"><widget class="QLabel" name="sourceLabel"><property name="text"><string>数据源:未启用</string></property><property name="alignment"><set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set></property></widget></item> | |||
| </layout> | |||
| </item> | |||
| <item> | |||
| @@ -417,10 +417,12 @@ public: | |||
| } | |||
| // 缓存运行服务读出的值并触发 Qt 重绘 | |||
| void setRuntimeValue(bool bit_value, std::int16_t word_value, bool available) | |||
| void setRuntimeValue( | |||
| bool bit_value, std::int16_t word_value, float float_value, bool available) | |||
| { | |||
| bit_value_ = bit_value; | |||
| word_value_ = word_value; | |||
| float_value_ = float_value; | |||
| has_runtime_value_ = available; | |||
| update(); | |||
| } | |||
| @@ -795,7 +797,10 @@ private: | |||
| if (control_.type == HmiControlType::NumericDisplay | |||
| || control_.type == HmiControlType::NumericInput) | |||
| { | |||
| return text + QStringLiteral(": ") + QString::number(word_value_); | |||
| const QString value = control_.dataType == RegisterDataType::Float32 | |||
| ? QString::number(float_value_, 'g', 7) | |||
| : QString::number(word_value_); | |||
| return text + QStringLiteral(": ") + value; | |||
| } | |||
| return text; | |||
| } | |||
| @@ -825,6 +830,7 @@ private: | |||
| bool bit_value_ = false; | |||
| // 数值控件读取到的 D 字值 | |||
| std::int16_t word_value_ = 0; | |||
| float float_value_ = 0.0f; | |||
| // 标记当前缓存值是否来自一次成功的运行时读取 | |||
| bool has_runtime_value_ = false; | |||
| }; | |||
| @@ -887,7 +893,7 @@ void HmiEditorWidget::setRuntimeActive(bool active) | |||
| HmiGraphicsItem *control_item = asHmiItem(item); | |||
| if (control_item != nullptr) | |||
| { | |||
| control_item->setRuntimeValue(false, 0, false); | |||
| control_item->setRuntimeValue(false, 0, 0.0f, false); | |||
| control_item->setAlarmRecords({}); | |||
| } | |||
| } | |||
| @@ -1037,7 +1043,8 @@ void HmiEditorWidget::refreshRuntimeValues() | |||
| } | |||
| // 运行值通过服务读取,图元不直接接触寄存器仓库 | |||
| const HmiRuntimeReadResult value = runtime_service_.readControl(*control); | |||
| control_item->setRuntimeValue(value.bit_value, value.word_value, value.succeeded); | |||
| control_item->setRuntimeValue( | |||
| value.bit_value, value.word_value, value.float_value, value.succeeded); | |||
| } | |||
| } | |||
| @@ -1140,22 +1147,39 @@ void HmiEditorWidget::handleNumericInputActivated(const std::string &control_id) | |||
| } | |||
| bool accepted = false; | |||
| const HmiRuntimeReadResult current = runtime_service_.readControl(*control); | |||
| const int initial = current.succeeded ? current.word_value : 0; | |||
| const int value = QInputDialog::getInt( | |||
| this, | |||
| tr("输入数值"), | |||
| QString::fromUtf8(control->text.data(), static_cast<int>(control->text.size())), | |||
| initial, | |||
| std::numeric_limits<std::int16_t>::min(), | |||
| std::numeric_limits<std::int16_t>::max(), | |||
| 1, | |||
| &accepted); | |||
| double value = 0.0; | |||
| if (control->dataType == RegisterDataType::Float32) | |||
| { | |||
| 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); | |||
| } | |||
| else | |||
| { | |||
| value = QInputDialog::getInt( | |||
| this, | |||
| tr("输入 Int16"), | |||
| 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(), | |||
| 1, | |||
| &accepted); | |||
| } | |||
| if (!accepted) | |||
| { | |||
| return; | |||
| } | |||
| const HmiRuntimeWriteResult result = runtime_service_.writeNumericInput( | |||
| *control, static_cast<std::int16_t>(value)); | |||
| *control, value); | |||
| if (!result.succeeded) | |||
| { | |||
| emit editorError(tr("寄存器操作失败")); | |||
| @@ -738,7 +738,18 @@ | |||
| </layout> | |||
| </widget> | |||
| </item> | |||
| <item row="13" column="0" colspan="2"> | |||
| <item row="13" column="0"> | |||
| <widget class="QLabel" name="dataTypeLabel"> | |||
| <property name="text"><string>数值类型</string></property> | |||
| </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> | |||
| </item> | |||
| <item row="14" column="0" colspan="2"> | |||
| <widget class="QPushButton" name="applyPropertiesButton"> | |||
| <property name="text"> | |||
| <string>应用属性</string> | |||
| @@ -108,6 +108,10 @@ void PropertyPanelController::configure() | |||
| 2, static_cast<int>(HmiButtonOperation::Toggle)); | |||
| ui_.buttonOperationComboBox->setItemData( | |||
| 3, static_cast<int>(HmiButtonOperation::MomentaryOn)); | |||
| ui_.dataTypeComboBox->setItemData( | |||
| 0, static_cast<int>(RegisterDataType::Int16)); | |||
| ui_.dataTypeComboBox->setItemData( | |||
| 1, static_cast<int>(RegisterDataType::Float32)); | |||
| for (int index = 0; index < ui_.logicComparisonComboBox->count(); ++index) | |||
| { | |||
| ui_.logicComparisonComboBox->setItemData(index, index); | |||
| @@ -195,6 +199,7 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||
| static_cast<QWidget *>(ui_.bindingIndexSpinBox), | |||
| static_cast<QWidget *>(ui_.targetPageComboBox), | |||
| static_cast<QWidget *>(ui_.buttonOperationComboBox), | |||
| static_cast<QWidget *>(ui_.dataTypeComboBox), | |||
| static_cast<QWidget *>(ui_.textColorEdit), | |||
| static_cast<QWidget *>(ui_.textColorButton), | |||
| static_cast<QWidget *>(ui_.fontSizeSpinBox), | |||
| @@ -217,6 +222,8 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||
| ui_.targetPageComboBox->setVisible(false); | |||
| ui_.buttonOperationLabel->setVisible(false); | |||
| ui_.buttonOperationComboBox->setVisible(false); | |||
| ui_.dataTypeLabel->setVisible(false); | |||
| ui_.dataTypeComboBox->setVisible(false); | |||
| ui_.textColorEdit->clear(); | |||
| ui_.textColorButton->setStyleSheet(QString{}); | |||
| ui_.fontSizeSpinBox->setValue( | |||
| @@ -256,6 +263,14 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||
| ui_.buttonOperationComboBox->findData( | |||
| static_cast<int>(control->buttonOperation))); | |||
| const bool is_numeric = control->type == HmiControlType::NumericDisplay | |||
| || control->type == HmiControlType::NumericInput; | |||
| ui_.dataTypeLabel->setVisible(is_numeric); | |||
| ui_.dataTypeComboBox->setVisible(is_numeric); | |||
| ui_.dataTypeComboBox->setEnabled(is_numeric); | |||
| ui_.dataTypeComboBox->setCurrentIndex( | |||
| ui_.dataTypeComboBox->findData(static_cast<int>(control->dataType))); | |||
| const bool is_page_jump = control->type == HmiControlType::PageJump; | |||
| ui_.targetPageLabel->setVisible(is_page_jump); | |||
| ui_.targetPageComboBox->setVisible(is_page_jump); | |||
| @@ -517,6 +532,8 @@ void PropertyPanelController::applySelectedControlProperties() | |||
| control.bounds.y = ui_.controlYSpinBox->value(); | |||
| control.bounds.width = ui_.controlWidthSpinBox->value(); | |||
| control.bounds.height = ui_.controlHeightSpinBox->value(); | |||
| control.dataType = static_cast<RegisterDataType>( | |||
| ui_.dataTypeComboBox->currentData().toInt()); | |||
| const HmiControlDescriptor *descriptor = | |||
| findHmiControlDescriptor(control.type); | |||
| if (descriptor == nullptr | |||
| @@ -103,7 +103,8 @@ void RuntimePanelController::configure() | |||
| &parent_, [this] | |||
| { | |||
| runtime_mode_service_.setMonitorAddresses( | |||
| register_monitor_service_.addresses()); | |||
| register_monitor_service_.pollAddresses(), | |||
| register_monitor_service_.float32Starts()); | |||
| runtime_monitor_widget_->refreshValues( | |||
| runtime_mode_service_.mode(), | |||
| runtime_mode_service_.plcConnectionState()); | |||
| @@ -10,10 +10,12 @@ | |||
| #include "support/test_support.h" | |||
| #include <algorithm> | |||
| #include <cmath> | |||
| #include <cstdint> | |||
| #include <exception> | |||
| #include <functional> | |||
| #include <iostream> | |||
| #include <limits> | |||
| #include <set> | |||
| #include <stdexcept> | |||
| #include <string> | |||
| @@ -101,6 +103,37 @@ void testRegisterRepositorySeparatesAreas() | |||
| "M address must not be read as a word"); | |||
| } | |||
| void testFloat32CodecAndPairAccess() | |||
| { | |||
| const std::array<float, 4> values = {1.0f, -2.5f, 0.1f, 0.0f}; | |||
| for (const float value : values) | |||
| { | |||
| const std::array<std::int16_t, 2> words = Float32Codec::encode(value); | |||
| const std::optional<float> decoded = Float32Codec::decode(words[0], words[1]); | |||
| require(decoded.has_value() && *decoded == value, | |||
| "Float32 codec must preserve representative finite values"); | |||
| } | |||
| require(!Float32Codec::decode( | |||
| static_cast<std::int16_t>(0), | |||
| static_cast<std::int16_t>(0x7f80)).has_value(), | |||
| "Float32 codec must reject positive infinity"); | |||
| require(!Float32Codec::decode( | |||
| static_cast<std::int16_t>(0), | |||
| static_cast<std::int16_t>(0x7fc0)).has_value(), | |||
| "Float32 codec must reject NaN"); | |||
| VirtualRegisterRepository repository; | |||
| const RegisterAddress d10{RegisterArea::D, 10}; | |||
| require(repository.writeWordPair(d10, Float32Codec::encode(-2.5f)).succeeded, | |||
| "virtual repository must write Float32 low and high words"); | |||
| const WordPairReadResult pair = repository.readWordPair(d10); | |||
| require(pair.succeeded | |||
| && Float32Codec::decode(pair.values[0], pair.values[1]).value() == -2.5f, | |||
| "virtual repository must read Float32 from two consecutive words"); | |||
| require(!repository.writeWordPair({RegisterArea::D, 4000}, {}).succeeded, | |||
| "Float32 write at D4000 must be rejected"); | |||
| } | |||
| void testHmiControlRegistryCompleteness() | |||
| { | |||
| std::set<const HmiControlDescriptor *> descriptors; | |||
| @@ -189,6 +222,55 @@ void testHmiControlRegistryCompleteness() | |||
| Project makeValidProject(); | |||
| void testFloat32HmiBoundaries() | |||
| { | |||
| Project project = makeValidProject(); | |||
| HmiControl display; | |||
| display.id = "float-display"; | |||
| display.type = HmiControlType::NumericDisplay; | |||
| display.text = "Value"; | |||
| display.binding = RegisterAddress{RegisterArea::D, 3999}; | |||
| display.dataType = RegisterDataType::Float32; | |||
| project.hmiPages.front().controls.push_back(display); | |||
| require(project.validate(), | |||
| "Float32 D3999 must be valid and occupy D3999~D4000"); | |||
| project.hmiPages.front().controls.back().binding = | |||
| RegisterAddress{RegisterArea::D, 4000}; | |||
| require(!project.validate(), "Float32 D4000 must be rejected"); | |||
| project = makeValidProject(); | |||
| HmiControl first = display; | |||
| first.binding = RegisterAddress{RegisterArea::D, 10}; | |||
| HmiControl second = first; | |||
| second.id = "float-display-duplicate"; | |||
| project.hmiPages.front().controls.push_back(first); | |||
| project.hmiPages.front().controls.push_back(second); | |||
| require(project.validate(), | |||
| "same Float32 start address and type may be bound more than once"); | |||
| second.dataType = RegisterDataType::Int16; | |||
| project.hmiPages.front().controls.back() = second; | |||
| 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"); | |||
| } | |||
| void testHmiAppearancePropertyBoundaries() | |||
| { | |||
| // 外观属性必须在领域层拒绝格式错误,但不能影响未知扩展属性 | |||
| @@ -860,7 +942,9 @@ int main() | |||
| testRegisterAddressBoundaries(); | |||
| testRegisterAddressParsing(); | |||
| testRegisterRepositorySeparatesAreas(); | |||
| testFloat32CodecAndPairAccess(); | |||
| testHmiControlRegistryCompleteness(); | |||
| testFloat32HmiBoundaries(); | |||
| testHmiAppearancePropertyBoundaries(); | |||
| testLogicNodeConfigurationBoundaries(); | |||
| testEdgeAndCommentBoundaries(); | |||
| @@ -10,6 +10,7 @@ | |||
| #include <exception> | |||
| #include <iostream> | |||
| #include <limits> | |||
| #include <stdexcept> | |||
| #include <string> | |||
| @@ -132,6 +133,18 @@ void testRuntimeUsesRegisterRepository() | |||
| require(numeric_value.succeeded && numeric_value.word_value == -18, | |||
| "numeric display must read D values through the repository"); | |||
| HmiControl float_input = numeric_input; | |||
| float_input.dataType = RegisterDataType::Float32; | |||
| float_input.binding = RegisterAddress{RegisterArea::D, 20}; | |||
| 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, | |||
| "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"); | |||
| } | |||
| void testHistoryAndAtomicBatchDelete() | |||
| @@ -2,10 +2,12 @@ | |||
| DOMAIN_REGISTER_SOURCES = \ | |||
| ../src/domain/register_address.cpp \ | |||
| ../src/domain/register_value_type.cpp \ | |||
| ../src/domain/virtual_register_repository.cpp | |||
| DOMAIN_REGISTER_HEADERS = \ | |||
| ../src/domain/register_address.h \ | |||
| ../src/domain/register_value_type.h \ | |||
| ../src/domain/register_repository.h \ | |||
| ../src/domain/virtual_register_repository.h | |||
| @@ -429,6 +429,7 @@ void testExampleProjectRoundTrip() | |||
| const QString unsupported_hmi_type_path = directory.filePath( | |||
| "unsupported-hmi-type.json"); | |||
| const QString missing_initial_path = directory.filePath("missing-initial.json"); | |||
| const QString missing_data_type_path = directory.filePath("missing-data-type.json"); | |||
| const QString missing_target_path = directory.filePath("missing-target.json"); | |||
| const QString missing_alarms_path = directory.filePath("missing-alarms.json"); | |||
| const QString missing_register_comments_path = directory.filePath( | |||
| @@ -450,6 +451,7 @@ void testExampleProjectRoundTrip() | |||
| require(!saved_json.contains("\"dataPoints\""), | |||
| "saved project must not contain the removed data point model"); | |||
| require(saved_json.contains("\"formatVersion\": \"1.0\"") | |||
| && saved_json.contains("\"dataType\": \"int16\"") | |||
| && saved_json.contains("\"buttonOperation\": \"setOn\"") | |||
| && saved_json.contains("\"initialHmiPageId\": \"main-page\"") | |||
| && saved_json.contains("\"targetPageId\": \"settings-page\"") | |||
| @@ -604,6 +606,26 @@ void testExampleProjectRoundTrip() | |||
| && missing_result.storageError == ProjectStorageError::MissingField, | |||
| "the 1.0 schema must require initialHmiPageId without migration defaults"); | |||
| QJsonObject missing_data_type = QJsonDocument::fromJson(saved_json).object(); | |||
| QJsonArray data_type_pages = missing_data_type.value( | |||
| QStringLiteral("hmiPages")).toArray(); | |||
| QJsonObject data_type_page = data_type_pages.at(0).toObject(); | |||
| QJsonArray data_type_controls = data_type_page.value( | |||
| QStringLiteral("controls")).toArray(); | |||
| QJsonObject numeric_display = data_type_controls.at(2).toObject(); | |||
| numeric_display.remove(QStringLiteral("dataType")); | |||
| data_type_controls.replace(2, numeric_display); | |||
| data_type_page.insert(QStringLiteral("controls"), data_type_controls); | |||
| data_type_pages.replace(0, data_type_page); | |||
| missing_data_type.insert(QStringLiteral("hmiPages"), data_type_pages); | |||
| writeText( | |||
| missing_data_type_path, | |||
| QJsonDocument(missing_data_type).toJson(QJsonDocument::Compact)); | |||
| missing_result = service.load(missing_data_type_path.toStdString()); | |||
| require(!missing_result.succeeded | |||
| && missing_result.storageError == ProjectStorageError::MissingField, | |||
| "numeric controls must require dataType in strict 1.0 JSON"); | |||
| QJsonObject missing_alarms = QJsonDocument::fromJson(saved_json).object(); | |||
| missing_alarms.remove(QStringLiteral("alarmDefinitions")); | |||
| writeText( | |||
| @@ -6,6 +6,8 @@ | |||
| #include "support/test_support.h" | |||
| #include <iostream> | |||
| #include <cmath> | |||
| #include <limits> | |||
| #include <stdexcept> | |||
| #include <string> | |||
| @@ -101,6 +103,45 @@ void testRegisterWrites() | |||
| "monitor word writes must preserve signed 16-bit boundary values"); | |||
| } | |||
| void testFloat32Monitoring() | |||
| { | |||
| VirtualRegisterRepository repository; | |||
| RegisterMonitorService service(repository); | |||
| const RegisterMonitorResult added = service.addRange( | |||
| "D1", 3, RegisterDataType::Float32); | |||
| require(added.succeeded && added.affectedCount == 3 | |||
| && service.points().size() == 3U | |||
| && service.points()[0].address == RegisterAddress{RegisterArea::D, 1} | |||
| && 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 | |||
| && service.pollAddresses().size() == 6U, | |||
| "Float32 monitor points must expose both words for polling"); | |||
| const RegisterMonitorResult duplicate = service.addRange( | |||
| "D1", 1, RegisterDataType::Float32); | |||
| require(!duplicate.succeeded && duplicate.error == RegisterMonitorError::NoChange, | |||
| "identical Float32 monitor points must be rejected"); | |||
| const RegisterMonitorResult overlap = service.addRange( | |||
| "D2", 1, RegisterDataType::Int16); | |||
| require(overlap.succeeded && !overlap.message.empty(), | |||
| "partially overlapping monitor points must be added with a warning"); | |||
| require(service.remove(std::vector<MonitorPoint>{ | |||
| MonitorPoint{{RegisterArea::D, 2}, RegisterDataType::Int16}}).succeeded | |||
| && service.points().size() == 3U, | |||
| "overlapping monitor removal must identify the point type"); | |||
| 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); | |||
| require(values.front().state == MonitorValueState::Valid | |||
| && std::fabs(values.front().floatValue - (-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"); | |||
| } | |||
| } // namespace | |||
| int main() | |||
| @@ -110,6 +151,7 @@ int main() | |||
| testRangeManagement(); | |||
| testSharedActiveRepositoryValues(); | |||
| testRegisterWrites(); | |||
| testFloat32Monitoring(); | |||
| } | |||
| catch (const std::exception &error) | |||
| { | |||