diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml new file mode 100644 index 000000000..0f0633906 --- /dev/null +++ b/.github/workflows/arstack-studio-qt.yml @@ -0,0 +1,80 @@ +name: ARStack Studio Qt + +on: + push: + paths: + - 'apps/arstack_studio/**' + - 'include/ariec61850/sampled_values/**' + - 'src/scl/**' + - 'include/ariec61850/scl/**' + - '.github/workflows/arstack-studio-qt.yml' + pull_request: + paths: + - 'apps/arstack_studio/**' + - 'include/ariec61850/sampled_values/**' + - 'src/scl/**' + - 'include/ariec61850/scl/**' + - '.github/workflows/arstack-studio-qt.yml' + +permissions: + contents: read + +jobs: + qt-build-smoke: + runs-on: ubuntu-latest + container: ubuntu:26.04 + steps: + - name: Install build and Qt dependencies + shell: bash + run: | + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ca-certificates git cmake ninja-build g++ \ + qt6-base-dev qt6-declarative-dev qt6-serialport-dev \ + qml6-module-qtquick qml6-module-qtquick-controls \ + qml6-module-qtquick-layouts qml6-module-qtquick-dialogs \ + qml6-module-qtquick-window + + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure + shell: bash + run: | + set -o pipefail + cmake -S apps/arstack_studio -B build-arstack-studio -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DARIEC61850_WARNINGS_AS_ERRORS=ON 2>&1 | tee /tmp/arstack-studio-configure.log + + - name: Build + shell: bash + run: | + set -o pipefail + cmake --build build-arstack-studio --target arstack_studio --parallel 2 \ + 2>&1 | tee /tmp/arstack-studio-build.log + + - name: Headless QML launch smoke + shell: bash + run: | + set +e + timeout 6s env QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \ + ./build-arstack-studio/arstack_studio > /tmp/arstack-studio-launch.log 2>&1 + status=$? + set -e + cat /tmp/arstack-studio-launch.log + if [ "$status" -ne 124 ]; then + echo "ARStack Studio exited before smoke timeout (status=$status)." + exit "$status" + fi + + - name: Retain Qt smoke logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: arstack-studio-qt-logs + path: | + /tmp/arstack-studio-configure.log + /tmp/arstack-studio-build.log + /tmp/arstack-studio-launch.log + if-no-files-found: ignore + retention-days: 7 diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt new file mode 100644 index 000000000..eb5ec7639 --- /dev/null +++ b/apps/arstack_studio/CMakeLists.txt @@ -0,0 +1,59 @@ +cmake_minimum_required(VERSION 3.24) + +project(ARStackStudio VERSION 0.1.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTORCC ON) + +find_package(Qt6 6.5 REQUIRED COMPONENTS Core Gui Qml Quick QuickControls2 SerialPort) +qt_standard_project_setup(REQUIRES 6.5) + +# Reuse the repository's IEC 61850/SCL implementation directly. The desktop UI +# is a presentation/control plane; it must not grow a second SCL semantic engine. +set(ARIEC61850_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(ARIEC61850_BUILD_TOOLS OFF CACHE BOOL "" FORCE) +set(ARIEC61850_BUILD_FUZZERS OFF CACHE BOOL "" FORCE) +add_subdirectory(${CMAKE_CURRENT_LIST_DIR}/../.. ${CMAKE_BINARY_DIR}/ariec61850-core EXCLUDE_FROM_ALL) + +qt_add_executable(arstack_studio + src/main.cpp + src/SclProfileModel.cpp + src/SclProfileModel.hpp + src/DeviceController.cpp + src/DeviceController.hpp +) + +qt_add_qml_module(arstack_studio + URI ARStack.Studio + VERSION 1.0 + QML_FILES + qml/Main.qml + qml/StudioTheme.qml + qml/SurfacePanel.qml + qml/CalmButton.qml + qml/NumericField.qml + qml/StateBadge.qml + qml/ProfileInspector.qml + qml/SignalMatrix.qml + qml/QualityEditor.qml + qml/SignalPreview.qml + qml/StatusToast.qml +) + +target_link_libraries(arstack_studio PRIVATE + Qt6::Core + Qt6::Gui + Qt6::Qml + Qt6::Quick + Qt6::QuickControls2 + Qt6::SerialPort + ARIEC61850::core +) + +target_compile_features(arstack_studio PRIVATE cxx_std_20) + +if(WIN32) + set_target_properties(arstack_studio PROPERTIES WIN32_EXECUTABLE TRUE) +endif() diff --git a/apps/arstack_studio/README.md b/apps/arstack_studio/README.md new file mode 100644 index 000000000..3bf9b0805 --- /dev/null +++ b/apps/arstack_studio/README.md @@ -0,0 +1,105 @@ +# ARStack Studio — native Qt SMV Injector + +`apps/arstack_studio` is the native Qt 6 / C++ / QML operator surface for the ESP32-P4 Sampled Values injector. + +This branch is intentionally stacked on `feature/smv-scl-profile-bridge` so the desktop application follows the latest SCL/profile and embedded-runtime behavior instead of freezing an older web-GUI snapshot. + +## Architecture + +```text +SCL / CID / SCD / IID + -> ARIEC61850 SclParser + -> SvPublisherProfileCompiler + -> shared ESP32-P4 deployment support classifier + -> Qt SclProfileModel + -> operator selects / validates stream + -> Qt DeviceController (QSerialPort) + -> current ESP32-P4 PROFILE + live-control protocol + -> deterministic publisher on ESP32-P4 +``` + +The Qt application is a presentation and control plane. The PC is not the 4000/4800 fps realtime clock and does not replace the ESP32-P4 publisher. + +## Latest workflow carried into Qt + +- direct native SCL/CID/SCD/IID import using the repository C++ parser; +- resolved SampledValueControl stream selection; +- compatibility Class A / B / C; +- fail-closed ESP32-P4 device-support classification; +- explicit sample-counter-modulus confirmation for Class-B candidate profiles; +- explicit current counts/A and voltage counts/V test scaling; +- immutable profile deployment while STOPPED; +- profile identity: svID, data set, destination MAC, APPID, VLAN/PCP, confRev, publisher rate and counter modulus; +- optional DataSet / SampleRate ASDU-field flags already supported by the current device bridge; +- native serial 115200 8N1 control using Qt SerialPort; +- live magnitude, phase, frequency, enable and quality controls; +- START / STOP / ZERO / SHOW and runtime telemetry; +- keyboard-first 4I + 4V matrix navigation; +- 3-phase linkage; +- persistent generated phasor and waveform preview; +- serial diagnostics surface. + +The current deployment gate intentionally accepts only the embedded layout already supported by the ESP32-P4 runtime. Broader SCL structures may parse correctly while remaining `unsupported-layout`; the GUI does not guess them into the firmware profile. + +## Truthfulness boundary + +The phasor and waveform are **generated setpoint previews**. They visualize the configured engineering state. They are not independent Ethernet on-wire evidence. + +A later Process Bus monitor can add an observed trace and generated-vs-observed comparison without changing this distinction. + +`smpSynch` remains an embedded timing/synchronization truth. The GUI must not claim synchronized output until the runtime has measured synchronization evidence. + +## Build + +Qt 6.5+ is required with: + +- Qt Core / Gui / Qml / Quick / QuickControls2 +- Qt SerialPort +- a C++20 compiler +- CMake 3.24+ + +### Windows example + +From the repository root with a Qt-enabled developer shell: + +```powershell +cmake -S apps/arstack_studio -B build-arstack-studio -G Ninja -DCMAKE_BUILD_TYPE=Release +cmake --build build-arstack-studio --target arstack_studio --parallel +.\build-arstack-studio\arstack_studio.exe +``` + +For redistribution, package the required dynamically linked Qt runtime libraries with the normal Qt deployment tooling and review the applicable Qt/LGPL distribution obligations for the chosen module set. + +## CI + +`.github/workflows/arstack-studio-qt.yml` performs: + +1. standalone Qt configure; +2. native C++ / QML build; +3. headless QML launch smoke test; +4. retained configure/build/launch logs on every run. + +A green desktop CI proves that the application builds and QML instantiates in the hosted environment. It does **not** replace physical ESP32-P4 / Ethernet validation. + +## Physical acceptance still required + +Before this desktop GUI is treated as a proven replacement for the current bench surface, retain hardware evidence for at least: + +1. native serial connect and SHOW/PROFILE SHOW; +2. development 4000 fps START/STOP and live SET/FREQ/ENABLE/QUALITY updates; +3. SCL Class-A profile deployment followed by PROFILE committed/armed confirmation; +4. 4800 fps profile deployment and observed ~4800 fps with the expected smpCnt cycle; +5. live magnitude/phase changes without unintended counter restart; +6. generated-vs-on-wire identity comparison using a trusted capture path. + +## UI direction + +The desktop visual target is a calm premium engineering instrument: + +- Current and Voltage are the primary working matrices; +- SCL/profile information stays available without dominating the work surface; +- phasor and waveform remain persistent; +- Start/Stop remain fixed and obvious; +- values are optimized for keyboard operation; +- no browser/local-web-server dependency is required by the desktop application; +- visualization remains intentionally lightweight before any future custom `QQuickItem`/scene-graph optimization. diff --git a/apps/arstack_studio/qml/CalmButton.qml b/apps/arstack_studio/qml/CalmButton.qml new file mode 100644 index 000000000..4653edbca --- /dev/null +++ b/apps/arstack_studio/qml/CalmButton.qml @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls + +Button { + id: control + property var theme + property string uiFont: "Inter" + property string tone: "neutral" + + implicitHeight: 34 + implicitWidth: Math.max(70, contentItem.implicitWidth + 24) + font.family: uiFont + font.pixelSize: 10 + font.weight: Font.DemiBold + + readonly property color activeFill: !enabled ? "#121820" : + tone === "success" ? "#194d38" : + tone === "danger" ? theme.redSoft : + tone === "accent" ? theme.accentSoft : + (down ? "#1c2733" : hovered ? theme.raisedHover : theme.raised) + readonly property color activeEdge: !enabled ? theme.lineSoft : + tone === "success" ? "#347a59" : + tone === "danger" ? "#86434b" : + tone === "accent" ? "#315f8d" : + (hovered ? "#3a4a5b" : theme.line) + readonly property color activeText: !enabled ? theme.muted2 : + tone === "success" ? "#d6f4e6" : + tone === "danger" ? "#ffdce0" : theme.textSoft + + contentItem: Text { + text: control.text + color: control.activeText + font: control.font + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + radius: 6 + color: control.activeFill + border.width: 1 + border.color: control.activeEdge + Behavior on color { ColorAnimation { duration: 90 } } + } +} \ No newline at end of file diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml new file mode 100644 index 000000000..a0b1a7773 --- /dev/null +++ b/apps/arstack_studio/qml/Main.qml @@ -0,0 +1,776 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import ARStack.Studio 1.0 + +ApplicationWindow { + id: root + width: 1480 + height: 900 + minimumWidth: 1080 + minimumHeight: 720 + visible: true + title: "ARStack Studio · SMV Injector" + color: studioTheme.bg + + StudioTheme { id: studioTheme } + + property string uiFont: "Inter" + property string monoFont: "Cascadia Mono" + property bool phaseLink: false + property bool profileDirty: false + property real currentScale: 1000.0 + property real voltageScale: 100.0 + property real signalFrequency: 50.0 + property string activeSignal: "Ia" + property int activeGroup: 0 + property int activeRow: 0 + property string activeUnit: "A" + property real activeMagnitude: 1.0 + property real activePhase: 0.0 + property real activeQuality: 0 + property string transientMessage: "" + property bool transientError: false + + readonly property bool compactLayout: width < 1300 + readonly property bool selectedProfileDeployable: + sclProfiles.selectedProfile.compatibilityClass === "A" && + sclProfiles.selectedProfile.deviceSupport === "ready" + readonly property bool canDeploy: + device.connected && !device.running && selectedProfileDeployable && !device.profileDeploying + readonly property bool canStart: + device.connected && !device.running && + (!sclProfiles.hasProfiles || (device.profileArmed && !profileDirty)) + readonly property string instrumentState: { + if (!device.connected) return "OFFLINE" + if (device.profileDeploying) return "DEPLOYING" + if (device.running) return "RUNNING" + if (profileDirty) return "PROFILE CHANGED" + if (sclProfiles.hasProfiles && !selectedProfileDeployable) return "PROFILE BLOCKED" + if (sclProfiles.hasProfiles && !device.profileArmed) return "PROFILE VALIDATED" + if (device.profileArmed) return "ARMED" + return "CONNECTED" + } + readonly property color instrumentStateColor: { + if (instrumentState === "RUNNING") return studioTheme.green + if (instrumentState === "ARMED") return studioTheme.accent + if (instrumentState === "DEPLOYING" || instrumentState === "PROFILE CHANGED") return studioTheme.amber + if (instrumentState === "PROFILE BLOCKED") return studioTheme.red + return device.connected ? studioTheme.textSoft : studioTheme.muted + } + readonly property string stateReason: { + if (!device.connected) return "Connect the injector; setpoints remain editable offline." + if (device.profileDeploying) return "Committing immutable SCL profile to the device." + if (device.running) return "Live apply active · valid edits update SMV immediately." + if (profileDirty) return "Selected profile changed · deploy before Start." + if (sclProfiles.hasProfiles && !selectedProfileDeployable) return "Selected stream is outside the current ESP32-P4 deployment boundary." + if (sclProfiles.hasProfiles && !device.profileArmed) return "Profile validated · deploy to arm the injector." + if (device.profileArmed) return "Ready · live setpoint editing is armed." + return "Connected · development setpoints ready." + } + readonly property string toastMessage: transientMessage.length ? transientMessage : device.lastError + readonly property bool toastError: transientMessage.length ? transientError : device.lastError.length > 0 + + SclProfileModel { id: sclProfiles } + DeviceController { id: device } + + Connections { + target: device + function onProfileStateChanged() { + if (device.profileArmed && !device.profileDeploying) + root.profileDirty = false + } + function onDeviceMessage(message) { + root.showMessage(message, false) + } + function onConnectedChanged() { + if (device.connected) + Qt.callLater(root.applyAllSignals) + } + } + + Timer { + id: messageTimer + interval: 2600 + onTriggered: { + root.transientMessage = "" + root.transientError = false + } + } + + ListModel { + id: currentModel + ListElement { signalId: "Ia"; magnitude: 1.000; phase: 0.0; enabled: true; quality: 0; traceColor: "#ff6673" } + ListElement { signalId: "Ib"; magnitude: 1.000; phase: -120.0; enabled: true; quality: 0; traceColor: "#e6b552" } + ListElement { signalId: "Ic"; magnitude: 1.000; phase: 120.0; enabled: true; quality: 0; traceColor: "#59a7ff" } + ListElement { signalId: "In"; magnitude: 0.000; phase: 0.0; enabled: true; quality: 0; traceColor: "#9a88df" } + } + + ListModel { + id: voltageModel + ListElement { signalId: "Ua"; magnitude: 57.740; phase: 0.0; enabled: true; quality: 0; traceColor: "#ff6673" } + ListElement { signalId: "Ub"; magnitude: 57.740; phase: -120.0; enabled: true; quality: 0; traceColor: "#e6b552" } + ListElement { signalId: "Uc"; magnitude: 57.740; phase: 120.0; enabled: true; quality: 0; traceColor: "#59a7ff" } + ListElement { signalId: "Un"; magnitude: 0.000; phase: 0.0; enabled: true; quality: 0; traceColor: "#9a88df" } + } + + function showMessage(message, error) { + transientMessage = message + transientError = error === true + messageTimer.restart() + } + + function groupModel(group) { + return group === 0 ? currentModel : voltageModel + } + + function groupMatrix(group) { + return group === 0 ? currentMatrix : voltageMatrix + } + + function parseOperatorNumber(text) { + var normalized = String(text).trim() + .replace(/\s/g, "") + .replace(/degrees?/ig, "") + .replace(/deg/ig, "") + .replace(/°/g, "") + .replace(/Hz/ig, "") + .replace(/[AV]$/i, "") + if (normalized.indexOf(",") >= 0 && normalized.indexOf(".") < 0) + normalized = normalized.replace(",", ".") + if (!normalized.length || normalized === "+" || normalized === "-" || normalized === ".") + return NaN + return Number(normalized) + } + + function maximumMagnitude(group) { + var scale = group === 0 ? currentScale : voltageScale + if (!isFinite(scale) || scale <= 0) + return 0 + return Math.min(1000000000.0, 2147483647.0 / scale) + } + + function validMagnitude(group, value) { + return isFinite(value) && value >= 0 && value <= maximumMagnitude(group) + } + + function validPhase(value) { + return isFinite(value) && Math.abs(value) <= 360000.0 + } + + function validFrequency(value) { + return isFinite(value) && value > 0 && value <= 1000.0 + } + + function normalizedAngle(value) { + var result = value % 360 + if (result > 180) result -= 360 + if (result <= -180) result += 360 + return result + } + + function refreshPreview() { + if (previewPanel) + previewPanel.requestPaint() + } + + function selectSignal(group, row) { + var signal = groupModel(group).get(row) + activeGroup = group + activeRow = row + activeSignal = signal.signalId + activeUnit = group === 0 ? "A" : "V" + activeMagnitude = signal.magnitude + activePhase = signal.phase + activeQuality = Number(signal.quality) + refreshPreview() + } + + function focusCell(group, row, column) { + var nextGroup = group + var nextRow = row + if (nextRow < 0) { + nextGroup = (nextGroup + 1) % 2 + nextRow = 3 + } + if (nextRow > 3) { + nextGroup = (nextGroup + 1) % 2 + nextRow = 0 + } + groupMatrix(nextGroup).focusCell(nextRow, column) + } + + function navigate(group, row, column, key) { + if (key === Qt.Key_Up) + focusCell(group, row - 1, column) + else if (key === Qt.Key_Down) + focusCell(group, row + 1, column) + else if (key === Qt.Key_Left) + column === 1 ? focusCell(group, row, 0) : focusCell(group, row - 1, 1) + else if (key === Qt.Key_Right) + column === 0 ? focusCell(group, row, 1) : focusCell(group, row + 1, 0) + } + + function sendSignal(group, row) { + if (!device.connected) + return true + var signal = groupModel(group).get(row) + return device.setSignal( + signal.signalId, + signal.magnitude, + signal.phase, + Number(signal.quality), + currentScale, + voltageScale) + } + + function sendLinkedGroup(group) { + var ok = true + for (var i = 0; i < 3; ++i) + ok = sendSignal(group, i) && ok + return ok + } + + function applyGroupSignals(group) { + for (var row = 0; row < 4; ++row) + sendSignal(group, row) + } + + function editSignal(group, row, field, value) { + if ((field === "magnitude" && !validMagnitude(group, value)) || + (field === "phase" && !validPhase(value))) + return false + + var model = groupModel(group) + model.setProperty(row, field, value) + + if (phaseLink && row < 3) { + if (field === "magnitude") { + for (var i = 0; i < 3; ++i) + model.setProperty(i, "magnitude", value) + } else { + var base = value + if (row === 1) base += 120 + if (row === 2) base -= 120 + model.setProperty(0, "phase", normalizedAngle(base)) + model.setProperty(1, "phase", normalizedAngle(base - 120)) + model.setProperty(2, "phase", normalizedAngle(base + 120)) + } + } + + selectSignal(group, row) + if (device.connected) { + if (phaseLink && row < 3) + sendLinkedGroup(group) + else + sendSignal(group, row) + } + return true + } + + function applyAllSignals() { + if (!device.connected) + return + for (var group = 0; group < 2; ++group) + applyGroupSignals(group) + device.setFrequency(signalFrequency) + } + + function setFrequencyValue(value) { + if (!validFrequency(value)) + return false + signalFrequency = value + frequencyField.text = value.toFixed(3) + frequencyField.invalidInput = false + refreshPreview() + if (device.connected) + device.setFrequency(value) + return true + } + + function balanced() { + var angles = [0, -120, 120, 0] + for (var i = 0; i < 4; ++i) { + currentModel.setProperty(i, "magnitude", i < 3 ? 1.0 : 0.0) + currentModel.setProperty(i, "phase", angles[i]) + currentModel.setProperty(i, "enabled", true) + voltageModel.setProperty(i, "magnitude", i < 3 ? 57.74 : 0.0) + voltageModel.setProperty(i, "phase", angles[i]) + voltageModel.setProperty(i, "enabled", true) + } + selectSignal(activeGroup, activeRow) + refreshPreview() + if (device.connected) + applyAllSignals() + } + + function zeroAll() { + for (var i = 0; i < 4; ++i) { + currentModel.setProperty(i, "magnitude", 0.0) + voltageModel.setProperty(i, "magnitude", 0.0) + } + selectSignal(activeGroup, activeRow) + refreshPreview() + if (device.connected) + device.zero() + } + + function setActiveQuality(value) { + var unsignedValue = Number(value) >>> 0 + groupModel(activeGroup).setProperty(activeRow, "quality", unsignedValue) + activeQuality = unsignedValue + if (device.connected) + device.setQuality(activeSignal, unsignedValue) + } + + Dialog { + id: diagnosticsDialog + width: Math.min(root.width * 0.72, 920) + height: Math.min(root.height * 0.72, 590) + anchors.centerIn: Overlay.overlay + modal: true + title: "Device diagnostics" + standardButtons: Dialog.Close + background: Rectangle { + color: studioTheme.surface + radius: 8 + border.width: 1 + border.color: studioTheme.line + } + contentItem: ColumnLayout { + spacing: 8 + RowLayout { + Layout.fillWidth: true + Label { + text: device.connected ? device.portName + " · 115200 8N1" : "Device offline" + color: studioTheme.muted + font.family: root.uiFont + font.pixelSize: 9 + } + Item { Layout.fillWidth: true } + CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "Clear"; onClicked: device.clearLog() } + } + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + TextArea { + readOnly: true + text: device.logText + color: studioTheme.textSoft + selectionColor: studioTheme.accent + font.family: root.monoFont + font.pixelSize: 9 + wrapMode: TextEdit.WrapAnywhere + background: Rectangle { color: "#090e14"; radius: 6; border.width: 1; border.color: studioTheme.lineSoft } + } + } + } + } + + header: Rectangle { + height: 54 + color: studioTheme.chrome + border.width: 1 + border.color: studioTheme.lineSoft + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 15 + anchors.rightMargin: 15 + spacing: 9 + + Rectangle { + width: 27 + height: 27 + radius: 6 + color: "#101b27" + border.width: 1 + border.color: "#315071" + Label { anchors.centerIn: parent; text: "≋"; color: studioTheme.accent; font.pixelSize: 16 } + } + + ColumnLayout { + spacing: 0 + Label { text: "ARSTACK61850"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: 7; font.weight: Font.Bold; font.letterSpacing: 1.15 } + Label { text: "SMV Injector"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: 13; font.weight: Font.DemiBold } + } + + Item { Layout.fillWidth: true } + + Rectangle { + visible: device.connected + implicitWidth: liveApplyText.implicitWidth + 18 + implicitHeight: 22 + radius: 5 + color: studioTheme.greenSoft + border.width: 1 + border.color: "#2b674d" + Label { + id: liveApplyText + anchors.centerIn: parent + text: "LIVE APPLY" + color: studioTheme.green + font.family: root.monoFont + font.pixelSize: 7 + font.weight: Font.Bold + font.letterSpacing: 0.65 + } + } + + Label { + visible: !root.compactLayout + text: sclProfiles.hasProfiles ? (sclProfiles.selectedProfile.svId || "Resolved SV") : "Development profile" + color: studioTheme.textSoft + font.family: root.monoFont + font.pixelSize: 9 + } + + Rectangle { width: 1; height: 19; color: studioTheme.line } + + ComboBox { + id: portCombo + implicitWidth: root.compactLayout ? 96 : 112 + model: device.ports + enabled: !device.connected + font.family: root.monoFont + font.pixelSize: 9 + onPressedChanged: if (pressed) device.refreshPorts() + } + + Rectangle { width: 7; height: 7; radius: 4; color: device.connected ? studioTheme.green : studioTheme.muted2 } + + Label { + visible: !root.compactLayout + text: device.connected ? device.portName : "Device offline" + color: studioTheme.muted + font.family: root.uiFont + font.pixelSize: 9 + } + + CalmButton { + theme: studioTheme + uiFont: root.uiFont + text: device.connected ? "Disconnect" : "Connect" + onClicked: { + if (device.connected) device.disconnectPort() + else device.connectPort(portCombo.currentText) + } + } + } + } + + footer: Rectangle { + height: 62 + color: studioTheme.chrome + border.width: 1 + border.color: studioTheme.lineSoft + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 15 + anchors.rightMargin: 15 + spacing: 9 + + StateBadge { theme: studioTheme; state: root.instrumentState; stateColor: root.instrumentStateColor; monoFont: root.monoFont } + + ColumnLayout { + Layout.maximumWidth: root.compactLayout ? 280 : 420 + spacing: 1 + Label { + Layout.fillWidth: true + text: root.stateReason + color: studioTheme.muted + font.family: root.uiFont + font.pixelSize: 9 + elide: Text.ElideRight + } + Label { + text: "FPS " + device.fps + " · MISSED " + device.missed + " · TX FAIL " + device.txFailures + color: studioTheme.muted + font.family: root.monoFont + font.pixelSize: 7 + } + } + + Item { Layout.fillWidth: true } + + CalmButton { + visible: !root.compactLayout + theme: studioTheme + uiFont: root.uiFont + text: "Diagnostics" + onClicked: diagnosticsDialog.open() + } + + CalmButton { + theme: studioTheme + uiFont: root.uiFont + tone: "accent" + text: device.profileDeploying ? "Deploying…" : "Deploy" + enabled: root.canDeploy + onClicked: if (device.deployProfile(sclProfiles.selectedProfile)) root.profileDirty = true + } + + CalmButton { + theme: studioTheme + uiFont: root.uiFont + tone: "danger" + text: "■ Stop" + implicitWidth: 88 + implicitHeight: 35 + enabled: device.connected && device.running + onClicked: device.stop() + } + + CalmButton { + theme: studioTheme + uiFont: root.uiFont + tone: "success" + text: "▶ Start" + implicitWidth: 98 + implicitHeight: 35 + enabled: root.canStart + onClicked: device.start() + } + } + } + + RowLayout { + anchors.fill: parent + anchors.margins: root.compactLayout ? 9 : 11 + spacing: root.compactLayout ? 9 : 11 + + ProfileInspector { + Layout.preferredWidth: root.compactLayout ? 200 : 238 + Layout.minimumWidth: root.compactLayout ? 192 : 214 + Layout.fillHeight: true + theme: studioTheme + controller: root + device: device + profiles: sclProfiles + uiFont: root.uiFont + monoFont: root.monoFont + compact: root.compactLayout + } + + SurfacePanel { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumWidth: root.compactLayout ? 500 : 550 + theme: studioTheme + + ColumnLayout { + anchors.fill: parent + anchors.margins: root.compactLayout ? 12 : 15 + spacing: 9 + + RowLayout { + Layout.fillWidth: true + ColumnLayout { + spacing: 1 + Label { text: "INJECTION WORKSPACE"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: 8; font.weight: Font.DemiBold; font.letterSpacing: 1.0 } + Label { text: "Manual Injection"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: root.compactLayout ? 18 : 20; font.weight: Font.DemiBold } + } + Item { Layout.fillWidth: true } + Rectangle { + implicitWidth: workspaceApplyText.implicitWidth + 17 + implicitHeight: 22 + radius: 5 + color: device.connected ? studioTheme.greenSoft : "#141b23" + border.width: 1 + border.color: device.connected ? "#2c674e" : studioTheme.lineSoft + Label { + id: workspaceApplyText + anchors.centerIn: parent + text: device.connected ? "LIVE APPLY" : "LOCAL EDIT" + color: device.connected ? studioTheme.green : studioTheme.muted + font.family: root.monoFont + font.pixelSize: 7 + font.weight: Font.Bold + } + } + } + + Rectangle { + Layout.fillWidth: true + height: 53 + radius: 7 + color: studioTheme.surface2 + border.width: 1 + border.color: studioTheme.lineSoft + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 11 + anchors.rightMargin: 11 + spacing: 8 + + ColumnLayout { + spacing: 0 + Label { text: "FREQUENCY"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: 7; font.weight: Font.DemiBold; font.letterSpacing: 0.85 } + RowLayout { + spacing: 5 + NumericField { + id: frequencyField + theme: studioTheme + monoFont: root.monoFont + compact: root.compactLayout + implicitWidth: 84 + text: "50.000" + validator: DoubleValidator { bottom: 0.001; top: 1000.0; decimals: 3 } + onTextEdited: { + var value = root.parseOperatorNumber(text) + if (root.validFrequency(value)) { + invalidInput = false + root.signalFrequency = value + root.refreshPreview() + if (device.connected) device.setFrequency(value) + } else { + invalidInput = true + } + } + onEditingFinished: { + var value = root.parseOperatorNumber(text) + if (!root.validFrequency(value)) { + text = root.signalFrequency.toFixed(3) + invalidInput = false + root.showMessage("Frequency must be greater than 0 and not exceed 1000 Hz.", true) + } else { + text = value.toFixed(3) + } + } + } + Label { text: "Hz"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: 8 } + } + } + + CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "50"; implicitWidth: 44; onClicked: root.setFrequencyValue(50) } + CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "60"; implicitWidth: 44; onClicked: root.setFrequencyValue(60) } + + Rectangle { width: 1; height: 24; color: studioTheme.lineSoft } + + CheckBox { + checked: root.phaseLink + text: "3-phase link" + onToggled: root.phaseLink = checked + font.family: root.uiFont + font.pixelSize: 9 + } + + Item { Layout.fillWidth: true } + + Label { + visible: !root.compactLayout + text: device.connected ? "Valid edit → SMV immediately" : "Connect to stream edits live" + color: device.connected ? studioTheme.green : studioTheme.muted + font.family: root.uiFont + font.pixelSize: 8 + } + } + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 9 + + SignalMatrix { + id: currentMatrix + Layout.fillWidth: true + Layout.fillHeight: true + theme: studioTheme + controller: root + device: device + sourceModel: currentModel + uiFont: root.uiFont + monoFont: root.monoFont + groupIndex: 0 + titleText: "Current" + symbolText: "I" + unitText: "A RMS" + compact: root.compactLayout + } + + SignalMatrix { + id: voltageMatrix + Layout.fillWidth: true + Layout.fillHeight: true + theme: studioTheme + controller: root + device: device + sourceModel: voltageModel + uiFont: root.uiFont + monoFont: root.monoFont + groupIndex: 1 + titleText: "Voltage" + symbolText: "U" + unitText: "V RMS" + compact: root.compactLayout + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: studioTheme.lineSoft } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + Rectangle { width: 6; height: 6; radius: 3; color: studioTheme.accent } + Label { text: root.activeSignal; color: studioTheme.textSoft; font.family: root.monoFont; font.pixelSize: 9; font.weight: Font.Bold } + Label { + text: root.activeMagnitude.toFixed(3) + " " + root.activeUnit + " RMS · " + root.activePhase.toFixed(2) + "°" + color: studioTheme.muted + font.family: root.uiFont + font.pixelSize: 9 + } + Rectangle { width: 1; height: 20; color: studioTheme.lineSoft } + QualityEditor { + Layout.fillWidth: true + theme: studioTheme + controller: root + device: device + uiFont: root.uiFont + monoFont: root.monoFont + qualityValue: root.activeQuality + compact: root.compactLayout + } + Label { + visible: !root.compactLayout + text: "↑↓ channel ←→ field" + color: studioTheme.muted + font.family: root.uiFont + font.pixelSize: 8 + } + } + } + } + + SignalPreview { + id: previewPanel + Layout.preferredWidth: root.compactLayout ? 290 : 360 + Layout.minimumWidth: root.compactLayout ? 280 : 320 + Layout.fillHeight: true + theme: studioTheme + currentModel: currentModel + voltageModel: voltageModel + uiFont: root.uiFont + monoFont: root.monoFont + compact: root.compactLayout + activeSignal: root.activeSignal + activeUnit: root.activeUnit + activeMagnitude: root.activeMagnitude + activePhase: root.activePhase + signalFrequency: root.signalFrequency + } + } + + StatusToast { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: 72 + theme: studioTheme + uiFont: root.uiFont + message: root.toastMessage + error: root.toastError + } +} \ No newline at end of file diff --git a/apps/arstack_studio/qml/NumericField.qml b/apps/arstack_studio/qml/NumericField.qml new file mode 100644 index 000000000..a64b1e671 --- /dev/null +++ b/apps/arstack_studio/qml/NumericField.qml @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls + +TextField { + id: field + property var theme + property string monoFont: "Cascadia Mono" + property bool invalidInput: false + property bool compact: false + + selectByMouse: true + horizontalAlignment: Text.AlignRight + color: invalidInput ? theme.red : theme.text + selectionColor: theme.accent + selectedTextColor: theme.bg + font.family: monoFont + font.pixelSize: compact ? 10 : 12 + font.weight: Font.DemiBold + leftPadding: 8 + rightPadding: 8 + implicitHeight: compact ? 31 : 34 + + background: Rectangle { + radius: 6 + color: field.activeFocus ? "#0c141d" : "#111820" + border.width: field.activeFocus || field.invalidInput ? 1 : 0 + border.color: field.invalidInput ? theme.red : theme.accent + Behavior on color { ColorAnimation { duration: 80 } } + } +} \ No newline at end of file diff --git a/apps/arstack_studio/qml/ProfileInspector.qml b/apps/arstack_studio/qml/ProfileInspector.qml new file mode 100644 index 000000000..0b298bef1 --- /dev/null +++ b/apps/arstack_studio/qml/ProfileInspector.qml @@ -0,0 +1,357 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Dialogs + +SurfacePanel { + id: panel + property var controller + property var device + property var profiles + property string uiFont: "Inter" + property string monoFont: "Cascadia Mono" + property bool compact: false + + readonly property var profile: profiles.selectedProfile + readonly property var issueItems: { + var out = [] + if (profiles.fatalError && profiles.fatalError.length) + out.push({ severity: "error", text: profiles.fatalError }) + var errors = profile.errors || [] + for (var i = 0; i < errors.length && out.length < 2; ++i) + out.push({ severity: "error", text: errors[i] }) + var warnings = profile.warnings || [] + for (var j = 0; j < warnings.length && out.length < 2; ++j) + out.push({ severity: "warning", text: warnings[j] }) + var documentWarnings = profiles.documentWarnings || [] + for (var k = 0; k < documentWarnings.length && out.length < 2; ++k) + out.push({ severity: "warning", text: documentWarnings[k] }) + return out + } + + function kicker(textValue) { + return textValue + } + + FileDialog { + id: engineeringFileDialog + title: "Open IEC 61850 engineering file" + nameFilters: [ + "IEC 61850 SCL (*.scd *.cid *.icd *.iid *.ssd *.xml)", + "All files (*)" + ] + onAccepted: { + if (panel.profiles.loadFile(selectedFile)) { + panel.controller.profileDirty = true + panel.controller.showMessage(panel.profiles.documentStatus, false) + } else { + panel.controller.showMessage(panel.profiles.fatalError || "Unable to load engineering file.", true) + } + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: panel.compact ? 11 : 14 + spacing: panel.compact ? 8 : 10 + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + Label { + text: "ENGINEERING SOURCE" + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 8 + font.weight: Font.DemiBold + font.letterSpacing: 1.0 + } + RowLayout { + Layout.fillWidth: true + Label { + Layout.fillWidth: true + text: panel.profiles.hasProfiles ? (panel.profile.svId || "Compiled SCL") : "Development profile" + color: panel.theme.text + font.family: panel.uiFont + font.pixelSize: panel.compact ? 13 : 15 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + Rectangle { + visible: panel.controller.profileDirty && panel.profiles.hasProfiles + implicitHeight: 20 + implicitWidth: dirtyText.implicitWidth + 14 + radius: 5 + color: panel.theme.amberSoft + border.width: 1 + border.color: "#705a31" + Label { + id: dirtyText + anchors.centerIn: parent + text: "DEPLOY" + color: panel.theme.amber + font.family: panel.monoFont + font.pixelSize: 7 + font.weight: Font.Bold + } + } + } + Label { + Layout.fillWidth: true + text: panel.profiles.sourceName.length ? panel.profiles.sourceName : "SCL / CID / SCD / IID" + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 9 + elide: Text.ElideMiddle + } + } + + CalmButton { + Layout.fillWidth: true + theme: panel.theme + uiFont: panel.uiFont + text: "Open SCL / CID" + enabled: !panel.device.running + onClicked: engineeringFileDialog.open() + } + + ColumnLayout { + visible: panel.profiles.hasProfiles + Layout.fillWidth: true + spacing: 6 + + Label { + text: "RESOLVED STREAM" + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 8 + font.weight: Font.DemiBold + font.letterSpacing: 1.0 + } + + ComboBox { + Layout.fillWidth: true + model: panel.profiles + textRole: "control" + currentIndex: panel.profiles.selectedIndex + enabled: !panel.device.running + font.family: panel.uiFont + font.pixelSize: 9 + onActivated: { + panel.profiles.selectStream(currentIndex) + panel.controller.profileDirty = true + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + Rectangle { + implicitHeight: 21 + implicitWidth: classText.implicitWidth + 14 + radius: 5 + color: panel.profile.compatibilityClass === "A" ? panel.theme.greenSoft : + panel.profile.compatibilityClass === "B" ? panel.theme.amberSoft : panel.theme.redSoft + border.width: 1 + border.color: panel.profile.compatibilityClass === "A" ? "#2b674d" : + panel.profile.compatibilityClass === "B" ? "#705a31" : "#78404a" + Label { + id: classText + anchors.centerIn: parent + text: "CLASS " + (panel.profile.compatibilityClass || "—") + color: panel.profile.compatibilityClass === "A" ? panel.theme.green : + panel.profile.compatibilityClass === "B" ? panel.theme.amber : panel.theme.red + font.family: panel.monoFont + font.pixelSize: 7 + font.weight: Font.Bold + } + } + Label { + Layout.fillWidth: true + text: String(panel.profile.deviceSupport || "—").toUpperCase() + color: panel.profile.deviceSupport === "ready" ? panel.theme.green : panel.theme.textSoft + font.family: panel.monoFont + font.pixelSize: 8 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: identityGrid.implicitHeight + 16 + radius: 6 + color: panel.theme.surface2 + border.width: 1 + border.color: panel.theme.lineSoft + + GridLayout { + id: identityGrid + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 9 + anchors.rightMargin: 9 + columns: 2 + columnSpacing: 8 + rowSpacing: 4 + + Label { text: "svID"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { Layout.fillWidth: true; text: panel.profile.svId || "—"; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 8; elide: Text.ElideMiddle } + Label { text: "DataSet"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { Layout.fillWidth: true; text: panel.profile.dataSetReference || "—"; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 8; elide: Text.ElideMiddle } + Label { text: "MAC / APPID"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { Layout.fillWidth: true; text: (panel.profile.destinationMac || "—") + " · " + (panel.profile.appIdHex || "—"); color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 8; elide: Text.ElideRight } + Label { text: "Rate"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.profile.publisherRate ? panel.profile.publisherRate + " fps" : "—"; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 8 } + Label { text: "VLAN"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.profile.vlanPresent ? "PCP " + panel.profile.vlanPriority + " · VID " + panel.profile.vlanId : "untagged"; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 8 } + Label { text: "Payload"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.profile.payloadBytes ? panel.profile.payloadBytes + " B · " + panel.profile.channelLeafCount + " leaves" : "—"; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 8 } + } + } + + ColumnLayout { + visible: panel.issueItems.length > 0 + Layout.fillWidth: true + spacing: 3 + Repeater { + model: panel.issueItems + delegate: Label { + required property var modelData + Layout.fillWidth: true + text: (modelData.severity === "error" ? "× " : "! ") + modelData.text + color: modelData.severity === "error" ? panel.theme.red : panel.theme.amber + font.family: panel.uiFont + font.pixelSize: 8 + wrapMode: Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + } + } + } + } + + ColumnLayout { + visible: panel.profile.compatibilityClass === "B" + Layout.fillWidth: true + spacing: 5 + Label { text: "COUNTER POLICY"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8; font.weight: Font.DemiBold; font.letterSpacing: 1.0 } + Label { + Layout.fillWidth: true + text: "Confirm the evidenced smpCnt modulus before deployment." + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 8 + wrapMode: Text.WordWrap + } + RowLayout { + Layout.fillWidth: true + NumericField { + id: counterField + Layout.fillWidth: true + theme: panel.theme + monoFont: panel.monoFont + compact: true + text: panel.profile.counterModulus || "" + validator: IntValidator { bottom: 1; top: 65535 } + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Confirm" + onClicked: { + if (counterField.acceptableInput && panel.profiles.confirmCounterModulus(parseInt(counterField.text))) { + panel.controller.profileDirty = true + panel.controller.showMessage("Sample-counter modulus confirmed.", false) + } + } + } + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: panel.theme.lineSoft } + + ColumnLayout { + Layout.fillWidth: true + spacing: 5 + Label { text: "ENGINEERING SCALING"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8; font.weight: Font.DemiBold; font.letterSpacing: 1.0 } + + RowLayout { + Layout.fillWidth: true + Label { text: "I"; Layout.preferredWidth: 16; color: panel.theme.muted; font.family: panel.monoFont; font.pixelSize: 9 } + NumericField { + Layout.fillWidth: true + theme: panel.theme + monoFont: panel.monoFont + compact: true + text: panel.controller.currentScale.toString() + validator: DoubleValidator { bottom: 0.000001; top: 2147483647; decimals: 6 } + onActiveFocusChanged: if (activeFocus) selectAll() + onEditingFinished: { + var value = panel.controller.parseOperatorNumber(text) + if (acceptableInput && isFinite(value) && value > 0) { + panel.controller.currentScale = value + if (panel.device.connected) panel.controller.applyGroupSignals(0) + } else { + text = panel.controller.currentScale.toString() + } + } + } + Label { text: "ct/A"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + } + + RowLayout { + Layout.fillWidth: true + Label { text: "U"; Layout.preferredWidth: 16; color: panel.theme.muted; font.family: panel.monoFont; font.pixelSize: 9 } + NumericField { + Layout.fillWidth: true + theme: panel.theme + monoFont: panel.monoFont + compact: true + text: panel.controller.voltageScale.toString() + validator: DoubleValidator { bottom: 0.000001; top: 2147483647; decimals: 6 } + onActiveFocusChanged: if (activeFocus) selectAll() + onEditingFinished: { + var value = panel.controller.parseOperatorNumber(text) + if (acceptableInput && isFinite(value) && value > 0) { + panel.controller.voltageScale = value + if (panel.device.connected) panel.controller.applyGroupSignals(1) + } else { + text = panel.controller.voltageScale.toString() + } + } + } + Label { text: "ct/V"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: panel.theme.lineSoft } + + Label { text: "QUICK SETUP"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8; font.weight: Font.DemiBold; font.letterSpacing: 1.0 } + RowLayout { + Layout.fillWidth: true + CalmButton { Layout.fillWidth: true; theme: panel.theme; uiFont: panel.uiFont; text: "Balanced"; onClicked: panel.controller.balanced() } + CalmButton { Layout.fillWidth: true; theme: panel.theme; uiFont: panel.uiFont; text: "Zero"; onClicked: panel.controller.zeroAll() } + } + + Item { Layout.fillHeight: true } + + Label { text: "RUNTIME"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8; font.weight: Font.DemiBold; font.letterSpacing: 1.0 } + GridLayout { + Layout.fillWidth: true + columns: 2 + columnSpacing: 8 + rowSpacing: 3 + Label { text: "Rate"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.device.fps; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 8; Layout.alignment: Qt.AlignRight } + Label { text: "Missed"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.device.missed; color: Number(panel.device.missed) > 0 ? panel.theme.amber : panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 8; Layout.alignment: Qt.AlignRight } + Label { text: "TX fail"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.device.txFailures; color: Number(panel.device.txFailures) > 0 ? panel.theme.red : panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 8; Layout.alignment: Qt.AlignRight } + Label { text: "Generation"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.device.signalGeneration; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 8; Layout.alignment: Qt.AlignRight } + } + } +} \ No newline at end of file diff --git a/apps/arstack_studio/qml/QualityEditor.qml b/apps/arstack_studio/qml/QualityEditor.qml new file mode 100644 index 000000000..d9c358483 --- /dev/null +++ b/apps/arstack_studio/qml/QualityEditor.qml @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +RowLayout { + id: editor + property var theme + property var controller + property var device + property string uiFont: "Inter" + property string monoFont: "Cascadia Mono" + property real qualityValue: 0 + property bool compact: false + + spacing: 7 + + function hexText(value) { + return "0x" + (Number(value) >>> 0).toString(16).padStart(8, "0").toUpperCase() + } + + function validityName(value) { + switch ((Number(value) >>> 0) & 0x03) { + case 0: return "GOOD" + case 1: return "INVALID" + case 2: return "RESERVED" + default: return "QUESTIONABLE" + } + } + + function flagSummary(value) { + var q = Number(value) >>> 0 + var parts = [] + if (q & (1 << 2)) parts.push("overflow") + if (q & (1 << 3)) parts.push("out-of-range") + if (q & (1 << 4)) parts.push("bad-ref") + if (q & (1 << 5)) parts.push("oscillatory") + if (q & (1 << 6)) parts.push("failure") + if (q & (1 << 7)) parts.push("old-data") + if (q & (1 << 8)) parts.push("inconsistent") + if (q & (1 << 9)) parts.push("inaccurate") + if (q & (1 << 11)) parts.push("test") + if (q & (1 << 12)) parts.push("operator-blocked") + return parts.length ? parts.join(" · ") : "no detail flags" + } + + function presetIndex(value) { + var q = Number(value) >>> 0 + if (q === 0) return 0 + if (q === 1) return 1 + if (q === 3) return 2 + if (q === (1 << 11)) return 3 + return -1 + } + + function presetValue(index) { + if (index === 0) return 0 + if (index === 1) return 1 + if (index === 2) return 3 + if (index === 3) return (1 << 11) + return Number(qualityValue) >>> 0 + } + + function applyValue(value) { + controller.setActiveQuality(Number(value) >>> 0) + } + + onQualityValueChanged: { + if (!rawField.activeFocus) + rawField.text = hexText(qualityValue) + } + + Label { + text: "QUALITY" + color: editor.theme.muted + font.family: editor.uiFont + font.pixelSize: 8 + font.weight: Font.DemiBold + font.letterSpacing: 0.8 + } + + Rectangle { + implicitHeight: 22 + implicitWidth: validityText.implicitWidth + 16 + radius: 5 + color: editor.validityName(editor.qualityValue) === "GOOD" ? editor.theme.greenSoft : + editor.validityName(editor.qualityValue) === "QUESTIONABLE" ? editor.theme.amberSoft : editor.theme.redSoft + border.width: 1 + border.color: editor.validityName(editor.qualityValue) === "GOOD" ? "#2b674d" : + editor.validityName(editor.qualityValue) === "QUESTIONABLE" ? "#705a31" : "#78404a" + Label { + id: validityText + anchors.centerIn: parent + text: editor.validityName(editor.qualityValue) + color: editor.validityName(editor.qualityValue) === "GOOD" ? editor.theme.green : + editor.validityName(editor.qualityValue) === "QUESTIONABLE" ? editor.theme.amber : editor.theme.red + font.family: editor.monoFont + font.pixelSize: 8 + font.weight: Font.Bold + } + } + + ComboBox { + id: presetBox + implicitWidth: editor.compact ? 104 : 118 + model: ["Good", "Invalid", "Questionable", "Test"] + currentIndex: editor.presetIndex(editor.qualityValue) + font.family: editor.uiFont + font.pixelSize: 9 + onActivated: editor.applyValue(editor.presetValue(currentIndex)) + } + + NumericField { + id: rawField + theme: editor.theme + monoFont: editor.monoFont + compact: true + implicitWidth: editor.compact ? 106 : 120 + Component.onCompleted: text = editor.hexText(editor.qualityValue) + onEditingFinished: { + var trimmed = text.trim() + var parsed = trimmed.toLowerCase().startsWith("0x") ? + parseInt(trimmed.substring(2), 16) : parseInt(trimmed, 10) + if (!isNaN(parsed) && parsed >= 0 && parsed <= 0xFFFFFFFF) { + invalidInput = false + editor.applyValue(parsed) + text = editor.hexText(parsed) + } else { + invalidInput = true + text = editor.hexText(editor.qualityValue) + controller.showMessage("Quality must be a valid 32-bit value.", true) + } + } + } + + Label { + Layout.fillWidth: true + visible: !editor.compact + text: editor.flagSummary(editor.qualityValue) + elide: Text.ElideRight + color: editor.theme.muted + font.family: editor.uiFont + font.pixelSize: 9 + } +} \ No newline at end of file diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml new file mode 100644 index 000000000..770e85389 --- /dev/null +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Rectangle { + id: matrix + property var theme + property var controller + property var device + property var sourceModel + property string uiFont: "Inter" + property string monoFont: "Cascadia Mono" + property int groupIndex: 0 + property string titleText: "Current" + property string symbolText: "I" + property string unitText: "A RMS" + property bool compact: false + + color: theme.surface2 + radius: 7 + border.width: 1 + border.color: theme.lineSoft + + function focusCell(row, column) { + var rowItem = rowRepeater.itemAt(row) + if (!rowItem) + return + var editor = column === 0 ? rowItem.magnitudeEditor : rowItem.phaseEditor + editor.forceActiveFocus() + editor.selectAll() + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 42 + Layout.leftMargin: 10 + Layout.rightMargin: 10 + spacing: 8 + + Rectangle { + width: 21 + height: 21 + radius: 5 + color: "#152235" + Label { + anchors.centerIn: parent + text: matrix.symbolText + color: matrix.theme.accent + font.family: matrix.monoFont + font.pixelSize: 9 + font.weight: Font.Bold + } + } + Label { + text: matrix.titleText + color: matrix.theme.textSoft + font.family: matrix.uiFont + font.pixelSize: 11 + font.weight: Font.DemiBold + } + Item { Layout.fillWidth: true } + Label { + text: matrix.unitText + color: matrix.theme.muted + font.family: matrix.uiFont + font.pixelSize: 9 + } + } + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 26 + Layout.leftMargin: 9 + Layout.rightMargin: 9 + spacing: 7 + Label { text: "ON"; Layout.preferredWidth: 28; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: 7; font.weight: Font.DemiBold } + Label { text: "CH"; Layout.preferredWidth: 34; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: 7; font.weight: Font.DemiBold } + Label { text: "MAGNITUDE"; Layout.fillWidth: true; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: 7; font.weight: Font.DemiBold } + Label { text: "PHASE"; Layout.preferredWidth: matrix.compact ? 82 : 98; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: 7; font.weight: Font.DemiBold } + } + + Repeater { + id: rowRepeater + model: matrix.sourceModel + + delegate: Rectangle { + id: signalRow + property int rowIndex: index + property string sid: model.signalId + property real mag: model.magnitude + property real angle: model.phase + property bool channelEnabled: model.enabled + property real signalQuality: model.quality + property color phaseColor: model.traceColor + property alias magnitudeEditor: magnitudeField + property alias phaseEditor: phaseField + + Layout.fillWidth: true + Layout.preferredHeight: matrix.compact ? 51 : 55 + color: magnitudeField.activeFocus || phaseField.activeFocus ? "#141d27" : "transparent" + Behavior on color { ColorAnimation { duration: 80 } } + + onMagChanged: if (!magnitudeField.activeFocus) magnitudeField.text = mag.toFixed(3) + onAngleChanged: if (!phaseField.activeFocus) phaseField.text = angle.toFixed(2) + + Rectangle { + visible: matrix.controller.activeSignal === signalRow.sid + width: 2 + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + color: matrix.theme.accent + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 9 + anchors.rightMargin: 9 + spacing: 7 + + CheckBox { + Layout.preferredWidth: 28 + checked: signalRow.channelEnabled + onToggled: { + matrix.sourceModel.setProperty(signalRow.rowIndex, "enabled", checked) + matrix.controller.selectSignal(matrix.groupIndex, signalRow.rowIndex) + matrix.controller.refreshPreview() + if (matrix.device.connected) + matrix.device.setEnabled(signalRow.sid, checked) + } + } + + RowLayout { + Layout.preferredWidth: 34 + spacing: 5 + Rectangle { width: 6; height: 6; radius: 3; color: signalRow.phaseColor } + Label { + text: signalRow.sid + color: matrix.theme.text + font.family: matrix.monoFont + font.pixelSize: 9 + font.weight: Font.Bold + } + } + + NumericField { + id: magnitudeField + Layout.fillWidth: true + theme: matrix.theme + monoFont: matrix.monoFont + compact: matrix.compact + text: signalRow.mag.toFixed(3) + invalidInput: false + onActiveFocusChanged: { + if (activeFocus) { + matrix.controller.selectSignal(matrix.groupIndex, signalRow.rowIndex) + text = signalRow.mag.toFixed(3) + selectAll() + } + } + onTextEdited: { + var value = matrix.controller.parseOperatorNumber(text) + if (matrix.controller.validMagnitude(matrix.groupIndex, value)) { + invalidInput = false + matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "magnitude", value) + } else { + invalidInput = true + } + } + onEditingFinished: { + var value = matrix.controller.parseOperatorNumber(text) + if (!matrix.controller.validMagnitude(matrix.groupIndex, value)) { + text = signalRow.mag.toFixed(3) + invalidInput = false + matrix.controller.showMessage(signalRow.sid + " magnitude is outside the valid wire/scaling range.", true) + } else { + text = value.toFixed(3) + } + } + Keys.onPressed: function(event) { + if ([Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right].indexOf(event.key) >= 0) { + matrix.controller.navigate(matrix.groupIndex, signalRow.rowIndex, 0, event.key) + event.accepted = true + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + matrix.controller.focusCell(matrix.groupIndex, signalRow.rowIndex + 1, 0) + event.accepted = true + } + } + } + + NumericField { + id: phaseField + Layout.preferredWidth: matrix.compact ? 82 : 98 + theme: matrix.theme + monoFont: matrix.monoFont + compact: matrix.compact + text: signalRow.angle.toFixed(2) + invalidInput: false + onActiveFocusChanged: { + if (activeFocus) { + matrix.controller.selectSignal(matrix.groupIndex, signalRow.rowIndex) + text = signalRow.angle.toFixed(2) + selectAll() + } + } + onTextEdited: { + var value = matrix.controller.parseOperatorNumber(text) + if (matrix.controller.validPhase(value)) { + invalidInput = false + matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "phase", value) + } else { + invalidInput = true + } + } + onEditingFinished: { + var value = matrix.controller.parseOperatorNumber(text) + if (!matrix.controller.validPhase(value)) { + text = signalRow.angle.toFixed(2) + invalidInput = false + matrix.controller.showMessage(signalRow.sid + " phase must stay within ±360000°.", true) + } else { + text = value.toFixed(2) + } + } + Keys.onPressed: function(event) { + if ([Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right].indexOf(event.key) >= 0) { + matrix.controller.navigate(matrix.groupIndex, signalRow.rowIndex, 1, event.key) + event.accepted = true + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + matrix.controller.focusCell(matrix.groupIndex, signalRow.rowIndex + 1, 1) + event.accepted = true + } + } + } + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 1 + color: matrix.theme.lineSoft + } + } + } + + Item { Layout.fillHeight: true } + } +} \ No newline at end of file diff --git a/apps/arstack_studio/qml/SignalPreview.qml b/apps/arstack_studio/qml/SignalPreview.qml new file mode 100644 index 000000000..ca20d5ad4 --- /dev/null +++ b/apps/arstack_studio/qml/SignalPreview.qml @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +SurfacePanel { + id: preview + property var currentModel + property var voltageModel + property string uiFont: "Inter" + property string monoFont: "Cascadia Mono" + property bool compact: false + property string activeSignal: "Ia" + property string activeUnit: "A" + property real activeMagnitude: 1 + property real activePhase: 0 + property real signalFrequency: 50 + + function requestPaint() { + phasor.requestPaint() + waveform.requestPaint() + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: preview.compact ? 11 : 14 + spacing: 8 + + RowLayout { + Layout.fillWidth: true + ColumnLayout { + spacing: 1 + Label { text: "GENERATED SETPOINT"; color: preview.theme.muted; font.family: preview.uiFont; font.pixelSize: 8; font.weight: Font.DemiBold; font.letterSpacing: 1.0 } + Label { text: "Signal Preview"; color: preview.theme.text; font.family: preview.uiFont; font.pixelSize: preview.compact ? 15 : 17; font.weight: Font.DemiBold } + } + Item { Layout.fillWidth: true } + Label { text: "LOCAL"; color: preview.theme.muted; font.family: preview.monoFont; font.pixelSize: 8; font.weight: Font.Bold } + } + + RowLayout { + Layout.fillWidth: true + Label { text: "Phasor"; color: preview.theme.textSoft; font.family: preview.uiFont; font.pixelSize: 10; font.weight: Font.DemiBold } + Item { Layout.fillWidth: true } + Label { + Layout.maximumWidth: parent.width * 0.72 + text: preview.activeSignal + " · " + preview.activeMagnitude.toFixed(3) + " " + preview.activeUnit + " ∠ " + preview.activePhase.toFixed(2) + "°" + color: preview.theme.muted + font.family: preview.monoFont + font.pixelSize: 8 + elide: Text.ElideRight + } + } + + Canvas { + id: phasor + Layout.fillWidth: true + Layout.fillHeight: true + Layout.preferredHeight: 1 + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + onPaint: { + var ctx = getContext("2d") + ctx.reset() + var cx = width / 2 + var cy = height / 2 + var r = Math.min(width, height) * 0.39 + + ctx.strokeStyle = "#283441" + ctx.lineWidth = 1 + for (var ring = 1; ring <= 3; ++ring) { + ctx.beginPath() + ctx.arc(cx, cy, r * ring / 3, 0, Math.PI * 2) + ctx.stroke() + } + for (var degrees = 0; degrees < 180; degrees += 30) { + var axis = degrees * Math.PI / 180 + var dx = Math.cos(axis) * r + var dy = Math.sin(axis) * r + ctx.beginPath() + ctx.moveTo(cx - dx, cy - dy) + ctx.lineTo(cx + dx, cy + dy) + ctx.stroke() + } + + function drawGroup(model, scale, dashed) { + var maxMagnitude = 0.0001 + for (var i = 0; i < 4; ++i) + maxMagnitude = Math.max(maxMagnitude, model.get(i).magnitude) + for (var j = 0; j < 4; ++j) { + var signal = model.get(j) + if (!signal.enabled || signal.magnitude <= 0) + continue + var radians = signal.phase * Math.PI / 180 + var length = r * scale * (0.30 + 0.70 * Math.min(1, signal.magnitude / maxMagnitude)) + var ex = cx + Math.cos(radians) * length + var ey = cy - Math.sin(radians) * length + var active = signal.signalId === preview.activeSignal + ctx.save() + ctx.globalAlpha = active ? 1 : 0.68 + ctx.strokeStyle = signal.traceColor + ctx.fillStyle = signal.traceColor + ctx.lineWidth = active ? 2.8 : 1.7 + ctx.lineCap = "round" + ctx.setLineDash(dashed ? [5, 4] : []) + ctx.beginPath() + ctx.moveTo(cx, cy) + ctx.lineTo(ex, ey) + ctx.stroke() + ctx.setLineDash([]) + var arrow = active ? 8 : 6 + ctx.beginPath() + ctx.moveTo(ex, ey) + ctx.lineTo(ex - Math.cos(radians - 0.45) * arrow, ey + Math.sin(radians - 0.45) * arrow) + ctx.lineTo(ex - Math.cos(radians + 0.45) * arrow, ey + Math.sin(radians + 0.45) * arrow) + ctx.closePath() + ctx.fill() + if (active) { + ctx.font = "600 10px Inter" + ctx.fillText(signal.signalId, ex + 7, ey - 7) + } + ctx.restore() + } + } + + drawGroup(preview.voltageModel, 1.0, false) + drawGroup(preview.currentModel, 0.78, true) + } + } + + RowLayout { + Layout.fillWidth: true + Label { text: "Waveform"; color: preview.theme.textSoft; font.family: preview.uiFont; font.pixelSize: 10; font.weight: Font.DemiBold } + Item { Layout.fillWidth: true } + Label { text: preview.signalFrequency.toFixed(3) + " Hz · 40 ms"; color: preview.theme.muted; font.family: preview.monoFont; font.pixelSize: 8 } + } + + Canvas { + id: waveform + Layout.fillWidth: true + Layout.fillHeight: true + Layout.preferredHeight: 0.84 + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + onPaint: { + var ctx = getContext("2d") + ctx.reset() + var left = 24 + var right = 8 + var top = 10 + var bottom = 12 + var plotWidth = width - left - right + var plotHeight = height - top - bottom + var gap = 14 + var laneHeight = (plotHeight - gap) / 2 + var voltageY = top + laneHeight / 2 + var currentY = top + laneHeight + gap + laneHeight / 2 + + ctx.strokeStyle = "#202b36" + ctx.lineWidth = 1 + for (var grid = 0; grid <= 8; ++grid) { + var xg = left + plotWidth * grid / 8 + ctx.beginPath() + ctx.moveTo(xg, top) + ctx.lineTo(xg, top + plotHeight) + ctx.stroke() + } + ctx.strokeStyle = "#33404d" + for (var b = 0; b < 2; ++b) { + var baseline = b === 0 ? voltageY : currentY + ctx.beginPath() + ctx.moveTo(left, baseline) + ctx.lineTo(left + plotWidth, baseline) + ctx.stroke() + } + + function drawWaveGroup(model, center, dashed) { + var maxMagnitude = 0.0001 + for (var i = 0; i < 4; ++i) + maxMagnitude = Math.max(maxMagnitude, model.get(i).magnitude) + for (var j = 0; j < 4; ++j) { + var signal = model.get(j) + if (!signal.enabled || signal.magnitude <= 0) + continue + var active = signal.signalId === preview.activeSignal + var amplitude = laneHeight * 0.39 * Math.min(1, signal.magnitude / maxMagnitude) + var phaseRadians = signal.phase * Math.PI / 180 + ctx.save() + ctx.strokeStyle = signal.traceColor + ctx.globalAlpha = active ? 1 : 0.62 + ctx.lineWidth = active ? 2.3 : 1.5 + ctx.lineCap = "round" + ctx.setLineDash(dashed ? [5, 4] : []) + ctx.beginPath() + for (var point = 0; point <= 180; ++point) { + var ratio = point / 180 + var time = 0.04 * ratio + var sample = Math.sin(2 * Math.PI * preview.signalFrequency * time + phaseRadians) + var x = left + plotWidth * ratio + var y = center - sample * amplitude + if (point === 0) ctx.moveTo(x, y) + else ctx.lineTo(x, y) + } + ctx.stroke() + ctx.restore() + } + } + + drawWaveGroup(preview.voltageModel, voltageY, false) + drawWaveGroup(preview.currentModel, currentY, true) + } + } + + Label { + Layout.fillWidth: true + text: "Generated setpoint · not independent on-wire proof" + horizontalAlignment: Text.AlignRight + color: preview.theme.muted + font.family: preview.uiFont + font.pixelSize: 8 + } + } +} \ No newline at end of file diff --git a/apps/arstack_studio/qml/StateBadge.qml b/apps/arstack_studio/qml/StateBadge.qml new file mode 100644 index 000000000..5926c291a --- /dev/null +++ b/apps/arstack_studio/qml/StateBadge.qml @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls + +Rectangle { + id: badge + property var theme + property string state: "OFFLINE" + property color stateColor: theme.muted + property string monoFont: "Cascadia Mono" + + implicitHeight: 24 + implicitWidth: stateText.implicitWidth + 24 + radius: 6 + color: state === "RUNNING" ? theme.greenSoft : + state === "PROFILE BLOCKED" ? theme.redSoft : + (state === "PROFILE CHANGED" || state === "DEPLOYING") ? theme.amberSoft : + theme.accentSoft + border.width: 1 + border.color: stateColor + Behavior on color { ColorAnimation { duration: 120 } } + + Label { + id: stateText + anchors.centerIn: parent + text: badge.state + color: badge.stateColor + font.family: badge.monoFont + font.pixelSize: 8 + font.weight: Font.Bold + font.letterSpacing: 0.45 + } +} \ No newline at end of file diff --git a/apps/arstack_studio/qml/StatusToast.qml b/apps/arstack_studio/qml/StatusToast.qml new file mode 100644 index 000000000..2eb22c5d7 --- /dev/null +++ b/apps/arstack_studio/qml/StatusToast.qml @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls + +Rectangle { + id: toast + property var theme + property string uiFont: "Inter" + property string message: "" + property bool error: false + + implicitWidth: Math.min(620, messageLabel.implicitWidth + 30) + implicitHeight: 36 + radius: 6 + color: "#18212b" + border.width: 1 + border.color: error ? "#70404a" : "#36536f" + opacity: message.length > 0 ? 1 : 0 + scale: message.length > 0 ? 1 : 0.985 + visible: opacity > 0 + + Behavior on opacity { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } + Behavior on scale { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } + + Label { + id: messageLabel + anchors.fill: parent + anchors.leftMargin: 14 + anchors.rightMargin: 14 + text: toast.message + color: toast.error ? "#ffb3bb" : toast.theme.textSoft + font.family: toast.uiFont + font.pixelSize: 9 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } +} \ No newline at end of file diff --git a/apps/arstack_studio/qml/StudioTheme.qml b/apps/arstack_studio/qml/StudioTheme.qml new file mode 100644 index 000000000..47a269802 --- /dev/null +++ b/apps/arstack_studio/qml/StudioTheme.qml @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick + +QtObject { + readonly property color bg: "#090d12" + readonly property color chrome: "#0c1117" + readonly property color surface: "#11171f" + readonly property color surface2: "#0e141b" + readonly property color raised: "#151d27" + readonly property color raisedHover: "#1a2430" + readonly property color line: "#26313d" + readonly property color lineSoft: "#1c252f" + readonly property color text: "#edf2f7" + readonly property color textSoft: "#c2ccd7" + readonly property color muted: "#778493" + readonly property color muted2: "#566271" + readonly property color accent: "#69a9ff" + readonly property color accentSoft: "#18314c" + readonly property color green: "#58d49d" + readonly property color greenSoft: "#153a2d" + readonly property color amber: "#e1b25a" + readonly property color amberSoft: "#3b301c" + readonly property color red: "#ff727f" + readonly property color redSoft: "#46252b" +} \ No newline at end of file diff --git a/apps/arstack_studio/qml/SurfacePanel.qml b/apps/arstack_studio/qml/SurfacePanel.qml new file mode 100644 index 000000000..24790fcf2 --- /dev/null +++ b/apps/arstack_studio/qml/SurfacePanel.qml @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick + +Rectangle { + property var theme + color: theme ? theme.surface : "#11171f" + radius: 8 + border.width: 1 + border.color: theme ? theme.lineSoft : "#1c252f" +} \ No newline at end of file diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp new file mode 100644 index 000000000..3027498c3 --- /dev/null +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -0,0 +1,403 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "DeviceController.hpp" + +#include +#include +#include + +#include +#include +#include + +namespace { +constexpr qint32 kBaudRate = 115200; +const QRegularExpression kAnsiExpression{QStringLiteral("\\x1B\\[[0-9;]*m")}; +const QRegularExpression kStateExpression{ + QStringLiteral("state=(RUNNING|STOPPED).*generation=(\\d+).*signal_frequency=(\\d+)\\s*mHz"), + QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kTimingExpression{ + QStringLiteral("samples=(\\d+)\\s+\\(~(\\d+)\\s+fps\\).*?MC ok=(\\d+) fail=(\\d+).*?missed=(\\d+).*?signal_gen=(\\d+)"), + QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kSignalGenerationExpression{ + QStringLiteral("Live signal generation\\s+(\\d+)\\s+committed"), + QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kProfileCommittedExpression{ + QStringLiteral("PROFILE committed generation=(\\d+)\\s+svID=(\\S+)\\s+APPID=0x([0-9A-Fa-f]+)\\s+rate=(\\d+)\\s+wrap=(\\d+)"), + QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kProfileArmedExpression{ + QStringLiteral("PROFILE armed generation=(\\d+)\\s+svID=(\\S+)\\s+APPID=0x([0-9A-Fa-f]+)\\s+rate=(\\d+)\\s+wrap=(\\d+)"), + QRegularExpression::CaseInsensitiveOption}; + +[[nodiscard]] bool finitePositive(const double value) noexcept { + return std::isfinite(value) && value > 0.0; +} +} // namespace + +DeviceController::DeviceController(QObject* parent) : QObject(parent) { + connect(&serial_, &QSerialPort::readyRead, this, [this] { + pendingRx_.append(serial_.readAll()); + while (true) { + const auto newline = pendingRx_.indexOf('\n'); + if (newline < 0) break; + QByteArray line = pendingRx_.left(newline); + pendingRx_.remove(0, newline + 1); + if (!line.isEmpty() && line.endsWith('\r')) line.chop(1); + processLine(QString::fromUtf8(line)); + } + }); + + connect(&serial_, &QSerialPort::errorOccurred, this, [this](const QSerialPort::SerialPortError error) { + if (error == QSerialPort::NoError) return; + if (error == QSerialPort::ResourceError || error == QSerialPort::DeviceNotFoundError) { + setError(serial_.errorString()); + disconnectPort(); + } else if (serial_.isOpen()) { + setError(serial_.errorString()); + } + }); + + refreshPorts(); +} + +QStringList DeviceController::ports() const { return ports_; } +bool DeviceController::connected() const noexcept { return serial_.isOpen(); } +bool DeviceController::running() const noexcept { return running_; } +QString DeviceController::portName() const { return serial_.portName(); } +QString DeviceController::lastError() const { return lastError_; } +QString DeviceController::logText() const { return logText_; } +QString DeviceController::fps() const { return fps_; } +QString DeviceController::missed() const { return missed_; } +QString DeviceController::txFailures() const { return txFailures_; } +QString DeviceController::signalGeneration() const { return signalGeneration_; } +QString DeviceController::profileGeneration() const { return profileGeneration_; } +bool DeviceController::profileArmed() const noexcept { return profileArmed_; } +bool DeviceController::profileDeploying() const noexcept { return profileDeploying_; } + +void DeviceController::refreshPorts() { + QStringList discovered; + for (const auto& info : QSerialPortInfo::availablePorts()) { + discovered.push_back(info.portName()); + } + discovered.removeDuplicates(); + std::sort(discovered.begin(), discovered.end(), [](const QString& left, const QString& right) { + return left.localeAwareCompare(right) < 0; + }); + if (discovered == ports_) return; + ports_ = std::move(discovered); + emit portsChanged(); +} + +bool DeviceController::connectPort(const QString& portName) { + if (portName.trimmed().isEmpty()) { + setError(QStringLiteral("Select a serial port first.")); + return false; + } + if (serial_.isOpen()) disconnectPort(); + + serial_.setPortName(portName.trimmed()); + serial_.setBaudRate(kBaudRate); + serial_.setDataBits(QSerialPort::Data8); + serial_.setParity(QSerialPort::NoParity); + serial_.setStopBits(QSerialPort::OneStop); + serial_.setFlowControl(QSerialPort::NoFlowControl); + + if (!serial_.open(QIODevice::ReadWrite)) { + setError(QStringLiteral("Cannot open %1: %2").arg(portName, serial_.errorString())); + return false; + } + + pendingRx_.clear(); + lastError_.clear(); + running_ = false; + profileArmed_ = false; + profileDeploying_ = false; + resetTelemetry(); + appendLog(QStringLiteral("•"), QStringLiteral("Connected %1 at 115200 8N1").arg(serial_.portName())); + emit connectedChanged(); + emit runningChanged(); + emit lastErrorChanged(); + emit profileStateChanged(); + static_cast(sendCommand(QStringLiteral("SHOW"))); + static_cast(sendCommand(QStringLiteral("PROFILE SHOW"))); + return true; +} + +void DeviceController::disconnectPort() { + const bool wasConnected = serial_.isOpen(); + if (serial_.isOpen()) serial_.close(); + pendingRx_.clear(); + if (running_) { + running_ = false; + emit runningChanged(); + } + if (profileDeploying_ || profileArmed_) { + profileDeploying_ = false; + profileArmed_ = false; + emit profileStateChanged(); + } + resetTelemetry(); + if (wasConnected) { + appendLog(QStringLiteral("•"), QStringLiteral("Device disconnected")); + emit connectedChanged(); + } +} + +bool DeviceController::sendShow() { return sendCommand(QStringLiteral("SHOW")); } +bool DeviceController::start() { return sendCommand(QStringLiteral("START")); } +bool DeviceController::stop() { return sendCommand(QStringLiteral("STOP")); } +bool DeviceController::zero() { return sendCommand(QStringLiteral("ZERO")); } + +bool DeviceController::setFrequency(const double hz) { + if (!finitePositive(hz) || hz > 1000.0) { + setError(QStringLiteral("Frequency must be within 0..1000 Hz.")); + return false; + } + const auto millihertz = std::llround(hz * 1000.0); + if (millihertz <= 0 || millihertz > std::numeric_limits::max()) return false; + return sendCommand(QStringLiteral("FREQ %1").arg(millihertz)); +} + +bool DeviceController::setSignal( + const QString& signalId, + const double magnitude, + const double phaseDegrees, + const quint32 quality, + const double currentCountsPerAmp, + const double voltageCountsPerVolt) { + const QString id = signalId.trimmed().toUpper(); + static const QStringList validIds{ + QStringLiteral("IA"), QStringLiteral("IB"), QStringLiteral("IC"), QStringLiteral("IN"), + QStringLiteral("UA"), QStringLiteral("UB"), QStringLiteral("UC"), QStringLiteral("UN")}; + if (!validIds.contains(id) || !std::isfinite(magnitude) || magnitude < 0.0 || !std::isfinite(phaseDegrees)) { + setError(QStringLiteral("Invalid signal setpoint.")); + return false; + } + if (!finitePositive(currentCountsPerAmp) || !finitePositive(voltageCountsPerVolt)) { + setError(QStringLiteral("Engineering scaling must be positive.")); + return false; + } + + const double scale = id.startsWith(QLatin1Char('I')) ? currentCountsPerAmp : voltageCountsPerVolt; + const auto counts = std::llround(magnitude * scale); + const auto phaseMdeg = std::llround(phaseDegrees * 1000.0); + if (counts < std::numeric_limits::min() || counts > std::numeric_limits::max() || + phaseMdeg < std::numeric_limits::min() || phaseMdeg > std::numeric_limits::max()) { + setError(QStringLiteral("Setpoint exceeds firmware wire range.")); + return false; + } + + return sendCommand(QStringLiteral("SET %1 %2 %3 %4") + .arg(id) + .arg(counts) + .arg(phaseMdeg) + .arg(static_cast(quality))); +} + +bool DeviceController::setEnabled(const QString& signalId, const bool enabled) { + return sendCommand(QStringLiteral("ENABLE %1 %2") + .arg(signalId.trimmed().toUpper()) + .arg(enabled ? 1 : 0)); +} + +bool DeviceController::setQuality(const QString& signalId, const quint32 quality) { + return sendCommand(QStringLiteral("QUALITY %1 %2") + .arg(signalId.trimmed().toUpper()) + .arg(static_cast(quality))); +} + +bool DeviceController::deployProfile(const QVariantMap& profile) { + if (!connected()) { + setError(QStringLiteral("Connect the ESP32-P4 before deployment.")); + return false; + } + if (running_) { + setError(QStringLiteral("Stop the publisher before changing profile identity/layout.")); + return false; + } + if (profile.value(QStringLiteral("compatibilityClass")).toString() != QStringLiteral("A") || + profile.value(QStringLiteral("deviceSupport")).toString() != QStringLiteral("ready")) { + setError(QStringLiteral("Only Class A profiles supported by the current ESP32-P4 layout can be deployed.")); + return false; + } + + const QString svId = profile.value(QStringLiteral("svId")).toString(); + const QString dataSet = profile.value(QStringLiteral("dataSetReference")).toString(); + const QString mac = compactMac(profile.value(QStringLiteral("destinationMac")).toString()); + const auto appId = profile.value(QStringLiteral("appId")).toUInt(); + const bool vlanPresent = profile.value(QStringLiteral("vlanPresent")).toBool(); + const auto vlanId = profile.value(QStringLiteral("vlanId")).toUInt(); + const auto pcp = profile.value(QStringLiteral("vlanPriority")).toUInt(); + const auto confRev = profile.value(QStringLiteral("confRev")).toULongLong(); + const auto rate = profile.value(QStringLiteral("publisherRate")).toULongLong(); + const auto modulus = profile.value(QStringLiteral("counterModulus")).toUInt(); + const auto noAsdu = profile.value(QStringLiteral("nofASDU")).toUInt(); + const bool includeDataSet = profile.value(QStringLiteral("includeDataSet")).toBool(); + const bool includeSampleRate = profile.value(QStringLiteral("includeSampleRate")).toBool(); + + const QString idHex = utf8Hex(svId); + const QString dataSetHex = includeDataSet ? utf8Hex(dataSet) : QStringLiteral("-"); + if (svId.isEmpty() || idHex.isEmpty() || idHex.size() > 180 || dataSetHex.size() > 170 || + mac.size() != 12 || appId == 0U || appId > 65535U || vlanId > 4095U || pcp > 7U || + rate == 0U || rate > 65535U || modulus == 0U || modulus > 65535U || noAsdu != 1U) { + setError(QStringLiteral("Compiled profile exceeds the current bounded device bridge.")); + return false; + } + + unsigned flags = 0U; + if (includeDataSet) flags |= 0x1U; + if (includeSampleRate) flags |= 0x2U; + + profileDeploying_ = true; + profileArmed_ = false; + emit profileStateChanged(); + + const QStringList commands{ + QStringLiteral("PROFILE BEGIN"), + QStringLiteral("PROFILE ID %1").arg(idHex), + QStringLiteral("PROFILE DATASET %1").arg(dataSetHex), + QStringLiteral("PROFILE L2 %1 %2 %3 %4 %5") + .arg(appId).arg(mac).arg(vlanPresent ? 1 : 0).arg(vlanId).arg(pcp), + QStringLiteral("PROFILE SV %1 %2 %3 %4 %5") + .arg(confRev).arg(rate).arg(modulus).arg(noAsdu).arg(flags), + QStringLiteral("PROFILE COMMIT"), + QStringLiteral("PROFILE SHOW"), + }; + + for (const auto& command : commands) { + if (!sendCommand(command)) { + profileDeploying_ = false; + emit profileStateChanged(); + return false; + } + } + return true; +} + +void DeviceController::clearLog() { + if (logText_.isEmpty()) return; + logText_.clear(); + emit logTextChanged(); +} + +bool DeviceController::sendCommand(const QString& command) { + if (!serial_.isOpen()) { + setError(QStringLiteral("Device is not connected.")); + return false; + } + const QByteArray bytes = command.toUtf8() + '\n'; + if (serial_.write(bytes) < 0) { + setError(serial_.errorString()); + return false; + } + appendLog(QStringLiteral("→"), command); + return true; +} + +void DeviceController::setRunning(const bool value) { + if (running_ == value) return; + running_ = value; + emit runningChanged(); +} + +void DeviceController::setError(const QString& message) { + if (message.isEmpty() || lastError_ == message) return; + lastError_ = message; + appendLog(QStringLiteral("!"), message); + emit lastErrorChanged(); + emit deviceMessage(message); +} + +void DeviceController::appendLog(const QString& direction, const QString& line) { + const QString stamp = QDateTime::currentDateTime().toString(QStringLiteral("HH:mm:ss")); + logText_.append(QStringLiteral("[%1] %2 %3\n").arg(stamp, direction, line)); + constexpr qsizetype kMaxChars = 60000; + constexpr qsizetype kKeepChars = 48000; + if (logText_.size() > kMaxChars) logText_ = logText_.right(kKeepChars); + emit logTextChanged(); +} + +void DeviceController::processLine(const QString& rawLine) { + const QString line = cleanLine(rawLine); + if (line.isEmpty()) return; + appendLog(QStringLiteral("←"), line); + + if (line.contains(QStringLiteral("START accepted"), Qt::CaseInsensitive)) setRunning(true); + if (line.contains(QStringLiteral("STOP accepted"), Qt::CaseInsensitive)) setRunning(false); + + auto match = kStateExpression.match(line); + if (match.hasMatch()) { + setRunning(match.captured(1).compare(QStringLiteral("RUNNING"), Qt::CaseInsensitive) == 0); + signalGeneration_ = match.captured(2); + emit telemetryChanged(); + } + + match = kTimingExpression.match(line); + if (match.hasMatch()) { + fps_ = match.captured(2); + txFailures_ = match.captured(4); + missed_ = match.captured(5); + signalGeneration_ = match.captured(6); + emit telemetryChanged(); + } + + match = kSignalGenerationExpression.match(line); + if (match.hasMatch()) { + signalGeneration_ = match.captured(1); + emit telemetryChanged(); + } + + match = kProfileCommittedExpression.match(line); + if (match.hasMatch()) { + profileGeneration_ = match.captured(1); + profileDeploying_ = false; + profileArmed_ = true; + emit profileStateChanged(); + emit deviceMessage(QStringLiteral("SCL profile deployed and armed.")); + } + + match = kProfileArmedExpression.match(line); + if (match.hasMatch()) { + profileGeneration_ = match.captured(1); + profileDeploying_ = false; + profileArmed_ = true; + emit profileStateChanged(); + } + + if (line.contains(QStringLiteral("PROFILE commit rejected"), Qt::CaseInsensitive) || + line.contains(QStringLiteral("PROFILE rejected"), Qt::CaseInsensitive)) { + profileDeploying_ = false; + profileArmed_ = false; + emit profileStateChanged(); + setError(QStringLiteral("Device rejected the profile.")); + } +} + +void DeviceController::resetTelemetry() { + fps_ = QStringLiteral("—"); + missed_ = QStringLiteral("—"); + txFailures_ = QStringLiteral("—"); + signalGeneration_ = QStringLiteral("—"); + emit telemetryChanged(); +} + +QString DeviceController::cleanLine(const QString& rawLine) { + QString line = rawLine; + line.remove(kAnsiExpression); + return line.trimmed(); +} + +QString DeviceController::utf8Hex(const QString& text) { + return QString::fromLatin1(text.toUtf8().toHex()); +} + +QString DeviceController::compactMac(const QString& text) { + QString out; + out.reserve(12); + for (const auto ch : text) { + if (ch.isDigit() || (ch.toUpper() >= QLatin1Char('A') && ch.toUpper() <= QLatin1Char('F'))) { + out.append(ch.toUpper()); + } + } + return out; +} diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp new file mode 100644 index 000000000..93783bbe6 --- /dev/null +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include +#include + +class DeviceController : public QObject { + Q_OBJECT + Q_PROPERTY(QStringList ports READ ports NOTIFY portsChanged) + Q_PROPERTY(bool connected READ connected NOTIFY connectedChanged) + Q_PROPERTY(bool running READ running NOTIFY runningChanged) + Q_PROPERTY(QString portName READ portName NOTIFY connectedChanged) + Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged) + Q_PROPERTY(QString logText READ logText NOTIFY logTextChanged) + Q_PROPERTY(QString fps READ fps NOTIFY telemetryChanged) + Q_PROPERTY(QString missed READ missed NOTIFY telemetryChanged) + Q_PROPERTY(QString txFailures READ txFailures NOTIFY telemetryChanged) + Q_PROPERTY(QString signalGeneration READ signalGeneration NOTIFY telemetryChanged) + Q_PROPERTY(QString profileGeneration READ profileGeneration NOTIFY profileStateChanged) + Q_PROPERTY(bool profileArmed READ profileArmed NOTIFY profileStateChanged) + Q_PROPERTY(bool profileDeploying READ profileDeploying NOTIFY profileStateChanged) + +public: + explicit DeviceController(QObject* parent = nullptr); + + [[nodiscard]] QStringList ports() const; + [[nodiscard]] bool connected() const noexcept; + [[nodiscard]] bool running() const noexcept; + [[nodiscard]] QString portName() const; + [[nodiscard]] QString lastError() const; + [[nodiscard]] QString logText() const; + [[nodiscard]] QString fps() const; + [[nodiscard]] QString missed() const; + [[nodiscard]] QString txFailures() const; + [[nodiscard]] QString signalGeneration() const; + [[nodiscard]] QString profileGeneration() const; + [[nodiscard]] bool profileArmed() const noexcept; + [[nodiscard]] bool profileDeploying() const noexcept; + + Q_INVOKABLE void refreshPorts(); + Q_INVOKABLE bool connectPort(const QString& portName); + Q_INVOKABLE void disconnectPort(); + Q_INVOKABLE bool sendShow(); + Q_INVOKABLE bool start(); + Q_INVOKABLE bool stop(); + Q_INVOKABLE bool zero(); + Q_INVOKABLE bool setFrequency(double hz); + Q_INVOKABLE bool setSignal( + const QString& signalId, + double magnitude, + double phaseDegrees, + quint32 quality, + double currentCountsPerAmp, + double voltageCountsPerVolt); + Q_INVOKABLE bool setEnabled(const QString& signalId, bool enabled); + Q_INVOKABLE bool setQuality(const QString& signalId, quint32 quality); + Q_INVOKABLE bool deployProfile(const QVariantMap& profile); + Q_INVOKABLE void clearLog(); + +signals: + void portsChanged(); + void connectedChanged(); + void runningChanged(); + void lastErrorChanged(); + void logTextChanged(); + void telemetryChanged(); + void profileStateChanged(); + void deviceMessage(const QString& message); + +private: + bool sendCommand(const QString& command); + void setRunning(bool value); + void setError(const QString& message); + void appendLog(const QString& direction, const QString& line); + void processLine(const QString& rawLine); + void resetTelemetry(); + static QString cleanLine(const QString& rawLine); + static QString utf8Hex(const QString& text); + static QString compactMac(const QString& text); + + QSerialPort serial_; + QStringList ports_; + QByteArray pendingRx_; + QString lastError_; + QString logText_; + QString fps_{QStringLiteral("—")}; + QString missed_{QStringLiteral("—")}; + QString txFailures_{QStringLiteral("—")}; + QString signalGeneration_{QStringLiteral("—")}; + QString profileGeneration_{QStringLiteral("—")}; + bool running_{false}; + bool profileArmed_{false}; + bool profileDeploying_{false}; +}; \ No newline at end of file diff --git a/apps/arstack_studio/src/SclProfileModel.cpp b/apps/arstack_studio/src/SclProfileModel.cpp new file mode 100644 index 000000000..f20cb03ed --- /dev/null +++ b/apps/arstack_studio/src/SclProfileModel.cpp @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "SclProfileModel.hpp" + +#include "ariec61850/sampled_values/esp32p4_profile_support.hpp" +#include "ariec61850/scl/parser.hpp" + +#include + +#include +#include +#include +#include + +namespace { +using ar::iec61850::sampled_values::Esp32P4SvProfileSupport; +using ar::iec61850::sampled_values::SvPublisherProfile; +using ar::iec61850::sampled_values::SvPublisherProfileCompileContext; +using ar::iec61850::sampled_values::SvPublisherProfileCompiler; +using ar::iec61850::sampled_values::SvSampleCounterPolicy; +using ar::iec61850::sampled_values::SvSampleMode; +using ar::iec61850::sampled_values::classify_esp32p4_sv_profile; +using ar::iec61850::sampled_values::esp32p4_sv_profile_support_name; + +QString qstring(const std::string& value) { + return QString::fromStdString(value); +} + +QStringList qstrings(const std::vector& values) { + QStringList out; + out.reserve(static_cast(values.size())); + for (const auto& value : values) out.push_back(qstring(value)); + return out; +} + +QString macText(const std::array& mac) { + QStringList bytes; + bytes.reserve(6); + for (const auto byte : mac) { + bytes.push_back(QStringLiteral("%1") + .arg(static_cast(byte), 2, 16, QLatin1Char('0')) + .toUpper()); + } + return bytes.join(QLatin1Char(':')); +} + +QString sampleModeText(const SvSampleMode mode) { + switch (mode) { + case SvSampleMode::samples_per_second: return QStringLiteral("SmpPerSec"); + case SvSampleMode::samples_per_period: return QStringLiteral("SmpPerPeriod"); + default: return QStringLiteral("Unknown"); + } +} + +QString counterPolicyText(const SvSampleCounterPolicy policy) { + switch (policy) { + case SvSampleCounterPolicy::explicit_modulus: return QStringLiteral("explicit"); + case SvSampleCounterPolicy::candidate_sample_rate_modulus: return QStringLiteral("candidate-rate"); + default: return QStringLiteral("unresolved"); + } +} + +QString supportText(const Esp32P4SvProfileSupport support) { + return QString::fromLatin1( + esp32p4_sv_profile_support_name(support).data(), + static_cast(esp32p4_sv_profile_support_name(support).size())); +} +} // namespace + +SclProfileModel::SclProfileModel(QObject* parent) : QAbstractListModel(parent) {} + +int SclProfileModel::rowCount(const QModelIndex& parent) const { + if (parent.isValid()) return 0; + return static_cast(rows_.size()); +} + +QVariant SclProfileModel::data(const QModelIndex& index, const int role) const { + if (!index.isValid() || index.row() < 0 || index.row() >= rowCount()) return {}; + const auto& row = rows_[static_cast(index.row())]; + + switch (role) { + case IedRole: return row.ied; + case ControlRole: return row.control; + case ControlBlockReferenceRole: return row.controlBlockReference; + case CompatibilityClassRole: return row.compatibilityClass; + case DeviceSupportRole: return row.deviceSupport; + case WarningsRole: return row.warnings; + case ErrorsRole: return row.errors; + default: break; + } + + if (!row.profile.has_value()) return {}; + const auto& p = *row.profile; + switch (role) { + case SvIdRole: return qstring(p.sv_id); + case DestinationMacRole: return macText(p.destination_mac); + case AppIdRole: return static_cast(p.app_id); + case PublisherRateRole: + return p.publisher_rate_hz.has_value() + ? QVariant::fromValue(static_cast(*p.publisher_rate_hz)) + : QVariant{}; + case PayloadBytesRole: return static_cast(p.payload_size_bytes); + case CounterPolicyRole: return counterPolicyText(p.sample_counter_policy); + case CounterModulusRole: + return p.sample_counter_modulus.has_value() + ? QVariant::fromValue(static_cast(*p.sample_counter_modulus)) + : QVariant{}; + default: return {}; + } +} + +QHash SclProfileModel::roleNames() const { + return { + {IedRole, "ied"}, + {ControlRole, "control"}, + {ControlBlockReferenceRole, "controlBlockReference"}, + {SvIdRole, "svId"}, + {CompatibilityClassRole, "compatibilityClass"}, + {DeviceSupportRole, "deviceSupport"}, + {DestinationMacRole, "destinationMac"}, + {AppIdRole, "appId"}, + {PublisherRateRole, "publisherRate"}, + {PayloadBytesRole, "payloadBytes"}, + {CounterPolicyRole, "counterPolicy"}, + {CounterModulusRole, "counterModulus"}, + {WarningsRole, "warnings"}, + {ErrorsRole, "errors"}, + }; +} + +QString SclProfileModel::sourceName() const { + return document_.has_value() ? qstring(document_->source_name) : QString{}; +} + +QString SclProfileModel::documentStatus() const { + if (!document_.has_value()) return QStringLiteral("No engineering file loaded"); + return QStringLiteral("%1 resolved SV stream%2") + .arg(static_cast(rows_.size())) + .arg(rows_.size() == 1U ? QString{} : QStringLiteral("s")); +} + +QStringList SclProfileModel::documentWarnings() const { + if (!document_.has_value()) return {}; + QStringList out = qstrings(document_->warnings); + for (const auto& conflict : document_->conflicts) { + out.push_back(QStringLiteral("Conflict: %1").arg(qstring(conflict.description))); + } + return out; +} + +QString SclProfileModel::fatalError() const { return fatalError_; } +int SclProfileModel::selectedIndex() const noexcept { return selectedIndex_; } +bool SclProfileModel::hasProfiles() const noexcept { return !rows_.empty(); } + +QVariantMap SclProfileModel::selectedProfile() const { + if (selectedIndex_ < 0 || selectedIndex_ >= rowCount()) return {}; + const auto& row = rows_[static_cast(selectedIndex_)]; + QVariantMap out; + out.insert(QStringLiteral("compatibilityClass"), row.compatibilityClass); + out.insert(QStringLiteral("deviceSupport"), row.deviceSupport); + out.insert(QStringLiteral("warnings"), row.warnings); + out.insert(QStringLiteral("errors"), row.errors); + if (row.profile.has_value()) { + const auto profileMap = profileToVariantMap(*row.profile); + for (auto it = profileMap.cbegin(); it != profileMap.cend(); ++it) { + out.insert(it.key(), it.value()); + } + } + return out; +} + +bool SclProfileModel::loadFile(const QUrl& fileUrl) { + const auto localPath = fileUrl.toLocalFile(); + if (localPath.isEmpty()) { + fatalError_ = QStringLiteral("Choose a local SCL/CID/SCD/IID file."); + emit fatalErrorChanged(); + return false; + } + + try { + auto loaded = ar::iec61850::scl::SclParser{}.load( + std::filesystem::path{localPath.toStdWString()}); + beginResetModel(); + document_ = std::move(loaded); + confirmedCounterModulus_.reset(); + rows_.clear(); + selectedIndex_ = -1; + fatalError_.clear(); + endResetModel(); + rebuildRows(); + emit fatalErrorChanged(); + emit sourceChanged(); + return true; + } catch (const std::exception& error) { + clear(); + fatalError_ = QString::fromUtf8(error.what()); + emit fatalErrorChanged(); + return false; + } +} + +void SclProfileModel::clear() { + beginResetModel(); + document_.reset(); + confirmedCounterModulus_.reset(); + rows_.clear(); + selectedIndex_ = -1; + fatalError_.clear(); + endResetModel(); + emit selectedIndexChanged(); + emit selectedProfileChanged(); + emit fatalErrorChanged(); + emit sourceChanged(); +} + +void SclProfileModel::selectStream(const int row) { + const int normalized = row >= 0 && row < rowCount() ? row : -1; + if (selectedIndex_ == normalized) return; + selectedIndex_ = normalized; + emit selectedIndexChanged(); + emit selectedProfileChanged(); +} + +bool SclProfileModel::confirmCounterModulus(const int modulus) { + if (!document_.has_value() || modulus <= 0 || modulus > 65535) return false; + confirmedCounterModulus_ = static_cast(modulus); + rebuildRows(); + return true; +} + +void SclProfileModel::clearCounterConfirmation() { + if (!confirmedCounterModulus_.has_value()) return; + confirmedCounterModulus_.reset(); + rebuildRows(); +} + +void SclProfileModel::rebuildRows() { + if (!document_.has_value()) return; + + const int previousSelection = selectedIndex_; + beginResetModel(); + rows_.clear(); + rows_.reserve(document_->sampled_values_streams.size()); + + for (const auto& stream : document_->sampled_values_streams) { + SvPublisherProfileCompileContext context; + context.sample_counter_modulus = confirmedCounterModulus_; + auto compiled = SvPublisherProfileCompiler::compile(stream, context); + + Row row; + row.ied = qstring(stream.ied_name); + row.control = qstring(stream.control_name); + row.controlBlockReference = qstring(stream.control_block_reference); + row.errors = qstrings(compiled.errors); + row.warnings = qstrings(compiled.warnings); + + if (!compiled.ok()) { + row.compatibilityClass = QStringLiteral("C"); + row.deviceSupport = QStringLiteral("blocked"); + } else { + row.profile = std::move(compiled.profile); + const auto& profile = *row.profile; + row.compatibilityClass = + profile.sample_counter_policy == SvSampleCounterPolicy::explicit_modulus + ? QStringLiteral("A") + : QStringLiteral("B"); + row.deviceSupport = supportText(classify_esp32p4_sv_profile(profile)); + } + rows_.push_back(std::move(row)); + } + + selectedIndex_ = rows_.empty() + ? -1 + : std::clamp(previousSelection, 0, static_cast(rows_.size()) - 1); + if (selectedIndex_ < 0 && !rows_.empty()) selectedIndex_ = 0; + endResetModel(); + + emit selectedIndexChanged(); + emit selectedProfileChanged(); + emit sourceChanged(); +} + +QVariantMap SclProfileModel::profileToVariantMap(const SvPublisherProfile& p) const { + QVariantMap map; + map.insert(QStringLiteral("svId"), qstring(p.sv_id)); + map.insert(QStringLiteral("controlBlockReference"), qstring(p.control_block_reference)); + map.insert(QStringLiteral("dataSetReference"), qstring(p.data_set_reference)); + map.insert(QStringLiteral("destinationMac"), macText(p.destination_mac)); + map.insert(QStringLiteral("appId"), static_cast(p.app_id)); + map.insert(QStringLiteral("appIdHex"), + QStringLiteral("0x%1").arg(p.app_id, 4, 16, QLatin1Char('0')).toUpper()); + map.insert(QStringLiteral("vlanPresent"), p.vlan_present); + map.insert(QStringLiteral("vlanId"), static_cast(p.vlan_id)); + map.insert(QStringLiteral("vlanPriority"), static_cast(p.vlan_priority)); + map.insert(QStringLiteral("confRev"), static_cast(p.configuration_revision)); + map.insert(QStringLiteral("sampleRate"), static_cast(p.sample_rate_value)); + map.insert(QStringLiteral("sampleMode"), sampleModeText(p.sample_mode)); + map.insert(QStringLiteral("publisherRate"), + p.publisher_rate_hz.has_value() + ? QVariant::fromValue(static_cast(*p.publisher_rate_hz)) + : QVariant{}); + map.insert(QStringLiteral("counterPolicy"), counterPolicyText(p.sample_counter_policy)); + map.insert(QStringLiteral("counterModulus"), + p.sample_counter_modulus.has_value() + ? QVariant::fromValue(static_cast(*p.sample_counter_modulus)) + : QVariant{}); + map.insert(QStringLiteral("payloadBytes"), static_cast(p.payload_size_bytes)); + map.insert(QStringLiteral("nofASDU"), static_cast(p.no_asdu)); + map.insert(QStringLiteral("channelLeafCount"), static_cast(p.channels.size())); + map.insert(QStringLiteral("includeDataSet"), p.asdu_options.data_set); + map.insert(QStringLiteral("includeSampleRate"), p.asdu_options.sample_rate); + map.insert(QStringLiteral("sampleSynchronizedField"), p.asdu_options.sample_synchronized); + return map; +} diff --git a/apps/arstack_studio/src/SclProfileModel.hpp b/apps/arstack_studio/src/SclProfileModel.hpp new file mode 100644 index 000000000..a01a7837f --- /dev/null +++ b/apps/arstack_studio/src/SclProfileModel.hpp @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/sampled_values/publisher_profile.hpp" +#include "ariec61850/scl/model.hpp" + +#include +#include +#include + +#include +#include +#include + +class SclProfileModel : public QAbstractListModel { + Q_OBJECT + Q_PROPERTY(QString sourceName READ sourceName NOTIFY sourceChanged) + Q_PROPERTY(QString documentStatus READ documentStatus NOTIFY sourceChanged) + Q_PROPERTY(QStringList documentWarnings READ documentWarnings NOTIFY sourceChanged) + Q_PROPERTY(QString fatalError READ fatalError NOTIFY fatalErrorChanged) + Q_PROPERTY(int selectedIndex READ selectedIndex WRITE selectStream NOTIFY selectedIndexChanged) + Q_PROPERTY(QVariantMap selectedProfile READ selectedProfile NOTIFY selectedProfileChanged) + Q_PROPERTY(bool hasProfiles READ hasProfiles NOTIFY sourceChanged) + +public: + enum Roles { + IedRole = Qt::UserRole + 1, + ControlRole, + ControlBlockReferenceRole, + SvIdRole, + CompatibilityClassRole, + DeviceSupportRole, + DestinationMacRole, + AppIdRole, + PublisherRateRole, + PayloadBytesRole, + CounterPolicyRole, + CounterModulusRole, + WarningsRole, + ErrorsRole, + }; + + explicit SclProfileModel(QObject* parent = nullptr); + + [[nodiscard]] int rowCount(const QModelIndex& parent = QModelIndex()) const override; + [[nodiscard]] QVariant data(const QModelIndex& index, int role) const override; + [[nodiscard]] QHash roleNames() const override; + + [[nodiscard]] QString sourceName() const; + [[nodiscard]] QString documentStatus() const; + [[nodiscard]] QStringList documentWarnings() const; + [[nodiscard]] QString fatalError() const; + [[nodiscard]] int selectedIndex() const noexcept; + [[nodiscard]] QVariantMap selectedProfile() const; + [[nodiscard]] bool hasProfiles() const noexcept; + + Q_INVOKABLE bool loadFile(const QUrl& fileUrl); + Q_INVOKABLE void clear(); + Q_INVOKABLE void selectStream(int row); + Q_INVOKABLE bool confirmCounterModulus(int modulus); + Q_INVOKABLE void clearCounterConfirmation(); + +signals: + void sourceChanged(); + void fatalErrorChanged(); + void selectedIndexChanged(); + void selectedProfileChanged(); + +private: + struct Row final { + QString ied; + QString control; + QString controlBlockReference; + QString compatibilityClass; + QString deviceSupport; + QStringList warnings; + QStringList errors; + std::optional profile; + }; + + void rebuildRows(); + [[nodiscard]] QVariantMap profileToVariantMap( + const ar::iec61850::sampled_values::SvPublisherProfile& profile) const; + + std::optional document_; + std::optional confirmedCounterModulus_; + std::vector rows_; + int selectedIndex_{-1}; + QString fatalError_; +}; \ No newline at end of file diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp new file mode 100644 index 000000000..d79ea73fd --- /dev/null +++ b/apps/arstack_studio/src/main.cpp @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "DeviceController.hpp" +#include "SclProfileModel.hpp" + +#include +#include +#include + +int main(int argc, char* argv[]) { + QGuiApplication app(argc, argv); + QCoreApplication::setOrganizationName(QStringLiteral("ARStack61850")); + QCoreApplication::setApplicationName(QStringLiteral("ARStack Studio")); + QCoreApplication::setApplicationVersion(QStringLiteral("0.1.0")); + + qmlRegisterType("ARStack.Studio", 1, 0, "SclProfileModel"); + qmlRegisterType("ARStack.Studio", 1, 0, "DeviceController"); + + QQmlApplicationEngine engine; + QObject::connect( + &engine, + &QQmlApplicationEngine::objectCreationFailed, + &app, + [] { QCoreApplication::exit(-1); }, + Qt::QueuedConnection); + engine.loadFromModule("ARStack.Studio", "Main"); + + return app.exec(); +} diff --git a/include/ariec61850/sampled_values/esp32p4_profile_support.hpp b/include/ariec61850/sampled_values/esp32p4_profile_support.hpp new file mode 100644 index 000000000..75be57a27 --- /dev/null +++ b/include/ariec61850/sampled_values/esp32p4_profile_support.hpp @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/sampled_values/publisher_profile.hpp" + +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +enum class Esp32P4SvProfileSupport { + ready, + needs_counter_confirmation, + unsupported_layout, +}; + +[[nodiscard]] inline std::string_view esp32p4_sv_profile_support_name( + const Esp32P4SvProfileSupport support) noexcept { + switch (support) { + case Esp32P4SvProfileSupport::ready: + return "ready"; + case Esp32P4SvProfileSupport::needs_counter_confirmation: + return "needs-counter-confirmation"; + case Esp32P4SvProfileSupport::unsupported_layout: + return "unsupported-layout"; + } + return "unsupported-layout"; +} + +namespace detail { +[[nodiscard]] inline std::string sv_support_lower_copy(const std::string_view text) { + std::string result; + result.reserve(text.size()); + for (const char ch : text) { + result.push_back(static_cast( + std::tolower(static_cast(ch)))); + } + return result; +} + +[[nodiscard]] inline bool esp32p4_4i4v_layout_matches( + const SvPublisherProfile& profile) { + if (profile.no_asdu != 1U || profile.payload_size_bytes != 64U || + profile.channels.size() != 16U || !profile.publisher_rate_hz.has_value() || + *profile.publisher_rate_hz == 0U || *profile.publisher_rate_hz > 65535U) { + return false; + } + if (profile.asdu_options.refresh_time || profile.asdu_options.security || + profile.asdu_options.synch_source_id) { + return false; + } + for (std::size_t i = 0U; i < profile.channels.size(); ++i) { + const auto& channel = profile.channels[i]; + if (channel.wire_width_bytes != 4U) return false; + if ((i % 2U) == 0U) { + if (channel.is_quality || sv_support_lower_copy(channel.basic_type) != "int32") { + return false; + } + } else if (!channel.is_quality) { + return false; + } + } + return true; +} +} // namespace detail + +// Centralized deployment boundary for the currently proven ESP32-P4 runtime. +// Parsing may accept broader IEC 61850 SV structures; this classifier is only +// the embedded-device deployment gate and deliberately fails closed. +[[nodiscard]] inline Esp32P4SvProfileSupport classify_esp32p4_sv_profile( + const SvPublisherProfile& profile) { + if (!detail::esp32p4_4i4v_layout_matches(profile)) { + return Esp32P4SvProfileSupport::unsupported_layout; + } + if (profile.sample_counter_policy != SvSampleCounterPolicy::explicit_modulus || + !profile.sample_counter_modulus.has_value() || + *profile.sample_counter_modulus == 0U) { + return Esp32P4SvProfileSupport::needs_counter_confirmation; + } + return Esp32P4SvProfileSupport::ready; +} + +} // namespace ar::iec61850::sampled_values diff --git a/tools/smv_profile_inspect.cpp b/tools/smv_profile_inspect.cpp index e739cd23e..529f63fa7 100644 --- a/tools/smv_profile_inspect.cpp +++ b/tools/smv_profile_inspect.cpp @@ -1,11 +1,10 @@ // SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/sampled_values/esp32p4_profile_support.hpp" #include "ariec61850/sampled_values/publisher_profile.hpp" #include "ariec61850/scl/parser.hpp" -#include #include -#include #include #include #include @@ -14,19 +13,19 @@ #include #include #include -#include namespace { - using ar::iec61850::sampled_values::SvPublisherProfile; using ar::iec61850::sampled_values::SvPublisherProfileCompileContext; using ar::iec61850::sampled_values::SvPublisherProfileCompiler; using ar::iec61850::sampled_values::SvSampleCounterPolicy; using ar::iec61850::sampled_values::SvSampleMode; +using ar::iec61850::sampled_values::classify_esp32p4_sv_profile; +using ar::iec61850::sampled_values::esp32p4_sv_profile_support_name; using ar::iec61850::scl::SclDocument; using ar::iec61850::scl::SclEdition; -std::string json_escape(std::string_view text) { +std::string json_escape(const std::string_view text) { std::ostringstream out; for (const unsigned char ch : text) { switch (ch) { @@ -49,7 +48,7 @@ std::string json_escape(std::string_view text) { return out.str(); } -void quoted(std::ostream& out, std::string_view value) { +void quoted(std::ostream& out, const std::string_view value) { out << '"' << json_escape(value) << '"'; } @@ -93,44 +92,13 @@ std::string counter_policy_name(const SvSampleCounterPolicy policy) { std::string mac_text(const std::array& mac) { std::ostringstream out; out << std::uppercase << std::hex << std::setfill('0'); - for (std::size_t i = 0; i < mac.size(); ++i) { + for (std::size_t i = 0U; i < mac.size(); ++i) { if (i != 0U) out << ':'; out << std::setw(2) << static_cast(mac[i]); } return out.str(); } -std::string lower_copy(std::string text) { - std::transform(text.begin(), text.end(), text.begin(), [](const unsigned char ch) { - return static_cast(std::tolower(ch)); - }); - return text; -} - -bool current_device_layout_supported(const SvPublisherProfile& profile) { - if (profile.no_asdu != 1U || profile.payload_size_bytes != 64U || - profile.channels.size() != 16U || !profile.publisher_rate_hz.has_value() || - *profile.publisher_rate_hz == 0U || *profile.publisher_rate_hz > 65535U || - profile.sample_counter_policy != SvSampleCounterPolicy::explicit_modulus || - !profile.sample_counter_modulus.has_value()) { - return false; - } - if (profile.asdu_options.refresh_time || profile.asdu_options.security || - profile.asdu_options.synch_source_id) { - return false; - } - for (std::size_t i = 0U; i < profile.channels.size(); ++i) { - const auto& channel = profile.channels[i]; - if (channel.wire_width_bytes != 4U) return false; - if ((i % 2U) == 0U) { - if (channel.is_quality || lower_copy(channel.basic_type) != "int32") return false; - } else if (!channel.is_quality) { - return false; - } - } - return true; -} - void emit_profile(std::ostream& out, const SvPublisherProfile& p) { out << '{'; out << "\"schemaVersion\":" << p.schema_version << ','; @@ -163,7 +131,7 @@ void emit_profile(std::ostream& out, const SvPublisherProfile& p) { out << "\"security\":" << (p.asdu_options.security ? "true" : "false") << ','; out << "\"synchSourceId\":" << (p.asdu_options.synch_source_id ? "true" : "false") << "},"; out << "\"channels\":["; - for (std::size_t i = 0; i < p.channels.size(); ++i) { + for (std::size_t i = 0U; i < p.channels.size(); ++i) { if (i != 0U) out << ','; const auto& channel = p.channels[i]; out << '{'; @@ -190,7 +158,7 @@ void emit_document( out << "\"headerID\":"; quoted(out, document.header_id); out << ','; out << "\"warnings\":"; string_array(out, document.warnings); out << ','; out << "\"conflicts\":["; - for (std::size_t i = 0; i < document.conflicts.size(); ++i) { + for (std::size_t i = 0U; i < document.conflicts.size(); ++i) { if (i != 0U) out << ','; const auto& conflict = document.conflicts[i]; out << '{'; @@ -201,21 +169,21 @@ void emit_document( } out << "],\"streams\":["; - for (std::size_t index = 0; index < document.sampled_values_streams.size(); ++index) { + for (std::size_t index = 0U; index < document.sampled_values_streams.size(); ++index) { if (index != 0U) out << ','; const auto& stream = document.sampled_values_streams[index]; SvPublisherProfileCompileContext context; context.sample_counter_modulus = counter_modulus; const auto compiled = SvPublisherProfileCompiler::compile(stream, context); - std::string compatibility = "C"; - std::string device_support = "blocked"; + std::string compatibility{"C"}; + std::string device_support{"blocked"}; if (compiled.ok()) { const auto& profile = *compiled.profile; - compatibility = profile.sample_counter_policy == SvSampleCounterPolicy::explicit_modulus ? "A" : "B"; - if (current_device_layout_supported(profile)) device_support = "ready"; - else if (compatibility == "B") device_support = "needs-counter-confirmation"; - else device_support = "unsupported-layout"; + compatibility = profile.sample_counter_policy == SvSampleCounterPolicy::explicit_modulus + ? "A" : "B"; + device_support = std::string{ + esp32p4_sv_profile_support_name(classify_esp32p4_sv_profile(profile))}; } out << '{'; @@ -234,7 +202,7 @@ void emit_document( out << "]}"; } -std::optional parse_u16(std::string_view text) { +std::optional parse_u16(const std::string_view text) { try { const auto value = std::stoul(std::string{text}); if (value == 0U || value > 65535U) return std::nullopt; @@ -243,7 +211,6 @@ std::optional parse_u16(std::string_view text) { return std::nullopt; } } - } // namespace int main(int argc, char** argv) { @@ -267,7 +234,8 @@ int main(int argc, char** argv) { } try { - const auto document = ar::iec61850::scl::SclParser{}.load(std::filesystem::path{argv[1]}); + const auto document = ar::iec61850::scl::SclParser{}.load( + std::filesystem::path{argv[1]}); emit_document(std::cout, document, counter_modulus); std::cout << '\n'; return 0;