diff --git a/.github/workflows/ied-simulator-qt.yml b/.github/workflows/ied-simulator-qt.yml index 6b9cbf498..e3f1ea0de 100644 --- a/.github/workflows/ied-simulator-qt.yml +++ b/.github/workflows/ied-simulator-qt.yml @@ -132,18 +132,19 @@ jobs: QT_QPA_PLATFORM: offscreen run: ./build-ied-simulator-qt/ied_product_hardening_qa - - name: IEDScout-style File/Home workflow contract + - name: File/Home workflow contract run: >- python3 apps/ied_simulator/test_file_home_workflow.py apps/ied_simulator/qml/FileWorkspace.qml apps/ied_simulator/qml/Main.qml - - name: IEDScout-style Browser convergence contract + - name: Browser convergence contract run: >- python3 apps/ied_simulator/test_browser_convergence.py apps/ied_simulator/qml/IedBrowserWorkspace.qml apps/ied_simulator/qml/IedBrowserNavigation.qml apps/ied_simulator/qml/BrowserStatusConsole.qml + apps/ied_simulator/qml/BrowserDataModelPane.qml - name: Canonical Browser service reuse contract run: >- diff --git a/apps/ied_simulator/CMakeLists.txt b/apps/ied_simulator/CMakeLists.txt index 688a18167..5de941830 100644 --- a/apps/ied_simulator/CMakeLists.txt +++ b/apps/ied_simulator/CMakeLists.txt @@ -102,6 +102,7 @@ qt_add_qml_module(arstack_ied_simulator qml/IedBrowserWorkspace.qml qml/IedBrowserNavigation.qml qml/BrowserStatusConsole.qml + qml/BrowserDataModelPane.qml qml/BrowserDataSetPane.qml qml/BrowserGoosePane.qml qml/BrowserGlobalDataPane.qml diff --git a/apps/ied_simulator/design-qa.md b/apps/ied_simulator/design-qa.md new file mode 100644 index 000000000..9c3aeec47 --- /dev/null +++ b/apps/ied_simulator/design-qa.md @@ -0,0 +1,38 @@ +# IED Browser workflow visual QA + +Date: 2026-10-14 +Result: PASS + +## Scope + +The Browser presentation was compared against eight user-provided commercial-tool screenshots. The implementation reproduces the useful interaction model and information hierarchy without copying vendor branding, trademarks, icons, or packaged assets. + +## Verified states + +- Browser shell with a compact grouped ribbon for application, endpoint, session, data, and service actions. +- Persistent dark engineering explorer with one active canonical IED context. +- Data Model hierarchy with LD, LN, DO, and DA rows. +- Dense right-pane Data Model projection with bounded direct-child rows, Back navigation, and double-click drill-down using the canonical tree. +- DataSet overview-to-member drill-down with live values resolved from the same canonical model when available. +- Reports inventory with URCB/BRCB identity, domain, logical node, selection, and contextual actions. +- RCB detail as a dense property grid, with explicit engineering/runtime evidence state. +- Received-report rows with bounded expandable value lists. +- GOOSE overview and item drill-down with control-block, addressing, VLAN, timing, and DataSet-member details. +- DataSets, Files, Setting Groups, Global Data, status history, and multi-IED tabs remain reachable. + +## Intentional product differences + +- ARStack identity and the existing teal/graphite design system are retained. +- Multi-IED workspace tabs remain because they are an ARStack capability. +- Unsupported or unavailable live values render as unknown (`—`); the UI does not invent false/default protocol state. +- Engineering-model GOOSE evidence remains distinct from live Ethernet capture and publication. +- Report activation remains gated by a live association; an offline SCL view does not pretend that an RCB is enabled. + +## Evidence reviewed + +- Side-by-side RCB detail comparison: hierarchy, grouped commands, property-grid density, report-data region, and status placement. +- Side-by-side GOOSE detail comparison: explorer drill-down, breadcrumb, attribute table, and DataSet members. +- Native rendered screenshots completed with no changed-pane QML runtime diagnostics on stderr; pre-existing diagnostics in unrelated dialogs/workspaces remain outside this change. +- Final Data Model render retained as local QA evidence outside the repository. +- The 60,106-node large-model gate remained within bounds after the presentation changes. + diff --git a/apps/ied_simulator/qml/BrowserDataModelPane.qml b/apps/ied_simulator/qml/BrowserDataModelPane.qml new file mode 100644 index 000000000..79e2cfae2 --- /dev/null +++ b/apps/ied_simulator/qml/BrowserDataModelPane.qml @@ -0,0 +1,292 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Rectangle { + id: root + + required property var theme + required property var client + required property var modelProvider + + readonly property var treeModel: modelProvider.treeModel + readonly property var currentNode: treeModel.presentationNode + property int highlightedRow: -1 + + color: theme.background + + function text(value) { + return value === undefined || value === null || String(value).length === 0 + ? "—" : String(value) + } + + function drillInto(row) { + if (!treeModel.selectChild(row)) + return + highlightedRow = -1 + if (treeModel.presentationNode.hasChildren !== true + && client.connected && !client.operationBusy + && treeModel.presentationNode.readable === true) + client.readEngineeringSelected() + } + + Connections { + target: root.treeModel + function onSelectionChanged() { root.highlightedRow = -1 } + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 30 + color: theme.surface + border.width: 1 + border.color: theme.lineSoft + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 7 + + Button { + objectName: "browserDataModelBack" + visible: Boolean(root.currentNode.kind) && root.currentNode.kind !== "IED" + implicitWidth: 24 + implicitHeight: 22 + text: "‹" + onClicked: root.treeModel.selectParent() + ToolTip.visible: hovered + ToolTip.text: "Up one model level" + } + + Rectangle { + implicitWidth: Math.max(24, nodeKind.implicitWidth + 10) + implicitHeight: 18 + radius: 2 + color: theme.accent + Label { + id: nodeKind + anchors.centerIn: parent + text: root.currentNode.kind || "MODEL" + color: "#ffffff" + font.pixelSize: 7 + font.weight: Font.Bold + } + } + + Label { + Layout.fillWidth: true + text: root.currentNode.label || "Data Model" + color: theme.text + font.pixelSize: 10 + font.weight: Font.DemiBold + elide: Text.ElideMiddle + } + + Label { + visible: root.treeModel.selectedChildCount > 0 + text: root.treeModel.selectedChildCount + " item(s)" + color: theme.muted + font.pixelSize: 8 + } + } + } + + Rectangle { + visible: root.treeModel.selectedChildCount > 0 + Layout.fillWidth: true + Layout.preferredHeight: 25 + color: theme.chrome + border.width: 1 + border.color: theme.lineSoft + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 8 + Label { + Layout.fillWidth: true + text: "Name" + color: theme.textSoft + font.pixelSize: 8 + font.weight: Font.DemiBold + } + Label { + Layout.preferredWidth: Math.max(180, root.width * 0.42) + text: "Value / description" + color: theme.textSoft + font.pixelSize: 8 + font.weight: Font.DemiBold + } + } + } + + ListView { + id: childList + objectName: "browserDataModelChildren" + visible: root.treeModel.selectedChildCount > 0 + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + reuseItems: true + cacheBuffer: 0 + model: root.treeModel.selectedChildren + ScrollBar.vertical: ScrollBar {} + + delegate: Rectangle { + id: childRow + required property int index + required property var modelData + width: childList.width + height: 25 + color: root.highlightedRow === index ? theme.accent + : childMouse.containsMouse ? theme.accentSoft + : index % 2 ? theme.surfaceSoft : theme.surface + border.width: 1 + border.color: theme.lineSoft + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 7 + anchors.rightMargin: 7 + spacing: 6 + + Label { + Layout.preferredWidth: 10 + text: childRow.modelData.hasChildren === true ? "›" : "" + color: root.highlightedRow === childRow.index ? "#ffffff" : theme.muted + font.pixelSize: 9 + } + Rectangle { + Layout.preferredWidth: 20 + Layout.preferredHeight: 17 + radius: 2 + color: root.highlightedRow === childRow.index ? "#ffffff" : theme.accent + Label { + anchors.centerIn: parent + text: childRow.modelData.kind || "" + color: root.highlightedRow === childRow.index ? theme.accent : "#ffffff" + font.pixelSize: 7 + font.weight: Font.Bold + } + } + Label { + Layout.fillWidth: true + text: childRow.modelData.label || childRow.modelData.reference || "—" + color: root.highlightedRow === childRow.index ? "#ffffff" : theme.text + font.pixelSize: 9 + elide: Text.ElideMiddle + } + Label { + visible: childRow.modelData.functionalConstraint + && String(childRow.modelData.functionalConstraint).length > 0 + text: "[" + childRow.modelData.functionalConstraint + "]" + color: root.highlightedRow === childRow.index ? "#dff7f3" : theme.muted + font.pixelSize: 8 + } + Label { + Layout.preferredWidth: Math.max(180, root.width * 0.42) + text: childRow.modelData.value + || childRow.modelData.description + || (childRow.modelData.childCount > 0 + ? childRow.modelData.childCount + " item(s)" : "—") + color: root.highlightedRow === childRow.index ? "#ffffff" : theme.textSoft + font.pixelSize: 9 + elide: Text.ElideRight + } + } + + MouseArea { + id: childMouse + anchors.fill: parent + hoverEnabled: true + onClicked: root.highlightedRow = childRow.index + onDoubleClicked: root.drillInto(childRow.index) + } + } + } + + ScrollView { + visible: root.treeModel.selectedChildCount === 0 + Layout.fillWidth: true + Layout.fillHeight: true + contentWidth: availableWidth + + ColumnLayout { + width: Math.max(0, parent.width - 24) + x: 12 + spacing: 0 + + Repeater { + model: [ + {label: "Reference", value: root.currentNode.reference}, + {label: "Value", value: root.currentNode.value}, + {label: "Quality", value: root.currentNode.quality}, + {label: "Timestamp", value: root.currentNode.timestamp}, + {label: "Functional constraint", value: root.currentNode.functionalConstraint}, + {label: "MMS type", value: root.currentNode.mmsType}, + {label: "SCL type", value: root.currentNode.sclType}, + {label: "Type evidence", value: root.currentNode.typeStatus} + ] + delegate: Rectangle { + required property int index + required property var modelData + visible: modelData.value !== undefined && modelData.value !== null + && String(modelData.value).length > 0 + Layout.fillWidth: true + Layout.preferredHeight: visible ? 27 : 0 + color: index % 2 ? theme.surfaceSoft : theme.surface + border.width: 1 + border.color: theme.lineSoft + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + Label { + Layout.preferredWidth: Math.max(150, root.width * 0.34) + text: modelData.label + color: theme.muted + font.pixelSize: 8 + } + Label { + Layout.fillWidth: true + text: root.text(modelData.value) + color: theme.text + font.pixelSize: 9 + elide: Text.ElideMiddle + } + } + } + } + + Label { + visible: !root.currentNode.kind + Layout.fillWidth: true + Layout.topMargin: 32 + text: "No canonical model is loaded." + horizontalAlignment: Text.AlignHCenter + color: theme.muted + font.pixelSize: 10 + } + Item { Layout.fillHeight: true } + } + } + + Label { + visible: root.treeModel.selectedChildrenTruncated + Layout.fillWidth: true + Layout.preferredHeight: 24 + text: "Showing the first 4096 direct children. Refine the canonical tree selection to continue." + color: theme.amber + font.pixelSize: 8 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } +} diff --git a/apps/ied_simulator/qml/BrowserDataSetPane.qml b/apps/ied_simulator/qml/BrowserDataSetPane.qml index a818d470b..7f89d6293 100644 --- a/apps/ied_simulator/qml/BrowserDataSetPane.qml +++ b/apps/ied_simulator/qml/BrowserDataSetPane.qml @@ -7,123 +7,86 @@ Rectangle { id: root required property var theme required property var reports - readonly property var selectedDataSet: reports.selectedDataSetIndex >= 0 - && reports.selectedDataSetIndex < reports.dataSets.length - ? reports.dataSets[reports.selectedDataSetIndex] : ({}) - + required property var modelProvider + readonly property bool overview: reports.selectedDataSetIndex < 0 + readonly property var selectedDataSet: !overview && reports.selectedDataSetIndex < reports.dataSets.length ? reports.dataSets[reports.selectedDataSetIndex] : ({}) color: theme.background - function text(value) { - return value === undefined || value === null || String(value).length === 0 ? "—" : String(value) - } + function memberNode(reference) { return modelProvider.treeModel.nodeForReference(reference) } ColumnLayout { - anchors.fill: parent - anchors.margins: 16 - spacing: 10 - - Label { - text: "DataSet" - color: theme.text - font.pixelSize: 15 - font.weight: Font.DemiBold - } - - Label { - Layout.fillWidth: true - text: reports.selectedDataSetIndex >= 0 && reports.selectedDataSetIndex < reports.dataSets.length - ? root.text(reports.dataSets[reports.selectedDataSetIndex].reference) - : "Select a DataSet in the navigation tree." - color: theme.textSoft - font.pixelSize: 10 - wrapMode: Text.WrapAnywhere - } - - RowLayout { - Layout.fillWidth: true - visible: reports.selectedDataSetIndex >= 0 - && reports.selectedDataSetIndex < reports.dataSets.length - Rectangle { - Layout.preferredWidth: 104 - Layout.preferredHeight: 22 - radius: 11 - color: root.selectedDataSet.dynamicOwned === true - ? theme.greenSoft : theme.surface - border.width: 1 - border.color: root.selectedDataSet.dynamicOwned === true - ? theme.green : theme.lineSoft + anchors.fill: parent; spacing: 0 + Rectangle { + Layout.fillWidth: true; Layout.preferredHeight: 30 + color: theme.surface; border.width: 1; border.color: theme.lineSoft + RowLayout { + anchors.fill: parent; anchors.leftMargin: 8; anchors.rightMargin: 8; spacing: 7 + Button { visible: !root.overview; implicitWidth: 24; implicitHeight: 22; text: "‹"; onClicked: reports.selectDataSet(-1) } + Rectangle { + implicitWidth: 24; implicitHeight: 18; radius: 2; color: theme.accent + Label { anchors.centerIn: parent; text: root.overview ? "LD" : "DS"; color: "#ffffff"; font.pixelSize: 7; font.weight: Font.Bold } + } + Label { Layout.fillWidth: true; text: root.overview ? "DataSets" : (root.selectedDataSet.reference || "DataSet"); color: theme.text; font.pixelSize: 10; font.weight: Font.DemiBold; elide: Text.ElideMiddle } Label { - anchors.centerIn: parent - text: root.selectedDataSet.dynamicOwned === true - ? "DYNAMIC OWNED" : "STATIC / READ-ONLY" - color: root.selectedDataSet.dynamicOwned === true - ? theme.green : theme.muted + visible: !root.overview + text: root.selectedDataSet.dynamicOwned === true ? "DYNAMIC OWNED" : "STATIC / READ-ONLY" + color: root.selectedDataSet.dynamicOwned === true ? theme.green : theme.muted font.pixelSize: 7 - font.weight: Font.Bold + font.weight: Font.DemiBold + ToolTip.visible: badgeMouse.containsMouse + ToolTip.text: root.selectedDataSet.dynamicOwned === true + ? "Association-owned DataSet." + : "Membership is immutable for static DataSets." + MouseArea { id: badgeMouse; anchors.fill: parent; hoverEnabled: true } } - } - Label { - Layout.fillWidth: true - text: root.selectedDataSet.dynamicOwned === true - ? "Created and verified on this live association; eligible for owned-only delete." - : "Membership is immutable in Browser authoring." - color: theme.muted - font.pixelSize: 8 - elide: Text.ElideRight + Label { text: root.overview ? reports.dataSets.length + " DataSet(s)" : reports.selectedDataSetMembers.length + " member(s)"; color: theme.muted; font.pixelSize: 8 } } } - - Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: theme.lineSoft } - - RowLayout { - Layout.fillWidth: true - Label { - text: "Ordered members" - color: theme.text - font.pixelSize: 10 - font.weight: Font.DemiBold - } - Item { Layout.fillWidth: true } - Label { - text: String(reports.selectedDataSetMembers.length) - color: theme.muted - font.pixelSize: 9 + Rectangle { + Layout.fillWidth: true; Layout.preferredHeight: 25; color: theme.chrome; border.width: 1; border.color: theme.lineSoft + RowLayout { + anchors.fill: parent; anchors.leftMargin: 8; anchors.rightMargin: 8 + Label { Layout.fillWidth: true; text: "Name"; color: theme.textSoft; font.pixelSize: 8; font.weight: Font.DemiBold } + Label { Layout.preferredWidth: Math.max(180, root.width * 0.42); text: root.overview ? "Scope / members" : "Value"; color: theme.textSoft; font.pixelSize: 8; font.weight: Font.DemiBold } } } - - ListView { - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - reuseItems: true - model: reports.selectedDataSetMembers - spacing: 1 - ScrollBar.vertical: ScrollBar {} - - delegate: Rectangle { - required property int index - required property string modelData - width: ListView.view.width - height: 32 - color: index % 2 ? theme.surfaceSoft : theme.surface - RowLayout { - anchors.fill: parent - anchors.leftMargin: 10 - anchors.rightMargin: 10 - spacing: 8 - Label { - Layout.preferredWidth: 34 - text: String(index + 1) - color: theme.muted - font.pixelSize: 8 + StackLayout { + Layout.fillWidth: true; Layout.fillHeight: true; currentIndex: root.overview ? 0 : 1 + ListView { + id: dataSetList; objectName: "browserDataSetOverview"; clip: true; reuseItems: true; model: reports.dataSets + ScrollBar.vertical: ScrollBar {} + delegate: Rectangle { + id: dataSetRow; required property int index; required property var modelData + width: dataSetList.width; height: 26 + color: dsMouse.containsMouse ? theme.accentSoft : index % 2 ? theme.surfaceSoft : theme.surface + border.width: 1; border.color: theme.lineSoft + RowLayout { + anchors.fill: parent; anchors.leftMargin: 8; anchors.rightMargin: 8; spacing: 6 + Label { text: "›"; color: theme.muted; font.pixelSize: 9 } + Rectangle { Layout.preferredWidth: 20; Layout.preferredHeight: 17; radius: 2; color: theme.accent; Label { anchors.centerIn: parent; text: "DS"; color: "#ffffff"; font.pixelSize: 7; font.weight: Font.Bold } } + Label { Layout.fillWidth: true; text: dataSetRow.modelData.name || dataSetRow.modelData.reference || "DataSet"; color: theme.text; font.pixelSize: 9; elide: Text.ElideMiddle } + Label { Layout.preferredWidth: Math.max(180, root.width * 0.42); text: (dataSetRow.modelData.domain || "") + (dataSetRow.modelData.members ? " · " + dataSetRow.modelData.members.length + " member(s)" : ""); color: theme.textSoft; font.pixelSize: 9; elide: Text.ElideRight } } - Label { - Layout.fillWidth: true - text: modelData - color: theme.textSoft - font.pixelSize: 9 - elide: Text.ElideMiddle + MouseArea { id: dsMouse; anchors.fill: parent; hoverEnabled: true; onDoubleClicked: reports.selectDataSet(dataSetRow.index) } + } + } + ListView { + id: memberList; objectName: "browserDataSetMembers"; clip: true; reuseItems: true; model: reports.selectedDataSetMembers + ScrollBar.vertical: ScrollBar {} + delegate: Rectangle { + id: memberRow; required property int index; required property string modelData + readonly property var resolved: root.memberNode(modelData) + width: memberList.width; height: 26 + color: memberMouse.containsMouse ? theme.accentSoft : index % 2 ? theme.surfaceSoft : theme.surface + border.width: 1; border.color: theme.lineSoft + RowLayout { + anchors.fill: parent; anchors.leftMargin: 8; anchors.rightMargin: 8; spacing: 6 + Rectangle { Layout.preferredWidth: 20; Layout.preferredHeight: 17; radius: 2; color: theme.accent; Label { anchors.centerIn: parent; text: memberRow.resolved.kind || "DA"; color: "#ffffff"; font.pixelSize: 7; font.weight: Font.Bold } } + Label { Layout.fillWidth: true; text: memberRow.modelData; color: theme.text; font.pixelSize: 9; elide: Text.ElideMiddle } + Label { visible: memberRow.resolved.functionalConstraint && String(memberRow.resolved.functionalConstraint).length > 0; text: "[" + memberRow.resolved.functionalConstraint + "]"; color: theme.muted; font.pixelSize: 8 } + Label { Layout.preferredWidth: Math.max(180, root.width * 0.42); text: memberRow.resolved.value || "—"; color: theme.textSoft; font.pixelSize: 9; elide: Text.ElideRight } } + MouseArea { id: memberMouse; anchors.fill: parent; hoverEnabled: true } } } } diff --git a/apps/ied_simulator/qml/BrowserGoosePane.qml b/apps/ied_simulator/qml/BrowserGoosePane.qml index 576387a5e..8ff12d889 100644 --- a/apps/ied_simulator/qml/BrowserGoosePane.qml +++ b/apps/ied_simulator/qml/BrowserGoosePane.qml @@ -13,32 +13,20 @@ Rectangle { property var selectedStream: selectedIndex >= 0 && selectedIndex < visibleStreams.length ? visibleStreams[selectedIndex] : ({}) - onVisibleStreamsChanged: { - if (!visibleStreams.length) - selectedIndex = -1 - else if (selectedIndex < 0 || selectedIndex >= visibleStreams.length) - selectedIndex = 0 - } - color: theme.background function text(value) { return value === undefined || value === null || String(value).length === 0 ? "—" : String(value) } - Connections { - target: context - function onContextChanged() { - root.selectedIndex = root.visibleStreams.length > 0 ? 0 : -1 - } - } - RowLayout { anchors.fill: parent spacing: 0 Rectangle { - Layout.preferredWidth: Math.max(300, root.width * 0.32) + visible: root.selectedIndex < 0 + Layout.preferredWidth: visible ? root.width : 0 + Layout.fillWidth: visible Layout.fillHeight: true color: theme.chrome border.width: 1 @@ -132,6 +120,7 @@ Rectangle { } ScrollView { + visible: root.selectedIndex >= 0 Layout.fillWidth: true Layout.fillHeight: true contentWidth: availableWidth @@ -159,37 +148,83 @@ Rectangle { Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: theme.lineSoft } - GridLayout { + Rectangle { Layout.fillWidth: true - columns: 2 - columnSpacing: 18 - rowSpacing: 8 + implicitHeight: gooseAttributes.implicitHeight + color: theme.surface + border.width: 1 + border.color: theme.line - Label { text: "IED"; color: theme.muted; font.pixelSize: 9 } - Label { text: root.text(root.selectedStream.iedName); color: theme.textSoft; font.pixelSize: 9 } - Label { text: "DataSet"; color: theme.muted; font.pixelSize: 9 } - Label { Layout.fillWidth: true; text: root.text(root.selectedStream.dataSet); color: theme.textSoft; font.pixelSize: 9; elide: Text.ElideMiddle } - Label { text: "ConfRev"; color: theme.muted; font.pixelSize: 9 } - Label { text: root.text(root.selectedStream.confRev); color: theme.textSoft; font.pixelSize: 9 } - Label { text: "APPID"; color: theme.muted; font.pixelSize: 9 } - Label { text: root.text(root.selectedStream.appId); color: theme.textSoft; font.pixelSize: 9 } - Label { text: "Destination MAC"; color: theme.muted; font.pixelSize: 9 } - Label { text: root.text(root.selectedStream.destinationMac); color: theme.textSoft; font.pixelSize: 9 } - Label { text: "VLAN"; color: theme.muted; font.pixelSize: 9 } - Label { - text: root.selectedStream.vlanId === undefined || root.selectedStream.vlanId === null - ? "—" - : String(root.selectedStream.vlanId) + " · priority " + root.text(root.selectedStream.vlanPriority) - color: theme.textSoft - font.pixelSize: 9 - } - Label { text: "Retransmission"; color: theme.muted; font.pixelSize: 9 } - Label { - text: root.selectedIndex >= 0 - ? root.text(root.selectedStream.minTimeMs) + " / " + root.text(root.selectedStream.maxTimeMs) + " ms" - : "—" - color: theme.textSoft - font.pixelSize: 9 + ColumnLayout { + id: gooseAttributes + anchors.left: parent.left + anchors.right: parent.right + spacing: 0 + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 26 + color: theme.surfaceRaised + Label { + anchors.fill: parent + leftPadding: 8 + verticalAlignment: Text.AlignVCenter + text: "▾ Control block attributes" + color: theme.text + font.pixelSize: 8 + font.weight: Font.DemiBold + } + } + Repeater { + model: [ + ["IED", root.selectedStream.iedName], + ["Control block reference", root.selectedStream.reference], + ["Destination MAC address", root.selectedStream.destinationMac], + ["Application ID", root.selectedStream.appId], + ["GOOSE ID", root.selectedStream.goId], + ["DataSet reference", root.selectedStream.dataSet], + ["VLAN ID", root.selectedStream.vlanId], + ["VLAN priority", root.selectedStream.vlanPriority], + ["Configuration revision", root.selectedStream.confRev], + ["Minimum retransmission (ms)", root.selectedStream.minTimeMs], + ["Maximum retransmission (ms)", root.selectedStream.maxTimeMs] + ] + delegate: Rectangle { + required property int index + required property var modelData + Layout.fillWidth: true + implicitHeight: 25 + color: index % 2 ? theme.surfaceSoft : theme.surface + RowLayout { + anchors.fill: parent + spacing: 0 + Label { + Layout.preferredWidth: 190 + Layout.fillHeight: true + leftPadding: 7 + verticalAlignment: Text.AlignVCenter + text: modelData[0] + color: theme.textSoft + font.pixelSize: 8 + } + Rectangle { + Layout.preferredWidth: 1 + Layout.fillHeight: true + color: theme.lineSoft + } + Label { + Layout.fillWidth: true + Layout.fillHeight: true + leftPadding: 7 + rightPadding: 7 + verticalAlignment: Text.AlignVCenter + text: root.text(modelData[1]) + color: theme.text + font.pixelSize: 8 + elide: Text.ElideMiddle + } + } + } + } } } diff --git a/apps/ied_simulator/qml/IedBrowserNavigation.qml b/apps/ied_simulator/qml/IedBrowserNavigation.qml index 2f2d6d509..f7dc2d707 100644 --- a/apps/ied_simulator/qml/IedBrowserNavigation.qml +++ b/apps/ied_simulator/qml/IedBrowserNavigation.qml @@ -15,12 +15,14 @@ Rectangle { property int section: 0 property int globalDataCount: 0 + property int selectedGooseIndex: -1 property string sectionTitle: "Data Model" signal sectionRequested(int section, string title) + signal gooseRequested(int index) - color: theme.chrome + color: theme.navigation border.width: 1 - border.color: theme.lineSoft + border.color: theme.navigationDark function choose(sectionValue, titleValue) { root.sectionTitle = titleValue @@ -45,8 +47,8 @@ Rectangle { property string countText: "" Layout.fillWidth: true implicitHeight: 27 - color: root.section === targetSection ? root.theme.accentSoft - : navMouse.containsMouse ? root.theme.surfaceRaised : "transparent" + color: root.section === targetSection ? root.theme.accent + : navMouse.containsMouse ? "#263833" : "transparent" border.width: root.section === targetSection ? 1 : 0 border.color: root.theme.accent @@ -57,21 +59,21 @@ Rectangle { spacing: 7 Label { text: root.section === nav.targetSection ? "▾" : "›" - color: root.section === nav.targetSection ? root.theme.accent : root.theme.muted + color: root.section === nav.targetSection ? "#ffffff" : root.theme.navigationMuted font.pixelSize: 11 Layout.preferredWidth: 12 } Label { Layout.fillWidth: true text: nav.title - color: root.section === nav.targetSection ? root.theme.text : root.theme.textSoft + color: root.section === nav.targetSection ? "#ffffff" : root.theme.navigationText font.pixelSize: 9 font.weight: root.section === nav.targetSection ? Font.DemiBold : Font.Normal } Label { visible: nav.countText.length > 0 text: nav.countText - color: root.theme.muted + color: root.section === nav.targetSection ? "#dff7f3" : root.theme.navigationMuted font.pixelSize: 8 } } @@ -80,7 +82,13 @@ Rectangle { id: navMouse anchors.fill: parent hoverEnabled: true - onClicked: root.choose(nav.targetSection, nav.title) + onClicked: { + if (nav.targetSection === 5) + root.gooseRequested(-1) + if (nav.targetSection === 1) + reports.selectDataSet(-1) + root.choose(nav.targetSection, nav.title) + } } } @@ -131,7 +139,7 @@ Rectangle { id: iedRoot Layout.fillWidth: true implicitHeight: 39 - color: root.theme.surfaceSoft + color: root.theme.navigationDark border.width: 1 border.color: root.theme.lineSoft @@ -153,7 +161,7 @@ Rectangle { Label { Layout.fillWidth: true text: context.iedName.length ? context.iedName : "IED" - color: root.theme.text + color: root.theme.navigationText font.pixelSize: 9 font.weight: Font.DemiBold elide: Text.ElideRight @@ -163,14 +171,14 @@ Rectangle { text: context.loaded ? context.authority + " · " + (context.endpoint.length ? context.endpoint : session.endpoint) : "No engineering model loaded" - color: root.theme.muted + color: root.theme.navigationMuted font.pixelSize: 7 elide: Text.ElideMiddle } } Label { text: context.online ? "ONLINE" : "OFFLINE" - color: context.online ? root.theme.green : root.theme.muted + color: context.online ? "#82e0ad" : root.theme.navigationMuted font.pixelSize: 7 font.weight: Font.DemiBold } @@ -183,6 +191,65 @@ Rectangle { countText: context.loaded ? String(context.gooseCount) : "" } + Loader { + Layout.fillWidth: true + active: root.section === 5 && context.gooseStreams.length > 0 + visible: active + sourceComponent: Component { + ColumnLayout { + spacing: 0 + Repeater { + model: context.gooseStreams + delegate: Rectangle { + required property int index + required property var modelData + Layout.fillWidth: true + implicitHeight: 32 + color: root.selectedGooseIndex === index + ? "#324842" : gooseMouse.containsMouse ? "#263833" : "transparent" + RowLayout { + anchors.fill: parent + anchors.leftMargin: 28 + anchors.rightMargin: 8 + spacing: 6 + Label { + text: "G" + color: "#69d5cb" + font.pixelSize: 7 + font.weight: Font.Bold + } + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + Label { + Layout.fillWidth: true + text: modelData.name || modelData.reference || "GOOSE" + color: root.theme.navigationText + font.pixelSize: 8 + elide: Text.ElideMiddle + } + Label { + Layout.fillWidth: true + text: (modelData.ldInst || "") + + (modelData.dataSet ? " · " + modelData.dataSet : "") + color: root.theme.navigationMuted + font.pixelSize: 7 + elide: Text.ElideMiddle + } + } + } + MouseArea { + id: gooseMouse + anchors.fill: parent + hoverEnabled: true + onClicked: root.gooseRequested(index) + } + } + } + } + } + } + NavButton { targetSection: 2 title: "Reports" @@ -202,9 +269,9 @@ Rectangle { required property int index required property var modelData Layout.fillWidth: true - implicitHeight: 27 + implicitHeight: 34 color: reports.selectedRcbIndex === index - ? root.theme.surfaceRaised : reportMouse.containsMouse ? root.theme.surface : "transparent" + ? "#324842" : reportMouse.containsMouse ? "#263833" : "transparent" RowLayout { anchors.fill: parent anchors.leftMargin: 28 @@ -212,16 +279,28 @@ Rectangle { spacing: 5 Label { text: modelData.buffered ? "B" : "U" - color: modelData.buffered ? root.theme.amber : root.theme.accent + color: modelData.buffered ? "#f4c96b" : "#69d5cb" font.pixelSize: 7 font.weight: Font.Bold } - Label { + ColumnLayout { Layout.fillWidth: true - text: modelData.reference || "Report" - color: root.theme.textSoft - font.pixelSize: 8 - elide: Text.ElideMiddle + spacing: 0 + Label { + Layout.fillWidth: true + text: modelData.name || modelData.reference || "Report" + color: root.theme.navigationText + font.pixelSize: 8 + elide: Text.ElideMiddle + } + Label { + Layout.fillWidth: true + text: (modelData.domain || "") + + (modelData.logicalNode ? " · " + modelData.logicalNode : "") + color: root.theme.navigationMuted + font.pixelSize: 7 + elide: Text.ElideMiddle + } } } MouseArea { @@ -258,16 +337,16 @@ Rectangle { required property int index required property var modelData Layout.fillWidth: true - implicitHeight: 27 + implicitHeight: 34 color: utilities.selectedSettingGroupIndex === index - ? root.theme.surfaceRaised : sgMouse.containsMouse ? root.theme.surface : "transparent" + ? "#324842" : sgMouse.containsMouse ? "#263833" : "transparent" Label { anchors.fill: parent anchors.leftMargin: 28 anchors.rightMargin: 8 verticalAlignment: Text.AlignVCenter text: modelData.reference || "SGCB" - color: root.theme.textSoft + color: root.theme.navigationText font.pixelSize: 8 elide: Text.ElideMiddle } @@ -312,24 +391,36 @@ Rectangle { required property int index required property var modelData Layout.fillWidth: true - implicitHeight: 27 + implicitHeight: 34 color: reports.selectedDataSetIndex === index - ? root.theme.surfaceRaised : dsMouse.containsMouse ? root.theme.surface : "transparent" + ? "#324842" : dsMouse.containsMouse ? "#263833" : "transparent" RowLayout { anchors.fill: parent anchors.leftMargin: 28 anchors.rightMargin: 8 spacing: 5 - Label { + ColumnLayout { Layout.fillWidth: true - text: modelData.reference || "DataSet" - color: root.theme.textSoft - font.pixelSize: 8 - elide: Text.ElideMiddle + spacing: 0 + Label { + Layout.fillWidth: true + text: modelData.name || modelData.reference || "DataSet" + color: root.theme.navigationText + font.pixelSize: 8 + elide: Text.ElideMiddle + } + Label { + Layout.fillWidth: true + text: (modelData.domain || "") + + (modelData.logicalNode ? " · " + modelData.logicalNode : "") + color: root.theme.navigationMuted + font.pixelSize: 7 + elide: Text.ElideMiddle + } } Label { text: modelData.members ? String(modelData.members.length) : "" - color: root.theme.muted + color: root.theme.navigationMuted font.pixelSize: 7 } } @@ -403,8 +494,8 @@ Rectangle { required property var model width: modelTree.width height: 25 - color: model.selected ? root.theme.accentSoft - : modelMouse.containsMouse ? root.theme.surfaceRaised : "transparent" + color: model.selected ? root.theme.accent + : modelMouse.containsMouse ? "#263833" : "transparent" border.width: model.selected ? 1 : 0 border.color: root.theme.accent @@ -416,27 +507,28 @@ Rectangle { Label { Layout.preferredWidth: 11 text: model.hasChildren ? (model.expanded ? "▾" : "▸") : "" - color: root.theme.muted + color: model.selected ? "#ffffff" : root.theme.navigationMuted font.pixelSize: 9 } Label { Layout.preferredWidth: 22 text: model.kind - color: model.kind === "DA" ? root.theme.accent : root.theme.muted + color: model.selected ? "#ffffff" + : model.kind === "DA" ? "#69d5cb" : root.theme.navigationMuted font.pixelSize: 7 font.weight: Font.DemiBold } Label { Layout.fillWidth: true text: model.label - color: root.theme.textSoft + color: model.selected ? "#ffffff" : root.theme.navigationText font.pixelSize: 8 elide: Text.ElideMiddle } Label { Layout.preferredWidth: 34 text: model.functionalConstraint || "" - color: root.theme.muted + color: model.selected ? "#dff7f3" : root.theme.navigationMuted font.pixelSize: 7 horizontalAlignment: Text.AlignRight } diff --git a/apps/ied_simulator/qml/IedBrowserWorkspace.qml b/apps/ied_simulator/qml/IedBrowserWorkspace.qml index e45370e16..f84408de5 100644 --- a/apps/ied_simulator/qml/IedBrowserWorkspace.qml +++ b/apps/ied_simulator/qml/IedBrowserWorkspace.qml @@ -21,9 +21,68 @@ Item { property int activeSection: 0 property string activeSectionTitle: "Data Model" property var selectedModelNode: context.treeModel.selectedNode + property int selectedGooseIndex: -1 signal watchedDataChanged() + component RibbonAction: Button { + id: action + required property string badge + property bool emphasized: false + + implicitWidth: 66 + implicitHeight: 64 + hoverEnabled: true + leftPadding: 4 + rightPadding: 4 + + contentItem: Column { + spacing: 3 + anchors.centerIn: parent + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + width: 27 + height: 25 + radius: 4 + color: action.enabled + ? (action.emphasized ? root.theme.accent : root.theme.surfaceSoft) + : root.theme.surfaceRaised + border.width: 1 + border.color: action.enabled + ? (action.emphasized ? root.theme.accent : root.theme.line) + : root.theme.lineSoft + Label { + anchors.centerIn: parent + text: action.badge + color: action.enabled + ? (action.emphasized ? "#ffffff" : root.theme.textSoft) + : root.theme.muted + font.pixelSize: action.badge.length > 2 ? 7 : 9 + font.weight: Font.DemiBold + } + } + Label { + anchors.horizontalCenter: parent.horizontalCenter + width: 62 + text: action.text + color: action.enabled ? root.theme.text : root.theme.muted + font.pixelSize: 8 + font.weight: Font.Medium + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + } + + background: Rectangle { + radius: 4 + color: !action.enabled ? "transparent" + : action.pressed ? root.theme.surfaceSoft + : action.hovered ? root.theme.accentSoft : "transparent" + border.width: action.hovered && action.enabled ? 1 : 0 + border.color: root.theme.accent + } + } + function watchedRows() { return globalDataPane.snapshotRows() } function refreshWatchedGlobalData() { return globalDataPane.refreshWatched() } @@ -43,23 +102,23 @@ Item { function breadcrumb() { if (activeSection === 0) { var ref = selectedModelNode.reference || selectedModelNode.label || "" - return ref.length ? root.activeIedLabel() + " / Data Model / " + ref : root.activeIedLabel() + " / Data Model" + return ref.length ? root.activeIedLabel() + " › Data Model › " + ref : root.activeIedLabel() + " › Data Model" } if (activeSection === 1) { if (reports.selectedDataSetIndex >= 0 && reports.selectedDataSetIndex < reports.dataSets.length) - return root.activeIedLabel() + " / DataSets / " + (reports.dataSets[reports.selectedDataSetIndex].reference || "") - return root.activeIedLabel() + " / DataSets" + return root.activeIedLabel() + " › DataSets › " + (reports.dataSets[reports.selectedDataSetIndex].reference || "") + return root.activeIedLabel() + " › DataSets" } if (activeSection === 2) - return reports.selectedRcb.reference ? root.activeIedLabel() + " / Reports / " + reports.selectedRcb.reference : root.activeIedLabel() + " / Reports" + return reports.selectedRcb.reference ? root.activeIedLabel() + " › Reports › " + reports.selectedRcb.reference : root.activeIedLabel() + " › Reports" if (activeSection === 3) return utilities.selectedSettingGroup.reference - ? root.activeIedLabel() + " / Setting Groups / " + utilities.selectedSettingGroup.reference - : root.activeIedLabel() + " / Setting Groups" + ? root.activeIedLabel() + " › Setting Groups › " + utilities.selectedSettingGroup.reference + : root.activeIedLabel() + " › Setting Groups" if (activeSection === 4) - return root.activeIedLabel() + " / Files" + (utilities.currentDirectory.length ? " / " + utilities.currentDirectory : "") - if (activeSection === 5) return root.activeIedLabel() + " / GOOSE" - return root.activeIedLabel() + " / Global Data" + return root.activeIedLabel() + " › Files" + (utilities.currentDirectory.length ? " › " + utilities.currentDirectory : "") + if (activeSection === 5) return root.activeIedLabel() + " › GOOSE" + return root.activeIedLabel() + " › Global Data" } function selectSection(section, title) { @@ -90,6 +149,13 @@ Item { } } + Connections { + target: context + function onContextChanged() { + root.selectedGooseIndex = -1 + } + } + FileDialog { id: browserSclDialog title: "Open IEC 61850 engineering model" @@ -123,154 +189,282 @@ Item { Rectangle { Layout.fillWidth: true - Layout.preferredHeight: 62 + Layout.preferredHeight: 94 color: root.theme.chrome border.width: 1 border.color: root.theme.lineSoft - RowLayout { + Flickable { anchors.fill: parent - anchors.leftMargin: 14 - anchors.rightMargin: 14 - spacing: 8 - - ColumnLayout { - Layout.preferredWidth: 155 - spacing: 1 - Label { - text: "MODEL" - color: root.theme.text - font.pixelSize: 11 - font.weight: Font.DemiBold - } - Label { - text: context.loaded - ? root.activeIedLabel() + " · " + context.authority - : "Open SCL or discover one IED endpoint" - color: root.theme.muted - font.pixelSize: 8 + contentWidth: ribbonRow.width + 16 + contentHeight: height + clip: true + boundsBehavior: Flickable.StopAtBounds + flickableDirection: Flickable.HorizontalFlick + + Row { + id: ribbonRow + x: 8 + height: parent.height + spacing: 7 + + Column { + width: 136 + height: parent.height + Row { + RibbonAction { + badge: "SCL" + text: "Open SCL" + enabled: !session.configurationLocked && !engineering.busy + onClicked: browserSclDialog.open() + ToolTip.visible: hovered + ToolTip.text: "Open SCL/CID/SCD/IID/ICD into the canonical Browser model." + } + RibbonAction { + badge: "↓" + text: engineering.busy ? "Saving…" : "Save SCL" + enabled: engineering.engineeringContextExportSupported && !engineering.busy + onClicked: saveSclDialog.open() + ToolTip.visible: hovered + ToolTip.text: "Export the cached canonical model locally without hidden rediscovery." + } + } + Label { + anchors.horizontalCenter: parent.horizontalCenter + text: "APPLICATION" + color: root.theme.muted + font.pixelSize: 7 + font.weight: Font.DemiBold + } } - } - - Button { - text: "Open SCL" - enabled: !session.configurationLocked && !engineering.busy - onClicked: browserSclDialog.open() - ToolTip.visible: hovered - ToolTip.text: "Open SCL/CID/SCD/IID/ICD into the persistent Browser model." - } - - Button { - text: engineering.busy ? "Saving…" : "Save SCL" - visible: context.loaded && context.authorityKey === "live-discovery" - enabled: engineering.engineeringContextExportSupported - && !engineering.busy - onClicked: saveSclDialog.open() - ToolTip.visible: hovered - ToolTip.text: context.online - ? "Save the cached canonical IED model locally; no rediscovery is performed." - : "Save the persistent canonical IED model while offline." - } - TextField { - id: hostField - Layout.preferredWidth: 205 - text: session.host - placeholderText: "IED IP / hostname" - selectByMouse: true - enabled: !session.configurationLocked - onEditingFinished: session.host = text - } - - SpinBox { - id: portField - Layout.preferredWidth: 102 - from: 1 - to: 65535 - value: session.port - editable: true - enabled: !session.configurationLocked - onValueModified: session.port = value - } - - Button { - text: "Online" - visible: context.loaded - enabled: !session.connected && !session.busy && !context.selectionRequired - onClicked: { - session.host = hostField.text - session.port = portField.value - session.connectUsingEngineeringContext() + Rectangle { width: 1; height: 78; y: 5; color: root.theme.lineSoft } + + Column { + width: 262 + height: parent.height + spacing: 4 + Row { + spacing: 5 + TextField { + id: hostField + width: 172 + height: 29 + text: session.host + placeholderText: "IED IP / hostname" + selectByMouse: true + enabled: !session.configurationLocked + font.pixelSize: 9 + onEditingFinished: session.host = text + } + SpinBox { + id: portField + width: 80 + height: 29 + from: 1 + to: 65535 + value: session.port + editable: true + enabled: !session.configurationLocked + onValueModified: session.port = value + } + } + Row { + spacing: 6 + Rectangle { + width: 8; height: 8; radius: 4 + anchors.verticalCenter: parent.verticalCenter + color: session.lastError.length ? root.theme.red + : session.connected ? root.theme.green + : session.busy ? root.theme.amber + : root.theme.muted + } + Label { + width: 244 + text: session.stateText + " · " + session.endpoint + color: session.lastError.length ? root.theme.red : root.theme.textSoft + font.pixelSize: 8 + font.weight: Font.Medium + elide: Text.ElideRight + } + } + Label { + anchors.horizontalCenter: parent.horizontalCenter + text: "ENDPOINT" + color: root.theme.muted + font.pixelSize: 7 + font.weight: Font.DemiBold + } } - ToolTip.visible: hovered - ToolTip.text: context.authorityKey === "scl" - ? "Connect using the active trusted SCL model." - : "Reconnect/discover the active IED endpoint." - } - Button { - text: session.busy && !session.connected ? "Discovering…" : "Discover IED" - enabled: !session.connected && !session.busy - onClicked: { - session.host = hostField.text - session.port = portField.value - session.discoverAndConnect() + Rectangle { width: 1; height: 78; y: 5; color: root.theme.lineSoft } + + Column { + width: 200 + height: parent.height + Row { + RibbonAction { + badge: "ON" + text: "Online" + emphasized: session.connected + enabled: context.loaded && !session.connected && !session.busy && !context.selectionRequired + onClicked: { + session.host = hostField.text + session.port = portField.value + session.connectUsingEngineeringContext() + } + } + RibbonAction { + badge: "IED" + text: session.busy && !session.connected ? "Working…" : "Discover IED" + enabled: !session.connected && !session.busy + onClicked: { + session.host = hostField.text + session.port = portField.value + session.discoverAndConnect() + } + } + RibbonAction { + badge: "×" + text: "Close" + enabled: session.connected || session.busy + onClicked: session.disconnectFromIed() + } + } + Label { + anchors.horizontalCenter: parent.horizontalCenter + text: "IED SESSION" + color: root.theme.muted + font.pixelSize: 7 + font.weight: Font.DemiBold + } } - ToolTip.visible: hovered - ToolTip.text: "Ignore trusted SCL for this connect, discover the live MMS model, and publish it into this Browser context." - } - - Button { - text: "Disconnect" - enabled: session.connected || session.busy - onClicked: session.disconnectFromIed() - } - - Rectangle { - width: 8 - height: 8 - radius: 4 - color: session.lastError.length ? root.theme.red - : session.connected ? root.theme.green - : session.busy ? root.theme.amber - : root.theme.muted - } - ColumnLayout { - spacing: 0 - Label { - text: session.stateText - color: session.lastError.length ? root.theme.red : root.theme.textSoft - font.pixelSize: 9 - font.weight: Font.DemiBold + Rectangle { width: 1; height: 78; y: 5; color: root.theme.lineSoft } + + Column { + width: 200 + height: parent.height + Row { + RibbonAction { + badge: "R" + text: "Read" + enabled: client.connected && !client.operationBusy + && root.selectedModelNode.readable === true + onClicked: client.readEngineeringSelected() + } + RibbonAction { + badge: "CO" + text: "Control" + enabled: session.connected + && root.selectedModelNode.controlCandidate === true + && !controls.busy + onClicked: controlDialog.openFor(root.selectedModelNode) + } + RibbonAction { + badge: "CLR" + text: "Clear" + enabled: globalDataPane.watchCount > 0 + onClicked: root.selectSection(6, "Global Data") + ToolTip.visible: hovered + ToolTip.text: "Open Global Data to review or clear monitored entries." + } + } + Label { + anchors.horizontalCenter: parent.horizontalCenter + text: "DATA" + color: root.theme.muted + font.pixelSize: 7 + font.weight: Font.DemiBold + } } - Label { - text: session.endpoint - color: root.theme.muted - font.pixelSize: 8 + + Rectangle { width: 1; height: 78; y: 5; color: root.theme.lineSoft } + + Column { + width: 266 + height: parent.height + Row { + RibbonAction { + badge: "GI" + text: "Enable + GI" + emphasized: reports.active + enabled: root.activeSection === 2 && reports.connected + && !reports.busy && !reports.active + && !reports.cleanupRequired && reports.selectedRcbIndex >= 0 + onClicked: reports.enableSelected(true) + } + RibbonAction { + badge: "OFF" + text: "Disable" + enabled: reports.connected && reports.active && !reports.busy + onClicked: reports.disableSelected() + } + RibbonAction { + badge: "DS" + text: "Add DataSet" + enabled: reports.connected && !reports.busy && !reports.active + onClicked: dataSetAuthorDialog.newDraft() + } + RibbonAction { + badge: "G" + text: "GOOSE" + enabled: context.loaded && context.gooseCount > 0 + onClicked: { + root.selectedGooseIndex = -1 + root.selectSection(5, "GOOSE") + } + } + } + Label { + anchors.horizontalCenter: parent.horizontalCenter + text: "SERVICES" + color: root.theme.muted + font.pixelSize: 7 + font.weight: Font.DemiBold + } } - } - Item { Layout.fillWidth: true } + Rectangle { width: 1; height: 78; y: 5; color: root.theme.lineSoft } - ColumnLayout { - visible: context.loaded - spacing: 0 - Label { - Layout.alignment: Qt.AlignRight - text: context.iedName.length ? context.iedName - : context.selectionRequired ? "Select active IED" : "IED model" - color: root.theme.text - font.pixelSize: 10 - font.weight: Font.DemiBold - } - Label { - Layout.alignment: Qt.AlignRight - text: context.logicalDeviceCount + " LD · " + context.logicalNodeCount + " LN · " - + context.dataAttributeCount + " DA · " - + (context.online ? "ONLINE" : "OFFLINE") - color: root.theme.muted - font.pixelSize: 8 + Column { + width: 176 + height: parent.height + y: 6 + spacing: 2 + Label { + width: parent.width + text: "MODEL" + color: root.theme.muted + font.pixelSize: 7 + font.weight: Font.DemiBold + } + Label { + width: parent.width + text: context.iedName.length ? context.iedName + : context.selectionRequired ? "Select active IED" : "IED model" + color: root.theme.text + font.pixelSize: 10 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + Label { + width: parent.width + text: context.loaded + ? context.logicalDeviceCount + " LD · " + context.logicalNodeCount + " LN · " + + context.dataAttributeCount + " DA" + : "Open SCL or discover one endpoint" + color: root.theme.muted + font.pixelSize: 8 + elide: Text.ElideRight + } + Label { + width: parent.width + text: context.online ? "ONLINE" : "OFFLINE" + color: context.online ? root.theme.green : root.theme.muted + font.pixelSize: 8 + font.weight: Font.DemiBold + } } } } @@ -347,10 +541,15 @@ Item { reports: root.reports utilities: root.utilities globalDataCount: globalDataPane.watchCount + selectedGooseIndex: root.selectedGooseIndex section: root.activeSection onSectionRequested: function(section, title) { root.selectSection(section, title) } + onGooseRequested: function(index) { + root.selectedGooseIndex = index + root.selectSection(5, "GOOSE") + } onWidthChanged: splitterPersist.restart() } @@ -560,17 +759,16 @@ Item { Layout.fillHeight: true currentIndex: root.activeSection - MmsClientWorkspace { + BrowserDataModelPane { theme: root.theme client: root.client modelProvider: root.context - showConnectionHeader: false - showNavigationPanel: false } BrowserDataSetPane { theme: root.theme reports: root.reports + modelProvider: root.context } ReportsWorkspace { @@ -597,6 +795,7 @@ Item { id: goosePane theme: root.theme context: root.context + selectedIndex: root.selectedGooseIndex } BrowserGlobalDataPane { diff --git a/apps/ied_simulator/qml/Main.qml b/apps/ied_simulator/qml/Main.qml index 898f358a9..c4bef363c 100644 --- a/apps/ied_simulator/qml/Main.qml +++ b/apps/ied_simulator/qml/Main.qml @@ -467,7 +467,10 @@ ApplicationWindow { IedBrowserWorkspace { required property int index objectName: "iedBrowserView_" + index - anchors.fill: parent + x: 0 + y: 0 + width: perIedBrowserHost.width + height: perIedBrowserHost.height visible: index === root.browserFleet.activeIndex theme: appTheme productState: hardening @@ -639,4 +642,4 @@ ApplicationWindow { theme: appTheme backend: simulator } -} \ No newline at end of file +} diff --git a/apps/ied_simulator/qml/ReportsWorkspace.qml b/apps/ied_simulator/qml/ReportsWorkspace.qml index f4cd3a229..04665a80d 100644 --- a/apps/ied_simulator/qml/ReportsWorkspace.qml +++ b/apps/ied_simulator/qml/ReportsWorkspace.qml @@ -321,43 +321,92 @@ Item { } } - GridLayout { + Rectangle { Layout.fillWidth: true - columns: 2 - columnSpacing: 10 - rowSpacing: 5 - Repeater { - model: [ - ["DataSet", reports.selectedRcb.dataSet], - ["RptID", reports.selectedRcb.reportId], - ["ConfRev", reports.selectedRcb.confRev], - ["BufTm", reports.selectedRcb.bufTm], - ["IntgPd", reports.selectedRcb.intgPd], - ["SqNum", reports.selectedRcb.sqNum], - ["RptEna", reports.selectedRcb.enabled], - ["Reserved", reports.selectedRcb.reserved], - ["ResvTms", reports.selectedRcb.resvTms], - ["Owner", reports.selectedRcb.owner] - ] - delegate: Rectangle { - required property var modelData + implicitHeight: controlAttributes.implicitHeight + color: theme.surface + border.width: 1 + border.color: theme.line + + ColumnLayout { + id: controlAttributes + anchors.left: parent.left + anchors.right: parent.right + spacing: 0 + + Rectangle { Layout.fillWidth: true - implicitHeight: 46 - radius: 5 - color: theme.surface - border.width: 1 - border.color: theme.lineSoft - ColumnLayout { + Layout.preferredHeight: 26 + color: theme.surfaceRaised + RowLayout { anchors.fill: parent - anchors.margins: 7 - spacing: 1 - Label { text: modelData[0]; color: theme.muted; font.pixelSize: 7 } + anchors.leftMargin: 8 + anchors.rightMargin: 8 Label { - Layout.fillWidth: true - text: root.text(modelData[1]) + text: "▾ Control block attributes" color: theme.text font.pixelSize: 8 - elide: Text.ElideMiddle + font.weight: Font.DemiBold + } + Item { Layout.fillWidth: true } + Label { + text: reports.selectedRcb.probeOk === true ? "VERIFIED" : "ENGINEERING" + color: reports.selectedRcb.probeOk === true ? theme.green : theme.muted + font.pixelSize: 7 + font.weight: Font.DemiBold + } + } + } + + Repeater { + model: [ + ["Enabled", reports.selectedRcb.enabled], + ["Reserved", reports.selectedRcb.reserved], + ["Control block reference", reports.selectedRcb.reference], + ["Report ID", reports.selectedRcb.reportId], + ["DataSet reference", reports.selectedRcb.dataSet], + ["Trigger options", reports.selectedRcb.triggerOptions], + ["Buffer time (ms)", reports.selectedRcb.bufTm], + ["Configuration revision", reports.selectedRcb.confRev], + ["Integrity period (ms)", reports.selectedRcb.intgPd], + ["Sequence number", reports.selectedRcb.sqNum], + ["Reservation time (s)", reports.selectedRcb.resvTms], + ["Owner", reports.selectedRcb.owner] + ] + delegate: Rectangle { + required property int index + required property var modelData + Layout.fillWidth: true + implicitHeight: 24 + color: index % 2 ? theme.surfaceSoft : theme.surface + RowLayout { + anchors.fill: parent + spacing: 0 + Label { + Layout.preferredWidth: 155 + Layout.fillHeight: true + leftPadding: 7 + verticalAlignment: Text.AlignVCenter + text: modelData[0] + color: theme.textSoft + font.pixelSize: 8 + } + Rectangle { + Layout.preferredWidth: 1 + Layout.fillHeight: true + color: theme.lineSoft + } + Label { + Layout.fillWidth: true + Layout.fillHeight: true + leftPadding: 7 + rightPadding: 7 + verticalAlignment: Text.AlignVCenter + text: root.text(modelData[1]) + color: theme.text + font.pixelSize: 8 + elide: Text.ElideMiddle + } } } } @@ -541,9 +590,11 @@ Item { model: reports.receivedReports ScrollBar.vertical: ScrollBar { } delegate: Rectangle { + id: reportRow required property var modelData + property bool expanded: false width: ListView.view.width - height: 78 + height: 78 + (expanded ? Math.min(modelData.values ? modelData.values.length : 0, 64) * 23 + 5 : 0) color: modelData.overflow ? theme.redSoft : "transparent" border.width: 0 ColumnLayout { @@ -555,6 +606,11 @@ Item { spacing: 2 RowLayout { Layout.fillWidth: true + Label { + text: reportRow.expanded ? "▾" : "▸" + color: theme.muted + font.pixelSize: 8 + } Label { Layout.fillWidth: true text: root.text(modelData.reportId) @@ -589,6 +645,36 @@ Item { color: theme.muted font.pixelSize: 7 } + Repeater { + model: reportRow.expanded && modelData.values + ? modelData.values.slice(0, 64) : [] + delegate: Rectangle { + required property int index + required property string modelData + Layout.fillWidth: true + implicitHeight: 22 + color: index % 2 ? theme.surfaceSoft : theme.surface + border.width: 1 + border.color: theme.lineSoft + Label { + anchors.fill: parent + leftPadding: 7 + rightPadding: 7 + verticalAlignment: Text.AlignVCenter + text: modelData + color: theme.textSoft + font.pixelSize: 7 + elide: Text.ElideMiddle + } + } + } + } + MouseArea { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: 78 + onClicked: reportRow.expanded = !reportRow.expanded } } } diff --git a/apps/ied_simulator/src/MmsLiveTreeModel.cpp b/apps/ied_simulator/src/MmsLiveTreeModel.cpp index 3e3c55a44..beb0491bb 100644 --- a/apps/ied_simulator/src/MmsLiveTreeModel.cpp +++ b/apps/ied_simulator/src/MmsLiveTreeModel.cpp @@ -14,6 +14,25 @@ QString leafName(const QString& path) { const auto split = std::max(dot, dollar); return split >= 0 ? path.mid(split + 1) : path; } + +QString logicalNodeDescription(const QString& logicalNodeClass) { + static const QHash descriptions{ + {QStringLiteral("LLN0"), QStringLiteral("Logical node zero")}, + {QStringLiteral("LPHD"), QStringLiteral("Physical device information")}, + {QStringLiteral("MMXU"), QStringLiteral("Measurement")}, + {QStringLiteral("MSQI"), QStringLiteral("Sequence and imbalance")}, + {QStringLiteral("MMTR"), QStringLiteral("Metering")}, + {QStringLiteral("PIOC"), QStringLiteral("Instantaneous overcurrent")}, + {QStringLiteral("PTOC"), QStringLiteral("Time overcurrent")}, + {QStringLiteral("XCBR"), QStringLiteral("Circuit breaker")}, + {QStringLiteral("CSWI"), QStringLiteral("Switch controller")}, + }; + const auto normalized = logicalNodeClass.trimmed().toUpper(); + if (const auto found = descriptions.constFind(normalized); found != descriptions.cend()) { + return found.value(); + } + return normalized.isEmpty() ? QString{} : QStringLiteral("Logical node %1").arg(normalized); +} } // namespace MmsLiveTreeModel::MmsLiveTreeModel(QObject* parent) : QAbstractListModel(parent) {} @@ -118,6 +137,7 @@ void MmsLiveTreeModel::applyDocument(const ar::iec61850::mms::MmsLiveModelDocume ld.label = q(logicalDevice.instance.empty() ? logicalDevice.mms_domain : logicalDevice.instance); ld.reference = q(logicalDevice.mms_domain); ld.domain = q(logicalDevice.mms_domain); + ld.description = QStringLiteral("Logical device"); const auto ldIndex = appendNode(std::move(ld)); for (const auto& logicalNode : logicalDevice.logical_nodes) { @@ -129,6 +149,7 @@ void MmsLiveTreeModel::applyDocument(const ar::iec61850::mms::MmsLiveModelDocume ln.label = q(logicalNode.name); ln.reference = q(logicalDevice.mms_domain + "/" + logicalNode.name); ln.domain = q(logicalDevice.mms_domain); + ln.description = logicalNodeDescription(q(logicalNode.logical_node_class)); const auto lnIndex = appendNode(std::move(ln)); for (const auto& dataObject : logicalNode.data_objects) { @@ -140,6 +161,7 @@ void MmsLiveTreeModel::applyDocument(const ar::iec61850::mms::MmsLiveModelDocume object.label = q(dataObject.name); object.reference = q(dataObject.reference); object.domain = q(logicalDevice.mms_domain); + object.description = q(dataObject.inferred_cdc); const auto objectIndex = appendNode(std::move(object)); for (const auto& attribute : dataObject.attributes) { @@ -161,6 +183,7 @@ void MmsLiveTreeModel::applyDocument(const ar::iec61850::mms::MmsLiveModelDocume da.mmsType = q(attribute.mms_type); da.sclType = q(attribute.scl_basic_type); da.typeStatus = q(attribute.type_discovery_status); + da.description = q(attribute.scl_basic_type); da.writable = isWritableAttribute( da.functionalConstraint, da.typeStatus, da.mmsType); (void)appendNode(std::move(da)); @@ -277,7 +300,27 @@ QVariantMap MmsLiveTreeModel::nodeMap(const int nodeIndex) const { result.insert(QStringLiteral("mmsType"), node.mmsType); result.insert(QStringLiteral("sclType"), node.sclType); result.insert(QStringLiteral("typeStatus"), node.typeStatus); - result.insert(QStringLiteral("value"), node.value); + result.insert(QStringLiteral("description"), node.description); + QString displayValue = node.value; + if (displayValue.isEmpty() && node.kind == NodeKind::dataObject) { + static const QStringList preferredLeaves{ + QStringLiteral("stVal"), QStringLiteral("mag"), QStringLiteral("setVal"), + QStringLiteral("instMag"), QStringLiteral("general")}; + for (const auto& preferred : preferredLeaves) { + for (const auto childIndex : node.children) { + const auto& child = nodes_.at(childIndex); + if (child.label.compare(preferred, Qt::CaseInsensitive) == 0 && + !child.value.isEmpty()) { + displayValue = child.value; + break; + } + } + if (!displayValue.isEmpty()) break; + } + } + result.insert(QStringLiteral("value"), displayValue); + result.insert(QStringLiteral("hasChildren"), !node.children.isEmpty()); + result.insert(QStringLiteral("childCount"), node.children.size()); result.insert(QStringLiteral("quality"), siblingValue(nodeIndex, QStringLiteral("q"))); result.insert(QStringLiteral("timestamp"), siblingValue(nodeIndex, QStringLiteral("t"))); result.insert(QStringLiteral("writable"), node.writable); @@ -299,6 +342,68 @@ QVariantMap MmsLiveTreeModel::selectedNode() const { return nodeMap(selectedNode_); } +int MmsLiveTreeModel::presentationNodeIndex() const noexcept { + if (selectedNode_ >= 0 && selectedNode_ < nodes_.size()) return selectedNode_; + return nodes_.isEmpty() ? -1 : 0; +} + +QVariantMap MmsLiveTreeModel::presentationNode() const { + return nodeMap(presentationNodeIndex()); +} + +int MmsLiveTreeModel::selectedChildCount() const noexcept { + const auto nodeIndex = presentationNodeIndex(); + return nodeIndex >= 0 ? nodes_.at(nodeIndex).children.size() : 0; +} + +bool MmsLiveTreeModel::selectedChildrenTruncated() const noexcept { + return selectedChildCount() > maximumProjectedChildren_; +} + +QVariantList MmsLiveTreeModel::selectedChildren() const { + QVariantList result; + const auto nodeIndex = presentationNodeIndex(); + if (nodeIndex < 0) return result; + const auto& children = nodes_.at(nodeIndex).children; + const auto count = std::min(children.size(), qsizetype{maximumProjectedChildren_}); + result.reserve(count); + for (int index = 0; index < count; ++index) result.push_back(nodeMap(children.at(index))); + return result; +} + +bool MmsLiveTreeModel::selectNode(const int nodeIndex) { + if (nodeIndex < 0 || nodeIndex >= nodes_.size() || nodeIndex == selectedNode_) return false; + const auto previousRow = selectedRow(); + selectedNode_ = nodeIndex; + for (int cursor = nodes_.at(selectedNode_).parent; cursor >= 0; cursor = nodes_.at(cursor).parent) { + nodes_[cursor].expanded = true; + } + rebuildVisible(); + const auto currentRow = selectedRow(); + if (previousRow >= 0 && previousRow < rowCount()) + emit dataChanged(index(previousRow), index(previousRow), {SelectedRole}); + if (currentRow >= 0) + emit dataChanged(index(currentRow), index(currentRow), {SelectedRole}); + emit selectionChanged(); + return true; +} + +bool MmsLiveTreeModel::selectChild(const int row) { + const auto nodeIndex = presentationNodeIndex(); + if (nodeIndex < 0 || row < 0 || row >= nodes_.at(nodeIndex).children.size()) return false; + return selectNode(nodes_.at(nodeIndex).children.at(row)); +} + +bool MmsLiveTreeModel::selectParent() { + const auto nodeIndex = presentationNodeIndex(); + if (nodeIndex < 0 || nodes_.at(nodeIndex).parent < 0) return false; + return selectNode(nodes_.at(nodeIndex).parent); +} + +bool MmsLiveTreeModel::selectRoot() { + return nodes_.isEmpty() ? false : selectNode(0); +} + QVariantMap MmsLiveTreeModel::nodeForReference(const QString& reference) const { const auto wanted = reference.trimmed(); if (wanted.isEmpty()) return {}; diff --git a/apps/ied_simulator/src/MmsLiveTreeModel.hpp b/apps/ied_simulator/src/MmsLiveTreeModel.hpp index 6e8fbb911..148aa345e 100644 --- a/apps/ied_simulator/src/MmsLiveTreeModel.hpp +++ b/apps/ied_simulator/src/MmsLiveTreeModel.hpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -20,6 +21,10 @@ class MmsLiveTreeModel : public QAbstractListModel { Q_PROPERTY(QString filterText READ filterText WRITE setFilterText NOTIFY filterTextChanged) Q_PROPERTY(int selectedRow READ selectedRow WRITE selectRow NOTIFY selectionChanged) Q_PROPERTY(QVariantMap selectedNode READ selectedNode NOTIFY selectionChanged) + Q_PROPERTY(QVariantMap presentationNode READ presentationNode NOTIFY selectionChanged) + Q_PROPERTY(QVariantList selectedChildren READ selectedChildren NOTIFY selectionChanged) + Q_PROPERTY(int selectedChildCount READ selectedChildCount NOTIFY selectionChanged) + Q_PROPERTY(bool selectedChildrenTruncated READ selectedChildrenTruncated NOTIFY selectionChanged) Q_PROPERTY(int totalNodeCount READ totalNodeCount NOTIFY countsChanged) Q_PROPERTY(int visibleNodeCount READ visibleNodeCount NOTIFY countsChanged) @@ -71,10 +76,17 @@ class MmsLiveTreeModel : public QAbstractListModel { [[nodiscard]] int selectedRow() const noexcept; [[nodiscard]] QVariantMap selectedNode() const; + [[nodiscard]] QVariantMap presentationNode() const; + [[nodiscard]] QVariantList selectedChildren() const; + [[nodiscard]] int selectedChildCount() const noexcept; + [[nodiscard]] bool selectedChildrenTruncated() const noexcept; [[nodiscard]] int totalNodeCount() const noexcept { return nodes_.size(); } [[nodiscard]] int visibleNodeCount() const noexcept { return visible_.size(); } Q_INVOKABLE void selectRow(int row); + Q_INVOKABLE bool selectChild(int row); + Q_INVOKABLE bool selectParent(); + Q_INVOKABLE bool selectRoot(); Q_INVOKABLE void toggle(int row); Q_INVOKABLE bool selectMmsItem(const QString& domain, const QString& item); Q_INVOKABLE QVariantMap nodeForReference(const QString& reference) const; @@ -115,6 +127,7 @@ class MmsLiveTreeModel : public QAbstractListModel { QString mmsType; QString sclType; QString typeStatus; + QString description; QString value; bool writable{}; }; @@ -131,6 +144,8 @@ class MmsLiveTreeModel : public QAbstractListModel { [[nodiscard]] int visibleRowForNode(int nodeIndex) const noexcept; [[nodiscard]] QString siblingValue(int nodeIndex, const QString& leafName) const; [[nodiscard]] QVariantMap nodeMap(int nodeIndex) const; + [[nodiscard]] int presentationNodeIndex() const noexcept; + bool selectNode(int nodeIndex); [[nodiscard]] QVector readTargetsForNode(int nodeIndex) const; [[nodiscard]] int appendNode(Node node); @@ -139,4 +154,5 @@ class MmsLiveTreeModel : public QAbstractListModel { QHash mmsIndex_; QString filterText_; int selectedNode_{-1}; + static constexpr int maximumProjectedChildren_{4096}; }; diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index 32b1b88ff..e1a8bc238 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -868,6 +868,13 @@ bool MmsReportController::selectRcb(const int row) { } bool MmsReportController::selectDataSet(const int row) { + if (row == -1) { + if (selectedDataSetIndex_ == -1 && selectedDataSetMembers_.isEmpty()) return true; + selectedDataSetIndex_ = -1; + selectedDataSetMembers_.clear(); + emit selectionChanged(); + return true; + } if (row < 0 || row >= dataSets_.size()) return false; selectedDataSetIndex_ = row; selectedDataSetMembers_ = dataSets_.at(row).toMap().value(QStringLiteral("members")).toStringList(); diff --git a/apps/ied_simulator/src/ied_browser_large_model_qa.cpp b/apps/ied_simulator/src/ied_browser_large_model_qa.cpp index 3b7531732..b47668047 100644 --- a/apps/ied_simulator/src/ied_browser_large_model_qa.cpp +++ b/apps/ied_simulator/src/ied_browser_large_model_qa.cpp @@ -85,6 +85,18 @@ int main(int argc, char** argv) { return 3; } + if (!model.selectRoot() || model.selectedChildCount() != 5 || + model.selectedChildren().size() != 5 || !model.selectChild(4) || + model.presentationNode().value(QStringLiteral("kind")).toString() != QStringLiteral("LD") || + model.selectedChildCount() != 20 || !model.selectChild(19) || + model.presentationNode().value(QStringLiteral("kind")).toString() != QStringLiteral("LN") || + model.selectedChildCount() != 100 || !model.selectChild(99) || + model.presentationNode().value(QStringLiteral("kind")).toString() != QStringLiteral("DO") || + model.selectedChildCount() != 5) { + std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL bounded_drilldown_projection\n"; + return 12; + } + QElapsedTimer filterTimer; filterTimer.start(); model.setFilterText(QStringLiteral("LD4/LLN19.DO99")); @@ -153,6 +165,6 @@ int main(int argc, char** argv) { << " apply_ms=" << applyMs << " filter_ms=" << filterMs << " select_ms=" << selectMs - << " eager_expansion=false\n"; + << " eager_expansion=false drilldown=canonical\n"; return passes ? 0 : 11; } diff --git a/apps/ied_simulator/src/main.cpp b/apps/ied_simulator/src/main.cpp index 6ca0859fd..db420d7e8 100644 --- a/apps/ied_simulator/src/main.cpp +++ b/apps/ied_simulator/src/main.cpp @@ -2,6 +2,7 @@ #include "IedFleetController.hpp" #include "IedBrowserFleetController.hpp" +#include "SclWorkspaceController.hpp" #include #include @@ -72,6 +73,14 @@ int main(int argc, char* argv[]) { QStringLiteral("scl"), QStringLiteral("Import an engineering file before showing the window."), QStringLiteral("path")}; + const QCommandLineOption browserModelOption{ + QStringLiteral("browser-model"), + QStringLiteral("Open an engineering file in the IED Browser before showing the window."), + QStringLiteral("path")}; + const QCommandLineOption browserSectionOption{ + QStringLiteral("browser-section"), + QStringLiteral("Select the initial Browser section (0-6)."), + QStringLiteral("index")}; const QCommandLineOption qaAsyncImportOption{ QStringLiteral("qa-async-import"), QStringLiteral("QA: import one engineering file through the interactive bounded worker and exit."), @@ -125,6 +134,8 @@ int main(int argc, char* argv[]) { QStringLiteral("milliseconds")}; parser.addOptions({ sclOption, + browserModelOption, + browserSectionOption, qaAsyncImportOption, qaAsyncImportRepeatOption, qaRuntimeCyclesOption, @@ -152,6 +163,53 @@ int main(int argc, char* argv[]) { if (!engine.rootObjects().isEmpty()) { auto* const rootObject = engine.rootObjects().constFirst(); auto* const backend = rootObject->findChild(QStringLiteral("simulatorBackend")); + auto* const browserFleet = rootObject->findChild( + QStringLiteral("iedBrowserFleetBackend")); + if (browserFleet != nullptr && parser.isSet(browserModelOption)) { + rootObject->setProperty("workspaceIndex", 1); + if (!browserFleet->activeEngineering()->openFile( + QUrl::fromLocalFile(parser.value(browserModelOption)))) { + qWarning().noquote() << "Browser model open request was rejected."; + } + } + if (parser.isSet(browserSectionOption)) { + bool sectionOk{}; + const auto section = parser.value(browserSectionOption).toInt(§ionOk); + if (!sectionOk || section < 0 || section > 6) { + qWarning().noquote() << "Browser section must be between 0 and 6."; + } else { + static const QStringList sectionTitles{ + QStringLiteral("Data Model"), + QStringLiteral("DataSets"), + QStringLiteral("Reports"), + QStringLiteral("Setting Groups"), + QStringLiteral("Files"), + QStringLiteral("GOOSE"), + QStringLiteral("Global Data")}; + QTimer::singleShot(1'250, rootObject, [rootObject, section] { + auto* const host = rootObject->findChild( + QStringLiteral("perIedBrowserHost")); + QObject* browser{}; + if (host != nullptr) { + for (auto* item : host->childItems()) { + if (item != nullptr && item->objectName() == QStringLiteral("iedBrowserView_0")) { + browser = item; + break; + } + } + } + if (browser == nullptr) return; + browser->setProperty("activeSection", section); + browser->setProperty("activeSectionTitle", sectionTitles.at(section)); + if (section == 5) { + auto* const context = browser->property("context").value(); + if (context != nullptr && context->property("gooseCount").toInt() > 0) { + browser->setProperty("selectedGooseIndex", 0); + } + } + }); + } + } int defaultPort = 102; if (backend != nullptr && parser.isSet(portOption)) { bool valid{}; diff --git a/apps/ied_simulator/test_browser_convergence.py b/apps/ied_simulator/test_browser_convergence.py index 82e4824ce..45162ae0b 100644 --- a/apps/ied_simulator/test_browser_convergence.py +++ b/apps/ied_simulator/test_browser_convergence.py @@ -2,14 +2,17 @@ from pathlib import Path import sys -if len(sys.argv) != 4: - raise SystemExit("usage: test_browser_convergence.py IedBrowserWorkspace.qml IedBrowserNavigation.qml BrowserStatusConsole.qml") +if len(sys.argv) != 5: + raise SystemExit("usage: test_browser_convergence.py IedBrowserWorkspace.qml IedBrowserNavigation.qml BrowserStatusConsole.qml BrowserDataModelPane.qml") workspace = Path(sys.argv[1]).read_text(encoding="utf-8") navigation = Path(sys.argv[2]).read_text(encoding="utf-8") status = Path(sys.argv[3]).read_text(encoding="utf-8") +data_model = Path(sys.argv[4]).read_text(encoding="utf-8") workspace_required = [ + 'component RibbonAction: Button', + 'Layout.preferredHeight: 94', 'text: "MODEL"', 'text: "COMMANDS"', 'text: "Online"', @@ -19,6 +22,7 @@ 'modelProvider: root.context', ] navigation_required = [ + 'color: theme.navigation', 'text: "ENGINEERING EXPLORER"', 'id: iedRoot', 'title: "GOOSE"', @@ -27,6 +31,8 @@ 'title: "Files"', 'title: "DataSets"', 'title: "Data Model"', + 'signal gooseRequested(int index)', + 'context.gooseStreams', 'context.treeModel', ] status_required = [ @@ -36,11 +42,19 @@ 'function captureContext()', 'Bounded Browser history · no background polling', ] +data_model_required = [ + 'objectName: "browserDataModelChildren"', + 'model: root.treeModel.selectedChildren', + 'onDoubleClicked: root.drillInto(childRow.index)', + 'root.treeModel.selectParent()', + 'root.treeModel.selectedChildrenTruncated', +] for label, source, required in [ ("workspace", workspace, workspace_required), ("navigation", navigation, navigation_required), ("status", status, status_required), + ("data-model", data_model, data_model_required), ]: missing = [token for token in required if token not in source] if missing: @@ -53,4 +67,4 @@ if "maximumEntries: 80" not in status: raise SystemExit("BROWSER_CONVERGENCE_FAIL status history is not bounded") -print("BROWSER_CONVERGENCE_PASS ied_root=pass contextual_commands=pass canonical_tree=pass bounded_history=80 polling=false") +print("BROWSER_CONVERGENCE_PASS ribbon=pass dense_explorer=pass goose_drilldown=pass data_model_drilldown=pass ied_root=pass contextual_commands=pass canonical_tree=pass bounded_history=80 polling=false") diff --git a/apps/ied_simulator/test_browser_service_reuse.py b/apps/ied_simulator/test_browser_service_reuse.py index 3c11090ac..2ef2e2928 100644 --- a/apps/ied_simulator/test_browser_service_reuse.py +++ b/apps/ied_simulator/test_browser_service_reuse.py @@ -46,7 +46,8 @@ ] required_goose = [ "property var visibleStreams: context.gooseStreams", - "target: context", + "property int selectedIndex: -1", + "property var selectedStream:", ] for label, source, required in [