| @@ -25,10 +25,35 @@ bool requiresWordBinding(HmiControlType type) | |||
| return type == HmiControlType::NumericDisplay || type == HmiControlType::NumericInput; | |||
| } | |||
| bool supportsRegisterBinding(HmiControlType type) | |||
| { | |||
| return requiresBitBinding(type) || requiresWordBinding(type); | |||
| } | |||
| bool isSupportedControlType(HmiControlType type) | |||
| { | |||
| switch (type) | |||
| { | |||
| case HmiControlType::Button: | |||
| case HmiControlType::Indicator: | |||
| case HmiControlType::NumericDisplay: | |||
| case HmiControlType::NumericInput: | |||
| case HmiControlType::Label: | |||
| case HmiControlType::PageJump: | |||
| return true; | |||
| } | |||
| return false; | |||
| } | |||
| } // namespace | |||
| bool HmiControl::validate(std::string *error) const | |||
| { | |||
| if (!isSupportedControlType(type)) | |||
| { | |||
| setError(error, "不支持的 HMI 控件类型"); | |||
| return false; | |||
| } | |||
| if (id.empty()) | |||
| { | |||
| setError(error, "HMI 控件 ID 不能为空"); | |||
| @@ -68,6 +93,24 @@ bool HmiControl::validate(std::string *error) const | |||
| setError(error, "HMI 控件绑定了无效地址"); | |||
| return false; | |||
| } | |||
| if (!supportsRegisterBinding(type) && binding.has_value()) | |||
| { | |||
| setError(error, "文本和页面跳转控件不能绑定寄存器"); | |||
| return false; | |||
| } | |||
| if (type == HmiControlType::PageJump) | |||
| { | |||
| if (!pageJump.has_value()) | |||
| { | |||
| setError(error, "页面跳转控件缺少跳转配置"); | |||
| return false; | |||
| } | |||
| } | |||
| else if (pageJump.has_value()) | |||
| { | |||
| setError(error, "非页面跳转控件不能包含跳转配置"); | |||
| return false; | |||
| } | |||
| return true; | |||
| } | |||
| @@ -77,6 +120,10 @@ bool HmiControl::isConfigured() const | |||
| { | |||
| return true; | |||
| } | |||
| if (type == HmiControlType::PageJump) | |||
| { | |||
| return pageJump.has_value() && !pageJump->targetPageId.empty(); | |||
| } | |||
| if (!binding.has_value() || !binding->isValid()) | |||
| { | |||
| return false; | |||
| @@ -41,7 +41,8 @@ enum class HmiControlType | |||
| Indicator, // 指示灯 | |||
| NumericDisplay, // 数值显示 | |||
| NumericInput, // 数值输入 | |||
| Label // 标签 | |||
| Label, // 标签 | |||
| PageJump // 页面跳转 | |||
| }; | |||
| /** | |||
| @@ -55,6 +56,11 @@ enum class HmiButtonOperation | |||
| MomentaryOn // 按下时写入 1,释放时写入 0 | |||
| }; | |||
| struct HmiPageJumpConfig | |||
| { | |||
| std::string targetPageId; | |||
| }; | |||
| /** | |||
| * @brief 描述一个可保存的 HMI 控件及其显示和寄存器配置 | |||
| * | |||
| @@ -69,6 +75,7 @@ struct HmiControl | |||
| std::optional<RegisterAddress> binding; | |||
| std::map<std::string, std::string> properties; | |||
| HmiButtonOperation buttonOperation = HmiButtonOperation::MomentaryOn; | |||
| std::optional<HmiPageJumpConfig> pageJump; | |||
| /** | |||
| * @brief 校验控件的标识、尺寸、扩展属性和寄存器绑定 | |||
| @@ -33,6 +33,26 @@ bool containsDuplicateId(const std::vector<TItem> &items) | |||
| return false; | |||
| } | |||
| template <typename TItem> | |||
| bool containsDuplicateName(const std::vector<TItem> &items) | |||
| { | |||
| for (auto current = items.cbegin(); current != items.cend(); ++current) | |||
| { | |||
| const auto duplicate = std::find_if( | |||
| current + 1, | |||
| items.cend(), | |||
| [¤t](const TItem &item) | |||
| { | |||
| return item.name == current->name; | |||
| }); | |||
| if (duplicate != items.cend()) | |||
| { | |||
| return true; | |||
| } | |||
| } | |||
| return false; | |||
| } | |||
| } // namespace | |||
| bool Project::validate(std::string *error) const | |||
| @@ -47,11 +67,40 @@ bool Project::validate(std::string *error) const | |||
| setError(error, "工程内的 HMI 页面 ID 必须唯一"); | |||
| return false; | |||
| } | |||
| if (containsDuplicateName(hmiPages)) | |||
| { | |||
| setError(error, "工程内的 HMI 页面名称必须唯一"); | |||
| return false; | |||
| } | |||
| if (hmiPages.empty()) | |||
| { | |||
| if (!initialHmiPageId.empty()) | |||
| { | |||
| setError(error, "空工程不能设置初始 HMI 页面"); | |||
| return false; | |||
| } | |||
| } | |||
| else | |||
| { | |||
| const auto initial_page = std::find_if( | |||
| hmiPages.cbegin(), hmiPages.cend(), | |||
| [this](const HmiPage &page) { return page.id == initialHmiPageId; }); | |||
| if (initial_page == hmiPages.cend()) | |||
| { | |||
| setError(error, "工程初始 HMI 页面不存在"); | |||
| return false; | |||
| } | |||
| } | |||
| if (containsDuplicateId(controlLogics)) | |||
| { | |||
| setError(error, "工程内的控制逻辑 ID 必须唯一"); | |||
| return false; | |||
| } | |||
| if (containsDuplicateName(controlLogics)) | |||
| { | |||
| setError(error, "工程内的控制逻辑名称必须唯一"); | |||
| return false; | |||
| } | |||
| for (const HmiPage &page : hmiPages) | |||
| { | |||
| // 工程聚合校验会向下委托页面和控件的完整规则 | |||
| @@ -59,6 +108,26 @@ bool Project::validate(std::string *error) const | |||
| { | |||
| return false; | |||
| } | |||
| for (const HmiControl &control : page.controls) | |||
| { | |||
| if (control.type != HmiControlType::PageJump | |||
| || !control.pageJump.has_value() | |||
| || control.pageJump->targetPageId.empty()) | |||
| { | |||
| continue; | |||
| } | |||
| const auto target = std::find_if( | |||
| hmiPages.cbegin(), hmiPages.cend(), | |||
| [&control](const HmiPage &candidate) | |||
| { | |||
| return candidate.id == control.pageJump->targetPageId; | |||
| }); | |||
| if (target == hmiPages.cend()) | |||
| { | |||
| setError(error, "页面跳转控件 " + control.id + " 的目标页面不存在"); | |||
| return false; | |||
| } | |||
| } | |||
| } | |||
| for (const ControlLogic &logic : controlLogics) | |||
| { | |||
| @@ -85,7 +154,7 @@ bool Project::validateForRunning(std::string *error) const | |||
| } | |||
| for (const ControlLogic &logic : controlLogics) | |||
| { | |||
| if (!logic.validateForRunning(error)) | |||
| if (logic.enabled && !logic.validateForRunning(error)) | |||
| { | |||
| return false; | |||
| } | |||
| @@ -24,6 +24,8 @@ struct Project | |||
| ProjectMetadata metadata; | |||
| // 工程包含的 HMI 页面集合 | |||
| std::vector<HmiPage> hmiPages; | |||
| // 进入运行态时显示的初始 HMI 页面 | |||
| std::string initialHmiPageId; | |||
| // 工程包含的控制逻辑集合 | |||
| std::vector<ControlLogic> controlLogics; | |||
| @@ -278,6 +278,10 @@ QString hmiControlTypeName(HmiControlType type) | |||
| { | |||
| return QStringLiteral("label"); | |||
| } | |||
| case HmiControlType::PageJump: | |||
| { | |||
| return QStringLiteral("pageJump"); | |||
| } | |||
| default: | |||
| { | |||
| return {}; | |||
| @@ -309,6 +313,10 @@ bool parseHmiControlType( | |||
| { | |||
| *type = HmiControlType::Label; | |||
| } | |||
| else if (value == "pageJump") | |||
| { | |||
| *type = HmiControlType::PageJump; | |||
| } | |||
| else | |||
| { | |||
| return state->fail( | |||
| @@ -476,6 +484,14 @@ QJsonObject serializeHmiControl(const HmiControl &control) | |||
| QStringLiteral("buttonOperation"), | |||
| hmiButtonOperationName(control.buttonOperation)); | |||
| } | |||
| if (control.type == HmiControlType::PageJump) | |||
| { | |||
| object.insert( | |||
| QStringLiteral("targetPageId"), | |||
| fromUtf8(control.pageJump.has_value() | |||
| ? control.pageJump->targetPageId | |||
| : std::string{})); | |||
| } | |||
| return object; | |||
| } | |||
| @@ -520,6 +536,15 @@ bool parseHmiControl( | |||
| return false; | |||
| } | |||
| } | |||
| if (control->type == HmiControlType::PageJump) | |||
| { | |||
| std::string target_page_id; | |||
| if (!readString(object, "targetPageId", context, &target_page_id, state)) | |||
| { | |||
| return false; | |||
| } | |||
| control->pageJump = HmiPageJumpConfig{std::move(target_page_id)}; | |||
| } | |||
| // binding 允许为 null,其余非空值必须是合法的寄存器地址对象 | |||
| if (binding.isNull()) | |||
| @@ -1144,6 +1169,8 @@ QJsonObject serializeProject(const Project &project) | |||
| object.insert(QStringLiteral("id"), fromUtf8(project.metadata.id)); | |||
| object.insert(QStringLiteral("name"), fromUtf8(project.metadata.name)); | |||
| object.insert(QStringLiteral("hmiPages"), pages); | |||
| object.insert(QStringLiteral("initialHmiPageId"), | |||
| fromUtf8(project.initialHmiPageId)); | |||
| object.insert(QStringLiteral("controlLogics"), logics); | |||
| return object; | |||
| } | |||
| @@ -1174,6 +1201,8 @@ bool parseProject( | |||
| if (!readString(object, "id", "project", &project->metadata.id, state) | |||
| || !readString(object, "name", "project", &project->metadata.name, state) | |||
| || !readArray(object, "hmiPages", "project", &pages, state) | |||
| || !readString(object, "initialHmiPageId", "project", | |||
| &project->initialHmiPageId, state) | |||
| || !readArray(object, "controlLogics", "project", &logics, state)) | |||
| { | |||
| return false; | |||
| @@ -115,10 +115,75 @@ Project makeValidProject() | |||
| Project project; | |||
| project.metadata = {"sample-project", "Sample project", "1.0"}; | |||
| project.hmiPages.push_back(page); | |||
| project.initialHmiPageId = page.id; | |||
| project.controlLogics.push_back(logic); | |||
| return project; | |||
| } | |||
| void testMultiPageAndLogicDomainRules() | |||
| { | |||
| Project project = makeValidProject(); | |||
| HmiPage settings; | |||
| settings.id = "settings-page"; | |||
| settings.name = "Settings"; | |||
| project.hmiPages.push_back(settings); | |||
| HmiControl label; | |||
| label.id = "title"; | |||
| label.type = HmiControlType::Label; | |||
| label.text = "Machine"; | |||
| project.hmiPages.front().controls.push_back(label); | |||
| require(project.validate(), "an unbound label must be a valid static control"); | |||
| project.hmiPages.front().controls.back().binding = | |||
| RegisterAddress{RegisterArea::M, 10}; | |||
| require(!project.validate(), "labels must reject register bindings"); | |||
| project = makeValidProject(); | |||
| project.hmiPages.push_back(settings); | |||
| HmiControl jump; | |||
| jump.id = "settings-jump"; | |||
| jump.type = HmiControlType::PageJump; | |||
| jump.text = "Settings"; | |||
| jump.pageJump = HmiPageJumpConfig{settings.id}; | |||
| project.hmiPages.front().controls.push_back(jump); | |||
| require(project.validate() && project.validateForRunning(), | |||
| "a page jump must resolve its target by stable page id"); | |||
| project.hmiPages.front().controls.back().pageJump->targetPageId = "missing"; | |||
| require(!project.validate(), "a page jump must reject a missing target page"); | |||
| project = makeValidProject(); | |||
| project.initialHmiPageId = "missing"; | |||
| require(!project.validate(), "the initial HMI page id must resolve to a page"); | |||
| project = makeValidProject(); | |||
| HmiPage duplicate_name = settings; | |||
| duplicate_name.name = project.hmiPages.front().name; | |||
| project.hmiPages.push_back(duplicate_name); | |||
| require(!project.validate(), "HMI page names must be unique"); | |||
| project = makeValidProject(); | |||
| ControlLogic disabled_draft; | |||
| disabled_draft.id = "draft-logic"; | |||
| disabled_draft.name = "Draft logic"; | |||
| disabled_draft.enabled = false; | |||
| disabled_draft.rungs.push_back( | |||
| {"rung-1", "Draft network", std::nullopt, std::nullopt}); | |||
| project.controlLogics.push_back(disabled_draft); | |||
| require(project.validateForRunning(), | |||
| "a disabled draft logic must not block offline running"); | |||
| project.controlLogics.back().name = project.controlLogics.front().name; | |||
| require(!project.validate(), "control logic names must be unique"); | |||
| project = makeValidProject(); | |||
| project.hmiPages.front().controls.front().type = | |||
| static_cast<HmiControlType>(99); | |||
| project.hmiPages.front().controls.front().binding.reset(); | |||
| require(!project.validate(), "unknown HMI control types must be rejected"); | |||
| } | |||
| void testLogicNodeConfigurationBoundaries() | |||
| { | |||
| // 触点只能绑定 M 区,数值比较只能绑定 D 区 | |||
| @@ -302,6 +367,7 @@ int main() | |||
| testLogicNodeConfigurationBoundaries(); | |||
| testLadderLogicBoundaries(); | |||
| testModelsValidateBindingsAndIdentifiers(); | |||
| testMultiPageAndLogicDomainRules(); | |||
| testRuntimeStateBoundaries(); | |||
| } | |||
| catch (const std::exception &error) | |||
| @@ -3,6 +3,9 @@ | |||
| #include "services/project_service.h" | |||
| #include <QFile> | |||
| #include <QJsonArray> | |||
| #include <QJsonDocument> | |||
| #include <QJsonObject> | |||
| #include <QTemporaryDir> | |||
| #include <exception> | |||
| @@ -56,6 +59,19 @@ Project makeExampleProject() | |||
| target_input.binding = RegisterAddress{RegisterArea::D, 3}; | |||
| target_input.properties.emplace("minimum", "-100"); | |||
| HmiControl title_label; | |||
| title_label.id = "title-label"; | |||
| title_label.type = HmiControlType::Label; | |||
| title_label.bounds = {10, 145, 180, 32}; | |||
| title_label.text = "Production line"; | |||
| HmiControl settings_jump; | |||
| settings_jump.id = "settings-jump"; | |||
| settings_jump.type = HmiControlType::PageJump; | |||
| settings_jump.bounds = {210, 145, 120, 40}; | |||
| settings_jump.text = "Settings"; | |||
| settings_jump.pageJump = HmiPageJumpConfig{"settings-page"}; | |||
| HmiPage page; | |||
| page.id = "main-page"; | |||
| page.name = "Main"; | |||
| @@ -63,6 +79,12 @@ Project makeExampleProject() | |||
| page.controls.push_back(running_indicator); | |||
| page.controls.push_back(temperature_display); | |||
| page.controls.push_back(target_input); | |||
| page.controls.push_back(title_label); | |||
| page.controls.push_back(settings_jump); | |||
| HmiPage settings_page; | |||
| settings_page.id = "settings-page"; | |||
| settings_page.name = "Settings"; | |||
| LogicNode contact; | |||
| contact.id = "start-contact"; | |||
| @@ -115,7 +137,16 @@ Project makeExampleProject() | |||
| Project project; | |||
| project.metadata = {"example-project", "Example project", "1.0"}; | |||
| project.hmiPages.push_back(page); | |||
| project.hmiPages.push_back(settings_page); | |||
| project.initialHmiPageId = page.id; | |||
| project.controlLogics.push_back(logic); | |||
| ControlLogic draft_logic; | |||
| draft_logic.id = "draft-logic"; | |||
| draft_logic.name = "Draft logic"; | |||
| draft_logic.enabled = false; | |||
| draft_logic.rungs.push_back( | |||
| {"rung-1", "Draft network", std::nullopt, std::nullopt}); | |||
| project.controlLogics.push_back(draft_logic); | |||
| return project; | |||
| } | |||
| @@ -172,6 +203,8 @@ void testExampleProjectRoundTrip() | |||
| const QString first_path = directory.filePath("example.json"); | |||
| const QString second_path = directory.filePath("example-copy.json"); | |||
| const QString invalid_operation_path = directory.filePath("invalid-operation.json"); | |||
| const QString missing_initial_path = directory.filePath("missing-initial.json"); | |||
| const QString missing_target_path = directory.filePath("missing-target.json"); | |||
| require(service.saveAs(first_path.toStdString()).succeeded, | |||
| "example project save must succeed"); | |||
| const QByteArray saved_json = readBytes(first_path); | |||
| @@ -183,8 +216,10 @@ void testExampleProjectRoundTrip() | |||
| require(!saved_json.contains("\"dataPoints\""), | |||
| "saved project must not contain the removed data point model"); | |||
| require(saved_json.contains("\"formatVersion\": \"1.0\"") | |||
| && saved_json.contains("\"buttonOperation\": \"setOn\""), | |||
| "version 1.0 projects must persist the button operation"); | |||
| && saved_json.contains("\"buttonOperation\": \"setOn\"") | |||
| && saved_json.contains("\"initialHmiPageId\": \"main-page\"") | |||
| && saved_json.contains("\"targetPageId\": \"settings-page\""), | |||
| "version 1.0 projects must persist the current multi-page schema"); | |||
| require(!saved_json.contains("\"stages\"") | |||
| && !saved_json.contains("\"branches\""), | |||
| "current project format must not contain the removed stage model"); | |||
| @@ -196,9 +231,11 @@ void testExampleProjectRoundTrip() | |||
| const Project &project = service.project(); | |||
| require(project.metadata.id == "example-project", "project id must survive round trip"); | |||
| require(project.hmiPages.size() == 1, "HMI page count must survive round trip"); | |||
| require(project.hmiPages.front().controls.size() == 4, | |||
| "all basic HMI controls must survive round trip"); | |||
| require(project.hmiPages.size() == 2, "HMI page count must survive round trip"); | |||
| require(project.initialHmiPageId == "main-page", | |||
| "the initial HMI page id must survive round trip"); | |||
| require(project.hmiPages.front().controls.size() == 6, | |||
| "register, Label and PageJump controls must survive round trip"); | |||
| require(project.hmiPages.front().controls.front().binding->area() | |||
| == RegisterArea::M, | |||
| "HMI M binding must survive round trip"); | |||
| @@ -213,7 +250,11 @@ void testExampleProjectRoundTrip() | |||
| "numeric display D binding must survive round trip"); | |||
| require(project.hmiPages.front().controls.at(3).bounds.x == 150, | |||
| "numeric input bounds must survive round trip"); | |||
| require(project.controlLogics.size() == 1, | |||
| require(project.hmiPages.front().controls.at(4).type == HmiControlType::Label | |||
| && project.hmiPages.front().controls.at(5).pageJump->targetPageId | |||
| == "settings-page", | |||
| "Label and PageJump typed data must survive round trip"); | |||
| require(project.controlLogics.size() == 2, | |||
| "control logic count must survive round trip"); | |||
| require(!project.controlLogics.front().enabled, | |||
| "control logic enabled state must survive round trip"); | |||
| @@ -244,6 +285,35 @@ void testExampleProjectRoundTrip() | |||
| writeText(invalid_operation_path, invalid_operation); | |||
| require(!service.load(invalid_operation_path.toStdString()).succeeded, | |||
| "unsupported HMI button operations must be rejected"); | |||
| QJsonObject missing_initial = QJsonDocument::fromJson(saved_json).object(); | |||
| missing_initial.remove(QStringLiteral("initialHmiPageId")); | |||
| writeText( | |||
| missing_initial_path, | |||
| QJsonDocument(missing_initial).toJson(QJsonDocument::Compact)); | |||
| ProjectOperationResult missing_result = service.load( | |||
| missing_initial_path.toStdString()); | |||
| require(!missing_result.succeeded | |||
| && missing_result.storageError == ProjectStorageError::MissingField, | |||
| "the 1.0 schema must require initialHmiPageId without migration defaults"); | |||
| QJsonObject missing_target = QJsonDocument::fromJson(saved_json).object(); | |||
| QJsonArray pages = missing_target.value(QStringLiteral("hmiPages")).toArray(); | |||
| QJsonObject main_page = pages.at(0).toObject(); | |||
| QJsonArray controls = main_page.value(QStringLiteral("controls")).toArray(); | |||
| QJsonObject jump = controls.at(5).toObject(); | |||
| jump.remove(QStringLiteral("targetPageId")); | |||
| controls.replace(5, jump); | |||
| main_page.insert(QStringLiteral("controls"), controls); | |||
| pages.replace(0, main_page); | |||
| missing_target.insert(QStringLiteral("hmiPages"), pages); | |||
| writeText( | |||
| missing_target_path, | |||
| QJsonDocument(missing_target).toJson(QJsonDocument::Compact)); | |||
| missing_result = service.load(missing_target_path.toStdString()); | |||
| require(!missing_result.succeeded | |||
| && missing_result.storageError == ProjectStorageError::MissingField, | |||
| "the 1.0 schema must require PageJump targetPageId"); | |||
| } | |||
| void testInvalidFiles() | |||