diff --git a/.github/workflows/ied-simulator-qt.yml b/.github/workflows/ied-simulator-qt.yml index 150918adf..82a9c60db 100644 --- a/.github/workflows/ied-simulator-qt.yml +++ b/.github/workflows/ied-simulator-qt.yml @@ -170,6 +170,16 @@ jobs: apps/ied_simulator/qml/Main.qml apps/ied_simulator/CMakeLists.txt + - name: P6C Dynamic DataSet + Report Authoring contract + run: >- + python3 apps/ied_simulator/test_browser_report_authoring.py + apps/ied_simulator/src/MmsReportController.cpp + apps/ied_simulator/qml/IedBrowserWorkspace.qml + apps/ied_simulator/qml/BrowserDataSetAuthoringDialog.qml + apps/ied_simulator/qml/BrowserReportAuthoringDialog.qml + apps/ied_simulator/qml/BrowserDataSetPane.qml + apps/ied_simulator/src/MmsReportController.hpp + - name: IED Browser session ownership gates env: QT_QPA_PLATFORM: offscreen diff --git a/apps/ied_simulator/CMakeLists.txt b/apps/ied_simulator/CMakeLists.txt index 64af46fbe..79fbf1365 100644 --- a/apps/ied_simulator/CMakeLists.txt +++ b/apps/ied_simulator/CMakeLists.txt @@ -102,6 +102,8 @@ qt_add_qml_module(arstack_ied_simulator qml/BrowserGoosePane.qml qml/BrowserGlobalDataPane.qml qml/BrowserControlDialog.qml + qml/BrowserDataSetAuthoringDialog.qml + qml/BrowserReportAuthoringDialog.qml qml/SnifferWorkspace.qml qml/MmsClientWorkspace.qml qml/ReportsWorkspace.qml diff --git a/apps/ied_simulator/qml/BrowserDataSetAuthoringDialog.qml b/apps/ied_simulator/qml/BrowserDataSetAuthoringDialog.qml new file mode 100644 index 000000000..282b7c7e6 --- /dev/null +++ b/apps/ied_simulator/qml/BrowserDataSetAuthoringDialog.qml @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Dialog { + id: root + + required property var theme + required property var reports + + property string pendingDeleteReference: "" + readonly property int maximumMembers: 64 + + title: "Dynamic DataSet Authoring" + modal: true + width: 700 + height: 620 + anchors.centerIn: Overlay.overlay + standardButtons: Dialog.Close + + ListModel { id: memberModel } + + function findMember(domain, item) { + for (var i = 0; i < memberModel.count; ++i) { + var row = memberModel.get(i) + if (row.mmsDomain === domain && row.mmsItem === item) + return i + } + return -1 + } + + function resetDraft() { + memberModel.clear() + referenceField.text = "" + statusLabel.text = "Draft is local until Create is explicitly confirmed." + } + + function newDraft() { + resetDraft() + root.open() + } + + function addSelected(node) { + if (!node || node.kind !== "DA" || node.readable !== true + || !node.mmsDomain || !node.mmsItem || !node.reference) { + statusLabel.text = "Select one readable canonical DataAttribute first." + root.open() + return false + } + if (findMember(node.mmsDomain, node.mmsItem) >= 0) { + statusLabel.text = "That MMS member is already in the draft." + root.open() + return true + } + if (memberModel.count >= root.maximumMembers) { + statusLabel.text = "Draft is full (64 members)." + root.open() + return false + } + memberModel.append({ + "reference": node.reference, + "mmsDomain": node.mmsDomain, + "mmsItem": node.mmsItem, + "functionalConstraint": node.functionalConstraint || "" + }) + if (referenceField.text.length === 0) + referenceField.text = node.mmsDomain + "/LLN0.ARStackDynamic1" + statusLabel.text = "Added canonical member " + node.reference + root.open() + return true + } + + function memberPayload() { + var result = [] + for (var i = 0; i < memberModel.count; ++i) { + var row = memberModel.get(i) + result.push({ + "reference": row.reference, + "mmsDomain": row.mmsDomain, + "mmsItem": row.mmsItem, + "functionalConstraint": row.functionalConstraint + }) + } + return result + } + + function createConfirmed() { + var accepted = reports.createDynamicDataSet( + referenceField.text, memberPayload()) + if (accepted) { + statusLabel.text = "DefineNamedVariableList started; verification is mandatory." + root.close() + } else { + statusLabel.text = reports.lastError.length + ? reports.lastError : "Dynamic DataSet create was not started." + } + } + + function requestDelete(dataSet) { + if (!dataSet || dataSet.dynamicOwned !== true) { + pendingDeleteReference = "" + deleteError.text = + "Only DataSets created by this live report association can be deleted here." + deleteDialog.open() + return false + } + pendingDeleteReference = dataSet.reference || "" + deleteError.text = "" + deleteDialog.open() + return true + } + + function deleteConfirmed() { + if (pendingDeleteReference.length === 0) + return + reports.deleteDynamicDataSet(pendingDeleteReference) + pendingDeleteReference = "" + } + + contentItem: ColumnLayout { + spacing: 8 + + Rectangle { + Layout.fillWidth: true + implicitHeight: 74 + radius: 6 + color: theme.surface + border.width: 1 + border.color: theme.lineSoft + + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 3 + Label { + text: "DATASET REFERENCE" + color: theme.muted + font.pixelSize: 7 + font.weight: Font.Bold + } + TextField { + id: referenceField + Layout.fillWidth: true + placeholderText: "LogicalDevice/LLN0.ARStackDynamic1" + selectByMouse: true + enabled: !reports.busy + } + } + } + + RowLayout { + Layout.fillWidth: true + Label { + Layout.fillWidth: true + text: "Ordered members · " + memberModel.count + + " / " + root.maximumMembers + color: theme.text + font.pixelSize: 10 + font.weight: Font.DemiBold + } + Button { + text: "Clear draft" + enabled: memberModel.count > 0 && !reports.busy + onClicked: memberModel.clear() + } + } + + ListView { + id: memberList + Layout.fillWidth: true + Layout.fillHeight: true + model: memberModel + clip: true + reuseItems: true + spacing: 1 + ScrollBar.vertical: ScrollBar {} + + delegate: Rectangle { + required property int index + required property string reference + required property string mmsDomain + required property string mmsItem + required property string functionalConstraint + + width: memberList.width + height: 54 + color: index % 2 ? theme.surfaceSoft : theme.surface + border.width: 1 + border.color: theme.lineSoft + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 9 + anchors.rightMargin: 8 + spacing: 7 + + Label { + text: String(index + 1) + color: theme.muted + font.pixelSize: 8 + Layout.preferredWidth: 24 + horizontalAlignment: Text.AlignHCenter + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Label { + Layout.fillWidth: true + text: reference + color: theme.text + font.pixelSize: 9 + font.weight: Font.DemiBold + elide: Text.ElideMiddle + } + Label { + Layout.fillWidth: true + text: mmsDomain + "/" + mmsItem + + (functionalConstraint.length + ? " · FC=" + functionalConstraint : "") + color: theme.muted + font.pixelSize: 7 + elide: Text.ElideMiddle + } + } + + Button { + text: "↑" + enabled: index > 0 && !reports.busy + onClicked: memberModel.move(index, index - 1, 1) + } + Button { + text: "↓" + enabled: index + 1 < memberModel.count && !reports.busy + onClicked: memberModel.move(index, index + 1, 1) + } + Button { + text: "Remove" + enabled: !reports.busy + onClicked: memberModel.remove(index) + } + } + } + + Label { + anchors.centerIn: parent + visible: memberModel.count === 0 + width: Math.max(220, parent.width - 60) + text: "Add readable DataAttributes from Data Model. Member order is preserved exactly in DefineNamedVariableList." + color: theme.muted + font.pixelSize: 9 + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + } + } + + Label { + id: statusLabel + Layout.fillWidth: true + text: "Draft is local until Create is explicitly confirmed." + color: reports.lastError.length ? theme.red : theme.muted + font.pixelSize: 8 + wrapMode: Text.WordWrap + } + + RowLayout { + Layout.fillWidth: true + Label { + Layout.fillWidth: true + text: "Create uses the existing MMS association and verifies the resulting directory before ownership is recorded." + color: theme.muted + font.pixelSize: 8 + wrapMode: Text.WordWrap + } + Button { + text: reports.busy ? "Working…" : "Create…" + enabled: reports.connected && !reports.busy + && referenceField.text.trim().length > 0 + && memberModel.count > 0 + onClicked: createConfirm.open() + } + } + } + + Dialog { + id: createConfirm + title: "Confirm dynamic DataSet create" + modal: true + anchors.centerIn: Overlay.overlay + standardButtons: Dialog.Ok | Dialog.Cancel + onAccepted: root.createConfirmed() + contentItem: Label { + width: 470 + text: "Create " + referenceField.text + + " with " + memberModel.count + + " ordered member(s)? This sends one DefineNamedVariableList request and requires directory read-back verification." + color: theme.text + font.pixelSize: 9 + wrapMode: Text.WordWrap + } + } + + Dialog { + id: deleteDialog + title: "Confirm dynamic DataSet delete" + modal: true + anchors.centerIn: Overlay.overlay + standardButtons: root.pendingDeleteReference.length > 0 + ? Dialog.Ok | Dialog.Cancel : Dialog.Close + onAccepted: root.deleteConfirmed() + contentItem: ColumnLayout { + width: 470 + spacing: 6 + Label { + Layout.fillWidth: true + text: root.pendingDeleteReference.length > 0 + ? "Delete owned dynamic DataSet " + root.pendingDeleteReference + "?" + : "Delete refused." + color: theme.text + font.pixelSize: 9 + font.weight: Font.DemiBold + wrapMode: Text.WordWrap + } + Label { + id: deleteError + Layout.fillWidth: true + text: "" + color: deleteError.text.length ? theme.red : theme.muted + font.pixelSize: 8 + wrapMode: Text.WordWrap + } + Label { + visible: root.pendingDeleteReference.length > 0 + Layout.fillWidth: true + text: "ARStack uses owned-only DeleteNamedVariableList. Static and foreign dynamic DataSets cannot be deleted from this workflow." + color: theme.muted + font.pixelSize: 8 + wrapMode: Text.WordWrap + } + } + } +} diff --git a/apps/ied_simulator/qml/BrowserDataSetPane.qml b/apps/ied_simulator/qml/BrowserDataSetPane.qml index 291678a7d..83186cdee 100644 --- a/apps/ied_simulator/qml/BrowserDataSetPane.qml +++ b/apps/ied_simulator/qml/BrowserDataSetPane.qml @@ -36,6 +36,40 @@ Rectangle { 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: reports.dataSets[reports.selectedDataSetIndex].dynamicOwned === true + ? theme.greenSoft : theme.surface + border.width: 1 + border.color: reports.dataSets[reports.selectedDataSetIndex].dynamicOwned === true + ? theme.green : theme.lineSoft + Label { + anchors.centerIn: parent + text: reports.dataSets[reports.selectedDataSetIndex].dynamicOwned === true + ? "DYNAMIC OWNED" : "STATIC / READ-ONLY" + color: reports.dataSets[reports.selectedDataSetIndex].dynamicOwned === true + ? theme.green : theme.muted + font.pixelSize: 7 + font.weight: Font.Bold + } + } + Label { + Layout.fillWidth: true + text: reports.dataSets[reports.selectedDataSetIndex].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 + } + } + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: theme.lineSoft } RowLayout { diff --git a/apps/ied_simulator/qml/BrowserReportAuthoringDialog.qml b/apps/ied_simulator/qml/BrowserReportAuthoringDialog.qml new file mode 100644 index 000000000..f4fec65a4 --- /dev/null +++ b/apps/ied_simulator/qml/BrowserReportAuthoringDialog.qml @@ -0,0 +1,344 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Dialog { + id: root + + required property var theme + required property var reports + + property bool pendingGi: false + + title: "Advanced Report Authoring" + modal: true + width: 720 + height: 650 + anchors.centerIn: Overlay.overlay + standardButtons: Dialog.Close + + function containsName(text, name) { + return text && String(text).indexOf(name) >= 0 + } + + function selectedDataSetMap() { + if (dataSetBox.currentIndex < 0 + || dataSetBox.currentIndex >= reports.dataSets.length) + return ({}) + return reports.dataSets[dataSetBox.currentIndex] + } + + function bindingAllowed() { + var ds = selectedDataSetMap() + if (!ds || !ds.reference) + return false + var rcb = reports.selectedRcb + if (rcb.dataSet === ds.reference) + return true + if (ds.dynamicOwned === true) + return true + return rcb.dynamicBinding === true + && rcb.canonicalDataSet + && rcb.canonicalDataSet === ds.reference + } + + function triggerNames() { + var result = [] + if (dchg.checked) result.push("data-change") + if (qchg.checked) result.push("quality-change") + if (dupd.checked) result.push("data-update") + if (integrity.checked) result.push("integrity") + if (giTrigger.checked) result.push("general-interrogation") + return result + } + + function optionalNames() { + var result = [] + if (seqNum.checked) result.push("sequence-number") + if (timeStamp.checked) result.push("report-time-stamp") + if (reason.checked) result.push("reason-for-inclusion") + if (dataSetName.checked) result.push("data-set-name") + if (dataReference.checked) result.push("data-reference") + if (bufferOverflow.checked) result.push("buffer-overflow") + if (entryId.checked) result.push("entry-id") + if (confRev.checked) result.push("configuration-revision") + if (segmentation.checked) result.push("segmentation") + return result + } + + function findDataSet(reference) { + for (var i = 0; i < reports.dataSets.length; ++i) { + if (reports.dataSets[i].reference === reference) + return i + } + return reports.selectedDataSetIndex >= 0 + ? reports.selectedDataSetIndex : 0 + } + + function openForSelected() { + if (reports.selectedRcbIndex < 0) + return false + var rcb = reports.selectedRcb + dataSetBox.currentIndex = findDataSet(rcb.dataSet || "") + + var trg = rcb.triggerOptions || "" + dchg.checked = containsName(trg, "data-change") + qchg.checked = containsName(trg, "quality-change") + dupd.checked = containsName(trg, "data-update") + integrity.checked = containsName(trg, "integrity") + giTrigger.checked = containsName(trg, "general-interrogation") + + var opt = rcb.optionalFields || "" + seqNum.checked = containsName(opt, "sequence-number") + timeStamp.checked = containsName(opt, "report-time-stamp") + reason.checked = containsName(opt, "reason-for-inclusion") + dataSetName.checked = containsName(opt, "data-set-name") + dataReference.checked = containsName(opt, "data-reference") + bufferOverflow.checked = containsName(opt, "buffer-overflow") + entryId.checked = containsName(opt, "entry-id") + confRev.checked = containsName(opt, "configuration-revision") + segmentation.checked = containsName(opt, "segmentation") + + if (!trg.length) { + dchg.checked = true + qchg.checked = true + integrity.checked = true + giTrigger.checked = true + } + if (!opt.length) { + seqNum.checked = true + timeStamp.checked = true + reason.checked = true + dataSetName.checked = true + dataReference.checked = true + confRev.checked = true + } + root.open() + return true + } + + function requestEnable(gi) { + pendingGi = gi + if (gi && !giTrigger.checked) + giTrigger.checked = true + confirmEnable.open() + } + + function executeEnable() { + var ds = selectedDataSetMap() + if (!ds || !ds.reference) + return + var accepted = reports.enableSelectedAuthored( + ds.reference, + triggerNames(), + optionalNames(), + pendingGi) + if (accepted) + root.close() + } + + contentItem: ScrollView { + contentWidth: availableWidth + + ColumnLayout { + width: Math.max(0, parent.width - 30) + x: 15 + spacing: 10 + + Rectangle { + Layout.fillWidth: true + implicitHeight: 76 + radius: 6 + color: theme.surface + border.width: 1 + border.color: theme.lineSoft + + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 2 + Label { + Layout.fillWidth: true + text: reports.selectedRcb.reference || "—" + color: theme.text + font.pixelSize: 11 + font.weight: Font.DemiBold + elide: Text.ElideMiddle + } + Label { + Layout.fillWidth: true + text: (reports.selectedRcb.mode || "RCB") + + " · currently " + (reports.selectedRcb.dataSet || "no DataSet") + color: theme.muted + font.pixelSize: 8 + elide: Text.ElideMiddle + } + } + } + + Label { + text: "DataSet binding" + color: theme.text + font.pixelSize: 10 + font.weight: Font.DemiBold + } + + ComboBox { + id: dataSetBox + Layout.fillWidth: true + model: reports.dataSets + textRole: "reference" + valueRole: "reference" + enabled: !reports.busy && !reports.active + delegate: ItemDelegate { + required property var modelData + width: dataSetBox.width + text: (modelData.dynamicOwned === true ? "DYNAMIC · " : "STATIC · ") + + (modelData.reference || "—") + + " · " + (modelData.memberCount || 0) + " member(s)" + } + } + + Rectangle { + visible: !root.bindingAllowed() + Layout.fillWidth: true + implicitHeight: bindingWarning.implicitHeight + 16 + radius: 5 + color: theme.redSoft + border.width: 1 + border.color: theme.red + Label { + id: bindingWarning + anchors.fill: parent + anchors.margins: 8 + text: "Binding to a different static/non-owned DataSet is blocked. " + + "Only an association-owned dynamic DataSet may replace RCB DatSet. " + + "If ARStack previously applied a dynamic binding, its canonical static binding may be restored explicitly." + color: theme.red + font.pixelSize: 8 + wrapMode: Text.WordWrap + } + } + + Label { + text: "Trigger Options (TrgOps)" + color: theme.text + font.pixelSize: 10 + font.weight: Font.DemiBold + } + + GridLayout { + Layout.fillWidth: true + columns: 3 + CheckBox { id: dchg; text: "Data change"; enabled: !reports.busy } + CheckBox { id: qchg; text: "Quality change"; enabled: !reports.busy } + CheckBox { id: dupd; text: "Data update"; enabled: !reports.busy } + CheckBox { id: integrity; text: "Integrity"; enabled: !reports.busy } + CheckBox { id: giTrigger; text: "General interrogation"; enabled: !reports.busy } + } + + Label { + text: "Optional Fields (OptFlds)" + color: theme.text + font.pixelSize: 10 + font.weight: Font.DemiBold + } + + GridLayout { + Layout.fillWidth: true + columns: 3 + CheckBox { id: seqNum; text: "Sequence number"; enabled: !reports.busy } + CheckBox { id: timeStamp; text: "Report timestamp"; enabled: !reports.busy } + CheckBox { id: reason; text: "Reason for inclusion"; enabled: !reports.busy } + CheckBox { id: dataSetName; text: "DataSet name"; enabled: !reports.busy } + CheckBox { id: dataReference; text: "Data reference"; enabled: !reports.busy } + CheckBox { id: bufferOverflow; text: "Buffer overflow"; enabled: !reports.busy } + CheckBox { id: entryId; text: "EntryID"; enabled: !reports.busy } + CheckBox { id: confRev; text: "Configuration revision"; enabled: !reports.busy } + CheckBox { id: segmentation; text: "Segmentation"; enabled: !reports.busy } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: note.implicitHeight + 16 + radius: 5 + color: theme.surface + border.width: 1 + border.color: theme.lineSoft + Label { + id: note + anchors.fill: parent + anchors.margins: 8 + text: "Draft-only until Enable is confirmed. Static binding never rewrites DatSet. " + + "For an owned dynamic DataSet—or an explicit restore to the canonical static binding—DatSet + TrgOps + OptFlds are written only through " + + "the proven report runtime, read back, and only then RptEna is written." + color: theme.muted + font.pixelSize: 8 + wrapMode: Text.WordWrap + } + } + + Rectangle { + visible: reports.lastError.length > 0 + Layout.fillWidth: true + implicitHeight: errorLabel.implicitHeight + 16 + radius: 5 + color: theme.redSoft + border.width: 1 + border.color: theme.red + Label { + id: errorLabel + anchors.fill: parent + anchors.margins: 8 + text: reports.lastError + color: theme.red + font.pixelSize: 8 + wrapMode: Text.WordWrap + } + } + + RowLayout { + Layout.fillWidth: true + Item { Layout.fillWidth: true } + Button { + text: reports.busy ? "Working…" : "Enable authored…" + enabled: reports.connected && !reports.busy && !reports.active + && root.bindingAllowed() + && dataSetBox.currentIndex >= 0 + onClicked: root.requestEnable(false) + } + Button { + text: reports.busy ? "Working…" : "Enable authored + GI…" + enabled: reports.connected && !reports.busy && !reports.active + && root.bindingAllowed() + && dataSetBox.currentIndex >= 0 + onClicked: root.requestEnable(true) + } + } + } + } + + Dialog { + id: confirmEnable + title: "Confirm authored RCB enable" + modal: true + anchors.centerIn: Overlay.overlay + standardButtons: Dialog.Ok | Dialog.Cancel + onAccepted: root.executeEnable() + contentItem: Label { + width: 500 + text: { + var ds = root.selectedDataSetMap() + return "Apply authored configuration to " + + (reports.selectedRcb.reference || "selected RCB") + + " using " + (ds.reference || "—") + + (root.pendingGi ? " and request GI?" : "?") + + "\n\nNo automatic retry is performed." + } + color: theme.text + font.pixelSize: 9 + wrapMode: Text.WordWrap + } + } +} diff --git a/apps/ied_simulator/qml/IedBrowserWorkspace.qml b/apps/ied_simulator/qml/IedBrowserWorkspace.qml index 7df41a61d..4e73050c5 100644 --- a/apps/ied_simulator/qml/IedBrowserWorkspace.qml +++ b/apps/ied_simulator/qml/IedBrowserWorkspace.qml @@ -415,6 +415,16 @@ Item { onClicked: globalDataPane.addSelectedData(root.selectedModelNode) } + Button { + visible: root.activeSection === 0 + text: "Add to DataSet draft…" + enabled: root.selectedModelNode.kind === "DA" + && root.selectedModelNode.readable === true + && root.selectedModelNode.mmsDomain + && root.selectedModelNode.mmsItem + onClicked: dataSetAuthorDialog.addSelected(root.selectedModelNode) + } + Button { visible: root.activeSection === 1 text: "Add to Global Data" @@ -428,12 +438,44 @@ Item { } } + Button { + visible: root.activeSection === 1 + text: "New dynamic…" + enabled: reports.connected && !reports.busy && !reports.active + onClicked: dataSetAuthorDialog.newDraft() + } + + Button { + visible: root.activeSection === 1 + text: "Delete owned…" + enabled: reports.connected && !reports.busy && !reports.active + && reports.selectedDataSetIndex >= 0 + && reports.selectedDataSetIndex < reports.dataSets.length + && reports.dataSets[reports.selectedDataSetIndex].dynamicOwned === true + onClicked: dataSetAuthorDialog.requestDelete( + reports.dataSets[reports.selectedDataSetIndex]) + } + Button { visible: root.activeSection === 2 text: "Enable + GI" enabled: reports.connected && !reports.busy && !reports.active && reports.selectedRcbIndex >= 0 + && reports.selectedRcb.dynamicBinding !== true onClicked: reports.enableSelected(true) + ToolTip.visible: hovered + ToolTip.text: reports.selectedRcb.dynamicBinding === true + ? "Restore the canonical static binding through Author… before using the legacy static enable path." + : "Enable the selected static RCB and request GI." + } + + Button { + visible: root.activeSection === 2 + text: "Author…" + enabled: reports.connected && !reports.busy && !reports.active + && reports.selectedRcbIndex >= 0 + && reports.dataSets.length > 0 + onClicked: reportAuthorDialog.openForSelected() } Button { @@ -556,6 +598,18 @@ Item { session: root.session controls: root.controls } + + BrowserDataSetAuthoringDialog { + id: dataSetAuthorDialog + theme: root.theme + reports: root.reports + } + + BrowserReportAuthoringDialog { + id: reportAuthorDialog + theme: root.theme + reports: root.reports + } } } } diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index 62dbfa325..32b1b88ff 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -2,8 +2,10 @@ #include "MmsReportController.hpp" #include "ariec61850/mms/data_codec.hpp" +#include "ariec61850/mms/dynamic_data_set.hpp" #include "ariec61850/mms/live_discovery.hpp" #include "ariec61850/mms/rcb_selection.hpp" +#include "ariec61850/mms/report_subscription_runtime.hpp" #include "ariec61850/mms/static_report_session.hpp" #include @@ -11,6 +13,8 @@ #include #include +#include +#include #include #include #include @@ -62,6 +66,112 @@ bool sameDataSetReference(const QString& left, const QString& right) { } } +std::vector triggerPayload(const QStringList& names) { + static const std::array, 5> bits{{ + {u"data-change", 0x40U}, + {u"quality-change", 0x20U}, + {u"data-update", 0x10U}, + {u"integrity", 0x08U}, + {u"general-interrogation", 0x04U}, + }}; + std::set unique; + std::uint8_t value{}; + for (const auto& raw : names) { + const auto name = raw.trimmed(); + if (name.isEmpty() || !unique.insert(name).second) continue; + const auto it = std::find_if(bits.begin(), bits.end(), [&name](const auto& item) { + return item.first.toString() == name; + }); + if (it == bits.end()) { + throw std::invalid_argument( + "Unknown IEC 61850 TrgOps name: " + name.toStdString()); + } + value = static_cast(value | it->second); + } + return {value}; +} + +std::vector optionalPayload(const QStringList& names) { + struct Bit final { + QStringView name; + int byte; + std::uint8_t mask; + }; + static const std::array bits{{ + {u"sequence-number", 0, 0x40U}, + {u"report-time-stamp", 0, 0x20U}, + {u"reason-for-inclusion", 0, 0x10U}, + {u"data-set-name", 0, 0x08U}, + {u"data-reference", 0, 0x04U}, + {u"buffer-overflow", 0, 0x02U}, + {u"entry-id", 0, 0x01U}, + {u"configuration-revision", 1, 0x80U}, + {u"segmentation", 1, 0x40U}, + }}; + std::set unique; + std::vector value(2U, 0U); + for (const auto& raw : names) { + const auto name = raw.trimmed(); + if (name.isEmpty() || !unique.insert(name).second) continue; + const auto it = std::find_if(bits.begin(), bits.end(), [&name](const auto& item) { + return item.name.toString() == name; + }); + if (it == bits.end()) { + throw std::invalid_argument( + "Unknown IEC 61850 OptFlds name: " + name.toStdString()); + } + value[static_cast(it->byte)] = + static_cast( + value[static_cast(it->byte)] | it->mask); + } + return value; +} + +QString bitNamesText(const QStringList& names) { + return names.isEmpty() ? QStringLiteral("none") : names.join(QStringLiteral(", ")); +} + +mms::MmsDataSetCandidate dataSetCandidateFromReference(const std::string& reference) { + const auto objectName = mms::MmsDataSetDirectoryCodec::parse_data_set_reference(reference); + if (objectName.kind != mms::MmsObjectNameKind::domain_specific || + objectName.domain.empty() || objectName.item.empty()) { + throw std::invalid_argument("Dynamic DataSet requires a domain-specific reference."); + } + const auto separator = objectName.item.find('$'); + if (separator == std::string::npos || separator == 0U || + separator + 1U >= objectName.item.size()) { + throw std::invalid_argument( + "Dynamic DataSet reference must resolve to LD/LN.DataSetName."); + } + mms::MmsDataSetCandidate candidate; + candidate.domain = objectName.domain; + candidate.logical_node = objectName.item.substr(0U, separator); + candidate.name = objectName.item.substr(separator + 1U); + candidate.reference = mms::MmsDataSetDirectoryCodec::to_iec_reference(objectName); + candidate.raw_mms_name = objectName.item; + return candidate; +} + +QVariantList markOwnedDataSets( + QVariantList dataSets, + const QStringList& ownedReferences) { + for (int row = 0; row < dataSets.size(); ++row) { + auto map = dataSets.at(row).toMap(); + const auto reference = map.value(QStringLiteral("reference")).toString(); + bool owned = false; + for (const auto& candidate : ownedReferences) { + if (sameDataSetReference(reference, candidate)) { + owned = true; + break; + } + } + map.insert(QStringLiteral("dynamicOwned"), owned); + map.insert(QStringLiteral("immutable"), !owned); + dataSets[row] = map; + } + return dataSets; +} + QVariantMap candidateMap(const mms::MmsRcbCandidateEvaluation& candidate) { QVariantMap map; map.insert(QStringLiteral("reference"), QString::fromStdString(candidate.reference)); @@ -118,6 +228,8 @@ DiscoveryUi buildDiscoveryUi( } map.insert(QStringLiteral("directoryAvailable"), directoryAvailable); map.insert(QStringLiteral("deletable"), deletable); + map.insert(QStringLiteral("dynamicOwned"), false); + map.insert(QStringLiteral("immutable"), true); map.insert(QStringLiteral("memberCount"), members.size()); map.insert(QStringLiteral("members"), members); ui.dataSets.push_back(map); @@ -144,6 +256,7 @@ DiscoveryUi buildDiscoveryUi( if (evidence.state) { const auto& state = *evidence.state; map.insert(QStringLiteral("dataSet"), QString::fromStdString(state.data_set_reference)); + map.insert(QStringLiteral("canonicalDataSet"), QString::fromStdString(state.data_set_reference)); map.insert(QStringLiteral("reportId"), QString::fromStdString(state.report_id)); map.insert(QStringLiteral("confRev"), numberText(state.configuration_revision)); map.insert(QStringLiteral("bufTm"), numberText(state.buffer_time_ms)); @@ -334,11 +447,11 @@ QString reportValueText(const mms::MmsReportValue& item) { .arg(reference, value, reasons.isEmpty() ? QString{} : QStringLiteral(" · ") + reasons); } -ReportUi buildReportUi(const mms::MmsStaticReportSessionSnapshot& snapshot) { +ReportUi buildSubscriptionUi( + const mms::MmsReportSubscriptionSnapshot& subscription, + const bool active) { ReportUi ui; - ui.active = snapshot.active; - if (!snapshot.subscription) return ui; - const auto& subscription = *snapshot.subscription; + ui.active = active; ui.receivedCount = subscription.received_reports; ui.cleanupRequired = subscription.cleanup_required; for (const auto& event : subscription.events) { @@ -346,8 +459,10 @@ ReportUi buildReportUi(const mms::MmsStaticReportSessionSnapshot& snapshot) { } while (ui.events.size() > 256) ui.events.removeFirst(); - for (auto streamIt = subscription.streams.rbegin(); streamIt != subscription.streams.rend(); ++streamIt) { - for (auto frameIt = streamIt->recent_frames.rbegin(); frameIt != streamIt->recent_frames.rend(); ++frameIt) { + for (auto streamIt = subscription.streams.rbegin(); + streamIt != subscription.streams.rend(); ++streamIt) { + for (auto frameIt = streamIt->recent_frames.rbegin(); + frameIt != streamIt->recent_frames.rend(); ++frameIt) { if (ui.reports.size() >= kMaximumVisibleReports) break; const auto& frame = *frameIt; QVariantMap map; @@ -374,12 +489,30 @@ ReportUi buildReportUi(const mms::MmsStaticReportSessionSnapshot& snapshot) { } return ui; } + +ReportUi buildReportUi(const mms::MmsStaticReportSessionSnapshot& snapshot) { + if (!snapshot.subscription) { + ReportUi ui; + ui.active = snapshot.active; + return ui; + } + return buildSubscriptionUi(*snapshot.subscription, snapshot.active); +} + +ReportUi buildReportUi(const mms::MmsReportSubscriptionSnapshot& snapshot) { + return buildSubscriptionUi( + snapshot, + snapshot.state == mms::MmsReportSubscriptionState::active); +} + } // namespace struct MmsReportController::WorkerState final { std::unique_ptr session; std::shared_ptr discovery; std::unique_ptr report; + std::unique_ptr authoredReport; + std::unique_ptr dynamicDataSets; }; MmsReportController::MmsReportController(QObject* parent) @@ -430,7 +563,7 @@ void MmsReportController::setEngineeringContext(IedEngineeringContextController* } bool MmsReportController::connected() const noexcept { - return state_ == State::ready || state_ == State::enabling || state_ == State::active || + return state_ == State::ready || state_ == State::authoring || state_ == State::enabling || state_ == State::active || state_ == State::disabling || state_ == State::cleanup_required; } @@ -445,6 +578,7 @@ QString MmsReportController::stateText() const { case State::connecting: return QStringLiteral("Connecting"); case State::discovering: return QStringLiteral("Attaching reports"); case State::ready: return QStringLiteral("Ready"); + case State::authoring: return QStringLiteral("Authoring DataSet"); case State::enabling: return QStringLiteral("Enabling RCB"); case State::active: return QStringLiteral("Report subscription active"); case State::disabling: return QStringLiteral("Releasing RCB"); @@ -476,6 +610,7 @@ void MmsReportController::clearInventory() { reportControls_.clear(); staticCandidates_.clear(); dynamicCandidates_.clear(); + ownedDynamicDataSets_.clear(); associationProfile_.clear(); selectedRcbIndex_ = -1; selectedDataSetIndex_ = -1; @@ -551,7 +686,10 @@ bool MmsReportController::connectToIed() { ioPool_.start([self, worker, stop, generation, requestedHost, requestedPort, contextModel] { try { if (worker->report) worker->report->stop(); + if (worker->authoredReport) worker->authoredReport->stop(); worker->report.reset(); + worker->authoredReport.reset(); + worker->dynamicDataSets.reset(); if (worker->session) worker->session->disconnect(); worker->session.reset(); worker->discovery.reset(); @@ -593,6 +731,11 @@ bool MmsReportController::connectToIed() { const auto profile = QString::fromStdString( session->association().active_association_profile()); const auto ui = buildDiscoveryUi(*discovery, profile); + mms::MmsDynamicDataSetOptions dynamicOptions; + dynamicOptions.maximum_members = 64U; + dynamicOptions.verify_after_create = true; + worker->dynamicDataSets = std::make_unique( + session->association(), dynamicOptions); worker->discovery = discovery; worker->session = std::move(session); @@ -619,6 +762,8 @@ bool MmsReportController::connectToIed() { } catch (const std::exception& exception) { if (worker->session) worker->session->disconnect(); worker->report.reset(); + worker->authoredReport.reset(); + worker->dynamicDataSets.reset(); worker->session.reset(); worker->discovery.reset(); if (self) { @@ -661,10 +806,20 @@ void MmsReportController::disconnectFromIed() { if (worker->report) { worker->report->stop(cleanupStop->get_token()); const auto snapshot = worker->report->snapshot(); - cleanupRequired = snapshot.subscription && snapshot.subscription->cleanup_required; + cleanupRequired = + snapshot.subscription && + snapshot.subscription->cleanup_required; + } + if (worker->authoredReport) { + worker->authoredReport->stop(cleanupStop->get_token()); + cleanupRequired = + cleanupRequired || + worker->authoredReport->snapshot().cleanup_required; } if (worker->session) worker->session->disconnect(cleanupStop->get_token()); worker->report.reset(); + worker->authoredReport.reset(); + worker->dynamicDataSets.reset(); worker->discovery.reset(); worker->session.reset(); if (cleanupRequired && self) { @@ -720,6 +875,678 @@ bool MmsReportController::selectDataSet(const int row) { return true; } +bool MmsReportController::createDynamicDataSet( + const QString& dataSetReference, + const QVariantList& canonicalMembers) { + if (!connected() || busy() || active_ || cleanupRequired_) return false; + if (!engineeringContext_ || !engineeringContext_->loaded() || + engineeringContext_->selectionRequired()) { + lastError_ = QStringLiteral( + "Dynamic DataSet authoring requires one active canonical engineering context."); + emit stateChanged(); + return false; + } + const auto requestedReference = dataSetReference.trimmed(); + if (requestedReference.isEmpty()) { + lastError_ = QStringLiteral("Dynamic DataSet reference is required."); + emit stateChanged(); + return false; + } + if (canonicalMembers.isEmpty() || canonicalMembers.size() > 64) { + lastError_ = QStringLiteral( + "Dynamic DataSet requires 1..64 canonical DataAttribute members."); + emit stateChanged(); + return false; + } + + std::vector members; + QStringList canonicalReferences; + QStringList functionalConstraints; + std::set> seen; + members.reserve(static_cast(canonicalMembers.size())); + try { + for (const auto& value : canonicalMembers) { + const auto supplied = value.toMap(); + const auto reference = supplied.value(QStringLiteral("reference")).toString().trimmed(); + if (reference.isEmpty()) { + throw std::invalid_argument( + "Dynamic DataSet member has no canonical DataAttribute reference."); + } + const auto canonical = + engineeringContext_->treeModel()->nodeForReference(reference); + if (canonical.value(QStringLiteral("kind")).toString() != QStringLiteral("DA") || + !canonical.value(QStringLiteral("readable")).toBool()) { + throw std::invalid_argument( + "Dynamic DataSet members must resolve to readable canonical DataAttributes."); + } + const auto domain = + canonical.value(QStringLiteral("mmsDomain")).toString().trimmed(); + const auto item = + canonical.value(QStringLiteral("mmsItem")).toString().trimmed(); + if (domain.isEmpty() || item.isEmpty()) { + throw std::invalid_argument( + "Canonical DataAttribute has no exact MMS domain/item identity."); + } + const auto suppliedDomain = + supplied.value(QStringLiteral("mmsDomain")).toString().trimmed(); + const auto suppliedItem = + supplied.value(QStringLiteral("mmsItem")).toString().trimmed(); + if ((!suppliedDomain.isEmpty() && suppliedDomain != domain) || + (!suppliedItem.isEmpty() && suppliedItem != item)) { + throw std::invalid_argument( + "Dynamic DataSet member MMS identity differs from the canonical engineering context."); + } + const auto key = (domain + QLatin1Char('/') + item).toStdString(); + if (!seen.insert(key).second) { + throw std::invalid_argument( + "Dynamic DataSet contains a duplicate MMS member."); + } + members.push_back(mms::MmsObjectName::domain_specific( + domain.toStdString(), item.toStdString())); + canonicalReferences.push_back(reference); + functionalConstraints.push_back( + canonical.value(QStringLiteral("functionalConstraint")).toString()); + } + } catch (const std::exception& exception) { + lastError_ = QString::fromUtf8(exception.what()); + emit stateChanged(); + return false; + } + + const auto generation = generation_; + const auto stop = stopSource_; + const auto worker = workerState_; + const QPointer self{this}; + operationBusy_ = true; + state_ = State::authoring; + lastError_.clear(); + appendDiagnostic( + QStringLiteral("Define dynamic DataSet requested · %1 · %2 member(s)") + .arg(requestedReference) + .arg(members.size())); + emit stateChanged(); + + ioPool_.start([ + self, worker, stop, generation, requestedReference, + members = std::move(members), canonicalReferences, + functionalConstraints + ]() mutable { + try { + if (!worker->session || !worker->session->associated() || + !worker->discovery || !worker->dynamicDataSets) { + throw std::runtime_error( + "Dynamic DataSet create requires the active Browser report association."); + } + const auto created = worker->dynamicDataSets->create( + requestedReference.toStdString(), members, stop->get_token()); + if (!created.verified_directory.has_value()) { + throw std::runtime_error( + "Dynamic DataSet create returned no verified directory."); + } + + auto candidate = dataSetCandidateFromReference(created.data_set_reference); + mms::MmsDataSetDirectoryEvidence evidence; + evidence.candidate = candidate; + evidence.directory = *created.verified_directory; + for (std::size_t index = 0U; + index < evidence.directory->members.size() && + index < static_cast(canonicalReferences.size()); + ++index) { + auto& member = evidence.directory->members[index]; + member.user_reference = + canonicalReferences.at(static_cast(index)).toStdString(); + member.functional_constraint = + functionalConstraints.at(static_cast(index)).toStdString(); + member.logical_node = candidate.logical_node; + } + + auto& inventory = worker->discovery->report_inventory.data_sets; + inventory.erase( + std::remove_if( + inventory.begin(), inventory.end(), + [&candidate](const auto& item) { + return item.reference == candidate.reference; + }), + inventory.end()); + inventory.push_back(candidate); + + auto& directories = worker->discovery->data_set_directories; + directories.erase( + std::remove_if( + directories.begin(), directories.end(), + [&candidate](const auto& item) { + return item.candidate.reference == candidate.reference; + }), + directories.end()); + directories.push_back(std::move(evidence)); + + QStringList owned; + for (const auto& reference : + worker->dynamicDataSets->owned_data_sets()) { + owned.push_back(QString::fromStdString(reference)); + } + const auto profile = QString::fromStdString( + worker->session->association().active_association_profile()); + auto ui = buildDiscoveryUi(*worker->discovery, profile); + ui.dataSets = markOwnedDataSets(ui.dataSets, owned); + const auto normalized = + QString::fromStdString(created.data_set_reference); + + if (self) { + QMetaObject::invokeMethod( + self, + [self, generation, ui, owned, normalized] { + if (!self || self->generation_ != generation) return; + self->operationBusy_ = false; + self->state_ = State::ready; + self->lastError_.clear(); + self->dataSets_ = ui.dataSets; + self->staticCandidates_ = ui.staticCandidates; + self->dynamicCandidates_ = ui.dynamicCandidates; + self->associationProfile_ = ui.associationProfile; + self->ownedDynamicDataSets_ = owned; + for (int row = 0; row < self->dataSets_.size(); ++row) { + if (sameDataSetReference( + self->dataSets_.at(row).toMap() + .value(QStringLiteral("reference")) + .toString(), + normalized)) { + self->selectedDataSetIndex_ = row; + break; + } + } + self->refreshSelection(); + self->appendDiagnostic( + QStringLiteral( + "Dynamic DataSet created + verified · %1") + .arg(normalized)); + emit self->inventoryChanged(); + emit self->stateChanged(); + }, + Qt::QueuedConnection); + } + } catch (const std::exception& exception) { + if (self) { + const auto message = QString::fromUtf8(exception.what()); + QMetaObject::invokeMethod( + self, + [self, generation, message] { + if (!self || self->generation_ != generation) return; + self->operationBusy_ = false; + self->state_ = State::ready; + self->lastError_ = message; + self->appendDiagnostic( + QStringLiteral("Dynamic DataSet create failed · %1") + .arg(message)); + emit self->stateChanged(); + }, + Qt::QueuedConnection); + } + } + }); + return true; +} + +bool MmsReportController::deleteDynamicDataSet( + const QString& dataSetReference) { + if (!connected() || busy() || active_ || cleanupRequired_) return false; + const auto reference = dataSetReference.trimmed(); + if (reference.isEmpty()) return false; + + bool owned = false; + for (const auto& item : ownedDynamicDataSets_) { + if (sameDataSetReference(reference, item)) { + owned = true; + break; + } + } + if (!owned) { + lastError_ = QStringLiteral( + "Refusing to delete a DataSet not created by this report association."); + emit stateChanged(); + return false; + } + for (const auto& item : reportControls_) { + const auto map = item.toMap(); + if (sameDataSetReference( + reference, + map.value(QStringLiteral("dataSet")).toString())) { + lastError_ = QStringLiteral( + "Rebind the RCB away from this dynamic DataSet before deleting it."); + emit stateChanged(); + return false; + } + } + + const auto generation = generation_; + const auto stop = stopSource_; + const auto worker = workerState_; + const QPointer self{this}; + operationBusy_ = true; + state_ = State::authoring; + lastError_.clear(); + appendDiagnostic( + QStringLiteral("Delete owned dynamic DataSet requested · %1") + .arg(reference)); + emit stateChanged(); + + ioPool_.start([self, worker, stop, generation, reference] { + try { + if (!worker->session || !worker->session->associated() || + !worker->discovery || !worker->dynamicDataSets) { + throw std::runtime_error( + "Dynamic DataSet delete requires the active report association."); + } + const auto response = worker->dynamicDataSets->remove( + reference.toStdString(), + mms::MmsDynamicDataSetDeletePolicy::owned_only, + stop->get_token()); + if (!response.deleted() && + response.number_matched.value_or(1U) != 0U) { + throw std::runtime_error( + "DeleteNamedVariableList did not confirm deletion."); + } + + const auto removeReference = reference; + auto& inventory = worker->discovery->report_inventory.data_sets; + inventory.erase( + std::remove_if( + inventory.begin(), inventory.end(), + [&removeReference](const auto& item) { + return sameDataSetReference( + QString::fromStdString(item.reference), + removeReference); + }), + inventory.end()); + auto& directories = worker->discovery->data_set_directories; + directories.erase( + std::remove_if( + directories.begin(), directories.end(), + [&removeReference](const auto& item) { + return sameDataSetReference( + QString::fromStdString(item.candidate.reference), + removeReference); + }), + directories.end()); + + QStringList ownedReferences; + for (const auto& item : worker->dynamicDataSets->owned_data_sets()) { + ownedReferences.push_back(QString::fromStdString(item)); + } + const auto profile = QString::fromStdString( + worker->session->association().active_association_profile()); + auto ui = buildDiscoveryUi(*worker->discovery, profile); + ui.dataSets = markOwnedDataSets(ui.dataSets, ownedReferences); + + if (self) { + QMetaObject::invokeMethod( + self, + [self, generation, ui, ownedReferences, reference] { + if (!self || self->generation_ != generation) return; + self->operationBusy_ = false; + self->state_ = State::ready; + self->lastError_.clear(); + self->dataSets_ = ui.dataSets; + self->staticCandidates_ = ui.staticCandidates; + self->dynamicCandidates_ = ui.dynamicCandidates; + self->associationProfile_ = ui.associationProfile; + self->ownedDynamicDataSets_ = ownedReferences; + self->selectedDataSetIndex_ = + self->dataSets_.isEmpty() + ? -1 + : std::clamp( + self->selectedDataSetIndex_, + 0, + static_cast(self->dataSets_.size()) - 1); + self->refreshSelection(); + self->appendDiagnostic( + QStringLiteral("Owned dynamic DataSet deleted · %1") + .arg(reference)); + emit self->inventoryChanged(); + emit self->stateChanged(); + }, + Qt::QueuedConnection); + } + } catch (const std::exception& exception) { + if (self) { + const auto message = QString::fromUtf8(exception.what()); + QMetaObject::invokeMethod( + self, + [self, generation, message] { + if (!self || self->generation_ != generation) return; + self->operationBusy_ = false; + self->state_ = State::ready; + self->lastError_ = message; + self->appendDiagnostic( + QStringLiteral("Dynamic DataSet delete failed · %1") + .arg(message)); + emit self->stateChanged(); + }, + Qt::QueuedConnection); + } + } + }); + return true; +} + +bool MmsReportController::enableSelectedAuthored( + const QString& dataSetReference, + const QStringList& triggerOptions, + const QStringList& optionalFields, + const bool requestGeneralInterrogation) { + if (!connected() || busy() || active_ || cleanupRequired_ || + selectedRcb_.isEmpty()) { + return false; + } + const auto rcbReference = + selectedRcb_.value(QStringLiteral("reference")).toString().trimmed(); + const auto targetDataSet = dataSetReference.trimmed(); + if (rcbReference.isEmpty() || targetDataSet.isEmpty()) { + lastError_ = QStringLiteral( + "Authored report enable requires an RCB and DataSet reference."); + emit stateChanged(); + return false; + } + if (requestGeneralInterrogation && + !triggerOptions.contains(QStringLiteral("general-interrogation"))) { + lastError_ = QStringLiteral( + "Enable + GI requires general-interrogation in authored TrgOps."); + emit stateChanged(); + return false; + } + + std::vector trgOps; + std::vector optFlds; + try { + trgOps = triggerPayload(triggerOptions); + optFlds = optionalPayload(optionalFields); + } catch (const std::exception& exception) { + lastError_ = QString::fromUtf8(exception.what()); + emit stateChanged(); + return false; + } + + bool targetOwnedDynamic = false; + for (const auto& reference : ownedDynamicDataSets_) { + if (sameDataSetReference(targetDataSet, reference)) { + targetOwnedDynamic = true; + break; + } + } + const auto attributes = + selectedRcb_.value(QStringLiteral("attributes")).toStringList(); + const bool writeTriggerOptions = + attributes.contains(QStringLiteral("TrgOps")); + const bool writeOptionalFields = + attributes.contains(QStringLiteral("OptFlds")); + if (!writeTriggerOptions && !triggerOptions.isEmpty()) { + lastError_ = QStringLiteral( + "Selected RCB does not expose TrgOps for authoring."); + emit stateChanged(); + return false; + } + if (!writeOptionalFields && !optionalFields.isEmpty()) { + lastError_ = QStringLiteral( + "Selected RCB does not expose OptFlds for authoring."); + emit stateChanged(); + return false; + } + if (targetOwnedDynamic && + !attributes.contains(QStringLiteral("DatSet"))) { + lastError_ = QStringLiteral( + "Selected RCB does not expose DatSet for dynamic binding."); + emit stateChanged(); + return false; + } + + const auto currentDataSet = + selectedRcb_.value(QStringLiteral("dataSet")).toString().trimmed(); + const auto canonicalDataSet = + selectedRcb_.value(QStringLiteral("canonicalDataSet")).toString().trimmed(); + const bool currentBindingAuthoredDynamic = + selectedRcb_.value(QStringLiteral("dynamicBinding")).toBool(); + const bool restoringCanonicalBinding = + currentBindingAuthoredDynamic && + !canonicalDataSet.isEmpty() && + sameDataSetReference(targetDataSet, canonicalDataSet); + const bool bindingChanged = + !sameDataSetReference(targetDataSet, currentDataSet); + if (bindingChanged && !targetOwnedDynamic && !restoringCanonicalBinding) { + lastError_ = QStringLiteral( + "Static/non-owned DataSet binding is immutable in Browser authoring. " + "Only an owned dynamic DataSet may replace it; an ARStack-authored " + "dynamic binding may be explicitly restored to its canonical static DataSet."); + emit stateChanged(); + return false; + } + + const auto generation = generation_; + const auto stop = stopSource_; + const auto worker = workerState_; + const auto selectedRow = selectedRcbIndex_; + const QPointer self{this}; + operationBusy_ = true; + state_ = State::enabling; + lastError_.clear(); + appendDiagnostic( + QStringLiteral( + "Authored RCB enable · %1 · DataSet %2 · TrgOps [%3] · OptFlds [%4]") + .arg( + rcbReference, + targetDataSet, + bitNamesText(triggerOptions), + bitNamesText(optionalFields))); + emit stateChanged(); + + ioPool_.start([ + self, worker, stop, generation, selectedRow, rcbReference, + targetDataSet, triggerOptions, optionalFields, requestGeneralInterrogation, + targetOwnedDynamic, restoringCanonicalBinding, bindingChanged, + writeTriggerOptions, writeOptionalFields, + trgOps = std::move(trgOps), optFlds = std::move(optFlds) + ]() mutable { + try { + if (!worker->session || !worker->session->associated() || + !worker->discovery) { + throw std::runtime_error( + "MMS report association is not active."); + } + if (worker->report) { + const auto previous = worker->report->snapshot(); + if (previous.active || + (previous.subscription && + previous.subscription->cleanup_required)) { + throw std::runtime_error( + "Previous static report subscription still owns state or requires cleanup."); + } + worker->report.reset(); + } + if (worker->authoredReport) { + const auto previous = worker->authoredReport->snapshot(); + if (worker->authoredReport->active() || + previous.cleanup_required) { + throw std::runtime_error( + "Previous authored report subscription still owns state or requires cleanup."); + } + worker->authoredReport.reset(); + } + + ReportUi ui; + if (bindingChanged || targetOwnedDynamic) { + if (targetOwnedDynamic && + (!worker->dynamicDataSets || + !worker->dynamicDataSets->owns(targetDataSet.toStdString()))) { + throw std::runtime_error( + "Dynamic DataSet ownership is no longer valid for this association."); + } + if (restoringCanonicalBinding && targetOwnedDynamic) { + throw std::runtime_error( + "Canonical restore target cannot simultaneously be classified as an owned dynamic DataSet."); + } + + const mms::MmsReportControlCandidate* candidate = nullptr; + for (const auto& item : + worker->discovery->report_inventory.report_controls) { + if (item.reference == rcbReference.toStdString()) { + candidate = &item; + break; + } + } + if (candidate == nullptr) { + throw std::runtime_error( + "Selected RCB is not present in the attached report inventory."); + } + + const mms::MmsDataSetDirectoryResponse* directory = nullptr; + for (const auto& item : + worker->discovery->data_set_directories) { + if (!item.directory.has_value()) continue; + if (sameDataSetReference( + QString::fromStdString(item.candidate.reference), + targetDataSet)) { + directory = &*item.directory; + break; + } + } + if (directory == nullptr || directory->members.empty()) { + throw std::runtime_error( + "Owned dynamic DataSet directory is unavailable or empty."); + } + + mms::MmsReportSubscriptionOptions options; + options.trigger_general_interrogation = + requestGeneralInterrogation; + options.reserve_unbuffered_rcb = true; + options.write_data_set_reference = true; + options.data_set_reference = targetDataSet.toStdString(); + options.write_trigger_options = writeTriggerOptions; + options.trigger_options = trgOps; + options.write_optional_fields = writeOptionalFields; + options.optional_fields = optFlds; + options.maximum_events = 256U; + options.monitor_options.maximum_streams = 64U; + options.monitor_options.maximum_frames_per_stream = 64U; + + auto report = + std::make_unique( + worker->session->association(), + *candidate, + *directory, + options); + report->start(stop->get_token()); + ui = buildReportUi(report->snapshot()); + worker->authoredReport = std::move(report); + } else { + mms::MmsStaticReportSessionOptions options; + options.selection.preferred_rcb_reference = + rcbReference.toStdString(); + options.selection.strict_rcb = true; + options.selection.allow_urcb_fallback = true; + options.selection.allow_polling_fallback = false; + options.maximum_candidate_attempts = 1U; + options.subscription.trigger_general_interrogation = + requestGeneralInterrogation; + options.subscription.reserve_unbuffered_rcb = true; + options.subscription.write_data_set_reference = false; + options.subscription.write_trigger_options = writeTriggerOptions; + options.subscription.trigger_options = trgOps; + options.subscription.write_optional_fields = writeOptionalFields; + options.subscription.optional_fields = optFlds; + options.subscription.maximum_events = 256U; + options.subscription.monitor_options.maximum_streams = 64U; + options.subscription.monitor_options.maximum_frames_per_stream = 64U; + + auto report = + std::make_unique( + worker->session->association(), + *worker->discovery, + options); + report->prepare(stop->get_token()); + report->start(stop->get_token()); + ui = buildReportUi(report->snapshot()); + worker->report = std::move(report); + } + + if (self) { + QMetaObject::invokeMethod( + self, + [self, generation, selectedRow, ui, targetDataSet, + triggerOptions, optionalFields, + requestGeneralInterrogation, targetOwnedDynamic, + restoringCanonicalBinding] { + if (!self || self->generation_ != generation) return; + self->operationBusy_ = false; + self->active_ = ui.active; + self->cleanupRequired_ = ui.cleanupRequired; + self->receivedReports_ = ui.reports; + self->events_ = ui.events; + self->receivedReportCount_ = ui.receivedCount; + if (selectedRow >= 0 && + selectedRow < self->reportControls_.size()) { + auto map = + self->reportControls_.at(selectedRow).toMap(); + map.insert( + QStringLiteral("dataSet"), + targetDataSet); + map.insert( + QStringLiteral("triggerOptions"), + bitNamesText(triggerOptions)); + map.insert( + QStringLiteral("optionalFields"), + bitNamesText(optionalFields)); + map.insert( + QStringLiteral("authored"), + true); + map.insert( + QStringLiteral("dynamicBinding"), + targetOwnedDynamic); + self->reportControls_[selectedRow] = map; + self->selectedRcbIndex_ = selectedRow; + self->refreshSelection(); + } + self->state_ = + ui.cleanupRequired + ? State::cleanup_required + : State::active; + self->appendDiagnostic( + restoringCanonicalBinding + ? QStringLiteral( + "Authored RCB restored to canonical static DataSet; configuration verified and enabled.") + : (requestGeneralInterrogation + ? QStringLiteral( + "Authored RCB configuration verified; enabled; GI requested.") + : QStringLiteral( + "Authored RCB configuration verified and enabled."))); + emit self->inventoryChanged(); + emit self->reportsChanged(); + emit self->stateChanged(); + if (self->active_) self->pollTimer_.start(); + }, + Qt::QueuedConnection); + } + } catch (const std::exception& exception) { + if (self) { + const auto message = QString::fromUtf8(exception.what()); + QMetaObject::invokeMethod( + self, + [self, generation, message] { + if (!self || self->generation_ != generation) return; + self->operationBusy_ = false; + self->active_ = false; + self->lastError_ = message; + self->state_ = State::faulted; + self->appendDiagnostic( + QStringLiteral("Authored RCB enable failed · %1") + .arg(message)); + emit self->stateChanged(); + }, + Qt::QueuedConnection); + } + } + }); + return true; +} + bool MmsReportController::enableSelected(const bool requestGeneralInterrogation) { if (!connected() || busy() || active_ || cleanupRequired_ || selectedRcb_.isEmpty()) return false; const auto reference = selectedRcb_.value(QStringLiteral("reference")).toString(); @@ -746,6 +1573,13 @@ bool MmsReportController::enableSelected(const bool requestGeneralInterrogation) } worker->report.reset(); } + if (worker->authoredReport) { + const auto previous = worker->authoredReport->snapshot(); + if (worker->authoredReport->active() || previous.cleanup_required) { + throw std::runtime_error("Previous authored report subscription still owns state or requires cleanup."); + } + worker->authoredReport.reset(); + } mms::MmsStaticReportSessionOptions options; options.selection.preferred_rcb_reference = reference.toStdString(); @@ -818,11 +1652,18 @@ bool MmsReportController::disableSelected() { const auto worker = workerState_; ioPool_.start([self, worker, stop, generation] { try { - if (!worker->report) throw std::runtime_error("No report subscription is active."); - worker->report->stop(stop->get_token()); - const auto snapshot = worker->report->snapshot(); - const auto ui = buildReportUi(snapshot); - if (!ui.cleanupRequired) worker->report.reset(); + ReportUi ui; + if (worker->report) { + worker->report->stop(stop->get_token()); + ui = buildReportUi(worker->report->snapshot()); + if (!ui.cleanupRequired) worker->report.reset(); + } else if (worker->authoredReport) { + worker->authoredReport->stop(stop->get_token()); + ui = buildReportUi(worker->authoredReport->snapshot()); + if (!ui.cleanupRequired) worker->authoredReport.reset(); + } else { + throw std::runtime_error("No report subscription is active."); + } if (self) { QMetaObject::invokeMethod(self, [self, generation, ui] { if (!self || self->generation_ != generation) return; @@ -873,12 +1714,23 @@ bool MmsReportController::retryCleanup() { const auto worker = workerState_; ioPool_.start([self, worker, stop, generation] { try { - if (!worker->session || !worker->session->associated() || !worker->report) { + if (!worker->session || !worker->session->associated()) { throw std::runtime_error("Cleanup retry requires the original active MMS association."); } - worker->report->stop(stop->get_token()); - const auto ui = buildReportUi(worker->report->snapshot()); - if (!ui.cleanupRequired) worker->report.reset(); + ReportUi ui; + if (worker->report) { + worker->report->stop(stop->get_token()); + ui = buildReportUi(worker->report->snapshot()); + if (!ui.cleanupRequired) worker->report.reset(); + } else if (worker->authoredReport) { + static_cast(worker->authoredReport->retry_cleanup( + stop->get_token())); + ui = buildReportUi(worker->authoredReport->snapshot()); + if (!ui.cleanupRequired) worker->authoredReport.reset(); + } else { + throw std::runtime_error( + "Cleanup retry has no retained report runtime."); + } if (self) { QMetaObject::invokeMethod(self, [self, generation, ui] { if (!self || self->generation_ != generation) return; @@ -923,14 +1775,29 @@ void MmsReportController::schedulePoll() { const QPointer self{this}; ioPool_.start([self, worker, stop, generation] { try { - if (!worker->session || !worker->session->associated() || !worker->report || !worker->report->active()) { - throw std::runtime_error("Report poll requires an active MMS association and RCB subscription."); + if (!worker->session || !worker->session->associated()) { + throw std::runtime_error( + "Report poll requires an active MMS association."); + } + const bool staticActive = + worker->report && worker->report->active(); + const bool authoredActive = + worker->authoredReport && worker->authoredReport->active(); + if (!staticActive && !authoredActive) { + throw std::runtime_error( + "Report poll requires an active RCB subscription."); } mms::MmsPduEnvelope envelope; static_cast(worker->session->association().try_poll_once_for( std::chrono::milliseconds{40}, envelope, stop->get_token())); - static_cast(worker->report->drain_queued_reports()); - const auto ui = buildReportUi(worker->report->snapshot()); + ReportUi ui; + if (staticActive) { + static_cast(worker->report->drain_queued_reports()); + ui = buildReportUi(worker->report->snapshot()); + } else { + static_cast(worker->authoredReport->drain_queued_reports()); + ui = buildReportUi(worker->authoredReport->snapshot()); + } if (self) { QMetaObject::invokeMethod(self, [self, generation, ui] { if (!self || self->generation_ != generation) return; @@ -955,6 +1822,9 @@ void MmsReportController::schedulePoll() { if (worker->report) { worker->report->stop(); ui = buildReportUi(worker->report->snapshot()); + } else if (worker->authoredReport) { + worker->authoredReport->stop(); + ui = buildReportUi(worker->authoredReport->snapshot()); } if (self) { const auto message = QString::fromUtf8(exception.what()); diff --git a/apps/ied_simulator/src/MmsReportController.hpp b/apps/ied_simulator/src/MmsReportController.hpp index 45390338c..a7a03cfe9 100644 --- a/apps/ied_simulator/src/MmsReportController.hpp +++ b/apps/ied_simulator/src/MmsReportController.hpp @@ -31,6 +31,7 @@ class MmsReportController : public QObject { Q_PROPERTY(QVariantList reportControls READ reportControls NOTIFY inventoryChanged) Q_PROPERTY(QVariantList staticCandidates READ staticCandidates NOTIFY inventoryChanged) Q_PROPERTY(QVariantList dynamicCandidates READ dynamicCandidates NOTIFY inventoryChanged) + Q_PROPERTY(QStringList ownedDynamicDataSets READ ownedDynamicDataSets NOTIFY inventoryChanged) Q_PROPERTY(int selectedRcbIndex READ selectedRcbIndex NOTIFY selectionChanged) Q_PROPERTY(int selectedDataSetIndex READ selectedDataSetIndex NOTIFY selectionChanged) Q_PROPERTY(QVariantMap selectedRcb READ selectedRcb NOTIFY selectionChanged) @@ -62,6 +63,7 @@ class MmsReportController : public QObject { [[nodiscard]] QVariantList reportControls() const { return reportControls_; } [[nodiscard]] QVariantList staticCandidates() const { return staticCandidates_; } [[nodiscard]] QVariantList dynamicCandidates() const { return dynamicCandidates_; } + [[nodiscard]] QStringList ownedDynamicDataSets() const { return ownedDynamicDataSets_; } [[nodiscard]] int selectedRcbIndex() const noexcept { return selectedRcbIndex_; } [[nodiscard]] int selectedDataSetIndex() const noexcept { return selectedDataSetIndex_; } [[nodiscard]] QVariantMap selectedRcb() const { return selectedRcb_; } @@ -77,6 +79,15 @@ class MmsReportController : public QObject { Q_INVOKABLE bool selectRcb(int row); Q_INVOKABLE bool selectDataSet(int row); Q_INVOKABLE bool enableSelected(bool requestGeneralInterrogation = true); + Q_INVOKABLE bool enableSelectedAuthored( + const QString& dataSetReference, + const QStringList& triggerOptions, + const QStringList& optionalFields, + bool requestGeneralInterrogation = true); + Q_INVOKABLE bool createDynamicDataSet( + const QString& dataSetReference, + const QVariantList& canonicalMembers); + Q_INVOKABLE bool deleteDynamicDataSet(const QString& dataSetReference); Q_INVOKABLE bool disableSelected(); Q_INVOKABLE bool retryCleanup(); Q_INVOKABLE QString diagnosticsText() const; @@ -96,6 +107,7 @@ class MmsReportController : public QObject { connecting, discovering, ready, + authoring, enabling, active, disabling, @@ -125,6 +137,7 @@ class MmsReportController : public QObject { QVariantList reportControls_; QVariantList staticCandidates_; QVariantList dynamicCandidates_; + QStringList ownedDynamicDataSets_; int selectedRcbIndex_{-1}; int selectedDataSetIndex_{-1}; QVariantMap selectedRcb_; diff --git a/apps/ied_simulator/src/ied_report_workspace_qa.cpp b/apps/ied_simulator/src/ied_report_workspace_qa.cpp index 7beb7d9c6..9e659c8f6 100644 --- a/apps/ied_simulator/src/ied_report_workspace_qa.cpp +++ b/apps/ied_simulator/src/ied_report_workspace_qa.cpp @@ -180,7 +180,18 @@ int main(int argc, char* argv[]) { const bool noConnectionEnableRejected = !negative.enableSelected(true); const bool inactiveDisableRejected = !negative.disableSelected(); const bool invalidSelectionRejected = !negative.selectRcb(0) && !negative.selectDataSet(0); - if (!noConnectionEnableRejected || !inactiveDisableRejected || !invalidSelectionRejected) { + const bool authoredEnableRejected = !negative.enableSelectedAuthored( + QStringLiteral("LD0/LLN0.Test"), + {QStringLiteral("data-change")}, + {QStringLiteral("sequence-number")}, + false); + const bool dynamicCreateRejected = !negative.createDynamicDataSet( + QStringLiteral("LD0/LLN0.Test"), {}); + const bool dynamicDeleteRejected = !negative.deleteDynamicDataSet( + QStringLiteral("LD0/LLN0.Test")); + if (!noConnectionEnableRejected || !inactiveDisableRejected || + !invalidSelectionRejected || !authoredEnableRejected || + !dynamicCreateRejected || !dynamicDeleteRejected) { qCritical() << "REPORTS_WORKBENCH_FAIL negative_preconditions"; return 3; } @@ -232,6 +243,18 @@ int main(int argc, char* argv[]) { << dataSets.size() << reportControls.size() << staticCandidates.size(); return 8; } + for (const auto& value : dataSets) { + const auto item = value.toMap(); + if (item.value(QStringLiteral("dynamicOwned")).toBool() || + !item.value(QStringLiteral("immutable")).toBool()) { + qCritical() << "REPORTS_WORKBENCH_FAIL static_dataset_mutability"; + return 27; + } + } + if (!reports.ownedDynamicDataSets().isEmpty()) { + qCritical() << "REPORTS_WORKBENCH_FAIL unexpected_dynamic_ownership"; + return 28; + } if (!verifyIedScoutIndexedInventory(sclPath, reportControls)) { QStringList actual; for (const auto& value : reportControls) { @@ -356,6 +379,44 @@ int main(int argc, char* argv[]) { return 18; } + if (selectEligibleRcb(reports, false, exactUrcb) < 0) { + qCritical() << "REPORTS_WORKBENCH_FAIL authored_urcb_select"; + return 29; + } + const auto authoredDataSet = + reports.selectedRcb().value(QStringLiteral("dataSet")).toString(); + const QStringList authoredTriggers{ + QStringLiteral("data-change"), + QStringLiteral("quality-change"), + QStringLiteral("integrity"), + QStringLiteral("general-interrogation")}; + const QStringList authoredOptional{ + QStringLiteral("sequence-number"), + QStringLiteral("report-time-stamp"), + QStringLiteral("reason-for-inclusion"), + QStringLiteral("data-set-name"), + QStringLiteral("data-reference"), + QStringLiteral("configuration-revision")}; + if (!reports.enableSelectedAuthored( + authoredDataSet, + authoredTriggers, + authoredOptional, + true) || + !waitUntil([&reports] { return reports.active() && !reports.busy(); }, 8'000) || + !waitUntil([&reports] { return reports.receivedReportCount() > 0; }, 5'000)) { + qCritical().noquote() << "REPORTS_WORKBENCH_FAIL authored_static_enable" + << reports.lastError() + << reports.diagnosticsText(); + return 30; + } + if (!reports.disableSelected() || + !waitUntil([&reports] { return !reports.active() && !reports.busy(); }, 6'000) || + reports.cleanupRequired()) { + qCritical().noquote() << "REPORTS_WORKBENCH_FAIL authored_static_cleanup" + << reports.lastError(); + return 31; + } + qulonglong brcbGiReports = 0; if (iedScoutFixture) { if (selectEligibleRcb(reports, true, exactBrcb) < 0 || @@ -409,7 +470,9 @@ int main(int argc, char* argv[]) { << "brcb_inventory=pass" << "entryid_indicator=pass" << "cleanup=pass" - << "active_reconnect_cleanup=pass"; + << "active_reconnect_cleanup=pass" + << "authored_static_rcb=pass" + << "static_dataset_immutable=pass"; qInfo().noquote() << "REPORTS_WORKBENCH_NEGATIVE_PASS" << "no_connection_enable=rejected" @@ -417,6 +480,7 @@ int main(int argc, char* argv[]) { << "invalid_selection=rejected" << "strict_single_candidate=true" << "idle_poll_nonfatal=true" - << "reconnect_reacquire=true"; + << "reconnect_reacquire=true" + << "dynamic_offline_actions=rejected"; return 0; } diff --git a/apps/ied_simulator/test_browser_report_authoring.py b/apps/ied_simulator/test_browser_report_authoring.py new file mode 100644 index 000000000..67ac4d0c3 --- /dev/null +++ b/apps/ied_simulator/test_browser_report_authoring.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +from pathlib import Path +import sys + +if len(sys.argv) != 7: + raise SystemExit( + "usage: test_browser_report_authoring.py " + "MmsReportController.cpp IedBrowserWorkspace.qml " + "BrowserDataSetAuthoringDialog.qml BrowserReportAuthoringDialog.qml " + "BrowserDataSetPane.qml MmsReportController.hpp" + ) + +controller = Path(sys.argv[1]).read_text(encoding="utf-8") +workspace = Path(sys.argv[2]).read_text(encoding="utf-8") +dataset_dialog = Path(sys.argv[3]).read_text(encoding="utf-8") +report_dialog = Path(sys.argv[4]).read_text(encoding="utf-8") +dataset_pane = Path(sys.argv[5]).read_text(encoding="utf-8") +header = Path(sys.argv[6]).read_text(encoding="utf-8") + +def require(text: str, token: str, label: str) -> None: + if token not in text: + raise SystemExit(f"{label}: missing required token: {token}") + +for token in ( + "MmsDynamicDataSetRuntime", + "dynamicOptions.maximum_members = 64U", + "dynamicOptions.verify_after_create = true", + "dynamicDataSets->create(", + "MmsDynamicDataSetDeletePolicy::owned_only", + "engineeringContext_->treeModel()->nodeForReference(reference)", + "canonicalMembers.size() > 64", + "MmsReportSubscriptionRuntime", + "options.write_data_set_reference = true", + "options.write_trigger_options = writeTriggerOptions", + "options.write_optional_fields = writeOptionalFields", + "options.subscription.write_data_set_reference = false", + "options.selection.allow_polling_fallback = false", + "Enable + GI requires general-interrogation in authored TrgOps", + "Static/non-owned DataSet binding is immutable", +): + require(controller, token, "Report controller") + +# Complete-helper regression: the browser authoring helper must be intact. +helper_start = controller.find("mms::MmsDataSetCandidate dataSetCandidateFromReference(") +helper_end = controller.find("QVariantMap candidateMap(", helper_start) +if helper_start < 0 or helper_end < 0: + raise SystemExit("P6C helper boundary missing") +helper = controller[helper_start:helper_end] +for token in ("candidate.logical_node =", "candidate.raw_mms_name =", "return candidate;", + "QVariantList markOwnedDataSets(", "return dataSets;"): + require(helper, token, "Complete DataSet helper") +if controller.count("MmsReportController::MmsReportController(") != 1: + raise SystemExit("P6C controller duplicated") +for forbidden in ( + "MmsNamedVariableListCodec::encode_define", + "MmsNamedVariableListCodec::encode_delete", +): + if forbidden in controller: + raise SystemExit( + f"Report controller: GUI-local dynamic DataSet wire encoding forbidden: {forbidden}" + ) + +for token in ( + 'text: "Add to DataSet draft…"', + 'text: "New dynamic…"', + 'text: "Delete owned…"', + 'text: "Author…"', + "BrowserDataSetAuthoringDialog {", + "BrowserReportAuthoringDialog {", +): + require(workspace, token, "Browser workspace") + +for token in ( + "readonly property int maximumMembers: 64", + "function addSelected(node)", + "memberModel.move(", + "reports.createDynamicDataSet(", + "reports.deleteDynamicDataSet(", + "id: createConfirm", + "id: deleteDialog", + "Only DataSets created by this live report association", +): + require(dataset_dialog, token, "DataSet authoring dialog") + +for token in ( + "function triggerNames()", + "function optionalNames()", + "reports.enableSelectedAuthored(", + "id: confirmEnable", + "general-interrogation", + "sequence-number", + "reason-for-inclusion", + "configuration-revision", + "Static binding never rewrites DatSet", + "No automatic retry", +): + require(report_dialog, token, "Report authoring dialog") + +for token in ( + "DYNAMIC OWNED", + "STATIC / READ-ONLY", + "Membership is immutable", +): + require(dataset_pane, token, "DataSet pane") + +for token in ( + "ownedDynamicDataSets", + "enableSelectedAuthored", + "createDynamicDataSet", + "deleteDynamicDataSet", +): + require(header, token, "Report controller header") + +if "Timer {" in dataset_dialog or "Timer {" in report_dialog: + raise SystemExit("P6C dialogs: background polling/timers are forbidden") + +print( + "P6C_BROWSER_REPORT_AUTHORING_PASS " + "static_immutable=true dynamic_owned_only=true member_bound=64 " + "create_verify=true canonical_members=true runtime_reuse=true " + "static_poll_fallback=false explicit_enable=true qml_polling=false" +)