Parcourir la source

feat: 扩展多字数值类型与监控通信

main
suyu il y a 3 semaines
Parent
révision
1f62ec6a2e
38 fichiers modifiés avec 1472 ajouts et 496 suppressions
  1. +6
    -6
      app/src/domain/active_register_repository.cpp
  2. +4
    -3
      app/src/domain/active_register_repository.h
  3. +19
    -5
      app/src/domain/hmi_model.cpp
  4. +13
    -8
      app/src/domain/project_model.cpp
  5. +4
    -5
      app/src/domain/register_monitor_model.cpp
  6. +1
    -2
      app/src/domain/register_monitor_model.h
  7. +56
    -14
      app/src/domain/register_repository.h
  8. +186
    -2
      app/src/domain/register_value_type.cpp
  9. +51
    -1
      app/src/domain/register_value_type.h
  10. +26
    -6
      app/src/domain/virtual_register_repository.cpp
  11. +4
    -3
      app/src/domain/virtual_register_repository.h
  12. +1
    -1
      app/src/infrastructure/json_project_storage.cpp
  13. +108
    -139
      app/src/infrastructure/plc_communication_service.cpp
  14. +16
    -16
      app/src/infrastructure/plc_communication_service.h
  15. +19
    -10
      app/src/infrastructure/plc_register_repository.cpp
  16. +8
    -6
      app/src/infrastructure/plc_register_repository.h
  17. +20
    -42
      app/src/services/hmi_runtime_service.cpp
  18. +2
    -3
      app/src/services/hmi_runtime_service.h
  19. +6
    -5
      app/src/services/plc_communication_gateway.h
  20. +107
    -68
      app/src/services/register_monitor_service.cpp
  21. +18
    -6
      app/src/services/register_monitor_service.h
  22. +26
    -33
      app/src/services/runtime_mode_service.cpp
  23. +5
    -4
      app/src/services/runtime_mode_service.h
  24. +76
    -30
      app/src/ui/free_monitor_widget.cpp
  25. +1
    -1
      app/src/ui/free_monitor_widget.ui
  26. +113
    -31
      app/src/ui/hmi_editor_widget.cpp
  27. +10
    -2
      app/src/ui/main_window.cpp
  28. +6
    -4
      app/src/ui/main_window.ui
  29. +5
    -1
      app/src/ui/property_panel_controller.cpp
  30. +1
    -1
      app/src/ui/runtime_panel_controller.cpp
  31. +150
    -19
      app/tests/domain_tests.cpp
  32. +51
    -5
      app/tests/hmi_editor_service_tests.cpp
  33. +30
    -4
      app/tests/offline_simulation_service_tests.cpp
  34. +67
    -1
      app/tests/plc_runtime_tests.cpp
  35. +64
    -0
      app/tests/project_management_tests.cpp
  36. +87
    -8
      app/tests/register_monitor_service_tests.cpp
  37. +81
    -1
      app/tests/runtime_mode_service_tests.cpp
  38. +24
    -0
      app/tests/runtime_panel_controller_tests.cpp

+ 6
- 6
app/src/domain/active_register_repository.cpp Voir le fichier

@@ -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);
}

+ 4
- 3
app/src/domain/active_register_repository.h Voir le fichier

@@ -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;


+ 19
- 5
app/src/domain/hmi_model.cpp Voir le fichier

@@ -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)


+ 13
- 8
app/src/domain/project_model.cpp Voir le fichier

@@ -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;
}


+ 4
- 5
app/src/domain/register_monitor_model.cpp Voir le fichier

@@ -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)
{


+ 1
- 2
app/src/domain/register_monitor_model.h Voir le fichier

@@ -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


+ 56
- 14
app/src/domain/register_repository.h Voir le fichier

@@ -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]});
}
};

+ 186
- 2
app/src/domain/register_value_type.cpp Voir le fichier

@@ -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 &registerDataTypeDescriptor(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;
}
}

+ 51
- 1
app/src/domain/register_value_type.h Voir le fichier

@@ -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 &registerDataTypeDescriptor(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);

+ 26
- 6
app/src/domain/virtual_register_repository.cpp Voir le fichier

@@ -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()


+ 4
- 3
app/src/domain/virtual_register_repository.h Voir le fichier

@@ -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();


+ 1
- 1
app/src/infrastructure/json_project_storage.cpp Voir le fichier

@@ -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)


+ 108
- 139
app/src/infrastructure/plc_communication_service.cpp Voir le fichier

@@ -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);
}
}
// 按区域和地址排序,方便后面合并连续读块
// 相同区域和编号的地址只保留一个

+ 16
- 16
app/src/infrastructure/plc_communication_service.h Voir le fichier

@@ -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; // 当前未完成的单点写请求


+ 19
- 10
app/src/infrastructure/plc_register_repository.cpp Voir le fichier

@@ -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)


+ 8
- 6
app/src/infrastructure/plc_register_repository.h Voir le fichier

@@ -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_;
};

+ 20
- 42
app/src/services/hmi_runtime_service.cpp Voir le fichier

@@ -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));


+ 2
- 3
app/src/services/hmi_runtime_service.h Voir le fichier

@@ -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}; // 数值控件的类型化读回值
};

/**


+ 6
- 5
app/src/services/plc_communication_gateway.h Voir le fichier

@@ -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);
}



+ 107
- 68
app/src/services/register_monitor_service.cpp Voir le fichier

@@ -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);
}

+ 18
- 6
app/src/services/register_monitor_service.h Voir le fichier

@@ -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_;
};

+ 26
- 33
app/src/services/runtime_mode_service.cpp Voir le fichier

@@ -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()


+ 5
- 4
app/src/services/runtime_mode_service.h Voir le fichier

@@ -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_; // 最近一次运行前梯形图检查结果
};

+ 76
- 30
app/src/ui/free_monitor_widget.cpp Voir le fichier

@@ -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);


+ 1
- 1
app/src/ui/free_monitor_widget.ui Voir le fichier

@@ -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>


+ 113
- 31
app/src/ui/hmi_editor_widget.cpp Voir le fichier

@@ -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 &current_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("寄存器操作失败"));


+ 10
- 2
app/src/ui/main_window.cpp Voir le fichier

@@ -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);


+ 6
- 4
app/src/ui/main_window.ui Voir le fichier

@@ -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">


+ 5
- 1
app/src/ui/property_panel_controller.cpp Voir le fichier

@@ -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);


+ 1
- 1
app/src/ui/runtime_panel_controller.cpp Voir le fichier

@@ -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());


+ 150
- 19
app/tests/domain_tests.cpp Voir le fichier

@@ -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();


+ 51
- 5
app/tests/hmi_editor_service_tests.cpp Voir le fichier

@@ -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");


+ 30
- 4
app/tests/offline_simulation_service_tests.cpp Voir le fichier

@@ -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,


+ 67
- 1
app/tests/plc_runtime_tests.cpp Voir le fichier

@@ -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


+ 64
- 0
app/tests/project_management_tests.cpp Voir le fichier

@@ -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();


+ 87
- 8
app/tests/register_monitor_service_tests.cpp Voir le fichier

@@ -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)
{


+ 81
- 1
app/tests/runtime_mode_service_tests.cpp Voir le fichier

@@ -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)


+ 24
- 0
app/tests/runtime_panel_controller_tests.cpp Voir le fichier

@@ -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();


Chargement…
Annuler
Enregistrer